Pynote

Python、機械学習、画像処理について

OpenCV - ハフ変換 (Hough Transform) で直線を検出する方法

HoughLines

lines = cv2.HoughLines(image, rho, theta, threshold[, lines[, srn[, stn[, min_theta[, max_theta]]]]])
  • 引数
    • image: 1チャンネルの2値画像。
    • rho: 投票の rho の解像度 (ピクセル)。
    • theta: 投票の theta の解像度 (ラジアン)。
    • lines: 検出された直線 (引数経由で受け取る場合は指定する。)
    • threshold: 直線と判断する投票数。
    • min_theta: パラメータ \theta の下限を [0, max_theta] の範囲で指定する。
    • min_theta: パラメータ \theta の上限を [min_theta, \pi] の範囲で指定する。
  • 返り値
    • lines: 検出された直線のパラメータ一覧。各要素は (\theta, \rho) のタプルである。

サンプルコード

入力画像

2値化する。

入力は2値画像である必要があるため、Canny 法でエッジ検出を行う。

pynote.hatenablog.com

import cv2

img = cv2.imread("sample.jpg")

# グレースケールに変換する。
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Canny 法で2値化する。
edges = cv2.Canny(gray, 150, 300, L2gradient=True)
imshow(edges)


ハフ変換で直線検出する。
# ハフ変換で直線検出する。
lines = cv2.HoughLines(edges, 1, np.pi / 180, 100)
lines = lines.squeeze(axis=1)
print(lines)
# [[215.          1.1170107]
#  [212.          1.1170107]
#  [213.          1.134464 ]
#  [ 93.          1.6057029]
#  [112.          1.553343 ]
#  [ -1.          1.9547688]
#  [ -6.          1.9722221]
#  [  1.          1.9547688]
#  [ -8.          1.9722221]
#  [ 97.          1.5882496]]
描画する。

\theta, \rho で表される直線のうち、画像内の始点と終点の座標を求める。
\sin \theta = 0 の場合、それは (\rho, 0), (\rho, height) という垂直方向の線分になる。
それ以外の場合、y = \frac{\rho}{\sin \theta} - x \frac{\cos \theta}{\sin \theta} で y 座標を計算できる。

def draw_line(img, theta, rho):
    h, w = img.shape[:2]
    if np.isclose(np.sin(theta), 0):
        x1, y1 = rho, 0
        x2, y2 = rho, h
    else:
        calc_y = lambda x: rho / np.sin(theta) - x * np.cos(theta) / np.sin(theta)
        x1, y1 = 0, int(calc_y(0))
        x2, y2 = w, int(calc_y(w))

    cv2.line(img, (x1, y1), (x2, y2), (0, 0, 255), 2)


# 直線を描画する。
for rho, theta in lines:
    draw_line(img, theta, rho)

cv2.imwrite("houghlines.jpg", img)


ipywidget

import cv2
from IPython.display import Image, display
from ipywidgets import widgets


def imshow(img):
    """画像を Notebook 上に表示する。
    """
    ret, encoded = cv2.imencode(".png", img)
    display(Image(encoded))


def houghline(img, rho, theta, threshold):
    """ハフ変換で直線検出を行い、結果を表示する。
    """
    # グレースケールに変換する。
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # Canny 法で2値化する。
    edges = cv2.Canny(gray, 150, 300, L2gradient=True)
    # ハフ変換で直線検出する。
    lines = cv2.HoughLines(edges, rho, np.radians(theta), threshold)
    
    # 検出した直線を描画する。
    copied = img.copy()
    
    if lines is not None:
        lines = lines.squeeze(axis=1)
        for rho, theta in lines:
            draw_line(copied, theta, rho)

    imshow(copied)


# パラメータ「rho」「theta」「threshold」「minLineLength」「maxLineGap」を設定するスライダー
rho_slider = widgets.IntSlider(min=1, max=10, step=1, value=1, description="rho: ")
rho_slider.layout.width = "400px"

# パラメータ「theta」を設定するスライダー
theta_slider = widgets.IntSlider(min=1, max=180, step=1, value=1, description="theta: ")
theta_slider.layout.width = "400px"

# パラメータ「threshold」を設定するスライダー
threshold_slider = widgets.IntSlider(
    min=0, max=500, step=1, value=100, description="threshold: "
)
threshold_slider.layout.width = "400px"

# 画像を読み込む。
img = cv2.imread("sample.jpg")

# ウィジェットを表示する。
widgets.interactive(
    houghline,
    img=widgets.fixed(img),
    rho=rho_slider,
    theta=theta_slider,
    threshold=threshold_slider,
)

HoughLinesP

lines	= cv2.HoughLinesP(image, rho, theta, threshold[, lines[, minLineLength[, maxLineGap]]])
  • 引数
    • image: 1チャンネルの2値画像。
    • rho: 投票の rho の解像度 (ピクセル)。
    • theta: 投票の theta の解像度 (ラジアン)。
    • lines: 検出された直線 (引数経由で受け取る場合は指定する。)
    • threshold: 直線と判断する投票数。
    • minLineLength: この長さ未満の線分は検出対象外とする。
    • maxLineGap: 同じ直線上の点と解釈するギャップの最大値。
  • 返り値
    • lines: 検出された線分の一覧。各要素は (x1, y2, x2, y2) のタプルである。

入力画像

import cv2

img = cv2.imread("sample.png")

# グレースケールに変換する。
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Canny で2値化する。
edges = cv2.Canny(gray, 150, 300, L2gradient=True)
imshow(edges)

# 確率的ハフ変換で直線検出する。
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 70, maxLineGap=100)
lines = lines.squeeze(axis=1)
print(lines)

# 直線を描画する。
for x1, y1, x2, y2 in lines:
    cv2.line(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
imshow(img)

cv2.imwrite("houghlines.jpg", img)


ipywidget

import cv2
from IPython.display import Image, display
from ipywidgets import widgets


def imshow(img):
    """画像を Notebook 上に表示する。
    """
    ret, encoded = cv2.imencode(".png", img)
    display(Image(encoded))


def houghline(img, rho, theta, threshold, min_line_len, max_line_gap):
    """ハフ変換で直線検出を行い、結果を表示する。
    """
    # グレースケールに変換する。
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # Canny 法で2値化する。
    edges = cv2.Canny(gray, 150, 300, L2gradient=True)
    # ハフ変換で直線検出する。
    lines = cv2.HoughLinesP(
        edges,
        rho,
        np.radians(theta),
        threshold,
        minLineLength=min_line_len,
        maxLineGap=max_line_gap,
    )

    # 検出した直線を描画する。
    copied = img.copy()

    if lines is not None:
        lines = lines.squeeze(axis=1)
        for x1, y1, x2, y2 in lines:
            cv2.line(copied, (x1, y1), (x2, y2), (0, 0, 255), 2)

    imshow(copied)


# パラメータ「rho」「theta」「threshold」「minLineLength」「maxLineGap」を設定するスライダー
rho_slider = widgets.IntSlider(min=1, max=10, step=1, value=1, description="rho: ")
rho_slider.layout.width = "400px"

# パラメータ「theta」を設定するスライダー
theta_slider = widgets.IntSlider(min=1, max=180, step=1, value=1, description="theta: ")
theta_slider.layout.width = "400px"

# パラメータ「threshold」を設定するスライダー
threshold_slider = widgets.IntSlider(
    min=0, max=500, step=1, value=100, description="threshold: "
)
threshold_slider.layout.width = "400px"

# パラメータ「threshold」を設定するスライダー
threshold_slider = widgets.IntSlider(
    min=0, max=500, step=1, value=100, description="threshold: "
)
threshold_slider.layout.width = "400px"

# パラメータ「minLineLength」を設定するスライダー
min_line_len_slider = widgets.IntSlider(
    min=0, max=500, step=1, value=0, description="minLineLength: "
)
min_line_len_slider.layout.width = "400px"

# パラメータ「maxLineGap」を設定するスライダー
max_line_gap_slider = widgets.IntSlider(
    min=0, max=500, step=1, value=0, description="maxLineGap: "
)
max_line_gap_slider.layout.width = "400px"

# 画像を読み込む。
img = cv2.imread("sample.png")

# ウィジェットを表示する。
widgets.interactive(
    houghline,
    img=widgets.fixed(img),
    rho=rho_slider,
    theta=theta_slider,
    threshold=threshold_slider,
    min_line_len=min_line_len_slider,
    max_line_gap=max_line_gap_slider,
)

ハフ変換の仕組み

任意の直線は、\rho, \theta で表せる。


直線上の任意の点を \vec{OP} = (x, y)^T とする。
原点から直線に垂線を下ろし、その点を \vec{OH} とすると、\vec{OH} = (\rho \cos \theta, \rho \sin \theta)^T と表せる。

\vec{HP}\vec{OH} は直交するので、

\displaystyle
\begin{aligned}
\vec{HP} \cdot \vec{OH} &= 0 \\
(\vec{OP} - \vec{OH}) \cdot \vec{OH} &= 0 \\
\vec{OP} \cdot \vec{OH} &= \vec{OH} \cdot \vec{OH} \\
x \rho \cos \theta + y \rho \sin \theta &= \rho^2 \\
x \cos \theta + y \sin \theta &= \rho
\end{aligned}

複数の点を通る直線のパラメータを求める。

2点 (x_1, y_1), (x_2, y_2) が同一直線上にあるとき、その直線が \theta, \rho で表されるとすると、次を満たす。

\displaystyle
\begin{aligned}
x_1 \cos \theta + y_1 \sin \theta &= \rho \\
x_2 \cos \theta + y_2 \sin \theta &= \rho
\end{aligned}

なので、\theta-\rho 空間の交点が2点を通る直線の \theta, \rho のパラメータであるとわかる。

import matplotlib.pyplot as plt
import numpy as np


points = np.array([[1, 1], [2, 3], [4, 2]])

fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(10, 5))

# ax1
ax1.set_xlim(0, 5)
ax1.set_ylim(0, 5)
ax1.set_xlabel(r"$x$")
ax1.set_ylabel(r"$y$")
ax1.grid()
ax1.scatter(points[:, 0], points[:, 1])

# ax2
ax2.set_xlim(0, np.pi)
ax2.set_ylim(-5, 5)
ax2.set_xlabel(r"$\theta$")
ax2.set_ylabel(r"$\rho$")
ax2.grid()

for x, y in points:
    theta = np.linspace(0, np.pi, 1000)
    rho = x * np.cos(theta) + y * np.sin(theta)

    ax2.plot(theta, rho, label=fr"${x} \cos \theta + {y} \sin \theta = \rho$")

ax2.legend()

import itertools

import matplotlib.pyplot as plt
import numpy as np
import sympy as sy


points = np.array([[1, 1], [2, 3], [4, 2]])


fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(10, 5))

# ax1
ax1.set_xlim(0, 5)
ax1.set_ylim(0, 5)
ax1.set_xlabel(r"$x$")
ax1.set_ylabel(r"$y$")
ax1.grid()
ax1.scatter(points[:, 0], points[:, 1])

# ax2
ax2.set_xlim(0, np.pi)
ax2.set_ylim(-5, 5)
ax2.set_xlabel(r"$\theta$")
ax2.set_ylabel(r"$\rho$")
ax2.grid()

for x, y in points:
    theta = np.linspace(0, np.pi, 1000)
    rho = x * np.cos(theta) + y * np.sin(theta)

    ax2.plot(theta, rho, label=fr"${x} \cos \theta + {y} \sin \theta = \rho$")


# 各点を通る直線を表す方程式を作成する。
theta, rho = sy.symbols("θ ρ")
lines = [x * sy.cos(theta) + y * sy.sin(theta) - rho for x, y in points]

# 交点を計算する。
intersections = []
for line1, line2 in itertools.combinations(lines, 2):
    # 方程式を解く。
    theta_n, rho_n = sy.nsolve([line1, line2], [theta, rho], (2, 2))
    theta_n, rho_n = float(theta_n), float(rho_n)

    # 交点を theta-rho 空間に描画する。
    ax2.text(theta_n, rho_n, f"({theta_n:.2f}, {rho_n:.2f})")
    ax2.scatter(theta_n, rho_n, c="r")

    # 交点に対応する直線を xy 空間に描画する。
    x = np.linspace(0, 5, 100)
    y = rho_n / np.sin(theta_n) - x * np.cos(theta_n) / np.sin(theta_n)
    ax1.plot(x, y, label=fr"$\theta = {theta_n:.2f}, \rho = {rho_n:.2f}$")

ax1.legend()
ax2.legend()


ハフ変換での利用

\theta-\rho 空間にビンを作成し、画像上の各特徴点を通る直線を該当するビンに投票する。
値が大きいビンに該当する直線は複数の特徴点を通っているということがわかる。