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

兩視圖的隱藏法則:對極幾何

揭開連結同一場景任意兩張照片的優雅幾何,並以基本矩陣與本質矩陣加以捕捉。

當相機不再對齊

在立體視覺那一篇,我們享受了一種舒適的奢侈:兩台相機並排鎖在一起、朝同一方向看,影像平面完美平行。那種整齊的排列,讓左影像中的一個點可以沿著同一條水平列直接滑過去,找到它在右影像中的夥伴。但真實世界很少這麼乾淨。隨手用手機從略為不同的位置拍兩張雕像照片,或讓單一相機一邊錄影一邊在場景中移動——此時兩個視點不但位置不同,連朝向也不同。水平列的小技巧就失效了。我們需要一條更普遍的法則。

貫穿整篇的核心問題是:給定影像 1 中的一個點,它在影像 2 中的對應點到底可能出現在哪裡? 直覺上答案似乎是「影像 2 的任何地方」——畢竟我們還不知道那個點的深度。但這種悲觀是錯的。同一個剛性場景的兩張照片,暗中被幾何牢牢綁在一起,而這種束縛會大幅縮小搜尋範圍。研究這種束縛的學問,就叫做 對極幾何

整個構想建立在一張你應該牢牢記住的圖上。在世界中選一個 3D 點,叫它 X。來自 X 的光,穿過第一台相機的光學中心(我們稱為 C)抵達它。同樣的光,也穿過第二台相機的中心 C' 抵達它。於是空間中漂浮著三個特別的點:兩個相機中心 C 與 C',以及世界點 X。三個點(只要不在同一條直線上)永遠恰好決定一個平面。這個平面就是一切運轉所依的樞紐。

兩台相機觀看同一個 3D 點 X。兩個中心 C、C' 與點 X 張成同一個平面。

兩個相機中心與一個 3D 點構成位於同一平面上的三角形;該點投影到每張影像中,並在第二張影像上畫出一條線。

對極約束

讓我們仔細為每個角色命名,因為這些詞會反覆出現。首先是基線:連接兩個相機中心 C 與 C' 的直線。它是整個架構的軸——把它想成你兩眼之間的連線,即使兩眼朝向不同也一樣。其餘的一切都掛在基線上。

現在把基線延長,直到它穿過每個影像平面。它刺穿影像 1 之處,得到一個特別的像素,叫做對極點 e;刺穿影像 2 之處,得到對極點 e'。美妙的是,影像 1 中的對極點,其實就是另一台相機中心的影像——它正是「如果你能拍到相機 2,它會出現在哪裡」的位置。(若兩台相機朝向大致相同,對極點可能落在可見畫面外很遠處,甚至在無窮遠,這都沒關係。)

最後,回想通過 C、C' 與 X 的對極平面。用這個平面去切影像平面 1,你會得到一條直線——一條對極線。再用它去切影像平面 2,你會在那裡得到對應的對極線。接下來就是本篇要傳達的頭條結論,整篇都是為了它而寫:

這正是我們在立體視覺中珍視的好處,如今以完全一般化的形式重現:尋找對應點,從在整張影像上的二維搜尋塌縮為沿單一直線的一維行走。這是巨大的節省,而且一舉兩得:它讓比對更快,也讓我們能剔除不可能的對應。任何不落在對極線上的候選像素,無論長得多像,都不可能是真正的對應。稍後在清理含雜訊的匹配時,我們正是要倚靠這個過濾器。而一旦在線上找到正確對應,把兩條射線在 3D 中交回去就能還原該點的深度——這就是立體視覺裡那套 三角測量 的構想,只是擺脫了相機平行的假設。

基線、對極點與對極線:影像 1 中的一個點,將其對應釘在影像 2 中的單一直線上。

基線連接兩中心並在每張影像上交於一個對極點;對極平面在每張影像上切出一條對極線,對應點必落於其上。

基本矩陣

幾何很美,但電腦想要代數。上一節的一切——對極平面、對極線、對極點——都能裝進一個 3×3 的矩陣裡,叫做 基本矩陣 F。F 最棒的一點,在於它不需要什麼:它直接作用在原始像素座標上。你完全不必知道相機的焦距、感光元件尺寸或任何校正參數就能用它。這使得 F 成為「隨手抓兩張網路照片、想把它們關聯起來」時,第一個會拿出來的工具。

F 在一個低調卻關鍵的地方很特別:它的秩為 2,而非一般 3×3 矩陣會有的滿秩 3。秩為 2 意味著它的行列式為零,並會把空間「壓扁」一個維度——而少掉的那個維度恰恰就是對極點。具體來說,對極點 e 是 F 的零向量(被 F 送到零的方向,使得 F·e = 0),而 e' 是其轉置的零向量。所以「秩為 2」不是技術細節:它是代數在記住「有兩個特別的點——對極點——存在」這件事。任何誠實估計出的 F 都必須遵守這點,這在第 5 節會反咬我們一口。

F 實際上什麼?它是一台把「一張影像中的點」變成「另一張影像中對極線」的機器。把影像 1 的齊次像素 x 餵給它,它就回傳影像 2 中的直線 l' = F·x——而我們已經知道真正的對應 x' 必落在 l' 上。這一句話就是完整的對極約束,可以寫成一條乾淨的方程式:

\mathbf{x}'^{\top}\,\mathbf{F}\,\mathbf{x} = 0

對極約束:每一組真實對應都必須滿足的單一純量方程式。

讓我們逐符號讀。向量 x = [u, v, 1]ᵀ 是影像 1 中的一個點,以齊次座標書寫——像素行 u、列 v,加上尾端的 1(這個尾端的 1 是第 1 篇提過的標準技巧,讓 3×3 矩陣能同時處理「類似旋轉」與「類似平移」的效果)。向量 x' = [u', v', 1]ᵀ 是影像 2 中的對應點。F 是我們的 3×3 基本矩陣。現在把乘積分組為 l' = F·x:這是一個 3 維向量,編碼影像 2 中的一條直線,寫成直線 a·u' + b·v' + c = 0 的係數 [a, b, c]。剩下的乘法 x'ᵀ·l' 不過是一個內積,而在齊次座標中,「一個點落在一條線上」正是「該點與該線的內積為零」這件事。所以 x'ᵀ F x = 0 字面上就在說「x' 落在 x 的對極線上」。圖像與代數說的是同一件事。

舉個具體例子:假設 F·x 算出來是 l' = [1, 0, -200]ᵀ。它編碼的是直線 1·u' + 0·v' − 200 = 0,也就是影像 2 中的鉛直線 u' = 200。約束 x'ᵀ l' = 0 於是說:任何真正的對應,其行座標 u' 都必須等於 200——它可以沿著那條鉛直線上下滑動,但不能離開它。這條純量方程式對影像對中每一組正確對應都成立,這正是它成為幾乎所有雙視圖幾何背後那位低調主力的原因。

F 把點 x 變成直線 l' = F·x;對應 x' 必須滿足 x'ᵀ F x = 0,亦即落在 l' 上。

左影像中的一個點被 F 映射成右影像中的一條線;對應點落在那條線上。

本質矩陣

基本矩陣慷慨得令人讚嘆——它不要求任何校正——但正是這份慷慨藏著一件事。因為 F 用像素說話,它把兩件不相干的東西混在一起:每台相機內部的特性(焦距、主點)和兩個視點之間外部的運動。如果我們在第 1 篇已經做過 相機校正、因而知道內參矩陣 K,就能把相機的內部特性剝掉,露出底下純粹的幾何。這個提煉出的物件,就是 本質矩陣 E。

訣竅是不再以像素度量,而改以正規化座標度量。把像素 x 乘上內參的反矩陣,x̂ = K⁻¹x,就抵消了相機內部的縮放,把該點重新表達成相機自身座標系中一個純粹的方向——彷彿每台相機都是理想化的「單位焦距」相機。在這套乾淨的座標裡,基本矩陣化為本質矩陣,而對極約束的形式完全一樣,x̂'ᵀ E x̂ = 0,只是作用在正規化點上。F 曾是光學與運動的混湯,E 則關乎運動。

\mathbf{E} = [\mathbf{t}]_{\times}\,\mathbf{R}, \qquad \mathbf{E} = \mathbf{K}'^{\top}\,\mathbf{F}\,\mathbf{K}

左:E 由兩相機之間的旋轉 R 與平移 t 構成。右:透過內參,從 F 通往 E 的橋樑。

讀第一條方程式,E = [t]×·R。這裡 R 是 3×3 的旋轉,把相機 1 的朝向轉成相機 2 的朝向——它回答「第二台相機相對第一台轉了多少?」。向量 t 是兩中心之間的平移——「相機移動了多遠、往哪個方向?」。符號 [t]× 是 t 的反對稱矩陣:一個特製的 3×3 矩陣,使得乘上它就等於做外積,亦即對任意向量 v 都有 [t]× v = t × v。我們需要外積,是因為對極平面由兩個方向決定(基線 t 與視線射線 R x̂),而外積正是檢驗三個方向是否共面的工具。所以 E = [t]×R 不過是「先旋轉射線,再與基線測共面性」寫成一個矩陣。

[\mathbf{t}]_{\times} = \begin{bmatrix} 0 & -t_z & t_y \\ t_z & 0 & -t_x \\ -t_y & t_x & 0 \end{bmatrix}

t = (tx, ty, tz) 的反對稱矩陣:乘上它就等於做外積 t × (·)。

別被那一格格數字嚇到:它只是把 t 的三個分量 (tx, ty, tz) 嵌進一個固定的反對稱樣式,對角線為零。代入比方說 t = (0, 0, 1),再乘上向量 v = (vx, vy, vz):你會得到 (−vy, vx, 0),這恰好就是 (0,0,1) × v。這個矩陣本身就是偽裝起來的外積運算,所以我們可以繼續做線性代數。(一個漂亮的結果:因為 t × t = 0,矩陣 [t]× 本身的秩為 2——這正是 E、進而 F 為何承襲秩 2 的原因。)

第二條方程式 E = K'ᵀ F K,是兩個世界之間的橋樑。KK' 是相機 1 與相機 2 的內參矩陣(常常是同一台相機,故 K = K')。把像素空間的 基本矩陣 F 夾在內參之間,就把它轉成校正空間的本質矩陣——而這個公式雙向都成立,所以一旦你還原出 E,也能反推 F,反之亦然。這正是校正解鎖度量運動的精確意涵:單憑 F 可以警告你對應點必落在何處,但唯有源自 K 的 E,才承載相機真正的旋轉與平移。而這也正是最後一節的懸念——E 可以被撬開,從中讀出 R 與 t,也就是相機在世界中真實的運動。

從匹配點估計基本矩陣

到目前為止 F 都是給定的。實務上我們必須從資料計算出它——而我們唯一擁有的資料,是一串點對應,也就是先前電腦視覺各篇中偵測子與描述子產生的特徵匹配(想想在兩張照片間配對好的角點,或 SIFT/ORB 關鍵點)。經典做法是八點演算法,它的構想乾淨得令人愉快:對極約束 x'ᵀ F x = 0 對 F 的九個未知元素而言是線性的,所以每一組對應貢獻一條線性方程式,而少少幾組對應就能把 F 釘死。

\big[\,u'u,\; u'v,\; u',\; v'u,\; v'v,\; v',\; u,\; v,\; 1\,\big]\;\mathbf{f} = 0 \quad\Longrightarrow\quad \mathbf{A}\,\mathbf{f} = \mathbf{0}

每組對應把 x'ᵀ F x = 0 展開成一列;把這些列堆成 A,然後解 A·f = 0。

讓我們拆開這個展開式。把未知的 F 寫成一個攤平的 9 維向量 f = [F₁₁, F₁₂, …, F₃₃]ᵀ。把 x'ᵀ F x = 0 以 x = [u, v, 1]ᵀ、x' = [u', v', 1]ᵀ 乘開,每一項都是「已知的像素乘積」乘上「f 的一個未知元素」。把它們收集起來,就得到上面那一列:它的九個數字是兩兩相乘的 u'u, u'v, u', v'u, v'v, v', u, v, 1——字面上就是「第二個點的每個座標」乘「第一個點的每個座標」。一組對應,一列。把許多對應的列堆成一個高瘦的矩陣 A,整個系統就是 A·f = 0。我們要找的是被這個矩陣送到(最接近)零的那個 f。

f 明明有九個數字,為何是八點而非九點?因為 F 只定義到差一個比例——把每個元素都乘 5,描述的是同一組對極線,因為 x'ᵀ(5F)x = 0 是同一條方程式。這抹去一個自由度,剩下八個要決定。我們解 A·f = 0 並非靠反矩陣(右邊是零),而是靠 A 的奇異值分解(SVD):解 f 就是「最小奇異值」對應的奇異向量——也就是 A 壓得最接近零的那個方向。在此把 SVD 當成「在單位長度約束下做最小平方」的標準可靠工具即可;我們用它,不重新推導它。

還有最後一個敵人:離群點。特徵比對並不完美;有些「對應」根本是錯的,而單單一組壞對應,就能把最小平方的 F 拉歪。解藥是 RANSAC(隨機抽樣一致法):與其信任所有對應,不如反覆賭一個極小的隨機子集,對它擬合一個候選 F,再看其餘對應有多少同意(它們到所預測對極線的距離很小)。同意者(內點)最多的那個候選勝出,我們再僅用那些內點重新擬合 F。RANSAC 就是免疫系統,讓乾淨的八點構想能在雜亂的真實照片中存活。

import numpy as np

def normalize(pts):
    """Hartley normalization: zero mean, mean distance sqrt(2) from origin."""
    c = pts.mean(axis=0)                       # centroid (cu, cv)
    d = np.sqrt(((pts - c) ** 2).sum(1)).mean()
    s = np.sqrt(2) / d                         # scale so mean distance = sqrt(2)
    T = np.array([[s, 0, -s * c[0]],
                  [0, s, -s * c[1]],
                  [0, 0,  1]])
    pts_h = np.c_[pts, np.ones(len(pts))]      # to homogeneous
    return (T @ pts_h.T).T, T

def eight_point(x1, x2):
    """Estimate F from >= 8 correspondences (x1[i] <-> x2[i])."""
    x1n, T1 = normalize(x1)
    x2n, T2 = normalize(x2)
    # Build one row per match: [u'u, u'v, u', v'u, v'v, v', u, v, 1]
    u, v   = x1n[:, 0], x1n[:, 1]
    up, vp = x2n[:, 0], x2n[:, 1]
    A = np.column_stack([up*u, up*v, up, vp*u, vp*v, vp, u, v, np.ones(len(u))])
    # Solve A f = 0: f is the smallest right-singular vector of A
    _, _, Vt = np.linalg.svd(A)
    F = Vt[-1].reshape(3, 3)
    # Force rank 2 by zeroing the smallest singular value of F
    U, S, Wt = np.linalg.svd(F)
    S[-1] = 0
    F = U @ np.diag(S) @ Wt
    # Undo normalization so F works on raw pixels again
    return T2.T @ F @ T1

def ransac_F(x1, x2, thresh=1.0, iters=2000):
    """Robust F: many random 8-point fits, keep the largest inlier set."""
    best_F, best_inliers = None, []
    n = len(x1)
    for _ in range(iters):
        idx = np.random.choice(n, 8, replace=False)   # random minimal sample
        F = eight_point(x1[idx], x2[idx])
        inliers = [i for i in range(n)
                   if sampson_distance(F, x1[i], x2[i]) < thresh]
        if len(inliers) > len(best_inliers):
            best_F, best_inliers = F, inliers
    return eight_point(x1[best_inliers], x2[best_inliers]), best_inliers
帶 Hartley 正規化與強制秩 2 的八點演算法,外包一層 RANSAC 以剔除離群點(sampson_distance 衡量一組對應離其對極線多遠)。

還原姿態與結構

我們已從像素得到 F,再透過 K 得到 本質矩陣 E。現在來領獎。回想 E = [t]×R,所以 E 暗中含有相機的旋轉與平移——對 E 做 SVD,就能把它們重新分解出來。這套運算是標準的,但有個值得知道的微妙處:分解並不唯一。它產生四組候選 (R, t)——兩個可能的旋轉,各配兩個可能的平移方向(t 與 −t)。在幾何上,這些就是同一條基線以自然的方式被反射與翻轉而成的歧義。

如何挑出真正的那一組?靠手性檢查(cheirality check)——這個花俏的名字其實是個常識規則:真實的 3D 點必須在兩台相機的前方,而非後方(你拍不到鏡頭背後的東西)。對四組候選各自三角測量一個測試點,檢查它在兩個視圖中的深度正負號。只有正確的 (R, t) 會把點放在兩台相機前方;其餘三組會把它放到其中一台或兩台的後方。一票定江山。

R 與 t 終於定下後,我們就能重建真正的 3D 點——也就是結構。直覺正是立體視覺裡那張射線交會圖:每台相機既知自己的姿態,便穿過匹配像素朝空間射出一條射線,兩條射線在世界點 X 相交。在有雜訊時,射線未必完美相交,於是我們改求「最接近兩者」的那個 X。標準的線性方法,就是 三角測量直接線性變換(DLT)

\mathbf{x} \sim \mathbf{P}\,\mathbf{X} \;\Longrightarrow\; \mathbf{x} \times (\mathbf{P}\,\mathbf{X}) = \mathbf{0} \;\Longrightarrow\; \mathbf{A}\,\mathbf{X} = \mathbf{0}

DLT 三角測量:每個視圖貢獻方程式;堆疊後以 SVD 解出 X。

慢慢拆開。P = K[R | t] 是一台相機的 3×4 投影矩陣——它把內參 K 與第 1 篇的姿態 [R | t] 釘在一起,把齊次 3D 點 X = [X, Y, Z, 1]ᵀ 映到一個齊次像素。符號 ~ 意為「相等到差一個比例」,因為投影會遺忘深度:P·X 給出正確的像素方向,但帶著一個未知的整體比例因子。為了消掉這個討厭的比例,我們動用外積:若 x 與 P·X 指向同一方向,則 x × (P·X) = 0 嚴格成立。每個視圖的外積,給出兩條關於 X 未知元素的獨立線性方程式(第三條是多餘的)。因此兩個視圖堆出四條方程式——恰好夠四個齊次未知數 X——組裝成矩陣 A 使得 A·X = 0,而再一次,SVD 的最小奇異向量就是我們的答案 X。

退一步,欣賞兩張照片剛剛給了我們什麼:僅憑一堆像素對應,我們就萃取出兩台相機的相對姿態(R 與 t,僅方向)以及一團稀疏的 3D 點雲——重建的種子。這正是 三維重建 的核心。但兩個視圖只是開端。下一篇我們會把許多視圖串接起來,讓點在視圖間共享,並一次同時打磨每一台相機與每一個點——運動恢復結構(SfM)、SLAM 與光束法平差。你在這裡建好的雙視圖機制,正是組裝出那些完整世界的原子。