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 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 23:26:37 -05:00
parent 1049057d7d
commit 20707da268
17 changed files with 2049 additions and 0 deletions

View File

@@ -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 = "<group>";
};
/* 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 = "<group>";
};
7F13382E2F247C1A007FA81B /* Products */ = {
isa = PBXGroup;
children = (
7F13382D2F247C1A007FA81B /* CheapRetouch.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@@ -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()
}
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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")!)
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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..<operations.count)
}
operations.append(operation)
currentIndex = operations.count
modifiedAt = Date()
}
mutating func undo() -> 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))
}
}

View File

@@ -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<Float>], 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>]) -> 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>]) -> 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<Float>(curr.x - prev.x, curr.y - prev.y)
let v2 = SIMD2<Float>(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>]) -> 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>]) -> Float {
var length: Float = 0
for i in 1..<points.count {
let dx = points[i].x - points[i - 1].x
let dy = points[i].y - points[i - 1].y
length += sqrt(dx * dx + dy * dy)
}
return length
}
private func collectAllContours(from observation: VNContoursObservation) -> [VNContour] {
var contours: [VNContour] = []
func collect(_ contour: VNContour) {
contours.append(contour)
for i in 0..<contour.childContourCount {
if let child = try? contour.childContour(at: i) {
collect(child)
}
}
}
for i in 0..<observation.contourCount {
if let contour = try? observation.contour(at: i) {
collect(contour)
}
}
return contours
}
}

View File

@@ -0,0 +1,275 @@
//
// InpaintEngine.swift
// CheapRetouch
//
// Exemplar-based inpainting engine using Metal.
//
import Foundation
import Metal
import MetalKit
import CoreGraphics
import UIKit
import Accelerate
actor InpaintEngine {
enum InpaintError: Error, LocalizedError {
case metalNotAvailable
case deviceCreationFailed
case textureCreationFailed
case processingFailed
case memoryPressure
case invalidInput
var errorDescription: String? {
switch self {
case .metalNotAvailable:
return "Metal is not available on this device"
case .deviceCreationFailed:
return "Failed to create Metal device"
case .textureCreationFailed:
return "Failed to create texture for processing"
case .processingFailed:
return "Inpainting processing failed"
case .memoryPressure:
return "Image too large to process. Try cropping first."
case .invalidInput:
return "Invalid image or mask input"
}
}
}
private let device: MTLDevice?
private let commandQueue: MTLCommandQueue?
private let patchRadius: Int
private let maxPreviewSize: Int = 2048
private let maxMemoryBytes: Int = 1_500_000_000 // 1.5GB
init(patchRadius: Int = 9) {
self.patchRadius = patchRadius
self.device = MTLCreateSystemDefaultDevice()
self.commandQueue = device?.makeCommandQueue()
}
var isMetalAvailable: Bool {
device != nil && commandQueue != nil
}
func inpaint(image: CGImage, mask: CGImage) async throws -> 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..<height {
for x in 0..<width {
let maskIndex = y * width + x
guard maskBuffer[maskIndex] > 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
}
}

View File

@@ -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)
}
}

View File

@@ -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<Float>.size
)
var destBufferY = vImage_Buffer(
data: &gradientY,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: width * MemoryLayout<Float>.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
}
}

View File

@@ -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
}
}

26
check_build.sh Executable file
View File

@@ -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