datapingpongに変態モードを追加しました
さらに本気(変態?)の人たちに向けて、機能を追加しました。
以下のツイートにあるように、本気の織部さんに応えられるよう全打球を記録できるようにしました。
「分析どうやるの?」って質問を受けますが、答えは簡単。
— 織部 隆宏(Takahiro Oribe) (@o_takahiro0212) 2019年12月18日
「記録して調べる」だけ。笑
ただ、目的を何にするかで記録の取り方は変わります。
・着眼点を見つける
・試合の傾向を見る
だけならデータピンポンhttps://t.co/CpbVRYyAvM
本気の時は全ラリー書き出します。
こんな感じに↓↓ pic.twitter.com/oS4jujAvCg
どんなデータになるのかは以下のリンクから実際のデータをご覧ください。2020年の全日本の男子決勝のデータです。
追加した機能
- 全打球情報(打球位置・フォアorバック・打法・コース・インorアウト)を記録可能に
- 打球情報を基にしたグラフを追加
- データの検索ページの追加(以下画像)
このページではユーザーが独自のパターンを設定すると、そのパターンの一覧と得点率がわかります。
サーブのコースや種類を限定し、得点数を表示するのはもちろん、
- 中陣vs中陣のラリーになった場合の得点率
- ミドル前にストップレシーブ→ダブルストップ→チキータからのラリーの得点率
などなど、組み合わせ次第で様々なパターンで集計することができます。
ちなみに全日本男子決勝をご覧になった方は気になるであろう、宇田選手のフォア前サイドへのサーブからの得点数は 11/13 で、得点率85%でした。
このパターンの組み合わせの中から試合を理解するのに有効な指標を見つけられるかどうかが、データを考察する人の腕の見せ所です。
データの入力について
データの入力は分析画面右上のproモードボタンをタップしていただくとおわかりのように、全打球ボタン入力です。(笑)
1打球あたり4回のタップが必要です。
このデータを作成するのに1試合3-4時間はかかります。もしやってみようと思う人がいれば、覚悟して取り組んでください。
自分でやろうと思う人はかなり少ないでしょうが、継続的にデータを作成できればなかなか価値があるデータになると思います。
まとめ
自動化しないとつらい。
datapingpongのapiを作りました
先日apiとかねえの?って聞かれたので調べてみると、Cloud Functionsで簡単に実装できそうだったので作ってみました。 ※エンジニア向けの記事になりますのでご注意ください。
個人のブログで申し訳ありませんが、使用方法の説明をします。
- アクセス先
https://us-central1-datapingpong-vue.cloudfunctions.net/gameData?id={game_id}
※ game_idはdatapingpongで生成する共有URLの後方20桁の文字列です。
例 https://datapingpong.com/#/share/9rHCYjMiDulGCB2o3bTH → 9rHCYjMiDulGCB2o3bTH
また、試合データの公開設定が「リンクを知っている人に公開する」となっているデータのみ対象です。
- HTTPメソッド
GET
- 返却データ形式
正常時のレスポンス項目 2019/8/21 時点
key | value | 備考 |
---|---|---|
getPointPlayer | ポイントごとにどちらのプレイヤーが得点したかを表す | 1:player1の得点、2 : player2の得点 |
rallyCnt | ポイントごとに何本ラリーが続いたかを表す | |
player1 | player1の選手名 | |
player2 | player2の選手名 | |
matchName | 試合名 | |
firstGamseServer | 1ゲーム目のサーバーを表す | 1:player1がサーブ、2:player2がサーブ |
memo | 試合のメモ |
game_idが不正及び非公開のデータとなっている場合のレスポンス項目
key | value |
---|---|
message | 公開されているデータがありません |
サーバー側でエラーが発生した場合のレスポンス項目
key | value |
---|---|
message | エラーが発生しました。お問い合わせください。 |
以上がレスポンス内容です。ここから減ることはないと思いますが、レスポンス項目が増えることはあると思います。増えればその都度、更新していきます。
最後にpythonでデータを取得するコードを例として記載します。
import requests import json game_id = "9rHCYjMiDulGCB2o3bTH" data = requests.get("https://us-central1-datapingpong-vue.cloudfunctions.net/gameData?id={}".format(game_id)) data = json.loads(data.text) print(data)
追加して欲しい返却項目があれば、お知らせください。
12.5万/月を超えるアクセスが発生すると、利用停止となります。あんまりいじめないでください。
半年前に作ったwebサイトを壊した話
半年前に作ったwebサイトを1から作りなおしてリニューアルしました。
デザインほぼ一緒やんけという突っ込みは一旦引っ込めてください。。。
見た目はほぼ同じですが、できることは増えています。
- ユーザーが追加した得点パターンの集計
- スコアの変化と同時にグラフを表示
- スコアと集計データのリアルタイムでのシェア
以上のようなことができるようになっています。
以前のバージョンを公開してから感じたのは、自分で集計するのはめんどくさいけど、集計されたデータなら興味がある人はそれなりにいるということです。
当初の目的は自分の試合で使ってもらい、強くなってもらうことでした。しかし、トップ選手の戦術を分析することのほうが需要があることを知りました。
今後はトップ選手の試合のデータを通して、戦術解釈って面白くね?ってところをもう少し自分でも発信していきたいです。
さらに、せっかくリアルタイムでのシェア機能があるので、YouTubeなどで配信されている試合の集計データをリアルタイムでシェアすることもやっていきたいです。
スコアをライブ配信するサービスはすでにありますが、その卓球特化番みたいなポジションになれればいいかなと思っています。誰でも配信できる仕組みになっているので、独自の戦術解釈を配信してくれる人が出てくると盛り上がるかなあとも思います。
他にも細かな機能は使いやすいものになっているはずなので、触ってみてください。
技術選定
旧バージョンは以下のような作りでした。
新バージョンで使ったフレームワーク、サービスは以下の通りです。
フロント
Vue.js
ミーハーなので流行りのVue.jsで作りました。Vue.jsはおろかJavascriptすらほぼ初心者だったのでいろいろと勉強しました。以前のバージョンはほとんどの処理をサーバーサイドで行っていたので、全く異なる作りになりました。
Vue.jsなり最近のjavascriptのフレームワークを使わないと、スコアの変化と同時にグラフを表示する処理は実現が困難だったと思います。ユーザーがラベルを追加する処理も同様です。
サーバーサイド
Firebase
Firebaseじゃないとリアルタイムでシェアするという機能も実現するのが大変だったと思います。
認証周りも任せっきりになったので、セキュリティも自分で管理するより安全になりました。また、デプロイも「firebase deploy」で終わってしまうので、sshで接続してみたいなことをしなくていいので非常に簡単です。
サーブ・レシーブ得点率から見るTリーグの勢力図(個人編)
今回は前回の記事で取得したデータを用いて、サーブ・レシーブ得点率を可視化していきます。
サーブ時の得点率についてはランキング形式で公式ページに掲載されています。
ただこのランキングはシーズンを通して13試合以上の試合に出場しないと、集計対象外になってしまいます。(21試合 ☓ 0.6以上が条件のため)より集計対象を広げたランキングを見てみたいと思います。
また、サーブ得点率女子一位の石川選手と男子一位の張本選手には5%ほどの差があります。女子が全体的にサーブ得点率が高くなりやすいのか、それともただ石川選手が鬼強いだけなのかがよくわかりません。この辺りも検証したいと思います。
目次
- サーブ得点率ランキング
- レシーブ得点率ランキング
- 基本統計量
- グラフにしてみる
サーブ得点率ランキング
男子
対象選手はサーブ機会が200以上、つまりTリーグの出場前試合を通して200回以上サーブを出した選手としています。また、ダブルスは除外していますが、ヴィクトリーマッチは含まれています。
それぞれの列の説明は以下の通りです。
serve_point : サーブ得点数
serve : サーブ機会数
serve_winrate : サーブ時の得点率
公式ページのランキングと比較しても、サーブ得点数、サーブ機会数がほとんど一致しているので、データは正しいものとして進めていきます。
6試合にしか出場しておらず、公式ランキングでは集計対象外だった林選手が張本選手に次いで二位となっています。
女子
こちらも公式ページでは集計対象外だった早田選手が二位となっています。11試合に出場しているのでたまたま運が良かったわけではなく、実力通りの数値になっていると思います。
レシーブ得点率ランキング
公式ページには掲載されていませんが、レシーブ得点率も集計しました。
男子
サーブ同様、対象選手はレシーブ機会数が200以上の選手にします。
それぞれの列の説明は以下の通りです。
receive_point : レシーブ得点数
receive : レシーブ機会数
receive_winrate : レシーブ時の得点率
張本選手が一位なのはもうさすがとしか言いようがありませんが、サーブ時得点率ではそこまで上位ではなかった森園選手が二位となっているのが意外でした。ダブルスのレシーブだけでなく、シングルのレシーブ力もかなり高いことが窺えます。
女子
ツートップが離れているのはサーブ時の得点率と変わらずという結果になりました。トップと最下位の差がけっこう大きいなとも思います。
基本統計量
女子は差が大きかったような気がしたので、それぞれの基本統計量だけ見ておきます。
平均値にはほとんど差がありませんが、女子のほうが少しだけサーブが有利なのかもしれません。標準偏差は女子のほうが大きくなっており、ばらつきが大きい、つまり個々の選手の実力差が男子と比較すると大きいとは言えそうです。
グラフにしてみる
男女まとめ
女子のほうがばらばらになっていて、サーブ得点率とレシーブ得点率の相関関係が高そうに見えます。
男子
男子はサーブ得点率とレシーブ得点率は相関関係がほとんどありません。
森園選手のようにレシーブの得点率が高く、サーブの得点率はさほど高くない選手がいたり、陳選手のようにサーブの得点率が高く、レシーブの得点率はさほど高くない選手もいます。
女子
男子に比べラリー勝負になる展開が多く、ラリーに強い選手の両方の得点率が高いという構図になっていそうです。
わかったこと
- 石川選手が鬼強く、サーブ得点率が高くなっていた
- 早田選手も鬼強かった
- 公式ランキングでは集計対象外の選手も見てみると面白かった
- レシーブ得点率も公式ページに掲載してほしい
こんなところです。今回は以上です。今後はチームごとにやるとか、得点推移に着目するとか、気が向けばやってみます。
Tリーグの試合結果をスクレイピングで収集する
Tリーグのプレーオフもいよいよ来週に迫ってきました。今回はTリーグのこれまでの試合のデータをスクレイピングで収集してみました。pythonでスクレイピングしてmysqlで作成したDBに突っ込むまでやってみます。
なぜこんなことをやるのかというと、もっと興味深いデータが眠っていると思ったからです。今でもランキング形式で多少のデータが載っていますが、もっと細かくできるんじゃないかと。API用意しろとか贅沢は言わないので、このくらいのアクセスは許して欲しいところです。
追記 2019-03-24
選手のデータを収集するプログラムもgithubに追加しました。
環境
言語 Python
説明するまでもなく。BeautifulSoup,requestsライブラリとかも同様に。
データベース mysql
dockerですぐに使えそうだったのでmysqlにしました。INSERT文でデータを作成するだけなのでどのデータベースでも同じように動くはずです。
実装
ここに置いています。収集したデータを配布する行為は会員規約での禁止行為*1なので、ソースコードだけ置いておきます。一回動かしても100回弱くらいのアクセスなので、サーバーに高い負荷をかけることはないと考えています。とはいえ頻繁に動かすのもどうかとは思うので、自己責任で動かしてください。
解説
最終的には以下のような3つのテーブルにデータを突っ込むイメージです。
point
各ラリーでのポイントです。
セット数、ポイント数、ポイントの得失、サーブ権、ホームチームがタイムアウト直後か、アウェーチームがタイムアウト直後かどうかが入ります。
まずは以下のSQLを実行して、データベースとテーブルを作成します。
CREATE DATABASE TLeagu; CREATE TABLE team_match ( match_id VARCHAR(20) , date DATE , home VARCHAR(100) , away VARCHAR(100) , home_point INT , away_point INT , visitors INT , sex INT , PRIMARY KEY(match_id) ); CREATE TABLE game ( match_id VARCHAR(20) , game_id INT , home_player1 VARCHAR(100) , home_player2 VARCHAR(100) , away_player1 VARCHAR(100) , away_player2 VARCHAR(100) , home_point INT , away_point INT , PRIMARY KEY(match_id,game_id) ); CREATE TABLE point ( match_id VARCHAR(20) , game_id INT , set_id INT , point_id INT , point INT , serve INT , home_timeout_fag INT , away_timeout_fag INT , PRIMARY KEY(match_id,game_id,set_id,point_id) );
次に必要な関数を定義していきます。
get_link
各試合へのリンクを配列にして取得します。
get_match_recorde
team_matchテーブルに対応したデータを取得します。
get_game_recorde
gameテーブルに対応したデータを取得します。
get_point_record
pointテーブルに対応したデータを取得します。
judge_end_set
ポイントの得失点からセットが終了したかどうかを判定します。
# func.py import requests from bs4 import BeautifulSoup import re basicurl = "https://tleague.jp/match/" def get_link(): link_list = [] for month in ["201810","201811","201812","201901","201902"]: url = basicurl + "?season=2018&month=" + month + "&mw=" response = requests.get(url) soup = BeautifulSoup(response.text, "html.parser") matchlist = soup.find(class_="ui-match-table") for i in matchlist.find_all(class_="match-men"): for inner in i.find_all(class_="inner"): link = inner.find("a").get("href") link_list.append(link) for i in matchlist.find_all(class_="match-women"): for inner in i.find_all(class_="inner"): link = inner.find("a").get("href") link_list.append(link) return link_list def get_match_recorde(soup): match = soup.find(class_="match-info") home = match.find(class_="home").get_text() away = match.find(class_="away").get_text() point = match.find_all(class_="cell-score") home_point = point[0].get_text() away_point = point[1].get_text() itemclass = match.find(class_="item-spec").find_all("li") visitors = itemclass[2].get_text() visitors = re.sub(r"[^0-9]", "", visitors) return [home, away,home_point, away_point, visitors] def get_game_recorde(soup): table = [] match = soup.find(class_="cell-game") home_point = 0 away_point = 0 game = [] for i, col in enumerate(match.find_all(class_="col")): if i % 3 == 1: # point col home_point = col.get_text()[0] away_point = col.get_text()[2] continue a_list = col.find_all("a") if len(a_list) >= 2: for a in a_list: game.append(a.get_text().replace("\n","")) else: for a in a_list: game.append(a.get_text().replace("\n","")) game.append(None) if i % 3 == 2: # reset game.append(home_point) game.append(away_point) table.append(game) game = [] return table def judge_end_set(point, is_final): home = 0 away = 0 if is_final: home = 6 away = 6 for i in point: if i == "1": home += 1 else: away += 1 if home >= 11 or away >= 11: if (home - away) ** 2 >= 4: return True return False def get_point_record(soup): match = [] match_serve = [] match_timeout = [] home_timeout, away_timeout = 0, 0 is_final = False for i in soup.find_all("div",class_="match-game"): game = [] game_serve = [] game_timeout = [] for w in i.find_all(class_="wrap-table"): point = [] serve = [] timeout = [] tmp = "" for k_ind,k in enumerate(w.find_all("td")): if k_ind == 0: if k.get_text() == "1" or k.get_text() == "0": point.append(k.get_text()) else: point.append(str(int(k.get_text()) - 6)) is_final = True else: if k.get_text() == "T": home_timeout = 1 continue elif re.match("[0-9]", k.get_text()) == None: away_timeout = 1 continue elif tmp == k.get_text(): point.append("0") else: point.append("1") timeout.append([home_timeout, away_timeout]) home_timeout, away_timeout = 0, 0 tmp = k.get_text() if k.get("class") != None: serve.append(1) else: serve.append(2) if judge_end_set(point, is_final): is_final = False game.append(point) game_serve.append(serve) game_timeout.append(timeout) break match.append(game) match_timeout.append(game_timeout) match_serve.append(game_serve) return match, match_timeout, match_serve
そして実際にwebページにアクセスして実行します。処理は以下のとおりです。
- mysqlへのコネクションを作成
- func.pyで定義した関数を順番に実行
- 結果からINSERT文を作成
- DBに登録
# Tleagu.py import requests from bs4 import BeautifulSoup import re import mysql.connector from func import get_game_recorde,get_link,get_match_recorde,get_point_record conn = mysql.connector.connect( host = '172.17.0.3',#localhost port = 3306, user = '', password = '', database = 'TLeagu', ) cur = conn.cursor() basicurl = "https://tleague.jp" link = get_link() # link = ["/match/20181024m01"] try: cur.execute("DELETE FROM team_match;") cur.execute("DELETE FROM game;") cur.execute("DELETE FROM point;") for i in link: url = basicurl + i print(url) response = requests.get(url) soup = BeautifulSoup(response.text, "html.parser") sex = 0 if i[-3] == "m" else 1 date = i[-11:-3] match_id = i[7:] all_match = get_match_recorde(soup) all_match_game = get_game_recorde(soup) all_match_point, all_match_timeout, all_match_serve = get_point_record(soup) # inesrt match table execute_str = "INSERT INTO team_match VALUES ('{}','{}','{}','{}',{},{},{},{});".format( match_id, date, all_match[0], all_match[1], int(all_match[2]), int(all_match[3]), int(all_match[4]), int(sex) ) cur.execute(execute_str) # insert game table for a_game_index, a_game in enumerate(all_match_game): if a_game[1] != None: execute_str = "INSERT INTO game VALUES ('{}',{},'{}','{}','{}','{}',{},{});".format( match_id, a_game_index, a_game[0], a_game[1], a_game[2], a_game[3], int(a_game[4]), int(a_game[5]) ) else: execute_str = "INSERT INTO game VALUES ('{}',{},'{}',NULL,'{}',NULL,{},{});".format( match_id, a_game_index, a_game[0], a_game[2], int(a_game[4]), int(a_game[5]) ) cur.execute(execute_str) # insert point table for a_match_point_index, a_match_point in enumerate(all_match_point): for a_set_point_index, a_set_point in enumerate(a_match_point): for a_point_index, a_point in enumerate(a_set_point): execute_str = "INSERT INTO point VALUES ('{}',{},{},{},{},{},{},{});".format( match_id, a_match_point_index, a_set_point_index, a_point_index, int(a_point), int(all_match_serve[a_match_point_index][a_set_point_index][a_point_index]), all_match_timeout[a_match_point_index][a_set_point_index][a_point_index][0], all_match_timeout[a_match_point_index][a_set_point_index][a_point_index][1] ) cur.execute(execute_str) except: print(all_match) print(all_match_game) print(all_match_point) conn.rollback() raise conn.commit()
ここまで実行すると、DBにデータが作成されていることが確認できると思います。
次回はこの取得したデータを使って分析してみたいと思います。
*1:https://fan.tleague.jp/terms/ 第4章 第10条 1.に該当
とあるwebサイトを作った話
ただの宣伝記事です。
こんなサイトを作ってみました。
使い方は説明するまでもなく、得失点の内容に応じてボタンをぽちぽちするだけです。
まだまだできることは限られていますが、昔の自分なら使うかどうかを考えた結果、作ってみることにしました。
当時は似たようなことを紙で集計していたので、それに比べればかなり使いやすくなったと自己満足しています。
特に説明することもないので、ありがちな質問に回答しておきます。
・こんなの意味あるの?
ただ集計しているだけなので、使うだけでは意味はありません。意味を持たせるのは使う人次第だと思います。何かとデータを見るのが好きで、卓球にそれなりに時間をかけている人なら、意味はあるんじゃないかと。
・誰も使わないのでは?
わざわざ自分で試合の反省をし、かつそれを数値で表したい人なんてほとんどいないでしょう。現段階では日本で10人の役に立てばいいかなと思っています。
・何を目指しているの?
『さいきょうのじんこうちのう』ってやつが欲しいです。人間が獲得できる経験値を越えたその先に何があるのか見てみたいわけです。
ちなみに現在は人工知能だなんてものとはほど遠く簡単な処理しかしていません。実現できそうなものから順にやってけばいいかなと。いずれはもっと多くの人に役立つものにしていきます。飽きない限りもっともっと面白くなるので、たまには見てみてください。
フォームの可視化と再現性の計算②
今回は前回の続きという形です。
前回は可視化する部分でグラフにプロットしていましたが、わかりにくいと思うので、GIF動画を作ってみます。
下記ページを参考にさせていただきました。
stmind.hatenablog.com
コードについて
importと後々使う変数の定義
import json import numpy as np import matplotlib.pyplot as plt import os from pylab import * from PIL import Image, ImageDraw import numpy as np import matplotlib.pyplot as plt #全表示 #pairList = np.array([0,1,1,2,2,3,3,4,1,5,5,6,6,7,1,8,8,9,9,10,1,11,11,12,12,13,0,14,14,16,0,15,15,17]) #目と耳削除 pairList = np.array([0,1,1,2,2,3,3,4,1,5,5,6,6,7,1,8,8,9,9,10,1,11,11,12,12,13]) pairList = pairList.reshape(int(len(pairList) / 2), 2) index = np.array([139,208,277,343,415,487,558]) colors = [(255., 0., 85.), (255., 0., 0.), (255., 85., 0.), (255., 170., 0.), (255., 255., 0.), (170., 255., 0.), (85., 255., 0.), (0., 255., 0.), (0., 255., 85.), (0., 255., 170.), (0., 255., 255.), (0., 170., 255.), (0., 85., 255.), (0., 0., 255.), (255., 0., 170.), (170., 0., 255.), (255., 0., 255.), (85., 0., 255.)]
ファイルを開いて変数に格納
data = np.array([]) path = 'パス' files = os.listdir(path) files = sort(files) for file in files: filename = path + file f = open(filename) val = json.load(f) f.close() val = np.array(val['people'][0]['pose_keypoints_2d']) data = np.append(data, val) data = data.reshape(len(files), 18, 3)
GIF動画を作成する関数の定義
def generateGIF(swing, path): images = [] xlim = int(np.max(swing[:, :, :, 0])) ylim = int(np.max(swing[:, :, :, 1])) for i in range(len(swing[0, :, :, :])): im = Image.new('RGB', (xlim, ylim))#, (128, 128, 128)) draw = ImageDraw.Draw(im) for d in swing: for pair in pairList: pt1 = (int(d[i, pair[0], 0]), int(d[i, pair[0], 1])) c1 = d[i, pair[0], 2] pt2 = (int(d[i, pair[1], 0]), int(d[i, pair[1], 1])) c2 = d[i, pair[1], 2] # 信頼度0.0の関節は無視 if c1 == 0.0 or c2 == 0.0: continue # 関節の描画 color = tuple(list(map(int, colors[pair[0]]))) draw.line((pt1, pt2), fill=color, width=2) for line in range(1, 3): draw.line((0,int(ylim/3)*line, xlim, int(ylim/3)*line), fill=(50,50,50), width=1) for line in range(1, 3): draw.line((int(xlim/3)*line, 0, int(xlim/3)*line, ylim), fill=(50,50,50), width=1) images.append(im) images[0].save(path, save_all=True, append_images=images[1:], optimize=False, duration=500, loop=0)
全体のデータからスイング部分を切り出し、両骨盤の平均位置を原点に調整して、GIF動画を作成
swingperframe = 20 swing = np.array([]) for i in index: swing = np.append(swing, data[i:i+swingperframe,:,:]) swing = swing.reshape(len(index),swingperframe, 18, 3) for i, ivalue in enumerate(swing): for k, kvalue in enumerate(ivalue): originx = np.mean([kvalue[8, 0], kvalue[11, 0]]) originy = np.mean([kvalue[8, 1],kvalue[11, 1]]) swing[i, k, :, 0] -= originx swing[i, k, :, 1] -= originy for t, tvalue in enumerate(kvalue): if tvalue[2] == 0: swing[i, k, t, 0] = 0 swing[i, k, t, 1] = 0 swing[:, :, :, 0] -= np.min(swing[:, :, :, 0]) swing[:, :, :, 1] -= np.min(swing[:, :, :, 1]) generateGIF(swing, 'ファイル名.gif')
以上です。やってることは簡単なことしかやっていませんが、グラフなんかよりは見やすくなったと思います。