JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

用矩陣描述相機:從世界座標到像素

把透視、焦距、相機的姿態,甚至真實鏡頭的扭曲,全部打包成一串優雅的矩陣連乘,將任意 3D 點直接對應到一個像素。

齊次座標:讓投影變成線性運算

還記得上一篇針孔模型留下的小麻煩嗎?要把 3-D 點投影到影像上,我們必須除以深度:世界深度為 Z 的點,落在螢幕上的位置正比於 X/Z 與 Y/Z。正是這個除法讓遠處的東西看起來變小——但它同時也讓透視投影變得很棘手。單純的矩陣只能對輸入做加法與縮放,永遠沒辦法把一個座標除以另一個座標。所以照第 4 篇寫法的投影,根本不可能用一次矩陣乘法表示。本篇要解決這件事,而這個解法會打開整個「相機就是一連串矩陣」的故事。

這個技巧便宜得令人意外。我們在每個點的尾端黏上一個額外的數字——一個 1。2-D 像素 (x, y) 變成三元組 (x, y, 1);3-D 世界點 (X, Y, Z) 變成四元組 (X, Y, Z, 1)。這些加了一格的向量就叫做齊次座標。它的全部用意就是把除法延後:中間所有步驟——旋轉、平移、套用焦距——都變成乾淨的矩陣乘法,而那個惱人的除法則被推遲到最後一個收尾步驟才做。

(x,\,y)\;\longrightarrow\;(x,\,y,\,1) \qquad\qquad [u,\,v,\,w]\;\longrightarrow\;\Bigl(\tfrac{u}{w},\;\tfrac{v}{w}\Bigr)

把點升到齊次形式(左),以及把它投影回真實平面(右)。

從左讀到右。往上:我們拿一個普通點 (x, y),附上齊次尺度 1,得到 (x, y, 1)。往回往下:齊次向量 [u, v, w] 透過把前兩格除以最後一格 w,回到普通平面,得到 (u/w, v/w)。這個最後的 w 正是「除以深度 Z」這個透視除法被藏起來的地方。當我們把矩陣串接起來時,深度會悄悄累積在 w 裡;只有在最後一刻我們才除以它。具體來說,若一條鏈算出 [480, 320, 2],真正的像素就是 (480/2, 320/2) = (240, 160)。

[x,\,y,\,1]\;\sim\;[2x,\,2y,\,2]\;\sim\;[kx,\,ky,\,k],\qquad k\neq 0

尺度不變性:這些齊次三元組全都指向同一個 2-D 點。

這裡的波浪號 ~ 意思是「代表同一個點」。只要一個齊次向量是另一個的非零純量倍數,就視為相等。為什麼?因為除回去會把尺度消掉:[2x, 2y, 2] 除完是 (2x/2, 2y/2) = (x, y),和 [x, y, 1] 一樣。所以我們附上的那個 1 從來都不特別——任何非零數字都行,因為最後的除法會把它正規化掉。正是這份自由買到了線性:矩陣可以隨意把整個向量重新縮放(全部乘上某個 k),因為縮放不會改變我們指的是哪個點。甚至還有額外的好處。把最後一格設成 w = 0,除法 (u/0) 會爆成無窮大——於是 [u, v, 0] 乾淨地代表一個無窮遠點,也就是平行鐵軌相交的方向。那正是第 4 篇的消失點,如今成了我們能直接計算的一等公民。

相機內參:相機的內部

在第 4 篇,針孔模型給了我們一條以物理單位表示的投影射線——可以想成「在感光元件上往上、往旁邊各幾毫米」。但影像並不是用毫米量的;它是用像素量的,而且從左上角開始數(回想第 1 篇)。把一條乾淨的物理射線換算成誠實的像素位址,這份工作交給一個 3×3 的矩陣,叫做相機內參,記為 K。它編碼了鏡頭與感光元件這一整組本身的一切資訊,而對同一台相機來說,它在每一張照片之間都不會改變。

K=\begin{bmatrix} f_x & s & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{bmatrix}

內參矩陣 K——描述一台相機鏡頭與感光元件的五個數字。

我們來替每一格命名。f_x 與 f_y 是以像素表示的焦距——分別是水平與垂直方向。c_x 與 c_y 是主點:光軸刺穿感光元件的那個像素。s 是傾斜(skew),衡量像素網格的兩軸是否完全垂直。最底下那列 [0, 0, 1] 純粹是記帳:它讓輸出維持齊次(一個合法的 [u, v, w] 三元組),這樣我們才能繼續串接。上面兩列的模式正是「乘上焦距,再加上中心偏移」——這就是我們需要的線性配方。

自然會冒出兩個問題。第一,第 4 篇只有單一個 f,為什麼這裡有兩個焦距 f_x 與 f_y?因為感光元件的像素不一定是正方形。物理焦距 f 是一個以毫米表示的數字,但要換成像素,水平方向要除以像素寬度、垂直方向要除以像素高度;如果兩者不同,就得到兩個不同的「像素焦距」。所以 f_x 與 f_y 並不是新謎團——它們就是第 4 篇那個同一個焦距 f,只是沿各軸用該軸的像素尺寸量出來。在大多數現代手機上像素是正方形,f_x ≈ f_y。第二,為什麼主點不在 (0, 0)?因為第 1 篇把影像原點放在左上角,而光軸大約打在感光元件的中心。所以 (c_x, c_y) ≈ (寬/2, 高/2) 把座標重新對齊到像素網格——對 640×480 的影像來說,大約是 (320, 240)。

傾斜 s 值得誠實地交代一句:在幾乎每一台真實相機上它都 ~0,因為感光元件的列與行在製造時就是垂直的。它只在奇特或刻意做出剪切的設置中才出現,所以你心裡可以把它劃掉,把 K 讀成四個有意義的數字 (f_x, f_y, c_x, c_y) 再加上那列記帳。

K 究竟怎麼把射線落到像素上?把投影射線以齊次座標餵給它。假設相機座標系裡的點是 (X_c, Y_c, Z_c)。把 K 乘上 [X_c, Y_c, Z_c],得到齊次像素 [f_x·X_c + c_x·Z_c, f_y·Y_c + c_y·Z_c, Z_c]。注意第三格正好是深度 Z_c——那就是將執行透視除法的 w。除以它,就得到 u = f_x·(X_c/Z_c) + c_x 與 v = f_y·(Y_c/Z_c) + c_y:也就是第 4 篇的正規化射線 X_c/Z_c,被焦距放大、再平移到影像中心。K 字面上就是「焦距 × 射線,再重新對中心」。

相機外參:相機站在哪裡

內參描述相機的內部;它對相機在哪裡、朝哪看完全不發一語。第二份工作屬於相機外參——一個旋轉 R 和一個平移 t,兩者一起描述相機的位姿(pose):它在世界中的朝向與位置。如果說內參是鏡頭與感光元件這組裝置的固定性質,那外參就是每一張照片都會改變的部分,因為每一張照片都是從某處拍、朝著某處拍的。

這裡有個值得記住的類比。你的眼睛是固定的光學儀器——那就是內參,你走來走去時它並不改變。但當你跨過房間、轉動頭部,「在你面前」的東西就完全變了。這個跨步(平移)與轉頭(旋轉)正是外參。相機在投影之前的工作,就是從它自己的視角重新描述每一個世界點——把點從世界座標轉換成相機座標,也就是相機坐在原點、沿著自己的軸往外看的那個座標系。唯有在那個座標系裡,第 4 篇的針孔投影才能套用。

X_{\text{cam}} = R\,X_{\text{world}} + t

世界到相機的轉換:先旋轉,再平移。

逐個符號來看:X_world 是寫在世界座標系裡的 3-D 點(大家都同意、固定共用的座標系)。R 是 3×3 旋轉矩陣;乘上 R 會把世界的軸重新轉向,讓它們對齊相機的觀看方向——它回答「我朝向哪邊?」。t 是一個 3 維平移向量,把原點從世界選定的零點移到相機的光心——它回答「我站在哪裡?」。結果 X_cam 是同一個物理點,但現在是從相機自己的視角來表示。順序很重要:我們先旋轉,再加上 t。做個小檢查:若相機正好坐在世界原點且完全對齊(R 是單位矩陣、t 為零),則 X_cam = X_world——相機與世界一致,正如你所期望。

\begin{bmatrix} X_{\text{cam}} \\ 1 \end{bmatrix} = \begin{bmatrix} R & t \\ \mathbf{0}^{\top} & 1 \end{bmatrix} \begin{bmatrix} X_{\text{world}} \\ 1 \end{bmatrix}

同樣的轉換,齊次形式:R 與 t 打包成一個區塊。

這又是齊次座標帶來的好處。一旦我們把 X_world 補上一個 1,原本分開的「乘上 R,再加上 t」就變成單一一次矩陣乘法。3×3 的 R 與 3 維的 t 並肩排成一個 3×4 的區塊,寫作 [R | t],再加上一列底列 [0, 0, 0, 1] 讓它保持方陣且齊次。把它乘上 [X_world, 1] 會精確重現 R·X_world + t——加上 t 現在是「免費的」,由矩陣代勞。這個 3×4 區塊 [R | t],正是下一節要和 K 扣在一起的那塊拼圖。

完整投影流程:世界座標 → 像素

現在我們把整台機器組起來。這個單元教過的一切——像素網格、顏色、取樣、針孔幾何,以及本篇的三個矩陣概念——全都收攏成一條優雅的方程式,把任意 3-D 世界點直接送到一個像素。剛好有三個階段,而且依固定順序執行:外參把點放進相機座標系,內參把那條射線變成像素,最後的齊次除法執行透視縮小。

影像形成流程:世界點先經過位姿,再經過鏡頭與感光元件,最後經過透視除法,落在感光元件網格上。

一張示意圖,顯示一個 3-D 世界點先被外參轉換成相機座標,再被內參投影到影像平面,並除以深度得到最終像素。

s\begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = K\,\bigl[\,R \mid t\,\bigr] \begin{bmatrix} X \\ Y \\ Z \\ 1 \end{bmatrix}

完整的針孔投影方程式——整個單元的高潮。

從右往左讀,把每個符號追溯回階梯先前引入它的地方。[X, Y, Z, 1]齊次形式的世界點(第 1 節,附上了它的 1)。[R | t] 是第 3 節的外參;它把點從世界座標映射到相機座標——這正是第 4 篇「把場景擺到鏡頭前」的幾何。K 是第 2 節的內參;它套用焦距(第 4 篇的 f,拆成 f_x、f_y)並重新對齊到像素網格(第 1 篇的左上角原點)。右側現在是一個齊次三元組。s 是它第三格冒出來的尺度——而這個 s 不是別的,正是相機座標系裡的深度,也就是第 4 篇透視投影那個除以 Z。把三元組除以 s,就得到誠實的像素 [u, v, 1]。一條方程式;整個單元都在裡頭。

  1. 把世界點升到齊次形式:(X, Y, Z) → [X, Y, Z, 1]。
  2. 套用外參 [R | t],得到相機座標下的點(它相對於鏡頭在哪裡)。
  3. 套用內參 K,用焦距縮放並平移到影像中心——輸出是齊次像素 [u', v', s]。
  4. 讀出尺度 s(深度),然後相除:最終像素是 (u'/s, v'/s)。

我們把一個具體的點跑過整台機器。取一台 640×480 的相機,K 由 f_x = f_y = 800、c_x = 320、c_y = 240、s = 0 給定。讓相機與世界軸對齊,所以 R 是單位矩陣,並讓它沿觀看軸後退 t = (0, 0, 6)——在世界原點後方六公尺。我們要投影世界點 (X, Y, Z) = (2, 1, 4)。

[R\mid t]\begin{bmatrix}2\\1\\4\\1\end{bmatrix} = \begin{bmatrix}2\\1\\4+6\end{bmatrix} = \begin{bmatrix}2\\1\\10\end{bmatrix} \;\xrightarrow{\;K\;}\; \begin{bmatrix}800\cdot2 + 320\cdot10\\ 800\cdot1 + 240\cdot10\\ 10\end{bmatrix} = \begin{bmatrix}4800\\3200\\10\end{bmatrix}

先外參再內參,深度留作尺度 s = 10。

逐階段來看:外參把世界點 (2, 1, 4) 變成相機座標系的點 (2, 1, 10)——深度從 4 長到 10,因為相機在後方 6 公尺。接著 K 相乘:第一列得 800·2 + 320·10 = 1600 + 3200 = 4800,第二列 800·1 + 240·10 = 800 + 2400 = 3200,第三列單純把深度帶下來,10。於是齊次像素是 [4800, 3200, 10],尺度 s = 10——正好是相機座標系的深度,證實 s 真的就是透視除法。最後一步相除:u = 4800/10 = 480、v = 3200/10 = 320。這個點落在像素 (480, 320)——在我們 640×480 影像之內,略偏中心的右下方。整台機器從頭跑到尾,產生了乾淨的整數像素。

import numpy as np

# Intrinsics: focal length in pixels, principal point at image center
K = np.array([[800,   0, 320],
              [  0, 800, 240],
              [  0,   0,   1]])

# Extrinsics: camera aligned with world (R = I), backed off 6 m along Z
R = np.eye(3)
t = np.array([0, 0, 6])
Rt = np.hstack([R, t.reshape(3, 1)])   # 3x4 block [R | t]

# A world point in homogeneous coordinates
Xw = np.array([2, 1, 4, 1])

# Full pipeline: extrinsics, then intrinsics
hom_pixel = K @ Rt @ Xw                  # = [4800, 3200, 10]
s = hom_pixel[2]                         # the scale = camera-frame depth
u, v = hom_pixel[0] / s, hom_pixel[1] / s
print(u, v)                             # -> 480.0 320.0
用幾行 NumPy 寫出完整的世界 → 像素投影。

真實鏡頭打破針孔假設:畸變

我們那條優雅的方程式假設了一個真正的針孔。但回想第 4 篇的亮度取捨:小到足夠銳利的針孔幾乎不透光,所以真實相機用鏡頭取代小孔,鏡頭能聚集多得多的光並把它聚焦。問題在於沒有任何真實鏡頭能完美彎折每一條光線。光線實際落點與理想針孔說它該落的位置之間的偏差,就叫做鏡頭畸變,它是我們整潔模型與真實照片之間最大的一道裂縫。

最主要的一種是徑向畸變(radial distortion):誤差隨著與影像中心的距離而增大,於是把直線扭成曲線。它有兩種樣貌。在桶形(barrel)畸變中,線向外彎,影像看起來像裹在一個木桶上——正是 GoPro 或魚眼的樣子,常見於視野很大的廣角鏡頭。在枕形(pincushion)畸變中,線向內彎,常見於望遠端。一個鮮明的測試:用廣角手機鏡頭在畫面邊緣附近拍一條直的牆緣,看著它彎曲,即使你明知那道牆筆直。

x_d = x\,(1 + k_1 r^2 + k_2 r^4 + \cdots), \qquad y_d = y\,(1 + k_1 r^2 + k_2 r^4 + \cdots), \qquad r^2 = x^2 + y^2

徑向畸變模型:對理想座標施加一個依半徑而定的伸縮。

逐個符號來看:(x, y) 是理想的正規化影像座標——乾淨的針孔位置,相對於中心量測。r 是該點到影像中心的距離,所以 r² = x² + y²。k_1, k_2 是徑向畸變係數——刻畫這顆特定鏡頭的幾個小數字(k_1 < 0 給出桶形,k_1 > 0 給出枕形)。因子 (1 + k_1 r² + k_2 r⁴ + …) 是一個只依賴 r 的伸縮。(x_d, y_d) 是光線真正落點的畸變座標。關鍵直覺:在中心 r = 0 時,因子正好是 1,所以什麼都不動——影像正中央保持乾淨。當 r 朝角落增大,r² 與 r⁴ 項膨脹,所以角落被推或拉得最多。這正是為什麼直線在中間看起來沒事、卻在邊緣彎曲。小例子:取 k_1 = 0.1,位於 r = 1 的點被放大 1.1 倍(推了 10%),而位於 r = 0.5(r² = 0.25)的點只被放大 1.025 倍——少了四倍。

還有第二個、較小的效應:切向畸變(tangential distortion),當鏡頭與感光元件不完全平行時發生(輕微的製造不對齊)。它把點往側邊而非徑向位移,用另外兩個係數建模,通常寫作 p_1 與 p_2。徑向的 k 與切向的 p 一起構成相機的畸變特性。關鍵心智模型:畸變是包在第 4 節乾淨針孔流程外面的一層加性修正層——矩陣 K 與 [R | t] 仍照常做事,而畸變項描述現實如何把理想像素從它預測的位置推開。

相機校正,以及這一切為何重要

我們建好了一個內含未知數的模型:內參 K、畸變係數,以及每一張照片的一組外參相機校正就是把這些數字填進去的程序。這個想法美在簡單:給相機看一個你早已精確知道其幾何的物體,從數個角度拍它,再讓軟體反推出能讓模型預測與照片實際記錄相符的相機參數。

經典的已知物體是棋盤格。它的角點落在一個間距已知的完美網格上,所以對每張照片,軟體都能偵測每個角點出現在哪個像素,並拿來和模型預測的位置比較。這個落差就是要最小化的誤差。流程是「已知幾何進 → 未知相機參數出」:你餵進一個你完全理解的東西(棋盤),於是唯一還需要被解釋的,就只剩相機本身。

  1. 印一張方格尺寸已知的棋盤格,貼到一塊平板上。
  2. 從不同角度與距離拍 10–20 張,讓棋盤填到畫面的不同位置。
  3. 在每張影像中偵測每一個內角點的像素座標。
  4. 求解出能最佳重現所有偵測角點的 K、畸變係數,以及每張照片的 [R | t]。
  5. 用還原出的 K 與畸變,對未來的影像去畸變並精確投影。

為什麼要費這麼大的功夫?因為一個值得信賴的相機模型,是所有需要還原投影之事的基礎——也就是第 4 篇就點出的困難反問題。前向投影把深度丟掉了(許多 3-D 點塌縮成同一個像素);從影像重建 3-D 結構,唯有在你精確知道相機如何把世界映射到像素時才行得通。校正讓這份知識變得精確。有了它,你就能做 3-D 重建、從照片量測真實世界的距離、用兩台相機算立體深度、為擴增實境把逼真的虛擬物件種進場景,並讓機器人與自駕車判斷東西實際在哪裡。

這就為本單元收尾了:你現在能追隨一束來自世界的光,穿過鏡頭與感光元件,進到一格格像素——並把整段旅程寫成一條矩陣鏈。接下來的內容直接建立在這個基礎上。前方的多視角幾何單元,會用兩台或更多已校正的相機來三角測量深度、重建完整的 3-D 場景——把你剛精通的投影反轉過來、往回跑。