@camstack/addon-pipeline 1.2.205 → 1.2.207

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 (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-BGsoboIK.js → default-detection-model-BSYGuA-l.js} +24 -17
  4. package/dist/{default-detection-model-23wBHkOQ.mjs → default-detection-model-C_Pyt1w1.mjs} +24 -17
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CR3_dWir.js → dist-CNPNWUoG.js} +21 -2
  8. package/dist/{dist-FaCzok3C.mjs → dist-DrKpb33H.mjs} +21 -2
  9. package/dist/{lazy-sharp-CKXcDP-V.js → lazy-sharp-BW3zCwk0.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-f4fUlGHQ.mjs → node-C6B8HJW_.mjs} +1 -1
  13. package/dist/{node-Re3uLz8K.js → node-h-nvNfmU.js} +1 -1
  14. package/dist/pipeline-runner/index.js +508 -239
  15. package/dist/pipeline-runner/index.mjs +507 -238
  16. package/dist/{process-memory-MC7wcxqk.mjs → process-memory-C1F0SJcc.mjs} +1 -1
  17. package/dist/{process-memory-DNheSoSc.js → process-memory-DL51Hzcr.js} +1 -1
  18. package/dist/recorder/index.js +2 -2
  19. package/dist/recorder/index.mjs +2 -2
  20. package/dist/{segment-demux-js-DUpovUKi.mjs → segment-demux-js-BgaS1a5g.mjs} +1 -1
  21. package/dist/{segment-demux-js-CQYpP1SU.js → segment-demux-js-BidvAwag.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D_m12d-s.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BjZ2kba0.mjs} +3 -3
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-Dfu865rx.mjs → hostInit-C8APD5CI.mjs} +3 -3
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/dist/{worker-protocol-lIjbPNbH.js → worker-protocol-C3cr5_6w.js} +1 -1
  32. package/dist/{worker-protocol-Dz6_u2y6.mjs → worker-protocol-D3HMNvw_.mjs} +1 -1
  33. package/package.json +1 -1
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CR3_dWir.js");
6
- const require_node = require("../node-Re3uLz8K.js");
7
- const require_default_detection_model = require("../default-detection-model-BGsoboIK.js");
8
- const require_worker_protocol = require("../worker-protocol-lIjbPNbH.js");
5
+ const require_dist = require("../dist-CNPNWUoG.js");
6
+ const require_node = require("../node-h-nvNfmU.js");
7
+ const require_default_detection_model = require("../default-detection-model-BSYGuA-l.js");
8
+ const require_worker_protocol = require("../worker-protocol-C3cr5_6w.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
- const require_lazy_sharp = require("../lazy-sharp-CKXcDP-V.js");
10
+ const require_lazy_sharp = require("../lazy-sharp-BW3zCwk0.js");
11
11
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
12
12
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
13
13
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
@@ -3406,6 +3406,456 @@ function summarizeRunnerHardware(hardware, cpuCores = node_os.cpus().length) {
3406
3406
  cpuCores
3407
3407
  };
3408
3408
  }
3409
+ /** Area of a normalised rectangle, never negative. Shared with the D396 recipe. */
3410
+ function normalizedArea(r) {
3411
+ return Math.max(0, r.w) * Math.max(0, r.h);
3412
+ }
3413
+ var area = normalizedArea;
3414
+ function intersection(a, b) {
3415
+ return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
3416
+ }
3417
+ /**
3418
+ * Share of `region` not covered by any detection box. The sum of pairwise
3419
+ * intersections over-counts where detections overlap each other, which only
3420
+ * makes the pass MORE conservative; clipped at 1.
3421
+ */
3422
+ function uncoveredFraction(region, detections) {
3423
+ const regionArea = area(region);
3424
+ if (regionArea <= 0) return 0;
3425
+ let covered = 0;
3426
+ for (const detection of detections) covered += intersection(region, detection);
3427
+ return 1 - Math.min(1, covered / regionArea);
3428
+ }
3429
+ /**
3430
+ * THE square-placement rule of this pass — one owner, shared by both recipes.
3431
+ *
3432
+ * A crop is a square of `side` native pixels centred where it can be and slid
3433
+ * inside the frame where it cannot. Both region rules place their squares
3434
+ * through this function so a crop means the same rectangle whichever rule
3435
+ * chose the region.
3436
+ */
3437
+ function squareAround(centreX, centreY, side, native) {
3438
+ return {
3439
+ left: Math.round(Math.max(0, Math.min(native.width - side, centreX - side / 2))),
3440
+ top: Math.round(Math.max(0, Math.min(native.height - side, centreY - side / 2))),
3441
+ side
3442
+ };
3443
+ }
3444
+ function contains(crop, x, y) {
3445
+ return x >= crop.left && x <= crop.left + crop.side && y >= crop.top && y <= crop.top + crop.side;
3446
+ }
3447
+ /**
3448
+ * Squares for one region: one when it fits the largest side, otherwise up to
3449
+ * two tiles along its longer axis (a person-sized blob with something small
3450
+ * at either end). The padded region decides the side, never the frame.
3451
+ */
3452
+ function cropsForRegion(region, native) {
3453
+ const widthPx = region.w * native.width;
3454
+ const heightPx = region.h * native.height;
3455
+ const longest = Math.max(widthPx, heightPx);
3456
+ const padded = longest * 1.5;
3457
+ const side = Math.round(Math.min(Math.max(padded, 640), 800));
3458
+ const centreX = (region.x + region.w / 2) * native.width;
3459
+ const centreY = (region.y + region.h / 2) * native.height;
3460
+ if (padded <= 800) return [squareAround(centreX, centreY, side, native)];
3461
+ const alongX = widthPx >= heightPx;
3462
+ const quarter = longest / 4;
3463
+ return [squareAround(alongX ? centreX - quarter : centreX, alongX ? centreY : centreY - quarter, side, native), squareAround(alongX ? centreX + quarter : centreX, alongX ? centreY : centreY + quarter, side, native)];
3464
+ }
3465
+ /**
3466
+ * The ONE place the two region gates are decided. `planRecoveryCrops` filters
3467
+ * on it and {@link countRecoverySkips} counts it, so the report can never
3468
+ * describe a gate the planner does not actually apply.
3469
+ */
3470
+ function classifyRegion(region, uncovered) {
3471
+ if (area(region) > .25) return "too-large";
3472
+ if (uncovered < .35) return "covered";
3473
+ return "eligible";
3474
+ }
3475
+ /**
3476
+ * WHY A FRAME PLANNED NO CROP, AS A NUMBER (D378).
3477
+ *
3478
+ * The pass report counts the passes that RAN, so "the second pass stops running
3479
+ * when a person is beside the dog" — a plausible reading of
3480
+ * {@link RECOVERY_MAX_REGION_FRAME_FRACTION} and
3481
+ * {@link RECOVERY_MIN_UNCOVERED_FRACTION}, and a live hypothesis on camera 615
3482
+ * — could not be confirmed or killed from the logs at all. This classifies the
3483
+ * regions a frame offered, against the same predicate the planner filters on.
3484
+ */
3485
+ function countRecoverySkips(input) {
3486
+ let tooLarge = 0;
3487
+ let covered = 0;
3488
+ let eligible = 0;
3489
+ for (const region of input.regions) switch (classifyRegion(region, uncoveredFraction(region, input.detections))) {
3490
+ case "too-large":
3491
+ tooLarge += 1;
3492
+ break;
3493
+ case "covered":
3494
+ covered += 1;
3495
+ break;
3496
+ case "eligible":
3497
+ eligible += 1;
3498
+ break;
3499
+ }
3500
+ return {
3501
+ tooLarge,
3502
+ covered,
3503
+ eligible
3504
+ };
3505
+ }
3506
+ function planRecoveryCrops(input) {
3507
+ const { native } = input;
3508
+ if (Math.min(native.width, native.height) < 640) return [];
3509
+ const ranked = input.regions.map((region) => ({
3510
+ region,
3511
+ uncovered: uncoveredFraction(region, input.detections)
3512
+ })).filter(({ region, uncovered }) => classifyRegion(region, uncovered) === "eligible").toSorted((a, b) => area(b.region) * b.uncovered - area(a.region) * a.uncovered);
3513
+ const planned = [];
3514
+ for (const { region } of ranked) for (const crop of cropsForRegion(region, native)) {
3515
+ if (planned.length >= 2) return planned;
3516
+ const centreX = crop.left + crop.side / 2;
3517
+ const centreY = crop.top + crop.side / 2;
3518
+ if (planned.some((existing) => contains(existing, centreX, centreY))) continue;
3519
+ planned.push(crop);
3520
+ }
3521
+ return planned;
3522
+ }
3523
+ /**
3524
+ * Boxes the executor produced on the crop VIEW → the result's own space.
3525
+ *
3526
+ * Three spaces meet here: the view the model saw (`view`, ≤640 px), the native
3527
+ * square it was cut from (`crop`, native px) and the space the full-frame
3528
+ * result reports in (`result`, native px on the `frameRef` path, the inline
3529
+ * ≤640 view otherwise). A box touching the view edge was clipped by the crop
3530
+ * and is dropped: half a person is not a person, and the tracker would take
3531
+ * the half as the whole.
3532
+ */
3533
+ function remapCropDetections(detections, crop, view, result, native) {
3534
+ const viewToNative = crop.side / Math.max(1, view.width);
3535
+ const nativeToResultX = result.width / Math.max(1, native.width);
3536
+ const nativeToResultY = result.height / Math.max(1, native.height);
3537
+ const out = [];
3538
+ for (const detection of detections) {
3539
+ const { x, y, width, height } = detection.bbox;
3540
+ if (x <= 2 || y <= 2 || x + width >= view.width - 2 || y + height >= view.height - 2) continue;
3541
+ out.push({
3542
+ ...detection,
3543
+ nativeRecovery: {
3544
+ score: detection.score,
3545
+ cropSidePx: crop.side,
3546
+ viewPx: view.width
3547
+ },
3548
+ bbox: {
3549
+ x: (crop.left + x * viewToNative) * nativeToResultX,
3550
+ y: (crop.top + y * viewToNative) * nativeToResultY,
3551
+ width: width * viewToNative * nativeToResultX,
3552
+ height: height * viewToNative * nativeToResultY
3553
+ }
3554
+ });
3555
+ }
3556
+ return out;
3557
+ }
3558
+ function boxIntersection(a, b) {
3559
+ return Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
3560
+ }
3561
+ function sameSubject(recovered, existing) {
3562
+ const inter = boxIntersection(recovered, existing);
3563
+ if (inter <= 0) return false;
3564
+ const areaR = recovered.width * recovered.height;
3565
+ const union = areaR + existing.width * existing.height - inter;
3566
+ if (union > 0 && inter / union >= .5) return true;
3567
+ return areaR > 0 && inter / areaR >= .7;
3568
+ }
3569
+ /**
3570
+ * Class-aware NMS of the recovered boxes against the full-frame ones. Every
3571
+ * full-frame box is kept as it is — the second pass only ADDS. A recovered box
3572
+ * is added when no full-frame box of the same macro class is the same subject
3573
+ * (`RECOVERY_FUSE_IOU`) or contains it (`RECOVERY_FUSE_CONTAINMENT`). Ids are
3574
+ * re-minted so a crop's `d0` cannot collide with the frame's.
3575
+ */
3576
+ function fuseRecoveredDetections(full, recovered) {
3577
+ const accepted = [];
3578
+ for (const [index, candidate] of recovered.entries()) {
3579
+ if ([...full, ...accepted].some((existing) => existing.macroClass === candidate.macroClass && sameSubject(candidate.bbox, existing.bbox))) continue;
3580
+ accepted.push({
3581
+ ...candidate,
3582
+ id: `recovered-${index}-${candidate.id}`
3583
+ });
3584
+ }
3585
+ return [...full, ...accepted];
3586
+ }
3587
+ /**
3588
+ * The step the second pass runs: the enabled first-level object detector of
3589
+ * the camera's own tree, with its own model and no children. Same model as
3590
+ * the full pass so a recovered box is comparable to a full-frame one.
3591
+ */
3592
+ function selectRecoveryStep(steps) {
3593
+ const detector = steps.find((step) => step.addonId === "object-detection" && step.enabled !== false);
3594
+ if (!detector) return null;
3595
+ return {
3596
+ addonId: detector.addonId,
3597
+ ...detector.modelId !== void 0 ? { modelId: detector.modelId } : {},
3598
+ enabled: true
3599
+ };
3600
+ }
3601
+ /**
3602
+ * The analyzer's last regions per camera, normalised, with the instant they
3603
+ * were measured. Refreshed off the motion path; read on the detection path.
3604
+ * A stale entry answers nothing — the regions of two seconds ago are not this
3605
+ * frame's. An EMPTY analyzer result replaces the previous regions: motion that
3606
+ * stopped is not motion.
3607
+ */
3608
+ var MotionRegionMirror = class {
3609
+ byDevice = /* @__PURE__ */ new Map();
3610
+ note(deviceId, regions, frameWidth, frameHeight, now) {
3611
+ if (frameWidth <= 0 || frameHeight <= 0) return;
3612
+ this.byDevice.set(deviceId, {
3613
+ at: now,
3614
+ regions: regions.map((region) => ({
3615
+ x: region.bbox.x / frameWidth,
3616
+ y: region.bbox.y / frameHeight,
3617
+ w: region.bbox.w / frameWidth,
3618
+ h: region.bbox.h / frameHeight
3619
+ }))
3620
+ });
3621
+ }
3622
+ fresh(deviceId, now) {
3623
+ const entry = this.byDevice.get(deviceId);
3624
+ if (!entry || now - entry.at > 2e3) return [];
3625
+ return entry.regions;
3626
+ }
3627
+ forget(deviceId) {
3628
+ this.byDevice.delete(deviceId);
3629
+ }
3630
+ };
3631
+ /**
3632
+ * Read the opt-in. Anything that is not the boolean `true` is the shipped
3633
+ * rule: a stored string or a number is a mis-write (this repo has shipped a
3634
+ * setting written into the wrong sqlite table before), and a mis-write must
3635
+ * not silently change what a camera detects on.
3636
+ */
3637
+ function readRecoveryRecipe(settings) {
3638
+ return settings?.["motionCropDispatch"] === true ? "motion-crop" : "uncovered-region";
3639
+ }
3640
+ /**
3641
+ * The recipe this camera's resolved step tree selects.
3642
+ *
3643
+ * Reads the ENABLED first-level detector's own settings, which is where
3644
+ * `pipelineOrchestrator.setCameraStepOverride` writes a per-camera value. A
3645
+ * tree with no enabled detector runs no second pass at all, so the answer
3646
+ * there is the shipped rule and it is never used.
3647
+ */
3648
+ function readRecoveryRecipeForSteps(steps) {
3649
+ return readRecoveryRecipe(steps.find((step) => step.addonId === "object-detection" && step.enabled !== false)?.settings);
3650
+ }
3651
+ /**
3652
+ * A motion box is grown ×1.5 about its centre before anything else.
3653
+ *
3654
+ * The motion step's box is the CHANGED pixels, which on a moving animal is the
3655
+ * leading edge plus its shadow, not the animal. The detector needs the subject
3656
+ * and the context it was trained on.
3657
+ */
3658
+ var MOTION_CROP_MOTION_PAD = 1.5;
3659
+ /**
3660
+ * An intersecting tracked box is grown ×1.25 — less, because it is already a
3661
+ * subject rectangle rather than a change mask.
3662
+ */
3663
+ var MOTION_CROP_TRACKED_PAD = 1.25;
3664
+ /**
3665
+ * The floor a `person` or `vehicle` box must clear when it came out of a CROP
3666
+ * rather than out of the full frame.
3667
+ *
3668
+ * Collected, not assumed: 3 085 quiet frames produced 27 motion-positive
3669
+ * frames and 28 crop invocations. The full-frame arm produced 0 boxes above
3670
+ * any gate; the crop arm produced 3, of which two are genuine false positives,
3671
+ * and both are night-IR corners upscaled into a 320 model — `vehicle 0.749`
3672
+ * and `person 0.701`. Zero false `animal` at any gate, which is why `animal`
3673
+ * is deliberately absent from this table: the class the pass exists to recover
3674
+ * is the class that never produced a false positive, and gating it would cost
3675
+ * the whole measured gain.
3676
+ *
3677
+ * This is a CROP-ONLY floor, applied on top of the per-macro floors the
3678
+ * executor already enforced. A full-frame `person` at 0.70 is untouched — the
3679
+ * full-frame arm is the one arm with ground truth behind its floor
3680
+ * (`minConfidencePerson` 0.65, raised on 2026-07-30 from a measured
3681
+ * distribution).
3682
+ *
3683
+ * The cost of this floor is stated rather than assumed away: it can also
3684
+ * remove a real distant person or vehicle recovered from a crop at 0.70–0.80.
3685
+ * Nothing in the quiet-footage sample measures that, so the floor is a
3686
+ * constant here rather than a slider, and the runner reports every drop it
3687
+ * makes per camera.
3688
+ */
3689
+ var CROP_CLASS_FLOORS = Object.freeze({
3690
+ person: .8,
3691
+ vehicle: .8
3692
+ });
3693
+ /**
3694
+ * Apply the crop-only class floors, REPORTING every drop.
3695
+ *
3696
+ * A class with no entry in {@link CROP_CLASS_FLOORS} is kept: a floor this
3697
+ * table cannot express is not a floor of zero, and silently dropping a class
3698
+ * nobody measured is the exact shape of silent drop this repo has paid for
3699
+ * three separate times.
3700
+ */
3701
+ function applyCropClassFloors(detections) {
3702
+ const kept = [];
3703
+ const dropped = [];
3704
+ for (const detection of detections) {
3705
+ const floor = CROP_CLASS_FLOORS[detection.macroClass];
3706
+ if (floor !== void 0 && detection.score < floor) {
3707
+ dropped.push({
3708
+ macroClass: detection.macroClass,
3709
+ score: detection.score,
3710
+ floor
3711
+ });
3712
+ continue;
3713
+ }
3714
+ kept.push(detection);
3715
+ }
3716
+ return {
3717
+ kept,
3718
+ dropped
3719
+ };
3720
+ }
3721
+ function scaleAbout(rect, factor) {
3722
+ const w = rect.w * factor;
3723
+ const h = rect.h * factor;
3724
+ return {
3725
+ x: rect.x + rect.w / 2 - w / 2,
3726
+ y: rect.y + rect.h / 2 - h / 2,
3727
+ w,
3728
+ h
3729
+ };
3730
+ }
3731
+ function overlaps(a, b) {
3732
+ return Math.min(a.x + a.w, b.x + b.w) > Math.max(a.x, b.x) && Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y);
3733
+ }
3734
+ function union(a, b) {
3735
+ const x = Math.min(a.x, b.x);
3736
+ const y = Math.min(a.y, b.y);
3737
+ return {
3738
+ x,
3739
+ y,
3740
+ w: Math.max(a.x + a.w, b.x + b.w) - x,
3741
+ h: Math.max(a.y + a.h, b.y + b.h) - y
3742
+ };
3743
+ }
3744
+ function clampToFrame(rect) {
3745
+ const x = Math.max(0, rect.x);
3746
+ const y = Math.max(0, rect.y);
3747
+ return {
3748
+ x,
3749
+ y,
3750
+ w: Math.min(1, rect.x + rect.w) - x,
3751
+ h: Math.min(1, rect.y + rect.h) - y
3752
+ };
3753
+ }
3754
+ /**
3755
+ * Merge transitively every seed whose padded box overlaps another, and answer
3756
+ * the union rectangles.
3757
+ *
3758
+ * Transitive on purpose: a person, the dog beside them and the dog's shadow
3759
+ * are three components of one subject, and cutting three crops for them spends
3760
+ * two inferences on pixels the first one already showed the detector.
3761
+ */
3762
+ function mergeSeeds(seeds) {
3763
+ const merged = [];
3764
+ for (const seed of seeds) {
3765
+ let current = seed;
3766
+ let changed = true;
3767
+ while (changed) {
3768
+ changed = false;
3769
+ for (let i = merged.length - 1; i >= 0; i -= 1) {
3770
+ const other = merged[i];
3771
+ if (other === void 0 || !overlaps(current, other)) continue;
3772
+ current = union(current, other);
3773
+ merged.splice(i, 1);
3774
+ changed = true;
3775
+ }
3776
+ }
3777
+ merged.push(current);
3778
+ }
3779
+ return merged;
3780
+ }
3781
+ /**
3782
+ * The regions the measured recipe judges this frame on.
3783
+ *
3784
+ * Motion boxes padded {@link MOTION_CROP_MOTION_PAD}; every full-frame box
3785
+ * that intersects a RAW motion box padded {@link MOTION_CROP_TRACKED_PAD} and
3786
+ * added as a seed of its own; everything that overlaps merged; the result
3787
+ * clamped to the frame, ordered largest first and capped at
3788
+ * {@link MOTION_CROP_MAX_REGIONS}.
3789
+ *
3790
+ * There is deliberately no coverage gate and no size gate here. Those belong
3791
+ * to the shipped rule, and they are what makes it a rule about a subject
3792
+ * BESIDE a detected one; the measurement says the pass is worth running on the
3793
+ * detected subject's own region too.
3794
+ */
3795
+ function planMotionCropRegions(input) {
3796
+ if (input.regions.length === 0) return [];
3797
+ const seeds = input.regions.map((r) => scaleAbout(r, MOTION_CROP_MOTION_PAD));
3798
+ for (const detection of input.detections) {
3799
+ if (!input.regions.some((region) => overlaps(detection, region))) continue;
3800
+ seeds.push(scaleAbout(detection, MOTION_CROP_TRACKED_PAD));
3801
+ }
3802
+ return mergeSeeds(seeds).map(clampToFrame).filter((r) => r.w > 0 && r.h > 0).toSorted((a, b) => normalizedArea(b) - normalizedArea(a)).slice(0, 3);
3803
+ }
3804
+ /**
3805
+ * The square the detector is fed for one measured region.
3806
+ *
3807
+ * "Grow to the detector's input aspect ratio without ever shrinking" is a
3808
+ * SQUARE here, and that is a fact about the production detector rather than a
3809
+ * simplification: `yolov9m-320-int8` takes 320×320, as does every hardware
3810
+ * default in the catalog.
3811
+ *
3812
+ * The side is the region's longer side, floored at
3813
+ * {@link RECOVERY_CROP_MIN_SIDE_PX} — the growth is what magnifies the subject
3814
+ * — and ceilinged at {@link RECOVERY_CROP_MAX_SIDE_PX}. The ceiling is NOT a
3815
+ * simplification either: at 1024 px the same 320 model scored the reference
3816
+ * dog 0.52–0.76, and at 1280 px lost it entirely
3817
+ * (`docs/benchmarks/pipeline-frame-model-eval.md`, case `miss-dog-615`). A
3818
+ * region longer than the ceiling is TILED along its longer axis, never zoomed
3819
+ * out.
3820
+ */
3821
+ function motionCropsForRegion(region, native) {
3822
+ const widthPx = region.w * native.width;
3823
+ const heightPx = region.h * native.height;
3824
+ const longest = Math.max(widthPx, heightPx);
3825
+ const side = Math.round(Math.min(Math.max(longest, 640), 800));
3826
+ const centreX = (region.x + region.w / 2) * native.width;
3827
+ const centreY = (region.y + region.h / 2) * native.height;
3828
+ if (longest <= 800) return [squareAround(centreX, centreY, side, native)];
3829
+ const alongX = widthPx >= heightPx;
3830
+ const quarter = longest / 4;
3831
+ return [squareAround(alongX ? centreX - quarter : centreX, alongX ? centreY : centreY - quarter, side, native), squareAround(alongX ? centreX + quarter : centreX, alongX ? centreY : centreY + quarter, side, native)];
3832
+ }
3833
+ /** `true` when `inner` lies entirely inside `outer`. */
3834
+ function cropContains(outer, inner) {
3835
+ return inner.left >= outer.left && inner.top >= outer.top && inner.left + inner.side <= outer.left + outer.side && inner.top + inner.side <= outer.top + outer.side;
3836
+ }
3837
+ /**
3838
+ * The crops the measured recipe runs on this frame.
3839
+ *
3840
+ * Dedupe is by CONTAINMENT only. Two squares that merely overlap both run: the
3841
+ * pixels the second adds are pixels no other crop shows the detector, and the
3842
+ * fusion step downstream already refuses a duplicate BOX. Dropping a crop
3843
+ * because it overlapped would drop the subject sitting at its edge, which is
3844
+ * the whole population this pass exists for.
3845
+ *
3846
+ * A frame that cannot hold the smallest crop yields nothing: it is already
3847
+ * seen at scale.
3848
+ */
3849
+ function planMotionCrops(input) {
3850
+ const { native } = input;
3851
+ if (Math.min(native.width, native.height) < 640) return [];
3852
+ const planned = [];
3853
+ for (const region of planMotionCropRegions(input)) for (const crop of motionCropsForRegion(region, native)) {
3854
+ if (planned.some((existing) => cropContains(existing, crop))) continue;
3855
+ planned.push(crop);
3856
+ }
3857
+ return planned;
3858
+ }
3409
3859
  //#endregion
3410
3860
  //#region src/pipeline-runner/motion-frame-view.ts
3411
3861
  var MOTION_FRAME_VIEW = {
@@ -5326,218 +5776,6 @@ var PipelineRunner = class {
5326
5776
  return null;
5327
5777
  }
5328
5778
  };
5329
- function area(r) {
5330
- return Math.max(0, r.w) * Math.max(0, r.h);
5331
- }
5332
- function intersection(a, b) {
5333
- return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
5334
- }
5335
- /**
5336
- * Share of `region` not covered by any detection box. The sum of pairwise
5337
- * intersections over-counts where detections overlap each other, which only
5338
- * makes the pass MORE conservative; clipped at 1.
5339
- */
5340
- function uncoveredFraction(region, detections) {
5341
- const regionArea = area(region);
5342
- if (regionArea <= 0) return 0;
5343
- let covered = 0;
5344
- for (const detection of detections) covered += intersection(region, detection);
5345
- return 1 - Math.min(1, covered / regionArea);
5346
- }
5347
- function squareAround(centreX, centreY, side, native) {
5348
- return {
5349
- left: Math.round(Math.max(0, Math.min(native.width - side, centreX - side / 2))),
5350
- top: Math.round(Math.max(0, Math.min(native.height - side, centreY - side / 2))),
5351
- side
5352
- };
5353
- }
5354
- function contains(crop, x, y) {
5355
- return x >= crop.left && x <= crop.left + crop.side && y >= crop.top && y <= crop.top + crop.side;
5356
- }
5357
- /**
5358
- * Squares for one region: one when it fits the largest side, otherwise up to
5359
- * two tiles along its longer axis (a person-sized blob with something small
5360
- * at either end). The padded region decides the side, never the frame.
5361
- */
5362
- function cropsForRegion(region, native) {
5363
- const widthPx = region.w * native.width;
5364
- const heightPx = region.h * native.height;
5365
- const longest = Math.max(widthPx, heightPx);
5366
- const padded = longest * 1.5;
5367
- const side = Math.round(Math.min(Math.max(padded, 640), 800));
5368
- const centreX = (region.x + region.w / 2) * native.width;
5369
- const centreY = (region.y + region.h / 2) * native.height;
5370
- if (padded <= 800) return [squareAround(centreX, centreY, side, native)];
5371
- const alongX = widthPx >= heightPx;
5372
- const quarter = longest / 4;
5373
- return [squareAround(alongX ? centreX - quarter : centreX, alongX ? centreY : centreY - quarter, side, native), squareAround(alongX ? centreX + quarter : centreX, alongX ? centreY : centreY + quarter, side, native)];
5374
- }
5375
- /**
5376
- * The ONE place the two region gates are decided. `planRecoveryCrops` filters
5377
- * on it and {@link countRecoverySkips} counts it, so the report can never
5378
- * describe a gate the planner does not actually apply.
5379
- */
5380
- function classifyRegion(region, uncovered) {
5381
- if (area(region) > .25) return "too-large";
5382
- if (uncovered < .35) return "covered";
5383
- return "eligible";
5384
- }
5385
- /**
5386
- * WHY A FRAME PLANNED NO CROP, AS A NUMBER (D378).
5387
- *
5388
- * The pass report counts the passes that RAN, so "the second pass stops running
5389
- * when a person is beside the dog" — a plausible reading of
5390
- * {@link RECOVERY_MAX_REGION_FRAME_FRACTION} and
5391
- * {@link RECOVERY_MIN_UNCOVERED_FRACTION}, and a live hypothesis on camera 615
5392
- * — could not be confirmed or killed from the logs at all. This classifies the
5393
- * regions a frame offered, against the same predicate the planner filters on.
5394
- */
5395
- function countRecoverySkips(input) {
5396
- let tooLarge = 0;
5397
- let covered = 0;
5398
- let eligible = 0;
5399
- for (const region of input.regions) switch (classifyRegion(region, uncoveredFraction(region, input.detections))) {
5400
- case "too-large":
5401
- tooLarge += 1;
5402
- break;
5403
- case "covered":
5404
- covered += 1;
5405
- break;
5406
- case "eligible":
5407
- eligible += 1;
5408
- break;
5409
- }
5410
- return {
5411
- tooLarge,
5412
- covered,
5413
- eligible
5414
- };
5415
- }
5416
- function planRecoveryCrops(input) {
5417
- const { native } = input;
5418
- if (Math.min(native.width, native.height) < 640) return [];
5419
- const ranked = input.regions.map((region) => ({
5420
- region,
5421
- uncovered: uncoveredFraction(region, input.detections)
5422
- })).filter(({ region, uncovered }) => classifyRegion(region, uncovered) === "eligible").toSorted((a, b) => area(b.region) * b.uncovered - area(a.region) * a.uncovered);
5423
- const planned = [];
5424
- for (const { region } of ranked) for (const crop of cropsForRegion(region, native)) {
5425
- if (planned.length >= 2) return planned;
5426
- const centreX = crop.left + crop.side / 2;
5427
- const centreY = crop.top + crop.side / 2;
5428
- if (planned.some((existing) => contains(existing, centreX, centreY))) continue;
5429
- planned.push(crop);
5430
- }
5431
- return planned;
5432
- }
5433
- /**
5434
- * Boxes the executor produced on the crop VIEW → the result's own space.
5435
- *
5436
- * Three spaces meet here: the view the model saw (`view`, ≤640 px), the native
5437
- * square it was cut from (`crop`, native px) and the space the full-frame
5438
- * result reports in (`result`, native px on the `frameRef` path, the inline
5439
- * ≤640 view otherwise). A box touching the view edge was clipped by the crop
5440
- * and is dropped: half a person is not a person, and the tracker would take
5441
- * the half as the whole.
5442
- */
5443
- function remapCropDetections(detections, crop, view, result, native) {
5444
- const viewToNative = crop.side / Math.max(1, view.width);
5445
- const nativeToResultX = result.width / Math.max(1, native.width);
5446
- const nativeToResultY = result.height / Math.max(1, native.height);
5447
- const out = [];
5448
- for (const detection of detections) {
5449
- const { x, y, width, height } = detection.bbox;
5450
- if (x <= 2 || y <= 2 || x + width >= view.width - 2 || y + height >= view.height - 2) continue;
5451
- out.push({
5452
- ...detection,
5453
- nativeRecovery: {
5454
- score: detection.score,
5455
- cropSidePx: crop.side,
5456
- viewPx: view.width
5457
- },
5458
- bbox: {
5459
- x: (crop.left + x * viewToNative) * nativeToResultX,
5460
- y: (crop.top + y * viewToNative) * nativeToResultY,
5461
- width: width * viewToNative * nativeToResultX,
5462
- height: height * viewToNative * nativeToResultY
5463
- }
5464
- });
5465
- }
5466
- return out;
5467
- }
5468
- function boxIntersection(a, b) {
5469
- return Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
5470
- }
5471
- function sameSubject(recovered, existing) {
5472
- const inter = boxIntersection(recovered, existing);
5473
- if (inter <= 0) return false;
5474
- const areaR = recovered.width * recovered.height;
5475
- const union = areaR + existing.width * existing.height - inter;
5476
- if (union > 0 && inter / union >= .5) return true;
5477
- return areaR > 0 && inter / areaR >= .7;
5478
- }
5479
- /**
5480
- * Class-aware NMS of the recovered boxes against the full-frame ones. Every
5481
- * full-frame box is kept as it is — the second pass only ADDS. A recovered box
5482
- * is added when no full-frame box of the same macro class is the same subject
5483
- * (`RECOVERY_FUSE_IOU`) or contains it (`RECOVERY_FUSE_CONTAINMENT`). Ids are
5484
- * re-minted so a crop's `d0` cannot collide with the frame's.
5485
- */
5486
- function fuseRecoveredDetections(full, recovered) {
5487
- const accepted = [];
5488
- for (const [index, candidate] of recovered.entries()) {
5489
- if ([...full, ...accepted].some((existing) => existing.macroClass === candidate.macroClass && sameSubject(candidate.bbox, existing.bbox))) continue;
5490
- accepted.push({
5491
- ...candidate,
5492
- id: `recovered-${index}-${candidate.id}`
5493
- });
5494
- }
5495
- return [...full, ...accepted];
5496
- }
5497
- /**
5498
- * The step the second pass runs: the enabled first-level object detector of
5499
- * the camera's own tree, with its own model and no children. Same model as
5500
- * the full pass so a recovered box is comparable to a full-frame one.
5501
- */
5502
- function selectRecoveryStep(steps) {
5503
- const detector = steps.find((step) => step.addonId === "object-detection" && step.enabled !== false);
5504
- if (!detector) return null;
5505
- return {
5506
- addonId: detector.addonId,
5507
- ...detector.modelId !== void 0 ? { modelId: detector.modelId } : {},
5508
- enabled: true
5509
- };
5510
- }
5511
- /**
5512
- * The analyzer's last regions per camera, normalised, with the instant they
5513
- * were measured. Refreshed off the motion path; read on the detection path.
5514
- * A stale entry answers nothing — the regions of two seconds ago are not this
5515
- * frame's. An EMPTY analyzer result replaces the previous regions: motion that
5516
- * stopped is not motion.
5517
- */
5518
- var MotionRegionMirror = class {
5519
- byDevice = /* @__PURE__ */ new Map();
5520
- note(deviceId, regions, frameWidth, frameHeight, now) {
5521
- if (frameWidth <= 0 || frameHeight <= 0) return;
5522
- this.byDevice.set(deviceId, {
5523
- at: now,
5524
- regions: regions.map((region) => ({
5525
- x: region.bbox.x / frameWidth,
5526
- y: region.bbox.y / frameHeight,
5527
- w: region.bbox.w / frameWidth,
5528
- h: region.bbox.h / frameHeight
5529
- }))
5530
- });
5531
- }
5532
- fresh(deviceId, now) {
5533
- const entry = this.byDevice.get(deviceId);
5534
- if (!entry || now - entry.at > 2e3) return [];
5535
- return entry.regions;
5536
- }
5537
- forget(deviceId) {
5538
- this.byDevice.delete(deviceId);
5539
- }
5540
- };
5541
5779
  //#endregion
5542
5780
  //#region src/pipeline-runner/stateless-step.ts
5543
5781
  function modelServability(step, modelId, format) {
@@ -5688,6 +5926,18 @@ function isUsable(bbox) {
5688
5926
  * independently from the inference workers.
5689
5927
  */
5690
5928
  /**
5929
+ * The zero of the recovery skip counters — one frozen literal so a new reason
5930
+ * cannot be added to the map's shape and forgotten in one of the two places
5931
+ * that used to spell the zero out by hand.
5932
+ */
5933
+ var EMPTY_RECOVERY_SKIPS = Object.freeze({
5934
+ rateLimited: 0,
5935
+ noMotion: 0,
5936
+ tooLarge: 0,
5937
+ covered: 0,
5938
+ unmagnifiable: 0
5939
+ });
5940
+ /**
5691
5941
  * How often the small-subject recovery pass reports per camera. The pass runs
5692
5942
  * up to twice a second; one aggregated line every ten seconds says how often
5693
5943
  * it ran, what it cost and what it found — the numbers a "did the second
@@ -6142,7 +6392,13 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6142
6392
  * beside the dog" was a live hypothesis on camera 615 and the logs could not
6143
6393
  * answer it: a report that counts only the passes that ran cannot say why the
6144
6394
  * others did not. `rateLimited` is the cadence cap, `noMotion` an empty or
6145
- * stale motion mirror, and `tooLarge` / `covered` the two region gates.
6395
+ * stale motion mirror, `tooLarge` / `covered` the shipped rule's two region
6396
+ * gates, and `unmagnifiable` the measured recipe's only non-run — a frame
6397
+ * whose short side is below the magnification floor (D396).
6398
+ *
6399
+ * Together with `passes` these count EVERY frame the pass was offered, which
6400
+ * is what turns `crops` into the detector invocations per frame the cost of
6401
+ * this feature is judged on.
6146
6402
  */
6147
6403
  recoverySkips = /* @__PURE__ */ new Map();
6148
6404
  /**
@@ -8849,13 +9105,17 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8849
9105
  })),
8850
9106
  native
8851
9107
  };
8852
- const crops = planRecoveryCrops(planInput);
9108
+ const recipe = readRecoveryRecipeForSteps(steps);
9109
+ const crops = recipe === "motion-crop" ? planMotionCrops(planInput) : planRecoveryCrops(planInput);
8853
9110
  if (crops.length === 0) {
8854
- const skipped = countRecoverySkips(planInput);
8855
- this.addRecoverySkips(deviceId, {
8856
- tooLarge: skipped.tooLarge,
8857
- covered: skipped.covered
8858
- });
9111
+ if (recipe === "motion-crop") this.addRecoverySkips(deviceId, { unmagnifiable: 1 });
9112
+ else {
9113
+ const skipped = countRecoverySkips(planInput);
9114
+ this.addRecoverySkips(deviceId, {
9115
+ tooLarge: skipped.tooLarge,
9116
+ covered: skipped.covered
9117
+ });
9118
+ }
8859
9119
  return result;
8860
9120
  }
8861
9121
  this.lastRecoveryAt.set(deviceId, now);
@@ -8890,11 +9150,19 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8890
9150
  } });
8891
9151
  }
8892
9152
  }
8893
- const fused = fuseRecoveredDetections(result.detections, recovered);
9153
+ const floor = applyCropClassFloors(recovered);
9154
+ if (floor.dropped.length > 0) log.info("small-subject recovery: crop boxes below the crop-only class floor", { meta: {
9155
+ recipe,
9156
+ dropped: floor.dropped,
9157
+ nodeId: this.nodeId
9158
+ } });
9159
+ const fused = fuseRecoveredDetections(result.detections, floor.kept);
8894
9160
  const added = fused.length - result.detections.length;
8895
9161
  this.reportRecovery(deviceId, {
8896
9162
  crops: cropsRun,
8897
9163
  recovered: added,
9164
+ floored: floor.dropped.length,
9165
+ recipe,
8898
9166
  ms: Date.now() - started,
8899
9167
  now,
8900
9168
  classes: fused.slice(result.detections.length).map((d) => `${d.macroClass}:${d.score.toFixed(3)}`)
@@ -8953,17 +9221,13 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8953
9221
  * in the log.
8954
9222
  */
8955
9223
  addRecoverySkips(deviceId, delta) {
8956
- const current = this.recoverySkips.get(deviceId) ?? {
8957
- rateLimited: 0,
8958
- noMotion: 0,
8959
- tooLarge: 0,
8960
- covered: 0
8961
- };
9224
+ const current = this.recoverySkips.get(deviceId) ?? EMPTY_RECOVERY_SKIPS;
8962
9225
  this.recoverySkips.set(deviceId, {
8963
9226
  rateLimited: current.rateLimited + (delta.rateLimited ?? 0),
8964
9227
  noMotion: current.noMotion + (delta.noMotion ?? 0),
8965
9228
  tooLarge: current.tooLarge + (delta.tooLarge ?? 0),
8966
- covered: current.covered + (delta.covered ?? 0)
9229
+ covered: current.covered + (delta.covered ?? 0),
9230
+ unmagnifiable: current.unmagnifiable + (delta.unmagnifiable ?? 0)
8967
9231
  });
8968
9232
  }
8969
9233
  /** Aggregate the pass per camera and report once per {@link RECOVERY_LOG_INTERVAL_MS}. */
@@ -8972,6 +9236,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8972
9236
  passes: 0,
8973
9237
  crops: 0,
8974
9238
  recovered: 0,
9239
+ floored: 0,
8975
9240
  ms: 0,
8976
9241
  reportedAt: sample.now
8977
9242
  };
@@ -8979,12 +9244,14 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8979
9244
  passes: stats.passes + 1,
8980
9245
  crops: stats.crops + sample.crops,
8981
9246
  recovered: stats.recovered + sample.recovered,
9247
+ floored: stats.floored + sample.floored,
8982
9248
  ms: stats.ms + sample.ms,
8983
9249
  reportedAt: stats.reportedAt
8984
9250
  };
8985
9251
  if (sample.recovered > 0) this.ctx.logger.info("small-subject recovery: the native crop pass found what the full frame missed", {
8986
9252
  tags: { deviceId },
8987
9253
  meta: {
9254
+ recipe: sample.recipe,
8988
9255
  crops: sample.crops,
8989
9256
  recovered: sample.recovered,
8990
9257
  classes: sample.classes,
@@ -8992,24 +9259,25 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8992
9259
  }
8993
9260
  });
8994
9261
  if (sample.now - stats.reportedAt >= RECOVERY_LOG_INTERVAL_MS) {
8995
- const skips = this.recoverySkips.get(deviceId) ?? {
8996
- rateLimited: 0,
8997
- noMotion: 0,
8998
- tooLarge: 0,
8999
- covered: 0
9000
- };
9262
+ const skips = this.recoverySkips.get(deviceId) ?? EMPTY_RECOVERY_SKIPS;
9263
+ const framesOffered = next.passes + skips.rateLimited + skips.noMotion + skips.tooLarge + skips.covered + skips.unmagnifiable;
9001
9264
  this.ctx.logger.info("small-subject recovery: pass report", {
9002
9265
  tags: { deviceId },
9003
9266
  meta: {
9267
+ recipe: sample.recipe,
9004
9268
  passes: next.passes,
9005
9269
  crops: next.crops,
9006
9270
  recovered: next.recovered,
9271
+ floored: next.floored,
9272
+ framesOffered,
9273
+ cropsPerFrame: framesOffered > 0 ? +(next.crops / framesOffered).toFixed(3) : 0,
9007
9274
  avgMsPerPass: next.passes > 0 ? Math.round(next.ms / next.passes) : 0,
9008
9275
  windowMs: sample.now - stats.reportedAt,
9009
9276
  skipRateLimited: skips.rateLimited,
9010
9277
  skipNoMotion: skips.noMotion,
9011
9278
  skipTooLarge: skips.tooLarge,
9012
- skipCovered: skips.covered
9279
+ skipCovered: skips.covered,
9280
+ skipUnmagnifiable: skips.unmagnifiable
9013
9281
  }
9014
9282
  });
9015
9283
  this.recoverySkips.delete(deviceId);
@@ -9017,6 +9285,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9017
9285
  passes: 0,
9018
9286
  crops: 0,
9019
9287
  recovered: 0,
9288
+ floored: 0,
9020
9289
  ms: 0,
9021
9290
  reportedAt: sample.now
9022
9291
  });