Canvas から getImageData で取得した RGB値は premultiplied-alpha で量子化される件

2 min read読了の目安(約2500字

透明色を含む RGBA データを Canvas から getImageData で取得すると、RGB 値が期待と異なる事があります。なお、本エントリで説明する挙動は不具合でなく仕様です。

サンプル画像

% magick -size 256x256 xc:white -alpha on \
         -channel RGB -fx "i/w"           \
	 -channel A   -fx "j/h"   RGBA.png
RGBA.png RGB.png A.png
RGBA.png RGB A

現象

この画像ファイルを取り込んだ Image を drawImage で Canvas に書き込み getImageData すると、以下のデータになります。RGBA は見た目変わらないので、そこから分離した RGB も並べます。

% magick RGBA-canvas.png -alpha off RGB-canvas.png
RGBA-canvas.png RGB-canvas.png
RGBA-canvas.png RGB-canvas.png

見ての通り、A の値が小さな上の方ほど RGB の値が崩れています。
見やすくするため、縦方向に x8 で拡大してみます。

RGB-canvas-x8.png
RGB-canvas-x8.png
  • A:0 の時は RGB は全て 0
  • A:1 だと RGB は2分割で、 0 か 255
  • A:2 は RGB が3分割、0, 128(0x80), 255(0xFF)
  • A:3 RGB: 4分割、0, 85(0x55), 170(0xAA), 255(0xFF)
  • ...

A が小さいほど RGB の値が荒くなります。このように飛び飛びの値になる現象は量子化(Quantization) とも呼ばれます。

原因

Canvas は ImageData RGBA を premultiplied-alpha として保持するからです。

premultiplied-alpha は雑にいうと、アルファ合成する時に A で乗算するので、予め RGB に A を乗じておけば乗算をサボれる。といった高速化と、あと合成処理で色々と都合が良いので広く使われています。

  • RGBA(straight alpha): 255, 128, 64, 128

の RGBA 値があるとすると、RGB に 128(/255) を乗ずる事で premultiplied になります。

  • RGBA(premultiplied alpha): 128, 64, 32, 128

getImageData はその A で割ってしまった RGB 値を元に復元するので、A:0 だと RGB は全て 0 に、A:1 だと 0 と 255 の2種類しか復元できません。

回避策

Canvas に入れてしまった RGBA を getImageData で取り出す場合はどうしようもないですが、画像ファイルの透明度を正確に取得したいのであれば、PNG.js といったデコーダーを使うのも手ですし、Canvas の 2d context を介さず WebGL2 のテクスチャで PNG32 を取り込むと RGBA そのままの ImageData が得られます。

備考

値が劣化して不便なので仕様を変えたい要望はあるようです。ただ、実現するには premultiplied の利点を捨てて遅くなるか、straight と premultiplied の2つの RGBA をメモリ保持するかの選択肢を迫られるので、おそらく無理かなと思います。

参考

英語版記事) https://dev.to/yoya/canvas-getimagedata-premultiplied-alpha-150b