« ^ »
[WIP]

小さいPNGを手で作りながらPNGについて考える

所要時間: 約 5分

TL;DR

  • PNGを手で作りながら仕様を学んだ。
  • 各種チャンクのデータをどのように生成すればよいか分かった。
  • CRCの計算には自分で計算するのは大変だったためPythonのbinasciiを使用した。


たまたま画像を返すダミーサーバを実装していた。このような場合、画像ファイルを事前に準備しておくか、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ピクセルのため小さ過ぎて見えないかもしれない。

↓画像

https://res.cloudinary.com/symdon/image/upload/v1673696933/blog.symdon.info/1673656503/test_q7edv1.png

↑画像

まとめ

今回は所々Pythonの力を借りながらではあるが、PNG画像を手で作成つつ、仕様を学んだ。このような事をしても何かの役に立つということは無いけれど、自分の知識を深める事には繋がった。