@trillboards/edge-sdk 0.2.89 → 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.
@@ -51,12 +51,44 @@ what is resident on that box right now.
51
51
 
52
52
  ```
53
53
  labels.py one committed ModelVocabulary per model (slots -> class names,
54
- logit width, measured RAM). ClassIndex is the UNION over them.
54
+ logit width, measured RAM, and the VariantContract naming what
55
+ its ONNX output MEANS). ClassIndex is the UNION over them.
56
+ decode.py the three output layouts and the arithmetic each needs, plus the
57
+ only IoU/NMS in the Python tree. NUMPY ONLY — no ORT, no Pillow.
55
58
  registry.py name -> session map, the class index, lazy load, LRU eviction
56
59
  under a RAM budget, and the merge. STDLIB ONLY.
57
60
  loader.py the poll loop + the real ORT session factory and probe frame.
58
61
  ```
59
62
 
63
+ ### Output layouts
64
+
65
+ Three, and **which one an artifact uses is DECLARED, never sniffed**. The
66
+ declaration is a `VariantContract` on the model's vocabulary — the box-side copy
67
+ of that artifact's `(model_id, format)` row in `model_platform_variants`
68
+ (`output_layout`, `box_units`, `box_scale_to_pixels`) — and `_read_graph_spec`
69
+ CHECKS the loaded graph against it and refuses on disagreement.
70
+
71
+ | layout | shape | needs NMS | preprocessing |
72
+ |---|---|---|---|
73
+ | `rfdetr_dets_labels` | `dets [1,Q,4]` + `labels [1,Q,C]` | no (set prediction) | ImageNet-normalised **stretch** |
74
+ | `anchor_4_plus_nc` | `[1, 4+nc, A]`, scores already sigmoid | **yes** | Ultralytics **letterbox**, 114-grey, /255 |
75
+ | `end2end_n_6` | `[1, N, 6]` = x1,y1,x2,y2,score,cls | no (already suppressed) | Ultralytics **letterbox**, 114-grey, /255 |
76
+
77
+ Sniffing is forbidden because `[1, 300, 6]` and `[1, 6, 8400]` are both rank-3
78
+ with a 6 in them: a reader that gets one backwards reads x1,y1,x2,y2,score,cls
79
+ as four box channels and two classes, with no exception and a plausible table.
80
+
81
+ **`box_units` is a property of the FORMAT, not of the model.** Ultralytics wraps
82
+ a `task=detect`, `end2end=False` TFLite export in `_NormalizeCoords`, dividing
83
+ the box channels by (W, H). Measured across all 17 committed LiteRT twins: 16 of
84
+ 17 have ONNX boxes in pixels 0..640 and LiteRT boxes in 0..1, agreeing to
85
+ 0.00037 px after multiplying by 640, with class scores agreeing to 5.5e-06.
86
+ `queue_supermarket`, the one `end2end_n_6` twin, is pixels on both sides. Every
87
+ other check passes on such a pair — same shape, same dtype, both load, both run
88
+ — so a decoder that assumes pixels draws every box 640x too small in silence.
89
+ The two preprocessors are likewise NOT interchangeable and `/health` reports
90
+ which one ran, next to `output_layout`.
91
+
60
92
  * **A class routes to the FIRST catalogue model that names it.** Adding a
61
93
  specialist can never move an existing class off the model it was scored on —
62
94
  `person` stays on `rfdetr_medium` even after a PPE model that also emits it.
@@ -181,12 +213,13 @@ python3 -m unittest discover -s . -p "test_*.py" -v
181
213
 
182
214
  Runs in `guards-fast`. `test_registry.py` covers the whole residency policy —
183
215
  routing, lazy load, eviction, and every refusal — with FAKE sessions, so it runs
184
- there too. **Everything except the post-processing cases needs
185
- nothing but the standard library** — those are the only ones that touch numpy,
186
- and they skip themselves with a stated reason when it is absent. That invariant
187
- is the durable claim; the counts (45 of 60 on 2026-08-24) are a snapshot and go
188
- stale the next time a case is added, so derive them by running it rather than
189
- reading them here.
216
+ there too. **The wire contract and the residency policy need nothing but the
217
+ standard library**; `test_decode.py` needs numpy and nothing else (`decode.py`
218
+ imports no ORT, deliberately, so the arithmetic is exercisable on any runner
219
+ that can `import numpy`), and `test_session.py` needs onnxruntime too because
220
+ `session.py` imports it. Each skips itself with a stated reason. That layering
221
+ is the durable claim; any count is a snapshot and goes stale the next time a
222
+ case is added, so derive it by running rather than reading it here.
190
223
 
191
224
  ```bash
192
225
  # The real client against the real service, over loopback.
@@ -218,3 +251,39 @@ uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillo
218
251
  Runs both pipelines on one image and fails on any disagreement. A documented
219
252
  manual run, not a CI step — it needs a checkpoint download and ~3 GB of
220
253
  dependencies.
254
+
255
+ `bench/verify_detector_decode.py` is the same control for the two YOLO layouts,
256
+ against `ultralytics` itself, in three arms that each hold one variable:
257
+
258
+ ```bash
259
+ uv venv /tmp/yolo-ref --python 3.11
260
+ uv pip install --python /tmp/yolo-ref/bin/python ultralytics onnxruntime pillow ai-edge-litert
261
+
262
+ # decode: SAME raw tensor, our decode vs ultralytics NMS + scale_boxes.
263
+ /tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm decode \
264
+ --models-dir /path/to/onnx --frames-dir /path/to/frames --tolerance-px 0.0
265
+
266
+ # pipeline: the whole service path vs YOLO(...).predict, resize kernel held constant.
267
+ /tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm pipeline \
268
+ --models-dir /path/to/onnx --frames-dir /path/to/frames --tolerance-px 0.001
269
+
270
+ # litert: the same weights through both runtimes, in PIXELS, per the DECLARED scale.
271
+ /tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm litert \
272
+ --models-dir /path/to/onnx --frames-dir /path/to/frames \
273
+ --litert-manifest /path/to/litert/MANIFEST.json --tolerance-px 0.01
274
+ ```
275
+
276
+ Two traps this bench cost, both of them the instrument rather than the decode,
277
+ and both now closed in the file:
278
+
279
+ * **The reference must be the ONNX, not the `.pt`** — Ultralytics letterboxes a
280
+ checkpoint to 640x480 while the graph is fixed 640x640, so the two arms
281
+ disagree by up to 0.25 confidence in both directions and it reads as a decode
282
+ bug.
283
+ * **Hold the resize kernel constant too.** Pillow's BILINEAR low-passes on
284
+ downscale, cv2's `INTER_LINEAR` does not, and on one 1200x900 frame that moved
285
+ a class from 0.18 to 0.51 — 20 of 378 presence decisions apart. The
286
+ `pipeline` arm therefore letterboxes ONCE and feeds both sides those bytes,
287
+ and it feeds the reference the pixels that came back OUT of the JPEG rather
288
+ than the canvas that went in, because JPEG at quality 100 is still lossy
289
+ enough to move a borderline box.
@@ -85,8 +85,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # noqa: E402
85
85
 
86
86
  from trillboards_detector.errors import ModelContractError # noqa: E402
87
87
  from trillboards_detector.labels import ( # noqa: E402
88
+ BOX_UNITS_PIXELS,
88
89
  BUILTIN_VOCABULARIES,
89
90
  ModelVocabulary,
91
+ VariantContract,
90
92
  normalize_class_name,
91
93
  )
92
94
  from trillboards_detector.registry import read_process_rss # noqa: E402
@@ -497,13 +499,24 @@ class ShippedRunner:
497
499
  """Inference through `OnnxDetector` — the class the connector actually runs."""
498
500
 
499
501
  path_kind = "shipped"
500
- preprocessing = "session.preprocess (imagenet-normalised stretch)"
501
502
  variant_format = "onnx"
502
- layout = "rfdetr_dets_labels"
503
503
 
504
504
  def __init__(self, model_path: Path, vocabulary: ModelVocabulary):
505
505
  self.detector = OnnxDetector(model_path, vocabulary)
506
506
  self.vocabulary = vocabulary
507
+ # READ OFF THE LOADED GRAPH, never a constant. These two feed the run
508
+ # file's `variant.output_layout` / `variant.end2end` and its
509
+ # `preprocessing`, and this class stopped being RF-DETR-only the moment
510
+ # the service learned the two Ultralytics layouts — a hardcoded
511
+ # `rfdetr_dets_labels` would publish a score card whose declared layout
512
+ # is wrong for every YOLO artifact, which is the exact defect the
513
+ # declaration exists to prevent.
514
+ self.layout = self.detector.spec.output_layout
515
+ self.preprocessing = (
516
+ "session.preprocess (imagenet-normalised stretch)"
517
+ if self.layout == "rfdetr_dets_labels"
518
+ else "session.preprocess_letterbox (ultralytics letterbox, 114-grey, /255)"
519
+ )
507
520
  self.width = self.detector.spec.width
508
521
  self.height = self.detector.spec.height
509
522
  self.providers = list(self.detector.spec.providers)
@@ -575,7 +588,9 @@ class Frame:
575
588
  self._image = None
576
589
 
577
590
 
578
- def synthesise_vocabulary(name: str, class_names: "dict[int, str]") -> ModelVocabulary:
591
+ def synthesise_vocabulary(
592
+ name: str, class_names: "dict[int, str]", output_layout: str
593
+ ) -> ModelVocabulary:
579
594
  """A ModelVocabulary over a model's OWN names, canonicalised the service's way.
580
595
 
581
596
  Synthesised rather than committed so the loadability check is a FAIR one: it
@@ -594,7 +609,35 @@ def synthesise_vocabulary(name: str, class_names: "dict[int, str]") -> ModelVoca
594
609
  continue
595
610
  slots[idx] = canonical
596
611
  return ModelVocabulary(
597
- model_name=name, slot_names=slots, logit_slots=width, approx_ram_bytes=1
612
+ model_name=name,
613
+ slot_names=slots,
614
+ logit_slots=width,
615
+ approx_ram_bytes=1,
616
+ # THE LAYOUT IS DERIVED FROM THE GRAPH, and it is the only half
617
+ # `_read_graph_spec` reads — so the probe declares what it just measured
618
+ # rather than a guess, and an artifact whose layout this build cannot name
619
+ # fails `VariantContract` here and is recorded as not loadable, with the
620
+ # reason, which is the right answer to "would the service load this?".
621
+ #
622
+ # THE UNITS ARE THE IDENTITY, AND THAT IS THE MEASUREMENT, not a claim
623
+ # about the artifact. They cannot be known here — `classify_box_units`
624
+ # needs a forward pass, which happens later and lands in the run file's
625
+ # `variant` block — and one caller of this function (`ShippedRunner`)
626
+ # DOES decode with them. `pixels`/1.0 is the only pair that scales
627
+ # nothing, so the graph's own box magnitudes reach `box_abs_max`
628
+ # untouched and the classifier sees what the model actually emitted.
629
+ # Declaring the other pair would multiply by imgsz the very quantity
630
+ # being measured, and the verdict would then be a function of the guess.
631
+ #
632
+ # This is right for ONNX specifically: measured across all 17 committed
633
+ # twins, every ONNX side is pixels and only the LiteRT sides are
634
+ # normalised — and `.tflite` never reaches here, it routes to
635
+ # `LitertRunner`. A normalised ONNX artifact would still be REPORTED
636
+ # correctly by `classify_box_units`, because the identity leaves its
637
+ # 0..1 boxes as 0..1; what it would lose is per-class detections on
638
+ # padded frames, which is the signal to read the units verdict before
639
+ # trusting that candidate's score.
640
+ onnx_variant=VariantContract(output_layout, BOX_UNITS_PIXELS, 1.0),
598
641
  )
599
642
 
600
643
 
@@ -613,6 +656,7 @@ def probe_loadability(model_path: Path, class_names: "dict[int, str]") -> dict:
613
656
  f"the detector service builds an onnxruntime InferenceSession and this is a "
614
657
  f"{model_path.suffix} artifact: {str(exc).splitlines()[0][:200]}"
615
658
  ),
659
+ "output_layout": None,
616
660
  }
617
661
 
618
662
  opts = ort.SessionOptions()
@@ -620,15 +664,28 @@ def probe_loadability(model_path: Path, class_names: "dict[int, str]") -> dict:
620
664
  session = ort.InferenceSession(
621
665
  str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
622
666
  )
667
+ # DERIVED ONCE AND RETURNED, because the only other way for a caller to learn
668
+ # it is to open a second session — and the caller that needs it sits between
669
+ # the RSS baseline and `rss_after_session`, where a transient session is
670
+ # exactly the contamination `introspect_in_subprocess` exists to avoid.
671
+ layout = classify_output_layout([list(o.shape) for o in session.get_outputs()])
623
672
  try:
624
- vocabulary = synthesise_vocabulary("probe-only", class_names)
673
+ vocabulary = synthesise_vocabulary("probe-only", class_names, layout)
625
674
  except ValueError as exc:
626
- return {"shipped_service_loadable": False, "refusal": f"vocabulary: {exc}"}
675
+ return {
676
+ "shipped_service_loadable": False,
677
+ "refusal": f"vocabulary: {exc}",
678
+ "output_layout": layout,
679
+ }
627
680
  try:
628
681
  _read_graph_spec(session, vocabulary)
629
682
  except ModelContractError as exc:
630
- return {"shipped_service_loadable": False, "refusal": str(exc)}
631
- return {"shipped_service_loadable": True, "refusal": None}
683
+ return {
684
+ "shipped_service_loadable": False,
685
+ "refusal": str(exc),
686
+ "output_layout": layout,
687
+ }
688
+ return {"shipped_service_loadable": True, "refusal": None, "output_layout": layout}
632
689
 
633
690
 
634
691
  def introspect_in_subprocess(model_path: Path) -> dict:
@@ -759,7 +816,10 @@ def main() -> int:
759
816
  elif model_path.suffix.lower() == ".tflite":
760
817
  runner = LitertRunner(model_path, declared_names)
761
818
  elif loadability["shipped_service_loadable"]:
762
- runner = ShippedRunner(model_path, synthesise_vocabulary(args.candidate, declared_names))
819
+ runner = ShippedRunner(
820
+ model_path,
821
+ synthesise_vocabulary(args.candidate, declared_names, loadability["output_layout"]),
822
+ )
763
823
  else:
764
824
  runner = HarnessYoloRunner(model_path, declared_names)
765
825