唐美人を描いた日本の浮世絵と、中国の年画を並べて見ていたときのことです。私にはどちらも「中国風の絵」にしか見えませんでした。個人開発で続けている浮世絵の壁紙アプリに入れる素材ですから、中国の版画が混ざったまま配信するわけにはいきません。仕分けの手を止めて、そのまま数分固まっていました。
同じ問いを Gemini に投げてみると、どちらの画像に対しても迷いなく答えが返ってきます。しかも、返ってくる答えは私が期待した方向に寄っていました。「これは日本の浮世絵ですか」と聞けば、たいてい「はい」と返ってくるのです。
このやりとりで気づいたのは、精度の問題ではなく問いの立て方の問題だということでした。以来、来歴の判定はモデルに任せず、モデルには「紙の上に何が写っているか」だけを答えてもらう形に組み替えています。その配線と、そこに至るまでに捨てた案を残しておきます。
なお、扱っている画像は公有となった古い版画そのもので、生成されたものではありません。AI を使っているのは配信前の検査工程だけです。App Store と Google Play の両方に同じ素材を出しているため、取り下げが必要になったときの手間も二重になります。止めるなら配信前しかありません。
画題は最も目立つ手がかりで、最も外す手がかりです
日本の絵師は中国の画題を好んで描きました。唐美人、唐子、故事人物。画面の中央を占めるモチーフだけを見れば、それは中国の絵に見えます。逆に、中国の年画にも日本の版画によく似た構図と彩色のものがあります。
つまり、画面で最も面積を占める情報と、来歴を決める情報が一致していません。人間が最初に目を向ける場所と、答えのある場所がずれているわけです。
モデルに画像を丸ごと渡して「日本の作品ですか」と聞くと、この最も目立つ情報が判断を引っ張ります。加えて、問いの中に「日本の作品」という語が入っている時点で、答えは肯定側へ傾きます。否定するには、肯定を退ける根拠を自分で見つけてこなければならないからです。見つからなければ、肯定の方が返しやすい。
私はここで、二つの間違いを同時に犯していました。判断材料として不適切なものを主役に据えたことと、答えを含んだ問いを投げたことです。
判定に使えるのは、紙に刻まれている痕跡だけです
素材を何十点も見ているうちに、実際に効いている手がかりは限られていると分かってきました。整理すると次のようになります。
痕跡 示すもの 読み取りの注意
ひらがな・変体仮名 日本 の作品であることをほぼ確定させる題簽や画中の詞書に出る。崩し字なので判読より存在の確認が先
版元印・改印 日本 の出版流通を経た版画であること余白の端に小さく入る。トリミングされた素材では失われやすい
落款・絵師印 絵師の特定につながる 四隅のいずれか。部分拡大の素材には写っていないことがある
漢字のみの題跋が長い 中国 の作品を疑う単独では決め手にならない。日本の作品にも漢文の賛はある
大きな朱印が複数 中国 の作品を疑う鑑蔵印が重なる形。日本の版画の印とは大きさの桁が違う
細密な植物図と学名表記 西洋のボタニカルアート 浮世絵の素材集に紛れることが実際にある
この表の右列が示しているとおり、どの痕跡も単独では決め手になりません。決め手になるのは、日本側の痕跡が一つでも確認できたときだけです。中国側の特徴は「疑う」で止まり、確定には使えません。
非対称なのです。この非対称性を、後述する判断の表にそのまま持ち込みます。
全体画像のまま聞くと、その痕跡はピクセルとして残りません
痕跡を根拠にすると決めたところで、次の問題にぶつかりました。落款や改印は、画面全体から見ればごく小さい領域です。
手元の素材でよくあるのは 1200×1800 程度の版画です。改印が縦横 60 ピクセルほどの領域に収まっているとして、画面全体に対する面積比はおよそ 0.17% になります。モデルに渡す前に長辺 768 ピクセル程度へ縮められると、この領域は縦横 25 ピクセル前後まで落ちます。輪郭は残っても、文字としては読めません。
読めないものを根拠にしろと指示しても、モデルは指示に従おうとします。従おうとした結果が、それらしい読みの創作です。私が最初に見た「迷いのない答え」の正体は、おそらくこれでした。
対処は単純で、聞く前に切り出しておくことです。四隅と余白を原寸で切り出したシートを作り、そのシートを渡します。全体像は構図の説明のために別途添えますが、痕跡の読み取りは切り出し側だけを見て答えてもらいます。画像の解像度とトークン消費の兼ね合いについては画像分類のトークンを解像度で制御する話 に別途書きました。
このゲートでは、モデルには推論の深さより読字の精度を求めています。そのため呼び出しは gemini-3.1-pro に寄せています。Flash 系は同じ工程の中でも枚数の多い前処理側に使い分けています。
「答え」ではなく「見えたもの」を返させます
ここまでの整理を、そのままスキーマに落とします。返してもらうのは国名でも真偽値でもなく、痕跡の一覧です。
import json
import time
from pathlib import Path
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_API_KEY" )
MARK_SCHEMA = {
"type" : "object" ,
"properties" : {
"marks" : {
"type" : "array" ,
"items" : {
"type" : "object" ,
"properties" : {
# 見えたものの種類。判定語(japanese / chinese)は入れない
"kind" : {
"type" : "string" ,
"enum" : [
"kana_script" , # ひらがな・変体仮名
"publisher_seal" , # 版元印
"censor_seal" , # 改印
"artist_signature" , # 落款
"red_seal_large" , # 大きな朱印
"long_colophon" , # 長い漢文の題跋
"latin_botanical_name" ,
],
},
"where" : { "type" : "string" }, # 切り出しシートのどのコマか
"transcription" : { "type" : "string" }, # 読めた文字。読めなければ空
"legibility" : {
"type" : "string" ,
"enum" : [ "legible" , "partial" , "illegible" ],
},
},
"required" : [ "kind" , "where" , "legibility" ],
},
},
# 痕跡が一つも写っていない場合に true。空配列との取り違えを防ぐ
"no_marks_visible" : { "type" : "boolean" },
},
"required" : [ "marks" , "no_marks_visible" ],
}
PROMPT = """この画像は、一枚の版画の四隅と余白を原寸で切り出して並べたシートです。
シートに実際に写っているものだけを列挙してください。
- 版画の主題や画風から推測したことは書かないでください
- 文字が潰れていて読めない場合は legibility を illegible にし、transcription は空にしてください
- 痕跡が一つも見当たらない場合は marks を空にし、no_marks_visible を true にしてください
- 作品がどこの国のものかは判断しないでください
"""
def read_marks (sheet_path: Path, retries: int = 3 ) -> dict :
image = types.Part.from_bytes(
data = sheet_path.read_bytes(),
mime_type = "image/jpeg" ,
)
last_error = None
for attempt in range (retries):
try :
response = client.models.generate_content(
model = "gemini-3.1-pro" ,
contents = [image, PROMPT ],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = MARK_SCHEMA ,
# 読字なので出力は短い。長い記述が返るのは創作のサイン
max_output_tokens = 1024 ,
),
)
return json.loads(response.text)
except json.JSONDecodeError as e:
# スキーマ指定でも稀に欠けた JSON が返る。ここは黙って握らない
last_error = e
except Exception as e:
last_error = e
time.sleep( 2 ** attempt)
raise RuntimeError ( f " { sheet_path.name } の読み取りに失敗しました: { last_error } " )
kind の enum に判定語を一切入れていないところが要点です。選択肢に japanese_style のような語を混ぜた版も試しましたが、それを入れた途端、モデルは痕跡を読むのをやめて画風の印象で埋め始めました。語彙が判断を呼び込みます。
no_marks_visible を別のフィールドとして持たせているのは、空配列を二通りに解釈できてしまうからです。何も写っていないのか、写っているが読み落としたのか。前者は正しい観察結果ですが、後者は失敗です。モデル自身に明示させておくと、後段でこの二つを区別できます。
証拠から結論を出すのは、モデルではなくこちら側の表です
読み取った痕跡を、先ほどの非対称性のまま判定へ落とします。ここは通常のコードで、モデルは関与しません。
from dataclasses import dataclass
JAPANESE_MARKS = { "kana_script" , "publisher_seal" , "censor_seal" }
SUSPECT_MARKS = { "red_seal_large" , "long_colophon" }
OUT_OF_SCOPE_MARKS = { "latin_botanical_name" }
@dataclass
class Verdict :
route: str # "accept" / "hold" / "reject"
reason: str
def decide (observation: dict ) -> Verdict:
marks = observation[ "marks" ]
# illegible な痕跡は「あった」ことしか言えないので、根拠には数えない
usable = {m[ "kind" ] for m in marks if m[ "legibility" ] in ( "legible" , "partial" )}
seen = {m[ "kind" ] for m in marks}
if usable & OUT_OF_SCOPE_MARKS :
return Verdict( "reject" , "浮世絵以外の版種です" )
if usable & JAPANESE_MARKS :
# 日本側の痕跡が一つでも読めれば確定。中国側の特徴が同居していても覆らない
return Verdict( "accept" , f "日本側の痕跡を確認しました( { sorted (usable & JAPANESE_MARKS ) } )" )
if seen & SUSPECT_MARKS :
return Verdict( "hold" , "中国の作品を疑う特徴があり、日本側の痕跡が読めていません" )
if observation[ "no_marks_visible" ]:
return Verdict( "hold" , "部分拡大の素材とみられ、来歴を示す痕跡が写っていません" )
return Verdict( "hold" , "痕跡はありますが判読できていません" )
usable と seen を分けているのが、運用してみて効いた箇所です。illegible の痕跡は「そこに何かがある」という情報でしかありません。これを根拠に採用へ回すと、読めていない印を読めたことにしてしまいます。一方で保留へ回す判断には使えます。読めない印が並んでいる素材は、そもそも人が見るべき素材だからです。
結果として、このゲートから採用が出るのは日本側の痕跡が読めたときだけになりました。それ以外はすべて保留です。厳しすぎるように見えますが、後述するとおり、これで困ることはほとんどありませんでした。
信頼度スコアを出させるのをやめました
最初の設計では、モデルに 0 から 1 の確信度を返させて、しきい値で採用と保留を分けていました。素直な設計に見えます。実際、しばらくはそれで動かしていました。
やめた理由は精度ではありません。検算できないから です。
0.82 という数字が返ってきたとき、私にできることは、その数字を信じるか信じないかの二択しかありません。低すぎると思えばしきい値を下げ、誤りが混ざれば上げる。上げ下げのたびに全件を見直す羽目になり、そのうち自分が何を基準に動かしているのか分からなくなりました。
痕跡の列挙に切り替えてからは、返ってくるのが「右下のコマに改印、判読可、読みは〇〇」という形の主張になります。切り出しシートの該当箇所を開けば、その主張が正しいかどうかを私が数秒で確かめられます。モデルの出力が、検証可能な形になった わけです。
これは事前に予想していなかった変化でした。私は精度を上げようとしてスキーマをいじっていたのですが、実際に改善したのは精度ではなく、間違いに気づくまでの時間の方でした。誤りの総数は劇的には減っていないのだと思います。ただ、誤りが目の前に出てくるようになりました。
しきい値の調整をやめられたことも、地味ながら大きな変化でした。判断の表はコードとして読めるので、方針を変えたければ表を書き換えれば済みます。数字を上下させて挙動を推測する作業が丸ごと消えました。
検算できる形で保留に置くための注意点
この配線で最初につまずいたのは、モデルの出力ではなく保留の置き方でした。
保留に回した素材を後で見直そうとしたとき、「右下のコマに改印」という主張のコマ番号が、元画像のどの領域を指しているのか分からなくなっていたのです。切り出しシートを作り直せば復元はできますが、その手間が積み上がると、保留の山は開かれないまま残ります。せっかく検証できる形にした出力を、自分で検証しにくい場所へ置いていました。
回避策として、判定の結果と一緒に次の三つを保存するようにしました。
切り出しシートのファイル名と、各コマが元画像のどの座標から切られたかの対応表
モデルが返した観察結果そのもの(判定後の結論ではなく、痕跡の一覧のまま)
判断の表がどの分岐でその結論を出したかの理由文
三つ目が意外に効きます。理由文がないと、時間を置いてから見たときに「なぜこれが保留なのか」を再現できません。decide() が Verdict に reason を持たせているのは、そのためです。
本番運用では、保留のフォルダは必ず開かれる場所に置くことも大事でした。素材の入庫先とは別のディレクトリへ移し、次のバッチを組む前に必ず通る導線に入れています。
保留は失敗ではなく、三つ目の出力です
このゲートには採用と除外のほかに、保留という出口を最初から用意しています。素材フォルダに _hold/ を作って移し、後で人が見る対象として残しておく形です。
保留が多く出ることを、私は当初は設計の失敗だと考えていました。今は逆に見ています。保留に落ちるのは、画像だけからは原理的に決まらない素材だからです。
たとえば、素材が版画の部分拡大である場合。題簽も落款も画面の外にあり、どれだけ解像度を上げても写っていないものは読めません。この素材に対して何らかの答えを出すモデルがあるとしたら、それは推測しているのであって、読んでいるのではありません。
そして、保留に落ちた素材の多くは、画像以外の情報で解決します。素材の入手元に作品ページがあれば、そこに絵師名と制作年が書かれています。取得時にそのメタデータを控えておけば、画像判定を経由せずに来歴が確定します。
情報源 優先度 使いどころ
入手元サイトの作品メタデータ 最優先 あればこれを正とする。画像判定は不要
切り出しシートから読めた痕跡 次点 メタデータがない素材の一次判定
画風・画題の印象 使わない カテゴリ分けの補助にとどめる
順序を明示しておくと、実装のときに迷いません。メタデータのない素材だけを画像判定へ流す形をお勧めします。私は当初、すべての素材を同じ経路で処理しようとして、メタデータがある素材にまで画像判定をかけていました。確かめられる情報があるのに、わざわざ推測させていたわけです。
分からないと言える余地が、判定より先に必要でした
ここまでの組み替えを一言でまとめると、モデルの役割を判定者から観察者へ下げた、ということになります。
判定者としてのモデルは、答えを出すことを求められています。求められれば出します。根拠が写っていなくても出します。観察者としてのモデルは、見えたものを言えばよく、見えなければ見えないと言えます。この差が、そのまま配信前に止められるかどうかの差になりました。
同じ考え方は、画像から属性を推定する工程には広く当てはまると感じています。似た画像を弾く工程については公開前に近重複を弾くゲートの作り方 に書きましたが、あちらは「似ている」という判断そのものが数値で表せるので、事情が違います。来歴のように、答えが画像の外にあり得る属性では、まず保留の出口を作るところから始めた方が早いです。
もし今、画像から何かを判定させる工程を組んでいるのでしたら、スキーマの enum に判定語が混ざっていないかを一度確かめてみてください。そこに一語あるだけで、モデルは読むのをやめて答え始めます。私の場合は、そこが分岐点でした。
拙い試行錯誤の記録ですが、同じ工程を組む方の遠回りが少しでも減れば嬉しく思います。お読みいただきありがとうございました。