【Python】Xリーグスタッツ自動抽出器を作ってみた（OCR、正規表現）

こんにちは。
ノジマ相模原ライズというアメフトチームでアナライズを担当している冨上と申します。

アメフトでデータを活用する取り組みをいくつか行っているので、紹介して参ります。

これまでの取り組みもぜひご覧ください。

https://qiita.com/ryojihimeno/items/73c3a201375aaa419492
https://qiita.com/ryojihimeno/items/6cb013f8b847a4864a87
https://qiita.com/KentaroTokami/items/96002901ee2d18fdad89

今回は[Xリーグの公式HP](https://xleague.jp/)から試合のスタッツを抽出してみました。

# 背景
アメフトの試合の分析を行うにあたって必要となるものは、プレーの結果を数値化したスタッツです。
近年のスポーツ業界では、リーグとしてデータの活用を行ったり、外部の企業がデータをまとめて提供したりしています。
Xリーグではまだそういった仕組みはなく、PDF化されたデータがHPで公開されているだけです。
PDFデータのままでは分析を行ったり、可視化することはできません。
そこで今回は現状唯一入手できるPDFデータをOCRを用いてcsvファイルに変換し、分析できる形にすることを目指します。

スタッツのPDFファイルを取得→PDFを画像に変換→OCRでテキストへの変換→正規表現によるパターンマッチングでスタッツ部分の抽出→csvファイルへの変換
という流れになります。

# 実行環境
- Windows10
- python3.8.5
- jupyter lab 2.2.6
今回はjupyter lab上で実行しました。


# 実装
まずは[Xリーグの公式HP](https://xleague.jp/)から試合詳細をダウンロードします。

続いて、jupyter labを起動し、pythonでコードを書いていきます。

OCRの実行は以下のリンクを参考にしました。
https://qiita.com/Tak3315/items/4cf6bc0ee011048f1424

まず、使用するライブラリをインポートしていきます。

```python
# 必要なライブラリのインポート
import os
import pathlib
from pathlib import Path
from pdf2image import convert_from_path
from PIL import Image
import sys
import pyocr
import pyocr.builders
import pathlib
import glob
import pandas as pd
import re
import numpy as np
```

次にダウンロードしたPDFを画像に変換します。


```python
def pdf_to_image(pdf_name):
    # poppler/binを環境変数Pathに追加する(一時的に)
    poppler_dir = pathlib.Path("__file__").parent.resolve() / "poppler/bin"
    os.environ["PATH"] += os.pathsep + str(poppler_dir)
    # PDFファイルのパスを取得する
    pdf_dir = pathlib.Path('./pdf_file')
    pdf_path = pdf_dir / pdf_name
    # PDF -> Imageに変換(200dpi)
    pages = convert_from_path(str(pdf_path))
    # 画像ファイルを１ページづつ保存
    if not os.path.exists("./image_file/{0}".format(pdf_name)):
        os.mkdir("./image_file/{0}".format(pdf_name))
    image_dir = pathlib.Path("./image_file/{0}".format(pdf_name))
    for i, page in enumerate(pages):  # enumerate関数でpagesのpage数を取得
        # .stemでpathの末尾を表示（pathlib)
        file_name = pdf_path.stem + "_{:02d}".format(i + 1) + ".jpeg"
        image_path = image_dir / file_name
        # JPEGで保存
        page.save(str(image_path), "JPEG")
```

画像をテキストファイルに変換します。

```python
def image_ocr(pdf_name):
    # tesseract-OCRのパスを通す
    tessera_path = "C:\Program Files\Tesseract-OCR"
    # pathsepは環境変数に追加するときの区切り；
    os.environ["PATH"] += os.pathsep + str(tessera_path)
    tools = pyocr.get_available_tools()
    if len(tools) == 0:
        print("No OCR tool found")
        sys.exit(1)  # 引数1は終了ステータスで１を返す
    tool = tools[0]

    # ocr対象のファイルがあるディレクトリ
    image_dir = pathlib.Path("./image_file/{0}".format(pdf_name))
    # globでディレクトリ内のjpegファイルをリストで取得
    jpg_path = list(image_dir.glob('**/*.jpeg'))
    if not os.path.exists("./txt_file/{0}".format(pdf_name)):
        os.mkdir("./txt_file/{0}".format(pdf_name))

    for i in jpg_path:
        # ocrした内容を変数txtにする
        txt = tool.image_to_string(
        Image.open(str(i)),
        lang="jpn",
        builder=pyocr.builders.TextBuilder(tesseract_layout=6)
        )
        # 変数txtをtxt_fileディレクトリにtxtファイルで保存

        with open("./txt_file/{0}/".format(pdf_name) + str(i.stem) + '.txt', mode='wt') as t:
            t.write(txt)
```

ここからは正規表現を用いてデータの抽出を行います。
テキストファイルを一行ごとに処理して各スタッツを抽出していきます。
まずはどのようなファイルが生成されたか確認します。

```python
path = './txt_file/rise_ibm_06.txt'

with open(path) as f:
    lines = f.readlines()
lines_strip = [line.strip() for line in lines]
```
lines_stripの中身はこのようになっています。

```
['IBM BigBlue(BB) vs ノジマ相模原ライズ(NR)',
 '2020/11/23(月) 会場 : 富士通スタジアム川崎',
 'Play by Play                                   Second Quarter',
 'ノジマ相模原ライズ 12:00',
 '2&12-NR42 M      RUN  #6 KURT PALANDECH 0yラン(#35 Gamboa Herbert)- No Play',
 '+Penalty NR #67 ホールディング 10y 久退',
 '2&22-NR32 M     PASS  #6 KURT PALANDECH パス失敗',
 '3&22-NR32 M     PASS  #6 KURT PALANDECH パス失敗',
```

リストにプレー内容と結果が格納されています。
このままでは会場情報等プレーには直接関係ない内容が含まれているので、プレー結果が含まれている要素にのみを抽出します。
＆やPenalty等プレーに関係してそうな文字列を含む行を抽出しています。

```python
l_XXX = [line for line in lines_strip if ('&' in line) or ('Penalty' in line) or ('Kick-off' in line) or ('Extra Point' in line) or ('TIMEOUT' in line) or (':' in line) or ('Quarter' in line)]
```

ここでの要素抽出は、すべてのプレーを網羅できていない可能性があるのでアップデートする必要がありそうです。

続いて各要素からスタッツを入手します。
まずはフィールドポジションの抽出を試みます。

pythonのreモジュールを用いて求めている文字列が含まれているか調べます。
「NR32M」のような文字列を抽出できればNR陣32yard、ハッシュミドルにボールがあるとわかるので、アルファベット2文字、数字1文字以上、LMRのいずれかがつながった文字列を探します。

```python
fp = re.search(r'\w\w\d+[LMR]', s)
```

情報を含む文字列を陣地、ヤード、ハッシュに分割します。

```python
if fp is not None:
    fp_all = fp.group()
    yard = re.search(r'\d+', fp_all)
    y = yard.group()
    position = fp_all[0:2]
    Hash = fp_all[-1]
```

以上のコードを関数化すると以下のようになります。

```python
def get_fieldposition(s):
    """
    陣地、ヤード、ハッシュを抽出する
    """
    position = np.nan
    y = np.nan
    Hash = np.nan
    fp = re.search(r'\w\w\d+[LMR]', s)
    if fp is not None:
        fp_all = fp.group()
        yard = re.search(r'\d+', fp_all)
        y = yard.group()
        position = fp_all[0:2]
        Hash = fp_all[-1]
    return position, y, Hash
```

他のスタッツも同様に正規表現を用いて抽出します。

```python
def get_downdistance(s):
    """
    ダウンディスタンスを抽出する
    """
    down = np.nan
    distance = np.nan
    dd = re.search(r'[1234]&\d+', s)
    if dd is not None:
        dd_split = re.split("&", dd.group())
        down = dd_split[0]
        distance = dd_split[1]
    return down, distance
```

```python
def get_gain(s):
    """
    獲得ヤードを抽出する
    """
    gain = np.nan
    yardy = re.search(r'-*\d+y', s)
    if yardy is not None:
        yard = re.search(r'-*\d+', yardy.group())
        gain = yard.group()
    return gain
```

```python
def get_playtype(s):
    """
    プレーのタイプを抽出する
    """
    play_type = np.nan
    if "RUN" in s:
        play_type = "Run"
    elif "PASS" in s:
        play_type = "Pass"
    elif "FG" in s:
        play_type = "FG"
    elif "PUNT" in s:
        play_type = "Punt"
    elif "Kick-off" in s:
        play_type = "KO"
    elif "Extra" in s:
        play_type = "Extra Pt."
    else:
        pass
    return play_type
```

```python
def get_offense_team(s):
    """
    攻撃チームを抽出する
    """
    offense_team = np.nan
    ot = re.search(r'\d+:\d+', s)
    team_dict = {"パナソニックインパルス": "PI", "東京ガスクリエイターズ": "TG",
                "ノジマ相模原ライズ": "NR", "IBMBigBlue": "BB"}
    if ot:
        offense_team = re.search(r'\D+', s).group()
        offense_team = offense_team.replace("Visitor", "")
        for k, v in team_dict.items():
            if offense_team == k:
                offense_team = v
    return offense_team
```

```python
def get_quarter(s):
    """
    クウォーターを抽出する
    """
    quarter = np.nan
    q = re.search(r'Quarter', s)
    if q:
        fq = re.search(r'First', s)
        sq = re.search(r'Second', s)        
        tq = re.search(r'Third', s)        
        yq = re.search(r'Fourth', s)
        if fq:
            quarter = "1"
        elif sq:
            quarter = "2"
        elif tq:
            quarter = "3"
        elif yq:
            quarter = "4"
    return quarter
```

スタッツを抽出する関数ができたら一つの関数にまとめます。
最終的にはヘッダーを持つDataFrameに変換したいのでここではdictをリターンするようにします。

```python
def get_stats_dict(s):
    """
    正規表現によるスタッツの抽出をまとめる
    """
    position, y, Hash = get_fieldposition(s)
    down, distance = get_downdistance(s)
    gain = get_gain(s)
    play_type = get_playtype(s)
    offense_team = get_offense_team(s)
    quarter = get_quarter(s)
    stats_list = [("position", position), ("YARD LN", y), ("HASH", Hash),
                  ("DN", down), ("DIST", distance), ("GN/LS", gain),
                  ("PLAY TYPE", play_type), ("offense team", offense_team),
                  ("QTR", quarter),]
    stats_dict = dict(stats_list)
    return stats_dict
```

PDFファイルのパスからDataFrameが入手できるよう、さらに関数をまとめます。

```python
def get_data(path):
    """
    PDFファイルからスタッツデータフレームに変換する
    """
    with open(path) as f:
        lines = f.readlines()
    lines_strip = [line.strip() for line in lines]
    l_XXX = [line for line in lines_strip if ('&' in line) or ('Penalty' in line) or ('Kick-off' in line) or ('Extra Point' in line) or ('TIMEOUT' in line) or (':' in line) or ('Quarter' in line)]
    remove_space = [line.replace(' ', '') for line in l_XXX]
    df = pd.DataFrame(remove_space, columns=["test"])
    stats_list = [get_stats_dict(df["test"][i]) for i in range(len(df["test"]))]
    stats_df = pd.DataFrame(stats_list)
    return stats_df
```

攻撃チーム、クウォーターの情報は各行には含まれていないので、切り替わったタイミングをもとに補完していきます。

```python
def cleandata(pdf_name):
    """
    不必要なデータを削除、足りていない部分を補完
    """
    df = pd.DataFrame(columns=["position", "YARD LN", "HASH", "DN", "DIST", "GN/LS", "PLAY TYPE", "offense team", "QTR"])
    path_list = glob.glob('./txt_file/{}/*'.format(pdf_name))
    for path in path_list:
        df = pd.concat([df, get_data(path)])
    df = df.dropna(how='all')
    df = df.reset_index(drop=True)
    for i in range(len(df["offense team"])):
        if df["offense team"][i] is np.nan:
            if i >= 1:
                h = i - 1
                d = df["offense team"][h]
                while d is np.nan and h > 0:
                    h -= 1
                    d = df["offense team"][h]
                df["offense team"][i] = d
    for i in range(len(df["QTR"])):
        if df["QTR"][i] is np.nan:
            if i >= 1:
                h = i - 1
                d = df["QTR"][h]
                while d is np.nan and h > 0:
                    h -= 1
                    d = df["QTR"][h]
                df["QTR"][i] = d
    df = df.dropna(subset=["QTR"]).reset_index(drop=True)
    df = df.dropna(subset=["position","YARD LN", "HASH", "DN", "DIST", "GN/LS", "PLAY TYPE"], how="all").reset_index(drop=True)
    team_list = df["offense team"].unique()
    df.loc[df["offense team"] == team_list[0], "defense team"] = team_list[1]
    df.loc[df["offense team"] == team_list[1], "defense team"] = team_list[0]
    return df
```

最後に今回作った関数をまとめて、保存したPDFファイル名からcsvファイルを出力できるようにします。

```python
def stats_csv(pdf_name):
    """
    最後に関数をまとめ、PDFファイル名からCSVファイルを作成する
    """
    pdf_to_image(pdf_name)
    image_ocr(pdf_name)
    df = cleandata(pdf_name)
    df.to_csv("./csv_file/" + pdf_name + '.csv', encoding="shift-jis")
```

```python
stats_csv("rise_ibm.pdf")
```

最終アウトプットは以下のようになります。

|  | position | YARD LN | HASH | DN | DIST | GN/LS | PLAY TYPE | offense team | QTR | defense team |
|:-----------:|:------------:|:------------:|:------------:|:------------:|:------------:|:------------:|:------------:|:------------:|:------------:|:------------:|
| 0 |    |    |   |   |    | 17 | KO | NR   | 1  | BB |
| 1 | BB | 31 | R | 1 | 10 |    | PASS | BB | 1 | NR |
| 2 | BB | 31 | R | 2 | 10 | 11 | PASS | BB | 1 | NR |
| 3 | BB | 31 | L | 1 | 10 | 7  | PASS | BB | 1 | NR |
| 4 | BB | 31 | R | 2 | 3  | 10 | PASS | BB | 1 | NR |



# 使い道
扱いやすいcsv形式のデータがあれば、いろいろな使い方ができます。
Hudl（動画共有プラットフォーム）へのインポートが可能になったりcsvファイルをエクセルやpythonで分析にかけることができます。

# 今後の展望
正規表現での抽出はすべてを網羅できていない可能性があり、もっと良い抽出方法がありそうです。また、元のPDFファイルにある情報で使えるものもまだまだあるはずです。

本来ならば今回のような手間をかけずに、扱いやすい形式でのデータ提供があることが一番です。
扱いやすい形式のデータの要求を続けつつ、現状で入手可能なデータを扱うという2方向からのアプローチを進めていきたいと思います。

また、ノジマ相模原ライズでは一緒に活動してくれるエンジニアを募集中です！
スタッフ申し込みは[こちらのフォーム](http://www.sagamihara-rise.com/recruit/recruit_staff.html)からお願いします。


# 参考資料

https://qiita.com/Tak3315/items/4cf6bc0ee011048f1424

https://punhundon-lifeshift.com/tesseract_ocr_pdf

https://qiita.com/hiroyuki_mrp/items/29e87bf5fe46de62983c

https://xleague.jp/score