こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
記事に付ける画像は、いくつかの素材サイトから候補を集めて、人が選びます。1 つの記事に対して、複数のキーワードで、複数のバックエンドを叩きます。
すると、同じ写真が何度も候補に上がります。URL で弾くだけでは取りこぼしたので、ピクセルでもう 1 段弾くようにしました。
症状 — 同じ写真が 2 回並ぶ
候補の一覧に、同じ写真が 2 枚並びました。実測で、同一の URL が 2 回登録されていました。
原因は単純です。1 つの記事に対して複数のキーワードで検索するので、どのキーワードでも上位に来る写真は、そのたびに引っかかります。バックエンドが複数あれば、その掛け算になります。
候補の枠は限られているので、同じ写真が枠を食うと、人が選べる幅が狭くなります。
1 段目 — URL を正規化して弾く
まず、ダウンロードする前に URL で弾きます。安いほうを先に置きます。
/** 同じ画像を 2 度登録しないための正規化キー。ホスト + パスだけを見てクエリと `#` を捨てる
* (Pexels / flickr は同じ写真にサイズ違いのクエリを付けるため)。#423
* 複数キーワードや複数バックエンドが同じ写真を引くのは普通に起きる(実測: 同一 flickr URL が
* 2 回登録された)。LLM を使わず機械的に弾く。**URL が違う同じ写真**は
* `droppedAsDuplicate` がピクセルで弾く。 */
export function imageDedupeKey(url: string | null): string | null {
if (!url) return null;
try {
const u = new URL(url);
return `${u.host}${u.pathname}`.toLowerCase();
} catch {
return url.trim().toLowerCase() || null;
}
}
ホストとパスだけを見て、クエリとフラグメントを捨てます。素材サイトは同じ写真にサイズ違いのクエリを付けるので、クエリを残すと別の写真として扱われます。
URL として解釈できない文字列でも、落とさずに文字列のまま正規化して返します。キーを作る関数が例外を投げると、呼び出し側が全部 try で囲むことになります。
弾く相手には、すでにその記事に登録されている候補も含めます。こうしておくと、同じ検索を再実行しても候補が増えません。
取りこぼす形が 2 つある
URL だけでは足りませんでした。
1 つは、集約サイトです。複数の素材サイトの写真をまとめて検索できるサービスは、元の写真を自前の URL で返します。元のサイトから直接引いたときと URL が違うので、同じ写真だと分かりません。
もう 1 つは、サイズ違いのパスです。クエリではなくパスの一部でサイズを表しているサイトがあります。この場合、パスを正規化しても別物になります。
どちらも、取ってみるまで同じ写真だと分かりません。
2 段目 — ピクセルの指紋で弾く
保存した後に、ピクセルからもう 1 度照合します。方式は dHash(横方向の明暗差)の 64 ビットです。
画像を 9×8 のグレースケールに縮小して、隣より明るいかを 1 ビットずつ並べます。
/** グレースケールの生ピクセル(幅 FINGERPRINT_GRID_WIDTH × 高さ FINGERPRINT_GRID_HEIGHT)から
* 指紋を作る。**左隣より明るいか**を 1 ビットずつ並べた 64bit を 16 進で返す。
* 画素が足りない(想定外の縮小結果)ときは指紋を作らない=null。 */
export function fingerprintFromGrayscale(pixels: Uint8Array | Buffer): string | null {
if (pixels.length < FINGERPRINT_GRID_WIDTH * FINGERPRINT_GRID_HEIGHT) return null;
9 列読んで隣との差を取るので、1 行あたり 8 ビット、8 行で 64 ビットになります。
比較はハミング距離です。
/** 2 つの指紋のハミング距離。形が違う(長さ違い・16 進でない)ものは比較せず 64(=別画像)。 */
export function fingerprintDistance(a: string, b: string): number {
if (a.length !== FINGERPRINT_HEX_LENGTH || b.length !== FINGERPRINT_HEX_LENGTH) return FINGERPRINT_BITS;
形が違う指紋は、比較せずに最大値(=別画像)を返します。壊れた値が来たときに「距離 0=同じ写真」に倒れると、別の写真を捨てます。捨てる側の判定なので、迷ったら捨てない方向に倒します。
閾値は実測で決める
知覚ハッシュを使うときの本題は、閾値です。いくつなら「同じ写真」とみなすかを決める必要があります。
手元の候補 54 枚を全ペア(1431 ペア)で比較しました。
// 方式は dHash(横方向の明暗差 64bit)。**閾値は実測で決めた**(2026-08-04・
// 手元の候補 54 枚 = 1431 ペア):
// - 別々の写真どうし … 最小 13(12 以下は 1 ペアも無い)
// - 同じ写真の再エンコード / 縮小 / サムネからの拡大 … 0〜4
// 間を取って 6 を上限にしてある。**crop(切り出し)は別画像として扱う**(5% トリムで
// 2〜18 とばらけ、同じ写真だと言い切れない)。
別々の写真どうしは最小で 13 でした。12 以下のペアは 1 つもありません。一方、同じ写真の再エンコードや縮小は 0〜4 に収まりました。
間が空いているので、その間を取って 6 にしました。この 2 つの分布が重なっていたら、この方式では分けられないと判断するところでした。
閾値を「だいたい 5 くらい」で決めると、後から動かすときに根拠がありません。分布を測っておくと、動かすときに何を取り直せばよいかも決まります。
切り抜きは別画像として扱うことにしました。5% だけ切ったペアで距離が 2〜18 とばらけたので、同じ写真だと言い切れません。ここは無理に同一と判定せず、範囲の外に置きました。
指紋は毎回ピクセルから作り直す
指紋を保存しておけば、比較のたびに計算しなくて済みます。していません。
保存すると、人がファイルを差し替えたときに嘘が残ります。このプロジェクトでは、候補の画像は人が触れるローカルのファイルとして置いてあります。差し替えられる前提の場所です。
毎回ピクセルから作り直せば、指紋とファイルが食い違うことがありません。計算は 64 ビットぶんなので、保存して節約する価値がありませんでした。
人が畳んだ写真を蘇らせない
比較の相手は、いま並んでいる候補だけではありません。すでに採用したものと、人が「これは使わない」と畳んだものも含めます。
畳んだ写真を対象から外すと、次の検索でまた同じ写真が候補に上がります。人は「さっき畳んだのに」と思いながら、もう一度畳むことになります。
「捨てた」という判断も、比較のときに使える情報です。
捨てたことは必ずログに残す
重複で捨てたときは、必ずログに残します。
黙って捨てると、「検索したのに候補が増えない」ことの説明がつきません。使う側から見ると、検索が壊れているのか、素材が無いのか、重複だったのかが分かりません。
出すのは 1 行で足ります。どの既存候補と同じだと判定したのかを書いておくと、判定が正しいかを人が確かめられます。
落とし穴 — 構造の無い絵では指紋が安定しない
テストを書いているときに 1 つ踏みました。
// ⚠️ 見ているのは**大きな明暗の並び**なので、一様なノイズ画像のように大きな構造を持たない絵では
// 縮小で平均化されて指紋が安定しない(テストで実際に踏んだ)。実写にはその心配が無い
// =上の実測がそのまま効く。合成画像で試すときは構造のある絵を使うこと。
テスト用にランダムなノイズ画像を作って、同じ画像を再エンコードしたら同じ指紋になるはず、というテストを書きました。安定しませんでした。
dHash が見ているのは、大きな明暗の並びです。9×8 まで縮小するので、細かいノイズは平均化されて消えます。平均に近い値どうしの比較になるので、わずかな差でビットが反転します。
実写では起きません。写真には必ず大きな明暗の構造があるからです。実測の 1431 ペアがそれを示しています。
合成した画像でテストするときは、構造のある絵を使う必要がありました。テストのために作った入力が、実データの性質を持っていないと、判定の性質そのものが変わります。
一般化できる部分
重複の判定は、安い順に段を分けます。今回は URL とピクセルの 2 段でした。1 段目で大半が落ちるので、ダウンロードと画像処理が要る 2 段目に来る数は少なくなります。
知覚ハッシュのような近似の判定を使うときは、閾値を実測で決めます。手順は 3 つです。
- 手元のデータで全ペアの距離を出す
- 「同じもの」と「違うもの」の分布を分けて見る
- 間が空いていれば、その間を取る。重なっていれば、その方式では分けられない
そして、判定の対象外にするものを決めます。今回は切り抜きです。「同じ写真から作られている」と「同じ写真である」は違うので、後者だけを扱うと決めました。
最後に、捨てる処理には必ず記録を付けます。捨てた結果は「何も起きていない」ように見えるので、記録が無いと使う側から区別できません。
まとめ
- 複数キーワード・複数バックエンドで検索すると、同じ写真が何度も候補に上がる
- 1 段目は URL の正規化(ホスト + パス、クエリと
#を捨てる)。ダウンロード前に効く安いほう - 既存の候補も既出として扱うと、同じ検索を再実行しても増えない
- 集約サイトの自前 URL とサイズ違いのパスは、URL では分からない。取ってから照合する
- 2 段目はピクセルの指紋(dHash 64bit)。壊れた値は「別画像」に倒し、捨てる方向に倒さない
- 閾値は全ペアの距離を測って決める。「同じ」と「違う」の分布が離れていることを確かめる
- 切り抜きは範囲の外に置く。同じ写真から作られたことと、同じ写真であることは別
- 指紋は保存せず毎回作り直す。人が差し替えられる場所のファイルは、保存した値が嘘になる
- 採用済み・却下済みも比較の相手に含める。人が畳んだ写真を次の検索で蘇らせない
- 捨てたことは必ずログに残す。黙って捨てると「増えない」ことの説明がつかない
- 合成した画像でテストするときは、実データと同じ性質(大きな構造)を持たせる