HOWTO · OpenCV

OpenCV 邊緣檢測

本教程將討論使用 OpenCV 中的 Canny 或 Sobel 邊緣檢測器檢測影象中的邊緣。

本頁內容

邊緣檢測廣泛應用於影像處理中的背景移除、形狀偵測與影像結構分析。在影像中,邊緣是畫素強度突然變化的位置。

例如,若有一張黑色背景的貓咪照片,在貓的輪廓處,畫素值會出現從黑到白的突然變化。

OpenCV 提供兩種方法來檢測影像中的邊緣:Canny 邊緣檢測器會執行完整的多階段流程,並回傳細緻的二值邊緣圖;而 Sobel 邊緣檢測器 則是計算影像在指定方向上的導數,把後續的過濾工作留給你處理。本教學將討論如何在 OpenCV 中使用 Canny 或 Sobel 邊緣檢測器來檢測影像中的邊緣。

兩種檢測器尋找的都是同一件事:畫素值的導數,它在強度躍變處會變大。你可以用純 Python 對單列灰階值看到這個概念,完全不需要 OpenCV。

執行這個範例:

本節中的程式碼以及顯示的標籤或輸出仍為英文。

pixels = [20, 20, 20, 20, 200, 200, 200, 200]
response = [pixels[i + 1] - pixels[i - 1] for i in range(1, len(pixels) - 1)]
print("response:", response)
print("peak:", max(response))
response: [0, 0, 180, 180, 0, 0]
peak: 180

這兩個非零值標記出強度階躍的位置。邊緣檢測器會在整張影像的兩個方向上計算類似的回應,然後判斷哪些回應算是真正的邊緣。

OpenCV Canny 邊緣檢測

我們可以使用 OpenCV 的 Canny() 函式進行 Canny 邊緣檢測。此函式接受灰階影像,先用 GaussianBlur() 函式加以平滑可獲得更好的結果。雜訊所造成的畫素強度突變可能被檢測為額外的邊緣,而這些邊緣並不屬於我們真正想檢測的結構,因此去除影像中的雜訊很重要。

下面的範例會建立一張小型灰階測試影像,因此安裝 OpenCV 後(pip install opencv-python)即可獨立執行。此範例以原始碼形式呈現,而非實際執行的輸出;若要處理真實照片,請以 cv2.imread("cat.jpg") 讀取影像,並在平滑前先以 cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 轉換為灰階。結果會以 cv2.imwrite() 儲存;在桌機環境中,也可以改用 cv2.imshow() 搭配 cv2.waitKey(0) 在視窗中顯示。

本節中的程式碼以及顯示的標籤或輸出仍為英文。

import os
import tempfile

try:
    import cv2
    import numpy as np
except ModuleNotFoundError:
    print("this example needs the opencv-python package")
    raise SystemExit(0)

img_src = np.zeros((240, 320), dtype=np.uint8)
cv2.rectangle(img_src, (40, 40), (200, 200), 120, -1)
cv2.circle(img_src, (240, 120), 50, 220, -1)

blur_img = cv2.GaussianBlur(img_src, (3, 3), 0)
img_edges = cv2.Canny(image=blur_img, threshold1=50, threshold2=155)

out_path = os.path.join(tempfile.gettempdir(), "canny-edges.png")
cv2.imwrite(out_path, img_edges)
print("edge map:", img_edges.shape, img_edges.dtype)

在已安裝 OpenCV 的機器上,此腳本會將 canny-edges.png 寫入暫存目錄,並印出 edge map: (240, 320) uint8(此標籤是程式碼中的英文字串,維持英文不變)。Canny() 的輸出是一張單通道 8 位元影像,其中邊緣畫素為 255,其餘皆為 0。套用到真實照片時,結果如下所示:

原始小貓照片與 Canny 邊緣檢測產生的黑白邊緣圖並排顯示

原始照片與 Canny() 回傳的細白邊緣圖。

Canny 邊緣檢測器是多階段的,會經過數個步驟來找出影像中存在的邊緣。第一步,演算法使用高斯濾波器降低給定影像中的雜訊。

第二步,演算法求出影像在 x 與 y 方向上的一階導數,並由這些導數計算每個畫素處邊緣梯度的大小與方向。

第三步,演算法套用非極大值抑制:只保留沿梯度方向上的局部最大值畫素,把檢測到的邊緣細化成單畫素寬的線條。

第四步,演算法以使用者定義的兩個強度門檻過濾邊緣,此步驟稱為遲滯閾值處理。梯度高於上門檻的畫素保留為強邊緣,低於下門檻的畫素被捨棄,而介於兩個門檻之間的畫素只有在與強邊緣相連時才會保留下來。

在上面的程式碼中,Canny() 函式的第一個引數 image 是給定的影像,應為 8 位元。第二個引數 threshold1 與第三個引數 threshold2 分別設定該遲滯範圍的下限與上限。

Canny() 函式還有兩個選擇性引數。第一個選擇性引數 apertureSize 設定內部用來計算梯度的 Sobel 運算子的孔徑大小,預設值為 3。

第二個選擇性引數 L2gradient 設定梯度大小的計算方式。預設為 False,使用較快的 L1 範數;設為 True 則選用更精確的 L2 範數。

改變閾值範圍會改變輸出,因為此範圍負責過濾邊緣。同時提高兩個閾值會回傳較少但更可靠的邊緣,而降低閾值則會回傳較多邊緣,包括微弱或帶雜訊的邊緣。

OpenCV Sobel 邊緣檢測

如上所述,Canny 邊緣檢測器是多階段的:它會去除雜訊、找出邊緣,並以閾值範圍加以過濾。如果我們要的是原始導數——例如想在它之上建立自訂的過濾流程——可以使用 OpenCV 的 Sobel() 函式。Sobel() 函式將影像與導數卷積核做卷積,求出影像在 x 方向、y 方向或兩者的導數,但不會替我們去除雜訊或做閾值處理。

Sobel() 函式同樣利用畫素強度的突變來尋找邊緣。下面的範例分別計算 x 與 y 方向的導數,讓每個變數名稱與其內容相符;和 Canny 範例一樣,它需要安裝 opencv-python,並以原始碼而非實際執行輸出的形式呈現。由於 ddepth 設為 cv2.CV_64F,結果是帶正負號的:由亮轉暗的變化會產生與由暗轉亮符號相反的值。cv2.convertScaleAbs() 取絕對值並將結果轉回 8 位元,使導數影像得以儲存或顯示;cv2.addWeighted() 則把兩個方向合併為單一的邊緣回應。

本節中的程式碼以及顯示的標籤或輸出仍為英文。

import os
import tempfile

try:
    import cv2
    import numpy as np
except ModuleNotFoundError:
    print("this example needs the opencv-python package")
    raise SystemExit(0)

img_src = np.zeros((240, 320), dtype=np.uint8)
cv2.rectangle(img_src, (40, 40), (200, 200), 120, -1)
cv2.circle(img_src, (240, 120), 50, 220, -1)

blur_img = cv2.GaussianBlur(img_src, (3, 3), 0)
sobel_x = cv2.Sobel(src=blur_img, ddepth=cv2.CV_64F, dx=1, dy=0, ksize=3)
sobel_y = cv2.Sobel(src=blur_img, ddepth=cv2.CV_64F, dx=0, dy=1, ksize=3)
sobel_xy = cv2.addWeighted(cv2.convertScaleAbs(sobel_x), 0.5,
                          cv2.convertScaleAbs(sobel_y), 0.5, 0)

out_path = os.path.join(tempfile.gettempdir(), "sobel-edges.png")
cv2.imwrite(out_path, sobel_xy)
print("derivative:", sobel_x.dtype, sobel_x.shape)
print("combined:", sobel_xy.dtype, sobel_xy.shape)

在一般桌機上執行時,腳本會印出 derivative: float64 (240, 320) 與 combined: uint8 (240, 320)(這些標籤是程式碼中的英文字串,維持英文不變),並將 sobel-edges.png 寫入暫存目錄。其中 sobel_x 量測沿 x(水平)方向的強度變化,因此會凸顯垂直邊緣;sobel_y 量測沿 y 方向的變化,會凸顯水平邊緣;sobel_xy 則合併兩個方向。如果直接顯示或儲存原始的 CV_64F 導數,帶正負號的值會發生飽和,視窗中會充滿密集的雜訊斑點,而非乾淨的線條:

原始小貓照片與一張密集、飽和的 Sobel 邊緣檢測結果並排顯示

原始的帶號 Sobel 結果在直接顯示時會飽和,這就是它看起來比 Canny 邊緣圖雜亂許多的原因。

Sobel() 函式的第一個引數 src 是來源影像。第二個引數 ddepth 設定輸出影像的深度;請使用 cv2.CV_64F 或 cv2.CV_16S,讓帶號導數得以保留,而不是被截斷為 0。

第三個引數 dx 設定 x 導數的階數,第四個引數 dy 設定 y 導數的階數:dx=1, dy=0 取水平方向的導數,會凸顯垂直邊緣;dx=0, dy=1 取垂直方向的導數,會凸顯水平邊緣;dx=1, dy=1 則對斜向變化產生回應。

第五個引數 ksize 設定卷積核大小,其值應為 1、3、5 或 7(-1 會選用 Scharr 運算子)。第六個引數 scale 是導數值的選擇性縮放因子,預設不使用縮放。

第七個引數 delta 同樣是選擇性的,用來設定加到輸出上的常數值。第八個引數 borderType 也是選擇性的,用來設定畫素外推的方法,預設的邊界類型為 BORDER_DEFAULT。

關於邊界類型的更多細節,請參閱此連結。

當你需要方向性的梯度資料,或想自行做後續處理時,選用 Sobel();當你想要一張乾淨的二值邊緣圖,且雜訊抑制與閾值過濾都已經處理好時,選用 Canny()。