masterdanh commited on
Commit
ff7db2d
·
1 Parent(s): 69cc9f9

fix(analyzer): clamp cue position across long track gaps

Browse files

A 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, and the nearest end point outside the range
955
- the track covers.
 
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 nearest end
1239
- point outside its range) is struck off before matching. The cue is
 
 
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