Day 20 我們用切片訓練把那條被砍半的跑道救了回來,但也留下一個新爛攤子,一條跑道被拆成六個歪斜的碎框。今天要處理的就是這件事,本來以為只要把 SAHI 的後處理從 NMS 換成 NMM 就結案,結果查完原始碼才發現,事情沒那麼簡單。
昨天的推論腳本只寫了 get_sliced_prediction(img, model, slice_height=..., ...),後處理參數一個都沒給。我原本以為預設是 NMS,所以今天的計畫是「換成 NMM 就好」。實際去翻函式簽章:
postprocess_type: str = 'GREEDYNMM',
postprocess_match_metric: str = 'IOS',
postprocess_match_threshold: float = 0.5,
預設早就是 GREEDYNMM 了。換句話說,昨天那六個碎框已經是 NMM 跑完之後的結果。今天的第一個計畫在還沒開始寫扣之前就被判死刑。
那就乾脆把整個參數空間掃過一輪,看內建後處理到底有沒有救。三種 postprocess_type x 兩種 match_metric x 三個門檻,總共 18 組:
for ptype, metric, thr in itertools.product(
("NMS", "NMM", "GREEDYNMM"), ("IOU", "IOS"), (0.5, 0.2, 0.05)
):
result = get_sliced_prediction(
img, model, slice_height=320, slice_width=320,
overlap_height_ratio=0.2, overlap_width_ratio=0.2,
postprocess_type=ptype,
postprocess_match_metric=metric,
postprocess_match_threshold=thr,
verbose=0,
)
跑出來的結果讓人有點傻眼:
| postprocess | metric | 門檻 | 框數 |
|---|---|---|---|
| NMS / NMM / GREEDYNMM | IOU | 0.5 | 9 |
| NMS / NMM / GREEDYNMM | IOU | 0.2 | 6 |
| NMS / NMM / GREEDYNMM | IOU | 0.05 | 6 |
| NMS / NMM / GREEDYNMM | IOS | 0.5 | 6 |
| NMS / NMM / GREEDYNMM | IOS | 0.2 | 6 |
| NMS / NMM / GREEDYNMM | IOS | 0.05 | 5 |
三種演算法在同一組參數下吐出完全一樣的數量與信心度,連把門檻壓到 0.05 這種幾乎「碰到就合併」的設定都只從 6 變成 5。這代表問題根本不在演算法選擇。
因為 NMS、NMM、GREEDYNMM 三者的差別只在匹配到之後怎麼處理,NMS 是丟掉信心度低的那個,NMM 是把兩個框合併成一個,但它們判斷「要不要匹配」的方式是一樣的,都是算重疊率。
所以我把六個碎框兩兩之間的 IOU 跟 IOS 全部印出來:
inter = polys[i].intersection(polys[j]).area
union = polys[i].union(polys[j]).area
small = min(polys[i].area, polys[j].area)
print(inter / union, inter / small) # IOU, IOS
=== 碎片兩兩重疊率 (n=6) ===
0 - 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000
1 0.000/0.000 - 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000
2 0.000/0.000 0.000/0.000 - 0.000/0.000 0.000/0.000 0.000/0.000
3 0.000/0.000 0.000/0.000 0.000/0.000 - 0.000/0.000 0.000/0.000
4 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000 - 0.000/0.000
5 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000 0.000/0.000 -
一整片 0。這六個框彼此完全沒有交集,一個像素都沒有重疊。
到這裡答案就很清楚了:任何以重疊率為判準的後處理,在這組資料上本質上不可能work。分子是 0,門檻調到多低都沒用。這不是調參問題,是判準選錯了。
既然重疊率是 0,那就換一個量來看。我把兩兩之間的最短邊界距離算出來:
=== 碎片兩兩最短邊界距離 (px) ===
0 1 2 3 4 5
0 - 68.3 6.7 114.4 134.2 122.3
1 68.3 - 196.4 361.9 61.8 109.8
2 6.7 196.4 - 189.8 276.3 257.2
3 114.4 361.9 189.8 - 350.9 178.9
4 134.2 61.8 276.3 350.9 - 8.0
5 122.3 109.8 257.2 178.9 8.0 -
訊號出來了。0-2 只差 6.7 px、4-5 只差 8.0 px,這兩對根本就是貼在一起卻差一點點沒碰到,難怪 IOU 是 0。而 3 跟其他所有碎片的距離都在 114 px 以上,它是獨立的一塊。
重疊率為 0 但距離只有 6.7 px,這就是細長物件被切片切碎之後的典型長相,碎片是首尾相接而不是彼此重疊。
最直覺的做法是把「邊界距離 <= gap」的碎片用 union-find 連成連通分量:
for i in range(len(polys)):
for j in range(i + 1, len(polys)):
if polys[i].distance(polys[j]) <= gap:
parent[find(i)] = find(j)
把 gap 掃一輪看群數怎麼變:
gap=0 群數=6
gap=40 群數=4 [0,2] [1] [3] [4,5]
gap=70 群數=2 [0,1,2,4,5] [3]
gap=120 群數=1 [0,1,2,3,4,5]
gap=70 是個甜蜜點,五個碎片連成一群,那個離群的 3 自己一國。但這一版有個明顯的問題,我把合併後的凸包做 minAreaRect,得到的框長寬比只有 1.54,而且填充率只有 0.35,意思是這個框有 65% 的面積根本沒有任何碎片,它只是一個把五個點包起來的大胖框,不是跑道的形狀。
原因是碎片 1 跟 5 雖然距離夠近,但它們不在跑道那條線上,把它們拉進來會把框橫向撐開。
跑道是細長物件,真正屬於它的碎片一定貼在同一條軸線上。所以我在分群之後多加一步,用 PCA 對群內所有角點擬合主軸,再把垂直偏移離群的碎片踢掉重新擬合:
def _fit_axis(points):
mu = points.mean(axis=0)
_, _, vt = np.linalg.svd(points - mu, full_matrices=False)
axis = vt[0] # 最大變異方向 = 跑道走向
normal = np.array([-axis[1], axis[0]])
return mu, axis, normal
剔除的判準用中位數而不是平均值,因為離群點本身就會把平均值拉歪:
offsets = {i: abs((centroid_of(i) - mu) @ normal) for i in kept}
limit = max(OUTLIER_K * np.median(list(offsets.values())), OUTLIER_MIN)
outliers = [i for i in kept if offsets[i] > limit]
OUTLIER_MIN(絕對下限 60 px)是必要的保險,如果群內碎片剛好排得非常整齊,中位數會逼近 0,3 x 中位數會變成一個荒謬的小門檻,把正常的碎片也一起殺掉。
跑起來是這樣:
iter0 members=[0, 1, 2, 4, 5] ang=141.4
frag0 垂直偏移= 16.7
frag1 垂直偏移= 112.9
frag2 垂直偏移= 15.4
frag4 垂直偏移= 16.2
frag5 垂直偏移= 127.9
median=16.7 剔除=[1, 5]
iter1 members=[0, 2, 4] ang=145.6
frag0 垂直偏移= 18.5
frag2 垂直偏移= 24.2
frag4 垂直偏移= 5.7
median=18.5 剔除=[]
第一輪就把 1 跟 5 揪出來了,它們的垂直偏移是 112.9 跟 127.9,而留下來的三個只有 16 左右,差了將近一個數量級。剔除之後主軸從 141.4° 修正到 145.6°,長寬比從 1.54 拉到 2.52。
這一步還帶來一個意外的好處,它順便告訴你哪些框不在跑道上。碎片 1 跟 5 被踢出來,回頭去看昨天那張圖,它們一個壓在市區、一個壓在海岸線上,本來就是誤判。共線性檢查等於送了一個弱誤判過濾器。
最後沿著主軸取投影範圍,重建一個貼齊跑道方向的框:
pts = corner_points(members) - mu
t, w = pts @ axis, pts @ normal
corners = [mu + axis * a + normal * b
for a, b in ((t.min(), w.min()), (t.max(), w.min()),
(t.max(), w.max()), (t.min(), w.max()))]
這裡刻意不用 cv2.minAreaRect。minAreaRect 求的是「面積最小的外接矩形」,它會自己挑一個角度,而我們已經有一條物理意義明確的主軸(跑道走向),直接沿著它投影得到的框方向才是對的。
[before] SAHI 預設後處理輸出 6 個碎框
[after ] 幾何合併後 4 個框
members=[0, 2, 4] dropped=[1, 5] conf=0.559 角度=145.6 長=566.0 寬=224.9 長寬比=2.52
members=[1] dropped=[] conf=0.354 角度= 56.2 長= 67.5 寬= 59.0 長寬比=1.14
members=[3] dropped=[] conf=0.316 角度= 2.0 長=154.1 寬=100.0 長寬比=1.54
members=[5] dropped=[] conf=0.273 角度= 30.1 長=182.2 寬= 87.7 長寬比=2.08

左邊是昨天那六個紅色碎框,右邊綠色那條就是合併後的跑道,從右上一路延伸到左下,一個框、一條跑道,方向也正確地跟著柏油路的走向斜過去。橘色的三個是沒有被併進去的框(含兩個被共線性檢查踢掉的),它們確實都不在跑道上。
要注意被剔除的碎片不會憑空消失,我讓它們以原本的框回到結果裡:
merged.extend(single(i) for i in dropped)
後處理的職責是重組不是刪除,要不要當成誤判丟掉是下游的事,不該在幾何合併這一層偷偷吃掉。
綠色那個框雖然方向對了、範圍也對了,但寬度 224.9 px 明顯比實際跑道胖,目測跑道本身大概只有 60 px 寬。原因是碎片本身就是方塊狀而不是細長段,模型在 320 的切片裡看到的是「一塊柏油路」,minAreaRect 出來的長寬比只有 1.1 到 2.1,把三塊胖碎片沿著軸線投影,寬度自然就是碎片的高度。
換句話說,縫合的問題解決了,但精度的問題還在。這是切片訓練換來的代價,Day 17 那種緊貼跑道邊緣的漂亮 OBB 還是沒回來。
另外 GAP_PX = 70 這個門檻是看著這一張圖調出來的,換一張跑道更長、碎片間隔更大的圖就得重調。比較合理的做法應該是讓它跟切片尺寸連動(例如 slice_size * 0.2),而不是寫死一個絕對像素值。
今天原本只是想把 NMS 換成 NMM,結果查完預設值發現昨天跑的本來就是 NMM,然後掃完 18 組參數發現三種演算法給出一模一樣的答案,最後把重疊率矩陣印出來才看到那一整片 0。
問題的根源是,NMS 家族全都以重疊率為判準,而細長物件被切碎之後的碎片是首尾相接、不是彼此重疊。分子是 0,換演算法、調門檻都沒用。
換成距離分群 + PCA 共線性檢查之後,六個碎框變成一條跑道,而且共線性檢查還順手把兩個壓在市區跟海岸線上的誤判框挑了出來。
明天要處理的是今天留下的精度問題,方向是 Day 20 提過但還沒做的混合資料集,把整圖跟切片一起丟進去訓練,讓模型同時保有全局視野(框得準)與局部細節(看得見),看能不能把胖框瘦回去,那我們明天見。