まえがき
以下の解説はいたしません。
- RGB
- ビットマップフォーマットのヘッダーレイアウト
- ビットマップフォーマットの画像データレイアウト
- リトルエンディアンとビッグエンディアン
背景
node-canvasをつかってSVGファイルをビットマップファイルに変換する - QiitaでCanvasの[ImageData](ImageData - Web APIs | MDN)から、32ビット ビットマップフォーマットのエンコードへの変換を実装しました。
32ビット ビットマップの問題
32ビット ビットマップフォーマット では、1つの画素につき4バイト、32ビット幅の領域で色情報を表現します。4バイトの内訳は、「RGBAの4つの情報にそれぞれ1バイトずつ」と思いきや、4番目はAlphaではなく、Reserved領域です。予約されていますが、画像の情報として、つかわれていません。予約した当初は、Alphaにつかう予定があったかもしれません。現在はただの空き領域です。
100 x 100の画像があった場合に、
32ビット ビットマップフォーマットだと、320,000ビット、40,000バイト、39KBです。
Reservedを使わなければ、240,000ビット、30,000バイト、29KBで済みます。
24ビット ビットマップフォーマットが、まさにRGBに1バイトずつつかい、予約領域をつかわないファイルフォーマットです。
目的
というわけで、出力ファイルサイズを節約するために、32ビットマップフォーマットを書き出すプログラムを、24ビット ビットマップフォーマットを書き出すプログラムに改良します。
24ビット ビットマップをエンコードするための課題
24ビット単位の書き出し
32ビット ビットマップフォーマットでは、4バイトの色情報はBGRA(正確にはAlphaではなくReservedですが、Redと見分けやすいので、便宜上Aと書きます)の順番で並べます。RGBの3バイトの情報(一番上位のバイトは初期値の0が入っていることを期待しています)を用意し、32ビットリトルエンディアンで書き込むと、バイト単位の順番が上手いこといれかわって、BGRAになります。次のイメージです。
const bodyData = new Uint32Array(data.length);
// 中略
bodyData[j] = (data[i + 0] << 16) | // Red
(data[i + 1] << 8) | // Green
data[i + 2]; // Blue
Uint32Arrayは、Intel CPUやArm CPUの上で動く、大抵の処理系ではリトルエンディアンで書き込まれます。
24ビット情報はこの方法では書き込めません。JavaScriptのTypedArrayにはUint24Arrayはありません。DataViewをつかって1バイトずつ書きます。
次のイメージです。
const buffer = new DataView(new ArrayBuffer(fileSize));
// 中略
buffer.setUint8(j, data[i + 2]); // Blue
buffer.setUint8(j + 1, data[i + 1]); // Green
buffer.setUint8(j + 2, data[i + 0]); // Red
Uint32Arrayをつかった方法と見比べて見ましょう。RGBの順で書いていたのを、BGRの順番に変更しています。Uint32Arrayが自動的にリトルエンディアンに入れ替えていた代わりに、手動で順番を入れ替えています。
単に1バイトずつ書き込むのであればUint8Arrayを使うこともできます。Uint8Arrayを使わずに、DataViewをつかったのは次の理由です。
ビットマップフォーマットのヘッダは2バイトと4バイトの領域があり、その書き込みにすでにDataViewをつかっています。2種類のAPIをつかうより、1つにまとめた方がプログラムは読みやすいです。Uint8ArrayかDataViewのどちらかにまとめます。
ヘッダの書き込みも、Uint8Arrayに統一すると、2バイト、4バイトの情報にもリトルエンディアンへの順番変更をする必要があります。DataViewには任意のバイトサイズをエンディアンを指定して書き込むAPIがあります。DataViewに統一しました。
行のパディング
ビットマップフォーマットの画像データは1行の画像データを4バイトで区切ります。
たとえば、横幅が5ピクセルの画像の場合を考えます。
24ビット ビットマップフォーマットでは、1行目の画像データは5掛ける3バイト、15バイトです。
2行目の情報は、16バイト目からは始めず、1バイト開けて、17バイト目から書きます。
行ごとに4バイトの倍数に合わせるために、1バイトパディングします。
32ビット ビットマップフォーマットでは、1行目の画像データが、5掛ける4バイト、20バイトです。2行目の情報は、つづけて、21バイト目から書きます。実は、32ビットの場合は、1ピクセルの情報が4バイトなので、画像の幅がいくつでも必ず1行の画像データは必ず4バイトの倍数になります。
行のパディングを意識する必要はありませんでした。そのため32ビット ビットマップフォーマットを書き出すプログラムではパディングの計算をしていませんでした。
24ビット ビットマップフォーマットでは、1行ごとに開けるバイト数を計算する必要があります。
次の式で求められます。
(width * 3) % 4 == 0 ? 0 : 4 - (width * 3) % 4;
画像データの1行の幅はひとつのファイルの途中で変わることはありません。ファイルごとに一回計算すれば十分です。
画像データサイズの計算
前述の行のパディングがあるため、画像のデータサイズはピクセル数 x 3バイト
にはなりません。
行のパディングを考慮すると1行のバイト数は次の式で求められます。
width * 3 + linePadding
これに行数を掛けると、画像データのサイズが得られます。
(width * 3 + linePadding) * height
ファイルサイズの計算
画像データサイズに行のパディングが入るため、ファイルサイズにも考慮が必要です。
headerSize + (width * 3 + linePadding) * height
ここではヘッダーサイズは54バイト固定とします。
ビットマップフォーマットには情報ヘッダーの種類にはいくつか種類がありますが、Windowsビットマップファイルフォーマット用のINFOタイプを使います。
実装
以上の課題を踏まえた実装が次です。
// canvasImageDataをビットマップフォーマットに変換します。
// ビットマップフォーマットの仕様は下記サイトに準拠します。
// http://www.umekkii.jp/data/computer/file_format/bitmap.cgi
// https://www.ruche-home.net/program/bmp/struct
const headerSize = 54; // ファイルヘッダ(14byte) + ファイル情報ヘッダ(40byte)= 54byte 固定
module.exports = class Canvas2Bitmap {
constructor(canvasImageData) {
this._depth = 3; // 色ビット数(バイト単位)
this._canvasImageData = canvasImageData;
this._buffer = new DataView(new ArrayBuffer(this._fileSize));
}
get _width() {
return this._canvasImageData.width;
}
get _height() {
return this._canvasImageData.height;
}
get _linePadding() {
return (this._width * this._depth) % 4 == 0 ? 0 : 4 - (this._width * this._depth) % 4;
}
get _lineDataSize() {
return this._width * this._depth + this._linePadding
}
get _bodySize() {
return this._lineDataSize * this._height;
}
get _fileSize() {
return headerSize + this._bodySize;
}
_fillFileHeader() {
// bfType ファイルタイプ BM固定
this._buffer.setUint8(0x0, "BM".charCodeAt(0));
this._buffer.setUint8(1, "BM".charCodeAt(1));
this._buffer.setUint32(2, this._fileSize, true); // bfSize ファイルサイズ
this._buffer.setUint16(6, 0); // bfReserved1 予約領域 0固定
this._buffer.setUint16(8, 0); // bfReserved2 予約領域 0固定
this._buffer.setUint32(10, headerSize, true); // bfOffBits ファイルの先頭から画像データまでのオフセット[byte]
}
// 情報ヘッダ INFOタイプ
_fillImageHeader() {
this._buffer.setUint32(14, 40, true); // biSize 情報ヘッダサイズ INFOタイプでは 40
this._buffer.setUint32(18, this._width, true); // biWidth 画像の幅[ピクセル]
this._buffer.setUint32(22, this._height, true); // biHeight 画像の高さ[ピクセル]
this._buffer.setUint16(26, 1, true); // biPlanes プレーン数 1固定
this._buffer.setUint16(28, this._depth * 8, true); // biBitCount 色ビット数[bit] 1, 4, 8, 16, 24, 32
this._buffer.setUint32(30, 0, true); // biCompression 圧縮形式 0, 1, 2, 3
this._buffer.setUint32(34, this._bodySize, true); // biSizeImage 画像データサイズ[byte]
this._buffer.setUint32(38, 0, true); // biXPixPerMeter 水平解像度[dot/m] 0で良さそう
this._buffer.setUint32(42, 0, true); // biYPixPerMeter 垂直解像度[dot/m] 0で良さそう
this._buffer.setUint32(46, 0, true); // bitClrUsed 格納パレット数[使用色数]
this._buffer.setUint32(50, 0, true); // bitClrImportant 重要色数
}
_fillBody() {
const data = this._canvasImageData.data;
// ある行を左から右に進んで行く
for (var x = 0; x < this._width; x++) {
// 上から下に行を進んで行く
for (var y = 0; y < this._height; y++) {
// canvasのimageDataは1バイトごとにRGBAが分かれている。
// 画素単位の4バイトずつ進みます。
const i = (y * this._width + x) * 4;
// ビットマップは左下から右上に記録されているので、下から詰めていく
const j = headerSize +
this._lineDataSize * (this._height - y - 1) +
x * this._depth;
// 24bitビットマップ
// 1画素あたり24bit(3byte)で、Blue(8bit)、Green(8bit)、Red(8bit)。
// 137, 41, 69, 255だとしたら?
// 0x45, 0x29, 0x89
this._buffer.setUint8(j, data[i + 2]); // Blue
this._buffer.setUint8(j + 1, data[i + 1]); // Green
this._buffer.setUint8(j + 2, data[i + 0]); // Red
}
}
}
// WebでもNodeでも扱いやすい、Uint8Arrayを返します。
get buffer() {
this._fillFileHeader();
this._fillImageHeader();
this._fillBody();
return new Uint8Array(this._buffer.buffer);
}
};
次のように使います。
const { createCanvas, loadImage } = require("canvas");
const Canvas2Bitmap = require("./Canvas2Bitmap")
const fs = require("fs");
function getCanvasImageData(image) {
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
return ctx.getImageData(0, 0, image.width, image.height);
}
!(async function () {
const image = await loadImage(process.argv[2]);
const canvasImageData = getCanvasImageData(image);
const bitmap = new Canvas2Bitmap(canvasImageData)
const stream = fs.createWriteStream('out.bmp');
stream.write(bitmap.buffer);
stream.end();
})();