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

把物件一個個分開:實例與全景分割

從「所有車都叫車」進階到用 Mask R-CNN 為每個物件各自產生遮罩,再用全景分割把一切整合起來。

為什麼語意分割還不夠

還記得本系列第一篇教學裡那兩台停著的車嗎——銀色那台有一半被紅色那台擋住。做語意分割的模型看著這個場景,會把所有「屬於車」的像素全部塗上同一個標籤「車」。因為兩台車重疊又相連,這些像素就連成了一整塊。網路在技術上沒錯——每個被塗到的像素確實都是車——但這個答案對我們最常想問的問題卻派不上用場。

語意分割把兩台車併成一塊「車」區域;實例分割則讓每台車各自擁有獨立的遮罩。

左:兩台重疊的車被塗成同一塊相連的車色區塊。右:同樣兩台車,各自塗上不同顏色並有清楚的輪廓。

問「總共有幾台車?」語意圖無法回答——一塊區域可能是一台車,也可能是五台。問「只選紅色那台車好讓我把它擦掉」一樣卡住——區域內部沒有任何邊界告訴你一台車到哪裡結束、下一台從哪裡開始。同樣的缺陷也讓你無法在影片連續影格中追蹤單獨一台車。缺的關鍵就是實例身分(instance identity):每一個獨立、可數的物件都應該擁有自己獨立的遮罩。這正是實例分割要做到的——不只回答每個像素「是什麼」,更回答它「屬於哪一個具體物件」。

要得到實例遮罩,有兩種很自然的策略。第一種——也是本篇的重點——是先偵測再分割(detect-then-segment):先把每個物件找出來框成方框,再在框內刻出一張遮罩,這樣一次只處理一個物件,就繞過了「黏成一塊」的問題。第二種是最終的整合(unification):用單一輸出標註每一個像素,讓 things 帶上實例 id、stuff 帶上類別,全部一次到位。這種整合觀點叫做全景分割(panoptic segmentation),我們會在最後逐步建立起來。

先偵測再分割:Mask R-CNN

理解 Mask R-CNN 最乾淨的方式是這樣:拿一個你已經認識的物件偵測器——Faster R-CNN——再多接一條分支上去。Faster R-CNN 的全部工作,就是在每個物件外圍畫一個貼合的方框,並說出它是什麼類別。Mask R-CNN 把這一切都保留下來,再加上一個小網路:對每一個偵測到的框,在框內把物件的精確形狀塗出來。偵測告訴你「在哪裡」和「是什麼」;新分支則給你精確的剪影。

我們用適當的高度回顧一下整條流程。骨幹 CNN 把影像轉成一張特徵圖。接著區域提議網路(Region Proposal Network, RPN)在特徵圖上滑動,借助一組預先設定形狀的參考框(稱為錨框 anchors),提出幾百個「裡面大概有東西」的候選區域。每個候選區域都從特徵圖上裁切出來,送進負責做最終判斷的各個頭(head)。

RPN 在每個位置放上數種尺度與長寬比的錨框,再為它們評分並修正成物件提議。

影像上的一個網格,某一點上錨定了多個不同大小與形狀的矩形,其中幾個被標示為可能的物件。

Faster R-CNN 在每個候選區域上接兩個並聯的頭:一個是分類頭,說出物件的類別(車、人……);一個是框迴歸頭,把粗略的提議微調成貼合的方框。Mask R-CNN 唯一的結構性改動,就是再加上第三個並聯的頭:一個小小的全卷積網路(FCN,就是第二篇那一族做逐像素預測的網路),在那單一方框之內輸出一張小小的二元遮罩——前景對背景。

三個並聯的頭共用同一份區域特徵:類別、修正後的框,以及 Mask R-CNN 新增的——每框一張二元遮罩。

一塊裁切下來的區域特徵分流到三條分支:類別標籤、邊界框,以及一張小小的遮罩網格。

接下來這個洞見,是讓整套方法成立、也終於打敗「兩台車黏成一塊」的關鍵。因為偵測器已經把每個框內只放一個物件,遮罩頭面對的不再是擁擠的場景——它只需回答一個乾淨的局部問題:在這個框內,哪些像素是物件、哪些不是? 這就只是普通的前景/背景分割,是這項任務最簡單的版本。兩台重疊的車落進兩個不同的框,各自得到自己的前景遮罩,於是每台車都以獨立實例的身分浮現出來——這正是語意分割永遠給不了我們的。

RoIAlign:修正錯位

有一項技術創新,少了它 Mask R-CNN 的遮罩就會模糊又錯位:RoIAlign。要體會它的價值,先看看它取代了什麼問題。為了把候選區域餵進固定大小的頭,網路必須從特徵圖上裁出一塊固定大小的網格(例如 7×7)。較舊的做法 RoIPool 在裁切時用了兩次取整(rounding)。

區域的實數邊界很少剛好對齊整數的特徵圖網格;你怎麼處理那段小數,決定了遮罩會不會錯位。

一張粗的特徵格子,區域邊界以小數位置切過格子,而不是落在格線上。

問題出在這裡。提議的座標是實數——某個框邊可能落在特徵座標 6.7,而不是漂亮的 7。RoIPool 把 6.7 無條件取整成 6(第一次取整),接著在把區域切成 7×7 子格時又取整一次(第二次取整)。每一次取整,都會讓取樣到的特徵相對於物件真正的位置最多偏移一個像素。對邊界框來說,偏一個像素看不出來——框照樣貼著車。但對講究逐像素精度的遮罩而言,同樣的偏移會把剪影抹糊:預測出的輪廓處處都跟真實邊緣差一個像素,整張遮罩看起來鬆軟又對不準。

Mask R-CNN 的修正方法 RoIAlign 簡單得令人開心:完全不取整。保留區域的實數座標,把取樣點放在它們確切的小數位置上,再用雙線性插值(bilinear interpolation)讀出每個取樣點上的特徵值——也就是混合最鄰近的四個格子。網格對齊被完整保留,於是遮罩能跟物件逐像素地對準。

雙線性插值不過就是「用加權混合的方式,讀出落在格子之間的值」。想像你的取樣點落在某個單位格子內,這個格子的四個角各自帶著已知的特徵值。你要的值,就是這四個角的加權平均,而某個角離你的點越近,權重就越大:

v = (1-d_x)(1-d_y)\,v_{00} + d_x(1-d_y)\,v_{10} + (1-d_x)d_y\,v_{01} + d_x d_y\,v_{11}

雙線性插值:取樣值是周圍四個格子值依距離加權的混合。

我們把每個符號拆開來看。四個角是 v_{00}(左上)、v_{10}(右上)、v_{01}(左下)、v_{11}(右下)——也就是你取樣點周圍那四個格子裡存的特徵值。數字 d_xd_y 都落在 [0,1] 之間,說明你的點橫越格子有多遠:d_x 是水平方向的比例(0=貼最左、1=貼最右),d_y 是垂直方向的比例(0=最上、1=最下)。每一項都把某個角的值乘上對角那塊子矩形的面積——這正是為什麼近的角權重大、遠的角權重小。具體來說,取 d_x=0.25d_y=0.75,角值 v_{00}=10,\,v_{10}=20,\,v_{01}=30,\,v_{11}=40。權重為 (0.75)(0.25)=0.1875(0.25)(0.25)=0.0625(0.75)(0.75)=0.5625(0.25)(0.75)=0.1875,於是 v = 0.1875\cdot10 + 0.0625\cdot20 + 0.5625\cdot30 + 0.1875\cdot40 = 27.5。注意答案偏向 30 與 40——也就是下方兩角——因為 d_y=0.75 把點放到了靠近下緣的位置。這種平滑、不取整的讀取就是整個訣竅:沒有跳變、沒有差一個像素,只是一次乾淨的加權查值。

def roi_align_sample(feat, x, y):
    # feat: feature map; (x, y): exact, non-rounded sample location
    x0, y0 = floor(x), floor(y)      # top-left grid cell (integers)
    x1, y1 = x0 + 1, y0 + 1          # neighbours to the right / below
    dx, dy = x - x0, y - y0          # fractional offsets in [0, 1]

    v00, v10 = feat[y0, x0], feat[y0, x1]   # top-left, top-right
    v01, v11 = feat[y1, x0], feat[y1, x1]   # bottom-left, bottom-right

    # weighted blend of the 4 neighbours -- nearer corners weigh more
    return ((1 - dx) * (1 - dy) * v00 + dx * (1 - dy) * v10 +
            (1 - dx) * dy * v01 + dx * dy * v11)

# RoIAlign: sample at exact locations (NO rounding of x, y),
# then average-pool the samples inside each output cell.
RoIAlign 透過雙線性插值在確切位置讀取每個特徵——取代 RoIPool、完全不取整的做法。

為實例遮罩評分

現在模型對每個物件吐出一張遮罩,我們要怎麼衡量它好不好?我們把第二篇的評估想法,從整張影像的重疊延伸到逐實例的重疊。核心工具是一張預測實例遮罩與一張真實實例遮罩之間的遮罩 IoU(Intersection over Union,交集除以聯集):數出兩張遮罩共享的像素,除以任一張遮罩覆蓋到的像素。IoU 等於 1 表示完美吻合;等於 0 表示完全沒有重疊。

IoU = 重疊面積除以聯集面積。同樣的想法也適用於遮罩,只是改數像素而非框的面積。

兩個重疊的形狀,交集以一種顏色填色、聯集以輪廓標出,示意這個比值。

一個預測實例要算對,必須同時滿足兩個條件:它與某個真實實例的遮罩 IoU 超過所選的門檻(例如 0.5),而且它預測的類別與那個真實物件的類別相符。一張形狀漂亮卻標成「卡車」的遮罩,蓋在一台真正的車上,不算對;一台標對了的車,遮罩卻幾乎沒蓋到真車,也不算對。形狀和標籤都得對上。

實例分割最常用的代表性指標是平均精確率(Average Precision, AP),沿用偵測系列的精確率/召回率直覺。精確率問「在我宣稱的遮罩裡,有多少比例是對的?」;召回率問「在真實的物件裡,我找到了多少比例?」。當你接受信心更低的預測時,會找到更多物件(召回率上升)卻也犯更多錯(精確率下降);AP 把這整條取捨曲線濃縮成一個數字。由於只挑一個 IoU 門檻太武斷,學界會報告寬鬆門檻下的 AP([email protected]),更具代表性的是把門檻從 0.5 掃到 0.95 取平均的 AP——這會獎勵那些即使你要求近乎完美重疊、依然準確的遮罩。

有一個實務上的小麻煩:偵測器很積極,常常對同一個物件吐出好幾張重疊的遮罩。非極大值抑制(Non-max suppression, NMS)負責清理:在同類別、彼此高度重疊的一群預測中,只留下信心最高的那一個,其餘丟掉。沒有 NMS,那些重複的假陽性會把 AP 拖垮。

NMS 在每一群重疊的偵測中保留信心最高的那一個,移除多餘的重複。

左側一個物件周圍有好幾個重疊的框;經過 NMS 後,右側只剩下一個框。

def nms(masks, scores, iou_thresh=0.5):
    order = argsort(scores)[::-1]   # most confident first
    keep = []
    while order:
        i = order.pop(0)            # take the top-scoring mask
        keep.append(i)
        # drop any remaining mask that overlaps it too much (same object)
        order = [j for j in order if mask_iou(masks[i], masks[j]) <= iou_thresh]
    return keep
貪婪式非極大值抑制:留下最好的、抑制它的近似重複,再重複此過程。

全景分割:整合 stuff 與 things

我們現在握有兩項互補的本領:第三篇給了我們把無定形的「stuff」(天空、道路)標得漂亮的語意圖,本篇給了我們把可數的「things」(每台車、每個人)分開的實例遮罩。全景分割把兩者融合成一個連貫的輸出,並下了一條嚴格的規則:每一個像素都恰好得到一個語意標籤;屬於 thing 的像素會額外得到一個唯一的實例 id;屬於 stuff 的像素就只拿到它的類別。沒有任何像素被漏掉,也沒有任何像素被兩個區段同時佔有。這是一張場景完整、無縫隙、無重疊的全貌。

全景輸出:stuff(天空、道路)只帶類別;每個 thing(車、人)帶類別再加一個唯一的實例 id——對每個像素都一致的單一標註。

一個街景:道路與天空是單色的 stuff 區域,而每台車輛與每位行人都是各自著色的實例。

實務上的挑戰是「調和」。實例分支可能交給你重疊的車輛遮罩(兩台車的遮罩共享了幾個邊界像素),而語意分支在接縫處又可能跟它們意見不合。要產生一張不自相矛盾的標註,系統必須化解每一個衝突——通常是依信心高低逐一處理重疊,再用語意的「stuff」圖填補剩下的像素——好讓每個像素最終都恰好歸屬於一個區段。把兩個可能彼此不合的輸出整成一張乾淨的圖,才是全景分割真正的工程功夫。

我們要怎麼為這樣的統合輸出評分?用一個專為它設計的單一指標:全景品質(Panoptic Quality, PQ)。我們先把預測區段與真實區段配對(配對需要 IoU > 0.5,這恰好保證每個區段最多只有一個配對),再把這些配對的「品質」與「數量」結合起來:

PQ = \frac{\sum_{(p,g)\in TP} \mathrm{IoU}(p,g)}{\underbrace{|TP|}_{\text{matched}} + \tfrac{1}{2}\underbrace{|FP|}_{\text{spurious}} + \tfrac{1}{2}\underbrace{|FN|}_{\text{missed}}}

全景品質:所有配對區段的 IoU 總和,除以「配對數」加上各半計的未配對區段。

我們逐項來讀。真陽性(TP)是一個配對 (p,g)——一個預測區段 p 與某個真實區段 g 的 IoU 超過 0.5;|TP| 就是這種配對的數量。假陽性(FP)是沒配對到任何東西的預測區段(模型憑空捏造了一個區段),假陰性(FN)是模型完全漏掉的真實區段。分子 \sum_{(p,g)\in TP}\mathrm{IoU}(p,g) 把所有配對的重疊品質加總——好的配對貢獻接近 1,馬虎的就少一些。分母把配對完整計入,但每個錯誤(一次捏造或一次漏掉)只算一半——這是一種平衡的懲罰,對兩種錯誤都罰、又不會重複計算。完美的預測會讓每個區段都以 IoU 1 配對、且沒有任何 FP/FN,於是 PQ = 1。

優雅之處在於,PQ 能乾淨地拆成兩個可解讀的部分——你對找到的東西分割得多好 乘上 你找東西找得多好

PQ = \underbrace{\frac{\sum_{(p,g)\in TP} \mathrm{IoU}(p,g)}{|TP|}}_{\text{SQ (segmentation quality)}} \;\times\; \underbrace{\frac{|TP|}{|TP| + \tfrac{1}{2}|FP| + \tfrac{1}{2}|FN|}}_{\text{RQ (recognition quality)}}

PQ 可拆為分割品質(配對的平均 IoU)乘以辨識品質(類似 F1 的偵測分數)。

這個拆解只是把原本的 PQ 同乘同除以 |TP|,卻把意義攤了出來。SQ(分割品質)是只算配對區段的平均 IoU——它問「當你真的找到一個物件時,遮罩貼得多緊?」。RQ(辨識品質)正好就是偵測的 F1 分數——|TP| 除以「配對數加上一半的錯誤」——它問「你有沒有找對該找的那組物件,而沒有無中生有或漏掉?」。模型可能以兩種很不同的方式拿低分:遮罩馬虎(SQ 低),或形狀對但物件數量錯(RQ 低),而 PQ 會告訴你是哪一種。實際算一遍:3 台真車;模型準確抓到 2 台(IoU 0.9 與 0.8)、無中生有 1 個(FP = 1)、漏掉 1 台(FN = 1)。則 SQ = (0.9+0.8)/2 = 0.85,RQ = 2 /(2 + 0.5 + 0.5)= 0.667,PQ = 0.85 × 0.667 ≈ 0.567——這與直接代入第一條公式一致:1.7 / 3 ≈ 0.567。它畫的遮罩很好(SQ 高),但把物件這一組搞錯了(RQ 把分數拖了下來)。

最後再看一次那個街景。全景輸出把道路與天空塗成單一的 stuff 區域,給每台車、每位行人各自俐落、分色的遮罩,且不留下任何一個模稜兩可的像素。那一張圖承載了全部:什麼在哪裡(語意)、哪個個體是哪個(實例),全部調和成一份統合、無矛盾的場景描述——這正是整個系列一路鋪陳要抵達的目標。

固定類別模型的不足之處

退一步看看本系列裡每一個模型的共通點——從最早的語意 FCN,到 Mask R-CNN,再到全景系統。每一個都是在固定的類別清單固定的任務上訓練的。網路永遠只能輸出它被訓練過的那些類別之一。想讓它分割一種新的物件——某個特定工具、一隻牠沒見過的動物、一個商標?你就得為那個類別蒐集標註資料、重新訓練。而且,你也無法只是去影像中任意一個東西——「把這個分割出來,不管它是什麼」——就讓模型把它刻出來。它的詞彙,乃至它對「什麼算一個物件」的根本認知,都在訓練那一刻被凍結了。

這正是最後一篇要打破的限制。下一步的轉向,是走向可提示、開放世界、基礎模型(foundation model)的分割:一個你能在推論時用提示去引導的模型——一個點擊、一個方框、一筆塗鴉——去分割任何物件,包括那些它從未被明確教過的類別。請注意,這個想法的種子其實很老:早在第一篇的 GrabCut,就已經讓你「給模型一個提示」——在你想要的東西外圍畫一個粗略矩形——並據此精修出一張遮罩。下一篇會把這個樸素的、由提示驅動的想法,用現代基礎模型的力量放大,讓單一一個通用模型,幾乎能分割你所指的任何東西。