知覚ハッシュ(pHash/dHash/aHash)でニア重複画像を検出する実務ガイド
画像のニア重複 (near duplicate) を放置するとストレージ増大・キャッシュ効率低下・検索結果の冗長化を招きます。perceptual hash は視覚特徴を 64bit などの小さなビット列へ圧縮し、Hamming 距離で類似度を高速に近似する手法です。本稿は pHash/dHash/aHash の違いと、閾値・インデックス・運用ワークフローを「壊れにくい実務手順」に整理します。
先に結論 (TL;DR)
- pHash + dHash の併用 AND/OR ルールで誤検出を抑制。
- 閾値は代表データで距離分布→F1最大付近を採用 (pHash≈8–12)。
- インデックスは8bitチャンク倒立方式で数百万件でも高速。
- 代表画像を決めグループID管理(キャッシュ統合/権利照合/重複抑制)。
1. なぜ perceptual hash か
ピクセル完全一致 (MD5/SHA1) ではリサイズや軽微な色調整で別物扱いになります。pHash 等は“知覚”に影響の小さい変換を吸収して距離を保ちます。
- 高速:64bit 同士の XOR+ビットカウントで距離。
- 小さな記憶域:数百万件でも数十MB〜。
- インデックス容易:チャンク分割で近傍候補生成。
2. pHash / dHash / aHash の比較
- pHash: DCT低周波を符号化。照度変化に強いが計算やや重。
- dHash: 隣接差分。高速・実装容易・ノイズに弱い。
- aHash: 平均輝度比較。極めて軽量・精度低め。
実務では pHash と dHash を併用し、双方の閾値を満たす/片方のみ等のルールで誤検出と漏れを調整。
3. 実装(ハッシュ計算)
// perceptual-hash.ts — pHash & dHash 実装例(簡略)
import sharp from 'sharp';
export async function phash(buf: Buffer): Promise<bigint> {
// 1) 32x32 & グレースケール
const raw = await sharp(buf).resize(32, 32, { fit: 'cover' }).greyscale().raw().toBuffer();
// 2) DCT (ここでは簡略: 行ごとに離散コサイン近似)
const N = 32;
const dct: number[] = [];
for (let u = 0; u < 8; u++) {
for (let v = 0; v < 8; v++) {
let sum = 0;
for (let x = 0; x < N; x++) {
for (let y = 0; y < N; y++) {
const px = raw[x * N + y];
sum += px * Math.cos(((2 * x + 1) * u * Math.PI) / (2 * N)) * Math.cos(((2 * y + 1) * v * Math.PI) / (2 * N));
}
}
dct.push(sum);
}
}
const coeffs = dct.slice(1, 64); // DC除外 63要素
const median = coeffs.slice().sort((a, b) => a - b)[Math.floor(coeffs.length / 2)];
let bits = 0n;
coeffs.forEach((c, i) => { if (c > median) bits |= 1n << BigInt(i); });
return bits;
}
export async function dhash(buf: Buffer): Promise<bigint> {
const w = 9, h = 8;
const raw = await sharp(buf).resize(w, h, { fit: 'cover' }).greyscale().raw().toBuffer();
let bits = 0n, i = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w - 1; x++) {
const p = raw[y * w + x];
const q = raw[y * w + x + 1];
if (p > q) bits |= 1n << BigInt(i);
i++;
}
}
return bits;
}
export function hamming(a: bigint, b: bigint): number {
let x = a ^ b;
let d = 0;
while (x) { x &= x - 1n; d++; }
return d;
}
4. インデックス戦略
64bit を 8bit * 8 のチャンクに分割し (位置情報もキーに含む) 倒立インデックスを形成、1〜2チャンク一致で候補集合を生成後 Hamming 距離を正確計算。
// inverted-index.ts — 64bitを8bitチャンク化し高速候補生成
type Entry = { id: number; hash: bigint };
const buckets: Map<number, number[]> = new Map(); // key=8bit, value=Entry.id list
const data: Entry[] = [];
export function add(id: number, hash: bigint) {
data.push({ id, hash });
for (let i = 0; i < 8; i++) {
const part = Number((hash >> BigInt(i * 8)) & 0xffn);
const key = (i << 8) | part; // チャンク位置も含め衝突低減
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(id);
}
}
export function candidates(hash: bigint): number[] {
const seen = new Set<number>();
for (let i = 0; i < 8; i++) {
const part = Number((hash >> BigInt(i * 8)) & 0xffn);
const key = (i << 8) | part;
for (const id of buckets.get(key) ?? []) seen.add(id);
}
return [...seen];
}
5. 閾値チューニング
ラベル付きサンプルを収集し距離分布を可視化。F1 / ROC AUC を指標に最適点を探す。
# 距離分布を可視化(擬似コード)
# pairs.csv: id1,id2,phash_distance,dhash_distance,label(dup|diff)
import pandas as pd
import seaborn as sns
if __name__ == '__main__':
df = pd.read_csv('pairs.csv')
sns.histplot(df[df.label=='dup'].phash_distance, color='red', alpha=.5)
sns.histplot(df[df.label=='diff'].phash_distance, color='blue', alpha=.5)
# 距離閾値候補を探索してF1計算
for t in range(4,20):
tp = ((df.label=='dup') & (df.phash_distance<=t)).sum()
fp = ((df.label=='diff') & (df.phash_distance<=t)).sum()
fn = ((df.label=='dup') & (df.phash_distance>t)).sum()
precision = tp / max(1,(tp+fp))
recall = tp / max(1,(tp+fn))
f1 = 2*precision*recall/max(1e-9,(precision+recall))
print(t, f1)
6. ワークフロー設計
- バッチ初期読み込み → ハッシュ計算 → グルーピング。
- 代表画像選定 (最小ファイル / 最高品質 / メタ完備)。
- 新規投入は差分ハッシュ → インデックス候補 → 距離判定 → グループID採番。
- 閾値近傍 (境界ケース) は人工審査キューへ。
7. 公開前チェック
- 距離閾値は分布解析済み (ドキュメント化)。
- 境界ケースの人工審査ルートがある。
- 代表画像選定ポリシー (画質/サイズ) が一貫。
- ハッシュ再計算の再現性 (バージョン・依存ライブラリ) を固定。
- ストレージ節約率・誤検出率を KPI としてモニタ。
8. まとめ
perceptual hash は軽量かつ実装容易。pHash+dHash 併用とチャンク倒立インデックスで数百万規模までスケールし、ニア重複の整理・キャッシュ統合・権利チェックの高速化に寄与します。