Skip to content

Commit c1f2ea5

Browse files
chodaictclaude
andcommitted
Tighten Look-alikes, rework Similar sets into named albums, plain-language UI
Look-alikes: drop the feature-print threshold 0.7 → 0.15. Apple-certified identical images measure 0.0; merely-similar shots (three cat poses ≈0.23–0.35) were sailing under 0.7 and getting pre-marked for deletion. 0.15 catches only true re-saved copies and leaves the similar-but-different ones alone. Similar sets: no longer broad category buckets. It now clusters the library at the "same scene, different moment" band (dHash ≤14 candidates, feature distance ≤0.45 — so the cats group here, not in Look-alikes) and turns each set into a named album. Names come from the set's Vision content tags, prettified by apfel (Apple Intelligence CLI) when available, else the top tag. No deletion. UI text rewritten in plain, everyday language across en/ja/zh-TW — onboarding captions, the two section headers (Near-identical / A bit alike — you choose), keep/delete/favorite tooltips, and the Apple-ranked / Faces hints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f591fe3 commit c1f2ea5

5 files changed

Lines changed: 107 additions & 59 deletions

File tree

app/Sources/SnapsiftApp/Apfel.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ enum Apfel {
2323
/// Labels (a subset of `labels`) whose meaning matches `query`, or nil if
2424
/// apfel can't run (caller should fall back to substring matching).
2525
static func match(query: String, labels: [String]) async -> [String]? {
26-
guard let url else { return nil }
26+
guard isInstalled else { return nil }
2727
let system = """
2828
You map a photo-search query to category labels. Given a QUERY and a \
2929
list of LABELS (lowercase taxonomy ids), reply with ONLY a JSON array \
@@ -42,6 +42,23 @@ enum Apfel {
4242
return matched.isEmpty ? nil : matched
4343
}
4444

45+
/// A short, friendly album name for a set of photos described by Vision tags
46+
/// (e.g. ["cat","basket","indoor"] → "Cat in a basket"). nil if apfel can't
47+
/// run — caller falls back to the top tag.
48+
static func albumName(tags: [String]) async -> String? {
49+
guard url != nil, !tags.isEmpty else { return nil }
50+
let system = """
51+
You name a photo album in 2–4 words from content tags. Reply with ONLY \
52+
the name — no quotes, no punctuation, Title Case. Be natural, not a tag list.
53+
"""
54+
let prompt = "TAGS: \(tags.joined(separator: ", "))"
55+
guard let out = await run(["-q", "--temperature", "0.3", "-s", system, prompt]) else { return nil }
56+
let name = out.trimmingCharacters(in: .whitespacesAndNewlines)
57+
.replacingOccurrences(of: "\"", with: "")
58+
let firstLine = name.split(whereSeparator: \.isNewline).first.map(String.init) ?? name
59+
return firstLine.isEmpty || firstLine.count > 40 ? nil : firstLine
60+
}
61+
4562
private static func run(_ args: [String]) async -> String? {
4663
guard let url else { return nil }
4764
return await withCheckedContinuation { cont in

app/Sources/SnapsiftApp/CategoryScanner.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ enum CategoryScanner {
1818

1919
/// The chosen bucket label for an asset, or nil if nothing is reliable.
2020
static func category(for asset: PHAsset, manager: PHCachingImageManager) async -> String? {
21-
guard let cg = await cgImage(for: asset, manager: manager) else { return nil }
21+
await labels(for: asset, manager: manager).first
22+
}
23+
24+
/// Up to `limit` reliable, specific content labels for an asset (most
25+
/// specific first; over-generic ancestors dropped). Used to name a set.
26+
static func labels(for asset: PHAsset, manager: PHCachingImageManager, limit: Int = 3) async -> [String] {
27+
guard let cg = await cgImage(for: asset, manager: manager) else { return [] }
2228
let request = VNClassifyImageRequest()
2329
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
24-
do { try handler.perform([request]) } catch { return nil }
25-
let reliable = (request.results ?? []).filter { $0.hasMinimumPrecision(0.3, forRecall: 0) }
26-
if let specific = reliable.first(where: { !generic.contains($0.identifier) }) {
27-
return specific.identifier
28-
}
29-
return reliable.first?.identifier
30+
do { try handler.perform([request]) } catch { return [] }
31+
let reliable = (request.results ?? [])
32+
.filter { $0.hasMinimumPrecision(0.3, forRecall: 0) && !generic.contains($0.identifier) }
33+
return Array(reliable.prefix(limit).map(\.identifier))
3034
}
3135

3236
/// A label like "interior_room" → "Interior room" for display.

app/Sources/SnapsiftApp/L10n.swift

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ struct L10n: Sendable {
5353
}
5454
func tipSimilarSets() -> String {
5555
switch language {
56-
case .en: return "Group your whole library by what's in each photo (neural, on-device) — across time, nothing deleted"
57-
case .ja: return "写真の内容でライブラリ全体を分類(オンデバイス・時間をまたぐ・削除しない)"
58-
case .zhTW: return "用照片內容把整個圖庫分類(裝置端神經、跨時間、不刪任何東西)"
56+
case .en: return "Gathers the sets where you took several shots of the same thing into named albums — nothing deleted"
57+
case .ja: return "同じ被写体を何枚も撮ったセットを、名前付きアルバムにまとめる(削除しない)"
58+
case .zhTW: return "把你對同一個東西拍了好幾張的成組,整理成有名字的相簿(不刪任何東西)"
5959
}
6060
}
6161
func progClassifying(_ i: Int, _ total: Int) -> String {
@@ -65,6 +65,13 @@ struct L10n: Sendable {
6565
case .zhTW: return "分類中 \(i)/\(total)"
6666
}
6767
}
68+
func progNaming(_ i: Int, _ total: Int) -> String {
69+
switch language {
70+
case .en: return "Naming sets \(i)/\(total)"
71+
case .ja: return "セットに名前を付け中 \(i)/\(total)"
72+
case .zhTW: return "為相簿命名 \(i)/\(total)"
73+
}
74+
}
6875
func categoryHeader(count: Int, shown: Int) -> String {
6976
switch language {
7077
case .en: return shown < count ? "\(count) photos · showing first \(shown)" : "\(count) photos"
@@ -138,9 +145,9 @@ struct L10n: Sendable {
138145
}
139146
func appleRanked() -> String {
140147
switch language {
141-
case .en: return "Apple-ranked"
142-
case .ja: return "Apple 品質順"
143-
case .zhTW: return "Apple 品質排序"
148+
case .en: return "Apple picked the best"
149+
case .ja: return "Apple がベストを選択"
150+
case .zhTW: return "Apple 幫你挑最好的"
144151
}
145152
}
146153
func selectCluster() -> String {
@@ -152,9 +159,9 @@ struct L10n: Sendable {
152159
}
153160
func scanHint() -> String {
154161
switch language {
155-
case .en: return "Scan to find near-duplicate bursts"
156-
case .ja: return "スキャンしてよく似た連写を探します"
157-
case .zhTW: return "掃描以找出近乎重複的連拍"
162+
case .en: return "Pick what to look for"
163+
case .ja: return "何を探すか選んでください"
164+
case .zhTW: return "選一個你想找的"
158165
}
159166
}
160167

@@ -176,44 +183,44 @@ struct L10n: Sendable {
176183
}
177184
func tipKeeper() -> String {
178185
switch language {
179-
case .en: return "Keeper"
180-
case .ja: return "残す1枚"
181-
case .zhTW: return "保留這張"
186+
case .en: return "The one we'll keep"
187+
case .ja: return "残すのはこの1枚"
188+
case .zhTW: return "會留下的就這張"
182189
}
183190
}
184191
func tipDelete() -> String {
185192
switch language {
186-
case .en: return "Will be deleted · click to keep this one"
187-
case .ja: return "削除されます · クリックでこれを残す"
188-
case .zhTW: return "將被刪除 · 點擊改留這張"
193+
case .en: return "This one gets deleted click it to keep it instead"
194+
case .ja: return "これは削除されます — クリックすれば残せます"
195+
case .zhTW: return "這張會被刪 — 點一下改成留它"
189196
}
190197
}
191198
func tipFavorite() -> String {
192199
switch language {
193-
case .en: return "Favorite — always kept"
194-
case .ja: return "お気に入り — 常に残します"
195-
case .zhTW: return "最愛 — 一律保留"
200+
case .en: return "Favorite — never deleted"
201+
case .ja: return "お気に入り — 絶対に削除しません"
202+
case .zhTW: return "最愛 — 絕對不刪"
196203
}
197204
}
198205
func tipScan() -> String {
199206
switch language {
200-
case .en: return "Find near-duplicate burst sequences"
201-
case .ja: return "よく似た連写を探す"
202-
case .zhTW: return "找出近乎重複的連拍序列"
207+
case .en: return "Clears the burst of near-identical shots from holding the shutter — keeps the best one"
208+
case .ja: return "シャッターを押し続けて撮れたそっくりな連写を片付け、ベストの1枚を残す"
209+
case .zhTW: return "把你按住快門連拍出的一堆幾乎一樣的,留最好一張、清掉其餘"
203210
}
204211
}
205212
func tipLookAlikes() -> String {
206213
switch language {
207-
case .en: return "Find the same photo saved across different days (neural, on-device)"
208-
case .ja: return "別の日に保存された同じ写真を探す(オンデバイスのニューラル解析)"
209-
case .zhTW: return "找出不同天存下的同一張照片(裝置端神經分析)"
214+
case .en: return "Finds the same photo saved more than once — re-downloaded, screenshotted, AirDropped back — even days apart"
215+
case .ja: return "同じ写真を何度も保存したもの(再ダウンロード・スクショ・AirDrop)を、別の日でも見つける"
216+
case .zhTW: return "找出同一張被存了好幾份的(重新下載、截圖、AirDrop 回來),就算隔了好幾天"
210217
}
211218
}
212219
func tipFaces() -> String {
213220
switch language {
214-
case .en: return "Re-pick keepers using on-device face + open-eyes analysis"
215-
case .ja: return "顔と開いた目の解析で残す1枚を選び直す(オンデバイス)"
216-
case .zhTW: return "用裝置端人臉與睜眼分析重新挑選保留張"
221+
case .en: return "Re-picks the keeper to the frame where faces look best — eyes open"
222+
case .ja: return "顔がいちばん良く写った1枚(目が開いている)を残すよう選び直す"
223+
case .zhTW: return "改挑大家臉拍得最好、眼睛有張開的那張當保留"
217224
}
218225
}
219226
func tipAppleRanked() -> String {
@@ -284,16 +291,16 @@ struct L10n: Sendable {
284291
}
285292
func sectionConfident(_ n: Int) -> String {
286293
switch language {
287-
case .en: return "Duplicates · \(n)"
288-
case .ja: return "重複 · \(n)"
289-
case .zhTW: return "確定重複 · \(n)"
294+
case .en: return "Near-identical · \(n)"
295+
case .ja: return "ほぼ同じ · \(n)"
296+
case .zhTW: return "幾乎一樣 · \(n)"
290297
}
291298
}
292299
func sectionPending(_ n: Int) -> String {
293300
switch language {
294-
case .en: return "Similar — you decide · \(n)"
295-
case .ja: return "似ている — あなたが選ぶ · \(n)"
296-
case .zhTW: return "相似 · 你決定 · \(n)"
301+
case .en: return "A bit alike — you choose · \(n)"
302+
case .ja: return "少し似ている — あなたが選ぶ · \(n)"
303+
case .zhTW: return "有點像 · 你決定 · \(n)"
297304
}
298305
}
299306
func keepAll() -> String {

app/Sources/SnapsiftApp/LibraryModel.swift

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ struct ReviewGroup: Identifiable {
3030
/// the same content label, across the whole library and across time.
3131
struct CategoryBucket: Identifiable {
3232
let id = UUID()
33-
let label: String
33+
let label: String // already a display-ready name (apfel or top Vision tag)
3434
let photos: [Photo]
3535
var count: Int { photos.count }
36-
var display: String { CategoryScanner.displayName(label) }
36+
var display: String { label }
3737
}
3838

3939
/// Drives the whole app: PhotoKit authorization, enumeration → Core clustering,
@@ -254,9 +254,11 @@ final class LibraryModel: ObservableObject {
254254
}
255255
}
256256

257-
/// "Similar sets": classify the whole library on-device (Vision) and bucket
258-
/// photos by what's in them — across time, no deletion. Heavier than the
259-
/// other passes (one classification per photo); shows progress.
257+
/// "Similar sets": find sets of photos you took of the same thing — several
258+
/// near-same shots (like three poses of the same cat) — by clustering the
259+
/// whole library on visual similarity (looser than Look-alikes), then naming
260+
/// each set from its Vision content tags (prettified by apfel if available).
261+
/// No deletion — this is for browsing/curation.
260262
func scanSimilarSets(_ t: L10n) async {
261263
guard !isScanning else { return }
262264
isScanning = true
@@ -285,20 +287,36 @@ final class LibraryModel: ObservableObject {
285287
}
286288
let enr = enrichment ?? [:]
287289

288-
var buckets: [String: [Photo]] = [:]
289-
var done = 0
290-
for asset in assets {
291-
if let label = await CategoryScanner.category(for: asset, manager: imageManager) {
292-
buckets[label, default: []].append(makePhoto(from: asset, enr: enr))
293-
}
294-
done += 1
295-
if done % 200 == 0 { progress = t.progClassifying(done, assets.count) }
290+
// Cluster the library at the "same scene, different moment" band — looser
291+
// than Look-alikes (which is now ≈identical only), so pose/angle changes
292+
// still group (cats ≈0.3 feature distance land here, not in Look-alikes).
293+
let idGroups = await LookAlikeScanner.scan(
294+
assets: assets, manager: imageManager, t: t,
295+
dHashDistance: 14, featureDistance: 0.45
296+
) { [weak self] msg in Task { @MainActor in self?.progress = msg } }
297+
298+
var albums: [CategoryBucket] = []
299+
var i = 0
300+
for ids in idGroups {
301+
i += 1
302+
if i % 10 == 0 { progress = t.progNaming(i, idGroups.count) }
303+
let photos = ids.compactMap { map[$0] }.map { makePhoto(from: $0, enr: enr) }
304+
.sorted { $0.takenAt < $1.takenAt }
305+
guard photos.count >= 2 else { continue }
306+
let name = await albumName(for: photos)
307+
albums.append(CategoryBucket(label: name, photos: photos))
296308
}
309+
categories = albums.sorted { $0.count > $1.count }
310+
}
297311

298-
categories = buckets
299-
.map { CategoryBucket(label: $0.key, photos: $0.value) }
300-
.filter { $0.count >= 2 }
301-
.sorted { $0.count > $1.count }
312+
/// Name a set from the Vision tags of its representative frame, prettified by
313+
/// apfel when available; otherwise the top tag (or a generic fallback).
314+
private func albumName(for photos: [Photo]) async -> String {
315+
guard let rep = photos.first, let asset = assetsByID[rep.uuid] else { return "Set" }
316+
let tags = await CategoryScanner.labels(for: asset, manager: imageManager)
317+
guard let top = tags.first else { return "Set" }
318+
if let pretty = await Apfel.albumName(tags: tags) { return pretty }
319+
return CategoryScanner.displayName(top)
302320
}
303321

304322
/// Promote a frame to keeper (favorites stay protected either way).

app/Sources/SnapsiftApp/LookAlikeScanner.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ enum LookAlikeScanner {
2020
manager: PHCachingImageManager,
2121
t: L10n,
2222
dHashDistance: Int = 8,
23-
featureDistance: Float = 0.7,
23+
featureDistance: Float = 0.15, // ≈0.0 for a true re-saved
24+
// copy; 0.15 excludes merely
25+
// similar shots (cats ≈0.3)
2426
progress: @escaping (String) -> Void) async -> [[String]] {
2527

2628
// Stage 1 — dHash all thumbnails (tiny 9×8 requests, fast).

0 commit comments

Comments
 (0)