@camstack/addon-pipeline 1.2.5 → 1.2.7

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/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +499 -30
  4. package/dist/detection-pipeline/index.mjs +493 -24
  5. package/dist/{dist-ZB-B8UrH.mjs → dist-BwyGTnMu.mjs} +306 -3
  6. package/dist/{dist-CtQsET1Q.js → dist-CAT3VF7A.js} +306 -3
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +30 -15
  10. package/dist/pipeline-runner/index.mjs +30 -15
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{step-definitions-DrRnEQPM.js → step-definitions-BPEExRug.js} +70 -2
  14. package/dist/{step-definitions-BKJI5CXI.mjs → step-definitions-DN1obXS7.mjs} +70 -2
  15. package/dist/stream-broker/_stub.js +1 -1
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPvS4Ex7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BtN6sZcL.mjs} +1 -1
  17. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D34ivtL2.mjs +26 -0
  18. package/dist/stream-broker/{hostInit-CG0IeE_e.mjs → hostInit-BIZvt2bk.mjs} +1 -1
  19. package/dist/stream-broker/index.js +35 -6
  20. package/dist/stream-broker/index.mjs +35 -6
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C-rkQ2rg.js → MaskShapeCanvas-DI4BY7W2-3uL2OmvC.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BtbCLveu.js → MotionZonesSettings-NcxxQN8r-JkIkJTeI.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C9CEgSlA.js → PrivacyMaskSettings-APgPLF7p-AKGABxQk.js} +1 -1
  25. package/embed-dist/assets/{index-CgJ1frQY.js → index-DfcKIHhA.js} +14 -14
  26. package/embed-dist/index.html +1 -1
  27. package/package.json +1 -1
  28. package/python/postprocessors/ctc.py +228 -4
  29. package/python/postprocessors/test_ctc.py +312 -0
  30. package/python/postprocessors/testdata/ctc_degenerate_lowres_logits.npy.gz.b64 +1 -0
  31. package/python/postprocessors/testdata/ctc_dwro309_logits.npy.gz.b64 +1 -0
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQC_f4Yv.mjs +0 -26
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-CtQsET1Q.js");
7
- const require_step_definitions = require("../step-definitions-DrRnEQPM.js");
6
+ const require_dist = require("../dist-CAT3VF7A.js");
7
+ const require_step_definitions = require("../step-definitions-BPEExRug.js");
8
8
  const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
9
9
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
10
10
  let node_child_process = require("node:child_process");
@@ -221,6 +221,43 @@ function normalizeEngineNodeId(rawNodeId) {
221
221
  return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
222
222
  }
223
223
  //#endregion
224
+ //#region src/detection-pipeline/pipeline/native-crop-compose.ts
225
+ /** Clamp `v` into `[lo, hi]`. */
226
+ function clamp(v, lo, hi) {
227
+ if (v < lo) return lo;
228
+ if (v > hi) return hi;
229
+ return v;
230
+ }
231
+ /**
232
+ * Compose a child `roi` given in the parent CROP's normalized space into the
233
+ * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
234
+ * frame dimensions. The result is clamped to the unit square (origin in
235
+ * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
236
+ * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
237
+ * clamps to zero area) so the caller falls back to the downscaled tile crop.
238
+ *
239
+ * Pure — never mutates its arguments.
240
+ */
241
+ function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
242
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
243
+ if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
244
+ const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
245
+ const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
246
+ const fw = roi.w * cropFrameSpace.w / frameWidth;
247
+ const fh = roi.h * cropFrameSpace.h / frameHeight;
248
+ const x = clamp(fx, 0, 1);
249
+ const y = clamp(fy, 0, 1);
250
+ const w = clamp(fw, 0, 1 - x);
251
+ const h = clamp(fh, 0, 1 - y);
252
+ if (!(w > 0) || !(h > 0)) return null;
253
+ return {
254
+ x,
255
+ y,
256
+ w,
257
+ h
258
+ };
259
+ }
260
+ //#endregion
224
261
  //#region src/detection-pipeline/default-detection-model.ts
225
262
  /** The object-detection step id — the only slot this resolver applies to. */
226
263
  var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
@@ -909,6 +946,9 @@ function serializeModelConfig(config) {
909
946
  if (config.inputChannels !== void 0) result["inputChannels"] = config.inputChannels;
910
947
  if (config.labels) result["labels"] = config.labels;
911
948
  if (config.charset) result["charset"] = config.charset;
949
+ if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
950
+ if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
951
+ if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
912
952
  if (config.numClasses) result["numClasses"] = config.numClasses;
913
953
  if (config.strides) result["strides"] = config.strides;
914
954
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1443,6 +1483,9 @@ var EngineFactory = class {
1443
1483
  nmsIouThreshold: this.opts.nmsIouThreshold ?? .45,
1444
1484
  labels,
1445
1485
  charset: def.charset,
1486
+ plateRegion: def.plateRegion,
1487
+ minTextLength: def.minTextLength,
1488
+ minTextConfidence: def.minTextConfidence,
1446
1489
  numClasses: labels?.length,
1447
1490
  strides: def.postprocessor === "scrfd" ? [
1448
1491
  8,
@@ -1647,6 +1690,14 @@ var EngineProvisioner = class {
1647
1690
  /** Detector NMS IoU fallback — the historical hardcoded literal, used when the
1648
1691
  * step carries no `nmsIouThreshold` setting (unset == today). */
1649
1692
  var DEFAULT_NMS_IOU = .45;
1693
+ /**
1694
+ * Region plate grammars for the TS fallback CTC decoder (nodejs+onnx path).
1695
+ * VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
1696
+ * confusables — the confusable repair needs the aligned per-timestep softmax
1697
+ * that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
1698
+ * the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
1699
+ */
1700
+ var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
1650
1701
  var VALID_KINDS = new Set([
1651
1702
  "detections",
1652
1703
  "classifications",
@@ -1923,9 +1974,20 @@ function postprocessCtc(output, stepDef) {
1923
1974
  if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
1924
1975
  prev = bestIdx;
1925
1976
  }
1977
+ const text = chars.join("");
1978
+ const region = stepDef.plateRegion;
1979
+ if (region !== void 0 && region !== "off") {
1980
+ const grammar = TS_PLATE_GRAMMARS[region];
1981
+ if (grammar) return {
1982
+ kind: "text",
1983
+ text,
1984
+ confidence: seqLen > 0 ? totalScore / seqLen : 0,
1985
+ formatValid: grammar.test(text)
1986
+ };
1987
+ }
1926
1988
  return {
1927
1989
  kind: "text",
1928
- text: chars.join(""),
1990
+ text,
1929
1991
  confidence: seqLen > 0 ? totalScore / seqLen : 0
1930
1992
  };
1931
1993
  }
@@ -2568,6 +2630,353 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
2568
2630
  };
2569
2631
  }
2570
2632
  //#endregion
2633
+ //#region src/detection-pipeline/pipeline/native-child-crop.ts
2634
+ /**
2635
+ * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2636
+ * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2637
+ *
2638
+ * On the detail plane the executor runs a child model against a parent crop
2639
+ * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2640
+ * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2641
+ * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2642
+ * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2643
+ * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2644
+ * straight from the frame's retained NATIVE surface (the provider re-composes
2645
+ * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2646
+ * giving the model native pixels. A miss/degenerate returns `null` so the caller
2647
+ * falls back to the existing tile crop (never an upscale — native or the tile).
2648
+ */
2649
+ /**
2650
+ * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2651
+ * native pixels wide, so this never binds for plates (effectively uncapped —
2652
+ * the quality path the task requires); it only bounds a pathologically large
2653
+ * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2654
+ */
2655
+ var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2656
+ /**
2657
+ * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2658
+ * resolution via `provider`, returning a JPEG crop + its native dimensions.
2659
+ * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2660
+ * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2661
+ * the sharp encode of the returned pixels.
2662
+ */
2663
+ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2664
+ if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2665
+ const x1 = Math.max(0, bbox[0]);
2666
+ const y1 = Math.max(0, bbox[1]);
2667
+ const x2 = Math.min(imageWidth, bbox[2]);
2668
+ const y2 = Math.min(imageHeight, bbox[3]);
2669
+ const w = x2 - x1;
2670
+ const h = y2 - y1;
2671
+ if (w < 1 || h < 1) return null;
2672
+ const native = await provider({
2673
+ x: x1 / imageWidth,
2674
+ y: y1 / imageHeight,
2675
+ w: w / imageWidth,
2676
+ h: h / imageHeight
2677
+ }, maxWidth);
2678
+ if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2679
+ return {
2680
+ jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
2681
+ width: native.width,
2682
+ height: native.height,
2683
+ channels: 3
2684
+ } }).jpeg({ quality: 90 }).toBuffer(),
2685
+ width: native.width,
2686
+ height: native.height
2687
+ };
2688
+ }
2689
+ //#endregion
2690
+ //#region src/detection-pipeline/pipeline/plate-deskew.ts
2691
+ /**
2692
+ * Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
2693
+ *
2694
+ * Plate crops reach the CTC recognizer as an axis-aligned bbox tile with NO
2695
+ * geometric correction. On steeply oblique cameras (cam 617) the plate text is
2696
+ * skewed, and CTC collapses beyond ~10-15° of baseline tilt. This module
2697
+ * estimates the plate's dominant rotation from the crop ALONE (no plate quad is
2698
+ * available) and rotates the tile so the text baseline is horizontal before OCR.
2699
+ *
2700
+ * Approach (classic projection-profile deskew, pure TS — no OpenCV, no new
2701
+ * native deps; sharp is used only to decode/encode JPEG, mirroring the sibling
2702
+ * `native-child-crop.ts` / `face-align.ts`):
2703
+ * 1. grayscale → isotropic Sobel gradient magnitude (an orientation-agnostic
2704
+ * "ink/edge" map, robust to plate polarity and lighting).
2705
+ * 2. for a small set of candidate CORRECTION angles, rotate the gradient map
2706
+ * (bilinear, same primitive family as the face warp) and score the
2707
+ * horizontal projection profile by its energy `Σ rowSum²`. That energy is
2708
+ * MAXIMISED when the text edges land on horizontal rows — the profile is
2709
+ * peaky (a few high rows) rather than smeared across many rows.
2710
+ * 3. pick the angle maximising the score (coarse 2° sweep + a 0.5° local
2711
+ * refinement); apply it to the full-resolution RGB tile only when it is
2712
+ * meaningfully non-zero.
2713
+ *
2714
+ * All of the geometry is pure functions over raw buffers, so the estimator is
2715
+ * fully deterministic and unit-testable without any image I/O.
2716
+ */
2717
+ var DEG2RAD = Math.PI / 180;
2718
+ /** Convert an interleaved RGB/RGBA buffer to single-channel luma. */
2719
+ function toGrayscale(rgb) {
2720
+ const { data, width, height, channels } = rgb;
2721
+ const out = new Uint8Array(width * height);
2722
+ for (let i = 0, p = 0; i < out.length; i++, p += channels) out[i] = data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29 >> 8;
2723
+ return {
2724
+ data: out,
2725
+ width,
2726
+ height
2727
+ };
2728
+ }
2729
+ /**
2730
+ * Isotropic Sobel gradient magnitude of a grayscale image, clamped to [0,255].
2731
+ * Border pixels are 0 (no valid 3×3 neighbourhood) — they carry no baseline
2732
+ * signal anyway. The magnitude is polarity-agnostic, so it responds to both
2733
+ * dark-on-light and light-on-dark plates.
2734
+ */
2735
+ function sobelMagnitude(gray) {
2736
+ const { data, width, height } = gray;
2737
+ const out = new Uint8Array(width * height);
2738
+ for (let y = 1; y < height - 1; y++) for (let x = 1; x < width - 1; x++) {
2739
+ const i = y * width + x;
2740
+ const tl = data[i - width - 1];
2741
+ const tc = data[i - width];
2742
+ const tr = data[i - width + 1];
2743
+ const ml = data[i - 1];
2744
+ const mr = data[i + 1];
2745
+ const bl = data[i + width - 1];
2746
+ const bc = data[i + width];
2747
+ const br = data[i + width + 1];
2748
+ const gx = tr + 2 * mr + br - (tl + 2 * ml + bl);
2749
+ const gy = bl + 2 * bc + br - (tl + 2 * tc + tr);
2750
+ const mag = Math.sqrt(gx * gx + gy * gy);
2751
+ out[i] = mag > 255 ? 255 : mag;
2752
+ }
2753
+ return {
2754
+ data: out,
2755
+ width,
2756
+ height
2757
+ };
2758
+ }
2759
+ /**
2760
+ * Rotate a raw image (any channel count) about its centre by `angleRad`
2761
+ * (positive = counter-clockwise) using bilinear interpolation — the same
2762
+ * resampling family as the face-alignment warp. When `expand` is true the
2763
+ * output canvas grows to contain the whole rotated rectangle (no corner
2764
+ * clipping); otherwise it keeps the source dimensions. Out-of-bounds samples
2765
+ * take the `fill` value (default 0), so an expanded canvas gets a flat border.
2766
+ */
2767
+ function rotateRaw(src, angleRad, opts = {}) {
2768
+ const { data, width: w, height: h, channels: ch } = src;
2769
+ const expand = opts.expand ?? false;
2770
+ const fill = opts.fill ?? 0;
2771
+ const c = Math.cos(angleRad);
2772
+ const s = Math.sin(angleRad);
2773
+ let ow = w;
2774
+ let oh = h;
2775
+ if (expand) {
2776
+ ow = Math.max(1, Math.ceil(Math.abs(w * c) + Math.abs(h * s)));
2777
+ oh = Math.max(1, Math.ceil(Math.abs(w * s) + Math.abs(h * c)));
2778
+ }
2779
+ const cx = (w - 1) / 2;
2780
+ const cy = (h - 1) / 2;
2781
+ const ocx = (ow - 1) / 2;
2782
+ const ocy = (oh - 1) / 2;
2783
+ const out = new Uint8Array(ow * oh * ch);
2784
+ const maxX = w - 1;
2785
+ const maxY = h - 1;
2786
+ for (let oy = 0; oy < oh; oy++) {
2787
+ const dyo = oy - ocy;
2788
+ for (let ox = 0; ox < ow; ox++) {
2789
+ const dxo = ox - ocx;
2790
+ const sx = c * dxo + s * dyo + cx;
2791
+ const sy = -s * dxo + c * dyo + cy;
2792
+ const o = (oy * ow + ox) * ch;
2793
+ if (sx < 0 || sx > maxX || sy < 0 || sy > maxY) {
2794
+ for (let k = 0; k < ch; k++) out[o + k] = fill;
2795
+ continue;
2796
+ }
2797
+ const x0 = Math.floor(sx);
2798
+ const y0 = Math.floor(sy);
2799
+ const x1 = x0 < maxX ? x0 + 1 : x0;
2800
+ const y1 = y0 < maxY ? y0 + 1 : y0;
2801
+ const fx = sx - x0;
2802
+ const fy = sy - y0;
2803
+ const w00 = (1 - fx) * (1 - fy);
2804
+ const w10 = fx * (1 - fy);
2805
+ const w01 = (1 - fx) * fy;
2806
+ const w11 = fx * fy;
2807
+ const i00 = (y0 * w + x0) * ch;
2808
+ const i10 = (y0 * w + x1) * ch;
2809
+ const i01 = (y1 * w + x0) * ch;
2810
+ const i11 = (y1 * w + x1) * ch;
2811
+ 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;
2812
+ }
2813
+ }
2814
+ return {
2815
+ data: out,
2816
+ width: ow,
2817
+ height: oh,
2818
+ channels: ch
2819
+ };
2820
+ }
2821
+ /**
2822
+ * Horizontal-projection-profile ENERGY of a single-channel map: `Σ_row rowSum²`.
2823
+ * Higher when the map's mass is concentrated in a few rows (aligned text) rather
2824
+ * than smeared across many (skewed text). Total mass is ~conserved by the
2825
+ * bilinear rotation, so this energy cleanly ranks candidate rotations.
2826
+ */
2827
+ function projectionEnergy(gray) {
2828
+ const { data, width, height } = gray;
2829
+ let energy = 0;
2830
+ for (let y = 0; y < height; y++) {
2831
+ let rowSum = 0;
2832
+ const base = y * width;
2833
+ for (let x = 0; x < width; x++) rowSum += data[base + x];
2834
+ energy += rowSum * rowSum;
2835
+ }
2836
+ return energy;
2837
+ }
2838
+ /** Area-average downscale of a gray map so its longest side ≤ `maxDim` (never upscales). */
2839
+ function downscaleGray(gray, maxDim) {
2840
+ const { data, width, height } = gray;
2841
+ const longest = Math.max(width, height);
2842
+ if (longest <= maxDim) return gray;
2843
+ const scale = maxDim / longest;
2844
+ const nw = Math.max(1, Math.round(width * scale));
2845
+ const nh = Math.max(1, Math.round(height * scale));
2846
+ const out = new Uint8Array(nw * nh);
2847
+ const fx = width / nw;
2848
+ const fy = height / nh;
2849
+ for (let y = 0; y < nh; y++) {
2850
+ const sy0 = Math.floor(y * fy);
2851
+ const sy1 = Math.min(height, Math.floor((y + 1) * fy));
2852
+ for (let x = 0; x < nw; x++) {
2853
+ const sx0 = Math.floor(x * fx);
2854
+ const sx1 = Math.min(width, Math.floor((x + 1) * fx));
2855
+ let sum = 0;
2856
+ let n = 0;
2857
+ for (let yy = sy0; yy < Math.max(sy0 + 1, sy1); yy++) {
2858
+ const base = yy * width;
2859
+ for (let xx = sx0; xx < Math.max(sx0 + 1, sx1); xx++) {
2860
+ sum += data[base + xx];
2861
+ n++;
2862
+ }
2863
+ }
2864
+ out[y * nw + x] = n > 0 ? sum / n | 0 : 0;
2865
+ }
2866
+ }
2867
+ return {
2868
+ data: out,
2869
+ width: nw,
2870
+ height: nh
2871
+ };
2872
+ }
2873
+ /** Score one correction angle: rotate the gradient map and take its profile energy. */
2874
+ function scoreAngle(gradient, angleRad) {
2875
+ if (angleRad === 0) return projectionEnergy(gradient);
2876
+ const rotated = rotateRaw({
2877
+ data: gradient.data,
2878
+ width: gradient.width,
2879
+ height: gradient.height,
2880
+ channels: 1
2881
+ }, angleRad, {
2882
+ expand: true,
2883
+ fill: 0
2884
+ });
2885
+ return projectionEnergy({
2886
+ data: rotated.data,
2887
+ width: rotated.width,
2888
+ height: rotated.height
2889
+ });
2890
+ }
2891
+ /**
2892
+ * Estimate the dominant text-baseline skew of a grayscale plate crop and return
2893
+ * the CORRECTION angle (degrees) that levels it. A coarse sweep over
2894
+ * ±`maxAngleDeg` in `coarseStepDeg` steps finds the best bucket; a `fineStepDeg`
2895
+ * local search refines it. Returns `0` when the flat (no-rotation) profile is
2896
+ * already the strongest response.
2897
+ */
2898
+ function estimateSkewAngle(gray, opts = {}) {
2899
+ const maxAngleDeg = opts.maxAngleDeg ?? 30;
2900
+ const coarseStepDeg = opts.coarseStepDeg ?? 2;
2901
+ const fineStepDeg = opts.fineStepDeg ?? .5;
2902
+ const scoreMaxDim = opts.scoreMaxDim ?? 480;
2903
+ if (gray.width < 4 || gray.height < 4) return {
2904
+ angleDeg: 0,
2905
+ score: 0
2906
+ };
2907
+ const gradient = sobelMagnitude(downscaleGray(gray, scoreMaxDim));
2908
+ let bestAngle = 0;
2909
+ let bestScore = scoreAngle(gradient, 0);
2910
+ for (let deg = -maxAngleDeg; deg <= maxAngleDeg + 1e-9; deg += coarseStepDeg) {
2911
+ if (deg === 0) continue;
2912
+ const score = scoreAngle(gradient, deg * DEG2RAD);
2913
+ if (score > bestScore) {
2914
+ bestScore = score;
2915
+ bestAngle = deg;
2916
+ }
2917
+ }
2918
+ const lo = bestAngle - coarseStepDeg;
2919
+ const hi = bestAngle + coarseStepDeg;
2920
+ for (let deg = lo; deg <= hi + 1e-9; deg += fineStepDeg) {
2921
+ if (deg < -maxAngleDeg || deg > maxAngleDeg) continue;
2922
+ const score = scoreAngle(gradient, deg * DEG2RAD);
2923
+ if (score > bestScore) {
2924
+ bestScore = score;
2925
+ bestAngle = deg;
2926
+ }
2927
+ }
2928
+ return {
2929
+ angleDeg: bestAngle,
2930
+ score: bestScore
2931
+ };
2932
+ }
2933
+ /**
2934
+ * Rectify an oblique plate crop: decode the JPEG, estimate the baseline skew,
2935
+ * and — only when it exceeds {@link MIN_RECTIFY_ANGLE_DEG} — rotate the
2936
+ * full-resolution RGB tile to level the text and re-encode. Returns `null` when
2937
+ * no meaningful rotation is needed (caller keeps the original tile) or on any
2938
+ * decode/degenerate failure (never worse than the un-rectified input).
2939
+ *
2940
+ * The returned `angleDeg` is the applied correction (0 is never returned with a
2941
+ * jpeg — a null means "unchanged").
2942
+ */
2943
+ async function rectifyPlateJpeg(jpeg, opts = {}) {
2944
+ const raw = await (0, sharp.default)(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
2945
+ if (!raw) return null;
2946
+ const { data, info } = raw;
2947
+ if (info.width < 4 || info.height < 4) return null;
2948
+ const rgb = {
2949
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
2950
+ width: info.width,
2951
+ height: info.height,
2952
+ channels: info.channels
2953
+ };
2954
+ const gray = toGrayscale(rgb);
2955
+ const { angleDeg } = estimateSkewAngle(gray, opts);
2956
+ if (Math.abs(angleDeg) < 2) return null;
2957
+ let mean = 0;
2958
+ for (let i = 0; i < gray.data.length; i++) mean += gray.data[i];
2959
+ const fill = gray.data.length > 0 ? mean / gray.data.length | 0 : 0;
2960
+ const rotated = rotateRaw(rgb, angleDeg * DEG2RAD, {
2961
+ expand: true,
2962
+ fill
2963
+ });
2964
+ try {
2965
+ return {
2966
+ jpeg: await (0, sharp.default)(Buffer.from(rotated.data), { raw: {
2967
+ width: rotated.width,
2968
+ height: rotated.height,
2969
+ channels: rotated.channels
2970
+ } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
2971
+ width: rotated.width,
2972
+ height: rotated.height,
2973
+ angleDeg
2974
+ };
2975
+ } catch {
2976
+ return null;
2977
+ }
2978
+ }
2979
+ //#endregion
2571
2980
  //#region src/detection-pipeline/pipeline/result-assembler.ts
2572
2981
  /**
2573
2982
  * Small deterministic id generator scoped to a single frame. Each call
@@ -2665,7 +3074,7 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
2665
3074
  });
2666
3075
  break;
2667
3076
  case "text":
2668
- parent.ownLabels.push({
3077
+ if (output.text.trim().length > 0) parent.ownLabels.push({
2669
3078
  label: output.text,
2670
3079
  score: output.confidence
2671
3080
  });
@@ -2748,7 +3157,8 @@ function buildFrameResult(input) {
2748
3157
  embedding,
2749
3158
  ...embeddingModelId !== void 0 ? { embeddingModelId } : {}
2750
3159
  } : {},
2751
- ...m.faceAlignedCrop !== void 0 ? { faceAlignedCrop: m.faceAlignedCrop } : {}
3160
+ ...m.faceAlignedCrop !== void 0 ? { faceAlignedCrop: m.faceAlignedCrop } : {},
3161
+ ...m.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: m.nativeFaceShortSidePx } : {}
2752
3162
  };
2753
3163
  if (input.debug) {
2754
3164
  const cleanDebug = pruneUndefined({
@@ -3051,7 +3461,7 @@ var PipelineExecutor = class {
3051
3461
  *
3052
3462
  * @returns FrameResult + optional trace
3053
3463
  */
3054
- async run(tree, rootInput, fullFrameJpegProvider, imageWidth, imageHeight, deviceId, runOpts, nativeFaceCropProvider, cropZoneBbox) {
3464
+ async run(tree, rootInput, fullFrameJpegProvider, imageWidth, imageHeight, deviceId, runOpts, nativeCropProvider, cropZoneBbox) {
3055
3465
  const startMs = Date.now();
3056
3466
  const verbosity = runOpts?.traceVerbosity ?? "off";
3057
3467
  const traceBuilder = new ExecutionTraceBuilder(verbosity, deviceId, imageWidth, imageHeight, this.opts.engineRuntime);
@@ -3081,7 +3491,7 @@ var PipelineExecutor = class {
3081
3491
  const mutable = this.synthesizeRootDetection(rootOutput, rootStep, idGen, rootMs, imageWidth, imageHeight);
3082
3492
  applyChildOutput(mutable, rootStep, rootOutput, rootMs, ctx);
3083
3493
  try {
3084
- await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeFaceCropProvider);
3494
+ await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3085
3495
  } catch (err) {
3086
3496
  this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", {
3087
3497
  tags: { deviceId },
@@ -3111,7 +3521,7 @@ var PipelineExecutor = class {
3111
3521
  continue;
3112
3522
  }
3113
3523
  try {
3114
- await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeFaceCropProvider);
3524
+ await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3115
3525
  } catch (err) {
3116
3526
  this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", {
3117
3527
  tags: { deviceId },
@@ -3269,7 +3679,7 @@ var PipelineExecutor = class {
3269
3679
  });
3270
3680
  return output;
3271
3681
  }
3272
- async executeChildren(children, parentDetection, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeFaceCropProvider) {
3682
+ async executeChildren(children, parentDetection, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeCropProvider) {
3273
3683
  for (const child of children) {
3274
3684
  if (plane === "frame" && child.definition.inputClasses !== null) continue;
3275
3685
  if (!this.matchesInputClasses(parentDetection.macroClass, child.inputClasses)) continue;
@@ -3292,7 +3702,7 @@ var PipelineExecutor = class {
3292
3702
  }
3293
3703
  const lms = parentDetection.landmarks;
3294
3704
  if (lms && lms.length >= 5) {
3295
- if (!nativeFaceCropProvider && detShortSide < minFaceSize) {
3705
+ if (!nativeCropProvider && detShortSide < minFaceSize) {
3296
3706
  this.warnFaceTooSmall(child, detShortSide, minFaceSize, void 0, deviceId);
3297
3707
  continue;
3298
3708
  }
@@ -3303,8 +3713,8 @@ var PipelineExecutor = class {
3303
3713
  imageWidth,
3304
3714
  imageHeight,
3305
3715
  outSize: modelEntry.inputSize.width,
3306
- ...nativeFaceCropProvider ? {
3307
- nativeCropProvider: nativeFaceCropProvider,
3716
+ ...nativeCropProvider ? {
3717
+ nativeCropProvider,
3308
3718
  nativeMaxWidth: NATIVE_FACE_CROP_MAX_WIDTH
3309
3719
  } : {}
3310
3720
  });
@@ -3317,6 +3727,7 @@ var PipelineExecutor = class {
3317
3727
  cropW = aligned.width;
3318
3728
  cropH = aligned.height;
3319
3729
  parentDetection.faceAlignedCrop = cropJpegBuf.toString("base64");
3730
+ if (aligned.nativeFaceShortSidePx !== void 0) parentDetection.nativeFaceShortSidePx = aligned.nativeFaceShortSidePx;
3320
3731
  } else {
3321
3732
  if (detShortSide < minFaceSize) {
3322
3733
  this.warnFaceTooSmall(child, detShortSide, minFaceSize, void 0, deviceId);
@@ -3328,10 +3739,31 @@ var PipelineExecutor = class {
3328
3739
  cropH = crop.height;
3329
3740
  }
3330
3741
  } else {
3331
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
3332
- cropJpegBuf = crop.jpeg;
3333
- cropW = crop.width;
3334
- cropH = crop.height;
3742
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
3743
+ if (nativeChild) {
3744
+ cropJpegBuf = nativeChild.jpeg;
3745
+ cropW = nativeChild.width;
3746
+ cropH = nativeChild.height;
3747
+ } else {
3748
+ const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
3749
+ cropJpegBuf = crop.jpeg;
3750
+ cropW = crop.width;
3751
+ cropH = crop.height;
3752
+ }
3753
+ if (child.definition.postprocessor === "ctc" && child.settings?.["rectify"] !== false) try {
3754
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
3755
+ if (rectified) {
3756
+ cropJpegBuf = rectified.jpeg;
3757
+ cropW = rectified.width;
3758
+ cropH = rectified.height;
3759
+ this.opts.logger?.debug("Plate crop deskewed before OCR", { meta: {
3760
+ stepId: child.definition.id,
3761
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
3762
+ width: cropW,
3763
+ height: cropH
3764
+ } });
3765
+ }
3766
+ } catch {}
3335
3767
  }
3336
3768
  const childOutput = await this.executeStep(child, {
3337
3769
  kind: "jpeg",
@@ -3351,7 +3783,7 @@ var PipelineExecutor = class {
3351
3783
  y: l.y + py1
3352
3784
  }));
3353
3785
  }
3354
- await this.executeChildren(child.children, detail, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeFaceCropProvider);
3786
+ await this.executeChildren(child.children, detail, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeCropProvider);
3355
3787
  }
3356
3788
  }
3357
3789
  } catch (err) {
@@ -4459,13 +4891,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4459
4891
  }
4460
4892
  async getSchema(engine) {
4461
4893
  if (!engine || !engine.runtime) engine = await this.getSelectedEngine();
4462
- const format = engine.format;
4463
4894
  const customByStep = await this.getCustomModels();
4464
- const slots = buildSchemaSlots(format, this.modelsDir, customByStep);
4465
4895
  const { hardware } = await this.fetchProbeGatingData();
4466
4896
  const env = runtimeEnvFromProcess(toProbedHardware(hardware));
4897
+ const enginesWithDevices = this.getAvailableEnginesWithDevices(env);
4898
+ const slots = buildSchemaSlots(new Set([engine.format, ...enginesWithDevices.map((e) => e.engine.format)]), this.modelsDir, customByStep);
4467
4899
  return {
4468
- availableEngines: this.getAvailableEnginesWithDevices(env).map((e) => this.toAvailableEngine(e)),
4900
+ availableEngines: enginesWithDevices.map((e) => this.toAvailableEngine(e)),
4469
4901
  selectedEngine: { ...engine },
4470
4902
  slots
4471
4903
  };
@@ -4904,6 +5336,42 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4904
5336
  }
4905
5337
  };
4906
5338
  }
5339
+ /**
5340
+ * Build the native-crop provider for the DETAIL plane (`nativeCropRef` set by
5341
+ * `runDetailSubtree` when the parent crop came from the frame's native surface).
5342
+ * Same best-effort round-trip as {@link buildNativeFaceCropProvider}, but the
5343
+ * executor's ROI arrives normalized in the PARENT CROP's space, so it is first
5344
+ * re-composed into FRAME-normalized coordinates (via `cropFrameSpace`) before
5345
+ * hitting the native surface. This is what lets a leaf child (plate-ocr,
5346
+ * face-embedding) read native pixels even though the executor is running on a
5347
+ * cut of the frame, not the whole frame. A compose/native miss → `null` (the
5348
+ * executor falls back to the downscaled tile crop).
5349
+ */
5350
+ buildNativeCropProviderFromRef(ref) {
5351
+ if (!ref) return void 0;
5352
+ const api = this.addonCtx?.api;
5353
+ if (!api) return void 0;
5354
+ const { handle, cropFrameSpace } = ref;
5355
+ return async (roi, maxWidth) => {
5356
+ try {
5357
+ const frameRoi = composeCropRoiToFrameNorm(roi, cropFrameSpace, handle.width, handle.height);
5358
+ if (!frameRoi) return null;
5359
+ const native = await api.pipelineRunner.getNativeCrop.query({
5360
+ handle,
5361
+ bbox: frameRoi,
5362
+ ...maxWidth !== void 0 ? { maxWidth } : {}
5363
+ }, require_dist.nodePin(handle.nodeId));
5364
+ if (!native || !native.bytes || native.width <= 0 || native.height <= 0) return null;
5365
+ return {
5366
+ bytes: native.bytes,
5367
+ width: native.width,
5368
+ height: native.height
5369
+ };
5370
+ } catch {
5371
+ return null;
5372
+ }
5373
+ };
5374
+ }
4907
5375
  async runPipeline(input, onProgress) {
4908
5376
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
4909
5377
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -4962,8 +5430,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4962
5430
  jpegProvider = async () => data;
4963
5431
  } else if (frame.format === "rgb" || frame.format === "bgr" || frame.format === "gray") {
4964
5432
  const channels = frame.format === "gray" ? 1 : 3;
4965
- const sharp$3 = (await import("sharp")).default;
4966
- const jpeg = await sharp$3(data, { raw: {
5433
+ const sharp$5 = (await import("sharp")).default;
5434
+ const jpeg = await sharp$5(data, { raw: {
4967
5435
  width: frame.width,
4968
5436
  height: frame.height,
4969
5437
  channels
@@ -4977,8 +5445,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4977
5445
  };
4978
5446
  jpegProvider = async () => jpeg;
4979
5447
  } else {
4980
- const sharp$4 = (await import("sharp")).default;
4981
- const encoded = await sharp$4(data, { raw: {
5448
+ const sharp$6 = (await import("sharp")).default;
5449
+ const encoded = await sharp$6(data, { raw: {
4982
5450
  width: frame.width,
4983
5451
  height: frame.height,
4984
5452
  channels: 3
@@ -5162,7 +5630,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5162
5630
  const effectiveDeviceId = input.deviceId ?? 0;
5163
5631
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5164
5632
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
5165
- const nativeFaceCropProvider = this.buildNativeFaceCropProvider(input.frameHandle);
5633
+ const nativeCropProvider = this.buildNativeCropProviderFromRef(input.nativeCropRef) ?? this.buildNativeFaceCropProvider(input.frameHandle);
5166
5634
  let cropZoneBbox;
5167
5635
  if (isRuntime && effectiveDeviceId > 0 && effectiveTree.roots.some((r) => r.definition.extractMode === "crop-zone")) {
5168
5636
  await this.ensureDeviceProxy(effectiveDeviceId);
@@ -5172,7 +5640,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5172
5640
  const { result, trace } = await executor.run(effectiveTree, rootInput, jpegProvider, imageWidth, imageHeight, effectiveDeviceId, {
5173
5641
  traceVerbosity: isRuntime ? this.eventBus ? "summary" : "off" : "full",
5174
5642
  plane: input.plane
5175
- }, nativeFaceCropProvider, cropZoneBbox);
5643
+ }, nativeCropProvider, cropZoneBbox);
5176
5644
  if (isRuntime) {
5177
5645
  if (trace && this.eventBus) this.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineTrace, {
5178
5646
  type: "device",
@@ -5462,8 +5930,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5462
5930
  /** Parse JPEG/PNG dimensions without decoding the full image */
5463
5931
  async getJpegDimensions(buf) {
5464
5932
  try {
5465
- const sharp$5 = (await import("sharp")).default;
5466
- const { width, height } = await sharp$5(buf).metadata();
5933
+ const sharp$7 = (await import("sharp")).default;
5934
+ const { width, height } = await sharp$7(buf).metadata();
5467
5935
  return {
5468
5936
  width: width ?? 640,
5469
5937
  height: height ?? 640
@@ -6233,11 +6701,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6233
6701
  }
6234
6702
  }
6235
6703
  };
6236
- function buildSchemaSlots(format, modelsDir, customByStep) {
6704
+ function buildSchemaSlots(formats, modelsDir, customByStep) {
6237
6705
  const slotMap = /* @__PURE__ */ new Map();
6706
+ const formatList = [...formats];
6238
6707
  for (const pipelineStep of require_step_definitions.ALL_PIPELINE_STEPS) {
6239
6708
  const step = pipelineStep.definition;
6240
- const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format] && m.legacy !== true);
6709
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.legacy !== true && formatList.some((f) => m.formats[f]));
6241
6710
  if (availableModels.length === 0) continue;
6242
6711
  const slot = step.slot;
6243
6712
  if (!slotMap.has(slot)) slotMap.set(slot, []);