Pythonの標準ライブラリのみで画像を生成する話
発端
Pythonで2次元の画像データを引数にして,画像として保存したい。しかし,コンセプトとしてなるべくサードパーティのライブラリは使いたくない。という状況でした。しかし,普通に検索してもPillowやmatplotlibやOpenCVを使え,という情報しか出てきません。
そこで色々考えた結果,bitmapを思い出しました。
bitmapについて
bitmap形式は「マイクロソフトとIBMがWindowsとOS/2にわかれる前のOSを共同で開発していた頃に作られた画像ファイル形式」だそうです[1]。注目した特徴として,bitmapでは画像の情報が圧縮されません。そのため,データがあり,フォーマットを知っていれば,外部ツールがなくとも作れるのではないかと思いました。
方法
実は,Pythonで同じようなことをしている先駆者は数名いました[2]。ただし,これらはデータを入力としていなかったので,そのままでは使えませんでした。そのため,任意の入力に対してbitmapファイルを作成できるようにしました。
bitmapのフォーマットはこちらやこちらで見つけられます。ざっくりとはヘッダー部分とデータ部分があります。また,bitmapのフォーマットには "Windows" と "OS/2" の2種類があり,ヘッダーに保存される情報が異なります。現在の主流は "Windows" のようですが,どちらにも対応できるようにしました。データを元にヘッダー情報を計算し,フォーマットに従ってバイナリアレイとしてスタックしていきます。ヘッダーに保存される値のうち,いくつかの値は使われていないようなので,値を入れていません。最後にバイナリデータとしてヘッダーとデータをファイルに書き込めばbitmapファイルが作成できるはずです。
そうして出来たものが以下になります。
import sys
from pathlib import Path
from typing import Literal, Any
def make_bitmap(filename: str | Path, rgb: Any,
bmp_type: Literal['Windows', 'OS/2'] = 'Windows',
) -> None:
"""
create a bitmap file.
Parameters
----------
filename: str or pathlike-object
name of saved file.
rgb: array-like object
image data.
shape of this data is [width, height, color] and length of color is
3 ([red, blue, green]) or 4 ([red, green, blue, alpha]).
NOTE: bitmap does't support alpha values.
bmp_type: Windows or OS/2
bitmap type. Windows or OS/2 is selectable.
see e.g., https://en.wikipedia.org/wiki/BMP_file_format for detail.
Returns
-------
None
"""
height = len(rgb)
width = len(rgb[0])
cols = len(rgb[0][0])
assert cols in [3, 4], \
f'length of rgb should be 3 or 4 (RGB or RGBA) (now {cols}).'
rgb_data = [[[0, 0, 0] for _ in range(width)] for _ in range(height)]
for i in range(height):
for j in range(width):
try:
rgb_data[i][j] = rgb[i][j][:3]
except IndexError:
raise IndexError(f'rgb[{i}, {j}] is out of range.'
f' (height: {height}, width: {width})')
# make color table (it doesn't need in 24bmp format.)
# q_bit = 256
color_table: list[int] = []
# for r in range(q_bit):
# for g in range(q_bit):
# for b in range(q_bit):
# color_table += [int(b), int(g), int(r), int(0)]
len_cols = len(color_table)
num_cols = len(color_table) >> 2
# make pixel data
img_data = []
for i in range(height):
line_data = []
for j in range(width):
r, g, b = rgb_data[height-i-1][j] # starts from left botom
line_data += [b, g, r]
# line length should be a multiple of 4 bytes (long).
padding = 4*(int((len(line_data)-1)/4)+1)-len(line_data)
for k in range(padding):
line_data.append(0)
img_data += line_data
len_data = len(img_data)
if bmp_type == 'Windows':
offset = 0x0e+0x28+len_cols
elif bmp_type == 'OS/2':
offset = 0x0e+0x0c+len_cols
else:
print('incorrect file format: {}.'.format(bmp_type), file=sys.stderr)
return None
file_size = offset+len_data
# make binary data
# FILE_HEADER
bd = bytearray([0x42, 0x4d]) # signature 'BM'
bd.extend(file_size.to_bytes(4, 'little')) # file size
bd.extend((0).to_bytes(2, 'little')) # reserved
bd.extend((0).to_bytes(2, 'little')) # reserved
bd.extend(offset.to_bytes(4, 'little')) # offset
# INFO_HEADER
if bmp_type == 'Windows':
bd.extend((0x28).to_bytes(4, 'little')) # size of header
bd.extend(width.to_bytes(4, 'little')) # width [dot]
bd.extend(height.to_bytes(4, 'little')) # height [dot]
bd.extend((1).to_bytes(2, 'little')) # number of planes
bd.extend((8*3).to_bytes(2, 'little')) # byte/1pixel
bd.extend((0).to_bytes(4, 'little')) # type of compression (0=BI_RGB, no compression)
bd.extend(len_data.to_bytes(4, 'little')) # size of image
bd.extend((0).to_bytes(4, 'little')) # horizontal resolution
bd.extend((0).to_bytes(4, 'little')) # vertical resolution
bd.extend(num_cols.to_bytes(4, 'little')) # number of colors (not used for 24bmp)
bd.extend((0).to_bytes(4, 'little')) # import colors (0=all)
elif bmp_type == 'OS/2':
bd.extend((0x0c).to_bytes(4, 'little')) # size of header
bd.extend(width.to_bytes(2, 'little')) # width [dot]
bd.extend(height.to_bytes(2, 'little')) # height [dot]
bd.extend((1).to_bytes(2, 'little')) # number of planes
bd.extend((8*3).to_bytes(2, 'little')) # byte/1pixel
# COLOR_TABLES
bd.extend(color_table)
# DATA
bd.extend(img_data)
with open(filename, 'wb') as f:
f.write(bd)
if __name__ == '__main__':
import numpy as np
w = 256*2
h = 256
data = np.arange(w*h*3).reshape(h, w, 3)
data = data % 256
make_bitmap('test.bmp', list(data))
make_bitmap()がbitmap作成用の関数となっています。基本的には保存するファイルの名前filenameとデータrgbを入れれば,bitmapファイルを作成してくれます。rgbは高さH, 幅W, RBGの値のH×W×3(か,RGBAのH×W×4)のshapeを持っている想定です。RGBの値はそれぞれ0-255の整数を入れてください。
最後には検証用のスクリプトがあります。このコードを動かすと,こんな感じのbmp画像が生成されるはずです。

終わりに
今回はPythonの標準ライブラリで画像ファイル (bitmap) を作成する話でした。Pythonの利点は様々なライブラリを使えることだと思うので,わざわざ苦労して標準ライブラリに縛ることもない気がします。ですが,バイナリファイルを直接いじるようなコードを書くとちゃんとプログラミングしている気持ちにはなりますね (?)。
Discussion