ITや趣味など気軽に投稿しています。

【Python】OpenCVで画像のエッジを検出する

今回はエッジを検出する方法だ。

エッジとは何です?

エッジというと「縁、端」といった意味だが、画像解析においては物体の輪郭検出を指す。

物体のエッジを検出することで、画像中の物体をより鮮明に浮かび上がらせることができる。いくつか手法があるので、順に紹介していこう。

お願いします。

エッジ検出の原理

画像におけるエッジの検出は、すごく大雑把に言うと明るさが大きく変化する部分を抽出することなのだ。

なるほど?

例えば白の背景に黒の物体があったとする。物体の輪郭は、画像では白から黒に変わる境界といえる。

なるほど!

その輪郭をどのように特定するかについてだが、各ピクセルの明るさ(輝度)の変化なので、輝度を微分した値が大きくなる部分をエッジとして検出する。

なるほど?

理解が追い付いていないことが語彙力からわかるぞ。
なお、画像のピクセルは連続値にはならないので、隣り合う画素間の差をとることで、近似的な微分として扱っている。

1次微分フィルタ(cv2.filter2D())

エッジ検出の代表的な手法として、 1次微分フィルタがある。1次微分フィルタでは以下のようなカーネルを使用する。

\begin{eqnarray*} K_x= \left[ \begin{array}{ccc} 0 & 0 & 0 \\ -1 & 0 & 1 \\ 0 & 0 & 0 \\ \end{array} \right], K_y= \left[ \begin{array}{ccc} 0 & -1 & 0 \\ 0 & 0 & 0 \\ 0 & 1 & 0 \\ \end{array} \right] \end{eqnarray*}

カーネルの中央を注目画素とし、隣合う画素との差を見てエッジを検出するモデルだ。

Kxでは水平方向の微分値が得られ、Kyでは垂直方向の微分値が得られる。

X方向とY方向それぞれフィルタをかけるイメージですね。

うむ。それぞれ単独で使用したり、組み合わせて使用したりする。

Pythonで実装

OpenCVでは一次微分を実行する関数は用意されていない。
なので、カーネルを定義してフィルタリングを行う必要がある。

フィルタリングは画像をぼかすところで出てきたやつですね。

【参考】

【Python】OpenCVで画像をぼかす

import cv2
import matplotlib.pyplot as plt
import numpy as np
import japanize_matplotlib

# 画像の読み込み
image = cv2.imread("input/flowers.png", cv2.IMREAD_GRAYSCALE)

# 水平方向微分のカーネル
kernel_x = np.array([[0, -1, 0], [0, 0, 0], [0, 1, 0]])

# 垂直方向微分のカーネル
kernel_y = np.array([[0, 0, 0], [-1, 0, 1], [0, 0, 0]])

# フィルタ2Dを適用
first_derivative_x = cv2.filter2D(image, cv2.CV_64F, kernel_x)
first_derivative_y = cv2.filter2D(image, cv2.CV_64F, kernel_y)

# エッジの強度を計算
first_derivative = np.sqrt(first_derivative_x**2 + first_derivative_y**2)

# 画像を表示
plt.figure(figsize=(10, 6))                                                     
plt.subplot(1, 2, 1)                                                     
plt.imshow(image, cmap='gray')                                         
plt.title('Original')                                             
plt.axis("off")                                                        
plt.subplot(1, 2, 2)                                                      
plt.imshow(first_derivative, cmap='gray')                                       
plt.title('一次微分フィルタ')                                             
plt.axis("off")                                                         

plt.show()
1次微分フィルタ

おお、エッジが検出されてます!

Prewittフィルタ(cv2.filter2D())

1次微分フィルタは、例えば画像にノイズが乗っていた場合、そこも輪郭として検出してしまう可能性が高い。

なるほど。じゃあ、ノイズをスルーするにはどうすれば!?

その時に使うのがPrewittフィルタだ。Prewittフィルタは以下のようなカーネルを使う。

\begin{eqnarray*} K_x= \left[ \begin{array}{ccc} -1 & 0 & 1 \\ -1 & 0 & 1 \\ -1 & 0 & 1 \\ \end{array} \right], K_y= \left[ \begin{array}{ccc} -1 & -1 & -1 \\ 0 & 0 & 0 \\ 1 & 1 & 1 \\ \end{array} \right] \end{eqnarray*}

さっきより1とか-1の数が増えてますね。

そうだな。一次微分フィルタは注目画素と隣接するピクセルが対象だったのに対し、Prewittフィルタでは対象が斜めを含む周辺ピクセルに拡大されている。
そうすることで、平滑化処理を加える形となり、ノイズを弱めることができる。

Pythonで実装

PrewittフィルタもOpenCVでは関数がないので、カーネルを定義して実装する。カーネルの配列以外は1次微分フィルタと同じだ。

ただ、今回は水平方向と垂直方向一方のみを適用したときの結果も載せている。

import cv2
import matplotlib.pyplot as plt
import numpy as np
import japanize_matplotlib

# 画像の読み込み
image = cv2.imread("input/flowers.png", cv2.IMREAD_GRAYSCALE)

# 水平方向微分のカーネル
kernel_x = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]])

# 垂直方向微分のカーネル
kernel_y = np.array([[-1, -1, -1], [0, 0, 0], [1, 1, 1]])

# フィルタ2Dを適用
prewitt_x = cv2.filter2D(image, -1, kernel_x)
prewitt_y = cv2.filter2D(image, -1, kernel_y)

# エッジの強度を計算
prewitt = np.sqrt(prewitt_x**2 + prewitt_y**2)

# 画像とエッジ画像を表示
plt.figure(figsize=(10, 6))
plt.subplot(2, 2, 1)
plt.imshow(image, cmap='gray')
plt.title('Original')
plt.axis("off")
plt.subplot(2, 2, 2)
plt.imshow(prewitt, cmap='gray')
plt.title('Prewitt Filter')
plt.axis("off") 
plt.subplot(2, 2, 3) 
plt.imshow(prewitt_x, cmap='gray')
plt.title('水平方向')
plt.axis("off")
plt.subplot(2, 2, 4)
plt.imshow(prewitt_y, cmap='gray')
plt.title('垂直方向') 
plt.axis("off")
plt.show()
Prewittフィルタ

確かに、縦方向と横方向それぞれの輪郭が抽出されていますね。

Sobelフィルタ(cv2.Sobel())

次はSobelフィルタだが、このフィルタも一次微分フィルタに分類される。このフィルタでは、中心に隣接するピクセルが強調される。

\begin{eqnarray*} K_x= \left[ \begin{array}{ccc} -1& 0 & 1 \\ -2& 0 & 2 \\ -1& 0 & 1 \\ \end{array} \right], K_y= \left[ \begin{array}{ccc} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \\ \end{array} \right] \end{eqnarray*}

一次微分フィルタと似てますね。

うむ。Sobelフィルタのメリットはコントラストの小さい、エッジがわかりづらい画像でもエッジを強調できるところにある。

一方、ノイズも検出しやすくなってしまう点がデメリットだな。

Pythonで実装

SobelフィルタにはOpenCVでcv2.Sobel()という関数が用意されている。

cv2.Sobel(src, ddepth, dx, dy[, dst[, ksize[, scale[, delta[, borderType]]]]])

引数

OpenCV共通の引数はこちら

引数説明
ddepth出力画像の深度(データ型)。通常は -1 を指定し、入力画像と同じ深度とする。
dx1を指定すると、X軸方向の微分を実施する。
微分しない場合は0を指定する。
dy1を指定すると、Y軸方向の微分を実施する。
微分しない場合は0を指定する。
ksizeカーネルのサイズ
scaleカーネル要素にかかる倍率
deltaSobelフィルタの計算結果に加算される定数値。正の値を指定すると、エッジが強調される。デフォルトは0。

コードと結果

#Sobel Filter
import cv2
import matplotlib.pyplot as plt

# 画像の読み込み(グレースケール)
image = cv2.imread("input/flowers.png", cv2.IMREAD_GRAYSCALE)

# Sobelフィルタを適用
#X軸方向のみ
sobelx = cv2.Sobel(src=image, ddepth=cv2.CV_32F, dx=1, dy=0, ksize=1) 
#Y軸方向のみ
sobely = cv2.Sobel(src=image, ddepth=cv2.CV_32F, dx=0, dy=1, ksize=1) 
#X軸とY軸の両方
sobelxy = cv2.Sobel(src=image, ddepth=cv2.CV_32F, dx=1, dy=1, ksize=1) 

# 画像とエッジ画像を表示
plt.figure(figsize=(10, 6))
plt.subplot(2, 2, 1)
plt.imshow(cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
plt.title('Original')
plt.axis("off")
plt.subplot(2, 2, 2)
plt.imshow(cv2.cvtColor(sobelx, cv2.COLOR_RGB2BGR))
plt.title('Sobel X')
plt.axis("off") 
plt.subplot(2, 2, 3) 
plt.imshow(cv2.cvtColor(sobely, cv2.COLOR_RGB2BGR))
plt.title('Sobel Y')
plt.axis("off")
plt.subplot(2, 2, 4)
plt.imshow(cv2.cvtColor(sobelxy, cv2.COLOR_RGB2BGR))
plt.title('Sobel XY') 
plt.axis("off")
plt.show()

では、Sobelフィルタの実装コードと結果を見てみよう。

cv.Sobel(src, ddepth, dx, dy[, dst[, ksize[, scale[, delta[, borderType]]]]])

Prewittフィルタとかよりエッジがはっきり出てきていますね。

Cannyフィルタ(cv2.Canny())

お次はCannyフィルタだな。

なんか次々と新しいものが、、

いっぱい種類があるから、仕方がない。

Cannyフィルタとは、要約するとガウシアンぼかしとSobelフィルタを組み合わせた手法だ。ノイズを除去しつつ、コントラストの弱いエッジを的確に検出できることが特徴だ。

ぼかしまで、、?

そうだ。ぼかし処理は、画像のノイズを除去する前処理だったな。

ぼかし処理をした画像にSobelフィルタをかけていくのだが、Sobelフィルタではいろいろな線が抽出され、明確な輪郭線にはなっていない。

確かに、デッサンみたいな抽出結果にはなっていましたが。

Cannyフィルタでは、さらに明確な輪郭線を出すために、「非極大値抑制処理」と「ヒステリシス閾値処理」というものを行っている。

詳細は割愛するが、「非極大値抑制処理」によって本来エッジではないピクセルが落とされ、1ピクセルの幅のシャープなエッジが抽出できる。

また、「ヒステリシス閾値処理」では「非極大値抑制処理」によって断片的になってしまったエッジを連結し、1本のエッジとして形成してくれる。

とりあえず、1本のきれいなエッジが抽出できる手法と理解しました。

【参考】処理の詳細

非極大値抑制処理(non-maximum suppression)」では、輪郭の線を細くしていきます。手順としては、まず注目画素の画素値と、その勾配方向に隣り合う2つの画素の画素値を比較します。注目画素の画素値が3つの画素で最大でない場合、画素値を0に置き換えます。

次に「ヒステリシス閾値処理(Hysteresis Thresholding)」を行います。これは、輪郭が本当に輪郭なのかを閾値で判断します。細線化処理した画像の画素値と予め定めた閾値を用いて判断します。

注目画素が最大閾値より高い場合は信頼性の高い輪郭と判断し、出力画像に輪郭を残します。

注目画素が最小閾値より低い場合は、信頼性の低い輪郭として出力画像から消去します。

注目画素が最小閾値と最大閾値の間にある場合は、隣の画素を参照します。隣が信頼性の高い画素の場合、信頼性の高い画素として出力画像に残します。一方、隣が信頼性の低い画素の場合は信頼性の低い画素として出力画像から消去します。

Pythonで実装

Pythonではcv2.Canny()という関数が用意されている。

cv2.Canny(image, threshold1, threshold2[, edges[, apertureSize[, L2gradient]]])

引数

OpenCV共通の引数はこちら

引数説明
threshold1ヒステリシス閾値の最小閾値
threshold2ヒステリシス閾値の最大閾値
edges出力されるエッジのマップ
apertureSizeSobel演算子に使用するカーネルサイズ。
L2gradient勾配の計算方法を指定する。Trueの場合、ユークリッド距離を使用して勾配を計算し、Falseの場合はL1ノルムを使用する。
(デフォルトはFalse)

コードと結果

#Canny Filter
import cv2
import matplotlib.pyplot as plt

# 画像の読み込み(グレースケール)
image = cv2.imread("input/flowers.png")

# Cannyフィルタ
img_canny = cv2.Canny(image=image, threshold1=100, threshold2=200)

# 画像とエッジ画像を表示
plt.figure(figsize=(10, 6))
plt.subplot(2, 2, 1)
plt.imshow(cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
plt.title('Original')
plt.axis("off")
plt.subplot(2, 2, 2)
plt.imshow(cv2.cvtColor(img_canny, cv2.COLOR_RGB2BGR))
plt.title('Canny')
plt.axis("off") 
plt.show()
cv2.Canny(image, threshold1, threshold2[, edges[, apertureSize[, L2gradient]]])

エッジが1本になって、きれいに輪郭が出ている気がします!

Laplacianフィルタ(cv2.Laplacian())

最後はLaplacianフィルタだ。このフィルタは二次微分を利用して輪郭を検出する。

これまでは一次微分だったので、そもそもが違うんですね。

Laplacianフィルタには4近傍と8近傍のカーネルが存在し、4近傍では、注目画素の上下左右の画素から二次微分を取る。

\begin{eqnarray*} K_4= \left[ \begin{array}{ccc} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \\ \end{array} \right] \end{eqnarray*}

8近傍では、上下左右に加え斜め方向の画素の二次微分もとるようになる。

\begin{eqnarray*} K_8= \left[ \begin{array}{ccc} 1 & 1 &  1 \\ 1 &  -8 &  1 \\ 1 &  1 &  1 \\ \end{array} \right] \end{eqnarray*}

なんか複雑そうですが、このフィルタのメリットってなんです?

直線的なエッジだけでなく、複雑な曲線も強調できる点がメリットだな。

一方で、二次微分の性質上、わずかなノイズや変化に反応してしまうから、強調したい部分以外のエッジが強調される場合があるのがデメリットだな。また、特定方向のみのエッジ検出には向いていない

Pythonで実装

OpenCVではcv2.Laplacian()が用意されている。

cv.Laplacian(src, ddepth[, dst[, ksize[, scale[, delta[, borderType]]]]])

引数

OpenCV共通の引数はこちら

引数説明
ddepth出力画像の深度(データ型)。通常は -1 を指定し、入力画像と同じ深度とする。
ksizeカーネルのサイズ
scaleカーネル要素にかかる倍率
delta処理結果に追加するオフセットを指定する。値を変更することで画像の輝度を調整できる。

コードと結果

#Laplacian Filter
import cv2
import matplotlib.pyplot as plt

# 画像の読み込み(グレースケール)
image = cv2.imread("input/flowers.png")

# Laplacianフィルタ
img_lap = cv2.Laplacian(image, cv2.CV_32F, ksize=1)

# 画像とエッジ画像を表示
plt.figure(figsize=(10, 6))
plt.subplot(2, 2, 1)
plt.imshow(cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
plt.title('Original')
plt.axis("off")
plt.subplot(2, 2, 2)
plt.imshow(cv2.cvtColor(img_lap, cv2.COLOR_RGB2BGR))
plt.title('Laplacian')
plt.axis("off") 
plt.show()

まとめ

色々とフィルタを紹介したが、Laplacianフィルタは二次微分を利用し、それ以外は一次微分を利用している。

あとは、処理速度等の性能を試して、適切なフィルタを選択する。ですね。

お、よくわかっているな。その通り。当然複雑な処理をしたほうが高精度だが、処理量が多くリアルタイム性は下がる。そのあたりを考慮してもらえるといい。

では今回はここまでにしよう。

ありがとうございました~~♪