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

從像素到幾何:相機如何看見世界

看相機如何把三維世界壓進平面感光元件——並認識讓我們反推這個把戲的針孔模型與相機校正。

為何需要三維視覺?遺失的深度問題

把相機對準世界,會悄悄發生一件戲劇性的事:一個豐富的三維場景——有近的東西、也有遠的東西——被壓扁到一張平面的像素紙上。這張紙忠實記錄了光線「落在哪裡」以及「有多亮」,卻把每個點的一個數字給忘了:它實際上離我們多遠。這個遺失的數字就是深度,而把它找回來,正是整條學習路線著迷的目標。

先看一眼罪魁禍首:每台相機本質上都是一個針孔,把三維場景匯聚到一塊平面的成像平面上。

左側有一個場景點,光線穿過中間單一的針孔,在右側的成像平面上形成一個上下顛倒的影像。

想想皮影戲。你的手是真正的三維物體,但燈光只把它的輪廓投到牆上。光憑那個平面的剪影,你其實分不出這隻手是小而貼近牆,還是大而離牆很遠——許多不同的手會投出一模一樣的影子。相機做的正是這種壓扁,而這帶出本篇最重要的一句話:無窮多個三維點會對應到同一個像素。 凡是落在相機某一條視線(一條射線)上的點,都會落到影像上完全相同的位置。

要逆轉一個真的遺失了資訊的東西,怎麼辦?把資訊補回去。這條路線會一階一階往上爬:先從一台相機開始,徹底搞懂它如何投影(本篇);再加上第二台相機,讓兩個視角像你的雙眼那樣用三角測量算出深度;接著串起多台相機/多個影格,重建整個場景並追蹤相機的軌跡;最後,在純幾何走到盡頭時,讓神經網路從資料中學習深度。走完這條路線,平面照片就會變成可以漫遊的三維世界。

針孔相機模型

真正的暗箱(camera obscura,「黑暗的房間」)是每台相機的祖先。在一個暗盒的某面牆上戳一個小孔。場景中某個點的光線沿直線前進,穿過這個孔,打在對面的內壁上。因為每個場景點都把自己的光線送過這同一個孔,後牆上形成的就是一幅完美、只是上下顛倒的世界影像。我們來替各部位命名:那個孔是光心(或稱針孔);從孔筆直射出、垂直於後牆的那條線是主軸;形成影像的後牆是成像平面;而從針孔到成像平面的距離就是焦距,寫作 f。

整個模型濃縮成一張圖:一個場景點、唯一的針孔(光心),以及在其後方一個焦距 f 處平面上的倒立影像。

來自場景點的光線匯聚穿過針孔,再發散開來,在成像平面上形成一個上下顛倒的影像。

接著談幾何。把座標原點設在針孔:讓 Z 沿主軸量測正前方(這就是深度),讓 X、Y 量測左右與上下。取一個位於 (X, Y, Z) 的場景點。從這個點到針孔的射線,以及繼續延伸到成像平面的射線,會構成兩個相似三角形,它們在針孔處尖端對尖端相接。其中一個三角形的「高」是 Y、「底」是 Z(點到針孔);另一個的高是 v(它落在影像上的位置)、底是 f(針孔到成像平面)。相似三角形對應邊的比值相等——光憑這一個事實,就是整個透視現象的引擎。

u = f\,\dfrac{X}{Z}, \qquad v = f\,\dfrac{Y}{Z}

透視投影:把兩個相似三角形相等的邊長比值重新整理而得。

把它唸出來。(X, Y, Z) 是相機自己座標系裡的三維點,其中 Z 是它的深度——它在正前方多遠處。f 是焦距,單位和 X、Y、Z 一樣是長度。(u, v) 是這個點落在成像平面上的位置。這條式子說:把橫向位置 X 乘以 f,再除以深度 Z。這個除法就是一切。把一個 1 公尺寬的物體放在 Z = 2 公尺處,它成像的大小是 f/2;把它推到 Z = 4 公尺——遠了一倍——它成像就變成 f/4,剛好是一半大小。距離加倍,影像減半。這就是為什麼鐵軌會朝一點縮去、遠方的朋友看起來小小的——這不是什麼光學怪癖,只不過是除以 Z 罷了。

齊次座標:數學家的妙招

投影式 u = fX/Z 是對的,但在數學上很彆扭。視覺裡幾乎所有其他操作——旋轉相機、移動相機、串接多台相機——都是用矩陣來做的,因為矩陣能乾淨地合成(做一步,再做下一步,把它們相乘就好)。可是我們的投影裡藏著一個對 Z 的除法,而除法不是單一一次矩陣乘法能辦到的事。我們需要一個辦法,把「除以 Z」翻譯成矩陣的語言。這個辦法就是齊次座標,後面每一篇都倚賴它。

這個點子幾乎有點調皮:多加一個座標。把二維影像點 (u, v) 用三元組 (u, v, 1) 來表示,把三維點 (X, Y, Z) 用 (X, Y, Z, 1) 來表示。接著定一條新規則——尺度無所謂:一個三元組 (x, y, w) 代表的是把它整個除以最後一項所得到的普通點 (x/w, y/w)。於是 (4, 6, 2)、(8, 12, 4) 和 (2, 3, 1) 全都是同一個影像點 (2, 3)。從幾何上看,你把平面往上抬高了一個維度,而在那個更大的空間裡,每一條過原點的直線,現在就代表平面上的一個點。報酬是:投影變成一次乾淨俐落的矩陣乘法,而那惱人的除法被推遲到最後唯一的一步——「除以最後一個座標」——只在最末了做一次。

\begin{bmatrix} u \\ v \\ 1 \end{bmatrix} \sim \begin{bmatrix} s\,u \\ s\,v \\ s \end{bmatrix}, \quad s \neq 0

一個齊次影像點只在一個非零尺度 s 的意義下被定義。

逐個符號來看:波浪號 ~ 代表「在尺度意義下相等」——左右兩邊指的是同一個影像點,即使數字不一樣。s 是任何你喜歡的非零尺度因子;你可以把一個齊次向量乘上任何 s ≠ 0 而不改變它所代表的東西。要還原成真正的像素,就做正規化:把前兩項除以第三項,(su, sv, s) → (u, v)。第二節裡所有的透視除法,就藏在這裡:矩陣會產生出 (su, sv, s),而其中的 s 最後會剛好等於深度 Z,所以最後的「除以 s」就是「除以 Z」。那團混亂從未消失——我們只是把它推遲成一個乾淨、可預測的單一步驟。

# A homogeneous image point, and how to read off real pixels.
# Equivalence "up to scale": all three of these are the SAME pixel.
p1 = [4.0, 6.0, 2.0]    # (s*u, s*v, s) with s = 2
p2 = [8.0, 12.0, 4.0]   # the same point, scaled again by 2
p3 = [2.0, 3.0, 1.0]    # the canonical form (last entry = 1)

def to_pixel(h):
    s = h[2]                       # the last (scale) coordinate
    return (h[0] / s, h[1] / s)    # divide it out -> (u, v)

to_pixel(p1)   # -> (2.0, 3.0)
to_pixel(p2)   # -> (2.0, 3.0)   same pixel!
to_pixel(p3)   # -> (2.0, 3.0)
三個不同的齊次三元組,卻是同一個像素——靠著除以最後一個座標就能還原。

內參與外參:完整的相機矩陣

現在我們可以把完整的相機組裝成一條式子,做法是把相機暗地裡同時做的兩件工作拆開來。工作一——相機在哪裡? 世界有自己的座標系(比方說以房間某個角落為原點),但我們的投影公式假設點已經是用相機座標系給出的了。所以我們得先把世界座標的點搬進相機座標。工作二——這顆鏡頭與這片感光元件,要怎麼把成像平面上的公尺數變成像素? 工作一是外參;工作二是內參

外參回答的是「相機在哪、朝哪個方向」,形式是 [R | t]。R 是一個 3×3 的旋轉,把世界的座標軸轉到與相機對齊(它面朝哪個方向);t 是一個三維的平移向量,移動原點(它坐落在哪)。把它們並排黏在一起,就成了一個 3×4 的方塊:餵給它一個世界座標的點,吐出來的就是同一個點、改用相機座標表示,隨時可以投影。打個比方:外參就是擺三腳架的姿勢——把相機搬到某處、瞄準。移動相機,改變的只有 [R | t]。

內參,也就是矩陣 K,是相機固定不變的內在個性——你走來走去,它都不變。K 把成像平面上一個投影後的點,轉換成真正的像素列/行號碼。它帶有四個有意義的數值:f_x 與 f_y 是以像素為單位量測的焦距(有兩個,因為感光元件的像素不一定是完美的正方形);(c_x, c_y) 是主點,也就是主軸刺穿感光元件的那個像素——通常靠近影像中心;還有一個歪斜項(skew),對真實相機而言基本上永遠是 0。打個比方:內參就是這顆鏡頭與這片感光元件的個性,出廠時就烙印好了。

\mathbf{x} \;\sim\; K\,[\,R \mid \mathbf{t}\,]\,\mathbf{X}

完整的投影:先擺好點,再把它變成像素。

整台相機就在這一行裡。X 是一個齊次的三維世界點 [X, Y, Z, 1]ᵀ。[R | t] 是 3×4 的外參方塊,把它搬進相機座標。K 是 3×3 的內參矩陣,把它變成像素。結果 x = [su, sv, s]ᵀ 是一個齊次的像素,所以你最後把 s 除掉,就讀出真正的行號 u 與列號 v——正是上一節的正規化步驟。那個 ~ 提醒你,這個等式只在尺度意義下成立。從右往左讀,它不過是把我們整個故事疊起來:擺好點(外參)、投影並像素化(內參),然後正規化。

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

內參矩陣,逐項列出(實務上歪斜 s ≈ 0)。

攤開來,K 是上三角矩陣:第一列 (f_x, s, c_x),中間列 (0, f_y, c_y),底列 (0, 0, 1)。底列那個 1,正是讓輸出的最後一個座標扛起正規化所需深度尺度的關鍵。把 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,你就回到 u = f_x·X_c/Z_c + c_x——第二節的公式,如今換成像素、再加上主點偏移。這條式子 x ~ K[R|t]X,正是每個三維視覺演算法暗地裡都在逆轉的那條式子,這也正是為什麼我們必須先量出 K。取得 K,恰恰就是相機校正——下一節的主題。

相機校正:量測你的相機

f_x、f_y、c_x、c_y 這些數字並沒有印在你相機的側邊,而且會因鏡頭而異(變焦時也會改變)。在你知道它們之前,每一次「像素到幾何」的計算都只是瞎猜,而你之後算出的任何「以公尺為單位的深度」都毫無意義。量測它們——以及鏡頭畸變——的程序,就是相機校正。最經典、又出奇實用的配方是張正友法(Zhang's method):就是你一定見過、在網路攝影機前晃來晃去的那塊棋盤格。

  1. 印一張方格大小已知的棋盤格,固定在某個平面上。因為圖案規則又剛硬,你精確地知道每個角點在現實世界的 (X, Y)——而且可以把它的 Z 設為 0,因為這塊板就是一個平面。
  2. 從大約 10–20 個不同的角度與距離拍攝這塊板,把它傾斜,讓每一張在幾何上都看起來不一樣。
  3. 在每一張照片裡,自動偵測棋盤格角點到次像素的精度——這些就是 (u, v) 像素位置。
  4. 現在你握有許多已知的「三維角點 ↔ 二維像素」配對。反解出能一次最好地解釋它們全部的未知數:共用的那一個 K(與畸變),外加每張照片各自的 [R | t]。
  5. 做最佳化(最小平方擬合),把「角點實際落點」與「K[R|t] 預測落點」之間的總誤差降到最小——然後留下 K 和畸變係數。
import cv2, numpy as np

# 3D corner positions on the flat board, in board coordinates (Z = 0).
# Identical for every photo, because the board itself never changes.
objp = np.zeros((6 * 9, 3), np.float32)
objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2)

obj_points, img_points = [], []        # 3D<->2D pairs, one set per photo
for img in checkerboard_photos:        # ~15 views from many angles
    found, corners = cv2.findChessboardCorners(img, (9, 6))
    if found:
        obj_points.append(objp)        # where the corners ARE (world)
        img_points.append(corners)     # where they LAND  (pixels)

# Solve backward for the camera: K, distortion, and each view's R, t.
err, K, dist, rvecs, tvecs = cv2.calibrateCamera(
    obj_points, img_points, image_size, None, None)
用 OpenCV 實作張正友法:偵測角點,再反解出 K 與畸變。

真實的鏡頭會比完美針孔所預測的,多彎折一點光線,尤其是往畫面邊緣去的地方。看得見的症狀就是徑向畸變:靠近邊框的直線會像木桶一樣往外鼓(或像針墊一樣往內縮)。校正也會估計幾個畸變係數,好讓軟體能在信任任何幾何之前,先把影像去畸變——把那些線拉直。最常見的模型,是依據每個點離中心多遠來修正它:

x_{\text{distorted}} = x\,\bigl(1 + k_1 r^2 + k_2 r^4\bigr)

徑向畸變模型(以 x 座標為例;y 完全相同)。

逐項來看:x 是正規化影像座標中的理想(未畸變)點,從主點起算;r 純粹是它離中心的距離,r = √(x² + y²);而 k₁、k₂ 是校正時找出的徑向畸變係數。括號 (1 + k₁r² + k₂r⁴) 是一個溫和的拉伸因子。注意它只與 r 有關:在正中心 r = 0,所以因子剛好是 1(那裡沒有畸變),但往邊角去時 r 變大,而且——關鍵在於——它是以 r² 和 r⁴ 的形式進來的,所以修正量爬升得很快。這正是為什麼邊緣扭曲得比中間嚴重得多。k₁ 為正會把點往外推(桶狀畸變);k₁ 為負則把點往內拉(針墊畸變)。一旦握有 K 和這些係數,一台校正過的相機終於能給出可度量、值得信賴的幾何。

校正並不改變影像的本質——它仍是一格格像素值——但它告訴我們每個(行, 列)精確的幾何意義,把原始像素變成可量測的數據。

一個由小格子組成的網格,每格裝著一個 0 到 255 的亮度數字,把數位影像表示成一個數值陣列。

一張影像還不夠:尺度的模糊性

這裡是讓人謙卑的關鍵句。即使有一台校正得完美無瑕的相機——K 與畸變都精確到小數點——單一一張影像仍然無法告訴你以公尺為單位的深度。 回想原因:投影把 X 乘上 f/Z,所以一台 30 公分外的玩具車,和一台 6 公尺外、大小剛好對得上的真車,會落在完全相同的像素上。深度 Z 被除掉了,而它把世界的絕對尺度也一併帶走了。

攝影師每天都在利用這一點。那個看似「托著」比薩斜塔、或用兩根手指「捏住」太陽的遊客,用的就是強迫透視:一個近處的小東西和一個遠處的大東西沿著同一條射線對齊,於是平面照片無法把它們分開。這不是什麼特殊鏡頭的把戲——這正是投影方程式照著我們推導的樣子在運作。我們把這個根本的極限叫做尺度(或深度)模糊性:從單一視角,世界只能在一個未知的整體尺度意義下被知道。

把遺失的資訊補回去,恰好有兩條路。路徑一——幾何:加上另一個視角。 從第二個位置觀看,每個場景點現在落在兩條射線上;這兩條射線相交之處,就用三角測量定出它的深度,這正是你雙眼判斷距離的方式。路徑二——先驗:從資料中學習。 用上百萬張影像訓練一個網路,讓它根據熟悉的線索猜測深度(一台車大概就那麼大;紋理會隨距離縮小)——這就是單目深度估計,是從單一影像做深度估計的一種學習式做法。幾何精確,但需要更多視角;學習只需一張影像,但會繼承它的訓練資料默默做出的種種假設。

兩條路我們都會走。接下來是幾何那條,以它最簡單、也最美的樣貌登場——兩台相機並排擺放,就像你的雙眼一樣,把一對平面照片變成一張真正的深度地圖。前往立體視覺