Python + OpenCVで画像の類似度を求める 2018

皆さんこんにちは。@best_not_bestです。
現在のお仕事は、Google Analytics、Adobe Analytics、Google Cloud Platformを使って、サイトの分析のお手伝いをしています。

Deep Learningでの画像解析は精度は良いですが、学習データを収集することがネックとなります。今回は、OpenCVで2枚の画像を比較して画像の類似度を求めてみたいと思います。

## やること

以下の犬画像の類似度を比較します。画像はGoogle先生から拾ってきています。
精度を高めるため、正面を向いている画像を選びました。

### 比較画像

| ファイル名 | 画像 | 説明 |
|:-----------:|:------------:|:------------:|
| 05.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/bb7eeb2f-e0fe-f3f7-0221-f9f8f89b2b31.png" alt="05" width="200px"> | 柴犬 | 

### 比較対象

| ファイル名 | 画像 | 説明 |
|:-----------:|:------------:|:------------:|
| 01.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/18c09e7c-c514-8e06-b852-8e7d6a1fdf1d.png" alt="01" width="200px"> | ダックスフント | 
| 02.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/93e6fc81-5ab9-ddf1-74d1-c1612ae98755.png" alt="02" width="200px"> | コーギー | 
| 03.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/faf70354-01d0-1f42-b140-cb94a36c038a.png" alt="03" width="200px"> | ゴールデン・レトリバー | 
| 04.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/b27f891f-edfb-3dd9-433c-ae5d70b2b38e.png" alt="04" width="200px"> | 柴犬 | 
| 06.png | <img src="https://qiita-image-store.s3.amazonaws.com/0/17452/4b2714cc-1ebb-a061-f428-91db85059ce7.png" alt="06" width="200px"> | ラブラドール・レトリーバー | 

同じ犬種である、05.pngと04.pngの類似度が高くなれば（いちおう）成功です。

## 環境

### マシン/OS

* MacBook Pro (13-inch, 2017, Four Thunderbolt 3 Ports)
* OS 10.12.6
* pyenv: 1.2.8
* pyenv-virtualenv: 1.1.3

### Python

pyenv、pyenv-virtualenvで 3.7.1 の環境を構築します。
（いつの間にかAnaconda使わなくてもOpenCVが入るようになってた！）

```shell-session
$ mkdir hogehoge
$ cd hogehoge

$ pyenv local 3.7.1
$ pyenv virtualenv 3.7.1 hogehoge
$ pyenv local hogehoge

$ pip install -U pip
```

以下のライブラリをインストールします。

```shell-session:requirements.txt
docopt==0.6.2
flake8==3.6.0
flake8-docstrings==1.1.0
flake8-polyfill==1.0.1
mccabe==0.6.1
numpy==1.15.4
opencv-python==3.4.4.19
pbr==5.1.1
pycodestyle==2.4.0
pydocstyle==3.0.0
pyflakes==2.0.0
six==1.12.0
snowballstemmer==1.2.1
```
### ディレクトリ構成

```shell-session
scripts
 ├─ hist_matching.py
 └─ feature_detection.py
comparing
 ├─ 01.png
 ├─ 02.png
 ├─ 03.png
 ├─ 04.png
 ├─ 05.png
 └─ 06.png
```

## 検証1: ヒストグラム比較

### 説明

色の分布や位置等から比較する手法です。詳細は以下を参照ください。

* <a href="http://aidiary.hatenablog.com/entry/20091003/1254574041" target="_blank">類似画像検索システムを作ろう - 人工知能に関する断創録</a>
* <a href="https://www.blog.umentu.work/python-opencv3%E3%81%A7%E5%BA%A6%E6%95%B0%E5%88%86%E5%B8%83%E3%82%92%E7%AE%97%E5%87%BA%E3%81%97%E3%81%A6%E3%83%92%E3%82%B9%E3%83%88%E3%82%B0%E3%83%A9%E3%83%A0%E5%87%BA%E5%8A%9B/" target="_blank">Python OpenCV3で度数分布を算出してヒストグラム出力 - from umentu import stupid</a>

画像サイズは一律200px × 200pxに変換して比較しています。
また、`calcHist`の第2引数はB、G、Rの色相に対応する[0]、[1]、[2]のどれかの値を指定するのですが、今回はB、G、R全てで比較しそれらの平均を算出しています。

```py3:hist_matching.py
#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""hist matching.

Usage:
    hist_matching.py
        --target_file_path=<target_file_path>
        --comparing_dir_path=<comparing_dir_path>
    hist_matching.py -h | --help
Options:
    -h --help show this screen and exit.
"""

import cv2
from docopt import docopt
import glob
import logging
import os
import sys
from statistics import mean

if __name__ == '__main__':
    # logging config
    logging.basicConfig(
        level=logging.DEBUG,
        format='%(asctime)s %(levelname)s: %(message)s',
    )
    logging.info('%s start.' % (__file__))

    # get parameters
    args = docopt(__doc__)
    target_file_path = args['--target_file_path']
    comparing_dir_path = args['--comparing_dir_path']

    # setting
    img_size = (200, 200)
    channels = (0, 1, 2)
    mask = None
    hist_size = 256
    ranges = (0, 256)
    ret = {}

    # get comparing files
    pattern = '%s/*.png'
    comparing_files = glob.glob(pattern % (comparing_dir_path))
    if len(comparing_files) == 0:
        logging.error('no files.')
        sys.exit(1)

    # read target image
    target_file_name = os.path.basename(target_file_path)
    target_img = cv2.imread(target_file_path)
    target_img = cv2.resize(target_img, img_size)

    for comparing_file in comparing_files:
        comparing_file_name = os.path.basename(comparing_file)
        if comparing_file_name == target_file_name:
            continue

        tmp = []
        for channel in channels:
            # calc hist of target image
            target_hist = cv2.calcHist([target_img], [channel], mask, [hist_size], ranges)

            # read comparing image
            comparing_img_path = os.path.join(
                os.path.abspath(os.path.dirname(__file__)) + '/../',
                comparing_file,
            )
            comparing_img = cv2.imread(comparing_img_path)
            comparing_img = cv2.resize(comparing_img, img_size)
            # calc hist of comparing image
            comparing_hist = cv2.calcHist([comparing_img], [channel], mask, [hist_size], ranges)

            # compare hist
            tmp.append(cv2.compareHist(target_hist, comparing_hist, 0))

        # mean hist
        ret[comparing_file] = mean(tmp)

    # sort
    for k, v in sorted(ret.items(), reverse=True, key=lambda x: x[1]):
        logging.info('%s: %f.' % (k, v))

    logging.info('%s end.' % (__file__))
    sys.exit(0)
```

### 実行方法

`target_file_path`、`comparing_dir_path`がコマンド引数となります。それぞれ`<対象の画像パス>`、`<比較対象の画像があるディレクトリパス>`を指定ください。

```shell-session:実行例
$ python scripts/hist_matching.py \
  --target_file_path=./comparing/05.png \
  --comparing_dir_path=./comparing/
```

```shell-session:実行結果
2018-12-16 23:09:02,826 INFO: ./comparing/03.png: 0.510273.
2018-12-16 23:09:02,826 INFO: ./comparing/04.png: 0.500878.
2018-12-16 23:09:02,826 INFO: ./comparing/06.png: 0.310258.
2018-12-16 23:09:02,826 INFO: ./comparing/02.png: 0.259239.
2018-12-16 23:09:02,826 INFO: ./comparing/01.png: 0.005728.
```

類似度は-1〜1の範囲を取り、全く同じ画像の場合に1となります。
04.pngとの類似度が高くなって欲しかったのですが、03.png（ゴールデン・レトリバー）との類似度が1番高いという結果になってしまいました・・・。

## 検証2: 特徴点のマッチング

### 説明

2画像の特徴点を抽出し、それらの距離を比較します。以下を参考にさせていただきました。

* <a href="http://aidiary.hatenablog.com/entry/20091102/1257167398" target="_blank">3日で作る高速特定物体認識システム (4) 特徴点のマッチング - 人工知能に関する断創録</a>
* <a href="http://qiita.com/hmichu/items/f5f1c778a155c7c414fd" target="_blank">[OpenCV] いまさら局所特徴量で物体検出！？ - Qiita</a>
* <a href="http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_feature2d/py_matcher/py_matcher.html" target="_blank">特徴点のマッチング - OpenCV-Python Tutorials 1 documentation</a>

抽出精度を高めるため、グレースケール変換を行っています。前項と同様に、画像サイズは一律200px × 200pxに変換して比較しています。

```py3:feature_detection.py
#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""feature detection.

Usage:
    feature_detection.py
        --target_file_path=<target_file_path>
        --comparing_dir_path=<comparing_dir_path>
    feature_detection.py -h | --help
Options:
    -h --help show this screen and exit.
"""

import cv2
from docopt import docopt
import glob
import logging
import os
import sys

if __name__ == '__main__':
    # logging config
    logging.basicConfig(
        level=logging.DEBUG,
        format='%(asctime)s %(levelname)s: %(message)s',
    )
    logging.info('%s start.' % (__file__))

    # get parameters
    args = docopt(__doc__)
    target_file_path = args['--target_file_path']
    comparing_dir_path = args['--comparing_dir_path']

    # setting
    img_size = (200, 200)
    bf = cv2.BFMatcher(cv2.NORM_HAMMING)
    # detector = cv2.ORB_create()
    detector = cv2.AKAZE_create()
    ret = {}

    # get comparing files
    pattern = '%s/*.png'
    comparing_files = glob.glob(pattern % (comparing_dir_path))
    if len(comparing_files) == 0:
        logging.error('no files.')
        sys.exit(1)

    # read target image
    target_file_name = os.path.basename(target_file_path)
    target_img = cv2.imread(target_file_path, cv2.IMREAD_GRAYSCALE)
    target_img = cv2.resize(target_img, img_size)
    (target_kp, target_des) = detector.detectAndCompute(target_img, None)

    for comparing_file in comparing_files:
        comparing_file_name = os.path.basename(comparing_file)
        if comparing_file_name == target_file_name:
            continue

        # read comparing image
        comparing_img_path = os.path.join(
            os.path.abspath(os.path.dirname(__file__)) + '/../',
            comparing_file,
        )
        comparing_img = cv2.imread(comparing_img_path, cv2.IMREAD_GRAYSCALE)
        comparing_img = cv2.resize(comparing_img, img_size)
        (comparing_kp, comparing_des) = detector.detectAndCompute(comparing_img, None)

        # detect
        matches = bf.match(target_des, comparing_des)
        dist = [m.distance for m in matches]
        ret[comparing_file] = sum(dist) / len(dist)

    # sort
    for k, v in sorted(ret.items(), reverse=False, key=lambda x: x[1]):
        logging.info('%s: %f.' % (k, v))

    logging.info('%s end.' % (__file__))
    sys.exit(0)
```

### 実行方法

コマンド引数は前項と同様です。

```shell-session:実行例
$ python scripts/feature_detection.py \
  --target_file_path=./comparing/05.png \
  --comparing_dir_path=./comparing/
```

```shell-session:実行結果（AKAZE）
2018-12-16 23:45:35,315 INFO: ./comparing/04.png: 124.100000.
2018-12-16 23:45:35,316 INFO: ./comparing/02.png: 131.225000.
2018-12-16 23:45:35,316 INFO: ./comparing/03.png: 135.725000.
2018-12-16 23:45:35,316 INFO: ./comparing/01.png: 143.700000.
2018-12-16 23:45:35,316 INFO: ./comparing/06.png: 143.850000.
```

```shell-session:実行結果（ORB）
2018-12-16 23:49:55,066 INFO: ./comparing/04.png: 53.519713.
2018-12-16 23:49:55,066 INFO: ./comparing/02.png: 58.659498.
2018-12-16 23:49:55,066 INFO: ./comparing/03.png: 59.534050.
2018-12-16 23:49:55,066 INFO: ./comparing/06.png: 63.512545.
2018-12-16 23:49:55,066 INFO: ./comparing/01.png: 67.397849.
```

類似度は距離を算出しているので、全く同じ画像の場合の値は0となり、値が小さいほど類似度が高くなります。
実施したどちらの手法（AKAZE、ORB）でも、04.pngとの類似度が高くなっています。

## まとめ

* Deep Learningを使わなくてもそれなりの精度は出せる
* 手法に応じてグレースケール変換、サイズ変換を行う
* 今回の場合、ヒストグラム比較では上手くいかなかったので手法の再検討が必要
* 特徴点抽出の場合、向きが異なる画像の比較は難しいので、同じ向きの画像を比較させる
  * 例えば、必ず正面を向いている社員証の画像どうしは比較し易い気がする
* いぬかわいい