Spaces:
Sleeping
Sleeping
File size: 47,429 Bytes
9e5b26f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 | """Far-zone detection probe -- design doc 7_FarZone_Detection_BallID.md
section 3.1, step P of BRIEF 28/08/2026. Called by
``probe_farzone_det.bat``, which owns the environment.
WHY THIS RUN HAPPENS. Three of the errors Danh reported on the packaged
demo (`a1cd87e8`) share one root: the detector loses balls at the far end of
the table. The camera decoded from that demo's own homography (h = 2.363 m,
f = 2155 px) puts a ball at 37 px across on the near rail and 22 px on the
far rail of a 1920x1080 frame; YOLO at ``imgsz=640`` shrinks the frame 3x,
so a far ball is about 7 px. This script MEASURES that -- it does not fix
anything and it does not choose a configuration. The decision rule lives in
section 3.1 of the design doc and Cowork applies it after reading these
numbers.
WHAT IT MEASURES. Four detector configurations
full640 full frame, imgsz 640 (the shipped baseline)
full960 full frame, imgsz 960
full1280 full frame, imgsz 1280
band full frame at 640 PLUS a second pass over the far half of
the table cropped at NATIVE resolution, the two merged and
de-duplicated with ``BALL_DEDUP_M``
over five target situations taken from the demo bundle, all at
``conf=0.05`` (the floor of design section 3.2, so a detection weaker than
the operating point still shows up as a number instead of vanishing):
T1 shot_02 frame 0 -- ball 5, wedged in the mouth of the FAR
RIGHT corner pocket, missing from
``balls_init`` in the demo
T2 shot_05 t in [2.30, 2.80] -- the cue ball against the far cushion,
the 0.5 s hole in the demo's cue track
T3 shot_03 t in [1.30, 1.70] -- the object ball running the last 23.7 cm
into the far right pocket, where the demo's
track dies
T4 clips 02 and 05, every frame -- detections per frame in the far band
(y_m > 1.9): the BACKGROUND number the three
targets have to be read against
T5 shot_09 + shot_10 frame 0 -- failure mode 2: the rack seen down its
own axis, balls hiding behind balls. Not a
far-zone case (the rack sits at y ~ 0.52-0.67,
balls ~35 px); it is here to measure the
CEILING of what NMS can return from a pile,
by sweeping ``iou`` {0.5, 0.7, 0.9} x
``imgsz`` {640, 1280}
For every target detection the confidence is logged TWICE: once over all
detections and once over only those that survive the ``IN_TOL_M`` table
bounds gate. That is the whole point of the split -- a ball in the mouth of
a pocket projects to the edge of the table, and "the gate ate it" had to be
ruled out as an explanation before the resolution one is believed.
HOW IT STAYS HONEST.
- It runs the SHIPPED chain, not a copy: the clips are cut by
``cv_worker.cut_clip`` with the same ffmpeg command and the same
boundaries the demo used, and the box centres are mapped by the same
height-compensated homography (``cv_worker.comp_homography_cls``) that the
analyze job builds. A REPRODUCTION CHECK confirms it: full640 re-run at
the operating confidence rebuilds ``balls_init`` of shot_02 exactly as the
demo bundle carries it, ball 5 missing included. If that check fails,
nothing below can be trusted, so it is reported first.
- The ball-5 ground truth is a MEASURED pixel position, not a detection:
(1181.5, 280.0) is the centroid of the purple pixels of that ball on
frame 0 (OpenCV hue 120-160, S >= 60, V >= 60 over the window
x 1120-1250, y 240-330), whose visible bounding box is 28 x 22 px --
the 22 px the design doc predicts for the far rail. Through the
compensated homography that lands at table (1.145, 2.475), which is
INSIDE the table and therefore not a candidate for being cut by the
bounds gate.
- The COST column comes from its own benchmark, not from the measurement
passes. The passes run over half an hour on a machine doing other things,
and their averages are incoherent as a ratio (960 measured cheaper than
640 on one clip). ``cost_benchmark`` decodes one set of frames, warms
every configuration at its own ``imgsz``, times them back to back and
keeps the best of three. Decision rule (c) reads that column; the
contaminated per-clip averages are kept beside it as a witness.
- A detection PAST the far rail projects to a table y of 4 m and more, which
is precisely what the ``IN_TOL_M`` gate exists to throw away. Distances to
a pocket are therefore measured from the last IN-BOUNDS detection, with
the out-of-bounds one reported separately rather than silently mixed in.
- The archive is read-only. ``D:\\Khoa luan`` is fingerprinted (every file's
size and mtime) before and after the run and the two are compared; a
single changed byte is a red gate.
Output in ``D:\\poolcoach\\out_probe_farzone_20260828\\``: ``probe_farzone.json``
(every number, machine readable) and ``probe_farzone.md`` (the tables a
human reads). The cut clips stay in ``clips\\`` of that same directory --
outside git, on the same machine as the archive VOD they come from.
"""
from __future__ import annotations
import importlib.util
import json
import math
import os
import statistics
import sys
import time
from pathlib import Path
import numpy as np
APP_ROOT = Path(os.environ.get("POOLCOACH_APP_ROOT",
r"D:\poolcoach\poolcoach-app"))
VOD = Path(os.environ["POOLCOACH_PROBE_VOD"])
OUT = Path(os.environ["POOLCOACH_PROBE_OUT"])
BUNDLE = Path(os.environ.get(
"POOLCOACH_PROBE_BUNDLE",
APP_ROOT / "app" / "demo_analyzer" / "a1cd87e813de422a868ae8eedf9c4969"))
DEVICE = os.environ.get("POOLCOACH_CV_DEVICE", "cpu")
ARCHIVE_ROOT = Path(os.environ.get("POOLCOACH_ARCHIVE_ROOT", r"D:\Khoa luan"))
# The 4 corner pockets of run A2b (14/08) and every regeneration since, in
# the order the front-end asks for them -- the same two inputs (VOD +
# corners) the demo bundle was produced from.
CORNERS = [[527.5, 869.7], [1380.1, 864.0], [1232.3, 278.5], [673.0, 272.8]]
for _p in (APP_ROOT / "src", APP_ROOT):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
# --------------------------------------------------------------- constants
DET_CONF_FLOOR = 0.05 # design section 3.2: the floor every configuration
# predicts at here, so a detection below the
# operating point is a NUMBER instead of a silence
NMS_IOU = 0.7 # the ultralytics default, stated explicitly so the
# rack sweep has a named centre to sweep around
FAR_Y_M = 1.9 # "the far band" for the background count (T4) --
# design section 3.1
# T1 -- ball 5 on shot_02 frame 0. A MEASURED pixel centroid (see the module
# docstring), not a detection, so a configuration that misses the ball still
# has something to be measured against.
GT_BALL5_PX = (1181.5, 280.0)
TARGET_MATCH_M = 0.06 # ~2R: a detection this close to the ground truth
# IS that ball. Two balls cannot both be inside it
# (centre to centre at contact is 2R = 57 mm)
# T2 -- the cue against the far cushion. The demo's cue track jumps
# 2.300 -> 2.800 with the ball at y ~ 2.28-2.31 and the cushion at y ~ 2.51.
CUE_WINDOW_S = (2.30, 2.80)
CUE_MIN_Y_M = 2.00 # in this window the cue is the ONLY thing above
# y = 2.0 (shot_05 starts with the 8 at y = 0.91
# and the 1 at y = 0.58)
# T3 -- shot_03's object ball over the last 23.7 cm into the far right
# pocket. The demo's track ends at t = 1.300, (1.217, 2.309), still moving
# at ~1.35 m/s, so the ball reaches the pocket around t = 1.47.
S03_WINDOW_S = (1.30, 1.70)
S03_MIN_Y_M = 2.25
S03_MIN_X_M = 1.00 # the corridor into the far right pocket, wide
# enough for the position noise and narrow enough
# that no other ball of that shot can enter it
POCKET_FAR_RIGHT = (1.27, 2.54) # (TABLE_W_M, TABLE_L_M) -- the pocket
# shot_03's ball is running at, and the one ball 5
# of shot_02 is wedged in
# The far-band second pass of the "band" configuration.
BAND_Y0_M = 1.20 # crop the table from just short of half way to the
# far rail -- slightly more than "the far half" so
# a ball straddling y = 1.27 is whole in the crop
BAND_MARGIN_PX = 40 # pixel margin around the projected region, so a
# ball ON the far rail is not cut in half
BAND_STRIDE = 32 # YOLO's stride: the band's imgsz is rounded UP to
# a multiple of it, which keeps the crop at native
# scale or larger -- never downscaled, since not
# downscaling is the entire point of the pass
# T5 -- the rack.
RACK_R_M = 0.15 # design section 3.4 ``RACK_R_M``: the radius
# around the cluster centre that counts as "in the
# rack". A 9-ball diamond spans ~2 ball diameters
# from centre to tip (~0.12 m), so 0.15 holds the
# whole rack and nothing else of those two frames
RACK_IOU = (0.5, 0.7, 0.9)
RACK_IMGSZ = (640, 1280)
CONFIGS = [
{"name": "full640", "imgsz": 640, "band": False},
{"name": "full960", "imgsz": 960, "band": False},
{"name": "full1280", "imgsz": 1280, "band": False},
{"name": "band", "imgsz": 640, "band": True},
]
CLIPS_NEEDED = (2, 3, 5, 9, 10)
# ----------------------------------------------------------------- helpers
def load_worker():
"""``scripts/cv_worker.py`` as a module, loaded BY PATH -- the same way
``app/localcv.py`` loads it, because ``scripts/`` is not a package. Only
four things are used from it (``cut_clip``, ``clip_frame_wh``,
``comp_homography_cls``, ``OP_CONF``); importing rather than copying is
what makes this probe measure the SHIPPED chain."""
name = "poolcoach_cv_worker"
if name in sys.modules:
return sys.modules[name]
path = APP_ROOT / "scripts" / "cv_worker.py"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ImportError(f"could not load {path}")
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
def fingerprint(root: Path) -> dict:
"""Every file under ``root`` as name -> (size, mtime_ns). The archive is
READ ONLY: this is taken before and after the run and compared, so
"nothing was written to D:\\Khoa luan" is a measurement rather than a
promise."""
out = {}
for p in sorted(root.rglob("*")):
try:
st = p.stat()
except OSError:
continue
if p.is_file():
out[str(p.relative_to(root))] = [st.st_size, st.st_mtime_ns]
return out
def cut_clips(cw, shots: list[dict]) -> dict[int, Path]:
"""The per-shot clips of the five target shots, cut by the worker's own
``cut_clip`` from the boundaries the demo manifest carries -- the same
ffmpeg command, the same seek, the same re-encode, so the frames the
probe reads are the frames the analyze job read."""
clips_dir = OUT / "clips"
clips_dir.mkdir(parents=True, exist_ok=True)
out: dict[int, Path] = {}
for s in shots:
idx = int(s["idx"])
if idx not in CLIPS_NEEDED:
continue
dst = clips_dir / f"shot_{idx:02d}.mp4"
t0 = float(s["t_start_s"])
dur = float(s["t_end_s"]) - t0
err = cw.cut_clip(VOD, t0, dur, dst)
if err is not None:
raise RuntimeError(f"shot {idx}: {err}")
out[idx] = dst
print(f"[probe] cut shot_{idx:02d} t0={t0:.3f} dur={dur:.3f} "
f"-> {dst.name} ({dst.stat().st_size / 1e6:.1f} MB)",
flush=True)
return out
def homography_for(cw, clip_path: Path):
"""The clip's height-compensated homography plus its ``height_comp``
metadata -- rebuilt exactly as ``analyze_once`` builds it (the
decomposition is deterministic, so the same clip gives the same map)."""
from poolcoach_cv import broadcast as bc
from poolcoach_cv.homography import orient_corners
sink: dict = {}
cls = cw.comp_homography_cls(cw.clip_frame_wh(clip_path), bc.BALL_R_M,
sink)
th = cls(orient_corners(np.asarray(CORNERS, dtype=np.float64)),
table_w=bc.TABLE_W_M, table_l=bc.TABLE_L_M)
return th, dict(sink)
def band_window(th, frame_w: int, frame_h: int) -> dict:
"""The pixel window of the far half of the table, plus the ``imgsz`` the
band pass runs at: the crop's longest side rounded UP to a multiple of
the stride, so ultralytics never SHRINKS the crop."""
from poolcoach_cv import broadcast as bc
w, ln = bc.TABLE_W_M, bc.TABLE_L_M
pts = [th.table_to_px(p) for p in
((0.0, BAND_Y0_M), (w, BAND_Y0_M), (w, ln), (0.0, ln))]
xs = [float(p[0]) for p in pts]
ys = [float(p[1]) for p in pts]
x0 = max(0, int(math.floor(min(xs))) - BAND_MARGIN_PX)
x1 = min(frame_w, int(math.ceil(max(xs))) + BAND_MARGIN_PX)
y0 = max(0, int(math.floor(min(ys))) - BAND_MARGIN_PX)
y1 = min(frame_h, int(math.ceil(max(ys))) + BAND_MARGIN_PX)
side = max(x1 - x0, y1 - y0)
imgsz = int(math.ceil(side / BAND_STRIDE) * BAND_STRIDE)
return {"box": [x0, y0, x1, y1], "w": x1 - x0, "h": y1 - y0,
"imgsz": imgsz, "scale": round(imgsz / max(side, 1), 3)}
def boxes_to_dets(res, th, offset=(0, 0), source="full"):
"""One ultralytics result -> the detections this probe records.
``in_tol`` is the ``IN_TOL_M`` table bounds gate of ``analyze_clip``:
kept as a FLAG instead of a filter, which is what lets the report show
the confidence before and after the gate in separate columns.
"""
from poolcoach_cv import broadcast as bc
ox, oy = offset
out = []
names_map = res.names
for b in res.boxes:
cls = names_map[int(b.cls)]
if cls not in bc.BALL_CLASSES:
continue
bx1, by1, bx2, by2 = (float(v) for v in b.xyxy[0])
bx1, bx2 = bx1 + ox, bx2 + ox
by1, by2 = by1 + oy, by2 + oy
cx, cy = (bx1 + bx2) / 2.0, (by1 + by2) / 2.0
tx, ty = th.px_to_table((cx, cy))
in_tol = (-bc.IN_TOL_M <= tx <= bc.TABLE_W_M + bc.IN_TOL_M
and -bc.IN_TOL_M <= ty <= bc.TABLE_L_M + bc.IN_TOL_M)
# conf is kept at FULL precision: the reproduction check rounds it to
# 3 exactly once, the way ``balls_init`` does. Rounding here first
# and again there turns 0.89649 into 0.897 where the pipeline says
# 0.896, and the check would fail on arithmetic instead of on optics.
out.append({"cls": cls, "conf": float(b.conf),
"x_m": round(float(tx), 4), "y_m": round(float(ty), 4),
"px": [round(cx, 1), round(cy, 1)],
"w_px": round(bx2 - bx1, 1), "h_px": round(by2 - by1, 1),
"in_tol": bool(in_tol), "source": source})
return out
def merge_band(main: list[dict], band: list[dict]) -> list[dict]:
"""Main-pass detections plus the band ones that are not already there --
``BALL_DEDUP_M`` on the TABLE plane, the same rule ``balls_init`` uses
for double detections."""
from poolcoach_cv import broadcast as bc
out = list(main)
for d in band:
if any(math.hypot(d["x_m"] - k["x_m"], d["y_m"] - k["y_m"])
< bc.BALL_DEDUP_M for k in out):
continue
out.append(d)
return out
def run_config(cw, clip_path: Path, model, cfg: dict, conf: float = None
) -> dict:
"""One clip x one configuration -> per-frame detections plus timings.
Decoding follows ``analyze_clip``: real container PTS, an ``img_diff``
column over the table region, batches of ``DETECT_BATCH``. Duplicate
frames are FLAGGED (``find_dup_frames``), not dropped, so a rate can be
reported both ways -- the pipeline's derivatives only ever see the real
frames.
"""
import cv2
from poolcoach_cv import broadcast as bc
conf = DET_CONF_FLOOR if conf is None else conf
th, comp = homography_for(cw, clip_path)
ci = th.corners_px.astype(int)
x0, x1 = ci[:, 0].min(), ci[:, 0].max()
y0, y1 = ci[:, 1].min(), ci[:, 1].max()
cap = cv2.VideoCapture(str(clip_path))
if not cap.isOpened():
raise RuntimeError(f"cannot open {clip_path}")
frames: list[dict] = []
names: list[str] = []
diffs: list[float] = []
t_predict = 0.0
t_band = 0.0
n_band_batches = 0
band = None
batch_imgs: list = []
batch_idx: list[int] = []
prev_crop = None
def flush():
nonlocal t_predict, t_band, n_band_batches
if not batch_imgs:
return
t = time.perf_counter()
results = model.predict(batch_imgs, conf=conf, imgsz=cfg["imgsz"],
iou=NMS_IOU, verbose=False, device=DEVICE)
t_predict += time.perf_counter() - t
for i, res in zip(batch_idx, results):
frames[i]["dets"] = boxes_to_dets(res, th)
if cfg["band"]:
bx0, by0, bx1, by1 = band["box"]
# .copy(): a numpy VIEW of a frame is not contiguous, and the
# pre-transform of ultralytics wants a real array
crops = [im[by0:by1, bx0:bx1].copy() for im in batch_imgs]
t = time.perf_counter()
bres = model.predict(crops, conf=conf, imgsz=band["imgsz"],
iou=NMS_IOU, verbose=False, device=DEVICE)
t_band += time.perf_counter() - t
n_band_batches += 1
for i, res in zip(batch_idx, bres):
frames[i]["dets"] = merge_band(
frames[i]["dets"],
boxes_to_dets(res, th, offset=(bx0, by0), source="band"))
batch_imgs.clear()
batch_idx.clear()
try:
while True:
if not cap.grab():
break
t_s = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
ok, frame = cap.retrieve()
if not ok:
break
if band is None and cfg["band"]:
band = band_window(th, frame.shape[1], frame.shape[0])
name = f"{len(frames):05d}"
crop = frame[max(0, y0):max(1, y1), max(0, x0):max(1, x1)]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.int16)
diffs.append(float(np.abs(gray - prev_crop).mean())
if prev_crop is not None else -1.0)
prev_crop = gray
names.append(name)
frames.append({"name": name, "t_s": round(t_s, 3), "dets": []})
batch_imgs.append(frame)
batch_idx.append(len(frames) - 1)
if len(batch_imgs) >= bc.DETECT_BATCH:
flush()
flush()
finally:
cap.release()
dups = bc.find_dup_frames(names, diffs)
for fr, d in zip(frames, diffs):
fr["img_diff"] = round(d, 4)
fr["dup"] = fr["name"] in dups
n = max(len(frames), 1)
return {"frames": frames, "n_frames": len(frames), "n_dup": len(dups),
"band": band, "height_comp": comp,
"ms_per_frame": round(1000.0 * (t_predict + t_band) / n, 1),
"ms_per_frame_main": round(1000.0 * t_predict / n, 1),
"ms_per_frame_band": round(1000.0 * t_band / n, 1)}
# ----------------------------------------------------------------- metrics
def _r4(v):
"""Round for the report, never for the arithmetic -- confidences are
carried at full precision everywhere else (see ``boxes_to_dets``)."""
return None if v is None else round(float(v), 4)
def _confs(dets: list[dict]) -> dict:
"""min / median / max of a list of detections, or nulls when empty."""
if not dets:
return {"n": 0, "min": None, "med": None, "max": None}
cs = sorted(d["conf"] for d in dets)
return {"n": len(cs), "min": round(cs[0], 4),
"med": round(statistics.median(cs), 4), "max": round(cs[-1], 4)}
def target_t1(run: dict, th) -> dict:
"""T1 -- ball 5 on shot_02 frame 0: the detections within
``TARGET_MATCH_M`` of the measured ground truth, split by the bounds
gate."""
gx, gy = th.px_to_table(GT_BALL5_PX)
fr = run["frames"][0]
hits = [d for d in fr["dets"]
if math.hypot(d["x_m"] - gx, d["y_m"] - gy) <= TARGET_MATCH_M]
inside = [d for d in hits if d["in_tol"]]
best = max(hits, key=lambda d: d["conf"], default=None)
best_in = max(inside, key=lambda d: d["conf"], default=None)
return {"gt_px": list(GT_BALL5_PX), "gt_table": [round(float(gx), 4),
round(float(gy), 4)],
"n_hits": len(hits), "n_hits_in_tol": len(inside),
"best_conf": round(best["conf"], 4) if best else None,
"best_conf_in_tol": round(best_in["conf"], 4) if best_in else None,
"best_det": best, "hits": hits,
"n_dets_frame": len(fr["dets"])}
def _window(run: dict, lo: float, hi: float, real_only: bool = False):
return [f for f in run["frames"] if lo <= f["t_s"] <= hi
and not (real_only and f["dup"])]
def target_t2(run: dict) -> dict:
"""T2 -- the cue against the far cushion of shot_05: the fraction of
frames in the window carrying a Cue detection above y = 2.0, over all
frames and over the REAL (non-duplicate) ones."""
out = {}
for label, real in (("all_frames", False), ("real_frames", True)):
win = _window(run, *CUE_WINDOW_S, real_only=real)
seen, seen_in, confs, confs_in = 0, 0, [], []
for f in win:
hits = [d for d in f["dets"]
if d["cls"] == "Cue" and d["y_m"] >= CUE_MIN_Y_M]
inside = [d for d in hits if d["in_tol"]]
if hits:
seen += 1
confs.append(max(d["conf"] for d in hits))
if inside:
seen_in += 1
confs_in.append(max(d["conf"] for d in inside))
n = len(win)
out[label] = {
"n_frames": n, "n_seen": seen, "n_seen_in_tol": seen_in,
"frac_seen": round(seen / n, 3) if n else None,
"frac_seen_in_tol": round(seen_in / n, 3) if n else None,
"conf": _confs([{"conf": c} for c in confs]),
"conf_in_tol": _confs([{"conf": c} for c in confs_in])}
win = _window(run, *CUE_WINDOW_S)
out["per_frame"] = [
{"t_s": f["t_s"], "dup": f["dup"],
"best": _r4(max((d["conf"] for d in f["dets"]
if d["cls"] == "Cue" and d["y_m"] >= CUE_MIN_Y_M),
default=None)),
"best_in_tol": _r4(max((d["conf"] for d in f["dets"]
if d["cls"] == "Cue"
and d["y_m"] >= CUE_MIN_Y_M and d["in_tol"]),
default=None))}
for f in win]
return out
def target_t3(run: dict) -> dict:
"""T3 -- shot_03's object ball over the last stretch into the far right
pocket: which frames of the window still carry it, and how close to the
pocket the last one sits."""
win = _window(run, *S03_WINDOW_S)
rows, last, last_any = [], None, None
for f in win:
hits = [d for d in f["dets"]
if d["cls"] != "Cue" and d["y_m"] >= S03_MIN_Y_M
and d["x_m"] >= S03_MIN_X_M]
best = max(hits, key=lambda d: d["conf"], default=None)
best_in = max([d for d in hits if d["in_tol"]],
key=lambda d: d["conf"], default=None)
if best is not None:
last_any = best
if best_in is not None:
last = best_in
rows.append({"t_s": f["t_s"], "dup": f["dup"],
"best": _r4(best["conf"]) if best else None,
"best_in_tol": _r4(best_in["conf"]) if best_in else None,
"xy": [best["x_m"], best["y_m"]] if best else None,
"in_tol": bool(best_in) if best else None})
seen = [r for r in rows if r["best"] is not None]
seen_in = [r for r in rows if r["best_in_tol"] is not None]
# The distance to the pocket is measured from the last IN-BOUNDS point.
# A box past the far rail projects to a table y of 4 m and more (the
# bounds gate is exactly what the pipeline uses to throw those away), so
# taking it as "the last point" would report a gap of 1.7 m for a ball
# that in fact reached the pocket. Both are reported; only the in-bounds
# one is a distance.
gap = (round(math.hypot(last["x_m"] - POCKET_FAR_RIGHT[0],
last["y_m"] - POCKET_FAR_RIGHT[1]), 4)
if last else None)
return {"n_frames": len(rows), "n_seen": len(seen),
"n_seen_in_tol": len(seen_in),
"frac_seen": round(len(seen) / len(rows), 3) if rows else None,
"frac_seen_in_tol": (round(len(seen_in) / len(rows), 3)
if rows else None),
"last_xy": [last["x_m"], last["y_m"]] if last else None,
"last_xy_any": ([last_any["x_m"], last_any["y_m"]]
if last_any else None),
"last_gap_to_pocket_m": gap, "per_frame": rows}
def target_t4(run: dict) -> dict:
"""T4 -- the background: detections per frame above ``FAR_Y_M``, at the
floor and at the operating point."""
op = OP_CONF
per, per_op = [], []
for f in run["frames"]:
far = [d for d in f["dets"] if d["y_m"] > FAR_Y_M and d["in_tol"]]
per.append(len(far))
per_op.append(sum(1 for d in far if d["conf"] >= op))
n = max(len(per), 1)
return {"n_frames": len(per),
"per_frame_mean": round(sum(per) / n, 3),
"per_frame_mean_op": round(sum(per_op) / n, 3),
"max": max(per) if per else 0,
"n_frames_zero": sum(1 for v in per if v == 0),
"series": per, "series_op": per_op}
def rack_centre(shot: dict) -> tuple[float, float]:
"""The centre of the rack of one demo shot -- the mean of the non-cue
entries of its ``balls_init``. Read from the bundle rather than written
down here, so the number cannot drift away from the demo."""
pts = [(b["x_m"], b["y_m"]) for b in shot.get("balls_init") or []
if b.get("type") != "cue"]
return (sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts))
def rack_sweep(cw, clip_path: Path, model, centre) -> list[dict]:
"""T5 -- ``imgsz`` x ``iou`` over the FIRST frame only: how many balls of
the pile come back, raw and after the ``BALL_DEDUP_M`` merge."""
import cv2
from poolcoach_cv import broadcast as bc
th, _ = homography_for(cw, clip_path)
cap = cv2.VideoCapture(str(clip_path))
try:
ok, frame = cap.read()
finally:
cap.release()
if not ok:
raise RuntimeError(f"cannot read the first frame of {clip_path}")
rows = []
for imgsz in RACK_IMGSZ:
for iou in RACK_IOU:
t = time.perf_counter()
res = model.predict([frame], conf=DET_CONF_FLOOR, imgsz=imgsz,
iou=iou, verbose=False, device=DEVICE)[0]
ms = round(1000.0 * (time.perf_counter() - t), 1)
dets = [d for d in boxes_to_dets(res, th)
if d["in_tol"] and d["cls"] != "Cue"
and math.hypot(d["x_m"] - centre[0],
d["y_m"] - centre[1]) <= RACK_R_M]
dets.sort(key=lambda d: -d["conf"])
merged: list[dict] = []
for d in dets:
if any(math.hypot(d["x_m"] - k["x_m"], d["y_m"] - k["y_m"])
< bc.BALL_DEDUP_M for k in merged):
continue
merged.append(d)
rows.append({"imgsz": imgsz, "iou": iou, "ms": ms,
"n_raw": len(dets), "n_dedup": len(merged),
"n_dedup_op": sum(1 for d in merged
if d["conf"] >= OP_CONF),
"confs": [_r4(d["conf"]) for d in merged],
"dets": merged})
return rows
BENCH_FRAMES = 64 # frames of shot_02 the cost benchmark runs on
BENCH_REPEATS = 3 # ... repeated this often, MINIMUM reported
def cost_benchmark(cw, clip_path: Path, model) -> dict:
"""CPU milliseconds per frame of each configuration, measured on its own.
The per-clip averages of the measurement passes are contaminated: they
are collected over half an hour on a machine that is doing other things,
and the RATIO between configurations -- which is the whole of decision
rule (c) in section 3.1 -- came out incoherent that way (960 measured
cheaper than 640 on one clip and 1.6x dearer on another). This is the
honest version: one set of frames decoded once, every configuration
warmed at its own ``imgsz``, all of them timed back to back, and the
MINIMUM of the repeats reported -- the minimum is the pass least
disturbed by whatever else the machine was doing.
"""
import cv2
from poolcoach_cv import broadcast as bc
th, _ = homography_for(cw, clip_path)
cap = cv2.VideoCapture(str(clip_path))
frames = []
try:
while len(frames) < BENCH_FRAMES:
ok, frame = cap.read()
if not ok:
break
frames.append(frame)
finally:
cap.release()
band = band_window(th, frames[0].shape[1], frames[0].shape[0])
batches = [frames[i:i + bc.DETECT_BATCH]
for i in range(0, len(frames), bc.DETECT_BATCH)]
def one_pass(cfg: dict) -> tuple[float, float]:
t_main = t_band = 0.0
for batch in batches:
t = time.perf_counter()
model.predict(batch, conf=DET_CONF_FLOOR, imgsz=cfg["imgsz"],
iou=NMS_IOU, verbose=False, device=DEVICE)
t_main += time.perf_counter() - t
if not cfg["band"]:
continue
bx0, by0, bx1, by1 = band["box"]
crops = [im[by0:by1, bx0:bx1].copy() for im in batch]
t = time.perf_counter()
model.predict(crops, conf=DET_CONF_FLOOR, imgsz=band["imgsz"],
iou=NMS_IOU, verbose=False, device=DEVICE)
t_band += time.perf_counter() - t
n = len(frames)
return 1000.0 * t_main / n, 1000.0 * t_band / n
out: dict[str, dict] = {}
for cfg in CONFIGS:
one_pass(cfg) # warm at this imgsz
runs = [one_pass(cfg) for _ in range(BENCH_REPEATS)]
best = min(runs, key=lambda r: r[0] + r[1])
out[cfg["name"]] = {
"n_frames": len(frames), "repeats": BENCH_REPEATS,
"ms_main": round(best[0], 1), "ms_band": round(best[1], 1),
"ms_per_frame": round(best[0] + best[1], 1),
"all_repeats": [round(a + b, 1) for a, b in runs]}
print(f"[probe] cost {cfg['name']:<10} "
f"{out[cfg['name']]['ms_per_frame']:>7.1f} ms/frame "
f"(repeats {out[cfg['name']]['all_repeats']})", flush=True)
return out
def reproduction_check(cw, clip_path: Path, model, demo_shot: dict) -> dict:
"""full640 at the OPERATING confidence on shot_02 frame 0 -> the
``balls_init`` the pipeline would build -> compared with the one the demo
bundle carries.
This is the probe's own negative control on itself: if the clip, the
homography and the height compensation are the ones the demo used, the
five entries come back to the millimetre -- ball 5 missing included. A
mismatch means every number below was measured on a different image than
the demo, so it is reported FIRST.
"""
from poolcoach_cv import broadcast as bc
run = run_config(cw, clip_path, model,
{"name": "full640", "imgsz": 640, "band": False},
conf=OP_CONF)
dets = sorted(run["frames"][0]["dets"], key=lambda d: -d["conf"])
got: list[dict] = []
for d in dets:
if not d["in_tol"]:
continue
if any(math.hypot(d["x_m"] - k["x_m"], d["y_m"] - k["y_m"])
< bc.BALL_DEDUP_M for k in got):
continue
got.append({"x_m": round(d["x_m"], 4), "y_m": round(d["y_m"], 4),
"type": "cue" if d["cls"] == "Cue" else "ball",
"conf": round(d["conf"], 3)})
want = [{"x_m": b["x_m"], "y_m": b["y_m"], "type": b["type"],
"conf": b["conf"]} for b in demo_shot.get("balls_init") or []]
return {"ok": got == want, "n_demo": len(want), "n_probe": len(got),
"demo": want, "probe": got,
"ms_per_frame": run["ms_per_frame"]}
# ------------------------------------------------------------------ report
def md_report(rep: dict) -> str:
"""The tables a human reads. Everything here also lives in the JSON --
this is a view, never a second source of numbers."""
L: list[str] = []
add = L.append
add("# Far-zone detection probe -- 28/08/2026")
add("")
add(f"Design doc `7_FarZone_Detection_BallID.md` section 3.1, step P of "
f"BRIEF 28/08. VOD `{rep['vod']}`, corners `{rep['corners']}`, "
f"device `{rep['device']}`, weights `{rep['weights']}`, "
f"`conf={DET_CONF_FLOOR}` everywhere, `OP_CONF={rep['op_conf']}`.")
add("")
add("**This file MEASURES. It chooses nothing** -- the decision rule is "
"section 3.1 of the design doc and it is Cowork's to apply.")
add("")
rc = rep["reproduction_check"]
add("## 0. Reproduction check -- is this the same image the demo saw?")
add("")
add(f"full640 re-run at the operating confidence on shot_02 frame 0, "
f"`balls_init` rebuilt the way `analyze_clip` builds it, compared "
f"with bundle `{rep['bundle']}`: "
f"**{'MATCH' if rc['ok'] else 'MISMATCH'}** "
f"({rc['n_probe']} entries against {rc['n_demo']}).")
add("")
def _entry(b) -> str:
if not b:
return "-"
return "{:.4f}, {:.4f} ({})".format(b["x_m"], b["y_m"], b["type"])
add("| # | demo x,y (m) | demo conf | probe x,y (m) | probe conf |")
add("|---|---|---|---|---|")
for i in range(max(len(rc["demo"]), len(rc["probe"]))):
d = rc["demo"][i] if i < len(rc["demo"]) else None
p = rc["probe"][i] if i < len(rc["probe"]) else None
add("| {} | {} | {} | {} | {} |".format(
i, _entry(d), d["conf"] if d else "-",
_entry(p), p["conf"] if p else "-"))
add("")
add("## 1. T1 -- shot_02 frame 0, ball 5 in the mouth of the far right "
"pocket")
add("")
t1 = rep["targets"]["T1"]
gt = t1["by_config"][CONFIGS[0]["name"]]
add(f"Ground truth: pixel `{gt['gt_px']}` (measured purple centroid, "
f"visible bbox 28 x 22 px) -> table `{gt['gt_table']}`, match "
f"radius {TARGET_MATCH_M} m.")
add("")
add("| config | dets on frame | hits | best conf (all) | hits in_tol | "
"best conf (in_tol) | >= OP_CONF | bbox w x h px |")
add("|---|---|---|---|---|---|---|---|")
for cfg in CONFIGS:
r = t1["by_config"][cfg["name"]]
b = r["best_det"]
bbox = "{} x {}".format(b["w_px"], b["h_px"]) if b else "-"
reach = "YES" if (r["best_conf_in_tol"] or 0) >= rep["op_conf"] \
else "no"
add("| {} | {} | {} | {} | {} | {} | {} | {} |".format(
cfg["name"], r["n_dets_frame"], r["n_hits"],
r["best_conf"] if r["best_conf"] is not None else "-",
r["n_hits_in_tol"],
r["best_conf_in_tol"] if r["best_conf_in_tol"] is not None
else "-", reach, bbox))
add("")
add("## 2. T2 -- shot_05, the cue against the far cushion, "
f"t in [{CUE_WINDOW_S[0]}, {CUE_WINDOW_S[1]}]")
add("")
add("| config | frames | seen | frac | frac (real frames) | conf min | "
"conf med | conf max | frac in_tol |")
add("|---|---|---|---|---|---|---|---|---|")
for cfg in CONFIGS:
r = rep["targets"]["T2"]["by_config"][cfg["name"]]
a, real = r["all_frames"], r["real_frames"]
c = a["conf"]
add(f"| {cfg['name']} | {a['n_frames']} | {a['n_seen']} | "
f"{a['frac_seen']} | {real['frac_seen']} | "
f"{c['min'] if c['min'] is not None else '-'} | "
f"{c['med'] if c['med'] is not None else '-'} | "
f"{c['max'] if c['max'] is not None else '-'} | "
f"{a['frac_seen_in_tol']} |")
add("")
add("## 3. T3 -- shot_03, the last run into the far right pocket, "
f"t in [{S03_WINDOW_S[0]}, {S03_WINDOW_S[1]}]")
add("")
add("(the demo's track dies at t = 1.300, 0.2375 m short of the pocket)")
add("")
add("| config | frames | seen (all) | seen (in_tol) | frac (in_tol) | "
"last in-bounds x,y | gap to pocket (m) | last x,y incl. out of "
"bounds |")
add("|---|---|---|---|---|---|---|---|")
for cfg in CONFIGS:
r = rep["targets"]["T3"]["by_config"][cfg["name"]]
add("| {} | {} | {} | {} | {} | {} | {} | {} |".format(
cfg["name"], r["n_frames"], r["n_seen"], r["n_seen_in_tol"],
r["frac_seen_in_tol"], r["last_xy"] or "-",
r["last_gap_to_pocket_m"]
if r["last_gap_to_pocket_m"] is not None else "-",
r["last_xy_any"] or "-"))
add("")
add(f"## 4. T4 -- background, detections per frame with y_m > {FAR_Y_M}")
add("")
add("| config | clip | frames | dets/frame (conf >= 0.05) | "
"dets/frame (conf >= OP_CONF) | max | frames with none |")
add("|---|---|---|---|---|---|---|")
for cfg in CONFIGS:
for clip in ("shot_02", "shot_05"):
r = rep["targets"]["T4"]["by_config"][cfg["name"]][clip]
add(f"| {cfg['name']} | {clip} | {r['n_frames']} | "
f"{r['per_frame_mean']} | {r['per_frame_mean_op']} | "
f"{r['max']} | {r['n_frames_zero']} |")
add("")
add("## 5. T5 -- the rack (failure mode 2), shot_09 and shot_10 frame 0")
add("")
add(f"Balls counted inside {RACK_R_M} m of the cluster centre of the "
f"demo's own `balls_init`. `conf={DET_CONF_FLOOR}`. The demo bundle "
f"itself has "
f"{rep['targets']['T5']['shot_09']['n_demo']}/9 and "
f"{rep['targets']['T5']['shot_10']['n_demo']}/9.")
add("")
add("| clip | centre x,y | imgsz | iou | raw | after dedup | "
">= OP_CONF | ms | confs |")
add("|---|---|---|---|---|---|---|---|---|")
for clip in ("shot_09", "shot_10"):
blk = rep["targets"]["T5"][clip]
for row in blk["sweep"]:
add(f"| {clip} | "
f"{blk['centre'][0]:.3f}, {blk['centre'][1]:.3f} | "
f"{row['imgsz']} | {row['iou']} | {row['n_raw']} | "
f"{row['n_dedup']} | {row['n_dedup_op']} | {row['ms']} | "
f"{row['confs']} |")
add("")
add("## 6. Cost -- CPU milliseconds per frame")
add("")
add(f"The `benchmark` column is the number decision rule (c) is read "
f"from: {BENCH_FRAMES} frames of shot_02, every configuration warmed "
f"at its own imgsz and then timed back to back, best of "
f"{BENCH_REPEATS}. The two per-clip columns are the averages of the "
f"measurement passes themselves -- they cover half an hour on a "
f"machine doing other things and are kept only as a witness, not as "
f"a ratio.")
add("")
add("| config | benchmark ms/frame | main | band | x baseline 640 | "
"pass shot_02 | pass shot_05 |")
add("|---|---|---|---|---|---|---|")
base = rep["cost"]["full640"]["ms_per_frame"]
for cfg in CONFIGS:
c = rep["cost"][cfg["name"]]
r = rep["runtime"][cfg["name"]]
add("| {} | {} | {} | {} | {} | {} | {} |".format(
cfg["name"], c["ms_per_frame"], c["ms_main"], c["ms_band"],
round(c["ms_per_frame"] / base, 2), r["shot_02"], r["shot_05"]))
add("")
b = rep["band"]
add(f"The band pass crops `{b['box']}` ({b['w']} x {b['h']} px) and runs "
f"at `imgsz={b['imgsz']}` (scale {b['scale']} -- at or above native, "
f"never a downscale).")
add("")
add("## 7. Archive integrity")
add("")
a = rep["archive"]
add(f"`{a['root']}`: {a['n_files']} files fingerprinted (size + mtime) "
f"before and after -- **{a['n_changed']} changed**.")
add("")
return "\n".join(L) + "\n"
# -------------------------------------------------------------------- main
OP_CONF = 0.2993 # replaced at runtime by cv_worker.OP_CONF -- declared
# here only so the metric helpers can be read top down
def main() -> int:
global OP_CONF
OUT.mkdir(parents=True, exist_ok=True)
print(f"[probe] app root : {APP_ROOT}")
print(f"[probe] vod : {VOD} ({VOD.stat().st_size / 1e6:.1f} MB)")
print(f"[probe] bundle : {BUNDLE}")
print(f"[probe] out : {OUT}", flush=True)
print(f"[probe] fingerprinting the archive {ARCHIVE_ROOT} ...",
flush=True)
fp_before = fingerprint(ARCHIVE_ROOT)
print(f"[probe] {len(fp_before)} files", flush=True)
cw = load_worker()
OP_CONF = float(cw.OP_CONF)
from poolcoach_cv import broadcast as bc # noqa: F401 -- warm
manifest = json.loads((BUNDLE / "shots.json").read_text(encoding="utf-8"))
clips = cut_clips(cw, manifest["shots"])
weights = cw.resolve_weights()
print(f"[probe] loading {weights} ...", flush=True)
from ultralytics import YOLO
model = YOLO(str(weights))
model.predict(np.zeros((640, 640, 3), dtype=np.uint8),
conf=DET_CONF_FLOOR, imgsz=640, verbose=False,
device=DEVICE)
print("[probe] model ready", flush=True)
demo02 = json.loads((BUNDLE / "shot_02.json").read_text(encoding="utf-8"))
print("[probe] reproduction check on shot_02 frame 0 ...", flush=True)
rc = reproduction_check(cw, clips[2], model, demo02)
print(f"[probe] reproduction check: "
f"{'MATCH' if rc['ok'] else 'MISMATCH'} "
f"({rc['n_probe']} vs {rc['n_demo']} balls_init entries)",
flush=True)
runs: dict[str, dict] = {}
for cfg in CONFIGS:
for idx in (2, 3, 5):
key = f"{cfg['name']}/shot_{idx:02d}"
t = time.perf_counter()
runs[key] = run_config(cw, clips[idx], model, cfg)
print(f"[probe] {key:<26} {runs[key]['n_frames']:>4} frames "
f"({runs[key]['n_dup']} dup) "
f"{runs[key]['ms_per_frame']:>7.1f} ms/frame "
f"in {time.perf_counter() - t:.1f}s", flush=True)
th02, comp02 = homography_for(cw, clips[2])
targets = {
"T1": {"by_config": {c["name"]: target_t1(runs[f"{c['name']}/shot_02"],
th02) for c in CONFIGS}},
"T2": {"by_config": {c["name"]: target_t2(runs[f"{c['name']}/shot_05"])
for c in CONFIGS}},
"T3": {"by_config": {c["name"]: target_t3(runs[f"{c['name']}/shot_03"])
for c in CONFIGS}},
"T4": {"by_config": {c["name"]: {
"shot_02": target_t4(runs[f"{c['name']}/shot_02"]),
"shot_05": target_t4(runs[f"{c['name']}/shot_05"])}
for c in CONFIGS}},
"T5": {},
}
for idx in (9, 10):
shot = json.loads((BUNDLE / f"shot_{idx:02d}.json")
.read_text(encoding="utf-8"))
centre = rack_centre(shot)
sweep = rack_sweep(cw, clips[idx], model, centre)
targets["T5"][f"shot_{idx:02d}"] = {
"centre": [round(centre[0], 4), round(centre[1], 4)],
"n_demo": sum(1 for b in shot.get("balls_init") or []
if b.get("type") != "cue"),
"sweep": sweep}
best = max(r["n_dedup"] for r in sweep)
print(f"[probe] rack shot_{idx:02d}: demo "
f"{targets['T5'][f'shot_{idx:02d}']['n_demo']}/9, probe best "
f"{best}/9 over the sweep", flush=True)
runtime = {}
for cfg in CONFIGS:
a = runs[f"{cfg['name']}/shot_02"]["ms_per_frame"]
b = runs[f"{cfg['name']}/shot_05"]["ms_per_frame"]
runtime[cfg["name"]] = {"shot_02": a, "shot_05": b,
"mean": round((a + b) / 2, 1),
"main": runs[f"{cfg['name']}/shot_02"]
["ms_per_frame_main"],
"band": runs[f"{cfg['name']}/shot_02"]
["ms_per_frame_band"]}
print("[probe] cost benchmark (isolated, back to back) ...", flush=True)
cost = cost_benchmark(cw, clips[2], model)
fp_after = fingerprint(ARCHIVE_ROOT)
changed = sorted(k for k in set(fp_before) | set(fp_after)
if fp_before.get(k) != fp_after.get(k))
report = {
"date": "2026-08-28",
"design_doc": "7_FarZone_Detection_BallID.md section 3.1",
"vod": str(VOD),
"corners": CORNERS,
"bundle": BUNDLE.name,
"weights": str(weights),
"device": DEVICE,
"op_conf": OP_CONF,
"det_conf_floor": DET_CONF_FLOOR,
"nms_iou": NMS_IOU,
"height_comp": comp02,
"band": runs["band/shot_02"]["band"],
"reproduction_check": rc,
"configs": CONFIGS,
"targets": targets,
"cost": cost,
"runtime": runtime,
"archive": {"root": str(ARCHIVE_ROOT), "n_files": len(fp_before),
"n_changed": len(changed), "changed": changed[:20]},
"runs": {k: {"n_frames": v["n_frames"], "n_dup": v["n_dup"],
"ms_per_frame": v["ms_per_frame"]}
for k, v in runs.items()},
}
(OUT / "probe_farzone.json").write_text(
json.dumps(report, indent=1), encoding="utf-8")
(OUT / "probe_farzone.md").write_text(md_report(report), encoding="utf-8")
print(f"[probe] wrote {OUT / 'probe_farzone.json'}")
print(f"[probe] wrote {OUT / 'probe_farzone.md'}")
if changed:
print(f"[probe] RED: {len(changed)} file(s) under {ARCHIVE_ROOT} "
f"changed -- {changed[:5]}")
return 7
print(f"[probe] archive untouched ({len(fp_before)} files identical)")
if not rc["ok"]:
print("[probe] RED: the reproduction check failed -- the probe did "
"NOT measure the same image the demo did.")
return 8
return 0
if __name__ == "__main__":
raise SystemExit(main())
|