Simon9 commited on
Commit
3eac17a
·
verified ·
1 Parent(s): c90836c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +288 -151
app.py CHANGED
@@ -4,6 +4,7 @@ from collections import deque, defaultdict
4
  from typing import List, Tuple, Dict, Optional, Union, Any
5
  from io import BytesIO
6
  import base64
 
7
 
8
  import cv2
9
  import numpy as np
@@ -26,7 +27,8 @@ from more_itertools import chunked
26
  from sklearn.cluster import KMeans
27
  import umap
28
 
29
- from inference_sdk import InferenceHTTPClient
 
30
 
31
  # ==============================================
32
  # ENVIRONMENT VARIABLES
@@ -40,29 +42,93 @@ 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
- # Distance units: pitch coordinates are effectively in centimeters
44
- CM_PER_METER = 100.0
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  # ==============================================
47
- # ROBOFLOW INFERENCE CLIENT
48
  # ==============================================
49
- CLIENT = InferenceHTTPClient(
50
- api_url="https://detect.roboflow.com",
51
- api_key=ROBOFLOW_API_KEY
52
- )
53
 
54
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
55
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
- def infer_with_confidence(model_id: str, frame: np.ndarray, confidence_threshold: float = 0.3):
59
- """Run inference and filter by confidence threshold"""
60
- result = CLIENT.infer(frame, model_id=model_id)
61
- detections = sv.Detections.from_inference(result)
62
- if len(detections) > 0:
63
- detections = detections[detections.confidence > confidence_threshold]
64
- return result, detections
65
 
 
 
66
 
67
  # ==============================================
68
  # SIGLIP MODEL (Embeddings)
@@ -72,9 +138,22 @@ EMBEDDINGS_MODEL = SiglipVisionModel.from_pretrained(SIGLIP_MODEL_PATH, token=HF
72
  EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
73
 
74
  # ==============================================
75
- # TEAM CONFIG
76
  # ==============================================
77
- CONFIG = SoccerPitchConfiguration()
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
 
80
  # ==============================================
@@ -82,9 +161,12 @@ CONFIG = SoccerPitchConfiguration()
82
  # ==============================================
83
  def replace_outliers_based_on_distance(
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
 
@@ -96,8 +178,11 @@ def replace_outliers_based_on_distance(
96
  cleaned_positions.append(position)
97
  last_valid_position = position
98
  else:
99
- distance = np.linalg.norm(position - last_valid_position)
100
- if distance > distance_threshold:
 
 
 
101
  cleaned_positions.append(np.array([], dtype=np.float64))
102
  else:
103
  cleaned_positions.append(position)
@@ -110,85 +195,77 @@ def replace_outliers_based_on_distance(
110
  # PLAYER PERFORMANCE TRACKING
111
  # ==============================================
112
  class PlayerPerformanceTracker:
113
- """Track individual player performance metrics and generate heatmaps"""
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
 
@@ -264,6 +341,53 @@ class PlayerTrackingManager:
264
  self.active_trackers = set()
265
 
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  # ==============================================
268
  # VISUALIZATION FUNCTIONS
269
  # ==============================================
@@ -527,12 +651,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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,
@@ -542,11 +661,9 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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)
@@ -608,7 +725,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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:
@@ -645,7 +762,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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] = {}
@@ -657,19 +774,17 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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):
@@ -693,7 +808,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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)))
@@ -738,27 +853,27 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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)
743
 
744
  filter_mask = key_points.confidence[0] > 0.5
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])
@@ -773,44 +888,47 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
@@ -825,18 +943,16 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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",
@@ -845,23 +961,20 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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"
@@ -881,7 +994,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
  {
@@ -896,20 +1009,24 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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(
@@ -937,7 +1054,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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()
@@ -947,14 +1064,13 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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]
@@ -1043,12 +1159,21 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
 
@@ -1093,7 +1218,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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"
@@ -1114,7 +1239,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
 
@@ -1123,6 +1248,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
  )
@@ -1147,8 +1273,14 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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
 
@@ -1246,16 +1378,22 @@ def analyze_football_video(video_path: str, progress=gr.Progress()
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():
@@ -1330,5 +1468,4 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
1330
  )
1331
 
1332
  if __name__ == "__main__":
1333
- # `share=True` is not supported on HF Spaces, so keep default.
1334
- iface.launch()
 
4
  from typing import List, Tuple, Dict, Optional, Union, Any
5
  from io import BytesIO
6
  import base64
7
+ import time
8
 
9
  import cv2
10
  import numpy as np
 
27
  from sklearn.cluster import KMeans
28
  import umap
29
 
30
+ from inference import get_model
31
+ from inference_sdk.http.errors import HTTPCallErrorError
32
 
33
  # ==============================================
34
  # ENVIRONMENT VARIABLES
 
42
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
43
  print(f"🖥️ Using device: {DEVICE}")
44
 
45
+ # ==============================================
46
+ # REAL-WORLD PITCH DIMENSIONS
47
+ # ==============================================
48
+ CONFIG = SoccerPitchConfiguration()
49
+
50
+ # Standard football pitch dimensions in meters
51
+ PITCH_LENGTH_M = 105.0 # meters (standard: 100-110m)
52
+ PITCH_WIDTH_M = 68.0 # meters (standard: 64-75m)
53
+
54
+ # Calculate scaling factors from config units to meters
55
+ SCALE_X = PITCH_LENGTH_M / CONFIG.length
56
+ SCALE_Y = PITCH_WIDTH_M / CONFIG.width
57
+
58
+ print(f"📏 Pitch config units - Length: {CONFIG.length}, Width: {CONFIG.width}")
59
+ print(f"📏 Scale factors - X: {SCALE_X:.6f} m/unit, Y: {SCALE_Y:.6f} m/unit")
60
 
61
  # ==============================================
62
+ # MODEL INITIALIZATION
63
  # ==============================================
64
+ PLAYER_DETECTION_MODEL = None
65
+ FIELD_DETECTION_MODEL = None
 
 
66
 
67
  PLAYER_DETECTION_MODEL_ID = "football-players-detection-3zvbc/11"
68
  FIELD_DETECTION_MODEL_ID = "football-field-detection-f07vi/14"
69
 
70
+ # IDs from Roboflow model
71
+ BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
72
+
73
+
74
+ def initialize_models():
75
+ """Initialize detection models with local inference (more reliable than HTTP API)"""
76
+ global PLAYER_DETECTION_MODEL, FIELD_DETECTION_MODEL
77
+
78
+ try:
79
+ print("📦 Loading detection models locally...")
80
+ PLAYER_DETECTION_MODEL = get_model(
81
+ model_id=PLAYER_DETECTION_MODEL_ID,
82
+ api_key=ROBOFLOW_API_KEY
83
+ )
84
+ FIELD_DETECTION_MODEL = get_model(
85
+ model_id=FIELD_DETECTION_MODEL_ID,
86
+ api_key=ROBOFLOW_API_KEY
87
+ )
88
+ print("✅ Models loaded successfully")
89
+ except Exception as e:
90
+ print(f"❌ Failed to load models: {e}")
91
+ raise
92
+
93
+
94
+ def infer_with_confidence(
95
+ model,
96
+ frame: np.ndarray,
97
+ confidence_threshold: float = 0.3,
98
+ max_retries: int = 3
99
+ ):
100
+ """
101
+ Run inference with retry logic for transient errors.
102
+
103
+ Args:
104
+ model: The detection model to use
105
+ frame: Input frame
106
+ confidence_threshold: Confidence threshold for detections
107
+ max_retries: Maximum number of retry attempts
108
+
109
+ Returns:
110
+ Tuple of (result, detections)
111
+ """
112
+ for attempt in range(max_retries):
113
+ try:
114
+ result = model.infer(frame, confidence=confidence_threshold)[0]
115
+ detections = sv.Detections.from_inference(result)
116
+ if len(detections) > 0:
117
+ detections = detections[detections.confidence > confidence_threshold]
118
+ return result, detections
119
+ except Exception as e:
120
+ if attempt < max_retries - 1:
121
+ delay = 2 ** attempt # exponential backoff: 1s, 2s, 4s
122
+ print(f"⚠️ Inference failed (attempt {attempt + 1}/{max_retries}), retrying in {delay}s...")
123
+ time.sleep(delay)
124
+ else:
125
+ print(f"❌ All inference attempts failed: {e}")
126
+ # Return empty detections to continue processing
127
+ return None, sv.Detections.empty()
128
 
 
 
 
 
 
 
 
129
 
130
+ # Initialize models at startup
131
+ initialize_models()
132
 
133
  # ==============================================
134
  # SIGLIP MODEL (Embeddings)
 
138
  EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF_TOKEN)
139
 
140
  # ==============================================
141
+ # DISTANCE CALCULATION UTILITIES
142
  # ==============================================
143
+ def calculate_real_distance(pos1: np.ndarray, pos2: np.ndarray) -> float:
144
+ """
145
+ Calculate real-world distance in meters between two pitch positions.
146
+
147
+ Args:
148
+ pos1, pos2: positions in pitch coordinate units [x, y]
149
+
150
+ Returns:
151
+ distance in meters
152
+ """
153
+ dx = (pos2[0] - pos1[0]) * SCALE_X
154
+ dy = (pos2[1] - pos1[1]) * SCALE_Y
155
+ distance_m = np.sqrt(dx**2 + dy**2)
156
+ return float(distance_m)
157
 
158
 
159
  # ==============================================
 
161
  # ==============================================
162
  def replace_outliers_based_on_distance(
163
  positions: List[np.ndarray],
164
+ distance_threshold_m: float = 50.0 # 50 meters is realistic max between frames
165
  ) -> List[np.ndarray]:
166
+ """
167
+ Remove outlier positions based on real-world distance threshold in meters.
168
+ Ball can't travel more than ~50m between frames at normal frame rates.
169
+ """
170
  last_valid_position: Union[np.ndarray, None] = None
171
  cleaned_positions: List[np.ndarray] = []
172
 
 
178
  cleaned_positions.append(position)
179
  last_valid_position = position
180
  else:
181
+ # Calculate real distance in meters
182
+ distance_m = calculate_real_distance(last_valid_position, position)
183
+
184
+ if distance_m > distance_threshold_m:
185
+ # Outlier detected - mark as invalid
186
  cleaned_positions.append(np.array([], dtype=np.float64))
187
  else:
188
  cleaned_positions.append(position)
 
195
  # PLAYER PERFORMANCE TRACKING
196
  # ==============================================
197
  class PlayerPerformanceTracker:
198
+ """Track individual player performance metrics with correct real-world scaling"""
199
 
200
  def __init__(self, pitch_config):
201
  self.config = pitch_config
202
+ self.player_positions = defaultdict(list) # (x, y, frame) in config units
203
+ self.player_velocities = defaultdict(list) # m/s
204
+ self.player_distances_m = defaultdict(float)
205
  self.player_team = {}
206
  self.player_stats = defaultdict(lambda: {
207
  'frames_visible': 0,
208
+ 'avg_velocity_m_s': 0.0,
209
+ 'max_velocity_m_s': 0.0,
210
  'time_in_attacking_third_frames': 0,
211
  'time_in_defensive_third_frames': 0,
212
  'time_in_middle_third_frames': 0
213
  })
214
 
215
+ def update(self, tracker_id: int, position: np.ndarray, team_id: int, frame: int, fps: float):
216
+ """Update player position and calculate metrics in real meters."""
217
+ if len(position) != 2:
218
  return
219
 
220
  self.player_team[tracker_id] = team_id
221
+ self.player_positions[tracker_id].append((position[0], position[1], frame))
222
  self.player_stats[tracker_id]['frames_visible'] += 1
223
 
224
  if len(self.player_positions[tracker_id]) > 1:
225
  prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
226
+ curr_pos = np.array(position)
227
+
228
+ # Calculate REAL distance in meters
229
+ distance_m = calculate_real_distance(prev_pos, curr_pos)
230
+ self.player_distances_m[tracker_id] += distance_m
231
 
232
+ # Calculate velocity in m/s
233
  dt = 1.0 / fps
234
+ velocity_m_s = distance_m / dt
235
+ self.player_velocities[tracker_id].append(velocity_m_s)
236
 
237
+ if velocity_m_s > self.player_stats[tracker_id]['max_velocity_m_s']:
238
+ self.player_stats[tracker_id]['max_velocity_m_s'] = velocity_m_s
239
 
240
+ # Zone calculation (thirds of pitch)
241
+ pitch_length = self.config.length
242
+ x = position[0]
243
+ if x < pitch_length / 3:
244
  self.player_stats[tracker_id]['time_in_defensive_third_frames'] += 1
245
+ elif x < 2 * pitch_length / 3:
246
  self.player_stats[tracker_id]['time_in_middle_third_frames'] += 1
247
  else:
248
  self.player_stats[tracker_id]['time_in_attacking_third_frames'] += 1
249
 
250
  def get_player_stats(self, tracker_id: int, fps: float) -> dict:
251
+ """Get comprehensive stats for a player in real-world units."""
252
  stats = self.player_stats[tracker_id].copy()
253
 
254
  if len(self.player_velocities[tracker_id]) > 0:
255
+ stats['avg_velocity_m_s'] = float(np.mean(self.player_velocities[tracker_id]))
256
 
257
+ # Total distance is already in meters
258
+ stats['total_distance_m'] = self.player_distances_m[tracker_id]
 
 
259
  stats['team_id'] = self.player_team.get(tracker_id, -1)
260
 
261
+ # Convert frames to seconds
262
+ stats['time_in_defensive_third_s'] = stats['time_in_defensive_third_frames'] / fps
263
+ stats['time_in_middle_third_s'] = stats['time_in_middle_third_frames'] / fps
264
+ stats['time_in_attacking_third_s'] = stats['time_in_attacking_third_frames'] / fps
 
 
 
 
 
 
265
 
266
+ # Convert m/s to km/h for display
267
+ stats['avg_speed_km_h'] = stats['avg_velocity_m_s'] * 3.6
268
+ stats['max_speed_km_h'] = stats['max_velocity_m_s'] * 3.6
 
 
 
 
269
 
270
  return stats
271
 
 
341
  self.active_trackers = set()
342
 
343
 
344
+ # ==============================================
345
+ # VALIDATION UTILITIES
346
+ # ==============================================
347
+ def validate_player_stats(performance_tracker: PlayerPerformanceTracker, fps: float, total_frames: int) -> List[str]:
348
+ """
349
+ Validate that player statistics are realistic.
350
+ Returns warnings for unrealistic values.
351
+ """
352
+ warnings = []
353
+
354
+ # Calculate clip duration
355
+ match_duration_minutes = (total_frames / fps) / 60.0
356
+
357
+ # Professional player typically covers 9-13 km in a 90-minute match
358
+ # Scale proportionally for shorter clips
359
+ expected_max_distance = 13.0 * (match_duration_minutes / 90.0) * 1000 # in meters
360
+
361
+ for tracker_id in performance_tracker.player_positions.keys():
362
+ stats = performance_tracker.get_player_stats(tracker_id, fps)
363
+
364
+ distance = stats['total_distance_m']
365
+ max_speed_kmh = stats['max_speed_km_h']
366
+ avg_speed_kmh = stats['avg_speed_km_h']
367
+
368
+ if distance > expected_max_distance * 1.5:
369
+ warnings.append(
370
+ f"⚠️ Player #{tracker_id}: Distance {distance:.1f}m seems high "
371
+ f"(expected max ~{expected_max_distance:.1f}m for {match_duration_minutes:.1f} min)"
372
+ )
373
+
374
+ # Professional players rarely exceed 37 km/h
375
+ if max_speed_kmh > 40:
376
+ warnings.append(
377
+ f"⚠️ Player #{tracker_id}: Max speed {max_speed_kmh:.1f} km/h seems unrealistic "
378
+ f"(typical max is 30-37 km/h)"
379
+ )
380
+
381
+ # Average speed during active play is typically 5-8 km/h
382
+ if avg_speed_kmh > 15:
383
+ warnings.append(
384
+ f"⚠️ Player #{tracker_id}: Avg speed {avg_speed_kmh:.1f} km/h seems too high "
385
+ f"(typical average is 5-8 km/h)"
386
+ )
387
+
388
+ return warnings
389
+
390
+
391
  # ==============================================
392
  # VISUALIZATION FUNCTIONS
393
  # ==============================================
 
651
  Optional[str]
652
  ]:
653
  """
654
+ Complete football analysis pipeline with proper distance/speed calculations
 
 
 
 
 
655
  """
656
  if not video_path:
657
  return (None, None, None, None, None,
 
661
  try:
662
  progress(0, desc="🔧 Initializing...")
663
 
 
 
664
  STRIDE = 30
665
  MAXLEN = 5
666
+ MAX_DISTANCE_THRESHOLD_M = 50.0 # realistic max ball travel between frames
667
 
668
  # Managers
669
  tracking_manager = PlayerTrackingManager(max_history=10)
 
725
  if not ret:
726
  break
727
  if frame_idx % STRIDE == 0:
728
+ _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL, frame, 0.3)
729
  detections = detections.with_nms(threshold=0.5, class_agnostic=True)
730
  players_detections = detections[detections.class_id == PLAYER_ID]
731
  if len(players_detections.xyxy) > 0:
 
762
 
763
  # stats for events / possession
764
  dt = 1.0 / fps
765
+ distance_covered_per_player_m = defaultdict(float)
766
  possession_time_player_s = defaultdict(float)
767
  possession_time_team_s = defaultdict(float)
768
  team_of_player: Dict[int, int] = {}
 
774
  EVENT_TEXT_DURATION_FRAMES = int(2.0 * fps)
775
 
776
  prev_owner_tid: Optional[int] = None
777
+ prev_ball_pos_pitch: Optional[np.ndarray] = None
778
 
779
+ # approximate goal centers in pitch coords
780
  goal_centers = {
781
  0: np.array([0.0, CONFIG.width / 2.0]),
782
  1: np.array([CONFIG.length, CONFIG.width / 2.0]),
783
  }
784
 
785
+ # thresholds
786
  POSSESSION_RADIUS_M = 5.0
 
787
  MIN_PASS_TRAVEL_M = 3.0
 
788
  HIGH_SHOT_SPEED_KM_H = 18.0
789
 
790
  def register_event(ev: Dict[str, Any], text: str):
 
808
  desc=f"🎬 Processing frame {frame_idx}/{total_frames}")
809
 
810
  # --- detections ---
811
+ _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL, frame, 0.3)
812
  if len(detections.xyxy) == 0:
813
  out.write(frame)
814
  ball_path_raw.append(np.empty((0, 2)))
 
853
 
854
  # --- field homography ---
855
  try:
856
+ result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL, frame, 0.3)
857
  key_points = sv.KeyPoints.from_inference(result_field)
858
 
859
  filter_mask = key_points.confidence[0] > 0.5
860
  frame_ref_pts = key_points.xy[0][filter_mask]
861
  pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
862
 
863
+ frame_ball_pos_pitch = None
864
+ frame_players_xy_pitch = None
865
 
866
  if len(frame_ref_pts) >= 4:
867
  transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
868
  M.append(transformer.m)
869
  transformer.m = np.mean(np.array(M), axis=0)
870
 
871
+ # ball position in pitch coords
872
  frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
873
  pitch_ball_xy = transformer.transform_points(frame_ball_xy)
874
  ball_path_raw.append(pitch_ball_xy)
875
  if len(pitch_ball_xy) > 0:
876
+ frame_ball_pos_pitch = pitch_ball_xy[0]
877
 
878
  # all players (incl. keepers)
879
  all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
 
888
  last_players_class_id = all_players.class_id
889
  last_pitch_referees_xy = pitch_referees_xy
890
 
891
+ frame_players_xy_pitch = pitch_players_xy
892
 
893
+ # update performance tracker with REAL distance calculations
894
  for idx, tracker_id in enumerate(all_players.tracker_id):
895
  tid_int = int(tracker_id)
896
  team_id = int(all_players.class_id[idx])
897
+ pos = pitch_players_xy[idx]
898
  performance_tracker.update(
899
+ tid_int, pos, team_id, frame_idx, fps
900
  )
901
 
902
+ # distance for HUD
903
+ prev_pos_list = performance_tracker.player_positions[tid_int]
904
+ if len(prev_pos_list) > 1:
905
+ prev_pos = np.array(prev_pos_list[-2][:2])
906
+ curr_pos = np.array(prev_pos_list[-1][:2])
907
+ dist_m = calculate_real_distance(prev_pos, curr_pos)
 
908
  distance_covered_per_player_m[tid_int] += dist_m
909
 
910
  team_of_player[tid_int] = team_id
911
  else:
912
  ball_path_raw.append(np.empty((0, 2)))
913
+ frame_ball_pos_pitch = None
914
+ frame_players_xy_pitch = None
915
  except Exception:
916
  ball_path_raw.append(np.empty((0, 2)))
917
+ frame_ball_pos_pitch = None
918
+ frame_players_xy_pitch = None
919
 
920
  # --- possession owner ---
921
  owner_tid: Optional[int] = None
922
+ if frame_ball_pos_pitch is not None and frame_players_xy_pitch is not None:
923
+ # Calculate distances in REAL meters
924
+ dists_m = []
925
+ for player_pos in frame_players_xy_pitch:
926
+ dist = calculate_real_distance(frame_ball_pos_pitch, player_pos)
927
+ dists_m.append(dist)
928
+ dists_m = np.array(dists_m)
929
+
930
+ j = int(np.argmin(dists_m))
931
+ if dists_m[j] < POSSESSION_RADIUS_M:
932
  owner_tid = int(all_players.tracker_id[j])
933
 
934
  # accumulate possession time
 
943
 
944
  if owner_tid != prev_owner_tid:
945
  if owner_tid is not None and prev_owner_tid is not None \
946
+ and frame_ball_pos_pitch is not None and prev_ball_pos_pitch is not None:
947
+ # ball travel in REAL meters
948
+ travel_m = calculate_real_distance(prev_ball_pos_pitch, frame_ball_pos_pitch)
949
+
 
950
  prev_team = team_of_player.get(prev_owner_tid)
951
  cur_team = team_of_player.get(owner_tid)
952
 
953
  if prev_team is not None and cur_team is not None:
954
+ if prev_team == cur_team and travel_m > MIN_PASS_TRAVEL_M:
955
  # pass
 
956
  register_event(
957
  {
958
  "type": "pass",
 
961
  "from_player_id": int(prev_owner_tid),
962
  "to_player_id": int(owner_tid),
963
  "team_id": int(cur_team),
964
+ "distance_m": travel_m,
965
  },
966
+ f"Pass: #{prev_owner_tid} → #{owner_tid} (Team {cur_team}, {travel_m:.1f} m)"
967
  )
968
  elif prev_team != cur_team:
969
  # tackle vs interception based on player distance
970
  d_pp_m = None
971
+ prev_pos_list = performance_tracker.player_positions.get(int(prev_owner_tid))
972
+ cur_pos_list = performance_tracker.player_positions.get(int(owner_tid))
973
+
974
+ if prev_pos_list and cur_pos_list and len(prev_pos_list) > 0 and len(cur_pos_list) > 0:
975
+ pos_prev = np.array(prev_pos_list[-1][:2])
976
+ pos_cur = np.array(cur_pos_list[-1][:2])
977
+ d_pp_m = calculate_real_distance(pos_prev, pos_cur)
 
 
 
978
 
979
  ev_type = "tackle"
980
  label = "Tackle"
 
994
  f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}"
995
  )
996
 
997
+ # explicit possession change event
998
  if owner_tid is not None:
999
  register_event(
1000
  {
 
1009
  )
1010
 
1011
  # shot / clearance
1012
+ if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None \
1013
  and owner_tid is not None:
1014
+ # Calculate velocity in REAL m/s
1015
+ v = (frame_ball_pos_pitch - prev_ball_pos_pitch)
1016
+ v_scaled = np.array([v[0] * SCALE_X, v[1] * SCALE_Y])
1017
+ speed_m_s = float(np.linalg.norm(v_scaled)) / dt
1018
  speed_km_h = speed_m_s * 3.6
1019
+
1020
  if speed_km_h > HIGH_SHOT_SPEED_KM_H:
1021
  shooter_team = team_of_player.get(owner_tid)
1022
  if shooter_team is not None:
1023
  target_goal = goal_centers[1 - shooter_team]
1024
+ direction = target_goal - frame_ball_pos_pitch
1025
+ direction_scaled = np.array([direction[0] * SCALE_X, direction[1] * SCALE_Y])
1026
+
1027
  cos_angle = float(
1028
+ np.dot(v_scaled, direction_scaled) /
1029
+ (np.linalg.norm(v_scaled) * np.linalg.norm(direction_scaled) + 1e-6)
1030
  )
1031
  if cos_angle > 0.8:
1032
  register_event(
 
1054
  )
1055
 
1056
  prev_owner_tid = owner_tid
1057
+ prev_ball_pos_pitch = frame_ball_pos_pitch
1058
 
1059
  # --- draw frame ---
1060
  annotated_frame = frame.copy()
 
1064
  if last_pitch_players_xy is not None and len(players_detections) > 0:
1065
  for idx, tid in enumerate(players_detections.tracker_id):
1066
  tid_int = int(tid)
1067
+ # estimate instantaneous speed from last two positions
1068
  pos_list = performance_tracker.player_positions[tid_int]
1069
  speed_km_h = 0.0
1070
  if len(pos_list) >= 2:
1071
+ prev = np.array(pos_list[-2][:2])
1072
+ curr = np.array(pos_list[-1][:2])
1073
+ dist_m = calculate_real_distance(prev, curr)
 
1074
  speed_km_h = (dist_m / dt) * 3.6
1075
 
1076
  d_total_m = distance_covered_per_player_m[tid_int]
 
1159
  cleaned_path = replace_outliers_based_on_distance(
1160
  [np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2))
1161
  for p in path_for_cleaning],
1162
+ MAX_DISTANCE_THRESHOLD_M
1163
  )
1164
  print(f"✅ Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
1165
 
1166
  # -----------------------------------
1167
+ # STEP 4: Validate stats
1168
+ # -----------------------------------
1169
+ warnings = validate_player_stats(performance_tracker, fps, frame_idx)
1170
+ if warnings:
1171
+ print("\n⚠️ VALIDATION WARNINGS:")
1172
+ for warning in warnings:
1173
+ print(warning)
1174
+
1175
+ # -----------------------------------
1176
+ # STEP 5: performance analytics
1177
  # -----------------------------------
1178
  progress(0.70, desc="📊 Generating performance analytics (Step 5/7)...")
1179
 
 
1218
  individual_heatmaps_path = None
1219
 
1220
  # -----------------------------------
1221
+ # STEP 6: radar view
1222
  # -----------------------------------
1223
  progress(0.85, desc="🗺️ Creating game-style radar view (Step 6/7)...")
1224
  radar_path = "/tmp/radar_view_enhanced.png"
 
1239
  radar_path = None
1240
 
1241
  # -----------------------------------
1242
+ # STEP 7: summary + tabular stats + events
1243
  # -----------------------------------
1244
  progress(0.92, desc="📝 Building summary & tables (Step 7/7)...")
1245
 
 
1248
  summary_lines.append(f"- Total Frames Processed: {frame_idx}")
1249
  summary_lines.append(f"- Video Resolution: {width}x{height}")
1250
  summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1251
+ summary_lines.append(f"- Duration: {frame_idx/fps:.1f} seconds")
1252
  summary_lines.append(
1253
  f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n"
1254
  )
 
1273
  summary_lines.append("✅ 1. Team classifier training")
1274
  summary_lines.append("✅ 2. Video processing with tracking & events")
1275
  summary_lines.append("✅ 3. Ball trajectory cleaning")
1276
+ summary_lines.append("✅ 4. Distance/speed validation")
1277
+ summary_lines.append("✅ 5. Performance analytics")
1278
+ summary_lines.append("✅ 6. Heatmaps & radar generation")
1279
+
1280
+ if warnings:
1281
+ summary_lines.append("\n⚠️ **Validation Warnings:**")
1282
+ for warning in warnings[:5]: # Show first 5 warnings
1283
+ summary_lines.append(f"- {warning}")
1284
 
1285
  summary_msg = "\n".join(summary_lines)
1286
 
 
1378
  with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
1379
  gr.Markdown("""
1380
  # ⚽ Advanced Football Video Analyzer
1381
+ ### Complete Pipeline with Accurate Distance & Speed Tracking
1382
 
1383
  This application computes:
1384
  - Player & team detection with Roboflow
1385
  - Team classification using SigLIP
1386
  - Persistent tracking with ByteTrack
1387
+ - **Realistic distances and speeds** (proper pitch scaling)
1388
  - Ball possession (per team & per player)
1389
  - Events: passes, tackles, interceptions, shots, clearances, possession changes
1390
  - Heatmaps and tactical radar view
1391
+ - **Validation warnings** for unrealistic statistics
1392
+
1393
+ **Expected realistic values:**
1394
+ - Distance covered: 800-1200m per 10 minutes
1395
+ - Average speed: 5-8 km/h (during active play)
1396
+ - Max speed: 20-35 km/h (sprinting)
1397
  """)
1398
 
1399
  with gr.Row():
 
1468
  )
1469
 
1470
  if __name__ == "__main__":
1471
+ iface.launch()