Ruby で png 画像を自力で生成する

png のフォーマットは gif に比べて難しい」などと聞いたことがありましたが、zlib が使える処理系なら、簡単な png 画像はそこそこ簡単に (ビット演算など不要で) 作れるみたいです。

2015/03/12追記:この記事は単なる知的好奇心で自力 png 生成しているに過ぎません。以下のサンプルコードもデモに過ぎないので、実用目的で使うことはおすすめしません。Ruby でベタデータの png 化をしたい人は chunky_png を、綺麗な絵を書きたい人は rcairo を使うといいと思います。

まずはサンプルコード

黒から赤へのグラデーション画像を作るプログラム。

# coding: UTF-8
require "zlib"

width, height = 100, 20
depth, color_type = 8, 2

# グラデーションのベタデータ
line = (0...width).map {|x| [x * 255 / width, 0, 0] }
raw_data = [line] * height

# チャンクのバイト列生成関数
def chunk(type, data)
  [data.bytesize, type, data, Zlib.crc32(type + data)].pack("NA4A*N")
end

# ファイルシグニチャ
print "\x89PNG\r\n\x1a\n"

# ヘッダ
print chunk("IHDR", [width, height, 8, 2, 0, 0, 0].pack("NNCCCCC"))

# 画像データ
img_data = raw_data.map {|line| ([0] + line.flatten).pack("C*") }.join
print chunk("IDAT", Zlib::Deflate.deflate(img_data))

# 終端
print chunk("IEND", "")

生成結果。

$ ruby19 gradation.rb > gradation.png


ファイルシグネチャ

"\x89PNG\r\n\x1a\n" の 8 文字。臼NG はみんな見たことあるはず。Ruby ならこうする。

print "\x89PNG\r\n\x1a\n"

チャンク構造

ファイルシグネチャの後はチャンクが複数個繰り返す。1 つのチャンクは

  • チャンク長 (4 バイト)
  • チャンクタイプ (4 バイト)
  • データ (チャンク長の示すバイト数)
  • CRC32 (4 バイト)

という構造になっている。整数は全部ビッグエンディアン。チャンク長はデータ部分だけの長さを表し、CRC32 はチャンクタイプとデータまでのバイト列の CRC を表すところに注意。
以下はチャンクタイプとデータを受け取ってチャンクのバイト列を返す関数。

require "zlib"
def chunk(type, data)
  [data.bytesize, type, data, Zlib.crc32(type + data)].pack("NA4A*N")
end

Array#pack を使うと簡単。pack フォーマットの N は 32 ビット整数のビッグエンディアン表現。A は ASCII 文字列。CRC32 の計算は zlib にそういう関数があります。
1 つの png ファイルは、IHDR (ヘッダ) 、IDAT (画像データ) 、IEND (終端) 、の 3 つのチャンクタイプのチャンクを必ず持つ。それ以上のチャンクを持つこともある。

IHDR チャンク

png のヘッダ情報。以下の 13 バイトのバイト列。

  • 画像の横幅 (4 バイト)
  • 画像の縦幅 (4 バイト)
  • ビット深度 (1 バイト)
  • カラー・タイプ (1 バイト)
  • 圧縮方式 (1 バイト)
  • フィルタ方式 (1 バイト)
  • インターレース方式 (1 バイト)

ビット深度は 1 、2 、4 、8 、16 のどれか。普通は 8 か 16 だと思う。
カラータイプはビット深度と関係するけれど、白黒なら 0 、カラーなら 2 と思えばよさそう。アルファ値が必要ならそれぞれ 4 と 6 みたいだけど試したことはない。詳細は仕様書を参照のこと。
圧縮方式は 0 (deflate/inflate) しか定義されていない。フィルタ方式も 0 (デフォルト) のみ。インターレース方式は 0 (非インターレース) しか試してないから知らない。知りたい人は自分で調べて。
ここでは 100 x 20 の 8 ビット深度カラー画像を作るとする。

print chunk("IHDR", [100, 20, 8, 2, 0, 0, 0].pack("NNCCCCC"))

IDAT チャンク

画像データ部分。まずは圧縮前のデータを作る。

  • 各行の先頭に 1 バイトの 0 をつける。*1
  • 各行の RGB を指定したビット深度で並べる。
  • 全行のデータを結合したら圧縮前データの完成。

言葉よりプログラムで見るほうが早いです。ここでは、各ピクセルを 0 〜 255 の 3 つの整数 (RGB) であらわす行列があるとします。なんかこんな感じの。

raw_data = [
  [ [0, 0, 0], [0, 0, 1], [0, 0, 2], ... ],
  [ [0, 1, 0], [0, 1, 1], [0, 1, 2], ... ],
  [ [0, 2, 0], [0, 2, 1], [0, 2, 2], ... ],
  ...
]

これをこういう形にする。

img_data = [
  0,  0,0,0, 0,0,1, 0,0,2, ...,
  0,  0,1,0, 0,1,1, 0,1,2, ...,
  0,  0,2,0, 0,2,1, 0,2,2, ...,
  ...
]

これをバイナリ列にする。今回は 8 ビット画像なので C* で pack 。16 ビット画像なら n* になると思う。4 ビット以下はビット演算が必要になる。

img_data = raw_data.map {|line| ([0] + line.flatten).pack("C*") }.join

これで得られた圧縮前データを Zlib::Deflate.deflate で圧縮すれば完成。

print chunk("IDAT", Zlib::Deflate.deflate(img_data))

IEND チャンク

終端情報だけ。データ部は空。

print chunk("IEND", "")

以上。簡単ですよね。

まとめ

簡単な png 画像を自力で生成する Ruby プログラムの解説でした。
画像生成なんて普通はライブラリを使うので、自力で生成しないといけないシチュエーションは少ないと思います。でも (非常に残念ながら) Ruby には標準的な画像ライブラリがないので、こういうのもたまにはありかもしれません。
ちなみに Q(uine)R(uby) code でもこの方法で png を自力生成しています (というか Q(uine)R(uby) code のために調べた) 。

おまけ

光の波長を RGB で近似してスペクトルっぽい画像を作ってみた。

# coding: US-ASCII

# http://www.physics.sfasu.edu/astro/color/spectra.html
def wavelength_to_rgb(n)
  rgb = case
  when n < 380 then [0.0               , 0.0               , 0.0               ]
  when n < 440 then [(440.0 - n) / 60.0, 0.0               , 1.0               ]
  when n < 440 then [(440.0 - n) / 60.0, 0.0               , 1.0               ]
  when n < 490 then [0.0               , (n - 440.0) / 50.0, 1.0               ]
  when n < 510 then [0.0               , 1.0               , (510.0 - n) / 20.0]
  when n < 580 then [(n - 510.0) / 70.0, 1.0               , 0.0               ]
  when n < 645 then [1.0               , (645 - n) / 65.0  , 0.0               ]
  when n < 780 then [1.0               , 0.0               , 0.0               ]
  else              [0.0               , 0.0               , 0.0               ]
  end

  factor = case
  when n < 380 then 0.0
  when n < 420 then 0.3 + 0.7 * (n - 380.0) / 40.0
  when n < 700 then 1.0
  when n < 780 then 0.3 + 0.7 * (780.0 - n) / 80.0
  else              0.0
  end

  rgb.map {|c| c == 0.0 ? 0 : 255 * ((c * factor) ** 0.8) }
end


# generate png

require "zlib"
width, height = 100, 20
depth, color_type = 8, 2

line = (0...width).map {|x| wavelength_to_rgb(380 + x * 400 / width) }
raw_data = [line] * height

def chunk(type, data)
  [data.bytesize, type, data, Zlib.crc32(type + data)].pack("NA4A*N")
end

print "\x89PNG\r\n\x1a\n"
print chunk("IHDR", [width, height, 8, 2, 0, 0, 0].pack("NNCCCCC"))
img_data = raw_data.map {|line| ([0] + line.flatten).pack("C*") }.join
print chunk("IDAT", Zlib::Deflate.deflate(img_data))
print chunk("IEND", "")

*1:この行のデータの形式 (フィルタ) をあらわす情報らしいけど 0 以外を試したことはない。0 はべたデータ (フィルタなし) という意味。ちゃんと作るならこのへんをちゃんとする必要があると思う。