5.26. 尋找直線與線段¶
有些場景特徵並非相連的色彩區域,而是有方向性的直線邊緣:地板上的一條彩繪線、兩個表面之間的接縫、印刷矩形的一邊、門口的邊緣。要求色塊偵測器去尋找它們是問錯了問題——邊緣只有一個像素寬,色塊演算法想要的是帶有色彩的面積,因此得到的答案會是空的或充滿雜訊。
適用於有方向性邊緣的偵測器是 Hough 直線變換。image 模組以兩種形式提供它:find_lines() 傳回無限直線(每條線都延伸貫穿整張影像);find_line_segments() 傳回有限線段(每條線的端點都在影格之內)。應用程式需要哪一種,取決於感興趣的邊緣是貫穿整個影格延續,還是只跨越其中一部分。
5.26.1. Hough 變換的運作方式¶
兩種偵測器共享相同的核心概念,因此值得一次徹底理解它。image 模組首先在輸入上執行 Sobel 式邊緣濾波器,為每個像素評分,衡量它落在有方向性邊緣上的可能性有多高。接著每個這樣的邊緣像素會投票給它可能落在其上的所有直線。累積到最多票數的直線勝出。
在 Hough 空間中,一條直線由兩個數值參數化:theta,即直線的角度(0 到 179 度),以及 rho,即從影像原點到直線的垂直距離(帶正負號,以像素為單位)。影像所含的每一條直線在 (theta, rho) 空間中都是一個點。輸入中的每個邊緣像素,會為與其位置相符的每一個 (theta, rho) 組合貢獻一票——在概念上,就是一條穿過 Hough 空間的曲線。許多這樣的曲線交會之處,代表許多邊緣像素對同一條直線達成共識,而該交會點便是一次偵測。
偵測器會傳回 Hough 空間中票數總和超過閾值的局部極大值。每個傳回的 Line 都帶有兩種表示法:端點形式的 x1, y1, x2, y2(對無限直線的情形會裁切至影像邊界內)、Hough 形式的 theta, rho,以及分別表示尺寸與票數的 length 和 magnitude。
5.26.2. 無限直線¶
find_lines() 執行 Hough 變換並傳回最強的直線,每一條都延伸貫穿整張影像:
lines = img.find_lines(threshold=1500, theta_margin=25, rho_margin=25)
for l in lines:
img.draw_line(l, color=(255, 0, 0))
threshold 是一條直線要被接受所需的最小票數總和。票數總和會把每個貢獻像素的 Sobel 邊緣量值加總起來,因此較大的 threshold 值要求更長或更強的邊緣才能通過——這使得適當的數值取決於影像解析度(在較高解析度下較長的直線會累積較多票數)以及場景本身,因此必須針對特定應用進行調校。作為調校用的粗略起始點:清晰影像中一條普通直線可用 1000,對比微弱或短小的直線用 500 或更低,而邊緣雜訊叢集會形成誤判直線的繁雜場景則用 2000 或更高。
theta_margin 與 rho_margin 控制鄰近極大值的合併。單一實體邊緣會在其真實的 (theta, rho) 周圍產生一小群高票數的格子,偵測器在傳回前會將每個叢集收斂至其峰值。theta_margin=25(度)會合併方向相差 25 度以內的任何峰值;rho_margin=25(像素)會合併距離相差 25 像素以內的峰值。預設值是合理的;調高它們會傳回較少、更明確的直線,而調低它們則傳回較多、有時重複的直線。
x_stride 與 y_stride 在投票期間逐步掃過邊緣像素,方式與它們在 find_blobs() 中逐步掃過像素的方式相同。預設值 2 與 1 適用於常見情形;調高它們會加快搜尋速度,但代價是解析度降低。roi 將搜尋限制在影格的某個區域,這既縮減了傳回的直線數量,也減少了運算量。
每條傳回的直線都可直接繪製:Line 物件可直接傳入 draw_line(),它會從物件前段讀取 (x1, y1, x2, y2) 端點欄位。l.theta 是以度為單位的角度,只需一次比較即可將直線分類為水平、垂直或對角線。l.magnitude 是票數總和,可將傳回的直線由強至弱排序。
5.26.3. 線段¶
對於貫穿整個影格的邊緣,find_lines() 是適當的偵測器,但許多真實的邊緣——印刷條碼的左側、標籤的上緣、尺的可見側邊——只跨越影像的一部分。find_line_segments() 傳回端點位於影格之內的有限線段:
segments = img.find_line_segments(merge_distance=5, max_theta_difference=10)
for s in segments:
img.draw_line(s, color=(0, 255, 0))
線段偵測器是直接沿著有方向性的邊緣像素進行追蹤,而非在 Hough 空間中投票,其結果是一組短小的直線段。merge_distance 設定兩段共線的短直線段之間最大可跨越且仍能合併為一條傳回線段的像素間隙;max_theta_difference 設定合併器在相鄰直線段之間所容忍的方向角度差有多少度。寬鬆的合併(merge_distance=10, max_theta_difference=15)會傳回少量的長線段,代價是有時會橋接起確實分開的邊緣;嚴格的合併(merge_distance=0, max_theta_difference=5)則傳回許多短線段,讓應用程式在 Python 中自行整理它們。
結果物件與 find_lines() 所傳回的是相同的 Line 型別,具有相同的屬性,因此流程可以透過相同的下游程式碼路徑處理任一種偵測結果。唯一實際的差異在於:線段的端點是直線在影像中的實際末端,而無限直線的端點則是直線穿越影像邊界之處。
5.26.4. 何時使用哪一種¶
在這兩種方法之間的抉擇歸結為一個問題:應用程式是否在意直線在何處終止?
當答案為否時,find_lines() 是適當的工具。一個循線機器人需要知道線的走向以及它在何處穿越影格底部;線本身則延伸到地平線以外。地平線偵測器想要的是影像中最強的有方向性邊緣;它不需要知道地平線在何處結束。
當答案為是時,find_line_segments() 是適當的工具。辨識印刷矩形的四個邊需要四條已知端點的線段。追蹤指向顯示器的手指意味著要追隨一條端點為手指尖端與根部的短線段。測量一道可見刮痕的長度則需要該線段在影像中的實際範圍(以像素計)。
兩種偵測器都有一個共同的限制:它們需要對比。它們所依據的 Sobel 邊緣濾波器會對亮度梯度起反應;面對亮度相當的背景的彩色邊緣(亮度相同的綠牆上的一條紅線)不會產生梯度,也就沒有偵測結果。當這種情形在實務中出現時,解法是在搜尋前先擷取單一 LAB 通道作為具有適當對比的灰階影像——使用 to_grayscale() 並選取 b 通道,可在亮度通道本身平坦之處把紅色與綠色區隔開來——然後把該通道影像交給直線偵測器。