wunderscout 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wunderscout-0.1.2/.github/workflows/publish.yml +28 -0
- wunderscout-0.1.2/.gitignore +40 -0
- wunderscout-0.1.2/.python-version +1 -0
- wunderscout-0.1.2/PKG-INFO +27 -0
- wunderscout-0.1.2/README.md +0 -0
- wunderscout-0.1.2/context.md +93 -0
- wunderscout-0.1.2/pyproject.toml +37 -0
- wunderscout-0.1.2/src/wunderscout/__init__.py +6 -0
- wunderscout-0.1.2/src/wunderscout/core.py +164 -0
- wunderscout-0.1.2/src/wunderscout/exporters.py +43 -0
- wunderscout-0.1.2/src/wunderscout/geometry.py +74 -0
- wunderscout-0.1.2/src/wunderscout/heatmap.py +115 -0
- wunderscout-0.1.2/src/wunderscout/main.py +598 -0
- wunderscout-0.1.2/src/wunderscout/pass_network.py +103 -0
- wunderscout-0.1.2/src/wunderscout/teams.py +76 -0
- wunderscout-0.1.2/src/wunderscout/vision.py +155 -0
- wunderscout-0.1.2/uv.lock +2074 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-n-publish:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment:
|
|
13
|
+
name: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
|
|
24
|
+
- name: Build package
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Publish package
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
.uv/
|
|
12
|
+
|
|
13
|
+
# Env
|
|
14
|
+
.env
|
|
15
|
+
|
|
16
|
+
# Datasets
|
|
17
|
+
football-players-detection-20/
|
|
18
|
+
football-field-detection-15/
|
|
19
|
+
|
|
20
|
+
# Output directories
|
|
21
|
+
runs/
|
|
22
|
+
data/
|
|
23
|
+
|
|
24
|
+
# Research scripts
|
|
25
|
+
research/
|
|
26
|
+
|
|
27
|
+
# Models
|
|
28
|
+
*.pt
|
|
29
|
+
|
|
30
|
+
# Videos
|
|
31
|
+
*.mp4
|
|
32
|
+
|
|
33
|
+
# Mac
|
|
34
|
+
.DS_Store
|
|
35
|
+
|
|
36
|
+
# Large/Auto-generated data
|
|
37
|
+
*.png
|
|
38
|
+
*.json
|
|
39
|
+
!pyproject.toml
|
|
40
|
+
!package.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wunderscout
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: ipython>=9.5.0
|
|
7
|
+
Requires-Dist: matplotlib>=3.10.6
|
|
8
|
+
Requires-Dist: memory-profiler>=0.61.0
|
|
9
|
+
Requires-Dist: more-itertools>=10.8.0
|
|
10
|
+
Requires-Dist: networkx>=3.5
|
|
11
|
+
Requires-Dist: numba>=0.58
|
|
12
|
+
Requires-Dist: numpy>=2.3.2
|
|
13
|
+
Requires-Dist: opencv-python>=4.11.0.86
|
|
14
|
+
Requires-Dist: pandas>=2.3.2
|
|
15
|
+
Requires-Dist: plotly>=6.3.0
|
|
16
|
+
Requires-Dist: protobuf>=6.32.1
|
|
17
|
+
Requires-Dist: psutil>=7.0.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.1.1
|
|
19
|
+
Requires-Dist: roboflow>=1.2.7
|
|
20
|
+
Requires-Dist: scikit-learn>=1.7.2
|
|
21
|
+
Requires-Dist: seaborn>=0.13.2
|
|
22
|
+
Requires-Dist: sentencepiece>=0.2.1
|
|
23
|
+
Requires-Dist: supervision>=0.26.1
|
|
24
|
+
Requires-Dist: tqdm>=4.67.1
|
|
25
|
+
Requires-Dist: transformers>=4.56.1
|
|
26
|
+
Requires-Dist: ultralytics>=8.3.193
|
|
27
|
+
Requires-Dist: umap-learn>=0.5.9.post2
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
This project integrates two main data sources: StatsBomb Events Data and Metrica Sports Tracking Data. Both datasets provide complementary information useful for AI models analyzing soccer matches.
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
1. StatsBomb Events Data
|
|
6
|
+
|
|
7
|
+
- Format: JSON (array of objects)
|
|
8
|
+
|
|
9
|
+
- Granularity: Event-based (specific match events with contextual details)
|
|
10
|
+
|
|
11
|
+
- Typical Event Example: "Starting XI", passes, shots, fouls, etc.
|
|
12
|
+
|
|
13
|
+
Main fields:
|
|
14
|
+
|
|
15
|
+
- id: Unique identifier of the event
|
|
16
|
+
|
|
17
|
+
- index: Sequential index of the event
|
|
18
|
+
|
|
19
|
+
- period: Match period (1 = first half, 2 = second half)
|
|
20
|
+
|
|
21
|
+
- timestamp, minute, second: Time indicators
|
|
22
|
+
|
|
23
|
+
- type: Event category (e.g., "Starting XI", "Pass", "Shot")
|
|
24
|
+
|
|
25
|
+
- possession: Possession sequence ID
|
|
26
|
+
|
|
27
|
+
- possession_team: Team in possession
|
|
28
|
+
|
|
29
|
+
- play_pattern: Context of play (e.g., "Regular Play", "From Corner")
|
|
30
|
+
|
|
31
|
+
- team: Team performing the event
|
|
32
|
+
|
|
33
|
+
- duration: Duration of the event (float in seconds)
|
|
34
|
+
|
|
35
|
+
- tactics: (only for Starting XI events) formation + list of players and their positions
|
|
36
|
+
|
|
37
|
+
Example – "Starting XI" event:
|
|
38
|
+
|
|
39
|
+
- Includes formation (e.g., 4-2-3-1)
|
|
40
|
+
|
|
41
|
+
- lineup: Array of players
|
|
42
|
+
|
|
43
|
+
- Each player has:
|
|
44
|
+
|
|
45
|
+
- player.id, player.name
|
|
46
|
+
|
|
47
|
+
- position.id, position.name (e.g., Goalkeeper, Right Back)
|
|
48
|
+
|
|
49
|
+
- jersey_number
|
|
50
|
+
|
|
51
|
+
This dataset is high-level, event-based and provides semantic information (who did what, when, and in which tactical context).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
2. Metrica Sports Tracking Data
|
|
56
|
+
|
|
57
|
+
- Format: CSV
|
|
58
|
+
|
|
59
|
+
- Granularity: Frame-by-frame spatio-temporal tracking (player and ball positions)
|
|
60
|
+
|
|
61
|
+
Structure:
|
|
62
|
+
|
|
63
|
+
- Header row contains player identifiers (Player15, Player16, …, Player28) and columns for ball position
|
|
64
|
+
|
|
65
|
+
- Each record represents a single frame in the match with timestamp
|
|
66
|
+
|
|
67
|
+
Main fields:
|
|
68
|
+
|
|
69
|
+
- Period: Match half (1 = first half, 2 = second half)
|
|
70
|
+
|
|
71
|
+
- Frame: Frame number within the match video
|
|
72
|
+
|
|
73
|
+
- Time [s]: Timestamp in seconds (float, high precision)
|
|
74
|
+
|
|
75
|
+
- PlayerXX: X and Y coordinates of each player (two columns per player)
|
|
76
|
+
|
|
77
|
+
- Example: Player15_x, Player15_y
|
|
78
|
+
|
|
79
|
+
- Ball: X and Y coordinates of the ball
|
|
80
|
+
|
|
81
|
+
Example row:
|
|
82
|
+
|
|
83
|
+
- Period = 1
|
|
84
|
+
|
|
85
|
+
- Frame = 1
|
|
86
|
+
|
|
87
|
+
- Time [s] = 0.04
|
|
88
|
+
|
|
89
|
+
- Player positions given as normalized coordinates (0–1 scale) across the pitch
|
|
90
|
+
|
|
91
|
+
- Ball position reported similarly
|
|
92
|
+
|
|
93
|
+
This dataset is low-level, continuous tracking data giving spatial and temporal movement of all players and the ball throughout the match.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wunderscout"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"ipython>=9.5.0",
|
|
9
|
+
"memory-profiler>=0.61.0",
|
|
10
|
+
"more-itertools>=10.8.0",
|
|
11
|
+
"numpy>=2.3.2",
|
|
12
|
+
"opencv-python>=4.11.0.86",
|
|
13
|
+
"plotly>=6.3.0",
|
|
14
|
+
"protobuf>=6.32.1",
|
|
15
|
+
"psutil>=7.0.0",
|
|
16
|
+
"python-dotenv>=1.1.1",
|
|
17
|
+
"roboflow>=1.2.7",
|
|
18
|
+
"scikit-learn>=1.7.2",
|
|
19
|
+
"sentencepiece>=0.2.1",
|
|
20
|
+
"tqdm>=4.67.1",
|
|
21
|
+
"transformers>=4.56.1",
|
|
22
|
+
"ultralytics>=8.3.193",
|
|
23
|
+
"umap-learn>=0.5.9.post2",
|
|
24
|
+
"numba>=0.58",
|
|
25
|
+
"supervision>=0.26.1",
|
|
26
|
+
"pandas>=2.3.2",
|
|
27
|
+
"matplotlib>=3.10.6",
|
|
28
|
+
"seaborn>=0.13.2",
|
|
29
|
+
"networkx>=3.5",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/wunderscout"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import supervision as sv
|
|
3
|
+
import numpy as np
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .vision import VisionEngine
|
|
6
|
+
from .geometry import PitchMapper
|
|
7
|
+
from .teams import TeamClassifier
|
|
8
|
+
from .exporters import DataExporter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScoutingPipeline:
|
|
12
|
+
def __init__(self, player_weights, field_weights):
|
|
13
|
+
self.engine = VisionEngine(player_weights, field_weights)
|
|
14
|
+
self.mapper = PitchMapper()
|
|
15
|
+
self.classifier = TeamClassifier()
|
|
16
|
+
|
|
17
|
+
def run(self, video_path, output_video_path):
|
|
18
|
+
# 1. Warm-up (Calibration)
|
|
19
|
+
print("WORKER: Calibrating teams...")
|
|
20
|
+
crops = self.engine.get_calibration_crops(video_path)
|
|
21
|
+
if len(crops) > 0:
|
|
22
|
+
embeddings = self.engine.get_embeddings(crops)
|
|
23
|
+
self.classifier.fit(embeddings)
|
|
24
|
+
else:
|
|
25
|
+
print("WARNING: No player crops found for calibration.")
|
|
26
|
+
|
|
27
|
+
# 2. Setup Video I/O
|
|
28
|
+
output_path_obj = Path(output_video_path)
|
|
29
|
+
output_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
# ---------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
cap = cv2.VideoCapture(video_path)
|
|
33
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
34
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
35
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
36
|
+
|
|
37
|
+
out = cv2.VideoWriter(
|
|
38
|
+
output_video_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not out.isOpened():
|
|
42
|
+
print(f"ERROR: Could not create video file at {output_video_path}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
tracker = sv.ByteTrack()
|
|
46
|
+
tracking_results = {}
|
|
47
|
+
|
|
48
|
+
# ID Constants
|
|
49
|
+
BALL_ID = 0
|
|
50
|
+
GOALKEEPER_ID = 1
|
|
51
|
+
PLAYER_ID = 2
|
|
52
|
+
REFEREE_ID = 3
|
|
53
|
+
|
|
54
|
+
# 3. Main Processing Loop
|
|
55
|
+
print(f"WORKER: Starting processing: {video_path}")
|
|
56
|
+
frame_generator = sv.get_video_frames_generator(video_path)
|
|
57
|
+
|
|
58
|
+
for frame_idx, frame in enumerate(frame_generator):
|
|
59
|
+
if frame_idx % 100 == 0:
|
|
60
|
+
print(f"WORKER: Processing frame {frame_idx}")
|
|
61
|
+
|
|
62
|
+
# --- A. DETECTION ---
|
|
63
|
+
all_dets = self.engine.detect_players(frame)
|
|
64
|
+
f_res = self.engine.detect_field(frame)
|
|
65
|
+
|
|
66
|
+
# --- B. FIELD HOMOGRAPHY ---
|
|
67
|
+
H = None
|
|
68
|
+
if f_res.keypoints is not None and len(f_res.keypoints.xy) > 0:
|
|
69
|
+
H = self.mapper.get_matrix(
|
|
70
|
+
f_res.keypoints.xy[0].cpu().numpy(),
|
|
71
|
+
f_res.keypoints.conf[0].cpu().numpy(),
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
H = self.mapper.last_h
|
|
75
|
+
|
|
76
|
+
# --- C. SEPARATE BALL & OTHERS ---
|
|
77
|
+
ball_detections = all_dets[all_dets.class_id == BALL_ID]
|
|
78
|
+
ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
|
|
79
|
+
|
|
80
|
+
other_detections = all_dets[all_dets.class_id != BALL_ID]
|
|
81
|
+
other_detections = other_detections.with_nms(threshold=0.5)
|
|
82
|
+
|
|
83
|
+
# --- D. TRACKING ---
|
|
84
|
+
tracked_objects = tracker.update_with_detections(other_detections)
|
|
85
|
+
|
|
86
|
+
# Split tracked objects
|
|
87
|
+
tracked_players = tracked_objects[tracked_objects.class_id == PLAYER_ID]
|
|
88
|
+
tracked_gks = tracked_objects[tracked_objects.class_id == GOALKEEPER_ID]
|
|
89
|
+
tracked_refs = tracked_objects[tracked_objects.class_id == REFEREE_ID]
|
|
90
|
+
|
|
91
|
+
# --- E. TEAM CLASSIFICATION ---
|
|
92
|
+
|
|
93
|
+
# 1. Players
|
|
94
|
+
if len(tracked_players) > 0:
|
|
95
|
+
p_crops = [sv.crop_image(frame, xyxy) for xyxy in tracked_players.xyxy]
|
|
96
|
+
p_pil = [sv.cv2_to_pillow(c) for c in p_crops]
|
|
97
|
+
p_embeddings = self.engine.get_embeddings(p_pil)
|
|
98
|
+
|
|
99
|
+
final_team_ids = []
|
|
100
|
+
for i, tid in enumerate(tracked_players.tracker_id):
|
|
101
|
+
team_id = self.classifier.get_consensus_team(tid, p_embeddings[i])
|
|
102
|
+
final_team_ids.append(team_id)
|
|
103
|
+
|
|
104
|
+
tracked_players.class_id = np.array(final_team_ids)
|
|
105
|
+
|
|
106
|
+
# 2. Goalkeepers
|
|
107
|
+
if len(tracked_gks) > 0 and len(tracked_players) > 0:
|
|
108
|
+
tracked_gks.class_id = self.classifier.resolve_goalkeepers_team_id(
|
|
109
|
+
tracked_players, tracked_gks
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# 3. Referees (Shift ID 3 -> 2)
|
|
113
|
+
if len(tracked_refs) > 0:
|
|
114
|
+
tracked_refs.class_id -= 1
|
|
115
|
+
|
|
116
|
+
# --- F. DATA STORAGE ---
|
|
117
|
+
tracking_results[frame_idx] = {"players": {}, "ball": None}
|
|
118
|
+
data_targets = sv.Detections.merge([tracked_players, tracked_gks])
|
|
119
|
+
|
|
120
|
+
if H is not None:
|
|
121
|
+
if len(data_targets) > 0:
|
|
122
|
+
feet_coords = data_targets.get_anchors_coordinates(
|
|
123
|
+
sv.Position.BOTTOM_CENTER
|
|
124
|
+
)
|
|
125
|
+
transformed_feet = self.mapper.transform(feet_coords, H)
|
|
126
|
+
|
|
127
|
+
for i, tid in enumerate(data_targets.tracker_id):
|
|
128
|
+
px, py = transformed_feet[i]
|
|
129
|
+
tracking_results[frame_idx]["players"][tid] = (
|
|
130
|
+
max(0.0, min(1.0, px)),
|
|
131
|
+
max(0.0, min(1.0, py)),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if len(ball_detections) > 0:
|
|
135
|
+
ball_coords = ball_detections.get_anchors_coordinates(
|
|
136
|
+
sv.Position.CENTER
|
|
137
|
+
)
|
|
138
|
+
transformed_ball = self.mapper.transform([ball_coords[0]], H)
|
|
139
|
+
bx, by = transformed_ball[0]
|
|
140
|
+
tracking_results[frame_idx]["ball"] = (
|
|
141
|
+
max(0.0, min(1.0, bx)),
|
|
142
|
+
max(0.0, min(1.0, by)),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# --- G. DRAW & WRITE VIDEO ---
|
|
146
|
+
all_tracked = sv.Detections.merge(
|
|
147
|
+
[tracked_players, tracked_gks, tracked_refs]
|
|
148
|
+
)
|
|
149
|
+
annotated_frame = self.engine.draw_annotations(
|
|
150
|
+
frame, all_tracked, ball_detections
|
|
151
|
+
)
|
|
152
|
+
out.write(annotated_frame)
|
|
153
|
+
|
|
154
|
+
# 4. Cleanup
|
|
155
|
+
out.release()
|
|
156
|
+
cap.release()
|
|
157
|
+
print(f"WORKER: Video saved to {output_video_path}")
|
|
158
|
+
|
|
159
|
+
# Save CSVs
|
|
160
|
+
final_assignments = self.classifier.get_final_assignments()
|
|
161
|
+
csv_path = output_video_path.replace(".mp4", ".csv")
|
|
162
|
+
DataExporter.save_csvs(
|
|
163
|
+
tracking_results, final_assignments, frame_idx, fps, csv_path
|
|
164
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DataExporter:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def save_csvs(tracking_data, team_assignments, total_frames, fps, output_path):
|
|
8
|
+
"""
|
|
9
|
+
tracking_data: {frame_idx: {"ball": (x,y), "players": {id: (x,y)}}}
|
|
10
|
+
team_assignments: {tracker_id: team_id}
|
|
11
|
+
"""
|
|
12
|
+
path_obj = Path(output_path)
|
|
13
|
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
base_name = str(path_obj.with_suffix(""))
|
|
15
|
+
home_ids = [tid for tid, team in team_assignments.items() if team == 0]
|
|
16
|
+
away_ids = [tid for tid, team in team_assignments.items() if team == 1]
|
|
17
|
+
|
|
18
|
+
def write_file(filename, team_name, ids):
|
|
19
|
+
with open(filename, "w", newline="") as f:
|
|
20
|
+
writer = csv.writer(f)
|
|
21
|
+
writer.writerow(
|
|
22
|
+
["", "", ""] + [team_name for _ in ids for _ in (0, 1)] + ["", ""]
|
|
23
|
+
)
|
|
24
|
+
writer.writerow(
|
|
25
|
+
["", "", ""] + [str(pid) for pid in ids for _ in (0, 1)] + ["", ""]
|
|
26
|
+
)
|
|
27
|
+
writer.writerow(
|
|
28
|
+
["Period", "Frame", "Time [s]"]
|
|
29
|
+
+ [f"Player{pid}_{axis}" for pid in ids for axis in ("X", "Y")]
|
|
30
|
+
+ ["Ball_X", "Ball_Y"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
for f_idx in range(total_frames):
|
|
34
|
+
data = tracking_data.get(f_idx, {"ball": None, "players": {}})
|
|
35
|
+
row = [1, f_idx, f"{f_idx / fps:.2f}"]
|
|
36
|
+
for tid in ids:
|
|
37
|
+
coords = data["players"].get(tid, ("NaN", "NaN"))
|
|
38
|
+
row.extend(coords)
|
|
39
|
+
row.extend(data["ball"] if data["ball"] else ("NaN", "NaN"))
|
|
40
|
+
writer.writerow(row)
|
|
41
|
+
|
|
42
|
+
write_file(f"{base_name}_Home.csv", "Home", sorted(home_ids))
|
|
43
|
+
write_file(f"{base_name}_Away.csv", "Away", sorted(away_ids))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
PITCH_CONFIG = {
|
|
5
|
+
# --- LEFT GOAL LINE ---
|
|
6
|
+
0: (0.000, 0.000), # Top-Left Corner
|
|
7
|
+
1: (0.000, 0.204), # Top Edge of Penalty Box
|
|
8
|
+
2: (0.000, 0.365), # Top Edge of Goal Area
|
|
9
|
+
3: (0.000, 0.635), # Bottom Edge of Goal Area
|
|
10
|
+
4: (0.000, 0.796), # Bottom Edge of Penalty Box
|
|
11
|
+
5: (0.000, 1.000), # Bottom-Left Corner
|
|
12
|
+
# --- LEFT PENALTY AREA ---
|
|
13
|
+
6: (0.052, 0.365),
|
|
14
|
+
7: (0.052, 0.635),
|
|
15
|
+
8: (0.105, 0.500), # Penalty Spot (Left)
|
|
16
|
+
9: (0.157, 0.204),
|
|
17
|
+
10: (0.157, 0.392),
|
|
18
|
+
11: (0.157, 0.608),
|
|
19
|
+
12: (0.157, 0.796),
|
|
20
|
+
# --- MIDFIELD ---
|
|
21
|
+
13: (0.413, 0.500),
|
|
22
|
+
14: (0.500, 0.000),
|
|
23
|
+
15: (0.500, 0.365),
|
|
24
|
+
16: (0.500, 0.635),
|
|
25
|
+
17: (0.500, 1.000),
|
|
26
|
+
18: (0.587, 0.500),
|
|
27
|
+
# --- RIGHT PENALTY AREA ---
|
|
28
|
+
19: (0.843, 0.204),
|
|
29
|
+
20: (0.843, 0.392),
|
|
30
|
+
21: (0.843, 0.608),
|
|
31
|
+
22: (0.843, 0.796),
|
|
32
|
+
23: (0.895, 0.500), # Penalty Spot (Right)
|
|
33
|
+
24: (0.948, 0.365),
|
|
34
|
+
25: (0.948, 0.635),
|
|
35
|
+
# --- RIGHT GOAL LINE ---
|
|
36
|
+
26: (1.000, 0.000),
|
|
37
|
+
27: (1.000, 0.204),
|
|
38
|
+
28: (1.000, 0.365),
|
|
39
|
+
29: (1.000, 0.635),
|
|
40
|
+
30: (1.000, 0.796),
|
|
41
|
+
31: (1.000, 1.000),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PitchMapper:
|
|
46
|
+
def __init__(self, pitch_config=PITCH_CONFIG):
|
|
47
|
+
self.pitch_config = pitch_config
|
|
48
|
+
self.last_h = None
|
|
49
|
+
|
|
50
|
+
def get_matrix(self, keypoints_xy, keypoints_conf):
|
|
51
|
+
src_points = []
|
|
52
|
+
dst_points = []
|
|
53
|
+
|
|
54
|
+
for i, (xy, conf) in enumerate(zip(keypoints_xy, keypoints_conf)):
|
|
55
|
+
if conf > 0.5 and i in self.pitch_config:
|
|
56
|
+
src_points.append(xy)
|
|
57
|
+
dst_points.append(self.pitch_config[i])
|
|
58
|
+
|
|
59
|
+
if len(src_points) >= 4:
|
|
60
|
+
H, _ = cv2.findHomography(
|
|
61
|
+
np.array(src_points), np.array(dst_points), cv2.RANSAC
|
|
62
|
+
)
|
|
63
|
+
self.last_h = H
|
|
64
|
+
|
|
65
|
+
return self.last_h
|
|
66
|
+
|
|
67
|
+
def transform(self, points, H=None):
|
|
68
|
+
target_h = H if H is not None else self.last_h
|
|
69
|
+
if target_h is None or len(points) == 0:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
points_reshaped = np.array(points).reshape(-1, 1, 2).astype(np.float32)
|
|
73
|
+
projected = cv2.perspectiveTransform(points_reshaped, target_h)
|
|
74
|
+
return projected.reshape(-1, 2)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import numpy as np
|
|
4
|
+
import seaborn as sns
|
|
5
|
+
import json
|
|
6
|
+
from scipy.stats import gaussian_kde
|
|
7
|
+
|
|
8
|
+
# === Load the raw tracking data CSV ===
|
|
9
|
+
# NOTE: "header=2" → skip the first two rows (team/labels) and use row 3 as header
|
|
10
|
+
df = pd.read_csv(
|
|
11
|
+
"./data/Sample_Game_1_RawTrackingData_Away_Team.csv",
|
|
12
|
+
header=2,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# === Clean column names so each player has _X and _Y ===
|
|
16
|
+
cleaned_colums = []
|
|
17
|
+
colnames = df.columns.tolist()
|
|
18
|
+
i = 0
|
|
19
|
+
while i < len(colnames):
|
|
20
|
+
col = colnames[i]
|
|
21
|
+
if col.startswith("Player") or col.startswith("Ball"):
|
|
22
|
+
cleaned_colums.append(f"{col}_X")
|
|
23
|
+
cleaned_colums.append(f"{col}_Y")
|
|
24
|
+
i += 2
|
|
25
|
+
else:
|
|
26
|
+
cleaned_colums.append(col)
|
|
27
|
+
i += 1
|
|
28
|
+
df.columns = cleaned_colums
|
|
29
|
+
|
|
30
|
+
print("Columns cleaned. First few rows:")
|
|
31
|
+
print(df.head())
|
|
32
|
+
|
|
33
|
+
# === Extract Player17 (drop NaN values where tracking failed) ===
|
|
34
|
+
player17 = df[["Player17_X", "Player17_Y"]].dropna()
|
|
35
|
+
x = player17["Player17_X"].to_numpy()
|
|
36
|
+
y = player17["Player17_Y"].to_numpy()
|
|
37
|
+
|
|
38
|
+
# === Detect scale (normalized [0,1] or real meters) ===
|
|
39
|
+
if x.max() <= 1.5 and y.max() <= 1.5:
|
|
40
|
+
print("Scaling Player17 data from normalized [0,1] to meters...")
|
|
41
|
+
x = x * 105 # pitch length in meters
|
|
42
|
+
y = y * 68 # pitch width in meters
|
|
43
|
+
else:
|
|
44
|
+
print("Data appears to already be in meters, leaving as is.")
|
|
45
|
+
|
|
46
|
+
print("First 10 points:", list(zip(x[:10], y[:10])))
|
|
47
|
+
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# 1. Scatter Plot (sanity check, raw positions)
|
|
50
|
+
# =============================================================================
|
|
51
|
+
fig, ax = plt.subplots(figsize=(10, 7))
|
|
52
|
+
# Pitch outline
|
|
53
|
+
ax.plot([0, 105, 105, 0, 0], [0, 0, 68, 68, 0], color="black")
|
|
54
|
+
ax.plot([52.5, 52.5], [0, 68], color="black") # halfway line
|
|
55
|
+
# Player positions
|
|
56
|
+
ax.scatter(x, y, s=1, alpha=0.3, color="blue")
|
|
57
|
+
ax.set_xlim(0, 105)
|
|
58
|
+
ax.set_ylim(0, 68)
|
|
59
|
+
ax.set_title("Player17 Movement Scatter (raw positions)")
|
|
60
|
+
plt.savefig("./heatmap/player17_scatter.png", dpi=150, bbox_inches="tight")
|
|
61
|
+
|
|
62
|
+
# =============================================================================
|
|
63
|
+
# 2. Histogram Heatmap (occupancy grid)
|
|
64
|
+
# =============================================================================
|
|
65
|
+
heatmap, xedges, yedges = np.histogram2d(x, y, bins=(50, 34), range=[[0, 105], [0, 68]])
|
|
66
|
+
|
|
67
|
+
fig, ax = plt.subplots(figsize=(10, 7))
|
|
68
|
+
extent = [xedges[0], xedges[-1], yedges[0], yedges[-1]]
|
|
69
|
+
im = ax.imshow(
|
|
70
|
+
heatmap.T, origin="lower", extent=extent, cmap="Blues", alpha=0.7, aspect="auto"
|
|
71
|
+
)
|
|
72
|
+
ax.plot([0, 105, 105, 0, 0], [0, 0, 68, 68, 0], color="black")
|
|
73
|
+
ax.plot([52.5, 52.5], [0, 68], color="black")
|
|
74
|
+
fig.colorbar(im, ax=ax, label="Frames")
|
|
75
|
+
ax.set_title("Player17 Heatmap (Histogram)")
|
|
76
|
+
plt.savefig("./heatmap/player17_histogram.png", dpi=150, bbox_inches="tight")
|
|
77
|
+
|
|
78
|
+
# === Export histogram data as JSON for three.js ===
|
|
79
|
+
heatmap_data = {
|
|
80
|
+
"xedges": xedges.tolist(),
|
|
81
|
+
"yedges": yedges.tolist(),
|
|
82
|
+
"values": heatmap.T.tolist(), # transpose so rows correspond to y-axis correctly
|
|
83
|
+
}
|
|
84
|
+
with open("./heatmap/player17_histogram.json", "w") as f:
|
|
85
|
+
json.dump(heatmap_data, f)
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# 3. KDE Heatmap (smoothed density field)
|
|
89
|
+
# =============================================================================
|
|
90
|
+
values = np.vstack([x, y])
|
|
91
|
+
kde = gaussian_kde(values)
|
|
92
|
+
|
|
93
|
+
# Define mesh grid
|
|
94
|
+
X, Y = np.meshgrid(np.linspace(0, 105, 100), np.linspace(0, 68, 68))
|
|
95
|
+
Z = kde(np.vstack([X.ravel(), Y.ravel()])).reshape(X.shape)
|
|
96
|
+
|
|
97
|
+
fig, ax = plt.subplots(figsize=(10, 7))
|
|
98
|
+
sns.kdeplot(x=x, y=y, fill=True, cmap="Blues", alpha=0.7, thresh=0.05, levels=50, ax=ax)
|
|
99
|
+
ax.plot([0, 105, 105, 0, 0], [0, 0, 68, 68, 0], color="black")
|
|
100
|
+
ax.plot([52.5, 52.5], [0, 68], color="black")
|
|
101
|
+
ax.set_xlim(0, 105)
|
|
102
|
+
ax.set_ylim(0, 68)
|
|
103
|
+
ax.set_title("Player17 Heatmap (KDE Smoothed)")
|
|
104
|
+
plt.savefig("./heatmap/player17_kde.png", dpi=150, bbox_inches="tight")
|
|
105
|
+
|
|
106
|
+
# === Export KDE density field for three.js ===
|
|
107
|
+
kde_data = {
|
|
108
|
+
"x": X[0].tolist(), # x grid coordinates
|
|
109
|
+
"y": Y[:, 0].tolist(), # y grid coordinates
|
|
110
|
+
"values": Z.tolist(), # density values
|
|
111
|
+
}
|
|
112
|
+
with open("./heatmap/player17_kde.json", "w") as f:
|
|
113
|
+
json.dump(kde_data, f)
|
|
114
|
+
|
|
115
|
+
print("Outputs saved: scatter, histogram PNG+JSON, KDE PNG+JSON for Player17")
|