iOS画像のEXIF方向と画素の正規化を検証する

iOS画像のEXIF方向と画素の正規化を検証する

写真の選択画面では縦向きなのに、アップロード後のサムネイルは横向きになる。この問題を調べるときは、まずトリミング比率を変えるのではなく、画像の方向を確認します。JPEGは横向きに並んだ画素を保存し、EXIFの方向情報でビューアーに回転表示を指示している場合があります。iOSのUIImageでは正しく表示できても、別の書き出し処理や受信側が同じように扱うとは限りません。ここではクラウドMac上のiOSテストターゲットで固定のサンプルを生成し、「表示が正しいこと」と「ファイルが正しいこと」を分けて検証します。

方向情報と画素を区別する

ファイルのEXIF方向、デコード後のUIImage.imageOrientation、基になるCGImageの画素幅と高さの3項目を記録します。EXIFの方向値6は、表示時に時計回りの回転が必要であることを示します。画素自体が回転済みという意味ではありません。基になる画素が幅120、高さ80なら、正しく表示したときは幅80、高さ120になります。

プレビューのスクリーンショットだけで修正完了と判断しないでください。プレビュー側がファイルの方向情報を適用していても、後続の処理では元の画素がそのまま渡されている可能性があります。

まず、何を保存する必要があるかを決めます。元ファイルを残すなら、正規化したファイルとは別に保存します。アップロード用に向きを統一した画像だけが必要なら、書き出し前に描画し直し、書き出したファイルを再度デコードして確認します。元ファイルの方向値だけを1に変更してはいけません。それでは画素は動きません。

iOSテストで固定サンプルを生成する

以下のテストを、UIKitを使用できるiOSユニットテストターゲットに追加します。実行時に120×80画素のJPEGを生成して方向値6を書き込むため、開発者のPCにある写真には依存せず、実際の画像素材やユーザー情報をリポジトリに入れる必要もありません。ImageIOが読み取った方向情報と、UIKitでデコードした後の向きを両方確認します。

import XCTest
import UIKit
import ImageIO
import UniformTypeIdentifiers

final class ImageOrientationTests: XCTestCase {
    func testOrientationSurvivesExport() throws {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        format.opaque = true

        let source = UIGraphicsImageRenderer(
            size: CGSize(width: 120, height: 80),
            format: format
        ).image { _ in
            UIColor.red.setFill()
            UIRectFill(CGRect(x: 0, y: 0, width: 60, height: 80))
            UIColor.blue.setFill()
            UIRectFill(CGRect(x: 60, y: 0, width: 60, height: 80))
        }

        let jpeg = NSMutableData()
        let destination = try XCTUnwrap(
            CGImageDestinationCreateWithData(
                jpeg, UTType.jpeg.identifier as CFString, 1, nil
            )
        )
        CGImageDestinationAddImage(
            destination,
            try XCTUnwrap(source.cgImage),
            [kCGImagePropertyOrientation: 6] as CFDictionary
        )
        XCTAssertTrue(CGImageDestinationFinalize(destination))

        let input = jpeg as Data
        let imageSource = try XCTUnwrap(
            CGImageSourceCreateWithData(input as CFData, nil)
        )
        let properties = try XCTUnwrap(
            CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
                as? [CFString: Any]
        )
        XCTAssertEqual(
            (properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue,
            6
        )

        let decoded = try XCTUnwrap(UIImage(data: input))
        XCTAssertEqual(decoded.imageOrientation, .right)
        XCTAssertEqual(decoded.cgImage?.width, 120)
        XCTAssertEqual(decoded.cgImage?.height, 80)

        let upright = UIGraphicsImageRenderer(
            size: CGSize(width: 80, height: 120),
            format: format
        ).image { _ in
            decoded.draw(in: CGRect(x: 0, y: 0, width: 80, height: 120))
        }
        XCTAssertEqual(upright.imageOrientation, .up)

        let exported = try XCTUnwrap(upright.pngData())
        let reopened = try XCTUnwrap(UIImage(data: exported))
        XCTAssertEqual(reopened.imageOrientation, .up)
        XCTAssertEqual(reopened.cgImage?.width, 80)
        XCTAssertEqual(reopened.cgImage?.height, 120)
    }
}

実行前に、このSwiftファイルがテストターゲットに含まれていることを確認し、プロジェクトが対応するiOSシミュレータを選びます。コマンドラインで実行する場合は、-workspace、-scheme、-destinationにプロジェクトの実際の値を指定してください。例に挙げられたデバイス名が、すべてのマシンで使えるとは限りません。

実際の書き出し処理をテストする

上のテストで確認できるのは、正規化の方法が機能することまでです。アプリの処理が実際にその方法を使っていることは、まだ確認できません。次に「デコード、目的のサイズへの描画、エンコード」をプロジェクト既存の画像書き出し関数に組み込み、テストから同じ関数を呼び出します。入力画像は引き続き実行時に生成できますが、アサーションは関数が返す前のUIImageではなく、書き出して再度開いたファイルに対して行います。

書き出し関数のサイズ契約を明確にする

まず、関数は元ファイルのデータを受け取り、表示方向が確定したデータを返す、と定めます。方向値が6のこのサンプルでは、出力画素は120×80から80×120になるはずです。アプリ側で縮小・拡大も行うなら、出力の縦横比と変換後の正確な目標サイズを確認し、サイズ変更の規則は別途テストします。回転、圧縮、トリミングの失敗を1つのアサーションにまとめないでください。

左右を2色にしているのは、目視で向きを確認するときに左右反転にも気づけるようにするためです。自動テストで色の位置まで確認する場合は、許容誤差を設けて画素を比較します。JPEGの非可逆圧縮では境界付近の色が変わるため、各チャンネルの値が完全に一致することは要求できません。

プレビューは正しいのに書き出しで失敗する経路を追う

まず、画像の表現形式がどの段階で変わるかを調べます。ファイルデータ、UIImage、CGImage、描画コンテキスト、再エンコード後のファイルのどこでしょうか。変換のたびに方向と画素サイズを記録すると、「cgImageを取り出してそのままエンコードした」といった分岐を特定しやすくなります。cgImageが持つのは画素です。UIImageの描画時に適用される方向の処理が、画素にも書き込まれたとは考えないでください。

書き出し先のファイルも確認します。描画し直すと、新しい画素とファイルデータが生成され、エンコード形式、メタデータ、ファイルサイズも変わる可能性があります。元ファイルのほかのメタデータを残す必要があるなら、保持すべき項目を列挙してそれぞれテストします。再描画でそのままコピーされると決めつけてはいけません。アップロード前には最終的なファイルをもう一度デコードして確認するのが確実です。ビューアーで正しく見えた途中段階のオブジェクトが、納品するファイルではありません。

マージ前のチェックリスト

少なくとも、方向値1の通常の画像、方向値6の回転画像、アプリが実際に許容する左右反転の方向を対象にします。各サンプルについて、入力の方向情報、正規化後の画素幅と高さ、再デコード後の表示方向をそれぞれ検証します。本番コードでトリミングを許容する場合は、正方形ではないサンプルも追加してください。正方形だと回転を誤っても、幅と高さのアサーションでは検出できません。

最後に、実際のリリース工程と同じiOSテストターゲットでテストを実行します。失敗時に保存するのは入力の方向、出力サイズ、処理段階で十分です。ユーザーの元画像を公開ログに書き込まないでください。こうしておけば、回帰テストの失敗から「写真の見た目がおかしい」というだけでなく、問題のあるファイル処理段階を追えます。

よくある質問

UIImageで正しく見える写真が、書き出すと回転するのはなぜですか?

UIImageの描画時には方向メタデータが反映されますが、書き出し先が同じように解釈するとは限りません。出力ファイルを再デコードして、実際の画素方向を確認してください。

方向情報を1に書き換えるだけで正規化できますか?

できません。元の画素配置を変えずに情報だけ書き換えると、表示方向が変わります。元の方向を反映して再描画してから出力を検証します。

次のビルドは専有クラウドMacにお任せください。

ViewMiniの機種、リージョン、利用期間を確認し、ワークフローに合った専有物理ノードを設定しましょう。

クラウドMacを設定

機種と利用期間をもう一度ご確認ください。

販売中の3機種は、日・週・月・四半期ごとの料金をご用意しています。ご注文前にプランの詳細をご確認ください。

機種と料金を見る クラウドMacを設定