Simon9 commited on
Commit
ac8b9c8
Β·
verified Β·
1 Parent(s): 1b07467

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +914 -188
app.py CHANGED
@@ -593,184 +593,911 @@ def create_game_style_radar(
593
  # ==============================================
594
  def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
595
  """
596
- Complete football analysis pipeline - FULL IMPLEMENTATION
597
- (This is your existing function - keeping it exactly as is)
 
 
 
 
 
 
598
  """
599
- # ... [Keep your entire existing analyze_football_video function here]
600
- # I'm not repeating it to save space, but include ALL the code from your original function
601
- pass # Replace this with your full function
602
-
603
-
604
- # ==============================================
605
- # API WRAPPER FOR REMOTE ACCESS
606
- # ==============================================
607
- def process_video_api(video_file):
608
- """
609
- API endpoint for processing videos from remote clients.
610
-
611
- This wrapper:
612
- 1. Accepts video file from gradio_client
613
- 2. Runs the full analysis pipeline
614
- 3. Returns results in a format suitable for JSON serialization
615
-
616
- Parameters:
617
- ----------
618
- video_file : str or dict
619
- Path to uploaded video file (from gr.Video component)
620
-
621
- Returns:
622
- -------
623
- tuple: (
624
- annotated_video_path: str,
625
- stats_json: str,
626
- events_json: str,
627
- artifacts_json: str
628
- )
629
- """
630
- print("=" * 80)
631
- print("🎬 API Request Received")
632
- print("=" * 80)
633
-
634
- if video_file is None:
635
- error_result = {
636
- "error": "No video file provided",
637
- "success": False
638
- }
639
- return None, json.dumps(error_result), json.dumps([]), json.dumps({})
640
-
641
- # Handle video path extraction
642
- if isinstance(video_file, dict):
643
- video_path = video_file.get("path") or video_file.get("name") or video_file.get("filename")
644
- else:
645
- video_path = str(video_file)
646
-
647
  if not video_path:
648
- error_result = {
649
- "error": "Could not resolve video file path",
650
- "success": False
651
- }
652
- return None, json.dumps(error_result), json.dumps([]), json.dumps({})
653
-
654
- print(f"πŸ“ Video path: {video_path}")
655
-
 
 
 
 
656
  try:
657
- # Run the full analysis pipeline
658
- print("βš™οΈ Running analysis pipeline...")
659
- results = analyze_football_video(video_path)
660
-
661
- # Unpack results (9 outputs from analyze_football_video)
662
- (
663
- annotated_video,
664
- comparison_fig,
665
- team_heatmaps_path,
666
- individual_heatmaps_path,
667
- radar_path,
668
- summary_msg,
669
- player_stats_table,
670
- events_table,
671
- events_json_path,
672
- ) = results
673
-
674
- # Build stats dictionary
675
- stats = {
676
- "success": True,
677
- "summary": summary_msg,
678
- "player_stats": player_stats_table,
679
- "comparison_plot_available": comparison_fig is not None,
680
- }
681
-
682
- # Build artifacts dictionary with all generated file paths
683
- artifacts = {
684
- "team_heatmaps": team_heatmaps_path,
685
- "individual_heatmaps": individual_heatmaps_path,
686
- "radar_view": radar_path,
687
- "events_json": events_json_path,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  }
689
-
690
- # Convert to JSON strings
691
- stats_json = json.dumps(stats, indent=2)
692
- events_json = json.dumps(events_table, indent=2)
693
- artifacts_json = json.dumps(artifacts, indent=2)
694
-
695
- print("βœ… Analysis complete!")
696
- print(f"πŸ“Š Stats: {len(player_stats_table)} players tracked")
697
- print(f"⚑ Events: {len(events_table)} events detected")
698
- print(f"🎬 Annotated video: {annotated_video}")
699
- print("=" * 80)
700
-
701
- return annotated_video, stats_json, events_json, artifacts_json
702
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
  except Exception as e:
 
 
704
  import traceback
705
- error_details = traceback.format_exc()
706
-
707
- print(f"❌ Error during analysis: {e}")
708
- print(f"πŸ“‹ Traceback:\n{error_details}")
709
- print("=" * 80)
710
-
711
- error_result = {
712
- "error": str(e),
713
- "traceback": error_details,
714
- "success": False
715
- }
716
-
717
- return None, json.dumps(error_result), json.dumps([]), json.dumps({})
 
718
 
719
 
720
  # ==============================================
721
- # GRADIO INTERFACE - MAIN WEB UI
722
  # ==============================================
 
723
  def run_pipeline(video) -> Tuple:
724
  """
725
- Gradio wrapper for web UI: accept the raw video object from gr.Video
 
726
  """
727
  if video is None:
728
  return (
729
- None, None, None, None, None,
 
 
 
 
730
  "❌ Please upload a video file.",
731
- [], [], None,
 
 
732
  )
733
 
 
734
  if isinstance(video, dict):
735
- video_path = video.get("path") or video.get("name") or video.get("filename")
 
 
 
 
736
  else:
 
737
  video_path = str(video)
738
 
739
  if not video_path:
740
  return (
741
- None, None, None, None, None,
 
 
 
 
742
  "❌ Could not resolve video file path from upload.",
743
- [], [], None,
 
 
744
  )
745
 
746
  return analyze_football_video(video_path)
747
 
748
 
749
- # ==============================================
750
- # CREATE GRADIO INTERFACES
751
- # ==============================================
752
-
753
- # Main web interface (for direct Space access)
754
- with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()) as web_interface:
755
  gr.Markdown(
756
  """
757
  # ⚽ Advanced Football Video Analyzer
758
  ### Complete Pipeline Implementation
759
 
760
- This application provides:
761
- 1. **Player Detection** - Roboflow models for player, ball, and referee detection
762
- 2. **Team Classification** - SigLIP-based team classifier with stable assignments
763
- 3. **Persistent Tracking** - ByteTrack with ID stability
764
- 4. **Field Transformation** - Homography projection to pitch coordinates
765
- 5. **Ball Trajectory** - Path tracking with outlier removal
766
- 6. **Performance Analytics** - Heatmaps, stats, possession, events
767
 
768
  Upload a football match video to get comprehensive performance analytics!
769
  """
770
  )
771
 
772
  with gr.Row():
773
- video_input = gr.File(label="Upload Football Video", file_types=["video"])
 
774
 
775
  analyze_btn = gr.Button("πŸš€ Start Analysis Pipeline", variant="primary", size="lg")
776
 
@@ -779,11 +1506,13 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
779
 
780
  with gr.Tabs():
781
  with gr.Tab("πŸ“Ή Annotated Video"):
782
- gr.Markdown("### Full video with tracking, teams, ball detection, and events")
 
 
783
  video_output = gr.Video(label="Processed Video")
784
 
785
  with gr.Tab("πŸ“Š Performance Comparison"):
786
- gr.Markdown("### Interactive charts comparing player performance")
787
  comparison_output = gr.Plot(label="Team Performance Metrics")
788
 
789
  with gr.Tab("πŸ—ΊοΈ Team Heatmaps"):
@@ -799,7 +1528,7 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
799
  radar_output = gr.Image(label="Tactical Radar View")
800
 
801
  with gr.Tab("πŸ“‹ Player Stats"):
802
- gr.Markdown("### Per-player: distance, speeds, zones, possession")
803
  player_stats_output = gr.Dataframe(
804
  headers=PLAYER_STATS_HEADERS,
805
  col_count=len(PLAYER_STATS_HEADERS),
@@ -808,72 +1537,69 @@ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()
808
  )
809
 
810
  with gr.Tab("⏱️ Event Timeline"):
811
- gr.Markdown("### Passes, tackles, interceptions, shots, clearances")
 
 
812
  events_output = gr.Dataframe(
813
  headers=EVENT_HEADERS,
814
  col_count=len(EVENT_HEADERS),
815
  row_count=0,
816
  interactive=False,
817
  )
818
- events_json_output = gr.File(label="Download events JSON", file_types=[".json"])
 
 
 
819
 
820
  analyze_btn.click(
821
  fn=run_pipeline,
822
  inputs=[video_input],
823
  outputs=[
824
- video_output,
825
- comparison_output,
826
- team_heatmaps_output,
827
- individual_heatmaps_output,
828
- radar_output,
829
- status_output,
830
- player_stats_output,
831
- events_output,
832
- events_json_output,
833
  ],
834
  )
835
 
836
- # API interface (for programmatic access via gradio_client)
837
- with gr.Blocks(title="⚽ AfriGoals API") as api_interface:
838
- gr.Markdown("# ⚽ AfriGoals - Football Video Analysis API")
839
- gr.Markdown("This interface is for programmatic access via `gradio_client`.")
840
 
841
- with gr.Row():
842
- api_video_input = gr.Video(label="Upload Match Video")
 
843
 
844
- with gr.Row():
845
- api_process_btn = gr.Button("Process Video", variant="primary")
 
 
846
 
847
- with gr.Tabs():
848
- with gr.Tab("πŸ“Ή Results"):
849
- api_video_output = gr.Video(label="Annotated Video")
850
- api_stats_output = gr.JSON(label="Match Statistics")
851
- api_events_output = gr.JSON(label="Detected Events")
852
- api_artifacts_output = gr.JSON(label="Generated Artifacts")
853
 
854
- api_process_btn.click(
855
- fn=process_video_api,
856
- inputs=[api_video_input],
857
- outputs=[
858
- api_video_output,
859
- api_stats_output,
860
- api_events_output,
861
- api_artifacts_output,
862
- ],
 
863
  )
864
 
865
- # ==============================================
866
- # LAUNCH BOTH INTERFACES
867
- # ==============================================
868
  if __name__ == "__main__":
869
- # Create tabbed interface with both UIs
870
- demo = gr.TabbedInterface(
871
- [web_interface, api_interface],
872
- ["🌐 Web Interface", "πŸ”Œ API Access"],
873
- title="⚽ AfriGoals - Football Video Analyzer"
874
- )
875
-
876
- demo.launch(
877
- show_error=True,
878
- share=False,
879
- )
 
593
  # ==============================================
594
  def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
595
  """
596
+ Complete football analysis pipeline:
597
+ - Player & ball detection (Roboflow)
598
+ - Team classification (SigLIP-based)
599
+ - Tracking (ByteTrack) with stable team assignments
600
+ - Field homography -> pitch coordinates
601
+ - Ball trajectory cleaning
602
+ - Performance analytics
603
+ - Simple events + possession + per-player stats
604
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  if not video_path:
606
+ return (
607
+ None,
608
+ None,
609
+ None,
610
+ None,
611
+ None,
612
+ "❌ Please upload a video file.",
613
+ [],
614
+ [],
615
+ None,
616
+ )
617
+
618
  try:
619
+ progress(0, desc="πŸ”§ Initializing...")
620
+
621
+ # IDs from Roboflow model
622
+ BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
623
+ STRIDE = 30 # Frame sampling for training
624
+ MAXLEN = 5 # Transformation matrix smoothing
625
+ MAX_DISTANCE_THRESHOLD = 500 # Ball path outlier threshold
626
+
627
+ # Video setup
628
+ cap = cv2.VideoCapture(video_path)
629
+ if not cap.isOpened():
630
+ return (
631
+ None,
632
+ None,
633
+ None,
634
+ None,
635
+ None,
636
+ f"❌ Failed to open video: {video_path}",
637
+ [],
638
+ [],
639
+ None,
640
+ )
641
+
642
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
643
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
644
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
645
+ fps = cap.get(cv2.CAP_PROP_FPS)
646
+ if fps <= 0:
647
+ fps = 30.0
648
+ dt = 1.0 / fps
649
+
650
+ print(f"πŸ“Ή Video: {width}x{height}, {fps}fps, {total_frames} frames")
651
+
652
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
653
+ output_path = "/tmp/annotated_football.mp4"
654
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
655
+
656
+ # Initialize managers
657
+ tracking_manager = PlayerTrackingManager(max_history=10)
658
+ performance_tracker = PlayerPerformanceTracker(CONFIG, fps=fps)
659
+
660
+ # Simple possession / events stats
661
+ distance_covered_m = defaultdict(float) # tid -> meters
662
+ possession_time_player = defaultdict(float) # tid -> seconds
663
+ possession_time_team = defaultdict(float) # team_id -> seconds
664
+ team_of_player = {} # tid -> team_id
665
+ events: List[Dict] = []
666
+
667
+ prev_owner_tid: Optional[int] = None
668
+ prev_ball_pos_pitch: Optional[np.ndarray] = None
669
+
670
+ # Annotators
671
+ ellipse_annotator = sv.EllipseAnnotator(
672
+ color=sv.ColorPalette.from_hex(["#00BFFF", "#FF1493", "#FFD700"]),
673
+ thickness=2,
674
+ )
675
+ label_annotator = sv.LabelAnnotator(
676
+ color=sv.ColorPalette.from_hex(["#00BFFF", "#FF1493", "#FFD700"]),
677
+ text_color=sv.Color.from_hex("#FFFFFF"),
678
+ text_thickness=2,
679
+ text_position=sv.Position.BOTTOM_CENTER,
680
+ )
681
+ triangle_annotator = sv.TriangleAnnotator(
682
+ color=sv.Color.from_hex("#FFD700"),
683
+ base=20,
684
+ height=17,
685
+ )
686
+
687
+ # ByteTrack tracker with optimized settings
688
+ tracker = sv.ByteTrack(
689
+ track_activation_threshold=0.4,
690
+ lost_track_buffer=60,
691
+ minimum_matching_threshold=0.85,
692
+ frame_rate=fps,
693
+ )
694
+ tracker.reset()
695
+
696
+ # For field transform smoothing + ball path
697
+ M = deque(maxlen=MAXLEN) # Transformation matrix smoothing
698
+ ball_path_raw = []
699
+
700
+ # Last pitch positions (for speed/distance overlay, events)
701
+ last_pitch_players_xy = None
702
+ last_players_class_id = None
703
+ last_pitch_referees_xy = None
704
+ last_pitch_pos_by_tid: Dict[int, np.ndarray] = {}
705
+
706
+ # Simple goal centers (for shot/clearance direction)
707
+ goal_centers = {
708
+ 0: np.array([0.0, CONFIG.width / 2.0]),
709
+ 1: np.array([CONFIG.length, CONFIG.width / 2.0]),
710
  }
711
+
712
+ # Event banner overlay
713
+ current_event_text = ""
714
+ event_text_frames_left = 0
715
+ EVENT_TEXT_DURATION_S = 2.0
716
+ EVENT_TEXT_DURATION_FRAMES = int(EVENT_TEXT_DURATION_S * fps)
717
+
718
+ # ========================================
719
+ # STEP 1: Collect Player Crops for Team Classifier
720
+ # ========================================
721
+ progress(0.05, desc="πŸƒ Collecting player samples (Step 1/6)...")
722
+ player_crops = []
723
+ frame_count = 0
724
+
725
+ while frame_count < min(total_frames, 300):
726
+ ret, frame = cap.read()
727
+ if not ret:
728
+ break
729
+
730
+ if frame_count % STRIDE == 0:
731
+ _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
732
+ detections = detections.with_nms(threshold=0.5, class_agnostic=True)
733
+ players_detections = detections[detections.class_id == PLAYER_ID]
734
+
735
+ if len(players_detections.xyxy) > 0:
736
+ crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
737
+ player_crops.extend(crops)
738
+
739
+ frame_count += 1
740
+
741
+ if len(player_crops) == 0:
742
+ cap.release()
743
+ out.release()
744
+ return (
745
+ None,
746
+ None,
747
+ None,
748
+ None,
749
+ None,
750
+ "❌ No player crops collected.",
751
+ [],
752
+ [],
753
+ None,
754
+ )
755
+
756
+ print(f"βœ… Collected {len(player_crops)} player samples")
757
+
758
+ # ========================================
759
+ # STEP 2: Train Team Classifier
760
+ # ========================================
761
+ progress(0.15, desc="🎯 Training team classifier (Step 2/6)...")
762
+ team_classifier = TeamClassifier(device=DEVICE)
763
+ team_classifier.fit(player_crops)
764
+ print("βœ… Team classifier trained")
765
+
766
+ # ========================================
767
+ # STEP 3: Process Full Video with Tracking + Events
768
+ # ========================================
769
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
770
+ frame_count = 0
771
+
772
+ progress(0.2, desc="🎬 Processing video frames (Step 3/6)...")
773
+
774
+ frame_idx = 0
775
+ while True:
776
+ ret, frame = cap.read()
777
+ if not ret:
778
+ break
779
+
780
+ frame_idx += 1
781
+ t = frame_idx * dt
782
+ frame_count += 1
783
+ tracking_manager.reset_frame()
784
+
785
+ if frame_count % 30 == 0:
786
+ progress(
787
+ 0.2 + 0.4 * (frame_count / max(total_frames, 1)),
788
+ desc=f"🎬 Processing frame {frame_count}/{total_frames}",
789
+ )
790
+
791
+ # Player and ball detection
792
+ _, detections = infer_with_confidence(PLAYER_DETECTION_MODEL_ID, frame, 0.3)
793
+
794
+ if len(detections.xyxy) == 0:
795
+ out.write(frame)
796
+ ball_path_raw.append(np.empty((0, 2)))
797
+ continue
798
+
799
+ # Separate ball from other detections
800
+ ball_detections = detections[detections.class_id == BALL_ID]
801
+ ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
802
+
803
+ all_detections = detections[detections.class_id != BALL_ID]
804
+ all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
805
+
806
+ # Track detections
807
+ all_detections = tracker.update_with_detections(detections=all_detections)
808
+
809
+ # Separate by type
810
+ goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
811
+ players_detections = all_detections[all_detections.class_id == PLAYER_ID]
812
+ referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
813
+
814
+ # Team prediction with stability
815
+ if len(players_detections.xyxy) > 0:
816
+ crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
817
+ predicted_teams = team_classifier.predict(crops)
818
+
819
+ # Apply stable team assignment
820
+ for idx, tracker_id in enumerate(players_detections.tracker_id):
821
+ tracking_manager.update_team_assignment(int(tracker_id), int(predicted_teams[idx]))
822
+ predicted_teams[idx] = tracking_manager.get_stable_team_id(
823
+ int(tracker_id),
824
+ int(predicted_teams[idx]),
825
+ )
826
+
827
+ players_detections.class_id = predicted_teams
828
+
829
+ # Assign goalkeeper teams
830
+ goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
831
+ players_detections,
832
+ goalkeepers_detections,
833
+ )
834
+
835
+ # Adjust referee class_id
836
+ referees_detections.class_id -= 1
837
+
838
+ # Merge all detections
839
+ all_detections = sv.Detections.merge(
840
+ [players_detections, goalkeepers_detections, referees_detections]
841
+ )
842
+
843
+ all_detections.class_id = all_detections.class_id.astype(int)
844
+
845
+ # ========================================
846
+ # STEP 4: Field Detection & Transformation
847
+ # ========================================
848
+ pitch_players_xy = None
849
+ pitch_referees_xy = None
850
+ pitch_ball_xy = np.empty((0, 2), dtype=np.float32)
851
+ frame_ball_pos_pitch = None
852
+
853
+ try:
854
+ result_field, _ = infer_with_confidence(FIELD_DETECTION_MODEL_ID, frame, 0.3)
855
+ key_points = sv.KeyPoints.from_inference(result_field)
856
+
857
+ # Filter confident keypoints
858
+ filter_mask = key_points.confidence[0] > 0.5
859
+ frame_ref_pts = key_points.xy[0][filter_mask]
860
+ pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
861
+
862
+ if len(frame_ref_pts) >= 4: # Need at least 4 points for homography
863
+ transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
864
+ M.append(transformer.m)
865
+ transformer.m = np.mean(np.array(M), axis=0)
866
+
867
+ # Transform ball position
868
+ frame_ball_xy = ball_detections.get_anchors_coordinates(
869
+ sv.Position.BOTTOM_CENTER
870
+ )
871
+ pitch_ball_xy = (
872
+ transformer.transform_points(frame_ball_xy)
873
+ if len(frame_ball_xy) > 0
874
+ else np.empty((0, 2))
875
+ )
876
+ if len(pitch_ball_xy) > 0:
877
+ frame_ball_pos_pitch = pitch_ball_xy[0]
878
+ ball_path_raw.append(pitch_ball_xy)
879
+
880
+ # Transform all players (including goalkeepers)
881
+ all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
882
+ players_xy = all_players.get_anchors_coordinates(
883
+ sv.Position.BOTTOM_CENTER
884
+ )
885
+ pitch_players_xy = (
886
+ transformer.transform_points(players_xy)
887
+ if len(players_xy) > 0
888
+ else np.empty((0, 2))
889
+ )
890
+
891
+ # Transform referees
892
+ referees_xy = referees_detections.get_anchors_coordinates(
893
+ sv.Position.BOTTOM_CENTER
894
+ )
895
+ pitch_referees_xy = (
896
+ transformer.transform_points(referees_xy)
897
+ if len(referees_xy) > 0
898
+ else np.empty((0, 2))
899
+ )
900
+
901
+ # Store for radar view
902
+ last_pitch_players_xy = pitch_players_xy
903
+ last_players_class_id = all_players.class_id
904
+ last_pitch_referees_xy = pitch_referees_xy
905
+
906
+ # Update performance tracker + distance per player (meters)
907
+ for idx, tracker_id in enumerate(all_players.tracker_id):
908
+ tid_int = int(tracker_id)
909
+ if idx < len(pitch_players_xy):
910
+ pos_pitch = pitch_players_xy[idx]
911
+ performance_tracker.update(
912
+ tid_int,
913
+ pos_pitch,
914
+ int(all_players.class_id[idx]),
915
+ frame_count,
916
+ )
917
+ team_of_player[tid_int] = int(all_players.class_id[idx])
918
+
919
+ prev_pos = last_pitch_pos_by_tid.get(tid_int)
920
+ if prev_pos is not None:
921
+ dist_m = pitch_distance_m(prev_pos, pos_pitch)
922
+ distance_covered_m[tid_int] += dist_m
923
+ last_pitch_pos_by_tid[tid_int] = pos_pitch
924
+ else:
925
+ ball_path_raw.append(np.empty((0, 2)))
926
+ except Exception:
927
+ ball_path_raw.append(np.empty((0, 2)))
928
+
929
+ # ========================================
930
+ # POSSESSION + EVENTS (simple heuristics)
931
+ # ========================================
932
+ owner_tid: Optional[int] = None
933
+ POSSESSION_RADIUS_M = 5.0
934
+
935
+ if frame_ball_pos_pitch is not None and pitch_players_xy is not None and len(pitch_players_xy) > 0:
936
+ dists = np.linalg.norm(pitch_players_xy - frame_ball_pos_pitch, axis=1)
937
+ j = int(np.argmin(dists))
938
+ nearest_dist_m = pitch_distance_m(pitch_players_xy[j], frame_ball_pos_pitch)
939
+ if nearest_dist_m < POSSESSION_RADIUS_M:
940
+ owner_tid = int(all_players.tracker_id[j])
941
+
942
+ # accumulate possession time
943
+ if owner_tid is not None:
944
+ possession_time_player[owner_tid] += dt
945
+ owner_team = team_of_player.get(owner_tid)
946
+ if owner_team is not None:
947
+ possession_time_team[owner_team] += dt
948
+
949
+ def register_event(ev: Dict, text: str):
950
+ nonlocal current_event_text, event_text_frames_left
951
+ events.append(ev)
952
+ if text:
953
+ current_event_text = text
954
+ event_text_frames_left = EVENT_TEXT_DURATION_FRAMES
955
+
956
+ # possession change events, passes, tackles, interceptions
957
+ if owner_tid != prev_owner_tid:
958
+ if owner_tid is not None and prev_owner_tid is not None:
959
+ prev_team = team_of_player.get(prev_owner_tid)
960
+ cur_team = team_of_player.get(owner_tid)
961
+
962
+ travel_m = 0.0
963
+ if prev_ball_pos_pitch is not None and frame_ball_pos_pitch is not None:
964
+ travel_m = pitch_distance_m(prev_ball_pos_pitch, frame_ball_pos_pitch)
965
+
966
+ MIN_PASS_TRAVEL_M = 3.0
967
+
968
+ if prev_team is not None and cur_team is not None:
969
+ if prev_team == cur_team and travel_m > MIN_PASS_TRAVEL_M:
970
+ # pass
971
+ register_event(
972
+ {
973
+ "type": "pass",
974
+ "t": float(t),
975
+ "from_tid": int(prev_owner_tid),
976
+ "to_tid": int(owner_tid),
977
+ "team_id": int(cur_team),
978
+ "extra": {"distance_m": travel_m},
979
+ },
980
+ f"Pass: #{prev_owner_tid} β†’ #{owner_tid} (Team {cur_team})",
981
+ )
982
+ elif prev_team != cur_team:
983
+ # tackle vs interception
984
+ d_pp = 999.0
985
+ if pitch_players_xy is not None:
986
+ pos_prev = last_pitch_pos_by_tid.get(int(prev_owner_tid))
987
+ pos_cur = last_pitch_pos_by_tid.get(int(owner_tid))
988
+ if pos_prev is not None and pos_cur is not None:
989
+ d_pp = pitch_distance_m(pos_prev, pos_cur)
990
+ ev_type = "tackle" if d_pp < 3.0 else "interception"
991
+ label = "Tackle" if ev_type == "tackle" else "Interception"
992
+ register_event(
993
+ {
994
+ "type": ev_type,
995
+ "t": float(t),
996
+ "from_tid": int(prev_owner_tid),
997
+ "to_tid": int(owner_tid),
998
+ "team_id": int(cur_team),
999
+ "extra": {
1000
+ "player_distance_m": d_pp,
1001
+ "ball_travel_m": travel_m,
1002
+ },
1003
+ },
1004
+ f"{label}: #{owner_tid} wins ball from #{prev_owner_tid}",
1005
+ )
1006
+
1007
+ # generic possession-change event (optional text)
1008
+ if owner_tid is not None:
1009
+ team_id = team_of_player.get(owner_tid)
1010
+ register_event(
1011
+ {
1012
+ "type": "possession_change",
1013
+ "t": float(t),
1014
+ "from_tid": int(prev_owner_tid)
1015
+ if prev_owner_tid is not None
1016
+ else None,
1017
+ "to_tid": int(owner_tid),
1018
+ "team_id": int(team_id) if team_id is not None else None,
1019
+ "extra": {},
1020
+ },
1021
+ "",
1022
+ )
1023
+
1024
+ # shot / clearance based on ball speed & direction
1025
+ if (
1026
+ prev_ball_pos_pitch is not None
1027
+ and frame_ball_pos_pitch is not None
1028
+ and owner_tid is not None
1029
+ ):
1030
+ v_vec = frame_ball_pos_pitch - prev_ball_pos_pitch # pitch units
1031
+ # convert to meters per second
1032
+ dist_m = pitch_distance_m(prev_ball_pos_pitch, frame_ball_pos_pitch)
1033
+ speed_mps = dist_m / dt
1034
+ speed_kmh = speed_mps * 3.6
1035
+ HIGH_SPEED_KMH = 18.0
1036
+
1037
+ if speed_kmh > HIGH_SPEED_KMH:
1038
+ shooter_team = team_of_player.get(owner_tid)
1039
+ if shooter_team is not None:
1040
+ target_goal = goal_centers[1 - shooter_team]
1041
+ direction = target_goal - frame_ball_pos_pitch
1042
+ v_norm = np.linalg.norm(v_vec)
1043
+ d_norm = np.linalg.norm(direction)
1044
+ cos_angle = 0.0
1045
+ if v_norm > 1e-6 and d_norm > 1e-6:
1046
+ cos_angle = float(np.dot(v_vec, direction) / (v_norm * d_norm))
1047
+
1048
+ if cos_angle > 0.8:
1049
+ register_event(
1050
+ {
1051
+ "type": "shot",
1052
+ "t": float(t),
1053
+ "from_tid": int(owner_tid),
1054
+ "to_tid": None,
1055
+ "team_id": int(shooter_team),
1056
+ "extra": {"speed_kmh": speed_kmh},
1057
+ },
1058
+ f"Shot by #{owner_tid} (Team {shooter_team}) – {speed_kmh:.1f} km/h",
1059
+ )
1060
+ else:
1061
+ register_event(
1062
+ {
1063
+ "type": "clearance",
1064
+ "t": float(t),
1065
+ "from_tid": int(owner_tid),
1066
+ "to_tid": None,
1067
+ "team_id": int(shooter_team),
1068
+ "extra": {"speed_kmh": speed_kmh},
1069
+ },
1070
+ f"Clearance by #{owner_tid} (Team {shooter_team})",
1071
+ )
1072
+
1073
+ prev_owner_tid = owner_tid
1074
+ prev_ball_pos_pitch = frame_ball_pos_pitch
1075
+
1076
+ # ========================================
1077
+ # FRAME ANNOTATION (video overlay)
1078
+ # ========================================
1079
+ annotated_frame = frame.copy()
1080
+
1081
+ # Basic labels: only player ID + team
1082
+ labels = []
1083
+ for tid, cid in zip(all_detections.tracker_id, all_detections.class_id):
1084
+ labels.append(f"#{int(tid)} T{int(cid)}")
1085
+
1086
+ annotated_frame = ellipse_annotator.annotate(annotated_frame, all_detections)
1087
+ annotated_frame = label_annotator.annotate(
1088
+ annotated_frame,
1089
+ all_detections,
1090
+ labels=labels,
1091
+ )
1092
+ annotated_frame = triangle_annotator.annotate(annotated_frame, ball_detections)
1093
+
1094
+ # HUD: possession per team
1095
+ total_poss = sum(possession_time_team.values()) + 1e-6
1096
+ team0_pct = 100.0 * possession_time_team.get(0, 0.0) / total_poss
1097
+ team1_pct = 100.0 * possession_time_team.get(1, 0.0) / total_poss
1098
+
1099
+ hud_text = (
1100
+ f"Team 0 Ball Control: {team0_pct:5.2f}% "
1101
+ f"Team 1 Ball Control: {team1_pct:5.2f}%"
1102
+ )
1103
+ cv2.rectangle(
1104
+ annotated_frame,
1105
+ (20, annotated_frame.shape[0] - 60),
1106
+ (annotated_frame.shape[1] - 20, annotated_frame.shape[0] - 20),
1107
+ (255, 255, 255),
1108
+ -1,
1109
+ )
1110
+ cv2.putText(
1111
+ annotated_frame,
1112
+ hud_text,
1113
+ (30, annotated_frame.shape[0] - 30),
1114
+ cv2.FONT_HERSHEY_SIMPLEX,
1115
+ 0.8,
1116
+ (0, 0, 0),
1117
+ 2,
1118
+ cv2.LINE_AA,
1119
+ )
1120
+
1121
+ # Event banner
1122
+ if event_text_frames_left > 0 and current_event_text:
1123
+ cv2.rectangle(
1124
+ annotated_frame,
1125
+ (20, 20),
1126
+ (annotated_frame.shape[1] - 20, 90),
1127
+ (255, 255, 255),
1128
+ -1,
1129
+ )
1130
+ cv2.putText(
1131
+ annotated_frame,
1132
+ current_event_text,
1133
+ (30, 70),
1134
+ cv2.FONT_HERSHEY_SIMPLEX,
1135
+ 1.0,
1136
+ (0, 0, 0),
1137
+ 2,
1138
+ cv2.LINE_AA,
1139
+ )
1140
+ event_text_frames_left -= 1
1141
+
1142
+ out.write(annotated_frame)
1143
+
1144
+ cap.release()
1145
+ out.release()
1146
+ print(f"βœ… Processed {frame_count} frames")
1147
+
1148
+ # ========================================
1149
+ # STEP 5: Clean Ball Path (Remove Outliers)
1150
+ # ========================================
1151
+ progress(0.65, desc="🧹 Cleaning ball trajectory (Step 4/6)...")
1152
+
1153
+ # Convert to proper format for cleaning
1154
+ path_for_cleaning = []
1155
+ for coords in ball_path_raw:
1156
+ if len(coords) == 0:
1157
+ path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
1158
+ elif coords.shape[0] >= 2:
1159
+ # If multiple points (rare for ball), ignore to avoid ambiguity
1160
+ path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
1161
+ else:
1162
+ path_for_cleaning.append(coords)
1163
+
1164
+ # Remove outliers
1165
+ cleaned_path = replace_outliers_based_on_distance(
1166
+ [
1167
+ np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2))
1168
+ for p in path_for_cleaning
1169
+ ],
1170
+ MAX_DISTANCE_THRESHOLD,
1171
+ )
1172
+
1173
+ print(
1174
+ f"βœ… Ball path cleaned: "
1175
+ f"{len([p for p in cleaned_path if len(p) > 0])} valid points"
1176
+ )
1177
+
1178
+ # ========================================
1179
+ # STEP 6: Generate Performance Analytics
1180
+ # ========================================
1181
+ progress(0.75, desc="πŸ“Š Generating performance analytics (Step 5/6)...")
1182
+
1183
+ # Team comparison charts
1184
+ comparison_fig = create_team_comparison_plot(performance_tracker)
1185
+
1186
+ # Combined team heatmaps
1187
+ team_heatmaps_path = "/tmp/team_heatmaps.png"
1188
+ team_heatmaps = create_combined_heatmaps(performance_tracker)
1189
+ cv2.imwrite(team_heatmaps_path, team_heatmaps)
1190
+
1191
+ # Individual player heatmaps (top 6 by distance)
1192
+ progress(0.85, desc="πŸ—ΊοΈ Creating individual heatmaps...")
1193
+ teams = performance_tracker.get_all_players_by_team()
1194
+ top_players = []
1195
+
1196
+ for team_id in [0, 1]:
1197
+ if team_id in teams:
1198
+ team_players = teams[team_id]
1199
+ player_distances = [
1200
+ (pid, performance_tracker.get_player_stats(pid)["total_distance_meters"])
1201
+ for pid in team_players
1202
+ ]
1203
+ player_distances.sort(key=lambda x: x[1], reverse=True)
1204
+ top_players.extend([pid for pid, _ in player_distances[:3]])
1205
+
1206
+ individual_heatmaps = []
1207
+ for pid in top_players[:6]:
1208
+ heatmap = create_player_heatmap_visualization(performance_tracker, pid)
1209
+ individual_heatmaps.append(heatmap)
1210
+
1211
+ # Arrange individual heatmaps in grid (3 columns)
1212
+ if len(individual_heatmaps) > 0:
1213
+ rows = []
1214
+ for i in range(0, len(individual_heatmaps), 3):
1215
+ row_maps = individual_heatmaps[i : i + 3]
1216
+ if len(row_maps) == 3:
1217
+ rows.append(np.hstack(row_maps))
1218
+ elif len(row_maps) == 2:
1219
+ rows.append(np.hstack([row_maps[0], row_maps[1]]))
1220
+ else:
1221
+ rows.append(row_maps[0])
1222
+
1223
+ individual_grid = np.vstack(rows) if len(rows) > 1 else rows[0]
1224
+ individual_heatmaps_path = "/tmp/individual_heatmaps.png"
1225
+ cv2.imwrite(individual_heatmaps_path, individual_grid)
1226
+ else:
1227
+ individual_heatmaps_path = None
1228
+
1229
+ # ========================================
1230
+ # STEP 7: Create Game-Style Radar View
1231
+ # ========================================
1232
+ progress(0.9, desc="πŸ—ΊοΈ Creating game-style radar view (Step 6/6)...")
1233
+ radar_path = "/tmp/radar_view_enhanced.png"
1234
+ try:
1235
+ if last_pitch_players_xy is not None:
1236
+ radar_frame = create_game_style_radar(
1237
+ pitch_ball_xy=cleaned_path[-1]
1238
+ if cleaned_path
1239
+ else np.empty((0, 2)),
1240
+ pitch_players_xy=last_pitch_players_xy,
1241
+ players_class_id=last_players_class_id,
1242
+ pitch_referees_xy=last_pitch_referees_xy,
1243
+ ball_path=cleaned_path,
1244
+ )
1245
+ cv2.imwrite(radar_path, radar_frame)
1246
+ else:
1247
+ radar_path = None
1248
+ except Exception as e:
1249
+ print(f"⚠️ Radar view creation failed: {e}")
1250
+ radar_path = None
1251
+
1252
+ # ========================================
1253
+ # BUILD PLAYER STATS TABLE & EVENTS TABLE
1254
+ # ========================================
1255
+ total_poss = sum(possession_time_team.values()) + 1e-6
1256
+
1257
+ player_stats_table = []
1258
+ for team_id, player_ids in teams.items():
1259
+ for pid in player_ids:
1260
+ stats = performance_tracker.get_player_stats(pid)
1261
+ poss_s = float(possession_time_player.get(pid, 0.0))
1262
+ poss_pct = 100.0 * poss_s / total_poss if total_poss > 0 else 0.0
1263
+
1264
+ row = [
1265
+ int(pid),
1266
+ int(stats["team_id"]),
1267
+ float(stats["total_distance_meters"]),
1268
+ float(stats["avg_velocity"]),
1269
+ float(stats["max_velocity"]),
1270
+ int(stats["frames_visible"]),
1271
+ int(stats["time_in_defensive_third"]),
1272
+ int(stats["time_in_middle_third"]),
1273
+ int(stats["time_in_attacking_third"]),
1274
+ poss_s,
1275
+ poss_pct,
1276
+ ]
1277
+ player_stats_table.append(row)
1278
+
1279
+ events_table = []
1280
+ for ev in events:
1281
+ ev_type = ev.get("type", "")
1282
+ t_ev = float(ev.get("t", 0.0))
1283
+ team_id = ev.get("team_id", None)
1284
+ from_tid = ev.get("from_tid", None)
1285
+ to_tid = ev.get("to_tid", None)
1286
+ extra = ev.get("extra", {}) or {}
1287
+
1288
+ speed_kmh = float(extra.get("speed_kmh", 0.0))
1289
+ ball_dist_m = float(extra.get("distance_m", extra.get("ball_travel_m", 0.0)))
1290
+ player_dist_m = float(extra.get("player_distance_m", 0.0))
1291
+
1292
+ if ev_type == "pass":
1293
+ desc = f"Pass #{from_tid} β†’ #{to_tid} (Team {team_id})"
1294
+ elif ev_type == "tackle":
1295
+ desc = (
1296
+ f"Tackle: #{to_tid} wins ball from #{from_tid} "
1297
+ f"(Team {team_id})"
1298
+ )
1299
+ elif ev_type == "interception":
1300
+ desc = (
1301
+ f"Interception: #{to_tid} intercepts #{from_tid} "
1302
+ f"(Team {team_id})"
1303
+ )
1304
+ elif ev_type == "shot":
1305
+ desc = (
1306
+ f"Shot by #{from_tid} (Team {team_id}) at {speed_kmh:.1f} km/h"
1307
+ )
1308
+ elif ev_type == "clearance":
1309
+ desc = f"Clearance by #{from_tid} (Team {team_id})"
1310
+ else:
1311
+ desc = ev_type
1312
+
1313
+ row = [
1314
+ t_ev,
1315
+ ev_type,
1316
+ team_id,
1317
+ from_tid,
1318
+ to_tid,
1319
+ speed_kmh,
1320
+ ball_dist_m,
1321
+ player_dist_m,
1322
+ desc,
1323
+ ]
1324
+ events_table.append(row)
1325
+
1326
+ events_json_path = "/tmp/events.json"
1327
+ with open(events_json_path, "w", encoding="utf-8") as f:
1328
+ json.dump(events, f, indent=2)
1329
+
1330
+ # ========================================
1331
+ # Generate Summary Report
1332
+ # ========================================
1333
+ progress(0.95, desc="πŸ“ Generating summary report...")
1334
+
1335
+ summary_lines = ["βœ… **Analysis Complete!**\n"]
1336
+ summary_lines.append("**Video Statistics:**")
1337
+ summary_lines.append(f"- Total Frames Processed: {frame_count}")
1338
+ summary_lines.append(f"- Video Resolution: {width}x{height}")
1339
+ summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
1340
+ summary_lines.append(
1341
+ f"- Ball Trajectory Points: "
1342
+ f"{len([p for p in cleaned_path if len(p) > 0])}\n"
1343
+ )
1344
+
1345
+ for team_id in [0, 1]:
1346
+ if team_id not in teams:
1347
+ continue
1348
+
1349
+ team_name = "Team 0 (Blue)" if team_id == 0 else "Team 1 (Pink)"
1350
+ summary_lines.append(f"\n**{team_name}:**")
1351
+ summary_lines.append(f"- Players Tracked: {len(teams[team_id])}")
1352
+
1353
+ total_dist = sum(
1354
+ performance_tracker.get_player_stats(pid)["total_distance_meters"]
1355
+ for pid in teams[team_id]
1356
+ )
1357
+ avg_dist = total_dist / len(teams[team_id]) if len(teams[team_id]) > 0 else 0
1358
+ summary_lines.append(f"- Team Total Distance: {total_dist:.1f} m")
1359
+ summary_lines.append(
1360
+ f"- Average Distance per Player: {avg_dist:.1f} m"
1361
+ )
1362
+
1363
+ # Top 3 performers (by distance)
1364
+ player_distances = [
1365
+ (pid, performance_tracker.get_player_stats(pid)["total_distance_meters"])
1366
+ for pid in teams[team_id]
1367
+ ]
1368
+ player_distances.sort(key=lambda x: x[1], reverse=True)
1369
+
1370
+ summary_lines.append("\n **Top 3 Performers:**")
1371
+ for i, (pid, dist) in enumerate(player_distances[:3], 1):
1372
+ stats = performance_tracker.get_player_stats(pid)
1373
+ summary_lines.append(
1374
+ f" {i}. Player #{pid}: {dist:.1f} m, "
1375
+ f"Avg: {stats['avg_velocity']:.2f} km/h, "
1376
+ f"Max: {stats['max_velocity']:.2f} km/h"
1377
+ )
1378
+
1379
+ # Team possession in summary
1380
+ summary_lines.append("\n**Team Possession:**")
1381
+ for team_id in sorted(possession_time_team.keys()):
1382
+ t_sec = possession_time_team[team_id]
1383
+ pct = 100.0 * t_sec / total_poss if total_poss > 0 else 0.0
1384
+ summary_lines.append(f"- Team {team_id}: {t_sec:.1f} s ({pct:.1f}%)")
1385
+
1386
+ summary_lines.append("\n**Pipeline Steps Completed:**")
1387
+ summary_lines.append("βœ… 1. Player crop collection")
1388
+ summary_lines.append("βœ… 2. Team classifier training")
1389
+ summary_lines.append("βœ… 3. Video processing with tracking & events")
1390
+ summary_lines.append("βœ… 4. Ball trajectory cleaning")
1391
+ summary_lines.append("βœ… 5. Performance analytics generation")
1392
+ summary_lines.append("βœ… 6. Visualization creation")
1393
+
1394
+ summary_msg = "\n".join(summary_lines)
1395
+
1396
+ progress(1.0, desc="βœ… Analysis Complete!")
1397
+
1398
+ # IMPORTANT: must return 9 outputs in the same order as Gradio wiring
1399
+ return (
1400
+ output_path, # video_output
1401
+ comparison_fig, # comparison_output
1402
+ team_heatmaps_path, # team_heatmaps_output
1403
+ individual_heatmaps_path, # individual_heatmaps_output
1404
+ radar_path, # radar_output
1405
+ summary_msg, # status_output
1406
+ player_stats_table, # player_stats_output (Dataframe)
1407
+ events_table, # events_output (Dataframe)
1408
+ events_json_path, # events_json_output (File download)
1409
+ )
1410
+
1411
  except Exception as e:
1412
+ error_msg = f"❌ Error: {str(e)}"
1413
+ print(error_msg)
1414
  import traceback
1415
+
1416
+ traceback.print_exc()
1417
+ # Match the 9 outputs (fill with Nones/empties)
1418
+ return (
1419
+ None,
1420
+ None,
1421
+ None,
1422
+ None,
1423
+ None,
1424
+ error_msg,
1425
+ [],
1426
+ [],
1427
+ None,
1428
+ )
1429
 
1430
 
1431
  # ==============================================
1432
+ # GRADIO INTERFACE
1433
  # ==============================================
1434
+
1435
  def run_pipeline(video) -> Tuple:
1436
  """
1437
+ Gradio wrapper: accept the raw video object from gr.Video and
1438
+ convert it to a filesystem path for analyze_football_video().
1439
  """
1440
  if video is None:
1441
  return (
1442
+ None,
1443
+ None,
1444
+ None,
1445
+ None,
1446
+ None,
1447
  "❌ Please upload a video file.",
1448
+ [],
1449
+ [],
1450
+ None,
1451
  )
1452
 
1453
+ # On Spaces, Video input is usually a dict with at least a "path" key.
1454
  if isinstance(video, dict):
1455
+ video_path = (
1456
+ video.get("path")
1457
+ or video.get("name")
1458
+ or video.get("filename")
1459
+ )
1460
  else:
1461
+ # Fallback: if it's already a string/path-like
1462
  video_path = str(video)
1463
 
1464
  if not video_path:
1465
  return (
1466
+ None,
1467
+ None,
1468
+ None,
1469
+ None,
1470
+ None,
1471
  "❌ Could not resolve video file path from upload.",
1472
+ [],
1473
+ [],
1474
+ None,
1475
  )
1476
 
1477
  return analyze_football_video(video_path)
1478
 
1479
 
1480
+ with gr.Blocks(title="⚽ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
 
 
 
 
 
1481
  gr.Markdown(
1482
  """
1483
  # ⚽ Advanced Football Video Analyzer
1484
  ### Complete Pipeline Implementation
1485
 
1486
+ This application:
1487
+ 1. **Player Detection** - Collect player crops using Roboflow
1488
+ 2. **Team Classification** - Train SigLIP-based team classifier
1489
+ 3. **Persistent Tracking** - ByteTrack with stable ID assignment
1490
+ 4. **Field Transformation** - Project players onto pitch coordinates
1491
+ 5. **Ball Trajectory** - Track and clean ball path with outlier removal
1492
+ 6. **Performance Analytics** - Heatmaps, stats, possession, and event detection
1493
 
1494
  Upload a football match video to get comprehensive performance analytics!
1495
  """
1496
  )
1497
 
1498
  with gr.Row():
1499
+ # No "type" argument – your Gradio version does not support it
1500
+ video_input = gr.Video(label="πŸ“€ Upload Football Video")
1501
 
1502
  analyze_btn = gr.Button("πŸš€ Start Analysis Pipeline", variant="primary", size="lg")
1503
 
 
1506
 
1507
  with gr.Tabs():
1508
  with gr.Tab("πŸ“Ή Annotated Video"):
1509
+ gr.Markdown(
1510
+ "### Full video with player tracking, team colors, ball detection, and events overlay"
1511
+ )
1512
  video_output = gr.Video(label="Processed Video")
1513
 
1514
  with gr.Tab("πŸ“Š Performance Comparison"):
1515
+ gr.Markdown("### Interactive charts comparing player performance metrics")
1516
  comparison_output = gr.Plot(label="Team Performance Metrics")
1517
 
1518
  with gr.Tab("πŸ—ΊοΈ Team Heatmaps"):
 
1528
  radar_output = gr.Image(label="Tactical Radar View")
1529
 
1530
  with gr.Tab("πŸ“‹ Player Stats"):
1531
+ gr.Markdown("### Per-player totals: distance, speeds, zones, possession")
1532
  player_stats_output = gr.Dataframe(
1533
  headers=PLAYER_STATS_HEADERS,
1534
  col_count=len(PLAYER_STATS_HEADERS),
 
1537
  )
1538
 
1539
  with gr.Tab("⏱️ Event Timeline"):
1540
+ gr.Markdown(
1541
+ "### Detected passes, tackles, interceptions, shots, clearances"
1542
+ )
1543
  events_output = gr.Dataframe(
1544
  headers=EVENT_HEADERS,
1545
  col_count=len(EVENT_HEADERS),
1546
  row_count=0,
1547
  interactive=False,
1548
  )
1549
+ events_json_output = gr.File(
1550
+ label="Download events JSON",
1551
+ file_types=[".json"],
1552
+ )
1553
 
1554
  analyze_btn.click(
1555
  fn=run_pipeline,
1556
  inputs=[video_input],
1557
  outputs=[
1558
+ video_output, # 1
1559
+ comparison_output, # 2
1560
+ team_heatmaps_output, # 3
1561
+ individual_heatmaps_output, # 4
1562
+ radar_output, # 5
1563
+ status_output, # 6
1564
+ player_stats_output, # 7
1565
+ events_output, # 8
1566
+ events_json_output, # 9
1567
  ],
1568
  )
1569
 
1570
+ gr.Markdown(
1571
+ """
1572
+ ---
1573
+ ### πŸ”§ Technical Details:
1574
 
1575
+ **Detection Models:**
1576
+ - Player/Ball/Referee Detection: `football-players-detection-3zvbc/11`
1577
+ - Field Keypoint Detection: `football-field-detection-f07vi/14`
1578
 
1579
+ **Tracking & Classification:**
1580
+ - ByteTrack for persistent player IDs
1581
+ - SigLIP embeddings for team classification
1582
+ - Majority voting for stable team assignments
1583
 
1584
+ **Performance Metrics:**
1585
+ - Distance covered (meters)
1586
+ - Average & maximum speed (km/h)
1587
+ - Zone activity (defensive/middle/attacking thirds)
1588
+ - Position heatmaps with Gaussian smoothing
1589
+ - Possession per player & per team
1590
 
1591
+ **Ball Tracking:**
1592
+ - Field homography transformation
1593
+ - Outlier removal (500 cm threshold)
1594
+ - Transformation matrix smoothing (5-frame window)
1595
+
1596
+ **Events:**
1597
+ - Passes, tackles, interceptions, shots, clearances
1598
+ - Event banner overlay in video
1599
+ - Full event list downloadable as JSON
1600
+ """
1601
  )
1602
 
1603
+
 
 
1604
  if __name__ == "__main__":
1605
+ iface.launch()