@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.
@@ -39,6 +39,14 @@ import numpy as np
39
39
  import onnxruntime as ort
40
40
  from PIL import Image
41
41
 
42
+ from .decode import (
43
+ ANCHOR_4_PLUS_NC,
44
+ END2END_N_6,
45
+ PREPROCESSING_BY_LAYOUT,
46
+ RFDETR_DETS_LABELS,
47
+ YOLO_DECODERS,
48
+ LetterboxGeometry,
49
+ )
42
50
  from .detections import Detection, DetectionBatch
43
51
  from .errors import ModelContractError
44
52
  from .labels import ModelVocabulary
@@ -54,13 +62,37 @@ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1
54
62
  NUM_SELECT = 300
55
63
 
56
64
 
65
+ #: Ultralytics' letterbox fill. `LetterBox.__init__(..., fill=114)` — a mid-grey
66
+ #: the model saw in training as padding and not as content.
67
+ LETTERBOX_FILL = (114, 114, 114)
68
+
69
+
57
70
  @dataclass
58
71
  class GraphSpec:
59
- """What the loaded artifact actually is, read off the graph, never assumed."""
72
+ """What the loaded artifact actually is, CHECKED against what it declared.
73
+
74
+ "Read off the graph, never assumed" was the whole rule while one layout
75
+ shipped. It cannot stay the whole rule now: `[1, 300, 6]` and `[1, 6, 8400]`
76
+ are both rank-3 tensors with a 6 in them, so a reader that decides from the
77
+ shape gets one of them backwards and reads x1,y1,x2,y2,score,cls as four box
78
+ channels and two classes — with no exception, no NaN and a plausible table.
79
+ So the LAYOUT is DECLARED by the artifact's variant contract and the graph is
80
+ checked against it here; everything else (resolution, output names, the class
81
+ axis) is still read off the graph and still refused on disagreement.
82
+ """
60
83
 
61
84
  input_name: str
62
- box_output: str
63
- logit_output: str
85
+ #: Exactly the outputs `detect()` asks ORT for, in the order the decode wants
86
+ #: them. Two names for RF-DETR (`dets`, `labels`), one for either YOLO head.
87
+ output_names: "tuple[str, ...]"
88
+ #: The DECLARED layout this graph was checked against, republished here so
89
+ #: `/health` can say which decode is running. A number nobody can attribute
90
+ #: to a decode is the `agent_version` failure one runbook over.
91
+ output_layout: str
92
+ #: Which preprocessing this layout's artifact was exported with. Reported for
93
+ #: the same reason: the two are not interchangeable and the difference is
94
+ #: invisible to every shape check.
95
+ preprocessing: str
64
96
  height: int
65
97
  width: int
66
98
  num_queries: int
@@ -89,6 +121,79 @@ def preprocess(jpeg: bytes, width: int, height: int) -> tuple[np.ndarray, int, i
89
121
  return chw[None, ...], src_w, src_h
90
122
 
91
123
 
124
+ def preprocess_letterbox(jpeg: bytes, width: int, height: int):
125
+ """JPEG bytes -> NCHW float32 the Ultralytics way, plus the mapping used.
126
+
127
+ A DIFFERENT TRANSFORM FROM `preprocess`, NOT A TIDIER ONE. RF-DETR STRETCHES
128
+ to a square and normalises by ImageNet statistics; an Ultralytics export is
129
+ scaled to FIT, padded 114-grey and divided by 255 with no mean or standard
130
+ deviation at all. Using either one on the other's artifact changes the aspect
131
+ of every object in the frame or shifts every input channel by ~2 standard
132
+ deviations, and neither failure raises anything: the tensor is the right
133
+ shape, the scores are still in (0,1), and the table still looks like
134
+ detections. Which one ran is on `/health` as `preprocessing` for exactly that
135
+ reason.
136
+
137
+ Transcribed from `ultralytics/data/augment.py::LetterBox.__call__`:
138
+
139
+ gain `r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])`
140
+ resize `new_unpad = round(shape[1] * r), round(shape[0] * r)`
141
+ pad centred, `fill=114`
142
+
143
+ IT MATCHES `bench/run_candidate.py::letterbox` BYTE FOR BYTE, and that is the
144
+ load-bearing property rather than matching Ultralytics' cv2 exactly. The
145
+ harness is what SCORES a candidate, so the service has to reproduce the arm
146
+ the score was measured on — the same argument this module's header makes for
147
+ transcribing `rfdetr`'s own resize. Measured on 2026-08-25: Pillow's BILINEAR
148
+ low-passes on downscale and cv2's INTER_LINEAR does not, and on a 1200x900
149
+ frame that moved one class's confidence from 0.18 to 0.51. So the two are NOT
150
+ interchangeable, and a future move to cv2 semantics is a re-score of every
151
+ published card, never a preprocessing tidy-up.
152
+
153
+ Returns the geometry rather than recomputing it at decode time: a box is
154
+ un-mapped with the same gain and padding that put the pixels there.
155
+ """
156
+ with Image.open(io.BytesIO(jpeg)) as im:
157
+ rgb = im.convert("RGB")
158
+ src_w, src_h = rgb.size
159
+ gain = min(width / src_w, height / src_h)
160
+ new_w, new_h = int(round(src_w * gain)), int(round(src_h * gain))
161
+ resized = rgb.resize((new_w, new_h), resample=Image.BILINEAR)
162
+ canvas = Image.new("RGB", (width, height), LETTERBOX_FILL)
163
+ # `(W - new_w) // 2` is Ultralytics' `round(dw - 0.1)` for every input:
164
+ # dw is either an integer or a half-integer, and both forms floor it.
165
+ # `ops.scale_boxes` recomputes the second form, which is why decoding
166
+ # against it agrees to 0.0 px — pinned by `test_decode.py`.
167
+ pad_x, pad_y = (width - new_w) // 2, (height - new_h) // 2
168
+ canvas.paste(resized, (pad_x, pad_y))
169
+ arr = np.asarray(canvas, dtype=np.float32)
170
+
171
+ blob = (arr / np.float32(255.0)).transpose(2, 0, 1)[None, ...]
172
+ return blob, LetterboxGeometry(gain, pad_x, pad_y, src_w, src_h)
173
+
174
+
175
+ def preprocess_for_layout(layout: str, jpeg: bytes, width: int, height: int):
176
+ """Pick the transform this artifact was exported with. NOT a formatting step.
177
+
178
+ ITS OWN FUNCTION SO IT CAN BE TESTED WITHOUT A MODEL. Inlined in
179
+ `OnnxDetector.detect` it was reachable only through a real ORT session, which
180
+ means only on a machine holding a 128 MiB artifact — and a mutation test
181
+ proved the gap: swapping the two preprocessors left all 178 unit tests green
182
+ and was caught only by the bench. A dispatch nothing can test is a dispatch
183
+ that will one day be wrong in the field with every surface green, which is
184
+ the failure this whole service is built against.
185
+
186
+ Returns `(blob, geometry, frame_w, frame_h)`. `geometry` is None for the
187
+ stretch path, where there is no padding to undo and boxes come back
188
+ normalised; the YOLO decodes need it and `postprocess` does not.
189
+ """
190
+ if layout == RFDETR_DETS_LABELS:
191
+ blob, frame_w, frame_h = preprocess(jpeg, width, height)
192
+ return blob, None, frame_w, frame_h
193
+ blob, geometry = preprocess_letterbox(jpeg, width, height)
194
+ return blob, geometry, geometry.frame_w, geometry.frame_h
195
+
196
+
92
197
  def postprocess(
93
198
  boxes_cxcywh: np.ndarray,
94
199
  logits: np.ndarray,
@@ -270,25 +375,50 @@ class OnnxDetector:
270
375
  min_confidence: float,
271
376
  wanted: "set[str] | None" = None,
272
377
  ) -> DetectionBatch:
378
+ """One frame in, this model's detections out, in the frame's own pixels.
379
+
380
+ THE ARM IS PICKED ONCE, AT LOAD, FROM THE DECLARATION — never per frame
381
+ and never from the tensor. `self.spec.output_layout` was checked against
382
+ the graph by `_read_graph_spec`, so by the time a frame arrives there is
383
+ no decision left to get wrong.
384
+ """
273
385
  started = time.perf_counter()
274
- blob, frame_w, frame_h = preprocess(jpeg, self.spec.width, self.spec.height)
386
+ layout = self.spec.output_layout
387
+ blob, geometry, frame_w, frame_h = preprocess_for_layout(
388
+ layout, jpeg, self.spec.width, self.spec.height
389
+ )
275
390
 
276
391
  with self._lock:
277
392
  infer_started = time.perf_counter()
278
- outputs = self._session.run(
279
- [self.spec.box_output, self.spec.logit_output], {self.spec.input_name: blob}
280
- )
393
+ outputs = self._session.run(list(self.spec.output_names), {self.spec.input_name: blob})
281
394
  infer_ms = (time.perf_counter() - infer_started) * 1000.0
282
395
 
283
- batch = postprocess(
284
- outputs[0],
285
- outputs[1],
286
- frame_w,
287
- frame_h,
288
- min_confidence,
289
- wanted,
290
- vocabulary=self.vocabulary,
291
- )
396
+ if layout == RFDETR_DETS_LABELS:
397
+ batch = postprocess(
398
+ outputs[0],
399
+ outputs[1],
400
+ frame_w,
401
+ frame_h,
402
+ min_confidence,
403
+ wanted,
404
+ vocabulary=self.vocabulary,
405
+ )
406
+ else:
407
+ contract = self.vocabulary.onnx_variant
408
+ scores, class_idx, x0, y0, x1, y1 = YOLO_DECODERS[layout](
409
+ outputs[0],
410
+ geometry,
411
+ min_confidence,
412
+ box_scale_to_pixels=contract.box_scale_to_pixels,
413
+ # The tensor ORT RETURNED, checked against the vocabulary — not
414
+ # the shape the graph DECLARED, which `_read_graph_spec` already
415
+ # checked at load. They are the same claim only while no axis is
416
+ # dynamic, and the artifact library is not ours to promise that
417
+ # about.
418
+ expected_classes=self.vocabulary.logit_slots,
419
+ )
420
+ batch = _to_wire(scores, class_idx, x0, y0, x1, y1, wanted, self.vocabulary)
421
+
292
422
  batch.inference_ms = infer_ms
293
423
  batch.total_ms = (time.perf_counter() - started) * 1000.0
294
424
  return batch
@@ -337,6 +467,47 @@ def _read_graph_spec(
337
467
  )
338
468
 
339
469
  outputs = session.get_outputs()
470
+ layout = vocabulary.onnx_variant.output_layout
471
+ if layout == RFDETR_DETS_LABELS:
472
+ output_names, num_queries, num_classes = _check_rfdetr_outputs(outputs, vocabulary)
473
+ elif layout == ANCHOR_4_PLUS_NC:
474
+ output_names, num_queries, num_classes = _check_anchor_outputs(outputs, vocabulary)
475
+ elif layout == END2END_N_6:
476
+ output_names, num_queries, num_classes = _check_end2end_outputs(outputs, vocabulary)
477
+ else: # pragma: no cover - `VariantContract` refuses an unknown layout first
478
+ raise ModelContractError(
479
+ f"{vocabulary.model_name} declares output layout {layout!r}, which this build "
480
+ "cannot decode. Refusing rather than returning an empty frame."
481
+ )
482
+
483
+ return GraphSpec(
484
+ input_name=inputs[0].name,
485
+ output_names=output_names,
486
+ output_layout=layout,
487
+ preprocessing=PREPROCESSING_BY_LAYOUT[layout],
488
+ height=int(height),
489
+ width=int(width),
490
+ num_queries=num_queries,
491
+ num_classes=num_classes,
492
+ providers=list(session.get_providers()),
493
+ )
494
+
495
+
496
+ def _sole_output(outputs, layout: str):
497
+ """The single output a YOLO graph must have, or a refusal naming both.
498
+
499
+ A second output on a graph declared single is not a graph we can decode by
500
+ taking the first one: whichever tensor `output0` happens to be, the other one
501
+ is a fact about this artifact nobody wrote down.
502
+ """
503
+ if len(outputs) != 1:
504
+ raise ModelContractError(
505
+ f"{layout} expects exactly one graph output, found {[o.name for o in outputs]}"
506
+ )
507
+ return outputs[0]
508
+
509
+
510
+ def _check_rfdetr_outputs(outputs, vocabulary: ModelVocabulary):
340
511
  by_name = {o.name: o for o in outputs}
341
512
  if "dets" not in by_name or "labels" not in by_name:
342
513
  raise ModelContractError(
@@ -356,17 +527,50 @@ def _read_graph_spec(
356
527
  f"{vocabulary.model_name} describes {vocabulary.logit_slots}. Refusing rather "
357
528
  "than labelling boxes by an index that vocabulary does not name — see labels.py."
358
529
  )
530
+ queries = int(logit_shape[1]) if isinstance(logit_shape[1], int) else -1
531
+ return ("dets", "labels"), queries, int(num_classes)
359
532
 
360
- return GraphSpec(
361
- input_name=inputs[0].name,
362
- box_output="dets",
363
- logit_output="labels",
364
- height=int(height),
365
- width=int(width),
366
- num_queries=int(logit_shape[1]) if isinstance(logit_shape[1], int) else -1,
367
- num_classes=int(num_classes),
368
- providers=list(session.get_providers()),
369
- )
533
+
534
+ def _check_anchor_outputs(outputs, vocabulary: ModelVocabulary):
535
+ """`[1, 4+nc, A]` — and the class axis is the whole check.
536
+
537
+ The 4 box channels come FIRST and the class scores after them, so an axis of
538
+ the wrong width does not fail, it SHIFTS: every class name lands on its
539
+ neighbour's scores and every box keeps a channel that belonged to a class.
540
+ `run_candidate.py` refuses the same mismatch in the same words — "refusing
541
+ rather than labelling a channel by position".
542
+ """
543
+ output = _sole_output(outputs, ANCHOR_4_PLUS_NC)
544
+ shape = output.shape
545
+ if len(shape) != 3:
546
+ raise ModelContractError(f"{ANCHOR_4_PLUS_NC} is not (1, 4+nc, anchors): {shape}")
547
+ expected = 4 + vocabulary.logit_slots
548
+ declared = shape[1]
549
+ if isinstance(declared, int) and declared != expected:
550
+ raise ModelContractError(
551
+ f"output axis is {declared} wide; 4 box channels + {vocabulary.logit_slots} named "
552
+ f"classes needs {expected}. Refusing rather than labelling a channel by position."
553
+ )
554
+ anchors = int(shape[2]) if isinstance(shape[2], int) else -1
555
+ return (output.name,), anchors, int(vocabulary.logit_slots)
556
+
557
+
558
+ def _check_end2end_outputs(outputs, vocabulary: ModelVocabulary):
559
+ """`[1, N, 6]` — the six is the contract and the N is not.
560
+
561
+ The class index rides IN a column here rather than being the axis, so there
562
+ is nothing on the graph to check the vocabulary's width against; a row naming
563
+ a slot this model does not have is caught per-detection by `_to_wire`, which
564
+ drops it and counts it as `unnamed_class_dropped`.
565
+ """
566
+ output = _sole_output(outputs, END2END_N_6)
567
+ shape = output.shape
568
+ if len(shape) != 3 or shape[2] != 6:
569
+ raise ModelContractError(
570
+ f"{END2END_N_6} is not (1, N, 6) — x1,y1,x2,y2,score,cls: {shape}"
571
+ )
572
+ rows = int(shape[1]) if isinstance(shape[1], int) else -1
573
+ return (output.name,), rows, int(vocabulary.logit_slots)
370
574
 
371
575
 
372
576
  #: Re-exported so `from .session import DetectionBatch` keeps working. ONE
@@ -376,8 +580,11 @@ __all__ = [
376
580
  "Detection",
377
581
  "DetectionBatch",
378
582
  "GraphSpec",
583
+ "LETTERBOX_FILL",
379
584
  "ModelContractError",
380
585
  "OnnxDetector",
381
586
  "postprocess",
382
587
  "preprocess",
588
+ "preprocess_for_layout",
589
+ "preprocess_letterbox",
383
590
  ]
package/dist/EdgeAgent.js CHANGED
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
44
44
  const CommandRouter_1 = require("./CommandRouter");
45
45
  const config_1 = require("./config");
46
46
  const dataDir_1 = require("./dataDir");
47
- const SDK_VERSION = '0.2.89';
47
+ const SDK_VERSION = '0.2.91';
48
48
  class EdgeAgent extends events_1.EventEmitter {
49
49
  constructor(config, deps) {
50
50
  super();
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
31
31
  skipped: number;
32
32
  };
33
33
  }
34
- declare const SDK_VERSION = "0.2.89";
34
+ declare const SDK_VERSION = "0.2.91";
35
35
  declare function checkNodeVersion(): CheckResult;
36
36
  declare function checkPlatform(): CheckResult;
37
37
  declare function checkMemory(): CheckResult;
package/dist/diagnose.js CHANGED
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
63
63
  // ---------------------------------------------------------------------------
64
64
  // SDK version — mirrors package.json; kept in sync by CI
65
65
  // ---------------------------------------------------------------------------
66
- const SDK_VERSION = '0.2.89';
66
+ const SDK_VERSION = '0.2.91';
67
67
  exports._SDK_VERSION = SDK_VERSION;
68
68
  // ---------------------------------------------------------------------------
69
69
  // ANSI helpers (no external deps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trillboards/edge-sdk",
3
- "version": "0.2.89",
3
+ "version": "0.2.91",
4
4
  "description": "Composable cross-platform Edge AI SDK for audience sensing on DOOH/CTV devices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",