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 に収まらない色だけだ。
対処は 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 チャンネル自体の誤差はゼロだった。狂うのは色だけだ。
トリミング後のふちに出るにじみの正体はこれだった。ほぼ透明なピクセルの色がすでに崩れていて、別の背景に重ねたときに見えてしまう。willReadFrequently を付けても損失は同じだった。別の canvas に一度描いてから読んでも変わらない。Firefox は PNG の書き出しでもう一度丸めが入る。完全に正確なピクセルの割合は 54.97% から 39.41% に下がった。関連してもう一つある。透明な部分を含む canvas を JPEG で書き出すと、透明なところは必ず黒になる。JPEG は透明を持てないのでブラウザが黒背景で合成し、何の警告も出さない。
書き出す前に確認していること
広色域の画像で色を守りたいなら、先に display-p3 の canvas を作る。そして getContextAttributes() で本当に効いているかを見る。透明なふちを残したい素材は、getImageData でピクセルを書き換えて戻す処理をなるべく通さない。JPEG で書き出すなら、先に白を塗ってから画像を描く。試すなら中間色がいい。(200,150,100) を alpha=1 で書いて読み戻すのがいちばん早い。純白や純赤は避ける。どの alpha でも誤差ゼロなので、試しても何も分からない。

