任意のデータをピクセルとして画像に埋め込んでみた | サイバーエージェント 公式エンジニアブログ

こんにちは、Morino(@kohei_april20)と申します。(だいぶ前にFlashの代替としてのHTML5というエントリを書いて今回2回目です


ちょっと前にスマホブラウザ向けのサービスでアバターを動かすアニメーションの仕組みが必要になった時がありました。それでボーンアニメーションやパラパラ漫画形式のアニメーションのスプライトシートによるアニメーションの仕組みを、Flashのアニメーション素材を元に生成して再生する仕組みを作ったりしていたのですが、その時に画像データにピクセルの色データとしてメタデータを埋め込む仕組みを作ったのでそれの紹介をしたいと思います。(ここ扱う画像はアルファ有りのPNG画像です)

特徴

  • スプライトシートアニメーションは通常スプライトシート画像とメタデータの少なくとも2つのデータを配信する必要があるのですが、このメタデータを画像データに突っ込んでしまえば一気に配信することが可能になり、必要なリクエスト数を減らすことができます。またこれはスプライトシート用途に限らず、汎用性があります。
  • PNG画像の仕様から言えば実はチャンクと呼ばれるデータの塊を独自に付加できるので、データをここに付加することが可能です。しかし、ここにデータを入れてもブラウザからJavaScriptを通じてのアクセスが非常に悪く実用的でなさそうです。(仮にこの方法で出来ても画像とメタデータを両方得るには無駄に2度ロードが必要かも?)そのため、ピクセルの色データを画像として付加することでJavaScriptからのアクセス可能なデータとしてデータ付与ができるようになっています。
下の画像がデータを埋め込んだ例です。画像の下部の帯状になっているのがデータ付与のために追加された部分です。ちなみにこの画像はボーンアニメーション用の画像でキャラクターの骨格のパーツをばらして一枚の画像に収めてあります。付与したデータには各パーツの切り出し位置や骨格を組み上げる初期配置情報が入っています。

データを埋め込んだ画像例



PNG画像へのデータ埋め込みと復元

方法
画像はアニメーションスプライトとしての素材であり背景などとの重ねあわせが想定されアルファ情報が必須であるので、対象のPNGはアルファを持つ1ピクセルあたり4バイトの形式のものとします。 素材画像部分は劣化させる事ができないためそのまま残し、下図のように画像の高さをオリジナルの画像よりも増やし末尾に必要な分だけ追加で領域を確保、付加したいデータをその領域の各ピクセルの色情報に変換して情報を付加します。追加するピクセルはオリジナル画像の幅の倍数になるので、付加部分の端にあまった部分であるアライメントが発生しますが、ここは利用しないのでゼロフィルします。付加情報はどのような形式でも可能ですが汎用性とデータの利用しやすさを考慮してJSONデータを文字列として付与するようにしました。 画像の末尾にデータを付加する関係で、データ復元のしやすさを考えデータを後ろから読む方式を採っています。

データを付与された画像

データ構造
埋め込むデータの構造は下図のようになっています。

データ構造

埋め込み
  1. 埋め込むデータ量を算出(情報が付加されているかを判別するためのシグネチャ、付加情報文字列のデータ長、付加情報本体の総和)
  2. 埋め込むデータ量から、1ピクセル3バイト(本来は4バイトあるが後述の理由により3バイトのみ利用)として必要ピクセル数を算出
  3. オリジナル画像の幅から必要行数を算出
  4. 「幅」×「必要行数」で確保するピクセル数の内の余るデータ長(アラインメント)を算出
  5. アラインメント部分(ゼロフィル)、付加情報、付加情報データ長、シグネチャ(文字列'EMB'の3バイト)を合わせて埋め込みデータを整形
  6. 埋め込みデータを各ピクセルに順にセット(アルファ部分は0を埋めてスキップ)

データ埋め込み部分の抜粋
※Flash素材からデータを生成するツールでAIRアプリケーションによる実装なのでActionScript3です。
public function embedToBitmap(bitmap:BitmapData, stringBytes:ByteArray):BitmapData {
 
    var lengthWithoutAlpha:int = stringBytes.length + STR_LENGTH_SIZE + SIGNATURE_SIZE;
    var numLines:int = Math.ceil(lengthWithoutAlpha / (bitmap.width * RGB_SIZE));
    var fraction:int = lengthWithoutAlpha % (bitmap.width * RGB_SIZE);
    var alignment:int = fraction == 0 ? 0 : bitmap.width * RGB_SIZE - fraction;
 
    var appendDataBytes:ByteArray = new ByteArray();
    var i:int, j:int;
    for (i = 0; i < alignment; i++) {
        appendDataBytes.writeByte(0);
    }
    appendDataBytes.writeBytes(stringBytes, 0, stringBytes.length);
    appendDataBytes.writeShort(stringBytes.length);
    // signature 'EMB'
    appendDataBytes.writeByte(0x45);
    appendDataBytes.writeByte(0x4D);
    appendDataBytes.writeByte(0x42);
 
    var newBitmapData:BitmapData = new BitmapData(bitmap.width, bitmap.height + numLines, true, 0x00FFFFFF);
    newBitmapData.draw(bitmap);
 
    var y:int = bitmap.height;
    var x:int = 0;
    // add data to original bitmap
    for (i = 0; i < appendDataBytes.length; i += 3) {
        var b1:uint = appendDataBytes[i] << 16;
        var b2:uint = appendDataBytes[i+1] << 8;
        var b3:uint = appendDataBytes[i+2];
        var color:uint = 0xff000000 | b1 | b2 | b3;
        // alpha must be 0xFF to avoid rounding RGB values by browser
        newBitmapData.setPixel32(x, y, color);
        // proceed pixel
        ++x;
        if (x >= bitmap.width) {
            x = 0;
            y++;
        }
    }
    return newBitmapData;
}

復元
  1. 末尾より4バイト(アルファ情報は捨てて3バイト)を読み、シグネチャを確認
  2. 更に3バイト(アルファ情報は捨てて2バイト)を読み、データ長を取得
  3. 付加情報領域の行数を算出
  4. データ長分をアルファ情報をスキップして読み進みバイナリ情報を構築して文字列を得る

データ復元部分抜粋
....
var data = context.getImageData(0,0,width,height).data;
 
// data length including image part and extra data part
var length = height * width * RGBA_SIZE;
var currentPosition = length - 4;
var sign = fromCharCode(
    data[currentPosition],
    data[currentPosition + 1],
    data[currentPosition + 2]
);
if (sign !== SIGNATURE) {
    // error handling
    ...
    return;
}
currentPosition = currentPosition - 3;
var strLength = data[currentPosition] << BITS_PER_BYTE | (data[currentPosition + 1]);
var numExtraLines = ceil((strLength + BYTES_STR_LENGTH + BYTES_SIGNATURE_LENGTH) / (width * RGB_SIZE));
var imageHeight = height - numExtraLines;
// data length of extra data part excluding alpha data (1 byte for each pixel 4 bytes)
var extraLength = width * RGB_SIZE * numExtraLines;
// data length of alignment excluding alpha data (1 byte for each pixel 4 bytes)
var alignLength = extraLength - (strLength + BYTES_STR_LENGTH + BYTES_SIGNATURE_LENGTH);
var extraStartPosition = imageHeight * width * RGBA_SIZE;
var strStartPosition = extraStartPosition + alignLength +
    // add alpha data length
    floor(alignLength / RGB_SIZE);
 
var text = '';
currentPosition = strStartPosition;
for (var i = 0; i < strLength; i++) {
    text += fromCharCode(data[currentPosition++]);
    if ((currentPosition - extraStartPosition + 1) % RGBA_SIZE === 0) {
        currentPosition++;
    }
}
...

色データへ変換したデータの復元性とその検証

アルファの値とブラウザの挙動
アルファ情報を持つPNGの各ピクセルの色データはR(赤)・G(緑)・B(青)・A(アルファ)を1バイトずつの計4バイトあり、始めは4バイトすべてを利用してデータを格納しようと試みました。しかし、ブラウザで画像データに含まれるピクセルのデータをピックアップして復元する際に、アルファの値によってRGBの値がわずかに元のデータから変化してしまい元のデータを復元することができないケースが多発しました。これはアルファによる各色への影響を考慮した上でブラウザでの見た目上問題の無い範囲で丸め処理がブラウザによって行われているように思われます。そこでアルファの値を他の色に影響のない0xFFに固定してみたところ完全に元データを復元することができました。この理由から、4バイトのうちの1バイトは捨ててRGBの3バイトのみを利用してデータ埋め込みを行いました。

検証と実用性
検証はRGBの3バイトのとりうるすべてのパターンについて色データから復元したものと元データを照らしあわせ差異の無いことをGoogle Chrome、iOS4・5・6・7のSafari、Android2系・4系のデフォルトブラウザで確認しすることで行いました。また、万一復元のうまくいかないブラウザがあった場合に備えて、サーバ側でスプライトシートだけでなく付加情報だけを取得できるように配信をしておき、クライアント側で復元にエラーが発生した場合にのみ付加情報を別途サーバにリクエストするようにしておけば安全を期すことができます。実用性という点に関しては現在「ペコロッジ」というサービスで実運用しているので一応運用実績もあります。※ちなみにペコロッジはまもなくクローズしてしまいます(´;ω;`)(執筆時点での情報です)

データの正当性
復元の際のデータの正当性に関してはシグネチャの有無、復元処理でのエラーの有無、復元後のテキストデータのパースでのエラーの有無によって判定することができます。これだけでも確率的には運用上問題ないと判断して上記のようなデータ構造を採っていますが、CRC検査などを入れればより堅実なチェックが出来るようになるのではないかと思います。



以上、スプライトシートに限らずメタデータとPNG画像がセットで必要になる場合にはこんな配信の仕方もあるよ、という紹介でした。
ちなみに画像などのコンテンツにデータを埋め込むことを電子透かしと言いますが、これも一応電子透かしと呼んで良いのでしょうかね・・?ただデータを末尾に追加してるだけですが。