"""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()