Skip to content

Commit 5030447

Browse files
August 2026 Update - Try our 2 new visualization tools, as well as tracking + phase of play visualizer
1 parent 6f44036 commit 5030447

12 files changed

Lines changed: 1118 additions & 9 deletions

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.10.16

docs/viz_tools.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 🛠️ Interactive Visualization Tools
2+
3+
SkillCorner Open Data includes standalone, browser-based HTML applications that allow you to interactively explore tracking data and dynamic events in any browser—without installing Python or running a local server.
4+
5+
---
6+
7+
## 💻 Available Tools
8+
9+
You can find these tools in the repository under the `viz_tools/` directory:
10+
11+
| Tool Name | File Path | Supported Datasets | Key Features |
12+
|---|---|---|---|
13+
| **SkillCorner Tracking Viewer** | `viz_tools/SkillCorner_Tracking_Viewer.html` | `*_tracking_extrapolated.jsonl`<br>`*_match.json` | 2D Pitch Animation, Convex Hulls, Player Trails, Playback Controls (0.25x-4x), Live Roster Inspector |
14+
| **Dynamic Events Explorer** | `viz_tools/Dynamic_Events_Explorer.html` | `*_dynamic_events.csv`<br>`*_phases_of_play.csv` | Spatial Run & Event Vectors, Phases of Play Timeline, Multi-Criteria Filters, Searchable Data Grid |
15+
16+
---
17+
18+
## 🚀 Getting Started
19+
20+
1. **Clone the Repository** or download the `viz_tools/` folder.
21+
2. **Double-click** either HTML tool (`SkillCorner_Tracking_Viewer.html` or `Dynamic_Events_Explorer.html`) to open it in Chrome, Firefox, Safari, or Edge.
22+
3. **Drag and Drop** open match dataset files from `data/matches/1886347/` directly into the browser window.

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ nav:
6060
- '🔗 Part 4: Merging Track/Event': notebooks/tutorials/02_Working_with_Game_Intelligence_and_Dynamic_Events/Part4_Merging_Dynamic_Events_and_Tracking_Data_Tutorial.ipynb
6161
- '🎬 Part 5: Animated Video': notebooks/tutorials/02_Working_with_Game_Intelligence_and_Dynamic_Events/Part5_Animated_2D_Video_From_Tracking_And_Events.ipynb
6262
- '📈 Part 6: Build Your Own Metric': notebooks/tutorials/02_Working_with_Game_Intelligence_and_Dynamic_Events/Part6_BuildYourOwnMetric_Detecting_and_Evaluating_Cutback_Opportunities.ipynb
63+
- '🗺️ Part 7: Pitch Maps by Phase': notebooks/tutorials/02_Working_with_Game_Intelligence_and_Dynamic_Events/Part7_Pitch_Maps_Tracking_by_Phase_Tutorial.ipynb
6364
- 'Path 03: Basics of Tracking':
6465
- '📍 Tracking Core': notebooks/tutorials/03_Basics_of_Tracking/Open_Data_Tracking_Tutorial.ipynb
6566
- '🚀 Kloppy Integration': notebooks/tutorials/03_Basics_of_Tracking/Open_Data_Getting_Started_with_Tracking_and_Kloppy_Tutorial.ipynb
6667
- 'Path 04: Visualizations':
6768
- '📊 Sectioned Summary Table': notebooks/tutorials/04_Visualizations/Sectioned_Summary_Table_Viz_Tutorial.ipynb
6869
- '📡 OffBall Runs Radar': notebooks/tutorials/04_Visualizations/OBR_Simple_Radar_Viz.ipynb
70+
- '🛠️ Viz Tools': docs/viz_tools.md
6971
- Source Code Setup: src.md

notebooks/tutorials/02_Working_with_Game_Intelligence_and_Dynamic_Events/Part2_Data_Aggregating_Phases_of_Play_Tutorial.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@
617617
}
618618
],
619619
"source": [
620-
"phases_of_play_aggregates_duration=phases_of_play_aggregates_all[['match_id','team_id','team_name']+time_columns]\n",
620+
"phases_of_play_aggregates_duration=phases_of_play_aggregates_all[['match_id','team_id','team_name']+time_columns].copy()\n",
621621
"\n",
622622
"for time_col in time_columns:\n",
623623
" phases_of_play_aggregates_duration[time_col+'_pct']=phases_of_play_aggregates_duration[time_col]*100/phases_of_play_aggregates_duration[time_columns].sum(axis=1)\n",
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# \ud83d\udcca Pitch Maps: Player Tracking by Phase of Play\n",
8+
"\n",
9+
"This tutorial demonstrates how to merge tracking data with SkillCorner **Phases of Play** data to generate tactical team pitch maps, calculate team spatial metrics (Width, Depth, Height), and visualize team shape across different in-possession and out-of-possession phases."
10+
]
11+
},
12+
{
13+
"cell_type": "markdown",
14+
"metadata": {},
15+
"source": [
16+
"## \ud83d\udccb Step 1: Setup & Prerequisites\n",
17+
"\n",
18+
"Make sure you have the required open-source libraries installed:\n",
19+
"```bash\n",
20+
"pip install numpy pandas matplotlib scipy mplsoccer skillcornerviz\n",
21+
"```"
22+
]
23+
},
24+
{
25+
"cell_type": "code",
26+
"execution_count": null,
27+
"metadata": {},
28+
"outputs": [],
29+
"source": [
30+
"import json\n",
31+
"import os\n",
32+
"import numpy as np\n",
33+
"import pandas as pd\n",
34+
"import matplotlib.pyplot as plt\n",
35+
"from scipy.spatial import ConvexHull\n",
36+
"from mplsoccer import Pitch\n",
37+
"from skillcornerviz.utils import constants\n"
38+
]
39+
},
40+
{
41+
"cell_type": "markdown",
42+
"metadata": {},
43+
"source": [
44+
"## \ud83d\udce5 Step 2: Load Open Data (Match Metadata, Tracking & Phases of Play)\n",
45+
"\n",
46+
"We load the open match dataset for `match_id = 1886347` directly from local files."
47+
]
48+
},
49+
{
50+
"cell_type": "code",
51+
"execution_count": null,
52+
"metadata": {},
53+
"outputs": [],
54+
"source": [
55+
"match_id = 1886347\n",
56+
"data_dir = f\"../../../data/matches/{match_id}\"\n",
57+
"\n",
58+
"# 1. Load Match Metadata\n",
59+
"with open(f\"{data_dir}/{match_id}_match.json\") as f:\n",
60+
" raw_match_data = json.load(f)\n",
61+
"\n",
62+
"raw_match_df = pd.json_normalize(raw_match_data, max_level=2)\n",
63+
"players_df = pd.json_normalize(\n",
64+
" raw_match_df.to_dict(\"records\"),\n",
65+
" record_path=\"players\",\n",
66+
" meta=[\"home_team.name\", \"home_team.id\", \"away_team.name\", \"away_team.id\", \"home_team_side\"]\n",
67+
")\n",
68+
"\n",
69+
"def time_to_seconds(t_str):\n",
70+
" if not t_str or pd.isna(t_str):\n",
71+
" return 0\n",
72+
" parts = str(t_str).split(\":\")\n",
73+
" return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])\n",
74+
"\n",
75+
"players_df = players_df[~((players_df[\"start_time\"].isna()) & (players_df[\"end_time\"].isna()))].copy()\n",
76+
"players_df[\"total_time\"] = players_df[\"end_time\"].apply(time_to_seconds) - players_df[\"start_time\"].apply(time_to_seconds)\n",
77+
"players_df[\"is_gk\"] = players_df[\"player_role.acronym\"] == \"GK\"\n",
78+
"players_df[\"game\"] = players_df[\"home_team.name\"] + \" vs \" + players_df[\"away_team.name\"]\n",
79+
"players_df[\"home_away_player\"] = np.where(players_df[\"team_id\"] == players_df[\"home_team.id\"], \"Home\", \"Away\")\n",
80+
"\n",
81+
"# Extract playing directions per half\n",
82+
"players_df[[\"home_team_side_1st_half\", \"home_team_side_2nd_half\"]] = (\n",
83+
" players_df[\"home_team_side\"]\n",
84+
" .astype(str)\n",
85+
" .str.strip(\"[]\")\n",
86+
" .str.replace(\"'\", \"\")\n",
87+
" .str.split(\", \", expand=True)\n",
88+
")\n",
89+
"players_df[\"direction_player_1st_half\"] = np.where(\n",
90+
" players_df[\"home_away_player\"] == \"Home\",\n",
91+
" players_df[\"home_team_side_1st_half\"],\n",
92+
" players_df[\"home_team_side_2nd_half\"]\n",
93+
")\n",
94+
"players_df[\"direction_player_2nd_half\"] = np.where(\n",
95+
" players_df[\"home_away_player\"] == \"Home\",\n",
96+
" players_df[\"home_team_side_2nd_half\"],\n",
97+
" players_df[\"home_team_side_1st_half\"]\n",
98+
")\n",
99+
"\n",
100+
"# Select top 10 outfield players + 1 goalkeeper per team (starters)\n",
101+
"outfield = players_df[~players_df[\"is_gk\"]].groupby(\"team_id\", group_keys=False).apply(lambda x: x.nlargest(10, \"total_time\")).reset_index(drop=True)\n",
102+
"gk = players_df[players_df[\"is_gk\"]].groupby(\"team_id\", group_keys=False).apply(lambda x: x.nlargest(1, \"total_time\")).reset_index(drop=True)\n",
103+
"selected_players = pd.concat([outfield, gk], ignore_index=True)\n",
104+
"\n",
105+
"# 2. Load Tracking & Phases Data\n",
106+
"raw_tracking = pd.read_json(f\"{data_dir}/{match_id}_tracking_extrapolated.jsonl\", lines=True)\n",
107+
"tracking_df = pd.json_normalize(\n",
108+
" raw_tracking.to_dict(\"records\"),\n",
109+
" \"player_data\",\n",
110+
" [\"frame\", \"timestamp\", \"period\", \"possession\", \"ball_data\"]\n",
111+
")\n",
112+
"tracking_df[\"possession.group\"] = tracking_df[\"possession\"].apply(lambda x: x.get(\"group\") if isinstance(x, dict) else None)\n",
113+
"\n",
114+
"phases_df = pd.read_csv(f\"{data_dir}/{match_id}_phases_of_play.csv\")\n"
115+
]
116+
},
117+
{
118+
"cell_type": "markdown",
119+
"metadata": {},
120+
"source": [
121+
"## \u2699\ufe0f Step 3: Map Tracking Frames to Phases of Play\n",
122+
"\n",
123+
"We map frame coordinates to their corresponding phase of play based on frame intervals and possession state."
124+
]
125+
},
126+
{
127+
"cell_type": "code",
128+
"execution_count": null,
129+
"metadata": {},
130+
"outputs": [],
131+
"source": [
132+
"main_df = tracking_df[tracking_df[\"possession.group\"].notnull()].copy()\n",
133+
"grouped = main_df.merge(\n",
134+
" selected_players[[\"team_id\", \"player_role.name\", \"id\", \"first_name\", \"short_name\", \"start_time\", \"end_time\", \"number\", \"home_team.name\", \"away_team.name\", \"game\", \"home_away_player\", \"direction_player_1st_half\", \"direction_player_2nd_half\", \"is_gk\"]],\n",
135+
" left_on=\"player_id\", right_on=\"id\"\n",
136+
")\n",
137+
"\n",
138+
"grouped[\"direction_player\"] = np.where(grouped[\"period\"] == 1, grouped[\"direction_player_1st_half\"], grouped[\"direction_player_2nd_half\"])\n",
139+
"grouped[\"x\"] = np.where(grouped[\"direction_player\"] == \"right_to_left\", -grouped[\"x\"], grouped[\"x\"])\n",
140+
"grouped[\"y\"] = np.where(grouped[\"direction_player\"] == \"right_to_left\", -grouped[\"y\"], grouped[\"y\"])\n",
141+
"grouped[\"team\"] = np.where(grouped[\"home_away_player\"] == \"Home\", grouped[\"home_team.name\"], grouped[\"away_team.name\"])\n",
142+
"\n",
143+
"interval_index = pd.IntervalIndex.from_arrays(phases_df[\"frame_start\"], phases_df[\"frame_end\"], closed=\"left\")\n",
144+
"matched_idx = interval_index.get_indexer(grouped[\"frame\"])\n",
145+
"\n",
146+
"valid_mask = matched_idx != -1\n",
147+
"tracking_valid = grouped[valid_mask].copy().reset_index(drop=True)\n",
148+
"matched_phases = phases_df.iloc[matched_idx[valid_mask]].reset_index(drop=True)\n",
149+
"\n",
150+
"combined = tracking_valid.join(matched_phases[[\"team_in_possession_id\", \"team_in_possession_phase_type\", \"team_out_of_possession_phase_type\"]])\n",
151+
"combined[\"phase\"] = np.where(\n",
152+
" combined[\"team_id\"] == combined[\"team_in_possession_id\"],\n",
153+
" combined[\"team_in_possession_phase_type\"],\n",
154+
" combined[\"team_out_of_possession_phase_type\"]\n",
155+
")\n"
156+
]
157+
},
158+
{
159+
"cell_type": "markdown",
160+
"metadata": {},
161+
"source": [
162+
"## \ud83d\udcca Step 4: Calculate Average Positions by Phase of Play\n",
163+
"\n",
164+
"We compute the mean `x` and `y` coordinates for each player grouped by phase of play."
165+
]
166+
},
167+
{
168+
"cell_type": "code",
169+
"execution_count": null,
170+
"metadata": {},
171+
"outputs": [],
172+
"source": [
173+
"aggregated_df = combined.groupby([\"player_id\", \"team\", \"number\", \"is_gk\", \"phase\"])[[\"x\", \"y\"]].mean().reset_index()\n",
174+
"game_title = grouped[\"game\"].iloc[0]\n",
175+
"aggregated_df.head()\n"
176+
]
177+
},
178+
{
179+
"cell_type": "markdown",
180+
"metadata": {},
181+
"source": [
182+
"## \ud83c\udfa8 Step 5: Visualize Pitch Maps & Team Shape Metrics\n",
183+
"\n",
184+
"We plot the average positions, convex hull, width, depth, and team height for each phase of play."
185+
]
186+
},
187+
{
188+
"cell_type": "code",
189+
"execution_count": null,
190+
"metadata": {},
191+
"outputs": [],
192+
"source": [
193+
"def plot_team_positions(ax, data, color, edge_color, text_color, label, include_area=False):\n",
194+
" points = data[~data[\"is_gk\"]][[\"x\", \"y\"]].values\n",
195+
" if len(points) > 2:\n",
196+
" hull = ConvexHull(points)\n",
197+
" ax.fill(points[hull.vertices, 0], points[hull.vertices, 1], color=color, alpha=0.3, zorder=8)\n",
198+
" for simplex in hull.simplices:\n",
199+
" ax.plot(points[simplex, 0], points[simplex, 1], color=edge_color, linestyle=\"--\", linewidth=1.2, zorder=8)\n",
200+
"\n",
201+
" ax.scatter(data[\"x\"], data[\"y\"], c=color, alpha=0.95, s=500, edgecolors=edge_color, linewidths=2.0, zorder=10, label=label)\n",
202+
"\n",
203+
" for _, row in data.iterrows():\n",
204+
" ax.text(row[\"x\"], row[\"y\"], str(int(row[\"number\"])), color=text_color, fontweight=\"bold\", fontsize=9, ha=\"center\", va=\"center\", zorder=11)\n",
205+
"\n",
206+
"team_name = aggregated_df[\"team\"].unique()[0]\n",
207+
"phase_list = [\"build_up\", \"create\", \"finish\", \"low_block\", \"medium_block\", \"high_block\"]\n",
208+
"\n",
209+
"for phase_type in phase_list:\n",
210+
" viz_phase = aggregated_df[(aggregated_df[\"phase\"] == phase_type) & (aggregated_df[\"team\"] == team_name)].reset_index(drop=True)\n",
211+
" if viz_phase.empty:\n",
212+
" continue\n",
213+
"\n",
214+
" pitch = Pitch(pitch_type=\"skillcorner\", line_alpha=0.5, pitch_length=105, pitch_width=68, pitch_color=\"#e8e8e6\", line_color=constants.TEXT_COLOR, linewidth=1.5)\n",
215+
" fig, ax = pitch.grid(figheight=7, endnote_height=0, title_height=0)\n",
216+
"\n",
217+
" plot_team_positions(ax, viz_phase, color=constants.PRIMARY_HIGHLIGHT_COLOR, edge_color=constants.TEXT_COLOR, text_color=\"white\", label=phase_type.replace(\"_\", \" \").upper())\n",
218+
"\n",
219+
" ax.set_title(f\"{team_name} | {game_title} | {phase_type.replace('_', ' ').upper()}\", size=16, fontweight=\"bold\", color=constants.TEXT_COLOR)\n",
220+
"\n",
221+
" # Calculate spatial bounds (excluding Goalkeeper)\n",
222+
" field_players = viz_phase[~viz_phase[\"is_gk\"]]\n",
223+
" if not field_players.empty:\n",
224+
" depth = field_players[\"x\"].min()\n",
225+
" top = field_players[\"x\"].max()\n",
226+
" bot_v = field_players[\"y\"].min()\n",
227+
" top_v = field_players[\"y\"].max()\n",
228+
"\n",
229+
" spread = round(top_v - bot_v)\n",
230+
" team_depth = round(top - depth)\n",
231+
" team_height = abs(round(depth + 52.5))\n",
232+
"\n",
233+
" # Draw spatial reference lines\n",
234+
" ax.plot([depth, depth], [-34, 34], ls=\"--\", color=constants.TEXT_COLOR, linewidth=1.2, alpha=0.5)\n",
235+
" ax.plot([top, top], [-34, 34], ls=\"--\", color=constants.TEXT_COLOR, linewidth=1.2, alpha=0.5)\n",
236+
" ax.plot([-52.5, 52.5], [bot_v, bot_v], ls=\"--\", color=constants.TEXT_COLOR, linewidth=1.2, alpha=0.5)\n",
237+
" ax.plot([-52.5, 52.5], [top_v, top_v], ls=\"--\", color=constants.TEXT_COLOR, linewidth=1.2, alpha=0.5)\n",
238+
"\n",
239+
" # Annotate Width, Height, and Depth\n",
240+
" ax.text(x=abs(40) * 1.05, y=0, s=f\"Width: {spread}m\", weight=\"bold\", rotation=270, ha=\"center\", color=constants.TEXT_COLOR)\n",
241+
" ax.text(x=(-52.5 + depth) / 2, y=34 * 1.03, s=f\"Height: {team_height}m\", weight=\"bold\", ha=\"center\", color=constants.TEXT_COLOR)\n",
242+
" ax.text(x=(depth + top) / 2, y=34 * 1.03, s=f\"Depth: {team_depth}m\", weight=\"bold\", ha=\"center\", color=constants.TEXT_COLOR)\n",
243+
"\n",
244+
" plt.show()\n"
245+
]
246+
}
247+
],
248+
"metadata": {
249+
"language_info": {
250+
"name": "python"
251+
}
252+
},
253+
"nbformat": 4,
254+
"nbformat_minor": 5
255+
}

src/data/basic_loading.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import os
12
import pandas as pd
23

34
match_id = 1886347
45

6+
# Resolve data directory absolute path relative to this script
7+
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8+
data_dir = os.path.join(base_dir, "data")
9+
510
# Dynamic Events
6-
de_match = pd.read_csv(f"../data/matches/{match_id}/{match_id}_dynamic_events.csv")
11+
de_match = pd.read_csv(os.path.join(data_dir, "matches", str(match_id), f"{match_id}_dynamic_events.csv"))
712

813
# Phases of Play
9-
pop_match = pd.read_csv(f"../data/matches/{match_id}/{match_id}_dynamic_events.csv")
14+
pop_match = pd.read_csv(os.path.join(data_dir, "matches", str(match_id), f"{match_id}_phases_of_play.csv"))
1015

11-
#
16+
# Tracking Data
1217
tracking_data = pd.read_json(
13-
f"../data/matches/{match_id}/{match_id}_tracking_extrapolated.jsonl", lines=True
18+
os.path.join(data_dir, "matches", str(match_id), f"{match_id}_tracking_extrapolated.jsonl"), lines=True
1419
)
20+

src/features/PhasesOfPlayAggregator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def get_out_of_possession_aggregates(self):
101101

102102
next_phase_in_possession_phase_aggs = self.phases_of_play_df.groupby([
103103
'team_out_of_possession_id', 'team_out_of_possession_phase_type', 'team_out_of_possession_next_phase'
104-
]).agg(count=('index', 'count')).reset_index()
104+
]).agg(count=('frame_start', 'count')).reset_index()
105105

106106
next_phase_in_possession_phase_aggs['team_phase_id'] = (
107107
next_phase_in_possession_phase_aggs['team_out_of_possession_id'].astype(str) + '_' +
@@ -174,7 +174,7 @@ def get_in_possession_aggregates(self):
174174

175175
next_phase_in_possession_phase_aggs = self.phases_of_play_df.groupby([
176176
'team_in_possession_id', 'team_in_possession_phase_type', 'team_in_possession_next_phase'
177-
]).agg(count=('index', 'count')).reset_index()
177+
]).agg(count=('frame_start', 'count')).reset_index()
178178

179179
next_phase_in_possession_phase_aggs['team_phase_id'] = (
180180
next_phase_in_possession_phase_aggs['team_in_possession_id'].astype(str) + '_' +

src/visualization/head2head_viz.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def plot_head2head(
5050
rval = float(df.loc[df[category_column] == cat_right, m].values[0])
5151
rows.append({"metric": m, "label": metric_labels.get(m, m), "L": lval, "R": rval})
5252
d = pd.DataFrame(rows)
53+
d["L"] = d["L"].fillna(0.0)
54+
d["R"] = d["R"].fillna(0.0)
5355

5456
# --- figure ---
5557
fig, ax = plt.subplots(figsize=(14, max(5, len(metrics) * 0.65)), constrained_layout=True)
@@ -109,7 +111,7 @@ def plot_head2head(
109111
# --- value labels at bar ends ---
110112
label_pad = 0.012 * (2 * xmax)
111113
for i, r in d.iterrows():
112-
suf = "" if unit==None else unit
114+
suf = "" if unit is None else unit
113115
ax.text(-gap - r["L"] - label_pad, i, f"{r['L']:.1f}{suf}",
114116
ha="right", va="center", fontsize=11, fontweight="bold", color="#111827",
115117
path_effects=[pe.withStroke(linewidth=2.0, foreground="white")], zorder=5)

src/visualization/sectioned_summary_table_viz.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def ranking_plot(df, questions, highlight_group,
9393
alpha=1)])
9494
i -= 1
9595
for metric in questions[key]:
96-
if metric_labels != None:
96+
if metric_labels is not None:
9797
metric_label = metric_labels[metric]
9898
else:
9999
metric_label = metric.replace('count_', '')

0 commit comments

Comments
 (0)