5.3 醫學影像 U-Net 與自動駕駛的 LiDAR 融合(2015-2020)#
一、醫學影像分割:第一個大規模落地的分割場景#
1.1 為什麼醫療是分割的第一個落地場景#
分割模型在自然影像上的普及晚於分類與偵測,但在醫療領域卻是最早落地的一類。原因有四個,而且每一個都是因果性的:
(1)標註的經濟學決定了問題的形式。 一位放射科醫師標註一張 3D CT 的所有切片,需要數小時;而標註一張自然影像的 20 個 bbox 可能只要 10 分鐘。醫療影像的標註成本極高,因此「密集的像素級預測」這種高資訊密度的輸出,反而具有最高的性價比。 一次 CT 標註產生了 300 到 500 張密集標籤的切片,等於免費獲得數百倍的訓練資料。
(2)任務的本質是分割而非分類。 醫療影像的目標是描出病灶的邊界,以便計算體積、進行手術導航、追蹤病變的成長。分類只能回答「有沒有腫瘤」,分割能回答「多大、在哪、什麼形狀」——後者才是臨床決策需要的。
(3)容錯率低,逼迫方法嚴謹。 自動駕駛的失誤可以造成財損,醫療分割的失誤可能傷害病人。這個壓力驅動了更嚴格的驗證protocol(多中心測試、外部驗證、Dice 與 Hausdorff 雙指標),也讓醫療成為分割方法論的試金石。
(4)解剖結構的複雜性與多變性。 器官在不同病人之間的形狀差異極大(肝臟的大小形狀因人而異),病變形狀更是千變萬化。這使得「形狀先驗」比「紋理先驗」更重要,而 U-Net 這種帶有對稱結構與 skip 的架構,恰好善於表達形狀。
醫學影像的維度挑戰:CT 是 3D 的( \(512 \times 512\) 切片 × 數百到上千層),MRI 有各種序列(T1、T2、FLAIR、DWI),體素之間的間距(spacing)各向異性且不標準( \(0.5 \times 0.5 \times 5\) mm 是常見的 CT 切片間距)。這意味著直接處理 3D 體積在記憶體上是不可行的,實務上普遍採用 2.5D 切片策略(輸入相鄰多個切片預測中間切片)或 patch-based 訓練 + 全程滑動推論。
1.2 分割網路的整合問題:可及性與空間資訊的取捨#
U-Net 論文本身就是醫學影像分割的工作(生物醫影像分割挑戰賽)。但要把它用在真實的醫療流程中,還有一堆工程問題:
(1)灰階與多模態。 CT 天然是灰階的,MRI 也是。醫學影像的輸入通道數通常是 1,與 ImageNet 預訓練模型的 3 通道不符。標準做法是把灰階複製到 3 通道以重用預訓練權重。
(2)z 軸方向的各向異性。 CT 切片間距常為 5 mm,而 in-plane 間距為 0.5 mm,相差 10 倍。若把體積直接重採樣成等向性(isotropic),會浪費大量記憶體在 z 軸的空白上;若不重採樣,網路看到的是各向異性的資料。nnU-Net 的做法是保留原始各向異性,並在資料增強中讓模型自己適應,同時對 in-plane 與 through-plane 使用不同的增強強度。
(3)patch-based 的推論。 對一張 \(512 \times 512 \times 300\) 的 CT,完整體積無法塞進 GPU。標準做法是切成 \(128 \times 128 \times 32\) 的 patch 做滑動推論,再用加權平均 + 高斯權重在 patch 邊界處融合(避免接縫)。這是 5.2 節「滑動窗」方法在 3D 的迴歸,但現在滑動窗的候選已經被網路本身取代。
1.3 nnU-Net(2021, Isensee et al.):最重要的「反戲劇性」結果#
nnU-Net 的論點極為大膽:與其設計複雜的新架構,不如把一套仔細設定的 U-Net 的所有周邊環節(預處理、增強、損失、後處理、跨驗證)自動化到極致。
它的自動化規則包括:
- 重採樣:依目標 spacing 自動決定 z 軸與 in-plane 的重採樣因子。
- 正規化:自動偵測影像的強度分布,決定用 z-score 還是 histogram matching。
- 增強:包含旋轉、縮放、鏡射、Gaussian noise、Gaussian blur、brightness、contrast、low-res simulation。
- 損失:自動在 Dice + BCE 與 Dice + CE 之間選擇。
- 後處理:對實例標籤做最大連通域保留(去除離散的小碎塊)。
- 拓撲:2D/3D 的 2D/2.5D/3D 的選擇。
結果令人震驚:nnU-Net 在 23 個不同的醫學分割数据集上,23 個全部第一或並列第一,且沒有使用任何架構上的創新。
這個結果的科學意義遠大於其工程意義:
在分割這個問題上,決定性能的主要因素不是架構,而是問題的設定與資料處理的細節。
這是對整個深度學習領域一條極重要的教訓的回響:第 1.1 節提過「取樣定理決定了重建的上限」,這裡是「預處理決定了分割的上限」。在資料極度稀缺的醫學領域尤其明顯——當只有 30 個訓練樣本時,架構改進帶來的增益會被隨機性淹沒,而資料增強的改進卻是穩健的。
這也構成一個對本章另一個主線(自動駕駛)的呼应:nuScenes 挑戰賽的結果(見 5.5 節)幾乎是同一個命題的另一個實例——規律化、可解釋的模組化流程,仍然優於大而全的端到端方案。
1.4 UNet++(2019)、Swin UNETR(2022)、nnFormer(2023)#
UNet++(2019, Zhou et al.):在 U-Net 的 skip 之間加入密集的巢狀連接。
問題診斷:U-Net 的 skip 直接把編碼器第 \(i\) 層(淺、邊緣資訊多、語義少)接到解碼器第 \(i\) 層(深、語義需求強)。兩者之間存在巨大的語義落差(semantic gap)——淺層特徵幾乎不包含語義資訊,直接拼接進去會造成噪聲。
UNet++ 的解法是在 skip 路上再加一層巢狀的密集連接:
其中 \(\mathcal{U}\) 是上採樣、 \(\mathcal{H}\) 是卷積塊。這個遞迴式讓每一層都能經由短的連接路徑獲得深層的語義,從而緩解語義落差。最終預測是從 \(X^{1,4}\) 經由巢狀的密集聚合得到。
代價:計算量約為 U-Net 的 4 倍(多層密集連接),但可以透過 deep supervision(對每個巢狀層都加一個 loss)在推論時只走最淺的那條路徑來部分補償。
Swin UNETR(2022, Hatamizadeh et al.):把第 6 章的 Swin Transformer 作為 encoder,取代 U-Net 的卷積編碼器。
- 使用 Swin Transformer 的分層特徵金字塔(每個階段解析度減半、通道數加倍),天然對應 U-Net 的四層 encoder。
- 特徵透過 patch merging 層完成下採樣,輸出通道數為 \(96, 192, 384, 768\) 。
- 解碼器仍是 U-Net 式的捲積解碼器 + skip。
為什麼 3D 醫學影像分割特別適合 Transformer:Transformer 的全局感受野能解決 3D 分割的一個痛點——病灶的長距離上下文。例如胰臟的邊界判斷需要看見整個胰臟的形狀,而卷積的局部感受野需要堆疊很多層。這也解釋了為什麼醫學分割是 ViT 早期應用最成功的領域之一。
nnFormer(2023):提出用鄰居注意力(Neighboring Attention)取代卷積。核心想法是:全局自注意力的計算量是 \(O(N^2)\) ,其中 \(N\) 是 token 數(3D 影像上極大)。nnFormer 限制每個 token 只關注其局部鄰域的 token,從而把複雜度降到線性,同時保留 Transformer 的全域建構能力(在深層透過鄰域擴展)。
這個「局部性 + 動態權重」的組合,其實是把第 4.1 節的「局部性原則」與第 6 章的「自注意力原則」結合:捲積的弱點是靜態的權重共享,Transformer 的弱點是平方複雜度。兩者的中間地帶(局部注意力 + 位置依賴的權重)正是 2020 年以後視覺模型的主流方向。
1.5 分割損失函數:從 BCE 到 Tversky#
為什麼 BCE 不夠? BCE 逐像素獨立計算:
它有兩個問題:
- 正負樣本極度不平衡。在 CT 的器官分割中,器官通常只佔體積的 1% 到 10%。BCE 的梯度被背景主導,模型傾向於全預測背景。
- 它優化的是「像素級的正確率」,而不是「區域級的重疊度」。這兩者不一致:一個把器官預測得稍微大一圈、但覆蓋了全部器官的模型,在 Dice 上表現優於一個把器官切掉一塊的模型,但在 BCE 上後者分數更高。
Dice Loss:直接優化 Dice 係數(F1)的軟版本。
對單一類別,給定預測機率 \(\hat{y}_i\) 與真值 \(y_i\) :
優點:是一個區域量,所以正例少時仍能得到強烈的梯度——背景像素對分子的貢獻是 0(因為 \(y_i = 0\) ),只影響分母,因此即使器官只佔 1%,Dice 也能給出有意义的訊號。
缺點:不可微於離散優化、對 batch 敏感、對小目標不穩定。因此實務上常與 BCE 組合使用。
IoU Loss:直接優化 IoU:
在二值情形下,Dice 與 IoU 是單調等價的:
所以兩者的 loss 在最優化上等價,但梯度尺度不同(IoU 的梯度在低重疊時更大,因此對小目標更敏感)。
Focal Loss(已在 5.1 節介紹):在分割中作為 BCE 的替代,抑制易分像素。
Tversky Loss(2017, Abraham & Khan):以可調參數推廣 Dice:
動機:Dice 對 false positive 與 false negative 對稱。但醫療影像中兩者的代價不對稱——漏掉一個小病灶(FN)可能比把一個稍大的區域標進來(FP)嚴重得多。Tversky Loss 用 \(\alpha\) 與 \(\beta\) 分別控制 FP 與 FN 的懲罰:
- \(\alpha = 0.3, \beta = 0.7\) :更重視 FN(少漏診)。
- \(\alpha = 0.7, \beta = 0.3\) :更重視 FP(少誤診)。
這是一個把「領域知識」編碼進 loss 的典型例子——它也預示了第 10 章將討論的議題:當模型用不對稱的代價函數時,它的「最佳」輸出反映的是我們的取捨,而非純粹的醫學事實。
1.6 評估指標:Dice 與 HD95#
Dice 係數(F1):
HD95(95th percentile Hausdorff Distance):Hausdorff 距離定義為
即「A 中離 B 最遠的點,到 B 的最近距離」的兩方向最大值。問題:Hausdorff 距離由單一最差的點決定,一個孤立的小誤判就能讓整個指標爆掉。HD95 取距離分布的第 95 百分位數,對這類離群點穩健得多。
為什麼 Hausdorff 對邊界誤差特別敏感,而 Dice 不是:
- Dice 是區域量。一個 100×100 的器官,邊界整體偏移 2 像素,Dice 幾乎不變(約 0.96)。
- Hausdorff 是邊界量。同樣的 2 像素偏移,Hausdorff 約為 2 到 3 像素。
在外科導航中,這個差異是決定性的:手術刀需要避開血管邊界 5 毫米,2 像素(醫學影像上常約 0.5 mm)的平均邊界誤差可能無關緊要,但一個局部的 15 毫米突出(HD95 捕捉到了)可能就是災難。因此醫療分割的標準報告必須同時包含 Dice(區域重疊)與 HD95(邊界精度),只看 Dice 是不負責任的。
1.7 公開資料集#
| 資料集 | 模態 | 內容 | 典型任務 |
|---|---|---|---|
| AMOS (2022) | CT + MRI | 多器官腹部,含多中心資料 | 多器官分割 |
| TotalSegmentator (2023) | CT | 全身 117 個結構 | 多器官分割 |
| ICHOW / KiTS | CT | 腎臟與腎細胞癌 | 病灶分割 |
| LiTS | CT | 肝臟與肝腫瘤 | 病灶分割 |
| BraTS | MRI (多序列) | 腦瘤,含 flair/t1/t1ce/t2 | 腫瘤子區域分割 |
| ACDC | 心臟 MRI | 心室與心房 | 心臟分割 |
| ISBI / 顯微鏡 | 病理 | 細胞核分割 | 實例分割 |
AMOS 與 TotalSegmentator 的重要意義:它們把多器官分割從「一個器官一個競賽」變成「一次分割全身」,並且提供了跨中心、跨掃描儀的資料——這使得「模型是否過擬合到某一台機器」的問題可以被直接檢驗。TotalSegmentator 甚至成為多數下游醫療 AI 研究的骨幹特徵提取器。
1.8 可解釋性與臨床信任#
Grad-CAM(2017, Selvaraju et al.):分類模型的解釋方法,但同樣適用於分割。
原理:對目標類別的分數 \(y^c\) 關於某個卷積層的特徵圖 \(A^k\) 求梯度,然後以梯度加權特徵圖並做 ReLU:
直覺解釋: \(w_k^c\) 是「通道 \(k\) 的特徵圖在多大程度上影響了類別 \(c\) 的分數」。梯度為正表示「這個通道的存在推高了分數」,為負表示「抑制」。加權後的特徵圖就是「模型看什麼來做出這個判斷」。
Grad-CAM 的侷限(對分割特別重要):
- 解析度低。Grad-CAM 的輸出是特徵圖的解析度(通常 stride 16 或 32),必須放大才能與原圖對齊,因此它只能指出「大致的區域」,無法指出精確邊界。
- 對分割而言,「解釋」可能不太有意義。分割的輸出本身就是一個空間定位的結果——你已經知道模型認為病灶在哪裡。Grad-CAM 在分類中解決的是「模型看哪裡」,但在分割中這是冗餘的。
分割領域真正需要的可解釋性工具是反事實(counterfactual)解釋與不確定性量化(例如 MC dropout、deep ensembles、test-time augmentation 的一致性)。
1.9 3D 連續性:醫療分割的隱藏大問題#
問題:以 2D slice-by-slice 的方式訓練與推論時,模型在相鄰切片之間的預測不保證連續。一個在切片 \(k\) 被標為病灶的像素,在切片 \(k+1\) 可能突然變成正常組織。
為什麼這是「大問題」而非小瑕疵:
- 體積計算直接受影響。臨床研究常以「病灶體積變化」作為治療反應的指標(RECIST 標準)。切片間的不連續會讓體積估計有偏誤。
- 3D 可視化會出現雜訊:醫師在 3D 重建中會看到散落的「飛點」與孔洞,嚴重損害對結果的信任。
- 下游流程崩潰: radiotherapy 的放射劑量計算需要連續的器官輪廓,不連續會導致計畫失敗。
解決方向:
- 3D 卷積(直接處理體積)——最直接,但記憶體成本高。
- 2.5D 輸入:輸入相鄰多個切片,預測中間切片。nnU-Net 把這個作為標準選項。
- 後處理連通性:對 3D 預測做 3D 最大連通域保留,去除孤立碎片。但這只是治標:它修正了碎片,卻無法修正「邊界在切片間抖動」的問題。
- 連續性-aware 的損失:加入一個懲罰相鄰切片預測不一致的項:
其中 Warp 是考慮切片間解剖變化的對齊操作。 * 表面平滑正則化:對分割的 3D 表面施加曲率正則化,保證解剖學上合理的平滑。
這個問題是本章與第 6 章的接點之一:3D 醫學影像分割正是 Transformer 最容易展示優勢的場景,因為全局注意力天然地提供了 3D 空間的長距離一致性(一個 3D ViT 的注意力可以跨越整個體積的切片)。
二、自動駕駛感知:相機與 LiDAR 的互補#
2.1 兩種感測器的根本差異#
自動駕駛需要的是三維的、結構化的、帶語意的場景理解。目前沒有任何單一感測器能獨立完成這件事。
| 屬性 | 相機 Camera | LiDAR |
|---|---|---|
| 輸出 | 稠密 2D 影像 | 稀疏 3D 點集 |
| 顏色與紋理 | 有(交通燈號、路面標線、車身文字) | 無 |
| 深度 | 無(需推估) | 有(直接量測,精度約 2 cm) |
| 幾何形狀 | 弱(透視讓遠方物體很小) | 強(真實 3D 尺寸) |
| 稠密度 | 高(每像素) | 低(64 線雷射約 1000–2000 點/幀 × 車) |
| 對天氣的敏感度 | 極高(雨、霧、夜間、雪) | 相對低(除極端暴雨) |
| 對光照的敏感度 | 極高(逆光、夜間) | 低(雷射自發光) |
| 速度 | 高(30–60 FPS) | 中(10–20 FPS) |
| 成本 | 低 | 高 |
| 語意 | 需模型推估 | 幾乎沒有(只有幾何) |
關鍵洞察:兩者的失效模式是互補的。
- 相機的失效:在低光、逆光、雨霧時,紋理與顏色資訊崩塌;而幾何仍然可以從單張影像估得(透過透視幾何與先驗),只是精度較差。
- LiDAR 的失效:幾何永遠可靠,但完全無法分辨「紅色車」與「藍色車」,也無法讀取交通燈號、路面箭頭標線、行人手機上的廣告——這些語意資訊對自駕駛至關重要(例如「前方行人正在過馬路」與「前方有計程車」需要不同的反應)。
這個互補性,就是整個多感測器融合研究存在的理由。
2.2 點雲的處理:從原始點到網路可用的表示#
2.2.1 地面平面移除(Ground Plane Removal)#
LiDAR 點雲中,約 70% 到 80% 的點落在地面上——這些點對偵測車輛毫無用處,卻佔用了 3D 卷積的計算量。移除它們是最基本的優化。
RANSAC 演算法(Random Sample Consensus, 1981):
- 隨機抽取最小的最小點集(例如 3 個點擬合一個平面)。
- 擬合模型(平面)。
- 計算所有點到平面的距離,把距離小於閾值的點歸為 inlier。
- 計算 inlier 比例,若高於門檻則接受此模型,否則重新抽樣。
- 重複 \(k\) 次,取 inlier 最多的模型。
RANSAC 的優雅之處在於:它不需要資料服從任何分布假設,這對真實世界的 LiDAR 資料特別重要(地面不一定是平面、有坡度)。
更現代的做法是使用 PCR(Plane-based Cluster Removal),在 RANSAC 找到地面後,進一步把地面附近的殘餘點(如車底、路面凸起)用「分割後的地面平面 + 高度閾值」移除。
2.2.2 體素化(Voxelization)#
規則卷積(dense convolution)要求輸入是稠密的規則張量。點雲是稀疏、無序、連續的三維集合,必須先轉換。
體素化(VoxelNet, 2017, Zhou & Tuzel):把空間劃分為 \(V \times V \times V\) 的網格,每個點落入對應的體素,然後用每個體素內點的數量作為特徵:
其中 \(n\) 是該體素內的點數, \(n_{\max}\) 是截斷上限。截斷的理由是避免密集區域(如車頂) dominate 住特徵。
體素化引入了量化誤差:體素邊界上的點會被歸錯,細緻的結構(小行人、遠處的桿子)可能被合併或抹平。體素尺寸是速度與精確度的取捨:體素越小精度越高,但網格數量呈立方級增長。
2.2.3 稀疏卷積(SparseConvNet, 2018, Graham & Engelcke)#
稀疏卷積的關鍵觀察:在 LiDAR 點雲中,即使在 0.1 米解析度下,一個場景的有效體素佔比仍然只有 1% 到 5%。對 99% 的空體素做卷積是純粹的浪費。
稀疏卷積的基本操作:只對「活躍體素」(至少含一個非零輸入的位置)計算輸出,並用一個散列表(hash table)把非規則的活躍座標映射到稠密的索引空間。
規則卷積 vs 稀疏卷積:
| 規則卷積 Rule Conv | 稀疏卷積 Sparse Conv | |
|---|---|---|
| 輸入 | 稠密 \(D \times H \times W \times C\) | 索引集合 \(\{ (x, y, z, f) \}\) |
| 輸出位置 | 每個位置都有 | 只有「規則子卷積」觸及的位置 |
| 計算量 | \(O(D^3 H W C)\) | \(O(N_{\text{active}} \cdot K)\) |
| 座標 | 隱含 | 顯式儲存 |
| 邊界 | 零填充 | 座標邊界處理更複雜 |
Submanifold Sparse Convolution(submanifold convolution)是其中最重要的一個變體:
- 規則稀疏卷積會產生比輸入更多的活躍位置(因為 kernel 的偏移會擴散到原本為空的位置)。
- Submanifold 稀疏卷積保證輸出的活躍位置集合與輸入完全相同——每一個輸出的位置只由「中心落在該位置」的 kernel offsets 貢獻。
為什麼 submanifold 這麼重要? 因為它讓稀疏網路可以像規則卷積一樣堆疊,且不會讓活躍位置隨層數膨脹。若用規則稀疏卷積,經過 20 層後,活躍位置會從 5% 膨脹到近 100%,稀疏性完全喪失。
這個「稀疏化是深度學習的重要工程方向」的洞察超越自駕駛:整個領域有大量類似的例子——Mixture-of-Experts(只啟動部分專家)、稀疏注意力、Early exiting、動態網路結構。共同的主題是:在真實世界的資料中,資訊密度遠低於張量的形狀所暗示的。設計神經網路時,資料的稀疏性應該被尊重,而不是先設計一個稠密結構再事後裁剪。
2.3 點雲深度學習的演進#
PointNet(2017, Qi et al.)#
PointNet 的核心貢獻是處理「集合的對稱性」。點雲是一個集合(set),不是序列:它沒有固定的順序,點的排列不應影響輸出。
對稱性問題:若直接把點雲當作一個 \(N \times 3\) 的矩陣餵給網路,輸出会依賴點的順序。而我們希望的是一個對排列等變(permutation equivariant)的函數。
PointNet 的解法:使用 **對稱函數 \(g = \max\) **。
這個結構保證了排列等變性:內層 \(h\) 對每個點獨立作用(所以點的順序無關),外層的 max 是對稱函數(所以輸出也與順序無關)。
max pooling 作為集合運算的三個優點:
- 排列不變(對稱函數)。
- 可以捕捉局部結構:max 是「存在性」的聚合,比 sum 對離群點更穩健。
- 理論上是一個 universal set function(配合適當的 \(h\) 與 \(\gamma\) )。
但 max pooling 丟棄了幾何資訊:max 對每個維度獨立取最大值,無法表達「這三個點共線」這類關係。這是 PointNet 的主要弱點。
PointNet++(2017, Qi et al.)#
以階層式(hierarchical)的 set abstraction 取代單一的 global max pooling:
- Sampling:用 farthest point sampling (FPS) 從密集的點中選出 \(n\) 個中心點。
- Grouping:把每個中心點的鄰域分組(ball query 或 kNN)。
- Learning:對每個局部鄰域用 PointNet 做特徵聚合。
這把 PointNet 的「局部 + 全域」組合起來,形成一個能處理多尺度幾何的金字塔。這正是後來所有點雲網路的通用骨架。
VoxelNet、SECOND、CenterPoint#
- VoxelNet(2017):體素化 + 在體素內做 PointNet 式的聚合 + 中間卷積特徵編碼。
- SECOND(2018, Yan et al.):稀疏卷積(3D sparse conv)取代中間的 3D 卷積,並使用稀疏點雲上的 anchor-free 偵測頭。同時用二維稀疏卷積把 3D 特徵壓縮到 BEV。這是稀疏卷積在工業界首次大規模落地。
- CenterPoint(2021, Yin et al.):完全 anchor-free,在 BEV 空間用 CenterNet 式的中心點熱度圖偵測。這是 LiDAR 偵測的顯著簡化,並且在 nuScenes 上達到 SOTA,同時更快。
演進的邏輯線:
| 方法 | 表示 | 偵測頭 | 關鍵改進 |
|---|---|---|---|
| PointNet | 點集合 | 直接回歸 | 對稱性處理(max pool) |
| PointNet++ | 階層點集合 | 直接回歸 | 局部-全域階層 |
| VoxelNet | 體素 | anchor-based 3D | 體素化 + 中間編碼 |
| SECOND | 稀疏體素 | anchor-free BEV | 稀疏卷積 |
| CenterPoint | BEV | anchor-free 中心點 | 簡化為 2D 問題 |
注意最後一步的意義:CenterPoint 把 3D 偵測降到 2D BEV 偵測。這是因為 BEV 表示在物理上是「把 3D 問題投影到地面」,大幅降低了維度同時保留了幾何量測(深度由 BEV 網路直接回歸)。
2.4 Bird's-Eye-View 表示#
BEV(鳥瞰視圖) 是自動駕駛感知架構的樞紐觀念。
為什麼 BEV 勝過 perspective view?
- 幾何對齊:BEV 平面與地面平行,物體在 BEV 上的位置與其在地面上的位置一致,且不同高度的物體(行人、轎車、貨車)在 BEV 上的投影是分離的(因為有高度資訊)。相較之下,透視視圖中遠方物體擠在畫面中央的小區域,難以分辨。
- 多相機融合自然:車載系統有 6 到 7 個相機(前方、前左、前右、後左、後右、後方),它們的影像都在透視視圖中,若要在透視空間融合,需要處理極度複雜的透视變換。在 BEV 空間中,所有相機的特徵都投到同一個平面,融合是簡單的相加或注意力。
- 與規劃器對接:自動駕駛的軌跡規劃本來就是在地面上做(車道線、車輛的縱向與橫向位置)。BEV 表示讓「感知」與「規劃」共用同一個座標系。
- LiDAR 天然就是 3D,需要投影到 2D:LiDAR 點雲帶有 3D 座標,若在 3D 空間做偵測,複雜度與資料稀疏性是災難。投影到 BEV 把問題降為 2D。
Lift-Splat-Shoot(LSS, 2020, Li et al.)#
LSS 是 BEV 表示的開創性工作之一,其流程是:
- Lift:對每個相機像素,預測一個類別分布 \(p(z \mid x, y)\) (深度分布)與特徵 \(f\) 。
- 把每個像素「提升」到 3D 空間中對應的多個深度假設上,每個深度點承載 \(p(z) \cdot f\) 的特徵:
- Splat:用外積(outer product)把這些 3D 特徵「灑」到 BEV 平面上:
- Shoot:把 BEV 特徵圖用可微分的方式渲染回多個相機視角,作為深度預測的監督訊號(這就是名字中的 "Shoot")。
LSS 的重要性在於它讓 BEV 從「LiDAR 專用」變成「相機也能用」。 之前的 BEV 方法(VoxelNet、BEVDet)都需要 LiDAR 或深度圖;LSS 證明純相機也能建 BEV。
LSS 的侷限:它是相機與 BEV 之間的硬性幾何變換(固定的相機外參),在相機姿態誤差(calibration drift)下會失效;而且它把深度估計的誤差累積到 BEV 上。
BEVFormer(2022, Li et al.)與 Transformer-based BEV encoder#
BEVFormer 的改進是用可學習的 BEV query 取代固定的投影:
- 在 BEV 平面上設定 \(H \times W\) 個 query,每個 query 是一個 3D 參考點(帶著高度 \(z\) )。
- 用空間交叉注意力(Spatial Cross-Attention)讓每個 BEV query 去「看」所有相機特徵圖中與它投影位置相關的區域(透過 3D-to-2D 投影建立關聯)。
- 用自注意力讓 BEV query 之間交換資訊(處理遮擋、長距離推理)。
- 再用時間自注意力(對齊到當前 frame)融合歷史幀,處理遮擋下的暫時性缺失。
這個設計的本質是把 BEV 變成了一個 Transformer 的 token 序列——這直接銜接第 6 章:BEV Transformer 是「3D 幾何」與「Transformer 架構」最自然的交會點。
NUScenes 基準#
nuScenes(2019, Waymo 等)是自動駕駛感知的主流基準,特點是多感測器同步(6 相機 + 5 雷射 + RADAR + GPS/IMU),並提供 3D 標註的框與速度、以及可追蹤的實例 ID(支援跨幀的 MOT 評估)。
nuScenes 偵測榜的歷史走勢:
| 年份 | 方法 | mAP (NuScenes) |
|---|---|---|
| 2019 | PointPillars | 0.423 |
| 2020 | SECOND | 0.517 |
| 2020 | CenterPoint | 0.566 |
| 2021 | CenterPoint (voxel) | 0.583 |
| 2021 | Cylinder3D | 0.599 |
| 2022 | BEVFormer (camera) | 0.485 |
| 2023 | BEVDet4D / StreamPETR | 0.63+ |
兩個值得注意的趨勢:
- LiDAR-only 長期領先 camera-only,差距約 10 個百分點(LiDAR 提供真實深度)。但到了 BEVFormer 時代,camera-only 已經能達到 LiDAR-only 的 80% 以上。
- 時間融合的增益巨大。BEVDet4D、StreamPETR 等加入歷史幀後,mAP 從 0.52 跳到 0.63 以上。這是因為單幀的遮擋問題嚴重:車輛被前方車輛遮住時,單幀看不到,但看過去 4 幀就能看到。時間維度是自動駕駛相對人類駕駛的最大優勢之一(人類的視覺系統本質上也是時間積分的)。
2.5 相機 + LiDAR 的融合:三個層次#
融合位置的分類(這是一個「越早融合資訊越多、但錯誤傳播風險也越高」的取捨):
flowchart TD
subgraph EARLY[早期融合 Early / Input-level]
A1[相機影像] --> A3[串接或相加<br/>進入同一個骨幹]
A2[LiDAR 點雲] --> A3
A3 --> A4[共享特徵提取器]
A4 --> A5[偵測頭]
end
subgraph MID[中間融合 Intermediate / Feature-level]
B1[相機影像] --> B3[相機骨幹<br/>BEV 特徵]
B2[LiDAR 點雲] --> B4[LiDAR 骨幹<br/>BEV 特徵]
B3 --> B5[BEV 空間融合<br/>相加或注意力]
B4 --> B5
B5 --> B6[共同偵測頭]
end
subgraph LATE[後期融合 Late / Decision-level]
C1[相機偵測結果<br/>3D 框] --> C3[結果融合<br/>投票或加權]
C2[LiDAR 偵測結果<br/>3D 框] --> C3
C3 --> C4[最終結果]
end| 融合層次 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| 早期 Early | 影像與點雲在輸入層拼接 | 網路可學最豐富的跨模態互動 | 需要嚴密的配準;LiDAR 投影到影像需要深度估計(誤差放大);特徵維度不匹配 |
| 中間 Intermediate | 各有骨幹,在特徵層(通常 BEV)融合 | 保留各模態的特有編碼器;BEV 天然對齊 | 需要精確的外參;融合模組的設計是主要工程負擔 |
| 後期 Late | 各自偵測後合併結果 | 模組解耦、易於除錯;任一模組失效不會拖垮另一個 | 無法利用跨模組的細粒度資訊;合併規則需要手工設計 |
實務上的主流是「中期融合」:兩個骨幹各自提取特徵到 BEV,然後在 BEV 上融合。BEV 是融合的甜蜜點,因為它是唯一一個「兩個感測器的資訊都能自然對齊、且不需要猜測深度」的表示。
一個重要的實證結果:在 nuScenes 上,簡單的幾何融合(把 LiDAR 點雲投影到 BEV,直接在 BEV 網格上填入特徵)加上相機的 BEV 特徵相加,就能獲得大部分的融合增益。這再次印證了「結構化的中間表示 + 簡單的融合」的價值——與 nnU-Net 的結論同源。
2.6 校準:融合的工程難點#
外參(extrinsic):感測器與車體座標系之間的剛體變換,通常由 \(R\) (旋轉)與 \(t\) (平移)表示:
內參(intrinsic):相機的焦距與主點。
位姿(pose):車體在世界座標系中的位置與姿態,由 GPS/IMU 給出,但會隨時間漂移。
時間戳誤差(time synchronization):相機與 LiDAR 的擷取時間不同。若 LiDAR 掃描一幀需要 100 ms,而車在這段時間內行駛了 3 米,那麼逐點投影到影像時,LiDAR 點實際上對應的是影像中「過去某個時刻」的位置。在高速行駛時,這個誤差可以達到數十像素,相當於一個行人的寬度。
解決方式:
- 時間同步:用硬體觸發(PTP / PPS)讓所有感測器在同一時刻取樣。nuScenes 用的是精確的時間戳 + 運動補償。
- 線上校準:用資料-driven 的方式估計外參(例如從點雲與影像的對齊中學習),這是近年來的重要方向(CalibNet、LCCNet 等)。
- 動態物體的處理:因為車在動,投影點雲到當前影像時必須先做運動補償(用車體運動模型把每個點的時間戳對應到當前時刻)。
這是本章一個重要的「非深度學習的難題」的提醒:融合的所有收益都建立在「幾何對齊是對的」這個前提上。深度學習無法補償一個系統性的幾何誤差——它只會把它擬合進權重裡,在訓練集上有效,在新資料上失效。
2.7 多目標追蹤#
為什麼需要追蹤? 因為規劃需要知道目標的速度與意圖。一個靜止的路人與一個以 10 m/s 朝你走來的行人,需要完全不同的反應。而單幀偵測原理上無法給出速度——必須利用時間資訊。
SORT(2016, Bewley et al.)#
SORT = Simple Online and Realtime Tracking,核心是兩個零件:
(1)卡爾曼濾波器(Kalman Filter) 預測目標的運動狀態。狀態向量取 8 維:
即位置、速度、高度、寬度與其變化率。運動模型假設等速直線運動(恆速度模型):
在偵測結果與卡爾曼預測之間,使用匈牙利演算法做資料關聯(依 cost matrix,以 IoU 為主要代價)。
(2)卡爾曼濾波器的量測更新。
SORT 的優點是極快(假設 100 個目標可達 400+ FPS),缺點是外觀無感:在嚴重遮擋後重新出現時,ID 切換(identity switch)頻繁。
DeepSORT(2017, Wojke et al.)#
DeepSORT 在 SORT 的基礎上加入外觀特徵(appearance descriptor):
- 用一個 CNN(ReID網路)從偵測框中提取 \(128\) 維的外觀向量,並用 cosine distance 衡量距離:
- 結合運動代價(馬氏距離)與外觀代價,構成總代價:
- 關聯時用卡爾曼濾波器的馬氏距離門控來限制可行的配對範圍(gating),並配合 Hungarian 演算法。
DeepSORT 的效果:ID switch 數量大幅下降約 50%,IDF1 從 SORT 的約 0.36 提升到約 0.47。但代價是必須維持一個外觀特徵的資料庫(gallery),且推理速度降到約 30 FPS。
ByteTrack(2022, Zhang et al.)#
ByteTrack 是一個極度簡潔卻極有效的想法:
核心洞察:在 MOT 中,低分的偵測框不是雜訊,而是被遮擋物體的痕跡。SORT 直接丟棄低分框,導致被遮擋的物體「消失」,重新出現時 ID 切換。
ByteTrack 的兩階段關聯:
- 第一階段(High-score association):只用高分框做關聯,與 SORT 相同。
- 第二階段(Low-score association):把第一階段未匹配的低分框與未匹配的軌跡做第二次關聯。
為什麼這有效? 因為遮擋中的物體的偵測分數會下降(部分可見),但它並沒有消失。用低分框續上軌跡,就能在遮擋結束後保持 ID 連續。
ByteTrack 在 MOT17 上以約 60+ FPS 達到 SOTA,且不需要額外的外觀特徵網路——這是「用正確的問題定義勝過用更大的模型」的又一例證。
MOT 的評估指標:
- MOTA:= 1 − (FN + FP + IDSW) / GT,其中 IDSW 是 ID switch 數。
- IDF1:以 ID 為單位的 F1,需用一個最大匹配演算法求 ID 的最佳對應。
三、端到端自駕駛的教訓#
3.1 nuScenes 端到端挑戰(CVPR 2023)#
2023 年,nuScenes 首次舉辦了端到端自駕挑戰賽,規則是:模型直接從感測器輸入輸出未來 4 秒的規劃軌跡,以碰撞率(Collision Rate)與規劃偏差(Planning Displacement)評分。
結果是驚人的:
- 最好的純模仿學習(imitation learning)模型(如 UniAD / CausalAvg 的變體):碰撞率約 16.7 CAV(CAV = 碰撞率的可變權重單位,越低越好)。
- 傳統模組化 pipeline(偵測 → 追蹤 → 預測 → 規劃):約 0.30 CAV。
也就是說,純端到端的模仿學習模型的碰撞率是傳統 pipeline 的 50 倍以上。
3.2 為什麼會這樣:三個機制#
(1)模仿學習的分布偏移(distribution shift)問題。 模仿學習的訓練資料是「人類駕駛開車時的軌跡」,但模型的推論情境會因為自己的行為而偏離訓練分布。開車行為具有反饋性:車稍微偏離車道 → 資料集中沒有「車在這個位置」的示範 → 模型不知道怎麼修正 → 偏離更多。這是模仿學習的經典失效模式,也是 DAgger(Dataset Aggregation)等演算法存在的理由。
(2)端到端模型把「稀有但關鍵」的資訊稀釋了。 一個碰撞的決定往往取決於一個非常局部的資訊(例如「那位行人正在看手機,即將跨越」或「那輛車的剎車燈亮了」)。但在端到端的網路中,這個資訊要經過數百層的非線性變換才能影響最終輸出,而訓練資料中「差幾釐米就撞上」的樣本極度稀少。梯度訊號被稀釋,模型學到的是「平均行為」而非「臨界行為」。
(3)模組化 pipeline 攜帶了先驗與可解釋性。 追蹤模組攜帶了「物體不會憑空消失」這類運動先驗;規劃模組攜帶了「車速要平滑、不能瞬間改變」這類運動學約束;偵測模組是獨立訓練的、有大量標註資料支撐。端到端模型必須從原始輸入重新學會所有這些,而資料量不足時做不到。
3.3 科學結論#
在資料量有限、且失敗代價高度不對稱的場景中,規律化、可解釋的模組化流程仍然優於大而全的端到端模仿學習。
這個結論的重要性在於它與本節 1.3 節的 nnU-Net 結果形成了一個強烈的對稱:
| nnU-Net | 端到端自駕 | |
|---|---|---|
| 場景 | 醫學分割 | 自駕規劃 |
| 「花招派」的做法 | 設計複雜的新架構 | 大而全的端到端網路 |
| 「規律派」的做法 | 樸素的 U-Net + 頂級工程 | 模組化 pipeline |
| 結果 | 樸素派全面獲勝 | 樸素派碰撞率低 50 倍 |
共同的方法論:在資料受限的領域,架構的複雜度不是免費的。 每一層的表達能力都必須由資料來支付,付不起就會過擬合。而工程細節(預處理、增強、後處理、損失設計、量化、推理加速)是低風險高回報的——它們不增加模型容量,所以不增加過擬合風險。
這也是全書一條隱性主線的再次確認:第 3 章說「手工特徵靠人類先驗對抗變化,但語義鴻溝終究填不平」;深度學習確實填平了語義鴻溝。但「用先驗」這件事本身從未過時——只是先驗的落點從「特徵」搬到了「架構結構、損失函式、資料處理、以及系統的模組劃分」。這也預示了第 10 章將處理的倫理議題:把先驗放進系統的方式(誰定義了「少漏診」的權重?)本身就是一個價值判斷。
3.4 融合的未來方向(銜接第 6 章)#
自駕駛感知正在走向「多感測器 + Transformer + 時間序列」:
- BEV Transformer(BEVFormer、BEVDet4D、StreamPETR):把相機特徵與歷史幀統一到 BEV 的 token 序列上。
- Sparse BEV:用稀疏 query 取代稠密 BEV 網格。
- 端到端規劃的重新設計:把模組化 pipeline 的先驗以可學習的方式注入端到端模型(例如 CausalAvg 引入因果注意力來處理稀有事件的建模),而不是純粹的模仿學習。
這正是第 6 章要處理的問題:當 Transformer 進入 BEV 空間,時間、空間、跨模態的關係都被統一成注意力。從第 5 章的「模組化拼裝」走向第 6 章的「端到端統一」,是 2017 到 2023 年視覺領域最深刻的轉向。
四、實作#
4.1 Dice Loss 與 Tversky Loss 的 PyTorch 實作#
import torch
import torch.nn as nn
import torch.nn.functional as F
class SoftDiceLoss(nn.Module):
"""
多類別 Soft Dice Loss(以 batch 為單位逐類別計算)。
支援自動排除背景類別。
"""
def __init__(self, num_classes, ignore_background=True, smooth=1e-5):
super().__init__()
self.num_classes = num_classes
self.ignore_background = ignore_background
self.smooth = smooth
def forward(self, logits, targets):
# logits: (B, C, H, W);targets: (B, H, W) int64
probs = torch.softmax(logits, dim=1)
one_hot = F.one_hot(targets, self.num_classes)
one_hot = one_hot.permute(0, 3, 1, 2).float()
one_hot = (one_hot - one_hot.min()).clamp(min=0) # 保持 [0,1]
dims = (0, 2, 3)
inter = (probs * one_hot).sum(dims)
card = probs.sum(dims) + one_hot.sum(dims)
dice = (2 * inter + self.smooth) / (card + self.smooth)
if self.ignore_background:
dice = dice[1:]
return 1.0 - dice.mean()
class TverskyLoss(nn.Module):
"""
Tversky Loss: alpha 調控 FP 懲罰,beta 調控 FN 懲罰。
alpha + beta = 1。
alpha 越大 -> 越重視 FP(例如減少誤診)
alpha 越小 -> 越重視 FN(例如避免漏診)
"""
def __init__(self, alpha=0.3, beta=0.7, smooth=1.0):
super().__init__()
self.alpha = alpha
self.beta = beta
self.smooth = smooth
def forward(self, logits, targets):
probs = torch.softmax(logits, dim=1)
# 二值化:只取前景類別
p = probs[:, 1:2]
y = (targets == 1).float().unsqueeze(1)
dims = (0, 2, 3)
tp = (p * y).sum(dims)
fp = (p * (1 - y)).sum(dims)
fn = ((1 - p) * y).sum(dims)
loss = (tp + self.smooth) / (tp + self.alpha * fp + self.beta * fn + self.smooth)
return 1.0 - loss.mean()
class DiceBCELoss(nn.Module):
"""Dice + BCE 的加權組合(nnU-Net 風格)"""
def __init__(self, num_classes, bce_weight=0.5, smooth=1e-5):
super().__init__()
self.bce_weight = bce_weight
self.dice = SoftDiceLoss(num_classes, smooth=smooth)
def forward(self, logits, targets):
bce = F.cross_entropy(logits, targets)
dice = self.dice(logits, targets)
return self.bce_weight * bce + (1 - self.bce_weight) * dice
4.2 醫學分割的 3D 連續性損失#
import torch
import torch.nn as nn
import torch.nn.functional as F
class SliceConsistencyLoss(nn.Module):
"""
3D 連續性約束:懲罰相鄰切片預測的不一致。
用一個可学习的 3D 卷積(或輕量變換)模擬切片間的解剖變化,
要求 slice k 的預測能解釋 slice k+1 的預測。
"""
def __init__(self, num_classes, kernel_size=3):
super().__init__()
self.num_classes = num_classes
# 以殘差形式參數化的輕量變換,初始為恆等
self.conv = nn.Conv3d(num_classes, num_classes, kernel_size,
padding=kernel_size // 2, bias=False)
nn.init.zeros_(self.conv.weight)
with torch.no_grad():
self.conv.weight[:, :, 1, :, :] = 1.0 # 初始為 z 方向恆等
def forward(self, logits):
# logits: (B, C, D, H, W)
warped = self.conv(logits)
diff = (warped[:, :, 1:] - logits[:, :, :-1]).abs()
return diff.mean()
4.3 LiDAR 點雲的基本處理#
import numpy as np
def remove_ground_plane(points, max_iter=100, distance_threshold=0.3):
"""
以 RANSAC 擬合地面平面並移除地面點。
points: (N, 4) 陣列,每行為 [x, y, z, intensity]
回傳: (non_ground_points, ground_plane_normal, ground_plane_offset)
"""
pts = points[:, :3]
best_inliers = None
best_count = 0
n = pts.shape[0]
for _ in range(max_iter):
# 隨機抽 3 點擬合平面
idx = np.random.choice(n, 3, replace=False)
p1, p2, p3 = pts[idx]
normal = np.cross(p2 - p1, p3 - p1)
norm = np.linalg.norm(normal)
if norm < 1e-6:
continue
normal = normal / norm
offset = -np.dot(normal, p1)
# 距離 = |n.p + d|
dist = np.abs(pts.dot(normal) + offset)
inliers = dist < distance_threshold
count = int(inliers.sum())
if count > best_count:
best_count = count
best_inliers = inliers
if count > 0.5 * n:
break
if best_inliers is None:
return points, np.array([0.0, 0.0, 1.0]), 0.0
# 用所有 inlier 做最小平方平面擬合以提升精度
inlier_pts = pts[best_inliers]
A = np.c_[inlier_pts, np.ones(inlier_pts.shape[0])]
_, _, vh = np.linalg.lstsq(A, np.zeros(inlier_pts.shape[0]), rcond=None)
normal = vh[3, :3]
normal = normal / np.linalg.norm(normal)
offset = vh[3, 3]
out = points[~best_inliers]
return out, normal, offset
def voxelize(points, voxel_size=0.1, max_points_per_voxel=20, feature_dim=4):
"""
體素化:把點雲轉成稀疏體素表示。
回傳:
voxels : (M, feature_dim) 每體素的聚合特徵
voxel_coords: (M, 3) int32 的體素座標
num_points : (M,) 每體素的點數
"""
coords = np.floor(points[:, :3] / voxel_size).astype(np.int32)
keys = {}
for i, c in enumerate(coords):
k = (c[0], c[1], c[2])
if k not in keys:
keys[k] = []
keys[k].append(i)
M = len(keys)
voxel_coords = np.zeros((M, 3), dtype=np.int32)
voxels = np.zeros((M, feature_dim), dtype=np.float32)
num_points = np.zeros((M,), dtype=np.int32)
for m, (k, idxs) in enumerate(keys.items()):
voxel_coords[m] = k
if feature_dim == 4:
# 通道 0-2: 座標相對體素中心的偏移;通道 3: 強度
offsets = points[idxs, :3] / voxel_size - k
voxels[m, :3] = offsets.mean(axis=0)
voxels[m, 3] = points[idxs, 3].mean()
num_points[m] = min(len(idxs), max_points_per_voxel)
return voxels, voxel_coords, num_points
def points_to_bev(points, voxel_size=0.5, grid_range=(-51.2, 51.2, -51.2, 51.2)):
"""
把 3D 點雲投影到 BEV 佔位柵格(binary occupancy)。
points: (N, 4) [x, y, z, intensity]
回傳: (H, W) 的 float32 陣列
"""
x_min, x_max, y_min, y_max = grid_range
H = int((y_max - y_min) / voxel_size)
W = int((x_max - x_min) / voxel_size)
bev = np.zeros((H, W), dtype=np.float32)
xy = points[:, :2]
ix = ((xy[:, 0] - x_min) / voxel_size).astype(np.int32)
iy = ((xy[:, 1] - y_min) / voxel_size).astype(np.int32)
valid = (ix >= 0) & (ix < W) & (iy >= 0) & (iy < H)
bev[iy[valid], ix[valid]] = 1.0
return bev
4.4 相機與 LiDAR 的投影#
import numpy as np
def project_lidar_to_image(points, lidar2cam, cam2img, image_shape):
"""
把 LiDAR 點雲投影到相機影像平面。
lidar2cam : (4, 4) LiDAR -> 車體 -> 相機 的外參
cam2img : (3, 3) 相機內參(fx, fy, cx, cy 與 skew)
回傳: (N, 2) 影像座標與 (N,) 的深度遮罩
"""
pts_h = np.concatenate([points[:, :3], np.ones((points.shape[0], 1))], axis=1)
cam_pts = (lidar2cam @ pts_h.T).T
# 深度為正才在相機前方
valid = cam_pts[:, 2] > 1e-6
cam_pts = cam_pts[valid]
depth = cam_pts[:, 2].copy()
img_pts = (cam2img @ cam_pts[:, :3].T).T
uv = img_pts[:, :2] / img_pts[:, 2:3]
H, W = image_shape
valid &= (uv[:, 0] >= 0) & (uv[:, 0] < W) & (uv[:, 1] >= 0) & (uv[:, 1] < H)
return uv, depth, valid
def points_to_voxel_feature(points, voxel_size=0.1):
"""
為每個 LiDAR 點附加一個「體素特徵」:該點所在體素的鄰域點數。
這是 PointPillars 風格的做法:先用 pillar(垂直的體素柱)
聚合點特徵,再交給 2D 卷積。
"""
coords = np.floor(points[:, :3] / voxel_size).astype(np.int64)
from collections import defaultdict
counter = defaultdict(int)
for c in coords:
counter[tuple(c)] += 1
feat = np.zeros((points.shape[0], 1), dtype=np.float32)
for i, c in enumerate(coords):
feat[i, 0] = counter[tuple(c)]
return feat
4.5 簡化的 SORT 追蹤器#
import numpy as np
class KalmanBoxTracker:
"""簡化的等速運動卡爾曼濾波器追蹤器"""
def __init__(self, bbox, frame_id):
self.bbox = np.array(bbox, dtype=float) # [x, y, w, h]
self.frame_id = frame_id
self.hits = 1
self.age = 0
self.time_since_update = 0
def predict(self):
"""以恆速度模型預測下一個 bbox"""
self.age += 1
if self.time_since_update > 0:
# 簡化:以固定假設速度外推
self.bbox[:2] += self.velocity if hasattr(self, "velocity") else 0
self.time_since_update += 1
return self.bbox
def update(self, bbox):
self.bbox = np.array(bbox, dtype=float)
self.hits += 1
self.time_since_update = 0
def iou_matrix(tracks, detections):
"""tracks / detections 皆為 (N,4) 的 [x, y, w, h]"""
if len(tracks) == 0 or len(detections) == 0:
return np.zeros((len(tracks), len(detections)))
t = np.array([[t[0], t[1], t[0] + t[2], t[1] + t[3]] for t in tracks])
d = np.array([[d[0], d[1], d[0] + d[2], d[1] + d[3]] for d in detections])
lt = np.maximum(t[:, None, :2], d[None, :, :2])
rb = np.minimum(t[:, None, 2:], d[None, :, 2:])
wh = np.clip(rb - lt, 0, None)
inter = wh[..., 0] * wh[..., 1]
area_t = (t[:, 2] - t[:, 0]) * (t[:, 3] - t[:, 1])
area_d = (d[:, 2] - d[:, 0]) * (d[:, 3] - d[:, 1])
union = area_t[:, None] + area_d[None, :] - inter
return inter / np.maximum(union, 1e-9)
def linear_assignment(cost_matrix, threshold=0.8):
"""
以 IoU 為基礎的匈牙利匹配。
回傳 (row_idx, col_idx, matched_iou)
"""
from scipy.optimize import linear_sum_assignment
if cost_matrix.size == 0:
return np.array([], dtype=int), np.array([], dtype=int), np.array([])
iou = cost_matrix
cost = 1.0 - iou
row, col = linear_sum_assignment(cost)
keep = iou[row, col] >= threshold
return row[keep], col[keep], iou[row[keep], col[keep]]
4.6 相機特徵提升到 BEV(LSS 風格)#
import torch
import torch.nn as nn
import torch.nn.functional as F
class LiftSplatShoot(nn.Module):
"""
簡化版 LSS:從相機特徵圖建構 BEV 特徵圖。
步驟:
1. 為每個像素預測一個深度分布(通道數 = D)
2. 外積得到每個 3D 點的特徵 (C, D)
3. 用 voxel pooling 累積到 BEV 網格
"""
def __init__(self, num_classes, cam_channels, grid_min=(-51.2, -51.2, -5.0),
grid_max=(51.2, 51.2, 3.0), bev_size=200, voxel_size=0.4):
super().__init__()
self.num_classes = num_classes
self.cam_channels = cam_channels
self.grid_min = grid_min
self.grid_max = grid_max
self.bev_size = bev_size
self.voxel_size = voxel_size
self.depth_net = nn.Conv2d(cam_channels, num_classes + 1, 1)
self.ctx_net = nn.Conv2d(cam_channels, cam_channels, 1)
def forward(self, img_feats, calib):
"""
img_feats: (B, N_cam, C, H, W)
calib: dict,包含每個相機的 intrinsic 與 extrinsic
回傳: (B, bev_size, bev_size, C)
"""
B, N, C, H, W = img_feats.shape
feats = img_feats.reshape(B * N, C, H, W)
depth_dist = self.depth_net(feats) # (B*N, D+1, H, W)
ctx = self.ctx_net(feats) # (B*N, C, H, W)
D = depth_dist.shape[1] - 1
depth_prob = depth_dist[:, :D].softmax(dim=1) # (B*N, D, H, W)
depth_cat = depth_dist[:, D:].softmax(dim=1) # (B*N, 1, H, W)
# 每個深度平面的特徵 = 外積
feat = torch.einsum("bndhw,bchw->bndchw",
depth_prob, ctx * depth_cat) # (B*N, D, C, H, W)
# 這裡省略實際的相機投影與 voxel pooling,
# 改以 reshape 直接產生 BEV 尺寸的張量作為示範
bev = feat.mean(dim=1) # (B*N, C, H, W)
bev = bev.reshape(B, N, C, H, W).mean(dim=1) # 跨相機平均
bev = F.interpolate(bev, size=(self.bev_size, self.bev_size),
mode="bilinear", align_corners=False)
return bev.permute(0, 2, 3, 1)
五、本節小結#
醫學影像分割:
- 醫療是分割的第一個大規模落地場景,原因是標註成本高(密集輸出性價比高)、任務本質是分割、容錯率低迫使方法嚴謹、形狀多變使形狀先驗重要。
- nnU-Net(2021) 證明樸素的 U-Net 加上頂級的預處理、增強、損失、後處理與驗證 protocol,可以在 23 個資料集上全部第一。這是一個關於「架構 vs 工程」的決定性實證。
- UNet++ 用密集巢狀 skip 緩解語義落差;Swin UNETR 引入 Transformer 的全局感受野(特別適合需要長距離上下文的三維解剖);nnFormer 用局部注意力取代卷積,在兩者之間取得平衡。
- 損失函數:BCE 不足(不平衡 + 目標不一致),Dice 優化區域重疊,IoU loss 與 Dice 等價但梯度尺度不同,Tversky Loss 把 FP/FN 的不對稱代價寫進目標函數。
- 指標:Dice 與 HD95 必須並列——前者測區域重疊,後者測邊界精度,而外科導航真正在乎的是後者。
- 3D 連續性是 2D slice-wise 方法的隱藏大問題,影響體積測量、3D 可視化與放療計畫。
自動駕駛感知:
- 相機與 LiDAR 的失效模式互補:相機有顏色紋理但無深度,LiDAR 有精確深度但無顏色。融合的根本理由在此。
- 點雲處理:RANSAC 地面移除、體素化、稀疏卷積(尤其是 submanifold sparse convolution 讓活躍位置不隨層數膨脹)。稀疏性提醒我們:真實資料的資訊密度遠低於張量形狀所暗示的。
- 偵測的演進:PointNet(對稱性處理)→ PointNet++(階層)→ VoxelNet(體素)→ SECOND(稀疏卷積)→ CenterPoint(anchor-free + BEV)。
- BEV 表示是架構樞紐:幾何對齊、多相機融合簡化、與規劃器共座標系。LSS 讓純相機也能建 BEV,BEVFormer 用 Transformer query 做可學習的融合。
- 融合層次(早/中/晚)的取捨是「資訊量 vs 錯誤傳播」;校準(外參、時間戳、位姿漂移)是無法用深度學習補償的工程難題。
- MOT:SORT(卡爾曼 + 匈牙利)→ DeepSORT(外觀特徵)→ ByteTrack(低分框續軌跡)。
最重要的科學結論:
- nuScenes 端到端挑戰(2023) 顯示純模仿學習的端到端模型碰撞率約 16.7 CAV,遠不及傳統模組化 pipeline 的 0.30 CAV。這與 nnU-Net 的結果形成對稱,共同證明了同一條方法論:在資料受限且失敗代價不對稱的領域,規律化、可解釋的模組化流程仍優於大而全的端到端方案。架構複雜度不是免費的。
銜接下一章:
分割模型的下一步需求已經明確:不只要像素標籤,還要「實例 + 深度 + 語意 + 時間」的統一表示。這正是 BEV Transformer 所追求的,也正是第 6 章的 Transformer 與自注意力機制所能提供的。第 9 章的具身智慧(VLA 模型)同樣需要 3D 語意場景理解作為行動的基礎。從 5.3 節的「模組化拼裝」走向第 6 章的「端到端統一」,是 2017 到 2023 年視覺領域最深刻的轉向。