カメラロールを開くと、同じ料理を 6 枚撮っている。同じ画面のスクリーンショットが 4 枚ある。旅行の写真を見返すと、ほぼ同じ構図が延々続く。
消したいのに、どれを消せばいいか分からない。
ファイル名で並べても意味がないし、撮影日順に並べても「似ているかどうか」は自分で目視するしかない。既存の重複チェックツールはファイルのハッシュ値で比較するので、リサイズしたものや保存し直したものは「別のファイル」として素通りします。
PhotoTwin は、この「見た目が同じかどうか」をブラウザの中の AI に判定させるサービスです。
🎯 ハッシュ比較では届かない領域
まず、なぜ既存のやり方では足りないのかを整理します。
| 手法 | 見つかるもの | 見つからないもの |
|---|---|---|
| ファイルハッシュ | バイト単位で完全に同じファイル | リサイズ・再保存・形式違い |
| ファイル名・サイズ | 命名規則が揃っているもの | 名前がバラバラのもの |
| 知覚ハッシュ (pHash) | ほぼ同じ画像 | 構図が近いだけの別カット |
| 画像埋め込み (今回) | 完全重複+見た目が近いもの | — |
実際に困るのは、**「同じだけど同じファイルではない」**ケースです。
- SNS 用に縮小した版と元の版
- スクショを撮り直した 2 枚
- 連写の 1 コマ違い
- JPEG で保存し直して容量だけ変わったもの
ハッシュはこの全部を取り逃します。pHash なら近いところまで行けますが、「同じ場所で撮った別カット」のような意味的に近いものまでは拾えません。
そこで、画像を意味のベクトルに変換する CLIP を使うことにしました。
🏗️ 技術選定:新しいライブラリを増やさない
ラボにはすでに what-cam(写真を撮ると「これは何か」を当てる画像認識サービス)があり、そこで CLIP を動かしています。PhotoTwin はまったく同じモデルを、別の使い方で呼ぶだけにしました。
- what-cam: 画像ベクトルとテキストベクトルの近さを測る(ゼロショット分類)
- PhotoTwin: 画像ベクトルと画像ベクトルの近さを測る(類似検出)
CLIP は画像とテキストを同じ空間に埋め込むモデルなので、片方だけ使えば画像同士の比較器になります。新しいモデルもライブラリも増やさずに、まったく別のサービスが 1 本立つ。
const MODEL_ID = "Xenova/clip-vit-base-patch32"
const TRANSFORMERS_CDN =
"https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js"
function loadTransformers(): Promise<TransformersModule> {
if (transformersLib) return transformersLib
transformersLib = (async () => {
const mod = (await import(
/* webpackIgnore: true */ /* @vite-ignore */ TRANSFORMERS_CDN
)) as TransformersModule
mod.env.allowLocalModels = false
mod.env.useBrowserCache = true
return mod
})()
return transformersLib
}
transformers.js は CDN から ESM として読み込む形。depth-cast・what-cam と同じパターンで、バンドルには一切入りません。useBrowserCache = true にしてあるので、モデルは初回だけ取得され、2 回目以降は端末のキャッシュから読まれます。
🔢 似ている度合いをどう数値にするか
やることは 2 段階です。画像をベクトルにする → ベクトル同士の角度を測る。
export async function embedImages(
urls: string[],
onProgress: ClipProgressCallback,
): Promise<number[][]> {
if (urls.length === 0) return []
const extractor = await getExtractor(onProgress)
const out: number[][] = []
for (let i = 0; i < urls.length; i++) {
onProgress({ kind: "running", message: `画像を解析中… (${i + 1}/${urls.length})` })
const r = await extractor(urls[i])
out.push(l2normalize(Array.from(r.data as Float32Array)))
}
return out
}
image-feature-extraction パイプラインに画像を渡すと、数百次元の浮動小数配列が返ってきます。これを L2 正規化(長さ 1 に揃える)しておくのがポイントで、こうすると内積がそのままコサイン類似度になります。
export function cosine(a: number[], b: number[]): number {
let s = 0
const n = Math.min(a.length, b.length)
for (let i = 0; i < n; i++) s += a[i] * b[i]
return s
}
正規化済みなので、割り算も平方根も要らない。ただの内積です。
😓 苦労したところ
コサイン類似度をそのまま出すと全部「似ている」ことになる
これが一番の落とし穴でした。
CLIP の画像ベクトル同士のコサイン類似度は、まったく関係ない 2 枚でも 0.5 前後になります。同じ写真なら 1.0 近く。つまり実際に使われる範囲は 0.5〜1.0 で、0〜0.5 は事実上デッドゾーンです。
これをそのままパーセント表示すると、猫の写真とビルの写真が「52% 一致」と出る。ユーザーからすれば「半分似ているの?」と混乱するだけです。
そこで、0.5〜1.0 を 0〜100% に引き伸ばしました。
export function simPct(cos: number): number {
// CLIP 画像同士の cos は概ね 0.5〜1.0。 0.5→0%, 1.0→100% に伸張。
const stretched = (cos - 0.5) / 0.5
return Math.round(Math.max(0, Math.min(1, stretched)) * 100)
}
たった 3 行ですが、これがあるとないとで体感がまるで違います。無関係な写真は 0〜10%、似た構図は 60〜80%、ほぼ同一は 95% 以上、という直感に合う分布になりました。
モデルの出力をそのまま見せない。人間が読む数字に翻訳する。 数値を出す AI サービスでは、ここが実装量の割に効きます。
逐次処理にした理由
embedImages はループで 1 枚ずつ処理しています。バッチでまとめて投げれば速くなるのですが、あえて逐次にしました。
理由は進捗表示です。(3/12) のように何枚目を処理中か出せないと、10 枚以上を投げたときに固まったように見える。オンデバイス推論は数秒〜数十秒かかるので、進捗が出ないこと自体が不具合として体験される。速度より、動いていることが伝わるほうを取りました。
何枚までを推奨とするか
ペアの比較回数は枚数の 2 乗で増えます。ただし実際のボトルネックは比較ではなく埋め込みの生成のほうで、こちらは枚数に比例。1 枚あたり数百 ms として、30 枚で十数秒という見当です。
UI では 30 枚程度を目安として案内しています。技術的な上限ではなく、「待てる時間」から逆算した数字です。
削除機能は付けない
最後まで迷って、付けませんでした。
似ている判定はあくまで見た目の近さの目安で、100% ではありません。構図と色が近いだけの別物を拾うこともある。そこに削除ボタンを置くと、AI の判定を根拠に不可逆の操作をさせることになります。
PhotoTwin は「候補を並べて、判断しやすくする」ところまでを役割にしました。実際に消すのは、自分の目で見てから、自分のファイルマネージャで。地味ですが、写真という取り返しのつかないデータを扱う以上、ここは引くべき線だと思っています。
🔒 端末から出さないという設計
推論はすべてブラウザ内で走ります。モデルは初回だけ CDN から取得され、写真そのものはネットワークに一切乗りません。
これは技術的な工夫というより構造の帰結です。サーバーに送る実装をしていないので、送りようがない。家族の写真や仕事の資料を投げても、それは自分の端末から出ません。
「アップロードしません」と書いてあるサービスは多いですが、送っているかどうかを利用者が検証する方法は普通ありません。ブラウザ完結なら、開発者ツールのネットワークタブを見れば誰でも確認できます。主張ではなく確認できる形にするのが、この手のサービスでは一番効く説明だと思っています。
🔭 今後の拡張
- フォルダごと読み込む —
webkitdirectoryで丸ごと投げられるように - グループ表示 — ペアの羅列ではなく「この 5 枚は同じ被写体」というまとまりで出す
- しきい値の調整 — 何 % 以上を「ほぼ同じ」とみなすかをユーザーが動かせるように
- 残す 1 枚の推薦 — 解像度・ブレ・明るさから、グループ内で最も良い 1 枚を提案する
最後のひとつまで行くと、「整理を助ける」から「整理してくれる」に変わります。ただし推薦の理由が説明できないと信用されないので、実装するなら根拠を必ず添える形にするつもりです。
💡 このサービスから言えること
同じモデルでも、入力の組み合わせを変えると別のサービスになる。 what-cam と PhotoTwin は同じ CLIP を使っていますが、片方は画像とテキストを、もう片方は画像と画像を比べています。それだけで解く問題がまったく違う。
新しいことをやろうとすると新しいモデルを探しがちですが、すでに手元にあるものの使い道を変えるほうが、動くところまでの距離が圧倒的に短い。ライブラリを増やさないので、依存の管理もサイズも増えません。
もうひとつは、先に書いた数字の翻訳です。0.5〜1.0 に偏った生の値を、そのままユーザーに見せていたら、このサービスは「よく分からない数字が出るツール」で終わっていました。モデルの精度を上げるより、出力の見せ方を直すほうが体験に効く場面は、思っているより多いです。
[ ./next_action ]
読んだら、 PhotoTwin を実際に動かす。
この開発ログは PhotoTwin をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。