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)
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: