▍AI Dev Lab
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

カメラロールを開くと、同じ料理を 6 枚撮っている。同じ画面のスクリーンショットが 4 枚ある。旅行の写真を見返すと、ほぼ同じ構図が延々続く。

消したいのに、どれを消せばいいか分からない。

ファイル名で並べても意味がないし、撮影日順に並べても「似ているかどうか」は自分で目視するしかない。既存の重複チェックツールはファイルのハッシュ値で比較するので、リサイズしたものや保存し直したものは「別のファイル」として素通りします。

PhotoTwin は、この「見た目が同じかどうか」をブラウザの中の AI に判定させるサービスです。


#🎯 ハッシュ比較では届かない領域

まず、なぜ既存のやり方では足りないのかを整理します。

手法見つかるもの見つからないもの
ファイルハッシュバイト単位で完全に同じファイルリサイズ・再保存・形式違い
ファイル名・サイズ命名規則が揃っているもの名前がバラバラのもの
知覚ハッシュ (pHash)ほぼ同じ画像構図が近いだけの別カット
画像埋め込み (今回)完全重複+見た目が近いもの—

実際に困るのは、**「同じだけど同じファイルではない」**ケースです。

  • SNS 用に縮小した版と元の版
  • スクショを撮り直した 2 枚
  • 連写の 1 コマ違い
  • JPEG で保存し直して容量だけ変わったもの

ハッシュはこの全部を取り逃します。pHash なら近いところまで行けますが、「同じ場所で撮った別カット」のような意味的に近いものまでは拾えません。

そこで、画像を意味のベクトルに変換する CLIP を使うことにしました。


#🏗️ 技術選定:新しいライブラリを増やさない

ラボにはすでに what-cam(写真を撮ると「これは何か」を当てる画像認識サービス)があり、そこで CLIP を動かしています。PhotoTwin はまったく同じモデルを、別の使い方で呼ぶだけにしました。

  • what-cam: 画像ベクトルとテキストベクトルの近さを測る(ゼロショット分類)
  • PhotoTwin: 画像ベクトルと画像ベクトルの近さを測る(類似検出)

CLIP は画像とテキストを同じ空間に埋め込むモデルなので、片方だけ使えば画像同士の比較器になります。新しいモデルもライブラリも増やさずに、まったく別のサービスが 1 本立つ。

ts
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 段階です。画像をベクトルにする → ベクトル同士の角度を測る。

ts
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 に揃える)しておくのがポイントで、こうすると内積がそのままコサイン類似度になります。

ts
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% に引き伸ばしました。

ts
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 をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →