0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python】10bit/12bitグレースケール画像をBitmapファイルで読み書きする

0
Posted at

はじめに

産業用カメラや医療画像の分野では、8bit (0~255) では階調が不足するため、10bit (0~1023) や12bit (0~4095) のグレースケール画像が使われます。

しかし、OpenCVの cv2.imread() / cv2.imwrite() やPillowなど、一般的な画像処理ライブラリのBMP読み書き関数は、10bitや12bitのグレースケールには対応していません。

そこで本記事では、Bitmapのビットフィールド (BI_BITFIELDS) を使って、10bit/12bitグレースケール画像をBitmapファイル (*.bmp) としてバイナリで読み書きするPythonの関数を作成します。

この方法で保存したBitmapファイルは Windowsのフォトでの表示は可能 ですが、エクスプローラのプロパティで画像のビット数を確認すると 32ビットと表示されてしまいます。これはWindowsの表示上の問題であり、ファイル内のデータは正しく16bitで格納されています。

BMPファイルフォーマット

BMPファイルは以下の構造で構成されています。

全体構造

領域 サイズ 説明
BITMAPFILEHEADER 14 bytes ファイルヘッダ
BITMAPINFOHEADER 40 bytes 情報ヘッダ
カラーパレット or ビットマスク 可変 パレットまたはBI_BITFIELDSのマスク
ピクセルデータ 可変 画像データ本体

BITMAPFILEHEADER (14 bytes)

フィールド サイズ 説明
bfType WORD 2 bytes ファイル識別子 "BM" (0x4D42)
bfSize DWORD 4 bytes ファイル全体のサイズ
bfReserved1 WORD 2 bytes 予約 (0)
bfReserved2 WORD 2 bytes 予約 (0)
bfOffBits DWORD 4 bytes ピクセルデータまでのオフセット

BITMAPINFOHEADER (40 bytes)

フィールド サイズ 説明
biSize DWORD 4 bytes ヘッダサイズ (40)
biWidth LONG 4 bytes 画像の幅 (ピクセル)
biHeight LONG 4 bytes 画像の高さ (正:ボトムアップ, 負:トップダウン)
biPlanes WORD 2 bytes プレーン数 (1)
biBitCount WORD 2 bytes 1ピクセルあたりのビット数
biCompression DWORD 4 bytes 圧縮形式 (0:BI_RGB, 3:BI_BITFIELDS)
biSizeImage DWORD 4 bytes 画像データのサイズ
biXPelsPerMeter LONG 4 bytes 水平解像度
biYPelsPerMeter LONG 4 bytes 垂直解像度
biClrUsed DWORD 4 bytes 使用色数
biClrImportant DWORD 4 bytes 重要色数

BI_BITFIELDS のビットマスク

biCompression = BI_BITFIELDS (3) の場合、BITMAPINFOHEADER の直後に3つのDWORD (各4 bytes) のビットマスクが配置されます。

マスク サイズ 説明
Rマスク 4 bytes 赤チャネルのビット位置
Gマスク 4 bytes 緑チャネルのビット位置
Bマスク 4 bytes 青チャネルのビット位置

グレースケールの場合、R/G/Bの3つのマスクをすべて同じ値に設定します。これにより、R=G=Bとなりグレースケールとして表示されます。

ビット深度 マスク値 ピクセル値の範囲
10bit 0x000003FF 0 ~ 1023
12bit 0x00000FFF 0 ~ 4095
14bit 0x00003FFF 0 ~ 16383
16bit 0x0000FFFF 0 ~ 65535

bfOffBits (ピクセルデータまでのオフセット)

フォーマット bfOffBits 内訳
BI_BITFIELDS (16bit) 66 14 + 40 + 12
BI_RGB (8bit グレースケール) 1078 14 + 40 + 1024 (パレット256色×4bytes)
BI_RGB (24bit / 32bit カラー) 54 14 + 40

行のパディング

BMPのピクセルデータは、各行が 4バイト境界 にアラインメントされます。

1行あたりのバイト数 = ((幅 × biBitCount + 31) // 32) × 4

画像データの格納方向

  • biHeight > 0 (通常): ボトムアップ — 画像の最下行からファイルに格納
  • biHeight < 0: トップダウン — 画像の最上行からファイルに格納

本記事の関数では、OpenCVと同様に 画像の左上を原点 として読み書きします。

Pythonでの実装

必要なライブラリ

import struct
import numpy as np

struct はPython標準ライブラリで、バイナリデータのパック/アンパックに使用します。

保存関数 imwrite_bmp

NumPy配列の画像データをBitmapファイルに保存します。

import struct
import numpy as np


# 定数
BI_RGB       = 0
BI_BITFIELDS = 3

_FMT_FILEHEADER = '<2sIHHI'       # BITMAPFILEHEADER (14 bytes)
_FMT_INFOHEADER = '<IiiHHIIiiII'  # BITMAPINFOHEADER (40 bytes)
_FMT_BITMASKS   = '<III'          # ビットマスク R, G, B (12 bytes)

_SIZE_FILEHEADER = struct.calcsize(_FMT_FILEHEADER)  # 14
_SIZE_INFOHEADER = struct.calcsize(_FMT_INFOHEADER)  # 40
_SIZE_BITMASKS   = struct.calcsize(_FMT_BITMASKS)    # 12
_SIZE_RGBQUAD    = 4


def imwrite_bmp(filename, img, bit_depth=None):
    """
    画像データをBitmapファイルに保存する

    Parameters
    ----------
    filename : str
        保存先のBitmapファイルのパス
    img : np.ndarray
        画像データ (画像の左上が原点)
        - np.uint8,  2D:         8bitグレースケール (BI_RGB)
        - np.uint8,  3D (ch=3):  24bit BGRカラー (BI_RGB)
        - np.uint8,  3D (ch=4):  32bit BGRAカラー (BI_RGB)
        - np.uint16, 2D:         グレースケール (BI_BITFIELDS, bit_depth必須)
    bit_depth : int, optional
        BI_BITFIELDSの場合のビット深度 (10, 12, 14, 16)
        np.uint16の2D配列を渡す場合は必須
    """
    ndim  = img.ndim
    dtype = img.dtype

    # ----- 画像タイプの判定 -----
    if dtype == np.uint8 and ndim == 2:
        mode = '8bit_gray'
    elif dtype == np.uint8 and ndim == 3 and img.shape[2] == 3:
        mode = '24bit_bgr'
    elif dtype == np.uint8 and ndim == 3 and img.shape[2] == 4:
        mode = '32bit_bgra'
    elif dtype == np.uint16 and ndim == 2:
        mode = 'bitfields_gray'
        if bit_depth is None:
            raise ValueError(
                "bit_depthを指定してください (10, 12, 14, 16)")
        if bit_depth not in (10, 12, 14, 16):
            raise ValueError(
                f"bit_depthは 10, 12, 14, 16 のいずれか: {bit_depth}")
    else:
        raise ValueError(
            f"未対応の画像形式: ndim={ndim}, dtype={dtype}")

    # ----- サイズ取得 -----
    if ndim == 2:
        height, width = img.shape
    else:
        height, width = img.shape[:2]

    # ----- モード別のヘッダパラメータ設定 -----
    if mode == 'bitfields_gray':
        biBitCount    = 16
        biCompression = BI_BITFIELDS
        mask_value    = (1 << bit_depth) - 1
        masks_data    = struct.pack(_FMT_BITMASKS,
                                    mask_value, mask_value, mask_value)
        extra_data    = masks_data
        biClrUsed     = 0

    elif mode == '8bit_gray':
        biBitCount    = 8
        biCompression = BI_RGB
        # 256エントリのグレースケールパレット
        palette = bytearray(256 * _SIZE_RGBQUAD)
        for i in range(256):
            offset = i * _SIZE_RGBQUAD
            palette[offset]     = i  # Blue
            palette[offset + 1] = i  # Green
            palette[offset + 2] = i  # Red
            palette[offset + 3] = 0  # Reserved
        extra_data = bytes(palette)
        biClrUsed  = 256

    elif mode == '24bit_bgr':
        biBitCount    = 24
        biCompression = BI_RGB
        extra_data    = b''
        biClrUsed     = 0

    elif mode == '32bit_bgra':
        biBitCount    = 32
        biCompression = BI_RGB
        extra_data    = b''
        biClrUsed     = 0

    # ----- 行バイト数とパディング -----
    row_bytes     = ((width * biBitCount + 31) // 32) * 4
    bytes_per_row = width * (biBitCount // 8)
    padding       = row_bytes - bytes_per_row
    pad_bytes     = b'\x00' * padding

    pixel_data_size = row_bytes * height

    # ----- ファイルサイズとオフセット -----
    bfOffBits = _SIZE_FILEHEADER + _SIZE_INFOHEADER + len(extra_data)
    bfSize    = bfOffBits + pixel_data_size

    # ----- BITMAPFILEHEADER -----
    bfh = struct.pack(_FMT_FILEHEADER,
                      b'BM', bfSize, 0, 0, bfOffBits)

    # ----- BITMAPINFOHEADER -----
    bih = struct.pack(_FMT_INFOHEADER,
                      _SIZE_INFOHEADER,  # biSize
                      width,             # biWidth
                      height,            # biHeight (正値=ボトムアップ)
                      1,                 # biPlanes
                      biBitCount,        # biBitCount
                      biCompression,     # biCompression
                      pixel_data_size,   # biSizeImage
                      0,                 # biXPelsPerMeter
                      0,                 # biYPelsPerMeter
                      biClrUsed,         # biClrUsed
                      0)                 # biClrImportant

    # ----- 画像を上下反転 (トップダウン → ボトムアップ) -----
    img_flipped = np.flipud(img)

    # ----- ファイル書き込み -----
    with open(filename, 'wb') as f:
        f.write(bfh)
        f.write(bih)
        if extra_data:
            f.write(extra_data)

        for y in range(height):
            row = img_flipped[y]
            f.write(row.tobytes())
            if padding > 0:
                f.write(pad_bytes)

保存関数のポイント

画像タイプの自動判定:

NumPy配列の dtypendim (次元数) から、保存するBMPフォーマットを自動的に判定します。

dtype ndim チャネル数 BMP形式
np.uint8 2 - 8bit グレースケール (BI_RGB)
np.uint8 3 3 24bit BGR カラー (BI_RGB)
np.uint8 3 4 32bit BGRA カラー (BI_RGB)
np.uint16 2 - BI_BITFIELDS (bit_depth指定必須)

BI_BITFIELDSのビットマスク:

グレースケール画像の場合、R/G/Bの3つのマスクをすべて同じ値に設定します。

mask_value = (1 << bit_depth) - 1  # 例: 10bit → 0x03FF
# R = G = B = mask_value
masks_data = struct.pack('<III', mask_value, mask_value, mask_value)

Windowsがピクセルを描画する際、R, G, Bの各チャネルで同じ値が取得されるため、グレースケールとして正しく表示されます。

読込関数 imread_bmp

Bitmapファイルを読み込み、NumPy配列として返します。

def imread_bmp(filename):
    """
    Bitmapファイルから画像データを読み込む

    Parameters
    ----------
    filename : str
        読み込むBitmapファイルのパス

    Returns
    -------
    img : np.ndarray
        画像データ (画像の左上が原点)
        - BI_RGB, 8bit:  np.uint8,  shape=(height, width)
        - BI_RGB, 24bit: np.uint8,  shape=(height, width, 3)  BGR順
        - BI_RGB, 32bit: np.uint8,  shape=(height, width, 4)  BGRA順
        - BI_BITFIELDS:  np.uint16, shape=(height, width)
    bit_depth : int
        画像のビット深度 (8, 10, 12, 14, 16, 24, 32)
    """
    with open(filename, 'rb') as f:
        # ----- BITMAPFILEHEADER (14 bytes) -----
        bfh_data = f.read(_SIZE_FILEHEADER)
        (bfType, bfSize, bfReserved1, bfReserved2,
         bfOffBits) = struct.unpack(_FMT_FILEHEADER, bfh_data)

        if bfType != b'BM':
            raise ValueError("BMPファイルではありません (bfType != 'BM')")

        # ----- BITMAPINFOHEADER (40 bytes) -----
        bih_data = f.read(_SIZE_INFOHEADER)
        (biSize, biWidth, biHeight, biPlanes, biBitCount,
         biCompression, biSizeImage, biXPelsPerMeter, biYPelsPerMeter,
         biClrUsed, biClrImportant) = struct.unpack(_FMT_INFOHEADER, bih_data)

        width  = biWidth
        height = abs(biHeight)
        top_down = (biHeight < 0)

        # ----- パレット / ビットマスク データ -----
        extra_size = bfOffBits - _SIZE_FILEHEADER - _SIZE_INFOHEADER
        extra_data = f.read(extra_size) if extra_size > 0 else b''

        # ----- ピクセルデータ -----
        f.seek(bfOffBits)
        pixel_data = f.read()

    # 1行あたりのバイト数 (4バイト境界にアラインメント)
    row_bytes = ((width * biBitCount + 31) // 32) * 4

    # ----- BI_BITFIELDS: 10/12/14/16bit グレースケール -----
    if biCompression == BI_BITFIELDS:
        r_mask, g_mask, b_mask = struct.unpack(
            _FMT_BITMASKS, extra_data[:_SIZE_BITMASKS])

        # Rマスクからビット深度とシフト量を算出
        mask = r_mask
        if mask == 0:
            raise ValueError("ビットマスクが0です")
        # シフト量: マスクの最下位ビットの位置
        shift = (mask & -mask).bit_length() - 1
        # ビット深度: シフト後のマスクのビット長
        bit_depth = (mask >> shift).bit_length()

        # 画像データ読込 (uint16)
        img = np.zeros((height, width), dtype=np.uint16)
        for y in range(height):
            row_start = y * row_bytes
            row_raw = pixel_data[row_start:row_start + width * 2]
            row_pixels = np.frombuffer(row_raw, dtype=np.uint16)
            img[y, :] = (row_pixels & mask) >> shift

        # ボトムアップ → トップダウン変換
        if not top_down:
            img = np.flipud(img)

        return img, bit_depth

    # ----- BI_RGB -----
    elif biCompression == BI_RGB:

        # 8bit グレースケール
        if biBitCount == 8:
            img = np.zeros((height, width), dtype=np.uint8)
            for y in range(height):
                row_start = y * row_bytes
                row_raw = pixel_data[row_start:row_start + width]
                img[y, :] = np.frombuffer(row_raw, dtype=np.uint8)

            if not top_down:
                img = np.flipud(img)
            return img, 8

        # 24bit BGR カラー
        elif biBitCount == 24:
            img = np.zeros((height, width, 3), dtype=np.uint8)
            for y in range(height):
                row_start = y * row_bytes
                row_raw = pixel_data[row_start:row_start + width * 3]
                img[y, :, :] = np.frombuffer(
                    row_raw, dtype=np.uint8).reshape(width, 3)

            if not top_down:
                img = np.flipud(img)
            return img, 24

        # 32bit BGRA カラー
        elif biBitCount == 32:
            img = np.zeros((height, width, 4), dtype=np.uint8)
            for y in range(height):
                row_start = y * row_bytes
                row_raw = pixel_data[row_start:row_start + width * 4]
                img[y, :, :] = np.frombuffer(
                    row_raw, dtype=np.uint8).reshape(width, 4)

            if not top_down:
                img = np.flipud(img)
            return img, 32

        else:
            raise ValueError(
                f"未対応のbiBitCount: {biBitCount} (対応: 8, 24, 32)")

    else:
        raise ValueError(
            f"未対応のbiCompression: {biCompression} "
            f"(対応: BI_RGB={BI_RGB}, BI_BITFIELDS={BI_BITFIELDS})")

読込関数のポイント

BI_BITFIELDSでのビット深度の自動検出:

ビットマスクの最上位ビットの位置からビット深度を自動判定します。

# シフト量: マスクの最下位ビットの位置
shift = (mask & -mask).bit_length() - 1
# ビット深度: シフト後のマスクのビット長
bit_depth = (mask >> shift).bit_length()

例えば、マスクが 0x03FF (= 0000001111111111) の場合:

  • shift = 0 (最下位ビットがbit 0)
  • bit_depth = 10 (10個の連続した1ビット)

画像データの格納方向:

BMPファイルの標準 (biHeight > 0) では画像がボトムアップで格納されているため、np.flipud() で上下反転してOpenCVと同じトップダウン (左上が原点) にします。

サンプルプログラム

10bitグレースケールのグラデーションテストパターン画像を生成し、BMPファイルに保存するサンプルです。

import numpy as np
import matplotlib.pyplot as plt
from bitmap_io import imread_bmp, imwrite_bmp


def main():
    # パラメータ
    width     = 1024
    height    = 256
    bit_depth = 10
    max_val   = (1 << bit_depth) - 1  # 1023
    filename  = 'gradient_10bit.bmp'

    # 斜めグラデーション画像の生成
    # 左上(0,0)が黒、右下に向かって明るくなる
    # 最大値を超えたら0に戻る
    col = np.arange(width, dtype=np.uint16)
    row = np.arange(height, dtype=np.uint16)
    img = (col[np.newaxis, :] + row[:, np.newaxis]) % (max_val + 1)
    img = img.astype(np.uint16)

    print(f"画像生成: shape={img.shape}, dtype={img.dtype}, "
          f"min={img.min()}, max={img.max()}")

    # Bitmapファイルに保存
    imwrite_bmp(filename, img, bit_depth=bit_depth)
    print(f"保存完了: {filename}")

    # 読み戻して表示
    img_read, read_bit_depth = imread_bmp(filename)
    print(f"読込完了: shape={img_read.shape}, dtype={img_read.dtype}, "
          f"bit_depth={read_bit_depth}, min={img_read.min()}, max={img_read.max()}")

    fig, ax = plt.subplots(figsize=(10, 3))
    im = ax.imshow(img_read, cmap='gray', vmin=0, vmax=max_val)
    ax.set_title(f'10bit Grayscale Gradient ({width}x{height})')
    ax.set_xlabel('Column')
    ax.set_ylabel('Row')
    fig.colorbar(im, ax=ax, label='Pixel Value (0-1023)')
    plt.tight_layout()
    plt.savefig('gradient_10bit.png', dpi=150)
    print("画像保存: gradient_10bit.png")
    plt.show()


if __name__ == '__main__':
    main()

実行結果

画像生成: shape=(256, 1024), dtype=uint16, min=0, max=1023
保存完了: gradient_10bit.bmp
読込完了: shape=(256, 1024), dtype=uint16, bit_depth=10, min=0, max=1023
画像保存: gradient_10bit.png

左上が黒 (0) で右下に向かって明るくなる斜めグラデーション画像が表示されます。輝度値が最大値 (1023) を超えると0に戻るため、斜めの縞模様が確認できます。

gradient_10bit.png

注意事項

  • この方法で保存したBitmapファイルはWindowsのフォトでの表示は可能ですが、エクスプローラのプロパティで画像のビット数を確認すると32ビットと表示されてしまいます
  • biBitCount = 16biCompression = BI_BITFIELDS で保存しているため、ファイル内のデータは正しく16bitで格納されています。
  • OpenCVの cv2.imread() はBI_BITFIELDSの16bitグレースケールBMPに対応していないため、本記事の imread_bmp() 関数で読み込む必要があります。
  • 10bitの画像をOpenCVで表示 (cv2.imshow) する場合は、8bitに変換 (2ビット右シフト) してから表示してください。12bitの場合は4ビット右シフトが必要です。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?