Spaces:
Sleeping
Sleeping
File size: 17,011 Bytes
514d439 1d4f5de 514d439 8e450ce 514d439 de96946 e30c0a5 514d439 de96946 514d439 1d4f5de 514d439 1d4f5de 85b59ec 514d439 1d4f5de 514d439 1d4f5de e30c0a5 514d439 1d4f5de e30c0a5 514d439 e30c0a5 514d439 e30c0a5 1d4f5de e30c0a5 514d439 1d4f5de e30c0a5 1d4f5de e30c0a5 1d4f5de 514d439 de96946 186ce06 514d439 1d4f5de de96946 514d439 1d4f5de de96946 1d4f5de 514d439 1d4f5de 514d439 de96946 1d4f5de de96946 1d4f5de de96946 1d4f5de de96946 1d4f5de 514d439 de96946 514d439 186ce06 514d439 186ce06 514d439 2950910 514d439 1d4f5de 2950910 514d439 186ce06 2950910 186ce06 514d439 2950910 514d439 39412a3 514d439 de96946 514d439 39412a3 514d439 1d4f5de 2950910 514d439 39412a3 514d439 1d4f5de 39412a3 514d439 2950910 0a99931 2950910 0a99931 514d439 de96946 514d439 de96946 514d439 1d4f5de e30c0a5 1d4f5de 39412a3 514d439 1d4f5de 514d439 e30c0a5 1d4f5de e30c0a5 1d4f5de de96946 514d439 de96946 1d4f5de de96946 1d4f5de 514d439 1d4f5de 514d439 5b67679 514d439 1d4f5de 514d439 0a99931 514d439 1d4f5de e30c0a5 1d4f5de e30c0a5 1d4f5de e30c0a5 514d439 de96946 1d4f5de de96946 1d4f5de de96946 514d439 186ce06 2950910 39412a3 514d439 0a99931 2950910 0a99931 514d439 de96946 85b59ec | 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 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 | """
VAR-style offside visualizer β Gradio app.
Flow:
upload -> scrub to the frame -> draw 2 goal-parallel lines on that frame
-> Detect players (GPU, once/frame) -> click players to select
-> mark defenders -> Build the 3D scene with a draggable offside plane.
The GPU is touched ONLY inside pipeline.gpu (detect_frame / reconstruct_selected);
every other callback here runs on cached numpy and stays on the CPU.
"""
import os
# headless GL + CUDA fragmentation hygiene must be set before any heavy import
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import numpy as np
import gradio as gr
from pipeline.video import probe_video, grab_frame
from pipeline.overlay import (annotate_detections, draw_lines, draw_masks,
pick_box, pick_mask)
from pipeline.autolines import propose_lines
from pipeline import geometry as G
# detector radio choice -> (backend, overlay/selection style)
DETECTORS = {
"ViTDet (boxes)": ("vitdet", "boxes"),
"RF-DETR (boxes)": ("rfdetr", "boxes"),
"RF-DETR (segments)": ("rfdetr", "segments"),
}
def _resolve(detector):
return DETECTORS.get(detector, ("vitdet", "boxes"))
def _render(frame, people, selected, style):
if style == "segments":
return draw_masks(frame, people, selected)
return annotate_detections(frame, people, selected)
def _pick(people, x, y, style):
return pick_mask(people, x, y) if style == "segments" else pick_box(people, x, y)
# ============================================================================
# Stage (a): upload + frame scrubbing (pure CPU)
# ============================================================================
def on_upload(video_path):
"""New clip: size the slider, show frame 0, reset lines/frame state."""
if not video_path:
return (gr.update(maximum=1, value=0), None, "Upload a clip to begin.",
0, None, [], "", [], 0)
n, fps = probe_video(video_path)
n_max = max(n - 1, 0)
frame = grab_frame(video_path, 0)
return (
gr.update(maximum=max(n_max, 1), value=0),
frame,
f"{n} frames @ {fps:.1f} fps β scrub to the moment the ball is played, "
"then auto-detect or click the 2 goal-parallel lines.",
n_max,
frame, # st_frame: clean copy for line redraws
[], # st_lines reset
"", # line_status reset
[], # st_families reset
0, # st_fam_idx reset
)
def on_scrub(video_path, idx):
"""Show the new frame and reset any half-drawn / proposed lines on it."""
if not video_path:
return None, None, [], "", [], 0
frame = grab_frame(video_path, int(idx))
return frame, frame, [], "", [], 0
def step_frame(idx, delta, n_max):
return max(0, min(int(n_max), int(idx) + delta))
# ============================================================================
# Stage (b): draw 2 goal-parallel lines on the scrubbed frame (CPU)
# ============================================================================
def on_line_click(line_pts, clean_frame, evt: gr.SelectData):
"""Collect 4 clicks = 2 lines; always redraw from the clean frame (no drift).
A manual click clears any auto-proposed families (so Flip stops applying).
"""
pts = list(line_pts) if line_pts else []
if len(pts) >= 4:
pts = []
pts.append([float(evt.index[0]), float(evt.index[1])])
img = draw_lines(clean_frame, pts)
status = {1: "line 1: 1/2", 2: "line 1 set", 3: "line 2: 1/2",
4: "both lines set β β now Detect players"}.get(len(pts), "")
return pts, img, status, [], 0
def on_auto_lines(clean_frame):
"""Propose the 2 goal-parallel lines from detected pitch lines."""
if clean_frame is None:
return [], None, "Scrub to a frame first.", [], 0
fams = propose_lines(clean_frame)
if not fams:
return [], gr.update(), ("No clear pitch lines found β draw the 2 "
"goal-parallel lines by hand."), [], 0
pts = fams[0]["pts"]
img = draw_lines(clean_frame, pts)
status = (f"Proposed goal-parallel lines (direction 1/{len(fams)}). If the "
"offside axis looks wrong, click **Flip line direction**, or just "
"click the frame to redraw by hand.")
return pts, img, status, fams, 0
def on_flip_lines(families, fam_idx, clean_frame):
"""Switch the proposal to the other detected line-family."""
if not families:
return gr.update(), gr.update(), "Run **Auto-detect lines** first.", gr.update()
if len(families) < 2:
return (families[0]["pts"], draw_lines(clean_frame, families[0]["pts"]),
"Only one line direction was found β redraw by hand if it's wrong.", 0)
new_idx = (int(fam_idx) + 1) % len(families)
pts = families[new_idx]["pts"]
return (pts, draw_lines(clean_frame, pts),
f"Line direction {new_idx + 1}/{len(families)}.", new_idx)
# ============================================================================
# Stage (c): detect (GPU, cached) + show boxes (CPU after the call)
# ============================================================================
def on_detect(video_path, idx, conf, detector):
"""Detect step: chosen detector (ViTDet / RF-DETR), boxes + masks. No meshes here."""
from pipeline.gpu import detect_frame
if not video_path:
return None, [], [], "Upload a clip first.", gr.update(choices=[], value=[])
backend, style = _resolve(detector)
people = detect_frame(video_path, idx, conf, backend)
if not people:
return (None, [], [], "No players detected β lower the confidence slider.",
gr.update(choices=[], value=[]))
annotated = _render(grab_frame(video_path, idx), people, [], style)
unit = "silhouette" if style == "segments" else "box"
msg = (f"Detected {len(people)} players with {detector}. Click a player's "
f"{unit} to select (click again to deselect).")
return annotated, people, [], msg, gr.update(choices=[], value=[])
# ============================================================================
# Stage (d): click players to select (CPU)
# ============================================================================
def on_select_player(people, selected, clean_frame, cur_def, detector, evt: gr.SelectData):
"""Toggle the clicked player; refresh highlight + defender choices (box or segment)."""
if not people:
return None, selected or [], "Detect players first.", gr.update()
_, style = _resolve(detector)
hit = _pick(people, evt.index[0], evt.index[1], style)
sel = list(selected or [])
if hit is not None:
sel.remove(hit) if hit in sel else sel.append(hit)
sel = sorted(sel)
img = _render(clean_frame, people, sel, style)
msg = (f"Selected players: {sel}. Mark defenders below, then Build."
if sel else "Click a player to select.")
keep_def = [d for d in (cur_def or []) if d in sel]
return img, sel, msg, gr.update(choices=sel, value=keep_def)
def on_detector_change(people, selected, clean_frame, detector):
"""Re-render current detections in the new style; hint to re-Detect on backend swap."""
if not people:
return gr.update(), "Detector set β click Detect players to apply."
_, style = _resolve(detector)
return _render(clean_frame, people, selected or [], style), \
"Re-rendered. For a different backend, click Detect players to re-run."
# ============================================================================
# Stage (e)+(f): place players + build the Plotly scene with a draggable plane
# ============================================================================
def on_build(video_path, idx, people_det, selected_ids, line_pts, flip_up,
attack_dir, defender_ids):
from pipeline.gpu import reconstruct_selected, get_faces
if not selected_ids:
return None, "Click at least one player to select.", gr.update(), None, +1, [], {}
if not line_pts or len(line_pts) < 4:
return None, "Draw 2 goal-parallel lines (4 points) on the frame first.", \
gr.update(), None, +1, [], {}
# Reconstruct ONLY the selected players' boxes (the heavy GPU step).
selected_ids = sorted(int(i) for i in selected_ids)
boxes = [people_det[i]["bbox"] for i in selected_ids]
recon = reconstruct_selected(video_path, idx, boxes)
if not recon:
return None, "Reconstruction returned no meshes.", gr.update(), None, +1, [], {}
# Key meshes back to their original detection ids (recon order == boxes order).
people = {selected_ids[k]: recon[k] for k in range(len(recon))}
faces = get_faces()
h, w = grab_frame(video_path, idx).shape[:2]
focal = people[selected_ids[0]]["focal_length"]
gdir = G.goal_dir_from_lines(line_pts, focal, w, h)
placed = G.place_players(people, selected_ids, gdir, flip_up=flip_up)
med = float(np.median([placed[i][:, 2].max() for i in placed]))
masks = {i: G.non_arm_mask(people[i]) for i in selected_ids} # exclude arms/hands
attack_sign = +1 if str(attack_dir).startswith("+X") else -1
dset = [int(d) for d in (defender_ids or [])]
plane_x = G.offside_plane_x(placed, attack_sign, dset, masks)
fig = G.build_scene(placed, faces, plane_x, attack_sign, dset, masks)
allX = np.vstack(list(placed.values()))[:, 0]
x0, x1 = float(allX.min() - 4), float(allX.max() + 4)
plane_update = gr.update(minimum=x0, maximum=x1, value=float(plane_x),
visible=True, label="Drag the offside plane (X, m)")
warn = " β heights look wrong β toggle 'flip up'." if med < 1.0 else ""
return (fig, f"Median player height {med:.2f} m (expect ~1.7β1.9).{warn}",
plane_update, placed, attack_sign, dset, masks)
def on_plane(placed, plane_x, attack_sign, defender_ids, masks):
"""Re-render the scene at a new plane X β pure CPU on the cached placement."""
from pipeline.gpu import get_faces
if not placed:
return gr.update()
return G.build_scene(placed, get_faces(), float(plane_x),
int(attack_sign), defender_ids or [], masks)
def on_gen3js(placed, plane_x, attack_sign, defender_ids, masks):
"""Generate a clean three.js view of the current scene (uses the current plane)."""
from pipeline.gpu import get_faces
from pipeline import threed
if not placed:
return "<p style='color:#9a8bd0'>Build a 3D scene first, then generate.</p>"
return threed.scene_html(placed, get_faces(), float(plane_x),
int(attack_sign), defender_ids or [], masks)
# ============================================================================
# UI
# ============================================================================
# Roboflow-flavored theme (violet primary β Roboflow purple #7C3AED) + light CSS.
RF_PURPLE = "#7C3AED"
THEME = gr.themes.Soft(primary_hue=gr.themes.colors.violet,
neutral_hue=gr.themes.colors.slate,
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"])
RF_CSS = """
.gradio-container {max-width: 1180px !important}
#rf-header {background: #7C3AED; color: #fff; padding: 18px 22px; border-radius: 14px;
margin-bottom: 6px}
#rf-header h2 {color: #fff !important; margin: 0 0 4px 0}
#rf-header p {color: #EDE7FF !important; margin: 0; font-size: 0.92rem}
.gr-button-primary, button.primary {background: #7C3AED !important; border-color: #7C3AED !important}
"""
with gr.Blocks(title="VAR Offside Visualizer") as demo:
gr.HTML(
"<div id='rf-header'><h2>VAR-style Offside Visualizer</h2>"
"<p>Upload β scrub β click 2 goal-parallel lines β Detect β "
"click players to select β mark defenders β Build</p></div>"
)
gr.Markdown(
"_Scale comes from reconstructed body height, so positions are approximate "
"metres β good for relative offside ordering, not sub-10 cm calls._"
)
# session state
st_nmax = gr.State(0) # last valid frame index
st_frame = gr.State(None) # clean RGB of the current frame
st_people = gr.State([]) # slim detections
st_lines = gr.State([]) # clicked / proposed line points
st_families = gr.State([]) # auto-proposed line families
st_fam_idx = gr.State(0) # which proposed family is active
st_selected = gr.State([]) # player ids selected by clicking
st_placed = gr.State(None) # placed meshes after build
st_attack = gr.State(+1)
st_defenders = gr.State([])
st_masks = gr.State({}) # per-player non-arm vertex masks
video = gr.Video(label="1. Upload match clip")
status = gr.Markdown()
with gr.Row():
frame_slider = gr.Slider(0, 1, value=0, step=1,
label="2. Scrub to the offside frame")
with gr.Row():
prev_btn = gr.Button("β prev frame")
next_btn = gr.Button("next frame βΆ")
# Stage (b): lines drawn on the scrubbed frame β auto-proposed or by hand
frame_view = gr.Image(label="3. Goal-parallel lines: auto-detect or click 4 points",
interactive=True)
with gr.Row():
auto_lines_btn = gr.Button("β¨ Auto-detect lines")
flip_lines_btn = gr.Button("β Flip line direction")
line_status = gr.Markdown()
# Stage (c)/(d): detect, then click to select
with gr.Row():
thr = gr.Slider(0.0, 0.95, value=0.3, step=0.05, label="Detection confidence")
detector = gr.Radio(list(DETECTORS.keys()), value="ViTDet (boxes)",
label="Detector")
detect_btn = gr.Button("4. Detect players (GPU)", variant="primary")
detect_view = gr.Image(label="5. Click a player to select (click again to deselect)",
interactive=True)
select_status = gr.Markdown()
# Stage (e)/(f)
defenders = gr.CheckboxGroup(choices=[], label="6. Defenders (incl. GK) β sets the offside line")
with gr.Row():
flip = gr.Checkbox(False, label="flip up (if players are upside-down)")
attack = gr.Radio(["βX β", "+X β"], value="βX β",
label="Attacking direction")
build_btn = gr.Button("7. Build 3D scene + offside line", variant="primary")
scene = gr.Plot(label="3D scene")
plane_slider = gr.Slider(-10, 10, value=0, step=0.05, visible=False,
label="Drag the offside plane (X, m)")
build_status = gr.Markdown()
gen3js_btn = gr.Button("π₯ Generate clean 3D scene (three.js)")
scene3js = gr.HTML()
# --- wiring ---
video.change(on_upload, [video],
[frame_slider, frame_view, status, st_nmax, st_frame, st_lines,
line_status, st_families, st_fam_idx])
frame_slider.change(on_scrub, [video, frame_slider],
[frame_view, st_frame, st_lines, line_status,
st_families, st_fam_idx])
prev_btn.click(lambda i, m: step_frame(i, -1, m), [frame_slider, st_nmax], [frame_slider])
next_btn.click(lambda i, m: step_frame(i, +1, m), [frame_slider, st_nmax], [frame_slider])
frame_view.select(on_line_click, [st_lines, st_frame],
[st_lines, frame_view, line_status, st_families, st_fam_idx])
auto_lines_btn.click(on_auto_lines, [st_frame],
[st_lines, frame_view, line_status, st_families, st_fam_idx])
flip_lines_btn.click(on_flip_lines, [st_families, st_fam_idx, st_frame],
[st_lines, frame_view, line_status, st_fam_idx])
detect_btn.click(on_detect, [video, frame_slider, thr, detector],
[detect_view, st_people, st_selected, select_status, defenders])
detect_view.select(on_select_player,
[st_people, st_selected, st_frame, defenders, detector],
[detect_view, st_selected, select_status, defenders])
detector.change(on_detector_change, [st_people, st_selected, st_frame, detector],
[detect_view, select_status])
build_btn.click(
on_build,
[video, frame_slider, st_people, st_selected, st_lines, flip, attack, defenders],
[scene, build_status, plane_slider, st_placed, st_attack, st_defenders, st_masks])
plane_slider.change(on_plane,
[st_placed, plane_slider, st_attack, st_defenders, st_masks],
[scene])
gen3js_btn.click(on_gen3js,
[st_placed, plane_slider, st_attack, st_defenders, st_masks],
[scene3js])
if __name__ == "__main__":
demo.queue().launch(server_name="0.0.0.0", server_port=7860,
theme=THEME, css=RF_CSS) |