Spaces:
Sleeping
Sleeping
Commit ·
ff7db2d
1
Parent(s): 69cc9f9
fix(analyzer): clamp cue position across long track gaps
Browse filesA line drawn between the ends of a 0.9 s hole is not a measurement:
on shot_07 it swept within CUE_EXCL_M of six still balls in turn
(the 2 at 4.1 mm), switching their detections off. Each half of a
hole over LOST_MAX_S now answers with its own end; shorter holes
still interpolate. The two detections that stole ball 8's track sit
3.4 mm from the end and stay excluded. 271 passed / 27 skipped.
src/poolcoach_cv/broadcast.py
CHANGED
|
@@ -228,6 +228,10 @@ LOST_MAX_S = 0.5 # object-ball tracks: a track that goes this long
|
|
| 228 |
# no resurrection on purpose: without the close, a
|
| 229 |
# different ball rolling past the pocket later gets
|
| 230 |
# picked up by the track of the ball that went in.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
POT_NEAR_M = 0.10 # pocketed_guess: the last track point has to end
|
| 232 |
# within this of a pocket mouth (~3.5R, a pocket
|
| 233 |
# jaw plus position noise)
|
|
@@ -951,8 +955,9 @@ def cue_positions(rows: list[dict] | None) -> list[tuple[float, float, float]]:
|
|
| 951 |
def cue_at(pts: list[tuple[float, float, float]],
|
| 952 |
t: float) -> tuple[float, float] | None:
|
| 953 |
"""Where the cue ball was at time ``t``: LINEAR between the two
|
| 954 |
-
measured points around it,
|
| 955 |
-
|
|
|
|
| 956 |
|
| 957 |
Holding the end point rather than giving up is deliberate. The frames
|
| 958 |
before the cue is first seen and after it is last seen are exactly the
|
|
@@ -961,6 +966,21 @@ def cue_at(pts: list[tuple[float, float, float]],
|
|
| 961 |
would switch the exclusion off precisely where it is needed. The cue
|
| 962 |
barely moves in those stretches anyway: it is standing still before the
|
| 963 |
shot, and it was lost, not teleported, after it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
"""
|
| 965 |
if not pts:
|
| 966 |
return None
|
|
@@ -979,6 +999,8 @@ def cue_at(pts: list[tuple[float, float, float]],
|
|
| 979 |
t1, x1, y1 = pts[hi]
|
| 980 |
if t1 <= t0:
|
| 981 |
return x0, y0
|
|
|
|
|
|
|
| 982 |
u = (t - t0) / (t1 - t0)
|
| 983 |
return x0 + u * (x1 - x0), y0 + u * (y1 - y0)
|
| 984 |
|
|
@@ -1235,8 +1257,10 @@ def track_others(meta: list[tuple[str, float]], others_by_frame: dict,
|
|
| 1235 |
|
| 1236 |
``cue_rows``: the ``track_cue`` result of the SAME clip. Any candidate,
|
| 1237 |
in EITHER band, that lies within ``CUE_EXCL_M`` of where the cue ball
|
| 1238 |
-
is at that instant (linear interpolation of that track, the
|
| 1239 |
-
point outside its range
|
|
|
|
|
|
|
| 1240 |
occasionally classified as a Solid, and an object track whose own ball
|
| 1241 |
has just disappeared into a pocket will happily adopt it: on shot_02
|
| 1242 |
that pulled ball 5 backwards out of the far corner pocket and into the
|
|
|
|
| 228 |
# no resurrection on purpose: without the close, a
|
| 229 |
# different ball rolling past the pocket later gets
|
| 230 |
# picked up by the track of the ball that went in.
|
| 231 |
+
# ``cue_at`` reuses the same number for the same
|
| 232 |
+
# reason: past it, a straight line between two
|
| 233 |
+
# measurements says nothing about what happened
|
| 234 |
+
# in between, so it clamps instead of drawing one.
|
| 235 |
POT_NEAR_M = 0.10 # pocketed_guess: the last track point has to end
|
| 236 |
# within this of a pocket mouth (~3.5R, a pocket
|
| 237 |
# jaw plus position noise)
|
|
|
|
| 955 |
def cue_at(pts: list[tuple[float, float, float]],
|
| 956 |
t: float) -> tuple[float, float] | None:
|
| 957 |
"""Where the cue ball was at time ``t``: LINEAR between the two
|
| 958 |
+
measured points around it, the nearest end point outside the range the
|
| 959 |
+
track covers, and the nearest end point of the HOLE when the two
|
| 960 |
+
measured points are more than LOST_MAX_S apart.
|
| 961 |
|
| 962 |
Holding the end point rather than giving up is deliberate. The frames
|
| 963 |
before the cue is first seen and after it is last seen are exactly the
|
|
|
|
| 966 |
would switch the exclusion off precisely where it is needed. The cue
|
| 967 |
barely moves in those stretches anyway: it is standing still before the
|
| 968 |
shot, and it was lost, not teleported, after it.
|
| 969 |
+
|
| 970 |
+
The same argument stops applying INSIDE a long hole, and the clamp is
|
| 971 |
+
where it stops. A straight line between the two ends of a 0.9 s gap is
|
| 972 |
+
not a measurement of anything: it is a ball-shaped eraser dragged
|
| 973 |
+
across the cloth at whatever speed the gap demands. On shot_07 of the
|
| 974 |
+
demo (cue coverage 0.5772, two holes of exactly 0.9 s where the other
|
| 975 |
+
thirteen shots never exceed 0.2 s) that line swept within CUE_EXCL_M
|
| 976 |
+
of SIX still balls in turn -- the 2 at 4.1 mm, the 9 at 12.6 mm, the 4
|
| 977 |
+
at 34.9 mm -- switching their detections off one after another while
|
| 978 |
+
the cue was in fact nowhere near any of them. Clamping to the nearer
|
| 979 |
+
end IN TIME keeps every exclusion that rests on a real measurement
|
| 980 |
+
(the same shot's two stolen detections sit 3.4 mm from the end point
|
| 981 |
+
of the hole and stay excluded) and drops every one that rested on the
|
| 982 |
+
line. Short holes keep interpolating: over 0.2 s the cue cannot cross
|
| 983 |
+
the table, so the line is still a fair reading of where it was.
|
| 984 |
"""
|
| 985 |
if not pts:
|
| 986 |
return None
|
|
|
|
| 999 |
t1, x1, y1 = pts[hi]
|
| 1000 |
if t1 <= t0:
|
| 1001 |
return x0, y0
|
| 1002 |
+
if t1 - t0 > LOST_MAX_S:
|
| 1003 |
+
return (x0, y0) if t - t0 <= t1 - t else (x1, y1)
|
| 1004 |
u = (t - t0) / (t1 - t0)
|
| 1005 |
return x0 + u * (x1 - x0), y0 + u * (y1 - y0)
|
| 1006 |
|
|
|
|
| 1257 |
|
| 1258 |
``cue_rows``: the ``track_cue`` result of the SAME clip. Any candidate,
|
| 1259 |
in EITHER band, that lies within ``CUE_EXCL_M`` of where the cue ball
|
| 1260 |
+
is at that instant (``cue_at``: linear interpolation of that track, the
|
| 1261 |
+
nearest end point outside its range, and the nearer end of a hole
|
| 1262 |
+
longer than LOST_MAX_S rather than a line drawn across it) is struck
|
| 1263 |
+
off before matching. The cue is
|
| 1264 |
occasionally classified as a Solid, and an object track whose own ball
|
| 1265 |
has just disappeared into a pocket will happily adopt it: on shot_02
|
| 1266 |
that pulled ball 5 backwards out of the far corner pocket and into the
|
tests/test_broadcast_analytics.py
CHANGED
|
@@ -1297,6 +1297,69 @@ def test_cue_at_interpolates_between_points_and_holds_the_ends():
|
|
| 1297 |
assert bc.cue_at([], 1.0) is None # no track, no exclusion
|
| 1298 |
|
| 1299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
def test_a_detection_sitting_on_the_cue_cannot_continue_an_object_track():
|
| 1301 |
"""The shot_02 shape: the object ball disappears into a pocket, the cue
|
| 1302 |
arrives at that very spot a frame later and is classified Solid. Without
|
|
|
|
| 1297 |
assert bc.cue_at([], 1.0) is None # no track, no exclusion
|
| 1298 |
|
| 1299 |
|
| 1300 |
+
def test_cue_at_clamps_to_the_nearer_end_across_a_long_hole():
|
| 1301 |
+
"""A hole longer than LOST_MAX_S is NOT interpolated. On shot_07 the
|
| 1302 |
+
cue is lost for 0.9 s twice, and the line the old reading drew between
|
| 1303 |
+
the ends of a hole swept within CUE_EXCL_M of six still balls in turn
|
| 1304 |
+
-- the 2 at 4.1 mm -- while the cue was near none of them. Past the
|
| 1305 |
+
close-the-track threshold a straight line is not a measurement, so
|
| 1306 |
+
each half of the hole answers with the end it belongs to. Holes the
|
| 1307 |
+
cue cannot cross the table in keep interpolating."""
|
| 1308 |
+
long_hole = round(0.9 / DT) # 27 frames, exactly 0.9 s
|
| 1309 |
+
pts = bc.cue_positions(cue_rows_from(
|
| 1310 |
+
[(0.30, 0.40)] + [None] * (long_hole - 1) + [(1.00, 0.40)]))
|
| 1311 |
+
t0, t1 = pts[0][0], pts[-1][0]
|
| 1312 |
+
assert t1 - t0 > bc.LOST_MAX_S
|
| 1313 |
+
mid = (t0 + t1) / 2
|
| 1314 |
+
assert bc.cue_at(pts, t0 + DT) == (0.30, 0.40) # the near half...
|
| 1315 |
+
assert bc.cue_at(pts, mid - DT) == (0.30, 0.40)
|
| 1316 |
+
assert bc.cue_at(pts, mid + DT) == (1.00, 0.40) # ...and the far
|
| 1317 |
+
assert bc.cue_at(pts, t1 - DT) == (1.00, 0.40)
|
| 1318 |
+
# the midpoint of the LINE is exactly the answer that is gone
|
| 1319 |
+
assert bc.cue_at(pts, mid - DT) != pytest.approx((0.65, 0.40), abs=1e-3)
|
| 1320 |
+
|
| 1321 |
+
short = bc.cue_positions(cue_rows_from(
|
| 1322 |
+
[(0.30, 0.40)] + [None] * 5 + [(0.60, 0.40)])) # 0.2 s, as usual
|
| 1323 |
+
assert short[-1][0] - short[0][0] < bc.LOST_MAX_S
|
| 1324 |
+
assert bc.cue_at(short, (short[0][0] + short[-1][0]) / 2) == \
|
| 1325 |
+
pytest.approx((0.45, 0.40), abs=1e-9)
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
+
def test_a_long_cue_hole_excludes_at_its_ends_and_nowhere_between():
|
| 1329 |
+
"""The shot_07 shape end to end. The cue is measured, lost for 0.9 s,
|
| 1330 |
+
and measured again on the far side; an object ball rolls THROUGH the
|
| 1331 |
+
midpoint of the line the old reading drew, and the cue is then read as
|
| 1332 |
+
a Solid 3.4 mm from the far end of the hole -- the two detections that
|
| 1333 |
+
took ball 8's track on the real clip.
|
| 1334 |
+
|
| 1335 |
+
Both halves in one scene: the ball crossing the middle keeps every
|
| 1336 |
+
point (the cue was never there), and the detection at the END is still
|
| 1337 |
+
struck off (the cue really was there, measured)."""
|
| 1338 |
+
n, gone, steal_from = 45, 20, 25
|
| 1339 |
+
a, b = (0.30, 0.40), (1.00, 0.40)
|
| 1340 |
+
hole_end = round(0.9 / DT) # covered again at 0.9 s
|
| 1341 |
+
ball = straight(0.65, 0.04, 0.0, 0.8, n)
|
| 1342 |
+
path = [p if i <= gone else None for i, p in enumerate(ball)]
|
| 1343 |
+
meta, others, diffs, init = make_scene([path])
|
| 1344 |
+
ghost = (b[0] + 0.0034, b[1]) # the cue wearing a Solid
|
| 1345 |
+
for i in range(steal_from, n):
|
| 1346 |
+
others[f"{i:05d}"] = [{"cls": "Solid", "conf": 0.9,
|
| 1347 |
+
"table_x_m": ghost[0],
|
| 1348 |
+
"table_y_m": ghost[1]}]
|
| 1349 |
+
cue = [a] + [None] * (hole_end - 1) + [b] * (n - hole_end)
|
| 1350 |
+
|
| 1351 |
+
stolen = bc.track_others(meta, others, diffs, init)
|
| 1352 |
+
assert stolen[0]["track"][-1]["x_m"] == pytest.approx(ghost[0], abs=1e-3)
|
| 1353 |
+
|
| 1354 |
+
kept = bc.track_others(meta, others, diffs, init,
|
| 1355 |
+
cue_rows=cue_rows_from(cue))
|
| 1356 |
+
# the ball crosses the old line at t ~ 0.45 and loses NOTHING there
|
| 1357 |
+
assert len(kept[0]["track"]) == gone + 1
|
| 1358 |
+
assert kept[0]["track"][-1]["t_s"] == pytest.approx(round(gone * DT, 3))
|
| 1359 |
+
assert kept[0]["track"][-1]["y_m"] == pytest.approx(ball[gone][1],
|
| 1360 |
+
abs=1e-3)
|
| 1361 |
+
|
| 1362 |
+
|
| 1363 |
def test_a_detection_sitting_on_the_cue_cannot_continue_an_object_track():
|
| 1364 |
"""The shot_02 shape: the object ball disappears into a pocket, the cue
|
| 1365 |
arrives at that very spot a frame later and is classified Solid. Without
|