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:
353
CheapRetouch.xcodeproj/project.pbxproj
Normal file
353
CheapRetouch.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
17
CheapRetouch/App/CheapRetouchApp.swift
Normal file
17
CheapRetouch/App/CheapRetouchApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
CheapRetouch/Assets.xcassets/Contents.json
Normal file
6
CheapRetouch/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
129
CheapRetouch/Features/Editor/CanvasView.swift
Normal file
129
CheapRetouch/Features/Editor/CanvasView.swift
Normal 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")!)
|
||||
}
|
||||
88
CheapRetouch/Features/Editor/PhotoEditorView.swift
Normal file
88
CheapRetouch/Features/Editor/PhotoEditorView.swift
Normal 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()
|
||||
}
|
||||
176
CheapRetouch/Features/Editor/ToolbarView.swift
Normal file
176
CheapRetouch/Features/Editor/ToolbarView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
71
CheapRetouch/Models/EditOperation.swift
Normal file
71
CheapRetouch/Models/EditOperation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
112
CheapRetouch/Models/MaskData.swift
Normal file
112
CheapRetouch/Models/MaskData.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
77
CheapRetouch/Models/Project.swift
Normal file
77
CheapRetouch/Models/Project.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
235
CheapRetouch/Services/ContourService.swift
Normal file
235
CheapRetouch/Services/ContourService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
275
CheapRetouch/Services/InpaintEngine/InpaintEngine.swift
Normal file
275
CheapRetouch/Services/InpaintEngine/InpaintEngine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
143
CheapRetouch/Services/MaskingService.swift
Normal file
143
CheapRetouch/Services/MaskingService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
192
CheapRetouch/Utilities/EdgeRefinement.swift
Normal file
192
CheapRetouch/Utilities/EdgeRefinement.swift
Normal 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
|
||||
}
|
||||
}
|
||||
103
CheapRetouch/Utilities/ImagePipeline.swift
Normal file
103
CheapRetouch/Utilities/ImagePipeline.swift
Normal 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
26
check_build.sh
Executable 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
|
||||
Reference in New Issue
Block a user