雑念日記

主に技術的なことをつらとらと(書ければいいな)。

PNG を自力で読んで表示しよう その1

この記事の内容に関するコードはこちら → display PNG image withou using PImage.

PNG 画像の構造を整理しつつ、(ある程度)自力で表示する試み。
車輪の再発明?N番煎じ?なんぼのもんじゃい!

PNG の基本

PNG 画像は最初の 8 バイトを除いてチャンクという基本要素から成っています。

謎の 8 byte
チャンク 1
チャンク 2
...
チャンク N

チャンク

チャンクにはいくつかの種類がありますが、すべて以下のような構造をしています。

4 byte length
4 byte type(name)
N byte data
4 byte CRC

最初の 4 バイトは当該チャンクのデータ部の長さを保持しています。
次の 4 バイトはチャンクのタイプ(後述)を保持しています。
次は length に記録されていた長さ分だけ、チャンクデータを保持しています。
最後の 4 バイトは CRC と呼ばれる部分です。
type と data から算出される値が記録されており、ここの値を見てデータの正しさをチェックすることが可能です。

必須チャンク

PNG 画像には、必ずそれを含んでいなくてはならない「必須チャンク」と、オプション的な「補助チャンク」が存在します。

以下の3つが必須チャンクです。

タイプ 意味 サイズ
IHDR 画像の幅、高さ、カラータイプといった画像情報を記録 25 byte (固定)
IDAT 画像の実データを記録 可変長
IEND 画像の終端を表す。data 部は必ず 0 byte 。 12 byte (固定)

シグネチャ

冒頭に先頭 8 バイトはチャンクではないと書きました。
ではその 8 バイトは何かというと、PNG かどうかを識別するためのシグネチャです。

PNG 画像の場合、この 8 バイトは必ず以下のようになっています。

10進 16進 ASCII 文字コード
137 89 非 ASCII 文字。
先頭が非 ASCII 値になることで、テキストファイルと明確に区別ができるらしい。
80 50 P
78 4e N
71 47 G
13 0d CR (Ctrl-M or ^M)
10 0a LF (Ctrl-J or ^J)
26 1a Ctrl-Z or ^Z
10 0a LF (Ctrl-J or ^J)

最小構成

以上より、以下が最小構成でもっともシンプルな PNG 画像となります。

8 byte シグネチャ
25 byte IHDR
N byte IDAT
12 byte IEND

ちなみに、IDAT チャンクは複数存在することもでき、その場合はすべての IDAT チャンクの data 部を連結したものが全画像データとなります。

処理の流れ

  1. PNG 画像ファイルをバイナリで読み込む
  2. シグネチャを解析して PNG 画像であることを確認する
  3. ループでチャンク毎に読み込む
    • チャンクの type で分岐
      • IHDR の場合 => 画像情報を取得する
      • IDAT の場合 => 画像データとしてあとで処理するよう保持する
      • IEND の場合 => チャンクの読込ループ終了
  4. 画像データは圧縮されているため、復元する
  5. 画像情報と画像データを基に、画像を表示する

サンプルコード

ではさっそく PNG を読み進めてみましょう。
コードは Processing ですが、ほぼ Java ですね。
ちなみに書き始めてすぐに Ruby でやればよかったと後悔しました。

display PNG image withou using PImage.

1. PNG 画像ファイルをバイナリで読み込む

loadBytes() を使います。
byte列が配列で返ってくるので、それを追っかけるためのインデックス idx もここで初期化してます。

  byte b[] = loadBytes("Lenna.png"); 
  int idx = 0;

2. シグネチャを解析して PNG 画像であることを確認する

ここでは自分で定義した readAsHexString() と isPng() という関数を使っています。
readAsHexString() は byte 列を指定した長さだけ読み込んで 16 進数の文字列に変換して返します。
isPng() は引数で渡した 16 進数の文字列が PNG のファイルシグネチャと等しい場合は true を、そうでない場合は false を返します。

  // シグネチャをチェック
  String str = readAsHexString(b, idx, FILE_SIGNATURE_SIZE);
  if (!isPng(str)) {
    println("ERROR: This file is broken, or not PNG.");
    return;
  } else {
    idx += FILE_SIGNATURE_SIZE;
  }

3. ループでチャンク毎に読み込む

今回はシンプルにするために必須チャンクしか読み込みません。
つまり複雑な画像はちゃんと表示できません。

  int length = -1;
  int iChankType = -1;
  HeaderInfo imageInfo = null;
  byte[] idat = null;
  // データの末尾までチャンク単位で読み込む
  while (idx >= 0) {
    length = readChankDataLength(b, idx);
    iChankType = readChankType(b, idx + LENGTH_SIZE);
    switch (iChankType) {
    case IHDR:
      // 画像ヘッダから画像情報(幅、高さ、...etc)を取得
      imageInfo = readIHDR(b, idx);
      break;
    case IDAT:
      // 画像データ
      if (idat == null) {
        idat = readIDAT(b, idx, length);
      } else {
        // IDAT チャンクは複数存在する可能性がある
        idat = concat(idat, readIDAT(b, idx, length));
      }
      break;
    case IEND:
      // 画像終端
      idx = -1;
      break;
    default:
      break;
    }
    // IEND まで到達していない場合は idx を加算してループ
    if (idx >= 0) {
      int chankSize = LENGTH_SIZE + CHANK_TYPE_SIZE + length + CRC_SIZE;
      idx += chankSize;
    }
  }

ここではチャンク毎に以下の処理を行なっています。

readChankDataLength() で当該チャンクのデータ部の長さを取得する。
readChankType() で当該チャンクのタイプを取得する。
チャンクタイプが IHDR なら readIHDR() で画像情報を取得。
チャンクタイプが IDAT なら readIDAT() で画像データを取得。
このとき、2個目以降の IDAT だった場合は、すでに持ってる画像データに連結させる形で保持します。
チャンクタイプが IEND なら byte 列を読み込むのに使用するインデックス idx を -1 に設定してループを終了。
ループが未終了なら、インデックスをチャンクサイズ分だけ進めて次のチャンクの読込み。

4. 画像データは圧縮されているため、復元する

PNG 画像の画像データは Deflate という手法で圧縮されているため、Inflate という手法で復元してやる必要があります。

上で取得した画像データ、すなわち IDAT チャンクから取得したデータを復元してやります。
getByteArrayFromDeflatedData() の中身はソースを参照のこと。

  // IDAT から読んだデータは圧縮されているため展開する
  byte[] imageData = getByteArrayFromDeflatedData(idat);

5. 画像情報と画像データを基に、画像を表示する

ここでようやく Processing らしくなりますが、最後にIDAT から取得した画像データを color に変換します。
IDAT から取得した画像データは先頭から順番に R, G, B, R, G, B,... と色値が並んでいるため、3つ区切りで color(R, G, B) に変換してやれば良いです。

  // 展開したデータを color に変換
  color[] colors = new color[imageInfo.imageWidth * imageInfo.imageHeight];
  for (int i = 0; i < imageInfo.imageHeight; i++) {  
    int filterType = 0;
    for (int j = 0; j < imageInfo.imageWidth; j++) {
      int rowHead = (i * (imageInfo.imageWidth * 3 + 1));
      // 行の先頭は色の持ち方
      if (j == 0) {
        filterType = readAsInt(imageData, rowHead, 1);
      }
      // TODO: filterType によって処理は変更する必要がある
      int p = rowHead + (j * 3 + 1);
      color c = color(readAsInt(imageData, p,     1),  // R
                      readAsInt(imageData, p + 1, 1),  // G
                      readAsInt(imageData, p + 2, 1)); // B
      colors[(i * imageInfo.imageWidth) + j] = c;
    }
  }

  // 最後に画像を表示
  size(imageInfo.imageWidth, imageInfo.imageHeight);
  for (int i = 0; i < imageInfo.imageHeight; i++) {
    for (int j = 0; j < imageInfo.imageWidth; j++) {
      set(j, i, colors[i * imageInfo.imageWidth + j]);
    }
  }

使ってみる

それでは 256x256 の Lenna さんを表示してみましょう。

f:id:hoshi_sano:20130816111358p:plain

できました!
本来なら以下の2行で済むことを、無駄な労力を使ってできました!

PImage img = loadImage("Lenna.png");
image(img, 0, 0);

では次に...

ここに Lenna さんを2倍に拡大した画像があるじゃろ?

( ^ω^)
⊃ □ ⊂

これを

( ^ω^)
≡⊃⊂≡

こうj...ぎゃあああああ

f:id:hoshi_sano:20130816111405p:plain

既に述べている通り、ここでは最低限の解析しかしていないため、最もシンプルな PNG 画像以外は正しく表示できません。
上の画像は画像加工ソフトでサイズを2倍にした際に、より圧縮率が高い方法で保存されてしまったためにこのような表示になってしまったものと思われます。

じゃあどうすればよいかというと、次の記事に続きます。