クラウド Mac で検証する iOS JPEG 書き出し:容量制限と失敗条件

クラウド Mac で検証する iOS JPEG 書き出し:容量制限と失敗条件

プロフィール画像をアップロードすると、ローカルのプレビューでは問題ないのに、サーバーにはファイルを拒否されることがあります。原因は拡張子ではなく、JPEG エンコード後のバイト数が API の上限を超えているケースが少なくありません。品質パラメータを固定値に変えるだけでは、一部の画像で改善しても、すべての画像が制限内に収まる保証にはなりません。クラウド Mac 上でまず書き出し処理を独立した検証スクリプトにし、そこで定めた契約を iOS のテストターゲットにも移すとよいでしょう。

まず容量上限を定義し、品質値をサイズの比率と考えない

API がバイト数で容量を制限しているなら、クライアント側も上限を整数で保持し、10 進単位と 2 進単位の混同を避けます。品質値はエンコーダーへの入力であり、出力サイズの割合ではありません。同じピクセル寸法でも、単色の背景と細かなテクスチャでは圧縮結果が大きく異なります。

この記事では、入力画像の向きの正規化、透明背景の処理、色空間の選択は済んでおり、書き出し時にはピクセル寸法を変えないものとします。有限個の品質候補を高い順に試し、容量上限を満たす最初の結果を返します。これはあくまで「候補の中で最も優先順位が高い合格結果」であり、連続した品質範囲全体で最適値を見つけたと主張するものではありません。

ファイルサイズは必ず確認すべき出力条件であり、見た目の品質は別の合格条件です。前者を満たしていても、文字の輪郭、肌の色、グラデーションまで合格とは限りません。

リモート macOS のターミナルで、以下の内容を jpeg-budget.swift として保存します。利用可能な Swift ツールチェーンが設定されている必要があります。スクリプトはシステムフレームワークのみを使い、個人の写真を読み込んだり、マシンの設定を変更したりしません。

import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

enum ExportError: Error {
    case invalidBudget
    case encoding
    case budgetUnreachable
    case decoding
}

func encodeJPEG(_ image: CGImage, quality: Double) throws -> Data {
    let buffer = NSMutableData()
    guard let destination = CGImageDestinationCreateWithData(
        buffer as CFMutableData,
        UTType.jpeg.identifier as CFString,
        1,
        nil
    ) else {
        throw ExportError.encoding
    }
    let properties = [
        kCGImageDestinationLossyCompressionQuality: quality
    ] as CFDictionary
    CGImageDestinationAddImage(destination, image, properties)
    guard CGImageDestinationFinalize(destination) else {
        throw ExportError.encoding
    }
    return buffer as Data
}

func exportJPEG(
    _ image: CGImage,
    maxBytes: Int
) throws -> (data: Data, quality: Double) {
    guard maxBytes > 0 else {
        throw ExportError.invalidBudget
    }
    let levels = [0.9, 0.8, 0.7, 0.6, 0.5]
    for quality in levels {
        let data = try encodeJPEG(image, quality: quality)
        if data.count <= maxBytes {
            return (data, quality)
        }
    }
    throw ExportError.budgetUnreachable
}

func verify(_ data: Data, width: Int, height: Int) throws {
    guard let source = CGImageSourceCreateWithData(
        data as CFData, nil
    ),
    CGImageSourceGetType(source) == UTType.jpeg.identifier as CFString,
    let decoded = CGImageSourceCreateImageAtIndex(source, 0, nil),
    decoded.width == width,
    decoded.height == height else {
        throw ExportError.decoding
    }
}

let space = CGColorSpace(name: CGColorSpace.sRGB)!
let context = CGContext(
    data: nil,
    width: 320,
    height: 240,
    bitsPerComponent: 8,
    bytesPerRow: 0,
    space: space,
    bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.setFillColor(
    CGColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)
)
context.fill(CGRect(x: 0, y: 0, width: 320, height: 240))
let image = context.makeImage()!

let result = try exportJPEG(image, maxBytes: 1_000_000)
precondition(result.data.count <= 1_000_000)
try verify(result.data, width: 320, height: 240)

do {
    _ = try exportJPEG(image, maxBytes: 1)
    preconditionFailure("Expected budget rejection")
} catch ExportError.budgetUnreachable {
}

xcrun swift jpeg-budget.swift を実行すると、成功時は何も出力せず、失敗時はゼロ以外の終了ステータスで終了します。ここで使う単色画像は処理の流れを検証するためのもので、実際の圧縮性能を測る代表サンプルではありません。コード内の強制アンラップは固定のテストフィクスチャに限ったものです。外部から画像を受け取るアプリのコードでは、明確なエラーを返すようにしてください。

成功したファイルだけでなく、失敗経路も確認する

スクリプトでは、不正な容量上限、エンコード失敗、容量上限を達成できない場合を区別しています。これらをすべて空ファイルに置き換えてはいけません。不正な容量上限は呼び出し側の誤りであり、エンコード失敗では入力やエンコーダーの調査が必要です。容量上限を達成できない場合の扱いは、プロダクトの方針として決めるべきです。

テストを追加する際は、少なくとも上限がゼロと負数の場合、出力バイト数とちょうど等しい場合、どの品質候補でも上限を満たせない場合を含めます。上限と等しい場合は必ず合格にします。「最高品質での出力より 1 バイト少なければ必ず失敗する」とは断言できません。後続の品質候補なら条件を満たせる可能性があるためです。

探索の分岐を個別に検証する

実際のエンコード処理を注入可能な関数に切り出し、あらかじめ決めた長さのデータを返して探索の流れを検証します。最初の候補が合格したら呼び出しは 1 回だけ、途中の候補が合格したら以降のエンコードは行わず、すべて上限を超えたら試行回数は候補数と一致することを確認します。特定の画像が偶然特定のサイズになることに依存せず、早期終了が正しく機能しているかを確かめられます。

未検証の二分探索を使わないのは、出力サイズが厳密に単調に変化するという前提を暗黙に持ち込まないためです。有限個の候補を順に試す方式はコストが明確ですが、大きな画像ではエンコードを繰り返すことになります。そのため、入力のピクセル数にも上限を設け、同時に処理する画像が多くなりすぎないようにします。

問題を顕在化させる画像フィクスチャを用意する

テストセットには、単色、細い線の文字、グラデーション、精細なテクスチャを含めます。プログラムで生成しても、チームで保存が許可された固定サンプルを使っても構いません。各サンプルの寸法と内容の概要を記録し、毎回ランダムに生成してシードを残さない運用は避けてください。

Data.count の確認に加えて、出力を読み直し、形式とピクセル寸法を確認します。壊れたファイルは小さくても、合格した結果ではありません。JPEG は非可逆圧縮なので、デコード後の全ピクセルが元画像と一致することは検証条件にできません。プロジェクトで見た目の誤差を自動評価する必要がある場合は、計算に使う色空間と許容誤差を固定し、人手で再確認するためのサンプルも残します。

同じ画像でも、システムのバージョンが異なれば同じハッシュになるとは限りません。形式が正しいこと、寸法が変わらないこと、バイト数が上限内であること、探索回数が有限の範囲に収まることを検証するほうが堅実です。そのうえで、調査用に実際に選ばれた品質段階を記録します。

iOS の書き出し処理に組み込み、継続的に検証する

Mac のスクリプトがテストを通過したら、関数をアプリで再利用できるモジュールに移し、対象の iOS 実行環境でも同じフィクスチャを使って検証します。システムフレームワークのエンコード結果は変わる可能性があるため、macOS での成功をそのまま iOS での動作の証明にはできません。

すべての品質候補で失敗した場合は、現在の条件では書き出せないことを UI で明確に伝えます。縮小を許可する場合は、品質とは別に寸法の段階と短辺の最小値を定め、ユーザーにも明示します。品質の探索中に、知らせずに寸法を変えてはいけません。アップロード前に画像をリクエストに組み込む場合は、画像ファイルの容量上限とリクエストボディ全体の容量上限をそれぞれ確認します。

最終的に残す記録は、入力フィクスチャの識別子、システムのバージョン、品質候補、選択された品質、出力バイト数で十分です。元の個人画像を記録する必要はありません。容量制約、見た目の要件、失敗時の振る舞いをそれぞれ明確に扱うことで、品質値を下げ続けるだけでは見えなくなる書き出しの問題を捉えられます。

よくある質問

JPEG の品質を 0.5 にすると容量も半分になりますか?

なりません。品質値はエンコードの設定であり、圧縮率ではありません。エンコード完了後の Data.count で容量を確認します。

どの品質段階でも容量制限を満たせない場合は?

定義済みのエラーを返して探索を終了します。画素寸法の縮小を許可する場合は、最小寸法を持つ別の処理として実装します。

Mac での成功を iOS の検証結果として扱えますか?

扱えません。Mac は探索ロジックの確認に使い、対象の iOS 実行環境でも同じ契約を検証します。OS をまたいだ JPEG のバイト単位の一致は求めません。

自分の作業でリモートMacを試す

Mac mini M4を専有でレンタルし、macOSのGUIとコマンドラインを使えます。接続やワークフローの検証に最適です。長時間のオフライン作業や専門機器の接続が必要な場合は、購入も比較検討しましょう。

Mac miniをレンタル

まず1日レンタルして、購入するか決めましょう。

接続の使い心地と実際の作業を試して、自分に合った利用期間を選びましょう。