同一张照片在预览里正常,导出后却偏暗,先别提高 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 解码空间里比较,并为固定样本设定误差阈值;尺寸和解码成功仍应严格检查。