【OpenCV】【Python】画像処理で並走忍者

この投稿は [OpenCV Advent Calendar 2018](https://qiita.com/advent-calendar/2018/opencv "OpenCV Advent Calendar 2018")の11日目の記事です。
なお、お決まりの断り文句ですが、この記事に書かれている内容は個人の見解であり、所属する組織の公式見解ではありません。

##はじめに
皆様、電車に乗っているときは何をされていますか？
今時だと、スマホでネット・ゲーム・音楽あたりが大多数を占めると思いますが、忍者を走らせている人もいるのでは無いでしょうか？
本記事では、並走忍者を画像処理を用いて実現したいと思います。

並走忍者のイメージがわかない方のための参考URL：
　[日本経済新聞：「走らせている人」たち　村田沙耶香](https://www.nikkei.com/article/DGXMZO32000950Q8A620C1FBB000/)
　[YouTube：ムーンスター バネのチカラ「並走忍者編」2016秋冬TVCM](https://www.youtube.com/watch?v=yUllF4gOEMo)


##実現方針
　画像処理の分野では、ディープラーニングが非常に盛況 [^1] ですが、業務上で画像処理をしていると、まだまだ古典的？な画像処理で課題を解決することが、ままある気がします（処理速度の問題や、ディープラーニングがオーバースペックな場合など）

　本記事では、スマホで撮影した動画を用いてPC上で処理しますが、将来的にはスマホ上で動作させたいと考えています。
　そのため、処理が重いディープラーニングは使用せずに輪郭検出等で実現したいと思います。
とはいえ、数年もしないうちに、ミドルスペックぐらいのスマホなら、エッジでディープラーニングとか言う時代になりそうですが。。。

##処理概要
以下の流れで処理を実施しました。


1. 動画から1フレーム取り出し
1. ノイズ低減のための前処理
    1. グレースケール
    1. ぼかし
    1. ２値化
    1. オープニング
1. 輪郭検出
1. 忍者位置算出
1. 描画

※輪郭検出以外にも何個か思いつく手法(ハフ変換し直線検出、HSV変換し明暗で判定などなど)を試してみましたが、記事投稿までのタイムリミットで一番出来が良かったのが輪郭検出でした。


##ソースコード
ソースコードは以下。

```python:TrainNINJA.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
[summary]
  並走忍者
"""
from collections import deque
import cv2 as cv
import numpy as np
from CvOverlayImage import CvOverlayImage

if __name__ == '__main__':
    # 車窓動画の読み込み
    video = cv.VideoCapture("TrainNINJA.mp4")

    # 忍者画像の読み込み(＋リサイズ、向き反転)
    ninja1_image = cv.imread('ninja_hashiru.png', cv.IMREAD_UNCHANGED)
    ninja1_image = cv.flip(ninja1_image, 1)
    ninja2_image = cv.imread('ninja_musasabi.png', cv.IMREAD_UNCHANGED)
    ninja2_image = cv.flip(ninja2_image, 1)

    ninja_image_h, ninja_image_w = 50, 50
    ninja1_image = cv.resize(ninja1_image, (50, 50))
    ninja2_image = cv.resize(ninja2_image, (50, 50))

    # 忍者座標 移動平均用deque
    ninja_point = [0, 0]
    moment_history_maxlen = 5
    pointsdeque = deque(maxlen=moment_history_maxlen)

    # むささび画像用カウンタ
    musasabi_count = 0

    while video.isOpened():
        # カメラ画像取得
        ret, frame = video.read()
        if ret is False:
            break
        frame_height, frame_width = frame.shape[:2]

        # ノイズ低減後、輪郭線を検出
        # ノイズ低減処理：グレースケール⇒ぼかし⇒２値化⇒オープニング
        gray_image = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)  # グレースケール変換
        blur_image = cv.GaussianBlur(gray_image, (11, 11), 0)  # ぼかし
        ret, th_image = cv.threshold(blur_image, 0, 255,
                                     cv.THRESH_BINARY + cv.THRESH_OTSU)  # ２値化
        kernel = np.ones((12, 12), np.uint8)
        th_image = cv.morphologyEx(th_image, cv.MORPH_OPEN, kernel)  # オープニング

        _, contours, hierarchy = cv.findContours(
            th_image, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)  # 輪郭線検出

        # 輪郭線をポリゴン近似し、一定区域内に座標があった場合、忍者描画座標に指定
        ninja_ninja_draw_point = [0, frame_height]
        for contour in contours:
            # ポリゴン近似
            arclen = cv.arcLength(contour, True)
            approx = cv.approxPolyDP(contour, 0.0005 * arclen, True)
            for approx_point_ in approx:
                for approx_point in approx_point_:
                    # ポリゴン座標が一定区域内に存在、かつ、区域内の一番高い箇所にいるか判定
                    if 200 < approx_point[0] < 210 \
                            and 50 < approx_point[1] < 300 \
                            and ninja_ninja_draw_point[1] > approx_point[1]:
                        ninja_ninja_draw_point = (approx_point[0],
                                                  approx_point[1])

        # 忍者位置が検出できた場合は、座標を更新
        if ninja_ninja_draw_point[0] != 0:
            ninja_point = ninja_ninja_draw_point
        # 忍者位置が検出できなかった場合には、むささびの術
        else:
            musasabi_count = 6

        # ブレを軽減するため、検出座標位置で移動平均を算出し描画位置とする
        pointsdeque.append(ninja_point)
        ninja_draw_point = [0, 0]
        for pointdeque in pointsdeque:
            ninja_draw_point[0] += pointdeque[0]
            ninja_draw_point[1] += pointdeque[1]
        ninja_draw_point[0] = int(ninja_draw_point[0] / len(pointsdeque))
        ninja_draw_point[1] = int(ninja_draw_point[1] / len(pointsdeque))

        # 忍者描画
        if musasabi_count <= 1:  # 走る
            image = CvOverlayImage.overlay(
                frame, ninja1_image,
                (ninja_draw_point[0] - int(ninja_image_h / 2),
                 ninja_draw_point[1] - ninja_image_h - 5))
            musasabi_count = 0
        elif musasabi_count > 1:  # むささび
            image = CvOverlayImage.overlay(
                frame, ninja2_image,
                (ninja_draw_point[0] - int(ninja_image_h / 2),
                 ninja_draw_point[1] - ninja_image_h - 5))
            musasabi_count -= 1
        cv.imshow('TrainNINJA', image)

        # キー待ち受け
        key = cv.waitKey(16) & 0xFF
        if key == 27:  # ESC
            break
```

ソース中に出てくる「CvOverlayImage」は以下の投稿で作成したクラスです。
　[【OpenCV】【Python】画像上に透過付き画像を重ねて描画](https://qiita.com/Kazuhito/items/ff4d24cd012e40653d0c)

概要には記載していませんでしたが、ノイズ低減のパラメータはトライ＆エラーです。
また、あまりブレすぎないように座標の移動平均を取ってます。
そして、検出が途切れたことをごまかすために、むささびの術が発動します。

##動作例
忍者は、いらすとや様のイラストを利用させていただいています。
動画は以下です（YouTubeリンク
https://www.youtube.com/watch?v=u6VJ4XfYo1U
[![並走忍者](https://img.youtube.com/vi/u6VJ4XfYo1U/0.jpg)](https://www.youtube.com/watch?v=u6VJ4XfYo1U)

##おわりに
チューニングが結構かなり大分イマイチなので、もう少し早めに作成着手すれば良かったと後悔。。。
あと、今回はディープラーニング利用しなかったですが、Monocular Depth等も試してみたいです。

次は、12/15　sai-san様の「何か書く」です。
※本記事作成時点では、12/12～12/14の参加登録がありませんでした。

以上。

[^1]: 産総研の方の講演で聞いた話ですが、国際的な画像処理の学会（CVPRとかICCVとか）で、発表される論文の9割以上がディープラーニングを用いているとか。→ エビデンスあるかと思ってググったら、ABEJA様が人力でカウントしてました。https://tech-blog.abeja.asia/entry/cvpr2018-oral