Как нарисовать границу (обводку) вокруг непрозрачных пикселей UIImage - PullRequest
0 голосов
/ 02 мая 2020

Учитывая UIImage, который содержит непрозрачные (альфа <1) данные о пикселях, как мы можем нарисовать контур / границу / обводку вокруг непрозрачных (альфа> 0) пикселей с пользовательским цветом и толщиной обводки? (Я задаю этот вопрос, чтобы дать ответ ниже)

1 Ответ

1 голос
/ 02 мая 2020

Я пришел к следующему подходу, собрав воедино предложения из других публикаций 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
    }

}

Теперь, когда мы можем получить изображение вида, нам нужно обрезать его до непрозрачных пикселей. Этот шаг не является обязательным, но для таких вещей, как UILabels, иногда их границы превышают отображаемые пиксели. Приведенная ниже функция принимает блок завершения, поэтому она может выполнять тяжелую работу в фоновом потоке (примечание: 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 градусов, тем более «точным» будет результирующий штрих. Тем не менее, по умолчанию 8 штрихов кажется достаточным для большинства вещей (в результате чего штрих отображается с интервалами 0, 45, 90, 135, 180, 225 и 270 градусов).

Далее, мы Также необходимо нарисовать это изображение штриха несколько раз для данного угла. В большинстве случаев достаточно 1 рисования на угол, но по мере увеличения желаемой толщины обводки число раз, когда мы должны рисовать изображение обводки вдоль заданного угла, также должно увеличиваться, чтобы сохранить красивый ход.

Когда все штрихи были нарисованы, мы заканчиваем sh, заново рисуя исходное изображение в центре этого нового изображения, так что оно появляется перед всеми нарисованными штриховыми изображениями.

функция ниже заботится об этих оставшихся шагах:

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)

Вот пример штриха в действии, применяется к метке с белым текстом, с черным штрихом:

Example of stroke code

...