OpenCVのfindContoursの階層構造まとめ

#環境

作業はJupyter Notedbook上で実行しています。
`OpenCV`のバージョンは(4.5.3)です。

#`cv2.findContours`とは

OpenCVの関数で、同じ値をもつ連続した点をつなげて輪郭を検出することができます。以下が公式のガイドです。

http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_contours/py_contours_begin/py_contours_begin.html

必須となる引数は以下の3つとなります。

| 引数 | 内容 |
|:-:|:-:|
| `image` | 輪郭を検出したい画像。精度のためにエッジ検出した二値画像の入力が推奨されている。 |
| `mode` | 輪郭検出の階層構造を4つの中から指定できる。 |
| `method` | 輪郭の近似手法を4つの中から指定できる。 |

戻り値は検出した輪郭の座標と、階層構造の情報を保存した配列です。

`mode`と`method`は以下の通りになります。

| `mode` | 概要 |  | `method` | 概要 |
|:-:|:-:|:-:|:-:|:-:|
| `CV_RETR_LIST` | 検出した全ての輪郭に階層構造を与えない |   | `CV_CHAIN_APPROX_NONE` | 全ての輪郭点を格納 |
| `CV_RETR_EXTERNAL` | 最も外側の輪郭のみ抽出し、それらに階層構造を与えない |  | `CV_CHAIN_APPROX_SIMPLE` | 水平・垂直・斜めの成分を圧縮して端点として格納 |
| `CV_RETR_CCOMP` | 検出した全ての輪郭に2階層構造を与える |   | `CV_CHAIN_APPROX_TC89_L1` | Teh-Chinチェーン近似アルゴリズム(未確認) |
| `CV_RETR_TREE` | 検出した全ての輪郭に完全な階層構造を与える |   | `CV_CHAIN_APPROX_TC89_KCOS` | Teh-Chinチェーン近似アルゴリズム(未確認) |

今回は`method`はわかりやすくかつデータ量の少ない`CV_CHAIN_APPROX_SIMPLE`を使うことにし、`mode`について違いを確認していきたいと思います。

#`mode`の確認
##準備

以下のように必要なライブラリをインポートし、元の画像と`cv2.Canny`によってエッジ処理を行った画像を表示してみます。

```python
import numpy as np
import cv2
import matplotlib.pyplot as plt
%matplotlib inline

org_image = cv2.cvtColor(cv2.imread('image.jpg', 1), cv2.COLOR_BGR2GRAY)
org_image = cv2.resize(org_image, (256, 256))
edge = cv2.Canny(org_image, 50, 150)
#描画
fig, ax = plt.subplots(1, 2, figsize=(20, 20), sharex=True, sharey=True)
ax[0].imshow(org_image, cmap='gray')
ax[0].set_title('orginal')
ax[1].imshow(edge, cmap='gray')
ax[1].set_title('edged')
ax[0].set_xticks([])
ax[0].set_yticks([])
plt.show()
```

![スクリーンショット (120).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/01a70c92-c183-41d4-456e-694364b3dc3e.png)

このエッジ処理後の画像を使って確認していきます。

##`RETR_LIST`

`RETR_LIST`は特に階層構造を与えずに全ての輪郭を検出します。輪郭と階層構造をそれぞれ`contours`と`hierachy`に格納し、`hierachy`の中身を確認します。

```python
contours, hierachy = cv2.findContours(edge, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
hierachy
#--->
#array([[[ 1, -1, -1, -1],
#        [ 2,  0, -1, -1],
#        [ 3,  1, -1, -1],
#        [ 4,  2, -1, -1],
#        [ 5,  3, -1, -1],
#        [ 6,  4, -1, -1],
#        [ 7,  5, -1, -1],
#        [ 8,  6, -1, -1],
#        [ 9,  7, -1, -1],
#        [-1,  8, -1, -1]]], dtype=int32)
```

この`hierachy`の中に格納されているリストの意味は次の通りです。

| 1 | 2 | 3 | 4 |
|:-:|:-:|:-:|:-:|
| 同階層内の次の輪郭の番号(次がなければ-1) | 同階層内の前の輪郭の番号(次がなければ-1) | 子の輪郭の番号(下の階層がなければ-1) | 親の輪郭の番号(上の階層がなければ-1) |

`RETR_LIST`では階層構造なく検出されるので、3, 4番目はすべて-1になっています。

また、`cv2.drawContours()`関数を使って`contours`に保存した輪郭を描画することができます。

```python
fig, ax = plt.subplots(2, 5, sharex=True, sharey=True, figsize=(15,15))
ax = ax.flatten()
for i in range(len(contours)):
    img = cv2.drawContours(cv2.cvtColor(org_image, cv2.COLOR_GRAY2RGB), #描画する画像
                           contours,                                    #輪郭を保存したリスト
                           i,                                           #リストの何番目を描くか
                           (0,255,0),                                   #色の指定
                           2)                                           #線の太さの指定
    ax[i].imshow(img)
    ax[i].set_title('contour{}'.format(i))
ax[0].set_xticks([])
ax[0].set_yticks([])
plt.tight_layout()
plt.show()
```

![スクリーンショット (121).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/1dc1e709-3f6e-9e43-e7fd-61043da2d8c8.png)

線の内側と外側で2つずつ、輪郭であってほしい部分がしっかりと検出されていることがわかります。

##`RETR_EXTERNAL`

`RETR_EXTERNAL`は一番外側の輪郭のみを階層構造なしに返します。

```python
contours, hierachy = cv2.findContours(edge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
hierachy
#--->
#array([[[ 1, -1, -1, -1],
#        [-1,  0, -1, -1]]], dtype=int32)
```

`RETR_LIST`と同じように3, 4番目の値はすべて-1になっていますが、格納されている数が少なくなっています。描画すると以下のようになっています。

![スクリーンショット (122).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/ab360c3e-1281-a593-5d8f-a9c3a7f4003e.png)

上の直線はしっかりと外側と認識されており、また、下の四角の中は輪郭検出されていません。

##`RETR_CCOMP`

`RETR_CCOMP`はすべての輪郭を、物体の外側の輪郭を第1階層、内側の輪郭を第2階層、というふうに2階層構造にして返します。

```python
contours, hierachy = cv2.findContours(edge, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
hierachy
#--->
#array([[[ 2, -1,  1, -1],
#        [-1, -1, -1,  0],
#        [ 4,  0,  3, -1],
#        [-1, -1, -1,  2],
#        [ 6,  2,  5, -1],
#        [-1, -1, -1,  4],
#        [ 8,  4,  7, -1],
#        [-1, -1, -1,  6],
#        [-1,  6,  9, -1],
#        [-1, -1, -1,  8]]], dtype=int32)
```

0番目の輪郭について、以下のように考えられます。

- 4番目の要素が-1なので親がいません、すなわち第1階層の輪郭です
- 3番目の要素が1なので、1番目の輪郭が子にいることがわかります
- 2番目の要素が-1なので、最初に出てきた第1階層の輪郭です
- 1番目の要素が2なので、次の第1階層の輪郭は2番目の輪郭です

同じように1番目の輪郭について、以下のように考えられます。

- 4番目の要素が0なので、これは0番目の輪郭の子の輪郭です
- 3番目の要素が-1なので、この輪郭に子はいません
- 2番目の要素が-1なので、0番目の子の輪郭の中で最初に出てきた第2階層の輪郭です
- 1番目の要素が-1なので、0番目の輪郭の子はもうありません

このようにして階層構造を考えることができます。例えば以下のように第1階層の輪郭、すなわち物体の外側の輪郭のみをみることができます。

```python
#0番目の輪郭から見て、1番目の要素を追って描画する
next_c = 0
fig, ax = plt.subplots(1, 5, sharex=True, sharey=True, figsize=(20, 20))
for i in range(len(contours)):
    img = cv2.drawContours(cv2.cvtColor(org_image, cv2.COLOR_GRAY2RGB), contours, next_c, (0,255,0), 2)
    ax[i].imshow(img)
    ax[i].set_title('contour{}'.format(i))
    next_c = hierachy[0][next_c][0]
    if next_c == -1: #第1階層が最後のとき脱出
        break
ax[0].set_xticks([])
ax[0].set_yticks([])
plt.tight_layout()
plt.show()
```

![スクリーンショット (124).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/329ecdc6-247d-fda1-a58b-2794c8a7ef05.png)

輪郭は全部で10個検出されましたが、そのうち外側の5つのみを描画することができました。

##`RETR_TREE`

`RETR_TREE`はすべての輪郭を入れ子構造を完全に保存した階層構造を持たせて返します。例の画像では下の四角たちが入れ子構造になります。

```python
contours, hierachy = cv2.findContours(edge, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
hierachy
#--->
#array([[[ 8, -1,  1, -1],
#        [-1, -1,  2,  0],
#        [-1, -1,  3,  1],
#        [-1, -1,  4,  2],
#        [ 6, -1,  5,  3],
#        [-1, -1, -1,  4],
#        [-1,  4,  7,  3],
#        [-1, -1, -1,  6],
#        [-1,  0,  9, -1],
#        [-1, -1, -1,  8]]], dtype=int32)
```

入れ子構造は以下の図のようになっています。

![スクリーンショット (128).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/30eab15c-7d60-bd39-91ac-d93bf57f0480.png)

四角の中に四角があるような部分はすべて一番外側の四角の下にあるように階層構造が作られています。

うまく利用すれば、物体ごとにマスクした画像を複製することもできそう。

#使用例：背景の削除

'RETR_EXTERNAL'を使って背景の削除を試します。

エッジ処理は`Canny`だけだと輪郭が複雑すぎてしまうので`dilate`を使って太くしておきます。

```python
image = cv2.cvtColor(cv2.imread('image2.png', 1), cv2.COLOR_BGR2RGB)
gray_image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
edge_image = cv2.Canny(gray_image, 50, 70)
kernel = np.ones((3, 3), np.uint8)
edge_image = cv2.dilate(edge_image, kernel, iterations=3)
plt.imshow(edge_image, cmap='gray')
```

![スクリーンショット (129).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/2db92210-c20e-f6b6-5948-6c31b9563137.png)


次に外側の輪郭で、囲って面積が生まれるようなものを取り出し、マスク画像を作ります。

```python
contours, hierachy = cv2.findContours(edge_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out = []
for c in contours:
    if cv2.contourArea(c) > 0:
        out.append(c)
mask = np.ones(edge_image.shape)
for i in range(len(out)):
    cv2.fillConvexPoly(mask, out[i], (0))
plt.imshow(mask, cmap='gray')
```

![スクリーンショット (130).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/8a6cdbed-5294-fe8c-cb01-b65b5c49d9f8.png)


最後にマスクと元画像を組み合わせると背景が黒塗りされた状態の画像(すなわち背景情報を消した画像)を作ることができます。

```python
mask = np.ones(edge_image.shape)
for i in range(len(contours)):
    cv2.fillConvexPoly(mask, contours[i], (0))
mask = cv2.dilate(mask, None, iterations=3)
mask = cv2.erode(mask, None, iterations=3)
mask_stack = np.dstack([mask]*3).astype('float32')
image = (image/255.).astype('float32')
sub_image = cv2.subtract(image, mask_stack)
plt.imshow(sub_image, cmap='gray')
```

![スクリーンショット (131).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/4733b394-ccd9-c619-4ef2-cb9292565f6e.png)

Cannyの閾値やdilate, erodeのイテレーション回数、カーネルサイズを動かすことで切り取り方をすこしかえることができます。
(ここをフォルダ内の画像すべてにうまい具合に適用する、というのがすごい難しそう。多分うまくいかない画像がたくさん出てくるので、よく観察しなければならないかもしれないです。例えばマスク画像であまりに黒い面積が生まれるならその画像は見ない、とかしたほうがいいような気かしました。今後実験してみたいです。)