將一張 4000×3000 像素的圖片放入 300×300 點的裁切視圖,選取中央區域,輸出後卻發現人物向上偏移。先別急著修改手勢程式碼:以等比例縮放至完整顯示時,上下會各留下 37.5 點的空白。若直接依整個視圖換算,選取範圍的起點與高度都會出錯。在雲端 Mac 上先用純 Swift 驗證座標,再串接介面,就能將數學計算錯誤與繪製錯誤分開排查。
明確定義三套座標的契約
先釐清三個對象:原圖的像素矩形、圖片在視圖中的實際顯示矩形,以及使用者拖曳出的選取範圍。後兩者必須位於同一個座標空間,不能一個相對於父視圖,另一個卻相對於圖片容器。
本文僅處理等比例縮放至完整顯示的情況,不涵蓋填滿裁切、旋轉或透視。約定原點位於左上角,橫軸向右、縱軸向下;輸入圖片已依顯示方向完成正規化,寬高取自實際像素,而非圖片物件以點為單位的尺寸。
螢幕縮放倍率不等於圖片的縮放比例。將選取範圍乘上螢幕倍率,只會在尺寸恰巧吻合時得到正確結果,不能當作通用的裁切公式。
若介面使用巢狀視圖,請先透過座標轉換 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 像素。最後一個範例同時超出右側與底部邊界,用來確認函式確實會裁掉越界部分,而不只是限制起點。
接著補上直向圖片、非正方形容器,以及座標或尺寸帶有小數的選取範圍。對所有有效結果,都要檢查寬高大於零、邊界未超出原圖,且四條邊的座標皆為整數。此處會直接拒絕寬高為負值的輸入;拖曳方向應由介面層預先正規化。
分別檢查計算結果與實際輸出
數學測試通過後,準備一張像素尺寸固定的測試圖片,讓四個角落各有不同顏色,中央則帶有十字標記。先以無損格式輸出,避免有損編碼產生雜訊,干擾顏色斷言。
檢查三項結果:輸出寬高等於函式回傳值;裁切右下角時沒有混入左上角標記;選取整張圖片時沒有多出透明邊。不要只比較檔案位元組,因為編碼器附帶的中繼資料發生變化,不一定代表像素也有變化。
若裁切器直接操作正規化後的像素影像,就傳入本文計算出的像素矩形;若是透過繪圖上下文重新繪製,則須另外確認原點方向與輸出縮放參數。最常見的二次錯誤,是座標函式已經回傳像素座標,輸出程式碼卻又乘了一次顯示倍率。
圖片非同步載入的情況也必須納入驗收:若使用者選取範圍後,載入了另一張尺寸不同的原圖,就必須重新計算,或讓舊的選取範圍失效。不要混用舊圖的比例與新圖的像素尺寸。
將失敗紀錄轉成可重現的輸入
每次失敗時,都保留原圖的像素寬高、容器以點為單位的尺寸、選取範圍矩形、實際顯示模式,以及計算結果。只需記錄數值,不必上傳使用者的照片;重現問題時,改用相同尺寸的測試圖片即可。
合併前至少要涵蓋橫向圖片、直向圖片、選取整張圖片、僅選取留白、超出邊界,以及小數邊界這六類輸入。版面配置變更後,必須重新讀取容器尺寸,不能沿用旋轉前的快照。
最終驗收分成兩層:純函式負責驗證座標關係,輸出測試負責驗證像素方向與尺寸。兩層都通過後,再透過遠端桌面檢查拖曳體驗。如此一來,無論是調整介面、更換原圖,或變更輸出實作,都能明確定位各自的失敗環節。
常見問題
裁切座標需要乘上螢幕縮放倍率嗎?
不能直接相乘。應根據原圖像素尺寸與影像實際顯示的矩形計算比例;螢幕縮放倍率描述顯示裝置,不代表原圖與畫面之間的比例。
為什麼像素邊界要向外取整?
最小邊界向下取整、最大邊界向上取整,可以涵蓋連續選區碰到的像素。取整後仍須將結果限制在原圖範圍內。