ある夜、手元の壁紙アプリのアセットを整理していました。ホーム画面に並べる約320枚のプレビュー用 PNG を、エージェントに WebP へ一括変換させたのです。翌朝ログを見ると、総容量は 41MB から 17MB へ。文句のつけようがない削減率でした。
けれど実機で確かめると、深い藍色の夜空を描いた数枚だけ、わずかに灰色がかっていました。並べて比べなければ気づかないほどの差です。エージェントは「サイズが減った」ことを成功として報告し、私はその報告を信じてマージ寸前まで進めていました。
胸の奥がひやりとしました。もし気づかずに公開していたら、いちばん見せたかった作品の色が、静かに損なわれていたことになります。
ここでまとめたいのは、画像の一括最適化をエージェントに委ねる際に、「見た目の劣化」を数値で捉えてマージ前に止めるゲートの設計です。個人開発で壁紙アプリを何本も運営してきた私自身が、色そのものが商品である現場で組んだ実装と、実運用で決めたしきい値の目安を共有いたします。
なぜ「サイズが減った=成功」で止めてはいけないのか
画像最適化のツールは、たいてい削減率を誇らしげに報告します。エージェントも同じで、「320枚を変換し、平均59%削減しました」と返してきます。この数字は嘘ではありません。ただ、削減率は「どれだけ捨てたか」を語るだけで、「何を捨てたか」を語りません。
損失の多くは目に見えません。JPEG や WebP の非可逆圧縮は、量子化テーブルの選び方しだいで、なめらかなグラデーションに帯状のムラ(バンディング)を生みます。減色を伴う変換では、微妙な色の階調が丸められます。カラープロファイルが剥がれれば、同じ数値の画素が別の色として表示されます。
これらはいずれも「ファイルとしては正常」で、破損チェックをすり抜けます。人間のレビューにも限界があります。320枚を1枚ずつ原本と並べて見比べる作業は、集中力が保つ枚数を超えています。実際、私が見落としかけたのも、そういう地味な劣化でした。
だからこそ、劣化を機械が測れる数値に置き換え、しきい値を割ったものだけを人間に差し戻す仕組みが要ります。エージェントに最適化そのものは任せてよいのです。任せてはいけないのは、合否の最終判断です。
見た目の劣化を数値で捉える — 3つの軸
私は次の3軸で判定しています。1つでは足りず、3つで補い合う関係です。
| 軸 | 測るもの | 捉えられる劣化 |
| SSIM(構造的類似性) | 輝度・コントラスト・構造の一致 | バンディング、ディテールの潰れ、輪郭のにじみ |
| ΔE(色差) | 知覚的な色のずれ(CIELAB 上の距離) | 色相の転び、彩度の低下、プロファイル剥がれ |
| ファイルサイズ | 最適化の効果 | 効きすぎ(過圧縮)と効かなさ(無変換)の両方 |
SSIM は 1.0 が完全一致で、値が下がるほど構造が崩れています。ピクセル単位の差分(MSE)と違い、人間が「同じに見えるか」に近い形で構造の違いを捉えます。バンディングやディテールの潰れに敏感なのが利点です。
一方、SSIM は色相の転びには鈍い性質があります。輝度が保たれていれば、青が少し緑に寄っても SSIM はあまり動きません。壁紙アプリでは色こそが命ですから、ここを ΔE で補います。ΔE は CIELAB 空間での色の距離で、**おおむね 1.0 が「訓練された目でようやく分かる差」、2〜3 が「並べれば分かる差」**とされます。
3つ目のファイルサイズは、劣化そのものではなく最適化の健全性を見ます。削減率が極端に高いときは過圧縮を疑い、逆にほとんど減っていないときは変換が効いていない(=わざわざ品質リスクを負う意味がない)と判断します。
ゲートを一枚の判定スクリプトにまとめる
判定は、原本と変換後の2枚を受け取り、3軸を計算して合否を返す関数に集約します。依存は Pillow と scikit-image、numpy だけです。重いライブラリを避けたのは、CI の隔離環境でも数秒で走らせたいからです。
# image_quality_gate.py
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image
from skimage.metrics import structural_similarity as ssim
@dataclass
class GateResult:
ssim: float
delta_e: float
size_ratio: float # 変換後 / 原本
passed: bool
reasons: list[str]
def _load_rgb(path: Path, size: tuple[int, int] | None = None) -> Image.Image:
img = Image.open(path).convert("RGB")
if size is not None and img.size != size:
img = img.resize(size, Image.LANCZOS)
return img
def _mean_delta_e(a: Image.Image, b: Image.Image) -> float:
# Pillow の LAB は L,a,b を 0-255 に格納するため実スケールへ戻す
la = np.asarray(a.convert("LAB"), dtype=np.float64)
lb = np.asarray(b.convert("LAB"), dtype=np.float64)
la[..., 0] *= 100.0 / 255.0
lb[..., 0] *= 100.0 / 255.0
la[..., 1:] -= 128.0
lb[..., 1:] -= 128.0
# CIE76: LAB 上のユークリッド距離を画素平均
diff = la - lb
return float(np.sqrt((diff ** 2).sum(axis=-1)).mean())
def evaluate(
original: Path,
converted: Path,
*,
ssim_floor: float = 0.985,
delta_e_ceiling: float = 2.0,
size_ratio_floor: float = 0.10, # 90%超の削減は過圧縮を疑う
size_ratio_ceiling: float = 0.95, # 5%も減らないなら変換の意味が薄い
) -> GateResult:
orig = _load_rgb(original)
conv = _load_rgb(converted, size=orig.size)
g_orig = np.asarray(orig.convert("L"), dtype=np.float64)
g_conv = np.asarray(conv.convert("L"), dtype=np.float64)
s = float(ssim(g_orig, g_conv, data_range=255))
de = _mean_delta_e(orig, conv)
ratio = converted.stat().st_size / original.stat().st_size
reasons: list[str] = []
if s < ssim_floor:
reasons.append(f"SSIM {s:.4f} < {ssim_floor}")
if de > delta_e_ceiling:
reasons.append(f"ΔE {de:.2f} > {delta_e_ceiling}")
if ratio < size_ratio_floor:
reasons.append(f"過圧縮の疑い: size_ratio {ratio:.2f}")
if ratio > size_ratio_ceiling:
reasons.append(f"最適化が効いていない: size_ratio {ratio:.2f}")
return GateResult(s, de, ratio, len(reasons) == 0, reasons)
ここで大切なのは、判定結果を単なる真偽値でなく reasons(理由)付きで返すことです。エージェントに差し戻すとき、「SSIM が 0.971 で下限を割った」と具体的に伝えれば、エージェントは変換方式を切り替えるなり、その1枚だけ可逆圧縮に逃がすなり、次の一手を選べます。「不合格」とだけ返すと、同じ失敗を繰り返しがちです。
なぜ SSIM をグレースケールで計算しているのかも触れておきます。カラー画像で SSIM をチャンネルごとに出して平均する方法もありますが、色のずれは ΔE が受け持つ役割です。SSIM は構造の担当と割り切ることで、2つの指標の責任範囲が重ならず、どちらが鳴ったかで原因を切り分けやすくなります。
しきい値をどう決めるか — 実測から
しきい値は最初から正解が分かるものではありません。私は手元のアセットを代表的な変換方式にかけ、値の分布を見てから決めました。壁紙アプリのプレビュー画像(写真的なもの、フラットなイラスト、グラデーション主体のもの)を各20枚ずつ試した、おおよその実測が次の表です。
| 変換方式 | SSIM 中央値 | ΔE 中央値 | サイズ削減率 | 判定の傾向 |
| WebP q=90 | 0.996 | 0.6 | 52% | 安定して合格 |
| WebP q=80 | 0.991 | 1.1 | 63% | 写真は合格・グラデは注意 |
| WebP q=70 | 0.983 | 1.9 | 71% | グラデ主体でしばしば不合格 |
| PNG 減色 256色 | 0.972 | 2.8 | 68% | グラデで明確に不合格 |
この分布から、私は SSIM の下限を 0.985、ΔE の上限を 2.0 に置きました。WebP q=80 を基準の変換方式としつつ、グラデーション主体の絵だけがしきい値付近に集まるので、そこはゲートが自然に拾ってくれます。拾われた1枚は q=90 に上げるか、可逆の WebP へ回します。
数字は絵柄の性格に強く依存します。ですから、この表の値をそのまま流用するのではなく、ご自身のアセットで一度分布を取ってから決めていただくことをおすすめします。判定スクリプトに --report のような集計モードを足し、CSV に SSIM・ΔE・サイズを吐かせておくと、しきい値の見直しが楽になります。
エージェントのワークフローに組み込む
ゲートは、エージェントの最適化ステップの「後ろ」に、独立した検証ステップとして差し込みます。ここを1つのステップにまとめてしまうと、最適化した本人が採点することになり、緩みが入ります。
変更前は、エージェントの指示がこうなっていました。
assets/preview/ の PNG を WebP へ変換し、元ファイルを置き換えてコミットしてください。
変更後は、変換と判定を分け、判定はスクリプトに委ねます。
1. assets/preview/ の各 PNG を assets/_staging/ に WebP で書き出す(原本は消さない)
2. すべてに python image_quality_gate.py --check <原本> <変換後> を実行する
3. 合格したものだけ原本を置き換える
4. 不合格は _staging に残し、理由を quality_report.md に列挙して停止する
原本を即座に上書きしないのが要点です。ステージング領域に一度置くことで、不合格分を安全に差し戻せますし、合格分だけを確定できます。エージェントには「合否を判断させない」代わりに「ゲートの出力に従わせる」わけです。
CLI として使えるよう、スクリプトに薄い入り口を付けておきます。
# image_quality_gate.py(末尾に追記)
import sys
if __name__ == "__main__":
# 使い方: python image_quality_gate.py --check original.png converted.webp
if len(sys.argv) == 4 and sys.argv[1] == "--check":
r = evaluate(Path(sys.argv[2]), Path(sys.argv[3]))
print(f"SSIM={r.ssim:.4f} dE={r.delta_e:.2f} ratio={r.size_ratio:.2f}")
if not r.passed:
print("FAIL: " + "; ".join(r.reasons))
sys.exit(1)
print("PASS")
終了コードを合否に対応させておくと、CI でもエージェントのシェル実行でも、そのまま分岐条件に使えます。非ゼロで止まるだけで、後続のコミットが実行されません。
見落としがちな軸 — カラープロファイル
3軸を測っていても、すり抜ける劣化がひとつあります。カラープロファイルの剥がれです。
変換ツールによっては、埋め込まれた ICC プロファイル(Display P3 など)を捨て、sRGB とみなして書き出します。数値としての画素値は同じですから、上記の ΔE でも差が出にくいことがあります。ところが広色域のディスプレイでは、鮮やかだったはずの赤や緑がくすんで表示されます。
ここは判定というより、変換時の設定で防ぐのが確実です。書き出し時にプロファイルを明示的に保持し、剥がれていないかを別途チェックします。
from PIL import Image
def has_icc_profile(path) -> bool:
with Image.open(path) as im:
return "icc_profile" in im.info and bool(im.info["icc_profile"])
原本にプロファイルがあったのに変換後で失われていたら、それだけで差し戻します。私はこの1行のチェックを入れるまで、P3 のアセットが sRGB に落ちていたことに、長らく気づけませんでした。測れるものを測っていても、測っていない軸はすり抜けます。ゲートは万能ではなく、既知の失敗を確実に止める道具だと捉えるのが健全です。
運用して分かったこと
数か月このゲートを回して、いくつか腑に落ちたことがあります。
ひとつは、ゲートがあるとエージェントへの指示が短くなることです。以前は「圧縮しすぎないで」「色を保って」と曖昧な注文を重ねていましたが、いまは合否の基準が数値で外に出ているので、指示は「変換してゲートに通す」で足ります。品質の定義をプロンプトから引きはがし、検証コードへ移せたのは、思っていた以上に効きました。
もうひとつは、しきい値付近の1枚が、いちばん学びをくれることです。安定して合格する絵柄からは何も分かりません。ゲートがときどき拾う「グラデーションの深い絵」こそ、私のアセットの弱点であり、変換方式を絵柄で出し分ける判断のきっかけになりました。ゲートは門番であると同時に、自分のアセットの性格を教えてくれる観測点でもあります。
最後に、これは自戒でもあるのですが、削減率という分かりやすい数字ほど、疑う癖をつけたほうがよいということです。良い報告ほど、その裏で何が起きているかを一度立ち止まって確かめる。自動化を広げるほど、その一拍が効いてきます。
もし画像に限らず「エージェントに任せた作業の成果を、どの数値で受け取るか」を考えるきっかけになれば嬉しいです。まずはご自身のアセットを判定スクリプトに一度通し、SSIM と ΔE の分布を眺めてみてください。そこから、あなたのアプリにとっての「合格」の輪郭が見えてくるはずです。お読みいただきありがとうございました。