What happens when you convert between sRGB, Display P3 and linear spaces, and why can a component be above 1 or below 0?
Predict first
What do the conversions produce?
import Foundation
import CoreGraphics
let srgb = CGColorSpace(name: CGColorSpace.sRGB)!
let p3 = CGColorSpace(name: CGColorSpace.displayP3)!
let extended = CGColorSpace(name: CGColorSpace.extendedSRGB)!
let linear = CGColorSpace(name: CGColorSpace.linearSRGB)!
func fmt(_ c: CGColor) -> [String] { c.components!.dropLast().map { String(format: "%.3f", $0) } }
func convert(_ c: CGColor, to s: CGColorSpace) -> CGColor { c.converted(to: s, intent: .defaultIntent, options: nil)! }
let p3Red = CGColor(colorSpace: p3, components: [1, 0, 0, 1])!
print("pure P3 red in sRGB:", fmt(convert(p3Red, to: srgb)))
print("pure P3 red in extended sRGB:", fmt(convert(p3Red, to: extended)))
print("pure sRGB red in P3:", fmt(convert(CGColor(colorSpace: srgb, components: [1, 0, 0, 1])!, to: p3)))
print("device-independent gray 0.5 in sRGB:", fmt(convert(CGColor(gray: 0.5, alpha: 1), to: srgb)))
print("sRGB 0.5 in linear sRGB:", fmt(convert(CGColor(colorSpace: srgb, components: [0.5, 0.5, 0.5, 1])!, to: linear)))
print("wide gamut:", srgb.isWideGamutRGB, p3.isWideGamutRGB, "| extended range:", CGColorSpaceUsesExtendedRange(srgb), CGColorSpaceUsesExtendedRange(extended))
- P3 red clamps to 1, 0, 0 in sRGB but is 1.093, -0.227, -0.150 in extended sRGB; sRGB 0.5 is 0.214 linear
- all conversions keep the same numbers
- extended sRGB also clamps to 0…1
Mental model. Paint mixed for a bigger palette: asked to match it with the smaller palette you get the nearest shade, so keep the big palette while you work and reduce only when you print.
A colour is numbers plus a colour space. Display P3 has a wider gamut than sRGB, so pure P3 red is more saturated than anything sRGB can name. Converting it to sRGB with CGColor.converted gives (1, 0, 0): the value is clamped to the nearest representable colour, and the extra saturation is lost. Converting to extended sRGB (the check prints 1.093, −0.227, −0.150) keeps it, because extended spaces allow components outside 0…1; the reverse trip, pure sRGB red into P3, is a less-saturated (0.917, 0.200, 0.139). So wide-colour work uses extended-range spaces and float or 16-bit formats, and a pipeline that quietly converts to sRGB 8-bit loses the gamut.
Gamma matters as much as gamut. sRGB stores values with a transfer curve, so 0.5 in sRGB is only about 0.214 in linear light, and device-independent gray 0.5 becomes 0.572 in sRGB. Averaging, blurring or blending is physically right in linear light and looks different in gamma space (the Core Image question shows 188 versus 128). Rules: tag every bitmap with its real colour space instead of using the device RGB spaces, convert once at the edges, and test wide-colour assets on a P3 display. Not run: ICC profiles from files, HDR transfer functions (PQ, HLG).
Conversion preserves the colour, not the numbers; non-extended targets clamp.
Common traps
- Assuming components stay in 0…1. Extended spaces use values outside it.
- Comparing raw numbers across spaces. (1, 0, 0) in P3 and in sRGB are different reds.
They might follow up with
- How do you know an asset is wide gamut? Check
isWideGamutRGBon its colour space.
Related questions
- 18.7.3 How much memory does a bitmap use, why is bytesPerRow not width × 4, and what is premultiplied alpha?
- 18.8.2 Which colour space does Core Image blend in by default, and how does that change a simple alpha blend?
- 7.7.3 How do dynamic colours, dark mode and the UIAppearance proxy work?
← How much memory does a bitmap use, why is bytesPerRow not width × 4…