@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
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
2
  import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-Du7_SNiR.mjs";
3
- import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-BkY9_IM6.mjs";
3
+ import { a as getStep, i as getDefaultModelForFormatFromDef, n as ALL_STEPS, o as getStepDefinition, r as getDefaultModelForFormat, s as resolveModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-C8tk9fPJ.mjs";
4
4
  import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
5
5
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
6
6
  import * as os from "node:os";
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import sharp from "sharp";
9
9
  import * as fs from "node:fs";
10
10
  import * as path$1 from "node:path";
11
+ import { PerformanceObserver } from "node:perf_hooks";
11
12
  //#region src/detection-pipeline/runtimes.ts
12
13
  var KNOWN_PLATFORMS = [
13
14
  "darwin",
@@ -243,43 +244,6 @@ function normalizeEngineNodeId(rawNodeId) {
243
244
  return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
244
245
  }
245
246
  //#endregion
246
- //#region src/detection-pipeline/pipeline/native-crop-compose.ts
247
- /** Clamp `v` into `[lo, hi]`. */
248
- function clamp(v, lo, hi) {
249
- if (v < lo) return lo;
250
- if (v > hi) return hi;
251
- return v;
252
- }
253
- /**
254
- * Compose a child `roi` given in the parent CROP's normalized space into the
255
- * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
256
- * frame dimensions. The result is clamped to the unit square (origin in
257
- * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
258
- * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
259
- * clamps to zero area) so the caller falls back to the downscaled tile crop.
260
- *
261
- * Pure — never mutates its arguments.
262
- */
263
- function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
264
- if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
265
- if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
266
- const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
267
- const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
268
- const fw = roi.w * cropFrameSpace.w / frameWidth;
269
- const fh = roi.h * cropFrameSpace.h / frameHeight;
270
- const x = clamp(fx, 0, 1);
271
- const y = clamp(fy, 0, 1);
272
- const w = clamp(fw, 0, 1 - x);
273
- const h = clamp(fh, 0, 1 - y);
274
- if (!(w > 0) || !(h > 0)) return null;
275
- return {
276
- x,
277
- y,
278
- w,
279
- h
280
- };
281
- }
282
- //#endregion
283
247
  //#region src/detection-pipeline/default-detection-model.ts
284
248
  /** The object-detection step id — the only slot this resolver applies to. */
285
249
  var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
@@ -330,10 +294,12 @@ function classifyAccelerator(backend, device) {
330
294
  *
331
295
  * Guarantees:
332
296
  * - never throws (hot default-tree path);
333
- * - never returns a model id that is not in the object-detection catalog
334
- * with a build for `format` an unmapped backend, a `'cpu'` class, a
335
- * mapped id missing from the catalog, or a mapped id without a `format`
336
- * build all fall back to the step's own `defaultModelId` (`yolo26n`).
297
+ * - never returns a model id without a build for `format` (unless the step
298
+ * has ZERO builds for `format`, which is unloadable regardless of pick and
299
+ * flagged by `collectZeroBuildIssues`) an unmapped backend, a `'cpu'`
300
+ * class, a mapped id missing from the catalog, or a mapped id without a
301
+ * `format` build all fall back to the step's PER-FORMAT default
302
+ * (`getDefaultModelForFormatFromDef`), NOT the bare `defaultModelId`.
337
303
  *
338
304
  * `getStepDef` is injectable (defaults to the real catalog lookup) so the
339
305
  * missing-build fallback branch is unit-testable without a live catalog —
@@ -346,12 +312,12 @@ function resolveDefaultDetectionModel(backend, device, format, getStepDef = getS
346
312
  } catch {
347
313
  return "yolo26n";
348
314
  }
349
- const fallback = def.defaultModelId;
350
315
  const candidate = MODEL_BY_CLASS[classifyAccelerator(backend, device)];
351
- if (candidate === null) return fallback;
352
- const entry = def.models.find((m) => m.id === candidate);
353
- if (!entry || entry.formats[format] === void 0) return fallback;
354
- return candidate;
316
+ if (candidate !== null) {
317
+ const entry = def.models.find((m) => m.id === candidate);
318
+ if (entry && entry.formats[format] !== void 0) return candidate;
319
+ }
320
+ return getDefaultModelForFormatFromDef(def, format);
355
321
  }
356
322
  //#endregion
357
323
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
@@ -468,10 +434,7 @@ var PoolHandle = class {
468
434
  }
469
435
  async infer(input) {
470
436
  const start = performance.now();
471
- return {
472
- 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),
473
- inferenceMs: performance.now() - start
474
- };
437
+ 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);
475
438
  }
476
439
  /**
477
440
  * Inference on a frame previously cached in the Python pool via
@@ -480,20 +443,50 @@ var PoolHandle = class {
480
443
  */
481
444
  async inferFromCache(frameId) {
482
445
  const start = performance.now();
483
- return {
484
- structured: await this.pool.inferCached(this.modelIndex, frameId),
485
- inferenceMs: performance.now() - start
486
- };
446
+ return poolResultToEngineOutput(await this.pool.inferCached(this.modelIndex, frameId), performance.now() - start);
487
447
  }
488
448
  async dispose() {}
489
449
  };
490
450
  /**
451
+ * Map a raw pool response record to an {@link EngineOutput}.
452
+ *
453
+ * An overload shed (`{dropped: true}` — from the Python pool's per-model bound
454
+ * OR the TS worker in-flight cap, which deliberately mirrors it) becomes a
455
+ * first-class `EngineOutput.dropped` with NO `structured` payload. Before this
456
+ * mapping the shed record rode through as `structured`, failed the StepOutput
457
+ * kind guard in the postprocess funnel, and every deliberate shed was logged
458
+ * as `runInference failed … unexpected kind: undefined` at error level on
459
+ * every node (~4/s cluster-wide, 2026-08-01). A shed is flow control, not a
460
+ * fault — it must never reach a postprocessor. Exported for tests.
461
+ */
462
+ function poolResultToEngineOutput(result, inferenceMs) {
463
+ if (result["dropped"] === true) {
464
+ const shedReason = result["shedReason"];
465
+ return {
466
+ dropped: true,
467
+ ...typeof shedReason === "string" ? { shedReason } : {},
468
+ inferenceMs
469
+ };
470
+ }
471
+ return {
472
+ structured: result,
473
+ inferenceMs
474
+ };
475
+ }
476
+ /**
491
477
  * How long to wait for a worker to exit on SIGTERM before escalating to
492
478
  * SIGKILL. A worker idle between frames exits well under this; only a worker
493
479
  * stuck in a native inference call (openvino/onnx C++) needs the escalation.
494
480
  */
495
481
  var POOL_WORKER_TERM_GRACE_MS = 2e3;
496
482
  /**
483
+ * Sampling stride for the "Python pool shed frame under overload" debug
484
+ * line — see `trackDroppedResponse`. At the observed chronic-overload rate
485
+ * (~13 shed/s, 2026-08-01) a stride of 500 yields roughly one line every
486
+ * 40 s while `droppedTotal` in the line keeps the exact count.
487
+ */
488
+ var SHED_LOG_SAMPLE_EVERY = 500;
489
+ /**
497
490
  * Terminate a spawned child GRACEFULLY THEN FORCEFULLY: end stdin, send
498
491
  * SIGTERM, and if the process hasn't exited within `graceMs`, send SIGKILL.
499
492
  * Resolves once the process has exited (or was already dead).
@@ -597,8 +590,12 @@ var PoolWorker = class {
597
590
  this.ready = false;
598
591
  });
599
592
  this.process.stdout.on("data", (chunk) => {
593
+ const t0 = Date.now();
600
594
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
595
+ this.recvConcatMs += Date.now() - t0;
596
+ if (this.receiveBuffer.length > this.recvPeakBytes) this.recvPeakBytes = this.receiveBuffer.length;
601
597
  this.drainResponses();
598
+ this.reportRecvCostIfDue();
602
599
  });
603
600
  const config = {
604
601
  runtime: this.opts.poolRuntime,
@@ -785,7 +782,7 @@ var PoolWorker = class {
785
782
  device: this.opts.device ?? "default",
786
783
  inFlight: this.pending.size,
787
784
  reqId,
788
- timeoutMs: POOL_INFER_TIMEOUT_MS
785
+ timeoutMs
789
786
  } });
790
787
  reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
791
788
  }
@@ -841,6 +838,36 @@ var PoolWorker = class {
841
838
  ensureReady() {
842
839
  if (!this.ready || !this.process?.stdin) throw new Error(`PoolWorker[${this.opts.workerLabel}]: not initialized`);
843
840
  }
841
+ /** Time spent in `Buffer.concat` since the last report. */
842
+ recvConcatMs = 0;
843
+ /** Largest the receive buffer has grown since the last report. */
844
+ recvPeakBytes = 0;
845
+ /** When the last receive-cost report was emitted. */
846
+ recvReportedAt = 0;
847
+ /**
848
+ * Emit the receive-path cost at most once every 10s, and only when it is
849
+ * actually interesting — silence when the reader costs nothing is the whole
850
+ * point, otherwise this becomes another line nobody reads.
851
+ */
852
+ reportRecvCostIfDue() {
853
+ const now = Date.now();
854
+ if (this.recvReportedAt === 0) this.recvReportedAt = now;
855
+ if (now - this.recvReportedAt < 1e4) return;
856
+ const concatMs = this.recvConcatMs;
857
+ const peak = this.recvPeakBytes;
858
+ this.recvConcatMs = 0;
859
+ this.recvPeakBytes = 0;
860
+ this.recvReportedAt = now;
861
+ if (concatMs < 250) return;
862
+ this.log.warn("pool receive path is copying", { meta: {
863
+ worker: this.opts.workerLabel,
864
+ runtime: this.opts.poolRuntime,
865
+ device: this.opts.device ?? "default",
866
+ concatMs,
867
+ peakBufferKb: Math.round(peak / 1024),
868
+ windowMs: 1e4
869
+ } });
870
+ }
844
871
  drainResponses() {
845
872
  while (this.receiveBuffer.length >= 8) {
846
873
  const totalLen = this.receiveBuffer.readUInt32LE(0);
@@ -1055,13 +1082,23 @@ var SharedInferencePool = class {
1055
1082
  * single-frame inference paths. Previously indistinguishable from a
1056
1083
  * genuine empty detection result — now counted + debug-logged so
1057
1084
  * overload is measurable. The response passes through unchanged.
1085
+ *
1086
+ * SAMPLED log: one line per shed frame turned into 45 741 rows/hour on
1087
+ * the hub during the 2026-08-01 chronic overload (~13 shed/s) — 2/3 of
1088
+ * the hub's entire log volume, burying every other signal. The counter
1089
+ * keeps exact totals (`droppedTotal` is authoritative, surfaced in
1090
+ * `getStatus` consumers via `getDroppedResponseCount`); the log line
1091
+ * now fires on the FIRST shed and every `SHED_LOG_SAMPLE_EVERY`th,
1092
+ * carrying the running total so nothing is lost — only repeated.
1058
1093
  */
1059
1094
  trackDroppedResponse(result, modelIndex) {
1060
1095
  if (result["dropped"] === true) {
1061
1096
  this.droppedResponseCount++;
1062
- this.log.debug("Python pool shed frame under overload", { meta: {
1097
+ const total = this.droppedResponseCount;
1098
+ if (total === 1 || total % SHED_LOG_SAMPLE_EVERY === 0) this.log.debug("Python pool shed frame under overload", { meta: {
1063
1099
  modelIndex,
1064
- droppedTotal: this.droppedResponseCount,
1100
+ droppedTotal: total,
1101
+ sampledEvery: SHED_LOG_SAMPLE_EVERY,
1065
1102
  runtime: this.poolRuntime,
1066
1103
  device: this.device ?? "default"
1067
1104
  } });
@@ -1138,6 +1175,66 @@ function flattenEnabledVideoSteps(steps) {
1138
1175
  walk(steps);
1139
1176
  return result;
1140
1177
  }
1178
+ /**
1179
+ * The steps that EXECUTE in a `plane: 'frame'` dispatch (two-plane design),
1180
+ * mirroring the executor's frame-plane skip in `executeChildren`: every
1181
+ * enabled top-level step runs at the root; a CHILD runs only when its catalog
1182
+ * definition is itself root-plane (`isDetailStep(addonId)` false). The walk
1183
+ * continues only through steps that execute — a skipped detail child's
1184
+ * descendants ride ITS per-track detail-subtree call, never this one.
1185
+ * Disabled and audio-classifier steps execute on neither plane.
1186
+ */
1187
+ function collectFramePlaneSteps(steps, isDetailStep) {
1188
+ const result = [];
1189
+ const walk = (nodes, topLevel) => {
1190
+ for (const step of nodes) {
1191
+ if (!step.enabled) continue;
1192
+ if (step.slot === "audio-classifier") continue;
1193
+ if (!topLevel && isDetailStep(step.addonId)) continue;
1194
+ result.push(step);
1195
+ if (step.children?.length) walk(step.children, false);
1196
+ }
1197
+ };
1198
+ walk(steps, true);
1199
+ return result;
1200
+ }
1201
+ /**
1202
+ * Immutably remove from a frame-plane dispatch tree every enabled DETAIL
1203
+ * subtree (at any depth below the top level) whose top step `isUnrunnable`.
1204
+ * Those steps can never load into the dispatch device's pool — leaving them
1205
+ * in the tree makes `ensureModelsForSteps` fail the whole call on a format
1206
+ * build that does not exist. They are NOT dropped work: each runs later as
1207
+ * its own detail-subtree dispatch, where the device-jump resolver
1208
+ * (`resolveStepDevice`) places it on a same-node device that can run it, or
1209
+ * warns no-candidate loudly. Runnable detail children are kept so their
1210
+ * models pre-warm the device pool as before. Disabled subtrees are kept
1211
+ * verbatim (never walked — `ensureModelsForSteps` skips them anyway).
1212
+ */
1213
+ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1214
+ const prunedAddonIds = [];
1215
+ const walk = (nodes, topLevel) => {
1216
+ const kept = [];
1217
+ for (const step of nodes) {
1218
+ if (!step.enabled) {
1219
+ kept.push(step);
1220
+ continue;
1221
+ }
1222
+ if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
1223
+ prunedAddonIds.push(step.addonId);
1224
+ continue;
1225
+ }
1226
+ kept.push(step.children?.length ? {
1227
+ ...step,
1228
+ children: walk(step.children, false)
1229
+ } : step);
1230
+ }
1231
+ return kept;
1232
+ };
1233
+ return {
1234
+ steps: walk(steps, true),
1235
+ prunedAddonIds
1236
+ };
1237
+ }
1141
1238
  //#endregion
1142
1239
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1143
1240
  var PipelineModelManager = class {
@@ -1535,7 +1632,7 @@ var RUNTIME_TO_FORMAT = {
1535
1632
  edgetpu: "tflite"
1536
1633
  };
1537
1634
  /** The step whose detections are re-filtered downstream by the executor's
1538
- * per-macro `minConfidence*` sliders (`matchesMacroFilter`). Its pool-side
1635
+ * per-macro `minConfidence*` sliders (`macroFilterVerdict`). Its pool-side
1539
1636
  * floor must stay BELOW those sliders or it silently pre-empts them. */
1540
1637
  var OBJECT_DETECTION_STEP_ID = "object-detection";
1541
1638
  /**
@@ -1980,601 +2077,6 @@ var EngineProvisioner = class {
1980
2077
  }
1981
2078
  };
1982
2079
  //#endregion
1983
- //#region src/detection-pipeline/postprocess/dispatch.ts
1984
- /** Detector NMS IoU fallback — the historical hardcoded literal, used when the
1985
- * step carries no `nmsIouThreshold` setting (unset == today). */
1986
- var DEFAULT_NMS_IOU = .45;
1987
- /**
1988
- * Region plate grammars for the TS fallback CTC decoder (nodejs+onnx path).
1989
- * VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
1990
- * confusables — the confusable repair needs the aligned per-timestep softmax
1991
- * that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
1992
- * the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
1993
- */
1994
- var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
1995
- var VALID_KINDS = new Set([
1996
- "detections",
1997
- "classifications",
1998
- "embedding",
1999
- "text",
2000
- "mask"
2001
- ]);
2002
- /**
2003
- * Type guard: validates that a structured payload from the Python pool is a
2004
- * well-formed StepOutput discriminated union.
2005
- *
2006
- * The Python inference_pool.py always sets `output.structured` with a `kind`
2007
- * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
2008
- * resorting to double-cast.
2009
- */
2010
- function isStepOutput(value) {
2011
- return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2012
- }
2013
- /**
2014
- * Return the structured payload as `StepOutput`, or null if it fails validation.
2015
- * Callers must handle null by proceeding to raw-tensor postprocessing.
2016
- */
2017
- function tryStructured(output) {
2018
- if (!output.structured) return null;
2019
- if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
2020
- return output.structured;
2021
- }
2022
- function postprocessYolo(output, stepDef, nmsIouThreshold) {
2023
- const structured = tryStructured(output);
2024
- if (structured) return structured;
2025
- const tensor = output.tensor;
2026
- if (!tensor) throw new Error("YOLO postprocessor: no tensor in engine output");
2027
- const labels = stepDef.labels ?? [];
2028
- const numClasses = labels.length || 80;
2029
- const numBoxes = tensor.length / (4 + numClasses);
2030
- const letterbox = output.letterbox;
2031
- const dets = [];
2032
- for (let i = 0; i < numBoxes; i++) {
2033
- const cx = tensor[i];
2034
- const cy = tensor[1 * numBoxes + i];
2035
- const w = tensor[2 * numBoxes + i];
2036
- const h = tensor[3 * numBoxes + i];
2037
- let bestScore = -Infinity;
2038
- let bestClass = 0;
2039
- for (let j = 0; j < numClasses; j++) {
2040
- const score = tensor[(4 + j) * numBoxes + i];
2041
- if (score > bestScore) {
2042
- bestScore = score;
2043
- bestClass = j;
2044
- }
2045
- }
2046
- if (bestScore <= 0) continue;
2047
- let x1 = cx - w / 2;
2048
- let y1 = cy - h / 2;
2049
- let x2 = cx + w / 2;
2050
- let y2 = cy + h / 2;
2051
- if (letterbox) {
2052
- x1 = (x1 - letterbox.padX) / letterbox.scale;
2053
- y1 = (y1 - letterbox.padY) / letterbox.scale;
2054
- x2 = (x2 - letterbox.padX) / letterbox.scale;
2055
- y2 = (y2 - letterbox.padY) / letterbox.scale;
2056
- }
2057
- const label = labels[bestClass] ?? String(bestClass);
2058
- dets.push({
2059
- class: label,
2060
- score: bestScore,
2061
- bbox: [
2062
- x1,
2063
- y1,
2064
- x2,
2065
- y2
2066
- ]
2067
- });
2068
- }
2069
- return {
2070
- kind: "detections",
2071
- detections: simpleNms(dets, nmsIouThreshold)
2072
- };
2073
- }
2074
- function postprocessSsd(output, _stepDef) {
2075
- const structured = tryStructured(output);
2076
- if (structured) return structured;
2077
- throw new Error("SSD postprocessing runs in the Python inference pool (ssd.py); no TypeScript raw-tensor path.");
2078
- }
2079
- function postprocessScrfd(output, stepDef, nmsIouThreshold) {
2080
- const structured = tryStructured(output);
2081
- if (structured) return structured;
2082
- if (!output.tensors) throw new Error("SCRFD postprocessor: no tensors in engine output");
2083
- const strides = [
2084
- 8,
2085
- 16,
2086
- 32
2087
- ];
2088
- const anchorsPerStride = 2;
2089
- const inputSize = Math.max(stepDef.models[0]?.inputSize.width ?? 640, stepDef.models[0]?.inputSize.height ?? 640);
2090
- const strideData = matchTensorsToStrides(output.tensors, strides, inputSize, anchorsPerStride);
2091
- const letterbox = output.letterbox;
2092
- const scale = letterbox?.scale ?? 1;
2093
- const padX = letterbox?.padX ?? 0;
2094
- const padY = letterbox?.padY ?? 0;
2095
- const origW = letterbox?.originalWidth ?? inputSize;
2096
- const origH = letterbox?.originalHeight ?? inputSize;
2097
- const candidates = [];
2098
- for (const stride of strides) {
2099
- const data = strideData.get(stride);
2100
- if (!data) continue;
2101
- const { scores, bboxes, landmarks } = data;
2102
- const featSize = Math.ceil(inputSize / stride);
2103
- const numAnchors = featSize * featSize * anchorsPerStride;
2104
- for (let i = 0; i < numAnchors; i++) {
2105
- if (i >= scores.length) break;
2106
- const score = scores[i];
2107
- if (score <= 0) continue;
2108
- const gridIdx = Math.floor(i / anchorsPerStride);
2109
- const cx = (gridIdx % featSize + .5) * stride;
2110
- const cy = (Math.floor(gridIdx / featSize) + .5) * stride;
2111
- const x1 = cx - bboxes[i * 4] * stride;
2112
- const y1 = cy - bboxes[i * 4 + 1] * stride;
2113
- const x2 = cx + bboxes[i * 4 + 2] * stride;
2114
- const y2 = cy + bboxes[i * 4 + 3] * stride;
2115
- const ox1 = Math.max(0, Math.min(origW, (x1 - padX) / scale));
2116
- const oy1 = Math.max(0, Math.min(origH, (y1 - padY) / scale));
2117
- const ox2 = Math.max(0, Math.min(origW, (x2 - padX) / scale));
2118
- const oy2 = Math.max(0, Math.min(origH, (y2 - padY) / scale));
2119
- if (ox2 - ox1 < 1 || oy2 - oy1 < 1) continue;
2120
- const det = {
2121
- class: "face",
2122
- score: Math.round(score * 1e4) / 1e4,
2123
- bbox: [
2124
- Math.round(ox1 * 10) / 10,
2125
- Math.round(oy1 * 10) / 10,
2126
- Math.round(ox2 * 10) / 10,
2127
- Math.round(oy2 * 10) / 10
2128
- ],
2129
- _xyxy: [
2130
- ox1,
2131
- oy1,
2132
- ox2,
2133
- oy2
2134
- ]
2135
- };
2136
- if (landmarks && i * 10 + 9 < landmarks.length) {
2137
- det.landmarks = [];
2138
- for (let p = 0; p < 5; p++) {
2139
- const lx = (cx + landmarks[i * 10 + p * 2] * stride - padX) / scale;
2140
- const ly = (cy + landmarks[i * 10 + p * 2 + 1] * stride - padY) / scale;
2141
- det.landmarks.push({
2142
- x: Math.round(lx * 10) / 10,
2143
- y: Math.round(ly * 10) / 10
2144
- });
2145
- }
2146
- }
2147
- candidates.push(det);
2148
- }
2149
- }
2150
- return {
2151
- kind: "detections",
2152
- detections: simpleNms(candidates.map((c) => ({
2153
- class: c.class,
2154
- score: c.score,
2155
- bbox: c.bbox,
2156
- ...c.landmarks ? { landmarks: c.landmarks } : {}
2157
- })), nmsIouThreshold)
2158
- };
2159
- }
2160
- /** Match ONNX output tensors to SCRFD strides by expected anchor count. */
2161
- function matchTensorsToStrides(tensors, strides, inputSize, anchorsPerStride) {
2162
- const scoresByN = /* @__PURE__ */ new Map();
2163
- const bboxesByN = /* @__PURE__ */ new Map();
2164
- const landmarksByN = /* @__PURE__ */ new Map();
2165
- for (const [, tensor] of Object.entries(tensors)) for (const stride of strides) {
2166
- const featSize = Math.ceil(inputSize / stride);
2167
- const expectedN = featSize * featSize * anchorsPerStride;
2168
- if (tensor.length === expectedN) scoresByN.set(expectedN, tensor);
2169
- else if (tensor.length === expectedN * 4) bboxesByN.set(expectedN, tensor);
2170
- else if (tensor.length === expectedN * 10) landmarksByN.set(expectedN, tensor);
2171
- }
2172
- const result = /* @__PURE__ */ new Map();
2173
- for (const stride of strides) {
2174
- const featSize = Math.ceil(inputSize / stride);
2175
- const n = featSize * featSize * anchorsPerStride;
2176
- const scores = scoresByN.get(n);
2177
- const bboxes = bboxesByN.get(n);
2178
- if (scores && bboxes) result.set(stride, {
2179
- scores,
2180
- bboxes,
2181
- landmarks: landmarksByN.get(n)
2182
- });
2183
- }
2184
- return result;
2185
- }
2186
- function postprocessArcface(output, _stepDef) {
2187
- const structured = tryStructured(output);
2188
- if (structured) return structured;
2189
- const tensor = output.tensor;
2190
- if (!tensor) throw new Error("ArcFace postprocessor: no tensor in engine output");
2191
- let sumSq = 0;
2192
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
2193
- const norm = Math.sqrt(sumSq);
2194
- const normalized = Array.from({ length: tensor.length });
2195
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
2196
- return {
2197
- kind: "embedding",
2198
- embedding: Array.from(tensor),
2199
- embeddingNorm: normalized
2200
- };
2201
- }
2202
- function postprocessClip(output, _stepDef) {
2203
- const structured = tryStructured(output);
2204
- if (structured) return structured;
2205
- const tensor = output.tensor;
2206
- if (!tensor) throw new Error("CLIP postprocessor: no tensor in engine output");
2207
- let sumSq = 0;
2208
- for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
2209
- const norm = Math.sqrt(sumSq);
2210
- const normalized = Array.from({ length: tensor.length });
2211
- for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
2212
- return {
2213
- kind: "embedding",
2214
- embedding: Array.from(tensor),
2215
- embeddingNorm: normalized
2216
- };
2217
- }
2218
- function postprocessSoftmax(output, stepDef) {
2219
- const structured = tryStructured(output);
2220
- if (structured) return structured;
2221
- const tensor = output.tensor;
2222
- if (!tensor) throw new Error("Softmax postprocessor: no tensor in engine output");
2223
- const labels = stepDef.labels ?? [];
2224
- let max = -Infinity;
2225
- for (let i = 0; i < tensor.length; i++) if (tensor[i] > max) max = tensor[i];
2226
- const exps = new Float32Array(tensor.length);
2227
- let sum = 0;
2228
- for (let i = 0; i < tensor.length; i++) {
2229
- exps[i] = Math.exp(tensor[i] - max);
2230
- sum += exps[i];
2231
- }
2232
- const classifications = [];
2233
- for (let i = 0; i < exps.length; i++) {
2234
- const score = exps[i] / sum;
2235
- if (score >= .01) classifications.push({
2236
- class: labels[i] ?? String(i),
2237
- score
2238
- });
2239
- }
2240
- classifications.sort((a, b) => b.score - a.score);
2241
- return {
2242
- kind: "classifications",
2243
- classifications: classifications.slice(0, 5)
2244
- };
2245
- }
2246
- function postprocessCtc(output, stepDef) {
2247
- const structured = tryStructured(output);
2248
- if (structured) return structured;
2249
- const tensor = output.tensor;
2250
- if (!tensor) throw new Error("CTC postprocessor: no tensor in engine output");
2251
- const charset = stepDef.charset ?? [];
2252
- const numChars = charset.length || 97;
2253
- const seqLen = Math.floor(tensor.length / numChars);
2254
- const chars = [];
2255
- let totalScore = 0;
2256
- let prev = -1;
2257
- for (let t = 0; t < seqLen; t++) {
2258
- let bestIdx = 0;
2259
- let bestVal = tensor[t * numChars];
2260
- for (let c = 1; c < numChars; c++) {
2261
- const val = tensor[t * numChars + c];
2262
- if (val > bestVal) {
2263
- bestVal = val;
2264
- bestIdx = c;
2265
- }
2266
- }
2267
- totalScore += bestVal;
2268
- if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
2269
- prev = bestIdx;
2270
- }
2271
- const text = chars.join("");
2272
- const region = stepDef.plateRegion;
2273
- if (region !== void 0 && region !== "off") {
2274
- const grammar = TS_PLATE_GRAMMARS[region];
2275
- if (grammar) return {
2276
- kind: "text",
2277
- text,
2278
- confidence: seqLen > 0 ? totalScore / seqLen : 0,
2279
- formatValid: grammar.test(text)
2280
- };
2281
- }
2282
- return {
2283
- kind: "text",
2284
- text,
2285
- confidence: seqLen > 0 ? totalScore / seqLen : 0
2286
- };
2287
- }
2288
- /**
2289
- * Bounding box [x, y, w, h] (mask-pixel space) of the largest 4-connected
2290
- * foreground component. Port of `_largest_component_bbox` from
2291
- * `python/postprocessors/saliency.py`.
2292
- *
2293
- * Returns null when there are no foreground pixels.
2294
- */
2295
- function largestComponentBbox(binary, w, h) {
2296
- const seeds = [];
2297
- 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]);
2298
- if (seeds.length === 0) return null;
2299
- const visited = new Uint8Array(w * h);
2300
- let best = null;
2301
- for (const [sy, sx] of seeds) {
2302
- const seedIdx = sy * w + sx;
2303
- if (visited[seedIdx] === 1) continue;
2304
- const stack = [[sy, sx]];
2305
- visited[seedIdx] = 1;
2306
- let minX = sx;
2307
- let maxX = sx;
2308
- let minY = sy;
2309
- let maxY = sy;
2310
- let area = 0;
2311
- while (stack.length > 0) {
2312
- const [cy, cx] = stack.pop();
2313
- area += 1;
2314
- if (cx < minX) minX = cx;
2315
- if (cx > maxX) maxX = cx;
2316
- if (cy < minY) minY = cy;
2317
- if (cy > maxY) maxY = cy;
2318
- const neighbours = [
2319
- [cy - 1, cx],
2320
- [cy + 1, cx],
2321
- [cy, cx - 1],
2322
- [cy, cx + 1]
2323
- ];
2324
- for (const [ny, nx] of neighbours) {
2325
- if (ny < 0 || ny >= h || nx < 0 || nx >= w) continue;
2326
- const nIdx = ny * w + nx;
2327
- if ((binary[nIdx] ?? 0) > 0 && visited[nIdx] !== 1) {
2328
- visited[nIdx] = 1;
2329
- stack.push([ny, nx]);
2330
- }
2331
- }
2332
- }
2333
- if (best === null || area > best.area) best = {
2334
- area,
2335
- minX,
2336
- minY,
2337
- maxX,
2338
- maxY
2339
- };
2340
- }
2341
- if (best === null) return null;
2342
- return [
2343
- best.minX,
2344
- best.minY,
2345
- best.maxX - best.minX + 1,
2346
- best.maxY - best.minY + 1
2347
- ];
2348
- }
2349
- function postprocessSaliency(output, _stepDef) {
2350
- const structured = tryStructured(output);
2351
- if (structured) return structured;
2352
- const tensor = output.tensor;
2353
- if (!tensor) throw new Error("Saliency postprocessor: no tensor in engine output");
2354
- const binary = new Uint8Array(tensor.length);
2355
- for (let i = 0; i < tensor.length; i++) binary[i] = 1 / (1 + Math.exp(-tensor[i])) > .5 ? 255 : 0;
2356
- const side = Math.round(Math.sqrt(tensor.length));
2357
- const mask = Buffer.from(binary).toString("base64");
2358
- const maskBbox = largestComponentBbox(binary, side, side);
2359
- if (maskBbox !== null) return {
2360
- kind: "mask",
2361
- mask,
2362
- maskWidth: side,
2363
- maskHeight: side,
2364
- maskBbox
2365
- };
2366
- return {
2367
- kind: "mask",
2368
- mask,
2369
- maskWidth: side,
2370
- maskHeight: side
2371
- };
2372
- }
2373
- function postprocessYoloSeg(output, stepDef, nmsIouThreshold) {
2374
- const structured = tryStructured(output);
2375
- if (structured) return structured;
2376
- const tensors = output.tensors;
2377
- if (!tensors) throw new Error("YOLO-seg postprocessor: no tensors in engine output");
2378
- const labels = stepDef.labels ?? [];
2379
- const numClasses = labels.length || 80;
2380
- const numMaskCoeffs = 32;
2381
- const protoSize = 160;
2382
- const letterbox = output.letterbox;
2383
- let detTensor;
2384
- let protoTensor;
2385
- let detRows = 300;
2386
- let detCols = 0;
2387
- for (const [, tensor] of Object.entries(tensors)) {
2388
- if (tensor.length === numMaskCoeffs * protoSize * protoSize) {
2389
- protoTensor = tensor;
2390
- continue;
2391
- }
2392
- const cols = 38;
2393
- if (tensor.length % cols === 0) {
2394
- detTensor = tensor;
2395
- detRows = tensor.length / cols;
2396
- detCols = cols;
2397
- continue;
2398
- }
2399
- const rawCols = 4 + numClasses + numMaskCoeffs;
2400
- if (tensor.length % rawCols === 0 && !detTensor) {
2401
- detTensor = tensor;
2402
- detRows = tensor.length / rawCols;
2403
- detCols = rawCols;
2404
- }
2405
- }
2406
- if (!detTensor) throw new Error("YOLO-seg postprocessor: could not find detection tensor");
2407
- if (!protoTensor) throw new Error("YOLO-seg postprocessor: could not find prototype tensor");
2408
- const isNmsFormat = detCols === 38;
2409
- const dets = [];
2410
- for (let i = 0; i < detRows; i++) {
2411
- const offset = i * detCols;
2412
- let x1, y1, x2, y2;
2413
- let bestScore;
2414
- let bestClass;
2415
- let coeffStart;
2416
- if (isNmsFormat) {
2417
- x1 = detTensor[offset];
2418
- y1 = detTensor[offset + 1];
2419
- x2 = detTensor[offset + 2];
2420
- y2 = detTensor[offset + 3];
2421
- bestScore = detTensor[offset + 4];
2422
- bestClass = detTensor[offset + 5];
2423
- coeffStart = offset + 6;
2424
- if (bestScore <= 0) continue;
2425
- if (bestClass < 0) continue;
2426
- } else {
2427
- const cx = detTensor[offset];
2428
- const cy = detTensor[offset + 1];
2429
- const w = detTensor[offset + 2];
2430
- const h = detTensor[offset + 3];
2431
- bestScore = -Infinity;
2432
- bestClass = 0;
2433
- for (let j = 0; j < numClasses; j++) {
2434
- const score = detTensor[offset + 4 + j];
2435
- if (score > bestScore) {
2436
- bestScore = score;
2437
- bestClass = j;
2438
- }
2439
- }
2440
- if (bestScore <= 0) continue;
2441
- x1 = cx - w / 2;
2442
- y1 = cy - h / 2;
2443
- x2 = cx + w / 2;
2444
- y2 = cy + h / 2;
2445
- coeffStart = offset + 4 + numClasses;
2446
- }
2447
- const coeffs = new Float32Array(numMaskCoeffs);
2448
- for (let j = 0; j < numMaskCoeffs; j++) coeffs[j] = detTensor[coeffStart + j];
2449
- const maskPixels = protoSize * protoSize;
2450
- const maskRaw = new Float32Array(maskPixels);
2451
- for (let p = 0; p < maskPixels; p++) {
2452
- let sum = 0;
2453
- for (let c = 0; c < numMaskCoeffs; c++) sum += coeffs[c] * protoTensor[c * maskPixels + p];
2454
- maskRaw[p] = 1 / (1 + Math.exp(-sum));
2455
- }
2456
- const protoScale = protoSize / (stepDef.models[0]?.inputSize.width ?? 640);
2457
- const px1 = Math.max(0, Math.floor(x1 * protoScale));
2458
- const py1 = Math.max(0, Math.floor(y1 * protoScale));
2459
- const px2 = Math.min(protoSize, Math.ceil(x2 * protoScale));
2460
- const py2 = Math.min(protoSize, Math.ceil(y2 * protoScale));
2461
- const cropW = Math.max(1, px2 - px1);
2462
- const cropH = Math.max(1, py2 - py1);
2463
- const cropped = new Uint8Array(cropW * cropH);
2464
- for (let row = 0; row < cropH; row++) for (let col = 0; col < cropW; col++) {
2465
- const srcIdx = (py1 + row) * protoSize + (px1 + col);
2466
- cropped[row * cropW + col] = maskRaw[srcIdx] > .5 ? 255 : 0;
2467
- }
2468
- let ox1 = x1;
2469
- let oy1 = y1;
2470
- let ox2 = x2;
2471
- let oy2 = y2;
2472
- if (letterbox) {
2473
- ox1 = (x1 - letterbox.padX) / letterbox.scale;
2474
- oy1 = (y1 - letterbox.padY) / letterbox.scale;
2475
- ox2 = (x2 - letterbox.padX) / letterbox.scale;
2476
- oy2 = (y2 - letterbox.padY) / letterbox.scale;
2477
- }
2478
- const label = labels[bestClass] ?? String(bestClass);
2479
- const maskB64 = Buffer.from(cropped).toString("base64");
2480
- dets.push({
2481
- class: label,
2482
- score: Math.round(bestScore * 1e4) / 1e4,
2483
- bbox: [
2484
- Math.round(ox1 * 10) / 10,
2485
- Math.round(oy1 * 10) / 10,
2486
- Math.round(ox2 * 10) / 10,
2487
- Math.round(oy2 * 10) / 10
2488
- ],
2489
- mask: maskB64,
2490
- maskWidth: cropW,
2491
- maskHeight: cropH
2492
- });
2493
- }
2494
- const keptBoxes = simpleNms(dets.map((d) => ({
2495
- class: d.class,
2496
- score: d.score,
2497
- bbox: d.bbox
2498
- })), nmsIouThreshold);
2499
- const keptSet = new Set(keptBoxes.map((d) => `${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`));
2500
- return {
2501
- kind: "detections",
2502
- detections: dets.filter((d) => keptSet.has(`${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`))
2503
- };
2504
- }
2505
- function postprocessYamnet(output, _stepDef) {
2506
- const structured = tryStructured(output);
2507
- if (structured) return structured;
2508
- throw new Error("YAMNet TypeScript postprocessing not implemented. Use Python backend or Apple SoundAnalysis.");
2509
- }
2510
- var DISPATCH = {
2511
- yolo: postprocessYolo,
2512
- ssd: postprocessSsd,
2513
- "yolo-seg": postprocessYoloSeg,
2514
- scrfd: postprocessScrfd,
2515
- arcface: postprocessArcface,
2516
- clip: postprocessClip,
2517
- softmax: postprocessSoftmax,
2518
- ctc: postprocessCtc,
2519
- saliency: postprocessSaliency,
2520
- yamnet: postprocessYamnet
2521
- };
2522
- /**
2523
- * Dispatch postprocessing based on step definition's postprocessor type.
2524
- *
2525
- * For Python backends: output.structured is already populated → passthrough.
2526
- * For Node.js ONNX: raw tensors → postprocess in TypeScript.
2527
- *
2528
- * Both paths pass through normalizeOutput() to ensure consistent format
2529
- * (e.g., classification top-K, alternates).
2530
- */
2531
- function dispatchPostprocess(output, stepDef, settings) {
2532
- const fn = DISPATCH[stepDef.postprocessor];
2533
- if (!fn) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2534
- const raw = settings?.["nmsIouThreshold"];
2535
- return normalizeOutput(fn(output, stepDef, typeof raw === "number" && raw > 0 ? raw : DEFAULT_NMS_IOU));
2536
- }
2537
- /**
2538
- * Normalize any StepOutput to a consistent format.
2539
- * Single funnel for both Python and TypeScript postprocessor paths.
2540
- *
2541
- * Classifications: keep only top-1 in classifications, move full list to alternates.
2542
- */
2543
- function normalizeOutput(output) {
2544
- if (output.kind !== "classifications") return output;
2545
- const all = output.classifications;
2546
- if (all.length <= 1) return output;
2547
- return {
2548
- kind: "classifications",
2549
- classifications: all.slice(0, 1),
2550
- alternates: all
2551
- };
2552
- }
2553
- function simpleNms(dets, iouThreshold) {
2554
- if (dets.length === 0) return [];
2555
- const sorted = [...dets].toSorted((a, b) => b.score - a.score);
2556
- const kept = [];
2557
- const suppressed = /* @__PURE__ */ new Set();
2558
- for (let i = 0; i < sorted.length; i++) {
2559
- if (suppressed.has(i)) continue;
2560
- kept.push(sorted[i]);
2561
- for (let j = i + 1; j < sorted.length; j++) {
2562
- if (suppressed.has(j)) continue;
2563
- if (iou(sorted[i].bbox, sorted[j].bbox) > iouThreshold) suppressed.add(j);
2564
- }
2565
- }
2566
- return kept;
2567
- }
2568
- function iou(a, b) {
2569
- const ix1 = Math.max(a[0], b[0]);
2570
- const iy1 = Math.max(a[1], b[1]);
2571
- const ix2 = Math.min(a[2], b[2]);
2572
- const iy2 = Math.min(a[3], b[3]);
2573
- const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2574
- if (inter === 0) return 0;
2575
- return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
2576
- }
2577
- //#endregion
2578
2080
  //#region src/detection-pipeline/pipeline/crop-utils.ts
2579
2081
  /**
2580
2082
  * Crop utilities — ROI extraction and bbox coordinate transforms.
@@ -2624,34 +2126,244 @@ function isBboxDegenerate(bbox) {
2624
2126
  return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
2625
2127
  }
2626
2128
  //#endregion
2627
- //#region src/detection-pipeline/pipeline/face-align.ts
2129
+ //#region src/detection-pipeline/pipeline/crop-zone.ts
2628
2130
  /**
2629
- * Face alignment — landmark-based similarity warp to the ArcFace template.
2630
- *
2631
- * ArcFace (and most face-recognition embedders) are only discriminative when
2632
- * the input is a *landmark-aligned* 112x112 crop: eyes, nose and mouth corners
2633
- * must land on fixed canonical positions. Feeding a raw axis-aligned bbox crop
2634
- * stretched to 112x112 (the previous behaviour) collapses embeddings toward a
2635
- * common direction — different people end up with ~0.99 cosine similarity.
2636
- *
2637
- * This module computes the least-squares similarity transform (rotation +
2638
- * uniform scale + translation, the cv2.estimateAffinePartial2D recipe) that
2639
- * maps SCRFD's 5 facial landmarks onto the canonical template, then resamples
2640
- * the source pixels into a fixed-size aligned crop with bilinear interpolation.
2131
+ * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
2132
+ * bounding box of the UNION of every polygon referenced by the device's
2133
+ * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
2134
+ * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
2641
2135
  *
2642
- * The warp is done in pure JS on a raw RGB buffer (a few thousand samples per
2643
- * face negligible cost) so it is fully deterministic and unit-testable, and
2644
- * needs no native affine primitive nor any change to the Python inference pool.
2645
- */
2646
- /**
2647
- * Canonical ArcFace 5-point template for a 112x112 aligned crop
2648
- * (insightface reference points). Order matches SCRFD landmark output:
2649
- * left-eye, right-eye, nose, left-mouth-corner, right-mouth-corner.
2136
+ * Returns `null` when there is nothing usable to crop to no enabled rules, no
2137
+ * referenced geometry, or a degenerate result in which case the caller falls
2138
+ * back to full-frame inference (behaviour identical to before the crop-zone
2139
+ * plumbing).
2650
2140
  */
2651
- var ARCFACE_TEMPLATE_112 = [
2652
- {
2653
- x: 38.2946,
2654
- y: 51.6963
2141
+ function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
2142
+ if (frameWidth <= 0 || frameHeight <= 0) return null;
2143
+ const enabledRules = packageRules.filter((r) => r.enabled !== false);
2144
+ if (enabledRules.length === 0) return null;
2145
+ const zoneIds = /* @__PURE__ */ new Set();
2146
+ for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
2147
+ if (zoneIds.size === 0) return null;
2148
+ const zoneById = new Map(zones.map((z) => [z.id, z]));
2149
+ let minX = Number.POSITIVE_INFINITY;
2150
+ let minY = Number.POSITIVE_INFINITY;
2151
+ let maxX = Number.NEGATIVE_INFINITY;
2152
+ let maxY = Number.NEGATIVE_INFINITY;
2153
+ let sawPoint = false;
2154
+ for (const id of zoneIds) {
2155
+ const zone = zoneById.get(id);
2156
+ if (!zone || zone.polygon.length === 0) continue;
2157
+ for (const point of zone.polygon) {
2158
+ sawPoint = true;
2159
+ if (point.x < minX) minX = point.x;
2160
+ if (point.y < minY) minY = point.y;
2161
+ if (point.x > maxX) maxX = point.x;
2162
+ if (point.y > maxY) maxY = point.y;
2163
+ }
2164
+ }
2165
+ if (!sawPoint) return null;
2166
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
2167
+ const bbox = [
2168
+ clamp01(minX) * frameWidth,
2169
+ clamp01(minY) * frameHeight,
2170
+ clamp01(maxX) * frameWidth,
2171
+ clamp01(maxY) * frameHeight
2172
+ ];
2173
+ if (isBboxDegenerate(bbox)) return null;
2174
+ return bbox;
2175
+ }
2176
+ //#endregion
2177
+ //#region src/detection-pipeline/postprocess/dispatch.ts
2178
+ var VALID_KINDS = new Set([
2179
+ "detections",
2180
+ "classifications",
2181
+ "embedding",
2182
+ "text",
2183
+ "mask"
2184
+ ]);
2185
+ /** Every postprocessor the Python pool implements — the dispatch table's keys
2186
+ * survived the raw-tensor removal as a config-drift guard: an unknown
2187
+ * postprocessor in a step definition still fails fast here. */
2188
+ var KNOWN_POSTPROCESSORS = new Set([
2189
+ "yolo",
2190
+ "ssd",
2191
+ "yolo-seg",
2192
+ "scrfd",
2193
+ "arcface",
2194
+ "clip",
2195
+ "softmax",
2196
+ "ctc",
2197
+ "saliency",
2198
+ "yamnet"
2199
+ ]);
2200
+ /**
2201
+ * Type guard: validates that a structured payload from the Python pool is a
2202
+ * well-formed StepOutput discriminated union.
2203
+ *
2204
+ * The Python inference_pool.py always sets `output.structured` with a `kind`
2205
+ * field. This guard narrows `Record<string, unknown>` to `StepOutput` without
2206
+ * resorting to double-cast.
2207
+ */
2208
+ function isStepOutput(value) {
2209
+ return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
2210
+ }
2211
+ /**
2212
+ * Validate + normalize a Python-pool engine output into a StepOutput.
2213
+ */
2214
+ function dispatchPostprocess(output, stepDef) {
2215
+ if (output.dropped === true) return {
2216
+ kind: "detections",
2217
+ detections: []
2218
+ };
2219
+ if (!KNOWN_POSTPROCESSORS.has(stepDef.postprocessor)) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
2220
+ 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.`);
2221
+ if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
2222
+ return normalizeOutput(output.structured);
2223
+ }
2224
+ /**
2225
+ * Normalize any StepOutput to a consistent format — the single funnel every
2226
+ * pool payload passes through.
2227
+ *
2228
+ * Classifications: keep only top-1 in classifications, move full list to alternates.
2229
+ */
2230
+ function normalizeOutput(output) {
2231
+ if (output.kind !== "classifications") return output;
2232
+ const all = output.classifications;
2233
+ if (all.length <= 1) return output;
2234
+ return {
2235
+ kind: "classifications",
2236
+ classifications: all.slice(0, 1),
2237
+ alternates: all
2238
+ };
2239
+ }
2240
+ //#endregion
2241
+ //#region src/detection-pipeline/pipeline/execution-trace.ts
2242
+ var traceCounter = 0;
2243
+ var ExecutionTraceBuilder = class {
2244
+ verbosity;
2245
+ deviceId;
2246
+ frameWidth;
2247
+ frameHeight;
2248
+ engineRuntime;
2249
+ steps = [];
2250
+ startTime;
2251
+ constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
2252
+ this.verbosity = verbosity;
2253
+ this.deviceId = deviceId;
2254
+ this.frameWidth = frameWidth;
2255
+ this.frameHeight = frameHeight;
2256
+ this.engineRuntime = engineRuntime;
2257
+ this.startTime = Date.now();
2258
+ }
2259
+ /** Whether trace collection is active. */
2260
+ get isActive() {
2261
+ return this.verbosity !== "off";
2262
+ }
2263
+ /**
2264
+ * Record a completed step execution.
2265
+ * No-op if verbosity is 'off'.
2266
+ */
2267
+ addStep(params) {
2268
+ if (this.verbosity === "off") return;
2269
+ const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
2270
+ const trace = {
2271
+ stepId: params.stepId,
2272
+ modelId: params.modelId,
2273
+ slot: params.slot,
2274
+ postprocessor: params.postprocessor,
2275
+ preprocessMs: Math.round(params.preprocessMs * 100) / 100,
2276
+ inferenceMs: Math.round(params.inferenceMs * 100) / 100,
2277
+ postprocessMs: Math.round(params.postprocessMs * 100) / 100,
2278
+ totalMs: Math.round(totalMs * 100) / 100,
2279
+ inputType: params.inputType,
2280
+ inputSize: {
2281
+ width: params.inputWidth,
2282
+ height: params.inputHeight
2283
+ },
2284
+ parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
2285
+ outputKind: params.output.kind,
2286
+ outputCount: countOutput(params.output),
2287
+ topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
2288
+ error: params.error
2289
+ };
2290
+ this.steps.push(trace);
2291
+ }
2292
+ /**
2293
+ * Finalize and return the complete execution trace.
2294
+ * Returns null if verbosity is 'off'.
2295
+ */
2296
+ build(detectionCount) {
2297
+ if (this.verbosity === "off") return null;
2298
+ const totalMs = Date.now() - this.startTime;
2299
+ return {
2300
+ traceId: `trace-${Date.now()}-${++traceCounter}`,
2301
+ deviceId: this.deviceId,
2302
+ timestamp: this.startTime,
2303
+ frameSize: {
2304
+ width: this.frameWidth,
2305
+ height: this.frameHeight
2306
+ },
2307
+ engineRuntime: this.engineRuntime,
2308
+ steps: this.steps,
2309
+ totalMs,
2310
+ detectionCount
2311
+ };
2312
+ }
2313
+ };
2314
+ function countOutput(output) {
2315
+ switch (output.kind) {
2316
+ case "detections": return output.detections.length;
2317
+ case "classifications": return output.classifications.length;
2318
+ case "embedding": return 1;
2319
+ case "text": return output.text.length > 0 ? 1 : 0;
2320
+ case "mask": return 1;
2321
+ }
2322
+ }
2323
+ function formatTopResult(output) {
2324
+ switch (output.kind) {
2325
+ case "detections": {
2326
+ const top = output.detections[0];
2327
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2328
+ }
2329
+ case "classifications": {
2330
+ const top = output.classifications[0];
2331
+ return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
2332
+ }
2333
+ case "embedding": return `embedding[${output.embedding.length}]`;
2334
+ case "text": return output.text || void 0;
2335
+ case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
2336
+ }
2337
+ }
2338
+ //#endregion
2339
+ //#region src/detection-pipeline/pipeline/face-align.ts
2340
+ /**
2341
+ * Face alignment — landmark-based similarity warp to the ArcFace template.
2342
+ *
2343
+ * ArcFace (and most face-recognition embedders) are only discriminative when
2344
+ * the input is a *landmark-aligned* 112x112 crop: eyes, nose and mouth corners
2345
+ * must land on fixed canonical positions. Feeding a raw axis-aligned bbox crop
2346
+ * stretched to 112x112 (the previous behaviour) collapses embeddings toward a
2347
+ * common direction — different people end up with ~0.99 cosine similarity.
2348
+ *
2349
+ * This module computes the least-squares similarity transform (rotation +
2350
+ * uniform scale + translation, the cv2.estimateAffinePartial2D recipe) that
2351
+ * maps SCRFD's 5 facial landmarks onto the canonical template, then resamples
2352
+ * the source pixels into a fixed-size aligned crop with bilinear interpolation.
2353
+ *
2354
+ * The warp is done in pure JS on a raw RGB buffer (a few thousand samples per
2355
+ * face — negligible cost) so it is fully deterministic and unit-testable, and
2356
+ * needs no native affine primitive nor any change to the Python inference pool.
2357
+ */
2358
+ /**
2359
+ * Canonical ArcFace 5-point template for a 112x112 aligned crop
2360
+ * (insightface reference points). Order matches SCRFD landmark output:
2361
+ * left-eye, right-eye, nose, left-mouth-corner, right-mouth-corner.
2362
+ */
2363
+ var ARCFACE_TEMPLATE_112 = [
2364
+ {
2365
+ x: 38.2946,
2366
+ y: 51.6963
2655
2367
  },
2656
2368
  {
2657
2369
  x: 73.5318,
@@ -2929,63 +2641,6 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
2929
2641
  height: aligned.height
2930
2642
  };
2931
2643
  }
2932
- //#endregion
2933
- //#region src/detection-pipeline/pipeline/native-child-crop.ts
2934
- /**
2935
- * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2936
- * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2937
- *
2938
- * On the detail plane the executor runs a child model against a parent crop
2939
- * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2940
- * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2941
- * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2942
- * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2943
- * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2944
- * straight from the frame's retained NATIVE surface (the provider re-composes
2945
- * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2946
- * giving the model native pixels. A miss/degenerate returns `null` so the caller
2947
- * falls back to the existing tile crop (never an upscale — native or the tile).
2948
- */
2949
- /**
2950
- * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2951
- * native pixels wide, so this never binds for plates (effectively uncapped —
2952
- * the quality path the task requires); it only bounds a pathologically large
2953
- * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2954
- */
2955
- var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2956
- /**
2957
- * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2958
- * resolution via `provider`, returning a JPEG crop + its native dimensions.
2959
- * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2960
- * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2961
- * the sharp encode of the returned pixels.
2962
- */
2963
- async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2964
- if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2965
- const x1 = Math.max(0, bbox[0]);
2966
- const y1 = Math.max(0, bbox[1]);
2967
- const x2 = Math.min(imageWidth, bbox[2]);
2968
- const y2 = Math.min(imageHeight, bbox[3]);
2969
- const w = x2 - x1;
2970
- const h = y2 - y1;
2971
- if (w < 1 || h < 1) return null;
2972
- const native = await provider({
2973
- x: x1 / imageWidth,
2974
- y: y1 / imageHeight,
2975
- w: w / imageWidth,
2976
- h: h / imageHeight
2977
- }, maxWidth);
2978
- if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2979
- return {
2980
- jpeg: await sharp(Buffer.from(native.bytes), { raw: {
2981
- width: native.width,
2982
- height: native.height,
2983
- channels: 3
2984
- } }).jpeg({ quality: 90 }).toBuffer(),
2985
- width: native.width,
2986
- height: native.height
2987
- };
2988
- }
2989
2644
  /**
2990
2645
  * Detection score at/above which a near-full-frame box is trusted as a REAL
2991
2646
  * close subject rather than a phantom. Below this bar a full-frame box is
@@ -3067,6 +2722,63 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
3067
2722
  return false;
3068
2723
  }
3069
2724
  //#endregion
2725
+ //#region src/detection-pipeline/pipeline/native-child-crop.ts
2726
+ /**
2727
+ * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2728
+ * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2729
+ *
2730
+ * On the detail plane the executor runs a child model against a parent crop
2731
+ * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2732
+ * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2733
+ * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2734
+ * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2735
+ * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2736
+ * straight from the frame's retained NATIVE surface (the provider re-composes
2737
+ * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2738
+ * giving the model native pixels. A miss/degenerate returns `null` so the caller
2739
+ * falls back to the existing tile crop (never an upscale — native or the tile).
2740
+ */
2741
+ /**
2742
+ * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2743
+ * native pixels wide, so this never binds for plates (effectively uncapped —
2744
+ * the quality path the task requires); it only bounds a pathologically large
2745
+ * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2746
+ */
2747
+ var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2748
+ /**
2749
+ * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2750
+ * resolution via `provider`, returning a JPEG crop + its native dimensions.
2751
+ * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2752
+ * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2753
+ * the sharp encode of the returned pixels.
2754
+ */
2755
+ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2756
+ if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2757
+ const x1 = Math.max(0, bbox[0]);
2758
+ const y1 = Math.max(0, bbox[1]);
2759
+ const x2 = Math.min(imageWidth, bbox[2]);
2760
+ const y2 = Math.min(imageHeight, bbox[3]);
2761
+ const w = x2 - x1;
2762
+ const h = y2 - y1;
2763
+ if (w < 1 || h < 1) return null;
2764
+ const native = await provider({
2765
+ x: x1 / imageWidth,
2766
+ y: y1 / imageHeight,
2767
+ w: w / imageWidth,
2768
+ h: h / imageHeight
2769
+ }, maxWidth);
2770
+ if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2771
+ return {
2772
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
2773
+ width: native.width,
2774
+ height: native.height,
2775
+ channels: 3
2776
+ } }).jpeg({ quality: 90 }).toBuffer(),
2777
+ width: native.width,
2778
+ height: native.height
2779
+ };
2780
+ }
2781
+ //#endregion
3070
2782
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
3071
2783
  /**
3072
2784
  * Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
@@ -3581,6 +3293,7 @@ function buildFrameResult(input) {
3581
3293
  width: input.width,
3582
3294
  height: input.height,
3583
3295
  detections,
3296
+ ...input.discarded !== void 0 && input.discarded.length > 0 ? { discarded: input.discarded } : {},
3584
3297
  debug: frameDebug
3585
3298
  };
3586
3299
  }
@@ -3589,137 +3302,39 @@ function bboxTupleToRect(bbox) {
3589
3302
  return {
3590
3303
  x: x1,
3591
3304
  y: y1,
3592
- width: x2 - x1,
3593
- height: y2 - y1
3594
- };
3595
- }
3596
- function pruneUndefined(obj) {
3597
- const out = {};
3598
- for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3599
- return out;
3600
- }
3601
- function generateFrameId() {
3602
- return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3603
- }
3604
- /** Seed a MutableObjectDetection from a root-level detector output. */
3605
- function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3606
- return {
3607
- id: idGen.next(),
3608
- kind: "first-level",
3609
- macroClass: det.class,
3610
- originalClass: det.class,
3611
- score: det.score,
3612
- bbox: [...det.bbox],
3613
- ownLabels: [],
3614
- alternateLabels: {},
3615
- stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3616
- modelIds: { [rootStep.stepId]: rootStep.modelId },
3617
- extractMode: "full-frame",
3618
- ...det.mask !== void 0 ? {
3619
- mask: det.mask,
3620
- maskWidth: det.maskWidth,
3621
- maskHeight: det.maskHeight
3622
- } : {},
3623
- ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3624
- };
3625
- }
3626
- //#endregion
3627
- //#region src/detection-pipeline/pipeline/execution-trace.ts
3628
- var traceCounter = 0;
3629
- var ExecutionTraceBuilder = class {
3630
- verbosity;
3631
- deviceId;
3632
- frameWidth;
3633
- frameHeight;
3634
- engineRuntime;
3635
- steps = [];
3636
- startTime;
3637
- constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
3638
- this.verbosity = verbosity;
3639
- this.deviceId = deviceId;
3640
- this.frameWidth = frameWidth;
3641
- this.frameHeight = frameHeight;
3642
- this.engineRuntime = engineRuntime;
3643
- this.startTime = Date.now();
3644
- }
3645
- /** Whether trace collection is active. */
3646
- get isActive() {
3647
- return this.verbosity !== "off";
3648
- }
3649
- /**
3650
- * Record a completed step execution.
3651
- * No-op if verbosity is 'off'.
3652
- */
3653
- addStep(params) {
3654
- if (this.verbosity === "off") return;
3655
- const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
3656
- const trace = {
3657
- stepId: params.stepId,
3658
- modelId: params.modelId,
3659
- slot: params.slot,
3660
- postprocessor: params.postprocessor,
3661
- preprocessMs: Math.round(params.preprocessMs * 100) / 100,
3662
- inferenceMs: Math.round(params.inferenceMs * 100) / 100,
3663
- postprocessMs: Math.round(params.postprocessMs * 100) / 100,
3664
- totalMs: Math.round(totalMs * 100) / 100,
3665
- inputType: params.inputType,
3666
- inputSize: {
3667
- width: params.inputWidth,
3668
- height: params.inputHeight
3669
- },
3670
- parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
3671
- outputKind: params.output.kind,
3672
- outputCount: countOutput(params.output),
3673
- topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
3674
- error: params.error
3675
- };
3676
- this.steps.push(trace);
3677
- }
3678
- /**
3679
- * Finalize and return the complete execution trace.
3680
- * Returns null if verbosity is 'off'.
3681
- */
3682
- build(detectionCount) {
3683
- if (this.verbosity === "off") return null;
3684
- const totalMs = Date.now() - this.startTime;
3685
- return {
3686
- traceId: `trace-${Date.now()}-${++traceCounter}`,
3687
- deviceId: this.deviceId,
3688
- timestamp: this.startTime,
3689
- frameSize: {
3690
- width: this.frameWidth,
3691
- height: this.frameHeight
3692
- },
3693
- engineRuntime: this.engineRuntime,
3694
- steps: this.steps,
3695
- totalMs,
3696
- detectionCount
3697
- };
3698
- }
3699
- };
3700
- function countOutput(output) {
3701
- switch (output.kind) {
3702
- case "detections": return output.detections.length;
3703
- case "classifications": return output.classifications.length;
3704
- case "embedding": return 1;
3705
- case "text": return output.text.length > 0 ? 1 : 0;
3706
- case "mask": return 1;
3707
- }
3708
- }
3709
- function formatTopResult(output) {
3710
- switch (output.kind) {
3711
- case "detections": {
3712
- const top = output.detections[0];
3713
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3714
- }
3715
- case "classifications": {
3716
- const top = output.classifications[0];
3717
- return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
3718
- }
3719
- case "embedding": return `embedding[${output.embedding.length}]`;
3720
- case "text": return output.text || void 0;
3721
- case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
3722
- }
3305
+ width: x2 - x1,
3306
+ height: y2 - y1
3307
+ };
3308
+ }
3309
+ function pruneUndefined(obj) {
3310
+ const out = {};
3311
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
3312
+ return out;
3313
+ }
3314
+ function generateFrameId() {
3315
+ return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3316
+ }
3317
+ /** Seed a MutableObjectDetection from a root-level detector output. */
3318
+ function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
3319
+ return {
3320
+ id: idGen.next(),
3321
+ kind: "first-level",
3322
+ macroClass: det.class,
3323
+ originalClass: det.class,
3324
+ score: det.score,
3325
+ bbox: [...det.bbox],
3326
+ ownLabels: [],
3327
+ alternateLabels: {},
3328
+ stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
3329
+ modelIds: { [rootStep.stepId]: rootStep.modelId },
3330
+ extractMode: "full-frame",
3331
+ ...det.mask !== void 0 ? {
3332
+ mask: det.mask,
3333
+ maskWidth: det.maskWidth,
3334
+ maskHeight: det.maskHeight
3335
+ } : {},
3336
+ ...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
3337
+ };
3723
3338
  }
3724
3339
  //#endregion
3725
3340
  //#region src/detection-pipeline/pipeline/executor.ts
@@ -3871,12 +3486,14 @@ var PipelineExecutor = class {
3871
3486
  idGen,
3872
3487
  details
3873
3488
  };
3489
+ let discarded;
3874
3490
  for (const rootStep of tree.roots) {
3875
3491
  if (debug) console.log(`[executor] rootStep=${rootStep.stepId} settings=${JSON.stringify(rootStep.settings ?? {})}`);
3876
3492
  const cropPrep = rootStep.definition.extractMode === "crop-zone" && cropZoneBbox !== void 0 ? await this.prepareCropZoneInput(cropZoneBbox, fullFrameJpegProvider, imageWidth, imageHeight, deviceId) : null;
3877
3493
  const rootStart = Date.now();
3878
3494
  const rawRootOutput = await this.executeStep(rootStep, cropPrep?.input ?? rootInput, cropPrep?.width ?? imageWidth, cropPrep?.height ?? imageHeight, "full-frame", void 0, void 0, traceBuilder, stepTimings, poolAgg);
3879
3495
  const rootMs = Date.now() - rootStart;
3496
+ if (rawRootOutput === null) continue;
3880
3497
  const rootOutput = cropPrep && rawRootOutput.kind === "detections" ? reprojectCropZoneDetections(rawRootOutput, cropPrep.origin) : rawRootOutput;
3881
3498
  if (rootOutput.kind !== "detections") {
3882
3499
  if (isEnrichmentOutput(rootOutput)) {
@@ -3906,10 +3523,35 @@ var PipelineExecutor = class {
3906
3523
  if (mapped) {
3907
3524
  mutable.originalClass = det.class;
3908
3525
  mutable.macroClass = mapped;
3909
- } else if (!rootStep.definition.classMap.preserveOriginal) continue;
3526
+ } else if (!rootStep.definition.classMap.preserveOriginal) {
3527
+ (discarded ??= []).push({
3528
+ bbox: [
3529
+ mutable.bbox[0],
3530
+ mutable.bbox[1],
3531
+ mutable.bbox[2],
3532
+ mutable.bbox[3]
3533
+ ],
3534
+ macroClass: mutable.macroClass,
3535
+ score: mutable.score,
3536
+ reason: "class-filter"
3537
+ });
3538
+ continue;
3539
+ }
3910
3540
  }
3911
- if (!this.matchesMacroFilter(mutable.macroClass, mutable.score, rootStep.settings)) {
3912
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (class or confidence filter)`);
3541
+ const filterVerdict = this.macroFilterVerdict(mutable.macroClass, mutable.score, rootStep.settings);
3542
+ if (filterVerdict !== "pass") {
3543
+ (discarded ??= []).push({
3544
+ bbox: [
3545
+ mutable.bbox[0],
3546
+ mutable.bbox[1],
3547
+ mutable.bbox[2],
3548
+ mutable.bbox[3]
3549
+ ],
3550
+ macroClass: mutable.macroClass,
3551
+ score: mutable.score,
3552
+ reason: filterVerdict
3553
+ });
3554
+ if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (${filterVerdict})`);
3913
3555
  continue;
3914
3556
  }
3915
3557
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
@@ -3928,9 +3570,20 @@ var PipelineExecutor = class {
3928
3570
  hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3929
3571
  }
3930
3572
  });
3573
+ (discarded ??= []).push({
3574
+ bbox: [
3575
+ mutable.bbox[0],
3576
+ mutable.bbox[1],
3577
+ mutable.bbox[2],
3578
+ mutable.bbox[3]
3579
+ ],
3580
+ macroClass: mutable.macroClass,
3581
+ score: mutable.score,
3582
+ reason: "full-frame-guard"
3583
+ });
3931
3584
  continue;
3932
3585
  }
3933
- 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", {
3586
+ 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", {
3934
3587
  tags: { deviceId },
3935
3588
  meta: {
3936
3589
  macroClass: mutable.macroClass,
@@ -3957,6 +3610,7 @@ var PipelineExecutor = class {
3957
3610
  firstLevel.push(mutable);
3958
3611
  }
3959
3612
  }
3613
+ const totalMs = Date.now() - startMs;
3960
3614
  return {
3961
3615
  result: buildFrameResult({
3962
3616
  deviceId,
@@ -3964,8 +3618,9 @@ var PipelineExecutor = class {
3964
3618
  height: imageHeight,
3965
3619
  firstLevel,
3966
3620
  details,
3621
+ discarded,
3967
3622
  stepTimings,
3968
- totalMs: Date.now() - startMs,
3623
+ totalMs,
3969
3624
  engine: this.opts.engineRuntime,
3970
3625
  debug,
3971
3626
  timestamp: Date.now(),
@@ -4070,13 +3725,40 @@ var PipelineExecutor = class {
4070
3725
  });
4071
3726
  throw new Error(`Inference failed for step "${step.stepId}": ${errorMsg}`, { cause: err });
4072
3727
  }
3728
+ if (engineOutput.dropped === true) {
3729
+ stepTimings.push({
3730
+ source: step.stepId,
3731
+ modelId: step.modelId,
3732
+ ms: Date.now() - preprocessStart,
3733
+ detectionCount: 0
3734
+ });
3735
+ if (traceBuilder.isActive) traceBuilder.addStep({
3736
+ stepId: step.stepId,
3737
+ modelId: step.modelId,
3738
+ slot: step.definition.slot,
3739
+ postprocessor: step.definition.postprocessor,
3740
+ preprocessMs,
3741
+ inferenceMs: Date.now() - inferenceStart,
3742
+ postprocessMs: 0,
3743
+ inputType,
3744
+ inputWidth,
3745
+ inputHeight,
3746
+ parentDetection: parentClass,
3747
+ output: {
3748
+ kind: "detections",
3749
+ detections: []
3750
+ },
3751
+ error: `shed:${engineOutput.shedReason ?? "pool-overload"}`
3752
+ });
3753
+ return null;
3754
+ }
4073
3755
  const structured = engineOutput.structured ?? {};
4074
3756
  const inferenceMs = typeof structured.inferenceMs === "number" ? structured.inferenceMs : Date.now() - inferenceStart;
4075
3757
  if (typeof structured.preprocessMs === "number") poolAgg.preprocessMs += structured.preprocessMs;
4076
3758
  if (typeof structured.predictMs === "number") poolAgg.predictMs += structured.predictMs;
4077
3759
  if (typeof structured.batchSize === "number" && structured.batchSize > poolAgg.batchSize) poolAgg.batchSize = structured.batchSize;
4078
3760
  const postprocessStart = Date.now();
4079
- const output = dispatchPostprocess(engineOutput, step.definition, step.settings);
3761
+ const output = dispatchPostprocess(engineOutput, step.definition);
4080
3762
  const postprocessMs = Date.now() - postprocessStart;
4081
3763
  stepTimings.push({
4082
3764
  source: step.stepId,
@@ -4219,6 +3901,7 @@ var PipelineExecutor = class {
4219
3901
  data: cropJpegBuf
4220
3902
  }, cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
4221
3903
  const childMs = Date.now() - childStart;
3904
+ if (childOutput === null) continue;
4222
3905
  const detailsBefore = ctx.details.length;
4223
3906
  applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
4224
3907
  if (childOutput.kind === "detections" && child.children.length > 0) {
@@ -4252,20 +3935,25 @@ var PipelineExecutor = class {
4252
3935
  * Apply the object-detection step's macro filter + per-macro
4253
3936
  * minConfidence sliders introduced in the Phase 6 step rework.
4254
3937
  *
3938
+ * Returns WHICH gate failed rather than a bare boolean so the
3939
+ * discard trail can label the dropped box with the honest reason
3940
+ * (`class-filter` vs `below-threshold`) — the two are remediated
3941
+ * differently (enable the class vs lower a slider).
3942
+ *
4255
3943
  * Expected `settings` shape:
4256
3944
  * - `enabledMacroClasses`: readonly string[] (e.g. ['person','vehicle','animal']).
4257
3945
  * Empty array = all allowed (legacy behaviour).
4258
3946
  * - `minConfidence<Macro>`: number (e.g. `minConfidencePerson: 0.5`)
4259
3947
  */
4260
- matchesMacroFilter(macroClass, score, settings) {
4261
- if (!settings) return true;
3948
+ macroFilterVerdict(macroClass, score, settings) {
3949
+ if (!settings) return "pass";
4262
3950
  const enabled = settings["enabledMacroClasses"];
4263
3951
  if (Array.isArray(enabled) && enabled.length > 0) {
4264
- if (!enabled.includes(macroClass)) return false;
3952
+ if (!enabled.includes(macroClass)) return "class-filter";
4265
3953
  }
4266
3954
  const perMacroThreshold = settings[`minConfidence${capitalize(macroClass)}`];
4267
- if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return false;
4268
- return true;
3955
+ if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return "below-threshold";
3956
+ return "pass";
4269
3957
  }
4270
3958
  };
4271
3959
  function capitalize(s) {
@@ -4273,52 +3961,41 @@ function capitalize(s) {
4273
3961
  return s.charAt(0).toUpperCase() + s.slice(1);
4274
3962
  }
4275
3963
  //#endregion
4276
- //#region src/detection-pipeline/pipeline/crop-zone.ts
3964
+ //#region src/detection-pipeline/pipeline/native-crop-compose.ts
3965
+ /** Clamp `v` into `[lo, hi]`. */
3966
+ function clamp(v, lo, hi) {
3967
+ if (v < lo) return lo;
3968
+ if (v > hi) return hi;
3969
+ return v;
3970
+ }
4277
3971
  /**
4278
- * Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
4279
- * bounding box of the UNION of every polygon referenced by the device's
4280
- * ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
4281
- * union is clamped to [0,1] then scaled to the frame's pixel dimensions.
3972
+ * Compose a child `roi` given in the parent CROP's normalized space into the
3973
+ * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
3974
+ * frame dimensions. The result is clamped to the unit square (origin in
3975
+ * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
3976
+ * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
3977
+ * clamps to zero area) so the caller falls back to the downscaled tile crop.
4282
3978
  *
4283
- * Returns `null` when there is nothing usable to crop to no enabled rules, no
4284
- * referenced geometry, or a degenerate result — in which case the caller falls
4285
- * back to full-frame inference (behaviour identical to before the crop-zone
4286
- * plumbing).
3979
+ * Purenever mutates its arguments.
4287
3980
  */
4288
- function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
4289
- if (frameWidth <= 0 || frameHeight <= 0) return null;
4290
- const enabledRules = packageRules.filter((r) => r.enabled !== false);
4291
- if (enabledRules.length === 0) return null;
4292
- const zoneIds = /* @__PURE__ */ new Set();
4293
- for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
4294
- if (zoneIds.size === 0) return null;
4295
- const zoneById = new Map(zones.map((z) => [z.id, z]));
4296
- let minX = Number.POSITIVE_INFINITY;
4297
- let minY = Number.POSITIVE_INFINITY;
4298
- let maxX = Number.NEGATIVE_INFINITY;
4299
- let maxY = Number.NEGATIVE_INFINITY;
4300
- let sawPoint = false;
4301
- for (const id of zoneIds) {
4302
- const zone = zoneById.get(id);
4303
- if (!zone || zone.polygon.length === 0) continue;
4304
- for (const point of zone.polygon) {
4305
- sawPoint = true;
4306
- if (point.x < minX) minX = point.x;
4307
- if (point.y < minY) minY = point.y;
4308
- if (point.x > maxX) maxX = point.x;
4309
- if (point.y > maxY) maxY = point.y;
4310
- }
4311
- }
4312
- if (!sawPoint) return null;
4313
- const clamp01 = (v) => Math.max(0, Math.min(1, v));
4314
- const bbox = [
4315
- clamp01(minX) * frameWidth,
4316
- clamp01(minY) * frameHeight,
4317
- clamp01(maxX) * frameWidth,
4318
- clamp01(maxY) * frameHeight
4319
- ];
4320
- if (isBboxDegenerate(bbox)) return null;
4321
- return bbox;
3981
+ function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
3982
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
3983
+ if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
3984
+ const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
3985
+ const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
3986
+ const fw = roi.w * cropFrameSpace.w / frameWidth;
3987
+ const fh = roi.h * cropFrameSpace.h / frameHeight;
3988
+ const x = clamp(fx, 0, 1);
3989
+ const y = clamp(fy, 0, 1);
3990
+ const w = clamp(fw, 0, 1 - x);
3991
+ const h = clamp(fh, 0, 1 - y);
3992
+ if (!(w > 0) || !(h > 0)) return null;
3993
+ return {
3994
+ x,
3995
+ y,
3996
+ w,
3997
+ h
3998
+ };
4322
3999
  }
4323
4000
  //#endregion
4324
4001
  //#region src/detection-pipeline/pipeline/tree-builder.ts
@@ -4554,6 +4231,44 @@ function resolveInputSteps(steps, format, engine) {
4554
4231
  };
4555
4232
  }
4556
4233
  //#endregion
4234
+ //#region src/detection-pipeline/zone-gate.ts
4235
+ function applyZoneRuleGate(result, zones, rules) {
4236
+ if (result.detections.length === 0) return result;
4237
+ if (zones.length === 0 || rules.length === 0) return result;
4238
+ const frameW = result.width;
4239
+ const frameH = result.height;
4240
+ if (frameW === 0 || frameH === 0) return result;
4241
+ const firstLevel = result.detections.filter((d) => d.kind === "first-level");
4242
+ const details = result.detections.filter((d) => d.kind === "detail");
4243
+ const { passed, excluded } = evaluateZoneRules(firstLevel, zones, rules, (det) => ({
4244
+ x: (det.bbox.x + det.bbox.width / 2) / frameW,
4245
+ y: (det.bbox.y + det.bbox.height / 2) / frameH
4246
+ }), (det) => det.macroClass);
4247
+ if (passed.length === firstLevel.length) return result;
4248
+ const passedIds = new Set(passed.map((d) => d.id));
4249
+ const filteredDetails = details.filter((d) => {
4250
+ const parentId = d.parentId;
4251
+ return parentId === void 0 || passedIds.has(parentId);
4252
+ });
4253
+ const zoneDiscards = excluded.map((d) => ({
4254
+ bbox: [
4255
+ d.bbox.x,
4256
+ d.bbox.y,
4257
+ d.bbox.x + d.bbox.width,
4258
+ d.bbox.y + d.bbox.height
4259
+ ],
4260
+ macroClass: d.macroClass,
4261
+ score: d.score,
4262
+ reason: "zone-gate"
4263
+ }));
4264
+ const mergedDiscarded = [...result.discarded ?? [], ...zoneDiscards];
4265
+ return {
4266
+ ...result,
4267
+ detections: [...passed, ...filteredDetails],
4268
+ ...mergedDiscarded.length > 0 ? { discarded: mergedDiscarded } : {}
4269
+ };
4270
+ }
4271
+ //#endregion
4557
4272
  //#region src/detection-pipeline/provider.ts
4558
4273
  /**
4559
4274
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4896,10 +4611,27 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4896
4611
  * Idle-TTL for a per-device inference pool (multi-device C3). A device pool
4897
4612
  * with no dispatch for this long is disposed and recreated on next use — the
4898
4613
  * memory guardrail that keeps `factoriesByDevice` from accumulating warm pools
4899
- * forever. Longer than the override cache (60s) since a device pool is heavier
4900
- * to respin; still well above any real inter-session gap for an active camera.
4614
+ * forever as an operator toggles device sets over a long run.
4615
+ *
4616
+ * It was 120s, on the stated assumption that this was "well above any real
4617
+ * inter-session gap for an active camera". Measured 2026-08-02, that is
4618
+ * false: detection is dispatched ON MOTION, so at night the gap between
4619
+ * events routinely exceeds two minutes, and the eviction fires constantly.
4620
+ * The rebuild is not cheap — `ensureModelsForSteps` loads EVERY enabled step
4621
+ * into the fresh factory, which on the hub is seven models
4622
+ * (yolov9m-320-int8, scrfd-2.5g, arcface-r100, yolov8n-plate,
4623
+ * vgg_english_g2, mobileclip-s1-vision, yolov8n-package). The hub log shows
4624
+ * the cycle plainly: `COMPILE ×7` → shed within 7-15s → `EVICT` ~2.5 min
4625
+ * later → repeat. So the first seconds of a motion event — precisely when
4626
+ * detection matters — were spent recompiling models that had just been
4627
+ * thrown away.
4628
+ *
4629
+ * Hours, not minutes. At this TTL only a pool that is genuinely unused
4630
+ * (device disabled, routing changed) is reclaimed, which is what the
4631
+ * guardrail was for; a nightly lull no longer costs a recompile. Override
4632
+ * via env for a memory-constrained node.
4901
4633
  */
4902
- static DEVICE_POOL_IDLE_TTL_MS = 12e4;
4634
+ static DEVICE_POOL_IDLE_TTL_MS = Math.max(6e4, Number(process.env["CAMSTACK_DEVICE_POOL_IDLE_TTL_MS"]) || 360 * 60 * 1e3);
4903
4635
  deviceReaper = new IdlePoolReaper({
4904
4636
  ttlMs: DetectionPipelineProvider.DEVICE_POOL_IDLE_TTL_MS,
4905
4637
  dispose: (deviceKey) => this.disposeDevicePool(deviceKey),
@@ -5952,19 +5684,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5952
5684
  emit(`Image: ${imageWidth}×${imageHeight}`);
5953
5685
  }
5954
5686
  decodeMs = performance.now() - decodeT0;
5955
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
5956
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
5957
- const resolveEngine = deviceEngine ? {
5958
- backend: deviceEngine.backend,
5959
- device: deviceEngine.device ?? null
5960
- } : input.engine ? {
5961
- backend: input.engine.backend,
5962
- device: input.engine.device ?? null
5963
- } : {
5964
- backend: this.currentEngine.backend,
5965
- device: this.currentEngine.device ?? null
5966
- };
5967
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
5687
+ const dispatchResolution = this.resolveStepsForDispatch({
5688
+ steps: input.steps,
5689
+ deviceKey: input.deviceKey,
5690
+ engineOverride: input.engine,
5691
+ deviceId: input.deviceId,
5692
+ plane: input.plane
5693
+ });
5694
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
5695
+ const benchmarkSteps = dispatchResolution.steps;
5968
5696
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5969
5697
  id: s.addonId,
5970
5698
  settings: s.settings ?? {}
@@ -6056,8 +5784,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6056
5784
  logger: this.log
6057
5785
  });
6058
5786
  this.executor = executor;
6059
- const dispatchEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : runEngine;
6060
- const dispatchFactory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
5787
+ const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
5788
+ const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6061
5789
  const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
6062
5790
  if (needed.length > 0) {
6063
5791
  this.log.info("Benchmark: models to load", { meta: {
@@ -6306,6 +6034,142 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6306
6034
  fn();
6307
6035
  }
6308
6036
  /**
6037
+ * Resolve a dispatch's step tree against its target engine, with the
6038
+ * CAPABILITY GATE + node-local engine fallback (cam-615, 2026-08-01;
6039
+ * per-step split 2026-08-01 evening).
6040
+ *
6041
+ * A per-call `deviceKey` (orchestrator device routing / detail-plane step
6042
+ * jump) selects an engine whose format every step EXECUTING IN THIS CALL
6043
+ * must actually ship a model build for. When such a step's catalog has
6044
+ * ZERO builds for that format (so not even `resolveModelForFormat`'s
6045
+ * substitution can rescue it), dispatching anyway means every frame
6046
+ * fails — before this gate it failed as a doomed 3-retry model download,
6047
+ * ~5.5s per frame, forever.
6048
+ *
6049
+ * The gated set is per-plane — the ROOT decides the dispatch device, a
6050
+ * child never bounces it (operator requirement: devices compensate for
6051
+ * each other's missing steps; the original whole-tree gate let ONE
6052
+ * tflite-less child knock the root off an otherwise-capable Coral):
6053
+ *
6054
+ * - `'frame'` (the live per-frame path) executes only root-plane steps,
6055
+ * so only those gate the device. Detail children that can NEVER run on
6056
+ * this device are pruned from THIS dispatch (so `ensureModelsForSteps`
6057
+ * doesn't fail the call on a format build that does not exist) and
6058
+ * logged once — NOT dropped work: each runs later as its own per-track
6059
+ * detail dispatch, where the runner's device-jump resolver
6060
+ * (`resolveStepDevice`) places it on a same-node device that can run
6061
+ * it, or warns no-candidate loudly.
6062
+ * - any other plane (detail subtrees, benchmark, batch) executes the
6063
+ * whole passed tree inline on ONE pool — a single call cannot split
6064
+ * devices — so the whole-tree gate stays.
6065
+ *
6066
+ * Fallback is strictly NODE-LOCAL and ordered: the node's own selected
6067
+ * engine (or the benchmark's explicit `engineOverride`) is the one and only
6068
+ * fallback — frames never migrate to another node from here (that is the
6069
+ * orchestrator's tier). The fallback is logged ONCE per distinct
6070
+ * (deviceKey, steps, format) signature, with `tags: { deviceId }` when the
6071
+ * dispatch is camera-scoped.
6072
+ *
6073
+ * When the node's own engine ALSO cannot run a step, this throws — fast,
6074
+ * before any download attempt — and logs the full picture once (model,
6075
+ * its available formats, both engines tried). A genuinely impossible
6076
+ * configuration must stay loud, not vanish into a silent fallback chain.
6077
+ */
6078
+ resolveStepsForDispatch(args) {
6079
+ const { deviceKey, engineOverride, deviceId } = args;
6080
+ const nodeFormat = engineOverride?.format ?? this.currentEngine.format;
6081
+ const nodeEngine = engineOverride ? {
6082
+ backend: engineOverride.backend,
6083
+ device: engineOverride.device ?? null
6084
+ } : {
6085
+ backend: this.currentEngine.backend,
6086
+ device: this.currentEngine.device ?? null
6087
+ };
6088
+ const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6089
+ const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6090
+ if (deviceKey === void 0 || deviceEngine === void 0) {
6091
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6092
+ this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
6093
+ return {
6094
+ deviceKey: void 0,
6095
+ steps
6096
+ };
6097
+ }
6098
+ const deviceFormat = deviceEngine.format;
6099
+ const deviceStepEngine = {
6100
+ backend: deviceEngine.backend,
6101
+ device: deviceEngine.device ?? null
6102
+ };
6103
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
6104
+ const framePlane = args.plane === "frame";
6105
+ const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6106
+ if (zeroBuild.length === 0) {
6107
+ if (!framePlane) return {
6108
+ deviceKey,
6109
+ steps
6110
+ };
6111
+ const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6112
+ if (pruneResult.prunedAddonIds.length > 0) {
6113
+ const prunedList = pruneResult.prunedAddonIds.join(",");
6114
+ 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", {
6115
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6116
+ meta: {
6117
+ deviceKey,
6118
+ deviceFormat,
6119
+ steps: [...pruneResult.prunedAddonIds]
6120
+ }
6121
+ }));
6122
+ }
6123
+ return {
6124
+ deviceKey,
6125
+ steps: pruneResult.steps
6126
+ };
6127
+ }
6128
+ const blockedSteps = zeroBuild.map((issue) => issue.addonId);
6129
+ 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", {
6130
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6131
+ meta: {
6132
+ deviceKey,
6133
+ deviceFormat,
6134
+ steps: blockedSteps,
6135
+ fallbackEngine: nodeEngineLabel
6136
+ }
6137
+ }));
6138
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
6139
+ this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
6140
+ return {
6141
+ deviceKey: void 0,
6142
+ steps: fallbackSteps
6143
+ };
6144
+ }
6145
+ /**
6146
+ * The loud, fast half of the capability gate: throw when any ENABLED step
6147
+ * has ZERO model builds for `format` — BEFORE any model download runs. The
6148
+ * error names the step, its chosen model, the formats the catalog actually
6149
+ * ships, and every engine that was tried, and is logged ONCE per distinct
6150
+ * signature (the throw itself still surfaces per dispatch, but costs
6151
+ * microseconds instead of a 3-retry download backoff).
6152
+ */
6153
+ assertStepsRunnable(steps, format, deviceId, enginesTried) {
6154
+ const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
6155
+ if (zeroBuild.length === 0) return;
6156
+ const detail = zeroBuild.map((issue) => {
6157
+ const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
6158
+ const formats = availableFormatsForStep(issue.addonId);
6159
+ return `step "${issue.addonId}" (model "${step?.modelId ?? "unknown"}") has no ${format} build (available: ${formats.join(", ") || "none"})`;
6160
+ }).join("; ");
6161
+ const message = `Pipeline dispatch impossible on this node: ${detail}. Engines tried: ${enginesTried.join(" → ")}.`;
6162
+ this.logLiveDispatchIssueOnce(`unrunnable:${format}:${zeroBuild.map((i) => i.addonId).join(",")}`, () => this.log.error("Pipeline step cannot run on any node-local engine", {
6163
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
6164
+ meta: {
6165
+ format,
6166
+ enginesTried: [...enginesTried],
6167
+ detail
6168
+ }
6169
+ }));
6170
+ throw new Error(message);
6171
+ }
6172
+ /**
6309
6173
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
6310
6174
  * callers (every camera that fires motion in the same window calls
6311
6175
  * `runPipeline` → `ensureModelsForSteps`) share the same in-flight
@@ -6332,6 +6196,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6332
6196
  for (const step of needed) {
6333
6197
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6334
6198
  if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
6199
+ 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`);
6335
6200
  this.log.info("Downloading model for step", { meta: {
6336
6201
  modelId: step.modelId,
6337
6202
  format,
@@ -6449,19 +6314,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6449
6314
  }
6450
6315
  async runPipelineBatchImpl(input) {
6451
6316
  if (input.frames.length === 0) return { results: [] };
6452
- const deviceEngine = input.deviceKey ? resolveDeviceEngine(input.deviceKey) : void 0;
6453
- const resolveFormat = deviceEngine?.format ?? input.engine?.format ?? this.currentEngine.format;
6454
- const resolveEngine = deviceEngine ? {
6455
- backend: deviceEngine.backend,
6456
- device: deviceEngine.device ?? null
6457
- } : input.engine ? {
6458
- backend: input.engine.backend,
6459
- device: input.engine.device ?? null
6460
- } : {
6461
- backend: this.currentEngine.backend,
6462
- device: this.currentEngine.device ?? null
6463
- };
6464
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
6317
+ const dispatchResolution = this.resolveStepsForDispatch({
6318
+ steps: input.steps,
6319
+ deviceKey: input.deviceKey,
6320
+ engineOverride: input.engine,
6321
+ deviceId: input.deviceId,
6322
+ plane: void 0
6323
+ });
6324
+ const dispatchDeviceKey = dispatchResolution.deviceKey;
6325
+ const resolveFormat = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey).format : input.engine?.format ?? this.currentEngine.format;
6326
+ const benchmarkSteps = dispatchResolution.steps;
6465
6327
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
6466
6328
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
6467
6329
  const rootStep = enabledSteps[0];
@@ -6483,7 +6345,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6483
6345
  await this.ensureEngineFactory();
6484
6346
  const restoreEngine = await this.applyEngineOverride(input.engine);
6485
6347
  try {
6486
- const factory = input.deviceKey ? await this.resolveDeviceFactory(input.deviceKey) : this.engineFactory;
6348
+ const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6487
6349
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
6488
6350
  if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
6489
6351
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
@@ -6500,7 +6362,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6500
6362
  frame,
6501
6363
  deviceId: input.deviceId,
6502
6364
  sessionId: input.sessionId,
6503
- deviceKey: input.deviceKey
6365
+ deviceKey: dispatchDeviceKey
6504
6366
  }))) };
6505
6367
  const items = input.frames.map((frame) => ({
6506
6368
  raw: Buffer.from(frame.data),
@@ -6685,31 +6547,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6685
6547
  * detections).
6686
6548
  */
6687
6549
  gateDetectionsByZoneRules(deviceId, result) {
6688
- if (deviceId <= 0 || result.detections.length === 0) return result;
6550
+ if (deviceId <= 0) return result;
6689
6551
  const proxy = this.deviceProxies.get(deviceId);
6690
6552
  if (!proxy) return result;
6691
- const zones = proxy.state.zones.value?.zones ?? [];
6692
- const rules = proxy.state.zoneRules.value?.detection ?? [];
6693
- if (zones.length === 0 || rules.length === 0) return result;
6694
- const frameW = result.width;
6695
- const frameH = result.height;
6696
- if (frameW === 0 || frameH === 0) return result;
6697
- const firstLevel = result.detections.filter((d) => d.kind === "first-level");
6698
- const details = result.detections.filter((d) => d.kind === "detail");
6699
- const { passed } = evaluateZoneRules(firstLevel, zones, rules, (det) => ({
6700
- x: (det.bbox.x + det.bbox.width / 2) / frameW,
6701
- y: (det.bbox.y + det.bbox.height / 2) / frameH
6702
- }), (det) => det.macroClass);
6703
- if (passed.length === firstLevel.length) return result;
6704
- const passedIds = new Set(passed.map((d) => d.id));
6705
- const filteredDetails = details.filter((d) => {
6706
- const parentId = d.parentId;
6707
- return parentId === void 0 || passedIds.has(parentId);
6708
- });
6709
- return {
6710
- ...result,
6711
- detections: [...passed, ...filteredDetails]
6712
- };
6553
+ return applyZoneRuleGate(result, proxy.state.zones.value?.zones ?? [], proxy.state.zoneRules.value?.detection ?? []);
6713
6554
  }
6714
6555
  async cacheFrameInPool(input) {
6715
6556
  await this.ensureEngineFactory();
@@ -7299,12 +7140,49 @@ function stepsToPipelineConfig(steps, engine) {
7299
7140
  } : {}
7300
7141
  };
7301
7142
  }
7302
- /** Flatten a step tree into a flat list (DFS). */
7303
7143
  /** Flatten a step tree into a flat list (DFS) — delegates to shared utility. */
7304
7144
  function flattenSteps(steps) {
7305
7145
  return flattenEnabledVideoSteps(steps);
7306
7146
  }
7307
7147
  /**
7148
+ * Catalog root-vs-detail plane of a step: a non-null `inputClasses` on the
7149
+ * DEFINITION marks a crop child served per-track on the detail plane —
7150
+ * mirrors the executor's frame-plane skip (`executeChildren`) and the
7151
+ * runner's `deriveDetailSteps`. Unknown addonIds (already dropped from
7152
+ * resolved trees) default to root-plane.
7153
+ */
7154
+ function isDetailPlaneStep(addonId) {
7155
+ try {
7156
+ return getStepDefinition(addonId).inputClasses !== null;
7157
+ } catch {
7158
+ return false;
7159
+ }
7160
+ }
7161
+ /**
7162
+ * True when the step's FULL catalog ships no model with a build for
7163
+ * `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
7164
+ * addonIds (no catalog to check) are never zero-build, mirroring the
7165
+ * collector's skip.
7166
+ */
7167
+ function stepHasZeroBuildsFor(addonId, format) {
7168
+ return collectZeroBuildIssues([{ addonId }], format).length > 0;
7169
+ }
7170
+ /**
7171
+ * The union of model formats a step's catalog ships ANY build for — the
7172
+ * "Available: …" half of the capability-gate error. Unknown/custom addonIds
7173
+ * yield an empty list (no catalog to enumerate), never a throw.
7174
+ */
7175
+ function availableFormatsForStep(addonId) {
7176
+ try {
7177
+ const def = getStepDefinition(addonId);
7178
+ const formats = /* @__PURE__ */ new Set();
7179
+ for (const model of def.models) for (const format of Object.keys(model.formats)) formats.add(format);
7180
+ return Array.from(formats).toSorted();
7181
+ } catch {
7182
+ return [];
7183
+ }
7184
+ }
7185
+ /**
7308
7186
  * Return a shallow-cloned executable tree with `rootStepId`'s `settings`
7309
7187
  * merged with `overrides` (per-device values win). Non-matching root steps
7310
7188
  * pass through unchanged. Used by `runFrame` to apply per-device detection
@@ -7320,6 +7198,142 @@ function applyDeviceOverridesToTree(tree, rootStepId, overrides) {
7320
7198
  } : root) };
7321
7199
  }
7322
7200
  //#endregion
7201
+ //#region src/detection-pipeline/diagnostics/event-loop-stall-monitor.ts
7202
+ /**
7203
+ * event-loop-stall-monitor — names the thing that freezes the detection process.
7204
+ *
7205
+ * ## What this exists to settle
7206
+ *
7207
+ * On 2026-08-01/02 inference across the whole fleet stalled in bursts: 759
7208
+ * calls in 90 minutes crossed the runner's 20s deadline, p50 41s and max 80s,
7209
+ * and EVERY one of them eventually resolved. Six explanations were killed with
7210
+ * telemetry alone:
7211
+ *
7212
+ * - not a slow GPU or a wedged pool — the work completes, just enormously late;
7213
+ * - not CPU starvation — hosts sat at 8-48% during the freezes;
7214
+ * - not swap — `pswpin/pswpout` are flat zero on every host;
7215
+ * - not a logging artifact — Loki's timestamp trails the in-process one by
7216
+ * 0.1-1.0s at p95, so the gaps are real, not buffered delivery;
7217
+ * - not the v1.2.30 release — the stalls predate it by a day;
7218
+ * - not only the idle-pool recompile — that is real and hub-side, but the
7219
+ * agent stalls with no eviction and no compile at all.
7220
+ *
7221
+ * What IS established: the 10s stats timer — which touches no inference code —
7222
+ * stops firing for 22-88s and resumes at the same instant the abandoned calls
7223
+ * settle. The event loop itself freezes. From outside the process, GC and a
7224
+ * synchronous block are indistinguishable, and guessing between them is how
7225
+ * this investigation would go wrong. So measure it.
7226
+ *
7227
+ * ## How
7228
+ *
7229
+ * A 1s interval whose LATENESS is the freeze: a tick that arrives 40s late means
7230
+ * the loop was blocked ~39s, whatever blocked it. In the same window the GC
7231
+ * observer accumulates pause time. The two numbers together answer the question
7232
+ * outright:
7233
+ *
7234
+ * - `gcMs` ≈ `stallMs` → garbage collection. Look at heap growth.
7235
+ * - `gcMs` ≈ 0 → something SYNCHRONOUS blocked the loop, and GC is innocent.
7236
+ *
7237
+ * Heap and RSS ride along because if it is GC, the next question is always
7238
+ * "growing since when?".
7239
+ *
7240
+ * Deliberately not a histogram (`monitorEventLoopDelay`): that reports a
7241
+ * distribution, and the question here is about ONE outlier and what coincided
7242
+ * with it.
7243
+ */
7244
+ /** Tick period. Small enough to bound attribution error, large enough to cost
7245
+ * nothing on a process already doing frame work. */
7246
+ var STALL_TICK_MS = 1e3;
7247
+ /**
7248
+ * Report a freeze only past this. The runner's own inference deadline is 20s
7249
+ * and the observed freezes run 22-88s, so this is far below the signal while
7250
+ * staying clear of ordinary scheduling jitter (a busy frame path routinely
7251
+ * overruns a 1s timer by tens of ms).
7252
+ */
7253
+ var STALL_REPORT_MS = 2e3;
7254
+ /**
7255
+ * Attribute a freeze. Pure, because this is the ONE judgement the whole module
7256
+ * exists to make and it must be testable without freezing a real event loop.
7257
+ *
7258
+ * `gc` when garbage collection accounts for most of the stall; a
7259
+ * `synchronous-block` when it accounts for almost none — that verdict points
7260
+ * away from the heap entirely and at whatever ran without yielding.
7261
+ */
7262
+ function attributeStall(stallMs, gcMs, gcCount, longestGcMs) {
7263
+ const share = stallMs > 0 ? gcMs / stallMs : 0;
7264
+ return {
7265
+ stallMs,
7266
+ gcMs,
7267
+ gcCount,
7268
+ longestGcMs,
7269
+ cause: share >= .7 ? "gc" : share <= .2 ? "synchronous-block" : "mixed"
7270
+ };
7271
+ }
7272
+ /**
7273
+ * Start watching. Returns a stop function; safe to call twice.
7274
+ *
7275
+ * Never throws into the caller: a diagnostic that can take the detection
7276
+ * process down is worse than no diagnostic. `PerformanceObserver` for `gc` is
7277
+ * unavailable in some embedded runtimes, and that must degrade to
7278
+ * stall-detection-without-attribution rather than a boot failure.
7279
+ */
7280
+ function startEventLoopStallMonitor(logger, reportThresholdMs = STALL_REPORT_MS) {
7281
+ let gc = {
7282
+ totalMs: 0,
7283
+ count: 0,
7284
+ longestMs: 0
7285
+ };
7286
+ let observer = null;
7287
+ try {
7288
+ observer = new PerformanceObserver((list) => {
7289
+ for (const entry of list.getEntries()) {
7290
+ gc.totalMs += entry.duration;
7291
+ gc.count += 1;
7292
+ if (entry.duration > gc.longestMs) gc.longestMs = entry.duration;
7293
+ }
7294
+ });
7295
+ observer.observe({ entryTypes: ["gc"] });
7296
+ } catch {
7297
+ observer = null;
7298
+ }
7299
+ let expected = Date.now() + STALL_TICK_MS;
7300
+ const timer = setInterval(() => {
7301
+ const now = Date.now();
7302
+ const lateBy = now - expected;
7303
+ const window = gc;
7304
+ gc = {
7305
+ totalMs: 0,
7306
+ count: 0,
7307
+ longestMs: 0
7308
+ };
7309
+ expected = now + STALL_TICK_MS;
7310
+ if (lateBy < reportThresholdMs) return;
7311
+ const verdict = attributeStall(lateBy, Math.round(window.totalMs), window.count, Math.round(window.longestMs));
7312
+ const mem = process.memoryUsage();
7313
+ logger.warn("event loop stalled", { meta: {
7314
+ stallMs: verdict.stallMs,
7315
+ cause: verdict.cause,
7316
+ gcMs: verdict.gcMs,
7317
+ gcCount: verdict.gcCount,
7318
+ longestGcMs: verdict.longestGcMs,
7319
+ gcObserved: observer !== null,
7320
+ heapUsedMb: Math.round(mem.heapUsed / 1e6),
7321
+ heapTotalMb: Math.round(mem.heapTotal / 1e6),
7322
+ rssMb: Math.round(mem.rss / 1e6),
7323
+ externalMb: Math.round(mem.external / 1e6),
7324
+ arrayBuffersMb: Math.round(mem.arrayBuffers / 1e6)
7325
+ } });
7326
+ }, STALL_TICK_MS);
7327
+ timer.unref?.();
7328
+ let stopped = false;
7329
+ return () => {
7330
+ if (stopped) return;
7331
+ stopped = true;
7332
+ clearInterval(timer);
7333
+ observer?.disconnect();
7334
+ };
7335
+ }
7336
+ //#endregion
7323
7337
  //#region src/detection-pipeline/python-readiness.ts
7324
7338
  /**
7325
7339
  * Resolve the embedded Python once, turning the null/throw failure modes into
@@ -7440,6 +7454,8 @@ var DetectionPipelineAddon = class extends BaseAddon {
7440
7454
  * doesn't flip a remote node's env back to the serving hub's platform. */
7441
7455
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
7442
7456
  engineMetricsTimer = null;
7457
+ /** Stops the event-loop stall monitor on shutdown. */
7458
+ stopStallMonitor = null;
7443
7459
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
7444
7460
  * the engine inventory is unchanged (no model load/unload), so
7445
7461
  * we skip the bus emit and let the heartbeat re-emit at
@@ -7778,6 +7794,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7778
7794
  async onInitialize() {
7779
7795
  const modelsDir = await this.resolveModelsDir();
7780
7796
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7797
+ this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
7781
7798
  this.pythonAddonDir = resolveAddonPythonDir();
7782
7799
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7783
7800
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
@@ -7864,6 +7881,10 @@ var DetectionPipelineAddon = class extends BaseAddon {
7864
7881
  }
7865
7882
  }
7866
7883
  async onShutdown() {
7884
+ if (this.stopStallMonitor) {
7885
+ this.stopStallMonitor();
7886
+ this.stopStallMonitor = null;
7887
+ }
7867
7888
  if (this.engineMetricsTimer) {
7868
7889
  clearInterval(this.engineMetricsTimer);
7869
7890
  this.engineMetricsTimer = null;