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

Files changed (34) hide show
  1. app.py +869 -54
  2. data/demo_matches.json +0 -177
  3. data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_000.jpg +0 -3
  4. data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_003.jpg +0 -3
  5. data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_001.jpg +0 -3
  6. data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_005.jpg +0 -3
  7. data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_001.jpg +0 -3
  8. data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_005.jpg +0 -3
  9. data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_000.jpg +0 -3
  10. data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_006.jpg +0 -3
  11. data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_002.jpg +0 -0
  12. data/vlm_results/frames/Barcelona_vs_PSG_2024-04-10/frame_005.jpg +0 -0
  13. data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_002.jpg +0 -3
  14. data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_006.jpg +0 -3
  15. data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_003.jpg +0 -3
  16. data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_005.jpg +0 -3
  17. data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_002.jpg +0 -0
  18. data/vlm_results/frames/Inter_Milan_vs_Atletico_Madrid_2024-02-20/frame_003.jpg +0 -3
  19. data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_000.jpg +0 -0
  20. data/vlm_results/frames/Inter_Milan_vs_Real_Sociedad_2023-12-12/frame_001.jpg +0 -3
  21. data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_001.jpg +0 -0
  22. data/vlm_results/frames/Man_City_vs_FC_Copenhagen_2024-03-06/frame_003.jpg +0 -3
  23. data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_002.jpg +0 -3
  24. data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_005.jpg +0 -3
  25. data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_000.jpg +0 -3
  26. data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_001.jpg +0 -3
  27. data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_001.jpg +0 -3
  28. data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_004.jpg +0 -3
  29. data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_000.jpg +0 -3
  30. data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_002.jpg +0 -3
  31. data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_002.jpg +0 -3
  32. data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_004.jpg +0 -3
  33. data/vlm_results/results.json +0 -481
  34. 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
- RESULTS_PATH = APP_DIR / "data" / "vlm_results" / "results.json"
15
- DEMO_PATH = APP_DIR / "data" / "demo_matches.json"
16
- FRAMES_DIR = APP_DIR / "data" / "vlm_results" / "frames"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"**Edge: +{best_val*100:.0f}pp on {outcome_label[best_outcome]}**"
123
 
124
  if correct:
125
- return f"### {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')}) — CORRECT"
126
  else:
127
- return f"### {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')})"
128
 
129
 
130
  def format_reasoning(match):
131
  a = match["vlm_assessment"]
132
  lines = []
133
- lines.append(f"**Confidence:** {a['confidence']}")
134
  lines.append("")
135
- lines.append(f"**Reasoning:** {a['reasoning']}")
 
136
  lines.append("")
137
- lines.append("**Visual Evidence:**")
138
  for ev in a.get("visual_evidence", []):
139
  lines.append(f"- {ev}")
140
  lines.append("")
141
- lines.append(f"**Edge Signal:** {a['edge_signal']}")
 
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"**{match['home_team']}** vs **{match['away_team']}**")
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
- metrics_text = format_metrics(match)
215
- stats_text = format_stats(match)
 
 
216
  info_text = format_match_info(match)
217
 
218
- return chart, frames, edge_text, reasoning_text, metrics_text, stats_text, info_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
 
221
  correct, total = get_scorecard()
 
222
 
223
- with gr.Blocks(title="Offsides — Tactical Edge Detection") as demo:
 
 
224
  gr.Markdown(f"""
225
  # Offsides — Tactical Edge Detection
226
 
227
- **Where the market gets it wrong.** Multimodal AI analyzes UEFA Champions League footage using YOLO + Qwen-VL 72B on AMD MI300X to detect mispriced prediction markets.
228
 
229
  **Scorecard: {correct}/{total} correct edge calls** | Model: {RESULTS['model']} | Generated: {RESULTS['generated_at'][:10]}
 
 
 
 
 
 
 
 
230
  """)
231
 
232
- with gr.Row():
233
- match_dropdown = gr.Dropdown(
234
- choices=get_match_choices(),
235
- value=get_match_choices()[0],
236
- label="Select Match",
237
- interactive=True,
238
- )
 
 
239
 
240
- with gr.Row():
241
- with gr.Column(scale=1):
242
- prob_chart = gr.Plot(label="Probability Comparison")
243
- edge_badge = gr.Markdown()
244
- reasoning_box = gr.Markdown(label="VLM Assessment")
 
 
245
 
246
- with gr.Column(scale=1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  frame_gallery = gr.Gallery(
248
  label="Annotated Frames (analyzed by VLM)",
249
  columns=2,
250
- height=400,
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
- demo.load(
267
- fn=on_match_select,
268
- inputs=[match_dropdown],
269
- outputs=[prob_chart, frame_gallery, edge_badge, reasoning_box, metrics_box, stats_box, info_box],
270
- )
271
 
272
- gr.Markdown("""
273
- ---
274
- **Architecture:** YouTube highlights → Frame extraction → YOLO detection → Annotation (OpenCV) → Qwen-VL 72B reasoning (AMD MI300X via vLLM on ROCm)
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
- **How it works:** For each upcoming match, the system analyzes the most recent 3 matches for both teams. YOLO detects player positions and ball location. OpenCV renders tactical overlays (defensive lines, compactness ellipses, team colors). Qwen-VL reasons over these annotated frames alongside stats and market odds to identify where the market may be mispriced.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
- Built for the AMD Developer Hackathon 2026 (Track 3: Vision & Multimodal AI)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 94942d44a1181ced14098cb5041214e2086bcfe24bd42c0d2d58f5106e6d7740
  • Pointer size: 131 Bytes
  • Size of remote file: 158 kB
data/vlm_results/frames/Atletico_Madrid_vs_Dortmund_2024-04-10/frame_003.jpg DELETED

Git LFS Details

  • SHA256: 4d836d2269517051a0621903b0f6f64c6f35b2c30d1450180e9f1eb85164bb7f
  • Pointer size: 131 Bytes
  • Size of remote file: 155 kB
data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_001.jpg DELETED

Git LFS Details

  • SHA256: c755504e602ecf35c449580b97c445cf7c67e44b8ea0ee44334ac891236adc76
  • Pointer size: 131 Bytes
  • Size of remote file: 175 kB
data/vlm_results/frames/Atletico_Madrid_vs_Inter_Milan_2024-03-13/frame_005.jpg DELETED

Git LFS Details

  • SHA256: 2095c14380151009ee86fc1881a06c10adc3e5b213f3536b603ec2dece555b32
  • Pointer size: 131 Bytes
  • Size of remote file: 110 kB
data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_001.jpg DELETED

Git LFS Details

  • SHA256: 4b2846d0436074d4a2884d49737c42845f28355b211369cbffa30f494de27a81
  • Pointer size: 131 Bytes
  • Size of remote file: 147 kB
data/vlm_results/frames/Atletico_Madrid_vs_Lazio_2023-12-13/frame_005.jpg DELETED

Git LFS Details

  • SHA256: a23e3eaca6b5f9c730f9325537b0f5baed3de5c55133d6bb06fe8eb9c9e99c66
  • Pointer size: 131 Bytes
  • Size of remote file: 150 kB
data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_000.jpg DELETED

Git LFS Details

  • SHA256: 972a0b27c039d2501ece7daa4cb92eac4f724a9e3af85d4469942ced9bfb5620
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
data/vlm_results/frames/Barcelona_vs_Napoli_2024-03-12/frame_006.jpg DELETED

Git LFS Details

  • SHA256: b509e9e4439916c48c00faefc5133eb3b1abb650f308c942c4f5719d8a95ec35
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
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

  • SHA256: b22be8890eaea879a3ae9c02de9c04b4d547f121eea66ba8cf0ac20ff315ece8
  • Pointer size: 131 Bytes
  • Size of remote file: 140 kB
data/vlm_results/frames/Dortmund_vs_Atletico_Madrid_2024-04-16/frame_006.jpg DELETED

Git LFS Details

  • SHA256: c9a2c7a4c3e11d6fb82c7538b12684465af3dd69c1d4a21472d5847eebc3e66e
  • Pointer size: 131 Bytes
  • Size of remote file: 146 kB
data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_003.jpg DELETED

Git LFS Details

  • SHA256: d9a8a1e0b6d78223c93ef196ad44843926573e291d1b35f4dfa7e7e08a826666
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
data/vlm_results/frames/Dortmund_vs_PSV_2024-03-13/frame_005.jpg DELETED

Git LFS Details

  • SHA256: dceba5062f88e9abaf80ca527031490d15c973da46afab937a86daf0bedaa331
  • Pointer size: 131 Bytes
  • Size of remote file: 127 kB
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

  • SHA256: aea4a3561f1e12334d1b3505c0444591a6f1edcee57f8462e0b2e0ad12e6a735
  • Pointer size: 131 Bytes
  • Size of remote file: 114 kB
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

  • SHA256: 4bec15c5a266c744ebc94457d661e1b22a06ec98c6de6b8a27484932b3a4b398
  • Pointer size: 131 Bytes
  • Size of remote file: 120 kB
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

  • SHA256: ca35e67c3faf97abd0dc7cd97f9aa4c0cbc81ce899f67e40943c3ebe7d763834
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_002.jpg DELETED

Git LFS Details

  • SHA256: 62ddf4c6eddeb7ef36656937dc87715717fc047fc7b0f8c9b4e0b22d8e7dcc93
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
data/vlm_results/frames/PSG_vs_Barcelona_2024-04-16/frame_005.jpg DELETED

Git LFS Details

  • SHA256: b9668261dc5a0894e5ae8432390a1a438abc63cf9105343e4a19f7dbc0eae87b
  • Pointer size: 131 Bytes
  • Size of remote file: 114 kB
data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_000.jpg DELETED

Git LFS Details

  • SHA256: ebf36c1bcb364897a56bd542394eecd8bda302cb5073611de8c11b5fc381b07e
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
data/vlm_results/frames/PSG_vs_Dortmund_2024-05-01/frame_001.jpg DELETED

Git LFS Details

  • SHA256: 7460c08b7d668744e3909dd98251a40a7e77ffcdd99193597a75ed5afb641b44
  • Pointer size: 131 Bytes
  • Size of remote file: 134 kB
data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_001.jpg DELETED

Git LFS Details

  • SHA256: 9b590c02f59d93880621662172f834f99c413cb268ed0e59c44fdf4f3fe335a8
  • Pointer size: 131 Bytes
  • Size of remote file: 160 kB
data/vlm_results/frames/Real_Madrid_vs_Man_City_2024-04-09/frame_004.jpg DELETED

Git LFS Details

  • SHA256: 0588b54b64b061cc107f3913c36526c4c0e3c58fbb084a0f2ce54d754c5f1866
  • Pointer size: 131 Bytes
  • Size of remote file: 168 kB
data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_000.jpg DELETED

Git LFS Details

  • SHA256: 88bd8147f6b6a14f925586419bf722bfb3041919a2097541ecf3d42d37d9cb16
  • Pointer size: 131 Bytes
  • Size of remote file: 119 kB
data/vlm_results/frames/Real_Madrid_vs_RB_Leipzig_2024-03-06/frame_002.jpg DELETED

Git LFS Details

  • SHA256: ad4694b5c14949c50f0e9c051f0c7c04873da9cfb5f93983ef337dd280b06ecb
  • Pointer size: 131 Bytes
  • Size of remote file: 156 kB
data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_002.jpg DELETED

Git LFS Details

  • SHA256: 23c6972188c4c0fc109cfca2e160b9b98f962f65d64a898c48c98b75033abb2b
  • Pointer size: 131 Bytes
  • Size of remote file: 140 kB
data/vlm_results/frames/Real_Sociedad_vs_PSG_2024-03-05/frame_004.jpg DELETED

Git LFS Details

  • SHA256: 45418617a9409f448ffff09bcbb7f35358e2044e2538b3962eff86749cad52d9
  • Pointer size: 131 Bytes
  • Size of remote file: 153 kB
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