學習筆記-canny邊緣檢測


Canny邊緣檢測


聲明:閱讀本文需要了解線性代數里面的點乘(圖像卷積的原理),高等數學里的二元函數的梯度,極大值定義,了解概率論里的二維高斯分布

1.canny邊緣檢測原理和簡介

2.實現步驟

3.總結

 

一、 Canny邊緣檢測算法的發展歷史

  Canny算子是28歲的John Canny在1986年提出的,該文章發表在PAMI頂級期刊(1986. A computational approach to edge detection. IEEE Transactions on Pattern Analysis and Machine Intelligence, vol. 8, 1986, pp. 679-698)。現在老大爺目前(61歲)在加州伯克利做machine learning,主頁(http://www.cs.berkeley.edu/~jfc/),大爺就是大爺。

  邊緣檢測是從圖像中提取有用的結構信息的一種技術,如果學過信息論就會知道,一面充滿花紋的牆要比一面白牆的信息量大很多,沒學過也沒關系,直觀上也能理解:充滿花紋的圖像要比單色圖像信息更豐富。為什么要檢測邊緣?因為我們需要計算機自動的提取圖像的底層(紋理等)或者高層(時間地點人物等)的信息,邊緣可以說是最直觀、最容易發現的一種信息了。Canny提出了一個對於邊緣檢測算法的評價標准,包括:

1)        以低的錯誤率檢測邊緣,也即意味着需要盡可能准確的捕獲圖像中盡可能多的邊緣。

2)        檢測到的邊緣應精確定位在真實邊緣的中心。

3)        圖像中給定的邊緣應只被標記一次,並且在可能的情況下,圖像的噪聲不應產生假的邊緣。

  簡單來說就是,檢測算法要做到:邊緣要全,位置要准,抵抗噪聲的能力要強。 

  接下來介紹最經典的canny邊緣檢測算法,很多邊緣檢測算法都是在此基礎上進行改進的,學習它有利於一通百通。

二、實現步驟

  step1:高斯平滑濾波

沒有哪張圖片是沒有噪聲的。————魯迅

  濾波是為了去除噪聲,選用高斯濾波也是因為在眾多噪聲濾波器中,高斯表現最好(表現怎么定義的?最好好到什么程度?),你也可以試試其他濾波器如均值濾波、中值濾波等等。一個大小為(2k+1)x(2k+1)的高斯濾波器核(核一般都是奇數尺寸的)的生成方程式由下式給出:

   

  下面是一個sigma = 1.4,尺寸為3x3的高斯卷積核的例子,注意矩陣求和值為1(歸一化):

 

  舉個例子:若圖像中一個3x3的窗口為A,要濾波的像素點為e,則經過高斯濾波之后,像素點e的亮度值為:

   其中*為卷積符號,sum表示矩陣中所有元素相加求和,簡單說,就是濾波后的每個像素值=其原像素中心值及其相鄰像素的加權求和。圖像卷積是圖像處理中非常重要且廣泛使用的操作,一定要理解好。

其中高斯卷積核的大小將影響Canny檢測器的性能。尺寸越大,去噪能力越強,因此噪聲越少,但圖片越模糊,canny檢測算法抗噪聲能力越強,但模糊的副作用也會導致定位精度不高,一般情況下,推薦尺寸5*5,3*3也行。

  step2: 計算梯度強度和方向

邊緣的最重要的特征是灰度值劇烈變化,如果把灰度值看成二元函數值,那么灰度值的變化可以用二元函數的”導數“(或者稱為梯度)來描述。由於圖像是離散數據,導數可以用差分值來表示,差分在實際工程中就是灰度差,說人話就是兩個像素的差值。一個像素點有8鄰域,那么分上下左右斜對角,因此Canny算法使用四個算子來檢測圖像中的水平、垂直和對角邊緣。算子是以圖像卷積的形式來計算梯度,比如Roberts,Prewitt,Sobel等,這里選用Sobel算子來計算二維圖像在x軸和y軸的差分值(這些數字的由來?),將下面兩個模板與原圖進行卷積,得出x和y軸的差分值圖,最后計算該點的梯度G和方向θ

  計算梯度的模和方向屬於高等數學部分的內容,如果不理解應該補習一下數學基本功,圖像處理經常會用到這個概念。

  這部分我實現了下,首先了解opencv的二維濾波函數:dst=cv.filter2D(src, ddepth, kernel[, dst[, anchor[, delta[, borderType]]]]) 

  dst:  輸出圖片

  src:  輸入圖片

  ddepth: 輸出圖片的深度, 詳見 combinations,如果填-1,那么就跟與輸入圖片的格式相同。

  kernel:   單通道、浮點精度的卷積核。

  以下是默認參數:

  anchor:內核的基准點(anchor),其默認值為(-1,-1)表示位於kernel的中心位置。基准點即kernel中與進行處理的像素點重合的點。舉個例子就是在上面的step1中,e=H*A得到的e是放在原像素的3*3的哪一個位置,一般來說都是放在中間位置,設置成默認值就好。

  delta :在儲存目標圖像前可選的添加到像素的值,默認值為0。(沒用過)

  borderType:像素向外逼近的方法,默認值是BORDER_DEFAULT,即對全部邊界進行計算。(沒用過)

  上代碼

 1 import cv2
 2 import numpy as np
 3 import matplotlib.pyplot as plt
 4 
 5 
 6 img=cv2.imread("images/luxun.png",cv2.IMREAD_GRAYSCALE)  # 讀入圖片
 7 sobel_x = np.array([[-1, 0, 1],[-2,0,+2],[-1, 0, 1]])    # sobel的x方向算子
 8 sobel_y = np.array([[1, 2, 1],[0,0,0],[-1, -2, -1]])     # sobel的x方向算子
 9 sobel_x=cv2.flip(sobel_x,-1)          # cv2.filter2D()計算的是相關,真正的卷積需要翻轉,在進行相關計算。
10 sobel_x=cv2.flip(sobel_y,-1)
11 # cv2.flip()第二個參數:等於0:沿着x軸反轉。大於0:沿着y軸反轉。小於零:沿着x軸,y軸同時反轉
12 
13 # 卷積 opencv是用濾波器函數實現的
14 img_x=cv2.filter2D(img,-1, sobel_x)
15 img_y=cv2.filter2D(img,-1, sobel_y)
16 # 畫圖 plt不支持中文,但是可以通過以下方法設置修復
17 plt.rcParams['font.sans-serif']=['SimHei']
18 plt.rcParams['axes.unicode_minus'] = False
19 
20 plt.subplot(221), plt.imshow(img_x, 'gray'),plt.title('sobel_x')
21 plt.subplot(222), plt.imshow(img_y, 'gray'),plt.title('sobel_y')
22 plt.subplot(223), plt.imshow(img_y+img_x, 'gray'),plt.title('sobel')
23 plt.subplot(224), plt.imshow(img, 'gray'),plt.title('原圖')
24 plt.show()
View Code

  運行效果:

 

    需要注意一點:在圖像處理領域,卷積運算的定義是先將核關於x軸和y軸反轉,然在做相關運算。然而工程實踐中往往跳過反轉,用相關運算代替卷積(比如opencv)。如果你需要嚴格的卷積運算,應該注意原函數的具體實現方式。sobel算子天生關於中心對稱,所以反轉與否並不影響結果(我在代碼里用cv2.flip()進行了反轉操作)。

    在之后的實現中,我發現用opencv自帶的濾波函數算出來的梯度是歸一化到(0-255)的,引入其他的庫也很麻煩,因此自己寫了個簡單的二位卷積函數來實現梯度計算。所以上面的圖適合看效果,並不適合在程序中使用,卷積函數的代碼如下:

 1 def conv2d(src,kernel):  # 輸入必須為方形卷積核
 2     # 本函數仍然是相關運算,沒有反轉。如果非要嚴格的卷積運算,把下面一行代碼的注釋取消。
 3     #kernel=cv2.flip(kernel,-1)
 4     [rows,cols] = kernel.shape
 5     border=rows//2                                  # 向下取整 獲得卷積核邊長
 6     [rows,cols]=src.shape
 7     dst = np.zeros(src.shape)                       # 采用零填充再卷積,卷積結果不會變小。
 8     # print("圖像長:",rows,"寬:",cols,"核邊界",border)
 9     # print(border,rows-border,border,cols-border)
10     temp=[]
11     for i in range(border,rows-border):
12         for j in range(border,cols-border):
13             temp=src[i-border:i+border+1,j-border:j+border+1]  # 從圖像獲取與核匹配的圖像
14             # 切片語法:索引位置包括開頭但不包括結尾 [start: end: step]
15             dst[i][j]=(kernel*temp).sum()  # 計算卷積
16     return dst
View Code

     小技巧:用plt顯示二維矩陣,鼠標移到某個像素就會顯示坐標(x,y)和灰度值,浮點數也可以顯示。這可以很方便的看某個數據(像素點)是否有問題。

    梯度和幅值的計算效果如下:

 

 

       能看出來sobel算子計算的邊緣很粗很亮,比較明顯,但是不夠精確,我們的目標是精確到一個像素寬,至於梯度相位就很難看出什么特征,並且梯度相位實際上是為了下一步打基礎的。下面附上代碼:

1 img_x=conv2d(img,sobel_x)  # 使用我自己的寫的卷積計算梯度
2 img_y=conv2d(img,sobel_y)
3 G=np.sqrt(img_x*img_x+img_y*img_y)  # 梯度幅值
4 theta=np.arctan(img_y,(img_x+0.0000000000001))*180/np.pi  # 化為角度,分母+極小值是為了避免除以0
5 # plt.imshow(theta, 'gray'),plt.title('梯度相位')
6 plt.imshow(G, 'gray'),plt.title('梯度幅值')
7 plt.show()
View Code

 

  step3:非極大值抑制

      sobel算子檢測出來的邊緣太粗了,我們需要抑制那些梯度不夠大的像素點,只保留最大的梯度,從而達到瘦邊的目的。這些梯度不夠大的像素點很可能是某一條邊緣的過渡點。按照高數上二位函數的極大值的定義,即對點(x0,y0)的某個鄰域內所有(x,y)都有f(x,y)≤(f(x0,y0),則稱f在(x0,y0)具有一個極大值,極大值為f(x0,y0)。簡單方案是判斷一個像素點的8鄰域與中心像素誰更大,但這很容易篩選出噪聲,因此我們需要用梯度和梯度方向來輔助確定。

      如下圖所示,中心像素C的梯度方向是藍色直線,那么只需比較中心點C與dTmp1和dTmp2的大小即可。由於這兩個點的像素不知道,假設像素變化是連續的,就可以用g1、g2和g3、g4進行線性插值估計。設g1的幅值M(g1),g2的幅值M(g2),則M(dtmp1)=w*M(g2)+(1-w)*M(g1)  ,其w=distance(dtmp1,g2)/distance(g1,g2)  。也就是利用g1和g2到dTmp1的距離作為權重,來估計dTmp1的值。w在程序中可以表示為tan(θ)來表示,具體又分為四種情況(下面右圖)討論。

      如下圖,經過非極大值抑制可以很明顯的看出去除了很多點,邊緣也變得很細。在程序實現中,要注意opencv的默認坐標系是從左到右為x軸,從上到下是y軸,原點位於左上方,計算g1、g2、g3、g4的位置的時候,一定要小心(坑了我很久)。經過非極大值抑制可以看出來圖片的邊緣明顯變細,很多看起來黑色的部分其實有值的,只是因為值太小了看不清楚,而這些黑色的部分可能是噪聲或者其他原因造成的局部極大值,下一步我們就要用雙閾值來限定出強邊緣和弱邊緣,盡可能的減少噪聲的檢出。代碼附上:

    

 1 # step3:非極大值抑制
 2 anchor=np.where(G!=0)  # 獲取非零梯度的位置
 3 Mask=np.zeros(img.shape)
 4 
 5 for i in range(len(anchor[0])):
 6     x=anchor[0][i]
 7     y=anchor[1][i]
 8     center_point=G[x,y]
 9     current_theta=theta[x,y]
10     dTmp1=0
11     dTmp2=0
12     W=0
13     if current_theta>=0 and current_theta<45:
14         #        g1         第一種情況
15         # g4  C  g2
16         # g3
17         g1 = G[x + 1, y - 1]
18         g2 = G[x + 1, y]
19         g3 = G[x - 1, y + 1]
20         g4 = G[x - 1, y]
21         W=abs(np.tan(current_theta*np.pi/180))  # tan0-45范圍為0-1
22         dTmp1= W*g1+(1-W)*g2
23         dTmp2= W*g3+(1-W)*g4
24 
25     elif current_theta>=45 and current_theta<90:
26         #     g2 g1         第二種情況
27         #     C
28         # g3  g4
29 
30         g1 = G[x + 1, y - 1]
31         g2 = G[x, y - 1]
32         g3 = G[x - 1, y + 1]
33         g4 = G[x, y + 1]
34         W = abs(np.tan((current_theta-90) * np.pi / 180))
35         dTmp1= W*g1+(1-W)*g2
36         dTmp2= W*g3+(1-W)*g4
37 
38     elif current_theta>=-90 and current_theta<-45:
39         # g1  g2            第三種情況
40         #     C
41         #     g4 g3
42         g1 = G[x - 1, y - 1]
43         g2 = G[x, y - 1]
44         g3 = G[x + 1, y + 1]
45         g4 = G[x, y + 1]
46         W = abs(np.tan((current_theta-90) * np.pi / 180))
47         dTmp1= W*g1+(1-W)*g2
48         dTmp2= W*g3+(1-W)*g4
49 
50     elif current_theta>=-45 and current_theta<0:
51         # g3                第四種情況
52         # g4  C  g2
53         #        g1
54         g1 = G[x + 1, y + 1]
55         g2 = G[x + 1, y]
56         g3 = G[x - 1, y - 1]
57         g4 = G[x - 1, y]
58         W = abs(np.tan(current_theta * np.pi / 180))
59         dTmp1= W*g1+(1-W)*g2
60         dTmp2= W*g3+(1-W)*g4
61 
62     if dTmp1<center_point and dTmp2<center_point:   # 記錄極大值結果
63             Mask[x,y]=center_point
64 #Mask=(Mask-Mask.min())/(Mask.max()-Mask.min())*256 #歸一化
65 plt.imshow(Mask,'gray'),plt.title('Mask')
66 plt.show()
View Code

  step4:用雙閾值算法檢測和連接邊緣

       雙閾值法非常簡單,我們假設兩類邊緣:經過非極大值抑制之后的邊緣點中,梯度值超過T1的稱為強邊緣,梯度值小於T1大於T2的稱為弱邊緣,梯度小於T2的不是邊緣。可以肯定的是,強邊緣必然是邊緣點,因此必須將T1設置的足夠高,以要求像素點的梯度值足夠大(變化足夠劇烈),而弱邊緣可能是邊緣,也可能是噪聲,如何判斷呢?當弱邊緣的周圍8鄰域有強邊緣點存在時,就將該弱邊緣點變成強邊緣點,以此來實現對強邊緣的補充。實際中人們發現T1:T2=2:1的比例效果比較好,其中T1可以人為指定,也可以設計算法來自適應的指定,比如定義梯度直方圖的前30%的分界線為T1,我實現的是人為指定閾值。檢查8鄰域的方法叫邊緣滯后跟蹤,連接邊緣的辦法還有區域生長法等等。強邊緣、弱邊緣、綜合效果、和opencv的canny函數對比如下:

三、總結

    實現結果還是很打擊的,我檢測到的邊緣過於斷續,沒有opencv實現的效果好。查了一下opencv的源碼,這里猜測兩個可能的原因:源碼里梯度的方向被近似到四個角度之一 (0,45,90,135),但我用線性插值的的結果是梯度方向更精確,而過於精確-->過於嚴格-->容易受到噪聲干擾,所以在非極大值抑制這之后,我比opencv少了更多的點,最終導致了邊緣不夠連續;第二個原因可能是邊緣連接算法效果不夠好,把圖象放大來看,我產生的邊緣傾向於對角線上連接,而opencv的邊緣傾向於折線連接,因此opencv的邊緣更完整連續,而我的邊緣更細,更容易斷續。

    限於時間,暫時研究到這里,希望各位多多指正!感謝所有我參考過的博客和文檔!

  1 import cv2
  2 import numpy as np
  3 import matplotlib.pyplot as plt
  4 
  5 # 畫圖 plt不支持中文,但是可以通過以下方法設置修復
  6 plt.rcParams['font.sans-serif']=['SimHei']
  7 plt.rcParams['axes.unicode_minus'] = False
  8 
  9 def conv2d(src,kernel):  # 輸入必須為方形卷積核
 10     # 本函數仍然是相關運算,沒有反轉。如果非要嚴格的卷積運算,把下面一行代碼的注釋取消。
 11     #kernel=cv2.flip(kernel,-1)
 12     [rows,cols] = kernel.shape
 13     border=rows//2                                  # 向下取整 獲得卷積核邊長
 14     [rows,cols]=src.shape
 15     dst = np.zeros(src.shape)                       # 采用零填充再卷積,卷積結果不會變小。
 16     # print("圖像長:",rows,"寬:",cols,"核邊界",border)
 17     # print(border,rows-border,border,cols-border)
 18     temp=[]
 19     for i in range(border,rows-border):
 20         for j in range(border,cols-border):
 21             temp=src[i-border:i+border+1,j-border:j+border+1]  # 從圖像獲取與核匹配的圖像
 22             # 切片語法:索引位置包括開頭但不包括結尾 [start: end: step]
 23             dst[i][j]=(kernel*temp).sum()  # 計算卷積
 24     return dst
 25 
 26 
 27 # step0:讀入圖片
 28 img=cv2.imread("images/luxun.png",cv2.IMREAD_GRAYSCALE)  # 讀入圖片
 29 
 30 # step1:高斯濾波
 31 img=cv2.GaussianBlur(img,(5,5),0)
 32 
 33 # step2:計算梯度強度和方向
 34 sobel_x = np.array([[-1, 0, 1],[-2,0,+2],[-1, 0, 1]])   # sobel的x方向算子
 35 sobel_y = np.array([[1, 2, 1],[0,0,0],[-1, -2, -1]])    # sobel的y方向算子
 36 
 37 # img_x=cv2.filter2D(img,-1, sobel_x)  # 這個濾波器會將卷積結果歸一化到0-255,無法計算梯度方向。
 38 # img_y=cv2.filter2D(img,-1, sobel_y)  # 而真正的圖像卷積可能會出現負數,因此只能自己寫個卷積。
 39 img_x=conv2d(img,sobel_x)  # 使用我自己的寫的卷積計算梯度
 40 img_y=conv2d(img,sobel_y)
 41 G=np.sqrt(img_x*img_x+img_y*img_y)  # 梯度幅值
 42 theta=np.arctan(img_y,(img_x+0.0000000000001))*180/np.pi  # 化為角度,分母+極小值是為了避免除以0
 43 
 44 # plt.imshow(theta, 'gray'),plt.title('梯度相位')
 45 # plt.imshow(G, 'gray'),plt.title('梯度幅值')
 46 # plt.show()
 47 # exit()
 48 
 49 # step3:非極大值抑制
 50 anchor=np.where(G!=0)  # 獲取非零梯度的位置
 51 Mask=np.zeros(img.shape)
 52 for i in range(len(anchor[0])):
 53     x=anchor[0][i]  # 取出第i個非零梯度的x坐標
 54     y=anchor[1][i]
 55     center_point=G[x,y]
 56     current_theta=theta[x,y]
 57     dTmp1=0
 58     dTmp2=0
 59     W=0
 60     if current_theta>=0 and current_theta<45:
 61         #        g1         第一種情況
 62         # g4  C  g2
 63         # g3
 64         g1 = G[x + 1, y - 1]
 65         g2 = G[x + 1, y]
 66         g3 = G[x - 1, y + 1]
 67         g4 = G[x - 1, y]
 68         W=abs(np.tan(current_theta*np.pi/180))  # tan0-45范圍為0-1
 69         dTmp1= W*g1+(1-W)*g2
 70         dTmp2= W*g3+(1-W)*g4
 71 
 72     elif current_theta>=45 and current_theta<90:
 73         #     g2 g1         第二種情況
 74         #     C
 75         # g3  g4
 76         g1 = G[x + 1, y - 1]
 77         g2 = G[x, y - 1]
 78         g3 = G[x - 1, y + 1]
 79         g4 = G[x, y + 1]
 80         W = abs(np.tan((current_theta-90) * np.pi / 180))
 81         dTmp1= W*g1+(1-W)*g2
 82         dTmp2= W*g3+(1-W)*g4
 83     elif current_theta>=-90 and current_theta<-45:
 84         # g1  g2            第三種情況
 85         #     C
 86         #     g4 g3
 87         g1 = G[x - 1, y - 1]
 88         g2 = G[x, y - 1]
 89         g3 = G[x + 1, y + 1]
 90         g4 = G[x, y + 1]
 91         W = abs(np.tan((current_theta-90) * np.pi / 180))
 92         dTmp1= W*g1+(1-W)*g2
 93         dTmp2= W*g3+(1-W)*g4
 94     elif current_theta>=-45 and current_theta<0:
 95         # g3                第四種情況
 96         # g4  C  g2
 97         #        g1
 98         g1 = G[x + 1, y + 1]
 99         g2 = G[x + 1, y]
100         g3 = G[x - 1, y - 1]
101         g4 = G[x - 1, y]
102         W = abs(np.tan(current_theta * np.pi / 180))
103         dTmp1= W*g1+(1-W)*g2
104         dTmp2= W*g3+(1-W)*g4
105 
106     if dTmp1<center_point and dTmp2<center_point:   # 記錄極大值結果
107             Mask[x,y]=center_point
108 # plt.imshow(Mask,'gray'),plt.title('Mask')
109 # plt.show()
110 # exit()
111 
112 # step4:雙閾值選取
113 high_threshold=100
114 low_threshold=high_threshold/2
115 strong_edge=np.zeros(G.shape)   # 強邊緣
116 weak_edge=np.zeros(G.shape)     # 弱邊緣
117 
118 xNum = [1, 1, 0, -1, -1, -1, 0, 1]  # 8鄰域偏移坐標
119 yNum = [0, 1, 1, 1, 0, -1, -1, -1]
120 [rows, cols] = G.shape
121 for i in range(rows):
122     for j in range(cols):
123         current_point=Mask[i,j]    
124         if current_point>0:
125             if current_point>high_threshold:   # 強邊緣提取
126                 strong_edge[i,j]=255
127             elif current_point<high_threshold and current_point>low_threshold:  # 弱邊緣提取
128                 # step6:順便進行邊緣連接
129                 change = True
130                 while change:
131                     change = False
132                     for k in range(8):
133                         xx=i+xNum[k]
134                         yy=j+yNum[k]
135                         if Mask[xx,yy]>high_threshold:
136                             weak_edge[i, j] = 255
137                             break  # 跳出八鄰域循環
138 output=strong_edge+weak_edge    # 強弱邊緣綜合效果
139 
140 img_edge = cv2.Canny(img, 50, 100)  # opencv實現效果
141 
142 # 顯示效果
143 plt.subplot(141), plt.imshow(strong_edge, 'gray'),plt.title('strong_edge')
144 plt.subplot(142), plt.imshow(weak_edge, 'gray'),plt.title('weak_edge')
145 plt.subplot(143), plt.imshow(output, 'gray'),plt.title('result')
146 plt.subplot(144), plt.imshow(img_edge, 'gray'),plt.title('opencv')
147 plt.show()
完整代碼

 

   參考文獻:

     https://www.cnblogs.com/love6tao/p/5152020.html

     https://www.cnblogs.com/techyan1990/p/7291771.html

       https://www.cnblogs.com/centor/p/5937788.html

       https://blog.csdn.net/piaoxuezhong/article/details/62217443

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM