「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
チャンク構造
ファイルシグネチャの後はチャンクが複数個繰り返す。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 はべたデータ (フィルタなし) という意味。ちゃんと作るならこのへんをちゃんとする必要があると思う。