Validate iOS Display P3 to sRGB image exports on a cloud Mac
If a photo looks right in Preview but turns darker after export, do not start by increasing JPEG quality. Assigning an sRGB profile directly to Display P3 pixels changes what those values mean; stripping the profile leaves the receiving application to guess. A cloud Mac can run regression tests for this pipeline: generate fixed color patches, perform an actual conversion, and read the delivered file back for verification instead of relying on how it looks over a remote desktop.
Define the output contract first
This article validates only opaque, standard dynamic range RGB images, with an sRGB JPEG containing a color profile as the output target. Create separate fixtures for transparent images, extended dynamic range content, and screenshots; do not mix them into this baseline.
Keep three operations separate: reading the source color space, drawing into the target color space, and encoding the file. The first two determine which colors the pixels represent; only the last determines compression loss. If an input has no reliable profile, either reject it or explicitly supply its source color space according to the product’s rules. Do not treat “unknown” as Display P3.
Assigning a profile specifies how pixels should be interpreted; color conversion changes their numeric values. These are different operations, and tests must cover them separately.
Fix the rendering intent as well. Colors outside the sRGB gamut cannot be preserved exactly, so clipping does not necessarily indicate an implementation error. Nor should tests require RGB values to remain identical across different color spaces.
Test the conversion with a synthetic color patch
On a cloud Mac with the command-line developer tools already installed, save the following as color-check.swift. The script uses only system frameworks, reads no private photos, and requires no network access.
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
let size = 64
let p3 = CGColorSpace(name: CGColorSpace.displayP3)!
let srgb = CGColorSpace(name: CGColorSpace.sRGB)!
let layout = CGBitmapInfo.byteOrder32Big.rawValue
| CGImageAlphaInfo.premultipliedLast.rawValue
func context(_ space: CGColorSpace) -> CGContext {
CGContext(
data: nil, width: size, height: size,
bitsPerComponent: 8, bytesPerRow: size * 4,
space: space, bitmapInfo: layout
)!
}
func pixel(_ context: CGContext) -> [Int] {
let bytes = context.data!.assumingMemoryBound(to: UInt8.self)
let offset = (32 * context.bytesPerRow) + 32 * 4
return (0..<3).map { Int(bytes[offset + $0]) }
}
let source = context(p3)
source.setFillColor(CGColor(
colorSpace: p3, components: [0.8, 0.4, 0.2, 1.0]
)!)
source.fill(CGRect(x: 0, y: 0, width: size, height: size))
let original = source.makeImage()!
let converted = context(srgb)
converted.setRenderingIntent(.relativeColorimetric)
converted.draw(
original, in: CGRect(x: 0, y: 0, width: size, height: size)
)
let reference = pixel(converted)
precondition(reference != pixel(source), "Conversion was not observed")
let url = URL(fileURLWithPath: "srgb-check.jpg")
let output = CGImageDestinationCreateWithURL(
url as CFURL, UTType.jpeg.identifier as CFString, 1, nil
)!
CGImageDestinationAddImage(output, converted.makeImage()!, [
kCGImageDestinationLossyCompressionQuality: 0.95
] as CFDictionary)
precondition(CGImageDestinationFinalize(output), "Encoding failed")
let input = CGImageSourceCreateWithURL(url as CFURL, nil)!
let decoded = CGImageSourceCreateImageAtIndex(input, 0, nil)!
precondition(decoded.width == size && decoded.height == size)
precondition(decoded.colorSpace?.model == .rgb)
let readback = context(srgb)
readback.draw(
decoded, in: CGRect(x: 0, y: 0, width: size, height: size)
)
let actual = pixel(readback)
let error = zip(reference, actual).map { abs($0 - $1) }.max()!
precondition(error <= 8, "JPEG round-trip exceeded tolerance")
let properties = CGImageSourceCopyPropertiesAtIndex(
input, 0, nil
) as NSDictionary?
let profile = properties?[kCGImagePropertyProfileName] as? String
precondition(profile?.isEmpty == false, "Missing color profile")
Run xcrun swift color-check.swift. On success, it exits with code zero and creates the test file. There is no debug output; a failed assertion stops the task. Force unwrapping is appropriate for this controlled fixture, but a production exporter should turn creation or decoding failures into explicit errors.
Evaluate conversion and compression separately
This patch deliberately makes the numeric change from P3 to sRGB observable. The first assertion proves only that conversion has not degraded into a direct copy of the channels; it does not prove that every color is converted correctly. A full test suite should also include neutral grays, near-black and near-white patches, and colors close to the boundary of the target gamut.
Compare the decoded JPEG with the converted sRGB reference, not with the original P3 values. Draw both into contexts with the same format, then read the center pixel to avoid false failures caused by differences in channel order or color space.
The script’s maximum per-channel error of 8 applies only to this uniform patch; it is not an image-quality standard. For real photos, collect measurements across multiple sample regions. Do not reuse this threshold unchanged for fine lines, text, or high-frequency textures. Checking that a profile name exists is also only a minimum check: the name alone does not establish that the profile contents are correct.
Test the application’s actual exporter
The standalone script validates the system pipeline. Next, pass the same P3 fixture through the export method the application actually uses, and have the test read the resulting file. Do not implement a separate “correct export” inside the test.
Keep two groups of assertions. Structural assertions should check successful decoding, dimensions, the RGB color model, and the presence of a profile. Pixel assertions should check the reference result for the fixed patch and the error introduced by the lossy round trip. Add a negative test case that merely reassigns the profile to the source pixels without converting them, and confirm that the pixel assertions catch it. This prevents the test gate from checking little more than a file extension.
Lock down the parameters for scaling, cropping, and orientation correction in the production code. Change only the color handling in this round; otherwise, dimension changes and interpolation can obscure the source of a color shift. On every failure, save the original fixture, the output file, and the test configuration. Never substitute recompressed attachments for the files from the failed run.
Establish the baseline and the limits of display checks
Record the operating system version, developer tools version, source profile, target profile, rendering intent, and encoding quality in the build records. When updating the runtime environment, run the existing fixtures side by side first. Determine whether any changes come from conversion or encoding before deciding whether to update the reference values. Do not simply relax the threshold to make failures disappear.
Once the automated tests pass, inspect real photos on a device that supports the relevant gamut. Remote desktop output may undergo additional encoding and color processing, so the displayed image is not pixel ground truth. Device inspection is no substitute for reading the file back, either.
The final deliverable must satisfy all three conditions: its target profile is recognizable, its pixels have actually been converted, and its lossy-compression error falls within the limits defined for the fixture. Build these checks into the exporter’s tests to turn “the colors look wrong” into an engineering failure that can be isolated and reproduced.
Frequently asked questions
Does assigning an sRGB profile convert the image?
No. Assigning a profile changes how existing pixel values are interpreted. Actual conversion requires drawing the source image into an sRGB destination context.
Should JPEG pixels match exactly after decoding?
No. JPEG is lossy. Compare both images in the same sRGB decoding space and set a tolerance for fixed fixtures, while checking dimensions and decoding strictly.
Give your next build a dedicated Mac.
Choose a cloud Mac that isn’t a virtual machine, and keep your existing Xcode and CI workflows. Select a machine based on your workload; check the console for real-time availability.