Skip to content

Instantly share code, notes, and snippets.

@jsmmth
Created May 6, 2025 10:01
Show Gist options
  • Save jsmmth/57a75b6085beb8fbb9839257ab77e6da to your computer and use it in GitHub Desktop.
Save jsmmth/57a75b6085beb8fbb9839257ab77e6da to your computer and use it in GitHub Desktop.
import UIKit
import CoreImage
public extension UIImage {
/// Draws a rounded, solid‐color outline around this image’s alpha shape,
/// then composites the full-color image back in the center.
///
/// - Parameters:
/// - strokeColor: the color of the ring
/// - strokeWidth: the thickness of the ring (in points)
/// - smoothing: if >0, blurs the alpha mask by this amount before extracting the ring
func stroked(
color strokeColor: UIColor = .white,
width strokeWidth: Float,
smoothing: Float = 5
) -> UIImage {
guard
let cgImg = self.cgImage,
let maskF = CIFilter(name: "CIMaskToAlpha"),
let threshF = CIFilter(name: "CIColorMatrix"),
let gradF = CIFilter(name: "CIMorphologyGradient"),
let colorGen = CIFilter(name: "CIConstantColorGenerator"),
let blendF = CIFilter(name: "CIBlendWithMask"),
let overF = CIFilter(name: "CISourceOverCompositing")
else {
return self
}
let blurF: CIFilter? = smoothing > 0
? CIFilter(name: "CIGaussianBlur")
: nil
let ciImg = CIImage(cgImage: cgImg)
let extent = ciImg.extent
let ctx = CIContext()
maskF.setValue(ciImg, forKey: kCIInputImageKey)
guard var gray = maskF.outputImage else { return self }
if let blur = blurF {
blur.setValue(gray, forKey: kCIInputImageKey)
blur.setValue(smoothing, forKey: kCIInputRadiusKey)
gray = blur.outputImage!.cropped(to: extent)
}
threshF.setValue(gray, forKey: kCIInputImageKey)
threshF.setValue(CIVector(x:0,y:0,z:0,w:1000),
forKey: "inputAVector")
threshF.setValue(CIVector(x:0,y:0,z:0,w:0),
forKey: "inputBiasVector")
guard let binary = threshF.outputImage else { return self }
gradF.setValue(binary, forKey: kCIInputImageKey)
gradF.setValue(strokeWidth / 2.0, forKey: "inputRadius")
guard let ring = gradF.outputImage else { return self }
threshF.setValue(ring, forKey: kCIInputImageKey)
guard let hardRing = threshF.outputImage else { return self }
colorGen.setValue(CIColor(color: strokeColor),
forKey: "inputColor")
guard let flatColor = colorGen.outputImage?
.cropped(to: extent)
else { return self }
blendF.setValue(flatColor, forKey: kCIInputImageKey)
blendF.setValue(hardRing, forKey: kCIInputMaskImageKey)
blendF.setValue(
CIImage(color: .clear).cropped(to: extent),
forKey: kCIInputBackgroundImageKey
)
guard let coloredRing = blendF.outputImage else { return self }
overF.setValue(ciImg, forKey: kCIInputImageKey)
overF.setValue(coloredRing, forKey: kCIInputBackgroundImageKey)
guard
let outCI = overF.outputImage,
let cg = ctx.createCGImage(outCI, from: extent)
else {
return self
}
return UIImage(
cgImage: cg,
scale: scale,
orientation: imageOrientation
)
}
}
@jsmmth
Copy link
Author

jsmmth commented May 6, 2025

This is a simplified version of what I use within Abode to make stroked avatars. I add a few more bits surrounding the code to better support problematic issues with hairs in avatars and better optimise for depth camera objects etc. But wanted to keep this a simple example thats usable across multiple assets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment