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