App Store のスクリーンショットを5枚まるごと差し替える前に、Gemini に採点させてみました。返ってきたのは 82点。念のため差し替え前の古い方も投げたら 79点。3点差。
判断がつかないので、テスト用にわざと崩した画像も投げました。キャッチコピーを全部消して、コントラストを落として、順番もバラバラにしたものです。
76点でした。
キャッチコピーが一文字もない画像が76点なら、82点にはもう意味がありません。この日から、私は Vision に「点を付けさせる」のをやめました。
以下は、絶対スコアが壊れていることを数字で確かめ、ペア比較へ切り替えるまでの実装記録です。個人開発で壁紙アプリのスクリーンショットを何度も作り直してきた中で、いちばん効いた設計変更でした。
採点器を疑うための最小の道具立て
まず環境から。
pip install google-genai pillow --break-system-packages
import os
import json
import statistics
from pathlib import Path
from itertools import combinations
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
# 検証は回数を稼ぐので Flash。最終確認だけ Pro に上げる運用にしています
JUDGE_MODEL = "gemini-flash-latest"
def load_image(path: str) -> types.Part:
"""ローカル画像を Part に変換する。拡張子から MIME を決める。"""
suffix = Path(path).suffix.lower()
mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(suffix, "image/jpeg")
with open(path, "rb") as f:
return types.Part.from_bytes(data=f.read(), mime_type=mime)
gemini-flash-latest を指定していますが、これはエイリアスです。2026年7月に指す実体が Gemini 3.5 Flash へ入れ替わりました。エイリアスで自動処理を回している場合、ある朝から採点の手触りが変わっていても気づけません。検証をやり直す前に、まず実体が何かを確認しておくのが安全です。
採点関数そのものは、元の設計をそのまま使います。疑う対象は関数ではなく、返ってくる数字の側です。
def score_absolute(image_paths: list[str], app_name: str, market: str = "日本") -> dict:
"""スクリーンショットセットを 0〜100 で採点する(従来型の絶対評価)。"""
parts = [
types.Part.from_text(
f"""あなたは App Store 最適化の専門家です。
以下のスクリーンショットセットを評価してください。
アプリ名: {app_name}
対象市場: {market}
必ず次の JSON 形式で返してください:
{{
"overall_score": <0-100の整数>,
"message_clarity": <0-100>,
"visual_hierarchy": <0-100>,
"reason": "スコアの根拠(1文)"
}}"""
)
]
for i, p in enumerate(image_paths):
parts.append(types.Part.from_text(f"スクリーンショット {i + 1}:"))
parts.append(load_image(p))
resp = client.models.generate_content(
model=JUDGE_MODEL,
contents=parts,
config=types.GenerateContentConfig(
response_mime_type="application/json",
temperature=0.2,
),
)
return json.loads(resp.text)
ノイズを測る — 同じ画像を10回投げる
採点器の良し悪しは、1回の出力を眺めても分かりません。同じ入力を繰り返して、返り値がどれだけ揺れるかを先に知る必要があります。
def measure_noise(image_paths: list[str], app_name: str, n: int = 10) -> dict:
"""同一入力を n 回採点し、スコアの散らばりを測る。"""
scores = []
for i in range(n):
result = score_absolute(image_paths, app_name)
scores.append(result["overall_score"])
print(f" 試行 {i + 1}: {result['overall_score']}")
return {
"scores": scores,
"mean": statistics.mean(scores),
"stdev": statistics.stdev(scores) if len(scores) > 1 else 0.0,
"range": max(scores) - min(scores),
}
temperature=0.2 まで下げているのだから、ほぼ同じ数字が返ると思っていました。私の手元(縦長スクリーンショット5枚・壁紙カテゴリ)で出たのは次の通りです。
| 指標 | 実測値 |
| 平均 | 81.4 |
| 標準偏差 | 2.3 |
| 最小〜最大 | 78〜85(幅7点) |
同じ画像に対して7点動きます。この時点で「79点と82点」の3点差は、ノイズの内側に完全に埋まっていたことになります。
temperature=0 にしても幅は3点までしか縮みませんでした。マルチモーダル入力では画像のタイル分割やトークン化の段階に揺らぎの余地があり、温度だけでは決定的になりません。ここは温度を下げて解決する種類の問題ではない、と割り切りました。
弁別能を測る — わざと壊した画像を作る
ノイズの大きさが分かったら、次はシグナルの大きさです。明らかに悪い画像を用意して、スコアがどれだけ下がるかを見ます。機械学習でいうアブレーションを、採点器そのものに当てる発想です。
from PIL import Image, ImageEnhance
def make_degraded_variants(src_path: str, out_dir: str) -> dict[str, str]:
"""検証用に、意図的に品質を落とした画像を生成する。
採点器が「悪くしたこと」を検出できるかを測るための対照群。
"""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
img = Image.open(src_path).convert("RGB")
stem = Path(src_path).stem
variants = {}
# (1) コントラストを 30% まで落とす — 視認性の劣化
low_contrast = ImageEnhance.Contrast(img).enhance(0.3)
p = out / f"{stem}_lowcontrast.png"
low_contrast.save(p)
variants["low_contrast"] = str(p)
# (2) 上半分を単色で塗り潰す — キャッチコピー領域の消去
no_text = img.copy()
w, h = no_text.size
no_text.paste((242, 242, 242), (0, 0, w, h // 3))
p = out / f"{stem}_notext.png"
no_text.save(p)
variants["no_text"] = str(p)
# (3) 極端に縮小してから戻す — 解像度の劣化
blurred = img.resize((w // 8, h // 8)).resize((w, h))
p = out / f"{stem}_lowres.png"
blurred.save(p)
variants["low_res"] = str(p)
return variants
そして、原本と劣化版のスコア差を、さきほど測ったノイズで割ります。この比が採点器の実力です。
def discrimination_margin(
original: list[str],
degraded: list[str],
app_name: str,
noise_stdev: float,
n: int = 5,
) -> dict:
"""弁別マージン = (原本スコア - 劣化スコア) / ノイズ標準偏差。
1.0 を下回るなら、スコア差はノイズに埋もれていて意味を持たない。
"""
orig_scores = [score_absolute(original, app_name)["overall_score"] for _ in range(n)]
deg_scores = [score_absolute(degraded, app_name)["overall_score"] for _ in range(n)]
gap = statistics.mean(orig_scores) - statistics.mean(deg_scores)
return {
"original_mean": statistics.mean(orig_scores),
"degraded_mean": statistics.mean(deg_scores),
"gap": gap,
"margin": gap / noise_stdev if noise_stdev > 0 else float("inf"),
"usable": (gap / noise_stdev) >= 2.0 if noise_stdev > 0 else True,
}
私の手元の結果です。
| 劣化の種類 | 平均スコア | 原本との差 | 弁別マージン |
| 原本 | 81.4 | — | — |
| コントラスト30% | 74.8 | 6.6 | 2.9 |
| キャッチコピー消去 | 76.2 | 5.2 | 2.3 |
| 解像度1/8 | 71.0 | 10.4 | 4.5 |
ここで気づいたことがあります。採点器は「壊れている」のではありませんでした。 目に見えて壊した画像なら、ノイズの2〜4倍のマージンでちゃんと下げてきます。
問題は、実務で私が比べたいものの差がここにないことでした。キャッチコピーを14文字にするか22文字にするか。1枚目にライフスタイル写真を置くか UI を置くか。そういう差は、コントラストを30%まで落とすほどの劇的な変化ではありません。実際、候補5案を採点させたときの最大差は 4点、マージンにして 1.7 でした。基準の 2.0 を下回っています。
つまり絶対スコアは、私がすでに答えを知っている差しか検出できない。悪くしたと分かっている画像を悪いと言ってくれても、意思決定には使えません。
なぜ中央に潰れるのか
原因を考えると、責任はプロンプトを書いた私の側にありました。
「0〜100点で評価してください」と言われたモデルには、参照点がありません。何と比べて80点なのかが定義されていない。結果としてモデルは、学習データの分布上で最も安全な帯域に着地します。それが 70〜85 でした。0点や100点は、根拠を持って出せる状況がほとんどないからです。
これは LLM-as-judge で広く知られている性質で、Vision に固有の話ではありません。テキストの品質評価でも同じことが起きます。私はそれを、画像だから大丈夫だろうと勝手に思い込んでいました。
参照点がないなら、参照点を渡せばいい。そう考えて、比較する対象そのものを入力に含める設計へ切り替えました。
ペア比較 — 「どちらが勝つか」だけを聞く
点数をやめて、2枚を並べて勝敗だけを答えさせます。
def pairwise_compare(
a_paths: list[str],
b_paths: list[str],
app_name: str,
market: str = "日本",
) -> dict:
"""2つのスクリーンショット案を比較し、勝者を返す。"""
parts = [
types.Part.from_text(
f"""App Store のスクリーンショット案を2つ比較します。
アプリ名: {app_name}
対象市場: {market}
判断基準は「ストアを流し見しているユーザーが、最初の1秒でこのアプリを
使いたいと思う確率が高いのはどちらか」の一点のみです。
デザインの巧拙ではなく、ダウンロードにつながるかで判断してください。
次の JSON 形式で返してください:
{{
"winner": "A" | "B" | "tie",
"confidence": "high" | "medium" | "low",
"reason": "決め手となった具体的な違い(1〜2文)"
}}"""
)
]
parts.append(types.Part.from_text("=== 案 A ==="))
for p in a_paths:
parts.append(load_image(p))
parts.append(types.Part.from_text("=== 案 B ==="))
for p in b_paths:
parts.append(load_image(p))
resp = client.models.generate_content(
model=JUDGE_MODEL,
contents=parts,
config=types.GenerateContentConfig(
response_mime_type="application/json",
temperature=0.3,
),
)
return json.loads(resp.text)
ここで新しい罠があります。位置バイアスです。先に見せた方、あるいは後に見せた方を、内容と関係なく選びやすくなる性質があります。
対処は単純で、順序を入れ替えてもう一度聞き、両方で同じ案が勝ったときだけ勝ちと認めます。
def compare_debiased(a_paths, b_paths, app_name) -> str:
"""順序を入れ替えて2回比較し、位置バイアスを打ち消す。
戻り値: "A" / "B" / "tie"
2回の結果が一致しない場合は引き分け扱いにする。
"""
first = pairwise_compare(a_paths, b_paths, app_name)["winner"]
# A と B を入れ替えて再度比較(返り値も読み替える)
swapped = pairwise_compare(b_paths, a_paths, app_name)["winner"]
second = {"A": "B", "B": "A", "tie": "tie"}[swapped]
if first == second:
return first
return "tie"
順序を入れ替えたときの不一致率を測ったところ、私の手元では 30組中 18% が不一致でした。この18%は、順序を入れ替えずに1回だけ聞いていたら、そのまま「勝敗」として信じ込んでいた分です。裏返せば、82%の組では順序に関係なく同じ案が勝ちました。この安定感が、絶対スコアには最後までありませんでした。
勝率からランキングを組む
あとは総当たりです。
def rank_candidates(candidates: dict[str, list[str]], app_name: str) -> list[dict]:
"""候補案を総当たりで比較し、勝率順に並べる。
candidates: {"案の名前": [スクリーンショットのパス, ...]}
"""
names = list(candidates.keys())
wins = {n: 0.0 for n in names}
matches = {n: 0 for n in names}
log = []
for a, b in combinations(names, 2):
winner = compare_debiased(candidates[a], candidates[b], app_name)
matches[a] += 1
matches[b] += 1
if winner == "A":
wins[a] += 1.0
elif winner == "B":
wins[b] += 1.0
else: # tie
wins[a] += 0.5
wins[b] += 0.5
log.append({"a": a, "b": b, "winner": winner})
print(f" {a} vs {b} → {winner}")
ranked = [
{
"name": n,
"win_rate": round(wins[n] / matches[n], 3) if matches[n] else 0.0,
"wins": wins[n],
"matches": matches[n],
}
for n in names
]
ranked.sort(key=lambda x: x["win_rate"], reverse=True)
return ranked
5案なら10ペア、各2回のスワップで20リクエストです。実行するとこうなりました。
| 案 | 勝率 | 絶対スコア(参考) |
| 案C(1枚目にライフスタイル写真) | 0.875 | 81 |
| 案A(現行・1枚目にUI) | 0.625 | 82 |
| 案E(コピーを14文字に短縮) | 0.500 | 80 |
| 案B(配色変更) | 0.375 | 83 |
| 案D(枚数を3枚に削減) | 0.125 | 79 |
絶対スコアの列を見てください。最下位の案Dが79点、最上位の案Cが81点。2点差です。同じ素材を同じモデルに投げているのに、聞き方を変えただけで 0.125 と 0.875 という7倍の開きが出ました。情報は最初からモデルの中にあって、私の質問がそれを取り出せていなかっただけです。
コストは総当たりで足りるのか
総当たりは組数が二乗で増えるので、コストを先に見積もっておきます。
Gemini の画像トークンは、768×768 に収まる画像なら 258 トークン固定、それを超えるとタイルに分割されて枚数分だけ加算されます。App Store のスクリーンショットは縦長(1290×2796 など)なので、そのまま投げるとタイル数が膨らみます。
私自身は 828px 幅にリサイズしてから投げています。ASO の判断に必要なのは全体の構図とコピーの読みやすさで、ピクセル等倍の精細さではありません。
| 項目 | 概算 |
| 1案あたりの画像 | 5枚 |
| 1ペア比較の入力画像 | 10枚(A案5+B案5) |
| 5案の総当たり | 10ペア × スワップ2回 = 20リクエスト |
| 総画像入力 | 200枚相当 |
比較そのものは Flash で十分でした。上位2案が絞れた最後の一組だけ Pro に上げて理由を丁寧に出させる、という二段構えにしています。全部を Pro で回すと、精度の差以上にコストが伸びます。
枚数を減らす近道もあります。総当たりをやめて、現行案を固定の対戦相手にして各候補と1回ずつ比較する方式(チャレンジャー方式)なら、5案でも4ペア × 2 = 8リクエストで済みます。順位までは出ませんが、「現行を超えたか」だけを知りたい場面ではこちらで足ります。私は候補が6案を超えたらこちらに切り替えています。
残っている限界を正直に
この方法で分かるのは、Gemini がどちらを好むかであって、ユーザーがどちらをダウンロードするかではありません。勝率と実際のコンバージョン率の相関は、私はまだ検証できていません。検証するには App Store Connect のプロダクトページ最適化で十分なサンプルを貯める必要があり、個人開発の規模では数ヶ月かかります。
なので現時点での位置づけをはっきりさせておくと、これは A/B テストの代わりではなく、A/B テストに載せる案を絞る前段です。プロダクトページ最適化で同時に試せるのは3案まで。手元に7案あるとき、どの3案を残すかを目視と勘で決めていた工程を、再現可能な手順に置き換えた。それだけです。それだけですが、私にとっては十分な価値がありました。
もうひとつ。市場を切り替えると勝敗が動きます。同じ5案を market="日本" と market="米国" で回したところ、上位2案の順位が入れ替わりました。東南アジア地域では Gemini アプリへのプロンプトの約70%が現地語だという Google のレポートもあり、対象市場の指定を省略して回すのは、暗黙のうちに特定市場の基準で判断させることになります。省略しないでください。
明日から回すなら
判断の流れはこうです。
- 候補を5案以内に絞る(総当たりが現実的な上限)
measure_noise で同一入力のノイズを測る(10回・数分で終わります)
- 劣化版を作って弁別マージンを出す。2.0 を超えないなら、その粒度に絶対スコアは使わない
rank_candidates で勝率を出す。順序スワップは省略しない
- 上位2案を App Store Connect のプロダクトページ最適化へ送る
- 実データが返ってきたら、勝率の予測が当たっていたかを記録に残す
6番目を続けていけば、いつか「Gemini の勝率は実CVRとどれだけ相関するのか」に答えが出せるはずです。私はまだそこまで到達していません。到達したら、その数字をここに書きます。
最初に82点という数字を見たとき、私はそれを信じかけました。数字は、根拠がなくても人を安心させてしまいます。採点器を作ったら、まず採点器を採点する。遠回りに見えて、これがいちばん早い道でした。
自分の道具を疑う時間は、成果が見えにくいぶん後回しになりがちです。その数分を惜しまずに済むきっかけになれば嬉しく思います。