@camstack/addon-pipeline 1.2.29 → 1.2.36

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.
Files changed (30) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +1229 -1147
  4. package/dist/detection-pipeline/index.mjs +1229 -1147
  5. package/dist/{dist-DUHlZcWd.js → dist-BvFLZyhW.js} +105 -3
  6. package/dist/{dist-CsKaXmP8.mjs → dist-Du7_SNiR.mjs} +105 -3
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +156 -9
  10. package/dist/pipeline-runner/index.mjs +155 -9
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{step-definitions-1GIwU6R8.mjs → step-definitions-C8tk9fPJ.mjs} +12 -3
  14. package/dist/{step-definitions-Drb9cWbB.js → step-definitions-i8O-hxyI.js} +17 -2
  15. package/dist/stream-broker/_stub.js +2 -2
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tHahN-lK.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C5m4hC2K.mjs} +3 -3
  17. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +26 -0
  18. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +26 -0
  19. package/dist/stream-broker/{hostInit-CIwilJUZ.mjs → hostInit-WXFrgppm.mjs} +3 -3
  20. package/dist/stream-broker/index.js +3 -3
  21. package/dist/stream-broker/index.mjs +3 -3
  22. package/dist/stream-broker/remoteEntry.js +1 -1
  23. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B7VdNJlO.js → MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js} +1 -1
  24. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-YyB4uLdH.js → MotionZonesSettings-NcxxQN8r-9D-BWwQP.js} +1 -1
  25. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DfqixEad.js → PrivacyMaskSettings-APgPLF7p-HvAetfA4.js} +1 -1
  26. package/embed-dist/assets/{index-zVLLVdIS.js → index-9Ob2YrIf.js} +14 -14
  27. package/embed-dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1zJmtUe.mjs +0 -26
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CSWUWZ8c.mjs +0 -26
@@ -3,10 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DUHlZcWd.js");
7
- const require_step_definitions = require("../step-definitions-Drb9cWbB.js");
6
+ const require_dist = require("../dist-BvFLZyhW.js");
7
+ const require_step_definitions = require("../step-definitions-i8O-hxyI.js");
8
8
  const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
9
9
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
10
+ let node_os = require("node:os");
11
+ node_os = require_chunk.__toESM(node_os);
10
12
  let node_child_process = require("node:child_process");
11
13
  let sharp = require("sharp");
12
14
  sharp = require_chunk.__toESM(sharp);
@@ -14,8 +16,6 @@ let node_fs = require("node:fs");
14
16
  node_fs = require_chunk.__toESM(node_fs);
15
17
  let node_path = require("node:path");
16
18
  node_path = require_chunk.__toESM(node_path);
17
- let node_os = require("node:os");
18
- node_os = require_chunk.__toESM(node_os);
19
19
  //#region src/detection-pipeline/runtimes.ts
20
20
  var KNOWN_PLATFORMS = [
21
21
  "darwin",
@@ -81,14 +81,28 @@ function probedToHardwareInfo(hw) {
81
81
  coral: coralInfoFrom(hw)
82
82
  };
83
83
  }
84
+ /**
85
+ * THE per-runtime pool provisioning registry — the single source for every
86
+ * pool's concurrency / worker count / batch tuning, keyed by the runtime THAT
87
+ * POOL runs. `engine-factory` used to carry a second, parallel copy of these
88
+ * defaults; it no longer does.
89
+ *
90
+ * `windowMs` / `maxBatchSize` are inert unless `batchMode !== 'none'`, and
91
+ * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
92
+ * still spelled out per row so a reader never has to guess which default a
93
+ * missing key would have hit.
94
+ */
84
95
  var RUNTIME_DETAIL = {
85
96
  onnx: {
86
97
  label: "ONNX Runtime",
87
98
  pythonRequirements: ["requirements.txt", "requirements-onnxruntime.txt"],
88
99
  tuning: {
89
100
  concurrency: 4,
101
+ numWorkers: 1,
90
102
  batchMode: "list",
103
+ windowMs: 2,
91
104
  maxBatchSize: 8,
105
+ numStreams: 0,
92
106
  intraOpThreads: 0
93
107
  }
94
108
  },
@@ -97,8 +111,12 @@ var RUNTIME_DETAIL = {
97
111
  pythonRequirements: ["requirements.txt", "requirements-openvino.txt"],
98
112
  tuning: {
99
113
  concurrency: 1,
114
+ numWorkers: 1,
100
115
  batchMode: "none",
101
- numStreams: 0
116
+ windowMs: 2,
117
+ maxBatchSize: 8,
118
+ numStreams: 0,
119
+ intraOpThreads: 0
102
120
  }
103
121
  },
104
122
  coreml: {
@@ -106,10 +124,12 @@ var RUNTIME_DETAIL = {
106
124
  pythonRequirements: ["requirements.txt", "requirements-coreml.txt"],
107
125
  tuning: {
108
126
  concurrency: 1,
127
+ numWorkers: 1,
109
128
  batchMode: "none",
110
129
  windowMs: 8,
111
130
  maxBatchSize: 8,
112
- numWorkers: 1
131
+ numStreams: 0,
132
+ intraOpThreads: 0
113
133
  }
114
134
  },
115
135
  edgetpu: {
@@ -117,8 +137,12 @@ var RUNTIME_DETAIL = {
117
137
  pythonRequirements: ["requirements.txt", "requirements-edgetpu.txt"],
118
138
  tuning: {
119
139
  concurrency: 1,
140
+ numWorkers: 1,
120
141
  batchMode: "none",
121
- numWorkers: 1
142
+ windowMs: 2,
143
+ maxBatchSize: 8,
144
+ numStreams: 0,
145
+ intraOpThreads: 0
122
146
  }
123
147
  }
124
148
  };
@@ -161,6 +185,12 @@ function modelFormatFor(id) {
161
185
  function pythonRequirementsFor(id) {
162
186
  return RUNTIME_DETAIL[id].pythonRequirements;
163
187
  }
188
+ /**
189
+ * The pool provisioning for ONE runtime. Callers must pass the runtime of the
190
+ * pool they are provisioning — never the node's default runtime. See
191
+ * `engine/pool-provisioning.ts` for the resolver that layers an operator
192
+ * override on top of this.
193
+ */
164
194
  function tuningFor(id) {
165
195
  return RUNTIME_DETAIL[id].tuning;
166
196
  }
@@ -221,43 +251,6 @@ function normalizeEngineNodeId(rawNodeId) {
221
251
  return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
222
252
  }
223
253
  //#endregion
224
- //#region src/detection-pipeline/pipeline/native-crop-compose.ts
225
- /** Clamp `v` into `[lo, hi]`. */
226
- function clamp(v, lo, hi) {
227
- if (v < lo) return lo;
228
- if (v > hi) return hi;
229
- return v;
230
- }
231
- /**
232
- * Compose a child `roi` given in the parent CROP's normalized space into the
233
- * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
234
- * frame dimensions. The result is clamped to the unit square (origin in
235
- * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
236
- * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
237
- * clamps to zero area) so the caller falls back to the downscaled tile crop.
238
- *
239
- * Pure — never mutates its arguments.
240
- */
241
- function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
242
- if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
243
- if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
244
- const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
245
- const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
246
- const fw = roi.w * cropFrameSpace.w / frameWidth;
247
- const fh = roi.h * cropFrameSpace.h / frameHeight;
248
- const x = clamp(fx, 0, 1);
249
- const y = clamp(fy, 0, 1);
250
- const w = clamp(fw, 0, 1 - x);
251
- const h = clamp(fh, 0, 1 - y);
252
- if (!(w > 0) || !(h > 0)) return null;
253
- return {
254
- x,
255
- y,
256
- w,
257
- h
258
- };
259
- }
260
- //#endregion
261
254
  //#region src/detection-pipeline/default-detection-model.ts
262
255
  /** The object-detection step id — the only slot this resolver applies to. */
263
256
  var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
@@ -308,10 +301,12 @@ function classifyAccelerator(backend, device) {
308
301
  *
309
302
  * Guarantees:
310
303
  * - never throws (hot default-tree path);
311
- * - never returns a model id that is not in the object-detection catalog
312
- * with a build for `format` an unmapped backend, a `'cpu'` class, a
313
- * mapped id missing from the catalog, or a mapped id without a `format`
314
- * build all fall back to the step's own `defaultModelId` (`yolo26n`).
304
+ * - never returns a model id without a build for `format` (unless the step
305
+ * has ZERO builds for `format`, which is unloadable regardless of pick and
306
+ * flagged by `collectZeroBuildIssues`) an unmapped backend, a `'cpu'`
307
+ * class, a mapped id missing from the catalog, or a mapped id without a
308
+ * `format` build all fall back to the step's PER-FORMAT default
309
+ * (`getDefaultModelForFormatFromDef`), NOT the bare `defaultModelId`.
315
310
  *
316
311
  * `getStepDef` is injectable (defaults to the real catalog lookup) so the
317
312
  * missing-build fallback branch is unit-testable without a live catalog —
@@ -324,12 +319,12 @@ function resolveDefaultDetectionModel(backend, device, format, getStepDef = requ
324
319
  } catch {
325
320
  return "yolo26n";
326
321
  }
327
- const fallback = def.defaultModelId;
328
322
  const candidate = MODEL_BY_CLASS[classifyAccelerator(backend, device)];
329
- if (candidate === null) return fallback;
330
- const entry = def.models.find((m) => m.id === candidate);
331
- if (!entry || entry.formats[format] === void 0) return fallback;
332
- return candidate;
323
+ if (candidate !== null) {
324
+ const entry = def.models.find((m) => m.id === candidate);
325
+ if (entry && entry.formats[format] !== void 0) return candidate;
326
+ }
327
+ return require_step_definitions.getDefaultModelForFormatFromDef(def, format);
333
328
  }
334
329
  //#endregion
335
330
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
@@ -379,6 +374,62 @@ var RAW_FMT_CODE = {
379
374
  * large frame) never trips; override via env for constrained hardware.
380
375
  */
381
376
  var POOL_INFER_TIMEOUT_MS = Math.max(1e3, Number(process.env["CAMSTACK_POOL_INFER_TIMEOUT_MS"]) || 6e4);
377
+ /**
378
+ * Max inference requests outstanding to ONE worker before new ones are SHED.
379
+ *
380
+ * Without this the camera dies permanently the first time the worker gets
381
+ * slow, and it did: device 615 produced nothing for 52 minutes on 2026-07-31.
382
+ * The worker was neither dead nor deadlocked — it was replying at full rate,
383
+ * about a minute behind, and every reply landed after {@link
384
+ * POOL_INFER_TIMEOUT_MS} had already abandoned its request. The proof is in the
385
+ * logs: a steady drip of `Response for unknown request id`, request ids
386
+ * climbing ~13/s, for as long as the outage lasted.
387
+ *
388
+ * That state is self-sustaining and cannot drain, because frames keep arriving
389
+ * while every one of them is written straight to the pipe. `dispatch` had NO
390
+ * bound: a transient slowdown (inference went 40ms → 2500ms across several
391
+ * cameras at 16:19 that day) buys a backlog that is never paid off, and once
392
+ * the backlog exceeds the deadline the loss is total.
393
+ *
394
+ * The queue is therefore capped in TIME, not just in count: at the normal ~40ms
395
+ * per inference this is ~0.3s of work, and even at a 20× slowdown it stays well
396
+ * inside the 60s deadline — so a worker that falls behind sheds, catches up,
397
+ * and recovers on its own. Python already sheds under its own per-model bound
398
+ * and answers `{dropped: true}`; this is the same contract applied one layer
399
+ * earlier, where the unbounded queue actually is.
400
+ */
401
+ var POOL_MAX_IN_FLIGHT = Math.max(1, Number(process.env["CAMSTACK_POOL_MAX_IN_FLIGHT"]) || 8);
402
+ /**
403
+ * Reply deadline for a LIVE inference request (ms), as opposed to the generous
404
+ * {@link POOL_INFER_TIMEOUT_MS} that model loads and commands keep.
405
+ *
406
+ * 60 seconds was never a deadline for live detection — it is three orders of
407
+ * magnitude past the point where the answer is worth having. Frames arrive at
408
+ * ~13/s; a detection that lands 60s late describes a scene that no longer
409
+ * exists, and the only thing the wait accomplished was holding a queue slot
410
+ * while the backlog grew. Operator directive 2026-07-31: "a few seconds are
411
+ * already too many for this scenario".
412
+ *
413
+ * Three seconds is deliberately still loose — normal inference on this fleet is
414
+ * 35-47ms, so this trips only at a ~70× slowdown — but it bounds the damage at
415
+ * seconds instead of a minute, and combined with {@link POOL_MAX_IN_FLIGHT} it
416
+ * makes the backlog mathematically unable to outrun the deadline: the queue can
417
+ * never hold more than `cap × deadline` of work.
418
+ *
419
+ * Commands and model loads keep the long timeout on purpose: a cold model load
420
+ * legitimately takes tens of seconds, and shedding or timing one out
421
+ * desynchronizes the worker's model slots.
422
+ */
423
+ var POOL_LIVE_INFER_TIMEOUT_MS = Math.max(250, Number(process.env["CAMSTACK_POOL_LIVE_INFER_TIMEOUT_MS"]) || 3e3);
424
+ /** Opcodes the in-flight cap may shed. Commands and frame-cache writes are
425
+ * NEVER shed: a dropped command silently desynchronizes model state, and a
426
+ * dropped `cacheFrame` strands the `inferCached` that follows it. */
427
+ var SHEDDABLE_MSG_TYPES = new Set([
428
+ MSG_INFER_JPEG,
429
+ MSG_INFER_RAW,
430
+ MSG_INFER_BATCH,
431
+ MSG_INFER_CACHED
432
+ ]);
382
433
  var PoolHandle = class {
383
434
  pool;
384
435
  modelIndex;
@@ -390,10 +441,7 @@ var PoolHandle = class {
390
441
  }
391
442
  async infer(input) {
392
443
  const start = performance.now();
393
- return {
394
- structured: input.kind === "raw" ? await this.pool.inferRaw(this.modelIndex, input.data, input.width, input.height, input.format) : await this.pool.infer(this.modelIndex, input.data),
395
- inferenceMs: performance.now() - start
396
- };
444
+ return poolResultToEngineOutput(input.kind === "raw" ? await this.pool.inferRaw(this.modelIndex, input.data, input.width, input.height, input.format) : await this.pool.infer(this.modelIndex, input.data), performance.now() - start);
397
445
  }
398
446
  /**
399
447
  * Inference on a frame previously cached in the Python pool via
@@ -402,20 +450,50 @@ var PoolHandle = class {
402
450
  */
403
451
  async inferFromCache(frameId) {
404
452
  const start = performance.now();
405
- return {
406
- structured: await this.pool.inferCached(this.modelIndex, frameId),
407
- inferenceMs: performance.now() - start
408
- };
453
+ return poolResultToEngineOutput(await this.pool.inferCached(this.modelIndex, frameId), performance.now() - start);
409
454
  }
410
455
  async dispose() {}
411
456
  };
412
457
  /**
458
+ * Map a raw pool response record to an {@link EngineOutput}.
459
+ *
460
+ * An overload shed (`{dropped: true}` — from the Python pool's per-model bound
461
+ * OR the TS worker in-flight cap, which deliberately mirrors it) becomes a
462
+ * first-class `EngineOutput.dropped` with NO `structured` payload. Before this
463
+ * mapping the shed record rode through as `structured`, failed the StepOutput
464
+ * kind guard in the postprocess funnel, and every deliberate shed was logged
465
+ * as `runInference failed … unexpected kind: undefined` at error level on
466
+ * every node (~4/s cluster-wide, 2026-08-01). A shed is flow control, not a
467
+ * fault — it must never reach a postprocessor. Exported for tests.
468
+ */
469
+ function poolResultToEngineOutput(result, inferenceMs) {
470
+ if (result["dropped"] === true) {
471
+ const shedReason = result["shedReason"];
472
+ return {
473
+ dropped: true,
474
+ ...typeof shedReason === "string" ? { shedReason } : {},
475
+ inferenceMs
476
+ };
477
+ }
478
+ return {
479
+ structured: result,
480
+ inferenceMs
481
+ };
482
+ }
483
+ /**
413
484
  * How long to wait for a worker to exit on SIGTERM before escalating to
414
485
  * SIGKILL. A worker idle between frames exits well under this; only a worker
415
486
  * stuck in a native inference call (openvino/onnx C++) needs the escalation.
416
487
  */
417
488
  var POOL_WORKER_TERM_GRACE_MS = 2e3;
418
489
  /**
490
+ * Sampling stride for the "Python pool shed frame under overload" debug
491
+ * line — see `trackDroppedResponse`. At the observed chronic-overload rate
492
+ * (~13 shed/s, 2026-08-01) a stride of 500 yields roughly one line every
493
+ * 40 s while `droppedTotal` in the line keeps the exact count.
494
+ */
495
+ var SHED_LOG_SAMPLE_EVERY = 500;
496
+ /**
419
497
  * Terminate a spawned child GRACEFULLY THEN FORCEFULLY: end stdin, send
420
498
  * SIGTERM, and if the process hasn't exited within `graceMs`, send SIGKILL.
421
499
  * Resolves once the process has exited (or was already dead).
@@ -458,6 +536,13 @@ var PoolWorker = class {
458
536
  process = null;
459
537
  receiveBuffer = Buffer.alloc(0);
460
538
  pending = /* @__PURE__ */ new Map();
539
+ /** True while the in-flight cap is shedding — so the warning fires on the
540
+ * EDGE rather than once per dropped frame. */
541
+ shedding = false;
542
+ /** Inference requests shed by the in-flight cap, for this worker's lifetime.
543
+ * Surfaced so "the camera is quiet" and "the camera is saturated" can be
544
+ * told apart without reading the logs. */
545
+ shedCount = 0;
461
546
  nextRequestId = 1;
462
547
  ready = false;
463
548
  log;
@@ -469,6 +554,15 @@ var PoolWorker = class {
469
554
  getPid() {
470
555
  return this.process?.pid ?? null;
471
556
  }
557
+ /** Requests written and not yet answered. This is the BACKLOG — the number
558
+ * nothing measured while a camera sat at zero frames for 52 minutes. */
559
+ getInFlight() {
560
+ return this.pending.size;
561
+ }
562
+ /** Inference shed by the in-flight cap over this worker's lifetime. */
563
+ getShedCount() {
564
+ return this.shedCount;
565
+ }
472
566
  isReady() {
473
567
  return this.ready;
474
568
  }
@@ -617,10 +711,51 @@ var PoolWorker = class {
617
711
  this.ready = false;
618
712
  await terminateChild(proc, POOL_WORKER_TERM_GRACE_MS);
619
713
  }
714
+ /**
715
+ * Shed an inference request when the worker already has {@link
716
+ * POOL_MAX_IN_FLIGHT} outstanding — the backlog is what turns a slow worker
717
+ * into a dead camera. Returns the shed response, or `null` to proceed.
718
+ *
719
+ * A shed is LOUD the first time and then rate-limited: at frame rate this
720
+ * fires ~13×/s, so logging every one would bury the signal it is meant to
721
+ * raise — but logging none is how a camera went dark for 52 minutes looking
722
+ * like a quiet afternoon.
723
+ */
724
+ shedIfSaturated(msgType) {
725
+ if (!SHEDDABLE_MSG_TYPES.has(msgType)) return null;
726
+ if (this.pending.size < POOL_MAX_IN_FLIGHT) {
727
+ this.shedding = false;
728
+ return null;
729
+ }
730
+ if (!this.shedding) {
731
+ this.shedding = true;
732
+ this.log.warn("worker saturated — shedding inference until it catches up", { meta: {
733
+ worker: this.opts.workerLabel,
734
+ pid: this.getPid(),
735
+ runtime: this.opts.poolRuntime,
736
+ device: this.opts.device ?? "default",
737
+ inFlight: this.pending.size,
738
+ cap: POOL_MAX_IN_FLIGHT,
739
+ deadlineMs: POOL_LIVE_INFER_TIMEOUT_MS
740
+ } });
741
+ }
742
+ this.shedCount++;
743
+ return {
744
+ dropped: true,
745
+ shedReason: "worker-in-flight-cap"
746
+ };
747
+ }
748
+ /** Live inference gets seconds; commands and model loads keep the long
749
+ * timeout — see {@link POOL_LIVE_INFER_TIMEOUT_MS}. */
750
+ deadlineFor(msgType) {
751
+ return SHEDDABLE_MSG_TYPES.has(msgType) ? POOL_LIVE_INFER_TIMEOUT_MS : POOL_INFER_TIMEOUT_MS;
752
+ }
620
753
  dispatch(msgType, payload) {
754
+ const shed = this.shedIfSaturated(msgType);
755
+ if (shed) return Promise.resolve(shed);
621
756
  const reqId = this.allocRequestId();
622
757
  return new Promise((resolve, reject) => {
623
- const timer = this.armRequestTimeout(reqId, reject);
758
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
624
759
  this.pending.set(reqId, {
625
760
  resolve,
626
761
  reject,
@@ -640,24 +775,30 @@ var PoolWorker = class {
640
775
  * `POOL_INFER_TIMEOUT_MS`. `unref` so it never keeps the event loop alive.
641
776
  * The normal response + `rejectAll` paths clear it via `PendingRequest.timer`.
642
777
  */
643
- armRequestTimeout(reqId, reject) {
778
+ armRequestTimeout(reqId, reject, timeoutMs) {
644
779
  const timer = setTimeout(() => {
645
780
  if (this.pending.delete(reqId)) {
646
781
  this.log.error("inference request timed out", { meta: {
647
782
  worker: this.opts.workerLabel,
783
+ pid: this.getPid(),
784
+ runtime: this.opts.poolRuntime,
785
+ device: this.opts.device ?? "default",
786
+ inFlight: this.pending.size,
648
787
  reqId,
649
788
  timeoutMs: POOL_INFER_TIMEOUT_MS
650
789
  } });
651
- reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${POOL_INFER_TIMEOUT_MS}ms (worker alive, no reply)`));
790
+ reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
652
791
  }
653
- }, POOL_INFER_TIMEOUT_MS);
792
+ }, timeoutMs);
654
793
  timer.unref?.();
655
794
  return timer;
656
795
  }
657
796
  dispatchMulti(msgType, payloadLen, parts) {
797
+ const shed = this.shedIfSaturated(msgType);
798
+ if (shed) return Promise.resolve(shed);
658
799
  const reqId = this.allocRequestId();
659
800
  return new Promise((resolve, reject) => {
660
- const timer = this.armRequestTimeout(reqId, reject);
801
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
661
802
  this.pending.set(reqId, {
662
803
  resolve,
663
804
  reject,
@@ -763,6 +904,22 @@ var SharedInferencePool = class {
763
904
  }
764
905
  device;
765
906
  /** Pid of the first worker (for legacy callers). Use `getPids()` for all. */
907
+ /** Summed backlog and shed count across the pool's workers. A rising
908
+ * `inFlight` with a rising `shed` is a worker falling behind; a rising
909
+ * `inFlight` with zero `shed` and no results is the state that used to be
910
+ * invisible. */
911
+ getBacklog() {
912
+ let inFlight = 0;
913
+ let shed = 0;
914
+ for (const w of this.workers) {
915
+ inFlight += w.getInFlight();
916
+ shed += w.getShedCount();
917
+ }
918
+ return {
919
+ inFlight,
920
+ shed
921
+ };
922
+ }
766
923
  getPid() {
767
924
  return this.workers[0]?.getPid() ?? null;
768
925
  }
@@ -796,6 +953,7 @@ var SharedInferencePool = class {
796
953
  loadedCount,
797
954
  startupMs,
798
955
  runtime: this.poolRuntime,
956
+ device: this.device ?? "default",
799
957
  workers: this.numWorkers,
800
958
  pythonWorkers: innerWorkers,
801
959
  pids: this.getPids()
@@ -887,20 +1045,35 @@ var SharedInferencePool = class {
887
1045
  async dispose() {
888
1046
  await Promise.all(this.workers.map((w) => w.dispose()));
889
1047
  this.workers.length = 0;
890
- this.log.info("Pool process(es) terminated");
1048
+ this.log.info("Pool process(es) terminated", { meta: {
1049
+ runtime: this.poolRuntime,
1050
+ device: this.device ?? "default"
1051
+ } });
891
1052
  }
892
1053
  /**
893
1054
  * Recognise a Python overload-shed response (`"dropped": true`) on the
894
1055
  * single-frame inference paths. Previously indistinguishable from a
895
1056
  * genuine empty detection result — now counted + debug-logged so
896
1057
  * overload is measurable. The response passes through unchanged.
1058
+ *
1059
+ * SAMPLED log: one line per shed frame turned into 45 741 rows/hour on
1060
+ * the hub during the 2026-08-01 chronic overload (~13 shed/s) — 2/3 of
1061
+ * the hub's entire log volume, burying every other signal. The counter
1062
+ * keeps exact totals (`droppedTotal` is authoritative, surfaced in
1063
+ * `getStatus` consumers via `getDroppedResponseCount`); the log line
1064
+ * now fires on the FIRST shed and every `SHED_LOG_SAMPLE_EVERY`th,
1065
+ * carrying the running total so nothing is lost — only repeated.
897
1066
  */
898
1067
  trackDroppedResponse(result, modelIndex) {
899
1068
  if (result["dropped"] === true) {
900
1069
  this.droppedResponseCount++;
901
- this.log.debug("Python pool shed frame under overload", { meta: {
1070
+ const total = this.droppedResponseCount;
1071
+ if (total === 1 || total % SHED_LOG_SAMPLE_EVERY === 0) this.log.debug("Python pool shed frame under overload", { meta: {
902
1072
  modelIndex,
903
- droppedTotal: this.droppedResponseCount
1073
+ droppedTotal: total,
1074
+ sampledEvery: SHED_LOG_SAMPLE_EVERY,
1075
+ runtime: this.poolRuntime,
1076
+ device: this.device ?? "default"
904
1077
  } });
905
1078
  }
906
1079
  return result;
@@ -975,6 +1148,66 @@ function flattenEnabledVideoSteps(steps) {
975
1148
  walk(steps);
976
1149
  return result;
977
1150
  }
1151
+ /**
1152
+ * The steps that EXECUTE in a `plane: 'frame'` dispatch (two-plane design),
1153
+ * mirroring the executor's frame-plane skip in `executeChildren`: every
1154
+ * enabled top-level step runs at the root; a CHILD runs only when its catalog
1155
+ * definition is itself root-plane (`isDetailStep(addonId)` false). The walk
1156
+ * continues only through steps that execute — a skipped detail child's
1157
+ * descendants ride ITS per-track detail-subtree call, never this one.
1158
+ * Disabled and audio-classifier steps execute on neither plane.
1159
+ */
1160
+ function collectFramePlaneSteps(steps, isDetailStep) {
1161
+ const result = [];
1162
+ const walk = (nodes, topLevel) => {
1163
+ for (const step of nodes) {
1164
+ if (!step.enabled) continue;
1165
+ if (step.slot === "audio-classifier") continue;
1166
+ if (!topLevel && isDetailStep(step.addonId)) continue;
1167
+ result.push(step);
1168
+ if (step.children?.length) walk(step.children, false);
1169
+ }
1170
+ };
1171
+ walk(steps, true);
1172
+ return result;
1173
+ }
1174
+ /**
1175
+ * Immutably remove from a frame-plane dispatch tree every enabled DETAIL
1176
+ * subtree (at any depth below the top level) whose top step `isUnrunnable`.
1177
+ * Those steps can never load into the dispatch device's pool — leaving them
1178
+ * in the tree makes `ensureModelsForSteps` fail the whole call on a format
1179
+ * build that does not exist. They are NOT dropped work: each runs later as
1180
+ * its own detail-subtree dispatch, where the device-jump resolver
1181
+ * (`resolveStepDevice`) places it on a same-node device that can run it, or
1182
+ * warns no-candidate loudly. Runnable detail children are kept so their
1183
+ * models pre-warm the device pool as before. Disabled subtrees are kept
1184
+ * verbatim (never walked — `ensureModelsForSteps` skips them anyway).
1185
+ */
1186
+ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1187
+ const prunedAddonIds = [];
1188
+ const walk = (nodes, topLevel) => {
1189
+ const kept = [];
1190
+ for (const step of nodes) {
1191
+ if (!step.enabled) {
1192
+ kept.push(step);
1193
+ continue;
1194
+ }
1195
+ if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
1196
+ prunedAddonIds.push(step.addonId);
1197
+ continue;
1198
+ }
1199
+ kept.push(step.children?.length ? {
1200
+ ...step,
1201
+ children: walk(step.children, false)
1202
+ } : step);
1203
+ }
1204
+ return kept;
1205
+ };
1206
+ return {
1207
+ steps: walk(steps, true),
1208
+ prunedAddonIds
1209
+ };
1210
+ }
978
1211
  //#endregion
979
1212
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
980
1213
  var PipelineModelManager = class {
@@ -1236,6 +1469,126 @@ var PipelineModelManager = class {
1236
1469
  }
1237
1470
  };
1238
1471
  //#endregion
1472
+ //#region src/detection-pipeline/engine/resolve-device-engine.ts
1473
+ /**
1474
+ * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1475
+ * {@link PipelineEngineChoice} used to build that device's inference pool.
1476
+ * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1477
+ * than silently building a wrong pool. Format comes from the shared
1478
+ * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1479
+ */
1480
+ function resolveDeviceEngine(deviceKey) {
1481
+ const colon = deviceKey.indexOf(":");
1482
+ const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1483
+ const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1484
+ const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
1485
+ if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1486
+ return {
1487
+ runtime: "python",
1488
+ backend,
1489
+ format,
1490
+ device
1491
+ };
1492
+ }
1493
+ /**
1494
+ * The inverse of {@link resolveDeviceEngine}: the `<backend>:<device>` key that
1495
+ * names the pool an engine belongs to. Every pool-level log line carries this —
1496
+ * without it a default pool logs anonymously and six hours of logs cannot tell
1497
+ * "the NPU is unused" from "the NPU IS the anonymous default pool".
1498
+ * Falls back to the bare backend when the engine carries no device.
1499
+ */
1500
+ function deviceKeyOf(engine) {
1501
+ return engine.device ? `${engine.backend}:${engine.device}` : engine.backend;
1502
+ }
1503
+ //#endregion
1504
+ //#region src/detection-pipeline/engine/pool-provisioning.ts
1505
+ /** The fields a {@link PoolProvisioningOverride} may carry, in table order. */
1506
+ var OVERRIDABLE_KEYS = [
1507
+ "concurrency",
1508
+ "numWorkers",
1509
+ "batchMode",
1510
+ "windowMs",
1511
+ "maxBatchSize",
1512
+ "numStreams",
1513
+ "intraOpThreads"
1514
+ ];
1515
+ /** Backends that are not a runtime of their own and run on onnxruntime. */
1516
+ var BACKEND_TO_RUNTIME = {
1517
+ onnx: "onnx",
1518
+ cpu: "onnx",
1519
+ cuda: "onnx",
1520
+ openvino: "openvino",
1521
+ coreml: "coreml",
1522
+ edgetpu: "edgetpu"
1523
+ };
1524
+ /**
1525
+ * The registry runtime a device-pool backend provisions from. Unknown backends
1526
+ * floor to `onnx` — the same floor `resolveDeviceEngine` applies to the model
1527
+ * format, so a pool never comes up unprovisioned.
1528
+ */
1529
+ function runtimeIdForBackend(backend) {
1530
+ return BACKEND_TO_RUNTIME[backend] ?? "onnx";
1531
+ }
1532
+ /**
1533
+ * Resolve the provisioning for a pool running `engine`, applying the precedence
1534
+ * documented at the top of this module. `engine.device` is not consulted today
1535
+ * (see the module doc) but is part of the signature so a bench-backed
1536
+ * per-device row lands here and nowhere else.
1537
+ */
1538
+ function resolvePoolProvisioning(engine, override = {}) {
1539
+ const runtime = runtimeIdForBackend(engine.backend);
1540
+ const profile = tuningFor(runtime);
1541
+ return {
1542
+ runtime,
1543
+ overriddenFields: OVERRIDABLE_KEYS.filter((k) => override[k] !== void 0),
1544
+ concurrency: Math.max(1, override.concurrency ?? profile.concurrency),
1545
+ numWorkers: Math.max(1, override.numWorkers ?? profile.numWorkers),
1546
+ batchMode: override.batchMode ?? profile.batchMode,
1547
+ windowMs: override.windowMs ?? profile.windowMs,
1548
+ maxBatchSize: override.maxBatchSize ?? profile.maxBatchSize,
1549
+ numStreams: override.numStreams ?? profile.numStreams,
1550
+ intraOpThreads: override.intraOpThreads ?? profile.intraOpThreads
1551
+ };
1552
+ }
1553
+ function positiveNumber(value) {
1554
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1555
+ }
1556
+ function batchMode(value) {
1557
+ return value === "none" || value === "list" || value === "window" ? value : void 0;
1558
+ }
1559
+ /**
1560
+ * Read the operator override out of the detection addon's settings. Only a
1561
+ * NON-AUTO value counts: the sliders persist `0` and the select persists `''`
1562
+ * for "Auto (backend default)", and those must resolve to the runtime's
1563
+ * registry value, not to a literal 0 worker threads.
1564
+ *
1565
+ * The result is deliberately sparse — an empty object means "nothing
1566
+ * overridden", which is what lets a per-device pool provision from its own
1567
+ * runtime instead of inheriting the node's.
1568
+ */
1569
+ function readOperatorOverride(settings) {
1570
+ const override = {};
1571
+ const concurrency = positiveNumber(settings.concurrency);
1572
+ if (concurrency !== void 0) override.concurrency = concurrency;
1573
+ const numWorkers = positiveNumber(settings.numWorkers);
1574
+ if (numWorkers !== void 0) override.numWorkers = numWorkers;
1575
+ const mode = batchMode(settings.batchMode);
1576
+ if (mode !== void 0) override.batchMode = mode;
1577
+ const windowMs = positiveNumber(settings.windowMs);
1578
+ if (windowMs !== void 0) override.windowMs = windowMs;
1579
+ const maxBatchSize = positiveNumber(settings.maxBatchSize);
1580
+ if (maxBatchSize !== void 0) override.maxBatchSize = maxBatchSize;
1581
+ const numStreams = positiveNumber(settings.numStreams);
1582
+ if (numStreams !== void 0) override.numStreams = numStreams;
1583
+ const intraOpThreads = positiveNumber(settings.intraOpThreads);
1584
+ if (intraOpThreads !== void 0) override.intraOpThreads = intraOpThreads;
1585
+ return override;
1586
+ }
1587
+ /** Two overrides carry the same fields with the same values. */
1588
+ function overridesEqual(a, b) {
1589
+ return OVERRIDABLE_KEYS.every((k) => a[k] === b[k]);
1590
+ }
1591
+ //#endregion
1239
1592
  //#region src/detection-pipeline/engine/engine-factory.ts
1240
1593
  var BACKEND_TO_POOL_RUNTIME = {
1241
1594
  coreml: "coreml",
@@ -1252,7 +1605,7 @@ var RUNTIME_TO_FORMAT = {
1252
1605
  edgetpu: "tflite"
1253
1606
  };
1254
1607
  /** The step whose detections are re-filtered downstream by the executor's
1255
- * per-macro `minConfidence*` sliders (`matchesMacroFilter`). Its pool-side
1608
+ * per-macro `minConfidence*` sliders (`macroFilterVerdict`). Its pool-side
1256
1609
  * floor must stay BELOW those sliders or it silently pre-empts them. */
1257
1610
  var OBJECT_DETECTION_STEP_ID = "object-detection";
1258
1611
  /**
@@ -1305,9 +1658,31 @@ var EngineFactory = class {
1305
1658
  poolManager = null;
1306
1659
  log;
1307
1660
  opts;
1661
+ /**
1662
+ * `<backend>:<device>` — on EVERY log line this factory writes. A pool that
1663
+ * logs anonymously cannot be told apart from another pool of the same runtime
1664
+ * on a different accelerator.
1665
+ */
1666
+ deviceKey;
1667
+ /**
1668
+ * Resolved at CONSTRUCTION, not at pool spawn, so it is inspectable without
1669
+ * starting Python and so a pool that fails to spawn still logs what it was
1670
+ * trying to be.
1671
+ */
1672
+ resolvedProvisioning;
1308
1673
  constructor(opts) {
1309
1674
  this.opts = opts;
1310
1675
  this.log = opts.logger;
1676
+ this.deviceKey = deviceKeyOf(opts.engine);
1677
+ this.resolvedProvisioning = resolvePoolProvisioning(opts.engine, opts.provisioning);
1678
+ }
1679
+ /**
1680
+ * The provisioning this factory's pool runs with — resolved from the pool's
1681
+ * OWN runtime plus any operator override. Read by the effective-tuning
1682
+ * readout and by the tests that pin the per-runtime behaviour.
1683
+ */
1684
+ get provisioning() {
1685
+ return this.resolvedProvisioning;
1311
1686
  }
1312
1687
  /** Detection always uses the Python pool. */
1313
1688
  get usesPythonPool() {
@@ -1409,40 +1784,51 @@ var EngineFactory = class {
1409
1784
  this.poolManager = null;
1410
1785
  }
1411
1786
  }
1787
+ /**
1788
+ * The one pool-init log line. `deviceKey` / `device` are the reason it
1789
+ * exists: the default pool used to log neither, so an operator reading a
1790
+ * day of logs could not tell whether the NPU was idle or was simply the
1791
+ * anonymous default pool.
1792
+ *
1793
+ * `overriddenFields` replaces the old `source` field. `source` claimed
1794
+ * `'auto-default' | 'user-override'` about the CONCURRENCY TUNING but read as
1795
+ * the provenance of the device/engine choice, and was `'user-override'` on
1796
+ * every pool because the addon always passed explicit tuning. This lists the
1797
+ * fields the operator actually overrode — empty means pure registry.
1798
+ */
1799
+ logProvisioning() {
1800
+ const p = this.resolvedProvisioning;
1801
+ this.log.info("Python inference pool", { meta: {
1802
+ deviceKey: this.deviceKey,
1803
+ backend: this.opts.engine.backend,
1804
+ device: this.opts.engine.device ?? null,
1805
+ runtime: p.runtime,
1806
+ concurrency: p.concurrency,
1807
+ numWorkers: p.numWorkers,
1808
+ overriddenFields: p.overriddenFields,
1809
+ tuning: {
1810
+ batchMode: p.batchMode,
1811
+ windowMs: p.windowMs,
1812
+ maxBatchSize: p.maxBatchSize,
1813
+ numStreams: p.numStreams,
1814
+ intraOpThreads: p.intraOpThreads
1815
+ }
1816
+ } });
1817
+ }
1412
1818
  async initPythonPool(steps) {
1819
+ this.logProvisioning();
1413
1820
  const pythonPath = this.opts.pythonPath;
1414
1821
  if (!pythonPath) throw new Error("EngineFactory: pythonPath is required — the addon must call ctx.deps.ensurePython() and pass the result. The embedded portable Python download likely failed; check the addon logs for the download error.");
1415
1822
  const poolRuntime = BACKEND_TO_POOL_RUNTIME[this.opts.engine.backend];
1416
1823
  if (!poolRuntime) throw new Error(`No pool runtime mapping for backend "${this.opts.engine.backend}"`);
1417
- const concurrency = this.opts.concurrency ?? {
1418
- coreml: 1,
1419
- openvino: 1,
1420
- onnxruntime: 4,
1421
- edgetpu: 1
1422
- }[poolRuntime];
1423
- const isAutoDefault = this.opts.concurrency === void 0;
1424
- const defaultBatchMode = {
1425
- coreml: "window",
1426
- openvino: "none",
1427
- onnxruntime: "list",
1428
- edgetpu: "none"
1429
- };
1430
- const tuning = this.opts.tuning ?? {};
1824
+ const { concurrency, numWorkers } = this.resolvedProvisioning;
1431
1825
  const resolvedTuning = {
1432
- batchMode: tuning.batchMode ?? defaultBatchMode[poolRuntime],
1433
- windowMs: tuning.windowMs ?? 8,
1434
- maxBatchSize: tuning.maxBatchSize ?? 8,
1435
- numStreams: tuning.numStreams ?? 0,
1436
- intraOpThreads: tuning.intraOpThreads ?? 0
1826
+ batchMode: this.resolvedProvisioning.batchMode,
1827
+ windowMs: this.resolvedProvisioning.windowMs,
1828
+ maxBatchSize: this.resolvedProvisioning.maxBatchSize,
1829
+ numStreams: this.resolvedProvisioning.numStreams,
1830
+ intraOpThreads: this.resolvedProvisioning.intraOpThreads
1437
1831
  };
1438
- const numWorkers = Math.max(1, this.opts.numWorkers ?? 1);
1439
- this.log.info("Python inference pool", { meta: {
1440
- runtime: poolRuntime,
1441
- concurrency,
1442
- numWorkers,
1443
- source: isAutoDefault ? "auto-default" : "user-override",
1444
- tuning: resolvedTuning
1445
- } });
1446
1832
  this.pool = new SharedInferencePool(pythonPath, poolRuntime, this.log.child("pool"), {
1447
1833
  concurrency,
1448
1834
  tuning: resolvedTuning,
@@ -1498,28 +1884,6 @@ var EngineFactory = class {
1498
1884
  }
1499
1885
  };
1500
1886
  //#endregion
1501
- //#region src/detection-pipeline/engine/resolve-device-engine.ts
1502
- /**
1503
- * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1504
- * {@link PipelineEngineChoice} used to build that device's inference pool.
1505
- * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1506
- * than silently building a wrong pool. Format comes from the shared
1507
- * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1508
- */
1509
- function resolveDeviceEngine(deviceKey) {
1510
- const colon = deviceKey.indexOf(":");
1511
- const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1512
- const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1513
- const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
1514
- if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1515
- return {
1516
- runtime: "python",
1517
- backend,
1518
- format,
1519
- device
1520
- };
1521
- }
1522
- //#endregion
1523
1887
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
1524
1888
  var IdlePoolReaper = class {
1525
1889
  lastUsed = /* @__PURE__ */ new Map();
@@ -1686,563 +2050,153 @@ var EngineProvisioner = class {
1686
2050
  }
1687
2051
  };
1688
2052
  //#endregion
1689
- //#region src/detection-pipeline/postprocess/dispatch.ts
1690
- /** Detector NMS IoU fallback — the historical hardcoded literal, used when the
1691
- * step carries no `nmsIouThreshold` setting (unset == today). */
1692
- var DEFAULT_NMS_IOU = .45;
2053
+ //#region src/detection-pipeline/pipeline/crop-utils.ts
1693
2054
  /**
1694
- * Region plate grammars for the TS fallback CTC decoder (nodejs+onnx path).
1695
- * VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
1696
- * confusables — the confusable repair needs the aligned per-timestep softmax
1697
- * that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
1698
- * the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
2055
+ * Crop utilities ROI extraction and bbox coordinate transforms.
1699
2056
  */
1700
- var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
1701
- var VALID_KINDS = new Set([
1702
- "detections",
1703
- "classifications",
1704
- "embedding",
1705
- "text",
1706
- "mask"
1707
- ]);
1708
2057
  /**
1709
- * Type guard: validates that a structured payload from the Python pool is a
1710
- * well-formed StepOutput discriminated union.
1711
- *
1712
- * The Python inference_pool.py always sets `output.structured` with a `kind`
1713
- * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
1714
- * resorting to double-cast.
2058
+ * Extract a JPEG crop from a full-frame JPEG buffer.
2059
+ * Clamps to image boundaries and returns the cropped JPEG + dimensions.
1715
2060
  */
1716
- function isStepOutput(value) {
1717
- return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2061
+ async function cropJpeg(jpeg, bbox, imageWidth, imageHeight) {
2062
+ const x1 = Math.max(0, Math.round(bbox[0]));
2063
+ const y1 = Math.max(0, Math.round(bbox[1]));
2064
+ const x2 = Math.min(imageWidth, Math.round(bbox[2]));
2065
+ const y2 = Math.min(imageHeight, Math.round(bbox[3]));
2066
+ const w = x2 - x1;
2067
+ const h = y2 - y1;
2068
+ if (w < 1 || h < 1) throw new Error(`Degenerate crop: [${x1},${y1},${x2},${y2}] on ${imageWidth}x${imageHeight}`);
2069
+ return {
2070
+ jpeg: await (0, sharp.default)(jpeg).extract({
2071
+ left: x1,
2072
+ top: y1,
2073
+ width: w,
2074
+ height: h
2075
+ }).jpeg({ quality: 90 }).toBuffer(),
2076
+ width: w,
2077
+ height: h
2078
+ };
1718
2079
  }
1719
2080
  /**
1720
- * Return the structured payload as `StepOutput`, or null if it fails validation.
1721
- * Callers must handle null by proceeding to raw-tensor postprocessing.
2081
+ * Transform a child detection's bbox from ROI-relative to image-absolute coordinates.
2082
+ *
2083
+ * When a child step (e.g., face-detection) runs on a crop of the parent (e.g., person),
2084
+ * its bbox is relative to the crop. This function maps it back to the full image space.
1722
2085
  */
1723
- function tryStructured(output) {
1724
- if (!output.structured) return null;
1725
- if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
1726
- return output.structured;
1727
- }
1728
- function postprocessYolo(output, stepDef, nmsIouThreshold) {
1729
- const structured = tryStructured(output);
1730
- if (structured) return structured;
1731
- const tensor = output.tensor;
1732
- if (!tensor) throw new Error("YOLO postprocessor: no tensor in engine output");
1733
- const labels = stepDef.labels ?? [];
1734
- const numClasses = labels.length || 80;
1735
- const numBoxes = tensor.length / (4 + numClasses);
1736
- const letterbox = output.letterbox;
1737
- const dets = [];
1738
- for (let i = 0; i < numBoxes; i++) {
1739
- const cx = tensor[i];
1740
- const cy = tensor[1 * numBoxes + i];
1741
- const w = tensor[2 * numBoxes + i];
1742
- const h = tensor[3 * numBoxes + i];
1743
- let bestScore = -Infinity;
1744
- let bestClass = 0;
1745
- for (let j = 0; j < numClasses; j++) {
1746
- const score = tensor[(4 + j) * numBoxes + i];
1747
- if (score > bestScore) {
1748
- bestScore = score;
1749
- bestClass = j;
1750
- }
1751
- }
1752
- if (bestScore <= 0) continue;
1753
- let x1 = cx - w / 2;
1754
- let y1 = cy - h / 2;
1755
- let x2 = cx + w / 2;
1756
- let y2 = cy + h / 2;
1757
- if (letterbox) {
1758
- x1 = (x1 - letterbox.padX) / letterbox.scale;
1759
- y1 = (y1 - letterbox.padY) / letterbox.scale;
1760
- x2 = (x2 - letterbox.padX) / letterbox.scale;
1761
- y2 = (y2 - letterbox.padY) / letterbox.scale;
1762
- }
1763
- const label = labels[bestClass] ?? String(bestClass);
1764
- dets.push({
1765
- class: label,
1766
- score: bestScore,
1767
- bbox: [
1768
- x1,
1769
- y1,
1770
- x2,
1771
- y2
1772
- ]
1773
- });
1774
- }
1775
- return {
1776
- kind: "detections",
1777
- detections: simpleNms(dets, nmsIouThreshold)
1778
- };
1779
- }
1780
- function postprocessSsd(output, _stepDef) {
1781
- const structured = tryStructured(output);
1782
- if (structured) return structured;
1783
- throw new Error("SSD postprocessing runs in the Python inference pool (ssd.py); no TypeScript raw-tensor path.");
1784
- }
1785
- function postprocessScrfd(output, stepDef, nmsIouThreshold) {
1786
- const structured = tryStructured(output);
1787
- if (structured) return structured;
1788
- if (!output.tensors) throw new Error("SCRFD postprocessor: no tensors in engine output");
1789
- const strides = [
1790
- 8,
1791
- 16,
1792
- 32
2086
+ function transformBboxToImageSpace(childBbox, parentBbox) {
2087
+ const [px1, py1] = parentBbox;
2088
+ return [
2089
+ px1 + childBbox[0],
2090
+ py1 + childBbox[1],
2091
+ px1 + childBbox[2],
2092
+ py1 + childBbox[3]
1793
2093
  ];
1794
- const anchorsPerStride = 2;
1795
- const inputSize = Math.max(stepDef.models[0]?.inputSize.width ?? 640, stepDef.models[0]?.inputSize.height ?? 640);
1796
- const strideData = matchTensorsToStrides(output.tensors, strides, inputSize, anchorsPerStride);
1797
- const letterbox = output.letterbox;
1798
- const scale = letterbox?.scale ?? 1;
1799
- const padX = letterbox?.padX ?? 0;
1800
- const padY = letterbox?.padY ?? 0;
1801
- const origW = letterbox?.originalWidth ?? inputSize;
1802
- const origH = letterbox?.originalHeight ?? inputSize;
1803
- const candidates = [];
1804
- for (const stride of strides) {
1805
- const data = strideData.get(stride);
1806
- if (!data) continue;
1807
- const { scores, bboxes, landmarks } = data;
1808
- const featSize = Math.ceil(inputSize / stride);
1809
- const numAnchors = featSize * featSize * anchorsPerStride;
1810
- for (let i = 0; i < numAnchors; i++) {
1811
- if (i >= scores.length) break;
1812
- const score = scores[i];
1813
- if (score <= 0) continue;
1814
- const gridIdx = Math.floor(i / anchorsPerStride);
1815
- const cx = (gridIdx % featSize + .5) * stride;
1816
- const cy = (Math.floor(gridIdx / featSize) + .5) * stride;
1817
- const x1 = cx - bboxes[i * 4] * stride;
1818
- const y1 = cy - bboxes[i * 4 + 1] * stride;
1819
- const x2 = cx + bboxes[i * 4 + 2] * stride;
1820
- const y2 = cy + bboxes[i * 4 + 3] * stride;
1821
- const ox1 = Math.max(0, Math.min(origW, (x1 - padX) / scale));
1822
- const oy1 = Math.max(0, Math.min(origH, (y1 - padY) / scale));
1823
- const ox2 = Math.max(0, Math.min(origW, (x2 - padX) / scale));
1824
- const oy2 = Math.max(0, Math.min(origH, (y2 - padY) / scale));
1825
- if (ox2 - ox1 < 1 || oy2 - oy1 < 1) continue;
1826
- const det = {
1827
- class: "face",
1828
- score: Math.round(score * 1e4) / 1e4,
1829
- bbox: [
1830
- Math.round(ox1 * 10) / 10,
1831
- Math.round(oy1 * 10) / 10,
1832
- Math.round(ox2 * 10) / 10,
1833
- Math.round(oy2 * 10) / 10
1834
- ],
1835
- _xyxy: [
1836
- ox1,
1837
- oy1,
1838
- ox2,
1839
- oy2
1840
- ]
1841
- };
1842
- if (landmarks && i * 10 + 9 < landmarks.length) {
1843
- det.landmarks = [];
1844
- for (let p = 0; p < 5; p++) {
1845
- const lx = (cx + landmarks[i * 10 + p * 2] * stride - padX) / scale;
1846
- const ly = (cy + landmarks[i * 10 + p * 2 + 1] * stride - padY) / scale;
1847
- det.landmarks.push({
1848
- x: Math.round(lx * 10) / 10,
1849
- y: Math.round(ly * 10) / 10
1850
- });
1851
- }
1852
- }
1853
- candidates.push(det);
1854
- }
1855
- }
1856
- return {
1857
- kind: "detections",
1858
- detections: simpleNms(candidates.map((c) => ({
1859
- class: c.class,
1860
- score: c.score,
1861
- bbox: c.bbox,
1862
- ...c.landmarks ? { landmarks: c.landmarks } : {}
1863
- })), nmsIouThreshold)
1864
- };
1865
- }
1866
- /** Match ONNX output tensors to SCRFD strides by expected anchor count. */
1867
- function matchTensorsToStrides(tensors, strides, inputSize, anchorsPerStride) {
1868
- const scoresByN = /* @__PURE__ */ new Map();
1869
- const bboxesByN = /* @__PURE__ */ new Map();
1870
- const landmarksByN = /* @__PURE__ */ new Map();
1871
- for (const [, tensor] of Object.entries(tensors)) for (const stride of strides) {
1872
- const featSize = Math.ceil(inputSize / stride);
1873
- const expectedN = featSize * featSize * anchorsPerStride;
1874
- if (tensor.length === expectedN) scoresByN.set(expectedN, tensor);
1875
- else if (tensor.length === expectedN * 4) bboxesByN.set(expectedN, tensor);
1876
- else if (tensor.length === expectedN * 10) landmarksByN.set(expectedN, tensor);
1877
- }
1878
- const result = /* @__PURE__ */ new Map();
1879
- for (const stride of strides) {
1880
- const featSize = Math.ceil(inputSize / stride);
1881
- const n = featSize * featSize * anchorsPerStride;
1882
- const scores = scoresByN.get(n);
1883
- const bboxes = bboxesByN.get(n);
1884
- if (scores && bboxes) result.set(stride, {
1885
- scores,
1886
- bboxes,
1887
- landmarks: landmarksByN.get(n)
1888
- });
1889
- }
1890
- return result;
1891
- }
1892
- function postprocessArcface(output, _stepDef) {
1893
- const structured = tryStructured(output);
1894
- if (structured) return structured;
1895
- const tensor = output.tensor;
1896
- if (!tensor) throw new Error("ArcFace postprocessor: no tensor in engine output");
1897
- let sumSq = 0;
1898
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
1899
- const norm = Math.sqrt(sumSq);
1900
- const normalized = Array.from({ length: tensor.length });
1901
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
1902
- return {
1903
- kind: "embedding",
1904
- embedding: Array.from(tensor),
1905
- embeddingNorm: normalized
1906
- };
1907
- }
1908
- function postprocessClip(output, _stepDef) {
1909
- const structured = tryStructured(output);
1910
- if (structured) return structured;
1911
- const tensor = output.tensor;
1912
- if (!tensor) throw new Error("CLIP postprocessor: no tensor in engine output");
1913
- let sumSq = 0;
1914
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
1915
- const norm = Math.sqrt(sumSq);
1916
- const normalized = Array.from({ length: tensor.length });
1917
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
1918
- return {
1919
- kind: "embedding",
1920
- embedding: Array.from(tensor),
1921
- embeddingNorm: normalized
1922
- };
1923
2094
  }
1924
- function postprocessSoftmax(output, stepDef) {
1925
- const structured = tryStructured(output);
1926
- if (structured) return structured;
1927
- const tensor = output.tensor;
1928
- if (!tensor) throw new Error("Softmax postprocessor: no tensor in engine output");
1929
- const labels = stepDef.labels ?? [];
1930
- let max = -Infinity;
1931
- for (let i = 0; i < tensor.length; i++) if (tensor[i] > max) max = tensor[i];
1932
- const exps = new Float32Array(tensor.length);
1933
- let sum = 0;
1934
- for (let i = 0; i < tensor.length; i++) {
1935
- exps[i] = Math.exp(tensor[i] - max);
1936
- sum += exps[i];
1937
- }
1938
- const classifications = [];
1939
- for (let i = 0; i < exps.length; i++) {
1940
- const score = exps[i] / sum;
1941
- if (score >= .01) classifications.push({
1942
- class: labels[i] ?? String(i),
1943
- score
1944
- });
1945
- }
1946
- classifications.sort((a, b) => b.score - a.score);
1947
- return {
1948
- kind: "classifications",
1949
- classifications: classifications.slice(0, 5)
1950
- };
1951
- }
1952
- function postprocessCtc(output, stepDef) {
1953
- const structured = tryStructured(output);
1954
- if (structured) return structured;
1955
- const tensor = output.tensor;
1956
- if (!tensor) throw new Error("CTC postprocessor: no tensor in engine output");
1957
- const charset = stepDef.charset ?? [];
1958
- const numChars = charset.length || 97;
1959
- const seqLen = Math.floor(tensor.length / numChars);
1960
- const chars = [];
1961
- let totalScore = 0;
1962
- let prev = -1;
1963
- for (let t = 0; t < seqLen; t++) {
1964
- let bestIdx = 0;
1965
- let bestVal = tensor[t * numChars];
1966
- for (let c = 1; c < numChars; c++) {
1967
- const val = tensor[t * numChars + c];
1968
- if (val > bestVal) {
1969
- bestVal = val;
1970
- bestIdx = c;
1971
- }
1972
- }
1973
- totalScore += bestVal;
1974
- if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
1975
- prev = bestIdx;
1976
- }
1977
- const text = chars.join("");
1978
- const region = stepDef.plateRegion;
1979
- if (region !== void 0 && region !== "off") {
1980
- const grammar = TS_PLATE_GRAMMARS[region];
1981
- if (grammar) return {
1982
- kind: "text",
1983
- text,
1984
- confidence: seqLen > 0 ? totalScore / seqLen : 0,
1985
- formatValid: grammar.test(text)
1986
- };
1987
- }
1988
- return {
1989
- kind: "text",
1990
- text,
1991
- confidence: seqLen > 0 ? totalScore / seqLen : 0
1992
- };
2095
+ /**
2096
+ * Check if a bbox is degenerate (zero or negative area).
2097
+ */
2098
+ function isBboxDegenerate(bbox) {
2099
+ return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
1993
2100
  }
2101
+ //#endregion
2102
+ //#region src/detection-pipeline/pipeline/crop-zone.ts
1994
2103
  /**
1995
- * Bounding box [x, y, w, h] (mask-pixel space) of the largest 4-connected
1996
- * foreground component. Port of `_largest_component_bbox` from
1997
- * `python/postprocessors/saliency.py`.
2104
+ * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
2105
+ * bounding box of the UNION of every polygon referenced by the device's
2106
+ * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
2107
+ * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
1998
2108
  *
1999
- * Returns null when there are no foreground pixels.
2109
+ * Returns `null` when there is nothing usable to crop to — no enabled rules, no
2110
+ * referenced geometry, or a degenerate result — in which case the caller falls
2111
+ * back to full-frame inference (behaviour identical to before the crop-zone
2112
+ * plumbing).
2000
2113
  */
2001
- function largestComponentBbox(binary, w, h) {
2002
- const seeds = [];
2003
- for (let row = 0; row < h; row++) for (let col = 0; col < w; col++) if ((binary[row * w + col] ?? 0) > 0) seeds.push([row, col]);
2004
- if (seeds.length === 0) return null;
2005
- const visited = new Uint8Array(w * h);
2006
- let best = null;
2007
- for (const [sy, sx] of seeds) {
2008
- const seedIdx = sy * w + sx;
2009
- if (visited[seedIdx] === 1) continue;
2010
- const stack = [[sy, sx]];
2011
- visited[seedIdx] = 1;
2012
- let minX = sx;
2013
- let maxX = sx;
2014
- let minY = sy;
2015
- let maxY = sy;
2016
- let area = 0;
2017
- while (stack.length > 0) {
2018
- const [cy, cx] = stack.pop();
2019
- area += 1;
2020
- if (cx < minX) minX = cx;
2021
- if (cx > maxX) maxX = cx;
2022
- if (cy < minY) minY = cy;
2023
- if (cy > maxY) maxY = cy;
2024
- const neighbours = [
2025
- [cy - 1, cx],
2026
- [cy + 1, cx],
2027
- [cy, cx - 1],
2028
- [cy, cx + 1]
2029
- ];
2030
- for (const [ny, nx] of neighbours) {
2031
- if (ny < 0 || ny >= h || nx < 0 || nx >= w) continue;
2032
- const nIdx = ny * w + nx;
2033
- if ((binary[nIdx] ?? 0) > 0 && visited[nIdx] !== 1) {
2034
- visited[nIdx] = 1;
2035
- stack.push([ny, nx]);
2036
- }
2037
- }
2114
+ function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
2115
+ if (frameWidth <= 0 || frameHeight <= 0) return null;
2116
+ const enabledRules = packageRules.filter((r) => r.enabled !== false);
2117
+ if (enabledRules.length === 0) return null;
2118
+ const zoneIds = /* @__PURE__ */ new Set();
2119
+ for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
2120
+ if (zoneIds.size === 0) return null;
2121
+ const zoneById = new Map(zones.map((z) => [z.id, z]));
2122
+ let minX = Number.POSITIVE_INFINITY;
2123
+ let minY = Number.POSITIVE_INFINITY;
2124
+ let maxX = Number.NEGATIVE_INFINITY;
2125
+ let maxY = Number.NEGATIVE_INFINITY;
2126
+ let sawPoint = false;
2127
+ for (const id of zoneIds) {
2128
+ const zone = zoneById.get(id);
2129
+ if (!zone || zone.polygon.length === 0) continue;
2130
+ for (const point of zone.polygon) {
2131
+ sawPoint = true;
2132
+ if (point.x < minX) minX = point.x;
2133
+ if (point.y < minY) minY = point.y;
2134
+ if (point.x > maxX) maxX = point.x;
2135
+ if (point.y > maxY) maxY = point.y;
2038
2136
  }
2039
- if (best === null || area > best.area) best = {
2040
- area,
2041
- minX,
2042
- minY,
2043
- maxX,
2044
- maxY
2045
- };
2046
2137
  }
2047
- if (best === null) return null;
2048
- return [
2049
- best.minX,
2050
- best.minY,
2051
- best.maxX - best.minX + 1,
2052
- best.maxY - best.minY + 1
2138
+ if (!sawPoint) return null;
2139
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
2140
+ const bbox = [
2141
+ clamp01(minX) * frameWidth,
2142
+ clamp01(minY) * frameHeight,
2143
+ clamp01(maxX) * frameWidth,
2144
+ clamp01(maxY) * frameHeight
2053
2145
  ];
2146
+ if (isBboxDegenerate(bbox)) return null;
2147
+ return bbox;
2054
2148
  }
2055
- function postprocessSaliency(output, _stepDef) {
2056
- const structured = tryStructured(output);
2057
- if (structured) return structured;
2058
- const tensor = output.tensor;
2059
- if (!tensor) throw new Error("Saliency postprocessor: no tensor in engine output");
2060
- const binary = new Uint8Array(tensor.length);
2061
- for (let i = 0; i < tensor.length; i++) binary[i] = 1 / (1 + Math.exp(-tensor[i])) > .5 ? 255 : 0;
2062
- const side = Math.round(Math.sqrt(tensor.length));
2063
- const mask = Buffer.from(binary).toString("base64");
2064
- const maskBbox = largestComponentBbox(binary, side, side);
2065
- if (maskBbox !== null) return {
2066
- kind: "mask",
2067
- mask,
2068
- maskWidth: side,
2069
- maskHeight: side,
2070
- maskBbox
2071
- };
2072
- return {
2073
- kind: "mask",
2074
- mask,
2075
- maskWidth: side,
2076
- maskHeight: side
2077
- };
2078
- }
2079
- function postprocessYoloSeg(output, stepDef, nmsIouThreshold) {
2080
- const structured = tryStructured(output);
2081
- if (structured) return structured;
2082
- const tensors = output.tensors;
2083
- if (!tensors) throw new Error("YOLO-seg postprocessor: no tensors in engine output");
2084
- const labels = stepDef.labels ?? [];
2085
- const numClasses = labels.length || 80;
2086
- const numMaskCoeffs = 32;
2087
- const protoSize = 160;
2088
- const letterbox = output.letterbox;
2089
- let detTensor;
2090
- let protoTensor;
2091
- let detRows = 300;
2092
- let detCols = 0;
2093
- for (const [, tensor] of Object.entries(tensors)) {
2094
- if (tensor.length === numMaskCoeffs * protoSize * protoSize) {
2095
- protoTensor = tensor;
2096
- continue;
2097
- }
2098
- const cols = 38;
2099
- if (tensor.length % cols === 0) {
2100
- detTensor = tensor;
2101
- detRows = tensor.length / cols;
2102
- detCols = cols;
2103
- continue;
2104
- }
2105
- const rawCols = 4 + numClasses + numMaskCoeffs;
2106
- if (tensor.length % rawCols === 0 && !detTensor) {
2107
- detTensor = tensor;
2108
- detRows = tensor.length / rawCols;
2109
- detCols = rawCols;
2110
- }
2111
- }
2112
- if (!detTensor) throw new Error("YOLO-seg postprocessor: could not find detection tensor");
2113
- if (!protoTensor) throw new Error("YOLO-seg postprocessor: could not find prototype tensor");
2114
- const isNmsFormat = detCols === 38;
2115
- const dets = [];
2116
- for (let i = 0; i < detRows; i++) {
2117
- const offset = i * detCols;
2118
- let x1, y1, x2, y2;
2119
- let bestScore;
2120
- let bestClass;
2121
- let coeffStart;
2122
- if (isNmsFormat) {
2123
- x1 = detTensor[offset];
2124
- y1 = detTensor[offset + 1];
2125
- x2 = detTensor[offset + 2];
2126
- y2 = detTensor[offset + 3];
2127
- bestScore = detTensor[offset + 4];
2128
- bestClass = detTensor[offset + 5];
2129
- coeffStart = offset + 6;
2130
- if (bestScore <= 0) continue;
2131
- if (bestClass < 0) continue;
2132
- } else {
2133
- const cx = detTensor[offset];
2134
- const cy = detTensor[offset + 1];
2135
- const w = detTensor[offset + 2];
2136
- const h = detTensor[offset + 3];
2137
- bestScore = -Infinity;
2138
- bestClass = 0;
2139
- for (let j = 0; j < numClasses; j++) {
2140
- const score = detTensor[offset + 4 + j];
2141
- if (score > bestScore) {
2142
- bestScore = score;
2143
- bestClass = j;
2144
- }
2145
- }
2146
- if (bestScore <= 0) continue;
2147
- x1 = cx - w / 2;
2148
- y1 = cy - h / 2;
2149
- x2 = cx + w / 2;
2150
- y2 = cy + h / 2;
2151
- coeffStart = offset + 4 + numClasses;
2152
- }
2153
- const coeffs = new Float32Array(numMaskCoeffs);
2154
- for (let j = 0; j < numMaskCoeffs; j++) coeffs[j] = detTensor[coeffStart + j];
2155
- const maskPixels = protoSize * protoSize;
2156
- const maskRaw = new Float32Array(maskPixels);
2157
- for (let p = 0; p < maskPixels; p++) {
2158
- let sum = 0;
2159
- for (let c = 0; c < numMaskCoeffs; c++) sum += coeffs[c] * protoTensor[c * maskPixels + p];
2160
- maskRaw[p] = 1 / (1 + Math.exp(-sum));
2161
- }
2162
- const protoScale = protoSize / (stepDef.models[0]?.inputSize.width ?? 640);
2163
- const px1 = Math.max(0, Math.floor(x1 * protoScale));
2164
- const py1 = Math.max(0, Math.floor(y1 * protoScale));
2165
- const px2 = Math.min(protoSize, Math.ceil(x2 * protoScale));
2166
- const py2 = Math.min(protoSize, Math.ceil(y2 * protoScale));
2167
- const cropW = Math.max(1, px2 - px1);
2168
- const cropH = Math.max(1, py2 - py1);
2169
- const cropped = new Uint8Array(cropW * cropH);
2170
- for (let row = 0; row < cropH; row++) for (let col = 0; col < cropW; col++) {
2171
- const srcIdx = (py1 + row) * protoSize + (px1 + col);
2172
- cropped[row * cropW + col] = maskRaw[srcIdx] > .5 ? 255 : 0;
2173
- }
2174
- let ox1 = x1;
2175
- let oy1 = y1;
2176
- let ox2 = x2;
2177
- let oy2 = y2;
2178
- if (letterbox) {
2179
- ox1 = (x1 - letterbox.padX) / letterbox.scale;
2180
- oy1 = (y1 - letterbox.padY) / letterbox.scale;
2181
- ox2 = (x2 - letterbox.padX) / letterbox.scale;
2182
- oy2 = (y2 - letterbox.padY) / letterbox.scale;
2183
- }
2184
- const label = labels[bestClass] ?? String(bestClass);
2185
- const maskB64 = Buffer.from(cropped).toString("base64");
2186
- dets.push({
2187
- class: label,
2188
- score: Math.round(bestScore * 1e4) / 1e4,
2189
- bbox: [
2190
- Math.round(ox1 * 10) / 10,
2191
- Math.round(oy1 * 10) / 10,
2192
- Math.round(ox2 * 10) / 10,
2193
- Math.round(oy2 * 10) / 10
2194
- ],
2195
- mask: maskB64,
2196
- maskWidth: cropW,
2197
- maskHeight: cropH
2198
- });
2199
- }
2200
- const keptBoxes = simpleNms(dets.map((d) => ({
2201
- class: d.class,
2202
- score: d.score,
2203
- bbox: d.bbox
2204
- })), nmsIouThreshold);
2205
- const keptSet = new Set(keptBoxes.map((d) => `${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`));
2206
- return {
2207
- kind: "detections",
2208
- detections: dets.filter((d) => keptSet.has(`${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`))
2209
- };
2210
- }
2211
- function postprocessYamnet(output, _stepDef) {
2212
- const structured = tryStructured(output);
2213
- if (structured) return structured;
2214
- throw new Error("YAMNet TypeScript postprocessing not implemented. Use Python backend or Apple SoundAnalysis.");
2215
- }
2216
- var DISPATCH = {
2217
- yolo: postprocessYolo,
2218
- ssd: postprocessSsd,
2219
- "yolo-seg": postprocessYoloSeg,
2220
- scrfd: postprocessScrfd,
2221
- arcface: postprocessArcface,
2222
- clip: postprocessClip,
2223
- softmax: postprocessSoftmax,
2224
- ctc: postprocessCtc,
2225
- saliency: postprocessSaliency,
2226
- yamnet: postprocessYamnet
2227
- };
2149
+ //#endregion
2150
+ //#region src/detection-pipeline/postprocess/dispatch.ts
2151
+ var VALID_KINDS = new Set([
2152
+ "detections",
2153
+ "classifications",
2154
+ "embedding",
2155
+ "text",
2156
+ "mask"
2157
+ ]);
2158
+ /** Every postprocessor the Python pool implements — the dispatch table's keys
2159
+ * survived the raw-tensor removal as a config-drift guard: an unknown
2160
+ * postprocessor in a step definition still fails fast here. */
2161
+ var KNOWN_POSTPROCESSORS = new Set([
2162
+ "yolo",
2163
+ "ssd",
2164
+ "yolo-seg",
2165
+ "scrfd",
2166
+ "arcface",
2167
+ "clip",
2168
+ "softmax",
2169
+ "ctc",
2170
+ "saliency",
2171
+ "yamnet"
2172
+ ]);
2228
2173
  /**
2229
- * Dispatch postprocessing based on step definition's postprocessor type.
2230
- *
2231
- * For Python backends: output.structured is already populated → passthrough.
2232
- * For Node.js ONNX: raw tensors → postprocess in TypeScript.
2174
+ * Type guard: validates that a structured payload from the Python pool is a
2175
+ * well-formed StepOutput discriminated union.
2233
2176
  *
2234
- * Both paths pass through normalizeOutput() to ensure consistent format
2235
- * (e.g., classification top-K, alternates).
2177
+ * The Python inference_pool.py always sets `output.structured` with a `kind`
2178
+ * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
2179
+ * resorting to double-cast.
2236
2180
  */
2237
- function dispatchPostprocess(output, stepDef, settings) {
2238
- const fn = DISPATCH[stepDef.postprocessor];
2239
- if (!fn) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2240
- const raw = settings?.["nmsIouThreshold"];
2241
- return normalizeOutput(fn(output, stepDef, typeof raw === "number" && raw > 0 ? raw : DEFAULT_NMS_IOU));
2181
+ function isStepOutput(value) {
2182
+ return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2242
2183
  }
2243
2184
  /**
2244
- * Normalize any StepOutput to a consistent format.
2245
- * Single funnel for both Python and TypeScript postprocessor paths.
2185
+ * Validate + normalize a Python-pool engine output into a StepOutput.
2186
+ */
2187
+ function dispatchPostprocess(output, stepDef) {
2188
+ if (output.dropped === true) return {
2189
+ kind: "detections",
2190
+ detections: []
2191
+ };
2192
+ if (!KNOWN_POSTPROCESSORS.has(stepDef.postprocessor)) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2193
+ if (!output.structured) throw new Error(`Postprocessing for step "${stepDef.id}" (${stepDef.postprocessor}) runs in the Python inference pool — the engine output carried no structured payload. The Node.js raw-tensor path was removed; there is nothing to fall back to.`);
2194
+ if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
2195
+ return normalizeOutput(output.structured);
2196
+ }
2197
+ /**
2198
+ * Normalize any StepOutput to a consistent format — the single funnel every
2199
+ * pool payload passes through.
2246
2200
  *
2247
2201
  * Classifications: keep only top-1 in classifications, move full list to alternates.
2248
2202
  */
@@ -2256,78 +2210,103 @@ function normalizeOutput(output) {
2256
2210
  alternates: all
2257
2211
  };
2258
2212
  }
2259
- function simpleNms(dets, iouThreshold) {
2260
- if (dets.length === 0) return [];
2261
- const sorted = [...dets].toSorted((a, b) => b.score - a.score);
2262
- const kept = [];
2263
- const suppressed = /* @__PURE__ */ new Set();
2264
- for (let i = 0; i < sorted.length; i++) {
2265
- if (suppressed.has(i)) continue;
2266
- kept.push(sorted[i]);
2267
- for (let j = i + 1; j < sorted.length; j++) {
2268
- if (suppressed.has(j)) continue;
2269
- if (iou(sorted[i].bbox, sorted[j].bbox) > iouThreshold) suppressed.add(j);
2270
- }
2271
- }
2272
- return kept;
2273
- }
2274
- function iou(a, b) {
2275
- const ix1 = Math.max(a[0], b[0]);
2276
- const iy1 = Math.max(a[1], b[1]);
2277
- const ix2 = Math.min(a[2], b[2]);
2278
- const iy2 = Math.min(a[3], b[3]);
2279
- const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2280
- if (inter === 0) return 0;
2281
- return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
2282
- }
2283
2213
  //#endregion
2284
- //#region src/detection-pipeline/pipeline/crop-utils.ts
2285
- /**
2286
- * Crop utilities ROI extraction and bbox coordinate transforms.
2287
- */
2288
- /**
2289
- * Extract a JPEG crop from a full-frame JPEG buffer.
2290
- * Clamps to image boundaries and returns the cropped JPEG + dimensions.
2291
- */
2292
- async function cropJpeg(jpeg, bbox, imageWidth, imageHeight) {
2293
- const x1 = Math.max(0, Math.round(bbox[0]));
2294
- const y1 = Math.max(0, Math.round(bbox[1]));
2295
- const x2 = Math.min(imageWidth, Math.round(bbox[2]));
2296
- const y2 = Math.min(imageHeight, Math.round(bbox[3]));
2297
- const w = x2 - x1;
2298
- const h = y2 - y1;
2299
- if (w < 1 || h < 1) throw new Error(`Degenerate crop: [${x1},${y1},${x2},${y2}] on ${imageWidth}x${imageHeight}`);
2300
- return {
2301
- jpeg: await (0, sharp.default)(jpeg).extract({
2302
- left: x1,
2303
- top: y1,
2304
- width: w,
2305
- height: h
2306
- }).jpeg({ quality: 90 }).toBuffer(),
2307
- width: w,
2308
- height: h
2309
- };
2310
- }
2311
- /**
2312
- * Transform a child detection's bbox from ROI-relative to image-absolute coordinates.
2313
- *
2314
- * When a child step (e.g., face-detection) runs on a crop of the parent (e.g., person),
2315
- * its bbox is relative to the crop. This function maps it back to the full image space.
2316
- */
2317
- function transformBboxToImageSpace(childBbox, parentBbox) {
2318
- const [px1, py1] = parentBbox;
2319
- return [
2320
- px1 + childBbox[0],
2321
- py1 + childBbox[1],
2322
- px1 + childBbox[2],
2323
- py1 + childBbox[3]
2324
- ];
2214
+ //#region src/detection-pipeline/pipeline/execution-trace.ts
2215
+ var traceCounter = 0;
2216
+ var ExecutionTraceBuilder = class {
2217
+ verbosity;
2218
+ deviceId;
2219
+ frameWidth;
2220
+ frameHeight;
2221
+ engineRuntime;
2222
+ steps = [];
2223
+ startTime;
2224
+ constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
2225
+ this.verbosity = verbosity;
2226
+ this.deviceId = deviceId;
2227
+ this.frameWidth = frameWidth;
2228
+ this.frameHeight = frameHeight;
2229
+ this.engineRuntime = engineRuntime;
2230
+ this.startTime = Date.now();
2231
+ }
2232
+ /** Whether trace collection is active. */
2233
+ get isActive() {
2234
+ return this.verbosity !== "off";
2235
+ }
2236
+ /**
2237
+ * Record a completed step execution.
2238
+ * No-op if verbosity is 'off'.
2239
+ */
2240
+ addStep(params) {
2241
+ if (this.verbosity === "off") return;
2242
+ const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
2243
+ const trace = {
2244
+ stepId: params.stepId,
2245
+ modelId: params.modelId,
2246
+ slot: params.slot,
2247
+ postprocessor: params.postprocessor,
2248
+ preprocessMs: Math.round(params.preprocessMs * 100) / 100,
2249
+ inferenceMs: Math.round(params.inferenceMs * 100) / 100,
2250
+ postprocessMs: Math.round(params.postprocessMs * 100) / 100,
2251
+ totalMs: Math.round(totalMs * 100) / 100,
2252
+ inputType: params.inputType,
2253
+ inputSize: {
2254
+ width: params.inputWidth,
2255
+ height: params.inputHeight
2256
+ },
2257
+ parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
2258
+ outputKind: params.output.kind,
2259
+ outputCount: countOutput(params.output),
2260
+ topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
2261
+ error: params.error
2262
+ };
2263
+ this.steps.push(trace);
2264
+ }
2265
+ /**
2266
+ * Finalize and return the complete execution trace.
2267
+ * Returns null if verbosity is 'off'.
2268
+ */
2269
+ build(detectionCount) {
2270
+ if (this.verbosity === "off") return null;
2271
+ const totalMs = Date.now() - this.startTime;
2272
+ return {
2273
+ traceId: `trace-${Date.now()}-${++traceCounter}`,
2274
+ deviceId: this.deviceId,
2275
+ timestamp: this.startTime,
2276
+ frameSize: {
2277
+ width: this.frameWidth,
2278
+ height: this.frameHeight
2279
+ },
2280
+ engineRuntime: this.engineRuntime,
2281
+ steps: this.steps,
2282
+ totalMs,
2283
+ detectionCount
2284
+ };
2285
+ }
2286
+ };
2287
+ function countOutput(output) {
2288
+ switch (output.kind) {
2289
+ case "detections": return output.detections.length;
2290
+ case "classifications": return output.classifications.length;
2291
+ case "embedding": return 1;
2292
+ case "text": return output.text.length > 0 ? 1 : 0;
2293
+ case "mask": return 1;
2294
+ }
2325
2295
  }
2326
- /**
2327
- * Check if a bbox is degenerate (zero or negative area).
2328
- */
2329
- function isBboxDegenerate(bbox) {
2330
- return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
2296
+ function formatTopResult(output) {
2297
+ switch (output.kind) {
2298
+ case "detections": {
2299
+ const top = output.detections[0];
2300
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2301
+ }
2302
+ case "classifications": {
2303
+ const top = output.classifications[0];
2304
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2305
+ }
2306
+ case "embedding": return `embedding[${output.embedding.length}]`;
2307
+ case "text": return output.text || void 0;
2308
+ case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
2309
+ }
2331
2310
  }
2332
2311
  //#endregion
2333
2312
  //#region src/detection-pipeline/pipeline/face-align.ts
@@ -2635,63 +2614,6 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
2635
2614
  height: aligned.height
2636
2615
  };
2637
2616
  }
2638
- //#endregion
2639
- //#region src/detection-pipeline/pipeline/native-child-crop.ts
2640
- /**
2641
- * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2642
- * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2643
- *
2644
- * On the detail plane the executor runs a child model against a parent crop
2645
- * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2646
- * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2647
- * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2648
- * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2649
- * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2650
- * straight from the frame's retained NATIVE surface (the provider re-composes
2651
- * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2652
- * giving the model native pixels. A miss/degenerate returns `null` so the caller
2653
- * falls back to the existing tile crop (never an upscale — native or the tile).
2654
- */
2655
- /**
2656
- * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2657
- * native pixels wide, so this never binds for plates (effectively uncapped —
2658
- * the quality path the task requires); it only bounds a pathologically large
2659
- * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2660
- */
2661
- var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2662
- /**
2663
- * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2664
- * resolution via `provider`, returning a JPEG crop + its native dimensions.
2665
- * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2666
- * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2667
- * the sharp encode of the returned pixels.
2668
- */
2669
- async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2670
- if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2671
- const x1 = Math.max(0, bbox[0]);
2672
- const y1 = Math.max(0, bbox[1]);
2673
- const x2 = Math.min(imageWidth, bbox[2]);
2674
- const y2 = Math.min(imageHeight, bbox[3]);
2675
- const w = x2 - x1;
2676
- const h = y2 - y1;
2677
- if (w < 1 || h < 1) return null;
2678
- const native = await provider({
2679
- x: x1 / imageWidth,
2680
- y: y1 / imageHeight,
2681
- w: w / imageWidth,
2682
- h: h / imageHeight
2683
- }, maxWidth);
2684
- if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2685
- return {
2686
- jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
2687
- width: native.width,
2688
- height: native.height,
2689
- channels: 3
2690
- } }).jpeg({ quality: 90 }).toBuffer(),
2691
- width: native.width,
2692
- height: native.height
2693
- };
2694
- }
2695
2617
  /**
2696
2618
  * Detection score at/above which a near-full-frame box is trusted as a REAL
2697
2619
  * close subject rather than a phantom. Below this bar a full-frame box is
@@ -2773,6 +2695,63 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
2773
2695
  return false;
2774
2696
  }
2775
2697
  //#endregion
2698
+ //#region src/detection-pipeline/pipeline/native-child-crop.ts
2699
+ /**
2700
+ * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2701
+ * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2702
+ *
2703
+ * On the detail plane the executor runs a child model against a parent crop
2704
+ * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2705
+ * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2706
+ * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2707
+ * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2708
+ * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2709
+ * straight from the frame's retained NATIVE surface (the provider re-composes
2710
+ * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2711
+ * giving the model native pixels. A miss/degenerate returns `null` so the caller
2712
+ * falls back to the existing tile crop (never an upscale — native or the tile).
2713
+ */
2714
+ /**
2715
+ * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2716
+ * native pixels wide, so this never binds for plates (effectively uncapped —
2717
+ * the quality path the task requires); it only bounds a pathologically large
2718
+ * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2719
+ */
2720
+ var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2721
+ /**
2722
+ * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2723
+ * resolution via `provider`, returning a JPEG crop + its native dimensions.
2724
+ * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2725
+ * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2726
+ * the sharp encode of the returned pixels.
2727
+ */
2728
+ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2729
+ if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2730
+ const x1 = Math.max(0, bbox[0]);
2731
+ const y1 = Math.max(0, bbox[1]);
2732
+ const x2 = Math.min(imageWidth, bbox[2]);
2733
+ const y2 = Math.min(imageHeight, bbox[3]);
2734
+ const w = x2 - x1;
2735
+ const h = y2 - y1;
2736
+ if (w < 1 || h < 1) return null;
2737
+ const native = await provider({
2738
+ x: x1 / imageWidth,
2739
+ y: y1 / imageHeight,
2740
+ w: w / imageWidth,
2741
+ h: h / imageHeight
2742
+ }, maxWidth);
2743
+ if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2744
+ return {
2745
+ jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
2746
+ width: native.width,
2747
+ height: native.height,
2748
+ channels: 3
2749
+ } }).jpeg({ quality: 90 }).toBuffer(),
2750
+ width: native.width,
2751
+ height: native.height
2752
+ };
2753
+ }
2754
+ //#endregion
2776
2755
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
2777
2756
  /**
2778
2757
  * Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
@@ -3287,6 +3266,7 @@ function buildFrameResult(input) {
3287
3266
  width: input.width,
3288
3267
  height: input.height,
3289
3268
  detections,
3269
+ ...input.discarded !== void 0 && input.discarded.length > 0 ? { discarded: input.discarded } : {},
3290
3270
  debug: frameDebug
3291
3271
  };
3292
3272
  }
@@ -3296,136 +3276,38 @@ function bboxTupleToRect(bbox) {
3296
3276
  x: x1,
3297
3277
  y: y1,
3298
3278
  width: x2 - x1,
3299
- height: y2 - y1
3300
- };
3301
- }
3302
- function pruneUndefined(obj) {
3303
- const out = {};
3304
- for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3305
- return out;
3306
- }
3307
- function generateFrameId() {
3308
- return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3309
- }
3310
- /** Seed a MutableObjectDetection from a root-level detector output. */
3311
- function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3312
- return {
3313
- id: idGen.next(),
3314
- kind: "first-level",
3315
- macroClass: det.class,
3316
- originalClass: det.class,
3317
- score: det.score,
3318
- bbox: [...det.bbox],
3319
- ownLabels: [],
3320
- alternateLabels: {},
3321
- stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3322
- modelIds: { [rootStep.stepId]: rootStep.modelId },
3323
- extractMode: "full-frame",
3324
- ...det.mask !== void 0 ? {
3325
- mask: det.mask,
3326
- maskWidth: det.maskWidth,
3327
- maskHeight: det.maskHeight
3328
- } : {},
3329
- ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3330
- };
3331
- }
3332
- //#endregion
3333
- //#region src/detection-pipeline/pipeline/execution-trace.ts
3334
- var traceCounter = 0;
3335
- var ExecutionTraceBuilder = class {
3336
- verbosity;
3337
- deviceId;
3338
- frameWidth;
3339
- frameHeight;
3340
- engineRuntime;
3341
- steps = [];
3342
- startTime;
3343
- constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
3344
- this.verbosity = verbosity;
3345
- this.deviceId = deviceId;
3346
- this.frameWidth = frameWidth;
3347
- this.frameHeight = frameHeight;
3348
- this.engineRuntime = engineRuntime;
3349
- this.startTime = Date.now();
3350
- }
3351
- /** Whether trace collection is active. */
3352
- get isActive() {
3353
- return this.verbosity !== "off";
3354
- }
3355
- /**
3356
- * Record a completed step execution.
3357
- * No-op if verbosity is 'off'.
3358
- */
3359
- addStep(params) {
3360
- if (this.verbosity === "off") return;
3361
- const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
3362
- const trace = {
3363
- stepId: params.stepId,
3364
- modelId: params.modelId,
3365
- slot: params.slot,
3366
- postprocessor: params.postprocessor,
3367
- preprocessMs: Math.round(params.preprocessMs * 100) / 100,
3368
- inferenceMs: Math.round(params.inferenceMs * 100) / 100,
3369
- postprocessMs: Math.round(params.postprocessMs * 100) / 100,
3370
- totalMs: Math.round(totalMs * 100) / 100,
3371
- inputType: params.inputType,
3372
- inputSize: {
3373
- width: params.inputWidth,
3374
- height: params.inputHeight
3375
- },
3376
- parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
3377
- outputKind: params.output.kind,
3378
- outputCount: countOutput(params.output),
3379
- topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
3380
- error: params.error
3381
- };
3382
- this.steps.push(trace);
3383
- }
3384
- /**
3385
- * Finalize and return the complete execution trace.
3386
- * Returns null if verbosity is 'off'.
3387
- */
3388
- build(detectionCount) {
3389
- if (this.verbosity === "off") return null;
3390
- const totalMs = Date.now() - this.startTime;
3391
- return {
3392
- traceId: `trace-${Date.now()}-${++traceCounter}`,
3393
- deviceId: this.deviceId,
3394
- timestamp: this.startTime,
3395
- frameSize: {
3396
- width: this.frameWidth,
3397
- height: this.frameHeight
3398
- },
3399
- engineRuntime: this.engineRuntime,
3400
- steps: this.steps,
3401
- totalMs,
3402
- detectionCount
3403
- };
3404
- }
3405
- };
3406
- function countOutput(output) {
3407
- switch (output.kind) {
3408
- case "detections": return output.detections.length;
3409
- case "classifications": return output.classifications.length;
3410
- case "embedding": return 1;
3411
- case "text": return output.text.length > 0 ? 1 : 0;
3412
- case "mask": return 1;
3413
- }
3279
+ height: y2 - y1
3280
+ };
3414
3281
  }
3415
- function formatTopResult(output) {
3416
- switch (output.kind) {
3417
- case "detections": {
3418
- const top = output.detections[0];
3419
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3420
- }
3421
- case "classifications": {
3422
- const top = output.classifications[0];
3423
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3424
- }
3425
- case "embedding": return `embedding[${output.embedding.length}]`;
3426
- case "text": return output.text || void 0;
3427
- case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
3428
- }
3282
+ function pruneUndefined(obj) {
3283
+ const out = {};
3284
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3285
+ return out;
3286
+ }
3287
+ function generateFrameId() {
3288
+ return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3289
+ }
3290
+ /** Seed a MutableObjectDetection from a root-level detector output. */
3291
+ function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3292
+ return {
3293
+ id: idGen.next(),
3294
+ kind: "first-level",
3295
+ macroClass: det.class,
3296
+ originalClass: det.class,
3297
+ score: det.score,
3298
+ bbox: [...det.bbox],
3299
+ ownLabels: [],
3300
+ alternateLabels: {},
3301
+ stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3302
+ modelIds: { [rootStep.stepId]: rootStep.modelId },
3303
+ extractMode: "full-frame",
3304
+ ...det.mask !== void 0 ? {
3305
+ mask: det.mask,
3306
+ maskWidth: det.maskWidth,
3307
+ maskHeight: det.maskHeight
3308
+ } : {},
3309
+ ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3310
+ };
3429
3311
  }
3430
3312
  //#endregion
3431
3313
  //#region src/detection-pipeline/pipeline/executor.ts
@@ -3577,12 +3459,14 @@ var PipelineExecutor = class {
3577
3459
  idGen,
3578
3460
  details
3579
3461
  };
3462
+ let discarded;
3580
3463
  for (const rootStep of tree.roots) {
3581
3464
  if (debug) console.log(`[executor] rootStep=${rootStep.stepId} settings=${JSON.stringify(rootStep.settings ?? {})}`);
3582
3465
  const cropPrep = rootStep.definition.extractMode === "crop-zone" && cropZoneBbox !== void 0 ? await this.prepareCropZoneInput(cropZoneBbox, fullFrameJpegProvider, imageWidth, imageHeight, deviceId) : null;
3583
3466
  const rootStart = Date.now();
3584
3467
  const rawRootOutput = await this.executeStep(rootStep, cropPrep?.input ?? rootInput, cropPrep?.width ?? imageWidth, cropPrep?.height ?? imageHeight, "full-frame", void 0, void 0, traceBuilder, stepTimings, poolAgg);
3585
3468
  const rootMs = Date.now() - rootStart;
3469
+ if (rawRootOutput === null) continue;
3586
3470
  const rootOutput = cropPrep && rawRootOutput.kind === "detections" ? reprojectCropZoneDetections(rawRootOutput, cropPrep.origin) : rawRootOutput;
3587
3471
  if (rootOutput.kind !== "detections") {
3588
3472
  if (isEnrichmentOutput(rootOutput)) {
@@ -3612,10 +3496,35 @@ var PipelineExecutor = class {
3612
3496
  if (mapped) {
3613
3497
  mutable.originalClass = det.class;
3614
3498
  mutable.macroClass = mapped;
3615
- } else if (!rootStep.definition.classMap.preserveOriginal) continue;
3499
+ } else if (!rootStep.definition.classMap.preserveOriginal) {
3500
+ (discarded ??= []).push({
3501
+ bbox: [
3502
+ mutable.bbox[0],
3503
+ mutable.bbox[1],
3504
+ mutable.bbox[2],
3505
+ mutable.bbox[3]
3506
+ ],
3507
+ macroClass: mutable.macroClass,
3508
+ score: mutable.score,
3509
+ reason: "class-filter"
3510
+ });
3511
+ continue;
3512
+ }
3616
3513
  }
3617
- if (!this.matchesMacroFilter(mutable.macroClass, mutable.score, rootStep.settings)) {
3618
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (class or confidence filter)`);
3514
+ const filterVerdict = this.macroFilterVerdict(mutable.macroClass, mutable.score, rootStep.settings);
3515
+ if (filterVerdict !== "pass") {
3516
+ (discarded ??= []).push({
3517
+ bbox: [
3518
+ mutable.bbox[0],
3519
+ mutable.bbox[1],
3520
+ mutable.bbox[2],
3521
+ mutable.bbox[3]
3522
+ ],
3523
+ macroClass: mutable.macroClass,
3524
+ score: mutable.score,
3525
+ reason: filterVerdict
3526
+ });
3527
+ if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (${filterVerdict})`);
3619
3528
  continue;
3620
3529
  }
3621
3530
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
@@ -3634,9 +3543,20 @@ var PipelineExecutor = class {
3634
3543
  hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3635
3544
  }
3636
3545
  });
3546
+ (discarded ??= []).push({
3547
+ bbox: [
3548
+ mutable.bbox[0],
3549
+ mutable.bbox[1],
3550
+ mutable.bbox[2],
3551
+ mutable.bbox[3]
3552
+ ],
3553
+ macroClass: mutable.macroClass,
3554
+ score: mutable.score,
3555
+ reason: "full-frame-guard"
3556
+ });
3637
3557
  continue;
3638
3558
  }
3639
- if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.warn("full-frame box SURVIVED the guard on score", {
3559
+ if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.info("full-frame box SURVIVED the guard on score", {
3640
3560
  tags: { deviceId },
3641
3561
  meta: {
3642
3562
  macroClass: mutable.macroClass,
@@ -3663,6 +3583,7 @@ var PipelineExecutor = class {
3663
3583
  firstLevel.push(mutable);
3664
3584
  }
3665
3585
  }
3586
+ const totalMs = Date.now() - startMs;
3666
3587
  return {
3667
3588
  result: buildFrameResult({
3668
3589
  deviceId,
@@ -3670,8 +3591,9 @@ var PipelineExecutor = class {
3670
3591
  height: imageHeight,
3671
3592
  firstLevel,
3672
3593
  details,
3594
+ discarded,
3673
3595
  stepTimings,
3674
- totalMs: Date.now() - startMs,
3596
+ totalMs,
3675
3597
  engine: this.opts.engineRuntime,
3676
3598
  debug,
3677
3599
  timestamp: Date.now(),
@@ -3776,13 +3698,40 @@ var PipelineExecutor = class {
3776
3698
  });
3777
3699
  throw new Error(`Inference failed for step "${step.stepId}": ${errorMsg}`, { cause: err });
3778
3700
  }
3701
+ if (engineOutput.dropped === true) {
3702
+ stepTimings.push({
3703
+ source: step.stepId,
3704
+ modelId: step.modelId,
3705
+ ms: Date.now() - preprocessStart,
3706
+ detectionCount: 0
3707
+ });
3708
+ if (traceBuilder.isActive) traceBuilder.addStep({
3709
+ stepId: step.stepId,
3710
+ modelId: step.modelId,
3711
+ slot: step.definition.slot,
3712
+ postprocessor: step.definition.postprocessor,
3713
+ preprocessMs,
3714
+ inferenceMs: Date.now() - inferenceStart,
3715
+ postprocessMs: 0,
3716
+ inputType,
3717
+ inputWidth,
3718
+ inputHeight,
3719
+ parentDetection: parentClass,
3720
+ output: {
3721
+ kind: "detections",
3722
+ detections: []
3723
+ },
3724
+ error: `shed:${engineOutput.shedReason ?? "pool-overload"}`
3725
+ });
3726
+ return null;
3727
+ }
3779
3728
  const structured = engineOutput.structured ?? {};
3780
3729
  const inferenceMs = typeof structured.inferenceMs === "number" ? structured.inferenceMs : Date.now() - inferenceStart;
3781
3730
  if (typeof structured.preprocessMs === "number") poolAgg.preprocessMs += structured.preprocessMs;
3782
3731
  if (typeof structured.predictMs === "number") poolAgg.predictMs += structured.predictMs;
3783
3732
  if (typeof structured.batchSize === "number" && structured.batchSize > poolAgg.batchSize) poolAgg.batchSize = structured.batchSize;
3784
3733
  const postprocessStart = Date.now();
3785
- const output = dispatchPostprocess(engineOutput, step.definition, step.settings);
3734
+ const output = dispatchPostprocess(engineOutput, step.definition);
3786
3735
  const postprocessMs = Date.now() - postprocessStart;
3787
3736
  stepTimings.push({
3788
3737
  source: step.stepId,
@@ -3925,6 +3874,7 @@ var PipelineExecutor = class {
3925
3874
  data: cropJpegBuf
3926
3875
  }, cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
3927
3876
  const childMs = Date.now() - childStart;
3877
+ if (childOutput === null) continue;
3928
3878
  const detailsBefore = ctx.details.length;
3929
3879
  applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
3930
3880
  if (childOutput.kind === "detections" && child.children.length > 0) {
@@ -3958,20 +3908,25 @@ var PipelineExecutor = class {
3958
3908
  * Apply the object-detection step's macro filter + per-macro
3959
3909
  * minConfidence sliders introduced in the Phase 6 step rework.
3960
3910
  *
3911
+ * Returns WHICH gate failed rather than a bare boolean so the
3912
+ * discard trail can label the dropped box with the honest reason
3913
+ * (`class-filter` vs `below-threshold`) — the two are remediated
3914
+ * differently (enable the class vs lower a slider).
3915
+ *
3961
3916
  * Expected `settings` shape:
3962
3917
  * - `enabledMacroClasses`: readonly string[] (e.g. ['person','vehicle','animal']).
3963
3918
  * Empty array = all allowed (legacy behaviour).
3964
3919
  * - `minConfidence<Macro>`: number (e.g. `minConfidencePerson: 0.5`)
3965
3920
  */
3966
- matchesMacroFilter(macroClass, score, settings) {
3967
- if (!settings) return true;
3921
+ macroFilterVerdict(macroClass, score, settings) {
3922
+ if (!settings) return "pass";
3968
3923
  const enabled = settings["enabledMacroClasses"];
3969
3924
  if (Array.isArray(enabled) && enabled.length > 0) {
3970
- if (!enabled.includes(macroClass)) return false;
3925
+ if (!enabled.includes(macroClass)) return "class-filter";
3971
3926
  }
3972
3927
  const perMacroThreshold = settings[`minConfidence${capitalize(macroClass)}`];
3973
- if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return false;
3974
- return true;
3928
+ if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return "below-threshold";
3929
+ return "pass";
3975
3930
  }
3976
3931
  };
3977
3932
  function capitalize(s) {
@@ -3979,52 +3934,41 @@ function capitalize(s) {
3979
3934
  return s.charAt(0).toUpperCase() + s.slice(1);
3980
3935
  }
3981
3936
  //#endregion
3982
- //#region src/detection-pipeline/pipeline/crop-zone.ts
3937
+ //#region src/detection-pipeline/pipeline/native-crop-compose.ts
3938
+ /** Clamp `v` into `[lo, hi]`. */
3939
+ function clamp(v, lo, hi) {
3940
+ if (v < lo) return lo;
3941
+ if (v > hi) return hi;
3942
+ return v;
3943
+ }
3983
3944
  /**
3984
- * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
3985
- * bounding box of the UNION of every polygon referenced by the device's
3986
- * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
3987
- * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
3945
+ * Compose a child `roi` given in the parent CROP's normalized space into the
3946
+ * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
3947
+ * frame dimensions. The result is clamped to the unit square (origin in
3948
+ * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
3949
+ * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
3950
+ * clamps to zero area) so the caller falls back to the downscaled tile crop.
3988
3951
  *
3989
- * Returns `null` when there is nothing usable to crop to no enabled rules, no
3990
- * referenced geometry, or a degenerate result — in which case the caller falls
3991
- * back to full-frame inference (behaviour identical to before the crop-zone
3992
- * plumbing).
3952
+ * Purenever mutates its arguments.
3993
3953
  */
3994
- function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
3995
- if (frameWidth <= 0 || frameHeight <= 0) return null;
3996
- const enabledRules = packageRules.filter((r) => r.enabled !== false);
3997
- if (enabledRules.length === 0) return null;
3998
- const zoneIds = /* @__PURE__ */ new Set();
3999
- for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
4000
- if (zoneIds.size === 0) return null;
4001
- const zoneById = new Map(zones.map((z) => [z.id, z]));
4002
- let minX = Number.POSITIVE_INFINITY;
4003
- let minY = Number.POSITIVE_INFINITY;
4004
- let maxX = Number.NEGATIVE_INFINITY;
4005
- let maxY = Number.NEGATIVE_INFINITY;
4006
- let sawPoint = false;
4007
- for (const id of zoneIds) {
4008
- const zone = zoneById.get(id);
4009
- if (!zone || zone.polygon.length === 0) continue;
4010
- for (const point of zone.polygon) {
4011
- sawPoint = true;
4012
- if (point.x < minX) minX = point.x;
4013
- if (point.y < minY) minY = point.y;
4014
- if (point.x > maxX) maxX = point.x;
4015
- if (point.y > maxY) maxY = point.y;
4016
- }
4017
- }
4018
- if (!sawPoint) return null;
4019
- const clamp01 = (v) => Math.max(0, Math.min(1, v));
4020
- const bbox = [
4021
- clamp01(minX) * frameWidth,
4022
- clamp01(minY) * frameHeight,
4023
- clamp01(maxX) * frameWidth,
4024
- clamp01(maxY) * frameHeight
4025
- ];
4026
- if (isBboxDegenerate(bbox)) return null;
4027
- return bbox;
3954
+ function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
3955
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
3956
+ if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
3957
+ const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
3958
+ const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
3959
+ const fw = roi.w * cropFrameSpace.w / frameWidth;
3960
+ const fh = roi.h * cropFrameSpace.h / frameHeight;
3961
+ const x = clamp(fx, 0, 1);
3962
+ const y = clamp(fy, 0, 1);
3963
+ const w = clamp(fw, 0, 1 - x);
3964
+ const h = clamp(fh, 0, 1 - y);
3965
+ if (!(w > 0) || !(h > 0)) return null;
3966
+ return {
3967
+ x,
3968
+ y,
3969
+ w,
3970
+ h
3971
+ };
4028
3972
  }
4029
3973
  //#endregion
4030
3974
  //#region src/detection-pipeline/pipeline/tree-builder.ts
@@ -4260,6 +4204,44 @@ function resolveInputSteps(steps, format, engine) {
4260
4204
  };
4261
4205
  }
4262
4206
  //#endregion
4207
+ //#region src/detection-pipeline/zone-gate.ts
4208
+ function applyZoneRuleGate(result, zones, rules) {
4209
+ if (result.detections.length === 0) return result;
4210
+ if (zones.length === 0 || rules.length === 0) return result;
4211
+ const frameW = result.width;
4212
+ const frameH = result.height;
4213
+ if (frameW === 0 || frameH === 0) return result;
4214
+ const firstLevel = result.detections.filter((d) => d.kind === "first-level");
4215
+ const details = result.detections.filter((d) => d.kind === "detail");
4216
+ const { passed, excluded } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
4217
+ x: (det.bbox.x + det.bbox.width / 2) / frameW,
4218
+ y: (det.bbox.y + det.bbox.height / 2) / frameH
4219
+ }), (det) => det.macroClass);
4220
+ if (passed.length === firstLevel.length) return result;
4221
+ const passedIds = new Set(passed.map((d) => d.id));
4222
+ const filteredDetails = details.filter((d) => {
4223
+ const parentId = d.parentId;
4224
+ return parentId === void 0 || passedIds.has(parentId);
4225
+ });
4226
+ const zoneDiscards = excluded.map((d) => ({
4227
+ bbox: [
4228
+ d.bbox.x,
4229
+ d.bbox.y,
4230
+ d.bbox.x + d.bbox.width,
4231
+ d.bbox.y + d.bbox.height
4232
+ ],
4233
+ macroClass: d.macroClass,
4234
+ score: d.score,
4235
+ reason: "zone-gate"
4236
+ }));
4237
+ const mergedDiscarded = [...result.discarded ?? [], ...zoneDiscards];
4238
+ return {
4239
+ ...result,
4240
+ detections: [...passed, ...filteredDetails],
4241
+ ...mergedDiscarded.length > 0 ? { discarded: mergedDiscarded } : {}
4242
+ };
4243
+ }
4244
+ //#endregion
4263
4245
  //#region src/detection-pipeline/provider.ts
4264
4246
  /**
4265
4247
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4961,8 +4943,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4961
4943
  modelsDir: this.modelsDir,
4962
4944
  logger: this.log.child("engine-verify"),
4963
4945
  pythonPath: this.executorOptions.pythonPath,
4964
- concurrency: 1,
4965
- numWorkers: 1
4946
+ provisioning: {
4947
+ concurrency: 1,
4948
+ numWorkers: 1
4949
+ }
4966
4950
  });
4967
4951
  try {
4968
4952
  await probe.initialize([]);
@@ -5656,19 +5640,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5656
5640
  emit(`Image: ${imageWidth}×${imageHeight}`);
5657
5641
  }
5658
5642
  decodeMs = performance.now() - decodeT0;
5659
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
5660
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
5661
- const resolveEngine = deviceEngine ? {
5662
- backend: deviceEngine.backend,
5663
- device: deviceEngine.device ?? null
5664
- } : input.engine ? {
5665
- backend: input.engine.backend,
5666
- device: input.engine.device ?? null
5667
- } : {
5668
- backend: this.currentEngine.backend,
5669
- device: this.currentEngine.device ?? null
5670
- };
5671
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
5643
+ const dispatchResolution = this.resolveStepsForDispatch({
5644
+ steps: input.steps,
5645
+ deviceKey: input.deviceKey,
5646
+ engineOverride: input.engine,
5647
+ deviceId: input.deviceId,
5648
+ plane: input.plane
5649
+ });
5650
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
5651
+ const benchmarkSteps = dispatchResolution.steps;
5672
5652
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5673
5653
  id: s.addonId,
5674
5654
  settings: s.settings ?? {}
@@ -5718,9 +5698,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5718
5698
  modelsDir: this.modelsDir,
5719
5699
  logger: this.log.child("engine-override"),
5720
5700
  pythonPath: this.executorOptions.pythonPath ?? "",
5721
- concurrency: this.executorOptions.concurrency,
5722
- tuning: this.executorOptions.tuning,
5723
- numWorkers: this.executorOptions.numWorkers
5701
+ provisioning: this.executorOptions.provisioning
5724
5702
  });
5725
5703
  const initPromise = newFactory.initialize([]);
5726
5704
  this.overrideCache = {
@@ -5762,8 +5740,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5762
5740
  logger: this.log
5763
5741
  });
5764
5742
  this.executor = executor;
5765
- const dispatchEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : runEngine;
5766
- const dispatchFactory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
5743
+ const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
5744
+ const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
5767
5745
  const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
5768
5746
  if (needed.length > 0) {
5769
5747
  this.log.info("Benchmark: models to load", { meta: {
@@ -5900,9 +5878,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5900
5878
  modelsDir: this.modelsDir,
5901
5879
  logger: this.log.child("engine-override"),
5902
5880
  pythonPath: this.executorOptions.pythonPath ?? "",
5903
- concurrency: this.executorOptions.concurrency,
5904
- tuning: this.executorOptions.tuning,
5905
- numWorkers: this.executorOptions.numWorkers
5881
+ provisioning: this.executorOptions.provisioning
5906
5882
  });
5907
5883
  const initPromise = newFactory.initialize([]);
5908
5884
  this.overrideCache = {
@@ -6014,6 +5990,142 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6014
5990
  fn();
6015
5991
  }
6016
5992
  /**
5993
+ * Resolve a dispatch's step tree against its target engine, with the
5994
+ * CAPABILITY GATE + node-local engine fallback (cam-615, 2026-08-01;
5995
+ * per-step split 2026-08-01 evening).
5996
+ *
5997
+ * A per-call `deviceKey` (orchestrator device routing / detail-plane step
5998
+ * jump) selects an engine whose format every step EXECUTING IN THIS CALL
5999
+ * must actually ship a model build for. When such a step's catalog has
6000
+ * ZERO builds for that format (so not even `resolveModelForFormat`'s
6001
+ * substitution can rescue it), dispatching anyway means every frame
6002
+ * fails — before this gate it failed as a doomed 3-retry model download,
6003
+ * ~5.5s per frame, forever.
6004
+ *
6005
+ * The gated set is per-plane — the ROOT decides the dispatch device, a
6006
+ * child never bounces it (operator requirement: devices compensate for
6007
+ * each other's missing steps; the original whole-tree gate let ONE
6008
+ * tflite-less child knock the root off an otherwise-capable Coral):
6009
+ *
6010
+ * - `'frame'` (the live per-frame path) executes only root-plane steps,
6011
+ * so only those gate the device. Detail children that can NEVER run on
6012
+ * this device are pruned from THIS dispatch (so `ensureModelsForSteps`
6013
+ * doesn't fail the call on a format build that does not exist) and
6014
+ * logged once — NOT dropped work: each runs later as its own per-track
6015
+ * detail dispatch, where the runner's device-jump resolver
6016
+ * (`resolveStepDevice`) places it on a same-node device that can run
6017
+ * it, or warns no-candidate loudly.
6018
+ * - any other plane (detail subtrees, benchmark, batch) executes the
6019
+ * whole passed tree inline on ONE pool — a single call cannot split
6020
+ * devices — so the whole-tree gate stays.
6021
+ *
6022
+ * Fallback is strictly NODE-LOCAL and ordered: the node's own selected
6023
+ * engine (or the benchmark's explicit `engineOverride`) is the one and only
6024
+ * fallback — frames never migrate to another node from here (that is the
6025
+ * orchestrator's tier). The fallback is logged ONCE per distinct
6026
+ * (deviceKey, steps, format) signature, with `tags: { deviceId }` when the
6027
+ * dispatch is camera-scoped.
6028
+ *
6029
+ * When the node's own engine ALSO cannot run a step, this throws — fast,
6030
+ * before any download attempt — and logs the full picture once (model,
6031
+ * its available formats, both engines tried). A genuinely impossible
6032
+ * configuration must stay loud, not vanish into a silent fallback chain.
6033
+ */
6034
+ resolveStepsForDispatch(args) {
6035
+ const { deviceKey, engineOverride, deviceId } = args;
6036
+ const nodeFormat = engineOverride?.format ?? this.currentEngine.format;
6037
+ const nodeEngine = engineOverride ? {
6038
+ backend: engineOverride.backend,
6039
+ device: engineOverride.device ?? null
6040
+ } : {
6041
+ backend: this.currentEngine.backend,
6042
+ device: this.currentEngine.device ?? null
6043
+ };
6044
+ const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6045
+ const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6046
+ if (deviceKey === void 0 || deviceEngine === void 0) {
6047
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6048
+ this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
6049
+ return {
6050
+ deviceKey: void 0,
6051
+ steps
6052
+ };
6053
+ }
6054
+ const deviceFormat = deviceEngine.format;
6055
+ const deviceStepEngine = {
6056
+ backend: deviceEngine.backend,
6057
+ device: deviceEngine.device ?? null
6058
+ };
6059
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
6060
+ const framePlane = args.plane === "frame";
6061
+ const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6062
+ if (zeroBuild.length === 0) {
6063
+ if (!framePlane) return {
6064
+ deviceKey,
6065
+ steps
6066
+ };
6067
+ const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6068
+ if (pruneResult.prunedAddonIds.length > 0) {
6069
+ const prunedList = pruneResult.prunedAddonIds.join(",");
6070
+ this.logLiveDispatchIssueOnce(`detail-defer:${deviceKey}:${deviceFormat}:${prunedList}`, () => this.log.info("Detail steps have no model build for the dispatch device format — deferred to the detail-plane device jump", {
6071
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6072
+ meta: {
6073
+ deviceKey,
6074
+ deviceFormat,
6075
+ steps: [...pruneResult.prunedAddonIds]
6076
+ }
6077
+ }));
6078
+ }
6079
+ return {
6080
+ deviceKey,
6081
+ steps: pruneResult.steps
6082
+ };
6083
+ }
6084
+ const blockedSteps = zeroBuild.map((issue) => issue.addonId);
6085
+ this.logLiveDispatchIssueOnce(`capability-fallback:${deviceKey}:${deviceFormat}:${blockedSteps.join(",")}`, () => this.log.warn("Device pool cannot run step (no model build for its format) — falling back to the node-local default engine", {
6086
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6087
+ meta: {
6088
+ deviceKey,
6089
+ deviceFormat,
6090
+ steps: blockedSteps,
6091
+ fallbackEngine: nodeEngineLabel
6092
+ }
6093
+ }));
6094
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6095
+ this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
6096
+ return {
6097
+ deviceKey: void 0,
6098
+ steps: fallbackSteps
6099
+ };
6100
+ }
6101
+ /**
6102
+ * The loud, fast half of the capability gate: throw when any ENABLED step
6103
+ * has ZERO model builds for `format` — BEFORE any model download runs. The
6104
+ * error names the step, its chosen model, the formats the catalog actually
6105
+ * ships, and every engine that was tried, and is logged ONCE per distinct
6106
+ * signature (the throw itself still surfaces per dispatch, but costs
6107
+ * microseconds instead of a 3-retry download backoff).
6108
+ */
6109
+ assertStepsRunnable(steps, format, deviceId, enginesTried) {
6110
+ const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
6111
+ if (zeroBuild.length === 0) return;
6112
+ const detail = zeroBuild.map((issue) => {
6113
+ const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
6114
+ const formats = availableFormatsForStep(issue.addonId);
6115
+ return `step "${issue.addonId}" (model "${step?.modelId ?? "unknown"}") has no ${format} build (available: ${formats.join(", ") || "none"})`;
6116
+ }).join("; ");
6117
+ const message = `Pipeline dispatch impossible on this node: ${detail}. Engines tried: ${enginesTried.join(" → ")}.`;
6118
+ this.logLiveDispatchIssueOnce(`unrunnable:${format}:${zeroBuild.map((i) => i.addonId).join(",")}`, () => this.log.error("Pipeline step cannot run on any node-local engine", {
6119
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6120
+ meta: {
6121
+ format,
6122
+ enginesTried: [...enginesTried],
6123
+ detail
6124
+ }
6125
+ }));
6126
+ throw new Error(message);
6127
+ }
6128
+ /**
6017
6129
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
6018
6130
  * callers (every camera that fires motion in the same window calls
6019
6131
  * `runPipeline` → `ensureModelsForSteps`) share the same in-flight
@@ -6040,6 +6152,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6040
6152
  for (const step of needed) {
6041
6153
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6042
6154
  if (modelEntry && !require_model_download_service_Cp9f4dk6.isModelDownloaded(this.modelsDir, modelEntry, format)) {
6155
+ if (modelEntry.formats[format] === void 0) throw new Error(`Model "${modelEntry.id}" has no ${format} format build (available: ${Object.keys(modelEntry.formats).join(", ") || "none"}) — not retrying a permanent format mismatch`);
6043
6156
  this.log.info("Downloading model for step", { meta: {
6044
6157
  modelId: step.modelId,
6045
6158
  format,
@@ -6157,19 +6270,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6157
6270
  }
6158
6271
  async runPipelineBatchImpl(input) {
6159
6272
  if (input.frames.length === 0) return { results: [] };
6160
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
6161
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
6162
- const resolveEngine = deviceEngine ? {
6163
- backend: deviceEngine.backend,
6164
- device: deviceEngine.device ?? null
6165
- } : input.engine ? {
6166
- backend: input.engine.backend,
6167
- device: input.engine.device ?? null
6168
- } : {
6169
- backend: this.currentEngine.backend,
6170
- device: this.currentEngine.device ?? null
6171
- };
6172
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
6273
+ const dispatchResolution = this.resolveStepsForDispatch({
6274
+ steps: input.steps,
6275
+ deviceKey: input.deviceKey,
6276
+ engineOverride: input.engine,
6277
+ deviceId: input.deviceId,
6278
+ plane: void 0
6279
+ });
6280
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
6281
+ const resolveFormat = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey).format : input.engine?.format ?? this.currentEngine.format;
6282
+ const benchmarkSteps = dispatchResolution.steps;
6173
6283
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
6174
6284
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
6175
6285
  const rootStep = enabledSteps[0];
@@ -6191,7 +6301,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6191
6301
  await this.ensureEngineFactory();
6192
6302
  const restoreEngine = await this.applyEngineOverride(input.engine);
6193
6303
  try {
6194
- const factory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
6304
+ const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6195
6305
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
6196
6306
  if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
6197
6307
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
@@ -6208,7 +6318,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6208
6318
  frame,
6209
6319
  deviceId: input.deviceId,
6210
6320
  sessionId: input.sessionId,
6211
- deviceKey: input.deviceKey
6321
+ deviceKey: dispatchDeviceKey
6212
6322
  }))) };
6213
6323
  const items = input.frames.map((frame) => ({
6214
6324
  raw: Buffer.from(frame.data),
@@ -6393,31 +6503,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6393
6503
  * detections).
6394
6504
  */
6395
6505
  gateDetectionsByZoneRules(deviceId, result) {
6396
- if (deviceId <= 0 || result.detections.length === 0) return result;
6506
+ if (deviceId <= 0) return result;
6397
6507
  const proxy = this.deviceProxies.get(deviceId);
6398
6508
  if (!proxy) return result;
6399
- const zones = proxy.state.zones.value?.zones ?? [];
6400
- const rules = proxy.state.zoneRules.value?.detection ?? [];
6401
- if (zones.length === 0 || rules.length === 0) return result;
6402
- const frameW = result.width;
6403
- const frameH = result.height;
6404
- if (frameW === 0 || frameH === 0) return result;
6405
- const firstLevel = result.detections.filter((d) => d.kind === "first-level");
6406
- const details = result.detections.filter((d) => d.kind === "detail");
6407
- const { passed } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
6408
- x: (det.bbox.x + det.bbox.width / 2) / frameW,
6409
- y: (det.bbox.y + det.bbox.height / 2) / frameH
6410
- }), (det) => det.macroClass);
6411
- if (passed.length === firstLevel.length) return result;
6412
- const passedIds = new Set(passed.map((d) => d.id));
6413
- const filteredDetails = details.filter((d) => {
6414
- const parentId = d.parentId;
6415
- return parentId === void 0 || passedIds.has(parentId);
6416
- });
6417
- return {
6418
- ...result,
6419
- detections: [...passed, ...filteredDetails]
6420
- };
6509
+ return applyZoneRuleGate(result, proxy.state.zones.value?.zones ?? [], proxy.state.zoneRules.value?.detection ?? []);
6421
6510
  }
6422
6511
  async cacheFrameInPool(input) {
6423
6512
  await this.ensureEngineFactory();
@@ -6434,13 +6523,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6434
6523
  if (!this.engineFactory) return;
6435
6524
  await this.engineFactory.uncacheFrame(input.frameId);
6436
6525
  }
6526
+ /**
6527
+ * The tuning of the node's DEFAULT pool. Per-device pools each resolve their
6528
+ * own runtime's row, so this is not "the node's tuning" — the per-pool truth
6529
+ * is on each pool's `Python inference pool` log line and in `listLoadedEngines`.
6530
+ */
6437
6531
  async getEffectiveTuning() {
6438
- const t = this.executorOptions.tuning;
6532
+ const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
6439
6533
  return {
6440
- batchMode: t?.batchMode ?? "none",
6441
- windowMs: t?.windowMs ?? 0,
6442
- maxBatchSize: t?.maxBatchSize ?? 1,
6443
- concurrency: this.executorOptions.concurrency ?? 1
6534
+ batchMode: p.batchMode,
6535
+ windowMs: p.windowMs,
6536
+ maxBatchSize: p.maxBatchSize,
6537
+ concurrency: p.concurrency
6444
6538
  };
6445
6539
  }
6446
6540
  /**
@@ -6458,9 +6552,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6458
6552
  modelsDir: this.modelsDir,
6459
6553
  logger: this.log.child("engine"),
6460
6554
  pythonPath: this.executorOptions.pythonPath ?? "",
6461
- concurrency: this.executorOptions.concurrency,
6462
- tuning: this.executorOptions.tuning,
6463
- numWorkers: this.executorOptions.numWorkers
6555
+ provisioning: this.executorOptions.provisioning
6464
6556
  });
6465
6557
  await this.engineFactory.initialize([]);
6466
6558
  }
@@ -6499,13 +6591,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6499
6591
  modelsDir: this.modelsDir,
6500
6592
  logger: this.log.child(`engine:${deviceKey}`),
6501
6593
  pythonPath: this.executorOptions.pythonPath ?? "",
6502
- concurrency: this.executorOptions.concurrency,
6503
- tuning: this.executorOptions.tuning,
6504
- numWorkers: this.executorOptions.numWorkers
6594
+ provisioning: this.executorOptions.provisioning
6505
6595
  });
6506
6596
  try {
6507
6597
  await factory.initialize([]);
6508
6598
  } catch (err) {
6599
+ this.log.error("Device pool failed to initialize", { meta: {
6600
+ deviceKey,
6601
+ backend: engine.backend,
6602
+ device: engine.device ?? null,
6603
+ provisioning: factory.provisioning,
6604
+ error: err instanceof Error ? err.message : String(err)
6605
+ } });
6509
6606
  await factory.dispose().catch(() => void 0);
6510
6607
  throw err;
6511
6608
  }
@@ -6999,12 +7096,49 @@ function stepsToPipelineConfig(steps, engine) {
6999
7096
  } : {}
7000
7097
  };
7001
7098
  }
7002
- /** Flatten a step tree into a flat list (DFS). */
7003
7099
  /** Flatten a step tree into a flat list (DFS) — delegates to shared utility. */
7004
7100
  function flattenSteps(steps) {
7005
7101
  return flattenEnabledVideoSteps(steps);
7006
7102
  }
7007
7103
  /**
7104
+ * Catalog root-vs-detail plane of a step: a non-null `inputClasses` on the
7105
+ * DEFINITION marks a crop child served per-track on the detail plane —
7106
+ * mirrors the executor's frame-plane skip (`executeChildren`) and the
7107
+ * runner's `deriveDetailSteps`. Unknown addonIds (already dropped from
7108
+ * resolved trees) default to root-plane.
7109
+ */
7110
+ function isDetailPlaneStep(addonId) {
7111
+ try {
7112
+ return require_step_definitions.getStepDefinition(addonId).inputClasses !== null;
7113
+ } catch {
7114
+ return false;
7115
+ }
7116
+ }
7117
+ /**
7118
+ * True when the step's FULL catalog ships no model with a build for
7119
+ * `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
7120
+ * addonIds (no catalog to check) are never zero-build, mirroring the
7121
+ * collector's skip.
7122
+ */
7123
+ function stepHasZeroBuildsFor(addonId, format) {
7124
+ return collectZeroBuildIssues([{ addonId }], format).length > 0;
7125
+ }
7126
+ /**
7127
+ * The union of model formats a step's catalog ships ANY build for — the
7128
+ * "Available: …" half of the capability-gate error. Unknown/custom addonIds
7129
+ * yield an empty list (no catalog to enumerate), never a throw.
7130
+ */
7131
+ function availableFormatsForStep(addonId) {
7132
+ try {
7133
+ const def = require_step_definitions.getStepDefinition(addonId);
7134
+ const formats = /* @__PURE__ */ new Set();
7135
+ for (const model of def.models) for (const format of Object.keys(model.formats)) formats.add(format);
7136
+ return Array.from(formats).toSorted();
7137
+ } catch {
7138
+ return [];
7139
+ }
7140
+ }
7141
+ /**
7008
7142
  * Return a shallow-cloned executable tree with `rootStepId`'s `settings`
7009
7143
  * merged with `overrides` (per-device values win). Non-matching root steps
7010
7144
  * pass through unchanged. Used by `runFrame` to apply per-device detection
@@ -7116,21 +7250,22 @@ function toNodePlatform(value) {
7116
7250
  return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
7117
7251
  }
7118
7252
  /**
7119
- * Tuning fields that bind into the Python pool at construction. Any
7120
- * change requires a pool respawn to take effect (the executor caches
7121
- * `tuning` at engine-factory build time). `onConfigChanged` watches
7122
- * these and rebuilds the provider when they flip.
7253
+ * Slider keys on the "Inference executor" section that map 1:1 onto a
7254
+ * {@link RuntimePoolProfile} field. Used to inject the node-default runtime's
7255
+ * registry value as the field's suggested default in `getGlobalSettings`.
7123
7256
  */
7124
- var POOL_BOUND_KEYS = [
7257
+ var PROFILE_SLIDER_KEYS = [
7125
7258
  "concurrency",
7126
- "batchMode",
7259
+ "numWorkers",
7127
7260
  "windowMs",
7128
7261
  "maxBatchSize",
7129
7262
  "numStreams",
7130
- "intraOpThreads",
7131
- "numWorkers"
7263
+ "intraOpThreads"
7132
7264
  ];
7133
- var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.BaseAddon {
7265
+ function isProfileSliderKey(key) {
7266
+ return PROFILE_SLIDER_KEYS.some((k) => k === key);
7267
+ }
7268
+ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7134
7269
  provider = null;
7135
7270
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
7136
7271
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -7145,14 +7280,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7145
7280
  * ENGINE_METRICS_HEARTBEAT_MS for liveness. */
7146
7281
  lastEmittedEngineSnapshot = null;
7147
7282
  /**
7148
- * Last-applied tuning snapshot, taken at the end of `onInitialize`
7149
- * and refreshed at the end of `onConfigChanged`. Lets the change
7150
- * handler diff the current config against the snapshot and decide
7151
- * whether a pool respawn is necessary — most config edits (engine
7152
- * cascade, audio settings) don't touch the tuning fields and don't
7153
- * deserve a multi-second model reload.
7283
+ * Last-applied OPERATOR OVERRIDE, taken at the end of `onInitialize` and
7284
+ * refreshed at the end of `onConfigChanged`. Lets the change handler diff the
7285
+ * current config against the snapshot and decide whether a pool respawn is
7286
+ * necessary — most config edits (engine cascade, audio settings) don't touch
7287
+ * the tuning fields and don't deserve a multi-second model reload.
7288
+ *
7289
+ * The per-runtime registry values are compile-time constants resolved inside
7290
+ * each pool, so they can never be the reason for a respawn; only an operator
7291
+ * edit can.
7154
7292
  */
7155
- lastAppliedPoolConfig = {};
7293
+ lastAppliedOverride = {};
7156
7294
  /**
7157
7295
  * Embedded Python path resolved once at boot via
7158
7296
  * `ctx.deps.ensurePython()`. Empty string means the download failed
@@ -7320,17 +7458,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7320
7458
  ...section,
7321
7459
  fields: section.fields.map((field) => {
7322
7460
  if (field.type === "select") {
7323
- if (field.key === "batchMode" && tuning["batchMode"] !== void 0) return {
7461
+ if (field.key === "batchMode") return {
7324
7462
  ...field,
7325
- default: tuning["batchMode"]
7463
+ default: tuning.batchMode
7326
7464
  };
7327
7465
  }
7328
7466
  if (field.type === "slider" && "key" in field) {
7329
- const tuned = tuning[field.key];
7330
7467
  const sliderField = field;
7331
- let patchedField = typeof tuned === "number" ? {
7468
+ let patchedField = isProfileSliderKey(sliderField.key) ? {
7332
7469
  ...sliderField,
7333
- default: tuned
7470
+ default: tuning[sliderField.key]
7334
7471
  } : sliderField;
7335
7472
  if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
7336
7473
  ...patchedField,
@@ -7439,60 +7576,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7439
7576
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7440
7577
  }
7441
7578
  /**
7442
- * Resolve the effective pool tuning for the configured backend.
7579
+ * The operator's explicit deviations from the per-runtime registry — and
7580
+ * NOTHING else.
7443
7581
  *
7444
- * Reads the registry's `tuningFor(backend)` and ignores any persisted
7445
- * override for `concurrency / batchMode / windowMs / maxBatchSize /
7446
- * numStreams / intraOpThreads`. Stored values are quietly discarded so an old
7447
- * suboptimal user-override (saved when the UI exposed these knobs)
7448
- * cannot resurrect itself after a restart.
7582
+ * This used to be `resolveBackendTuning()`: it read `tuningFor(<the node's
7583
+ * default backend>)` and handed the whole resolved row to the provider, which
7584
+ * passed it verbatim to every pool it built. That is how an `openvino:npu`
7585
+ * pool on an onnx-default node came up with onnx's `concurrency: 4`. The
7586
+ * per-runtime values now resolve inside each pool
7587
+ * (`engine/pool-provisioning.ts`), keyed by that pool's own runtime, so
7588
+ * nothing backend-shaped may be computed here.
7449
7589
  *
7450
- * The matrix sweep (`bench-pool-matrix.py` + `bench-coreml-patterns.py`)
7451
- * already chose the best per-backend combo; the operator no longer has
7452
- * a reason to disagree.
7590
+ * Precedence, in full: an operator override below wins over the pool's
7591
+ * runtime registry row; an "Auto" field (slider `0`, select `''`) is not an
7592
+ * override. An override is node-wide by design — the operator set it against
7593
+ * a node, not against a runtime.
7453
7594
  */
7454
- resolveBackendTuning() {
7455
- const t = tuningFor(this.provider?.resolvedEngineBackend() ?? "onnx");
7456
- const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7457
- const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7458
- return {
7459
- concurrency: num(t["concurrency"], 1),
7460
- batchMode: batch(t["batchMode"], "window"),
7461
- windowMs: num(t["windowMs"], 2),
7462
- maxBatchSize: num(t["maxBatchSize"], 8),
7463
- numStreams: num(t["numStreams"], 0),
7464
- intraOpThreads: num(t["intraOpThreads"], 0),
7465
- numWorkers: num(t["numWorkers"], 1)
7466
- };
7595
+ resolveOperatorOverride() {
7596
+ return readOperatorOverride(this.config);
7467
7597
  }
7468
7598
  /**
7469
- * Construct a fresh provider with the given effective tuning. Single source of
7470
- * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7471
- * tuning reconciliation, and the `onConfigChanged` pool respawn.
7599
+ * Construct a fresh provider carrying the operator override. Single source of
7600
+ * truth for the provider wiring — shared by `onInitialize` and the
7601
+ * `onConfigChanged` pool respawn. NOTHING backend-derived is passed: each
7602
+ * pool resolves its own runtime's registry row.
7472
7603
  */
7473
- buildProvider(modelsDir, tuning) {
7604
+ buildProvider(modelsDir, override) {
7474
7605
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7475
7606
  return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7476
- concurrency: tuning.concurrency,
7477
- tuning: {
7478
- batchMode: tuning.batchMode,
7479
- windowMs: tuning.windowMs,
7480
- maxBatchSize: tuning.maxBatchSize,
7481
- numStreams: tuning.numStreams,
7482
- intraOpThreads: tuning.intraOpThreads
7483
- },
7484
- numWorkers: tuning.numWorkers,
7607
+ provisioning: override,
7485
7608
  pythonPath: this.pythonPath,
7486
7609
  pythonAddonDir: this.pythonAddonDir
7487
7610
  });
7488
7611
  }
7489
- /**
7490
- * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7491
- * post-resolve / config-change respawn is actually needed).
7492
- */
7493
- static poolTuningEqual(a, b) {
7494
- return a.concurrency === b.concurrency && a.batchMode === b.batchMode && a.windowMs === b.windowMs && a.maxBatchSize === b.maxBatchSize && a.numStreams === b.numStreams && a.intraOpThreads === b.intraOpThreads && a.numWorkers === b.numWorkers;
7495
- }
7496
7612
  async onInitialize() {
7497
7613
  const modelsDir = await this.resolveModelsDir();
7498
7614
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
@@ -7501,23 +7617,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7501
7617
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7502
7618
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7503
7619
  await this.proactivelyInstallOpenvino();
7504
- const initialTuning = this.resolveBackendTuning();
7505
- this.provider = this.buildProvider(modelsDir, initialTuning);
7620
+ this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
7506
7621
  await this.provider.init();
7507
7622
  await this.provider.setApi(this.ctx);
7508
- const resolvedTuning = this.resolveBackendTuning();
7509
- if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7510
- this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7511
- from: initialTuning,
7512
- to: resolvedTuning
7513
- } });
7514
- this.provider = this.buildProvider(modelsDir, resolvedTuning);
7515
- await this.provider.init();
7516
- await this.provider.setApi(this.ctx);
7517
- }
7518
7623
  await this.provider.warmPool();
7519
7624
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7520
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7625
+ this.lastAppliedOverride = this.resolveOperatorOverride();
7521
7626
  return [{
7522
7627
  capability: require_dist.pipelineExecutorCapability,
7523
7628
  provider: this.provider
@@ -7603,36 +7708,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7603
7708
  }
7604
7709
  }
7605
7710
  /**
7606
- * Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
7607
- * `resolveBackendTuning`). Stored config values for these fields are
7608
- * ignored, so this snapshot only changes when `engineBackend` flips
7609
- * onto a different `tuningFor` row.
7610
- */
7611
- snapshotPoolConfig() {
7612
- const t = this.resolveBackendTuning();
7613
- return {
7614
- concurrency: t.concurrency ?? 0,
7615
- batchMode: t.batchMode,
7616
- windowMs: t.windowMs,
7617
- maxBatchSize: t.maxBatchSize,
7618
- numStreams: t.numStreams,
7619
- intraOpThreads: t.intraOpThreads,
7620
- numWorkers: t.numWorkers
7621
- };
7622
- }
7623
- poolConfigChanged() {
7624
- const next = this.snapshotPoolConfig();
7625
- const prev = this.lastAppliedPoolConfig;
7626
- for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
7627
- return false;
7628
- }
7629
- /**
7630
7711
  * BaseAddon calls `onConfigChanged` after every settings write. This is the
7631
7712
  * SOLE apply path for tuning changes — none of those fields declare
7632
7713
  * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7633
- * every model on every set, which is exactly what we want to avoid): pool-bound
7634
- * tuning (`concurrency`/`batchMode`/`windowMs`/…) drives an in-place pool
7635
- * respawn when the snapshot flips (`poolConfigChanged` below). The engine
7714
+ * every model on every set, which is exactly what we want to avoid): a changed
7715
+ * OPERATOR OVERRIDE drives an in-place pool respawn. The per-runtime registry
7716
+ * values cannot change at runtime, so they can never trigger one. The engine
7636
7717
  * SELECTION is no longer operator-facing — the node-default backend is derived
7637
7718
  * once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
7638
7719
  * is no per-write engine re-select. The respawn applies optimistically — the
@@ -7640,14 +7721,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7640
7721
  * crashed), no addon bounce.
7641
7722
  */
7642
7723
  async onConfigChanged() {
7643
- if (!this.poolConfigChanged()) return;
7724
+ const next = this.resolveOperatorOverride();
7725
+ if (overridesEqual(next, this.lastAppliedOverride)) return;
7644
7726
  if (!this.provider) {
7645
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7727
+ this.lastAppliedOverride = next;
7646
7728
  return;
7647
7729
  }
7648
- this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
7649
- prev: this.lastAppliedPoolConfig,
7650
- next: this.snapshotPoolConfig()
7730
+ this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
7731
+ prev: this.lastAppliedOverride,
7732
+ next
7651
7733
  } });
7652
7734
  try {
7653
7735
  await this.provider.shutdown();
@@ -7655,11 +7737,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7655
7737
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7656
7738
  }
7657
7739
  const modelsDir = await this.resolveModelsDir();
7658
- this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7740
+ this.provider = this.buildProvider(modelsDir, next);
7659
7741
  await this.provider.init();
7660
7742
  await this.provider.setApi(this.ctx);
7661
7743
  await this.provider.warmPool();
7662
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7744
+ this.lastAppliedOverride = next;
7663
7745
  this.ctx.logger.info("detection-pipeline pool respawn complete");
7664
7746
  }
7665
7747
  };