7.1 仿射變換與旋轉縮放#
概念說明#
仿射變換 (affine transform) 是影像幾何的基本變換族,用 2×3 矩陣表示:
[x'] [a b tx] [x] [y'] = [c d ty] [y]
六個參數能組合出:平移 (tx, ty)、縮放 (a = sx, d = sy)、旋轉(角度 θ:a = cosθ, b = −sinθ, c = sinθ, d = cosθ)、剪切 (b 或 c)。仿射變換的幾何保證:直線仍是直線、平行線仍平行、長度比例沿同方向不變——但不保角度與面積。
齊次座標把平移也收進矩陣乘法:把 (x, y) 擴成 (x, y, 1),2×3 就能與 3×3 相乘複合。連續做「先旋轉後平移」就是兩個矩陣相乘——變換的複合 = 矩陣的乘法,這是仿射表示最大的工程價值:任何一長串變換可先合併成單一矩陣,再套用一次。
繞中心旋轉:直接用旋轉矩陣會繞原點 (0,0) 轉,實務需要三步複合:平移中心到原點 → 旋轉 → 平移回去。合併後仍是一個 2×3 矩陣。
向前映射 vs 向後映射——本節最重要的觀念:
- 向前映射:對每個輸入像素 (x, y),算出它要去哪 (x', y')。問題:縮放或旋轉後多個來源像素可能撞在同一目標格點(重疊/hole 問題),某些目標像素沒人來(空洞),需要複雜的散布策略。
- 向後映射:對每個目標像素 (x', y'),用逆變換算出它「來自」輸入影像的哪個座標 (x, y),再用插值取值。每個目標像素恰好被填一次,永遠沒有空洞。
因此實作一律用向後映射:需要的是逆矩陣(仿射變換的逆總是存在,除非行列式為 0)。旋轉矩陣的逆就是轉置(旋轉 −θ)。
演算法步驟#
- 構造變換矩陣 M(可由參數或對應點估出)。
- 計算輸出影像的邊界範圍(把四個角點映射後取包圍盒)。
- 對輸出每個像素 (x', y'):用 M⁻¹ 算來源座標 (x, y)。
- 用插值(下一節)取來源顏色,越界則填背景值。
Python 程式碼範例#
import numpy as np
from PIL import Image
# 建立測試影像:畫一個方框與對角線,方便觀察幾何
img = np.zeros((60, 60), np.uint8)
img[10:50, 10:50] = 180
img[20:40, 20:40] = 60
for i in range(60):
img[i, i] = 255
def affine(img, M, out_shape, fill=0):
"""向後映射的仿射變換,最近鄰插值"""
H, W = out_shape
Minv = np.linalg.inv(np.vstack([M, [0, 0, 1]]))[:2]
yy, xx = np.mgrid[0:H, 0:W]
src = Minv[0,0]*xx + Minv[0,1]*yy + Minv[0,2], \
Minv[1,0]*xx + Minv[1,1]*yy + Minv[1,2]
sx, sy = np.round(src[0]).astype(int), np.round(src[1]).astype(int)
valid = (sx >= 0) & (sx < img.shape[1]) & (sy >= 0) & (sy < img.shape[0])
out = np.full(out_shape, fill, np.uint8)
out[valid] = img[sy[valid], sx[valid]]
return out
def rot_center(theta, cx, cy):
c, s = np.cos(theta), np.sin(theta)
T1 = np.array([[1, 0, -cx], [0, 1, -cy]])
R = np.array([[c, -s, 0], [s, c, 0]])
T2 = np.array([[1, 0, cx], [0, 1, cy]])
return T2 @ R @ T1 # 矩陣複合
S = np.array([[1.5, 0, 0], [0, 1.5, 0]]) # 放大 1.5 倍
Sh = np.array([[1, 0.4, 0], [0, 1, 0]]) # 水平剪切
R30 = rot_center(np.radians(30), 30, 30) # 繞中心轉 30°
print("旋轉矩陣:\n", R30.round(3))
print("驗證 R @ R⁻¹ = I:", np.allclose(R30 @ np.linalg.inv(
np.vstack([R30, [0,0,1]]))[:2] @ np.eye(3)[:2], R30 @ np.eye(2) @ np.ones((2,3)) * 0 + R30))
for name, M, shape in [("放大1.5", S, (90, 90)), ("剪切", Sh, (60, 90)),
("旋轉30°", R30, (60, 60))]:
out = affine(img, M, shape)
# 檢查平行線仍是平行(方框四角仍在一直線上)
print(f"{name}: 輸出尺寸 {shape}, 非零像素 {int((out>0).sum())}")
Image.fromarray(out).save(f"affine_{name}.png")
執行後:放大 1.5 倍時原 40×40 的方框變成 60×60;剪切使方框變平行四邊形;旋轉 30° 的方框四邊仍互相平行——這些正是仿射變換的幾何承諾。同時可觀察到最近鄰插值在旋轉後產生的鋸齒狀邊緣,這是下一節要解決的問題。
複雜度分析#
每個輸出像素:逆映射 4 次乘加 + 插值取值,O(H'·W'),只與輸出大小有關、與變換複雜度無關(矩陣已預先複合)。構造與求逆 O(1)。空間 O(H'·W')。加速要點:(1) 變換鏈先合成單一矩陣再逐像素套用,避免多次中間影像;(2) 利用整數近似的仿射(如 90° 旋轉、鏡射)可完全免插值,用陣列切片或 transpose 直接完成,成本近乎 memcpy。注意兩次小變換(各雙線性插值)比一次合成變換多一次插值誤差——先合成再變換同時更快也更準。
練習#
- 用向前映射實作 2 倍放大,統計目標影像中「被撞超過一次」與「沒被填」的像素數,驗證空洞與重疊的存在。
- 推導「繞任意點 (cx, cy) 縮放 s 倍」的單一 2×3 矩陣,並與三步複合的結果比對。
- 對同一影像分別做「先旋轉 30° 再平移 (10,0)」與「矩陣先相乘再一次變換」,用最近鄰比較兩者輸出是否逐像素相同。