[Python] BMP(ビットマップ)データを読み込む方法

Python
この記事は約15分で読めます。
具体的な例を求める女
具体的な例を求める女

このモグラ、バイナリ・バイナリってうるさかったけど、結局バイナリファイルが読み書きできるようになって、何かいいことあるの?

具体的な例を出すのが遅いモグラ
具体的な例を出すのが遅いモグラ

具体的な例を示せておらずにスミマセン。身近なところでは、Windowsのビットマップ(BMP)画像を開いて、画像処理を行った後に、再びビットマップ画像として保存する事が出来るようになります。

画像処理エンジニアのつちもぐらです。 バイナリファイル(画像データ)とはかれこれ10年以上の付き合いです。仕事では色々な独自フォーマットの画像データをPythonで読み込んだりC++で読み込んだりしています。

Pythonで画像データを読み込むというと、OpenCVというライブラリで読み込ませて・・みたいなイメージを持たれるかもですが、この記事ではビットマップ(BMP)画像データのファイルフォーマットを理解して、自分でバイナリファイルを処理するコードが書けるようになる事を目標としています。

この記事の内容が理解出来れば、今後バイナリファイルを目の前にしても、まず怯むことはないでしょう。(内部の圧縮アルゴとかの難易度は、また別の話となります。。)

本記事に記載のPythonスクリプト動作確認環境

OS : Windows10(64bit版)
Python version : 3.7.3

スポンサーリンク

BMP(ビットマップ)データのフォーマット

いきなり他力本願ですが、BMPのファイルフォーマットを分かり易く表にまとめているサイトを見つけたので、ご紹介します。

このファイルヘッダ、情報ヘッダのパラメタ数だけで「あかん、もうワケ分からん・・」となってしまった人もいるかもしれませんが、実際の処理で必要となるパラメタ数は、そんなに多くは無いです。この辺りは実際のソースコードと一緒に確認していければと思います。

Pythonでビットマップデータを読み込ませるソースコード

import sys

### 入出力画像ファイルのオブジェクトを生成 ###
f  = open(r"C:\Python_source\02_BinaryFile\input.bmp","rb")
fw = open(r"C:\Python_source\02_BinaryFile\output.bmp","wb")

### BMPファイルヘッダ ###
bfType         = f.read(2)
bfSize         = f.read(4)
bfReserved1    = f.read(2)
bfReserved2    = f.read(2)
bfOffBitsbfOffBits = f.read(4)

### 情報ヘッダ ###
bcSize         = f.read(4)
bcWidth        = f.read(4)
bcHeight       = f.read(4)
bcPlanes       = f.read(2)
bcBitCount     = f.read(2)
biCompression  = f.read(4)
biSizeImage    = f.read(4)
biXPixPerMeter = f.read(4)
biYPixPerMeter = f.read(4)
biClrUsed      = f.read(4)
biCirImportant = f.read(4)

### 出力ファイルのヘッダ作成 ###
fw.write(bfType            )
fw.write(bfSize            )
fw.write(bfReserved1       )
fw.write(bfReserved2       )
fw.write((54).to_bytes(4,"little"))
fw.write(bcSize            )
fw.write(bcWidth           )
fw.write(bcHeight          )
fw.write(bcPlanes          )
fw.write(bcBitCount        )
fw.write(biCompression     )
fw.write(biSizeImage       )
fw.write(biXPixPerMeter    )
fw.write(biYPixPerMeter    )
fw.write(biClrUsed         )
fw.write(biCirImportant    )

### 処理に必要そうなデータはデータとして持っておく ###
bfType_str             = bfType.decode()
bfOffBitsbfOffBits_int = int.from_bytes(bfOffBitsbfOffBits, "little")
bcSize_int             = int.from_bytes(bcSize,             "little")
bcWidth_int            = int.from_bytes(bcWidth,            "little")
bcHeight_int           = int.from_bytes(bcHeight,           "little")
bcBitCount_int         = int.from_bytes(bcBitCount,         "little")
biCompression_int      = int.from_bytes(biCompression,      "little")

### 想定する画像フォーマットでない場合は、ここで処理を終了 ###
if (bfType_str!="BM") or \
   (bcSize_int!=40)   or \
   (bcBitCount_int!=24) or \
   (biCompression_int!=0):
  print ("### This file format is not supported! ###")
  sys.exit()

### 画像サイズ確認(デバッグ用) ###
print ("(Width,Height)=(%d,%d)" % (bcWidth_int,bcHeight_int))

### 画像データ本体へJump。ほどんど不要かも。###
offset = bfOffBitsbfOffBits_int-54
f.read(offset)

######################
### 画像データ処理 ###
######################
### 画像処理パラメータ ###
gain = 1.5

### 画像データ処理開始 ###
dummy_size=0
mod = (bcWidth_int*3)%4
if (mod!=0) : dummy_size = 4-mod 
for y in range(bcHeight_int):
  for x in range(bcWidth_int):
    R = int.from_bytes(f.read(1), "little")
    G = int.from_bytes(f.read(1), "little")
    B = int.from_bytes(f.read(1), "little")
    ### 画像処理(ゲイン) ###
    R = min(int(R*gain), 255)
    G = min(int(G*gain), 255)
    B = min(int(B*gain), 255)
    ### 処理結果を書き込む ###
    fw.write(R.to_bytes(1,"little",signed=False))
    fw.write(G.to_bytes(1,"little",signed=False))
    fw.write(B.to_bytes(1,"little",signed=False))           
  ### 画像の横ラインデータサイズを4の倍数にそろえる ###
  for i in range(dummy_size):
    tmp = int.from_bytes(f.read(1), "little")
    fw.write((255).to_bytes(1,"little",signed=False))
        
### ファイルオブジェクトをclose ### 
f.close()
fw.close()

今までとはコードの量が一気に増えて「ぎゃ」っと思った人もいるかもしれませんが、実はやっている事は単純です。本サイトのPythonカテゴリの記事が理解できていれば、理解する事は困難ではないでしょう。では、早速見ていきましょう。

標準ライブラリ(1行目,60行目)

1行目で “import sys” とありますが、これはPythonの標準ライブラリを読み込んでいます。今までの記事では組み込み関数(import なしで使える関数)のみでコードを実装してきたので、 標準ライブラリを読み込んだコードの記事はありませんでした。

標準ライブラリはPythonをインストールすれば自動でついてきますが、標準ライブラリ以外にもサードパーティーライブラリというものが存在して、追加でインストールすればPythonの機能を拡張する事が可能です。冒頭のOpenCVも追加でインストールする事でPythonでその機能を使えるようになります。

少し話が逸れましたが、今回のソースコードでは処理できないBMPフォーマットファイルを読み込んだ時に処理を強制終了する為に、sysモジュールを読み込んでいます。

入力画像ファイルの読み込み、出力画像ファイルの準備(3~5)

4行目で入力画像ファイルの読み込んで、5行目で出力画像ファイル名とディレクトリを設定する事で、ファイルオブジェクトを生成しています。必要に応じてファイル名とディレクトリのパスは変更してください。

BMPヘッダ情報の読み込み(7~25行目)

行数は多いですが、先に紹介したBMPフォーマットのファイルヘッダ、情報ヘッダの表と見比べてみると、とても対応が取りやすく、難しい事は全くやっていない雰囲気が伝わってきます。

具体的な処理としては、BMPヘッダのパラメタ名称をそのままPythonの変数名として流用して、パラメタ毎にPythonのread()メソッドで変数へと読み込んでいく事で、ヘッダ情報を取得します。ここでPythonの変数は全てbytes型となります。

もしバイナリファイルの読み込みがよく分からなくなってしまった場合は、過去記事を参考にしてみてください。

BMPヘッダ情報の書き込み(27~43行目)

読み込んだヘッダ情報をそのまま書き込んでいるだけです。変数はbytes型なので、そのままダイレクトにwrite()メソッドで書き込めます。

ただし32行目だけは “fw.write((54).to_bytes(4,”little”))” と54という値をダイレクトに書き込んでいます。ここはbfOffBitsbfOffBits(ファイル先頭から画像データまでのオフセットのバイト数)を書き込むアドレスですが、ヘッダ終了直後に画像データ本体を配置するのであれば、オフセット値は必ず54となるので、決め打ちで書き込んでいます。もし入力画像のbfOffBitsbfOffBitsをそのまま流用するのであれば、 bfOffBitsbfOffBits=54でない時に、ヘッダ~画像本体間へダミーデータを詰め込むコードを念のために実装する必要が出てきます。

また画像サイズが入力から変わるような画像処理(拡大・縮小など)を行う場合は、画像処理後の

  • bcWidth – 画像の幅
  • bcHeight – 画像の高さ
  • bfSize – ファイルサイズ

といった情報をプログラムの中で計算して、その計算結果をファイルヘッダに書き込む必要があります。この処理をさぼった場合はパソコンの画像ビューワーが実際の画像とは一致しないBMPのヘッダ情報を読み込む事となるので、結果としてぐちゃぐちゃな画像が表示される、もしくはファイルを開く事すら出来ないでしょう。

画像処理に必要なパラメタを整数の変数として取り込む(45~52行目)

これから行う画像処理と、そもそも処理できる画像フォーマットなのかを判定するのに必要となるパラメタを、変数として取り込んでいます。

まず54行目では、パラメタbfTypeにはBMPフォーマットを示す識別子「BM」が入っているはずなので、後で確認する為にbytes型のdecode()メソッドを用いて文字列として取り出しています。

次に47行目から52行目では、bytes型の変数を整数化する時に全て “little” で読み込んでいます。これはBMPヘッダ情報のバイトオーダーが、リトルエンディアンとなっているからです。

例えば水平445画素のBMP画像データを読み込ませた場合、インタラクティブモードで確認してみると、

>>> print(bcWidth)
b'\xbd\x01\x00\x00'
>>> 0x000001bd
445

という風に、バイトの並びをひっくり返して先頭に0xを付けてEnterキーを押すと(16進数->10進数変換が出来る)、正しい数字が読み込めている事が確認出来ます。バイトの並びはバイナリエディタでも確認できます。このバイトオーダーについては、次の記事が参考となります。

処理できる画像なのかどうかを判定する(54~60行目)

本記事に掲載したソースコードは、

  • BMP画像であること。-> bfType =”BM”であること。
  • Windowsビットマップであること。-> bcSize =40であること。
  • 24ビット ビットマップ画像であること。-> bcBitCount_int=24であること。
  • 無圧縮の画像であること。-> biCompression=0であること。

を前提に実装されています。

そのため上記の条件に当てはまらない場合は、ここで処理を終了します。

ここでは55~58行目で一緒くたに条件式を or でつないで書ききってしまいましたが、何が原因で処理が終了したのかを丁寧に示したい場合は、条件ごとにメッセージを変える実装とすれば良いでしょう。

画像サイズを標準出力(62~63行目)

コメントの通り、デバッグ用に仕込んでいるだけです。私はソースの実装時に画像の幅と高さの情報が正しく読み込めているのかを確認するのに使っていました。不要となった場合は、コメントアウトするか削除をお願いします。

画像データ本体へジャンプ(65~67行目)

ヘッダのオフセット情報とヘッダのサイズ(54バイト)を元に、画像データ本体へファイルのシーク位置を移動しています。ただほとんどのBMP画像データは、ヘッダ情報のすぐ後から画像データ本体が始まるので “bfOffBitsbfOffBits_int-54” の計算結果はほとんど “0” になるかと思われます。

画像データ処理(69~95行目)

ここから画像データ処理が始まります。元の画像データにゲインをかけるだけの簡単な処理なので、計算後の画素値は直ぐに出力ファイルへ書き込んでいます。

画像処理パラメータ(73行目 )

画像処理に必要なゲイン値を変数に格納しています。ここでは ”gain = 1.5” としているので、元画像よりも1.5倍 明るい画像が得られます。1.0だと元画像そのまま、1.0未満だと元画像よりも暗い画像が得られます。

画像処理のループ回数を設定(79~95行目)

いつものfor文とrange()関数で2重のfor文ループを組む事で、総画素数(=水平画素数×垂直画素数)分のループ処理を実行し、入力画像の全画素へゲイン処理を行います。

BMP画像ファイルは画像の横1ラインのバイト数は4の倍数に揃えなければいけないというルールがあります。そのため、76~78行目ではそのルールを守るために必要となるダミーデータのバイト数を計算しています。

本スクリプトが読み込まるBMP画像データは、1画素3バイト(R,G,Bそれぞれが1バイト)のデータとなるので、まずは水平1ラインの総バイト数(bcWidth_int*3)を4で割った時の余りmodを求め、次にmodが0でない場合は、必要なダミーデータのサイズdummy_sizeを計算します。

画像処理演算部(81行目~91行目)

81~83行目で入力画像BMPファイルからRGBそれぞれの画素値を1バイトずつ読み込んでいます。バイトオーダーは”little”としていますが、1バイトデータなので、”big” でも問題ありません。ただバイトオーダーを引数に与えないとエラーとなります。

85~87行目で入力画像のゲイン処理を行っています。gainパラメタはfloat型なので、演算式をint()で括る事でint型に戻して、その後min()関数で255でクリップ処理をしています。min()関数は 2つ以上の引数を持ち、一番小さい数値を返す組み込み関数です。同様にmax()関数もあるので、値がマイナスを持つような演算をした場合には下限値のクリップに使えます。max(),min()関数は使わずに、条件式を書いてクリップ処理をベタ書きしてもOKです。

画像処理演算結果のファイルへの書き込み

89~91行目で、ライトファイルオブジェクトへ演算結果の書き込みを行っています。1バイトデータの書き込みなので、バイトオーダーは”big”と”little”どちらでも良いです。to_bytes( )メソッドのsigned引数はdefault値がFalseなので省略しています。

ダミーデータの読み出しと書き込み

93~95行目で、77行目で求めたダミーバイト数(dummy_size)分だけ、入力ファイルオブジェクトはシーク位置を進め、出力ファイルオブジェクトにはダミーデータ(255)を書き込んでいます。詰め込む値は何でも良いです。

98,99行目でリード・ライトファイルオブジェクトをそれぞれクロースして、画像処理は終了となります。

まとめ

今までの記事で紹介してきたバイナリファイルの読み出し・書き込みの知識を前提として、身近なバイナリデータのBMP画像ファイルを読みだして画像処理を行い、処理データをBMPファイルとして保存するソースを紹介しました。是非手元のビットマップファイルで本スクリプトを試してみてください。

ゲイン値を変えたり、加減算を行ったり、RGB毎に独立したパラメタを持たせたり、256階調を10階調位に落としてみたりと、色々な画像処理を試してもらえたら嬉しいです。

自作関数もクラスも用いないソースだったので、プログラムをある程度知っている人から見たら、ダサいベタなコードに見えたのではないかと思います。その辺は後日の記事で改善方法を示していきたいと考えています。

それでは今日はこの辺で。
長い記事にお付き合い頂き、ありがとうございました。

タイトルとURLをコピーしました