@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-
|
|
1
|
+
import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-CrAkB8NZ.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1435,6 +1435,94 @@ function resolveDetectionLabel(input) {
|
|
|
1435
1435
|
if (top) return top;
|
|
1436
1436
|
return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
|
|
1437
1437
|
}
|
|
1438
|
+
var DEFAULT_ZONE_TRANSITION_CONFIG = {
|
|
1439
|
+
enterFrames: 2,
|
|
1440
|
+
exitFrames: 4
|
|
1441
|
+
};
|
|
1442
|
+
var NO_TRANSITIONS = {
|
|
1443
|
+
entered: [],
|
|
1444
|
+
exited: []
|
|
1445
|
+
};
|
|
1446
|
+
function newState() {
|
|
1447
|
+
return {
|
|
1448
|
+
inside: /* @__PURE__ */ new Set(),
|
|
1449
|
+
enterStreak: /* @__PURE__ */ new Map(),
|
|
1450
|
+
exitStreak: /* @__PURE__ */ new Map()
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
var ZoneTransitionTracker = class {
|
|
1454
|
+
config;
|
|
1455
|
+
tracks = /* @__PURE__ */ new Map();
|
|
1456
|
+
constructor(config = {}) {
|
|
1457
|
+
this.config = {
|
|
1458
|
+
...DEFAULT_ZONE_TRANSITION_CONFIG,
|
|
1459
|
+
...config
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Feed one frame's zone membership for one track.
|
|
1464
|
+
*
|
|
1465
|
+
* `currentZoneIds` is the set the geometry says the box is in RIGHT NOW; the
|
|
1466
|
+
* return value is only what has persisted long enough to be believed.
|
|
1467
|
+
*/
|
|
1468
|
+
observe(trackId, currentZoneIds) {
|
|
1469
|
+
const state = this.tracks.get(trackId) ?? newState();
|
|
1470
|
+
this.tracks.set(trackId, state);
|
|
1471
|
+
const now = new Set(currentZoneIds);
|
|
1472
|
+
const entered = [];
|
|
1473
|
+
const exited = [];
|
|
1474
|
+
for (const zoneId of now) {
|
|
1475
|
+
if (state.inside.has(zoneId)) {
|
|
1476
|
+
state.exitStreak.delete(zoneId);
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
const streak = (state.enterStreak.get(zoneId) ?? 0) + 1;
|
|
1480
|
+
if (streak >= this.config.enterFrames) {
|
|
1481
|
+
state.enterStreak.delete(zoneId);
|
|
1482
|
+
state.inside.add(zoneId);
|
|
1483
|
+
entered.push(zoneId);
|
|
1484
|
+
} else state.enterStreak.set(zoneId, streak);
|
|
1485
|
+
}
|
|
1486
|
+
for (const zoneId of [...state.enterStreak.keys()]) if (!now.has(zoneId)) state.enterStreak.delete(zoneId);
|
|
1487
|
+
for (const zoneId of [...state.inside]) {
|
|
1488
|
+
if (now.has(zoneId)) continue;
|
|
1489
|
+
const streak = (state.exitStreak.get(zoneId) ?? 0) + 1;
|
|
1490
|
+
if (streak >= this.config.exitFrames) {
|
|
1491
|
+
state.exitStreak.delete(zoneId);
|
|
1492
|
+
state.inside.delete(zoneId);
|
|
1493
|
+
exited.push(zoneId);
|
|
1494
|
+
} else state.exitStreak.set(zoneId, streak);
|
|
1495
|
+
}
|
|
1496
|
+
if (entered.length === 0 && exited.length === 0) return NO_TRANSITIONS;
|
|
1497
|
+
return {
|
|
1498
|
+
entered,
|
|
1499
|
+
exited
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/** Zones a track is currently confirmed to be inside. */
|
|
1503
|
+
zonesFor(trackId) {
|
|
1504
|
+
const state = this.tracks.get(trackId);
|
|
1505
|
+
return state ? [...state.inside] : [];
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Drop a track and report the zones it was still inside.
|
|
1509
|
+
*
|
|
1510
|
+
* The caller uses these to close the subject's presence — a track that
|
|
1511
|
+
* disappears mid-zone has genuinely left it, and nothing else will ever say
|
|
1512
|
+
* so. Without this, a zone entry would have no matching exit whenever the
|
|
1513
|
+
* subject walked out of frame rather than out of the zone.
|
|
1514
|
+
*/
|
|
1515
|
+
forget(trackId) {
|
|
1516
|
+
const state = this.tracks.get(trackId);
|
|
1517
|
+
if (!state) return [];
|
|
1518
|
+
this.tracks.delete(trackId);
|
|
1519
|
+
return [...state.inside];
|
|
1520
|
+
}
|
|
1521
|
+
/** Number of tracks held — a leak check for the caller's tests. */
|
|
1522
|
+
size() {
|
|
1523
|
+
return this.tracks.size;
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1438
1526
|
//#endregion
|
|
1439
1527
|
//#region src/pipeline-analytics/pipeline/zones/geometry.ts
|
|
1440
1528
|
/** Ray-casting point-in-polygon test */
|
|
@@ -2296,22 +2384,48 @@ function resolveRuleThreshold(rule) {
|
|
|
2296
2384
|
*/
|
|
2297
2385
|
var ZoneEngine = class {
|
|
2298
2386
|
/**
|
|
2299
|
-
* Annotate a single detection with its zone memberships
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2387
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2388
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2389
|
+
* the detection's own area).
|
|
2390
|
+
*
|
|
2391
|
+
* The previous version of this comment claimed memberships were returned
|
|
2392
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2393
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2394
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2395
|
+
*
|
|
2396
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2397
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2398
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2399
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2400
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2401
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2302
2402
|
*/
|
|
2303
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2403
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2404
|
+
return [...this.splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap).memberships];
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* {@link annotateDetection}, keeping the zones the bar REJECTED as well as
|
|
2408
|
+
* the ones it admitted. Same geometry, one pass — see
|
|
2409
|
+
* {@link ZoneMembershipSplit} for why the rejected side has to survive.
|
|
2410
|
+
*/
|
|
2411
|
+
splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2304
2412
|
const memberships = [];
|
|
2413
|
+
const belowBar = [];
|
|
2305
2414
|
for (const zone of zones) {
|
|
2306
2415
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2307
2416
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2308
|
-
|
|
2417
|
+
const entry = {
|
|
2309
2418
|
zoneId: zone.id,
|
|
2310
2419
|
zoneName: zone.name,
|
|
2311
2420
|
overlap
|
|
2312
|
-
}
|
|
2421
|
+
};
|
|
2422
|
+
if (overlap > minOverlap) memberships.push(entry);
|
|
2423
|
+
else if (overlap > 0) belowBar.push(entry);
|
|
2313
2424
|
}
|
|
2314
|
-
return
|
|
2425
|
+
return {
|
|
2426
|
+
memberships,
|
|
2427
|
+
belowBar
|
|
2428
|
+
};
|
|
2315
2429
|
}
|
|
2316
2430
|
/**
|
|
2317
2431
|
* Filter detections through a zone-rule set. `zones` provides the
|
|
@@ -2372,6 +2486,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
|
|
|
2372
2486
|
return false;
|
|
2373
2487
|
}
|
|
2374
2488
|
//#endregion
|
|
2489
|
+
//#region src/pipeline-analytics/pipeline/rider-pairing.ts
|
|
2490
|
+
/**
|
|
2491
|
+
* Fine classes that carry a rider. A scooter/moped reaches us as
|
|
2492
|
+
* `motorcycle` (COCO) or `motorbike` depending on the model's label set.
|
|
2493
|
+
*/
|
|
2494
|
+
var TWO_WHEELERS = new Set([
|
|
2495
|
+
"bicycle",
|
|
2496
|
+
"motorcycle",
|
|
2497
|
+
"motorbike"
|
|
2498
|
+
]);
|
|
2499
|
+
/** Intersection area of two boxes. */
|
|
2500
|
+
function intersection(a, b) {
|
|
2501
|
+
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));
|
|
2502
|
+
}
|
|
2503
|
+
/** True when `vehicle` is a two-wheeler that `person` is riding. */
|
|
2504
|
+
function isRiderPair(person, vehicle) {
|
|
2505
|
+
if (person.macroClass !== "person") return false;
|
|
2506
|
+
if (vehicle.macroClass !== "vehicle") return false;
|
|
2507
|
+
if (!TWO_WHEELERS.has((vehicle.originalClass ?? "").toLowerCase())) return false;
|
|
2508
|
+
const personArea = person.bbox.w * person.bbox.h;
|
|
2509
|
+
if (!(personArea > 0)) return false;
|
|
2510
|
+
if (intersection(person.bbox, vehicle.bbox) / personArea < .25) return false;
|
|
2511
|
+
return person.bbox.y + person.bbox.h / 2 < vehicle.bbox.y + vehicle.bbox.h / 2;
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* Pair every rider with their machine. A person is paired at most once — with
|
|
2515
|
+
* the two-wheeler they overlap MOST — so two bikes side by side cannot both
|
|
2516
|
+
* claim the same rider.
|
|
2517
|
+
*/
|
|
2518
|
+
function pairRiders(detections) {
|
|
2519
|
+
const people = detections.filter((d) => d.macroClass === "person");
|
|
2520
|
+
const twoWheelers = detections.filter((d) => d.macroClass === "vehicle" && TWO_WHEELERS.has((d.originalClass ?? "").toLowerCase()));
|
|
2521
|
+
if (people.length === 0 || twoWheelers.length === 0) return [];
|
|
2522
|
+
const pairs = [];
|
|
2523
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2524
|
+
for (const person of people) {
|
|
2525
|
+
let best = null;
|
|
2526
|
+
const personArea = person.bbox.w * person.bbox.h;
|
|
2527
|
+
if (!(personArea > 0)) continue;
|
|
2528
|
+
for (const vehicle of twoWheelers) {
|
|
2529
|
+
if (claimed.has(vehicle.id)) continue;
|
|
2530
|
+
if (!isRiderPair(person, vehicle)) continue;
|
|
2531
|
+
const overlap = intersection(person.bbox, vehicle.bbox) / personArea;
|
|
2532
|
+
if (!best || overlap > best.overlap) best = {
|
|
2533
|
+
vehicle,
|
|
2534
|
+
overlap
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
if (best) {
|
|
2538
|
+
claimed.add(best.vehicle.id);
|
|
2539
|
+
pairs.push({
|
|
2540
|
+
personId: person.id,
|
|
2541
|
+
vehicleId: best.vehicle.id,
|
|
2542
|
+
overlap: Math.round(best.overlap * 1e3) / 1e3
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
return pairs;
|
|
2547
|
+
}
|
|
2548
|
+
//#endregion
|
|
2375
2549
|
//#region src/pipeline-analytics/pipeline/frame-processor.ts
|
|
2376
2550
|
/** Mapping from StateAnalyzer's `ObjectState.state` values to the
|
|
2377
2551
|
* canonical TrackState enum used on tracks + events. */
|
|
@@ -2405,6 +2579,21 @@ var FrameProcessor = class {
|
|
|
2405
2579
|
* runners that haven't picked up the new gating yet.
|
|
2406
2580
|
*/
|
|
2407
2581
|
detectionRules;
|
|
2582
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2583
|
+
zoneMembershipMinOverlap;
|
|
2584
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2585
|
+
lastZoneOverlaps;
|
|
2586
|
+
/** See {@link getLastZoneRejections}. */
|
|
2587
|
+
lastZoneRejections;
|
|
2588
|
+
/** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
|
|
2589
|
+
zoneTransitions = new ZoneTransitionTracker();
|
|
2590
|
+
/**
|
|
2591
|
+
* Last zone membership seen per track, so an event for a track that is GONE
|
|
2592
|
+
* this frame still carries the zones it was in. See the use site.
|
|
2593
|
+
*/
|
|
2594
|
+
lastZonesByTrack = /* @__PURE__ */ new Map();
|
|
2595
|
+
/** See {@link getLastRiderPairs}. */
|
|
2596
|
+
lastRiderPairs = [];
|
|
2408
2597
|
zoneEngine = new ZoneEngine();
|
|
2409
2598
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2410
2599
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2417,10 +2606,46 @@ var FrameProcessor = class {
|
|
|
2417
2606
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2418
2607
|
this.zones = [];
|
|
2419
2608
|
this.detectionRules = [];
|
|
2609
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2610
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2611
|
+
this.lastZoneRejections = /* @__PURE__ */ new Map();
|
|
2420
2612
|
}
|
|
2421
2613
|
setZones(zones) {
|
|
2422
2614
|
this.zones = zones;
|
|
2423
2615
|
}
|
|
2616
|
+
/**
|
|
2617
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2618
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2619
|
+
*
|
|
2620
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2621
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2622
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2623
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2624
|
+
* exists so the distribution can be read before a number is picked.
|
|
2625
|
+
*/
|
|
2626
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2627
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2628
|
+
}
|
|
2629
|
+
/** Rider pairings folded on the most recent frame — the PERSON half that was
|
|
2630
|
+
* dropped so the passage counts once, as a vehicle. Reported so both
|
|
2631
|
+
* entities stay on the record rather than one silently disappearing. */
|
|
2632
|
+
getLastRiderPairs() {
|
|
2633
|
+
return this.lastRiderPairs;
|
|
2634
|
+
}
|
|
2635
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2636
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2637
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2638
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2639
|
+
getLastZoneOverlaps() {
|
|
2640
|
+
return this.lastZoneOverlaps;
|
|
2641
|
+
}
|
|
2642
|
+
/** Per-track zones the membership bar REJECTED (0 < overlap ≤ the bar), from
|
|
2643
|
+
* the most recent frame. Raising the bar otherwise makes the stamping log go
|
|
2644
|
+
* quiet for exactly the cases it was armed to measure — see
|
|
2645
|
+
* `ZoneMembershipSplit` in `zones/zone-engine.ts`. */
|
|
2646
|
+
getLastZoneRejections() {
|
|
2647
|
+
return this.lastZoneRejections;
|
|
2648
|
+
}
|
|
2424
2649
|
setDetectionRules(rules) {
|
|
2425
2650
|
this.detectionRules = rules;
|
|
2426
2651
|
}
|
|
@@ -2513,7 +2738,24 @@ var FrameProcessor = class {
|
|
|
2513
2738
|
});
|
|
2514
2739
|
}
|
|
2515
2740
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
2516
|
-
|
|
2741
|
+
let filteredDetections = passed.map((fd) => fd.detection);
|
|
2742
|
+
const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
|
|
2743
|
+
id: String(i),
|
|
2744
|
+
macroClass: d.class,
|
|
2745
|
+
...d.originalClass !== void 0 ? { originalClass: d.originalClass } : {},
|
|
2746
|
+
bbox: d.bbox,
|
|
2747
|
+
score: d.score
|
|
2748
|
+
})));
|
|
2749
|
+
if (riderPairs.length > 0) {
|
|
2750
|
+
const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
|
|
2751
|
+
this.lastRiderPairs = riderPairs.map((p) => ({
|
|
2752
|
+
overlap: p.overlap,
|
|
2753
|
+
personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
|
|
2754
|
+
vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
|
|
2755
|
+
vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
|
|
2756
|
+
}));
|
|
2757
|
+
filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
|
|
2758
|
+
} else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
|
|
2517
2759
|
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
2518
2760
|
detections: filteredDetections,
|
|
2519
2761
|
frameWidth,
|
|
@@ -2529,12 +2771,46 @@ var FrameProcessor = class {
|
|
|
2529
2771
|
frameHeight
|
|
2530
2772
|
});
|
|
2531
2773
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2532
|
-
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2533
2774
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2775
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2776
|
+
const rejectedByTrack = /* @__PURE__ */ new Map();
|
|
2534
2777
|
for (const td of trackedDetections) {
|
|
2535
2778
|
const m = maskByBbox.get(td.bbox);
|
|
2536
|
-
const memberships = this.zoneEngine.
|
|
2779
|
+
const { memberships, belowBar } = this.zoneEngine.splitDetectionZones(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2537
2780
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2781
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2782
|
+
if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
|
|
2783
|
+
}
|
|
2784
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2785
|
+
this.lastZoneRejections = rejectedByTrack;
|
|
2786
|
+
for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
|
|
2787
|
+
for (const state of objectStates) {
|
|
2788
|
+
if (zonesByTrack.has(state.trackId)) continue;
|
|
2789
|
+
const remembered = this.lastZonesByTrack.get(state.trackId);
|
|
2790
|
+
if (remembered && remembered.length > 0) zonesByTrack.set(state.trackId, remembered);
|
|
2791
|
+
}
|
|
2792
|
+
const zoneEvents = [];
|
|
2793
|
+
const pushZoneEvent = (type, zoneId, detection) => {
|
|
2794
|
+
const zone = this.zones.find((z) => z.id === zoneId);
|
|
2795
|
+
zoneEvents.push({
|
|
2796
|
+
type,
|
|
2797
|
+
zoneId,
|
|
2798
|
+
zoneName: zone?.name ?? zoneId,
|
|
2799
|
+
trackId: detection.trackId,
|
|
2800
|
+
detection,
|
|
2801
|
+
timestamp
|
|
2802
|
+
});
|
|
2803
|
+
};
|
|
2804
|
+
for (const td of trackedDetections) {
|
|
2805
|
+
const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
|
|
2806
|
+
for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
|
|
2807
|
+
for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
|
|
2808
|
+
}
|
|
2809
|
+
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
|
|
2810
|
+
for (const state of objectStates) {
|
|
2811
|
+
if (state.state !== "leaving") continue;
|
|
2812
|
+
this.zoneTransitions.forget(state.trackId);
|
|
2813
|
+
this.lastZonesByTrack.delete(state.trackId);
|
|
2538
2814
|
}
|
|
2539
2815
|
const tracked = trackedDetections.map((td) => {
|
|
2540
2816
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
@@ -2573,10 +2849,22 @@ var FrameProcessor = class {
|
|
|
2573
2849
|
} : {}
|
|
2574
2850
|
};
|
|
2575
2851
|
});
|
|
2852
|
+
const crossingOf = (e) => {
|
|
2853
|
+
const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
|
|
2854
|
+
if (direction === void 0) return void 0;
|
|
2855
|
+
const ze = e.zoneEvents[0];
|
|
2856
|
+
if (ze === void 0) return void 0;
|
|
2857
|
+
return {
|
|
2858
|
+
direction,
|
|
2859
|
+
zoneId: ze.zoneId,
|
|
2860
|
+
zoneName: ze.zoneName
|
|
2861
|
+
};
|
|
2862
|
+
};
|
|
2576
2863
|
const toObjectEvent = (e, forcedState) => {
|
|
2577
2864
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
2578
2865
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
2579
2866
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
2867
|
+
const crossing = crossingOf(e);
|
|
2580
2868
|
const label = td ? resolveDetectionLabel({
|
|
2581
2869
|
className: td.class,
|
|
2582
2870
|
originalClass: td.originalClass,
|
|
@@ -2600,6 +2888,7 @@ var FrameProcessor = class {
|
|
|
2600
2888
|
},
|
|
2601
2889
|
zones,
|
|
2602
2890
|
state,
|
|
2891
|
+
...crossing !== void 0 ? { zoneCrossing: crossing } : {},
|
|
2603
2892
|
...label ? { label } : {},
|
|
2604
2893
|
frameWidth,
|
|
2605
2894
|
frameHeight
|
|
@@ -3498,7 +3787,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
|
3498
3787
|
* for the full contract.
|
|
3499
3788
|
*/
|
|
3500
3789
|
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3501
|
-
if (input.
|
|
3790
|
+
if (input.hasBestMedia) return "persist";
|
|
3791
|
+
if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3502
3792
|
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3503
3793
|
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3504
3794
|
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
@@ -3692,9 +3982,11 @@ var TrackCloser = class {
|
|
|
3692
3982
|
const duration = t.lastSeen - t.firstSeen;
|
|
3693
3983
|
const closure = this.deps.residents.closure(t.trackId);
|
|
3694
3984
|
const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
|
|
3985
|
+
const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
|
|
3695
3986
|
const outcome = decideZeroMediaPolicy({
|
|
3696
3987
|
durationMs: duration,
|
|
3697
3988
|
hasMedia: ownedMedia.length > 0,
|
|
3989
|
+
hasBestMedia,
|
|
3698
3990
|
confirmed: closure?.confirmed ?? false,
|
|
3699
3991
|
hasRasterFallback: closure?.rasterFallback !== void 0
|
|
3700
3992
|
});
|
|
@@ -4036,6 +4328,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
4036
4328
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
4037
4329
|
source: ev.source ?? "pipeline",
|
|
4038
4330
|
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
4331
|
+
...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
|
|
4039
4332
|
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
|
|
4040
4333
|
};
|
|
4041
4334
|
}
|
|
@@ -4202,6 +4495,7 @@ function presentConditionIds(c) {
|
|
|
4202
4495
|
if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
|
|
4203
4496
|
if (c.zones !== void 0) ids.push("zones");
|
|
4204
4497
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
|
|
4498
|
+
if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
|
|
4205
4499
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
4206
4500
|
if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
|
|
4207
4501
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
@@ -4237,6 +4531,21 @@ function matchesOccupancy(occ, s) {
|
|
|
4237
4531
|
case "<=": return s.occupied === false && s.threshold === occ.count + 1;
|
|
4238
4532
|
}
|
|
4239
4533
|
}
|
|
4534
|
+
/**
|
|
4535
|
+
* The zone ids a `zones` / `zonesExclude` condition tests against: the
|
|
4536
|
+
* record's membership PLUS the zone it crossed, when it is a crossing.
|
|
4537
|
+
*
|
|
4538
|
+
* The union is what makes an EXIT addressable. Membership is computed from the
|
|
4539
|
+
* box's current geometry, so on the frame an exit is confirmed the subject is
|
|
4540
|
+
* by definition no longer in the zone — a rule scoped to "Uscio" would never
|
|
4541
|
+
* see the exit from Uscio. An ENTRY's zone is already in the membership, so
|
|
4542
|
+
* this changes nothing for every rule that exists today.
|
|
4543
|
+
*/
|
|
4544
|
+
function zonesVisitedBy(subject) {
|
|
4545
|
+
const visited = new Set(subject.zones);
|
|
4546
|
+
if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
|
|
4547
|
+
return visited;
|
|
4548
|
+
}
|
|
4240
4549
|
function toLowerSet(values) {
|
|
4241
4550
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
4242
4551
|
}
|
|
@@ -4322,14 +4631,20 @@ function evaluateRule(rule, subject) {
|
|
|
4322
4631
|
if (c.minDwellSeconds !== void 0) {
|
|
4323
4632
|
if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
|
|
4324
4633
|
}
|
|
4634
|
+
if (c.crossing !== "any") {
|
|
4635
|
+
const direction = subject.crossing?.direction;
|
|
4636
|
+
if (c.crossing === "exit") {
|
|
4637
|
+
if (direction !== "exit") return fail("crossing");
|
|
4638
|
+
} else if (direction === "exit") return fail("crossing");
|
|
4639
|
+
}
|
|
4325
4640
|
if (c.zones !== void 0) {
|
|
4326
|
-
const visited =
|
|
4641
|
+
const visited = zonesVisitedBy(subject);
|
|
4327
4642
|
if (c.zones.match === "all") {
|
|
4328
4643
|
for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
|
|
4329
4644
|
} else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
|
|
4330
4645
|
}
|
|
4331
4646
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
|
|
4332
|
-
const visited =
|
|
4647
|
+
const visited = zonesVisitedBy(subject);
|
|
4333
4648
|
if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
|
|
4334
4649
|
}
|
|
4335
4650
|
if (c.customZones !== void 0 && c.customZones.length > 0) {
|
|
@@ -4458,10 +4773,28 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
4458
4773
|
for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
|
|
4459
4774
|
return false;
|
|
4460
4775
|
}
|
|
4461
|
-
/**
|
|
4776
|
+
/**
|
|
4777
|
+
* Should this subject's class be part of the cooldown key?
|
|
4778
|
+
*
|
|
4779
|
+
* AUDIO is unconditional and predates the setting: a rule opted into several
|
|
4780
|
+
* audio classes (dog + scream) must not have a safety-relevant scream
|
|
4781
|
+
* swallowed by an unrelated bark's 60 s window.
|
|
4782
|
+
*
|
|
4783
|
+
* Every other subject gets the same treatment only when the rule ASKS for it
|
|
4784
|
+
* (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
|
|
4785
|
+
* Absent / `shared` reproduces the class-agnostic key byte for byte, because
|
|
4786
|
+
* changing how often an operator's existing rules fire is not a side effect
|
|
4787
|
+
* anyone asked for.
|
|
4788
|
+
*/
|
|
4789
|
+
function keysPerClass(rule, subject) {
|
|
4790
|
+
if (subject.kind === "audio-event") return true;
|
|
4791
|
+
return rule.throttle.granularity === "per-class";
|
|
4792
|
+
}
|
|
4793
|
+
/** Stable cooldown key per the rule's throttle scope + class granularity. */
|
|
4462
4794
|
function cooldownKey(rule, subject) {
|
|
4463
|
-
const
|
|
4464
|
-
|
|
4795
|
+
const first = subject.classNames[0];
|
|
4796
|
+
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
4797
|
+
return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:d:${subject.deviceId}${classKey}`;
|
|
4465
4798
|
}
|
|
4466
4799
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4467
4800
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -4629,7 +4962,9 @@ var NcDispatcher = class {
|
|
|
4629
4962
|
ruleId: entry.ruleId,
|
|
4630
4963
|
target: target.name,
|
|
4631
4964
|
kind: target.kind,
|
|
4632
|
-
recordKind: entry.recordKind
|
|
4965
|
+
recordKind: entry.recordKind,
|
|
4966
|
+
eventId: entry.recordId,
|
|
4967
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4633
4968
|
}
|
|
4634
4969
|
});
|
|
4635
4970
|
return { ok: true };
|
|
@@ -4667,9 +5002,10 @@ var NcDispatcher = class {
|
|
|
4667
5002
|
async buildNotification(entry) {
|
|
4668
5003
|
const subject = entry.payload.subject;
|
|
4669
5004
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4670
|
-
const
|
|
5005
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
5006
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4671
5007
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4672
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
5008
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4673
5009
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4674
5010
|
const params = pickParams(entry.payload.params);
|
|
4675
5011
|
return {
|
|
@@ -4868,15 +5204,37 @@ var NcDispatcher = class {
|
|
|
4868
5204
|
return null;
|
|
4869
5205
|
}
|
|
4870
5206
|
};
|
|
4871
|
-
|
|
5207
|
+
/**
|
|
5208
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
5209
|
+
*
|
|
5210
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
5211
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
5212
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
5213
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
5214
|
+
*/
|
|
5215
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
5216
|
+
if (zoneIds.length === 0) return [];
|
|
5217
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
5218
|
+
try {
|
|
5219
|
+
const zones = await getZoneNames(deviceId);
|
|
5220
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
5221
|
+
return zoneIds.map((id) => {
|
|
5222
|
+
const name = byId.get(id);
|
|
5223
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
5224
|
+
});
|
|
5225
|
+
} catch {
|
|
5226
|
+
return [...zoneIds];
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4872
5230
|
const subject = entry.payload.subject;
|
|
4873
5231
|
const occupancy = subject.occupancy;
|
|
4874
5232
|
return {
|
|
4875
5233
|
camera: deviceName,
|
|
4876
5234
|
class: subject.className,
|
|
4877
5235
|
label: subject.label ?? "",
|
|
4878
|
-
zones:
|
|
4879
|
-
zone: occupancy?.zone ??
|
|
5236
|
+
zones: zoneLabels.join(", "),
|
|
5237
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4880
5238
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4881
5239
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4882
5240
|
rule: entry.payload.ruleName,
|
|
@@ -4894,12 +5252,12 @@ function renderTemplate(template, vars) {
|
|
|
4894
5252
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4895
5253
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4896
5254
|
}
|
|
4897
|
-
function defaultBody(entry, deviceName) {
|
|
5255
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4898
5256
|
const subject = entry.payload.subject;
|
|
4899
5257
|
const occupancy = subject.occupancy;
|
|
4900
5258
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4901
5259
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4902
|
-
const zones =
|
|
5260
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4903
5261
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4904
5262
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4905
5263
|
}
|
|
@@ -6226,6 +6584,13 @@ var TimelapseStore = class {
|
|
|
6226
6584
|
};
|
|
6227
6585
|
//#endregion
|
|
6228
6586
|
//#region src/notification-center/index.ts
|
|
6587
|
+
/**
|
|
6588
|
+
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
6589
|
+
* a busy camera prints one line rather than one per event, short enough that a
|
|
6590
|
+
* rule which has stopped matching is visible within minutes rather than by
|
|
6591
|
+
* comparison with another system fifteen hours later.
|
|
6592
|
+
*/
|
|
6593
|
+
var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
|
|
6229
6594
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
6230
6595
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
6231
6596
|
var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
|
|
@@ -6336,6 +6701,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6336
6701
|
occupancyEnabled = false;
|
|
6337
6702
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
6338
6703
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
6704
|
+
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
6705
|
+
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
6706
|
+
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
6339
6707
|
/**
|
|
6340
6708
|
* Serialized evaluation chain. The persist hooks are fire-and-forget for
|
|
6341
6709
|
* the frame path, but two concurrent evaluations of the same device
|
|
@@ -6667,9 +7035,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6667
7035
|
return;
|
|
6668
7036
|
}
|
|
6669
7037
|
const now = this.now();
|
|
7038
|
+
let anyMatched = false;
|
|
7039
|
+
const rejections = [];
|
|
6670
7040
|
for (const rule of candidates) {
|
|
6671
7041
|
const evaluation = evaluateRule(rule, subject);
|
|
6672
7042
|
if (!evaluation.matched) {
|
|
7043
|
+
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
6673
7044
|
this.logger.debug("rule did not match", {
|
|
6674
7045
|
tags: { deviceId: subject.deviceId },
|
|
6675
7046
|
meta: {
|
|
@@ -6677,7 +7048,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6677
7048
|
rule: rule.name,
|
|
6678
7049
|
kind,
|
|
6679
7050
|
failed: evaluation.failedCondition,
|
|
6680
|
-
classes: subject.classNames
|
|
7051
|
+
classes: subject.classNames,
|
|
7052
|
+
eventId: subject.recordId,
|
|
7053
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6681
7054
|
}
|
|
6682
7055
|
});
|
|
6683
7056
|
continue;
|
|
@@ -6689,7 +7062,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6689
7062
|
meta: {
|
|
6690
7063
|
ruleId: rule.id,
|
|
6691
7064
|
rule: rule.name,
|
|
6692
|
-
key
|
|
7065
|
+
key,
|
|
7066
|
+
eventId: subject.recordId,
|
|
7067
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6693
7068
|
}
|
|
6694
7069
|
});
|
|
6695
7070
|
continue;
|
|
@@ -6700,12 +7075,42 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6700
7075
|
ruleId: rule.id,
|
|
6701
7076
|
rule: rule.name,
|
|
6702
7077
|
kind,
|
|
6703
|
-
targets: rule.targets.length
|
|
7078
|
+
targets: rule.targets.length,
|
|
7079
|
+
eventId: subject.recordId,
|
|
7080
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
7081
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
7082
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6704
7083
|
}
|
|
6705
7084
|
});
|
|
6706
7085
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6707
7086
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
7087
|
+
anyMatched = true;
|
|
6708
7088
|
}
|
|
7089
|
+
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
7090
|
+
}
|
|
7091
|
+
/** See the call site. Bounded to one line per device per window. */
|
|
7092
|
+
reportNoMatch(subject, kind, rejections, now) {
|
|
7093
|
+
const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
|
|
7094
|
+
const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
|
|
7095
|
+
if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
|
|
7096
|
+
this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
|
|
7097
|
+
return;
|
|
7098
|
+
}
|
|
7099
|
+
this.lastNoMatchReportAt.set(subject.deviceId, now);
|
|
7100
|
+
this.noMatchSuppressed.set(subject.deviceId, 0);
|
|
7101
|
+
this.logger.info("event matched NO rule", {
|
|
7102
|
+
tags: { deviceId: subject.deviceId },
|
|
7103
|
+
meta: {
|
|
7104
|
+
kind,
|
|
7105
|
+
rejectedBy: rejections,
|
|
7106
|
+
classes: subject.classNames,
|
|
7107
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
7108
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : { zones: [] },
|
|
7109
|
+
eventId: subject.recordId,
|
|
7110
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
7111
|
+
...suppressed > 0 ? { alsoSuppressedSinceLastReport: suppressed } : {}
|
|
7112
|
+
}
|
|
7113
|
+
});
|
|
6709
7114
|
}
|
|
6710
7115
|
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6711
7116
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
@@ -7800,6 +8205,63 @@ function classifyTrackAppearance(input) {
|
|
|
7800
8205
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
7801
8206
|
}
|
|
7802
8207
|
//#endregion
|
|
8208
|
+
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
8209
|
+
/**
|
|
8210
|
+
* Memory of births the confirmation gate rejected, per device.
|
|
8211
|
+
*
|
|
8212
|
+
* The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
|
|
8213
|
+
* position count is what distinguishes a true birth from a tracker
|
|
8214
|
+
* resurrection. So a rejected birth has already been written by the time the
|
|
8215
|
+
* verdict exists. It correctly gets no `start`, no media and no notification —
|
|
8216
|
+
* but the record stayed, and surfaced in `listRecentTracks`. On 2026-07-31 six
|
|
8217
|
+
* full-frame phantoms on device 615 in nine minutes were every one of them
|
|
8218
|
+
* suppressed AND listed.
|
|
8219
|
+
*
|
|
8220
|
+
* Retracting once is not enough. The tracker keeps carrying a rejected id for
|
|
8221
|
+
* as long as the phantom persists — 27 seconds in the worst measured case — and
|
|
8222
|
+
* every later frame would write it straight back. So the rejection is
|
|
8223
|
+
* remembered until the id stops being tracked, and only then forgotten.
|
|
8224
|
+
*
|
|
8225
|
+
* Kept as its own module because the state is the part with real risk: forget
|
|
8226
|
+
* too early and the phantom returns, never forget and a camera producing
|
|
8227
|
+
* phantoms continuously grows this set for the process's lifetime.
|
|
8228
|
+
*/
|
|
8229
|
+
var SuppressedBirthRegistry = class {
|
|
8230
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
8231
|
+
/** Record that this birth was rejected; later frames must not re-upsert it. */
|
|
8232
|
+
reject(deviceKey, trackId) {
|
|
8233
|
+
let ids = this.byDevice.get(deviceKey);
|
|
8234
|
+
if (!ids) {
|
|
8235
|
+
ids = /* @__PURE__ */ new Set();
|
|
8236
|
+
this.byDevice.set(deviceKey, ids);
|
|
8237
|
+
}
|
|
8238
|
+
ids.add(trackId);
|
|
8239
|
+
}
|
|
8240
|
+
isRejected(deviceKey, trackId) {
|
|
8241
|
+
return this.byDevice.get(deviceKey)?.has(trackId) === true;
|
|
8242
|
+
}
|
|
8243
|
+
/**
|
|
8244
|
+
* Forget every rejected id the tracker is no longer carrying.
|
|
8245
|
+
*
|
|
8246
|
+
* Called once per frame with the ids present THIS frame. An id absent from
|
|
8247
|
+
* that set can never be upserted again, so holding it serves nothing.
|
|
8248
|
+
*/
|
|
8249
|
+
retain(deviceKey, currentTrackIds) {
|
|
8250
|
+
const ids = this.byDevice.get(deviceKey);
|
|
8251
|
+
if (!ids) return;
|
|
8252
|
+
for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
|
|
8253
|
+
if (ids.size === 0) this.byDevice.delete(deviceKey);
|
|
8254
|
+
}
|
|
8255
|
+
/** Rejected ids currently held for a device — diagnostics and tests. */
|
|
8256
|
+
size(deviceKey) {
|
|
8257
|
+
return this.byDevice.get(deviceKey)?.size ?? 0;
|
|
8258
|
+
}
|
|
8259
|
+
/** Drop a device's memory wholesale (device removed / pipeline reset). */
|
|
8260
|
+
clearDevice(deviceKey) {
|
|
8261
|
+
this.byDevice.delete(deviceKey);
|
|
8262
|
+
}
|
|
8263
|
+
};
|
|
8264
|
+
//#endregion
|
|
7803
8265
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
7804
8266
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
7805
8267
|
const scored = [];
|
|
@@ -8304,9 +8766,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
|
8304
8766
|
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
8305
8767
|
* new object.
|
|
8306
8768
|
*
|
|
8307
|
-
* NOTE: clamping
|
|
8308
|
-
*
|
|
8309
|
-
*
|
|
8769
|
+
* NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
|
|
8770
|
+
* bound slides inward and keeps the extent that was asked for, on either side.
|
|
8771
|
+
* It shrinks only when the padded window is larger than the frame itself
|
|
8310
8772
|
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
8311
8773
|
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
8312
8774
|
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
@@ -8316,13 +8778,13 @@ function padBbox(bbox, padding) {
|
|
|
8316
8778
|
const rawY = bbox.y - padding * bbox.h;
|
|
8317
8779
|
const rawW = bbox.w * (1 + 2 * padding);
|
|
8318
8780
|
const rawH = bbox.h * (1 + 2 * padding);
|
|
8319
|
-
const
|
|
8320
|
-
const
|
|
8781
|
+
const w = Math.min(rawW, 1);
|
|
8782
|
+
const h = Math.min(rawH, 1);
|
|
8321
8783
|
return {
|
|
8322
|
-
x,
|
|
8323
|
-
y,
|
|
8324
|
-
w
|
|
8325
|
-
h
|
|
8784
|
+
x: Math.min(Math.max(0, rawX), 1 - w),
|
|
8785
|
+
y: Math.min(Math.max(0, rawY), 1 - h),
|
|
8786
|
+
w,
|
|
8787
|
+
h
|
|
8326
8788
|
};
|
|
8327
8789
|
}
|
|
8328
8790
|
//#endregion
|
|
@@ -12017,7 +12479,11 @@ function squareSubjectCropRegion(bbox, frame) {
|
|
|
12017
12479
|
* cases). `C` becomes the middle square of the output.
|
|
12018
12480
|
* 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
|
|
12019
12481
|
* 3. anchor: place the canvas so `C` is its horizontal middle →
|
|
12020
|
-
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0)
|
|
12482
|
+
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
|
|
12483
|
+
* then CLAMP the window fully inside the frame whenever it fits. The
|
|
12484
|
+
* subject therefore drifts off-centre near a frame edge and the output
|
|
12485
|
+
* carries no padding at all. Centring is a preference; containing the
|
|
12486
|
+
* subject is the contract, and clamping cannot break it.
|
|
12021
12487
|
* 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
|
|
12022
12488
|
* `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
|
|
12023
12489
|
* the vertical extent is always in-frame). Its canvas offset is
|
|
@@ -12029,13 +12495,8 @@ function wideCentralSquareLayout(bbox, frame) {
|
|
|
12029
12495
|
const canvasW = Math.round(c * 16 / 9);
|
|
12030
12496
|
const centralX0 = Math.round((canvasW - c) / 2);
|
|
12031
12497
|
let frameOriginX = central.x - (canvasW - c) / 2;
|
|
12032
|
-
if (canvasW <= frame.W)
|
|
12033
|
-
|
|
12034
|
-
const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
|
|
12035
|
-
if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
|
|
12036
|
-
const overRight = frameOriginX + canvasW - frame.W;
|
|
12037
|
-
if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
|
|
12038
|
-
} else frameOriginX = (frame.W - canvasW) / 2;
|
|
12498
|
+
if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
|
|
12499
|
+
else frameOriginX = (frame.W - canvasW) / 2;
|
|
12039
12500
|
const fxa = Math.max(0, frameOriginX);
|
|
12040
12501
|
const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
|
|
12041
12502
|
const slabOffsetX = fxa - frameOriginX;
|
|
@@ -12084,10 +12545,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
|
|
|
12084
12545
|
/**
|
|
12085
12546
|
* Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
|
|
12086
12547
|
* in-frame slab fetched for `layout`. The slab is placed at its computed offset
|
|
12087
|
-
* on a 16:9 canvas; any lateral part of the window outside the frame
|
|
12088
|
-
*
|
|
12089
|
-
*
|
|
12090
|
-
*
|
|
12548
|
+
* on a 16:9 canvas; any lateral part of the window outside the frame is filled
|
|
12549
|
+
* with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
|
|
12550
|
+
* (operator triage 2026-07-22).
|
|
12551
|
+
*
|
|
12552
|
+
* **That fill is now nearly unreachable, and deliberately so.** The geometry
|
|
12553
|
+
* clamps the window fully inside the frame whenever it fits, so a subject
|
|
12554
|
+
* against a frame edge yields real pixels off-centre rather than ambience
|
|
12555
|
+
* (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
|
|
12556
|
+
* still pads — there is no more scene to slide into — which is why this code
|
|
12557
|
+
* stays. If you are looking at a blurred band in a best shot, the window was
|
|
12558
|
+
* wider than the frame; do not go looking for a sliding bug.
|
|
12091
12559
|
* When the whole window is in-frame (`slab` already spans the full canvas) the
|
|
12092
12560
|
* slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
|
|
12093
12561
|
* the canvas is at the slab's native scale; only the out-of-frame ambience fill
|
|
@@ -13210,6 +13678,13 @@ var ZoneAnalyticsProvider = class {
|
|
|
13210
13678
|
zones: snapshot.zones.length
|
|
13211
13679
|
}
|
|
13212
13680
|
});
|
|
13681
|
+
this.ctx.emitOccupancyChanged?.({
|
|
13682
|
+
deviceId: input.deviceId,
|
|
13683
|
+
timestamp: input.timestamp,
|
|
13684
|
+
totalObjects: total,
|
|
13685
|
+
byClass: snapshot.frame.byClass,
|
|
13686
|
+
zones: snapshot.zones.length
|
|
13687
|
+
});
|
|
13213
13688
|
}
|
|
13214
13689
|
this.snapshots.set(input.deviceId, snapshot);
|
|
13215
13690
|
this.appendHistory(input.deviceId, snapshot);
|
|
@@ -13847,6 +14322,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
13847
14322
|
};
|
|
13848
14323
|
}
|
|
13849
14324
|
var TrackingSettingsSchema = object({
|
|
14325
|
+
/**
|
|
14326
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
14327
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
14328
|
+
*
|
|
14329
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
14330
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
14331
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
14332
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
14333
|
+
* inside it.
|
|
14334
|
+
*
|
|
14335
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
14336
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
14337
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
14338
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
14339
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
14340
|
+
*/
|
|
14341
|
+
zoneMembershipMinOverlap: number().min(0).max(1).default(0),
|
|
13850
14342
|
/** IoU required to match a (predicted) track to a detection. */
|
|
13851
14343
|
iouThreshold: number().min(0).max(1).default(.3),
|
|
13852
14344
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -13983,6 +14475,7 @@ function resolveTrackingSettings(raw) {
|
|
|
13983
14475
|
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;
|
|
13984
14476
|
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;
|
|
13985
14477
|
return {
|
|
14478
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
13986
14479
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
13987
14480
|
maxMissedMs,
|
|
13988
14481
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -14037,7 +14530,11 @@ function resolveTrackingSettings(raw) {
|
|
|
14037
14530
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
14038
14531
|
* misclassified static object).
|
|
14039
14532
|
*
|
|
14040
|
-
*
|
|
14533
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14534
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14535
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14536
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14537
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
14041
14538
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
14042
14539
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
14043
14540
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -14133,10 +14630,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
14133
14630
|
if (track === "other" || det === "other") return true;
|
|
14134
14631
|
return track === det;
|
|
14135
14632
|
}
|
|
14136
|
-
var failOpen = (
|
|
14137
|
-
trackId,
|
|
14633
|
+
var failOpen = (candidate, reason) => ({
|
|
14634
|
+
trackId: candidate.trackId,
|
|
14138
14635
|
confirmed: true,
|
|
14139
|
-
reason
|
|
14636
|
+
reason,
|
|
14637
|
+
className: candidate.className
|
|
14140
14638
|
});
|
|
14141
14639
|
function withTimeout(promise, timeoutMs) {
|
|
14142
14640
|
return new Promise((resolve, reject) => {
|
|
@@ -14152,23 +14650,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
14152
14650
|
}
|
|
14153
14651
|
async function runConfirmation(candidate, config, deps) {
|
|
14154
14652
|
const crop = await deps.fetchCrop(candidate);
|
|
14155
|
-
if (!crop) return failOpen(candidate
|
|
14653
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
14156
14654
|
const detections = await deps.redetect(crop);
|
|
14157
|
-
if (detections === null) return failOpen(candidate
|
|
14158
|
-
|
|
14655
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14656
|
+
let best;
|
|
14657
|
+
let bestIncompatible;
|
|
14658
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14659
|
+
if (!best || d.score > best.score) best = d;
|
|
14660
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14661
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
14159
14662
|
return {
|
|
14160
14663
|
trackId: candidate.trackId,
|
|
14161
14664
|
confirmed,
|
|
14162
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14665
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14666
|
+
className: candidate.className,
|
|
14667
|
+
...best ? { bestScore: best.score } : {},
|
|
14668
|
+
...bestIncompatible ? {
|
|
14669
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14670
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14671
|
+
} : {}
|
|
14163
14672
|
};
|
|
14164
14673
|
}
|
|
14165
14674
|
async function confirmOne(candidate, config, deps) {
|
|
14166
14675
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
14167
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14676
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
14168
14677
|
try {
|
|
14169
14678
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
14170
14679
|
} catch {
|
|
14171
|
-
return failOpen(candidate
|
|
14680
|
+
return failOpen(candidate, "timeout");
|
|
14172
14681
|
}
|
|
14173
14682
|
}
|
|
14174
14683
|
/**
|
|
@@ -14351,8 +14860,23 @@ function resolveMediaSettings(raw) {
|
|
|
14351
14860
|
* §3.3 draft said 45s).
|
|
14352
14861
|
*/
|
|
14353
14862
|
var PackageDropSettingsSchema = object({
|
|
14354
|
-
/**
|
|
14355
|
-
|
|
14863
|
+
/**
|
|
14864
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14865
|
+
* package detection**, not a second switch.
|
|
14866
|
+
*
|
|
14867
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14868
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14869
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14870
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14871
|
+
* silently by a boolean none of them mentions.
|
|
14872
|
+
*
|
|
14873
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14874
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14875
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14876
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14877
|
+
* rule.
|
|
14878
|
+
*/
|
|
14879
|
+
packageDropEnabled: boolean().default(true),
|
|
14356
14880
|
/**
|
|
14357
14881
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
14358
14882
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -14893,6 +15417,15 @@ function buildDetectionSettingsSections() {
|
|
|
14893
15417
|
step: .05,
|
|
14894
15418
|
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
14895
15419
|
},
|
|
15420
|
+
{
|
|
15421
|
+
type: "number",
|
|
15422
|
+
key: "rescueCentroidFactor",
|
|
15423
|
+
label: "Rescue centroid distance",
|
|
15424
|
+
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.",
|
|
15425
|
+
min: 0,
|
|
15426
|
+
step: .05,
|
|
15427
|
+
default: TRACKING_DEFAULTS.rescueCentroidFactor
|
|
15428
|
+
},
|
|
14896
15429
|
{
|
|
14897
15430
|
type: "number",
|
|
14898
15431
|
key: "stationarySpeedPx",
|
|
@@ -15174,6 +15707,16 @@ function buildDetectionSettingsSections() {
|
|
|
15174
15707
|
label: "Person/animal group matching",
|
|
15175
15708
|
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.",
|
|
15176
15709
|
default: TRACKING_DEFAULTS.classGroupAssoc
|
|
15710
|
+
},
|
|
15711
|
+
{
|
|
15712
|
+
type: "number",
|
|
15713
|
+
key: "zoneMembershipMinOverlap",
|
|
15714
|
+
label: "Zone membership minimum overlap",
|
|
15715
|
+
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.",
|
|
15716
|
+
min: 0,
|
|
15717
|
+
max: 1,
|
|
15718
|
+
step: .05,
|
|
15719
|
+
default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
|
|
15177
15720
|
}
|
|
15178
15721
|
]
|
|
15179
15722
|
},
|
|
@@ -19571,7 +20114,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
19571
20114
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
19572
20115
|
return Array.from(view);
|
|
19573
20116
|
}
|
|
20117
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
20118
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
20119
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
20120
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
20121
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
20122
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
20123
|
+
* to the pixel still land in one cell. */
|
|
20124
|
+
var PHANTOM_CELL_PX = 32;
|
|
20125
|
+
/** How long a cell remembers its closes. */
|
|
20126
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
19574
20127
|
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
20128
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
20129
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
20130
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
20131
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
19575
20132
|
processors = /* @__PURE__ */ new Map();
|
|
19576
20133
|
trackStore = null;
|
|
19577
20134
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -19729,6 +20286,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19729
20286
|
* dataPlane facility in the current environment). */
|
|
19730
20287
|
eventMediaBaseUrl = null;
|
|
19731
20288
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
20289
|
+
/** See `pipeline/suppressed-births.ts` — rejected births must not be
|
|
20290
|
+
* re-upserted, and must be forgotten when the tracker drops the id. */
|
|
20291
|
+
suppressedBirths = new SuppressedBirthRegistry();
|
|
19732
20292
|
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
19733
20293
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
19734
20294
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
@@ -19881,7 +20441,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19881
20441
|
});
|
|
19882
20442
|
},
|
|
19883
20443
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
19884
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20444
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20445
|
+
this.noteShortMotionlessTrack(track);
|
|
20446
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20447
|
+
},
|
|
19885
20448
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
19886
20449
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
19887
20450
|
...input,
|
|
@@ -20309,6 +20872,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20309
20872
|
const zoneAnalytics = new ZoneAnalyticsProvider({
|
|
20310
20873
|
logger: logger.child("ZoneAnalytics"),
|
|
20311
20874
|
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
20875
|
+
emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
|
|
20876
|
+
id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
|
|
20877
|
+
timestamp: new Date(payload.timestamp),
|
|
20878
|
+
source: {
|
|
20879
|
+
type: "addon",
|
|
20880
|
+
id: "pipeline-analytics",
|
|
20881
|
+
addonId: "pipeline-analytics"
|
|
20882
|
+
},
|
|
20883
|
+
category: EventCategory.ZoneAnalyticsOccupancyChanged,
|
|
20884
|
+
data: {
|
|
20885
|
+
deviceId: payload.deviceId,
|
|
20886
|
+
totalObjects: payload.totalObjects,
|
|
20887
|
+
byClass: payload.byClass,
|
|
20888
|
+
zones: payload.zones
|
|
20889
|
+
}
|
|
20890
|
+
}),
|
|
20312
20891
|
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
20313
20892
|
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
20314
20893
|
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
@@ -20368,6 +20947,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20368
20947
|
};
|
|
20369
20948
|
},
|
|
20370
20949
|
dispatcher: {
|
|
20950
|
+
getZoneNames: async (deviceId) => {
|
|
20951
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20952
|
+
id: z.id,
|
|
20953
|
+
name: z.name
|
|
20954
|
+
}));
|
|
20955
|
+
},
|
|
20371
20956
|
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20372
20957
|
const zones = await api.zones.listZones.query({ deviceId });
|
|
20373
20958
|
const wanted = new Set(zoneIds);
|
|
@@ -20745,10 +21330,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20745
21330
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
20746
21331
|
processor.setZones(liveZones);
|
|
20747
21332
|
processor.setDetectionRules(liveRules);
|
|
21333
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
20748
21334
|
const result = processor.process({
|
|
20749
21335
|
timestamp: frame.timestamp,
|
|
20750
21336
|
frame
|
|
20751
21337
|
});
|
|
21338
|
+
for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
|
|
21339
|
+
tags: { deviceId },
|
|
21340
|
+
meta: {
|
|
21341
|
+
vehicleClass: r.vehicleClass,
|
|
21342
|
+
personScore: r.personScore,
|
|
21343
|
+
vehicleScore: r.vehicleScore,
|
|
21344
|
+
overlap: r.overlap
|
|
21345
|
+
}
|
|
21346
|
+
});
|
|
20752
21347
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
20753
21348
|
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
20754
21349
|
deviceId,
|
|
@@ -20779,6 +21374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20779
21374
|
const positionsCountById = /* @__PURE__ */ new Map();
|
|
20780
21375
|
for (const t of result.tracked) {
|
|
20781
21376
|
currentTrackIds.add(t.trackId);
|
|
21377
|
+
if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
|
|
20782
21378
|
const center = {
|
|
20783
21379
|
x: t.bbox.x + t.bbox.w / 2,
|
|
20784
21380
|
y: t.bbox.y + t.bbox.h / 2
|
|
@@ -20845,7 +21441,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20845
21441
|
frameHeight: result.frameHeight
|
|
20846
21442
|
});
|
|
20847
21443
|
for (const { id, t } of bornCandidates) {
|
|
20848
|
-
if (!confirmedBirths.has(id))
|
|
21444
|
+
if (!confirmedBirths.has(id)) {
|
|
21445
|
+
this.suppressedBirths.reject(key, id);
|
|
21446
|
+
this.trackStore?.dropActive(id);
|
|
21447
|
+
log.info("birth suppressed — track record retracted", { meta: {
|
|
21448
|
+
trackId: id,
|
|
21449
|
+
className: t.className,
|
|
21450
|
+
source
|
|
21451
|
+
} });
|
|
21452
|
+
continue;
|
|
21453
|
+
}
|
|
20849
21454
|
newTrackCount += 1;
|
|
20850
21455
|
log.info("track started", { meta: {
|
|
20851
21456
|
trackId: id,
|
|
@@ -20906,6 +21511,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20906
21511
|
source
|
|
20907
21512
|
} });
|
|
20908
21513
|
}
|
|
21514
|
+
this.suppressedBirths.retain(key, currentTrackIds);
|
|
20909
21515
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
20910
21516
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
20911
21517
|
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
@@ -20975,7 +21581,46 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20975
21581
|
} });
|
|
20976
21582
|
}
|
|
20977
21583
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
20978
|
-
if (this.notificationCenter !== null)
|
|
21584
|
+
if (this.notificationCenter !== null) {
|
|
21585
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21586
|
+
const rejections = processor.getLastZoneRejections();
|
|
21587
|
+
for (const e of result.objectEvents) {
|
|
21588
|
+
if (e.zones && e.zones.length > 0) {
|
|
21589
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21590
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21591
|
+
tags: { deviceId },
|
|
21592
|
+
meta: {
|
|
21593
|
+
eventId: e.id,
|
|
21594
|
+
trackId: e.trackId,
|
|
21595
|
+
className: e.className,
|
|
21596
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21597
|
+
zones: (m ?? []).map((z) => ({
|
|
21598
|
+
id: z.zoneId,
|
|
21599
|
+
name: z.zoneName,
|
|
21600
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21601
|
+
}))
|
|
21602
|
+
}
|
|
21603
|
+
});
|
|
21604
|
+
}
|
|
21605
|
+
const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
|
|
21606
|
+
if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
|
|
21607
|
+
tags: { deviceId },
|
|
21608
|
+
meta: {
|
|
21609
|
+
eventId: e.id,
|
|
21610
|
+
trackId: e.trackId,
|
|
21611
|
+
className: e.className,
|
|
21612
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21613
|
+
stamped: e.zones?.length ?? 0,
|
|
21614
|
+
zones: rejected.map((z) => ({
|
|
21615
|
+
id: z.zoneId,
|
|
21616
|
+
name: z.zoneName,
|
|
21617
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21618
|
+
}))
|
|
21619
|
+
}
|
|
21620
|
+
});
|
|
21621
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21622
|
+
}
|
|
21623
|
+
}
|
|
20979
21624
|
const objectEmbeddingBests = [];
|
|
20980
21625
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
20981
21626
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -21327,19 +21972,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21327
21972
|
}),
|
|
21328
21973
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
21329
21974
|
onDecision: (decision) => {
|
|
21975
|
+
const meta = {
|
|
21976
|
+
trackId: decision.trackId,
|
|
21977
|
+
reason: decision.reason,
|
|
21978
|
+
className: decision.className,
|
|
21979
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21980
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21981
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21982
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21983
|
+
} : {},
|
|
21984
|
+
minConfidence: config.minConfidence
|
|
21985
|
+
};
|
|
21330
21986
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
21331
21987
|
tags: { deviceId },
|
|
21332
|
-
meta
|
|
21333
|
-
trackId: decision.trackId,
|
|
21334
|
-
reason: decision.reason
|
|
21335
|
-
}
|
|
21988
|
+
meta
|
|
21336
21989
|
});
|
|
21337
21990
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
21338
21991
|
tags: { deviceId },
|
|
21339
|
-
meta
|
|
21340
|
-
|
|
21341
|
-
|
|
21342
|
-
}
|
|
21992
|
+
meta
|
|
21993
|
+
});
|
|
21994
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
21995
|
+
tags: { deviceId },
|
|
21996
|
+
meta
|
|
21343
21997
|
});
|
|
21344
21998
|
}
|
|
21345
21999
|
});
|
|
@@ -21356,6 +22010,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21356
22010
|
async resolveDeviceStationarySettings(deviceId) {
|
|
21357
22011
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
21358
22012
|
}
|
|
22013
|
+
/**
|
|
22014
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
22015
|
+
*
|
|
22016
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
22017
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
22018
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
22019
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
22020
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
22021
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
22022
|
+
* to notice it.
|
|
22023
|
+
*
|
|
22024
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
22025
|
+
* standing still at a door is not declared scenery.
|
|
22026
|
+
*
|
|
22027
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
22028
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
22029
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
22030
|
+
*/
|
|
22031
|
+
noteShortMotionlessTrack(track) {
|
|
22032
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
22033
|
+
const moved = track.totalDistance ?? 0;
|
|
22034
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
22035
|
+
const first = track.positions?.[0];
|
|
22036
|
+
if (!first) return;
|
|
22037
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
22038
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
22039
|
+
const now = Date.now();
|
|
22040
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
22041
|
+
seen.push(now);
|
|
22042
|
+
this.shortMotionlessCells.set(key, seen);
|
|
22043
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
22044
|
+
tags: { deviceId: track.deviceId },
|
|
22045
|
+
meta: {
|
|
22046
|
+
trackId: track.trackId,
|
|
22047
|
+
className: track.className,
|
|
22048
|
+
lifeMs,
|
|
22049
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
22050
|
+
cell,
|
|
22051
|
+
repeatsInWindow: seen.length
|
|
22052
|
+
}
|
|
22053
|
+
});
|
|
22054
|
+
}
|
|
21359
22055
|
stationarySettingsFromCache(deviceId) {
|
|
21360
22056
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
21361
22057
|
}
|
|
@@ -21365,9 +22061,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21365
22061
|
/**
|
|
21366
22062
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
21367
22063
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
21368
|
-
* when the cached slice is empty, forces one refresh.
|
|
21369
|
-
*
|
|
21370
|
-
*
|
|
22064
|
+
* when the cached slice is empty, forces one refresh.
|
|
22065
|
+
*
|
|
22066
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
22067
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
22068
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
22069
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
22070
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
22071
|
+
* operator has drawn no package zone rule, nothing more.
|
|
21371
22072
|
*/
|
|
21372
22073
|
async resolveDevicePackageRules(deviceId) {
|
|
21373
22074
|
const proxy = await this.ensureProxy(deviceId);
|