核心構想:先提議、再分類
想像你要在一張照片裡找出所有物件,而且完全不用任何聰明的技巧。最直接的做法是滑動視窗:讓一個小方框掃過影像中的每一個位置,每停一格就問一次「這裡有沒有物件?」但物件有各種大小與形狀,所以你得對每一種尺度(小、中、超大)和每一種長寬比(高瘦、正方、寬扁)的方框都重複一遍。把位置數乘上尺度數再乘上形狀數,一張影像就會產生數十萬到數百萬個方框——而你還要對每一個方框跑一次分類器。這個成本高得驚人,而且其中絕大多數方框只框到空蕩蕩的天空或一面白牆。物件偵測需要更聰明的計畫。
兩階段的構想簡單到令人意外:把工作拆成先提議、再分類。第一步,用一個快速的程序掃過影像,回傳幾百到幾千個候選方框——也就是某個東西可能存在的位置,先不管它是什麼。每一個這樣的方框就是一個候選區域:一個與類別無關、表示「這裡可能有東西」的矩形。它不在意框裡是狗、是車還是茶壺,只負責標記出一個值得細看的區域。第二步,我們只把真正的運算力花在這幾個有希望的區域上——為每一個分類並修正它的方框。我們等於是把一場注定打不贏的「百萬視窗搜尋」,換成了對一份規模可控的候選名單做仔細研究。
偵測頭的示意圖:輸入影像特徵,輸出類別標籤與邊界框座標。
接下來的一切,都是在講如何把這個拆分的「兩半」各自做得更好、更便宜。R-CNN 證明了這個想法可行,但慢得令人痛苦;Fast R-CNN 共用了運算,讓「分類」階段飛快起來;Faster R-CNN 不再用人工設計、而是把「提議」階段也學了出來;RoI Align 則修正了一個雖小卻代價高昂的對齊瑕疵。讀完本篇,你將能夠把一張影像從原始像素一路追到最終的偵測物件清單,並理解每一塊拼圖為何存在。
R-CNN:候選區域加上 CNN 分類器
在 2014 年,R-CNN(Regions with CNN features,帶 CNN 特徵的候選區域)是第一個把卷積網路的強大威力帶進偵測任務的方法,做法是把一個 CNN 接到「先提議、再分類」的骨架上。「提議」這一步用的是一個經典、人工設計的演算法,叫做選擇性搜尋(Selective Search),它會把顏色與紋理相近的相鄰像素聚成一群,為每張影像建議大約 2000 個候選區域。這一步沒有任何學習成分——純粹是影像處理——但它撒出的網夠大,能可靠地涵蓋大多數真實物件。
管線示意圖:一塊裁切下來的影像區域流經卷積層,輸出一個固定長度的特徵向量。
- 提議:對影像跑選擇性搜尋,得到約 2000 個與類別無關的候選方框。
- 變形:把每個候選區域從影像中裁出來,並不論其原始形狀,一律拉伸到固定的輸入尺寸(例如 224x224)。
- 抽取特徵:讓每一塊變形後的裁切區塊通過 CNN,得到一個固定長度的特徵向量。
- 分類:把這個向量送進一組各別針對單一類別的線性 SVM(每個物件類別一個),為「這是狗嗎?是車嗎?……」打分數。
- 修正:另一個獨立的線性回歸器,微調候選方框,讓它更貼合物件。
R-CNN 有一個想法特別值得注意,因為它在後面到處都會再出現:邊界框回歸。我們不是要網路從零開始預測一個方框的絕對像素座標,而是要它預測相對於手上既有候選區域的微小修正量(偏移量)。候選區域通常已經很接近真正的物件,所以修正量很小——而預測一個小小的微調,遠比憑空變出原始座標來得容易且穩定。具體來說,給定一個中心為 (x_a, y_a)、大小為 (w_a, h_a) 的候選區域,以及一個中心為 (x, y)、大小為 (w, h) 的真實標註框,回歸的目標值定義如下。別忘了,一個邊界框不過就是描述一個矩形的四個數字。
邊界框回歸的目標值:中心偏移量以候選區域大小做正規化,寬與高則取對數比值。
我們逐項來讀。符號 x_a, y_a, w_a, h_a 是錨框/候選區域的中心座標及其寬高;x, y, w, h 是真實物件的中心與寬高;而 t_x, t_y, t_w, t_h 則是網路被訓練去輸出的四個數字。對中心而言,t_x = (x - x_a)/w_a 衡量的是真實中心離候選區域中心有多遠,並以候選區域自身的寬度為單位來表示。除以 w_a 正是讓目標值具備尺度不變性的關鍵:在一個只有 10 像素的小框上偏移 5 像素是大事(t_x = 0.5),但同樣 5 像素放在 500 像素的大框上幾乎無關緊要(t_x = 0.01)。對大小而言,t_w = log(w/w_a) 用了對數,讓放大與縮小對稱:寬度加倍得到 log 2 ≈ +0.69,寬度減半得到 log(1/2) ≈ -0.69——大小相等、方向相反。(若用單純的比值,縮小會被擠進 0 到 1 的狹窄區間,放大卻能衝到無限大,對網路來說是個難看的失衡。)而且一旦我們用指數函數把它還原,對數還能保證預測出來的寬度恆為正。舉個實例:一個中心在 (100, 100)、大小 50x50 的候選區域,搭配一個中心在 (110, 95)、大小 60x40 的真實框,得到 t_x = 10/50 = 0.2、t_y = -5/50 = -0.1、t_w = log(1.2) ≈ 0.18、t_h = log(0.8) ≈ -0.22——四個又小又乖的數字,正是網路樂於學習的那種。
Fast R-CNN:共用卷積運算
R-CNN 致命的浪費,一旦看穿就很明顯:來自同一張影像的約 2000 塊裁切區塊彼此大量重疊,所以 CNN 把幾乎一模一樣的特徵重算了好幾千次。Fast R-CNN(2015)用一個決定性的洞見解決了這點——讓骨幹 CNN 對整張影像只跑一次,產生單一張共用的特徵圖;接著,對每個候選區域,直接從那張特徵圖上裁出對應的區域,而不是對像素裁切區塊重跑 CNN。因為卷積會保留空間排列,原影像中的一個方框會對應到特徵圖中一個相符的方框;我們只需查出那塊已算好特徵的正確矩形即可。如今不論有多少個候選區域,昂貴的卷積運算都只做一次。
一張影像與其特徵圖的空間網格,並把一個候選方框從影像空間對應到特徵圖上。
但有個小麻煩。偵測頭——也就是最後那個小型的分類器兼回歸器——期望的是固定尺寸的輸入,然而候選區域有各種你想得到的形狀,所以我們從特徵圖裁出來的小塊大小全都不一樣。Fast R-CNN 的解法是 RoI 池化(RoI pooling)(RoI = Region of Interest,感興趣區域)。它把任意大小的特徵小塊切成固定的網格(比如 7x7 格),再在每一格內做最大池化,於是不論輸入區域是大是小,永遠輸出同樣的 7x7。一塊 35x21 與一塊 14x7 的小塊,都會變成一個整整齊齊、偵測頭能吞下的 7x7 區塊。(我們會在 RoI Align 那一節看到,這個「四捨五入對齊到網格」的步驟有個微妙的瑕疵,後來由 RoI Align 修正。)
有了固定尺寸的特徵,Fast R-CNN 接上一個多任務頭:由同一份共用特徵餵入兩個並列的輸出層。其中一個輸出一個機率分佈 p,涵蓋 K 個物件類別外加一個「背景」類別;另一個則為每個類別輸出方框回歸偏移量 t(也就是上一節的 t_x, t_y, t_w, t_h)。關鍵在於,兩者用單一個合併的損失一起端到端地訓練——除了候選區域之外的一切;候選區域仍然來自選擇性搜尋。這個聯合損失是:
Fast R-CNN 的多任務損失:分類損失,加上一個只對物件才開啟的定位損失項。
逐個符號來看:p 是預測的類別機率分佈,u 是真實的類別標籤(一個整數;依慣例 u = 0 代表背景,u ≥ 1 代表一個真實的物件類別)。L_cls(p, u) 是分類損失——通常是模型賦予正確類別的機率取負對數,所以當模型有把握且答對時它就很小。t 是針對真實類別所預測的方框偏移四元組,t* 則是依照上一節的方式、由真實標註框算出的目標偏移量;L_loc(t, t*) 是定位損失,採用 smooth-L1(Huber)距離,對小誤差表現得像平方誤差、對大誤差表現得像絕對誤差,如此可避免單一個放歪的候選區域把梯度炸開。方括號 [u ≥ 1] 是一個指示函數:對真實物件等於 1,對背景等於 0,所以背景 RoI 完全不貢獻定位損失。這正是重點所在——當區域只是一面空牆時,根本沒有「正確的框」可供回歸,我們就不該要求回歸器去硬湊一個。最後,λ 是單一個正數,用來平衡這兩項任務;取 λ = 1(常見選擇)時,分類與定位的權重相等。λ 設太高,模型會拼命追求像素級精準的框卻把標籤標錯;設太低,則標籤標得好但定位鬆散。舉例:對一個背景 RoI,[u ≥ 1] = 0,所以 L 只剩 L_cls;對一個 u = 3 的「貓」RoI,兩項都啟動,框會被推去貼合物件。
Faster R-CNN:連候選區域也學出來
Fast R-CNN 讓「分類」階段快如閃電,卻也暴露了一個尷尬的事實:現在「提議」階段成了拖慢一切的環節。選擇性搜尋跑在 CPU 上,每張影像約要兩秒,而且——觀念上更糟的是——它是人工設計且固定不變的,既無法從資料學習,也無法隨系統其餘部分一起進步。Faster R-CNN(2015)給出了致命一擊:連候選區域也一起學出來,把它們收進同一個網路,使整個偵測器成為一個可訓練的整體。
它的新元件是候選區域網路(Region Proposal Network,RPN):一個小型、全卷積的網路,在偵測頭所用的那張共用特徵圖上滑動。在特徵圖的每一個位置,RPN 觀察一個極小的特徵視窗,輸出兩樣東西——一個物件性分數(「這裡有任何物件、而非背景的機率有多高?」)以及一組用來修正該區域的方框偏移量。由於它只是疊在骨幹早已算好的特徵之上的幾層卷積,產生所有候選區域幾乎不花成本,而且它和其他一切一起跑在 GPU 上。
但特徵圖上單一個點並沒有寬高——它要怎麼預測一個方框?這就輪到錨框登場了,而且值得慢慢認識,因為後續大多數偵測器都靠它驅動。想法是這樣:在每一個特徵圖位置,我們預先擺放一組固定的參考矩形——也就是錨框——涵蓋數種尺度(小、中、大)與數種長寬比(例如 1:1、1:2、2:1)。典型設定用 3 種尺度 x 3 種長寬比 = 每個位置 9 個錨框。每個錨框都是一個固定、已知的方框,於是 RPN 的工作變得無比具體:針對每一個錨框,預測(a)一個物件性分數,說明是否有真實物件與這個特定錨框重疊,以及(b)四個偏移量 (t_x, t_y, t_w, t_h),把錨框變形成更貼合的樣子。這些錨框以多種大小與形狀鋪滿整張影像,所以它們合起來幾乎能對上任何物件,而每一個別的預測,不過就是先前我們稱讚過的那種又小又容易的修正。
一張特徵圖網格,其中某一格上疊著數個以該格為中心、大小與長寬比各異的參考矩形。
RPN 怎麼訓練?用的正是你剛學到的那種損失形式——一個分類項加上一個只對物件才開啟的回歸項——只是特化成兩類。若一個錨框與某個真實標註框重疊得夠好(IoU 高,也就是第 2 篇的重疊度量),就標記為正樣本(物件);若它什麼都沒對上,就標記為負樣本(背景);物件性分數用這個二元分類損失來訓練,偏移量則用 smooth-L1 定位損失,而(一如既往)該損失只對正樣本錨框計算。接著,RPN 會把分數最高的前幾百到幾千個候選區域往下游送進 Fast R-CNN 的偵測頭——也就是第二階段——做完整的分類與最終的方框修正。
RoI Align:修正對齊誤差
現在回到先前標記過的那個「微妙瑕疵」。回想一下,RoI 池化必須拿一個候選區域——它的座標是活在原影像中的實數浮點值——並從特徵圖那離散的整數網格上讀取特徵。為此它四捨五入(量化)了兩次:第一次是把候選區域的邊界貼齊到最近的特徵網格格子;第二次是把那塊區域切成固定的 7x7 子格(子格邊界同樣被四捨五入)。每一次取整都可能讓取樣區域偏移多達半個特徵圖格子,而——因為特徵圖經過降採樣,相對於影像往往是 16 倍或 32 倍——這在原影像上可能意味著好幾個像素的對不準。Faster R-CNN 的分類器幾乎察覺不到,因為判斷「貓還是狗」對小幅的空間位移很有韌性。但凡是需要精確空間保真度的任務——緊貼的框,尤其是逐像素的遮罩——都會受害。
一張特徵網格,顯示候選區域真正的浮點邊界,對比被貼齊到整數格子的取整邊界,呈現出對不準的情形。
RoI Align(2017 年隨 Mask R-CNN 一同提出)移除了所有取整。它保留候選區域精確的浮點邊界,以浮點邊界把它切成固定網格,並在每一格內、精確的次像素位置上擺放取樣點。問題來了:像 (2.7, 4.2) 這樣的取樣點會落在整數特徵格子之間,那裡並沒有儲存任何數值。RoI Align 用雙線性內插算出該處的值——也就是把最近的四個網格值做加權混合。對一個落在整數格子之間的點 (x, y),設 a 與 b 為它的小數偏移;則:
雙線性內插:周圍四個特徵值的加權混合,且權重總和為 1。
拆解一下:⌊x⌋ 是 x 的下取整(無條件捨去),所以 a = x - ⌊x⌋ 就是我們的點越過左側網格線多遠,是個介於 0 與 1 之間的小數;b 則是垂直方向上的同一個小數。四個值 f_00, f_10, f_01, f_11 是圍繞該點的四個整數角落上所儲存的特徵——左下、右下、左上、右上。每個角落的權重,是它到對角角落的兩段小數距離的乘積:左下角拿到 (1-a)(1-b),只有當點靠近它(a 與 b 都很小)時才會大。所以越近的角落權重越大——正符合「一個值應該最像離它最近的資料點」這個直覺。四個權重永遠加起來等於 1,所以結果是貨真價實的加權平均,既不會被灌水也不會被縮水。在 (x, y) = (2.7, 4.2) 的實例:a = 0.7、b = 0.2,得到權重 (1-0.7)(1-0.2)=0.24、(0.7)(0.8)=0.56、(0.3)(0.2)=0.06、(0.7)(0.2)=0.14——加起來剛好 1.00,而 x = 3 那個角落(f_10)以 0.56 居首,因為這個點比較靠右。由於沒有任何座標被取整,取樣到的特徵始終鎖定在真正的區域上,把空間保真度保留到了次像素的層級。
整合起來:兩階段流程全貌
讓我們把一張影像端到端地走過完工的 Faster R-CNN(搭配 RoI Align),好讓每一塊拼圖各歸其位。這條管線是一條乾淨的生產線:昂貴的卷積運算只做一次,便宜的逐區域運算則做很多次。
- 骨幹:讓整張影像通過 CNN 一次,產生單一張共用的特徵圖。
- RPN:讓候選區域網路在那張特徵圖上滑動;在每個位置為錨框打物件性分數並預測其偏移量,產出幾百到幾千個候選區域。
- RoI Align:為每個留下的候選區域,從共用特徵圖上無取整地取樣出一塊固定尺寸(例如 7x7)的特徵網格。
- 偵測頭:把每個區域分類成 K 類之一(或背景),並回歸出最終、修正過的方框。
- NMS:移除同一物件重複、重疊的偵測結果,只保留分數最高的那個。
- 輸出:存活下來的「方框加標籤」就是最終的偵測結果;其品質以 mAP 總結。
# Faster R-CNN forward pass (inference), pseudocode
def detect(image):
feat = backbone(image) # 1. one shared feature map
# 2. RPN proposes regions from anchors
objectness, rpn_offsets = rpn(feat) # score + box delta per anchor
proposals = decode(anchors, rpn_offsets) # anchor + offset -> box
proposals = top_k(proposals, objectness, k=1000)
proposals = nms(proposals, iou_thresh=0.7) # thin out RPN duplicates
detections = []
for box in proposals: # cheap per-region work
roi = roi_align(feat, box, out=(7, 7)) # 3. no rounding
cls_probs, box_offsets = head(roi) # 4. what + where
label, score = argmax(cls_probs)
if label != BACKGROUND and score > thresh:
refined = decode(box, box_offsets[label])
detections.append((label, score, refined))
# 5. drop duplicate detections of the same object
return nms_per_class(detections, iou_thresh=0.5)一張共用特徵圖餵入偵測頭,為每個區域輸出類別分數與邊界框偏移量。
這條血脈為我們換來了什麼?兩階段偵測器以頂尖的準確度著稱,而且在小型與密集排列的物件上格外強悍,因為候選區域網路能提名許多重疊的候選,而專責的第二階段會逐一細看。代價則是結構性的:兩個階段依序執行——先提議、再分類——這會增加延遲,讓嚴格的即時預算(比方說影片每秒 30 張以上)變得真的很難達成。也請注意,第 2 篇的非極大值抑制在上面的管線裡出現了兩次(一次用來精簡 RPN 的候選區域,一次用在最終偵測結果上),這提醒我們:你早已學過的評分工具,是任何真實偵測器中承重的零件。
這份速度稅引出一個讓人無法抗拒的問題:我們真的非得先提議、再分類嗎?如果有一個網路能把影像只看一次,就一口氣直接吐出「方框加標籤」——用一點準確度去換取大量速度,會怎麼樣?這正是單階段偵測器下的賭注,也是我們下一站要去的地方:YOLO、SSD,以及那個讓單次偵測器得以與兩階段巨頭分庭抗禮的巧妙 focal loss。