@camstack/addon-pipeline 1.2.31 → 1.2.37

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 (70) hide show
  1. package/dist/detection-pipeline/index.js +999 -978
  2. package/dist/detection-pipeline/index.mjs +999 -978
  3. package/dist/pipeline-runner/index.js +30 -4
  4. package/dist/pipeline-runner/index.mjs +30 -4
  5. package/dist/{step-definitions-BkY9_IM6.mjs → step-definitions-C8tk9fPJ.mjs} +11 -2
  6. package/dist/{step-definitions-gC-Ovglc.js → step-definitions-i8O-hxyI.js} +16 -1
  7. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BUXeX-vb.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C5m4hC2K.mjs} +3 -3
  8. package/dist/stream-broker/{hostInit-DJ3h-b1X.mjs → hostInit-WXFrgppm.mjs} +3 -3
  9. package/dist/stream-broker/remoteEntry.js +1 -1
  10. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B0DyP46h.js → MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js} +1 -1
  11. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CzGSy_EO.js → MotionZonesSettings-NcxxQN8r-9D-BWwQP.js} +1 -1
  12. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CEgMcOOW.js → PrivacyMaskSettings-APgPLF7p-HvAetfA4.js} +1 -1
  13. package/embed-dist/assets/{index-CHXOWKHF.js → index-9Ob2YrIf.js} +5 -5
  14. package/embed-dist/index.html +1 -1
  15. package/package.json +1 -1
  16. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  17. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  18. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  19. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  20. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  21. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  22. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  23. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  24. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  25. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  26. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  29. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  30. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  31. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  32. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  33. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  34. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  35. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  36. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  37. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  38. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  39. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  40. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  41. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  42. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  43. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  44. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  45. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  46. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  47. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  48. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  50. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  51. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  52. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  53. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  54. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  55. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  56. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  57. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  58. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  59. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  60. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  61. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  64. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  66. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  67. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  68. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  69. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  70. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -4,7 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
6
  const require_dist = require("../dist-BvFLZyhW.js");
7
- const require_step_definitions = require("../step-definitions-gC-Ovglc.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
10
  let node_os = require("node:os");
@@ -16,6 +16,7 @@ let node_fs = require("node:fs");
16
16
  node_fs = require_chunk.__toESM(node_fs);
17
17
  let node_path = require("node:path");
18
18
  node_path = require_chunk.__toESM(node_path);
19
+ let node_perf_hooks = require("node:perf_hooks");
19
20
  //#region src/detection-pipeline/runtimes.ts
20
21
  var KNOWN_PLATFORMS = [
21
22
  "darwin",
@@ -251,43 +252,6 @@ function normalizeEngineNodeId(rawNodeId) {
251
252
  return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
252
253
  }
253
254
  //#endregion
254
- //#region src/detection-pipeline/pipeline/native-crop-compose.ts
255
- /** Clamp `v` into `[lo, hi]`. */
256
- function clamp(v, lo, hi) {
257
- if (v < lo) return lo;
258
- if (v > hi) return hi;
259
- return v;
260
- }
261
- /**
262
- * Compose a child `roi` given in the parent CROP's normalized space into the
263
- * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
264
- * frame dimensions. The result is clamped to the unit square (origin in
265
- * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
266
- * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
267
- * clamps to zero area) so the caller falls back to the downscaled tile crop.
268
- *
269
- * Pure — never mutates its arguments.
270
- */
271
- function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
272
- if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
273
- if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
274
- const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
275
- const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
276
- const fw = roi.w * cropFrameSpace.w / frameWidth;
277
- const fh = roi.h * cropFrameSpace.h / frameHeight;
278
- const x = clamp(fx, 0, 1);
279
- const y = clamp(fy, 0, 1);
280
- const w = clamp(fw, 0, 1 - x);
281
- const h = clamp(fh, 0, 1 - y);
282
- if (!(w > 0) || !(h > 0)) return null;
283
- return {
284
- x,
285
- y,
286
- w,
287
- h
288
- };
289
- }
290
- //#endregion
291
255
  //#region src/detection-pipeline/default-detection-model.ts
292
256
  /** The object-detection step id — the only slot this resolver applies to. */
293
257
  var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
@@ -338,10 +302,12 @@ function classifyAccelerator(backend, device) {
338
302
  *
339
303
  * Guarantees:
340
304
  * - never throws (hot default-tree path);
341
- * - never returns a model id that is not in the object-detection catalog
342
- * with a build for `format` an unmapped backend, a `'cpu'` class, a
343
- * mapped id missing from the catalog, or a mapped id without a `format`
344
- * build all fall back to the step's own `defaultModelId` (`yolo26n`).
305
+ * - never returns a model id without a build for `format` (unless the step
306
+ * has ZERO builds for `format`, which is unloadable regardless of pick and
307
+ * flagged by `collectZeroBuildIssues`) an unmapped backend, a `'cpu'`
308
+ * class, a mapped id missing from the catalog, or a mapped id without a
309
+ * `format` build all fall back to the step's PER-FORMAT default
310
+ * (`getDefaultModelForFormatFromDef`), NOT the bare `defaultModelId`.
345
311
  *
346
312
  * `getStepDef` is injectable (defaults to the real catalog lookup) so the
347
313
  * missing-build fallback branch is unit-testable without a live catalog —
@@ -354,12 +320,12 @@ function resolveDefaultDetectionModel(backend, device, format, getStepDef = requ
354
320
  } catch {
355
321
  return "yolo26n";
356
322
  }
357
- const fallback = def.defaultModelId;
358
323
  const candidate = MODEL_BY_CLASS[classifyAccelerator(backend, device)];
359
- if (candidate === null) return fallback;
360
- const entry = def.models.find((m) => m.id === candidate);
361
- if (!entry || entry.formats[format] === void 0) return fallback;
362
- return candidate;
324
+ if (candidate !== null) {
325
+ const entry = def.models.find((m) => m.id === candidate);
326
+ if (entry && entry.formats[format] !== void 0) return candidate;
327
+ }
328
+ return require_step_definitions.getDefaultModelForFormatFromDef(def, format);
363
329
  }
364
330
  //#endregion
365
331
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
@@ -476,10 +442,7 @@ var PoolHandle = class {
476
442
  }
477
443
  async infer(input) {
478
444
  const start = performance.now();
479
- return {
480
- 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),
481
- inferenceMs: performance.now() - start
482
- };
445
+ 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);
483
446
  }
484
447
  /**
485
448
  * Inference on a frame previously cached in the Python pool via
@@ -488,20 +451,50 @@ var PoolHandle = class {
488
451
  */
489
452
  async inferFromCache(frameId) {
490
453
  const start = performance.now();
491
- return {
492
- structured: await this.pool.inferCached(this.modelIndex, frameId),
493
- inferenceMs: performance.now() - start
494
- };
454
+ return poolResultToEngineOutput(await this.pool.inferCached(this.modelIndex, frameId), performance.now() - start);
495
455
  }
496
456
  async dispose() {}
497
457
  };
498
458
  /**
459
+ * Map a raw pool response record to an {@link EngineOutput}.
460
+ *
461
+ * An overload shed (`{dropped: true}` — from the Python pool's per-model bound
462
+ * OR the TS worker in-flight cap, which deliberately mirrors it) becomes a
463
+ * first-class `EngineOutput.dropped` with NO `structured` payload. Before this
464
+ * mapping the shed record rode through as `structured`, failed the StepOutput
465
+ * kind guard in the postprocess funnel, and every deliberate shed was logged
466
+ * as `runInference failed … unexpected kind: undefined` at error level on
467
+ * every node (~4/s cluster-wide, 2026-08-01). A shed is flow control, not a
468
+ * fault — it must never reach a postprocessor. Exported for tests.
469
+ */
470
+ function poolResultToEngineOutput(result, inferenceMs) {
471
+ if (result["dropped"] === true) {
472
+ const shedReason = result["shedReason"];
473
+ return {
474
+ dropped: true,
475
+ ...typeof shedReason === "string" ? { shedReason } : {},
476
+ inferenceMs
477
+ };
478
+ }
479
+ return {
480
+ structured: result,
481
+ inferenceMs
482
+ };
483
+ }
484
+ /**
499
485
  * How long to wait for a worker to exit on SIGTERM before escalating to
500
486
  * SIGKILL. A worker idle between frames exits well under this; only a worker
501
487
  * stuck in a native inference call (openvino/onnx C++) needs the escalation.
502
488
  */
503
489
  var POOL_WORKER_TERM_GRACE_MS = 2e3;
504
490
  /**
491
+ * Sampling stride for the "Python pool shed frame under overload" debug
492
+ * line — see `trackDroppedResponse`. At the observed chronic-overload rate
493
+ * (~13 shed/s, 2026-08-01) a stride of 500 yields roughly one line every
494
+ * 40 s while `droppedTotal` in the line keeps the exact count.
495
+ */
496
+ var SHED_LOG_SAMPLE_EVERY = 500;
497
+ /**
505
498
  * Terminate a spawned child GRACEFULLY THEN FORCEFULLY: end stdin, send
506
499
  * SIGTERM, and if the process hasn't exited within `graceMs`, send SIGKILL.
507
500
  * Resolves once the process has exited (or was already dead).
@@ -605,8 +598,12 @@ var PoolWorker = class {
605
598
  this.ready = false;
606
599
  });
607
600
  this.process.stdout.on("data", (chunk) => {
601
+ const t0 = Date.now();
608
602
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
603
+ this.recvConcatMs += Date.now() - t0;
604
+ if (this.receiveBuffer.length > this.recvPeakBytes) this.recvPeakBytes = this.receiveBuffer.length;
609
605
  this.drainResponses();
606
+ this.reportRecvCostIfDue();
610
607
  });
611
608
  const config = {
612
609
  runtime: this.opts.poolRuntime,
@@ -793,7 +790,7 @@ var PoolWorker = class {
793
790
  device: this.opts.device ?? "default",
794
791
  inFlight: this.pending.size,
795
792
  reqId,
796
- timeoutMs: POOL_INFER_TIMEOUT_MS
793
+ timeoutMs
797
794
  } });
798
795
  reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
799
796
  }
@@ -849,6 +846,36 @@ var PoolWorker = class {
849
846
  ensureReady() {
850
847
  if (!this.ready || !this.process?.stdin) throw new Error(`PoolWorker[${this.opts.workerLabel}]: not initialized`);
851
848
  }
849
+ /** Time spent in `Buffer.concat` since the last report. */
850
+ recvConcatMs = 0;
851
+ /** Largest the receive buffer has grown since the last report. */
852
+ recvPeakBytes = 0;
853
+ /** When the last receive-cost report was emitted. */
854
+ recvReportedAt = 0;
855
+ /**
856
+ * Emit the receive-path cost at most once every 10s, and only when it is
857
+ * actually interesting — silence when the reader costs nothing is the whole
858
+ * point, otherwise this becomes another line nobody reads.
859
+ */
860
+ reportRecvCostIfDue() {
861
+ const now = Date.now();
862
+ if (this.recvReportedAt === 0) this.recvReportedAt = now;
863
+ if (now - this.recvReportedAt < 1e4) return;
864
+ const concatMs = this.recvConcatMs;
865
+ const peak = this.recvPeakBytes;
866
+ this.recvConcatMs = 0;
867
+ this.recvPeakBytes = 0;
868
+ this.recvReportedAt = now;
869
+ if (concatMs < 250) return;
870
+ this.log.warn("pool receive path is copying", { meta: {
871
+ worker: this.opts.workerLabel,
872
+ runtime: this.opts.poolRuntime,
873
+ device: this.opts.device ?? "default",
874
+ concatMs,
875
+ peakBufferKb: Math.round(peak / 1024),
876
+ windowMs: 1e4
877
+ } });
878
+ }
852
879
  drainResponses() {
853
880
  while (this.receiveBuffer.length >= 8) {
854
881
  const totalLen = this.receiveBuffer.readUInt32LE(0);
@@ -1063,13 +1090,23 @@ var SharedInferencePool = class {
1063
1090
  * single-frame inference paths. Previously indistinguishable from a
1064
1091
  * genuine empty detection result — now counted + debug-logged so
1065
1092
  * overload is measurable. The response passes through unchanged.
1093
+ *
1094
+ * SAMPLED log: one line per shed frame turned into 45 741 rows/hour on
1095
+ * the hub during the 2026-08-01 chronic overload (~13 shed/s) — 2/3 of
1096
+ * the hub's entire log volume, burying every other signal. The counter
1097
+ * keeps exact totals (`droppedTotal` is authoritative, surfaced in
1098
+ * `getStatus` consumers via `getDroppedResponseCount`); the log line
1099
+ * now fires on the FIRST shed and every `SHED_LOG_SAMPLE_EVERY`th,
1100
+ * carrying the running total so nothing is lost — only repeated.
1066
1101
  */
1067
1102
  trackDroppedResponse(result, modelIndex) {
1068
1103
  if (result["dropped"] === true) {
1069
1104
  this.droppedResponseCount++;
1070
- this.log.debug("Python pool shed frame under overload", { meta: {
1105
+ const total = this.droppedResponseCount;
1106
+ if (total === 1 || total % SHED_LOG_SAMPLE_EVERY === 0) this.log.debug("Python pool shed frame under overload", { meta: {
1071
1107
  modelIndex,
1072
- droppedTotal: this.droppedResponseCount,
1108
+ droppedTotal: total,
1109
+ sampledEvery: SHED_LOG_SAMPLE_EVERY,
1073
1110
  runtime: this.poolRuntime,
1074
1111
  device: this.device ?? "default"
1075
1112
  } });
@@ -1146,6 +1183,66 @@ function flattenEnabledVideoSteps(steps) {
1146
1183
  walk(steps);
1147
1184
  return result;
1148
1185
  }
1186
+ /**
1187
+ * The steps that EXECUTE in a `plane: 'frame'` dispatch (two-plane design),
1188
+ * mirroring the executor's frame-plane skip in `executeChildren`: every
1189
+ * enabled top-level step runs at the root; a CHILD runs only when its catalog
1190
+ * definition is itself root-plane (`isDetailStep(addonId)` false). The walk
1191
+ * continues only through steps that execute — a skipped detail child's
1192
+ * descendants ride ITS per-track detail-subtree call, never this one.
1193
+ * Disabled and audio-classifier steps execute on neither plane.
1194
+ */
1195
+ function collectFramePlaneSteps(steps, isDetailStep) {
1196
+ const result = [];
1197
+ const walk = (nodes, topLevel) => {
1198
+ for (const step of nodes) {
1199
+ if (!step.enabled) continue;
1200
+ if (step.slot === "audio-classifier") continue;
1201
+ if (!topLevel && isDetailStep(step.addonId)) continue;
1202
+ result.push(step);
1203
+ if (step.children?.length) walk(step.children, false);
1204
+ }
1205
+ };
1206
+ walk(steps, true);
1207
+ return result;
1208
+ }
1209
+ /**
1210
+ * Immutably remove from a frame-plane dispatch tree every enabled DETAIL
1211
+ * subtree (at any depth below the top level) whose top step `isUnrunnable`.
1212
+ * Those steps can never load into the dispatch device's pool — leaving them
1213
+ * in the tree makes `ensureModelsForSteps` fail the whole call on a format
1214
+ * build that does not exist. They are NOT dropped work: each runs later as
1215
+ * its own detail-subtree dispatch, where the device-jump resolver
1216
+ * (`resolveStepDevice`) places it on a same-node device that can run it, or
1217
+ * warns no-candidate loudly. Runnable detail children are kept so their
1218
+ * models pre-warm the device pool as before. Disabled subtrees are kept
1219
+ * verbatim (never walked — `ensureModelsForSteps` skips them anyway).
1220
+ */
1221
+ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1222
+ const prunedAddonIds = [];
1223
+ const walk = (nodes, topLevel) => {
1224
+ const kept = [];
1225
+ for (const step of nodes) {
1226
+ if (!step.enabled) {
1227
+ kept.push(step);
1228
+ continue;
1229
+ }
1230
+ if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
1231
+ prunedAddonIds.push(step.addonId);
1232
+ continue;
1233
+ }
1234
+ kept.push(step.children?.length ? {
1235
+ ...step,
1236
+ children: walk(step.children, false)
1237
+ } : step);
1238
+ }
1239
+ return kept;
1240
+ };
1241
+ return {
1242
+ steps: walk(steps, true),
1243
+ prunedAddonIds
1244
+ };
1245
+ }
1149
1246
  //#endregion
1150
1247
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1151
1248
  var PipelineModelManager = class {
@@ -1543,7 +1640,7 @@ var RUNTIME_TO_FORMAT = {
1543
1640
  edgetpu: "tflite"
1544
1641
  };
1545
1642
  /** The step whose detections are re-filtered downstream by the executor's
1546
- * per-macro `minConfidence*` sliders (`matchesMacroFilter`). Its pool-side
1643
+ * per-macro `minConfidence*` sliders (`macroFilterVerdict`). Its pool-side
1547
1644
  * floor must stay BELOW those sliders or it silently pre-empts them. */
1548
1645
  var OBJECT_DETECTION_STEP_ID = "object-detection";
1549
1646
  /**
@@ -1988,601 +2085,6 @@ var EngineProvisioner = class {
1988
2085
  }
1989
2086
  };
1990
2087
  //#endregion
1991
- //#region src/detection-pipeline/postprocess/dispatch.ts
1992
- /** Detector NMS IoU fallback — the historical hardcoded literal, used when the
1993
- * step carries no `nmsIouThreshold` setting (unset == today). */
1994
- var DEFAULT_NMS_IOU = .45;
1995
- /**
1996
- * Region plate grammars for the TS fallback CTC decoder (nodejs+onnx path).
1997
- * VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
1998
- * confusables — the confusable repair needs the aligned per-timestep softmax
1999
- * that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
2000
- * the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
2001
- */
2002
- var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
2003
- var VALID_KINDS = new Set([
2004
- "detections",
2005
- "classifications",
2006
- "embedding",
2007
- "text",
2008
- "mask"
2009
- ]);
2010
- /**
2011
- * Type guard: validates that a structured payload from the Python pool is a
2012
- * well-formed StepOutput discriminated union.
2013
- *
2014
- * The Python inference_pool.py always sets `output.structured` with a `kind`
2015
- * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
2016
- * resorting to double-cast.
2017
- */
2018
- function isStepOutput(value) {
2019
- return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2020
- }
2021
- /**
2022
- * Return the structured payload as `StepOutput`, or null if it fails validation.
2023
- * Callers must handle null by proceeding to raw-tensor postprocessing.
2024
- */
2025
- function tryStructured(output) {
2026
- if (!output.structured) return null;
2027
- if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
2028
- return output.structured;
2029
- }
2030
- function postprocessYolo(output, stepDef, nmsIouThreshold) {
2031
- const structured = tryStructured(output);
2032
- if (structured) return structured;
2033
- const tensor = output.tensor;
2034
- if (!tensor) throw new Error("YOLO postprocessor: no tensor in engine output");
2035
- const labels = stepDef.labels ?? [];
2036
- const numClasses = labels.length || 80;
2037
- const numBoxes = tensor.length / (4 + numClasses);
2038
- const letterbox = output.letterbox;
2039
- const dets = [];
2040
- for (let i = 0; i < numBoxes; i++) {
2041
- const cx = tensor[i];
2042
- const cy = tensor[1 * numBoxes + i];
2043
- const w = tensor[2 * numBoxes + i];
2044
- const h = tensor[3 * numBoxes + i];
2045
- let bestScore = -Infinity;
2046
- let bestClass = 0;
2047
- for (let j = 0; j < numClasses; j++) {
2048
- const score = tensor[(4 + j) * numBoxes + i];
2049
- if (score > bestScore) {
2050
- bestScore = score;
2051
- bestClass = j;
2052
- }
2053
- }
2054
- if (bestScore <= 0) continue;
2055
- let x1 = cx - w / 2;
2056
- let y1 = cy - h / 2;
2057
- let x2 = cx + w / 2;
2058
- let y2 = cy + h / 2;
2059
- if (letterbox) {
2060
- x1 = (x1 - letterbox.padX) / letterbox.scale;
2061
- y1 = (y1 - letterbox.padY) / letterbox.scale;
2062
- x2 = (x2 - letterbox.padX) / letterbox.scale;
2063
- y2 = (y2 - letterbox.padY) / letterbox.scale;
2064
- }
2065
- const label = labels[bestClass] ?? String(bestClass);
2066
- dets.push({
2067
- class: label,
2068
- score: bestScore,
2069
- bbox: [
2070
- x1,
2071
- y1,
2072
- x2,
2073
- y2
2074
- ]
2075
- });
2076
- }
2077
- return {
2078
- kind: "detections",
2079
- detections: simpleNms(dets, nmsIouThreshold)
2080
- };
2081
- }
2082
- function postprocessSsd(output, _stepDef) {
2083
- const structured = tryStructured(output);
2084
- if (structured) return structured;
2085
- throw new Error("SSD postprocessing runs in the Python inference pool (ssd.py); no TypeScript raw-tensor path.");
2086
- }
2087
- function postprocessScrfd(output, stepDef, nmsIouThreshold) {
2088
- const structured = tryStructured(output);
2089
- if (structured) return structured;
2090
- if (!output.tensors) throw new Error("SCRFD postprocessor: no tensors in engine output");
2091
- const strides = [
2092
- 8,
2093
- 16,
2094
- 32
2095
- ];
2096
- const anchorsPerStride = 2;
2097
- const inputSize = Math.max(stepDef.models[0]?.inputSize.width ?? 640, stepDef.models[0]?.inputSize.height ?? 640);
2098
- const strideData = matchTensorsToStrides(output.tensors, strides, inputSize, anchorsPerStride);
2099
- const letterbox = output.letterbox;
2100
- const scale = letterbox?.scale ?? 1;
2101
- const padX = letterbox?.padX ?? 0;
2102
- const padY = letterbox?.padY ?? 0;
2103
- const origW = letterbox?.originalWidth ?? inputSize;
2104
- const origH = letterbox?.originalHeight ?? inputSize;
2105
- const candidates = [];
2106
- for (const stride of strides) {
2107
- const data = strideData.get(stride);
2108
- if (!data) continue;
2109
- const { scores, bboxes, landmarks } = data;
2110
- const featSize = Math.ceil(inputSize / stride);
2111
- const numAnchors = featSize * featSize * anchorsPerStride;
2112
- for (let i = 0; i < numAnchors; i++) {
2113
- if (i >= scores.length) break;
2114
- const score = scores[i];
2115
- if (score <= 0) continue;
2116
- const gridIdx = Math.floor(i / anchorsPerStride);
2117
- const cx = (gridIdx % featSize + .5) * stride;
2118
- const cy = (Math.floor(gridIdx / featSize) + .5) * stride;
2119
- const x1 = cx - bboxes[i * 4] * stride;
2120
- const y1 = cy - bboxes[i * 4 + 1] * stride;
2121
- const x2 = cx + bboxes[i * 4 + 2] * stride;
2122
- const y2 = cy + bboxes[i * 4 + 3] * stride;
2123
- const ox1 = Math.max(0, Math.min(origW, (x1 - padX) / scale));
2124
- const oy1 = Math.max(0, Math.min(origH, (y1 - padY) / scale));
2125
- const ox2 = Math.max(0, Math.min(origW, (x2 - padX) / scale));
2126
- const oy2 = Math.max(0, Math.min(origH, (y2 - padY) / scale));
2127
- if (ox2 - ox1 < 1 || oy2 - oy1 < 1) continue;
2128
- const det = {
2129
- class: "face",
2130
- score: Math.round(score * 1e4) / 1e4,
2131
- bbox: [
2132
- Math.round(ox1 * 10) / 10,
2133
- Math.round(oy1 * 10) / 10,
2134
- Math.round(ox2 * 10) / 10,
2135
- Math.round(oy2 * 10) / 10
2136
- ],
2137
- _xyxy: [
2138
- ox1,
2139
- oy1,
2140
- ox2,
2141
- oy2
2142
- ]
2143
- };
2144
- if (landmarks && i * 10 + 9 < landmarks.length) {
2145
- det.landmarks = [];
2146
- for (let p = 0; p < 5; p++) {
2147
- const lx = (cx + landmarks[i * 10 + p * 2] * stride - padX) / scale;
2148
- const ly = (cy + landmarks[i * 10 + p * 2 + 1] * stride - padY) / scale;
2149
- det.landmarks.push({
2150
- x: Math.round(lx * 10) / 10,
2151
- y: Math.round(ly * 10) / 10
2152
- });
2153
- }
2154
- }
2155
- candidates.push(det);
2156
- }
2157
- }
2158
- return {
2159
- kind: "detections",
2160
- detections: simpleNms(candidates.map((c) => ({
2161
- class: c.class,
2162
- score: c.score,
2163
- bbox: c.bbox,
2164
- ...c.landmarks ? { landmarks: c.landmarks } : {}
2165
- })), nmsIouThreshold)
2166
- };
2167
- }
2168
- /** Match ONNX output tensors to SCRFD strides by expected anchor count. */
2169
- function matchTensorsToStrides(tensors, strides, inputSize, anchorsPerStride) {
2170
- const scoresByN = /* @__PURE__ */ new Map();
2171
- const bboxesByN = /* @__PURE__ */ new Map();
2172
- const landmarksByN = /* @__PURE__ */ new Map();
2173
- for (const [, tensor] of Object.entries(tensors)) for (const stride of strides) {
2174
- const featSize = Math.ceil(inputSize / stride);
2175
- const expectedN = featSize * featSize * anchorsPerStride;
2176
- if (tensor.length === expectedN) scoresByN.set(expectedN, tensor);
2177
- else if (tensor.length === expectedN * 4) bboxesByN.set(expectedN, tensor);
2178
- else if (tensor.length === expectedN * 10) landmarksByN.set(expectedN, tensor);
2179
- }
2180
- const result = /* @__PURE__ */ new Map();
2181
- for (const stride of strides) {
2182
- const featSize = Math.ceil(inputSize / stride);
2183
- const n = featSize * featSize * anchorsPerStride;
2184
- const scores = scoresByN.get(n);
2185
- const bboxes = bboxesByN.get(n);
2186
- if (scores && bboxes) result.set(stride, {
2187
- scores,
2188
- bboxes,
2189
- landmarks: landmarksByN.get(n)
2190
- });
2191
- }
2192
- return result;
2193
- }
2194
- function postprocessArcface(output, _stepDef) {
2195
- const structured = tryStructured(output);
2196
- if (structured) return structured;
2197
- const tensor = output.tensor;
2198
- if (!tensor) throw new Error("ArcFace postprocessor: no tensor in engine output");
2199
- let sumSq = 0;
2200
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
2201
- const norm = Math.sqrt(sumSq);
2202
- const normalized = Array.from({ length: tensor.length });
2203
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
2204
- return {
2205
- kind: "embedding",
2206
- embedding: Array.from(tensor),
2207
- embeddingNorm: normalized
2208
- };
2209
- }
2210
- function postprocessClip(output, _stepDef) {
2211
- const structured = tryStructured(output);
2212
- if (structured) return structured;
2213
- const tensor = output.tensor;
2214
- if (!tensor) throw new Error("CLIP postprocessor: no tensor in engine output");
2215
- let sumSq = 0;
2216
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
2217
- const norm = Math.sqrt(sumSq);
2218
- const normalized = Array.from({ length: tensor.length });
2219
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
2220
- return {
2221
- kind: "embedding",
2222
- embedding: Array.from(tensor),
2223
- embeddingNorm: normalized
2224
- };
2225
- }
2226
- function postprocessSoftmax(output, stepDef) {
2227
- const structured = tryStructured(output);
2228
- if (structured) return structured;
2229
- const tensor = output.tensor;
2230
- if (!tensor) throw new Error("Softmax postprocessor: no tensor in engine output");
2231
- const labels = stepDef.labels ?? [];
2232
- let max = -Infinity;
2233
- for (let i = 0; i < tensor.length; i++) if (tensor[i] > max) max = tensor[i];
2234
- const exps = new Float32Array(tensor.length);
2235
- let sum = 0;
2236
- for (let i = 0; i < tensor.length; i++) {
2237
- exps[i] = Math.exp(tensor[i] - max);
2238
- sum += exps[i];
2239
- }
2240
- const classifications = [];
2241
- for (let i = 0; i < exps.length; i++) {
2242
- const score = exps[i] / sum;
2243
- if (score >= .01) classifications.push({
2244
- class: labels[i] ?? String(i),
2245
- score
2246
- });
2247
- }
2248
- classifications.sort((a, b) => b.score - a.score);
2249
- return {
2250
- kind: "classifications",
2251
- classifications: classifications.slice(0, 5)
2252
- };
2253
- }
2254
- function postprocessCtc(output, stepDef) {
2255
- const structured = tryStructured(output);
2256
- if (structured) return structured;
2257
- const tensor = output.tensor;
2258
- if (!tensor) throw new Error("CTC postprocessor: no tensor in engine output");
2259
- const charset = stepDef.charset ?? [];
2260
- const numChars = charset.length || 97;
2261
- const seqLen = Math.floor(tensor.length / numChars);
2262
- const chars = [];
2263
- let totalScore = 0;
2264
- let prev = -1;
2265
- for (let t = 0; t < seqLen; t++) {
2266
- let bestIdx = 0;
2267
- let bestVal = tensor[t * numChars];
2268
- for (let c = 1; c < numChars; c++) {
2269
- const val = tensor[t * numChars + c];
2270
- if (val > bestVal) {
2271
- bestVal = val;
2272
- bestIdx = c;
2273
- }
2274
- }
2275
- totalScore += bestVal;
2276
- if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
2277
- prev = bestIdx;
2278
- }
2279
- const text = chars.join("");
2280
- const region = stepDef.plateRegion;
2281
- if (region !== void 0 && region !== "off") {
2282
- const grammar = TS_PLATE_GRAMMARS[region];
2283
- if (grammar) return {
2284
- kind: "text",
2285
- text,
2286
- confidence: seqLen > 0 ? totalScore / seqLen : 0,
2287
- formatValid: grammar.test(text)
2288
- };
2289
- }
2290
- return {
2291
- kind: "text",
2292
- text,
2293
- confidence: seqLen > 0 ? totalScore / seqLen : 0
2294
- };
2295
- }
2296
- /**
2297
- * Bounding box [x, y, w, h] (mask-pixel space) of the largest 4-connected
2298
- * foreground component. Port of `_largest_component_bbox` from
2299
- * `python/postprocessors/saliency.py`.
2300
- *
2301
- * Returns null when there are no foreground pixels.
2302
- */
2303
- function largestComponentBbox(binary, w, h) {
2304
- const seeds = [];
2305
- 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]);
2306
- if (seeds.length === 0) return null;
2307
- const visited = new Uint8Array(w * h);
2308
- let best = null;
2309
- for (const [sy, sx] of seeds) {
2310
- const seedIdx = sy * w + sx;
2311
- if (visited[seedIdx] === 1) continue;
2312
- const stack = [[sy, sx]];
2313
- visited[seedIdx] = 1;
2314
- let minX = sx;
2315
- let maxX = sx;
2316
- let minY = sy;
2317
- let maxY = sy;
2318
- let area = 0;
2319
- while (stack.length > 0) {
2320
- const [cy, cx] = stack.pop();
2321
- area += 1;
2322
- if (cx < minX) minX = cx;
2323
- if (cx > maxX) maxX = cx;
2324
- if (cy < minY) minY = cy;
2325
- if (cy > maxY) maxY = cy;
2326
- const neighbours = [
2327
- [cy - 1, cx],
2328
- [cy + 1, cx],
2329
- [cy, cx - 1],
2330
- [cy, cx + 1]
2331
- ];
2332
- for (const [ny, nx] of neighbours) {
2333
- if (ny < 0 || ny >= h || nx < 0 || nx >= w) continue;
2334
- const nIdx = ny * w + nx;
2335
- if ((binary[nIdx] ?? 0) > 0 && visited[nIdx] !== 1) {
2336
- visited[nIdx] = 1;
2337
- stack.push([ny, nx]);
2338
- }
2339
- }
2340
- }
2341
- if (best === null || area > best.area) best = {
2342
- area,
2343
- minX,
2344
- minY,
2345
- maxX,
2346
- maxY
2347
- };
2348
- }
2349
- if (best === null) return null;
2350
- return [
2351
- best.minX,
2352
- best.minY,
2353
- best.maxX - best.minX + 1,
2354
- best.maxY - best.minY + 1
2355
- ];
2356
- }
2357
- function postprocessSaliency(output, _stepDef) {
2358
- const structured = tryStructured(output);
2359
- if (structured) return structured;
2360
- const tensor = output.tensor;
2361
- if (!tensor) throw new Error("Saliency postprocessor: no tensor in engine output");
2362
- const binary = new Uint8Array(tensor.length);
2363
- for (let i = 0; i < tensor.length; i++) binary[i] = 1 / (1 + Math.exp(-tensor[i])) > .5 ? 255 : 0;
2364
- const side = Math.round(Math.sqrt(tensor.length));
2365
- const mask = Buffer.from(binary).toString("base64");
2366
- const maskBbox = largestComponentBbox(binary, side, side);
2367
- if (maskBbox !== null) return {
2368
- kind: "mask",
2369
- mask,
2370
- maskWidth: side,
2371
- maskHeight: side,
2372
- maskBbox
2373
- };
2374
- return {
2375
- kind: "mask",
2376
- mask,
2377
- maskWidth: side,
2378
- maskHeight: side
2379
- };
2380
- }
2381
- function postprocessYoloSeg(output, stepDef, nmsIouThreshold) {
2382
- const structured = tryStructured(output);
2383
- if (structured) return structured;
2384
- const tensors = output.tensors;
2385
- if (!tensors) throw new Error("YOLO-seg postprocessor: no tensors in engine output");
2386
- const labels = stepDef.labels ?? [];
2387
- const numClasses = labels.length || 80;
2388
- const numMaskCoeffs = 32;
2389
- const protoSize = 160;
2390
- const letterbox = output.letterbox;
2391
- let detTensor;
2392
- let protoTensor;
2393
- let detRows = 300;
2394
- let detCols = 0;
2395
- for (const [, tensor] of Object.entries(tensors)) {
2396
- if (tensor.length === numMaskCoeffs * protoSize * protoSize) {
2397
- protoTensor = tensor;
2398
- continue;
2399
- }
2400
- const cols = 38;
2401
- if (tensor.length % cols === 0) {
2402
- detTensor = tensor;
2403
- detRows = tensor.length / cols;
2404
- detCols = cols;
2405
- continue;
2406
- }
2407
- const rawCols = 4 + numClasses + numMaskCoeffs;
2408
- if (tensor.length % rawCols === 0 && !detTensor) {
2409
- detTensor = tensor;
2410
- detRows = tensor.length / rawCols;
2411
- detCols = rawCols;
2412
- }
2413
- }
2414
- if (!detTensor) throw new Error("YOLO-seg postprocessor: could not find detection tensor");
2415
- if (!protoTensor) throw new Error("YOLO-seg postprocessor: could not find prototype tensor");
2416
- const isNmsFormat = detCols === 38;
2417
- const dets = [];
2418
- for (let i = 0; i < detRows; i++) {
2419
- const offset = i * detCols;
2420
- let x1, y1, x2, y2;
2421
- let bestScore;
2422
- let bestClass;
2423
- let coeffStart;
2424
- if (isNmsFormat) {
2425
- x1 = detTensor[offset];
2426
- y1 = detTensor[offset + 1];
2427
- x2 = detTensor[offset + 2];
2428
- y2 = detTensor[offset + 3];
2429
- bestScore = detTensor[offset + 4];
2430
- bestClass = detTensor[offset + 5];
2431
- coeffStart = offset + 6;
2432
- if (bestScore <= 0) continue;
2433
- if (bestClass < 0) continue;
2434
- } else {
2435
- const cx = detTensor[offset];
2436
- const cy = detTensor[offset + 1];
2437
- const w = detTensor[offset + 2];
2438
- const h = detTensor[offset + 3];
2439
- bestScore = -Infinity;
2440
- bestClass = 0;
2441
- for (let j = 0; j < numClasses; j++) {
2442
- const score = detTensor[offset + 4 + j];
2443
- if (score > bestScore) {
2444
- bestScore = score;
2445
- bestClass = j;
2446
- }
2447
- }
2448
- if (bestScore <= 0) continue;
2449
- x1 = cx - w / 2;
2450
- y1 = cy - h / 2;
2451
- x2 = cx + w / 2;
2452
- y2 = cy + h / 2;
2453
- coeffStart = offset + 4 + numClasses;
2454
- }
2455
- const coeffs = new Float32Array(numMaskCoeffs);
2456
- for (let j = 0; j < numMaskCoeffs; j++) coeffs[j] = detTensor[coeffStart + j];
2457
- const maskPixels = protoSize * protoSize;
2458
- const maskRaw = new Float32Array(maskPixels);
2459
- for (let p = 0; p < maskPixels; p++) {
2460
- let sum = 0;
2461
- for (let c = 0; c < numMaskCoeffs; c++) sum += coeffs[c] * protoTensor[c * maskPixels + p];
2462
- maskRaw[p] = 1 / (1 + Math.exp(-sum));
2463
- }
2464
- const protoScale = protoSize / (stepDef.models[0]?.inputSize.width ?? 640);
2465
- const px1 = Math.max(0, Math.floor(x1 * protoScale));
2466
- const py1 = Math.max(0, Math.floor(y1 * protoScale));
2467
- const px2 = Math.min(protoSize, Math.ceil(x2 * protoScale));
2468
- const py2 = Math.min(protoSize, Math.ceil(y2 * protoScale));
2469
- const cropW = Math.max(1, px2 - px1);
2470
- const cropH = Math.max(1, py2 - py1);
2471
- const cropped = new Uint8Array(cropW * cropH);
2472
- for (let row = 0; row < cropH; row++) for (let col = 0; col < cropW; col++) {
2473
- const srcIdx = (py1 + row) * protoSize + (px1 + col);
2474
- cropped[row * cropW + col] = maskRaw[srcIdx] > .5 ? 255 : 0;
2475
- }
2476
- let ox1 = x1;
2477
- let oy1 = y1;
2478
- let ox2 = x2;
2479
- let oy2 = y2;
2480
- if (letterbox) {
2481
- ox1 = (x1 - letterbox.padX) / letterbox.scale;
2482
- oy1 = (y1 - letterbox.padY) / letterbox.scale;
2483
- ox2 = (x2 - letterbox.padX) / letterbox.scale;
2484
- oy2 = (y2 - letterbox.padY) / letterbox.scale;
2485
- }
2486
- const label = labels[bestClass] ?? String(bestClass);
2487
- const maskB64 = Buffer.from(cropped).toString("base64");
2488
- dets.push({
2489
- class: label,
2490
- score: Math.round(bestScore * 1e4) / 1e4,
2491
- bbox: [
2492
- Math.round(ox1 * 10) / 10,
2493
- Math.round(oy1 * 10) / 10,
2494
- Math.round(ox2 * 10) / 10,
2495
- Math.round(oy2 * 10) / 10
2496
- ],
2497
- mask: maskB64,
2498
- maskWidth: cropW,
2499
- maskHeight: cropH
2500
- });
2501
- }
2502
- const keptBoxes = simpleNms(dets.map((d) => ({
2503
- class: d.class,
2504
- score: d.score,
2505
- bbox: d.bbox
2506
- })), nmsIouThreshold);
2507
- const keptSet = new Set(keptBoxes.map((d) => `${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`));
2508
- return {
2509
- kind: "detections",
2510
- detections: dets.filter((d) => keptSet.has(`${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`))
2511
- };
2512
- }
2513
- function postprocessYamnet(output, _stepDef) {
2514
- const structured = tryStructured(output);
2515
- if (structured) return structured;
2516
- throw new Error("YAMNet TypeScript postprocessing not implemented. Use Python backend or Apple SoundAnalysis.");
2517
- }
2518
- var DISPATCH = {
2519
- yolo: postprocessYolo,
2520
- ssd: postprocessSsd,
2521
- "yolo-seg": postprocessYoloSeg,
2522
- scrfd: postprocessScrfd,
2523
- arcface: postprocessArcface,
2524
- clip: postprocessClip,
2525
- softmax: postprocessSoftmax,
2526
- ctc: postprocessCtc,
2527
- saliency: postprocessSaliency,
2528
- yamnet: postprocessYamnet
2529
- };
2530
- /**
2531
- * Dispatch postprocessing based on step definition's postprocessor type.
2532
- *
2533
- * For Python backends: output.structured is already populated → passthrough.
2534
- * For Node.js ONNX: raw tensors → postprocess in TypeScript.
2535
- *
2536
- * Both paths pass through normalizeOutput() to ensure consistent format
2537
- * (e.g., classification top-K, alternates).
2538
- */
2539
- function dispatchPostprocess(output, stepDef, settings) {
2540
- const fn = DISPATCH[stepDef.postprocessor];
2541
- if (!fn) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2542
- const raw = settings?.["nmsIouThreshold"];
2543
- return normalizeOutput(fn(output, stepDef, typeof raw === "number" && raw > 0 ? raw : DEFAULT_NMS_IOU));
2544
- }
2545
- /**
2546
- * Normalize any StepOutput to a consistent format.
2547
- * Single funnel for both Python and TypeScript postprocessor paths.
2548
- *
2549
- * Classifications: keep only top-1 in classifications, move full list to alternates.
2550
- */
2551
- function normalizeOutput(output) {
2552
- if (output.kind !== "classifications") return output;
2553
- const all = output.classifications;
2554
- if (all.length <= 1) return output;
2555
- return {
2556
- kind: "classifications",
2557
- classifications: all.slice(0, 1),
2558
- alternates: all
2559
- };
2560
- }
2561
- function simpleNms(dets, iouThreshold) {
2562
- if (dets.length === 0) return [];
2563
- const sorted = [...dets].toSorted((a, b) => b.score - a.score);
2564
- const kept = [];
2565
- const suppressed = /* @__PURE__ */ new Set();
2566
- for (let i = 0; i < sorted.length; i++) {
2567
- if (suppressed.has(i)) continue;
2568
- kept.push(sorted[i]);
2569
- for (let j = i + 1; j < sorted.length; j++) {
2570
- if (suppressed.has(j)) continue;
2571
- if (iou(sorted[i].bbox, sorted[j].bbox) > iouThreshold) suppressed.add(j);
2572
- }
2573
- }
2574
- return kept;
2575
- }
2576
- function iou(a, b) {
2577
- const ix1 = Math.max(a[0], b[0]);
2578
- const iy1 = Math.max(a[1], b[1]);
2579
- const ix2 = Math.min(a[2], b[2]);
2580
- const iy2 = Math.min(a[3], b[3]);
2581
- const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2582
- if (inter === 0) return 0;
2583
- return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
2584
- }
2585
- //#endregion
2586
2088
  //#region src/detection-pipeline/pipeline/crop-utils.ts
2587
2089
  /**
2588
2090
  * Crop utilities — ROI extraction and bbox coordinate transforms.
@@ -2632,34 +2134,244 @@ function isBboxDegenerate(bbox) {
2632
2134
  return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
2633
2135
  }
2634
2136
  //#endregion
2635
- //#region src/detection-pipeline/pipeline/face-align.ts
2137
+ //#region src/detection-pipeline/pipeline/crop-zone.ts
2636
2138
  /**
2637
- * Face alignment — landmark-based similarity warp to the ArcFace template.
2638
- *
2639
- * ArcFace (and most face-recognition embedders) are only discriminative when
2640
- * the input is a *landmark-aligned* 112x112 crop: eyes, nose and mouth corners
2641
- * must land on fixed canonical positions. Feeding a raw axis-aligned bbox crop
2642
- * stretched to 112x112 (the previous behaviour) collapses embeddings toward a
2643
- * common direction — different people end up with ~0.99 cosine similarity.
2644
- *
2645
- * This module computes the least-squares similarity transform (rotation +
2646
- * uniform scale + translation, the cv2.estimateAffinePartial2D recipe) that
2647
- * maps SCRFD's 5 facial landmarks onto the canonical template, then resamples
2648
- * the source pixels into a fixed-size aligned crop with bilinear interpolation.
2139
+ * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
2140
+ * bounding box of the UNION of every polygon referenced by the device's
2141
+ * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
2142
+ * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
2649
2143
  *
2650
- * The warp is done in pure JS on a raw RGB buffer (a few thousand samples per
2651
- * face negligible cost) so it is fully deterministic and unit-testable, and
2652
- * needs no native affine primitive nor any change to the Python inference pool.
2653
- */
2654
- /**
2655
- * Canonical ArcFace 5-point template for a 112x112 aligned crop
2656
- * (insightface reference points). Order matches SCRFD landmark output:
2657
- * left-eye, right-eye, nose, left-mouth-corner, right-mouth-corner.
2144
+ * Returns `null` when there is nothing usable to crop to no enabled rules, no
2145
+ * referenced geometry, or a degenerate result in which case the caller falls
2146
+ * back to full-frame inference (behaviour identical to before the crop-zone
2147
+ * plumbing).
2658
2148
  */
2659
- var ARCFACE_TEMPLATE_112 = [
2660
- {
2661
- x: 38.2946,
2662
- y: 51.6963
2149
+ function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
2150
+ if (frameWidth <= 0 || frameHeight <= 0) return null;
2151
+ const enabledRules = packageRules.filter((r) => r.enabled !== false);
2152
+ if (enabledRules.length === 0) return null;
2153
+ const zoneIds = /* @__PURE__ */ new Set();
2154
+ for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
2155
+ if (zoneIds.size === 0) return null;
2156
+ const zoneById = new Map(zones.map((z) => [z.id, z]));
2157
+ let minX = Number.POSITIVE_INFINITY;
2158
+ let minY = Number.POSITIVE_INFINITY;
2159
+ let maxX = Number.NEGATIVE_INFINITY;
2160
+ let maxY = Number.NEGATIVE_INFINITY;
2161
+ let sawPoint = false;
2162
+ for (const id of zoneIds) {
2163
+ const zone = zoneById.get(id);
2164
+ if (!zone || zone.polygon.length === 0) continue;
2165
+ for (const point of zone.polygon) {
2166
+ sawPoint = true;
2167
+ if (point.x < minX) minX = point.x;
2168
+ if (point.y < minY) minY = point.y;
2169
+ if (point.x > maxX) maxX = point.x;
2170
+ if (point.y > maxY) maxY = point.y;
2171
+ }
2172
+ }
2173
+ if (!sawPoint) return null;
2174
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
2175
+ const bbox = [
2176
+ clamp01(minX) * frameWidth,
2177
+ clamp01(minY) * frameHeight,
2178
+ clamp01(maxX) * frameWidth,
2179
+ clamp01(maxY) * frameHeight
2180
+ ];
2181
+ if (isBboxDegenerate(bbox)) return null;
2182
+ return bbox;
2183
+ }
2184
+ //#endregion
2185
+ //#region src/detection-pipeline/postprocess/dispatch.ts
2186
+ var VALID_KINDS = new Set([
2187
+ "detections",
2188
+ "classifications",
2189
+ "embedding",
2190
+ "text",
2191
+ "mask"
2192
+ ]);
2193
+ /** Every postprocessor the Python pool implements — the dispatch table's keys
2194
+ * survived the raw-tensor removal as a config-drift guard: an unknown
2195
+ * postprocessor in a step definition still fails fast here. */
2196
+ var KNOWN_POSTPROCESSORS = new Set([
2197
+ "yolo",
2198
+ "ssd",
2199
+ "yolo-seg",
2200
+ "scrfd",
2201
+ "arcface",
2202
+ "clip",
2203
+ "softmax",
2204
+ "ctc",
2205
+ "saliency",
2206
+ "yamnet"
2207
+ ]);
2208
+ /**
2209
+ * Type guard: validates that a structured payload from the Python pool is a
2210
+ * well-formed StepOutput discriminated union.
2211
+ *
2212
+ * The Python inference_pool.py always sets `output.structured` with a `kind`
2213
+ * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
2214
+ * resorting to double-cast.
2215
+ */
2216
+ function isStepOutput(value) {
2217
+ return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2218
+ }
2219
+ /**
2220
+ * Validate + normalize a Python-pool engine output into a StepOutput.
2221
+ */
2222
+ function dispatchPostprocess(output, stepDef) {
2223
+ if (output.dropped === true) return {
2224
+ kind: "detections",
2225
+ detections: []
2226
+ };
2227
+ if (!KNOWN_POSTPROCESSORS.has(stepDef.postprocessor)) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2228
+ 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.`);
2229
+ if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
2230
+ return normalizeOutput(output.structured);
2231
+ }
2232
+ /**
2233
+ * Normalize any StepOutput to a consistent format — the single funnel every
2234
+ * pool payload passes through.
2235
+ *
2236
+ * Classifications: keep only top-1 in classifications, move full list to alternates.
2237
+ */
2238
+ function normalizeOutput(output) {
2239
+ if (output.kind !== "classifications") return output;
2240
+ const all = output.classifications;
2241
+ if (all.length <= 1) return output;
2242
+ return {
2243
+ kind: "classifications",
2244
+ classifications: all.slice(0, 1),
2245
+ alternates: all
2246
+ };
2247
+ }
2248
+ //#endregion
2249
+ //#region src/detection-pipeline/pipeline/execution-trace.ts
2250
+ var traceCounter = 0;
2251
+ var ExecutionTraceBuilder = class {
2252
+ verbosity;
2253
+ deviceId;
2254
+ frameWidth;
2255
+ frameHeight;
2256
+ engineRuntime;
2257
+ steps = [];
2258
+ startTime;
2259
+ constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
2260
+ this.verbosity = verbosity;
2261
+ this.deviceId = deviceId;
2262
+ this.frameWidth = frameWidth;
2263
+ this.frameHeight = frameHeight;
2264
+ this.engineRuntime = engineRuntime;
2265
+ this.startTime = Date.now();
2266
+ }
2267
+ /** Whether trace collection is active. */
2268
+ get isActive() {
2269
+ return this.verbosity !== "off";
2270
+ }
2271
+ /**
2272
+ * Record a completed step execution.
2273
+ * No-op if verbosity is 'off'.
2274
+ */
2275
+ addStep(params) {
2276
+ if (this.verbosity === "off") return;
2277
+ const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
2278
+ const trace = {
2279
+ stepId: params.stepId,
2280
+ modelId: params.modelId,
2281
+ slot: params.slot,
2282
+ postprocessor: params.postprocessor,
2283
+ preprocessMs: Math.round(params.preprocessMs * 100) / 100,
2284
+ inferenceMs: Math.round(params.inferenceMs * 100) / 100,
2285
+ postprocessMs: Math.round(params.postprocessMs * 100) / 100,
2286
+ totalMs: Math.round(totalMs * 100) / 100,
2287
+ inputType: params.inputType,
2288
+ inputSize: {
2289
+ width: params.inputWidth,
2290
+ height: params.inputHeight
2291
+ },
2292
+ parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
2293
+ outputKind: params.output.kind,
2294
+ outputCount: countOutput(params.output),
2295
+ topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
2296
+ error: params.error
2297
+ };
2298
+ this.steps.push(trace);
2299
+ }
2300
+ /**
2301
+ * Finalize and return the complete execution trace.
2302
+ * Returns null if verbosity is 'off'.
2303
+ */
2304
+ build(detectionCount) {
2305
+ if (this.verbosity === "off") return null;
2306
+ const totalMs = Date.now() - this.startTime;
2307
+ return {
2308
+ traceId: `trace-${Date.now()}-${++traceCounter}`,
2309
+ deviceId: this.deviceId,
2310
+ timestamp: this.startTime,
2311
+ frameSize: {
2312
+ width: this.frameWidth,
2313
+ height: this.frameHeight
2314
+ },
2315
+ engineRuntime: this.engineRuntime,
2316
+ steps: this.steps,
2317
+ totalMs,
2318
+ detectionCount
2319
+ };
2320
+ }
2321
+ };
2322
+ function countOutput(output) {
2323
+ switch (output.kind) {
2324
+ case "detections": return output.detections.length;
2325
+ case "classifications": return output.classifications.length;
2326
+ case "embedding": return 1;
2327
+ case "text": return output.text.length > 0 ? 1 : 0;
2328
+ case "mask": return 1;
2329
+ }
2330
+ }
2331
+ function formatTopResult(output) {
2332
+ switch (output.kind) {
2333
+ case "detections": {
2334
+ const top = output.detections[0];
2335
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2336
+ }
2337
+ case "classifications": {
2338
+ const top = output.classifications[0];
2339
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2340
+ }
2341
+ case "embedding": return `embedding[${output.embedding.length}]`;
2342
+ case "text": return output.text || void 0;
2343
+ case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
2344
+ }
2345
+ }
2346
+ //#endregion
2347
+ //#region src/detection-pipeline/pipeline/face-align.ts
2348
+ /**
2349
+ * Face alignment — landmark-based similarity warp to the ArcFace template.
2350
+ *
2351
+ * ArcFace (and most face-recognition embedders) are only discriminative when
2352
+ * the input is a *landmark-aligned* 112x112 crop: eyes, nose and mouth corners
2353
+ * must land on fixed canonical positions. Feeding a raw axis-aligned bbox crop
2354
+ * stretched to 112x112 (the previous behaviour) collapses embeddings toward a
2355
+ * common direction — different people end up with ~0.99 cosine similarity.
2356
+ *
2357
+ * This module computes the least-squares similarity transform (rotation +
2358
+ * uniform scale + translation, the cv2.estimateAffinePartial2D recipe) that
2359
+ * maps SCRFD's 5 facial landmarks onto the canonical template, then resamples
2360
+ * the source pixels into a fixed-size aligned crop with bilinear interpolation.
2361
+ *
2362
+ * The warp is done in pure JS on a raw RGB buffer (a few thousand samples per
2363
+ * face — negligible cost) so it is fully deterministic and unit-testable, and
2364
+ * needs no native affine primitive nor any change to the Python inference pool.
2365
+ */
2366
+ /**
2367
+ * Canonical ArcFace 5-point template for a 112x112 aligned crop
2368
+ * (insightface reference points). Order matches SCRFD landmark output:
2369
+ * left-eye, right-eye, nose, left-mouth-corner, right-mouth-corner.
2370
+ */
2371
+ var ARCFACE_TEMPLATE_112 = [
2372
+ {
2373
+ x: 38.2946,
2374
+ y: 51.6963
2663
2375
  },
2664
2376
  {
2665
2377
  x: 73.5318,
@@ -2937,63 +2649,6 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
2937
2649
  height: aligned.height
2938
2650
  };
2939
2651
  }
2940
- //#endregion
2941
- //#region src/detection-pipeline/pipeline/native-child-crop.ts
2942
- /**
2943
- * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2944
- * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2945
- *
2946
- * On the detail plane the executor runs a child model against a parent crop
2947
- * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2948
- * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2949
- * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2950
- * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2951
- * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2952
- * straight from the frame's retained NATIVE surface (the provider re-composes
2953
- * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2954
- * giving the model native pixels. A miss/degenerate returns `null` so the caller
2955
- * falls back to the existing tile crop (never an upscale — native or the tile).
2956
- */
2957
- /**
2958
- * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2959
- * native pixels wide, so this never binds for plates (effectively uncapped —
2960
- * the quality path the task requires); it only bounds a pathologically large
2961
- * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2962
- */
2963
- var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2964
- /**
2965
- * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2966
- * resolution via `provider`, returning a JPEG crop + its native dimensions.
2967
- * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2968
- * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2969
- * the sharp encode of the returned pixels.
2970
- */
2971
- async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2972
- if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2973
- const x1 = Math.max(0, bbox[0]);
2974
- const y1 = Math.max(0, bbox[1]);
2975
- const x2 = Math.min(imageWidth, bbox[2]);
2976
- const y2 = Math.min(imageHeight, bbox[3]);
2977
- const w = x2 - x1;
2978
- const h = y2 - y1;
2979
- if (w < 1 || h < 1) return null;
2980
- const native = await provider({
2981
- x: x1 / imageWidth,
2982
- y: y1 / imageHeight,
2983
- w: w / imageWidth,
2984
- h: h / imageHeight
2985
- }, maxWidth);
2986
- if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2987
- return {
2988
- jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
2989
- width: native.width,
2990
- height: native.height,
2991
- channels: 3
2992
- } }).jpeg({ quality: 90 }).toBuffer(),
2993
- width: native.width,
2994
- height: native.height
2995
- };
2996
- }
2997
2652
  /**
2998
2653
  * Detection score at/above which a near-full-frame box is trusted as a REAL
2999
2654
  * close subject rather than a phantom. Below this bar a full-frame box is
@@ -3075,6 +2730,63 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
3075
2730
  return false;
3076
2731
  }
3077
2732
  //#endregion
2733
+ //#region src/detection-pipeline/pipeline/native-child-crop.ts
2734
+ /**
2735
+ * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2736
+ * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2737
+ *
2738
+ * On the detail plane the executor runs a child model against a parent crop
2739
+ * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2740
+ * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2741
+ * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2742
+ * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2743
+ * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2744
+ * straight from the frame's retained NATIVE surface (the provider re-composes
2745
+ * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2746
+ * giving the model native pixels. A miss/degenerate returns `null` so the caller
2747
+ * falls back to the existing tile crop (never an upscale — native or the tile).
2748
+ */
2749
+ /**
2750
+ * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2751
+ * native pixels wide, so this never binds for plates (effectively uncapped —
2752
+ * the quality path the task requires); it only bounds a pathologically large
2753
+ * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2754
+ */
2755
+ var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2756
+ /**
2757
+ * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2758
+ * resolution via `provider`, returning a JPEG crop + its native dimensions.
2759
+ * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2760
+ * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2761
+ * the sharp encode of the returned pixels.
2762
+ */
2763
+ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2764
+ if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2765
+ const x1 = Math.max(0, bbox[0]);
2766
+ const y1 = Math.max(0, bbox[1]);
2767
+ const x2 = Math.min(imageWidth, bbox[2]);
2768
+ const y2 = Math.min(imageHeight, bbox[3]);
2769
+ const w = x2 - x1;
2770
+ const h = y2 - y1;
2771
+ if (w < 1 || h < 1) return null;
2772
+ const native = await provider({
2773
+ x: x1 / imageWidth,
2774
+ y: y1 / imageHeight,
2775
+ w: w / imageWidth,
2776
+ h: h / imageHeight
2777
+ }, maxWidth);
2778
+ if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2779
+ return {
2780
+ jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
2781
+ width: native.width,
2782
+ height: native.height,
2783
+ channels: 3
2784
+ } }).jpeg({ quality: 90 }).toBuffer(),
2785
+ width: native.width,
2786
+ height: native.height
2787
+ };
2788
+ }
2789
+ //#endregion
3078
2790
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
3079
2791
  /**
3080
2792
  * Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
@@ -3589,6 +3301,7 @@ function buildFrameResult(input) {
3589
3301
  width: input.width,
3590
3302
  height: input.height,
3591
3303
  detections,
3304
+ ...input.discarded !== void 0 && input.discarded.length > 0 ? { discarded: input.discarded } : {},
3592
3305
  debug: frameDebug
3593
3306
  };
3594
3307
  }
@@ -3597,137 +3310,39 @@ function bboxTupleToRect(bbox) {
3597
3310
  return {
3598
3311
  x: x1,
3599
3312
  y: y1,
3600
- width: x2 - x1,
3601
- height: y2 - y1
3602
- };
3603
- }
3604
- function pruneUndefined(obj) {
3605
- const out = {};
3606
- for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3607
- return out;
3608
- }
3609
- function generateFrameId() {
3610
- return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3611
- }
3612
- /** Seed a MutableObjectDetection from a root-level detector output. */
3613
- function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3614
- return {
3615
- id: idGen.next(),
3616
- kind: "first-level",
3617
- macroClass: det.class,
3618
- originalClass: det.class,
3619
- score: det.score,
3620
- bbox: [...det.bbox],
3621
- ownLabels: [],
3622
- alternateLabels: {},
3623
- stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3624
- modelIds: { [rootStep.stepId]: rootStep.modelId },
3625
- extractMode: "full-frame",
3626
- ...det.mask !== void 0 ? {
3627
- mask: det.mask,
3628
- maskWidth: det.maskWidth,
3629
- maskHeight: det.maskHeight
3630
- } : {},
3631
- ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3632
- };
3633
- }
3634
- //#endregion
3635
- //#region src/detection-pipeline/pipeline/execution-trace.ts
3636
- var traceCounter = 0;
3637
- var ExecutionTraceBuilder = class {
3638
- verbosity;
3639
- deviceId;
3640
- frameWidth;
3641
- frameHeight;
3642
- engineRuntime;
3643
- steps = [];
3644
- startTime;
3645
- constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
3646
- this.verbosity = verbosity;
3647
- this.deviceId = deviceId;
3648
- this.frameWidth = frameWidth;
3649
- this.frameHeight = frameHeight;
3650
- this.engineRuntime = engineRuntime;
3651
- this.startTime = Date.now();
3652
- }
3653
- /** Whether trace collection is active. */
3654
- get isActive() {
3655
- return this.verbosity !== "off";
3656
- }
3657
- /**
3658
- * Record a completed step execution.
3659
- * No-op if verbosity is 'off'.
3660
- */
3661
- addStep(params) {
3662
- if (this.verbosity === "off") return;
3663
- const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
3664
- const trace = {
3665
- stepId: params.stepId,
3666
- modelId: params.modelId,
3667
- slot: params.slot,
3668
- postprocessor: params.postprocessor,
3669
- preprocessMs: Math.round(params.preprocessMs * 100) / 100,
3670
- inferenceMs: Math.round(params.inferenceMs * 100) / 100,
3671
- postprocessMs: Math.round(params.postprocessMs * 100) / 100,
3672
- totalMs: Math.round(totalMs * 100) / 100,
3673
- inputType: params.inputType,
3674
- inputSize: {
3675
- width: params.inputWidth,
3676
- height: params.inputHeight
3677
- },
3678
- parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
3679
- outputKind: params.output.kind,
3680
- outputCount: countOutput(params.output),
3681
- topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
3682
- error: params.error
3683
- };
3684
- this.steps.push(trace);
3685
- }
3686
- /**
3687
- * Finalize and return the complete execution trace.
3688
- * Returns null if verbosity is 'off'.
3689
- */
3690
- build(detectionCount) {
3691
- if (this.verbosity === "off") return null;
3692
- const totalMs = Date.now() - this.startTime;
3693
- return {
3694
- traceId: `trace-${Date.now()}-${++traceCounter}`,
3695
- deviceId: this.deviceId,
3696
- timestamp: this.startTime,
3697
- frameSize: {
3698
- width: this.frameWidth,
3699
- height: this.frameHeight
3700
- },
3701
- engineRuntime: this.engineRuntime,
3702
- steps: this.steps,
3703
- totalMs,
3704
- detectionCount
3705
- };
3706
- }
3707
- };
3708
- function countOutput(output) {
3709
- switch (output.kind) {
3710
- case "detections": return output.detections.length;
3711
- case "classifications": return output.classifications.length;
3712
- case "embedding": return 1;
3713
- case "text": return output.text.length > 0 ? 1 : 0;
3714
- case "mask": return 1;
3715
- }
3716
- }
3717
- function formatTopResult(output) {
3718
- switch (output.kind) {
3719
- case "detections": {
3720
- const top = output.detections[0];
3721
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3722
- }
3723
- case "classifications": {
3724
- const top = output.classifications[0];
3725
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3726
- }
3727
- case "embedding": return `embedding[${output.embedding.length}]`;
3728
- case "text": return output.text || void 0;
3729
- case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
3730
- }
3313
+ width: x2 - x1,
3314
+ height: y2 - y1
3315
+ };
3316
+ }
3317
+ function pruneUndefined(obj) {
3318
+ const out = {};
3319
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3320
+ return out;
3321
+ }
3322
+ function generateFrameId() {
3323
+ return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3324
+ }
3325
+ /** Seed a MutableObjectDetection from a root-level detector output. */
3326
+ function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3327
+ return {
3328
+ id: idGen.next(),
3329
+ kind: "first-level",
3330
+ macroClass: det.class,
3331
+ originalClass: det.class,
3332
+ score: det.score,
3333
+ bbox: [...det.bbox],
3334
+ ownLabels: [],
3335
+ alternateLabels: {},
3336
+ stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3337
+ modelIds: { [rootStep.stepId]: rootStep.modelId },
3338
+ extractMode: "full-frame",
3339
+ ...det.mask !== void 0 ? {
3340
+ mask: det.mask,
3341
+ maskWidth: det.maskWidth,
3342
+ maskHeight: det.maskHeight
3343
+ } : {},
3344
+ ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3345
+ };
3731
3346
  }
3732
3347
  //#endregion
3733
3348
  //#region src/detection-pipeline/pipeline/executor.ts
@@ -3879,12 +3494,14 @@ var PipelineExecutor = class {
3879
3494
  idGen,
3880
3495
  details
3881
3496
  };
3497
+ let discarded;
3882
3498
  for (const rootStep of tree.roots) {
3883
3499
  if (debug) console.log(`[executor] rootStep=${rootStep.stepId} settings=${JSON.stringify(rootStep.settings ?? {})}`);
3884
3500
  const cropPrep = rootStep.definition.extractMode === "crop-zone" && cropZoneBbox !== void 0 ? await this.prepareCropZoneInput(cropZoneBbox, fullFrameJpegProvider, imageWidth, imageHeight, deviceId) : null;
3885
3501
  const rootStart = Date.now();
3886
3502
  const rawRootOutput = await this.executeStep(rootStep, cropPrep?.input ?? rootInput, cropPrep?.width ?? imageWidth, cropPrep?.height ?? imageHeight, "full-frame", void 0, void 0, traceBuilder, stepTimings, poolAgg);
3887
3503
  const rootMs = Date.now() - rootStart;
3504
+ if (rawRootOutput === null) continue;
3888
3505
  const rootOutput = cropPrep && rawRootOutput.kind === "detections" ? reprojectCropZoneDetections(rawRootOutput, cropPrep.origin) : rawRootOutput;
3889
3506
  if (rootOutput.kind !== "detections") {
3890
3507
  if (isEnrichmentOutput(rootOutput)) {
@@ -3914,10 +3531,35 @@ var PipelineExecutor = class {
3914
3531
  if (mapped) {
3915
3532
  mutable.originalClass = det.class;
3916
3533
  mutable.macroClass = mapped;
3917
- } else if (!rootStep.definition.classMap.preserveOriginal) continue;
3534
+ } else if (!rootStep.definition.classMap.preserveOriginal) {
3535
+ (discarded ??= []).push({
3536
+ bbox: [
3537
+ mutable.bbox[0],
3538
+ mutable.bbox[1],
3539
+ mutable.bbox[2],
3540
+ mutable.bbox[3]
3541
+ ],
3542
+ macroClass: mutable.macroClass,
3543
+ score: mutable.score,
3544
+ reason: "class-filter"
3545
+ });
3546
+ continue;
3547
+ }
3918
3548
  }
3919
- if (!this.matchesMacroFilter(mutable.macroClass, mutable.score, rootStep.settings)) {
3920
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (class or confidence filter)`);
3549
+ const filterVerdict = this.macroFilterVerdict(mutable.macroClass, mutable.score, rootStep.settings);
3550
+ if (filterVerdict !== "pass") {
3551
+ (discarded ??= []).push({
3552
+ bbox: [
3553
+ mutable.bbox[0],
3554
+ mutable.bbox[1],
3555
+ mutable.bbox[2],
3556
+ mutable.bbox[3]
3557
+ ],
3558
+ macroClass: mutable.macroClass,
3559
+ score: mutable.score,
3560
+ reason: filterVerdict
3561
+ });
3562
+ if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (${filterVerdict})`);
3921
3563
  continue;
3922
3564
  }
3923
3565
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
@@ -3936,9 +3578,20 @@ var PipelineExecutor = class {
3936
3578
  hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3937
3579
  }
3938
3580
  });
3581
+ (discarded ??= []).push({
3582
+ bbox: [
3583
+ mutable.bbox[0],
3584
+ mutable.bbox[1],
3585
+ mutable.bbox[2],
3586
+ mutable.bbox[3]
3587
+ ],
3588
+ macroClass: mutable.macroClass,
3589
+ score: mutable.score,
3590
+ reason: "full-frame-guard"
3591
+ });
3939
3592
  continue;
3940
3593
  }
3941
- 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", {
3594
+ 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", {
3942
3595
  tags: { deviceId },
3943
3596
  meta: {
3944
3597
  macroClass: mutable.macroClass,
@@ -3965,6 +3618,7 @@ var PipelineExecutor = class {
3965
3618
  firstLevel.push(mutable);
3966
3619
  }
3967
3620
  }
3621
+ const totalMs = Date.now() - startMs;
3968
3622
  return {
3969
3623
  result: buildFrameResult({
3970
3624
  deviceId,
@@ -3972,8 +3626,9 @@ var PipelineExecutor = class {
3972
3626
  height: imageHeight,
3973
3627
  firstLevel,
3974
3628
  details,
3629
+ discarded,
3975
3630
  stepTimings,
3976
- totalMs: Date.now() - startMs,
3631
+ totalMs,
3977
3632
  engine: this.opts.engineRuntime,
3978
3633
  debug,
3979
3634
  timestamp: Date.now(),
@@ -4078,13 +3733,40 @@ var PipelineExecutor = class {
4078
3733
  });
4079
3734
  throw new Error(`Inference failed for step "${step.stepId}": ${errorMsg}`, { cause: err });
4080
3735
  }
3736
+ if (engineOutput.dropped === true) {
3737
+ stepTimings.push({
3738
+ source: step.stepId,
3739
+ modelId: step.modelId,
3740
+ ms: Date.now() - preprocessStart,
3741
+ detectionCount: 0
3742
+ });
3743
+ if (traceBuilder.isActive) traceBuilder.addStep({
3744
+ stepId: step.stepId,
3745
+ modelId: step.modelId,
3746
+ slot: step.definition.slot,
3747
+ postprocessor: step.definition.postprocessor,
3748
+ preprocessMs,
3749
+ inferenceMs: Date.now() - inferenceStart,
3750
+ postprocessMs: 0,
3751
+ inputType,
3752
+ inputWidth,
3753
+ inputHeight,
3754
+ parentDetection: parentClass,
3755
+ output: {
3756
+ kind: "detections",
3757
+ detections: []
3758
+ },
3759
+ error: `shed:${engineOutput.shedReason ?? "pool-overload"}`
3760
+ });
3761
+ return null;
3762
+ }
4081
3763
  const structured = engineOutput.structured ?? {};
4082
3764
  const inferenceMs = typeof structured.inferenceMs === "number" ? structured.inferenceMs : Date.now() - inferenceStart;
4083
3765
  if (typeof structured.preprocessMs === "number") poolAgg.preprocessMs += structured.preprocessMs;
4084
3766
  if (typeof structured.predictMs === "number") poolAgg.predictMs += structured.predictMs;
4085
3767
  if (typeof structured.batchSize === "number" && structured.batchSize > poolAgg.batchSize) poolAgg.batchSize = structured.batchSize;
4086
3768
  const postprocessStart = Date.now();
4087
- const output = dispatchPostprocess(engineOutput, step.definition, step.settings);
3769
+ const output = dispatchPostprocess(engineOutput, step.definition);
4088
3770
  const postprocessMs = Date.now() - postprocessStart;
4089
3771
  stepTimings.push({
4090
3772
  source: step.stepId,
@@ -4227,6 +3909,7 @@ var PipelineExecutor = class {
4227
3909
  data: cropJpegBuf
4228
3910
  }, cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
4229
3911
  const childMs = Date.now() - childStart;
3912
+ if (childOutput === null) continue;
4230
3913
  const detailsBefore = ctx.details.length;
4231
3914
  applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
4232
3915
  if (childOutput.kind === "detections" && child.children.length > 0) {
@@ -4260,20 +3943,25 @@ var PipelineExecutor = class {
4260
3943
  * Apply the object-detection step's macro filter + per-macro
4261
3944
  * minConfidence sliders introduced in the Phase 6 step rework.
4262
3945
  *
3946
+ * Returns WHICH gate failed rather than a bare boolean so the
3947
+ * discard trail can label the dropped box with the honest reason
3948
+ * (`class-filter` vs `below-threshold`) — the two are remediated
3949
+ * differently (enable the class vs lower a slider).
3950
+ *
4263
3951
  * Expected `settings` shape:
4264
3952
  * - `enabledMacroClasses`: readonly string[] (e.g. ['person','vehicle','animal']).
4265
3953
  * Empty array = all allowed (legacy behaviour).
4266
3954
  * - `minConfidence<Macro>`: number (e.g. `minConfidencePerson: 0.5`)
4267
3955
  */
4268
- matchesMacroFilter(macroClass, score, settings) {
4269
- if (!settings) return true;
3956
+ macroFilterVerdict(macroClass, score, settings) {
3957
+ if (!settings) return "pass";
4270
3958
  const enabled = settings["enabledMacroClasses"];
4271
3959
  if (Array.isArray(enabled) && enabled.length > 0) {
4272
- if (!enabled.includes(macroClass)) return false;
3960
+ if (!enabled.includes(macroClass)) return "class-filter";
4273
3961
  }
4274
3962
  const perMacroThreshold = settings[`minConfidence${capitalize(macroClass)}`];
4275
- if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return false;
4276
- return true;
3963
+ if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return "below-threshold";
3964
+ return "pass";
4277
3965
  }
4278
3966
  };
4279
3967
  function capitalize(s) {
@@ -4281,52 +3969,41 @@ function capitalize(s) {
4281
3969
  return s.charAt(0).toUpperCase() + s.slice(1);
4282
3970
  }
4283
3971
  //#endregion
4284
- //#region src/detection-pipeline/pipeline/crop-zone.ts
3972
+ //#region src/detection-pipeline/pipeline/native-crop-compose.ts
3973
+ /** Clamp `v` into `[lo, hi]`. */
3974
+ function clamp(v, lo, hi) {
3975
+ if (v < lo) return lo;
3976
+ if (v > hi) return hi;
3977
+ return v;
3978
+ }
4285
3979
  /**
4286
- * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
4287
- * bounding box of the UNION of every polygon referenced by the device's
4288
- * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
4289
- * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
3980
+ * Compose a child `roi` given in the parent CROP's normalized space into the
3981
+ * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
3982
+ * frame dimensions. The result is clamped to the unit square (origin in
3983
+ * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
3984
+ * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
3985
+ * clamps to zero area) so the caller falls back to the downscaled tile crop.
4290
3986
  *
4291
- * Returns `null` when there is nothing usable to crop to no enabled rules, no
4292
- * referenced geometry, or a degenerate result — in which case the caller falls
4293
- * back to full-frame inference (behaviour identical to before the crop-zone
4294
- * plumbing).
3987
+ * Purenever mutates its arguments.
4295
3988
  */
4296
- function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
4297
- if (frameWidth <= 0 || frameHeight <= 0) return null;
4298
- const enabledRules = packageRules.filter((r) => r.enabled !== false);
4299
- if (enabledRules.length === 0) return null;
4300
- const zoneIds = /* @__PURE__ */ new Set();
4301
- for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
4302
- if (zoneIds.size === 0) return null;
4303
- const zoneById = new Map(zones.map((z) => [z.id, z]));
4304
- let minX = Number.POSITIVE_INFINITY;
4305
- let minY = Number.POSITIVE_INFINITY;
4306
- let maxX = Number.NEGATIVE_INFINITY;
4307
- let maxY = Number.NEGATIVE_INFINITY;
4308
- let sawPoint = false;
4309
- for (const id of zoneIds) {
4310
- const zone = zoneById.get(id);
4311
- if (!zone || zone.polygon.length === 0) continue;
4312
- for (const point of zone.polygon) {
4313
- sawPoint = true;
4314
- if (point.x < minX) minX = point.x;
4315
- if (point.y < minY) minY = point.y;
4316
- if (point.x > maxX) maxX = point.x;
4317
- if (point.y > maxY) maxY = point.y;
4318
- }
4319
- }
4320
- if (!sawPoint) return null;
4321
- const clamp01 = (v) => Math.max(0, Math.min(1, v));
4322
- const bbox = [
4323
- clamp01(minX) * frameWidth,
4324
- clamp01(minY) * frameHeight,
4325
- clamp01(maxX) * frameWidth,
4326
- clamp01(maxY) * frameHeight
4327
- ];
4328
- if (isBboxDegenerate(bbox)) return null;
4329
- return bbox;
3989
+ function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
3990
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
3991
+ if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
3992
+ const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
3993
+ const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
3994
+ const fw = roi.w * cropFrameSpace.w / frameWidth;
3995
+ const fh = roi.h * cropFrameSpace.h / frameHeight;
3996
+ const x = clamp(fx, 0, 1);
3997
+ const y = clamp(fy, 0, 1);
3998
+ const w = clamp(fw, 0, 1 - x);
3999
+ const h = clamp(fh, 0, 1 - y);
4000
+ if (!(w > 0) || !(h > 0)) return null;
4001
+ return {
4002
+ x,
4003
+ y,
4004
+ w,
4005
+ h
4006
+ };
4330
4007
  }
4331
4008
  //#endregion
4332
4009
  //#region src/detection-pipeline/pipeline/tree-builder.ts
@@ -4562,6 +4239,44 @@ function resolveInputSteps(steps, format, engine) {
4562
4239
  };
4563
4240
  }
4564
4241
  //#endregion
4242
+ //#region src/detection-pipeline/zone-gate.ts
4243
+ function applyZoneRuleGate(result, zones, rules) {
4244
+ if (result.detections.length === 0) return result;
4245
+ if (zones.length === 0 || rules.length === 0) return result;
4246
+ const frameW = result.width;
4247
+ const frameH = result.height;
4248
+ if (frameW === 0 || frameH === 0) return result;
4249
+ const firstLevel = result.detections.filter((d) => d.kind === "first-level");
4250
+ const details = result.detections.filter((d) => d.kind === "detail");
4251
+ const { passed, excluded } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
4252
+ x: (det.bbox.x + det.bbox.width / 2) / frameW,
4253
+ y: (det.bbox.y + det.bbox.height / 2) / frameH
4254
+ }), (det) => det.macroClass);
4255
+ if (passed.length === firstLevel.length) return result;
4256
+ const passedIds = new Set(passed.map((d) => d.id));
4257
+ const filteredDetails = details.filter((d) => {
4258
+ const parentId = d.parentId;
4259
+ return parentId === void 0 || passedIds.has(parentId);
4260
+ });
4261
+ const zoneDiscards = excluded.map((d) => ({
4262
+ bbox: [
4263
+ d.bbox.x,
4264
+ d.bbox.y,
4265
+ d.bbox.x + d.bbox.width,
4266
+ d.bbox.y + d.bbox.height
4267
+ ],
4268
+ macroClass: d.macroClass,
4269
+ score: d.score,
4270
+ reason: "zone-gate"
4271
+ }));
4272
+ const mergedDiscarded = [...result.discarded ?? [], ...zoneDiscards];
4273
+ return {
4274
+ ...result,
4275
+ detections: [...passed, ...filteredDetails],
4276
+ ...mergedDiscarded.length > 0 ? { discarded: mergedDiscarded } : {}
4277
+ };
4278
+ }
4279
+ //#endregion
4565
4280
  //#region src/detection-pipeline/provider.ts
4566
4281
  /**
4567
4282
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4904,10 +4619,27 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4904
4619
  * Idle-TTL for a per-device inference pool (multi-device C3). A device pool
4905
4620
  * with no dispatch for this long is disposed and recreated on next use — the
4906
4621
  * memory guardrail that keeps `factoriesByDevice` from accumulating warm pools
4907
- * forever. Longer than the override cache (60s) since a device pool is heavier
4908
- * to respin; still well above any real inter-session gap for an active camera.
4622
+ * forever as an operator toggles device sets over a long run.
4623
+ *
4624
+ * It was 120s, on the stated assumption that this was "well above any real
4625
+ * inter-session gap for an active camera". Measured 2026-08-02, that is
4626
+ * false: detection is dispatched ON MOTION, so at night the gap between
4627
+ * events routinely exceeds two minutes, and the eviction fires constantly.
4628
+ * The rebuild is not cheap — `ensureModelsForSteps` loads EVERY enabled step
4629
+ * into the fresh factory, which on the hub is seven models
4630
+ * (yolov9m-320-int8, scrfd-2.5g, arcface-r100, yolov8n-plate,
4631
+ * vgg_english_g2, mobileclip-s1-vision, yolov8n-package). The hub log shows
4632
+ * the cycle plainly: `COMPILE ×7` → shed within 7-15s → `EVICT` ~2.5 min
4633
+ * later → repeat. So the first seconds of a motion event — precisely when
4634
+ * detection matters — were spent recompiling models that had just been
4635
+ * thrown away.
4636
+ *
4637
+ * Hours, not minutes. At this TTL only a pool that is genuinely unused
4638
+ * (device disabled, routing changed) is reclaimed, which is what the
4639
+ * guardrail was for; a nightly lull no longer costs a recompile. Override
4640
+ * via env for a memory-constrained node.
4909
4641
  */
4910
- static DEVICE_POOL_IDLE_TTL_MS = 12e4;
4642
+ static DEVICE_POOL_IDLE_TTL_MS = Math.max(6e4, Number(process.env["CAMSTACK_DEVICE_POOL_IDLE_TTL_MS"]) || 360 * 60 * 1e3);
4911
4643
  deviceReaper = new IdlePoolReaper({
4912
4644
  ttlMs: DetectionPipelineProvider.DEVICE_POOL_IDLE_TTL_MS,
4913
4645
  dispose: (deviceKey) => this.disposeDevicePool(deviceKey),
@@ -5960,19 +5692,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5960
5692
  emit(`Image: ${imageWidth}×${imageHeight}`);
5961
5693
  }
5962
5694
  decodeMs = performance.now() - decodeT0;
5963
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
5964
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
5965
- const resolveEngine = deviceEngine ? {
5966
- backend: deviceEngine.backend,
5967
- device: deviceEngine.device ?? null
5968
- } : input.engine ? {
5969
- backend: input.engine.backend,
5970
- device: input.engine.device ?? null
5971
- } : {
5972
- backend: this.currentEngine.backend,
5973
- device: this.currentEngine.device ?? null
5974
- };
5975
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
5695
+ const dispatchResolution = this.resolveStepsForDispatch({
5696
+ steps: input.steps,
5697
+ deviceKey: input.deviceKey,
5698
+ engineOverride: input.engine,
5699
+ deviceId: input.deviceId,
5700
+ plane: input.plane
5701
+ });
5702
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
5703
+ const benchmarkSteps = dispatchResolution.steps;
5976
5704
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5977
5705
  id: s.addonId,
5978
5706
  settings: s.settings ?? {}
@@ -6064,8 +5792,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6064
5792
  logger: this.log
6065
5793
  });
6066
5794
  this.executor = executor;
6067
- const dispatchEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : runEngine;
6068
- const dispatchFactory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
5795
+ const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
5796
+ const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6069
5797
  const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
6070
5798
  if (needed.length > 0) {
6071
5799
  this.log.info("Benchmark: models to load", { meta: {
@@ -6314,6 +6042,142 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6314
6042
  fn();
6315
6043
  }
6316
6044
  /**
6045
+ * Resolve a dispatch's step tree against its target engine, with the
6046
+ * CAPABILITY GATE + node-local engine fallback (cam-615, 2026-08-01;
6047
+ * per-step split 2026-08-01 evening).
6048
+ *
6049
+ * A per-call `deviceKey` (orchestrator device routing / detail-plane step
6050
+ * jump) selects an engine whose format every step EXECUTING IN THIS CALL
6051
+ * must actually ship a model build for. When such a step's catalog has
6052
+ * ZERO builds for that format (so not even `resolveModelForFormat`'s
6053
+ * substitution can rescue it), dispatching anyway means every frame
6054
+ * fails — before this gate it failed as a doomed 3-retry model download,
6055
+ * ~5.5s per frame, forever.
6056
+ *
6057
+ * The gated set is per-plane — the ROOT decides the dispatch device, a
6058
+ * child never bounces it (operator requirement: devices compensate for
6059
+ * each other's missing steps; the original whole-tree gate let ONE
6060
+ * tflite-less child knock the root off an otherwise-capable Coral):
6061
+ *
6062
+ * - `'frame'` (the live per-frame path) executes only root-plane steps,
6063
+ * so only those gate the device. Detail children that can NEVER run on
6064
+ * this device are pruned from THIS dispatch (so `ensureModelsForSteps`
6065
+ * doesn't fail the call on a format build that does not exist) and
6066
+ * logged once — NOT dropped work: each runs later as its own per-track
6067
+ * detail dispatch, where the runner's device-jump resolver
6068
+ * (`resolveStepDevice`) places it on a same-node device that can run
6069
+ * it, or warns no-candidate loudly.
6070
+ * - any other plane (detail subtrees, benchmark, batch) executes the
6071
+ * whole passed tree inline on ONE pool — a single call cannot split
6072
+ * devices — so the whole-tree gate stays.
6073
+ *
6074
+ * Fallback is strictly NODE-LOCAL and ordered: the node's own selected
6075
+ * engine (or the benchmark's explicit `engineOverride`) is the one and only
6076
+ * fallback — frames never migrate to another node from here (that is the
6077
+ * orchestrator's tier). The fallback is logged ONCE per distinct
6078
+ * (deviceKey, steps, format) signature, with `tags: { deviceId }` when the
6079
+ * dispatch is camera-scoped.
6080
+ *
6081
+ * When the node's own engine ALSO cannot run a step, this throws — fast,
6082
+ * before any download attempt — and logs the full picture once (model,
6083
+ * its available formats, both engines tried). A genuinely impossible
6084
+ * configuration must stay loud, not vanish into a silent fallback chain.
6085
+ */
6086
+ resolveStepsForDispatch(args) {
6087
+ const { deviceKey, engineOverride, deviceId } = args;
6088
+ const nodeFormat = engineOverride?.format ?? this.currentEngine.format;
6089
+ const nodeEngine = engineOverride ? {
6090
+ backend: engineOverride.backend,
6091
+ device: engineOverride.device ?? null
6092
+ } : {
6093
+ backend: this.currentEngine.backend,
6094
+ device: this.currentEngine.device ?? null
6095
+ };
6096
+ const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6097
+ const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6098
+ if (deviceKey === void 0 || deviceEngine === void 0) {
6099
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6100
+ this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
6101
+ return {
6102
+ deviceKey: void 0,
6103
+ steps
6104
+ };
6105
+ }
6106
+ const deviceFormat = deviceEngine.format;
6107
+ const deviceStepEngine = {
6108
+ backend: deviceEngine.backend,
6109
+ device: deviceEngine.device ?? null
6110
+ };
6111
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
6112
+ const framePlane = args.plane === "frame";
6113
+ const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6114
+ if (zeroBuild.length === 0) {
6115
+ if (!framePlane) return {
6116
+ deviceKey,
6117
+ steps
6118
+ };
6119
+ const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6120
+ if (pruneResult.prunedAddonIds.length > 0) {
6121
+ const prunedList = pruneResult.prunedAddonIds.join(",");
6122
+ 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", {
6123
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6124
+ meta: {
6125
+ deviceKey,
6126
+ deviceFormat,
6127
+ steps: [...pruneResult.prunedAddonIds]
6128
+ }
6129
+ }));
6130
+ }
6131
+ return {
6132
+ deviceKey,
6133
+ steps: pruneResult.steps
6134
+ };
6135
+ }
6136
+ const blockedSteps = zeroBuild.map((issue) => issue.addonId);
6137
+ 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", {
6138
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6139
+ meta: {
6140
+ deviceKey,
6141
+ deviceFormat,
6142
+ steps: blockedSteps,
6143
+ fallbackEngine: nodeEngineLabel
6144
+ }
6145
+ }));
6146
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6147
+ this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
6148
+ return {
6149
+ deviceKey: void 0,
6150
+ steps: fallbackSteps
6151
+ };
6152
+ }
6153
+ /**
6154
+ * The loud, fast half of the capability gate: throw when any ENABLED step
6155
+ * has ZERO model builds for `format` — BEFORE any model download runs. The
6156
+ * error names the step, its chosen model, the formats the catalog actually
6157
+ * ships, and every engine that was tried, and is logged ONCE per distinct
6158
+ * signature (the throw itself still surfaces per dispatch, but costs
6159
+ * microseconds instead of a 3-retry download backoff).
6160
+ */
6161
+ assertStepsRunnable(steps, format, deviceId, enginesTried) {
6162
+ const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
6163
+ if (zeroBuild.length === 0) return;
6164
+ const detail = zeroBuild.map((issue) => {
6165
+ const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
6166
+ const formats = availableFormatsForStep(issue.addonId);
6167
+ return `step "${issue.addonId}" (model "${step?.modelId ?? "unknown"}") has no ${format} build (available: ${formats.join(", ") || "none"})`;
6168
+ }).join("; ");
6169
+ const message = `Pipeline dispatch impossible on this node: ${detail}. Engines tried: ${enginesTried.join(" → ")}.`;
6170
+ this.logLiveDispatchIssueOnce(`unrunnable:${format}:${zeroBuild.map((i) => i.addonId).join(",")}`, () => this.log.error("Pipeline step cannot run on any node-local engine", {
6171
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6172
+ meta: {
6173
+ format,
6174
+ enginesTried: [...enginesTried],
6175
+ detail
6176
+ }
6177
+ }));
6178
+ throw new Error(message);
6179
+ }
6180
+ /**
6317
6181
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
6318
6182
  * callers (every camera that fires motion in the same window calls
6319
6183
  * `runPipeline` → `ensureModelsForSteps`) share the same in-flight
@@ -6340,6 +6204,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6340
6204
  for (const step of needed) {
6341
6205
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6342
6206
  if (modelEntry && !require_model_download_service_Cp9f4dk6.isModelDownloaded(this.modelsDir, modelEntry, format)) {
6207
+ 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`);
6343
6208
  this.log.info("Downloading model for step", { meta: {
6344
6209
  modelId: step.modelId,
6345
6210
  format,
@@ -6457,19 +6322,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6457
6322
  }
6458
6323
  async runPipelineBatchImpl(input) {
6459
6324
  if (input.frames.length === 0) return { results: [] };
6460
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
6461
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
6462
- const resolveEngine = deviceEngine ? {
6463
- backend: deviceEngine.backend,
6464
- device: deviceEngine.device ?? null
6465
- } : input.engine ? {
6466
- backend: input.engine.backend,
6467
- device: input.engine.device ?? null
6468
- } : {
6469
- backend: this.currentEngine.backend,
6470
- device: this.currentEngine.device ?? null
6471
- };
6472
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
6325
+ const dispatchResolution = this.resolveStepsForDispatch({
6326
+ steps: input.steps,
6327
+ deviceKey: input.deviceKey,
6328
+ engineOverride: input.engine,
6329
+ deviceId: input.deviceId,
6330
+ plane: void 0
6331
+ });
6332
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
6333
+ const resolveFormat = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey).format : input.engine?.format ?? this.currentEngine.format;
6334
+ const benchmarkSteps = dispatchResolution.steps;
6473
6335
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
6474
6336
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
6475
6337
  const rootStep = enabledSteps[0];
@@ -6491,7 +6353,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6491
6353
  await this.ensureEngineFactory();
6492
6354
  const restoreEngine = await this.applyEngineOverride(input.engine);
6493
6355
  try {
6494
- const factory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
6356
+ const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6495
6357
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
6496
6358
  if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
6497
6359
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
@@ -6508,7 +6370,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6508
6370
  frame,
6509
6371
  deviceId: input.deviceId,
6510
6372
  sessionId: input.sessionId,
6511
- deviceKey: input.deviceKey
6373
+ deviceKey: dispatchDeviceKey
6512
6374
  }))) };
6513
6375
  const items = input.frames.map((frame) => ({
6514
6376
  raw: Buffer.from(frame.data),
@@ -6693,31 +6555,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6693
6555
  * detections).
6694
6556
  */
6695
6557
  gateDetectionsByZoneRules(deviceId, result) {
6696
- if (deviceId <= 0 || result.detections.length === 0) return result;
6558
+ if (deviceId <= 0) return result;
6697
6559
  const proxy = this.deviceProxies.get(deviceId);
6698
6560
  if (!proxy) return result;
6699
- const zones = proxy.state.zones.value?.zones ?? [];
6700
- const rules = proxy.state.zoneRules.value?.detection ?? [];
6701
- if (zones.length === 0 || rules.length === 0) return result;
6702
- const frameW = result.width;
6703
- const frameH = result.height;
6704
- if (frameW === 0 || frameH === 0) return result;
6705
- const firstLevel = result.detections.filter((d) => d.kind === "first-level");
6706
- const details = result.detections.filter((d) => d.kind === "detail");
6707
- const { passed } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
6708
- x: (det.bbox.x + det.bbox.width / 2) / frameW,
6709
- y: (det.bbox.y + det.bbox.height / 2) / frameH
6710
- }), (det) => det.macroClass);
6711
- if (passed.length === firstLevel.length) return result;
6712
- const passedIds = new Set(passed.map((d) => d.id));
6713
- const filteredDetails = details.filter((d) => {
6714
- const parentId = d.parentId;
6715
- return parentId === void 0 || passedIds.has(parentId);
6716
- });
6717
- return {
6718
- ...result,
6719
- detections: [...passed, ...filteredDetails]
6720
- };
6561
+ return applyZoneRuleGate(result, proxy.state.zones.value?.zones ?? [], proxy.state.zoneRules.value?.detection ?? []);
6721
6562
  }
6722
6563
  async cacheFrameInPool(input) {
6723
6564
  await this.ensureEngineFactory();
@@ -7307,12 +7148,49 @@ function stepsToPipelineConfig(steps, engine) {
7307
7148
  } : {}
7308
7149
  };
7309
7150
  }
7310
- /** Flatten a step tree into a flat list (DFS). */
7311
7151
  /** Flatten a step tree into a flat list (DFS) — delegates to shared utility. */
7312
7152
  function flattenSteps(steps) {
7313
7153
  return flattenEnabledVideoSteps(steps);
7314
7154
  }
7315
7155
  /**
7156
+ * Catalog root-vs-detail plane of a step: a non-null `inputClasses` on the
7157
+ * DEFINITION marks a crop child served per-track on the detail plane —
7158
+ * mirrors the executor's frame-plane skip (`executeChildren`) and the
7159
+ * runner's `deriveDetailSteps`. Unknown addonIds (already dropped from
7160
+ * resolved trees) default to root-plane.
7161
+ */
7162
+ function isDetailPlaneStep(addonId) {
7163
+ try {
7164
+ return require_step_definitions.getStepDefinition(addonId).inputClasses !== null;
7165
+ } catch {
7166
+ return false;
7167
+ }
7168
+ }
7169
+ /**
7170
+ * True when the step's FULL catalog ships no model with a build for
7171
+ * `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
7172
+ * addonIds (no catalog to check) are never zero-build, mirroring the
7173
+ * collector's skip.
7174
+ */
7175
+ function stepHasZeroBuildsFor(addonId, format) {
7176
+ return collectZeroBuildIssues([{ addonId }], format).length > 0;
7177
+ }
7178
+ /**
7179
+ * The union of model formats a step's catalog ships ANY build for — the
7180
+ * "Available: …" half of the capability-gate error. Unknown/custom addonIds
7181
+ * yield an empty list (no catalog to enumerate), never a throw.
7182
+ */
7183
+ function availableFormatsForStep(addonId) {
7184
+ try {
7185
+ const def = require_step_definitions.getStepDefinition(addonId);
7186
+ const formats = /* @__PURE__ */ new Set();
7187
+ for (const model of def.models) for (const format of Object.keys(model.formats)) formats.add(format);
7188
+ return Array.from(formats).toSorted();
7189
+ } catch {
7190
+ return [];
7191
+ }
7192
+ }
7193
+ /**
7316
7194
  * Return a shallow-cloned executable tree with `rootStepId`'s `settings`
7317
7195
  * merged with `overrides` (per-device values win). Non-matching root steps
7318
7196
  * pass through unchanged. Used by `runFrame` to apply per-device detection
@@ -7328,6 +7206,142 @@ function applyDeviceOverridesToTree(tree, rootStepId, overrides) {
7328
7206
  } : root) };
7329
7207
  }
7330
7208
  //#endregion
7209
+ //#region src/detection-pipeline/diagnostics/event-loop-stall-monitor.ts
7210
+ /**
7211
+ * event-loop-stall-monitor — names the thing that freezes the detection process.
7212
+ *
7213
+ * ## What this exists to settle
7214
+ *
7215
+ * On 2026-08-01/02 inference across the whole fleet stalled in bursts: 759
7216
+ * calls in 90 minutes crossed the runner's 20s deadline, p50 41s and max 80s,
7217
+ * and EVERY one of them eventually resolved. Six explanations were killed with
7218
+ * telemetry alone:
7219
+ *
7220
+ * - not a slow GPU or a wedged pool — the work completes, just enormously late;
7221
+ * - not CPU starvation — hosts sat at 8-48% during the freezes;
7222
+ * - not swap — `pswpin/pswpout` are flat zero on every host;
7223
+ * - not a logging artifact — Loki's timestamp trails the in-process one by
7224
+ * 0.1-1.0s at p95, so the gaps are real, not buffered delivery;
7225
+ * - not the v1.2.30 release — the stalls predate it by a day;
7226
+ * - not only the idle-pool recompile — that is real and hub-side, but the
7227
+ * agent stalls with no eviction and no compile at all.
7228
+ *
7229
+ * What IS established: the 10s stats timer — which touches no inference code —
7230
+ * stops firing for 22-88s and resumes at the same instant the abandoned calls
7231
+ * settle. The event loop itself freezes. From outside the process, GC and a
7232
+ * synchronous block are indistinguishable, and guessing between them is how
7233
+ * this investigation would go wrong. So measure it.
7234
+ *
7235
+ * ## How
7236
+ *
7237
+ * A 1s interval whose LATENESS is the freeze: a tick that arrives 40s late means
7238
+ * the loop was blocked ~39s, whatever blocked it. In the same window the GC
7239
+ * observer accumulates pause time. The two numbers together answer the question
7240
+ * outright:
7241
+ *
7242
+ * - `gcMs` ≈ `stallMs` → garbage collection. Look at heap growth.
7243
+ * - `gcMs` ≈ 0 → something SYNCHRONOUS blocked the loop, and GC is innocent.
7244
+ *
7245
+ * Heap and RSS ride along because if it is GC, the next question is always
7246
+ * "growing since when?".
7247
+ *
7248
+ * Deliberately not a histogram (`monitorEventLoopDelay`): that reports a
7249
+ * distribution, and the question here is about ONE outlier and what coincided
7250
+ * with it.
7251
+ */
7252
+ /** Tick period. Small enough to bound attribution error, large enough to cost
7253
+ * nothing on a process already doing frame work. */
7254
+ var STALL_TICK_MS = 1e3;
7255
+ /**
7256
+ * Report a freeze only past this. The runner's own inference deadline is 20s
7257
+ * and the observed freezes run 22-88s, so this is far below the signal while
7258
+ * staying clear of ordinary scheduling jitter (a busy frame path routinely
7259
+ * overruns a 1s timer by tens of ms).
7260
+ */
7261
+ var STALL_REPORT_MS = 2e3;
7262
+ /**
7263
+ * Attribute a freeze. Pure, because this is the ONE judgement the whole module
7264
+ * exists to make and it must be testable without freezing a real event loop.
7265
+ *
7266
+ * `gc` when garbage collection accounts for most of the stall; a
7267
+ * `synchronous-block` when it accounts for almost none — that verdict points
7268
+ * away from the heap entirely and at whatever ran without yielding.
7269
+ */
7270
+ function attributeStall(stallMs, gcMs, gcCount, longestGcMs) {
7271
+ const share = stallMs > 0 ? gcMs / stallMs : 0;
7272
+ return {
7273
+ stallMs,
7274
+ gcMs,
7275
+ gcCount,
7276
+ longestGcMs,
7277
+ cause: share >= .7 ? "gc" : share <= .2 ? "synchronous-block" : "mixed"
7278
+ };
7279
+ }
7280
+ /**
7281
+ * Start watching. Returns a stop function; safe to call twice.
7282
+ *
7283
+ * Never throws into the caller: a diagnostic that can take the detection
7284
+ * process down is worse than no diagnostic. `PerformanceObserver` for `gc` is
7285
+ * unavailable in some embedded runtimes, and that must degrade to
7286
+ * stall-detection-without-attribution rather than a boot failure.
7287
+ */
7288
+ function startEventLoopStallMonitor(logger, reportThresholdMs = STALL_REPORT_MS) {
7289
+ let gc = {
7290
+ totalMs: 0,
7291
+ count: 0,
7292
+ longestMs: 0
7293
+ };
7294
+ let observer = null;
7295
+ try {
7296
+ observer = new node_perf_hooks.PerformanceObserver((list) => {
7297
+ for (const entry of list.getEntries()) {
7298
+ gc.totalMs += entry.duration;
7299
+ gc.count += 1;
7300
+ if (entry.duration > gc.longestMs) gc.longestMs = entry.duration;
7301
+ }
7302
+ });
7303
+ observer.observe({ entryTypes: ["gc"] });
7304
+ } catch {
7305
+ observer = null;
7306
+ }
7307
+ let expected = Date.now() + STALL_TICK_MS;
7308
+ const timer = setInterval(() => {
7309
+ const now = Date.now();
7310
+ const lateBy = now - expected;
7311
+ const window = gc;
7312
+ gc = {
7313
+ totalMs: 0,
7314
+ count: 0,
7315
+ longestMs: 0
7316
+ };
7317
+ expected = now + STALL_TICK_MS;
7318
+ if (lateBy < reportThresholdMs) return;
7319
+ const verdict = attributeStall(lateBy, Math.round(window.totalMs), window.count, Math.round(window.longestMs));
7320
+ const mem = process.memoryUsage();
7321
+ logger.warn("event loop stalled", { meta: {
7322
+ stallMs: verdict.stallMs,
7323
+ cause: verdict.cause,
7324
+ gcMs: verdict.gcMs,
7325
+ gcCount: verdict.gcCount,
7326
+ longestGcMs: verdict.longestGcMs,
7327
+ gcObserved: observer !== null,
7328
+ heapUsedMb: Math.round(mem.heapUsed / 1e6),
7329
+ heapTotalMb: Math.round(mem.heapTotal / 1e6),
7330
+ rssMb: Math.round(mem.rss / 1e6),
7331
+ externalMb: Math.round(mem.external / 1e6),
7332
+ arrayBuffersMb: Math.round(mem.arrayBuffers / 1e6)
7333
+ } });
7334
+ }, STALL_TICK_MS);
7335
+ timer.unref?.();
7336
+ let stopped = false;
7337
+ return () => {
7338
+ if (stopped) return;
7339
+ stopped = true;
7340
+ clearInterval(timer);
7341
+ observer?.disconnect();
7342
+ };
7343
+ }
7344
+ //#endregion
7331
7345
  //#region src/detection-pipeline/python-readiness.ts
7332
7346
  /**
7333
7347
  * Resolve the embedded Python once, turning the null/throw failure modes into
@@ -7448,6 +7462,8 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7448
7462
  * doesn't flip a remote node's env back to the serving hub's platform. */
7449
7463
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
7450
7464
  engineMetricsTimer = null;
7465
+ /** Stops the event-loop stall monitor on shutdown. */
7466
+ stopStallMonitor = null;
7451
7467
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
7452
7468
  * the engine inventory is unchanged (no model load/unload), so
7453
7469
  * we skip the bus emit and let the heartbeat re-emit at
@@ -7786,6 +7802,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7786
7802
  async onInitialize() {
7787
7803
  const modelsDir = await this.resolveModelsDir();
7788
7804
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7805
+ this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
7789
7806
  this.pythonAddonDir = resolveAddonPythonDir();
7790
7807
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7791
7808
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
@@ -7872,6 +7889,10 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7872
7889
  }
7873
7890
  }
7874
7891
  async onShutdown() {
7892
+ if (this.stopStallMonitor) {
7893
+ this.stopStallMonitor();
7894
+ this.stopStallMonitor = null;
7895
+ }
7875
7896
  if (this.engineMetricsTimer) {
7876
7897
  clearInterval(this.engineMetricsTimer);
7877
7898
  this.engineMetricsTimer = null;