@camstack/addon-pipeline 1.2.103 → 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 +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-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
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-C11WuNUP.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-Cq_NeC4o.js");
7
- const require_lazy_sharp = require("../lazy-sharp-RxUs6on_.js");
8
- const require_process_memory = require("../process-memory-DOjQ3MgC.js");
9
- const require_addon_utils = require("../addon-utils-CLc6yHCN.js");
5
+ const require_dist = require("../dist-gLAVhbvO.js");
6
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BUhYY39J.js");
7
+ const require_lazy_sharp = require("../lazy-sharp-OiAUva0g.js");
8
+ const require_process_memory = require("../process-memory-DY4RHcTj.js");
9
+ const require_addon_utils = require("../addon-utils-DRbRzrDy.js");
10
10
  let sharp = require("sharp");
11
11
  sharp = require_dist.__toESM(sharp);
12
12
  let node_child_process = require("node:child_process");
@@ -91,6 +91,11 @@ function probedToHardwareInfo(hw) {
91
91
  * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
92
92
  * still spelled out per row so a reader never has to guess which default a
93
93
  * missing key would have hit.
94
+ *
95
+ * `batchMode` / `maxBatchSize` are REQUEST GROUPING, not shape. Whether a graph
96
+ * may keep a dynamic batch axis is a hardware fact derived at load from the
97
+ * (engine, device) pair — `_static_batch_required` in `inference_pool.py`, D202 —
98
+ * and appears nowhere in this registry, in the catalog, or in any operator knob.
94
99
  */
95
100
  var RUNTIME_DETAIL = {
96
101
  onnx: {
@@ -1210,6 +1215,7 @@ function serializeModelConfig(config) {
1210
1215
  if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
1211
1216
  if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
1212
1217
  if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
1218
+ if (config.minCharProbability !== void 0) result["minCharProbability"] = config.minCharProbability;
1213
1219
  if (config.numClasses) result["numClasses"] = config.numClasses;
1214
1220
  if (config.strides) result["strides"] = config.strides;
1215
1221
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1557,6 +1563,21 @@ var PipelineModelManager = class {
1557
1563
  }
1558
1564
  };
1559
1565
  //#endregion
1566
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1567
+ function resolveEffectivePostprocessor(definition, modelId) {
1568
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1569
+ }
1570
+ /**
1571
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1572
+ * required for CUSTOM (operator-registered) models, which are NOT in
1573
+ * `definition.models` and would otherwise silently fall back to the step
1574
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1575
+ * error anywhere, just zero detections).
1576
+ */
1577
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1578
+ return entry?.postprocessor ?? definition.postprocessor;
1579
+ }
1580
+ //#endregion
1560
1581
  //#region src/detection-pipeline/engine/resolve-device-engine.ts
1561
1582
  /**
1562
1583
  * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
@@ -1963,49 +1984,70 @@ var EngineFactory = class {
1963
1984
  await this.poolManager.applyConfig(steps);
1964
1985
  }
1965
1986
  buildPoolModelConfig(stepId, modelId, poolRuntime) {
1966
- const def = require_event_loop_stall_monitor.getStepDefinition(stepId);
1967
- const modelEntry = def.models.find((m) => m.id === modelId);
1968
- if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog`);
1969
- const format = RUNTIME_TO_FORMAT[poolRuntime];
1970
- if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
1971
- const formatEntry = modelEntry.formats[format];
1972
- if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
1973
- const urlParts = formatEntry.url.split("/");
1974
- const filename = urlParts[urlParts.length - 1] ?? `${modelId}.${format}`;
1975
- const modelPath = `${this.opts.modelsDir}/${filename}`;
1976
- const inputWidth = modelEntry.inputSize.width;
1977
- const inputHeight = modelEntry.inputSize.height;
1978
- const inputSize = Math.max(inputWidth, inputHeight);
1979
- const inputChannels = modelEntry.inputChannels ?? 3;
1980
- const labels = readModelLabels(this.opts.modelsDir, modelEntry) ?? def.labels;
1981
- return {
1982
- path: modelPath,
1983
- inputSize,
1984
- inputWidth,
1985
- inputHeight,
1986
- inputChannels,
1987
- preprocessMode: modelEntry.preprocessMode ?? "letterbox",
1988
- inputNormalization: modelEntry.inputNormalization,
1989
- outputProbabilities: modelEntry.outputProbabilities,
1990
- postprocessor: modelEntry.postprocessor ?? def.postprocessor,
1991
- confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
1992
- nmsIouThreshold: this.opts.nmsIouThreshold ?? .45,
1993
- labels,
1994
- charset: def.charset,
1995
- plateRegion: def.plateRegion,
1996
- minTextLength: def.minTextLength,
1997
- minTextConfidence: def.minTextConfidence,
1998
- numClasses: labels?.length,
1999
- strides: def.postprocessor === "scrfd" ? [
2000
- 8,
2001
- 16,
2002
- 32
2003
- ] : void 0,
2004
- maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2005
- device: this.opts.engine.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2006
- };
1987
+ return buildPoolModelConfigForStep({
1988
+ stepId,
1989
+ modelId,
1990
+ poolRuntime,
1991
+ modelsDir: this.opts.modelsDir,
1992
+ nmsIouThreshold: this.opts.nmsIouThreshold,
1993
+ device: this.opts.engine.device,
1994
+ resolveCustomModel: this.opts.resolveCustomModel
1995
+ });
2007
1996
  }
2008
1997
  };
1998
+ /**
1999
+ * Build the Python-pool model config for `(stepId, modelId)`.
2000
+ *
2001
+ * Resolution order: static step catalog first, then the injected
2002
+ * custom-model resolver (operator-registered models from the
2003
+ * `custom-model-registry` cap). A model found in NEITHER throws — that step
2004
+ * cannot load.
2005
+ */
2006
+ function buildPoolModelConfigForStep(inputs) {
2007
+ const { stepId, modelId, poolRuntime, modelsDir } = inputs;
2008
+ const def = require_event_loop_stall_monitor.getStepDefinition(stepId);
2009
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? inputs.resolveCustomModel?.(stepId, modelId);
2010
+ if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2011
+ const format = RUNTIME_TO_FORMAT[poolRuntime];
2012
+ if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2013
+ const formatEntry = modelEntry.formats[format];
2014
+ if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2015
+ const urlParts = formatEntry.url.split("/");
2016
+ const modelPath = `${modelsDir}/${urlParts[urlParts.length - 1] ?? `${modelId}.${format}`}`;
2017
+ const inputWidth = modelEntry.inputSize.width;
2018
+ const inputHeight = modelEntry.inputSize.height;
2019
+ const inputSize = Math.max(inputWidth, inputHeight);
2020
+ const inputChannels = modelEntry.inputChannels ?? 3;
2021
+ const entryLabels = modelEntry.labels.length > 0 ? modelEntry.labels.map((l) => l.id) : void 0;
2022
+ const labels = readModelLabels(modelsDir, modelEntry) ?? entryLabels ?? def.labels;
2023
+ return {
2024
+ path: modelPath,
2025
+ inputSize,
2026
+ inputWidth,
2027
+ inputHeight,
2028
+ inputChannels,
2029
+ preprocessMode: modelEntry.preprocessMode ?? "letterbox",
2030
+ inputNormalization: modelEntry.inputNormalization,
2031
+ outputProbabilities: modelEntry.outputProbabilities,
2032
+ postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2033
+ confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2034
+ nmsIouThreshold: inputs.nmsIouThreshold ?? .45,
2035
+ labels,
2036
+ charset: def.charset,
2037
+ plateRegion: def.plateRegion,
2038
+ minTextLength: def.minTextLength,
2039
+ minTextConfidence: def.minTextConfidence,
2040
+ minCharProbability: def.minCharProbability,
2041
+ numClasses: labels?.length,
2042
+ strides: def.postprocessor === "scrfd" ? [
2043
+ 8,
2044
+ 16,
2045
+ 32
2046
+ ] : void 0,
2047
+ maskThreshold: def.postprocessor === "saliency" ? .5 : void 0,
2048
+ device: inputs.device ?? (poolRuntime === "coreml" ? "all" : void 0)
2049
+ };
2050
+ }
2009
2051
  //#endregion
2010
2052
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
2011
2053
  var IdlePoolReaper = class {
@@ -3270,7 +3312,7 @@ function estimateSkewAngle(gray, opts = {}) {
3270
3312
  score: bestScore
3271
3313
  };
3272
3314
  }
3273
- function toSharpChannels(channels) {
3315
+ function toSharpChannels$1(channels) {
3274
3316
  switch (channels) {
3275
3317
  case 1: return 1;
3276
3318
  case 2: return 2;
@@ -3351,7 +3393,7 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3351
3393
  jpeg: await (0, sharp.default)(Buffer.from(band.data), { raw: {
3352
3394
  width: band.width,
3353
3395
  height: band.height,
3354
- channels: toSharpChannels(band.channels)
3396
+ channels: toSharpChannels$1(band.channels)
3355
3397
  } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
3356
3398
  width: band.width,
3357
3399
  height: band.height,
@@ -3362,6 +3404,413 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3362
3404
  }
3363
3405
  }
3364
3406
  //#endregion
3407
+ //#region src/detection-pipeline/pipeline/plate-ocr-crop-policy.ts
3408
+ /**
3409
+ * Keyed by POSTPROCESSOR, not by model id: the postprocessor is what identifies
3410
+ * the reader family, it is already what the executor branches on, and a new
3411
+ * model of an existing family inherits the right pixels without touching this
3412
+ * table. A new reader family with a new postprocessor has to add a row — which
3413
+ * is the point, since it cannot be given the wrong one by default.
3414
+ */
3415
+ var PLATE_OCR_CROP_POLICIES = {
3416
+ ctc: {
3417
+ marginFraction: 0,
3418
+ rectify: true,
3419
+ minLongSidePx: 85
3420
+ },
3421
+ "plate-slots": {
3422
+ marginFraction: .15,
3423
+ rectify: false,
3424
+ minLongSidePx: 70
3425
+ }
3426
+ };
3427
+ /**
3428
+ * The crop policy for a step child, or `null` when the child is not a plate
3429
+ * reader at all (a face embedder, a classifier — nothing here applies to them).
3430
+ */
3431
+ function resolvePlateOcrCropPolicy(postprocessor) {
3432
+ return PLATE_OCR_CROP_POLICIES[postprocessor] ?? null;
3433
+ }
3434
+ /**
3435
+ * Grow a bbox by `marginFraction` of its own size on each side, clamped to the
3436
+ * frame. Returns the input unchanged at margin 0 or on a degenerate box, so the
3437
+ * un-margined readers keep the exact pixels they had.
3438
+ */
3439
+ function expandBbox(bbox, marginFraction, imageWidth, imageHeight) {
3440
+ const [x1, y1, x2, y2] = bbox;
3441
+ const width = x2 - x1;
3442
+ const height = y2 - y1;
3443
+ if (!(marginFraction > 0) || !(width > 0) || !(height > 0)) return [
3444
+ x1,
3445
+ y1,
3446
+ x2,
3447
+ y2
3448
+ ];
3449
+ return [
3450
+ Math.max(0, x1 - width * marginFraction),
3451
+ Math.max(0, y1 - height * marginFraction),
3452
+ Math.min(imageWidth, x2 + width * marginFraction),
3453
+ Math.min(imageHeight, y2 + height * marginFraction)
3454
+ ];
3455
+ }
3456
+ //#endregion
3457
+ //#region src/detection-pipeline/pipeline/plate-warp.ts
3458
+ /**
3459
+ * Plate-crop WARP — perspective rectification of a licence-plate ROI to a
3460
+ * canonical, aspect-locked text strip, run immediately before the CTC reader.
3461
+ *
3462
+ * WHY this exists on top of {@link ./plate-deskew.rectifyPlateJpeg}
3463
+ * ----------------------------------------------------------------
3464
+ * The detector hands OCR an AXIS-ALIGNED box around a plate that, on an oblique
3465
+ * camera, is a rotated trapezoid inside it. Two things then destroy the read,
3466
+ * and neither is a resolution problem:
3467
+ *
3468
+ * 1. **Aspect.** A German plate is 4.7 : 1. The boxes cam 617 produces measure
3469
+ * 1.3 – 2.0 : 1 — the box is 2-3× too tall because it circumscribes a
3470
+ * tilted rectangle. The recognizer resizes its input to a fixed 64px height
3471
+ * preserving aspect, so a 1.5 : 1 tile becomes ~96px wide: seven glyphs get
3472
+ * ~13px each. The same plate rectified to 4.7 : 1 arrives ~300px wide.
3473
+ * 2. **Tilt.** The 2026-08 deskew estimator searches ±16° and REJECTS an
3474
+ * unbracketed optimum (D186) — correct, and it means it returns 0° on this
3475
+ * camera, whose plates sit at 24-31°. The rotation is real; the projection
3476
+ * profile just cannot measure it from a box that is mostly car.
3477
+ *
3478
+ * The plate itself is the one thing in the ROI that is reliably a large, bright,
3479
+ * convex blob. So: Otsu-threshold the tile, keep the largest bright connected
3480
+ * component, take its four extreme corners, and sample that quadrilateral onto
3481
+ * a fixed 4.7 : 1 × 64px canvas with bilinear interpolation. One pass, no
3482
+ * OpenCV, no new dependency — sharp only decodes and re-encodes, exactly as
3483
+ * `plate-deskew.ts` and `native-child-crop.ts` do.
3484
+ *
3485
+ * MEASURED on 21 ground-truthed cam-617 plate crops (9 distinct vehicles,
3486
+ * 2026-08-20 offline replay against the production `vgg_english_g2` weights and
3487
+ * the production CTC postprocessor):
3488
+ *
3489
+ * | chain | exact | char acc | false on 18 junk ROIs |
3490
+ * | deskew only + 100px gate (live today) | 0/21 | 4.8 % | 0 |
3491
+ * | deskew + text-band + 3× upscale | 0/21 | 16.3 % | 0 |
3492
+ * | THIS warp + seal-tolerant gate, 85px | 4/21 | 42.9 % | 0 |
3493
+ *
3494
+ * The four exact reads are four DIFFERENT vehicles at 91-98px native plate
3495
+ * width — every one of them SKIPPED entirely by the old 100px floor. Upscaling
3496
+ * and contrast work (CLAHE, autocontrast, unsharp, 2/3/4× lanczos) moved char
3497
+ * accuracy by ≤2 points on their own and are NOT included: the geometry was the
3498
+ * whole story.
3499
+ */
3500
+ /**
3501
+ * Canonical strip geometry. 4.7 : 1 is the German plate ratio (520 × 110 mm);
3502
+ * 64px is the recognizer's own input height, so the warp writes exactly the
3503
+ * pixels the model reads and no resampling is wasted.
3504
+ */
3505
+ var PLATE_STRIP_ASPECT = 4.7;
3506
+ /**
3507
+ * Longest side the blob search runs at. The connected-component pass is O(px)
3508
+ * with an explicit stack; capping it keeps a 600px plate ROI at ~1ms.
3509
+ */
3510
+ var BLOB_SEARCH_MAX_DIM = 200;
3511
+ /**
3512
+ * The blob must be a plausible plate: neither a speck nor the whole tile, and
3513
+ * within a wide-but-finite aspect band. Outside these the caller keeps its
3514
+ * un-warped tile — a bad quad is far worse than no quad.
3515
+ */
3516
+ var MIN_BLOB_AREA_FRAC = .04;
3517
+ var MAX_BLOB_AREA_FRAC = .96;
3518
+ var MIN_QUAD_ASPECT = 1.5;
3519
+ var MAX_QUAD_ASPECT = 9;
3520
+ var MIN_QUAD_WIDTH_PX = 12;
3521
+ var MIN_QUAD_HEIGHT_PX = 5;
3522
+ /**
3523
+ * Fraction of the plate width dropped from the LEFT edge before sampling.
3524
+ * The bright blob's left boundary sits on the plate's black frame / the blue EU
3525
+ * band shoulder, and that edge column decodes as a spurious leading glyph
3526
+ * ("IDND7578" for DN-D 7578). Swept end to end over the ground-truth set at
3527
+ * 0 / 2 / 3.5 / 4 / 5 / 6 / 7 %: 4 % is the peak (4/21 exact, 42.9 % char), the
3528
+ * neighbours give 3/21, and 7 % starts eating the first character (0/21).
3529
+ */
3530
+ var LEFT_INSET_FRAC = .04;
3531
+ /** Otsu's between-class-variance threshold of a grayscale image. */
3532
+ function otsuThreshold(gray) {
3533
+ const hist = new Float64Array(256);
3534
+ for (let i = 0; i < gray.data.length; i++) {
3535
+ const v = gray.data[i];
3536
+ hist[v] = (hist[v] ?? 0) + 1;
3537
+ }
3538
+ const total = gray.data.length;
3539
+ if (total === 0) return 128;
3540
+ let sum = 0;
3541
+ for (let v = 0; v < 256; v++) sum += v * hist[v];
3542
+ let wB = 0;
3543
+ let sumB = 0;
3544
+ let best = 0;
3545
+ let bestVar = -1;
3546
+ for (let v = 0; v < 256; v++) {
3547
+ wB += hist[v];
3548
+ if (wB === 0) continue;
3549
+ const wF = total - wB;
3550
+ if (wF === 0) break;
3551
+ sumB += v * hist[v];
3552
+ const mB = sumB / wB;
3553
+ const mF = (sum - sumB) / wF;
3554
+ const between = wB * wF * (mB - mF) * (mB - mF);
3555
+ if (between > bestVar) {
3556
+ bestVar = between;
3557
+ best = v;
3558
+ }
3559
+ }
3560
+ return best;
3561
+ }
3562
+ /** `p`-th percentile (0-100) of a grayscale image, via its 256-bin histogram. */
3563
+ function percentile(gray, p) {
3564
+ const hist = new Int32Array(256);
3565
+ for (let i = 0; i < gray.data.length; i++) hist[gray.data[i]] = (hist[gray.data[i]] ?? 0) + 1;
3566
+ const target = gray.data.length * p / 100;
3567
+ let seen = 0;
3568
+ for (let v = 0; v < 256; v++) {
3569
+ seen += hist[v] ?? 0;
3570
+ if (seen >= target) return v;
3571
+ }
3572
+ return 255;
3573
+ }
3574
+ /**
3575
+ * Mask (1/0, same dimensions) of the LARGEST 4-connected component of pixels
3576
+ * strictly above `threshold`. `null` when nothing clears the threshold.
3577
+ * Iterative flood fill — a plate ROI is small and recursion depth is not.
3578
+ */
3579
+ function largestBrightComponent(gray, threshold) {
3580
+ const { data, width, height } = gray;
3581
+ const n = width * height;
3582
+ const label = new Int32Array(n);
3583
+ const stack = [];
3584
+ let current = 0;
3585
+ let bestLabel = 0;
3586
+ let bestSize = 0;
3587
+ for (let seed = 0; seed < n; seed++) {
3588
+ if (label[seed] !== 0 || data[seed] <= threshold) continue;
3589
+ current += 1;
3590
+ label[seed] = current;
3591
+ stack.length = 0;
3592
+ stack.push(seed);
3593
+ let size = 0;
3594
+ while (stack.length > 0) {
3595
+ const i = stack.pop();
3596
+ size += 1;
3597
+ const x = i % width;
3598
+ const y = (i - x) / width;
3599
+ if (x > 0 && label[i - 1] === 0 && data[i - 1] > threshold) {
3600
+ label[i - 1] = current;
3601
+ stack.push(i - 1);
3602
+ }
3603
+ if (x < width - 1 && label[i + 1] === 0 && data[i + 1] > threshold) {
3604
+ label[i + 1] = current;
3605
+ stack.push(i + 1);
3606
+ }
3607
+ if (y > 0 && label[i - width] === 0 && data[i - width] > threshold) {
3608
+ label[i - width] = current;
3609
+ stack.push(i - width);
3610
+ }
3611
+ if (y < height - 1 && label[i + width] === 0 && data[i + width] > threshold) {
3612
+ label[i + width] = current;
3613
+ stack.push(i + width);
3614
+ }
3615
+ }
3616
+ if (size > bestSize) {
3617
+ bestSize = size;
3618
+ bestLabel = current;
3619
+ }
3620
+ }
3621
+ if (bestLabel === 0) return null;
3622
+ const out = new Uint8Array(n);
3623
+ for (let i = 0; i < n; i++) out[i] = label[i] === bestLabel ? 1 : 0;
3624
+ return out;
3625
+ }
3626
+ /**
3627
+ * Four corners of a mask, taken as the extreme points of `x+y` (top-left /
3628
+ * bottom-right) and `x−y` (top-right / bottom-left). Exact for a rotated
3629
+ * rectangle and stable under the blob noise of a real plate. `null` when the
3630
+ * mask is degenerate.
3631
+ */
3632
+ function quadFromMask(mask, width, height) {
3633
+ let minSum = Number.POSITIVE_INFINITY;
3634
+ let maxSum = Number.NEGATIVE_INFINITY;
3635
+ let minDif = Number.POSITIVE_INFINITY;
3636
+ let maxDif = Number.NEGATIVE_INFINITY;
3637
+ let tl = null;
3638
+ let br = null;
3639
+ let bl = null;
3640
+ let tr = null;
3641
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
3642
+ if (mask[y * width + x] !== 1) continue;
3643
+ const s = x + y;
3644
+ const d = x - y;
3645
+ if (s < minSum) {
3646
+ minSum = s;
3647
+ tl = {
3648
+ x,
3649
+ y
3650
+ };
3651
+ }
3652
+ if (s > maxSum) {
3653
+ maxSum = s;
3654
+ br = {
3655
+ x,
3656
+ y
3657
+ };
3658
+ }
3659
+ if (d < minDif) {
3660
+ minDif = d;
3661
+ bl = {
3662
+ x,
3663
+ y
3664
+ };
3665
+ }
3666
+ if (d > maxDif) {
3667
+ maxDif = d;
3668
+ tr = {
3669
+ x,
3670
+ y
3671
+ };
3672
+ }
3673
+ }
3674
+ if (!tl || !tr || !br || !bl) return null;
3675
+ const w = Math.max(dist(tl, tr), dist(bl, br));
3676
+ const h = Math.max(dist(tl, bl), dist(tr, br));
3677
+ if (w < MIN_QUAD_WIDTH_PX || h < MIN_QUAD_HEIGHT_PX) return null;
3678
+ return {
3679
+ tl,
3680
+ tr,
3681
+ br,
3682
+ bl
3683
+ };
3684
+ }
3685
+ function dist(a, b) {
3686
+ return Math.hypot(a.x - b.x, a.y - b.y);
3687
+ }
3688
+ /**
3689
+ * Bilinear sample of the source quadrilateral onto an `outW × outH` canvas.
3690
+ * The mapping is the standard bilinear-in-(u,v) interpolation of the four
3691
+ * corners — the same family as the rotation warp in `plate-deskew.ts`, and
3692
+ * exact for the affine case. Out-of-bounds samples clamp to the edge pixel.
3693
+ */
3694
+ function samplePerspective(src, quad, outW, outH) {
3695
+ const { data, width: sw, height: sh, channels: ch } = src;
3696
+ const out = new Uint8Array(outW * outH * ch);
3697
+ const maxX = sw - 1;
3698
+ const maxY = sh - 1;
3699
+ for (let oy = 0; oy < outH; oy++) {
3700
+ const v = (oy + .5) / outH;
3701
+ for (let ox = 0; ox < outW; ox++) {
3702
+ const u = (ox + .5) / outW;
3703
+ const topX = quad.tl.x + (quad.tr.x - quad.tl.x) * u;
3704
+ const topY = quad.tl.y + (quad.tr.y - quad.tl.y) * u;
3705
+ const botX = quad.bl.x + (quad.br.x - quad.bl.x) * u;
3706
+ const botY = quad.bl.y + (quad.br.y - quad.bl.y) * u;
3707
+ const sx = Math.min(maxX, Math.max(0, topX + (botX - topX) * v));
3708
+ const sy = Math.min(maxY, Math.max(0, topY + (botY - topY) * v));
3709
+ const x0 = Math.floor(sx);
3710
+ const y0 = Math.floor(sy);
3711
+ const x1 = x0 < maxX ? x0 + 1 : x0;
3712
+ const y1 = y0 < maxY ? y0 + 1 : y0;
3713
+ const fx = sx - x0;
3714
+ const fy = sy - y0;
3715
+ const w00 = (1 - fx) * (1 - fy);
3716
+ const w10 = fx * (1 - fy);
3717
+ const w01 = (1 - fx) * fy;
3718
+ const w11 = fx * fy;
3719
+ const i00 = (y0 * sw + x0) * ch;
3720
+ const i10 = (y0 * sw + x1) * ch;
3721
+ const i01 = (y1 * sw + x0) * ch;
3722
+ const i11 = (y1 * sw + x1) * ch;
3723
+ const o = (oy * outW + ox) * ch;
3724
+ 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;
3725
+ }
3726
+ }
3727
+ return {
3728
+ data: out,
3729
+ width: outW,
3730
+ height: outH,
3731
+ channels: ch
3732
+ };
3733
+ }
3734
+ function toSharpChannels(channels) {
3735
+ switch (channels) {
3736
+ case 1: return 1;
3737
+ case 2: return 2;
3738
+ case 4: return 4;
3739
+ default: return 3;
3740
+ }
3741
+ }
3742
+ /**
3743
+ * Locate the plate blob inside `jpeg` and return it rectified to the canonical
3744
+ * {@link PLATE_STRIP_ASPECT} × {@link PLATE_STRIP_HEIGHT} strip.
3745
+ *
3746
+ * `null` — meaning "caller keeps its own tile" — on: a decode failure, a tile
3747
+ * under 16 × 8, no bright component, a component that is a speck or the whole
3748
+ * tile, or a quad outside the plate aspect band. Never throws, never returns a
3749
+ * strip it is not confident about: the un-warped tile is the floor, not a
3750
+ * guess.
3751
+ */
3752
+ async function warpPlateJpeg(jpeg, opts = {}) {
3753
+ const raw = await (0, sharp.default)(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
3754
+ if (!raw) return null;
3755
+ const { data, info } = raw;
3756
+ if (info.width < 16 || info.height < 8) return null;
3757
+ const rgb = {
3758
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
3759
+ width: info.width,
3760
+ height: info.height,
3761
+ channels: info.channels
3762
+ };
3763
+ const gray = toGrayscale(rgb);
3764
+ const small = downscaleGray(gray, BLOB_SEARCH_MAX_DIM);
3765
+ const scale = small.width / gray.width;
3766
+ const mask = largestBrightComponent(small, Math.max(otsuThreshold(small), percentile(small, 55)));
3767
+ if (!mask) return null;
3768
+ let area = 0;
3769
+ for (let i = 0; i < mask.length; i++) area += mask[i];
3770
+ const frac = area / (small.width * small.height);
3771
+ if (frac < MIN_BLOB_AREA_FRAC || frac > MAX_BLOB_AREA_FRAC) return null;
3772
+ const q = quadFromMask(mask, small.width, small.height);
3773
+ if (!q) return null;
3774
+ const up = (p) => ({
3775
+ x: p.x / scale,
3776
+ y: p.y / scale
3777
+ });
3778
+ const tl = up(q.tl);
3779
+ const tr = up(q.tr);
3780
+ const br = up(q.br);
3781
+ const bl = up(q.bl);
3782
+ const quadW = Math.max(dist(tl, tr), dist(bl, br));
3783
+ const quadH = Math.max(dist(tl, bl), dist(tr, br));
3784
+ if (quadW < MIN_QUAD_WIDTH_PX || quadH < MIN_QUAD_HEIGHT_PX) return null;
3785
+ const ratio = quadW / quadH;
3786
+ if (ratio < MIN_QUAD_ASPECT || ratio > MAX_QUAD_ASPECT) return null;
3787
+ const inset = (a, b) => ({
3788
+ x: a.x + (b.x - a.x) * LEFT_INSET_FRAC,
3789
+ y: a.y + (b.y - a.y) * LEFT_INSET_FRAC
3790
+ });
3791
+ const warped = {
3792
+ tl: inset(tl, tr),
3793
+ tr,
3794
+ br,
3795
+ bl: inset(bl, br)
3796
+ };
3797
+ const outH = 64;
3798
+ const strip = samplePerspective(rgb, warped, Math.round(outH * PLATE_STRIP_ASPECT), outH);
3799
+ try {
3800
+ return {
3801
+ jpeg: await (0, sharp.default)(Buffer.from(strip.data), { raw: {
3802
+ width: strip.width,
3803
+ height: strip.height,
3804
+ channels: toSharpChannels(strip.channels)
3805
+ } }).jpeg({ quality: opts.quality ?? 92 }).toBuffer(),
3806
+ width: strip.width,
3807
+ height: strip.height
3808
+ };
3809
+ } catch {
3810
+ return null;
3811
+ }
3812
+ }
3813
+ //#endregion
3365
3814
  //#region src/detection-pipeline/pipeline/result-assembler.ts
3366
3815
  /** L2 norm of a raw embedding vector. */
3367
3816
  function vectorMagnitude(v) {
@@ -3445,6 +3894,13 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3445
3894
  const passesClass = (c) => (enabledSet === null || enabledSet.has(c.class)) && !rejectSet.has(c.class.toLowerCase());
3446
3895
  const minConfidence = typeof childStep.settings?.["minConfidence"] === "number" ? childStep.settings["minConfidence"] : 0;
3447
3896
  const winner = output.classifications[0];
3897
+ if (winner && !passesClass(winner) && winner.score >= minConfidence) ctx.onClassifierClassDropped?.({
3898
+ stepId: childStep.stepId,
3899
+ modelId: childStep.modelId,
3900
+ droppedClass: winner.class,
3901
+ score: winner.score,
3902
+ reason: enabledSet !== null && !enabledSet.has(winner.class) ? "class-not-enabled" : "reject-class"
3903
+ });
3448
3904
  if (winner && passesClass(winner) && winner.score >= minConfidence) parent.ownLabels.push({
3449
3905
  label: winner.class,
3450
3906
  score: winner.score,
@@ -3684,36 +4140,6 @@ var MIN_FACE_DETECTION_FLOOR_PX = 6;
3684
4140
  * above the downscaled detection resolution while bounding the crop payload.
3685
4141
  */
3686
4142
  var NATIVE_FACE_CROP_MAX_WIDTH = 320;
3687
- /**
3688
- * Minimum LONG side (px) of the ACTUAL crop fed to the plate-OCR (`ctc`) leaf —
3689
- * the plate analogue of the face path's min-face-size gate.
3690
- *
3691
- * The long side, not the short one. A plate's information lives along its text
3692
- * run: the recognizer resizes the tile to a fixed 64px height and reads left to
3693
- * right, so what decides whether a glyph survives is how many horizontal pixels
3694
- * each character got. The previous 20px SHORT-side floor measured the wrong
3695
- * axis and passed essentially everything.
3696
- *
3697
- * The number comes from the 2026-08 audit of 11 cam-617 plate tracks with
3698
- * ground truth. Native long side vs read:
3699
- *
3700
- * 117px → "DNRO3O9" (truth DNRO309 — one glyph out, the only near-miss)
3701
- * 82px → "VONND9592" (truth DNDS597)
3702
- * 77px → "DEPNOQ1" (truth DNP1091)
3703
- * 76px → "VDHPXOE8" (truth DNPK838)
3704
- * 73px → "DAEOR2D8" (truth DNDR298)
3705
- * ≤62px → "I0IAZ", "EUSM9", "0IA3", "DYAGI2", "I0AA", "IC1E"
3706
- *
3707
- * Not one read under 100px resembled its plate, and the gap between 82 and 117
3708
- * is empty — 100 sits in it. Below this floor the OCR call is pure cost: it
3709
- * produces a label that is wrong, and a wrong plate label is worse than none.
3710
- *
3711
- * Measured on the NATIVE crop when the native ROI fetch succeeded (its
3712
- * dimensions ARE the plate's real native size — `nativeChildCrop`'s 1920 cap
3713
- * never binds for a plate), else on the fallback detection-space tile — which
3714
- * IS the input OCR would read, so the same floor applies honestly in both cases.
3715
- */
3716
- var MIN_PLATE_OCR_LONG_SIDE_PX = 100;
3717
4143
  function isEnrichmentOutput(output) {
3718
4144
  return output.kind === "classifications" || output.kind === "embedding" || output.kind === "text";
3719
4145
  }
@@ -3768,6 +4194,10 @@ var PipelineExecutor = class {
3768
4194
  /** Bounds the full-frame-guard drop log: first example per (device,
3769
4195
  * class), then a counted summary per minute. See `drop-sampler.ts`. */
3770
4196
  dropSampler = new DropSampler();
4197
+ /** Bounds the classifier class-filter drop log the same way, keyed per
4198
+ * (device, `step:class`) — an excluded species firing on every frame of a
4199
+ * track must be a line and a counter, not wallpaper. */
4200
+ classFilterDropSampler = new DropSampler();
3771
4201
  constructor(opts) {
3772
4202
  this.opts = opts;
3773
4203
  }
@@ -3836,7 +4266,30 @@ var PipelineExecutor = class {
3836
4266
  const idGen = new FrameIdGenerator();
3837
4267
  const ctx = {
3838
4268
  idGen,
3839
- details
4269
+ details,
4270
+ onClassifierClassDropped: (drop) => {
4271
+ const sample = this.classFilterDropSampler.record(deviceId, `${drop.stepId}:${drop.droppedClass}`);
4272
+ if (sample.logExample) this.opts.logger?.info("classifier winner excluded by class filter — label dropped", {
4273
+ tags: { deviceId },
4274
+ meta: {
4275
+ step: drop.stepId,
4276
+ model: drop.modelId,
4277
+ droppedClass: drop.droppedClass,
4278
+ score: Number(drop.score.toFixed(3)),
4279
+ reason: drop.reason,
4280
+ hint: "detection keeps its macro class; adjust the step enabledClasses to change this"
4281
+ }
4282
+ });
4283
+ if (sample.summaryCount !== null) this.opts.logger?.info("classifier class filter: still dropping", {
4284
+ tags: { deviceId },
4285
+ meta: {
4286
+ step: drop.stepId,
4287
+ droppedClass: drop.droppedClass,
4288
+ suppressed: sample.summaryCount,
4289
+ windowMs: DROP_SUMMARY_INTERVAL_MS
4290
+ }
4291
+ });
4292
+ }
3840
4293
  };
3841
4294
  let discarded;
3842
4295
  const rootShed = {
@@ -4241,20 +4694,22 @@ var PipelineExecutor = class {
4241
4694
  cropH = crop.height;
4242
4695
  }
4243
4696
  } else {
4244
- const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4697
+ const cropPolicy = resolvePlateOcrCropPolicy(resolveEffectivePostprocessor(child.definition, child.modelId));
4698
+ const cropBbox = cropPolicy ? expandBbox(parentDetection.bbox, cropPolicy.marginFraction, imageWidth, imageHeight) : parentDetection.bbox;
4699
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, cropBbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
4245
4700
  if (nativeChild) {
4246
4701
  cropJpegBuf = nativeChild.jpeg;
4247
4702
  cropW = nativeChild.width;
4248
4703
  cropH = nativeChild.height;
4249
4704
  } else {
4250
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
4705
+ const crop = await cropJpeg(fullFrameJpeg, cropBbox, imageWidth, imageHeight);
4251
4706
  cropJpegBuf = crop.jpeg;
4252
4707
  cropW = crop.width;
4253
4708
  cropH = crop.height;
4254
4709
  }
4255
- if (child.definition.postprocessor === "ctc") {
4710
+ if (cropPolicy) {
4256
4711
  const plateLongSidePx = Math.max(cropW, cropH);
4257
- if (plateLongSidePx < MIN_PLATE_OCR_LONG_SIDE_PX) {
4712
+ if (plateLongSidePx < cropPolicy.minLongSidePx) {
4258
4713
  this.opts.logger?.info("plate ROI too small for OCR — skipping", {
4259
4714
  tags: { deviceId },
4260
4715
  meta: {
@@ -4262,28 +4717,44 @@ var PipelineExecutor = class {
4262
4717
  model: child.modelId,
4263
4718
  plateLongSidePx: Math.round(plateLongSidePx),
4264
4719
  plateShortSidePx: Math.round(Math.min(cropW, cropH)),
4265
- minPlateLongSidePx: MIN_PLATE_OCR_LONG_SIDE_PX,
4720
+ minPlateLongSidePx: cropPolicy.minLongSidePx,
4721
+ marginFraction: cropPolicy.marginFraction,
4266
4722
  source: nativeChild ? "native" : "detection-tile"
4267
4723
  }
4268
4724
  });
4269
4725
  continue;
4270
4726
  }
4271
4727
  }
4272
- if (child.definition.postprocessor === "ctc" && child.settings?.["rectify"] !== false) try {
4273
- const rectified = await rectifyPlateJpeg(cropJpegBuf);
4274
- if (rectified) {
4275
- cropJpegBuf = rectified.jpeg;
4276
- cropW = rectified.width;
4277
- cropH = rectified.height;
4278
- this.opts.logger?.info("plate crop deskewed before OCR", {
4728
+ if (cropPolicy?.rectify && child.settings?.["rectify"] !== false) try {
4729
+ const warped = await warpPlateJpeg(cropJpegBuf);
4730
+ if (warped) {
4731
+ cropJpegBuf = warped.jpeg;
4732
+ cropW = warped.width;
4733
+ cropH = warped.height;
4734
+ this.opts.logger?.info("plate crop warped before OCR", {
4279
4735
  tags: { deviceId },
4280
4736
  meta: {
4281
4737
  step: child.stepId,
4282
- angleDeg: Number(rectified.angleDeg.toFixed(2)),
4283
4738
  width: cropW,
4284
4739
  height: cropH
4285
4740
  }
4286
4741
  });
4742
+ } else {
4743
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
4744
+ if (rectified) {
4745
+ cropJpegBuf = rectified.jpeg;
4746
+ cropW = rectified.width;
4747
+ cropH = rectified.height;
4748
+ this.opts.logger?.info("plate crop deskewed before OCR", {
4749
+ tags: { deviceId },
4750
+ meta: {
4751
+ step: child.stepId,
4752
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
4753
+ width: cropW,
4754
+ height: cropH
4755
+ }
4756
+ });
4757
+ }
4287
4758
  }
4288
4759
  } catch {}
4289
4760
  }
@@ -4486,6 +4957,81 @@ function mergeCustomModels(staticModels, customModels) {
4486
4957
  return merged;
4487
4958
  }
4488
4959
  //#endregion
4960
+ //#region src/detection-pipeline/cluster-model-resolution.ts
4961
+ /**
4962
+ * The cluster model rule, enforced on the node that has to obey it.
4963
+ *
4964
+ * ## What is different about a cluster-scoped step
4965
+ *
4966
+ * For every other step, `resolveModelForFormat` is a kindness: a chosen model
4967
+ * with no build for this node's format is quietly swapped for the format's
4968
+ * default, one info line, and the camera keeps detecting. That is the right
4969
+ * trade when the output is a box, because two nodes producing boxes with two
4970
+ * detectors are not producing anything that gets compared.
4971
+ *
4972
+ * `face-embedding` and `clip-embedding` produce vectors that land in ONE shared
4973
+ * index and are ranked against each other by cosine. There the same kindness is
4974
+ * corruption: the substituted node writes a second feature space into the index
4975
+ * and nothing in the data says so. So for a step whose definition declares
4976
+ * `modelScope: 'cluster'` this module replaces the substitution with a
4977
+ * **refusal** — the rule `runStatelessStep` already applies to a pinned model
4978
+ * ([D54](../../../../docs/decisions/adr-0056.md)), extended to the live detail
4979
+ * plane, which is the other writer into the same indexes.
4980
+ *
4981
+ * ## What "refused" means here, exactly
4982
+ *
4983
+ * It means **refused to substitute**, not "dropped". The step keeps the pinned
4984
+ * model id, and the placement machinery that already exists —
4985
+ * `resolveStepsForDispatch`'s node-local engine fallback and the detail plane's
4986
+ * `resolveDetailStepDevice` jump — keeps doing its job, both of which key off a
4987
+ * step whose model has no build for the candidate format. That is how a
4988
+ * `face-embedding` dispatched at a Coral gets rescued onto the node's onnx
4989
+ * engine today, and taking that rescue away in the name of safety would stop
4990
+ * producing faces on every mixed-accelerator node.
4991
+ *
4992
+ * What the refusal removes is the LAST resort: after the fallback and the jump,
4993
+ * a step whose model this node truly cannot serve fails loudly
4994
+ * (`assertStepsRunnable`, D39) instead of quietly running a different encoder.
4995
+ *
4996
+ * A refusal is COUNTED and logged by the caller, with `tags: { deviceId }`.
4997
+ * Silence would read as "never happened" (CLAUDE.md: a branch that drops work
4998
+ * logs it).
4999
+ */
5000
+ var defaultLookup = (stepId) => {
5001
+ try {
5002
+ return require_event_loop_stall_monitor.getStepDefinition(stepId);
5003
+ } catch {
5004
+ return null;
5005
+ }
5006
+ };
5007
+ /**
5008
+ * Decide what `stepId` runs on a node dispatching in `format`.
5009
+ *
5010
+ * An unknown step is `node-scoped`: a custom addon owns its own format
5011
+ * compatibility and this module has no catalog entry to reason about.
5012
+ */
5013
+ function resolveClusterStepModel(stepId, clusterModels, format, lookup = defaultLookup) {
5014
+ const def = lookup(stepId);
5015
+ if (def === null || def.modelScope !== "cluster") return { kind: "node-scoped" };
5016
+ const modelId = require_dist.resolveClusterStepModelId(stepId, clusterModels);
5017
+ if (modelId === null) return { kind: "node-scoped" };
5018
+ const entry = def.models.find((m) => m.id === modelId);
5019
+ if (entry === void 0) return {
5020
+ kind: "pinned",
5021
+ modelId
5022
+ };
5023
+ if (entry.formats[format] !== void 0) return {
5024
+ kind: "pinned",
5025
+ modelId
5026
+ };
5027
+ return {
5028
+ kind: "refused",
5029
+ modelId,
5030
+ format,
5031
+ formatsShipped: Object.keys(entry.formats)
5032
+ };
5033
+ }
5034
+ //#endregion
4489
5035
  //#region src/detection-pipeline/step-resolution.ts
4490
5036
  /**
4491
5037
  * Walk `steps` (recursing into children) and return exactly the
@@ -4587,10 +5133,11 @@ function collectUnknownAddonIssues(steps, getStepDef = require_event_loop_stall_
4587
5133
  * `diagnostics.substitutions` instead of happening silently. The caller
4588
5134
  * (the provider) owns logging + dedup for both.
4589
5135
  */
4590
- function resolveInputSteps(steps, format, engine) {
5136
+ function resolveInputSteps(steps, format, engine, clusterModels) {
4591
5137
  const resolvedSteps = [];
4592
5138
  const unknownAddonIds = [];
4593
5139
  const substitutions = [];
5140
+ const clusterRefusals = [];
4594
5141
  for (const s of steps) {
4595
5142
  let def;
4596
5143
  try {
@@ -4599,6 +5146,35 @@ function resolveInputSteps(steps, format, engine) {
4599
5146
  unknownAddonIds.push(s.addonId);
4600
5147
  continue;
4601
5148
  }
5149
+ if (clusterModels !== void 0) {
5150
+ const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
5151
+ if (verdict.kind !== "node-scoped") {
5152
+ if (verdict.kind === "refused") clusterRefusals.push({
5153
+ stepId: s.addonId,
5154
+ modelId: verdict.modelId,
5155
+ format: verdict.format,
5156
+ formatsShipped: verdict.formatsShipped
5157
+ });
5158
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
5159
+ if (childResult) {
5160
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
5161
+ substitutions.push(...childResult.diagnostics.substitutions);
5162
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
5163
+ }
5164
+ resolvedSteps.push({
5165
+ addonId: s.addonId,
5166
+ addonName: def.name,
5167
+ slot: def.slot,
5168
+ inputClasses: def.inputClasses ?? [],
5169
+ outputClasses: def.outputClasses,
5170
+ enabled: s.enabled,
5171
+ modelId: verdict.modelId,
5172
+ children: childResult ? childResult.steps : [],
5173
+ ...s.settings ? { settings: { ...s.settings } } : {}
5174
+ });
5175
+ continue;
5176
+ }
5177
+ }
4602
5178
  const chosenModelId = s.modelId ?? null;
4603
5179
  const runningModelId = chosenModelId === null && engine !== void 0 && s.addonId === "object-detection" ? resolveDefaultDetectionModel(engine.backend, engine.device, format) : require_event_loop_stall_monitor.resolveModelForFormat(s.addonId, chosenModelId, format);
4604
5180
  if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
@@ -4607,10 +5183,11 @@ function resolveInputSteps(steps, format, engine) {
4607
5183
  running: runningModelId,
4608
5184
  format
4609
5185
  });
4610
- const childResult = s.children ? resolveInputSteps(s.children, format, engine) : null;
5186
+ const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels) : null;
4611
5187
  if (childResult) {
4612
5188
  unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4613
5189
  substitutions.push(...childResult.diagnostics.substitutions);
5190
+ clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
4614
5191
  }
4615
5192
  resolvedSteps.push({
4616
5193
  addonId: s.addonId,
@@ -4628,7 +5205,8 @@ function resolveInputSteps(steps, format, engine) {
4628
5205
  steps: resolvedSteps,
4629
5206
  diagnostics: {
4630
5207
  unknownAddonIds,
4631
- substitutions
5208
+ substitutions,
5209
+ clusterRefusals
4632
5210
  }
4633
5211
  };
4634
5212
  }
@@ -4671,6 +5249,94 @@ function applyZoneRuleGate(result, zones, rules) {
4671
5249
  };
4672
5250
  }
4673
5251
  //#endregion
5252
+ //#region src/shared/cluster-model-source.ts
5253
+ /**
5254
+ * Where the runner gets the ONE cluster model choice per cluster-scoped step.
5255
+ *
5256
+ * The sibling of `crop-convention-source.ts`, and deliberately built the same
5257
+ * way, because it answers the neighbouring half of the same question: the crop
5258
+ * convention decides WHAT pixels the encoder sees, this decides WHICH encoder
5259
+ * sees them. Both are cluster-wide, both are read by the runner from the
5260
+ * `pipeline-orchestrator` global settings over the hub-routed `addon-settings`
5261
+ * cap, both pass NO `nodeId`.
5262
+ *
5263
+ * ## Deliberately NOT node-scoped, and not on the attach config
5264
+ *
5265
+ * With a `nodeId` the settings cap would project that node's scoped value and
5266
+ * two nodes could embed into one index with two encoders — the original defect,
5267
+ * relocated to the transport. And the attach config is the wrong carrier for
5268
+ * the same reason it was wrong for the crop: eleven builders construct a
5269
+ * `RunnerCameraConfig`, a cluster-wide constant threaded through eleven
5270
+ * builders eventually reaches ten of them, and the cameras attached by the
5271
+ * forgotten one quietly write a foreign feature space into the shared index.
5272
+ *
5273
+ * ## Why the failure mode keeps the last value
5274
+ *
5275
+ * A failed read must never revert to the registry default MID-PASS: that would
5276
+ * silently change what every remaining vector of that dispatch means, which is
5277
+ * the exact class of harm the scope exists to prevent. So a failure keeps the
5278
+ * last known row and says so, loudly, once per failure.
5279
+ */
5280
+ /**
5281
+ * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
5282
+ * convention and the native-frame lease: hub-resident and neutral between the
5283
+ * addons that read it (post-analysis pins it for a re-embed pass, the runner
5284
+ * enforces it live — neither should own a value the other depends on).
5285
+ */
5286
+ var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
5287
+ /** TTL-cached read of the cluster model row. */
5288
+ var ClusterModelSource = class {
5289
+ models = require_dist.DEFAULT_CLUSTER_STEP_MODELS;
5290
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
5291
+ inFlight = null;
5292
+ logger;
5293
+ now;
5294
+ ttlMs;
5295
+ constructor(options) {
5296
+ this.logger = options.logger;
5297
+ this.now = options.now ?? (() => Date.now());
5298
+ this.ttlMs = options.ttlMs ?? 6e4;
5299
+ }
5300
+ /** The row in force right now — the registry defaults until a read lands. */
5301
+ current() {
5302
+ return this.models;
5303
+ }
5304
+ /**
5305
+ * Re-read if the cached row has expired. Concurrent callers share one
5306
+ * in-flight read; a failure keeps the LAST KNOWN row.
5307
+ */
5308
+ async refresh(api) {
5309
+ if (api === void 0) return this.models;
5310
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
5311
+ this.inFlight ??= this.read(api).finally(() => {
5312
+ this.inFlight = null;
5313
+ });
5314
+ await this.inFlight;
5315
+ return this.models;
5316
+ }
5317
+ async read(api) {
5318
+ try {
5319
+ const next = require_dist.pickClusterStepModels(await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID }));
5320
+ this.lastReadAtMs = this.now();
5321
+ const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
5322
+ if (changed.length > 0) {
5323
+ this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
5324
+ stepId,
5325
+ from: this.models[stepId],
5326
+ to: next[stepId]
5327
+ })) } });
5328
+ this.models = next;
5329
+ }
5330
+ } catch (err) {
5331
+ this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
5332
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
5333
+ inUse: { ...this.models },
5334
+ error: err instanceof Error ? err.message : String(err)
5335
+ } });
5336
+ }
5337
+ }
5338
+ };
5339
+ //#endregion
4674
5340
  //#region src/detection-pipeline/provider.ts
4675
5341
  /**
4676
5342
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4936,6 +5602,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4936
5602
  */
4937
5603
  customModelsCache = null;
4938
5604
  /**
5605
+ * SYNC custom-model lookup handed to every live {@link EngineFactory} so
5606
+ * `buildPoolModelConfigForStep` can resolve an operator-registered model at
5607
+ * pool-load time. Reads the {@link customModelsCache} snapshot — every load
5608
+ * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
5609
+ * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
5610
+ * the snapshot is fresh when the pool asks. Without this seam a selected
5611
+ * custom model failed the WHOLE pool applyConfig with
5612
+ * `Model "X" not found in step "Y" catalog`.
5613
+ */
5614
+ customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
5615
+ /**
4939
5616
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4940
5617
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4941
5618
  * synchronously per frame so detections inside an `exclude` zone
@@ -4945,6 +5622,19 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4945
5622
  deviceProxies = /* @__PURE__ */ new Map();
4946
5623
  proxyUnsubs = /* @__PURE__ */ new Map();
4947
5624
  /**
5625
+ * The cluster-wide model row for `modelScope: 'cluster'` steps
5626
+ * (`face-embedding`, `clip-embedding`) — read from the orchestrator's global
5627
+ * settings, cluster-scoped, TTL-cached. See `shared/cluster-model-source.ts`
5628
+ * for why it is not carried on the attach config and never node-scoped.
5629
+ *
5630
+ * Refreshed on the ASYNC boundary (`runPipeline`) and read SYNCHRONOUSLY by
5631
+ * `inputStepsToPipelineSteps`, which runs per dispatch and must not await.
5632
+ */
5633
+ clusterModels = new ClusterModelSource({ logger: {
5634
+ info: (message, extras) => this.log.info(message, extras),
5635
+ warn: (message, extras) => this.log.warn(message, extras)
5636
+ } });
5637
+ /**
4948
5638
  * Last logged "step configured" signature — used to dedupe the
4949
5639
  * per-step debug trail so it only fires on actual config CHANGE
4950
5640
  * (not on every frame). `runPipeline` is called once per decoded
@@ -6031,6 +6721,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6031
6721
  };
6032
6722
  }
6033
6723
  async runPipeline(input, onProgress) {
6724
+ await this.clusterModels.refresh(this.addonCtx?.api);
6034
6725
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
6035
6726
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6036
6727
  const emit = (message, extra) => {
@@ -6215,7 +6906,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6215
6906
  modelsDir: this.modelsDir,
6216
6907
  logger: this.log.child("engine-override"),
6217
6908
  pythonPath: this.executorOptions.pythonPath ?? "",
6218
- provisioning: this.executorOptions.provisioning
6909
+ provisioning: this.executorOptions.provisioning,
6910
+ resolveCustomModel: this.customModelResolver
6219
6911
  });
6220
6912
  const initPromise = newFactory.initialize([]);
6221
6913
  this.overrideCache = {
@@ -6395,7 +7087,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6395
7087
  modelsDir: this.modelsDir,
6396
7088
  logger: this.log.child("engine-override"),
6397
7089
  pythonPath: this.executorOptions.pythonPath ?? "",
6398
- provisioning: this.executorOptions.provisioning
7090
+ provisioning: this.executorOptions.provisioning,
7091
+ resolveCustomModel: this.customModelResolver
6399
7092
  });
6400
7093
  const initPromise = newFactory.initialize([]);
6401
7094
  this.overrideCache = {
@@ -6489,8 +7182,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6489
7182
  inputStepsToPipelineSteps(steps, format = this.currentEngine.format, engine = {
6490
7183
  backend: this.currentEngine.backend,
6491
7184
  device: this.currentEngine.device ?? null
6492
- }) {
6493
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine);
7185
+ }, clusterRefusalsOut) {
7186
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current());
7187
+ if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
6494
7188
  for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
6495
7189
  for (const sub of diagnostics.substitutions) this.logLiveDispatchIssueOnce(`${sub.addonId}|${sub.chosen}|${sub.running}|${sub.format}`, () => this.log.info("Live pipeline step model substituted for engine format (node-local)", { meta: {
6496
7190
  step: sub.addonId,
@@ -6500,6 +7194,34 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6500
7194
  } }));
6501
7195
  return resolvedSteps;
6502
7196
  }
7197
+ /**
7198
+ * Announce the cluster-scope refusals of the resolution that is actually
7199
+ * being RETURNED — never of a speculative one.
7200
+ *
7201
+ * A refusal here means: this dispatch will run `stepId` with the cluster's
7202
+ * chosen model on an engine that ships no build of it, and NOTHING was
7203
+ * substituted in its place. `assertStepsRunnable` has already had its say
7204
+ * (it throws when the catalog ships nothing at all for the format); what
7205
+ * this line adds is the case the throw cannot see — the catalog has a build,
7206
+ * just not of the model the cluster chose — which without this line is the
7207
+ * quietest possible way to stop producing vectors.
7208
+ *
7209
+ * Deduped per (step, model, engine): a fleet of cameras on one incompatible
7210
+ * node produces one line, not one per track.
7211
+ */
7212
+ logClusterRefusals(refusals, deviceId, engineLabel) {
7213
+ 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", {
7214
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7215
+ meta: {
7216
+ step: refusal.stepId,
7217
+ clusterModelId: refusal.modelId,
7218
+ format: refusal.format,
7219
+ formatsShipped: [...refusal.formatsShipped],
7220
+ engine: engineLabel,
7221
+ remedy: `choose a cluster model with a ${refusal.format} build, or take this engine out of the rotation for ${refusal.stepId}`
7222
+ }
7223
+ }));
7224
+ }
6503
7225
  /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6504
7226
  logLiveDispatchIssueOnce(key, fn) {
6505
7227
  if (this.loggedLiveDispatchIssues.has(key)) return;
@@ -6561,8 +7283,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6561
7283
  const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
6562
7284
  const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
6563
7285
  if (deviceKey === void 0 || deviceEngine === void 0) {
6564
- const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7286
+ const refusals = [];
7287
+ const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, refusals);
6565
7288
  this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
7289
+ this.logClusterRefusals(refusals, deviceId, nodeEngineLabel);
6566
7290
  return {
6567
7291
  deviceKey: void 0,
6568
7292
  steps
@@ -6573,14 +7297,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6573
7297
  backend: deviceEngine.backend,
6574
7298
  device: deviceEngine.device ?? null
6575
7299
  };
6576
- const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
7300
+ const deviceRefusals = [];
7301
+ const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
6577
7302
  const framePlane = args.plane === "frame";
6578
7303
  const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
6579
7304
  if (zeroBuild.length === 0) {
6580
- if (!framePlane) return {
6581
- deviceKey,
6582
- steps
6583
- };
7305
+ if (!framePlane) {
7306
+ this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
7307
+ return {
7308
+ deviceKey,
7309
+ steps
7310
+ };
7311
+ }
6584
7312
  const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
6585
7313
  if (pruneResult.prunedAddonIds.length > 0) {
6586
7314
  const prunedList = pruneResult.prunedAddonIds.join(",");
@@ -6593,6 +7321,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6593
7321
  }
6594
7322
  }));
6595
7323
  }
7324
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
6596
7325
  return {
6597
7326
  deviceKey,
6598
7327
  steps: pruneResult.steps
@@ -6608,8 +7337,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6608
7337
  fallbackEngine: nodeEngineLabel
6609
7338
  }
6610
7339
  }));
6611
- const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
7340
+ const fallbackRefusals = [];
7341
+ const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine, fallbackRefusals);
6612
7342
  this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
7343
+ this.logClusterRefusals(fallbackRefusals, deviceId, nodeEngineLabel);
6613
7344
  return {
6614
7345
  deviceKey: void 0,
6615
7346
  steps: fallbackSteps
@@ -6670,6 +7401,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6670
7401
  const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
6671
7402
  if (modelEntry && !require_addon_utils.isModelDownloaded(this.modelsDir, modelEntry, format)) {
6672
7403
  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`);
7404
+ if (modelEntry.formats[format]?.url.startsWith("camstack-local://") === true) throw new Error(`Custom model "${modelEntry.id}" (${format}) is not present in this node's models directory — distribute it to this node from Model Studio before selecting it here`);
6673
7405
  this.log.info("Downloading model for step", { meta: {
6674
7406
  modelId: step.modelId,
6675
7407
  format,
@@ -7075,7 +7807,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7075
7807
  modelsDir: this.modelsDir,
7076
7808
  logger: this.log.child("engine"),
7077
7809
  pythonPath: this.executorOptions.pythonPath ?? "",
7078
- provisioning: this.executorOptions.provisioning
7810
+ provisioning: this.executorOptions.provisioning,
7811
+ resolveCustomModel: this.customModelResolver
7079
7812
  });
7080
7813
  try {
7081
7814
  await factory.initialize([]);
@@ -7155,7 +7888,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7155
7888
  modelsDir: this.modelsDir,
7156
7889
  logger: this.log.child(`engine:${deviceKey}`),
7157
7890
  pythonPath: this.executorOptions.pythonPath ?? "",
7158
- provisioning: this.executorOptions.provisioning
7891
+ provisioning: this.executorOptions.provisioning,
7892
+ resolveCustomModel: this.customModelResolver
7159
7893
  });
7160
7894
  try {
7161
7895
  await factory.initialize([]);
@@ -7903,7 +8637,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7903
8637
  type: "select",
7904
8638
  key: "batchMode",
7905
8639
  label: "Batch dispatch mode",
7906
- description: "How the Python pool dispatches predicts. `none` = one call per frame. `window` = per-model accumulator coalesces concurrent calls within Window (ms). Auto uses the matrix-sweep default for the current backend. Applies to THIS node only.",
8640
+ description: "How the Python pool GROUPS predict requests — it never changes a model's compiled batch SHAPE, which is derived from (engine, device) at load and is not configurable (D202). `none` = one call per frame. `window` = per-model accumulator coalesces concurrent calls within Window (ms). Auto uses the matrix-sweep default for the current backend. Applies to THIS node only.",
7907
8641
  options: [
7908
8642
  {
7909
8643
  value: "",
@@ -7947,7 +8681,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7947
8681
  type: "slider",
7948
8682
  key: "maxBatchSize",
7949
8683
  label: "Max batch size",
7950
- description: "Cap on items per batched predict call. Auto = backend default. Applies to THIS node only.",
8684
+ description: "Cap on how many requests are GROUPED into one batched predict call. Not the model's compiled batch shape — that is derived from (engine, device) at load and is not configurable (D202); a stacked tensor is only ever built for an export that declares a flexible batch axis itself. Auto = backend default. Applies to THIS node only.",
7951
8685
  min: 1,
7952
8686
  max: 16,
7953
8687
  step: 1,