@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
@@ -1,6 +1,6 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { B as supportedRuntimes$1, C as detectionPipelineCapability, H as errMsg, J as hydrateSchema, L as runtimeDevices$1, M as pipelineExecutorCapability, Q as parseJsonUnknown, T as evaluateZoneRules, U as BaseAddon, dt as string, ft as union, h as YAMNET_TO_MACRO, j as nodePin, lt as object, pt as EventCategory, q as createEvent, rt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, tt as sleep, w as enumerateInferenceDevices, x as defaultDeviceFor$1 } from "../dist-ZB-B8UrH.mjs";
3
- import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-BKJI5CXI.mjs";
2
+ import { B as supportedRuntimes$1, C as detectionPipelineCapability, H as errMsg, J as hydrateSchema, L as runtimeDevices$1, M as pipelineExecutorCapability, Q as parseJsonUnknown, T as evaluateZoneRules, U as BaseAddon, dt as string, ft as union, h as YAMNET_TO_MACRO, j as nodePin, lt as object, pt as EventCategory, q as createEvent, rt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, tt as sleep, w as enumerateInferenceDevices, x as defaultDeviceFor$1 } from "../dist-BwyGTnMu.mjs";
3
+ import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-DN1obXS7.mjs";
4
4
  import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
5
5
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
6
6
  import { spawn } from "node:child_process";
@@ -213,6 +213,43 @@ function normalizeEngineNodeId(rawNodeId) {
213
213
  return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
214
214
  }
215
215
  //#endregion
216
+ //#region src/detection-pipeline/pipeline/native-crop-compose.ts
217
+ /** Clamp `v` into `[lo, hi]`. */
218
+ function clamp(v, lo, hi) {
219
+ if (v < lo) return lo;
220
+ if (v > hi) return hi;
221
+ return v;
222
+ }
223
+ /**
224
+ * Compose a child `roi` given in the parent CROP's normalized space into the
225
+ * FRAME's normalized space, given the crop's frame-space pixel rectangle and the
226
+ * frame dimensions. The result is clamped to the unit square (origin in
227
+ * `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
228
+ * degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
229
+ * clamps to zero area) so the caller falls back to the downscaled tile crop.
230
+ *
231
+ * Pure — never mutates its arguments.
232
+ */
233
+ function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
234
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
235
+ if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
236
+ const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
237
+ const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
238
+ const fw = roi.w * cropFrameSpace.w / frameWidth;
239
+ const fh = roi.h * cropFrameSpace.h / frameHeight;
240
+ const x = clamp(fx, 0, 1);
241
+ const y = clamp(fy, 0, 1);
242
+ const w = clamp(fw, 0, 1 - x);
243
+ const h = clamp(fh, 0, 1 - y);
244
+ if (!(w > 0) || !(h > 0)) return null;
245
+ return {
246
+ x,
247
+ y,
248
+ w,
249
+ h
250
+ };
251
+ }
252
+ //#endregion
216
253
  //#region src/detection-pipeline/default-detection-model.ts
217
254
  /** The object-detection step id — the only slot this resolver applies to. */
218
255
  var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
@@ -901,6 +938,9 @@ function serializeModelConfig(config) {
901
938
  if (config.inputChannels !== void 0) result["inputChannels"] = config.inputChannels;
902
939
  if (config.labels) result["labels"] = config.labels;
903
940
  if (config.charset) result["charset"] = config.charset;
941
+ if (config.plateRegion !== void 0) result["plateRegion"] = config.plateRegion;
942
+ if (config.minTextLength !== void 0) result["minTextLength"] = config.minTextLength;
943
+ if (config.minTextConfidence !== void 0) result["minTextConfidence"] = config.minTextConfidence;
904
944
  if (config.numClasses) result["numClasses"] = config.numClasses;
905
945
  if (config.strides) result["strides"] = config.strides;
906
946
  if (config.maskThreshold !== void 0) result["maskThreshold"] = config.maskThreshold;
@@ -1435,6 +1475,9 @@ var EngineFactory = class {
1435
1475
  nmsIouThreshold: this.opts.nmsIouThreshold ?? .45,
1436
1476
  labels,
1437
1477
  charset: def.charset,
1478
+ plateRegion: def.plateRegion,
1479
+ minTextLength: def.minTextLength,
1480
+ minTextConfidence: def.minTextConfidence,
1438
1481
  numClasses: labels?.length,
1439
1482
  strides: def.postprocessor === "scrfd" ? [
1440
1483
  8,
@@ -1639,6 +1682,14 @@ var EngineProvisioner = class {
1639
1682
  /** Detector NMS IoU fallback — the historical hardcoded literal, used when the
1640
1683
  * step carries no `nmsIouThreshold` setting (unset == today). */
1641
1684
  var DEFAULT_NMS_IOU = .45;
1685
+ /**
1686
+ * Region plate grammars for the TS fallback CTC decoder (nodejs+onnx path).
1687
+ * VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
1688
+ * confusables — the confusable repair needs the aligned per-timestep softmax
1689
+ * that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
1690
+ * the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
1691
+ */
1692
+ var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
1642
1693
  var VALID_KINDS = new Set([
1643
1694
  "detections",
1644
1695
  "classifications",
@@ -1915,9 +1966,20 @@ function postprocessCtc(output, stepDef) {
1915
1966
  if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
1916
1967
  prev = bestIdx;
1917
1968
  }
1969
+ const text = chars.join("");
1970
+ const region = stepDef.plateRegion;
1971
+ if (region !== void 0 && region !== "off") {
1972
+ const grammar = TS_PLATE_GRAMMARS[region];
1973
+ if (grammar) return {
1974
+ kind: "text",
1975
+ text,
1976
+ confidence: seqLen > 0 ? totalScore / seqLen : 0,
1977
+ formatValid: grammar.test(text)
1978
+ };
1979
+ }
1918
1980
  return {
1919
1981
  kind: "text",
1920
- text: chars.join(""),
1982
+ text,
1921
1983
  confidence: seqLen > 0 ? totalScore / seqLen : 0
1922
1984
  };
1923
1985
  }
@@ -2560,6 +2622,353 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
2560
2622
  };
2561
2623
  }
2562
2624
  //#endregion
2625
+ //#region src/detection-pipeline/pipeline/native-child-crop.ts
2626
+ /**
2627
+ * NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
2628
+ * — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
2629
+ *
2630
+ * On the detail plane the executor runs a child model against a parent crop
2631
+ * (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
2632
+ * in the parent crop's PIXEL space) is what the child model reads. Cutting it
2633
+ * from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
2634
+ * to the tile's resolution — fatal for OCR on a small, distant plate. When a
2635
+ * native-crop provider is bound to the frame, we instead resolve the SAME ROI
2636
+ * straight from the frame's retained NATIVE surface (the provider re-composes
2637
+ * the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
2638
+ * giving the model native pixels. A miss/degenerate returns `null` so the caller
2639
+ * falls back to the existing tile crop (never an upscale — native or the tile).
2640
+ */
2641
+ /**
2642
+ * Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
2643
+ * native pixels wide, so this never binds for plates (effectively uncapped —
2644
+ * the quality path the task requires); it only bounds a pathologically large
2645
+ * whole-object classifier ROI so the in-process native fetch/encode stays cheap.
2646
+ */
2647
+ var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
2648
+ /**
2649
+ * Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
2650
+ * resolution via `provider`, returning a JPEG crop + its native dimensions.
2651
+ * Returns `null` on a degenerate ROI or any provider miss (so the caller uses
2652
+ * the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
2653
+ * the sharp encode of the returned pixels.
2654
+ */
2655
+ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
2656
+ if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
2657
+ const x1 = Math.max(0, bbox[0]);
2658
+ const y1 = Math.max(0, bbox[1]);
2659
+ const x2 = Math.min(imageWidth, bbox[2]);
2660
+ const y2 = Math.min(imageHeight, bbox[3]);
2661
+ const w = x2 - x1;
2662
+ const h = y2 - y1;
2663
+ if (w < 1 || h < 1) return null;
2664
+ const native = await provider({
2665
+ x: x1 / imageWidth,
2666
+ y: y1 / imageHeight,
2667
+ w: w / imageWidth,
2668
+ h: h / imageHeight
2669
+ }, maxWidth);
2670
+ if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
2671
+ return {
2672
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
2673
+ width: native.width,
2674
+ height: native.height,
2675
+ channels: 3
2676
+ } }).jpeg({ quality: 90 }).toBuffer(),
2677
+ width: native.width,
2678
+ height: native.height
2679
+ };
2680
+ }
2681
+ //#endregion
2682
+ //#region src/detection-pipeline/pipeline/plate-deskew.ts
2683
+ /**
2684
+ * Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
2685
+ *
2686
+ * Plate crops reach the CTC recognizer as an axis-aligned bbox tile with NO
2687
+ * geometric correction. On steeply oblique cameras (cam 617) the plate text is
2688
+ * skewed, and CTC collapses beyond ~10-15° of baseline tilt. This module
2689
+ * estimates the plate's dominant rotation from the crop ALONE (no plate quad is
2690
+ * available) and rotates the tile so the text baseline is horizontal before OCR.
2691
+ *
2692
+ * Approach (classic projection-profile deskew, pure TS — no OpenCV, no new
2693
+ * native deps; sharp is used only to decode/encode JPEG, mirroring the sibling
2694
+ * `native-child-crop.ts` / `face-align.ts`):
2695
+ * 1. grayscale → isotropic Sobel gradient magnitude (an orientation-agnostic
2696
+ * "ink/edge" map, robust to plate polarity and lighting).
2697
+ * 2. for a small set of candidate CORRECTION angles, rotate the gradient map
2698
+ * (bilinear, same primitive family as the face warp) and score the
2699
+ * horizontal projection profile by its energy `Σ rowSum²`. That energy is
2700
+ * MAXIMISED when the text edges land on horizontal rows — the profile is
2701
+ * peaky (a few high rows) rather than smeared across many rows.
2702
+ * 3. pick the angle maximising the score (coarse 2° sweep + a 0.5° local
2703
+ * refinement); apply it to the full-resolution RGB tile only when it is
2704
+ * meaningfully non-zero.
2705
+ *
2706
+ * All of the geometry is pure functions over raw buffers, so the estimator is
2707
+ * fully deterministic and unit-testable without any image I/O.
2708
+ */
2709
+ var DEG2RAD = Math.PI / 180;
2710
+ /** Convert an interleaved RGB/RGBA buffer to single-channel luma. */
2711
+ function toGrayscale(rgb) {
2712
+ const { data, width, height, channels } = rgb;
2713
+ const out = new Uint8Array(width * height);
2714
+ 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;
2715
+ return {
2716
+ data: out,
2717
+ width,
2718
+ height
2719
+ };
2720
+ }
2721
+ /**
2722
+ * Isotropic Sobel gradient magnitude of a grayscale image, clamped to [0,255].
2723
+ * Border pixels are 0 (no valid 3×3 neighbourhood) — they carry no baseline
2724
+ * signal anyway. The magnitude is polarity-agnostic, so it responds to both
2725
+ * dark-on-light and light-on-dark plates.
2726
+ */
2727
+ function sobelMagnitude(gray) {
2728
+ const { data, width, height } = gray;
2729
+ const out = new Uint8Array(width * height);
2730
+ for (let y = 1; y < height - 1; y++) for (let x = 1; x < width - 1; x++) {
2731
+ const i = y * width + x;
2732
+ const tl = data[i - width - 1];
2733
+ const tc = data[i - width];
2734
+ const tr = data[i - width + 1];
2735
+ const ml = data[i - 1];
2736
+ const mr = data[i + 1];
2737
+ const bl = data[i + width - 1];
2738
+ const bc = data[i + width];
2739
+ const br = data[i + width + 1];
2740
+ const gx = tr + 2 * mr + br - (tl + 2 * ml + bl);
2741
+ const gy = bl + 2 * bc + br - (tl + 2 * tc + tr);
2742
+ const mag = Math.sqrt(gx * gx + gy * gy);
2743
+ out[i] = mag > 255 ? 255 : mag;
2744
+ }
2745
+ return {
2746
+ data: out,
2747
+ width,
2748
+ height
2749
+ };
2750
+ }
2751
+ /**
2752
+ * Rotate a raw image (any channel count) about its centre by `angleRad`
2753
+ * (positive = counter-clockwise) using bilinear interpolation — the same
2754
+ * resampling family as the face-alignment warp. When `expand` is true the
2755
+ * output canvas grows to contain the whole rotated rectangle (no corner
2756
+ * clipping); otherwise it keeps the source dimensions. Out-of-bounds samples
2757
+ * take the `fill` value (default 0), so an expanded canvas gets a flat border.
2758
+ */
2759
+ function rotateRaw(src, angleRad, opts = {}) {
2760
+ const { data, width: w, height: h, channels: ch } = src;
2761
+ const expand = opts.expand ?? false;
2762
+ const fill = opts.fill ?? 0;
2763
+ const c = Math.cos(angleRad);
2764
+ const s = Math.sin(angleRad);
2765
+ let ow = w;
2766
+ let oh = h;
2767
+ if (expand) {
2768
+ ow = Math.max(1, Math.ceil(Math.abs(w * c) + Math.abs(h * s)));
2769
+ oh = Math.max(1, Math.ceil(Math.abs(w * s) + Math.abs(h * c)));
2770
+ }
2771
+ const cx = (w - 1) / 2;
2772
+ const cy = (h - 1) / 2;
2773
+ const ocx = (ow - 1) / 2;
2774
+ const ocy = (oh - 1) / 2;
2775
+ const out = new Uint8Array(ow * oh * ch);
2776
+ const maxX = w - 1;
2777
+ const maxY = h - 1;
2778
+ for (let oy = 0; oy < oh; oy++) {
2779
+ const dyo = oy - ocy;
2780
+ for (let ox = 0; ox < ow; ox++) {
2781
+ const dxo = ox - ocx;
2782
+ const sx = c * dxo + s * dyo + cx;
2783
+ const sy = -s * dxo + c * dyo + cy;
2784
+ const o = (oy * ow + ox) * ch;
2785
+ if (sx < 0 || sx > maxX || sy < 0 || sy > maxY) {
2786
+ for (let k = 0; k < ch; k++) out[o + k] = fill;
2787
+ continue;
2788
+ }
2789
+ const x0 = Math.floor(sx);
2790
+ const y0 = Math.floor(sy);
2791
+ const x1 = x0 < maxX ? x0 + 1 : x0;
2792
+ const y1 = y0 < maxY ? y0 + 1 : y0;
2793
+ const fx = sx - x0;
2794
+ const fy = sy - y0;
2795
+ const w00 = (1 - fx) * (1 - fy);
2796
+ const w10 = fx * (1 - fy);
2797
+ const w01 = (1 - fx) * fy;
2798
+ const w11 = fx * fy;
2799
+ const i00 = (y0 * w + x0) * ch;
2800
+ const i10 = (y0 * w + x1) * ch;
2801
+ const i01 = (y1 * w + x0) * ch;
2802
+ const i11 = (y1 * w + x1) * ch;
2803
+ 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;
2804
+ }
2805
+ }
2806
+ return {
2807
+ data: out,
2808
+ width: ow,
2809
+ height: oh,
2810
+ channels: ch
2811
+ };
2812
+ }
2813
+ /**
2814
+ * Horizontal-projection-profile ENERGY of a single-channel map: `Σ_row rowSum²`.
2815
+ * Higher when the map's mass is concentrated in a few rows (aligned text) rather
2816
+ * than smeared across many (skewed text). Total mass is ~conserved by the
2817
+ * bilinear rotation, so this energy cleanly ranks candidate rotations.
2818
+ */
2819
+ function projectionEnergy(gray) {
2820
+ const { data, width, height } = gray;
2821
+ let energy = 0;
2822
+ for (let y = 0; y < height; y++) {
2823
+ let rowSum = 0;
2824
+ const base = y * width;
2825
+ for (let x = 0; x < width; x++) rowSum += data[base + x];
2826
+ energy += rowSum * rowSum;
2827
+ }
2828
+ return energy;
2829
+ }
2830
+ /** Area-average downscale of a gray map so its longest side ≤ `maxDim` (never upscales). */
2831
+ function downscaleGray(gray, maxDim) {
2832
+ const { data, width, height } = gray;
2833
+ const longest = Math.max(width, height);
2834
+ if (longest <= maxDim) return gray;
2835
+ const scale = maxDim / longest;
2836
+ const nw = Math.max(1, Math.round(width * scale));
2837
+ const nh = Math.max(1, Math.round(height * scale));
2838
+ const out = new Uint8Array(nw * nh);
2839
+ const fx = width / nw;
2840
+ const fy = height / nh;
2841
+ for (let y = 0; y < nh; y++) {
2842
+ const sy0 = Math.floor(y * fy);
2843
+ const sy1 = Math.min(height, Math.floor((y + 1) * fy));
2844
+ for (let x = 0; x < nw; x++) {
2845
+ const sx0 = Math.floor(x * fx);
2846
+ const sx1 = Math.min(width, Math.floor((x + 1) * fx));
2847
+ let sum = 0;
2848
+ let n = 0;
2849
+ for (let yy = sy0; yy < Math.max(sy0 + 1, sy1); yy++) {
2850
+ const base = yy * width;
2851
+ for (let xx = sx0; xx < Math.max(sx0 + 1, sx1); xx++) {
2852
+ sum += data[base + xx];
2853
+ n++;
2854
+ }
2855
+ }
2856
+ out[y * nw + x] = n > 0 ? sum / n | 0 : 0;
2857
+ }
2858
+ }
2859
+ return {
2860
+ data: out,
2861
+ width: nw,
2862
+ height: nh
2863
+ };
2864
+ }
2865
+ /** Score one correction angle: rotate the gradient map and take its profile energy. */
2866
+ function scoreAngle(gradient, angleRad) {
2867
+ if (angleRad === 0) return projectionEnergy(gradient);
2868
+ const rotated = rotateRaw({
2869
+ data: gradient.data,
2870
+ width: gradient.width,
2871
+ height: gradient.height,
2872
+ channels: 1
2873
+ }, angleRad, {
2874
+ expand: true,
2875
+ fill: 0
2876
+ });
2877
+ return projectionEnergy({
2878
+ data: rotated.data,
2879
+ width: rotated.width,
2880
+ height: rotated.height
2881
+ });
2882
+ }
2883
+ /**
2884
+ * Estimate the dominant text-baseline skew of a grayscale plate crop and return
2885
+ * the CORRECTION angle (degrees) that levels it. A coarse sweep over
2886
+ * ±`maxAngleDeg` in `coarseStepDeg` steps finds the best bucket; a `fineStepDeg`
2887
+ * local search refines it. Returns `0` when the flat (no-rotation) profile is
2888
+ * already the strongest response.
2889
+ */
2890
+ function estimateSkewAngle(gray, opts = {}) {
2891
+ const maxAngleDeg = opts.maxAngleDeg ?? 30;
2892
+ const coarseStepDeg = opts.coarseStepDeg ?? 2;
2893
+ const fineStepDeg = opts.fineStepDeg ?? .5;
2894
+ const scoreMaxDim = opts.scoreMaxDim ?? 480;
2895
+ if (gray.width < 4 || gray.height < 4) return {
2896
+ angleDeg: 0,
2897
+ score: 0
2898
+ };
2899
+ const gradient = sobelMagnitude(downscaleGray(gray, scoreMaxDim));
2900
+ let bestAngle = 0;
2901
+ let bestScore = scoreAngle(gradient, 0);
2902
+ for (let deg = -maxAngleDeg; deg <= maxAngleDeg + 1e-9; deg += coarseStepDeg) {
2903
+ if (deg === 0) continue;
2904
+ const score = scoreAngle(gradient, deg * DEG2RAD);
2905
+ if (score > bestScore) {
2906
+ bestScore = score;
2907
+ bestAngle = deg;
2908
+ }
2909
+ }
2910
+ const lo = bestAngle - coarseStepDeg;
2911
+ const hi = bestAngle + coarseStepDeg;
2912
+ for (let deg = lo; deg <= hi + 1e-9; deg += fineStepDeg) {
2913
+ if (deg < -maxAngleDeg || deg > maxAngleDeg) continue;
2914
+ const score = scoreAngle(gradient, deg * DEG2RAD);
2915
+ if (score > bestScore) {
2916
+ bestScore = score;
2917
+ bestAngle = deg;
2918
+ }
2919
+ }
2920
+ return {
2921
+ angleDeg: bestAngle,
2922
+ score: bestScore
2923
+ };
2924
+ }
2925
+ /**
2926
+ * Rectify an oblique plate crop: decode the JPEG, estimate the baseline skew,
2927
+ * and — only when it exceeds {@link MIN_RECTIFY_ANGLE_DEG} — rotate the
2928
+ * full-resolution RGB tile to level the text and re-encode. Returns `null` when
2929
+ * no meaningful rotation is needed (caller keeps the original tile) or on any
2930
+ * decode/degenerate failure (never worse than the un-rectified input).
2931
+ *
2932
+ * The returned `angleDeg` is the applied correction (0 is never returned with a
2933
+ * jpeg — a null means "unchanged").
2934
+ */
2935
+ async function rectifyPlateJpeg(jpeg, opts = {}) {
2936
+ const raw = await sharp(jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true }).catch(() => null);
2937
+ if (!raw) return null;
2938
+ const { data, info } = raw;
2939
+ if (info.width < 4 || info.height < 4) return null;
2940
+ const rgb = {
2941
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
2942
+ width: info.width,
2943
+ height: info.height,
2944
+ channels: info.channels
2945
+ };
2946
+ const gray = toGrayscale(rgb);
2947
+ const { angleDeg } = estimateSkewAngle(gray, opts);
2948
+ if (Math.abs(angleDeg) < 2) return null;
2949
+ let mean = 0;
2950
+ for (let i = 0; i < gray.data.length; i++) mean += gray.data[i];
2951
+ const fill = gray.data.length > 0 ? mean / gray.data.length | 0 : 0;
2952
+ const rotated = rotateRaw(rgb, angleDeg * DEG2RAD, {
2953
+ expand: true,
2954
+ fill
2955
+ });
2956
+ try {
2957
+ return {
2958
+ jpeg: await sharp(Buffer.from(rotated.data), { raw: {
2959
+ width: rotated.width,
2960
+ height: rotated.height,
2961
+ channels: rotated.channels
2962
+ } }).jpeg({ quality: opts.quality ?? 90 }).toBuffer(),
2963
+ width: rotated.width,
2964
+ height: rotated.height,
2965
+ angleDeg
2966
+ };
2967
+ } catch {
2968
+ return null;
2969
+ }
2970
+ }
2971
+ //#endregion
2563
2972
  //#region src/detection-pipeline/pipeline/result-assembler.ts
2564
2973
  /**
2565
2974
  * Small deterministic id generator scoped to a single frame. Each call
@@ -2657,7 +3066,7 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
2657
3066
  });
2658
3067
  break;
2659
3068
  case "text":
2660
- parent.ownLabels.push({
3069
+ if (output.text.trim().length > 0) parent.ownLabels.push({
2661
3070
  label: output.text,
2662
3071
  score: output.confidence
2663
3072
  });
@@ -2740,7 +3149,8 @@ function buildFrameResult(input) {
2740
3149
  embedding,
2741
3150
  ...embeddingModelId !== void 0 ? { embeddingModelId } : {}
2742
3151
  } : {},
2743
- ...m.faceAlignedCrop !== void 0 ? { faceAlignedCrop: m.faceAlignedCrop } : {}
3152
+ ...m.faceAlignedCrop !== void 0 ? { faceAlignedCrop: m.faceAlignedCrop } : {},
3153
+ ...m.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: m.nativeFaceShortSidePx } : {}
2744
3154
  };
2745
3155
  if (input.debug) {
2746
3156
  const cleanDebug = pruneUndefined({
@@ -3043,7 +3453,7 @@ var PipelineExecutor = class {
3043
3453
  *
3044
3454
  * @returns FrameResult + optional trace
3045
3455
  */
3046
- async run(tree, rootInput, fullFrameJpegProvider, imageWidth, imageHeight, deviceId, runOpts, nativeFaceCropProvider, cropZoneBbox) {
3456
+ async run(tree, rootInput, fullFrameJpegProvider, imageWidth, imageHeight, deviceId, runOpts, nativeCropProvider, cropZoneBbox) {
3047
3457
  const startMs = Date.now();
3048
3458
  const verbosity = runOpts?.traceVerbosity ?? "off";
3049
3459
  const traceBuilder = new ExecutionTraceBuilder(verbosity, deviceId, imageWidth, imageHeight, this.opts.engineRuntime);
@@ -3073,7 +3483,7 @@ var PipelineExecutor = class {
3073
3483
  const mutable = this.synthesizeRootDetection(rootOutput, rootStep, idGen, rootMs, imageWidth, imageHeight);
3074
3484
  applyChildOutput(mutable, rootStep, rootOutput, rootMs, ctx);
3075
3485
  try {
3076
- await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeFaceCropProvider);
3486
+ await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3077
3487
  } catch (err) {
3078
3488
  this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", {
3079
3489
  tags: { deviceId },
@@ -3103,7 +3513,7 @@ var PipelineExecutor = class {
3103
3513
  continue;
3104
3514
  }
3105
3515
  try {
3106
- await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeFaceCropProvider);
3516
+ await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3107
3517
  } catch (err) {
3108
3518
  this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", {
3109
3519
  tags: { deviceId },
@@ -3261,7 +3671,7 @@ var PipelineExecutor = class {
3261
3671
  });
3262
3672
  return output;
3263
3673
  }
3264
- async executeChildren(children, parentDetection, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeFaceCropProvider) {
3674
+ async executeChildren(children, parentDetection, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeCropProvider) {
3265
3675
  for (const child of children) {
3266
3676
  if (plane === "frame" && child.definition.inputClasses !== null) continue;
3267
3677
  if (!this.matchesInputClasses(parentDetection.macroClass, child.inputClasses)) continue;
@@ -3284,7 +3694,7 @@ var PipelineExecutor = class {
3284
3694
  }
3285
3695
  const lms = parentDetection.landmarks;
3286
3696
  if (lms && lms.length >= 5) {
3287
- if (!nativeFaceCropProvider && detShortSide < minFaceSize) {
3697
+ if (!nativeCropProvider && detShortSide < minFaceSize) {
3288
3698
  this.warnFaceTooSmall(child, detShortSide, minFaceSize, void 0, deviceId);
3289
3699
  continue;
3290
3700
  }
@@ -3295,8 +3705,8 @@ var PipelineExecutor = class {
3295
3705
  imageWidth,
3296
3706
  imageHeight,
3297
3707
  outSize: modelEntry.inputSize.width,
3298
- ...nativeFaceCropProvider ? {
3299
- nativeCropProvider: nativeFaceCropProvider,
3708
+ ...nativeCropProvider ? {
3709
+ nativeCropProvider,
3300
3710
  nativeMaxWidth: NATIVE_FACE_CROP_MAX_WIDTH
3301
3711
  } : {}
3302
3712
  });
@@ -3309,6 +3719,7 @@ var PipelineExecutor = class {
3309
3719
  cropW = aligned.width;
3310
3720
  cropH = aligned.height;
3311
3721
  parentDetection.faceAlignedCrop = cropJpegBuf.toString("base64");
3722
+ if (aligned.nativeFaceShortSidePx !== void 0) parentDetection.nativeFaceShortSidePx = aligned.nativeFaceShortSidePx;
3312
3723
  } else {
3313
3724
  if (detShortSide < minFaceSize) {
3314
3725
  this.warnFaceTooSmall(child, detShortSide, minFaceSize, void 0, deviceId);
@@ -3320,10 +3731,31 @@ var PipelineExecutor = class {
3320
3731
  cropH = crop.height;
3321
3732
  }
3322
3733
  } else {
3323
- const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
3324
- cropJpegBuf = crop.jpeg;
3325
- cropW = crop.width;
3326
- cropH = crop.height;
3734
+ const nativeChild = nativeCropProvider && child.children.length === 0 ? await nativeChildCrop(nativeCropProvider, parentDetection.bbox, imageWidth, imageHeight, NATIVE_CHILD_CROP_MAX_WIDTH) : null;
3735
+ if (nativeChild) {
3736
+ cropJpegBuf = nativeChild.jpeg;
3737
+ cropW = nativeChild.width;
3738
+ cropH = nativeChild.height;
3739
+ } else {
3740
+ const crop = await cropJpeg(fullFrameJpeg, parentDetection.bbox, imageWidth, imageHeight);
3741
+ cropJpegBuf = crop.jpeg;
3742
+ cropW = crop.width;
3743
+ cropH = crop.height;
3744
+ }
3745
+ if (child.definition.postprocessor === "ctc" && child.settings?.["rectify"] !== false) try {
3746
+ const rectified = await rectifyPlateJpeg(cropJpegBuf);
3747
+ if (rectified) {
3748
+ cropJpegBuf = rectified.jpeg;
3749
+ cropW = rectified.width;
3750
+ cropH = rectified.height;
3751
+ this.opts.logger?.debug("Plate crop deskewed before OCR", { meta: {
3752
+ stepId: child.definition.id,
3753
+ angleDeg: Number(rectified.angleDeg.toFixed(2)),
3754
+ width: cropW,
3755
+ height: cropH
3756
+ } });
3757
+ }
3758
+ } catch {}
3327
3759
  }
3328
3760
  const childOutput = await this.executeStep(child, {
3329
3761
  kind: "jpeg",
@@ -3343,7 +3775,7 @@ var PipelineExecutor = class {
3343
3775
  y: l.y + py1
3344
3776
  }));
3345
3777
  }
3346
- await this.executeChildren(child.children, detail, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeFaceCropProvider);
3778
+ await this.executeChildren(child.children, detail, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, plane, deviceId, nativeCropProvider);
3347
3779
  }
3348
3780
  }
3349
3781
  } catch (err) {
@@ -4451,13 +4883,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4451
4883
  }
4452
4884
  async getSchema(engine) {
4453
4885
  if (!engine || !engine.runtime) engine = await this.getSelectedEngine();
4454
- const format = engine.format;
4455
4886
  const customByStep = await this.getCustomModels();
4456
- const slots = buildSchemaSlots(format, this.modelsDir, customByStep);
4457
4887
  const { hardware } = await this.fetchProbeGatingData();
4458
4888
  const env = runtimeEnvFromProcess(toProbedHardware(hardware));
4889
+ const enginesWithDevices = this.getAvailableEnginesWithDevices(env);
4890
+ const slots = buildSchemaSlots(new Set([engine.format, ...enginesWithDevices.map((e) => e.engine.format)]), this.modelsDir, customByStep);
4459
4891
  return {
4460
- availableEngines: this.getAvailableEnginesWithDevices(env).map((e) => this.toAvailableEngine(e)),
4892
+ availableEngines: enginesWithDevices.map((e) => this.toAvailableEngine(e)),
4461
4893
  selectedEngine: { ...engine },
4462
4894
  slots
4463
4895
  };
@@ -4896,6 +5328,42 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4896
5328
  }
4897
5329
  };
4898
5330
  }
5331
+ /**
5332
+ * Build the native-crop provider for the DETAIL plane (`nativeCropRef` set by
5333
+ * `runDetailSubtree` when the parent crop came from the frame's native surface).
5334
+ * Same best-effort round-trip as {@link buildNativeFaceCropProvider}, but the
5335
+ * executor's ROI arrives normalized in the PARENT CROP's space, so it is first
5336
+ * re-composed into FRAME-normalized coordinates (via `cropFrameSpace`) before
5337
+ * hitting the native surface. This is what lets a leaf child (plate-ocr,
5338
+ * face-embedding) read native pixels even though the executor is running on a
5339
+ * cut of the frame, not the whole frame. A compose/native miss → `null` (the
5340
+ * executor falls back to the downscaled tile crop).
5341
+ */
5342
+ buildNativeCropProviderFromRef(ref) {
5343
+ if (!ref) return void 0;
5344
+ const api = this.addonCtx?.api;
5345
+ if (!api) return void 0;
5346
+ const { handle, cropFrameSpace } = ref;
5347
+ return async (roi, maxWidth) => {
5348
+ try {
5349
+ const frameRoi = composeCropRoiToFrameNorm(roi, cropFrameSpace, handle.width, handle.height);
5350
+ if (!frameRoi) return null;
5351
+ const native = await api.pipelineRunner.getNativeCrop.query({
5352
+ handle,
5353
+ bbox: frameRoi,
5354
+ ...maxWidth !== void 0 ? { maxWidth } : {}
5355
+ }, nodePin(handle.nodeId));
5356
+ if (!native || !native.bytes || native.width <= 0 || native.height <= 0) return null;
5357
+ return {
5358
+ bytes: native.bytes,
5359
+ width: native.width,
5360
+ height: native.height
5361
+ };
5362
+ } catch {
5363
+ return null;
5364
+ }
5365
+ };
5366
+ }
4899
5367
  async runPipeline(input, onProgress) {
4900
5368
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
4901
5369
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -5154,7 +5622,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5154
5622
  const effectiveDeviceId = input.deviceId ?? 0;
5155
5623
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5156
5624
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
5157
- const nativeFaceCropProvider = this.buildNativeFaceCropProvider(input.frameHandle);
5625
+ const nativeCropProvider = this.buildNativeCropProviderFromRef(input.nativeCropRef) ?? this.buildNativeFaceCropProvider(input.frameHandle);
5158
5626
  let cropZoneBbox;
5159
5627
  if (isRuntime && effectiveDeviceId > 0 && effectiveTree.roots.some((r) => r.definition.extractMode === "crop-zone")) {
5160
5628
  await this.ensureDeviceProxy(effectiveDeviceId);
@@ -5164,7 +5632,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5164
5632
  const { result, trace } = await executor.run(effectiveTree, rootInput, jpegProvider, imageWidth, imageHeight, effectiveDeviceId, {
5165
5633
  traceVerbosity: isRuntime ? this.eventBus ? "summary" : "off" : "full",
5166
5634
  plane: input.plane
5167
- }, nativeFaceCropProvider, cropZoneBbox);
5635
+ }, nativeCropProvider, cropZoneBbox);
5168
5636
  if (isRuntime) {
5169
5637
  if (trace && this.eventBus) this.eventBus.emit(createEvent(EventCategory.PipelineTrace, {
5170
5638
  type: "device",
@@ -6225,11 +6693,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6225
6693
  }
6226
6694
  }
6227
6695
  };
6228
- function buildSchemaSlots(format, modelsDir, customByStep) {
6696
+ function buildSchemaSlots(formats, modelsDir, customByStep) {
6229
6697
  const slotMap = /* @__PURE__ */ new Map();
6698
+ const formatList = [...formats];
6230
6699
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6231
6700
  const step = pipelineStep.definition;
6232
- const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format] && m.legacy !== true);
6701
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.legacy !== true && formatList.some((f) => m.formats[f]));
6233
6702
  if (availableModels.length === 0) continue;
6234
6703
  const slot = step.slot;
6235
6704
  if (!slotMap.has(slot)) slotMap.set(slot, []);