swiftuiimagebordercore-graphicsstroke

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)


Solution

  • 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