云端 Mac 上的 iOS 图片色域转换验收:Display P3 到 sRGB

云端 Mac 上的 iOS 图片色域转换验收:Display P3 到 sRGB

同一张照片在预览里正常,导出后却偏暗,先别提高 JPEG 质量。把 Display P3 的像素直接贴上 sRGB 标签,会改变颜色含义;丢掉配置文件,又会让接收端自行猜测。云端 Mac 可以承担这条管线的回归测试:生成固定色块,执行真实转换,再把交付文件读回来检查,而不是依赖远程桌面的观感。

先固定输出契约

本文只验收不透明、标准动态范围的 RGB 图片,输出目标是带色彩配置的 sRGB JPEG。透明图片、扩展动态范围和屏幕截图另建样本,不混进这条基线。

把三个动作分开:读取源色彩空间、向目标色彩空间绘制、编码文件。前两步决定像素代表什么颜色,最后一步才决定压缩损失。输入没有可靠配置时,应按产品约定拒绝或明确补全来源,不能把“未知”默认为 Display P3。

配置标记说明像素如何解释,色彩转换改变像素数值。两者不是同一个操作,测试必须分别覆盖。

同时固定渲染意图。超出 sRGB 范围的颜色无法原样保留,出现裁切不等于实现错误;更不能要求不同色域下的 RGB 数值保持相同。

用合成色块跑通转换

在已具备命令行开发工具的云端 Mac 上,将下面内容保存为 color-check.swift。脚本只用系统框架,不读取私人照片,也不依赖网络。

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")

执行 xcrun swift color-check.swift,成功时退出码为零,并生成测试文件。这里没有调试打印;失败由断言阻断任务。脚本中的强制解包适用于受控夹具,生产导出器应将创建或解码失败转换为明确错误。

分开判断转换与压缩

这个色块故意让 P3 到 sRGB 的数值变化可见。第一次断言只证明转换没有退化成直接复制通道,不能证明所有颜色都转换正确。正式测试还要加入中性灰、近黑、近白,以及接近目标色域边缘的色块。

JPEG 回读与转换前的 P3 数值不比较,而是与转换后的 sRGB 参考比较。两边都绘制到相同格式的上下文,再读取中间像素,避免通道顺序和色彩空间不同造成假失败。

脚本的通道误差上限为 8,仅用于这块均匀样本,不是图片质量标准。真实照片应统计多个采样区域;不要直接复用这个阈值覆盖细线、文字和高频纹理。配置名称存在也只是最低检查,不能单凭名字判定配置内容正确。

接入应用真正的导出器

独立脚本验证的是系统管线。下一步把同一张 P3 夹具送进应用实际使用的导出方法,让测试读取它产生的文件,而不是在测试里重新实现一套“正确导出”。

建议保留两组断言。结构组检查解码成功、尺寸、RGB 模型和配置存在;像素组检查固定色块的参考结果与有损回读误差。新增一个负例:仅重新标记源像素、不进行转换,确认像素组能识别它,避免门禁只检查文件后缀。

对生产代码里的缩放、裁剪和方向修正先固定参数。本轮只改变色彩处理,否则尺寸变化与插值会掩盖偏色来源。每次失败保存原始夹具、输出文件和测试配置,禁止用重新压缩后的附件代替现场文件。

固化基线并划清显示边界

在构建记录里保存系统版本、开发工具版本、源配置、目标配置、渲染意图和编码质量。更新运行环境时,先并排执行旧样本,检查变化来自转换还是编码,再决定是否更新参考值;不要直接放宽阈值消除失败。

自动化通过以后,再用支持对应色域的设备检查真实照片。远程桌面可能经过额外编码和色彩处理,不能把看到的画面当作像素真值;设备检查也不能替代文件回读。

最终交付应同时满足三件事:目标配置可识别,像素确实经过转换,有损误差处于样本约定范围。把这三项写进导出器测试,偏色问题才会从“看起来不对”变成可定位、可复现的工程失败。

常见问题

把图片的色彩配置改成 sRGB,就完成转换了吗?

没有。重新标记只改变像素的解释方式;真正转换需要让色彩管理管线把源色彩空间中的像素绘制到 sRGB 目标上下文。

JPEG 回读后的像素必须完全一致吗?

不必。JPEG 是有损编码,应在统一的 sRGB 解码空间里比较,并为固定样本设定误差阈值;尺寸和解码成功仍应严格检查。

让下一次构建独享一台 Mac。

选择非虚拟机的云端 Mac,保留现有 Xcode 与 CI 工作流程。按工程负载配置机器,实际可用状态以控制台实时返回为准。

立即租用云端 Mac

把下一次构建交给独享物理机。

保留现有命令与 CI 流程,按工程负载选择云端 Mac。