はじめに
産業用カメラや医療画像の分野では、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配列の dtype と ndim (次元数) から、保存する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に戻るため、斜めの縞模様が確認できます。
注意事項
- この方法で保存したBitmapファイルはWindowsのフォトでの表示は可能ですが、エクスプローラのプロパティで画像のビット数を確認すると32ビットと表示されてしまいます。
-
biBitCount = 16、biCompression = BI_BITFIELDSで保存しているため、ファイル内のデータは正しく16bitで格納されています。 - OpenCVの
cv2.imread()はBI_BITFIELDSの16bitグレースケールBMPに対応していないため、本記事のimread_bmp()関数で読み込む必要があります。 - 10bitの画像をOpenCVで表示 (
cv2.imshow) する場合は、8bitに変換 (2ビット右シフト) してから表示してください。12bitの場合は4ビット右シフトが必要です。
