@camstack/addon-pipeline 1.2.101 → 1.2.103

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 (32) hide show
  1. package/dist/{addon-utils-eUS6n_Zj.js → addon-utils-CLc6yHCN.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 +163 -42
  5. package/dist/detection-pipeline/index.mjs +161 -40
  6. package/dist/{dist-dm3t4BOt.js → dist-C11WuNUP.js} +87 -51
  7. package/dist/{dist-gXdWP96z.mjs → dist-Ccmt3fGJ.mjs} +82 -52
  8. package/dist/{event-loop-stall-monitor-Lki2_mYY.js → event-loop-stall-monitor-Cq_NeC4o.js} +175 -132
  9. package/dist/{event-loop-stall-monitor-TnQ21mu0.mjs → event-loop-stall-monitor-OJrOMeuu.mjs} +170 -133
  10. package/dist/{lazy-sharp-oYppDIZR.js → lazy-sharp-RxUs6on_.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 +5 -4
  14. package/dist/pipeline-runner/index.mjs +4 -3
  15. package/dist/{process-memory-CKWh609B.mjs → process-memory-D0zDmXLI.mjs} +1 -1
  16. package/dist/{process-memory-vkFVOdkV.js → process-memory-DOjQ3MgC.js} +1 -1
  17. package/dist/recorder/index.js +2 -2
  18. package/dist/recorder/index.mjs +1 -1
  19. package/dist/session-decode/decode-worker-child.js +2 -2
  20. package/dist/session-decode/decode-worker-child.mjs +1 -1
  21. package/dist/stream-broker/_stub.js +1 -1
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C4fRkYcj.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs} +2 -2
  23. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-zT-cYUdF.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs} +1 -1
  24. package/dist/stream-broker/{hostInit-CS6xpCdb.mjs → hostInit-Da9wVA2r.mjs} +2 -2
  25. package/dist/stream-broker/index.js +31 -11
  26. package/dist/stream-broker/index.mjs +31 -11
  27. package/dist/stream-broker/remoteEntry.js +1 -1
  28. package/dist/{worker-protocol-BrlrMxXu.js → worker-protocol-BePduZVV.js} +1 -1
  29. package/dist/{worker-protocol-DJWdPLKF.mjs → worker-protocol-DDpliBIW.mjs} +1 -1
  30. package/package.json +1 -1
  31. package/python/postprocessors/ctc.py +56 -3
  32. package/python/postprocessors/test_ctc.py +37 -0
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-dm3t4BOt.js");
1
+ const require_dist = require("./dist-C11WuNUP.js");
2
2
  require("node:crypto");
3
3
  let node_path = require("node:path");
4
4
  node_path = require_dist.__toESM(node_path, 1);
@@ -2,9 +2,9 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-dm3t4BOt.js");
6
- const require_process_memory = require("../process-memory-vkFVOdkV.js");
7
- const require_addon_utils = require("../addon-utils-eUS6n_Zj.js");
5
+ const require_dist = require("../dist-C11WuNUP.js");
6
+ const require_process_memory = require("../process-memory-DOjQ3MgC.js");
7
+ const require_addon_utils = require("../addon-utils-CLc6yHCN.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_dist.__toESM(node_path);
10
10
  let node_fs = require("node:fs");
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { D as audioAnalysisCapability, O as audioAnalyzerCapability, W as mapAudioLabelToMacro, ct as errMsg, h as HF_BASE_URL, mt as BaseAddon, n as AUDIO_BACKEND_CHOICES, s as DEFAULT_AUDIO_ANALYZER_CONFIG, tt as resolvePoolMemoryPolicy, y as PoolMemoryWatchdog, yt as hydrateSchema } from "../dist-gXdWP96z.mjs";
3
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-CKWh609B.mjs";
2
+ import { D as audioAnalysisCapability, G as mapAudioLabelToMacro, O as audioAnalyzerCapability, bt as hydrateSchema, h as HF_BASE_URL, ht as BaseAddon, lt as errMsg, n as AUDIO_BACKEND_CHOICES, nt as resolvePoolMemoryPolicy, s as DEFAULT_AUDIO_ANALYZER_CONFIG, y as PoolMemoryWatchdog } from "../dist-Ccmt3fGJ.mjs";
3
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-D0zDmXLI.mjs";
4
4
  import { i as downloadFile } from "../addon-utils-A2S9D7pu.mjs";
5
5
  import * as path$1 from "node:path";
6
6
  import * as fs from "node:fs";
@@ -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-dm3t4BOt.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-Lki2_mYY.js");
7
- const require_lazy_sharp = require("../lazy-sharp-oYppDIZR.js");
8
- const require_process_memory = require("../process-memory-vkFVOdkV.js");
9
- const require_addon_utils = require("../addon-utils-eUS6n_Zj.js");
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");
10
10
  let sharp = require("sharp");
11
11
  sharp = require_dist.__toESM(sharp);
12
12
  let node_child_process = require("node:child_process");
@@ -3040,7 +3040,9 @@ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth
3040
3040
  * peaky (a few high rows) rather than smeared across many rows.
3041
3041
  * 3. pick the angle maximising the score (coarse 2° sweep + a 0.5° local
3042
3042
  * refinement); apply it to the full-resolution RGB tile only when it is
3043
- * meaningfully non-zero.
3043
+ * meaningfully non-zero AND the optimum was BRACKETED by the sweep — an
3044
+ * argmax sitting on the boundary means the profile never turned over and
3045
+ * the "angle" is an artefact of where the search stopped.
3044
3046
  *
3045
3047
  * All of the geometry is pure functions over raw buffers, so the estimator is
3046
3048
  * fully deterministic and unit-testable without any image I/O.
@@ -3219,22 +3221,28 @@ function scoreAngle(gradient, angleRad) {
3219
3221
  height: rotated.height
3220
3222
  });
3221
3223
  }
3224
+ var DEFAULT_COARSE_STEP_DEG = 2;
3225
+ var NO_SKEW = {
3226
+ angleDeg: 0,
3227
+ score: 0
3228
+ };
3222
3229
  /**
3223
3230
  * Estimate the dominant text-baseline skew of a grayscale plate crop and return
3224
3231
  * the CORRECTION angle (degrees) that levels it. A coarse sweep over
3225
3232
  * ±`maxAngleDeg` in `coarseStepDeg` steps finds the best bucket; a `fineStepDeg`
3226
3233
  * local search refines it. Returns `0` when the flat (no-rotation) profile is
3227
- * already the strongest response.
3234
+ * already the strongest response, and — critically — also when the coarse
3235
+ * winner is not BRACKETED by the sweep (see {@link BOUNDARY_REJECT_DEG}): an
3236
+ * optimum sitting on the edge of the searched interval is not a measurement,
3237
+ * it is the place the search ran out of road.
3228
3238
  */
3229
3239
  function estimateSkewAngle(gray, opts = {}) {
3230
- const maxAngleDeg = opts.maxAngleDeg ?? 30;
3231
- const coarseStepDeg = opts.coarseStepDeg ?? 2;
3240
+ const maxAngleDeg = opts.maxAngleDeg ?? 16;
3241
+ const coarseStepDeg = opts.coarseStepDeg ?? DEFAULT_COARSE_STEP_DEG;
3232
3242
  const fineStepDeg = opts.fineStepDeg ?? .5;
3233
3243
  const scoreMaxDim = opts.scoreMaxDim ?? 480;
3234
- if (gray.width < 4 || gray.height < 4) return {
3235
- angleDeg: 0,
3236
- score: 0
3237
- };
3244
+ const boundaryRejectDeg = Math.max(0, maxAngleDeg - coarseStepDeg);
3245
+ if (gray.width < 4 || gray.height < 4) return NO_SKEW;
3238
3246
  const gradient = sobelMagnitude(downscaleGray(gray, scoreMaxDim));
3239
3247
  let bestAngle = 0;
3240
3248
  let bestScore = scoreAngle(gradient, 0);
@@ -3246,6 +3254,7 @@ function estimateSkewAngle(gray, opts = {}) {
3246
3254
  bestAngle = deg;
3247
3255
  }
3248
3256
  }
3257
+ if (Math.abs(bestAngle) >= boundaryRejectDeg) return NO_SKEW;
3249
3258
  const lo = bestAngle - coarseStepDeg;
3250
3259
  const hi = bestAngle + coarseStepDeg;
3251
3260
  for (let deg = lo; deg <= hi + 1e-9; deg += fineStepDeg) {
@@ -3261,6 +3270,50 @@ function estimateSkewAngle(gray, opts = {}) {
3261
3270
  score: bestScore
3262
3271
  };
3263
3272
  }
3273
+ function toSharpChannels(channels) {
3274
+ switch (channels) {
3275
+ case 1: return 1;
3276
+ case 2: return 2;
3277
+ case 4: return 4;
3278
+ default: return 3;
3279
+ }
3280
+ }
3281
+ /**
3282
+ * Cut an expand-rotated canvas back to the LEVELLED SOURCE BAND.
3283
+ *
3284
+ * `rotateRaw(expand: true)` grows the canvas to `w·|cos| + h·|sin|` ×
3285
+ * `w·|sin| + h·|cos|` so no corner is clipped — but the `|sin|` terms are pure
3286
+ * corner spread: flat fill above/below (and left/right of) the plate. Handing
3287
+ * that to OCR is not free. The recognizer letterboxes the whole tile into a
3288
+ * fixed 64px-tall input, so a 155px crop returned at 194px shrinks every glyph
3289
+ * by ~20% before a single character is read — the warp paying back part of what
3290
+ * it just won.
3291
+ *
3292
+ * The band kept is the source rectangle scaled by `|cos angle|`, centred: a
3293
+ * source edge of length L, once levelled, spans `L·|cos|` along its own axis.
3294
+ * Everything outside is the fill the rotation introduced.
3295
+ */
3296
+ function cropLevelledBand(rotated, srcWidth, srcHeight, angleRad) {
3297
+ const c = Math.abs(Math.cos(angleRad));
3298
+ const bw = Math.max(1, Math.min(rotated.width, Math.round(srcWidth * c)));
3299
+ const bh = Math.max(1, Math.min(rotated.height, Math.round(srcHeight * c)));
3300
+ if (bw === rotated.width && bh === rotated.height) return rotated;
3301
+ const ch = rotated.channels;
3302
+ const x0 = Math.max(0, Math.floor((rotated.width - bw) / 2));
3303
+ const y0 = Math.max(0, Math.floor((rotated.height - bh) / 2));
3304
+ const out = new Uint8Array(bw * bh * ch);
3305
+ const rowBytes = bw * ch;
3306
+ for (let y = 0; y < bh; y++) {
3307
+ const srcOff = ((y0 + y) * rotated.width + x0) * ch;
3308
+ out.set(rotated.data.subarray(srcOff, srcOff + rowBytes), y * rowBytes);
3309
+ }
3310
+ return {
3311
+ data: out,
3312
+ width: bw,
3313
+ height: bh,
3314
+ channels: ch
3315
+ };
3316
+ }
3264
3317
  /**
3265
3318
  * Rectify an oblique plate crop: decode the JPEG, estimate the baseline skew,
3266
3319
  * and — only when it exceeds {@link MIN_RECTIFY_ANGLE_DEG} — rotate the
@@ -3288,19 +3341,20 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3288
3341
  let mean = 0;
3289
3342
  for (let i = 0; i < gray.data.length; i++) mean += gray.data[i];
3290
3343
  const fill = gray.data.length > 0 ? mean / gray.data.length | 0 : 0;
3291
- const rotated = rotateRaw(rgb, angleDeg * DEG2RAD, {
3344
+ const angleRad = angleDeg * DEG2RAD;
3345
+ const band = cropLevelledBand(rotateRaw(rgb, angleRad, {
3292
3346
  expand: true,
3293
3347
  fill
3294
- });
3348
+ }), rgb.width, rgb.height, angleRad);
3295
3349
  try {
3296
3350
  return {
3297
- jpeg: await (0, sharp.default)(Buffer.from(rotated.data), { raw: {
3298
- width: rotated.width,
3299
- height: rotated.height,
3300
- channels: rotated.channels
3351
+ jpeg: await (0, sharp.default)(Buffer.from(band.data), { raw: {
3352
+ width: band.width,
3353
+ height: band.height,
3354
+ channels: toSharpChannels(band.channels)
3301
3355
  } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
3302
- width: rotated.width,
3303
- height: rotated.height,
3356
+ width: band.width,
3357
+ height: band.height,
3304
3358
  angleDeg
3305
3359
  };
3306
3360
  } catch {
@@ -3309,6 +3363,12 @@ async function rectifyPlateJpeg(jpeg, opts = {}) {
3309
3363
  }
3310
3364
  //#endregion
3311
3365
  //#region src/detection-pipeline/pipeline/result-assembler.ts
3366
+ /** L2 norm of a raw embedding vector. */
3367
+ function vectorMagnitude(v) {
3368
+ let sum = 0;
3369
+ for (const x of v) sum += x * x;
3370
+ return Math.sqrt(sum);
3371
+ }
3312
3372
  /**
3313
3373
  * Small deterministic id generator scoped to a single frame. Each call
3314
3374
  * returns the next `d{n}` id so detail parentId references can be set
@@ -3402,6 +3462,7 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3402
3462
  case "embedding":
3403
3463
  parent.embedding = output.embeddingNorm;
3404
3464
  parent.embeddingModelId = childStep.modelId;
3465
+ parent.embeddingMagnitude = vectorMagnitude(output.embedding);
3405
3466
  parent.ownLabels.push({
3406
3467
  label: `embedding:${output.embedding.length}d`,
3407
3468
  score: 1
@@ -3624,20 +3685,50 @@ var MIN_FACE_DETECTION_FLOOR_PX = 6;
3624
3685
  */
3625
3686
  var NATIVE_FACE_CROP_MAX_WIDTH = 320;
3626
3687
  /**
3627
- * Minimum short side (px) of the ACTUAL crop fed to the plate-OCR (`ctc`) leaf
3628
- * the plate analogue of the face path's min-face-size gate. Below ~20px the
3629
- * CTC logit map collapses toward the blank and the surviving timesteps emit
3630
- * symbol junk ("[miv4o)", "6ou.2" live-observed 2026-07-22). Measured on the
3631
- * NATIVE crop when the native ROI fetch succeeded (its dimensions ARE the
3632
- * plate's real native size `nativeChildCrop`'s 1920 cap never binds for a
3633
- * plate), else on the fallback detection-space tile which IS the input OCR
3634
- * would read, so the same floor applies honestly in both cases.
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.
3635
3715
  */
3636
- var MIN_PLATE_OCR_SHORT_SIDE_PX = 20;
3716
+ var MIN_PLATE_OCR_LONG_SIDE_PX = 100;
3637
3717
  function isEnrichmentOutput(output) {
3638
3718
  return output.kind === "classifications" || output.kind === "embedding" || output.kind === "text";
3639
3719
  }
3640
3720
  /**
3721
+ * Read a numeric step setting, falling back to `fallback` when the key is
3722
+ * absent or not a finite number. A type guard rather than a cast — a step's
3723
+ * `settings` bag is `Record<string, unknown>` because it is operator data, and
3724
+ * asserting `as number` over it is how a string from a form becomes a silent
3725
+ * NaN comparison that admits everything.
3726
+ */
3727
+ function numericSetting(settings, key, fallback) {
3728
+ const raw = settings?.[key];
3729
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
3730
+ }
3731
+ /**
3641
3732
  * Stamp the camera onto an inference input so a failure inside the SHARED pool
3642
3733
  * can name it (`InferenceInput.deviceId` — diagnostic only, it selects
3643
3734
  * nothing). Immutable, and a no-op for a caller with no real camera: the
@@ -4078,12 +4169,27 @@ var PipelineExecutor = class {
4078
4169
  let cropW;
4079
4170
  let cropH;
4080
4171
  if (modelEntry?.faceAlignment) {
4081
- const minFaceSize = typeof child.settings?.["minFaceSize"] === "number" ? child.settings["minFaceSize"] : DEFAULT_MIN_FACE_SIZE_PX;
4172
+ const minFaceSize = numericSetting(child.settings, "minFaceSize", DEFAULT_MIN_FACE_SIZE_PX);
4082
4173
  const detShortSide = bboxShortSide(parentDetection.bbox);
4083
4174
  if (detShortSide < MIN_FACE_DETECTION_FLOOR_PX) {
4084
4175
  this.warnFaceTooSmall(child, detShortSide, minFaceSize, void 0, deviceId);
4085
4176
  continue;
4086
4177
  }
4178
+ const precision = require_event_loop_stall_monitor.landmarkPrecisionVerdict({
4179
+ detectionShortSidePx: detShortSide,
4180
+ floorPx: numericSetting(child.settings, "minLandmarkFaceSize", 24)
4181
+ });
4182
+ if (precision !== null) {
4183
+ this.throttledWarn(`face-landmark-floor:${child.stepId}:${deviceId}`, 3e4, "face detected but its landmarks are below the precision floor — no embedding", {
4184
+ step: child.stepId,
4185
+ model: child.modelId,
4186
+ detectionShortSidePx: Math.round(precision.detectionShortSidePx),
4187
+ minLandmarkFaceSizePx: precision.floorPx,
4188
+ tileW: imageWidth,
4189
+ tileH: imageHeight
4190
+ }, deviceId);
4191
+ continue;
4192
+ }
4087
4193
  const lms = parentDetection.landmarks;
4088
4194
  if (lms && lms.length >= 5) {
4089
4195
  if (!nativeCropProvider && detShortSide < minFaceSize) {
@@ -4147,15 +4253,16 @@ var PipelineExecutor = class {
4147
4253
  cropH = crop.height;
4148
4254
  }
4149
4255
  if (child.definition.postprocessor === "ctc") {
4150
- const plateShortSidePx = Math.min(cropW, cropH);
4151
- if (plateShortSidePx < MIN_PLATE_OCR_SHORT_SIDE_PX) {
4152
- this.opts.logger?.debug("plate ROI too small for OCR — skipping", {
4256
+ const plateLongSidePx = Math.max(cropW, cropH);
4257
+ if (plateLongSidePx < MIN_PLATE_OCR_LONG_SIDE_PX) {
4258
+ this.opts.logger?.info("plate ROI too small for OCR — skipping", {
4153
4259
  tags: { deviceId },
4154
4260
  meta: {
4155
4261
  step: child.stepId,
4156
4262
  model: child.modelId,
4157
- plateShortSidePx: Math.round(plateShortSidePx),
4158
- minPlateShortSidePx: MIN_PLATE_OCR_SHORT_SIDE_PX,
4263
+ plateLongSidePx: Math.round(plateLongSidePx),
4264
+ plateShortSidePx: Math.round(Math.min(cropW, cropH)),
4265
+ minPlateLongSidePx: MIN_PLATE_OCR_LONG_SIDE_PX,
4159
4266
  source: nativeChild ? "native" : "detection-tile"
4160
4267
  }
4161
4268
  });
@@ -4168,12 +4275,15 @@ var PipelineExecutor = class {
4168
4275
  cropJpegBuf = rectified.jpeg;
4169
4276
  cropW = rectified.width;
4170
4277
  cropH = rectified.height;
4171
- this.opts.logger?.debug("Plate crop deskewed before OCR", { meta: {
4172
- stepId: child.definition.id,
4173
- angleDeg: Number(rectified.angleDeg.toFixed(2)),
4174
- width: cropW,
4175
- height: cropH
4176
- } });
4278
+ this.opts.logger?.info("plate crop deskewed before OCR", {
4279
+ tags: { deviceId },
4280
+ meta: {
4281
+ step: child.stepId,
4282
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
4283
+ width: cropW,
4284
+ height: cropH
4285
+ }
4286
+ });
4177
4287
  }
4178
4288
  } catch {}
4179
4289
  }
@@ -4183,6 +4293,17 @@ var PipelineExecutor = class {
4183
4293
  }, deviceId), cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
4184
4294
  const childMs = Date.now() - childStart;
4185
4295
  if (childOutput === null) continue;
4296
+ if (child.definition.postprocessor === "ctc" && childOutput.kind === "text") this.opts.logger?.info("plate OCR read", {
4297
+ tags: { deviceId },
4298
+ meta: {
4299
+ step: child.stepId,
4300
+ model: child.modelId,
4301
+ text: childOutput.text,
4302
+ confidence: Number(childOutput.confidence.toFixed(3)),
4303
+ formatValid: childOutput.formatValid ?? null,
4304
+ tilePx: `${cropW}x${cropH}`
4305
+ }
4306
+ });
4186
4307
  const detailsBefore = ctx.details.length;
4187
4308
  applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
4188
4309
  if (childOutput.kind === "detections" && child.children.length > 0) {