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.
@@ -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,6 @@
1
+ from .vision import VisionEngine
2
+ from .geometry import PitchMapper
3
+ from .teams import TeamClassifier
4
+ from .core import ScoutingPipeline
5
+
6
+ __all__ = ["VisionEngine", "PitchMapper", "TeamClassifier", "ScoutingPipeline"]
@@ -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")