22

Currently I am using a CIDetector to detect rectangles in my UIImage. I am doing the suggested way in which you pass the coordinates into a filter to get back a CIImage to put over the taken UIImage. It looks like this:

func performRectangleDetection(image: UIKit.CIImage) -> UIKit.CIImage? {
    var resultImage: UIKit.CIImage?
    let detector:CIDetector = CIDetector(ofType: CIDetectorTypeRectangle, context: nil, options: [CIDetectorAccuracy : CIDetectorAccuracyHigh])
        // Get the detections
        let features = detector.featuresInImage(image)
        for feature in features as! [CIRectangleFeature] {
            resultImage = self.drawHighlightOverlayForPoints(image, topLeft: feature.topLeft, topRight: feature.topRight,
                                                        bottomLeft: feature.bottomLeft, bottomRight: feature.bottomRight)
        }
    return resultImage

}


func drawHighlightOverlayForPoints(image: UIKit.CIImage, topLeft: CGPoint, topRight: CGPoint,
                                   bottomLeft: CGPoint, bottomRight: CGPoint) -> UIKit.CIImage {

    var overlay = UIKit.CIImage(color: CIColor(red: 1.0, green: 0.55, blue: 0.0, alpha: 0.45))
    overlay = overlay.imageByCroppingToRect(image.extent)
    overlay = overlay.imageByApplyingFilter("CIPerspectiveTransformWithExtent",
                                            withInputParameters: [
                                                "inputExtent": CIVector(CGRect: image.extent),
                                                "inputTopLeft": CIVector(CGPoint: topLeft),
                                                "inputTopRight": CIVector(CGPoint: topRight),
                                                "inputBottomLeft": CIVector(CGPoint: bottomLeft),
                                                "inputBottomRight": CIVector(CGPoint: bottomRight)
        ])
    return overlay.imageByCompositingOverImage(image)
}

Calling performRectangleDetection displays the detected rectangle through a CIImage.

It looks something like the picture above. I need to display this same red rectangle with a UIBezierPath that is set to stroke. I need to have this so the user can adjust the detection in case it is not 100% accurate. I have tried to draw a path but it has been unsuccessful. Here is how I am drawing the path. I use a custom class called rect to hold the 4 points. Here is the detection:

func detectRect() -> Rect{
    var rect:Rect?
    let detector:CIDetector = CIDetector(ofType: CIDetectorTypeRectangle, context: nil, options: [CIDetectorAccuracy : CIDetectorAccuracyHigh])
    // Get the detections
    let features = detector.featuresInImage(UIKit.CIImage(image: self)!)
    for feature in features as! [CIRectangleFeature] {
        rect = Rect(tL: feature.topLeft, tR: feature.topRight, bR: feature.bottomRight, bL: feature.bottomLeft)
    }
    return rect!
}

Next, I have to scale the coordinates. Here is the function inside of Rect class that does that:

func scaleRect(image:UIImage, imageView:UIImageView) ->Rect{

    let scaleX = imageView.bounds.width/image.size.width
    var tlx = topLeft.x * scaleX
    var tly = topLeft.y * scaleX
    tlx += (imageView.bounds.width - image.size.width * scaleX) / 2.0
    tly += (imageView.bounds.height - image.size.height * scaleX) / 2.0
    let tl = CGPointMake(tlx, tly)

    var trx = topRight.x * scaleX
    var trY = topRight.y * scaleX
    trx += (imageView.bounds.width - image.size.width * scaleX) / 2.0
    trY += (imageView.bounds.height - image.size.height * scaleX) / 2.0
    let tr = CGPointMake(trx, trY)

    var brx = bottomRight.x * scaleX
    var bry = bottomRight.y * scaleX
    brx += (imageView.bounds.width - image.size.width * scaleX) / 2.0
    bry += (imageView.bounds.height - image.size.height * scaleX) / 2.0
    let br = CGPointMake(brx, bry)

    var blx = bottomLeft.x * scaleX
    var bly = bottomLeft.y * scaleX
    blx += (imageView.bounds.width - image.size.width * scaleX) / 2.0
    bly += (imageView.bounds.height - image.size.height * scaleX) / 2.0
    let bl = CGPointMake(blx, bly)

    let rect = Rect(tL: tl, tR: tr, bR: br, bL: bl)
    return rect
}

Finally I draw the path:

var tet = image.detectRect()
tet = tet.scaleRect(image, imageView: imageView)
let shapeLayer = CAShapeLayer()
let path = ViewController.drawPath(tet.topLeft, p2: tet.topRight, p3: tet.bottomRight, p4: tet.bottomLeft)
shapeLayer.path = path.CGPath
shapeLayer.lineWidth = 5
shapeLayer.fillColor = nil
shapeLayer.strokeColor = UIColor.orangeColor().CGColor
imageView.layer.addSublayer(shapeLayer)

The path has been off the screen and inaccurate. I know I have to adjust the coordinates from CoreImage coordinates to UIKit coordinates and then scale them for the UIImageView. Unfortunately, I do not know how to do that properly. I know I can reuse some of my detection code to accomplish this but I do not know the right steps to take. Any help would be appreciated! Thanks. Here is an example of what is occuring:

Update

In order for testing my scaling that I perform in scaleRect() I decided to make my ImageView size the same size as my image size. I then printed the coordinates before and after the scaling. I would think that because they are the same, my scaling is done properly. Here is the code:

var tet = image.detectRect()
//Before scaling
print(tet.topLeft)
print(tet.topRight)
print(tet.bottomRight)
print(tet.bottomLeft)
print("**************************************************")
//After scaling
tet = tet.scaleRect(image, imageView: imageView)
print(tet.topLeft)
print(tet.topRight)
print(tet.bottomRight)
print(tet.bottomLeft)

Here is the output:

(742.386596679688, 927.240844726562)

(1514.93835449219, 994.811096191406)

(1514.29675292969, 155.2802734375)

(741.837524414062, 208.55403137207)


(742.386596679688, 927.240844726562)

(1514.93835449219, 994.811096191406)

(1514.29675292969, 155.2802734375)

(741.837524414062, 208.55403137207)

Update

I have tried two more things in order to attempt and scale my coordinates.

Number 1: I have tried to use UIView convertPoint function in order to convert point from image to UIImageView. Here is how I coded it: I replaced the scaleRect() function with

let view_image = UIView(frame: CGRectMake(0, 0, image.size.width, image.size.height))
let tL = view_image.convertPoint(self.topLeft, toView: imageView)
let tR = view_image.convertPoint(self.topRight, toView: imageView)
let bR = view_image.convertPoint(self.bottomRight, toView: imageView)
let bL = view_image.convertPoint(self.bottomLeft, toView: imageView)

I then returned a new rect with these points.

Number 2: I tried a simple translation of the coordinates based on the difference in width and height of the image and imageView. Heres the code:

 let widthDiff = (image.size.width - imageView.frame.size.width)
 let highDiff = (image.size.height - imageView.frame.size.height)

 let tL = CGPointMake(self.topLeft.x-widthDiff, self.topLeft.y-highDiff)
 let tR = CGPointMake(self.topRight.x-widthDiff, self.topRight.y-highDiff)
 let bR = CGPointMake(self.bottomRight.x-widthDiff, self.bottomRight.y-highDiff)
 let bL = CGPointMake(self.bottomLeft.x-widthDiff, self.bottomLeft.y-highDiff)

Update I have also tried using CGAffineTransform. Code:

var transform = CGAffineTransformMakeScale(1, -1)
transform = CGAffineTransformTranslate(transform, 0, -imageView.bounds.size.height)
let tL = CGPointApplyAffineTransform(self.topLeft, transform)
let tR = CGPointApplyAffineTransform(self.topRight, transform)
let bR = CGPointApplyAffineTransform(self.bottomRight, transform)
let bL = CGPointApplyAffineTransform(self.bottomLeft, transform)

None have worked. I do not know what else I can try. Please help. It would be greatly appreciated. Thanks!

John55
  • 301
  • 2
  • 11

2 Answers2

7

If you only need to display the path then it's a bit easier to draw the path in a CAShapeLayer.

  1. Add a CAShapeLayer to the preview image.
  2. Calculate the rectangle.
  3. Create a UIBezierPath for the feature.
  4. Transform the path to match the source image.
  5. Set the path to the CAShapeLayer

Some complications arise in step 4 if you need to support scaled images, or images with orientation (i.e. anything from the user's camera).

Below is an example. This supports This code assumes that the image is displayed in a UIImageView with a contentMode of AspectFit, AspectFill, ScaleToFill, or Centre. It also supports images with an orientation Up, Down, Right and Left.

// Extension for calculating the image scale in an image view.
// See: http://stackoverflow.com/questions/6856879/iphone-getting-the-size-of-an-image-after-aspectft
extension UIImageView {

    var imageScale: CGSize? {

        guard let image = image else {
            return nil
        }

        let sx = Double(self.frame.size.width / image.size.width)
        let sy = Double(self.frame.size.height / image.size.height)
        var s = 1.0
        switch (self.contentMode) {
        case .ScaleAspectFit:
            s = fmin(sx, sy)
            return CGSize (width: s, height: s)

        case .ScaleAspectFill:
            s = fmax(sx, sy)
            return CGSize(width:s, height:s)

        case .ScaleToFill:
            return CGSize(width:sx, height:sy)

        default:
            return CGSize(width:s, height:s)
        }
    }
}

// Extension which provides a transform to rotate the image based on it's orientation metadata. 
extension UIImageView {

    var normalizedTransformForOrientation: CGAffineTransform? {

        guard let image = image else {
            return nil
        }

        let r: CGFloat

        switch image.imageOrientation {

        case .Up:
            r = 0

        case .Down:
            r = +1.0

        case .Left:
            r = -0.5

        case .Right:
            r = +0.5

        default:
            fatalError()
        }

        let cx = CGRectGetMidX(bounds)
        let cy = CGRectGetMidY(bounds)

        var transform = CGAffineTransformIdentity
        transform = CGAffineTransformTranslate(transform, cx, cy)
        transform = CGAffineTransformRotate(transform, CGFloat(M_PI) * r)
        transform = CGAffineTransformTranslate(transform, -cx, -cy)
        return transform
    }
}

class ViewController: UIViewController {

    // Shape layer for displaying the path.
    let pathLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.fillColor = UIColor.greenColor().colorWithAlphaComponent(0.3).CGColor
        layer.strokeColor = UIColor.greenColor().colorWithAlphaComponent(0.9).CGColor
        layer.lineWidth = 2.0
        return layer
    }()

    // Image view where the preview and path overlay will be displayed.
    @IBOutlet var imageView: UIImageView?

    override func viewDidLoad() {

        super.viewDidLoad()

        // Add the path overlay to the image view.
        imageView?.layer.addSublayer(pathLayer)

        // Load a sample image from the assets.
        selectImage(UIImage(named: "sample"))
    }

    func selectImage(image: UIImage?) {

        imageView?.image = image

        if let image = image {
            processImage(image)
        }
    }

    // Detect rectangles in image, and draw the path on the screen.
    func processImage(input: UIImage) {

        let path = pathsForRectanglesInImage(input)

        let transform = pathTransformForImageView(imageView!)
        path?.applyTransform(transform)

        pathLayer.path = path?.CGPath
    }

    // Detect rectangles in an image and return a UIBezierPath.
    func pathsForRectanglesInImage(input: UIImage) -> UIBezierPath? {

        guard let sourceImage = CIImage(image: input) else {
            return nil
        }

        let features = performRectangleDetection(sourceImage)

        return pathForFeatures(features)
    }

    // Detect rectangles in image.
    func performRectangleDetection(image: CIImage) -> [CIFeature] {

        let detector:CIDetector = CIDetector(
            ofType: CIDetectorTypeRectangle,
            context: nil,
            options: [CIDetectorAccuracy : CIDetectorAccuracyHigh]
        )

        let features = detector.featuresInImage(image)

        return features
    }

    // Compose a UIBezierPath from CIRectangleFeatures. 
    func pathForFeatures(features: [CIFeature]) -> UIBezierPath {

        let path = UIBezierPath()

        for feature in features {

            guard let rect = feature as? CIRectangleFeature else {
                continue
            }

            path.moveToPoint(rect.topLeft)
            path.addLineToPoint(rect.topRight)
            path.addLineToPoint(rect.bottomRight)
            path.addLineToPoint(rect.bottomLeft)
            path.closePath()
        }

        return path
    }

    // Calculate the transform to orient the preview path to the image shown inside the image view.
    func pathTransformForImageView(imageView: UIImageView) -> CGAffineTransform {

        guard let image = imageView.image else {
            return CGAffineTransformIdentity
        }

        guard let imageScale = imageView.imageScale else {
            return CGAffineTransformIdentity
        }

        guard let imageTransform = imageView.normalizedTransformForOrientation else {
            return CGAffineTransformIdentity
        }

        let frame = imageView.frame

        let imageWidth = image.size.width * imageScale.width
        let imageHeight = image.size.height * imageScale.height

        var transform = CGAffineTransformIdentity

        // Rotate to match the image orientation.
        transform = CGAffineTransformConcat(imageTransform, transform)

        // Flip vertically (flipped in CIDetector).
        transform = CGAffineTransformTranslate(transform, 0, CGRectGetHeight(frame))
        transform = CGAffineTransformScale(transform, 1.0, -1.0)

        // Centre align.
        let tx: CGFloat = (CGRectGetWidth(frame) - imageWidth) * 0.5
        let ty: CGFloat = (CGRectGetHeight(frame) - imageHeight) * 0.5
        transform = CGAffineTransformTranslate(transform, tx, ty)

        // Scale to match UIImageView scaling.
        transform = CGAffineTransformScale(transform, imageScale.width, imageScale.height)

        return transform
    }
}

Detected rectangle with stroked overlay

Luke Van In
  • 5,215
  • 2
  • 24
  • 45
  • Hey Luke, Once again thanks for the response. I copy and pasted your code exactly but the bezier path is still off in that it is in the lower left hand side of the view as opposed to on the image. Other than that it is scaled appropriately. I cannot seem to figure it out – John55 Jul 11 '16 at 02:20
  • @John55 I have updated the answer to clarify which use cases are supported. If your question covers a different use case (i.e. other orientations, content mode, or any other conditions) please add that and I will update my answer. – Luke Van In Jul 11 '16 at 08:20
  • @John55 That will happen if the scale of the source image is greater than 1. i.e. if it's loaded from a file with an 2x feel extension. How are you obtaining the image? – Luke Van In Jul 11 '16 at 19:26
  • I'm using images from the camera or loaded from xcassets (to test), and am experiencing this issue, the path is scaled correctly but its off to the left and bottom by around 60points. When I rotate the source CIImage and points coordinates by .pi/2, the path is right on! Only on portrait orientations it goes off. Any idea what might be wrong here? – Rafael Nobre Sep 01 '17 at 12:04
  • The implicit orientation and scale of the source image makes a difference. Ensure that the image has no orientation, and that it is loaded at 1:1 scale. This code does not account for image orientation or scale. Ensure that `image.imageOrientation == .up` and `scale == 1`. E.g. If you take a photo in landscape mode, it is actually stored rotated at 90°, and rotated back to the correct orientation when it's displayed. – Luke Van In Sep 02 '17 at 10:05
  • This is precisely what I'm going through. I want to be able to allow the user to rotate the image and the transforms as desired, but it only works on one axis (the original stored orientation). I'm applying an affine rotation by .pi/2 on both the ciimage and my rectangle feature points, but it goes awry – Rafael Nobre Sep 11 '17 at 18:16
  • 1
    I was following your example to transform coordinates for an image captured by the device camera. It worked great from an image from the assets but when trying to use a captured image the detection lines drawn seemed to be rotated off of the object by 90 degrees, similar to the image in the initial question. It's bizarre and hacky but by rotating my output image by pi * 2, 360 degrees, corrected the problem. I don't understand how this worked for me but I figure it shouldn't harm anything in the future as its just a 360 degree turn. – Trevor Jul 17 '18 at 10:26
  • This worked for me, I had to do a slight change as I was using previewLayer , and image size is greater than the previewLayer size. CGSize imageSize = _ciImage.extent.size; CGFloat widthScale = previewLayer.frame.size.width/imageSize.width; CGFloat heightScale = previewLayer.frame.size.height/imageSize.height; CGSize imageScale = CGSizeMake(widthScale, heightScale); – Shihab May 13 '19 at 18:50
3

I've been struggling with the same problem for a few days, and this is how I overcame the problem:

I made a custom class to store the points and add some helper functions:

//
//  ObyRectangleFeature.swift
//
//  Created by 4oby on 5/20/16.
//  Copyright © 2016 cvv. All rights reserved.
//

import Foundation
import UIKit

extension CGPoint {
    func scalePointByCeficient(ƒ_x: CGFloat, ƒ_y: CGFloat) -> CGPoint {
        return CGPoint(x: self.x/ƒ_x, y: self.y/ƒ_y) //original image
    }

    func reversePointCoordinates() -> CGPoint {
        return CGPoint(x: self.y, y: self.x)
    }

    func sumPointCoordinates(add: CGPoint) -> CGPoint {
        return CGPoint(x: self.x + add.x, y: self.y + add.y)
    }

    func substractPointCoordinates(sub: CGPoint) -> CGPoint {
        return CGPoint(x: self.x - sub.x, y: self.y - sub.y)
    }
}

class ObyRectangleFeature : NSObject {

    var topLeft: CGPoint!
    var topRight: CGPoint!
    var bottomLeft: CGPoint!
    var bottomRight: CGPoint!

    var centerPoint : CGPoint{
        get {
            let centerX = ((topLeft.x + bottomLeft.x)/2 + (topRight.x + bottomRight.x)/2)/2
            let centerY = ((topRight.y + topLeft.y)/2 + (bottomRight.y + bottomLeft.y)/2)/2
            return CGPoint(x: centerX, y: centerY)
        }

    }

    convenience init(_ rectangleFeature: CIRectangleFeature) {
        self.init()
        topLeft = rectangleFeature.topLeft
        topRight = rectangleFeature.topRight
        bottomLeft = rectangleFeature.bottomLeft
        bottomRight = rectangleFeature.bottomRight
    }

    override init() {
        super.init()
    }


    func rotate90Degree() -> Void {

        let centerPoint =  self.centerPoint

//        /rotate cos(90)=0, sin(90)=1
        topLeft = CGPoint(x: centerPoint.x + (topLeft.y - centerPoint.y), y: centerPoint.y + (topLeft.x - centerPoint.x))
        topRight = CGPoint(x: centerPoint.x + (topRight.y - centerPoint.y), y: centerPoint.y + (topRight.x - centerPoint.x))
        bottomLeft = CGPoint(x: centerPoint.x + (bottomLeft.y - centerPoint.y), y: centerPoint.y + (bottomLeft.x - centerPoint.x))
        bottomRight = CGPoint(x: centerPoint.x + (bottomRight.y - centerPoint.y), y: centerPoint.y + (bottomRight.x - centerPoint.x))
    }

    func  scaleRectWithCoeficient(ƒ_x: CGFloat, ƒ_y: CGFloat) -> Void {
        topLeft =  topLeft.scalePointByCeficient(ƒ_x, ƒ_y: ƒ_y)
        topRight = topRight.scalePointByCeficient(ƒ_x, ƒ_y: ƒ_y)
        bottomLeft = bottomLeft.scalePointByCeficient(ƒ_x, ƒ_y: ƒ_y)
        bottomRight = bottomRight.scalePointByCeficient(ƒ_x, ƒ_y: ƒ_y)
    }

    func correctOriginPoints() -> Void {

        let deltaCenter = self.centerPoint.reversePointCoordinates().substractPointCoordinates(self.centerPoint)

        let TL = topLeft
        let TR = topRight
        let BL = bottomLeft
        let BR = bottomRight

        topLeft = BL.sumPointCoordinates(deltaCenter)
        topRight = TL.sumPointCoordinates(deltaCenter)
        bottomLeft = BR.sumPointCoordinates(deltaCenter)
        bottomRight = TR.sumPointCoordinates(deltaCenter)
    }
}

And this is the initialization code :

let scalatedRect : ObyRectangleFeature = ObyRectangleFeature(rectangleFeature)
        // fromSize -> Initial size of the CIImage
        // toSize -> the size of the scaled Image
        let ƒ_x = (fromSize.width/toSize.width)
        let ƒ_y = (fromSize.height/toSize.height)

        /*the coeficients are interchange intentionally cause of the different
        coordinate system used by CIImage and UIImage, you could rotate before 
        scaling, to preserve the order, but if you do, the result will be offCenter*/

        scalatedRect.scaleRectWithCoeficient(ƒ_y, ƒ_y: ƒ_x)
        scalatedRect.rotate90Degree()
        scalatedRect.correctOriginPoints()

At this point scaleRect is ready to be drawn any way you like.

4oby
  • 607
  • 10
  • 19
  • can I use toSize as the size of the imageView? – John55 Jul 11 '16 at 12:57
  • yes, the size of the imageView is the final size of the image, thus the size of the scaled image – 4oby Jul 11 '16 at 13:57
  • Oby once again thanks for the response. I was wondering if I needed to reverse the points to get back to the original points, would that be possible with this code or is that a whole other issue? – John55 Jul 11 '16 at 14:58
  • with this code, you could run `scalatedRect.rotate90Degree() scalatedRect.correctOriginPoints()` 3 more times to get the full 360 degree turn, but I have not tested it, so not sure. Or you could add a function to rotate to 270 degree but you'll have to do the math. I think it's way easier to just save them by adding 4 more points like `originalTopLeft`and so on. – 4oby Jul 12 '16 at 06:22
  • Not working right for me. I have portrait only app and when i use this class all 4 points are in wrong places. I tried without rotation and ended up with same result. I did passed ciimage.extent.size.width as fromsize and uiimageview.size as tosize. Any idea? – Hassy May 19 '17 at 08:57