diff --git a/cull/CullApp.swift b/cull/CullApp.swift index 582f8ad..7e1324b 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -70,6 +70,15 @@ struct CullApp: App { } } + // View menu + CommandMenu("View") { + Button("Zoom to Face / Center") { + session.cycleZoom() + } + .keyboardShortcut(.space, modifiers: []) + .disabled(session.selectedPhoto == nil) + } + // Navigate menu CommandMenu("Navigate") { Button("Next Photo") { diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 8e6a2af..2310900 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -8,6 +8,9 @@ final class CullSession { var selectedGroupIndex: Int = 0 var selectedPhotoIndex: Int = 0 + /// Zoom state: nil = fit, -1 = center zoom, 0+ = face index + var zoomFaceIndex: Int? = nil + var isImporting: Bool = false var importProgress: Double = 0 var importStatus: String = "" @@ -67,6 +70,7 @@ final class CullSession { func moveToNextGroup() { guard !groups.isEmpty else { return } + resetZoom() saveCursorPosition() let start = selectedGroupIndex for offset in 1...groups.count { @@ -81,6 +85,7 @@ final class CullSession { func moveToPreviousGroup() { guard !groups.isEmpty else { return } + resetZoom() saveCursorPosition() let start = selectedGroupIndex for offset in 1...groups.count { @@ -95,6 +100,7 @@ final class CullSession { func moveToNextPhoto() { guard let group = selectedGroup else { return } + resetZoom() // Try to find next visible photo in current group for i in (selectedPhotoIndex + 1).. Double? { - guard let device = MTLCreateSystemDefaultDevice() else { return nil } - - let ciImage: CIImage? - if let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) { - let options: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageIfAbsent: true, - kCGImageSourceThumbnailMaxPixelSize: 512, - kCGImageSourceShouldCache: false, - kCGImageSourceCreateThumbnailWithTransform: true - ] - if let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) { - ciImage = CIImage(cgImage: cgImage) - } else { - ciImage = nil - } - } else { - ciImage = nil - } + guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageIfAbsent: true, + kCGImageSourceThumbnailMaxPixelSize: 512, + kCGImageSourceShouldCache: false, + kCGImageSourceCreateThumbnailWithTransform: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } - guard let ci = ciImage, - let cgImage = CIContext().createCGImage(ci, from: ci.extent) - else { return nil } + // Read ISO for noise compensation + let iso = readISO(from: source) - let width = cgImage.width - let height = cgImage.height + guard let variance = laplacianVariance(cgImage) else { return nil } - let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: .r32Float, width: width, height: height, mipmapped: false - ) - textureDescriptor.usage = [.shaderRead, .shaderWrite] + // Compensate for high-ISO noise inflating the score + if let iso, iso > 100 { + let isoStops = log2(Double(iso) / 100.0) + let noisePenalty = pow(1.3, isoStops) + return variance / noisePenalty + } + return variance + } - guard let sourceTexture = device.makeTexture(descriptor: textureDescriptor), - let laplacianTexture = device.makeTexture(descriptor: textureDescriptor) - else { return nil } + /// 8-connected Laplacian variance via vDSP (Apple's recommended approach). + private static func laplacianVariance(_ cgImage: CGImage) -> Double? { + let width = cgImage.width + let height = cgImage.height + guard width > 4, height > 4 else { return nil } - // Convert to grayscale float texture + // Render to 8-bit grayscale let colorSpace = CGColorSpaceCreateDeviceGray() guard let context = CGContext( data: nil, width: width, height: height, - bitsPerComponent: 32, bytesPerRow: width * 4, - space: colorSpace, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue | CGBitmapInfo.floatComponents.rawValue | CGBitmapInfo.byteOrder32Little.rawValue).rawValue + bitsPerComponent: 8, bytesPerRow: width, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.none.rawValue ) else { return nil } context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let pixelData = context.data else { return nil } + + // Convert UInt8 → Float + let pixelCount = width * height + let uint8Ptr = pixelData.assumingMemoryBound(to: UInt8.self) + var floatPixels = [Float](repeating: 0, count: pixelCount) + vDSP.convertElements(of: UnsafeBufferPointer(start: uint8Ptr, count: pixelCount), to: &floatPixels) + + // 8-connected Laplacian: [-1,-1,-1; -1,8,-1; -1,-1,-1] + // More sensitive than 4-connected, detects diagonal edges too + let kernel: [Float] = [ + -1, -1, -1, + -1, 8, -1, + -1, -1, -1 + ] - guard let data = context.data else { return nil } - sourceTexture.replace( - region: MTLRegionMake2D(0, 0, width, height), - mipmapLevel: 0, - withBytes: data, - bytesPerRow: width * 4 - ) - - // Laplacian + variance - guard let commandQueue = device.makeCommandQueue(), - let commandBuffer = commandQueue.makeCommandBuffer() - else { return nil } - - let laplacian = MPSImageLaplacian(device: device) - laplacian.encode(commandBuffer: commandBuffer, sourceTexture: sourceTexture, destinationTexture: laplacianTexture) - - let varianceDesc = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: .r32Float, width: 2, height: 1, mipmapped: false - ) - varianceDesc.usage = [.shaderRead, .shaderWrite] - guard let varianceTexture = device.makeTexture(descriptor: varianceDesc) else { return nil } + // vDSP convolution + var result = [Float](repeating: 0, count: pixelCount) + vDSP.convolve(floatPixels, + rowCount: height, + columnCount: width, + with3x3Kernel: kernel, + result: &result) - let stats = MPSImageStatisticsMeanAndVariance(device: device) - stats.encode(commandBuffer: commandBuffer, sourceTexture: laplacianTexture, destinationTexture: varianceTexture) + // Variance via vDSP_normalize (stddev² = variance) + var mean: Float = 0 + var stddev: Float = 0 + vDSP_normalize(result, 1, nil, 1, &mean, &stddev, vDSP_Length(pixelCount)) - commandBuffer.commit() - await commandBuffer.completed() + return Double(stddev * stddev) + } - var result = [Float](repeating: 0, count: 2) - varianceTexture.getBytes( - &result, - bytesPerRow: 8, - from: MTLRegionMake2D(0, 0, 2, 1), - mipmapLevel: 0 - ) + /// Read ISO speed from EXIF for noise compensation + private static func readISO(from source: CGImageSource) -> Int? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], + let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any], + let isoArray = exif[kCGImagePropertyExifISOSpeedRatings as String] as? [Int], + let iso = isoArray.first + else { return nil } + return iso + } - return Double(result[1]) // variance = sharpness score + struct FaceResult { + let quality: Double? + let regions: [CGRect] } - static func analyzeFaceQuality(imageURL: URL) async -> Double? { - guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil } + static func analyzeFaces(imageURL: URL) async -> FaceResult { + guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { + return FaceResult(quality: nil, regions: []) + } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageIfAbsent: true, kCGImageSourceThumbnailMaxPixelSize: 1024, kCGImageSourceShouldCache: false, kCGImageSourceCreateThumbnailWithTransform: true ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return FaceResult(quality: nil, regions: []) + } let request = VNDetectFaceCaptureQualityRequest() let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) try? handler.perform([request]) - guard let results = request.results, !results.isEmpty else { return nil } - return results.map { Double($0.faceCaptureQuality ?? 0) }.max() + guard let results = request.results, !results.isEmpty else { + return FaceResult(quality: 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() + // 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) } static func analyze(photo: Photo) async { - async let blur = analyzeBlur(imageURL: photo.pairedURL ?? photo.url) - async let face = analyzeFaceQuality(imageURL: photo.pairedURL ?? photo.url) + let url = photo.pairedURL ?? photo.url + async let blur = analyzeBlur(imageURL: url) + async let faces = analyzeFaces(imageURL: url) - let (blurResult, faceResult) = await (blur, face) + let (blurResult, faceResult) = await (blur, faces) await MainActor.run { photo.blurScore = blurResult - photo.faceQualityScore = faceResult + photo.faceQualityScore = faceResult.quality + photo.faceRegions = faceResult.regions } } } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 2a056ec..bdcdf6a 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -57,11 +57,11 @@ struct ContentView: View { await MainActor.run { s.importStatus = "Scanning photos..." } let result = try await PhotoImporter.importFolder(url) + // Phase 1: Feature print grouping (0-20%) await MainActor.run { s.importStatus = "Grouping similar shots..." } - // Phase 1: Feature print grouping (0-30%) var lastReported = 0.0 let groups = await ShotGrouper.group(photos: result.photos) { p in - let mapped = p * 0.30 + let mapped = p * 0.20 guard mapped - lastReported > 0.01 else { return } lastReported = mapped await MainActor.run { @@ -71,11 +71,47 @@ struct ContentView: View { } } - await MainActor.run { s.importStatus = "Generating thumbnails..." } - // Phase 2: Thumbnails (30-60%) + // Phase 2: Quality analysis — blur + faces (20-50%) + await MainActor.run { s.importStatus = "Analyzing sharpness & faces..." } let allPhotos = groups.flatMap(\.photos) + 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) + } + + // Phase 3: Thumbnails (50-75%) + await MainActor.run { s.importStatus = "Generating thumbnails..." } await c.preloadAllThumbnails(photos: allPhotos) { p in - let mapped = 0.30 + p * 0.30 + let mapped = 0.50 + p * 0.25 await MainActor.run { withAnimation(.linear(duration: 0.2)) { s.importProgress = mapped @@ -83,13 +119,13 @@ struct ContentView: View { } } + // Phase 4: Initial full-res previews (75-100%) await MainActor.run { s.importStatus = "Loading previews..." } - // Phase 3: Initial full-res previews (60-100%) let ahead = Array(allPhotos.prefix(30)) let behind = Array(allPhotos.suffix(30)) let initialPreviews = ahead + behind.reversed() await c.preloadAllPreviews(photos: initialPreviews) { p in - let mapped = 0.60 + p * 0.40 + let mapped = 0.75 + p * 0.25 await MainActor.run { withAnimation(.linear(duration: 0.2)) { s.importProgress = mapped @@ -104,31 +140,6 @@ struct ContentView: View { s.selectedPhotoIndex = 0 s.isImporting = false } - - let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) } - let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) }) - Task.detached(priority: .background) { - for batchStart in stride(from: 0, to: analysisWork.count, by: 4) { - let batch = Array(analysisWork[batchStart.. 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 + } + } + + return score + } + +} + struct ToolbarFilterButton: View { let activeIcon: String let inactiveIcon: String diff --git a/cull/Views/GroupDetailView.swift b/cull/Views/GroupDetailView.swift index 0289ffb..93948ad 100644 --- a/cull/Views/GroupDetailView.swift +++ b/cull/Views/GroupDetailView.swift @@ -13,6 +13,7 @@ struct GroupDetailView: View { if !session.isPhotoFiltered(photo) { PhotoThumbnail( photo: photo, + group: group, isSelected: index == session.selectedPhotoIndex ) .id(photo.id) @@ -38,13 +39,14 @@ struct GroupDetailView: View { private struct PhotoThumbnail: View { let photo: Photo + let group: PhotoGroup let isSelected: Bool @Environment(ThumbnailCache.self) private var cache @State private var thumbnail: NSImage? var body: some View { VStack(spacing: 2) { - ZStack(alignment: .topLeading) { + ZStack { if let thumbnail { Image(nsImage: thumbnail) .resizable() @@ -57,13 +59,47 @@ private struct PhotoThumbnail: View { .frame(width: 148, height: 100) } - // Flag badge - if photo.flag != .none { - Image(systemName: photo.flag == .pick ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(photo.flag == .pick ? .green : .red) - .font(.caption) - .padding(4) + // Badges + VStack { + HStack { + // Flag badge + if photo.flag != .none { + Image(systemName: photo.flag == .pick ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(photo.flag == .pick ? .green : .red) + .font(.caption) + } + Spacer() + // Blur badge — hybrid: trust face quality for bokeh shots + if isPhotoBlurry() { + Image(systemName: "eye.slash.fill") + .foregroundStyle(.orange) + .font(.caption) + } + } + Spacer() + HStack { + // Best-in-group badge + if isBestInGroup() { + Image(systemName: "star.circle.fill") + .foregroundStyle(.green) + .font(.caption) + } + Spacer() + // Face count badge + if !photo.faceRegions.isEmpty { + HStack(spacing: 2) { + Image(systemName: "face.smiling") + Text("\(photo.faceRegions.count)") + } + .font(.caption2) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.black.opacity(0.5), in: Capsule()) + } + } } + .padding(4) } // Rating stars @@ -93,4 +129,47 @@ private struct PhotoThumbnail: View { thumbnail = await cache.thumbnail(for: photo) } } + + private func isBestInGroup() -> Bool { + let scored = group.photos.filter { $0.blurScore != nil || $0.faceQualityScore != 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 + } + + private func isPhotoBlurry() -> Bool { + if !photo.faceRegions.isEmpty { + guard let fq = photo.faceQualityScore else { return false } + return fq < 0.35 + } + 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] + return blur < median * 0.4 + } } diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index 4235cf2..d827c47 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -9,17 +9,40 @@ 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 { Color.black if let displayImage { - Image(nsImage: displayImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity, maxHeight: .infinity) + GeometryReader { geo in + let imageSize = displayImage.size + let fitted = fittedSize(image: imageSize, in: geo.size) + let zoomInfo = currentZoomInfo(fittedSize: fitted, containerSize: geo.size) + + ZStack { + Image(nsImage: displayImage) + .resizable() + .aspectRatio(contentMode: .fit) + .overlay { + // Face region overlays (only when not zoomed) + if session.zoomFaceIndex == nil, let photo = session.selectedPhoto, !photo.faceRegions.isEmpty { + faceOverlays(photo: photo, fittedSize: fitted) + } + } + } + .frame(width: fitted.width, height: fitted.height) + .scaleEffect(zoomInfo.scale) + .offset(zoomInfo.offset) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .animation(.easeInOut(duration: 0.3), value: session.zoomFaceIndex) + } } + // Bottom bar overlay if let photo = session.selectedPhoto { VStack { Spacer() @@ -34,6 +57,53 @@ struct PhotoViewer: View { Spacer() + // Quality scores + HStack(spacing: 10) { + if let blur = photo.blurScore { + HStack(spacing: 3) { + Image(systemName: "scope") + Text(String(format: "%.0f", blur)) + } + .foregroundStyle(isPhotoBlurry(photo) ? .orange : .white.opacity(0.6)) + } + + if let fq = photo.faceQualityScore { + HStack(spacing: 3) { + Image(systemName: "face.smiling") + Text(String(format: "%.0f%%", fq * 100)) + } + .foregroundStyle(.white.opacity(0.7)) + } + + if !photo.faceRegions.isEmpty { + HStack(spacing: 3) { + Image(systemName: "person.crop.rectangle") + Text("\(photo.faceRegions.count)") + } + .foregroundStyle(.white.opacity(0.6)) + } + + // Group rank + if let group = session.selectedGroup { + let rank = groupRank(photo: photo, in: group) + if let rank { + HStack(spacing: 3) { + Image(systemName: "number") + Text("\(rank)/\(group.photos.count)") + } + .foregroundStyle(rank == 1 ? .green : .white.opacity(0.6)) + } + } + } + .font(.caption) + + // Blur badge + if isPhotoBlurry(photo) { + Label("Blurry", systemImage: "eye.slash.fill") + .foregroundStyle(.orange) + .font(.caption) + } + HStack(spacing: 2) { ForEach(1...5, id: \.self) { star in Image(systemName: star <= photo.rating ? "star.fill" : "star") @@ -118,4 +188,142 @@ struct PhotoViewer: View { } } } + + // MARK: - Group ranking + + /// 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 } + guard scored.count >= 2 else { return nil } + + let ranked = scored.sorted { qualityScore($0, in: group) > qualityScore($1, in: group) } + guard let idx = ranked.firstIndex(where: { $0.id == photo.id }) else { return nil } + 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 + } + + // 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. + private func isPhotoBlurry(_ photo: Photo) -> Bool { + if !photo.faceRegions.isEmpty { + guard let fq = photo.faceQualityScore else { return false } + return fq < faceBlurThreshold + } + + guard let blur = photo.blurScore, + let group = session.selectedGroup else { return false } + + // Gather blur scores from group peers that have been analyzed + 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 + } + + // MARK: - Zoom calculations + + private struct ZoomInfo { + let scale: CGFloat + let offset: CGSize + } + + private func currentZoomInfo(fittedSize: CGSize, containerSize: CGSize) -> ZoomInfo { + guard let zoomIndex = session.zoomFaceIndex, + let photo = session.selectedPhoto else { + return ZoomInfo(scale: 1, offset: .zero) + } + + if zoomIndex == -1 { + // Center zoom — 2.5x + return ZoomInfo(scale: 2.5, offset: .zero) + } + + guard photo.faceRegions.indices.contains(zoomIndex) else { + return ZoomInfo(scale: 1, offset: .zero) + } + + let faceRect = photo.faceRegions[zoomIndex] + // Vision coordinates: origin bottom-left, normalized 0-1 + // Calculate scale so the face takes up ~35% of the view width + let faceW = faceRect.width + let faceH = faceRect.height + let scale = min(0.35 / max(faceW, faceH), 5.0) + + // Face center in normalized image coords (flip Y) + let faceCenterX = faceRect.midX + let faceCenterY = 1 - faceRect.midY + + // Face center in fitted image pixel coords + let facePixelX = faceCenterX * fittedSize.width + let facePixelY = faceCenterY * fittedSize.height + + // Image center in fitted coords + let imageCenterX = fittedSize.width / 2 + let imageCenterY = fittedSize.height / 2 + + // Offset to move face center to view center, then multiply by scale + let offsetX = (imageCenterX - facePixelX) * scale + let offsetY = (imageCenterY - facePixelY) * scale + + return ZoomInfo(scale: scale, offset: CGSize(width: offsetX, height: offsetY)) + } + + private func fittedSize(image: CGSize, in container: CGSize) -> CGSize { + let scaleW = container.width / image.width + let scaleH = container.height / image.height + let s = min(scaleW, scaleH) + return CGSize(width: image.width * s, height: image.height * s) + } + + // MARK: - Face overlays + + @ViewBuilder + private func faceOverlays(photo: Photo, fittedSize: CGSize) -> some View { + ForEach(0.. + + + + + + + + + + + + + + + diff --git a/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg b/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg new file mode 100644 index 0000000..29fdb92 --- /dev/null +++ b/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cull/icon.icon/Assets/emoji_u1f52a 1.svg b/cull/icon.icon/Assets/emoji_u1f52a 1.svg new file mode 100644 index 0000000..99d1047 --- /dev/null +++ b/cull/icon.icon/Assets/emoji_u1f52a 1.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/cull/icon.icon/icon.json b/cull/icon.icon/icon.json new file mode 100644 index 0000000..ec78206 --- /dev/null +++ b/cull/icon.icon/icon.json @@ -0,0 +1,74 @@ +{ + "fill-specializations" : [ + { + "value" : { + "solid" : "display-p3:0.12880,0.13019,0.13434,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "display-p3:0.12880,0.13019,0.13434,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "none", + "glass" : true, + "hidden" : false, + "image-name" : "emoji_u1f52a 1.svg", + "name" : "emoji_u1f52a 1", + "opacity" : 1, + "position" : { + "scale" : 1, + "translation-in-points" : [ + -97.0625, + -105.04424700203003 + ] + } + }, + { + "image-name" : "emoji_u1f39e 1 (1).svg", + "name" : "emoji_u1f39e 1 (1)", + "position" : { + "scale" : 1.23, + "translation-in-points" : [ + -0.7779750000000831, + 23.765625 + ] + } + }, + { + "glass" : true, + "image-name" : "Subtract.svg", + "name" : "Subtract", + "position" : { + "scale" : 1.07, + "translation-in-points" : [ + 0.4913672408982279, + 106.2578125 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file