pico-epaper/microbmp.py

394 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""A small Python module for BMP image processing.
- Author: Quan Lin
- Adapted by: Dejvino
- Adapted from: https://github.com/jacklinquan/micropython-microbmp
- License: MIT
"""
from struct import pack, unpack
# Project Version
__version__ = "0.1.0"
__all__ = ["MicroBMP"]
class MicroBMP(object):
def __init__(self, width=None, height=None, depth=None, palette=None, header_callback=None, data_callback=None):
# BMP Header
self.BMP_id = b"BM"
self.BMP_size = None
self.BMP_reserved1 = b"\x00\x00"
self.BMP_reserved2 = b"\x00\x00"
self.BMP_offset = None
# DIB Header
self.DIB_len = 40
self.DIB_w = width
self.DIB_h = height
self.DIB_planes_num = 1
self.DIB_depth = depth
self.DIB_comp = 0
self.DIB_raw_size = None
self.DIB_hres = 2835 # 72 DPI * 39.3701 inches/metre.
self.DIB_vres = 2835
self.DIB_num_in_plt = None
self.DIB_extra = None
self.palette = palette
self.parray = None # Pixel array
self.ppb = None # Number of pixels per byte for depth <= 8.
self.pmask = None # Pixel Mask
self.row_size = None
self.padded_row_size = None
self.header_callback = header_callback
self.data_callback = data_callback
self.initialised = False
self._init()
def __getitem__(self, key):
assert self.initialised, "Image not initialised!"
assert key[0] < self.DIB_w and key[1] < self.DIB_h, "Out of image boundary!"
# Pixels are arranged in HLSB format with high bits being the leftmost
pindex = key[1] * self.DIB_w + key[0] # Pixel index
if self.DIB_depth <= 8:
return self._extract_from_bytes(self.parray, pindex)
else:
pindex *= 3
if (len(key) > 2) and (key[2] in (0, 1, 2)):
return self.parray[pindex + key[2]]
else:
return (
self.parray[pindex],
self.parray[pindex + 1],
self.parray[pindex + 2],
)
def __setitem__(self, key, value):
assert self.initialised, "Image not initialised!"
assert key[0] < self.DIB_w and key[1] < self.DIB_h, "Out of image boundary!"
# Pixels are arranged in HLSB format with high bits being the leftmost
pindex = key[1] * self.DIB_w + key[0] # Pixel index
if self.DIB_depth <= 8:
self._fill_in_bytes(self.parray, pindex, value)
else:
pindex *= 3
if (len(key) > 2) and (key[2] in (0, 1, 2)):
self.parray[pindex + key[2]] = value
else:
self.parray[pindex] = value[0]
self.parray[pindex + 1] = value[1]
self.parray[pindex + 2] = value[2]
def __str__(self):
if not self.initialised:
return repr(self)
return "BMP image, {}, {}-bit, {}x{} pixels, {} bytes".format(
"indexed" if self.DIB_depth <= 8 else "RGB",
self.DIB_depth,
self.DIB_w,
self.DIB_h,
self.BMP_size,
)
def _init(self):
if None in (self.DIB_w, self.DIB_h, self.DIB_depth):
self.initialised = False
return self.initialised
assert self.BMP_id == b"BM", "BMP id ({}) must be b'BM'!".format(self.BMP_id)
assert (
len(self.BMP_reserved1) == 2 and len(self.BMP_reserved2) == 2
), "Length of BMP reserved fields ({}+{}) must be 2+2!".format(
len(self.BMP_reserved1), len(self.BMP_reserved2)
)
assert self.DIB_planes_num == 1, "DIB planes number ({}) must be 1!".format(
self.DIB_planes_num
)
assert self.DIB_depth in (
1,
2,
4,
8,
24,
), "Colour depth ({}) must be in (1, 2, 4, 8, 24)!".format(self.DIB_depth)
assert (
self.DIB_comp == 0
or (self.DIB_depth == 8 and self.DIB_comp == 1)
or (self.DIB_depth == 4 and self.DIB_comp == 2)
), "Colour depth + compression ({}+{}) must be X+0/8+1/4+2!".format(
self.DIB_depth, self.DIB_comp
)
if self.DIB_depth <= 8:
self.ppb = 8 // self.DIB_depth
self.pmask = 0xFF >> (8 - self.DIB_depth)
if self.palette is None:
# Default palette is black and white or full size grey scale.
self.DIB_num_in_plt = 2 ** self.DIB_depth
self.palette = [None for i in range(self.DIB_num_in_plt)]
for i in range(self.DIB_num_in_plt):
# Assignment that suits all: 1/2/4/8-bit colour depth.
s = 255 * i // (self.DIB_num_in_plt - 1)
self.palette[i] = bytearray([s, s, s])
else:
self.DIB_num_in_plt = len(self.palette)
else:
self.ppb = None
self.pmask = None
self.DIB_num_in_plt = 0
self.palette = None
#if self.parray is None:
# if self.DIB_depth <= 8:
# div, mod = divmod(self.DIB_w * self.DIB_h, self.ppb)
# self.parray = bytearray(div + (1 if mod else 0))
# else:
# self.parray = bytearray(self.DIB_w * self.DIB_h * 3)
plt_size = self.DIB_num_in_plt * 4
self.BMP_offset = 14 + self.DIB_len + plt_size
self.row_size = self._size_from_width(self.DIB_w)
self.padded_row_size = self._padded_size_from_size(self.row_size)
if self.DIB_comp == 0:
self.DIB_raw_size = self.padded_row_size * self.DIB_h
self.BMP_size = self.BMP_offset + self.DIB_raw_size
self.initialised = True
return self.initialised
def _size_from_width(self, width):
return (width * self.DIB_depth + 7) // 8
def _padded_size_from_size(self, size):
return (size + 3) // 4 * 4
def _extract_from_bytes(self, data, index):
# One formula that suits all: 1/2/4/8-bit colour depth.
byte_index, pos_in_byte = divmod(index, self.ppb)
shift = 8 - self.DIB_depth * (pos_in_byte + 1)
return (data[byte_index] >> shift) & self.pmask
def _fill_in_bytes(self, data, index, value):
# One formula that suits all: 1/2/4/8-bit colour depth.
byte_index, pos_in_byte = divmod(index, self.ppb)
shift = 8 - self.DIB_depth * (pos_in_byte + 1)
value &= self.pmask
data[byte_index] = (data[byte_index] & ~(self.pmask << shift)) + (
value << shift
)
def _decode_rle(self, bf_io):
# Only bottom-up bitmap can be compressed.
x, y = 0, self.DIB_h - 1
while True:
data = bf_io.read(2)
if data[0] == 0:
if data[1] == 0:
x, y = 0, y - 1
elif data[1] == 1:
return
elif data[1] == 2:
data = bf_io.read(2)
x, y = x + data[0], y - data[1]
else:
num_of_pixels = data[1]
num_to_read = (self._size_from_width(num_of_pixels) + 1) // 2 * 2
data = bf_io.read(num_to_read)
for i in range(num_of_pixels):
self[x, y] = self._extract_from_bytes(data, i)
x += 1
else:
b = bytes([data[1]])
for i in range(data[0]):
self[x, y] = self._extract_from_bytes(b, i % self.ppb)
x += 1
def read_io(self, bf_io):
#print("BMP reading file")
# BMP Header
data = bf_io.read(14)
self.BMP_id = data[0:2]
self.BMP_size = unpack("<I", data[2:6])[0]
self.BMP_reserved1 = data[6:8]
self.BMP_reserved2 = data[8:10]
self.BMP_offset = unpack("<I", data[10:14])[0]
# DIB Header
data = bf_io.read(4)
self.DIB_len = unpack("<I", data[0:4])[0]
data = bf_io.read(self.DIB_len - 4)
(
self.DIB_w,
self.DIB_h,
self.DIB_planes_num,
self.DIB_depth,
self.DIB_comp,
self.DIB_raw_size,
self.DIB_hres,
self.DIB_vres,
) = unpack("<iiHHIIii", data[0:28])
DIB_plt_num_info = unpack("<I", data[28:32])[0]
DIB_plt_important_num_info = unpack("<I", data[32:36])[0]
if self.DIB_len > 40:
self.DIB_extra = data[36:]
# Palette
if self.DIB_depth <= 8:
if DIB_plt_num_info == 0:
self.DIB_num_in_plt = 2 ** self.DIB_depth
else:
self.DIB_num_in_plt = DIB_plt_num_info
self.palette = [None for i in range(self.DIB_num_in_plt)]
for i in range(self.DIB_num_in_plt):
data = bf_io.read(4)
colour = bytearray([data[2], data[1], data[0]])
self.palette[i] = colour
# In case self.DIB_h < 0 for top-down format.
if self.DIB_h < 0:
self.DIB_h = -self.DIB_h
is_top_down = True
else:
is_top_down = False
header = [
self.DIB_w,
self.DIB_h,
self.DIB_planes_num,
self.DIB_depth,
self.DIB_comp,
self.DIB_raw_size,
self.DIB_hres,
self.DIB_vres,
self.palette,
]
#print("BMP metadata: ", str(header))
if self.header_callback is not None:
self.header_callback(header)
self.parray = None
assert self._init(), "Failed to initialize the image!"
# Pixels
#print("BMP reading data")
if self.DIB_comp == 0:
# BI_RGB
for h in range(self.DIB_h):
y = h if is_top_down else self.DIB_h - h - 1
data = bf_io.read(self.padded_row_size)
#pixels_row = []
for x in range(self.DIB_w):
if self.DIB_depth <= 8:
##self[x, y] = self._extract_from_bytes(data, x)
byte_index, pos_in_byte = divmod(x, self.ppb)
shift = 8 - self.DIB_depth * (pos_in_byte + 1)
pixel_data = (data[byte_index] >> shift) & self.pmask
#pixel_data = self._extract_from_bytes(data, x)
pixel = pixel_data if self.palette is None else self.palette[pixel_data]
##pixels_row.append(pixel)
self.data_callback(x, y, pixel)
else:
v = x * 3
# BMP colour is in BGR order.
self[x, y] = (data[v + 2], data[v + 1], data[v])
#self.data_callback(0, y, pixels_row)
else:
# BI_RLE8 or BI_RLE4
self._decode_rle(bf_io)
#print("BMP done")
return self
def write_io(self, bf_io, force_40B_DIB=False):
if force_40B_DIB:
self.DIB_len = 40
self.DIB_extra = None
# Only uncompressed image is supported to write.
self.DIB_comp = 0
assert self._init(), "Failed to initialize the image!"
# BMP Header
bf_io.write(self.BMP_id)
bf_io.write(pack("<I", self.BMP_size))
bf_io.write(self.BMP_reserved1)
bf_io.write(self.BMP_reserved2)
bf_io.write(pack("<I", self.BMP_offset))
# DIB Header
bf_io.write(
pack(
"<IiiHHIIiiII",
self.DIB_len,
self.DIB_w,
self.DIB_h,
self.DIB_planes_num,
self.DIB_depth,
self.DIB_comp,
self.DIB_raw_size,
self.DIB_hres,
self.DIB_vres,
self.DIB_num_in_plt,
self.DIB_num_in_plt,
)
)
if self.DIB_len > 40:
bf_io.write(self.DIB_extra)
# Palette
if self.DIB_depth <= 8:
for colour in self.palette:
bf_io.write(bytes([colour[2], colour[1], colour[0], 0]))
# Pixels
for h in range(self.DIB_h):
# BMP last row comes first.
y = self.DIB_h - h - 1
if self.DIB_depth <= 8:
d = 0
for x in range(self.DIB_w):
self[x, y] %= self.DIB_num_in_plt
# One formula that suits all: 1/2/4/8-bit colour depth.
d = (d << (self.DIB_depth % 8)) + self[x, y]
if x % self.ppb == self.ppb - 1:
# Got a whole byte.
bf_io.write(bytes([d]))
d = 0
if x % self.ppb != self.ppb - 1:
# Last byte if width does not fit in whole bytes.
d <<= (
8
- self.DIB_depth
- (x % self.ppb) * (2 ** (self.DIB_depth - 1))
)
bf_io.write(bytes([d]))
d = 0
else:
for x in range(self.DIB_w):
r, g, b = self[x, y]
bf_io.write(bytes([b, g, r]))
# Pad row to multiple of 4 bytes with 0x00.
bf_io.write(b"\x00" * (self.padded_row_size - self.row_size))
num_of_bytes = bf_io.tell()
return num_of_bytes
def load(self, file_path):
with open(file_path, "rb") as file:
self.read_io(file)
return self
def save(self, file_path, force_40B_DIB=False):
with open(file_path, "wb") as file:
num_of_bytes = self.write_io(file, force_40B_DIB)
return num_of_bytes