@trillboards/edge-sdk 0.2.80 → 0.2.82

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.
@@ -32,8 +32,7 @@ import sys as _sys
32
32
  import threading
33
33
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
34
 
35
- from .errors import DetectorNotReady
36
- from .labels import KNOWN_CLASS_NAMES, resolve_requested_classes
35
+ from .errors import DetectorNotReady, UnknownClasses
37
36
 
38
37
  #: Loopback only. litert-lm owns 9379; this is a SECOND service, not a route on
39
38
  #: that one — see `LocalDetectorClient.ts` for why (its own uv tool, its own
@@ -68,14 +67,24 @@ class WireError(Exception):
68
67
  self.message = message
69
68
 
70
69
 
71
- def parse_detect_request(raw: bytes) -> tuple[bytes, float, "set[str] | None"]:
72
- """Body bytes -> (jpeg, min_confidence, wanted classes or None for all).
70
+ def parse_detect_request(raw: bytes) -> tuple[bytes, float, "list[str] | None"]:
71
+ """Body bytes -> (jpeg, min_confidence, requested class names or None for all).
73
72
 
74
73
  Every refusal here is a 400: the caller sent something this service cannot
75
74
  act on, and saying so beats guessing. Guessing is how a profile naming a
76
75
  class the model cannot emit returns an empty frame forever while looking
77
76
  healthy — `yolov8_nano`, offered to ~97 of 114 profiles and loadable
78
77
  nowhere, `vehicle_count` at ~0% fleet-wide for six months.
78
+
79
+ THE CLASS NAMES COME BACK RAW, deliberately. Which classes exist is a
80
+ property of the models on THIS BOX at THIS MOMENT — a specialist's ONNX
81
+ lands over the air and widens the vocabulary with no restart — so it cannot
82
+ be a constant in the wire layer, which is what `KNOWN_CLASS_NAMES` used to
83
+ be. `docs/runbooks/sensing-model-library.md` §3 puts class -> model routing
84
+ in the service "because the service is the only component that knows what is
85
+ resident", and the loader is the half of the service that knows. The
86
+ resolution and the 400 still happen BEFORE any inference; only the authority
87
+ moved.
79
88
  """
80
89
  try:
81
90
  payload = json.loads(raw.decode("utf-8"))
@@ -113,15 +122,7 @@ def parse_detect_request(raw: bytes) -> tuple[bytes, float, "set[str] | None"]:
113
122
  raise WireError(400, "`classes` must be an array of strings")
114
123
  if not raw_classes:
115
124
  raise WireError(400, "`classes` was an empty array; omit the key to mean every class")
116
-
117
- wanted, unknown = resolve_requested_classes(raw_classes)
118
- if not wanted:
119
- raise WireError(
120
- 400,
121
- f"none of the requested classes exist in this model's vocabulary: {unknown}. "
122
- f"Known classes: {sorted(KNOWN_CLASS_NAMES)}",
123
- )
124
- return jpeg, min_confidence, wanted
125
+ return jpeg, min_confidence, raw_classes
125
126
 
126
127
 
127
128
  def _assert_wire_safe(detections: list) -> None:
@@ -154,7 +155,26 @@ def _assert_wire_safe(detections: list) -> None:
154
155
  )
155
156
 
156
157
 
157
- def build_detect_response(batch, model_name: str) -> dict:
158
+ #: How several models' names are joined into the one `model` string the wire
159
+ #: carries. `LocalDetectorClient` types it `string | null` and never branches on
160
+ #: it (`LocalDetectorClient.ts`: `model: typeof parsed?.model === 'string' &&
161
+ #: parsed.model ? parsed.model : null`), so a joined string is contract-safe —
162
+ #: verified against the client rather than assumed. It is an ATTRIBUTION field,
163
+ #: and losing the second name would make a merged answer indistinguishable from
164
+ #: a single-model one when a specialist later turns out to be wrong.
165
+ MODEL_JOIN = "+"
166
+
167
+
168
+ def build_detect_response(batch) -> dict:
169
+ models = list(getattr(batch, "models", []) or [])
170
+ if not models:
171
+ # An unattributed result. `model` would serialise as `""` -> the client
172
+ # reads null, and every downstream reader loses the ability to say which
173
+ # artifact produced a detection. `cctv-detector-tier.md` §5 rule 2 is a
174
+ # whole scar about exactly that class of missing provenance, so this is
175
+ # a 500 rather than a plausible 200.
176
+ raise ValueError("batch names no model; a detection that cannot be attributed must not ship")
177
+
158
178
  body = {
159
179
  "detections": [
160
180
  {
@@ -169,7 +189,10 @@ def build_detect_response(batch, model_name: str) -> dict:
169
189
  # ~63 ms in `docs/runbooks/cctv-detector-tier.md` — a decode cost folded
170
190
  # in here would quietly invalidate that comparison later.
171
191
  "inference_ms": batch.inference_ms,
172
- "model": model_name,
192
+ # THE SET THAT ANSWERED — `sensing-model-library.md` §3. One name on a
193
+ # single-model box, so the shipped payload is byte-identical to what it
194
+ # was before the library existed.
195
+ "model": MODEL_JOIN.join(models),
173
196
  }
174
197
  # A non-finite timing would serialise as a bare `NaN`, which is INVALID JSON
175
198
  # (`json.dumps` emits it by default; `JSON.parse` refuses it). The client
@@ -249,11 +272,30 @@ class DetectorHandler(BaseHTTPRequestHandler):
249
272
  loader = self.server.loader # type: ignore[attr-defined]
250
273
  try:
251
274
  raw = self._read_body()
252
- jpeg, min_confidence, wanted = parse_detect_request(raw)
275
+ jpeg, min_confidence, requested = parse_detect_request(raw)
253
276
  except WireError as err:
254
277
  self._json(err.status, {"error": err.message})
255
278
  return
256
279
 
280
+ wanted = None
281
+ if requested is not None:
282
+ try:
283
+ wanted = loader.resolve_classes(requested)
284
+ except UnknownClasses as err:
285
+ # 400, BEFORE INFERENCE, on ANY unknown — not just on all of
286
+ # them. The partial case is the dangerous one and it becomes the
287
+ # COMMON one the moment specialists exist: a PPE profile asks for
288
+ # `glove` and `hairnet` alongside `person`, and on a box holding
289
+ # only the general detector two thirds of the question would
290
+ # vanish into a 200 that looks like an answer. Refusing costs the
291
+ # `person` detections too; that is the right trade, because which
292
+ # models are on the box was decided upstream by the profile push,
293
+ # so a class this box cannot answer is a configuration error and
294
+ # the client maps 400 to `DetectorUnavailableError`. Visible
295
+ # beats quieter.
296
+ self._json(400, {"error": err.detail or str(err)})
297
+ return
298
+
257
299
  try:
258
300
  batch = loader.detect(jpeg, min_confidence, wanted)
259
301
  except DetectorNotReady as err:
@@ -267,7 +309,7 @@ class DetectorHandler(BaseHTTPRequestHandler):
267
309
  return
268
310
 
269
311
  try:
270
- body = build_detect_response(batch, loader.model_name)
312
+ body = build_detect_response(batch)
271
313
  except ValueError as err:
272
314
  self._log(f"[detector] REFUSING to emit a malformed payload: {err}")
273
315
  self._json(500, {"error": f"refused to emit a malformed payload: {err}"})
@@ -39,7 +39,9 @@ import numpy as np
39
39
  import onnxruntime as ort
40
40
  from PIL import Image
41
41
 
42
- from .labels import COCO91_CLASS_NAMES, EXPECTED_LOGIT_SLOTS
42
+ from .detections import Detection, DetectionBatch
43
+ from .errors import ModelContractError
44
+ from .labels import ModelVocabulary
43
45
 
44
46
  #: `rfdetr/detr.py` — ImageNet statistics, applied AFTER scaling to [0,1].
45
47
  IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
@@ -52,34 +54,6 @@ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1
52
54
  NUM_SELECT = 300
53
55
 
54
56
 
55
- @dataclass(frozen=True)
56
- class Detection:
57
- """One wire-shaped detection. Pixel box, canonical label, [0,1] confidence."""
58
-
59
- label: str
60
- confidence: float
61
- x: float
62
- y: float
63
- width: float
64
- height: float
65
-
66
-
67
- @dataclass
68
- class DetectionBatch:
69
- detections: list[Detection]
70
- #: The ORT forward pass alone, in ms — directly comparable to the ~63 ms in
71
- #: `docs/runbooks/cctv-detector-tier.md`. Decode and post-processing are
72
- #: reported separately (`total_ms`) so a slow JPEG decode cannot hide inside
73
- #: a number the runbook will later be checked against.
74
- inference_ms: float = 0.0
75
- total_ms: float = 0.0
76
- #: Boxes that survived the threshold but cover no pixels after clamping to
77
- #: the frame. Counted, never silently swallowed — see `_to_wire`.
78
- degenerate_dropped: int = 0
79
- #: Detections whose argmax landed on one of the eleven COCO id gaps.
80
- unnamed_class_dropped: int = 0
81
-
82
-
83
57
  @dataclass
84
58
  class GraphSpec:
85
59
  """What the loaded artifact actually is, read off the graph, never assumed."""
@@ -94,10 +68,6 @@ class GraphSpec:
94
68
  providers: list[str] = field(default_factory=list)
95
69
 
96
70
 
97
- class ModelContractError(RuntimeError):
98
- """The artifact loaded, but it is not the graph this service can label."""
99
-
100
-
101
71
  def preprocess(jpeg: bytes, width: int, height: int) -> tuple[np.ndarray, int, int]:
102
72
  """JPEG bytes -> NCHW float32, plus the SUBMITTED frame's pixel size.
103
73
 
@@ -126,6 +96,8 @@ def postprocess(
126
96
  frame_h: int,
127
97
  min_confidence: float,
128
98
  wanted: "set[str] | None" = None,
99
+ *,
100
+ vocabulary: ModelVocabulary,
129
101
  ) -> DetectionBatch:
130
102
  """Raw graph outputs -> wire detections, in the submitted frame's pixels.
131
103
 
@@ -134,6 +106,14 @@ def postprocess(
134
106
  `wanted` filters HERE, at the service, because the alternative is shipping
135
107
  every box across loopback to throw most away — `LocalDetectorClient`'s own
136
108
  reasoning, and the reason `classes` rides the wire at all.
109
+
110
+ `vocabulary` IS REQUIRED AND HAS NO DEFAULT. It used to be the module-level
111
+ COCO-91 map, which is exactly what made a specialist inexpressible — and a
112
+ default naming the shipped model would be worse than the constant it
113
+ replaces: a PPE model would silently get COCO names for its own slots and
114
+ every number downstream would stay plausible. That is `cctv-detector-tier.md`
115
+ §5 rule 2 with the sign flipped, and it is the reason the argument is
116
+ keyword-only: a positional one could be filled by the wrong thing.
137
117
  """
138
118
  # (1, Q, C) -> (Q, C); the graph is batch-1 by construction.
139
119
  prob = _sigmoid(logits[0])
@@ -161,7 +141,7 @@ def postprocess(
161
141
  x1 = np.clip((cx + 0.5 * w) * frame_w, 0.0, frame_w)
162
142
  y1 = np.clip((cy + 0.5 * h) * frame_h, 0.0, frame_h)
163
143
 
164
- return _to_wire(scores, class_idx, x0, y0, x1, y1, wanted)
144
+ return _to_wire(scores, class_idx, x0, y0, x1, y1, wanted, vocabulary)
165
145
 
166
146
 
167
147
  def _to_wire(
@@ -172,14 +152,17 @@ def _to_wire(
172
152
  x1: np.ndarray,
173
153
  y1: np.ndarray,
174
154
  wanted: "set[str] | None",
155
+ vocabulary: ModelVocabulary,
175
156
  ) -> DetectionBatch:
176
157
  """Assemble detections, dropping only what CANNOT be put on the wire.
177
158
 
178
159
  Two drops, and each is counted rather than swallowed:
179
160
 
180
- * an UNNAMED CLASS SLOT. Eleven of the ninety-one slots are COCO id gaps
181
- with no name. `docs/runbooks/cctv-detector-tier.md` §5 rule 2 forbids
182
- guessing; a detection with no nameable class is dropped.
161
+ * an UNNAMED CLASS SLOT. For COCO-91, eleven of the ninety-one slots are
162
+ category-id gaps with no name; a specialist may have its own. The name
163
+ comes from THIS MODEL's committed vocabulary and from nowhere else.
164
+ `docs/runbooks/cctv-detector-tier.md` §5 rule 2 forbids guessing; a
165
+ detection with no nameable class is dropped.
183
166
  * a ZERO-EXTENT BOX. A box clamped flush against a frame edge can have zero
184
167
  width or height, and `LocalDetectorClient` REJECTS THE ENTIRE PAYLOAD on
185
168
  one — correctly, since it cannot tell a schema-drifted service from a
@@ -197,8 +180,9 @@ def _to_wire(
197
180
  degenerate = 0
198
181
  unnamed = 0
199
182
 
183
+ slot_names = vocabulary.slot_names
200
184
  for i in range(scores.shape[0]):
201
- name = COCO91_CLASS_NAMES.get(int(class_idx[i]))
185
+ name = slot_names.get(int(class_idx[i]))
202
186
  if name is None:
203
187
  unnamed += 1
204
188
  continue
@@ -221,7 +205,12 @@ def _to_wire(
221
205
  # sigmoid is (0,1); clamp only guards float64 round-trip at the edges.
222
206
  out.append(Detection(name, min(1.0, max(0.0, conf)), left, top, width, height))
223
207
 
224
- return DetectionBatch(out, degenerate_dropped=degenerate, unnamed_class_dropped=unnamed)
208
+ return DetectionBatch(
209
+ out,
210
+ degenerate_dropped=degenerate,
211
+ unnamed_class_dropped=unnamed,
212
+ models=[vocabulary.model_name],
213
+ )
225
214
 
226
215
 
227
216
  def _sigmoid(x: np.ndarray) -> np.ndarray:
@@ -250,21 +239,30 @@ class OnnxDetector:
250
239
  overrides them. Pinning them is the mistake the same section names.
251
240
  """
252
241
 
253
- def __init__(self, model_path: Path, model_name: str, intra_op_threads: "int | None" = None):
242
+ def __init__(
243
+ self,
244
+ model_path: Path,
245
+ vocabulary: ModelVocabulary,
246
+ intra_op_threads: "int | None" = None,
247
+ ):
254
248
  self.model_path = model_path
255
- self.model_name = model_name
249
+ self.vocabulary = vocabulary
250
+ self.model_name = vocabulary.model_name
256
251
  self._lock = threading.Lock()
257
252
 
258
253
  opts = ort.SessionOptions()
259
254
  if intra_op_threads is not None:
260
255
  opts.intra_op_num_threads = intra_op_threads
261
- # Loopback service, one process, one model: log only what a fault needs.
256
+ # Loopback service, one process: log only what a fault needs. Several
257
+ # sessions may be resident at once now (`registry.py`), which is exactly
258
+ # why the thread counts below stay at ORT's default — pinning them would
259
+ # multiply an already non-monotonic pool by the number of models.
262
260
  opts.log_severity_level = 3
263
261
 
264
262
  self._session = ort.InferenceSession(
265
263
  str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
266
264
  )
267
- self.spec = _read_graph_spec(self._session)
265
+ self.spec = _read_graph_spec(self._session, vocabulary)
268
266
 
269
267
  def detect(
270
268
  self,
@@ -282,21 +280,38 @@ class OnnxDetector:
282
280
  )
283
281
  infer_ms = (time.perf_counter() - infer_started) * 1000.0
284
282
 
285
- batch = postprocess(outputs[0], outputs[1], frame_w, frame_h, min_confidence, wanted)
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
+ )
286
292
  batch.inference_ms = infer_ms
287
293
  batch.total_ms = (time.perf_counter() - started) * 1000.0
288
294
  return batch
289
295
 
290
296
 
291
- def _read_graph_spec(session: "ort.InferenceSession") -> GraphSpec:
297
+ def _read_graph_spec(
298
+ session: "ort.InferenceSession",
299
+ vocabulary: ModelVocabulary,
300
+ ) -> GraphSpec:
292
301
  """Read the contract off the loaded graph, and REFUSE a graph we cannot label.
293
302
 
294
- The exported artifact carries no class metadata, so the label map in
295
- `labels.py` is the only thing naming its 91 slots. If the graph's class axis
296
- is not 91 wide, that map does not describe it — and applying it anyway
297
- renames every object in the venue while every number stays plausible. That
298
- is the `yolov8_nano` failure with the sign flipped: instead of a model
299
- offered and never loadable, a model loaded and never right.
303
+ The exported artifact carries no class metadata, so the committed
304
+ vocabulary in `labels.py` is the only thing naming its slots. If the graph's
305
+ class axis is not the width that vocabulary declares, it does not describe
306
+ this artifact — and applying it anyway renames every object in the venue
307
+ while every number stays plausible. That is the `yolov8_nano` failure with
308
+ the sign flipped: instead of a model offered and never loadable, a model
309
+ loaded and never right.
310
+
311
+ THE CHECK IS PER MODEL, NOT PER SERVICE. With a library, "91 slots" is one
312
+ model's fact rather than the box's, so the width is read off the vocabulary
313
+ being loaded. A specialist with a 4-wide axis loads happily against ITS OWN
314
+ vocabulary and is still refused against anyone else's.
300
315
 
301
316
  So a mismatch raises, the service goes UNHEALTHY with the reason, and
302
317
  nothing is emitted. The 2.1%-of-events figure the placement decision rests
@@ -335,11 +350,11 @@ def _read_graph_spec(session: "ort.InferenceSession") -> GraphSpec:
335
350
  raise ModelContractError(f"`labels` is not (N, Q, C): {logit_shape}")
336
351
 
337
352
  num_classes = logit_shape[2]
338
- if num_classes != EXPECTED_LOGIT_SLOTS:
353
+ if num_classes != vocabulary.logit_slots:
339
354
  raise ModelContractError(
340
- f"model has {num_classes} class slots; the committed label map describes "
341
- f"{EXPECTED_LOGIT_SLOTS}. Refusing rather than labelling boxes by an "
342
- "index this map does not name — see labels.py."
355
+ f"model has {num_classes} class slots; the committed vocabulary for "
356
+ f"{vocabulary.model_name} describes {vocabulary.logit_slots}. Refusing rather "
357
+ "than labelling boxes by an index that vocabulary does not name — see labels.py."
343
358
  )
344
359
 
345
360
  return GraphSpec(
@@ -352,3 +367,17 @@ def _read_graph_spec(session: "ort.InferenceSession") -> GraphSpec:
352
367
  num_classes=int(num_classes),
353
368
  providers=list(session.get_providers()),
354
369
  )
370
+
371
+
372
+ #: Re-exported so `from .session import DetectionBatch` keeps working. ONE
373
+ #: definition, in `detections.py`, which is stdlib-only so `registry.py` can
374
+ #: merge two models' answers on a runner that has no onnxruntime.
375
+ __all__ = [
376
+ "Detection",
377
+ "DetectionBatch",
378
+ "GraphSpec",
379
+ "ModelContractError",
380
+ "OnnxDetector",
381
+ "postprocess",
382
+ "preprocess",
383
+ ]
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.80';
47
+ const SDK_VERSION = '0.2.82';
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.80";
34
+ declare const SDK_VERSION = "0.2.82";
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.80';
66
+ const SDK_VERSION = '0.2.82';
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.80",
3
+ "version": "0.2.82",
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",