Fix brush tool not working after undo
- Fixed dimension mismatch between mask and display image after undo - Mask was being created at original image size, but displayImage is at preview scale after undo/redo (renderPreview scales images > 2048px) - Now create mask at actual displayImage dimensions, ensuring mask and image sizes match for inpainting - Also fixed edge refinement gradient to recompute when image changes
@@ -252,7 +252,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -292,7 +292,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.851",
|
||||
"green" : "0.459",
|
||||
"red" : "0.239"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.918",
|
||||
"green" : "0.557",
|
||||
"red" : "0.341"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
||||
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
CheapRetouch/Assets.xcassets/AppIcon.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -1,35 +1 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ struct BrushCanvasView: View {
|
||||
|
||||
@State private var currentStroke: [CGPoint] = []
|
||||
@State private var allStrokes: [[CGPoint]] = []
|
||||
@State private var isErasing = false
|
||||
@State private var currentTouchLocation: CGPoint?
|
||||
@State private var gradientImage: EdgeRefinement.GradientImage?
|
||||
|
||||
@@ -23,12 +22,12 @@ struct BrushCanvasView: View {
|
||||
Canvas { context, size in
|
||||
// Draw all completed strokes
|
||||
for stroke in allStrokes {
|
||||
drawStroke(stroke, in: &context, color: isErasing ? .black : .white)
|
||||
drawStroke(stroke, in: &context, color: .white)
|
||||
}
|
||||
|
||||
// Draw current stroke
|
||||
if !currentStroke.isEmpty {
|
||||
drawStroke(currentStroke, in: &context, color: isErasing ? .black : .white)
|
||||
drawStroke(currentStroke, in: &context, color: .white)
|
||||
}
|
||||
|
||||
// Draw brush preview circle at current touch location
|
||||
@@ -47,9 +46,6 @@ struct BrushCanvasView: View {
|
||||
}
|
||||
}
|
||||
.gesture(drawingGesture)
|
||||
.overlay(alignment: .bottom) {
|
||||
brushControls
|
||||
}
|
||||
.onAppear {
|
||||
// Precompute gradient for edge refinement if enabled
|
||||
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
||||
@@ -61,6 +57,34 @@ struct BrushCanvasView: View {
|
||||
computeGradientAsync(from: image)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.editedImage) { _, _ in
|
||||
// Recompute gradient when image changes (e.g., after undo/redo)
|
||||
// to ensure edge refinement works correctly with the current image
|
||||
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
||||
computeGradientAsync(from: image)
|
||||
} else {
|
||||
// Invalidate cached gradient so it will be recomputed when edge refinement is enabled
|
||||
gradientImage = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: allStrokes.count) { _, newCount in
|
||||
viewModel.brushStrokesCount = newCount
|
||||
}
|
||||
.onChange(of: viewModel.triggerClearBrushStrokes) { _, shouldClear in
|
||||
if shouldClear {
|
||||
allStrokes.removeAll()
|
||||
currentStroke.removeAll()
|
||||
viewModel.triggerClearBrushStrokes = false
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.triggerApplyBrushMask) { _, shouldApply in
|
||||
if shouldApply {
|
||||
Task {
|
||||
await applyBrushMask()
|
||||
}
|
||||
viewModel.triggerApplyBrushMask = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func computeGradientAsync(from image: CGImage) {
|
||||
@@ -122,67 +146,38 @@ struct BrushCanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var brushControls: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Erase toggle
|
||||
Button {
|
||||
isErasing.toggle()
|
||||
} label: {
|
||||
Image(systemName: isErasing ? "eraser.fill" : "eraser")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(isErasing ? Color.accentColor : Color.clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityLabel(isErasing ? "Eraser active" : "Switch to eraser")
|
||||
|
||||
// Clear all
|
||||
Button {
|
||||
allStrokes.removeAll()
|
||||
currentStroke.removeAll()
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.disabled(allStrokes.isEmpty)
|
||||
.accessibilityLabel("Clear all strokes")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Done button
|
||||
Button {
|
||||
Task {
|
||||
await applyBrushMask()
|
||||
}
|
||||
} label: {
|
||||
Text("Done")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(allStrokes.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private func applyBrushMask() async {
|
||||
DebugLogger.action("BrushCanvasView.applyBrushMask called")
|
||||
guard !allStrokes.isEmpty else {
|
||||
DebugLogger.log("No strokes to apply")
|
||||
|
||||
// Check if we have strokes to apply or a pending mask to refine
|
||||
let hasStrokes = !allStrokes.isEmpty
|
||||
let hasPendingMask = viewModel.pendingRefineMask != nil
|
||||
|
||||
guard hasStrokes || hasPendingMask else {
|
||||
DebugLogger.log("No strokes or pending mask to apply")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the actual display image dimensions - this may differ from originalImage
|
||||
// after undo/redo due to preview scaling in renderPreview
|
||||
guard let displayImage = viewModel.displayImage else {
|
||||
DebugLogger.error("No display image available")
|
||||
return
|
||||
}
|
||||
let actualImageSize = CGSize(width: displayImage.width, height: displayImage.height)
|
||||
|
||||
DebugLogger.log("Stroke count: \(allStrokes.count), total points: \(allStrokes.reduce(0) { $0 + $1.count })")
|
||||
DebugLogger.log("Image size: \(imageSize), displayed frame: \(displayedImageFrame)")
|
||||
DebugLogger.log("Original image size: \(imageSize)")
|
||||
DebugLogger.log("Actual display image size: \(actualImageSize)")
|
||||
DebugLogger.log("Displayed frame: \(displayedImageFrame)")
|
||||
DebugLogger.log("Has pending mask: \(hasPendingMask)")
|
||||
|
||||
let scaleX = imageSize.width / displayedImageFrame.width
|
||||
let scaleY = imageSize.height / displayedImageFrame.height
|
||||
// Use actual display image size for scaling, not the original image size
|
||||
let scaleX = actualImageSize.width / displayedImageFrame.width
|
||||
let scaleY = actualImageSize.height / displayedImageFrame.height
|
||||
DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)")
|
||||
|
||||
// Convert all strokes to image coordinates
|
||||
// Convert all strokes to image coordinates (using actual display image dimensions)
|
||||
var imageCoordStrokes: [[CGPoint]] = []
|
||||
for stroke in allStrokes {
|
||||
let imageStroke = stroke.map { point in
|
||||
@@ -205,16 +200,24 @@ struct BrushCanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Create mask image from strokes (use scale 1.0 to match actual image pixels)
|
||||
// Create mask image from strokes at the ACTUAL display image size
|
||||
// (not the original image size, which may differ after undo/redo)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0 // Don't use screen scale, use actual pixel size
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize, format: format)
|
||||
let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format)
|
||||
let maskImage = renderer.image { ctx in
|
||||
// Fill with black (not masked)
|
||||
UIColor.black.setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: imageSize))
|
||||
ctx.fill(CGRect(origin: .zero, size: actualImageSize))
|
||||
|
||||
// If we have a pending mask from Person/Object detection, draw it first
|
||||
// Note: pendingMask might be at original size, so we draw it scaled
|
||||
if let pendingMask = viewModel.pendingRefineMask {
|
||||
// Draw the pending mask - white areas become part of the combined mask
|
||||
ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize))
|
||||
}
|
||||
|
||||
// Draw strokes in white (masked areas)
|
||||
// Draw brush strokes in white (masked areas) on top
|
||||
UIColor.white.setStroke()
|
||||
|
||||
for stroke in imageCoordStrokes {
|
||||
@@ -236,6 +239,9 @@ struct BrushCanvasView: View {
|
||||
|
||||
if let cgImage = maskImage.cgImage {
|
||||
DebugLogger.imageInfo("Created brush mask", image: cgImage)
|
||||
// Clear the pending mask since we've incorporated it
|
||||
viewModel.pendingRefineMask = nil
|
||||
viewModel.maskPreview = nil
|
||||
await viewModel.applyBrushMask(cgImage)
|
||||
} else {
|
||||
DebugLogger.error("Failed to create CGImage from brush mask")
|
||||
@@ -247,149 +253,6 @@ struct BrushCanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Line Brush View for Wire Tool
|
||||
|
||||
struct LineBrushView: View {
|
||||
@Bindable var viewModel: EditorViewModel
|
||||
let imageSize: CGSize
|
||||
let displayedImageFrame: CGRect
|
||||
|
||||
@State private var linePoints: [CGPoint] = []
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
guard linePoints.count >= 2 else { return }
|
||||
|
||||
var path = Path()
|
||||
path.move(to: linePoints[0])
|
||||
|
||||
for point in linePoints.dropFirst() {
|
||||
path.addLine(to: point)
|
||||
}
|
||||
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(.white.opacity(0.7)),
|
||||
style: StrokeStyle(
|
||||
lineWidth: viewModel.wireWidth,
|
||||
lineCap: .round,
|
||||
lineJoin: .round
|
||||
)
|
||||
)
|
||||
}
|
||||
.gesture(lineDrawingGesture)
|
||||
.overlay(alignment: .bottom) {
|
||||
lineControls
|
||||
}
|
||||
}
|
||||
|
||||
private var lineDrawingGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let point = value.location
|
||||
if displayedImageFrame.contains(point) {
|
||||
// For line brush, we sample less frequently for smoother lines
|
||||
if linePoints.isEmpty || distance(from: linePoints.last!, to: point) > 5 {
|
||||
linePoints.append(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Line complete, ready to apply
|
||||
}
|
||||
}
|
||||
|
||||
private var lineControls: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Clear
|
||||
Button {
|
||||
linePoints.removeAll()
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.disabled(linePoints.isEmpty)
|
||||
.accessibilityLabel("Clear line")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Cancel
|
||||
Button {
|
||||
linePoints.removeAll()
|
||||
viewModel.selectedTool = .wire
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
// Done button
|
||||
Button {
|
||||
Task {
|
||||
await applyLineMask()
|
||||
}
|
||||
} label: {
|
||||
Text("Remove Line")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(linePoints.count < 2)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private func applyLineMask() async {
|
||||
guard linePoints.count >= 2 else { return }
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize)
|
||||
let maskImage = renderer.image { ctx in
|
||||
UIColor.black.setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: imageSize))
|
||||
|
||||
UIColor.white.setStroke()
|
||||
|
||||
let scaleX = imageSize.width / displayedImageFrame.width
|
||||
let scaleY = imageSize.height / displayedImageFrame.height
|
||||
|
||||
let path = UIBezierPath()
|
||||
let firstPoint = CGPoint(
|
||||
x: (linePoints[0].x - displayedImageFrame.minX) * scaleX,
|
||||
y: (linePoints[0].y - displayedImageFrame.minY) * scaleY
|
||||
)
|
||||
path.move(to: firstPoint)
|
||||
|
||||
for point in linePoints.dropFirst() {
|
||||
let scaledPoint = CGPoint(
|
||||
x: (point.x - displayedImageFrame.minX) * scaleX,
|
||||
y: (point.y - displayedImageFrame.minY) * scaleY
|
||||
)
|
||||
path.addLine(to: scaledPoint)
|
||||
}
|
||||
|
||||
path.lineWidth = viewModel.wireWidth * scaleX
|
||||
path.lineCapStyle = .round
|
||||
path.lineJoinStyle = .round
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
if let cgImage = maskImage.cgImage {
|
||||
await viewModel.applyBrushMask(cgImage)
|
||||
}
|
||||
|
||||
linePoints.removeAll()
|
||||
}
|
||||
|
||||
private func distance(from p1: CGPoint, to p2: CGPoint) -> CGFloat {
|
||||
sqrt(pow(p2.x - p1.x, 2) + pow(p2.y - p1.y, 2))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let viewModel = EditorViewModel()
|
||||
return BrushCanvasView(
|
||||
|
||||
@@ -8,19 +8,6 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Conditional View Modifier
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CanvasView: View {
|
||||
@Bindable var viewModel: EditorViewModel
|
||||
|
||||
@@ -68,26 +55,13 @@ struct CanvasView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Line brush path overlay
|
||||
if viewModel.selectedTool == .wire && viewModel.isLineBrushMode && !viewModel.lineBrushPath.isEmpty {
|
||||
LineBrushPathView(
|
||||
path: viewModel.lineBrushPath,
|
||||
lineWidth: viewModel.wireWidth,
|
||||
imageSize: viewModel.imageSize,
|
||||
displayedFrame: displayedImageFrame(in: geometry.size)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(tapGesture(in: geometry))
|
||||
.gesture(magnificationGesture(in: geometry))
|
||||
.simultaneousGesture(dragGesture(in: geometry))
|
||||
.simultaneousGesture(combinedDragGesture(in: geometry))
|
||||
.simultaneousGesture(longPressGesture)
|
||||
// Only attach line brush gesture when in line brush mode
|
||||
.if(viewModel.selectedTool == .wire && viewModel.isLineBrushMode) { view in
|
||||
view.gesture(lineBrushGesture(in: geometry))
|
||||
}
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTapZoom()
|
||||
}
|
||||
@@ -97,6 +71,13 @@ struct CanvasView: View {
|
||||
.onChange(of: geometry.size) { _, newSize in
|
||||
viewSize = newSize
|
||||
}
|
||||
.onChange(of: viewModel.triggerResetZoom) { _, _ in
|
||||
// Reset zoom and offset when a new image is loaded
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
@@ -158,10 +139,8 @@ struct CanvasView: View {
|
||||
DebugLogger.log("Tap ignored - brush tool selected")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip tap if in line brush mode
|
||||
if viewModel.selectedTool == .wire && viewModel.isLineBrushMode {
|
||||
DebugLogger.log("Tap ignored - line brush mode")
|
||||
guard viewModel.selectedTool != .move else {
|
||||
DebugLogger.log("Tap ignored - move tool selected")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,41 +152,13 @@ struct CanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func lineBrushGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
// Only activate for wire tool in line brush mode
|
||||
guard viewModel.selectedTool == .wire,
|
||||
viewModel.isLineBrushMode else { return }
|
||||
// Only allow panning when move tool is selected
|
||||
guard viewModel.selectedTool == .move else { return }
|
||||
|
||||
guard !viewModel.isProcessing,
|
||||
!viewModel.showingMaskConfirmation else { return }
|
||||
|
||||
let imagePoint = convertViewPointToImagePoint(value.location, in: geometry.size)
|
||||
viewModel.addLineBrushPoint(imagePoint)
|
||||
}
|
||||
}
|
||||
|
||||
private func magnificationGesture(in geometry: GeometryProxy) -> some 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
// Don't pan when brush tool is selected - let brush drawing take priority
|
||||
guard viewModel.selectedTool != .brush else { return }
|
||||
|
||||
// Pan mode: only when zoomed in
|
||||
if scale > 1.0 {
|
||||
offset = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
@@ -216,9 +167,9 @@ struct CanvasView: View {
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Don't update offset if brush tool is selected
|
||||
guard viewModel.selectedTool != .brush else { return }
|
||||
|
||||
// Only allow panning when move tool is selected
|
||||
guard viewModel.selectedTool == .move else { return }
|
||||
|
||||
lastOffset = offset
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
clampOffset(in: geometry.size)
|
||||
@@ -226,6 +177,26 @@ struct CanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
// Only allow zooming when move tool is selected
|
||||
guard viewModel.selectedTool == .move else { return }
|
||||
|
||||
let newScale = lastScale * value
|
||||
scale = min(max(newScale, minScale), maxScale)
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Only allow zooming when move tool is selected
|
||||
guard viewModel.selectedTool == .move else { return }
|
||||
|
||||
lastScale = scale
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
clampOffset(in: geometry.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var longPressGesture: some Gesture {
|
||||
LongPressGesture(minimumDuration: 0.3)
|
||||
.onEnded { _ in
|
||||
@@ -239,6 +210,9 @@ struct CanvasView: View {
|
||||
}
|
||||
|
||||
private func doubleTapZoom() {
|
||||
// Only allow double-tap zoom when move tool is selected
|
||||
guard viewModel.selectedTool == .move else { return }
|
||||
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
if scale > 1.0 {
|
||||
scale = 1.0
|
||||
@@ -338,44 +312,6 @@ struct CanvasView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Line Brush Path View
|
||||
|
||||
struct LineBrushPathView: View {
|
||||
let path: [CGPoint]
|
||||
let lineWidth: CGFloat
|
||||
let imageSize: CGSize
|
||||
let displayedFrame: CGRect
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
guard path.count >= 2 else { return }
|
||||
|
||||
let scaledPath = path.map { point -> CGPoint in
|
||||
let normalizedX = point.x / imageSize.width
|
||||
let normalizedY = point.y / imageSize.height
|
||||
return CGPoint(
|
||||
x: displayedFrame.origin.x + normalizedX * displayedFrame.width,
|
||||
y: displayedFrame.origin.y + normalizedY * displayedFrame.height
|
||||
)
|
||||
}
|
||||
|
||||
var strokePath = Path()
|
||||
strokePath.move(to: scaledPath[0])
|
||||
for point in scaledPath.dropFirst() {
|
||||
strokePath.addLine(to: point)
|
||||
}
|
||||
|
||||
context.stroke(
|
||||
strokePath,
|
||||
with: .color(.red.opacity(0.7)),
|
||||
lineWidth: lineWidth * (displayedFrame.width / imageSize.width)
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let viewModel = EditorViewModel()
|
||||
return CanvasView(viewModel: viewModel)
|
||||
|
||||
@@ -28,7 +28,11 @@ final class EditorViewModel {
|
||||
var selectedTool: EditTool = .person
|
||||
var brushSize: CGFloat = 20
|
||||
var featherAmount: CGFloat = 4
|
||||
var wireWidth: CGFloat = 6
|
||||
|
||||
// Brush tool state
|
||||
var brushStrokesCount = 0
|
||||
var triggerClearBrushStrokes = false
|
||||
var triggerApplyBrushMask = false
|
||||
|
||||
var isProcessing = false
|
||||
var processingMessage = ""
|
||||
@@ -36,19 +40,17 @@ final class EditorViewModel {
|
||||
var showingMaskConfirmation = false
|
||||
var detectedPeopleCount = 0
|
||||
var showSelectAllPeople = false
|
||||
var isLineBrushMode = false
|
||||
var lineBrushPath: [CGPoint] = []
|
||||
var useHighContrastMask = false
|
||||
var useEdgeRefinement = true
|
||||
var pendingRefineMask: CGImage?
|
||||
var isLowConfidenceMask = false
|
||||
var triggerResetZoom = false
|
||||
|
||||
private(set) var project: Project?
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
private let maskingService = MaskingService()
|
||||
private let contourService = ContourService()
|
||||
private let inpaintEngine = InpaintEngine()
|
||||
private let imagePipeline = ImagePipeline()
|
||||
|
||||
@@ -92,6 +94,7 @@ final class EditorViewModel {
|
||||
pendingRefineMask = nil
|
||||
showSelectAllPeople = false
|
||||
detectedPeopleCount = 0
|
||||
triggerResetZoom.toggle()
|
||||
|
||||
// Check image size and warn if large
|
||||
let pixelCount = cgImage.width * cgImage.height
|
||||
@@ -153,14 +156,13 @@ final class EditorViewModel {
|
||||
DebugLogger.processing("Starting object detection")
|
||||
try await handleObjectTap(at: point, in: image)
|
||||
|
||||
case .wire:
|
||||
processingMessage = "Detecting wire..."
|
||||
DebugLogger.processing("Starting wire detection")
|
||||
try await handleWireTap(at: point, in: image)
|
||||
|
||||
case .brush:
|
||||
DebugLogger.log("Brush tool - tap ignored (handled by canvas)")
|
||||
break
|
||||
|
||||
case .move:
|
||||
DebugLogger.log("Move tool - tap ignored")
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.error("handleTap failed", error: error)
|
||||
@@ -278,163 +280,6 @@ final class EditorViewModel {
|
||||
DebugLogger.state("Object mask preview set, showing confirmation")
|
||||
}
|
||||
|
||||
private func handleWireTap(at point: CGPoint, in image: CGImage) async throws {
|
||||
// If in line brush mode, don't process taps
|
||||
if isLineBrushMode {
|
||||
return
|
||||
}
|
||||
|
||||
let contours = try await contourService.detectContours(in: image)
|
||||
let bestContour = await contourService.findBestWireContour(
|
||||
at: point,
|
||||
from: contours,
|
||||
imageSize: CGSize(width: image.width, height: image.height)
|
||||
)
|
||||
|
||||
guard let contour = bestContour else {
|
||||
errorMessage = "No lines detected. Tap 'Line Brush' to draw along the wire."
|
||||
return
|
||||
}
|
||||
|
||||
let mask = await contourService.contourToMask(
|
||||
contour,
|
||||
width: Int(wireWidth),
|
||||
imageSize: CGSize(width: image.width, height: image.height)
|
||||
)
|
||||
|
||||
guard let mask = mask else {
|
||||
errorMessage = "Failed to create mask from contour"
|
||||
return
|
||||
}
|
||||
|
||||
maskPreview = mask
|
||||
showingMaskConfirmation = true
|
||||
}
|
||||
|
||||
// MARK: - Line Brush Mode
|
||||
|
||||
func toggleLineBrushMode() {
|
||||
isLineBrushMode.toggle()
|
||||
if !isLineBrushMode {
|
||||
lineBrushPath.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func addLineBrushPoint(_ point: CGPoint) {
|
||||
lineBrushPath.append(point)
|
||||
}
|
||||
|
||||
func finishLineBrush() async {
|
||||
DebugLogger.action("finishLineBrush called, path points: \(lineBrushPath.count)")
|
||||
guard let image = displayImage, lineBrushPath.count >= 2 else {
|
||||
DebugLogger.log("Not enough points or no image, clearing path")
|
||||
lineBrushPath.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
DebugLogger.imageInfo("Source image for line brush", image: image)
|
||||
|
||||
isProcessing = true
|
||||
processingMessage = "Creating line mask..."
|
||||
|
||||
// Generate mask from line brush path
|
||||
let mask = createLineBrushMask(
|
||||
path: lineBrushPath,
|
||||
width: Int(wireWidth),
|
||||
imageSize: CGSize(width: image.width, height: image.height)
|
||||
)
|
||||
|
||||
lineBrushPath.removeAll()
|
||||
|
||||
guard let mask = mask else {
|
||||
DebugLogger.error("Failed to create mask from line brush")
|
||||
errorMessage = "Failed to create mask from line brush"
|
||||
isProcessing = false
|
||||
processingMessage = ""
|
||||
return
|
||||
}
|
||||
|
||||
DebugLogger.imageInfo("Line brush mask created", image: mask)
|
||||
maskPreview = mask
|
||||
showingMaskConfirmation = true
|
||||
isLineBrushMode = false
|
||||
isProcessing = false
|
||||
processingMessage = ""
|
||||
}
|
||||
|
||||
private func createLineBrushMask(path: [CGPoint], width: Int, imageSize: CGSize) -> CGImage? {
|
||||
let intWidth = Int(imageSize.width)
|
||||
let intHeight = Int(imageSize.height)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: intWidth,
|
||||
height: intHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: intWidth,
|
||||
space: CGColorSpaceCreateDeviceGray(),
|
||||
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||
) else { return nil }
|
||||
|
||||
context.setFillColor(gray: 0, alpha: 1)
|
||||
context.fill(CGRect(x: 0, y: 0, width: intWidth, height: intHeight))
|
||||
|
||||
context.setStrokeColor(gray: 1, alpha: 1)
|
||||
context.setLineWidth(CGFloat(width))
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
|
||||
// Use Catmull-Rom spline for smooth path
|
||||
if path.count >= 4 {
|
||||
let smoothedPath = catmullRomSpline(points: path, segments: 10)
|
||||
context.move(to: CGPoint(x: smoothedPath[0].x, y: imageSize.height - smoothedPath[0].y))
|
||||
for point in smoothedPath.dropFirst() {
|
||||
context.addLine(to: CGPoint(x: point.x, y: imageSize.height - point.y))
|
||||
}
|
||||
} else {
|
||||
context.move(to: CGPoint(x: path[0].x, y: imageSize.height - path[0].y))
|
||||
for point in path.dropFirst() {
|
||||
context.addLine(to: CGPoint(x: point.x, y: imageSize.height - point.y))
|
||||
}
|
||||
}
|
||||
|
||||
context.strokePath()
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
private func catmullRomSpline(points: [CGPoint], segments: Int) -> [CGPoint] {
|
||||
guard points.count >= 4 else { return points }
|
||||
|
||||
var result: [CGPoint] = []
|
||||
|
||||
for i in 0..<(points.count - 1) {
|
||||
let p0 = points[max(0, i - 1)]
|
||||
let p1 = points[i]
|
||||
let p2 = points[min(points.count - 1, i + 1)]
|
||||
let p3 = points[min(points.count - 1, i + 2)]
|
||||
|
||||
for t in 0..<segments {
|
||||
let t0 = CGFloat(t) / CGFloat(segments)
|
||||
let t2 = t0 * t0
|
||||
let t3 = t2 * t0
|
||||
|
||||
let x = 0.5 * ((2 * p1.x) +
|
||||
(-p0.x + p2.x) * t0 +
|
||||
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
||||
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3)
|
||||
let y = 0.5 * ((2 * p1.y) +
|
||||
(-p0.y + p2.y) * t0 +
|
||||
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
||||
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3)
|
||||
|
||||
result.append(CGPoint(x: x, y: y))
|
||||
}
|
||||
}
|
||||
|
||||
result.append(points.last!)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Mask Confirmation
|
||||
|
||||
func confirmMask() async {
|
||||
@@ -496,8 +341,8 @@ final class EditorViewModel {
|
||||
|
||||
func refineWithBrush() {
|
||||
// Save current mask for refinement and switch to brush tool
|
||||
// Keep maskPreview visible so user can see what they're refining
|
||||
pendingRefineMask = maskPreview
|
||||
maskPreview = nil
|
||||
showingMaskConfirmation = false
|
||||
showSelectAllPeople = false
|
||||
selectedTool = .brush
|
||||
@@ -555,8 +400,8 @@ final class EditorViewModel {
|
||||
switch selectedTool {
|
||||
case .person: return .person
|
||||
case .object: return .object
|
||||
case .wire: return .wire
|
||||
case .brush: return .brush
|
||||
case .move: return .move
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,9 @@ struct PhotoEditorView: View {
|
||||
let uiImage = UIImage(data: data) {
|
||||
viewModel.loadImage(uiImage, localIdentifier: localIdentifier)
|
||||
}
|
||||
|
||||
// Reset selection so the same image can be selected again
|
||||
selectedItem = nil
|
||||
}
|
||||
}
|
||||
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
||||
@@ -198,35 +201,25 @@ struct PhotoEditorView: View {
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
.accessibilityLabel("Cancel mask selection")
|
||||
|
||||
Button {
|
||||
viewModel.refineWithBrush()
|
||||
} label: {
|
||||
Label("Refine", systemImage: "paintbrush")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Refine selection with brush")
|
||||
.accessibilityHint("Switch to brush tool to adjust the selection")
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.confirmMask()
|
||||
}
|
||||
} label: {
|
||||
Label("Remove", systemImage: "checkmark")
|
||||
Label("Remove", systemImage: "trash")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.accessibilityLabel("Confirm and remove selected area")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import UIKit
|
||||
enum EditTool: String, CaseIterable, Identifiable {
|
||||
case person = "Person"
|
||||
case object = "Object"
|
||||
case wire = "Wire"
|
||||
case brush = "Brush"
|
||||
case move = "Move/Zoom"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -20,8 +20,8 @@ enum EditTool: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .person: return "person.fill"
|
||||
case .object: return "circle.dashed"
|
||||
case .wire: return "line.diagonal"
|
||||
case .brush: return "paintbrush.fill"
|
||||
case .move: return "arrow.up.and.down.and.arrow.left.and.right"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ enum EditTool: String, CaseIterable, Identifiable {
|
||||
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"
|
||||
case .move: return "Pinch to zoom, drag to move"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ struct ToolbarView: View {
|
||||
Divider()
|
||||
|
||||
// Inspector panel (contextual)
|
||||
if viewModel.selectedTool == .brush || viewModel.selectedTool == .wire || viewModel.selectedTool == .person || viewModel.selectedTool == .object {
|
||||
if viewModel.selectedTool == .brush || viewModel.selectedTool == .person || viewModel.selectedTool == .object {
|
||||
inspectorPanel
|
||||
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
@@ -178,66 +178,33 @@ struct ToolbarView: View {
|
||||
.accessibilityHint("When enabled, brush strokes snap to nearby edges for cleaner selections")
|
||||
}
|
||||
|
||||
if viewModel.selectedTool == .wire {
|
||||
// Line brush toggle
|
||||
HStack {
|
||||
Text("Mode")
|
||||
.font(.subheadline)
|
||||
// Brush tool action buttons
|
||||
if viewModel.selectedTool == .brush {
|
||||
HStack(spacing: 12) {
|
||||
// Clear button
|
||||
Button {
|
||||
viewModel.triggerClearBrushStrokes = true
|
||||
} label: {
|
||||
Label("Clear", systemImage: "trash")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.brushStrokesCount == 0)
|
||||
.accessibilityLabel("Clear all brush strokes")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Remove button
|
||||
Button {
|
||||
viewModel.toggleLineBrushMode()
|
||||
viewModel.triggerApplyBrushMask = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.isLineBrushMode ? "scribble" : "hand.tap")
|
||||
Text(viewModel.isLineBrushMode ? "Line Brush" : "Tap to Detect")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(viewModel.isLineBrushMode ? Color.accentColor : Color(.tertiarySystemFill))
|
||||
)
|
||||
.foregroundStyle(viewModel.isLineBrushMode ? .white : .primary)
|
||||
}
|
||||
.accessibilityLabel(viewModel.isLineBrushMode ? "Line brush mode active" : "Tap to detect mode active")
|
||||
.accessibilityHint("Double tap to toggle between line brush and tap detection modes")
|
||||
}
|
||||
|
||||
if viewModel.isLineBrushMode && !viewModel.lineBrushPath.isEmpty {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.finishLineBrush()
|
||||
}
|
||||
} label: {
|
||||
Label("Apply Line", systemImage: "checkmark.circle.fill")
|
||||
Label("Remove", systemImage: "trash")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityLabel("Apply line brush selection")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Line Width")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text("\(Int(viewModel.wireWidth))px")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Slider(value: $viewModel.wireWidth, in: 2...20, step: 1)
|
||||
.accessibilityLabel("Wire width")
|
||||
.accessibilityValue("\(Int(viewModel.wireWidth)) pixels")
|
||||
|
||||
Stepper("", value: $viewModel.wireWidth, in: 2...20, step: 1)
|
||||
.labelsHidden()
|
||||
.accessibilityLabel("Wire width stepper")
|
||||
.accessibilityValue("\(Int(viewModel.wireWidth)) pixels")
|
||||
.tint(.red)
|
||||
.disabled(viewModel.brushStrokesCount == 0)
|
||||
.accessibilityLabel("Remove selected area")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import Foundation
|
||||
enum ToolType: String, Codable {
|
||||
case person
|
||||
case object
|
||||
case wire
|
||||
case brush
|
||||
case move
|
||||
}
|
||||
|
||||
enum EditOperation: Codable {
|
||||
|
||||
@@ -10,7 +10,7 @@ import CoreGraphics
|
||||
import UIKit
|
||||
import Accelerate
|
||||
|
||||
struct MaskData {
|
||||
struct MaskData: Sendable {
|
||||
let width: Int
|
||||
let height: Int
|
||||
let data: Data
|
||||
@@ -78,32 +78,36 @@ struct MaskData {
|
||||
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)
|
||||
)
|
||||
let error = sourceArray.withUnsafeMutableBufferPointer { sourcePtr -> vImage_Error in
|
||||
destinationArray.withUnsafeMutableBufferPointer { destPtr -> vImage_Error in
|
||||
var sourceBuffer = vImage_Buffer(
|
||||
data: sourcePtr.baseAddress,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: width
|
||||
)
|
||||
|
||||
var destinationBuffer = vImage_Buffer(
|
||||
data: destPtr.baseAddress,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: width
|
||||
)
|
||||
|
||||
return vImageDilate_Planar8(
|
||||
&sourceBuffer,
|
||||
&destinationBuffer,
|
||||
0, 0,
|
||||
kernel,
|
||||
vImagePixelCount(kernelSize),
|
||||
vImagePixelCount(kernelSize),
|
||||
vImage_Flags(kvImageNoFlags)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard error == kvImageNoError else { return nil }
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,18 @@ actor InpaintEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// LaMa inpainter for AI-powered inpainting (initialized lazily on first use)
|
||||
private nonisolated(unsafe) lazy var lamaInpainter: LaMaInpainter = LaMaInpainter()
|
||||
/// LaMa inpainter for AI-powered inpainting (cached on first use)
|
||||
private var _lamaInpainter: LaMaInpainter?
|
||||
private var lamaInpainter: LaMaInpainter {
|
||||
get async {
|
||||
if let existing = _lamaInpainter {
|
||||
return existing
|
||||
}
|
||||
let inpainter = await MainActor.run { LaMaInpainter() }
|
||||
_lamaInpainter = inpainter
|
||||
return inpainter
|
||||
}
|
||||
}
|
||||
|
||||
func inpaintPreview(image: CGImage, mask: CGImage) async throws -> CGImage {
|
||||
// Scale down for preview if needed
|
||||
|
||||
@@ -71,12 +71,17 @@ actor ImagePipeline {
|
||||
|
||||
case .inpaint:
|
||||
if let maskOp = pendingMask {
|
||||
let maskData = MaskData(
|
||||
width: maskOp.maskWidth,
|
||||
height: maskOp.maskHeight,
|
||||
data: maskOp.maskData
|
||||
)
|
||||
if let mask = maskData.toCGImage() {
|
||||
// Create mask data and convert to CGImage on MainActor for Swift 6 compatibility
|
||||
let mask = await MainActor.run {
|
||||
let maskData = MaskData(
|
||||
width: maskOp.maskWidth,
|
||||
height: maskOp.maskHeight,
|
||||
data: maskOp.maskData
|
||||
)
|
||||
return maskData.toCGImage()
|
||||
}
|
||||
|
||||
if let mask = mask {
|
||||
// Scale mask if needed
|
||||
let scaledMask: CGImage
|
||||
if scaleFactor != 1.0 {
|
||||
|
||||
BIN
CheapRetouch/appstore.jpeg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
CheapRetouch/appstore.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
CheapRetouch/playstore.png
Normal file
|
After Width: | Height: | Size: 160 KiB |