Adam Eisfeld
Adam Eisfeld

Reputation: 1464

How to draw a border (stroke) around a UIImage's opaque pixels

Given a UIImage that contains non-opaque (alpha < 1) pixel data, how can we draw an outline / border / stroke around the pixels that are opaque (alpha > 0), with a custom stroke color and thickness? (I'm asking this question to provide an answer below)

Upvotes: 1

Views: 1499

Answers (1)

Adam Eisfeld
Adam Eisfeld

Reputation: 1464

I've come up with the following approach by piecing together suggestions from other SO posts and adapting them to something I'm happy with.

The first step is to obtain a UIImage to begin processing. In some cases you might already have this image, but in the event that you might want to add a stroke to a UIView (maybe a UILabel with a custom font), you'll first want to capture an image of that view:

public extension UIView {

    /// Renders the view to a UIImage
    /// - Returns: A UIImage representing the view
    func imageByRenderingView() -> UIImage {
        layoutIfNeeded()
        let rendererFormat = UIGraphicsImageRendererFormat.default()
        rendererFormat.scale = layer.contentsScale
        rendererFormat.opaque = false
        let renderer = UIGraphicsImageRenderer(size: bounds.size, format: rendererFormat)
        let image = renderer.image { _ in
            self.drawHierarchy(in: self.bounds, afterScreenUpdates: true)
        }
        return image
    }

}

Now that we can obtain an image of a view, we need to be able to crop it to it's opaque pixels. This step is optional, but for things like UILabels, sometimes their bounds are bigger than the pixels they are displaying. The below function takes a completion block so it can perform the heavy lifting on a background thread (note: UIKit isn't thread safe, but CGContexts are).

public extension UIImage {

    /// Converts the image's color space to the specified color space
    /// - Parameter colorSpace: The color space to convert to
    /// - Returns: A CGImage in the specified color space
    func cgImageInColorSpace(_ colorSpace: CGColorSpace) -> CGImage? {
        guard let cgImage = self.cgImage else {
            return nil
        }

        guard cgImage.colorSpace != colorSpace else {
            return cgImage
        }

        let rect = CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)

        let ciImage = CIImage(cgImage: cgImage)
        guard let convertedImage = ciImage.matchedFromWorkingSpace(to: CGColorSpaceCreateDeviceRGB()) else {
            return nil
        }

        let ciContext = CIContext()
        let convertedCGImage = ciContext.createCGImage(convertedImage, from: rect)

        return convertedCGImage
    }

    /// Crops the image to the bounding box containing it's opaque pixels, trimming away fully transparent pixels
    /// - Parameter minimumAlpha: The minimum alpha value to crop out of the image
    /// - Parameter completion: A completion block to execute as the processing takes place on a background thread
    func imageByCroppingToOpaquePixels(withMinimumAlpha minimumAlpha: CGFloat = 0, _ completion: @escaping ((_ image: UIImage)->())) {

        guard let originalImage = cgImage else {
            completion(self)
            return
        }

        // Move to a background thread for the heavy lifting
        DispatchQueue.global(qos: .background).async {

            // Ensure we have the correct colorspace so we can safely iterate over the pixel data
            let colorSpace = CGColorSpaceCreateDeviceRGB()
            guard let cgImage = self.cgImageInColorSpace(colorSpace) else {
                DispatchQueue.main.async {
                    completion(UIImage())
                }
                return
            }

            // Store some helper variables for iterating the pixel data
            let width: Int = cgImage.width
            let height: Int = cgImage.height
            let bytesPerPixel: Int = cgImage.bitsPerPixel / 8
            let bytesPerRow: Int = cgImage.bytesPerRow
            let bitsPerComponent: Int = cgImage.bitsPerComponent
            let bitmapInfo: UInt32 = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue

            // Attempt to access our pixel data
            guard
                let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo),
                let ptr = context.data?.assumingMemoryBound(to: UInt8.self) else {
                    DispatchQueue.main.async {
                        completion(UIImage())
                    }
                    return
            }

            context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

            var minX: Int = width
            var minY: Int = height
            var maxX: Int = 0
            var maxY: Int = 0

            for x in 0 ..< width {
                for y in 0 ..< height {

                    let pixelIndex = bytesPerRow * Int(y) + bytesPerPixel * Int(x)
                    let alphaAtPixel = CGFloat(ptr[pixelIndex + 3]) / 255.0

                    if alphaAtPixel > minimumAlpha {
                        if x < minX { minX = x }
                        if x > maxX { maxX = x }
                        if y < minY { minY = y }
                        if y > maxY { maxY = y }
                    }
                }
            }

            let rectangleForOpaquePixels = CGRect(x: CGFloat(minX), y: CGFloat(minY), width: CGFloat( maxX - minX ), height: CGFloat( maxY - minY ))
            guard let croppedImage = originalImage.cropping(to: rectangleForOpaquePixels) else {
                DispatchQueue.main.async {
                    completion(UIImage())
                }
                return
            }

            DispatchQueue.main.async {
                let result = UIImage(cgImage: croppedImage, scale: self.scale, orientation: self.imageOrientation)
                completion(result)
            }

        }

    }

}

Finally, we need the ability to fill a UIImage with a color of our choosing:

public extension UIImage {

    /// Returns a version of this image any non-transparent pixels filled with the specified color
    /// - Parameter color: The color to fill
    /// - Returns: A re-colored version of this image with the specified color
    func imageByFillingWithColor(_ color: UIColor) -> UIImage {
        return UIGraphicsImageRenderer(size: size).image { context in
            color.setFill()
            context.fill(context.format.bounds)
            draw(in: context.format.bounds, blendMode: .destinationIn, alpha: 1.0)
        }
    }

}

Now we can get to the problem at hand, adding a stroke to our rendered / cropped UIImage. This process involves flooding the input image the desired stroke color, then rendering the image offset from our original image's centre point by the stroke thickness, in a circular formation. The more times we draw this "stroke" image within the 0...360 degree range, the more "precise" the resulting stroke will appear. That being said, a default of 8 strokes seems to suffice for most things (resulting in a stroke being rendered at 0, 45, 90, 135, 180, 225, and 270 degree intervals).

Further, we also need to draw this stroke image multiple times for a given angle. In most cases, 1 draw per angle will suffice, but as the desired stroke thickness increases, the number of times we should draw the stroke image along a given angle should also increase to maintain a good looking stroke.

When all of the strokes have been drawn, we finish off by re-drawing the original image in the centre of this new image, so that it appears in front of all of the drawn stroke images.

The below function takes care of these remaining steps:

public extension UIImage {

    /// Applies a stroke around the image
    /// - Parameters:
    ///   - strokeColor: The color of the desired stroke
    ///   - inputThickness: The thickness, in pixels, of the desired stroke
    ///   - rotationSteps: The number of rotations to make when applying the stroke. Higher rotationSteps will result in a more precise stroke. Defaults to 8.
    ///   - extrusionSteps: The number of extrusions to make along a given rotation. Higher extrusions will make a more precise stroke, but aren't usually needed unless using a very thick stroke. Defaults to 1.
    func imageByApplyingStroke(strokeColor: UIColor = .white, strokeThickness inputThickness: CGFloat = 2, rotationSteps: Int = 8, extrusionSteps: Int = 1) -> UIImage {

        let thickness: CGFloat = inputThickness > 0 ? inputThickness : 0

        // Create a "stamp" version of ourselves that we can stamp around our edges
        let strokeImage = imageByFillingWithColor(strokeColor)

        let inputSize: CGSize = size
        let outputSize: CGSize = CGSize(width: size.width + (thickness * 2), height: size.height + (thickness * 2))
        let renderer = UIGraphicsImageRenderer(size: outputSize)
        let stroked = renderer.image { ctx in

            // Compute the center of our image
            let center = CGPoint(x: outputSize.width / 2, y: outputSize.height / 2)
            let centerRect = CGRect(x: center.x - (inputSize.width / 2), y: center.y - (inputSize.height / 2), width: inputSize.width, height: inputSize.height)

            // Compute the increments for rotations / extrusions
            let rotationIncrement: CGFloat = rotationSteps > 0 ? 360 / CGFloat(rotationSteps) : 360
            let extrusionIncrement: CGFloat = extrusionSteps > 0 ? thickness / CGFloat(extrusionSteps) : thickness

            for rotation in 0..<rotationSteps {

                for extrusion in 1...extrusionSteps {

                    // Compute the angle and distance for this stamp
                    let angleInDegrees: CGFloat = CGFloat(rotation) * rotationIncrement
                    let angleInRadians: CGFloat = angleInDegrees * .pi / 180.0
                    let extrusionDistance: CGFloat = CGFloat(extrusion) * extrusionIncrement

                    // Compute the position for this stamp
                    let x = center.x + extrusionDistance * cos(angleInRadians)
                    let y = center.y + extrusionDistance * sin(angleInRadians)
                    let vector = CGPoint(x: x, y: y)

                    // Draw our stamp at this position
                    let drawRect = CGRect(x: vector.x - (inputSize.width / 2), y: vector.y - (inputSize.height / 2), width: inputSize.width, height: inputSize.height)
                    strokeImage.draw(in: drawRect, blendMode: .destinationOver, alpha: 1.0)

                }

            }

            // Finally, re-draw ourselves centered within the context, so we appear in-front of all of the stamps we've drawn
            self.draw(in: centerRect, blendMode: .normal, alpha: 1.0)

        }

        return stroked

    }

}

Combining it all together, you can apply a stroke to a UIImage like so:

let inputImage: UIImage = UIImage()
let outputImage = inputImage.imageByApplyingStroke(strokeColor: .black, strokeThickness: 2.0)

Here is an example of the stroke in action, being applied to a label with white text, with a black stroke:

Example of stroke code

Upvotes: 7

Related Questions