雲端 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 解碼空間比較,並替固定樣本設定誤差容許值;尺寸與解碼成功則須嚴格檢查。