如何在 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)

这是一个实际的笔划示例,应用于带有黑色笔划的白色文本标签: