0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

canvas に描いた P3 のオレンジが (255,119,0) になる — 色域の切り捨てと premultiplied alpha

0
Posted at

P3 のオレンジ (255,128,0) を canvas に描いて読み戻す。返ってくるのは (255,119,0) だ。個人開発で作っている画像トリミング機能で、書き出した画像の色がなんとなく薄い気がしたのが始まりだった。半透明のふちには妙な色のにじみも出ていた。最初は目の錯覚だと思った。ピクセル値を読み出して一つずつ比べたら、本当に変わっていた。原因は二つあった。一つは色空間、もう一つは canvas が透明度をどう持っているかだ。

検証用の画像はすべてスクリプトで合成した。写真は使っていない。環境は Playwright 1.61.1 同梱の三つのビルドだ。Chromium 149、WebKit 26.5、Firefox 151 を使った。同じ検証セットには形式とファイルサイズの項目もある。そちらの比較対象には ImgIng(https://imging.ai/)の形式変換を使い、同じ合成サンプルを三つのエンジンで一度ずつ流した。色の話であるこの記事では canvas の素の出力だけを見ていて、ツールとの比較はしていない。

P3 画像を canvas に描くと色が薄くなるのはなぜか

用語を先に整理しておく。sRGB はほとんどの Web ページと画像が前提にしている色の範囲だ。Display P3 はそれより広く、より鮮やかな赤や緑やオレンジを表せる。画像ファイルには ICC プロファイルが埋め込まれている。数値をどちらの範囲で読むかをブラウザに伝える役目だ。

試したのは 12 色のパッチを並べた PNG で、Display P3 の ICC を埋め込んである。前半 8 色は P3 の鮮やかな色で、そのうち 7 色は sRGB の範囲からはみ出す。後半 4 色は sRGB にもともとある色だ。これを既定の canvas に描いて読み戻した。Chromium と WebKit はどちらも ICC に従って sRGB に変換する。範囲外は切り捨てだ。冒頭のオレンジが (255,119,0) になったのはこのためだ。

色の差は CIEDE2000 で測った。二つの色が見た目でどれだけ違うかを表す数で、0 なら同じ色になる。範囲外の 7 色は 2.91 から 7.50 で、WebKit では最大 7.71 だった。範囲内の 4 色はすべて 0.23 以下で、これは 8 ビットに丸めたときの誤差にすぎない。薄くなるのは sRGB に収まらない色だけだ。

青が Chromium 149 の既定 canvas、オレンジが Playwright の Firefox 151 ビルド。上の 8 色は P3 の鮮やかな色でどちらもずれる。下の 3 色(肌色・空色・深緑)は sRGB 内の色で、青はほぼ 0 なのにオレンジは 2.36〜3.24 ある

対処は canvas を作るときに色空間を指定することだ。書き方は getContext('2d', {colorSpace: 'display-p3'}) になる。Chromium 149 と WebKit 26.5 はこの指定を受け付ける。同じパッチを読み戻してみた。差はすべて 0 だった。書き出した PNG にも P3 の ICC が付く。例外は Firefox だ。Playwright の Firefox 151 ビルドはこの指定を黙って無視する。画像の ICC も読まないので、sRGB 内の色まで 2.36 から 3.24 ずれた。いまは canvas を作るたびに getContextAttributes().colorSpace を見ている。このビルドでは srgb が返る。Firefox の正式版でも同じなのかは、手元では確かめられていない。

半透明のふちに色のにじみが出るのはなぜか

二つ目の原因は premultiplied alpha(乗算済みアルファ)だ。alpha は透明度のことで、0 が完全な透明、255 が不透明になる。canvas は内部で色を持つとき、RGB にあらかじめ透明度を掛けた値で保存する。透明度が低いほど掛けたあとの数は小さくなり、残せる精度も減る。読み戻すときに割り戻しても、失った分は戻らない。

幅 255 のテスト帯を作り、alpha を 1 から 255 まで並べた。putImageData で書き込み、すぐに getImageData で読み出す。(200,150,100) を alpha=1 で書いてみた。Chromium と WebKit では (255,255,0) が返ってくる。Firefox は違う。(255,255,255) になる。この段の RGB の最大誤差はそれぞれ 127 と 252 だ。alpha を 10 に上げると 9 と 25 まで下がる。全部の値が正確に戻るのは Chromium と WebKit で alpha=249 から。Firefox では 242 からだった。テスト帯全体で RGB が完全に一致したピクセルは、Chromium で 57.06% しかない。alpha チャンネル自体の誤差はゼロだった。狂うのは色だけだ。

横軸は書き込んだ alpha(対数目盛)、縦軸は RGB の最大誤差。左端を見てほしい。alpha=1 で Chromium/WebKit は 127、Firefox は 252。64 あたりから二本の線がほぼ 0 に寄る

トリミング後のふちに出るにじみの正体はこれだった。ほぼ透明なピクセルの色がすでに崩れていて、別の背景に重ねたときに見えてしまう。willReadFrequently を付けても損失は同じだった。別の canvas に一度描いてから読んでも変わらない。Firefox は PNG の書き出しでもう一度丸めが入る。完全に正確なピクセルの割合は 54.97% から 39.41% に下がった。関連してもう一つある。透明な部分を含む canvas を JPEG で書き出すと、透明なところは必ず黒になる。JPEG は透明を持てないのでブラウザが黒背景で合成し、何の警告も出さない。

書き出す前に確認していること

広色域の画像で色を守りたいなら、先に display-p3 の canvas を作る。そして getContextAttributes() で本当に効いているかを見る。透明なふちを残したい素材は、getImageData でピクセルを書き換えて戻す処理をなるべく通さない。JPEG で書き出すなら、先に白を塗ってから画像を描く。試すなら中間色がいい。(200,150,100) を alpha=1 で書いて読み戻すのがいちばん早い。純白や純赤は避ける。どの alpha でも誤差ゼロなので、試しても何も分からない。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?