Spaces:
Sleeping
Sleeping
Commit ·
2bcd966
1
Parent(s): 0d6965c
feat(cv): draw object-ball trails in the shot overlay
Browse filesOnly 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 +53 -2
- tests/test_overlay.py +92 -3
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);
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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)
|
| 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.
|
|
|
|
|
|
|
| 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)
|