Skip to content

Commit 783eadf

Browse files
chodaictclaude
andcommitted
Scan reliability: neural confident-gate, look-alike big-cluster guard, gate language
Three trust/robustness fixes found while dogfooding on a large library: - Burst scan never pre-marks a deletion on dHash alone. dHash can't tell "same framing, subject moved" from a true duplicate, so it was confidently marking a different-moment frame for deletion. A cluster now stays confident only if Apple's neural feature print agrees it's near-identical (max pairwise distance ≤ 0.10); anything looser — or any frame that can't be read — falls to "you decide, keep all". Missing a duplicate is harmless; deleting a keeper isn't. - Look-alikes no longer freezes. A stage-1 dHash cluster bigger than 80 is collision noise (solid colours, screenshots) whose stage-2 confirmation means thousands of neural prints + O(n²) — an effective hang. Skip them (count surfaced via progSkippedClusters), and compute the surviving clusters' feature prints concurrently. - The permission-gate screen carries the globe language menu too; a non-default-language user could otherwise not switch language before granting access. Extracted as a shared languageMenu, reused by the main toolbar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8693b93 commit 783eadf

4 files changed

Lines changed: 104 additions & 15 deletions

File tree

app/Sources/SnapsiftApp/ContentView.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ struct ContentView: View {
5151
}
5252
.frame(maxWidth: .infinity, maxHeight: .infinity)
5353
.background(Color.reefGround)
54+
// The permission screen has no main toolbar, so carry the language menu
55+
// here too — otherwise a non-default-language user is stuck on the gate.
56+
.toolbar { ToolbarItem { languageMenu } }
57+
}
58+
59+
/// Globe language switcher, shared by the gate and the main toolbar.
60+
private var languageMenu: some View {
61+
Menu {
62+
Picker("", selection: $langRaw) {
63+
ForEach(Language.allCases) { l in Text(l.endonym).tag(l.rawValue) }
64+
}.pickerStyle(.inline)
65+
} label: { Image(systemName: "globe") }
5466
}
5567

5668
// MARK: main split
@@ -537,13 +549,7 @@ struct ContentView: View {
537549
.help(t.helpTitle())
538550
.keyboardShortcut("?", modifiers: .command)
539551
}
540-
ToolbarItem {
541-
Menu {
542-
Picker("", selection: $langRaw) {
543-
ForEach(Language.allCases) { l in Text(l.endonym).tag(l.rawValue) }
544-
}.pickerStyle(.inline)
545-
} label: { Image(systemName: "globe") }
546-
}
552+
ToolbarItem { languageMenu }
547553
}
548554

549555
private func runDelete() async {

app/Sources/SnapsiftApp/L10n.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,14 @@ struct L10n: Sendable {
340340
case .zhTW: return "確認候選群組 \(i)/\(total)"
341341
}
342342
}
343+
/// Shown when oversized dHash-collision clusters were skipped (noise guard).
344+
func progSkippedClusters(_ n: Int) -> String {
345+
switch language {
346+
case .en: return "Skipped \(n) oversized noise clusters"
347+
case .ja: return "過大なノイズ群 \(n) 件をスキップ"
348+
case .zhTW: return "略過 \(n) 個過大的噪音群"
349+
}
350+
}
343351
func progFaces(_ i: Int, _ total: Int) -> String {
344352
switch language {
345353
case .en: return "Analysing faces \(i)/\(total)"

app/Sources/SnapsiftApp/LibraryModel.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ final class LibraryModel: ObservableObject {
5757
/// bursts: median spread 3, p75 6 — so ≤6 captures ~86% of genuine
5858
/// held-shutter bursts while pose/subject changes (≈12) fall to "you decide".
5959
var contentConfidentSpread = 6
60+
/// Before a frame is *ever* pre-marked for deletion, the cluster must also be
61+
/// neurally near-identical, not just dHash-close (dHash can't tell "same
62+
/// framing, subject moved" from a true duplicate). A pre-marked deletion needs
63+
/// max pairwise feature distance ≤ this; a moved-subject pair (≈0.2+) falls to
64+
/// "you decide". Tight on purpose — wrongly deleting a keeper breaks trust,
65+
/// while missing a duplicate is harmless.
66+
var contentConfidentFeature: Float = 0.10
6067
@Published var refiningFaces = false
6168
/// True once a face-refinement pass has re-picked keepers.
6269
@Published var facesApplied = false
@@ -181,13 +188,27 @@ final class LibraryModel: ObservableObject {
181188
clustered, asset: { lookup[$0] }, manager: imageManager,
182189
maxDistance: contentMaxDistance, t: t
183190
) { [weak self] msg in Task { @MainActor in self?.progress = msg } }
184-
groups = verified.map { vc in
185-
let confident = vc.spread <= contentConfidentSpread
191+
// Neural gate: a cluster only stays "confident" (→ pre-marks a deletion)
192+
// if Apple's feature print agrees it's near-identical. dHash is cheap
193+
// recall; this is the precise arbiter that keeps the scan from ever
194+
// suggesting you delete a frame that's actually a different moment.
195+
var built: [ReviewGroup] = []
196+
var n = 0
197+
for vc in verified {
198+
n += 1
199+
if n % 50 == 0 { progress = t.progConfirming(n, verified.count) }
200+
var confident = vc.spread <= contentConfidentSpread
201+
if confident {
202+
let fs = await LookAlikeScanner.featureSpread(
203+
vc.photos.map(\.uuid), asset: { lookup[$0] }, manager: imageManager)
204+
confident = (fs != nil && fs! <= contentConfidentFeature)
205+
}
186206
var g = ReviewGroup(photos: vc.photos, keeperID: keeper(vc.photos).uuid)
187207
g.confidentDupe = confident
188208
g.keepAll = !confident // uncertain groups: keep all, pre-mark nothing
189-
return g
209+
built.append(g)
190210
}
211+
groups = built
191212
}
192213

193214
/// Build a Core Photo from a PHAsset, enriched with Apple quality + size.

app/Sources/SnapsiftApp/LookAlikeScanner.swift

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ enum LookAlikeScanner {
2323
featureDistance: Float = 0.15, // ≈0.0 for a true re-saved
2424
// copy; 0.15 excludes merely
2525
// similar shots (cats ≈0.3)
26+
maxCluster: Int = 80, // a stage-1 dHash cluster
27+
// bigger than this is noise
28+
// (solid colours, screenshots
29+
// all colliding). Confirming
30+
// it means thousands of neural
31+
// prints + O(n²) — the freeze.
2632
progress: @escaping (String) -> Void) async -> [[String]] {
2733

2834
// Stage 1 — dHash all thumbnails (tiny 9×8 requests, fast).
@@ -43,20 +49,33 @@ enum LookAlikeScanner {
4349
for a in assets { byID[a.localIdentifier] = a }
4450

4551
var confirmed: [[String]] = []
52+
var skipped = 0
4653
var c = 0
4754
for cand in candidates {
4855
c += 1
4956
if c % 25 == 0 { progress(t.progConfirming(c, candidates.count)) }
50-
var prints: [(String, VNFeaturePrintObservation)] = []
51-
for id in cand {
52-
if let a = byID[id], let fp = await featurePrint(a, manager) {
53-
prints.append((id, fp))
57+
58+
// Oversized dHash clusters are collision noise; confirming them is the
59+
// O(n²) + thousands-of-fetches freeze. Skip rather than hang.
60+
if cand.count > maxCluster { skipped += 1; continue }
61+
62+
// Compute the members' neural prints concurrently — one cluster at a
63+
// time keeps total in-flight work bounded by the cluster size.
64+
let prints: [(String, VNFeaturePrintObservation)] =
65+
await withTaskGroup(of: (String, VNFeaturePrintObservation)?.self) { group in
66+
for id in cand {
67+
guard let a = byID[id] else { continue }
68+
group.addTask { (await featurePrint(a, manager)).map { (id, $0) } }
69+
}
70+
var acc: [(String, VNFeaturePrintObservation)] = []
71+
for await r in group { if let r { acc.append(r) } }
72+
return acc
5473
}
55-
}
5674
for group in unionByDistance(prints, maxDistance: featureDistance) where group.count >= 2 {
5775
confirmed.append(group)
5876
}
5977
}
78+
if skipped > 0 { progress(t.progSkippedClusters(skipped)) }
6079
return confirmed
6180
}
6281

@@ -110,6 +129,41 @@ enum LookAlikeScanner {
110129
return out
111130
}
112131

132+
/// Largest pairwise neural feature-print distance among a cluster's frames —
133+
/// the precise "how different are these really?" that dHash can't give. Used
134+
/// to confirm a dHash-confident burst is genuinely near-identical *before* the
135+
/// scan dares pre-mark a frame for deletion. Returns nil if any frame can't be
136+
/// read; the caller treats nil as "uncertain — don't pre-mark", same as a
137+
/// distance over threshold. "Same framing, subject moved" lands ≈0.2+; a true
138+
/// held-shutter duplicate ≈0.0.
139+
static func featureSpread(_ uuids: [String],
140+
asset: @escaping (String) -> PHAsset?,
141+
manager: PHCachingImageManager) async -> Float? {
142+
let prints: [VNFeaturePrintObservation?] =
143+
await withTaskGroup(of: VNFeaturePrintObservation?.self) { group in
144+
for id in uuids {
145+
group.addTask {
146+
guard let a = asset(id) else { return nil }
147+
return await featurePrint(a, manager)
148+
}
149+
}
150+
var acc: [VNFeaturePrintObservation?] = []
151+
for await r in group { acc.append(r) }
152+
return acc
153+
}
154+
let fps = prints.compactMap { $0 }
155+
guard fps.count == uuids.count else { return nil } // any unreadable → uncertain
156+
var maxD: Float = 0
157+
for i in 0..<fps.count {
158+
for j in (i + 1)..<fps.count {
159+
var d: Float = 0
160+
do { try fps[i].computeDistance(&d, to: fps[j]) } catch { continue }
161+
maxD = max(maxD, d)
162+
}
163+
}
164+
return maxD
165+
}
166+
113167
// MARK: - feature-print union
114168

115169
private static func unionByDistance(_ prints: [(String, VNFeaturePrintObservation)],

0 commit comments

Comments
 (0)