Michael Paonam commited on
Commit ·
7c06ee6
1
Parent(s): cb414cf
Update app to use mounted bucket at /data, add league stats to Compare tab
Browse files- Read data from /data (HF bucket mount) when available, fallback to ./data
- Add xG/PPDA/form comparison in Compare Teams tab
- Add league stats to VLM prediction context
- Remove bundled data files (now served from bucket)
- Add openai, pillow, numpy to requirements
- app.py +869 -54
- data/demo_matches.json +0 -177
- data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_000.jpg +0 -3
- data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_003.jpg +0 -3
- data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_001.jpg +0 -3
- data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_005.jpg +0 -3
- data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_001.jpg +0 -3
- data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_005.jpg +0 -3
- data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_000.jpg +0 -3
- data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_006.jpg +0 -3
- data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_002.jpg +0 -0
- data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_005.jpg +0 -0
- data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_002.jpg +0 -3
- data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_006.jpg +0 -3
- data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_003.jpg +0 -3
- data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_005.jpg +0 -3
- data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_002.jpg +0 -0
- data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_003.jpg +0 -3
- data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_000.jpg +0 -0
- data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_001.jpg +0 -3
- data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_001.jpg +0 -0
- data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_003.jpg +0 -3
- data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_002.jpg +0 -3
- data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_005.jpg +0 -3
- data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_000.jpg +0 -3
- data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_001.jpg +0 -3
- data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_001.jpg +0 -3
- data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_004.jpg +0 -3
- data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_000.jpg +0 -3
- data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_002.jpg +0 -3
- data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_002.jpg +0 -3
- data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_004.jpg +0 -3
- data/vlm_results/results.json +0 -481
- requirements.txt +3 -0
app.py
CHANGED
|
@@ -4,16 +4,34 @@ Gradio app displaying pre-computed Qwen-VL 72B tactical assessments
|
|
| 4 |
of UEFA Champions League matches on AMD MI300X.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
import json
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
import gradio as gr
|
| 11 |
import plotly.graph_objects as go
|
| 12 |
|
| 13 |
APP_DIR = Path(__file__).resolve().parent
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def load_results():
|
|
@@ -22,11 +40,30 @@ def load_results():
|
|
| 22 |
with open(DEMO_PATH) as f:
|
| 23 |
demos = json.load(f)
|
| 24 |
demo_lookup = {d["match_id"]: d for d in demos}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
for m in results["matches"]:
|
| 26 |
demo = demo_lookup.get(m["match_id"], {})
|
| 27 |
m["first_leg"] = demo.get("first_leg", "")
|
| 28 |
m["odds"] = demo.get("odds", {})
|
| 29 |
m["narrative"] = demo.get("narrative", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return results
|
| 31 |
|
| 32 |
|
|
@@ -99,6 +136,145 @@ def make_prob_chart(match):
|
|
| 99 |
return fig
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def get_frame_images(match):
|
| 103 |
images = []
|
| 104 |
for fp in match.get("frames_used", []):
|
|
@@ -111,6 +287,281 @@ def get_frame_images(match):
|
|
| 111 |
return images
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def format_edge_badge(match):
|
| 115 |
edge = match["vlm_assessment"]["edge"]
|
| 116 |
actual = result_key(match["actual_result"])
|
|
@@ -119,26 +570,28 @@ def format_edge_badge(match):
|
|
| 119 |
|
| 120 |
correct = best_outcome == actual
|
| 121 |
outcome_label = {"home": match["home_team"], "draw": "Draw", "away": match["away_team"]}
|
| 122 |
-
badge = f"
|
| 123 |
|
| 124 |
if correct:
|
| 125 |
-
return f"##
|
| 126 |
else:
|
| 127 |
-
return f"##
|
| 128 |
|
| 129 |
|
| 130 |
def format_reasoning(match):
|
| 131 |
a = match["vlm_assessment"]
|
| 132 |
lines = []
|
| 133 |
-
lines.append(f"
|
| 134 |
lines.append("")
|
| 135 |
-
lines.append(f"
|
|
|
|
| 136 |
lines.append("")
|
| 137 |
-
lines.append("
|
| 138 |
for ev in a.get("visual_evidence", []):
|
| 139 |
lines.append(f"- {ev}")
|
| 140 |
lines.append("")
|
| 141 |
-
lines.append(f"
|
|
|
|
| 142 |
return "\n".join(lines)
|
| 143 |
|
| 144 |
|
|
@@ -166,6 +619,35 @@ def format_metrics(match):
|
|
| 166 |
return "\n".join(lines)
|
| 167 |
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
def format_stats(match):
|
| 170 |
stats = match.get("stats", {})
|
| 171 |
lines = []
|
|
@@ -186,9 +668,30 @@ def format_stats(match):
|
|
| 186 |
return "\n".join(lines)
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
def format_match_info(match):
|
| 190 |
lines = []
|
| 191 |
-
lines.append(f"
|
| 192 |
lines.append(f"- Stage: {match['stage']}")
|
| 193 |
lines.append(f"- Date: {match['date']}")
|
| 194 |
if match.get("first_leg"):
|
|
@@ -208,76 +711,388 @@ def on_match_select(choice):
|
|
| 208 |
match = MATCHES[idx]
|
| 209 |
|
| 210 |
chart = make_prob_chart(match)
|
|
|
|
| 211 |
frames = get_frame_images(match)
|
| 212 |
edge_text = format_edge_badge(match)
|
| 213 |
reasoning_text = format_reasoning(match)
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
| 216 |
info_text = format_match_info(match)
|
| 217 |
|
| 218 |
-
return chart, frames, edge_text, reasoning_text,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
|
| 221 |
correct, total = get_scorecard()
|
|
|
|
| 222 |
|
| 223 |
-
with gr.Blocks(
|
|
|
|
|
|
|
| 224 |
gr.Markdown(f"""
|
| 225 |
# Offsides — Tactical Edge Detection
|
| 226 |
|
| 227 |
-
**Where the market gets it wrong.** Multimodal AI analyzes UEFA Champions League footage
|
| 228 |
|
| 229 |
**Scorecard: {correct}/{total} correct edge calls** | Model: {RESULTS['model']} | Generated: {RESULTS['generated_at'][:10]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
""")
|
| 231 |
|
| 232 |
-
with gr.
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
frame_gallery = gr.Gallery(
|
| 248 |
label="Annotated Frames (analyzed by VLM)",
|
| 249 |
columns=2,
|
| 250 |
-
height=
|
| 251 |
)
|
| 252 |
-
with gr.Accordion("Tactical Metrics", open=False):
|
| 253 |
-
metrics_box = gr.Markdown()
|
| 254 |
-
with gr.Accordion("Match Statistics", open=False):
|
| 255 |
-
stats_box = gr.Markdown()
|
| 256 |
-
|
| 257 |
-
with gr.Row():
|
| 258 |
-
info_box = gr.Markdown()
|
| 259 |
-
|
| 260 |
-
match_dropdown.change(
|
| 261 |
-
fn=on_match_select,
|
| 262 |
-
inputs=[match_dropdown],
|
| 263 |
-
outputs=[prob_chart, frame_gallery, edge_badge, reasoning_box, metrics_box, stats_box, info_box],
|
| 264 |
-
)
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
inputs=[match_dropdown],
|
| 269 |
-
outputs=[prob_chart, frame_gallery, edge_badge, reasoning_box, metrics_box, stats_box, info_box],
|
| 270 |
-
)
|
| 271 |
|
| 272 |
-
|
| 273 |
-
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
""")
|
| 280 |
|
| 281 |
|
| 282 |
if __name__ == "__main__":
|
| 283 |
-
demo.launch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
of UEFA Champions League matches on AMD MI300X.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import base64
|
| 8 |
import json
|
| 9 |
+
import os
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
import gradio as gr
|
| 13 |
import plotly.graph_objects as go
|
| 14 |
|
| 15 |
APP_DIR = Path(__file__).resolve().parent
|
| 16 |
+
|
| 17 |
+
# When running on HF Spaces with a mounted bucket, use /data directly
|
| 18 |
+
HF_BUCKET_MOUNT = Path("/data")
|
| 19 |
+
if HF_BUCKET_MOUNT.exists() and HF_BUCKET_MOUNT.is_dir():
|
| 20 |
+
DATA_DIR = HF_BUCKET_MOUNT
|
| 21 |
+
else:
|
| 22 |
+
DATA_DIR = APP_DIR / "data"
|
| 23 |
+
|
| 24 |
+
RESULTS_PATH = DATA_DIR / "vlm_results" / "results.json"
|
| 25 |
+
DEMO_PATH = DATA_DIR / "demo_matches.json"
|
| 26 |
+
FRAMES_DIR = DATA_DIR / "vlm_results" / "frames"
|
| 27 |
+
CLIPS_DIR = DATA_DIR / "vlm_results" / "clips"
|
| 28 |
+
INDEX_PATH = DATA_DIR / "frames_index.json"
|
| 29 |
+
ALL_FRAMES_DIR = DATA_DIR / "frames"
|
| 30 |
+
MATCH_STATS_PATH = DATA_DIR / "match_stats.json"
|
| 31 |
+
|
| 32 |
+
VLM_BASE_URL = os.environ.get("VLM_BASE_URL", "")
|
| 33 |
+
VLM_MODEL = os.environ.get("VLM_MODEL", "Qwen/Qwen2.5-VL-72B-Instruct")
|
| 34 |
+
VLM_API_KEY = os.environ.get("VLM_API_KEY", "EMPTY")
|
| 35 |
|
| 36 |
|
| 37 |
def load_results():
|
|
|
|
| 40 |
with open(DEMO_PATH) as f:
|
| 41 |
demos = json.load(f)
|
| 42 |
demo_lookup = {d["match_id"]: d for d in demos}
|
| 43 |
+
|
| 44 |
+
# Load league stats for all teams
|
| 45 |
+
team_stats = {}
|
| 46 |
+
if MATCH_STATS_PATH.exists():
|
| 47 |
+
with open(MATCH_STATS_PATH) as f:
|
| 48 |
+
ms = json.load(f)
|
| 49 |
+
team_stats = ms.get("team_stats", {})
|
| 50 |
+
|
| 51 |
for m in results["matches"]:
|
| 52 |
demo = demo_lookup.get(m["match_id"], {})
|
| 53 |
m["first_leg"] = demo.get("first_leg", "")
|
| 54 |
m["odds"] = demo.get("odds", {})
|
| 55 |
m["narrative"] = demo.get("narrative", "")
|
| 56 |
+
# Inject team stats if not already present
|
| 57 |
+
if not m.get("stats"):
|
| 58 |
+
home = m["home_team"]
|
| 59 |
+
away = m["away_team"]
|
| 60 |
+
stats = {}
|
| 61 |
+
if home in team_stats:
|
| 62 |
+
stats["home"] = {"team": home, **team_stats[home]}
|
| 63 |
+
if away in team_stats:
|
| 64 |
+
stats["away"] = {"team": away, **team_stats[away]}
|
| 65 |
+
if stats:
|
| 66 |
+
m["stats"] = stats
|
| 67 |
return results
|
| 68 |
|
| 69 |
|
|
|
|
| 136 |
return fig
|
| 137 |
|
| 138 |
|
| 139 |
+
def make_formation_plot(match):
|
| 140 |
+
"""Generate a pitch plot showing player positions from tactical keyframes."""
|
| 141 |
+
import numpy as np
|
| 142 |
+
|
| 143 |
+
home_team = match["home_team"]
|
| 144 |
+
away_team = match["away_team"]
|
| 145 |
+
match_id = match["match_id"]
|
| 146 |
+
|
| 147 |
+
# Find detection data
|
| 148 |
+
det_path = ALL_FRAMES_DIR / match_id / "detections.json"
|
| 149 |
+
if not det_path.exists():
|
| 150 |
+
# Try finding from metrics_context
|
| 151 |
+
ctx = match.get("metrics_context", {})
|
| 152 |
+
for side in ["home", "away"]:
|
| 153 |
+
analyzed = ctx.get(side, {}).get("matches_analyzed", [])
|
| 154 |
+
for m in analyzed:
|
| 155 |
+
p = ALL_FRAMES_DIR / m / "detections.json"
|
| 156 |
+
if p.exists():
|
| 157 |
+
det_path = p
|
| 158 |
+
break
|
| 159 |
+
if det_path.exists():
|
| 160 |
+
break
|
| 161 |
+
|
| 162 |
+
if not det_path.exists():
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
import json as _json
|
| 166 |
+
with open(det_path) as f:
|
| 167 |
+
detections = _json.load(f)
|
| 168 |
+
|
| 169 |
+
tactical = detections.get("tactical_keyframes", [])
|
| 170 |
+
if not tactical:
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
# Use the first tactical keyframe (most players visible)
|
| 174 |
+
best_frame = None
|
| 175 |
+
best_count = 0
|
| 176 |
+
for kf_name in tactical:
|
| 177 |
+
kf_data = detections["keyframes"].get(kf_name, {})
|
| 178 |
+
count = len(kf_data.get("players", []))
|
| 179 |
+
if count > best_count:
|
| 180 |
+
best_count = count
|
| 181 |
+
best_frame = kf_name
|
| 182 |
+
best_data = kf_data
|
| 183 |
+
|
| 184 |
+
if best_frame is None or best_count < 8:
|
| 185 |
+
return None
|
| 186 |
+
|
| 187 |
+
players = best_data["players"]
|
| 188 |
+
centers = []
|
| 189 |
+
for p in players:
|
| 190 |
+
bbox = p["bbox"]
|
| 191 |
+
cx = (bbox[0] + bbox[2]) / 2
|
| 192 |
+
cy = (bbox[1] + bbox[3]) / 2
|
| 193 |
+
centers.append([cx, cy])
|
| 194 |
+
|
| 195 |
+
centers = np.array(centers)
|
| 196 |
+
|
| 197 |
+
# Normalize to pitch coordinates (0-105 x 0-68)
|
| 198 |
+
x_min, x_max = centers[:, 0].min(), centers[:, 0].max()
|
| 199 |
+
y_min, y_max = centers[:, 1].min(), centers[:, 1].max()
|
| 200 |
+
x_range = x_max - x_min if x_max - x_min > 0 else 1
|
| 201 |
+
y_range = y_max - y_min if y_max - y_min > 0 else 1
|
| 202 |
+
|
| 203 |
+
pitch_x = (centers[:, 0] - x_min) / x_range * 100 + 2.5
|
| 204 |
+
pitch_y = (centers[:, 1] - y_min) / y_range * 64 + 2
|
| 205 |
+
|
| 206 |
+
# KMeans to split into two teams
|
| 207 |
+
from sklearn.cluster import KMeans
|
| 208 |
+
kmeans = KMeans(n_clusters=2, random_state=0, n_init=10).fit(centers)
|
| 209 |
+
labels = kmeans.labels_
|
| 210 |
+
|
| 211 |
+
# Left cluster = home, right = away
|
| 212 |
+
avg_x_0 = pitch_x[labels == 0].mean()
|
| 213 |
+
avg_x_1 = pitch_x[labels == 1].mean()
|
| 214 |
+
home_cluster = 0 if avg_x_0 < avg_x_1 else 1
|
| 215 |
+
|
| 216 |
+
home_x = pitch_x[labels == home_cluster]
|
| 217 |
+
home_y = pitch_y[labels == home_cluster]
|
| 218 |
+
away_x = pitch_x[labels != home_cluster]
|
| 219 |
+
away_y = pitch_y[labels != home_cluster]
|
| 220 |
+
|
| 221 |
+
# Build Plotly pitch figure
|
| 222 |
+
fig = go.Figure()
|
| 223 |
+
|
| 224 |
+
# Pitch outline
|
| 225 |
+
pitch_shapes = [
|
| 226 |
+
dict(type="rect", x0=0, y0=0, x1=105, y1=68, line=dict(color="#555", width=2)),
|
| 227 |
+
dict(type="line", x0=52.5, y0=0, x1=52.5, y1=68, line=dict(color="#555", width=1)),
|
| 228 |
+
dict(type="circle", x0=52.5-9.15, y0=34-9.15, x1=52.5+9.15, y1=34+9.15, line=dict(color="#555", width=1)),
|
| 229 |
+
# Penalty areas
|
| 230 |
+
dict(type="rect", x0=0, y0=13.84, x1=16.5, y1=54.16, line=dict(color="#555", width=1)),
|
| 231 |
+
dict(type="rect", x0=88.5, y0=13.84, x1=105, y1=54.16, line=dict(color="#555", width=1)),
|
| 232 |
+
# Goal areas
|
| 233 |
+
dict(type="rect", x0=0, y0=24.84, x1=5.5, y1=43.16, line=dict(color="#555", width=1)),
|
| 234 |
+
dict(type="rect", x0=99.5, y0=24.84, x1=105, y1=43.16, line=dict(color="#555", width=1)),
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
fig.add_trace(go.Scatter(
|
| 238 |
+
x=home_x, y=home_y, mode="markers",
|
| 239 |
+
marker=dict(size=14, color="#3b82f6", line=dict(width=2, color="white")),
|
| 240 |
+
name=home_team,
|
| 241 |
+
))
|
| 242 |
+
fig.add_trace(go.Scatter(
|
| 243 |
+
x=away_x, y=away_y, mode="markers",
|
| 244 |
+
marker=dict(size=14, color="#ef4444", line=dict(width=2, color="white")),
|
| 245 |
+
name=away_team,
|
| 246 |
+
))
|
| 247 |
+
|
| 248 |
+
# Ball position
|
| 249 |
+
ball = best_data.get("ball")
|
| 250 |
+
if ball:
|
| 251 |
+
ball_cx = (ball["bbox"][0] + ball["bbox"][2]) / 2
|
| 252 |
+
ball_cy = (ball["bbox"][1] + ball["bbox"][3]) / 2
|
| 253 |
+
ball_px = (ball_cx - x_min) / x_range * 100 + 2.5
|
| 254 |
+
ball_py = (ball_cy - y_min) / y_range * 64 + 2
|
| 255 |
+
fig.add_trace(go.Scatter(
|
| 256 |
+
x=[ball_px], y=[ball_py], mode="markers",
|
| 257 |
+
marker=dict(size=10, color="#fbbf24", symbol="circle",
|
| 258 |
+
line=dict(width=2, color="white")),
|
| 259 |
+
name="Ball",
|
| 260 |
+
))
|
| 261 |
+
|
| 262 |
+
fig.update_layout(
|
| 263 |
+
plot_bgcolor="#1a1a1a",
|
| 264 |
+
paper_bgcolor="#111111",
|
| 265 |
+
font_color="white",
|
| 266 |
+
shapes=pitch_shapes,
|
| 267 |
+
xaxis=dict(range=[-2, 107], showgrid=False, zeroline=False, showticklabels=False),
|
| 268 |
+
yaxis=dict(range=[-2, 70], showgrid=False, zeroline=False, showticklabels=False, scaleanchor="x"),
|
| 269 |
+
margin=dict(l=10, r=10, t=40, b=10),
|
| 270 |
+
height=350,
|
| 271 |
+
title=dict(text=f"Formation — {best_frame}", font=dict(size=13)),
|
| 272 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
return fig
|
| 276 |
+
|
| 277 |
+
|
| 278 |
def get_frame_images(match):
|
| 279 |
images = []
|
| 280 |
for fp in match.get("frames_used", []):
|
|
|
|
| 287 |
return images
|
| 288 |
|
| 289 |
|
| 290 |
+
def get_match_clips(match):
|
| 291 |
+
"""Get annotated video clips for a match's source matches."""
|
| 292 |
+
clips = []
|
| 293 |
+
for fp in match.get("frames_used", []):
|
| 294 |
+
parts = Path(fp).parts
|
| 295 |
+
if len(parts) >= 3:
|
| 296 |
+
match_dir = parts[2]
|
| 297 |
+
clip_dir = CLIPS_DIR / match_dir
|
| 298 |
+
if clip_dir.exists():
|
| 299 |
+
for mp4 in sorted(clip_dir.glob("*.mp4")):
|
| 300 |
+
if str(mp4) not in clips:
|
| 301 |
+
clips.append(str(mp4))
|
| 302 |
+
return clips
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def load_frame_index():
|
| 306 |
+
"""Load the pre-built frame index for team comparison."""
|
| 307 |
+
if not INDEX_PATH.exists():
|
| 308 |
+
return {"teams": [], "matches": {}}
|
| 309 |
+
with open(INDEX_PATH) as f:
|
| 310 |
+
return json.load(f)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
FRAME_INDEX = load_frame_index()
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def get_team_list():
|
| 317 |
+
"""Return sorted list of team names formatted for display."""
|
| 318 |
+
return [t.replace("_", " ") for t in FRAME_INDEX.get("teams", [])]
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def get_team_form(team: str, n: int = 3) -> tuple[list[str], dict]:
|
| 322 |
+
"""Get last N matches for a team: frames + averaged metrics."""
|
| 323 |
+
team_pat = team.replace(" ", "_")
|
| 324 |
+
team_matches = []
|
| 325 |
+
for match_name, data in FRAME_INDEX.get("matches", {}).items():
|
| 326 |
+
if team_pat in (data["home"], data["away"]):
|
| 327 |
+
team_matches.append((match_name, data))
|
| 328 |
+
|
| 329 |
+
team_matches.sort(key=lambda x: x[1]["date"], reverse=True)
|
| 330 |
+
team_matches = team_matches[:n]
|
| 331 |
+
|
| 332 |
+
frames = []
|
| 333 |
+
metrics_list = []
|
| 334 |
+
for match_name, data in team_matches:
|
| 335 |
+
ann_dir = ALL_FRAMES_DIR / match_name / "annotated"
|
| 336 |
+
for fname in data["frames"][:2]:
|
| 337 |
+
fpath = ann_dir / fname
|
| 338 |
+
if fpath.exists():
|
| 339 |
+
frames.append(str(fpath))
|
| 340 |
+
if data.get("metrics"):
|
| 341 |
+
metrics_list.append(data["metrics"])
|
| 342 |
+
|
| 343 |
+
avg_metrics = {}
|
| 344 |
+
if metrics_list:
|
| 345 |
+
keys = ["avg_pressing_speed", "avg_def_line_movement", "avg_compactness_delta", "avg_transition_speed"]
|
| 346 |
+
for key in keys:
|
| 347 |
+
values = [m[key] for m in metrics_list if key in m]
|
| 348 |
+
if values:
|
| 349 |
+
avg_metrics[key] = round(sum(values) / len(values), 4)
|
| 350 |
+
|
| 351 |
+
return frames, avg_metrics
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def get_h2h(team_a: str, team_b: str) -> tuple[list[str], dict]:
|
| 355 |
+
"""Get head-to-head frames and metrics between two teams."""
|
| 356 |
+
pat_a = team_a.replace(" ", "_")
|
| 357 |
+
pat_b = team_b.replace(" ", "_")
|
| 358 |
+
h2h_matches = []
|
| 359 |
+
|
| 360 |
+
for match_name, data in FRAME_INDEX.get("matches", {}).items():
|
| 361 |
+
if pat_a in (data["home"], data["away"]) and pat_b in (data["home"], data["away"]):
|
| 362 |
+
h2h_matches.append((match_name, data))
|
| 363 |
+
|
| 364 |
+
h2h_matches.sort(key=lambda x: x[1]["date"], reverse=True)
|
| 365 |
+
|
| 366 |
+
frames = []
|
| 367 |
+
metrics_list = []
|
| 368 |
+
for match_name, data in h2h_matches[:3]:
|
| 369 |
+
ann_dir = ALL_FRAMES_DIR / match_name / "annotated"
|
| 370 |
+
for fname in data["frames"][:2]:
|
| 371 |
+
fpath = ann_dir / fname
|
| 372 |
+
if fpath.exists():
|
| 373 |
+
frames.append(str(fpath))
|
| 374 |
+
if data.get("metrics"):
|
| 375 |
+
metrics_list.append(data["metrics"])
|
| 376 |
+
|
| 377 |
+
avg_metrics = {}
|
| 378 |
+
if metrics_list:
|
| 379 |
+
keys = ["avg_pressing_speed", "avg_def_line_movement", "avg_compactness_delta", "avg_transition_speed"]
|
| 380 |
+
for key in keys:
|
| 381 |
+
values = [m[key] for m in metrics_list if key in m]
|
| 382 |
+
if values:
|
| 383 |
+
avg_metrics[key] = round(sum(values) / len(values), 4)
|
| 384 |
+
|
| 385 |
+
return frames, avg_metrics
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def format_metrics_md(metrics: dict, team_name: str) -> str:
|
| 389 |
+
"""Format metrics dict as markdown."""
|
| 390 |
+
if not metrics:
|
| 391 |
+
return f"*No metrics available for {team_name}*"
|
| 392 |
+
lines = [f"**{team_name}** (avg last 3 matches):"]
|
| 393 |
+
labels = {
|
| 394 |
+
"avg_pressing_speed": "Pressing Speed",
|
| 395 |
+
"avg_def_line_movement": "Defensive Line Movement",
|
| 396 |
+
"avg_compactness_delta": "Compactness Delta",
|
| 397 |
+
"avg_transition_speed": "Transition Speed",
|
| 398 |
+
}
|
| 399 |
+
for key, label in labels.items():
|
| 400 |
+
if key in metrics:
|
| 401 |
+
lines.append(f"- {label}: `{metrics[key]:.4f}`")
|
| 402 |
+
return "\n".join(lines)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def format_league_stats_compare(team_name: str, stats: dict) -> str:
|
| 406 |
+
"""Format league stats for a team in the Compare tab."""
|
| 407 |
+
if not stats:
|
| 408 |
+
return f"**{team_name}**\n\n*No league stats available*"
|
| 409 |
+
lines = [f"**{team_name}**", ""]
|
| 410 |
+
lines.append("| Metric | Value |")
|
| 411 |
+
lines.append("|--------|-------|")
|
| 412 |
+
if stats.get("xg_last5") is not None:
|
| 413 |
+
lines.append(f"| xG — Expected Goals/match | {stats['xg_last5']} |")
|
| 414 |
+
if stats.get("xga_last5") is not None:
|
| 415 |
+
lines.append(f"| xGA — Expected Goals Against/match | {stats['xga_last5']} |")
|
| 416 |
+
if stats.get("ppda") is not None:
|
| 417 |
+
lines.append(f"| PPDA — Passes Per Defensive Action | {stats['ppda']} |")
|
| 418 |
+
if stats.get("possession_pct") is not None:
|
| 419 |
+
lines.append(f"| Possession | {stats['possession_pct']}% |")
|
| 420 |
+
if stats.get("form") is not None:
|
| 421 |
+
lines.append(f"| Form (last 5) | {stats['form']} |")
|
| 422 |
+
if stats.get("goals_scored_last5") is not None:
|
| 423 |
+
lines.append(f"| Goals (last 5) | {stats['goals_scored_last5']}F / {stats.get('goals_conceded_last5', '-')}A |")
|
| 424 |
+
return "\n".join(lines)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def compare_teams(team_a: str, team_b: str):
|
| 428 |
+
"""Main comparison function — returns all outputs for the Compare tab."""
|
| 429 |
+
if not team_a or not team_b:
|
| 430 |
+
empty = [], "", [], "", [], "", "", ""
|
| 431 |
+
return empty
|
| 432 |
+
|
| 433 |
+
frames_a, metrics_a = get_team_form(team_a)
|
| 434 |
+
frames_b, metrics_b = get_team_form(team_b)
|
| 435 |
+
h2h_frames, h2h_metrics = get_h2h(team_a, team_b)
|
| 436 |
+
|
| 437 |
+
metrics_a_md = format_metrics_md(metrics_a, team_a)
|
| 438 |
+
metrics_b_md = format_metrics_md(metrics_b, team_b)
|
| 439 |
+
|
| 440 |
+
if h2h_frames:
|
| 441 |
+
h2h_md = f"**{len(h2h_frames)//2} prior matchups found**\n\n" + format_metrics_md(h2h_metrics, f"{team_a} vs {team_b} H2H")
|
| 442 |
+
else:
|
| 443 |
+
h2h_md = f"*No head-to-head matches found between {team_a} and {team_b} in the dataset.*"
|
| 444 |
+
|
| 445 |
+
# League stats
|
| 446 |
+
league_stats = {}
|
| 447 |
+
if MATCH_STATS_PATH.exists():
|
| 448 |
+
with open(MATCH_STATS_PATH) as f:
|
| 449 |
+
ms = json.load(f)
|
| 450 |
+
league_stats = ms.get("team_stats", {})
|
| 451 |
+
|
| 452 |
+
stats_a_md = format_league_stats_compare(team_a, league_stats.get(team_a, {}))
|
| 453 |
+
stats_b_md = format_league_stats_compare(team_b, league_stats.get(team_b, {}))
|
| 454 |
+
|
| 455 |
+
return frames_a, metrics_a_md, frames_b, metrics_b_md, h2h_frames, h2h_md, stats_a_md, stats_b_md
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def predict_matchup(team_a: str, team_b: str):
|
| 459 |
+
"""Run live VLM inference on a custom matchup."""
|
| 460 |
+
if not VLM_BASE_URL:
|
| 461 |
+
return "**GPU Offline** — Connect AMD MI300X to enable live predictions. Set `VLM_BASE_URL` as a Space secret."
|
| 462 |
+
|
| 463 |
+
if not team_a or not team_b or team_a == team_b:
|
| 464 |
+
return "Select two different teams to predict."
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
from openai import OpenAI
|
| 468 |
+
|
| 469 |
+
# Load league stats for context
|
| 470 |
+
league_stats = {}
|
| 471 |
+
if MATCH_STATS_PATH.exists():
|
| 472 |
+
with open(MATCH_STATS_PATH) as f:
|
| 473 |
+
ms = json.load(f)
|
| 474 |
+
league_stats = ms.get("team_stats", {})
|
| 475 |
+
|
| 476 |
+
frames_a, metrics_a = get_team_form(team_a)
|
| 477 |
+
frames_b, metrics_b = get_team_form(team_b)
|
| 478 |
+
h2h_frames, h2h_metrics = get_h2h(team_a, team_b)
|
| 479 |
+
|
| 480 |
+
content = []
|
| 481 |
+
if frames_a:
|
| 482 |
+
content.append({"type": "text", "text": f"--- {team_a.upper()} RECENT FORM ---"})
|
| 483 |
+
for fp in frames_a[:4]:
|
| 484 |
+
b64 = encode_frame(fp)
|
| 485 |
+
if b64:
|
| 486 |
+
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
|
| 487 |
+
|
| 488 |
+
if frames_b:
|
| 489 |
+
content.append({"type": "text", "text": f"--- {team_b.upper()} RECENT FORM ---"})
|
| 490 |
+
for fp in frames_b[:4]:
|
| 491 |
+
b64 = encode_frame(fp)
|
| 492 |
+
if b64:
|
| 493 |
+
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
|
| 494 |
+
|
| 495 |
+
if h2h_frames:
|
| 496 |
+
content.append({"type": "text", "text": f"--- HEAD-TO-HEAD ---"})
|
| 497 |
+
for fp in h2h_frames[:4]:
|
| 498 |
+
b64 = encode_frame(fp)
|
| 499 |
+
if b64:
|
| 500 |
+
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
|
| 501 |
+
|
| 502 |
+
context_lines = [
|
| 503 |
+
f"=== MATCHUP: {team_a} vs {team_b} ===",
|
| 504 |
+
f"\n{team_a} tactical metrics (last 3 matches):",
|
| 505 |
+
]
|
| 506 |
+
for k, v in metrics_a.items():
|
| 507 |
+
context_lines.append(f" {k}: {v}")
|
| 508 |
+
context_lines.append(f"\n{team_b} tactical metrics (last 3 matches):")
|
| 509 |
+
for k, v in metrics_b.items():
|
| 510 |
+
context_lines.append(f" {k}: {v}")
|
| 511 |
+
if h2h_metrics:
|
| 512 |
+
context_lines.append(f"\nHead-to-head metrics:")
|
| 513 |
+
for k, v in h2h_metrics.items():
|
| 514 |
+
context_lines.append(f" {k}: {v}")
|
| 515 |
+
|
| 516 |
+
# Add league stats (xG, PPDA, possession, form)
|
| 517 |
+
stat_labels = {
|
| 518 |
+
"xg_last5": "Expected Goals (xG, last 5)",
|
| 519 |
+
"xga_last5": "Expected Goals Against (xGA, last 5)",
|
| 520 |
+
"ppda": "Passes Per Defensive Action (PPDA)",
|
| 521 |
+
"possession_pct": "Possession %",
|
| 522 |
+
"form": "Recent Form (last 5)",
|
| 523 |
+
"goals_scored_last5": "Goals Scored (last 5)",
|
| 524 |
+
"goals_conceded_last5": "Goals Conceded (last 5)",
|
| 525 |
+
}
|
| 526 |
+
for team_name, team_key in [(team_a, team_a), (team_b, team_b)]:
|
| 527 |
+
if team_key in league_stats:
|
| 528 |
+
context_lines.append(f"\n{team_name} league statistics:")
|
| 529 |
+
for stat_key, label in stat_labels.items():
|
| 530 |
+
val = league_stats[team_key].get(stat_key)
|
| 531 |
+
if val is not None:
|
| 532 |
+
context_lines.append(f" {label}: {val}")
|
| 533 |
+
|
| 534 |
+
content.append({"type": "text", "text": "\n".join(context_lines)})
|
| 535 |
+
content.append({"type": "text", "text": (
|
| 536 |
+
f"Based on {team_a}'s recent form, {team_b}'s recent form, and their head-to-head history, "
|
| 537 |
+
f"which team has the tactical advantage? Provide your assessment as: "
|
| 538 |
+
f"probabilities (home/draw/away), confidence, and 2-3 sentence reasoning."
|
| 539 |
+
)})
|
| 540 |
+
|
| 541 |
+
system_msg = (
|
| 542 |
+
"You are a tactical football analyst. Analyze the annotated frames showing "
|
| 543 |
+
"player positions, defensive lines, and team compactness. Also consider the "
|
| 544 |
+
"league statistics (xG, PPDA, possession, form) to assess underlying quality. "
|
| 545 |
+
"Compare the tactical patterns and statistical profiles of both teams to assess "
|
| 546 |
+
"who has the advantage."
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
client = OpenAI(base_url=VLM_BASE_URL, api_key=VLM_API_KEY)
|
| 550 |
+
response = client.chat.completions.create(
|
| 551 |
+
model=VLM_MODEL,
|
| 552 |
+
messages=[
|
| 553 |
+
{"role": "system", "content": system_msg},
|
| 554 |
+
{"role": "user", "content": content},
|
| 555 |
+
],
|
| 556 |
+
max_tokens=512,
|
| 557 |
+
temperature=0.3,
|
| 558 |
+
)
|
| 559 |
+
return f"**VLM Prediction ({VLM_MODEL}):**\n\n{response.choices[0].message.content}"
|
| 560 |
+
|
| 561 |
+
except Exception as e:
|
| 562 |
+
return f"**Error:** {str(e)}"
|
| 563 |
+
|
| 564 |
+
|
| 565 |
def format_edge_badge(match):
|
| 566 |
edge = match["vlm_assessment"]["edge"]
|
| 567 |
actual = result_key(match["actual_result"])
|
|
|
|
| 570 |
|
| 571 |
correct = best_outcome == actual
|
| 572 |
outcome_label = {"home": match["home_team"], "draw": "Draw", "away": match["away_team"]}
|
| 573 |
+
badge = f"Edge: +{best_val*100:.0f}pp on {outcome_label[best_outcome]}"
|
| 574 |
|
| 575 |
if correct:
|
| 576 |
+
return f"## {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')}) — CORRECT"
|
| 577 |
else:
|
| 578 |
+
return f"## {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')})"
|
| 579 |
|
| 580 |
|
| 581 |
def format_reasoning(match):
|
| 582 |
a = match["vlm_assessment"]
|
| 583 |
lines = []
|
| 584 |
+
lines.append(f"### Confidence: {a['confidence']}")
|
| 585 |
lines.append("")
|
| 586 |
+
lines.append(f"### Reasoning")
|
| 587 |
+
lines.append(a['reasoning'])
|
| 588 |
lines.append("")
|
| 589 |
+
lines.append("### Visual Evidence")
|
| 590 |
for ev in a.get("visual_evidence", []):
|
| 591 |
lines.append(f"- {ev}")
|
| 592 |
lines.append("")
|
| 593 |
+
lines.append(f"### Edge Signal")
|
| 594 |
+
lines.append(a['edge_signal'])
|
| 595 |
return "\n".join(lines)
|
| 596 |
|
| 597 |
|
|
|
|
| 619 |
return "\n".join(lines)
|
| 620 |
|
| 621 |
|
| 622 |
+
def format_metrics_side(match, side):
|
| 623 |
+
ctx = match.get("metrics_context", {})
|
| 624 |
+
data = ctx.get(side, {})
|
| 625 |
+
metrics = data.get("metrics", {})
|
| 626 |
+
label = match["home_team"] if side == "home" else match["away_team"]
|
| 627 |
+
lines = []
|
| 628 |
+
lines.append(f"**{label}** (last 3 matches):")
|
| 629 |
+
lines.append("")
|
| 630 |
+
matches_analyzed = data.get("matches_analyzed", [])
|
| 631 |
+
if matches_analyzed:
|
| 632 |
+
lines.append(f"| Metric | Value |")
|
| 633 |
+
lines.append(f"|--------|-------|")
|
| 634 |
+
if "avg_pressing_speed" in metrics:
|
| 635 |
+
lines.append(f"| Pressing Speed | {metrics['avg_pressing_speed']:.4f} |")
|
| 636 |
+
if "avg_def_line_movement" in metrics:
|
| 637 |
+
lines.append(f"| Defensive Line Movement | {metrics['avg_def_line_movement']:.4f} |")
|
| 638 |
+
if "avg_compactness_delta" in metrics:
|
| 639 |
+
lines.append(f"| Compactness Delta | {metrics['avg_compactness_delta']:.3f} |")
|
| 640 |
+
if "avg_transition_speed" in metrics:
|
| 641 |
+
lines.append(f"| Transition Speed | {metrics['avg_transition_speed']:.4f} |")
|
| 642 |
+
lines.append("")
|
| 643 |
+
lines.append("*Matches analyzed:*")
|
| 644 |
+
for m in matches_analyzed:
|
| 645 |
+
lines.append(f"- {m.replace('_', ' ')}")
|
| 646 |
+
else:
|
| 647 |
+
lines.append("*No tactical data available*")
|
| 648 |
+
return "\n".join(lines)
|
| 649 |
+
|
| 650 |
+
|
| 651 |
def format_stats(match):
|
| 652 |
stats = match.get("stats", {})
|
| 653 |
lines = []
|
|
|
|
| 668 |
return "\n".join(lines)
|
| 669 |
|
| 670 |
|
| 671 |
+
def format_stats_side(match, side):
|
| 672 |
+
stats = match.get("stats", {})
|
| 673 |
+
s = stats.get(side, {})
|
| 674 |
+
label = match["home_team"] if side == "home" else match["away_team"]
|
| 675 |
+
lines = []
|
| 676 |
+
lines.append(f"**{label}:**")
|
| 677 |
+
lines.append("")
|
| 678 |
+
if not s:
|
| 679 |
+
lines.append("*No stats available*")
|
| 680 |
+
return "\n".join(lines)
|
| 681 |
+
lines.append(f"| Metric | Value |")
|
| 682 |
+
lines.append(f"|--------|-------|")
|
| 683 |
+
lines.append(f"| xG — Expected Goals/match | {s.get('xg_last5', '-')} |")
|
| 684 |
+
lines.append(f"| xGA — Expected Goals Against/match | {s.get('xga_last5', '-')} |")
|
| 685 |
+
lines.append(f"| PPDA — Passes Per Defensive Action | {s.get('ppda', '-')} |")
|
| 686 |
+
lines.append(f"| Possession | {s.get('possession_pct', '-')}% |")
|
| 687 |
+
lines.append(f"| Form (last 5) | {s.get('form', '-')} |")
|
| 688 |
+
lines.append(f"| Goals (last 5) | {s.get('goals_scored_last5', '-')}F / {s.get('goals_conceded_last5', '-')}A |")
|
| 689 |
+
return "\n".join(lines)
|
| 690 |
+
|
| 691 |
+
|
| 692 |
def format_match_info(match):
|
| 693 |
lines = []
|
| 694 |
+
lines.append(f"## {match['home_team']} vs {match['away_team']}")
|
| 695 |
lines.append(f"- Stage: {match['stage']}")
|
| 696 |
lines.append(f"- Date: {match['date']}")
|
| 697 |
if match.get("first_leg"):
|
|
|
|
| 711 |
match = MATCHES[idx]
|
| 712 |
|
| 713 |
chart = make_prob_chart(match)
|
| 714 |
+
formation = make_formation_plot(match)
|
| 715 |
frames = get_frame_images(match)
|
| 716 |
edge_text = format_edge_badge(match)
|
| 717 |
reasoning_text = format_reasoning(match)
|
| 718 |
+
metrics_home = format_metrics_side(match, "home")
|
| 719 |
+
metrics_away = format_metrics_side(match, "away")
|
| 720 |
+
stats_home = format_stats_side(match, "home")
|
| 721 |
+
stats_away = format_stats_side(match, "away")
|
| 722 |
info_text = format_match_info(match)
|
| 723 |
|
| 724 |
+
return chart, formation, frames, edge_text, reasoning_text, metrics_home, metrics_away, stats_home, stats_away, info_text
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def update_video_for_match(match_choice):
|
| 728 |
+
idx = get_match_choices().index(match_choice)
|
| 729 |
+
match = MATCHES[idx]
|
| 730 |
+
clips = get_match_clips(match)
|
| 731 |
+
labels = []
|
| 732 |
+
for clip in clips:
|
| 733 |
+
p = Path(clip)
|
| 734 |
+
match_name = p.parent.name.replace("_", " ").rsplit(" ", 1)[0]
|
| 735 |
+
seq = p.stem.replace("_", " ").title()
|
| 736 |
+
labels.append(f"{match_name} — {seq}")
|
| 737 |
+
first_clip = clips[0] if clips else None
|
| 738 |
+
info = f"**{len(clips)} clips** from recent matches of {match['home_team']} and {match['away_team']}" if clips else "No clips available."
|
| 739 |
+
return (
|
| 740 |
+
first_clip,
|
| 741 |
+
gr.update(choices=labels, value=labels[0] if labels else None),
|
| 742 |
+
info,
|
| 743 |
+
)
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def select_clip_for_match(clip_label, match_choice):
|
| 747 |
+
idx = get_match_choices().index(match_choice)
|
| 748 |
+
match = MATCHES[idx]
|
| 749 |
+
clips = get_match_clips(match)
|
| 750 |
+
labels = []
|
| 751 |
+
for clip in clips:
|
| 752 |
+
p = Path(clip)
|
| 753 |
+
match_name = p.parent.name.replace("_", " ").rsplit(" ", 1)[0]
|
| 754 |
+
seq = p.stem.replace("_", " ").title()
|
| 755 |
+
labels.append(f"{match_name} — {seq}")
|
| 756 |
+
if clip_label in labels:
|
| 757 |
+
return clips[labels.index(clip_label)]
|
| 758 |
+
return clips[0] if clips else None
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def build_live_context(match_idx: int) -> str:
|
| 762 |
+
match = MATCHES[match_idx]
|
| 763 |
+
lines = []
|
| 764 |
+
lines.append(f"Match: {match['home_team']} vs {match['away_team']} ({match['stage']}, {match['date']})")
|
| 765 |
+
market = match["market_odds"]
|
| 766 |
+
lines.append(f"Market implied: {match['home_team']} {market['home']*100:.0f}% / Draw {market['draw']*100:.0f}% / {match['away_team']} {market['away']*100:.0f}%")
|
| 767 |
+
stats = match.get("stats", {})
|
| 768 |
+
for side in ["home", "away"]:
|
| 769 |
+
s = stats.get(side, {})
|
| 770 |
+
if s:
|
| 771 |
+
lines.append(f"{s['team']}: xG={s.get('xg_last5')}, PPDA={s.get('ppda')}, Poss={s.get('possession_pct')}%, Form={s.get('form')}")
|
| 772 |
+
ctx = match.get("metrics_context", {})
|
| 773 |
+
for side in ["home", "away"]:
|
| 774 |
+
data = ctx.get(side, {})
|
| 775 |
+
metrics = data.get("metrics", {})
|
| 776 |
+
if metrics:
|
| 777 |
+
lines.append(f"{data.get('team', side)} tactical: pressing={metrics.get('avg_pressing_speed', 0):.4f}, def_line={metrics.get('avg_def_line_movement', 0):.4f}, compactness={metrics.get('avg_compactness_delta', 0):.3f}, transition={metrics.get('avg_transition_speed', 0):.4f}")
|
| 778 |
+
a = match["vlm_assessment"]
|
| 779 |
+
lines.append(f"VLM assessment: H={a['probabilities']['home']:.0%} D={a['probabilities']['draw']:.0%} A={a['probabilities']['away']:.0%}")
|
| 780 |
+
lines.append(f"Edge: {a['edge']}")
|
| 781 |
+
lines.append(f"Reasoning: {a['reasoning']}")
|
| 782 |
+
return "\n".join(lines)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def encode_frame(path: str, max_width: int = 512) -> str:
|
| 786 |
+
try:
|
| 787 |
+
import cv2
|
| 788 |
+
img = cv2.imread(path)
|
| 789 |
+
if img is None:
|
| 790 |
+
return ""
|
| 791 |
+
h, w = img.shape[:2]
|
| 792 |
+
if w > max_width:
|
| 793 |
+
scale = max_width / w
|
| 794 |
+
img = cv2.resize(img, (max_width, int(h * scale)))
|
| 795 |
+
_, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 70])
|
| 796 |
+
return base64.b64encode(buffer).decode("utf-8")
|
| 797 |
+
except ImportError:
|
| 798 |
+
with open(path, "rb") as f:
|
| 799 |
+
return base64.b64encode(f.read()).decode("utf-8")
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
def live_query(match_choice: str, user_question: str, history: list):
|
| 803 |
+
if not VLM_BASE_URL:
|
| 804 |
+
history.append({"role": "assistant", "content": "Live inference is not available — no VLM endpoint configured. Set VLM_BASE_URL as a Space secret."})
|
| 805 |
+
return history, history
|
| 806 |
+
|
| 807 |
+
if not user_question.strip():
|
| 808 |
+
return history, history
|
| 809 |
+
|
| 810 |
+
history.append({"role": "user", "content": user_question})
|
| 811 |
+
|
| 812 |
+
try:
|
| 813 |
+
from openai import OpenAI
|
| 814 |
+
|
| 815 |
+
idx = get_match_choices().index(match_choice)
|
| 816 |
+
match = MATCHES[idx]
|
| 817 |
+
context = build_live_context(idx)
|
| 818 |
+
|
| 819 |
+
frames = get_frame_images(match)
|
| 820 |
+
content = []
|
| 821 |
+
for frame_path in frames[:4]:
|
| 822 |
+
b64 = encode_frame(frame_path)
|
| 823 |
+
if b64:
|
| 824 |
+
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
|
| 825 |
+
|
| 826 |
+
content.append({"type": "text", "text": f"Match context:\n{context}\n\nUser question: {user_question}"})
|
| 827 |
+
|
| 828 |
+
system_msg = (
|
| 829 |
+
"You are a tactical football analyst for UEFA Champions League. "
|
| 830 |
+
"You have access to annotated match frames showing player positions (colored bounding boxes), "
|
| 831 |
+
"defensive lines, and compactness ellipses. You also have tactical metrics and match statistics. "
|
| 832 |
+
"Answer the user's question with specific references to what you observe in the frames and data. "
|
| 833 |
+
"Be concise but specific."
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
messages = [
|
| 837 |
+
{"role": "system", "content": system_msg},
|
| 838 |
+
{"role": "user", "content": content},
|
| 839 |
+
]
|
| 840 |
+
|
| 841 |
+
client = OpenAI(base_url=VLM_BASE_URL, api_key=VLM_API_KEY)
|
| 842 |
+
response = client.chat.completions.create(
|
| 843 |
+
model=VLM_MODEL,
|
| 844 |
+
messages=messages,
|
| 845 |
+
max_tokens=512,
|
| 846 |
+
temperature=0.3,
|
| 847 |
+
)
|
| 848 |
+
answer = response.choices[0].message.content
|
| 849 |
+
history.append({"role": "assistant", "content": answer})
|
| 850 |
+
|
| 851 |
+
except Exception as e:
|
| 852 |
+
history.append({"role": "assistant", "content": f"Error: {str(e)}"})
|
| 853 |
+
|
| 854 |
+
return history, history
|
| 855 |
|
| 856 |
|
| 857 |
correct, total = get_scorecard()
|
| 858 |
+
live_available = bool(VLM_BASE_URL)
|
| 859 |
|
| 860 |
+
with gr.Blocks(
|
| 861 |
+
title="Offsides — Tactical Edge Detection",
|
| 862 |
+
) as demo:
|
| 863 |
gr.Markdown(f"""
|
| 864 |
# Offsides — Tactical Edge Detection
|
| 865 |
|
| 866 |
+
**Where the market gets it wrong.** Multimodal AI analyzes UEFA Champions League footage to detect when prediction markets are mispriced.
|
| 867 |
|
| 868 |
**Scorecard: {correct}/{total} correct edge calls** | Model: {RESULTS['model']} | Generated: {RESULTS['generated_at'][:10]}
|
| 869 |
+
|
| 870 |
+
---
|
| 871 |
+
|
| 872 |
+
`YouTube Highlights` → `Frame Extraction` → `YOLO Detection (YOLOv8m)` → `Annotation (OpenCV)` → `Tactical Reasoning (Qwen-VL 72B)` → `Edge Signal`
|
| 873 |
+
|
| 874 |
+
**Powered by AMD Instinct MI300X** on ROCm via AMD Developer Cloud
|
| 875 |
+
|
| 876 |
+
---
|
| 877 |
""")
|
| 878 |
|
| 879 |
+
with gr.Tabs():
|
| 880 |
+
with gr.TabItem("Pre-computed Results"):
|
| 881 |
+
with gr.Row():
|
| 882 |
+
match_dropdown = gr.Dropdown(
|
| 883 |
+
choices=get_match_choices(),
|
| 884 |
+
value=get_match_choices()[0],
|
| 885 |
+
label="Select Match",
|
| 886 |
+
interactive=True,
|
| 887 |
+
)
|
| 888 |
|
| 889 |
+
# Video Player
|
| 890 |
+
_init_clips = get_match_clips(MATCHES[0])
|
| 891 |
+
_init_labels = []
|
| 892 |
+
for _c in _init_clips:
|
| 893 |
+
_p = Path(_c)
|
| 894 |
+
_mn = _p.parent.name.replace("_", " ").rsplit(" ", 1)[0]
|
| 895 |
+
_init_labels.append(f"{_mn} — {_p.stem.replace('_', ' ').title()}")
|
| 896 |
|
| 897 |
+
with gr.Row():
|
| 898 |
+
with gr.Column(scale=2):
|
| 899 |
+
video_player = gr.Video(
|
| 900 |
+
value=_init_clips[0] if _init_clips else None,
|
| 901 |
+
label="Tactical Overlay Clip",
|
| 902 |
+
height=400,
|
| 903 |
+
autoplay=True,
|
| 904 |
+
loop=True,
|
| 905 |
+
)
|
| 906 |
+
with gr.Column(scale=1):
|
| 907 |
+
clip_dropdown = gr.Dropdown(
|
| 908 |
+
choices=_init_labels,
|
| 909 |
+
value=_init_labels[0] if _init_labels else None,
|
| 910 |
+
label="Select Clip",
|
| 911 |
+
interactive=True,
|
| 912 |
+
)
|
| 913 |
+
video_info = gr.Markdown(
|
| 914 |
+
f"**{len(_init_clips)} clips** from recent matches of {MATCHES[0]['home_team']} and {MATCHES[0]['away_team']}"
|
| 915 |
+
if _init_clips else "No clips available."
|
| 916 |
+
)
|
| 917 |
+
|
| 918 |
+
# Annotated Frames Gallery
|
| 919 |
frame_gallery = gr.Gallery(
|
| 920 |
label="Annotated Frames (analyzed by VLM)",
|
| 921 |
columns=2,
|
| 922 |
+
height=350,
|
| 923 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
|
| 925 |
+
# Formation Plot
|
| 926 |
+
formation_plot = gr.Plot(label="Formation Map")
|
|
|
|
|
|
|
|
|
|
| 927 |
|
| 928 |
+
# Tactical Metrics (side-by-side)
|
| 929 |
+
gr.Markdown("## Tactical Metrics", elem_classes=["section-heading"])
|
| 930 |
+
with gr.Row():
|
| 931 |
+
with gr.Column():
|
| 932 |
+
metrics_home_box = gr.Markdown(elem_classes=["center-content"])
|
| 933 |
+
with gr.Column():
|
| 934 |
+
metrics_away_box = gr.Markdown(elem_classes=["center-content"])
|
| 935 |
+
|
| 936 |
+
# Match Statistics (side-by-side)
|
| 937 |
+
gr.Markdown("## Match Statistics", elem_classes=["section-heading"])
|
| 938 |
+
with gr.Row():
|
| 939 |
+
with gr.Column():
|
| 940 |
+
stats_home_box = gr.Markdown(elem_classes=["center-content"])
|
| 941 |
+
with gr.Column():
|
| 942 |
+
stats_away_box = gr.Markdown(elem_classes=["center-content"])
|
| 943 |
|
| 944 |
+
# Probability Comparison
|
| 945 |
+
with gr.Row():
|
| 946 |
+
gr.Column(scale=1)
|
| 947 |
+
with gr.Column(scale=2):
|
| 948 |
+
prob_chart = gr.Plot(label="Probability Comparison")
|
| 949 |
+
gr.Column(scale=1)
|
| 950 |
+
|
| 951 |
+
# Edge + Reasoning + Info
|
| 952 |
+
edge_badge = gr.Markdown()
|
| 953 |
+
reasoning_box = gr.Markdown()
|
| 954 |
+
info_box = gr.Markdown()
|
| 955 |
+
|
| 956 |
+
match_dropdown.change(
|
| 957 |
+
fn=on_match_select,
|
| 958 |
+
inputs=[match_dropdown],
|
| 959 |
+
outputs=[prob_chart, formation_plot, frame_gallery, edge_badge, reasoning_box, metrics_home_box, metrics_away_box, stats_home_box, stats_away_box, info_box],
|
| 960 |
+
)
|
| 961 |
+
match_dropdown.change(
|
| 962 |
+
fn=update_video_for_match,
|
| 963 |
+
inputs=[match_dropdown],
|
| 964 |
+
outputs=[video_player, clip_dropdown, video_info],
|
| 965 |
+
)
|
| 966 |
+
clip_dropdown.change(
|
| 967 |
+
fn=select_clip_for_match,
|
| 968 |
+
inputs=[clip_dropdown, match_dropdown],
|
| 969 |
+
outputs=[video_player],
|
| 970 |
+
)
|
| 971 |
|
| 972 |
+
demo.load(
|
| 973 |
+
fn=on_match_select,
|
| 974 |
+
inputs=[match_dropdown],
|
| 975 |
+
outputs=[prob_chart, formation_plot, frame_gallery, edge_badge, reasoning_box, metrics_home_box, metrics_away_box, stats_home_box, stats_away_box, info_box],
|
| 976 |
+
)
|
| 977 |
+
|
| 978 |
+
with gr.TabItem("Live Query" + (" (Active)" if live_available else " (Offline)")):
|
| 979 |
+
if not live_available:
|
| 980 |
+
gr.Markdown("""
|
| 981 |
+
**Live inference is currently offline.** The AMD MI300X GPU is not connected.
|
| 982 |
+
|
| 983 |
+
To enable live queries, set the `VLM_BASE_URL` Space secret to the vLLM endpoint
|
| 984 |
+
(e.g., `http://<droplet-ip>:8000/v1`).
|
| 985 |
+
""")
|
| 986 |
+
else:
|
| 987 |
+
gr.Markdown(f"""
|
| 988 |
+
**Live VLM connected** — Ask tactical questions about any match. The model ({VLM_MODEL}) will reason
|
| 989 |
+
over the annotated frames and tactical data in real time on AMD MI300X.
|
| 990 |
+
""")
|
| 991 |
+
|
| 992 |
+
live_match = gr.Dropdown(
|
| 993 |
+
choices=get_match_choices(),
|
| 994 |
+
value=get_match_choices()[0],
|
| 995 |
+
label="Match Context",
|
| 996 |
+
interactive=True,
|
| 997 |
+
)
|
| 998 |
+
chatbot = gr.Chatbot(label="Tactical Q&A", height=400)
|
| 999 |
+
chat_state = gr.State([])
|
| 1000 |
+
with gr.Row():
|
| 1001 |
+
user_input = gr.Textbox(
|
| 1002 |
+
placeholder="Ask a tactical question (e.g., 'What's wrong with PSG's defensive line?')",
|
| 1003 |
+
label="Your Question",
|
| 1004 |
+
scale=4,
|
| 1005 |
+
)
|
| 1006 |
+
send_btn = gr.Button("Ask", variant="primary", scale=1)
|
| 1007 |
+
|
| 1008 |
+
send_btn.click(
|
| 1009 |
+
fn=live_query,
|
| 1010 |
+
inputs=[live_match, user_input, chat_state],
|
| 1011 |
+
outputs=[chatbot, chat_state],
|
| 1012 |
+
).then(fn=lambda: "", outputs=[user_input])
|
| 1013 |
+
|
| 1014 |
+
user_input.submit(
|
| 1015 |
+
fn=live_query,
|
| 1016 |
+
inputs=[live_match, user_input, chat_state],
|
| 1017 |
+
outputs=[chatbot, chat_state],
|
| 1018 |
+
).then(fn=lambda: "", outputs=[user_input])
|
| 1019 |
+
|
| 1020 |
+
with gr.TabItem("Compare Teams"):
|
| 1021 |
+
gr.Markdown("""
|
| 1022 |
+
**Pick any two teams** to compare their recent tactical form, head-to-head history, and optionally get a live VLM prediction.
|
| 1023 |
+
50 UCL teams available with annotated frames from 273 matches.
|
| 1024 |
+
""")
|
| 1025 |
+
with gr.Row():
|
| 1026 |
+
team_a_dd = gr.Dropdown(
|
| 1027 |
+
choices=get_team_list(),
|
| 1028 |
+
value="Dortmund",
|
| 1029 |
+
label="Team A",
|
| 1030 |
+
interactive=True,
|
| 1031 |
+
)
|
| 1032 |
+
team_b_dd = gr.Dropdown(
|
| 1033 |
+
choices=get_team_list(),
|
| 1034 |
+
value="PSG",
|
| 1035 |
+
label="Team B",
|
| 1036 |
+
interactive=True,
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
with gr.Row():
|
| 1040 |
+
compare_btn = gr.Button("Compare", variant="primary")
|
| 1041 |
+
|
| 1042 |
+
with gr.Row():
|
| 1043 |
+
with gr.Column():
|
| 1044 |
+
gallery_a = gr.Gallery(label="Team A — Recent Form", columns=3, height=250)
|
| 1045 |
+
metrics_a_md = gr.Markdown()
|
| 1046 |
+
with gr.Column():
|
| 1047 |
+
gallery_b = gr.Gallery(label="Team B — Recent Form", columns=3, height=250)
|
| 1048 |
+
metrics_b_md = gr.Markdown()
|
| 1049 |
+
|
| 1050 |
+
with gr.Row():
|
| 1051 |
+
with gr.Column():
|
| 1052 |
+
h2h_gallery = gr.Gallery(label="Head-to-Head", columns=3, height=200)
|
| 1053 |
+
h2h_md = gr.Markdown()
|
| 1054 |
+
|
| 1055 |
+
gr.Markdown("## League Statistics", elem_classes=["section-heading"])
|
| 1056 |
+
with gr.Row():
|
| 1057 |
+
with gr.Column():
|
| 1058 |
+
league_stats_a_md = gr.Markdown(elem_classes=["center-content"])
|
| 1059 |
+
with gr.Column():
|
| 1060 |
+
league_stats_b_md = gr.Markdown(elem_classes=["center-content"])
|
| 1061 |
+
|
| 1062 |
+
with gr.Row():
|
| 1063 |
+
predict_btn = gr.Button(
|
| 1064 |
+
"Predict Winner (Live VLM)" if live_available else "Predict Winner (GPU Offline)",
|
| 1065 |
+
variant="secondary",
|
| 1066 |
+
)
|
| 1067 |
+
prediction_output = gr.Markdown()
|
| 1068 |
+
|
| 1069 |
+
compare_btn.click(
|
| 1070 |
+
fn=compare_teams,
|
| 1071 |
+
inputs=[team_a_dd, team_b_dd],
|
| 1072 |
+
outputs=[gallery_a, metrics_a_md, gallery_b, metrics_b_md, h2h_gallery, h2h_md, league_stats_a_md, league_stats_b_md],
|
| 1073 |
+
)
|
| 1074 |
+
predict_btn.click(
|
| 1075 |
+
fn=predict_matchup,
|
| 1076 |
+
inputs=[team_a_dd, team_b_dd],
|
| 1077 |
+
outputs=[prediction_output],
|
| 1078 |
+
)
|
| 1079 |
+
|
| 1080 |
+
gr.Markdown("""
|
| 1081 |
+
---
|
| 1082 |
+
Built for the **AMD Developer Hackathon 2026** (Track 3: Vision & Multimodal AI)
|
| 1083 |
""")
|
| 1084 |
|
| 1085 |
|
| 1086 |
if __name__ == "__main__":
|
| 1087 |
+
demo.launch(
|
| 1088 |
+
theme=gr.themes.Monochrome(font=gr.themes.GoogleFont("Inter")),
|
| 1089 |
+
js="() => { document.documentElement.classList.add('dark'); }",
|
| 1090 |
+
css="""
|
| 1091 |
+
.center-content { display: flex !important; flex-direction: column !important; align-items: center !important; }
|
| 1092 |
+
.center-content table { margin: 0 auto !important; }
|
| 1093 |
+
.center-content th, .center-content td { padding: 8px 12px !important; }
|
| 1094 |
+
.center-content th { text-align: left !important; font-weight: 600 !important; }
|
| 1095 |
+
.center-content ul { text-align: left !important; }
|
| 1096 |
+
.section-heading { text-align: center !important; }
|
| 1097 |
+
""",
|
| 1098 |
+
)
|
data/demo_matches.json
DELETED
|
@@ -1,177 +0,0 @@
|
|
| 1 |
-
[
|
| 2 |
-
{
|
| 3 |
-
"match_id": "Dortmund_vs_PSG_2024-05-07",
|
| 4 |
-
"home_team": "Dortmund",
|
| 5 |
-
"away_team": "PSG",
|
| 6 |
-
"date": "2024-05-07",
|
| 7 |
-
"stage": "Semi-final 2nd leg",
|
| 8 |
-
"first_leg": "PSG 0-1 Dortmund",
|
| 9 |
-
"actual_score": "1-0",
|
| 10 |
-
"actual_result": "home_win",
|
| 11 |
-
"odds": {"home": 4.33, "draw": 4.00, "away": 1.80},
|
| 12 |
-
"implied_prob": {"home": 0.23, "draw": 0.25, "away": 0.56},
|
| 13 |
-
"stats": {
|
| 14 |
-
"home": {
|
| 15 |
-
"team": "Dortmund",
|
| 16 |
-
"xg_last5": 1.52,
|
| 17 |
-
"xga_last5": 1.28,
|
| 18 |
-
"ppda": 10.2,
|
| 19 |
-
"possession_pct": 46,
|
| 20 |
-
"form": "WWLWW",
|
| 21 |
-
"goals_scored_last5": 9,
|
| 22 |
-
"goals_conceded_last5": 5
|
| 23 |
-
},
|
| 24 |
-
"away": {
|
| 25 |
-
"team": "PSG",
|
| 26 |
-
"xg_last5": 2.14,
|
| 27 |
-
"xga_last5": 0.72,
|
| 28 |
-
"ppda": 9.6,
|
| 29 |
-
"possession_pct": 58,
|
| 30 |
-
"form": "WWWWL",
|
| 31 |
-
"goals_scored_last5": 12,
|
| 32 |
-
"goals_conceded_last5": 3
|
| 33 |
-
}
|
| 34 |
-
},
|
| 35 |
-
"narrative": "PSG heavily favored to overturn 1st-leg deficit at home but Dortmund's compact defensive shape and rapid transitions from their recent knockout run suggested resilience the market underpriced."
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
"match_id": "Dortmund_vs_Atletico_Madrid_2024-04-16",
|
| 39 |
-
"home_team": "Dortmund",
|
| 40 |
-
"away_team": "Atletico Madrid",
|
| 41 |
-
"date": "2024-04-16",
|
| 42 |
-
"stage": "Quarter-final 2nd leg",
|
| 43 |
-
"first_leg": "Atletico Madrid 2-1 Dortmund",
|
| 44 |
-
"actual_score": "4-2",
|
| 45 |
-
"actual_result": "home_win",
|
| 46 |
-
"odds": {"home": 2.50, "draw": 3.60, "away": 2.75},
|
| 47 |
-
"implied_prob": {"home": 0.40, "draw": 0.28, "away": 0.36},
|
| 48 |
-
"stats": {
|
| 49 |
-
"home": {
|
| 50 |
-
"team": "Dortmund",
|
| 51 |
-
"xg_last5": 1.68,
|
| 52 |
-
"xga_last5": 1.34,
|
| 53 |
-
"ppda": 9.8,
|
| 54 |
-
"possession_pct": 48,
|
| 55 |
-
"form": "WLWDW",
|
| 56 |
-
"goals_scored_last5": 10,
|
| 57 |
-
"goals_conceded_last5": 6
|
| 58 |
-
},
|
| 59 |
-
"away": {
|
| 60 |
-
"team": "Atletico Madrid",
|
| 61 |
-
"xg_last5": 1.44,
|
| 62 |
-
"xga_last5": 0.96,
|
| 63 |
-
"ppda": 12.8,
|
| 64 |
-
"possession_pct": 52,
|
| 65 |
-
"form": "DWWWW",
|
| 66 |
-
"goals_scored_last5": 7,
|
| 67 |
-
"goals_conceded_last5": 4
|
| 68 |
-
}
|
| 69 |
-
},
|
| 70 |
-
"narrative": "Atletico held 1st-leg advantage and were favored on aggregate. Dortmund's explosive transition speed and Signal Iduna Park atmosphere fueled a 4-2 comeback the market didn't fully price in."
|
| 71 |
-
},
|
| 72 |
-
{
|
| 73 |
-
"match_id": "PSG_vs_Barcelona_2024-04-16",
|
| 74 |
-
"home_team": "PSG",
|
| 75 |
-
"away_team": "Barcelona",
|
| 76 |
-
"date": "2024-04-16",
|
| 77 |
-
"stage": "Quarter-final 2nd leg",
|
| 78 |
-
"first_leg": "Barcelona 3-2 PSG",
|
| 79 |
-
"actual_score": "4-1",
|
| 80 |
-
"actual_result": "home_win",
|
| 81 |
-
"odds": {"home": 2.10, "draw": 3.80, "away": 3.40},
|
| 82 |
-
"implied_prob": {"home": 0.48, "draw": 0.26, "away": 0.29},
|
| 83 |
-
"stats": {
|
| 84 |
-
"home": {
|
| 85 |
-
"team": "PSG",
|
| 86 |
-
"xg_last5": 2.06,
|
| 87 |
-
"xga_last5": 0.88,
|
| 88 |
-
"ppda": 9.4,
|
| 89 |
-
"possession_pct": 56,
|
| 90 |
-
"form": "WWWDW",
|
| 91 |
-
"goals_scored_last5": 11,
|
| 92 |
-
"goals_conceded_last5": 5
|
| 93 |
-
},
|
| 94 |
-
"away": {
|
| 95 |
-
"team": "Barcelona",
|
| 96 |
-
"xg_last5": 1.82,
|
| 97 |
-
"xga_last5": 1.22,
|
| 98 |
-
"ppda": 10.8,
|
| 99 |
-
"possession_pct": 60,
|
| 100 |
-
"form": "WWLWW",
|
| 101 |
-
"goals_scored_last5": 9,
|
| 102 |
-
"goals_conceded_last5": 6
|
| 103 |
-
}
|
| 104 |
-
},
|
| 105 |
-
"narrative": "Barcelona had 1st-leg advantage and high possession but PSG's aggressive pressing intensity and Dembele's pace on transitions created a 4-1 demolition the aggregate market didn't reflect."
|
| 106 |
-
},
|
| 107 |
-
{
|
| 108 |
-
"match_id": "Man_City_vs_Real_Madrid_2024-04-17",
|
| 109 |
-
"home_team": "Man City",
|
| 110 |
-
"away_team": "Real Madrid",
|
| 111 |
-
"date": "2024-04-17",
|
| 112 |
-
"stage": "Quarter-final 2nd leg",
|
| 113 |
-
"first_leg": "Real Madrid 3-3 Man City",
|
| 114 |
-
"actual_score": "1-1 (Real Madrid won on penalties)",
|
| 115 |
-
"actual_result": "draw",
|
| 116 |
-
"odds": {"home": 1.83, "draw": 4.00, "away": 4.33},
|
| 117 |
-
"implied_prob": {"home": 0.55, "draw": 0.25, "away": 0.23},
|
| 118 |
-
"stats": {
|
| 119 |
-
"home": {
|
| 120 |
-
"team": "Man City",
|
| 121 |
-
"xg_last5": 2.24,
|
| 122 |
-
"xga_last5": 0.86,
|
| 123 |
-
"ppda": 8.2,
|
| 124 |
-
"possession_pct": 64,
|
| 125 |
-
"form": "WWWWW",
|
| 126 |
-
"goals_scored_last5": 13,
|
| 127 |
-
"goals_conceded_last5": 4
|
| 128 |
-
},
|
| 129 |
-
"away": {
|
| 130 |
-
"team": "Real Madrid",
|
| 131 |
-
"xg_last5": 1.76,
|
| 132 |
-
"xga_last5": 1.08,
|
| 133 |
-
"ppda": 11.6,
|
| 134 |
-
"possession_pct": 52,
|
| 135 |
-
"form": "WDWWW",
|
| 136 |
-
"goals_scored_last5": 10,
|
| 137 |
-
"goals_conceded_last5": 5
|
| 138 |
-
}
|
| 139 |
-
},
|
| 140 |
-
"narrative": "Man City dominant favorites at home but Real Madrid's low-block + lethal transitions and penalty-shootout composure saw them through. City's high line was vulnerable to counter-attacks the market discounted."
|
| 141 |
-
},
|
| 142 |
-
{
|
| 143 |
-
"match_id": "Atletico_Madrid_vs_Inter_Milan_2024-03-13",
|
| 144 |
-
"home_team": "Atletico Madrid",
|
| 145 |
-
"away_team": "Inter Milan",
|
| 146 |
-
"date": "2024-03-13",
|
| 147 |
-
"stage": "Round of 16 2nd leg",
|
| 148 |
-
"first_leg": "Inter Milan 1-0 Atletico Madrid",
|
| 149 |
-
"actual_score": "2-1",
|
| 150 |
-
"actual_result": "home_win",
|
| 151 |
-
"odds": {"home": 2.20, "draw": 3.30, "away": 3.40},
|
| 152 |
-
"implied_prob": {"home": 0.45, "draw": 0.30, "away": 0.29},
|
| 153 |
-
"stats": {
|
| 154 |
-
"home": {
|
| 155 |
-
"team": "Atletico Madrid",
|
| 156 |
-
"xg_last5": 1.56,
|
| 157 |
-
"xga_last5": 0.92,
|
| 158 |
-
"ppda": 11.4,
|
| 159 |
-
"possession_pct": 50,
|
| 160 |
-
"form": "WWDWL",
|
| 161 |
-
"goals_scored_last5": 8,
|
| 162 |
-
"goals_conceded_last5": 5
|
| 163 |
-
},
|
| 164 |
-
"away": {
|
| 165 |
-
"team": "Inter Milan",
|
| 166 |
-
"xg_last5": 1.88,
|
| 167 |
-
"xga_last5": 0.78,
|
| 168 |
-
"ppda": 10.2,
|
| 169 |
-
"possession_pct": 54,
|
| 170 |
-
"form": "WWWWD",
|
| 171 |
-
"goals_scored_last5": 10,
|
| 172 |
-
"goals_conceded_last5": 3
|
| 173 |
-
}
|
| 174 |
-
},
|
| 175 |
-
"narrative": "Inter led on aggregate and had the best defensive record in Serie A. Atletico's high-energy pressing at home disrupted Inter's build-up play, creating chaos the market underestimated."
|
| 176 |
-
}
|
| 177 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_000.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_003.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_001.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_005.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_001.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_005.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_000.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_006.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_002.jpg
DELETED
|
Binary file (91.7 kB)
|
|
|
data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_005.jpg
DELETED
|
Binary file (86.8 kB)
|
|
|
data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_002.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_006.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_003.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_005.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_002.jpg
DELETED
|
Binary file (98 kB)
|
|
|
data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_003.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_000.jpg
DELETED
|
Binary file (88.3 kB)
|
|
|
data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_001.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_001.jpg
DELETED
|
Binary file (94.1 kB)
|
|
|
data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_003.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_002.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_005.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_000.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_001.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_001.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_004.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_000.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_002.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_002.jpg
DELETED
Git LFS Details
|
data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_004.jpg
DELETED
Git LFS Details
|
data/vlm_results/results.json
DELETED
|
@@ -1,481 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"generated_at": "2026-05-07T21:03:43.559765",
|
| 3 |
-
"model": "Qwen/Qwen2.5-VL-72B-Instruct",
|
| 4 |
-
"matches": [
|
| 5 |
-
{
|
| 6 |
-
"match_id": "Dortmund_vs_PSG_2024-05-07",
|
| 7 |
-
"home_team": "Dortmund",
|
| 8 |
-
"away_team": "PSG",
|
| 9 |
-
"date": "2024-05-07",
|
| 10 |
-
"stage": "Semi-final 2nd leg",
|
| 11 |
-
"market_odds": {
|
| 12 |
-
"home": 0.23,
|
| 13 |
-
"draw": 0.25,
|
| 14 |
-
"away": 0.56
|
| 15 |
-
},
|
| 16 |
-
"actual_result": "home_win",
|
| 17 |
-
"actual_score": "1-0",
|
| 18 |
-
"vlm_assessment": {
|
| 19 |
-
"probabilities": {
|
| 20 |
-
"home": 0.32,
|
| 21 |
-
"draw": 0.28,
|
| 22 |
-
"away": 0.4
|
| 23 |
-
},
|
| 24 |
-
"edge": {
|
| 25 |
-
"home": 0.09,
|
| 26 |
-
"draw": 0.03,
|
| 27 |
-
"away": -0.16
|
| 28 |
-
},
|
| 29 |
-
"reasoning": "Dortmund's aggressive pressing and high defensive line, as seen in Frame 3, combined with their compactness, suggest they can create chances against PSG. However, PSG\u2019s solid defensive structure and transition speed indicate they could exploit counter-attacks effectively.",
|
| 30 |
-
"visual_evidence": [
|
| 31 |
-
"In Frame 3, Dortmund's defensive line is very high up the pitch, indicating an aggressive approach which could lead to scoring opportunities but also leaves them vulnerable on the counter.",
|
| 32 |
-
"Frames 5 and 6 show PSG maintaining a compact defensive shape, limiting space for Barcelona to attack, suggesting they can neutralize Dortmund's offensive play.",
|
| 33 |
-
"The compactness delta for both teams indicates that while Dortmund presses aggressively, PSG remains organized defensively."
|
| 34 |
-
],
|
| 35 |
-
"confidence": "medium",
|
| 36 |
-
"edge_signal": "The market underprices a Dortmund win due to their recent tactical success in pressing and creating chances against strong opponents. A 23% implied probability seems low given their form and tactical setup."
|
| 37 |
-
},
|
| 38 |
-
"frames_used": [
|
| 39 |
-
"data/frames/PSG_vs_Dortmund_2024-05-01/annotated/frame_001.jpg",
|
| 40 |
-
"data/frames/PSG_vs_Dortmund_2024-05-01/annotated/frame_000.jpg",
|
| 41 |
-
"data/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/annotated/frame_002.jpg",
|
| 42 |
-
"data/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/annotated/frame_006.jpg",
|
| 43 |
-
"data/frames/PSG_vs_Barcelona_2024-04-16/annotated/frame_002.jpg",
|
| 44 |
-
"data/frames/PSG_vs_Barcelona_2024-04-16/annotated/frame_005.jpg"
|
| 45 |
-
],
|
| 46 |
-
"metrics_context": {
|
| 47 |
-
"home": {
|
| 48 |
-
"team": "Dortmund",
|
| 49 |
-
"matches_analyzed": [
|
| 50 |
-
"PSG_vs_Dortmund_2024-05-01",
|
| 51 |
-
"Dortmund_vs_Atletico_Madrid_2024-04-16",
|
| 52 |
-
"Atletico_Madrid_vs_Dortmund_2024-04-10"
|
| 53 |
-
],
|
| 54 |
-
"metrics": {
|
| 55 |
-
"avg_pressing_speed": 0.0037,
|
| 56 |
-
"avg_def_line_movement": -0.0103,
|
| 57 |
-
"avg_compactness_delta": -0.2123,
|
| 58 |
-
"avg_transition_speed": 0.152
|
| 59 |
-
}
|
| 60 |
-
},
|
| 61 |
-
"away": {
|
| 62 |
-
"team": "PSG",
|
| 63 |
-
"matches_analyzed": [
|
| 64 |
-
"PSG_vs_Dortmund_2024-05-01",
|
| 65 |
-
"PSG_vs_Barcelona_2024-04-16",
|
| 66 |
-
"Barcelona_vs_PSG_2024-04-10"
|
| 67 |
-
],
|
| 68 |
-
"metrics": {
|
| 69 |
-
"avg_pressing_speed": 0.003,
|
| 70 |
-
"avg_def_line_movement": -0.035,
|
| 71 |
-
"avg_compactness_delta": -0.047,
|
| 72 |
-
"avg_transition_speed": 0.141
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
},
|
| 76 |
-
"stats": {
|
| 77 |
-
"home": {
|
| 78 |
-
"team": "Dortmund",
|
| 79 |
-
"xg_last5": 1.52,
|
| 80 |
-
"xga_last5": 1.28,
|
| 81 |
-
"ppda": 10.2,
|
| 82 |
-
"possession_pct": 46,
|
| 83 |
-
"form": "WWLWW",
|
| 84 |
-
"goals_scored_last5": 9,
|
| 85 |
-
"goals_conceded_last5": 5
|
| 86 |
-
},
|
| 87 |
-
"away": {
|
| 88 |
-
"team": "PSG",
|
| 89 |
-
"xg_last5": 2.14,
|
| 90 |
-
"xga_last5": 0.72,
|
| 91 |
-
"ppda": 9.6,
|
| 92 |
-
"possession_pct": 58,
|
| 93 |
-
"form": "WWWWL",
|
| 94 |
-
"goals_scored_last5": 12,
|
| 95 |
-
"goals_conceded_last5": 3
|
| 96 |
-
}
|
| 97 |
-
},
|
| 98 |
-
"inference_time_s": 18.504204750061035
|
| 99 |
-
},
|
| 100 |
-
{
|
| 101 |
-
"match_id": "Dortmund_vs_Atletico_Madrid_2024-04-16",
|
| 102 |
-
"home_team": "Dortmund",
|
| 103 |
-
"away_team": "Atletico Madrid",
|
| 104 |
-
"date": "2024-04-16",
|
| 105 |
-
"stage": "Quarter-final 2nd leg",
|
| 106 |
-
"market_odds": {
|
| 107 |
-
"home": 0.4,
|
| 108 |
-
"draw": 0.28,
|
| 109 |
-
"away": 0.36
|
| 110 |
-
},
|
| 111 |
-
"actual_result": "home_win",
|
| 112 |
-
"actual_score": "4-2",
|
| 113 |
-
"vlm_assessment": {
|
| 114 |
-
"probabilities": {
|
| 115 |
-
"home": 0.45,
|
| 116 |
-
"draw": 0.25,
|
| 117 |
-
"away": 0.3
|
| 118 |
-
},
|
| 119 |
-
"edge": {
|
| 120 |
-
"home": 0.05,
|
| 121 |
-
"draw": -0.03,
|
| 122 |
-
"away": -0.06
|
| 123 |
-
},
|
| 124 |
-
"reasoning": "Dortmund has shown tactical flexibility and offensive prowess in recent matches, while Atletico Madrid's defensive solidity might be slightly overstated given their lower transition speed. The home advantage and current form suggest a slight edge for Dortmund.",
|
| 125 |
-
"visual_evidence": [
|
| 126 |
-
"In Frame 3, Dortmund's compactness ellipse shows a well-organized structure around the ball, indicating strong control and potential attacking opportunities.",
|
| 127 |
-
"Frames 5 and 6 show Atletico Madrid's vulnerability when facing a compact and aggressive opponent like Inter Milan, hinting at possible weaknesses against Dortmund's similar approach.",
|
| 128 |
-
"The defensive line of Dortmund is consistently positioned higher up the pitch, as seen in Frame 4, suggesting an aggressive pressing game which could disrupt Atletico Madrid's build-up play."
|
| 129 |
-
],
|
| 130 |
-
"confidence": "medium",
|
| 131 |
-
"edge_signal": "The market slightly underprices a Dortmund win due to their tactical advantages and recent form. The higher implied probability for an Atletico Madrid win seems inflated compared to their tactical metrics."
|
| 132 |
-
},
|
| 133 |
-
"frames_used": [
|
| 134 |
-
"data/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/annotated/frame_003.jpg",
|
| 135 |
-
"data/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/annotated/frame_000.jpg",
|
| 136 |
-
"data/frames/Dortmund_vs_PSV_2024-03-13/annotated/frame_003.jpg",
|
| 137 |
-
"data/frames/Dortmund_vs_PSV_2024-03-13/annotated/frame_005.jpg",
|
| 138 |
-
"data/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/annotated/frame_001.jpg",
|
| 139 |
-
"data/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/annotated/frame_005.jpg"
|
| 140 |
-
],
|
| 141 |
-
"metrics_context": {
|
| 142 |
-
"home": {
|
| 143 |
-
"team": "Dortmund",
|
| 144 |
-
"matches_analyzed": [
|
| 145 |
-
"Atletico_Madrid_vs_Dortmund_2024-04-10",
|
| 146 |
-
"Dortmund_vs_PSV_2024-03-13",
|
| 147 |
-
"PSV_vs_Dortmund_2024-02-20"
|
| 148 |
-
],
|
| 149 |
-
"metrics": {
|
| 150 |
-
"avg_pressing_speed": 0.0033,
|
| 151 |
-
"avg_def_line_movement": -0.0267,
|
| 152 |
-
"avg_compactness_delta": -0.168,
|
| 153 |
-
"avg_transition_speed": 0.1557
|
| 154 |
-
}
|
| 155 |
-
},
|
| 156 |
-
"away": {
|
| 157 |
-
"team": "Atletico Madrid",
|
| 158 |
-
"matches_analyzed": [
|
| 159 |
-
"Atletico_Madrid_vs_Dortmund_2024-04-10",
|
| 160 |
-
"Atletico_Madrid_vs_Inter_Milan_2024-03-13",
|
| 161 |
-
"Inter_Milan_vs_Atletico_Madrid_2024-02-20"
|
| 162 |
-
],
|
| 163 |
-
"metrics": {
|
| 164 |
-
"avg_pressing_speed": 0.0043,
|
| 165 |
-
"avg_def_line_movement": -0.0163,
|
| 166 |
-
"avg_compactness_delta": -0.3237,
|
| 167 |
-
"avg_transition_speed": 0.1473
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
},
|
| 171 |
-
"stats": {
|
| 172 |
-
"home": {
|
| 173 |
-
"team": "Dortmund",
|
| 174 |
-
"xg_last5": 1.68,
|
| 175 |
-
"xga_last5": 1.34,
|
| 176 |
-
"ppda": 9.8,
|
| 177 |
-
"possession_pct": 48,
|
| 178 |
-
"form": "WLWDW",
|
| 179 |
-
"goals_scored_last5": 10,
|
| 180 |
-
"goals_conceded_last5": 6
|
| 181 |
-
},
|
| 182 |
-
"away": {
|
| 183 |
-
"team": "Atletico Madrid",
|
| 184 |
-
"xg_last5": 1.44,
|
| 185 |
-
"xga_last5": 0.96,
|
| 186 |
-
"ppda": 12.8,
|
| 187 |
-
"possession_pct": 52,
|
| 188 |
-
"form": "DWWWW",
|
| 189 |
-
"goals_scored_last5": 7,
|
| 190 |
-
"goals_conceded_last5": 4
|
| 191 |
-
}
|
| 192 |
-
},
|
| 193 |
-
"inference_time_s": 12.080515146255493
|
| 194 |
-
},
|
| 195 |
-
{
|
| 196 |
-
"match_id": "PSG_vs_Barcelona_2024-04-16",
|
| 197 |
-
"home_team": "PSG",
|
| 198 |
-
"away_team": "Barcelona",
|
| 199 |
-
"date": "2024-04-16",
|
| 200 |
-
"stage": "Quarter-final 2nd leg",
|
| 201 |
-
"market_odds": {
|
| 202 |
-
"home": 0.48,
|
| 203 |
-
"draw": 0.26,
|
| 204 |
-
"away": 0.29
|
| 205 |
-
},
|
| 206 |
-
"actual_result": "home_win",
|
| 207 |
-
"actual_score": "4-1",
|
| 208 |
-
"vlm_assessment": {
|
| 209 |
-
"probabilities": {
|
| 210 |
-
"home": 0.52,
|
| 211 |
-
"draw": 0.24,
|
| 212 |
-
"away": 0.24
|
| 213 |
-
},
|
| 214 |
-
"edge": {
|
| 215 |
-
"home": 0.04,
|
| 216 |
-
"draw": -0.02,
|
| 217 |
-
"away": -0.05
|
| 218 |
-
},
|
| 219 |
-
"reasoning": "PSG's defensive solidity and transition speed suggest they can maintain their lead. Barcelona's pressing struggles indicate difficulty in breaking down PSG.",
|
| 220 |
-
"visual_evidence": [
|
| 221 |
-
"In Frame 1, PSG's defensive organization limits Barcelona's penetration despite trailing.",
|
| 222 |
-
"Frame 3 shows PSG maintaining a high defensive line and compact shape against Real Sociedad, indicating strong defensive discipline.",
|
| 223 |
-
"Barcelona's attacking structure in Frame 5 appears disjointed, suggesting potential difficulties in creating clear chances."
|
| 224 |
-
],
|
| 225 |
-
"confidence": "medium",
|
| 226 |
-
"edge_signal": "The market slightly underprices PSG's win due to their defensive resilience and transition capabilities, which are not fully reflected in the current odds."
|
| 227 |
-
},
|
| 228 |
-
"frames_used": [
|
| 229 |
-
"data/frames/Barcelona_vs_PSG_2024-04-10/annotated/frame_005.jpg",
|
| 230 |
-
"data/frames/Barcelona_vs_PSG_2024-04-10/annotated/frame_002.jpg",
|
| 231 |
-
"data/frames/Real_Sociedad_vs_PSG_2024-03-05/annotated/frame_002.jpg",
|
| 232 |
-
"data/frames/Real_Sociedad_vs_PSG_2024-03-05/annotated/frame_004.jpg",
|
| 233 |
-
"data/frames/Barcelona_vs_Napoli_2024-03-12/annotated/frame_006.jpg",
|
| 234 |
-
"data/frames/Barcelona_vs_Napoli_2024-03-12/annotated/frame_000.jpg"
|
| 235 |
-
],
|
| 236 |
-
"metrics_context": {
|
| 237 |
-
"home": {
|
| 238 |
-
"team": "PSG",
|
| 239 |
-
"matches_analyzed": [
|
| 240 |
-
"Barcelona_vs_PSG_2024-04-10",
|
| 241 |
-
"Real_Sociedad_vs_PSG_2024-03-05",
|
| 242 |
-
"PSG_vs_Real_Sociedad_2024-02-14"
|
| 243 |
-
],
|
| 244 |
-
"metrics": {
|
| 245 |
-
"avg_pressing_speed": 0.0033,
|
| 246 |
-
"avg_def_line_movement": -0.0397,
|
| 247 |
-
"avg_compactness_delta": -0.0023,
|
| 248 |
-
"avg_transition_speed": 0.1257
|
| 249 |
-
}
|
| 250 |
-
},
|
| 251 |
-
"away": {
|
| 252 |
-
"team": "Barcelona",
|
| 253 |
-
"matches_analyzed": [
|
| 254 |
-
"Barcelona_vs_PSG_2024-04-10",
|
| 255 |
-
"Barcelona_vs_Napoli_2024-03-12",
|
| 256 |
-
"Napoli_vs_Barcelona_2024-02-21"
|
| 257 |
-
],
|
| 258 |
-
"metrics": {
|
| 259 |
-
"avg_pressing_speed": 0.003,
|
| 260 |
-
"avg_def_line_movement": -0.0493,
|
| 261 |
-
"avg_compactness_delta": -0.0793,
|
| 262 |
-
"avg_transition_speed": 0.1457
|
| 263 |
-
}
|
| 264 |
-
}
|
| 265 |
-
},
|
| 266 |
-
"stats": {
|
| 267 |
-
"home": {
|
| 268 |
-
"team": "PSG",
|
| 269 |
-
"xg_last5": 2.06,
|
| 270 |
-
"xga_last5": 0.88,
|
| 271 |
-
"ppda": 9.4,
|
| 272 |
-
"possession_pct": 56,
|
| 273 |
-
"form": "WWWDW",
|
| 274 |
-
"goals_scored_last5": 11,
|
| 275 |
-
"goals_conceded_last5": 5
|
| 276 |
-
},
|
| 277 |
-
"away": {
|
| 278 |
-
"team": "Barcelona",
|
| 279 |
-
"xg_last5": 1.82,
|
| 280 |
-
"xga_last5": 1.22,
|
| 281 |
-
"ppda": 10.8,
|
| 282 |
-
"possession_pct": 60,
|
| 283 |
-
"form": "WWLWW",
|
| 284 |
-
"goals_scored_last5": 9,
|
| 285 |
-
"goals_conceded_last5": 6
|
| 286 |
-
}
|
| 287 |
-
},
|
| 288 |
-
"inference_time_s": 9.315885782241821
|
| 289 |
-
},
|
| 290 |
-
{
|
| 291 |
-
"match_id": "Man_City_vs_Real_Madrid_2024-04-17",
|
| 292 |
-
"home_team": "Man City",
|
| 293 |
-
"away_team": "Real Madrid",
|
| 294 |
-
"date": "2024-04-17",
|
| 295 |
-
"stage": "Quarter-final 2nd leg",
|
| 296 |
-
"market_odds": {
|
| 297 |
-
"home": 0.55,
|
| 298 |
-
"draw": 0.25,
|
| 299 |
-
"away": 0.23
|
| 300 |
-
},
|
| 301 |
-
"actual_result": "draw",
|
| 302 |
-
"actual_score": "1-1 (Real Madrid won on penalties)",
|
| 303 |
-
"vlm_assessment": {
|
| 304 |
-
"probabilities": {
|
| 305 |
-
"home": 0.58,
|
| 306 |
-
"draw": 0.22,
|
| 307 |
-
"away": 0.2
|
| 308 |
-
},
|
| 309 |
-
"edge": {
|
| 310 |
-
"home": 0.03,
|
| 311 |
-
"draw": -0.03,
|
| 312 |
-
"away": -0.03
|
| 313 |
-
},
|
| 314 |
-
"reasoning": "Man City's high pressing speed, compactness, and transition speed suggest they can dominate possession and create chances. However, Real Madrid's defensive resilience and ability to counter-attack pose a threat. The market slightly undervalues the draw.",
|
| 315 |
-
"visual_evidence": [
|
| 316 |
-
"In Frame 3, Man City's compactness and high defensive line indicate their aggressive approach to regain possession quickly.",
|
| 317 |
-
"Frames 5 and 6 show Real Madrid maintaining a solid defensive structure, limiting space for opponents to exploit.",
|
| 318 |
-
"The defensive line movement of both teams indicates a tactical battle for control in midfield."
|
| 319 |
-
],
|
| 320 |
-
"confidence": "medium",
|
| 321 |
-
"edge_signal": "The market underprices the draw due to Man City's offensive dominance but overlooks Real Madrid's defensive solidity and potential for counter-attacks."
|
| 322 |
-
},
|
| 323 |
-
"frames_used": [
|
| 324 |
-
"data/frames/Real_Madrid_vs_Man_City_2024-04-09/annotated/frame_004.jpg",
|
| 325 |
-
"data/frames/Real_Madrid_vs_Man_City_2024-04-09/annotated/frame_001.jpg",
|
| 326 |
-
"data/frames/Man_City_vs_FC_Copenhagen_2024-03-06/annotated/frame_001.jpg",
|
| 327 |
-
"data/frames/Man_City_vs_FC_Copenhagen_2024-03-06/annotated/frame_003.jpg",
|
| 328 |
-
"data/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/annotated/frame_000.jpg",
|
| 329 |
-
"data/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/annotated/frame_002.jpg"
|
| 330 |
-
],
|
| 331 |
-
"metrics_context": {
|
| 332 |
-
"home": {
|
| 333 |
-
"team": "Man City",
|
| 334 |
-
"matches_analyzed": [
|
| 335 |
-
"Real_Madrid_vs_Man_City_2024-04-09",
|
| 336 |
-
"Man_City_vs_FC_Copenhagen_2024-03-06",
|
| 337 |
-
"FC_Copenhagen_vs_Man_City_2024-02-13"
|
| 338 |
-
],
|
| 339 |
-
"metrics": {
|
| 340 |
-
"avg_pressing_speed": 0.003,
|
| 341 |
-
"avg_def_line_movement": -0.0107,
|
| 342 |
-
"avg_compactness_delta": -0.1893,
|
| 343 |
-
"avg_transition_speed": 0.155
|
| 344 |
-
}
|
| 345 |
-
},
|
| 346 |
-
"away": {
|
| 347 |
-
"team": "Real Madrid",
|
| 348 |
-
"matches_analyzed": [
|
| 349 |
-
"Real_Madrid_vs_Man_City_2024-04-09",
|
| 350 |
-
"Real_Madrid_vs_RB_Leipzig_2024-03-06",
|
| 351 |
-
"RB_Leipzig_vs_Real_Madrid_2024-02-13"
|
| 352 |
-
],
|
| 353 |
-
"metrics": {
|
| 354 |
-
"avg_pressing_speed": 0.0033,
|
| 355 |
-
"avg_def_line_movement": -0.035,
|
| 356 |
-
"avg_compactness_delta": -0.247,
|
| 357 |
-
"avg_transition_speed": 0.126
|
| 358 |
-
}
|
| 359 |
-
}
|
| 360 |
-
},
|
| 361 |
-
"stats": {
|
| 362 |
-
"home": {
|
| 363 |
-
"team": "Man City",
|
| 364 |
-
"xg_last5": 2.24,
|
| 365 |
-
"xga_last5": 0.86,
|
| 366 |
-
"ppda": 8.2,
|
| 367 |
-
"possession_pct": 64,
|
| 368 |
-
"form": "WWWWW",
|
| 369 |
-
"goals_scored_last5": 13,
|
| 370 |
-
"goals_conceded_last5": 4
|
| 371 |
-
},
|
| 372 |
-
"away": {
|
| 373 |
-
"team": "Real Madrid",
|
| 374 |
-
"xg_last5": 1.76,
|
| 375 |
-
"xga_last5": 1.08,
|
| 376 |
-
"ppda": 11.6,
|
| 377 |
-
"possession_pct": 52,
|
| 378 |
-
"form": "WDWWW",
|
| 379 |
-
"goals_scored_last5": 10,
|
| 380 |
-
"goals_conceded_last5": 5
|
| 381 |
-
}
|
| 382 |
-
},
|
| 383 |
-
"inference_time_s": 10.033257007598877
|
| 384 |
-
},
|
| 385 |
-
{
|
| 386 |
-
"match_id": "Atletico_Madrid_vs_Inter_Milan_2024-03-13",
|
| 387 |
-
"home_team": "Atletico Madrid",
|
| 388 |
-
"away_team": "Inter Milan",
|
| 389 |
-
"date": "2024-03-13",
|
| 390 |
-
"stage": "Round of 16 2nd leg",
|
| 391 |
-
"market_odds": {
|
| 392 |
-
"home": 0.45,
|
| 393 |
-
"draw": 0.3,
|
| 394 |
-
"away": 0.29
|
| 395 |
-
},
|
| 396 |
-
"actual_result": "home_win",
|
| 397 |
-
"actual_score": "2-1",
|
| 398 |
-
"vlm_assessment": {
|
| 399 |
-
"probabilities": {
|
| 400 |
-
"home": 0.4,
|
| 401 |
-
"draw": 0.32,
|
| 402 |
-
"away": 0.28
|
| 403 |
-
},
|
| 404 |
-
"edge": {
|
| 405 |
-
"home": -0.05,
|
| 406 |
-
"draw": 0.02,
|
| 407 |
-
"away": -0.01
|
| 408 |
-
},
|
| 409 |
-
"reasoning": "Inter Milan's higher xG and better defensive record suggest they could neutralize Atletico's attack. However, Atletico's pressing and compactness in their own half might limit Inter's chances. The first-leg result adds pressure on Atletico to score.",
|
| 410 |
-
"visual_evidence": [
|
| 411 |
-
"In Frame 2, we can see Inter Milan's compact defensive block, which limits Atletico Madrid's attacking options.",
|
| 412 |
-
"Frame 3 shows Atletico Madrid's high defensive line and aggressive pressing, which could disrupt Inter Milan's build-up play.",
|
| 413 |
-
"Frame 5 illustrates Inter Milan's ability to maintain possession and control the game when facing opposition."
|
| 414 |
-
],
|
| 415 |
-
"confidence": "medium",
|
| 416 |
-
"edge_signal": "The market may slightly overprice an Atletico Madrid win due to home advantage and the need to overturn the deficit. A draw or an Inter Milan win seems more likely given their recent form and defensive solidity."
|
| 417 |
-
},
|
| 418 |
-
"frames_used": [
|
| 419 |
-
"data/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/annotated/frame_002.jpg",
|
| 420 |
-
"data/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/annotated/frame_003.jpg",
|
| 421 |
-
"data/frames/Atletico_Madrid_vs_Lazio_2023-12-13/annotated/frame_001.jpg",
|
| 422 |
-
"data/frames/Atletico_Madrid_vs_Lazio_2023-12-13/annotated/frame_005.jpg",
|
| 423 |
-
"data/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/annotated/frame_001.jpg",
|
| 424 |
-
"data/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/annotated/frame_000.jpg"
|
| 425 |
-
],
|
| 426 |
-
"metrics_context": {
|
| 427 |
-
"home": {
|
| 428 |
-
"team": "Atletico Madrid",
|
| 429 |
-
"matches_analyzed": [
|
| 430 |
-
"Inter_Milan_vs_Atletico_Madrid_2024-02-20",
|
| 431 |
-
"Atletico_Madrid_vs_Lazio_2023-12-13",
|
| 432 |
-
"Feyenoord_vs_Atletico_Madrid_2023-12-12"
|
| 433 |
-
],
|
| 434 |
-
"metrics": {
|
| 435 |
-
"avg_pressing_speed": 0.0037,
|
| 436 |
-
"avg_def_line_movement": -0.012,
|
| 437 |
-
"avg_compactness_delta": -0.4683,
|
| 438 |
-
"avg_transition_speed": 0.1453
|
| 439 |
-
}
|
| 440 |
-
},
|
| 441 |
-
"away": {
|
| 442 |
-
"team": "Inter Milan",
|
| 443 |
-
"matches_analyzed": [
|
| 444 |
-
"Inter_Milan_vs_Atletico_Madrid_2024-02-20",
|
| 445 |
-
"Inter_Milan_vs_Real_Sociedad_2023-12-12",
|
| 446 |
-
"Benfica_vs_Inter_Milan_2023-11-07"
|
| 447 |
-
],
|
| 448 |
-
"metrics": {
|
| 449 |
-
"avg_pressing_speed": 0.0033,
|
| 450 |
-
"avg_def_line_movement": -0.055,
|
| 451 |
-
"avg_compactness_delta": -0.326,
|
| 452 |
-
"avg_transition_speed": 0.1163
|
| 453 |
-
}
|
| 454 |
-
}
|
| 455 |
-
},
|
| 456 |
-
"stats": {
|
| 457 |
-
"home": {
|
| 458 |
-
"team": "Atletico Madrid",
|
| 459 |
-
"xg_last5": 1.56,
|
| 460 |
-
"xga_last5": 0.92,
|
| 461 |
-
"ppda": 11.4,
|
| 462 |
-
"possession_pct": 50,
|
| 463 |
-
"form": "WWDWL",
|
| 464 |
-
"goals_scored_last5": 8,
|
| 465 |
-
"goals_conceded_last5": 5
|
| 466 |
-
},
|
| 467 |
-
"away": {
|
| 468 |
-
"team": "Inter Milan",
|
| 469 |
-
"xg_last5": 1.88,
|
| 470 |
-
"xga_last5": 0.78,
|
| 471 |
-
"ppda": 10.2,
|
| 472 |
-
"possession_pct": 54,
|
| 473 |
-
"form": "WWWWD",
|
| 474 |
-
"goals_scored_last5": 10,
|
| 475 |
-
"goals_conceded_last5": 3
|
| 476 |
-
}
|
| 477 |
-
},
|
| 478 |
-
"inference_time_s": 11.459537267684937
|
| 479 |
-
}
|
| 480 |
-
]
|
| 481 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,2 +1,5 @@
|
|
| 1 |
gradio>=4.0
|
| 2 |
plotly>=5.0
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
gradio>=4.0
|
| 2 |
plotly>=5.0
|
| 3 |
+
pillow>=10.0
|
| 4 |
+
openai>=1.0
|
| 5 |
+
numpy>=1.26
|