@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.
@@ -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-C41w6Xvl.mjs";
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
- if (overlap > minOverlap) memberships.push({
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 memberships;
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
- const filteredDetections = passed.map((fd) => fd.detection);
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.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
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.hasMedia) return "persist";
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 = new Set(subject.zones);
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 = new Set(subject.zones);
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
- /** Stable cooldown key per the rule's throttle scope. */
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 audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4505
- return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
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;
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;
6782
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,63 @@ 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
7877
8265
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
7878
8266
  async function rankKeyEvents(candidates, options, peakLookup) {
7879
8267
  const scored = [];
@@ -8378,9 +8766,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
8378
8766
  * padding === 0 is the identity operation. Pure, immutable — always returns a
8379
8767
  * new object.
8380
8768
  *
8381
- * NOTE: clamping is single-sided when the box hugs an edge, the origin is
8382
- * clamped to 0/keeps the full padded extent against the opposite bound, so the
8383
- * crop can extend slightly further on the far side than the symmetric padding
8769
+ * NOTE: clamping SHIFTS, it does not truncate a window that overflows a
8770
+ * bound slides inward and keeps the extent that was asked for, on either side.
8771
+ * It shrinks only when the padded window is larger than the frame itself
8384
8772
  * would (e.g. a left-hugging box gains a bit more right-side context). This is
8385
8773
  * acceptable for detection crops (more context, never out of [0,1]); the
8386
8774
  * downstream `extractCrop` clamps to pixel bounds again regardless.
@@ -8390,13 +8778,13 @@ function padBbox(bbox, padding) {
8390
8778
  const rawY = bbox.y - padding * bbox.h;
8391
8779
  const rawW = bbox.w * (1 + 2 * padding);
8392
8780
  const rawH = bbox.h * (1 + 2 * padding);
8393
- const x = Math.max(0, rawX);
8394
- const y = Math.max(0, rawY);
8781
+ const w = Math.min(rawW, 1);
8782
+ const h = Math.min(rawH, 1);
8395
8783
  return {
8396
- x,
8397
- y,
8398
- w: Math.min(rawW, 1 - x),
8399
- h: Math.min(rawH, 1 - y)
8784
+ x: Math.min(Math.max(0, rawX), 1 - w),
8785
+ y: Math.min(Math.max(0, rawY), 1 - h),
8786
+ w,
8787
+ h
8400
8788
  };
8401
8789
  }
8402
8790
  //#endregion
@@ -12091,7 +12479,11 @@ function squareSubjectCropRegion(bbox, frame) {
12091
12479
  * cases). `C` becomes the middle square of the output.
12092
12480
  * 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
12093
12481
  * 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).
12482
+ * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
12483
+ * then CLAMP the window fully inside the frame whenever it fits. The
12484
+ * subject therefore drifts off-centre near a frame edge and the output
12485
+ * carries no padding at all. Centring is a preference; containing the
12486
+ * subject is the contract, and clamping cannot break it.
12095
12487
  * 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
12096
12488
  * `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
12097
12489
  * the vertical extent is always in-frame). Its canvas offset is
@@ -12103,13 +12495,8 @@ function wideCentralSquareLayout(bbox, frame) {
12103
12495
  const canvasW = Math.round(c * 16 / 9);
12104
12496
  const centralX0 = Math.round((canvasW - c) / 2);
12105
12497
  let frameOriginX = central.x - (canvasW - c) / 2;
12106
- if (canvasW <= frame.W) {
12107
- const slideRightMax = Math.max(0, bbox.x - central.x);
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;
12498
+ if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
12499
+ else frameOriginX = (frame.W - canvasW) / 2;
12113
12500
  const fxa = Math.max(0, frameOriginX);
12114
12501
  const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
12115
12502
  const slabOffsetX = fxa - frameOriginX;
@@ -12158,10 +12545,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
12158
12545
  /**
12159
12546
  * Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
12160
12547
  * 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 (a subject
12162
- * flush against a frame edge the geometry already slides the window in-frame
12163
- * wherever the subject has slack) is filled with a BLURRED, dimmed stretch of
12164
- * the slab itself instead of dead black bars (operator triage 2026-07-22).
12548
+ * on a 16:9 canvas; any lateral part of the window outside the frame is filled
12549
+ * with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
12550
+ * (operator triage 2026-07-22).
12551
+ *
12552
+ * **That fill is now nearly unreachable, and deliberately so.** The geometry
12553
+ * clamps the window fully inside the frame whenever it fits, so a subject
12554
+ * against a frame edge yields real pixels off-centre rather than ambience
12555
+ * (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
12556
+ * still pads — there is no more scene to slide into — which is why this code
12557
+ * stays. If you are looking at a blurred band in a best shot, the window was
12558
+ * wider than the frame; do not go looking for a sliding bug.
12165
12559
  * When the whole window is in-frame (`slab` already spans the full canvas) the
12166
12560
  * slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
12167
12561
  * the canvas is at the slab's native scale; only the out-of-frame ambience fill
@@ -13284,6 +13678,13 @@ var ZoneAnalyticsProvider = class {
13284
13678
  zones: snapshot.zones.length
13285
13679
  }
13286
13680
  });
13681
+ this.ctx.emitOccupancyChanged?.({
13682
+ deviceId: input.deviceId,
13683
+ timestamp: input.timestamp,
13684
+ totalObjects: total,
13685
+ byClass: snapshot.frame.byClass,
13686
+ zones: snapshot.zones.length
13687
+ });
13287
13688
  }
13288
13689
  this.snapshots.set(input.deviceId, snapshot);
13289
13690
  this.appendHistory(input.deviceId, snapshot);
@@ -15016,6 +15417,15 @@ function buildDetectionSettingsSections() {
15016
15417
  step: .05,
15017
15418
  default: TRACKING_DEFAULTS.rescueIouThreshold
15018
15419
  },
15420
+ {
15421
+ type: "number",
15422
+ key: "rescueCentroidFactor",
15423
+ label: "Rescue centroid distance",
15424
+ description: "The other half of the rescue gate: how far a detection’s centre may sit from the track’s last-known centre, as a fraction of the box diagonal. A rescue must pass BOTH this and the rescue IoU, so raising the IoU alone does not tighten re-attachment.",
15425
+ min: 0,
15426
+ step: .05,
15427
+ default: TRACKING_DEFAULTS.rescueCentroidFactor
15428
+ },
15019
15429
  {
15020
15430
  type: "number",
15021
15431
  key: "stationarySpeedPx",
@@ -15297,6 +15707,16 @@ function buildDetectionSettingsSections() {
15297
15707
  label: "Person/animal group matching",
15298
15708
  description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15299
15709
  default: TRACKING_DEFAULTS.classGroupAssoc
15710
+ },
15711
+ {
15712
+ type: "number",
15713
+ key: "zoneMembershipMinOverlap",
15714
+ label: "Zone membership minimum overlap",
15715
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
15716
+ min: 0,
15717
+ max: 1,
15718
+ step: .05,
15719
+ default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
15300
15720
  }
15301
15721
  ]
15302
15722
  },
@@ -19866,6 +20286,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19866
20286
  * dataPlane facility in the current environment). */
19867
20287
  eventMediaBaseUrl = null;
19868
20288
  lastActiveTrackIds = /* @__PURE__ */ new Map();
20289
+ /** See `pipeline/suppressed-births.ts` — rejected births must not be
20290
+ * re-upserted, and must be forgotten when the tracker drops the id. */
20291
+ suppressedBirths = new SuppressedBirthRegistry();
19869
20292
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
19870
20293
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
19871
20294
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -20449,6 +20872,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20449
20872
  const zoneAnalytics = new ZoneAnalyticsProvider({
20450
20873
  logger: logger.child("ZoneAnalytics"),
20451
20874
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
20875
+ emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
20876
+ id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
20877
+ timestamp: new Date(payload.timestamp),
20878
+ source: {
20879
+ type: "addon",
20880
+ id: "pipeline-analytics",
20881
+ addonId: "pipeline-analytics"
20882
+ },
20883
+ category: EventCategory.ZoneAnalyticsOccupancyChanged,
20884
+ data: {
20885
+ deviceId: payload.deviceId,
20886
+ totalObjects: payload.totalObjects,
20887
+ byClass: payload.byClass,
20888
+ zones: payload.zones
20889
+ }
20890
+ }),
20452
20891
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
20453
20892
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
20454
20893
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
@@ -20896,6 +21335,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20896
21335
  timestamp: frame.timestamp,
20897
21336
  frame
20898
21337
  });
21338
+ for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
21339
+ tags: { deviceId },
21340
+ meta: {
21341
+ vehicleClass: r.vehicleClass,
21342
+ personScore: r.personScore,
21343
+ vehicleScore: r.vehicleScore,
21344
+ overlap: r.overlap
21345
+ }
21346
+ });
20899
21347
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
20900
21348
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
20901
21349
  deviceId,
@@ -20926,6 +21374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20926
21374
  const positionsCountById = /* @__PURE__ */ new Map();
20927
21375
  for (const t of result.tracked) {
20928
21376
  currentTrackIds.add(t.trackId);
21377
+ if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
20929
21378
  const center = {
20930
21379
  x: t.bbox.x + t.bbox.w / 2,
20931
21380
  y: t.bbox.y + t.bbox.h / 2
@@ -20992,7 +21441,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20992
21441
  frameHeight: result.frameHeight
20993
21442
  });
20994
21443
  for (const { id, t } of bornCandidates) {
20995
- if (!confirmedBirths.has(id)) continue;
21444
+ if (!confirmedBirths.has(id)) {
21445
+ this.suppressedBirths.reject(key, id);
21446
+ this.trackStore?.dropActive(id);
21447
+ log.info("birth suppressed — track record retracted", { meta: {
21448
+ trackId: id,
21449
+ className: t.className,
21450
+ source
21451
+ } });
21452
+ continue;
21453
+ }
20996
21454
  newTrackCount += 1;
20997
21455
  log.info("track started", { meta: {
20998
21456
  trackId: id,
@@ -21053,6 +21511,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21053
21511
  source
21054
21512
  } });
21055
21513
  }
21514
+ this.suppressedBirths.retain(key, currentTrackIds);
21056
21515
  this.lastActiveTrackIds.set(key, currentTrackIds);
21057
21516
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
21058
21517
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
@@ -21124,6 +21583,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21124
21583
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
21125
21584
  if (this.notificationCenter !== null) {
21126
21585
  const overlaps = processor.getLastZoneOverlaps();
21586
+ const rejections = processor.getLastZoneRejections();
21127
21587
  for (const e of result.objectEvents) {
21128
21588
  if (e.zones && e.zones.length > 0) {
21129
21589
  const m = e.trackId ? overlaps.get(e.trackId) : void 0;
@@ -21142,6 +21602,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21142
21602
  }
21143
21603
  });
21144
21604
  }
21605
+ const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
21606
+ if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
21607
+ tags: { deviceId },
21608
+ meta: {
21609
+ eventId: e.id,
21610
+ trackId: e.trackId,
21611
+ className: e.className,
21612
+ minOverlap: trk.zoneMembershipMinOverlap,
21613
+ stamped: e.zones?.length ?? 0,
21614
+ zones: rejected.map((z) => ({
21615
+ id: z.zoneId,
21616
+ name: z.zoneName,
21617
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21618
+ }))
21619
+ }
21620
+ });
21145
21621
  this.notificationCenter.onObjectEventPersisted(e);
21146
21622
  }
21147
21623
  }