新しく仕上げた壁紙を60枚、カタログに追加しようとした朝のことでした。プレビューを眺めていて、ふと手が止まりました。この青いグラデーション、前にも似たものを出した気がする。
記憶は当てになりません。数千枚まで育ったカタログの中に、本当に近いものがあるのか、それとも気のせいなのか。私自身、確かめる術を持たないまま「たぶん大丈夫」で公開してきた時期がありました。
同じ壁紙を色だけ変えて二度出してしまうと、ユーザーは「使い回し」と受け取ります。ストアの印象にも、日々の信頼にも、静かに効いてきます。完全一致のハッシュではすり抜ける近重複(ニアデュプ)を、知覚ハッシュと Claude Vision の二段構えで機械的に捕まえる。個人開発でカタログを回してきた立場から、その仕組みと実測をここに書き残します。
完全一致ハッシュがすり抜けた朝
最初に手を打ったのは、ファイルの中身をそのまま SHA256 で突き合わせる方法でした。これは重複ファイルの検出には確実です。同じバイト列なら同じハッシュになります。以前、この考え方で Files API 上の重複を掃除する台帳を組んだこともあります(Files APIのコンテンツハッシュで重複を排除する に詳しく書きました)。
ところが壁紙の現場では、これがほとんど役に立ちませんでした。理由は単純で、私が「似ている」と感じるものの多くは、バイト列としては別物だからです。
操作 見た目 SHA256
色調を少し温かく調整 ほぼ同じ 別物になる
四辺を数パーセント切り詰め ほぼ同じ 別物になる
書き出しを再圧縮(画質90→85) 同じ 別物になる
解像度を1割縮小 同じ 別物になる
完全一致ハッシュは、ピクセルが1つでも変われば無関係の値を返します。つまり、私がいちばん困っていた「見た目は同じだが少しだけ違う」ケースこそ、原理的に捕まえられなかったのです。
必要なのは、バイト列ではなく見た目の近さ を測る道具でした。
なぜ知覚ハッシュとVisionを組み合わせるのか
見た目の近さを数値化する古典的な道具が、知覚ハッシュ(perceptual hash)です。画像を極端に縮小してグレースケール化し、隣り合う画素の明暗関係だけを取り出して64ビットに畳み込みます。色調整や軽い圧縮では明暗の骨格が保たれるため、近い画像は近いハッシュになります。
ただ、知覚ハッシュ単独では精度が足りませんでした。しきい値を緩めれば取りこぼしは減りますが、構図が偶然似ているだけの別デザインまで拾ってしまいます。逆に厳しくすると、色違いのニアデュプを見逃します。この「緩めても厳しくしても外す」帯こそ、人間なら一目で判断できるのに機械が苦手とする領域です。
そこで役割を分けました。知覚ハッシュは安く・速く・大量に 候補を絞る事前フィルタに徹し、最終的な「これは実質同じか」の判断だけを Claude Vision に委ねます。Vision は高価ですが、絞り込んだ後なら呼び出し回数はごくわずかで済みます。安いフィルタで recall を稼ぎ、高い判定で precision を回収する。この分担が設計の核です。
二段構えの全体像
処理の流れは次の通りです。
カタログの全画像から知覚ハッシュを一度だけ計算し、台帳に保存する
ハッシュ同士のハミング距離で、近い候補ペアだけを列挙する
候補ペアに限り、2枚を Claude Vision に束ねて送り、実質同一かを構造化 JSON で判定させる
判定が明確なペアは自動処理、グレーゾーンは理由つきで人手キューへ回す
肝は2番目です。ここで候補を十分に絞れないと、3番目の Vision 呼び出しが総当たりになり、コストが破綻します。順番に見ていきます。
第一段: 知覚ハッシュで候補ペアを絞る
知覚ハッシュにはいくつか種類がありますが、実装が素直で壁紙に相性が良かったのが dHash(difference hash)でした。画像を9×8のグレースケールに縮め、横に隣り合う画素の明暗を比較して64ビットを作ります。
from PIL import Image
def dhash (path: str , hash_size: int = 8 ) -> int :
# 幅を1つ多く取り、横方向の隣接差分をビット化する
img = (
Image.open(path)
.convert( "L" )
.resize((hash_size + 1 , hash_size), Image. LANCZOS )
)
px = list (img.getdata())
w = hash_size + 1
bits = 0
idx = 0
for row in range (hash_size):
for col in range (hash_size):
left = px[row * w + col]
right = px[row * w + col + 1 ]
bits |= ( 1 if left > right else 0 ) << idx
idx += 1
return bits # 64ビット整数
def hamming (a: int , b: int ) -> int :
# 異なるビット数。0なら同一ハッシュ、小さいほど見た目が近い
return bin (a ^ b).count( "1" )
ハミング距離が小さいほど見た目が近いという解釈になります。私の環境では、しきい値を10ビット以下に置くと、色調整や軽いクロップのニアデュプをほぼ取りこぼさずに拾えました。ここは意図的に緩めです。取りこぼし(recall の欠落)は後段の Vision では取り返せないため、事前フィルタは「疑わしきは通す」側に倒します。
候補ペアの列挙は、素朴に書けば総当たりです。カタログが数千枚に育つと、これは無視できない計算量になります。私は当初、全ペアを二重ループで回して痛い目を見ました。実運用では、上位ビットで束ねてバケツに分けるか、BK木でハミング距離の近傍だけを引く索引に置き換えます。それでも考え方は同じで、「距離がしきい値以下のペアだけを候補として次段へ渡す」ことに変わりはありません。
from itertools import combinations
def candidate_pairs (hashes: dict[ str , int ], threshold: int = 10 ):
# hashes: {image_id: dhash}
# 小規模ならこの総当たりで足りる。数千枚超はBK木/バケツ分割に置き換える
for (id_a, h_a), (id_b, h_b) in combinations(hashes.items(), 2 ):
d = hamming(h_a, h_b)
if d <= threshold:
yield (id_a, id_b, d)
第二段: 近いペアだけをClaude Visionに束ねて判定させる
候補が絞れたら、そのペアだけを Claude に渡します。ここで効くのが、1回のメッセージに複数の画像を並べて送れる点です。2枚を同時に見せ、「実質的に同じ壁紙か」を判定してもらいます。
出力は必ず構造化したいので、tool_choice で判定用のツールを強制的に呼ばせ、スキーマで受け取ります。自由記述の JSON をあとから正規表現で削り出すより、この方が壊れません(構造化出力の検証と修復については ツール利用とJSONスキーマで壊れない構造化出力を作る に整理しています)。
import base64
import anthropic
client = anthropic.Anthropic( api_key = "YOUR_API_KEY" )
def _b64 (path: str ) -> str :
with open (path, "rb" ) as f:
return base64.standard_b64encode(f.read()).decode()
REPORT_TOOL = {
"name" : "report_duplicate" ,
"description" : "2枚の壁紙が実質同一かどうかの判定を返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"duplicate" : { "type" : "boolean" },
"similarity" : { "type" : "number" , "description" : "0.0〜1.0" },
"reason" : { "type" : "string" , "description" : "判断の根拠を一文で" },
},
"required" : [ "duplicate" , "similarity" , "reason" ],
},
}
def adjudicate (path_a: str , path_b: str ) -> dict :
msg = client.messages.create(
model = "claude-haiku-4-5-20251001" ,
max_tokens = 300 ,
system = (
"あなたは壁紙カタログの重複判定担当です。"
"2枚が実質同一(再配色・トリミング・軽微な編集を含む)かを判定します。"
"構図もモチーフも異なる別デザインは duplicate=false としてください。"
),
messages = [{
"role" : "user" ,
"content" : [
{ "type" : "text" , "text" : "この2枚は実質的に同じ壁紙ですか。" },
{ "type" : "image" , "source" : {
"type" : "base64" , "media_type" : "image/jpeg" , "data" : _b64(path_a)}},
{ "type" : "image" , "source" : {
"type" : "base64" , "media_type" : "image/jpeg" , "data" : _b64(path_b)}},
],
}],
tools = [ REPORT_TOOL ],
tool_choice = { "type" : "tool" , "name" : "report_duplicate" },
)
for block in msg.content:
if block.type == "tool_use" :
return block.input
raise RuntimeError ( "ツール出力が返りませんでした" )
判定モデルには Haiku 4.5 を選びました。2枚の壁紙が同じかという問いは、最上位モデルを持ち出すほど難しくありません。事前フィルタで候補が十分に絞れていれば、ここは速くて安いモデルで足ります。難所だけ上位モデルへ回す発想は、前回の クラス別しきい値の較正と棄却ルーティング と同じ考え方です。
しきい値をどう決めたか
数字で確かめないと、しきい値は勘になります。私はカタログから重複と非重複を手で200ペアほどラベル付けし、それを基準に較正しました。
段階 判定の根拠 precision 取りこぼし
知覚ハッシュ単独(距離≤10) ハミング距離のみ 0.61 少ない
+ Claude Vision 判定 2枚を見て実質同一か 0.95 ほぼ変わらず
知覚ハッシュ単独では、候補として挙がったペアの4割近くが「距離は近いが別デザイン」でした。空や海の壁紙は明暗の骨格が似がちで、ハッシュだけでは切り分けられません。ここに Vision を挟むと precision が 0.61 から 0.95 まで上がり、しかも事前フィルタを緩めに保っているので取りこぼしはほとんど増えませんでした。
較正のたびにラベル済みペアで回帰を確認するのが習慣になりました。しきい値をいじって recall が落ちていないか、数字で見張る。私はこの一手間を、公開前に自分を安心させるための保険だと思っています。
総当たりを避けるコスト設計
Vision は事前フィルタの後にしか呼ばないので、コストは候補ペア数でほぼ決まります。ここが設計の効きどころです。
カタログ全体を一度洗い直したときの実測です。約2,180枚。総当たりなら組み合わせは約237万ペアにのぼります。ここに Vision を全部当てるのは論外です。知覚ハッシュの事前フィルタを通すと、候補は812ペアまで落ちました。全ペアのおよそ0.03%です。Vision 呼び出しはこの812回だけで済みました。
指標 値
カタログ枚数 2,180
総当たりのペア数 約2,375,000
知覚ハッシュ通過後の候補ペア 812(約0.03%)
Vision が実質同一と判定 213
事前フィルタを持たずに Vision へ流していたら、呼び出しは数千倍に膨らみ、費用も待ち時間も現実的ではなくなっていました。知覚ハッシュはローカルで一瞬で計算できるうえ、一度出したハッシュは台帳に保存して使い回せます。つまり「安い計算で母数を削り、高い判定を最小回数に抑える」構造が、そのままコストの上限を決めています。
大量に判定するなら、候補ペアを Message Batches に束ねると、さらに単価とスループットの両面で楽になります。
グレーゾーンの棄却と正規シリーズの誤検出回避
自動化で怖いのは、白黒つかないケースを機械が勝手に断定することです。私は similarity を三つの帯に分けました。
similarity 扱い
0.85以上 近重複として自動でフラグ
0.55〜0.85 判断を保留し、理由つきで人手キューへ
0.55未満 別デザインとして通過
グレーゾーンを無理に断定させないことが、誤削除を防ぐ一番の近道でした。人手キューには Vision が返した reason をそのまま添えるので、レビューは数秒で終わります。「配色違いだが構図が同一」といった一文があるだけで、判断の手がかりになります。
もう一つ、実運用で欠かせなかったのが正規バリエーションの除外です。壁紙には、意図して配色違いを揃えたシリーズがあります。これは重複ではなく、正規のプロダクトです。ここを誤検出すると、真面目に作った商品を消しかねません。私は各画像に series_id を持たせ、同じシリーズに属するペアは判定の対象から最初に外すようにしました。機械の目より先に、人間が決めた「これは意図した別物だ」という文脈を尊重する。この一線は譲らないようにしています。
週次の増分運用に落とす
一度カタログ全体を洗ったあとは、毎回全件を回す必要はありません。新しく追加する分だけを、既存カタログのハッシュ台帳と突き合わせれば十分です。
60枚の新規バッチを2,180枚の既存と照合すると、比較は約13万ペア。知覚ハッシュの事前フィルタを通すと候補は数十ペアに収まり、Vision 判定は一瞬で終わります。ハッシュは追加時に一度だけ計算して台帳に足していくので、増分運用ではフィルタの計算コストもほぼ無視できます。
この形に落ち着いてから、公開前の「前にも出した気がする」という曖昧な不安を、数字で確かめられるようになりました。似ていれば人手キューに理由つきで並び、似ていなければ静かに通る。判断を勘から手続きへ移せたことが、いちばんの収穫でした。
まとめ
完全一致のハッシュは、同じファイルを見つけるには確実ですが、見た目の近さは測れません。近重複を捕まえるには、知覚ハッシュで安く母数を絞り、最終判断だけを Claude Vision に委ねる二段構えが、コストと精度の両面で釣り合いました。しきい値はラベル済みペアで較正し、グレーゾーンは断定させず人手へ、正規シリーズは文脈で除外する。この三つを守れば、個人運用でも公開前の重複チェックを手続きに変えられます。
まずは手元のカタログから200ペアほど手でラベル付けし、知覚ハッシュ単独の precision を測ってみてください。そこに Vision を一段足したとき、数字がどれだけ動くか。ご自身のカタログで確かめる一歩が、いちばん確かな出発点になると思います。
近重複の判定は、正解が一意には決まらない厄介な作業です。だからこそ手続きに落とせたときの安心は大きく、同じ悩みを持つ方の一助になれば嬉しく思います。最後までお付き合いくださり、ありがとうございました。