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

從靜態到動態:像素如何移動

認識影片與一疊照片的根本差異,並僅用影格相減就打造出你的第一個運動偵測器。

影片不只是影像的堆疊

到目前為止,你一次只研究一張影像:一個被永遠凍結的像素網格。影片打破了這個咒語。它其實就是一連串的靜態影像,我們稱每一張為影格(frame),以穩定的節奏一張接一張地拍下來。這個節奏就是影格率(frame rate),以每秒影格數(fps)來衡量。一部 24 fps 的電影每秒讓你看到 24 張獨立的照片;你的手機可能拍 30 或 60。一小段這樣的影格——你實際處理的那幾秒——就叫做一個片段(clip)

想像這件事最乾淨的方式是手翻書(flipbook):一疊圖畫,每一張都和下面那張只差一點點,當你快速翻動時就變成了流暢的動作。每一頁都是一張普通的圖片;魔法存在於它們的順序時機之中。這給了我們一個強大的新心智模型。一張影像有兩個軸:寬度(x)和高度(y)。影片多加了第三個軸:時間(t)。所以影片是一個小小的三維數字方塊——一疊像素網格——而我們剛剛解鎖的這個新軸,就是時間維度(temporal dimension)

一個影格就只是一個像素值的網格。影片把許多這樣的網格沿著時間軸疊起來。

一個由方格組成的網格,每格裝著一個亮度數值,代表一個影片影格的像素。

現在來談那兩個悄悄定義了整個領域的對立事實。事實一:時間冗餘(temporal redundancy)。兩個相鄰影格看起來幾乎一模一樣。如果在 1/30 秒內世界上沒有什麼東西大幅移動,絕大多數像素就保持完全相同的數值。從某個角度看這很浪費——但它同時也是一份禮物,因為這正是影片為什麼能壓縮得那麼好的原因:我們可以只存一張影格,接著只存下一張影格的那一點點變化。事實二:時間資訊(temporal information)。在影格之間真的改變了的那一小撮像素,恰恰就是我們在意的部分。一個走路的人、一扇正在打開的門、一顆被丟出的球——這一切都完全活在那些微小的差異裡。

這條學習線上的一切,都是對同一個問題的長篇回答:我們要如何理解隨時間的變化?這項技能有個名字——時間建模——它是貫穿這裡每一篇指南的主線。到最後,你會從「一個像素變了」一路爬升到能看一段片段並說出裡面動作名稱的系統,這項任務叫做影片分類。在這篇指南裡,我們從那道階梯的最底層開始,完全不用數學——只憑一個想法:時間是新的軸,而變化是獎品。

影格相減:最簡單的運動偵測器

我們現在就來打造一個能用的運動偵測器,只用一個想法:如果一個像素的數值從上一格到這一格改變了,那裡大概有東西動了。這就是整個演算法。拿現在這一格,逐像素減去上一格,取差值的絕對值,然後把任何變化夠大的像素叫做「在動」。這叫做影格相減(frame differencing),儘管它如此簡單,它仍是一種貨真價實、粗糙的運動估計——我們把原始像素轉化為一句關於運動的陳述的第一次嘗試。

D_t(x,y) = \bigl|\, I_t(x,y) - I_{t-1}(x,y) \,\bigr| , \qquad M_t(x,y) = \bigl[\, D_t(x,y) > \tau \,\bigr]

我們慢慢地、一個符號一個符號地讀。I_t(x,y) 是在時間 t 的影格中,第 x 行、第 y 列那個像素的亮度;I_{t-1}(x,y)同一個像素早一格時的亮度。它們的差就是那個點改變了多少。垂直線 |\cdot| 是絕對值:它丟掉正負號,所以一個變的像素(正變化)和一個變的像素(負變化)都算作運動——我們在意的是它變了,而不是往哪個方向變。結果 D_t(x,y)差異影像(difference image):一個新網格,大數字標示出大變化。第二條式子把它變成一個是/否的答案。括號 [\cdot]艾佛森括號(Iverson bracket):當括號裡的敘述為真時 [\text{敘述}] 等於 1,為假時等於 0。所以 M_t(x,y) 在變化超過閾值 \tau 的地方是 1(在動),其餘地方是 0(靜止)。M_t 就是我們的前景遮罩(foreground mask)——一張乾淨的黑白模板,標出運動在哪裡。

用數字讓它變得真實。想像一個 8 位元灰階影像的小小 3x3 區塊(數值 0–255)。在時間 t-1,這塊區域是平的,每格都是 50;到了時間 t,一道明亮的邊緣滑過了它的右側那一行:

I_{t-1}            I_t              D_t = |I_t - I_{t-1}|     M_t = [D_t > tau], tau = 30
50 50 50          50 51 200        0   1   150               0  0  1
50 50 50          49 50 210        1   0   160               0  0  1
50 50 50          51 49 205        1   1   155               0  0  1

# Left column barely moved (0 or 1) -> sensor noise, stays 0 in the mask.
# Right column jumped ~150 -> the moving edge lights up as 1.
# A threshold of tau = 30 cleanly separates the noise from the real motion.
在 3x3 區塊上做影格相減:右行的移動邊緣通過了閾值;左行的微小雜訊被正確地忽略。

為什麼要設閾值?因為這正是 \tau 真正的工作。即使面對一個完全靜止的場景,相機感光元件也絕不會兩次回報完全相同的數字——微小的電氣波動讓 50 讀成 49 或 51。沒有閾值的話,每一個像素都會閃爍著「在動」,遮罩就變成一片沒用的雜訊。\tau 是那個說「忽略小於此的變化」的旋鈕。選擇它是一種取捨\tau 設得太低,雜訊會以假陽性(false positives)的形式滲漏進來(到處都是幻影般的運動);\tau 設得太高,你會對真實但微弱的運動變得遲鈍,漏掉緩慢或低對比的移動者。在我們的例子裡,\tau = 30 舒服地坐在雜訊(變化 0–1)之上、又遠低於真實邊緣(變化約 150)。

背景相減:把移動者從場景中分離出來

影格相減把這一格前一格比較——只有一格深的記憶。正是這個短記憶造成了空洞和鬼影:它完全不知道在什麼事都沒發生時,這個場景「應該」長什麼樣子。升級的辦法是:把每一格拿來比較的對象,不是它的鄰格,而是一個學習得到的靜態場景模型本身。這就是背景相減,它是固定相機監控的主力。直覺是:每一個像素都保有一份對自己平常顏色的滾動記憶——空蕩的走廊、空著的停車格——而前景物體就只是任何強烈偏離那份記憶的東西。

最簡單的這種記憶是滾動平均背景(running-average background)。我們保留一個對背景長相的估計 B_t,並讓它朝每一張新影格輕輕地推近:

B_t(x,y) = \alpha \, I_t(x,y) + (1-\alpha)\, B_{t-1}(x,y)

一個符號一個符號來:B_t(x,y) 是我們對像素 (x,y)背景亮度當前的最佳猜測;B_{t-1}(x,y) 是我們一格之前的猜測;I_t(x,y) 是我們剛剛觀測到的亮度。數字 \alpha(alpha),落在 [0,1] 之間,是學習率(learning rate)——我們給最新影格的權重。用白話讀這條式子:「新的背景是一點點我剛看到的,加上一大堆我原本就相信的,兩者混合而成。」如果 \alpha = 0.05,那麼每次更新是 5% 的新觀測加 95% 的舊記憶。具體走一步:如果我在某像素存的背景是 B_{t-1} = 100,而新影格讀到 I_t = 200(剛好有人走過那裡),那麼 B_t = 0.05\times 200 + 0.95\times 100 = 10 + 95 = 105。背景幾乎沒動——它拒絕被單獨一張亮影格騙到。

所以 \alpha 字面上的意思就是「我有多信任最新的影格?」小的 \alpha 給出長記憶:背景適應得慢,能穩健地忽略短暫經過的物體——但當場景真的改變時(一輛停著的車開走了)它反應遲鈍。大的 \alpha 給出短記憶:它能快速適應真實變化,但一個停下來幾秒鐘的人會慢慢「烘焙進」背景裡,從你的前景中消失。偵測本身於是就是對這個模型做相減加閾值:|I_t(x,y) - B_t(x,y)| > \tau 標出前景——和之前一樣的想法,但現在比較的對象是場景真正的靜止狀態,而不是某一張任意的前一格,這恰恰就是填補空洞的關鍵。

# Running-average background subtraction (one channel, fixed camera)
import numpy as np

alpha = 0.05         # learning rate: small = long memory
tau   = 30           # foreground threshold
background = first_frame.astype(np.float32)   # initialize memory

for frame in video:
    f = frame.astype(np.float32)
    diff = np.abs(f - background)             # deviation from the scene's usual look
    mask = (diff > tau).astype(np.uint8)      # 1 = foreground (a mover), 0 = background
    background = alpha * f + (1 - alpha) * background   # update the memory
    yield mask
用程式碼寫出滾動平均模型:一行偵測,一行慢慢更新記憶。

每像素只用一個平均值是個脆弱的模型,因為它假設每個像素有一個平常的顏色。真實世界不斷打破這個假設:隨風搖曳的樹葉讓一個像素每秒在「綠葉」與「藍天」之間翻轉好幾次,閃爍的螢幕或盪漾的水面也一樣。平均值無用地落在中間,把這種自然的閃動標成前景。修補的辦法是讓一個像素同時記住好幾種平常的顏色。每像素高斯(per-pixel Gaussian)把一個像素的顏色建模成一條鐘形曲線——一個平均值加上一段允許的散布範圍——所以它能容忍一點抖動。高斯混合(Mixture of Gaussians, MOG)走得更遠:它為每個像素保留好幾條鐘形曲線(一條給「葉」、一條給「天」),只有當你哪一條都不符合時才把你算作前景。這就是為什麼 MOG 是處理多模態(multimodal)背景的經典答案。

從「有東西動了」到「它怎麼動的」

到目前為止的一切都在回答一個是/否的問題:這個像素有沒有動?這是偵測(detection),它的輸出是一個二值遮罩。但要真正理解運動,我們想要的是估計(estimation)——不只是某個點有沒有動,而是它怎麼動的:移了多遠、往哪個方向。那個更豐富的答案是在每個位置上的一個位移向量(displacement vector),而散布在整張影像上所有這些小箭頭組成的場,就叫做運動場(motion field)。把遮罩升級成運動場,就是從「這裡有事發生」躍升到「這個物體向左滑了兩個像素、向上滑了一個像素」。

估計那個向量最直覺的方式是區塊匹配(block matching),它運作的方式正是你用眼睛找東西的方式。在影格 t 中,繞著某個點取一個小區塊——一塊 patch。接著在下一格 t+1 裡,把那塊 patch 在一個小小的搜尋窗(search window)內四處滑動,試遍每一個候選偏移量,然後問:「在哪個位移下,這塊 patch 和新影格對得最齊?」勝出的那個位移就是你對那個區塊的運動估計。你字面上就是在問「這一塊跑到哪去了?」,然後相信那個匹配得最好的偏移量。

\mathrm{SSD}(u,v) = \sum_{(x,y)\,\in\,\text{patch}} \bigl[\, I_t(x,y) - I_{t+1}(x+u,\; y+v) \,\bigr]^2 , \qquad (\hat{u},\hat{v}) = \operatorname*{arg\,min}_{(u,v)} \, \mathrm{SSD}(u,v)

這是我們要最小化的成本,一個符號一個符號來。(u,v) 是一個候選位移——一個猜測,說這塊 patch 水平移了 u 個像素、垂直移了 v 個像素。求和跑遍 patch 內的每一個像素 (x,y)。對每一個像素,我們拿它在影格 t 中的值,也就是 I_t(x,y),去和下一格中那個位移後位置的值 I_{t+1}(x+u, y+v) 比較。我們取它們的差,把它平方(這樣任何不匹配都是正的,而且大的誤差會被狠狠懲罰),再把這些平方誤差在整塊 patch 上加總。那個總和就是 SSD——平方差之和(Sum of Squared Differences)——一個衡量這塊 patch 在位移 (u,v) 下對得有多糟的單一數字:SSD 小代表匹配極佳,SSD 大代表很差。第二部分 \operatorname{arg\,min} 只是說「讓 SSD 最小的那個 (u,v)」;那個勝出的位移 (\hat{u},\hat{v}) 就是我們的運動估計。具體勾勒一下:某塊 patch 若不位移時 SSD = 4000,位移 (+2, −1) 時是 120,位移 (−3, 0) 時是 5000;偏移 (+2, −1) 勝出,所以我們回報「這個區塊向右移了兩個像素、向上移了一個像素」。

一個運動場:每個位置都得到一個箭頭,說出它在影格之間移了多遠、往哪個方向。區塊匹配為每塊 patch 估計出一個這樣的箭頭。

一個網格上疊著許多長度與方向各異的小箭頭,描繪兩格之間估計出的像素運動。

陷阱,以及接下來的路

在我們繼續之前,先講一個能替你省下無盡困惑的概念區分:影像中的表觀運動,和世界中真實的三維運動,不是同一回事。我們的相機永遠只看到亮度圖樣在一片平坦的感光元件上移動;我們從那裡推論出運動。這兩者通常一致,但並非總是如此——一根緩慢旋轉的理髮店燈柱看起來永遠在向上移動,而一面均勻的牆可以移動卻沒有任何像素改變。我們計算的一切都是表觀運動,我們必須對「像素的故事」與「世界的真相」之間的落差保持謙卑。

我們也來把剛剛打造的這些簡單工具的極限收攏起來,因為每一個極限其實都是通往更好方法的路標。影格相減在平滑物體上留下空洞、在快速物體上留下鬼影。背景相減征服了空洞,卻在突然的光照變化以及偽裝成移動者的陰影上跌倒。區塊匹配給了我們真實的運動向量,卻在平滑邊緣上被孔徑問題絆住。這些都不是死路——每一次失敗,正是下一項技術之所以存在的確切理由。

所以這裡是這條學習線要帶你去哪的地圖。第 2 篇把區塊匹配粗略的箭頭,變成精準、密集、每像素的運動,並正面迎戰孔徑問題——那就是光流第 3 篇不再逐格觀看,而是改為在整段片段中持續追隨某個選定的物體,即使它短暫被遮住也不放手——那就是物體追蹤第 4 篇從「東西在哪、怎麼動」往上爬到「正在發生什麼事」——教神經網路看一段片段並說出動作名稱,這是影片分類與動作辨識的領域。而第 5 篇走向密集與生成:為每一格的每一個像素隨時間貼上標籤,甚至發明出夾在中間的影格(分割與插值)。

你以一本手翻書和一次相減開始了這篇指南,而你已經握住了驅動整個領域的核心洞見:在影片裡,獎品是變化,而變化是一個微弱的訊號,我們必須小心地把它從一個有雜訊、幾乎靜止、相機還可能在動的世界中分離出來。後面每一篇指南,都只是為同一場狩獵打造的、更鋒利更聰明的工具。前進吧,去逐個像素地測量運動。