Which colour space does Core Image blend in by default, and how does that change a simple alpha blend?
Predict first
What does 50% white over black render as?
import Foundation
import CoreImage
func render(_ image: CIImage, with context: CIContext) -> [Int] {
var rgba = [UInt8](repeating: 0, count: 4)
context.render(image, toBitmap: &rgba, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .RGBA8, colorSpace: CGColorSpace(name: CGColorSpace.sRGB))
return rgba.map(Int.init)
}
let area = CGRect(x: 0, y: 0, width: 2, height: 2)
let whiteHalf = CIImage(color: CIColor(red: 1, green: 1, blue: 1, alpha: 0.5)).cropped(to: area)
let black = CIImage(color: CIColor(red: 0, green: 0, blue: 0)).cropped(to: area)
let blended = whiteHalf.composited(over: black)
let standard = CIContext()
print("default working space:", standard.workingColorSpace!.name!)
print("default working format is half-float RGBA:", standard.workingFormat == .RGBAh)
print("50% white over black, default context:", render(blended, with: standard))
print("50% white over black, sRGB working space:", render(blended, with: CIContext(options: [.workingColorSpace: CGColorSpace(name: CGColorSpace.sRGB)!])))
print("50% white over black, no colour management:", render(blended, with: CIContext(options: [.workingColorSpace: NSNull()])))
- 188 with the default linear working space, 128 with sRGB or none
- 128 in every context
- 188 in every context
Mental model. Mixing paint versus mixing light: the same “half and half” recipe gives a different brightness depending on which physics the mixer assumes.
A default CIContext works in extended linear sRGB with half-float RGBA (the check prints both). Inputs are converted into it, filters run, and the output is converted to the target space when you render. Because blending in linear light is physically correct, 50% white over black renders as 188 per channel in an sRGB bitmap, while a context created with .workingColorSpace set to sRGB, or to NSNull() (no colour management), gives 128. Neither is “wrong”: 188 looks like real half-intensity light; 128 matches what an editor or CSS that blends in gamma space shows.
Decide on purpose: keep the default for photographic filters, and if designers compare against a gamma-space tool, set the working space (or use no management for data such as masks and normals, which are not colours). Extended range also lets values pass 1 through a pipeline without clipping, so the output conversion is where clipping happens. Create one CIContext and reuse it, because creation is expensive and each context has its own cache (not timed here). Not run: custom Metal CIKernels and wide-gamut display output.
The default working space is linear, so the midpoint is brighter in sRGB output.
Common traps
- Treating data images as colours. Use no colour management for masks.
- Creating a context per frame. Reuse one.
They might follow up with
- How does this relate to Q397’s pipeline advice? One context, lazy chain, one render at the end.
Related questions
- 18.8.1 What is a CIImage extent, why is a generator infinite, and why do blurred edges look wrong or the image grow?
- 18.7.4 What happens when you convert between sRGB, Display P3 and linear spaces, and why can a component be above 1…
- 18.5.2 How do Core Image and Metal relate, why is Core Image lazy, and how do you build a smooth image-processing…
← What is a CIImage extent, why is a generator infinite, and why do… What are the steps of a Metal compute pass, what runs when, and what… →