【画像処理】Numpyでハフ変換による直線検出

Numpyでハフ変換による直線検出を実装してみます。

直線を検出する画像を読み込みます。

```python
import numpy as np
import matplotlib.pyplot as plt

original_image = plt.imread(image_name)
if np.issubdtype(original_image.dtype, np.integer):
  original_image = original_image / np.iinfo(original_image.dtype).max
gray_image = 0.2116 * original_image[:,:,0] + 0.7152 * original_image[:,:,1] + 0.0722 * original_image[:,:,2]
plt.imshow(gray_image, cmap='gray')
```

![GrayImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/6b124fb5-311b-4180-091f-f538f150f18b.png)

エッジ画像を作成します。ここでは、Cannyのエッジ検出を用います。Cannyのエッジ検出については以下の記事を参照してください。

[【画像処理】NumpyでCannyエッジ検出 \- Qiita](https://qiita.com/aa_debdeb/items/035564db05fcb79a1b57)

```python
def _convolve2d(image, kernel):
  shape = (image.shape[0] - kernel.shape[0] + 1, image.shape[1] - kernel.shape[1] + 1) + kernel.shape
  strides = image.strides * 2
  strided_image = np.lib.stride_tricks.as_strided(image, shape, strides)
  return np.einsum('kl,ijkl->ij', kernel, strided_image)

def _convolve2d_multichannel(image, kernel):
  convolved_image = np.empty((image.shape[0] - kernel.shape[0] + 1, image.shape[1] - kernel.shape[1] + 1, image.shape[2]))
  for i in range(image.shape[2]):
    convolved_image[:,:,i] = _convolve2d(image[:,:,i], kernel)
  return convolved_image

def _pad_singlechannel_image(image, kernel_shape, boundary):
  return np.pad(image, ((int(kernel_shape[0] / 2),), (int(kernel_shape[1] / 2),)), boundary)

def _pad_multichannel_image(image, kernel_shape, boundary):
  return  np.pad(image, ((int(kernel_shape[0] / 2),), (int(kernel_shape[1] / 2),), (0,)), boundary)

def convolve2d(image, kernel, boundary='edge'):
  if image.ndim == 2:
    pad_image = _pad_singlechannel_image(image, kernel.shape, boundary) if boundary is not None else image
    return _convolve2d(pad_image, kernel)
  elif image.ndim == 3:
    pad_image = _pad_multichannel_image(image, kernel.shape, boundary) if boundary is not None else image
    return _convolve2d_multichannel(pad_image, kernel)

def create_gaussian_kernel(size=(3, 3), sigma=1):
  center = ((size[0] - 1) / 2, (size[1] - 1) / 2)
  sigma2 = 2 * sigma * sigma
  kernel = np.fromfunction(lambda y, x: np.exp(-((x - center[1]) ** 2 + (y - center[0]) ** 2) / sigma2), size)
  kernel = kernel / np.sum(kernel)
  return kernel 

def _suppress_nonmaximum(array):
  direction = array[9]
  if direction < 0:
    direction += np.pi
  if direction < 0.125 * np.pi:
    v0, v1, v2 = (array[3], array[4], array[5])
  elif direction < 0.375 * np.pi:
    v0, v1, v2 = (array[2], array[4], array[6])
  elif direction < 0.625 * np.pi:
    v0, v1, v2 = (array[1], array[4], array[7])
  elif direction < 0.875 * np.pi:
    v0, v1, v2 = (array[0], array[4], array[8])
  else:
    v0, v1, v2 = (array[3], array[4], array[5])
  return v1 if v1 >= v0 and v1 >= v2 else 0

def suppress_nonmaximum(intensity_image, direction_image):
  pad_image = np.pad(intensity_image, 1, 'edge')
  shape = intensity_image.shape + (3, 3)
  strides = pad_image.strides * 2
  strided_image = np.lib.stride_tricks.as_strided(pad_image, shape, strides).reshape(shape[0], shape[1], 9)
  return np.apply_along_axis(_suppress_nonmaximum, 2, np.dstack((strided_image, direction_image)))

def histeresis_thresholding(image, threshold):
  edge_image = np.where(image >= threshold[1], 1.0, 0.0)
  while True:
    pad_edge_image = np.pad(edge_image, 1, 'edge')
    shape = image.shape + (3, 3)
    strides = pad_edge_image.strides * 2
    neighbour_edge_image = np.lib.stride_tricks.as_strided(pad_edge_image, shape, strides)
    edge_candidate_index = np.where((image >= threshold[0]) & (edge_image != 1.0))

    changed = False
    for index in list(zip(*edge_candidate_index)):
      if np.any(neighbour_edge_image[index] == 1.0):
        edge_image[index] = 1.0
        changed = True
    if not changed:
      break;
  return edge_image

def canny(image, gaussian_size=(3, 3), gaussian_sigma=1, threshold=(0.02, 0.05)):
  gaussian_kernel = create_gaussian_kernel(size=gaussian_size, sigma=gaussian_sigma)
  gaussian_image = convolve2d(gray_image, gaussian_kernel)
  sobel_x_kernel = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])
  sobel_x_image = convolve2d(gaussian_image, sobel_x_kernel) / 8
  sobel_y_kernel = np.array(([[1, 2, 1], [0, 0, 0], [-1, -2, -1]]))
  sobel_y_image = convolve2d(gaussian_image, sobel_y_kernel) / 8
  sobel_intensity_image = np.sqrt(sobel_x_image ** 2 + sobel_y_image ** 2)
  sobel_direction_image = np.arctan(np.divide(sobel_y_image, sobel_x_image, out=np.zeros_like(sobel_y_image), where=sobel_x_image!=0))
  nonmaximum_suppression_image = suppress_nonmaximum(sobel_intensity_image, sobel_direction_image)
  return histeresis_thresholding(nonmaximum_suppression_image, threshold)

edge_image = canny(gray_image, gaussian_size=(3, 3), gaussian_sigma=0.2, threshold=(0.05, 0.2))
plt.imshow(edge_image, cmap='gray')
```

![EdgeImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/53d1c4e1-75a3-b37d-1020-1ce02c72a408.png)

直線を次のように$(r, \theta)$で表します。$r$は原点から直線までの最短距離、$\theta$は原点からの直線までの最短地点を結んだ直線と原点がなす角度です。

```math
r = x\cos\theta + y\sin\theta
```

上式よりエッジ画素$(x_e, y_e)$を通る直線は以下のようになります。

```math
r = x_e\cos\theta + y_e\sin\theta
```
$(r, \theta)$パラメータ空間を一定間隔で区切り、エッジ画素ごとに$\theta$を少しづつ変えながら$r$を求めます。求めた$(r, \theta)$の組み合わせをカウント(投票)していきます。

```python
edges = np.where(edge_image == 1.0)
r_max = np.sqrt(edge_image.shape[0] ** 2 + edge_image.shape[1] ** 2)
votes = np.zeros((int(r_max), 180), dtype=int)
for edge in list(zip(edges[1], edges[0])):
  for i in range(votes.shape[1]):
    ang = np.deg2rad(i)
    r = edge[0] * np.cos(ang) + edge[1] * np.sin(ang)
    votes[int(r)][i] += 1
    
plt.figure(figsize=(8,8))
plt.imshow(votes, aspect=0.5)
plt.colorbar()
```

![VoteImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/a412983f-9807-17ed-d220-4f4a6eadad35.png)


多数の投票が集まった$(r,\theta)$が検出された直線になります。検出された直線を画像上に描画してみます。直線の描画については以下の記事を参照してください。
[【画像処理】Numpyで図形描画 \- Qiita](https://qiita.com/aa_debdeb/items/8178fef706e8dc7e30d2)

```python
def is_local_maximum(image, size=(5, 5), boundary='edge'):
  pad_image = np.pad(image, ((int(size[0] / 2),), (int(size[1] / 2),)), boundary)
  areas = np.lib.stride_tricks.as_strided(pad_image, image.shape + size, pad_image.strides * 2)
  local_maximum = np.max(areas, axis=(2, 3))
  return image == local_maximum

def draw_line(image, color, weight, point1, point2):
  coord = np.fromfunction(lambda y, x: np.dstack((y + 0.5, x + 0.5)), image.shape[:2])
  p12 = np.array([point2[1] - point1[1], point2[0] - point1[0]])
  p12_norm = np.linalg.norm(p12)
  p12_dir = p12 / p12_norm
  p1c = np.dstack(([coord[:,:,0] - point1[1], coord[:,:,1] - point1[0]]))
  dot = np.einsum('ijk,k->ij', p1c, p12_dir)
  dist = np.sqrt(p1c[:,:,0] ** 2 + p1c[:,:,1] ** 2 - dot ** 2)
  condition = (dot >= 0) & (dot <= p12_norm) & (dist <= weight * 0.5)
  if image.ndim == 3:
    condition = np.tile(condition.reshape(condition.shape + (1,)), (1, 1, image.shape[2]))
  return np.where(condition, color, image)

def draw_hough_lines(image, lines, color, weight):
  for line in lines:
    s = np.sin(line[1])
    c = np.cos(line[1])
    if s > 0.01:
      p1 = (0.0, line[0] / s)
      p2 = (image.shape[1], (line[0] - image.shape[1] * c) / s)
      image = draw_line(image, color, weight, p1, p2)
    else:
      p1 = (line[0] / c, 0.0)
      p2 = ((line[0] - image.shape[0] * s) / c, image.shape[0])
      image = draw_line(image, color, weight, p1, p2)
  return image

hough_lines = np.where(is_local_maximum(votes, size=(5, 5)) & (votes >= 50))
hough_line_image = draw_hough_lines(original_image, list(zip(hough_lines[0], np.deg2rad(hough_lines[1]))), np.array([1.0, 0.0, 1.0]), 2.0)
plt.imshow(hough_line_image)
```

![HoughLineImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/cdb0c793-6035-2cab-2c20-a3b9d1f9d096.png)

これでハフ変換による直線の検出ができました。

---

ハフ変換による直線検出を1つの関数にまとめると次のようになります。

```python
def detect_hough_lines(edge_image, vote_size, threshold, local_maximum_size):
  votes = np.zeros(vote_size, dtype=int)
  r_step = np.sqrt(edge_image.shape[0] ** 2 + edge_image.shape[1] ** 2) / vote_size[0]
  theta_step = np.pi / vote_size[1]
  edges = np.where(edge_image)
  for edge in list(zip(edges[1], edges[0])):
    for i in range(vote_size[1]):
      angle = i * theta_step
      r = edge[0] * np.cos(angle) + edge[1] * np.sin(angle)
      votes[int(r / r_step)][i] += 1
  lines = np.where(is_local_maximum(votes) & (votes >= threshold))
  return list(zip(lines[0] * r_step, lines[1] * theta_step))

r_max = np.sqrt(edge_image.shape[0] ** 2 + edge_image.shape[1] ** 2)
hough_lines = detect_hough_lines(edge_image == 1.0, (int(r_max), 180), 50, (5, 5))
hough_line_image = draw_hough_lines(original_image, hough_lines, np.array([1.0, 0.0, 1.0]), 2.0)
plt.imshow(hough_line_image)
```

![HoughLineImageSummary.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/65933835-0391-6d64-891b-ca2298d0b463.png)

---

実装したコードはGoogle Colaboratoryに置いてあります。

https://colab.research.google.com/drive/1fmY-JP7Trlee3Ex8B52QVEXPidoMEsdj?usp=sharing