mirrash7 commited on
Commit
514d439
·
verified ·
1 Parent(s): 3d8f861

VAR offside visualizer

Browse files
Files changed (10) hide show
  1. Dockerfile +39 -0
  2. README.md +66 -7
  3. TODO.md +21 -0
  4. app.py +215 -0
  5. pipeline/__init__.py +8 -0
  6. pipeline/geometry.py +135 -0
  7. pipeline/gpu.py +89 -0
  8. pipeline/overlay.py +36 -0
  9. pipeline/video.py +26 -0
  10. 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: blue
5
- colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
- license: apache-2.0
9
- short_description: VAR Offside - SAM3
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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