同じJPEGがブラウザごとに違って見える理由 — 「ピクセルパーフェクト」という神話
同じJPEGがブラウザごとに違って見える理由 — 「ピクセルパーフェクト」という神話
まったく同じJPEGファイルが、Chromeでは細く、Firefoxでは太く描かれる。これはどちらかのブラウザのバグなのか、それとも我々がWebに対して抱いてきた「同じコードは同じピクセルを生む」という信念が、最初から錯覚だった証拠なのか。
導入
ある開発者が同僚の画面で奇妙なものに気づいた。自分が作った15ピクセルの小さなロゴが、同じWebページであるにもかかわらず、ChromeではFirefoxよりも明らかに細く見えたのである。拡大して並べてみると違いは明白だった。Chrome側の文字の画は潰れて太く、Firefox側は鋭かった。同じURL、同じ画像ファイル、同じCSS。異なるのはブラウザだけだった。開発者guillaumetechはこの些細なズレを見過ごさず、JPEGデコーディングの底までたどってその原因を突き止めた記事を公開した。発端はアイコンひとつだったが、彼が掘り起こしたのははるかに大きな問いである。我々はしばしば「ピクセルパーフェクト」を目標に掲げ、デザインカンプとブラウザのレンダリングが1:1で一致することを期待する。しかし同じ画像すらブラウザごとに違って描かれるのなら、その目標はそもそも成り立つのか。本稿は狭い現象ひとつから出発し、「ブラウザのレンダリングはなぜ互いに違うのか」という広い問いへ踏み込む。
15ピクセルのロゴが残した手がかり: Chromeは画像を「少なく」デコードする
まず事実関係から切り分けよう。guillaumetechの追跡によれば、Chromeが小さなJPEGを違って描く直接の原因は「部分IDCTスケーリング(partial IDCT scaling)」というデコード最適化である。IDCTは逆離散コサイン変換(Inverse Discrete Cosine Transform)の略で、JPEGをピクセルへ戻す中核の演算だ。この名を理解するには、JPEGが画像をどう格納するかを知る必要がある。
JPEGは画像を丸ごと保存しない。絵を8×8ピクセルのブロックへ細かく分割し、各ブロックをピクセル値の羅列ではなく「周波数成分」の組み合わせに変換して格納する。これがDCT(離散コサイン変換)である。ひとつのブロックの中で、緩やかに変化する明るさの平均値のような低周波成分と、輪郭・画・細かい模様のように急激に変化する高周波成分とが、それぞれ係数として分離されて収まる。人間の目が低周波に敏感で、高周波の微細な損失には鈍いという性質を利用し、JPEGは高周波を大胆に捨てて容量を削る。写真がよく圧縮される理由はここにある。
部分IDCTスケーリングは、この構造を逆用した速度最適化である。画像を元のサイズへ完全に復元してから縮小するのではなく、最初から縮小されたサイズでデコードしてしまう。guillaumetechの説明では、Chromeは目標サイズに合わせて「分母が8である最も近い分数」を選び、その倍率でのみブロックを復元する。大きく縮めるほど高周波係数を計算からまるごと除き、極端に1/8まで縮めると、ブロックあたり低周波の最下、すなわち8×8ブロック全体の平均値ひとつ(DC成分)だけが残る。8×8ブロックがピクセル一点へ潰れるのである。この過程で画の鋭さを作っていた高周波情報が丸ごと蒸発し、それゆえChromeのロゴは太くぼやけて見えた。Chromeは画像デコードをグラフィックライブラリのSkiaへ委ね、Skiaはさらにlibjpeg-turboを使うが、まさにこのlibjpeg-turboが部分IDCTスケーリングを実装している。
この最適化はタダの性能ではない。HNの議論である参加者は自身が遭遇した実際のバグを共有していた。一部のエンコーダはJPEGの末尾ブロック(トレーリングMCU)の、実際には使われない行・列にゼロやゴミの値を残すというのである。元のサイズでデコードすればその余分は切り取られて問題ないが、IDCTスケーリングはそのゴミの値を隣接する出力ピクセルへ混ぜてしまい、目に見えるアーティファクトを作る、と彼は指摘した(Chromeのバグトラッカーに関連イシューが上がっている)。なぜこの危険を冒すのかという問いには、メモリ節約ではなく速度のためだという答えが続いた ── メモリはCPUキャッシュに比べれば遅いので、大きな画像を丸ごと展開してから縮めるより、最初から小さくデコードするほうがはるかに速い、というわけだ。
ではFirefoxはなぜ違うのか。HNでFirefoxの内部に詳しい参加者が説明したところを移すと、Firefoxも画像を原解像度で全部展開してから縮めるわけではない。代わりに「デコードしながら縮小(downscale-during-decode)」する方式で、ストリーミングデコードの途中で縮小を同時に適用する。ただしChromeのように8×8ブロック単位で係数を切り捨てる部分デコードまではしない、という指摘も添えられた。両ブラウザとも「小さく描くなら大きく作る必要はない」という同じ目標へ向かうが、その目標に至る経路が異なり、その経路の差がそのまま画面の差として残るのである。
なぜ単一の原因ではないのか: スケーリング・ガンマ・クロマ・カラーマネジメントの層
ここからがこの現象の本当の深さである。guillaumetech自身も記事の末尾に訂正のノートを添えた。部分IDCTだけですべての差を説明することはできず、縮小に使うスケーリングアルゴリズムそのものも結果に大きく影響する、というのだ。HNの議論はこの点を幾重にも剥がしていった。要点はひとつ ── ブラウザレンダリングの差には単一の犯人がいない。
第一の層はリサンプリングアルゴリズムである。画像を縮めるとき、複数の原ピクセルをひとつの出力ピクセルへどう合わせるかは、正解がひとつではない。ある参加者は、ChromeとFirefoxがそもそも異なる縮小アルゴリズムを使っており、これがIDCTよりむしろ大きな差を生むと見た。彼の観察では、Chrome側はおおむねよりぼやけており、Firefox側はより鋭いが、輪郭にリンギング(ringing)のアーティファクトがわずかに多く乗る。これは画像処理における古典的なトレードオフだ。バイリニア(bilinear)のような滑らかなアルゴリズムは潰れる代わりにさざ波がなく、ランチョス(Lanczos)のような鋭いアルゴリズムは鮮明な代わりにコントラストの大きい境界でオーバーシュートが生じる。興味深いのは人間の好みだ ── ある参加者は、PSNRのような客観的画質指標はおおむねリンギングよりぼやけを良く評価するが、人間の主観的評価は逆に傾く場合が多いと指摘した。どちらが「正しい」縮小なのかに普遍の正解がない、ということである。
第二の層はガンマと色空間だ。ある参加者はガンマ補正の問題を提起した。JPEGはピクセルを明るさに線形な値ではなく、非線形にエンコードされたYCbCr色空間へ収めるが、縮小のためのピクセル平均をこの非線形の値の上で直に計算すると、物理的に誤った平均が出る。二つのピクセルの明るさを混ぜるには、原則として線形の光量へ戻してから平均し、再び非線形へガンマエンコードすべきなのに、この段階を飛ばすと、とりわけ明るさのコントラストが大きい境界で微妙に暗くなったり画が細くなったりする。部分デコードの経路がこのガンマ補正を飛ばす可能性がある、というのだ。これに加えて第三の層として、JPEGは明るさ(Y)より色(Cb, Cr)の情報を半分の解像度に削って格納するクロマサブサンプリングをよく使う。色チャンネルの元の解像度がすでに低いので、これをどう補間して蘇らせながら縮小するかで、もう一度ブラウザごとの差が分かれる。最後の層はカラーマネジメント(color management)である。画像に埋め込まれたICCプロファイルを尊重するか、ディスプレイの色域へどう変換するかも、ブラウザとOS・GPUの組み合わせごとに異なる。
これらの層がなぜ標準で縫合されなかったのかが核心だ。JPEG・PNGといったフォーマット標準は「ビットストリームをどうピクセルへ戻すか」までは規定する。しかしそのピクセルを画面サイズに合わせて「どう縮小し、どの色空間で混ぜ、どのガンマで補正するか」は、おおむね実装の裁量に委ねられている。標準が沈黙した箇所ごとに、ブラウザは各自の工学的判断 ── たいていは速度と画質の秤 ── で空欄を埋め、その判断が互いに違ったのである。HNである参加者が冷静に要約したように、画質が本当に重要なら縮小をブラウザに任せてはならない。ブラウザは最新最良の画質アルゴリズム(彼はランチョス3-lobeを例に挙げた)を一貫して使わず性能を優先し、しかもどのアルゴリズムが使われるかが搭載されたGPUによって変わることさえある、というのだ。この最後の一文が重い。レンダリング結果がユーザーのハードウェアに依存するのなら、「ピクセルパーフェクト」は開発者が制御できる変数ではない。
「ピクセルパーフェクト」という神話と、その実務的な請求書
だからこの一件が与える第一の教訓は認識の転換である。Webにおいてピクセルパーフェクトは達成可能なデフォルトではなく、特定の条件でのみ近似される例外だ。同じマークアップと同じアセットが同じピクセルを生むという仮定は、フォントヒンティング・サブピクセルレンダリング・画像縮小・カラーマネジメントのように、ブラウザ・OS・GPUがそれぞれに決める無数の下位段階の前で崩れる。デザイナーとフロントエンド開発者にとって、これは抽象的な哲学ではなく具体的な請求書として返ってくる。
最も実用的な対応は、そもそもブラウザに縮小をさせないことだ。HNの議論がほぼ全会一致でたどり着いた結論がここだった。20×20で表示するアイコンに2000×2000の原本を入れ、ブラウザに縮めさせるのは帯域の浪費であり画質の賭けである。表示するサイズに合った解像度の画像を配ることが先だ。アイコン・ロゴ・ラインアートのように境界がはっきりしたグラフィックは、そもそもラスターではなくSVGへ行くほうがよい。原文の著者が結局、問題のロゴをSVGへ差し替えて解決したように。SVGはどんな倍率でも鮮明で容量も小さく、ダークモードに合わせて色を変えることもできる。ある参加者は、ChromeのこのようなアーティファクトがElectronのリリースにまで流れ込んで製品のあちこちのアイコンが壊れ、SVGへ乗り換えるまでアップグレードを見送らねばならなかった経験を伝えた。同じ系統の問題がJPEGだけでなく、縮小されたPNGでも現れたというのである。やむを得ずラスターを縮小せねばならないなら、ピクセルアートのように補間を望まない場合には image-rendering: pixelated のようなCSSでブラウザの滑らかな縮小を切ることもできる。ただしこれは万能ではなく、特定状況の道具だ。
第二の教訓はテストとアセット準備の方法である。レンダリングが非決定的なら、「自分のChromeではよく見える」という検証は不完全だ。視覚的に重要な要素は複数のブラウザと複数のピクセル密度(1x・2x・3x)で実際に確認すべきであり、画質が事業的に重要なら、縮小はビルドパイプラインで制御されたアルゴリズムによって事前に実行し、結果画像を固定するほうが安全だ。先の参加者の言うように、本当に画質に命を懸けるなら、ブラウザの代わりにキャンバスへ直接レンダラを書くか、ネイティブへ降りる選択肢さえある。もちろん大半のサービスには過剰だ。現実的な均衡点は「ブラウザごとにピクセルが少しずつ違いうることを受け入れつつ、その偏差が目立たないようアセットを適正サイズで準備すること」である。
第三はフォーマット移行の流れだ。HNでは、AVIFの対応率がすでに95%に達したのだから、もはやJPEGより優先して考えるときだという声が出た。ただし新しいフォーマットが広がっても、縮小・ガンマ・カラーマネジメントの実装差という根本の問題が消えるわけではない。フォーマットは「何を保存するか」を定めるが、「画面へどう移すか」の最後の一歩は依然として実装の担当だからだ。楽観シナリオはブラウザたちが高品質な縮小をデフォルトへ収束させることであり、悲観シナリオはGPU・OSの断片化がむしろ深まることであり、現実はその間のどこかで、開発者が相変わらずアセットを手ずから整える姿だろう。
結論
リードの問いへ戻ろう。同じJPEGがブラウザごとに違って見えるのはバグなのか、それともWebの本質なのか。答えは後者に近い。Chromeの部分IDCTスケーリングは明白な誤りというより、速度のために画質を差し出した合理的な最適化であり、Firefoxの異なる結果もまた別の最適化の産物だった。両ブラウザのどちらも規則を破ってはいない。彼らが埋めたのは標準が空けておいた裁量の空間であり、その空間が存在する限り「ピクセルパーフェクト」は保証ではなく希望である。
とはいえ制御を放棄せよという話ではない。むしろ逆だ。制御できないもの(ブラウザ内部の縮小パイプライン)と制御できるもの(どのフォーマットを、どの解像度で、どのサイズに配るか)をはっきり分け、後者に集中するのがプロの態度である。15ピクセルのロゴひとつが教えたのは、結局これだ ── Webは決定論的なキャンバスではなく、無数の実装がそれぞれの秤で交渉する場である。その事実を知ってアセットを準備する開発者と、「自分の画面ではよく見えたが」と言う開発者との距離は、思うより遠い。あなたが最後に「ピクセルまで合わせた」と言ったその画面は、本当にすべてのユーザーの画面で同じピクセルだったのか。
出典: