iOS 画像切り抜きの座標を検証する:表示上の選択範囲から画素境界まで

iOS 画像切り抜きの座標を検証する:表示上の選択範囲から画素境界まで

iOS 画像切り抜きの座標を検証する:表示上の選択範囲から画素境界まで

4000×3000 ピクセルの画像を 300×300 ポイントの切り抜きビューに表示し、中央を選択したのに、書き出すと人物が上にずれている。そんなとき、ジェスチャーのコードを直す前に確認したいのが表示領域です。アスペクトフィットでは上下にそれぞれ 37.5 ポイントの余白が生じます。ビュー全体を基準に換算すると、選択範囲の始点も高さも誤ってしまいます。クラウド Mac 上でまず純粋な Swift コードによる座標検証を行い、その後に UI を接続すれば、計算の誤りと描画の誤りを切り分けられます。

3 つの座標系の関係を明確にする

区別するのは、元画像のピクセル矩形、ビュー内で画像が実際に表示される矩形、ユーザーがドラッグして指定した選択範囲の 3 つです。後者の 2 つは、必ず同じ座標空間で扱います。一方を親ビュー基準、もう一方を画像コンテナ基準にしてはいけません。

この記事で扱うのはアスペクトフィットのみです。アスペクトフィルによる切り抜き、回転、透視変換は対象外とします。原点は左上、横軸は右向き、縦軸は下向きとします。入力画像は表示方向に合わせて正規化済みで、幅と高さには画像オブジェクトのポイント単位のサイズではなく、実際のピクセル数を使います。

画面のスケール倍率と画像の拡大縮小率は別物です。選択範囲に画面のスケール倍率を掛ける方法は、寸法がたまたま一致する場合にしか正しくならず、汎用的な切り抜きの式にはなりません。

UI に入れ子のビューがある場合は、座標変換 API を使って選択範囲を画像コンテナの座標系へ変換してから、以下の関数に渡します。ピンチズームなどの拡大縮小操作がある場合は、平行移動と拡大縮小の変換も先に取り除く必要があります。変換後の矩形を、そのまま静的な計算式に当てはめてはいけません。

表示矩形とピクセル変換を実装する

次のコードを CropCheck.swift として保存します。完全な Xcode ツールチェーンが利用できる環境では、ターミナルで xcrun swift CropCheck.swift を実行してください。依存するのはシステムの Foundation のみで、ネットワークアクセスもシミュレータも必要ありません。

import Foundation

func cropPixels(
    source: CGSize,
    viewport: CGSize,
    selection: CGRect
) -> CGRect? {
    let values = [
        source.width, source.height,
        viewport.width, viewport.height,
        selection.origin.x, selection.origin.y,
        selection.width, selection.height
    ]
    guard values.allSatisfy({ $0.isFinite }),
          source.width > 0, source.height > 0,
          viewport.width > 0, viewport.height > 0,
          source.width.rounded(.down) == source.width,
          source.height.rounded(.down) == source.height,
          selection.width > 0, selection.height > 0
    else {
        return nil
    }

    let scale = min(
        viewport.width / source.width,
        viewport.height / source.height
    )
    guard scale.isFinite, scale > 0 else {
        return nil
    }

    let display = CGRect(
        x: (viewport.width - source.width * scale) / 2,
        y: (viewport.height - source.height * scale) / 2,
        width: source.width * scale,
        height: source.height * scale
    )
    let clipped = selection.intersection(display)
    guard !clipped.isNull, !clipped.isEmpty else {
        return nil
    }

    let left = max(
        0, floor((clipped.minX - display.minX) / scale)
    )
    let top = max(
        0, floor((clipped.minY - display.minY) / scale)
    )
    let right = min(
        source.width,
        ceil((clipped.maxX - display.minX) / scale)
    )
    let bottom = min(
        source.height,
        ceil((clipped.maxY - display.minY) / scale)
    )
    guard right > left, bottom > top else {
        return nil
    }

    return CGRect(
        x: left, y: top,
        width: right - left, height: bottom - top
    )
}

処理の順序は変えられません。まず選択範囲と表示領域の共通部分を求め、次に表示位置のオフセットを引き、最後に拡大縮小率で割ります。ビュー座標を先に整数へ丸めると、元画像とビューのサイズ比に応じて誤差が拡大します。

ピクセル境界は外側へ丸め、選択範囲が触れるピクセルをすべて含めます。そのため、計算上の選択範囲の寸法が厳密に保たれるわけではありません。出力サイズを固定する必要がある場合は、書き出し時のリサイズを後続の独立した処理として扱います。

手計算できるテストで位置ずれを防ぐ

続けて、次のアサーションを同じファイルに追記します。正常に実行されると何も出力されません。いずれかのアサーションが失敗すると、プロセスは異常終了します。この検証スクリプトは、アサーションが除去される最適化設定では実行しないでください。

let source = CGSize(width: 4000, height: 3000)
let viewport = CGSize(width: 300, height: 300)

func mapped(_ rectangle: CGRect) -> CGRect? {
    cropPixels(
        source: source,
        viewport: viewport,
        selection: rectangle
    )
}

assert(
    mapped(CGRect(x: 75, y: 75, width: 150, height: 150))
    == CGRect(x: 1000, y: 500, width: 2000, height: 2000)
)

assert(
    mapped(CGRect(x: 0, y: 0, width: 300, height: 300))
    == CGRect(x: 0, y: 0, width: 4000, height: 3000)
)

assert(
    mapped(CGRect(x: 10, y: 0, width: 20, height: 20))
    == nil
)

assert(
    mapped(CGRect(x: 0, y: 0, width: 0, height: 20))
    == nil
)

assert(
    mapped(CGRect(x: 225, y: 225, width: 100, height: 100))
    == CGRect(x: 3000, y: 2500, width: 1000, height: 500)
)

中央を選択する例は手計算で確認できます。表示倍率は 0.075 で、選択範囲の始点から上部の余白を引くと 37.5 ポイントになり、元画像の縦座標 500 ピクセルに対応します。最後の例は右端と下端の両方をはみ出します。始点だけを制限しているのではなく、範囲外の部分を実際に切り詰めていることを確認するためのテストです。

さらに、縦長の画像、正方形ではないコンテナ、小数座標の選択範囲も追加します。有効な結果については、幅と高さが正であること、境界が元画像をはみ出していないこと、四辺の座標がすべて整数であることを確認してください。この関数では、幅や高さが負の入力はそのまま拒否します。ドラッグ方向の違いは、UI 層で事前に正規化する必要があります。

計算結果と実際の書き出しを分けて検証する

計算のテストが通ったら、四隅の色がそれぞれ異なり、中央に十字がある、ピクセル寸法が固定されたテスト画像を用意します。まずは可逆圧縮形式で書き出し、非可逆圧縮によるノイズが色のアサーションに影響しないようにします。

確認するのは 3 点です。書き出された画像の幅と高さが関数の戻り値と一致すること、右下を切り抜いたときに左上のマーカーが混入していないこと、画像全体を選択したときに余計な透明の縁が付かないことです。ファイルのバイト列だけで比較してはいけません。エンコーダーが付加するメタデータの変化は、必ずしもピクセルの変化を意味しないためです。

正規化済みのピクセル画像を直接切り抜く場合は、この記事の関数が返すピクセル矩形を渡します。グラフィックスコンテキストで再描画する場合は、原点の向きと出力スケールの設定を別途確認してください。よくある二重処理の誤りは、座標変換関数がすでにピクセル単位の値を返しているのに、書き出しコードでもう一度表示倍率を掛けてしまうことです。

画像を非同期で読み込む場合の検証も必要です。ユーザーが範囲を選択した後に、寸法の異なる元画像へ差し替わった場合は、再計算するか、以前の選択範囲を無効にしなければなりません。古い画像の倍率と新しい画像のピクセル寸法を混在させないでください。

失敗の記録を再現可能な入力にする

失敗するたびに、元画像のピクセル幅と高さ、コンテナのポイント単位のサイズ、選択矩形、実際の表示モード、計算結果を記録します。数値だけを残せばよく、ユーザーの写真をアップロードする必要はありません。再現時には、同じ寸法のテスト画像で代用します。

マージ前には、少なくとも横長画像、縦長画像、画像全体の選択、余白だけの選択、端からはみ出す選択、小数座標の境界という 6 種類の入力を網羅します。レイアウトが変わったら、コンテナのサイズを必ず取得し直してください。画面回転前の値を使い回してはいけません。

最終的な検証は 2 段階に分けます。純粋関数のテストで座標の関係を確認し、書き出しのテストでピクセルの向きと寸法を確認します。両方が通ってから、リモートデスクトップでドラッグ操作の使い勝手を確かめます。こうしておけば、UI の調整、元画像の差し替え、書き出し実装の変更のそれぞれについて、問題がどの段階で起きたかを明確にできます。

よくある質問

切り抜き座標に画面の倍率を掛ければよいですか?

直接掛ける方法では不十分です。元画像の画素数と、画像が実際に表示される矩形から変換比率を求めます。画面の倍率とは別の値です。

画素境界を外向きに丸める理由は何ですか?

最小境界を切り下げ、最大境界を切り上げると、選択範囲が触れる画素を含められます。丸めた結果は元画像の範囲内に制限します。

専有の物理 Mac でプロジェクトを実行

クラウド Mac でビルドやテストを行い、リモートデスクトップから操作できます。プロジェクトに合った構成と利用期間を選択してください。現在の利用状況はコンソールでリアルタイムに確認できます。

次は、実行環境を確認しましょう

まず構成を比較し、接続方法を選びましょう。SSH コマンドラインはビルドに、VNC デスクトップは操作やデバッグに便利です。