@camstack/addon-pipeline 1.2.102 → 1.2.104

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 (52) hide show
  1. package/dist/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.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 +844 -110
  5. package/dist/detection-pipeline/index.mjs +842 -108
  6. package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
  7. package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
  8. package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
  9. package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
  10. package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.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-DY4RHcTj.js} +1 -1
  16. package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.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-ZaqZ17-x.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-BnhMnnKV.mjs +26 -0
  28. package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
  29. package/dist/stream-broker/index.js +628 -92
  30. package/dist/stream-broker/index.mjs +627 -91
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
  33. package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
  34. package/package.json +1 -1
  35. package/python/inference_pool.py +193 -1
  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_static_batch.py +426 -0
  51. package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
  52. 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-D9sltFoR.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-BS_lPX6H.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-Dw7-9jCI.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: {
@@ -1203,6 +1208,7 @@ function serializeModelConfig(config) {
1203
1208
  if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
1204
1209
  if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
1205
1210
  if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
1211
+ if (config.minCharProbability !== void 0) result["minCharProbability"] = config.minCharProbability;
1206
1212
  if (config.numClasses) result["numClasses"] = config.numClasses;
1207
1213
  if (config.strides) result["strides"] = config.strides;
1208
1214
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1550,6 +1556,21 @@ var PipelineModelManager = class {
1550
1556
  }
1551
1557
  };
1552
1558
  //#endregion
1559
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1560
+ function resolveEffectivePostprocessor(definition, modelId) {
1561
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1562
+ }
1563
+ /**
1564
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1565
+ * required for CUSTOM (operator-registered) models, which are NOT in
1566
+ * `definition.models` and would otherwise silently fall back to the step
1567
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1568
+ * error anywhere, just zero detections).
1569
+ */
1570
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1571
+ return entry?.postprocessor ?? definition.postprocessor;
1572
+ }
1573
+ //#endregion
1553
1574
  //#region src/detection-pipeline/engine/resolve-device-engine.ts
1554
1575
  /**
1555
1576
  * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
@@ -1956,49 +1977,70 @@ var EngineFactory = class {
1956
1977
  await this.poolManager.applyConfig(steps);
1957
1978
  }
1958
1979
  buildPoolModelConfig(stepId, modelId, poolRuntime) {
1959
- 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
- };
1980
+ return buildPoolModelConfigForStep({
1981
+ stepId,
1982
+ modelId,
1983
+ poolRuntime,
1984
+ modelsDir: this.opts.modelsDir,
1985
+ nmsIouThreshold: this.opts.nmsIouThreshold,
1986
+ device: this.opts.engine.device,
1987
+ resolveCustomModel: this.opts.resolveCustomModel
1988
+ });
2000
1989
  }
2001
1990
  };
1991
+ /**
1992
+ * Build the Python-pool model config for `(stepId, modelId)`.
1993
+ *
1994
+ * Resolution order: static step catalog first, then the injected
1995
+ * custom-model resolver (operator-registered models from the
1996
+ * `custom-model-registry` cap). A model found in NEITHER throws — that step
1997
+ * cannot load.
1998
+ */
1999
+ function buildPoolModelConfigForStep(inputs) {
2000
+ const { stepId, modelId, poolRuntime, modelsDir } = inputs;
2001
+ const def = getStepDefinition(stepId);
2002
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? inputs.resolveCustomModel?.(stepId, modelId);
2003
+ if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2004
+ const format = RUNTIME_TO_FORMAT[poolRuntime];
2005
+ if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2006
+ const formatEntry = modelEntry.formats[format];
2007
+ if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2008
+ const urlParts = formatEntry.url.split("/");
2009
+ const modelPath = `${modelsDir}/${urlParts[urlParts.length - 1] ?? `${modelId}.${format}`}`;
2010
+ const inputWidth = modelEntry.inputSize.width;
2011
+ const inputHeight = modelEntry.inputSize.height;
2012
+ const inputSize = Math.max(inputWidth, inputHeight);
2013
+ const inputChannels = modelEntry.inputChannels ?? 3;
2014
+ const entryLabels = modelEntry.labels.length > 0 ? modelEntry.labels.map((l) => l.id) : void 0;
2015
+ const labels = readModelLabels(modelsDir, modelEntry) ?? entryLabels ?? def.labels;
2016
+ return {
2017
+ path: modelPath,
2018
+ inputSize,
2019
+ inputWidth,
2020
+ inputHeight,
2021
+ inputChannels,
2022
+ preprocessMode: modelEntry.preprocessMode ?? "letterbox",
2023
+ inputNormalization: modelEntry.inputNormalization,
2024
+ outputProbabilities: modelEntry.outputProbabilities,
2025
+ postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2026
+ confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2027
+ nmsIouThreshold: inputs.nmsIouThreshold ?? .45,
2028
+ labels,
2029
+ charset: def.charset,
2030
+ plateRegion: def.plateRegion,
2031
+ minTextLength: def.minTextLength,
2032
+ minTextConfidence: def.minTextConfidence,
2033
+ minCharProbability: def.minCharProbability,
2034
+ numClasses: labels?.length,
2035
+ strides: def.postprocessor === "scrfd" ? [
2036
+ 8,
2037
+ 16,
2038
+ 32
2039
+ ] : void 0,
2040
+ maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2041
+ device: inputs.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2042
+ };
2043
+ }
2002
2044
  //#endregion
2003
2045
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
2004
2046
  var IdlePoolReaper = class {
@@ -3263,7 +3305,7 @@ function estimateSkewAngle(gray, opts = {}) {
3263
3305
  score: bestScore
3264
3306
  };
3265
3307
  }
3266
- function toSharpChannels(channels) {
3308
+ function toSharpChannels$1(channels) {
3267
3309
  switch (channels) {
3268
3310
  case 1: return 1;
3269
3311
  case 2: return 2;
@@ -3344,7 +3386,7 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3344
3386
  jpeg: await sharp(Buffer.from(band.data), { raw: {
3345
3387
  width: band.width,
3346
3388
  height: band.height,
3347
- channels: toSharpChannels(band.channels)
3389
+ channels: toSharpChannels$1(band.channels)
3348
3390
  } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
3349
3391
  width: band.width,
3350
3392
  height: band.height,
@@ -3355,6 +3397,413 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3355
3397
  }
3356
3398
  }
3357
3399
  //#endregion
3400
+ //#region src/detection-pipeline/pipeline/plate-ocr-crop-policy.ts
3401
+ /**
3402
+ * Keyed by POSTPROCESSOR, not by model id: the postprocessor is what identifies
3403
+ * the reader family, it is already what the executor branches on, and a new
3404
+ * model of an existing family inherits the right pixels without touching this
3405
+ * table. A new reader family with a new postprocessor has to add a row — which
3406
+ * is the point, since it cannot be given the wrong one by default.
3407
+ */
3408
+ var PLATE_OCR_CROP_POLICIES = {
3409
+ ctc: {
3410
+ marginFraction: 0,
3411
+ rectify: true,
3412
+ minLongSidePx: 85
3413
+ },
3414
+ "plate-slots": {
3415
+ marginFraction: .15,
3416
+ rectify: false,
3417
+ minLongSidePx: 70
3418
+ }
3419
+ };
3420
+ /**
3421
+ * The crop policy for a step child, or `null` when the child is not a plate
3422
+ * reader at all (a face embedder, a classifier — nothing here applies to them).
3423
+ */
3424
+ function resolvePlateOcrCropPolicy(postprocessor) {
3425
+ return PLATE_OCR_CROP_POLICIES[postprocessor] ?? null;
3426
+ }
3427
+ /**
3428
+ * Grow a bbox by `marginFraction` of its own size on each side, clamped to the
3429
+ * frame. Returns the input unchanged at margin 0 or on a degenerate box, so the
3430
+ * un-margined readers keep the exact pixels they had.
3431
+ */
3432
+ function expandBbox(bbox, marginFraction, imageWidth, imageHeight) {
3433
+ const [x1, y1, x2, y2] = bbox;
3434
+ const width = x2 - x1;
3435
+ const height = y2 - y1;
3436
+ if (!(marginFraction > 0) || !(width > 0) || !(height > 0)) return [
3437
+ x1,
3438
+ y1,
3439
+ x2,
3440
+ y2
3441
+ ];
3442
+ return [
3443
+ Math.max(0, x1 - width * marginFraction),
3444
+ Math.max(0, y1 - height * marginFraction),
3445
+ Math.min(imageWidth, x2 + width * marginFraction),
3446
+ Math.min(imageHeight, y2 + height * marginFraction)
3447
+ ];
3448
+ }
3449
+ //#endregion
3450
+ //#region src/detection-pipeline/pipeline/plate-warp.ts
3451
+ /**
3452
+ * Plate-crop WARP — perspective rectification of a licence-plate ROI to a
3453
+ * canonical, aspect-locked text strip, run immediately before the CTC reader.
3454
+ *
3455
+ * WHY this exists on top of {@link ./plate-deskew.rectifyPlateJpeg}
3456
+ * ----------------------------------------------------------------
3457
+ * The detector hands OCR an AXIS-ALIGNED box around a plate that, on an oblique
3458
+ * camera, is a rotated trapezoid inside it. Two things then destroy the read,
3459
+ * and neither is a resolution problem:
3460
+ *
3461
+ * 1. **Aspect.** A German plate is 4.7 : 1. The boxes cam 617 produces measure
3462
+ * 1.3 – 2.0 : 1 — the box is 2-3× too tall because it circumscribes a
3463
+ * tilted rectangle. The recognizer resizes its input to a fixed 64px height
3464
+ * preserving aspect, so a 1.5 : 1 tile becomes ~96px wide: seven glyphs get
3465
+ * ~13px each. The same plate rectified to 4.7 : 1 arrives ~300px wide.
3466
+ * 2. **Tilt.** The 2026-08 deskew estimator searches ±16° and REJECTS an
3467
+ * unbracketed optimum (D186) — correct, and it means it returns 0° on this
3468
+ * camera, whose plates sit at 24-31°. The rotation is real; the projection
3469
+ * profile just cannot measure it from a box that is mostly car.
3470
+ *
3471
+ * The plate itself is the one thing in the ROI that is reliably a large, bright,
3472
+ * convex blob. So: Otsu-threshold the tile, keep the largest bright connected
3473
+ * component, take its four extreme corners, and sample that quadrilateral onto
3474
+ * a fixed 4.7 : 1 × 64px canvas with bilinear interpolation. One pass, no
3475
+ * OpenCV, no new dependency — sharp only decodes and re-encodes, exactly as
3476
+ * `plate-deskew.ts` and `native-child-crop.ts` do.
3477
+ *
3478
+ * MEASURED on 21 ground-truthed cam-617 plate crops (9 distinct vehicles,
3479
+ * 2026-08-20 offline replay against the production `vgg_english_g2` weights and
3480
+ * the production CTC postprocessor):
3481
+ *
3482
+ * | chain | exact | char acc | false on 18 junk ROIs |
3483
+ * | deskew only + 100px gate (live today) | 0/21 | 4.8 % | 0 |
3484
+ * | deskew + text-band + 3× upscale | 0/21 | 16.3 % | 0 |
3485
+ * | THIS warp + seal-tolerant gate, 85px | 4/21 | 42.9 % | 0 |
3486
+ *
3487
+ * The four exact reads are four DIFFERENT vehicles at 91-98px native plate
3488
+ * width — every one of them SKIPPED entirely by the old 100px floor. Upscaling
3489
+ * and contrast work (CLAHE, autocontrast, unsharp, 2/3/4× lanczos) moved char
3490
+ * accuracy by ≤2 points on their own and are NOT included: the geometry was the
3491
+ * whole story.
3492
+ */
3493
+ /**
3494
+ * Canonical strip geometry. 4.7 : 1 is the German plate ratio (520 × 110 mm);
3495
+ * 64px is the recognizer's own input height, so the warp writes exactly the
3496
+ * pixels the model reads and no resampling is wasted.
3497
+ */
3498
+ var PLATE_STRIP_ASPECT = 4.7;
3499
+ /**
3500
+ * Longest side the blob search runs at. The connected-component pass is O(px)
3501
+ * with an explicit stack; capping it keeps a 600px plate ROI at ~1ms.
3502
+ */
3503
+ var BLOB_SEARCH_MAX_DIM = 200;
3504
+ /**
3505
+ * The blob must be a plausible plate: neither a speck nor the whole tile, and
3506
+ * within a wide-but-finite aspect band. Outside these the caller keeps its
3507
+ * un-warped tile — a bad quad is far worse than no quad.
3508
+ */
3509
+ var MIN_BLOB_AREA_FRAC = .04;
3510
+ var MAX_BLOB_AREA_FRAC = .96;
3511
+ var MIN_QUAD_ASPECT = 1.5;
3512
+ var MAX_QUAD_ASPECT = 9;
3513
+ var MIN_QUAD_WIDTH_PX = 12;
3514
+ var MIN_QUAD_HEIGHT_PX = 5;
3515
+ /**
3516
+ * Fraction of the plate width dropped from the LEFT edge before sampling.
3517
+ * The bright blob's left boundary sits on the plate's black frame / the blue EU
3518
+ * band shoulder, and that edge column decodes as a spurious leading glyph
3519
+ * ("IDND7578" for DN-D 7578). Swept end to end over the ground-truth set at
3520
+ * 0 / 2 / 3.5 / 4 / 5 / 6 / 7 %: 4 % is the peak (4/21 exact, 42.9 % char), the
3521
+ * neighbours give 3/21, and 7 % starts eating the first character (0/21).
3522
+ */
3523
+ var LEFT_INSET_FRAC = .04;
3524
+ /** Otsu's between-class-variance threshold of a grayscale image. */
3525
+ function otsuThreshold(gray) {
3526
+ const hist = new Float64Array(256);
3527
+ for (let i = 0; i < gray.data.length; i++) {
3528
+ const v = gray.data[i];
3529
+ hist[v] = (hist[v] ?? 0) + 1;
3530
+ }
3531
+ const total = gray.data.length;
3532
+ if (total === 0) return 128;
3533
+ let sum = 0;
3534
+ for (let v = 0; v < 256; v++) sum += v * hist[v];
3535
+ let wB = 0;
3536
+ let sumB = 0;
3537
+ let best = 0;
3538
+ let bestVar = -1;
3539
+ for (let v = 0; v < 256; v++) {
3540
+ wB += hist[v];
3541
+ if (wB === 0) continue;
3542
+ const wF = total - wB;
3543
+ if (wF === 0) break;
3544
+ sumB += v * hist[v];
3545
+ const mB = sumB / wB;
3546
+ const mF = (sum - sumB) / wF;
3547
+ const between = wB * wF * (mB - mF) * (mB - mF);
3548
+ if (between > bestVar) {
3549
+ bestVar = between;
3550
+ best = v;
3551
+ }
3552
+ }
3553
+ return best;
3554
+ }
3555
+ /** `p`-th percentile (0-100) of a grayscale image, via its 256-bin histogram. */
3556
+ function percentile(gray, p) {
3557
+ const hist = new Int32Array(256);
3558
+ for (let i = 0; i < gray.data.length; i++) hist[gray.data[i]] = (hist[gray.data[i]] ?? 0) + 1;
3559
+ const target = gray.data.length * p / 100;
3560
+ let seen = 0;
3561
+ for (let v = 0; v < 256; v++) {
3562
+ seen += hist[v] ?? 0;
3563
+ if (seen >= target) return v;
3564
+ }
3565
+ return 255;
3566
+ }
3567
+ /**
3568
+ * Mask (1/0, same dimensions) of the LARGEST 4-connected component of pixels
3569
+ * strictly above `threshold`. `null` when nothing clears the threshold.
3570
+ * Iterative flood fill — a plate ROI is small and recursion depth is not.
3571
+ */
3572
+ function largestBrightComponent(gray, threshold) {
3573
+ const { data, width, height } = gray;
3574
+ const n = width * height;
3575
+ const label = new Int32Array(n);
3576
+ const stack = [];
3577
+ let current = 0;
3578
+ let bestLabel = 0;
3579
+ let bestSize = 0;
3580
+ for (let seed = 0; seed < n; seed++) {
3581
+ if (label[seed] !== 0 || data[seed] <= threshold) continue;
3582
+ current += 1;
3583
+ label[seed] = current;
3584
+ stack.length = 0;
3585
+ stack.push(seed);
3586
+ let size = 0;
3587
+ while (stack.length > 0) {
3588
+ const i = stack.pop();
3589
+ size += 1;
3590
+ const x = i % width;
3591
+ const y = (i - x) / width;
3592
+ if (x > 0 && label[i - 1] === 0 && data[i - 1] > threshold) {
3593
+ label[i - 1] = current;
3594
+ stack.push(i - 1);
3595
+ }
3596
+ if (x < width - 1 && label[i + 1] === 0 && data[i + 1] > threshold) {
3597
+ label[i + 1] = current;
3598
+ stack.push(i + 1);
3599
+ }
3600
+ if (y > 0 && label[i - width] === 0 && data[i - width] > threshold) {
3601
+ label[i - width] = current;
3602
+ stack.push(i - width);
3603
+ }
3604
+ if (y < height - 1 && label[i + width] === 0 && data[i + width] > threshold) {
3605
+ label[i + width] = current;
3606
+ stack.push(i + width);
3607
+ }
3608
+ }
3609
+ if (size > bestSize) {
3610
+ bestSize = size;
3611
+ bestLabel = current;
3612
+ }
3613
+ }
3614
+ if (bestLabel === 0) return null;
3615
+ const out = new Uint8Array(n);
3616
+ for (let i = 0; i < n; i++) out[i] = label[i] === bestLabel ? 1 : 0;
3617
+ return out;
3618
+ }
3619
+ /**
3620
+ * Four corners of a mask, taken as the extreme points of `x+y` (top-left /
3621
+ * bottom-right) and `x−y` (top-right / bottom-left). Exact for a rotated
3622
+ * rectangle and stable under the blob noise of a real plate. `null` when the
3623
+ * mask is degenerate.
3624
+ */
3625
+ function quadFromMask(mask, width, height) {
3626
+ let minSum = Number.POSITIVE_INFINITY;
3627
+ let maxSum = Number.NEGATIVE_INFINITY;
3628
+ let minDif = Number.POSITIVE_INFINITY;
3629
+ let maxDif = Number.NEGATIVE_INFINITY;
3630
+ let tl = null;
3631
+ let br = null;
3632
+ let bl = null;
3633
+ let tr = null;
3634
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
3635
+ if (mask[y * width + x] !== 1) continue;
3636
+ const s = x + y;
3637
+ const d = x - y;
3638
+ if (s < minSum) {
3639
+ minSum = s;
3640
+ tl = {
3641
+ x,
3642
+ y
3643
+ };
3644
+ }
3645
+ if (s > maxSum) {
3646
+ maxSum = s;
3647
+ br = {
3648
+ x,
3649
+ y
3650
+ };
3651
+ }
3652
+ if (d < minDif) {
3653
+ minDif = d;
3654
+ bl = {
3655
+ x,
3656
+ y
3657
+ };
3658
+ }
3659
+ if (d > maxDif) {
3660
+ maxDif = d;
3661
+ tr = {
3662
+ x,
3663
+ y
3664
+ };
3665
+ }
3666
+ }
3667
+ if (!tl || !tr || !br || !bl) return null;
3668
+ const w = Math.max(dist(tl, tr), dist(bl, br));
3669
+ const h = Math.max(dist(tl, bl), dist(tr, br));
3670
+ if (w < MIN_QUAD_WIDTH_PX || h < MIN_QUAD_HEIGHT_PX) return null;
3671
+ return {
3672
+ tl,
3673
+ tr,
3674
+ br,
3675
+ bl
3676
+ };
3677
+ }
3678
+ function dist(a, b) {
3679
+ return Math.hypot(a.x - b.x, a.y - b.y);
3680
+ }
3681
+ /**
3682
+ * Bilinear sample of the source quadrilateral onto an `outW × outH` canvas.
3683
+ * The mapping is the standard bilinear-in-(u,v) interpolation of the four
3684
+ * corners — the same family as the rotation warp in `plate-deskew.ts`, and
3685
+ * exact for the affine case. Out-of-bounds samples clamp to the edge pixel.
3686
+ */
3687
+ function samplePerspective(src, quad, outW, outH) {
3688
+ const { data, width: sw, height: sh, channels: ch } = src;
3689
+ const out = new Uint8Array(outW * outH * ch);
3690
+ const maxX = sw - 1;
3691
+ const maxY = sh - 1;
3692
+ for (let oy = 0; oy < outH; oy++) {
3693
+ const v = (oy + .5) / outH;
3694
+ for (let ox = 0; ox < outW; ox++) {
3695
+ const u = (ox + .5) / outW;
3696
+ const topX = quad.tl.x + (quad.tr.x - quad.tl.x) * u;
3697
+ const topY = quad.tl.y + (quad.tr.y - quad.tl.y) * u;
3698
+ const botX = quad.bl.x + (quad.br.x - quad.bl.x) * u;
3699
+ const botY = quad.bl.y + (quad.br.y - quad.bl.y) * u;
3700
+ const sx = Math.min(maxX, Math.max(0, topX + (botX - topX) * v));
3701
+ const sy = Math.min(maxY, Math.max(0, topY + (botY - topY) * v));
3702
+ const x0 = Math.floor(sx);
3703
+ const y0 = Math.floor(sy);
3704
+ const x1 = x0 < maxX ? x0 + 1 : x0;
3705
+ const y1 = y0 < maxY ? y0 + 1 : y0;
3706
+ const fx = sx - x0;
3707
+ const fy = sy - y0;
3708
+ const w00 = (1 - fx) * (1 - fy);
3709
+ const w10 = fx * (1 - fy);
3710
+ const w01 = (1 - fx) * fy;
3711
+ const w11 = fx * fy;
3712
+ const i00 = (y0 * sw + x0) * ch;
3713
+ const i10 = (y0 * sw + x1) * ch;
3714
+ const i01 = (y1 * sw + x0) * ch;
3715
+ const i11 = (y1 * sw + x1) * ch;
3716
+ const o = (oy * outW + ox) * ch;
3717
+ 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;
3718
+ }
3719
+ }
3720
+ return {
3721
+ data: out,
3722
+ width: outW,
3723
+ height: outH,
3724
+ channels: ch
3725
+ };
3726
+ }
3727
+ function toSharpChannels(channels) {
3728
+ switch (channels) {
3729
+ case 1: return 1;
3730
+ case 2: return 2;
3731
+ case 4: return 4;
3732
+ default: return 3;
3733
+ }
3734
+ }
3735
+ /**
3736
+ * Locate the plate blob inside `jpeg` and return it rectified to the canonical
3737
+ * {@link PLATE_STRIP_ASPECT} × {@link PLATE_STRIP_HEIGHT} strip.
3738
+ *
3739
+ * `null` — meaning "caller keeps its own tile" — on: a decode failure, a tile
3740
+ * under 16 × 8, no bright component, a component that is a speck or the whole
3741
+ * tile, or a quad outside the plate aspect band. Never throws, never returns a
3742
+ * strip it is not confident about: the un-warped tile is the floor, not a
3743
+ * guess.
3744
+ */
3745
+ async function warpPlateJpeg(jpeg, opts = {}) {
3746
+ const raw = await sharp(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
3747
+ if (!raw) return null;
3748
+ const { data, info } = raw;
3749
+ if (info.width < 16 || info.height < 8) return null;
3750
+ const rgb = {
3751
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
3752
+ width: info.width,
3753
+ height: info.height,
3754
+ channels: info.channels
3755
+ };
3756
+ const gray = toGrayscale(rgb);
3757
+ const small = downscaleGray(gray, BLOB_SEARCH_MAX_DIM);
3758
+ const scale = small.width / gray.width;
3759
+ const mask = largestBrightComponent(small, Math.max(otsuThreshold(small), percentile(small, 55)));
3760
+ if (!mask) return null;
3761
+ let area = 0;
3762
+ for (let i = 0; i < mask.length; i++) area += mask[i];
3763
+ const frac = area / (small.width * small.height);
3764
+ if (frac < MIN_BLOB_AREA_FRAC || frac > MAX_BLOB_AREA_FRAC) return null;
3765
+ const q = quadFromMask(mask, small.width, small.height);
3766
+ if (!q) return null;
3767
+ const up = (p) => ({
3768
+ x: p.x / scale,
3769
+ y: p.y / scale
3770
+ });
3771
+ const tl = up(q.tl);
3772
+ const tr = up(q.tr);
3773
+ const br = up(q.br);
3774
+ const bl = up(q.bl);
3775
+ const quadW = Math.max(dist(tl, tr), dist(bl, br));
3776
+ const quadH = Math.max(dist(tl, bl), dist(tr, br));
3777
+ if (quadW < MIN_QUAD_WIDTH_PX || quadH < MIN_QUAD_HEIGHT_PX) return null;
3778
+ const ratio = quadW / quadH;
3779
+ if (ratio < MIN_QUAD_ASPECT || ratio > MAX_QUAD_ASPECT) return null;
3780
+ const inset = (a, b) => ({
3781
+ x: a.x + (b.x - a.x) * LEFT_INSET_FRAC,
3782
+ y: a.y + (b.y - a.y) * LEFT_INSET_FRAC
3783
+ });
3784
+ const warped = {
3785
+ tl: inset(tl, tr),
3786
+ tr,
3787
+ br,
3788
+ bl: inset(bl, br)
3789
+ };
3790
+ const outH = 64;
3791
+ const strip = samplePerspective(rgb, warped, Math.round(outH * PLATE_STRIP_ASPECT), outH);
3792
+ try {
3793
+ return {
3794
+ jpeg: await sharp(Buffer.from(strip.data), { raw: {
3795
+ width: strip.width,
3796
+ height: strip.height,
3797
+ channels: toSharpChannels(strip.channels)
3798
+ } }).jpeg({ quality: opts.quality ?? 92 }).toBuffer(),
3799
+ width: strip.width,
3800
+ height: strip.height
3801
+ };
3802
+ } catch {
3803
+ return null;
3804
+ }
3805
+ }
3806
+ //#endregion
3358
3807
  //#region src/detection-pipeline/pipeline/result-assembler.ts
3359
3808
  /** L2 norm of a raw embedding vector. */
3360
3809
  function vectorMagnitude(v) {
@@ -3438,6 +3887,13 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3438
3887
  const passesClass = (c) => (enabledSet === null || enabledSet.has(c.class)) && !rejectSet.has(c.class.toLowerCase());
3439
3888
  const minConfidence = typeof childStep.settings?.["minConfidence"] === "number" ? childStep.settings["minConfidence"] : 0;
3440
3889
  const winner = output.classifications[0];
3890
+ if (winner && !passesClass(winner) && winner.score >= minConfidence) ctx.onClassifierClassDropped?.({
3891
+ stepId: childStep.stepId,
3892
+ modelId: childStep.modelId,
3893
+ droppedClass: winner.class,
3894
+ score: winner.score,
3895
+ reason: enabledSet !== null && !enabledSet.has(winner.class) ? "class-not-enabled" : "reject-class"
3896
+ });
3441
3897
  if (winner && passesClass(winner) && winner.score >= minConfidence) parent.ownLabels.push({
3442
3898
  label: winner.class,
3443
3899
  score: winner.score,
@@ -3677,36 +4133,6 @@ var MIN_FACE_DETECTION_FLOOR_PX = 6;
3677
4133
  * above the downscaled detection resolution while bounding the crop payload.
3678
4134
  */
3679
4135
  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
4136
  function isEnrichmentOutput(output) {
3711
4137
  return output.kind === "classifications" || output.kind === "embedding" || output.kind === "text";
3712
4138
  }
@@ -3761,6 +4187,10 @@ var PipelineExecutor = class {
3761
4187
  /** Bounds the full-frame-guard drop log: first example per (device,
3762
4188
  * class), then a counted summary per minute. See `drop-sampler.ts`. */
3763
4189
  dropSampler = new DropSampler();
4190
+ /** Bounds the classifier class-filter drop log the same way, keyed per
4191
+ * (device, `step:class`) — an excluded species firing on every frame of a
4192
+ * track must be a line and a counter, not wallpaper. */
4193
+ classFilterDropSampler = new DropSampler();
3764
4194
  constructor(opts) {
3765
4195
  this.opts = opts;
3766
4196
  }
@@ -3829,7 +4259,30 @@ var PipelineExecutor = class {
3829
4259
  const idGen = new FrameIdGenerator();
3830
4260
  const ctx = {
3831
4261
  idGen,
3832
- details
4262
+ details,
4263
+ onClassifierClassDropped: (drop) => {
4264
+ const sample = this.classFilterDropSampler.record(deviceId, `${drop.stepId}:${drop.droppedClass}`);
4265
+ if (sample.logExample) this.opts.logger?.info("classifier winner excluded by class filter — label dropped", {
4266
+ tags: { deviceId },
4267
+ meta: {
4268
+ step: drop.stepId,
4269
+ model: drop.modelId,
4270
+ droppedClass: drop.droppedClass,
4271
+ score: Number(drop.score.toFixed(3)),
4272
+ reason: drop.reason,
4273
+ hint: "detection keeps its macro class; adjust the step enabledClasses to change this"
4274
+ }
4275
+ });
4276
+ if (sample.summaryCount !== null) this.opts.logger?.info("classifier class filter: still dropping", {
4277
+ tags: { deviceId },
4278
+ meta: {
4279
+ step: drop.stepId,
4280
+ droppedClass: drop.droppedClass,
4281
+ suppressed: sample.summaryCount,
4282
+ windowMs: DROP_SUMMARY_INTERVAL_MS
4283
+ }
4284
+ });
4285
+ }
3833
4286
  };
3834
4287
  let discarded;
3835
4288
  const rootShed = {
@@ -4234,20 +4687,22 @@ var PipelineExecutor = class {
4234
4687
  cropH = crop.height;
4235
4688
  }
4236
4689
  } else {
4237
- const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4690
+ const cropPolicy = resolvePlateOcrCropPolicy(resolveEffectivePostprocessor(child.definition, child.modelId));
4691
+ const cropBbox = cropPolicy ? expandBbox(parentDetection.bbox, cropPolicy.marginFraction, imageWidth, imageHeight) : parentDetection.bbox;
4692
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, cropBbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4238
4693
  if (nativeChild) {
4239
4694
  cropJpegBuf = nativeChild.jpeg;
4240
4695
  cropW = nativeChild.width;
4241
4696
  cropH = nativeChild.height;
4242
4697
  } else {
4243
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
4698
+ const crop = await cropJpeg(fullFrameJpeg, cropBbox, imageWidth, imageHeight);
4244
4699
  cropJpegBuf = crop.jpeg;
4245
4700
  cropW = crop.width;
4246
4701
  cropH = crop.height;
4247
4702
  }
4248
- if (child.definition.postprocessor === "ctc") {
4703
+ if (cropPolicy) {
4249
4704
  const plateLongSidePx = Math.max(cropW, cropH);
4250
- if (plateLongSidePx < MIN_PLATE_OCR_LONG_SIDE_PX) {
4705
+ if (plateLongSidePx < cropPolicy.minLongSidePx) {
4251
4706
  this.opts.logger?.info("plate ROI too small for OCR — skipping", {
4252
4707
  tags: { deviceId },
4253
4708
  meta: {
@@ -4255,28 +4710,44 @@ var PipelineExecutor = class {
4255
4710
  model: child.modelId,
4256
4711
  plateLongSidePx: Math.round(plateLongSidePx),
4257
4712
  plateShortSidePx: Math.round(Math.min(cropW, cropH)),
4258
- minPlateLongSidePx: MIN_PLATE_OCR_LONG_SIDE_PX,
4713
+ minPlateLongSidePx: cropPolicy.minLongSidePx,
4714
+ marginFraction: cropPolicy.marginFraction,
4259
4715
  source: nativeChild ? "native" : "detection-tile"
4260
4716
  }
4261
4717
  });
4262
4718
  continue;
4263
4719
  }
4264
4720
  }
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", {
4721
+ if (cropPolicy?.rectify && child.settings?.["rectify"] !== false) try {
4722
+ const warped = await warpPlateJpeg(cropJpegBuf);
4723
+ if (warped) {
4724
+ cropJpegBuf = warped.jpeg;
4725
+ cropW = warped.width;
4726
+ cropH = warped.height;
4727
+ this.opts.logger?.info("plate crop warped before OCR", {
4272
4728
  tags: { deviceId },
4273
4729
  meta: {
4274
4730
  step: child.stepId,
4275
- angleDeg: Number(rectified.angleDeg.toFixed(2)),
4276
4731
  width: cropW,
4277
4732
  height: cropH
4278
4733
  }
4279
4734
  });
4735
+ } else {
4736
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
4737
+ if (rectified) {
4738
+ cropJpegBuf = rectified.jpeg;
4739
+ cropW = rectified.width;
4740
+ cropH = rectified.height;
4741
+ this.opts.logger?.info("plate crop deskewed before OCR", {
4742
+ tags: { deviceId },
4743
+ meta: {
4744
+ step: child.stepId,
4745
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
4746
+ width: cropW,
4747
+ height: cropH
4748
+ }
4749
+ });
4750
+ }
4280
4751
  }
4281
4752
  } catch {}
4282
4753
  }
@@ -4479,6 +4950,81 @@ function mergeCustomModels(staticModels, customModels) {
4479
4950
  return merged;
4480
4951
  }
4481
4952
  //#endregion
4953
+ //#region src/detection-pipeline/cluster-model-resolution.ts
4954
+ /**
4955
+ * The cluster model rule, enforced on the node that has to obey it.
4956
+ *
4957
+ * ## What is different about a cluster-scoped step
4958
+ *
4959
+ * For every other step, `resolveModelForFormat` is a kindness: a chosen model
4960
+ * with no build for this node's format is quietly swapped for the format's
4961
+ * default, one info line, and the camera keeps detecting. That is the right
4962
+ * trade when the output is a box, because two nodes producing boxes with two
4963
+ * detectors are not producing anything that gets compared.
4964
+ *
4965
+ * `face-embedding` and `clip-embedding` produce vectors that land in ONE shared
4966
+ * index and are ranked against each other by cosine. There the same kindness is
4967
+ * corruption: the substituted node writes a second feature space into the index
4968
+ * and nothing in the data says so. So for a step whose definition declares
4969
+ * `modelScope: 'cluster'` this module replaces the substitution with a
4970
+ * **refusal** — the rule `runStatelessStep` already applies to a pinned model
4971
+ * ([D54](../../../../docs/decisions/adr-0056.md)), extended to the live detail
4972
+ * plane, which is the other writer into the same indexes.
4973
+ *
4974
+ * ## What "refused" means here, exactly
4975
+ *
4976
+ * It means **refused to substitute**, not "dropped". The step keeps the pinned
4977
+ * model id, and the placement machinery that already exists —
4978
+ * `resolveStepsForDispatch`'s node-local engine fallback and the detail plane's
4979
+ * `resolveDetailStepDevice` jump — keeps doing its job, both of which key off a
4980
+ * step whose model has no build for the candidate format. That is how a
4981
+ * `face-embedding` dispatched at a Coral gets rescued onto the node's onnx
4982
+ * engine today, and taking that rescue away in the name of safety would stop
4983
+ * producing faces on every mixed-accelerator node.
4984
+ *
4985
+ * What the refusal removes is the LAST resort: after the fallback and the jump,
4986
+ * a step whose model this node truly cannot serve fails loudly
4987
+ * (`assertStepsRunnable`, D39) instead of quietly running a different encoder.
4988
+ *
4989
+ * A refusal is COUNTED and logged by the caller, with `tags: { deviceId }`.
4990
+ * Silence would read as "never happened" (CLAUDE.md: a branch that drops work
4991
+ * logs it).
4992
+ */
4993
+ var defaultLookup = (stepId) => {
4994
+ try {
4995
+ return getStepDefinition(stepId);
4996
+ } catch {
4997
+ return null;
4998
+ }
4999
+ };
5000
+ /**
5001
+ * Decide what `stepId` runs on a node dispatching in `format`.
5002
+ *
5003
+ * An unknown step is `node-scoped`: a custom addon owns its own format
5004
+ * compatibility and this module has no catalog entry to reason about.
5005
+ */
5006
+ function resolveClusterStepModel(stepId, clusterModels, format, lookup = defaultLookup) {
5007
+ const def = lookup(stepId);
5008
+ if (def === null || def.modelScope !== "cluster") return { kind: "node-scoped" };
5009
+ const modelId = resolveClusterStepModelId(stepId, clusterModels);
5010
+ if (modelId === null) return { kind: "node-scoped" };
5011
+ const entry = def.models.find((m) => m.id === modelId);
5012
+ if (entry === void 0) return {
5013
+ kind: "pinned",
5014
+ modelId
5015
+ };
5016
+ if (entry.formats[format] !== void 0) return {
5017
+ kind: "pinned",
5018
+ modelId
5019
+ };
5020
+ return {
5021
+ kind: "refused",
5022
+ modelId,
5023
+ format,
5024
+ formatsShipped: Object.keys(entry.formats)
5025
+ };
5026
+ }
5027
+ //#endregion
4482
5028
  //#region src/detection-pipeline/step-resolution.ts
4483
5029
  /**
4484
5030
  * Walk `steps` (recursing into children) and return exactly the
@@ -4580,10 +5126,11 @@ function collectUnknownAddonIssues(steps, getStepDef = getStepDefinition) {
4580
5126
  * `diagnostics.substitutions` instead of happening silently. The caller
4581
5127
  * (the provider) owns logging + dedup for both.
4582
5128
  */
4583
- function resolveInputSteps(steps, format, engine) {
5129
+ function resolveInputSteps(steps, format, engine, clusterModels) {
4584
5130
  const resolvedSteps = [];
4585
5131
  const unknownAddonIds = [];
4586
5132
  const substitutions = [];
5133
+ const clusterRefusals = [];
4587
5134
  for (const s of steps) {
4588
5135
  let def;
4589
5136
  try {
@@ -4592,6 +5139,35 @@ function resolveInputSteps(steps, format, engine) {
4592
5139
  unknownAddonIds.push(s.addonId);
4593
5140
  continue;
4594
5141
  }
5142
+ if (clusterModels !== void 0) {
5143
+ const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
5144
+ if (verdict.kind !== "node-scoped") {
5145
+ if (verdict.kind === "refused") clusterRefusals.push({
5146
+ stepId: s.addonId,
5147
+ modelId: verdict.modelId,
5148
+ format: verdict.format,
5149
+ formatsShipped: verdict.formatsShipped
5150
+ });
5151
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
5152
+ if (childResult) {
5153
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
5154
+ substitutions.push(...childResult.diagnostics.substitutions);
5155
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
5156
+ }
5157
+ resolvedSteps.push({
5158
+ addonId: s.addonId,
5159
+ addonName: def.name,
5160
+ slot: def.slot,
5161
+ inputClasses: def.inputClasses ?? [],
5162
+ outputClasses: def.outputClasses,
5163
+ enabled: s.enabled,
5164
+ modelId: verdict.modelId,
5165
+ children: childResult ? childResult.steps : [],
5166
+ ...s.settings ? { settings: { ...s.settings } } : {}
5167
+ });
5168
+ continue;
5169
+ }
5170
+ }
4595
5171
  const chosenModelId = s.modelId ?? null;
4596
5172
  const runningModelId = chosenModelId === null && engine !== void 0 && s.addonId === "object-detection" ? resolveDefaultDetectionModel(engine.backend, engine.device, format) : resolveModelForFormat(s.addonId, chosenModelId, format);
4597
5173
  if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
@@ -4600,10 +5176,11 @@ function resolveInputSteps(steps, format, engine) {
4600
5176
  running: runningModelId,
4601
5177
  format
4602
5178
  });
4603
- const childResult = s.children ? resolveInputSteps(s.children, format, engine) : null;
5179
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
4604
5180
  if (childResult) {
4605
5181
  unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4606
5182
  substitutions.push(...childResult.diagnostics.substitutions);
5183
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
4607
5184
  }
4608
5185
  resolvedSteps.push({
4609
5186
  addonId: s.addonId,
@@ -4621,7 +5198,8 @@ function resolveInputSteps(steps, format, engine) {
4621
5198
  steps: resolvedSteps,
4622
5199
  diagnostics: {
4623
5200
  unknownAddonIds,
4624
- substitutions
5201
+ substitutions,
5202
+ clusterRefusals
4625
5203
  }
4626
5204
  };
4627
5205
  }
@@ -4664,6 +5242,94 @@ function applyZoneRuleGate(result, zones, rules) {
4664
5242
  };
4665
5243
  }
4666
5244
  //#endregion
5245
+ //#region src/shared/cluster-model-source.ts
5246
+ /**
5247
+ * Where the runner gets the ONE cluster model choice per cluster-scoped step.
5248
+ *
5249
+ * The sibling of `crop-convention-source.ts`, and deliberately built the same
5250
+ * way, because it answers the neighbouring half of the same question: the crop
5251
+ * convention decides WHAT pixels the encoder sees, this decides WHICH encoder
5252
+ * sees them. Both are cluster-wide, both are read by the runner from the
5253
+ * `pipeline-orchestrator` global settings over the hub-routed `addon-settings`
5254
+ * cap, both pass NO `nodeId`.
5255
+ *
5256
+ * ## Deliberately NOT node-scoped, and not on the attach config
5257
+ *
5258
+ * With a `nodeId` the settings cap would project that node's scoped value and
5259
+ * two nodes could embed into one index with two encoders — the original defect,
5260
+ * relocated to the transport. And the attach config is the wrong carrier for
5261
+ * the same reason it was wrong for the crop: eleven builders construct a
5262
+ * `RunnerCameraConfig`, a cluster-wide constant threaded through eleven
5263
+ * builders eventually reaches ten of them, and the cameras attached by the
5264
+ * forgotten one quietly write a foreign feature space into the shared index.
5265
+ *
5266
+ * ## Why the failure mode keeps the last value
5267
+ *
5268
+ * A failed read must never revert to the registry default MID-PASS: that would
5269
+ * silently change what every remaining vector of that dispatch means, which is
5270
+ * the exact class of harm the scope exists to prevent. So a failure keeps the
5271
+ * last known row and says so, loudly, once per failure.
5272
+ */
5273
+ /**
5274
+ * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
5275
+ * convention and the native-frame lease: hub-resident and neutral between the
5276
+ * addons that read it (post-analysis pins it for a re-embed pass, the runner
5277
+ * enforces it live — neither should own a value the other depends on).
5278
+ */
5279
+ var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
5280
+ /** TTL-cached read of the cluster model row. */
5281
+ var ClusterModelSource = class {
5282
+ models = DEFAULT_CLUSTER_STEP_MODELS;
5283
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
5284
+ inFlight = null;
5285
+ logger;
5286
+ now;
5287
+ ttlMs;
5288
+ constructor(options) {
5289
+ this.logger = options.logger;
5290
+ this.now = options.now ?? (() => Date.now());
5291
+ this.ttlMs = options.ttlMs ?? 6e4;
5292
+ }
5293
+ /** The row in force right now — the registry defaults until a read lands. */
5294
+ current() {
5295
+ return this.models;
5296
+ }
5297
+ /**
5298
+ * Re-read if the cached row has expired. Concurrent callers share one
5299
+ * in-flight read; a failure keeps the LAST KNOWN row.
5300
+ */
5301
+ async refresh(api) {
5302
+ if (api === void 0) return this.models;
5303
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
5304
+ this.inFlight ??= this.read(api).finally(() => {
5305
+ this.inFlight = null;
5306
+ });
5307
+ await this.inFlight;
5308
+ return this.models;
5309
+ }
5310
+ async read(api) {
5311
+ try {
5312
+ const next = pickClusterStepModels(await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID }));
5313
+ this.lastReadAtMs = this.now();
5314
+ const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
5315
+ if (changed.length > 0) {
5316
+ this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
5317
+ stepId,
5318
+ from: this.models[stepId],
5319
+ to: next[stepId]
5320
+ })) } });
5321
+ this.models = next;
5322
+ }
5323
+ } catch (err) {
5324
+ this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
5325
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
5326
+ inUse: { ...this.models },
5327
+ error: err instanceof Error ? err.message : String(err)
5328
+ } });
5329
+ }
5330
+ }
5331
+ };
5332
+ //#endregion
4667
5333
  //#region src/detection-pipeline/provider.ts
4668
5334
  /**
4669
5335
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4929,6 +5595,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4929
5595
  */
4930
5596
  customModelsCache = null;
4931
5597
  /**
5598
+ * SYNC custom-model lookup handed to every live {@link EngineFactory} so
5599
+ * `buildPoolModelConfigForStep` can resolve an operator-registered model at
5600
+ * pool-load time. Reads the {@link customModelsCache} snapshot — every load
5601
+ * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
5602
+ * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
5603
+ * the snapshot is fresh when the pool asks. Without this seam a selected
5604
+ * custom model failed the WHOLE pool applyConfig with
5605
+ * `Model "X" not found in step "Y" catalog`.
5606
+ */
5607
+ customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
5608
+ /**
4932
5609
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4933
5610
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4934
5611
  * synchronously per frame so detections inside an `exclude` zone
@@ -4938,6 +5615,19 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4938
5615
  deviceProxies = /* @__PURE__ */ new Map();
4939
5616
  proxyUnsubs = /* @__PURE__ */ new Map();
4940
5617
  /**
5618
+ * The cluster-wide model row for `modelScope: 'cluster'` steps
5619
+ * (`face-embedding`, `clip-embedding`) — read from the orchestrator's global
5620
+ * settings, cluster-scoped, TTL-cached. See `shared/cluster-model-source.ts`
5621
+ * for why it is not carried on the attach config and never node-scoped.
5622
+ *
5623
+ * Refreshed on the ASYNC boundary (`runPipeline`) and read SYNCHRONOUSLY by
5624
+ * `inputStepsToPipelineSteps`, which runs per dispatch and must not await.
5625
+ */
5626
+ clusterModels = new ClusterModelSource({ logger: {
5627
+ info: (message, extras) => this.log.info(message, extras),
5628
+ warn: (message, extras) => this.log.warn(message, extras)
5629
+ } });
5630
+ /**
4941
5631
  * Last logged "step configured" signature — used to dedupe the
4942
5632
  * per-step debug trail so it only fires on actual config CHANGE
4943
5633
  * (not on every frame). `runPipeline` is called once per decoded
@@ -6024,6 +6714,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6024
6714
  };
6025
6715
  }
6026
6716
  async runPipeline(input, onProgress) {
6717
+ await this.clusterModels.refresh(this.addonCtx?.api);
6027
6718
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
6028
6719
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6029
6720
  const emit = (message, extra) => {
@@ -6208,7 +6899,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6208
6899
  modelsDir: this.modelsDir,
6209
6900
  logger: this.log.child("engine-override"),
6210
6901
  pythonPath: this.executorOptions.pythonPath ?? "",
6211
- provisioning: this.executorOptions.provisioning
6902
+ provisioning: this.executorOptions.provisioning,
6903
+ resolveCustomModel: this.customModelResolver
6212
6904
  });
6213
6905
  const initPromise = newFactory.initialize([]);
6214
6906
  this.overrideCache = {
@@ -6388,7 +7080,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6388
7080
  modelsDir: this.modelsDir,
6389
7081
  logger: this.log.child("engine-override"),
6390
7082
  pythonPath: this.executorOptions.pythonPath ?? "",
6391
- provisioning: this.executorOptions.provisioning
7083
+ provisioning: this.executorOptions.provisioning,
7084
+ resolveCustomModel: this.customModelResolver
6392
7085
  });
6393
7086
  const initPromise = newFactory.initialize([]);
6394
7087
  this.overrideCache = {
@@ -6482,8 +7175,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6482
7175
  inputStepsToPipelineSteps(steps, format = this.currentEngine.format, engine = {
6483
7176
  backend: this.currentEngine.backend,
6484
7177
  device: this.currentEngine.device ?? null
6485
- }) {
6486
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine);
7178
+ }, clusterRefusalsOut) {
7179
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current());
7180
+ if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
6487
7181
  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
7182
  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
7183
  step: sub.addonId,
@@ -6493,6 +7187,34 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6493
7187
  } }));
6494
7188
  return resolvedSteps;
6495
7189
  }
7190
+ /**
7191
+ * Announce the cluster-scope refusals of the resolution that is actually
7192
+ * being RETURNED — never of a speculative one.
7193
+ *
7194
+ * A refusal here means: this dispatch will run `stepId` with the cluster's
7195
+ * chosen model on an engine that ships no build of it, and NOTHING was
7196
+ * substituted in its place. `assertStepsRunnable` has already had its say
7197
+ * (it throws when the catalog ships nothing at all for the format); what
7198
+ * this line adds is the case the throw cannot see — the catalog has a build,
7199
+ * just not of the model the cluster chose — which without this line is the
7200
+ * quietest possible way to stop producing vectors.
7201
+ *
7202
+ * Deduped per (step, model, engine): a fleet of cameras on one incompatible
7203
+ * node produces one line, not one per track.
7204
+ */
7205
+ logClusterRefusals(refusals, deviceId, engineLabel) {
7206
+ 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", {
7207
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7208
+ meta: {
7209
+ step: refusal.stepId,
7210
+ clusterModelId: refusal.modelId,
7211
+ format: refusal.format,
7212
+ formatsShipped: [...refusal.formatsShipped],
7213
+ engine: engineLabel,
7214
+ remedy: `choose a cluster model with a ${refusal.format} build, or take this engine out of the rotation for ${refusal.stepId}`
7215
+ }
7216
+ }));
7217
+ }
6496
7218
  /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6497
7219
  logLiveDispatchIssueOnce(key, fn) {
6498
7220
  if (this.loggedLiveDispatchIssues.has(key)) return;
@@ -6554,8 +7276,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6554
7276
  const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6555
7277
  const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6556
7278
  if (deviceKey === void 0 || deviceEngine === void 0) {
6557
- const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7279
+ const refusals = [];
7280
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, refusals);
6558
7281
  this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
7282
+ this.logClusterRefusals(refusals, deviceId, nodeEngineLabel);
6559
7283
  return {
6560
7284
  deviceKey: void 0,
6561
7285
  steps
@@ -6566,14 +7290,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6566
7290
  backend: deviceEngine.backend,
6567
7291
  device: deviceEngine.device ?? null
6568
7292
  };
6569
- const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
7293
+ const deviceRefusals = [];
7294
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
6570
7295
  const framePlane = args.plane === "frame";
6571
7296
  const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6572
7297
  if (zeroBuild.length === 0) {
6573
- if (!framePlane) return {
6574
- deviceKey,
6575
- steps
6576
- };
7298
+ if (!framePlane) {
7299
+ this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
7300
+ return {
7301
+ deviceKey,
7302
+ steps
7303
+ };
7304
+ }
6577
7305
  const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6578
7306
  if (pruneResult.prunedAddonIds.length > 0) {
6579
7307
  const prunedList = pruneResult.prunedAddonIds.join(",");
@@ -6586,6 +7314,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6586
7314
  }
6587
7315
  }));
6588
7316
  }
7317
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
6589
7318
  return {
6590
7319
  deviceKey,
6591
7320
  steps: pruneResult.steps
@@ -6601,8 +7330,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6601
7330
  fallbackEngine: nodeEngineLabel
6602
7331
  }
6603
7332
  }));
6604
- const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7333
+ const fallbackRefusals = [];
7334
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, fallbackRefusals);
6605
7335
  this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
7336
+ this.logClusterRefusals(fallbackRefusals, deviceId, nodeEngineLabel);
6606
7337
  return {
6607
7338
  deviceKey: void 0,
6608
7339
  steps: fallbackSteps
@@ -6663,6 +7394,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6663
7394
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6664
7395
  if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
6665
7396
  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`);
7397
+ 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
7398
  this.log.info("Downloading model for step", { meta: {
6667
7399
  modelId: step.modelId,
6668
7400
  format,
@@ -7068,7 +7800,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7068
7800
  modelsDir: this.modelsDir,
7069
7801
  logger: this.log.child("engine"),
7070
7802
  pythonPath: this.executorOptions.pythonPath ?? "",
7071
- provisioning: this.executorOptions.provisioning
7803
+ provisioning: this.executorOptions.provisioning,
7804
+ resolveCustomModel: this.customModelResolver
7072
7805
  });
7073
7806
  try {
7074
7807
  await factory.initialize([]);
@@ -7148,7 +7881,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7148
7881
  modelsDir: this.modelsDir,
7149
7882
  logger: this.log.child(`engine:${deviceKey}`),
7150
7883
  pythonPath: this.executorOptions.pythonPath ?? "",
7151
- provisioning: this.executorOptions.provisioning
7884
+ provisioning: this.executorOptions.provisioning,
7885
+ resolveCustomModel: this.customModelResolver
7152
7886
  });
7153
7887
  try {
7154
7888
  await factory.initialize([]);
@@ -7896,7 +8630,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7896
8630
  type: "select",
7897
8631
  key: "batchMode",
7898
8632
  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.",
8633
+ 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
8634
  options: [
7901
8635
  {
7902
8636
  value: "",
@@ -7940,7 +8674,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7940
8674
  type: "slider",
7941
8675
  key: "maxBatchSize",
7942
8676
  label: "Max batch size",
7943
- description: "Cap on items per batched predict call. Auto = backend default. Applies to THIS node only.",
8677
+ 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
8678
  min: 1,
7945
8679
  max: 16,
7946
8680
  step: 1,