📊

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の利点は様々なライブラリを使えることだと思うので,わざわざ苦労して標準ライブラリに縛ることもない気がします。ですが,バイナリファイルを直接いじるようなコードを書くとちゃんとプログラミングしている気持ちにはなりますね (?)。

脚注
  1. Wikipediaより https://ja.wikipedia.org/wiki/Windows_bitmap ↩︎

  2. http://bttb.s1.valueserver.jp/wordpress/blog/2018/12/13/python_bitmap/, https://qiita.com/cat2151/items/6db59a0dc54d7ba1b87a 1つ目はリンクが切れてしまっていますね... ↩︎

Discussion