Jeff C.
Jeff C.

Reputation: 2136

Image fails to work as a mask with Core Graphics

I am having some trouble masking a UIImage using another UIImage as a mask. I've tried using the masking(_:) Core Graphics function (CGImageCreateWithMask in Objective C), but the output for the masked image shows no masking whatsoever. It just shows the normal image.

I've used two methods to do this: First, applying by passing along the mask image itself. Second, by using the CGImage initializer to create a mask from that image and passing it instead. Here is the function that does this:

func performMask(_ originalImage:UIImage, maskImage:UIImage) -> UIImage? {
    guard let originalImageCGImage = originalImage.cgImage, let maskImageCGImage = maskImage.cgImage else { return nil }

    print("Method 1 - Image (UIImage):")
    print("\(String(describing: maskImage.cgImage))\n\n")

    print("Method 2 - Mask (CGImage):")

    let maskImage2 = CGImage(
        maskWidth: Int(maskImage.size.width * maskImage.scale),
        height: Int(maskImage.size.height * maskImage.scale),
        bitsPerComponent: maskImageCGImage.bitsPerComponent,
        bitsPerPixel: maskImageCGImage.bitsPerPixel,
        bytesPerRow: maskImageCGImage.bytesPerRow,
        provider: maskImageCGImage.dataProvider!,
        decode: nil,
        shouldInterpolate: true
    )

    print("\(String(describing: maskImage2))\n\n")

    let maskedImage     = originalImageCGImage.masking(maskImage.cgImage!)  // Output: Method 1
//  let maskedImage2    = originalImageCGImage.masking(maskImage2!)         // Output: Method 2

    return UIImage(cgImage: maskedImage!)
}

Here is the console output I get, showing the print statements above:

Method 1 - Image (UIImage):
Optional(<CGImage 0x104223890> (DP)
    <<CGColorSpace 0x281414960> (kCGColorSpaceICCBased; kCGColorSpaceModelMonochrome; Generic Gray Gamma 2.2 Profile)>
        width = 320, height = 320, bpc = 8, bpp = 8, row bytes = 640 
        kCGImageAlphaNone | 0 (default byte order)  | kCGImagePixelFormatPacked 
        is mask? No, has masking color? No, has soft mask? No, has matte? No, should interpolate? Yes)


Method 2 - Mask (CGImage):
Optional(<CGImage 0x104223e90> (DP)
    <(null)>
        width = 320, height = 320, bpc = 8, bpp = 8, row bytes = 640 
        kCGImageAlphaNone | 0 (default byte order)  | kCGImagePixelFormatPacked 
        is mask? Yes, has masking color? No, has soft mask? No, has matte? No, should interpolate? Yes)

A theory I have is that the image being in the kCGColorSpaceModelMonochrome color space is the problem. The documentation for the masking(_:) function says:

"If the mask is an image, it must be in the DeviceGray color space, must not have an alpha component, and may not itself be masked by an image mask or a masking color."

However, the headers give a less stringent color space requirement:

"If `mask' is an image, then it must be in a monochrome color space (e.g. DeviceGray, GenericGray, etc...), may not have alpha, and may not itself be masked by an image mask or a masking color."

No matter what I do, I am only ever to get a kCGColorSpaceModelMonochrome color space for my mask image (not DeviceGray) - which should work based on the latter statement, but not the first.

The mask image itself is generated from a UIBezierPath using UIGraphicsImageRenderer's image(actions: (UIGraphicsImageRendererContext) -> Void) function, and it looks okay to me in the debugger (it's an irregularly-shaped black-and-white image) - it just doesn't work as a mask.

Note: I am attempting to benefit from the iOS 12 memory usage optimizations in UIGraphicsImageRenderer detailed in 2018's WWDC talk 'Image and Graphics Best Practices' (29:15), but unfortunately have no way to manually specify the color space of the resultant image. This is framed as a feature but I wish there was a way to override it.

https://developer.apple.com/videos/play/wwdc2018/219/

Does anyone know how to get this mask operation to work properly?

Upvotes: 3

Views: 1048

Answers (1)

Rob C
Rob C

Reputation: 5073

Yeah, the mask needs to be in color space DeviceGray. The good news is that you can easily generate a mask in DeviceGray, you just can't use UIGraphicsImageRenderer. Let's implement a type that generates a mask in DeviceGray and accepts a closure that allows you to draw whatever mask you like.

struct MaskRenderer {
    let size: CGSize
    let scale: CGFloat

    var sizeInPixels: CGSize {
        return CGSize(width: size.width * scale, height: size.height * scale)
    }

    func image(actions: (CGContext) -> Void) -> UIImage? {
        let colorSpace = CGColorSpaceCreateDeviceGray()

        guard let context = CGContext.init(
            data: nil,
            width: Int(sizeInPixels.width),
            height: Int(sizeInPixels.height),
            bitsPerComponent: 8,
            bytesPerRow: 0,
            space: colorSpace,
            bitmapInfo: CGImageAlphaInfo.none.rawValue
            ) else { return nil }

        actions(context)

        guard let coreImageMask = context.makeImage() else { return nil }

        return UIImage(cgImage: coreImageMask)
    }
}

Let's also implement a UIImage extension that applies a mask to the receiver, returning the masked image:

extension UIImage {
    func withMask(_ imageMask: UIImage) -> UIImage? {
        guard let coreImage = cgImage else { return nil }
        guard let coreImageMask = imageMask.cgImage else { return nil }
        guard let coreMaskedImage = coreImage.masking(coreImageMask) else { return nil }

        return UIImage(cgImage: coreMaskedImage)
    }
}

Now we can draw and apply the mask to an arbitrary image:

func applyMask(to image: UIImage) -> UIImage? {
    let renderer = MaskRenderer(size: image.size, scale: image.scale)
    guard let imageMask = renderer.image(actions: { context in
        let rect = CGRect(origin: .zero, size: renderer.sizeInPixels)
            .insetBy(dx: 0, dy: renderer.sizeInPixels.height / 4)
        let path = UIBezierPath(ovalIn: rect)
        context.addPath(path.cgPath)
        context.setFillColor(gray: 1, alpha: 1)
        context.drawPath(using: .fillStroke)
    }) else { return nil }
    return image.withMask(imageMask)
}

All you have to do is replace the bezier path with yours.

Upvotes: 2

Related Questions