個人開発で App Store と Google Play に配信している浮世絵壁紙アプリには、素材を追加するたびに重複検査を通す工程があります。ある日、同じ構図で摺りの色がまるで違う二枚を続けて登録しようとしたところ、検査はその二枚を「同一」と判定しました。
私にとって、この二枚は別の作品です。版木が同じでも、摺りが違えば両方残したい。機械の答えと私の答えが食い違ったこの一件が、検査の作りを見直すきっかけになりました。
ちょうど File Search が画像のマルチモーダル検索に対応し、gemini-embedding-2 で画像をそのまま埋め込めるようになった時期でもあります。ハッシュ照合をやめて埋め込み検索へ寄せるべきか、しばらく迷いました。結論だけ先に書くと、私は埋め込みを門番にしませんでした。その判断の過程を、距離の実数と一緒に残しておきます。
先に結論 — 門番はハッシュのまま、埋め込みは提示役に
見直し後の構成は次のとおりです。
| 段階 | 手法 | 機械に許した動作 |
| 登録時・第一関門 | SHA-256(バイト一致) | 自動で拒否 |
| 登録時・第二関門 | dHash(知覚ハッシュ・64bit) | 保留にしてレビューキューへ積む。削除はしない |
| 登録後・非同期 | 埋め込み近傍検索 | 「似ている既存作品」を並べて見せるだけ |
埋め込み検索を導入しなかったのではありません。導入した上で、拒否の権限を与えなかった、という判断です。理由は誤りの方向にあります。ハッシュの誤りは「変形を見逃す」方向に出ますが、埋め込みの誤りは「別作品を重複と言う」方向に出ます。前者は重複が一組残るだけで済み、後者は作品を一点失います。削除は不可逆ですから、危険な側の誤りを持つ手法に門番を任せない、というのが私の線引きです。
「重複」は一枚岩ではない — 三つの層に分ける
見直しの途中で気づいたのは、技術選定の前に「何を重複と呼ぶか」が決まっていなかったことです。書き出してみると、重複と呼んでいたものは三つの層に分かれました。
| 層 | 中身 | 例 | 答えを出せるもの |
| 第一層 | 同一ファイル | 同じファイルの二重登録 | SHA-256 で機械が即答できる |
| 第二層 | 同一画像の変形 | 再エンコード・リサイズ・軽微なトリミング | 知覚ハッシュで機械がほぼ即答できる |
| 第三層 | 近い見た目の別作品 | 別摺り・連作の一枚・構図の近い作品 | 機械には答えられない。編集判断 |
冒頭の二枚は第三層の住人でした。そして第三層は、そもそも「検出精度」の問題ではありません。同じ版木の別摺りを一点として扱うか二点として扱うかは、コレクションをどう見せたいかという方針の問題です。ここを技術で解決しようとしていたことが、迷いの正体だったのだと思います。
知覚ハッシュの守備範囲を距離で確かめる
第二層をどこまでハッシュに任せられるかは、手を動かせば確かめられます。構図のある合成画像を1枚作り、実際の運用で起きる変形を加えて、dHash の Hamming 距離を測りました。以下は検証に使ったコードそのままです。
from PIL import Image
def dhash(img, size=8):
# 9x8 のグレースケールに縮小し、隣接画素の明暗差を 64bit に符号化します
g = img.convert("L").resize((size + 1, size), Image.LANCZOS)
px = g.load()
bits = 0
for y in range(size):
for x in range(size):
bits = (bits << 1) | (1 if px[x, y] > px[x + 1, y] else 0)
return bits
def hamming(a, b):
# 距離 0 = 完全一致。64 に近いほど別物です
return bin(a ^ b).count("1")
# 使い方の例
a = dhash(Image.open("original.png"))
b = dhash(Image.open("candidate.jpg"))
print(hamming(a, b)) # 期待する出力: 変形なら一桁、別画像なら 20 以上
手元での計測結果です。
| 変形 | Hamming 距離(/64) | しきい値 10 での判定 |
| JPEG 再エンコード(品質40) | 2 | 重複と判定 |
| 50% リサイズ | 0 | 重複と判定 |
| 外周 5% トリミング | 8 | 重複と判定 |
| 明度 +30% | 2 | 重複と判定 |
| 16:9 への引き伸ばし | 0 | 重複と判定 |
| 色替え(チャンネル入替) | 3 | 重複と判定 |
| 下部 25% を切り落とし | 29 | 通過してしまう |
| まったく別の画像 | 28 | 通過(正しい) |
再エンコード・リサイズ・軽微なトリミングといった第二層の典型は、距離一桁できれいに拾えます。縮小してから比較する仕組みなので、リサイズやアスペクト比の変更には距離 0 のまま反応しました。第二層の門番としては十分な成績です。
距離 29 と 28 の間に境界線は引けない
計測結果には、事前の予想と逆だった点が二つあります。
一つめは、下部を 25% 切り落とした画像の距離が 29 で、まったく別の画像の 28 とほぼ同じだったことです。大きなトリミングは、ハッシュから見れば「別画像」と区別がつきません。ここでしきい値を 30 まで上げれば拾えるように思えますが、そうすると別画像まで重複と判定し始めます。しきい値の調整では回避できない境界が、この 29 と 28 の間にあります。大きく切り抜かれた同一画像を拾いたければ、ハッシュとは別の目、つまり埋め込みのような意味側の手法が必要になります。
二つめは、色をまるごと入れ替えた画像の距離がわずか 3 だったことです。dHash はグレースケールの明暗勾配だけを見るので、構図が同じなら色が違っても同一と答えます。冒頭の別摺り二枚が「同一」と判定されたのは、まさにこの性質によるものでした。
ここで立ち止まって考えると、面白いことに気づきます。仮に埋め込み検索へ乗り換えていたとしても、別摺りの二枚は高い類似度を返したはずです。意味的にも視覚的にも近いのですから。つまり第三層の問いには、ハッシュも埋め込みも同じ側の答えを返します。どちらの技術を選んでも、冒頭の食い違いは解消しなかった。技術選定に見えていた問題が実は定義の問題だった、というのが今回いちばんの収穫でした。
File Search 側で何が変わったか
埋め込みを提示役として採用する後押しになったのは、今年の一連の更新です。Gemini API リリースノートによると、2026年3月10日にマルチモーダル埋め込みモデル gemini-embedding-2 がプレビュー公開され、4月22日に GA、そして5月5日には File Search が同モデルによる画像のネイティブ埋め込みに対応しました。OCR でテキスト化してから検索する回り道がなくなり、画像を画像のまま索引に入れられます。
自前で埋め込みベクトルの保存と近傍探索を組む選択肢もありますが、インデックスの管理を App 側から切り離せる点で、コレクション規模が万単位の個人開発には File Search の方が維持しやすいと感じています。検索側の使い勝手は壁紙アセットの画像探しを File Search のマルチモーダル検索に任せてみた記録に書いたとおりで、今回はその索引を重複の疑い候補の提示に転用した形です。
一点だけ、運用上の落とし穴になりそうな点があります。埋め込みは1枚ごとに API 呼び出しが発生し、モデルの世代交代の影響も受けます。ローカルで完結する dHash には廃止日がありません。ハッシュ照合は10万件規模でも手元の計算だけで済み、API コストは 0 円のままです。門番という毎回必ず通る位置に置くなら、止まらない・値上がりしない・仕様が変わらない方を選びたい、という判断もここに含まれています。埋め込みモデルの世代交代への備えはGemini Embedding モデルを切り替える日:無停止リインデックスの設計で扱った話がそのまま効いてきます。
採った構成 — 機械は落とさず、疑いを並べる
最終的な登録フローは次のコードの形に落ち着きました。ポイントは、dHash に引っかかった候補を自動削除せず、判断材料つきでキューに積むところです。
import hashlib
import json
import time
from pathlib import Path
from PIL import Image
DHASH_THRESHOLD = 10 # 手元の計測で第二層の変形が距離 8 以下に収まったため
def sha256_of(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def check_new_image(path, index):
"""index: 既存作品の {id: {"sha": ..., "dhash": ...}} 辞書"""
sha = sha256_of(path)
h = dhash(Image.open(path))
for asset_id, entry in index.items():
if entry["sha"] == sha:
return {"action": "reject", "reason": f"同一ファイル: {asset_id}"}
suspects = []
for asset_id, entry in index.items():
d = hamming(h, entry["dhash"])
if d <= DHASH_THRESHOLD:
suspects.append({"asset_id": asset_id, "distance": d})
if suspects:
# ここで削除しないことが最重要です。人が見る材料として残します
queue_item = {
"path": str(path),
"suspects": sorted(suspects, key=lambda s: s["distance"]),
"queued_at": int(time.time()),
}
Path("review_queue.jsonl").open("a").write(
json.dumps(queue_item, ensure_ascii=False) + "\n"
)
return {"action": "hold", "suspects": len(suspects)}
return {"action": "accept", "sha": sha, "dhash": h}
このキューを開く画面に、File Search の近傍検索結果(似ている既存作品の上位5件)を並べて表示します。埋め込みが返すのはあくまで「似ているもの」であって「重複」ではない、と役割を言い切ってしまえば、意味的に近い別作品が並んでも困りません。むしろ連作や同主題の作品が近くに集まって見えるので、第三層の編集判断がしやすくなりました。
カテゴリ体系を後から広げたときに再分類の範囲を絞った経験(カテゴリを30から34に増やした日 — 8,000枚を全部かけ直さない差分再分類の設計)でも感じたことですが、機械に不可逆な操作を任せる範囲は、間違えたときの被害から逆算して決めるのが結局いちばん安全です。
判断基準を一般化する
自分のコレクションに重複検査を入れる、あるいは埋め込み検索を足すか迷っている場合、判断は次の表に集約できると考えています。
| 観点 | 知覚ハッシュ(dHash 等) | 埋め込み検索(gemini-embedding-2) |
| 得意な層 | 第二層(再エンコード・リサイズ・軽微な切り抜き) | 第三層寄り(大きな切り抜き・意味的な近さ) |
| 誤りの方向 | 変形を見逃す(安全側) | 別作品を近いと言う(削除に使うと危険側) |
| コスト | ローカル完結・0 円 | 1枚ごとに API 呼び出し |
| 寿命 | アルゴリズムに廃止日なし | モデル世代交代の影響を受ける |
| 適した権限 | 自動保留までなら任せられる | 提示・並べ替えまでに留める |
順序も大切です。
- 第三層の実例(過去に迷った組)を書き出し、何を重複と呼ぶかの定義を先に決めます。
- 第二層のしきい値を、自分の素材に実際の変形を加えて測ります。
- 最後に、埋め込み検索を提示役として足します。
この順番なら、どの段階で止めても検査として成立します。逆に、定義を飛ばして埋め込みから入ると、高い類似度のリストを前に「で、どれを消していいのか」が誰にも答えられなくなります。
まとめ — 定義を書き出すことが最初の一歩
次の一歩としてお勧めするのは、コードを書く前に、自分のコレクションで「これは重複だろうか」と過去に迷った実例を3組だけ書き出してみることです。その3組が三つの層のどこに落ちるかを決めれば、しきい値も、埋め込みの置き場所も、機械に許す権限も、自然と決まっていきます。私自身、別摺りの二枚に立ち止まらされるまで定義を後回しにしていたので、同じ判断を迫られている方の材料になれば嬉しいです。