클라우드 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를 실행한다. 성공하면 종료 코드는 0이며 테스트 파일이 생성된다. 별도의 디버그 출력은 없고, 검증에 실패하면 단언문이 작업을 중단한다. 스크립트의 강제 언래핑은 통제된 테스트 픽스처에 적합한 방식이다. 실제 서비스의 내보내기 코드에서는 생성 또는 디코딩 실패를 명확한 오류로 처리해야 한다.
변환 오류와 압축 손실 구분하기
이 색상 샘플은 P3에서 sRGB로 변환할 때 수치 변화가 드러나도록 의도적으로 선택했다. 첫 번째 단언문은 변환이 단순한 채널 복사로 끝나지 않았다는 사실만 확인한다. 모든 색이 올바르게 변환되었다는 뜻은 아니다. 정식 테스트에는 중성 회색, 검정과 흰색에 가까운 색, 대상 색역의 경계에 가까운 색상 샘플도 추가해야 한다.
JPEG를 다시 읽은 값은 변환 전 P3 값이 아니라 변환 후 sRGB 기준값과 비교한다. 양쪽 이미지를 같은 형식의 컨텍스트에 그린 다음 중앙 픽셀을 읽어야 채널 순서나 색 공간 차이로 인한 오탐을 피할 수 있다.
스크립트의 채널 오차 상한인 8은 이 균일한 색상 샘플에만 적용되며, 일반적인 이미지 품질 기준이 아니다. 실제 사진은 여러 영역을 샘플링해 오차를 집계해야 한다. 가는 선, 텍스트, 고주파 텍스처에 이 임계값을 그대로 적용하지 말자. 프로파일 이름의 존재 여부 역시 최소한의 검사일 뿐이다. 이름만으로 프로파일 내용이 올바르다고 판단할 수는 없다.
앱의 실제 내보내기 코드에 연결하기
독립 스크립트가 검증하는 것은 시스템 파이프라인이다. 다음 단계에서는 동일한 P3 픽스처를 앱이 실제 사용하는 내보내기 메서드에 전달하고, 그 메서드가 만든 파일을 테스트에서 읽도록 한다. 테스트 안에 별도의 ‘올바른 내보내기’를 다시 구현해서는 안 된다.
단언문은 두 그룹으로 유지하는 것이 좋다. 구조 검증 그룹에서는 디코딩 성공 여부, 이미지 크기, RGB 모델, 프로파일 존재 여부를 확인한다. 픽셀 검증 그룹에서는 고정된 색상 샘플의 기준 결과와 손실 압축 후 다시 읽은 값의 오차를 확인한다. 여기에 부정 테스트 사례를 하나 추가한다. 소스 픽셀을 변환하지 않고 프로파일만 다시 지정한 뒤, 픽셀 검증 그룹이 이를 감지하는지 확인한다. 검증 관문이 파일 확장자만 검사하는 데 그쳐서는 안 된다.
실제 코드의 크기 조절, 자르기, 방향 보정 매개변수는 먼저 고정한다. 이번 테스트에서는 색상 처리만 바꿔야 한다. 그렇지 않으면 크기 변화와 보간 때문에 색상 이상이 어디서 발생했는지 파악하기 어려워진다. 실패할 때마다 원본 픽스처, 출력 파일, 테스트 설정을 보관한다. 문제 발생 당시의 파일을 재압축된 첨부 파일로 대체해서는 안 된다.
기준선 고정과 화면 검증의 한계 설정하기
빌드 기록에 운영체제 버전, 개발 도구 버전, 소스 프로파일, 대상 프로파일, 렌더링 의도, 인코딩 품질을 남긴다. 실행 환경을 업데이트할 때는 먼저 기존 샘플을 이전 환경과 새 환경에서 나란히 실행한다. 차이가 변환에서 생겼는지 인코딩에서 생겼는지 확인한 뒤 기준값 갱신 여부를 결정해야 한다. 실패를 없애려고 임계값부터 완화해서는 안 된다.
자동화 테스트를 통과한 다음에는 해당 색역을 지원하는 기기에서 실제 사진을 확인한다. 원격 데스크톱 화면에는 추가 인코딩과 색상 처리가 적용될 수 있으므로, 눈에 보이는 화면을 픽셀 값의 기준으로 삼을 수 없다. 기기에서 확인하는 작업 역시 파일을 다시 읽어 검증하는 절차를 대신하지 못한다.
최종 출력물은 세 가지 조건을 동시에 충족해야 한다. 대상 프로파일을 식별할 수 있어야 하고, 픽셀이 실제로 변환되어야 하며, 손실 오차가 해당 샘플에 정해 둔 범위 안에 있어야 한다. 이 세 항목을 내보내기 테스트에 포함해야 색상 문제가 ‘뭔가 이상해 보인다’는 인상을 넘어, 원인을 추적하고 재현할 수 있는 엔지니어링 실패로 바뀐다.
자주 묻는 질문
sRGB 프로파일을 지정하면 변환이 끝나나요?
아닙니다. 프로파일 지정은 기존 픽셀값의 해석만 바꿉니다. 실제 변환은 원본 이미지를 sRGB 대상 컨텍스트에 그려야 합니다.
JPEG를 다시 읽으면 픽셀값이 같아야 하나요?
아닙니다. JPEG는 손실 압축이므로 같은 sRGB 디코딩 공간에서 비교하고 고정 샘플에 맞는 허용 오차를 정해야 합니다.
다음 빌드에는 전용 Mac을 사용해 보세요.
가상 머신이 아닌 클라우드 Mac을 선택해 기존 Xcode와 CI 워크플로를 그대로 유지하세요. 프로젝트 요구 사항에 맞춰 머신을 선택할 수 있으며, 실제 사용 가능 여부는 콘솔에 표시되는 실시간 정보를 기준으로 합니다.