Canvas convert to Bitmap BLOB

Javascriptで大量のサムネイル画像を生成する必要があったのでそのお話です。


Javascriptで画像のサムネイルを作成する場合の定番的な手法として、

  1. Imageを作成
  2. CanvasにImageを書き込み
  3. Canvasで変形
  4. CanvasからData URLに変換

という流れがあります。

var img = new Image(); img.src = '/path/to/filename';
img.onload = function() {
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  var w = maxWidth/this.naturalWidth;
  var h = maxHeight/this.naturalHeight;
  var rate = w <= h ? w : h;
  rate = rate < 1 ? rate : 1;
  canvas.width = Math.round(this.naturalWidth*rate);
  canvas.height = Math.round(this.naturalHeight*rate);
  ctx.drawImage(this, 0, 0, this.naturalWidth, this.naturalHeight, 0, 0, canvas.width, canvas.height);

  if (this.src.match(/^data:image\/jpeg;/)) {
    this.src = canvas.toDataURL('image/jpeg', 1.0);
  } else {
    this.src = canvas.toDataURL();
  }
};

ただし、この方法は凄い重いです。 一つの画像のサムネイルを作るだけなら気になるほどの重さではないのですが、何枚もサムネイルを作るとメインスレッドが完全に固まってしまうほどの重さになります。 何が重いかというとcanvas.toDataURLがこの処理の大部分をしめる原因になります。 (drawImageで変形すると重いのでsetTransform使う必要があるけど、その話は割愛)

他の方法としてW3Cの方をみるとcanvas.toBlobというメソッドで非同期に生成できますが、実装されているブラウザがないので自力で別の方法を実装する必要があります。 そのため、凄い面倒臭いのですがCanvasの画像データから自力で画像のバイナリを作成します。

Get image data from Canvas

まずはCanvasから画像のピクセルデータを取得します。

var ctx = canvas.getContext('2d');
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

取得したimageDataimageData.dataからRGBAで4要素を取る1次元配列を取得することができます。

var red   = imageData.data[0];
var green = imageData.data[1];
var blue  = imageData.data[2];
var alpha = imageData.data[3];

Create Bitmap header

画像のピクセルデータの準備が出来たら、次にBitmap画像を作成していきます。 まずはBitmapのヘッダーを生成します。

function getLittleEndianHex(value) {
  var result = [];
  for (var bytes = 4; bytes > 0; bytes--) {
    result.push(String.fromCharCode(value & 255));
    value >>= 8;
  }
  return result.join('');
}
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
var bodyByteSize = imageData.data.length;
// create header string
var header =
 'BM' + 
 getLittleEndianHex(bodyByteSize)
 '\x00\x00' +
 '\x00\x00' + 
 '\x36\x00\x00\x00' +
 '\x28\x00\x00\x00' +
 getLittleEndianHex(canvas.width) +
 getLittleEndianHex(canvas.height) +
 '\x01\x00' + 
 '\x20\x00' + 
 '\x00\x00\x00\x00' + 
 '\x00\x00\x00\x00' +
 '\x13\x0B\x00\x00' +
 '\x13\x0B\x00\x00' +
 '\x00\x00\x00\x00' +
 '\x00\x00\x00\x00'; 

※ 参考サイト

Write Bitmap data to ArrayBuffer

これで準備が出来たので、ArrayBufferにBitmapのヘッダと画像のピクセルデータを書き込んでいきます。

// create buffer
var buf = new ArrayBuffer(byteSize + header.length + 4 - (header.length % 4);
var data8 = new Uint8Array(buf);
var data32 = new Uint32Array(buf);
// write header
for (var i = 0; i < header.length; i++) {
  data8[i] = header.charCodeAt(i);
}
// write image data
for (var x = 0, z = Math.ceil(header.length % 4); x < width; x++) {
  for (var y = 0; y < height; y++) {
    var i = (width * y) + x;
    var j = (width * (height - y)) - (width - x) + z;
    data32[j] = (imageData.data[(i*4)+1] << 24)
                | (imageData.data[(i*4)+2] << 16)
                | (imageData.data[(i*4)+3] <<  8)
                | (imageData.data[(i*4)+0]) || -1;
  }
}

注意点としてUint32Arrayを生成するときは4バイト単位の長さにすること。 BitmapとCanvasのピクセルデータでデータではy座標が逆になるという点に気をつけてください。

Convert ArrayBuffer to Blob and Blob URL

最後に作成したArrayBufferをBlobに変換し、そこからBlob URLに変換します。

var b = new Blob([buf, {type: 'image/bmp'});
var url = URL.createObjectURL(b);
var img = new Image(); img.src = url;
img.onload = function() {
  URLrevokeObjectURL(img.src);
}

URL.createObjectURLで作成できるBlob URLの数は決まっているため、Blob URLを作成したら必ずすぐにURLrevokeObjectURLで解放してあげる必要があります。 きちんと計測した訳ではありませんが200〜400ぐらいでBlob URLの確保に失敗して読み込めないURLが出来始めます。 (URL.createObjectURL自体は成功するので、読み込むまで失敗するかどうかわかりません)

Exceute on worker

これでCanvasからBlob URLへの変換ができましたが、canvas.toDataURLよりもパフォーマンスが悪化しています。 そこで、Workerを使って別スレッドで処理を行います。 ちょうどimageDataがTransferableなオブジェクトのため、Workerに大きなデータを渡してもそこまで遅延せずにデータのやり取りを行えます。

// worker function
function canvasToBlob() {
  self.addEventListener('message', function(event) {

    // create bitmap ...

    self.postMessage({buffer: buf}, [buf]);
  });
}
// generate worker
var workerFnString = '(' + canvasToBlob.toString() + ')()';
var workerblob = new Blob([workerFnString], {type: 'text/javascript'});
var workerUrl = URL.createObjectURL(workerblob);
var worker = new Worker(workerUrl);
// revoke url
URL.revokeObjectURL(workerUrl);
worker.addEventListener('message', function(e) {
  var b = new Blob([e.data.buffer], {type: 'image/bmp'});
  var url = URL.createObjectURL(b);
  // apply object
  var img = new Image(); img.src = url;
  img.onload = function() {
    URL.revokeObjectURL(img.src);
  });
});
worker.postMessage({imageData: imageData}, [imageData.data.buffer]);

これでメインスレッドをブロックせずに実行できるようになりました。

※参考サイト


他にもリサイズ処理をWorkerでやったり、同時実行数を制限することでメインスレッドを固めるのを防ぐことができます。 将来的にはWorker上でCanvasを操作できるCanvasProxyや、CanvasからBlobを生成できるtoBlobがきたらもう少しパフォーマンスを良くできると思います。

しかし、まさかJSでBitmapの仕様を調べることになるとは思いませんでした。。。 基本的にブラウザアプリケーションではサーバーサイドでサムネイルを生成するのがベストな選択なので、どうしてもクライアントサイドで生成しないといけない理由がないときはサーバーサイドで生成するようにしてください。