@trillboards/edge-sdk 0.2.89 → 0.2.92
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.
- package/deploy/detector/README.md +76 -7
- package/deploy/detector/bench/box_units.py +71 -0
- package/deploy/detector/bench/run_candidate.py +76 -51
- package/deploy/detector/bench/verify_detector_decode.py +574 -0
- package/deploy/detector/bench/verify_yolo_decode.py +4 -1
- package/deploy/detector/tests/test_box_units.py +119 -0
- package/deploy/detector/tests/test_decode.py +559 -0
- package/deploy/detector/tests/test_loader.py +10 -1
- package/deploy/detector/tests/test_registry.py +42 -4
- package/deploy/detector/tests/test_server.py +9 -1
- package/deploy/detector/tests/test_session.py +402 -1
- package/deploy/detector/trillboards_detector/decode.py +373 -0
- package/deploy/detector/trillboards_detector/labels.py +139 -0
- package/deploy/detector/trillboards_detector/loader.py +15 -1
- package/deploy/detector/trillboards_detector/registry.py +9 -0
- package/deploy/detector/trillboards_detector/session.py +233 -26
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
47
|
+
const SDK_VERSION = '0.2.92';
|
|
48
48
|
class EdgeAgent extends events_1.EventEmitter {
|
|
49
49
|
constructor(config, deps) {
|
|
50
50
|
super();
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
|
|
|
31
31
|
skipped: number;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
declare const SDK_VERSION = "0.2.
|
|
34
|
+
declare const SDK_VERSION = "0.2.92";
|
|
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.
|
|
66
|
+
const SDK_VERSION = '0.2.92';
|
|
67
67
|
exports._SDK_VERSION = SDK_VERSION;
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
// ANSI helpers (no external deps)
|