@camstack/addon-post-analysis 1.2.19 → 1.2.21
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-BpP-KZvi.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-CkDMrlOe.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +781 -93
- package/dist/pipeline-analytics/index.mjs +780 -92
- 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 */
|
|
@@ -2313,17 +2401,31 @@ var ZoneEngine = class {
|
|
|
2313
2401
|
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2314
2402
|
*/
|
|
2315
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) {
|
|
2316
2412
|
const memberships = [];
|
|
2413
|
+
const belowBar = [];
|
|
2317
2414
|
for (const zone of zones) {
|
|
2318
2415
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2319
2416
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2320
|
-
|
|
2417
|
+
const entry = {
|
|
2321
2418
|
zoneId: zone.id,
|
|
2322
2419
|
zoneName: zone.name,
|
|
2323
2420
|
overlap
|
|
2324
|
-
}
|
|
2421
|
+
};
|
|
2422
|
+
if (overlap > minOverlap) memberships.push(entry);
|
|
2423
|
+
else if (overlap > 0) belowBar.push(entry);
|
|
2325
2424
|
}
|
|
2326
|
-
return
|
|
2425
|
+
return {
|
|
2426
|
+
memberships,
|
|
2427
|
+
belowBar
|
|
2428
|
+
};
|
|
2327
2429
|
}
|
|
2328
2430
|
/**
|
|
2329
2431
|
* Filter detections through a zone-rule set. `zones` provides the
|
|
@@ -2384,6 +2486,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
|
|
|
2384
2486
|
return false;
|
|
2385
2487
|
}
|
|
2386
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
|
|
2387
2549
|
//#region src/pipeline-analytics/pipeline/frame-processor.ts
|
|
2388
2550
|
/** Mapping from StateAnalyzer's `ObjectState.state` values to the
|
|
2389
2551
|
* canonical TrackState enum used on tracks + events. */
|
|
@@ -2421,6 +2583,17 @@ var FrameProcessor = class {
|
|
|
2421
2583
|
zoneMembershipMinOverlap;
|
|
2422
2584
|
/** See {@link getLastZoneOverlaps}. */
|
|
2423
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 = [];
|
|
2424
2597
|
zoneEngine = new ZoneEngine();
|
|
2425
2598
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2426
2599
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2435,6 +2608,7 @@ var FrameProcessor = class {
|
|
|
2435
2608
|
this.detectionRules = [];
|
|
2436
2609
|
this.zoneMembershipMinOverlap = 0;
|
|
2437
2610
|
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2611
|
+
this.lastZoneRejections = /* @__PURE__ */ new Map();
|
|
2438
2612
|
}
|
|
2439
2613
|
setZones(zones) {
|
|
2440
2614
|
this.zones = zones;
|
|
@@ -2452,6 +2626,12 @@ var FrameProcessor = class {
|
|
|
2452
2626
|
setZoneMembershipMinOverlap(minOverlap) {
|
|
2453
2627
|
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2454
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
|
+
}
|
|
2455
2635
|
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2456
2636
|
* recent frame. The engine computes these and the pipeline previously
|
|
2457
2637
|
* discarded everything but the ids — which is why no amount of production
|
|
@@ -2459,6 +2639,13 @@ var FrameProcessor = class {
|
|
|
2459
2639
|
getLastZoneOverlaps() {
|
|
2460
2640
|
return this.lastZoneOverlaps;
|
|
2461
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
|
+
}
|
|
2462
2649
|
setDetectionRules(rules) {
|
|
2463
2650
|
this.detectionRules = rules;
|
|
2464
2651
|
}
|
|
@@ -2551,7 +2738,24 @@ var FrameProcessor = class {
|
|
|
2551
2738
|
});
|
|
2552
2739
|
}
|
|
2553
2740
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
2554
|
-
|
|
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 = [];
|
|
2555
2759
|
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
2556
2760
|
detections: filteredDetections,
|
|
2557
2761
|
frameWidth,
|
|
@@ -2567,16 +2771,47 @@ var FrameProcessor = class {
|
|
|
2567
2771
|
frameHeight
|
|
2568
2772
|
});
|
|
2569
2773
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2570
|
-
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2571
2774
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2572
2775
|
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2776
|
+
const rejectedByTrack = /* @__PURE__ */ new Map();
|
|
2573
2777
|
for (const td of trackedDetections) {
|
|
2574
2778
|
const m = maskByBbox.get(td.bbox);
|
|
2575
|
-
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);
|
|
2576
2780
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2577
2781
|
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2782
|
+
if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
|
|
2578
2783
|
}
|
|
2579
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);
|
|
2814
|
+
}
|
|
2580
2815
|
const tracked = trackedDetections.map((td) => {
|
|
2581
2816
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2582
2817
|
const label = resolveDetectionLabel({
|
|
@@ -2614,10 +2849,22 @@ var FrameProcessor = class {
|
|
|
2614
2849
|
} : {}
|
|
2615
2850
|
};
|
|
2616
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
|
+
};
|
|
2617
2863
|
const toObjectEvent = (e, forcedState) => {
|
|
2618
2864
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
2619
2865
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
2620
2866
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
2867
|
+
const crossing = crossingOf(e);
|
|
2621
2868
|
const label = td ? resolveDetectionLabel({
|
|
2622
2869
|
className: td.class,
|
|
2623
2870
|
originalClass: td.originalClass,
|
|
@@ -2641,6 +2888,7 @@ var FrameProcessor = class {
|
|
|
2641
2888
|
},
|
|
2642
2889
|
zones,
|
|
2643
2890
|
state,
|
|
2891
|
+
...crossing !== void 0 ? { zoneCrossing: crossing } : {},
|
|
2644
2892
|
...label ? { label } : {},
|
|
2645
2893
|
frameWidth,
|
|
2646
2894
|
frameHeight
|
|
@@ -3539,7 +3787,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
|
|
|
3539
3787
|
* for the full contract.
|
|
3540
3788
|
*/
|
|
3541
3789
|
function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
|
|
3542
|
-
if (input.
|
|
3790
|
+
if (input.hasBestMedia) return "persist";
|
|
3791
|
+
if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
3543
3792
|
if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
|
|
3544
3793
|
if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
|
|
3545
3794
|
return input.hasRasterFallback ? "raster-fallback" : "persist";
|
|
@@ -3733,9 +3982,11 @@ var TrackCloser = class {
|
|
|
3733
3982
|
const duration = t.lastSeen - t.firstSeen;
|
|
3734
3983
|
const closure = this.deps.residents.closure(t.trackId);
|
|
3735
3984
|
const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
|
|
3985
|
+
const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
|
|
3736
3986
|
const outcome = decideZeroMediaPolicy({
|
|
3737
3987
|
durationMs: duration,
|
|
3738
3988
|
hasMedia: ownedMedia.length > 0,
|
|
3989
|
+
hasBestMedia,
|
|
3739
3990
|
confirmed: closure?.confirmed ?? false,
|
|
3740
3991
|
hasRasterFallback: closure?.rasterFallback !== void 0
|
|
3741
3992
|
});
|
|
@@ -4077,6 +4328,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
4077
4328
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
4078
4329
|
source: ev.source ?? "pipeline",
|
|
4079
4330
|
...ev.importance !== void 0 ? { importance: ev.importance } : {},
|
|
4331
|
+
...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
|
|
4080
4332
|
...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
|
|
4081
4333
|
};
|
|
4082
4334
|
}
|
|
@@ -4243,6 +4495,7 @@ function presentConditionIds(c) {
|
|
|
4243
4495
|
if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
|
|
4244
4496
|
if (c.zones !== void 0) ids.push("zones");
|
|
4245
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");
|
|
4246
4499
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
4247
4500
|
if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
|
|
4248
4501
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
@@ -4278,6 +4531,21 @@ function matchesOccupancy(occ, s) {
|
|
|
4278
4531
|
case "<=": return s.occupied === false && s.threshold === occ.count + 1;
|
|
4279
4532
|
}
|
|
4280
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
|
+
}
|
|
4281
4549
|
function toLowerSet(values) {
|
|
4282
4550
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
4283
4551
|
}
|
|
@@ -4363,14 +4631,20 @@ function evaluateRule(rule, subject) {
|
|
|
4363
4631
|
if (c.minDwellSeconds !== void 0) {
|
|
4364
4632
|
if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
|
|
4365
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
|
+
}
|
|
4366
4640
|
if (c.zones !== void 0) {
|
|
4367
|
-
const visited =
|
|
4641
|
+
const visited = zonesVisitedBy(subject);
|
|
4368
4642
|
if (c.zones.match === "all") {
|
|
4369
4643
|
for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
|
|
4370
4644
|
} else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
|
|
4371
4645
|
}
|
|
4372
4646
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
|
|
4373
|
-
const visited =
|
|
4647
|
+
const visited = zonesVisitedBy(subject);
|
|
4374
4648
|
if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
|
|
4375
4649
|
}
|
|
4376
4650
|
if (c.customZones !== void 0 && c.customZones.length > 0) {
|
|
@@ -4499,10 +4773,28 @@ function matchesPlate(label, values, maxDistance) {
|
|
|
4499
4773
|
for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
|
|
4500
4774
|
return false;
|
|
4501
4775
|
}
|
|
4502
|
-
/**
|
|
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. */
|
|
4503
4794
|
function cooldownKey(rule, subject) {
|
|
4504
|
-
const
|
|
4505
|
-
|
|
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}`;
|
|
4506
4798
|
}
|
|
4507
4799
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4508
4800
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -6292,6 +6584,13 @@ var TimelapseStore = class {
|
|
|
6292
6584
|
};
|
|
6293
6585
|
//#endregion
|
|
6294
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;
|
|
6295
6594
|
var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
|
|
6296
6595
|
var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
|
|
6297
6596
|
var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
|
|
@@ -6402,6 +6701,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6402
6701
|
occupancyEnabled = false;
|
|
6403
6702
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
6404
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();
|
|
6405
6707
|
/**
|
|
6406
6708
|
* Serialized evaluation chain. The persist hooks are fire-and-forget for
|
|
6407
6709
|
* the frame path, but two concurrent evaluations of the same device
|
|
@@ -6733,9 +7035,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6733
7035
|
return;
|
|
6734
7036
|
}
|
|
6735
7037
|
const now = this.now();
|
|
7038
|
+
let anyMatched = false;
|
|
7039
|
+
const rejections = [];
|
|
6736
7040
|
for (const rule of candidates) {
|
|
6737
7041
|
const evaluation = evaluateRule(rule, subject);
|
|
6738
7042
|
if (!evaluation.matched) {
|
|
7043
|
+
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
6739
7044
|
this.logger.debug("rule did not match", {
|
|
6740
7045
|
tags: { deviceId: subject.deviceId },
|
|
6741
7046
|
meta: {
|
|
@@ -6779,7 +7084,33 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6779
7084
|
});
|
|
6780
7085
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6781
7086
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
7087
|
+
anyMatched = true;
|
|
6782
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
|
+
});
|
|
6783
7114
|
}
|
|
6784
7115
|
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6785
7116
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
@@ -7874,6 +8205,137 @@ function classifyTrackAppearance(input) {
|
|
|
7874
8205
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
7875
8206
|
}
|
|
7876
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
|
|
8265
|
+
//#region src/pipeline-analytics/pipeline/deferred-births.ts
|
|
8266
|
+
var DeferredBirthRegistry = class {
|
|
8267
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
8268
|
+
/** Record an undecided attempt. The first call registers; later calls count. */
|
|
8269
|
+
defer(deviceKey, trackId, nowMs) {
|
|
8270
|
+
let ids = this.byDevice.get(deviceKey);
|
|
8271
|
+
if (!ids) {
|
|
8272
|
+
ids = /* @__PURE__ */ new Map();
|
|
8273
|
+
this.byDevice.set(deviceKey, ids);
|
|
8274
|
+
}
|
|
8275
|
+
const existing = ids.get(trackId);
|
|
8276
|
+
if (existing) existing.attempts += 1;
|
|
8277
|
+
else ids.set(trackId, {
|
|
8278
|
+
firstSeenMs: nowMs,
|
|
8279
|
+
attempts: 1
|
|
8280
|
+
});
|
|
8281
|
+
}
|
|
8282
|
+
isDeferred(deviceKey, trackId) {
|
|
8283
|
+
return this.byDevice.get(deviceKey)?.has(trackId) === true;
|
|
8284
|
+
}
|
|
8285
|
+
attemptsFor(deviceKey, trackId) {
|
|
8286
|
+
return this.byDevice.get(deviceKey)?.get(trackId)?.attempts ?? 0;
|
|
8287
|
+
}
|
|
8288
|
+
/** Ms since the FIRST attempt, or 0 for an id this registry never saw. */
|
|
8289
|
+
elapsedMs(deviceKey, trackId, nowMs) {
|
|
8290
|
+
const entry = this.byDevice.get(deviceKey)?.get(trackId);
|
|
8291
|
+
return entry ? nowMs - entry.firstSeenMs : 0;
|
|
8292
|
+
}
|
|
8293
|
+
/** A verdict finally arrived — stop tracking it. */
|
|
8294
|
+
resolve(deviceKey, trackId) {
|
|
8295
|
+
const ids = this.byDevice.get(deviceKey);
|
|
8296
|
+
if (!ids) return;
|
|
8297
|
+
ids.delete(trackId);
|
|
8298
|
+
if (ids.size === 0) this.byDevice.delete(deviceKey);
|
|
8299
|
+
}
|
|
8300
|
+
/**
|
|
8301
|
+
* True when this birth has had enough tries, or waited long enough.
|
|
8302
|
+
*
|
|
8303
|
+
* Either bound ends the deferral: attempts alone would let a camera whose
|
|
8304
|
+
* frames arrive slowly hold a birth for minutes, and elapsed alone would let
|
|
8305
|
+
* a fast camera burn dozens of inference calls on one hopeless box.
|
|
8306
|
+
*/
|
|
8307
|
+
exhausted(deviceKey, trackId, nowMs, maxAttempts, maxDeferralMs) {
|
|
8308
|
+
const entry = this.byDevice.get(deviceKey)?.get(trackId);
|
|
8309
|
+
if (!entry) return false;
|
|
8310
|
+
return entry.attempts >= maxAttempts || nowMs - entry.firstSeenMs >= maxDeferralMs;
|
|
8311
|
+
}
|
|
8312
|
+
/** Every id awaiting a verdict on this device. */
|
|
8313
|
+
deferredIds(deviceKey) {
|
|
8314
|
+
const ids = this.byDevice.get(deviceKey);
|
|
8315
|
+
return ids ? [...ids.keys()] : [];
|
|
8316
|
+
}
|
|
8317
|
+
/**
|
|
8318
|
+
* Forget every deferred id the tracker is no longer carrying.
|
|
8319
|
+
*
|
|
8320
|
+
* Called once per frame with the ids present THIS frame — the same contract
|
|
8321
|
+
* as the suppressed registry's `retain`.
|
|
8322
|
+
*/
|
|
8323
|
+
retain(deviceKey, currentTrackIds) {
|
|
8324
|
+
const ids = this.byDevice.get(deviceKey);
|
|
8325
|
+
if (!ids) return;
|
|
8326
|
+
for (const id of [...ids.keys()]) if (!currentTrackIds.has(id)) ids.delete(id);
|
|
8327
|
+
if (ids.size === 0) this.byDevice.delete(deviceKey);
|
|
8328
|
+
}
|
|
8329
|
+
/** Deferred ids currently held for a device — diagnostics and tests. */
|
|
8330
|
+
size(deviceKey) {
|
|
8331
|
+
return this.byDevice.get(deviceKey)?.size ?? 0;
|
|
8332
|
+
}
|
|
8333
|
+
/** Drop a device's memory wholesale (device removed / pipeline reset). */
|
|
8334
|
+
clearDevice(deviceKey) {
|
|
8335
|
+
this.byDevice.delete(deviceKey);
|
|
8336
|
+
}
|
|
8337
|
+
};
|
|
8338
|
+
//#endregion
|
|
7877
8339
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
7878
8340
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
7879
8341
|
const scored = [];
|
|
@@ -8378,9 +8840,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
|
8378
8840
|
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
8379
8841
|
* new object.
|
|
8380
8842
|
*
|
|
8381
|
-
* NOTE: clamping
|
|
8382
|
-
*
|
|
8383
|
-
*
|
|
8843
|
+
* NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
|
|
8844
|
+
* bound slides inward and keeps the extent that was asked for, on either side.
|
|
8845
|
+
* It shrinks only when the padded window is larger than the frame itself
|
|
8384
8846
|
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
8385
8847
|
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
8386
8848
|
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
@@ -8390,19 +8852,19 @@ function padBbox(bbox, padding) {
|
|
|
8390
8852
|
const rawY = bbox.y - padding * bbox.h;
|
|
8391
8853
|
const rawW = bbox.w * (1 + 2 * padding);
|
|
8392
8854
|
const rawH = bbox.h * (1 + 2 * padding);
|
|
8393
|
-
const
|
|
8394
|
-
const
|
|
8855
|
+
const w = Math.min(rawW, 1);
|
|
8856
|
+
const h = Math.min(rawH, 1);
|
|
8395
8857
|
return {
|
|
8396
|
-
x,
|
|
8397
|
-
y,
|
|
8398
|
-
w
|
|
8399
|
-
h
|
|
8858
|
+
x: Math.min(Math.max(0, rawX), 1 - w),
|
|
8859
|
+
y: Math.min(Math.max(0, rawY), 1 - h),
|
|
8860
|
+
w,
|
|
8861
|
+
h
|
|
8400
8862
|
};
|
|
8401
8863
|
}
|
|
8402
8864
|
//#endregion
|
|
8403
8865
|
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
8404
8866
|
function createCaptureCrop(deps) {
|
|
8405
|
-
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
8867
|
+
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
|
|
8406
8868
|
const paddedNorm = padBbox({
|
|
8407
8869
|
x: bbox.x / frameWidth,
|
|
8408
8870
|
y: bbox.y / frameHeight,
|
|
@@ -8415,7 +8877,10 @@ function createCaptureCrop(deps) {
|
|
|
8415
8877
|
return nativeCrop;
|
|
8416
8878
|
}
|
|
8417
8879
|
deps.bumpCropMetric(false);
|
|
8418
|
-
deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", {
|
|
8880
|
+
deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", {
|
|
8881
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
8882
|
+
meta: { nodeId: frameHandle.nodeId }
|
|
8883
|
+
});
|
|
8419
8884
|
return null;
|
|
8420
8885
|
};
|
|
8421
8886
|
}
|
|
@@ -12091,7 +12556,11 @@ function squareSubjectCropRegion(bbox, frame) {
|
|
|
12091
12556
|
* cases). `C` becomes the middle square of the output.
|
|
12092
12557
|
* 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
|
|
12093
12558
|
* 3. anchor: place the canvas so `C` is its horizontal middle →
|
|
12094
|
-
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0)
|
|
12559
|
+
* `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
|
|
12560
|
+
* then CLAMP the window fully inside the frame whenever it fits. The
|
|
12561
|
+
* subject therefore drifts off-centre near a frame edge and the output
|
|
12562
|
+
* carries no padding at all. Centring is a preference; containing the
|
|
12563
|
+
* subject is the contract, and clamping cannot break it.
|
|
12095
12564
|
* 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
|
|
12096
12565
|
* `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
|
|
12097
12566
|
* the vertical extent is always in-frame). Its canvas offset is
|
|
@@ -12103,13 +12572,8 @@ function wideCentralSquareLayout(bbox, frame) {
|
|
|
12103
12572
|
const canvasW = Math.round(c * 16 / 9);
|
|
12104
12573
|
const centralX0 = Math.round((canvasW - c) / 2);
|
|
12105
12574
|
let frameOriginX = central.x - (canvasW - c) / 2;
|
|
12106
|
-
if (canvasW <= frame.W)
|
|
12107
|
-
|
|
12108
|
-
const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
|
|
12109
|
-
if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
|
|
12110
|
-
const overRight = frameOriginX + canvasW - frame.W;
|
|
12111
|
-
if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
|
|
12112
|
-
} else frameOriginX = (frame.W - canvasW) / 2;
|
|
12575
|
+
if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
|
|
12576
|
+
else frameOriginX = (frame.W - canvasW) / 2;
|
|
12113
12577
|
const fxa = Math.max(0, frameOriginX);
|
|
12114
12578
|
const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
|
|
12115
12579
|
const slabOffsetX = fxa - frameOriginX;
|
|
@@ -12158,10 +12622,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
|
|
|
12158
12622
|
/**
|
|
12159
12623
|
* Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
|
|
12160
12624
|
* in-frame slab fetched for `layout`. The slab is placed at its computed offset
|
|
12161
|
-
* on a 16:9 canvas; any lateral part of the window outside the frame
|
|
12162
|
-
*
|
|
12163
|
-
*
|
|
12164
|
-
*
|
|
12625
|
+
* on a 16:9 canvas; any lateral part of the window outside the frame is filled
|
|
12626
|
+
* with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
|
|
12627
|
+
* (operator triage 2026-07-22).
|
|
12628
|
+
*
|
|
12629
|
+
* **That fill is now nearly unreachable, and deliberately so.** The geometry
|
|
12630
|
+
* clamps the window fully inside the frame whenever it fits, so a subject
|
|
12631
|
+
* against a frame edge yields real pixels off-centre rather than ambience
|
|
12632
|
+
* (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
|
|
12633
|
+
* still pads — there is no more scene to slide into — which is why this code
|
|
12634
|
+
* stays. If you are looking at a blurred band in a best shot, the window was
|
|
12635
|
+
* wider than the frame; do not go looking for a sliding bug.
|
|
12165
12636
|
* When the whole window is in-frame (`slab` already spans the full canvas) the
|
|
12166
12637
|
* slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
|
|
12167
12638
|
* the canvas is at the slab's native scale; only the out-of-frame ambience fill
|
|
@@ -13284,6 +13755,13 @@ var ZoneAnalyticsProvider = class {
|
|
|
13284
13755
|
zones: snapshot.zones.length
|
|
13285
13756
|
}
|
|
13286
13757
|
});
|
|
13758
|
+
this.ctx.emitOccupancyChanged?.({
|
|
13759
|
+
deviceId: input.deviceId,
|
|
13760
|
+
timestamp: input.timestamp,
|
|
13761
|
+
totalObjects: total,
|
|
13762
|
+
byClass: snapshot.frame.byClass,
|
|
13763
|
+
zones: snapshot.zones.length
|
|
13764
|
+
});
|
|
13287
13765
|
}
|
|
13288
13766
|
this.snapshots.set(input.deviceId, snapshot);
|
|
13289
13767
|
this.appendHistory(input.deviceId, snapshot);
|
|
@@ -14133,11 +14611,15 @@ function resolveTrackingSettings(raw) {
|
|
|
14133
14611
|
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14134
14612
|
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14135
14613
|
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14136
|
-
* Read the schema, not this paragraph.
|
|
14137
|
-
*
|
|
14138
|
-
*
|
|
14139
|
-
*
|
|
14140
|
-
*
|
|
14614
|
+
* Read the schema, not this paragraph.
|
|
14615
|
+
*
|
|
14616
|
+
* The gate is fail-DEFERRED, not fail-open (changed 2026-08-01). Anything that
|
|
14617
|
+
* prevents a MEASUREMENT — a missing frame handle, an unavailable inference
|
|
14618
|
+
* cap, a crop-fetch miss, a re-detection error, a timeout — leaves the birth
|
|
14619
|
+
* UNDECIDED and re-tried on later frames. Only an exhausted deferral with no
|
|
14620
|
+
* crop at all falls open, and that is logged at `warn`. Fail-open on every one
|
|
14621
|
+
* of those paths is what let a brick wall onto camera 636's track feed as a
|
|
14622
|
+
* `vehicle`, and left 45% of births unmeasured over twelve hours.
|
|
14141
14623
|
*
|
|
14142
14624
|
* Every field is independently overridable per camera; an unknown/invalid value
|
|
14143
14625
|
* falls back to the field default (never throws on a bad blob) — mirrors
|
|
@@ -14154,7 +14636,9 @@ var CONFIRMATION_GATE_KEYS = {
|
|
|
14154
14636
|
enabled: "confirmationGateEnabled",
|
|
14155
14637
|
minConfidence: "confirmationGateMinConfidence",
|
|
14156
14638
|
minCropPx: "confirmationGateMinCropPx",
|
|
14157
|
-
timeoutMs: "confirmationGateTimeoutMs"
|
|
14639
|
+
timeoutMs: "confirmationGateTimeoutMs",
|
|
14640
|
+
maxDeferralMs: "confirmationGateMaxDeferralMs",
|
|
14641
|
+
maxAttempts: "confirmationGateMaxAttempts"
|
|
14158
14642
|
};
|
|
14159
14643
|
var ConfirmationGateSettingsSchema = object({
|
|
14160
14644
|
/** Master switch. DEFAULT ON (2026-07-20 rollout) — the gate is FAIL-OPEN (any
|
|
@@ -14176,11 +14660,21 @@ var ConfirmationGateSettingsSchema = object({
|
|
|
14176
14660
|
*/
|
|
14177
14661
|
minCropPx: number().int().min(0).default(48),
|
|
14178
14662
|
/**
|
|
14179
|
-
* Per-
|
|
14180
|
-
* not resolve within this window the
|
|
14181
|
-
*
|
|
14663
|
+
* Per-ATTEMPT confirmation budget (ms). If the crop fetch + re-detection does
|
|
14664
|
+
* not resolve within this window the attempt ends UNDECIDED, so the
|
|
14665
|
+
* synchronous frame path never stalls on inference.
|
|
14666
|
+
*/
|
|
14667
|
+
timeoutMs: number().int().min(1).default(300),
|
|
14668
|
+
/**
|
|
14669
|
+
* How long a birth may stay UNDECIDED before the gate stops waiting for a
|
|
14670
|
+
* native crop and decides on the sub-native fallback.
|
|
14671
|
+
*
|
|
14672
|
+
* Measured from the FIRST attempt, so retries cannot push it out. 0 = decide
|
|
14673
|
+
* on the first attempt (the pre-2026-08-01 cadence, without the fail-open).
|
|
14182
14674
|
*/
|
|
14183
|
-
|
|
14675
|
+
maxDeferralMs: number().int().min(0).default(2e3),
|
|
14676
|
+
/** How many gate attempts one birth may have, the first included. */
|
|
14677
|
+
maxAttempts: number().int().min(1).default(4)
|
|
14184
14678
|
});
|
|
14185
14679
|
var CONFIRMATION_GATE_DEFAULTS = ConfirmationGateSettingsSchema.parse({});
|
|
14186
14680
|
/**
|
|
@@ -14197,7 +14691,9 @@ function resolveConfirmationGateSettings(raw) {
|
|
|
14197
14691
|
enabled: s.enabled.catch(CONFIRMATION_GATE_DEFAULTS.enabled).parse(raw[CONFIRMATION_GATE_KEYS.enabled]),
|
|
14198
14692
|
minConfidence: s.minConfidence.catch(CONFIRMATION_GATE_DEFAULTS.minConfidence).parse(raw[CONFIRMATION_GATE_KEYS.minConfidence]),
|
|
14199
14693
|
minCropPx: s.minCropPx.catch(CONFIRMATION_GATE_DEFAULTS.minCropPx).parse(raw[CONFIRMATION_GATE_KEYS.minCropPx]),
|
|
14200
|
-
timeoutMs: s.timeoutMs.catch(CONFIRMATION_GATE_DEFAULTS.timeoutMs).parse(raw[CONFIRMATION_GATE_KEYS.timeoutMs])
|
|
14694
|
+
timeoutMs: s.timeoutMs.catch(CONFIRMATION_GATE_DEFAULTS.timeoutMs).parse(raw[CONFIRMATION_GATE_KEYS.timeoutMs]),
|
|
14695
|
+
maxDeferralMs: s.maxDeferralMs.catch(CONFIRMATION_GATE_DEFAULTS.maxDeferralMs).parse(raw[CONFIRMATION_GATE_KEYS.maxDeferralMs]),
|
|
14696
|
+
maxAttempts: s.maxAttempts.catch(CONFIRMATION_GATE_DEFAULTS.maxAttempts).parse(raw[CONFIRMATION_GATE_KEYS.maxAttempts])
|
|
14201
14697
|
};
|
|
14202
14698
|
}
|
|
14203
14699
|
//#endregion
|
|
@@ -14229,9 +14725,9 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
14229
14725
|
if (track === "other" || det === "other") return true;
|
|
14230
14726
|
return track === det;
|
|
14231
14727
|
}
|
|
14232
|
-
var
|
|
14728
|
+
var undecided = (candidate, reason) => ({
|
|
14233
14729
|
trackId: candidate.trackId,
|
|
14234
|
-
|
|
14730
|
+
verdict: "undecided",
|
|
14235
14731
|
reason,
|
|
14236
14732
|
className: candidate.className
|
|
14237
14733
|
});
|
|
@@ -14247,11 +14743,12 @@ function withTimeout(promise, timeoutMs) {
|
|
|
14247
14743
|
});
|
|
14248
14744
|
});
|
|
14249
14745
|
}
|
|
14250
|
-
async function runConfirmation(candidate, config, deps) {
|
|
14251
|
-
|
|
14252
|
-
if (!crop)
|
|
14746
|
+
async function runConfirmation(candidate, config, deps, exhausted) {
|
|
14747
|
+
let crop = await deps.fetchCrop(candidate);
|
|
14748
|
+
if (!crop && exhausted && deps.fetchFallbackCrop) crop = await deps.fetchFallbackCrop(candidate);
|
|
14749
|
+
if (!crop) return undecided(candidate, "no-crop");
|
|
14253
14750
|
const detections = await deps.redetect(crop);
|
|
14254
|
-
if (detections === null) return
|
|
14751
|
+
if (detections === null) return undecided(candidate, "redetect-error");
|
|
14255
14752
|
let best;
|
|
14256
14753
|
let bestIncompatible;
|
|
14257
14754
|
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
@@ -14260,7 +14757,7 @@ async function runConfirmation(candidate, config, deps) {
|
|
|
14260
14757
|
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
14261
14758
|
return {
|
|
14262
14759
|
trackId: candidate.trackId,
|
|
14263
|
-
confirmed,
|
|
14760
|
+
verdict: confirmed ? "confirmed" : "suppressed",
|
|
14264
14761
|
reason: confirmed ? "confirmed" : "suppressed",
|
|
14265
14762
|
className: candidate.className,
|
|
14266
14763
|
...best ? { bestScore: best.score } : {},
|
|
@@ -14270,31 +14767,40 @@ async function runConfirmation(candidate, config, deps) {
|
|
|
14270
14767
|
} : {}
|
|
14271
14768
|
};
|
|
14272
14769
|
}
|
|
14273
|
-
async function confirmOne(candidate, config, deps) {
|
|
14770
|
+
async function confirmOne(candidate, config, deps, exhausted) {
|
|
14274
14771
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
14275
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return
|
|
14772
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return undecided(candidate, "below-min-crop");
|
|
14276
14773
|
try {
|
|
14277
|
-
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
14774
|
+
return await withTimeout(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
|
|
14278
14775
|
} catch {
|
|
14279
|
-
return
|
|
14776
|
+
return undecided(candidate, "timeout");
|
|
14280
14777
|
}
|
|
14281
14778
|
}
|
|
14282
14779
|
/**
|
|
14283
|
-
* Confirm a batch of birth candidates CONCURRENTLY
|
|
14284
|
-
*
|
|
14285
|
-
*
|
|
14286
|
-
*
|
|
14287
|
-
*
|
|
14780
|
+
* Confirm a batch of birth candidates CONCURRENTLY. When the gate is disabled
|
|
14781
|
+
* (or there are no candidates) every candidate is confirmed — byte-identical to
|
|
14782
|
+
* no gate. The caller runs this once, then processes the confirmed births,
|
|
14783
|
+
* defers the undecided ones, and retracts the rest, preserving the original
|
|
14784
|
+
* birth-loop ordering.
|
|
14288
14785
|
*/
|
|
14289
|
-
async function confirmBirths(candidates, config, deps) {
|
|
14290
|
-
if (!config.enabled || candidates.length === 0) return
|
|
14291
|
-
|
|
14786
|
+
async function confirmBirths(candidates, config, deps, exhaustedIds = /* @__PURE__ */ new Set()) {
|
|
14787
|
+
if (!config.enabled || candidates.length === 0) return {
|
|
14788
|
+
confirmed: new Set(candidates.map((c) => c.trackId)),
|
|
14789
|
+
undecided: /* @__PURE__ */ new Set()
|
|
14790
|
+
};
|
|
14791
|
+
const decisions = await Promise.all(candidates.map((c) => confirmOne(c, config, deps, exhaustedIds.has(c.trackId))));
|
|
14292
14792
|
const confirmed = /* @__PURE__ */ new Set();
|
|
14793
|
+
const pending = /* @__PURE__ */ new Set();
|
|
14293
14794
|
for (const decision of decisions) {
|
|
14294
14795
|
deps.onDecision?.(decision);
|
|
14295
|
-
if (decision.confirmed) confirmed.add(decision.trackId);
|
|
14796
|
+
if (decision.verdict === "confirmed") confirmed.add(decision.trackId);
|
|
14797
|
+
else if (decision.verdict === "undecided") if (exhaustedIds.has(decision.trackId)) confirmed.add(decision.trackId);
|
|
14798
|
+
else pending.add(decision.trackId);
|
|
14296
14799
|
}
|
|
14297
|
-
return
|
|
14800
|
+
return {
|
|
14801
|
+
confirmed,
|
|
14802
|
+
undecided: pending
|
|
14803
|
+
};
|
|
14298
14804
|
}
|
|
14299
14805
|
//#endregion
|
|
14300
14806
|
//#region src/pipeline-analytics/face-settings.ts
|
|
@@ -14913,7 +15419,7 @@ function buildDetectionSettingsSections() {
|
|
|
14913
15419
|
{
|
|
14914
15420
|
id: "confirmation-gate",
|
|
14915
15421
|
title: "Confirmation gate",
|
|
14916
|
-
description: "Before a NEW track is born,
|
|
15422
|
+
description: "Before a NEW track is born, re-run object detection on the hi-res native crop of the detection box. If the crop does not confirm a compatible object above the threshold, the birth is suppressed as a false positive. Fail-DEFERRED: a crop miss, unavailable inference, or a timeout leaves the birth UNDECIDED and looks again on later frames — it is not confirmed by default. Only an exhausted deferral with no crop at all lets a birth through unmeasured.",
|
|
14917
15423
|
columns: 2,
|
|
14918
15424
|
fields: [
|
|
14919
15425
|
{
|
|
@@ -14938,7 +15444,7 @@ function buildDetectionSettingsSections() {
|
|
|
14938
15444
|
type: "slider",
|
|
14939
15445
|
key: CONFIRMATION_GATE_KEYS.minCropPx,
|
|
14940
15446
|
label: "Min crop size",
|
|
14941
|
-
description: "Subject boxes smaller than this (longest side, detection-frame px) are too tiny to confirm reliably — the gate
|
|
15447
|
+
description: "Subject boxes smaller than this (longest side, detection-frame px) are too tiny to confirm reliably — the gate defers them and looks again as the subject approaches and the box grows. 0 = confirm every birth regardless of size.",
|
|
14942
15448
|
min: 0,
|
|
14943
15449
|
max: 256,
|
|
14944
15450
|
step: 8,
|
|
@@ -14950,13 +15456,36 @@ function buildDetectionSettingsSections() {
|
|
|
14950
15456
|
type: "slider",
|
|
14951
15457
|
key: CONFIRMATION_GATE_KEYS.timeoutMs,
|
|
14952
15458
|
label: "Confirmation timeout",
|
|
14953
|
-
description: "Per-
|
|
15459
|
+
description: "Per-ATTEMPT budget for crop fetch + re-detection. If it does not resolve in time the attempt ends undecided, so the frame path never stalls on inference.",
|
|
14954
15460
|
min: 50,
|
|
14955
15461
|
max: 2e3,
|
|
14956
15462
|
step: 50,
|
|
14957
15463
|
default: CONFIRMATION_GATE_DEFAULTS.timeoutMs,
|
|
14958
15464
|
showValue: true,
|
|
14959
15465
|
unit: "ms"
|
|
15466
|
+
},
|
|
15467
|
+
{
|
|
15468
|
+
type: "slider",
|
|
15469
|
+
key: CONFIRMATION_GATE_KEYS.maxDeferralMs,
|
|
15470
|
+
label: "Max deferral",
|
|
15471
|
+
description: "How long an undecided birth may wait for a native crop before the gate decides on the sub-native fallback. Measured from the first attempt, so retries cannot push it out. 0 = decide on the first attempt.",
|
|
15472
|
+
min: 0,
|
|
15473
|
+
max: 1e4,
|
|
15474
|
+
step: 250,
|
|
15475
|
+
default: CONFIRMATION_GATE_DEFAULTS.maxDeferralMs,
|
|
15476
|
+
showValue: true,
|
|
15477
|
+
unit: "ms"
|
|
15478
|
+
},
|
|
15479
|
+
{
|
|
15480
|
+
type: "slider",
|
|
15481
|
+
key: CONFIRMATION_GATE_KEYS.maxAttempts,
|
|
15482
|
+
label: "Max gate attempts",
|
|
15483
|
+
description: "How many times one birth may be put through the gate, the first attempt included. The deferral ends on this or on the max deferral, whichever comes first.",
|
|
15484
|
+
min: 1,
|
|
15485
|
+
max: 12,
|
|
15486
|
+
step: 1,
|
|
15487
|
+
default: CONFIRMATION_GATE_DEFAULTS.maxAttempts,
|
|
15488
|
+
showValue: true
|
|
14960
15489
|
}
|
|
14961
15490
|
]
|
|
14962
15491
|
},
|
|
@@ -15016,6 +15545,15 @@ function buildDetectionSettingsSections() {
|
|
|
15016
15545
|
step: .05,
|
|
15017
15546
|
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
15018
15547
|
},
|
|
15548
|
+
{
|
|
15549
|
+
type: "number",
|
|
15550
|
+
key: "rescueCentroidFactor",
|
|
15551
|
+
label: "Rescue centroid distance",
|
|
15552
|
+
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.",
|
|
15553
|
+
min: 0,
|
|
15554
|
+
step: .05,
|
|
15555
|
+
default: TRACKING_DEFAULTS.rescueCentroidFactor
|
|
15556
|
+
},
|
|
15019
15557
|
{
|
|
15020
15558
|
type: "number",
|
|
15021
15559
|
key: "stationarySpeedPx",
|
|
@@ -15297,6 +15835,16 @@ function buildDetectionSettingsSections() {
|
|
|
15297
15835
|
label: "Person/animal group matching",
|
|
15298
15836
|
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.",
|
|
15299
15837
|
default: TRACKING_DEFAULTS.classGroupAssoc
|
|
15838
|
+
},
|
|
15839
|
+
{
|
|
15840
|
+
type: "number",
|
|
15841
|
+
key: "zoneMembershipMinOverlap",
|
|
15842
|
+
label: "Zone membership minimum overlap",
|
|
15843
|
+
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.",
|
|
15844
|
+
min: 0,
|
|
15845
|
+
max: 1,
|
|
15846
|
+
step: .05,
|
|
15847
|
+
default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
|
|
15300
15848
|
}
|
|
15301
15849
|
]
|
|
15302
15850
|
},
|
|
@@ -19866,6 +20414,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19866
20414
|
* dataPlane facility in the current environment). */
|
|
19867
20415
|
eventMediaBaseUrl = null;
|
|
19868
20416
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
20417
|
+
/** See `pipeline/suppressed-births.ts` — rejected births must not be
|
|
20418
|
+
* re-upserted, and must be forgotten when the tracker drops the id. */
|
|
20419
|
+
suppressedBirths = new SuppressedBirthRegistry();
|
|
20420
|
+
/** Births the gate could not MEASURE, awaiting another look on a later frame. */
|
|
20421
|
+
deferredBirths = new DeferredBirthRegistry();
|
|
19869
20422
|
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
19870
20423
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
19871
20424
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
@@ -19951,6 +20504,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19951
20504
|
* the same live-frame window as the face/plate/event-media captures. The
|
|
19952
20505
|
* optional `maxWidth` caps the native crop width (used for the full-frame key
|
|
19953
20506
|
* frame so a 4K native surface never floods the transport). */
|
|
20507
|
+
/**
|
|
20508
|
+
* The BOUNDED fallback `captureCrop` refuses — same padded ROI out of the
|
|
20509
|
+
* retained full frame (keyframe-native tier, or the runner's ≤640 RAM tier;
|
|
20510
|
+
* honest sub-native, NEVER upscaled). Built for the gallery tiles; the
|
|
20511
|
+
* confirmation gate borrows it on an EXHAUSTED deferral only. See
|
|
20512
|
+
* `docs/decisions/` — the gate is a model input, so this is a deliberate
|
|
20513
|
+
* exception to "model-input crops keep captureCrop".
|
|
20514
|
+
*/
|
|
20515
|
+
captureDisplayCropFn = null;
|
|
19954
20516
|
captureCrop = null;
|
|
19955
20517
|
/** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
|
|
19956
20518
|
* to the owning runner, PLUS the source `tier`. Captured in the async init
|
|
@@ -20349,6 +20911,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20349
20911
|
* route through the CaptureScheduler at the injection point (S4). */
|
|
20350
20912
|
buildRecognizers(api, logger, stores, transport) {
|
|
20351
20913
|
const captureDisplayCrop = transport.captureDisplayCrop;
|
|
20914
|
+
this.captureDisplayCropFn = captureDisplayCrop;
|
|
20352
20915
|
this.faceRecognizer = new FaceRecognizer({
|
|
20353
20916
|
identityStore: stores.identityStore,
|
|
20354
20917
|
faceStore: stores.faceStore,
|
|
@@ -20449,6 +21012,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20449
21012
|
const zoneAnalytics = new ZoneAnalyticsProvider({
|
|
20450
21013
|
logger: logger.child("ZoneAnalytics"),
|
|
20451
21014
|
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
21015
|
+
emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
|
|
21016
|
+
id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
|
|
21017
|
+
timestamp: new Date(payload.timestamp),
|
|
21018
|
+
source: {
|
|
21019
|
+
type: "addon",
|
|
21020
|
+
id: "pipeline-analytics",
|
|
21021
|
+
addonId: "pipeline-analytics"
|
|
21022
|
+
},
|
|
21023
|
+
category: EventCategory.ZoneAnalyticsOccupancyChanged,
|
|
21024
|
+
data: {
|
|
21025
|
+
deviceId: payload.deviceId,
|
|
21026
|
+
totalObjects: payload.totalObjects,
|
|
21027
|
+
byClass: payload.byClass,
|
|
21028
|
+
zones: payload.zones
|
|
21029
|
+
}
|
|
21030
|
+
}),
|
|
20452
21031
|
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
20453
21032
|
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
20454
21033
|
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
@@ -20896,6 +21475,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20896
21475
|
timestamp: frame.timestamp,
|
|
20897
21476
|
frame
|
|
20898
21477
|
});
|
|
21478
|
+
for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
|
|
21479
|
+
tags: { deviceId },
|
|
21480
|
+
meta: {
|
|
21481
|
+
vehicleClass: r.vehicleClass,
|
|
21482
|
+
personScore: r.personScore,
|
|
21483
|
+
vehicleScore: r.vehicleScore,
|
|
21484
|
+
overlap: r.overlap
|
|
21485
|
+
}
|
|
21486
|
+
});
|
|
20899
21487
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
20900
21488
|
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
20901
21489
|
deviceId,
|
|
@@ -20926,6 +21514,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20926
21514
|
const positionsCountById = /* @__PURE__ */ new Map();
|
|
20927
21515
|
for (const t of result.tracked) {
|
|
20928
21516
|
currentTrackIds.add(t.trackId);
|
|
21517
|
+
if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
|
|
20929
21518
|
const center = {
|
|
20930
21519
|
x: t.bbox.x + t.bbox.w / 2,
|
|
20931
21520
|
y: t.bbox.y + t.bbox.h / 2
|
|
@@ -20981,7 +21570,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20981
21570
|
t
|
|
20982
21571
|
});
|
|
20983
21572
|
}
|
|
20984
|
-
const
|
|
21573
|
+
for (const id of this.deferredBirths.deferredIds(key)) {
|
|
21574
|
+
if (bornCandidates.some((c) => c.id === id)) continue;
|
|
21575
|
+
const t = result.tracked.find((x) => x.trackId === id);
|
|
21576
|
+
if (t) bornCandidates.push({
|
|
21577
|
+
id,
|
|
21578
|
+
t
|
|
21579
|
+
});
|
|
21580
|
+
}
|
|
21581
|
+
const gateSettings = await this.resolveDeviceConfirmationGateSettings(deviceId);
|
|
21582
|
+
const exhaustionNowMs = Date.now();
|
|
21583
|
+
const exhaustedIds = /* @__PURE__ */ new Set();
|
|
21584
|
+
for (const { id } of bornCandidates) if (this.deferredBirths.exhausted(key, id, exhaustionNowMs, gateSettings.maxAttempts, gateSettings.maxDeferralMs)) exhaustedIds.add(id);
|
|
21585
|
+
const outcome = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => ({
|
|
20985
21586
|
trackId: id,
|
|
20986
21587
|
className: t.className,
|
|
20987
21588
|
bbox: t.bbox
|
|
@@ -20990,9 +21591,53 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20990
21591
|
frameHandle,
|
|
20991
21592
|
frameWidth: result.frameWidth,
|
|
20992
21593
|
frameHeight: result.frameHeight
|
|
20993
|
-
});
|
|
21594
|
+
}, exhaustedIds);
|
|
21595
|
+
const gateNowMs = Date.now();
|
|
20994
21596
|
for (const { id, t } of bornCandidates) {
|
|
20995
|
-
if (
|
|
21597
|
+
if (outcome.undecided.has(id)) {
|
|
21598
|
+
this.deferredBirths.defer(key, id, gateNowMs);
|
|
21599
|
+
log.info("birth undecided — deferred for another look", { meta: {
|
|
21600
|
+
trackId: id,
|
|
21601
|
+
className: t.className,
|
|
21602
|
+
attempts: this.deferredBirths.attemptsFor(key, id),
|
|
21603
|
+
elapsedMs: this.deferredBirths.elapsedMs(key, id, gateNowMs),
|
|
21604
|
+
source
|
|
21605
|
+
} });
|
|
21606
|
+
continue;
|
|
21607
|
+
}
|
|
21608
|
+
const wasDeferred = this.deferredBirths.isDeferred(key, id);
|
|
21609
|
+
const deferredForMs = this.deferredBirths.elapsedMs(key, id, gateNowMs);
|
|
21610
|
+
const deferredAttempts = this.deferredBirths.attemptsFor(key, id);
|
|
21611
|
+
this.deferredBirths.resolve(key, id);
|
|
21612
|
+
if (!outcome.confirmed.has(id)) {
|
|
21613
|
+
this.suppressedBirths.reject(key, id);
|
|
21614
|
+
this.trackStore?.dropActive(id);
|
|
21615
|
+
log.info("birth suppressed — track record retracted", { meta: {
|
|
21616
|
+
trackId: id,
|
|
21617
|
+
className: t.className,
|
|
21618
|
+
source,
|
|
21619
|
+
...wasDeferred ? {
|
|
21620
|
+
wasDeferred,
|
|
21621
|
+
deferredForMs,
|
|
21622
|
+
deferredAttempts
|
|
21623
|
+
} : {}
|
|
21624
|
+
} });
|
|
21625
|
+
continue;
|
|
21626
|
+
}
|
|
21627
|
+
if (wasDeferred && exhaustedIds.has(id)) log.warn("birth allowed unmeasured — deferral exhausted, no crop available", { meta: {
|
|
21628
|
+
trackId: id,
|
|
21629
|
+
className: t.className,
|
|
21630
|
+
source,
|
|
21631
|
+
deferredForMs,
|
|
21632
|
+
deferredAttempts
|
|
21633
|
+
} });
|
|
21634
|
+
else if (wasDeferred) log.info("birth decided late — confirmed after deferral", { meta: {
|
|
21635
|
+
trackId: id,
|
|
21636
|
+
className: t.className,
|
|
21637
|
+
source,
|
|
21638
|
+
deferredForMs,
|
|
21639
|
+
deferredAttempts
|
|
21640
|
+
} });
|
|
20996
21641
|
newTrackCount += 1;
|
|
20997
21642
|
log.info("track started", { meta: {
|
|
20998
21643
|
trackId: id,
|
|
@@ -21053,6 +21698,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21053
21698
|
source
|
|
21054
21699
|
} });
|
|
21055
21700
|
}
|
|
21701
|
+
this.suppressedBirths.retain(key, currentTrackIds);
|
|
21702
|
+
this.deferredBirths.retain(key, currentTrackIds);
|
|
21056
21703
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
21057
21704
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
21058
21705
|
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
@@ -21124,6 +21771,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21124
21771
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
21125
21772
|
if (this.notificationCenter !== null) {
|
|
21126
21773
|
const overlaps = processor.getLastZoneOverlaps();
|
|
21774
|
+
const rejections = processor.getLastZoneRejections();
|
|
21127
21775
|
for (const e of result.objectEvents) {
|
|
21128
21776
|
if (e.zones && e.zones.length > 0) {
|
|
21129
21777
|
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
@@ -21142,6 +21790,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21142
21790
|
}
|
|
21143
21791
|
});
|
|
21144
21792
|
}
|
|
21793
|
+
const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
|
|
21794
|
+
if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
|
|
21795
|
+
tags: { deviceId },
|
|
21796
|
+
meta: {
|
|
21797
|
+
eventId: e.id,
|
|
21798
|
+
trackId: e.trackId,
|
|
21799
|
+
className: e.className,
|
|
21800
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21801
|
+
stamped: e.zones?.length ?? 0,
|
|
21802
|
+
zones: rejected.map((z) => ({
|
|
21803
|
+
id: z.zoneId,
|
|
21804
|
+
name: z.zoneName,
|
|
21805
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21806
|
+
}))
|
|
21807
|
+
}
|
|
21808
|
+
});
|
|
21145
21809
|
this.notificationCenter.onObjectEventPersisted(e);
|
|
21146
21810
|
}
|
|
21147
21811
|
}
|
|
@@ -21464,19 +22128,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21464
22128
|
* candidate) and each failure path fails OPEN, so the synchronous frame path
|
|
21465
22129
|
* is never blocked or reordered by a slow/failed re-detection.
|
|
21466
22130
|
*/
|
|
21467
|
-
async confirmTrackBirths(candidates, params) {
|
|
21468
|
-
const allConfirmed = () =>
|
|
22131
|
+
async confirmTrackBirths(candidates, params, exhaustedIds = /* @__PURE__ */ new Set()) {
|
|
22132
|
+
const allConfirmed = () => ({
|
|
22133
|
+
confirmed: new Set(candidates.map((c) => c.trackId)),
|
|
22134
|
+
undecided: /* @__PURE__ */ new Set()
|
|
22135
|
+
});
|
|
21469
22136
|
if (candidates.length === 0) return allConfirmed();
|
|
21470
22137
|
const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
|
|
21471
22138
|
if (!config.enabled) return allConfirmed();
|
|
21472
22139
|
const { frameHandle, frameWidth, frameHeight, deviceId } = params;
|
|
21473
22140
|
const captureCrop = this.captureCrop;
|
|
21474
22141
|
if (!frameHandle || !captureCrop || frameWidth <= 0 || frameHeight <= 0) {
|
|
21475
|
-
this.ctx.logger.
|
|
22142
|
+
this.ctx.logger.info("confirmation gate: no crop path — births allowed unmeasured", {
|
|
21476
22143
|
tags: { deviceId },
|
|
21477
22144
|
meta: {
|
|
21478
22145
|
candidates: candidates.length,
|
|
21479
|
-
hasHandle: Boolean(frameHandle)
|
|
22146
|
+
hasHandle: Boolean(frameHandle),
|
|
22147
|
+
hasCaptureCrop: Boolean(captureCrop),
|
|
22148
|
+
frameWidth,
|
|
22149
|
+
frameHeight
|
|
21480
22150
|
}
|
|
21481
22151
|
});
|
|
21482
22152
|
return allConfirmed();
|
|
@@ -21492,8 +22162,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21492
22162
|
y: candidate.bbox.y,
|
|
21493
22163
|
w: candidate.bbox.w,
|
|
21494
22164
|
h: candidate.bbox.h
|
|
21495
|
-
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH)
|
|
22165
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH, deviceId)
|
|
21496
22166
|
}),
|
|
22167
|
+
fetchFallbackCrop: (candidate) => {
|
|
22168
|
+
const displayCrop = this.captureDisplayCropFn;
|
|
22169
|
+
if (!displayCrop) return Promise.resolve(null);
|
|
22170
|
+
return displayCrop(frameHandle, {
|
|
22171
|
+
x: candidate.bbox.x,
|
|
22172
|
+
y: candidate.bbox.y,
|
|
22173
|
+
w: candidate.bbox.w,
|
|
22174
|
+
h: candidate.bbox.h
|
|
22175
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, CONFIRMATION_CROP_MAX_WIDTH);
|
|
22176
|
+
},
|
|
21497
22177
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
21498
22178
|
onDecision: (decision) => {
|
|
21499
22179
|
const meta = {
|
|
@@ -21507,20 +22187,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21507
22187
|
} : {},
|
|
21508
22188
|
minConfidence: config.minConfidence
|
|
21509
22189
|
};
|
|
21510
|
-
|
|
21511
|
-
|
|
21512
|
-
|
|
21513
|
-
|
|
21514
|
-
|
|
21515
|
-
|
|
21516
|
-
|
|
21517
|
-
|
|
21518
|
-
|
|
21519
|
-
|
|
21520
|
-
|
|
21521
|
-
|
|
22190
|
+
switch (decision.verdict) {
|
|
22191
|
+
case "suppressed":
|
|
22192
|
+
this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
22193
|
+
tags: { deviceId },
|
|
22194
|
+
meta
|
|
22195
|
+
});
|
|
22196
|
+
break;
|
|
22197
|
+
case "undecided":
|
|
22198
|
+
this.ctx.logger.info("confirmation gate: birth undecided", {
|
|
22199
|
+
tags: { deviceId },
|
|
22200
|
+
meta
|
|
22201
|
+
});
|
|
22202
|
+
break;
|
|
22203
|
+
case "confirmed":
|
|
22204
|
+
this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
22205
|
+
tags: { deviceId },
|
|
22206
|
+
meta
|
|
22207
|
+
});
|
|
22208
|
+
break;
|
|
22209
|
+
}
|
|
21522
22210
|
}
|
|
21523
|
-
});
|
|
22211
|
+
}, exhaustedIds);
|
|
21524
22212
|
}
|
|
21525
22213
|
async resolveGlobalFaceEnabled() {
|
|
21526
22214
|
return this.faceGlobalEnabledCache.get(() => this.faceGlobalEnabledState.get());
|