浮世絵壁紙の新着分を夜のうちに分類させていたとき、前の週に青と返ってきた一枚が、その晩は灰と返ってきました。プロンプトは変えておりません。モデルも同じです。変えたのは、書き出しに使っていたツールを別のものへ差し替えたことだけでした。
原因を探すのに二晩かかりました。行き着いたのは、モデルの気分ではなく、私が渡していた画像の側でした——正確には、画素値がどの色空間の数値なのかを伝える手がかりを、書き出しのときに落としていたのです。
同じ症状に心当たりのある方のために、書き出し方を5通り並べて数えた記録を書き残します。個人開発で運用している壁紙アプリの新着 120 枚が材料です。
揺れていたのは色名だけでした
最初に手を付けたのは、揺れの範囲を絞ることでした。分類の出力は「被写体」「構図」「主たる色」「用途タグ」の4項目です。この4項目のうち、どれが入れ替わっているのかを並べ直しました。
被写体と構図は、2回投げても同じ答えが返りました。用途タグも同じです。入れ替わっていたのは主たる色の1項目だけでした。
ここで方向が決まりました。プロンプト全体を書き直す前に、色という1項目だけを対象にして測ればよいのです。範囲を狭めたおかげで、120 枚を5通りで投げても一晩で終わりました。
揺れているのは出力の全部ではありません。項目ごとに分けて数えると、直す場所が一箇所に寄ります。
測り方を決める前に、書き出しの5通りを並べました
比較の条件
原本は Display P3 の 4K 画像です。ここから5通りを作りました。長辺はすべて 1,024 ピクセルへ揃えてあります。解像度の影響を混ぜないためです。
- sRGB へ変換して書き出した JPEG(品質 95)——これを基準に置きます
- Display P3 のまま、ICC プロファイルを埋め込んだ JPEG(品質 95)
- Display P3 の画素値のまま、ICC プロファイルを落とした JPEG(品質 95)
- sRGB へ変換した PNG
- sRGB へ変換した WebP(品質 80)
3番は、配信用に少しでも軽くしたいという理由で私がやっていたことです。プロファイルは数十キロバイトありますので、落としたくなります。
正規化そのものは Pillow の ImageCms で足ります。
# normalize_srgb.py — 渡す前に色空間をこちらで決めてしまう
from io import BytesIO
from PIL import Image, ImageCms
SRGB = ImageCms.createProfile("sRGB")
def to_srgb_jpeg(path: str, long_edge: int = 1024, quality: int = 95) -> bytes:
img = Image.open(path)
raw = img.info.get("icc_profile")
if raw:
# プロファイルがある場合だけ変換する。無い画像に変換をかけると二重適用になる
src = ImageCms.ImageCmsProfile(BytesIO(raw))
img = ImageCms.profileToProfile(img, src, SRGB, outputMode="RGB")
else:
# 手がかりが無い画像は sRGB とみなすほかない。ここはログに残す
img = img.convert("RGB")
img.thumbnail((long_edge, long_edge), Image.LANCZOS)
buf = BytesIO()
# 変換後は sRGB なので、埋めても埋めなくても解釈は一つに決まる
img.save(buf, format="JPEG", quality=quality, optimize=True)
return buf.getvalue()
profileToProfile を通すと画素値そのものが書き換わります。プロファイルを埋め込むだけの操作とは別物です。この二つを同じものだと思っていたことが、私の取り違えの入口でした。
判定の投げ方
判定は1枚ずつ、同じプロンプトで投げます。温度は 0 に固定し、色名は8語の閉じた語彙に限りました。自由記述にすると「群青」「紺碧」のような表記の差が混ざり、色が変わったのか言い方が変わったのかを分けられません。
何を一致とみなすか
基準の答えと、他の4通りの答えが同じ語であれば一致と数えました。加えて、基準そのものを2回投げて自己一致率も測っています。基準自体が揺れていたら、比べる土台がありません。
数えた結果
| 渡し方 | 基準との色名一致 | 不一致の枚数 | 1枚あたり入力トークン |
| sRGB 変換 JPEG(基準・2回実行の自己一致) | 96% | 5 | 258 |
| P3 + ICC プロファイル埋め込み JPEG | 88% | 14 | 258 |
| P3 の画素値・プロファイルなし JPEG | 71% | 35 | 258 |
| sRGB 変換 PNG | 96% | 5 | 258 |
| sRGB 変換 WebP(品質 80) | 93% | 8 | 258 |
予想と逆だったのは3番です。プロファイルを埋め込んだ2番よりも、プロファイルを落とした3番のほうが悪くなりました。71% ですから、120 枚のうち 35 枚で色名が入れ替わっています。軽くするための最適化が、いちばん判定を壊していたのです。
もう一つ目を引いたのは、入力トークンがどの通りでも 258 で同じだったことです。長辺を揃えていますので、これ自体は当然の結果です。ただ、当然であることに意味があります——色の正確さは、追加の費用なしに手に入るということです。
不一致の中身も数えました。3番の 35 枚のうち 33 枚は隣り合う色の取り違えで、青と藍、橙と桃のあいだを行き来していました。緑と赤のように離れた色へ飛んだのは2枚だけでした。
なぜこうなるのかは、推測として書き残します
Gemini API の画像理解ドキュメントに書かれているのは、受け付ける形式と、画像がどうトークンに変わるかまでです。ICC プロファイルをどう扱うかは、私が読んだかぎり明言されておりません。
ですので以下は私の推測です。画素値がどの色空間の数値なのかを伝える手がかりが無ければ、sRGB として読むほかありません。Display P3 の数値を sRGB として読むと、同じ数値がより鮮やかな色として解釈されます。藍に見えていたものが青に寄り、桃に見えていたものが橙に寄る——隣接色に偏った不一致の形は、この読み違えとよく重なります。
いま思えば、軽くする工夫のほうが得だと決め込んでいた私の癖が、確かめる手間を飛ばさせていたのかもしれません。
推測ですから、記事の結論には置きません。置けるのは、原因がどちらにあるかを確かめる手続きのほうです。色を尋ねる前に、色空間はこちらで決めておきます。 この一行だけで、私の 35 枚は 5 枚まで下がりました。
色名を閉じた語彙へ丸め、機械側の基準値と突き合わせています
正規化で済むなら、それで終わりにしたいところでした。ただ、渡す画像の出自を私がいつも選べるとは限りません。受託でお預かりする画像や、過去に別の設定で書き出した在庫が混ざります。
そこで判定の側も変えました。色名を8語に閉じ、迷ったときは「不明」を返してよいことにしたのです。
# classify_color.py — 閉じた語彙と「不明」を許す
from google import genai
from google.genai import types
PALETTE = ["赤", "橙", "黄", "緑", "青", "藍", "紫", "無彩色", "不明"]
client = genai.Client(api_key="YOUR_API_KEY")
SCHEMA = types.Schema(
type=types.Type.OBJECT,
properties={
"color": types.Schema(type=types.Type.STRING, enum=PALETTE),
"confident": types.Schema(type=types.Type.BOOLEAN),
},
required=["color", "confident"],
)
def classify(jpeg_bytes: bytes) -> dict:
res = client.models.generate_content(
model="gemini-3.8-flash",
contents=[
types.Part.from_bytes(data=jpeg_bytes, mime_type="image/jpeg"),
# 「近い色」を選ばせず、迷いを迷いとして返させるところが肝
"画像全体でいちばん面積を占める色を一語で選んでください。"
"二つの色のあいだで迷う場合は不明を選んでください。",
],
config=types.GenerateContentConfig(
temperature=0,
response_mime_type="application/json",
response_schema=SCHEMA,
),
)
return res.parsed # {"color": "藍", "confident": True}
enum を置くと表記の差が消えますので、揺れの原因を色そのものに絞れます。この形はモデル入れ替えの合否を一致率で決めると、カテゴリ分布のずれは通過しますで書いた台帳と組み合わせると、世代を上げた日の比較もそのまま流せます。
そして検算です。画像の平均色相は自分の手元で計算できますので、モデルの答えと突き合わせます。
# hue_check.py — 機械側の基準値。モデルの答えと離れたものだけ人が見る
import colorsys
from PIL import Image
HUE_BANDS = [(15, "赤"), (45, "橙"), (70, "黄"), (170, "緑"),
(240, "青"), (265, "藍"), (310, "紫"), (360, "赤")]
def hue_label(path: str) -> str:
img = Image.open(path).convert("RGB").resize((64, 64), Image.LANCZOS)
hs, ss = [], []
for r, g, b in list(img.getdata()):
h, s, _ = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
hs.append(h * 360)
ss.append(s)
if sum(ss) / len(ss) < 0.12: # 彩度が低い画像は色名で語れない
return "無彩色"
mean_hue = sum(hs) / len(hs)
return next(label for edge, label in HUE_BANDS if mean_hue < edge)
平均色相は乱暴な指標です。二色が半分ずつの画像では的を外します。ですから合否を決める道具には使わず、離れたものだけを人の目のキューへ回す用途に限っています。この運用に切り替えてから、私が実際に目視した枚数は 120 枚のうち 9 枚まで減りました。
丸め方をもう一段あげる手も残してあります。色名ではなく「暖色」「寒色」「中間」の3値にすると、プロファイルを落とした3番でも一致は 98% まで上がりました。隣接色の揺れは3値には影響しないのです。アプリの一覧で色フィルタを提供するだけなら、この粗さで用は足ります。
つまずいた三つの落とし穴
- 埋め込みと変換を同じものだと思っていました。 ICC プロファイルを埋め込む操作は、画素値に触りません。渡す先が解釈してくれる前提の設計です。こちらで決めきるなら
profileToProfile を通します。
- サムネイル生成の途中でプロファイルが落ちていました。 配信用の縮小をかけるライブラリの既定設定で、プロファイルが引き継がれておりませんでした。エラーは出ません。本番運用では、この無音の欠落がいちばん厄介です。書き出し後に
Image.open(path).info.get("icc_profile") を確かめる1行を、パイプラインの最後へ置きました。
- アルファ付き PNG を JPEG へ落とすと、背景の色が混ざります。 透過部分は白か黒で埋まりますので、平均色相も色名も動きます。私は白で埋めた版と黒で埋めた版の両方を作って比べ、白に寄せる判断をしました。
いずれも、モデルの性能とは関係のない場所でした。画像を渡す前の工程を疑う癖がついたのは、この三つを踏んだあとです。渡す前の縮小についてはGemini API に画像を送る前に Pillow で縮小しておくべき理由にも書いております。
今日、20 枚だけで試せること
すべてを測り直す必要はありません。手元の画像を 20 枚選び、プロファイルを落とした版と sRGB へ変換した版の2通りだけを投げて、色名を並べてみてください。ずれが2〜3枚出たなら、渡し方の側に直す余地があります。
私はこの2通りの比較を、新しい書き出し設定を採用する場合の入口に置くようにしました。トークンの費用は増えませんので、迷ったら測るほうが早いのです。
お読みいただきありがとうございました。同じところで二晩を使わずに済む方が一人でもいらしたら、書いた甲斐があります。