File size: 6,474 Bytes
9374e3e
 
 
 
 
 
 
 
 
c52625f
9374e3e
 
 
 
 
 
 
 
 
 
c52625f
 
 
 
 
 
 
 
 
9374e3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c52625f
 
9374e3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""AI VAR β€” Gradio UI (HF Spaces entrypoint).

Upload one or more camera angles (videos ≀2 min and/or images); each file is
treated as a separate view and fused SoccerNet-VARS style.
"""
import traceback
from pathlib import Path

import gradio as gr
import spaces

from aivar import cleanup
from aivar.ingest import IngestError
from aivar.llm import BudgetExceeded
from aivar.pipeline import analyze
from aivar.ratelimit import RateLimited
from aivar.schemas import AnalysisResult, Decision, EvidenceStatus

cleanup.start_sweeper()


@spaces.GPU(duration=90)
def gpu_vision(views):
    """Runs the YOLO+ByteTrack stage on ZeroGPU-allocated hardware."""
    from aivar import vision
    for v in views:
        vision.analyze_view(v)
    return views

STATUS_ICON = {EvidenceStatus.CONFIRMED: "βœ…",
               EvidenceStatus.CONTRADICTED: "❌",
               EvidenceStatus.NOT_VISIBLE: "πŸ•ΆοΈ"}

DECISION_COLOR = {Decision.RED_CARD: "#c62828", Decision.YELLOW_CARD: "#f9a825",
                  Decision.PENALTY: "#6a1b9a", Decision.INSUFFICIENT_EVIDENCE: "#546e7a"}


def _verdict_md(r: AnalysisResult) -> str:
    v = r.verdict
    color = DECISION_COLOR.get(v.decision, "#2e7d32")
    cached = " · ⚑ from cache (0 Gemini calls)" if r.from_cache else f" · {r.gemini_calls} Gemini calls"
    lines = [
        f"## <span style='color:{color}'>{v.decision.value}</span>",
        f"**Incident:** {v.incident.value} &nbsp;Β·&nbsp; **Confidence:** {v.confidence}%{cached}",
    ]
    inc = r.incident
    if any([inc.offending_team, inc.offending_player, inc.fouled_team, inc.fouled_player]):
        foul_by = " ".join(p for p in [inc.offending_team, inc.offending_player] if p) or "unknown"
        on = " ".join(p for p in [inc.fouled_team, inc.fouled_player] if p) or "unknown"
        lines.append(f"**Foul by:** {foul_by} &nbsp;Β·&nbsp; **On:** {on}")
    lines += ["", f"**Why:** {v.why}"]
    if v.why_not:
        lines += ["", f"**Why not:** {v.why_not}"]
    if v.rule_citations:
        lines += ["", "### πŸ“– Rule citations (IFAB Laws of the Game 2025/26)"]
        for c in v.rule_citations:
            lines.append(f"> **{c.law} β€” {c.section}**: β€œ{c.quote}”")
    if v.missing_evidence:
        lines += ["", "### πŸ” Missing evidence"]
        lines += [f"- {m}" for m in v.missing_evidence]
    if v.recommendation:
        lines += ["", f"**Recommendation:** {v.recommendation}"]
    return "\n".join(lines)


def _evidence_md(r: AnalysisResult) -> str:
    if not r.evidence:
        return "_No checklist evaluated._"
    lines = ["### Evidence checklist (fused across angles)"]
    for e in r.evidence:
        icon = STATUS_ICON[e.status]
        src = f" β€” via Angle {e.source_angle}" if e.source_angle else ""
        crit = " **[critical]**" if e.critical else ""
        conf = f" ({e.confidence}%)" if e.confidence else ""
        lines.append(f"- {icon} **{e.question}**{crit}{src}{conf}  \n  {e.detail}")
        if e.conflict:
            lines.append("  ⚠️ *Angles disagree on this item.*")
    return "\n".join(lines)


def preview_files(files):
    if not files:
        return []
    paths = [f.name if hasattr(f, "name") else f for f in files]
    return [(p, f"Angle {i}") for i, p in enumerate(paths, start=1)]


def run(files, user_key, progress=gr.Progress()):
    if not files:
        raise gr.Error("Upload at least one video (≀2 min) or image.")
    paths = [f.name if hasattr(f, "name") else f for f in files]
    api_key = (user_key or "").strip() or None
    try:
        result = analyze(paths, progress=lambda m: progress(0, desc=m), api_key=api_key,
                          vision_fn=gpu_vision)
    except (IngestError, BudgetExceeded, RateLimited) as e:
        raise gr.Error(str(e))
    except Exception as e:
        traceback.print_exc()
        raise gr.Error(f"Analysis failed: {e}")

    gallery = []
    for view in result.views:
        for kf in view.keyframes:
            if not Path(kf.path).exists():
                continue  # swept by the frame-cleanup TTL; cache hit still shows verdict/evidence
            tag = f"Angle {view.angle_id} @ {kf.timestamp:.2f}s"
            if kf.is_replay:
                tag += " (replay)"
            gallery.append((kf.path, tag))
    return _verdict_md(result), _evidence_md(result), gallery


with gr.Blocks(title="AI VAR β€” Football Referee Assistant") as demo:
    gr.Markdown(
        "# ⚽ AI VAR β€” Intelligent Referee Decision Assistant\n"
        "Upload **multiple camera angles** β€” videos (≀2 min) and/or photos of the same "
        "incident. Decisions are grounded in the **IFAB Laws of the Game 2025/26** and "
        "the system refuses to guess when evidence is insufficient.")
    with gr.Row():
        with gr.Column(scale=1):
            files = gr.File(label="Camera angles (videos / images)",
                            file_count="multiple",
                            file_types=[".mp4", ".mov", ".avi", ".mkv", ".webm",
                                        ".jpg", ".jpeg", ".png", ".webp"])
            preview_out = gr.Gallery(label="Preview β€” uploaded angles", columns=3, height=240)
            user_key = gr.Textbox(
                label="Your Gemini API key (only needed after the daily free limit)",
                type="password", placeholder="AIza…")
            gr.Markdown("*Free tier: 25 analyses/day globally. After that, paste your "
                        "own key β€” it is used only for your request and never stored.*")
            btn = gr.Button("πŸ” Analyze incident", variant="primary")
            gr.Markdown("*Repeat uploads of the same footage β€” even re-encoded or "
                        "trimmed β€” are served instantly from the perceptual cache. "
                        "Extracted keyframe images are auto-deleted 10 minutes after "
                        "creation for privacy/disk hygiene β€” cached verdicts stay "
                        "instant, but keyframe thumbnails may no longer display.*")
        with gr.Column(scale=2):
            verdict_out = gr.Markdown(label="Verdict")
            evidence_out = gr.Markdown(label="Evidence")
    gallery_out = gr.Gallery(label="Annotated keyframes by angle", columns=6, height=260)

    files.change(preview_files, inputs=[files], outputs=[preview_out])
    btn.click(run, inputs=[files, user_key], outputs=[verdict_out, evidence_out, gallery_out])

if __name__ == "__main__":
    demo.launch()