クラウド Mac で iOS PNG 書き出しの透明度を検証する

クラウド Mac で iOS PNG 書き出しの透明度を検証する

クラウド Mac で iOS PNG 書き出しの透明度を検証する

書き出したステッカーに黒い縁が出る場合、原因は PNG の破損ではなく、乗算済みの色をストレートアルファの色として扱っていたり、描画時に不透明な背景を先に塗っていたりすることがほとんどです。クラウド Mac 上で検証し、まず 3 つの画素でエンコード時の取り決めを確認してから、実際の書き出しファイルを調べましょう。最初からスクリーンショット全体を比較すると、拡大縮小、表示背景、色変換の影響が混ざり、原因を切り分けられません。

まず画素の取り決めを固定する

この記事で検証するのは、SDR・8 ビット・sRGB の PNG です。広色域や HDR は対象外とします。完全透明、半透明の赤、完全不透明の青という 3 つの画素を横一列に並べます。出力サイズは必ず 3×1 を維持し、エンコードと再読み込みを経ても透明度が失われないことを確認します。

ここでは、2 つの表現を区別する必要があります。PNG はアルファ非乗算の表現を使いますが、CoreGraphics のビットマップコンテキストでは乗算済みアルファを使えます。乗算済み RGBA として明示的に定義したバッファでは、半透明の赤は赤チャンネルが約 128、アルファが約 128 になります。赤チャンネルは 255 ではありません。

CGImage の dataProvider から得たバイト列を、そのまま共通の RGBA 形式として扱わないでください。色空間、チャンネル順序、行ストライドが異なる可能性があります。自分で定義したビットマップコンテキストにいったん描画してから、バイト列を読み取ります。

完全透明な画素の RGB 値は、合否判定の対象にしません。その色は見えず、エンコードや変換によって元の値が保持されない場合があるためです。確認するのは、アルファがゼロであることと、合成しても背景が変わらないことだけです。

Swift で最小限のラウンドトリップテストを作る

完全版の Xcode がインストールされたノードで、次のコードを alpha-check.swift として保存します。システムフレームワークだけを使い、PNG を生成してから再度デコードします。バイトオーダー、色空間、乗算済み形式も明示的に指定しています。出力先はカレントディレクトリなので、実行前に専用のテストディレクトリへ移動してください。

import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

let width = 3
let height = 1
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let info = CGBitmapInfo.byteOrder32Big.rawValue
    | CGImageAlphaInfo.premultipliedLast.rawValue

func context() -> CGContext {
    CGContext(
        data: nil,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: space,
        bitmapInfo: info
    )!
}

let source = context()
source.clear(CGRect(x: 0, y: 0, width: width, height: height))
source.setFillColor(red: 1, green: 0, blue: 0, alpha: 0.5)
source.fill(CGRect(x: 1, y: 0, width: 1, height: 1))
source.setFillColor(red: 0, green: 0, blue: 1, alpha: 1)
source.fill(CGRect(x: 2, y: 0, width: 1, height: 1))

let url = URL(fileURLWithPath: "alpha-fixture.png")
let destination = CGImageDestinationCreateWithURL(
    url as CFURL, UTType.png.identifier as CFString, 1, nil
)!
CGImageDestinationAddImage(destination, source.makeImage()!, nil)
precondition(CGImageDestinationFinalize(destination), "PNG write failed")

let reader = CGImageSourceCreateWithURL(url as CFURL, nil)!
let image = CGImageSourceCreateImageAtIndex(reader, 0, nil)!
precondition(image.width == width && image.height == height)

func pixels(background: CGFloat?) -> [UInt8] {
    let target = context()
    let bounds = CGRect(x: 0, y: 0, width: width, height: height)
    target.clear(bounds)
    if let value = background {
        target.setFillColor(
            red: value, green: value, blue: value, alpha: 1
        )
        target.fill(bounds)
    }
    target.interpolationQuality = .none
    target.draw(image, in: bounds)
    return Array(UnsafeBufferPointer(
        start: target.data!.assumingMemoryBound(to: UInt8.self),
        count: width * 4
    ))
}

func near(_ actual: UInt8, _ expected: Int) -> Bool {
    abs(Int(actual) - expected) <= 2
}

let rgba = pixels(background: nil)
precondition(rgba[3] == 0)
precondition(near(rgba[4], 128) && near(rgba[7], 128))
precondition(rgba[8] == 0 && rgba[10] == 255 && rgba[11] == 255)

let black = pixels(background: 0)
let white = pixels(background: 1)
precondition(near(black[4], 128) && black[5] == 0 && black[6] == 0)
precondition(white[4] == 255 && near(white[5], 127))
precondition(near(white[6], 127))
precondition(Array(white[0...3]) == [255, 255, 255, 255])
precondition(Array(black[0...3]) == [0, 0, 0, 255])

実行時は終了コードを確認できるようにし、画像ビューアの背景色を見て成功と判断しないでください。

mkdir -p alpha-check
cd alpha-check
xcrun swift ../alpha-check.swift

アサーションが失敗すると、ゼロ以外の終了ステータスで終了します。この検証スクリプトは、アサーションチェックを取り除く最適化オプションを付けて実行しないでください。許容差を 2 階調としているのは 8 ビット量子化の誤差を吸収するためであり、誤差を自由に広げてよいという意味ではありません。

黒背景と白背景で 2 種類の誤りを切り分ける

上記のビットマップコンテキストでは、半透明の赤を黒背景に重ねた結果は約 (128,0,0)、白背景に重ねた結果は約 (255,127,127) になるはずです。これは、このコンテキストでの合成結果を定めたものであり、すべてのレンダラーに共通する色の保証ではありません。

黒背景だけのテストでは、見逃す問題があります。たとえば、書き出し前に黒背景を塗ってしまっていても、ファイル上では赤が見えます。しかし、透明だった画素は不透明な黒に変わっています。白背景で確認すれば、このような背景の混入をすぐに検出できます。

すでに乗算済みの赤にもう一度アルファを掛けると、赤が暗くなり、半透明の輪郭に黒い縁が出やすくなります。逆に、ストレートアルファの色を乗算済みとして扱うと、縁が明るくなりすぎることがあります。調査では、まずバッファの宣言を確認し、次に変換が行われる箇所を調べます。シャープネスを上げて補おうとしないでください。

テストデータを実際の iOS 書き出し経路に通す

上のスクリプトで確認できるのは、ツールとアサーションが信頼できるかどうかです。アプリの書き出し処理が合格したことにはなりません。次は同じ 3 色のテストデータを本番のエンコード関数に渡し、その関数が返したデータを再読み込みします。サンプルのエンコーダーを再び呼び出してはいけません。

UIKit の描画を個別に検証する

アプリで UIGraphicsImageRenderer を使っている場合は、iOS のテストターゲットで format.opaque = false を明示的に設定します。ピクセル寸法のテストでは、format.scale = 1 も設定してください。そうしないと、論理サイズとファイルのピクセル寸法を混同してしまいます。本番環境で別のスケールを使う場合は、それに対応する寸法のアサーションを別途追加します。

返された PNG データを ImageIO でデコードし、同じ sRGB コンテキストに統一して描画します。UIImage.size をそのまま出力ピクセル数として扱わないでください。メインスレッドでの描画を伴うテストはメインアクター上で実行し、テスト自体が不確定な動作を持ち込まないようにします。

さらに、アンチエイリアス付きの斜めの輪郭を持つテスト画像を追加します。アルファがゼロより大きく最大値より小さい輪郭画素が実際に存在することを確認してから、黒背景と白背景にそれぞれ合成します。特定の OS バージョンで得られる輪郭の全バイト値にテストを固定しないでください。

失敗時は原因を特定できる証拠を残す

失敗するたびに、少なくとも元の PNG、デコード後の寸法、指定した色空間、サンプリング座標、期待する RGBA と実際の RGBA、ツールチェーンのバージョンを保存します。テストには人工的に作成した素材で十分であり、ユーザーの画像をアップロードする必要はありません。

寸法は正しいのに全画素が不透明なら、まず描画コンテキストの opaque 設定と背景の塗りつぶしを確認します。アルファは正しいのに縁が暗くなるなら、二重に乗算していないかを調べます。全体の色がずれているなら、ピクセル値の許容差を検討する前に、色空間変換を確認してください。

最終的な合否判定は、寸法の一致、透明度の保持、取り決めどおりの背景合成という 3 つの独立した項目に分けます。ファイルを開けることは、あくまで前提条件です。共通のピクセル処理はクラウド Mac で自動実行できますが、実際の UIKit による書き出しと、共有後の最終ファイルは別途検証し、途中の再エンコードによる変化を見逃さないようにします。

よくある質問

アルファチャンネルがあれば透明度は保持されていますか?

そうとは限りません。すべての値が不透明になっている場合もあります。透明と半透明の既知の画素を読み戻し、値と合成結果を検証してください。

Mac 上のテストで iOS 実機確認を省略できますか?

完全には省略できません。共有する符号化処理とファイルの画素は検証できますが、UIKit の描画、表示色域、実際の共有経路は iOS 側で確認が必要です。

次のビルドに、クラウド Mac を

専有の物理 Mac mini をご利用いただけます。仮想マシンではありません。日・週・月・四半期から利用期間を選べます。構成と実際の空き状況は、コンソールの表示をご確認ください。