はじめに
去年、日本三大提灯祭り 360年続く伝統の二本松提灯祭りの提灯を最新のAIで画像認識してみた を投稿しました。去年に引き続き、今年も、続編となる記事を投稿します。
去年は、静止画を対象としていましたが、今年は、認識した結果を動画に組み立ててご覧いただけるようにしました。また、膨大な数の提灯を認識するので、その数をカウントするようにしました。
学習モデル
去年と同様YOLOにて物体検知しました。学習結果モデルは、去年と全く同じそのものを使用しました。
./yolov5/runs/train/chochin/weights/best.pt
なお、去年は、pythonコマンドでYOLOを動作させましたが、今年は、pythonの関数を呼ぶようにしました。
具体的には、下記の通り、yolov5をimportし、detect.run()で物体検知します。
import yolov5.detect as detect
detect.run(weights='best.pt',source=filename_result,save_csv=True)
提灯カウンター
山車が交差点を曲がる際、提灯がゆらゆらするので認識が難しかろうと、交差点を曲がる際の動画を1分ぶん対象としてカウントします。
このような祭りの山車ものって、まがるところが魅せ場だったりするんですよね。今回、山車がきて人混みができる前に、私がスタンバって待っていると、隣に子連れの若夫婦がいたので、旦那さんに「今からここで待ってるなんて、通だね。」って声をかけたら、「隣町のお祭りで若連やっていて、曲がり角が見ものなんでね。」って言ってました。さすが現役のお祭りストだと思いました。隣町のお祭りも面白いので今度見に行こうっと。
閑話休題、提灯カウンターの話に戻りましょう。
各字によって、曲がり方に特徴があり、あっさり曲がっていく場合もあり、ゆっくり曲がっていく場合もあり、最大1分程度かかるので、カウント対象の動画を1分としました。その1分の開始位置は、各動画でまちまちなので、目視確認後、その開始時間を入力するようにしました。
以下、YOLOで提灯を検知し、正しく認識できた数をカウントアップする関数を作りました。
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:
# 1フレーム読み込み
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 # ファイルが作成されなければ、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)
# 出力用動画に1フレーム追加書き込み
video.write(img)
frameno+=1
else:
video.release()
return 0
return
以下、解説します。
関数の入力パラメータ
入力パラメータは、
- 動画ファイル名称(入力用:物体検知する対象の動画)
- 動画ファイル名称(出力用:提灯を認識した矩形と数を描画した動画)
- ラベル(どの字の動画か?)
- カウント開始秒数
- カウント終了秒数
処理概要は、ざっくりと、動画ファイル名称(入力用)を読み込んで、カウント開始秒数からカウント終了秒数までの各フレームでYOLOで物体検知し、認識した結果が、ラベル(どの字の動画か?)と同じだったらその数をカウントし、ラベル矩形と認識した数を画像に重ね合わせて表示し、動画ファイル名称(出力用)を作成する、といった感じです。
動画処理(入力動画)
opencvで動画ファイルをオープン(cap=cv2.VideoCapture)し、1フレームずつ処理します。(cap.read)後で動画に組み立て直すので、fpsも取得(cap.get)しておきます。また、入力パラメータのカウント開始/終了は秒ですので、fpsを使ってフレーム番号に変換します。
物体検知
detect.run()で物体検知してくれるのですが、ファイルで入力/出力するので、ちょっとめんどかったです。
入力ファイル
メモリのまま画像イメージを渡すことができなかったので、(実はできるのですかね?)一旦ファイルに書き込んで(cv2.imwrite)、そのファイルを物体検知するようにしました。
出力ファイル
検知結果も、メモリでもらうことができなかったので、ファイルに出力したものを、読み込むようにしました。検知した結果のファイルは、以下2ファイル作成されます。
-
result.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)します。その後、1フレームずつ、video.writeで書き込み、最後は、video.releaseで終了します。
動画ファイルのリスト
各字の動画ファイル名と、ラベル、カウント開始秒をリストで定義します。カウント開始秒は、前述の通り、目視で確認しました。カウント終了秒は、全て1分(60秒)なので、共通で60秒を設定します。
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
処理実行
ファイルリストをループし、1動画ずつ、提灯カウンター関数をコールします。iPhoneで撮影したので、.MOVとして保存されていたので、.mp4として処理結果の動画を保存します。ここはちょっと力技になってしまいますが、ファイル名をreplace関数により、'.MOV'⇒'.mp4'に変換しています。
# ファイルリストのループ
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)```
音声を追加
動画を1フレームずつ処理し、物体検知した結果を、新しい動画として作成したが、音声がないので、オリジナル音声を追加します。
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')
物体検知するために定義したファイルリストをそのままループさせ、1動画ファイルずつ処理します。処理内容は、VideoFileClipに、AudioFileClipの音声をセットするだけになります。
提灯カウンターの処理結果
各字毎に、
- 提灯に描かれた文字の認識のしやすさ
- 交差点のどのコースをまがっていくか
- カメラ手前でとどまってくれるか
などにより、正しく認識できた提灯の数は、各字毎で大きく異なります。
字 | 正しく認識できた数 |
---|---|
本町 | 128,867個 |
亀谷 | 108,698個 |
竹田 | 48,689個 |
松岡 | 41,063個 |
根崎 | 110,297個 |
若宮 | 82,436個 |
郭内 | 85,497個 |
詳細は、動画をご確認ください。
考察
- 去年の結果からも、「本町」の認識はそもそもよかったが、山車がカメラの手前でとどまってくれたので、さらに有利に働き、12万個以上の提灯を認識できた。
- 「亀谷」は、「本町」と同じようなコースをたどり、そこそこ認識できたが、「龜」「谷」の二文字なので認識が難しく、本町には及ばなかった。
- 「竹田」「松岡」は、どちらもやんわり(ゆっくり、とか、ちょっとずつ、とかの意味の土地言葉、特にお祭りで使われる。)曲がっていき、カメラから遠く、かつ、あっさり過ぎ去っていったので、認識できた提灯の数は低迷していた。やんわり曲がる場合に、遠くの小さい提灯を認識せざるを得ないケースでは、ほぼ「亀谷」と誤認識している。「龜」の字がごちゃごちゃしているので、遠目の小さい提灯はみんな「龜」に見えているのではないか?小さい提灯の教師データも作って再学習しないといけない。「松岡」は、近づいてきても「若宮」と誤認識するケースが多かった。松の異体字「枩」も「若」も横線が多く似ているためと考えられる。
- 「根崎」は、「本町」と同じようなコースをたどったが、90度曲がる際に時間がかかり、正面からの映像が多く、映り込む提灯の数が少なかったため、結果的に、認識できた提灯の数が「本町」に及ばなかったのだろう。
- 「若宮」「郭内」は、90度まがる位置が、カメラからちょっと遠かったので、認識が難しかったようである。「郭内」の「郭」も字画が多く、認識が難しいが、カメラ手前でとどまってくれたので提灯が大きく映り、「若宮」超えの認識数となった。
- 「亀谷」「根崎」の動画に、交通整理のおじさんのヘルメットが映り込み、それを提灯と誤認識ケースが多発した。本来は、この原因究明と対策や、カウント方法の改善が必要だが、趣味でやっているので、このままにしておく。(ちょっと面白いので、)
【参考】去年の動画
おわりに
360年続く伝統のお祭りでは、提灯のデザインをモデルチェンジすることはありませんので、今年は教師データを作らず、学習モデルも去年のまま使用しました。しかし、動画として連続静止画を何枚も画像認識すると、さすがに認識率の悪さが目立つようにもなり、再学習が必要かなと思いました。来年は、教師データをとり直して、ラベリングし、再学習して認識率改善できるように努めたいなぁ。