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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +513 -463
app.py CHANGED
@@ -40,6 +40,9 @@ if not HF_TOKEN or not ROBOFLOW_API_KEY:
40
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
41
  print(f"๐Ÿ–ฅ๏ธ Using device: {DEVICE}")
42
 
 
 
 
43
  # ==============================================
44
  # ROBOFLOW INFERENCE CLIENT
45
  # ==============================================
@@ -65,10 +68,7 @@ def infer_with_confidence(model_id: str, frame: np.ndarray, confidence_threshold
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
  # ==============================================
@@ -76,6 +76,7 @@ EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF
76
  # ==============================================
77
  CONFIG = SoccerPitchConfiguration()
78
 
 
79
  # ==============================================
80
  # BALL PATH OUTLIER REMOVAL
81
  # ==============================================
@@ -83,7 +84,7 @@ def replace_outliers_based_on_distance(
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
 
@@ -113,62 +114,82 @@ class PlayerPerformanceTracker:
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,
122
- 'avg_velocity': 0,
123
- 'max_velocity': 0,
124
- 'time_in_attacking_third': 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
155
- elif position[0] < 2 * pitch_length / 3:
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:
@@ -224,7 +245,7 @@ class PlayerTrackingManager:
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]:
@@ -232,7 +253,9 @@ class PlayerTrackingManager:
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
 
@@ -245,7 +268,8 @@ class PlayerTrackingManager:
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)
@@ -255,42 +279,50 @@ def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTr
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
  )
@@ -308,11 +340,11 @@ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker) -
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
 
@@ -346,16 +378,17 @@ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker) -
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
 
@@ -376,20 +409,23 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> n
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
 
@@ -478,30 +514,41 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
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...")
497
 
498
  # IDs from Roboflow model
499
  BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
500
- STRIDE = 30 # Frame sampling for training
501
- MAXLEN = 5 # Transformation matrix smoothing
502
- MAX_DISTANCE_THRESHOLD = 500 # Ball path outlier threshold
503
 
504
- # Initialize managers
505
  tracking_manager = PlayerTrackingManager(max_history=10)
506
  performance_tracker = PlayerPerformanceTracker(CONFIG)
507
 
@@ -522,7 +569,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
522
  height=17
523
  )
524
 
525
- # ByteTrack tracker with optimized settings
526
  tracker = sv.ByteTrack(
527
  track_activation_threshold=0.4,
528
  lost_track_buffer=60,
@@ -531,171 +578,165 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
531
  )
532
  tracker.reset()
533
 
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:
561
  break
562
-
563
- if frame_count % STRIDE == 0:
564
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
565
  detections = detections.with_nms(threshold=0.5, class_agnostic=True)
566
  players_detections = detections[detections.class_id == PLAYER_ID]
567
-
568
  if len(players_detections.xyxy) > 0:
569
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
570
  player_crops.extend(crops)
571
-
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:
635
  break
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:
648
  out.write(frame)
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)
@@ -704,225 +745,243 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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)
799
  cur_team = team_of_player.get(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
-
808
  if prev_team is not None and cur_team is not None:
809
- if prev_team == cur_team and travel_m > MIN_PASS_TRAVEL_M:
810
  # pass
 
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
  )
843
 
844
- # generic possession-change event
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
 
865
- if speed_kmh > HIGH_SPEED_KMH:
 
 
 
 
 
 
 
866
  shooter_team = team_of_player.get(owner_tid)
867
  if shooter_team is not None:
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
  )
885
  else:
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
  )
896
 
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,
@@ -942,10 +1001,13 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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,
@@ -956,18 +1018,18 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
956
  2,
957
  cv2.LINE_AA,
958
  )
959
- event_text_frames_left -= 1
960
 
961
  out.write(annotated_frame)
962
 
963
  cap.release()
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:
@@ -979,31 +1041,31 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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)
@@ -1011,7 +1073,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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:
@@ -1024,26 +1086,24 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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)),
1047
  ball_path=cleaned_path
1048
  )
1049
  cv2.imwrite(radar_path, radar_frame)
@@ -1053,146 +1113,131 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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:
1191
- error_msg = f"โŒ Error: {str(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
  # ==============================================
@@ -1201,16 +1246,16 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
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():
@@ -1219,11 +1264,11 @@ with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()
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"):
@@ -1239,29 +1284,34 @@ with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()
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,12 +1323,12 @@ with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()
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()
 
40
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
41
  print(f"๐Ÿ–ฅ๏ธ Using device: {DEVICE}")
42
 
43
+ # Distance units: pitch coordinates are effectively in centimeters
44
+ CM_PER_METER = 100.0
45
+
46
  # ==============================================
47
  # ROBOFLOW INFERENCE CLIENT
48
  # ==============================================
 
68
  # SIGLIP MODEL (Embeddings)
69
  # ==============================================
70
  SIGLIP_MODEL_PATH = "google/siglip-base-patch16-224"
71
+ EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN).to(DEVICE)
 
 
 
72
  EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
73
 
74
  # ==============================================
 
76
  # ==============================================
77
  CONFIG = SoccerPitchConfiguration()
78
 
79
+
80
  # ==============================================
81
  # BALL PATH OUTLIER REMOVAL
82
  # ==============================================
 
84
  positions: List[np.ndarray],
85
  distance_threshold: float
86
  ) -> List[np.ndarray]:
87
+ """Remove outlier positions based on distance threshold (in same units as positions)"""
88
  last_valid_position: Union[np.ndarray, None] = None
89
  cleaned_positions: List[np.ndarray] = []
90
 
 
114
 
115
  def __init__(self, pitch_config):
116
  self.config = pitch_config
117
+ self.player_positions = defaultdict(list) # (x_cm, y_cm, frame)
118
+ self.player_velocities = defaultdict(list) # cm/s
119
+ self.player_distances_cm = defaultdict(float)
120
  self.player_team = {}
121
  self.player_stats = defaultdict(lambda: {
122
  'frames_visible': 0,
123
+ 'avg_velocity_cm_s': 0.0,
124
+ 'max_velocity_cm_s': 0.0,
125
+ 'time_in_attacking_third_frames': 0,
126
+ 'time_in_defensive_third_frames': 0,
127
+ 'time_in_middle_third_frames': 0
128
  })
129
 
130
+ def update(self, tracker_id: int, position_cm: np.ndarray, team_id: int, frame: int, fps: float):
131
+ """Update player position and calculate metrics (position in pitch units, treated as cm)."""
132
+ if len(position_cm) != 2:
133
  return
134
 
135
  self.player_team[tracker_id] = team_id
136
+ self.player_positions[tracker_id].append((position_cm[0], position_cm[1], frame))
137
  self.player_stats[tracker_id]['frames_visible'] += 1
138
 
139
  if len(self.player_positions[tracker_id]) > 1:
140
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
141
+ curr_pos = np.array(position_cm)
142
+ distance_cm = np.linalg.norm(curr_pos - prev_pos)
143
+ self.player_distances_cm[tracker_id] += distance_cm
144
+
145
+ dt = 1.0 / fps
146
+ velocity_cm_s = distance_cm / dt
147
+ self.player_velocities[tracker_id].append(velocity_cm_s)
148
+
149
+ if velocity_cm_s > self.player_stats[tracker_id]['max_velocity_cm_s']:
150
+ self.player_stats[tracker_id]['max_velocity_cm_s'] = velocity_cm_s
151
+
152
+ pitch_length_cm = self.config.length # same units as transform
153
+ x = position_cm[0]
154
+ if x < pitch_length_cm / 3:
155
+ self.player_stats[tracker_id]['time_in_defensive_third_frames'] += 1
156
+ elif x < 2 * pitch_length_cm / 3:
157
+ self.player_stats[tracker_id]['time_in_middle_third_frames'] += 1
158
  else:
159
+ self.player_stats[tracker_id]['time_in_attacking_third_frames'] += 1
160
 
161
+ def get_player_stats(self, tracker_id: int, fps: float) -> dict:
162
+ """Get comprehensive stats for a player (distances in m, speed in m/s and km/h)."""
163
  stats = self.player_stats[tracker_id].copy()
164
 
165
  if len(self.player_velocities[tracker_id]) > 0:
166
+ stats['avg_velocity_cm_s'] = float(np.mean(self.player_velocities[tracker_id]))
167
 
168
+ # convert distances from cm to m
169
+ total_distance_m = self.player_distances_cm[tracker_id] / CM_PER_METER
170
+
171
+ stats['total_distance_m'] = total_distance_m
172
  stats['team_id'] = self.player_team.get(tracker_id, -1)
173
 
174
+ # frames in zones -> seconds
175
+ stats['time_in_defensive_third_s'] = (
176
+ stats['time_in_defensive_third_frames'] / fps
177
+ )
178
+ stats['time_in_middle_third_s'] = (
179
+ stats['time_in_middle_third_frames'] / fps
180
+ )
181
+ stats['time_in_attacking_third_s'] = (
182
+ stats['time_in_attacking_third_frames'] / fps
183
+ )
184
+
185
+ # convert cm/s -> m/s and km/h
186
+ avg_v_m_s = stats['avg_velocity_cm_s'] / CM_PER_METER
187
+ max_v_m_s = stats['max_velocity_cm_s'] / CM_PER_METER
188
+ stats['avg_speed_m_s'] = avg_v_m_s
189
+ stats['max_speed_m_s'] = max_v_m_s
190
+ stats['avg_speed_km_h'] = avg_v_m_s * 3.6
191
+ stats['max_speed_km_h'] = max_v_m_s * 3.6
192
+
193
  return stats
194
 
195
  def generate_heatmap(self, tracker_id: int, resolution: int = 100) -> np.ndarray:
 
245
 
246
  history = self.tracker_team_history[tracker_id]
247
  team_counts = np.bincount(history)
248
+ stable_team = np.argmax(team_counts)
249
  return stable_team
250
 
251
  def get_player_count_by_team(self) -> Dict[int, int]:
 
253
  team_counts = defaultdict(int)
254
  for tracker_id in self.active_trackers:
255
  if tracker_id in self.tracker_team_history and len(self.tracker_team_history[tracker_id]) > 0:
256
+ stable_team = self.get_stable_team_id(
257
+ tracker_id, self.tracker_team_history[tracker_id][-1]
258
+ )
259
  team_counts[stable_team] += 1
260
  return team_counts
261
 
 
268
  # VISUALIZATION FUNCTIONS
269
  # ==============================================
270
  def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTracker,
271
+ tracker_id: int,
272
+ fps: float) -> np.ndarray:
273
  """Create a single player heatmap overlay on pitch"""
274
  pitch = draw_pitch(CONFIG)
275
  heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
 
279
 
280
  padding = 50
281
  pitch_height, pitch_width = pitch.shape[:2]
282
+ heatmap_resized = cv2.resize(
283
+ heatmap, (pitch_width - 2 * padding, pitch_height - 2 * padding)
284
+ )
285
 
286
+ heatmap_colored = cv2.applyColorMap(
287
+ (heatmap_resized * 255).astype(np.uint8), cv2.COLORMAP_JET
288
+ )
289
 
290
  overlay = pitch.copy()
291
  overlay[padding:pitch_height - padding, padding:pitch_width - padding] = heatmap_colored
292
 
293
  result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
294
 
295
+ stats = performance_tracker.get_player_stats(tracker_id, fps)
296
  team_color = "Blue" if stats['team_id'] == 0 else "Pink"
297
 
298
  text_lines = [
299
  f"Player #{tracker_id} ({team_color} Team)",
300
+ f"Distance: {stats['total_distance_m']:.1f} m",
301
+ f"Avg Speed: {stats['avg_speed_km_h']:.2f} km/h",
302
+ f"Max Speed: {stats['max_speed_km_h']:.2f} km/h",
303
  f"Frames: {stats['frames_visible']}"
304
  ]
305
 
306
  y_offset = 30
307
  for line in text_lines:
308
+ cv2.putText(
309
+ result, line, (10, y_offset),
310
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6,
311
+ (255, 255, 255), 2, cv2.LINE_AA
312
+ )
313
  y_offset += 25
314
 
315
  return result
316
 
317
 
318
+ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker,
319
+ fps: float) -> go.Figure:
320
  """Create interactive performance comparison plots"""
321
  teams = performance_tracker.get_all_players_by_team()
322
 
323
  fig = make_subplots(
324
  rows=2, cols=2,
325
+ subplot_titles=('Distance Covered', 'Average Speed', 'Max Speed', 'Activity by Zone'),
326
  specs=[[{'type': 'bar'}, {'type': 'bar'}],
327
  [{'type': 'bar'}, {'type': 'bar'}]]
328
  )
 
340
  attacking_time = []
341
 
342
  for pid in player_ids:
343
+ stats = performance_tracker.get_player_stats(pid, fps)
344
+ distances.append(stats['total_distance_m'])
345
+ avg_speeds.append(stats['avg_speed_km_h'])
346
+ max_speeds.append(stats['max_speed_km_h'])
347
+ attacking_time.append(stats['time_in_attacking_third_s'])
348
 
349
  player_labels = [f"#{pid}" for pid in player_ids]
350
 
 
378
  fig.update_xaxes(title_text="Players", row=2, col=2)
379
 
380
  fig.update_yaxes(title_text="Distance (m)", row=1, col=1)
381
+ fig.update_yaxes(title_text="Speed (km/h)", row=1, col=2)
382
+ fig.update_yaxes(title_text="Speed (km/h)", row=2, col=1)
383
+ fig.update_yaxes(title_text="Time in attacking third (s)", row=2, col=2)
384
 
385
  fig.update_layout(height=800, title_text="Team Performance Comparison", barmode='group')
386
 
387
  return fig
388
 
389
 
390
+ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker,
391
+ fps: float) -> np.ndarray:
392
  """Create side-by-side team heatmaps"""
393
  teams = performance_tracker.get_all_players_by_team()
394
 
 
409
  padding = 50
410
  pitch_height, pitch_width = pitch.shape[:2]
411
  heatmap_resized = cv2.resize(
412
+ combined_heatmap, (pitch_width - 2 * padding, pitch_height - 2 * padding)
 
413
  )
414
 
415
  colormap = cv2.COLORMAP_JET if team_id == 0 else cv2.COLORMAP_HOT
416
+ heatmap_colored = cv2.applyColorMap(
417
+ (heatmap_resized * 255).astype(np.uint8), colormap
418
+ )
419
 
420
  overlay = pitch.copy()
421
  overlay[padding:pitch_height - padding, padding:pitch_width - padding] = heatmap_colored
422
  result = cv2.addWeighted(pitch, 0.5, overlay, 0.5, 0)
423
 
424
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
425
+ cv2.putText(
426
+ result, team_name, (10, 30),
427
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2, cv2.LINE_AA
428
+ )
429
 
430
  team_heatmaps.append(result)
431
 
 
514
  # ==============================================
515
  # MAIN ANALYSIS PIPELINE
516
  # ==============================================
517
+ def analyze_football_video(video_path: str, progress=gr.Progress()
518
+ ) -> Tuple[
519
+ Optional[str],
520
+ Optional[go.Figure],
521
+ Optional[str],
522
+ Optional[str],
523
+ Optional[str],
524
+ str,
525
+ List[List[float]],
526
+ str,
527
+ Optional[str]
528
+ ]:
529
  """
530
  Complete football analysis pipeline:
531
+ * team classification
532
+ * tracking + speeds/distances
533
+ * possession per team & per player
534
+ * events: passes, tackles, interceptions, shots, clearances, possession changes
535
+ * heatmaps + radar
 
 
536
  """
537
  if not video_path:
538
+ return (None, None, None, None, None,
539
+ "โŒ Please upload a video file.",
540
+ [], "No events detected.", None)
541
 
542
  try:
543
  progress(0, desc="๐Ÿ”ง Initializing...")
544
 
545
  # IDs from Roboflow model
546
  BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
547
+ STRIDE = 30
548
+ MAXLEN = 5
549
+ MAX_DISTANCE_THRESHOLD = 500.0 # in 'cm' units of pitch
550
 
551
+ # Managers
552
  tracking_manager = PlayerTrackingManager(max_history=10)
553
  performance_tracker = PlayerPerformanceTracker(CONFIG)
554
 
 
569
  height=17
570
  )
571
 
572
+ # Tracker
573
  tracker = sv.ByteTrack(
574
  track_activation_threshold=0.4,
575
  lost_track_buffer=60,
 
578
  )
579
  tracker.reset()
580
 
 
581
  cap = cv2.VideoCapture(video_path)
582
  if not cap.isOpened():
583
+ return (None, None, None, None, None,
584
+ f"โŒ Failed to open video: {video_path}",
585
+ [], "No events detected.", None)
586
 
587
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
588
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
589
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
590
+ fps = cap.get(cv2.CAP_PROP_FPS)
591
+ if fps <= 0:
592
+ fps = 25.0
593
  print(f"๐Ÿ“น Video: {width}x{height}, {fps}fps, {total_frames} frames")
594
 
595
  fourcc = cv2.VideoWriter_fourcc(*"mp4v")
596
  output_path = "/tmp/annotated_football.mp4"
597
  out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
598
 
599
+ # -----------------------------------
600
+ # STEP 1: Train team classifier
601
+ # -----------------------------------
 
 
602
  progress(0.05, desc="๐Ÿƒ Collecting player samples (Step 1/7)...")
603
  player_crops = []
604
+ frame_idx = 0
605
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
606
+ while frame_idx < min(total_frames, 300):
607
  ret, frame = cap.read()
608
  if not ret:
609
  break
610
+ if frame_idx % STRIDE == 0:
 
611
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
612
  detections = detections.with_nms(threshold=0.5, class_agnostic=True)
613
  players_detections = detections[detections.class_id == PLAYER_ID]
 
614
  if len(players_detections.xyxy) > 0:
615
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
616
  player_crops.extend(crops)
617
+ frame_idx += 1
 
618
 
619
  if len(player_crops) == 0:
620
+ cap.release()
621
+ out.release()
622
  return (None, None, None, None, None,
623
+ "โŒ No player crops collected.",
624
+ [], "No events detected.", None)
625
 
626
  print(f"โœ… Collected {len(player_crops)} player samples")
627
 
 
 
 
628
  progress(0.15, desc="๐ŸŽฏ Training team classifier (Step 2/7)...")
629
  team_classifier = TeamClassifier(device=DEVICE)
630
  team_classifier.fit(player_crops)
631
  print("โœ… Team classifier trained")
632
 
633
+ # -----------------------------------
634
+ # STEP 2: Full video processing + events
635
+ # -----------------------------------
636
  cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
637
+ frame_idx = 0
638
+ M = deque(maxlen=MAXLEN)
639
  ball_path_raw: List[np.ndarray] = []
640
 
641
+ # for radar
642
  last_pitch_players_xy = None
643
  last_players_class_id = None
644
  last_pitch_referees_xy = None
645
 
646
+ # stats for events / possession
647
+ dt = 1.0 / fps
648
+ distance_covered_per_player_m = defaultdict(float) # using correct meters
649
+ possession_time_player_s = defaultdict(float)
650
+ possession_time_team_s = defaultdict(float)
651
  team_of_player: Dict[int, int] = {}
652
  events: List[Dict[str, Any]] = []
653
 
654
+ # event HUD
655
+ current_event_text = ""
656
+ event_frames_left = 0
657
+ EVENT_TEXT_DURATION_FRAMES = int(2.0 * fps)
658
+
659
  prev_owner_tid: Optional[int] = None
660
+ prev_ball_pos_pitch_cm: Optional[np.ndarray] = None
661
 
662
+ # approximate goal centers in pitch coords (same units)
663
  goal_centers = {
664
  0: np.array([0.0, CONFIG.width / 2.0]),
665
  1: np.array([CONFIG.length, CONFIG.width / 2.0]),
666
  }
667
 
668
+ # thresholds in cm units
669
+ POSSESSION_RADIUS_M = 5.0
670
+ POSSESSION_RADIUS_CM = POSSESSION_RADIUS_M * CM_PER_METER
671
+ MIN_PASS_TRAVEL_M = 3.0
672
+ MIN_PASS_TRAVEL_CM = MIN_PASS_TRAVEL_M * CM_PER_METER
673
+ HIGH_SHOT_SPEED_KM_H = 18.0
674
 
675
  def register_event(ev: Dict[str, Any], text: str):
676
+ nonlocal current_event_text, event_frames_left
677
  events.append(ev)
678
  if text:
679
  current_event_text = text
680
+ event_frames_left = EVENT_TEXT_DURATION_FRAMES
681
 
682
+ progress(0.20, desc="๐ŸŽฌ Processing video frames (Step 3/7)...")
683
 
684
  while True:
685
  ret, frame = cap.read()
686
  if not ret:
687
  break
688
+ frame_idx += 1
 
689
  tracking_manager.reset_frame()
690
 
691
+ if frame_idx % 30 == 0:
692
+ progress(0.20 + 0.30 * (frame_idx / max(total_frames, 1)),
693
+ desc=f"๐ŸŽฌ Processing frame {frame_idx}/{total_frames}")
694
 
695
+ # --- detections ---
696
  _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
 
697
  if len(detections.xyxy) == 0:
698
  out.write(frame)
699
  ball_path_raw.append(np.empty((0, 2)))
700
  continue
701
 
 
702
  ball_detections = detections[detections.class_id == BALL_ID]
703
  ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
704
 
 
705
  all_detections = detections[detections.class_id != BALL_ID]
706
  all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
 
 
707
  all_detections = tracker.update_with_detections(detections=all_detections)
708
 
 
709
  goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
710
  players_detections = all_detections[all_detections.class_id == PLAYER_ID]
711
  referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
712
 
713
+ # --- team prediction + stabilisation ---
714
  if len(players_detections.xyxy) > 0:
715
  crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
716
  predicted_teams = team_classifier.predict(crops)
 
717
  for idx, tracker_id in enumerate(players_detections.tracker_id):
718
+ tracking_manager.update_team_assignment(tracker_id, predicted_teams[idx])
719
  predicted_teams[idx] = tracking_manager.get_stable_team_id(
720
+ tracker_id, predicted_teams[idx]
721
  )
 
722
  players_detections.class_id = predicted_teams
723
 
724
+ # goalkeeper teams
725
+ if len(goalkeepers_detections) > 0 and len(players_detections) > 0:
726
+ goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
727
+ players_detections, goalkeepers_detections
728
+ )
729
 
730
+ # adjust referee class_id
731
  referees_detections.class_id -= 1
732
 
733
+ # merged for drawing
734
+ merged_dets = sv.Detections.merge(
735
+ [players_detections, goalkeepers_detections, referees_detections]
736
+ )
737
+ merged_dets.class_id = merged_dets.class_id.astype(int)
 
 
 
 
738
 
739
+ # --- field homography ---
740
  try:
741
  result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL_ID, frame, 0.3)
742
  key_points = sv.KeyPoints.from_inference(result_field)
 
745
  frame_ref_pts = key_points.xy[0][filter_mask]
746
  pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
747
 
748
+ frame_ball_pos_pitch_cm = None
749
+ frame_players_xy_pitch_cm = None
750
+
751
  if len(frame_ref_pts) >= 4:
752
  transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
753
  M.append(transformer.m)
754
  transformer.m = np.mean(np.array(M), axis=0)
755
 
756
+ # ball position in pitch coords (cm)
757
  frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
758
  pitch_ball_xy = transformer.transform_points(frame_ball_xy)
759
  ball_path_raw.append(pitch_ball_xy)
760
  if len(pitch_ball_xy) > 0:
761
+ frame_ball_pos_pitch_cm = pitch_ball_xy[0]
762
 
763
+ # all players (incl. keepers)
764
  all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
765
+ players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
766
+ pitch_players_xy = transformer.transform_points(players_xy)
767
+
768
+ # referees
769
+ referees_xy = referees_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
770
+ pitch_referees_xy = transformer.transform_points(referees_xy)
771
+
772
+ last_pitch_players_xy = pitch_players_xy
773
+ last_players_class_id = all_players.class_id
774
+ last_pitch_referees_xy = pitch_referees_xy
775
+
776
+ frame_players_xy_pitch_cm = pitch_players_xy
777
+
778
+ # update performance tracker + distance/speed stats
779
+ for idx, tracker_id in enumerate(all_players.tracker_id):
780
+ tid_int = int(tracker_id)
781
+ team_id = int(all_players.class_id[idx])
782
+ pos_cm = pitch_players_xy[idx]
783
+ performance_tracker.update(
784
+ tid_int, pos_cm, team_id, frame_idx, fps
785
+ )
 
 
 
 
 
 
 
 
786
 
787
+ # distance & speed (m) for HUD + per-player
788
+ prev_pos_cm = performance_tracker.player_positions[tid_int][-2][:2] \
789
+ if len(performance_tracker.player_positions[tid_int]) > 1 else None
790
+ if prev_pos_cm is not None:
791
+ prev_pos_cm = np.array(prev_pos_cm)
792
+ dist_cm = float(np.linalg.norm(pos_cm - prev_pos_cm))
793
+ dist_m = dist_cm / CM_PER_METER
794
+ distance_covered_per_player_m[tid_int] += dist_m
795
+
796
+ team_of_player[tid_int] = team_id
 
 
 
 
797
  else:
798
  ball_path_raw.append(np.empty((0, 2)))
799
+ frame_ball_pos_pitch_cm = None
800
+ frame_players_xy_pitch_cm = None
801
  except Exception:
802
  ball_path_raw.append(np.empty((0, 2)))
803
+ frame_ball_pos_pitch_cm = None
804
+ frame_players_xy_pitch_cm = None
 
805
 
806
+ # --- possession owner ---
807
  owner_tid: Optional[int] = None
808
+ if frame_ball_pos_pitch_cm is not None and frame_players_xy_pitch_cm is not None:
809
+ dists_cm = np.linalg.norm(
810
+ frame_players_xy_pitch_cm - frame_ball_pos_pitch_cm, axis=1
811
+ )
812
+ j = int(np.argmin(dists_cm))
813
+ if dists_cm[j] < POSSESSION_RADIUS_CM:
814
+ owner_tid = int(all_players.tracker_id[j])
 
 
 
 
815
 
816
+ # accumulate possession time
817
  if owner_tid is not None:
818
  possession_time_player_s[owner_tid] += dt
819
  owner_team = team_of_player.get(owner_tid)
820
  if owner_team is not None:
821
  possession_time_team_s[owner_team] += dt
822
 
823
+ # --- events (pass, tackle, interception, shot, clearance, possession change) ---
824
+ t_s = frame_idx * dt
825
+
826
  if owner_tid != prev_owner_tid:
827
+ if owner_tid is not None and prev_owner_tid is not None \
828
+ and frame_ball_pos_pitch_cm is not None and prev_ball_pos_pitch_cm is not None:
829
+ # ball travel
830
+ travel_cm = float(
831
+ np.linalg.norm(frame_ball_pos_pitch_cm - prev_ball_pos_pitch_cm)
832
+ )
833
  prev_team = team_of_player.get(prev_owner_tid)
834
  cur_team = team_of_player.get(owner_tid)
835
 
 
 
 
 
 
 
 
836
  if prev_team is not None and cur_team is not None:
837
+ if prev_team == cur_team and travel_cm > MIN_PASS_TRAVEL_CM:
838
  # pass
839
+ dist_m = travel_cm / CM_PER_METER
840
  register_event(
841
  {
842
  "type": "pass",
843
+ "time_s": t_s,
844
+ "frame_idx": frame_idx,
845
+ "from_player_id": int(prev_owner_tid),
846
+ "to_player_id": int(owner_tid),
847
  "team_id": int(cur_team),
848
+ "distance_m": dist_m,
849
  },
850
+ f"Pass: #{prev_owner_tid} โ†’ #{owner_tid} (Team {cur_team}, {dist_m:.1f} m)"
851
  )
852
  elif prev_team != cur_team:
853
+ # tackle vs interception based on player distance
854
+ d_pp_m = None
855
+ if frame_players_xy_pitch_cm is not None:
856
+ pos_prev = performance_tracker.player_positions[int(prev_owner_tid)][-1][:2] \
857
+ if performance_tracker.player_positions[int(prev_owner_tid)] else None
858
+ pos_cur = performance_tracker.player_positions[int(owner_tid)][-1][:2] \
859
+ if performance_tracker.player_positions[int(owner_tid)] else None
860
+ if pos_prev is not None and pos_cur is not None:
861
+ pos_prev = np.array(pos_prev)
862
+ pos_cur = np.array(pos_cur)
863
+ d_pp_cm = float(np.linalg.norm(pos_prev - pos_cur))
864
+ d_pp_m = d_pp_cm / CM_PER_METER
865
+
866
+ ev_type = "tackle"
867
+ label = "Tackle"
868
+ if d_pp_m is not None and d_pp_m > 3.0:
869
+ ev_type = "interception"
870
+ label = "Interception"
871
  register_event(
872
  {
873
  "type": ev_type,
874
+ "time_s": t_s,
875
+ "frame_idx": frame_idx,
876
+ "from_player_id": int(prev_owner_tid),
877
+ "to_player_id": int(owner_tid),
878
  "team_id": int(cur_team),
879
+ "player_distance_m": float(d_pp_m) if d_pp_m is not None else None,
 
880
  },
881
+ f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}"
882
  )
883
 
884
+ # explicit possession change event (only when someone gains it)
885
+ if owner_tid is not None:
886
+ register_event(
887
+ {
888
+ "type": "possession_change",
889
+ "time_s": t_s,
890
+ "frame_idx": frame_idx,
891
+ "from_player_id": int(prev_owner_tid) if prev_owner_tid is not None else None,
892
+ "to_player_id": int(owner_tid),
893
+ "team_id": int(team_of_player.get(owner_tid, -1)),
894
+ },
895
+ f"Team {team_of_player.get(owner_tid, -1)} now in possession"
896
+ )
 
 
 
 
 
 
 
897
 
898
+ # shot / clearance
899
+ if prev_ball_pos_pitch_cm is not None and frame_ball_pos_pitch_cm is not None \
900
+ and owner_tid is not None:
901
+ v_cm = (frame_ball_pos_pitch_cm - prev_ball_pos_pitch_cm) / dt
902
+ speed_cm_s = float(np.linalg.norm(v_cm))
903
+ speed_m_s = speed_cm_s / CM_PER_METER
904
+ speed_km_h = speed_m_s * 3.6
905
+ if speed_km_h > HIGH_SHOT_SPEED_KM_H:
906
  shooter_team = team_of_player.get(owner_tid)
907
  if shooter_team is not None:
908
  target_goal = goal_centers[1 - shooter_team]
909
+ direction = target_goal - frame_ball_pos_pitch_cm
910
  cos_angle = float(
911
+ np.dot(v_cm, direction) /
912
+ (np.linalg.norm(v_cm) * np.linalg.norm(direction) + 1e-6)
913
  )
914
  if cos_angle > 0.8:
915
  register_event(
916
  {
917
  "type": "shot",
918
+ "time_s": t_s,
919
+ "frame_idx": frame_idx,
920
+ "from_player_id": int(owner_tid),
921
  "team_id": int(shooter_team),
922
+ "speed_km_h": speed_km_h,
923
  },
924
+ f"Shot by #{owner_tid} (Team {shooter_team}) โ€“ {speed_km_h:.1f} km/h"
925
  )
926
  else:
927
  register_event(
928
  {
929
  "type": "clearance",
930
+ "time_s": t_s,
931
+ "frame_idx": frame_idx,
932
+ "from_player_id": int(owner_tid),
933
  "team_id": int(shooter_team),
934
+ "speed_km_h": speed_km_h,
935
  },
936
+ f"Clearance by #{owner_tid} (Team {shooter_team})"
937
  )
938
 
939
  prev_owner_tid = owner_tid
940
+ prev_ball_pos_pitch_cm = frame_ball_pos_pitch_cm
941
 
942
+ # --- draw frame ---
943
  annotated_frame = frame.copy()
944
 
945
+ # labels with speed + distance
946
+ player_labels = []
947
+ if last_pitch_players_xy is not None and len(players_detections) > 0:
948
+ for idx, tid in enumerate(players_detections.tracker_id):
949
+ tid_int = int(tid)
950
+ # estimate instantaneous speed from last two positions in performance tracker
951
+ pos_list = performance_tracker.player_positions[tid_int]
952
+ speed_km_h = 0.0
953
+ if len(pos_list) >= 2:
954
+ prev_cm = np.array(pos_list[-2][:2])
955
+ curr_cm = np.array(pos_list[-1][:2])
956
+ dist_cm = float(np.linalg.norm(curr_cm - prev_cm))
957
+ dist_m = dist_cm / CM_PER_METER
958
+ speed_km_h = (dist_m / dt) * 3.6
959
+
960
+ d_total_m = distance_covered_per_player_m[tid_int]
961
+ team_id = team_of_player.get(tid_int, -1)
962
+ player_labels.append(
963
+ f"#{tid_int} T{team_id} {speed_km_h:4.1f} km/h {d_total_m:.1f} m"
964
+ )
965
 
966
+ annotated_frame = ellipse_annotator.annotate(
967
+ scene=annotated_frame, detections=players_detections
968
+ )
969
+ annotated_frame = label_annotator.annotate(
970
+ scene=annotated_frame, detections=players_detections, labels=player_labels
971
+ )
972
+
973
+ annotated_frame = triangle_annotator.annotate(
974
+ scene=annotated_frame, detections=ball_detections
975
+ )
976
 
977
+ # possession HUD
978
  total_poss_time = sum(possession_time_team_s.values()) + 1e-6
979
  team0_pct = 100.0 * possession_time_team_s.get(0, 0.0) / total_poss_time
980
  team1_pct = 100.0 * possession_time_team_s.get(1, 0.0) / total_poss_time
981
+ hud_text = (
982
+ f"Team 0 Possession: {team0_pct:5.1f}% "
983
+ f"Team 1 Possession: {team1_pct:5.1f}%"
984
+ )
985
 
986
  cv2.rectangle(
987
  annotated_frame,
 
1001
  cv2.LINE_AA,
1002
  )
1003
 
1004
+ # event banner
1005
+ if event_frames_left > 0 and current_event_text:
1006
+ cv2.rectangle(
1007
+ annotated_frame, (20, 20),
1008
+ (annotated_frame.shape[1] - 20, 90),
1009
+ (255, 255, 255), -1
1010
+ )
1011
  cv2.putText(
1012
  annotated_frame,
1013
  current_event_text,
 
1018
  2,
1019
  cv2.LINE_AA,
1020
  )
1021
+ event_frames_left -= 1
1022
 
1023
  out.write(annotated_frame)
1024
 
1025
  cap.release()
1026
  out.release()
1027
+ print(f"โœ… Processed {frame_idx} frames")
1028
 
1029
+ # -----------------------------------
1030
+ # STEP 3: clean ball path
1031
+ # -----------------------------------
1032
+ progress(0.60, desc="๐Ÿงน Cleaning ball trajectory (Step 4/7)...")
1033
 
1034
  path_for_cleaning = []
1035
  for coords in ball_path_raw:
 
1041
  path_for_cleaning.append(coords)
1042
 
1043
  cleaned_path = replace_outliers_based_on_distance(
1044
+ [np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2))
1045
+ for p in path_for_cleaning],
1046
  MAX_DISTANCE_THRESHOLD
1047
  )
 
1048
  print(f"โœ… Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
1049
 
1050
+ # -----------------------------------
1051
+ # STEP 4: performance analytics
1052
+ # -----------------------------------
1053
+ progress(0.70, desc="๐Ÿ“Š Generating performance analytics (Step 5/7)...")
1054
 
1055
+ comparison_fig = create_team_comparison_plot(performance_tracker, fps)
1056
 
1057
  team_heatmaps_path = "/tmp/team_heatmaps.png"
1058
+ team_heatmaps = create_combined_heatmaps(performance_tracker, fps)
1059
  cv2.imwrite(team_heatmaps_path, team_heatmaps)
1060
 
1061
+ # individual heatmaps (top 6 by distance)
1062
  teams = performance_tracker.get_all_players_by_team()
1063
  top_players = []
1064
  for team_id in [0, 1]:
1065
  if team_id in teams:
1066
  team_players = teams[team_id]
1067
  player_distances = [
1068
+ (pid, performance_tracker.get_player_stats(pid, fps)['total_distance_m'])
1069
  for pid in team_players
1070
  ]
1071
  player_distances.sort(key=lambda x: x[1], reverse=True)
 
1073
 
1074
  individual_heatmaps = []
1075
  for pid in top_players[:6]:
1076
+ heatmap = create_player_heatmap_visualization(performance_tracker, pid, fps)
1077
  individual_heatmaps.append(heatmap)
1078
 
1079
  if len(individual_heatmaps) > 0:
 
1086
  rows.append(np.hstack([row_maps[0], row_maps[1]]))
1087
  else:
1088
  rows.append(row_maps[0])
 
1089
  individual_grid = np.vstack(rows) if len(rows) > 1 else rows[0]
1090
  individual_heatmaps_path = "/tmp/individual_heatmaps.png"
1091
  cv2.imwrite(individual_heatmaps_path, individual_grid)
1092
  else:
1093
  individual_heatmaps_path = None
1094
 
1095
+ # -----------------------------------
1096
+ # STEP 5: radar view
1097
+ # -----------------------------------
1098
+ progress(0.85, desc="๐Ÿ—บ๏ธ Creating game-style radar view (Step 6/7)...")
1099
  radar_path = "/tmp/radar_view_enhanced.png"
1100
  try:
1101
  if last_pitch_players_xy is not None:
 
1102
  radar_frame = create_game_style_radar(
1103
+ pitch_ball_xy=cleaned_path[-1] if cleaned_path else np.empty((0, 2)),
1104
  pitch_players_xy=last_pitch_players_xy,
1105
  players_class_id=last_players_class_id,
1106
+ pitch_referees_xy=last_pitch_referees_xy,
1107
  ball_path=cleaned_path
1108
  )
1109
  cv2.imwrite(radar_path, radar_frame)
 
1113
  print(f"โš ๏ธ Radar view creation failed: {e}")
1114
  radar_path = None
1115
 
1116
+ # -----------------------------------
1117
+ # STEP 6: summary + tabular stats + events
1118
+ # -----------------------------------
1119
+ progress(0.92, desc="๐Ÿ“ Building summary & tables (Step 7/7)...")
1120
 
 
1121
  summary_lines = ["โœ… **Analysis Complete!**\n"]
1122
  summary_lines.append("**Video Statistics:**")
1123
+ summary_lines.append(f"- Total Frames Processed: {frame_idx}")
1124
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1125
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1126
+ summary_lines.append(
1127
+ f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n"
1128
+ )
1129
 
1130
  teams = performance_tracker.get_all_players_by_team()
1131
  for team_id in [0, 1]:
1132
  if team_id not in teams:
1133
  continue
 
1134
  team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
1135
  summary_lines.append(f"\n**{team_name}:**")
1136
  summary_lines.append(f"- Players Tracked: {len(teams[team_id])}")
1137
 
1138
+ total_dist = sum(
1139
+ performance_tracker.get_player_stats(pid, fps)['total_distance_m']
1140
+ for pid in teams[team_id]
1141
+ )
1142
  avg_dist = total_dist / len(teams[team_id]) if len(teams[team_id]) > 0 else 0
1143
+ summary_lines.append(f"- Team Total Distance: {total_dist:.1f} m")
1144
+ summary_lines.append(f"- Average Distance per Player: {avg_dist:.1f} m")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1145
 
1146
  summary_lines.append("\n**Pipeline Steps Completed:**")
1147
+ summary_lines.append("โœ… 1. Team classifier training")
1148
+ summary_lines.append("โœ… 2. Video processing with tracking & events")
1149
+ summary_lines.append("โœ… 3. Ball trajectory cleaning")
1150
+ summary_lines.append("โœ… 4. Performance analytics")
1151
+ summary_lines.append("โœ… 5. Heatmaps & radar generation")
1152
+
 
1153
  summary_msg = "\n".join(summary_lines)
1154
 
1155
+ # ---------- player stats table for Gradio Dataframe ----------
1156
+ player_ids = sorted(performance_tracker.player_positions.keys())
1157
+ player_stats_rows: List[List[float]] = []
1158
+
1159
+ for pid in player_ids:
1160
+ stats_p = performance_tracker.get_player_stats(pid, fps)
1161
+ possession_s = possession_time_player_s.get(pid, 0.0)
1162
+ row = [
1163
+ int(pid),
1164
+ int(stats_p['team_id']),
1165
+ float(stats_p['total_distance_m']),
1166
+ float(stats_p['avg_speed_km_h']),
1167
+ float(stats_p['max_speed_km_h']),
1168
+ float(stats_p['time_in_defensive_third_s']),
1169
+ float(stats_p['time_in_middle_third_s']),
1170
+ float(stats_p['time_in_attacking_third_s']),
1171
+ float(possession_s),
1172
+ ]
1173
  player_stats_rows.append(row)
1174
 
1175
+ # ---------- events timeline text ----------
1176
+ if events:
1177
+ lines = []
1178
+ for ev in events:
1179
+ t = ev.get("time_s", 0.0)
1180
+ ev_type = ev.get("type", "")
1181
+ team_id = ev.get("team_id", None)
1182
+ from_id = ev.get("from_player_id", None)
1183
+ to_id = ev.get("to_player_id", None)
1184
+
1185
+ prefix = f"{t:6.2f}s | {ev_type.upper():<16}"
1186
+
1187
+ if ev_type == "pass":
1188
+ dist_m = ev.get("distance_m", 0.0)
1189
+ lines.append(
1190
+ f"{prefix} | Team {team_id} | #{from_id} โ†’ #{to_id} ({dist_m:.1f} m)"
1191
+ )
1192
+ elif ev_type in ("tackle", "interception"):
1193
+ lines.append(
1194
+ f"{prefix} | Team {team_id} | #{to_id} wins ball from #{from_id}"
1195
+ )
1196
+ elif ev_type in ("shot", "clearance"):
1197
+ speed_kmh = ev.get("speed_km_h", 0.0)
1198
+ lines.append(
1199
+ f"{prefix} | Team {team_id} | #{from_id} | {ev_type} at {speed_kmh:.1f} km/h"
1200
+ )
1201
+ elif ev_type == "possession_change":
1202
+ lines.append(
1203
+ f"{prefix} | Team {team_id} | Possession โ†’ #{to_id}"
1204
+ )
1205
+ else:
1206
+ lines.append(f"{prefix} | {ev}")
1207
+ events_text = "\n".join(lines)
1208
+ else:
1209
+ events_text = "No events detected."
1210
 
1211
+ # ---------- JSON file with events ----------
1212
  events_json_path = "/tmp/events.json"
1213
  with open(events_json_path, "w", encoding="utf-8") as f:
1214
+ json.dump(events, f, indent=2)
1215
 
1216
  progress(1.0, desc="โœ… Analysis Complete!")
1217
 
1218
  return (
1219
+ output_path,
1220
+ comparison_fig,
1221
+ team_heatmaps_path,
1222
  individual_heatmaps_path,
1223
  radar_path,
1224
+ summary_msg,
1225
+ player_stats_rows,
1226
+ events_text,
1227
+ events_json_path,
1228
  )
1229
 
1230
  except Exception as e:
 
 
1231
  import traceback
1232
  traceback.print_exc()
1233
+ error_msg = f"โŒ Error: {str(e)}"
1234
+ return (
1235
+ None, None, None, None, None,
1236
+ error_msg,
1237
+ [],
1238
+ "No events detected.",
1239
+ None,
1240
+ )
1241
 
1242
 
1243
  # ==============================================
 
1246
  with gr.Blocks(title="โšฝ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1247
  gr.Markdown("""
1248
  # โšฝ Advanced Football Video Analyzer
1249
+ ### Complete Pipeline Implementation
1250
+
1251
+ This application computes:
1252
+ - Player & team detection with Roboflow
1253
+ - Team classification using SigLIP
1254
+ - Persistent tracking with ByteTrack
1255
+ - Distances, speeds, and zone activity
1256
+ - Ball possession (per team & per player)
1257
+ - Events: passes, tackles, interceptions, shots, clearances, possession changes
1258
+ - Heatmaps and tactical radar view
1259
  """)
1260
 
1261
  with gr.Row():
 
1264
  analyze_btn = gr.Button("๐Ÿš€ Start Analysis Pipeline", variant="primary")
1265
 
1266
  with gr.Row():
1267
+ status_output = gr.Markdown(label="๐Ÿ“Š Analysis Summary & Statistics")
1268
 
1269
  with gr.Tabs():
1270
  with gr.Tab("๐Ÿ“น Annotated Video"):
1271
+ gr.Markdown("### Full video with tracking, events, and possession HUD")
1272
  video_output = gr.Video(label="Processed Video")
1273
 
1274
  with gr.Tab("๐Ÿ“Š Performance Comparison"):
 
1284
  individual_heatmaps_output = gr.Image(label="Top Players Heatmaps")
1285
 
1286
  with gr.Tab("๐ŸŽฎ Game Radar View"):
1287
+ gr.Markdown("### Game-style tactical view with ball trail")
1288
  radar_output = gr.Image(label="Tactical Radar View")
1289
 
1290
+ with gr.Tab("๐Ÿ“ˆ Player Stats & Events"):
1291
  gr.Markdown("### Per-player stats (distance, speed, zones, possession time)")
1292
+ player_stats_df = gr.Dataframe(
1293
  headers=[
1294
+ "player_id",
1295
+ "team_id",
1296
+ "total_distance_m",
1297
+ "avg_speed_km_h",
1298
+ "max_speed_km_h",
1299
+ "time_def_third_s",
1300
+ "time_mid_third_s",
1301
+ "time_att_third_s",
1302
+ "possession_time_s",
1303
  ],
1304
+ row_count=(0, "dynamic"),
1305
+ col_count=(9, "fixed"),
1306
+ label="Player Stats",
1307
  )
1308
 
1309
+ gr.Markdown(
1310
+ "### Detected events: passes, tackles, interceptions, shots, clearances, possession changes"
1311
+ )
1312
+ events_timeline = gr.Markdown(label="Event Timeline")
1313
 
1314
+ events_json_file = gr.File(label="Download events JSON")
 
 
1315
 
1316
  analyze_btn.click(
1317
  fn=analyze_football_video,
 
1323
  individual_heatmaps_output,
1324
  radar_output,
1325
  status_output,
1326
+ player_stats_df,
1327
+ events_timeline,
1328
+ events_json_file,
1329
+ ],
1330
  )
1331
 
1332
  if __name__ == "__main__":
1333
+ # `share=True` is not supported on HF Spaces, so keep default.
1334
  iface.launch()