@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.
@@ -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;
6788
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;
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,137 @@ 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
8271
+ //#region src/pipeline-analytics/pipeline/deferred-births.ts
8272
+ var DeferredBirthRegistry = class {
8273
+ byDevice = /* @__PURE__ */ new Map();
8274
+ /** Record an undecided attempt. The first call registers; later calls count. */
8275
+ defer(deviceKey, trackId, nowMs) {
8276
+ let ids = this.byDevice.get(deviceKey);
8277
+ if (!ids) {
8278
+ ids = /* @__PURE__ */ new Map();
8279
+ this.byDevice.set(deviceKey, ids);
8280
+ }
8281
+ const existing = ids.get(trackId);
8282
+ if (existing) existing.attempts += 1;
8283
+ else ids.set(trackId, {
8284
+ firstSeenMs: nowMs,
8285
+ attempts: 1
8286
+ });
8287
+ }
8288
+ isDeferred(deviceKey, trackId) {
8289
+ return this.byDevice.get(deviceKey)?.has(trackId) === true;
8290
+ }
8291
+ attemptsFor(deviceKey, trackId) {
8292
+ return this.byDevice.get(deviceKey)?.get(trackId)?.attempts ?? 0;
8293
+ }
8294
+ /** Ms since the FIRST attempt, or 0 for an id this registry never saw. */
8295
+ elapsedMs(deviceKey, trackId, nowMs) {
8296
+ const entry = this.byDevice.get(deviceKey)?.get(trackId);
8297
+ return entry ? nowMs - entry.firstSeenMs : 0;
8298
+ }
8299
+ /** A verdict finally arrived — stop tracking it. */
8300
+ resolve(deviceKey, trackId) {
8301
+ const ids = this.byDevice.get(deviceKey);
8302
+ if (!ids) return;
8303
+ ids.delete(trackId);
8304
+ if (ids.size === 0) this.byDevice.delete(deviceKey);
8305
+ }
8306
+ /**
8307
+ * True when this birth has had enough tries, or waited long enough.
8308
+ *
8309
+ * Either bound ends the deferral: attempts alone would let a camera whose
8310
+ * frames arrive slowly hold a birth for minutes, and elapsed alone would let
8311
+ * a fast camera burn dozens of inference calls on one hopeless box.
8312
+ */
8313
+ exhausted(deviceKey, trackId, nowMs, maxAttempts, maxDeferralMs) {
8314
+ const entry = this.byDevice.get(deviceKey)?.get(trackId);
8315
+ if (!entry) return false;
8316
+ return entry.attempts >= maxAttempts || nowMs - entry.firstSeenMs >= maxDeferralMs;
8317
+ }
8318
+ /** Every id awaiting a verdict on this device. */
8319
+ deferredIds(deviceKey) {
8320
+ const ids = this.byDevice.get(deviceKey);
8321
+ return ids ? [...ids.keys()] : [];
8322
+ }
8323
+ /**
8324
+ * Forget every deferred id the tracker is no longer carrying.
8325
+ *
8326
+ * Called once per frame with the ids present THIS frame — the same contract
8327
+ * as the suppressed registry's `retain`.
8328
+ */
8329
+ retain(deviceKey, currentTrackIds) {
8330
+ const ids = this.byDevice.get(deviceKey);
8331
+ if (!ids) return;
8332
+ for (const id of [...ids.keys()]) if (!currentTrackIds.has(id)) ids.delete(id);
8333
+ if (ids.size === 0) this.byDevice.delete(deviceKey);
8334
+ }
8335
+ /** Deferred ids currently held for a device — diagnostics and tests. */
8336
+ size(deviceKey) {
8337
+ return this.byDevice.get(deviceKey)?.size ?? 0;
8338
+ }
8339
+ /** Drop a device's memory wholesale (device removed / pipeline reset). */
8340
+ clearDevice(deviceKey) {
8341
+ this.byDevice.delete(deviceKey);
8342
+ }
8343
+ };
8344
+ //#endregion
7883
8345
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
7884
8346
  async function rankKeyEvents(candidates, options, peakLookup) {
7885
8347
  const scored = [];
@@ -8384,9 +8846,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
8384
8846
  * padding === 0 is the identity operation. Pure, immutable — always returns a
8385
8847
  * new object.
8386
8848
  *
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
8849
+ * NOTE: clamping SHIFTS, it does not truncate a window that overflows a
8850
+ * bound slides inward and keeps the extent that was asked for, on either side.
8851
+ * It shrinks only when the padded window is larger than the frame itself
8390
8852
  * would (e.g. a left-hugging box gains a bit more right-side context). This is
8391
8853
  * acceptable for detection crops (more context, never out of [0,1]); the
8392
8854
  * downstream `extractCrop` clamps to pixel bounds again regardless.
@@ -8396,19 +8858,19 @@ function padBbox(bbox, padding) {
8396
8858
  const rawY = bbox.y - padding * bbox.h;
8397
8859
  const rawW = bbox.w * (1 + 2 * padding);
8398
8860
  const rawH = bbox.h * (1 + 2 * padding);
8399
- const x = Math.max(0, rawX);
8400
- const y = Math.max(0, rawY);
8861
+ const w = Math.min(rawW, 1);
8862
+ const h = Math.min(rawH, 1);
8401
8863
  return {
8402
- x,
8403
- y,
8404
- w: Math.min(rawW, 1 - x),
8405
- h: Math.min(rawH, 1 - y)
8864
+ x: Math.min(Math.max(0, rawX), 1 - w),
8865
+ y: Math.min(Math.max(0, rawY), 1 - h),
8866
+ w,
8867
+ h
8406
8868
  };
8407
8869
  }
8408
8870
  //#endregion
8409
8871
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
8410
8872
  function createCaptureCrop(deps) {
8411
- return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
8873
+ return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
8412
8874
  const paddedNorm = padBbox({
8413
8875
  x: bbox.x / frameWidth,
8414
8876
  y: bbox.y / frameHeight,
@@ -8421,7 +8883,10 @@ function createCaptureCrop(deps) {
8421
8883
  return nativeCrop;
8422
8884
  }
8423
8885
  deps.bumpCropMetric(false);
8424
- deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", { meta: { nodeId: frameHandle.nodeId } });
8886
+ deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", {
8887
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
8888
+ meta: { nodeId: frameHandle.nodeId }
8889
+ });
8425
8890
  return null;
8426
8891
  };
8427
8892
  }
@@ -12097,7 +12562,11 @@ function squareSubjectCropRegion(bbox, frame) {
12097
12562
  * cases). `C` becomes the middle square of the output.
12098
12563
  * 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
12099
12564
  * 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).
12565
+ * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
12566
+ * then CLAMP the window fully inside the frame whenever it fits. The
12567
+ * subject therefore drifts off-centre near a frame edge and the output
12568
+ * carries no padding at all. Centring is a preference; containing the
12569
+ * subject is the contract, and clamping cannot break it.
12101
12570
  * 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
12102
12571
  * `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
12103
12572
  * the vertical extent is always in-frame). Its canvas offset is
@@ -12109,13 +12578,8 @@ function wideCentralSquareLayout(bbox, frame) {
12109
12578
  const canvasW = Math.round(c * 16 / 9);
12110
12579
  const centralX0 = Math.round((canvasW - c) / 2);
12111
12580
  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;
12581
+ if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
12582
+ else frameOriginX = (frame.W - canvasW) / 2;
12119
12583
  const fxa = Math.max(0, frameOriginX);
12120
12584
  const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
12121
12585
  const slabOffsetX = fxa - frameOriginX;
@@ -12164,10 +12628,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
12164
12628
  /**
12165
12629
  * Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
12166
12630
  * 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).
12631
+ * on a 16:9 canvas; any lateral part of the window outside the frame is filled
12632
+ * with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
12633
+ * (operator triage 2026-07-22).
12634
+ *
12635
+ * **That fill is now nearly unreachable, and deliberately so.** The geometry
12636
+ * clamps the window fully inside the frame whenever it fits, so a subject
12637
+ * against a frame edge yields real pixels off-centre rather than ambience
12638
+ * (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
12639
+ * still pads — there is no more scene to slide into — which is why this code
12640
+ * stays. If you are looking at a blurred band in a best shot, the window was
12641
+ * wider than the frame; do not go looking for a sliding bug.
12171
12642
  * When the whole window is in-frame (`slab` already spans the full canvas) the
12172
12643
  * slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
12173
12644
  * the canvas is at the slab's native scale; only the out-of-frame ambience fill
@@ -13290,6 +13761,13 @@ var ZoneAnalyticsProvider = class {
13290
13761
  zones: snapshot.zones.length
13291
13762
  }
13292
13763
  });
13764
+ this.ctx.emitOccupancyChanged?.({
13765
+ deviceId: input.deviceId,
13766
+ timestamp: input.timestamp,
13767
+ totalObjects: total,
13768
+ byClass: snapshot.frame.byClass,
13769
+ zones: snapshot.zones.length
13770
+ });
13293
13771
  }
13294
13772
  this.snapshots.set(input.deviceId, snapshot);
13295
13773
  this.appendHistory(input.deviceId, snapshot);
@@ -14139,11 +14617,15 @@ function resolveTrackingSettings(raw) {
14139
14617
  * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14140
14618
  * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14141
14619
  * all. It is: it suppressed several phantom births on device 615 that same day.
14142
- * Read the schema, not this paragraph. Historically the intent was byte-identical
14143
- * to today until an operator opts in per camera. The gate is fail-OPEN — any
14144
- * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
14145
- * error, or timeout ALLOWS the birth (a real track is never suppressed because
14146
- * confirmation was unavailable).
14620
+ * Read the schema, not this paragraph.
14621
+ *
14622
+ * The gate is fail-DEFERRED, not fail-open (changed 2026-08-01). Anything that
14623
+ * prevents a MEASUREMENT a missing frame handle, an unavailable inference
14624
+ * cap, a crop-fetch miss, a re-detection error, a timeout — leaves the birth
14625
+ * UNDECIDED and re-tried on later frames. Only an exhausted deferral with no
14626
+ * crop at all falls open, and that is logged at `warn`. Fail-open on every one
14627
+ * of those paths is what let a brick wall onto camera 636's track feed as a
14628
+ * `vehicle`, and left 45% of births unmeasured over twelve hours.
14147
14629
  *
14148
14630
  * Every field is independently overridable per camera; an unknown/invalid value
14149
14631
  * falls back to the field default (never throws on a bad blob) — mirrors
@@ -14160,7 +14642,9 @@ var CONFIRMATION_GATE_KEYS = {
14160
14642
  enabled: "confirmationGateEnabled",
14161
14643
  minConfidence: "confirmationGateMinConfidence",
14162
14644
  minCropPx: "confirmationGateMinCropPx",
14163
- timeoutMs: "confirmationGateTimeoutMs"
14645
+ timeoutMs: "confirmationGateTimeoutMs",
14646
+ maxDeferralMs: "confirmationGateMaxDeferralMs",
14647
+ maxAttempts: "confirmationGateMaxAttempts"
14164
14648
  };
14165
14649
  var ConfirmationGateSettingsSchema = require_dist.object({
14166
14650
  /** Master switch. DEFAULT ON (2026-07-20 rollout) — the gate is FAIL-OPEN (any
@@ -14182,11 +14666,21 @@ var ConfirmationGateSettingsSchema = require_dist.object({
14182
14666
  */
14183
14667
  minCropPx: require_dist.number().int().min(0).default(48),
14184
14668
  /**
14185
- * Per-birth confirmation budget (ms). If the crop fetch + re-detection does
14186
- * not resolve within this window the gate fails open (confirms the birth) so
14187
- * the synchronous frame path never stalls on inference.
14669
+ * Per-ATTEMPT confirmation budget (ms). If the crop fetch + re-detection does
14670
+ * not resolve within this window the attempt ends UNDECIDED, so the
14671
+ * synchronous frame path never stalls on inference.
14672
+ */
14673
+ timeoutMs: require_dist.number().int().min(1).default(300),
14674
+ /**
14675
+ * How long a birth may stay UNDECIDED before the gate stops waiting for a
14676
+ * native crop and decides on the sub-native fallback.
14677
+ *
14678
+ * Measured from the FIRST attempt, so retries cannot push it out. 0 = decide
14679
+ * on the first attempt (the pre-2026-08-01 cadence, without the fail-open).
14188
14680
  */
14189
- timeoutMs: require_dist.number().int().min(1).default(300)
14681
+ maxDeferralMs: require_dist.number().int().min(0).default(2e3),
14682
+ /** How many gate attempts one birth may have, the first included. */
14683
+ maxAttempts: require_dist.number().int().min(1).default(4)
14190
14684
  });
14191
14685
  var CONFIRMATION_GATE_DEFAULTS = ConfirmationGateSettingsSchema.parse({});
14192
14686
  /**
@@ -14203,7 +14697,9 @@ function resolveConfirmationGateSettings(raw) {
14203
14697
  enabled: s.enabled.catch(CONFIRMATION_GATE_DEFAULTS.enabled).parse(raw[CONFIRMATION_GATE_KEYS.enabled]),
14204
14698
  minConfidence: s.minConfidence.catch(CONFIRMATION_GATE_DEFAULTS.minConfidence).parse(raw[CONFIRMATION_GATE_KEYS.minConfidence]),
14205
14699
  minCropPx: s.minCropPx.catch(CONFIRMATION_GATE_DEFAULTS.minCropPx).parse(raw[CONFIRMATION_GATE_KEYS.minCropPx]),
14206
- timeoutMs: s.timeoutMs.catch(CONFIRMATION_GATE_DEFAULTS.timeoutMs).parse(raw[CONFIRMATION_GATE_KEYS.timeoutMs])
14700
+ timeoutMs: s.timeoutMs.catch(CONFIRMATION_GATE_DEFAULTS.timeoutMs).parse(raw[CONFIRMATION_GATE_KEYS.timeoutMs]),
14701
+ maxDeferralMs: s.maxDeferralMs.catch(CONFIRMATION_GATE_DEFAULTS.maxDeferralMs).parse(raw[CONFIRMATION_GATE_KEYS.maxDeferralMs]),
14702
+ maxAttempts: s.maxAttempts.catch(CONFIRMATION_GATE_DEFAULTS.maxAttempts).parse(raw[CONFIRMATION_GATE_KEYS.maxAttempts])
14207
14703
  };
14208
14704
  }
14209
14705
  //#endregion
@@ -14235,9 +14731,9 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
14235
14731
  if (track === "other" || det === "other") return true;
14236
14732
  return track === det;
14237
14733
  }
14238
- var failOpen = (candidate, reason) => ({
14734
+ var undecided = (candidate, reason) => ({
14239
14735
  trackId: candidate.trackId,
14240
- confirmed: true,
14736
+ verdict: "undecided",
14241
14737
  reason,
14242
14738
  className: candidate.className
14243
14739
  });
@@ -14253,11 +14749,12 @@ function withTimeout(promise, timeoutMs) {
14253
14749
  });
14254
14750
  });
14255
14751
  }
14256
- async function runConfirmation(candidate, config, deps) {
14257
- const crop = await deps.fetchCrop(candidate);
14258
- if (!crop) return failOpen(candidate, "no-crop");
14752
+ async function runConfirmation(candidate, config, deps, exhausted) {
14753
+ let crop = await deps.fetchCrop(candidate);
14754
+ if (!crop && exhausted && deps.fetchFallbackCrop) crop = await deps.fetchFallbackCrop(candidate);
14755
+ if (!crop) return undecided(candidate, "no-crop");
14259
14756
  const detections = await deps.redetect(crop);
14260
- if (detections === null) return failOpen(candidate, "redetect-error");
14757
+ if (detections === null) return undecided(candidate, "redetect-error");
14261
14758
  let best;
14262
14759
  let bestIncompatible;
14263
14760
  for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
@@ -14266,7 +14763,7 @@ async function runConfirmation(candidate, config, deps) {
14266
14763
  const confirmed = best !== void 0 && best.score >= config.minConfidence;
14267
14764
  return {
14268
14765
  trackId: candidate.trackId,
14269
- confirmed,
14766
+ verdict: confirmed ? "confirmed" : "suppressed",
14270
14767
  reason: confirmed ? "confirmed" : "suppressed",
14271
14768
  className: candidate.className,
14272
14769
  ...best ? { bestScore: best.score } : {},
@@ -14276,31 +14773,40 @@ async function runConfirmation(candidate, config, deps) {
14276
14773
  } : {}
14277
14774
  };
14278
14775
  }
14279
- async function confirmOne(candidate, config, deps) {
14776
+ async function confirmOne(candidate, config, deps, exhausted) {
14280
14777
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
14281
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
14778
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return undecided(candidate, "below-min-crop");
14282
14779
  try {
14283
- return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
14780
+ return await withTimeout(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
14284
14781
  } catch {
14285
- return failOpen(candidate, "timeout");
14782
+ return undecided(candidate, "timeout");
14286
14783
  }
14287
14784
  }
14288
14785
  /**
14289
- * Confirm a batch of birth candidates CONCURRENTLY and return the set of
14290
- * trackIds whose births may PROCEED. When the gate is disabled (or there are no
14291
- * candidates) every candidate is confirmed byte-identical to no gate. The
14292
- * caller runs this once, then processes only the confirmed births, preserving
14293
- * the original birth-loop ordering.
14786
+ * Confirm a batch of birth candidates CONCURRENTLY. When the gate is disabled
14787
+ * (or there are no candidates) every candidate is confirmed byte-identical to
14788
+ * no gate. The caller runs this once, then processes the confirmed births,
14789
+ * defers the undecided ones, and retracts the rest, preserving the original
14790
+ * birth-loop ordering.
14294
14791
  */
14295
- async function confirmBirths(candidates, config, deps) {
14296
- if (!config.enabled || candidates.length === 0) return new Set(candidates.map((c) => c.trackId));
14297
- const decisions = await Promise.all(candidates.map((c) => confirmOne(c, config, deps)));
14792
+ async function confirmBirths(candidates, config, deps, exhaustedIds = /* @__PURE__ */ new Set()) {
14793
+ if (!config.enabled || candidates.length === 0) return {
14794
+ confirmed: new Set(candidates.map((c) => c.trackId)),
14795
+ undecided: /* @__PURE__ */ new Set()
14796
+ };
14797
+ const decisions = await Promise.all(candidates.map((c) => confirmOne(c, config, deps, exhaustedIds.has(c.trackId))));
14298
14798
  const confirmed = /* @__PURE__ */ new Set();
14799
+ const pending = /* @__PURE__ */ new Set();
14299
14800
  for (const decision of decisions) {
14300
14801
  deps.onDecision?.(decision);
14301
- if (decision.confirmed) confirmed.add(decision.trackId);
14802
+ if (decision.verdict === "confirmed") confirmed.add(decision.trackId);
14803
+ else if (decision.verdict === "undecided") if (exhaustedIds.has(decision.trackId)) confirmed.add(decision.trackId);
14804
+ else pending.add(decision.trackId);
14302
14805
  }
14303
- return confirmed;
14806
+ return {
14807
+ confirmed,
14808
+ undecided: pending
14809
+ };
14304
14810
  }
14305
14811
  //#endregion
14306
14812
  //#region src/pipeline-analytics/face-settings.ts
@@ -14919,7 +15425,7 @@ function buildDetectionSettingsSections() {
14919
15425
  {
14920
15426
  id: "confirmation-gate",
14921
15427
  title: "Confirmation gate",
14922
- description: "Before a NEW track is born, optionally 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-OPEN: any crop-fetch miss, unavailable inference, error, or timeout confirms the birth (a real track is never dropped because confirmation was unavailable).",
15428
+ 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.",
14923
15429
  columns: 2,
14924
15430
  fields: [
14925
15431
  {
@@ -14944,7 +15450,7 @@ function buildDetectionSettingsSections() {
14944
15450
  type: "slider",
14945
15451
  key: CONFIRMATION_GATE_KEYS.minCropPx,
14946
15452
  label: "Min crop size",
14947
- description: "Subject boxes smaller than this (longest side, detection-frame px) are too tiny to confirm reliably — the gate skips them and confirms the birth. 0 = confirm every birth regardless of size.",
15453
+ 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.",
14948
15454
  min: 0,
14949
15455
  max: 256,
14950
15456
  step: 8,
@@ -14956,13 +15462,36 @@ function buildDetectionSettingsSections() {
14956
15462
  type: "slider",
14957
15463
  key: CONFIRMATION_GATE_KEYS.timeoutMs,
14958
15464
  label: "Confirmation timeout",
14959
- description: "Per-birth budget for crop fetch + re-detection. If it does not resolve in time the gate fails open (confirms the birth) so the frame path never stalls on inference.",
15465
+ 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.",
14960
15466
  min: 50,
14961
15467
  max: 2e3,
14962
15468
  step: 50,
14963
15469
  default: CONFIRMATION_GATE_DEFAULTS.timeoutMs,
14964
15470
  showValue: true,
14965
15471
  unit: "ms"
15472
+ },
15473
+ {
15474
+ type: "slider",
15475
+ key: CONFIRMATION_GATE_KEYS.maxDeferralMs,
15476
+ label: "Max deferral",
15477
+ 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.",
15478
+ min: 0,
15479
+ max: 1e4,
15480
+ step: 250,
15481
+ default: CONFIRMATION_GATE_DEFAULTS.maxDeferralMs,
15482
+ showValue: true,
15483
+ unit: "ms"
15484
+ },
15485
+ {
15486
+ type: "slider",
15487
+ key: CONFIRMATION_GATE_KEYS.maxAttempts,
15488
+ label: "Max gate attempts",
15489
+ 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.",
15490
+ min: 1,
15491
+ max: 12,
15492
+ step: 1,
15493
+ default: CONFIRMATION_GATE_DEFAULTS.maxAttempts,
15494
+ showValue: true
14966
15495
  }
14967
15496
  ]
14968
15497
  },
@@ -15022,6 +15551,15 @@ function buildDetectionSettingsSections() {
15022
15551
  step: .05,
15023
15552
  default: TRACKING_DEFAULTS.rescueIouThreshold
15024
15553
  },
15554
+ {
15555
+ type: "number",
15556
+ key: "rescueCentroidFactor",
15557
+ label: "Rescue centroid distance",
15558
+ 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.",
15559
+ min: 0,
15560
+ step: .05,
15561
+ default: TRACKING_DEFAULTS.rescueCentroidFactor
15562
+ },
15025
15563
  {
15026
15564
  type: "number",
15027
15565
  key: "stationarySpeedPx",
@@ -15303,6 +15841,16 @@ function buildDetectionSettingsSections() {
15303
15841
  label: "Person/animal group matching",
15304
15842
  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
15843
  default: TRACKING_DEFAULTS.classGroupAssoc
15844
+ },
15845
+ {
15846
+ type: "number",
15847
+ key: "zoneMembershipMinOverlap",
15848
+ label: "Zone membership minimum overlap",
15849
+ 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.",
15850
+ min: 0,
15851
+ max: 1,
15852
+ step: .05,
15853
+ default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
15306
15854
  }
15307
15855
  ]
15308
15856
  },
@@ -19872,6 +20420,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19872
20420
  * dataPlane facility in the current environment). */
19873
20421
  eventMediaBaseUrl = null;
19874
20422
  lastActiveTrackIds = /* @__PURE__ */ new Map();
20423
+ /** See `pipeline/suppressed-births.ts` — rejected births must not be
20424
+ * re-upserted, and must be forgotten when the tracker drops the id. */
20425
+ suppressedBirths = new SuppressedBirthRegistry();
20426
+ /** Births the gate could not MEASURE, awaiting another look on a later frame. */
20427
+ deferredBirths = new DeferredBirthRegistry();
19875
20428
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
19876
20429
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
19877
20430
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -19957,6 +20510,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19957
20510
  * the same live-frame window as the face/plate/event-media captures. The
19958
20511
  * optional `maxWidth` caps the native crop width (used for the full-frame key
19959
20512
  * frame so a 4K native surface never floods the transport). */
20513
+ /**
20514
+ * The BOUNDED fallback `captureCrop` refuses — same padded ROI out of the
20515
+ * retained full frame (keyframe-native tier, or the runner's ≤640 RAM tier;
20516
+ * honest sub-native, NEVER upscaled). Built for the gallery tiles; the
20517
+ * confirmation gate borrows it on an EXHAUSTED deferral only. See
20518
+ * `docs/decisions/` — the gate is a model input, so this is a deliberate
20519
+ * exception to "model-input crops keep captureCrop".
20520
+ */
20521
+ captureDisplayCropFn = null;
19960
20522
  captureCrop = null;
19961
20523
  /** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
19962
20524
  * to the owning runner, PLUS the source `tier`. Captured in the async init
@@ -20173,7 +20735,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20173
20735
  let storage = this.ctx.kernel.storage;
20174
20736
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20175
20737
  if (mediaRoot) {
20176
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
20738
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
20177
20739
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20178
20740
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20179
20741
  }
@@ -20355,6 +20917,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20355
20917
  * route through the CaptureScheduler at the injection point (S4). */
20356
20918
  buildRecognizers(api, logger, stores, transport) {
20357
20919
  const captureDisplayCrop = transport.captureDisplayCrop;
20920
+ this.captureDisplayCropFn = captureDisplayCrop;
20358
20921
  this.faceRecognizer = new FaceRecognizer({
20359
20922
  identityStore: stores.identityStore,
20360
20923
  faceStore: stores.faceStore,
@@ -20455,6 +21018,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20455
21018
  const zoneAnalytics = new ZoneAnalyticsProvider({
20456
21019
  logger: logger.child("ZoneAnalytics"),
20457
21020
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
21021
+ emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
21022
+ id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
21023
+ timestamp: new Date(payload.timestamp),
21024
+ source: {
21025
+ type: "addon",
21026
+ id: "pipeline-analytics",
21027
+ addonId: "pipeline-analytics"
21028
+ },
21029
+ category: require_dist.EventCategory.ZoneAnalyticsOccupancyChanged,
21030
+ data: {
21031
+ deviceId: payload.deviceId,
21032
+ totalObjects: payload.totalObjects,
21033
+ byClass: payload.byClass,
21034
+ zones: payload.zones
21035
+ }
21036
+ }),
20458
21037
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
20459
21038
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
20460
21039
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
@@ -20902,6 +21481,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20902
21481
  timestamp: frame.timestamp,
20903
21482
  frame
20904
21483
  });
21484
+ for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
21485
+ tags: { deviceId },
21486
+ meta: {
21487
+ vehicleClass: r.vehicleClass,
21488
+ personScore: r.personScore,
21489
+ vehicleScore: r.vehicleScore,
21490
+ overlap: r.overlap
21491
+ }
21492
+ });
20905
21493
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
20906
21494
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
20907
21495
  deviceId,
@@ -20932,6 +21520,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20932
21520
  const positionsCountById = /* @__PURE__ */ new Map();
20933
21521
  for (const t of result.tracked) {
20934
21522
  currentTrackIds.add(t.trackId);
21523
+ if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
20935
21524
  const center = {
20936
21525
  x: t.bbox.x + t.bbox.w / 2,
20937
21526
  y: t.bbox.y + t.bbox.h / 2
@@ -20987,7 +21576,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20987
21576
  t
20988
21577
  });
20989
21578
  }
20990
- const confirmedBirths = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => ({
21579
+ for (const id of this.deferredBirths.deferredIds(key)) {
21580
+ if (bornCandidates.some((c) => c.id === id)) continue;
21581
+ const t = result.tracked.find((x) => x.trackId === id);
21582
+ if (t) bornCandidates.push({
21583
+ id,
21584
+ t
21585
+ });
21586
+ }
21587
+ const gateSettings = await this.resolveDeviceConfirmationGateSettings(deviceId);
21588
+ const exhaustionNowMs = Date.now();
21589
+ const exhaustedIds = /* @__PURE__ */ new Set();
21590
+ for (const { id } of bornCandidates) if (this.deferredBirths.exhausted(key, id, exhaustionNowMs, gateSettings.maxAttempts, gateSettings.maxDeferralMs)) exhaustedIds.add(id);
21591
+ const outcome = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => ({
20991
21592
  trackId: id,
20992
21593
  className: t.className,
20993
21594
  bbox: t.bbox
@@ -20996,9 +21597,53 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20996
21597
  frameHandle,
20997
21598
  frameWidth: result.frameWidth,
20998
21599
  frameHeight: result.frameHeight
20999
- });
21600
+ }, exhaustedIds);
21601
+ const gateNowMs = Date.now();
21000
21602
  for (const { id, t } of bornCandidates) {
21001
- if (!confirmedBirths.has(id)) continue;
21603
+ if (outcome.undecided.has(id)) {
21604
+ this.deferredBirths.defer(key, id, gateNowMs);
21605
+ log.info("birth undecided — deferred for another look", { meta: {
21606
+ trackId: id,
21607
+ className: t.className,
21608
+ attempts: this.deferredBirths.attemptsFor(key, id),
21609
+ elapsedMs: this.deferredBirths.elapsedMs(key, id, gateNowMs),
21610
+ source
21611
+ } });
21612
+ continue;
21613
+ }
21614
+ const wasDeferred = this.deferredBirths.isDeferred(key, id);
21615
+ const deferredForMs = this.deferredBirths.elapsedMs(key, id, gateNowMs);
21616
+ const deferredAttempts = this.deferredBirths.attemptsFor(key, id);
21617
+ this.deferredBirths.resolve(key, id);
21618
+ if (!outcome.confirmed.has(id)) {
21619
+ this.suppressedBirths.reject(key, id);
21620
+ this.trackStore?.dropActive(id);
21621
+ log.info("birth suppressed — track record retracted", { meta: {
21622
+ trackId: id,
21623
+ className: t.className,
21624
+ source,
21625
+ ...wasDeferred ? {
21626
+ wasDeferred,
21627
+ deferredForMs,
21628
+ deferredAttempts
21629
+ } : {}
21630
+ } });
21631
+ continue;
21632
+ }
21633
+ if (wasDeferred && exhaustedIds.has(id)) log.warn("birth allowed unmeasured — deferral exhausted, no crop available", { meta: {
21634
+ trackId: id,
21635
+ className: t.className,
21636
+ source,
21637
+ deferredForMs,
21638
+ deferredAttempts
21639
+ } });
21640
+ else if (wasDeferred) log.info("birth decided late — confirmed after deferral", { meta: {
21641
+ trackId: id,
21642
+ className: t.className,
21643
+ source,
21644
+ deferredForMs,
21645
+ deferredAttempts
21646
+ } });
21002
21647
  newTrackCount += 1;
21003
21648
  log.info("track started", { meta: {
21004
21649
  trackId: id,
@@ -21059,6 +21704,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21059
21704
  source
21060
21705
  } });
21061
21706
  }
21707
+ this.suppressedBirths.retain(key, currentTrackIds);
21708
+ this.deferredBirths.retain(key, currentTrackIds);
21062
21709
  this.lastActiveTrackIds.set(key, currentTrackIds);
21063
21710
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
21064
21711
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
@@ -21130,6 +21777,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21130
21777
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
21131
21778
  if (this.notificationCenter !== null) {
21132
21779
  const overlaps = processor.getLastZoneOverlaps();
21780
+ const rejections = processor.getLastZoneRejections();
21133
21781
  for (const e of result.objectEvents) {
21134
21782
  if (e.zones && e.zones.length > 0) {
21135
21783
  const m = e.trackId ? overlaps.get(e.trackId) : void 0;
@@ -21148,6 +21796,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21148
21796
  }
21149
21797
  });
21150
21798
  }
21799
+ const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
21800
+ if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
21801
+ tags: { deviceId },
21802
+ meta: {
21803
+ eventId: e.id,
21804
+ trackId: e.trackId,
21805
+ className: e.className,
21806
+ minOverlap: trk.zoneMembershipMinOverlap,
21807
+ stamped: e.zones?.length ?? 0,
21808
+ zones: rejected.map((z) => ({
21809
+ id: z.zoneId,
21810
+ name: z.zoneName,
21811
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21812
+ }))
21813
+ }
21814
+ });
21151
21815
  this.notificationCenter.onObjectEventPersisted(e);
21152
21816
  }
21153
21817
  }
@@ -21470,19 +22134,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21470
22134
  * candidate) and each failure path fails OPEN, so the synchronous frame path
21471
22135
  * is never blocked or reordered by a slow/failed re-detection.
21472
22136
  */
21473
- async confirmTrackBirths(candidates, params) {
21474
- const allConfirmed = () => new Set(candidates.map((c) => c.trackId));
22137
+ async confirmTrackBirths(candidates, params, exhaustedIds = /* @__PURE__ */ new Set()) {
22138
+ const allConfirmed = () => ({
22139
+ confirmed: new Set(candidates.map((c) => c.trackId)),
22140
+ undecided: /* @__PURE__ */ new Set()
22141
+ });
21475
22142
  if (candidates.length === 0) return allConfirmed();
21476
22143
  const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
21477
22144
  if (!config.enabled) return allConfirmed();
21478
22145
  const { frameHandle, frameWidth, frameHeight, deviceId } = params;
21479
22146
  const captureCrop = this.captureCrop;
21480
22147
  if (!frameHandle || !captureCrop || frameWidth <= 0 || frameHeight <= 0) {
21481
- this.ctx.logger.debug("confirmation gate: no crop path — births allowed (fail-open)", {
22148
+ this.ctx.logger.info("confirmation gate: no crop path — births allowed unmeasured", {
21482
22149
  tags: { deviceId },
21483
22150
  meta: {
21484
22151
  candidates: candidates.length,
21485
- hasHandle: Boolean(frameHandle)
22152
+ hasHandle: Boolean(frameHandle),
22153
+ hasCaptureCrop: Boolean(captureCrop),
22154
+ frameWidth,
22155
+ frameHeight
21486
22156
  }
21487
22157
  });
21488
22158
  return allConfirmed();
@@ -21498,8 +22168,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21498
22168
  y: candidate.bbox.y,
21499
22169
  w: candidate.bbox.w,
21500
22170
  h: candidate.bbox.h
21501
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH)
22171
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH, deviceId)
21502
22172
  }),
22173
+ fetchFallbackCrop: (candidate) => {
22174
+ const displayCrop = this.captureDisplayCropFn;
22175
+ if (!displayCrop) return Promise.resolve(null);
22176
+ return displayCrop(frameHandle, {
22177
+ x: candidate.bbox.x,
22178
+ y: candidate.bbox.y,
22179
+ w: candidate.bbox.w,
22180
+ h: candidate.bbox.h
22181
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, CONFIRMATION_CROP_MAX_WIDTH);
22182
+ },
21503
22183
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
21504
22184
  onDecision: (decision) => {
21505
22185
  const meta = {
@@ -21513,20 +22193,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21513
22193
  } : {},
21514
22194
  minConfidence: config.minConfidence
21515
22195
  };
21516
- if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
21517
- tags: { deviceId },
21518
- meta
21519
- });
21520
- else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
21521
- tags: { deviceId },
21522
- meta
21523
- });
21524
- else this.ctx.logger.info("confirmation gate: birth confirmed", {
21525
- tags: { deviceId },
21526
- meta
21527
- });
22196
+ switch (decision.verdict) {
22197
+ case "suppressed":
22198
+ this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
22199
+ tags: { deviceId },
22200
+ meta
22201
+ });
22202
+ break;
22203
+ case "undecided":
22204
+ this.ctx.logger.info("confirmation gate: birth undecided", {
22205
+ tags: { deviceId },
22206
+ meta
22207
+ });
22208
+ break;
22209
+ case "confirmed":
22210
+ this.ctx.logger.info("confirmation gate: birth confirmed", {
22211
+ tags: { deviceId },
22212
+ meta
22213
+ });
22214
+ break;
22215
+ }
21528
22216
  }
21529
- });
22217
+ }, exhaustedIds);
21530
22218
  }
21531
22219
  async resolveGlobalFaceEnabled() {
21532
22220
  return this.faceGlobalEnabledCache.get(() => this.faceGlobalEnabledState.get());