···21212222 // Populated asynchronously by QualityAnalyzer
2323 var blurScore: Double?
2424- var faceQualityScore: Double?
2424+ /// Laplacian variance measured on the face crop — actual face sharpness
2525+ var faceSharpness: Double?
2526 /// Normalized face bounding boxes (Vision coordinates: origin bottom-left, 0-1 range)
2627 var faceRegions: [CGRect] = []
2728
+32-10
cull/Services/QualityAnalyzer.swift
···8989 }
90909191 struct FaceResult {
9292- let quality: Double?
9292+ /// Sharpness of the best face region (Laplacian variance on face crop)
9393+ let sharpness: Double?
9394 let regions: [CGRect]
9495 }
95969697 static func analyzeFaces(imageURL: URL) async -> FaceResult {
9798 guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
9898- return FaceResult(quality: nil, regions: [])
9999+ return FaceResult(sharpness: nil, regions: [])
99100 }
100101 let options: [CFString: Any] = [
101102 kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
···104105 kCGImageSourceCreateThumbnailWithTransform: true
105106 ]
106107 guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
107107- return FaceResult(quality: nil, regions: [])
108108+ return FaceResult(sharpness: nil, regions: [])
108109 }
109110110111 let request = VNDetectFaceCaptureQualityRequest()
···112113 try? handler.perform([request])
113114114115 guard let results = request.results, !results.isEmpty else {
115115- return FaceResult(quality: nil, regions: [])
116116+ return FaceResult(sharpness: nil, regions: [])
116117 }
117118118119 // Filter out small background faces and low-confidence detections
119120 let meaningful = results.filter { face in
120121 let area = face.boundingBox.width * face.boundingBox.height
121121- // Must be at least 1.5% of image area and have decent confidence
122122 guard area >= 0.015, face.confidence >= 0.5 else { return false }
123123- // Skip very low quality faces (blurry background people)
124124- if let q = face.faceCaptureQuality, q < 0.15 { return false }
125123 return true
126124 }
127125128128- let quality = meaningful.map { Double($0.faceCaptureQuality ?? 0) }.max()
126126+ guard !meaningful.isEmpty else {
127127+ return FaceResult(sharpness: nil, regions: [])
128128+ }
129129+129130 // Sort faces by size (largest first) for better cycling order
130131 let regions = meaningful
131132 .map(\.boundingBox)
132133 .sorted { $0.width * $0.height > $1.width * $1.height }
133134134134- return FaceResult(quality: quality, regions: regions)
135135+ // Measure sharpness directly on the largest face crop
136136+ // This is what actually matters — is the face in focus?
137137+ let bestFaceRect = regions[0]
138138+ let imageW = CGFloat(cgImage.width)
139139+ let imageH = CGFloat(cgImage.height)
140140+ // Vision rect (bottom-left origin) → pixel rect (top-left origin), padded 20%
141141+ let padX = bestFaceRect.width * 0.2
142142+ let padY = bestFaceRect.height * 0.2
143143+ let pixelRect = CGRect(
144144+ x: (bestFaceRect.origin.x - padX) * imageW,
145145+ y: (1 - bestFaceRect.origin.y - bestFaceRect.height - padY) * imageH,
146146+ width: (bestFaceRect.width + padX * 2) * imageW,
147147+ height: (bestFaceRect.height + padY * 2) * imageH
148148+ ).intersection(CGRect(x: 0, y: 0, width: imageW, height: imageH))
149149+150150+ var faceSharpness: Double? = nil
151151+ if pixelRect.width > 10, pixelRect.height > 10,
152152+ let faceCrop = cgImage.cropping(to: pixelRect) {
153153+ faceSharpness = laplacianVariance(faceCrop)
154154+ }
155155+156156+ return FaceResult(sharpness: faceSharpness, regions: regions)
135157 }
136158137159 static func analyze(photo: Photo) async {
···142164 let (blurResult, faceResult) = await (blur, faces)
143165 await MainActor.run {
144166 photo.blurScore = blurResult
145145- photo.faceQualityScore = faceResult.quality
167167+ photo.faceSharpness = faceResult.sharpness
146168 photo.faceRegions = faceResult.regions
147169 }
148170 }
+67-64
cull/Views/ContentView.swift
···7171 }
7272 }
73737474- // Phase 2: Quality analysis — blur + faces (20-50%)
7575- await MainActor.run { s.importStatus = "Analyzing sharpness & faces..." }
7474+ // Phase 2: Analysis + Thumbnails + Previews in parallel (20-100%)
7675 let allPhotos = groups.flatMap(\.photos)
7676+ await MainActor.run { s.importStatus = "Analyzing & loading..." }
7777+7878+ // Track progress from three parallel streams
7779 let totalPhotos = Double(allPhotos.count)
7878- var analysisCompleted = 0.0
7979- for batchStart in stride(from: 0, to: allPhotos.count, by: 4) {
8080- let batch = Array(allPhotos[batchStart..<min(batchStart + 4, allPhotos.count)])
8181- await withTaskGroup(of: Void.self) { group in
8282- for photo in batch {
8383- group.addTask {
8484- let url = photo.pairedURL ?? photo.url
8585- let blur = await QualityAnalyzer.analyzeBlur(imageURL: url)
8686- let faces = await QualityAnalyzer.analyzeFaces(imageURL: url)
8787- await MainActor.run {
8888- photo.blurScore = blur
8989- photo.faceQualityScore = faces.quality
9090- photo.faceRegions = faces.regions
8080+ // Each stream contributes a fraction: analysis 40%, thumbnails 35%, previews 25%
8181+ nonisolated(unsafe) var analysisProgress = 0.0
8282+ nonisolated(unsafe) var thumbProgress = 0.0
8383+ nonisolated(unsafe) var previewProgress = 0.0
91849292- }
9393- }
9494- }
9595- }
9696- analysisCompleted += Double(batch.count)
9797- let mapped = 0.20 + (analysisCompleted / totalPhotos) * 0.30
8585+ @Sendable func reportProgress() async {
8686+ let combined = 0.20 + (analysisProgress * 0.40 + thumbProgress * 0.35 + previewProgress * 0.25) * 0.80
9887 await MainActor.run {
9988 withAnimation(.linear(duration: 0.2)) {
100100- s.importProgress = mapped
8989+ s.importProgress = combined
10190 }
10291 }
10392 }
10493105105- // Rank photos within each group — best first
106106- for group in groups {
107107- let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) }
108108- group.photos = scored.sorted { $0.score > $1.score }.map(\.photo)
109109- }
9494+ await withTaskGroup(of: Void.self) { parallelGroup in
9595+ // Stream 1: Quality analysis (blur + faces)
9696+ parallelGroup.addTask {
9797+ var completed = 0.0
9898+ for batchStart in stride(from: 0, to: allPhotos.count, by: 8) {
9999+ let batch = Array(allPhotos[batchStart..<min(batchStart + 8, allPhotos.count)])
100100+ await withTaskGroup(of: Void.self) { group in
101101+ for photo in batch {
102102+ group.addTask {
103103+ await QualityAnalyzer.analyze(photo: photo)
104104+ }
105105+ }
106106+ }
107107+ completed += Double(batch.count)
108108+ analysisProgress = completed / totalPhotos
109109+ await reportProgress()
110110+ }
111111+ }
110112111111- // Phase 3: Thumbnails (50-75%)
112112- await MainActor.run { s.importStatus = "Generating thumbnails..." }
113113- await c.preloadAllThumbnails(photos: allPhotos) { p in
114114- let mapped = 0.50 + p * 0.25
115115- await MainActor.run {
116116- withAnimation(.linear(duration: 0.2)) {
117117- s.importProgress = mapped
113113+ // Stream 2: Thumbnails
114114+ parallelGroup.addTask {
115115+ await c.preloadAllThumbnails(photos: allPhotos) { p in
116116+ thumbProgress = p
117117+ await reportProgress()
118118 }
119119 }
120120- }
121120122122- // Phase 4: Initial full-res previews (75-100%)
123123- await MainActor.run { s.importStatus = "Loading previews..." }
124124- let ahead = Array(allPhotos.prefix(30))
125125- let behind = Array(allPhotos.suffix(30))
126126- let initialPreviews = ahead + behind.reversed()
127127- await c.preloadAllPreviews(photos: initialPreviews) { p in
128128- let mapped = 0.75 + p * 0.25
129129- await MainActor.run {
130130- withAnimation(.linear(duration: 0.2)) {
131131- s.importProgress = mapped
121121+ // Stream 3: Initial full-res previews
122122+ parallelGroup.addTask {
123123+ let ahead = Array(allPhotos.prefix(30))
124124+ let behind = Array(allPhotos.suffix(30))
125125+ let initialPreviews = ahead + behind.reversed()
126126+ await c.preloadAllPreviews(photos: initialPreviews) { p in
127127+ previewProgress = p
128128+ await reportProgress()
132129 }
133130 }
131131+ }
132132+133133+ // Rank photos within each group — best first (after analysis completes)
134134+ for group in groups {
135135+ let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) }
136136+ group.photos = scored.sorted { $0.score > $1.score }.map(\.photo)
134137 }
135138136139 await MainActor.run {
···261264262265extension ContentView {
263266 /// Quality score for ranking within a group. Higher = better.
267267+ /// With faces: face sharpness (Laplacian on face crop) is the score.
268268+ /// Without faces: global blur score relative to group peers.
264269 static func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double {
265265- var score = 0.0
266270 let peers = group.photos
267271268268- if let blur = photo.blurScore {
269269- let peerBlurs = peers.compactMap(\.blurScore)
270270- if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB {
271271- score += ((blur - minB) / (maxB - minB)) * 0.5
272272- } else {
273273- score += 0.25
274274- }
275275- }
276276-277277- if let fq = photo.faceQualityScore {
278278- score += fq * 0.5
279279- } else if let blur = photo.blurScore {
280280- let peerBlurs = peers.compactMap(\.blurScore)
281281- if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB {
282282- score += ((blur - minB) / (maxB - minB)) * 0.5
283283- } else {
284284- score += 0.25
272272+ if let faceSharp = photo.faceSharpness, !photo.faceRegions.isEmpty {
273273+ // Face detected — use face-region sharpness (Laplacian on face crop).
274274+ // Normalize relative to peers who also have faces.
275275+ let peerFaceScores = peers.compactMap(\.faceSharpness)
276276+ if let maxF = peerFaceScores.max(), let minF = peerFaceScores.min(), maxF > minF {
277277+ return (faceSharp - minF) / (maxF - minF)
285278 }
279279+ return 0.5
280280+ } else {
281281+ // No faces — use global blur score
282282+ return normalizedBlur(photo, peers: peers)
286283 }
284284+ }
287285288288- return score
286286+ /// Normalize blur score relative to group peers (0-1 range)
287287+ private static func normalizedBlur(_ photo: Photo, peers: [Photo]) -> Double {
288288+ guard let blur = photo.blurScore else { return 0.5 }
289289+ let peerBlurs = peers.compactMap(\.blurScore)
290290+ guard let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB else { return 0.5 }
291291+ return (blur - minB) / (maxB - minB)
289292 }
290293291294}
+8-25
cull/Views/GroupDetailView.swift
···131131 }
132132133133 private func isBestInGroup() -> Bool {
134134- let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil }
134134+ let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil }
135135 guard scored.count >= 2 else { return false }
136136 let best = scored.max { qualityScore($0) < qualityScore($1) }
137137 return best?.id == photo.id
138138 }
139139140140 private func qualityScore(_ p: Photo) -> Double {
141141- var score = 0.0
142142- let peers = group.photos
143143- if let blur = p.blurScore {
144144- let peerBlurs = peers.compactMap(\.blurScore)
145145- if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB {
146146- score += ((blur - minB) / (maxB - minB)) * 0.5
147147- } else {
148148- score += 0.25
149149- }
150150- }
151151- if let fq = p.faceQualityScore {
152152- score += fq * 0.5
153153- } else if let blur = p.blurScore {
154154- let peerBlurs = peers.compactMap(\.blurScore)
155155- if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB {
156156- score += ((blur - minB) / (maxB - minB)) * 0.5
157157- } else {
158158- score += 0.25
159159- }
160160- }
161161- return score
141141+ ContentView.qualityScore(p, in: group)
162142 }
163143164144 private func isPhotoBlurry() -> Bool {
165145 if !photo.faceRegions.isEmpty {
166166- guard let fq = photo.faceQualityScore else { return false }
167167- return fq < 0.35
146146+ guard let fs = photo.faceSharpness else { return false }
147147+ let peerScores = group.photos.compactMap { $0.faceSharpness }
148148+ guard peerScores.count >= 2 else { return false }
149149+ let median = peerScores.sorted()[peerScores.count / 2]
150150+ return fs < median * 0.4
168151 }
169152 guard let blur = photo.blurScore else { return false }
170170- let peerScores = group.photos.compactMap(\.blurScore)
153153+ let peerScores = group.photos.compactMap { $0.blurScore }
171154 guard peerScores.count >= 2 else { return false }
172155 let median = peerScores.sorted()[peerScores.count / 2]
173156 return blur < median * 0.4
+14-42
cull/Views/PhotoViewer.swift
···99 private let lookaheadCount = 30
1010 private let lookbehindCount = 30
11111212- /// Face quality threshold — below this, faces are considered blurry
1313- private let faceBlurThreshold: Double = 0.35
14121513 var body: some View {
1614 ZStack {
···6765 .foregroundStyle(isPhotoBlurry(photo) ? .orange : .white.opacity(0.6))
6866 }
69677070- if let fq = photo.faceQualityScore {
6868+ if let fs = photo.faceSharpness {
7169 HStack(spacing: 3) {
7270 Image(systemName: "face.smiling")
7373- Text(String(format: "%.0f%%", fq * 100))
7171+ Text(String(format: "%.0f", fs))
7472 }
7573 .foregroundStyle(.white.opacity(0.7))
7674 }
···193191194192 /// Ranks photo within its group by quality. Returns 1-based rank, or nil if no scores yet.
195193 private func groupRank(photo: Photo, in group: PhotoGroup) -> Int? {
196196- let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil }
194194+ let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil }
197195 guard scored.count >= 2 else { return nil }
198196199197 let ranked = scored.sorted { qualityScore($0, in: group) > qualityScore($1, in: group) }
···201199 return idx + 1
202200 }
203201204204- /// Composite quality score for ranking within a group.
205205- /// Higher = better. Uses relative ranking within the group's score range.
206202 private func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double {
207207- var score = 0.0
208208- let peers = group.photos
209209-210210- if let blur = photo.blurScore {
211211- let peerBlurs = peers.compactMap(\.blurScore)
212212- if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur {
213213- score += ((blur - minBlur) / (maxBlur - minBlur)) * 0.5
214214- } else {
215215- score += 0.25
216216- }
217217- }
218218-219219- if let fq = photo.faceQualityScore {
220220- score += fq * 0.5
221221- } else if photo.blurScore != nil {
222222- // No faces — blur gets full weight
223223- let peerBlurs = peers.compactMap(\.blurScore)
224224- if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur {
225225- score += ((photo.blurScore! - minBlur) / (maxBlur - minBlur)) * 0.5
226226- } else {
227227- score += 0.25
228228- }
229229- }
230230-231231- return score
203203+ ContentView.qualityScore(photo, in: group)
232204 }
233205234206 // MARK: - Blur detection (relative within group)
235207236236- /// Uses relative ranking: a photo is blurry only if it's significantly softer
237237- /// than its group peers. For faces, uses face quality score directly.
208208+ /// Relative blur detection — blurry only if significantly softer than group peers.
209209+ /// For faces: compares face sharpness. Without faces: compares global blur.
238210 private func isPhotoBlurry(_ photo: Photo) -> Bool {
211211+ guard let group = session.selectedGroup else { return false }
212212+239213 if !photo.faceRegions.isEmpty {
240240- guard let fq = photo.faceQualityScore else { return false }
241241- return fq < faceBlurThreshold
214214+ guard let fs = photo.faceSharpness else { return false }
215215+ let peerScores = group.photos.compactMap(\.faceSharpness)
216216+ guard peerScores.count >= 2 else { return false }
217217+ let median = peerScores.sorted()[peerScores.count / 2]
218218+ return fs < median * 0.4
242219 }
243220244244- guard let blur = photo.blurScore,
245245- let group = session.selectedGroup else { return false }
246246-247247- // Gather blur scores from group peers that have been analyzed
221221+ guard let blur = photo.blurScore else { return false }
248222 let peerScores = group.photos.compactMap(\.blurScore)
249223 guard peerScores.count >= 2 else { return false }
250250-251224 let median = peerScores.sorted()[peerScores.count / 2]
252252- // Only flag if this photo is less than 40% of the group median
253225 return blur < median * 0.4
254226 }
255227