Simon9 commited on
Commit
628400c
Β·
verified Β·
1 Parent(s): c118765

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +488 -527
app.py CHANGED
@@ -1,21 +1,10 @@
1
  import os
2
  import json
 
3
  from collections import deque, defaultdict
4
  from typing import List, Tuple, Dict, Optional, Union, Any
5
  from io import BytesIO
6
  import base64
7
- import time
8
-
9
- # Suppress optional model warnings BEFORE importing inference
10
- os.environ["CORE_MODEL_SAM_ENABLED"] = "False"
11
- os.environ["CORE_MODEL_SAM2_ENABLED"] = "False"
12
- os.environ["CORE_MODEL_SAM3_ENABLED"] = "False"
13
- os.environ["CORE_MODEL_GAZE_ENABLED"] = "False"
14
- os.environ["CORE_MODEL_GROUNDINGDINO_ENABLED"] = "False"
15
- os.environ["CORE_MODEL_YOLO_WORLD_ENABLED"] = "False"
16
-
17
- # Set stable ONNX providers
18
- os.environ["ONNXRUNTIME_EXECUTION_PROVIDERS"] = "CPUExecutionProvider"
19
 
20
  import cv2
21
  import numpy as np
@@ -39,6 +28,7 @@ from sklearn.cluster import KMeans
39
  import umap
40
 
41
  from inference_sdk import InferenceHTTPClient
 
42
 
43
  # ==============================================
44
  # ENVIRONMENT VARIABLES
@@ -52,21 +42,9 @@ if not HF_TOKEN or not ROBOFLOW_API_KEY:
52
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
53
  print(f"πŸ–₯️ Using device: {DEVICE}")
54
 
55
- # ==============================================
56
- # REAL-WORLD PITCH DIMENSIONS
57
- # ==============================================
58
- CONFIG = SoccerPitchConfiguration()
59
 
60
- # Standard football pitch dimensions in meters
61
- PITCH_LENGTH_M = 105.0 # meters (standard: 100-110m)
62
- PITCH_WIDTH_M = 68.0 # meters (standard: 64-75m)
63
-
64
- # Calculate scaling factors from config units to meters
65
- SCALE_X = PITCH_LENGTH_M / CONFIG.length
66
- SCALE_Y = PITCH_WIDTH_M / CONFIG.width
67
-
68
- print(f"πŸ“ Pitch config units - Length: {CONFIG.length}, Width: {CONFIG.width}")
69
- print(f"πŸ“ Scale factors - X: {SCALE_X:.6f} m/unit, Y: {SCALE_Y:.6f} m/unit")
70
 
71
  # ==============================================
72
  # ROBOFLOW INFERENCE CLIENT
@@ -79,57 +57,22 @@ CLIENT = InferenceHTTPClient(
79
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
80
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
81
 
82
- # IDs from Roboflow model
83
- BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
84
-
85
-
86
- def verify_roboflow_api():
87
- """Verify Roboflow API key is valid at startup"""
88
- try:
89
- # Make a simple test request
90
- import requests
91
- response = requests.get(
92
- "https://api.roboflow.com/",
93
- params={"api_key": ROBOFLOW_API_KEY},
94
- timeout=10
95
- )
96
- if response.status_code == 200:
97
- print("βœ… Roboflow API key verified")
98
- return True
99
- elif response.status_code == 403:
100
- print("❌ Roboflow API key is invalid or you've hit usage limits")
101
- print(" Please check your API key in Space secrets")
102
- return False
103
- else:
104
- print(f"⚠️ Roboflow API returned status {response.status_code}")
105
- return True # Continue anyway
106
- except Exception as e:
107
- print(f"⚠️ Could not verify Roboflow API: {e}")
108
- return True # Continue anyway
109
-
110
-
111
- # Verify API at startup
112
- verify_roboflow_api()
113
-
114
 
115
  def infer_with_confidence(
116
  model_id: str,
117
  frame: np.ndarray,
118
  confidence_threshold: float = 0.3,
119
- max_retries: int = 3
 
120
  ):
121
  """
122
- Run inference with retry logic for transient errors.
123
-
124
- Args:
125
- model_id: The model ID to use
126
- frame: Input frame
127
- confidence_threshold: Confidence threshold for detections
128
- max_retries: Maximum number of retry attempts
129
-
130
- Returns:
131
- Tuple of (result, detections)
132
  """
 
 
133
  for attempt in range(max_retries):
134
  try:
135
  result = CLIENT.infer(frame, model_id=model_id)
@@ -137,53 +80,56 @@ def infer_with_confidence(
137
  if len(detections) > 0:
138
  detections = detections[detections.confidence > confidence_threshold]
139
  return result, detections
140
- except Exception as e:
141
- if attempt < max_retries - 1:
142
- delay = 2 ** attempt # exponential backoff: 1s, 2s, 4s
143
- print(f"⚠️ Inference failed (attempt {attempt + 1}/{max_retries}), retrying in {delay}s...")
144
- time.sleep(delay)
 
 
 
 
 
 
 
145
  else:
146
- print(f"❌ All inference attempts failed: {e}")
147
- # Return empty detections to continue processing
148
- return None, sv.Detections.empty()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  # ==============================================
151
  # SIGLIP MODEL (Embeddings)
152
  # ==============================================
153
  SIGLIP_MODEL_PATH = "google/siglip-base-patch16-224"
154
- EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN).to(DEVICE)
155
- EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
 
 
 
 
156
 
157
  # ==============================================
158
- # DISTANCE CALCULATION UTILITIES
159
  # ==============================================
160
- def calculate_real_distance(pos1: np.ndarray, pos2: np.ndarray) -> float:
161
- """
162
- Calculate real-world distance in meters between two pitch positions.
163
- Handles all array shapes robustly.
164
-
165
- Args:
166
- pos1, pos2: positions in pitch coordinate units (any shape with at least 2 elements)
167
-
168
- Returns:
169
- distance in meters (0.0 if invalid input)
170
- """
171
- # Convert to flat arrays
172
- p1 = pos1.flatten()
173
- p2 = pos2.flatten()
174
-
175
- # Validate we have at least 2 elements
176
- if p1.size < 2 or p2.size < 2:
177
- return 0.0
178
-
179
- # Extract x, y coordinates
180
- x1, y1 = float(p1[0]), float(p1[1])
181
- x2, y2 = float(p2[0]), float(p2[1])
182
-
183
- dx = (x2 - x1) * SCALE_X
184
- dy = (y2 - y1) * SCALE_Y
185
- distance_m = np.sqrt(dx**2 + dy**2)
186
- return float(distance_m)
187
 
188
 
189
  # ==============================================
@@ -191,45 +137,26 @@ def calculate_real_distance(pos1: np.ndarray, pos2: np.ndarray) -> float:
191
  # ==============================================
192
  def replace_outliers_based_on_distance(
193
  positions: List[np.ndarray],
194
- distance_threshold_m: float = 50.0 # 50 meters is realistic max between frames
195
  ) -> List[np.ndarray]:
196
- """
197
- Remove outlier positions based on real-world distance threshold in meters.
198
- Ball can't travel more than ~50m between frames at normal frame rates.
199
- """
200
  last_valid_position: Union[np.ndarray, None] = None
201
  cleaned_positions: List[np.ndarray] = []
202
 
203
  for position in positions:
204
- # Handle empty positions
205
- if position.size == 0:
206
- cleaned_positions.append(np.array([], dtype=np.float64))
207
- continue
208
-
209
- # Flatten and validate
210
- pos_flat = position.flatten()
211
- if pos_flat.size < 2:
212
- cleaned_positions.append(np.array([], dtype=np.float64))
213
- continue
214
-
215
- # Take first 2 elements as [x, y]
216
- current_pos = pos_flat[:2]
217
-
218
- if last_valid_position is None:
219
- # First valid position
220
- cleaned_positions.append(current_pos)
221
- last_valid_position = current_pos
222
  else:
223
- # Calculate distance from last valid position
224
- distance_m = calculate_real_distance(last_valid_position, current_pos)
225
-
226
- if distance_m > distance_threshold_m:
227
- # Outlier: ball moved too far
228
- cleaned_positions.append(np.array([], dtype=np.float64))
229
  else:
230
- # Valid position
231
- cleaned_positions.append(current_pos)
232
- last_valid_position = current_pos
 
 
 
233
 
234
  return cleaned_positions
235
 
@@ -238,82 +165,86 @@ def replace_outliers_based_on_distance(
238
  # PLAYER PERFORMANCE TRACKING
239
  # ==============================================
240
  class PlayerPerformanceTracker:
241
- """Track individual player performance metrics with correct real-world scaling"""
242
 
243
- def __init__(self, pitch_config):
244
  self.config = pitch_config
245
- self.player_positions = defaultdict(list) # (x, y, frame) in config units
246
- self.player_velocities = defaultdict(list) # m/s
247
  self.player_distances_m = defaultdict(float)
248
  self.player_team = {}
249
  self.player_stats = defaultdict(lambda: {
250
- 'frames_visible': 0,
251
- 'avg_velocity_m_s': 0.0,
252
- 'max_velocity_m_s': 0.0,
253
- 'time_in_attacking_third_frames': 0,
254
- 'time_in_defensive_third_frames': 0,
255
- 'time_in_middle_third_frames': 0
256
  })
257
 
258
- def update(self, tracker_id: int, position: np.ndarray, team_id: int, frame: int, fps: float):
259
- """Update player position and calculate metrics in real meters."""
260
- if len(position) != 2:
261
  return
262
 
263
  self.player_team[tracker_id] = team_id
264
- self.player_positions[tracker_id].append((position[0], position[1], frame))
265
- self.player_stats[tracker_id]['frames_visible'] += 1
266
 
267
  if len(self.player_positions[tracker_id]) > 1:
268
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
269
- curr_pos = np.array(position)
270
-
271
- # Calculate REAL distance in meters
272
- distance_m = calculate_real_distance(prev_pos, curr_pos)
273
  self.player_distances_m[tracker_id] += distance_m
274
 
275
- # Calculate velocity in m/s
276
  dt = 1.0 / fps
277
  velocity_m_s = distance_m / dt
278
- self.player_velocities[tracker_id].append(velocity_m_s)
279
 
280
- if velocity_m_s > self.player_stats[tracker_id]['max_velocity_m_s']:
281
- self.player_stats[tracker_id]['max_velocity_m_s'] = velocity_m_s
282
 
283
- # Zone calculation (thirds of pitch)
284
- pitch_length = self.config.length
285
- x = position[0]
286
- if x < pitch_length / 3:
287
- self.player_stats[tracker_id]['time_in_defensive_third_frames'] += 1
288
- elif x < 2 * pitch_length / 3:
289
- self.player_stats[tracker_id]['time_in_middle_third_frames'] += 1
290
  else:
291
- self.player_stats[tracker_id]['time_in_attacking_third_frames'] += 1
292
 
293
  def get_player_stats(self, tracker_id: int, fps: float) -> dict:
294
- """Get comprehensive stats for a player in real-world units."""
295
  stats = self.player_stats[tracker_id].copy()
296
 
297
- if len(self.player_velocities[tracker_id]) > 0:
298
- stats['avg_velocity_m_s'] = float(np.mean(self.player_velocities[tracker_id]))
299
 
300
- # Total distance is already in meters
301
- stats['total_distance_m'] = self.player_distances_m[tracker_id]
302
- stats['team_id'] = self.player_team.get(tracker_id, -1)
303
 
304
- # Convert frames to seconds
305
- stats['time_in_defensive_third_s'] = stats['time_in_defensive_third_frames'] / fps
306
- stats['time_in_middle_third_s'] = stats['time_in_middle_third_frames'] / fps
307
- stats['time_in_attacking_third_s'] = stats['time_in_attacking_third_frames'] / fps
 
 
 
 
 
308
 
309
- # Convert m/s to km/h for display
310
- stats['avg_speed_km_h'] = stats['avg_velocity_m_s'] * 3.6
311
- stats['max_speed_km_h'] = stats['max_velocity_m_s'] * 3.6
 
 
 
312
 
313
  return stats
314
 
315
  def generate_heatmap(self, tracker_id: int, resolution: int = 100) -> np.ndarray:
316
- """Generate heatmap for a specific player"""
317
  if tracker_id not in self.player_positions or len(self.player_positions[tracker_id]) == 0:
318
  return np.zeros((resolution, resolution))
319
 
@@ -323,17 +254,17 @@ class PlayerPerformanceTracker:
323
  pitch_width = self.config.width
324
 
325
  heatmap, xedges, yedges = np.histogram2d(
326
- positions[:, 0], positions[:, 1],
 
327
  bins=[resolution, resolution],
328
- range=[[0, pitch_length], [0, pitch_width]]
329
  )
330
 
331
  heatmap = gaussian_filter(heatmap, sigma=3)
332
-
333
  return heatmap.T
334
 
335
  def get_all_players_by_team(self) -> Dict[int, List[int]]:
336
- """Get all player IDs grouped by team"""
337
  teams = defaultdict(list)
338
  for tracker_id, team_id in self.player_team.items():
339
  teams[team_id].append(tracker_id)
@@ -346,31 +277,31 @@ class PlayerPerformanceTracker:
346
  class PlayerTrackingManager:
347
  """Manages persistent player tracking with team assignment stability"""
348
 
349
- def __init__(self, max_history=10):
350
  self.tracker_team_history: Dict[int, List[int]] = defaultdict(list)
351
  self.max_history = max_history
352
  self.active_trackers = set()
353
 
354
  def update_team_assignment(self, tracker_id: int, team_id: int):
355
- """Store team assignment history for each tracker"""
356
  self.tracker_team_history[tracker_id].append(team_id)
357
  if len(self.tracker_team_history[tracker_id]) > self.max_history:
358
  self.tracker_team_history[tracker_id].pop(0)
359
  self.active_trackers.add(tracker_id)
360
 
361
  def get_stable_team_id(self, tracker_id: int, current_team_id: int) -> int:
362
- """Get stable team ID using majority voting from history"""
363
  if tracker_id not in self.tracker_team_history or len(self.tracker_team_history[tracker_id]) < 3:
364
  return current_team_id
365
 
366
  history = self.tracker_team_history[tracker_id]
367
  team_counts = np.bincount(history)
368
- stable_team = np.argmax(team_counts)
369
  return stable_team
370
 
371
  def get_player_count_by_team(self) -> Dict[int, int]:
372
- """Get current count of players per team"""
373
- team_counts = defaultdict(int)
374
  for tracker_id in self.active_trackers:
375
  if tracker_id in self.tracker_team_history and len(self.tracker_team_history[tracker_id]) > 0:
376
  stable_team = self.get_stable_team_id(
@@ -380,64 +311,19 @@ class PlayerTrackingManager:
380
  return team_counts
381
 
382
  def reset_frame(self):
383
- """Reset active trackers for new frame"""
384
  self.active_trackers = set()
385
 
386
 
387
- # ==============================================
388
- # VALIDATION UTILITIES
389
- # ==============================================
390
- def validate_player_stats(performance_tracker: PlayerPerformanceTracker, fps: float, total_frames: int) -> List[str]:
391
- """
392
- Validate that player statistics are realistic.
393
- Returns warnings for unrealistic values.
394
- """
395
- warnings = []
396
-
397
- # Calculate clip duration
398
- match_duration_minutes = (total_frames / fps) / 60.0
399
-
400
- # Professional player typically covers 9-13 km in a 90-minute match
401
- # Scale proportionally for shorter clips
402
- expected_max_distance = 13.0 * (match_duration_minutes / 90.0) * 1000 # in meters
403
-
404
- for tracker_id in performance_tracker.player_positions.keys():
405
- stats = performance_tracker.get_player_stats(tracker_id, fps)
406
-
407
- distance = stats['total_distance_m']
408
- max_speed_kmh = stats['max_speed_km_h']
409
- avg_speed_kmh = stats['avg_speed_km_h']
410
-
411
- if distance > expected_max_distance * 1.5:
412
- warnings.append(
413
- f"⚠️ Player #{tracker_id}: Distance {distance:.1f}m seems high "
414
- f"(expected max ~{expected_max_distance:.1f}m for {match_duration_minutes:.1f} min)"
415
- )
416
-
417
- # Professional players rarely exceed 37 km/h
418
- if max_speed_kmh > 40:
419
- warnings.append(
420
- f"⚠️ Player #{tracker_id}: Max speed {max_speed_kmh:.1f} km/h seems unrealistic "
421
- f"(typical max is 30-37 km/h)"
422
- )
423
-
424
- # Average speed during active play is typically 5-8 km/h
425
- if avg_speed_kmh > 15:
426
- warnings.append(
427
- f"⚠️ Player #{tracker_id}: Avg speed {avg_speed_kmh:.1f} km/h seems too high "
428
- f"(typical average is 5-8 km/h)"
429
- )
430
-
431
- return warnings
432
-
433
-
434
  # ==============================================
435
  # VISUALIZATION FUNCTIONS
436
  # ==============================================
437
- def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTracker,
438
- tracker_id: int,
439
- fps: float) -> np.ndarray:
440
- """Create a single player heatmap overlay on pitch"""
 
 
441
  pitch = draw_pitch(CONFIG)
442
  heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
443
 
@@ -460,42 +346,55 @@ def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTr
460
  result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
461
 
462
  stats = performance_tracker.get_player_stats(tracker_id, fps)
463
- team_color = "Blue" if stats['team_id'] == 0 else "Pink"
464
 
465
  text_lines = [
466
  f"Player #{tracker_id} ({team_color} Team)",
467
  f"Distance: {stats['total_distance_m']:.1f} m",
468
  f"Avg Speed: {stats['avg_speed_km_h']:.2f} km/h",
469
  f"Max Speed: {stats['max_speed_km_h']:.2f} km/h",
470
- f"Frames: {stats['frames_visible']}"
471
  ]
472
 
473
  y_offset = 30
474
  for line in text_lines:
475
  cv2.putText(
476
- result, line, (10, y_offset),
477
- cv2.FONT_HERSHEY_SIMPLEX, 0.6,
478
- (255, 255, 255), 2, cv2.LINE_AA
 
 
 
 
 
479
  )
480
  y_offset += 25
481
 
482
  return result
483
 
484
 
485
- def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker,
486
- fps: float) -> go.Figure:
487
- """Create interactive performance comparison plots"""
 
 
488
  teams = performance_tracker.get_all_players_by_team()
489
 
490
  fig = make_subplots(
491
- rows=2, cols=2,
492
- subplot_titles=('Distance Covered', 'Average Speed', 'Max Speed', 'Activity by Zone'),
493
- specs=[[{'type': 'bar'}, {'type': 'bar'}],
494
- [{'type': 'bar'}, {'type': 'bar'}]]
 
 
 
 
 
 
495
  )
496
 
497
- colors = {0: '#00BFFF', 1: '#FF1493'}
498
- team_names = {0: 'Team 0 (Blue)', 1: 'Team 1 (Pink)'}
499
 
500
  for team_id, player_ids in teams.items():
501
  if team_id not in [0, 1]:
@@ -508,35 +407,59 @@ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker,
508
 
509
  for pid in player_ids:
510
  stats = performance_tracker.get_player_stats(pid, fps)
511
- distances.append(stats['total_distance_m'])
512
- avg_speeds.append(stats['avg_speed_km_h'])
513
- max_speeds.append(stats['max_speed_km_h'])
514
- attacking_time.append(stats['time_in_attacking_third_s'])
515
 
516
  player_labels = [f"#{pid}" for pid in player_ids]
517
 
518
  fig.add_trace(
519
- go.Bar(x=player_labels, y=distances, name=team_names[team_id],
520
- marker_color=colors[team_id], showlegend=True),
521
- row=1, col=1
 
 
 
 
 
 
522
  )
523
 
524
  fig.add_trace(
525
- go.Bar(x=player_labels, y=avg_speeds, name=team_names[team_id],
526
- marker_color=colors[team_id], showlegend=False),
527
- row=1, col=2
 
 
 
 
 
 
528
  )
529
 
530
  fig.add_trace(
531
- go.Bar(x=player_labels, y=max_speeds, name=team_names[team_id],
532
- marker_color=colors[team_id], showlegend=False),
533
- row=2, col=1
 
 
 
 
 
 
534
  )
535
 
536
  fig.add_trace(
537
- go.Bar(x=player_labels, y=attacking_time, name=team_names[team_id],
538
- marker_color=colors[team_id], showlegend=False),
539
- row=2, col=2
 
 
 
 
 
 
540
  )
541
 
542
  fig.update_xaxes(title_text="Players", row=1, col=1)
@@ -549,14 +472,20 @@ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker,
549
  fig.update_yaxes(title_text="Speed (km/h)", row=2, col=1)
550
  fig.update_yaxes(title_text="Time in attacking third (s)", row=2, col=2)
551
 
552
- fig.update_layout(height=800, title_text="Team Performance Comparison", barmode='group')
 
 
 
 
553
 
554
  return fig
555
 
556
 
557
- def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker,
558
- fps: float) -> np.ndarray:
559
- """Create side-by-side team heatmaps"""
 
 
560
  teams = performance_tracker.get_all_players_by_team()
561
 
562
  team_heatmaps = []
@@ -590,8 +519,14 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker,
590
 
591
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
592
  cv2.putText(
593
- result, team_name, (10, 30),
594
- cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2, cv2.LINE_AA
 
 
 
 
 
 
595
  )
596
 
597
  team_heatmaps.append(result)
@@ -608,25 +543,32 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker,
608
  # HELPER FUNCTIONS
609
  # ==============================================
610
  def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detections) -> np.ndarray:
611
- """Assign goalkeepers to the nearest team centroid"""
612
  if len(goalkeepers) == 0 or len(players) == 0:
613
  return np.array([])
614
  goalkeepers_xy = goalkeepers.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
615
  players_xy = players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
616
  team_0_centroid = players_xy[players.class_id == 0].mean(axis=0)
617
  team_1_centroid = players_xy[players.class_id == 1].mean(axis=0)
618
- return np.array([
619
- 0 if np.linalg.norm(gk - team_0_centroid) < np.linalg.norm(gk - team_1_centroid) else 1
620
- for gk in goalkeepers_xy
621
- ])
 
 
622
 
623
 
624
- def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
625
- pitch_referees_xy, ball_path=None):
626
- """Create game-style radar view with ball trail effect"""
 
 
 
 
 
627
  annotated_frame = draw_pitch(CONFIG)
628
 
629
- # Draw ball trail with fading effect
630
  if ball_path is not None and len(ball_path) > 0:
631
  valid_path = [coords for coords in ball_path if len(coords) > 0]
632
  if len(valid_path) > 1:
@@ -634,45 +576,53 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
634
  if len(coords) == 0:
635
  continue
636
  alpha = (i + 1) / min(20, len(valid_path))
637
- color = sv.Color(int(255 * alpha), int(255 * alpha), int(255 * alpha))
 
 
 
 
638
  annotated_frame = draw_points_on_pitch(
639
- CONFIG, coords,
 
640
  face_color=color,
641
  edge_color=sv.Color.BLACK,
642
  radius=int(6 + alpha * 4),
643
- pitch=annotated_frame
644
  )
645
 
646
- # Draw current ball position
647
  if len(pitch_ball_xy) > 0:
648
  annotated_frame = draw_points_on_pitch(
649
- CONFIG, pitch_ball_xy,
 
650
  face_color=sv.Color.WHITE,
651
  edge_color=sv.Color.BLACK,
652
  radius=10,
653
- pitch=annotated_frame
654
  )
655
 
656
- # Draw players
657
  for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
658
  mask = players_class_id == team_id
659
  if np.any(mask):
660
  annotated_frame = draw_points_on_pitch(
661
- CONFIG, pitch_players_xy[mask],
 
662
  face_color=sv.Color.from_hex(color_hex),
663
  edge_color=sv.Color.BLACK,
664
  radius=16,
665
- pitch=annotated_frame
666
  )
667
 
668
- # Draw referees
669
  if len(pitch_referees_xy) > 0:
670
  annotated_frame = draw_points_on_pitch(
671
- CONFIG, pitch_referees_xy,
 
672
  face_color=sv.Color.from_hex("FFD700"),
673
  edge_color=sv.Color.BLACK,
674
  radius=16,
675
- pitch=annotated_frame
676
  )
677
 
678
  return annotated_frame
@@ -681,104 +631,89 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
681
  # ==============================================
682
  # MAIN ANALYSIS PIPELINE
683
  # ==============================================
684
- def analyze_football_video(video_path: str, progress=gr.Progress()
685
- ) -> Tuple[
686
- Optional[str],
687
- Optional[go.Figure],
688
- Optional[str],
689
- Optional[str],
690
- Optional[str],
691
- str,
692
- List[List[float]],
693
- str,
694
- Optional[str]
695
- ]:
 
 
696
  """
697
- Complete football analysis pipeline with proper distance/speed calculations
 
 
 
 
 
698
  """
699
  if not video_path:
700
- return (None, None, None, None, None,
701
- "❌ Please upload a video file.",
702
- [], "No events detected.", None)
703
-
704
- # Test Roboflow API access before processing
705
- print("πŸ” Testing Roboflow API access...")
706
- test_frame = np.zeros((640, 640, 3), dtype=np.uint8)
707
- _, test_det = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, test_frame, 0.3, max_retries=1)
708
-
709
- if test_det is None or len(test_det) == 0:
710
- # API test returned empty, which likely means 403 error
711
- error_msg = """
712
- ❌ **Roboflow API Access Error**
713
-
714
- Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
715
-
716
- **To fix this:**
717
-
718
- 1. **Get a valid API key:**
719
- - Go to https://app.roboflow.com/
720
- - Sign in or create an account
721
- - Click your profile β†’ Settings β†’ Roboflow API
722
- - Copy your **Private API Key**
723
-
724
- 2. **Update the key in your Space:**
725
- - Go to your Space Settings β†’ Variables and secrets
726
- - Find `ROBOFLOW_API_KEY`
727
- - Replace with your new API key
728
- - Restart the Space
729
-
730
- 3. **Check usage limits:**
731
- - Free tier: 10,000 API calls/month
732
- - Check your usage at https://app.roboflow.com/
733
-
734
- **Current API key:** `{}...{}`
735
- """.format(ROBOFLOW_API_KEY[:4], ROBOFLOW_API_KEY[-4:])
736
- return (None, None, None, None, None, error_msg, [], "No events detected.", None)
737
-
738
- print("βœ… Roboflow API is accessible")
739
 
740
  try:
741
  progress(0, desc="πŸ”§ Initializing...")
742
 
 
743
  STRIDE = 30
744
  MAXLEN = 5
745
- MAX_DISTANCE_THRESHOLD_M = 50.0 # realistic max ball travel between frames
746
 
747
- # Managers
748
  tracking_manager = PlayerTrackingManager(max_history=10)
749
  performance_tracker = PlayerPerformanceTracker(CONFIG)
750
 
751
- # Annotators
752
  ellipse_annotator = sv.EllipseAnnotator(
753
- color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
754
- thickness=2
755
  )
756
  label_annotator = sv.LabelAnnotator(
757
- color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
758
- text_color=sv.Color.from_hex('#FFFFFF'),
759
  text_thickness=2,
760
- text_position=sv.Position.BOTTOM_CENTER
761
  )
762
  triangle_annotator = sv.TriangleAnnotator(
763
- color=sv.Color.from_hex('#FFD700'),
764
  base=20,
765
- height=17
766
  )
767
 
768
- # Tracker
769
  tracker = sv.ByteTrack(
770
  track_activation_threshold=0.4,
771
  lost_track_buffer=60,
772
  minimum_matching_threshold=0.85,
773
- frame_rate=30
774
  )
775
  tracker.reset()
776
 
777
  cap = cv2.VideoCapture(video_path)
778
  if not cap.isOpened():
779
- return (None, None, None, None, None,
780
- f"❌ Failed to open video: {video_path}",
781
- [], "No events detected.", None)
 
 
 
 
 
 
 
 
782
 
783
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
784
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
@@ -804,20 +739,33 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
804
  if not ret:
805
  break
806
  if frame_idx % STRIDE == 0:
807
- _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
 
 
808
  detections = detections.with_nms(threshold=0.5, class_agnostic=True)
809
  players_detections = detections[detections.class_id == PLAYER_ID]
810
  if len(players_detections.xyxy) > 0:
811
- crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
 
 
 
812
  player_crops.extend(crops)
813
  frame_idx += 1
814
 
815
  if len(player_crops) == 0:
816
  cap.release()
817
  out.release()
818
- return (None, None, None, None, None,
819
- "❌ No player crops collected.",
820
- [], "No events detected.", None)
 
 
 
 
 
 
 
 
821
 
822
  print(f"βœ… Collected {len(player_crops)} player samples")
823
 
@@ -834,12 +782,10 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
834
  M = deque(maxlen=MAXLEN)
835
  ball_path_raw: List[np.ndarray] = []
836
 
837
- # for radar
838
  last_pitch_players_xy = None
839
  last_players_class_id = None
840
  last_pitch_referees_xy = None
841
 
842
- # stats for events / possession
843
  dt = 1.0 / fps
844
  distance_covered_per_player_m = defaultdict(float)
845
  possession_time_player_s = defaultdict(float)
@@ -847,7 +793,6 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
847
  team_of_player: Dict[int, int] = {}
848
  events: List[Dict[str, Any]] = []
849
 
850
- # event HUD
851
  current_event_text = ""
852
  event_frames_left = 0
853
  EVENT_TEXT_DURATION_FRAMES = int(2.0 * fps)
@@ -855,15 +800,13 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
855
  prev_owner_tid: Optional[int] = None
856
  prev_ball_pos_pitch: Optional[np.ndarray] = None
857
 
858
- # approximate goal centers in pitch coords
859
  goal_centers = {
860
  0: np.array([0.0, CONFIG.width / 2.0]),
861
  1: np.array([CONFIG.length, CONFIG.width / 2.0]),
862
  }
863
 
864
- # thresholds
865
- POSSESSION_RADIUS_M = 5.0
866
- MIN_PASS_TRAVEL_M = 3.0
867
  HIGH_SHOT_SPEED_KM_H = 18.0
868
 
869
  def register_event(ev: Dict[str, Any], text: str):
@@ -883,8 +826,10 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
883
  tracking_manager.reset_frame()
884
 
885
  if frame_idx % 30 == 0:
886
- progress(0.20 + 0.30 * (frame_idx / max(total_frames, 1)),
887
- desc=f"🎬 Processing frame {frame_idx}/{total_frames}")
 
 
888
 
889
  # --- detections ---
890
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
@@ -900,31 +845,37 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
900
  all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
901
  all_detections = tracker.update_with_detections(detections=all_detections)
902
 
903
- goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
904
- players_detections = all_detections[all_detections.class_id == PLAYER_ID]
905
- referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
 
 
 
 
 
 
906
 
907
- # --- team prediction + stabilisation ---
908
  if len(players_detections.xyxy) > 0:
909
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
910
  predicted_teams = team_classifier.predict(crops)
911
  for idx, tracker_id in enumerate(players_detections.tracker_id):
912
- tracking_manager.update_team_assignment(tracker_id, predicted_teams[idx])
 
 
913
  predicted_teams[idx] = tracking_manager.get_stable_team_id(
914
  tracker_id, predicted_teams[idx]
915
  )
916
  players_detections.class_id = predicted_teams
917
 
918
- # goalkeeper teams
919
  if len(goalkeepers_detections) > 0 and len(players_detections) > 0:
920
  goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
921
  players_detections, goalkeepers_detections
922
  )
923
 
924
- # adjust referee class_id
925
  referees_detections.class_id -= 1
926
 
927
- # merged for drawing
928
  merged_dets = sv.Detections.merge(
929
  [players_detections, goalkeepers_detections, referees_detections]
930
  )
@@ -932,7 +883,9 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
932
 
933
  # --- field homography ---
934
  try:
935
- result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL_ID, frame, 0.3)
 
 
936
  key_points = sv.KeyPoints.from_inference(result_field)
937
 
938
  filter_mask = key_points.confidence[0] > 0.5
@@ -943,24 +896,31 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
943
  frame_players_xy_pitch = None
944
 
945
  if len(frame_ref_pts) >= 4:
946
- transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
 
 
947
  M.append(transformer.m)
948
  transformer.m = np.mean(np.array(M), axis=0)
949
 
950
- # ball position in pitch coords
951
- frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
 
952
  pitch_ball_xy = transformer.transform_points(frame_ball_xy)
953
  ball_path_raw.append(pitch_ball_xy)
954
  if len(pitch_ball_xy) > 0:
955
  frame_ball_pos_pitch = pitch_ball_xy[0]
956
 
957
- # all players (incl. keepers)
958
- all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
959
- players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
 
 
 
960
  pitch_players_xy = transformer.transform_points(players_xy)
961
 
962
- # referees
963
- referees_xy = referees_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
 
964
  pitch_referees_xy = transformer.transform_points(referees_xy)
965
 
966
  last_pitch_players_xy = pitch_players_xy
@@ -969,21 +929,18 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
969
 
970
  frame_players_xy_pitch = pitch_players_xy
971
 
972
- # update performance tracker with REAL distance calculations
973
  for idx, tracker_id in enumerate(all_players.tracker_id):
974
  tid_int = int(tracker_id)
975
  team_id = int(all_players.class_id[idx])
976
- pos = pitch_players_xy[idx]
977
- performance_tracker.update(
978
- tid_int, pos, team_id, frame_idx, fps
979
- )
980
 
981
- # distance for HUD
982
- prev_pos_list = performance_tracker.player_positions[tid_int]
983
- if len(prev_pos_list) > 1:
984
- prev_pos = np.array(prev_pos_list[-2][:2])
985
- curr_pos = np.array(prev_pos_list[-1][:2])
986
- dist_m = calculate_real_distance(prev_pos, curr_pos)
987
  distance_covered_per_player_m[tid_int] += dist_m
988
 
989
  team_of_player[tid_int] = team_id
@@ -999,39 +956,37 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
999
  # --- possession owner ---
1000
  owner_tid: Optional[int] = None
1001
  if frame_ball_pos_pitch is not None and frame_players_xy_pitch is not None:
1002
- # Calculate distances in REAL meters
1003
- dists_m = []
1004
- for player_pos in frame_players_xy_pitch:
1005
- dist = calculate_real_distance(frame_ball_pos_pitch, player_pos)
1006
- dists_m.append(dist)
1007
- dists_m = np.array(dists_m)
1008
-
1009
- j = int(np.argmin(dists_m))
1010
- if dists_m[j] < POSSESSION_RADIUS_M:
1011
  owner_tid = int(all_players.tracker_id[j])
1012
 
1013
- # accumulate possession time
1014
  if owner_tid is not None:
1015
  possession_time_player_s[owner_tid] += dt
1016
  owner_team = team_of_player.get(owner_tid)
1017
  if owner_team is not None:
1018
  possession_time_team_s[owner_team] += dt
1019
 
1020
- # --- events (pass, tackle, interception, shot, clearance, possession change) ---
1021
  t_s = frame_idx * dt
1022
 
1023
  if owner_tid != prev_owner_tid:
1024
- if owner_tid is not None and prev_owner_tid is not None \
1025
- and frame_ball_pos_pitch is not None and prev_ball_pos_pitch is not None:
1026
- # ball travel in REAL meters
1027
- travel_m = calculate_real_distance(prev_ball_pos_pitch, frame_ball_pos_pitch)
1028
-
 
 
 
 
1029
  prev_team = team_of_player.get(prev_owner_tid)
1030
  cur_team = team_of_player.get(owner_tid)
1031
 
1032
  if prev_team is not None and cur_team is not None:
1033
  if prev_team == cur_team and travel_m > MIN_PASS_TRAVEL_M:
1034
- # pass
1035
  register_event(
1036
  {
1037
  "type": "pass",
@@ -1042,18 +997,22 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1042
  "team_id": int(cur_team),
1043
  "distance_m": travel_m,
1044
  },
1045
- f"Pass: #{prev_owner_tid} β†’ #{owner_tid} (Team {cur_team}, {travel_m:.1f} m)"
 
1046
  )
1047
  elif prev_team != cur_team:
1048
- # tackle vs interception based on player distance
1049
  d_pp_m = None
1050
- prev_pos_list = performance_tracker.player_positions.get(int(prev_owner_tid))
1051
- cur_pos_list = performance_tracker.player_positions.get(int(owner_tid))
1052
-
1053
- if prev_pos_list and cur_pos_list and len(prev_pos_list) > 0 and len(cur_pos_list) > 0:
1054
- pos_prev = np.array(prev_pos_list[-1][:2])
1055
- pos_cur = np.array(cur_pos_list[-1][:2])
1056
- d_pp_m = calculate_real_distance(pos_prev, pos_cur)
 
 
 
 
1057
 
1058
  ev_type = "tackle"
1059
  label = "Tackle"
@@ -1068,44 +1027,42 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1068
  "from_player_id": int(prev_owner_tid),
1069
  "to_player_id": int(owner_tid),
1070
  "team_id": int(cur_team),
1071
- "player_distance_m": float(d_pp_m) if d_pp_m is not None else None,
1072
  },
1073
- f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}"
1074
  )
1075
 
1076
- # explicit possession change event
1077
  if owner_tid is not None:
1078
  register_event(
1079
  {
1080
  "type": "possession_change",
1081
  "time_s": t_s,
1082
  "frame_idx": frame_idx,
1083
- "from_player_id": int(prev_owner_tid) if prev_owner_tid is not None else None,
 
 
1084
  "to_player_id": int(owner_tid),
1085
  "team_id": int(team_of_player.get(owner_tid, -1)),
1086
  },
1087
- f"Team {team_of_player.get(owner_tid, -1)} now in possession"
1088
  )
1089
 
1090
- # shot / clearance
1091
- if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None \
1092
- and owner_tid is not None:
1093
- # Calculate velocity in REAL m/s
1094
- v = (frame_ball_pos_pitch - prev_ball_pos_pitch)
1095
- v_scaled = np.array([v[0] * SCALE_X, v[1] * SCALE_Y])
1096
- speed_m_s = float(np.linalg.norm(v_scaled)) / dt
1097
  speed_km_h = speed_m_s * 3.6
1098
-
1099
  if speed_km_h > HIGH_SHOT_SPEED_KM_H:
1100
  shooter_team = team_of_player.get(owner_tid)
1101
  if shooter_team is not None:
1102
  target_goal = goal_centers[1 - shooter_team]
1103
  direction = target_goal - frame_ball_pos_pitch
1104
- direction_scaled = np.array([direction[0] * SCALE_X, direction[1] * SCALE_Y])
1105
-
1106
  cos_angle = float(
1107
- np.dot(v_scaled, direction_scaled) /
1108
- (np.linalg.norm(v_scaled) * np.linalg.norm(direction_scaled) + 1e-6)
1109
  )
1110
  if cos_angle > 0.8:
1111
  register_event(
@@ -1117,7 +1074,8 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1117
  "team_id": int(shooter_team),
1118
  "speed_km_h": speed_km_h,
1119
  },
1120
- f"Shot by #{owner_tid} (Team {shooter_team}) – {speed_km_h:.1f} km/h"
 
1121
  )
1122
  else:
1123
  register_event(
@@ -1129,27 +1087,25 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1129
  "team_id": int(shooter_team),
1130
  "speed_km_h": speed_km_h,
1131
  },
1132
- f"Clearance by #{owner_tid} (Team {shooter_team})"
1133
  )
1134
 
1135
  prev_owner_tid = owner_tid
1136
  prev_ball_pos_pitch = frame_ball_pos_pitch
1137
 
1138
- # --- draw frame ---
1139
  annotated_frame = frame.copy()
1140
 
1141
- # labels with speed + distance
1142
  player_labels = []
1143
- if last_pitch_players_xy is not None and len(players_detections) > 0:
1144
  for idx, tid in enumerate(players_detections.tracker_id):
1145
  tid_int = int(tid)
1146
- # estimate instantaneous speed from last two positions
1147
  pos_list = performance_tracker.player_positions[tid_int]
1148
  speed_km_h = 0.0
1149
  if len(pos_list) >= 2:
1150
- prev = np.array(pos_list[-2][:2])
1151
- curr = np.array(pos_list[-1][:2])
1152
- dist_m = calculate_real_distance(prev, curr)
1153
  speed_km_h = (dist_m / dt) * 3.6
1154
 
1155
  d_total_m = distance_covered_per_player_m[tid_int]
@@ -1162,17 +1118,22 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1162
  scene=annotated_frame, detections=players_detections
1163
  )
1164
  annotated_frame = label_annotator.annotate(
1165
- scene=annotated_frame, detections=players_detections, labels=player_labels
 
 
1166
  )
1167
 
1168
  annotated_frame = triangle_annotator.annotate(
1169
  scene=annotated_frame, detections=ball_detections
1170
  )
1171
 
1172
- # possession HUD
1173
  total_poss_time = sum(possession_time_team_s.values()) + 1e-6
1174
- team0_pct = 100.0 * possession_time_team_s.get(0, 0.0) / total_poss_time
1175
- team1_pct = 100.0 * possession_time_team_s.get(1, 0.0) / total_poss_time
 
 
 
 
1176
  hud_text = (
1177
  f"Team 0 Possession: {team0_pct:5.1f}% "
1178
  f"Team 1 Possession: {team1_pct:5.1f}%"
@@ -1196,12 +1157,13 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1196
  cv2.LINE_AA,
1197
  )
1198
 
1199
- # event banner
1200
  if event_frames_left > 0 and current_event_text:
1201
  cv2.rectangle(
1202
- annotated_frame, (20, 20),
 
1203
  (annotated_frame.shape[1] - 20, 90),
1204
- (255, 255, 255), -1
 
1205
  )
1206
  cv2.putText(
1207
  annotated_frame,
@@ -1236,23 +1198,18 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1236
  path_for_cleaning.append(coords)
1237
 
1238
  cleaned_path = replace_outliers_based_on_distance(
1239
- [np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2))
1240
- for p in path_for_cleaning],
1241
- MAX_DISTANCE_THRESHOLD_M
 
 
 
 
 
1242
  )
1243
- print(f"βœ… Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
1244
-
1245
- # -----------------------------------
1246
- # STEP 4: Validate stats
1247
- # -----------------------------------
1248
- warnings = validate_player_stats(performance_tracker, fps, frame_idx)
1249
- if warnings:
1250
- print("\n⚠️ VALIDATION WARNINGS:")
1251
- for warning in warnings:
1252
- print(warning)
1253
 
1254
  # -----------------------------------
1255
- # STEP 5: performance analytics
1256
  # -----------------------------------
1257
  progress(0.70, desc="πŸ“Š Generating performance analytics (Step 5/7)...")
1258
 
@@ -1262,14 +1219,18 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1262
  team_heatmaps = create_combined_heatmaps(performance_tracker, fps)
1263
  cv2.imwrite(team_heatmaps_path, team_heatmaps)
1264
 
1265
- # individual heatmaps (top 6 by distance)
1266
  teams = performance_tracker.get_all_players_by_team()
1267
  top_players = []
1268
  for team_id in [0, 1]:
1269
  if team_id in teams:
1270
  team_players = teams[team_id]
1271
  player_distances = [
1272
- (pid, performance_tracker.get_player_stats(pid, fps)['total_distance_m'])
 
 
 
 
 
1273
  for pid in team_players
1274
  ]
1275
  player_distances.sort(key=lambda x: x[1], reverse=True)
@@ -1277,13 +1238,15 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1277
 
1278
  individual_heatmaps = []
1279
  for pid in top_players[:6]:
1280
- heatmap = create_player_heatmap_visualization(performance_tracker, pid, fps)
 
 
1281
  individual_heatmaps.append(heatmap)
1282
 
1283
  if len(individual_heatmaps) > 0:
1284
  rows = []
1285
  for i in range(0, len(individual_heatmaps), 3):
1286
- row_maps = individual_heatmaps[i:i + 3]
1287
  if len(row_maps) == 3:
1288
  rows.append(np.hstack(row_maps))
1289
  elif len(row_maps) == 2:
@@ -1297,18 +1260,20 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1297
  individual_heatmaps_path = None
1298
 
1299
  # -----------------------------------
1300
- # STEP 6: radar view
1301
  # -----------------------------------
1302
  progress(0.85, desc="πŸ—ΊοΈ Creating game-style radar view (Step 6/7)...")
1303
  radar_path = "/tmp/radar_view_enhanced.png"
1304
  try:
1305
  if last_pitch_players_xy is not None:
1306
  radar_frame = create_game_style_radar(
1307
- pitch_ball_xy=cleaned_path[-1] if cleaned_path else np.empty((0, 2)),
 
 
1308
  pitch_players_xy=last_pitch_players_xy,
1309
  players_class_id=last_players_class_id,
1310
  pitch_referees_xy=last_pitch_referees_xy,
1311
- ball_path=cleaned_path
1312
  )
1313
  cv2.imwrite(radar_path, radar_frame)
1314
  else:
@@ -1318,7 +1283,7 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1318
  radar_path = None
1319
 
1320
  # -----------------------------------
1321
- # STEP 7: summary + tabular stats + events
1322
  # -----------------------------------
1323
  progress(0.92, desc="πŸ“ Building summary & tables (Step 7/7)...")
1324
 
@@ -1327,7 +1292,6 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1327
  summary_lines.append(f"- Total Frames Processed: {frame_idx}")
1328
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1329
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1330
- summary_lines.append(f"- Duration: {frame_idx/fps:.1f} seconds")
1331
  summary_lines.append(
1332
  f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n"
1333
  )
@@ -1341,7 +1305,7 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1341
  summary_lines.append(f"- Players Tracked: {len(teams[team_id])}")
1342
 
1343
  total_dist = sum(
1344
- performance_tracker.get_player_stats(pid, fps)['total_distance_m']
1345
  for pid in teams[team_id]
1346
  )
1347
  avg_dist = total_dist / len(teams[team_id]) if len(teams[team_id]) > 0 else 0
@@ -1352,38 +1316,31 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1352
  summary_lines.append("βœ… 1. Team classifier training")
1353
  summary_lines.append("βœ… 2. Video processing with tracking & events")
1354
  summary_lines.append("βœ… 3. Ball trajectory cleaning")
1355
- summary_lines.append("βœ… 4. Distance/speed validation")
1356
- summary_lines.append("βœ… 5. Performance analytics")
1357
- summary_lines.append("βœ… 6. Heatmaps & radar generation")
1358
-
1359
- if warnings:
1360
- summary_lines.append("\n⚠️ **Validation Warnings:**")
1361
- for warning in warnings[:5]: # Show first 5 warnings
1362
- summary_lines.append(f"- {warning}")
1363
 
1364
  summary_msg = "\n".join(summary_lines)
1365
 
1366
- # ---------- player stats table for Gradio Dataframe ----------
1367
  player_ids = sorted(performance_tracker.player_positions.keys())
1368
  player_stats_rows: List[List[float]] = []
1369
-
1370
  for pid in player_ids:
1371
  stats_p = performance_tracker.get_player_stats(pid, fps)
1372
  possession_s = possession_time_player_s.get(pid, 0.0)
1373
  row = [
1374
  int(pid),
1375
- int(stats_p['team_id']),
1376
- float(stats_p['total_distance_m']),
1377
- float(stats_p['avg_speed_km_h']),
1378
- float(stats_p['max_speed_km_h']),
1379
- float(stats_p['time_in_defensive_third_s']),
1380
- float(stats_p['time_in_middle_third_s']),
1381
- float(stats_p['time_in_attacking_third_s']),
1382
  float(possession_s),
1383
  ]
1384
  player_stats_rows.append(row)
1385
 
1386
- # ---------- events timeline text ----------
1387
  if events:
1388
  lines = []
1389
  for ev in events:
@@ -1419,7 +1376,7 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1419
  else:
1420
  events_text = "No events detected."
1421
 
1422
- # ---------- JSON file with events ----------
1423
  events_json_path = "/tmp/events.json"
1424
  with open(events_json_path, "w", encoding="utf-8") as f:
1425
  json.dump(events, f, indent=2)
@@ -1440,10 +1397,15 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1440
 
1441
  except Exception as e:
1442
  import traceback
 
1443
  traceback.print_exc()
1444
  error_msg = f"❌ Error: {str(e)}"
1445
  return (
1446
- None, None, None, None, None,
 
 
 
 
1447
  error_msg,
1448
  [],
1449
  "No events detected.",
@@ -1455,25 +1417,21 @@ Your ROBOFLOW_API_KEY appears to be invalid or you've hit usage limits.
1455
  # GRADIO INTERFACE
1456
  # ==============================================
1457
  with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1458
- gr.Markdown("""
 
1459
  # ⚽ Advanced Football Video Analyzer
1460
- ### Complete Pipeline with Accurate Distance & Speed Tracking
1461
 
1462
  This application computes:
1463
  - Player & team detection with Roboflow
1464
  - Team classification using SigLIP
1465
  - Persistent tracking with ByteTrack
1466
- - **Realistic distances and speeds** (proper pitch scaling)
1467
  - Ball possession (per team & per player)
1468
  - Events: passes, tackles, interceptions, shots, clearances, possession changes
1469
  - Heatmaps and tactical radar view
1470
- - **Validation warnings** for unrealistic statistics
1471
-
1472
- **Expected realistic values:**
1473
- - Distance covered: 800-1200m per 10 minutes
1474
- - Average speed: 5-8 km/h (during active play)
1475
- - Max speed: 20-35 km/h (sprinting)
1476
- """)
1477
 
1478
  with gr.Row():
1479
  video_input = gr.Video(label="πŸ“€ Upload Football Video")
@@ -1505,7 +1463,9 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
1505
  radar_output = gr.Image(label="Tactical Radar View")
1506
 
1507
  with gr.Tab("πŸ“ˆ Player Stats & Events"):
1508
- gr.Markdown("### Per-player stats (distance, speed, zones, possession time)")
 
 
1509
  player_stats_df = gr.Dataframe(
1510
  headers=[
1511
  "player_id",
@@ -1547,4 +1507,5 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
1547
  )
1548
 
1549
  if __name__ == "__main__":
1550
- iface.launch()
 
 
1
  import os
2
  import json
3
+ import time
4
  from collections import deque, defaultdict
5
  from typing import List, Tuple, Dict, Optional, Union, Any
6
  from io import BytesIO
7
  import base64
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  import cv2
10
  import numpy as np
 
28
  import umap
29
 
30
  from inference_sdk import InferenceHTTPClient
31
+ from inference_sdk.http.errors import HTTPCallErrorError
32
 
33
  # ==============================================
34
  # ENVIRONMENT VARIABLES
 
42
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
43
  print(f"πŸ–₯️ Using device: {DEVICE}")
44
 
45
+ # Units: we treat pitch coordinates as *meters* (same units as SoccerPitchConfiguration)
46
+ METERS_PER_UNIT = 1.0 # keep for clarity, but effectively identity
 
 
47
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  # ==============================================
50
  # ROBOFLOW INFERENCE CLIENT
 
57
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
58
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  def infer_with_confidence(
62
  model_id: str,
63
  frame: np.ndarray,
64
  confidence_threshold: float = 0.3,
65
+ max_retries: int = 3,
66
+ retry_delay: float = 0.5,
67
  ):
68
  """
69
+ Run inference on Roboflow with retries and filter by confidence.
70
+
71
+ If Roboflow returns 5xx errors, we retry a few times. If it still fails,
72
+ we return an empty result and empty detections so the pipeline can keep running.
 
 
 
 
 
 
73
  """
74
+ last_error: Optional[Exception] = None
75
+
76
  for attempt in range(max_retries):
77
  try:
78
  result = CLIENT.infer(frame, model_id=model_id)
 
80
  if len(detections) > 0:
81
  detections = detections[detections.confidence > confidence_threshold]
82
  return result, detections
83
+
84
+ except HTTPCallErrorError as e:
85
+ last_error = e
86
+ status = getattr(e, "status_code", None)
87
+ # Retry only on 5xx
88
+ if status is None or 500 <= status < 600:
89
+ print(
90
+ f"[infer_with_confidence] Roboflow 5xx on model {model_id} "
91
+ f"(attempt {attempt+1}/{max_retries}): {e}"
92
+ )
93
+ time.sleep(retry_delay * (attempt + 1))
94
+ continue
95
  else:
96
+ # 4xx etc – configuration/auth issue; bubble up
97
+ raise
98
+
99
+ except Exception as e:
100
+ last_error = e
101
+ print(f"[infer_with_confidence] Unexpected error on model {model_id}: {e}")
102
+ break
103
+
104
+ # Give up and return empty detections so we don't crash the app
105
+ print(
106
+ f"[infer_with_confidence] Giving up on model {model_id} after "
107
+ f"{max_retries} attempts. Last error: {last_error}"
108
+ )
109
+ h, w = frame.shape[:2]
110
+ empty_result = {
111
+ "predictions": [],
112
+ "image": {"width": int(w), "height": int(h)},
113
+ }
114
+ empty_detections = sv.Detections.empty()
115
+ return empty_result, empty_detections
116
+
117
 
118
  # ==============================================
119
  # SIGLIP MODEL (Embeddings)
120
  # ==============================================
121
  SIGLIP_MODEL_PATH = "google/siglip-base-patch16-224"
122
+ EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(
123
+ SIGLIP_MODEL_PATH, token=HF_TOKEN
124
+ ).to(DEVICE)
125
+ EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(
126
+ SIGLIP_MODEL_PATH, token=HF_TOKEN
127
+ )
128
 
129
  # ==============================================
130
+ # TEAM CONFIG
131
  # ==============================================
132
+ CONFIG = SoccerPitchConfiguration()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
 
135
  # ==============================================
 
137
  # ==============================================
138
  def replace_outliers_based_on_distance(
139
  positions: List[np.ndarray],
140
+ distance_threshold: float
141
  ) -> List[np.ndarray]:
142
+ """Remove outlier positions based on distance threshold (same units as positions)."""
 
 
 
143
  last_valid_position: Union[np.ndarray, None] = None
144
  cleaned_positions: List[np.ndarray] = []
145
 
146
  for position in positions:
147
+ if len(position) == 0:
148
+ cleaned_positions.append(position)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  else:
150
+ if last_valid_position is None:
151
+ cleaned_positions.append(position)
152
+ last_valid_position = position
 
 
 
153
  else:
154
+ distance = np.linalg.norm(position - last_valid_position)
155
+ if distance > distance_threshold:
156
+ cleaned_positions.append(np.array([], dtype=np.float64))
157
+ else:
158
+ cleaned_positions.append(position)
159
+ last_valid_position = position
160
 
161
  return cleaned_positions
162
 
 
165
  # PLAYER PERFORMANCE TRACKING
166
  # ==============================================
167
  class PlayerPerformanceTracker:
168
+ """Track individual player performance metrics and generate heatmaps (units in meters)."""
169
 
170
+ def __init__(self, pitch_config: SoccerPitchConfiguration):
171
  self.config = pitch_config
172
+ self.player_positions = defaultdict(list) # (x_m, y_m, frame)
173
+ self.player_velocities_m_s = defaultdict(list)
174
  self.player_distances_m = defaultdict(float)
175
  self.player_team = {}
176
  self.player_stats = defaultdict(lambda: {
177
+ "frames_visible": 0,
178
+ "avg_velocity_m_s": 0.0,
179
+ "max_velocity_m_s": 0.0,
180
+ "time_in_attacking_third_frames": 0,
181
+ "time_in_defensive_third_frames": 0,
182
+ "time_in_middle_third_frames": 0,
183
  })
184
 
185
+ def update(self, tracker_id: int, position_m: np.ndarray, team_id: int, frame: int, fps: float):
186
+ """Update player position and calculate metrics (position in meters)."""
187
+ if len(position_m) != 2:
188
  return
189
 
190
  self.player_team[tracker_id] = team_id
191
+ self.player_positions[tracker_id].append((position_m[0], position_m[1], frame))
192
+ self.player_stats[tracker_id]["frames_visible"] += 1
193
 
194
  if len(self.player_positions[tracker_id]) > 1:
195
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
196
+ curr_pos = np.array(position_m)
197
+ distance_m = np.linalg.norm(curr_pos - prev_pos)
 
 
198
  self.player_distances_m[tracker_id] += distance_m
199
 
 
200
  dt = 1.0 / fps
201
  velocity_m_s = distance_m / dt
202
+ self.player_velocities_m_s[tracker_id].append(velocity_m_s)
203
 
204
+ if velocity_m_s > self.player_stats[tracker_id]["max_velocity_m_s"]:
205
+ self.player_stats[tracker_id]["max_velocity_m_s"] = velocity_m_s
206
 
207
+ pitch_length_m = self.config.length
208
+ x = position_m[0]
209
+ if x < pitch_length_m / 3:
210
+ self.player_stats[tracker_id]["time_in_defensive_third_frames"] += 1
211
+ elif x < 2 * pitch_length_m / 3:
212
+ self.player_stats[tracker_id]["time_in_middle_third_frames"] += 1
 
213
  else:
214
+ self.player_stats[tracker_id]["time_in_attacking_third_frames"] += 1
215
 
216
  def get_player_stats(self, tracker_id: int, fps: float) -> dict:
217
+ """Get comprehensive stats for a player (meters, m/s, km/h, seconds)."""
218
  stats = self.player_stats[tracker_id].copy()
219
 
220
+ if len(self.player_velocities_m_s[tracker_id]) > 0:
221
+ stats["avg_velocity_m_s"] = float(np.mean(self.player_velocities_m_s[tracker_id]))
222
 
223
+ total_distance_m = self.player_distances_m[tracker_id]
224
+ stats["total_distance_m"] = total_distance_m
225
+ stats["team_id"] = self.player_team.get(tracker_id, -1)
226
 
227
+ stats["time_in_defensive_third_s"] = (
228
+ stats["time_in_defensive_third_frames"] / fps
229
+ )
230
+ stats["time_in_middle_third_s"] = (
231
+ stats["time_in_middle_third_frames"] / fps
232
+ )
233
+ stats["time_in_attacking_third_s"] = (
234
+ stats["time_in_attacking_third_frames"] / fps
235
+ )
236
 
237
+ avg_v_m_s = stats["avg_velocity_m_s"]
238
+ max_v_m_s = stats["max_velocity_m_s"]
239
+ stats["avg_speed_m_s"] = avg_v_m_s
240
+ stats["max_speed_m_s"] = max_v_m_s
241
+ stats["avg_speed_km_h"] = avg_v_m_s * 3.6
242
+ stats["max_speed_km_h"] = max_v_m_s * 3.6
243
 
244
  return stats
245
 
246
  def generate_heatmap(self, tracker_id: int, resolution: int = 100) -> np.ndarray:
247
+ """Generate heatmap for a specific player."""
248
  if tracker_id not in self.player_positions or len(self.player_positions[tracker_id]) == 0:
249
  return np.zeros((resolution, resolution))
250
 
 
254
  pitch_width = self.config.width
255
 
256
  heatmap, xedges, yedges = np.histogram2d(
257
+ positions[:, 0],
258
+ positions[:, 1],
259
  bins=[resolution, resolution],
260
+ range=[[0, pitch_length], [0, pitch_width]],
261
  )
262
 
263
  heatmap = gaussian_filter(heatmap, sigma=3)
 
264
  return heatmap.T
265
 
266
  def get_all_players_by_team(self) -> Dict[int, List[int]]:
267
+ """Get all player IDs grouped by team."""
268
  teams = defaultdict(list)
269
  for tracker_id, team_id in self.player_team.items():
270
  teams[team_id].append(tracker_id)
 
277
  class PlayerTrackingManager:
278
  """Manages persistent player tracking with team assignment stability"""
279
 
280
+ def __init__(self, max_history: int = 10):
281
  self.tracker_team_history: Dict[int, List[int]] = defaultdict(list)
282
  self.max_history = max_history
283
  self.active_trackers = set()
284
 
285
  def update_team_assignment(self, tracker_id: int, team_id: int):
286
+ """Store team assignment history for each tracker."""
287
  self.tracker_team_history[tracker_id].append(team_id)
288
  if len(self.tracker_team_history[tracker_id]) > self.max_history:
289
  self.tracker_team_history[tracker_id].pop(0)
290
  self.active_trackers.add(tracker_id)
291
 
292
  def get_stable_team_id(self, tracker_id: int, current_team_id: int) -> int:
293
+ """Get stable team ID using majority voting from history."""
294
  if tracker_id not in self.tracker_team_history or len(self.tracker_team_history[tracker_id]) < 3:
295
  return current_team_id
296
 
297
  history = self.tracker_team_history[tracker_id]
298
  team_counts = np.bincount(history)
299
+ stable_team = int(np.argmax(team_counts))
300
  return stable_team
301
 
302
  def get_player_count_by_team(self) -> Dict[int, int]:
303
+ """Get current count of players per team."""
304
+ team_counts: Dict[int, int] = defaultdict(int)
305
  for tracker_id in self.active_trackers:
306
  if tracker_id in self.tracker_team_history and len(self.tracker_team_history[tracker_id]) > 0:
307
  stable_team = self.get_stable_team_id(
 
311
  return team_counts
312
 
313
  def reset_frame(self):
314
+ """Reset active trackers for new frame."""
315
  self.active_trackers = set()
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  # ==============================================
319
  # VISUALIZATION FUNCTIONS
320
  # ==============================================
321
+ def create_player_heatmap_visualization(
322
+ performance_tracker: PlayerPerformanceTracker,
323
+ tracker_id: int,
324
+ fps: float,
325
+ ) -> np.ndarray:
326
+ """Create a single player heatmap overlay on pitch."""
327
  pitch = draw_pitch(CONFIG)
328
  heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
329
 
 
346
  result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
347
 
348
  stats = performance_tracker.get_player_stats(tracker_id, fps)
349
+ team_color = "Blue" if stats["team_id"] == 0 else "Pink"
350
 
351
  text_lines = [
352
  f"Player #{tracker_id} ({team_color} Team)",
353
  f"Distance: {stats['total_distance_m']:.1f} m",
354
  f"Avg Speed: {stats['avg_speed_km_h']:.2f} km/h",
355
  f"Max Speed: {stats['max_speed_km_h']:.2f} km/h",
356
+ f"Frames: {stats['frames_visible']}",
357
  ]
358
 
359
  y_offset = 30
360
  for line in text_lines:
361
  cv2.putText(
362
+ result,
363
+ line,
364
+ (10, y_offset),
365
+ cv2.FONT_HERSHEY_SIMPLEX,
366
+ 0.6,
367
+ (255, 255, 255),
368
+ 2,
369
+ cv2.LINE_AA,
370
  )
371
  y_offset += 25
372
 
373
  return result
374
 
375
 
376
+ def create_team_comparison_plot(
377
+ performance_tracker: PlayerPerformanceTracker,
378
+ fps: float,
379
+ ) -> go.Figure:
380
+ """Create interactive performance comparison plots."""
381
  teams = performance_tracker.get_all_players_by_team()
382
 
383
  fig = make_subplots(
384
+ rows=2,
385
+ cols=2,
386
+ subplot_titles=(
387
+ "Distance Covered",
388
+ "Average Speed",
389
+ "Max Speed",
390
+ "Activity by Zone",
391
+ ),
392
+ specs=[[{"type": "bar"}, {"type": "bar"}],
393
+ [{"type": "bar"}, {"type": "bar"}]],
394
  )
395
 
396
+ colors = {0: "#00BFFF", 1: "#FF1493"}
397
+ team_names = {0: "Team 0 (Blue)", 1: "Team 1 (Pink)"}
398
 
399
  for team_id, player_ids in teams.items():
400
  if team_id not in [0, 1]:
 
407
 
408
  for pid in player_ids:
409
  stats = performance_tracker.get_player_stats(pid, fps)
410
+ distances.append(stats["total_distance_m"])
411
+ avg_speeds.append(stats["avg_speed_km_h"])
412
+ max_speeds.append(stats["max_speed_km_h"])
413
+ attacking_time.append(stats["time_in_attacking_third_s"])
414
 
415
  player_labels = [f"#{pid}" for pid in player_ids]
416
 
417
  fig.add_trace(
418
+ go.Bar(
419
+ x=player_labels,
420
+ y=distances,
421
+ name=team_names[team_id],
422
+ marker_color=colors[team_id],
423
+ showlegend=True,
424
+ ),
425
+ row=1,
426
+ col=1,
427
  )
428
 
429
  fig.add_trace(
430
+ go.Bar(
431
+ x=player_labels,
432
+ y=avg_speeds,
433
+ name=team_names[team_id],
434
+ marker_color=colors[team_id],
435
+ showlegend=False,
436
+ ),
437
+ row=1,
438
+ col=2,
439
  )
440
 
441
  fig.add_trace(
442
+ go.Bar(
443
+ x=player_labels,
444
+ y=max_speeds,
445
+ name=team_names[team_id],
446
+ marker_color=colors[team_id],
447
+ showlegend=False,
448
+ ),
449
+ row=2,
450
+ col=1,
451
  )
452
 
453
  fig.add_trace(
454
+ go.Bar(
455
+ x=player_labels,
456
+ y=attacking_time,
457
+ name=team_names[team_id],
458
+ marker_color=colors[team_id],
459
+ showlegend=False,
460
+ ),
461
+ row=2,
462
+ col=2,
463
  )
464
 
465
  fig.update_xaxes(title_text="Players", row=1, col=1)
 
472
  fig.update_yaxes(title_text="Speed (km/h)", row=2, col=1)
473
  fig.update_yaxes(title_text="Time in attacking third (s)", row=2, col=2)
474
 
475
+ fig.update_layout(
476
+ height=800,
477
+ title_text="Team Performance Comparison",
478
+ barmode="group",
479
+ )
480
 
481
  return fig
482
 
483
 
484
+ def create_combined_heatmaps(
485
+ performance_tracker: PlayerPerformanceTracker,
486
+ fps: float,
487
+ ) -> np.ndarray:
488
+ """Create side-by-side team heatmaps."""
489
  teams = performance_tracker.get_all_players_by_team()
490
 
491
  team_heatmaps = []
 
519
 
520
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
521
  cv2.putText(
522
+ result,
523
+ team_name,
524
+ (10, 30),
525
+ cv2.FONT_HERSHEY_SIMPLEX,
526
+ 1,
527
+ (255, 255, 255),
528
+ 2,
529
+ cv2.LINE_AA,
530
  )
531
 
532
  team_heatmaps.append(result)
 
543
  # HELPER FUNCTIONS
544
  # ==============================================
545
  def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detections) -> np.ndarray:
546
+ """Assign goalkeepers to the nearest team centroid."""
547
  if len(goalkeepers) == 0 or len(players) == 0:
548
  return np.array([])
549
  goalkeepers_xy = goalkeepers.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
550
  players_xy = players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
551
  team_0_centroid = players_xy[players.class_id == 0].mean(axis=0)
552
  team_1_centroid = players_xy[players.class_id == 1].mean(axis=0)
553
+ return np.array(
554
+ [
555
+ 0 if np.linalg.norm(gk - team_0_centroid) < np.linalg.norm(gk - team_1_centroid) else 1
556
+ for gk in goalkeepers_xy
557
+ ]
558
+ )
559
 
560
 
561
+ def create_game_style_radar(
562
+ pitch_ball_xy: np.ndarray,
563
+ pitch_players_xy: np.ndarray,
564
+ players_class_id: np.ndarray,
565
+ pitch_referees_xy: np.ndarray,
566
+ ball_path: Optional[List[np.ndarray]] = None,
567
+ ) -> np.ndarray:
568
+ """Create game-style radar view with ball trail effect."""
569
  annotated_frame = draw_pitch(CONFIG)
570
 
571
+ # Ball trail
572
  if ball_path is not None and len(ball_path) > 0:
573
  valid_path = [coords for coords in ball_path if len(coords) > 0]
574
  if len(valid_path) > 1:
 
576
  if len(coords) == 0:
577
  continue
578
  alpha = (i + 1) / min(20, len(valid_path))
579
+ color = sv.Color(
580
+ int(255 * alpha),
581
+ int(255 * alpha),
582
+ int(255 * alpha),
583
+ )
584
  annotated_frame = draw_points_on_pitch(
585
+ CONFIG,
586
+ coords,
587
  face_color=color,
588
  edge_color=sv.Color.BLACK,
589
  radius=int(6 + alpha * 4),
590
+ pitch=annotated_frame,
591
  )
592
 
593
+ # Current ball
594
  if len(pitch_ball_xy) > 0:
595
  annotated_frame = draw_points_on_pitch(
596
+ CONFIG,
597
+ pitch_ball_xy,
598
  face_color=sv.Color.WHITE,
599
  edge_color=sv.Color.BLACK,
600
  radius=10,
601
+ pitch=annotated_frame,
602
  )
603
 
604
+ # Players
605
  for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
606
  mask = players_class_id == team_id
607
  if np.any(mask):
608
  annotated_frame = draw_points_on_pitch(
609
+ CONFIG,
610
+ pitch_players_xy[mask],
611
  face_color=sv.Color.from_hex(color_hex),
612
  edge_color=sv.Color.BLACK,
613
  radius=16,
614
+ pitch=annotated_frame,
615
  )
616
 
617
+ # Referees
618
  if len(pitch_referees_xy) > 0:
619
  annotated_frame = draw_points_on_pitch(
620
+ CONFIG,
621
+ pitch_referees_xy,
622
  face_color=sv.Color.from_hex("FFD700"),
623
  edge_color=sv.Color.BLACK,
624
  radius=16,
625
+ pitch=annotated_frame,
626
  )
627
 
628
  return annotated_frame
 
631
  # ==============================================
632
  # MAIN ANALYSIS PIPELINE
633
  # ==============================================
634
+ def analyze_football_video(
635
+ video_path: str,
636
+ progress=gr.Progress(),
637
+ ) -> Tuple[
638
+ Optional[str],
639
+ Optional[go.Figure],
640
+ Optional[str],
641
+ Optional[str],
642
+ Optional[str],
643
+ str,
644
+ List[List[float]],
645
+ str,
646
+ Optional[str],
647
+ ]:
648
  """
649
+ Complete football analysis pipeline:
650
+ * team classification
651
+ * tracking + speeds/distances
652
+ * possession per team & per player
653
+ * events: passes, tackles, interceptions, shots, clearances, possession changes
654
+ * heatmaps + radar
655
  """
656
  if not video_path:
657
+ return (
658
+ None,
659
+ None,
660
+ None,
661
+ None,
662
+ None,
663
+ "❌ Please upload a video file.",
664
+ [],
665
+ "No events detected.",
666
+ None,
667
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
  try:
670
  progress(0, desc="πŸ”§ Initializing...")
671
 
672
+ BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
673
  STRIDE = 30
674
  MAXLEN = 5
675
+ MAX_DISTANCE_THRESHOLD = 50.0 # meters – generous for smoothing outliers
676
 
 
677
  tracking_manager = PlayerTrackingManager(max_history=10)
678
  performance_tracker = PlayerPerformanceTracker(CONFIG)
679
 
 
680
  ellipse_annotator = sv.EllipseAnnotator(
681
+ color=sv.ColorPalette.from_hex(["#00BFFF", "#FF1493", "#FFD700"]),
682
+ thickness=2,
683
  )
684
  label_annotator = sv.LabelAnnotator(
685
+ color=sv.ColorPalette.from_hex(["#00BFFF", "#FF1493", "#FFD700"]),
686
+ text_color=sv.Color.from_hex("#FFFFFF"),
687
  text_thickness=2,
688
+ text_position=sv.Position.BOTTOM_CENTER,
689
  )
690
  triangle_annotator = sv.TriangleAnnotator(
691
+ color=sv.Color.from_hex("#FFD700"),
692
  base=20,
693
+ height=17,
694
  )
695
 
 
696
  tracker = sv.ByteTrack(
697
  track_activation_threshold=0.4,
698
  lost_track_buffer=60,
699
  minimum_matching_threshold=0.85,
700
+ frame_rate=30,
701
  )
702
  tracker.reset()
703
 
704
  cap = cv2.VideoCapture(video_path)
705
  if not cap.isOpened():
706
+ return (
707
+ None,
708
+ None,
709
+ None,
710
+ None,
711
+ None,
712
+ f"❌ Failed to open video: {video_path}",
713
+ [],
714
+ "No events detected.",
715
+ None,
716
+ )
717
 
718
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
719
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
 
739
  if not ret:
740
  break
741
  if frame_idx % STRIDE == 0:
742
+ _, detections = infer_with_confidence(
743
+ PLAYER_DETECTION_MODEL_ID, frame, 0.3
744
+ )
745
  detections = detections.with_nms(threshold=0.5, class_agnostic=True)
746
  players_detections = detections[detections.class_id == PLAYER_ID]
747
  if len(players_detections.xyxy) > 0:
748
+ crops = [
749
+ sv.crop_image(frame, xyxy)
750
+ for xyxy in players_detections.xyxy
751
+ ]
752
  player_crops.extend(crops)
753
  frame_idx += 1
754
 
755
  if len(player_crops) == 0:
756
  cap.release()
757
  out.release()
758
+ return (
759
+ None,
760
+ None,
761
+ None,
762
+ None,
763
+ None,
764
+ "❌ No player crops collected.",
765
+ [],
766
+ "No events detected.",
767
+ None,
768
+ )
769
 
770
  print(f"βœ… Collected {len(player_crops)} player samples")
771
 
 
782
  M = deque(maxlen=MAXLEN)
783
  ball_path_raw: List[np.ndarray] = []
784
 
 
785
  last_pitch_players_xy = None
786
  last_players_class_id = None
787
  last_pitch_referees_xy = None
788
 
 
789
  dt = 1.0 / fps
790
  distance_covered_per_player_m = defaultdict(float)
791
  possession_time_player_s = defaultdict(float)
 
793
  team_of_player: Dict[int, int] = {}
794
  events: List[Dict[str, Any]] = []
795
 
 
796
  current_event_text = ""
797
  event_frames_left = 0
798
  EVENT_TEXT_DURATION_FRAMES = int(2.0 * fps)
 
800
  prev_owner_tid: Optional[int] = None
801
  prev_ball_pos_pitch: Optional[np.ndarray] = None
802
 
 
803
  goal_centers = {
804
  0: np.array([0.0, CONFIG.width / 2.0]),
805
  1: np.array([CONFIG.length, CONFIG.width / 2.0]),
806
  }
807
 
808
+ POSSESSION_RADIUS_M = 4.0
809
+ MIN_PASS_TRAVEL_M = 2.0
 
810
  HIGH_SHOT_SPEED_KM_H = 18.0
811
 
812
  def register_event(ev: Dict[str, Any], text: str):
 
826
  tracking_manager.reset_frame()
827
 
828
  if frame_idx % 30 == 0:
829
+ progress(
830
+ 0.20 + 0.30 * (frame_idx / max(total_frames, 1)),
831
+ desc=f"🎬 Processing frame {frame_idx}/{total_frames}",
832
+ )
833
 
834
  # --- detections ---
835
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
 
845
  all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
846
  all_detections = tracker.update_with_detections(detections=all_detections)
847
 
848
+ goalkeepers_detections = all_detections[
849
+ all_detections.class_id == GOALKEEPER_ID
850
+ ]
851
+ players_detections = all_detections[
852
+ all_detections.class_id == PLAYER_ID
853
+ ]
854
+ referees_detections = all_detections[
855
+ all_detections.class_id == REFEREE_ID
856
+ ]
857
 
858
+ # Team prediction + stabilisation
859
  if len(players_detections.xyxy) > 0:
860
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
861
  predicted_teams = team_classifier.predict(crops)
862
  for idx, tracker_id in enumerate(players_detections.tracker_id):
863
+ tracking_manager.update_team_assignment(
864
+ tracker_id, predicted_teams[idx]
865
+ )
866
  predicted_teams[idx] = tracking_manager.get_stable_team_id(
867
  tracker_id, predicted_teams[idx]
868
  )
869
  players_detections.class_id = predicted_teams
870
 
871
+ # Goalkeeper teams
872
  if len(goalkeepers_detections) > 0 and len(players_detections) > 0:
873
  goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
874
  players_detections, goalkeepers_detections
875
  )
876
 
 
877
  referees_detections.class_id -= 1
878
 
 
879
  merged_dets = sv.Detections.merge(
880
  [players_detections, goalkeepers_detections, referees_detections]
881
  )
 
883
 
884
  # --- field homography ---
885
  try:
886
+ result_field, _ = infer_with_confidence(
887
+ FIELD_DETECTION_MODEL_ID, frame, 0.3
888
+ )
889
  key_points = sv.KeyPoints.from_inference(result_field)
890
 
891
  filter_mask = key_points.confidence[0] > 0.5
 
896
  frame_players_xy_pitch = None
897
 
898
  if len(frame_ref_pts) >= 4:
899
+ transformer = ViewTransformer(
900
+ source=frame_ref_pts, target=pitch_ref_pts
901
+ )
902
  M.append(transformer.m)
903
  transformer.m = np.mean(np.array(M), axis=0)
904
 
905
+ frame_ball_xy = ball_detections.get_anchors_coordinates(
906
+ sv.Position.BOTTOM_CENTER
907
+ )
908
  pitch_ball_xy = transformer.transform_points(frame_ball_xy)
909
  ball_path_raw.append(pitch_ball_xy)
910
  if len(pitch_ball_xy) > 0:
911
  frame_ball_pos_pitch = pitch_ball_xy[0]
912
 
913
+ all_players = sv.Detections.merge(
914
+ [players_detections, goalkeepers_detections]
915
+ )
916
+ players_xy = all_players.get_anchors_coordinates(
917
+ sv.Position.BOTTOM_CENTER
918
+ )
919
  pitch_players_xy = transformer.transform_points(players_xy)
920
 
921
+ referees_xy = referees_detections.get_anchors_coordinates(
922
+ sv.Position.BOTTOM_CENTER
923
+ )
924
  pitch_referees_xy = transformer.transform_points(referees_xy)
925
 
926
  last_pitch_players_xy = pitch_players_xy
 
929
 
930
  frame_players_xy_pitch = pitch_players_xy
931
 
932
+ # performance tracker + distance
933
  for idx, tracker_id in enumerate(all_players.tracker_id):
934
  tid_int = int(tracker_id)
935
  team_id = int(all_players.class_id[idx])
936
+ pos_m = pitch_players_xy[idx]
937
+ performance_tracker.update(tid_int, pos_m, team_id, frame_idx, fps)
 
 
938
 
939
+ if len(performance_tracker.player_positions[tid_int]) > 1:
940
+ prev_pos = np.array(
941
+ performance_tracker.player_positions[tid_int][-2][:2]
942
+ )
943
+ dist_m = float(np.linalg.norm(pos_m - prev_pos))
 
944
  distance_covered_per_player_m[tid_int] += dist_m
945
 
946
  team_of_player[tid_int] = team_id
 
956
  # --- possession owner ---
957
  owner_tid: Optional[int] = None
958
  if frame_ball_pos_pitch is not None and frame_players_xy_pitch is not None:
959
+ dists = np.linalg.norm(
960
+ frame_players_xy_pitch - frame_ball_pos_pitch, axis=1
961
+ )
962
+ j = int(np.argmin(dists))
963
+ if dists[j] < POSSESSION_RADIUS_M:
 
 
 
 
964
  owner_tid = int(all_players.tracker_id[j])
965
 
 
966
  if owner_tid is not None:
967
  possession_time_player_s[owner_tid] += dt
968
  owner_team = team_of_player.get(owner_tid)
969
  if owner_team is not None:
970
  possession_time_team_s[owner_team] += dt
971
 
972
+ # --- events ---
973
  t_s = frame_idx * dt
974
 
975
  if owner_tid != prev_owner_tid:
976
+ if (
977
+ owner_tid is not None
978
+ and prev_owner_tid is not None
979
+ and frame_ball_pos_pitch is not None
980
+ and prev_ball_pos_pitch is not None
981
+ ):
982
+ travel_m = float(
983
+ np.linalg.norm(frame_ball_pos_pitch - prev_ball_pos_pitch)
984
+ )
985
  prev_team = team_of_player.get(prev_owner_tid)
986
  cur_team = team_of_player.get(owner_tid)
987
 
988
  if prev_team is not None and cur_team is not None:
989
  if prev_team == cur_team and travel_m > MIN_PASS_TRAVEL_M:
 
990
  register_event(
991
  {
992
  "type": "pass",
 
997
  "team_id": int(cur_team),
998
  "distance_m": travel_m,
999
  },
1000
+ f"Pass: #{prev_owner_tid} β†’ #{owner_tid} "
1001
+ f"(Team {cur_team}, {travel_m:.1f} m)",
1002
  )
1003
  elif prev_team != cur_team:
 
1004
  d_pp_m = None
1005
+ if frame_players_xy_pitch is not None:
1006
+ pos_prev_list = performance_tracker.player_positions[
1007
+ int(prev_owner_tid)
1008
+ ]
1009
+ pos_cur_list = performance_tracker.player_positions[
1010
+ int(owner_tid)
1011
+ ]
1012
+ if pos_prev_list and pos_cur_list:
1013
+ pos_prev = np.array(pos_prev_list[-1][:2])
1014
+ pos_cur = np.array(pos_cur_list[-1][:2])
1015
+ d_pp_m = float(np.linalg.norm(pos_prev - pos_cur))
1016
 
1017
  ev_type = "tackle"
1018
  label = "Tackle"
 
1027
  "from_player_id": int(prev_owner_tid),
1028
  "to_player_id": int(owner_tid),
1029
  "team_id": int(cur_team),
1030
+ "player_distance_m": d_pp_m,
1031
  },
1032
+ f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}",
1033
  )
1034
 
 
1035
  if owner_tid is not None:
1036
  register_event(
1037
  {
1038
  "type": "possession_change",
1039
  "time_s": t_s,
1040
  "frame_idx": frame_idx,
1041
+ "from_player_id": int(prev_owner_tid)
1042
+ if prev_owner_tid is not None
1043
+ else None,
1044
  "to_player_id": int(owner_tid),
1045
  "team_id": int(team_of_player.get(owner_tid, -1)),
1046
  },
1047
+ f"Team {team_of_player.get(owner_tid, -1)} now in possession",
1048
  )
1049
 
1050
+ if (
1051
+ prev_ball_pos_pitch is not None
1052
+ and frame_ball_pos_pitch is not None
1053
+ and owner_tid is not None
1054
+ ):
1055
+ v = (frame_ball_pos_pitch - prev_ball_pos_pitch) / dt
1056
+ speed_m_s = float(np.linalg.norm(v))
1057
  speed_km_h = speed_m_s * 3.6
 
1058
  if speed_km_h > HIGH_SHOT_SPEED_KM_H:
1059
  shooter_team = team_of_player.get(owner_tid)
1060
  if shooter_team is not None:
1061
  target_goal = goal_centers[1 - shooter_team]
1062
  direction = target_goal - frame_ball_pos_pitch
 
 
1063
  cos_angle = float(
1064
+ np.dot(v, direction)
1065
+ / (np.linalg.norm(v) * np.linalg.norm(direction) + 1e-6)
1066
  )
1067
  if cos_angle > 0.8:
1068
  register_event(
 
1074
  "team_id": int(shooter_team),
1075
  "speed_km_h": speed_km_h,
1076
  },
1077
+ f"Shot by #{owner_tid} (Team {shooter_team}) – "
1078
+ f"{speed_km_h:.1f} km/h",
1079
  )
1080
  else:
1081
  register_event(
 
1087
  "team_id": int(shooter_team),
1088
  "speed_km_h": speed_km_h,
1089
  },
1090
+ f"Clearance by #{owner_tid} (Team {shooter_team})",
1091
  )
1092
 
1093
  prev_owner_tid = owner_tid
1094
  prev_ball_pos_pitch = frame_ball_pos_pitch
1095
 
1096
+ # --- drawing ---
1097
  annotated_frame = frame.copy()
1098
 
 
1099
  player_labels = []
1100
+ if len(players_detections) > 0:
1101
  for idx, tid in enumerate(players_detections.tracker_id):
1102
  tid_int = int(tid)
 
1103
  pos_list = performance_tracker.player_positions[tid_int]
1104
  speed_km_h = 0.0
1105
  if len(pos_list) >= 2:
1106
+ prev_m = np.array(pos_list[-2][:2])
1107
+ curr_m = np.array(pos_list[-1][:2])
1108
+ dist_m = float(np.linalg.norm(curr_m - prev_m))
1109
  speed_km_h = (dist_m / dt) * 3.6
1110
 
1111
  d_total_m = distance_covered_per_player_m[tid_int]
 
1118
  scene=annotated_frame, detections=players_detections
1119
  )
1120
  annotated_frame = label_annotator.annotate(
1121
+ scene=annotated_frame,
1122
+ detections=players_detections,
1123
+ labels=player_labels,
1124
  )
1125
 
1126
  annotated_frame = triangle_annotator.annotate(
1127
  scene=annotated_frame, detections=ball_detections
1128
  )
1129
 
 
1130
  total_poss_time = sum(possession_time_team_s.values()) + 1e-6
1131
+ team0_pct = (
1132
+ 100.0 * possession_time_team_s.get(0, 0.0) / total_poss_time
1133
+ )
1134
+ team1_pct = (
1135
+ 100.0 * possession_time_team_s.get(1, 0.0) / total_poss_time
1136
+ )
1137
  hud_text = (
1138
  f"Team 0 Possession: {team0_pct:5.1f}% "
1139
  f"Team 1 Possession: {team1_pct:5.1f}%"
 
1157
  cv2.LINE_AA,
1158
  )
1159
 
 
1160
  if event_frames_left > 0 and current_event_text:
1161
  cv2.rectangle(
1162
+ annotated_frame,
1163
+ (20, 20),
1164
  (annotated_frame.shape[1] - 20, 90),
1165
+ (255, 255, 255),
1166
+ -1,
1167
  )
1168
  cv2.putText(
1169
  annotated_frame,
 
1198
  path_for_cleaning.append(coords)
1199
 
1200
  cleaned_path = replace_outliers_based_on_distance(
1201
+ [
1202
+ np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2))
1203
+ for p in path_for_cleaning
1204
+ ],
1205
+ MAX_DISTANCE_THRESHOLD,
1206
+ )
1207
+ print(
1208
+ f"βœ… Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points"
1209
  )
 
 
 
 
 
 
 
 
 
 
1210
 
1211
  # -----------------------------------
1212
+ # STEP 4: performance analytics
1213
  # -----------------------------------
1214
  progress(0.70, desc="πŸ“Š Generating performance analytics (Step 5/7)...")
1215
 
 
1219
  team_heatmaps = create_combined_heatmaps(performance_tracker, fps)
1220
  cv2.imwrite(team_heatmaps_path, team_heatmaps)
1221
 
 
1222
  teams = performance_tracker.get_all_players_by_team()
1223
  top_players = []
1224
  for team_id in [0, 1]:
1225
  if team_id in teams:
1226
  team_players = teams[team_id]
1227
  player_distances = [
1228
+ (
1229
+ pid,
1230
+ performance_tracker.get_player_stats(pid, fps)[
1231
+ "total_distance_m"
1232
+ ],
1233
+ )
1234
  for pid in team_players
1235
  ]
1236
  player_distances.sort(key=lambda x: x[1], reverse=True)
 
1238
 
1239
  individual_heatmaps = []
1240
  for pid in top_players[:6]:
1241
+ heatmap = create_player_heatmap_visualization(
1242
+ performance_tracker, pid, fps
1243
+ )
1244
  individual_heatmaps.append(heatmap)
1245
 
1246
  if len(individual_heatmaps) > 0:
1247
  rows = []
1248
  for i in range(0, len(individual_heatmaps), 3):
1249
+ row_maps = individual_heatmaps[i : i + 3]
1250
  if len(row_maps) == 3:
1251
  rows.append(np.hstack(row_maps))
1252
  elif len(row_maps) == 2:
 
1260
  individual_heatmaps_path = None
1261
 
1262
  # -----------------------------------
1263
+ # STEP 5: radar view
1264
  # -----------------------------------
1265
  progress(0.85, desc="πŸ—ΊοΈ Creating game-style radar view (Step 6/7)...")
1266
  radar_path = "/tmp/radar_view_enhanced.png"
1267
  try:
1268
  if last_pitch_players_xy is not None:
1269
  radar_frame = create_game_style_radar(
1270
+ pitch_ball_xy=cleaned_path[-1]
1271
+ if cleaned_path
1272
+ else np.empty((0, 2)),
1273
  pitch_players_xy=last_pitch_players_xy,
1274
  players_class_id=last_players_class_id,
1275
  pitch_referees_xy=last_pitch_referees_xy,
1276
+ ball_path=cleaned_path,
1277
  )
1278
  cv2.imwrite(radar_path, radar_frame)
1279
  else:
 
1283
  radar_path = None
1284
 
1285
  # -----------------------------------
1286
+ # STEP 6: summary + tables + events
1287
  # -----------------------------------
1288
  progress(0.92, desc="πŸ“ Building summary & tables (Step 7/7)...")
1289
 
 
1292
  summary_lines.append(f"- Total Frames Processed: {frame_idx}")
1293
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1294
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
 
1295
  summary_lines.append(
1296
  f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n"
1297
  )
 
1305
  summary_lines.append(f"- Players Tracked: {len(teams[team_id])}")
1306
 
1307
  total_dist = sum(
1308
+ performance_tracker.get_player_stats(pid, fps)["total_distance_m"]
1309
  for pid in teams[team_id]
1310
  )
1311
  avg_dist = total_dist / len(teams[team_id]) if len(teams[team_id]) > 0 else 0
 
1316
  summary_lines.append("βœ… 1. Team classifier training")
1317
  summary_lines.append("βœ… 2. Video processing with tracking & events")
1318
  summary_lines.append("βœ… 3. Ball trajectory cleaning")
1319
+ summary_lines.append("βœ… 4. Performance analytics")
1320
+ summary_lines.append("βœ… 5. Heatmaps & radar generation")
 
 
 
 
 
 
1321
 
1322
  summary_msg = "\n".join(summary_lines)
1323
 
1324
+ # Player stats table
1325
  player_ids = sorted(performance_tracker.player_positions.keys())
1326
  player_stats_rows: List[List[float]] = []
 
1327
  for pid in player_ids:
1328
  stats_p = performance_tracker.get_player_stats(pid, fps)
1329
  possession_s = possession_time_player_s.get(pid, 0.0)
1330
  row = [
1331
  int(pid),
1332
+ int(stats_p["team_id"]),
1333
+ float(stats_p["total_distance_m"]),
1334
+ float(stats_p["avg_speed_km_h"]),
1335
+ float(stats_p["max_speed_km_h"]),
1336
+ float(stats_p["time_in_defensive_third_s"]),
1337
+ float(stats_p["time_in_middle_third_s"]),
1338
+ float(stats_p["time_in_attacking_third_s"]),
1339
  float(possession_s),
1340
  ]
1341
  player_stats_rows.append(row)
1342
 
1343
+ # Events timeline text
1344
  if events:
1345
  lines = []
1346
  for ev in events:
 
1376
  else:
1377
  events_text = "No events detected."
1378
 
1379
+ # JSON file with events
1380
  events_json_path = "/tmp/events.json"
1381
  with open(events_json_path, "w", encoding="utf-8") as f:
1382
  json.dump(events, f, indent=2)
 
1397
 
1398
  except Exception as e:
1399
  import traceback
1400
+
1401
  traceback.print_exc()
1402
  error_msg = f"❌ Error: {str(e)}"
1403
  return (
1404
+ None,
1405
+ None,
1406
+ None,
1407
+ None,
1408
+ None,
1409
  error_msg,
1410
  [],
1411
  "No events detected.",
 
1417
  # GRADIO INTERFACE
1418
  # ==============================================
1419
  with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1420
+ gr.Markdown(
1421
+ """
1422
  # ⚽ Advanced Football Video Analyzer
1423
+ ### Complete Pipeline Implementation
1424
 
1425
  This application computes:
1426
  - Player & team detection with Roboflow
1427
  - Team classification using SigLIP
1428
  - Persistent tracking with ByteTrack
1429
+ - Distances, speeds, and zone activity
1430
  - Ball possession (per team & per player)
1431
  - Events: passes, tackles, interceptions, shots, clearances, possession changes
1432
  - Heatmaps and tactical radar view
1433
+ """
1434
+ )
 
 
 
 
 
1435
 
1436
  with gr.Row():
1437
  video_input = gr.Video(label="πŸ“€ Upload Football Video")
 
1463
  radar_output = gr.Image(label="Tactical Radar View")
1464
 
1465
  with gr.Tab("πŸ“ˆ Player Stats & Events"):
1466
+ gr.Markdown(
1467
+ "### Per-player stats (distance, speed, zones, possession time)"
1468
+ )
1469
  player_stats_df = gr.Dataframe(
1470
  headers=[
1471
  "player_id",
 
1507
  )
1508
 
1509
  if __name__ == "__main__":
1510
+ # `share=True` is not supported on HF Spaces.
1511
+ iface.launch()