@camstack/addon-pipeline 1.2.103 → 1.2.105

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 (53) hide show
  1. package/dist/{addon-utils-CLc6yHCN.js → addon-utils-UzUMfVj1.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +1188 -127
  5. package/dist/detection-pipeline/index.mjs +1186 -125
  6. package/dist/{dist-Ccmt3fGJ.mjs → dist-BSWr9Qg8.mjs} +296 -11
  7. package/dist/{dist-C11WuNUP.js → dist-DVMPCqaj.js} +313 -10
  8. package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BazdZ0g0.js} +82 -88
  9. package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BeQHivuY.mjs} +82 -88
  10. package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-Du1-XaNP.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +8 -6
  14. package/dist/pipeline-runner/index.mjs +6 -4
  15. package/dist/{process-memory-DOjQ3MgC.js → process-memory-CiNyC-wv.js} +1 -1
  16. package/dist/{process-memory-D0zDmXLI.mjs → process-memory-DFL51gCx.mjs} +1 -1
  17. package/dist/recorder/index.js +660 -130
  18. package/dist/recorder/index.mjs +659 -129
  19. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  20. package/dist/remote-restream-CO36Sr30.js +36 -0
  21. package/dist/restream-intent-B4BXZra7.mjs +72 -0
  22. package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
  23. package/dist/session-decode/decode-worker-child.js +2 -2
  24. package/dist/session-decode/decode-worker-child.mjs +1 -1
  25. package/dist/stream-broker/_stub.js +1 -1
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-pkzutnza.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DvZWLWk1.mjs +26 -0
  28. package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-B1i1rtBB.mjs} +3 -3
  29. package/dist/stream-broker/index.js +598 -82
  30. package/dist/stream-broker/index.mjs +597 -81
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Clcy8PvY.mjs} +1 -1
  33. package/dist/{worker-protocol-BePduZVV.js → worker-protocol-pUUt-yhh.js} +1 -1
  34. package/package.json +1 -1
  35. package/python/inference_pool.py +238 -2
  36. package/python/postprocessors/__init__.py +4 -0
  37. package/python/postprocessors/ctc.py +52 -0
  38. package/python/postprocessors/plate_slots.py +180 -0
  39. package/python/postprocessors/test_ctc.py +39 -0
  40. package/python/postprocessors/test_plate_slots.py +217 -0
  41. package/python/postprocessors/test_yolonas.py +83 -0
  42. package/python/postprocessors/yolonas.py +67 -0
  43. package/python/test_inference_pool_backpressure.py +6 -2
  44. package/python/test_inference_pool_coreml_cache.py +12 -4
  45. package/python/test_inference_pool_device_selection.py +12 -4
  46. package/python/test_inference_pool_layout.py +16 -5
  47. package/python/test_inference_pool_memstats.py +6 -2
  48. package/python/test_inference_pool_ov_ppp.py +12 -9
  49. package/python/test_inference_pool_preprocess.py +57 -0
  50. package/python/test_inference_pool_reconfigure.py +122 -0
  51. package/python/test_inference_pool_static_batch.py +426 -0
  52. package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
  53. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs +0 -26
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-C11WuNUP.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-Cq_NeC4o.js");
7
- const require_lazy_sharp = require("../lazy-sharp-RxUs6on_.js");
8
- const require_process_memory = require("../process-memory-DOjQ3MgC.js");
9
- const require_addon_utils = require("../addon-utils-CLc6yHCN.js");
5
+ const require_dist = require("../dist-DVMPCqaj.js");
6
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BazdZ0g0.js");
7
+ const require_lazy_sharp = require("../lazy-sharp-Du1-XaNP.js");
8
+ const require_process_memory = require("../process-memory-CiNyC-wv.js");
9
+ const require_addon_utils = require("../addon-utils-UzUMfVj1.js");
10
10
  let sharp = require("sharp");
11
11
  sharp = require_dist.__toESM(sharp);
12
12
  let node_child_process = require("node:child_process");
@@ -91,6 +91,11 @@ function probedToHardwareInfo(hw) {
91
91
  * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
92
92
  * still spelled out per row so a reader never has to guess which default a
93
93
  * missing key would have hit.
94
+ *
95
+ * `batchMode` / `maxBatchSize` are REQUEST GROUPING, not shape. Whether a graph
96
+ * may keep a dynamic batch axis is a hardware fact derived at load from the
97
+ * (engine, device) pair — `_static_batch_required` in `inference_pool.py`, D202 —
98
+ * and appears nowhere in this registry, in the catalog, or in any operator knob.
94
99
  */
95
100
  var RUNTIME_DETAIL = {
96
101
  onnx: {
@@ -1071,6 +1076,25 @@ var SharedInferencePool = class {
1071
1076
  })));
1072
1077
  for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to unload model at index ${index}: ${resp.error ?? "unknown"}`);
1073
1078
  }
1079
+ /**
1080
+ * Retune a LOADED slot's decode-scoped settings without touching its
1081
+ * inference session — the narrow counterpart of `replaceModel`.
1082
+ *
1083
+ * `replace` is `unload` + `load`: it re-instantiates the session (~1.4s on
1084
+ * coreml, and an OpenVINO recompile) and stalls every live camera routed to
1085
+ * this pool. An operator moving the NMS slider must not cost that, and must
1086
+ * not touch any other slot: the Python side applies the patch to `index`
1087
+ * only, and refuses any key outside its reconfigurable allow-list rather
1088
+ * than accepting a `path` change that would silently not reload.
1089
+ */
1090
+ async reconfigureModel(index, settings) {
1091
+ const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1092
+ cmd: "reconfigure",
1093
+ index,
1094
+ config: { nmsIouThreshold: settings.nmsIouThreshold }
1095
+ })));
1096
+ for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to reconfigure model at index ${index}: ${resp.error ?? "unknown"}`);
1097
+ }
1074
1098
  async replaceModel(index, config) {
1075
1099
  const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1076
1100
  cmd: "replace",
@@ -1210,6 +1234,7 @@ function serializeModelConfig(config) {
1210
1234
  if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
1211
1235
  if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
1212
1236
  if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
1237
+ if (config.minCharProbability !== void 0) result["minCharProbability"] = config.minCharProbability;
1213
1238
  if (config.numClasses) result["numClasses"] = config.numClasses;
1214
1239
  if (config.strides) result["strides"] = config.strides;
1215
1240
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1297,10 +1322,188 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1297
1322
  };
1298
1323
  }
1299
1324
  //#endregion
1325
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1326
+ function resolveEffectivePostprocessor(definition, modelId) {
1327
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1328
+ }
1329
+ /**
1330
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1331
+ * required for CUSTOM (operator-registered) models, which are NOT in
1332
+ * `definition.models` and would otherwise silently fall back to the step
1333
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1334
+ * error anywhere, just zero detections).
1335
+ */
1336
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1337
+ return entry?.postprocessor ?? definition.postprocessor;
1338
+ }
1339
+ //#endregion
1340
+ //#region src/detection-pipeline/registry/model-knob-applicability.ts
1341
+ /** Decodes that run NMS in the pipeline (and hence consult `nmsIouThreshold`).
1342
+ * Mirrors the Python postprocessors that read the knob — yolo.py,
1343
+ * yolo_seg.py, rfdetr.py, scrfd.py. */
1344
+ var NMS_CONSUMING_POSTPROCESSORS = new Set([
1345
+ "yolo",
1346
+ "yolo-seg",
1347
+ "rfdetr",
1348
+ "scrfd"
1349
+ ]);
1350
+ /**
1351
+ * Does this decode consult `nmsIouThreshold`?
1352
+ *
1353
+ * ONE predicate for the whole system: the UI disables the slider through
1354
+ * {@link annotateConfigSchemaForModels}, and the runtime refuses to forward a
1355
+ * value through `resolvePoolDecodeSettings` — both off this set. Two copies of
1356
+ * the matrix would let the UI promise an effect the pool does not deliver,
1357
+ * which is the failure D214 exists to remove.
1358
+ */
1359
+ function postprocessorConsumesNms(postprocessor) {
1360
+ return NMS_CONSUMING_POSTPROCESSORS.has(postprocessor);
1361
+ }
1362
+ var MIN_CONFIDENCE_MACRO_PREFIX = "minConfidence";
1363
+ /** `minConfidencePerson` → `person`; bare `minConfidence` → null (generic). */
1364
+ function macroOfMinConfidenceKey(key) {
1365
+ if (!key.startsWith(MIN_CONFIDENCE_MACRO_PREFIX)) return null;
1366
+ const rest = key.slice(13);
1367
+ if (rest.length === 0) return null;
1368
+ return rest.charAt(0).toLowerCase() + rest.slice(1);
1369
+ }
1370
+ /**
1371
+ * The macro classes a model's OWN labelmap can reach through the step's
1372
+ * classMap. `undefined` ⇒ the model declares no labels (catalog default
1373
+ * labelmap) — every macro of the step is reachable, nothing to annotate.
1374
+ */
1375
+ function reachableMacros(def, entry) {
1376
+ if (entry.labels.length === 0) return void 0;
1377
+ const map = def.classMap;
1378
+ if (!map) return new Set(entry.labels.map((l) => l.id));
1379
+ if (map.preserveOriginal) return void 0;
1380
+ const macros = /* @__PURE__ */ new Set();
1381
+ for (const label of entry.labels) {
1382
+ const mapped = map.mapping[label.id];
1383
+ if (mapped !== void 0) macros.add(mapped);
1384
+ }
1385
+ return macros;
1386
+ }
1387
+ /**
1388
+ * The labels a custom model declares that the step's dropping classMap
1389
+ * (`preserveOriginal: false`) removes UPSTREAM of every operator setting.
1390
+ * `null` when the model declares no labels, the step has no classMap, the map
1391
+ * preserves originals, or nothing is dropped. The engine factory logs this at
1392
+ * pool-config time — a dropped class must never be silent (CLAUDE.md logging
1393
+ * rule; the discard trail alone answers "what", not "why zero forever").
1394
+ */
1395
+ function collectClassMapDropWarning(def, entry) {
1396
+ if (entry.labels.length === 0) return null;
1397
+ const map = def.classMap;
1398
+ if (!map || map.preserveOriginal) return null;
1399
+ const unmapped = entry.labels.filter((l) => map.mapping[l.id] === void 0).map((l) => l.id);
1400
+ if (unmapped.length === 0) return null;
1401
+ return {
1402
+ unmappedLabels: unmapped,
1403
+ mappedCount: entry.labels.length - unmapped.length
1404
+ };
1405
+ }
1406
+ function toApplicability(acc) {
1407
+ const hasInapplicable = Object.keys(acc.inapplicable).length > 0;
1408
+ const hasOptions = Object.keys(acc.optionsByModel).length > 0;
1409
+ if (!hasInapplicable && !hasOptions) return void 0;
1410
+ return {
1411
+ ...hasInapplicable ? { inapplicable: acc.inapplicable } : {},
1412
+ ...hasOptions ? { optionsByModel: acc.optionsByModel } : {}
1413
+ };
1414
+ }
1415
+ function isAnnotatable(field) {
1416
+ return field.type === "slider" || field.type === "number" || field.type === "multiselect" || field.type === "select" || field.type === "boolean";
1417
+ }
1418
+ function annotateField(field, inputs) {
1419
+ if (!isAnnotatable(field)) return field;
1420
+ const { def, models, customModelIds } = inputs;
1421
+ const acc = {
1422
+ inapplicable: {},
1423
+ optionsByModel: {}
1424
+ };
1425
+ const macroKey = macroOfMinConfidenceKey(field.key);
1426
+ for (const entry of models) {
1427
+ const postprocessor = resolveEffectivePostprocessorForEntry(def, entry);
1428
+ if (field.key === "nmsIouThreshold" && !postprocessorConsumesNms(postprocessor)) {
1429
+ acc.inapplicable[entry.id] = `NMS is fused inside this model's "${postprocessor}" export — the pipeline IoU threshold is not consulted.`;
1430
+ continue;
1431
+ }
1432
+ if (field.key === "rectify" && postprocessor !== "ctc") {
1433
+ acc.inapplicable[entry.id] = `The "${postprocessor}" reader consumes the raw plate box — its crop policy never deskews, so this switch has no effect.`;
1434
+ continue;
1435
+ }
1436
+ if (macroKey !== null) {
1437
+ const macros = reachableMacros(def, entry);
1438
+ if (macros !== void 0 && !macros.has(macroKey)) acc.inapplicable[entry.id] = `This model's labelmap cannot produce "${macroKey}" — the slider filters a class that never arrives.`;
1439
+ continue;
1440
+ }
1441
+ if (field.key === "enabledMacroClasses" && field.type === "multiselect") {
1442
+ const macros = reachableMacros(def, entry);
1443
+ if (macros === void 0) continue;
1444
+ const reachable = field.options.filter((o) => macros.has(o.value));
1445
+ if (reachable.length === 0) acc.inapplicable[entry.id] = "None of this model's labels map to a macro class the pipeline tracks — every detection it emits is dropped upstream of this filter.";
1446
+ else if (reachable.length < field.options.length) acc.optionsByModel[entry.id] = reachable.map((o) => ({
1447
+ value: o.value,
1448
+ label: o.label
1449
+ }));
1450
+ continue;
1451
+ }
1452
+ if (field.key === "enabledClasses" && field.type === "multiselect" && customModelIds.has(entry.id) && entry.labels.length > 0) {
1453
+ acc.optionsByModel[entry.id] = entry.labels.map((l) => ({
1454
+ value: l.id,
1455
+ label: l.name
1456
+ }));
1457
+ continue;
1458
+ }
1459
+ }
1460
+ const applicability = toApplicability(acc);
1461
+ if (applicability === void 0) return field;
1462
+ return {
1463
+ ...field,
1464
+ modelApplicability: applicability
1465
+ };
1466
+ }
1467
+ /**
1468
+ * Decorate a step's config schema with per-model applicability, computed from
1469
+ * the same model union the picker offers. Fields with nothing to say are
1470
+ * returned untouched (identity), so a static-only step with fully-consuming
1471
+ * models produces a byte-identical schema.
1472
+ */
1473
+ function annotateConfigSchemaForModels(inputs) {
1474
+ return inputs.configSchema.map((field) => annotateField(field, inputs));
1475
+ }
1476
+ /**
1477
+ * A persisted settings blob is external data — it survives schema changes,
1478
+ * hand edits and migrations. Accept only a finite IoU in the open (0,1)
1479
+ * interval; anything else falls back rather than reaching the decode, where a
1480
+ * 0 would suppress every overlapping box and a 1 would suppress none.
1481
+ */
1482
+ function readIouThreshold(value) {
1483
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1484
+ if (value <= 0 || value >= 1) return void 0;
1485
+ return value;
1486
+ }
1487
+ /** The effective decode-scoped values for a `(step, model)` pair. */
1488
+ function resolvePoolDecodeSettings(inputs) {
1489
+ const fallback = readIouThreshold(inputs.fallbackNmsIouThreshold) ?? .45;
1490
+ if (!postprocessorConsumesNms(resolveEffectivePostprocessorForEntry(inputs.def, inputs.modelEntry))) return { nmsIouThreshold: fallback };
1491
+ return { nmsIouThreshold: readIouThreshold(inputs.settings?.["nmsIouThreshold"]) ?? fallback };
1492
+ }
1493
+ /** What a built pool config actually carries — the state the slot is IN after
1494
+ * a `load`, so the manager's diff is anchored to the command it sent, never
1495
+ * to a second derivation of the same inputs. */
1496
+ function poolDecodeSettingsOf(config) {
1497
+ return { nmsIouThreshold: config.nmsIouThreshold ?? .45 };
1498
+ }
1499
+ function poolDecodeSettingsEqual(a, b) {
1500
+ return a.nmsIouThreshold === b.nmsIouThreshold;
1501
+ }
1502
+ //#endregion
1300
1503
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1301
1504
  var PipelineModelManager = class {
1302
1505
  pool;
1303
- buildPoolConfig;
1506
+ source;
1304
1507
  /** stepId → modelId → loaded entry. */
1305
1508
  loaded = /* @__PURE__ */ new Map();
1306
1509
  /** stepId → currently-active modelId (the runtime choice). */
@@ -1309,9 +1512,9 @@ var PipelineModelManager = class {
1309
1512
  lruClock = 0;
1310
1513
  log;
1311
1514
  maxModelsPerStep;
1312
- constructor(pool, buildPoolConfig, logger, options) {
1515
+ constructor(pool, source, logger, options) {
1313
1516
  this.pool = pool;
1314
- this.buildPoolConfig = buildPoolConfig;
1517
+ this.source = source;
1315
1518
  this.log = logger;
1316
1519
  this.maxModelsPerStep = options?.maxModelsPerStep ?? 4;
1317
1520
  }
@@ -1335,7 +1538,7 @@ var PipelineModelManager = class {
1335
1538
  } });
1336
1539
  }
1337
1540
  for (const added of diff.added) {
1338
- await this.ensureLoaded(added.addonId, added.modelId);
1541
+ await this.ensureLoaded(added.addonId, added.modelId, added.settings);
1339
1542
  this.activeByStep.set(added.addonId, added.modelId);
1340
1543
  this.log.info("Step loaded", { meta: {
1341
1544
  step: added.addonId,
@@ -1344,7 +1547,7 @@ var PipelineModelManager = class {
1344
1547
  } });
1345
1548
  }
1346
1549
  for (const { step, oldModelId } of diff.modelChanged) {
1347
- await this.ensureLoaded(step.addonId, step.modelId);
1550
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1348
1551
  this.activeByStep.set(step.addonId, step.modelId);
1349
1552
  this.log.info("Active model switched", { meta: {
1350
1553
  step: step.addonId,
@@ -1353,6 +1556,7 @@ var PipelineModelManager = class {
1353
1556
  } });
1354
1557
  await this.enforceLruCap(step.addonId);
1355
1558
  }
1559
+ for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
1356
1560
  }
1357
1561
  /**
1358
1562
  * Get an `IInferenceEngine` handle. Without `modelId`, returns the
@@ -1374,6 +1578,22 @@ var PipelineModelManager = class {
1374
1578
  isLoadedWithModel(stepId, modelId) {
1375
1579
  return this.loaded.get(stepId)?.has(modelId) ?? false;
1376
1580
  }
1581
+ /**
1582
+ * Does the pool NOT yet reflect this step? True when the (step, model) pair
1583
+ * is absent, and also when it is resident with different decode-scoped
1584
+ * settings — the case `isLoadedWithModel` cannot see.
1585
+ *
1586
+ * This is the per-dispatch skip gate: callers that short-circuit on
1587
+ * "already loaded" were the reason a saved threshold never reached a
1588
+ * running pool. Cheap by contract — `resolveDecode` never touches the fs.
1589
+ */
1590
+ needsPoolUpdate(step) {
1591
+ const entry = this.loaded.get(step.addonId)?.get(step.modelId);
1592
+ if (!entry) return true;
1593
+ const desired = this.source.resolveDecode(step.addonId, step.modelId, step.settings);
1594
+ if (desired === null) return true;
1595
+ return !poolDecodeSettingsEqual(entry.decode, desired);
1596
+ }
1377
1597
  /** ModelId currently designated as active for the step. */
1378
1598
  getLoadedModelId(stepId) {
1379
1599
  return this.activeByStep.get(stepId);
@@ -1425,8 +1645,7 @@ var PipelineModelManager = class {
1425
1645
  */
1426
1646
  async loadAdditional(steps) {
1427
1647
  for (const step of steps) {
1428
- if (this.isLoadedWithModel(step.addonId, step.modelId)) continue;
1429
- await this.ensureLoaded(step.addonId, step.modelId);
1648
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1430
1649
  if (!this.activeByStep.has(step.addonId)) this.activeByStep.set(step.addonId, step.modelId);
1431
1650
  }
1432
1651
  }
@@ -1435,7 +1654,7 @@ var PipelineModelManager = class {
1435
1654
  * Honors `maxModelsPerStep` — evicts a non-active LRU variant first
1436
1655
  * when the cap would otherwise be exceeded.
1437
1656
  */
1438
- async ensureLoaded(stepId, modelId) {
1657
+ async ensureLoaded(stepId, modelId, settings) {
1439
1658
  let perStep = this.loaded.get(stepId);
1440
1659
  if (!perStep) {
1441
1660
  perStep = /* @__PURE__ */ new Map();
@@ -1444,6 +1663,7 @@ var PipelineModelManager = class {
1444
1663
  const existing = perStep.get(modelId);
1445
1664
  if (existing) {
1446
1665
  this.touch(existing);
1666
+ await this.reconcileDecode(existing, settings);
1447
1667
  return existing;
1448
1668
  }
1449
1669
  while (perStep.size >= this.maxModelsPerStep) {
@@ -1458,11 +1678,13 @@ var PipelineModelManager = class {
1458
1678
  } });
1459
1679
  }
1460
1680
  const index = this.pool.allocateIndex();
1461
- const config = this.buildPoolConfig(stepId, modelId);
1681
+ const config = this.source.buildConfig(stepId, modelId, settings);
1682
+ const decode = poolDecodeSettingsOf(config);
1462
1683
  this.log.info("Loading step variant", { meta: {
1463
1684
  step: stepId,
1464
1685
  modelId,
1465
- poolIndex: index
1686
+ poolIndex: index,
1687
+ ...decode
1466
1688
  } });
1467
1689
  const { loadMs } = await this.pool.loadModel(index, config);
1468
1690
  this.log.info("Step variant loaded", { meta: {
@@ -1475,11 +1697,71 @@ var PipelineModelManager = class {
1475
1697
  stepId,
1476
1698
  modelId,
1477
1699
  poolIndex: index,
1478
- lruTick: ++this.lruClock
1700
+ lruTick: ++this.lruClock,
1701
+ decode
1479
1702
  };
1480
1703
  perStep.set(modelId, entry);
1481
1704
  return entry;
1482
1705
  }
1706
+ /**
1707
+ * Push a retuned decode setting into ONE already-loaded slot.
1708
+ *
1709
+ * Scope is the point: `reconfigure` names a single pool index, so a slider
1710
+ * moved on `object-detection/yolo26n` never disturbs the face detector, the
1711
+ * classifiers, or the same step's warm benchmark variants — and it never
1712
+ * costs a session reload (see `SharedInferencePool.reconfigureModel`).
1713
+ *
1714
+ * A no-op when nothing moved: this runs on the per-dispatch path, so the
1715
+ * common case must send zero commands.
1716
+ */
1717
+ async reconcileDecode(entry, settings) {
1718
+ const desired = this.source.resolveDecode(entry.stepId, entry.modelId, settings);
1719
+ if (desired === null) return;
1720
+ if (poolDecodeSettingsEqual(entry.decode, desired)) return;
1721
+ const previous = entry.decode;
1722
+ await this.pool.reconfigureModel(entry.poolIndex, desired);
1723
+ entry.decode = desired;
1724
+ this.log.info("Decode settings retuned in place — no model reload", { meta: {
1725
+ step: entry.stepId,
1726
+ modelId: entry.modelId,
1727
+ poolIndex: entry.poolIndex,
1728
+ previousNmsIouThreshold: previous.nmsIouThreshold,
1729
+ ...desired
1730
+ } });
1731
+ this.reportContention(entry, previous, desired);
1732
+ }
1733
+ /** Decode values each (step, model) pair has already run, so a value
1734
+ * RETURNING can be told apart from a value being changed. */
1735
+ decodeHistory = /* @__PURE__ */ new Map();
1736
+ contentionReported = /* @__PURE__ */ new Set();
1737
+ /**
1738
+ * A pool slot is NODE-level; the settings driving it arrive per dispatch, so
1739
+ * two cameras with different per-camera overrides for the same (step, model)
1740
+ * pull the same slot back and forth. That is not expressible — one of them is
1741
+ * always decoding with the other's threshold — and it is invisible from the
1742
+ * outside, so it is reported the first time a pair flip-flops BACK to a value
1743
+ * it already ran. One steady retune (the operator moving a slider) never
1744
+ * trips it; alternating writers do, immediately.
1745
+ */
1746
+ reportContention(entry, previous, desired) {
1747
+ const key = `${entry.stepId}::${entry.modelId}`;
1748
+ let seen = this.decodeHistory.get(key);
1749
+ if (!seen) {
1750
+ seen = new Set([previous.nmsIouThreshold]);
1751
+ this.decodeHistory.set(key, seen);
1752
+ }
1753
+ const returning = seen.has(desired.nmsIouThreshold);
1754
+ seen.add(desired.nmsIouThreshold);
1755
+ if (!returning || this.contentionReported.has(key)) return;
1756
+ this.contentionReported.add(key);
1757
+ this.log.warn("Two dispatches disagree on this step’s decode settings — the pool slot is node-level, so one of them always decodes with the other’s value", { meta: {
1758
+ step: entry.stepId,
1759
+ modelId: entry.modelId,
1760
+ poolIndex: entry.poolIndex,
1761
+ valuesSeen: [...seen].sort((a, b) => a - b),
1762
+ hint: "a per-camera override of a decode-scoped knob cannot be honoured on a shared pool — keep this knob at the node level"
1763
+ } });
1764
+ }
1483
1765
  async unloadEntry(entry) {
1484
1766
  await this.pool.unloadModel(entry.poolIndex);
1485
1767
  const perStep = this.loaded.get(entry.stepId);
@@ -1545,7 +1827,10 @@ var PipelineModelManager = class {
1545
1827
  step: desired,
1546
1828
  oldModelId: activeModelId
1547
1829
  });
1548
- else unchanged.push(activeEntry);
1830
+ else unchanged.push({
1831
+ entry: activeEntry,
1832
+ step: desired
1833
+ });
1549
1834
  }
1550
1835
  for (const [stepId, step] of desiredMap) if (!this.activeByStep.has(stepId)) added.push(step);
1551
1836
  return {
@@ -1823,6 +2108,14 @@ var EngineFactory = class {
1823
2108
  return this.poolManager?.isLoadedWithModel(stepId, modelId) ?? false;
1824
2109
  }
1825
2110
  /**
2111
+ * Does the pool still owe this step something — a load, or a retune of its
2112
+ * decode-scoped settings? The skip gate every dispatch path must use instead
2113
+ * of `isLoadedWithModel`, which cannot see a changed threshold (D214).
2114
+ */
2115
+ needsPoolUpdate(step) {
2116
+ return this.poolManager?.needsPoolUpdate(step) ?? true;
2117
+ }
2118
+ /**
1826
2119
  * List every loaded (stepId, modelId) variant — the pool surfaces each
1827
2120
  * warm slot independently (including bench overrides).
1828
2121
  */
@@ -1958,54 +2251,124 @@ var EngineFactory = class {
1958
2251
  numWorkers,
1959
2252
  ...this.opts.engine.device ? { device: this.opts.engine.device } : {}
1960
2253
  });
1961
- this.poolManager = new PipelineModelManager(this.pool, (stepId, modelId) => this.buildPoolModelConfig(stepId, modelId, poolRuntime), this.log.child("model-mgr"));
2254
+ this.poolManager = new PipelineModelManager(this.pool, {
2255
+ buildConfig: (stepId, modelId, settings) => this.buildPoolModelConfig(stepId, modelId, poolRuntime, settings),
2256
+ resolveDecode: (stepId, modelId, settings) => this.resolveDecode(stepId, modelId, settings)
2257
+ }, this.log.child("model-mgr"));
1962
2258
  await this.pool.initialize([]);
1963
2259
  await this.poolManager.applyConfig(steps);
1964
2260
  }
1965
- buildPoolModelConfig(stepId, modelId, poolRuntime) {
2261
+ /** (step, model) pairs whose classMap-drop warning already logged — the
2262
+ * pool reloads models on every applyConfig; the fact doesn't change. */
2263
+ classMapDropLogged = /* @__PURE__ */ new Set();
2264
+ /**
2265
+ * The fs-free half of {@link PoolModelSource} — the effective decode-scoped
2266
+ * settings for a `(step, model, settings)` triple. Runs on the per-dispatch
2267
+ * path, so it must resolve the catalog entry and nothing more; the label
2268
+ * file, the format build and the model path are load-time concerns.
2269
+ *
2270
+ * `null` when the pair resolves to no model at all — the caller routes that
2271
+ * to `buildConfig`, which throws the message naming the step and the model.
2272
+ */
2273
+ resolveDecode(stepId, modelId, settings) {
1966
2274
  const def = require_event_loop_stall_monitor.getStepDefinition(stepId);
1967
- const modelEntry = def.models.find((m) => m.id === modelId);
1968
- if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog`);
1969
- const format = RUNTIME_TO_FORMAT[poolRuntime];
1970
- if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
1971
- const formatEntry = modelEntry.formats[format];
1972
- if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
1973
- const urlParts = formatEntry.url.split("/");
1974
- const filename = urlParts[urlParts.length - 1] ?? `${modelId}.${format}`;
1975
- const modelPath = `${this.opts.modelsDir}/${filename}`;
1976
- const inputWidth = modelEntry.inputSize.width;
1977
- const inputHeight = modelEntry.inputSize.height;
1978
- const inputSize = Math.max(inputWidth, inputHeight);
1979
- const inputChannels = modelEntry.inputChannels ?? 3;
1980
- const labels = readModelLabels(this.opts.modelsDir, modelEntry) ?? def.labels;
1981
- return {
1982
- path: modelPath,
1983
- inputSize,
1984
- inputWidth,
1985
- inputHeight,
1986
- inputChannels,
1987
- preprocessMode: modelEntry.preprocessMode ?? "letterbox",
1988
- inputNormalization: modelEntry.inputNormalization,
1989
- outputProbabilities: modelEntry.outputProbabilities,
1990
- postprocessor: modelEntry.postprocessor ?? def.postprocessor,
1991
- confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
1992
- nmsIouThreshold: this.opts.nmsIouThreshold ?? .45,
1993
- labels,
1994
- charset: def.charset,
1995
- plateRegion: def.plateRegion,
1996
- minTextLength: def.minTextLength,
1997
- minTextConfidence: def.minTextConfidence,
1998
- numClasses: labels?.length,
1999
- strides: def.postprocessor === "scrfd" ? [
2000
- 8,
2001
- 16,
2002
- 32
2003
- ] : void 0,
2004
- maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2005
- device: this.opts.engine.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2006
- };
2275
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? this.opts.resolveCustomModel?.(stepId, modelId);
2276
+ if (!modelEntry) return null;
2277
+ return resolvePoolDecodeSettings({
2278
+ def,
2279
+ modelEntry,
2280
+ settings,
2281
+ fallbackNmsIouThreshold: this.opts.nmsIouThreshold
2282
+ });
2283
+ }
2284
+ buildPoolModelConfig(stepId, modelId, poolRuntime, stepSettings) {
2285
+ return buildPoolModelConfigForStep({
2286
+ stepId,
2287
+ modelId,
2288
+ poolRuntime,
2289
+ modelsDir: this.opts.modelsDir,
2290
+ nmsIouThreshold: this.opts.nmsIouThreshold,
2291
+ stepSettings,
2292
+ device: this.opts.engine.device,
2293
+ resolveCustomModel: this.opts.resolveCustomModel,
2294
+ onClassMapDrop: ({ stepId: sid, modelId: mid, unmappedLabels }) => {
2295
+ const key = `${sid}::${mid}`;
2296
+ if (this.classMapDropLogged.has(key)) return;
2297
+ this.classMapDropLogged.add(key);
2298
+ this.log.warn("Model labels dropped by step classMap — unreachable by any setting", { meta: {
2299
+ step: sid,
2300
+ modelId: mid,
2301
+ unmappedLabels: [...unmappedLabels],
2302
+ hint: "these classes are removed before enabledMacroClasses / minConfidence* run; only labels in the step classMap are tracked"
2303
+ } });
2304
+ }
2305
+ });
2007
2306
  }
2008
2307
  };
2308
+ /**
2309
+ * Build the Python-pool model config for `(stepId, modelId)`.
2310
+ *
2311
+ * Resolution order: static step catalog first, then the injected
2312
+ * custom-model resolver (operator-registered models from the
2313
+ * `custom-model-registry` cap). A model found in NEITHER throws — that step
2314
+ * cannot load.
2315
+ */
2316
+ function buildPoolModelConfigForStep(inputs) {
2317
+ const { stepId, modelId, poolRuntime, modelsDir } = inputs;
2318
+ const def = require_event_loop_stall_monitor.getStepDefinition(stepId);
2319
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? inputs.resolveCustomModel?.(stepId, modelId);
2320
+ if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2321
+ const format = RUNTIME_TO_FORMAT[poolRuntime];
2322
+ if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2323
+ const dropWarning = collectClassMapDropWarning(def, modelEntry);
2324
+ if (dropWarning !== null) inputs.onClassMapDrop?.({
2325
+ stepId,
2326
+ modelId,
2327
+ unmappedLabels: dropWarning.unmappedLabels
2328
+ });
2329
+ const formatEntry = modelEntry.formats[format];
2330
+ if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2331
+ const urlParts = formatEntry.url.split("/");
2332
+ const modelPath = `${modelsDir}/${urlParts[urlParts.length - 1] ?? `${modelId}.${format}`}`;
2333
+ const inputWidth = modelEntry.inputSize.width;
2334
+ const inputHeight = modelEntry.inputSize.height;
2335
+ const inputSize = Math.max(inputWidth, inputHeight);
2336
+ const inputChannels = modelEntry.inputChannels ?? 3;
2337
+ const entryLabels = modelEntry.labels.length > 0 ? modelEntry.labels.map((l) => l.id) : void 0;
2338
+ const labels = readModelLabels(modelsDir, modelEntry) ?? entryLabels ?? def.labels;
2339
+ return {
2340
+ path: modelPath,
2341
+ inputSize,
2342
+ inputWidth,
2343
+ inputHeight,
2344
+ inputChannels,
2345
+ preprocessMode: modelEntry.preprocessMode ?? "letterbox",
2346
+ inputNormalization: modelEntry.inputNormalization,
2347
+ outputProbabilities: modelEntry.outputProbabilities,
2348
+ postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2349
+ confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2350
+ nmsIouThreshold: resolvePoolDecodeSettings({
2351
+ def,
2352
+ modelEntry,
2353
+ settings: inputs.stepSettings,
2354
+ fallbackNmsIouThreshold: inputs.nmsIouThreshold
2355
+ }).nmsIouThreshold,
2356
+ labels,
2357
+ charset: def.charset,
2358
+ plateRegion: def.plateRegion,
2359
+ minTextLength: def.minTextLength,
2360
+ minTextConfidence: def.minTextConfidence,
2361
+ minCharProbability: def.minCharProbability,
2362
+ numClasses: labels?.length,
2363
+ strides: def.postprocessor === "scrfd" ? [
2364
+ 8,
2365
+ 16,
2366
+ 32
2367
+ ] : void 0,
2368
+ maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2369
+ device: inputs.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2370
+ };
2371
+ }
2009
2372
  //#endregion
2010
2373
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
2011
2374
  var IdlePoolReaper = class {
@@ -3270,7 +3633,7 @@ function estimateSkewAngle(gray, opts = {}) {
3270
3633
  score: bestScore
3271
3634
  };
3272
3635
  }
3273
- function toSharpChannels(channels) {
3636
+ function toSharpChannels$1(channels) {
3274
3637
  switch (channels) {
3275
3638
  case 1: return 1;
3276
3639
  case 2: return 2;
@@ -3351,7 +3714,7 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3351
3714
  jpeg: await (0, sharp.default)(Buffer.from(band.data), { raw: {
3352
3715
  width: band.width,
3353
3716
  height: band.height,
3354
- channels: toSharpChannels(band.channels)
3717
+ channels: toSharpChannels$1(band.channels)
3355
3718
  } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
3356
3719
  width: band.width,
3357
3720
  height: band.height,
@@ -3362,6 +3725,413 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3362
3725
  }
3363
3726
  }
3364
3727
  //#endregion
3728
+ //#region src/detection-pipeline/pipeline/plate-ocr-crop-policy.ts
3729
+ /**
3730
+ * Keyed by POSTPROCESSOR, not by model id: the postprocessor is what identifies
3731
+ * the reader family, it is already what the executor branches on, and a new
3732
+ * model of an existing family inherits the right pixels without touching this
3733
+ * table. A new reader family with a new postprocessor has to add a row — which
3734
+ * is the point, since it cannot be given the wrong one by default.
3735
+ */
3736
+ var PLATE_OCR_CROP_POLICIES = {
3737
+ ctc: {
3738
+ marginFraction: 0,
3739
+ rectify: true,
3740
+ minLongSidePx: 85
3741
+ },
3742
+ "plate-slots": {
3743
+ marginFraction: .15,
3744
+ rectify: false,
3745
+ minLongSidePx: 70
3746
+ }
3747
+ };
3748
+ /**
3749
+ * The crop policy for a step child, or `null` when the child is not a plate
3750
+ * reader at all (a face embedder, a classifier — nothing here applies to them).
3751
+ */
3752
+ function resolvePlateOcrCropPolicy(postprocessor) {
3753
+ return PLATE_OCR_CROP_POLICIES[postprocessor] ?? null;
3754
+ }
3755
+ /**
3756
+ * Grow a bbox by `marginFraction` of its own size on each side, clamped to the
3757
+ * frame. Returns the input unchanged at margin 0 or on a degenerate box, so the
3758
+ * un-margined readers keep the exact pixels they had.
3759
+ */
3760
+ function expandBbox(bbox, marginFraction, imageWidth, imageHeight) {
3761
+ const [x1, y1, x2, y2] = bbox;
3762
+ const width = x2 - x1;
3763
+ const height = y2 - y1;
3764
+ if (!(marginFraction > 0) || !(width > 0) || !(height > 0)) return [
3765
+ x1,
3766
+ y1,
3767
+ x2,
3768
+ y2
3769
+ ];
3770
+ return [
3771
+ Math.max(0, x1 - width * marginFraction),
3772
+ Math.max(0, y1 - height * marginFraction),
3773
+ Math.min(imageWidth, x2 + width * marginFraction),
3774
+ Math.min(imageHeight, y2 + height * marginFraction)
3775
+ ];
3776
+ }
3777
+ //#endregion
3778
+ //#region src/detection-pipeline/pipeline/plate-warp.ts
3779
+ /**
3780
+ * Plate-crop WARP — perspective rectification of a licence-plate ROI to a
3781
+ * canonical, aspect-locked text strip, run immediately before the CTC reader.
3782
+ *
3783
+ * WHY this exists on top of {@link ./plate-deskew.rectifyPlateJpeg}
3784
+ * ----------------------------------------------------------------
3785
+ * The detector hands OCR an AXIS-ALIGNED box around a plate that, on an oblique
3786
+ * camera, is a rotated trapezoid inside it. Two things then destroy the read,
3787
+ * and neither is a resolution problem:
3788
+ *
3789
+ * 1. **Aspect.** A German plate is 4.7 : 1. The boxes cam 617 produces measure
3790
+ * 1.3 – 2.0 : 1 — the box is 2-3× too tall because it circumscribes a
3791
+ * tilted rectangle. The recognizer resizes its input to a fixed 64px height
3792
+ * preserving aspect, so a 1.5 : 1 tile becomes ~96px wide: seven glyphs get
3793
+ * ~13px each. The same plate rectified to 4.7 : 1 arrives ~300px wide.
3794
+ * 2. **Tilt.** The 2026-08 deskew estimator searches ±16° and REJECTS an
3795
+ * unbracketed optimum (D186) — correct, and it means it returns 0° on this
3796
+ * camera, whose plates sit at 24-31°. The rotation is real; the projection
3797
+ * profile just cannot measure it from a box that is mostly car.
3798
+ *
3799
+ * The plate itself is the one thing in the ROI that is reliably a large, bright,
3800
+ * convex blob. So: Otsu-threshold the tile, keep the largest bright connected
3801
+ * component, take its four extreme corners, and sample that quadrilateral onto
3802
+ * a fixed 4.7 : 1 × 64px canvas with bilinear interpolation. One pass, no
3803
+ * OpenCV, no new dependency — sharp only decodes and re-encodes, exactly as
3804
+ * `plate-deskew.ts` and `native-child-crop.ts` do.
3805
+ *
3806
+ * MEASURED on 21 ground-truthed cam-617 plate crops (9 distinct vehicles,
3807
+ * 2026-08-20 offline replay against the production `vgg_english_g2` weights and
3808
+ * the production CTC postprocessor):
3809
+ *
3810
+ * | chain | exact | char acc | false on 18 junk ROIs |
3811
+ * | deskew only + 100px gate (live today) | 0/21 | 4.8 % | 0 |
3812
+ * | deskew + text-band + 3× upscale | 0/21 | 16.3 % | 0 |
3813
+ * | THIS warp + seal-tolerant gate, 85px | 4/21 | 42.9 % | 0 |
3814
+ *
3815
+ * The four exact reads are four DIFFERENT vehicles at 91-98px native plate
3816
+ * width — every one of them SKIPPED entirely by the old 100px floor. Upscaling
3817
+ * and contrast work (CLAHE, autocontrast, unsharp, 2/3/4× lanczos) moved char
3818
+ * accuracy by ≤2 points on their own and are NOT included: the geometry was the
3819
+ * whole story.
3820
+ */
3821
+ /**
3822
+ * Canonical strip geometry. 4.7 : 1 is the German plate ratio (520 × 110 mm);
3823
+ * 64px is the recognizer's own input height, so the warp writes exactly the
3824
+ * pixels the model reads and no resampling is wasted.
3825
+ */
3826
+ var PLATE_STRIP_ASPECT = 4.7;
3827
+ /**
3828
+ * Longest side the blob search runs at. The connected-component pass is O(px)
3829
+ * with an explicit stack; capping it keeps a 600px plate ROI at ~1ms.
3830
+ */
3831
+ var BLOB_SEARCH_MAX_DIM = 200;
3832
+ /**
3833
+ * The blob must be a plausible plate: neither a speck nor the whole tile, and
3834
+ * within a wide-but-finite aspect band. Outside these the caller keeps its
3835
+ * un-warped tile — a bad quad is far worse than no quad.
3836
+ */
3837
+ var MIN_BLOB_AREA_FRAC = .04;
3838
+ var MAX_BLOB_AREA_FRAC = .96;
3839
+ var MIN_QUAD_ASPECT = 1.5;
3840
+ var MAX_QUAD_ASPECT = 9;
3841
+ var MIN_QUAD_WIDTH_PX = 12;
3842
+ var MIN_QUAD_HEIGHT_PX = 5;
3843
+ /**
3844
+ * Fraction of the plate width dropped from the LEFT edge before sampling.
3845
+ * The bright blob's left boundary sits on the plate's black frame / the blue EU
3846
+ * band shoulder, and that edge column decodes as a spurious leading glyph
3847
+ * ("IDND7578" for DN-D 7578). Swept end to end over the ground-truth set at
3848
+ * 0 / 2 / 3.5 / 4 / 5 / 6 / 7 %: 4 % is the peak (4/21 exact, 42.9 % char), the
3849
+ * neighbours give 3/21, and 7 % starts eating the first character (0/21).
3850
+ */
3851
+ var LEFT_INSET_FRAC = .04;
3852
+ /** Otsu's between-class-variance threshold of a grayscale image. */
3853
+ function otsuThreshold(gray) {
3854
+ const hist = new Float64Array(256);
3855
+ for (let i = 0; i < gray.data.length; i++) {
3856
+ const v = gray.data[i];
3857
+ hist[v] = (hist[v] ?? 0) + 1;
3858
+ }
3859
+ const total = gray.data.length;
3860
+ if (total === 0) return 128;
3861
+ let sum = 0;
3862
+ for (let v = 0; v < 256; v++) sum += v * hist[v];
3863
+ let wB = 0;
3864
+ let sumB = 0;
3865
+ let best = 0;
3866
+ let bestVar = -1;
3867
+ for (let v = 0; v < 256; v++) {
3868
+ wB += hist[v];
3869
+ if (wB === 0) continue;
3870
+ const wF = total - wB;
3871
+ if (wF === 0) break;
3872
+ sumB += v * hist[v];
3873
+ const mB = sumB / wB;
3874
+ const mF = (sum - sumB) / wF;
3875
+ const between = wB * wF * (mB - mF) * (mB - mF);
3876
+ if (between > bestVar) {
3877
+ bestVar = between;
3878
+ best = v;
3879
+ }
3880
+ }
3881
+ return best;
3882
+ }
3883
+ /** `p`-th percentile (0-100) of a grayscale image, via its 256-bin histogram. */
3884
+ function percentile(gray, p) {
3885
+ const hist = new Int32Array(256);
3886
+ for (let i = 0; i < gray.data.length; i++) hist[gray.data[i]] = (hist[gray.data[i]] ?? 0) + 1;
3887
+ const target = gray.data.length * p / 100;
3888
+ let seen = 0;
3889
+ for (let v = 0; v < 256; v++) {
3890
+ seen += hist[v] ?? 0;
3891
+ if (seen >= target) return v;
3892
+ }
3893
+ return 255;
3894
+ }
3895
+ /**
3896
+ * Mask (1/0, same dimensions) of the LARGEST 4-connected component of pixels
3897
+ * strictly above `threshold`. `null` when nothing clears the threshold.
3898
+ * Iterative flood fill — a plate ROI is small and recursion depth is not.
3899
+ */
3900
+ function largestBrightComponent(gray, threshold) {
3901
+ const { data, width, height } = gray;
3902
+ const n = width * height;
3903
+ const label = new Int32Array(n);
3904
+ const stack = [];
3905
+ let current = 0;
3906
+ let bestLabel = 0;
3907
+ let bestSize = 0;
3908
+ for (let seed = 0; seed < n; seed++) {
3909
+ if (label[seed] !== 0 || data[seed] <= threshold) continue;
3910
+ current += 1;
3911
+ label[seed] = current;
3912
+ stack.length = 0;
3913
+ stack.push(seed);
3914
+ let size = 0;
3915
+ while (stack.length > 0) {
3916
+ const i = stack.pop();
3917
+ size += 1;
3918
+ const x = i % width;
3919
+ const y = (i - x) / width;
3920
+ if (x > 0 && label[i - 1] === 0 && data[i - 1] > threshold) {
3921
+ label[i - 1] = current;
3922
+ stack.push(i - 1);
3923
+ }
3924
+ if (x < width - 1 && label[i + 1] === 0 && data[i + 1] > threshold) {
3925
+ label[i + 1] = current;
3926
+ stack.push(i + 1);
3927
+ }
3928
+ if (y > 0 && label[i - width] === 0 && data[i - width] > threshold) {
3929
+ label[i - width] = current;
3930
+ stack.push(i - width);
3931
+ }
3932
+ if (y < height - 1 && label[i + width] === 0 && data[i + width] > threshold) {
3933
+ label[i + width] = current;
3934
+ stack.push(i + width);
3935
+ }
3936
+ }
3937
+ if (size > bestSize) {
3938
+ bestSize = size;
3939
+ bestLabel = current;
3940
+ }
3941
+ }
3942
+ if (bestLabel === 0) return null;
3943
+ const out = new Uint8Array(n);
3944
+ for (let i = 0; i < n; i++) out[i] = label[i] === bestLabel ? 1 : 0;
3945
+ return out;
3946
+ }
3947
+ /**
3948
+ * Four corners of a mask, taken as the extreme points of `x+y` (top-left /
3949
+ * bottom-right) and `x−y` (top-right / bottom-left). Exact for a rotated
3950
+ * rectangle and stable under the blob noise of a real plate. `null` when the
3951
+ * mask is degenerate.
3952
+ */
3953
+ function quadFromMask(mask, width, height) {
3954
+ let minSum = Number.POSITIVE_INFINITY;
3955
+ let maxSum = Number.NEGATIVE_INFINITY;
3956
+ let minDif = Number.POSITIVE_INFINITY;
3957
+ let maxDif = Number.NEGATIVE_INFINITY;
3958
+ let tl = null;
3959
+ let br = null;
3960
+ let bl = null;
3961
+ let tr = null;
3962
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
3963
+ if (mask[y * width + x] !== 1) continue;
3964
+ const s = x + y;
3965
+ const d = x - y;
3966
+ if (s < minSum) {
3967
+ minSum = s;
3968
+ tl = {
3969
+ x,
3970
+ y
3971
+ };
3972
+ }
3973
+ if (s > maxSum) {
3974
+ maxSum = s;
3975
+ br = {
3976
+ x,
3977
+ y
3978
+ };
3979
+ }
3980
+ if (d < minDif) {
3981
+ minDif = d;
3982
+ bl = {
3983
+ x,
3984
+ y
3985
+ };
3986
+ }
3987
+ if (d > maxDif) {
3988
+ maxDif = d;
3989
+ tr = {
3990
+ x,
3991
+ y
3992
+ };
3993
+ }
3994
+ }
3995
+ if (!tl || !tr || !br || !bl) return null;
3996
+ const w = Math.max(dist(tl, tr), dist(bl, br));
3997
+ const h = Math.max(dist(tl, bl), dist(tr, br));
3998
+ if (w < MIN_QUAD_WIDTH_PX || h < MIN_QUAD_HEIGHT_PX) return null;
3999
+ return {
4000
+ tl,
4001
+ tr,
4002
+ br,
4003
+ bl
4004
+ };
4005
+ }
4006
+ function dist(a, b) {
4007
+ return Math.hypot(a.x - b.x, a.y - b.y);
4008
+ }
4009
+ /**
4010
+ * Bilinear sample of the source quadrilateral onto an `outW × outH` canvas.
4011
+ * The mapping is the standard bilinear-in-(u,v) interpolation of the four
4012
+ * corners — the same family as the rotation warp in `plate-deskew.ts`, and
4013
+ * exact for the affine case. Out-of-bounds samples clamp to the edge pixel.
4014
+ */
4015
+ function samplePerspective(src, quad, outW, outH) {
4016
+ const { data, width: sw, height: sh, channels: ch } = src;
4017
+ const out = new Uint8Array(outW * outH * ch);
4018
+ const maxX = sw - 1;
4019
+ const maxY = sh - 1;
4020
+ for (let oy = 0; oy < outH; oy++) {
4021
+ const v = (oy + .5) / outH;
4022
+ for (let ox = 0; ox < outW; ox++) {
4023
+ const u = (ox + .5) / outW;
4024
+ const topX = quad.tl.x + (quad.tr.x - quad.tl.x) * u;
4025
+ const topY = quad.tl.y + (quad.tr.y - quad.tl.y) * u;
4026
+ const botX = quad.bl.x + (quad.br.x - quad.bl.x) * u;
4027
+ const botY = quad.bl.y + (quad.br.y - quad.bl.y) * u;
4028
+ const sx = Math.min(maxX, Math.max(0, topX + (botX - topX) * v));
4029
+ const sy = Math.min(maxY, Math.max(0, topY + (botY - topY) * v));
4030
+ const x0 = Math.floor(sx);
4031
+ const y0 = Math.floor(sy);
4032
+ const x1 = x0 < maxX ? x0 + 1 : x0;
4033
+ const y1 = y0 < maxY ? y0 + 1 : y0;
4034
+ const fx = sx - x0;
4035
+ const fy = sy - y0;
4036
+ const w00 = (1 - fx) * (1 - fy);
4037
+ const w10 = fx * (1 - fy);
4038
+ const w01 = (1 - fx) * fy;
4039
+ const w11 = fx * fy;
4040
+ const i00 = (y0 * sw + x0) * ch;
4041
+ const i10 = (y0 * sw + x1) * ch;
4042
+ const i01 = (y1 * sw + x0) * ch;
4043
+ const i11 = (y1 * sw + x1) * ch;
4044
+ const o = (oy * outW + ox) * ch;
4045
+ for (let k = 0; k < ch; k++) out[o + k] = data[i00 + k] * w00 + data[i10 + k] * w10 + data[i01 + k] * w01 + data[i11 + k] * w11 + .5 | 0;
4046
+ }
4047
+ }
4048
+ return {
4049
+ data: out,
4050
+ width: outW,
4051
+ height: outH,
4052
+ channels: ch
4053
+ };
4054
+ }
4055
+ function toSharpChannels(channels) {
4056
+ switch (channels) {
4057
+ case 1: return 1;
4058
+ case 2: return 2;
4059
+ case 4: return 4;
4060
+ default: return 3;
4061
+ }
4062
+ }
4063
+ /**
4064
+ * Locate the plate blob inside `jpeg` and return it rectified to the canonical
4065
+ * {@link PLATE_STRIP_ASPECT} × {@link PLATE_STRIP_HEIGHT} strip.
4066
+ *
4067
+ * `null` — meaning "caller keeps its own tile" — on: a decode failure, a tile
4068
+ * under 16 × 8, no bright component, a component that is a speck or the whole
4069
+ * tile, or a quad outside the plate aspect band. Never throws, never returns a
4070
+ * strip it is not confident about: the un-warped tile is the floor, not a
4071
+ * guess.
4072
+ */
4073
+ async function warpPlateJpeg(jpeg, opts = {}) {
4074
+ const raw = await (0, sharp.default)(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
4075
+ if (!raw) return null;
4076
+ const { data, info } = raw;
4077
+ if (info.width < 16 || info.height < 8) return null;
4078
+ const rgb = {
4079
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
4080
+ width: info.width,
4081
+ height: info.height,
4082
+ channels: info.channels
4083
+ };
4084
+ const gray = toGrayscale(rgb);
4085
+ const small = downscaleGray(gray, BLOB_SEARCH_MAX_DIM);
4086
+ const scale = small.width / gray.width;
4087
+ const mask = largestBrightComponent(small, Math.max(otsuThreshold(small), percentile(small, 55)));
4088
+ if (!mask) return null;
4089
+ let area = 0;
4090
+ for (let i = 0; i < mask.length; i++) area += mask[i];
4091
+ const frac = area / (small.width * small.height);
4092
+ if (frac < MIN_BLOB_AREA_FRAC || frac > MAX_BLOB_AREA_FRAC) return null;
4093
+ const q = quadFromMask(mask, small.width, small.height);
4094
+ if (!q) return null;
4095
+ const up = (p) => ({
4096
+ x: p.x / scale,
4097
+ y: p.y / scale
4098
+ });
4099
+ const tl = up(q.tl);
4100
+ const tr = up(q.tr);
4101
+ const br = up(q.br);
4102
+ const bl = up(q.bl);
4103
+ const quadW = Math.max(dist(tl, tr), dist(bl, br));
4104
+ const quadH = Math.max(dist(tl, bl), dist(tr, br));
4105
+ if (quadW < MIN_QUAD_WIDTH_PX || quadH < MIN_QUAD_HEIGHT_PX) return null;
4106
+ const ratio = quadW / quadH;
4107
+ if (ratio < MIN_QUAD_ASPECT || ratio > MAX_QUAD_ASPECT) return null;
4108
+ const inset = (a, b) => ({
4109
+ x: a.x + (b.x - a.x) * LEFT_INSET_FRAC,
4110
+ y: a.y + (b.y - a.y) * LEFT_INSET_FRAC
4111
+ });
4112
+ const warped = {
4113
+ tl: inset(tl, tr),
4114
+ tr,
4115
+ br,
4116
+ bl: inset(bl, br)
4117
+ };
4118
+ const outH = 64;
4119
+ const strip = samplePerspective(rgb, warped, Math.round(outH * PLATE_STRIP_ASPECT), outH);
4120
+ try {
4121
+ return {
4122
+ jpeg: await (0, sharp.default)(Buffer.from(strip.data), { raw: {
4123
+ width: strip.width,
4124
+ height: strip.height,
4125
+ channels: toSharpChannels(strip.channels)
4126
+ } }).jpeg({ quality: opts.quality ?? 92 }).toBuffer(),
4127
+ width: strip.width,
4128
+ height: strip.height
4129
+ };
4130
+ } catch {
4131
+ return null;
4132
+ }
4133
+ }
4134
+ //#endregion
3365
4135
  //#region src/detection-pipeline/pipeline/result-assembler.ts
3366
4136
  /** L2 norm of a raw embedding vector. */
3367
4137
  function vectorMagnitude(v) {
@@ -3445,6 +4215,13 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3445
4215
  const passesClass = (c) => (enabledSet === null || enabledSet.has(c.class)) && !rejectSet.has(c.class.toLowerCase());
3446
4216
  const minConfidence = typeof childStep.settings?.["minConfidence"] === "number" ? childStep.settings["minConfidence"] : 0;
3447
4217
  const winner = output.classifications[0];
4218
+ if (winner && !passesClass(winner) && winner.score >= minConfidence) ctx.onClassifierClassDropped?.({
4219
+ stepId: childStep.stepId,
4220
+ modelId: childStep.modelId,
4221
+ droppedClass: winner.class,
4222
+ score: winner.score,
4223
+ reason: enabledSet !== null && !enabledSet.has(winner.class) ? "class-not-enabled" : "reject-class"
4224
+ });
3448
4225
  if (winner && passesClass(winner) && winner.score >= minConfidence) parent.ownLabels.push({
3449
4226
  label: winner.class,
3450
4227
  score: winner.score,
@@ -3684,36 +4461,6 @@ var MIN_FACE_DETECTION_FLOOR_PX = 6;
3684
4461
  * above the downscaled detection resolution while bounding the crop payload.
3685
4462
  */
3686
4463
  var NATIVE_FACE_CROP_MAX_WIDTH = 320;
3687
- /**
3688
- * Minimum LONG side (px) of the ACTUAL crop fed to the plate-OCR (`ctc`) leaf —
3689
- * the plate analogue of the face path's min-face-size gate.
3690
- *
3691
- * The long side, not the short one. A plate's information lives along its text
3692
- * run: the recognizer resizes the tile to a fixed 64px height and reads left to
3693
- * right, so what decides whether a glyph survives is how many horizontal pixels
3694
- * each character got. The previous 20px SHORT-side floor measured the wrong
3695
- * axis and passed essentially everything.
3696
- *
3697
- * The number comes from the 2026-08 audit of 11 cam-617 plate tracks with
3698
- * ground truth. Native long side vs read:
3699
- *
3700
- * 117px → "DNRO3O9" (truth DNRO309 — one glyph out, the only near-miss)
3701
- * 82px → "VONND9592" (truth DNDS597)
3702
- * 77px → "DEPNOQ1" (truth DNP1091)
3703
- * 76px → "VDHPXOE8" (truth DNPK838)
3704
- * 73px → "DAEOR2D8" (truth DNDR298)
3705
- * ≤62px → "I0IAZ", "EUSM9", "0IA3", "DYAGI2", "I0AA", "IC1E"
3706
- *
3707
- * Not one read under 100px resembled its plate, and the gap between 82 and 117
3708
- * is empty — 100 sits in it. Below this floor the OCR call is pure cost: it
3709
- * produces a label that is wrong, and a wrong plate label is worse than none.
3710
- *
3711
- * Measured on the NATIVE crop when the native ROI fetch succeeded (its
3712
- * dimensions ARE the plate's real native size — `nativeChildCrop`'s 1920 cap
3713
- * never binds for a plate), else on the fallback detection-space tile — which
3714
- * IS the input OCR would read, so the same floor applies honestly in both cases.
3715
- */
3716
- var MIN_PLATE_OCR_LONG_SIDE_PX = 100;
3717
4464
  function isEnrichmentOutput(output) {
3718
4465
  return output.kind === "classifications" || output.kind === "embedding" || output.kind === "text";
3719
4466
  }
@@ -3768,6 +4515,10 @@ var PipelineExecutor = class {
3768
4515
  /** Bounds the full-frame-guard drop log: first example per (device,
3769
4516
  * class), then a counted summary per minute. See `drop-sampler.ts`. */
3770
4517
  dropSampler = new DropSampler();
4518
+ /** Bounds the classifier class-filter drop log the same way, keyed per
4519
+ * (device, `step:class`) — an excluded species firing on every frame of a
4520
+ * track must be a line and a counter, not wallpaper. */
4521
+ classFilterDropSampler = new DropSampler();
3771
4522
  constructor(opts) {
3772
4523
  this.opts = opts;
3773
4524
  }
@@ -3836,7 +4587,30 @@ var PipelineExecutor = class {
3836
4587
  const idGen = new FrameIdGenerator();
3837
4588
  const ctx = {
3838
4589
  idGen,
3839
- details
4590
+ details,
4591
+ onClassifierClassDropped: (drop) => {
4592
+ const sample = this.classFilterDropSampler.record(deviceId, `${drop.stepId}:${drop.droppedClass}`);
4593
+ if (sample.logExample) this.opts.logger?.info("classifier winner excluded by class filter — label dropped", {
4594
+ tags: { deviceId },
4595
+ meta: {
4596
+ step: drop.stepId,
4597
+ model: drop.modelId,
4598
+ droppedClass: drop.droppedClass,
4599
+ score: Number(drop.score.toFixed(3)),
4600
+ reason: drop.reason,
4601
+ hint: "detection keeps its macro class; adjust the step enabledClasses to change this"
4602
+ }
4603
+ });
4604
+ if (sample.summaryCount !== null) this.opts.logger?.info("classifier class filter: still dropping", {
4605
+ tags: { deviceId },
4606
+ meta: {
4607
+ step: drop.stepId,
4608
+ droppedClass: drop.droppedClass,
4609
+ suppressed: sample.summaryCount,
4610
+ windowMs: DROP_SUMMARY_INTERVAL_MS
4611
+ }
4612
+ });
4613
+ }
3840
4614
  };
3841
4615
  let discarded;
3842
4616
  const rootShed = {
@@ -4241,20 +5015,22 @@ var PipelineExecutor = class {
4241
5015
  cropH = crop.height;
4242
5016
  }
4243
5017
  } else {
4244
- const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
5018
+ const cropPolicy = resolvePlateOcrCropPolicy(resolveEffectivePostprocessor(child.definition, child.modelId));
5019
+ const cropBbox = cropPolicy ? expandBbox(parentDetection.bbox, cropPolicy.marginFraction, imageWidth, imageHeight) : parentDetection.bbox;
5020
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, cropBbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4245
5021
  if (nativeChild) {
4246
5022
  cropJpegBuf = nativeChild.jpeg;
4247
5023
  cropW = nativeChild.width;
4248
5024
  cropH = nativeChild.height;
4249
5025
  } else {
4250
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
5026
+ const crop = await cropJpeg(fullFrameJpeg, cropBbox, imageWidth, imageHeight);
4251
5027
  cropJpegBuf = crop.jpeg;
4252
5028
  cropW = crop.width;
4253
5029
  cropH = crop.height;
4254
5030
  }
4255
- if (child.definition.postprocessor === "ctc") {
5031
+ if (cropPolicy) {
4256
5032
  const plateLongSidePx = Math.max(cropW, cropH);
4257
- if (plateLongSidePx < MIN_PLATE_OCR_LONG_SIDE_PX) {
5033
+ if (plateLongSidePx < cropPolicy.minLongSidePx) {
4258
5034
  this.opts.logger?.info("plate ROI too small for OCR — skipping", {
4259
5035
  tags: { deviceId },
4260
5036
  meta: {
@@ -4262,28 +5038,44 @@ var PipelineExecutor = class {
4262
5038
  model: child.modelId,
4263
5039
  plateLongSidePx: Math.round(plateLongSidePx),
4264
5040
  plateShortSidePx: Math.round(Math.min(cropW, cropH)),
4265
- minPlateLongSidePx: MIN_PLATE_OCR_LONG_SIDE_PX,
5041
+ minPlateLongSidePx: cropPolicy.minLongSidePx,
5042
+ marginFraction: cropPolicy.marginFraction,
4266
5043
  source: nativeChild ? "native" : "detection-tile"
4267
5044
  }
4268
5045
  });
4269
5046
  continue;
4270
5047
  }
4271
5048
  }
4272
- if (child.definition.postprocessor === "ctc" && child.settings?.["rectify"] !== false) try {
4273
- const rectified = await rectifyPlateJpeg(cropJpegBuf);
4274
- if (rectified) {
4275
- cropJpegBuf = rectified.jpeg;
4276
- cropW = rectified.width;
4277
- cropH = rectified.height;
4278
- this.opts.logger?.info("plate crop deskewed before OCR", {
5049
+ if (cropPolicy?.rectify && child.settings?.["rectify"] !== false) try {
5050
+ const warped = await warpPlateJpeg(cropJpegBuf);
5051
+ if (warped) {
5052
+ cropJpegBuf = warped.jpeg;
5053
+ cropW = warped.width;
5054
+ cropH = warped.height;
5055
+ this.opts.logger?.info("plate crop warped before OCR", {
4279
5056
  tags: { deviceId },
4280
5057
  meta: {
4281
5058
  step: child.stepId,
4282
- angleDeg: Number(rectified.angleDeg.toFixed(2)),
4283
5059
  width: cropW,
4284
5060
  height: cropH
4285
5061
  }
4286
5062
  });
5063
+ } else {
5064
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
5065
+ if (rectified) {
5066
+ cropJpegBuf = rectified.jpeg;
5067
+ cropW = rectified.width;
5068
+ cropH = rectified.height;
5069
+ this.opts.logger?.info("plate crop deskewed before OCR", {
5070
+ tags: { deviceId },
5071
+ meta: {
5072
+ step: child.stepId,
5073
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
5074
+ width: cropW,
5075
+ height: cropH
5076
+ }
5077
+ });
5078
+ }
4287
5079
  }
4288
5080
  } catch {}
4289
5081
  }
@@ -4486,6 +5278,81 @@ function mergeCustomModels(staticModels, customModels) {
4486
5278
  return merged;
4487
5279
  }
4488
5280
  //#endregion
5281
+ //#region src/detection-pipeline/cluster-model-resolution.ts
5282
+ /**
5283
+ * The cluster model rule, enforced on the node that has to obey it.
5284
+ *
5285
+ * ## What is different about a cluster-scoped step
5286
+ *
5287
+ * For every other step, `resolveModelForFormat` is a kindness: a chosen model
5288
+ * with no build for this node's format is quietly swapped for the format's
5289
+ * default, one info line, and the camera keeps detecting. That is the right
5290
+ * trade when the output is a box, because two nodes producing boxes with two
5291
+ * detectors are not producing anything that gets compared.
5292
+ *
5293
+ * `face-embedding` and `clip-embedding` produce vectors that land in ONE shared
5294
+ * index and are ranked against each other by cosine. There the same kindness is
5295
+ * corruption: the substituted node writes a second feature space into the index
5296
+ * and nothing in the data says so. So for a step whose definition declares
5297
+ * `modelScope: 'cluster'` this module replaces the substitution with a
5298
+ * **refusal** — the rule `runStatelessStep` already applies to a pinned model
5299
+ * ([D54](../../../../docs/decisions/adr-0056.md)), extended to the live detail
5300
+ * plane, which is the other writer into the same indexes.
5301
+ *
5302
+ * ## What "refused" means here, exactly
5303
+ *
5304
+ * It means **refused to substitute**, not "dropped". The step keeps the pinned
5305
+ * model id, and the placement machinery that already exists —
5306
+ * `resolveStepsForDispatch`'s node-local engine fallback and the detail plane's
5307
+ * `resolveDetailStepDevice` jump — keeps doing its job, both of which key off a
5308
+ * step whose model has no build for the candidate format. That is how a
5309
+ * `face-embedding` dispatched at a Coral gets rescued onto the node's onnx
5310
+ * engine today, and taking that rescue away in the name of safety would stop
5311
+ * producing faces on every mixed-accelerator node.
5312
+ *
5313
+ * What the refusal removes is the LAST resort: after the fallback and the jump,
5314
+ * a step whose model this node truly cannot serve fails loudly
5315
+ * (`assertStepsRunnable`, D39) instead of quietly running a different encoder.
5316
+ *
5317
+ * A refusal is COUNTED and logged by the caller, with `tags: { deviceId }`.
5318
+ * Silence would read as "never happened" (CLAUDE.md: a branch that drops work
5319
+ * logs it).
5320
+ */
5321
+ var defaultLookup = (stepId) => {
5322
+ try {
5323
+ return require_event_loop_stall_monitor.getStepDefinition(stepId);
5324
+ } catch {
5325
+ return null;
5326
+ }
5327
+ };
5328
+ /**
5329
+ * Decide what `stepId` runs on a node dispatching in `format`.
5330
+ *
5331
+ * An unknown step is `node-scoped`: a custom addon owns its own format
5332
+ * compatibility and this module has no catalog entry to reason about.
5333
+ */
5334
+ function resolveClusterStepModel(stepId, clusterModels, format, lookup = defaultLookup) {
5335
+ const def = lookup(stepId);
5336
+ if (def === null || def.modelScope !== "cluster") return { kind: "node-scoped" };
5337
+ const modelId = require_dist.resolveClusterStepModelId(stepId, clusterModels);
5338
+ if (modelId === null) return { kind: "node-scoped" };
5339
+ const entry = def.models.find((m) => m.id === modelId);
5340
+ if (entry === void 0) return {
5341
+ kind: "pinned",
5342
+ modelId
5343
+ };
5344
+ if (entry.formats[format] !== void 0) return {
5345
+ kind: "pinned",
5346
+ modelId
5347
+ };
5348
+ return {
5349
+ kind: "refused",
5350
+ modelId,
5351
+ format,
5352
+ formatsShipped: Object.keys(entry.formats)
5353
+ };
5354
+ }
5355
+ //#endregion
4489
5356
  //#region src/detection-pipeline/step-resolution.ts
4490
5357
  /**
4491
5358
  * Walk `steps` (recursing into children) and return exactly the
@@ -4587,10 +5454,11 @@ function collectUnknownAddonIssues(steps, getStepDef = require_event_loop_stall_
4587
5454
  * `diagnostics.substitutions` instead of happening silently. The caller
4588
5455
  * (the provider) owns logging + dedup for both.
4589
5456
  */
4590
- function resolveInputSteps(steps, format, engine) {
5457
+ function resolveInputSteps(steps, format, engine, clusterModels) {
4591
5458
  const resolvedSteps = [];
4592
5459
  const unknownAddonIds = [];
4593
5460
  const substitutions = [];
5461
+ const clusterRefusals = [];
4594
5462
  for (const s of steps) {
4595
5463
  let def;
4596
5464
  try {
@@ -4599,6 +5467,35 @@ function resolveInputSteps(steps, format, engine) {
4599
5467
  unknownAddonIds.push(s.addonId);
4600
5468
  continue;
4601
5469
  }
5470
+ if (clusterModels !== void 0) {
5471
+ const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
5472
+ if (verdict.kind !== "node-scoped") {
5473
+ if (verdict.kind === "refused") clusterRefusals.push({
5474
+ stepId: s.addonId,
5475
+ modelId: verdict.modelId,
5476
+ format: verdict.format,
5477
+ formatsShipped: verdict.formatsShipped
5478
+ });
5479
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
5480
+ if (childResult) {
5481
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
5482
+ substitutions.push(...childResult.diagnostics.substitutions);
5483
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
5484
+ }
5485
+ resolvedSteps.push({
5486
+ addonId: s.addonId,
5487
+ addonName: def.name,
5488
+ slot: def.slot,
5489
+ inputClasses: def.inputClasses ?? [],
5490
+ outputClasses: def.outputClasses,
5491
+ enabled: s.enabled,
5492
+ modelId: verdict.modelId,
5493
+ children: childResult ? childResult.steps : [],
5494
+ ...s.settings ? { settings: { ...s.settings } } : {}
5495
+ });
5496
+ continue;
5497
+ }
5498
+ }
4602
5499
  const chosenModelId = s.modelId ?? null;
4603
5500
  const runningModelId = chosenModelId === null && engine !== void 0 && s.addonId === "object-detection" ? resolveDefaultDetectionModel(engine.backend, engine.device, format) : require_event_loop_stall_monitor.resolveModelForFormat(s.addonId, chosenModelId, format);
4604
5501
  if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
@@ -4607,10 +5504,11 @@ function resolveInputSteps(steps, format, engine) {
4607
5504
  running: runningModelId,
4608
5505
  format
4609
5506
  });
4610
- const childResult = s.children ? resolveInputSteps(s.children, format, engine) : null;
5507
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
4611
5508
  if (childResult) {
4612
5509
  unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4613
5510
  substitutions.push(...childResult.diagnostics.substitutions);
5511
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
4614
5512
  }
4615
5513
  resolvedSteps.push({
4616
5514
  addonId: s.addonId,
@@ -4628,7 +5526,8 @@ function resolveInputSteps(steps, format, engine) {
4628
5526
  steps: resolvedSteps,
4629
5527
  diagnostics: {
4630
5528
  unknownAddonIds,
4631
- substitutions
5529
+ substitutions,
5530
+ clusterRefusals
4632
5531
  }
4633
5532
  };
4634
5533
  }
@@ -4671,6 +5570,94 @@ function applyZoneRuleGate(result, zones, rules) {
4671
5570
  };
4672
5571
  }
4673
5572
  //#endregion
5573
+ //#region src/shared/cluster-model-source.ts
5574
+ /**
5575
+ * Where the runner gets the ONE cluster model choice per cluster-scoped step.
5576
+ *
5577
+ * The sibling of `crop-convention-source.ts`, and deliberately built the same
5578
+ * way, because it answers the neighbouring half of the same question: the crop
5579
+ * convention decides WHAT pixels the encoder sees, this decides WHICH encoder
5580
+ * sees them. Both are cluster-wide, both are read by the runner from the
5581
+ * `pipeline-orchestrator` global settings over the hub-routed `addon-settings`
5582
+ * cap, both pass NO `nodeId`.
5583
+ *
5584
+ * ## Deliberately NOT node-scoped, and not on the attach config
5585
+ *
5586
+ * With a `nodeId` the settings cap would project that node's scoped value and
5587
+ * two nodes could embed into one index with two encoders — the original defect,
5588
+ * relocated to the transport. And the attach config is the wrong carrier for
5589
+ * the same reason it was wrong for the crop: eleven builders construct a
5590
+ * `RunnerCameraConfig`, a cluster-wide constant threaded through eleven
5591
+ * builders eventually reaches ten of them, and the cameras attached by the
5592
+ * forgotten one quietly write a foreign feature space into the shared index.
5593
+ *
5594
+ * ## Why the failure mode keeps the last value
5595
+ *
5596
+ * A failed read must never revert to the registry default MID-PASS: that would
5597
+ * silently change what every remaining vector of that dispatch means, which is
5598
+ * the exact class of harm the scope exists to prevent. So a failure keeps the
5599
+ * last known row and says so, loudly, once per failure.
5600
+ */
5601
+ /**
5602
+ * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
5603
+ * convention and the native-frame lease: hub-resident and neutral between the
5604
+ * addons that read it (post-analysis pins it for a re-embed pass, the runner
5605
+ * enforces it live — neither should own a value the other depends on).
5606
+ */
5607
+ var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
5608
+ /** TTL-cached read of the cluster model row. */
5609
+ var ClusterModelSource = class {
5610
+ models = require_dist.DEFAULT_CLUSTER_STEP_MODELS;
5611
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
5612
+ inFlight = null;
5613
+ logger;
5614
+ now;
5615
+ ttlMs;
5616
+ constructor(options) {
5617
+ this.logger = options.logger;
5618
+ this.now = options.now ?? (() => Date.now());
5619
+ this.ttlMs = options.ttlMs ?? 6e4;
5620
+ }
5621
+ /** The row in force right now — the registry defaults until a read lands. */
5622
+ current() {
5623
+ return this.models;
5624
+ }
5625
+ /**
5626
+ * Re-read if the cached row has expired. Concurrent callers share one
5627
+ * in-flight read; a failure keeps the LAST KNOWN row.
5628
+ */
5629
+ async refresh(api) {
5630
+ if (api === void 0) return this.models;
5631
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
5632
+ this.inFlight ??= this.read(api).finally(() => {
5633
+ this.inFlight = null;
5634
+ });
5635
+ await this.inFlight;
5636
+ return this.models;
5637
+ }
5638
+ async read(api) {
5639
+ try {
5640
+ const next = require_dist.pickClusterStepModels(await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID }));
5641
+ this.lastReadAtMs = this.now();
5642
+ const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
5643
+ if (changed.length > 0) {
5644
+ this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
5645
+ stepId,
5646
+ from: this.models[stepId],
5647
+ to: next[stepId]
5648
+ })) } });
5649
+ this.models = next;
5650
+ }
5651
+ } catch (err) {
5652
+ this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
5653
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
5654
+ inUse: { ...this.models },
5655
+ error: err instanceof Error ? err.message : String(err)
5656
+ } });
5657
+ }
5658
+ }
5659
+ };
5660
+ //#endregion
4674
5661
  //#region src/detection-pipeline/provider.ts
4675
5662
  /**
4676
5663
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4936,6 +5923,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4936
5923
  */
4937
5924
  customModelsCache = null;
4938
5925
  /**
5926
+ * SYNC custom-model lookup handed to every live {@link EngineFactory} so
5927
+ * `buildPoolModelConfigForStep` can resolve an operator-registered model at
5928
+ * pool-load time. Reads the {@link customModelsCache} snapshot — every load
5929
+ * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
5930
+ * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
5931
+ * the snapshot is fresh when the pool asks. Without this seam a selected
5932
+ * custom model failed the WHOLE pool applyConfig with
5933
+ * `Model "X" not found in step "Y" catalog`.
5934
+ */
5935
+ customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
5936
+ /**
4939
5937
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4940
5938
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4941
5939
  * synchronously per frame so detections inside an `exclude` zone
@@ -4945,6 +5943,19 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4945
5943
  deviceProxies = /* @__PURE__ */ new Map();
4946
5944
  proxyUnsubs = /* @__PURE__ */ new Map();
4947
5945
  /**
5946
+ * The cluster-wide model row for `modelScope: 'cluster'` steps
5947
+ * (`face-embedding`, `clip-embedding`) — read from the orchestrator's global
5948
+ * settings, cluster-scoped, TTL-cached. See `shared/cluster-model-source.ts`
5949
+ * for why it is not carried on the attach config and never node-scoped.
5950
+ *
5951
+ * Refreshed on the ASYNC boundary (`runPipeline`) and read SYNCHRONOUSLY by
5952
+ * `inputStepsToPipelineSteps`, which runs per dispatch and must not await.
5953
+ */
5954
+ clusterModels = new ClusterModelSource({ logger: {
5955
+ info: (message, extras) => this.log.info(message, extras),
5956
+ warn: (message, extras) => this.log.warn(message, extras)
5957
+ } });
5958
+ /**
4948
5959
  * Last logged "step configured" signature — used to dedupe the
4949
5960
  * per-step debug trail so it only fires on actual config CHANGE
4950
5961
  * (not on every frame). `runPipeline` is called once per decoded
@@ -6031,6 +7042,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6031
7042
  };
6032
7043
  }
6033
7044
  async runPipeline(input, onProgress) {
7045
+ await this.clusterModels.refresh(this.addonCtx?.api);
6034
7046
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
6035
7047
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6036
7048
  const emit = (message, extra) => {
@@ -6215,7 +7227,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6215
7227
  modelsDir: this.modelsDir,
6216
7228
  logger: this.log.child("engine-override"),
6217
7229
  pythonPath: this.executorOptions.pythonPath ?? "",
6218
- provisioning: this.executorOptions.provisioning
7230
+ provisioning: this.executorOptions.provisioning,
7231
+ resolveCustomModel: this.customModelResolver
6219
7232
  });
6220
7233
  const initPromise = newFactory.initialize([]);
6221
7234
  this.overrideCache = {
@@ -6259,7 +7272,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6259
7272
  this.executor = executor;
6260
7273
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
6261
7274
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6262
- const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
7275
+ const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
6263
7276
  if (needed.length > 0) {
6264
7277
  this.log.info("Benchmark: models to load", { meta: {
6265
7278
  count: needed.length,
@@ -6395,7 +7408,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6395
7408
  modelsDir: this.modelsDir,
6396
7409
  logger: this.log.child("engine-override"),
6397
7410
  pythonPath: this.executorOptions.pythonPath ?? "",
6398
- provisioning: this.executorOptions.provisioning
7411
+ provisioning: this.executorOptions.provisioning,
7412
+ resolveCustomModel: this.customModelResolver
6399
7413
  });
6400
7414
  const initPromise = newFactory.initialize([]);
6401
7415
  this.overrideCache = {
@@ -6489,8 +7503,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6489
7503
  inputStepsToPipelineSteps(steps, format = this.currentEngine.format, engine = {
6490
7504
  backend: this.currentEngine.backend,
6491
7505
  device: this.currentEngine.device ?? null
6492
- }) {
6493
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine);
7506
+ }, clusterRefusalsOut) {
7507
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current());
7508
+ if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
6494
7509
  for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
6495
7510
  for (const sub of diagnostics.substitutions) this.logLiveDispatchIssueOnce(`${sub.addonId}|${sub.chosen}|${sub.running}|${sub.format}`, () => this.log.info("Live pipeline step model substituted for engine format (node-local)", { meta: {
6496
7511
  step: sub.addonId,
@@ -6500,6 +7515,34 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6500
7515
  } }));
6501
7516
  return resolvedSteps;
6502
7517
  }
7518
+ /**
7519
+ * Announce the cluster-scope refusals of the resolution that is actually
7520
+ * being RETURNED — never of a speculative one.
7521
+ *
7522
+ * A refusal here means: this dispatch will run `stepId` with the cluster's
7523
+ * chosen model on an engine that ships no build of it, and NOTHING was
7524
+ * substituted in its place. `assertStepsRunnable` has already had its say
7525
+ * (it throws when the catalog ships nothing at all for the format); what
7526
+ * this line adds is the case the throw cannot see — the catalog has a build,
7527
+ * just not of the model the cluster chose — which without this line is the
7528
+ * quietest possible way to stop producing vectors.
7529
+ *
7530
+ * Deduped per (step, model, engine): a fleet of cameras on one incompatible
7531
+ * node produces one line, not one per track.
7532
+ */
7533
+ logClusterRefusals(refusals, deviceId, engineLabel) {
7534
+ for (const refusal of refusals) this.logLiveDispatchIssueOnce(`cluster-refused:${refusal.stepId}|${refusal.modelId}|${engineLabel}`, () => this.log.warn("cluster-scoped step: this engine ships no build of the CLUSTER model and nothing was substituted — a substitute encoder would put a second feature space in a shared vector index", {
7535
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7536
+ meta: {
7537
+ step: refusal.stepId,
7538
+ clusterModelId: refusal.modelId,
7539
+ format: refusal.format,
7540
+ formatsShipped: [...refusal.formatsShipped],
7541
+ engine: engineLabel,
7542
+ remedy: `choose a cluster model with a ${refusal.format} build, or take this engine out of the rotation for ${refusal.stepId}`
7543
+ }
7544
+ }));
7545
+ }
6503
7546
  /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6504
7547
  logLiveDispatchIssueOnce(key, fn) {
6505
7548
  if (this.loggedLiveDispatchIssues.has(key)) return;
@@ -6561,8 +7604,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6561
7604
  const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6562
7605
  const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6563
7606
  if (deviceKey === void 0 || deviceEngine === void 0) {
6564
- const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7607
+ const refusals = [];
7608
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, refusals);
6565
7609
  this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
7610
+ this.logClusterRefusals(refusals, deviceId, nodeEngineLabel);
6566
7611
  return {
6567
7612
  deviceKey: void 0,
6568
7613
  steps
@@ -6573,14 +7618,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6573
7618
  backend: deviceEngine.backend,
6574
7619
  device: deviceEngine.device ?? null
6575
7620
  };
6576
- const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
7621
+ const deviceRefusals = [];
7622
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
6577
7623
  const framePlane = args.plane === "frame";
6578
7624
  const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6579
7625
  if (zeroBuild.length === 0) {
6580
- if (!framePlane) return {
6581
- deviceKey,
6582
- steps
6583
- };
7626
+ if (!framePlane) {
7627
+ this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
7628
+ return {
7629
+ deviceKey,
7630
+ steps
7631
+ };
7632
+ }
6584
7633
  const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6585
7634
  if (pruneResult.prunedAddonIds.length > 0) {
6586
7635
  const prunedList = pruneResult.prunedAddonIds.join(",");
@@ -6593,6 +7642,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6593
7642
  }
6594
7643
  }));
6595
7644
  }
7645
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
6596
7646
  return {
6597
7647
  deviceKey,
6598
7648
  steps: pruneResult.steps
@@ -6608,8 +7658,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6608
7658
  fallbackEngine: nodeEngineLabel
6609
7659
  }
6610
7660
  }));
6611
- const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7661
+ const fallbackRefusals = [];
7662
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, fallbackRefusals);
6612
7663
  this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
7664
+ this.logClusterRefusals(fallbackRefusals, deviceId, nodeEngineLabel);
6613
7665
  return {
6614
7666
  deviceKey: void 0,
6615
7667
  steps: fallbackSteps
@@ -6661,7 +7713,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6661
7713
  const needed = [];
6662
7714
  for (const step of allEnabled) {
6663
7715
  if (!step.enabled) continue;
6664
- if (factory?.isLoadedWithModel(step.addonId, step.modelId)) continue;
7716
+ if (factory !== null && !factory.needsPoolUpdate(step)) continue;
6665
7717
  needed.push(step);
6666
7718
  }
6667
7719
  if (needed.length === 0) return;
@@ -6670,6 +7722,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6670
7722
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6671
7723
  if (modelEntry && !require_addon_utils.isModelDownloaded(this.modelsDir, modelEntry, format)) {
6672
7724
  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`);
7725
+ if (modelEntry.formats[format]?.url.startsWith("camstack-local://") === true) throw new Error(`Custom model "${modelEntry.id}" (${format}) is not present in this node's models directory — distribute it to this node from Model Studio before selecting it here`);
6673
7726
  this.log.info("Downloading model for step", { meta: {
6674
7727
  modelId: step.modelId,
6675
7728
  format,
@@ -6819,7 +7872,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6819
7872
  try {
6820
7873
  const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6821
7874
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
6822
- if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7875
+ if (enabledSteps.filter((s) => factory.needsPoolUpdate(s)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
6823
7876
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
6824
7877
  this.log.info("runPipelineBatch path decision", { meta: {
6825
7878
  phase: "batch",
@@ -7075,7 +8128,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7075
8128
  modelsDir: this.modelsDir,
7076
8129
  logger: this.log.child("engine"),
7077
8130
  pythonPath: this.executorOptions.pythonPath ?? "",
7078
- provisioning: this.executorOptions.provisioning
8131
+ provisioning: this.executorOptions.provisioning,
8132
+ resolveCustomModel: this.customModelResolver
7079
8133
  });
7080
8134
  try {
7081
8135
  await factory.initialize([]);
@@ -7155,7 +8209,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7155
8209
  modelsDir: this.modelsDir,
7156
8210
  logger: this.log.child(`engine:${deviceKey}`),
7157
8211
  pythonPath: this.executorOptions.pythonPath ?? "",
7158
- provisioning: this.executorOptions.provisioning
8212
+ provisioning: this.executorOptions.provisioning,
8213
+ resolveCustomModel: this.customModelResolver
7159
8214
  });
7160
8215
  try {
7161
8216
  await factory.initialize([]);
@@ -7526,7 +8581,13 @@ function buildSchemaSlots(formats, modelsDir, customByStep) {
7526
8581
  if (availableModels.length === 0) continue;
7527
8582
  const slot = step.slot;
7528
8583
  if (!slotMap.has(slot)) slotMap.set(slot, []);
7529
- const configSchema = pipelineStep.getConfigSchema();
8584
+ const customIds = new Set((customByStep?.get(step.id) ?? []).map((m) => m.id));
8585
+ const configSchema = annotateConfigSchemaForModels({
8586
+ def: step,
8587
+ configSchema: pipelineStep.getConfigSchema(),
8588
+ models: availableModels,
8589
+ customModelIds: customIds
8590
+ });
7530
8591
  slotMap.get(slot).push({
7531
8592
  id: step.id,
7532
8593
  name: step.name,
@@ -7903,7 +8964,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7903
8964
  type: "select",
7904
8965
  key: "batchMode",
7905
8966
  label: "Batch dispatch mode",
7906
- description: "How the Python pool dispatches predicts. `none` = one call per frame. `window` = per-model accumulator coalesces concurrent calls within Window (ms). Auto uses the matrix-sweep default for the current backend. Applies to THIS node only.",
8967
+ description: "How the Python pool GROUPS predict requests — it never changes a model's compiled batch SHAPE, which is derived from (engine, device) at load and is not configurable (D202). `none` = one call per frame. `window` = per-model accumulator coalesces concurrent calls within Window (ms). Auto uses the matrix-sweep default for the current backend. Applies to THIS node only.",
7907
8968
  options: [
7908
8969
  {
7909
8970
  value: "",
@@ -7947,7 +9008,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7947
9008
  type: "slider",
7948
9009
  key: "maxBatchSize",
7949
9010
  label: "Max batch size",
7950
- description: "Cap on items per batched predict call. Auto = backend default. Applies to THIS node only.",
9011
+ description: "Cap on how many requests are GROUPED into one batched predict call. Not the model's compiled batch shape — that is derived from (engine, device) at load and is not configurable (D202); a stacked tensor is only ever built for an export that declares a flexible batch axis itself. Auto = backend default. Applies to THIS node only.",
7951
9012
  min: 1,
7952
9013
  max: 16,
7953
9014
  step: 1,