@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
@@ -1,8 +1,8 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { At as array, Bt as EventCategory, H as enumerateInferenceDevices, It as object, Ot as sleep, P as defaultDeviceFor$1, Rt as string, T as YAMNET_TO_MACRO, Tt as parseJsonUnknown, U as evaluateZoneRules, Z as pipelineExecutorCapability, bt as hydrateSchema, d as DEVICE_BACKEND_TO_FORMAT, ht as BaseAddon, it as runtimeDevices$1, lt as errMsg, nt as resolvePoolMemoryPolicy, st as supportedRuntimes$1, t as APPLE_SA_TO_MACRO, wt as nodePin, y as PoolMemoryWatchdog, yt as createEvent, z as detectionPipelineCapability, zt as union } from "../dist-Ccmt3fGJ.mjs";
3
- import { a as getDefaultModelForFormat, c as getStepDefinition, i as ALL_STEPS, l as resolveModelForFormat, n as startEventLoopStallMonitor, o as getDefaultModelForFormatFromDef, r as ALL_PIPELINE_STEPS, s as getStep, u as landmarkPrecisionVerdict } from "../event-loop-stall-monitor-OJrOMeuu.mjs";
2
+ import { $ as pipelineExecutorCapability, B as detectionPipelineCapability, Ct as hydrateSchema, Dt as nodePin, E as YAMNET_TO_MACRO, F as defaultDeviceFor$1, Ht as union, Nt as array, Ot as parseJsonUnknown, St as createEvent, U as enumerateInferenceDevices, Ut as EventCategory, Vt as string, W as evaluateZoneRules, X as pickClusterStepModels, at as resolvePoolMemoryPolicy, b as PoolMemoryWatchdog, c as DEFAULT_CLUSTER_STEP_MODELS, f as DEVICE_BACKEND_TO_FORMAT, ft as errMsg, jt as sleep, rt as resolveClusterStepModelId, st as runtimeDevices$1, t as APPLE_SA_TO_MACRO, ut as supportedRuntimes$1, vt as BaseAddon, zt as object } from "../dist-BSWr9Qg8.mjs";
3
+ import { a as getDefaultModelForFormat, c as getStepDefinition, i as ALL_STEPS, l as resolveModelForFormat, n as startEventLoopStallMonitor, o as getDefaultModelForFormatFromDef, r as ALL_PIPELINE_STEPS, s as getStep, u as landmarkPrecisionVerdict } from "../event-loop-stall-monitor-BeQHivuY.mjs";
4
4
  import { t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
5
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-D0zDmXLI.mjs";
5
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-DFL51gCx.mjs";
6
6
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../addon-utils-A2S9D7pu.mjs";
7
7
  import sharp from "sharp";
8
8
  import { spawn } from "node:child_process";
@@ -84,6 +84,11 @@ function probedToHardwareInfo(hw) {
84
84
  * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
85
85
  * still spelled out per row so a reader never has to guess which default a
86
86
  * missing key would have hit.
87
+ *
88
+ * `batchMode` / `maxBatchSize` are REQUEST GROUPING, not shape. Whether a graph
89
+ * may keep a dynamic batch axis is a hardware fact derived at load from the
90
+ * (engine, device) pair — `_static_batch_required` in `inference_pool.py`, D202 —
91
+ * and appears nowhere in this registry, in the catalog, or in any operator knob.
87
92
  */
88
93
  var RUNTIME_DETAIL = {
89
94
  onnx: {
@@ -1064,6 +1069,25 @@ var SharedInferencePool = class {
1064
1069
  })));
1065
1070
  for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to unload model at index ${index}: ${resp.error ?? "unknown"}`);
1066
1071
  }
1072
+ /**
1073
+ * Retune a LOADED slot's decode-scoped settings without touching its
1074
+ * inference session — the narrow counterpart of `replaceModel`.
1075
+ *
1076
+ * `replace` is `unload` + `load`: it re-instantiates the session (~1.4s on
1077
+ * coreml, and an OpenVINO recompile) and stalls every live camera routed to
1078
+ * this pool. An operator moving the NMS slider must not cost that, and must
1079
+ * not touch any other slot: the Python side applies the patch to `index`
1080
+ * only, and refuses any key outside its reconfigurable allow-list rather
1081
+ * than accepting a `path` change that would silently not reload.
1082
+ */
1083
+ async reconfigureModel(index, settings) {
1084
+ const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1085
+ cmd: "reconfigure",
1086
+ index,
1087
+ config: { nmsIouThreshold: settings.nmsIouThreshold }
1088
+ })));
1089
+ for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to reconfigure model at index ${index}: ${resp.error ?? "unknown"}`);
1090
+ }
1067
1091
  async replaceModel(index, config) {
1068
1092
  const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1069
1093
  cmd: "replace",
@@ -1203,6 +1227,7 @@ function serializeModelConfig(config) {
1203
1227
  if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
1204
1228
  if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
1205
1229
  if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
1230
+ if (config.minCharProbability !== void 0) result["minCharProbability"] = config.minCharProbability;
1206
1231
  if (config.numClasses) result["numClasses"] = config.numClasses;
1207
1232
  if (config.strides) result["strides"] = config.strides;
1208
1233
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1290,10 +1315,188 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1290
1315
  };
1291
1316
  }
1292
1317
  //#endregion
1318
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1319
+ function resolveEffectivePostprocessor(definition, modelId) {
1320
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1321
+ }
1322
+ /**
1323
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1324
+ * required for CUSTOM (operator-registered) models, which are NOT in
1325
+ * `definition.models` and would otherwise silently fall back to the step
1326
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1327
+ * error anywhere, just zero detections).
1328
+ */
1329
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1330
+ return entry?.postprocessor ?? definition.postprocessor;
1331
+ }
1332
+ //#endregion
1333
+ //#region src/detection-pipeline/registry/model-knob-applicability.ts
1334
+ /** Decodes that run NMS in the pipeline (and hence consult `nmsIouThreshold`).
1335
+ * Mirrors the Python postprocessors that read the knob — yolo.py,
1336
+ * yolo_seg.py, rfdetr.py, scrfd.py. */
1337
+ var NMS_CONSUMING_POSTPROCESSORS = new Set([
1338
+ "yolo",
1339
+ "yolo-seg",
1340
+ "rfdetr",
1341
+ "scrfd"
1342
+ ]);
1343
+ /**
1344
+ * Does this decode consult `nmsIouThreshold`?
1345
+ *
1346
+ * ONE predicate for the whole system: the UI disables the slider through
1347
+ * {@link annotateConfigSchemaForModels}, and the runtime refuses to forward a
1348
+ * value through `resolvePoolDecodeSettings` — both off this set. Two copies of
1349
+ * the matrix would let the UI promise an effect the pool does not deliver,
1350
+ * which is the failure D214 exists to remove.
1351
+ */
1352
+ function postprocessorConsumesNms(postprocessor) {
1353
+ return NMS_CONSUMING_POSTPROCESSORS.has(postprocessor);
1354
+ }
1355
+ var MIN_CONFIDENCE_MACRO_PREFIX = "minConfidence";
1356
+ /** `minConfidencePerson` → `person`; bare `minConfidence` → null (generic). */
1357
+ function macroOfMinConfidenceKey(key) {
1358
+ if (!key.startsWith(MIN_CONFIDENCE_MACRO_PREFIX)) return null;
1359
+ const rest = key.slice(13);
1360
+ if (rest.length === 0) return null;
1361
+ return rest.charAt(0).toLowerCase() + rest.slice(1);
1362
+ }
1363
+ /**
1364
+ * The macro classes a model's OWN labelmap can reach through the step's
1365
+ * classMap. `undefined` ⇒ the model declares no labels (catalog default
1366
+ * labelmap) — every macro of the step is reachable, nothing to annotate.
1367
+ */
1368
+ function reachableMacros(def, entry) {
1369
+ if (entry.labels.length === 0) return void 0;
1370
+ const map = def.classMap;
1371
+ if (!map) return new Set(entry.labels.map((l) => l.id));
1372
+ if (map.preserveOriginal) return void 0;
1373
+ const macros = /* @__PURE__ */ new Set();
1374
+ for (const label of entry.labels) {
1375
+ const mapped = map.mapping[label.id];
1376
+ if (mapped !== void 0) macros.add(mapped);
1377
+ }
1378
+ return macros;
1379
+ }
1380
+ /**
1381
+ * The labels a custom model declares that the step's dropping classMap
1382
+ * (`preserveOriginal: false`) removes UPSTREAM of every operator setting.
1383
+ * `null` when the model declares no labels, the step has no classMap, the map
1384
+ * preserves originals, or nothing is dropped. The engine factory logs this at
1385
+ * pool-config time — a dropped class must never be silent (CLAUDE.md logging
1386
+ * rule; the discard trail alone answers "what", not "why zero forever").
1387
+ */
1388
+ function collectClassMapDropWarning(def, entry) {
1389
+ if (entry.labels.length === 0) return null;
1390
+ const map = def.classMap;
1391
+ if (!map || map.preserveOriginal) return null;
1392
+ const unmapped = entry.labels.filter((l) => map.mapping[l.id] === void 0).map((l) => l.id);
1393
+ if (unmapped.length === 0) return null;
1394
+ return {
1395
+ unmappedLabels: unmapped,
1396
+ mappedCount: entry.labels.length - unmapped.length
1397
+ };
1398
+ }
1399
+ function toApplicability(acc) {
1400
+ const hasInapplicable = Object.keys(acc.inapplicable).length > 0;
1401
+ const hasOptions = Object.keys(acc.optionsByModel).length > 0;
1402
+ if (!hasInapplicable && !hasOptions) return void 0;
1403
+ return {
1404
+ ...hasInapplicable ? { inapplicable: acc.inapplicable } : {},
1405
+ ...hasOptions ? { optionsByModel: acc.optionsByModel } : {}
1406
+ };
1407
+ }
1408
+ function isAnnotatable(field) {
1409
+ return field.type === "slider" || field.type === "number" || field.type === "multiselect" || field.type === "select" || field.type === "boolean";
1410
+ }
1411
+ function annotateField(field, inputs) {
1412
+ if (!isAnnotatable(field)) return field;
1413
+ const { def, models, customModelIds } = inputs;
1414
+ const acc = {
1415
+ inapplicable: {},
1416
+ optionsByModel: {}
1417
+ };
1418
+ const macroKey = macroOfMinConfidenceKey(field.key);
1419
+ for (const entry of models) {
1420
+ const postprocessor = resolveEffectivePostprocessorForEntry(def, entry);
1421
+ if (field.key === "nmsIouThreshold" && !postprocessorConsumesNms(postprocessor)) {
1422
+ acc.inapplicable[entry.id] = `NMS is fused inside this model's "${postprocessor}" export — the pipeline IoU threshold is not consulted.`;
1423
+ continue;
1424
+ }
1425
+ if (field.key === "rectify" && postprocessor !== "ctc") {
1426
+ acc.inapplicable[entry.id] = `The "${postprocessor}" reader consumes the raw plate box — its crop policy never deskews, so this switch has no effect.`;
1427
+ continue;
1428
+ }
1429
+ if (macroKey !== null) {
1430
+ const macros = reachableMacros(def, entry);
1431
+ 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.`;
1432
+ continue;
1433
+ }
1434
+ if (field.key === "enabledMacroClasses" && field.type === "multiselect") {
1435
+ const macros = reachableMacros(def, entry);
1436
+ if (macros === void 0) continue;
1437
+ const reachable = field.options.filter((o) => macros.has(o.value));
1438
+ 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.";
1439
+ else if (reachable.length < field.options.length) acc.optionsByModel[entry.id] = reachable.map((o) => ({
1440
+ value: o.value,
1441
+ label: o.label
1442
+ }));
1443
+ continue;
1444
+ }
1445
+ if (field.key === "enabledClasses" && field.type === "multiselect" && customModelIds.has(entry.id) && entry.labels.length > 0) {
1446
+ acc.optionsByModel[entry.id] = entry.labels.map((l) => ({
1447
+ value: l.id,
1448
+ label: l.name
1449
+ }));
1450
+ continue;
1451
+ }
1452
+ }
1453
+ const applicability = toApplicability(acc);
1454
+ if (applicability === void 0) return field;
1455
+ return {
1456
+ ...field,
1457
+ modelApplicability: applicability
1458
+ };
1459
+ }
1460
+ /**
1461
+ * Decorate a step's config schema with per-model applicability, computed from
1462
+ * the same model union the picker offers. Fields with nothing to say are
1463
+ * returned untouched (identity), so a static-only step with fully-consuming
1464
+ * models produces a byte-identical schema.
1465
+ */
1466
+ function annotateConfigSchemaForModels(inputs) {
1467
+ return inputs.configSchema.map((field) => annotateField(field, inputs));
1468
+ }
1469
+ /**
1470
+ * A persisted settings blob is external data — it survives schema changes,
1471
+ * hand edits and migrations. Accept only a finite IoU in the open (0,1)
1472
+ * interval; anything else falls back rather than reaching the decode, where a
1473
+ * 0 would suppress every overlapping box and a 1 would suppress none.
1474
+ */
1475
+ function readIouThreshold(value) {
1476
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1477
+ if (value <= 0 || value >= 1) return void 0;
1478
+ return value;
1479
+ }
1480
+ /** The effective decode-scoped values for a `(step, model)` pair. */
1481
+ function resolvePoolDecodeSettings(inputs) {
1482
+ const fallback = readIouThreshold(inputs.fallbackNmsIouThreshold) ?? .45;
1483
+ if (!postprocessorConsumesNms(resolveEffectivePostprocessorForEntry(inputs.def, inputs.modelEntry))) return { nmsIouThreshold: fallback };
1484
+ return { nmsIouThreshold: readIouThreshold(inputs.settings?.["nmsIouThreshold"]) ?? fallback };
1485
+ }
1486
+ /** What a built pool config actually carries — the state the slot is IN after
1487
+ * a `load`, so the manager's diff is anchored to the command it sent, never
1488
+ * to a second derivation of the same inputs. */
1489
+ function poolDecodeSettingsOf(config) {
1490
+ return { nmsIouThreshold: config.nmsIouThreshold ?? .45 };
1491
+ }
1492
+ function poolDecodeSettingsEqual(a, b) {
1493
+ return a.nmsIouThreshold === b.nmsIouThreshold;
1494
+ }
1495
+ //#endregion
1293
1496
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1294
1497
  var PipelineModelManager = class {
1295
1498
  pool;
1296
- buildPoolConfig;
1499
+ source;
1297
1500
  /** stepId → modelId → loaded entry. */
1298
1501
  loaded = /* @__PURE__ */ new Map();
1299
1502
  /** stepId → currently-active modelId (the runtime choice). */
@@ -1302,9 +1505,9 @@ var PipelineModelManager = class {
1302
1505
  lruClock = 0;
1303
1506
  log;
1304
1507
  maxModelsPerStep;
1305
- constructor(pool, buildPoolConfig, logger, options) {
1508
+ constructor(pool, source, logger, options) {
1306
1509
  this.pool = pool;
1307
- this.buildPoolConfig = buildPoolConfig;
1510
+ this.source = source;
1308
1511
  this.log = logger;
1309
1512
  this.maxModelsPerStep = options?.maxModelsPerStep ?? 4;
1310
1513
  }
@@ -1328,7 +1531,7 @@ var PipelineModelManager = class {
1328
1531
  } });
1329
1532
  }
1330
1533
  for (const added of diff.added) {
1331
- await this.ensureLoaded(added.addonId, added.modelId);
1534
+ await this.ensureLoaded(added.addonId, added.modelId, added.settings);
1332
1535
  this.activeByStep.set(added.addonId, added.modelId);
1333
1536
  this.log.info("Step loaded", { meta: {
1334
1537
  step: added.addonId,
@@ -1337,7 +1540,7 @@ var PipelineModelManager = class {
1337
1540
  } });
1338
1541
  }
1339
1542
  for (const { step, oldModelId } of diff.modelChanged) {
1340
- await this.ensureLoaded(step.addonId, step.modelId);
1543
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1341
1544
  this.activeByStep.set(step.addonId, step.modelId);
1342
1545
  this.log.info("Active model switched", { meta: {
1343
1546
  step: step.addonId,
@@ -1346,6 +1549,7 @@ var PipelineModelManager = class {
1346
1549
  } });
1347
1550
  await this.enforceLruCap(step.addonId);
1348
1551
  }
1552
+ for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
1349
1553
  }
1350
1554
  /**
1351
1555
  * Get an `IInferenceEngine` handle. Without `modelId`, returns the
@@ -1367,6 +1571,22 @@ var PipelineModelManager = class {
1367
1571
  isLoadedWithModel(stepId, modelId) {
1368
1572
  return this.loaded.get(stepId)?.has(modelId) ?? false;
1369
1573
  }
1574
+ /**
1575
+ * Does the pool NOT yet reflect this step? True when the (step, model) pair
1576
+ * is absent, and also when it is resident with different decode-scoped
1577
+ * settings — the case `isLoadedWithModel` cannot see.
1578
+ *
1579
+ * This is the per-dispatch skip gate: callers that short-circuit on
1580
+ * "already loaded" were the reason a saved threshold never reached a
1581
+ * running pool. Cheap by contract — `resolveDecode` never touches the fs.
1582
+ */
1583
+ needsPoolUpdate(step) {
1584
+ const entry = this.loaded.get(step.addonId)?.get(step.modelId);
1585
+ if (!entry) return true;
1586
+ const desired = this.source.resolveDecode(step.addonId, step.modelId, step.settings);
1587
+ if (desired === null) return true;
1588
+ return !poolDecodeSettingsEqual(entry.decode, desired);
1589
+ }
1370
1590
  /** ModelId currently designated as active for the step. */
1371
1591
  getLoadedModelId(stepId) {
1372
1592
  return this.activeByStep.get(stepId);
@@ -1418,8 +1638,7 @@ var PipelineModelManager = class {
1418
1638
  */
1419
1639
  async loadAdditional(steps) {
1420
1640
  for (const step of steps) {
1421
- if (this.isLoadedWithModel(step.addonId, step.modelId)) continue;
1422
- await this.ensureLoaded(step.addonId, step.modelId);
1641
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1423
1642
  if (!this.activeByStep.has(step.addonId)) this.activeByStep.set(step.addonId, step.modelId);
1424
1643
  }
1425
1644
  }
@@ -1428,7 +1647,7 @@ var PipelineModelManager = class {
1428
1647
  * Honors `maxModelsPerStep` — evicts a non-active LRU variant first
1429
1648
  * when the cap would otherwise be exceeded.
1430
1649
  */
1431
- async ensureLoaded(stepId, modelId) {
1650
+ async ensureLoaded(stepId, modelId, settings) {
1432
1651
  let perStep = this.loaded.get(stepId);
1433
1652
  if (!perStep) {
1434
1653
  perStep = /* @__PURE__ */ new Map();
@@ -1437,6 +1656,7 @@ var PipelineModelManager = class {
1437
1656
  const existing = perStep.get(modelId);
1438
1657
  if (existing) {
1439
1658
  this.touch(existing);
1659
+ await this.reconcileDecode(existing, settings);
1440
1660
  return existing;
1441
1661
  }
1442
1662
  while (perStep.size >= this.maxModelsPerStep) {
@@ -1451,11 +1671,13 @@ var PipelineModelManager = class {
1451
1671
  } });
1452
1672
  }
1453
1673
  const index = this.pool.allocateIndex();
1454
- const config = this.buildPoolConfig(stepId, modelId);
1674
+ const config = this.source.buildConfig(stepId, modelId, settings);
1675
+ const decode = poolDecodeSettingsOf(config);
1455
1676
  this.log.info("Loading step variant", { meta: {
1456
1677
  step: stepId,
1457
1678
  modelId,
1458
- poolIndex: index
1679
+ poolIndex: index,
1680
+ ...decode
1459
1681
  } });
1460
1682
  const { loadMs } = await this.pool.loadModel(index, config);
1461
1683
  this.log.info("Step variant loaded", { meta: {
@@ -1468,11 +1690,71 @@ var PipelineModelManager = class {
1468
1690
  stepId,
1469
1691
  modelId,
1470
1692
  poolIndex: index,
1471
- lruTick: ++this.lruClock
1693
+ lruTick: ++this.lruClock,
1694
+ decode
1472
1695
  };
1473
1696
  perStep.set(modelId, entry);
1474
1697
  return entry;
1475
1698
  }
1699
+ /**
1700
+ * Push a retuned decode setting into ONE already-loaded slot.
1701
+ *
1702
+ * Scope is the point: `reconfigure` names a single pool index, so a slider
1703
+ * moved on `object-detection/yolo26n` never disturbs the face detector, the
1704
+ * classifiers, or the same step's warm benchmark variants — and it never
1705
+ * costs a session reload (see `SharedInferencePool.reconfigureModel`).
1706
+ *
1707
+ * A no-op when nothing moved: this runs on the per-dispatch path, so the
1708
+ * common case must send zero commands.
1709
+ */
1710
+ async reconcileDecode(entry, settings) {
1711
+ const desired = this.source.resolveDecode(entry.stepId, entry.modelId, settings);
1712
+ if (desired === null) return;
1713
+ if (poolDecodeSettingsEqual(entry.decode, desired)) return;
1714
+ const previous = entry.decode;
1715
+ await this.pool.reconfigureModel(entry.poolIndex, desired);
1716
+ entry.decode = desired;
1717
+ this.log.info("Decode settings retuned in place — no model reload", { meta: {
1718
+ step: entry.stepId,
1719
+ modelId: entry.modelId,
1720
+ poolIndex: entry.poolIndex,
1721
+ previousNmsIouThreshold: previous.nmsIouThreshold,
1722
+ ...desired
1723
+ } });
1724
+ this.reportContention(entry, previous, desired);
1725
+ }
1726
+ /** Decode values each (step, model) pair has already run, so a value
1727
+ * RETURNING can be told apart from a value being changed. */
1728
+ decodeHistory = /* @__PURE__ */ new Map();
1729
+ contentionReported = /* @__PURE__ */ new Set();
1730
+ /**
1731
+ * A pool slot is NODE-level; the settings driving it arrive per dispatch, so
1732
+ * two cameras with different per-camera overrides for the same (step, model)
1733
+ * pull the same slot back and forth. That is not expressible — one of them is
1734
+ * always decoding with the other's threshold — and it is invisible from the
1735
+ * outside, so it is reported the first time a pair flip-flops BACK to a value
1736
+ * it already ran. One steady retune (the operator moving a slider) never
1737
+ * trips it; alternating writers do, immediately.
1738
+ */
1739
+ reportContention(entry, previous, desired) {
1740
+ const key = `${entry.stepId}::${entry.modelId}`;
1741
+ let seen = this.decodeHistory.get(key);
1742
+ if (!seen) {
1743
+ seen = new Set([previous.nmsIouThreshold]);
1744
+ this.decodeHistory.set(key, seen);
1745
+ }
1746
+ const returning = seen.has(desired.nmsIouThreshold);
1747
+ seen.add(desired.nmsIouThreshold);
1748
+ if (!returning || this.contentionReported.has(key)) return;
1749
+ this.contentionReported.add(key);
1750
+ 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: {
1751
+ step: entry.stepId,
1752
+ modelId: entry.modelId,
1753
+ poolIndex: entry.poolIndex,
1754
+ valuesSeen: [...seen].sort((a, b) => a - b),
1755
+ hint: "a per-camera override of a decode-scoped knob cannot be honoured on a shared pool — keep this knob at the node level"
1756
+ } });
1757
+ }
1476
1758
  async unloadEntry(entry) {
1477
1759
  await this.pool.unloadModel(entry.poolIndex);
1478
1760
  const perStep = this.loaded.get(entry.stepId);
@@ -1538,7 +1820,10 @@ var PipelineModelManager = class {
1538
1820
  step: desired,
1539
1821
  oldModelId: activeModelId
1540
1822
  });
1541
- else unchanged.push(activeEntry);
1823
+ else unchanged.push({
1824
+ entry: activeEntry,
1825
+ step: desired
1826
+ });
1542
1827
  }
1543
1828
  for (const [stepId, step] of desiredMap) if (!this.activeByStep.has(stepId)) added.push(step);
1544
1829
  return {
@@ -1816,6 +2101,14 @@ var EngineFactory = class {
1816
2101
  return this.poolManager?.isLoadedWithModel(stepId, modelId) ?? false;
1817
2102
  }
1818
2103
  /**
2104
+ * Does the pool still owe this step something — a load, or a retune of its
2105
+ * decode-scoped settings? The skip gate every dispatch path must use instead
2106
+ * of `isLoadedWithModel`, which cannot see a changed threshold (D214).
2107
+ */
2108
+ needsPoolUpdate(step) {
2109
+ return this.poolManager?.needsPoolUpdate(step) ?? true;
2110
+ }
2111
+ /**
1819
2112
  * List every loaded (stepId, modelId) variant — the pool surfaces each
1820
2113
  * warm slot independently (including bench overrides).
1821
2114
  */
@@ -1951,54 +2244,124 @@ var EngineFactory = class {
1951
2244
  numWorkers,
1952
2245
  ...this.opts.engine.device ? { device: this.opts.engine.device } : {}
1953
2246
  });
1954
- this.poolManager = new PipelineModelManager(this.pool, (stepId, modelId) => this.buildPoolModelConfig(stepId, modelId, poolRuntime), this.log.child("model-mgr"));
2247
+ this.poolManager = new PipelineModelManager(this.pool, {
2248
+ buildConfig: (stepId, modelId, settings) => this.buildPoolModelConfig(stepId, modelId, poolRuntime, settings),
2249
+ resolveDecode: (stepId, modelId, settings) => this.resolveDecode(stepId, modelId, settings)
2250
+ }, this.log.child("model-mgr"));
1955
2251
  await this.pool.initialize([]);
1956
2252
  await this.poolManager.applyConfig(steps);
1957
2253
  }
1958
- buildPoolModelConfig(stepId, modelId, poolRuntime) {
2254
+ /** (step, model) pairs whose classMap-drop warning already logged — the
2255
+ * pool reloads models on every applyConfig; the fact doesn't change. */
2256
+ classMapDropLogged = /* @__PURE__ */ new Set();
2257
+ /**
2258
+ * The fs-free half of {@link PoolModelSource} — the effective decode-scoped
2259
+ * settings for a `(step, model, settings)` triple. Runs on the per-dispatch
2260
+ * path, so it must resolve the catalog entry and nothing more; the label
2261
+ * file, the format build and the model path are load-time concerns.
2262
+ *
2263
+ * `null` when the pair resolves to no model at all — the caller routes that
2264
+ * to `buildConfig`, which throws the message naming the step and the model.
2265
+ */
2266
+ resolveDecode(stepId, modelId, settings) {
1959
2267
  const def = getStepDefinition(stepId);
1960
- const modelEntry = def.models.find((m) => m.id === modelId);
1961
- if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog`);
1962
- const format = RUNTIME_TO_FORMAT[poolRuntime];
1963
- if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
1964
- const formatEntry = modelEntry.formats[format];
1965
- if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
1966
- const urlParts = formatEntry.url.split("/");
1967
- const filename = urlParts[urlParts.length - 1] ?? `${modelId}.${format}`;
1968
- const modelPath = `${this.opts.modelsDir}/${filename}`;
1969
- const inputWidth = modelEntry.inputSize.width;
1970
- const inputHeight = modelEntry.inputSize.height;
1971
- const inputSize = Math.max(inputWidth, inputHeight);
1972
- const inputChannels = modelEntry.inputChannels ?? 3;
1973
- const labels = readModelLabels(this.opts.modelsDir, modelEntry) ?? def.labels;
1974
- return {
1975
- path: modelPath,
1976
- inputSize,
1977
- inputWidth,
1978
- inputHeight,
1979
- inputChannels,
1980
- preprocessMode: modelEntry.preprocessMode ?? "letterbox",
1981
- inputNormalization: modelEntry.inputNormalization,
1982
- outputProbabilities: modelEntry.outputProbabilities,
1983
- postprocessor: modelEntry.postprocessor ?? def.postprocessor,
1984
- confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
1985
- nmsIouThreshold: this.opts.nmsIouThreshold ?? .45,
1986
- labels,
1987
- charset: def.charset,
1988
- plateRegion: def.plateRegion,
1989
- minTextLength: def.minTextLength,
1990
- minTextConfidence: def.minTextConfidence,
1991
- numClasses: labels?.length,
1992
- strides: def.postprocessor === "scrfd" ? [
1993
- 8,
1994
- 16,
1995
- 32
1996
- ] : void 0,
1997
- maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
1998
- device: this.opts.engine.device ?? (poolRuntime === "coreml" ? "all" : void 0)
1999
- };
2268
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? this.opts.resolveCustomModel?.(stepId, modelId);
2269
+ if (!modelEntry) return null;
2270
+ return resolvePoolDecodeSettings({
2271
+ def,
2272
+ modelEntry,
2273
+ settings,
2274
+ fallbackNmsIouThreshold: this.opts.nmsIouThreshold
2275
+ });
2276
+ }
2277
+ buildPoolModelConfig(stepId, modelId, poolRuntime, stepSettings) {
2278
+ return buildPoolModelConfigForStep({
2279
+ stepId,
2280
+ modelId,
2281
+ poolRuntime,
2282
+ modelsDir: this.opts.modelsDir,
2283
+ nmsIouThreshold: this.opts.nmsIouThreshold,
2284
+ stepSettings,
2285
+ device: this.opts.engine.device,
2286
+ resolveCustomModel: this.opts.resolveCustomModel,
2287
+ onClassMapDrop: ({ stepId: sid, modelId: mid, unmappedLabels }) => {
2288
+ const key = `${sid}::${mid}`;
2289
+ if (this.classMapDropLogged.has(key)) return;
2290
+ this.classMapDropLogged.add(key);
2291
+ this.log.warn("Model labels dropped by step classMap — unreachable by any setting", { meta: {
2292
+ step: sid,
2293
+ modelId: mid,
2294
+ unmappedLabels: [...unmappedLabels],
2295
+ hint: "these classes are removed before enabledMacroClasses / minConfidence* run; only labels in the step classMap are tracked"
2296
+ } });
2297
+ }
2298
+ });
2000
2299
  }
2001
2300
  };
2301
+ /**
2302
+ * Build the Python-pool model config for `(stepId, modelId)`.
2303
+ *
2304
+ * Resolution order: static step catalog first, then the injected
2305
+ * custom-model resolver (operator-registered models from the
2306
+ * `custom-model-registry` cap). A model found in NEITHER throws — that step
2307
+ * cannot load.
2308
+ */
2309
+ function buildPoolModelConfigForStep(inputs) {
2310
+ const { stepId, modelId, poolRuntime, modelsDir } = inputs;
2311
+ const def = getStepDefinition(stepId);
2312
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? inputs.resolveCustomModel?.(stepId, modelId);
2313
+ if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2314
+ const format = RUNTIME_TO_FORMAT[poolRuntime];
2315
+ if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2316
+ const dropWarning = collectClassMapDropWarning(def, modelEntry);
2317
+ if (dropWarning !== null) inputs.onClassMapDrop?.({
2318
+ stepId,
2319
+ modelId,
2320
+ unmappedLabels: dropWarning.unmappedLabels
2321
+ });
2322
+ const formatEntry = modelEntry.formats[format];
2323
+ if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2324
+ const urlParts = formatEntry.url.split("/");
2325
+ const modelPath = `${modelsDir}/${urlParts[urlParts.length - 1] ?? `${modelId}.${format}`}`;
2326
+ const inputWidth = modelEntry.inputSize.width;
2327
+ const inputHeight = modelEntry.inputSize.height;
2328
+ const inputSize = Math.max(inputWidth, inputHeight);
2329
+ const inputChannels = modelEntry.inputChannels ?? 3;
2330
+ const entryLabels = modelEntry.labels.length > 0 ? modelEntry.labels.map((l) => l.id) : void 0;
2331
+ const labels = readModelLabels(modelsDir, modelEntry) ?? entryLabels ?? def.labels;
2332
+ return {
2333
+ path: modelPath,
2334
+ inputSize,
2335
+ inputWidth,
2336
+ inputHeight,
2337
+ inputChannels,
2338
+ preprocessMode: modelEntry.preprocessMode ?? "letterbox",
2339
+ inputNormalization: modelEntry.inputNormalization,
2340
+ outputProbabilities: modelEntry.outputProbabilities,
2341
+ postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2342
+ confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2343
+ nmsIouThreshold: resolvePoolDecodeSettings({
2344
+ def,
2345
+ modelEntry,
2346
+ settings: inputs.stepSettings,
2347
+ fallbackNmsIouThreshold: inputs.nmsIouThreshold
2348
+ }).nmsIouThreshold,
2349
+ labels,
2350
+ charset: def.charset,
2351
+ plateRegion: def.plateRegion,
2352
+ minTextLength: def.minTextLength,
2353
+ minTextConfidence: def.minTextConfidence,
2354
+ minCharProbability: def.minCharProbability,
2355
+ numClasses: labels?.length,
2356
+ strides: def.postprocessor === "scrfd" ? [
2357
+ 8,
2358
+ 16,
2359
+ 32
2360
+ ] : void 0,
2361
+ maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2362
+ device: inputs.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2363
+ };
2364
+ }
2002
2365
  //#endregion
2003
2366
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
2004
2367
  var IdlePoolReaper = class {
@@ -3263,7 +3626,7 @@ function estimateSkewAngle(gray, opts = {}) {
3263
3626
  score: bestScore
3264
3627
  };
3265
3628
  }
3266
- function toSharpChannels(channels) {
3629
+ function toSharpChannels$1(channels) {
3267
3630
  switch (channels) {
3268
3631
  case 1: return 1;
3269
3632
  case 2: return 2;
@@ -3344,7 +3707,7 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3344
3707
  jpeg: await sharp(Buffer.from(band.data), { raw: {
3345
3708
  width: band.width,
3346
3709
  height: band.height,
3347
- channels: toSharpChannels(band.channels)
3710
+ channels: toSharpChannels$1(band.channels)
3348
3711
  } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
3349
3712
  width: band.width,
3350
3713
  height: band.height,
@@ -3355,6 +3718,413 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3355
3718
  }
3356
3719
  }
3357
3720
  //#endregion
3721
+ //#region src/detection-pipeline/pipeline/plate-ocr-crop-policy.ts
3722
+ /**
3723
+ * Keyed by POSTPROCESSOR, not by model id: the postprocessor is what identifies
3724
+ * the reader family, it is already what the executor branches on, and a new
3725
+ * model of an existing family inherits the right pixels without touching this
3726
+ * table. A new reader family with a new postprocessor has to add a row — which
3727
+ * is the point, since it cannot be given the wrong one by default.
3728
+ */
3729
+ var PLATE_OCR_CROP_POLICIES = {
3730
+ ctc: {
3731
+ marginFraction: 0,
3732
+ rectify: true,
3733
+ minLongSidePx: 85
3734
+ },
3735
+ "plate-slots": {
3736
+ marginFraction: .15,
3737
+ rectify: false,
3738
+ minLongSidePx: 70
3739
+ }
3740
+ };
3741
+ /**
3742
+ * The crop policy for a step child, or `null` when the child is not a plate
3743
+ * reader at all (a face embedder, a classifier — nothing here applies to them).
3744
+ */
3745
+ function resolvePlateOcrCropPolicy(postprocessor) {
3746
+ return PLATE_OCR_CROP_POLICIES[postprocessor] ?? null;
3747
+ }
3748
+ /**
3749
+ * Grow a bbox by `marginFraction` of its own size on each side, clamped to the
3750
+ * frame. Returns the input unchanged at margin 0 or on a degenerate box, so the
3751
+ * un-margined readers keep the exact pixels they had.
3752
+ */
3753
+ function expandBbox(bbox, marginFraction, imageWidth, imageHeight) {
3754
+ const [x1, y1, x2, y2] = bbox;
3755
+ const width = x2 - x1;
3756
+ const height = y2 - y1;
3757
+ if (!(marginFraction > 0) || !(width > 0) || !(height > 0)) return [
3758
+ x1,
3759
+ y1,
3760
+ x2,
3761
+ y2
3762
+ ];
3763
+ return [
3764
+ Math.max(0, x1 - width * marginFraction),
3765
+ Math.max(0, y1 - height * marginFraction),
3766
+ Math.min(imageWidth, x2 + width * marginFraction),
3767
+ Math.min(imageHeight, y2 + height * marginFraction)
3768
+ ];
3769
+ }
3770
+ //#endregion
3771
+ //#region src/detection-pipeline/pipeline/plate-warp.ts
3772
+ /**
3773
+ * Plate-crop WARP — perspective rectification of a licence-plate ROI to a
3774
+ * canonical, aspect-locked text strip, run immediately before the CTC reader.
3775
+ *
3776
+ * WHY this exists on top of {@link ./plate-deskew.rectifyPlateJpeg}
3777
+ * ----------------------------------------------------------------
3778
+ * The detector hands OCR an AXIS-ALIGNED box around a plate that, on an oblique
3779
+ * camera, is a rotated trapezoid inside it. Two things then destroy the read,
3780
+ * and neither is a resolution problem:
3781
+ *
3782
+ * 1. **Aspect.** A German plate is 4.7 : 1. The boxes cam 617 produces measure
3783
+ * 1.3 – 2.0 : 1 — the box is 2-3× too tall because it circumscribes a
3784
+ * tilted rectangle. The recognizer resizes its input to a fixed 64px height
3785
+ * preserving aspect, so a 1.5 : 1 tile becomes ~96px wide: seven glyphs get
3786
+ * ~13px each. The same plate rectified to 4.7 : 1 arrives ~300px wide.
3787
+ * 2. **Tilt.** The 2026-08 deskew estimator searches ±16° and REJECTS an
3788
+ * unbracketed optimum (D186) — correct, and it means it returns 0° on this
3789
+ * camera, whose plates sit at 24-31°. The rotation is real; the projection
3790
+ * profile just cannot measure it from a box that is mostly car.
3791
+ *
3792
+ * The plate itself is the one thing in the ROI that is reliably a large, bright,
3793
+ * convex blob. So: Otsu-threshold the tile, keep the largest bright connected
3794
+ * component, take its four extreme corners, and sample that quadrilateral onto
3795
+ * a fixed 4.7 : 1 × 64px canvas with bilinear interpolation. One pass, no
3796
+ * OpenCV, no new dependency — sharp only decodes and re-encodes, exactly as
3797
+ * `plate-deskew.ts` and `native-child-crop.ts` do.
3798
+ *
3799
+ * MEASURED on 21 ground-truthed cam-617 plate crops (9 distinct vehicles,
3800
+ * 2026-08-20 offline replay against the production `vgg_english_g2` weights and
3801
+ * the production CTC postprocessor):
3802
+ *
3803
+ * | chain | exact | char acc | false on 18 junk ROIs |
3804
+ * | deskew only + 100px gate (live today) | 0/21 | 4.8 % | 0 |
3805
+ * | deskew + text-band + 3× upscale | 0/21 | 16.3 % | 0 |
3806
+ * | THIS warp + seal-tolerant gate, 85px | 4/21 | 42.9 % | 0 |
3807
+ *
3808
+ * The four exact reads are four DIFFERENT vehicles at 91-98px native plate
3809
+ * width — every one of them SKIPPED entirely by the old 100px floor. Upscaling
3810
+ * and contrast work (CLAHE, autocontrast, unsharp, 2/3/4× lanczos) moved char
3811
+ * accuracy by ≤2 points on their own and are NOT included: the geometry was the
3812
+ * whole story.
3813
+ */
3814
+ /**
3815
+ * Canonical strip geometry. 4.7 : 1 is the German plate ratio (520 × 110 mm);
3816
+ * 64px is the recognizer's own input height, so the warp writes exactly the
3817
+ * pixels the model reads and no resampling is wasted.
3818
+ */
3819
+ var PLATE_STRIP_ASPECT = 4.7;
3820
+ /**
3821
+ * Longest side the blob search runs at. The connected-component pass is O(px)
3822
+ * with an explicit stack; capping it keeps a 600px plate ROI at ~1ms.
3823
+ */
3824
+ var BLOB_SEARCH_MAX_DIM = 200;
3825
+ /**
3826
+ * The blob must be a plausible plate: neither a speck nor the whole tile, and
3827
+ * within a wide-but-finite aspect band. Outside these the caller keeps its
3828
+ * un-warped tile — a bad quad is far worse than no quad.
3829
+ */
3830
+ var MIN_BLOB_AREA_FRAC = .04;
3831
+ var MAX_BLOB_AREA_FRAC = .96;
3832
+ var MIN_QUAD_ASPECT = 1.5;
3833
+ var MAX_QUAD_ASPECT = 9;
3834
+ var MIN_QUAD_WIDTH_PX = 12;
3835
+ var MIN_QUAD_HEIGHT_PX = 5;
3836
+ /**
3837
+ * Fraction of the plate width dropped from the LEFT edge before sampling.
3838
+ * The bright blob's left boundary sits on the plate's black frame / the blue EU
3839
+ * band shoulder, and that edge column decodes as a spurious leading glyph
3840
+ * ("IDND7578" for DN-D 7578). Swept end to end over the ground-truth set at
3841
+ * 0 / 2 / 3.5 / 4 / 5 / 6 / 7 %: 4 % is the peak (4/21 exact, 42.9 % char), the
3842
+ * neighbours give 3/21, and 7 % starts eating the first character (0/21).
3843
+ */
3844
+ var LEFT_INSET_FRAC = .04;
3845
+ /** Otsu's between-class-variance threshold of a grayscale image. */
3846
+ function otsuThreshold(gray) {
3847
+ const hist = new Float64Array(256);
3848
+ for (let i = 0; i < gray.data.length; i++) {
3849
+ const v = gray.data[i];
3850
+ hist[v] = (hist[v] ?? 0) + 1;
3851
+ }
3852
+ const total = gray.data.length;
3853
+ if (total === 0) return 128;
3854
+ let sum = 0;
3855
+ for (let v = 0; v < 256; v++) sum += v * hist[v];
3856
+ let wB = 0;
3857
+ let sumB = 0;
3858
+ let best = 0;
3859
+ let bestVar = -1;
3860
+ for (let v = 0; v < 256; v++) {
3861
+ wB += hist[v];
3862
+ if (wB === 0) continue;
3863
+ const wF = total - wB;
3864
+ if (wF === 0) break;
3865
+ sumB += v * hist[v];
3866
+ const mB = sumB / wB;
3867
+ const mF = (sum - sumB) / wF;
3868
+ const between = wB * wF * (mB - mF) * (mB - mF);
3869
+ if (between > bestVar) {
3870
+ bestVar = between;
3871
+ best = v;
3872
+ }
3873
+ }
3874
+ return best;
3875
+ }
3876
+ /** `p`-th percentile (0-100) of a grayscale image, via its 256-bin histogram. */
3877
+ function percentile(gray, p) {
3878
+ const hist = new Int32Array(256);
3879
+ for (let i = 0; i < gray.data.length; i++) hist[gray.data[i]] = (hist[gray.data[i]] ?? 0) + 1;
3880
+ const target = gray.data.length * p / 100;
3881
+ let seen = 0;
3882
+ for (let v = 0; v < 256; v++) {
3883
+ seen += hist[v] ?? 0;
3884
+ if (seen >= target) return v;
3885
+ }
3886
+ return 255;
3887
+ }
3888
+ /**
3889
+ * Mask (1/0, same dimensions) of the LARGEST 4-connected component of pixels
3890
+ * strictly above `threshold`. `null` when nothing clears the threshold.
3891
+ * Iterative flood fill — a plate ROI is small and recursion depth is not.
3892
+ */
3893
+ function largestBrightComponent(gray, threshold) {
3894
+ const { data, width, height } = gray;
3895
+ const n = width * height;
3896
+ const label = new Int32Array(n);
3897
+ const stack = [];
3898
+ let current = 0;
3899
+ let bestLabel = 0;
3900
+ let bestSize = 0;
3901
+ for (let seed = 0; seed < n; seed++) {
3902
+ if (label[seed] !== 0 || data[seed] <= threshold) continue;
3903
+ current += 1;
3904
+ label[seed] = current;
3905
+ stack.length = 0;
3906
+ stack.push(seed);
3907
+ let size = 0;
3908
+ while (stack.length > 0) {
3909
+ const i = stack.pop();
3910
+ size += 1;
3911
+ const x = i % width;
3912
+ const y = (i - x) / width;
3913
+ if (x > 0 && label[i - 1] === 0 && data[i - 1] > threshold) {
3914
+ label[i - 1] = current;
3915
+ stack.push(i - 1);
3916
+ }
3917
+ if (x < width - 1 && label[i + 1] === 0 && data[i + 1] > threshold) {
3918
+ label[i + 1] = current;
3919
+ stack.push(i + 1);
3920
+ }
3921
+ if (y > 0 && label[i - width] === 0 && data[i - width] > threshold) {
3922
+ label[i - width] = current;
3923
+ stack.push(i - width);
3924
+ }
3925
+ if (y < height - 1 && label[i + width] === 0 && data[i + width] > threshold) {
3926
+ label[i + width] = current;
3927
+ stack.push(i + width);
3928
+ }
3929
+ }
3930
+ if (size > bestSize) {
3931
+ bestSize = size;
3932
+ bestLabel = current;
3933
+ }
3934
+ }
3935
+ if (bestLabel === 0) return null;
3936
+ const out = new Uint8Array(n);
3937
+ for (let i = 0; i < n; i++) out[i] = label[i] === bestLabel ? 1 : 0;
3938
+ return out;
3939
+ }
3940
+ /**
3941
+ * Four corners of a mask, taken as the extreme points of `x+y` (top-left /
3942
+ * bottom-right) and `x−y` (top-right / bottom-left). Exact for a rotated
3943
+ * rectangle and stable under the blob noise of a real plate. `null` when the
3944
+ * mask is degenerate.
3945
+ */
3946
+ function quadFromMask(mask, width, height) {
3947
+ let minSum = Number.POSITIVE_INFINITY;
3948
+ let maxSum = Number.NEGATIVE_INFINITY;
3949
+ let minDif = Number.POSITIVE_INFINITY;
3950
+ let maxDif = Number.NEGATIVE_INFINITY;
3951
+ let tl = null;
3952
+ let br = null;
3953
+ let bl = null;
3954
+ let tr = null;
3955
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
3956
+ if (mask[y * width + x] !== 1) continue;
3957
+ const s = x + y;
3958
+ const d = x - y;
3959
+ if (s < minSum) {
3960
+ minSum = s;
3961
+ tl = {
3962
+ x,
3963
+ y
3964
+ };
3965
+ }
3966
+ if (s > maxSum) {
3967
+ maxSum = s;
3968
+ br = {
3969
+ x,
3970
+ y
3971
+ };
3972
+ }
3973
+ if (d < minDif) {
3974
+ minDif = d;
3975
+ bl = {
3976
+ x,
3977
+ y
3978
+ };
3979
+ }
3980
+ if (d > maxDif) {
3981
+ maxDif = d;
3982
+ tr = {
3983
+ x,
3984
+ y
3985
+ };
3986
+ }
3987
+ }
3988
+ if (!tl || !tr || !br || !bl) return null;
3989
+ const w = Math.max(dist(tl, tr), dist(bl, br));
3990
+ const h = Math.max(dist(tl, bl), dist(tr, br));
3991
+ if (w < MIN_QUAD_WIDTH_PX || h < MIN_QUAD_HEIGHT_PX) return null;
3992
+ return {
3993
+ tl,
3994
+ tr,
3995
+ br,
3996
+ bl
3997
+ };
3998
+ }
3999
+ function dist(a, b) {
4000
+ return Math.hypot(a.x - b.x, a.y - b.y);
4001
+ }
4002
+ /**
4003
+ * Bilinear sample of the source quadrilateral onto an `outW × outH` canvas.
4004
+ * The mapping is the standard bilinear-in-(u,v) interpolation of the four
4005
+ * corners — the same family as the rotation warp in `plate-deskew.ts`, and
4006
+ * exact for the affine case. Out-of-bounds samples clamp to the edge pixel.
4007
+ */
4008
+ function samplePerspective(src, quad, outW, outH) {
4009
+ const { data, width: sw, height: sh, channels: ch } = src;
4010
+ const out = new Uint8Array(outW * outH * ch);
4011
+ const maxX = sw - 1;
4012
+ const maxY = sh - 1;
4013
+ for (let oy = 0; oy < outH; oy++) {
4014
+ const v = (oy + .5) / outH;
4015
+ for (let ox = 0; ox < outW; ox++) {
4016
+ const u = (ox + .5) / outW;
4017
+ const topX = quad.tl.x + (quad.tr.x - quad.tl.x) * u;
4018
+ const topY = quad.tl.y + (quad.tr.y - quad.tl.y) * u;
4019
+ const botX = quad.bl.x + (quad.br.x - quad.bl.x) * u;
4020
+ const botY = quad.bl.y + (quad.br.y - quad.bl.y) * u;
4021
+ const sx = Math.min(maxX, Math.max(0, topX + (botX - topX) * v));
4022
+ const sy = Math.min(maxY, Math.max(0, topY + (botY - topY) * v));
4023
+ const x0 = Math.floor(sx);
4024
+ const y0 = Math.floor(sy);
4025
+ const x1 = x0 < maxX ? x0 + 1 : x0;
4026
+ const y1 = y0 < maxY ? y0 + 1 : y0;
4027
+ const fx = sx - x0;
4028
+ const fy = sy - y0;
4029
+ const w00 = (1 - fx) * (1 - fy);
4030
+ const w10 = fx * (1 - fy);
4031
+ const w01 = (1 - fx) * fy;
4032
+ const w11 = fx * fy;
4033
+ const i00 = (y0 * sw + x0) * ch;
4034
+ const i10 = (y0 * sw + x1) * ch;
4035
+ const i01 = (y1 * sw + x0) * ch;
4036
+ const i11 = (y1 * sw + x1) * ch;
4037
+ const o = (oy * outW + ox) * ch;
4038
+ 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;
4039
+ }
4040
+ }
4041
+ return {
4042
+ data: out,
4043
+ width: outW,
4044
+ height: outH,
4045
+ channels: ch
4046
+ };
4047
+ }
4048
+ function toSharpChannels(channels) {
4049
+ switch (channels) {
4050
+ case 1: return 1;
4051
+ case 2: return 2;
4052
+ case 4: return 4;
4053
+ default: return 3;
4054
+ }
4055
+ }
4056
+ /**
4057
+ * Locate the plate blob inside `jpeg` and return it rectified to the canonical
4058
+ * {@link PLATE_STRIP_ASPECT} × {@link PLATE_STRIP_HEIGHT} strip.
4059
+ *
4060
+ * `null` — meaning "caller keeps its own tile" — on: a decode failure, a tile
4061
+ * under 16 × 8, no bright component, a component that is a speck or the whole
4062
+ * tile, or a quad outside the plate aspect band. Never throws, never returns a
4063
+ * strip it is not confident about: the un-warped tile is the floor, not a
4064
+ * guess.
4065
+ */
4066
+ async function warpPlateJpeg(jpeg, opts = {}) {
4067
+ const raw = await sharp(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
4068
+ if (!raw) return null;
4069
+ const { data, info } = raw;
4070
+ if (info.width < 16 || info.height < 8) return null;
4071
+ const rgb = {
4072
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
4073
+ width: info.width,
4074
+ height: info.height,
4075
+ channels: info.channels
4076
+ };
4077
+ const gray = toGrayscale(rgb);
4078
+ const small = downscaleGray(gray, BLOB_SEARCH_MAX_DIM);
4079
+ const scale = small.width / gray.width;
4080
+ const mask = largestBrightComponent(small, Math.max(otsuThreshold(small), percentile(small, 55)));
4081
+ if (!mask) return null;
4082
+ let area = 0;
4083
+ for (let i = 0; i < mask.length; i++) area += mask[i];
4084
+ const frac = area / (small.width * small.height);
4085
+ if (frac < MIN_BLOB_AREA_FRAC || frac > MAX_BLOB_AREA_FRAC) return null;
4086
+ const q = quadFromMask(mask, small.width, small.height);
4087
+ if (!q) return null;
4088
+ const up = (p) => ({
4089
+ x: p.x / scale,
4090
+ y: p.y / scale
4091
+ });
4092
+ const tl = up(q.tl);
4093
+ const tr = up(q.tr);
4094
+ const br = up(q.br);
4095
+ const bl = up(q.bl);
4096
+ const quadW = Math.max(dist(tl, tr), dist(bl, br));
4097
+ const quadH = Math.max(dist(tl, bl), dist(tr, br));
4098
+ if (quadW < MIN_QUAD_WIDTH_PX || quadH < MIN_QUAD_HEIGHT_PX) return null;
4099
+ const ratio = quadW / quadH;
4100
+ if (ratio < MIN_QUAD_ASPECT || ratio > MAX_QUAD_ASPECT) return null;
4101
+ const inset = (a, b) => ({
4102
+ x: a.x + (b.x - a.x) * LEFT_INSET_FRAC,
4103
+ y: a.y + (b.y - a.y) * LEFT_INSET_FRAC
4104
+ });
4105
+ const warped = {
4106
+ tl: inset(tl, tr),
4107
+ tr,
4108
+ br,
4109
+ bl: inset(bl, br)
4110
+ };
4111
+ const outH = 64;
4112
+ const strip = samplePerspective(rgb, warped, Math.round(outH * PLATE_STRIP_ASPECT), outH);
4113
+ try {
4114
+ return {
4115
+ jpeg: await sharp(Buffer.from(strip.data), { raw: {
4116
+ width: strip.width,
4117
+ height: strip.height,
4118
+ channels: toSharpChannels(strip.channels)
4119
+ } }).jpeg({ quality: opts.quality ?? 92 }).toBuffer(),
4120
+ width: strip.width,
4121
+ height: strip.height
4122
+ };
4123
+ } catch {
4124
+ return null;
4125
+ }
4126
+ }
4127
+ //#endregion
3358
4128
  //#region src/detection-pipeline/pipeline/result-assembler.ts
3359
4129
  /** L2 norm of a raw embedding vector. */
3360
4130
  function vectorMagnitude(v) {
@@ -3438,6 +4208,13 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3438
4208
  const passesClass = (c) => (enabledSet === null || enabledSet.has(c.class)) && !rejectSet.has(c.class.toLowerCase());
3439
4209
  const minConfidence = typeof childStep.settings?.["minConfidence"] === "number" ? childStep.settings["minConfidence"] : 0;
3440
4210
  const winner = output.classifications[0];
4211
+ if (winner && !passesClass(winner) && winner.score >= minConfidence) ctx.onClassifierClassDropped?.({
4212
+ stepId: childStep.stepId,
4213
+ modelId: childStep.modelId,
4214
+ droppedClass: winner.class,
4215
+ score: winner.score,
4216
+ reason: enabledSet !== null && !enabledSet.has(winner.class) ? "class-not-enabled" : "reject-class"
4217
+ });
3441
4218
  if (winner && passesClass(winner) && winner.score >= minConfidence) parent.ownLabels.push({
3442
4219
  label: winner.class,
3443
4220
  score: winner.score,
@@ -3677,36 +4454,6 @@ var MIN_FACE_DETECTION_FLOOR_PX = 6;
3677
4454
  * above the downscaled detection resolution while bounding the crop payload.
3678
4455
  */
3679
4456
  var NATIVE_FACE_CROP_MAX_WIDTH = 320;
3680
- /**
3681
- * Minimum LONG side (px) of the ACTUAL crop fed to the plate-OCR (`ctc`) leaf —
3682
- * the plate analogue of the face path's min-face-size gate.
3683
- *
3684
- * The long side, not the short one. A plate's information lives along its text
3685
- * run: the recognizer resizes the tile to a fixed 64px height and reads left to
3686
- * right, so what decides whether a glyph survives is how many horizontal pixels
3687
- * each character got. The previous 20px SHORT-side floor measured the wrong
3688
- * axis and passed essentially everything.
3689
- *
3690
- * The number comes from the 2026-08 audit of 11 cam-617 plate tracks with
3691
- * ground truth. Native long side vs read:
3692
- *
3693
- * 117px → "DNRO3O9" (truth DNRO309 — one glyph out, the only near-miss)
3694
- * 82px → "VONND9592" (truth DNDS597)
3695
- * 77px → "DEPNOQ1" (truth DNP1091)
3696
- * 76px → "VDHPXOE8" (truth DNPK838)
3697
- * 73px → "DAEOR2D8" (truth DNDR298)
3698
- * ≤62px → "I0IAZ", "EUSM9", "0IA3", "DYAGI2", "I0AA", "IC1E"
3699
- *
3700
- * Not one read under 100px resembled its plate, and the gap between 82 and 117
3701
- * is empty — 100 sits in it. Below this floor the OCR call is pure cost: it
3702
- * produces a label that is wrong, and a wrong plate label is worse than none.
3703
- *
3704
- * Measured on the NATIVE crop when the native ROI fetch succeeded (its
3705
- * dimensions ARE the plate's real native size — `nativeChildCrop`'s 1920 cap
3706
- * never binds for a plate), else on the fallback detection-space tile — which
3707
- * IS the input OCR would read, so the same floor applies honestly in both cases.
3708
- */
3709
- var MIN_PLATE_OCR_LONG_SIDE_PX = 100;
3710
4457
  function isEnrichmentOutput(output) {
3711
4458
  return output.kind === "classifications" || output.kind === "embedding" || output.kind === "text";
3712
4459
  }
@@ -3761,6 +4508,10 @@ var PipelineExecutor = class {
3761
4508
  /** Bounds the full-frame-guard drop log: first example per (device,
3762
4509
  * class), then a counted summary per minute. See `drop-sampler.ts`. */
3763
4510
  dropSampler = new DropSampler();
4511
+ /** Bounds the classifier class-filter drop log the same way, keyed per
4512
+ * (device, `step:class`) — an excluded species firing on every frame of a
4513
+ * track must be a line and a counter, not wallpaper. */
4514
+ classFilterDropSampler = new DropSampler();
3764
4515
  constructor(opts) {
3765
4516
  this.opts = opts;
3766
4517
  }
@@ -3829,7 +4580,30 @@ var PipelineExecutor = class {
3829
4580
  const idGen = new FrameIdGenerator();
3830
4581
  const ctx = {
3831
4582
  idGen,
3832
- details
4583
+ details,
4584
+ onClassifierClassDropped: (drop) => {
4585
+ const sample = this.classFilterDropSampler.record(deviceId, `${drop.stepId}:${drop.droppedClass}`);
4586
+ if (sample.logExample) this.opts.logger?.info("classifier winner excluded by class filter — label dropped", {
4587
+ tags: { deviceId },
4588
+ meta: {
4589
+ step: drop.stepId,
4590
+ model: drop.modelId,
4591
+ droppedClass: drop.droppedClass,
4592
+ score: Number(drop.score.toFixed(3)),
4593
+ reason: drop.reason,
4594
+ hint: "detection keeps its macro class; adjust the step enabledClasses to change this"
4595
+ }
4596
+ });
4597
+ if (sample.summaryCount !== null) this.opts.logger?.info("classifier class filter: still dropping", {
4598
+ tags: { deviceId },
4599
+ meta: {
4600
+ step: drop.stepId,
4601
+ droppedClass: drop.droppedClass,
4602
+ suppressed: sample.summaryCount,
4603
+ windowMs: DROP_SUMMARY_INTERVAL_MS
4604
+ }
4605
+ });
4606
+ }
3833
4607
  };
3834
4608
  let discarded;
3835
4609
  const rootShed = {
@@ -4234,20 +5008,22 @@ var PipelineExecutor = class {
4234
5008
  cropH = crop.height;
4235
5009
  }
4236
5010
  } else {
4237
- const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
5011
+ const cropPolicy = resolvePlateOcrCropPolicy(resolveEffectivePostprocessor(child.definition, child.modelId));
5012
+ const cropBbox = cropPolicy ? expandBbox(parentDetection.bbox, cropPolicy.marginFraction, imageWidth, imageHeight) : parentDetection.bbox;
5013
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, cropBbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4238
5014
  if (nativeChild) {
4239
5015
  cropJpegBuf = nativeChild.jpeg;
4240
5016
  cropW = nativeChild.width;
4241
5017
  cropH = nativeChild.height;
4242
5018
  } else {
4243
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
5019
+ const crop = await cropJpeg(fullFrameJpeg, cropBbox, imageWidth, imageHeight);
4244
5020
  cropJpegBuf = crop.jpeg;
4245
5021
  cropW = crop.width;
4246
5022
  cropH = crop.height;
4247
5023
  }
4248
- if (child.definition.postprocessor === "ctc") {
5024
+ if (cropPolicy) {
4249
5025
  const plateLongSidePx = Math.max(cropW, cropH);
4250
- if (plateLongSidePx < MIN_PLATE_OCR_LONG_SIDE_PX) {
5026
+ if (plateLongSidePx < cropPolicy.minLongSidePx) {
4251
5027
  this.opts.logger?.info("plate ROI too small for OCR — skipping", {
4252
5028
  tags: { deviceId },
4253
5029
  meta: {
@@ -4255,28 +5031,44 @@ var PipelineExecutor = class {
4255
5031
  model: child.modelId,
4256
5032
  plateLongSidePx: Math.round(plateLongSidePx),
4257
5033
  plateShortSidePx: Math.round(Math.min(cropW, cropH)),
4258
- minPlateLongSidePx: MIN_PLATE_OCR_LONG_SIDE_PX,
5034
+ minPlateLongSidePx: cropPolicy.minLongSidePx,
5035
+ marginFraction: cropPolicy.marginFraction,
4259
5036
  source: nativeChild ? "native" : "detection-tile"
4260
5037
  }
4261
5038
  });
4262
5039
  continue;
4263
5040
  }
4264
5041
  }
4265
- if (child.definition.postprocessor === "ctc" && child.settings?.["rectify"] !== false) try {
4266
- const rectified = await rectifyPlateJpeg(cropJpegBuf);
4267
- if (rectified) {
4268
- cropJpegBuf = rectified.jpeg;
4269
- cropW = rectified.width;
4270
- cropH = rectified.height;
4271
- this.opts.logger?.info("plate crop deskewed before OCR", {
5042
+ if (cropPolicy?.rectify && child.settings?.["rectify"] !== false) try {
5043
+ const warped = await warpPlateJpeg(cropJpegBuf);
5044
+ if (warped) {
5045
+ cropJpegBuf = warped.jpeg;
5046
+ cropW = warped.width;
5047
+ cropH = warped.height;
5048
+ this.opts.logger?.info("plate crop warped before OCR", {
4272
5049
  tags: { deviceId },
4273
5050
  meta: {
4274
5051
  step: child.stepId,
4275
- angleDeg: Number(rectified.angleDeg.toFixed(2)),
4276
5052
  width: cropW,
4277
5053
  height: cropH
4278
5054
  }
4279
5055
  });
5056
+ } else {
5057
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
5058
+ if (rectified) {
5059
+ cropJpegBuf = rectified.jpeg;
5060
+ cropW = rectified.width;
5061
+ cropH = rectified.height;
5062
+ this.opts.logger?.info("plate crop deskewed before OCR", {
5063
+ tags: { deviceId },
5064
+ meta: {
5065
+ step: child.stepId,
5066
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
5067
+ width: cropW,
5068
+ height: cropH
5069
+ }
5070
+ });
5071
+ }
4280
5072
  }
4281
5073
  } catch {}
4282
5074
  }
@@ -4479,6 +5271,81 @@ function mergeCustomModels(staticModels, customModels) {
4479
5271
  return merged;
4480
5272
  }
4481
5273
  //#endregion
5274
+ //#region src/detection-pipeline/cluster-model-resolution.ts
5275
+ /**
5276
+ * The cluster model rule, enforced on the node that has to obey it.
5277
+ *
5278
+ * ## What is different about a cluster-scoped step
5279
+ *
5280
+ * For every other step, `resolveModelForFormat` is a kindness: a chosen model
5281
+ * with no build for this node's format is quietly swapped for the format's
5282
+ * default, one info line, and the camera keeps detecting. That is the right
5283
+ * trade when the output is a box, because two nodes producing boxes with two
5284
+ * detectors are not producing anything that gets compared.
5285
+ *
5286
+ * `face-embedding` and `clip-embedding` produce vectors that land in ONE shared
5287
+ * index and are ranked against each other by cosine. There the same kindness is
5288
+ * corruption: the substituted node writes a second feature space into the index
5289
+ * and nothing in the data says so. So for a step whose definition declares
5290
+ * `modelScope: 'cluster'` this module replaces the substitution with a
5291
+ * **refusal** — the rule `runStatelessStep` already applies to a pinned model
5292
+ * ([D54](../../../../docs/decisions/adr-0056.md)), extended to the live detail
5293
+ * plane, which is the other writer into the same indexes.
5294
+ *
5295
+ * ## What "refused" means here, exactly
5296
+ *
5297
+ * It means **refused to substitute**, not "dropped". The step keeps the pinned
5298
+ * model id, and the placement machinery that already exists —
5299
+ * `resolveStepsForDispatch`'s node-local engine fallback and the detail plane's
5300
+ * `resolveDetailStepDevice` jump — keeps doing its job, both of which key off a
5301
+ * step whose model has no build for the candidate format. That is how a
5302
+ * `face-embedding` dispatched at a Coral gets rescued onto the node's onnx
5303
+ * engine today, and taking that rescue away in the name of safety would stop
5304
+ * producing faces on every mixed-accelerator node.
5305
+ *
5306
+ * What the refusal removes is the LAST resort: after the fallback and the jump,
5307
+ * a step whose model this node truly cannot serve fails loudly
5308
+ * (`assertStepsRunnable`, D39) instead of quietly running a different encoder.
5309
+ *
5310
+ * A refusal is COUNTED and logged by the caller, with `tags: { deviceId }`.
5311
+ * Silence would read as "never happened" (CLAUDE.md: a branch that drops work
5312
+ * logs it).
5313
+ */
5314
+ var defaultLookup = (stepId) => {
5315
+ try {
5316
+ return getStepDefinition(stepId);
5317
+ } catch {
5318
+ return null;
5319
+ }
5320
+ };
5321
+ /**
5322
+ * Decide what `stepId` runs on a node dispatching in `format`.
5323
+ *
5324
+ * An unknown step is `node-scoped`: a custom addon owns its own format
5325
+ * compatibility and this module has no catalog entry to reason about.
5326
+ */
5327
+ function resolveClusterStepModel(stepId, clusterModels, format, lookup = defaultLookup) {
5328
+ const def = lookup(stepId);
5329
+ if (def === null || def.modelScope !== "cluster") return { kind: "node-scoped" };
5330
+ const modelId = resolveClusterStepModelId(stepId, clusterModels);
5331
+ if (modelId === null) return { kind: "node-scoped" };
5332
+ const entry = def.models.find((m) => m.id === modelId);
5333
+ if (entry === void 0) return {
5334
+ kind: "pinned",
5335
+ modelId
5336
+ };
5337
+ if (entry.formats[format] !== void 0) return {
5338
+ kind: "pinned",
5339
+ modelId
5340
+ };
5341
+ return {
5342
+ kind: "refused",
5343
+ modelId,
5344
+ format,
5345
+ formatsShipped: Object.keys(entry.formats)
5346
+ };
5347
+ }
5348
+ //#endregion
4482
5349
  //#region src/detection-pipeline/step-resolution.ts
4483
5350
  /**
4484
5351
  * Walk `steps` (recursing into children) and return exactly the
@@ -4580,10 +5447,11 @@ function collectUnknownAddonIssues(steps, getStepDef = getStepDefinition) {
4580
5447
  * `diagnostics.substitutions` instead of happening silently. The caller
4581
5448
  * (the provider) owns logging + dedup for both.
4582
5449
  */
4583
- function resolveInputSteps(steps, format, engine) {
5450
+ function resolveInputSteps(steps, format, engine, clusterModels) {
4584
5451
  const resolvedSteps = [];
4585
5452
  const unknownAddonIds = [];
4586
5453
  const substitutions = [];
5454
+ const clusterRefusals = [];
4587
5455
  for (const s of steps) {
4588
5456
  let def;
4589
5457
  try {
@@ -4592,6 +5460,35 @@ function resolveInputSteps(steps, format, engine) {
4592
5460
  unknownAddonIds.push(s.addonId);
4593
5461
  continue;
4594
5462
  }
5463
+ if (clusterModels !== void 0) {
5464
+ const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
5465
+ if (verdict.kind !== "node-scoped") {
5466
+ if (verdict.kind === "refused") clusterRefusals.push({
5467
+ stepId: s.addonId,
5468
+ modelId: verdict.modelId,
5469
+ format: verdict.format,
5470
+ formatsShipped: verdict.formatsShipped
5471
+ });
5472
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
5473
+ if (childResult) {
5474
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
5475
+ substitutions.push(...childResult.diagnostics.substitutions);
5476
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
5477
+ }
5478
+ resolvedSteps.push({
5479
+ addonId: s.addonId,
5480
+ addonName: def.name,
5481
+ slot: def.slot,
5482
+ inputClasses: def.inputClasses ?? [],
5483
+ outputClasses: def.outputClasses,
5484
+ enabled: s.enabled,
5485
+ modelId: verdict.modelId,
5486
+ children: childResult ? childResult.steps : [],
5487
+ ...s.settings ? { settings: { ...s.settings } } : {}
5488
+ });
5489
+ continue;
5490
+ }
5491
+ }
4595
5492
  const chosenModelId = s.modelId ?? null;
4596
5493
  const runningModelId = chosenModelId === null && engine !== void 0 && s.addonId === "object-detection" ? resolveDefaultDetectionModel(engine.backend, engine.device, format) : resolveModelForFormat(s.addonId, chosenModelId, format);
4597
5494
  if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
@@ -4600,10 +5497,11 @@ function resolveInputSteps(steps, format, engine) {
4600
5497
  running: runningModelId,
4601
5498
  format
4602
5499
  });
4603
- const childResult = s.children ? resolveInputSteps(s.children, format, engine) : null;
5500
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
4604
5501
  if (childResult) {
4605
5502
  unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4606
5503
  substitutions.push(...childResult.diagnostics.substitutions);
5504
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
4607
5505
  }
4608
5506
  resolvedSteps.push({
4609
5507
  addonId: s.addonId,
@@ -4621,7 +5519,8 @@ function resolveInputSteps(steps, format, engine) {
4621
5519
  steps: resolvedSteps,
4622
5520
  diagnostics: {
4623
5521
  unknownAddonIds,
4624
- substitutions
5522
+ substitutions,
5523
+ clusterRefusals
4625
5524
  }
4626
5525
  };
4627
5526
  }
@@ -4664,6 +5563,94 @@ function applyZoneRuleGate(result, zones, rules) {
4664
5563
  };
4665
5564
  }
4666
5565
  //#endregion
5566
+ //#region src/shared/cluster-model-source.ts
5567
+ /**
5568
+ * Where the runner gets the ONE cluster model choice per cluster-scoped step.
5569
+ *
5570
+ * The sibling of `crop-convention-source.ts`, and deliberately built the same
5571
+ * way, because it answers the neighbouring half of the same question: the crop
5572
+ * convention decides WHAT pixels the encoder sees, this decides WHICH encoder
5573
+ * sees them. Both are cluster-wide, both are read by the runner from the
5574
+ * `pipeline-orchestrator` global settings over the hub-routed `addon-settings`
5575
+ * cap, both pass NO `nodeId`.
5576
+ *
5577
+ * ## Deliberately NOT node-scoped, and not on the attach config
5578
+ *
5579
+ * With a `nodeId` the settings cap would project that node's scoped value and
5580
+ * two nodes could embed into one index with two encoders — the original defect,
5581
+ * relocated to the transport. And the attach config is the wrong carrier for
5582
+ * the same reason it was wrong for the crop: eleven builders construct a
5583
+ * `RunnerCameraConfig`, a cluster-wide constant threaded through eleven
5584
+ * builders eventually reaches ten of them, and the cameras attached by the
5585
+ * forgotten one quietly write a foreign feature space into the shared index.
5586
+ *
5587
+ * ## Why the failure mode keeps the last value
5588
+ *
5589
+ * A failed read must never revert to the registry default MID-PASS: that would
5590
+ * silently change what every remaining vector of that dispatch means, which is
5591
+ * the exact class of harm the scope exists to prevent. So a failure keeps the
5592
+ * last known row and says so, loudly, once per failure.
5593
+ */
5594
+ /**
5595
+ * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
5596
+ * convention and the native-frame lease: hub-resident and neutral between the
5597
+ * addons that read it (post-analysis pins it for a re-embed pass, the runner
5598
+ * enforces it live — neither should own a value the other depends on).
5599
+ */
5600
+ var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
5601
+ /** TTL-cached read of the cluster model row. */
5602
+ var ClusterModelSource = class {
5603
+ models = DEFAULT_CLUSTER_STEP_MODELS;
5604
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
5605
+ inFlight = null;
5606
+ logger;
5607
+ now;
5608
+ ttlMs;
5609
+ constructor(options) {
5610
+ this.logger = options.logger;
5611
+ this.now = options.now ?? (() => Date.now());
5612
+ this.ttlMs = options.ttlMs ?? 6e4;
5613
+ }
5614
+ /** The row in force right now — the registry defaults until a read lands. */
5615
+ current() {
5616
+ return this.models;
5617
+ }
5618
+ /**
5619
+ * Re-read if the cached row has expired. Concurrent callers share one
5620
+ * in-flight read; a failure keeps the LAST KNOWN row.
5621
+ */
5622
+ async refresh(api) {
5623
+ if (api === void 0) return this.models;
5624
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
5625
+ this.inFlight ??= this.read(api).finally(() => {
5626
+ this.inFlight = null;
5627
+ });
5628
+ await this.inFlight;
5629
+ return this.models;
5630
+ }
5631
+ async read(api) {
5632
+ try {
5633
+ const next = pickClusterStepModels(await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID }));
5634
+ this.lastReadAtMs = this.now();
5635
+ const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
5636
+ if (changed.length > 0) {
5637
+ this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
5638
+ stepId,
5639
+ from: this.models[stepId],
5640
+ to: next[stepId]
5641
+ })) } });
5642
+ this.models = next;
5643
+ }
5644
+ } catch (err) {
5645
+ this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
5646
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
5647
+ inUse: { ...this.models },
5648
+ error: err instanceof Error ? err.message : String(err)
5649
+ } });
5650
+ }
5651
+ }
5652
+ };
5653
+ //#endregion
4667
5654
  //#region src/detection-pipeline/provider.ts
4668
5655
  /**
4669
5656
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4929,6 +5916,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4929
5916
  */
4930
5917
  customModelsCache = null;
4931
5918
  /**
5919
+ * SYNC custom-model lookup handed to every live {@link EngineFactory} so
5920
+ * `buildPoolModelConfigForStep` can resolve an operator-registered model at
5921
+ * pool-load time. Reads the {@link customModelsCache} snapshot — every load
5922
+ * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
5923
+ * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
5924
+ * the snapshot is fresh when the pool asks. Without this seam a selected
5925
+ * custom model failed the WHOLE pool applyConfig with
5926
+ * `Model "X" not found in step "Y" catalog`.
5927
+ */
5928
+ customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
5929
+ /**
4932
5930
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4933
5931
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4934
5932
  * synchronously per frame so detections inside an `exclude` zone
@@ -4938,6 +5936,19 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4938
5936
  deviceProxies = /* @__PURE__ */ new Map();
4939
5937
  proxyUnsubs = /* @__PURE__ */ new Map();
4940
5938
  /**
5939
+ * The cluster-wide model row for `modelScope: 'cluster'` steps
5940
+ * (`face-embedding`, `clip-embedding`) — read from the orchestrator's global
5941
+ * settings, cluster-scoped, TTL-cached. See `shared/cluster-model-source.ts`
5942
+ * for why it is not carried on the attach config and never node-scoped.
5943
+ *
5944
+ * Refreshed on the ASYNC boundary (`runPipeline`) and read SYNCHRONOUSLY by
5945
+ * `inputStepsToPipelineSteps`, which runs per dispatch and must not await.
5946
+ */
5947
+ clusterModels = new ClusterModelSource({ logger: {
5948
+ info: (message, extras) => this.log.info(message, extras),
5949
+ warn: (message, extras) => this.log.warn(message, extras)
5950
+ } });
5951
+ /**
4941
5952
  * Last logged "step configured" signature — used to dedupe the
4942
5953
  * per-step debug trail so it only fires on actual config CHANGE
4943
5954
  * (not on every frame). `runPipeline` is called once per decoded
@@ -6024,6 +7035,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6024
7035
  };
6025
7036
  }
6026
7037
  async runPipeline(input, onProgress) {
7038
+ await this.clusterModels.refresh(this.addonCtx?.api);
6027
7039
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
6028
7040
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6029
7041
  const emit = (message, extra) => {
@@ -6208,7 +7220,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6208
7220
  modelsDir: this.modelsDir,
6209
7221
  logger: this.log.child("engine-override"),
6210
7222
  pythonPath: this.executorOptions.pythonPath ?? "",
6211
- provisioning: this.executorOptions.provisioning
7223
+ provisioning: this.executorOptions.provisioning,
7224
+ resolveCustomModel: this.customModelResolver
6212
7225
  });
6213
7226
  const initPromise = newFactory.initialize([]);
6214
7227
  this.overrideCache = {
@@ -6252,7 +7265,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6252
7265
  this.executor = executor;
6253
7266
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
6254
7267
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6255
- const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
7268
+ const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
6256
7269
  if (needed.length > 0) {
6257
7270
  this.log.info("Benchmark: models to load", { meta: {
6258
7271
  count: needed.length,
@@ -6388,7 +7401,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6388
7401
  modelsDir: this.modelsDir,
6389
7402
  logger: this.log.child("engine-override"),
6390
7403
  pythonPath: this.executorOptions.pythonPath ?? "",
6391
- provisioning: this.executorOptions.provisioning
7404
+ provisioning: this.executorOptions.provisioning,
7405
+ resolveCustomModel: this.customModelResolver
6392
7406
  });
6393
7407
  const initPromise = newFactory.initialize([]);
6394
7408
  this.overrideCache = {
@@ -6482,8 +7496,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6482
7496
  inputStepsToPipelineSteps(steps, format = this.currentEngine.format, engine = {
6483
7497
  backend: this.currentEngine.backend,
6484
7498
  device: this.currentEngine.device ?? null
6485
- }) {
6486
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine);
7499
+ }, clusterRefusalsOut) {
7500
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current());
7501
+ if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
6487
7502
  for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
6488
7503
  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: {
6489
7504
  step: sub.addonId,
@@ -6493,6 +7508,34 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6493
7508
  } }));
6494
7509
  return resolvedSteps;
6495
7510
  }
7511
+ /**
7512
+ * Announce the cluster-scope refusals of the resolution that is actually
7513
+ * being RETURNED — never of a speculative one.
7514
+ *
7515
+ * A refusal here means: this dispatch will run `stepId` with the cluster's
7516
+ * chosen model on an engine that ships no build of it, and NOTHING was
7517
+ * substituted in its place. `assertStepsRunnable` has already had its say
7518
+ * (it throws when the catalog ships nothing at all for the format); what
7519
+ * this line adds is the case the throw cannot see — the catalog has a build,
7520
+ * just not of the model the cluster chose — which without this line is the
7521
+ * quietest possible way to stop producing vectors.
7522
+ *
7523
+ * Deduped per (step, model, engine): a fleet of cameras on one incompatible
7524
+ * node produces one line, not one per track.
7525
+ */
7526
+ logClusterRefusals(refusals, deviceId, engineLabel) {
7527
+ 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", {
7528
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7529
+ meta: {
7530
+ step: refusal.stepId,
7531
+ clusterModelId: refusal.modelId,
7532
+ format: refusal.format,
7533
+ formatsShipped: [...refusal.formatsShipped],
7534
+ engine: engineLabel,
7535
+ remedy: `choose a cluster model with a ${refusal.format} build, or take this engine out of the rotation for ${refusal.stepId}`
7536
+ }
7537
+ }));
7538
+ }
6496
7539
  /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6497
7540
  logLiveDispatchIssueOnce(key, fn) {
6498
7541
  if (this.loggedLiveDispatchIssues.has(key)) return;
@@ -6554,8 +7597,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6554
7597
  const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6555
7598
  const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6556
7599
  if (deviceKey === void 0 || deviceEngine === void 0) {
6557
- const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7600
+ const refusals = [];
7601
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, refusals);
6558
7602
  this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
7603
+ this.logClusterRefusals(refusals, deviceId, nodeEngineLabel);
6559
7604
  return {
6560
7605
  deviceKey: void 0,
6561
7606
  steps
@@ -6566,14 +7611,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6566
7611
  backend: deviceEngine.backend,
6567
7612
  device: deviceEngine.device ?? null
6568
7613
  };
6569
- const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
7614
+ const deviceRefusals = [];
7615
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
6570
7616
  const framePlane = args.plane === "frame";
6571
7617
  const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6572
7618
  if (zeroBuild.length === 0) {
6573
- if (!framePlane) return {
6574
- deviceKey,
6575
- steps
6576
- };
7619
+ if (!framePlane) {
7620
+ this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
7621
+ return {
7622
+ deviceKey,
7623
+ steps
7624
+ };
7625
+ }
6577
7626
  const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6578
7627
  if (pruneResult.prunedAddonIds.length > 0) {
6579
7628
  const prunedList = pruneResult.prunedAddonIds.join(",");
@@ -6586,6 +7635,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6586
7635
  }
6587
7636
  }));
6588
7637
  }
7638
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
6589
7639
  return {
6590
7640
  deviceKey,
6591
7641
  steps: pruneResult.steps
@@ -6601,8 +7651,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6601
7651
  fallbackEngine: nodeEngineLabel
6602
7652
  }
6603
7653
  }));
6604
- const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7654
+ const fallbackRefusals = [];
7655
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, fallbackRefusals);
6605
7656
  this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
7657
+ this.logClusterRefusals(fallbackRefusals, deviceId, nodeEngineLabel);
6606
7658
  return {
6607
7659
  deviceKey: void 0,
6608
7660
  steps: fallbackSteps
@@ -6654,7 +7706,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6654
7706
  const needed = [];
6655
7707
  for (const step of allEnabled) {
6656
7708
  if (!step.enabled) continue;
6657
- if (factory?.isLoadedWithModel(step.addonId, step.modelId)) continue;
7709
+ if (factory !== null && !factory.needsPoolUpdate(step)) continue;
6658
7710
  needed.push(step);
6659
7711
  }
6660
7712
  if (needed.length === 0) return;
@@ -6663,6 +7715,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6663
7715
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6664
7716
  if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
6665
7717
  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`);
7718
+ 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`);
6666
7719
  this.log.info("Downloading model for step", { meta: {
6667
7720
  modelId: step.modelId,
6668
7721
  format,
@@ -6812,7 +7865,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6812
7865
  try {
6813
7866
  const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6814
7867
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
6815
- if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7868
+ if (enabledSteps.filter((s) => factory.needsPoolUpdate(s)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
6816
7869
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
6817
7870
  this.log.info("runPipelineBatch path decision", { meta: {
6818
7871
  phase: "batch",
@@ -7068,7 +8121,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7068
8121
  modelsDir: this.modelsDir,
7069
8122
  logger: this.log.child("engine"),
7070
8123
  pythonPath: this.executorOptions.pythonPath ?? "",
7071
- provisioning: this.executorOptions.provisioning
8124
+ provisioning: this.executorOptions.provisioning,
8125
+ resolveCustomModel: this.customModelResolver
7072
8126
  });
7073
8127
  try {
7074
8128
  await factory.initialize([]);
@@ -7148,7 +8202,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7148
8202
  modelsDir: this.modelsDir,
7149
8203
  logger: this.log.child(`engine:${deviceKey}`),
7150
8204
  pythonPath: this.executorOptions.pythonPath ?? "",
7151
- provisioning: this.executorOptions.provisioning
8205
+ provisioning: this.executorOptions.provisioning,
8206
+ resolveCustomModel: this.customModelResolver
7152
8207
  });
7153
8208
  try {
7154
8209
  await factory.initialize([]);
@@ -7519,7 +8574,13 @@ function buildSchemaSlots(formats, modelsDir, customByStep) {
7519
8574
  if (availableModels.length === 0) continue;
7520
8575
  const slot = step.slot;
7521
8576
  if (!slotMap.has(slot)) slotMap.set(slot, []);
7522
- const configSchema = pipelineStep.getConfigSchema();
8577
+ const customIds = new Set((customByStep?.get(step.id) ?? []).map((m) => m.id));
8578
+ const configSchema = annotateConfigSchemaForModels({
8579
+ def: step,
8580
+ configSchema: pipelineStep.getConfigSchema(),
8581
+ models: availableModels,
8582
+ customModelIds: customIds
8583
+ });
7523
8584
  slotMap.get(slot).push({
7524
8585
  id: step.id,
7525
8586
  name: step.name,
@@ -7896,7 +8957,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7896
8957
  type: "select",
7897
8958
  key: "batchMode",
7898
8959
  label: "Batch dispatch mode",
7899
- 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.",
8960
+ 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.",
7900
8961
  options: [
7901
8962
  {
7902
8963
  value: "",
@@ -7940,7 +9001,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7940
9001
  type: "slider",
7941
9002
  key: "maxBatchSize",
7942
9003
  label: "Max batch size",
7943
- description: "Cap on items per batched predict call. Auto = backend default. Applies to THIS node only.",
9004
+ 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.",
7944
9005
  min: 1,
7945
9006
  max: 16,
7946
9007
  step: 1,