masterdanh Claude Opus 5 commited on
Commit
b202b21
·
1 Parent(s): 000a154

fix(analyzer): draw only on-table detections in the overlay

Browse files

The overlay drew every recorded box while analyze_clip has always
dropped the ones projecting off the cloth, so the imgsz 960 pass put
rectangles on the wall behind the far rail -- 2172 of 3476 ball boxes
on shot_02, reaching table y 4.48 and x -0.42. _on_table_dets applies
the same class filter and the same IN_TOL_M gate, in the same
height-compensated frame, and none survives into the drawing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

src/poolcoach_cv/overlay.py CHANGED
@@ -9,6 +9,11 @@ viewer sees immediately); EVERY tracked ball gets a tail, not just the cue
9
  (``ball_tracks``, coloured by the same number); and the inferred collisions
10
  are marked.
11
 
 
 
 
 
 
12
  Rendering happens INSIDE the analyze job, BEFORE the worker deletes the clip
13
  -- the lesson from the first full run being that once the clip is gone the
14
  overlay cannot be produced retroactively. Detections are NOT recomputed:
@@ -40,7 +45,8 @@ from pathlib import Path
40
 
41
  import numpy as np
42
 
43
- from poolcoach_cv.broadcast import BALL_CLASSES, BALL_R_M
 
44
 
45
  TRAIL_N = 90 # track points drawn as a ball tail -- ONE window
46
  # for the cue and every object ball, so the tails
@@ -112,6 +118,65 @@ def _uncomp_xy(pts: np.ndarray, height_comp: dict | None) -> np.ndarray:
112
  - k * np.array([cx, cy])
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def _ball_trails(result: dict, th) -> list[tuple]:
116
  """Every OBJECT-ball track of the result, ready to draw:
117
  ``[(t_s array, pixel polyline, BGR colour)]``.
@@ -189,6 +254,9 @@ def render_shot_overlay(clip_path, corners_px, result: dict,
189
  suspect_not_shot -- table coordinates, with t_s relative to the first
190
  frame of the clip).
191
  ``frames_dets``: from RecordingModel -- the index is the frame order.
 
 
 
192
  ``label``: the leading text of the header (ASCII), e.g. "shot 03".
193
  ``beat``: a callback invoked periodically to keep the heartbeat alive
194
  during a long job.
@@ -263,10 +331,9 @@ def render_shot_overlay(clip_path, corners_px, result: dict,
263
  beat()
264
 
265
  dets = frames_dets[i] if i < n_total else []
266
- balls = [(cls, conf, box) for cls, conf, box in dets
267
- if cls in BALL_CLASSES]
268
- others = [b for b in balls if b[0] != "Cue"]
269
- cue = next((b for b in balls if b[0] == "Cue"), None)
270
  assigns = []
271
  if others:
272
  try:
@@ -290,6 +357,7 @@ def render_shot_overlay(clip_path, corners_px, result: dict,
290
  cv2.rectangle(frame, (int(x1), int(y1)),
291
  (int(x2), int(y2)), CUE_BGR, 2)
292
 
 
293
  # object-ball tails -- the same TRAIL_N window as the cue, each
294
  # in its own ball colour. Drawn FIRST so the cue tail stays on
295
  # top: the cue is still the main character of the shot.
 
9
  (``ball_tracks``, coloured by the same number); and the inferred collisions
10
  are marked.
11
 
12
+ Only detections ON THE CLOTH are drawn: the same class filter and the same
13
+ IN_TOL_M projection gate ``analyze_clip`` applies, so the video shows what
14
+ the measurement accepted and nothing else (before that gate reached here,
15
+ the imgsz 960 pass put boxes on the wall behind the far rail).
16
+
17
  Rendering happens INSIDE the analyze job, BEFORE the worker deletes the clip
18
  -- the lesson from the first full run being that once the clip is gone the
19
  overlay cannot be produced retroactively. Detections are NOT recomputed:
 
45
 
46
  import numpy as np
47
 
48
+ from poolcoach_cv.broadcast import (BALL_CLASSES, BALL_R_M,
49
+ IN_TOL_M, TABLE_L_M, TABLE_W_M)
50
 
51
  TRAIL_N = 90 # track points drawn as a ball tail -- ONE window
52
  # for the cue and every object ball, so the tails
 
118
  - k * np.array([cx, cy])
119
 
120
 
121
+ def _comp_xy(pts: np.ndarray, height_comp: dict | None) -> np.ndarray:
122
+ """Apply the ball-centre height compensation: X', the cloth-plane
123
+ mapped position of a bbox centre, forward to P, the coordinate the JSON
124
+ speaks in. The exact forward of ``_uncomp_xy`` and a copy of
125
+ ``camera.HeightCompensation.apply``: P = (X' + k.C)/(1 + k) with
126
+ k = R/(h - R).
127
+
128
+ It exists so the overlay can ask the SAME question about a detection
129
+ that ``analyze_clip`` asked -- is this box on the cloth? -- in the same
130
+ coordinates. Reading the raw homography instead would put the far
131
+ cushion 3 cm further away here than there, and a box the numeric layer
132
+ accepted could vanish from the video that is supposed to show it.
133
+ An off flag or a missing camera leaves the points alone."""
134
+ if not height_comp or not height_comp.get("on"):
135
+ return pts
136
+ h = height_comp.get("h_m")
137
+ cx, cy = height_comp.get("cx_m"), height_comp.get("cy_m")
138
+ if h is None or cx is None or cy is None:
139
+ return pts
140
+ k = BALL_R_M / (h - BALL_R_M)
141
+ return (np.asarray(pts, dtype=np.float64)
142
+ + k * np.array([cx, cy])) / (1.0 + k)
143
+
144
+
145
+ def _on_table_dets(th, dets: list[tuple],
146
+ height_comp: dict | None) -> list[tuple]:
147
+ """The recorded detections that are BALLS ON THE CLOTH, each paired
148
+ with its table position: ``[((cls, conf, box), (x_m, y_m)), ...]``.
149
+
150
+ Two filters, both copied from ``broadcast.table_det`` so the overlay
151
+ draws neither more nor less than the numeric layer accepted: the class
152
+ has to be in BALL_CLASSES, and the bbox centre has to project inside the
153
+ table plus IN_TOL_M. The second one is why this function exists. The
154
+ detector at imgsz 960 returns boxes off the cloth -- the far-zone probe
155
+ measured one projecting to y ~ 4.25 m, 1.7 m past the far cushion --
156
+ and analyze_clip has always dropped them, but the overlay drew every
157
+ recorded box and so painted rectangles on the wall behind the table.
158
+ A viewer reading the video as the picture of the measurement was being
159
+ told the analyzer had found a ball there.
160
+
161
+ Pure numpy (``th`` is the pure TableHomography), so it is unit tested
162
+ in the app environment.
163
+ """
164
+ out: list[tuple] = []
165
+ for det in dets:
166
+ cls, _conf, (x1, y1, x2, y2) = det
167
+ if cls not in BALL_CLASSES:
168
+ continue
169
+ xy = _comp_xy(np.asarray(th.px_to_table(((x1 + x2) / 2.0,
170
+ (y1 + y2) / 2.0)),
171
+ dtype=np.float64).reshape(2),
172
+ height_comp)
173
+ tx, ty = float(xy[0]), float(xy[1])
174
+ if (-IN_TOL_M <= tx <= TABLE_W_M + IN_TOL_M
175
+ and -IN_TOL_M <= ty <= TABLE_L_M + IN_TOL_M):
176
+ out.append((det, (tx, ty)))
177
+ return out
178
+
179
+
180
  def _ball_trails(result: dict, th) -> list[tuple]:
181
  """Every OBJECT-ball track of the result, ready to draw:
182
  ``[(t_s array, pixel polyline, BGR colour)]``.
 
254
  suspect_not_shot -- table coordinates, with t_s relative to the first
255
  frame of the clip).
256
  ``frames_dets``: from RecordingModel -- the index is the frame order.
257
+ They are filtered through ``_on_table_dets`` before anything is drawn:
258
+ a box the numeric layer threw away for projecting off the cloth must
259
+ not appear on the video either.
260
  ``label``: the leading text of the header (ASCII), e.g. "shot 03".
261
  ``beat``: a callback invoked periodically to keep the heartbeat alive
262
  during a long job.
 
331
  beat()
332
 
333
  dets = frames_dets[i] if i < n_total else []
334
+ balls = _on_table_dets(th, dets, hc)
335
+ others = [b for b, _xy in balls if b[0] != "Cue"]
336
+ cue = next((b for b, _xy in balls if b[0] == "Cue"), None)
 
337
  assigns = []
338
  if others:
339
  try:
 
357
  cv2.rectangle(frame, (int(x1), int(y1)),
358
  (int(x2), int(y2)), CUE_BGR, 2)
359
 
360
+
361
  # object-ball tails -- the same TRAIL_N window as the cue, each
362
  # in its own ball colour. Drawn FIRST so the cue tail stays on
363
  # top: the cue is still the main character of the shot.
tests/test_overlay.py CHANGED
@@ -13,10 +13,11 @@ from __future__ import annotations
13
  import numpy as np
14
  import pytest
15
 
16
- from poolcoach_cv.broadcast import TABLE_L_M, TABLE_W_M
17
  from poolcoach_cv.camera import HeightCompensation
18
  from poolcoach_cv.homography import TableHomography, orient_corners
19
- from poolcoach_cv.overlay import (BALL_BGR, UNKNOWN_BGR, RecordingModel, _ball_trails,
 
20
  _uncomp_xy)
21
 
22
 
@@ -164,3 +165,82 @@ def test_ball_trails_undo_the_height_compensation_like_the_cue_tail():
164
  assert not np.allclose(plain, comped)
165
  res["height_comp"] = {"on": False}
166
  assert np.allclose(_ball_trails(res, _th())[0][1], plain)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  import numpy as np
14
  import pytest
15
 
16
+ from poolcoach_cv.broadcast import IN_TOL_M, TABLE_L_M, TABLE_W_M
17
  from poolcoach_cv.camera import HeightCompensation
18
  from poolcoach_cv.homography import TableHomography, orient_corners
19
+ from poolcoach_cv.overlay import (BALL_BGR, UNKNOWN_BGR, RecordingModel,
20
+ _ball_trails, _comp_xy, _on_table_dets,
21
  _uncomp_xy)
22
 
23
 
 
165
  assert not np.allclose(plain, comped)
166
  res["height_comp"] = {"on": False}
167
  assert np.allclose(_ball_trails(res, _th())[0][1], plain)
168
+
169
+
170
+ # ------------------------------------------- only what is ON THE CLOTH
171
+ # _on_table_dets is the whole of step 1 of hand-off 4: the overlay used to
172
+ # draw every recorded box, so the imgsz 960 pass painted rectangles on the
173
+ # wall behind the far rail -- boxes analyze_clip itself had thrown away.
174
+
175
+ HC = {"on": True, "h_m": 2.363, "cx_m": 0.549, "cy_m": -2.366}
176
+
177
+
178
+ def _box_at(x_m, y_m, hc=None, s=8.0):
179
+ """A square pixel box whose CENTRE maps back to the table point
180
+ (x_m, y_m) -- built through the same inverse the drawing uses, so a
181
+ point placed here is a point _on_table_dets has to read back."""
182
+ cx, cy = _th().table_to_px(_uncomp_xy(np.array([[x_m, y_m]]), hc))[0]
183
+ return (cx - s, cy - s, cx + s, cy + s)
184
+
185
+
186
+ def test_comp_xy_is_the_exact_forward_of_uncomp_xy():
187
+ """The overlay asks analyze_clip's question in analyze_clip's
188
+ coordinates, so the two directions have to be one map."""
189
+ pts = np.array([[0.2, 0.4], [1.1, 2.3]])
190
+ assert _comp_xy(_uncomp_xy(pts, HC), HC) == pytest.approx(pts, abs=1e-9)
191
+ comp = HeightCompensation(True, cam_xy=(HC["cx_m"], HC["cy_m"]),
192
+ h_m=HC["h_m"], f_px=2155.0, ball_r=0.028575)
193
+ assert _comp_xy(pts, HC) == pytest.approx(comp.apply(pts), abs=1e-9)
194
+ assert _comp_xy(pts, {"on": False}) is pts
195
+ assert _comp_xy(pts, None) is pts
196
+
197
+
198
+ def test_on_table_dets_keeps_a_detection_inside_the_cloth():
199
+ th = _th()
200
+ det = ("Solid", 0.8, _box_at(0.6, 1.2))
201
+ got = _on_table_dets(th, [det], None)
202
+ assert len(got) == 1 and got[0][0] is det
203
+ assert got[0][1] == pytest.approx((0.6, 1.2), abs=1e-6)
204
+
205
+
206
+ def test_on_table_dets_drops_a_detection_off_the_cloth():
207
+ """The box on the wall: the probe measured one projecting to y ~ 4.25 m,
208
+ 1.7 m past the far cushion."""
209
+ th = _th()
210
+ assert _on_table_dets(th, [("Solid", 0.7, _box_at(0.6, 4.25))], None) == []
211
+ assert _on_table_dets(th, [("Cue", 0.9, _box_at(-0.4, 1.2))], None) == []
212
+
213
+
214
+ def test_on_table_dets_reads_the_edge_the_way_analyze_clip_does():
215
+ """IN_TOL_M is the tolerance the numeric layer uses, inclusive -- one
216
+ tolerance, not two, or a ball the metric table shows would have no box
217
+ on the video."""
218
+ th = _th()
219
+ inside = ("Solid", 0.7, _box_at(0.6, TABLE_L_M + IN_TOL_M - 1e-4))
220
+ outside = ("Solid", 0.7, _box_at(0.6, TABLE_L_M + IN_TOL_M + 1e-3))
221
+ assert len(_on_table_dets(th, [inside], None)) == 1
222
+ assert _on_table_dets(th, [outside], None) == []
223
+ left = ("Solid", 0.7, _box_at(-IN_TOL_M + 1e-4, 1.2))
224
+ assert len(_on_table_dets(th, [left], None)) == 1
225
+
226
+
227
+ def test_on_table_dets_still_drops_a_class_that_is_not_a_ball():
228
+ """The class filter did not move -- a diamond marker is not a ball."""
229
+ th = _th()
230
+ assert _on_table_dets(th, [("Dot", 0.9, _box_at(0.6, 1.2))], None) == []
231
+
232
+
233
+ def test_on_table_dets_answers_in_the_compensated_frame():
234
+ """A box read at the far rail: with the compensation on it lands where
235
+ balls_init would put it, which is the only frame the two layers can
236
+ compare positions in."""
237
+ th = _th()
238
+ det = ("Solid", 0.7, _box_at(1.146, 2.4519, HC))
239
+ got = _on_table_dets(th, [det], HC)
240
+ assert len(got) == 1
241
+ assert got[0][1] == pytest.approx((1.146, 2.4519), abs=1e-6)
242
+ # read WITHOUT the flag the same box sits further away -- which is the
243
+ # bias this exists to avoid
244
+ plain = _on_table_dets(th, [det], None)
245
+ assert plain[0][1][1] > got[0][1][1] + 0.02
246
+