| """Offsides — Tactical Edge Detection Demo. |
| |
| Gradio app displaying pre-computed Qwen-VL 72B tactical assessments |
| of UEFA Champions League matches on AMD MI300X. |
| """ |
|
|
| import json |
| from pathlib import Path |
|
|
| import gradio as gr |
| import plotly.graph_objects as go |
|
|
| APP_DIR = Path(__file__).resolve().parent |
| RESULTS_PATH = APP_DIR / "data" / "vlm_results" / "results.json" |
| DEMO_PATH = APP_DIR / "data" / "demo_matches.json" |
| FRAMES_DIR = APP_DIR / "data" / "vlm_results" / "frames" |
|
|
|
|
| def load_results(): |
| with open(RESULTS_PATH) as f: |
| results = json.load(f) |
| with open(DEMO_PATH) as f: |
| demos = json.load(f) |
| demo_lookup = {d["match_id"]: d for d in demos} |
| for m in results["matches"]: |
| demo = demo_lookup.get(m["match_id"], {}) |
| m["first_leg"] = demo.get("first_leg", "") |
| m["odds"] = demo.get("odds", {}) |
| m["narrative"] = demo.get("narrative", "") |
| return results |
|
|
|
|
| RESULTS = load_results() |
| MATCHES = RESULTS["matches"] |
|
|
|
|
| def result_key(actual_result: str) -> str: |
| if actual_result == "home_win": |
| return "home" |
| if actual_result == "away_win": |
| return "away" |
| return "draw" |
|
|
|
|
| def get_match_choices(): |
| choices = [] |
| for m in MATCHES: |
| label = f"{m['home_team']} vs {m['away_team']} — {m['stage']} ({m['date']})" |
| choices.append(label) |
| return choices |
|
|
|
|
| def get_scorecard(): |
| correct = 0 |
| for m in MATCHES: |
| edge = m["vlm_assessment"]["edge"] |
| actual = result_key(m["actual_result"]) |
| best = max(edge.items(), key=lambda x: x[1]) |
| if best[0] == actual: |
| correct += 1 |
| return correct, len(MATCHES) |
|
|
|
|
| def make_prob_chart(match): |
| market = match["market_odds"] |
| vlm = match["vlm_assessment"]["probabilities"] |
|
|
| categories = ["Home", "Draw", "Away"] |
| market_vals = [market["home"] * 100, market["draw"] * 100, market["away"] * 100] |
| vlm_vals = [vlm.get("home", 0) * 100, vlm.get("draw", 0) * 100, vlm.get("away", 0) * 100] |
|
|
| fig = go.Figure() |
| fig.add_trace(go.Bar( |
| name="Market Implied", |
| x=categories, |
| y=market_vals, |
| marker_color="#6366f1", |
| text=[f"{v:.0f}%" for v in market_vals], |
| textposition="outside", |
| )) |
| fig.add_trace(go.Bar( |
| name="VLM Assessment", |
| x=categories, |
| y=vlm_vals, |
| marker_color="#10b981", |
| text=[f"{v:.0f}%" for v in vlm_vals], |
| textposition="outside", |
| )) |
| fig.update_layout( |
| barmode="group", |
| title="Probability Comparison: Market vs VLM", |
| yaxis_title="Probability (%)", |
| yaxis_range=[0, 75], |
| template="plotly_dark", |
| height=350, |
| margin=dict(t=40, b=40), |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), |
| ) |
| return fig |
|
|
|
|
| def get_frame_images(match): |
| images = [] |
| for fp in match.get("frames_used", []): |
| parts = Path(fp).parts |
| match_dir = parts[2] |
| frame_name = parts[-1] |
| local_path = FRAMES_DIR / match_dir / frame_name |
| if local_path.exists(): |
| images.append(str(local_path)) |
| return images |
|
|
|
|
| def format_edge_badge(match): |
| edge = match["vlm_assessment"]["edge"] |
| actual = result_key(match["actual_result"]) |
| best = max(edge.items(), key=lambda x: x[1]) |
| best_outcome, best_val = best |
|
|
| correct = best_outcome == actual |
| outcome_label = {"home": match["home_team"], "draw": "Draw", "away": match["away_team"]} |
| badge = f"**Edge: +{best_val*100:.0f}pp on {outcome_label[best_outcome]}**" |
|
|
| if correct: |
| return f"### {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')}) — CORRECT" |
| else: |
| return f"### {badge}\n\nActual result: **{match['actual_score']}** ({match['actual_result'].replace('_', ' ')})" |
|
|
|
|
| def format_reasoning(match): |
| a = match["vlm_assessment"] |
| lines = [] |
| lines.append(f"**Confidence:** {a['confidence']}") |
| lines.append("") |
| lines.append(f"**Reasoning:** {a['reasoning']}") |
| lines.append("") |
| lines.append("**Visual Evidence:**") |
| for ev in a.get("visual_evidence", []): |
| lines.append(f"- {ev}") |
| lines.append("") |
| lines.append(f"**Edge Signal:** {a['edge_signal']}") |
| return "\n".join(lines) |
|
|
|
|
| def format_metrics(match): |
| ctx = match.get("metrics_context", {}) |
| lines = [] |
| for side, label in [("home", match["home_team"]), ("away", match["away_team"])]: |
| data = ctx.get(side, {}) |
| metrics = data.get("metrics", {}) |
| if not metrics: |
| continue |
| lines.append(f"**{label}** (last 3 matches):") |
| matches_analyzed = data.get("matches_analyzed", []) |
| if matches_analyzed: |
| lines.append(f"- Matches: {', '.join(m.replace('_', ' ') for m in matches_analyzed)}") |
| if "avg_pressing_speed" in metrics: |
| lines.append(f"- Pressing speed: {metrics['avg_pressing_speed']:.4f}") |
| if "avg_def_line_movement" in metrics: |
| lines.append(f"- Defensive line movement: {metrics['avg_def_line_movement']:.4f}") |
| if "avg_compactness_delta" in metrics: |
| lines.append(f"- Compactness delta: {metrics['avg_compactness_delta']:.3f}") |
| if "avg_transition_speed" in metrics: |
| lines.append(f"- Transition speed: {metrics['avg_transition_speed']:.4f}") |
| lines.append("") |
| return "\n".join(lines) |
|
|
|
|
| def format_stats(match): |
| stats = match.get("stats", {}) |
| lines = [] |
| for side in ["home", "away"]: |
| s = stats.get(side, {}) |
| if not s: |
| continue |
| lines.append(f"**{s.get('team', side.title())}:**") |
| lines.append(f"| Metric | Value |") |
| lines.append(f"|--------|-------|") |
| lines.append(f"| xG/match | {s.get('xg_last5', '-')} |") |
| lines.append(f"| xGA/match | {s.get('xga_last5', '-')} |") |
| lines.append(f"| PPDA | {s.get('ppda', '-')} |") |
| lines.append(f"| Possession | {s.get('possession_pct', '-')}% |") |
| lines.append(f"| Form | {s.get('form', '-')} |") |
| lines.append(f"| Goals (last 5) | {s.get('goals_scored_last5', '-')}F / {s.get('goals_conceded_last5', '-')}A |") |
| lines.append("") |
| return "\n".join(lines) |
|
|
|
|
| def format_match_info(match): |
| lines = [] |
| lines.append(f"**{match['home_team']}** vs **{match['away_team']}**") |
| lines.append(f"- Stage: {match['stage']}") |
| lines.append(f"- Date: {match['date']}") |
| if match.get("first_leg"): |
| lines.append(f"- First leg: {match['first_leg']}") |
| odds = match.get("odds", {}) |
| if odds: |
| lines.append(f"- Decimal odds: {match['home_team']} {odds.get('home', '-')} / Draw {odds.get('draw', '-')} / {match['away_team']} {odds.get('away', '-')}") |
| market = match["market_odds"] |
| lines.append(f"- Implied probability: {match['home_team']} {market['home']*100:.0f}% / Draw {market['draw']*100:.0f}% / {match['away_team']} {market['away']*100:.0f}%") |
| if match.get("narrative"): |
| lines.append(f"\n*{match['narrative']}*") |
| return "\n".join(lines) |
|
|
|
|
| def on_match_select(choice): |
| idx = get_match_choices().index(choice) |
| match = MATCHES[idx] |
|
|
| chart = make_prob_chart(match) |
| frames = get_frame_images(match) |
| edge_text = format_edge_badge(match) |
| reasoning_text = format_reasoning(match) |
| metrics_text = format_metrics(match) |
| stats_text = format_stats(match) |
| info_text = format_match_info(match) |
|
|
| return chart, frames, edge_text, reasoning_text, metrics_text, stats_text, info_text |
|
|
|
|
| correct, total = get_scorecard() |
|
|
| with gr.Blocks(title="Offsides — Tactical Edge Detection") as demo: |
| gr.Markdown(f""" |
| # Offsides — Tactical Edge Detection |
| |
| **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. |
| |
| **Scorecard: {correct}/{total} correct edge calls** | Model: {RESULTS['model']} | Generated: {RESULTS['generated_at'][:10]} |
| """) |
|
|
| with gr.Row(): |
| match_dropdown = gr.Dropdown( |
| choices=get_match_choices(), |
| value=get_match_choices()[0], |
| label="Select Match", |
| interactive=True, |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| prob_chart = gr.Plot(label="Probability Comparison") |
| edge_badge = gr.Markdown() |
| reasoning_box = gr.Markdown(label="VLM Assessment") |
|
|
| with gr.Column(scale=1): |
| frame_gallery = gr.Gallery( |
| label="Annotated Frames (analyzed by VLM)", |
| columns=2, |
| height=400, |
| ) |
| with gr.Accordion("Tactical Metrics", open=False): |
| metrics_box = gr.Markdown() |
| with gr.Accordion("Match Statistics", open=False): |
| stats_box = gr.Markdown() |
|
|
| with gr.Row(): |
| info_box = gr.Markdown() |
|
|
| match_dropdown.change( |
| fn=on_match_select, |
| inputs=[match_dropdown], |
| outputs=[prob_chart, frame_gallery, edge_badge, reasoning_box, metrics_box, stats_box, info_box], |
| ) |
|
|
| demo.load( |
| fn=on_match_select, |
| inputs=[match_dropdown], |
| outputs=[prob_chart, frame_gallery, edge_badge, reasoning_box, metrics_box, stats_box, info_box], |
| ) |
|
|
| gr.Markdown(""" |
| --- |
| **Architecture:** YouTube highlights → Frame extraction → YOLO detection → Annotation (OpenCV) → Qwen-VL 72B reasoning (AMD MI300X via vLLM on ROCm) |
| |
| **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. |
| |
| Built for the AMD Developer Hackathon 2026 (Track 3: Vision & Multimodal AI) |
| """) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|