masterdanh commited on
Commit
2bcd966
·
1 Parent(s): 0d6965c

feat(cv): draw object-ball trails in the shot overlay

Browse files

Only the cue left a tail, so the video showed the target balls
teleporting between boxes. Each ball_track now draws its own tail in
the same TRAIL_N window, coloured through init_idx -> balls_init ->
number, under the cue tail so the cue stays the main character.
Gate: pytest 188/202 green, 5 new cases on the pure half.

src/poolcoach_cv/overlay.py CHANGED
@@ -3,7 +3,9 @@
3
  The drawing style is reused from the pilot tracking script (a track tail
4
  plus a status header), extended for the app: EVERY ball is drawn, not just
5
  the cue; colours follow the ball number (the two-stage BallID runs per frame
6
- on the same bbox crops); and the inferred collisions are marked.
 
 
7
 
8
  Rendering happens INSIDE the analyze job, BEFORE the worker deletes the clip
9
  -- the lesson from the first full run being that once the clip is gone the
@@ -38,7 +40,9 @@ import numpy as np
38
 
39
  from poolcoach_cv.broadcast import BALL_CLASSES, BALL_R_M
40
 
41
- TRAIL_N = 90 # track points drawn as the cue-ball tail
 
 
42
 
43
  # Ball colours by number (BGR) -- matched to the front-end COLORS table so
44
  # both places tell the same colour story; unknown or unrecognised is grey.
@@ -95,6 +99,43 @@ def _uncomp_xy(pts: np.ndarray, height_comp: dict | None) -> np.ndarray:
95
  - k * np.array([cx, cy])
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def _reencode_h264(src: Path, dst: Path) -> str | None:
99
  """mp4v -> H.264 yuv420p +faststart so a browser <video> can play it.
100
  Returns None on success, or a warning message when ffmpeg is missing or
@@ -158,6 +199,7 @@ def render_shot_overlay(clip_path, corners_px, result: dict,
158
  trail_px = (th.table_to_px(_uncomp_xy(
159
  np.array([[float(p["x_m"]), float(p["y_m"])] for p in track]), hc))
160
  if track else np.empty((0, 2)))
 
161
  cols = result.get("collisions") or []
162
  col_t = [float(c["t_s"]) for c in cols]
163
  col_px = (th.table_to_px(_uncomp_xy(
@@ -228,6 +270,15 @@ def render_shot_overlay(clip_path, corners_px, result: dict,
228
  cv2.rectangle(frame, (int(x1), int(y1)),
229
  (int(x2), int(y2)), CUE_BGR, 2)
230
 
 
 
 
 
 
 
 
 
 
231
  # cue-ball track tail -- the points measured up to this moment
232
  m = trail_t <= t_rel + 1e-6
233
  pts = trail_px[m][-TRAIL_N:]
 
3
  The drawing style is reused from the pilot tracking script (a track tail
4
  plus a status header), extended for the app: EVERY ball is drawn, not just
5
  the cue; colours follow the ball number (the two-stage BallID runs per frame
6
+ on the same bbox crops); EVERY tracked ball gets a tail, not just the cue
7
+ (``ball_tracks``, coloured by the same number); and the inferred collisions
8
+ are marked.
9
 
10
  Rendering happens INSIDE the analyze job, BEFORE the worker deletes the clip
11
  -- the lesson from the first full run being that once the clip is gone the
 
40
 
41
  from poolcoach_cv.broadcast import BALL_CLASSES, BALL_R_M
42
 
43
+ TRAIL_N = 90 # track points drawn as a ball tail -- ONE window
44
+ # for the cue and every object ball, so the tails
45
+ # tell the same length of story
46
 
47
  # Ball colours by number (BGR) -- matched to the front-end COLORS table so
48
  # both places tell the same colour story; unknown or unrecognised is grey.
 
99
  - k * np.array([cx, cy])
100
 
101
 
102
+ def _ball_trails(result: dict, th) -> list[tuple]:
103
+ """Every OBJECT-ball track of the result, ready to draw:
104
+ ``[(t_s array, pixel polyline, BGR colour)]``.
105
+
106
+ The colour comes from the ball NUMBER, which the track does not carry
107
+ itself: ``init_idx`` points back into ``balls_init``, where the worker
108
+ attached ``number`` from the same BallID pass the boxes use, so a ball's
109
+ tail and its box tell the same colour story. No number -- an
110
+ unrecognised colour or an older result -- draws grey rather than
111
+ inventing one.
112
+
113
+ Coordinates go through ``_uncomp_xy`` exactly like the cue tail: the
114
+ JSON holds compensated ball centres, and the frame shows where the ball
115
+ APPEARS. A track shorter than two points cannot be a polyline and is
116
+ dropped here rather than in the render loop.
117
+
118
+ Pure numpy (``th`` is the pure TableHomography), so it is unit tested in
119
+ the app environment.
120
+ """
121
+ hc = result.get("height_comp")
122
+ balls_init = result.get("balls_init") or []
123
+ trails: list[tuple] = []
124
+ for bt in result.get("ball_tracks") or []:
125
+ pts = bt.get("track") or []
126
+ if len(pts) < 2:
127
+ continue
128
+ idx = bt.get("init_idx")
129
+ num = (balls_init[idx].get("number")
130
+ if isinstance(idx, int) and 0 <= idx < len(balls_init)
131
+ else None)
132
+ xy = np.array([[float(p["x_m"]), float(p["y_m"])] for p in pts])
133
+ trails.append((np.array([float(p["t_s"]) for p in pts]),
134
+ th.table_to_px(_uncomp_xy(xy, hc)),
135
+ BALL_BGR.get(num, UNKNOWN_BGR)))
136
+ return trails
137
+
138
+
139
  def _reencode_h264(src: Path, dst: Path) -> str | None:
140
  """mp4v -> H.264 yuv420p +faststart so a browser <video> can play it.
141
  Returns None on success, or a warning message when ffmpeg is missing or
 
199
  trail_px = (th.table_to_px(_uncomp_xy(
200
  np.array([[float(p["x_m"]), float(p["y_m"])] for p in track]), hc))
201
  if track else np.empty((0, 2)))
202
+ ball_trails = _ball_trails(result, th)
203
  cols = result.get("collisions") or []
204
  col_t = [float(c["t_s"]) for c in cols]
205
  col_px = (th.table_to_px(_uncomp_xy(
 
270
  cv2.rectangle(frame, (int(x1), int(y1)),
271
  (int(x2), int(y2)), CUE_BGR, 2)
272
 
273
+ # object-ball tails -- the same TRAIL_N window as the cue, each
274
+ # in its own ball colour. Drawn FIRST so the cue tail stays on
275
+ # top: the cue is still the main character of the shot.
276
+ for bt_t, bt_px, bt_col in ball_trails:
277
+ bp = bt_px[bt_t <= t_rel + 1e-6][-TRAIL_N:]
278
+ if len(bp) >= 2:
279
+ cv2.polylines(frame, [bp.astype(np.int32)], False,
280
+ bt_col, 2)
281
+
282
  # cue-ball track tail -- the points measured up to this moment
283
  m = trail_t <= t_rel + 1e-6
284
  pts = trail_px[m][-TRAIL_N:]
tests/test_overlay.py CHANGED
@@ -1,7 +1,8 @@
1
  """Tests for the PURE part of poolcoach_cv.overlay (slice A2 part 3) -- the
2
  app venv, no cv2 and no torch: RecordingModel (recording the boxes around a
3
- predict) plus _uncomp_xy (inverting the height compensation, so drawing back
4
- onto the frame lands where the ball APPEARS).
 
5
 
6
  The real render was accepted by a smoke run on the shot 03 clip of rack_a1
7
  (the CV venv, 13/08) plus gate A2.2 when the whole VOD was rerun.
@@ -12,8 +13,11 @@ from __future__ import annotations
12
  import numpy as np
13
  import pytest
14
 
 
15
  from poolcoach_cv.camera import HeightCompensation
16
- from poolcoach_cv.overlay import BALL_BGR, RecordingModel, _uncomp_xy
 
 
17
 
18
 
19
  class _Box:
@@ -75,3 +79,88 @@ def test_uncomp_xy_leaves_points_alone_when_off_or_camera_missing():
75
 
76
  def test_ball_colour_table_covers_1_to_9():
77
  assert set(BALL_BGR) == set(range(1, 10))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Tests for the PURE part of poolcoach_cv.overlay (slice A2 part 3) -- the
2
  app venv, no cv2 and no torch: RecordingModel (recording the boxes around a
3
+ predict), _uncomp_xy (inverting the height compensation, so drawing back
4
+ onto the frame lands where the ball APPEARS) and _ball_trails (an
5
+ object-ball track -> the coloured polyline the render loop clips by time).
6
 
7
  The real render was accepted by a smoke run on the shot 03 clip of rack_a1
8
  (the CV venv, 13/08) plus gate A2.2 when the whole VOD was rerun.
 
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
 
23
  class _Box:
 
79
 
80
  def test_ball_colour_table_covers_1_to_9():
81
  assert set(BALL_BGR) == set(range(1, 10))
82
+
83
+
84
+ # ----------------------------------------------------- object-ball tails
85
+ # _ball_trails is the pure half of drawing them: track -> (times, pixel
86
+ # polyline, colour). The render loop only clips it by time and calls
87
+ # polylines, which is the part the smoke run covers.
88
+
89
+ CORNERS_PX = [[527.5, 869.7], [1380.1, 864.0],
90
+ [1232.3, 278.5], [673.0, 272.8]]
91
+
92
+
93
+ def _th():
94
+ return TableHomography(orient_corners(np.asarray(CORNERS_PX,
95
+ dtype=np.float64)),
96
+ table_w=TABLE_W_M, table_l=TABLE_L_M)
97
+
98
+
99
+ def _line(t0, n=4, x=0.6, y=1.2):
100
+ return [{"t_s": round(t0 + i * 0.033, 3), "x_m": x + i * 0.05, "y_m": y}
101
+ for i in range(n)]
102
+
103
+
104
+ def test_ball_trails_take_their_colour_from_the_number_via_init_idx():
105
+ """A track carries no number of its own: init_idx points back into
106
+ balls_init, which is where BallID left it -- so a tail and the box of
107
+ the same ball are drawn in the same colour."""
108
+ res = {
109
+ "balls_init": [
110
+ {"x_m": 0.5, "y_m": 0.2, "type": "cue", "conf": 0.9},
111
+ {"x_m": 0.6, "y_m": 1.2, "type": "ball", "conf": 0.9,
112
+ "number": 3},
113
+ {"x_m": 0.9, "y_m": 1.8, "type": "ball", "conf": 0.9},
114
+ ],
115
+ "ball_tracks": [{"init_idx": 1, "track": _line(0.0),
116
+ "pocketed_guess": False},
117
+ {"init_idx": 2, "track": _line(0.1, x=0.9),
118
+ "pocketed_guess": True}],
119
+ }
120
+ trails = _ball_trails(res, _th())
121
+ assert len(trails) == 2
122
+ assert trails[0][2] == BALL_BGR[3]
123
+ assert trails[1][2] == UNKNOWN_BGR # no number -> grey, not guessed
124
+ assert trails[0][0].tolist() == [p["t_s"] for p in _line(0.0)]
125
+ assert trails[0][1].shape == (4, 2)
126
+ assert np.isfinite(trails[0][1]).all()
127
+
128
+
129
+ def test_ball_trails_fall_back_to_grey_on_an_init_idx_that_points_nowhere():
130
+ """A hand-edited or truncated result must not raise: an index outside
131
+ balls_init draws grey."""
132
+ res = {"balls_init": [{"x_m": 0.5, "y_m": 0.2, "type": "cue",
133
+ "conf": 0.9}],
134
+ "ball_tracks": [{"init_idx": 7, "track": _line(0.0)},
135
+ {"init_idx": None, "track": _line(0.0)}]}
136
+ assert [t[2] for t in _ball_trails(res, _th())] \
137
+ == [UNKNOWN_BGR, UNKNOWN_BGR]
138
+
139
+
140
+ def test_ball_trails_drop_a_track_too_short_to_be_a_polyline():
141
+ res = {"balls_init": [], "ball_tracks": [
142
+ {"init_idx": 0, "track": _line(0.0, n=1)},
143
+ {"init_idx": 0, "track": []},
144
+ {"init_idx": 0, "track": _line(0.0, n=2)}]}
145
+ assert len(_ball_trails(res, _th())) == 1
146
+
147
+
148
+ def test_a_result_without_ball_tracks_draws_no_extra_tail():
149
+ """An older result (the field predates nothing else) simply has no
150
+ object-ball tails -- the overlay is what it always was."""
151
+ assert _ball_trails({"track": _line(0.0)}, _th()) == []
152
+ assert _ball_trails({"ball_tracks": []}, _th()) == []
153
+
154
+
155
+ def test_ball_trails_undo_the_height_compensation_like_the_cue_tail():
156
+ """The JSON holds COMPENSATED centres; the tail has to land where the
157
+ ball appears on the frame, so the same inverse the cue tail uses is
158
+ applied -- an on flag must move the pixels."""
159
+ res = {"balls_init": [], "ball_tracks": [{"init_idx": 0,
160
+ "track": _line(0.0)}]}
161
+ plain = _ball_trails(res, _th())[0][1]
162
+ res["height_comp"] = {"on": True, "h_m": 2.41, "cx_m": 0.6, "cy_m": 1.2}
163
+ comped = _ball_trails(res, _th())[0][1]
164
+ assert not np.allclose(plain, comped)
165
+ res["height_comp"] = {"on": False}
166
+ assert np.allclose(_ball_trails(res, _th())[0][1], plain)