Simon9 commited on
Commit
7225d5b
ยท
verified ยท
1 Parent(s): 552375d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +523 -548
app.py CHANGED
@@ -1,7 +1,7 @@
1
  import os
2
  import json
3
  from collections import deque, defaultdict
4
- from typing import List, Tuple, Dict, Optional, Union
5
  from io import BytesIO
6
  import base64
7
 
@@ -51,24 +51,28 @@ CLIENT = InferenceHTTPClient(
51
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
52
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
53
 
 
54
  def infer_with_confidence(model_id: str, frame: np.ndarray, confidence_threshold: float = 0.3):
55
  """Run inference and filter by confidence threshold"""
56
  result = CLIENT.infer(frame, model_id=model_id)
57
  detections = sv.Detections.from_inference(result)
58
- # Filter by confidence
59
  if len(detections) > 0:
60
  detections = detections[detections.confidence > confidence_threshold]
61
  return result, detections
62
 
 
63
  # ==============================================
64
  # SIGLIP MODEL (Embeddings)
65
  # ==============================================
66
  SIGLIP_MODEL_PATH = "google/siglip-base-patch16-224"
67
- EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN).to(DEVICE)
 
 
 
68
  EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
69
 
70
  # ==============================================
71
- # TEAM CLASSIFIER & CONFIG
72
  # ==============================================
73
  CONFIG = SoccerPitchConfiguration()
74
 
@@ -79,7 +83,7 @@ def replace_outliers_based_on_distance(
79
  positions: List[np.ndarray],
80
  distance_threshold: float
81
  ) -> List[np.ndarray]:
82
- """Remove outlier positions based on distance threshold"""
83
  last_valid_position: Union[np.ndarray, None] = None
84
  cleaned_positions: List[np.ndarray] = []
85
 
@@ -106,12 +110,12 @@ def replace_outliers_based_on_distance(
106
  # ==============================================
107
  class PlayerPerformanceTracker:
108
  """Track individual player performance metrics and generate heatmaps"""
109
-
110
  def __init__(self, pitch_config):
111
  self.config = pitch_config
112
- self.player_positions = defaultdict(list) # (x, y, frame)
113
- self.player_velocities = defaultdict(list) # cm/s
114
- self.player_distances = defaultdict(float) # cm
115
  self.player_team = {}
116
  self.player_stats = defaultdict(lambda: {
117
  'frames_visible': 0,
@@ -121,28 +125,30 @@ class PlayerPerformanceTracker:
121
  'time_in_defensive_third': 0,
122
  'time_in_middle_third': 0
123
  })
124
-
125
- def update(self, tracker_id: int, position: np.ndarray, team_id: int, frame: int):
126
  """Update player position and calculate metrics"""
127
  if len(position) != 2:
128
  return
129
-
130
  self.player_team[tracker_id] = team_id
131
  self.player_positions[tracker_id].append((position[0], position[1], frame))
132
  self.player_stats[tracker_id]['frames_visible'] += 1
133
-
134
  if len(self.player_positions[tracker_id]) > 1:
135
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
136
  curr_pos = np.array(position)
137
  distance = np.linalg.norm(curr_pos - prev_pos)
138
  self.player_distances[tracker_id] += distance
139
-
140
- velocity = distance * 30 # cm/s assuming 30fps
 
 
141
  self.player_velocities[tracker_id].append(velocity)
142
-
143
  if velocity > self.player_stats[tracker_id]['max_velocity']:
144
  self.player_stats[tracker_id]['max_velocity'] = velocity
145
-
146
  pitch_length = self.config.length
147
  if position[0] < pitch_length / 3:
148
  self.player_stats[tracker_id]['time_in_defensive_third'] += 1
@@ -150,45 +156,46 @@ class PlayerPerformanceTracker:
150
  self.player_stats[tracker_id]['time_in_middle_third'] += 1
151
  else:
152
  self.player_stats[tracker_id]['time_in_attacking_third'] += 1
153
-
154
  def get_player_stats(self, tracker_id: int) -> dict:
155
  """Get comprehensive stats for a player"""
156
  stats = self.player_stats[tracker_id].copy()
157
-
158
  if len(self.player_velocities[tracker_id]) > 0:
159
  stats['avg_velocity'] = float(np.mean(self.player_velocities[tracker_id]))
160
-
 
161
  stats['total_distance'] = float(self.player_distances[tracker_id])
162
- stats['total_distance_meters'] = self.player_distances[tracker_id] / 100.0
163
- stats['team_id'] = int(self.player_team.get(tracker_id, -1))
164
-
165
  return stats
166
-
167
  def generate_heatmap(self, tracker_id: int, resolution: int = 100) -> np.ndarray:
168
  """Generate heatmap for a specific player"""
169
  if tracker_id not in self.player_positions or len(self.player_positions[tracker_id]) == 0:
170
  return np.zeros((resolution, resolution))
171
-
172
  positions = np.array([(x, y) for x, y, _ in self.player_positions[tracker_id]])
173
-
174
  pitch_length = self.config.length
175
  pitch_width = self.config.width
176
-
177
  heatmap, xedges, yedges = np.histogram2d(
178
  positions[:, 0], positions[:, 1],
179
  bins=[resolution, resolution],
180
  range=[[0, pitch_length], [0, pitch_width]]
181
  )
182
-
183
  heatmap = gaussian_filter(heatmap, sigma=3)
184
-
185
  return heatmap.T
186
-
187
  def get_all_players_by_team(self) -> Dict[int, List[int]]:
188
  """Get all player IDs grouped by team"""
189
  teams = defaultdict(list)
190
  for tracker_id, team_id in self.player_team.items():
191
- teams[int(team_id)].append(int(tracker_id))
192
  return teams
193
 
194
 
@@ -197,38 +204,38 @@ class PlayerPerformanceTracker:
197
  # ==============================================
198
  class PlayerTrackingManager:
199
  """Manages persistent player tracking with team assignment stability"""
200
-
201
  def __init__(self, max_history=10):
202
  self.tracker_team_history: Dict[int, List[int]] = defaultdict(list)
203
  self.max_history = max_history
204
  self.active_trackers = set()
205
-
206
  def update_team_assignment(self, tracker_id: int, team_id: int):
207
  """Store team assignment history for each tracker"""
208
- self.tracker_team_history[int(tracker_id)].append(int(team_id))
209
- if len(self.tracker_team_history[int(tracker_id)]) > self.max_history:
210
- self.tracker_team_history[int(tracker_id)].pop(0)
211
- self.active_trackers.add(int(tracker_id))
212
-
213
  def get_stable_team_id(self, tracker_id: int, current_team_id: int) -> int:
214
  """Get stable team ID using majority voting from history"""
215
  if tracker_id not in self.tracker_team_history or len(self.tracker_team_history[tracker_id]) < 3:
216
- return int(current_team_id)
217
-
218
  history = self.tracker_team_history[tracker_id]
219
  team_counts = np.bincount(history)
220
  stable_team = int(np.argmax(team_counts))
221
  return stable_team
222
-
223
  def get_player_count_by_team(self) -> Dict[int, int]:
224
  """Get current count of players per team"""
225
  team_counts = defaultdict(int)
226
  for tracker_id in self.active_trackers:
227
  if tracker_id in self.tracker_team_history and len(self.tracker_team_history[tracker_id]) > 0:
228
  stable_team = self.get_stable_team_id(tracker_id, self.tracker_team_history[tracker_id][-1])
229
- team_counts[int(stable_team)] += 1
230
  return team_counts
231
-
232
  def reset_frame(self):
233
  """Reset active trackers for new frame"""
234
  self.active_trackers = set()
@@ -237,154 +244,155 @@ class PlayerTrackingManager:
237
  # ==============================================
238
  # VISUALIZATION FUNCTIONS
239
  # ==============================================
240
- def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTracker,
241
  tracker_id: int) -> np.ndarray:
242
  """Create a single player heatmap overlay on pitch"""
243
  pitch = draw_pitch(CONFIG)
244
  heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
245
-
246
  if heatmap.max() > 0:
247
  heatmap = heatmap / heatmap.max()
248
-
249
  padding = 50
250
-
251
  pitch_height, pitch_width = pitch.shape[:2]
252
- heatmap_resized = cv2.resize(heatmap, (pitch_width - 2*padding, pitch_height - 2*padding))
253
-
254
  heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), cv2.COLORMAP_JET)
255
-
256
  overlay = pitch.copy()
257
- overlay[padding:pitch_height-padding, padding:pitch_width-padding] = heatmap_colored
258
-
259
  result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
260
-
261
  stats = performance_tracker.get_player_stats(tracker_id)
262
  team_color = "Blue" if stats['team_id'] == 0 else "Pink"
263
-
264
  text_lines = [
265
  f"Player #{tracker_id} ({team_color} Team)",
266
  f"Distance: {stats['total_distance_meters']:.1f}m",
267
- f"Avg Speed: {stats['avg_velocity']/100:.2f}m/s",
268
- f"Max Speed: {stats['max_velocity']/100:.2f}m/s",
269
  f"Frames: {stats['frames_visible']}"
270
  ]
271
-
272
  y_offset = 30
273
  for line in text_lines:
274
- cv2.putText(result, line, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX,
275
- 0.6, (255, 255, 255), 2, cv2.LINE_AA)
276
  y_offset += 25
277
-
278
  return result
279
 
280
 
281
  def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker) -> go.Figure:
282
  """Create interactive performance comparison plots"""
283
  teams = performance_tracker.get_all_players_by_team()
284
-
285
  fig = make_subplots(
286
  rows=2, cols=2,
287
- subplot_titles=('Distance Covered', 'Average Speed', 'Max Speed', 'Activity by Zone'),
288
  specs=[[{'type': 'bar'}, {'type': 'bar'}],
289
  [{'type': 'bar'}, {'type': 'bar'}]]
290
  )
291
-
292
  colors = {0: '#00BFFF', 1: '#FF1493'}
293
  team_names = {0: 'Team 0 (Blue)', 1: 'Team 1 (Pink)'}
294
-
295
  for team_id, player_ids in teams.items():
296
  if team_id not in [0, 1]:
297
  continue
298
-
299
  distances = []
300
  avg_speeds = []
301
  max_speeds = []
302
  attacking_time = []
303
-
304
  for pid in player_ids:
305
  stats = performance_tracker.get_player_stats(pid)
306
  distances.append(stats['total_distance_meters'])
307
- avg_speeds.append(stats['avg_velocity']/100.0)
308
- max_speeds.append(stats['max_velocity']/100.0)
309
  attacking_time.append(stats['time_in_attacking_third'])
310
-
311
  player_labels = [f"#{pid}" for pid in player_ids]
312
-
313
  fig.add_trace(
314
  go.Bar(x=player_labels, y=distances, name=team_names[team_id],
315
- marker_color=colors[team_id], showlegend=True),
316
  row=1, col=1
317
  )
318
-
319
  fig.add_trace(
320
  go.Bar(x=player_labels, y=avg_speeds, name=team_names[team_id],
321
- marker_color=colors[team_id], showlegend=False),
322
  row=1, col=2
323
  )
324
-
325
  fig.add_trace(
326
  go.Bar(x=player_labels, y=max_speeds, name=team_names[team_id],
327
- marker_color=colors[team_id], showlegend=False),
328
  row=2, col=1
329
  )
330
-
331
  fig.add_trace(
332
  go.Bar(x=player_labels, y=attacking_time, name=team_names[team_id],
333
- marker_color=colors[team_id], showlegend=False),
334
  row=2, col=2
335
  )
336
-
337
  fig.update_xaxes(title_text="Players", row=1, col=1)
338
  fig.update_xaxes(title_text="Players", row=1, col=2)
339
  fig.update_xaxes(title_text="Players", row=2, col=1)
340
  fig.update_xaxes(title_text="Players", row=2, col=2)
341
-
342
  fig.update_yaxes(title_text="Distance (m)", row=1, col=1)
343
- fig.update_yaxes(title_text="Speed (m/s)", row=1, col=2)
344
- fig.update_yaxes(title_text="Speed (m/s)", row=2, col=1)
345
- fig.update_yaxes(title_text="Frames in Zone", row=2, col=2)
346
-
347
  fig.update_layout(height=800, title_text="Team Performance Comparison", barmode='group')
348
-
349
  return fig
350
 
351
 
352
  def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> np.ndarray:
353
  """Create side-by-side team heatmaps"""
354
  teams = performance_tracker.get_all_players_by_team()
355
-
356
  team_heatmaps = []
357
  for team_id in [0, 1]:
358
  if team_id not in teams:
359
  continue
360
-
361
  combined_heatmap = np.zeros((150, 150))
362
  for pid in teams[team_id]:
363
  player_heatmap = performance_tracker.generate_heatmap(pid, resolution=150)
364
  combined_heatmap += player_heatmap
365
-
366
  if combined_heatmap.max() > 0:
367
  combined_heatmap = combined_heatmap / combined_heatmap.max()
368
-
369
  pitch = draw_pitch(CONFIG)
370
  padding = 50
371
  pitch_height, pitch_width = pitch.shape[:2]
372
- heatmap_resized = cv2.resize(combined_heatmap,
373
- (pitch_width - 2*padding, pitch_height - 2*padding))
374
-
 
 
375
  colormap = cv2.COLORMAP_JET if team_id == 0 else cv2.COLORMAP_HOT
376
  heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), colormap)
377
-
378
  overlay = pitch.copy()
379
- overlay[padding:pitch_height-padding, padding:pitch_width-padding] = heatmap_colored
380
  result = cv2.addWeighted(pitch, 0.5, overlay, 0.5, 0)
381
-
382
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
383
- cv2.putText(result, team_name, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
384
- 1, (255, 255, 255), 2, cv2.LINE_AA)
385
-
386
  team_heatmaps.append(result)
387
-
388
  if len(team_heatmaps) == 2:
389
  return np.hstack(team_heatmaps)
390
  elif len(team_heatmaps) == 1:
@@ -410,11 +418,11 @@ def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detectio
410
  ])
411
 
412
 
413
- def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
414
- pitch_referees_xy, ball_path=None):
415
  """Create game-style radar view with ball trail effect"""
416
  annotated_frame = draw_pitch(CONFIG)
417
-
418
  # Draw ball trail with fading effect
419
  if ball_path is not None and len(ball_path) > 0:
420
  valid_path = [coords for coords in ball_path if len(coords) > 0]
@@ -425,68 +433,64 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
425
  alpha = (i + 1) / min(20, len(valid_path))
426
  color = sv.Color(int(255 * alpha), int(255 * alpha), int(255 * alpha))
427
  annotated_frame = draw_points_on_pitch(
428
- CONFIG, coords,
429
- face_color=color,
430
- edge_color=sv.Color.BLACK,
431
  radius=int(6 + alpha * 4),
432
  pitch=annotated_frame
433
  )
434
-
435
  # Draw current ball position
436
  if len(pitch_ball_xy) > 0:
437
  annotated_frame = draw_points_on_pitch(
438
- CONFIG, pitch_ball_xy,
439
- face_color=sv.Color.WHITE,
440
- edge_color=sv.Color.BLACK,
441
- radius=10,
442
  pitch=annotated_frame
443
  )
444
-
445
  # Draw players
446
  for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
447
  mask = players_class_id == team_id
448
  if np.any(mask):
449
  annotated_frame = draw_points_on_pitch(
450
- CONFIG, pitch_players_xy[mask],
451
- face_color=sv.Color.from_hex(color_hex),
452
- edge_color=sv.Color.BLACK,
453
- radius=16,
454
  pitch=annotated_frame
455
  )
456
-
457
  # Draw referees
458
  if len(pitch_referees_xy) > 0:
459
  annotated_frame = draw_points_on_pitch(
460
- CONFIG, pitch_referees_xy,
461
- face_color=sv.Color.from_hex("FFD700"),
462
- edge_color=sv.Color.BLACK,
463
- radius=16,
464
  pitch=annotated_frame
465
  )
466
-
467
  return annotated_frame
468
 
469
 
470
  # ==============================================
471
- # MAIN ANALYSIS PIPELINE WITH EVENTS + STATS
472
  # ==============================================
473
  def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
474
  """
475
  Complete football analysis pipeline:
476
- - Player & ball detection + tracking
477
- - Team classification with stable assignment
478
- - Field homography, ball path cleaning
479
- - Player performance tracking
480
- - Event & possession analysis
481
- - Annotated match video
482
- - Heatmaps, comparison plots
483
- - Per-player stats table
484
- - Event timeline + downloadable JSON
485
  """
486
  if not video_path:
487
- return (None, None, None, None, None,
488
- "โŒ Please upload a video file.",
489
- None, None, None)
490
 
491
  try:
492
  progress(0, desc="๐Ÿ”ง Initializing...")
@@ -501,26 +505,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
501
  tracking_manager = PlayerTrackingManager(max_history=10)
502
  performance_tracker = PlayerPerformanceTracker(CONFIG)
503
 
504
- # Event & possession tracking
505
- possession_time_player = defaultdict(float) # tid -> seconds
506
- possession_time_team = defaultdict(float) # team_id -> seconds
507
- team_of_player: Dict[int, int] = {}
508
- events: List[Dict[str, Any]] = []
509
-
510
- prev_owner_tid: Optional[int] = None
511
- prev_ball_pos_pitch: Optional[np.ndarray] = None
512
-
513
- # Simple goal centers (for shot vs clearance direction)
514
- goal_centers = {
515
- 0: np.array([0.0, CONFIG.width / 2.0]),
516
- 1: np.array([CONFIG.length, CONFIG.width / 2.0]),
517
- }
518
-
519
- def register_event(ev: Dict[str, Any], text: str):
520
- # We just register events here; text is for optional future HUD
521
- events.append(ev)
522
-
523
- # Annotators with exact colors from notebook
524
  ellipse_annotator = sv.EllipseAnnotator(
525
  color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
526
  thickness=2
@@ -549,30 +534,27 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
549
  # Video setup
550
  cap = cv2.VideoCapture(video_path)
551
  if not cap.isOpened():
552
- return (None, None, None, None, None,
553
- f"โŒ Failed to open video: {video_path}",
554
- None, None, None)
555
 
556
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
557
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
558
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
559
- fps = cap.get(cv2.CAP_PROP_FPS)
560
- fps = fps if fps > 0 else 30.0
561
- dt = 1.0 / fps
562
-
563
  print(f"๐Ÿ“น Video: {width}x{height}, {fps}fps, {total_frames} frames")
564
 
565
  fourcc = cv2.VideoWriter_fourcc(*"mp4v")
566
  output_path = "/tmp/annotated_football.mp4"
567
  out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
568
 
569
- # ========================================
 
 
570
  # STEP 1: Collect Player Crops for Team Classifier
571
- # ========================================
572
  progress(0.05, desc="๐Ÿƒ Collecting player samples (Step 1/7)...")
573
  player_crops = []
574
  frame_count = 0
575
-
576
  while frame_count < min(total_frames, 300):
577
  ret, frame = cap.read()
578
  if not ret:
@@ -590,37 +572,63 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
590
  frame_count += 1
591
 
592
  if len(player_crops) == 0:
593
- cap.release()
594
- out.release()
595
  return (None, None, None, None, None,
596
- "โŒ No player crops collected.",
597
- None, None, None)
598
 
599
  print(f"โœ… Collected {len(player_crops)} player samples")
600
 
601
- # ========================================
602
  # STEP 2: Train Team Classifier
603
- # ========================================
604
  progress(0.15, desc="๐ŸŽฏ Training team classifier (Step 2/7)...")
605
  team_classifier = TeamClassifier(device=DEVICE)
606
  team_classifier.fit(player_crops)
607
  print("โœ… Team classifier trained")
608
 
609
- # ========================================
610
  # STEP 3: Process Full Video with Tracking + Events
611
- # ========================================
612
  cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
613
  frame_count = 0
614
  M = deque(maxlen=MAXLEN) # Transformation matrix smoothing
615
- ball_path_raw = []
616
-
617
- # Store last frame data for radar
618
  last_pitch_players_xy = None
619
  last_players_class_id = None
620
  last_pitch_referees_xy = None
621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  progress(0.2, desc="๐ŸŽฌ Processing video frames (Step 3/7)...")
623
-
624
  while True:
625
  ret, frame = cap.read()
626
  if not ret:
@@ -628,12 +636,12 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
628
 
629
  frame_count += 1
630
  tracking_manager.reset_frame()
631
-
632
  if frame_count % 30 == 0:
633
- progress(0.2 + 0.35 * (frame_count / max(total_frames, 1)),
634
- desc=f"๐ŸŽฌ Processing frame {frame_count}/{total_frames}")
635
 
636
- # Player and ball detection
637
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
638
 
639
  if len(detections.xyxy) == 0:
@@ -641,129 +649,150 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
641
  ball_path_raw.append(np.empty((0, 2)))
642
  continue
643
 
644
- # Separate ball from other detections
645
  ball_detections = detections[detections.class_id == BALL_ID]
646
  ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
647
-
 
648
  all_detections = detections[detections.class_id != BALL_ID]
649
  all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
650
-
651
- # Track detections
652
  all_detections = tracker.update_with_detections(detections=all_detections)
653
 
654
- # Separate by type
655
  goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
656
  players_detections = all_detections[all_detections.class_id == PLAYER_ID]
657
  referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
658
 
659
- # Team prediction with stability
660
  if len(players_detections.xyxy) > 0:
661
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
662
  predicted_teams = team_classifier.predict(crops)
663
-
664
- # Apply stable team assignment
665
  for idx, tracker_id in enumerate(players_detections.tracker_id):
666
  tracking_manager.update_team_assignment(int(tracker_id), int(predicted_teams[idx]))
667
  predicted_teams[idx] = tracking_manager.get_stable_team_id(
668
  int(tracker_id), int(predicted_teams[idx])
669
  )
670
-
671
  players_detections.class_id = predicted_teams
672
 
673
- # Assign goalkeeper teams
674
- if len(goalkeepers_detections) > 0 and len(players_detections) > 0:
675
- goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
676
- players_detections, goalkeepers_detections
677
- )
678
 
679
- # Adjust referee class_id
680
  referees_detections.class_id -= 1
681
 
682
- # Merge all detections (players + keepers + refs)
683
- all_detections_vis = sv.Detections.merge([
684
  players_detections, goalkeepers_detections, referees_detections
685
  ])
686
- all_detections_vis.class_id = all_detections_vis.class_id.astype(int)
687
 
688
- # ========================================
689
- # STEP 4: Field Detection & Transformation
690
- # ========================================
691
  frame_ball_pos_pitch = None
692
- pitch_players_xy = None
693
 
694
  try:
695
  result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL_ID, frame, 0.3)
696
  key_points = sv.KeyPoints.from_inference(result_field)
697
-
698
- # Filter confident keypoints
699
  filter_mask = key_points.confidence[0] > 0.5
700
  frame_ref_pts = key_points.xy[0][filter_mask]
701
  pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
702
-
703
- if len(frame_ref_pts) >= 4: # Need at least 4 points for homography
704
  transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
705
  M.append(transformer.m)
706
  transformer.m = np.mean(np.array(M), axis=0)
707
 
708
- # Transform ball position
709
  frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
710
- pitch_ball_xy = transformer.transform_points(frame_ball_xy) if len(frame_ball_xy) > 0 else np.empty((0, 2))
711
  ball_path_raw.append(pitch_ball_xy)
712
  if len(pitch_ball_xy) > 0:
713
  frame_ball_pos_pitch = pitch_ball_xy[0]
714
 
715
- # Transform all players (including goalkeepers)
716
  all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
717
- players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
718
- pitch_players_xy = transformer.transform_points(players_xy) if len(players_xy) > 0 else np.empty((0, 2))
719
-
720
- # Transform referees
721
- referees_xy = referees_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
722
- pitch_referees_xy = transformer.transform_points(referees_xy) if len(referees_xy) > 0 else np.empty((0, 2))
723
-
724
- # Store for radar view
725
- last_pitch_players_xy = pitch_players_xy
726
- last_players_class_id = all_players.class_id
727
- last_pitch_referees_xy = pitch_referees_xy
728
-
729
- # Update performance tracker + team_of_player
730
- for idx, tracker_id in enumerate(all_players.tracker_id):
731
- if idx < len(pitch_players_xy):
732
- tid_int = int(tracker_id)
 
 
733
  team_id = int(all_players.class_id[idx])
734
- team_of_player[tid_int] = team_id
 
 
735
  performance_tracker.update(
736
- tid_int,
737
- pitch_players_xy[idx],
738
  team_id,
739
- frame_count
 
740
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  else:
742
  ball_path_raw.append(np.empty((0, 2)))
743
- except Exception as e:
 
 
744
  ball_path_raw.append(np.empty((0, 2)))
 
 
 
745
 
746
- # ========================================
747
- # STEP 5: POSSESSION & EVENTS (only if we have ball + players in pitch coords)
748
- # ========================================
749
  owner_tid: Optional[int] = None
750
  POSSESSION_RADIUS_M = 5.0
751
 
752
- if frame_ball_pos_pitch is not None and pitch_players_xy is not None and len(pitch_players_xy) > 0:
753
- dists = np.linalg.norm(pitch_players_xy - frame_ball_pos_pitch, axis=1)
 
 
 
 
754
  j = int(np.argmin(dists))
755
  if dists[j] < POSSESSION_RADIUS_M:
756
- if 'all_players' in locals() and len(all_players.tracker_id) > j:
757
- owner_tid = int(all_players.tracker_id[j])
758
 
759
- # accumulate possession time
760
  if owner_tid is not None:
761
- possession_time_player[owner_tid] += dt
762
  owner_team = team_of_player.get(owner_tid)
763
  if owner_team is not None:
764
- possession_time_team[owner_team] += dt
765
 
766
- # possession change events, passes, tackles, interceptions
767
  if owner_tid != prev_owner_tid:
768
  if owner_tid is not None and prev_owner_tid is not None:
769
  prev_team = team_of_player.get(prev_owner_tid)
@@ -771,7 +800,8 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
771
 
772
  travel_m = 0.0
773
  if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None:
774
- travel_m = float(np.linalg.norm(frame_ball_pos_pitch - prev_ball_pos_pitch))
 
775
 
776
  MIN_PASS_TRAVEL_M = 3.0
777
 
@@ -781,35 +811,32 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
781
  register_event(
782
  {
783
  "type": "pass",
784
- "t": float(frame_count * dt),
785
  "from_tid": int(prev_owner_tid),
786
  "to_tid": int(owner_tid),
787
  "team_id": int(cur_team),
788
- "extra": {"distance_m": float(travel_m)},
789
  },
790
  f"Pass: #{prev_owner_tid} โ†’ #{owner_tid} (Team {cur_team})",
791
  )
792
  elif prev_team != cur_team:
793
  # tackle vs interception
794
  d_pp = 999.0
795
- if pitch_players_xy is not None and len(pitch_players_xy) > 0:
796
- pos_prev = None
797
- pos_cur = None
798
- if 'all_players' in locals():
799
- # approximate positions using last known from perf tracker
800
- pos_prev = None
801
- pos_cur = None
802
- # we don't strictly need d_pp for coach view; keep simple
803
- ev_type = "tackle" # simplified
804
- label = "Tackle"
805
  register_event(
806
  {
807
  "type": ev_type,
808
- "t": float(frame_count * dt),
809
  "from_tid": int(prev_owner_tid),
810
  "to_tid": int(owner_tid),
811
  "team_id": int(cur_team),
812
- "extra": {"ball_travel_m": float(travel_m)},
 
813
  },
814
  f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}",
815
  )
@@ -818,23 +845,20 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
818
  register_event(
819
  {
820
  "type": "possession_change",
821
- "t": float(frame_count * dt),
822
  "from_tid": int(prev_owner_tid) if prev_owner_tid is not None else None,
823
  "to_tid": int(owner_tid) if owner_tid is not None else None,
824
  "team_id": int(team_of_player.get(owner_tid)) if owner_tid is not None else None,
825
- "extra": {},
826
  },
827
- "" # no banner text here
828
  )
829
 
830
- # shot / clearance based on ball speed & direction
831
- if (
832
- prev_ball_pos_pitch is not None
833
- and frame_ball_pos_pitch is not None
834
- and owner_tid is not None
835
- ):
836
- v = (frame_ball_pos_pitch - prev_ball_pos_pitch) / dt # m/s
837
- speed_mps = float(np.linalg.norm(v))
838
  speed_kmh = speed_mps * 3.6
839
  HIGH_SPEED_KMH = 18.0
840
 
@@ -844,19 +868,17 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
844
  target_goal = goal_centers[1 - shooter_team]
845
  direction = target_goal - frame_ball_pos_pitch
846
  cos_angle = float(
847
- np.dot(v, direction)
848
- / (np.linalg.norm(v) * np.linalg.norm(direction) + 1e-6)
849
  )
850
-
851
  if cos_angle > 0.8:
852
  register_event(
853
  {
854
  "type": "shot",
855
- "t": float(frame_count * dt),
856
  "from_tid": int(owner_tid),
857
- "to_tid": None,
858
  "team_id": int(shooter_team),
859
- "extra": {"speed_kmh": float(speed_kmh)},
860
  },
861
  f"Shot by #{owner_tid} (Team {shooter_team}) โ€“ {speed_kmh:.1f} km/h",
862
  )
@@ -864,11 +886,10 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
864
  register_event(
865
  {
866
  "type": "clearance",
867
- "t": float(frame_count * dt),
868
  "from_tid": int(owner_tid),
869
- "to_tid": None,
870
  "team_id": int(shooter_team),
871
- "extra": {"speed_kmh": float(speed_kmh)},
872
  },
873
  f"Clearance by #{owner_tid} (Team {shooter_team})",
874
  )
@@ -876,35 +897,66 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
876
  prev_owner_tid = owner_tid
877
  prev_ball_pos_pitch = frame_ball_pos_pitch
878
 
879
- # ========================================
880
- # FRAME ANNOTATION (with speed & distance per player)
881
- # ========================================
882
  annotated_frame = frame.copy()
883
 
884
- # Build labels for players: id + team + avg speed + total distance
885
- if len(players_detections) > 0:
886
- player_labels = []
887
- for tid, team_id in zip(players_detections.tracker_id, players_detections.class_id):
888
- tid_int = int(tid)
889
- stats = performance_tracker.get_player_stats(tid_int)
890
- avg_speed_ms = stats['avg_velocity'] / 100.0
891
- total_dist_m = stats['total_distance_meters']
892
- player_labels.append(
893
- f"#{tid_int} T{int(team_id)} {avg_speed_ms:4.2f} m/s {total_dist_m:.1f} m"
894
- )
 
 
895
 
896
- annotated_frame = ellipse_annotator.annotate(annotated_frame, players_detections)
897
- annotated_frame = label_annotator.annotate(
898
- annotated_frame, players_detections, labels=player_labels
899
- )
900
 
901
- # Draw goalkeepers & referees as ellipses (without extra labels for now)
902
- rest_dets = sv.Detections.merge([goalkeepers_detections, referees_detections])
903
- if len(rest_dets) > 0:
904
- annotated_frame = ellipse_annotator.annotate(annotated_frame, rest_dets)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
 
906
- # Draw ball
907
- annotated_frame = triangle_annotator.annotate(annotated_frame, ball_detections)
 
 
 
 
 
 
 
 
 
 
 
 
 
908
 
909
  out.write(annotated_frame)
910
 
@@ -912,11 +964,11 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
912
  out.release()
913
  print(f"โœ… Processed {frame_count} frames")
914
 
915
- # ========================================
916
- # STEP 6: Clean Ball Path (Remove Outliers)
917
- # ========================================
918
- progress(0.6, desc="๐Ÿงน Cleaning ball trajectory (Step 4/7)...")
919
-
920
  path_for_cleaning = []
921
  for coords in ball_path_raw:
922
  if len(coords) == 0:
@@ -925,72 +977,70 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
925
  path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
926
  else:
927
  path_for_cleaning.append(coords)
928
-
929
  cleaned_path = replace_outliers_based_on_distance(
930
  [np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2)) for p in path_for_cleaning],
931
  MAX_DISTANCE_THRESHOLD
932
  )
933
-
934
  print(f"โœ… Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
935
 
936
- # ========================================
937
- # STEP 7: Generate Performance Analytics, Stats Table & Events
938
- # ========================================
939
  progress(0.7, desc="๐Ÿ“Š Generating performance analytics (Step 5/7)...")
940
-
941
- # Team comparison charts
942
  comparison_fig = create_team_comparison_plot(performance_tracker)
943
-
944
- # Combined team heatmaps
945
  team_heatmaps_path = "/tmp/team_heatmaps.png"
946
  team_heatmaps = create_combined_heatmaps(performance_tracker)
947
  cv2.imwrite(team_heatmaps_path, team_heatmaps)
948
-
949
  # Individual player heatmaps (top 6 by distance)
950
- progress(0.8, desc="๐Ÿ—บ๏ธ Creating individual heatmaps...")
951
  teams = performance_tracker.get_all_players_by_team()
952
  top_players = []
953
-
954
  for team_id in [0, 1]:
955
  if team_id in teams:
956
  team_players = teams[team_id]
957
- player_distances = [(pid, performance_tracker.get_player_stats(pid)['total_distance'])
958
- for pid in team_players]
 
 
959
  player_distances.sort(key=lambda x: x[1], reverse=True)
960
  top_players.extend([pid for pid, _ in player_distances[:3]])
961
-
962
  individual_heatmaps = []
963
  for pid in top_players[:6]:
964
  heatmap = create_player_heatmap_visualization(performance_tracker, pid)
965
  individual_heatmaps.append(heatmap)
966
-
967
- # Arrange individual heatmaps in grid (3 columns)
968
  if len(individual_heatmaps) > 0:
969
  rows = []
970
  for i in range(0, len(individual_heatmaps), 3):
971
- row_maps = individual_heatmaps[i:i+3]
972
  if len(row_maps) == 3:
973
  rows.append(np.hstack(row_maps))
974
  elif len(row_maps) == 2:
975
  rows.append(np.hstack([row_maps[0], row_maps[1]]))
976
  else:
977
  rows.append(row_maps[0])
978
-
979
  individual_grid = np.vstack(rows) if len(rows) > 1 else rows[0]
980
  individual_heatmaps_path = "/tmp/individual_heatmaps.png"
981
  cv2.imwrite(individual_heatmaps_path, individual_grid)
982
  else:
983
  individual_heatmaps_path = None
984
 
985
- # ========================================
986
- # Game-Style Radar View
987
- # ========================================
988
- progress(0.85, desc="๐Ÿ—บ๏ธ Creating game-style radar view (Step 6/7)...")
989
  radar_path = "/tmp/radar_view_enhanced.png"
990
  try:
991
  if last_pitch_players_xy is not None:
 
992
  radar_frame = create_game_style_radar(
993
- pitch_ball_xy=cleaned_path[-1] if cleaned_path else np.empty((0, 2)),
994
  pitch_players_xy=last_pitch_players_xy,
995
  players_class_id=last_players_class_id,
996
  pitch_referees_xy=last_pitch_referees_xy if last_pitch_referees_xy is not None else np.empty((0, 2)),
@@ -1003,169 +1053,138 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
1003
  print(f"โš ๏ธ Radar view creation failed: {e}")
1004
  radar_path = None
1005
 
1006
- # ========================================
1007
- # Build Player Stats Table (for coaches)
1008
- # ========================================
1009
- progress(0.9, desc="๐Ÿ“‹ Building player stats & event timeline (Step 7/7)...")
1010
-
1011
- total_poss_time = sum(possession_time_team.values()) + 1e-6
1012
-
1013
- # stats table headers:
1014
- # Player ID, Team, Distance (m), Avg Speed (m/s), Max Speed (m/s),
1015
- # Frames, Def 3rd, Mid 3rd, Att 3rd, Possession (s), Poss % Team, Poss % Game
1016
- stats_rows = []
1017
-
1018
- for team_id in [0, 1]:
1019
- if team_id not in teams:
1020
- continue
1021
- team_players = teams[team_id]
1022
- team_poss_time = sum(possession_time_player[pid] for pid in team_players) + 1e-6
1023
- for pid in team_players:
1024
- stats = performance_tracker.get_player_stats(pid)
1025
- poss_s = float(possession_time_player[pid])
1026
- poss_pct_team = 100.0 * poss_s / team_poss_time
1027
- poss_pct_game = 100.0 * poss_s / total_poss_time
1028
-
1029
- stats_rows.append([
1030
- int(pid),
1031
- int(team_id),
1032
- float(stats['total_distance_meters']),
1033
- float(stats['avg_velocity']) / 100.0,
1034
- float(stats['max_velocity']) / 100.0,
1035
- int(stats['frames_visible']),
1036
- int(stats['time_in_defensive_third']),
1037
- int(stats['time_in_middle_third']),
1038
- int(stats['time_in_attacking_third']),
1039
- poss_s,
1040
- poss_pct_team,
1041
- poss_pct_game
1042
- ])
1043
-
1044
- # ========================================
1045
- # Build Event Timeline Table + JSON
1046
- # ========================================
1047
- events_sorted = sorted(events, key=lambda e: e.get("t", 0.0))
1048
- event_rows = []
1049
- for ev in events_sorted:
1050
- t = float(ev.get("t", 0.0))
1051
- ev_type = ev.get("type", "")
1052
- team_id = ev.get("team_id", None)
1053
- from_tid = ev.get("from_tid", None)
1054
- to_tid = ev.get("to_tid", None)
1055
- extra = ev.get("extra", {})
1056
-
1057
- detail_str = ""
1058
- if ev_type == "pass":
1059
- if "distance_m" in extra:
1060
- detail_str = f"distance={extra['distance_m']:.1f}m"
1061
- elif ev_type in ["shot", "clearance"]:
1062
- if "speed_kmh" in extra:
1063
- detail_str = f"speed={extra['speed_kmh']:.1f}km/h"
1064
- elif ev_type in ["tackle", "interception"]:
1065
- if "ball_travel_m" in extra:
1066
- detail_str = f"ball_travel={extra['ball_travel_m']:.1f}m"
1067
-
1068
- event_rows.append([
1069
- t,
1070
- ev_type,
1071
- int(team_id) if team_id is not None else None,
1072
- int(from_tid) if from_tid is not None else None,
1073
- int(to_tid) if to_tid is not None else None,
1074
- detail_str
1075
- ])
1076
-
1077
- # JSON file for events
1078
- events_serializable = []
1079
- for ev in events_sorted:
1080
- ev_out = {
1081
- "type": ev.get("type"),
1082
- "t": float(ev.get("t", 0.0)),
1083
- "from_tid": int(ev["from_tid"]) if ev.get("from_tid") is not None else None,
1084
- "to_tid": int(ev["to_tid"]) if ev.get("to_tid") is not None else None,
1085
- "team_id": int(ev["team_id"]) if ev.get("team_id") is not None else None,
1086
- "extra": {}
1087
- }
1088
- for k, v in ev.get("extra", {}).items():
1089
- try:
1090
- ev_out["extra"][k] = float(v)
1091
- except Exception:
1092
- ev_out["extra"][k] = v
1093
- events_serializable.append(ev_out)
1094
-
1095
- events_json_path = "/tmp/events.json"
1096
- with open(events_json_path, "w", encoding="utf-8") as f:
1097
- json.dump(events_serializable, f, indent=2)
1098
 
1099
- # ========================================
1100
- # Generate Summary Report
1101
- # ========================================
1102
- teams_dict = performance_tracker.get_all_players_by_team()
1103
-
1104
  summary_lines = ["โœ… **Analysis Complete!**\n"]
1105
- summary_lines.append(f"**Video Statistics:**")
1106
  summary_lines.append(f"- Total Frames Processed: {frame_count}")
1107
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1108
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1109
  summary_lines.append(f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n")
1110
 
1111
- # Possession summary
1112
- team0_poss_pct = 100.0 * possession_time_team.get(0, 0.0) / total_poss_time
1113
- team1_poss_pct = 100.0 * possession_time_team.get(1, 0.0) / total_poss_time
1114
- summary_lines.append(f"**Ball Possession:**")
1115
- summary_lines.append(f"- Team 0 (Blue): {team0_poss_pct:.1f}%")
1116
- summary_lines.append(f"- Team 1 (Pink): {team1_poss_pct:.1f}%\n")
1117
-
1118
  for team_id in [0, 1]:
1119
- if team_id not in teams_dict:
1120
  continue
1121
-
1122
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
1123
  summary_lines.append(f"\n**{team_name}:**")
1124
- summary_lines.append(f"- Players Tracked: {len(teams_dict[team_id])}")
1125
-
1126
- total_dist = sum(performance_tracker.get_player_stats(pid)['total_distance_meters']
1127
- for pid in teams_dict[team_id])
1128
- avg_dist = total_dist / len(teams_dict[team_id]) if len(teams_dict[team_id]) > 0 else 0
1129
  summary_lines.append(f"- Team Total Distance: {total_dist:.1f}m")
1130
  summary_lines.append(f"- Average Distance per Player: {avg_dist:.1f}m")
1131
-
1132
  # Top 3 performers
1133
- player_distances = [(pid, performance_tracker.get_player_stats(pid)['total_distance_meters'])
1134
- for pid in teams_dict[team_id]]
1135
  player_distances.sort(key=lambda x: x[1], reverse=True)
1136
-
1137
  summary_lines.append(f"\n **Top 3 Performers:**")
1138
  for i, (pid, dist) in enumerate(player_distances[:3], 1):
1139
  stats = performance_tracker.get_player_stats(pid)
1140
  summary_lines.append(
1141
  f" {i}. Player #{pid}: {dist:.1f}m, "
1142
- f"Avg: {stats['avg_velocity']/100:.2f}m/s, "
1143
- f"Max: {stats['max_velocity']/100:.2f}m/s"
1144
  )
1145
-
 
 
 
 
 
 
 
 
 
 
1146
  summary_lines.append("\n**Pipeline Steps Completed:**")
1147
  summary_lines.append("โœ… 1. Player crop collection")
1148
  summary_lines.append("โœ… 2. Team classifier training")
1149
- summary_lines.append("โœ… 3. Video processing with tracking & events")
1150
  summary_lines.append("โœ… 4. Ball trajectory cleaning")
1151
  summary_lines.append("โœ… 5. Performance analytics generation")
1152
  summary_lines.append("โœ… 6. Visualization creation")
1153
- summary_lines.append("โœ… 7. Stats & event export")
1154
-
1155
  summary_msg = "\n".join(summary_lines)
1156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1157
  progress(1.0, desc="โœ… Analysis Complete!")
1158
 
1159
  return (
1160
- output_path, # video_output
1161
- comparison_fig, # comparison_output
1162
- team_heatmaps_path, # team_heatmaps_output
1163
- individual_heatmaps_path, # individual_heatmaps_output
1164
- radar_path, # radar_output
1165
- summary_msg, # status_output
1166
- stats_rows, # stats_table_output
1167
- event_rows, # events_table_output
1168
- events_json_path # events_json_output
1169
  )
1170
 
1171
  except Exception as e:
@@ -1173,82 +1192,76 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
1173
  print(error_msg)
1174
  import traceback
1175
  traceback.print_exc()
1176
- return (
1177
- None, None, None, None, None,
1178
- error_msg,
1179
- None, None, None
1180
- )
1181
 
1182
 
1183
  # ==============================================
1184
- # GRADIO INTERFACE (with new stats & events)
1185
  # ==============================================
1186
  with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1187
  gr.Markdown("""
1188
  # โšฝ Advanced Football Video Analyzer
1189
- ### Complete Pipeline Implementation
1190
-
1191
- This application:
1192
- 1. **Detects players & ball** using Roboflow
1193
- 2. **Classifies teams** using SigLIP embeddings
1194
- 3. **Tracks players** with ByteTrack and stable IDs
1195
- 4. **Transforms field view** and tracks ball trajectory
1196
- 5. **Computes events & possession** (passes, tackles, shots, clearances)
1197
- 6. **Generates heatmaps & stats** per player and per team
1198
- 7. **Exports event JSON** for further analysis
1199
-
1200
- Upload a football match video to get comprehensive performance analytics!
1201
  """)
1202
-
1203
  with gr.Row():
1204
  video_input = gr.Video(label="๐Ÿ“ค Upload Football Video")
1205
-
1206
- analyze_btn = gr.Button("๐Ÿš€ Start Analysis Pipeline", variant="primary", size="lg")
1207
-
1208
  with gr.Row():
1209
  status_output = gr.Textbox(label="๐Ÿ“Š Analysis Summary & Statistics", lines=25)
1210
-
1211
  with gr.Tabs():
1212
  with gr.Tab("๐Ÿ“น Annotated Video"):
1213
- gr.Markdown("### Full video with player tracking, team colors, and ball detection + speed & distance overlays")
1214
  video_output = gr.Video(label="Processed Video")
1215
-
1216
  with gr.Tab("๐Ÿ“Š Performance Comparison"):
1217
  gr.Markdown("### Interactive charts comparing player performance metrics")
1218
  comparison_output = gr.Plot(label="Team Performance Metrics")
1219
-
1220
  with gr.Tab("๐Ÿ—บ๏ธ Team Heatmaps"):
1221
  gr.Markdown("### Combined activity heatmaps showing team positioning")
1222
  team_heatmaps_output = gr.Image(label="Team Activity Heatmaps")
1223
-
1224
  with gr.Tab("๐Ÿ‘ค Individual Heatmaps"):
1225
  gr.Markdown("### Top 6 players with detailed activity analysis")
1226
  individual_heatmaps_output = gr.Image(label="Top Players Heatmaps")
1227
-
1228
  with gr.Tab("๐ŸŽฎ Game Radar View"):
1229
- gr.Markdown("### Game-style tactical view with ball trail")
1230
  radar_output = gr.Image(label="Tactical Radar View")
1231
 
1232
- with gr.Tab("๐Ÿ“‹ Player Stats & Events"):
1233
- gr.Markdown("### Detailed per-player stats and full event timeline")
1234
- stats_table_output = gr.Dataframe(
1235
- headers=[
1236
- "Player ID", "Team",
1237
- "Distance (m)", "Avg Speed (m/s)", "Max Speed (m/s)",
1238
- "Frames", "Def 1/3", "Mid 1/3", "Att 1/3",
1239
- "Possession (s)", "Poss % Team", "Poss % Game"
1240
- ],
1241
- row_count=(0, "dynamic"),
1242
- label="Per-player Stats"
1243
- )
1244
- events_table_output = gr.Dataframe(
1245
  headers=[
1246
- "Time (s)", "Type", "Team", "From Player", "To Player", "Details"
 
 
1247
  ],
1248
- row_count=(0, "dynamic"),
1249
- label="Event Timeline"
 
1250
  )
1251
- events_json_output = gr.File(label="Download Events JSON")
 
 
 
 
 
 
 
1252
 
1253
  analyze_btn.click(
1254
  fn=analyze_football_video,
@@ -1260,50 +1273,12 @@ with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()
1260
  individual_heatmaps_output,
1261
  radar_output,
1262
  status_output,
1263
- stats_table_output,
1264
- events_table_output,
1265
- events_json_output
1266
  ]
1267
  )
1268
-
1269
- gr.Markdown("""
1270
- ---
1271
- ### ๐Ÿ”ง Technical Details:
1272
-
1273
- **Detection Models:**
1274
- - Player/Ball/Referee Detection: `football-players-detection-3zvbc/11`
1275
- - Field Keypoint Detection: `football-field-detection-f07vi/14`
1276
-
1277
- **Tracking & Classification:**
1278
- - ByteTrack for persistent player IDs (60-frame buffer)
1279
- - SigLIP embeddings for team classification
1280
- - Majority voting for stable team assignments
1281
-
1282
- **Performance Metrics:**
1283
- - Distance covered (meters)
1284
- - Average & maximum speed (m/s)
1285
- - Zone activity (defensive/middle/attacking thirds)
1286
- - Position heatmaps with Gaussian smoothing
1287
- - Possession time per player and per team
1288
-
1289
- **Events & Decisions:**
1290
- - Passes (distance-based)
1291
- - Tackles & interceptions (possession changes between teams)
1292
- - Shots vs clearances (ball speed + direction toward goal)
1293
- - Full timeline export as JSON for downstream analysis
1294
-
1295
- **Ball Tracking:**
1296
- - Field homography transformation
1297
- - Outlier removal (500cm threshold)
1298
- - Transformation matrix smoothing (5-frame window)
1299
-
1300
- ### ๐Ÿ“ˆ Output Files:
1301
- - Annotated video: `/tmp/annotated_football.mp4`
1302
- - Team heatmaps: `/tmp/team_heatmaps.png`
1303
- - Individual heatmaps: `/tmp/individual_heatmaps.png`
1304
- - Radar view: `/tmp/radar_view_enhanced.png`
1305
- - Event log: `/tmp/events.json`
1306
- """)
1307
 
1308
  if __name__ == "__main__":
1309
- iface.launch(share=True)
 
 
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
 
 
51
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
52
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
53
 
54
+
55
  def infer_with_confidence(model_id: str, frame: np.ndarray, confidence_threshold: float = 0.3):
56
  """Run inference and filter by confidence threshold"""
57
  result = CLIENT.infer(frame, model_id=model_id)
58
  detections = sv.Detections.from_inference(result)
 
59
  if len(detections) > 0:
60
  detections = detections[detections.confidence > confidence_threshold]
61
  return result, detections
62
 
63
+
64
  # ==============================================
65
  # SIGLIP MODEL (Embeddings)
66
  # ==============================================
67
  SIGLIP_MODEL_PATH = "google/siglip-base-patch16-224"
68
+ EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(
69
+ SIGLIP_MODEL_PATH,
70
+ token=HF_TOKEN
71
+ ).to(DEVICE)
72
  EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
73
 
74
  # ==============================================
75
+ # TEAM CONFIG
76
  # ==============================================
77
  CONFIG = SoccerPitchConfiguration()
78
 
 
83
  positions: List[np.ndarray],
84
  distance_threshold: float
85
  ) -> List[np.ndarray]:
86
+ """Remove outlier positions based on distance threshold (in pitch units)."""
87
  last_valid_position: Union[np.ndarray, None] = None
88
  cleaned_positions: List[np.ndarray] = []
89
 
 
110
  # ==============================================
111
  class PlayerPerformanceTracker:
112
  """Track individual player performance metrics and generate heatmaps"""
113
+
114
  def __init__(self, pitch_config):
115
  self.config = pitch_config
116
+ self.player_positions = defaultdict(list)
117
+ self.player_velocities = defaultdict(list)
118
+ self.player_distances = defaultdict(float)
119
  self.player_team = {}
120
  self.player_stats = defaultdict(lambda: {
121
  'frames_visible': 0,
 
125
  'time_in_defensive_third': 0,
126
  'time_in_middle_third': 0
127
  })
128
+
129
+ def update(self, tracker_id: int, position: np.ndarray, team_id: int, frame: int, fps: float):
130
  """Update player position and calculate metrics"""
131
  if len(position) != 2:
132
  return
133
+
134
  self.player_team[tracker_id] = team_id
135
  self.player_positions[tracker_id].append((position[0], position[1], frame))
136
  self.player_stats[tracker_id]['frames_visible'] += 1
137
+
138
  if len(self.player_positions[tracker_id]) > 1:
139
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
140
  curr_pos = np.array(position)
141
  distance = np.linalg.norm(curr_pos - prev_pos)
142
  self.player_distances[tracker_id] += distance
143
+
144
+ # pitch units per second -> just relative for now
145
+ dt = 1.0 / max(fps, 1.0)
146
+ velocity = distance / dt
147
  self.player_velocities[tracker_id].append(velocity)
148
+
149
  if velocity > self.player_stats[tracker_id]['max_velocity']:
150
  self.player_stats[tracker_id]['max_velocity'] = velocity
151
+
152
  pitch_length = self.config.length
153
  if position[0] < pitch_length / 3:
154
  self.player_stats[tracker_id]['time_in_defensive_third'] += 1
 
156
  self.player_stats[tracker_id]['time_in_middle_third'] += 1
157
  else:
158
  self.player_stats[tracker_id]['time_in_attacking_third'] += 1
159
+
160
  def get_player_stats(self, tracker_id: int) -> dict:
161
  """Get comprehensive stats for a player"""
162
  stats = self.player_stats[tracker_id].copy()
163
+
164
  if len(self.player_velocities[tracker_id]) > 0:
165
  stats['avg_velocity'] = float(np.mean(self.player_velocities[tracker_id]))
166
+
167
+ # treat pitch units as cm-ish; convert to meters for readability
168
  stats['total_distance'] = float(self.player_distances[tracker_id])
169
+ stats['total_distance_meters'] = float(self.player_distances[tracker_id] / 100.0)
170
+ stats['team_id'] = self.player_team.get(tracker_id, -1)
171
+
172
  return stats
173
+
174
  def generate_heatmap(self, tracker_id: int, resolution: int = 100) -> np.ndarray:
175
  """Generate heatmap for a specific player"""
176
  if tracker_id not in self.player_positions or len(self.player_positions[tracker_id]) == 0:
177
  return np.zeros((resolution, resolution))
178
+
179
  positions = np.array([(x, y) for x, y, _ in self.player_positions[tracker_id]])
180
+
181
  pitch_length = self.config.length
182
  pitch_width = self.config.width
183
+
184
  heatmap, xedges, yedges = np.histogram2d(
185
  positions[:, 0], positions[:, 1],
186
  bins=[resolution, resolution],
187
  range=[[0, pitch_length], [0, pitch_width]]
188
  )
189
+
190
  heatmap = gaussian_filter(heatmap, sigma=3)
191
+
192
  return heatmap.T
193
+
194
  def get_all_players_by_team(self) -> Dict[int, List[int]]:
195
  """Get all player IDs grouped by team"""
196
  teams = defaultdict(list)
197
  for tracker_id, team_id in self.player_team.items():
198
+ teams[team_id].append(tracker_id)
199
  return teams
200
 
201
 
 
204
  # ==============================================
205
  class PlayerTrackingManager:
206
  """Manages persistent player tracking with team assignment stability"""
207
+
208
  def __init__(self, max_history=10):
209
  self.tracker_team_history: Dict[int, List[int]] = defaultdict(list)
210
  self.max_history = max_history
211
  self.active_trackers = set()
212
+
213
  def update_team_assignment(self, tracker_id: int, team_id: int):
214
  """Store team assignment history for each tracker"""
215
+ self.tracker_team_history[tracker_id].append(team_id)
216
+ if len(self.tracker_team_history[tracker_id]) > self.max_history:
217
+ self.tracker_team_history[tracker_id].pop(0)
218
+ self.active_trackers.add(tracker_id)
219
+
220
  def get_stable_team_id(self, tracker_id: int, current_team_id: int) -> int:
221
  """Get stable team ID using majority voting from history"""
222
  if tracker_id not in self.tracker_team_history or len(self.tracker_team_history[tracker_id]) < 3:
223
+ return current_team_id
224
+
225
  history = self.tracker_team_history[tracker_id]
226
  team_counts = np.bincount(history)
227
  stable_team = int(np.argmax(team_counts))
228
  return stable_team
229
+
230
  def get_player_count_by_team(self) -> Dict[int, int]:
231
  """Get current count of players per team"""
232
  team_counts = defaultdict(int)
233
  for tracker_id in self.active_trackers:
234
  if tracker_id in self.tracker_team_history and len(self.tracker_team_history[tracker_id]) > 0:
235
  stable_team = self.get_stable_team_id(tracker_id, self.tracker_team_history[tracker_id][-1])
236
+ team_counts[stable_team] += 1
237
  return team_counts
238
+
239
  def reset_frame(self):
240
  """Reset active trackers for new frame"""
241
  self.active_trackers = set()
 
244
  # ==============================================
245
  # VISUALIZATION FUNCTIONS
246
  # ==============================================
247
+ def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTracker,
248
  tracker_id: int) -> np.ndarray:
249
  """Create a single player heatmap overlay on pitch"""
250
  pitch = draw_pitch(CONFIG)
251
  heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
252
+
253
  if heatmap.max() > 0:
254
  heatmap = heatmap / heatmap.max()
255
+
256
  padding = 50
 
257
  pitch_height, pitch_width = pitch.shape[:2]
258
+ heatmap_resized = cv2.resize(heatmap, (pitch_width - 2 * padding, pitch_height - 2 * padding))
259
+
260
  heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), cv2.COLORMAP_JET)
261
+
262
  overlay = pitch.copy()
263
+ overlay[padding:pitch_height - padding, padding:pitch_width - padding] = heatmap_colored
264
+
265
  result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
266
+
267
  stats = performance_tracker.get_player_stats(tracker_id)
268
  team_color = "Blue" if stats['team_id'] == 0 else "Pink"
269
+
270
  text_lines = [
271
  f"Player #{tracker_id} ({team_color} Team)",
272
  f"Distance: {stats['total_distance_meters']:.1f}m",
273
+ f"Avg Speed (rel): {stats['avg_velocity']:.2f}",
274
+ f"Max Speed (rel): {stats['max_velocity']:.2f}",
275
  f"Frames: {stats['frames_visible']}"
276
  ]
277
+
278
  y_offset = 30
279
  for line in text_lines:
280
+ cv2.putText(result, line, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX,
281
+ 0.6, (255, 255, 255), 2, cv2.LINE_AA)
282
  y_offset += 25
283
+
284
  return result
285
 
286
 
287
  def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker) -> go.Figure:
288
  """Create interactive performance comparison plots"""
289
  teams = performance_tracker.get_all_players_by_team()
290
+
291
  fig = make_subplots(
292
  rows=2, cols=2,
293
+ subplot_titles=('Distance Covered (m)', 'Avg Speed (rel)', 'Max Speed (rel)', 'Activity in Attacking Third'),
294
  specs=[[{'type': 'bar'}, {'type': 'bar'}],
295
  [{'type': 'bar'}, {'type': 'bar'}]]
296
  )
297
+
298
  colors = {0: '#00BFFF', 1: '#FF1493'}
299
  team_names = {0: 'Team 0 (Blue)', 1: 'Team 1 (Pink)'}
300
+
301
  for team_id, player_ids in teams.items():
302
  if team_id not in [0, 1]:
303
  continue
304
+
305
  distances = []
306
  avg_speeds = []
307
  max_speeds = []
308
  attacking_time = []
309
+
310
  for pid in player_ids:
311
  stats = performance_tracker.get_player_stats(pid)
312
  distances.append(stats['total_distance_meters'])
313
+ avg_speeds.append(stats['avg_velocity'])
314
+ max_speeds.append(stats['max_velocity'])
315
  attacking_time.append(stats['time_in_attacking_third'])
316
+
317
  player_labels = [f"#{pid}" for pid in player_ids]
318
+
319
  fig.add_trace(
320
  go.Bar(x=player_labels, y=distances, name=team_names[team_id],
321
+ marker_color=colors[team_id], showlegend=True),
322
  row=1, col=1
323
  )
324
+
325
  fig.add_trace(
326
  go.Bar(x=player_labels, y=avg_speeds, name=team_names[team_id],
327
+ marker_color=colors[team_id], showlegend=False),
328
  row=1, col=2
329
  )
330
+
331
  fig.add_trace(
332
  go.Bar(x=player_labels, y=max_speeds, name=team_names[team_id],
333
+ marker_color=colors[team_id], showlegend=False),
334
  row=2, col=1
335
  )
336
+
337
  fig.add_trace(
338
  go.Bar(x=player_labels, y=attacking_time, name=team_names[team_id],
339
+ marker_color=colors[team_id], showlegend=False),
340
  row=2, col=2
341
  )
342
+
343
  fig.update_xaxes(title_text="Players", row=1, col=1)
344
  fig.update_xaxes(title_text="Players", row=1, col=2)
345
  fig.update_xaxes(title_text="Players", row=2, col=1)
346
  fig.update_xaxes(title_text="Players", row=2, col=2)
347
+
348
  fig.update_yaxes(title_text="Distance (m)", row=1, col=1)
349
+ fig.update_yaxes(title_text="Speed (rel units)", row=1, col=2)
350
+ fig.update_yaxes(title_text="Speed (rel units)", row=2, col=1)
351
+ fig.update_yaxes(title_text="Frames in Attacking Third", row=2, col=2)
352
+
353
  fig.update_layout(height=800, title_text="Team Performance Comparison", barmode='group')
354
+
355
  return fig
356
 
357
 
358
  def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> np.ndarray:
359
  """Create side-by-side team heatmaps"""
360
  teams = performance_tracker.get_all_players_by_team()
361
+
362
  team_heatmaps = []
363
  for team_id in [0, 1]:
364
  if team_id not in teams:
365
  continue
366
+
367
  combined_heatmap = np.zeros((150, 150))
368
  for pid in teams[team_id]:
369
  player_heatmap = performance_tracker.generate_heatmap(pid, resolution=150)
370
  combined_heatmap += player_heatmap
371
+
372
  if combined_heatmap.max() > 0:
373
  combined_heatmap = combined_heatmap / combined_heatmap.max()
374
+
375
  pitch = draw_pitch(CONFIG)
376
  padding = 50
377
  pitch_height, pitch_width = pitch.shape[:2]
378
+ heatmap_resized = cv2.resize(
379
+ combined_heatmap,
380
+ (pitch_width - 2 * padding, pitch_height - 2 * padding)
381
+ )
382
+
383
  colormap = cv2.COLORMAP_JET if team_id == 0 else cv2.COLORMAP_HOT
384
  heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), colormap)
385
+
386
  overlay = pitch.copy()
387
+ overlay[padding:pitch_height - padding, padding:pitch_width - padding] = heatmap_colored
388
  result = cv2.addWeighted(pitch, 0.5, overlay, 0.5, 0)
389
+
390
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
391
+ cv2.putText(result, team_name, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
392
+ 1, (255, 255, 255), 2, cv2.LINE_AA)
393
+
394
  team_heatmaps.append(result)
395
+
396
  if len(team_heatmaps) == 2:
397
  return np.hstack(team_heatmaps)
398
  elif len(team_heatmaps) == 1:
 
418
  ])
419
 
420
 
421
+ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
422
+ pitch_referees_xy, ball_path=None):
423
  """Create game-style radar view with ball trail effect"""
424
  annotated_frame = draw_pitch(CONFIG)
425
+
426
  # Draw ball trail with fading effect
427
  if ball_path is not None and len(ball_path) > 0:
428
  valid_path = [coords for coords in ball_path if len(coords) > 0]
 
433
  alpha = (i + 1) / min(20, len(valid_path))
434
  color = sv.Color(int(255 * alpha), int(255 * alpha), int(255 * alpha))
435
  annotated_frame = draw_points_on_pitch(
436
+ CONFIG, coords,
437
+ face_color=color,
438
+ edge_color=sv.Color.BLACK,
439
  radius=int(6 + alpha * 4),
440
  pitch=annotated_frame
441
  )
442
+
443
  # Draw current ball position
444
  if len(pitch_ball_xy) > 0:
445
  annotated_frame = draw_points_on_pitch(
446
+ CONFIG, pitch_ball_xy,
447
+ face_color=sv.Color.WHITE,
448
+ edge_color=sv.Color.BLACK,
449
+ radius=10,
450
  pitch=annotated_frame
451
  )
452
+
453
  # Draw players
454
  for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
455
  mask = players_class_id == team_id
456
  if np.any(mask):
457
  annotated_frame = draw_points_on_pitch(
458
+ CONFIG, pitch_players_xy[mask],
459
+ face_color=sv.Color.from_hex(color_hex),
460
+ edge_color=sv.Color.BLACK,
461
+ radius=16,
462
  pitch=annotated_frame
463
  )
464
+
465
  # Draw referees
466
  if len(pitch_referees_xy) > 0:
467
  annotated_frame = draw_points_on_pitch(
468
+ CONFIG, pitch_referees_xy,
469
+ face_color=sv.Color.from_hex("FFD700"),
470
+ edge_color=sv.Color.BLACK,
471
+ radius=16,
472
  pitch=annotated_frame
473
  )
474
+
475
  return annotated_frame
476
 
477
 
478
  # ==============================================
479
+ # MAIN ANALYSIS PIPELINE
480
  # ==============================================
481
  def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
482
  """
483
  Complete football analysis pipeline:
484
+ - Player detection & tracking
485
+ - Team classification
486
+ - Homography to pitch coordinates
487
+ - Player speed & distance overlays
488
+ - Ball path cleaning
489
+ - Heatmaps & comparisons
490
+ - Event + possession stats
 
 
491
  """
492
  if not video_path:
493
+ return (None,) * 9
 
 
494
 
495
  try:
496
  progress(0, desc="๐Ÿ”ง Initializing...")
 
505
  tracking_manager = PlayerTrackingManager(max_history=10)
506
  performance_tracker = PlayerPerformanceTracker(CONFIG)
507
 
508
+ # Annotators
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  ellipse_annotator = sv.EllipseAnnotator(
510
  color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
511
  thickness=2
 
534
  # Video setup
535
  cap = cv2.VideoCapture(video_path)
536
  if not cap.isOpened():
537
+ return (None, None, None, None, None, f"โŒ Failed to open video: {video_path}", None, None, None)
 
 
538
 
539
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
540
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
541
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
542
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
 
 
 
543
  print(f"๐Ÿ“น Video: {width}x{height}, {fps}fps, {total_frames} frames")
544
 
545
  fourcc = cv2.VideoWriter_fourcc(*"mp4v")
546
  output_path = "/tmp/annotated_football.mp4"
547
  out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
548
 
549
+ dt = 1.0 / fps
550
+
551
+ # ===================================================
552
  # STEP 1: Collect Player Crops for Team Classifier
553
+ # ===================================================
554
  progress(0.05, desc="๐Ÿƒ Collecting player samples (Step 1/7)...")
555
  player_crops = []
556
  frame_count = 0
557
+
558
  while frame_count < min(total_frames, 300):
559
  ret, frame = cap.read()
560
  if not ret:
 
572
  frame_count += 1
573
 
574
  if len(player_crops) == 0:
 
 
575
  return (None, None, None, None, None,
576
+ "โŒ No player crops collected.", None, None, None)
 
577
 
578
  print(f"โœ… Collected {len(player_crops)} player samples")
579
 
580
+ # ===================================================
581
  # STEP 2: Train Team Classifier
582
+ # ===================================================
583
  progress(0.15, desc="๐ŸŽฏ Training team classifier (Step 2/7)...")
584
  team_classifier = TeamClassifier(device=DEVICE)
585
  team_classifier.fit(player_crops)
586
  print("โœ… Team classifier trained")
587
 
588
+ # ===================================================
589
  # STEP 3: Process Full Video with Tracking + Events
590
+ # ===================================================
591
  cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
592
  frame_count = 0
593
  M = deque(maxlen=MAXLEN) # Transformation matrix smoothing
594
+ ball_path_raw: List[np.ndarray] = []
595
+
596
+ # For radar
597
  last_pitch_players_xy = None
598
  last_players_class_id = None
599
  last_pitch_referees_xy = None
600
 
601
+ # Event & possession stats
602
+ distance_covered_m = defaultdict(float) # per player
603
+ possession_time_player_s = defaultdict(float) # per player
604
+ possession_time_team_s = defaultdict(float) # per team
605
+ team_of_player: Dict[int, int] = {}
606
+ events: List[Dict[str, Any]] = []
607
+
608
+ last_pitch_pos: Dict[int, np.ndarray] = {}
609
+ prev_owner_tid: Optional[int] = None
610
+ prev_ball_pos_pitch: Optional[np.ndarray] = None
611
+
612
+ # simple goal centers in pitch coordinates
613
+ goal_centers = {
614
+ 0: np.array([0.0, CONFIG.width / 2.0]),
615
+ 1: np.array([CONFIG.length, CONFIG.width / 2.0]),
616
+ }
617
+
618
+ # HUD event text (optional)
619
+ current_event_text = ""
620
+ event_text_frames_left = 0
621
+ EVENT_TEXT_DURATION_FRAMES = int(2.0 * fps)
622
+
623
+ def register_event(ev: Dict[str, Any], text: str):
624
+ nonlocal current_event_text, event_text_frames_left
625
+ events.append(ev)
626
+ if text:
627
+ current_event_text = text
628
+ event_text_frames_left = EVENT_TEXT_DURATION_FRAMES
629
+
630
  progress(0.2, desc="๐ŸŽฌ Processing video frames (Step 3/7)...")
631
+
632
  while True:
633
  ret, frame = cap.read()
634
  if not ret:
 
636
 
637
  frame_count += 1
638
  tracking_manager.reset_frame()
639
+
640
  if frame_count % 30 == 0:
641
+ progress(0.2 + 0.3 * (frame_count / max(total_frames, 1)),
642
+ desc=f"๐ŸŽฌ Processing frame {frame_count}/{total_frames}")
643
 
644
+ # ----------------- Detection & Tracking -----------------
645
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
646
 
647
  if len(detections.xyxy) == 0:
 
649
  ball_path_raw.append(np.empty((0, 2)))
650
  continue
651
 
652
+ # ball
653
  ball_detections = detections[detections.class_id == BALL_ID]
654
  ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
655
+
656
+ # rest
657
  all_detections = detections[detections.class_id != BALL_ID]
658
  all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
659
+
660
+ # track
661
  all_detections = tracker.update_with_detections(detections=all_detections)
662
 
663
+ # split by type
664
  goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
665
  players_detections = all_detections[all_detections.class_id == PLAYER_ID]
666
  referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
667
 
668
+ # ----------------- Team classification -----------------
669
  if len(players_detections.xyxy) > 0:
670
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
671
  predicted_teams = team_classifier.predict(crops)
672
+
 
673
  for idx, tracker_id in enumerate(players_detections.tracker_id):
674
  tracking_manager.update_team_assignment(int(tracker_id), int(predicted_teams[idx]))
675
  predicted_teams[idx] = tracking_manager.get_stable_team_id(
676
  int(tracker_id), int(predicted_teams[idx])
677
  )
678
+
679
  players_detections.class_id = predicted_teams
680
 
681
+ # assign GK teams
682
+ goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
683
+ players_detections, goalkeepers_detections
684
+ )
 
685
 
686
+ # referees class shift
687
  referees_detections.class_id -= 1
688
 
689
+ # merge for drawing
690
+ all_dets_for_draw = sv.Detections.merge([
691
  players_detections, goalkeepers_detections, referees_detections
692
  ])
693
+ all_dets_for_draw.class_id = all_dets_for_draw.class_id.astype(int)
694
 
695
+ # ----------------- Field homography & pitch coords -----------------
 
 
696
  frame_ball_pos_pitch = None
697
+ frame_players_xy_pitch = None
698
 
699
  try:
700
  result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL_ID, frame, 0.3)
701
  key_points = sv.KeyPoints.from_inference(result_field)
702
+
 
703
  filter_mask = key_points.confidence[0] > 0.5
704
  frame_ref_pts = key_points.xy[0][filter_mask]
705
  pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
706
+
707
+ if len(frame_ref_pts) >= 4:
708
  transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
709
  M.append(transformer.m)
710
  transformer.m = np.mean(np.array(M), axis=0)
711
 
712
+ # ball
713
  frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
714
+ pitch_ball_xy = transformer.transform_points(frame_ball_xy)
715
  ball_path_raw.append(pitch_ball_xy)
716
  if len(pitch_ball_xy) > 0:
717
  frame_ball_pos_pitch = pitch_ball_xy[0]
718
 
719
+ # players + gk
720
  all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
721
+ if len(all_players) > 0:
722
+ players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
723
+ pitch_players_xy = transformer.transform_points(players_xy)
724
+ frame_players_xy_pitch = pitch_players_xy
725
+
726
+ # referees
727
+ refs_xy = referees_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
728
+ pitch_referees_xy = transformer.transform_points(refs_xy) if len(refs_xy) > 0 else np.empty((0, 2))
729
+
730
+ last_pitch_players_xy = pitch_players_xy
731
+ last_players_class_id = all_players.class_id
732
+ last_pitch_referees_xy = pitch_referees_xy
733
+
734
+ # --------- update per-player stats & speed/distance ---------
735
+ current_speed_kmh: Dict[int, float] = {}
736
+
737
+ for idx, tracker_id in enumerate(all_players.tracker_id):
738
+ tid = int(tracker_id)
739
  team_id = int(all_players.class_id[idx])
740
+ pos_pitch = pitch_players_xy[idx]
741
+
742
+ # performance tracker (for heatmaps, zone time, etc.)
743
  performance_tracker.update(
744
+ tid,
745
+ pos_pitch,
746
  team_id,
747
+ frame_count,
748
+ fps
749
  )
750
+
751
+ # distance & speed for overlays (assuming pitch units โ‰ˆ cm)
752
+ prev_pos = last_pitch_pos.get(tid)
753
+ if prev_pos is not None:
754
+ dist_units = float(np.linalg.norm(pos_pitch - prev_pos))
755
+ distance_covered_m[tid] += dist_units / 100.0 # convert to meters approx
756
+ speed_mps = (dist_units / 100.0) / dt
757
+ speed_kmh = speed_mps * 3.6
758
+ else:
759
+ speed_kmh = 0.0
760
+ current_speed_kmh[tid] = speed_kmh
761
+ last_pitch_pos[tid] = pos_pitch
762
+ team_of_player[tid] = team_id
763
+ else:
764
+ pitch_referees_xy = np.empty((0, 2))
765
  else:
766
  ball_path_raw.append(np.empty((0, 2)))
767
+ pitch_referees_xy = np.empty((0, 2))
768
+ current_speed_kmh = {}
769
+ except Exception:
770
  ball_path_raw.append(np.empty((0, 2)))
771
+ pitch_referees_xy = np.empty((0, 2))
772
+ current_speed_kmh = {}
773
+ frame_players_xy_pitch = None
774
 
775
+ # ----------------- Possession & Events -----------------
 
 
776
  owner_tid: Optional[int] = None
777
  POSSESSION_RADIUS_M = 5.0
778
 
779
+ if frame_ball_pos_pitch is not None and frame_players_xy_pitch is not None and len(players_detections) > 0:
780
+ # only real players (exclude gk's if you want, but here include all_players)
781
+ # for possession, use players_detections only
782
+ players_xy_img = players_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
783
+ pitch_players_xy_pos = transformer.transform_points(players_xy_img)
784
+ dists = np.linalg.norm(pitch_players_xy_pos - frame_ball_pos_pitch, axis=1)
785
  j = int(np.argmin(dists))
786
  if dists[j] < POSSESSION_RADIUS_M:
787
+ owner_tid = int(players_detections.tracker_id[j])
 
788
 
 
789
  if owner_tid is not None:
790
+ possession_time_player_s[owner_tid] += dt
791
  owner_team = team_of_player.get(owner_tid)
792
  if owner_team is not None:
793
+ possession_time_team_s[owner_team] += dt
794
 
795
+ # detect passes / tackles / interceptions
796
  if owner_tid != prev_owner_tid:
797
  if owner_tid is not None and prev_owner_tid is not None:
798
  prev_team = team_of_player.get(prev_owner_tid)
 
800
 
801
  travel_m = 0.0
802
  if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None:
803
+ travel_units = float(np.linalg.norm(frame_ball_pos_pitch - prev_ball_pos_pitch))
804
+ travel_m = travel_units / 100.0
805
 
806
  MIN_PASS_TRAVEL_M = 3.0
807
 
 
811
  register_event(
812
  {
813
  "type": "pass",
814
+ "time_s": float(frame_count * dt),
815
  "from_tid": int(prev_owner_tid),
816
  "to_tid": int(owner_tid),
817
  "team_id": int(cur_team),
818
+ "distance_m": travel_m,
819
  },
820
  f"Pass: #{prev_owner_tid} โ†’ #{owner_tid} (Team {cur_team})",
821
  )
822
  elif prev_team != cur_team:
823
  # tackle vs interception
824
  d_pp = 999.0
825
+ pos_prev = last_pitch_pos.get(int(prev_owner_tid))
826
+ pos_cur = last_pitch_pos.get(int(owner_tid))
827
+ if pos_prev is not None and pos_cur is not None:
828
+ d_pp = float(np.linalg.norm(pos_prev - pos_cur))
829
+ ev_type = "tackle" if d_pp < 3.0 else "interception"
830
+ label = "Tackle" if ev_type == "tackle" else "Interception"
 
 
 
 
831
  register_event(
832
  {
833
  "type": ev_type,
834
+ "time_s": float(frame_count * dt),
835
  "from_tid": int(prev_owner_tid),
836
  "to_tid": int(owner_tid),
837
  "team_id": int(cur_team),
838
+ "player_distance_units": d_pp,
839
+ "ball_travel_m": travel_m,
840
  },
841
  f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}",
842
  )
 
845
  register_event(
846
  {
847
  "type": "possession_change",
848
+ "time_s": float(frame_count * dt),
849
  "from_tid": int(prev_owner_tid) if prev_owner_tid is not None else None,
850
  "to_tid": int(owner_tid) if owner_tid is not None else None,
851
  "team_id": int(team_of_player.get(owner_tid)) if owner_tid is not None else None,
 
852
  },
853
+ "" if owner_tid is None else f"Team {team_of_player.get(owner_tid)} in possession",
854
  )
855
 
856
+ # shots / clearances
857
+ if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None and owner_tid is not None:
858
+ v_units = (frame_ball_pos_pitch - prev_ball_pos_pitch) / dt
859
+ speed_units = float(np.linalg.norm(v_units))
860
+ # convert approximate -> m/s (assuming pitch units ~ cm)
861
+ speed_mps = speed_units / 100.0
 
 
862
  speed_kmh = speed_mps * 3.6
863
  HIGH_SPEED_KMH = 18.0
864
 
 
868
  target_goal = goal_centers[1 - shooter_team]
869
  direction = target_goal - frame_ball_pos_pitch
870
  cos_angle = float(
871
+ np.dot(v_units, direction)
872
+ / (np.linalg.norm(v_units) * np.linalg.norm(direction) + 1e-6)
873
  )
 
874
  if cos_angle > 0.8:
875
  register_event(
876
  {
877
  "type": "shot",
878
+ "time_s": float(frame_count * dt),
879
  "from_tid": int(owner_tid),
 
880
  "team_id": int(shooter_team),
881
+ "speed_kmh": speed_kmh,
882
  },
883
  f"Shot by #{owner_tid} (Team {shooter_team}) โ€“ {speed_kmh:.1f} km/h",
884
  )
 
886
  register_event(
887
  {
888
  "type": "clearance",
889
+ "time_s": float(frame_count * dt),
890
  "from_tid": int(owner_tid),
 
891
  "team_id": int(shooter_team),
892
+ "speed_kmh": speed_kmh,
893
  },
894
  f"Clearance by #{owner_tid} (Team {shooter_team})",
895
  )
 
897
  prev_owner_tid = owner_tid
898
  prev_ball_pos_pitch = frame_ball_pos_pitch
899
 
900
+ # ----------------- Annotate Frame -----------------
 
 
901
  annotated_frame = frame.copy()
902
 
903
+ # build labels: ID + team + speed + distance
904
+ labels = []
905
+ for i, tid in enumerate(all_dets_for_draw.tracker_id):
906
+ tid_int = int(tid)
907
+ team_id = int(all_dets_for_draw.class_id[i])
908
+ spd = 0.0
909
+ if 'current_speed_kmh' in locals():
910
+ spd = current_speed_kmh.get(tid_int, 0.0)
911
+ dist = distance_covered_m.get(tid_int, 0.0)
912
+ if team_id in [0, 1]:
913
+ labels.append(f"#{tid_int} T{team_id} {spd:4.1f} km/h {dist:.1f} m")
914
+ else:
915
+ labels.append(f"#{tid_int}")
916
 
917
+ annotated_frame = ellipse_annotator.annotate(annotated_frame, all_dets_for_draw)
918
+ annotated_frame = label_annotator.annotate(annotated_frame, all_dets_for_draw, labels=labels)
919
+ annotated_frame = triangle_annotator.annotate(annotated_frame, ball_detections)
 
920
 
921
+ # HUD: team possession percentages
922
+ total_poss_time = sum(possession_time_team_s.values()) + 1e-6
923
+ team0_pct = 100.0 * possession_time_team_s.get(0, 0.0) / total_poss_time
924
+ team1_pct = 100.0 * possession_time_team_s.get(1, 0.0) / total_poss_time
925
+ hud_text = f"Team 0 Possession: {team0_pct:5.1f}% Team 1 Possession: {team1_pct:5.1f}%"
926
+
927
+ cv2.rectangle(
928
+ annotated_frame,
929
+ (20, annotated_frame.shape[0] - 60),
930
+ (annotated_frame.shape[1] - 20, annotated_frame.shape[0] - 20),
931
+ (255, 255, 255),
932
+ -1,
933
+ )
934
+ cv2.putText(
935
+ annotated_frame,
936
+ hud_text,
937
+ (30, annotated_frame.shape[0] - 30),
938
+ cv2.FONT_HERSHEY_SIMPLEX,
939
+ 0.8,
940
+ (0, 0, 0),
941
+ 2,
942
+ cv2.LINE_AA,
943
+ )
944
 
945
+ # Top banner for recent event
946
+ if event_text_frames_left > 0 and current_event_text:
947
+ cv2.rectangle(annotated_frame, (20, 20), (annotated_frame.shape[1] - 20, 90),
948
+ (255, 255, 255), -1)
949
+ cv2.putText(
950
+ annotated_frame,
951
+ current_event_text,
952
+ (30, 70),
953
+ cv2.FONT_HERSHEY_SIMPLEX,
954
+ 1.0,
955
+ (0, 0, 0),
956
+ 2,
957
+ cv2.LINE_AA,
958
+ )
959
+ event_text_frames_left -= 1
960
 
961
  out.write(annotated_frame)
962
 
 
964
  out.release()
965
  print(f"โœ… Processed {frame_count} frames")
966
 
967
+ # ===================================================
968
+ # STEP 4: Clean Ball Path
969
+ # ===================================================
970
+ progress(0.55, desc="๐Ÿงน Cleaning ball trajectory (Step 4/7)...")
971
+
972
  path_for_cleaning = []
973
  for coords in ball_path_raw:
974
  if len(coords) == 0:
 
977
  path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
978
  else:
979
  path_for_cleaning.append(coords)
980
+
981
  cleaned_path = replace_outliers_based_on_distance(
982
  [np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2)) for p in path_for_cleaning],
983
  MAX_DISTANCE_THRESHOLD
984
  )
985
+
986
  print(f"โœ… Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
987
 
988
+ # ===================================================
989
+ # STEP 5: Performance Analytics
990
+ # ===================================================
991
  progress(0.7, desc="๐Ÿ“Š Generating performance analytics (Step 5/7)...")
992
+
 
993
  comparison_fig = create_team_comparison_plot(performance_tracker)
994
+
 
995
  team_heatmaps_path = "/tmp/team_heatmaps.png"
996
  team_heatmaps = create_combined_heatmaps(performance_tracker)
997
  cv2.imwrite(team_heatmaps_path, team_heatmaps)
998
+
999
  # Individual player heatmaps (top 6 by distance)
 
1000
  teams = performance_tracker.get_all_players_by_team()
1001
  top_players = []
 
1002
  for team_id in [0, 1]:
1003
  if team_id in teams:
1004
  team_players = teams[team_id]
1005
+ player_distances = [
1006
+ (pid, performance_tracker.get_player_stats(pid)['total_distance'])
1007
+ for pid in team_players
1008
+ ]
1009
  player_distances.sort(key=lambda x: x[1], reverse=True)
1010
  top_players.extend([pid for pid, _ in player_distances[:3]])
1011
+
1012
  individual_heatmaps = []
1013
  for pid in top_players[:6]:
1014
  heatmap = create_player_heatmap_visualization(performance_tracker, pid)
1015
  individual_heatmaps.append(heatmap)
1016
+
 
1017
  if len(individual_heatmaps) > 0:
1018
  rows = []
1019
  for i in range(0, len(individual_heatmaps), 3):
1020
+ row_maps = individual_heatmaps[i:i + 3]
1021
  if len(row_maps) == 3:
1022
  rows.append(np.hstack(row_maps))
1023
  elif len(row_maps) == 2:
1024
  rows.append(np.hstack([row_maps[0], row_maps[1]]))
1025
  else:
1026
  rows.append(row_maps[0])
1027
+
1028
  individual_grid = np.vstack(rows) if len(rows) > 1 else rows[0]
1029
  individual_heatmaps_path = "/tmp/individual_heatmaps.png"
1030
  cv2.imwrite(individual_heatmaps_path, individual_grid)
1031
  else:
1032
  individual_heatmaps_path = None
1033
 
1034
+ # ===================================================
1035
+ # STEP 6: Radar View
1036
+ # ===================================================
1037
+ progress(0.8, desc="๐Ÿ—บ๏ธ Creating game-style radar view (Step 6/7)...")
1038
  radar_path = "/tmp/radar_view_enhanced.png"
1039
  try:
1040
  if last_pitch_players_xy is not None:
1041
+ last_ball = cleaned_path[-1] if cleaned_path else np.empty((0, 2))
1042
  radar_frame = create_game_style_radar(
1043
+ pitch_ball_xy=last_ball,
1044
  pitch_players_xy=last_pitch_players_xy,
1045
  players_class_id=last_players_class_id,
1046
  pitch_referees_xy=last_pitch_referees_xy if last_pitch_referees_xy is not None else np.empty((0, 2)),
 
1053
  print(f"โš ๏ธ Radar view creation failed: {e}")
1054
  radar_path = None
1055
 
1056
+ # ===================================================
1057
+ # STEP 7: Summaries + Tables + Events JSON
1058
+ # ===================================================
1059
+ progress(0.9, desc="๐Ÿ“ Generating summary & tables (Step 7/7)...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
 
1061
+ # Summary text
 
 
 
 
1062
  summary_lines = ["โœ… **Analysis Complete!**\n"]
1063
+ summary_lines.append("**Video Statistics:**")
1064
  summary_lines.append(f"- Total Frames Processed: {frame_count}")
1065
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1066
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1067
  summary_lines.append(f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n")
1068
 
1069
+ teams = performance_tracker.get_all_players_by_team()
 
 
 
 
 
 
1070
  for team_id in [0, 1]:
1071
+ if team_id not in teams:
1072
  continue
1073
+
1074
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
1075
  summary_lines.append(f"\n**{team_name}:**")
1076
+ summary_lines.append(f"- Players Tracked: {len(teams[team_id])}")
1077
+
1078
+ total_dist = sum(performance_tracker.get_player_stats(pid)['total_distance_meters']
1079
+ for pid in teams[team_id])
1080
+ avg_dist = total_dist / len(teams[team_id]) if len(teams[team_id]) > 0 else 0
1081
  summary_lines.append(f"- Team Total Distance: {total_dist:.1f}m")
1082
  summary_lines.append(f"- Average Distance per Player: {avg_dist:.1f}m")
1083
+
1084
  # Top 3 performers
1085
+ player_distances = [(pid, performance_tracker.get_player_stats(pid)['total_distance_meters'])
1086
+ for pid in teams[team_id]]
1087
  player_distances.sort(key=lambda x: x[1], reverse=True)
1088
+
1089
  summary_lines.append(f"\n **Top 3 Performers:**")
1090
  for i, (pid, dist) in enumerate(player_distances[:3], 1):
1091
  stats = performance_tracker.get_player_stats(pid)
1092
  summary_lines.append(
1093
  f" {i}. Player #{pid}: {dist:.1f}m, "
1094
+ f"Avg Speed (rel): {stats['avg_velocity']:.2f}, "
1095
+ f"Max Speed (rel): {stats['max_velocity']:.2f}"
1096
  )
1097
+
1098
+ # Possession summary
1099
+ total_poss = sum(possession_time_team_s.values()) + 1e-6
1100
+ poss_summary = []
1101
+ for team_id in sorted(possession_time_team_s.keys()):
1102
+ pct = 100.0 * possession_time_team_s[team_id] / total_poss
1103
+ poss_summary.append(f"- Team {team_id} Possession: {pct:.1f}% ({possession_time_team_s[team_id]:.1f}s)")
1104
+ if poss_summary:
1105
+ summary_lines.append("\n**Team Possession:**")
1106
+ summary_lines.extend(poss_summary)
1107
+
1108
  summary_lines.append("\n**Pipeline Steps Completed:**")
1109
  summary_lines.append("โœ… 1. Player crop collection")
1110
  summary_lines.append("โœ… 2. Team classifier training")
1111
+ summary_lines.append("โœ… 3. Video processing with tracking + events")
1112
  summary_lines.append("โœ… 4. Ball trajectory cleaning")
1113
  summary_lines.append("โœ… 5. Performance analytics generation")
1114
  summary_lines.append("โœ… 6. Visualization creation")
1115
+ summary_lines.append("โœ… 7. Event & possession stats")
 
1116
  summary_msg = "\n".join(summary_lines)
1117
 
1118
+ # Player stats table (for DataFrame)
1119
+ player_stats_rows = []
1120
+ all_player_ids = sorted(performance_tracker.player_positions.keys())
1121
+ for tid in all_player_ids:
1122
+ stats = performance_tracker.get_player_stats(tid)
1123
+ row = {
1124
+ "player_id": int(tid),
1125
+ "team_id": int(stats["team_id"]),
1126
+ "distance_m": float(stats["total_distance_meters"]),
1127
+ "avg_speed_rel": float(stats["avg_velocity"]),
1128
+ "max_speed_rel": float(stats["max_velocity"]),
1129
+ "frames_visible": int(stats["frames_visible"]),
1130
+ "time_def_third": int(stats["time_in_defensive_third"]),
1131
+ "time_mid_third": int(stats["time_in_middle_third"]),
1132
+ "time_att_third": int(stats["time_in_attacking_third"]),
1133
+ "possession_time_s": float(possession_time_player_s.get(tid, 0.0)),
1134
+ }
1135
+ player_stats_rows.append(row)
1136
+
1137
+ # Event timeline (text)
1138
+ events_sorted = sorted(events, key=lambda e: e.get("time_s", 0.0))
1139
+ timeline_lines = []
1140
+ for ev in events_sorted:
1141
+ t = ev.get("time_s", 0.0)
1142
+ ev_type = ev.get("type", "event")
1143
+ if ev_type == "pass":
1144
+ timeline_lines.append(
1145
+ f"[{t:6.2f}s] PASS - Team {ev.get('team_id')} #{ev.get('from_tid')} โ†’ #{ev.get('to_tid')} "
1146
+ f"({ev.get('distance_m', 0.0):.1f}m)"
1147
+ )
1148
+ elif ev_type in ["tackle", "interception"]:
1149
+ timeline_lines.append(
1150
+ f"[{t:6.2f}s] {ev_type.upper():9} - #{ev.get('to_tid')} from #{ev.get('from_tid')}"
1151
+ )
1152
+ elif ev_type == "shot":
1153
+ timeline_lines.append(
1154
+ f"[{t:6.2f}s] SHOT - Team {ev.get('team_id')} #{ev.get('from_tid')} "
1155
+ f"({ev.get('speed_kmh', 0.0):.1f} km/h)"
1156
+ )
1157
+ elif ev_type == "clearance":
1158
+ timeline_lines.append(
1159
+ f"[{t:6.2f}s] CLEAR - Team {ev.get('team_id')} #{ev.get('from_tid')} "
1160
+ f"({ev.get('speed_kmh', 0.0):.1f} km/h)"
1161
+ )
1162
+ elif ev_type == "possession_change":
1163
+ timeline_lines.append(
1164
+ f"[{t:6.2f}s] POSS - {ev.get('from_tid')} โ†’ {ev.get('to_tid')} "
1165
+ f"(Team {ev.get('team_id')})"
1166
+ )
1167
+ else:
1168
+ timeline_lines.append(f"[{t:6.2f}s] {ev_type.upper()}")
1169
+ events_timeline_text = "\n".join(timeline_lines) if timeline_lines else "No events detected."
1170
+
1171
+ # Events JSON file
1172
+ events_json_path = "/tmp/events.json"
1173
+ with open(events_json_path, "w", encoding="utf-8") as f:
1174
+ json.dump(events_sorted, f, indent=2)
1175
+
1176
  progress(1.0, desc="โœ… Analysis Complete!")
1177
 
1178
  return (
1179
+ output_path, # video
1180
+ comparison_fig, # plot
1181
+ team_heatmaps_path, # image
1182
+ individual_heatmaps_path,
1183
+ radar_path,
1184
+ summary_msg, # text summary
1185
+ player_stats_rows, # DataFrame
1186
+ events_timeline_text, # text
1187
+ events_json_path # downloadable JSON
1188
  )
1189
 
1190
  except Exception as e:
 
1192
  print(error_msg)
1193
  import traceback
1194
  traceback.print_exc()
1195
+ return (None, None, None, None, None, error_msg, None, None, None)
 
 
 
 
1196
 
1197
 
1198
  # ==============================================
1199
+ # GRADIO INTERFACE
1200
  # ==============================================
1201
  with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1202
  gr.Markdown("""
1203
  # โšฝ Advanced Football Video Analyzer
1204
+ ### End-to-end Tactical & Performance Analysis
1205
+
1206
+ This app performs:
1207
+ 1. **Player Detection & Tracking** (Roboflow + ByteTrack)
1208
+ 2. **Team Classification** (SigLIP-based)
1209
+ 3. **Pitch Projection** (homography)
1210
+ 4. **Speed & Distance** per player (overlay on video)
1211
+ 5. **Ball Trajectory** with outlier removal
1212
+ 6. **Performance Analytics** (heatmaps, distance, zones)
1213
+ 7. **Events & Possession** (passes, tackles, shots, clearances, possession changes)
 
 
1214
  """)
1215
+
1216
  with gr.Row():
1217
  video_input = gr.Video(label="๐Ÿ“ค Upload Football Video")
1218
+
1219
+ analyze_btn = gr.Button("๐Ÿš€ Start Analysis Pipeline", variant="primary")
1220
+
1221
  with gr.Row():
1222
  status_output = gr.Textbox(label="๐Ÿ“Š Analysis Summary & Statistics", lines=25)
1223
+
1224
  with gr.Tabs():
1225
  with gr.Tab("๐Ÿ“น Annotated Video"):
1226
+ gr.Markdown("### Full video with player IDs, team colors, speed & distance, and event banners")
1227
  video_output = gr.Video(label="Processed Video")
1228
+
1229
  with gr.Tab("๐Ÿ“Š Performance Comparison"):
1230
  gr.Markdown("### Interactive charts comparing player performance metrics")
1231
  comparison_output = gr.Plot(label="Team Performance Metrics")
1232
+
1233
  with gr.Tab("๐Ÿ—บ๏ธ Team Heatmaps"):
1234
  gr.Markdown("### Combined activity heatmaps showing team positioning")
1235
  team_heatmaps_output = gr.Image(label="Team Activity Heatmaps")
1236
+
1237
  with gr.Tab("๐Ÿ‘ค Individual Heatmaps"):
1238
  gr.Markdown("### Top 6 players with detailed activity analysis")
1239
  individual_heatmaps_output = gr.Image(label="Top Players Heatmaps")
1240
+
1241
  with gr.Tab("๐ŸŽฎ Game Radar View"):
1242
+ gr.Markdown("### Game-style tactical radar view with ball trail")
1243
  radar_output = gr.Image(label="Tactical Radar View")
1244
 
1245
+ with gr.Tab("๐Ÿ“‹ Player Stats Table"):
1246
+ gr.Markdown("### Per-player stats (distance, speed, zones, possession time)")
1247
+ player_stats_output = gr.Dataframe(
 
 
 
 
 
 
 
 
 
 
1248
  headers=[
1249
+ "player_id", "team_id", "distance_m", "avg_speed_rel", "max_speed_rel",
1250
+ "frames_visible", "time_def_third", "time_mid_third", "time_att_third",
1251
+ "possession_time_s"
1252
  ],
1253
+ datatype=["number", "number", "number", "number", "number",
1254
+ "number", "number", "number", "number", "number"],
1255
+ label="Player Stats"
1256
  )
1257
+
1258
+ with gr.Tab("๐Ÿ“œ Event Timeline"):
1259
+ gr.Markdown("### Detected events: passes, tackles, interceptions, shots, clearances, possession changes")
1260
+ events_timeline_output = gr.Textbox(label="Event Timeline", lines=30)
1261
+
1262
+ with gr.Tab("โฌ‡๏ธ Events JSON"):
1263
+ gr.Markdown("### Download full raw event data as JSON")
1264
+ events_json_output = gr.File(label="Events JSON")
1265
 
1266
  analyze_btn.click(
1267
  fn=analyze_football_video,
 
1273
  individual_heatmaps_output,
1274
  radar_output,
1275
  status_output,
1276
+ player_stats_output,
1277
+ events_timeline_output,
1278
+ events_json_output,
1279
  ]
1280
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1281
 
1282
  if __name__ == "__main__":
1283
+ # On Spaces, just iface.launch()
1284
+ iface.launch()