【デレステ】OpenCVで腋を見せてもらう

まずは[この動画](https://www.nicovideo.jp/watch/sm35108395)を見てくれ。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="und" dir="ltr"><a href="https://t.co/5nXndpCjIO">https://t.co/5nXndpCjIO</a></p>&mdash; ラナ・クアール (@rana_kualu) <a href="https://twitter.com/rana_kualu/status/1128670943688003584?ref_src=twsrc%5Etfw">2019年5月15日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

15分50秒、1秒たりとも目を離すことなく堪能してくれたであろうと思う。
訓練されたPなら全員の特定も余裕だろう。[^1]

今回はこの動画作成に使った技術を紹介しよう。


# 使用した技術

## 目grep

技術もへったくれもなかった！

録画したPV動画をコマ送りしながら、同じくらいのシーンをスクリーンショット撮影する。

これを190回繰り返しました。
![image_shudou.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26088/0eca0de7-a7f9-1e80-dd4e-b7c3aff8785b.png)


# 自動化しよう

まあなんだ、キャプチャしながら考えてはいたわけですが、こういうのはやっぱり自動化したほうがいいですよね。

やりたいことは動画から特定のシーンを抜き出すことです。
動画は手動録画したものなので録画開始時刻が微妙にずれているため、時間指定で抜き出すことはできません。
何らかのアルゴリズムで抽出タイミングを算出する必要があるでしょう。

## 実装方針

ここでは単純に、サンプル画像を1枚撮っておいて、『動画からサンプル画像と最も近いシーンを抜き出す』というアプローチでいきましょう。

まずはサンプル画像を用意。
最初の一枚くらいは仕方ないね。
![eve.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26088/91198adf-a22b-bb12-37e7-5f17c5f68ab8.png)

# 環境準備

## Anaconda

Anaconda Promptを起動して、とりあえず全部アプデ。

```bash
conda update -n base conda
conda update --all
conda update python
```

## OpenCV

Anaconda navigatorを起動する。

Environment → 『OpenCV』環境を作成
`OpenCV`および`scikit-image`をインストールする。
`OpenCV`で調べたら`libopencv`と`opencv`と`py-opencv`の三つが出てきたんだけど、区別がつかないのでとりあえず全部インストールした。

![install.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26088/7ca59a5a-0e30-e9e6-1665-6fda1bd372a1.png)

あと、そのままだと`matplotlib._qhull`がねーぞとかいうエラーが出てきたけど、`uninstall matplotlib && install matplotlib`としたらなおった。
よくわからない。

# 実装

## ソース

```python
import cv2
import os
import glob

def save_frame(video_path, frame_num, result_path):
    """
    対象のフレームを画像に保存する。

    Parameters
    ----------
    video_path : string
        動画フルパス
    frame_num : int
        フレーム数
    result_path : string
        保存先の画像フルパス

    Returns
    -------
    ret : boolean
        キャプチャできたらTrue。
    """
    cap = cv2.VideoCapture(video_path)
    cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
    ret, frame = cap.read()

    # cv2.imwriteは日本語使えないので、一度テンポラリに保存してから移動する
    temporary_path = 'C:/path/to/dummy.png'

    if ret:
        cv2.imwrite(temporary_path, frame)
        shutil.move(temporary_path, result_path)
        return True

    return False


def get_ssim_list(imageFileName, movieFileName, frameStart=0, frameFinish=9999, frameStep=1):
    """
    動画のSSIMを求める

    Parameters
    ----------
    imageFileName : string
        画像ファイルのフルパス
    movieFileName : string
        動画ファイルのフルパス
    frameStart : int
        調べる開始フレーム
    frameFinish : int
        調べる終了フレーム
    frameStep : int
        フレームを飛ばす数。1だととても遅い。

    Returns
    -------
    ssimArray : array
        {フレーム:SSIM}
    """

    # 初期値
    imageData = cv2.imread(imageFileName)
    movieData = cv2.VideoCapture(movieFileName)
    ssimArray = {}
    frame = frameStart

    while frame < frameFinish:
        # 画像を取得
        movieData.set(cv2.CAP_PROP_POS_FRAMES, frame)
        ret, movieImageData = movieData.read()
        # 取れなくなったら終わり
        if not ret:
            break
        # 返り値に積む
        ssimArray[frame] = compare_ssim(imageData, movieImageData, multichannel=True)
        # 次フレーム
        frame += frameStep

    return ssimArray


# 比較元画像
defaultImage = 'C:/path/to/eve.png';

# 調査元動画ディレクトリ
movieDir = 'C:/path/to/movie/'
# 画像の出力先ディレクトリ
imageDir = 'C:/path/to/image/'

# 動画リストを取得
movieArray = glob.glob(movieDir + '*.mp4', recursive=True)

# 動画リストでループ
for movieFile in movieArray:
    # 保存ファイル名を作成
    a = os.path.basename(movieFile)
    saveImageFile = imageDir + os.path.splitext(a)[0] + ".png"

    # まず大まかに調べる
    ssimList1 = get_ssim_list(defaultImage, movieFile, frameStart=0, frameFinish=600, frameStep=30)
    nearFrame = max(ssimList1, key=ssimList1.get)

    # 一番近いところの周囲をフレーム単位で調査
    ssimList2 = get_ssim_list(defaultImage, movieFile, frameStart=max(0, nearFrame-28), frameFinish=nearFrame+28, frameStep=1)
    nearestFrame = max(ssimList2, key=ssimList2.get)

    # 保存する
    save_frame(movieFile, nearestFrame, saveImageFile)
```

Pythonは素人なのでソースは適当です。

最初は大まかに1秒ごと調べて、最も近かったところの周囲を再度1フレームずつ調べるという二段階の調査になっています。
さすがに最初から最後まで全フレームを調べるのは重すぎますからね。


# 実行

全員の処理に3時間弱かかりました。
![image_opencv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26088/c9ffc9bb-5ee6-40a9-52e4-461608fe4537.png)

キャプチャ画像のサムネイルを見るかぎりでは、非常に良さそうな結果に見えますね。
髪の毛とかあるからもっと残念なことになるかもしれないと危惧していたのですが、予想外に綺麗にいいかんじになってくれました。

# 完成したPV

OpenCVが出力した画像を使って作成したPVが[こちらになります](https://www.nicovideo.jp/watch/sm35128968)。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="und" dir="ltr"><a href="https://t.co/jH2nyCIIeU">https://t.co/jH2nyCIIeU</a></p>&mdash; ラナ・クアール (@rana_kualu) <a href="https://twitter.com/rana_kualu/status/1128671829663465472?ref_src=twsrc%5Etfw">2019年5月15日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

残念ながら、カメラが下の方にずれているアイドルが散見されます。
腋ではなく、なにやら別のところを注視しているようにも見えてしまいますね。
しかし、全く関係ないシーンを検出するようなことはありませんでした。
最良とはとても言えないものの、まあそこそこな結果になったのではないでしょうか。


# 今後の展望

今回は『画像同士の近似度を測る』だけという単純なアプローチでした。
キャプチャの高さがずれているアイドルはおそらく背景に引っ張られているせいだと思うので、次は人物だけをクリッピングして比較するとか、あるいは機械学習やらなにやらを使って骨格の位置で比較する、みたいなことをやりたいですね。

あと、どちらかというとスクリーンショットのキャプチャより、その前のPV録画のほうが辛かったので、むしろそっちを自動化したいところです。

・アイドルを選択する
・シンデレラドリームに着替えてもらう
・PVを再生する
・PCのキャプチャソフトでPVを録画する
・録画されたファイルにアイドル名を付ける
・これを190回繰り返す
![movie.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26088/7a0534fc-f696-d6b7-e211-c208f2a2579c.png)

ちなみにPV録画機材は[Ragno Grabber2](https://www.amazon.co.jp/gp/product/B01HI17LN2/ref=as_li_tl?ie=UTF8&camp=247&creative=1211&creativeASIN=B01HI17LN2&linkCode=as2&tag=fooga-22&linkId=acf3645332c0b2ba264df9d91b0ab2b2)です。
安いわりにけっこうな画質でなんでも録画できるのと、あとパススルーがあるので無遅延でTV出力できるのがとっても便利。


# まとめ

サンタクロースちゃんと結婚したい。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">サンタクロースちゃんに全部<br> <a href="https://twitter.com/hashtag/%E3%83%87%E3%83%AC%E3%82%B9%E3%83%86?src=hash&amp;ref_src=twsrc%5Etfw">#デレステ</a> <a href="https://twitter.com/hashtag/%E7%AC%AC8%E5%9B%9E%E3%82%B7%E3%83%B3%E3%83%87%E3%83%AC%E3%83%A9%E3%82%AC%E3%83%BC%E3%83%AB%E7%B7%8F%E9%81%B8%E6%8C%99?src=hash&amp;ref_src=twsrc%5Etfw">#第8回シンデレラガール総選挙</a> <a href="https://t.co/YtNCPzRPCs">pic.twitter.com/YtNCPzRPCs</a></p>&mdash; ラナ・クアール (@rana_kualu) <a href="https://twitter.com/rana_kualu/status/1121556194764546049?ref_src=twsrc%5Etfw">2019年4月25日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

-----

[^1]: 私は20人くらいしかわからない。