たまたま画像を返すダミーサーバを実装していた。このような場合、画像ファイルを事前に準備しておくか、pillowなど画像ライブラリを用いて生成し、そのデータを使用する。普段は何気なく画像ファイルを扱っていて、画像ファイルの形式の種類について、その名称とざっくりとした特徴ぐらいは知っている。しかし、その形式の中身については理解できていない。もし画像ファイルの形式を理解していれば、それらを使わず手で画像ファイルを生成できるとふと思った。そこで、これは良い機会と考え、画像の形式について手を動かしながら学ぶ事にした。画像ファイルの形式は何でも良かったのだが、とっかかりやすそうなPNG画像を扱うことにした。
普及している画像の仕様は、たいていWebで公開されている。PNGも同様でPNGは幾つかのバージョンがあり、RFCとしても承認されている。wikipediaには、このあたりの情報が詳しく記述されている1。libpngのWebページも色々と詳しい情報が掲載されており、このあたりから情報を辿るのも良さそうだった2。また今回やろうとしている事と同じような事を、過去にしている人は結構いて、それらのサイトがとても参考になった3 4 5 6 7。
PNGファイル署名
PNGファイルの先頭は常に同じデータから始まる。このデータがあることで、このファイルがPNG形式であると判定できる。
return b"\x89PNG\r\n\x1a\n"
b'\x89PNG\r\n\x1a\n'
チャンク
PNGは、チャンクというデータの塊を繋げることで、画像を表現する。チャンクデータの長さ、チャンクの型、チャンクのデータ、チャンクのCRCという形式で構成される。
+----------------------------------------------------------------------|
| |
| チャンクデータの長さ: 圧縮後のチャンクデータのみの長さ |
| 4バイト/符号なし整数型/ビッグエンディアン |
| |
+----------------------------------------------------------------------+
| |
| チャンクの型: 次のいずれか(IHDR, IDAT, IEND) |
| 4バイト |
| |
+----------------------------------------------------------------------+
| |
| チャンクのデータ |
| 可変長 |
| |
+----------------------------------------------------------------------+
| |
| チャンクのCRC: チャンクの型とチャンクのデータからCRCを算出する |
| 4バイト/符号なし整数型/ビッグエンディアン |
| |
+-----------------------------------------------------------------------
画像ヘッダチャンク
画像の幅、高さ、ビット深度など画像全体の情報をヘッダとして設定する。ここでは画像の幅と高さを2x2のサイズとなるように指定した。
return (
b"\x00\x00\x00\x02" # 画像の幅
b"\x00\x00\x00\x02" # 画像の高さ
b"\x01" # ビット深度 (1, 2, 4, 8, 16)
b"\x00" # カラータイプ (0, 2, 3, 4, 6)
b"\x00" # 圧縮手法
b"\x00" # フィルター手法
b"\x00" # インターレース手法
)
b'\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00'
チャンクのデータの長さを取得する。この値は後で符号無し整数をビッグエンディアンとして、チャンクの先頭に追加する。そのためstructモジュールを使用してバイト列に変換している。
data = b'\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00'
import struct
val = len(data)
return struct.pack(">I", val)
b'\x00\x00\x00\r'
次にCRCを計算する。このチャンクは画像ヘッダで、画像ヘッダ用のチャンクの型は IHDR
と決められている。CRCはチャンクの型とチャンクのデータを連結した状態で計算する。
data = (
# チャンクの型
b'IHDR'
# チャンクのデータ
b'\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00'
)
import struct
import binascii
val = binascii.crc32(data)
return struct.pack(">I", val)
b'Z\xcd0\x89'
最後に全てのデータを繋ぎ合わせると画像ヘッダ用のチャンクが完成する。
return (
# チャンクの長さ
b'\x00\x00\x00\r'
# チャンクの型
b'IHDR'
# チャンクのデータ
b'\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00'
# チャンクのCRC
b'Z\xcd0\x89'
)
b'\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00Z\xcd0\x89'
画像データチャンク
ここでは画像の実際のデータを取り扱う。画像のデータはzlib形式として圧縮する必要がある。zlib形式ではDeflate圧縮アルゴリズムでデータを圧縮し、そのデータにヘッダーとフッターを追加する。
前述のIHDRチャンクでビット深度は1を指定したため、0が黒、1が白になるような形式の画像となる。また2x2の画像サイズとしたため、画像自体のデータは4バイトなのだが、zlib圧縮するためデータは増える。通常、画像サイズはもっと大きいため、zlib圧縮によってサイズは小さくなる。ここでは画像データは以下のデータとする。
(注意) この部分には間違いがあるとご指摘を頂きました。ぱっと見たところ、そのご指摘は正しく、この記事の記述に間違いがありそうなのですが、現在確認中です。確認でき次第、修正いたします。また、ご指摘をくださった方に感謝します。
return b"\x00\x01\x00\x01"
b'\x00\x01\x00\x01'
このデータをzlib形式に圧縮する。
data = b'\x00\x01\x00\x01'
import zlib
return zlib.compress(data)
b'x\x9cc`d`\x04\x00\x00\x08\x00\x03'
チャンクのデータの長さを取得する。
data = b'x\x9cc`d`\x04\x00\x00\x08\x00\x03'
import struct
val = len(data)
return struct.pack(">I", val)
b'\x00\x00\x00\x0c'
チャンクのデータの先頭にチャンクタイプ IDAT
を追加しCRCを計算する。
data = (
# チャンクの型
b"IDAT"
# チャンクのデータ
b'x\x9cc`d`\x04\x00\x00\x08\x00\x03'
)
import struct
import binascii
val = binascii.crc32(data)
return struct.pack(">I", val)
b'\x0e\xd8\xec\xf1'
最後に全てのデータを繋ぎ合わせ、画像データ用のチャンクを完成させる。
return (
# チャンクの長さ
b'\x00\x00\x00\x0c'
# チャンクの型
b"IDAT"
# チャンクのデータ
b'x\x9cc`d`\x04\x00\x00\x08\x00\x03'
# チャンクのCRC
b'\x0e\xd8\xec\xf1'
)
b'\x00\x00\x00\x0cIDATx\x9cc`d`\x04\x00\x00\x08\x00\x03\x0e\xd8\xec\xf1'
画像終端
チャンクの末尾には終端を示すチャンクを挿入する。チャンクの型に IEND
を指定する。終端を示すチャンクにはチャンクのデータはない。つまり画像終端のCRCは、チャンクの型である IEND
を元に計算されるため常に同じ値となる。
import struct
import binascii
chunk_type = b"IEND"
return struct.pack(">I", binascii.crc32(chunk_type))
b'\xaeB`\x82'
また、チャンクのデータの長さは0となるため、終端ヘッダ自体が常に同じ値となる。
return (
# チャンクのデータの長さは0
b"\x00\x00\x00\x00"
# チャンクの型
b"IEND"
# チャンクのCRC
b'\xaeB`\x82'
)
b'\x00\x00\x00\x00IEND\xaeB`\x82'
PNG全体
PNGファイル署名と取得した各チャンクを繋げることで、PNGファイルが完成する。
return (
b'\x89PNG\r\n\x1a\n'
b'\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00Z\xcd0\x89'
b'\x00\x00\x00\x0cIDATx\x9cc`d`\x04\x00\x00\x08\x00\x03\x0e\xd8\xec\xf1'
b'\x00\x00\x00\x00IEND\xaeB`\x82'
)
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00Z\xcd0\x89\x00\x00\x00\x0cIDATx\x9cc`d`\x04\x00\x00\x08\x00\x03\x0e\xd8\xec\xf1\x00\x00\x00\x00IEND\xaeB`\x82'
正しく生成できていることを確認するため、pillowを用いてデータを読み込んでみる。
data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00Z\xcd0\x89\x00\x00\x00\x0cIDATx\x9cc`d`\x04\x00\x00\x08\x00\x03\x0e\xd8\xec\xf1\x00\x00\x00\x00IEND\xaeB`\x82'
import io
from PIL import Image
fp = io.BytesIO(data)
return Image.open(fp)
<PIL.PngImagePlugin.PngImageFile image mode=1 size=2x2 at 0x100860520>
正しく読み込まれたことが確認できた。形式が正しくないと、pillowは UnidentifiedImageError
例外を送出する。その場合、生成方法に誤りはないか確認するとよい。ファイルに書き出したければ、 Image.open()
が返しているオブジェクトの save()
メソッドを使用すると、ファイルとして書き出すことができる。
data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x01\x00\x00\x00\x00Z\xcd0\x89\x00\x00\x00\x0cIDATx\x9cc`d`\x04\x00\x00\x08\x00\x03\x0e\xd8\xec\xf1\x00\x00\x00\x00IEND\xaeB`\x82'
import io
from PIL import Image
fp = io.BytesIO(data)
im = Image.open(fp)
im.save("test.png")
この画像を一応掲載するが、サイズが2ピクセルx2ピクセルのため小さ過ぎて見えないかもしれない。
↓画像
↑画像
まとめ
今回は所々Pythonの力を借りながらではあるが、PNG画像を手で作成つつ、仕様を学んだ。このような事をしても何かの役に立つということは無いけれど、自分の知識を深める事には繋がった。