@camstack/addon-post-analysis 1.2.19 → 1.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CjwPOJKc.js");
5
+ const require_dist = require("../dist-vIJhE1KT.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -1441,6 +1441,94 @@ function resolveDetectionLabel(input) {
1441
1441
  if (top) return top;
1442
1442
  return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
1443
1443
  }
1444
+ var DEFAULT_ZONE_TRANSITION_CONFIG = {
1445
+ enterFrames: 2,
1446
+ exitFrames: 4
1447
+ };
1448
+ var NO_TRANSITIONS = {
1449
+ entered: [],
1450
+ exited: []
1451
+ };
1452
+ function newState() {
1453
+ return {
1454
+ inside: /* @__PURE__ */ new Set(),
1455
+ enterStreak: /* @__PURE__ */ new Map(),
1456
+ exitStreak: /* @__PURE__ */ new Map()
1457
+ };
1458
+ }
1459
+ var ZoneTransitionTracker = class {
1460
+ config;
1461
+ tracks = /* @__PURE__ */ new Map();
1462
+ constructor(config = {}) {
1463
+ this.config = {
1464
+ ...DEFAULT_ZONE_TRANSITION_CONFIG,
1465
+ ...config
1466
+ };
1467
+ }
1468
+ /**
1469
+ * Feed one frame's zone membership for one track.
1470
+ *
1471
+ * `currentZoneIds` is the set the geometry says the box is in RIGHT NOW; the
1472
+ * return value is only what has persisted long enough to be believed.
1473
+ */
1474
+ observe(trackId, currentZoneIds) {
1475
+ const state = this.tracks.get(trackId) ?? newState();
1476
+ this.tracks.set(trackId, state);
1477
+ const now = new Set(currentZoneIds);
1478
+ const entered = [];
1479
+ const exited = [];
1480
+ for (const zoneId of now) {
1481
+ if (state.inside.has(zoneId)) {
1482
+ state.exitStreak.delete(zoneId);
1483
+ continue;
1484
+ }
1485
+ const streak = (state.enterStreak.get(zoneId) ?? 0) + 1;
1486
+ if (streak >= this.config.enterFrames) {
1487
+ state.enterStreak.delete(zoneId);
1488
+ state.inside.add(zoneId);
1489
+ entered.push(zoneId);
1490
+ } else state.enterStreak.set(zoneId, streak);
1491
+ }
1492
+ for (const zoneId of [...state.enterStreak.keys()]) if (!now.has(zoneId)) state.enterStreak.delete(zoneId);
1493
+ for (const zoneId of [...state.inside]) {
1494
+ if (now.has(zoneId)) continue;
1495
+ const streak = (state.exitStreak.get(zoneId) ?? 0) + 1;
1496
+ if (streak >= this.config.exitFrames) {
1497
+ state.exitStreak.delete(zoneId);
1498
+ state.inside.delete(zoneId);
1499
+ exited.push(zoneId);
1500
+ } else state.exitStreak.set(zoneId, streak);
1501
+ }
1502
+ if (entered.length === 0 && exited.length === 0) return NO_TRANSITIONS;
1503
+ return {
1504
+ entered,
1505
+ exited
1506
+ };
1507
+ }
1508
+ /** Zones a track is currently confirmed to be inside. */
1509
+ zonesFor(trackId) {
1510
+ const state = this.tracks.get(trackId);
1511
+ return state ? [...state.inside] : [];
1512
+ }
1513
+ /**
1514
+ * Drop a track and report the zones it was still inside.
1515
+ *
1516
+ * The caller uses these to close the subject's presence — a track that
1517
+ * disappears mid-zone has genuinely left it, and nothing else will ever say
1518
+ * so. Without this, a zone entry would have no matching exit whenever the
1519
+ * subject walked out of frame rather than out of the zone.
1520
+ */
1521
+ forget(trackId) {
1522
+ const state = this.tracks.get(trackId);
1523
+ if (!state) return [];
1524
+ this.tracks.delete(trackId);
1525
+ return [...state.inside];
1526
+ }
1527
+ /** Number of tracks held — a leak check for the caller's tests. */
1528
+ size() {
1529
+ return this.tracks.size;
1530
+ }
1531
+ };
1444
1532
  //#endregion
1445
1533
  //#region src/pipeline-analytics/pipeline/zones/geometry.ts
1446
1534
  /** Ray-casting point-in-polygon test */
@@ -2319,17 +2407,31 @@ var ZoneEngine = class {
2319
2407
  * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
2320
2408
  */
2321
2409
  annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2410
+ return [...this.splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap).memberships];
2411
+ }
2412
+ /**
2413
+ * {@link annotateDetection}, keeping the zones the bar REJECTED as well as
2414
+ * the ones it admitted. Same geometry, one pass — see
2415
+ * {@link ZoneMembershipSplit} for why the rejected side has to survive.
2416
+ */
2417
+ splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2322
2418
  const memberships = [];
2419
+ const belowBar = [];
2323
2420
  for (const zone of zones) {
2324
2421
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
2325
2422
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
2326
- if (overlap > minOverlap) memberships.push({
2423
+ const entry = {
2327
2424
  zoneId: zone.id,
2328
2425
  zoneName: zone.name,
2329
2426
  overlap
2330
- });
2427
+ };
2428
+ if (overlap > minOverlap) memberships.push(entry);
2429
+ else if (overlap > 0) belowBar.push(entry);
2331
2430
  }
2332
- return memberships;
2431
+ return {
2432
+ memberships,
2433
+ belowBar
2434
+ };
2333
2435
  }
2334
2436
  /**
2335
2437
  * Filter detections through a zone-rule set. `zones` provides the
@@ -2390,6 +2492,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
2390
2492
  return false;
2391
2493
  }
2392
2494
  //#endregion
2495
+ //#region src/pipeline-analytics/pipeline/rider-pairing.ts
2496
+ /**
2497
+ * Fine classes that carry a rider. A scooter/moped reaches us as
2498
+ * `motorcycle` (COCO) or `motorbike` depending on the model's label set.
2499
+ */
2500
+ var TWO_WHEELERS = new Set([
2501
+ "bicycle",
2502
+ "motorcycle",
2503
+ "motorbike"
2504
+ ]);
2505
+ /** Intersection area of two boxes. */
2506
+ function intersection(a, b) {
2507
+ return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
2508
+ }
2509
+ /** True when `vehicle` is a two-wheeler that `person` is riding. */
2510
+ function isRiderPair(person, vehicle) {
2511
+ if (person.macroClass !== "person") return false;
2512
+ if (vehicle.macroClass !== "vehicle") return false;
2513
+ if (!TWO_WHEELERS.has((vehicle.originalClass ?? "").toLowerCase())) return false;
2514
+ const personArea = person.bbox.w * person.bbox.h;
2515
+ if (!(personArea > 0)) return false;
2516
+ if (intersection(person.bbox, vehicle.bbox) / personArea < .25) return false;
2517
+ return person.bbox.y + person.bbox.h / 2 < vehicle.bbox.y + vehicle.bbox.h / 2;
2518
+ }
2519
+ /**
2520
+ * Pair every rider with their machine. A person is paired at most once — with
2521
+ * the two-wheeler they overlap MOST — so two bikes side by side cannot both
2522
+ * claim the same rider.
2523
+ */
2524
+ function pairRiders(detections) {
2525
+ const people = detections.filter((d) => d.macroClass === "person");
2526
+ const twoWheelers = detections.filter((d) => d.macroClass === "vehicle" && TWO_WHEELERS.has((d.originalClass ?? "").toLowerCase()));
2527
+ if (people.length === 0 || twoWheelers.length === 0) return [];
2528
+ const pairs = [];
2529
+ const claimed = /* @__PURE__ */ new Set();
2530
+ for (const person of people) {
2531
+ let best = null;
2532
+ const personArea = person.bbox.w * person.bbox.h;
2533
+ if (!(personArea > 0)) continue;
2534
+ for (const vehicle of twoWheelers) {
2535
+ if (claimed.has(vehicle.id)) continue;
2536
+ if (!isRiderPair(person, vehicle)) continue;
2537
+ const overlap = intersection(person.bbox, vehicle.bbox) / personArea;
2538
+ if (!best || overlap > best.overlap) best = {
2539
+ vehicle,
2540
+ overlap
2541
+ };
2542
+ }
2543
+ if (best) {
2544
+ claimed.add(best.vehicle.id);
2545
+ pairs.push({
2546
+ personId: person.id,
2547
+ vehicleId: best.vehicle.id,
2548
+ overlap: Math.round(best.overlap * 1e3) / 1e3
2549
+ });
2550
+ }
2551
+ }
2552
+ return pairs;
2553
+ }
2554
+ //#endregion
2393
2555
  //#region src/pipeline-analytics/pipeline/frame-processor.ts
2394
2556
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
2395
2557
  * canonical TrackState enum used on tracks + events. */
@@ -2427,6 +2589,17 @@ var FrameProcessor = class {
2427
2589
  zoneMembershipMinOverlap;
2428
2590
  /** See {@link getLastZoneOverlaps}. */
2429
2591
  lastZoneOverlaps;
2592
+ /** See {@link getLastZoneRejections}. */
2593
+ lastZoneRejections;
2594
+ /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2595
+ zoneTransitions = new ZoneTransitionTracker();
2596
+ /**
2597
+ * Last zone membership seen per track, so an event for a track that is GONE
2598
+ * this frame still carries the zones it was in. See the use site.
2599
+ */
2600
+ lastZonesByTrack = /* @__PURE__ */ new Map();
2601
+ /** See {@link getLastRiderPairs}. */
2602
+ lastRiderPairs = [];
2430
2603
  zoneEngine = new ZoneEngine();
2431
2604
  /** Optional stationary-object gate (parked-object suppression). Null until
2432
2605
  * the addon wires it via {@link setStationaryGate}. */
@@ -2441,6 +2614,7 @@ var FrameProcessor = class {
2441
2614
  this.detectionRules = [];
2442
2615
  this.zoneMembershipMinOverlap = 0;
2443
2616
  this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2617
+ this.lastZoneRejections = /* @__PURE__ */ new Map();
2444
2618
  }
2445
2619
  setZones(zones) {
2446
2620
  this.zones = zones;
@@ -2458,6 +2632,12 @@ var FrameProcessor = class {
2458
2632
  setZoneMembershipMinOverlap(minOverlap) {
2459
2633
  this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2460
2634
  }
2635
+ /** Rider pairings folded on the most recent frame — the PERSON half that was
2636
+ * dropped so the passage counts once, as a vehicle. Reported so both
2637
+ * entities stay on the record rather than one silently disappearing. */
2638
+ getLastRiderPairs() {
2639
+ return this.lastRiderPairs;
2640
+ }
2461
2641
  /** Per-track zone memberships WITH their overlap fractions, from the most
2462
2642
  * recent frame. The engine computes these and the pipeline previously
2463
2643
  * discarded everything but the ids — which is why no amount of production
@@ -2465,6 +2645,13 @@ var FrameProcessor = class {
2465
2645
  getLastZoneOverlaps() {
2466
2646
  return this.lastZoneOverlaps;
2467
2647
  }
2648
+ /** Per-track zones the membership bar REJECTED (0 < overlap ≤ the bar), from
2649
+ * the most recent frame. Raising the bar otherwise makes the stamping log go
2650
+ * quiet for exactly the cases it was armed to measure — see
2651
+ * `ZoneMembershipSplit` in `zones/zone-engine.ts`. */
2652
+ getLastZoneRejections() {
2653
+ return this.lastZoneRejections;
2654
+ }
2468
2655
  setDetectionRules(rules) {
2469
2656
  this.detectionRules = rules;
2470
2657
  }
@@ -2557,7 +2744,24 @@ var FrameProcessor = class {
2557
2744
  });
2558
2745
  }
2559
2746
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
2560
- const filteredDetections = passed.map((fd) => fd.detection);
2747
+ let filteredDetections = passed.map((fd) => fd.detection);
2748
+ const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
2749
+ id: String(i),
2750
+ macroClass: d.class,
2751
+ ...d.originalClass !== void 0 ? { originalClass: d.originalClass } : {},
2752
+ bbox: d.bbox,
2753
+ score: d.score
2754
+ })));
2755
+ if (riderPairs.length > 0) {
2756
+ const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
2757
+ this.lastRiderPairs = riderPairs.map((p) => ({
2758
+ overlap: p.overlap,
2759
+ personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
2760
+ vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
2761
+ vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
2762
+ }));
2763
+ filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
2764
+ } else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
2561
2765
  const gate = this.stationaryGate ? this.stationaryGate.filter({
2562
2766
  detections: filteredDetections,
2563
2767
  frameWidth,
@@ -2573,16 +2777,47 @@ var FrameProcessor = class {
2573
2777
  frameHeight
2574
2778
  });
2575
2779
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2576
- const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2577
2780
  const zonesByTrack = /* @__PURE__ */ new Map();
2578
2781
  const overlapsByTrack = /* @__PURE__ */ new Map();
2782
+ const rejectedByTrack = /* @__PURE__ */ new Map();
2579
2783
  for (const td of trackedDetections) {
2580
2784
  const m = maskByBbox.get(td.bbox);
2581
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2785
+ const { memberships, belowBar } = this.zoneEngine.splitDetectionZones(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2582
2786
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2583
2787
  if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2788
+ if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
2584
2789
  }
2585
2790
  this.lastZoneOverlaps = overlapsByTrack;
2791
+ this.lastZoneRejections = rejectedByTrack;
2792
+ for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2793
+ for (const state of objectStates) {
2794
+ if (zonesByTrack.has(state.trackId)) continue;
2795
+ const remembered = this.lastZonesByTrack.get(state.trackId);
2796
+ if (remembered && remembered.length > 0) zonesByTrack.set(state.trackId, remembered);
2797
+ }
2798
+ const zoneEvents = [];
2799
+ const pushZoneEvent = (type, zoneId, detection) => {
2800
+ const zone = this.zones.find((z) => z.id === zoneId);
2801
+ zoneEvents.push({
2802
+ type,
2803
+ zoneId,
2804
+ zoneName: zone?.name ?? zoneId,
2805
+ trackId: detection.trackId,
2806
+ detection,
2807
+ timestamp
2808
+ });
2809
+ };
2810
+ for (const td of trackedDetections) {
2811
+ const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2812
+ for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2813
+ for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2814
+ }
2815
+ const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2816
+ for (const state of objectStates) {
2817
+ if (state.state !== "leaving") continue;
2818
+ this.zoneTransitions.forget(state.trackId);
2819
+ this.lastZonesByTrack.delete(state.trackId);
2820
+ }
2586
2821
  const tracked = trackedDetections.map((td) => {
2587
2822
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
2588
2823
  const label = resolveDetectionLabel({
@@ -2620,10 +2855,22 @@ var FrameProcessor = class {
2620
2855
  } : {}
2621
2856
  };
2622
2857
  });
2858
+ const crossingOf = (e) => {
2859
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2860
+ if (direction === void 0) return void 0;
2861
+ const ze = e.zoneEvents[0];
2862
+ if (ze === void 0) return void 0;
2863
+ return {
2864
+ direction,
2865
+ zoneId: ze.zoneId,
2866
+ zoneName: ze.zoneName
2867
+ };
2868
+ };
2623
2869
  const toObjectEvent = (e, forcedState) => {
2624
2870
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
2625
2871
  const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
2626
2872
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
2873
+ const crossing = crossingOf(e);
2627
2874
  const label = td ? resolveDetectionLabel({
2628
2875
  className: td.class,
2629
2876
  originalClass: td.originalClass,
@@ -2647,6 +2894,7 @@ var FrameProcessor = class {
2647
2894
  },
2648
2895
  zones,
2649
2896
  state,
2897
+ ...crossing !== void 0 ? { zoneCrossing: crossing } : {},
2650
2898
  ...label ? { label } : {},
2651
2899
  frameWidth,
2652
2900
  frameHeight
@@ -3545,7 +3793,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3545
3793
  * for the full contract.
3546
3794
  */
3547
3795
  function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3548
- if (input.hasMedia) return "persist";
3796
+ if (input.hasBestMedia) return "persist";
3797
+ if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
3549
3798
  if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3550
3799
  if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3551
3800
  return input.hasRasterFallback ? "raster-fallback" : "persist";
@@ -3739,9 +3988,11 @@ var TrackCloser = class {
3739
3988
  const duration = t.lastSeen - t.firstSeen;
3740
3989
  const closure = this.deps.residents.closure(t.trackId);
3741
3990
  const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
3991
+ const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
3742
3992
  const outcome = decideZeroMediaPolicy({
3743
3993
  durationMs: duration,
3744
3994
  hasMedia: ownedMedia.length > 0,
3995
+ hasBestMedia,
3745
3996
  confirmed: closure?.confirmed ?? false,
3746
3997
  hasRasterFallback: closure?.rasterFallback !== void 0
3747
3998
  });
@@ -4083,6 +4334,7 @@ function subjectFromObjectEvent(ev) {
4083
4334
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
4084
4335
  source: ev.source ?? "pipeline",
4085
4336
  ...ev.importance !== void 0 ? { importance: ev.importance } : {},
4337
+ ...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
4086
4338
  ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
4087
4339
  };
4088
4340
  }
@@ -4249,6 +4501,7 @@ function presentConditionIds(c) {
4249
4501
  if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
4250
4502
  if (c.zones !== void 0) ids.push("zones");
4251
4503
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
4504
+ if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
4252
4505
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
4253
4506
  if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
4254
4507
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
@@ -4284,6 +4537,21 @@ function matchesOccupancy(occ, s) {
4284
4537
  case "<=": return s.occupied === false && s.threshold === occ.count + 1;
4285
4538
  }
4286
4539
  }
4540
+ /**
4541
+ * The zone ids a `zones` / `zonesExclude` condition tests against: the
4542
+ * record's membership PLUS the zone it crossed, when it is a crossing.
4543
+ *
4544
+ * The union is what makes an EXIT addressable. Membership is computed from the
4545
+ * box's current geometry, so on the frame an exit is confirmed the subject is
4546
+ * by definition no longer in the zone — a rule scoped to "Uscio" would never
4547
+ * see the exit from Uscio. An ENTRY's zone is already in the membership, so
4548
+ * this changes nothing for every rule that exists today.
4549
+ */
4550
+ function zonesVisitedBy(subject) {
4551
+ const visited = new Set(subject.zones);
4552
+ if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
4553
+ return visited;
4554
+ }
4287
4555
  function toLowerSet(values) {
4288
4556
  return new Set(values.map((v) => v.trim().toLowerCase()));
4289
4557
  }
@@ -4369,14 +4637,20 @@ function evaluateRule(rule, subject) {
4369
4637
  if (c.minDwellSeconds !== void 0) {
4370
4638
  if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
4371
4639
  }
4640
+ if (c.crossing !== "any") {
4641
+ const direction = subject.crossing?.direction;
4642
+ if (c.crossing === "exit") {
4643
+ if (direction !== "exit") return fail("crossing");
4644
+ } else if (direction === "exit") return fail("crossing");
4645
+ }
4372
4646
  if (c.zones !== void 0) {
4373
- const visited = new Set(subject.zones);
4647
+ const visited = zonesVisitedBy(subject);
4374
4648
  if (c.zones.match === "all") {
4375
4649
  for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
4376
4650
  } else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
4377
4651
  }
4378
4652
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
4379
- const visited = new Set(subject.zones);
4653
+ const visited = zonesVisitedBy(subject);
4380
4654
  if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
4381
4655
  }
4382
4656
  if (c.customZones !== void 0 && c.customZones.length > 0) {
@@ -4505,10 +4779,28 @@ function matchesPlate(label, values, maxDistance) {
4505
4779
  for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
4506
4780
  return false;
4507
4781
  }
4508
- /** Stable cooldown key per the rule's throttle scope. */
4782
+ /**
4783
+ * Should this subject's class be part of the cooldown key?
4784
+ *
4785
+ * AUDIO is unconditional and predates the setting: a rule opted into several
4786
+ * audio classes (dog + scream) must not have a safety-relevant scream
4787
+ * swallowed by an unrelated bark's 60 s window.
4788
+ *
4789
+ * Every other subject gets the same treatment only when the rule ASKS for it
4790
+ * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
4791
+ * Absent / `shared` reproduces the class-agnostic key byte for byte, because
4792
+ * changing how often an operator's existing rules fire is not a side effect
4793
+ * anyone asked for.
4794
+ */
4795
+ function keysPerClass(rule, subject) {
4796
+ if (subject.kind === "audio-event") return true;
4797
+ return rule.throttle.granularity === "per-class";
4798
+ }
4799
+ /** Stable cooldown key per the rule's throttle scope + class granularity. */
4509
4800
  function cooldownKey(rule, subject) {
4510
- const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4511
- return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
4801
+ const first = subject.classNames[0];
4802
+ const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
4803
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:d:${subject.deviceId}${classKey}`;
4512
4804
  }
4513
4805
  /** True when the rule fired within its cooldown window before `now`. */
4514
4806
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -6298,6 +6590,13 @@ var TimelapseStore = class {
6298
6590
  };
6299
6591
  //#endregion
6300
6592
  //#region src/notification-center/index.ts
6593
+ /**
6594
+ * How often the "matched NO rule" report may fire per device. Long enough that
6595
+ * a busy camera prints one line rather than one per event, short enough that a
6596
+ * rule which has stopped matching is visible within minutes rather than by
6597
+ * comparison with another system fifteen hours later.
6598
+ */
6599
+ var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
6301
6600
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
6302
6601
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
6303
6602
  var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
@@ -6408,6 +6707,9 @@ var NotificationCenter = class NotificationCenter {
6408
6707
  occupancyEnabled = false;
6409
6708
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
6410
6709
  lastFiredAt = /* @__PURE__ */ new Map();
6710
+ /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
6711
+ lastNoMatchReportAt = /* @__PURE__ */ new Map();
6712
+ noMatchSuppressed = /* @__PURE__ */ new Map();
6411
6713
  /**
6412
6714
  * Serialized evaluation chain. The persist hooks are fire-and-forget for
6413
6715
  * the frame path, but two concurrent evaluations of the same device
@@ -6739,9 +7041,12 @@ var NotificationCenter = class NotificationCenter {
6739
7041
  return;
6740
7042
  }
6741
7043
  const now = this.now();
7044
+ let anyMatched = false;
7045
+ const rejections = [];
6742
7046
  for (const rule of candidates) {
6743
7047
  const evaluation = evaluateRule(rule, subject);
6744
7048
  if (!evaluation.matched) {
7049
+ rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
6745
7050
  this.logger.debug("rule did not match", {
6746
7051
  tags: { deviceId: subject.deviceId },
6747
7052
  meta: {
@@ -6785,7 +7090,33 @@ var NotificationCenter = class NotificationCenter {
6785
7090
  });
6786
7091
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6787
7092
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7093
+ anyMatched = true;
7094
+ }
7095
+ if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
7096
+ }
7097
+ /** See the call site. Bounded to one line per device per window. */
7098
+ reportNoMatch(subject, kind, rejections, now) {
7099
+ const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
7100
+ const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
7101
+ if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
7102
+ this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
7103
+ return;
6788
7104
  }
7105
+ this.lastNoMatchReportAt.set(subject.deviceId, now);
7106
+ this.noMatchSuppressed.set(subject.deviceId, 0);
7107
+ this.logger.info("event matched NO rule", {
7108
+ tags: { deviceId: subject.deviceId },
7109
+ meta: {
7110
+ kind,
7111
+ rejectedBy: rejections,
7112
+ classes: subject.classNames,
7113
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
7114
+ ...subject.zones.length > 0 ? { zones: subject.zones } : { zones: [] },
7115
+ eventId: subject.recordId,
7116
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
7117
+ ...suppressed > 0 ? { alsoSuppressedSinceLastReport: suppressed } : {}
7118
+ }
7119
+ });
6789
7120
  }
6790
7121
  buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6791
7122
  const hasEventMedia = kind === "object-event" || kind === "package-event";
@@ -7880,6 +8211,63 @@ function classifyTrackAppearance(input) {
7880
8211
  return input.positionsCount > 1 ? "resurrection" : "birth";
7881
8212
  }
7882
8213
  //#endregion
8214
+ //#region src/pipeline-analytics/pipeline/suppressed-births.ts
8215
+ /**
8216
+ * Memory of births the confirmation gate rejected, per device.
8217
+ *
8218
+ * The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
8219
+ * position count is what distinguishes a true birth from a tracker
8220
+ * resurrection. So a rejected birth has already been written by the time the
8221
+ * verdict exists. It correctly gets no `start`, no media and no notification —
8222
+ * but the record stayed, and surfaced in `listRecentTracks`. On 2026-07-31 six
8223
+ * full-frame phantoms on device 615 in nine minutes were every one of them
8224
+ * suppressed AND listed.
8225
+ *
8226
+ * Retracting once is not enough. The tracker keeps carrying a rejected id for
8227
+ * as long as the phantom persists — 27 seconds in the worst measured case — and
8228
+ * every later frame would write it straight back. So the rejection is
8229
+ * remembered until the id stops being tracked, and only then forgotten.
8230
+ *
8231
+ * Kept as its own module because the state is the part with real risk: forget
8232
+ * too early and the phantom returns, never forget and a camera producing
8233
+ * phantoms continuously grows this set for the process's lifetime.
8234
+ */
8235
+ var SuppressedBirthRegistry = class {
8236
+ byDevice = /* @__PURE__ */ new Map();
8237
+ /** Record that this birth was rejected; later frames must not re-upsert it. */
8238
+ reject(deviceKey, trackId) {
8239
+ let ids = this.byDevice.get(deviceKey);
8240
+ if (!ids) {
8241
+ ids = /* @__PURE__ */ new Set();
8242
+ this.byDevice.set(deviceKey, ids);
8243
+ }
8244
+ ids.add(trackId);
8245
+ }
8246
+ isRejected(deviceKey, trackId) {
8247
+ return this.byDevice.get(deviceKey)?.has(trackId) === true;
8248
+ }
8249
+ /**
8250
+ * Forget every rejected id the tracker is no longer carrying.
8251
+ *
8252
+ * Called once per frame with the ids present THIS frame. An id absent from
8253
+ * that set can never be upserted again, so holding it serves nothing.
8254
+ */
8255
+ retain(deviceKey, currentTrackIds) {
8256
+ const ids = this.byDevice.get(deviceKey);
8257
+ if (!ids) return;
8258
+ for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
8259
+ if (ids.size === 0) this.byDevice.delete(deviceKey);
8260
+ }
8261
+ /** Rejected ids currently held for a device — diagnostics and tests. */
8262
+ size(deviceKey) {
8263
+ return this.byDevice.get(deviceKey)?.size ?? 0;
8264
+ }
8265
+ /** Drop a device's memory wholesale (device removed / pipeline reset). */
8266
+ clearDevice(deviceKey) {
8267
+ this.byDevice.delete(deviceKey);
8268
+ }
8269
+ };
8270
+ //#endregion
7883
8271
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
7884
8272
  async function rankKeyEvents(candidates, options, peakLookup) {
7885
8273
  const scored = [];
@@ -8384,9 +8772,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
8384
8772
  * padding === 0 is the identity operation. Pure, immutable — always returns a
8385
8773
  * new object.
8386
8774
  *
8387
- * NOTE: clamping is single-sided when the box hugs an edge, the origin is
8388
- * clamped to 0/keeps the full padded extent against the opposite bound, so the
8389
- * crop can extend slightly further on the far side than the symmetric padding
8775
+ * NOTE: clamping SHIFTS, it does not truncate a window that overflows a
8776
+ * bound slides inward and keeps the extent that was asked for, on either side.
8777
+ * It shrinks only when the padded window is larger than the frame itself
8390
8778
  * would (e.g. a left-hugging box gains a bit more right-side context). This is
8391
8779
  * acceptable for detection crops (more context, never out of [0,1]); the
8392
8780
  * downstream `extractCrop` clamps to pixel bounds again regardless.
@@ -8396,13 +8784,13 @@ function padBbox(bbox, padding) {
8396
8784
  const rawY = bbox.y - padding * bbox.h;
8397
8785
  const rawW = bbox.w * (1 + 2 * padding);
8398
8786
  const rawH = bbox.h * (1 + 2 * padding);
8399
- const x = Math.max(0, rawX);
8400
- const y = Math.max(0, rawY);
8787
+ const w = Math.min(rawW, 1);
8788
+ const h = Math.min(rawH, 1);
8401
8789
  return {
8402
- x,
8403
- y,
8404
- w: Math.min(rawW, 1 - x),
8405
- h: Math.min(rawH, 1 - y)
8790
+ x: Math.min(Math.max(0, rawX), 1 - w),
8791
+ y: Math.min(Math.max(0, rawY), 1 - h),
8792
+ w,
8793
+ h
8406
8794
  };
8407
8795
  }
8408
8796
  //#endregion
@@ -12097,7 +12485,11 @@ function squareSubjectCropRegion(bbox, frame) {
12097
12485
  * cases). `C` becomes the middle square of the output.
12098
12486
  * 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
12099
12487
  * 3. anchor: place the canvas so `C` is its horizontal middle →
12100
- * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0).
12488
+ * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
12489
+ * then CLAMP the window fully inside the frame whenever it fits. The
12490
+ * subject therefore drifts off-centre near a frame edge and the output
12491
+ * carries no padding at all. Centring is a preference; containing the
12492
+ * subject is the contract, and clamping cannot break it.
12101
12493
  * 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
12102
12494
  * `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
12103
12495
  * the vertical extent is always in-frame). Its canvas offset is
@@ -12109,13 +12501,8 @@ function wideCentralSquareLayout(bbox, frame) {
12109
12501
  const canvasW = Math.round(c * 16 / 9);
12110
12502
  const centralX0 = Math.round((canvasW - c) / 2);
12111
12503
  let frameOriginX = central.x - (canvasW - c) / 2;
12112
- if (canvasW <= frame.W) {
12113
- const slideRightMax = Math.max(0, bbox.x - central.x);
12114
- const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
12115
- if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
12116
- const overRight = frameOriginX + canvasW - frame.W;
12117
- if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
12118
- } else frameOriginX = (frame.W - canvasW) / 2;
12504
+ if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
12505
+ else frameOriginX = (frame.W - canvasW) / 2;
12119
12506
  const fxa = Math.max(0, frameOriginX);
12120
12507
  const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
12121
12508
  const slabOffsetX = fxa - frameOriginX;
@@ -12164,10 +12551,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
12164
12551
  /**
12165
12552
  * Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
12166
12553
  * in-frame slab fetched for `layout`. The slab is placed at its computed offset
12167
- * on a 16:9 canvas; any lateral part of the window outside the frame (a subject
12168
- * flush against a frame edge the geometry already slides the window in-frame
12169
- * wherever the subject has slack) is filled with a BLURRED, dimmed stretch of
12170
- * the slab itself instead of dead black bars (operator triage 2026-07-22).
12554
+ * on a 16:9 canvas; any lateral part of the window outside the frame is filled
12555
+ * with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
12556
+ * (operator triage 2026-07-22).
12557
+ *
12558
+ * **That fill is now nearly unreachable, and deliberately so.** The geometry
12559
+ * clamps the window fully inside the frame whenever it fits, so a subject
12560
+ * against a frame edge yields real pixels off-centre rather than ambience
12561
+ * (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
12562
+ * still pads — there is no more scene to slide into — which is why this code
12563
+ * stays. If you are looking at a blurred band in a best shot, the window was
12564
+ * wider than the frame; do not go looking for a sliding bug.
12171
12565
  * When the whole window is in-frame (`slab` already spans the full canvas) the
12172
12566
  * slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
12173
12567
  * the canvas is at the slab's native scale; only the out-of-frame ambience fill
@@ -13290,6 +13684,13 @@ var ZoneAnalyticsProvider = class {
13290
13684
  zones: snapshot.zones.length
13291
13685
  }
13292
13686
  });
13687
+ this.ctx.emitOccupancyChanged?.({
13688
+ deviceId: input.deviceId,
13689
+ timestamp: input.timestamp,
13690
+ totalObjects: total,
13691
+ byClass: snapshot.frame.byClass,
13692
+ zones: snapshot.zones.length
13693
+ });
13293
13694
  }
13294
13695
  this.snapshots.set(input.deviceId, snapshot);
13295
13696
  this.appendHistory(input.deviceId, snapshot);
@@ -15022,6 +15423,15 @@ function buildDetectionSettingsSections() {
15022
15423
  step: .05,
15023
15424
  default: TRACKING_DEFAULTS.rescueIouThreshold
15024
15425
  },
15426
+ {
15427
+ type: "number",
15428
+ key: "rescueCentroidFactor",
15429
+ label: "Rescue centroid distance",
15430
+ description: "The other half of the rescue gate: how far a detection’s centre may sit from the track’s last-known centre, as a fraction of the box diagonal. A rescue must pass BOTH this and the rescue IoU, so raising the IoU alone does not tighten re-attachment.",
15431
+ min: 0,
15432
+ step: .05,
15433
+ default: TRACKING_DEFAULTS.rescueCentroidFactor
15434
+ },
15025
15435
  {
15026
15436
  type: "number",
15027
15437
  key: "stationarySpeedPx",
@@ -15303,6 +15713,16 @@ function buildDetectionSettingsSections() {
15303
15713
  label: "Person/animal group matching",
15304
15714
  description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15305
15715
  default: TRACKING_DEFAULTS.classGroupAssoc
15716
+ },
15717
+ {
15718
+ type: "number",
15719
+ key: "zoneMembershipMinOverlap",
15720
+ label: "Zone membership minimum overlap",
15721
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
15722
+ min: 0,
15723
+ max: 1,
15724
+ step: .05,
15725
+ default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
15306
15726
  }
15307
15727
  ]
15308
15728
  },
@@ -19872,6 +20292,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19872
20292
  * dataPlane facility in the current environment). */
19873
20293
  eventMediaBaseUrl = null;
19874
20294
  lastActiveTrackIds = /* @__PURE__ */ new Map();
20295
+ /** See `pipeline/suppressed-births.ts` — rejected births must not be
20296
+ * re-upserted, and must be forgotten when the tracker drops the id. */
20297
+ suppressedBirths = new SuppressedBirthRegistry();
19875
20298
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
19876
20299
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
19877
20300
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -20173,7 +20596,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20173
20596
  let storage = this.ctx.kernel.storage;
20174
20597
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20175
20598
  if (mediaRoot) {
20176
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
20599
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
20177
20600
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20178
20601
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20179
20602
  }
@@ -20455,6 +20878,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20455
20878
  const zoneAnalytics = new ZoneAnalyticsProvider({
20456
20879
  logger: logger.child("ZoneAnalytics"),
20457
20880
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
20881
+ emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
20882
+ id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
20883
+ timestamp: new Date(payload.timestamp),
20884
+ source: {
20885
+ type: "addon",
20886
+ id: "pipeline-analytics",
20887
+ addonId: "pipeline-analytics"
20888
+ },
20889
+ category: require_dist.EventCategory.ZoneAnalyticsOccupancyChanged,
20890
+ data: {
20891
+ deviceId: payload.deviceId,
20892
+ totalObjects: payload.totalObjects,
20893
+ byClass: payload.byClass,
20894
+ zones: payload.zones
20895
+ }
20896
+ }),
20458
20897
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
20459
20898
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
20460
20899
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
@@ -20902,6 +21341,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20902
21341
  timestamp: frame.timestamp,
20903
21342
  frame
20904
21343
  });
21344
+ for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
21345
+ tags: { deviceId },
21346
+ meta: {
21347
+ vehicleClass: r.vehicleClass,
21348
+ personScore: r.personScore,
21349
+ vehicleScore: r.vehicleScore,
21350
+ overlap: r.overlap
21351
+ }
21352
+ });
20905
21353
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
20906
21354
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
20907
21355
  deviceId,
@@ -20932,6 +21380,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20932
21380
  const positionsCountById = /* @__PURE__ */ new Map();
20933
21381
  for (const t of result.tracked) {
20934
21382
  currentTrackIds.add(t.trackId);
21383
+ if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
20935
21384
  const center = {
20936
21385
  x: t.bbox.x + t.bbox.w / 2,
20937
21386
  y: t.bbox.y + t.bbox.h / 2
@@ -20998,7 +21447,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20998
21447
  frameHeight: result.frameHeight
20999
21448
  });
21000
21449
  for (const { id, t } of bornCandidates) {
21001
- if (!confirmedBirths.has(id)) continue;
21450
+ if (!confirmedBirths.has(id)) {
21451
+ this.suppressedBirths.reject(key, id);
21452
+ this.trackStore?.dropActive(id);
21453
+ log.info("birth suppressed — track record retracted", { meta: {
21454
+ trackId: id,
21455
+ className: t.className,
21456
+ source
21457
+ } });
21458
+ continue;
21459
+ }
21002
21460
  newTrackCount += 1;
21003
21461
  log.info("track started", { meta: {
21004
21462
  trackId: id,
@@ -21059,6 +21517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21059
21517
  source
21060
21518
  } });
21061
21519
  }
21520
+ this.suppressedBirths.retain(key, currentTrackIds);
21062
21521
  this.lastActiveTrackIds.set(key, currentTrackIds);
21063
21522
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
21064
21523
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
@@ -21130,6 +21589,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21130
21589
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
21131
21590
  if (this.notificationCenter !== null) {
21132
21591
  const overlaps = processor.getLastZoneOverlaps();
21592
+ const rejections = processor.getLastZoneRejections();
21133
21593
  for (const e of result.objectEvents) {
21134
21594
  if (e.zones && e.zones.length > 0) {
21135
21595
  const m = e.trackId ? overlaps.get(e.trackId) : void 0;
@@ -21148,6 +21608,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21148
21608
  }
21149
21609
  });
21150
21610
  }
21611
+ const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
21612
+ if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
21613
+ tags: { deviceId },
21614
+ meta: {
21615
+ eventId: e.id,
21616
+ trackId: e.trackId,
21617
+ className: e.className,
21618
+ minOverlap: trk.zoneMembershipMinOverlap,
21619
+ stamped: e.zones?.length ?? 0,
21620
+ zones: rejected.map((z) => ({
21621
+ id: z.zoneId,
21622
+ name: z.zoneName,
21623
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21624
+ }))
21625
+ }
21626
+ });
21151
21627
  this.notificationCenter.onObjectEventPersisted(e);
21152
21628
  }
21153
21629
  }