import { Callout } from '@/components/ui/callout';
iPhone を 5 年も使い続けると、写真ライブラリは平気で 3 万枚を超えます。私自身、過去のアプリ素材や旅行写真を探すたびに「あの夕焼けの写真、どのフォルダに入れたっけ」とスクロールに 10 分溶かすことがあり、ずっと「写真整理アプリは AI 前提で作り直すべき領域だな」と感じてきました。実際、App Store の上位写真整理アプリの大半は 2024 年以降に AI 機能を主軸に作り変わっており、ここから新規参入する余地はまだ十分に残っています。
ここではRork で「写真ライブラリを AI が自動で分類してくれるアプリ」を本気で作るための実装パイプラインを、コード付きで一から設計していきます。単に「Vision を呼び出して終わり」ではなく、5 万枚規模のライブラリでも実用速度で動く本番運用パターン にこだわって解説します。私が実際に試して詰まったポイントも全部書きます。
5 万枚を捌くための 3 層アーキテクチャ
写真整理アプリでよくある失敗は、起動時に全画像を一気に解析してしまい、メモリも電池も食い尽くす 実装です。私も最初の試作では、ユーザーが起動してから「処理中...」が 30 分続くアプリを作ってしまいました。本番品質に持っていくには、解析を 3 層に分けて非同期パイプライン化する必要があります。
設計の骨格は次の 3 層です。
Layer 1 (端末側・即時) : PhotoKit から PHAsset を列挙し、Vision で軽量な物体検出 / OCR / 顔検出を実施します。CoreML CLIP モデルで 512 次元の埋め込みを生成します。
Layer 2 (端末側・バックグラウンド) : 端末がアイドル状態のときに BGProcessingTask で残りの画像を順次処理します。SQLite に解析結果と埋め込みをキャッシュします。
Layer 3 (サーバー側) : 端末ストレージに収まらない検索用ベクトルインデックスは Supabase pgvector に保存し、類似検索は HNSW インデックスで高速化します。
ローカル CLIP 埋め込みをそのまま pgvector に同期できるよう、端末とサーバーで同じ埋め込み空間を共有する ことが鍵です。これを最初に決めておかないと、後からアーキテクチャを総取り替えする羽目になります。
なぜ CLIP を使うのか — Vision との役割分担
Vision フレームワークは「これは犬」「これは車」「これはテキスト」というカテゴリ判定 は得意ですが、「夕焼けの海辺で犬と一緒に写っている写真」のような意味的な類似性 は扱えません。一方 CLIP(OpenAI が公開し、Apple が CoreML 形式に最適化したモデルが利用可能)は、画像とテキストを同じベクトル空間に射影できるため、自然言語クエリでの類似検索が可能になります。
実装では両方を併用します。Vision で「タグ」を付け、CLIP で「意味埋め込み」を付ける。検索時は両方を組み合わせるのが最も精度が高く、私が試した中でも最終的にこのハイブリッドが残りました。
Step 1: PhotoKit で写真ライブラリにアクセスする
最初の関門は、ユーザーから写真ライブラリへのアクセス許可をどう取るか です。iOS 14 以降、PHPickerViewController が推奨されていますが、写真整理アプリのように「全画像を解析したい」場合は PHPhotoLibrary の addOnlyAccess ではなく readWriteAccess を要求する必要があります。
権限取得は次のように書きます。Rork のネイティブモジュール(または Expo Modules API)から呼び出せるよう、Swift 側のヘルパーとしてまとめておくのが運用しやすい構造です。
import Photos
enum PhotoLibraryAuthError : Error {
case denied
case restricted
case limited
}
func requestPhotoLibraryAccess () async throws -> [PHAsset] {
let status = await PHPhotoLibrary. requestAuthorization ( for : .readWrite)
switch status {
case .authorized :
return fetchAllAssets ()
case .limited :
// ユーザーが選択した画像だけが返る。UI で「フルアクセスへの誘導」を表示する。
throw PhotoLibraryAuthError.limited
case .denied :
throw PhotoLibraryAuthError.denied
case .restricted :
throw PhotoLibraryAuthError.restricted
case .notDetermined :
throw PhotoLibraryAuthError.denied
@unknown default:
throw PhotoLibraryAuthError.denied
}
}
private func fetchAllAssets () -> [PHAsset] {
let options = PHFetchOptions ()
options.sortDescriptors = [ NSSortDescriptor ( key : "creationDate" , ascending : false )]
options.predicate = NSPredicate ( format : "mediaType = %d" , PHAssetMediaType. image . rawValue )
let result = PHAsset. fetchAssets ( with : options)
var assets: [PHAsset] = []
result. enumerateObjects { asset, _ , _ in assets. append (asset) }
return assets
}
期待する動作は、初回起動時に許可ダイアログが出て、許可されればすべての画像 PHAsset が降順で返ります。limited を返すユーザーは、設定アプリでアクセスを「すべての写真」に変更しないと AI 整理機能が使えないことを画面内で丁寧に説明する必要があります。私は「ライブラリ全体をスキャンしないと類似検索ができないため、フルアクセスをお願いしています」と一行で説明する画面を別途作りました。
PHAsset から実画像を取り出すときの落とし穴
PHImageManager で画像を取得するとき、requestImage のオプション設定を間違えると iCloud から都度ダウンロードが走り、月のモバイル通信を食い尽くす ことになります。これは私が初回リリース後にユーザーから本気で苦情をもらったポイントなので、必ず以下の設定にしてください。
let options = PHImageRequestOptions ()
options.isNetworkAccessAllowed = false // ローカルキャッシュのみ使用
options.deliveryMode = .opportunistic
options.resizeMode = .fast
options.isSynchronous = false
isNetworkAccessAllowed = false にしておくと、「最適化済み(クラウド保存)」の画像だけはスキップされます。これらは別キューで「オフライン解析待ち」にしておき、Wi-Fi 接続時にまとめて取得する設計にします。後述するバックグラウンドジョブで処理します。
Step 2: Vision で物体・OCR・顔ベクトルを抽出する
Vision フレームワークは iOS 17 以降で大幅に強化されており、VNGenerateImageFeaturePrintRequest(特徴量埋め込み)、VNRecognizeTextRequest(OCR)、VNGenerateObjectnessBasedSaliencyImageRequest(注目領域)など、写真整理に有用な機能が揃っています。
私の本番アプリでは、1 枚の画像に対して以下の 4 つを同時に走らせています。VNImageRequestHandler の 1 回の呼び出しで複数のリクエストをまとめると、画像のデコードコストが共有されて 2〜3 倍速くなります。
import Vision
struct PhotoAnalysisResult {
let assetIdentifier: String
let labels: [ String ] // 物体ラベル("dog", "beach" 等)
let texts: [ String ] // OCR で抽出したテキスト
let faceCount: Int // 顔の数
let saliencyBox: CGRect ? // 主要被写体のバウンディングボックス
let visionFeaturePrint: Data // Vision の特徴量
let aestheticScore: Float // 写真の品質スコア(iOS 18+)
}
func analyzeWithVision ( image : CGImage, identifier : String ) async throws -> PhotoAnalysisResult {
let handler = VNImageRequestHandler ( cgImage : image, options : [ : ])
// 1. 物体分類
let classifyRequest = VNClassifyImageRequest ()
classifyRequest.usesCPUOnly = false // Neural Engine を使う
// 2. OCR
let ocrRequest = VNRecognizeTextRequest ()
ocrRequest.recognitionLevel = .accurate
ocrRequest.recognitionLanguages = [ "ja-JP" , "en-US" ]
// 3. 顔検出
let faceRequest = VNDetectFaceRectanglesRequest ()
// 4. 特徴量
let featurePrintRequest = VNGenerateImageFeaturePrintRequest ()
try handler. perform ([classifyRequest, ocrRequest, faceRequest, featurePrintRequest])
let labels = (classifyRequest.results ?? [])
. filter { $0 .confidence > 0.5 }
. prefix ( 10 )
. map { $0 .identifier }
let texts = (ocrRequest.results ?? [])
. compactMap { $0 . topCandidates ( 1 ). first ? .string }
. filter { ! $0 . isEmpty }
let faceCount = faceRequest.results ? . count ?? 0
guard let featurePrint = featurePrintRequest.results ? . first as? VNFeaturePrintObservation else {
throw NSError ( domain : "Vision" , code : -1 )
}
return PhotoAnalysisResult (
assetIdentifier : identifier,
labels : Array (labels),
texts : texts,
faceCount : faceCount,
saliencyBox : nil ,
visionFeaturePrint : featurePrint.data,
aestheticScore : 0.0
)
}
期待する出力は、犬と海が写った写真なら labels: ["dog", "beach", "ocean", "outdoor"]、レストランの看板写真なら labels: ["restaurant", "sign"] と texts: ["営業時間", "11:00-22:00"] のような形です。
なぜ VNGenerateImageFeaturePrintRequest も使うのか
Vision の VNFeaturePrintObservation は、同一サブジェクトの写真同士の距離計算 に最適化されています。CLIP は意味的類似(「犬の写真」と「猫の写真」は近い)を測りますが、Vision の特徴量は視覚的類似(「同じ場所で撮った 3 枚」が近い)を測ります。両者は補完関係で、私のアプリでは「重複・連写検出」には Vision、「テーマ検索」には CLIP と使い分けています。
このトレードオフの判断は、目的によって変わります。重複検出だけが目的なら Vision の特徴量だけで十分ですし、自然言語検索が中心なら CLIP のみでも成立します。両方搭載すると埋め込みデータの容量が倍になるため、ターゲットユーザーのストレージ事情も考慮してください。
Step 3: CoreML で CLIP 埋め込みを生成する
CLIP の CoreML 化されたモデルは、Apple ML Research がリリースした「MobileCLIP」を使うのが現状ベストです。MobileCLIP-S0(標準)や MobileCLIP-S2(高精度)が選択でき、iPhone 14 以降であれば MobileCLIP-S2 でも 1 枚 50ms 程度で推論が回ります。私はバランス重視で MobileCLIP-S1 を採用しています。
モデルの読み込みと推論は次のようになります。
import CoreML
class CLIPEmbeddingService {
private let model: MobileCLIPS1
private let imageProcessor: VNCoreMLRequest
init () throws {
let config = MLModelConfiguration ()
config.computeUnits = .cpuAndNeuralEngine // Neural Engine を優先
self .model = try MobileCLIPS1 ( configuration : config)
let coreMLModel = try VNCoreMLModel ( for : model.model)
self .imageProcessor = VNCoreMLRequest ( model : coreMLModel)
self .imageProcessor.imageCropAndScaleOption = .centerCrop
}
func embedImage ( _ cgImage: CGImage) async throws -> [ Float ] {
let handler = VNImageRequestHandler ( cgImage : cgImage, options : [ : ])
try handler. perform ([imageProcessor])
guard let result = imageProcessor.results ? . first as? VNCoreMLFeatureValueObservation,
let array = result.featureValue.multiArrayValue else {
throw NSError ( domain : "CLIP" , code : -1 )
}
// 512 次元の Float32 ベクトルに変換
let count = array. count
var embedding = [ Float ]( repeating : 0 , count : count)
for i in 0 ..< count {
embedding[i] = array[i].floatValue
}
// L2 正規化(pgvector でコサイン類似度を使うため)
let norm = sqrt (embedding. reduce ( 0 ) { $0 + $1 * $1 })
return embedding. map { $0 / norm }
}
func embedText ( _ text: String ) async throws -> [ Float ] {
// テキストエンコーダーは tokenize → embed の 2 段階
// ここでは簡略化して説明(実装は Apple のサンプルコードを参照)
fatalError ( "テキスト埋め込みの完全実装は本記事のスコープを超えるため省略" )
}
}
期待する出力は、512 次元の Float32 配列で、L2 正規化済み(ノルムが 1.0)です。これを後段の pgvector に流し込みます。
なぜ L2 正規化が必須なのか
pgvector でコサイン類似度(<=> 演算子)を使う場合、入力ベクトルが L2 正規化されていないと距離計算の意味が変わります 。CLIP の出力は正規化されていないので、必ず端末側で正規化してから保存してください。これを忘れて 1 ヶ月運用してしまい、検索精度が低くて悩んだのが私の失敗談です。
Step 4: Supabase pgvector でハイブリッド検索基盤を作る
サーバー側のスキーマ設計です。1 枚の画像に対して、Vision の構造化メタデータと CLIP 埋め込みを両方持ちます。pgvector の HNSW インデックスを使うと、100 万件規模でも 50ms 以下でトップ K 件の類似画像を返せます。
-- pgvector 拡張を有効化
create extension if not exists vector ;
-- 画像メタデータテーブル
create table photo_assets (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth . users (id) on delete cascade ,
asset_identifier text not null , -- iOS の PHAsset.localIdentifier
taken_at timestamptz ,
labels text [], -- Vision の物体ラベル
ocr_text text , -- OCR 全文(GIN インデックス用)
face_count int default 0 ,
embedding vector ( 512 ), -- CLIP 埋め込み
created_at timestamptz default now (),
unique (user_id, asset_identifier)
);
-- HNSW インデックス(コサイン類似度)
create index on photo_assets
using hnsw (embedding vector_cosine_ops)
with (m = 16 , ef_construction = 64 );
-- ラベル検索用 GIN インデックス
create index on photo_assets using gin (labels);
-- OCR 全文検索(日本語)
create index on photo_assets using gin (to_tsvector( 'simple' , ocr_text));
-- RLS 設定
alter table photo_assets enable row level security ;
create policy "users can read own assets"
on photo_assets for select
using ( auth . uid () = user_id);
create policy "users can insert own assets"
on photo_assets for insert
with check ( auth . uid () = user_id);
ハイブリッド検索のクエリは次のようになります。「夕焼けの海」のような自然言語クエリと、「labels に beach が含まれる」というメタデータ条件を組み合わせます。
-- 自然言語クエリ + メタデータフィルタのハイブリッド検索
create or replace function search_photos_hybrid (
query_embedding vector ( 512 ),
filter_labels text [],
match_count int default 30
)
returns table (
id uuid,
asset_identifier text ,
labels text [],
similarity float
)
language sql stable as $$
select
p . id ,
p . asset_identifier ,
p . labels ,
1 - ( p . embedding <=> query_embedding) as similarity
from photo_assets p
where p . user_id = auth . uid ()
and (filter_labels is null or p . labels && filter_labels)
order by p . embedding <=> query_embedding
limit match_count;
$$;
期待する動作は、query_embedding に「夕焼けの海」のテキスト埋め込みを渡し、filter_labels に ARRAY['beach', 'ocean'] を指定すると、海・ビーチタグが付いた写真の中から意味的に最も近い 30 枚が similarity 降順で返ります。
よくあるパフォーマンス落とし穴 3 つ
実装中に必ず踏むであろう罠を、私が踏んだ順に紹介します。
HNSW インデックスを作らずに ivfflat を選ぶ : 古い記事だと ivfflat を勧めるものが多いですが、pgvector 0.5+ なら HNSW の方が明確に高速で、構築時のチューニングも楽です。m = 16, ef_construction = 64 から始めて、精度が足りなければ m を上げます。
embedding カラムを text で持ってしまう : クライアント実装の都合で JSON 文字列のまま保存している例をよく見ますが、これだと HNSW が効かずフルスキャン になります。必ず vector(512) 型で持ってください。
アップサート時に on conflict ... do update で全カラム更新する : Vision の解析結果は端末で再計算が起こりやすく、毎回フル更新するとサーバー負荷が跳ね上がります。embedding だけは初回計算したら触らない設計にしておくのが安全です(CLIP の出力は決定論的なので、再計算する意味がありません)。
Step 5: バックグラウンドジョブで 5 万枚を捌く
ここが本番運用で最も差が出る部分です。フォアグラウンドで全画像を処理しようとすると、ユーザーは絶対にアプリを閉じます(私の最初の試作では離脱率 70% でした)。BGProcessingTask を使った非同期パイプラインに切り替えると、就寝中に勝手に処理が進み、朝起きたら全画像が解析済み、という体験を作れます。
import BackgroundTasks
let backgroundTaskIdentifier = "com.example.photoorganizer.indexing"
func registerBackgroundTask () {
BGTaskScheduler.shared. register (
forTaskWithIdentifier : backgroundTaskIdentifier,
using : nil
) { task in
handleBackgroundIndexing ( task : task as! BGProcessingTask)
}
}
func scheduleBackgroundIndexing () {
let request = BGProcessingTaskRequest ( identifier : backgroundTaskIdentifier)
request.requiresNetworkConnectivity = true // pgvector 同期のため
request.requiresExternalPower = true // CLIP は電池を食う
request.earliestBeginDate = Date ( timeIntervalSinceNow : 60 * 60 ) // 1 時間後
do {
try BGTaskScheduler.shared. submit (request)
} catch {
print ( "BGTask scheduling failed: \( error ) " )
}
}
func handleBackgroundIndexing ( task : BGProcessingTask) {
scheduleBackgroundIndexing () // 次回分を予約
let queue = OperationQueue ()
queue.maxConcurrentOperationCount = 2 // Neural Engine の競合回避
task.expirationHandler = {
queue. cancelAllOperations ()
}
Task {
do {
let pending = try await fetchPendingAssets ( limit : 500 )
for asset in pending {
guard ! task.isCancelled else { break }
try await processAsset (asset)
}
task. setTaskCompleted ( success : true )
} catch {
task. setTaskCompleted ( success : false )
}
}
}
期待する動作は、requiresExternalPower = true を指定しているため、ユーザーが充電器を繋いで放置している夜に走り、500 枚ずつ処理が進みます。earliestBeginDate を 1 時間後にすることで、頻繁に起動しないユーザーでも 1〜2 日でライブラリ全体の解析が完了します。
充電条件をオプトアウトする選択肢
「すぐに整理したい」というユーザー向けに、フォアグラウンドで明示的に処理を進めるモードも用意します。この場合は 画像 1 枚ずつではなく、20 枚ずつバッチで処理 して、UI に「23,400 / 50,000 枚処理中」のように細かい進捗を表示するのがコツです。私が試した中で最も離脱率が下がったのは、進捗バーに加えて「あと約 12 分」のような時間予測を出した版でした。
Step 6: 検索 UI と AI 自動アルバム生成
検索 UI は、テキスト入力欄に「夕焼けの海」と打つと CLIP テキストエンコーダーで埋め込み化し、Step 4 の search_photos_hybrid を呼び出すだけです。それより面白いのは「自動アルバム生成」機能です。
CLIP 埋め込み空間で k-means クラスタリング を実行すると、ユーザーが定義しなくても「自然なテーマ別アルバム」が浮かび上がります。私のアプリでは 50 クラスタに分けて、各クラスタの中心ベクトルに最も近い画像を「アルバム表紙」にし、クラスタ全体を 1 枚ずつ Claude や Gemini に投げて「このアルバムにふさわしいタイトルを 10 文字以内で」と命令することで、「夏の海辺」「街角のカフェ」「家族の休日」のような人間が付けるような名前を自動生成しています。
// React Native 側 — Supabase Edge Function を呼ぶ
async function generateSmartAlbums () {
const response = await fetch (
`${ SUPABASE_URL }/functions/v1/generate-albums` ,
{
method: 'POST' ,
headers: {
Authorization: `Bearer ${ session . access_token }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify ({ cluster_count: 50 }),
}
);
const albums = await response. json ();
// [{ id, title, cover_asset_identifier, asset_count }, ...]
return albums;
}
期待する応答は、50 個のアルバム情報の配列で、各アルバムには Claude が命名したタイトル、表紙画像の asset_identifier、含まれる画像数が入ります。私の体感では、Claude Sonnet にクラスタ内の上位 5 枚のメタデータ(labels、ocr_text、taken_at の月)を渡して命名させると、9 割は人間が違和感なく受け入れる名前を返してくれます。
本番運用で必ず詰まる 5 つの落とし穴
ここまでの実装をリリースしてから、私が実際に詰まったポイントを共有します。事前に知っておくと、何時間も溶かさずに済みます。
iCloud 写真ライブラリで「最適化」されているユーザーが想定外に多い : 全体の 60% が「iPhone のストレージを最適化」設定のため、フル解像度画像が端末にない状態でした。Wi-Fi 接続時にだけ isNetworkAccessAllowed = true で取得し、それ以外はスキップする分岐が必須です。
CLIP 埋め込みのバージョン管理を忘れると検索精度が一晩で崩壊する : モデルを MobileCLIP-S1 から S2 に上げたとき、新旧の埋め込みが混在して全く違うベクトル空間になりました。embedding_model_version カラムを必ず持ち、バージョンが違うものは検索対象から除外するか、再計算ジョブをスケジュールしてください。
HNSW インデックスの構築は意外と時間がかかる : 5 万枚 × 512 次元のインデックス構築に Supabase の Free プランで 8 分ほどかかりました。Pro プランでも 2〜3 分は見込んでください。リリース前のロードテストで気付いておかないと、本番投入時にダウンタイムが出ます。
App Tracking Transparency の同意を取らないと写真メタデータの一部が制限される : 厳密には PhotoKit は ATT の対象外ですが、写真の Exif(位置情報含む)を解析機能として使うなら、Apple のレビュアーに聞かれた際の回答を準備しておくと安全です。私は審査で 1 回質問されました。
5 万枚の PHAsset を一度に取得すると 100MB 級のメモリを食う : PHFetchResult は遅延評価ですが、enumerateObjects で配列化すると一気にメモリに乗ります。1,000 件ずつページングするか、PHFetchResult のまま objectAt(_:) で必要な分だけ触る設計にしてください。
応用 — 本番アプリへの組み込みパターン
ここまでの実装は、単体の写真整理アプリだけでなく、以下のようなプロダクトにも転用できます。
ECアプリの商品写真検索 : 在庫商品の画像を CLIP で埋め込み化し、ユーザーがアップロードした「こんな感じの商品」画像で類似検索を返すバイサイド検索を実装できます。
不動産アプリの内見写真 : 物件の内見写真を「明るい・モダン・レトロ」のような自然言語クエリで検索可能にします。Apple の MobileCLIP は日本語にも対応しており、「白い壁の和室」のようなクエリも通ります。
教育アプリのノート整理 : ユーザーが撮影した手書きノートを Vision の OCR でテキスト化し、CLIP 埋め込みで「微積分のグラフが書かれているページ」のようなセマンティック検索を実装できます。
私自身、最初の写真整理アプリで作ったパイプラインを、他のジャンルの 2 つ目のアプリでほぼ流用しました。埋め込みベース検索の基盤は一度作れば多目的に使える のが、この設計の最大のメリットです。
Vision フレームワークの深掘りには、Apple の WWDC24 のセッション「What's new in Vision」 が現時点で最も実装に近い情報源です。
全体を振り返って — 今日から始める最小ステップ
この記事の全パイプラインを一度に実装する必要はありません。まず Step 1 と Step 2 だけを動かして、ユーザーの写真ライブラリから物体ラベルが取れることを確認してください 。Vision だけでも「犬の写真を一覧」「テキストが写った写真を検索」レベルのアプリは成立し、リリースして反応を見ながら CLIP 埋め込みと pgvector を後から積み上げていく方が、最初から全部実装しようとして燃え尽きるよりずっと健全です。
私自身、最初のリリースは Vision だけの「タグ検索アプリ」でした。1,000 ダウンロード超えてからユーザーの「もっと自由に検索したい」という声を受けて CLIP 統合を追加しました。先に動くものを出して、ユーザーの実際の使い方からニーズを拾う 方が、結果的にいいアプリになります。
関連する記事として、検索基盤の理論部分は Rork × Supabase pgvector でセマンティック検索アプリを構築する で別角度から解説しています。Vision フレームワーク自体の詳細は Rork Max Vision Framework ガイド に整理しているので、本記事と組み合わせて読むと理解が立体的になります。