クラウド Mac で検証する iOS 画像の Display P3・sRGB 変換

クラウド 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を選べます。