Integrate services with UI via EditorViewModel

- Add EditorViewModel coordinating masking, contour detection, and inpainting
- Connect PhotoEditorView to viewModel with mask confirmation bar
- Add processing overlay and error toast UI
- Update CanvasView with tap-to-mask functionality
- Add coordinate conversion from view space to image space
- Update ToolbarView to bind to viewModel state
- Wire up undo/redo actions through viewModel

Person, object, and wire removal now flow:
tap -> detect -> preview mask -> confirm -> inpaint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 23:29:21 -05:00
parent 20707da268
commit daa8ba82bb
5 changed files with 549 additions and 94 deletions

View File

@@ -4,7 +4,11 @@
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(git ls-remote:*)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd Ralph Wiggum agent setup and project specifications\n\n- Add project constitution with vision, principles, and autonomy settings\n- Add 15 feature specifications covering full app scope\n- Configure agent entry points \\(AGENTS.md, CLAUDE.md\\)\n- Add build prompt and speckit command for spec creation\n- Include comprehensive .gitignore for iOS development\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(./check_build.sh:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplement core project structure and foundation components\n\nProject Setup \\(Spec 01\\):\n- Configure iOS 17.0 deployment target\n- Add photo library usage descriptions\n- Create proper folder structure \\(App, Features, Services, Models, Utilities\\)\n\nData Model \\(Spec 02\\):\n- Add EditOperation enum with mask, inpaint, adjustment cases\n- Add MaskOperation, InpaintOperation structs \\(Codable\\)\n- Add Project model with operation stack and undo/redo support\n- Add MaskData with dilation support via vImage\n\nServices \\(Specs 03-05\\):\n- Add InpaintEngine with Metal + Accelerate fallback\n- Add MaskingService wrapping Vision framework\n- Add ContourService for wire/line detection and scoring\n\nUI Components \\(Specs 06-08\\):\n- Add PhotoEditorView with photo picker integration\n- Add CanvasView with pinch-to-zoom and pan gestures\n- Add ToolbarView with tool selection and inspector panel\n\nUtilities:\n- Add ImagePipeline for preview/export rendering\n- Add EdgeRefinement for smart brush edge detection\n- Add check_build.sh for CI verification\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git push)"
]
}
}

View File

@@ -9,13 +9,14 @@ import SwiftUI
import UIKit
struct CanvasView: View {
let image: UIImage
@Bindable var viewModel: EditorViewModel
@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
@State private var viewSize: CGSize = .zero
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 10.0
@@ -25,68 +26,170 @@ struct CanvasView: View {
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
}
}
if let cgImage = isShowingOriginal ? viewModel.originalImage : viewModel.displayImage {
// Main image
Image(decorative: cgImage, scale: 1.0)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
// Mask overlay
if let mask = viewModel.maskPreview {
Image(decorative: mask, scale: 1.0)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
.blendMode(.multiply)
.colorMultiply(.red.opacity(0.5))
}
}
}
.contentShape(Rectangle())
.gesture(tapGesture(in: geometry))
.gesture(magnificationGesture(in: geometry))
.simultaneousGesture(dragGesture(in: geometry))
.simultaneousGesture(longPressGesture)
.onTapGesture(count: 2) {
doubleTapZoom()
}
.onAppear {
viewSize = geometry.size
}
.onChange(of: geometry.size) { _, newSize in
viewSize = newSize
}
}
.clipped()
}
// MARK: - Gestures
private func tapGesture(in geometry: GeometryProxy) -> some Gesture {
SpatialTapGesture()
.onEnded { value in
guard !viewModel.isProcessing,
!viewModel.showingMaskConfirmation,
viewModel.selectedTool != .brush else { return }
let imagePoint = convertViewPointToImagePoint(value.location, in: geometry.size)
Task {
await viewModel.handleTap(at: 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
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)
}
}
}
private var longPressGesture: some Gesture {
LongPressGesture(minimumDuration: 0.3)
.onEnded { _ in
// Toggle to show original briefly
isShowingOriginal = true
Task {
try? await Task.sleep(for: .seconds(0.5))
isShowingOriginal = false
}
}
}
private func doubleTapZoom() {
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
}
}
}
// MARK: - Coordinate Conversion
private func convertViewPointToImagePoint(_ viewPoint: CGPoint, in viewSize: CGSize) -> CGPoint {
let imageSize = viewModel.imageSize
guard imageSize.width > 0, imageSize.height > 0 else { return viewPoint }
// Calculate the displayed image frame
let imageAspect = imageSize.width / imageSize.height
let viewAspect = viewSize.width / viewSize.height
let displayedSize: CGSize
if imageAspect > viewAspect {
displayedSize = CGSize(
width: viewSize.width,
height: viewSize.width / imageAspect
)
} else {
displayedSize = CGSize(
width: viewSize.height * imageAspect,
height: viewSize.height
)
}
// Account for centering
let displayedOrigin = CGPoint(
x: (viewSize.width - displayedSize.width) / 2,
y: (viewSize.height - displayedSize.height) / 2
)
// Account for scale and offset
let scaledSize = CGSize(
width: displayedSize.width * scale,
height: displayedSize.height * scale
)
let scaledOrigin = CGPoint(
x: (viewSize.width - scaledSize.width) / 2 + offset.width,
y: (viewSize.height - scaledSize.height) / 2 + offset.height
)
// Convert view point to image coordinates
let relativeX = (viewPoint.x - scaledOrigin.x) / scaledSize.width
let relativeY = (viewPoint.y - scaledOrigin.y) / scaledSize.height
return CGPoint(
x: relativeX * imageSize.width,
y: relativeY * imageSize.height
)
}
// MARK: - Helpers
private func clampOffset(in size: CGSize) {
guard scale > 1.0 else {
offset = .zero
@@ -94,7 +197,10 @@ struct CanvasView: View {
return
}
let imageAspect = image.size.width / image.size.height
let imageSize = viewModel.imageSize
guard imageSize.width > 0, imageSize.height > 0 else { return }
let imageAspect = imageSize.width / imageSize.height
let viewAspect = size.width / size.height
let imageDisplaySize: CGSize
@@ -125,5 +231,6 @@ struct CanvasView: View {
}
#Preview {
CanvasView(image: UIImage(systemName: "photo")!)
let viewModel = EditorViewModel()
return CanvasView(viewModel: viewModel)
}

View File

@@ -0,0 +1,249 @@
//
// EditorViewModel.swift
// CheapRetouch
//
// View model coordinating edit operations between services and UI.
//
import SwiftUI
import UIKit
import CoreGraphics
import Observation
@Observable
@MainActor
final class EditorViewModel {
// MARK: - State
var originalImage: CGImage?
var editedImage: CGImage?
var maskPreview: CGImage?
var selectedTool: EditTool = .person
var brushSize: CGFloat = 20
var featherAmount: CGFloat = 4
var wireWidth: CGFloat = 6
var isProcessing = false
var processingMessage = ""
var errorMessage: String?
var showingMaskConfirmation = false
private(set) var project: Project?
// MARK: - Services
private let maskingService = MaskingService()
private let contourService = ContourService()
private let inpaintEngine = InpaintEngine()
private let imagePipeline = ImagePipeline()
// MARK: - Computed Properties
var displayImage: CGImage? {
editedImage ?? originalImage
}
var canUndo: Bool {
project?.canUndo ?? false
}
var canRedo: Bool {
project?.canRedo ?? false
}
var imageSize: CGSize {
guard let image = originalImage else { return .zero }
return CGSize(width: image.width, height: image.height)
}
// MARK: - Image Loading
func loadImage(_ uiImage: UIImage) {
guard let cgImage = uiImage.cgImage else { return }
originalImage = cgImage
editedImage = nil
maskPreview = nil
errorMessage = nil
// Create new project
let imageData = uiImage.jpegData(compressionQuality: 0.9) ?? Data()
project = Project(imageSource: .embedded(data: imageData))
}
// MARK: - Tap Handling
func handleTap(at point: CGPoint) async {
guard let image = displayImage else { return }
isProcessing = true
errorMessage = nil
do {
switch selectedTool {
case .person:
processingMessage = "Detecting person..."
try await handlePersonTap(at: point, in: image)
case .object:
processingMessage = "Detecting object..."
try await handleObjectTap(at: point, in: image)
case .wire:
processingMessage = "Detecting wire..."
try await handleWireTap(at: point, in: image)
case .brush:
// Brush tool handles drawing directly in CanvasView
break
}
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
processingMessage = ""
}
private func handlePersonTap(at point: CGPoint, in image: CGImage) async throws {
let mask = try await maskingService.generatePersonMask(at: point, in: image)
guard let mask = mask else {
errorMessage = "No person found at tap location"
return
}
maskPreview = mask
showingMaskConfirmation = true
}
private func handleObjectTap(at point: CGPoint, in image: CGImage) async throws {
let mask = try await maskingService.generateForegroundMask(at: point, in: image)
guard let mask = mask else {
errorMessage = "Couldn't detect object. Try the brush tool to select manually."
return
}
maskPreview = mask
showingMaskConfirmation = true
}
private func handleWireTap(at point: CGPoint, in image: CGImage) async throws {
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. Use the 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: - Mask Confirmation
func confirmMask() async {
guard let image = displayImage, let mask = maskPreview else { return }
isProcessing = true
processingMessage = "Removing..."
do {
let result = try await inpaintEngine.inpaint(image: image, mask: mask)
editedImage = result
// Add operation to project
if var project = project {
let maskData = MaskData(from: mask)?.data ?? Data()
let maskOp = MaskOperation(toolType: toolTypeForCurrentTool(), maskData: maskData)
let inpaintOp = InpaintOperation(maskOperationId: maskOp.id, featherAmount: Float(featherAmount))
project.addOperation(.mask(maskOp))
project.addOperation(.inpaint(inpaintOp))
self.project = project
}
} catch {
errorMessage = error.localizedDescription
}
maskPreview = nil
showingMaskConfirmation = false
isProcessing = false
processingMessage = ""
}
func cancelMask() {
maskPreview = nil
showingMaskConfirmation = false
}
// MARK: - Undo/Redo
func undo() async {
guard var project = project, project.undo() else { return }
self.project = project
await rebuildEditedImage()
}
func redo() async {
guard var project = project, project.redo() else { return }
self.project = project
await rebuildEditedImage()
}
private func rebuildEditedImage() async {
guard let original = originalImage, let project = project else { return }
isProcessing = true
processingMessage = "Rebuilding..."
do {
let result = try await imagePipeline.renderPreview(
originalImage: original,
operations: project.activeOperations
)
editedImage = result
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
processingMessage = ""
}
// MARK: - Brush Tool
func applyBrushMask(_ mask: CGImage) async {
maskPreview = mask
await confirmMask()
}
// MARK: - Helpers
private func toolTypeForCurrentTool() -> ToolType {
switch selectedTool {
case .person: return .person
case .object: return .object
case .wire: return .wire
case .brush: return .brush
}
}
}

View File

@@ -10,24 +10,39 @@ import PhotosUI
import UIKit
struct PhotoEditorView: View {
@State private var selectedImage: UIImage?
@State private var viewModel = EditorViewModel()
@State private var selectedItem: PhotosPickerItem?
@State private var isShowingPicker = false
var body: some View {
NavigationStack {
ZStack {
Color(.systemBackground)
Color.black
.ignoresSafeArea()
if let image = selectedImage {
if viewModel.originalImage != nil {
VStack(spacing: 0) {
CanvasView(image: image)
ToolbarView()
CanvasView(viewModel: viewModel)
if viewModel.showingMaskConfirmation {
maskConfirmationBar
}
ToolbarView(viewModel: viewModel)
}
} else {
EmptyStateView(isShowingPicker: $isShowingPicker)
}
// Processing overlay
if viewModel.isProcessing {
processingOverlay
}
// Error toast
if let error = viewModel.errorMessage {
errorToast(message: error)
}
}
.navigationTitle("CheapRetouch")
.navigationBarTitleDisplayMode(.inline)
@@ -36,19 +51,98 @@ struct PhotoEditorView: View {
PhotosPicker(selection: $selectedItem, matching: .images) {
Image(systemName: "photo.on.rectangle")
}
.disabled(viewModel.isProcessing)
}
if viewModel.originalImage != nil {
ToolbarItem(placement: .topBarTrailing) {
Button {
// Export action
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(viewModel.isProcessing || viewModel.editedImage == nil)
}
}
}
.onChange(of: selectedItem) { oldValue, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self),
let uiImage = UIImage(data: data) {
selectedImage = uiImage
viewModel.loadImage(uiImage)
}
}
}
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
}
}
private var maskConfirmationBar: some View {
HStack(spacing: 20) {
Button {
viewModel.cancelMask()
} label: {
Label("Cancel", systemImage: "xmark")
.font(.headline)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
Button {
Task {
await viewModel.confirmMask()
}
} label: {
Label("Remove", systemImage: "checkmark")
.font(.headline)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(.ultraThinMaterial)
}
private var processingOverlay: some View {
VStack(spacing: 12) {
ProgressView()
.scaleEffect(1.2)
if !viewModel.processingMessage.isEmpty {
Text(viewModel.processingMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private func errorToast(message: String) -> some View {
VStack {
Spacer()
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(message)
.font(.subheadline)
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.padding()
.padding(.bottom, 80)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
Task {
try? await Task.sleep(for: .seconds(3))
viewModel.errorMessage = nil
}
}
}
}
struct EmptyStateView: View {

View File

@@ -24,7 +24,7 @@ enum EditTool: String, CaseIterable, Identifiable {
}
}
var description: String {
var toolDescription: String {
switch self {
case .person: return "Tap to remove people"
case .object: return "Tap to remove objects"
@@ -35,19 +35,14 @@ enum EditTool: String, CaseIterable, Identifiable {
}
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
@Bindable var viewModel: EditorViewModel
var body: some View {
VStack(spacing: 0) {
Divider()
// Inspector panel (contextual)
if selectedTool == .brush || selectedTool == .wire {
if viewModel.selectedTool == .brush || viewModel.selectedTool == .wire {
inspectorPanel
.transition(.move(edge: .bottom).combined(with: .opacity))
}
@@ -63,35 +58,39 @@ struct ToolbarView: View {
// Undo/Redo
Button {
// Undo action
Task {
await viewModel.undo()
}
} label: {
Image(systemName: "arrow.uturn.backward")
.font(.title3)
.frame(width: 44, height: 44)
}
.disabled(!canUndo)
.disabled(!viewModel.canUndo || viewModel.isProcessing)
.accessibilityLabel("Undo")
Button {
// Redo action
Task {
await viewModel.redo()
}
} label: {
Image(systemName: "arrow.uturn.forward")
.font(.title3)
.frame(width: 44, height: 44)
}
.disabled(!canRedo)
.disabled(!viewModel.canRedo || viewModel.isProcessing)
.accessibilityLabel("Redo")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.systemBackground))
}
.animation(.easeInOut(duration: 0.2), value: selectedTool)
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedTool)
}
private func toolButton(for tool: EditTool) -> some View {
Button {
selectedTool = tool
viewModel.selectedTool = tool
} label: {
VStack(spacing: 4) {
Image(systemName: tool.icon)
@@ -101,51 +100,52 @@ struct ToolbarView: View {
Text(tool.rawValue)
.font(.caption2)
}
.foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary)
.foregroundStyle(viewModel.selectedTool == tool ? Color.accentColor : Color.secondary)
.frame(width: 60)
}
.disabled(viewModel.isProcessing)
.accessibilityLabel("\(tool.rawValue) tool")
.accessibilityHint(tool.description)
.accessibilityAddTraits(selectedTool == tool ? .isSelected : [])
.accessibilityHint(tool.toolDescription)
.accessibilityAddTraits(viewModel.selectedTool == tool ? .isSelected : [])
}
private var inspectorPanel: some View {
VStack(spacing: 12) {
Divider()
if selectedTool == .brush {
if viewModel.selectedTool == .brush {
HStack {
Text("Brush Size")
.font(.subheadline)
Spacer()
Text("\(Int(brushSize))px")
Text("\(Int(viewModel.brushSize))px")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
HStack {
Slider(value: $brushSize, in: 1...100, step: 1)
Slider(value: $viewModel.brushSize, in: 1...100, step: 1)
.accessibilityLabel("Brush size slider")
Stepper("", value: $brushSize, in: 1...100, step: 1)
Stepper("", value: $viewModel.brushSize, in: 1...100, step: 1)
.labelsHidden()
.accessibilityLabel("Brush size stepper")
}
}
if selectedTool == .wire {
if viewModel.selectedTool == .wire {
HStack {
Text("Line Width")
.font(.subheadline)
Spacer()
Text("\(Int(wireWidth))px")
Text("\(Int(viewModel.wireWidth))px")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $wireWidth, in: 2...20, step: 1)
Slider(value: $viewModel.wireWidth, in: 2...20, step: 1)
.accessibilityLabel("Wire width slider")
}
@@ -153,13 +153,13 @@ struct ToolbarView: View {
Text("Feather")
.font(.subheadline)
Spacer()
Text("\(Int(featherAmount))px")
Text("\(Int(viewModel.featherAmount))px")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $featherAmount, in: 0...20, step: 1)
Slider(value: $viewModel.featherAmount, in: 0...20, step: 1)
.accessibilityLabel("Feather amount slider")
}
.padding(.horizontal)
@@ -169,8 +169,9 @@ struct ToolbarView: View {
}
#Preview {
VStack {
let viewModel = EditorViewModel()
return VStack {
Spacer()
ToolbarView()
ToolbarView(viewModel: viewModel)
}
}