Spaces:
Paused
Paused
VAR-RFDETR: RF-DETR-Seg large detector with box/segment toggle
Browse files- Dockerfile +53 -0
- README.md +68 -5
- TODO.md +21 -0
- app.py +324 -0
- pipeline/__init__.py +8 -0
- pipeline/autolines.py +165 -0
- pipeline/geometry.py +182 -0
- pipeline/gpu.py +133 -0
- pipeline/overlay.py +109 -0
- pipeline/video.py +26 -0
- requirements.txt +37 -0
Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, plus a C++ toolchain.
|
| 13 |
+
# build-essential is required because the runtime base ships no compiler and
|
| 14 |
+
# detectron2 (a1ce2f9) builds its ops from source against the installed torch.
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
git ffmpeg libgl1 libglib2.0-0 libosmesa6 libegl1 \
|
| 17 |
+
build-essential ninja-build \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Clone the model repo (added to sys.path by pipeline/gpu.py via SAM3D_DIR)
|
| 23 |
+
RUN git clone https://github.com/facebookresearch/sam-3d-body.git /app/sam-3d-body
|
| 24 |
+
|
| 25 |
+
# Python deps. detectron2 is pinned to a1ce2f9 and built --no-build-isolation
|
| 26 |
+
# --no-deps so it compiles against the torch already in the base image — the
|
| 27 |
+
# single most fragile step; the pinned base keeps it reproducible.
|
| 28 |
+
COPY requirements.txt /app/requirements.txt
|
| 29 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt \
|
| 30 |
+
&& pip install --no-cache-dir \
|
| 31 |
+
"git+https://github.com/facebookresearch/detectron2.git@a1ce2f9" --no-build-isolation --no-deps \
|
| 32 |
+
&& pip install --no-cache-dir "git+https://github.com/microsoft/MoGe.git"
|
| 33 |
+
|
| 34 |
+
# Runtime cache redirection. HF Spaces runs as a non-root user with HOME=/, so
|
| 35 |
+
# anything writing to ~/.cache (torch.hub clones DINOv3 here at model load,
|
| 36 |
+
# matplotlib, fontconfig, ...) hits a read-only /.cache. These are only needed at
|
| 37 |
+
# runtime, so they sit BELOW the pip layer to keep that expensive layer cached.
|
| 38 |
+
ENV HOME=/app \
|
| 39 |
+
XDG_CACHE_HOME=/app/.cache \
|
| 40 |
+
TORCH_HOME=/app/.cache/torch \
|
| 41 |
+
MPLCONFIGDIR=/app/.cache/matplotlib
|
| 42 |
+
|
| 43 |
+
# App (UI + the pipeline package; GPU code is isolated in pipeline/gpu.py)
|
| 44 |
+
COPY app.py /app/app.py
|
| 45 |
+
COPY pipeline /app/pipeline
|
| 46 |
+
|
| 47 |
+
# Writable cache + home (HF Spaces runs as a non-root user). torch.hub needs to
|
| 48 |
+
# clone into TORCH_HOME at runtime, so the whole /app tree must be writable.
|
| 49 |
+
RUN mkdir -p /app/.cache/huggingface /app/.cache/torch /app/.cache/matplotlib \
|
| 50 |
+
&& chmod -R 777 /app
|
| 51 |
+
|
| 52 |
+
EXPOSE 7860
|
| 53 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,73 @@
|
|
| 1 |
---
|
| 2 |
-
title: VAR RFDETR
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: VAR - RFDETR
|
| 3 |
+
emoji: 🥅
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# VAR - RFDETR — Offside Visualizer
|
| 12 |
+
|
| 13 |
+
RF-DETR variant: detection uses Roboflow's **RF-DETR-Seg (large)** with a toggle
|
| 14 |
+
between bounding boxes and segments. Upload a match clip, scrub to the moment the
|
| 15 |
+
ball is played, reconstruct selected players in 3D with SAM 3D Body, and place
|
| 16 |
+
them on a virtual pitch with a draggable
|
| 17 |
+
offside plane.
|
| 18 |
+
|
| 19 |
+
## Pipeline
|
| 20 |
+
|
| 21 |
+
1. **Upload** a video clip.
|
| 22 |
+
2. **Scrub** to the offside frame (slider + prev/next, frames seeked on demand).
|
| 23 |
+
3. **Detect** players on that frame — the only GPU step, cached per (video, frame, threshold).
|
| 24 |
+
4. **Select** the players to analyze and mark the defenders (incl. GK).
|
| 25 |
+
5. **Click two goal-parallel lines** (4 points) on the detected frame to fix the offside axis.
|
| 26 |
+
6. **Build** the 3D scene; drag the offside plane and read the OFFSIDE / NO-OFFSIDE verdict.
|
| 27 |
+
|
| 28 |
+
The GPU runs once per frame (`pipeline/gpu.py`). Scrubbing, line geometry,
|
| 29 |
+
placement, plotting, and the draggable plane are all CPU on the cached result.
|
| 30 |
+
|
| 31 |
+
## Code layout
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
app.py Gradio UI + event wiring (CPU)
|
| 35 |
+
pipeline/
|
| 36 |
+
video.py frame seek/probe (CPU)
|
| 37 |
+
gpu.py model load + reconstruct_frame ← the ONLY GPU code
|
| 38 |
+
geometry.py vanishing point, ground fit, field frame, scene (CPU)
|
| 39 |
+
overlay.py detection boxes + line-click drawing (CPU)
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Isolating the GPU in `pipeline/gpu.py` means moving inference to a serverless
|
| 43 |
+
backend (Modal / ZeroGPU) later only touches `reconstruct_frame`.
|
| 44 |
+
|
| 45 |
+
## Deploy
|
| 46 |
+
|
| 47 |
+
This is a **Docker SDK** Space for **dedicated GPU hardware** (A100 recommended):
|
| 48 |
+
|
| 49 |
+
1. Set hardware to an A100 tier.
|
| 50 |
+
2. Add a secret `HF_TOKEN` — a read token for an account with approved access to
|
| 51 |
+
the gated **`facebook/sam-3d-body-dinov3`**. (Override the repo with the
|
| 52 |
+
`SAM3D_REPO_ID` env var if you use a different checkpoint.)
|
| 53 |
+
3. First boot builds the image and downloads ~7 GB of weights — give it time.
|
| 54 |
+
After that, the model stays warm until you pause the Space.
|
| 55 |
+
|
| 56 |
+
**Cost control:** dedicated GPU bills while the Space is running, with no
|
| 57 |
+
auto-shutoff. Pause the Space from its settings when you are not using it.
|
| 58 |
+
|
| 59 |
+
### Why not ZeroGPU?
|
| 60 |
+
|
| 61 |
+
ZeroGPU allocates the GPU per call, caps call duration, enforces a daily quota,
|
| 62 |
+
and cold-loads the ~7 GB model stack on each allocation — a poor fit for an
|
| 63 |
+
interactive video-scrubbing session, and it requires the Gradio SDK (not Docker).
|
| 64 |
+
|
| 65 |
+
## Notes / limits
|
| 66 |
+
|
| 67 |
+
- Scale comes from the reconstructed body height, so positions are approximate
|
| 68 |
+
metres — good for relative offside ordering, **not** sub-10 cm officiating calls.
|
| 69 |
+
The verdict surfaces a "too close to call" band rather than implying false precision.
|
| 70 |
+
- The offside point currently uses the forward-most body vertex **including arms**;
|
| 71 |
+
excluding arms (via MHR body-part labels) is planned — see `TODO.md`.
|
| 72 |
+
- "Find the offside moment" is manual scrubbing; automatic pass-instant detection
|
| 73 |
+
(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,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VAR-style offside visualizer — Gradio app.
|
| 3 |
+
|
| 4 |
+
Flow:
|
| 5 |
+
upload -> scrub to the frame -> draw 2 goal-parallel lines on that frame
|
| 6 |
+
-> Detect players (GPU, once/frame) -> click players to select
|
| 7 |
+
-> mark defenders -> Build the 3D scene with a draggable offside plane.
|
| 8 |
+
|
| 9 |
+
The GPU is touched ONLY inside pipeline.gpu (detect_frame / reconstruct_selected);
|
| 10 |
+
every other callback here 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, draw_masks,
|
| 24 |
+
pick_box, pick_mask)
|
| 25 |
+
from pipeline.autolines import propose_lines
|
| 26 |
+
from pipeline import geometry as G
|
| 27 |
+
|
| 28 |
+
SEG_STYLE = "Segments"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _render(frame, people, selected, style):
|
| 32 |
+
"""Draw detections in the chosen style (boxes vs segments)."""
|
| 33 |
+
if style == SEG_STYLE:
|
| 34 |
+
return draw_masks(frame, people, selected)
|
| 35 |
+
return annotate_detections(frame, people, selected)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _pick(people, x, y, style):
|
| 39 |
+
return pick_mask(people, x, y) if style == SEG_STYLE else pick_box(people, x, y)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# Stage (a): upload + frame scrubbing (pure CPU)
|
| 44 |
+
# ============================================================================
|
| 45 |
+
def on_upload(video_path):
|
| 46 |
+
"""New clip: size the slider, show frame 0, reset lines/frame state."""
|
| 47 |
+
if not video_path:
|
| 48 |
+
return (gr.update(maximum=1, value=0), None, "Upload a clip to begin.",
|
| 49 |
+
0, None, [], "")
|
| 50 |
+
n, fps = probe_video(video_path)
|
| 51 |
+
n_max = max(n - 1, 0)
|
| 52 |
+
frame = grab_frame(video_path, 0)
|
| 53 |
+
return (
|
| 54 |
+
gr.update(maximum=max(n_max, 1), value=0),
|
| 55 |
+
frame,
|
| 56 |
+
f"{n} frames @ {fps:.1f} fps — scrub to the moment the ball is played, "
|
| 57 |
+
"then auto-detect or click the 2 goal-parallel lines.",
|
| 58 |
+
n_max,
|
| 59 |
+
frame, # st_frame: clean copy for line redraws
|
| 60 |
+
[], # st_lines reset
|
| 61 |
+
"", # line_status reset
|
| 62 |
+
[], # st_families reset
|
| 63 |
+
0, # st_fam_idx reset
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def on_scrub(video_path, idx):
|
| 68 |
+
"""Show the new frame and reset any half-drawn / proposed lines on it."""
|
| 69 |
+
if not video_path:
|
| 70 |
+
return None, None, [], "", [], 0
|
| 71 |
+
frame = grab_frame(video_path, int(idx))
|
| 72 |
+
return frame, frame, [], "", [], 0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def step_frame(idx, delta, n_max):
|
| 76 |
+
return max(0, min(int(n_max), int(idx) + delta))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ============================================================================
|
| 80 |
+
# Stage (b): draw 2 goal-parallel lines on the scrubbed frame (CPU)
|
| 81 |
+
# ============================================================================
|
| 82 |
+
def on_line_click(line_pts, clean_frame, evt: gr.SelectData):
|
| 83 |
+
"""Collect 4 clicks = 2 lines; always redraw from the clean frame (no drift).
|
| 84 |
+
|
| 85 |
+
A manual click clears any auto-proposed families (so Flip stops applying).
|
| 86 |
+
"""
|
| 87 |
+
pts = list(line_pts) if line_pts else []
|
| 88 |
+
if len(pts) >= 4:
|
| 89 |
+
pts = []
|
| 90 |
+
pts.append([float(evt.index[0]), float(evt.index[1])])
|
| 91 |
+
img = draw_lines(clean_frame, pts)
|
| 92 |
+
status = {1: "line 1: 1/2", 2: "line 1 set", 3: "line 2: 1/2",
|
| 93 |
+
4: "both lines set ✓ — now Detect players"}.get(len(pts), "")
|
| 94 |
+
return pts, img, status, [], 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def on_auto_lines(clean_frame):
|
| 98 |
+
"""Propose the 2 goal-parallel lines from detected pitch lines."""
|
| 99 |
+
if clean_frame is None:
|
| 100 |
+
return [], None, "Scrub to a frame first.", [], 0
|
| 101 |
+
fams = propose_lines(clean_frame)
|
| 102 |
+
if not fams:
|
| 103 |
+
return [], gr.update(), ("No clear pitch lines found — draw the 2 "
|
| 104 |
+
"goal-parallel lines by hand."), [], 0
|
| 105 |
+
pts = fams[0]["pts"]
|
| 106 |
+
img = draw_lines(clean_frame, pts)
|
| 107 |
+
status = (f"Proposed goal-parallel lines (direction 1/{len(fams)}). If the "
|
| 108 |
+
"offside axis looks wrong, click **Flip line direction**, or just "
|
| 109 |
+
"click the frame to redraw by hand.")
|
| 110 |
+
return pts, img, status, fams, 0
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def on_flip_lines(families, fam_idx, clean_frame):
|
| 114 |
+
"""Switch the proposal to the other detected line-family."""
|
| 115 |
+
if not families:
|
| 116 |
+
return gr.update(), gr.update(), "Run **Auto-detect lines** first.", gr.update()
|
| 117 |
+
if len(families) < 2:
|
| 118 |
+
return (families[0]["pts"], draw_lines(clean_frame, families[0]["pts"]),
|
| 119 |
+
"Only one line direction was found — redraw by hand if it's wrong.", 0)
|
| 120 |
+
new_idx = (int(fam_idx) + 1) % len(families)
|
| 121 |
+
pts = families[new_idx]["pts"]
|
| 122 |
+
return (pts, draw_lines(clean_frame, pts),
|
| 123 |
+
f"Line direction {new_idx + 1}/{len(families)}.", new_idx)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ============================================================================
|
| 127 |
+
# Stage (c): detect (GPU, cached) + show boxes (CPU after the call)
|
| 128 |
+
# ============================================================================
|
| 129 |
+
def on_detect(video_path, idx, conf, style):
|
| 130 |
+
"""Detect step: RF-DETR-Seg only (boxes + masks). No mesh reconstruction here."""
|
| 131 |
+
from pipeline.gpu import detect_frame
|
| 132 |
+
if not video_path:
|
| 133 |
+
return None, [], [], "Upload a clip first.", gr.update(choices=[], value=[])
|
| 134 |
+
people = detect_frame(video_path, idx, conf)
|
| 135 |
+
if not people:
|
| 136 |
+
return (None, [], [], "No players detected — lower the confidence slider.",
|
| 137 |
+
gr.update(choices=[], value=[]))
|
| 138 |
+
annotated = _render(grab_frame(video_path, idx), people, [], style)
|
| 139 |
+
unit = "silhouette" if style == SEG_STYLE else "box"
|
| 140 |
+
msg = (f"Detected {len(people)} players. Click a player's {unit} to select "
|
| 141 |
+
"(click again to deselect).")
|
| 142 |
+
return annotated, people, [], msg, gr.update(choices=[], value=[])
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def on_restyle(people, selected, clean_frame, style):
|
| 146 |
+
"""Re-render the current detection when the box/segment toggle changes."""
|
| 147 |
+
if not people or clean_frame is None:
|
| 148 |
+
return gr.update()
|
| 149 |
+
return _render(clean_frame, people, selected or [], style)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ============================================================================
|
| 153 |
+
# Stage (d): click players to select (CPU)
|
| 154 |
+
# ============================================================================
|
| 155 |
+
def on_select_player(people, selected, clean_frame, cur_def, style, evt: gr.SelectData):
|
| 156 |
+
"""Toggle the player clicked; refresh highlight + defender choices (box or segment)."""
|
| 157 |
+
if not people:
|
| 158 |
+
return None, selected or [], "Detect players first.", gr.update()
|
| 159 |
+
hit = _pick(people, evt.index[0], evt.index[1], style)
|
| 160 |
+
sel = list(selected or [])
|
| 161 |
+
if hit is not None:
|
| 162 |
+
sel.remove(hit) if hit in sel else sel.append(hit)
|
| 163 |
+
sel = sorted(sel)
|
| 164 |
+
img = _render(clean_frame, people, sel, style)
|
| 165 |
+
msg = (f"Selected players: {sel}. Mark defenders below, then Build."
|
| 166 |
+
if sel else "Click a player to select.")
|
| 167 |
+
keep_def = [d for d in (cur_def or []) if d in sel]
|
| 168 |
+
return img, sel, msg, gr.update(choices=sel, value=keep_def)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ============================================================================
|
| 172 |
+
# Stage (e)+(f): place players + build the Plotly scene with a draggable plane
|
| 173 |
+
# ============================================================================
|
| 174 |
+
def on_build(video_path, idx, people_det, selected_ids, line_pts, flip_up,
|
| 175 |
+
attack_dir, defender_ids):
|
| 176 |
+
from pipeline.gpu import reconstruct_selected, get_faces
|
| 177 |
+
if not selected_ids:
|
| 178 |
+
return None, "Click at least one player to select.", gr.update(), None, +1, [], {}
|
| 179 |
+
if not line_pts or len(line_pts) < 4:
|
| 180 |
+
return None, "Draw 2 goal-parallel lines (4 points) on the frame first.", \
|
| 181 |
+
gr.update(), None, +1, [], {}
|
| 182 |
+
|
| 183 |
+
# Reconstruct ONLY the selected players' boxes (the heavy GPU step).
|
| 184 |
+
selected_ids = sorted(int(i) for i in selected_ids)
|
| 185 |
+
boxes = [people_det[i]["bbox"] for i in selected_ids]
|
| 186 |
+
recon = reconstruct_selected(video_path, idx, boxes)
|
| 187 |
+
if not recon:
|
| 188 |
+
return None, "Reconstruction returned no meshes.", gr.update(), None, +1, [], {}
|
| 189 |
+
# Key meshes back to their original detection ids (recon order == boxes order).
|
| 190 |
+
people = {selected_ids[k]: recon[k] for k in range(len(recon))}
|
| 191 |
+
faces = get_faces()
|
| 192 |
+
h, w = grab_frame(video_path, idx).shape[:2]
|
| 193 |
+
focal = people[selected_ids[0]]["focal_length"]
|
| 194 |
+
gdir = G.goal_dir_from_lines(line_pts, focal, w, h)
|
| 195 |
+
|
| 196 |
+
placed = G.place_players(people, selected_ids, gdir, flip_up=flip_up)
|
| 197 |
+
med = float(np.median([placed[i][:, 2].max() for i in placed]))
|
| 198 |
+
masks = {i: G.non_arm_mask(people[i]) for i in selected_ids} # exclude arms/hands
|
| 199 |
+
|
| 200 |
+
attack_sign = +1 if attack_dir == "toward +X" else -1
|
| 201 |
+
dset = [int(d) for d in (defender_ids or [])]
|
| 202 |
+
plane_x = G.offside_plane_x(placed, attack_sign, dset, masks)
|
| 203 |
+
fig = G.build_scene(placed, faces, plane_x, attack_sign, dset, masks)
|
| 204 |
+
|
| 205 |
+
allX = np.vstack(list(placed.values()))[:, 0]
|
| 206 |
+
x0, x1 = float(allX.min() - 4), float(allX.max() + 4)
|
| 207 |
+
plane_update = gr.update(minimum=x0, maximum=x1, value=float(plane_x),
|
| 208 |
+
visible=True, label="Drag the offside plane (X, m)")
|
| 209 |
+
|
| 210 |
+
warn = " ⚠ heights look wrong — toggle 'flip up'." if med < 1.0 else ""
|
| 211 |
+
return (fig, f"Median player height {med:.2f} m (expect ~1.7–1.9).{warn}",
|
| 212 |
+
plane_update, placed, attack_sign, dset, masks)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def on_plane(placed, plane_x, attack_sign, defender_ids, masks):
|
| 216 |
+
"""Re-render the scene at a new plane X — pure CPU on the cached placement."""
|
| 217 |
+
from pipeline.gpu import get_faces
|
| 218 |
+
if not placed:
|
| 219 |
+
return gr.update()
|
| 220 |
+
return G.build_scene(placed, get_faces(), float(plane_x),
|
| 221 |
+
int(attack_sign), defender_ids or [], masks)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ============================================================================
|
| 225 |
+
# UI
|
| 226 |
+
# ============================================================================
|
| 227 |
+
with gr.Blocks(title="VAR Offside Visualizer") as demo:
|
| 228 |
+
gr.Markdown(
|
| 229 |
+
"## VAR-style Offside Visualizer\n"
|
| 230 |
+
"Upload → scrub → **click 2 goal-parallel lines** → Detect → "
|
| 231 |
+
"**click players to select** → mark defenders → Build.\n\n"
|
| 232 |
+
"_Scale comes from reconstructed body height, so positions are approximate "
|
| 233 |
+
"metres — good for relative offside ordering, not sub-10 cm calls._"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# session state
|
| 237 |
+
st_nmax = gr.State(0) # last valid frame index
|
| 238 |
+
st_frame = gr.State(None) # clean RGB of the current frame
|
| 239 |
+
st_people = gr.State([]) # slim detections
|
| 240 |
+
st_lines = gr.State([]) # clicked / proposed line points
|
| 241 |
+
st_families = gr.State([]) # auto-proposed line families
|
| 242 |
+
st_fam_idx = gr.State(0) # which proposed family is active
|
| 243 |
+
st_selected = gr.State([]) # player ids selected by clicking
|
| 244 |
+
st_placed = gr.State(None) # placed meshes after build
|
| 245 |
+
st_attack = gr.State(+1)
|
| 246 |
+
st_defenders = gr.State([])
|
| 247 |
+
st_masks = gr.State({}) # per-player non-arm vertex masks
|
| 248 |
+
|
| 249 |
+
video = gr.Video(label="1. Upload match clip")
|
| 250 |
+
status = gr.Markdown()
|
| 251 |
+
|
| 252 |
+
with gr.Row():
|
| 253 |
+
frame_slider = gr.Slider(0, 1, value=0, step=1,
|
| 254 |
+
label="2. Scrub to the offside frame")
|
| 255 |
+
with gr.Row():
|
| 256 |
+
prev_btn = gr.Button("◀ prev frame")
|
| 257 |
+
next_btn = gr.Button("next frame ▶")
|
| 258 |
+
|
| 259 |
+
# Stage (b): lines drawn on the scrubbed frame — auto-proposed or by hand
|
| 260 |
+
frame_view = gr.Image(label="3. Goal-parallel lines: auto-detect or click 4 points",
|
| 261 |
+
interactive=True)
|
| 262 |
+
with gr.Row():
|
| 263 |
+
auto_lines_btn = gr.Button("✨ Auto-detect lines")
|
| 264 |
+
flip_lines_btn = gr.Button("↔ Flip line direction")
|
| 265 |
+
line_status = gr.Markdown()
|
| 266 |
+
|
| 267 |
+
# Stage (c)/(d): detect (RF-DETR-Seg), then click to select
|
| 268 |
+
with gr.Row():
|
| 269 |
+
thr = gr.Slider(0.0, 0.95, value=0.4, step=0.05, label="Detection confidence")
|
| 270 |
+
style = gr.Radio(["Bounding boxes", "Segments"], value="Bounding boxes",
|
| 271 |
+
label="Detection style")
|
| 272 |
+
detect_btn = gr.Button("4. Detect players (GPU)", variant="primary")
|
| 273 |
+
detect_view = gr.Image(label="5. Click a player to select (click again to deselect)",
|
| 274 |
+
interactive=True)
|
| 275 |
+
select_status = gr.Markdown()
|
| 276 |
+
|
| 277 |
+
# Stage (e)/(f)
|
| 278 |
+
defenders = gr.CheckboxGroup(choices=[], label="6. Defenders (incl. GK) — sets the offside line")
|
| 279 |
+
with gr.Row():
|
| 280 |
+
flip = gr.Checkbox(False, label="flip up (if players are upside-down)")
|
| 281 |
+
attack = gr.Radio(["toward +X", "toward -X"], value="toward +X",
|
| 282 |
+
label="Attacking direction")
|
| 283 |
+
build_btn = gr.Button("7. Build 3D scene + offside line", variant="primary")
|
| 284 |
+
|
| 285 |
+
scene = gr.Plot(label="3D scene")
|
| 286 |
+
plane_slider = gr.Slider(-10, 10, value=0, step=0.05, visible=False,
|
| 287 |
+
label="Drag the offside plane (X, m)")
|
| 288 |
+
build_status = gr.Markdown()
|
| 289 |
+
|
| 290 |
+
# --- wiring ---
|
| 291 |
+
video.change(on_upload, [video],
|
| 292 |
+
[frame_slider, frame_view, status, st_nmax, st_frame, st_lines,
|
| 293 |
+
line_status, st_families, st_fam_idx])
|
| 294 |
+
frame_slider.change(on_scrub, [video, frame_slider],
|
| 295 |
+
[frame_view, st_frame, st_lines, line_status,
|
| 296 |
+
st_families, st_fam_idx])
|
| 297 |
+
prev_btn.click(lambda i, m: step_frame(i, -1, m), [frame_slider, st_nmax], [frame_slider])
|
| 298 |
+
next_btn.click(lambda i, m: step_frame(i, +1, m), [frame_slider, st_nmax], [frame_slider])
|
| 299 |
+
|
| 300 |
+
frame_view.select(on_line_click, [st_lines, st_frame],
|
| 301 |
+
[st_lines, frame_view, line_status, st_families, st_fam_idx])
|
| 302 |
+
auto_lines_btn.click(on_auto_lines, [st_frame],
|
| 303 |
+
[st_lines, frame_view, line_status, st_families, st_fam_idx])
|
| 304 |
+
flip_lines_btn.click(on_flip_lines, [st_families, st_fam_idx, st_frame],
|
| 305 |
+
[st_lines, frame_view, line_status, st_fam_idx])
|
| 306 |
+
|
| 307 |
+
detect_btn.click(on_detect, [video, frame_slider, thr, style],
|
| 308 |
+
[detect_view, st_people, st_selected, select_status, defenders])
|
| 309 |
+
detect_view.select(on_select_player,
|
| 310 |
+
[st_people, st_selected, st_frame, defenders, style],
|
| 311 |
+
[detect_view, st_selected, select_status, defenders])
|
| 312 |
+
style.change(on_restyle, [st_people, st_selected, st_frame, style], [detect_view])
|
| 313 |
+
|
| 314 |
+
build_btn.click(
|
| 315 |
+
on_build,
|
| 316 |
+
[video, frame_slider, st_people, st_selected, st_lines, flip, attack, defenders],
|
| 317 |
+
[scene, build_status, plane_slider, st_placed, st_attack, st_defenders, st_masks])
|
| 318 |
+
plane_slider.change(on_plane,
|
| 319 |
+
[st_placed, plane_slider, st_attack, st_defenders, st_masks],
|
| 320 |
+
[scene])
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
if __name__ == "__main__":
|
| 324 |
+
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/autolines.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auto-PROPOSE the two goal-parallel lines (CPU, OpenCV only).
|
| 3 |
+
|
| 4 |
+
Fully-automatic goal-direction detection is unreliable on broadcast frames
|
| 5 |
+
(a full pitch homography can score "confident" yet be degenerate), so this only
|
| 6 |
+
*proposes*: it detects the white pitch lines, clusters them by vanishing point
|
| 7 |
+
into the two pitch line-families, and returns the 2 longest lines of each. The UI
|
| 8 |
+
shows one family, lets the user flip to the other, or redraw by hand.
|
| 9 |
+
|
| 10 |
+
Line detection (field/white masks + Hough merge) is ported from the project's
|
| 11 |
+
backend pitch-registration code, which is already proven on this footage.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import cv2
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ----------------------------- masks -------------------------------------- #
|
| 19 |
+
def field_mask(bgr):
|
| 20 |
+
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
| 21 |
+
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
|
| 22 |
+
green = ((h > 30) & (h < 95) & (s > 40) & (v > 40)).astype(np.uint8) * 255
|
| 23 |
+
green = cv2.morphologyEx(green, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
|
| 24 |
+
green = cv2.morphologyEx(green, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8))
|
| 25 |
+
cnts, _ = cv2.findContours(green, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 26 |
+
mask = np.zeros_like(green)
|
| 27 |
+
if cnts:
|
| 28 |
+
cv2.drawContours(mask, [max(cnts, key=cv2.contourArea)], -1, 255, -1)
|
| 29 |
+
return mask
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def white_mask(bgr, fmask):
|
| 33 |
+
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
| 34 |
+
s, v = hsv[..., 1], hsv[..., 2]
|
| 35 |
+
w = ((v > 150) & (s < 60)).astype(np.uint8) * 255
|
| 36 |
+
w = cv2.bitwise_and(w, fmask)
|
| 37 |
+
return cv2.morphologyEx(w, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ----------------------------- line tools --------------------------------- #
|
| 41 |
+
def _seg_angle(seg):
|
| 42 |
+
x1, y1, x2, y2 = seg
|
| 43 |
+
return np.degrees(np.arctan2(y2 - y1, x2 - x1)) % 180.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _line_from_seg(seg):
|
| 47 |
+
x1, y1, x2, y2 = seg
|
| 48 |
+
l = np.cross([x1, y1, 1.0], [x2, y2, 1.0])
|
| 49 |
+
return l / (np.hypot(l[0], l[1]) or 1.0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _intersect(l1, l2):
|
| 53 |
+
p = np.cross(l1, l2)
|
| 54 |
+
return None if abs(p[2]) < 1e-9 else np.array([p[0] / p[2], p[1] / p[2]])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def merge_lines(wmask):
|
| 58 |
+
"""Cluster Hough segments into merged pitch lines (angle + signed distance)."""
|
| 59 |
+
segs = cv2.HoughLinesP(wmask, 1, np.pi / 180, threshold=70,
|
| 60 |
+
minLineLength=70, maxLineGap=30)
|
| 61 |
+
if segs is None:
|
| 62 |
+
return []
|
| 63 |
+
segs = segs[:, 0]
|
| 64 |
+
used = np.zeros(len(segs), bool)
|
| 65 |
+
merged = []
|
| 66 |
+
for i in range(len(segs)):
|
| 67 |
+
if used[i]:
|
| 68 |
+
continue
|
| 69 |
+
ai, li = _seg_angle(segs[i]), _line_from_seg(segs[i])
|
| 70 |
+
group = [segs[i]]; used[i] = True
|
| 71 |
+
for j in range(i + 1, len(segs)):
|
| 72 |
+
if used[j]:
|
| 73 |
+
continue
|
| 74 |
+
da = abs(ai - _seg_angle(segs[j]))
|
| 75 |
+
if min(da, 180 - da) > 6:
|
| 76 |
+
continue
|
| 77 |
+
mid = np.array([(segs[j][0] + segs[j][2]) / 2,
|
| 78 |
+
(segs[j][1] + segs[j][3]) / 2, 1.0])
|
| 79 |
+
if abs(li @ mid) < 14:
|
| 80 |
+
group.append(segs[j]); used[j] = True
|
| 81 |
+
pts = np.array([[s[0], s[1]] for s in group] +
|
| 82 |
+
[[s[2], s[3]] for s in group], dtype=np.float32)
|
| 83 |
+
vx, vy, x0, y0 = cv2.fitLine(pts, cv2.DIST_L2, 0, 0.01, 0.01).ravel()
|
| 84 |
+
l = _line_from_seg((x0, y0, x0 + vx, y0 + vy))
|
| 85 |
+
proj = (pts - [x0, y0]) @ np.array([vx, vy])
|
| 86 |
+
a = np.array([x0, y0]) + proj.min() * np.array([vx, vy])
|
| 87 |
+
b = np.array([x0, y0]) + proj.max() * np.array([vx, vy])
|
| 88 |
+
merged.append(dict(line=l, a=a, b=b, length=float(np.hypot(*(b - a)))))
|
| 89 |
+
return merged
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ----------------------- vanishing-point families ------------------------- #
|
| 93 |
+
def _line_dir(l):
|
| 94 |
+
d = np.array([-l[1], l[0]])
|
| 95 |
+
return d / (np.linalg.norm(d) + 1e-9)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _vp_inliers(lines, vp, thr_deg=2.5):
|
| 99 |
+
inl = []
|
| 100 |
+
for k, m in enumerate(lines):
|
| 101 |
+
mid = (m["a"] + m["b"]) / 2.0
|
| 102 |
+
to_vp = vp - mid
|
| 103 |
+
n = np.linalg.norm(to_vp)
|
| 104 |
+
if n < 1e-6:
|
| 105 |
+
inl.append(k); continue
|
| 106 |
+
cosang = abs(_line_dir(m["line"]) @ (to_vp / n))
|
| 107 |
+
if np.degrees(np.arccos(min(cosang, 1.0))) < thr_deg:
|
| 108 |
+
inl.append(k)
|
| 109 |
+
return inl
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _best_family(lines):
|
| 113 |
+
"""VP supported by the most total line-length; returns (inlier_idxs, vp)."""
|
| 114 |
+
best = None
|
| 115 |
+
for i in range(len(lines)):
|
| 116 |
+
for j in range(i + 1, len(lines)):
|
| 117 |
+
vp = _intersect(lines[i]["line"], lines[j]["line"])
|
| 118 |
+
if vp is None:
|
| 119 |
+
continue
|
| 120 |
+
inl = _vp_inliers(lines, vp)
|
| 121 |
+
score = sum(lines[k]["length"] for k in inl)
|
| 122 |
+
if best is None or score > best[0]:
|
| 123 |
+
best = (score, inl, vp)
|
| 124 |
+
return (best[1], best[2]) if best else (None, None)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _family_points(fam):
|
| 128 |
+
"""4 points (2 longest lines, 2 endpoints each): [l1a, l1b, l2a, l2b]."""
|
| 129 |
+
fam = sorted(fam, key=lambda m: -m["length"])[:2]
|
| 130 |
+
pts = []
|
| 131 |
+
for m in fam:
|
| 132 |
+
pts += [[float(m["a"][0]), float(m["a"][1])],
|
| 133 |
+
[float(m["b"][0]), float(m["b"][1])]]
|
| 134 |
+
return pts
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def propose_lines(frame_rgb):
|
| 138 |
+
"""Return up to 2 families, each as {"pts": [[x,y]*4], "vp": [x,y], "n": int}.
|
| 139 |
+
|
| 140 |
+
families[0] is the larger (by total length); the goal-parallel one may be
|
| 141 |
+
either — the UI lets the user flip. Empty list if too few lines are found.
|
| 142 |
+
"""
|
| 143 |
+
bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 144 |
+
merged = merge_lines(white_mask(bgr, field_mask(bgr)))
|
| 145 |
+
longl = sorted([m for m in merged if m["length"] > 80],
|
| 146 |
+
key=lambda m: -m["length"])[:14]
|
| 147 |
+
if len(longl) < 2:
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
fams = []
|
| 151 |
+
inl, vp = _best_family(longl)
|
| 152 |
+
if inl is None:
|
| 153 |
+
return []
|
| 154 |
+
fam1 = [longl[k] for k in inl]
|
| 155 |
+
fams.append({"pts": _family_points(fam1), "vp": vp.tolist(), "n": len(fam1)})
|
| 156 |
+
|
| 157 |
+
rest = [longl[k] for k in range(len(longl)) if k not in inl]
|
| 158 |
+
if len(rest) >= 2:
|
| 159 |
+
inl2, vp2 = _best_family(rest)
|
| 160 |
+
if inl2 is not None:
|
| 161 |
+
fam2 = [rest[k] for k in inl2]
|
| 162 |
+
if len(fam2) >= 2:
|
| 163 |
+
fams.append({"pts": _family_points(fam2),
|
| 164 |
+
"vp": vp2.tolist(), "n": len(fam2)})
|
| 165 |
+
return fams
|
pipeline/geometry.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# MHR-70 arm/hand joints: elbows, wrists, and every finger joint. The offside law
|
| 38 |
+
# ignores arms/hands, so vertices nearest these joints are excluded from the
|
| 39 |
+
# forward-most point used for both the line and the verdict.
|
| 40 |
+
_ARM_KP = frozenset({7, 8, 41, 62} | set(range(21, 41)) | set(range(42, 62)))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def non_arm_mask(p):
|
| 44 |
+
"""Boolean per-vertex mask, True for body (keep), False for arm/hand vertices.
|
| 45 |
+
|
| 46 |
+
Labels each vertex by its nearest keypoint. Falls back to all-True (no
|
| 47 |
+
exclusion) if keypoints are missing or malformed.
|
| 48 |
+
"""
|
| 49 |
+
V = np.asarray(p["pred_vertices"])
|
| 50 |
+
kp = p.get("pred_keypoints_3d")
|
| 51 |
+
if kp is None:
|
| 52 |
+
return np.ones(len(V), bool)
|
| 53 |
+
kp = np.asarray(kp)
|
| 54 |
+
if kp.ndim != 2 or kp.shape[0] < 15:
|
| 55 |
+
return np.ones(len(V), bool)
|
| 56 |
+
kp = kp[:, :3]
|
| 57 |
+
arm = [i for i in _ARM_KP if i < len(kp)]
|
| 58 |
+
keep = [i for i in range(len(kp)) if i not in _ARM_KP]
|
| 59 |
+
if not arm or not keep:
|
| 60 |
+
return np.ones(len(V), bool)
|
| 61 |
+
d_arm = np.linalg.norm(V[:, None, :] - kp[arm][None], axis=2).min(1)
|
| 62 |
+
d_keep = np.linalg.norm(V[:, None, :] - kp[keep][None], axis=2).min(1)
|
| 63 |
+
return d_keep <= d_arm
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _forward_most(Vf, attack_sign, mask=None):
|
| 67 |
+
"""Furthest-forward X (in attack direction) over non-arm vertices."""
|
| 68 |
+
sel = Vf[mask] if (mask is not None and mask.any()) else Vf
|
| 69 |
+
return float((attack_sign * sel[:, 0]).max())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def place_players(people, selected_ids, goal_dir_cam, flip_up=False):
|
| 73 |
+
"""Place selected meshes on a common field frame: X=offside axis, Y=goal line, Z=up."""
|
| 74 |
+
def feet_pts(Vc, frac=0.03):
|
| 75 |
+
thr = np.quantile(Vc[:, 1], 1 - frac) # camera Y is down -> feet = largest Y
|
| 76 |
+
return Vc[Vc[:, 1] >= thr]
|
| 77 |
+
|
| 78 |
+
def head_pts(Vc, frac=0.03):
|
| 79 |
+
thr = np.quantile(Vc[:, 1], frac) # camera Y is down -> head = smallest Y
|
| 80 |
+
return Vc[Vc[:, 1] <= thr]
|
| 81 |
+
|
| 82 |
+
# Up axis = the players' OWN averaged vertical (each player's feet->head),
|
| 83 |
+
# which is what actually makes them stand upright. A plane fit through the
|
| 84 |
+
# feet *positions* (the notebook's original approach) tilts whenever feet land
|
| 85 |
+
# unevenly across a clip, leaving players leaning with one foot pinned at Z=0.
|
| 86 |
+
ups = []
|
| 87 |
+
for i in selected_ids:
|
| 88 |
+
Vc = world_verts(people[i])
|
| 89 |
+
u = head_pts(Vc).mean(0) - feet_pts(Vc).mean(0)
|
| 90 |
+
ups.append(u / (np.linalg.norm(u) + 1e-9))
|
| 91 |
+
n = np.mean(ups, 0)
|
| 92 |
+
n /= np.linalg.norm(n) + 1e-9
|
| 93 |
+
if flip_up:
|
| 94 |
+
n = -n
|
| 95 |
+
|
| 96 |
+
feet_all = np.vstack([feet_pts(world_verts(people[i])) for i in selected_ids])
|
| 97 |
+
o = feet_all.mean(0) # origin at the feet centroid
|
| 98 |
+
|
| 99 |
+
g = goal_dir_cam - (goal_dir_cam @ n) * n
|
| 100 |
+
g /= np.linalg.norm(g)
|
| 101 |
+
ez, ey = n, g
|
| 102 |
+
ex = np.cross(ey, ez); ex /= np.linalg.norm(ex)
|
| 103 |
+
ey = np.cross(ez, ex)
|
| 104 |
+
Rwf = np.stack([ex, ey, ez], axis=1)
|
| 105 |
+
|
| 106 |
+
placed = {i: (world_verts(people[i]) - o) @ Rwf for i in selected_ids}
|
| 107 |
+
for i in placed: # drop each player's feet to Z=0 individually
|
| 108 |
+
placed[i][:, 2] -= placed[i][:, 2].min()
|
| 109 |
+
return placed
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def offside_plane_x(placed, attack_sign, defender_ids, masks=None):
|
| 113 |
+
"""X of the offside line = the furthest-forward point among the MARKED defenders.
|
| 114 |
+
|
| 115 |
+
Every marked defender is treated as a candidate "second-last defender": the
|
| 116 |
+
line is drawn at whichever of them reaches furthest forward (in the attack
|
| 117 |
+
direction), using their furthest body point with arms/hands excluded. Falls
|
| 118 |
+
back to the scene's median X if no defenders are labeled.
|
| 119 |
+
"""
|
| 120 |
+
dset = set(int(d) for d in (defender_ids or []))
|
| 121 |
+
m = masks or {}
|
| 122 |
+
dfwd = [_forward_most(placed[i], attack_sign, m.get(i)) for i in placed if i in dset]
|
| 123 |
+
if dfwd:
|
| 124 |
+
return attack_sign * max(dfwd)
|
| 125 |
+
return float(np.median(np.vstack(list(placed.values()))[:, 0]))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def build_scene(placed, faces, plane_x, attack_sign, defender_ids, masks=None,
|
| 129 |
+
too_close=0.30):
|
| 130 |
+
allP = np.vstack(list(placed.values()))
|
| 131 |
+
x0, x1 = allP[:, 0].min() - 4, allP[:, 0].max() + 4
|
| 132 |
+
y0, y1 = allP[:, 1].min() - 4, allP[:, 1].max() + 4
|
| 133 |
+
|
| 134 |
+
def fwd(x):
|
| 135 |
+
return attack_sign * x
|
| 136 |
+
|
| 137 |
+
m = masks or {}
|
| 138 |
+
fmost = {i: _forward_most(placed[i], attack_sign, m.get(i)) for i in placed}
|
| 139 |
+
|
| 140 |
+
fig = go.Figure()
|
| 141 |
+
# pitch
|
| 142 |
+
fig.add_trace(go.Mesh3d(x=[x0, x1, x1, x0], y=[y0, y0, y1, y1], z=[0, 0, 0, 0],
|
| 143 |
+
i=[0, 0], j=[1, 2], k=[2, 3], color="seagreen",
|
| 144 |
+
opacity=0.5, showlegend=False))
|
| 145 |
+
# offside plane
|
| 146 |
+
if plane_x is not None:
|
| 147 |
+
fig.add_trace(go.Mesh3d(x=[plane_x] * 4, y=[y0, y1, y1, y0], z=[0, 0, 3, 3],
|
| 148 |
+
i=[0, 0], j=[1, 2], k=[2, 3], color="red",
|
| 149 |
+
opacity=0.3, showlegend=False))
|
| 150 |
+
# players
|
| 151 |
+
palette = ["crimson", "royalblue", "gold", "darkorange", "mediumpurple",
|
| 152 |
+
"deepskyblue", "hotpink", "mediumspringgreen", "tomato", "slateblue"]
|
| 153 |
+
dset = set(int(d) for d in (defender_ids or []))
|
| 154 |
+
any_off = False
|
| 155 |
+
line_fwd = fwd(plane_x) if plane_x is not None else None
|
| 156 |
+
for n_, i in enumerate(placed):
|
| 157 |
+
if i in dset:
|
| 158 |
+
col, lab = "royalblue", "defender"
|
| 159 |
+
elif line_fwd is None:
|
| 160 |
+
col, lab = palette[n_ % len(palette)], ""
|
| 161 |
+
else:
|
| 162 |
+
m = fmost[i] - line_fwd
|
| 163 |
+
if m > too_close:
|
| 164 |
+
col, lab, any_off = "red", f"OFFSIDE +{m:.2f}m", True
|
| 165 |
+
elif m < -too_close:
|
| 166 |
+
col, lab = "seagreen", f"onside {m:.2f}m"
|
| 167 |
+
else:
|
| 168 |
+
col, lab = "orange", f"close {m:+.2f}m"
|
| 169 |
+
V = placed[i]
|
| 170 |
+
fig.add_trace(go.Mesh3d(x=V[:, 0], y=V[:, 1], z=V[:, 2],
|
| 171 |
+
i=faces[:, 0], j=faces[:, 1], k=faces[:, 2],
|
| 172 |
+
color=col, opacity=1.0, name=f"#{i} {lab}"))
|
| 173 |
+
|
| 174 |
+
title = "VAR — OFFSIDE" if any_off else "VAR — NO OFFSIDE"
|
| 175 |
+
fig.update_layout(
|
| 176 |
+
title=dict(text=title, x=0.5, font=dict(size=22, color="white")),
|
| 177 |
+
paper_bgcolor="#0b1f3a", height=640, margin=dict(l=0, r=0, t=40, b=0),
|
| 178 |
+
scene=dict(aspectmode="data", bgcolor="#0b1f3a",
|
| 179 |
+
xaxis_title="X offside axis (m)", yaxis_title="Y goal line (m)",
|
| 180 |
+
zaxis_title="Z (m)",
|
| 181 |
+
camera=dict(eye=dict(x=0, y=-2.2, z=1.2), up=dict(x=0, y=0, z=1))))
|
| 182 |
+
return fig
|
pipeline/gpu.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The ONLY module that touches the GPU. Two lazily-loaded models, kept separate so
|
| 3 |
+
each stage pays only for what it needs:
|
| 4 |
+
|
| 5 |
+
detect_frame() -> RF-DETR-Seg (large): boxes + per-player masks.
|
| 6 |
+
reconstruct_selected()-> SAM 3D Body: meshes for ONLY the selected boxes (Build).
|
| 7 |
+
|
| 8 |
+
This is the RF-DETR variant of the Space: detection uses Roboflow's RF-DETR-Seg
|
| 9 |
+
(default the *large* model), which returns boxes AND instance masks in one pass —
|
| 10 |
+
the UI toggles between showing/selecting by box or by segment. Build still
|
| 11 |
+
reconstructs only the selected players with SAM 3D Body.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import functools
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
# The sam-3d-body repo is cloned here by the Dockerfile and added to sys.path.
|
| 21 |
+
SAM3D_DIR = os.environ.get("SAM3D_DIR", "/app/sam-3d-body")
|
| 22 |
+
if SAM3D_DIR not in sys.path:
|
| 23 |
+
sys.path.insert(0, SAM3D_DIR)
|
| 24 |
+
|
| 25 |
+
HF_REPO_ID = os.environ.get("SAM3D_REPO_ID", "facebook/sam-3d-body-dinov3")
|
| 26 |
+
RFDETR_SIZE = os.environ.get("RFDETR_SIZE", "large").lower()
|
| 27 |
+
|
| 28 |
+
COCO_PERSON = 1 # rfdetr/COCO is 1-indexed: person == 1
|
| 29 |
+
|
| 30 |
+
_DETECTOR = None
|
| 31 |
+
_ESTIMATOR = None
|
| 32 |
+
_FACES = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ----------------------------------------------------------------------------
|
| 36 |
+
# Detector — RF-DETR-Seg (boxes + masks). Loaded on the first Detect.
|
| 37 |
+
# ----------------------------------------------------------------------------
|
| 38 |
+
def get_detector():
|
| 39 |
+
global _DETECTOR
|
| 40 |
+
if _DETECTOR is None:
|
| 41 |
+
from rfdetr import (RFDETRSegNano, RFDETRSegSmall, # type: ignore
|
| 42 |
+
RFDETRSegMedium, RFDETRSegLarge)
|
| 43 |
+
sizes = {"nano": RFDETRSegNano, "small": RFDETRSegSmall,
|
| 44 |
+
"medium": RFDETRSegMedium, "large": RFDETRSegLarge}
|
| 45 |
+
Model = sizes.get(RFDETR_SIZE, RFDETRSegLarge)
|
| 46 |
+
_DETECTOR = Model()
|
| 47 |
+
try:
|
| 48 |
+
_DETECTOR.optimize_for_inference()
|
| 49 |
+
except Exception:
|
| 50 |
+
pass
|
| 51 |
+
return _DETECTOR
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@functools.lru_cache(maxsize=16)
|
| 55 |
+
def _detect_cached(video_path, idx, conf):
|
| 56 |
+
"""Run RF-DETR-Seg on one frame; keep person boxes + masks. Cached per frame."""
|
| 57 |
+
from .video import grab_frame
|
| 58 |
+
frame_rgb = grab_frame(video_path, idx)
|
| 59 |
+
if frame_rgb is None:
|
| 60 |
+
return None
|
| 61 |
+
det = get_detector().predict(frame_rgb, threshold=conf)
|
| 62 |
+
masks = getattr(det, "mask", None)
|
| 63 |
+
people = []
|
| 64 |
+
for k in range(len(det.xyxy)):
|
| 65 |
+
if int(det.class_id[k]) != COCO_PERSON:
|
| 66 |
+
continue
|
| 67 |
+
x1, y1, x2, y2 = [float(v) for v in det.xyxy[k]]
|
| 68 |
+
people.append({
|
| 69 |
+
"bbox": np.array([x1, y1, x2, y2], dtype=float),
|
| 70 |
+
"score": float(det.confidence[k]),
|
| 71 |
+
"mask": (np.asarray(masks[k], dtype=bool) if masks is not None else None),
|
| 72 |
+
})
|
| 73 |
+
# stable left-to-right order
|
| 74 |
+
people.sort(key=lambda p: (p["bbox"][0], p["bbox"][1]))
|
| 75 |
+
return people
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def detect_frame(video_path, idx, conf=0.4):
|
| 79 |
+
"""CPU-cheap wrapper around the cached RF-DETR-Seg detection."""
|
| 80 |
+
return _detect_cached(str(video_path), int(idx), round(float(conf), 3))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ----------------------------------------------------------------------------
|
| 84 |
+
# Reconstructor — SAM 3D Body. Loaded on the first Build.
|
| 85 |
+
# ----------------------------------------------------------------------------
|
| 86 |
+
def get_estimator():
|
| 87 |
+
"""Lazy-load the SAM 3D Body estimator once; returns (estimator, faces)."""
|
| 88 |
+
global _ESTIMATOR, _FACES
|
| 89 |
+
if _ESTIMATOR is None:
|
| 90 |
+
from huggingface_hub import login
|
| 91 |
+
token = os.environ.get("HF_TOKEN")
|
| 92 |
+
if token:
|
| 93 |
+
login(token=token)
|
| 94 |
+
from notebook.utils import setup_sam_3d_body
|
| 95 |
+
_ESTIMATOR = setup_sam_3d_body(hf_repo_id=HF_REPO_ID)
|
| 96 |
+
_FACES = np.asarray(_ESTIMATOR.faces)
|
| 97 |
+
return _ESTIMATOR, _FACES
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def get_faces():
|
| 101 |
+
return get_estimator()[1]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@functools.lru_cache(maxsize=16)
|
| 105 |
+
def _reconstruct_cached(video_path, idx, boxes_key):
|
| 106 |
+
"""Reconstruct ONLY the given boxes. boxes_key is a hashable tuple of int xyxy."""
|
| 107 |
+
from .video import grab_frame
|
| 108 |
+
est, _ = get_estimator()
|
| 109 |
+
frame_rgb = grab_frame(video_path, idx)
|
| 110 |
+
if frame_rgb is None:
|
| 111 |
+
return None
|
| 112 |
+
boxes = np.array(boxes_key, dtype=np.float32).reshape(-1, 4)
|
| 113 |
+
# Providing bboxes reconstructs exactly these people (in order); the FOV
|
| 114 |
+
# estimator still runs for focal_length.
|
| 115 |
+
people = est.process_one_image(frame_rgb, bboxes=boxes)
|
| 116 |
+
slim = []
|
| 117 |
+
for p in people:
|
| 118 |
+
kp = p.get("pred_keypoints_3d")
|
| 119 |
+
slim.append({
|
| 120 |
+
"bbox": np.asarray(p["bbox"]).reshape(-1)[:4].astype(float),
|
| 121 |
+
"pred_vertices": np.asarray(p["pred_vertices"], dtype=np.float32),
|
| 122 |
+
"pred_cam_t": np.asarray(p["pred_cam_t"], dtype=np.float32).reshape(3),
|
| 123 |
+
"focal_length": float(np.asarray(p["focal_length"]).reshape(-1)[0]),
|
| 124 |
+
"pred_keypoints_3d": (None if kp is None
|
| 125 |
+
else np.asarray(kp, dtype=np.float32)),
|
| 126 |
+
})
|
| 127 |
+
return slim
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def reconstruct_selected(video_path, idx, boxes):
|
| 131 |
+
"""Reconstruct meshes for the selected boxes (list of [x1,y1,x2,y2])."""
|
| 132 |
+
boxes_key = tuple(int(round(v)) for b in boxes for v in b[:4])
|
| 133 |
+
return _reconstruct_cached(str(video_path), int(idx), boxes_key)
|
pipeline/overlay.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, selected_ids=()):
|
| 11 |
+
"""Draw numbered boxes; selected players get a thick yellow box, others thin green."""
|
| 12 |
+
sel = set(int(i) for i in selected_ids)
|
| 13 |
+
img = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 14 |
+
for i, p in enumerate(people):
|
| 15 |
+
x1, y1, x2, y2 = [int(v) for v in p["bbox"]]
|
| 16 |
+
if i in sel:
|
| 17 |
+
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 230, 255), 3) # yellow, thick
|
| 18 |
+
else:
|
| 19 |
+
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) # green, thin
|
| 20 |
+
cv2.putText(img, str(i), (x1, max(y1 - 6, 14)),
|
| 21 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 3)
|
| 22 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def pick_box(people, x, y):
|
| 26 |
+
"""Return the index of the smallest box containing (x, y), or None."""
|
| 27 |
+
hit, best_area = None, None
|
| 28 |
+
for i, p in enumerate(people):
|
| 29 |
+
x1, y1, x2, y2 = p["bbox"]
|
| 30 |
+
if x1 <= x <= x2 and y1 <= y <= y2:
|
| 31 |
+
area = (x2 - x1) * (y2 - y1)
|
| 32 |
+
if best_area is None or area < best_area:
|
| 33 |
+
best_area, hit = area, i
|
| 34 |
+
return hit
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# palette for per-player mask tints (BGR)
|
| 38 |
+
_MASK_COLORS = [(60, 60, 220), (220, 140, 60), (60, 200, 220), (200, 80, 200),
|
| 39 |
+
(80, 200, 120), (220, 200, 80), (140, 100, 240), (90, 180, 60)]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def draw_masks(frame_rgb, people, selected_ids=()):
|
| 43 |
+
"""Translucent colored silhouettes; selected players brighter + outlined.
|
| 44 |
+
|
| 45 |
+
Falls back to a box for any detection lacking a mask.
|
| 46 |
+
"""
|
| 47 |
+
sel = set(int(i) for i in selected_ids)
|
| 48 |
+
img = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 49 |
+
for i, p in enumerate(people):
|
| 50 |
+
m = p.get("mask")
|
| 51 |
+
col = _MASK_COLORS[i % len(_MASK_COLORS)]
|
| 52 |
+
is_sel = i in sel
|
| 53 |
+
if m is not None:
|
| 54 |
+
alpha = 0.55 if is_sel else 0.30
|
| 55 |
+
tint = np.zeros_like(img); tint[m] = col
|
| 56 |
+
img[m] = (img[m] * (1 - alpha) + tint[m] * alpha).astype(np.uint8)
|
| 57 |
+
if is_sel: # outline the selected silhouette
|
| 58 |
+
cnts, _ = cv2.findContours(m.astype(np.uint8), cv2.RETR_EXTERNAL,
|
| 59 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 60 |
+
cv2.drawContours(img, cnts, -1, (0, 230, 255), 3)
|
| 61 |
+
else:
|
| 62 |
+
x1, y1, x2, y2 = [int(v) for v in p["bbox"]]
|
| 63 |
+
cv2.rectangle(img, (x1, y1), (x2, y2),
|
| 64 |
+
(0, 230, 255) if is_sel else (0, 255, 0), 3 if is_sel else 2)
|
| 65 |
+
x1, y1 = int(p["bbox"][0]), int(p["bbox"][1])
|
| 66 |
+
cv2.putText(img, str(i), (x1, max(y1 - 6, 14)),
|
| 67 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 3)
|
| 68 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def pick_mask(people, x, y):
|
| 72 |
+
"""Index of the player whose mask covers (x, y); ties -> smallest mask.
|
| 73 |
+
|
| 74 |
+
Falls back to box hit-testing for detections without a mask.
|
| 75 |
+
"""
|
| 76 |
+
x, y = int(x), int(y)
|
| 77 |
+
hit, best_area = None, None
|
| 78 |
+
for i, p in enumerate(people):
|
| 79 |
+
m = p.get("mask")
|
| 80 |
+
if m is not None:
|
| 81 |
+
if 0 <= y < m.shape[0] and 0 <= x < m.shape[1] and m[y, x]:
|
| 82 |
+
area = int(m.sum())
|
| 83 |
+
if best_area is None or area < best_area:
|
| 84 |
+
best_area, hit = area, i
|
| 85 |
+
else:
|
| 86 |
+
x1, y1, x2, y2 = p["bbox"]
|
| 87 |
+
if x1 <= x <= x2 and y1 <= y <= y2:
|
| 88 |
+
area = (x2 - x1) * (y2 - y1)
|
| 89 |
+
if best_area is None or area < best_area:
|
| 90 |
+
best_area, hit = area, i
|
| 91 |
+
return hit
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def draw_lines(frame_rgb, pts):
|
| 95 |
+
"""Draw the clicked points and the (up to two) line segments.
|
| 96 |
+
|
| 97 |
+
pts is a list of [x, y]; points 0-1 form line 1 (red), 2-3 form line 2 (yellow).
|
| 98 |
+
"""
|
| 99 |
+
if frame_rgb is None:
|
| 100 |
+
return None
|
| 101 |
+
img = cv2.cvtColor(frame_rgb.copy(), cv2.COLOR_RGB2BGR)
|
| 102 |
+
cols = [(0, 0, 255), (0, 255, 255)] # BGR: line1 red, line2 yellow
|
| 103 |
+
for k, (px, py) in enumerate(pts):
|
| 104 |
+
cv2.circle(img, (int(px), int(py)), 6, cols[k // 2], -1)
|
| 105 |
+
if len(pts) >= 2:
|
| 106 |
+
cv2.line(img, tuple(map(int, pts[0])), tuple(map(int, pts[1])), cols[0], 2)
|
| 107 |
+
if len(pts) >= 4:
|
| 108 |
+
cv2.line(img, tuple(map(int, pts[2])), tuple(map(int, pts[3])), cols[1], 2)
|
| 109 |
+
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,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
rfdetr>=1.8.1
|
| 7 |
+
supervision>=0.29.0
|
| 8 |
+
numpy
|
| 9 |
+
huggingface_hub
|
| 10 |
+
pytorch-lightning
|
| 11 |
+
pyrender
|
| 12 |
+
yacs
|
| 13 |
+
scikit-image
|
| 14 |
+
einops
|
| 15 |
+
timm
|
| 16 |
+
dill
|
| 17 |
+
pandas
|
| 18 |
+
rich
|
| 19 |
+
hydra-core
|
| 20 |
+
hydra-submitit-launcher
|
| 21 |
+
hydra-colorlog
|
| 22 |
+
pyrootutils
|
| 23 |
+
webdataset
|
| 24 |
+
chump
|
| 25 |
+
networkx==3.2.1
|
| 26 |
+
roma
|
| 27 |
+
joblib
|
| 28 |
+
seaborn
|
| 29 |
+
appdirs
|
| 30 |
+
cython
|
| 31 |
+
jsonlines
|
| 32 |
+
xtcocotools
|
| 33 |
+
loguru
|
| 34 |
+
optree
|
| 35 |
+
fvcore
|
| 36 |
+
pycocotools
|
| 37 |
+
trimesh
|