スポーツの動画解析_part1_テニスボールの検出

# 目的
動画分析(opencv)したことの備忘録。
スポーツの動画解析(具体的に何をどう進めるかは検討中)をしたいと思い、一旦手元にあるデータでテニスボールのトラッキングを試みた。一旦半日かけてやってみて何が難しそうか見てみようスタンス。

# 参考
qiitaでopencvを使用したボールの検出例の内2件を参考にさせていただきました。
[1]"OpenCV(Python版)でテニスのボール軌道を検出する", https://qiita.com/otakoma/items/04216c60fa31eae60947, 2018
[2] "Python + OpenCVで野球ボールをトラッキング",https://qiita.com/t_okkan/items/e08116d989bd9e241052, 2020

# 使用した動画　
昔観客席からスマホで撮影した、indian wells(ATP1000)の映像。
分析データは違う試合ですが、決勝はフェデラー対ワウリンカの試合をかなりいい席で見れて幸せでした。
[1]の記事の動画と違う点は
1. 斜めから撮影
2. 固定されていないため手振れしている
3. サーブだけでなくラリーが入っている。
4. ラリーの途中でボールが画面の外に出てしまっている。

もっと検出に適したデータを用意したいが、一旦手元にあるデータでトライ。
フレーム切り抜き。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/548cf487-d235-2900-d521-8710ade1d032.png)


# 実施内容
最初は基本的に参考記事に沿うような形でまずやってみました。
・方針：テンプレート画像(テニスボール)を用意して、opencvのmatchTemplate機能でボールを検出する。
・参考の二つの記事の内容を基に、フレーム間の差分を特徴量(以下、差分画像)とすることで、動いている物以外の特徴量を削減した。

差分画像例(分析自体は白黒にして実施しています、下に白黒verもあり)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/8a77af42-0bfb-e6b4-0952-2b7f7b41282c.png)

検出画像
赤い四角が検出結果。四角の左上にはmatchTemplateの結果が緑色で載っている。
![img_example_150.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/edffd68e-9718-844f-180c-4f665f49e5ea.jpeg)



# 出てきた課題点と対策
1. ボール以外の検出が多い。
・単純な丸なので、形の似ているかつ良く動いているものがよく誤検出されました。
　特に選手の足と腰の誤検出が多かったです。

対策1: 元のボールの画像ではなく、差分画像中に検出されたテニスボールをテンプレート画像にする。ただ、動いている箇所(手振れ、選手、観客)が多いかつテニスボールのただの小さい丸なので、根本的に消すのはこの動画で差分画像を用いる検出方法では難しい印象でした。

2. ボールが画像中に見えない(or ボールの動きが無い)画像で違う場所を検知してしまう。

対策2: 検出の閾値を設定(0.6)。閾値以下の場合は検出結果を表示しない。
閾値設定しない場合のmatchTemplateの値は下図のようになる。template valueが大きい時間帯と小さい時間帯が交互に来て波打っている。ボールの有無や画像全体で検出された動きの量の違いでこのようになっていると思われる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/8f4b68d1-713d-1448-45dc-a61286d45b6e.png)

3. ラリー中のボールが速いと、テンプレートと形状が違うため検知できない。
例:ボールが線になっていて、最初の方法ではラリー中の素早いボールは全く検知できませんでした。
![img_example_500_diff.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/e888dcf8-3840-e87b-c048-dcabb1cfc916.jpeg)


対策3: 早いボールを検知するためのテンプレートを作成。affine行列変換で20°毎に回転させて、色々な方向に動くボールを検知する。
![tmp_speedball.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/71dec441-ce70-34a6-c9f4-bdd7a66012b8.jpeg)

# 結果
良かった点：
最初は検出できていなかった、ラリー中のボールも検知できるようになりました！
![img_example_500.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/2eceabf3-e8b5-8046-6549-ca6b5fcd3c51.jpeg)
良くなかった点：
線形な形状の特徴量に対する誤検知が増えてしまいました、、。
![img_example_450.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424989/7ad6825e-db28-44fa-b4aa-0c8fc66a6d8f.jpeg)

# 考えられる改善方法
1. 前のフレームの検出位置を次のフレームの検出位置に紐づける。
   特定の箇所へのmatchTemplateの値をいじれば、前の検出に近いところで検出しやすいように出来そう。
2. 撮影方法をどうにかする。
   手振れとか色々あったので、、
3. テンプレートを工夫する
   色々なボールの動き・形状に対するテンプレートを作れば精度は伸びしろあり。
4. 差分画像に頼らない検出方法
   YOLO等。opencvの方法とYOLOでの違いを比較してみたい。

# 今回学んだこと
・ボール検出系だと、検出物の形状変化に対する汎化性能を持たせることが必要。
・時系列関係を捉えた検出の方がロバストな結果になりそう。既存の方法を調べてみる。
・ニューラルネットワークの物体検出系モデルを使わなくても、データがきれい(撮影点が固定、撮影角度が良い)ならば工夫次第である程度精度は出せそう。

# ソースコード
githubにもあげています。検出動画を表示する部分を下記に共有。
https://github.com/GoGoGonda/sports_movie_analysis/tree/master/tennis_ball_230810

```
#import文
import cv2
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
```
```
#data_path
path = 'C:/Users/imyme/movie_analysis_2310/data/IMG_0527.MOV'

#path for referenced temlate
#tmp_pic = 'C:/Users/imyme/movie_analysis_2310/data/ball1.jpg'

#normal ball data
tmp_path_normal = 'C:/Users/imyme/movie_analysis_2310/data/tmp_normalball2.jpg'

#fast ball data
tmp_path_sp = 'C:/Users/imyme/movie_analysis_2310/data/tmp_speedball.jpg'

#if use tmp_normalball3.jpg, this is True
use_ball3 = False

#case1: apply original image for detection
#tmp_pic = cv2.imread(tmp_path)
#apply gray color
#tmp_pic_gray = cv2.cvtColor(tmp_pic, cv2.COLOR_RGB2GRAY)



#case2: apply normal and speed ball pictures pictured on a differential frame
tmp_ball_list=[]

#read the normal ball pic and convert it to gray scale image
tmp_pic_gray = cv2.imread(tmp_path_normal)
tmp_pic_gray = cv2.cvtColor(tmp_pic_gray, cv2.COLOR_RGB2GRAY)

#append normal pic
tmp_ball_list.append(tmp_pic_gray)

#read fast ball picx
tmp_sp_ball = cv2.imread(tmp_path_sp)
for angle in [20*i for i in range(9)]: 
    #prepare affine conversion matrix
    rotate_matrix = cv2.getRotationMatrix2D(center=(33,33), angle=angle, scale=1)

    #apply affince conversion matrix
    rotated_image = cv2.warpAffine(src=tmp_sp_ball, M=rotate_matrix, dsize=(66, 65))
    
    #reduce dimension
    tmp_pic_gray = cv2.cvtColor(rotated_image, cv2.COLOR_RGB2GRAY)
    
    #speed_ball_listを作成
    tmp_ball_list.append(tmp_pic_gray)

    

#capture video
cap = cv2.VideoCapture(path)

#used to check maxvals
maxvals_record = []

i = 1
while True :

    #get frame info
    ret, img = cap.read()
    
    # break while roup when ret is False
    if ret == False:
        break
    
    #initialization of used variables
    maxLoc = []
    maxVal = 0
    result = []
    maxLocs = []
    maxVals = []
    max_index = 0
    
    #differential frames : img_diff
    if i != 1:
        img_diff=cv2.absdiff(img,img_prev)
        
        #apply gray color for calculation
        img_diff_gray = cv2.cvtColor(img_diff, cv2.COLOR_RGB2GRAY)
        
        #matchtemplate Case1
        #maxLoc has the location of ball
        #result = cv2.matchTemplate(img_diff_gray, tmp_pic_gray, cv2.TM_CCOEFF_NORMED )
        #minVal, maxVal, minLoc, maxLoc = cv2.minMaxLoc(result)

        
        #matchtemplate Case2
        for tmp_pic in tmp_ball_list:
            result = cv2.matchTemplate(img_diff_gray, tmp_pic, cv2.TM_CCOEFF_NORMED )
            minVal, maxVal, minLoc, maxLoc = cv2.minMaxLoc(result)
            maxVals.append(maxVal)
            maxLocs.append(maxLoc)
        
        
        #get maximum value and location
        maxVal = max(maxVals)
        max_index = maxVals.index(maxVal)
        maxLoc = maxLocs[max_index]
        
        #show differential frames
        #cv2.imshow("window2",img_diff)
        #cv2.waitKey(30)
        
        #save image 
        if i % 100 == 0:
            cv2.imwrite(f'C:/Users/imyme/movie_analysis_2310/data/img_example_{i}_diff.jpg',img_diff_gray)
    
    

    #for the record of the maxVal
    maxvals_record.append(maxVal)
    
    #get the previous image here to get differential frame in the next roup
    img_prev = img.copy()
    
    #show ball detection
    #0.6 is threshhold for detection
    if maxLoc != [] and maxVal > 0.6:
        #size of ball3 pic is smaller than ball2
        if max_index == 0 and use_ball3 == True:
            cv2.rectangle(
                img, maxLoc, 
                (maxLoc[0]+32, maxLoc[1]+32),
                color=(0,0,255),
                thickness= 4)
        else:      
            cv2.rectangle(
                img, maxLoc, 
                (maxLoc[0]+66, maxLoc[1]+65),
                color=(0,0,255),
                thickness= 4)
        
        #put text to the pic to show the value
        cv2.putText(img,
            text=f'val:{maxVal:.2f}',
            org=(max(0,maxLoc[0]-30), max(0,maxLoc[1]-30)),
            fontFace=cv2.FONT_HERSHEY_SIMPLEX,
            fontScale=0.5,
            color=(0, 255, 0),
            thickness=1,
            lineType=cv2.LINE_4)
    
    #show movie
    cv2.imshow("window1",img)
    #cannot check the movie w/o waitKey
    cv2.waitKey(30)
    
    #save sample picture
    if i % 50 == 0:
        cv2.imwrite(f'C:/Users/imyme/movie_analysis_2310/data/img_example_{i}.jpg',img)
    
    
    
    i +=1

#close movie
cap.release()
#close windows 
cv2.destroyAllWindows()
```