Spaces:
Sleeping
Sleeping
Commit ·
b202b21
1
Parent(s): 000a154
fix(analyzer): draw only on-table detections in the overlay
Browse filesThe 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 +73 -5
- tests/test_overlay.py +82 -2
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 =
|
| 267 |
-
|
| 268 |
-
|
| 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,
|
|
|
|
| 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 |
+
|