@camstack/addon-post-analysis 1.2.18 → 1.2.20
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/{dist-C41w6Xvl.mjs → dist-CrAkB8NZ.mjs} +115 -3
- package/dist/{dist-CjwPOJKc.js → dist-vIJhE1KT.js} +115 -3
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Dwh-F2Zf.js → node-C1I1svLc.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C0HgiJXz.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-uZ5AHBEf.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-25P5PBxH.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DisaD6Xu.mjs → hostInit-BzSezYSo.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +777 -76
- package/dist/pipeline-analytics/index.mjs +776 -75
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54-w8Eqz.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-vIJhE1KT.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_dist.__toESM(node_path);
|
|
@@ -1441,6 +1441,94 @@ function resolveDetectionLabel(input) {
|
|
|
1441
1441
|
if (top) return top;
|
|
1442
1442
|
return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
|
|
1443
1443
|
}
|
|
1444
|
+
var DEFAULT_ZONE_TRANSITION_CONFIG = {
|
|
1445
|
+
enterFrames: 2,
|
|
1446
|
+
exitFrames: 4
|
|
1447
|
+
};
|
|
1448
|
+
var NO_TRANSITIONS = {
|
|
1449
|
+
entered: [],
|
|
1450
|
+
exited: []
|
|
1451
|
+
};
|
|
1452
|
+
function newState() {
|
|
1453
|
+
return {
|
|
1454
|
+
inside: /* @__PURE__ */ new Set(),
|
|
1455
|
+
enterStreak: /* @__PURE__ */ new Map(),
|
|
1456
|
+
exitStreak: /* @__PURE__ */ new Map()
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
var ZoneTransitionTracker = class {
|
|
1460
|
+
config;
|
|
1461
|
+
tracks = /* @__PURE__ */ new Map();
|
|
1462
|
+
constructor(config = {}) {
|
|
1463
|
+
this.config = {
|
|
1464
|
+
...DEFAULT_ZONE_TRANSITION_CONFIG,
|
|
1465
|
+
...config
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Feed one frame's zone membership for one track.
|
|
1470
|
+
*
|
|
1471
|
+
* `currentZoneIds` is the set the geometry says the box is in RIGHT NOW; the
|
|
1472
|
+
* return value is only what has persisted long enough to be believed.
|
|
1473
|
+
*/
|
|
1474
|
+
observe(trackId, currentZoneIds) {
|
|
1475
|
+
const state = this.tracks.get(trackId) ?? newState();
|
|
1476
|
+
this.tracks.set(trackId, state);
|
|
1477
|
+
const now = new Set(currentZoneIds);
|
|
1478
|
+
const entered = [];
|
|
1479
|
+
const exited = [];
|
|
1480
|
+
for (const zoneId of now) {
|
|
1481
|
+
if (state.inside.has(zoneId)) {
|
|
1482
|
+
state.exitStreak.delete(zoneId);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
const streak = (state.enterStreak.get(zoneId) ?? 0) + 1;
|
|
1486
|
+
if (streak >= this.config.enterFrames) {
|
|
1487
|
+
state.enterStreak.delete(zoneId);
|
|
1488
|
+
state.inside.add(zoneId);
|
|
1489
|
+
entered.push(zoneId);
|
|
1490
|
+
} else state.enterStreak.set(zoneId, streak);
|
|
1491
|
+
}
|
|
1492
|
+
for (const zoneId of [...state.enterStreak.keys()]) if (!now.has(zoneId)) state.enterStreak.delete(zoneId);
|
|
1493
|
+
for (const zoneId of [...state.inside]) {
|
|
1494
|
+
if (now.has(zoneId)) continue;
|
|
1495
|
+
const streak = (state.exitStreak.get(zoneId) ?? 0) + 1;
|
|
1496
|
+
if (streak >= this.config.exitFrames) {
|
|
1497
|
+
state.exitStreak.delete(zoneId);
|
|
1498
|
+
state.inside.delete(zoneId);
|
|
1499
|
+
exited.push(zoneId);
|
|
1500
|
+
} else state.exitStreak.set(zoneId, streak);
|
|
1501
|
+
}
|
|
1502
|
+
if (entered.length === 0 && exited.length === 0) return NO_TRANSITIONS;
|
|
1503
|
+
return {
|
|
1504
|
+
entered,
|
|
1505
|
+
exited
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
/** Zones a track is currently confirmed to be inside. */
|
|
1509
|
+
zonesFor(trackId) {
|
|
1510
|
+
const state = this.tracks.get(trackId);
|
|
1511
|
+
return state ? [...state.inside] : [];
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Drop a track and report the zones it was still inside.
|
|
1515
|
+
*
|
|
1516
|
+
* The caller uses these to close the subject's presence — a track that
|
|
1517
|
+
* disappears mid-zone has genuinely left it, and nothing else will ever say
|
|
1518
|
+
* so. Without this, a zone entry would have no matching exit whenever the
|
|
1519
|
+
* subject walked out of frame rather than out of the zone.
|
|
1520
|
+
*/
|
|
1521
|
+
forget(trackId) {
|
|
1522
|
+
const state = this.tracks.get(trackId);
|
|
1523
|
+
if (!state) return [];
|
|
1524
|
+
this.tracks.delete(trackId);
|
|
1525
|
+
return [...state.inside];
|
|
1526
|
+
}
|
|
1527
|
+
/** Number of tracks held — a leak check for the caller's tests. */
|
|
1528
|
+
size() {
|
|
1529
|
+
return this.tracks.size;
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1444
1532
|
//#endregion
|
|
1445
1533
|
//#region src/pipeline-analytics/pipeline/zones/geometry.ts
|
|
1446
1534
|
/** Ray-casting point-in-polygon test */
|
|
@@ -2302,22 +2390,48 @@ function resolveRuleThreshold(rule) {
|
|
|
2302
2390
|
*/
|
|
2303
2391
|
var ZoneEngine = class {
|
|
2304
2392
|
/**
|
|
2305
|
-
* Annotate a single detection with its zone memberships
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2393
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2394
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2395
|
+
* the detection's own area).
|
|
2396
|
+
*
|
|
2397
|
+
* The previous version of this comment claimed memberships were returned
|
|
2398
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2399
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2400
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2401
|
+
*
|
|
2402
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2403
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2404
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2405
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2406
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2407
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2308
2408
|
*/
|
|
2309
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2409
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2410
|
+
return [...this.splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap).memberships];
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* {@link annotateDetection}, keeping the zones the bar REJECTED as well as
|
|
2414
|
+
* the ones it admitted. Same geometry, one pass — see
|
|
2415
|
+
* {@link ZoneMembershipSplit} for why the rejected side has to survive.
|
|
2416
|
+
*/
|
|
2417
|
+
splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2310
2418
|
const memberships = [];
|
|
2419
|
+
const belowBar = [];
|
|
2311
2420
|
for (const zone of zones) {
|
|
2312
2421
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2313
2422
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2314
|
-
|
|
2423
|
+
const entry = {
|
|
2315
2424
|
zoneId: zone.id,
|
|
2316
2425
|
zoneName: zone.name,
|
|
2317
2426
|
overlap
|
|
2318
|
-
}
|
|
2427
|
+
};
|
|
2428
|
+
if (overlap > minOverlap) memberships.push(entry);
|
|
2429
|
+
else if (overlap > 0) belowBar.push(entry);
|
|
2319
2430
|
}
|
|
2320
|
-
return
|
|
2431
|
+
return {
|
|
2432
|
+
memberships,
|
|
2433
|
+
belowBar
|
|
2434
|
+
};
|
|
2321
2435
|
}
|
|
2322
2436
|
/**
|
|
2323
2437
|
* Filter detections through a zone-rule set. `zones` provides the
|
|
@@ -2378,6 +2492,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
|
|
|
2378
2492
|
return false;
|
|
2379
2493
|
}
|
|
2380
2494
|
//#endregion
|
|
2495
|
+
//#region src/pipeline-analytics/pipeline/rider-pairing.ts
|
|
2496
|
+
/**
|
|
2497
|
+
* Fine classes that carry a rider. A scooter/moped reaches us as
|
|
2498
|
+
* `motorcycle` (COCO) or `motorbike` depending on the model's label set.
|
|
2499
|
+
*/
|
|
2500
|
+
var TWO_WHEELERS = new Set([
|
|
2501
|
+
"bicycle",
|
|
2502
|
+
"motorcycle",
|
|
2503
|
+
"motorbike"
|
|
2504
|
+
]);
|
|
2505
|
+
/** Intersection area of two boxes. */
|
|
2506
|
+
function intersection(a, b) {
|
|
2507
|
+
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));
|
|
2508
|
+
}
|
|
2509
|
+
/** True when `vehicle` is a two-wheeler that `person` is riding. */
|
|
2510
|
+
function isRiderPair(person, vehicle) {
|
|
2511
|
+
if (person.macroClass !== "person") return false;
|
|
2512
|
+
if (vehicle.macroClass !== "vehicle") return false;
|
|
2513
|
+
if (!TWO_WHEELERS.has((vehicle.originalClass ?? "").toLowerCase())) return false;
|
|
2514
|
+
const personArea = person.bbox.w * person.bbox.h;
|
|
2515
|
+
if (!(personArea > 0)) return false;
|
|
2516
|
+
if (intersection(person.bbox, vehicle.bbox) / personArea < .25) return false;
|
|
2517
|
+
return person.bbox.y + person.bbox.h / 2 < vehicle.bbox.y + vehicle.bbox.h / 2;
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Pair every rider with their machine. A person is paired at most once — with
|
|
2521
|
+
* the two-wheeler they overlap MOST — so two bikes side by side cannot both
|
|
2522
|
+
* claim the same rider.
|
|
2523
|
+
*/
|
|
2524
|
+
function pairRiders(detections) {
|
|
2525
|
+
const people = detections.filter((d) => d.macroClass === "person");
|
|
2526
|
+
const twoWheelers = detections.filter((d) => d.macroClass === "vehicle" && TWO_WHEELERS.has((d.originalClass ?? "").toLowerCase()));
|
|
2527
|
+
if (people.length === 0 || twoWheelers.length === 0) return [];
|
|
2528
|
+
const pairs = [];
|
|
2529
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2530
|
+
for (const person of people) {
|
|
2531
|
+
let best = null;
|
|
2532
|
+
const personArea = person.bbox.w * person.bbox.h;
|
|
2533
|
+
if (!(personArea > 0)) continue;
|
|
2534
|
+
for (const vehicle of twoWheelers) {
|
|
2535
|
+
if (claimed.has(vehicle.id)) continue;
|
|
2536
|
+
if (!isRiderPair(person, vehicle)) continue;
|
|
2537
|
+
const overlap = intersection(person.bbox, vehicle.bbox) / personArea;
|
|
2538
|
+
if (!best || overlap > best.overlap) best = {
|
|
2539
|
+
vehicle,
|
|
2540
|
+
overlap
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
if (best) {
|
|
2544
|
+
claimed.add(best.vehicle.id);
|
|
2545
|
+
pairs.push({
|
|
2546
|
+
personId: person.id,
|
|
2547
|
+
vehicleId: best.vehicle.id,
|
|
2548
|
+
overlap: Math.round(best.overlap * 1e3) / 1e3
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
return pairs;
|
|
2553
|
+
}
|
|
2554
|
+
//#endregion
|
|
2381
2555
|
//#region src/pipeline-analytics/pipeline/frame-processor.ts
|
|
2382
2556
|
/** Mapping from StateAnalyzer's `ObjectState.state` values to the
|
|
2383
2557
|
* canonical TrackState enum used on tracks + events. */
|
|
@@ -2411,6 +2585,21 @@ var FrameProcessor = class {
|
|
|
2411
2585
|
* runners that haven't picked up the new gating yet.
|
|
2412
2586
|
*/
|
|
2413
2587
|
detectionRules;
|
|
2588
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2589
|
+
zoneMembershipMinOverlap;
|
|
2590
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2591
|
+
lastZoneOverlaps;
|
|
2592
|
+
/** See {@link getLastZoneRejections}. */
|
|
2593
|
+
lastZoneRejections;
|
|
2594
|
+
/** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
|
|
2595
|
+
zoneTransitions = new ZoneTransitionTracker();
|
|
2596
|
+
/**
|
|
2597
|
+
* Last zone membership seen per track, so an event for a track that is GONE
|
|
2598
|
+
* this frame still carries the zones it was in. See the use site.
|
|
2599
|
+
*/
|
|
2600
|
+
lastZonesByTrack = /* @__PURE__ */ new Map();
|
|
2601
|
+
/** See {@link getLastRiderPairs}. */
|
|
2602
|
+
lastRiderPairs = [];
|
|
2414
2603
|
zoneEngine = new ZoneEngine();
|
|
2415
2604
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2416
2605
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2423,10 +2612,46 @@ var FrameProcessor = class {
|
|
|
2423
2612
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2424
2613
|
this.zones = [];
|
|
2425
2614
|
this.detectionRules = [];
|
|
2615
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2616
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2617
|
+
this.lastZoneRejections = /* @__PURE__ */ new Map();
|
|
2426
2618
|
}
|
|
2427
2619
|
setZones(zones) {
|
|
2428
2620
|
this.zones = zones;
|
|
2429
2621
|
}
|
|
2622
|
+
/**
|
|
2623
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2624
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2625
|
+
*
|
|
2626
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2627
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2628
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2629
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2630
|
+
* exists so the distribution can be read before a number is picked.
|
|
2631
|
+
*/
|
|
2632
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2633
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2634
|
+
}
|
|
2635
|
+
/** Rider pairings folded on the most recent frame — the PERSON half that was
|
|
2636
|
+
* dropped so the passage counts once, as a vehicle. Reported so both
|
|
2637
|
+
* entities stay on the record rather than one silently disappearing. */
|
|
2638
|
+
getLastRiderPairs() {
|
|
2639
|
+
return this.lastRiderPairs;
|
|
2640
|
+
}
|
|
2641
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2642
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2643
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2644
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2645
|
+
getLastZoneOverlaps() {
|
|
2646
|
+
return this.lastZoneOverlaps;
|
|
2647
|
+
}
|
|
2648
|
+
/** Per-track zones the membership bar REJECTED (0 < overlap ≤ the bar), from
|
|
2649
|
+
* the most recent frame. Raising the bar otherwise makes the stamping log go
|
|
2650
|
+
* quiet for exactly the cases it was armed to measure — see
|
|
2651
|
+
* `ZoneMembershipSplit` in `zones/zone-engine.ts`. */
|
|
2652
|
+
getLastZoneRejections() {
|
|
2653
|
+
return this.lastZoneRejections;
|
|
2654
|
+
}
|
|
2430
2655
|
setDetectionRules(rules) {
|
|
2431
2656
|
this.detectionRules = rules;
|
|
2432
2657
|
}
|
|
@@ -2519,7 +2744,24 @@ var FrameProcessor = class {
|
|
|
2519
2744
|
});
|
|
2520
2745
|
}
|
|
2521
2746
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
2522
|
-
|
|
2747
|
+
let filteredDetections = passed.map((fd) => fd.detection);
|
|
2748
|
+
const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
|
|
2749
|
+
id: String(i),
|
|
2750
|
+
macroClass: d.class,
|
|
2751
|
+
...d.originalClass !== void 0 ? { originalClass: d.originalClass } : {},
|
|
2752
|
+
bbox: d.bbox,
|
|
2753
|
+
score: d.score
|
|
2754
|
+
})));
|
|
2755
|
+
if (riderPairs.length > 0) {
|
|
2756
|
+
const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
|
|
2757
|
+
this.lastRiderPairs = riderPairs.map((p) => ({
|
|
2758
|
+
overlap: p.overlap,
|
|
2759
|
+
personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
|
|
2760
|
+
vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
|
|
2761
|
+
vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
|
|
2762
|
+
}));
|
|
2763
|
+
filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
|
|
2764
|
+
} else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
|
|
2523
2765
|
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
2524
2766
|
detections: filteredDetections,
|
|
2525
2767
|
frameWidth,
|
|
@@ -2535,12 +2777,46 @@ var FrameProcessor = class {
|
|
|
2535
2777
|
frameHeight
|
|
2536
2778
|
});
|
|
2537
2779
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2538
|
-
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2539
2780
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2781
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2782
|
+
const rejectedByTrack = /* @__PURE__ */ new Map();
|
|
2540
2783
|
for (const td of trackedDetections) {
|
|
2541
2784
|
const m = maskByBbox.get(td.bbox);
|
|
2542
|
-
const memberships = this.zoneEngine.
|
|
2785
|
+
const { memberships, belowBar } = this.zoneEngine.splitDetectionZones(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2543
2786
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2787
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2788
|
+
if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
|
|
2789
|
+
}
|
|
2790
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2791
|
+
this.lastZoneRejections = rejectedByTrack;
|
|
2792
|
+
for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
|
|
2793
|
+
for (const state of objectStates) {
|
|
2794
|
+
if (zonesByTrack.has(state.trackId)) continue;
|
|
2795
|
+
const remembered = this.lastZonesByTrack.get(state.trackId);
|
|
2796
|
+
if (remembered && remembered.length > 0) zonesByTrack.set(state.trackId, remembered);
|
|
2797
|
+
}
|
|
2798
|
+
const zoneEvents = [];
|
|
2799
|
+
const pushZoneEvent = (type, zoneId, detection) => {
|
|
2800
|
+
const zone = this.zones.find((z) => z.id === zoneId);
|
|
2801
|
+
zoneEvents.push({
|
|
2802
|
+
type,
|
|
2803
|
+
zoneId,
|
|
2804
|
+
zoneName: zone?.name ?? zoneId,
|
|
2805
|
+
trackId: detection.trackId,
|
|
2806
|
+
detection,
|
|
2807
|
+
timestamp
|
|
2808
|
+
});
|
|
2809
|
+
};
|
|
2810
|
+
for (const td of trackedDetections) {
|
|
2811
|
+
const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
|
|
2812
|
+
for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
|
|
2813
|
+
for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
|
|
2814
|
+
}
|
|
2815
|
+
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
|
|
2816
|
+
for (const state of objectStates) {
|
|
2817
|
+
if (state.state !== "leaving") continue;
|
|
2818
|
+
this.zoneTransitions.forget(state.trackId);
|
|
2819
|
+
this.lastZonesByTrack.delete(state.trackId);
|
|
2544
2820
|
}
|
|
2545
2821
|
const tracked = trackedDetections.map((td) => {
|
|
2546
2822
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
@@ -2579,10 +2855,22 @@ var FrameProcessor = class {
|
|
|
2579
2855
|
} : {}
|
|
2580
2856
|
};
|
|
2581
2857
|
});
|
|
2858
|
+
const crossingOf = (e) => {
|
|
2859
|
+
const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
|
|
2860
|
+
if (direction === void 0) return void 0;
|
|
2861
|
+
const ze = e.zoneEvents[0];
|
|
2862
|
+
if (ze === void 0) return void 0;
|
|
2863
|
+
return {
|
|
2864
|
+
direction,
|
|
2865
|
+
zoneId: ze.zoneId,
|
|
2866
|
+
zoneName: ze.zoneName
|
|
2867
|
+
};
|
|
2868
|
+
};
|
|
2582
2869
|
const toObjectEvent = (e, forcedState) => {
|
|
2583
2870
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
2584
2871
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
2585
2872
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
2873
|
+
const crossing = crossingOf(e);
|
|
2586
2874
|
const label = td ? resolveDetectionLabel({
|
|
2587
2875
|
className: td.class,
|
|
2588
2876
|
originalClass: td.originalClass,
|
|
@@ -2606,6 +2894,7 @@ var FrameProcessor = class {
|
|
|
2606
2894
|
},
|
|
2607
2895
|
zones,
|
|
2608
2896
|
state,
|
|
2897
|
+
...crossing !== void 0 ? { zoneCrossing: crossing } : {},
|
|
2609
2898
|
...label ? { label } : {},
|
|
2610
2899
|
frameWidth,
|
|
2611
2900
|
frameHeight
|
|
@@ -3504,7 +3793,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
|
3504
3793
|
* for the full contract.
|
|
3505
3794
|
*/
|
|
3506
3795
|
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3507
|
-
if (input.
|
|
3796
|
+
if (input.hasBestMedia) return "persist";
|
|
3797
|
+
if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3508
3798
|
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3509
3799
|
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3510
3800
|
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
@@ -3698,9 +3988,11 @@ var TrackCloser = class {
|
|
|
3698
3988
|
const duration = t.lastSeen - t.firstSeen;
|
|
3699
3989
|
const closure = this.deps.residents.closure(t.trackId);
|
|
3700
3990
|
const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
|
|
3991
|
+
const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
|
|
3701
3992
|
const outcome = decideZeroMediaPolicy({
|
|
3702
3993
|
durationMs: duration,
|
|
3703
3994
|
hasMedia: ownedMedia.length > 0,
|
|
3995
|
+
hasBestMedia,
|
|
3704
3996
|
confirmed: closure?.confirmed ?? false,
|
|
3705
3997
|
hasRasterFallback: closure?.rasterFallback !== void 0
|
|
3706
3998
|
});
|
|
@@ -4042,6 +4334,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
4042
4334
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
4043
4335
|
source: ev.source ?? "pipeline",
|
|
4044
4336
|
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
4337
|
+
...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
|
|
4045
4338
|
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
|
|
4046
4339
|
};
|
|
4047
4340
|
}
|
|
@@ -4208,6 +4501,7 @@ function presentConditionIds(c) {
|
|
|
4208
4501
|
if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
|
|
4209
4502
|
if (c.zones !== void 0) ids.push("zones");
|
|
4210
4503
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
|
|
4504
|
+
if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
|
|
4211
4505
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
4212
4506
|
if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
|
|
4213
4507
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
@@ -4243,6 +4537,21 @@ function matchesOccupancy(occ, s) {
|
|
|
4243
4537
|
case "<=": return s.occupied === false && s.threshold === occ.count + 1;
|
|
4244
4538
|
}
|
|
4245
4539
|
}
|
|
4540
|
+
/**
|
|
4541
|
+
* The zone ids a `zones` / `zonesExclude` condition tests against: the
|
|
4542
|
+
* record's membership PLUS the zone it crossed, when it is a crossing.
|
|
4543
|
+
*
|
|
4544
|
+
* The union is what makes an EXIT addressable. Membership is computed from the
|
|
4545
|
+
* box's current geometry, so on the frame an exit is confirmed the subject is
|
|
4546
|
+
* by definition no longer in the zone — a rule scoped to "Uscio" would never
|
|
4547
|
+
* see the exit from Uscio. An ENTRY's zone is already in the membership, so
|
|
4548
|
+
* this changes nothing for every rule that exists today.
|
|
4549
|
+
*/
|
|
4550
|
+
function zonesVisitedBy(subject) {
|
|
4551
|
+
const visited = new Set(subject.zones);
|
|
4552
|
+
if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
|
|
4553
|
+
return visited;
|
|
4554
|
+
}
|
|
4246
4555
|
function toLowerSet(values) {
|
|
4247
4556
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
4248
4557
|
}
|
|
@@ -4328,14 +4637,20 @@ function evaluateRule(rule, subject) {
|
|
|
4328
4637
|
if (c.minDwellSeconds !== void 0) {
|
|
4329
4638
|
if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
|
|
4330
4639
|
}
|
|
4640
|
+
if (c.crossing !== "any") {
|
|
4641
|
+
const direction = subject.crossing?.direction;
|
|
4642
|
+
if (c.crossing === "exit") {
|
|
4643
|
+
if (direction !== "exit") return fail("crossing");
|
|
4644
|
+
} else if (direction === "exit") return fail("crossing");
|
|
4645
|
+
}
|
|
4331
4646
|
if (c.zones !== void 0) {
|
|
4332
|
-
const visited =
|
|
4647
|
+
const visited = zonesVisitedBy(subject);
|
|
4333
4648
|
if (c.zones.match === "all") {
|
|
4334
4649
|
for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
|
|
4335
4650
|
} else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
|
|
4336
4651
|
}
|
|
4337
4652
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
|
|
4338
|
-
const visited =
|
|
4653
|
+
const visited = zonesVisitedBy(subject);
|
|
4339
4654
|
if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
|
|
4340
4655
|
}
|
|
4341
4656
|
if (c.customZones !== void 0 && c.customZones.length > 0) {
|
|
@@ -4464,10 +4779,28 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
4464
4779
|
for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
|
|
4465
4780
|
return false;
|
|
4466
4781
|
}
|
|
4467
|
-
/**
|
|
4782
|
+
/**
|
|
4783
|
+
* Should this subject's class be part of the cooldown key?
|
|
4784
|
+
*
|
|
4785
|
+
* AUDIO is unconditional and predates the setting: a rule opted into several
|
|
4786
|
+
* audio classes (dog + scream) must not have a safety-relevant scream
|
|
4787
|
+
* swallowed by an unrelated bark's 60 s window.
|
|
4788
|
+
*
|
|
4789
|
+
* Every other subject gets the same treatment only when the rule ASKS for it
|
|
4790
|
+
* (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
|
|
4791
|
+
* Absent / `shared` reproduces the class-agnostic key byte for byte, because
|
|
4792
|
+
* changing how often an operator's existing rules fire is not a side effect
|
|
4793
|
+
* anyone asked for.
|
|
4794
|
+
*/
|
|
4795
|
+
function keysPerClass(rule, subject) {
|
|
4796
|
+
if (subject.kind === "audio-event") return true;
|
|
4797
|
+
return rule.throttle.granularity === "per-class";
|
|
4798
|
+
}
|
|
4799
|
+
/** Stable cooldown key per the rule's throttle scope + class granularity. */
|
|
4468
4800
|
function cooldownKey(rule, subject) {
|
|
4469
|
-
const
|
|
4470
|
-
|
|
4801
|
+
const first = subject.classNames[0];
|
|
4802
|
+
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
4803
|
+
return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:d:${subject.deviceId}${classKey}`;
|
|
4471
4804
|
}
|
|
4472
4805
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4473
4806
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -4635,7 +4968,9 @@ var NcDispatcher = class {
|
|
|
4635
4968
|
ruleId: entry.ruleId,
|
|
4636
4969
|
target: target.name,
|
|
4637
4970
|
kind: target.kind,
|
|
4638
|
-
recordKind: entry.recordKind
|
|
4971
|
+
recordKind: entry.recordKind,
|
|
4972
|
+
eventId: entry.recordId,
|
|
4973
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4639
4974
|
}
|
|
4640
4975
|
});
|
|
4641
4976
|
return { ok: true };
|
|
@@ -4673,9 +5008,10 @@ var NcDispatcher = class {
|
|
|
4673
5008
|
async buildNotification(entry) {
|
|
4674
5009
|
const subject = entry.payload.subject;
|
|
4675
5010
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4676
|
-
const
|
|
5011
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
5012
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4677
5013
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4678
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
5014
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4679
5015
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4680
5016
|
const params = pickParams(entry.payload.params);
|
|
4681
5017
|
return {
|
|
@@ -4874,15 +5210,37 @@ var NcDispatcher = class {
|
|
|
4874
5210
|
return null;
|
|
4875
5211
|
}
|
|
4876
5212
|
};
|
|
4877
|
-
|
|
5213
|
+
/**
|
|
5214
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
5215
|
+
*
|
|
5216
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
5217
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
5218
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
5219
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
5220
|
+
*/
|
|
5221
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
5222
|
+
if (zoneIds.length === 0) return [];
|
|
5223
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
5224
|
+
try {
|
|
5225
|
+
const zones = await getZoneNames(deviceId);
|
|
5226
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
5227
|
+
return zoneIds.map((id) => {
|
|
5228
|
+
const name = byId.get(id);
|
|
5229
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
5230
|
+
});
|
|
5231
|
+
} catch {
|
|
5232
|
+
return [...zoneIds];
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4878
5236
|
const subject = entry.payload.subject;
|
|
4879
5237
|
const occupancy = subject.occupancy;
|
|
4880
5238
|
return {
|
|
4881
5239
|
camera: deviceName,
|
|
4882
5240
|
class: subject.className,
|
|
4883
5241
|
label: subject.label ?? "",
|
|
4884
|
-
zones:
|
|
4885
|
-
zone: occupancy?.zone ??
|
|
5242
|
+
zones: zoneLabels.join(", "),
|
|
5243
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4886
5244
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4887
5245
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4888
5246
|
rule: entry.payload.ruleName,
|
|
@@ -4900,12 +5258,12 @@ function renderTemplate(template, vars) {
|
|
|
4900
5258
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4901
5259
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4902
5260
|
}
|
|
4903
|
-
function defaultBody(entry, deviceName) {
|
|
5261
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4904
5262
|
const subject = entry.payload.subject;
|
|
4905
5263
|
const occupancy = subject.occupancy;
|
|
4906
5264
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4907
5265
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4908
|
-
const zones =
|
|
5266
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4909
5267
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4910
5268
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4911
5269
|
}
|
|
@@ -6232,6 +6590,13 @@ var TimelapseStore = class {
|
|
|
6232
6590
|
};
|
|
6233
6591
|
//#endregion
|
|
6234
6592
|
//#region src/notification-center/index.ts
|
|
6593
|
+
/**
|
|
6594
|
+
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
6595
|
+
* a busy camera prints one line rather than one per event, short enough that a
|
|
6596
|
+
* rule which has stopped matching is visible within minutes rather than by
|
|
6597
|
+
* comparison with another system fifteen hours later.
|
|
6598
|
+
*/
|
|
6599
|
+
var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
|
|
6235
6600
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
6236
6601
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
6237
6602
|
var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
|
|
@@ -6342,6 +6707,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6342
6707
|
occupancyEnabled = false;
|
|
6343
6708
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
6344
6709
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
6710
|
+
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
6711
|
+
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
6712
|
+
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
6345
6713
|
/**
|
|
6346
6714
|
* Serialized evaluation chain. The persist hooks are fire-and-forget for
|
|
6347
6715
|
* the frame path, but two concurrent evaluations of the same device
|
|
@@ -6673,9 +7041,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6673
7041
|
return;
|
|
6674
7042
|
}
|
|
6675
7043
|
const now = this.now();
|
|
7044
|
+
let anyMatched = false;
|
|
7045
|
+
const rejections = [];
|
|
6676
7046
|
for (const rule of candidates) {
|
|
6677
7047
|
const evaluation = evaluateRule(rule, subject);
|
|
6678
7048
|
if (!evaluation.matched) {
|
|
7049
|
+
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
6679
7050
|
this.logger.debug("rule did not match", {
|
|
6680
7051
|
tags: { deviceId: subject.deviceId },
|
|
6681
7052
|
meta: {
|
|
@@ -6683,7 +7054,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6683
7054
|
rule: rule.name,
|
|
6684
7055
|
kind,
|
|
6685
7056
|
failed: evaluation.failedCondition,
|
|
6686
|
-
classes: subject.classNames
|
|
7057
|
+
classes: subject.classNames,
|
|
7058
|
+
eventId: subject.recordId,
|
|
7059
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6687
7060
|
}
|
|
6688
7061
|
});
|
|
6689
7062
|
continue;
|
|
@@ -6695,7 +7068,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6695
7068
|
meta: {
|
|
6696
7069
|
ruleId: rule.id,
|
|
6697
7070
|
rule: rule.name,
|
|
6698
|
-
key
|
|
7071
|
+
key,
|
|
7072
|
+
eventId: subject.recordId,
|
|
7073
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6699
7074
|
}
|
|
6700
7075
|
});
|
|
6701
7076
|
continue;
|
|
@@ -6706,12 +7081,42 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6706
7081
|
ruleId: rule.id,
|
|
6707
7082
|
rule: rule.name,
|
|
6708
7083
|
kind,
|
|
6709
|
-
targets: rule.targets.length
|
|
7084
|
+
targets: rule.targets.length,
|
|
7085
|
+
eventId: subject.recordId,
|
|
7086
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
7087
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
7088
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6710
7089
|
}
|
|
6711
7090
|
});
|
|
6712
7091
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6713
7092
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
7093
|
+
anyMatched = true;
|
|
6714
7094
|
}
|
|
7095
|
+
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
7096
|
+
}
|
|
7097
|
+
/** See the call site. Bounded to one line per device per window. */
|
|
7098
|
+
reportNoMatch(subject, kind, rejections, now) {
|
|
7099
|
+
const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
|
|
7100
|
+
const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
|
|
7101
|
+
if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
|
|
7102
|
+
this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
|
|
7103
|
+
return;
|
|
7104
|
+
}
|
|
7105
|
+
this.lastNoMatchReportAt.set(subject.deviceId, now);
|
|
7106
|
+
this.noMatchSuppressed.set(subject.deviceId, 0);
|
|
7107
|
+
this.logger.info("event matched NO rule", {
|
|
7108
|
+
tags: { deviceId: subject.deviceId },
|
|
7109
|
+
meta: {
|
|
7110
|
+
kind,
|
|
7111
|
+
rejectedBy: rejections,
|
|
7112
|
+
classes: subject.classNames,
|
|
7113
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
7114
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : { zones: [] },
|
|
7115
|
+
eventId: subject.recordId,
|
|
7116
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
7117
|
+
...suppressed > 0 ? { alsoSuppressedSinceLastReport: suppressed } : {}
|
|
7118
|
+
}
|
|
7119
|
+
});
|
|
6715
7120
|
}
|
|
6716
7121
|
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6717
7122
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
@@ -7806,6 +8211,63 @@ function classifyTrackAppearance(input) {
|
|
|
7806
8211
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
7807
8212
|
}
|
|
7808
8213
|
//#endregion
|
|
8214
|
+
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
8215
|
+
/**
|
|
8216
|
+
* Memory of births the confirmation gate rejected, per device.
|
|
8217
|
+
*
|
|
8218
|
+
* The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
|
|
8219
|
+
* position count is what distinguishes a true birth from a tracker
|
|
8220
|
+
* resurrection. So a rejected birth has already been written by the time the
|
|
8221
|
+
* verdict exists. It correctly gets no `start`, no media and no notification —
|
|
8222
|
+
* but the record stayed, and surfaced in `listRecentTracks`. On 2026-07-31 six
|
|
8223
|
+
* full-frame phantoms on device 615 in nine minutes were every one of them
|
|
8224
|
+
* suppressed AND listed.
|
|
8225
|
+
*
|
|
8226
|
+
* Retracting once is not enough. The tracker keeps carrying a rejected id for
|
|
8227
|
+
* as long as the phantom persists — 27 seconds in the worst measured case — and
|
|
8228
|
+
* every later frame would write it straight back. So the rejection is
|
|
8229
|
+
* remembered until the id stops being tracked, and only then forgotten.
|
|
8230
|
+
*
|
|
8231
|
+
* Kept as its own module because the state is the part with real risk: forget
|
|
8232
|
+
* too early and the phantom returns, never forget and a camera producing
|
|
8233
|
+
* phantoms continuously grows this set for the process's lifetime.
|
|
8234
|
+
*/
|
|
8235
|
+
var SuppressedBirthRegistry = class {
|
|
8236
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
8237
|
+
/** Record that this birth was rejected; later frames must not re-upsert it. */
|
|
8238
|
+
reject(deviceKey, trackId) {
|
|
8239
|
+
let ids = this.byDevice.get(deviceKey);
|
|
8240
|
+
if (!ids) {
|
|
8241
|
+
ids = /* @__PURE__ */ new Set();
|
|
8242
|
+
this.byDevice.set(deviceKey, ids);
|
|
8243
|
+
}
|
|
8244
|
+
ids.add(trackId);
|
|
8245
|
+
}
|
|
8246
|
+
isRejected(deviceKey, trackId) {
|
|
8247
|
+
return this.byDevice.get(deviceKey)?.has(trackId) === true;
|
|
8248
|
+
}
|
|
8249
|
+
/**
|
|
8250
|
+
* Forget every rejected id the tracker is no longer carrying.
|
|
8251
|
+
*
|
|
8252
|
+
* Called once per frame with the ids present THIS frame. An id absent from
|
|
8253
|
+
* that set can never be upserted again, so holding it serves nothing.
|
|
8254
|
+
*/
|
|
8255
|
+
retain(deviceKey, currentTrackIds) {
|
|
8256
|
+
const ids = this.byDevice.get(deviceKey);
|
|
8257
|
+
if (!ids) return;
|
|
8258
|
+
for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
|
|
8259
|
+
if (ids.size === 0) this.byDevice.delete(deviceKey);
|
|
8260
|
+
}
|
|
8261
|
+
/** Rejected ids currently held for a device — diagnostics and tests. */
|
|
8262
|
+
size(deviceKey) {
|
|
8263
|
+
return this.byDevice.get(deviceKey)?.size ?? 0;
|
|
8264
|
+
}
|
|
8265
|
+
/** Drop a device's memory wholesale (device removed / pipeline reset). */
|
|
8266
|
+
clearDevice(deviceKey) {
|
|
8267
|
+
this.byDevice.delete(deviceKey);
|
|
8268
|
+
}
|
|
8269
|
+
};
|
|
8270
|
+
//#endregion
|
|
7809
8271
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
7810
8272
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
7811
8273
|
const scored = [];
|
|
@@ -8310,9 +8772,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
|
8310
8772
|
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
8311
8773
|
* new object.
|
|
8312
8774
|
*
|
|
8313
|
-
* NOTE: clamping
|
|
8314
|
-
*
|
|
8315
|
-
*
|
|
8775
|
+
* NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
|
|
8776
|
+
* bound slides inward and keeps the extent that was asked for, on either side.
|
|
8777
|
+
* It shrinks only when the padded window is larger than the frame itself
|
|
8316
8778
|
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
8317
8779
|
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
8318
8780
|
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
@@ -8322,13 +8784,13 @@ function padBbox(bbox, padding) {
|
|
|
8322
8784
|
const rawY = bbox.y - padding * bbox.h;
|
|
8323
8785
|
const rawW = bbox.w * (1 + 2 * padding);
|
|
8324
8786
|
const rawH = bbox.h * (1 + 2 * padding);
|
|
8325
|
-
const
|
|
8326
|
-
const
|
|
8787
|
+
const w = Math.min(rawW, 1);
|
|
8788
|
+
const h = Math.min(rawH, 1);
|
|
8327
8789
|
return {
|
|
8328
|
-
x,
|
|
8329
|
-
y,
|
|
8330
|
-
w
|
|
8331
|
-
h
|
|
8790
|
+
x: Math.min(Math.max(0, rawX), 1 - w),
|
|
8791
|
+
y: Math.min(Math.max(0, rawY), 1 - h),
|
|
8792
|
+
w,
|
|
8793
|
+
h
|
|
8332
8794
|
};
|
|
8333
8795
|
}
|
|
8334
8796
|
//#endregion
|
|
@@ -12023,7 +12485,11 @@ function squareSubjectCropRegion(bbox, frame) {
|
|
|
12023
12485
|
* cases). `C` becomes the middle square of the output.
|
|
12024
12486
|
* 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
|
|
12025
12487
|
* 3. anchor: place the canvas so `C` is its horizontal middle →
|
|
12026
|
-
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0)
|
|
12488
|
+
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
|
|
12489
|
+
* then CLAMP the window fully inside the frame whenever it fits. The
|
|
12490
|
+
* subject therefore drifts off-centre near a frame edge and the output
|
|
12491
|
+
* carries no padding at all. Centring is a preference; containing the
|
|
12492
|
+
* subject is the contract, and clamping cannot break it.
|
|
12027
12493
|
* 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
|
|
12028
12494
|
* `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
|
|
12029
12495
|
* the vertical extent is always in-frame). Its canvas offset is
|
|
@@ -12035,13 +12501,8 @@ function wideCentralSquareLayout(bbox, frame) {
|
|
|
12035
12501
|
const canvasW = Math.round(c * 16 / 9);
|
|
12036
12502
|
const centralX0 = Math.round((canvasW - c) / 2);
|
|
12037
12503
|
let frameOriginX = central.x - (canvasW - c) / 2;
|
|
12038
|
-
if (canvasW <= frame.W)
|
|
12039
|
-
|
|
12040
|
-
const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
|
|
12041
|
-
if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
|
|
12042
|
-
const overRight = frameOriginX + canvasW - frame.W;
|
|
12043
|
-
if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
|
|
12044
|
-
} else frameOriginX = (frame.W - canvasW) / 2;
|
|
12504
|
+
if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
|
|
12505
|
+
else frameOriginX = (frame.W - canvasW) / 2;
|
|
12045
12506
|
const fxa = Math.max(0, frameOriginX);
|
|
12046
12507
|
const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
|
|
12047
12508
|
const slabOffsetX = fxa - frameOriginX;
|
|
@@ -12090,10 +12551,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
|
|
|
12090
12551
|
/**
|
|
12091
12552
|
* Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
|
|
12092
12553
|
* in-frame slab fetched for `layout`. The slab is placed at its computed offset
|
|
12093
|
-
* on a 16:9 canvas; any lateral part of the window outside the frame
|
|
12094
|
-
*
|
|
12095
|
-
*
|
|
12096
|
-
*
|
|
12554
|
+
* on a 16:9 canvas; any lateral part of the window outside the frame is filled
|
|
12555
|
+
* with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
|
|
12556
|
+
* (operator triage 2026-07-22).
|
|
12557
|
+
*
|
|
12558
|
+
* **That fill is now nearly unreachable, and deliberately so.** The geometry
|
|
12559
|
+
* clamps the window fully inside the frame whenever it fits, so a subject
|
|
12560
|
+
* against a frame edge yields real pixels off-centre rather than ambience
|
|
12561
|
+
* (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
|
|
12562
|
+
* still pads — there is no more scene to slide into — which is why this code
|
|
12563
|
+
* stays. If you are looking at a blurred band in a best shot, the window was
|
|
12564
|
+
* wider than the frame; do not go looking for a sliding bug.
|
|
12097
12565
|
* When the whole window is in-frame (`slab` already spans the full canvas) the
|
|
12098
12566
|
* slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
|
|
12099
12567
|
* the canvas is at the slab's native scale; only the out-of-frame ambience fill
|
|
@@ -13216,6 +13684,13 @@ var ZoneAnalyticsProvider = class {
|
|
|
13216
13684
|
zones: snapshot.zones.length
|
|
13217
13685
|
}
|
|
13218
13686
|
});
|
|
13687
|
+
this.ctx.emitOccupancyChanged?.({
|
|
13688
|
+
deviceId: input.deviceId,
|
|
13689
|
+
timestamp: input.timestamp,
|
|
13690
|
+
totalObjects: total,
|
|
13691
|
+
byClass: snapshot.frame.byClass,
|
|
13692
|
+
zones: snapshot.zones.length
|
|
13693
|
+
});
|
|
13219
13694
|
}
|
|
13220
13695
|
this.snapshots.set(input.deviceId, snapshot);
|
|
13221
13696
|
this.appendHistory(input.deviceId, snapshot);
|
|
@@ -13853,6 +14328,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
13853
14328
|
};
|
|
13854
14329
|
}
|
|
13855
14330
|
var TrackingSettingsSchema = require_dist.object({
|
|
14331
|
+
/**
|
|
14332
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
14333
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
14334
|
+
*
|
|
14335
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
14336
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
14337
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
14338
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
14339
|
+
* inside it.
|
|
14340
|
+
*
|
|
14341
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
14342
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
14343
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
14344
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
14345
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
14346
|
+
*/
|
|
14347
|
+
zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
|
|
13856
14348
|
/** IoU required to match a (predicted) track to a detection. */
|
|
13857
14349
|
iouThreshold: require_dist.number().min(0).max(1).default(.3),
|
|
13858
14350
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -13989,6 +14481,7 @@ function resolveTrackingSettings(raw) {
|
|
|
13989
14481
|
const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
|
|
13990
14482
|
const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
|
|
13991
14483
|
return {
|
|
14484
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
13992
14485
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
13993
14486
|
maxMissedMs,
|
|
13994
14487
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -14043,7 +14536,11 @@ function resolveTrackingSettings(raw) {
|
|
|
14043
14536
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
14044
14537
|
* misclassified static object).
|
|
14045
14538
|
*
|
|
14046
|
-
*
|
|
14539
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14540
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14541
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14542
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14543
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
14047
14544
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
14048
14545
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
14049
14546
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -14139,10 +14636,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
14139
14636
|
if (track === "other" || det === "other") return true;
|
|
14140
14637
|
return track === det;
|
|
14141
14638
|
}
|
|
14142
|
-
var failOpen = (
|
|
14143
|
-
trackId,
|
|
14639
|
+
var failOpen = (candidate, reason) => ({
|
|
14640
|
+
trackId: candidate.trackId,
|
|
14144
14641
|
confirmed: true,
|
|
14145
|
-
reason
|
|
14642
|
+
reason,
|
|
14643
|
+
className: candidate.className
|
|
14146
14644
|
});
|
|
14147
14645
|
function withTimeout(promise, timeoutMs) {
|
|
14148
14646
|
return new Promise((resolve, reject) => {
|
|
@@ -14158,23 +14656,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
14158
14656
|
}
|
|
14159
14657
|
async function runConfirmation(candidate, config, deps) {
|
|
14160
14658
|
const crop = await deps.fetchCrop(candidate);
|
|
14161
|
-
if (!crop) return failOpen(candidate
|
|
14659
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
14162
14660
|
const detections = await deps.redetect(crop);
|
|
14163
|
-
if (detections === null) return failOpen(candidate
|
|
14164
|
-
|
|
14661
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14662
|
+
let best;
|
|
14663
|
+
let bestIncompatible;
|
|
14664
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14665
|
+
if (!best || d.score > best.score) best = d;
|
|
14666
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14667
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
14165
14668
|
return {
|
|
14166
14669
|
trackId: candidate.trackId,
|
|
14167
14670
|
confirmed,
|
|
14168
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14671
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14672
|
+
className: candidate.className,
|
|
14673
|
+
...best ? { bestScore: best.score } : {},
|
|
14674
|
+
...bestIncompatible ? {
|
|
14675
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14676
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14677
|
+
} : {}
|
|
14169
14678
|
};
|
|
14170
14679
|
}
|
|
14171
14680
|
async function confirmOne(candidate, config, deps) {
|
|
14172
14681
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
14173
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14682
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
14174
14683
|
try {
|
|
14175
14684
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
14176
14685
|
} catch {
|
|
14177
|
-
return failOpen(candidate
|
|
14686
|
+
return failOpen(candidate, "timeout");
|
|
14178
14687
|
}
|
|
14179
14688
|
}
|
|
14180
14689
|
/**
|
|
@@ -14357,8 +14866,23 @@ function resolveMediaSettings(raw) {
|
|
|
14357
14866
|
* §3.3 draft said 45s).
|
|
14358
14867
|
*/
|
|
14359
14868
|
var PackageDropSettingsSchema = require_dist.object({
|
|
14360
|
-
/**
|
|
14361
|
-
|
|
14869
|
+
/**
|
|
14870
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14871
|
+
* package detection**, not a second switch.
|
|
14872
|
+
*
|
|
14873
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14874
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14875
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14876
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14877
|
+
* silently by a boolean none of them mentions.
|
|
14878
|
+
*
|
|
14879
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14880
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14881
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14882
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14883
|
+
* rule.
|
|
14884
|
+
*/
|
|
14885
|
+
packageDropEnabled: require_dist.boolean().default(true),
|
|
14362
14886
|
/**
|
|
14363
14887
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
14364
14888
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -14899,6 +15423,15 @@ function buildDetectionSettingsSections() {
|
|
|
14899
15423
|
step: .05,
|
|
14900
15424
|
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
14901
15425
|
},
|
|
15426
|
+
{
|
|
15427
|
+
type: "number",
|
|
15428
|
+
key: "rescueCentroidFactor",
|
|
15429
|
+
label: "Rescue centroid distance",
|
|
15430
|
+
description: "The other half of the rescue gate: how far a detection’s centre may sit from the track’s last-known centre, as a fraction of the box diagonal. A rescue must pass BOTH this and the rescue IoU, so raising the IoU alone does not tighten re-attachment.",
|
|
15431
|
+
min: 0,
|
|
15432
|
+
step: .05,
|
|
15433
|
+
default: TRACKING_DEFAULTS.rescueCentroidFactor
|
|
15434
|
+
},
|
|
14902
15435
|
{
|
|
14903
15436
|
type: "number",
|
|
14904
15437
|
key: "stationarySpeedPx",
|
|
@@ -15180,6 +15713,16 @@ function buildDetectionSettingsSections() {
|
|
|
15180
15713
|
label: "Person/animal group matching",
|
|
15181
15714
|
description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
|
|
15182
15715
|
default: TRACKING_DEFAULTS.classGroupAssoc
|
|
15716
|
+
},
|
|
15717
|
+
{
|
|
15718
|
+
type: "number",
|
|
15719
|
+
key: "zoneMembershipMinOverlap",
|
|
15720
|
+
label: "Zone membership minimum overlap",
|
|
15721
|
+
description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
|
|
15722
|
+
min: 0,
|
|
15723
|
+
max: 1,
|
|
15724
|
+
step: .05,
|
|
15725
|
+
default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
|
|
15183
15726
|
}
|
|
15184
15727
|
]
|
|
15185
15728
|
},
|
|
@@ -19577,7 +20120,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
19577
20120
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
19578
20121
|
return Array.from(view);
|
|
19579
20122
|
}
|
|
20123
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
20124
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
20125
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
20126
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
20127
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
20128
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
20129
|
+
* to the pixel still land in one cell. */
|
|
20130
|
+
var PHANTOM_CELL_PX = 32;
|
|
20131
|
+
/** How long a cell remembers its closes. */
|
|
20132
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
19580
20133
|
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
20134
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
20135
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
20136
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
20137
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
19581
20138
|
processors = /* @__PURE__ */ new Map();
|
|
19582
20139
|
trackStore = null;
|
|
19583
20140
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -19735,6 +20292,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19735
20292
|
* dataPlane facility in the current environment). */
|
|
19736
20293
|
eventMediaBaseUrl = null;
|
|
19737
20294
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
20295
|
+
/** See `pipeline/suppressed-births.ts` — rejected births must not be
|
|
20296
|
+
* re-upserted, and must be forgotten when the tracker drops the id. */
|
|
20297
|
+
suppressedBirths = new SuppressedBirthRegistry();
|
|
19738
20298
|
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
19739
20299
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
19740
20300
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
@@ -19887,7 +20447,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19887
20447
|
});
|
|
19888
20448
|
},
|
|
19889
20449
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
19890
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20450
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20451
|
+
this.noteShortMotionlessTrack(track);
|
|
20452
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20453
|
+
},
|
|
19891
20454
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
19892
20455
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
19893
20456
|
...input,
|
|
@@ -20033,7 +20596,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20033
20596
|
let storage = this.ctx.kernel.storage;
|
|
20034
20597
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
20035
20598
|
if (mediaRoot) {
|
|
20036
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
20599
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
|
|
20037
20600
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
20038
20601
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
20039
20602
|
}
|
|
@@ -20315,6 +20878,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20315
20878
|
const zoneAnalytics = new ZoneAnalyticsProvider({
|
|
20316
20879
|
logger: logger.child("ZoneAnalytics"),
|
|
20317
20880
|
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
20881
|
+
emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
|
|
20882
|
+
id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
|
|
20883
|
+
timestamp: new Date(payload.timestamp),
|
|
20884
|
+
source: {
|
|
20885
|
+
type: "addon",
|
|
20886
|
+
id: "pipeline-analytics",
|
|
20887
|
+
addonId: "pipeline-analytics"
|
|
20888
|
+
},
|
|
20889
|
+
category: require_dist.EventCategory.ZoneAnalyticsOccupancyChanged,
|
|
20890
|
+
data: {
|
|
20891
|
+
deviceId: payload.deviceId,
|
|
20892
|
+
totalObjects: payload.totalObjects,
|
|
20893
|
+
byClass: payload.byClass,
|
|
20894
|
+
zones: payload.zones
|
|
20895
|
+
}
|
|
20896
|
+
}),
|
|
20318
20897
|
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
20319
20898
|
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
20320
20899
|
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
@@ -20374,6 +20953,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20374
20953
|
};
|
|
20375
20954
|
},
|
|
20376
20955
|
dispatcher: {
|
|
20956
|
+
getZoneNames: async (deviceId) => {
|
|
20957
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20958
|
+
id: z.id,
|
|
20959
|
+
name: z.name
|
|
20960
|
+
}));
|
|
20961
|
+
},
|
|
20377
20962
|
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20378
20963
|
const zones = await api.zones.listZones.query({ deviceId });
|
|
20379
20964
|
const wanted = new Set(zoneIds);
|
|
@@ -20751,10 +21336,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20751
21336
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
20752
21337
|
processor.setZones(liveZones);
|
|
20753
21338
|
processor.setDetectionRules(liveRules);
|
|
21339
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
20754
21340
|
const result = processor.process({
|
|
20755
21341
|
timestamp: frame.timestamp,
|
|
20756
21342
|
frame
|
|
20757
21343
|
});
|
|
21344
|
+
for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
|
|
21345
|
+
tags: { deviceId },
|
|
21346
|
+
meta: {
|
|
21347
|
+
vehicleClass: r.vehicleClass,
|
|
21348
|
+
personScore: r.personScore,
|
|
21349
|
+
vehicleScore: r.vehicleScore,
|
|
21350
|
+
overlap: r.overlap
|
|
21351
|
+
}
|
|
21352
|
+
});
|
|
20758
21353
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
20759
21354
|
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
20760
21355
|
deviceId,
|
|
@@ -20785,6 +21380,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20785
21380
|
const positionsCountById = /* @__PURE__ */ new Map();
|
|
20786
21381
|
for (const t of result.tracked) {
|
|
20787
21382
|
currentTrackIds.add(t.trackId);
|
|
21383
|
+
if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
|
|
20788
21384
|
const center = {
|
|
20789
21385
|
x: t.bbox.x + t.bbox.w / 2,
|
|
20790
21386
|
y: t.bbox.y + t.bbox.h / 2
|
|
@@ -20851,7 +21447,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20851
21447
|
frameHeight: result.frameHeight
|
|
20852
21448
|
});
|
|
20853
21449
|
for (const { id, t } of bornCandidates) {
|
|
20854
|
-
if (!confirmedBirths.has(id))
|
|
21450
|
+
if (!confirmedBirths.has(id)) {
|
|
21451
|
+
this.suppressedBirths.reject(key, id);
|
|
21452
|
+
this.trackStore?.dropActive(id);
|
|
21453
|
+
log.info("birth suppressed — track record retracted", { meta: {
|
|
21454
|
+
trackId: id,
|
|
21455
|
+
className: t.className,
|
|
21456
|
+
source
|
|
21457
|
+
} });
|
|
21458
|
+
continue;
|
|
21459
|
+
}
|
|
20855
21460
|
newTrackCount += 1;
|
|
20856
21461
|
log.info("track started", { meta: {
|
|
20857
21462
|
trackId: id,
|
|
@@ -20912,6 +21517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20912
21517
|
source
|
|
20913
21518
|
} });
|
|
20914
21519
|
}
|
|
21520
|
+
this.suppressedBirths.retain(key, currentTrackIds);
|
|
20915
21521
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
20916
21522
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
20917
21523
|
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
@@ -20981,7 +21587,46 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20981
21587
|
} });
|
|
20982
21588
|
}
|
|
20983
21589
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
20984
|
-
if (this.notificationCenter !== null)
|
|
21590
|
+
if (this.notificationCenter !== null) {
|
|
21591
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21592
|
+
const rejections = processor.getLastZoneRejections();
|
|
21593
|
+
for (const e of result.objectEvents) {
|
|
21594
|
+
if (e.zones && e.zones.length > 0) {
|
|
21595
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21596
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21597
|
+
tags: { deviceId },
|
|
21598
|
+
meta: {
|
|
21599
|
+
eventId: e.id,
|
|
21600
|
+
trackId: e.trackId,
|
|
21601
|
+
className: e.className,
|
|
21602
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21603
|
+
zones: (m ?? []).map((z) => ({
|
|
21604
|
+
id: z.zoneId,
|
|
21605
|
+
name: z.zoneName,
|
|
21606
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21607
|
+
}))
|
|
21608
|
+
}
|
|
21609
|
+
});
|
|
21610
|
+
}
|
|
21611
|
+
const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
|
|
21612
|
+
if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
|
|
21613
|
+
tags: { deviceId },
|
|
21614
|
+
meta: {
|
|
21615
|
+
eventId: e.id,
|
|
21616
|
+
trackId: e.trackId,
|
|
21617
|
+
className: e.className,
|
|
21618
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21619
|
+
stamped: e.zones?.length ?? 0,
|
|
21620
|
+
zones: rejected.map((z) => ({
|
|
21621
|
+
id: z.zoneId,
|
|
21622
|
+
name: z.zoneName,
|
|
21623
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21624
|
+
}))
|
|
21625
|
+
}
|
|
21626
|
+
});
|
|
21627
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21628
|
+
}
|
|
21629
|
+
}
|
|
20985
21630
|
const objectEmbeddingBests = [];
|
|
20986
21631
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
20987
21632
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -21333,19 +21978,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21333
21978
|
}),
|
|
21334
21979
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
21335
21980
|
onDecision: (decision) => {
|
|
21981
|
+
const meta = {
|
|
21982
|
+
trackId: decision.trackId,
|
|
21983
|
+
reason: decision.reason,
|
|
21984
|
+
className: decision.className,
|
|
21985
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21986
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21987
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21988
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21989
|
+
} : {},
|
|
21990
|
+
minConfidence: config.minConfidence
|
|
21991
|
+
};
|
|
21336
21992
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
21337
21993
|
tags: { deviceId },
|
|
21338
|
-
meta
|
|
21339
|
-
trackId: decision.trackId,
|
|
21340
|
-
reason: decision.reason
|
|
21341
|
-
}
|
|
21994
|
+
meta
|
|
21342
21995
|
});
|
|
21343
21996
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
21344
21997
|
tags: { deviceId },
|
|
21345
|
-
meta
|
|
21346
|
-
|
|
21347
|
-
|
|
21348
|
-
}
|
|
21998
|
+
meta
|
|
21999
|
+
});
|
|
22000
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
22001
|
+
tags: { deviceId },
|
|
22002
|
+
meta
|
|
21349
22003
|
});
|
|
21350
22004
|
}
|
|
21351
22005
|
});
|
|
@@ -21362,6 +22016,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21362
22016
|
async resolveDeviceStationarySettings(deviceId) {
|
|
21363
22017
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
21364
22018
|
}
|
|
22019
|
+
/**
|
|
22020
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
22021
|
+
*
|
|
22022
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
22023
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
22024
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
22025
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
22026
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
22027
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
22028
|
+
* to notice it.
|
|
22029
|
+
*
|
|
22030
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
22031
|
+
* standing still at a door is not declared scenery.
|
|
22032
|
+
*
|
|
22033
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
22034
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
22035
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
22036
|
+
*/
|
|
22037
|
+
noteShortMotionlessTrack(track) {
|
|
22038
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
22039
|
+
const moved = track.totalDistance ?? 0;
|
|
22040
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
22041
|
+
const first = track.positions?.[0];
|
|
22042
|
+
if (!first) return;
|
|
22043
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
22044
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
22045
|
+
const now = Date.now();
|
|
22046
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
22047
|
+
seen.push(now);
|
|
22048
|
+
this.shortMotionlessCells.set(key, seen);
|
|
22049
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
22050
|
+
tags: { deviceId: track.deviceId },
|
|
22051
|
+
meta: {
|
|
22052
|
+
trackId: track.trackId,
|
|
22053
|
+
className: track.className,
|
|
22054
|
+
lifeMs,
|
|
22055
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
22056
|
+
cell,
|
|
22057
|
+
repeatsInWindow: seen.length
|
|
22058
|
+
}
|
|
22059
|
+
});
|
|
22060
|
+
}
|
|
21365
22061
|
stationarySettingsFromCache(deviceId) {
|
|
21366
22062
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
21367
22063
|
}
|
|
@@ -21371,9 +22067,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21371
22067
|
/**
|
|
21372
22068
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
21373
22069
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
21374
|
-
* when the cached slice is empty, forces one refresh.
|
|
21375
|
-
*
|
|
21376
|
-
*
|
|
22070
|
+
* when the cached slice is empty, forces one refresh.
|
|
22071
|
+
*
|
|
22072
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
22073
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
22074
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
22075
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
22076
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
22077
|
+
* operator has drawn no package zone rule, nothing more.
|
|
21377
22078
|
*/
|
|
21378
22079
|
async resolveDevicePackageRules(deviceId) {
|
|
21379
22080
|
const proxy = await this.ensureProxy(deviceId);
|