光流究竟是什麼
在上一篇指南中,我們看著像素在影格之間移動,得到一種模糊的感覺:『有東西動了』。光流就是把這種感覺化為數字的工具。形式上,光流為每一個像素指派一個微小的二維向量 (u, v):u 表示該位置的亮度圖樣在兩個相鄰影格之間,看起來水平滑動了幾個像素;v 表示它看起來垂直滑動了多遠。把每個像素的這種向量疊在一起,就得到一個稠密的光流場——整張影像的運動地圖。這是最基本、也最可重複利用的運動估計形式:到處都有向量,而不只是一個寫著『動』或『不動』的標籤。
左:影像上疊著一格格小箭頭,指向各區域的移動方向。右:同樣的運動以色輪地圖呈現,向右運動是一種色相,向上是另一種,顏色越鮮豔代表越快。
視覺化這個流場有兩種標準方式。第一種是一格格的小箭頭:在取樣的像素上畫一支箭頭,方向是 (u, v),長度是速度。第二種是顏色編碼的光流圖:我們把 (u, v) 的方向對應到色相(比方說向右=藍、向上=綠),把速度對應到飽和度(靜止處淡、快速處鮮豔)。幾乎所有現代光流結果都用顏色圖呈現,因為上百萬支小箭頭根本無法閱讀。
現在來看關鍵的微妙之處。光流量測的是表觀(apparent)運動——亮度圖樣在影像平面上的行為——這並不總是等同於世界的真實三維運動。想像漂在河面上的標記:你看到那些圖樣往下游滑動是真的,但它們告訴你的是水面的事,不一定是河床上一根定住的樹枝的事。兩個經典反例讓人永生難忘。旋轉的理髮店三色柱是水平轉動的,但斜紋看起來卻是直直往上爬——表觀運動指錯了方向。而一面完全無特徵的白牆可能正高速從鏡頭前掠過,卻產生零表觀運動,因為影格之間沒有任何亮度在變化。光流看的是亮度,不是物質。
亮度恆定假設
要計算光流,我們需要一個物理假設來錨定一切,而它優美地簡單:當場景中一小塊區域移動時,它的亮度保持不變——只是落到了新的位置。原本在某處很亮的一個紅點,過一瞬間後依然同樣亮,只是位移了 (u, v)。這就是亮度恆定假設,雖然世界有時會違背它(光照變化、陰影、反光表面),但在兩個相鄰影格之間那極短的時間間隔內,它成立得夠好,足以成為古典光流的基石。
亮度恆定:像素 (x, y) 在時刻 t 的強度,等於一個影格之後位移處的強度。
讓我們慢慢讀這條方程式。I 是影像強度(亮度)——一個函數,吃進一個位置和一個時間,回傳一個數字,比方說 0 是黑、255 是白。(x, y) 是我們正在看的像素,t 是當前時間,dt 是到下一影格的極小時間步。未知量 u 與 v 是這塊區域在 dt 期間的水平與垂直位移——正是我們想要的光流。這條方程式只是用符號陳述那個假設:我此時此地看到的亮度,等於一個影格之後在移動到的位置 (x+u, y+v) 找到的亮度。具體地說,若一塊值為 200 的亮區位於 (10, 10),且方程式在 (u, v) = (3, -1) 時成立,那麼一個影格後,位於 (13, 9) 的像素也會讀到 200。
上面那條方程式雖然正確,但照字面寫出來毫無用處——未知量埋在 I 裡面。訣竅在於:相鄰影格之間的運動很小,所以我們可以把右邊換成在 (x, y, t) 附近的一階泰勒展開。『一階』的意思就是只保留線性項——數值加上變化率乘以小步長——而丟掉更高階的曲率項,因為當 u、v、dt 很小時,它們的平方會小到可忽略。展開後得到 I(x+u, y+v, t+dt) ≈ I(x, y, t) + I_x·u + I_y·v + I_t·dt。把它代回亮度恆定,兩邊的 I(x,y,t) 相消,再整條除以 dt,你就抵達了整篇指南的核心:
光流約束方程式(亮度恆定的微分形式)。
這精簡的一行就是光流約束方程式,每個符號都名正言順。I_x = ∂I/∂x 與 I_y = ∂I/∂y 是空間梯度:當你橫向跨一個像素(I_x)或上下跨一個像素(I_y)時,亮度變化得多快。它們在邊緣處很大、在平滑平坦區近乎為零——它們回答『這裡紋理多豐富?』I_t = ∂I/∂t 是時間梯度:這個確切像素的亮度在兩影格之間變化了多少——它回答『有多少東西從我面前移過?』而 (u, v) 仍是未知的光流。用白話說:方程式表示你隨時間量到的亮度變化(I_t),必須完全由運動 (u, v) 把空間圖樣 (I_x, I_y) 在像素底下滑動所解釋。快速驗證一下:若某區域完全平坦,則 I_x = I_y = 0,方程式塌縮成 I_t = 0,它對 (u, v) 什麼都沒說——這正是第一節的白牆問題。
孔徑問題
這裡把『無法確定』講得活靈活現。想像你透過一個極小的洞——一個孔徑——觀看一條長長的直邊:黑條與白底之間的邊界,洞小到你看不見這條邊的兩端。現在這黑條滑動了。你能清楚看出它在垂直於邊的方向移動(邊界在你的窺孔裡爬過),但你完全無法偵測沿著邊的任何運動,因為把一條無特徵的直線沿著它自身的長度滑動,看起來跟它靜止不動一模一樣。平行於邊的那個運動分量,透過孔徑是真正看不見的。這就是孔徑問題。
一個圓形窺孔覆在傾斜的邊緣上。一支箭頭直直橫越邊緣,標示『可觀察』;另一支沿著邊緣的箭頭被劃掉,標示『不可見』。真實運動是兩者的對角線之和。
這不是模糊的直覺——它正是約束方程式告訴我們的。把 I_x·u + I_y·v + I_t = 0 改寫成內積:∇I · (u, v) = −I_t,其中 ∇I = (I_x, I_y) 是空間梯度,它永遠指向橫越邊緣的方向(亮度變化最陡的方向)。方程式只釘住 (u, v) 沿著 ∇I 的分量——也就是垂直於邊緣的運動,稱為法向光流。任何垂直於 ∇I(即沿著邊緣)的運動量都不會對內積有任何貢獻,因而同樣滿足方程式。一條方程式定住一個分量,把另一個分量完全放任自由。孔徑問題與『一條方程式、兩個未知量』的計數,其實是同一個事實穿了兩套戲服。
理髮店三色柱是這個錯覺的招牌展品。柱子繞垂直軸旋轉,所以它表面的物理運動純粹是水平的。但漆上去的條紋是斜的,而透過你眼睛實質上的孔徑,你只感知到每條條紋垂直於自身的運動——那是向上的。於是就有了那個著名又惱人的錯覺:條紋永無止盡地向上爬,而柱子其實只是橫向旋轉。表觀運動(向上)與真實運動(橫向)之所以分道揚鑣,正是因為孔徑問題。
Lucas-Kanade:假設鄰居一起移動
Lucas-Kanade 方法採用最簡單可能的額外假設,並從中榨出巨大的價值:一個小視窗內的每個像素都以相同的光流 (u, v) 移動。想像目標像素周圍一塊 5×5 的小區。單一像素給了我們一條約束方程式,然後我們就卡住了。但如果視窗內全部 25 個像素都共享同一個 (u, v),那麼每個像素都貢獻自己一份約束方程式——各有自己的 I_x、I_y、I_t——卻全都關於同樣的兩個未知量。25 條方程式對 2 個未知量是超定(over-determined)的,而超定線性系統有一個標準的最佳擬合答案:最小平方法。只要視窗內含有指向超過一個方向的梯度,孔徑問題就迎刃而解。
把視窗內的方程式疊成矩陣形式 A·v = −b,其中 A 的每一列是某像素的梯度 (I_x, I_y),向量 b 裝著該像素的時間梯度 I_t,而 v = (u, v) 是共享的光流。一般沒有精確解(資料有雜訊),所以我們最小化平方殘差 ‖A·v + b‖²。令導數為零便得到正規方程式(normal equations),展開後是一個整潔的 2×2 系統,由視窗內梯度乘積之和構成:
Lucas-Kanade 的最小平方解;左邊的 2×2 矩陣是結構張量(二階矩矩陣),所有求和都在視窗內進行。
讓我們逐塊解碼。左邊的矩陣 A⊤A = [[ΣI_x², ΣI_xI_y], [ΣI_xI_y, ΣI_y²]] 是結構張量(或二階矩矩陣)。每個元素都是視窗內的求和:ΣI_x² 量測視窗含有多少水平邊緣能量,ΣI_y² 是垂直邊緣能量,ΣI_xI_y 則是兩者的相關性。右邊的 −(ΣI_xI_t, ΣI_yI_t) 把空間梯度與時間梯度耦合起來——它是『亮度隨時間變化了多少、往哪個方向』那一項。解這個小小的 2×2 系統(把矩陣求逆、相乘),整個視窗的單一光流向量 (u, v) 就跳出來了。舉個具體例子:一塊有紋理的角點小區可能給出 A⊤A = [[800, 50], [50, 600]]、右邊 (−400, 300);求逆後得到乾淨而唯一的 (u, v),因為矩陣是良態的(兩個對角項都大、行列式遠離零)。
這裡有個與先前電腦視覺教材的優美連結:那個 2×2 結構張量,正是 Harris 角點矩陣。決定『這是不是角點?』的同一個量,也決定『這裡的光流可不可信?』它的兩個特徵值道盡一切。兩個大特徵值代表梯度指向兩個不同方向——一個角點——系統良態,光流可靠。一大一接近零的特徵值代表只有單一主導的邊緣方向——孔徑問題回來了,矩陣近乎奇異,只有法向光流分量能被還原。兩個特徵值都接近零代表平坦無紋理的區域——矩陣本質上是零、不可逆、光流毫無指望(又是那面白牆)。第三節裡含糊其辭的孔徑問題,如今成了關於矩陣秩的精確陳述。
import numpy as np
def lucas_kanade(I1, I2, x, y, win=5):
"""Estimate flow (u, v) for the pixel at (x, y) using a win x win window.
I1, I2 are consecutive grayscale frames (float arrays)."""
h = win // 2
# Spatial gradients (from frame 1) and temporal gradient (frame diff)
Ix = 0.5 * (I1[:, 2:] - I1[:, :-2]) # d/dx via central difference
Iy = 0.5 * (I1[2:, :] - I1[:-2, :]) # d/dy via central difference
It = I2 - I1 # d/dt: brightness change
# Gather gradients inside the window around (x, y)
gx = Ix[y-h:y+h+1, x-h:x+h+1].ravel()
gy = Iy[y-h:y+h+1, x-h:x+h+1].ravel()
gt = It[y-h:y+h+1, x-h:x+h+1].ravel()
# Structure tensor A^T A (== Harris corner matrix) and right-hand side
ATA = np.array([[np.sum(gx*gx), np.sum(gx*gy)],
[np.sum(gx*gy), np.sum(gy*gy)]])
ATb = np.array([np.sum(gx*gt), np.sum(gy*gt)])
# Solve only where the window is corner-like (both eigenvalues large)
eig_min = np.linalg.eigvalsh(ATA)[0]
if eig_min < 1e-2: # flat or single-edge -> unreliable
return None
u, v = np.linalg.solve(ATA, -ATb) # the normal equations
return u, v稀疏與稠密,以及看見大幅運動
如前所述的 Lucas-Kanade 有個內建的限制:它只在紋理豐富、角點狀的位置產生可信的向量,所以它的輸出是稀疏的——影像有趣部位上散落著一些箭頭,其餘到處是空白。這對追蹤少數幾個特徵很完美,但許多任務想要的是每一個像素都有向量——那就是稠密光流。為了填補 Lucas-Kanade 放棄的平坦無紋理區域,我們需要不同的哲學:不再孤立地解每個視窗,而是一次解出整個光流場,並讓有把握的區域把答案傳播到不確定的區域。
經典的稠密方法 Horn-Schunck 把這套哲學編碼成一個要在整張影像上最小化的能量(成本)。它恰好有兩個彼此競爭的項。第一個是亮度恆定的資料項:我們約束方程式左邊的平方,我們希望它到處都接近零。第二個是平滑項,懲罰光流從一個像素到相鄰像素的劇烈變化——這就是正則化項,是孔徑問題所要求的『額外假設』,如今被明白地寫了出來。最小化兩者之和,讓有紋理的像素(資料項強的地方)主導光流,而平滑項則溫和地把那股光流帶過平坦的空隙。
Horn-Schunck 能量:資料保真度加上平滑正則化項,由 λ 平衡。
逐項閱讀:雙重積分 ∬...dx dy 只是表示『把成本加總到影像中的每一個像素』。裡面,資料項 (I_x u + I_y v + I_t)² 是第二節的光流約束,取平方,使得任何違反(不論正負)都計為成本——最小化它會逼光流服從亮度恆定。平滑項用到 ∇u = (∂u/∂x, ∂u/∂y),也就是水平光流場本身的空間梯度(∇v 亦然);只要相鄰光流向量不一致,‖∇u‖² 就會很大,所以懲罰它便迫使流場溫和地變化。旋鈕 λ(lambda)設定取捨:小 λ 信任資料,給出銳利但有雜訊的光流,在平坦區會崩解;大 λ 強制平滑,給出乾淨的光流,但很遺憾會在真實的運動邊界(物體邊緣對背景)上糊掉。調 λ 就是在決定要多相信像素、還是多相信假設——例如乾淨的影片可能適合 λ ≈ 0.1,而雜訊較多的影片則想要較大的 λ 來壓制偽向量。
最後一道實務難關:這裡的每個方法都建立在小運動的泰勒近似上,而它對快速物體會失效。如果一顆球在影格之間跳了 40 像素,在起始像素附近的線性化就錯得無可救藥——那裡的梯度對球落到哪裡毫無頭緒。解法是由粗到細的影像金字塔。把兩個影格反覆縮小(每層解析度減半);在縮小 16 倍的影像上,那 40 像素的跳躍變成溫和的 2.5 像素輕推,穩穩落在小運動範圍內,於是我們能在那裡可靠地估計光流。接著把那個光流估計放大,用它把下一個較細層先行扭曲(warp,抵銷掉大部分運動),只解出剩下的小殘差——一層層細化直到全解析度。只要永遠在運動看起來很小的地方工作,大幅運動就變得可解。
同一影格逐層縮小的堆疊。在頂端的小層,一支短箭頭代表如今變小的運動;箭頭往下穿過各層,每層在更大的版本上細化,直到全解析度。
現代深度光流,以及光流的用途
目前為止的一切都是手工設計的:我們寫下亮度恆定與平滑假設,再解出隨之而來的方程式。現代稠密光流保留了完全相同的兩個直覺——匹配亮度、保持平滑——但用一個端對端訓練的神經網路從資料中學會它們。先驅 FlowNet 直接把一對影格餵進卷積網路,讓它回歸出完整的光流場,從大量已知真實運動的合成影片資料集中學習。資料項與平滑先驗不再由人手鍵入;它們被吸收進網路學到的權重裡,這也是為什麼深度光流在處理光照變化、模糊、與紋理貧乏區域時,遠比古典方法優雅。
當代主流的設計 RAFT 值得用一段話勾勒,因為它如此乾淨地呼應了我們的故事。它首先建立一個相關性體積(correlation volume):對影格 1 中的每個像素,計算該像素的特徵與影格 2 中每個候選位置的匹配程度——這是亮度匹配的暴力、可學習版本,關鍵在於它能直接看見大位移(不需要金字塔)。接著,它不是一口氣預測光流,而是反覆細化一個估計:一個循環更新模組反覆在當前光流猜測處查詢相關性體積,把光流往更接近的方向輕推,重複數十次。那種反覆輕推在概念上與由粗到細的細化是同一個迴圈,只是用學的、而非手寫的。概念上沒有新東西——匹配,然後平滑與細化——但由一個訓練好的網路執行,準確度大幅提升。
要評斷這些方法中的任何一個,我們需要一個數字,而標準的那個是端點誤差(Endpoint Error, EPE)——說穿了就是:預測箭頭的尖端落在離真實箭頭尖端多遠的地方。
平均端點誤差:所有像素上,預測光流向量與真實光流向量之間歐氏距離的平均。
逐符號來看:對單一像素 p,(u_pred, v_pred) 是我們的方法預測的光流向量,(u_gt, v_gt) 是真實光流('gt' = ground truth,已知的正確答案)。它們的差本身是一個二維向量——誤差箭頭——而 ‖·‖₂ 是它的歐氏長度(就是普通的 √(Δu² + Δv²) 直線距離)。我們在每個像素算出這個長度,再對全部 N 個像素取平均(那個 1/N 的求和)。直覺好得驚人地直接:EPE 回答『平均而言,我預測的箭頭與真實箭頭差了幾個像素?』舉個具體數字:若某像素我預測 (3, 4) 但真值是 (0, 0),誤差向量是 (3, 4)、長度 √(9+16) = 5,所以該像素貢獻 5 像素的端點誤差。在基準資料集上,頂尖方法的平均 EPE 遠低於 1 像素。