VAR offside visualizer
Browse files- Dockerfile +39 -0
- README.md +66 -7
- TODO.md +21 -0
- app.py +215 -0
- pipeline/__init__.py +8 -0
- pipeline/geometry.py +135 -0
- pipeline/gpu.py +89 -0
- pipeline/overlay.py +36 -0
- pipeline/video.py +26 -0
- requirements.txt +35 -0
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A100 / dedicated-GPU Hugging Face Space (Docker SDK).
|
| 2 |
+
# Models load once at boot and stay warm; pause the Space to stop billing.
|
| 3 |
+
|
| 4 |
+
FROM pytorch/pytorch:2.4.1-cuda12.1-cudnn9-runtime
|
| 5 |
+
|
| 6 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 7 |
+
PYOPENGL_PLATFORM=egl \
|
| 8 |
+
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
|
| 9 |
+
SAM3D_DIR=/app/sam-3d-body \
|
| 10 |
+
HF_HOME=/app/.cache/huggingface
|
| 11 |
+
|
| 12 |
+
# System libs for OpenCV / pyrender / video decoding
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
git ffmpeg libgl1 libglib2.0-0 libosmesa6 libegl1 \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
|
| 19 |
+
# Clone the model repo (added to sys.path by pipeline/gpu.py via SAM3D_DIR)
|
| 20 |
+
RUN git clone https://github.com/facebookresearch/sam-3d-body.git /app/sam-3d-body
|
| 21 |
+
|
| 22 |
+
# Python deps. detectron2 is pinned to a1ce2f9 and built --no-build-isolation
|
| 23 |
+
# --no-deps so it compiles against the torch already in the base image — the
|
| 24 |
+
# single most fragile step; the pinned base keeps it reproducible.
|
| 25 |
+
COPY requirements.txt /app/requirements.txt
|
| 26 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt \
|
| 27 |
+
&& pip install --no-cache-dir \
|
| 28 |
+
"git+https://github.com/facebookresearch/detectron2.git@a1ce2f9" --no-build-isolation --no-deps \
|
| 29 |
+
&& pip install --no-cache-dir "git+https://github.com/microsoft/MoGe.git"
|
| 30 |
+
|
| 31 |
+
# App (UI + the pipeline package; GPU code is isolated in pipeline/gpu.py)
|
| 32 |
+
COPY app.py /app/app.py
|
| 33 |
+
COPY pipeline /app/pipeline
|
| 34 |
+
|
| 35 |
+
# Writable cache (HF Spaces runs as a non-root user)
|
| 36 |
+
RUN mkdir -p /app/.cache/huggingface && chmod -R 777 /app/.cache
|
| 37 |
+
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,12 +1,71 @@
|
|
| 1 |
---
|
| 2 |
-
title: VAR
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: apache-2.0
|
| 9 |
-
short_description: VAR Offside - SAM3
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: VAR Offside Visualizer
|
| 3 |
+
emoji: 🥅
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# VAR-style Offside Visualizer
|
| 12 |
+
|
| 13 |
+
Upload a match clip, scrub to the moment the ball is played, reconstruct selected
|
| 14 |
+
players in 3D with SAM 3D Body, and place them on a virtual pitch with a draggable
|
| 15 |
+
offside plane.
|
| 16 |
+
|
| 17 |
+
## Pipeline
|
| 18 |
+
|
| 19 |
+
1. **Upload** a video clip.
|
| 20 |
+
2. **Scrub** to the offside frame (slider + prev/next, frames seeked on demand).
|
| 21 |
+
3. **Detect** players on that frame — the only GPU step, cached per (video, frame, threshold).
|
| 22 |
+
4. **Select** the players to analyze and mark the defenders (incl. GK).
|
| 23 |
+
5. **Click two goal-parallel lines** (4 points) on the detected frame to fix the offside axis.
|
| 24 |
+
6. **Build** the 3D scene; drag the offside plane and read the OFFSIDE / NO-OFFSIDE verdict.
|
| 25 |
+
|
| 26 |
+
The GPU runs once per frame (`pipeline/gpu.py`). Scrubbing, line geometry,
|
| 27 |
+
placement, plotting, and the draggable plane are all CPU on the cached result.
|
| 28 |
+
|
| 29 |
+
## Code layout
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
app.py Gradio UI + event wiring (CPU)
|
| 33 |
+
pipeline/
|
| 34 |
+
video.py frame seek/probe (CPU)
|
| 35 |
+
gpu.py model load + reconstruct_frame ← the ONLY GPU code
|
| 36 |
+
geometry.py vanishing point, ground fit, field frame, scene (CPU)
|
| 37 |
+
overlay.py detection boxes + line-click drawing (CPU)
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Isolating the GPU in `pipeline/gpu.py` means moving inference to a serverless
|
| 41 |
+
backend (Modal / ZeroGPU) later only touches `reconstruct_frame`.
|
| 42 |
+
|
| 43 |
+
## Deploy
|
| 44 |
+
|
| 45 |
+
This is a **Docker SDK** Space for **dedicated GPU hardware** (A100 recommended):
|
| 46 |
+
|
| 47 |
+
1. Set hardware to an A100 tier.
|
| 48 |
+
2. Add a secret `HF_TOKEN` — a read token for an account with approved access to
|
| 49 |
+
the gated **`facebook/sam-3d-body-dinov3`**. (Override the repo with the
|
| 50 |
+
`SAM3D_REPO_ID` env var if you use a different checkpoint.)
|
| 51 |
+
3. First boot builds the image and downloads ~7 GB of weights — give it time.
|
| 52 |
+
After that, the model stays warm until you pause the Space.
|
| 53 |
+
|
| 54 |
+
**Cost control:** dedicated GPU bills while the Space is running, with no
|
| 55 |
+
auto-shutoff. Pause the Space from its settings when you are not using it.
|
| 56 |
+
|
| 57 |
+
### Why not ZeroGPU?
|
| 58 |
+
|
| 59 |
+
ZeroGPU allocates the GPU per call, caps call duration, enforces a daily quota,
|
| 60 |
+
and cold-loads the ~7 GB model stack on each allocation — a poor fit for an
|
| 61 |
+
interactive video-scrubbing session, and it requires the Gradio SDK (not Docker).
|
| 62 |
+
|
| 63 |
+
## Notes / limits
|
| 64 |
+
|
| 65 |
+
- Scale comes from the reconstructed body height, so positions are approximate
|
| 66 |
+
metres — good for relative offside ordering, **not** sub-10 cm officiating calls.
|
| 67 |
+
The verdict surfaces a "too close to call" band rather than implying false precision.
|
| 68 |
+
- The offside point currently uses the forward-most body vertex **including arms**;
|
| 69 |
+
excluding arms (via MHR body-part labels) is planned — see `TODO.md`.
|
| 70 |
+
- "Find the offside moment" is manual scrubbing; automatic pass-instant detection
|
| 71 |
+
(ball tracking) is future work.
|
TODO.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Parked — explicitly out of scope for now (SPEC §7)
|
| 2 |
+
|
| 3 |
+
These are deliberately NOT built yet. Listed so they aren't lost.
|
| 4 |
+
|
| 5 |
+
- **Automatic pass-instant detection** — needs ball tracking to replace manual
|
| 6 |
+
frame scrubbing.
|
| 7 |
+
- **Exclude arms from the forward-most point** — needs MHR body-part vertex
|
| 8 |
+
labels; today `build_scene`/`offside_plane_x` use the forward-most body vertex
|
| 9 |
+
including arms.
|
| 10 |
+
- **three.js broadcast frontend** (fog/bloom/camera moves) replacing Plotly.
|
| 11 |
+
- **Team identification by jersey colour** — currently the user labels defenders
|
| 12 |
+
manually.
|
| 13 |
+
- **Multi-frame tracking.**
|
| 14 |
+
|
| 15 |
+
# Known nuances carried from the notebook
|
| 16 |
+
|
| 17 |
+
- `flip up` toggle exists because the ground-normal sign can invert; if median
|
| 18 |
+
player height < 1.0 m the scene warns to toggle it.
|
| 19 |
+
- The offside line uses the **2nd-last** defender's forward-most point (last is
|
| 20 |
+
usually the GK). With only one labeled defender it falls back to that defender;
|
| 21 |
+
with none, to the scene's median X (then drag the plane).
|
app.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VAR-style offside visualizer — Gradio app.
|
| 3 |
+
|
| 4 |
+
Pipeline (matches the working Colab notebook):
|
| 5 |
+
upload video -> scrub to the offside frame -> detect players (GPU, once/frame)
|
| 6 |
+
-> select players -> click 2 goal-parallel lines -> place 3D poses on a field
|
| 7 |
+
-> Plotly scene with a draggable offside plane.
|
| 8 |
+
|
| 9 |
+
The GPU is touched ONLY inside pipeline.gpu.reconstruct_frame(); every other
|
| 10 |
+
callback in this file runs on cached numpy and stays on the CPU.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
# headless GL + CUDA fragmentation hygiene must be set before any heavy import
|
| 16 |
+
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
|
| 17 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import gradio as gr
|
| 21 |
+
|
| 22 |
+
from pipeline.video import probe_video, grab_frame
|
| 23 |
+
from pipeline.overlay import annotate_detections, draw_lines
|
| 24 |
+
from pipeline import geometry as G
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ============================================================================
|
| 28 |
+
# Stage (a): upload + frame scrubbing (pure CPU)
|
| 29 |
+
# ============================================================================
|
| 30 |
+
def on_upload(video_path):
|
| 31 |
+
"""New clip: size the slider, show frame 0, report length, remember max idx."""
|
| 32 |
+
if not video_path:
|
| 33 |
+
return gr.update(maximum=1, value=0), None, "Upload a clip to begin.", 0
|
| 34 |
+
n, fps = probe_video(video_path)
|
| 35 |
+
n_max = max(n - 1, 0)
|
| 36 |
+
return (
|
| 37 |
+
gr.update(maximum=max(n_max, 1), value=0),
|
| 38 |
+
grab_frame(video_path, 0),
|
| 39 |
+
f"{n} frames @ {fps:.1f} fps — scrub to the moment the ball is played.",
|
| 40 |
+
n_max,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def on_scrub(video_path, idx):
|
| 45 |
+
if not video_path:
|
| 46 |
+
return None
|
| 47 |
+
return grab_frame(video_path, int(idx))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def step_frame(idx, delta, n_max):
|
| 51 |
+
"""Clamp prev/next within [0, slider max]."""
|
| 52 |
+
return max(0, min(int(n_max), int(idx) + delta))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ============================================================================
|
| 56 |
+
# Stage (b)+(c): detect (GPU, cached) + populate selection (CPU after the call)
|
| 57 |
+
# ============================================================================
|
| 58 |
+
def on_detect(video_path, idx, bbox_thr):
|
| 59 |
+
"""The one GPU step. Imported lazily so the app boots without the model."""
|
| 60 |
+
from pipeline.gpu import reconstruct_frame
|
| 61 |
+
if not video_path:
|
| 62 |
+
return None, gr.update(choices=[], value=[]), gr.update(choices=[], value=[]), [], "Upload a clip first."
|
| 63 |
+
people = reconstruct_frame(video_path, idx, bbox_thr)
|
| 64 |
+
if not people:
|
| 65 |
+
return (None, gr.update(choices=[], value=[]), gr.update(choices=[], value=[]),
|
| 66 |
+
[], "No players detected — try a lower threshold.")
|
| 67 |
+
annotated = annotate_detections(grab_frame(video_path, idx), people)
|
| 68 |
+
choices = list(range(len(people)))
|
| 69 |
+
msg = (f"Detected {len(people)} players. Tick the ones to analyze, mark defenders, "
|
| 70 |
+
"then click 2 goal-parallel lines on the image above.")
|
| 71 |
+
return (annotated,
|
| 72 |
+
gr.update(choices=choices, value=[]),
|
| 73 |
+
gr.update(choices=choices, value=[]),
|
| 74 |
+
people, msg)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ============================================================================
|
| 78 |
+
# Stage (d): click two goal-parallel lines (4 points) (CPU)
|
| 79 |
+
# ============================================================================
|
| 80 |
+
def on_line_click(line_pts, frame_rgb, evt: gr.SelectData):
|
| 81 |
+
"""Collect 4 clicks = 2 lines; redraw dots + segments. Resets after a full set."""
|
| 82 |
+
pts = list(line_pts) if line_pts else []
|
| 83 |
+
if len(pts) >= 4:
|
| 84 |
+
pts = []
|
| 85 |
+
pts.append([float(evt.index[0]), float(evt.index[1])])
|
| 86 |
+
img = draw_lines(frame_rgb, pts)
|
| 87 |
+
status = {1: "line 1: 1/2", 2: "line 1 set", 3: "line 2: 1/2",
|
| 88 |
+
4: "both lines set — build the scene"}.get(len(pts), "")
|
| 89 |
+
return pts, img, status
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ============================================================================
|
| 93 |
+
# Stage (e)+(f): place players + build the Plotly scene with a draggable plane
|
| 94 |
+
# ============================================================================
|
| 95 |
+
def on_build(video_path, idx, bbox_thr, selected_ids, line_pts, flip_up,
|
| 96 |
+
attack_dir, defender_ids):
|
| 97 |
+
from pipeline.gpu import reconstruct_frame, get_faces
|
| 98 |
+
if not selected_ids:
|
| 99 |
+
return None, "Select at least one player.", gr.update(), None, +1, []
|
| 100 |
+
if not line_pts or len(line_pts) < 4:
|
| 101 |
+
return None, "Click 2 goal-parallel lines (4 points) first.", gr.update(), None, +1, []
|
| 102 |
+
|
| 103 |
+
people = reconstruct_frame(video_path, idx, bbox_thr)
|
| 104 |
+
faces = get_faces()
|
| 105 |
+
h, w = grab_frame(video_path, idx).shape[:2]
|
| 106 |
+
focal = people[selected_ids[0]]["focal_length"]
|
| 107 |
+
gdir = G.goal_dir_from_lines(line_pts, focal, w, h)
|
| 108 |
+
|
| 109 |
+
placed = G.place_players(people, list(selected_ids), gdir, flip_up=flip_up)
|
| 110 |
+
med = float(np.median([placed[i][:, 2].max() for i in placed]))
|
| 111 |
+
|
| 112 |
+
attack_sign = +1 if attack_dir == "toward +X" else -1
|
| 113 |
+
dset = [int(d) for d in (defender_ids or [])]
|
| 114 |
+
plane_x = G.offside_plane_x(placed, attack_sign, dset)
|
| 115 |
+
|
| 116 |
+
fig = G.build_scene(placed, faces, plane_x, attack_sign, dset)
|
| 117 |
+
|
| 118 |
+
allX = np.vstack(list(placed.values()))[:, 0]
|
| 119 |
+
x0, x1 = float(allX.min() - 4), float(allX.max() + 4)
|
| 120 |
+
plane_update = gr.update(minimum=x0, maximum=x1, value=float(plane_x),
|
| 121 |
+
visible=True, label="Drag the offside plane (X, m)")
|
| 122 |
+
|
| 123 |
+
warn = " ⚠ heights look wrong — toggle 'flip up'." if med < 1.0 else ""
|
| 124 |
+
msg = f"Median player height {med:.2f} m (expect ~1.7–1.9).{warn}"
|
| 125 |
+
return fig, msg, plane_update, placed, attack_sign, dset
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def on_plane(placed, plane_x, attack_sign, defender_ids):
|
| 129 |
+
"""Re-render the scene at a new plane X — pure CPU on the cached placement."""
|
| 130 |
+
from pipeline.gpu import get_faces
|
| 131 |
+
if not placed:
|
| 132 |
+
return gr.update()
|
| 133 |
+
fig = G.build_scene(placed, get_faces(), float(plane_x),
|
| 134 |
+
int(attack_sign), defender_ids or [])
|
| 135 |
+
return fig
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ============================================================================
|
| 139 |
+
# UI
|
| 140 |
+
# ============================================================================
|
| 141 |
+
with gr.Blocks(title="VAR Offside Visualizer") as demo:
|
| 142 |
+
gr.Markdown(
|
| 143 |
+
"## VAR-style Offside Visualizer\n"
|
| 144 |
+
"Upload a clip, scrub to the moment the ball is played, then "
|
| 145 |
+
"detect → select → click 2 goal-parallel lines → build the 3D scene.\n\n"
|
| 146 |
+
"_Scale comes from reconstructed body height, so positions are approximate "
|
| 147 |
+
"metres — good for relative offside ordering, not sub-10 cm calls._"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# session state
|
| 151 |
+
st_nmax = gr.State(0) # last valid frame index
|
| 152 |
+
st_people = gr.State([]) # slim detections for the current frame
|
| 153 |
+
st_lines = gr.State([]) # clicked line points
|
| 154 |
+
st_placed = gr.State(None) # placed meshes (field frame) after build
|
| 155 |
+
st_attack = gr.State(+1) # attack_sign
|
| 156 |
+
st_defenders = gr.State([]) # defender ids used for the verdict
|
| 157 |
+
|
| 158 |
+
# --- stage (a) ---
|
| 159 |
+
video = gr.Video(label="1. Upload match clip")
|
| 160 |
+
status = gr.Markdown()
|
| 161 |
+
with gr.Row():
|
| 162 |
+
frame_slider = gr.Slider(0, 1, value=0, step=1,
|
| 163 |
+
label="2. Scrub to the offside frame")
|
| 164 |
+
with gr.Row():
|
| 165 |
+
prev_btn = gr.Button("◀ prev frame")
|
| 166 |
+
next_btn = gr.Button("next frame ▶")
|
| 167 |
+
frame_view = gr.Image(label="Current frame", interactive=False)
|
| 168 |
+
|
| 169 |
+
# --- stage (b)/(c) ---
|
| 170 |
+
with gr.Row():
|
| 171 |
+
thr = gr.Slider(0.3, 0.95, value=0.85, step=0.05, label="Detection confidence")
|
| 172 |
+
detect_btn = gr.Button("3. Detect players (GPU)", variant="primary")
|
| 173 |
+
detect_view = gr.Image(
|
| 174 |
+
label="Detected players — click 2 goal-parallel lines here (4 points)",
|
| 175 |
+
interactive=True)
|
| 176 |
+
line_status = gr.Markdown()
|
| 177 |
+
sel = gr.CheckboxGroup(choices=[], label="4. Select players to analyze (indices)")
|
| 178 |
+
|
| 179 |
+
# --- stage (e)/(f) controls ---
|
| 180 |
+
with gr.Row():
|
| 181 |
+
flip = gr.Checkbox(False, label="flip up (if players are upside-down)")
|
| 182 |
+
attack = gr.Radio(["toward +X", "toward -X"], value="toward +X",
|
| 183 |
+
label="Attacking direction")
|
| 184 |
+
defenders = gr.CheckboxGroup(
|
| 185 |
+
choices=[], label="Defenders (incl. GK) — sets the offside line")
|
| 186 |
+
build_btn = gr.Button("5. Build 3D scene + offside line", variant="primary")
|
| 187 |
+
|
| 188 |
+
scene = gr.Plot(label="3D scene")
|
| 189 |
+
plane_slider = gr.Slider(-10, 10, value=0, step=0.05, visible=False,
|
| 190 |
+
label="Drag the offside plane (X, m)")
|
| 191 |
+
build_status = gr.Markdown()
|
| 192 |
+
|
| 193 |
+
# --- wiring ---
|
| 194 |
+
video.change(on_upload, [video], [frame_slider, frame_view, status, st_nmax])
|
| 195 |
+
frame_slider.change(on_scrub, [video, frame_slider], [frame_view])
|
| 196 |
+
prev_btn.click(lambda i, m: step_frame(i, -1, m),
|
| 197 |
+
[frame_slider, st_nmax], [frame_slider])
|
| 198 |
+
next_btn.click(lambda i, m: step_frame(i, +1, m),
|
| 199 |
+
[frame_slider, st_nmax], [frame_slider])
|
| 200 |
+
|
| 201 |
+
detect_btn.click(on_detect, [video, frame_slider, thr],
|
| 202 |
+
[detect_view, sel, defenders, st_people, status])
|
| 203 |
+
detect_view.select(on_line_click, [st_lines, detect_view],
|
| 204 |
+
[st_lines, detect_view, line_status])
|
| 205 |
+
|
| 206 |
+
build_btn.click(
|
| 207 |
+
on_build,
|
| 208 |
+
[video, frame_slider, thr, sel, st_lines, flip, attack, defenders],
|
| 209 |
+
[scene, build_status, plane_slider, st_placed, st_attack, st_defenders])
|
| 210 |
+
plane_slider.change(on_plane, [st_placed, plane_slider, st_attack, st_defenders],
|
| 211 |
+
[scene])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
demo.queue().launch(server_name="0.0.0.0", server_port=7860)
|
pipeline/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VAR offside visualizer pipeline.
|
| 2 |
+
|
| 3 |
+
Module layout keeps the GPU strictly isolated:
|
| 4 |
+
video.py — frame seek/probe (CPU)
|
| 5 |
+
gpu.py — model load + reconstruct_frame (the ONLY GPU code)
|
| 6 |
+
geometry.py — vanishing point, ground fit, field frame, scene (CPU)
|
| 7 |
+
overlay.py — cv2 detection boxes + line-click drawing (CPU)
|
| 8 |
+
"""
|
pipeline/geometry.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Placement geometry — pure CPU, copied verbatim from the verified Colab notebook
|
| 3 |
+
(sections 4 & the placement cell). Do NOT rewrite this math:
|
| 4 |
+
|
| 5 |
+
world_verts = pred_vertices + pred_cam_t (shared camera frame)
|
| 6 |
+
goal dir = back-projected vanishing point of two clicked parallel lines
|
| 7 |
+
ground = SVD on the lowest ~3% feet vertices (camera Y is down)
|
| 8 |
+
field frame = ez=up, ey=goal dir in-plane, ex=ey x ez (the offside axis)
|
| 9 |
+
per player : drop each player's own feet to Z=0 (not one global shift)
|
| 10 |
+
offside : constant-X plane, parallel to the goal line by construction
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import plotly.graph_objects as go
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def goal_dir_from_lines(line_pts, focal, w, h):
|
| 18 |
+
"""Two clicked goal-parallel lines -> goal-line direction in camera coords."""
|
| 19 |
+
def homog_line(p, q):
|
| 20 |
+
return np.cross([p[0], p[1], 1.0], [q[0], q[1], 1.0])
|
| 21 |
+
lp = np.asarray(line_pts, dtype=float)
|
| 22 |
+
l1 = homog_line(lp[0], lp[1])
|
| 23 |
+
l2 = homog_line(lp[2], lp[3])
|
| 24 |
+
vp = np.cross(l1, l2)
|
| 25 |
+
K = np.array([[focal, 0, w / 2], [0, focal, h / 2], [0, 0, 1.0]])
|
| 26 |
+
d_img = (np.array([vp[0], vp[1], 0.0]) if abs(vp[2]) < 1e-9
|
| 27 |
+
else np.array([vp[0] / vp[2], vp[1] / vp[2], 1.0]))
|
| 28 |
+
g = np.linalg.inv(K) @ d_img
|
| 29 |
+
return g / np.linalg.norm(g)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def world_verts(p):
|
| 33 |
+
"""Vertices in the shared camera frame = local mesh + per-player cam translation."""
|
| 34 |
+
return np.asarray(p["pred_vertices"]) + np.asarray(p["pred_cam_t"]).reshape(1, 3)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def place_players(people, selected_ids, goal_dir_cam, flip_up=False):
|
| 38 |
+
"""Place selected meshes on a common field frame: X=offside axis, Y=goal line, Z=up."""
|
| 39 |
+
def feet_pts(Vc, frac=0.03):
|
| 40 |
+
thr = np.quantile(Vc[:, 1], 1 - frac) # camera Y is down -> feet = largest Y
|
| 41 |
+
return Vc[Vc[:, 1] >= thr]
|
| 42 |
+
|
| 43 |
+
feet_all = np.vstack([feet_pts(world_verts(people[i])) for i in selected_ids])
|
| 44 |
+
o = feet_all.mean(0)
|
| 45 |
+
_, _, Vt = np.linalg.svd(feet_all - o)
|
| 46 |
+
n = Vt[-1]
|
| 47 |
+
if n @ np.array([0, -1, 0]) < 0:
|
| 48 |
+
n = -n
|
| 49 |
+
if flip_up:
|
| 50 |
+
n = -n
|
| 51 |
+
|
| 52 |
+
g = goal_dir_cam - (goal_dir_cam @ n) * n
|
| 53 |
+
g /= np.linalg.norm(g)
|
| 54 |
+
ez, ey = n, g
|
| 55 |
+
ex = np.cross(ey, ez); ex /= np.linalg.norm(ex)
|
| 56 |
+
ey = np.cross(ez, ex)
|
| 57 |
+
Rwf = np.stack([ex, ey, ez], axis=1)
|
| 58 |
+
|
| 59 |
+
placed = {i: (world_verts(people[i]) - o) @ Rwf for i in selected_ids}
|
| 60 |
+
for i in placed: # drop each player's feet to Z=0 individually
|
| 61 |
+
placed[i][:, 2] -= placed[i][:, 2].min()
|
| 62 |
+
return placed
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def offside_plane_x(placed, attack_sign, defender_ids):
|
| 66 |
+
"""X of the offside line = 2nd-last defender's forward-most point.
|
| 67 |
+
|
| 68 |
+
Forward-most is measured in the ATTACK direction (fwd = attack_sign * X), the
|
| 69 |
+
same quantity build_scene uses for the per-player verdict, so the drawn plane
|
| 70 |
+
and the colors always agree — matches the notebook. Falls back to the only
|
| 71 |
+
defender, then to the scene's median X if no defenders are labeled.
|
| 72 |
+
"""
|
| 73 |
+
dset = set(int(d) for d in (defender_ids or []))
|
| 74 |
+
fmost = {i: float((attack_sign * placed[i][:, 0]).max()) for i in placed}
|
| 75 |
+
dfwd = sorted([fmost[i] for i in placed if i in dset], reverse=True)
|
| 76 |
+
if len(dfwd) >= 2:
|
| 77 |
+
return attack_sign * dfwd[1]
|
| 78 |
+
if dfwd:
|
| 79 |
+
return attack_sign * dfwd[0]
|
| 80 |
+
return float(np.median(np.vstack(list(placed.values()))[:, 0]))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_scene(placed, faces, plane_x, attack_sign, defender_ids, too_close=0.30):
|
| 84 |
+
allP = np.vstack(list(placed.values()))
|
| 85 |
+
x0, x1 = allP[:, 0].min() - 4, allP[:, 0].max() + 4
|
| 86 |
+
y0, y1 = allP[:, 1].min() - 4, allP[:, 1].max() + 4
|
| 87 |
+
|
| 88 |
+
def fwd(x):
|
| 89 |
+
return attack_sign * x
|
| 90 |
+
|
| 91 |
+
fmost = {i: float(fwd(placed[i][:, 0]).max()) for i in placed}
|
| 92 |
+
|
| 93 |
+
fig = go.Figure()
|
| 94 |
+
# pitch
|
| 95 |
+
fig.add_trace(go.Mesh3d(x=[x0, x1, x1, x0], y=[y0, y0, y1, y1], z=[0, 0, 0, 0],
|
| 96 |
+
i=[0, 0], j=[1, 2], k=[2, 3], color="seagreen",
|
| 97 |
+
opacity=0.5, showlegend=False))
|
| 98 |
+
# offside plane
|
| 99 |
+
if plane_x is not None:
|
| 100 |
+
fig.add_trace(go.Mesh3d(x=[plane_x] * 4, y=[y0, y1, y1, y0], z=[0, 0, 3, 3],
|
| 101 |
+
i=[0, 0], j=[1, 2], k=[2, 3], color="red",
|
| 102 |
+
opacity=0.3, showlegend=False))
|
| 103 |
+
# players
|
| 104 |
+
palette = ["crimson", "royalblue", "gold", "darkorange", "mediumpurple",
|
| 105 |
+
"deepskyblue", "hotpink", "mediumspringgreen", "tomato", "slateblue"]
|
| 106 |
+
dset = set(int(d) for d in (defender_ids or []))
|
| 107 |
+
any_off = False
|
| 108 |
+
line_fwd = fwd(plane_x) if plane_x is not None else None
|
| 109 |
+
for n_, i in enumerate(placed):
|
| 110 |
+
if i in dset:
|
| 111 |
+
col, lab = "royalblue", "defender"
|
| 112 |
+
elif line_fwd is None:
|
| 113 |
+
col, lab = palette[n_ % len(palette)], ""
|
| 114 |
+
else:
|
| 115 |
+
m = fmost[i] - line_fwd
|
| 116 |
+
if m > too_close:
|
| 117 |
+
col, lab, any_off = "red", f"OFFSIDE +{m:.2f}m", True
|
| 118 |
+
elif m < -too_close:
|
| 119 |
+
col, lab = "seagreen", f"onside {m:.2f}m"
|
| 120 |
+
else:
|
| 121 |
+
col, lab = "orange", f"close {m:+.2f}m"
|
| 122 |
+
V = placed[i]
|
| 123 |
+
fig.add_trace(go.Mesh3d(x=V[:, 0], y=V[:, 1], z=V[:, 2],
|
| 124 |
+
i=faces[:, 0], j=faces[:, 1], k=faces[:, 2],
|
| 125 |
+
color=col, opacity=1.0, name=f"#{i} {lab}"))
|
| 126 |
+
|
| 127 |
+
title = "VAR — OFFSIDE" if any_off else "VAR — NO OFFSIDE"
|
| 128 |
+
fig.update_layout(
|
| 129 |
+
title=dict(text=title, x=0.5, font=dict(size=22, color="white")),
|
| 130 |
+
paper_bgcolor="#0b1f3a", height=640, margin=dict(l=0, r=0, t=40, b=0),
|
| 131 |
+
scene=dict(aspectmode="data", bgcolor="#0b1f3a",
|
| 132 |
+
xaxis_title="X offside axis (m)", yaxis_title="Y goal line (m)",
|
| 133 |
+
zaxis_title="Z (m)",
|
| 134 |
+
camera=dict(eye=dict(x=0, y=-2.2, z=1.2), up=dict(x=0, y=0, z=1))))
|
| 135 |
+
return fig
|
pipeline/gpu.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The ONLY module that touches the GPU.
|
| 3 |
+
|
| 4 |
+
`get_estimator()` loads SAM 3D Body once at first use and keeps it warm on the
|
| 5 |
+
GPU. `reconstruct_frame()` runs `process_one_image` for a single frame and caches
|
| 6 |
+
the slimmed result per (video, frame, threshold). Everything downstream consumes
|
| 7 |
+
that cached numpy on the CPU and must never call back into this module's GPU path.
|
| 8 |
+
|
| 9 |
+
Isolating the GPU here is deliberate: to move inference to a serverless backend
|
| 10 |
+
(Modal / ZeroGPU) later, only `reconstruct_frame` has to change.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import functools
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
# The sam-3d-body repo is cloned here by the Dockerfile and added to sys.path.
|
| 20 |
+
SAM3D_DIR = os.environ.get("SAM3D_DIR", "/app/sam-3d-body")
|
| 21 |
+
if SAM3D_DIR not in sys.path:
|
| 22 |
+
sys.path.insert(0, SAM3D_DIR)
|
| 23 |
+
|
| 24 |
+
HF_REPO_ID = os.environ.get("SAM3D_REPO_ID", "facebook/sam-3d-body-dinov3")
|
| 25 |
+
|
| 26 |
+
_ESTIMATOR = None
|
| 27 |
+
_FACES = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_estimator():
|
| 31 |
+
"""Lazy-load the SAM 3D Body estimator a single time; returns (estimator, faces).
|
| 32 |
+
|
| 33 |
+
Verified against facebookresearch/sam-3d-body:
|
| 34 |
+
- setup_sam_3d_body -> load_sam_3d_body_hf -> load_sam_3d_body(ckpt, mhr_path),
|
| 35 |
+
which sidesteps the model_config.yaml path bug in the demo.py route.
|
| 36 |
+
- estimator.faces == model.head_pose.faces (numpy).
|
| 37 |
+
- The "missing keys" warning at load is benign (MHR rig buffers).
|
| 38 |
+
"""
|
| 39 |
+
global _ESTIMATOR, _FACES
|
| 40 |
+
if _ESTIMATOR is None:
|
| 41 |
+
from huggingface_hub import login
|
| 42 |
+
token = os.environ.get("HF_TOKEN")
|
| 43 |
+
if token:
|
| 44 |
+
login(token=token)
|
| 45 |
+
# Imported here so module import never fails before the repo is on sys.path.
|
| 46 |
+
from notebook.utils import setup_sam_3d_body
|
| 47 |
+
_ESTIMATOR = setup_sam_3d_body(hf_repo_id=HF_REPO_ID)
|
| 48 |
+
_FACES = np.asarray(_ESTIMATOR.faces)
|
| 49 |
+
return _ESTIMATOR, _FACES
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_faces():
|
| 53 |
+
"""Triangle faces for the body mesh (CPU-only consumers use this)."""
|
| 54 |
+
return get_estimator()[1]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@functools.lru_cache(maxsize=8)
|
| 58 |
+
def _reconstruct_cached(video_path, idx, bbox_thr):
|
| 59 |
+
"""GPU call, memoized per (video, frame, threshold).
|
| 60 |
+
|
| 61 |
+
Returns a list of slim, picklable dicts (one per detected person) holding only
|
| 62 |
+
the numpy fields used downstream. Returns None if the frame can't be read.
|
| 63 |
+
"""
|
| 64 |
+
from .video import grab_frame # local import keeps this module import-light
|
| 65 |
+
|
| 66 |
+
est, _ = get_estimator()
|
| 67 |
+
frame_rgb = grab_frame(video_path, idx)
|
| 68 |
+
if frame_rgb is None:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
# process_one_image accepts an RGB array and a bbox_thr kwarg (verified).
|
| 72 |
+
# It returns a LIST, one dict per person, keys include:
|
| 73 |
+
# bbox, pred_vertices, pred_cam_t, focal_length, pred_keypoints_2d, mask
|
| 74 |
+
people = est.process_one_image(frame_rgb, bbox_thr=bbox_thr)
|
| 75 |
+
|
| 76 |
+
slim = []
|
| 77 |
+
for p in people:
|
| 78 |
+
slim.append({
|
| 79 |
+
"bbox": np.asarray(p["bbox"]).reshape(-1)[:4].astype(float),
|
| 80 |
+
"pred_vertices": np.asarray(p["pred_vertices"], dtype=np.float32),
|
| 81 |
+
"pred_cam_t": np.asarray(p["pred_cam_t"], dtype=np.float32).reshape(3),
|
| 82 |
+
"focal_length": float(np.asarray(p["focal_length"]).reshape(-1)[0]),
|
| 83 |
+
})
|
| 84 |
+
return slim
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def reconstruct_frame(video_path, idx, bbox_thr=0.85):
|
| 88 |
+
"""CPU-cheap wrapper: normalizes the cache key and returns cached people."""
|
| 89 |
+
return _reconstruct_cached(str(video_path), int(idx), round(float(bbox_thr), 3))
|
pipeline/overlay.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cv2 overlays — pure CPU. Draws detection boxes and the two goal-parallel lines
|
| 3 |
+
the user clicks. Works on RGB arrays in, RGB arrays out (Gradio Image is numpy/RGB).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def annotate_detections(frame_rgb, people):
|
| 11 |
+
"""Draw numbered green boxes for every detected person."""
|
| 12 |
+
img = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 13 |
+
for i, p in enumerate(people):
|
| 14 |
+
x1, y1, x2, y2 = [int(v) for v in p["bbox"]]
|
| 15 |
+
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
| 16 |
+
cv2.putText(img, str(i), (x1, max(y1 - 6, 14)),
|
| 17 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 3)
|
| 18 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def draw_lines(frame_rgb, pts):
|
| 22 |
+
"""Draw the clicked points and the (up to two) line segments.
|
| 23 |
+
|
| 24 |
+
pts is a list of [x, y]; points 0-1 form line 1 (red), 2-3 form line 2 (yellow).
|
| 25 |
+
"""
|
| 26 |
+
if frame_rgb is None:
|
| 27 |
+
return None
|
| 28 |
+
img = cv2.cvtColor(frame_rgb.copy(), cv2.COLOR_RGB2BGR)
|
| 29 |
+
cols = [(0, 0, 255), (0, 255, 255)] # BGR: line1 red, line2 yellow
|
| 30 |
+
for k, (px, py) in enumerate(pts):
|
| 31 |
+
cv2.circle(img, (int(px), int(py)), 6, cols[k // 2], -1)
|
| 32 |
+
if len(pts) >= 2:
|
| 33 |
+
cv2.line(img, tuple(map(int, pts[0])), tuple(map(int, pts[1])), cols[0], 2)
|
| 34 |
+
if len(pts) >= 4:
|
| 35 |
+
cv2.line(img, tuple(map(int, pts[2])), tuple(map(int, pts[3])), cols[1], 2)
|
| 36 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
pipeline/video.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video helpers — pure CPU. Frames are seeked on demand (no bulk extraction),
|
| 3 |
+
so scrubbing a long clip stays cheap and never touches the GPU.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import cv2
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def probe_video(video_path):
|
| 10 |
+
"""Return (frame_count, fps) for a clip. fps falls back to 25 if unknown."""
|
| 11 |
+
cap = cv2.VideoCapture(video_path)
|
| 12 |
+
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 13 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
| 14 |
+
cap.release()
|
| 15 |
+
return n, fps
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def grab_frame(video_path, idx):
|
| 19 |
+
"""Seek to an exact frame index and return it as an RGB array (or None)."""
|
| 20 |
+
cap = cv2.VideoCapture(video_path)
|
| 21 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
|
| 22 |
+
ok, frame_bgr = cap.read()
|
| 23 |
+
cap.release()
|
| 24 |
+
if not ok:
|
| 25 |
+
return None
|
| 26 |
+
return cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
requirements.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pinned to the versions verified during the build (mirrors the Dockerfile's
|
| 2 |
+
# torch pin). Gradio 6 requires Slider minimum < maximum — see app.py.
|
| 3 |
+
gradio==6.19.0
|
| 4 |
+
plotly>=5.20
|
| 5 |
+
opencv-python-headless
|
| 6 |
+
numpy
|
| 7 |
+
huggingface_hub
|
| 8 |
+
pytorch-lightning
|
| 9 |
+
pyrender
|
| 10 |
+
yacs
|
| 11 |
+
scikit-image
|
| 12 |
+
einops
|
| 13 |
+
timm
|
| 14 |
+
dill
|
| 15 |
+
pandas
|
| 16 |
+
rich
|
| 17 |
+
hydra-core
|
| 18 |
+
hydra-submitit-launcher
|
| 19 |
+
hydra-colorlog
|
| 20 |
+
pyrootutils
|
| 21 |
+
webdataset
|
| 22 |
+
chump
|
| 23 |
+
networkx==3.2.1
|
| 24 |
+
roma
|
| 25 |
+
joblib
|
| 26 |
+
seaborn
|
| 27 |
+
appdirs
|
| 28 |
+
cython
|
| 29 |
+
jsonlines
|
| 30 |
+
xtcocotools
|
| 31 |
+
loguru
|
| 32 |
+
optree
|
| 33 |
+
fvcore
|
| 34 |
+
pycocotools
|
| 35 |
+
trimesh
|