@camstack/addon-post-analysis 1.2.19 → 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 +512 -36
- package/dist/pipeline-analytics/index.mjs +511 -35
- 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 */
|
|
@@ -2319,17 +2407,31 @@ var ZoneEngine = class {
|
|
|
2319
2407
|
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2320
2408
|
*/
|
|
2321
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) {
|
|
2322
2418
|
const memberships = [];
|
|
2419
|
+
const belowBar = [];
|
|
2323
2420
|
for (const zone of zones) {
|
|
2324
2421
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2325
2422
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2326
|
-
|
|
2423
|
+
const entry = {
|
|
2327
2424
|
zoneId: zone.id,
|
|
2328
2425
|
zoneName: zone.name,
|
|
2329
2426
|
overlap
|
|
2330
|
-
}
|
|
2427
|
+
};
|
|
2428
|
+
if (overlap > minOverlap) memberships.push(entry);
|
|
2429
|
+
else if (overlap > 0) belowBar.push(entry);
|
|
2331
2430
|
}
|
|
2332
|
-
return
|
|
2431
|
+
return {
|
|
2432
|
+
memberships,
|
|
2433
|
+
belowBar
|
|
2434
|
+
};
|
|
2333
2435
|
}
|
|
2334
2436
|
/**
|
|
2335
2437
|
* Filter detections through a zone-rule set. `zones` provides the
|
|
@@ -2390,6 +2492,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
|
|
|
2390
2492
|
return false;
|
|
2391
2493
|
}
|
|
2392
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
|
|
2393
2555
|
//#region src/pipeline-analytics/pipeline/frame-processor.ts
|
|
2394
2556
|
/** Mapping from StateAnalyzer's `ObjectState.state` values to the
|
|
2395
2557
|
* canonical TrackState enum used on tracks + events. */
|
|
@@ -2427,6 +2589,17 @@ var FrameProcessor = class {
|
|
|
2427
2589
|
zoneMembershipMinOverlap;
|
|
2428
2590
|
/** See {@link getLastZoneOverlaps}. */
|
|
2429
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 = [];
|
|
2430
2603
|
zoneEngine = new ZoneEngine();
|
|
2431
2604
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2432
2605
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2441,6 +2614,7 @@ var FrameProcessor = class {
|
|
|
2441
2614
|
this.detectionRules = [];
|
|
2442
2615
|
this.zoneMembershipMinOverlap = 0;
|
|
2443
2616
|
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2617
|
+
this.lastZoneRejections = /* @__PURE__ */ new Map();
|
|
2444
2618
|
}
|
|
2445
2619
|
setZones(zones) {
|
|
2446
2620
|
this.zones = zones;
|
|
@@ -2458,6 +2632,12 @@ var FrameProcessor = class {
|
|
|
2458
2632
|
setZoneMembershipMinOverlap(minOverlap) {
|
|
2459
2633
|
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2460
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
|
+
}
|
|
2461
2641
|
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2462
2642
|
* recent frame. The engine computes these and the pipeline previously
|
|
2463
2643
|
* discarded everything but the ids — which is why no amount of production
|
|
@@ -2465,6 +2645,13 @@ var FrameProcessor = class {
|
|
|
2465
2645
|
getLastZoneOverlaps() {
|
|
2466
2646
|
return this.lastZoneOverlaps;
|
|
2467
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
|
+
}
|
|
2468
2655
|
setDetectionRules(rules) {
|
|
2469
2656
|
this.detectionRules = rules;
|
|
2470
2657
|
}
|
|
@@ -2557,7 +2744,24 @@ var FrameProcessor = class {
|
|
|
2557
2744
|
});
|
|
2558
2745
|
}
|
|
2559
2746
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
2560
|
-
|
|
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 = [];
|
|
2561
2765
|
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
2562
2766
|
detections: filteredDetections,
|
|
2563
2767
|
frameWidth,
|
|
@@ -2573,16 +2777,47 @@ var FrameProcessor = class {
|
|
|
2573
2777
|
frameHeight
|
|
2574
2778
|
});
|
|
2575
2779
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2576
|
-
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2577
2780
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2578
2781
|
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2782
|
+
const rejectedByTrack = /* @__PURE__ */ new Map();
|
|
2579
2783
|
for (const td of trackedDetections) {
|
|
2580
2784
|
const m = maskByBbox.get(td.bbox);
|
|
2581
|
-
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);
|
|
2582
2786
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2583
2787
|
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2788
|
+
if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
|
|
2584
2789
|
}
|
|
2585
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);
|
|
2820
|
+
}
|
|
2586
2821
|
const tracked = trackedDetections.map((td) => {
|
|
2587
2822
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2588
2823
|
const label = resolveDetectionLabel({
|
|
@@ -2620,10 +2855,22 @@ var FrameProcessor = class {
|
|
|
2620
2855
|
} : {}
|
|
2621
2856
|
};
|
|
2622
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
|
+
};
|
|
2623
2869
|
const toObjectEvent = (e, forcedState) => {
|
|
2624
2870
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
2625
2871
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
2626
2872
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
2873
|
+
const crossing = crossingOf(e);
|
|
2627
2874
|
const label = td ? resolveDetectionLabel({
|
|
2628
2875
|
className: td.class,
|
|
2629
2876
|
originalClass: td.originalClass,
|
|
@@ -2647,6 +2894,7 @@ var FrameProcessor = class {
|
|
|
2647
2894
|
},
|
|
2648
2895
|
zones,
|
|
2649
2896
|
state,
|
|
2897
|
+
...crossing !== void 0 ? { zoneCrossing: crossing } : {},
|
|
2650
2898
|
...label ? { label } : {},
|
|
2651
2899
|
frameWidth,
|
|
2652
2900
|
frameHeight
|
|
@@ -3545,7 +3793,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
|
3545
3793
|
* for the full contract.
|
|
3546
3794
|
*/
|
|
3547
3795
|
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3548
|
-
if (input.
|
|
3796
|
+
if (input.hasBestMedia) return "persist";
|
|
3797
|
+
if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3549
3798
|
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3550
3799
|
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3551
3800
|
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
@@ -3739,9 +3988,11 @@ var TrackCloser = class {
|
|
|
3739
3988
|
const duration = t.lastSeen - t.firstSeen;
|
|
3740
3989
|
const closure = this.deps.residents.closure(t.trackId);
|
|
3741
3990
|
const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
|
|
3991
|
+
const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
|
|
3742
3992
|
const outcome = decideZeroMediaPolicy({
|
|
3743
3993
|
durationMs: duration,
|
|
3744
3994
|
hasMedia: ownedMedia.length > 0,
|
|
3995
|
+
hasBestMedia,
|
|
3745
3996
|
confirmed: closure?.confirmed ?? false,
|
|
3746
3997
|
hasRasterFallback: closure?.rasterFallback !== void 0
|
|
3747
3998
|
});
|
|
@@ -4083,6 +4334,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
4083
4334
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
4084
4335
|
source: ev.source ?? "pipeline",
|
|
4085
4336
|
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
4337
|
+
...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
|
|
4086
4338
|
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
|
|
4087
4339
|
};
|
|
4088
4340
|
}
|
|
@@ -4249,6 +4501,7 @@ function presentConditionIds(c) {
|
|
|
4249
4501
|
if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
|
|
4250
4502
|
if (c.zones !== void 0) ids.push("zones");
|
|
4251
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");
|
|
4252
4505
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
4253
4506
|
if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
|
|
4254
4507
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
@@ -4284,6 +4537,21 @@ function matchesOccupancy(occ, s) {
|
|
|
4284
4537
|
case "<=": return s.occupied === false && s.threshold === occ.count + 1;
|
|
4285
4538
|
}
|
|
4286
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
|
+
}
|
|
4287
4555
|
function toLowerSet(values) {
|
|
4288
4556
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
4289
4557
|
}
|
|
@@ -4369,14 +4637,20 @@ function evaluateRule(rule, subject) {
|
|
|
4369
4637
|
if (c.minDwellSeconds !== void 0) {
|
|
4370
4638
|
if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
|
|
4371
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
|
+
}
|
|
4372
4646
|
if (c.zones !== void 0) {
|
|
4373
|
-
const visited =
|
|
4647
|
+
const visited = zonesVisitedBy(subject);
|
|
4374
4648
|
if (c.zones.match === "all") {
|
|
4375
4649
|
for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
|
|
4376
4650
|
} else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
|
|
4377
4651
|
}
|
|
4378
4652
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
|
|
4379
|
-
const visited =
|
|
4653
|
+
const visited = zonesVisitedBy(subject);
|
|
4380
4654
|
if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
|
|
4381
4655
|
}
|
|
4382
4656
|
if (c.customZones !== void 0 && c.customZones.length > 0) {
|
|
@@ -4505,10 +4779,28 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
4505
4779
|
for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
|
|
4506
4780
|
return false;
|
|
4507
4781
|
}
|
|
4508
|
-
/**
|
|
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. */
|
|
4509
4800
|
function cooldownKey(rule, subject) {
|
|
4510
|
-
const
|
|
4511
|
-
|
|
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}`;
|
|
4512
4804
|
}
|
|
4513
4805
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4514
4806
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -6298,6 +6590,13 @@ var TimelapseStore = class {
|
|
|
6298
6590
|
};
|
|
6299
6591
|
//#endregion
|
|
6300
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;
|
|
6301
6600
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
6302
6601
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
6303
6602
|
var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
|
|
@@ -6408,6 +6707,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6408
6707
|
occupancyEnabled = false;
|
|
6409
6708
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
6410
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();
|
|
6411
6713
|
/**
|
|
6412
6714
|
* Serialized evaluation chain. The persist hooks are fire-and-forget for
|
|
6413
6715
|
* the frame path, but two concurrent evaluations of the same device
|
|
@@ -6739,9 +7041,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6739
7041
|
return;
|
|
6740
7042
|
}
|
|
6741
7043
|
const now = this.now();
|
|
7044
|
+
let anyMatched = false;
|
|
7045
|
+
const rejections = [];
|
|
6742
7046
|
for (const rule of candidates) {
|
|
6743
7047
|
const evaluation = evaluateRule(rule, subject);
|
|
6744
7048
|
if (!evaluation.matched) {
|
|
7049
|
+
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
6745
7050
|
this.logger.debug("rule did not match", {
|
|
6746
7051
|
tags: { deviceId: subject.deviceId },
|
|
6747
7052
|
meta: {
|
|
@@ -6785,7 +7090,33 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6785
7090
|
});
|
|
6786
7091
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6787
7092
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
7093
|
+
anyMatched = true;
|
|
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;
|
|
6788
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
|
+
});
|
|
6789
7120
|
}
|
|
6790
7121
|
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6791
7122
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
@@ -7880,6 +8211,63 @@ function classifyTrackAppearance(input) {
|
|
|
7880
8211
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
7881
8212
|
}
|
|
7882
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
|
|
7883
8271
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
7884
8272
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
7885
8273
|
const scored = [];
|
|
@@ -8384,9 +8772,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
|
8384
8772
|
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
8385
8773
|
* new object.
|
|
8386
8774
|
*
|
|
8387
|
-
* NOTE: clamping
|
|
8388
|
-
*
|
|
8389
|
-
*
|
|
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
|
|
8390
8778
|
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
8391
8779
|
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
8392
8780
|
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
@@ -8396,13 +8784,13 @@ function padBbox(bbox, padding) {
|
|
|
8396
8784
|
const rawY = bbox.y - padding * bbox.h;
|
|
8397
8785
|
const rawW = bbox.w * (1 + 2 * padding);
|
|
8398
8786
|
const rawH = bbox.h * (1 + 2 * padding);
|
|
8399
|
-
const
|
|
8400
|
-
const
|
|
8787
|
+
const w = Math.min(rawW, 1);
|
|
8788
|
+
const h = Math.min(rawH, 1);
|
|
8401
8789
|
return {
|
|
8402
|
-
x,
|
|
8403
|
-
y,
|
|
8404
|
-
w
|
|
8405
|
-
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
|
|
8406
8794
|
};
|
|
8407
8795
|
}
|
|
8408
8796
|
//#endregion
|
|
@@ -12097,7 +12485,11 @@ function squareSubjectCropRegion(bbox, frame) {
|
|
|
12097
12485
|
* cases). `C` becomes the middle square of the output.
|
|
12098
12486
|
* 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
|
|
12099
12487
|
* 3. anchor: place the canvas so `C` is its horizontal middle →
|
|
12100
|
-
* `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.
|
|
12101
12493
|
* 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
|
|
12102
12494
|
* `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
|
|
12103
12495
|
* the vertical extent is always in-frame). Its canvas offset is
|
|
@@ -12109,13 +12501,8 @@ function wideCentralSquareLayout(bbox, frame) {
|
|
|
12109
12501
|
const canvasW = Math.round(c * 16 / 9);
|
|
12110
12502
|
const centralX0 = Math.round((canvasW - c) / 2);
|
|
12111
12503
|
let frameOriginX = central.x - (canvasW - c) / 2;
|
|
12112
|
-
if (canvasW <= frame.W)
|
|
12113
|
-
|
|
12114
|
-
const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
|
|
12115
|
-
if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
|
|
12116
|
-
const overRight = frameOriginX + canvasW - frame.W;
|
|
12117
|
-
if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
|
|
12118
|
-
} 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;
|
|
12119
12506
|
const fxa = Math.max(0, frameOriginX);
|
|
12120
12507
|
const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
|
|
12121
12508
|
const slabOffsetX = fxa - frameOriginX;
|
|
@@ -12164,10 +12551,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
|
|
|
12164
12551
|
/**
|
|
12165
12552
|
* Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
|
|
12166
12553
|
* in-frame slab fetched for `layout`. The slab is placed at its computed offset
|
|
12167
|
-
* on a 16:9 canvas; any lateral part of the window outside the frame
|
|
12168
|
-
*
|
|
12169
|
-
*
|
|
12170
|
-
*
|
|
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.
|
|
12171
12565
|
* When the whole window is in-frame (`slab` already spans the full canvas) the
|
|
12172
12566
|
* slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
|
|
12173
12567
|
* the canvas is at the slab's native scale; only the out-of-frame ambience fill
|
|
@@ -13290,6 +13684,13 @@ var ZoneAnalyticsProvider = class {
|
|
|
13290
13684
|
zones: snapshot.zones.length
|
|
13291
13685
|
}
|
|
13292
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
|
+
});
|
|
13293
13694
|
}
|
|
13294
13695
|
this.snapshots.set(input.deviceId, snapshot);
|
|
13295
13696
|
this.appendHistory(input.deviceId, snapshot);
|
|
@@ -15022,6 +15423,15 @@ function buildDetectionSettingsSections() {
|
|
|
15022
15423
|
step: .05,
|
|
15023
15424
|
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
15024
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
|
+
},
|
|
15025
15435
|
{
|
|
15026
15436
|
type: "number",
|
|
15027
15437
|
key: "stationarySpeedPx",
|
|
@@ -15303,6 +15713,16 @@ function buildDetectionSettingsSections() {
|
|
|
15303
15713
|
label: "Person/animal group matching",
|
|
15304
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.",
|
|
15305
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
|
|
15306
15726
|
}
|
|
15307
15727
|
]
|
|
15308
15728
|
},
|
|
@@ -19872,6 +20292,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19872
20292
|
* dataPlane facility in the current environment). */
|
|
19873
20293
|
eventMediaBaseUrl = null;
|
|
19874
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();
|
|
19875
20298
|
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
19876
20299
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
19877
20300
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
@@ -20173,7 +20596,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20173
20596
|
let storage = this.ctx.kernel.storage;
|
|
20174
20597
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
20175
20598
|
if (mediaRoot) {
|
|
20176
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
20599
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
|
|
20177
20600
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
20178
20601
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
20179
20602
|
}
|
|
@@ -20455,6 +20878,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20455
20878
|
const zoneAnalytics = new ZoneAnalyticsProvider({
|
|
20456
20879
|
logger: logger.child("ZoneAnalytics"),
|
|
20457
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
|
+
}),
|
|
20458
20897
|
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
20459
20898
|
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
20460
20899
|
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
@@ -20902,6 +21341,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20902
21341
|
timestamp: frame.timestamp,
|
|
20903
21342
|
frame
|
|
20904
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
|
+
});
|
|
20905
21353
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
20906
21354
|
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
20907
21355
|
deviceId,
|
|
@@ -20932,6 +21380,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20932
21380
|
const positionsCountById = /* @__PURE__ */ new Map();
|
|
20933
21381
|
for (const t of result.tracked) {
|
|
20934
21382
|
currentTrackIds.add(t.trackId);
|
|
21383
|
+
if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
|
|
20935
21384
|
const center = {
|
|
20936
21385
|
x: t.bbox.x + t.bbox.w / 2,
|
|
20937
21386
|
y: t.bbox.y + t.bbox.h / 2
|
|
@@ -20998,7 +21447,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20998
21447
|
frameHeight: result.frameHeight
|
|
20999
21448
|
});
|
|
21000
21449
|
for (const { id, t } of bornCandidates) {
|
|
21001
|
-
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
|
+
}
|
|
21002
21460
|
newTrackCount += 1;
|
|
21003
21461
|
log.info("track started", { meta: {
|
|
21004
21462
|
trackId: id,
|
|
@@ -21059,6 +21517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21059
21517
|
source
|
|
21060
21518
|
} });
|
|
21061
21519
|
}
|
|
21520
|
+
this.suppressedBirths.retain(key, currentTrackIds);
|
|
21062
21521
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
21063
21522
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
21064
21523
|
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
@@ -21130,6 +21589,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21130
21589
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
21131
21590
|
if (this.notificationCenter !== null) {
|
|
21132
21591
|
const overlaps = processor.getLastZoneOverlaps();
|
|
21592
|
+
const rejections = processor.getLastZoneRejections();
|
|
21133
21593
|
for (const e of result.objectEvents) {
|
|
21134
21594
|
if (e.zones && e.zones.length > 0) {
|
|
21135
21595
|
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
@@ -21148,6 +21608,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21148
21608
|
}
|
|
21149
21609
|
});
|
|
21150
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
|
+
});
|
|
21151
21627
|
this.notificationCenter.onObjectEventPersisted(e);
|
|
21152
21628
|
}
|
|
21153
21629
|
}
|