gheclipse
gheclipse

Reputation: 1009

CoreImage: CIImage write JPG is shifting colors [macOS]

Using CoreImage to filter photos, I have found that saving to JPG file will result in an image that has a subtle but visible blue hue. In this example using a B&W image, the histogram reveals how the colors have been shifted in the saved file.

---Input Input Histogram

Output [Output Histogram] Histogram shows the color layers are offset

-- Issue demonstrated with MacOS 'Preview' App

I can show a similar result using only the Preview App.

  1. test image here: https://i.sstatic.net/Y3f03.jpg
  2. Open the JPG image using Preview.
  3. Export to JPEG at any 'Quality' other than the default (85%?)
  4. Open the exported file and look at the Histogram, and the same color shifting can be seen as I experience within my app.

enter image description here

-- Issue demonstrated in custom MacOS App

The code here is as bare bones as possible, creating a CIImage from the photo and immediately saving it without performing any filters. In this example I chose 0.61 for compression as it resulted in a similar file size as the original. The distortion seems to be broader if using a higher compression ratio, but I could not find any value that would eliminate it.

if let img = CIImage(contentsOf: url) {
   let dest = procFolder.url(named: "InOut.jpg")
   img.jpgWrite(url: dest)
}

extension CIImage {
    func jpgWrite(url: URL) {

        let prop: [NSBitmapImageRep.PropertyKey: Any] = [
            .compressionFactor: 0.61
        ]

        let bitmap = NSBitmapImageRep(ciImage: self)
        let data = bitmap.representation(using: NSBitmapImageRep.FileType.jpeg, properties: prop)

        do {
            try data?.write(to: url, options: .atomic)
        } catch {
            log.error(error)
        }
    }
}

Update 1: Using @Frank Schlegel's answer for saving JPG file

The JPG now carries a Color Sync Profile, and I can (unscientifically) track a ~10% performance boost for portrait images (less for landscape), which are nice improvements. But, unfortunately the resulting file is still skewing the colors in the same way demonstrated in the histograms above.

extension CIImage {
   static let writeContext = CIContext(mtlDevice: MTLCreateSystemDefaultDevice()!, options: [
        // using an extended working color space allows you to retain wide gamut information, e.g., if the input is in DisplayP3
        .workingColorSpace: CGColorSpace(name: CGColorSpace.extendedSRGB)!,
        .workingFormat: CIFormat.RGBAh // 16 bit color depth, needed in extended space
    ])

    func jpgWrite(url: URL) {
       // write the output in the same color space as the input; fallback to sRGB if it can't be determined
        let outputColorSpace = colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
        do {
            try CIImage.writeContext.writeJPEGRepresentation(of: self, to: url, colorSpace: outputColorSpace, options: [:])
        } catch {
        }
    }
}

Question:

How can I open a B&W JPG as a CIImage, and re-save a JPG file avoiding any color shifting?

Upvotes: 4

Views: 1242

Answers (2)

gheclipse
gheclipse

Reputation: 1009

I never found the underlying cause of this issue and therefore no 'true' solution as I was seeking. In discussion with @Frank Schlegel, it led to a belief that it is an artifact of Apple's jpeg converter. And the issue was certainly more apparent when using test files that appear monochrome but actually had small amount of color info in them.

The simplest fix for my app was to ensure there was no color in the source image, so I drop the saturation to 0 prior to saving the file.

    let params = [
        "inputBrightness": brightness,  // -1...1, This filter calculates brightness by adding a bias value: color.rgb + vec3(brightness)
        "inputContrast": contrast,      // 0...2, this filter uses the following formula: (color.rgb - vec3(0.5)) * contrast + vec3(0.5)
        "inputSaturation": saturation   // 0...2
    ]
    image.applyingFilter("CIColorControls", parameters: params)

Upvotes: 0

Frank Rupprecht
Frank Rupprecht

Reputation: 10408

This looks like a color sync issue (as Leo pointed out) – more specifically a mismatch/misinterpretation of color spaces between input, processing, and output.

When you are calling NSBitmapImageRep(ciImage:), there's actually a lot happening under the hood. The system actually needs to render the CIImage you are providing to get the bitmap data of the result. It does so by creating a CIContext with default (device-specific) settings, using it to process your image (with all filters and transformations applied to it), and then giving you the raw bitmap data of the result. In the process, there are multiple color space conversions happening that you can't control when using this API (and seemingly don't lead to the result you intended). I don't like these "convenience" APIs for rendering CIImages for this reason and I see a lot of questions on SO that are related to them.

I recommend you instead use a CIContext to render your CIImage into a JPEG file. This gives you direct control over color spaces and more:

let input = CIImage(contentsOf: url)

// ideally you create this context once and re-use it because it's an expensive object
let context = CIContext(mtlDevice: MTLCreateSystemDefaultDevice()!, options: [
    // using an extended working color space allows you to retain wide gamut information, e.g., if the input is in DisplayP3
    .workingColorSpace: CGColorSpace(name: CGColorSpace.extendedSRGB)!,
    .workingFormat: CIFormat.RGBAh // 16 bit color depth, needed in extended space
])

// write the output in the same color space as the input; fallback to sRGB if it can't be determined
let outputColorSpace = input.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!

context.writeJPEGRepresentation(of: input, to: dest, colorSpace: outputColorSpace, options: [kCGImageDestinationLossyCompressionQuality: 0.61])

Please let me know if you still see a discrepancy when using this API.

Upvotes: 1

Related Questions