Skip to content

Commit

Permalink
Add method for finding minimum rect
Browse files Browse the repository at this point in the history
  • Loading branch information
Arclite committed Jun 18, 2024
1 parent 18e387a commit 1bf83f5
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 35 deletions.
120 changes: 120 additions & 0 deletions Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Created by Geoff Pado on 6/17/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import CoreGraphics
import Foundation

enum MinimumAreaRectFinder {
static func minimumAreaShape(for points: [CGPoint]) -> Shape {
let (rect, angle) = minimumAreaEnclosingRectangle(for: points)!
let rotationTransform = CGAffineTransform(rotationAngle: angle)
let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY)
let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY)

let finalTransform = translationTransform.concatenating(rotationTransform).concatenating(inverseTranslate)

let points = [CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)]
let transformedPoints = points.map { $0.applying(finalTransform) }

return Shape(bottomLeft: transformedPoints[0], bottomRight: transformedPoints[1], topLeft: transformedPoints[2], topRight: transformedPoints[3])
}

private static func minimumAreaEnclosingRectangle(for points: [CGPoint]) -> (CGRect, Double)? {
guard points.count > 2 else { return nil }

var minArea = CGFloat.greatestFiniteMagnitude
var bestRect = CGRect.zero
var bestAngle = Double.zero

for i in 0..<points.count {
for j in i+1..<points.count {
let p1 = points[i]
let p2 = points[j]
let angle = atan2(p2.y - p1.y, p2.x - p1.x)
let rotatedPoints = points.map { rotate(point: $0, around: p1, by: -angle) }
let rect = boundingRect(for: rotatedPoints)
let area = rect.width * rect.height
if area < minArea {
minArea = area
bestRect = rect
bestAngle = angle
}
}
}

return (bestRect, bestAngle)
}

private static func rotate(point: CGPoint, around origin: CGPoint, by angle: CGFloat) -> CGPoint {
let translatedX = point.x - origin.x
let translatedY = point.y - origin.y
let rotatedX = translatedX * cos(angle) - translatedY * sin(angle)
let rotatedY = translatedX * sin(angle) + translatedY * cos(angle)
return CGPoint(x: rotatedX + origin.x, y: rotatedY + origin.y)
}

private static func boundingRect(for points: [CGPoint]) -> CGRect {
let minX = points.map { $0.x }.min() ?? 0
let maxX = points.map { $0.x }.max() ?? 0
let minY = points.map { $0.y }.min() ?? 0
let maxY = points.map { $0.y }.max() ?? 0
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}

// Helper function to find the orientation of the triplet (p, q, r)
// 0 -> p, q and r are collinear
// 1 -> Clockwise
// 2 -> Counterclockwise
private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Int {
let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
if val == 0 { return 0 }
return (val > 0) ? 1 : 2
}

// Helper function to find the square of the distance between two points
private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat {
return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y)
}

// Function to find the convex hull using Graham Scan algorithm
static func convexHull(for points: [CGPoint]) -> [CGPoint] {
guard points.count >= 3 else { return points }

// Find the point with the lowest y-coordinate, break ties by x-coordinate
var minYPoint = points[0]
var minYIndex = 0
for (index, point) in points.enumerated() {
if (point.y < minYPoint.y) || (point.y == minYPoint.y && point.x < minYPoint.x) {
minYPoint = point
minYIndex = index
}
}

// Place the bottom-most point at the first position
var sortedPoints = points
sortedPoints.swapAt(0, minYIndex)

// Sort the remaining points based on the polar angle with the first point
let p0 = sortedPoints[0]
sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in
let o = orientation(p0, p1, p2)
if o == 0 {
return distanceSquared(p0, p1) < distanceSquared(p0, p2)
}
return o == 2
}

// Create an empty stack and push the first three points to it
var stack: [CGPoint] = [sortedPoints[0], sortedPoints[1], sortedPoints[2]]

// Process the remaining points
for i in 3..<sortedPoints.count {
while stack.count > 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != 2 {
stack.removeLast()
}
stack.append(sortedPoints[i])
}

return stack
}
}
14 changes: 1 addition & 13 deletions Modules/Capabilities/Geometry/Sources/Shape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,7 @@ public struct Shape: Hashable {
}

public func union(_ other: Shape) -> Shape {
let allPoints = [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight]

let minX = allPoints.map { $0.x }.min() ?? 0
let minY = allPoints.map { $0.y }.min() ?? 0
let maxX = allPoints.map { $0.x }.max() ?? 0
let maxY = allPoints.map { $0.y }.max() ?? 0

return Shape(
bottomLeft: CGPoint(x: minX, y: maxY),
bottomRight: CGPoint(x: maxX, y: maxY),
topLeft: CGPoint(x: minX, y: minY),
topRight: CGPoint(x: maxX, y: minY)
)
MinimumAreaRectFinder.minimumAreaShape(for: [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight])
}

static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero)
Expand Down
26 changes: 26 additions & 0 deletions Modules/Capabilities/Geometry/Tests/ShapeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,30 @@ final class ShapeTests: XCTestCase {

XCTAssertTrue(shape.isNotEmpty)
}

func testUnionOfRotatedShapes() {
let firstShape = Shape(
bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572),
bottomRight: CGPoint(x: 342.6968497848278, y: 948.305784288113),
topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367),
topRight: CGPoint(x: 269.7560323061029, y: 852.1565330586908)
)

let secondShape = Shape(
bottomLeft: CGPoint(x: 354.71550618850557, y: 939.1881821032728),
bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724),
topLeft: CGPoint(x: 281.7746887097807, y: 843.0389308738502),
topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519)
)

let expectedShape = Shape(
bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572),
bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724),
topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367),
topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519)
)

let unionShape = firstShape.union(secondShape)
XCTAssert(unionShape.path.isEqual(to: expectedShape.path, accuracy: 0.01))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ extension Redaction {
siblingObservations.append(characterObservation)
result[textObservationUUID] = siblingObservations
}.values.map { siblingObservations in
siblingObservations.reduce(siblingObservations[0].bounds, { currentRect, characterObservation in
var siblingObservations = siblingObservations
let firstObservation = siblingObservations.removeFirst()
return siblingObservations.reduce(firstObservation.bounds, { currentRect, characterObservation in
currentRect.union(characterObservation.bounds)
})
}.map(RedactionPart.shape)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// Created by Geoff Pado on 6/17/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Geometry

class PhotoEditingObservationDebugLayer: CAShapeLayer {
init(fillColor: UIColor, frame: CGRect, path: CGPath) {
init(fillColor: UIColor, frame: CGRect, shape: Shape) {
super.init()
self.strokeColor = fillColor.cgColor
self.fillColor = fillColor.withAlphaComponent(0.3).cgColor
self.frame = frame
self.path = path
self.path = shape.path
}

override init(layer: Any) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView {
let wordLayers: [PhotoEditingObservationDebugLayer]
if isRecognizedTextOverlayEnabled {
wordLayers = recognizedTextObservations.flatMap(\.characterObservations).map { observation in
PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, path: observation.bounds.path)
PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, shape: observation.bounds)
}
} else { wordLayers = [] }

Expand All @@ -76,12 +76,12 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView {
let characterLayers: [PhotoEditingObservationDebugLayer]
if isDetectedCharactersOverlayEnabled {
characterLayers = characterObservations.map { observation -> PhotoEditingObservationDebugLayer in
PhotoEditingObservationDebugLayer(fillColor: .systemBlue, frame: bounds, path: observation.bounds.path)
PhotoEditingObservationDebugLayer(fillColor: .systemBlue, frame: bounds, shape: observation.bounds)
}
} else { characterLayers = [] }

if Defaults.Value(key: .showDetectedTextOverlay).wrappedValue {
let textLayer = PhotoEditingObservationDebugLayer(fillColor: .systemRed, frame: bounds, path: CGPath(rect: textObservation.bounds.boundingBox, transform: nil))
let textLayer = PhotoEditingObservationDebugLayer(fillColor: .systemRed, frame: bounds, shape: textObservation.bounds)

return characterLayers + [textLayer]
} else { return characterLayers }
Expand All @@ -92,7 +92,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView {
let wordCharacterLayers: [PhotoEditingObservationDebugLayer]
if isCalculatedOverlayEnabled {
wordCharacterLayers = calculatedObservations.map { (calculatedObservation: CharacterObservation) -> PhotoEditingObservationDebugLayer in
PhotoEditingObservationDebugLayer(fillColor: .systemGreen, frame: bounds, path: calculatedObservation.bounds.path)
PhotoEditingObservationDebugLayer(fillColor: .systemGreen, frame: bounds, shape: calculatedObservation.bounds)
}
} else { wordCharacterLayers = [] }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ actor PhotoEditingObservationCalculator {
childArea + observation.bounds.path.area()
}
let parentArea = parent.bounds.path.area()
return childArea < (parentArea / 2)
return childArea < (parentArea * 0.35)
}.keys

// combine the remaining parent observations and their children
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,20 @@ class RedactionPathLayer: CALayer {
let offsetPath = UIBezierPath(cgPath: shape.path)
offsetPath.apply(offsetTransform)
offsetPath.fill()

context.saveGState()
context.translateBy(x: shape.topLeft.x - frame.origin.x, y: shape.topLeft.y - frame.origin.y)
context.rotate(by: shape.angle)
context.translateBy(x: -(startImage.size.width - 1), y: 0)
context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size))
context.restoreGState()

context.saveGState()
context.translateBy(x: shape.topRight.x - frame.origin.x, y: shape.topRight.y - frame.origin.y)
context.rotate(by: shape.angle)
context.translateBy(x: -1, y: 0)
context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size))
context.restoreGState()
//
// context.saveGState()
// context.translateBy(x: shape.topLeft.x - frame.origin.x, y: shape.topLeft.y - frame.origin.y)
// context.rotate(by: shape.angle)
// context.translateBy(x: -(startImage.size.width - 1), y: 0)
// context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size))
// context.restoreGState()
//
// context.saveGState()
// context.translateBy(x: shape.topRight.x - frame.origin.x, y: shape.topRight.y - frame.origin.y)
// context.rotate(by: shape.angle)
// context.translateBy(x: -1, y: 0)
// context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size))
// context.restoreGState()

case let .path(path, dikembeMutombo):
let dashedPath = path.dashedPath
Expand Down

0 comments on commit 1bf83f5

Please sign in to comment.