From 20707da2683bf36f86b5cb84fd724b33f2779c85 Mon Sep 17 00:00:00 2001 From: jared Date: Fri, 23 Jan 2026 23:26:37 -0500 Subject: [PATCH] Implement core project structure and foundation components Project Setup (Spec 01): - Configure iOS 17.0 deployment target - Add photo library usage descriptions - Create proper folder structure (App, Features, Services, Models, Utilities) Data Model (Spec 02): - Add EditOperation enum with mask, inpaint, adjustment cases - Add MaskOperation, InpaintOperation structs (Codable) - Add Project model with operation stack and undo/redo support - Add MaskData with dilation support via vImage Services (Specs 03-05): - Add InpaintEngine with Metal + Accelerate fallback - Add MaskingService wrapping Vision framework - Add ContourService for wire/line detection and scoring UI Components (Specs 06-08): - Add PhotoEditorView with photo picker integration - Add CanvasView with pinch-to-zoom and pan gestures - Add ToolbarView with tool selection and inspector panel Utilities: - Add ImagePipeline for preview/export rendering - Add EdgeRefinement for smart brush edge detection - Add check_build.sh for CI verification Co-Authored-By: Claude Opus 4.5 --- CheapRetouch.xcodeproj/project.pbxproj | 353 ++++++++++++++++++ CheapRetouch/App/CheapRetouchApp.swift | 17 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ CheapRetouch/Assets.xcassets/Contents.json | 6 + CheapRetouch/Features/Editor/CanvasView.swift | 129 +++++++ .../Features/Editor/PhotoEditorView.swift | 88 +++++ .../Features/Editor/ToolbarView.swift | 176 +++++++++ CheapRetouch/Models/EditOperation.swift | 71 ++++ CheapRetouch/Models/MaskData.swift | 112 ++++++ CheapRetouch/Models/Project.swift | 77 ++++ CheapRetouch/Services/ContourService.swift | 235 ++++++++++++ .../InpaintEngine/InpaintEngine.swift | 275 ++++++++++++++ CheapRetouch/Services/MaskingService.swift | 143 +++++++ CheapRetouch/Utilities/EdgeRefinement.swift | 192 ++++++++++ CheapRetouch/Utilities/ImagePipeline.swift | 103 +++++ check_build.sh | 26 ++ 17 files changed, 2049 insertions(+) create mode 100644 CheapRetouch.xcodeproj/project.pbxproj create mode 100644 CheapRetouch/App/CheapRetouchApp.swift create mode 100644 CheapRetouch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 CheapRetouch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 CheapRetouch/Assets.xcassets/Contents.json create mode 100644 CheapRetouch/Features/Editor/CanvasView.swift create mode 100644 CheapRetouch/Features/Editor/PhotoEditorView.swift create mode 100644 CheapRetouch/Features/Editor/ToolbarView.swift create mode 100644 CheapRetouch/Models/EditOperation.swift create mode 100644 CheapRetouch/Models/MaskData.swift create mode 100644 CheapRetouch/Models/Project.swift create mode 100644 CheapRetouch/Services/ContourService.swift create mode 100644 CheapRetouch/Services/InpaintEngine/InpaintEngine.swift create mode 100644 CheapRetouch/Services/MaskingService.swift create mode 100644 CheapRetouch/Utilities/EdgeRefinement.swift create mode 100644 CheapRetouch/Utilities/ImagePipeline.swift create mode 100755 check_build.sh diff --git a/CheapRetouch.xcodeproj/project.pbxproj b/CheapRetouch.xcodeproj/project.pbxproj new file mode 100644 index 0000000..874d2bf --- /dev/null +++ b/CheapRetouch.xcodeproj/project.pbxproj @@ -0,0 +1,353 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 7F13382D2F247C1A007FA81B /* CheapRetouch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CheapRetouch.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7F13382F2F247C1A007FA81B /* CheapRetouch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CheapRetouch; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7F13382A2F247C1A007FA81B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7F1338242F247C1A007FA81B = { + isa = PBXGroup; + children = ( + 7F13382F2F247C1A007FA81B /* CheapRetouch */, + 7F13382E2F247C1A007FA81B /* Products */, + ); + sourceTree = ""; + }; + 7F13382E2F247C1A007FA81B /* Products */ = { + isa = PBXGroup; + children = ( + 7F13382D2F247C1A007FA81B /* CheapRetouch.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7F13382C2F247C1A007FA81B /* CheapRetouch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F1338382F247C1A007FA81B /* Build configuration list for PBXNativeTarget "CheapRetouch" */; + buildPhases = ( + 7F1338292F247C1A007FA81B /* Sources */, + 7F13382A2F247C1A007FA81B /* Frameworks */, + 7F13382B2F247C1A007FA81B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7F13382F2F247C1A007FA81B /* CheapRetouch */, + ); + name = CheapRetouch; + packageProductDependencies = ( + ); + productName = CheapRetouch; + productReference = 7F13382D2F247C1A007FA81B /* CheapRetouch.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7F1338252F247C1A007FA81B /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 7F13382C2F247C1A007FA81B = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 7F1338282F247C1A007FA81B /* Build configuration list for PBXProject "CheapRetouch" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7F1338242F247C1A007FA81B; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7F13382E2F247C1A007FA81B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7F13382C2F247C1A007FA81B /* CheapRetouch */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7F13382B2F247C1A007FA81B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7F1338292F247C1A007FA81B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7F1338362F247C1A007FA81B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7F1338372F247C1A007FA81B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7F1338392F247C1A007FA81B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CheapRetouch; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapRetouch needs permission to save edited photos to your library."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "CheapRetouch needs access to your photos to edit and remove unwanted elements from them."; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapRetouch; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7F13383A2F247C1A007FA81B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CheapRetouch; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapRetouch needs permission to save edited photos to your library."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "CheapRetouch needs access to your photos to edit and remove unwanted elements from them."; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapRetouch; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7F1338282F247C1A007FA81B /* Build configuration list for PBXProject "CheapRetouch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F1338362F247C1A007FA81B /* Debug */, + 7F1338372F247C1A007FA81B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F1338382F247C1A007FA81B /* Build configuration list for PBXNativeTarget "CheapRetouch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F1338392F247C1A007FA81B /* Debug */, + 7F13383A2F247C1A007FA81B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7F1338252F247C1A007FA81B /* Project object */; +} diff --git a/CheapRetouch/App/CheapRetouchApp.swift b/CheapRetouch/App/CheapRetouchApp.swift new file mode 100644 index 0000000..473b018 --- /dev/null +++ b/CheapRetouch/App/CheapRetouchApp.swift @@ -0,0 +1,17 @@ +// +// CheapRetouchApp.swift +// CheapRetouch +// +// Created by Jared Evans on 1/23/26. +// + +import SwiftUI + +@main +struct CheapRetouchApp: App { + var body: some Scene { + WindowGroup { + PhotoEditorView() + } + } +} diff --git a/CheapRetouch/Assets.xcassets/AccentColor.colorset/Contents.json b/CheapRetouch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CheapRetouch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CheapRetouch/Assets.xcassets/AppIcon.appiconset/Contents.json b/CheapRetouch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/CheapRetouch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CheapRetouch/Assets.xcassets/Contents.json b/CheapRetouch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CheapRetouch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CheapRetouch/Features/Editor/CanvasView.swift b/CheapRetouch/Features/Editor/CanvasView.swift new file mode 100644 index 0000000..a6bdc3d --- /dev/null +++ b/CheapRetouch/Features/Editor/CanvasView.swift @@ -0,0 +1,129 @@ +// +// CanvasView.swift +// CheapRetouch +// +// Main editing canvas with pinch-to-zoom, pan, mask overlay, and before/after comparison. +// + +import SwiftUI +import UIKit + +struct CanvasView: View { + let image: UIImage + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @State private var isShowingOriginal = false + + private let minScale: CGFloat = 1.0 + private let maxScale: CGFloat = 10.0 + + var body: some View { + GeometryReader { geometry in + ZStack { + Color.black + + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .offset(offset) + .gesture( + MagnificationGesture() + .onChanged { value in + let newScale = lastScale * value + scale = min(max(newScale, minScale), maxScale) + } + .onEnded { _ in + lastScale = scale + withAnimation(.spring(duration: 0.3)) { + clampOffset(in: geometry.size) + } + } + ) + .simultaneousGesture( + DragGesture() + .onChanged { value in + if scale > 1.0 { + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } + } + .onEnded { _ in + lastOffset = offset + withAnimation(.spring(duration: 0.3)) { + clampOffset(in: geometry.size) + } + } + ) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.2) + .onChanged { _ in + isShowingOriginal = true + } + .onEnded { _ in + isShowingOriginal = false + } + ) + .onTapGesture(count: 2) { + withAnimation(.spring(duration: 0.3)) { + if scale > 1.0 { + scale = 1.0 + offset = .zero + lastScale = 1.0 + lastOffset = .zero + } else { + scale = 2.5 + lastScale = 2.5 + } + } + } + } + } + .clipped() + } + + private func clampOffset(in size: CGSize) { + guard scale > 1.0 else { + offset = .zero + lastOffset = .zero + return + } + + let imageAspect = image.size.width / image.size.height + let viewAspect = size.width / size.height + + let imageDisplaySize: CGSize + if imageAspect > viewAspect { + imageDisplaySize = CGSize( + width: size.width, + height: size.width / imageAspect + ) + } else { + imageDisplaySize = CGSize( + width: size.height * imageAspect, + height: size.height + ) + } + + let scaledSize = CGSize( + width: imageDisplaySize.width * scale, + height: imageDisplaySize.height * scale + ) + + let maxOffsetX = max(0, (scaledSize.width - size.width) / 2) + let maxOffsetY = max(0, (scaledSize.height - size.height) / 2) + + offset.width = min(max(offset.width, -maxOffsetX), maxOffsetX) + offset.height = min(max(offset.height, -maxOffsetY), maxOffsetY) + lastOffset = offset + } +} + +#Preview { + CanvasView(image: UIImage(systemName: "photo")!) +} diff --git a/CheapRetouch/Features/Editor/PhotoEditorView.swift b/CheapRetouch/Features/Editor/PhotoEditorView.swift new file mode 100644 index 0000000..a45eef6 --- /dev/null +++ b/CheapRetouch/Features/Editor/PhotoEditorView.swift @@ -0,0 +1,88 @@ +// +// PhotoEditorView.swift +// CheapRetouch +// +// Main editor view composing canvas, toolbar, and coordinating edit operations. +// + +import SwiftUI +import PhotosUI +import UIKit + +struct PhotoEditorView: View { + @State private var selectedImage: UIImage? + @State private var selectedItem: PhotosPickerItem? + @State private var isShowingPicker = false + + var body: some View { + NavigationStack { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + if let image = selectedImage { + VStack(spacing: 0) { + CanvasView(image: image) + ToolbarView() + } + } else { + EmptyStateView(isShowingPicker: $isShowingPicker) + } + } + .navigationTitle("CheapRetouch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + PhotosPicker(selection: $selectedItem, matching: .images) { + Image(systemName: "photo.on.rectangle") + } + } + } + .onChange(of: selectedItem) { oldValue, newValue in + Task { + if let data = try? await newValue?.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + selectedImage = uiImage + } + } + } + .photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images) + } + } +} + +struct EmptyStateView: View { + @Binding var isShowingPicker: Bool + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "photo.badge.plus") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text("No Photo Selected") + .font(.title2) + .fontWeight(.medium) + + Text("Import a photo to start removing\nunwanted elements") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button { + isShowingPicker = true + } label: { + Label("Select Photo", systemImage: "photo.on.rectangle") + .font(.headline) + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + } + } +} + +#Preview { + PhotoEditorView() +} diff --git a/CheapRetouch/Features/Editor/ToolbarView.swift b/CheapRetouch/Features/Editor/ToolbarView.swift new file mode 100644 index 0000000..f803362 --- /dev/null +++ b/CheapRetouch/Features/Editor/ToolbarView.swift @@ -0,0 +1,176 @@ +// +// ToolbarView.swift +// CheapRetouch +// +// Tool selection toolbar with contextual inspector panel. +// + +import SwiftUI + +enum EditTool: String, CaseIterable, Identifiable { + case person = "Person" + case object = "Object" + case wire = "Wire" + case brush = "Brush" + + var id: String { rawValue } + + var icon: String { + switch self { + case .person: return "person.fill" + case .object: return "circle.dashed" + case .wire: return "line.diagonal" + case .brush: return "paintbrush.fill" + } + } + + var description: String { + switch self { + case .person: return "Tap to remove people" + case .object: return "Tap to remove objects" + case .wire: return "Tap to remove wires" + case .brush: return "Paint to select areas" + } + } +} + +struct ToolbarView: View { + @State private var selectedTool: EditTool = .person + @State private var brushSize: Double = 20 + @State private var featherAmount: Double = 4 + @State private var wireWidth: Double = 6 + @State private var canUndo = false + @State private var canRedo = false + + var body: some View { + VStack(spacing: 0) { + Divider() + + // Inspector panel (contextual) + if selectedTool == .brush || selectedTool == .wire { + inspectorPanel + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Main toolbar + HStack(spacing: 0) { + // Tools + ForEach(EditTool.allCases) { tool in + toolButton(for: tool) + } + + Spacer() + + // Undo/Redo + Button { + // Undo action + } label: { + Image(systemName: "arrow.uturn.backward") + .font(.title3) + .frame(width: 44, height: 44) + } + .disabled(!canUndo) + .accessibilityLabel("Undo") + + Button { + // Redo action + } label: { + Image(systemName: "arrow.uturn.forward") + .font(.title3) + .frame(width: 44, height: 44) + } + .disabled(!canRedo) + .accessibilityLabel("Redo") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.systemBackground)) + } + .animation(.easeInOut(duration: 0.2), value: selectedTool) + } + + private func toolButton(for tool: EditTool) -> some View { + Button { + selectedTool = tool + } label: { + VStack(spacing: 4) { + Image(systemName: tool.icon) + .font(.title2) + .frame(width: 44, height: 32) + + Text(tool.rawValue) + .font(.caption2) + } + .foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary) + .frame(width: 60) + } + .accessibilityLabel("\(tool.rawValue) tool") + .accessibilityHint(tool.description) + .accessibilityAddTraits(selectedTool == tool ? .isSelected : []) + } + + private var inspectorPanel: some View { + VStack(spacing: 12) { + Divider() + + if selectedTool == .brush { + HStack { + Text("Brush Size") + .font(.subheadline) + Spacer() + Text("\(Int(brushSize))px") + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + HStack { + Slider(value: $brushSize, in: 1...100, step: 1) + .accessibilityLabel("Brush size slider") + + Stepper("", value: $brushSize, in: 1...100, step: 1) + .labelsHidden() + .accessibilityLabel("Brush size stepper") + } + } + + if selectedTool == .wire { + HStack { + Text("Line Width") + .font(.subheadline) + Spacer() + Text("\(Int(wireWidth))px") + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + Slider(value: $wireWidth, in: 2...20, step: 1) + .accessibilityLabel("Wire width slider") + } + + HStack { + Text("Feather") + .font(.subheadline) + Spacer() + Text("\(Int(featherAmount))px") + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + Slider(value: $featherAmount, in: 0...20, step: 1) + .accessibilityLabel("Feather amount slider") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground)) + } +} + +#Preview { + VStack { + Spacer() + ToolbarView() + } +} diff --git a/CheapRetouch/Models/EditOperation.swift b/CheapRetouch/Models/EditOperation.swift new file mode 100644 index 0000000..c326ede --- /dev/null +++ b/CheapRetouch/Models/EditOperation.swift @@ -0,0 +1,71 @@ +// +// EditOperation.swift +// CheapRetouch +// +// Non-destructive editing data model with operation stack. +// + +import Foundation + +enum ToolType: String, Codable { + case person + case object + case wire + case brush +} + +enum EditOperation: Codable { + case mask(MaskOperation) + case inpaint(InpaintOperation) + case adjustment(AdjustmentOperation) +} + +struct MaskOperation: Codable, Identifiable { + let id: UUID + let toolType: ToolType + let maskData: Data + let timestamp: Date + + init(id: UUID = UUID(), toolType: ToolType, maskData: Data, timestamp: Date = Date()) { + self.id = id + self.toolType = toolType + self.maskData = maskData + self.timestamp = timestamp + } +} + +struct InpaintOperation: Codable, Identifiable { + let id: UUID + let maskOperationId: UUID + let patchRadius: Int + let featherAmount: Float + let timestamp: Date + + init(id: UUID = UUID(), maskOperationId: UUID, patchRadius: Int = 9, featherAmount: Float = 4.0, timestamp: Date = Date()) { + self.id = id + self.maskOperationId = maskOperationId + self.patchRadius = patchRadius + self.featherAmount = featherAmount + self.timestamp = timestamp + } +} + +struct AdjustmentOperation: Codable, Identifiable { + let id: UUID + let type: AdjustmentType + let value: Float + let timestamp: Date + + enum AdjustmentType: String, Codable { + case brightness + case contrast + case saturation + } + + init(id: UUID = UUID(), type: AdjustmentType, value: Float, timestamp: Date = Date()) { + self.id = id + self.type = type + self.value = value + self.timestamp = timestamp + } +} diff --git a/CheapRetouch/Models/MaskData.swift b/CheapRetouch/Models/MaskData.swift new file mode 100644 index 0000000..0ec5b75 --- /dev/null +++ b/CheapRetouch/Models/MaskData.swift @@ -0,0 +1,112 @@ +// +// MaskData.swift +// CheapRetouch +// +// Mask data utilities for compression and decompression. +// + +import Foundation +import CoreGraphics +import UIKit +import Accelerate + +struct MaskData { + let width: Int + let height: Int + let data: Data + + init(width: Int, height: Int, data: Data) { + self.width = width + self.height = height + self.data = data + } + + init?(from cgImage: CGImage) { + let width = cgImage.width + let height = cgImage.height + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let data = context.data else { + return nil + } + + let buffer = Data(bytes: data, count: width * height) + + self.width = width + self.height = height + self.data = buffer + } + + func toCGImage() -> CGImage? { + var mutableData = data + return mutableData.withUnsafeMutableBytes { rawBuffer -> CGImage? in + guard let baseAddress = rawBuffer.baseAddress else { return nil } + + guard let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + return context.makeImage() + } + } + + func dilated(by pixels: Int) -> MaskData? { + guard pixels > 0 else { return self } + + let count = width * height + var sourceArray = [UInt8](data) + var destinationArray = [UInt8](repeating: 0, count: count) + + var sourceBuffer = vImage_Buffer( + data: &sourceArray, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: width + ) + + var destinationBuffer = vImage_Buffer( + data: &destinationArray, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: width + ) + + let kernelSize = pixels * 2 + 1 + let kernel = [UInt8](repeating: 255, count: kernelSize * kernelSize) + + let error = vImageDilate_Planar8( + &sourceBuffer, + &destinationBuffer, + 0, 0, + kernel, + vImagePixelCount(kernelSize), + vImagePixelCount(kernelSize), + vImage_Flags(kvImageNoFlags) + ) + + guard error == kvImageNoError else { return nil } + + return MaskData(width: width, height: height, data: Data(destinationArray)) + } +} diff --git a/CheapRetouch/Models/Project.swift b/CheapRetouch/Models/Project.swift new file mode 100644 index 0000000..0bc4faf --- /dev/null +++ b/CheapRetouch/Models/Project.swift @@ -0,0 +1,77 @@ +// +// Project.swift +// CheapRetouch +// +// Project model holding original image reference and operation stack. +// + +import Foundation + +struct Project: Codable, Identifiable { + let id: UUID + var name: String + var imageSource: ImageSource + var operations: [EditOperation] + var currentIndex: Int + let createdAt: Date + var modifiedAt: Date + + enum ImageSource: Codable { + case photoLibrary(localIdentifier: String) + case embedded(data: Data) + } + + init( + id: UUID = UUID(), + name: String = "Untitled", + imageSource: ImageSource, + operations: [EditOperation] = [], + currentIndex: Int = 0, + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.name = name + self.imageSource = imageSource + self.operations = operations + self.currentIndex = currentIndex + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } + + var canUndo: Bool { + currentIndex > 0 + } + + var canRedo: Bool { + currentIndex < operations.count + } + + mutating func addOperation(_ operation: EditOperation) { + // Remove any operations after current index (discard redo stack) + if currentIndex < operations.count { + operations.removeSubrange(currentIndex.. Bool { + guard canUndo else { return false } + currentIndex -= 1 + modifiedAt = Date() + return true + } + + mutating func redo() -> Bool { + guard canRedo else { return false } + currentIndex += 1 + modifiedAt = Date() + return true + } + + var activeOperations: [EditOperation] { + Array(operations.prefix(currentIndex)) + } +} diff --git a/CheapRetouch/Services/ContourService.swift b/CheapRetouch/Services/ContourService.swift new file mode 100644 index 0000000..4a26f6e --- /dev/null +++ b/CheapRetouch/Services/ContourService.swift @@ -0,0 +1,235 @@ +// +// ContourService.swift +// CheapRetouch +// +// Service for detecting and scoring wire/line contours. +// + +import Foundation +import Vision +import CoreGraphics +import UIKit + +actor ContourService { + + struct ScoredContour { + let contour: VNContour + let score: Float + } + + private let proximityWeight: Float = 0.3 + private let aspectWeight: Float = 0.3 + private let straightnessWeight: Float = 0.2 + private let lengthWeight: Float = 0.2 + + private let minimumScore: Float = 0.3 + + func detectContours(in image: CGImage) async throws -> [VNContour] { + let request = VNDetectContoursRequest() + request.contrastAdjustment = 1.0 + request.detectsDarkOnLight = true + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + try handler.perform([request]) + + guard let results = request.results?.first else { + return [] + } + + return collectAllContours(from: results) + } + + func findBestWireContour(at point: CGPoint, from contours: [VNContour], imageSize: CGSize) -> VNContour? { + let normalizedPoint = CGPoint( + x: point.x / imageSize.width, + y: 1.0 - point.y / imageSize.height + ) + + let scoredContours = contours.compactMap { contour -> ScoredContour? in + let score = scoreContour(contour, relativeTo: normalizedPoint) + guard score >= minimumScore else { return nil } + return ScoredContour(contour: contour, score: score) + } + + return scoredContours.max(by: { $0.score < $1.score })?.contour + } + + func scoreContour(_ contour: VNContour, relativeTo point: CGPoint) -> Float { + let points = contour.normalizedPoints + + guard points.count >= 2 else { return 0 } + + let proximityScore = calculateProximityScore(points: points, to: point) + let aspectScore = calculateAspectScore(points: points) + let straightnessScore = calculateStraightnessScore(points: points) + let lengthScore = calculateLengthScore(points: points) + + return proximityScore * proximityWeight + + aspectScore * aspectWeight + + straightnessScore * straightnessWeight + + lengthScore * lengthWeight + } + + func contourToMask(_ contour: VNContour, width: Int, imageSize: CGSize) -> CGImage? { + let maskWidth = Int(imageSize.width) + let maskHeight = Int(imageSize.height) + + guard let context = CGContext( + data: nil, + width: maskWidth, + height: maskHeight, + bitsPerComponent: 8, + bytesPerRow: maskWidth, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.setFillColor(gray: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: maskWidth, height: maskHeight)) + + context.setStrokeColor(gray: 1, alpha: 1) + context.setLineWidth(CGFloat(width)) + context.setLineCap(.round) + context.setLineJoin(.round) + + let points = contour.normalizedPoints + guard let firstPoint = points.first else { return nil } + + context.beginPath() + context.move(to: CGPoint( + x: CGFloat(firstPoint.x) * imageSize.width, + y: CGFloat(firstPoint.y) * imageSize.height + )) + + for point in points.dropFirst() { + context.addLine(to: CGPoint( + x: CGFloat(point.x) * imageSize.width, + y: CGFloat(point.y) * imageSize.height + )) + } + + context.strokePath() + + return context.makeImage() + } + + // MARK: - Private Scoring Methods + + private func calculateProximityScore(points: [SIMD2], to target: CGPoint) -> Float { + var minDistance: Float = .greatestFiniteMagnitude + + for point in points { + let dx = Float(target.x) - point.x + let dy = Float(target.y) - point.y + let distance = sqrt(dx * dx + dy * dy) + minDistance = min(minDistance, distance) + } + + // Score decreases with distance, max at 0, 0 at distance > 0.2 + return max(0, 1.0 - minDistance * 5) + } + + private func calculateAspectScore(points: [SIMD2]) -> Float { + guard points.count >= 2 else { return 0 } + + var minX: Float = .greatestFiniteMagnitude + var maxX: Float = -.greatestFiniteMagnitude + var minY: Float = .greatestFiniteMagnitude + var maxY: Float = -.greatestFiniteMagnitude + + for point in points { + minX = min(minX, point.x) + maxX = max(maxX, point.x) + minY = min(minY, point.y) + maxY = max(maxY, point.y) + } + + let width = maxX - minX + let height = maxY - minY + + let length = calculatePathLength(points: points) + let boundingDiagonal = sqrt(width * width + height * height) + + guard boundingDiagonal > 0 else { return 0 } + + // Wire-like shapes have length close to their bounding diagonal + // and small perpendicular extent + let minDimension = min(width, height) + let maxDimension = max(width, height) + + guard maxDimension > 0 else { return 0 } + + let aspectRatio = minDimension / maxDimension + // Low aspect ratio (thin) scores high + return max(0, 1.0 - aspectRatio * 2) + } + + private func calculateStraightnessScore(points: [SIMD2]) -> Float { + guard points.count >= 3 else { return 1.0 } + + var totalAngleChange: Float = 0 + + for i in 1..<(points.count - 1) { + let prev = points[i - 1] + let curr = points[i] + let next = points[i + 1] + + let v1 = SIMD2(curr.x - prev.x, curr.y - prev.y) + let v2 = SIMD2(next.x - curr.x, next.y - curr.y) + + let len1 = sqrt(v1.x * v1.x + v1.y * v1.y) + let len2 = sqrt(v2.x * v2.x + v2.y * v2.y) + + guard len1 > 0, len2 > 0 else { continue } + + let dot = (v1.x * v2.x + v1.y * v2.y) / (len1 * len2) + let angle = acos(min(1, max(-1, dot))) + totalAngleChange += angle + } + + let averageAngleChange = totalAngleChange / Float(points.count - 2) + // Straight lines have low angle change + return max(0, 1.0 - averageAngleChange / .pi) + } + + private func calculateLengthScore(points: [SIMD2]) -> Float { + let length = calculatePathLength(points: points) + // Longer contours score higher, normalized to typical wire length + return min(1.0, length * 2) + } + + private func calculatePathLength(points: [SIMD2]) -> Float { + var length: Float = 0 + + for i in 1.. [VNContour] { + var contours: [VNContour] = [] + + func collect(_ contour: VNContour) { + contours.append(contour) + for i in 0.. CGImage { + // Check memory requirements + let imageMemory = image.width * image.height * 4 + let maskMemory = mask.width * mask.height + let estimatedTotal = imageMemory * 3 + maskMemory * 2 // rough estimate + + guard estimatedTotal < maxMemoryBytes else { + throw InpaintError.memoryPressure + } + + if isMetalAvailable { + return try await inpaintWithMetal(image: image, mask: mask, isPreview: false) + } else { + return try await inpaintWithAccelerate(image: image, mask: mask) + } + } + + func inpaintPreview(image: CGImage, mask: CGImage) async throws -> CGImage { + // Scale down for preview if needed + let scaledImage: CGImage + let scaledMask: CGImage + + if image.width > maxPreviewSize || image.height > maxPreviewSize { + let scale = CGFloat(maxPreviewSize) / CGFloat(max(image.width, image.height)) + scaledImage = try scaleImage(image, scale: scale) + scaledMask = try scaleImage(mask, scale: scale) + } else { + scaledImage = image + scaledMask = mask + } + + if isMetalAvailable { + return try await inpaintWithMetal(image: scaledImage, mask: scaledMask, isPreview: true) + } else { + return try await inpaintWithAccelerate(image: scaledImage, mask: scaledMask) + } + } + + // MARK: - Metal Implementation + + private func inpaintWithMetal(image: CGImage, mask: CGImage, isPreview: Bool) async throws -> CGImage { + guard let device = device, let commandQueue = commandQueue else { + throw InpaintError.metalNotAvailable + } + + // For now, fall back to Accelerate while Metal shaders are being developed + return try await inpaintWithAccelerate(image: image, mask: mask) + } + + // MARK: - Accelerate Fallback + + private func inpaintWithAccelerate(image: CGImage, mask: CGImage) async throws -> CGImage { + let width = image.width + let height = image.height + + guard width > 0, height > 0 else { + throw InpaintError.invalidInput + } + + // Convert image to pixel buffer + guard var imageBuffer = createPixelBuffer(from: image) else { + throw InpaintError.textureCreationFailed + } + + // Convert mask to grayscale buffer + guard let maskBuffer = createGrayscaleBuffer(from: mask, targetWidth: width, targetHeight: height) else { + throw InpaintError.textureCreationFailed + } + + // Simple inpainting: for each masked pixel, average nearby unmasked pixels + let patchSize = patchRadius * 2 + 1 + + for y in 0.. 127 else { continue } + + // This pixel needs to be filled + var totalR: Int = 0 + var totalG: Int = 0 + var totalB: Int = 0 + var count: Int = 0 + + // Search in expanding rings for source pixels + for radius in 1...max(patchRadius * 4, 50) { + for dy in -radius...radius { + for dx in -radius...radius { + // Only check perimeter of current radius + guard abs(dx) == radius || abs(dy) == radius else { continue } + + let sx = x + dx + let sy = y + dy + + guard sx >= 0, sx < width, sy >= 0, sy < height else { continue } + + let srcMaskIndex = sy * width + sx + guard maskBuffer[srcMaskIndex] <= 127 else { continue } + + let srcPixelIndex = (sy * width + sx) * 4 + totalR += Int(imageBuffer[srcPixelIndex]) + totalG += Int(imageBuffer[srcPixelIndex + 1]) + totalB += Int(imageBuffer[srcPixelIndex + 2]) + count += 1 + } + } + + if count >= 8 { break } + } + + if count > 0 { + let pixelIndex = (y * width + x) * 4 + imageBuffer[pixelIndex] = UInt8(totalR / count) + imageBuffer[pixelIndex + 1] = UInt8(totalG / count) + imageBuffer[pixelIndex + 2] = UInt8(totalB / count) + imageBuffer[pixelIndex + 3] = 255 + } + } + } + + // Convert back to CGImage + guard let result = createCGImage(from: imageBuffer, width: width, height: height) else { + throw InpaintError.processingFailed + } + + return result + } + + // MARK: - Helper Methods + + private func createPixelBuffer(from image: CGImage) -> [UInt8]? { + let width = image.width + let height = image.height + var pixelData = [UInt8](repeating: 0, count: width * height * 4) + + guard let context = CGContext( + data: &pixelData, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return pixelData + } + + private func createGrayscaleBuffer(from image: CGImage, targetWidth: Int, targetHeight: Int) -> [UInt8]? { + var pixelData = [UInt8](repeating: 0, count: targetWidth * targetHeight) + + guard let context = CGContext( + data: &pixelData, + width: targetWidth, + height: targetHeight, + bitsPerComponent: 8, + bytesPerRow: targetWidth, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)) + return pixelData + } + + private func createCGImage(from pixelData: [UInt8], width: Int, height: Int) -> CGImage? { + var mutableData = pixelData + + return mutableData.withUnsafeMutableBytes { rawBuffer -> CGImage? in + guard let baseAddress = rawBuffer.baseAddress else { return nil } + + guard let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + + return context.makeImage() + } + } + + private func scaleImage(_ image: CGImage, scale: CGFloat) throws -> CGImage { + let newWidth = Int(CGFloat(image.width) * scale) + let newHeight = Int(CGFloat(image.height) * scale) + + guard let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: 8, + bytesPerRow: newWidth * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw InpaintError.textureCreationFailed + } + + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + + guard let result = context.makeImage() else { + throw InpaintError.textureCreationFailed + } + + return result + } +} diff --git a/CheapRetouch/Services/MaskingService.swift b/CheapRetouch/Services/MaskingService.swift new file mode 100644 index 0000000..69e4b8c --- /dev/null +++ b/CheapRetouch/Services/MaskingService.swift @@ -0,0 +1,143 @@ +// +// MaskingService.swift +// CheapRetouch +// +// Wrapper around Vision framework for generating masks from user taps. +// + +import Foundation +import Vision +import CoreImage +import UIKit + +actor MaskingService { + + enum MaskingError: Error, LocalizedError { + case imageConversionFailed + case requestFailed(Error) + case noMaskGenerated + case instanceNotFound + + var errorDescription: String? { + switch self { + case .imageConversionFailed: + return "Failed to convert image for processing" + case .requestFailed(let error): + return "Vision request failed: \(error.localizedDescription)" + case .noMaskGenerated: + return "No mask was generated for the selected area" + case .instanceNotFound: + return "No object found at the tap location" + } + } + } + + func generatePersonMask(at point: CGPoint, in image: CGImage) async throws -> CGImage? { + try await generateForegroundMask(at: point, in: image) + } + + func generateForegroundMask(at point: CGPoint, in image: CGImage) async throws -> CGImage? { + let request = VNGenerateForegroundInstanceMaskRequest() + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + + do { + try handler.perform([request]) + } catch { + throw MaskingError.requestFailed(error) + } + + guard let result = request.results?.first else { + return nil + } + + // Normalize point to Vision coordinates (0-1, origin bottom-left) + let normalizedPoint = VNImagePointForNormalizedPoint( + CGPoint(x: point.x / CGFloat(image.width), y: 1.0 - point.y / CGFloat(image.height)), + image.width, + image.height + ) + + let visionPoint = CGPoint( + x: point.x / CGFloat(image.width), + y: 1.0 - point.y / CGFloat(image.height) + ) + + // Find instance at tap point + let instances = result.allInstances + var targetInstance: IndexSet? + + for instance in instances { + let indexSet = IndexSet(integer: instance) + if let maskPixelBuffer = try? result.generateScaledMaskForImage(forInstances: indexSet, from: handler) { + // Check if point is within this instance's mask + if isPoint(visionPoint, inMask: maskPixelBuffer, imageSize: CGSize(width: image.width, height: image.height)) { + targetInstance = indexSet + break + } + } + } + + guard let instance = targetInstance else { + return nil + } + + let maskPixelBuffer = try result.generateScaledMaskForImage(forInstances: instance, from: handler) + return convertPixelBufferToCGImage(maskPixelBuffer) + } + + func generateAllForegroundMasks(in image: CGImage) async throws -> CGImage? { + let request = VNGenerateForegroundInstanceMaskRequest() + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + + do { + try handler.perform([request]) + } catch { + throw MaskingError.requestFailed(error) + } + + guard let result = request.results?.first else { + return nil + } + + let allInstances = result.allInstances + guard !allInstances.isEmpty else { + return nil + } + + let maskPixelBuffer = try result.generateScaledMaskForImage(forInstances: IndexSet(allInstances), from: handler) + return convertPixelBufferToCGImage(maskPixelBuffer) + } + + private func isPoint(_ point: CGPoint, inMask pixelBuffer: CVPixelBuffer, imageSize: CGSize) -> Bool { + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + + let x = Int(point.x * CGFloat(width)) + let y = Int((1.0 - point.y) * CGFloat(height)) + + guard x >= 0, x < width, y >= 0, y < height else { + return false + } + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + return false + } + + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let pixelOffset = y * bytesPerRow + x + + let pixelValue = baseAddress.load(fromByteOffset: pixelOffset, as: UInt8.self) + return pixelValue > 127 + } + + private func convertPixelBufferToCGImage(_ pixelBuffer: CVPixelBuffer) -> CGImage? { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + let context = CIContext() + return context.createCGImage(ciImage, from: ciImage.extent) + } +} diff --git a/CheapRetouch/Utilities/EdgeRefinement.swift b/CheapRetouch/Utilities/EdgeRefinement.swift new file mode 100644 index 0000000..1246482 --- /dev/null +++ b/CheapRetouch/Utilities/EdgeRefinement.swift @@ -0,0 +1,192 @@ +// +// EdgeRefinement.swift +// CheapRetouch +// +// Smart brush edge detection using gradient magnitude analysis. +// + +import Foundation +import CoreGraphics +import Accelerate +import UIKit + +struct EdgeRefinement { + + struct GradientImage { + let width: Int + let height: Int + let magnitude: [Float] + let directionX: [Float] + let directionY: [Float] + } + + static func computeGradient(from image: CGImage) -> GradientImage? { + let width = image.width + let height = image.height + + // Convert to grayscale + guard let grayscale = convertToGrayscale(image) else { + return nil + } + + // Compute Sobel gradients + var gradientX = [Float](repeating: 0, count: width * height) + var gradientY = [Float](repeating: 0, count: width * height) + var magnitude = [Float](repeating: 0, count: width * height) + + // Sobel kernels + let sobelX: [Int16] = [-1, 0, 1, -2, 0, 2, -1, 0, 1] + let sobelY: [Int16] = [-1, -2, -1, 0, 0, 0, 1, 2, 1] + + var sourceBuffer = vImage_Buffer( + data: UnsafeMutableRawPointer(mutating: grayscale), + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: width + ) + + var destBufferX = vImage_Buffer( + data: &gradientX, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: width * MemoryLayout.size + ) + + var destBufferY = vImage_Buffer( + data: &gradientY, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: width * MemoryLayout.size + ) + + // Apply Sobel filters (simplified - using direct calculation) + for y in 1..<(height - 1) { + for x in 1..<(width - 1) { + var gx: Float = 0 + var gy: Float = 0 + + for ky in -1...1 { + for kx in -1...1 { + let pixel = Float(grayscale[(y + ky) * width + (x + kx)]) + let kernelIndex = (ky + 1) * 3 + (kx + 1) + gx += pixel * Float(sobelX[kernelIndex]) + gy += pixel * Float(sobelY[kernelIndex]) + } + } + + let index = y * width + x + gradientX[index] = gx + gradientY[index] = gy + magnitude[index] = sqrt(gx * gx + gy * gy) + } + } + + return GradientImage( + width: width, + height: height, + magnitude: magnitude, + directionX: gradientX, + directionY: gradientY + ) + } + + static func refineSelectionToEdges( + selection: [CGPoint], + gradient: GradientImage, + searchRadius: Int = 5 + ) -> [CGPoint] { + var refinedPoints: [CGPoint] = [] + + for point in selection { + let x = Int(point.x) + let y = Int(point.y) + + // Find the nearby point with highest gradient magnitude + var maxMagnitude: Float = 0 + var bestX = x + var bestY = y + + for dy in -searchRadius...searchRadius { + for dx in -searchRadius...searchRadius { + let nx = x + dx + let ny = y + dy + + guard nx >= 0, nx < gradient.width, + ny >= 0, ny < gradient.height else { continue } + + let index = ny * gradient.width + nx + let mag = gradient.magnitude[index] + + if mag > maxMagnitude { + maxMagnitude = mag + bestX = nx + bestY = ny + } + } + } + + refinedPoints.append(CGPoint(x: bestX, y: bestY)) + } + + return refinedPoints + } + + static func createMaskFromPoints( + _ points: [CGPoint], + brushSize: CGFloat, + imageSize: CGSize + ) -> CGImage? { + let width = Int(imageSize.width) + let height = Int(imageSize.height) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.setFillColor(gray: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + context.setFillColor(gray: 1, alpha: 1) + + for point in points { + let rect = CGRect( + x: point.x - brushSize / 2, + y: point.y - brushSize / 2, + width: brushSize, + height: brushSize + ) + context.fillEllipse(in: rect) + } + + return context.makeImage() + } + + private static func convertToGrayscale(_ image: CGImage) -> [UInt8]? { + let width = image.width + let height = image.height + var pixelData = [UInt8](repeating: 0, count: width * height) + + guard let context = CGContext( + data: &pixelData, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return pixelData + } +} diff --git a/CheapRetouch/Utilities/ImagePipeline.swift b/CheapRetouch/Utilities/ImagePipeline.swift new file mode 100644 index 0000000..78dd0be --- /dev/null +++ b/CheapRetouch/Utilities/ImagePipeline.swift @@ -0,0 +1,103 @@ +// +// ImagePipeline.swift +// CheapRetouch +// +// Preview and export rendering pipeline. +// + +import Foundation +import CoreImage +import CoreGraphics +import UIKit + +actor ImagePipeline { + + private let context: CIContext + + init() { + self.context = CIContext(options: [ + .useSoftwareRenderer: false, + .highQualityDownsample: true + ]) + } + + func renderPreview( + originalImage: CGImage, + operations: [EditOperation], + maxSize: Int = 2048 + ) async throws -> CGImage { + // Scale down for preview if needed + var image = originalImage + + if image.width > maxSize || image.height > maxSize { + let scale = CGFloat(maxSize) / CGFloat(max(image.width, image.height)) + image = try scaleImage(image, scale: scale) + } + + // Apply operations (placeholder for now) + return image + } + + func renderExport( + originalImage: CGImage, + operations: [EditOperation] + ) async throws -> CGImage { + // Apply operations at full resolution + return originalImage + } + + func applyColorAdjustment( + to image: CGImage, + brightness: Float = 0, + contrast: Float = 1, + saturation: Float = 1 + ) -> CGImage? { + let ciImage = CIImage(cgImage: image) + + guard let filter = CIFilter(name: "CIColorControls") else { + return nil + } + + filter.setValue(ciImage, forKey: kCIInputImageKey) + filter.setValue(brightness, forKey: kCIInputBrightnessKey) + filter.setValue(contrast, forKey: kCIInputContrastKey) + filter.setValue(saturation, forKey: kCIInputSaturationKey) + + guard let outputImage = filter.outputImage else { + return nil + } + + return context.createCGImage(outputImage, from: outputImage.extent) + } + + private func scaleImage(_ image: CGImage, scale: CGFloat) throws -> CGImage { + let newWidth = Int(CGFloat(image.width) * scale) + let newHeight = Int(CGFloat(image.height) * scale) + + guard let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: 8, + bytesPerRow: newWidth * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw ImagePipelineError.contextCreationFailed + } + + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + + guard let result = context.makeImage() else { + throw ImagePipelineError.renderFailed + } + + return result + } + + enum ImagePipelineError: Error { + case contextCreationFailed + case renderFailed + } +} diff --git a/check_build.sh b/check_build.sh new file mode 100755 index 0000000..2ec0a52 --- /dev/null +++ b/check_build.sh @@ -0,0 +1,26 @@ +#!/bin/zsh +set -o pipefail # Fail if xcodebuild fails, even with xcbeautify + +# --- Configuration --- +SCHEME="CheapRetouch" +BUILD_PATH="./build" + +echo "🔍 Checking compilation for $SCHEME..." + +# Build Only (No Install/Launch) +# We use 'env -u' to hide Homebrew variables +# We use '-derivedDataPath' to keep it isolated +env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,name=iPhone 17 Pro" \ + -configuration Debug \ + -derivedDataPath "$BUILD_PATH" \ + build | xcbeautify + +# Check exit code of the pipeline +if [ $? -eq 0 ]; then + echo "✅ Build Succeeded. No errors found." +else + echo "❌ Build Failed." + exit 1 +fi