mirrash7 commited on
Commit
1d4f5de
·
verified ·
1 Parent(s): d9201c2

Reorder flow: draw lines on scrubbed frame, split Detect, click-to-select players

Browse files
Files changed (2) hide show
  1. app.py +101 -74
  2. pipeline/overlay.py +19 -3
app.py CHANGED
@@ -1,13 +1,13 @@
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
@@ -20,7 +20,7 @@ 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
 
@@ -28,65 +28,87 @@ from pipeline import geometry as G
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
  # ============================================================================
@@ -96,9 +118,10 @@ 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()
@@ -112,7 +135,6 @@ def on_build(video_path, idx, bbox_thr, selected_ids, line_pts, flip_up,
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]
@@ -121,8 +143,8 @@ def on_build(video_path, idx, bbox_thr, selected_ids, line_pts, flip_up,
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):
@@ -130,9 +152,8 @@ def on_plane(placed, plane_x, attack_sign, defender_ids):
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
  # ============================================================================
@@ -141,49 +162,52 @@ def on_plane(placed, plane_x, attack_sign, defender_ids):
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.0, 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,
@@ -191,21 +215,24 @@ with gr.Blocks(title="VAR Offside Visualizer") as demo:
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])
 
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.reconstruct_frame(); every other
10
+ callback here runs on cached numpy and stays on the CPU.
11
  """
12
 
13
  import os
 
20
  import gradio as gr
21
 
22
  from pipeline.video import probe_video, grab_frame
23
+ from pipeline.overlay import annotate_detections, draw_lines, pick_box
24
  from pipeline import geometry as G
25
 
26
 
 
28
  # Stage (a): upload + frame scrubbing (pure CPU)
29
  # ============================================================================
30
  def on_upload(video_path):
31
+ """New clip: size the slider, show frame 0, reset lines/frame state."""
32
  if not video_path:
33
+ return (gr.update(maximum=1, value=0), None, "Upload a clip to begin.",
34
+ 0, None, [], "")
35
  n, fps = probe_video(video_path)
36
  n_max = max(n - 1, 0)
37
+ frame = grab_frame(video_path, 0)
38
  return (
39
  gr.update(maximum=max(n_max, 1), value=0),
40
+ frame,
41
+ f"{n} frames @ {fps:.1f} fps — scrub to the moment the ball is played, "
42
+ "then click 2 goal-parallel lines on the frame.",
43
  n_max,
44
+ frame, # st_frame: clean copy for line redraws
45
+ [], # st_lines reset
46
+ "", # line_status reset
47
  )
48
 
49
 
50
  def on_scrub(video_path, idx):
51
+ """Show the new frame and reset any half-drawn lines on it."""
52
  if not video_path:
53
+ return None, None, [], ""
54
+ frame = grab_frame(video_path, int(idx))
55
+ return frame, frame, [], ""
56
 
57
 
58
  def step_frame(idx, delta, n_max):
 
59
  return max(0, min(int(n_max), int(idx) + delta))
60
 
61
 
62
  # ============================================================================
63
+ # Stage (b): draw 2 goal-parallel lines on the scrubbed frame (CPU)
64
+ # ============================================================================
65
+ def on_line_click(line_pts, clean_frame, evt: gr.SelectData):
66
+ """Collect 4 clicks = 2 lines; always redraw from the clean frame (no drift)."""
67
+ pts = list(line_pts) if line_pts else []
68
+ if len(pts) >= 4:
69
+ pts = []
70
+ pts.append([float(evt.index[0]), float(evt.index[1])])
71
+ img = draw_lines(clean_frame, pts)
72
+ status = {1: "line 1: 1/2", 2: "line 1 set", 3: "line 2: 1/2",
73
+ 4: "both lines set ✓ — now Detect players"}.get(len(pts), "")
74
+ return pts, img, status
75
+
76
+
77
+ # ============================================================================
78
+ # Stage (c): detect (GPU, cached) + show boxes (CPU after the call)
79
  # ============================================================================
80
  def on_detect(video_path, idx, bbox_thr):
81
+ """The one GPU step. Lazy-imported so the app boots without the model."""
82
  from pipeline.gpu import reconstruct_frame
83
  if not video_path:
84
+ return None, [], [], "Upload a clip first.", gr.update(choices=[], value=[])
85
  people = reconstruct_frame(video_path, idx, bbox_thr)
86
  if not people:
87
+ return (None, [], [], "No players detected — lower the confidence slider.",
88
+ gr.update(choices=[], value=[]))
89
+ annotated = annotate_detections(grab_frame(video_path, idx), people, [])
90
+ msg = (f"Detected {len(people)} players. Click a box to select a player "
91
+ "(click again to deselect).")
92
+ return annotated, people, [], msg, gr.update(choices=[], value=[])
 
 
 
 
93
 
94
 
95
  # ============================================================================
96
+ # Stage (d): click players to select (CPU)
97
  # ============================================================================
98
+ def on_select_player(people, selected, clean_frame, cur_def, evt: gr.SelectData):
99
+ """Toggle the player whose box was clicked; refresh highlight + defender choices."""
100
+ if not people:
101
+ return None, selected or [], "Detect players first.", gr.update()
102
+ hit = pick_box(people, evt.index[0], evt.index[1])
103
+ sel = list(selected or [])
104
+ if hit is not None:
105
+ sel.remove(hit) if hit in sel else sel.append(hit)
106
+ sel = sorted(sel)
107
+ img = annotate_detections(clean_frame, people, sel)
108
+ msg = (f"Selected players: {sel}. Mark defenders below, then Build."
109
+ if sel else "Click a box to select a player.")
110
+ keep_def = [d for d in (cur_def or []) if d in sel]
111
+ return img, sel, msg, gr.update(choices=sel, value=keep_def)
112
 
113
 
114
  # ============================================================================
 
118
  attack_dir, defender_ids):
119
  from pipeline.gpu import reconstruct_frame, get_faces
120
  if not selected_ids:
121
+ return None, "Click at least one player to select.", gr.update(), None, +1, []
122
  if not line_pts or len(line_pts) < 4:
123
+ return None, "Draw 2 goal-parallel lines (4 points) on the frame first.", \
124
+ gr.update(), None, +1, []
125
 
126
  people = reconstruct_frame(video_path, idx, bbox_thr)
127
  faces = get_faces()
 
135
  attack_sign = +1 if attack_dir == "toward +X" else -1
136
  dset = [int(d) for d in (defender_ids or [])]
137
  plane_x = G.offside_plane_x(placed, attack_sign, dset)
 
138
  fig = G.build_scene(placed, faces, plane_x, attack_sign, dset)
139
 
140
  allX = np.vstack(list(placed.values()))[:, 0]
 
143
  visible=True, label="Drag the offside plane (X, m)")
144
 
145
  warn = " ⚠ heights look wrong — toggle 'flip up'." if med < 1.0 else ""
146
+ return (fig, f"Median player height {med:.2f} m (expect ~1.7–1.9).{warn}",
147
+ plane_update, placed, attack_sign, dset)
148
 
149
 
150
  def on_plane(placed, plane_x, attack_sign, defender_ids):
 
152
  from pipeline.gpu import get_faces
153
  if not placed:
154
  return gr.update()
155
+ return G.build_scene(placed, get_faces(), float(plane_x),
156
+ int(attack_sign), defender_ids or [])
 
157
 
158
 
159
  # ============================================================================
 
162
  with gr.Blocks(title="VAR Offside Visualizer") as demo:
163
  gr.Markdown(
164
  "## VAR-style Offside Visualizer\n"
165
+ "Upload scrub **click 2 goal-parallel lines** Detect "
166
+ "**click players to select**mark defendersBuild.\n\n"
167
  "_Scale comes from reconstructed body height, so positions are approximate "
168
  "metres — good for relative offside ordering, not sub-10 cm calls._"
169
  )
170
 
171
  # session state
172
  st_nmax = gr.State(0) # last valid frame index
173
+ st_frame = gr.State(None) # clean RGB of the current frame
174
+ st_people = gr.State([]) # slim detections
175
  st_lines = gr.State([]) # clicked line points
176
+ st_selected = gr.State([]) # player ids selected by clicking
177
+ st_placed = gr.State(None) # placed meshes after build
178
+ st_attack = gr.State(+1)
179
+ st_defenders = gr.State([])
180
 
 
181
  video = gr.Video(label="1. Upload match clip")
182
  status = gr.Markdown()
183
+
184
  with gr.Row():
185
  frame_slider = gr.Slider(0, 1, value=0, step=1,
186
  label="2. Scrub to the offside frame")
187
  with gr.Row():
188
  prev_btn = gr.Button("◀ prev frame")
189
  next_btn = gr.Button("next frame ▶")
 
190
 
191
+ # Stage (b): lines are drawn directly on the scrubbed frame
192
+ frame_view = gr.Image(label="3. Click 2 goal-parallel lines here (4 points)",
193
+ interactive=True)
194
+ line_status = gr.Markdown()
195
+
196
+ # Stage (c)/(d): detect, then click boxes to select
197
  with gr.Row():
198
  thr = gr.Slider(0.0, 0.95, value=0.85, step=0.05, label="Detection confidence")
199
+ detect_btn = gr.Button("4. Detect players (GPU)", variant="primary")
200
+ detect_view = gr.Image(label="5. Click a player's box to select (click again to deselect)",
201
+ interactive=True)
202
+ select_status = gr.Markdown()
 
 
203
 
204
+ # Stage (e)/(f)
205
+ defenders = gr.CheckboxGroup(choices=[], label="6. Defenders (incl. GK) — sets the offside line")
206
  with gr.Row():
207
  flip = gr.Checkbox(False, label="flip up (if players are upside-down)")
208
  attack = gr.Radio(["toward +X", "toward -X"], value="toward +X",
209
  label="Attacking direction")
210
+ build_btn = gr.Button("7. Build 3D scene + offside line", variant="primary")
 
 
211
 
212
  scene = gr.Plot(label="3D scene")
213
  plane_slider = gr.Slider(-10, 10, value=0, step=0.05, visible=False,
 
215
  build_status = gr.Markdown()
216
 
217
  # --- wiring ---
218
+ video.change(on_upload, [video],
219
+ [frame_slider, frame_view, status, st_nmax, st_frame, st_lines, line_status])
220
+ frame_slider.change(on_scrub, [video, frame_slider],
221
+ [frame_view, st_frame, st_lines, line_status])
222
+ prev_btn.click(lambda i, m: step_frame(i, -1, m), [frame_slider, st_nmax], [frame_slider])
223
+ next_btn.click(lambda i, m: step_frame(i, +1, m), [frame_slider, st_nmax], [frame_slider])
224
+
225
+ frame_view.select(on_line_click, [st_lines, st_frame],
226
+ [st_lines, frame_view, line_status])
227
 
228
  detect_btn.click(on_detect, [video, frame_slider, thr],
229
+ [detect_view, st_people, st_selected, select_status, defenders])
230
+ detect_view.select(on_select_player, [st_people, st_selected, st_frame, defenders],
231
+ [detect_view, st_selected, select_status, defenders])
232
 
233
  build_btn.click(
234
  on_build,
235
+ [video, frame_slider, thr, st_selected, st_lines, flip, attack, defenders],
236
  [scene, build_status, plane_slider, st_placed, st_attack, st_defenders])
237
  plane_slider.change(on_plane, [st_placed, plane_slider, st_attack, st_defenders],
238
  [scene])
pipeline/overlay.py CHANGED
@@ -7,17 +7,33 @@ 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
 
 
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
  def draw_lines(frame_rgb, pts):
38
  """Draw the clicked points and the (up to two) line segments.
39