Update app.py
Browse files
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
|
| 597 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
try:
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
}
|
| 689 |
-
|
| 690 |
-
#
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
except Exception as e:
|
|
|
|
|
|
|
| 704 |
import traceback
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
|
|
|
| 718 |
|
| 719 |
|
| 720 |
# ==============================================
|
| 721 |
-
# GRADIO INTERFACE
|
| 722 |
# ==============================================
|
|
|
|
| 723 |
def run_pipeline(video) -> Tuple:
|
| 724 |
"""
|
| 725 |
-
Gradio wrapper
|
|
|
|
| 726 |
"""
|
| 727 |
if video is None:
|
| 728 |
return (
|
| 729 |
-
None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
"β Please upload a video file.",
|
| 731 |
-
[],
|
|
|
|
|
|
|
| 732 |
)
|
| 733 |
|
|
|
|
| 734 |
if isinstance(video, dict):
|
| 735 |
-
video_path =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
else:
|
|
|
|
| 737 |
video_path = str(video)
|
| 738 |
|
| 739 |
if not video_path:
|
| 740 |
return (
|
| 741 |
-
None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
"β Could not resolve video file path from upload.",
|
| 743 |
-
[],
|
|
|
|
|
|
|
| 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
|
| 761 |
-
1. **Player Detection** -
|
| 762 |
-
2. **Team Classification** - SigLIP-based team classifier
|
| 763 |
-
3. **Persistent Tracking** - ByteTrack with ID
|
| 764 |
-
4. **Field Transformation** -
|
| 765 |
-
5. **Ball Trajectory** -
|
| 766 |
-
6. **Performance Analytics** - Heatmaps, stats, possession,
|
| 767 |
|
| 768 |
Upload a football match video to get comprehensive performance analytics!
|
| 769 |
"""
|
| 770 |
)
|
| 771 |
|
| 772 |
with gr.Row():
|
| 773 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
|
| 841 |
-
|
| 842 |
-
|
|
|
|
| 843 |
|
| 844 |
-
|
| 845 |
-
|
|
|
|
|
|
|
| 846 |
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
|
|
|
| 863 |
)
|
| 864 |
|
| 865 |
-
|
| 866 |
-
# LAUNCH BOTH INTERFACES
|
| 867 |
-
# ==============================================
|
| 868 |
if __name__ == "__main__":
|
| 869 |
-
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|