日本三大提灯祭り 360年続く伝統の二本松提灯祭りの提灯カウンターを作ってみた

# はじめに

去年、[日本三大提灯祭り 360年続く伝統の二本松提灯祭りの提灯を最新のAIで画像認識してみた](https://qiita.com/DekaGodzilla/items/191b933cbd53415f98bf) を投稿しました。去年に引き続き、今年も、続編となる記事を投稿します。
去年は、静止画を対象としていましたが、今年は、認識した結果を動画に組み立ててご覧いただけるようにしました。また、膨大な数の提灯を認識するので、その数をカウントするようにしました。

# 学習モデル
去年と同様YOLOにて物体検知しました。学習結果モデルは、去年と全く同じそのものを使用しました。
```
./yolov5/runs/train/chochin/weights/best.pt
```
なお、去年は、pythonコマンドでYOLOを動作させましたが、今年は、pythonの関数を呼ぶようにしました。
具体的には、下記の通り、yolov5をimportし、detect.run()で物体検知します。
```python
import yolov5.detect as detect
detect.run(weights='best.pt',source=filename_result,save_csv=True)
```

# 提灯カウンター
山車が交差点を曲がる際、提灯がゆらゆらするので認識が難しかろうと、交差点を曲がる際の動画を１分ぶん対象としてカウントします。

このような祭りの山車ものって、まがるところが魅せ場だったりするんですよね。今回、山車がきて人混みができる前に、私がスタンバって待っていると、隣に子連れの若夫婦がいたので、旦那さんに「今からここで待ってるなんて、通だね。」って声をかけたら、「隣町のお祭りで若連やっていて、曲がり角が見ものなんでね。」って言ってました。さすが現役のお祭りストだと思いました。隣町のお祭りも面白いので今度見に行こうっと。

閑話休題、提灯カウンターの話に戻りましょう。
各字によって、曲がり方に特徴があり、あっさり曲がっていく場合もあり、ゆっくり曲がっていく場合もあり、最大１分程度かかるので、カウント対象の動画を１分としました。その１分の開始位置は、各動画でまちまちなので、目視確認後、その開始時間を入力するようにしました。

以下、YOLOで提灯を検知し、正しく認識できた数をカウントアップする関数を作りました。

```python
import cv2
import yolov5.detect as detect
import shutil
import pandas as pd
import os

filename_result = 'result.png'                     # YOLOで物体検知する対象の画像
yolo_detect     = './yolov5/runs/detect/exp/'      # YOLOで物体検知した結果フォルダ
filename_yolo   = yolo_detect + filename_result    # YOLOで物体検知した結果画像

# 提灯カウンター
def count_chochin(
    filename_in,  # 動画ファイル名称（入力用：物体検知する対象の動画）
    filename_out, # 動画ファイル名称（出力用：提灯を認識した矩形と数を描画した動画）
    label,        # ラベル（どの字の動画か？）
    count_start,  # カウント開始秒数
    count_end):   # カウント終了秒数

    # 動画ファイルオープン
    cap = cv2.VideoCapture(filename_in)
    if not cap.isOpened():
        return -1
    # fpsの取得
    fps = int(cap.get(cv2.CAP_PROP_FPS))
    # カウント開始/終了をフレーム番号に変換
    count_start_frameno = fps * count_start
    count_end_frameno   = fps * count_end
    frameno             = 0
    total_true_count    = 0
    # 動画終了までのループ
    while True:
        # １フレーム読み込み
        ret, frame = cap.read()
        if ret:
            print(frameno)
            if frameno==0:
                # 初回処理（出力動画を作成）
                fourcc = cv2.VideoWriter_fourcc('m','p','4', 'v')
                video  = cv2.VideoWriter(filename_out, fourcc, fps, (frame.shape[1], frame.shape[0]))

            # カウント対象の場合
            if count_start_frameno<=frameno and frameno<count_end_frameno:
                # 物体検知する静止画を、ファイル保存
                cv2.imwrite(filename_result,frame)
                # YOLOで物体検知
                detect.run(weights='best.pt',source=filename_result,save_csv=True)
                # 認識結果の画像
                img = cv2.imread(filename_yolo)
                # 正しく認識できた数の取得
                filename_predict = yolo_detect + 'predictions.csv'
                if os.path.exists(filename_predict):                        # ファイルが作成されていれば、
                    df_predict = pd.read_csv(filename_predict,header=None)  # 読み込んで、
                    df_predict = df_predict[df_predict[1]==label]           # 同じラベルのみを取得し、
                    true_count = df_predict.shape[0]                        # その数を取得する
                else:
                    true_count = 0                                          # ファイルが作成されなければ、０
                total_true_count += true_count
                # 認識結果ファイルをフォルダごと削除
                shutil.rmtree(yolo_detect)
            else:
                img = frame

            if total_true_count>0:
                # 正しく認識できた数を表示
                total_true_count_test = f'{total_true_count:>6}'
                cv2.putText(img, text=total_true_count_test,org=(1400, 1050),fontFace=cv2.FONT_HERSHEY_PLAIN,fontScale=8.0,color=(255, 255, 255),thickness=5,lineType=cv2.LINE_8)

            # 出力用動画に１フレーム追加書き込み
            video.write(img)
            frameno+=1
        else:
          video.release()
          return 0
    return
```
以下、解説します。

#### 関数の入力パラメータ
入力パラメータは、
* 動画ファイル名称（入力用：物体検知する対象の動画）
* 動画ファイル名称（出力用：提灯を認識した矩形と数を描画した動画）
* ラベル（どの字の動画か？）
* カウント開始秒数
* カウント終了秒数

処理概要は、ざっくりと、動画ファイル名称（入力用）を読み込んで、カウント開始秒数からカウント終了秒数までの各フレームでYOLOで物体検知し、認識した結果が、ラベル（どの字の動画か？）と同じだったらその数をカウントし、ラベル矩形と認識した数を画像に重ね合わせて表示し、動画ファイル名称（出力用）を作成する、といった感じです。

#### 動画処理（入力動画）
opencvで動画ファイルをオープン（cap=cv2.VideoCapture）し、１フレームずつ処理します。（cap.read）後で動画に組み立て直すので、fpsも取得（cap.get）しておきます。また、入力パラメータのカウント開始/終了は秒ですので、fpsを使ってフレーム番号に変換します。

#### 物体検知
detect.run()で物体検知してくれるのですが、ファイルで入力/出力するので、ちょっとめんどかったです。

##### 入力ファイル
メモリのまま画像イメージを渡すことができなかったので、（実はできるのですかね？）一旦ファイルに書き込んで（cv2.imwrite）、そのファイルを物体検知するようにしました。

##### 出力ファイル
検知結果も、メモリでもらうことができなかったので、ファイルに出力したものを、読み込むようにしました。検知した結果のファイルは、以下２ファイル作成されます。
* result.png  
  物体検知できた提灯の矩形とラベル、信頼度を表示した画像
  ![result50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3570565/1674486c-9681-1f63-f562-c7f6383e4811.png)
  亀谷の提灯を認識してますが、なぜか、交通整理のおじさんのヘルメットも、認識してしまっています。

* predictions.csv（save_csv=Trueとした場合）  
  物体検知できた提灯のラベルと信頼度（0-1）
  単純に読み込んで先頭を表示すると以下のようになります。ヘッダがないですが、"画像ファイル名","ラベル","信頼度"の順になります。
```
0	1	2
0	result.png	亀谷	0.26
1	result.png	亀谷	0.27
2	result.png	亀谷	0.29
3	result.png	亀谷	0.31
4	result.png	亀谷	0.3
```

認識結果は、./yolov5/runs/detect/exp/に書き込まれるのですが、expフォルダが存在していれば、exp2が作成され書き込まれます。以下順に、exp3,exp4と作成されていきます。ですので、毎回shutil.rmtree()により、認識結果ファイルをフォルダごと削除し、必ずexpフォルダに格納されるようにました。

#### 検知結果
predictions.csvができていれば、何らかの物体検知ができたことになるので、そのファイルを読み込み（pd.read_csv）、入力パラメータのラベルと同じラベルが認識できていれば（df_predict[1]==label）、その数をカウントします（df_predict.shape[0]）。それを、トータルの数に積算していきます（total_true_count += true_count）。正しく認識できた数として、画像の右下に表示します（cv2.putText）。なお、今回は、信頼度は参照せず、信頼度が低くとも、正しく認識できればカウントするようにしました。

#### 動画処理（出力動画）
初回のみ、opencvで動画ファイルを書き込みオープン（cv2.VideoWriter_fourcc、cv2.VideoWriter）します。その後、１フレームずつ、video.writeで書き込み、最後は、video.releaseで終了します。

#### 動画ファイルのリスト
各字の動画ファイル名と、ラベル、カウント開始秒をリストで定義します。カウント開始秒は、前述の通り、目視で確認しました。カウント終了秒は、全て１分（６０秒）なので、共通で60秒を設定します。
```python
file_list = [
    # ファイル名、     ラベル、カウント開始秒
    ['1_Motomachi.MOV', '本町', 110],
    ['2_Kamegai.MOV',   '亀谷',  40],
    ['3_Takeda.MOV',    '竹田',   1],
    ['4_Matsuoka.MOV',  '松岡',  20],
    ['5_Nezaki.MOV',    '根崎',  75],
    ['6_Wakamiya.MOV',  '若宮',  40],
    ['7_Kakunai.MOV',   '郭内',  60],
]
# カウントする秒数
count_time = 60
```

#### 処理実行
ファイルリストをループし、１動画ずつ、提灯カウンター関数をコールします。iPhoneで撮影したので、.MOVとして保存されていたので、.mp4として処理結果の動画を保存します。ここはちょっと力技になってしまいますが、ファイル名をreplace関数により、'.MOV'⇒'.mp4'に変換しています。
```python
# ファイルリストのループ
for file in file_list:
    # 動画ファイル名称（入力用：物体検知する対象の動画）
    filename_in  = f'data/{file[0]}'
    # 動画ファイル名称（出力用：提灯を認識した矩形と数を描画した動画）
    filename_out = filename_in.replace('.MOV','.mp4')
    # ラベル
    label        = file[1]
    # カウント開始/終了秒数
    count_start  = file[2]
    count_end    = count_start + count_time
    print(filename_in,filename_out,label,count_start,count_end)
    # 提灯カウンターをコール
    count_chochin(filename_in,filename_out,label,count_start,count_end)```
```

# 音声を追加
動画を１フレームずつ処理し、物体検知した結果を、新しい動画として作成したが、音声がないので、オリジナル音声を追加します。
```python
from moviepy.editor import VideoFileClip, AudioFileClip

# ファイルリストのループ
for file in file_list:
    # 音声ファイル（オリジナル動画）
    filename_audio  = f'data/{file[0]}'
    # 動画ファイル（認識結果動画）
    filename_movie  = filename_audio.replace('.MOV','.mp4')
    # 音声を追加した認識結果動画ファイル
    filename_out    = filename_movie.replace('.mp4','_count.mp4')
    print(filename_audio,filename_movie,filename_out)

    # 動画ファイルを指定
    video_clip = VideoFileClip(filename_movie)
    # 音声ファイルを指定
    audio_clip = AudioFileClip(filename_audio)

    # 音声をセット
    final_clip = video_clip.set_audio(audio_clip)
    # 音声を追加した動画を書き込み
    final_clip.write_videofile(filename_out, codec='libx264', audio_codec='aac')
```
物体検知するために定義したファイルリストをそのままループさせ、１動画ファイルずつ処理します。処理内容は、VideoFileClipに、AudioFileClipの音声をセットするだけになります。

# 提灯カウンターの処理結果
各字毎に、
* 提灯に描かれた文字の認識のしやすさ
* 交差点のどのコースをまがっていくか
* カメラ手前でとどまってくれるか

などにより、正しく認識できた提灯の数は、各字毎で大きく異なります。

| 字 | 正しく認識できた数 |
| :-: | --: |
| 本町 | 128,867個 |
| 亀谷 | 108,698個 |
| 竹田 | 48,689個 |
| 松岡 | 41,063個 | 
| 根崎 | 110,297個 |
| 若宮 | 82,436個 |
| 郭内 | 85,497個 |

詳細は、動画をご確認ください。
<iframe width="800" height="500" src="https://www.youtube.com/embed/NDDjw_C64fk?si=vxyHzqRFGMssLscE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

# 考察
* 去年の結果からも、「本町」の認識はそもそもよかったが、山車がカメラの手前でとどまってくれたので、さらに有利に働き、12万個以上の提灯を認識できた。
* 「亀谷」は、「本町」と同じようなコースをたどり、そこそこ認識できたが、「龜」「谷」の二文字なので認識が難しく、本町には及ばなかった。
* 「竹田」「松岡」は、どちらも**やんわり**（ゆっくり、とか、ちょっとずつ、とかの意味の土地言葉、特にお祭りで使われる。）曲がっていき、カメラから遠く、かつ、あっさり過ぎ去っていったので、認識できた提灯の数は低迷していた。**やんわり**曲がる場合に、遠くの小さい提灯を認識せざるを得ないケースでは、ほぼ「亀谷」と誤認識している。「龜」の字がごちゃごちゃしているので、遠目の小さい提灯はみんな「龜」に見えているのではないか？小さい提灯の教師データも作って再学習しないといけない。「松岡」は、近づいてきても「若宮」と誤認識するケースが多かった。松の異体字「枩」も「若」も横線が多く似ているためと考えられる。
* 「根崎」は、「本町」と同じようなコースをたどったが、90度曲がる際に時間がかかり、正面からの映像が多く、映り込む提灯の数が少なかったため、結果的に、認識できた提灯の数が「本町」に及ばなかったのだろう。
* 「若宮」「郭内」は、90度まがる位置が、カメラからちょっと遠かったので、認識が難しかったようである。「郭内」の「郭」も字画が多く、認識が難しいが、カメラ手前でとどまってくれたので提灯が大きく映り、「若宮」超えの認識数となった。
* 「亀谷」「根崎」の動画に、交通整理のおじさんのヘルメットが映り込み、それを提灯と誤認識ケースが多発した。本来は、この原因究明と対策や、カウント方法の改善が必要だが、趣味でやっているので、このままにしておく。（ちょっと面白いので、）

【参考】去年の動画
<iframe width="800" height="500" src="https://www.youtube.com/embed/MWK_GVe1LWI?si=NL3bjE3zZHO1dbqp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

# おわりに
360年続く伝統のお祭りでは、提灯のデザインをモデルチェンジすることはありませんので、今年は教師データを作らず、学習モデルも去年のまま使用しました。しかし、動画として連続静止画を何枚も画像認識すると、さすがに認識率の悪さが目立つようにもなり、再学習が必要かなと思いました。来年は、教師データをとり直して、ラベリングし、再学習して認識率改善できるように努めたいなぁ。