こんにちは。
ノジマ相模原ライズというアメフトチームでアナライズを担当している冨上と申します。
アメフトでデータを活用する取り組みをいくつか行っているので、紹介して参ります。
これまでの取り組みもぜひご覧ください。
https://qiita.com/ryojihimeno/items/73c3a201375aaa419492
https://qiita.com/ryojihimeno/items/6cb013f8b847a4864a87
https://qiita.com/KentaroTokami/items/96002901ee2d18fdad89
今回はXリーグの公式HPから試合のスタッツを抽出してみました。
背景
アメフトの試合の分析を行うにあたって必要となるものは、プレーの結果を数値化したスタッツです。
近年のスポーツ業界では、リーグとしてデータの活用を行ったり、外部の企業がデータをまとめて提供したりしています。
Xリーグではまだそういった仕組みはなく、PDF化されたデータがHPで公開されているだけです。
PDFデータのままでは分析を行ったり、可視化することはできません。
そこで今回は現状唯一入手できるPDFデータをOCRを用いてcsvファイルに変換し、分析できる形にすることを目指します。
スタッツのPDFファイルを取得→PDFを画像に変換→OCRでテキストへの変換→正規表現によるパターンマッチングでスタッツ部分の抽出→csvファイルへの変換
という流れになります。
実行環境
- Windows10
- python3.8.5
- jupyter lab 2.2.6
今回はjupyter lab上で実行しました。
実装
まずはXリーグの公式HPから試合詳細をダウンロードします。
続いて、jupyter labを起動し、pythonでコードを書いていきます。
OCRの実行は以下のリンクを参考にしました。
https://qiita.com/Tak3315/items/4cf6bc0ee011048f1424
まず、使用するライブラリをインポートしていきます。
# 必要なライブラリのインポート
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を画像に変換します。
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))
# 画像ファイルを1ページづつ保存
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")
画像をテキストファイルに変換します。
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は終了ステータスで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)
ここからは正規表現を用いてデータの抽出を行います。
テキストファイルを一行ごとに処理して各スタッツを抽出していきます。
まずはどのようなファイルが生成されたか確認します。
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等プレーに関係してそうな文字列を含む行を抽出しています。
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のいずれかがつながった文字列を探します。
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]
以上のコードを関数化すると以下のようになります。
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
他のスタッツも同様に正規表現を用いて抽出します。
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
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
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
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
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をリターンするようにします。
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が入手できるよう、さらに関数をまとめます。
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
攻撃チーム、クウォーターの情報は各行には含まれていないので、切り替わったタイミングをもとに補完していきます。
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ファイルを出力できるようにします。
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")
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方向からのアプローチを進めていきたいと思います。
また、ノジマ相模原ライズでは一緒に活動してくれるエンジニアを募集中です!
スタッフ申し込みはこちらのフォームからお願いします。
参考資料