業務のアップロード処理は、どの画像も同じ JPEG 設定で圧縮しています。商品写真はそれで困りません。困るのは運用担当が上げてくる注文画面や管理画面のスクリーンショットで、文字のまわりが灰色にざらつきます。そこで、スクショはパレット PNG、写真は WebP と、中身を見て形式を選ぶ案を出すことにしました。判定ロジックの話はよく見かけます。ただ、レビューで突っ込まれるのはその周りです。1 枚あたりの判定コストはどれくらいか。選んだ形式でかえってファイルが大きくなったらどうするか。半年後に誰かがパラメーターを変えたとき、劣化に気づけるか。この 3 つを先に数字で押さえました。
検証に使った画像と判定の指標
画像は 8 枚です。架空のページをブラウザーで描画して撮った 1280×800 のスクショ 2 枚(中国語の議事録と、色付きステータスのある注文一覧)。ウィキメディア・コモンズの写真 2 枚(Jakub Fryš 氏の湖、Josep M. Gracia 氏の果物の露店。どちらも CC BY-SA 4.0)を幅 1280 に縮めた可逆 PNG。スクショ 2 枚を JPEG 品質 80 で保存し直したもの。コモンズの CC BY-SA の人物写真を 413×579 に切り出した JPEG 2 枚です。処理時間を測るためだけに、人物写真の 2996×4200 版も使いました。すべて Node と sharp 0.34.5、Apple M4 の Mac で動かしています。
判定にユニーク色数は使いませんでした。注文一覧を一度 JPEG で保存しただけで 21,500 色になり、人物写真の 26,827 色と区別がつかなくなるからです。代わりに、上位 16 色が全画素に占める割合と、同じ行で左隣と同じ色の画素の割合を見ています。
実装の前に、公開されているツールがどう振り分けるかも見ておきました。ブラウザー上で動く ImgIng(https://imging.ai/)を Chromium 149 で開き、原画像 4 枚と人物写真 2 枚(2 サイズずつ)を読み込むと、スクショは PNG-8・256 色、写真は WebP 品質 75 が既定になりました。果物の露店と 2996×4200 の人物写真はスクショ・UI と判定されましたが、品質が 84 に上がっただけで WebP のままでした。後で出てくる「迷ったら品質を上げた WebP」という中間の経路は、この挙動を参考にしています。
特徴量は最近傍で縮小してから計算する
2996×4200 の人物写真は、原寸のまま計算するとデコード込みで 404 ms かかりました。長辺 512 に縮めてから計算すると 52 ms です。意外だったのは縮小方法の影響でした。最近傍なら、スクショの 2 つの値の低下は最大 0.03 で、写真はどちらも 0.25 以下のままです。sharp の既定の lanczos3 で縮めると、補間で輪郭に中間色が生まれ、スクショの上位 16 色の割合が 0.697〜0.812 まで下がりました。
3 つの経路と、元ファイルを残すフォールバック
振り分けのルールは書きながら決めたもので、調整はしていません。2 つの値の小さいほうが 0.6 以上ならスクショとしてパレット PNG(256 色、ディザなし)に回します。大きいほうが 0.35 以下なら写真として WebP 品質 75。どちらでもなければ WebP 品質 84 です。出力が入力より小さくならなければ、元のファイルをそのまま残します。
8 枚はすべて正しい経路に入り、中間に落ちた画像はありませんでした。それでもフォールバックは働きました。JPEG で届いたスクショ 2 枚はパレット PNG にすると 164,265 バイトが 256,076 バイトに、111,650 バイトが 159,108 バイトに増えたので、元ファイルが残っています。試しに WebP 品質 75 に回すと 105,738 バイトと 54,482 バイトでした。議事録のほうは、可逆の原画像と比べた SSIM が 0.9947 で、JPEG の入力そのもの(0.9917)より高く出ます。JPEG の入力を基準にすると逆に悪く見えるので、何を基準に測るかで結論が変わります。JPEG で届いたスクショに専用の経路を作るかどうかは、まだ決めていません。
経路を間違えたときの損は向きで大きく違いました。可逆のスクショを WebP 品質 75 に回すと、パレット PNG より 1 割ほど小さくなる代わりに非可逆になります(議事録で 103,054 バイト、PSNR 40.39)。写真をパレット PNG に回すと、湖は 56,414 バイトが 417,298 バイトに、露店は 127,602 バイトが 461,340 バイトに膨らみました。
劣化は画像ごとの基準値と比べて拾う
あとからパラメーターを変えられたときに気づけるかを確かめるため、パレットを 64 色に減らす変更と、写真の品質を 50 に下げる変更を試しました。最初のチェックは固定の合格ラインです(スクショは PSNR 50 以上、写真は平均 SSIM 0.93 以上、入力より大きくない)。64 色の変更ではスクショ 4 枚とも引っかかりました。品質 50 のほうは 4 枚中 2 枚しか拾えず、露店(0.9543)と青紫背景の人物写真(0.9476)は通ってしまいました。そこで、前回の実行結果を基準として画像ごとに比べる方式に変えました。
function findRegressions(base, cur) {
const hits = [];
for (const [k, b] of Object.entries(base)) {
const a = cur[k];
if (a.lane !== b.lane) hits.push(`${k}: 経路`);
if (a.bytes > b.bytes * 1.1) hits.push(`${k}: サイズ`);
if (b.ssim - a.ssim > 0.01 || b.ssimWorst1 - a.ssimWorst1 > 0.01) hits.push(`${k}: SSIM`);
if (b.psnr - a.psnr > 3) hits.push(`${k}: PSNR`);
}
return hits;
}
ssimWorst1 は SSIM マップの下位 1% の値です。この比較だと、どちらの変更でも 8 枚中 4 枚、つまり変更の影響を受けた画像はすべて引っかかりました。平均 SSIM だけを見ていたら、64 色の変更は素通りしていたはずです。スクショ 4 枚の平均 SSIM は 0.9997、0.9962、0.9991、0.9913 で、どれも 0.99 を超えています。文字の多い画像では下位 1% のほうがずっと敏感で、注文一覧を JPEG 品質 80 にしたものは平均 0.9874、下位 1% は 0.8386 でした。測れなかったものもあります。拡大すると分かるステータスラベルの色にじみは、色差だけで計算した SSIM でも輝度の SSIM より低くならず、数字には出てきませんでした。
しきい値の 0.6 と 0.35、許容幅の 0.01・10%・3 dB は 8 枚から決めた値なので、精度の数字は出していません。ブラウザーの canvas での実装もまだ試していません。既定値を本番で変える前に、コンプライアンス担当が持ち出しを認めた範囲の実際のアップロード画像で、まず基準値を取るところから始めます。