【解説編】すき焼きの写真にすき焼きの3Dモデルを埋め込む方法

この記事は Tech KAYAC Advent Calendar 2021 の21日目の記事です。

こんにちは!意匠部のおばらです。

今回は アドベントカレンダー14日目の記事 でご紹介した
「すき焼きの写真にすき焼きの3Dモデルなど任意のファイルをバイナリとして埋め込む方法」
の解説記事です。

f:id:tsmallfield:20211220173219p:plain
【解説編】すき焼きの写真にすき焼きの3Dモデルを埋め込む方法

techblog.kayac.com

任意のファイルを ArrayBuffer として取得する

fetch() を使う場合

See the Pen sukiyaki.lib.urlToArrayBuffer.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

これだけ!便利な時代になりました。
XMLHttpRequestoverrideMimeType('text/plain; charset=x-user-defined') でバイナリテキストを取得し charCodeAt() で文字コードに変換して、、とがんばっていた頃が懐かしいです。

(参考: https://www.html5rocks.com/ja/tutorials/file/xhr2/

FileReader を使う場合

input[type="file"] や ドラッグ& ドロップ などで取得した File オブジェクトを ArrayBuffer に変換したい場合は FileReader を使います。

See the Pen sukiyaki.lib.fileToArrayBuffer by Tohl SMALLFIELD (@tsmallfield) on CodePen.

埋め込むフォーマットを決める

サイズ情報を先頭に追加

後で抜き出す際、データ(ArrayBuffer)のサイズ(何バイトか)を知る必要があるため、 埋め込むデータの先頭32ビット(4バイト)にサイズ情報を追加することにします。

See the Pen sukiyaki.lib.embedByteLength by Tohl SMALLFIELD (@tsmallfield) on CodePen.

(※ new Uint32Array(arrayBuffer)[0] = data.byteLength; としたいところですが arrayBuffer の長さが8バイト(32ビット)の整数倍とは限らないためNG)

埋め込むロジック

様々なロジックが考えられますが
今回の解説では簡単のため
・Rチャンネルの最下位1ビットのみ
つまり
・1ピクセルに1ビットずつ埋め込む
ことにします。

f:id:tsmallfield:20211220201855p:plain
1ピクセルに1ビットずつ埋め込む

※(Canvas2Dで埋め込み&抜き出しをする際)Aチャンネルを1以外にすると、経験上データが劣化します。 これは premultiplied alpha の関係かな?と思うのですが検証はできていません。以下の通り検証しました!

乗算済みアルファによるデータの劣化(2021/12/22 追記)

putImageData() でピクセルに埋め込んだデータをgetImageData()で取り出す際
Aチャンネル(アルファ)が 255 より小さいと
乗算済みアルファ(premultiplied alpha)の影響で他の R、G、B チャンネルのデータが劣化する可能性があります。
以下はデータの劣化を再現するサンプルコードです。

See the Pen LossyOperation via putImageData() and getImageData() by Tohl SMALLFIELD (@tsmallfield) on CodePen.

putImageData()でR, G, Bチャンネルのいずれかに埋め込んだ値(0〜255)を source
getImageData()で取り出した値(0〜255)をresult
・ アルファ(0〜255)をalpha
とするとresult
Math.round(Math.round(source * alpha / 255) * 255 / alpha)
ほぼ一致します。

(※ 参考 CanvasRenderingContext2D.putImageData() developer.mozilla.org

埋め込む画像のサイズを決める

f:id:tsmallfield:20211212143612j:plain
すきやきの写真

埋め込むデータのサイズ(バイト数)に応じて生成する画像のピクセル数も変える必要があります。
埋め込むデータのサイズ(byteLength)と元画像のアスペクト比(aspectRatio)から
生成する画像の幅(width)と高さ(height)を求めるコードは以下です。

See the Pen sukiyaki.lib.calcImageSize by Tohl SMALLFIELD (@tsmallfield) on CodePen.

まず単位をバイトからビットに変換します。
(※1バイトは8ビット)

const bitLength = byteLength * 8;

次に必要なピクセル数を計算します。
1ピクセルに1ビット埋め込むので以下となります。

const pixelLength = bitLength;

このとき pixelLength、width、height が次の条件を満たせばよいです。

pixelLength <= width * height

ここで、高さ(height)は 「幅 ÷ アスペクト比」(width / aspectRatio)と書き換えられます。

pixelLength <= width * width / aspectRatio

両辺に aspectRatio を掛けて変形すると

pixelLength * aspectRatio <= width ** 2
Math.sqrt(pixelLength * aspectRatio) <= width

よって

const width  = Math.ceil(Math.sqrt(pixelLength * aspectRatio));
const height = Math.ceil(pixelLength / width);

となります。
<canvas> のサイズ(ピクセル数)には上限があります。

画像のピクセルデータを取得する

画像からピクセルデータをArrayBufferとして抜き出すコードです。

See the Pen sukiyaki.lib.imageToPixelArrayBuffer.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

getImageData(...).data は昔は CanvasPixelArray というオブジェクトだった記憶がありますが、いつの間にか型付配列 Uint8ClampedArray になっていますね!

バイナリデータの埋め込み

地道に1ピクセルに1ビットを埋め込んでいきます。
(※この処理は Worker でやっても良いかもしれません)

See the Pen sukiyaki.lib.embedDataInPixels by Tohl SMALLFIELD (@tsmallfield) on CodePen.

埋め込んだ ArrayBuffer は Canvas2D を使用して画像のBlobに変換します。

See the Pen sukiyaki.lib.arrayBufferToPNGBlob by Tohl SMALLFIELD (@tsmallfield) on CodePen.

(※ 説明の関係で <canvas> を新たに生成していますが getImageData() のために生成した <canvas> を使い回しても良いでしょう)

以上が埋め込むロジックの解説です。

サンプルコード

ドラッグ&ドロップされたファイルをすき焼きの写真に埋め込むツールです。
軽めのファイルをドラッグ&ドロップしてみてください。

See the Pen sukiyaki.pack by Tohl SMALLFIELD (@tsmallfield) on CodePen.

データが埋め込まれたすき焼き画像からバイナリデータを取り出す

埋め込むときと同様 Canvas2D を使いピクセルデータを ArrayBuffer に変換し
Rチャンネルの最下位1ビットをつなぎ合わせていきます。

See the Pen sukiyaki.lib.extractDataFromPixels by Tohl SMALLFIELD (@tsmallfield) on CodePen.

こうして取得したArrayBufferの先頭の32ビット(4バイト)には
埋め込まれたデータのサイズ情報(embeddedByteLength)が入っています。
その情報をもとにデータ部分(embeddedArrayBuffer)を抜き出します。

const embeddedByteLength = new DataView(arrayBuffer).getUint32(0, true);
const embeddedArrayBuffer = arrayBuffer.slice(4, 4 + embeddedByteLength);

抜き出したデータ(ArrayBuffer)は必要に応じてBlobURL.createObjectURL()FileReaderなどで任意のフォーマットに変換するとよいでしょう。

例えば抜き出したデータがSVGであるとわかっている場合は次のようにします。

const blob = new Blob([embeddedArrayBuffer], { type: 'image/svg+xml' });
const url  = URL.createObjectURL(blob);
...

以上が抜き出すロジックの解説です。

サンプルコード

画像から埋め込まれたデータを抜き出すツールです。
データが埋め込まれたすき焼きの写真をドラッグ&ドロップすると
ArrayBufferを抜き出しbinファイルとしてダウンロードできます。

See the Pen sukiyaki.unpack by Tohl SMALLFIELD (@tsmallfield) on CodePen.

まとめ

実際の業務ですき焼きの写真にデータを埋め込む機会はあまりないとは思いますが
バイナリ操作を理解する良いきっかけになるのでぜひ挑戦してみてください!

ビット演算、型付配列、Canvas2D などを使った JavaScript(Node.js含む)でのバイナリ操作は
(特に生の)WebGLを扱う場合でも必要となる機会が多いです。

例えば

(3Dソフトで作成したモデルデータの)VBOのデータ構造を最適化したい
(インターリーブ化、頂点カラーを32ビット浮動小数点ではなく8ビットの符号なし整数にするなど)
(3Dソフトで作成したモデルデータの)VBOに(attribute用の)情報を追加したい
(IDやオブジェクトの中心座標など)
VBO、IBOを0から動的に生成したい
KTXなどのコンテナからGPU向けに圧縮されたテクスチャの各種情報: 画像サイズやフォーマット、gl.compressedTexImage2D() に渡すデータ部分などを抜き出したい
テクスチャに複数の情報を埋め込みたい

などです。

さいごに

前回の記事でご紹介した3Dすき焼きのサンプルコード、
「すき焼きといえば生でしょ!」ということで無駄に生WebGLでくるくる回しているのですが
実は難読化されたJavaScriptのコードの中に秘密のメッセージが埋め込まれているんです。
気づいていただけたでしょうか?

まだの方はぜひ探してみてください!

See the Pen Sukiyaki by Tohl SMALLFIELD (@tsmallfield) on CodePen.

カヤックではデザインもしたい!コードも書きたい!3Dモデリングもしたい!
そんな欲張りなアートディレクター/デザイナーを大募集中です!

www.kayac.com