如何在 UIImage 的不透明像素周围绘制边框(描边)
How to draw a border (stroke) around a UIImage's opaque pixels
给定一个包含非不透明(alpha < 1)像素数据的 UIImage,我们如何在不透明(alpha > 0)的像素周围绘制轮廓/边框/笔划,并使用自定义笔划颜色和粗细? (我问这个问题是为了在下面提供答案)
我将其他 SO 帖子的建议拼凑在一起,并根据我喜欢的内容进行调整,从而提出了以下方法。
第一步是获取一个UIImage来开始处理。在某些情况下,您可能已经拥有此图像,但如果您可能想要向 UIView 添加笔划(可能是具有自定义字体的 UILabel),您首先需要捕获该视图的图像:
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
}
}
现在我们可以获得视图的图像,我们需要能够将其裁剪为不透明像素。此步骤是可选的,但对于 UILabel 之类的东西,有时它们的边界大于它们显示的像素。下面的函数采用一个完成块,因此它可以在后台线程上执行繁重的工作(注意:UIKit 不是线程安全的,但 CGContexts 是)。
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)
}
}
}
}
最后,我们需要能够用我们选择的颜色填充 UIImage:
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)
}
}
}
现在我们可以解决手头的问题了,向渲染/裁剪后的 UIImage 添加描边。
这个过程包括用所需的笔触颜色填充输入图像,然后以圆形形式渲染图像从原始图像的中心点偏移笔触粗细。我们在 0...360 度范围内绘制此 "stroke" 图像的次数越多,生成的笔划出现的次数就越多 "precise"。话虽如此,默认的 8 个笔划似乎足以满足大多数情况(导致笔划以 0、45、90、135、180、225 和 270 度间隔呈现)。
此外,我们还需要针对给定的角度多次绘制此笔画图像。在大多数情况下,每个角度绘制 1 次就足够了,但是随着所需笔划粗细的增加,我们应该沿着给定角度绘制笔划图像的次数也应该增加,以保持漂亮的笔划。
当所有的笔划都画完后,我们通过在这个新图像的中心重新绘制原始图像来完成,这样它就会出现在所有绘制的笔画图像的前面。
下面的函数负责剩下的步骤:
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
}
}
将它们组合在一起,您可以像这样向 UIImage 应用笔触:
let inputImage: UIImage = UIImage()
let outputImage = inputImage.imageByApplyingStroke(strokeColor: .black, strokeThickness: 2.0)
这是一个实际的笔划示例,应用于带有黑色笔划的白色文本标签:
给定一个包含非不透明(alpha < 1)像素数据的 UIImage,我们如何在不透明(alpha > 0)的像素周围绘制轮廓/边框/笔划,并使用自定义笔划颜色和粗细? (我问这个问题是为了在下面提供答案)
我将其他 SO 帖子的建议拼凑在一起,并根据我喜欢的内容进行调整,从而提出了以下方法。
第一步是获取一个UIImage来开始处理。在某些情况下,您可能已经拥有此图像,但如果您可能想要向 UIView 添加笔划(可能是具有自定义字体的 UILabel),您首先需要捕获该视图的图像:
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
}
}
现在我们可以获得视图的图像,我们需要能够将其裁剪为不透明像素。此步骤是可选的,但对于 UILabel 之类的东西,有时它们的边界大于它们显示的像素。下面的函数采用一个完成块,因此它可以在后台线程上执行繁重的工作(注意:UIKit 不是线程安全的,但 CGContexts 是)。
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)
}
}
}
}
最后,我们需要能够用我们选择的颜色填充 UIImage:
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)
}
}
}
现在我们可以解决手头的问题了,向渲染/裁剪后的 UIImage 添加描边。 这个过程包括用所需的笔触颜色填充输入图像,然后以圆形形式渲染图像从原始图像的中心点偏移笔触粗细。我们在 0...360 度范围内绘制此 "stroke" 图像的次数越多,生成的笔划出现的次数就越多 "precise"。话虽如此,默认的 8 个笔划似乎足以满足大多数情况(导致笔划以 0、45、90、135、180、225 和 270 度间隔呈现)。
此外,我们还需要针对给定的角度多次绘制此笔画图像。在大多数情况下,每个角度绘制 1 次就足够了,但是随着所需笔划粗细的增加,我们应该沿着给定角度绘制笔划图像的次数也应该增加,以保持漂亮的笔划。
当所有的笔划都画完后,我们通过在这个新图像的中心重新绘制原始图像来完成,这样它就会出现在所有绘制的笔画图像的前面。
下面的函数负责剩下的步骤:
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
}
}
将它们组合在一起,您可以像这样向 UIImage 应用笔触:
let inputImage: UIImage = UIImage()
let outputImage = inputImage.imageByApplyingStroke(strokeColor: .black, strokeThickness: 2.0)
这是一个实际的笔划示例,应用于带有黑色笔划的白色文本标签: