@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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-BGsoboIK.js → default-detection-model-BSYGuA-l.js} +24 -17
- package/dist/{default-detection-model-23wBHkOQ.mjs → default-detection-model-C_Pyt1w1.mjs} +24 -17
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CR3_dWir.js → dist-CNPNWUoG.js} +21 -2
- package/dist/{dist-FaCzok3C.mjs → dist-DrKpb33H.mjs} +21 -2
- package/dist/{lazy-sharp-CKXcDP-V.js → lazy-sharp-BW3zCwk0.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-f4fUlGHQ.mjs → node-C6B8HJW_.mjs} +1 -1
- package/dist/{node-Re3uLz8K.js → node-h-nvNfmU.js} +1 -1
- package/dist/pipeline-runner/index.js +508 -239
- package/dist/pipeline-runner/index.mjs +507 -238
- package/dist/{process-memory-MC7wcxqk.mjs → process-memory-C1F0SJcc.mjs} +1 -1
- package/dist/{process-memory-DNheSoSc.js → process-memory-DL51Hzcr.js} +1 -1
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/{segment-demux-js-DUpovUKi.mjs → segment-demux-js-BgaS1a5g.mjs} +1 -1
- package/dist/{segment-demux-js-CQYpP1SU.js → segment-demux-js-BidvAwag.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-Dfu865rx.mjs → hostInit-C8APD5CI.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-lIjbPNbH.js → worker-protocol-C3cr5_6w.js} +1 -1
- package/dist/{worker-protocol-Dz6_u2y6.mjs → worker-protocol-D3HMNvw_.mjs} +1 -1
- 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-
|
|
2
|
-
import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
3
|
-
import { i as resolveFrameViewGeometry, l as getStepDefinition, r as localFrameRegistry } from "../default-detection-model-
|
|
4
|
-
import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-
|
|
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,
|
|
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
|
|
9101
|
+
const recipe = readRecoveryRecipeForSteps(steps);
|
|
9102
|
+
const crops = recipe === "motion-crop" ? planMotionCrops(planInput) : planRecoveryCrops(planInput);
|
|
8846
9103
|
if (crops.length === 0) {
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|