Lib for reading bitmaps
This commit is contained in:
434
bmp_file_reader.py
Normal file
434
bmp_file_reader.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# 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]
|
||||
Reference in New Issue
Block a user