この記事は Tech KAYAC Advent Calendar 2021 の21日目の記事です。
こんにちは!意匠部のおばらです。
今回は アドベントカレンダー14日目の記事 でご紹介した
「すき焼きの写真にすき焼きの3Dモデルなど任意のファイルをバイナリとして埋め込む方法」
の解説記事です。
任意のファイルを ArrayBuffer として取得する
fetch() を使う場合
See the Pen sukiyaki.lib.urlToArrayBuffer.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.
これだけ!便利な時代になりました。
XMLHttpRequest
と overrideMimeType('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ビットずつ埋め込む
ことにします。
※(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
埋め込む画像のサイズを決める
埋め込むデータのサイズ(バイト数)に応じて生成する画像のピクセル数も変える必要があります。
埋め込むデータのサイズ(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
)は必要に応じてBlob
やURL.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モデリングもしたい!
そんな欲張りなアートディレクター/デザイナーを大募集中です!