diff --git a/cull/Models/Photo.swift b/cull/Models/Photo.swift index 6cae338..5e1a8f5 100644 --- a/cull/Models/Photo.swift +++ b/cull/Models/Photo.swift @@ -21,7 +21,8 @@ final class Photo: Identifiable { // Populated asynchronously by QualityAnalyzer var blurScore: Double? - var faceQualityScore: Double? + /// Laplacian variance measured on the face crop — actual face sharpness + var faceSharpness: Double? /// Normalized face bounding boxes (Vision coordinates: origin bottom-left, 0-1 range) var faceRegions: [CGRect] = [] diff --git a/cull/Services/QualityAnalyzer.swift b/cull/Services/QualityAnalyzer.swift index 2686988..311128b 100644 --- a/cull/Services/QualityAnalyzer.swift +++ b/cull/Services/QualityAnalyzer.swift @@ -89,13 +89,14 @@ struct QualityAnalyzer { } struct FaceResult { - let quality: Double? + /// Sharpness of the best face region (Laplacian variance on face crop) + let sharpness: Double? let regions: [CGRect] } static func analyzeFaces(imageURL: URL) async -> FaceResult { guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { - return FaceResult(quality: nil, regions: []) + return FaceResult(sharpness: nil, regions: []) } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageIfAbsent: true, @@ -104,7 +105,7 @@ struct QualityAnalyzer { kCGImageSourceCreateThumbnailWithTransform: true ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { - return FaceResult(quality: nil, regions: []) + return FaceResult(sharpness: nil, regions: []) } let request = VNDetectFaceCaptureQualityRequest() @@ -112,26 +113,47 @@ struct QualityAnalyzer { try? handler.perform([request]) guard let results = request.results, !results.isEmpty else { - return FaceResult(quality: nil, regions: []) + return FaceResult(sharpness: nil, regions: []) } // Filter out small background faces and low-confidence detections let meaningful = results.filter { face in let area = face.boundingBox.width * face.boundingBox.height - // Must be at least 1.5% of image area and have decent confidence guard area >= 0.015, face.confidence >= 0.5 else { return false } - // Skip very low quality faces (blurry background people) - if let q = face.faceCaptureQuality, q < 0.15 { return false } return true } - let quality = meaningful.map { Double($0.faceCaptureQuality ?? 0) }.max() + guard !meaningful.isEmpty else { + return FaceResult(sharpness: nil, regions: []) + } + // Sort faces by size (largest first) for better cycling order let regions = meaningful .map(\.boundingBox) .sorted { $0.width * $0.height > $1.width * $1.height } - return FaceResult(quality: quality, regions: regions) + // Measure sharpness directly on the largest face crop + // This is what actually matters — is the face in focus? + let bestFaceRect = regions[0] + let imageW = CGFloat(cgImage.width) + let imageH = CGFloat(cgImage.height) + // Vision rect (bottom-left origin) → pixel rect (top-left origin), padded 20% + let padX = bestFaceRect.width * 0.2 + let padY = bestFaceRect.height * 0.2 + let pixelRect = CGRect( + x: (bestFaceRect.origin.x - padX) * imageW, + y: (1 - bestFaceRect.origin.y - bestFaceRect.height - padY) * imageH, + width: (bestFaceRect.width + padX * 2) * imageW, + height: (bestFaceRect.height + padY * 2) * imageH + ).intersection(CGRect(x: 0, y: 0, width: imageW, height: imageH)) + + var faceSharpness: Double? = nil + if pixelRect.width > 10, pixelRect.height > 10, + let faceCrop = cgImage.cropping(to: pixelRect) { + faceSharpness = laplacianVariance(faceCrop) + } + + return FaceResult(sharpness: faceSharpness, regions: regions) } static func analyze(photo: Photo) async { @@ -142,7 +164,7 @@ struct QualityAnalyzer { let (blurResult, faceResult) = await (blur, faces) await MainActor.run { photo.blurScore = blurResult - photo.faceQualityScore = faceResult.quality + photo.faceSharpness = faceResult.sharpness photo.faceRegions = faceResult.regions } } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index bdcdf6a..5973f46 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -71,68 +71,71 @@ struct ContentView: View { } } - // Phase 2: Quality analysis — blur + faces (20-50%) - await MainActor.run { s.importStatus = "Analyzing sharpness & faces..." } + // Phase 2: Analysis + Thumbnails + Previews in parallel (20-100%) let allPhotos = groups.flatMap(\.photos) + await MainActor.run { s.importStatus = "Analyzing & loading..." } + + // Track progress from three parallel streams let totalPhotos = Double(allPhotos.count) - var analysisCompleted = 0.0 - for batchStart in stride(from: 0, to: allPhotos.count, by: 4) { - let batch = Array(allPhotos[batchStart.. $1.score }.map(\.photo) - } + await withTaskGroup(of: Void.self) { parallelGroup in + // Stream 1: Quality analysis (blur + faces) + parallelGroup.addTask { + var completed = 0.0 + for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { + let batch = Array(allPhotos[batchStart.. $1.score }.map(\.photo) + } + await MainActor.run { s.importProgress = 1.0 s.groups = groups @@ -261,31 +264,31 @@ struct ContentView: View { extension ContentView { /// Quality score for ranking within a group. Higher = better. + /// With faces: face sharpness (Laplacian on face crop) is the score. + /// Without faces: global blur score relative to group peers. static func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double { - var score = 0.0 let peers = group.photos - if let blur = photo.blurScore { - let peerBlurs = peers.compactMap(\.blurScore) - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { - score += ((blur - minB) / (maxB - minB)) * 0.5 - } else { - score += 0.25 - } - } - - if let fq = photo.faceQualityScore { - score += fq * 0.5 - } else if let blur = photo.blurScore { - let peerBlurs = peers.compactMap(\.blurScore) - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { - score += ((blur - minB) / (maxB - minB)) * 0.5 - } else { - score += 0.25 + if let faceSharp = photo.faceSharpness, !photo.faceRegions.isEmpty { + // Face detected — use face-region sharpness (Laplacian on face crop). + // Normalize relative to peers who also have faces. + let peerFaceScores = peers.compactMap(\.faceSharpness) + if let maxF = peerFaceScores.max(), let minF = peerFaceScores.min(), maxF > minF { + return (faceSharp - minF) / (maxF - minF) } + return 0.5 + } else { + // No faces — use global blur score + return normalizedBlur(photo, peers: peers) } + } - return score + /// Normalize blur score relative to group peers (0-1 range) + private static func normalizedBlur(_ photo: Photo, peers: [Photo]) -> Double { + guard let blur = photo.blurScore else { return 0.5 } + let peerBlurs = peers.compactMap(\.blurScore) + guard let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB else { return 0.5 } + return (blur - minB) / (maxB - minB) } } diff --git a/cull/Views/GroupDetailView.swift b/cull/Views/GroupDetailView.swift index 93948ad..e16a744 100644 --- a/cull/Views/GroupDetailView.swift +++ b/cull/Views/GroupDetailView.swift @@ -131,43 +131,26 @@ private struct PhotoThumbnail: View { } private func isBestInGroup() -> Bool { - let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil } + let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil } guard scored.count >= 2 else { return false } let best = scored.max { qualityScore($0) < qualityScore($1) } return best?.id == photo.id } private func qualityScore(_ p: Photo) -> Double { - var score = 0.0 - let peers = group.photos - if let blur = p.blurScore { - let peerBlurs = peers.compactMap(\.blurScore) - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { - score += ((blur - minB) / (maxB - minB)) * 0.5 - } else { - score += 0.25 - } - } - if let fq = p.faceQualityScore { - score += fq * 0.5 - } else if let blur = p.blurScore { - let peerBlurs = peers.compactMap(\.blurScore) - if let maxB = peerBlurs.max(), let minB = peerBlurs.min(), maxB > minB { - score += ((blur - minB) / (maxB - minB)) * 0.5 - } else { - score += 0.25 - } - } - return score + ContentView.qualityScore(p, in: group) } private func isPhotoBlurry() -> Bool { if !photo.faceRegions.isEmpty { - guard let fq = photo.faceQualityScore else { return false } - return fq < 0.35 + guard let fs = photo.faceSharpness else { return false } + let peerScores = group.photos.compactMap { $0.faceSharpness } + guard peerScores.count >= 2 else { return false } + let median = peerScores.sorted()[peerScores.count / 2] + return fs < median * 0.4 } guard let blur = photo.blurScore else { return false } - let peerScores = group.photos.compactMap(\.blurScore) + let peerScores = group.photos.compactMap { $0.blurScore } guard peerScores.count >= 2 else { return false } let median = peerScores.sorted()[peerScores.count / 2] return blur < median * 0.4 diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index d827c47..020ba34 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -9,8 +9,6 @@ struct PhotoViewer: View { private let lookaheadCount = 30 private let lookbehindCount = 30 - /// Face quality threshold — below this, faces are considered blurry - private let faceBlurThreshold: Double = 0.35 var body: some View { ZStack { @@ -67,10 +65,10 @@ struct PhotoViewer: View { .foregroundStyle(isPhotoBlurry(photo) ? .orange : .white.opacity(0.6)) } - if let fq = photo.faceQualityScore { + if let fs = photo.faceSharpness { HStack(spacing: 3) { Image(systemName: "face.smiling") - Text(String(format: "%.0f%%", fq * 100)) + Text(String(format: "%.0f", fs)) } .foregroundStyle(.white.opacity(0.7)) } @@ -193,7 +191,7 @@ struct PhotoViewer: View { /// Ranks photo within its group by quality. Returns 1-based rank, or nil if no scores yet. private func groupRank(photo: Photo, in group: PhotoGroup) -> Int? { - let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != nil } + let scored = group.photos.filter { $0.blurScore != nil || $0.faceSharpness != nil } guard scored.count >= 2 else { return nil } let ranked = scored.sorted { qualityScore($0, in: group) > qualityScore($1, in: group) } @@ -201,55 +199,29 @@ struct PhotoViewer: View { return idx + 1 } - /// Composite quality score for ranking within a group. - /// Higher = better. Uses relative ranking within the group's score range. private func qualityScore(_ photo: Photo, in group: PhotoGroup) -> Double { - var score = 0.0 - let peers = group.photos - - if let blur = photo.blurScore { - let peerBlurs = peers.compactMap(\.blurScore) - if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur { - score += ((blur - minBlur) / (maxBlur - minBlur)) * 0.5 - } else { - score += 0.25 - } - } - - if let fq = photo.faceQualityScore { - score += fq * 0.5 - } else if photo.blurScore != nil { - // No faces — blur gets full weight - let peerBlurs = peers.compactMap(\.blurScore) - if let maxBlur = peerBlurs.max(), let minBlur = peerBlurs.min(), maxBlur > minBlur { - score += ((photo.blurScore! - minBlur) / (maxBlur - minBlur)) * 0.5 - } else { - score += 0.25 - } - } - - return score + ContentView.qualityScore(photo, in: group) } // MARK: - Blur detection (relative within group) - /// Uses relative ranking: a photo is blurry only if it's significantly softer - /// than its group peers. For faces, uses face quality score directly. + /// Relative blur detection — blurry only if significantly softer than group peers. + /// For faces: compares face sharpness. Without faces: compares global blur. private func isPhotoBlurry(_ photo: Photo) -> Bool { + guard let group = session.selectedGroup else { return false } + if !photo.faceRegions.isEmpty { - guard let fq = photo.faceQualityScore else { return false } - return fq < faceBlurThreshold + guard let fs = photo.faceSharpness else { return false } + let peerScores = group.photos.compactMap(\.faceSharpness) + guard peerScores.count >= 2 else { return false } + let median = peerScores.sorted()[peerScores.count / 2] + return fs < median * 0.4 } - guard let blur = photo.blurScore, - let group = session.selectedGroup else { return false } - - // Gather blur scores from group peers that have been analyzed + guard let blur = photo.blurScore else { return false } let peerScores = group.photos.compactMap(\.blurScore) guard peerScores.count >= 2 else { return false } - let median = peerScores.sorted()[peerScores.count / 2] - // Only flag if this photo is less than 40% of the group median return blur < median * 0.4 }