@trillboards/edge-sdk 0.2.85 → 0.2.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """verify_yolo_decode — is `run_candidate.py`'s YOLO decode the same instrument
3
+ Ultralytics is?
4
+
5
+ This is `verify_against_reference.py` one level up. That file checks the RF-DETR
6
+ ONNX export against the `rfdetr` package; this one checks the harness's OWN
7
+ decode of an Ultralytics (1, 4+C, 8400) head against `ultralytics` itself, on the
8
+ same pixels. It exists because §5 rule 5 of `cctv-detector-tier.md` is that every
9
+ wrong number in that day's work came from the instrument rather than the
10
+ reasoning, and the YOLO decode is the newest, least-exercised code in the scoring
11
+ path. A decode that transposes an axis, or applies a sigmoid twice, or reads the
12
+ box channels as class scores, produces a complete and entirely plausible table.
13
+
14
+ WHAT IS COMPARED, AND WHY NOT BOXES. The harness scores PRESENCE — the maximum
15
+ confidence per class per frame — because there is no box-level ground truth
16
+ committed anywhere in this repo. So the agreement that matters is on that
17
+ quantity, not on box coordinates the harness never reads. Comparing something the
18
+ scorer does not use would prove the export is fine and leave the actual scoring
19
+ path unchecked.
20
+
21
+ THE REFERENCE IS THE SAME ONNX FILE, LOADED BY ULTRALYTICS — not the `.pt` it
22
+ was exported from, and that choice cost a wrong run to find. Ultralytics'
23
+ predictor letterboxes with `auto = same_shapes and args.rect and format == "pt"`,
24
+ so a `.pt` reference on our 640x480 frames pads to **640x480** while the ONNX
25
+ graph has a fixed **640x640** input. The two arms then disagree by up to 0.25
26
+ confidence in BOTH directions and it looks like a decode bug. It is §5 rule 1
27
+ again: an arm at one imgsz compared against an arm at another is not a
28
+ comparison. Loading the ONNX makes `format != "pt"`, so `auto=False` and both
29
+ sides letterbox to the graph's own input size; `rect=False` is passed as well so
30
+ the geometry does not depend on that default. Pass `--reference <best.pt>`
31
+ explicitly to compare against the checkpoint, and expect it to disagree for this
32
+ reason rather than for a real one.
33
+
34
+ That leaves exactly one variable — our preprocessing and decode against theirs,
35
+ on identical bytes and identical geometry — which is what this file is for.
36
+
37
+ TOLERANCE. Pillow BILINEAR and Ultralytics' cv2 INTER_LINEAR agree closely and
38
+ not bit-for-bit, so a small confidence tolerance is expected and a large one is a
39
+ decode bug. The DECISION tolerance is zero: at the threshold, both sides must
40
+ agree on whether the class fires, on every frame.
41
+
42
+ python3 bench/verify_yolo_decode.py --onnx best.onnx \
43
+ --frames-dir /tmp/eval134 --limit 20 [--threshold 0.25] [--conf-tol 0.05]
44
+
45
+ Needs `ultralytics` — a documented manual run, never CI.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import sys
52
+ from pathlib import Path
53
+
54
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # noqa: E402
55
+
56
+ from run_candidate import HarnessYoloRunner, Frame, introspect_in_subprocess # noqa: E402
57
+
58
+
59
+ def main() -> int:
60
+ parser = argparse.ArgumentParser()
61
+ parser.add_argument("--onnx", required=True)
62
+ parser.add_argument(
63
+ "--reference", default=None,
64
+ help="what ultralytics should load as the reference. Defaults to --onnx, which is the "
65
+ "only setting that holds input geometry constant; see the file header.",
66
+ )
67
+ parser.add_argument("--frames-dir", required=True)
68
+ parser.add_argument("--limit", type=int, default=20)
69
+ parser.add_argument("--threshold", type=float, default=0.25)
70
+ parser.add_argument("--conf-tol", type=float, default=0.05)
71
+ args = parser.parse_args()
72
+
73
+ from ultralytics import YOLO # noqa: PLC0415 - heavy, and only this file needs it
74
+
75
+ introspection = introspect_in_subprocess(Path(args.onnx))
76
+ names = {int(k): v for k, v in introspection["names"].items()}
77
+ ours = HarnessYoloRunner(Path(args.onnx), names)
78
+ reference = YOLO(args.reference or args.onnx)
79
+
80
+ frame_paths = sorted(Path(args.frames_dir).glob("*.jpg"))[: args.limit]
81
+ if not frame_paths:
82
+ raise SystemExit(f"no frames in {args.frames_dir}")
83
+
84
+ problems: "list[str]" = []
85
+ decisions = 0
86
+ for path in frame_paths:
87
+ frame = Frame(path)
88
+ # `HarnessYoloRunner.run` returns (per_class, infer_ms, box_max); the
89
+ # third arrived with the box-units verdict and this call still unpacked
90
+ # two, so this file could not execute at all.
91
+ mine, _, _ = ours.run(frame, 0.0)
92
+ # imgsz is pinned to the graph's own input size and verbose is off; a
93
+ # default imgsz here would compare an arm at 640 against one at 1280,
94
+ # which §5 rule 1 says is not a comparison.
95
+ result = reference.predict(
96
+ str(path), imgsz=ours.width, conf=0.001, rect=False, verbose=False, device="cpu"
97
+ )[0]
98
+ theirs: "dict[str, float]" = {}
99
+ for cls, conf in zip(result.boxes.cls.tolist(), result.boxes.conf.tolist()):
100
+ name = names[int(cls)]
101
+ theirs[name] = max(theirs.get(name, 0.0), float(conf))
102
+
103
+ for name in names.values():
104
+ a = mine.get(name, [0.0])[0] if name in mine else 0.0
105
+ b = theirs.get(name, 0.0)
106
+ decisions += 1
107
+ if (a >= args.threshold) != (b >= args.threshold):
108
+ problems.append(
109
+ f"{path.name} {name}: DECISION differs at {args.threshold} — ours {a:.4f}, reference {b:.4f}"
110
+ )
111
+ elif abs(a - b) > args.conf_tol and max(a, b) >= args.threshold:
112
+ problems.append(
113
+ f"{path.name} {name}: confidence {a:.4f} vs {b:.4f} (tol {args.conf_tol})"
114
+ )
115
+
116
+ print(
117
+ f"reference {Path(args.reference or args.onnx).name} frames {len(frame_paths)} "
118
+ f"classes {len(names)} imgsz {ours.width}x{ours.height} presence decisions compared {decisions}"
119
+ )
120
+ if problems:
121
+ print("DISAGREEMENT — the decode in run_candidate.py is not the reference instrument:")
122
+ for p in problems[:40]:
123
+ print(f" - {p}")
124
+ if len(problems) > 40:
125
+ print(f" ... and {len(problems) - 40} more")
126
+ return 1
127
+ print(
128
+ f"AGREE: every presence decision matches at threshold {args.threshold}, and every "
129
+ f"firing confidence is within {args.conf_tol}."
130
+ )
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ raise SystemExit(main())