435 lines
14 KiB
Python
435 lines
14 KiB
Python
# MIT License
|
|
#
|
|
# Copyright (c) 2021 Christopher Wells
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all
|
|
# copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
import math
|
|
|
|
|
|
class BMPFileReader:
|
|
"""
|
|
An object for reading a BMP image file.
|
|
"""
|
|
|
|
def __init__(self, file_handle):
|
|
"""
|
|
Creates a BMPFileReader from the given file handle.
|
|
|
|
The file handle must have been opened in read binary mode ("rb").
|
|
|
|
:param file_handle: The file handle of the BMP image to read.
|
|
:type file_handle: io.TextIOWrapper
|
|
"""
|
|
self.file_handle = file_handle
|
|
self.__bmp_header = None
|
|
self.__dib_header = None
|
|
|
|
def read_bmp_file_header(self):
|
|
"""
|
|
Returns the BMP file header of the image.
|
|
|
|
:return: BMP file header of the image.
|
|
:rtype: BMPHeader
|
|
"""
|
|
if self.__bmp_header is not None:
|
|
return self.__bmp_header
|
|
|
|
self.file_handle.seek(0)
|
|
|
|
header_bytes = self.file_handle.read(14)
|
|
|
|
bmp_header = BMPHeader.from_bytes(header_bytes)
|
|
self.__bmp_header = bmp_header
|
|
|
|
return bmp_header
|
|
|
|
def read_dib_header(self):
|
|
"""
|
|
Returns the DIB header of the BMP file.
|
|
|
|
:return: DIB header of the image.
|
|
:rtype: DIBHeader
|
|
"""
|
|
if self.__dib_header is not None:
|
|
return self.__dib_header
|
|
|
|
self.file_handle.seek(14)
|
|
|
|
dib_header = DIBHeader.from_positioned_file_handler(self.file_handle)
|
|
self.__dib_header = dib_header
|
|
|
|
return dib_header
|
|
|
|
def get_width(self):
|
|
"""
|
|
Returns the width of the image (in pixels).
|
|
|
|
:return: Width of the image in pixels.
|
|
:rtype: int
|
|
"""
|
|
return self.read_dib_header().width
|
|
|
|
def get_height(self):
|
|
"""
|
|
Returns the height of the image (in pixels).
|
|
|
|
:return: Height of the image in pixels.
|
|
:rtype: int
|
|
"""
|
|
return self.read_dib_header().height
|
|
|
|
def get_row(self, row):
|
|
"""
|
|
Reads in the pixels of the specified row (zero-indexed).
|
|
|
|
:param row: The index of the row to read.
|
|
:type row: int
|
|
:return: The colors of the pixels in the specified row.
|
|
:rtype: List[Color]
|
|
"""
|
|
PIXEL_SIZE_BYTES = 3
|
|
|
|
# Check the file info to make sure we support it
|
|
bits_per_pixel = self.read_dib_header().bits_per_pixel
|
|
if bits_per_pixel != 24:
|
|
raise ValueError(
|
|
"This parser does not currently support BMP files with {} bits per pixel. Currently only 24-bit color values are supported.".format(bits_per_pixel)
|
|
)
|
|
|
|
compression_type = self.read_dib_header().compression_type
|
|
if compression_type != CompressionType.BI_RGB:
|
|
raise ValueError(
|
|
"This parser does not currently support compressed BMP files."
|
|
)
|
|
|
|
# Prepare to start parsing the row
|
|
height = self.get_height()
|
|
assert row < height
|
|
|
|
row_index = (height - row) - 1
|
|
|
|
# Rows are padded out to 4 byte alignment
|
|
row_size = int(math.ceil((PIXEL_SIZE_BYTES * self.get_width()) / 4.0) * 4)
|
|
|
|
row_start = (
|
|
self.read_bmp_file_header().image_start_offset + row_size * row_index
|
|
)
|
|
|
|
# Read in the row information from the file
|
|
self.file_handle.seek(row_start)
|
|
|
|
row_bytes = list(bytearray(self.file_handle.read(row_size)))
|
|
|
|
# Parse the pixel color information for the row
|
|
pixels = []
|
|
i = 0
|
|
while i < self.get_width():
|
|
start = i * 3
|
|
end = (i + 1) * 3
|
|
|
|
pixels.append(Color.from_bytes(row_bytes[start:end]))
|
|
|
|
i += 1
|
|
|
|
return pixels
|
|
|
|
|
|
class Color:
|
|
"""
|
|
A 24bit RGB color value.
|
|
"""
|
|
|
|
red = 0
|
|
green = 0
|
|
blue = 0
|
|
|
|
def __init__(self, red, green, blue):
|
|
"""
|
|
Creates a Color from the given 1 byte red, green, and blue color values.
|
|
|
|
:param red: The 1 byte red value.
|
|
:type red: int
|
|
:param green: The 1 byte green value.
|
|
:type green: int
|
|
:param blue: The 1 byte blue value.
|
|
:type blue: int
|
|
"""
|
|
self.red = red
|
|
self.green = green
|
|
self.blue = blue
|
|
|
|
def __repr__(self):
|
|
return "Color(red={}, green={}, blue={})".format(self.red, self.green, self.blue)
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, Color):
|
|
return False
|
|
|
|
return (
|
|
self.red == other.red
|
|
and self.green == other.green
|
|
and self.blue == other.blue
|
|
)
|
|
|
|
@staticmethod
|
|
def from_bytes(color_bytes):
|
|
blue = color_bytes[0]
|
|
green = color_bytes[1]
|
|
red = color_bytes[2]
|
|
|
|
return Color(red, green, blue)
|
|
|
|
|
|
class BMPHeader:
|
|
def __init__(self, bmp_type, size, value_1, value_2, image_start_offset):
|
|
self.bmp_type = bmp_type
|
|
self.size = size
|
|
self.value_1 = value_1
|
|
self.value_2 = value_2
|
|
self.image_start_offset = image_start_offset
|
|
|
|
def __repr__(self):
|
|
return "BMPHeader(bmp_type={}, size={}, value_1={}, value_2={}, image_start_offset={})".format(
|
|
self.bmp_type, self.size, self.value_1, self.value_2, self.image_start_offset
|
|
)
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, BMPHeader):
|
|
return False
|
|
|
|
return (
|
|
self.bmp_type == other.bmp_type
|
|
and self.size == other.size
|
|
and self.value_1 == other.value_1
|
|
and self.value_2 == other.value_2
|
|
and self.image_start_offset == other.image_start_offset
|
|
)
|
|
|
|
@staticmethod
|
|
def from_bytes(header_bytes):
|
|
header_bytes_list = list(bytearray(header_bytes))
|
|
|
|
bmp_type = BMPType.from_bytes(header_bytes_list[0:2])
|
|
size = int.from_bytes(bytes(header_bytes_list[2:6]), "little")
|
|
value_1 = bytes(header_bytes_list[6:8])
|
|
value_2 = bytes(header_bytes_list[8:10])
|
|
image_start_offset = int.from_bytes(bytes(header_bytes_list[10:14]), "little")
|
|
|
|
return BMPHeader(bmp_type, size, value_1, value_2, image_start_offset)
|
|
|
|
|
|
class DIBHeader:
|
|
def __init__(
|
|
self,
|
|
width,
|
|
height,
|
|
num_color_planes,
|
|
bits_per_pixel,
|
|
compression_type,
|
|
raw_bitmap_size,
|
|
horizontal_resolution_ppm,
|
|
vertical_resolution_ppm,
|
|
num_colors_in_palette,
|
|
num_important_colors_used,
|
|
):
|
|
self.width = width
|
|
self.height = height
|
|
self.num_color_planes = num_color_planes
|
|
self.bits_per_pixel = bits_per_pixel
|
|
self.compression_type = compression_type
|
|
self.raw_bitmap_size = raw_bitmap_size
|
|
self.horizontal_resolution_ppm = horizontal_resolution_ppm
|
|
self.vertical_resolution_ppm = vertical_resolution_ppm
|
|
self.num_colors_in_palette = num_colors_in_palette
|
|
self.num_important_colors_used = num_important_colors_used
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, DIBHeader):
|
|
return False
|
|
|
|
return (
|
|
self.width == other.width
|
|
and self.height == other.height
|
|
and self.num_color_planes == other.num_color_planes
|
|
and self.bits_per_pixel == other.bits_per_pixel
|
|
and self.compression_type == other.compression_type
|
|
and self.raw_bitmap_size == other.raw_bitmap_size
|
|
and self.horizontal_resolution_ppm == other.horizontal_resolution_ppm
|
|
and self.vertical_resolution_ppm == other.vertical_resolution_ppm
|
|
and self.num_colors_in_palette == other.num_colors_in_palette
|
|
and self.num_important_colors_used == other.num_important_colors_used
|
|
)
|
|
|
|
def __repr__(self):
|
|
return """DIBHeader(
|
|
width={},
|
|
height={},
|
|
num_color_planes={},
|
|
bits_per_pixel={},
|
|
compression_type={},
|
|
raw_bitmap_size={},
|
|
horizontal_resolution_ppm={},
|
|
vertical_resolution_ppm={},
|
|
num_colors_in_palette={},
|
|
num_important_colors_used={},
|
|
)""".format(
|
|
self.width,
|
|
self.height,
|
|
self.num_color_planes,
|
|
self.bits_per_pixel,
|
|
CompressionType.to_str(self.compression_type),
|
|
self.raw_bitmap_size,
|
|
self.horizontal_resolution_ppm,
|
|
self.vertical_resolution_ppm,
|
|
self.num_colors_in_palette,
|
|
self.num_important_colors_used,
|
|
)
|
|
|
|
@staticmethod
|
|
def from_positioned_file_handler(file_handler):
|
|
# Based on info from:
|
|
# https://en.wikipedia.org/wiki/BMP_file_format#DIB_header_(bitmap_information_header)
|
|
|
|
header_size = int.from_bytes(file_handler.read(4), "little")
|
|
|
|
if header_size <= 0:
|
|
raise ValueError("BMP header has invalid header size: " + str(header_size))
|
|
elif header_size > 100000:
|
|
raise ValueError("BMP header looks like it may be too big (header_size=" + str(header_size) + ").")
|
|
|
|
try:
|
|
header_bytes_list = list(bytearray(file_handler.read(header_size - 4)))
|
|
except MemoryError:
|
|
raise MemoryError("MemoryError when trying to read BMP file header. header_size=" + str(header_size))
|
|
|
|
width = None
|
|
height = None
|
|
num_color_planes = None
|
|
bits_per_pixel = None
|
|
compression_type = None
|
|
raw_bitmap_size = None
|
|
horizontal_resolution_ppm = None
|
|
vertical_resolution_ppm = None
|
|
num_colors_in_palette = None
|
|
num_important_colors_used = None
|
|
|
|
# BITMAPINFOHEADER or higher version
|
|
if header_size in [40, 52, 56, 108, 124] or header_size > 124:
|
|
width = int.from_bytes(bytes(header_bytes_list[0:4]), "little")
|
|
height = int.from_bytes(bytes(header_bytes_list[4:8]), "little")
|
|
num_color_planes = int.from_bytes(bytes(header_bytes_list[8:10]), "little")
|
|
bits_per_pixel = int.from_bytes(bytes(header_bytes_list[10:12]), "little")
|
|
compression_type = int.from_bytes(bytes(header_bytes_list[12:16]), "little")
|
|
raw_bitmap_size = int.from_bytes(bytes(header_bytes_list[16:20]), "little")
|
|
horizontal_resolution_ppm = int.from_bytes(
|
|
bytes(header_bytes_list[20:24]), "little"
|
|
)
|
|
vertical_resolution_ppm = int.from_bytes(
|
|
bytes(header_bytes_list[24:28]), "little"
|
|
)
|
|
num_colors_in_palette = int.from_bytes(
|
|
bytes(header_bytes_list[28:32]), "little"
|
|
)
|
|
num_important_colors_used = int.from_bytes(
|
|
bytes(header_bytes_list[32:36]), "little"
|
|
)
|
|
else:
|
|
# Note: Might add some support for older headers in the future, but I don't know how to
|
|
# generate BMP files with them, so maybe not.
|
|
raise ValueError(
|
|
"BMP file looks like it might be using an old BMP DIB header that we do not support."
|
|
)
|
|
|
|
return DIBHeader(
|
|
width=width,
|
|
height=height,
|
|
num_color_planes=num_color_planes,
|
|
bits_per_pixel=bits_per_pixel,
|
|
compression_type=compression_type,
|
|
raw_bitmap_size=raw_bitmap_size,
|
|
horizontal_resolution_ppm=horizontal_resolution_ppm,
|
|
vertical_resolution_ppm=vertical_resolution_ppm,
|
|
num_colors_in_palette=num_colors_in_palette,
|
|
num_important_colors_used=num_important_colors_used,
|
|
)
|
|
|
|
|
|
# Note: Can't use enum here, since MicroPython doesn't currently have an enum standard library
|
|
class BMPType:
|
|
BM = 0
|
|
BA = 1
|
|
CI = 2
|
|
CP = 3
|
|
IC = 4
|
|
PT = 5
|
|
|
|
@staticmethod
|
|
def from_bytes(bmp_type_bytes):
|
|
type_str = bytes(bmp_type_bytes).decode()
|
|
|
|
if type_str == "BM":
|
|
return BMPType.BM
|
|
elif type_str == "BA":
|
|
return BMPType.BA
|
|
elif type_str == "CI":
|
|
return BMPType.CI
|
|
elif type_str == "CP":
|
|
return BMPType.CP
|
|
elif type_str == "IC":
|
|
return BMPType.IC
|
|
elif type_str == "PT":
|
|
return BMPType.PT
|
|
else:
|
|
raise ValueError(f'Invalid BMP type: "{type_str}"')
|
|
|
|
|
|
class CompressionType:
|
|
BI_RGB = 0
|
|
BI_RLE8 = 1
|
|
BI_REL4 = 2
|
|
BI_BITFIELDS = 3
|
|
BI_JPEG = 4
|
|
BI_PNG = 5
|
|
BI_ALPHABITFIELDS = 6
|
|
BI_CMYK = 11
|
|
BI_CMYKRLE8 = 12
|
|
BI_CMYKRLE4 = 13
|
|
|
|
STRINGS_DICT = {
|
|
0: "BI_RGB",
|
|
1: "BI_RLE8",
|
|
2: "BI_REL4",
|
|
3: "BI_BITFIELDS",
|
|
4: "BI_JPEG",
|
|
5: "BI_PNG",
|
|
6: "BI_ALPHABITFIELDS",
|
|
11: "BI_CMYK",
|
|
12: "BI_CMYKRLE8",
|
|
13: "BI_CMYKRLE4",
|
|
}
|
|
|
|
@staticmethod
|
|
def to_str(compression_type):
|
|
return CompressionType.STRINGS_DICT.get(compression_type, str(compression_type))
|
|
|
|
@staticmethod
|
|
def is_compressed(compression_type):
|
|
return compression_type not in [CompressionType.BI_RGB, CompressionType.BI_CMYK]
|