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 本立つ。

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

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →
GanttPad

GanttPad ができるまで — 日付軸タイムラインのレイアウト計算

タスクの開始/終了日から全体期間を求め、 各バーの offset(日数) と duration を算出して SVG に配置。 期間に応じて日/週/月の目盛りを自動切替し、 今日ライン・週末シェード・進捗塗りを描く。 プランニングボード motif で 組んだ サービス設計記録。

read log →
HeirShare

HeirShare ができるまで — 相続税の早見表ロジックを実装する

家族構成から法定相続人と法定相続分を判定し、 基礎控除 → 課税遺産総額 → 法定相続分按分 → 速算表 → 相続税の総額 → 配偶者の税額軽減 (法定相続分 or 1.6億) という早見表と同じ流れを純 JS で実装。 相続関係図 motif で 組んだ サービス設計記録。

read log →
GridNine

GridNine ができるまで — 唯一解の数独をブラウザで生成する

MRV バックトラッキングで完成盤を作り、 セルを抜くたびに解の個数を 2 で打ち切り数えて『唯一解』を保ったまま穴掘り。 候補メモ・重複ハイライト・ヒント・タイマーを 文庫パズル誌 motif で 組んだ サービス設計記録。

read log →