朝、自分の壁紙アプリのカタログ画面をスクロールしていて、指が止まりました。
同じ絵が、少し離れた位置に二度並んでいます。
片方は初期に入れたもの、もう片方は後から別バッチで追加したもの。ファイル名も追加日も違うので、これまで一度も気づきませんでした。個人開発で数千枚のアセットを扱っていると、こうした取りこぼしが静かに積み上がっていきます。
App Store のレビューで指摘されたわけではありません。それでも、お金を払って使ってくださっている方の画面に同じ絵が二度出るのは、避けたいところでした。
手作業で全部見比べる選択肢はありません。機械で見つける仕組みを入れることにしました。
その過程で、事前に思い描いていた設計は二度ひっくり返りました。以下、実際に測った数字とともに残しておきます。
検証セットを手元に切り出す
本番カタログをそのまま実験台にすると、結果を後から検証できません。挙動を数字で確かめられるよう、同じ性質を持つ検証セットを合成しました。
1290×2796(iPhone の縦解像度)の JPEG を160枚。中身はグラデーション・ぼかしたブロブ・ストライプ・ノイズ雲の4種類を40枚ずつ。壁紙カタログにありがちな構成をなぞっています。
そこから、意図的に5種類の派生を作りました。
派生の種類 枚数 正解の扱い
JPEG 品質92→74 で再エンコード 54 重複
1290×2796 → 828×1792 にリサイズ 32 重複
明度を6%上げる 23 重複
上下左右を2%クロップして戻す 15 重複
色相を80度回す 40 別物
合計324枚。最後の色相変種を「別物」に置いたところが、この検証の肝になります。壁紙アプリでは色違いこそが並べたい商品であって、統合されては困ります。
環境は Node 22.22.3 と sharp 8.18.3(libvips 経由)。以降の数字は全てこの構成での実測です。
SHA-256 から始めて、すぐ行き詰まる
最初に試したのはファイルのバイト列に対する SHA-256 でした。実装は数行で済みますし、まずは完全一致だけでも掃除できるはずと考えていました。
結果は、真の重複124組のうち検出できたのは3組。再現率2.4%です。
理由は考えてみれば当たり前でした。JPEG は再エンコードした時点でバイト列が変わります。リサイズも明度調整もクロップも同様です。バイト一致で拾えるのは「同じファイルを二度アップロードした」ケースだけで、そのケースはアップロード時のガードで既に潰してありました。
残っていたのはまさに「見た目は同じだがバイトは違う」ものばかりで、ハッシュの完全一致はその全てを素通りさせます。
dHash を入れたら、今度は無関係な絵が束になった
次に知覚ハッシュへ移りました。dHash は画像を 9×8 のグレースケールに縮小し、横に隣り合う画素の大小関係を 64 ビットに畳む方式です。縮小の時点で圧縮ノイズも解像度差も落ちるので、見た目の同一性を捉えられます。
実装はこうなりました。
// fingerprint.mjs
import sharp from "sharp" ;
/** dHash: 9x8 グレースケールに縮小し、横隣接ピクセルの大小関係を 64bit に畳む */
export async function dHash ( path ) {
const { data } = await sharp (path)
. greyscale ()
. resize ( 9 , 8 , { fit: "fill" , kernel: "lanczos3" })
. raw ()
. toBuffer ({ resolveWithObject: true });
let bits = 0 n ;
for ( let y = 0 ; y < 8 ; y ++ ) {
for ( let x = 0 ; x < 8 ; x ++ ) {
const l = data[y * 9 + x];
const r = data[y * 9 + x + 1 ];
bits = (bits << 1 n ) | (l > r ? 1 n : 0 n );
}
}
return bits;
}
export function hamming ( a , b ) {
let x = a ^ b, n = 0 ;
while (x) { x &= x - 1 n ; n ++ ; }
return n;
}
同一グループ内のハミング距離を測ると、期待どおりの分布が出ました。
派生の種類 n 中央値 95パーセンタイル 最大
再エンコード 54 0 3 3
リサイズ 32 0 11 12
明度+6% 23 0 5 8
2%クロップ 15 0 11 11
別グループ同士 12,720 27 36 47
重複は中央値0、別グループは中央値27。きれいに分かれています。閾値を10に置けば十分だろうと判断しました。
ところが全324枚を総当たりで判定させたところ、数字が壊れました。
再現率97.5%に対して、適合率0.9%。誤検出16,570組。
平坦な画像で知覚ハッシュが潰れる
原因を探るため、各ハッシュの立っているビット数(popcount)を数えました。dHash は隣接画素の大小関係なので、なだらかな画像では比較がほぼ全て同じ向きに倒れ、0 か 64 に張り付きます。
元画像160枚のうち、popcount が4以下または60以上だったのは80枚。ちょうど半分です。
内訳を見て納得しました。グラデーション40枚とストライプ40枚が全て該当し、ブロブとノイズ雲は1枚も該当していません。そしてこの80枚は、全員が完全に同一の dHash を共有していました。無関係な壁紙80枚が、たった一つの巨大クラスタになっていたわけです。
壁紙カタログは、まさにこの「なだらかで詳細の少ない画像」の集積です。知覚ハッシュが最も苦手とする素材で回していたことになります。参考記事の多くが写真を前提に閾値を語っているため、この落とし穴には気づきにくいところがあります。
そこで、情報量の乏しいハッシュを判定から外すゲートを入れました。
const popcount = ( x ) => { let n = 0 ; while (x) { x &= x - 1 n ; n ++ ; } return n; };
/** 立っているビットが極端に偏るハッシュは「識別能力なし」として扱う */
export function isDegenerate ( hash ) {
const p = popcount (hash);
return p <= 4 || p >= 60 ;
}
ゲートを通した残り80枚だけで別グループ間の距離を測り直すと、最小12・下位5%点25・中央値31。閾値10以下での誤検出は0組、12でようやく1組でした。弾かれた画像を捨てるのではなく、後述の色相署名とブロック署名へ回すことで取りこぼしを回避しています。
ハッシュそのものは正しく働いていて、対象を選べていなかっただけでした。
グレースケールに落とす時点で、売り物の違いが消えていた
ゲートを入れて安心しかけたところで、もっと厄介な事実に行き当たりました。
色相を80度回した変種40枚の dHash 距離を確認したところ、40枚すべてが距離0 。閾値をいくつに下げても、色違いは元画像と完全に同じハッシュになります。
dHash は最初にグレースケール化します。色相だけを回した画像は輝度分布が変わらないので、当然の帰結です。
ただ、壁紙アプリの立場では致命的でした。同じ構図の青版と橙版は別々に並べたい商品であって、統合されたらカタログの品数が減ります。重複を掃除する仕組みが、商品を消してしまうところでした。
明るさや圧縮に強いという性質は、そのまま「色の違いに盲目」という性質の裏返しです。この二つは切り離せません。
補うために、色相のヒストグラムを別の指紋として持たせました。
/** 色相ヒストグラム署名: HSV の H を 12 ビンに落とし、彩度で重み付けして正規化 */
export async function hueSignature ( path ) {
const { data , info } = await sharp (path)
. resize ( 64 , 64 , { fit: "fill" })
. raw ()
. toBuffer ({ resolveWithObject: true });
const bins = new Float64Array ( 12 );
const ch = info.channels;
for ( let i = 0 ; i < data. length ; i += ch) {
const r = data[i] / 255 , g = data[i + 1 ] / 255 , b = data[i + 2 ] / 255 ;
const max = Math. max (r, g, b), min = Math. min (r, g, b), d = max - min;
if (d < 0.08 ) continue ; // 無彩色は色相が不安定なので捨てる
let h;
if (max === r) h = ((g - b) / d) % 6 ;
else if (max === g) h = (b - r) / d + 2 ;
else h = (r - g) / d + 4 ;
h = ((h * 60 ) + 360 ) % 360 ;
bins[Math. floor (h / 30 )] += d; // 彩度相当で重み付け
}
const sum = bins. reduce (( a , v ) => a + v, 0 ) || 1 ;
return Array. from (bins, ( v ) => v / sum);
}
/** 2つの色相署名の距離(L1 の半分 = 0〜1) */
export function hueDistance ( a , b ) {
let s = 0 ;
for ( let i = 0 ; i < a. length ; i ++ ) s += Math. abs (a[i] - b[i]);
return s / 2 ;
}
彩度の低い画素を捨てている if (d < 0.08) continue; の一行が効きます。ほぼ無彩色の領域では色相の計算が不安定になり、同じ画像を再エンコードしただけで大きく揺れるためです。
測った結果は明快でした。真の重複は最大でも距離0.057、色相変種は40枚すべてがちょうど1.000。閾値0.15を挟めば、両者が混ざる余地はありません。
明度の6%はリサイズより効く
もう一つ、4×4 グリッドの平均色を並べただけの粗い署名も足しました。構図の大枠を見るためです。
/** ブロック署名: 4x4 グリッドの平均色を 48 次元で持つ */
export async function blockSignature ( path ) {
const { data , info } = await sharp (path)
. resize ( 4 , 4 , { fit: "fill" })
. raw ()
. toBuffer ({ resolveWithObject: true });
const out = [];
for ( let i = 0 ; i < 16 ; i ++ ) {
const o = i * info.channels;
out. push (data[o] / 255 , data[o + 1 ] / 255 , data[o + 2 ] / 255 );
}
return out;
}
/** ブロック署名同士の RMSE(0〜1) */
export function blockDistance ( a , b ) {
let s = 0 ;
for ( let i = 0 ; i < a. length ; i ++ ) { const d = a[i] - b[i]; s += d * d; }
return Math. sqrt (s / a. length );
}
RMSE の分布で、予想と逆の並びが出ました。
派生の種類 中央値 最大
再エンコード 0.0013 0.0028
リサイズ(画素数40%) 0.0036 0.0179
2%クロップ 0.0052 0.0340
明度+6% 0.0270 0.0365
色相変種 0.2819 0.3164
解像度を半分近くまで落としたリサイズ(中央値0.0036)より、人間の目にはほとんど分からない明度+6%(中央値0.0270)のほうが、7倍以上大きく署名を動かしています。
平均色を取る操作は解像度に対して頑健で、輝度のオフセットには無防備。そう言われれば理屈は通りますが、閾値を決める前に測っていなければ、リサイズを基準に0.01あたりを選んで明度変化を全て取りこぼしていました。
白黒で決めず、3つのバンドに分ける
3種類の指紋が揃った段階で、当初考えていた「重複かどうかを真偽値で返す関数」という設計をやめました。
理由は、どう閾値を組んでも適合率が頭打ちになったからです。緩めれば無関係な壁紙が混ざり、締めれば明度違いが漏れます。壁紙カタログのように互いに似た画像が並ぶ集合では、機械が単独で決め切れない帯が必ず残ります。
代わりに、判定結果を3つのバンドへ振り分ける形にしました。
/**
* ペアを3つのバンドへ振り分ける。
* auto … 自動統合してよい
* review … 人が見て決める
* distinct … 別物として扱う
*/
export function classifyPair ( a , b ) {
// 1. 色が違えば別商品。ここで先に落とす
if ( hueDistance (a.hue, b.hue) > 0.15 ) return "distinct" ;
const bd = blockDistance (a.block, b.block);
const dd = hamming (a.dhash, b.dhash);
// 2. 構図も細部も一致していれば自動統合
if (bd <= 0.005 && dd <= 6 ) return "auto" ;
// 3. どちらかが近いだけならレビュー行きにする
if (bd <= 0.04 && dd <= 12 ) return "review" ;
return "distinct" ;
}
順序に意味があります。色相を最初に見るのは、色違いを自動統合させないためです。仮に dHash を先に評価すると、平坦な画像同士が距離0で通過し、色相判定まで到達しないケースが出ます。
324枚(52,326ペア)に対する結果は次のとおりでした。
バンド ペア数 適合率 真の重複のカバー率
auto(自動統合) 96 96.9% 47.4%
review(目視) 449 22.9% 52.6%
distinct(別物) 51,781 — 0%
大事なのは最終行です。distinct に落ちた51,781ペアの中に、真の重複は1組も含まれていませんでした。取りこぼしゼロで、人が見る対象を449ペアまで絞れています。324枚に対して1枚あたり1.39ペア。夕方の30分で片付く量です。
3種類の指紋をまとめて算出する所要時間は、324枚で6.67秒。1枚あたり20.6ミリ秒でした。数千枚規模でも数分で終わる計算になります。
誤検出を追いかけていたら、正解ラベルの方が間違っていた
最後に、いちばん意外だった話を書いておきます。
3バンドに分けた直後、auto 帯の適合率は90.6%でした。96ペア中9ペアが「別グループなのに自動統合される」誤検出です。私はこれを閾値の詰めが甘いせいだと考え、bd <= 0.005 を 0.003、0.002、0.0015 と下げていきました。
適合率は 91.4%、91.5%、92.3% と、ほとんど動きません。代わりにカバー率だけが54.7%から22.6%へ落ちていきました。
おかしいと思い、誤検出とされた9ペアの中身を印字させました。
base_0056.jpg <-> base_0140.jpg bd=0.0000 dd=0
base_0044.jpg <-> base_0064.jpg bd=0.0020 dd=0
base_0116.jpg <-> base_0152.jpg bd=0.0041 dd=0
bd=0.0000 という値が引っかかりました。4×4 の平均色が48次元すべて一致するというのは、偶然にしては出来すぎています。
画素単位で突き合わせてみました。
比較 平均画素差 最大画素差
base_0056 と base_0140 0.00 0
base_0044 と base_0064 0.36 4
base_0116 と base_0152 0.79 4
(参考)無関係な2枚 87.71 146
base_0056 と base_0140 は、全画素が完全に一致していました。独立に生成したつもりの2枚が、同じ絵になっていたわけです。残り2組も、最大画素差4という、目で見て絶対に分からない差しかありません。
つまり、誤検出ではありませんでした。私が用意した正解ラベルのほうが間違っていました。
元画像160枚を総当たりで画素照合したところ、平均画素差2.0未満のペアが10組見つかりました。この10組を同一グループとして正解を訂正し、同じ判定器を回し直すと、auto 帯の適合率は90.6%から96.9%へ上がりました。判定器には一行も手を入れていません。
閾値を4段階も下げてカバー率を半分以下にしたのは、まったく無駄な作業でした。
そしてこれは、合成した検証セットに限った話ではありません。本番カタログの管理テーブルも、人間が「別の画像」として登録した情報でしかありません。指紋のほうが、管理テーブルより正確に中身を見ています。誤検出だと思ったものを機械の誤りだと決めつけていたら、カタログに残っていた本物の重複3組を、私はそのまま見逃していました。
運用に落とすときに決めたこと
実際に組み込むにあたって、次の3点を判断の軸にしました。
自動統合は auto 帯のみ、それも取り消せる形で残す。 統合は物理削除ではなく merged_into 列を立てるだけにして、後から戻せるようにしています。適合率96.9%は、100本に3本は間違うという意味でもあります。本番運用で物理削除を選ぶのは、統合ログを別テーブルに残せる場合だけに留めることをお勧めします。
review 帯は溜めず、その日のうちに見る。 449ペアという数字は、放置すれば手に負えなくなり、日々消化すれば苦になりません。私は新規アセットを取り込んだ直後に回す運用を好みます。「あとでまとめて」にすると、結局やらなくなるためです。
判定器の誤りを疑う前に、正解の側を疑う。 今回いちばん高くついた教訓がこれでした。誤検出リストは、閾値の調整材料である前に、カタログの棚卸しリストとして読む価値があります。閾値を触る場合は、先に該当ペアを画素照合してから動かす順番を推奨します。
閾値の数字(0.15 / 0.005 / 6 / 0.04 / 12)は、この検証セットで測った結果です。扱う画像の性質が違えば動きます。写真中心のカタログであれば平坦画像の問題はほとんど出ませんし、逆に単色に近いミニマル系が多ければ popcount ゲートで弾かれる割合はさらに上がります。
そのまま持っていくより、同じ手順で自分のカタログから100枚ほど切り出して測り直していただくほうが、確実に良い数字になります。派生の作り方と測定スクリプトは、この記事に全て載せてあります。
同じ絵が二度並んでいるのに気づいてから、腑に落ちるまでに三日かかりました。遠回りの記録が、どこかで手間を減らす一助になれば嬉しく思います。お読みいただきありがとうございました。