its whats on the tin; culls raw photos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use face crop to calc sharpness

+123 -142
+2 -1
cull/Models/Photo.swift
··· 21 21 22 22 // Populated asynchronously by QualityAnalyzer 23 23 var blurScore: Double? 24 - var faceQualityScore: Double? 24 + /// Laplacian variance measured on the face crop — actual face sharpness 25 + var faceSharpness: Double? 25 26 /// Normalized face bounding boxes (Vision coordinates: origin bottom-left, 0-1 range) 26 27 var faceRegions: [CGRect] = [] 27 28
+32 -10
cull/Services/QualityAnalyzer.swift
··· 89 89 } 90 90 91 91 struct FaceResult { 92 - let quality: Double? 92 + /// Sharpness of the best face region (Laplacian variance on face crop) 93 + let sharpness: Double? 93 94 let regions: [CGRect] 94 95 } 95 96 96 97 static func analyzeFaces(imageURL: URL) async -> FaceResult { 97 98 guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { 98 - return FaceResult(quality: nil, regions: []) 99 + return FaceResult(sharpness: nil, regions: []) 99 100 } 100 101 let options: [CFString: Any] = [ 101 102 kCGImageSourceCreateThumbnailFromImageIfAbsent: true, ··· 104 105 kCGImageSourceCreateThumbnailWithTransform: true 105 106 ] 106 107 guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { 107 - return FaceResult(quality: nil, regions: []) 108 + return FaceResult(sharpness: nil, regions: []) 108 109 } 109 110 110 111 let request = VNDetectFaceCaptureQualityRequest() ··· 112 113 try? handler.perform([request]) 113 114 114 115 guard let results = request.results, !results.isEmpty else { 115 - return FaceResult(quality: nil, regions: []) 116 + return FaceResult(sharpness: nil, regions: []) 116 117 } 117 118 118 119 // Filter out small background faces and low-confidence detections 119 120 let meaningful = results.filter { face in 120 121 let area = face.boundingBox.width * face.boundingBox.height 121 - // Must be at least 1.5% of image area and have decent confidence 122 122 guard area >= 0.015, face.confidence >= 0.5 else { return false } 123 - // Skip very low quality faces (blurry background people) 124 - if let q = face.faceCaptureQuality, q < 0.15 { return false } 125 123 return true 126 124 } 127 125 128 - let quality = meaningful.map { Double($0.faceCaptureQuality ?? 0) }.max() 126 + guard !meaningful.isEmpty else { 127 + return FaceResult(sharpness: nil, regions: []) 128 + } 129 + 129 130 // Sort faces by size (largest first) for better cycling order 130 131 let regions = meaningful 131 132 .map(\.boundingBox) 132 133 .sorted { $0.width * $0.height > $1.width * $1.height } 133 134 134 - return FaceResult(quality: quality, regions: regions) 135 + // Measure sharpness directly on the largest face crop 136 + // This is what actually matters — is the face in focus? 137 + let bestFaceRect = regions[0] 138 + let imageW = CGFloat(cgImage.width) 139 + let imageH = CGFloat(cgImage.height) 140 + // Vision rect (bottom-left origin) → pixel rect (top-left origin), padded 20% 141 + let padX = bestFaceRect.width * 0.2 142 + let padY = bestFaceRect.height * 0.2 143 + let pixelRect = CGRect( 144 + x: (bestFaceRect.origin.x - padX) * imageW, 145 + y: (1 - bestFaceRect.origin.y - bestFaceRect.height - padY) * imageH, 146 + width: (bestFaceRect.width + padX * 2) * imageW, 147 + height: (bestFaceRect.height + padY * 2) * imageH 148 + ).intersection(CGRect(x: 0, y: 0, width: imageW, height: imageH)) 149 + 150 + var faceSharpness: Double? = nil 151 + if pixelRect.width > 10, pixelRect.height > 10, 152 + let faceCrop = cgImage.cropping(to: pixelRect) { 153 + faceSharpness = laplacianVariance(faceCrop) 154 + } 155 + 156 + return FaceResult(sharpness: faceSharpness, regions: regions) 135 157 } 136 158 137 159 static func analyze(photo: Photo) async { ··· 142 164 let (blurResult, faceResult) = await (blur, faces) 143 165 await MainActor.run { 144 166 photo.blurScore = blurResult 145 - photo.faceQualityScore = faceResult.quality 167 + photo.faceSharpness = faceResult.sharpness 146 168 photo.faceRegions = faceResult.regions 147 169 } 148 170 }
+67 -64
cull/Views/ContentView.swift
··· 71 71 } 72 72 } 73 73 74 - // Phase 2: Quality analysis — blur + faces (20-50%) 75 - await MainActor.run { s.importStatus = "Analyzing sharpness & faces..." } 74 + // Phase 2: Analysis + Thumbnails + Previews in parallel (20-100%) 76 75 let allPhotos = groups.flatMap(\.photos) 76 + await MainActor.run { s.importStatus = "Analyzing & loading..." } 77 + 78 + // Track progress from three parallel streams 77 79 let totalPhotos = Double(allPhotos.count) 78 - var analysisCompleted = 0.0 79 - for batchStart in stride(from: 0, to: allPhotos.count, by: 4) { 80 - let batch = Array(allPhotos[batchStart..<min(batchStart + 4, allPhotos.count)]) 81 - await withTaskGroup(of: Void.self) { group in 82 - for photo in batch { 83 - group.addTask { 84 - let url = photo.pairedURL ?? photo.url 85 - let blur = await QualityAnalyzer.analyzeBlur(imageURL: url) 86 - let faces = await QualityAnalyzer.analyzeFaces(imageURL: url) 87 - await MainActor.run { 88 - photo.blurScore = blur 89 - photo.faceQualityScore = faces.quality 90 - photo.faceRegions = faces.regions 80 + // Each stream contributes a fraction: analysis 40%, thumbnails 35%, previews 25% 81 + nonisolated(unsafe) var analysisProgress = 0.0 82 + nonisolated(unsafe) var thumbProgress = 0.0 83 + nonisolated(unsafe) var previewProgress = 0.0 91 84 92 - } 93 - } 94 - } 95 - } 96 - analysisCompleted += Double(batch.count) 97 - let mapped = 0.20 + (analysisCompleted / totalPhotos) * 0.30 85 + @Sendable func reportProgress() async { 86 + let combined = 0.20 + (analysisProgress * 0.40 + thumbProgress * 0.35 + previewProgress * 0.25) * 0.80 98 87 await MainActor.run { 99 88 withAnimation(.linear(duration: 0.2)) { 100 - s.importProgress = mapped 89 + s.importProgress = combined 101 90 } 102 91 } 103 92 } 104 93 105 - // Rank photos within each group — best first 106 - for group in groups { 107 - let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) } 108 - group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) 109 - } 94 + await withTaskGroup(of: Void.self) { parallelGroup in 95 + // Stream 1: Quality analysis (blur + faces) 96 + parallelGroup.addTask { 97 + var completed = 0.0 98 + for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { 99 + let batch = Array(allPhotos[batchStart..<min(batchStart + 8, allPhotos.count)]) 100 + await withTaskGroup(of: Void.self) { group in 101 + for photo in batch { 102 + group.addTask { 103 + await QualityAnalyzer.analyze(photo: photo) 104 + } 105 + } 106 + } 107 + completed += Double(batch.count) 108 + analysisProgress = completed / totalPhotos 109 + await reportProgress() 110 + } 111 + } 110 112 111 - // Phase 3: Thumbnails (50-75%) 112 - await MainActor.run { s.importStatus = "Generating thumbnails..." } 113 - await c.preloadAllThumbnails(photos: allPhotos) { p in 114 - let mapped = 0.50 + p * 0.25 115 - await MainActor.run { 116 - withAnimation(.linear(duration: 0.2)) { 117 - s.importProgress = mapped 113 + // Stream 2: Thumbnails 114 + parallelGroup.addTask { 115 + await c.preloadAllThumbnails(photos: allPhotos) { p in 116 + thumbProgress = p 117 + await reportProgress() 118 118 } 119 119 } 120 - } 121 120 122 - // Phase 4: Initial full-res previews (75-100%) 123 - await MainActor.run { s.importStatus = "Loading previews..." } 124 - let ahead = Array(allPhotos.prefix(30)) 125 - let behind = Array(allPhotos.suffix(30)) 126 - let initialPreviews = ahead + behind.reversed() 127 - await c.preloadAllPreviews(photos: initialPreviews) { p in 128 - let mapped = 0.75 + p * 0.25 129 - await MainActor.run { 130 - withAnimation(.linear(duration: 0.2)) { 131 - s.importProgress = mapped 121 + // Stream 3: Initial full-res previews 122 + parallelGroup.addTask { 123 + let ahead = Array(allPhotos.prefix(30)) 124 + let behind = Array(allPhotos.suffix(30)) 125 + let initialPreviews = ahead + behind.reversed() 126 + await c.preloadAllPreviews(photos: initialPreviews) { p in 127 + previewProgress = p 128 + await reportProgress() 132 129 } 133 130 } 131 + } 132 + 133 + // Rank photos within each group — best first (after analysis completes) 134 + for group in groups { 135 + let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) } 136 + group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) 134 137 } 135 138 136 139 await MainActor.run { ··· 261 264 262 265 extension ContentView { 263 266 /// Quality score for ranking within a group. Higher = better. 267 + /// With faces: face sharpness (Laplacian on face crop) is the score. 268 + /// Without faces: global blur score relative to group peers. 264 269 static func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double { 265 - var score = 0.0 266 270 let peers = group.photos 267 271 268 - if let blur = photo.blurScore { 269 - let peerBlurs = peers.compactMap(\.blurScore) 270 - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { 271 - score += ((blur - minB) / (maxB - minB)) * 0.5 272 - } else { 273 - score += 0.25 274 - } 275 - } 276 - 277 - if let fq = photo.faceQualityScore { 278 - score += fq * 0.5 279 - } else if let blur = photo.blurScore { 280 - let peerBlurs = peers.compactMap(\.blurScore) 281 - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { 282 - score += ((blur - minB) / (maxB - minB)) * 0.5 283 - } else { 284 - score += 0.25 272 + if let faceSharp = photo.faceSharpness, !photo.faceRegions.isEmpty { 273 + // Face detected — use face-region sharpness (Laplacian on face crop). 274 + // Normalize relative to peers who also have faces. 275 + let peerFaceScores = peers.compactMap(\.faceSharpness) 276 + if let maxF = peerFaceScores.max(), let minF = peerFaceScores.min(), maxF > minF { 277 + return (faceSharp - minF) / (maxF - minF) 285 278 } 279 + return 0.5 280 + } else { 281 + // No faces — use global blur score 282 + return normalizedBlur(photo, peers: peers) 286 283 } 284 + } 287 285 288 - return score 286 + /// Normalize blur score relative to group peers (0-1 range) 287 + private static func normalizedBlur(_ photo: Photo, peers: [Photo]) -> Double { 288 + guard let blur = photo.blurScore else { return 0.5 } 289 + let peerBlurs = peers.compactMap(\.blurScore) 290 + guard let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB else { return 0.5 } 291 + return (blur - minB) / (maxB - minB) 289 292 } 290 293 291 294 }
+8 -25
cull/Views/GroupDetailView.swift
··· 131 131 } 132 132 133 133 private func isBestInGroup() -> Bool { 134 - let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil } 134 + let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil } 135 135 guard scored.count >= 2 else { return false } 136 136 let best = scored.max { qualityScore($0) < qualityScore($1) } 137 137 return best?.id == photo.id 138 138 } 139 139 140 140 private func qualityScore(_ p: Photo) -> Double { 141 - var score = 0.0 142 - let peers = group.photos 143 - if let blur = p.blurScore { 144 - let peerBlurs = peers.compactMap(\.blurScore) 145 - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { 146 - score += ((blur - minB) / (maxB - minB)) * 0.5 147 - } else { 148 - score += 0.25 149 - } 150 - } 151 - if let fq = p.faceQualityScore { 152 - score += fq * 0.5 153 - } else if let blur = p.blurScore { 154 - let peerBlurs = peers.compactMap(\.blurScore) 155 - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { 156 - score += ((blur - minB) / (maxB - minB)) * 0.5 157 - } else { 158 - score += 0.25 159 - } 160 - } 161 - return score 141 + ContentView.qualityScore(p, in: group) 162 142 } 163 143 164 144 private func isPhotoBlurry() -> Bool { 165 145 if !photo.faceRegions.isEmpty { 166 - guard let fq = photo.faceQualityScore else { return false } 167 - return fq < 0.35 146 + guard let fs = photo.faceSharpness else { return false } 147 + let peerScores = group.photos.compactMap { $0.faceSharpness } 148 + guard peerScores.count >= 2 else { return false } 149 + let median = peerScores.sorted()[peerScores.count / 2] 150 + return fs < median * 0.4 168 151 } 169 152 guard let blur = photo.blurScore else { return false } 170 - let peerScores = group.photos.compactMap(\.blurScore) 153 + let peerScores = group.photos.compactMap { $0.blurScore } 171 154 guard peerScores.count >= 2 else { return false } 172 155 let median = peerScores.sorted()[peerScores.count / 2] 173 156 return blur < median * 0.4
+14 -42
cull/Views/PhotoViewer.swift
··· 9 9 private let lookaheadCount = 30 10 10 private let lookbehindCount = 30 11 11 12 - /// Face quality threshold — below this, faces are considered blurry 13 - private let faceBlurThreshold: Double = 0.35 14 12 15 13 var body: some View { 16 14 ZStack { ··· 67 65 .foregroundStyle(isPhotoBlurry(photo) ? .orange : .white.opacity(0.6)) 68 66 } 69 67 70 - if let fq = photo.faceQualityScore { 68 + if let fs = photo.faceSharpness { 71 69 HStack(spacing: 3) { 72 70 Image(systemName: "face.smiling") 73 - Text(String(format: "%.0f%%", fq * 100)) 71 + Text(String(format: "%.0f", fs)) 74 72 } 75 73 .foregroundStyle(.white.opacity(0.7)) 76 74 } ··· 193 191 194 192 /// Ranks photo within its group by quality. Returns 1-based rank, or nil if no scores yet. 195 193 private func groupRank(photo: Photo, in group: PhotoGroup) -> Int? { 196 - let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil } 194 + let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil } 197 195 guard scored.count >= 2 else { return nil } 198 196 199 197 let ranked = scored.sorted { qualityScore($0, in: group) > qualityScore($1, in: group) } ··· 201 199 return idx + 1 202 200 } 203 201 204 - /// Composite quality score for ranking within a group. 205 - /// Higher = better. Uses relative ranking within the group's score range. 206 202 private func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double { 207 - var score = 0.0 208 - let peers = group.photos 209 - 210 - if let blur = photo.blurScore { 211 - let peerBlurs = peers.compactMap(\.blurScore) 212 - if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur { 213 - score += ((blur - minBlur) / (maxBlur - minBlur)) * 0.5 214 - } else { 215 - score += 0.25 216 - } 217 - } 218 - 219 - if let fq = photo.faceQualityScore { 220 - score += fq * 0.5 221 - } else if photo.blurScore != nil { 222 - // No faces — blur gets full weight 223 - let peerBlurs = peers.compactMap(\.blurScore) 224 - if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur { 225 - score += ((photo.blurScore! - minBlur) / (maxBlur - minBlur)) * 0.5 226 - } else { 227 - score += 0.25 228 - } 229 - } 230 - 231 - return score 203 + ContentView.qualityScore(photo, in: group) 232 204 } 233 205 234 206 // MARK: - Blur detection (relative within group) 235 207 236 - /// Uses relative ranking: a photo is blurry only if it's significantly softer 237 - /// than its group peers. For faces, uses face quality score directly. 208 + /// Relative blur detection — blurry only if significantly softer than group peers. 209 + /// For faces: compares face sharpness. Without faces: compares global blur. 238 210 private func isPhotoBlurry(_ photo: Photo) -> Bool { 211 + guard let group = session.selectedGroup else { return false } 212 + 239 213 if !photo.faceRegions.isEmpty { 240 - guard let fq = photo.faceQualityScore else { return false } 241 - return fq < faceBlurThreshold 214 + guard let fs = photo.faceSharpness else { return false } 215 + let peerScores = group.photos.compactMap(\.faceSharpness) 216 + guard peerScores.count >= 2 else { return false } 217 + let median = peerScores.sorted()[peerScores.count / 2] 218 + return fs < median * 0.4 242 219 } 243 220 244 - guard let blur = photo.blurScore, 245 - let group = session.selectedGroup else { return false } 246 - 247 - // Gather blur scores from group peers that have been analyzed 221 + guard let blur = photo.blurScore else { return false } 248 222 let peerScores = group.photos.compactMap(\.blurScore) 249 223 guard peerScores.count >= 2 else { return false } 250 - 251 224 let median = peerScores.sorted()[peerScores.count / 2] 252 - // Only flag if this photo is less than 40% of the group median 253 225 return blur < median * 0.4 254 226 } 255 227