kaggle 「1st and Future - Player Contact Detection」の解法(ラグビーの試合における接触検知)

# はじめに

　皆さんこんにちは、今回はkaggleの「1st and Future - Player Contact Detection」
について解説していこうと思います。

本コンペ、ラグビーが好きな方はワクワクしたのではないでしょうか？！
そうです！今回のコンペの内容は、ラグビーの試合における選手の接触検知なのです！
ラグビーというと、

「肉体と肉体が火花を散らしながら相手陣地へとボールを運ぶ！！」

というかなり激しいスポーツです。ただ、この激しい戦いが観客を魅了してやみません！！
そんな素敵なプレーで私たちを熱くさせてくれる選手たちが怪我をしてしまったら大変ですよね...？
ということで、怪我のリスクから選手たちを守るために検知頑張っちゃいましょう！

# データについて

　今回のコンペは、試合の切り抜き動画とテーブルデータの二つが与えられています。
切り抜き動画は、

・「Endzone」(縦方向に移している)
・「Sideline」(横方向に移している)
・「All」(全体を映している)

の三種類、テーブルデータは

・「train_baseline_helmets.csv」(主に頭部bbox(バウンディングボックス)の情報)
・「train_video_metadata.csv」(動画の基本情報)
・「train_player_tracking.csv」(主に選手のアノテーション情報)
・「train_labels.csv」(今回の目的変数の情報)

の四種類が与えられています。

※(注釈)
labels以外はtestデータも同じものが与えられています。

これらのデータは全て、ゲーム識別番号やプレイヤーIDなどで紐付けされているので、連動させて扱います。

# 解法の方針

　今回は、以下の二つの解法

　「予測しなくてはいけない組み合わせを全て切り出して接触判定をする方法」

　「フレームごとに接触判定をして、後に選手の位置情報などから接触した個人を特定する方法」

　で検知を行いたいと思います。前者の解法のメリットは

・ダイレクトに予測が可能
・接触判定が二つ以上にも対応できる

の二つで、後者の解法のメリットは

・CNNでの検知難易度が低い
・切り取り画像が少なくて済む

の二つです。
どちらも実験しますが、せっかくテーブルデータが与えられているのと、学習時間・データ量を考慮して後者で予測してみようと思います。

# 1 選手情報の切り抜き

　まずは動画データを処理します。具体的には、動画をフレームごとに切り分け、選手のbboxを拡大してその部分の画像を切り取ります。
また、どの選手とどの選手(または地面)が接触したのかの予測のために、画像に対応するラベルデータ、選手同士のbboxのL2距離、選手同士の盤面位置のL2距離を同時取得します。

L2を計算するのに必要なボックスの中心点は与えられていないので追加します。
コードはこちらです。
```center.py
tr_helmets['y_position']=tr_helmets['top'].values-(tr_helmets['height'].values/2)
tr_helmets['x_position']=tr_helmets['left'].values+(tr_helmets['width'].values/2)
```

全体のコードはこちらです。

```get_img_label.py

import os
import shutil
import gc
import cv2
import math


def get_img_label(meta,tr_helmets,labels,track,num_set,view='Endzone'):

    labels_all=[]
    labels_=[]
    img_path=[]
    helmets_distance=[]
    position_distance=[]
    
    meta=meta[meta['view']==view]

    if not os.path.exists('/kaggle/working/out/'):
        os.mkdir('/kaggle/working/out/')     

    for i in range(num_set):
        base=meta['game_play'].iloc[i]
        path='/kaggle/working/'+str(base)
        out_path='/kaggle/working/out/'

        if not os.path.exists(path):
            os.mkdir(path)

        if not os.path.exists(out_path):
            os.mkdir(out_path)
  
        !ffmpeg -i ../input/nfl-player-contact-detection/train/{base}_Endzone.mp4 /kaggle/working/{base}/output_end_%02d_.jpg
    
        meta_tmp=meta[meta['game_play']==base]
        labels_tmp=labels[labels['game_play']==base]
        track_tmp=track[track['game_play']==base]
      
        #動画の、初め、終わり、labelのついてるタイミング、動画全体の時間、を生成

        start=pd.to_datetime(meta_tmp['start_time'].iloc[0]).timestamp()
        end=pd.to_datetime(meta_tmp['end_time'].iloc[0]).timestamp()
        ln=pd.to_datetime(labels_tmp['datetime'].iloc[0]).timestamp()
        time=end-start

        #frame数を取得
        frames=len(os.listdir(path))

        #labelのある瞬間の画像を生成していく
        #動画に関する時間と総フレーム数を使ってlabelありのフレームを特定する
        #特定したフレームのbboxデータを取り出す。

        for n in range(len(labels_tmp['datetime'].unique())-1):
            path_n=path+'/output_end_'+str(frame)+'_.jpg'
            img=cv2.imread(path_n)

            p=0.1*n+(ln-start)
            frame=int((p/time)*frames)
            df=tr_helmets[(tr_helmets['game_play']==base)&(tr_helmets['view']==view)&(tr_helmets['frame']==frame)] 
            df2=tr_helmets[(tr_helmets['game_play']==base)&(tr_helmets['view']=='Sideline')&(tr_helmets['frame']==frame)]

            if len(df)>=1:                
                #dfの数、すなわちframeに紐ずいているbboxの数だけ画像切り取る
                #生成画像を512*512サイズにリサイズ   
  
                for nn in range(len(df)):
                    t,l,h,w=df['top'].iloc[nn],df['left'].iloc[nn],df['height'].iloc[nn],df['width'].iloc[nn]

                    label=labels_tmp[(labels_tmp['step']==n)&((labels_tmp['nfl_player_id_1']==df['nfl_player_id'].iloc[nn]))]
       
                    if (int(t+h/2)-256)<0:
                        left=0                    
                    else:
                        left=int(t+h/2)-256
                        
                    if (int(l+w/2)-256)<0:
                        under=0
                    else:
                        under=int(l+w/2)-256

                    img_n=img[left:int(t+h/2)+256,under:int(l+w/2)+256]
                    img_n= cv2.resize(img_n, dsize=(512,512))
                    cv2.imwrite(out_path+'_'+str(base)+'_'+str(n)+'_'+str(nn)+'_.jpg', img_n)
                    img_path.append(out_path+'_'+str(base)+'_'+str(n)+'_'+str(nn)+'_.jpg')
                          
                    label=labels_tmp[(labels_tmp['step']==n)&((labels_tmp['nfl_player_id_1']==df['nfl_player_id'].iloc[nn]))]#|(labels_tmp['nfl_player_id_2']==df['nfl_player_id'].iloc[nn]))]   
                    player_position=track[(track['game_play']==base)&(track['nfl_player_id']==df['nfl_player_id'].iloc[nn])&(track['step']==n)][['x_position','y_position']]
                                
                    for i in range(len(label)):
                            
                        if not label['nfl_player_id_2'].iloc[i]=='G':
                            ids=int(label['nfl_player_id_2'].iloc[i])                          
                            player2_helmet=tr_helmets[(tr_helmets['nfl_player_id']==ids)&(tr_helmets['game_play']==base)&(tr_helmets['frame']==frame)]
                            player_position2=track[(track['game_play']==base)&(track['nfl_player_id']==ids)&(track['step']==n)][['x_position','y_position']]
                                
                            if (len(player2_helmet)==0)|(len(player2_helmet)==0):
                                position_distance.append(300)                 
                                helmets_distance.append(300)
                            else:
                                l2_distance=math.sqrt((player_position['x_position'].iloc[0]-player_position2['x_position'].iloc[0])**2+(player_position['y_position'].iloc[0]-player_position2['y_position'].iloc[0])**2)
                                l2_distance_helmet=math.sqrt((df['x_position'].iloc[nn]-player2_helmet['x_position'].iloc[0])**2+((df['y_position'].iloc[nn]-player2_helmet['y_position'].iloc[0]))**2)

                                position_distance.append(l2_distance)
                                helmets_distance.append(l2_distance_helmet)
                        else:                          
                            position_G=df2[df2['nfl_player_id']==df['nfl_player_id'].iloc[nn]]['y_position']
                            helmets_G=tr_helmets[(tr_helmets['nfl_player_id']==df['nfl_player_id'].iloc[nn])&(tr_helmets['game_play']==base)&(tr_helmets['frame']==frame)&(tr_helmets['view']=='Sideline')]['y_position']

                            if (len(position_G)>0)&(len(helmets_G)>0):
                                position_distance.append(position_G.iloc[0])
                                helmets_distance.append(helmets_G.iloc[0])    
                            else:
                                position_distance.append(df['y_position'].iloc[0])      
                                helmets_distance.append(df['y_position'].iloc[0])

                        if label['contact'].iloc[i]==1:   
                            print(ids)
                            print(l2_distance)
                            print(l2_distance_helmet)
                            
                    if len(label[label['contact']==1])>=1:#&(labels_all[0]['nfl_player_id_2']!='G'):
                        labels_.append(1)
                    else:
                        labels_.append(0)
                        
                    labels_all.append(label[['contact','contact_id']])
                  
                    del img_n
                    gc.collect()
            del img
            gc.collect()
        shutil.rmtree(path)
    
    return img_path,labels_all,labels_,position_distance,helmets_distance

```
　始めに、出力用のディレクトリの作成と動画の切り出しを行います。次に、選手のbboxを拡大させフレーム画像を切り取り、512*512サイズに直して保存します。最後に、そのフレームにおける選手の組み合わせで、

・お互いのヘルメットのL2距離

・お互いの盤面上でのL2距離

の二つを計算し、接触ラベルと共にリストに保存します。画像のサイズですが、衝突の見逃しが少ないように512＊512と大きめに切りだしています。(recallが高くなれば良し)
ただ、学習時間やメモリのことを考えれば、256＊256くらいの切り出しの方が良いかもしれません。

※追記
　今回のように大きめの画像を大量に扱う場合の注意点なのですが、生成してそのままリストに保存しようとすると一瞬でメモリが逝ってしまいます。（笑)
ですので、画像はディスクに書き出し、リストにはパスを入れて返してあげてください。
また、切り抜き画像以外の不要な画像はshutilでディレクトリごと消去し、imgオブジェクトはdelで明示的に消去することもお勧めいたします。(メモリ・容量との闘い...)


# 2 データローダーの作成

　次に、学習・推論で使用するデータローダーを作成します。実際のコードはこちら。


```DataLoader.py

from torchvision import transforms

class dataset(torch.utils.data.Dataset):
    
    def __init__(self,transform,data,labels):
        
        self.transform=transform
        
        self.imgs=data
        
        self.labels=labels

    def __len__(self):  
        return len(self.imgs)
    
    def __getitem__(self,idx):
        return self.item(idx)
    
    def item(self,idx):     
   
        img_path=self.imgs[idx]

        img=cv2.imread(img_path)

        img=self.transform(img)

        label=self.labels[idx]

        return img,label

def make_dataloader(batch,x,y,test=False):

    if test==False:
        transformer=transforms.Compose([transforms.ToTensor(),
                                    transforms.RandomHorizontalFlip(),
                                    transforms.Normalize(std=[0.5,0.5,0.5],mean=(0.5,0.5,0.5))])
    else:
        transformer=transforms.Compose([transforms.ToTensor(),
                                        transforms.Normalize(std=[0.5,0.5,0.5],mean=(0.5,0.5,0.5))])       
    
    train_dataset=dataset(transformer,x,y)  

    train_dataloader=torch.utils.data.DataLoader(train_dataset,batch_size=batch,shuffle=True)
    
    return train_dataloader


```
ポイントは、

・transformersはhorizontalfripが今回の画像にあってそうなのでtrainにのみ実装。
・batchがepochごとにいじれるようにmake_dataloader関数を作成

の二つです。transformersは上手くいかなければ追加していきます。また、サイクルごとにbatchサイズをいじることで、疑似的に学習率を変動させます。

※追記
　すいません、今回のようなサイズの大きい画像を扱う場合、マシンスペックが弱いとbatchサイズをあまり大きくできないので素直にスケジューラーを組んだ方がいいです。

# 3 modelの定義

まずはシンプルなCNNを組んで試してみます。実際のコードはこちらです。

```model_cnn.py
import torch
import torch.nn as nn


class cbr(nn.Module):
    
    def __init__(self,in_ch,out_ch,kernel,stride,padding):
        
        super(cbr,self).__init__()
        
        self.conv=nn.Conv2d(in_ch,out_ch,kernel,stride,padding)
        
        self.BN=nn.BatchNorm2d(out_ch)
        
        self.relu=nn.ReLU(inplace=True)
        
        
    def forward(self,x):
        
        out=self.conv(x)
        
        out=self.BN(out)
        
        out=self.relu(out)
        
        return out

class model(nn.Module):
    
    def __init__(self):
        
        super(model,self).__init__()
        
        self.conv1=cbr(3,64,4,2,1)
        
        self.conv2=cbr(64,128,4,2,1)
        
        self.conv3=cbr(128,256,4,2,1)
        
        self.conv4=cbr(256,512,4,2,1)
        
        self.conv5=cbr(512,1024,4,2,1)
        
        self.conv6=cbr(1024,1024,4,2,1)
        
        self.ln=nn.Linear(524288,1)
        
        self.sigmoid=nn.Sigmoid() 
         
        
    def forward(self,x):
        
        out=self.conv1(x)
        
        out=self.conv2(out)
        
        out=self.conv3(out)
        
        out=self.conv4(out)
        
        out=out.view(out.size(0),-1)
        
        out=self.ln(out)
        
        out=self.sigmoid(out)
        
        return out

```
　始めに畳み込み、バッチ正規化、reluをCBRクラスにまとめてから組んでいきます。(スイッチを追加すると、コンパクトながらの多様性が得られるので個人的に大好きです)
もし過学習を起こしたらdropoutを後から追加します！！

# 4 Baseline作成

　さて、やっとこさモデルとデータセットができましたので、Baselineを作成していきます。
条件は、

・損失関数はbinary_crossentropy(二値分類のため)
・optimizerはAdam
・評価関数はf1_score(recallとpresitionも見たいのでこちらも計算します)
・modelはCNN
・バリデーション設計はshuffleありのStratifiedKFold(偏りが大きいので)

で組んでいきたいと思います。

　学習データはトレーニングデータのうち6動画を使用します。(本当は全部使いたいのですが、データ処理と学習に時間が莫大にかかってしまうので今回は割愛。)

また今回の注意点として、画像サイズが大きいのでGPUメモリが飛びやすい事が挙げられます。自分でオブジェクトを明示的に消去しメモリを空ける必要があります。(メモリとの闘い...)
これも同じ理由なのですが、validation時にevalモードではなく、torch.no_grad()でくくって検証してください。(計算グラフを出さないことでGPUメモリを死守します)

実際のコードはこちらです。

```baseline.py

import gc
from sklearn.model_selection import  StratifiedKFold

def baseline(img_path,labels,epochs=1):

    device=torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
    print(device)
   
    net=model()  
    optimizer=torch.optim.Adam(lr=0.000001,params=net.parameters())
    criterion=nn.BCELoss()
    
    kf=StratifiedKFold(n_splits=20,shuffle=True,random_state=123)

    for epoch in range(epochs):
        l=0
        for tr_index,va_index in tuple(kf.split(img_path,labels_)):

            loss_all=0

            pp=1e-7
            pf=1e-7
            fp=1e-7
            ff=1e-7

            tr_x,va_x=img_path[tr_index[0]:tr_index[-1]],img_path[va_index[0]:va_index[-1]]
            tr_y,va_y=labels_[tr_index[0]:tr_index[-1]],labels_[va_index[0]:va_index[-1]]
        
            if ((l%2)!=0)|(l==0):
                dataloader=make_dataloader(4,transformer,tr_x,tr_y)       
                net.train()
                phase='train'
                print('mode:train')       
            else:                
                dataloader=make_dataloader(4,transformer,va_x,va_y) 
                phase='eval'      
                print('mode:eval')

            for img,label in dataloader:
          
                        net=net.to(device)
                        img=img.to(device)
                        label=label.unsqueeze(1).to(torch.float).to(device)
                        
                        if phase=='train':   
                            optimizer.zero_grad()   
                            out=net.forward(img)
                            loss=criterion(out,label)
                            loss.backward()
                            optimizer.step()                        
                        else:
                            with torch.no_grad():                                
                                optimizer.zero_grad()
                                out=net.forward(img)
                                loss=criterion(out,label)

                        for i in range(len(out)):
                            if out[i]<0.5:

                                if label[i]==0:
                                    ff+=1
                                else:
                                    pf+=1

                            else:

                                if label[i]==1:
                                    pp+=1 
                                else: 
                                    fp+=1
 
                        loss_all+=loss
        
                        del img,label
                        torch.cuda.empty_cache()
                        gc.collect()
                        
            l+=1

            del dataloader
            torch.cuda.empty_cache()
            gc.collect()
    
            print('train_loss:{}  recall:{} precision:{} f1:{} coef_f1:{}'.format(loss_all/len(tr_x),pp/(pp+fp),pp/(pf+pp),2*pp/(2*pp+fp+pf),score))
```
　torch.cuda.empty_cache()を使用することで、メモリを明示的に開けています。(GPUメモリはdelとgcのみではクリアできません)また、ppなどの初期値は0で割ることを防ぐため、小さな値を設定しています。

学習結果はこちらです。

phase:訓練
|bce_loss|recall|precision|f1|
|---------------|---------------|---------------|---------------|
|0.144049271941185|0.19512343842225155|0.0042804168774299755|0.008377066308467788|
|0.13729138672351837|0.45714310203941694|0.00856078025031779 |0.01680682420024702|
|0.1313302367925644|0.7878779155214681| 0.027822415428312958| 0.05374686227455044|
|0.12627097964286804|  0.798164590523687| 0.04654900518469715|0.08796772740794188|
|0.12092972546815872|  0.8503396594018239|0.06688073120591427|0.12400801110952162|
|0.11582594364881516 |0.9211614762145426| 0.11878013710217902 |0.2104265951797924|
|0.1059025228023529 | 0.9385024393035084| 0.18780099648999501|0.31297372929581285|
|0.10036242753267288 | 0.9773753496039143|0.23113967564048413|0.37386414991533534|
|0.09658543765544891  |0.9854807675205925 |0.29052971743930256|0.4487603390478778|
|0.09215594083070755 | 0.9889238959101596| 0.33440344201140265|0.4998000799999872|
|0.08766843378543854 | 0.9888578025465732| 0.3798823028483357| 0.5488983302824383|
|0.08346831798553467 | 0.992822848606974| 0.4440877534416529 |0.6136783565725166|
|0.07933742552995682|  0.9956755685025798 |0.4927768868082518| 0.6592698411925783|
|0.07486207038164139 | 0.996261589483815| 0.5703584729418435 |0.7254167777588598|
|0.06784945726394653 | 0.9975040769543966| 0.6415195140161034 |0.7808531057176027|
|0.06390105187892914 | 0.9969253655764234 |0.6939539653339791 |0.8182964898048594|
|0.06094752252101898  |0.9971448255325017|0.7474585074950768| 0.8544342074086596|
|0.05456714704632759 | 0.9986763072566106|0.8073835947155061| 0.8928993617870578|
|0.0514361709356308  |0.998724426183109 |0.8378811837473319| 0.9112597717474807|
|0.048653267323970795 | 0.9975323260293244| 0.8651685002494917|0.9266475155704852|


phase:検証
|bce_loss|recall|precision|f1|
|------------|-----------|-----------|-----------|
|0.13431976735591888|0.7499875006249688|0.0016051897693750916|0.00320352307452790|
|0.12787862122058868|0.8275839476969125|0.012938057904252518|0.025477807754180945|
|0.12700285017490387 |0.7862795323063154 |0.16125544791607704|0.26762464883257314|
|0.11630873382091522 |0.9653173811359755 |0.09110751870076174 |0.1665005650048724|
|0.11305831372737885 |0.9497201679104269 |0.09139789339807598|0.16674847145689625|
|0.10862899571657181 |0.9767436316934515 |0.0904198503315554 |0.1655173072872301|
|0.10445758700370789 |0.9951451503445142|0.10974308246862073 |0.1976856899352575|
|0.09891096502542496 |0.9999995901642703 |0.1316783991712467 |0.23271345107993305|
|0.09501539170742035 |0.9867468706634047 |0.4393776889079733 |0.608017801333659|
|0.09015800058841705 |0.9919612566040976 |0.33047671874914636|0.49578143900659877|
|0.08533155918121338 |0.9988037084201655 |0.4482018305741459 |0.6187476667287637|
|0.08449552953243256 |0.9819003798038644 |0.696629192442034 |0.8150234347388501|
|0.07734893262386322 |0.9979230533908509 |0.5147295110091579 |0.6791519181410717|
|0.07352695614099503 |0.996601444927537 |0.6292918316210481 |0.7714567219392671|
|0.07009857147932053 |0.9989527750884241|0.5115281488977856 |0.6765957196318129|
|0.06598962843418121|0.9970413465915168 |0.7247311586310582 |0.8393523861329532
|0.06258811056613922 |0.9983817963783501 |0.6627282317155497 |0.7966429571797344
|0.060013484209775925 |0.9974873746875158 |0.8611713274217649 |0.9243305675983041|
|0.05646476522088051 |0.9981261089161638 |0.8605276940734847 |0.9242336120030524
|0.05321609601378441 |0.9981237650877092 |0.8650406108357062 |0.9268292187190221
|0.04835115000605583|0.9988186061643702 |0.904761861448704|0.9494665413288554|

以上、1epoch分の結果です。
GPU使用で学習時間約6時間...。(コンペ的には超outです（笑）)
もし、phaseをtrainのみに絞っても3時間以上はかかってしまいます。(outですね...)
しかし結果は、1epochで検証時のf1_scoreが0.94と学習時間に見合ったものが得られたのではないでしょうか！？
また、思った通りrecallが高くpresitionを上げられるかどうかといった構図になりました。安全を期すための検知なのでrecallが高いのはいい点だと思います。(もちろんここからの個人の特定が勝負です。)

# ５ 推論

　訓練の結果、どの画像が接触している画像か予測できるので、二つ目の予測の、
「接触した判定の画像では誰と誰(または地面)が接触しているのか」を行います。
作戦としては、

「選手の組み合わせのうち、bbox距離・盤面距離の最小値を保有する組が接触している」

と仮定して、これらの距離を閾値で区切り、その閾値以下を接触している組み合わせとして特定します。
また、もしその閾値以上で画像が接触している判定の場合、地面と接触している可能性が高いとし、地面に接触していると判断します。

上記の条件で最もf1スコアが高い閾値を探索します。まずはラベルに盤面距離とbbox距離の追加です。

(※注釈)
　f1スコアとは、目的変数の割合が不均衡な時に基準にできる精度です。

```research_pre.py
import numpy as np


n=0
ln=[]
#まず、接触した人物がいる画像のラベルをあぶりだし、盤面距離、ヘルメット距離を追加します。
#その後、リストに追加し次のフェーズへ。
for i in range(len(labels_all)):

    if len(labels_all[i][labels_all[i]['contact']==1])>=1:     
   
        labels_all[i]['p']=position_distance[n:n+len(labels_all[i])]
        labels_all[i]['h']=helmets_distance[n:n+len(labels_all[i])]
        labels_all[i]['play']=labels_all[i]['contact_id'].map(lambda x: x.split('_')[4])

        ln.append(labels_all[i])
        
    n+=len(labels_all[i])
```
　次に、閾値探索を行います。今回は閾値の検討が大体ついているのと、二値分類で済む事から、閾値を一定の割合で増やしていく探索法を取ります。(多クラス分類だと閾値学習の方が楽です)

```coef_research.py
#bbox距離で判定する場合、['p']を['h']に変えて実験します。
#分岐１:ある閾値以下で、pの最小値が接触判定となっているpの値に等しいかどうか
#分岐2:ある閾値以上で、接触判定となっている項目が地面であるかかどうか

def coef_train(ln,coef_init=1.0,epochs=40,rate=0.1):
    
    for i in range(epochs):
        coef=coef_init+rate*i        
        pp,pf,fp=1e-7,1e-7,1e-7

        for ln2 in ln:

            if (np.min(ln2['p'])<coef)&(np.min(ln2['p'])==np.min(ln2[ln2['contact']==1]['p'])):
                pp+=1

                if len(ln2[ln2['contact']==1])>=2:
                    pf+=len(ln2[ln2['contact']==1])-1

            elif (np.min(ln2['p'])<coef)&(np.min(ln2['p'])!=np.min(ln2[ln2['contact']==1]['p'])):
                pf+=1
                fp+=1

                if len(ln2[ln2['contact']==1])>=2:
                    pf+=len(ln2[ln2['contact']==1])-1
                    fp+=len(ln2[ln2['contact']==1])-1

            if (np.min(ln2['p'])>=coef)&(ln2[ln2['contact']==1]['play'].iloc[0]=='G'):
                pp+=1

                if len(ln2[ln2['contact']==1])>=2:
                    pf+=len(ln2[ln2['contact']==1])-1

            elif (np.min(ln2['p'])>=coef)&(ln2[ln2['contact']==1]['play'].iloc[0]!='G'):
                pf+=1
                fp+=1

                if len(ln2[ln2['contact']==1])>=2:
                    pf+=len(ln2[ln2['contact']==1])-1
                    fp+=len(ln2[ln2['contact']==1])-1
                    
        f1=2*pp/(2*pp+fp+pf)

        if f1>f1_out:                
                print(f1)
                f1_out=f1                
                coef_out=coef
            
    return f1_out,coef_out

```
探索結果は、

・盤面距離の場合:f1=0.8406779661016949

・bbox距離の場合:f1=0.7637102234258633

でしたので、今回は盤面距離の予測と閾値を採用します。盤面距離での閾値は1.44です。

すなわち、接触したフレームの検知としては0.94のf1精度で接触画像を検知できて、その後の選手の組み合わせ特定においては0.84のf1精度なので、0.78のf1精度で予測可能ということになります。(実際はもう少し変動します。)

ただ、GPU使用時間二時間以内にしないとoutなのでもっと改良が必要です...。

# まとめ

　同時に二つ以上の検知はできないという制約はあるものの、スコアだけはまあまあな値が出せたかとは思います。実際には、同時検知ができないのは困るのでもう一つの「全組み合わせ切り出し」の解法でも上手く精度が出せるように実験します。

また、画像のサイズがだいぶ大きめであったのと、モデルの精査は軽くしか行っておりません。ですので、ここら辺を精度と相談しながら弄るとGPU稼働時間の短縮を図れるのではと思います。

GPU使用権が復活したら引き続き実験してみます。(二日間kaggleはGPU無しです(泣))
　
# ※追記

　お待たせしました、画像サイズを256＊256に落とした実験結果です！！
なんと、CPUで6時間学習でコンペ的にもOKです。最終結果がこちら。

mode:train
|bce_loss|recall|precision|f1|
|------------|-----------|-----------|-----------|
|0.022174743935465813  |0.9985335781426458 |0.9632247628784264 |0.9805614166849772 |

mode:eval
|0.02114269696176052 |0.9999998538012124 |0.9674680431490684 |0.9834649939712454| 

画像サイズ512＊512は過保護でした。（笑）
始め256＊256画像を確認した時、識別可能か不安な画像であったので512＊512にしたのですが、CNN頭いいですね。
また、後者の解法である全パターンを切り出して直接予測する方法ですが、やはり学習難易度が高いらしいです。結果はまだ、0.40程の精度しか出せておりません。
ちゃんと精度を向上させられたら記事にしますのでもうしばらくお待ちください...。