@camstack/addon-post-analysis 1.2.18 → 1.2.19

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.
@@ -2302,16 +2302,28 @@ function resolveRuleThreshold(rule) {
2302
2302
  */
2303
2303
  var ZoneEngine = class {
2304
2304
  /**
2305
- * Annotate a single detection with its zone memberships.
2306
- * Returns zones where the detection overlaps above any active
2307
- * rule's threshold (or the engine default if no rule sets one).
2305
+ * Annotate a single detection with its zone memberships — every zone whose
2306
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2307
+ * the detection's own area).
2308
+ *
2309
+ * The previous version of this comment claimed memberships were returned
2310
+ * "above any active rule's threshold"; they were not — the threshold was
2311
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2312
+ * consulted. Read the parameter, not this paragraph.
2313
+ *
2314
+ * `minOverlap` matters because membership is what lands on an event as
2315
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2316
+ * over that field — so this, not the zone-RULE threshold, is what decides
2317
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2318
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2319
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
2308
2320
  */
2309
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2321
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2310
2322
  const memberships = [];
2311
2323
  for (const zone of zones) {
2312
2324
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
2313
2325
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
2314
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2326
+ if (overlap > minOverlap) memberships.push({
2315
2327
  zoneId: zone.id,
2316
2328
  zoneName: zone.name,
2317
2329
  overlap
@@ -2411,6 +2423,10 @@ var FrameProcessor = class {
2411
2423
  * runners that haven't picked up the new gating yet.
2412
2424
  */
2413
2425
  detectionRules;
2426
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2427
+ zoneMembershipMinOverlap;
2428
+ /** See {@link getLastZoneOverlaps}. */
2429
+ lastZoneOverlaps;
2414
2430
  zoneEngine = new ZoneEngine();
2415
2431
  /** Optional stationary-object gate (parked-object suppression). Null until
2416
2432
  * the addon wires it via {@link setStationaryGate}. */
@@ -2423,10 +2439,32 @@ var FrameProcessor = class {
2423
2439
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2424
2440
  this.zones = [];
2425
2441
  this.detectionRules = [];
2442
+ this.zoneMembershipMinOverlap = 0;
2443
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2426
2444
  }
2427
2445
  setZones(zones) {
2428
2446
  this.zones = zones;
2429
2447
  }
2448
+ /**
2449
+ * How much of a detection's box must lie inside a zone for the zone to be
2450
+ * stamped onto it (0–1 fraction of the box's own area).
2451
+ *
2452
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2453
+ * positive overlap counted. Raising it is an operator decision and needs
2454
+ * evidence: a bar set blind removes notifications silently, which is the
2455
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2456
+ * exists so the distribution can be read before a number is picked.
2457
+ */
2458
+ setZoneMembershipMinOverlap(minOverlap) {
2459
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2460
+ }
2461
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2462
+ * recent frame. The engine computes these and the pipeline previously
2463
+ * discarded everything but the ids — which is why no amount of production
2464
+ * data could say how far inside the zone a notifying subject actually was. */
2465
+ getLastZoneOverlaps() {
2466
+ return this.lastZoneOverlaps;
2467
+ }
2430
2468
  setDetectionRules(rules) {
2431
2469
  this.detectionRules = rules;
2432
2470
  }
@@ -2537,11 +2575,14 @@ var FrameProcessor = class {
2537
2575
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2538
2576
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2539
2577
  const zonesByTrack = /* @__PURE__ */ new Map();
2578
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2540
2579
  for (const td of trackedDetections) {
2541
2580
  const m = maskByBbox.get(td.bbox);
2542
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2581
+ const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2543
2582
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2583
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2544
2584
  }
2585
+ this.lastZoneOverlaps = overlapsByTrack;
2545
2586
  const tracked = trackedDetections.map((td) => {
2546
2587
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
2547
2588
  const label = resolveDetectionLabel({
@@ -4635,7 +4676,9 @@ var NcDispatcher = class {
4635
4676
  ruleId: entry.ruleId,
4636
4677
  target: target.name,
4637
4678
  kind: target.kind,
4638
- recordKind: entry.recordKind
4679
+ recordKind: entry.recordKind,
4680
+ eventId: entry.recordId,
4681
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4639
4682
  }
4640
4683
  });
4641
4684
  return { ok: true };
@@ -4673,9 +4716,10 @@ var NcDispatcher = class {
4673
4716
  async buildNotification(entry) {
4674
4717
  const subject = entry.payload.subject;
4675
4718
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4676
- const vars = buildTemplateVars(entry, deviceName);
4719
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
4720
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4677
4721
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4678
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4722
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4679
4723
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4680
4724
  const params = pickParams(entry.payload.params);
4681
4725
  return {
@@ -4874,15 +4918,37 @@ var NcDispatcher = class {
4874
4918
  return null;
4875
4919
  }
4876
4920
  };
4877
- function buildTemplateVars(entry, deviceName) {
4921
+ /**
4922
+ * Map admin zone IDs to their display names for rendering only.
4923
+ *
4924
+ * Order follows `zoneIds` (the order the track visited them), not the zone
4925
+ * catalog. Every failure mode degrades to the ID rather than dropping the
4926
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
4927
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
4928
+ */
4929
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4930
+ if (zoneIds.length === 0) return [];
4931
+ if (getZoneNames === void 0) return [...zoneIds];
4932
+ try {
4933
+ const zones = await getZoneNames(deviceId);
4934
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
4935
+ return zoneIds.map((id) => {
4936
+ const name = byId.get(id);
4937
+ return name !== void 0 && name.trim().length > 0 ? name : id;
4938
+ });
4939
+ } catch {
4940
+ return [...zoneIds];
4941
+ }
4942
+ }
4943
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4878
4944
  const subject = entry.payload.subject;
4879
4945
  const occupancy = subject.occupancy;
4880
4946
  return {
4881
4947
  camera: deviceName,
4882
4948
  class: subject.className,
4883
4949
  label: subject.label ?? "",
4884
- zones: subject.zones.join(", "),
4885
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
4950
+ zones: zoneLabels.join(", "),
4951
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4886
4952
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4887
4953
  time: new Date(subject.timestamp).toLocaleTimeString(),
4888
4954
  rule: entry.payload.ruleName,
@@ -4900,12 +4966,12 @@ function renderTemplate(template, vars) {
4900
4966
  if (template === void 0 || template.trim().length === 0) return null;
4901
4967
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4902
4968
  }
4903
- function defaultBody(entry, deviceName) {
4969
+ function defaultBody(entry, deviceName, zoneLabels) {
4904
4970
  const subject = entry.payload.subject;
4905
4971
  const occupancy = subject.occupancy;
4906
4972
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4907
4973
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4908
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4974
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4909
4975
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4910
4976
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4911
4977
  }
@@ -6683,7 +6749,9 @@ var NotificationCenter = class NotificationCenter {
6683
6749
  rule: rule.name,
6684
6750
  kind,
6685
6751
  failed: evaluation.failedCondition,
6686
- classes: subject.classNames
6752
+ classes: subject.classNames,
6753
+ eventId: subject.recordId,
6754
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6687
6755
  }
6688
6756
  });
6689
6757
  continue;
@@ -6695,7 +6763,9 @@ var NotificationCenter = class NotificationCenter {
6695
6763
  meta: {
6696
6764
  ruleId: rule.id,
6697
6765
  rule: rule.name,
6698
- key
6766
+ key,
6767
+ eventId: subject.recordId,
6768
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6699
6769
  }
6700
6770
  });
6701
6771
  continue;
@@ -6706,7 +6776,11 @@ var NotificationCenter = class NotificationCenter {
6706
6776
  ruleId: rule.id,
6707
6777
  rule: rule.name,
6708
6778
  kind,
6709
- targets: rule.targets.length
6779
+ targets: rule.targets.length,
6780
+ eventId: subject.recordId,
6781
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
6782
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
6783
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6710
6784
  }
6711
6785
  });
6712
6786
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
@@ -13853,6 +13927,23 @@ function resolveDetectionSensitivitySettings(raw) {
13853
13927
  };
13854
13928
  }
13855
13929
  var TrackingSettingsSchema = require_dist.object({
13930
+ /**
13931
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
13932
+ * box's own area) for that zone to be STAMPED onto the detection.
13933
+ *
13934
+ * This is the field a zone-scoped notification rule ultimately depends on: a
13935
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
13936
+ * a subject that merely clips a zone edge satisfies it. Measured on
13937
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
13938
+ * inside it.
13939
+ *
13940
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
13941
+ * is deliberately an operator decision: the overlap fractions are now logged
13942
+ * (`zone membership` lines), so the bar can be chosen from the distribution
13943
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
13944
+ * gates the DETECTION stage, not what gets stamped.
13945
+ */
13946
+ zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
13856
13947
  /** IoU required to match a (predicted) track to a detection. */
13857
13948
  iouThreshold: require_dist.number().min(0).max(1).default(.3),
13858
13949
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -13989,6 +14080,7 @@ function resolveTrackingSettings(raw) {
13989
14080
  const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
13990
14081
  const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
13991
14082
  return {
14083
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
13992
14084
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
13993
14085
  maxMissedMs,
13994
14086
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -14043,7 +14135,11 @@ function resolveTrackingSettings(raw) {
14043
14135
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
14044
14136
  * misclassified static object).
14045
14137
  *
14046
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14138
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14139
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14140
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14141
+ * 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
14047
14143
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
14048
14144
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
14049
14145
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -14139,10 +14235,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
14139
14235
  if (track === "other" || det === "other") return true;
14140
14236
  return track === det;
14141
14237
  }
14142
- var failOpen = (trackId, reason) => ({
14143
- trackId,
14238
+ var failOpen = (candidate, reason) => ({
14239
+ trackId: candidate.trackId,
14144
14240
  confirmed: true,
14145
- reason
14241
+ reason,
14242
+ className: candidate.className
14146
14243
  });
14147
14244
  function withTimeout(promise, timeoutMs) {
14148
14245
  return new Promise((resolve, reject) => {
@@ -14158,23 +14255,34 @@ function withTimeout(promise, timeoutMs) {
14158
14255
  }
14159
14256
  async function runConfirmation(candidate, config, deps) {
14160
14257
  const crop = await deps.fetchCrop(candidate);
14161
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14258
+ if (!crop) return failOpen(candidate, "no-crop");
14162
14259
  const detections = await deps.redetect(crop);
14163
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
14164
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14260
+ if (detections === null) return failOpen(candidate, "redetect-error");
14261
+ let best;
14262
+ let bestIncompatible;
14263
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14264
+ if (!best || d.score > best.score) best = d;
14265
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14266
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
14165
14267
  return {
14166
14268
  trackId: candidate.trackId,
14167
14269
  confirmed,
14168
- reason: confirmed ? "confirmed" : "suppressed"
14270
+ reason: confirmed ? "confirmed" : "suppressed",
14271
+ className: candidate.className,
14272
+ ...best ? { bestScore: best.score } : {},
14273
+ ...bestIncompatible ? {
14274
+ bestIncompatibleClass: bestIncompatible.macroClass,
14275
+ bestIncompatibleScore: bestIncompatible.score
14276
+ } : {}
14169
14277
  };
14170
14278
  }
14171
14279
  async function confirmOne(candidate, config, deps) {
14172
14280
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
14173
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14281
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
14174
14282
  try {
14175
14283
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
14176
14284
  } catch {
14177
- return failOpen(candidate.trackId, "timeout");
14285
+ return failOpen(candidate, "timeout");
14178
14286
  }
14179
14287
  }
14180
14288
  /**
@@ -14357,8 +14465,23 @@ function resolveMediaSettings(raw) {
14357
14465
  * §3.3 draft said 45s).
14358
14466
  */
14359
14467
  var PackageDropSettingsSchema = require_dist.object({
14360
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
14361
- packageDropEnabled: require_dist.boolean().default(false),
14468
+ /**
14469
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14470
+ * package detection**, not a second switch.
14471
+ *
14472
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14473
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14474
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14475
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14476
+ * silently by a boolean none of them mentions.
14477
+ *
14478
+ * Defaulting to true costs nothing on cameras nobody configured:
14479
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14480
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14481
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14482
+ * rule.
14483
+ */
14484
+ packageDropEnabled: require_dist.boolean().default(true),
14362
14485
  /**
14363
14486
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
14364
14487
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -19577,7 +19700,21 @@ function decodeEmbeddingBase64(base64) {
19577
19700
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
19578
19701
  return Array.from(view);
19579
19702
  }
19703
+ /** A track shorter than this is a candidate phantom, not a subject that came
19704
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
19705
+ var SHORT_TRACK_MAX_MS = 25e3;
19706
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
19707
+ var MOTIONLESS_MAX_PX = 8;
19708
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
19709
+ * to the pixel still land in one cell. */
19710
+ var PHANTOM_CELL_PX = 32;
19711
+ /** How long a cell remembers its closes. */
19712
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
19580
19713
  var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19714
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
19715
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
19716
+ * filtered against the 6-hour window on write, so it stays bounded. */
19717
+ shortMotionlessCells = /* @__PURE__ */ new Map();
19581
19718
  processors = /* @__PURE__ */ new Map();
19582
19719
  trackStore = null;
19583
19720
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -19887,7 +20024,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19887
20024
  });
19888
20025
  },
19889
20026
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
19890
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20027
+ onTrackClosed: (track, ownedMedia, info) => {
20028
+ this.noteShortMotionlessTrack(track);
20029
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20030
+ },
19891
20031
  deriveThumbnailFromKeyFrame: async (input) => {
19892
20032
  const derived = await deriveKeyFrameThumbnailJpeg({
19893
20033
  ...input,
@@ -20374,6 +20514,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20374
20514
  };
20375
20515
  },
20376
20516
  dispatcher: {
20517
+ getZoneNames: async (deviceId) => {
20518
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20519
+ id: z.id,
20520
+ name: z.name
20521
+ }));
20522
+ },
20377
20523
  getZonePolygons: async (deviceId, zoneIds) => {
20378
20524
  const zones = await api.zones.listZones.query({ deviceId });
20379
20525
  const wanted = new Set(zoneIds);
@@ -20751,6 +20897,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20751
20897
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
20752
20898
  processor.setZones(liveZones);
20753
20899
  processor.setDetectionRules(liveRules);
20900
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
20754
20901
  const result = processor.process({
20755
20902
  timestamp: frame.timestamp,
20756
20903
  frame
@@ -20981,7 +21128,29 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20981
21128
  } });
20982
21129
  }
20983
21130
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
20984
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21131
+ if (this.notificationCenter !== null) {
21132
+ const overlaps = processor.getLastZoneOverlaps();
21133
+ for (const e of result.objectEvents) {
21134
+ if (e.zones && e.zones.length > 0) {
21135
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21136
+ this.ctx.logger.info("zone membership stamped on event", {
21137
+ tags: { deviceId },
21138
+ meta: {
21139
+ eventId: e.id,
21140
+ trackId: e.trackId,
21141
+ className: e.className,
21142
+ minOverlap: trk.zoneMembershipMinOverlap,
21143
+ zones: (m ?? []).map((z) => ({
21144
+ id: z.zoneId,
21145
+ name: z.zoneName,
21146
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21147
+ }))
21148
+ }
21149
+ });
21150
+ }
21151
+ this.notificationCenter.onObjectEventPersisted(e);
21152
+ }
21153
+ }
20985
21154
  const objectEmbeddingBests = [];
20986
21155
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
20987
21156
  if (!isClipObjectEmbedding(t)) continue;
@@ -21333,19 +21502,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21333
21502
  }),
21334
21503
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
21335
21504
  onDecision: (decision) => {
21505
+ const meta = {
21506
+ trackId: decision.trackId,
21507
+ reason: decision.reason,
21508
+ className: decision.className,
21509
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21510
+ ...decision.bestIncompatibleClass !== void 0 ? {
21511
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21512
+ bestIncompatibleScore: decision.bestIncompatibleScore
21513
+ } : {},
21514
+ minConfidence: config.minConfidence
21515
+ };
21336
21516
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
21337
21517
  tags: { deviceId },
21338
- meta: {
21339
- trackId: decision.trackId,
21340
- reason: decision.reason
21341
- }
21518
+ meta
21342
21519
  });
21343
21520
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
21344
21521
  tags: { deviceId },
21345
- meta: {
21346
- trackId: decision.trackId,
21347
- reason: decision.reason
21348
- }
21522
+ meta
21523
+ });
21524
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
21525
+ tags: { deviceId },
21526
+ meta
21349
21527
  });
21350
21528
  }
21351
21529
  });
@@ -21362,6 +21540,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21362
21540
  async resolveDeviceStationarySettings(deviceId) {
21363
21541
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
21364
21542
  }
21543
+ /**
21544
+ * Count SHORT + MOTIONLESS track closes per frame cell.
21545
+ *
21546
+ * The stationary registry cannot see this class of phantom. Promotion needs a
21547
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
21548
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
21549
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
21550
+ * fifteen of them in a day, all at the same spot. Each one is individually
21551
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
21552
+ * to notice it.
21553
+ *
21554
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
21555
+ * standing still at a door is not declared scenery.
21556
+ *
21557
+ * This is the measurement half: it establishes how often a cell repeats
21558
+ * before any suppression is built, so "N closes in what window" comes from
21559
+ * data rather than intuition. It suppresses NOTHING.
21560
+ */
21561
+ noteShortMotionlessTrack(track) {
21562
+ const lifeMs = track.lastSeen - track.firstSeen;
21563
+ const moved = track.totalDistance ?? 0;
21564
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
21565
+ const first = track.positions?.[0];
21566
+ if (!first) return;
21567
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
21568
+ const key = `${track.deviceId}:${track.className}:${cell}`;
21569
+ const now = Date.now();
21570
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
21571
+ seen.push(now);
21572
+ this.shortMotionlessCells.set(key, seen);
21573
+ this.ctx.logger.info("short motionless track closed", {
21574
+ tags: { deviceId: track.deviceId },
21575
+ meta: {
21576
+ trackId: track.trackId,
21577
+ className: track.className,
21578
+ lifeMs,
21579
+ movedPx: Math.round(moved * 10) / 10,
21580
+ cell,
21581
+ repeatsInWindow: seen.length
21582
+ }
21583
+ });
21584
+ }
21365
21585
  stationarySettingsFromCache(deviceId) {
21366
21586
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
21367
21587
  }
@@ -21371,9 +21591,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21371
21591
  /**
21372
21592
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
21373
21593
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
21374
- * when the cached slice is empty, forces one refresh. The `package` slice
21375
- * is written by the orchestrator's package-stage provider (a later slice);
21376
- * until then this returns `[]` and no package events fire.
21594
+ * when the cached slice is empty, forces one refresh.
21595
+ *
21596
+ * The `package` slice is written through the `zone-rules` capability
21597
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
21598
+ * version of this comment claimed the provider did not exist yet and that
21599
+ * "no package events fire" — that was stale, and believing it produced a
21600
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
21601
+ * operator has drawn no package zone rule, nothing more.
21377
21602
  */
21378
21603
  async resolveDevicePackageRules(deviceId) {
21379
21604
  const proxy = await this.ensureProxy(deviceId);
@@ -2296,16 +2296,28 @@ function resolveRuleThreshold(rule) {
2296
2296
  */
2297
2297
  var ZoneEngine = class {
2298
2298
  /**
2299
- * Annotate a single detection with its zone memberships.
2300
- * Returns zones where the detection overlaps above any active
2301
- * rule's threshold (or the engine default if no rule sets one).
2299
+ * Annotate a single detection with its zone memberships — every zone whose
2300
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2301
+ * the detection's own area).
2302
+ *
2303
+ * The previous version of this comment claimed memberships were returned
2304
+ * "above any active rule's threshold"; they were not — the threshold was
2305
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2306
+ * consulted. Read the parameter, not this paragraph.
2307
+ *
2308
+ * `minOverlap` matters because membership is what lands on an event as
2309
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2310
+ * over that field — so this, not the zone-RULE threshold, is what decides
2311
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2312
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2313
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
2302
2314
  */
2303
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2315
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2304
2316
  const memberships = [];
2305
2317
  for (const zone of zones) {
2306
2318
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
2307
2319
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
2308
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2320
+ if (overlap > minOverlap) memberships.push({
2309
2321
  zoneId: zone.id,
2310
2322
  zoneName: zone.name,
2311
2323
  overlap
@@ -2405,6 +2417,10 @@ var FrameProcessor = class {
2405
2417
  * runners that haven't picked up the new gating yet.
2406
2418
  */
2407
2419
  detectionRules;
2420
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2421
+ zoneMembershipMinOverlap;
2422
+ /** See {@link getLastZoneOverlaps}. */
2423
+ lastZoneOverlaps;
2408
2424
  zoneEngine = new ZoneEngine();
2409
2425
  /** Optional stationary-object gate (parked-object suppression). Null until
2410
2426
  * the addon wires it via {@link setStationaryGate}. */
@@ -2417,10 +2433,32 @@ var FrameProcessor = class {
2417
2433
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2418
2434
  this.zones = [];
2419
2435
  this.detectionRules = [];
2436
+ this.zoneMembershipMinOverlap = 0;
2437
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2420
2438
  }
2421
2439
  setZones(zones) {
2422
2440
  this.zones = zones;
2423
2441
  }
2442
+ /**
2443
+ * How much of a detection's box must lie inside a zone for the zone to be
2444
+ * stamped onto it (0–1 fraction of the box's own area).
2445
+ *
2446
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2447
+ * positive overlap counted. Raising it is an operator decision and needs
2448
+ * evidence: a bar set blind removes notifications silently, which is the
2449
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2450
+ * exists so the distribution can be read before a number is picked.
2451
+ */
2452
+ setZoneMembershipMinOverlap(minOverlap) {
2453
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2454
+ }
2455
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2456
+ * recent frame. The engine computes these and the pipeline previously
2457
+ * discarded everything but the ids — which is why no amount of production
2458
+ * data could say how far inside the zone a notifying subject actually was. */
2459
+ getLastZoneOverlaps() {
2460
+ return this.lastZoneOverlaps;
2461
+ }
2424
2462
  setDetectionRules(rules) {
2425
2463
  this.detectionRules = rules;
2426
2464
  }
@@ -2531,11 +2569,14 @@ var FrameProcessor = class {
2531
2569
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2532
2570
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2533
2571
  const zonesByTrack = /* @__PURE__ */ new Map();
2572
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2534
2573
  for (const td of trackedDetections) {
2535
2574
  const m = maskByBbox.get(td.bbox);
2536
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2575
+ const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2537
2576
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2577
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2538
2578
  }
2579
+ this.lastZoneOverlaps = overlapsByTrack;
2539
2580
  const tracked = trackedDetections.map((td) => {
2540
2581
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
2541
2582
  const label = resolveDetectionLabel({
@@ -4629,7 +4670,9 @@ var NcDispatcher = class {
4629
4670
  ruleId: entry.ruleId,
4630
4671
  target: target.name,
4631
4672
  kind: target.kind,
4632
- recordKind: entry.recordKind
4673
+ recordKind: entry.recordKind,
4674
+ eventId: entry.recordId,
4675
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4633
4676
  }
4634
4677
  });
4635
4678
  return { ok: true };
@@ -4667,9 +4710,10 @@ var NcDispatcher = class {
4667
4710
  async buildNotification(entry) {
4668
4711
  const subject = entry.payload.subject;
4669
4712
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4670
- const vars = buildTemplateVars(entry, deviceName);
4713
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
4714
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4671
4715
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4672
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4716
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4673
4717
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4674
4718
  const params = pickParams(entry.payload.params);
4675
4719
  return {
@@ -4868,15 +4912,37 @@ var NcDispatcher = class {
4868
4912
  return null;
4869
4913
  }
4870
4914
  };
4871
- function buildTemplateVars(entry, deviceName) {
4915
+ /**
4916
+ * Map admin zone IDs to their display names for rendering only.
4917
+ *
4918
+ * Order follows `zoneIds` (the order the track visited them), not the zone
4919
+ * catalog. Every failure mode degrades to the ID rather than dropping the
4920
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
4921
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
4922
+ */
4923
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4924
+ if (zoneIds.length === 0) return [];
4925
+ if (getZoneNames === void 0) return [...zoneIds];
4926
+ try {
4927
+ const zones = await getZoneNames(deviceId);
4928
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
4929
+ return zoneIds.map((id) => {
4930
+ const name = byId.get(id);
4931
+ return name !== void 0 && name.trim().length > 0 ? name : id;
4932
+ });
4933
+ } catch {
4934
+ return [...zoneIds];
4935
+ }
4936
+ }
4937
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4872
4938
  const subject = entry.payload.subject;
4873
4939
  const occupancy = subject.occupancy;
4874
4940
  return {
4875
4941
  camera: deviceName,
4876
4942
  class: subject.className,
4877
4943
  label: subject.label ?? "",
4878
- zones: subject.zones.join(", "),
4879
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
4944
+ zones: zoneLabels.join(", "),
4945
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4880
4946
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4881
4947
  time: new Date(subject.timestamp).toLocaleTimeString(),
4882
4948
  rule: entry.payload.ruleName,
@@ -4894,12 +4960,12 @@ function renderTemplate(template, vars) {
4894
4960
  if (template === void 0 || template.trim().length === 0) return null;
4895
4961
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4896
4962
  }
4897
- function defaultBody(entry, deviceName) {
4963
+ function defaultBody(entry, deviceName, zoneLabels) {
4898
4964
  const subject = entry.payload.subject;
4899
4965
  const occupancy = subject.occupancy;
4900
4966
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4901
4967
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4902
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4968
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4903
4969
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4904
4970
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4905
4971
  }
@@ -6677,7 +6743,9 @@ var NotificationCenter = class NotificationCenter {
6677
6743
  rule: rule.name,
6678
6744
  kind,
6679
6745
  failed: evaluation.failedCondition,
6680
- classes: subject.classNames
6746
+ classes: subject.classNames,
6747
+ eventId: subject.recordId,
6748
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6681
6749
  }
6682
6750
  });
6683
6751
  continue;
@@ -6689,7 +6757,9 @@ var NotificationCenter = class NotificationCenter {
6689
6757
  meta: {
6690
6758
  ruleId: rule.id,
6691
6759
  rule: rule.name,
6692
- key
6760
+ key,
6761
+ eventId: subject.recordId,
6762
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6693
6763
  }
6694
6764
  });
6695
6765
  continue;
@@ -6700,7 +6770,11 @@ var NotificationCenter = class NotificationCenter {
6700
6770
  ruleId: rule.id,
6701
6771
  rule: rule.name,
6702
6772
  kind,
6703
- targets: rule.targets.length
6773
+ targets: rule.targets.length,
6774
+ eventId: subject.recordId,
6775
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
6776
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
6777
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6704
6778
  }
6705
6779
  });
6706
6780
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
@@ -13847,6 +13921,23 @@ function resolveDetectionSensitivitySettings(raw) {
13847
13921
  };
13848
13922
  }
13849
13923
  var TrackingSettingsSchema = object({
13924
+ /**
13925
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
13926
+ * box's own area) for that zone to be STAMPED onto the detection.
13927
+ *
13928
+ * This is the field a zone-scoped notification rule ultimately depends on: a
13929
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
13930
+ * a subject that merely clips a zone edge satisfies it. Measured on
13931
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
13932
+ * inside it.
13933
+ *
13934
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
13935
+ * is deliberately an operator decision: the overlap fractions are now logged
13936
+ * (`zone membership` lines), so the bar can be chosen from the distribution
13937
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
13938
+ * gates the DETECTION stage, not what gets stamped.
13939
+ */
13940
+ zoneMembershipMinOverlap: number().min(0).max(1).default(0),
13850
13941
  /** IoU required to match a (predicted) track to a detection. */
13851
13942
  iouThreshold: number().min(0).max(1).default(.3),
13852
13943
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -13983,6 +14074,7 @@ function resolveTrackingSettings(raw) {
13983
14074
  const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
13984
14075
  const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
13985
14076
  return {
14077
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
13986
14078
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
13987
14079
  maxMissedMs,
13988
14080
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -14037,7 +14129,11 @@ function resolveTrackingSettings(raw) {
14037
14129
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
14038
14130
  * misclassified static object).
14039
14131
  *
14040
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14132
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14133
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14134
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14135
+ * all. It is: it suppressed several phantom births on device 615 that same day.
14136
+ * Read the schema, not this paragraph. Historically the intent was byte-identical
14041
14137
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
14042
14138
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
14043
14139
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -14133,10 +14229,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
14133
14229
  if (track === "other" || det === "other") return true;
14134
14230
  return track === det;
14135
14231
  }
14136
- var failOpen = (trackId, reason) => ({
14137
- trackId,
14232
+ var failOpen = (candidate, reason) => ({
14233
+ trackId: candidate.trackId,
14138
14234
  confirmed: true,
14139
- reason
14235
+ reason,
14236
+ className: candidate.className
14140
14237
  });
14141
14238
  function withTimeout(promise, timeoutMs) {
14142
14239
  return new Promise((resolve, reject) => {
@@ -14152,23 +14249,34 @@ function withTimeout(promise, timeoutMs) {
14152
14249
  }
14153
14250
  async function runConfirmation(candidate, config, deps) {
14154
14251
  const crop = await deps.fetchCrop(candidate);
14155
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14252
+ if (!crop) return failOpen(candidate, "no-crop");
14156
14253
  const detections = await deps.redetect(crop);
14157
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
14158
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14254
+ if (detections === null) return failOpen(candidate, "redetect-error");
14255
+ let best;
14256
+ let bestIncompatible;
14257
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14258
+ if (!best || d.score > best.score) best = d;
14259
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14260
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
14159
14261
  return {
14160
14262
  trackId: candidate.trackId,
14161
14263
  confirmed,
14162
- reason: confirmed ? "confirmed" : "suppressed"
14264
+ reason: confirmed ? "confirmed" : "suppressed",
14265
+ className: candidate.className,
14266
+ ...best ? { bestScore: best.score } : {},
14267
+ ...bestIncompatible ? {
14268
+ bestIncompatibleClass: bestIncompatible.macroClass,
14269
+ bestIncompatibleScore: bestIncompatible.score
14270
+ } : {}
14163
14271
  };
14164
14272
  }
14165
14273
  async function confirmOne(candidate, config, deps) {
14166
14274
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
14167
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14275
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
14168
14276
  try {
14169
14277
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
14170
14278
  } catch {
14171
- return failOpen(candidate.trackId, "timeout");
14279
+ return failOpen(candidate, "timeout");
14172
14280
  }
14173
14281
  }
14174
14282
  /**
@@ -14351,8 +14459,23 @@ function resolveMediaSettings(raw) {
14351
14459
  * §3.3 draft said 45s).
14352
14460
  */
14353
14461
  var PackageDropSettingsSchema = object({
14354
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
14355
- packageDropEnabled: boolean().default(false),
14462
+ /**
14463
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14464
+ * package detection**, not a second switch.
14465
+ *
14466
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14467
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14468
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14469
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14470
+ * silently by a boolean none of them mentions.
14471
+ *
14472
+ * Defaulting to true costs nothing on cameras nobody configured:
14473
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14474
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14475
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14476
+ * rule.
14477
+ */
14478
+ packageDropEnabled: boolean().default(true),
14356
14479
  /**
14357
14480
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
14358
14481
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -19571,7 +19694,21 @@ function decodeEmbeddingBase64(base64) {
19571
19694
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
19572
19695
  return Array.from(view);
19573
19696
  }
19697
+ /** A track shorter than this is a candidate phantom, not a subject that came
19698
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
19699
+ var SHORT_TRACK_MAX_MS = 25e3;
19700
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
19701
+ var MOTIONLESS_MAX_PX = 8;
19702
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
19703
+ * to the pixel still land in one cell. */
19704
+ var PHANTOM_CELL_PX = 32;
19705
+ /** How long a cell remembers its closes. */
19706
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
19574
19707
  var PipelineAnalyticsAddon = class extends BaseAddon {
19708
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
19709
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
19710
+ * filtered against the 6-hour window on write, so it stays bounded. */
19711
+ shortMotionlessCells = /* @__PURE__ */ new Map();
19575
19712
  processors = /* @__PURE__ */ new Map();
19576
19713
  trackStore = null;
19577
19714
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -19881,7 +20018,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19881
20018
  });
19882
20019
  },
19883
20020
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
19884
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20021
+ onTrackClosed: (track, ownedMedia, info) => {
20022
+ this.noteShortMotionlessTrack(track);
20023
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20024
+ },
19885
20025
  deriveThumbnailFromKeyFrame: async (input) => {
19886
20026
  const derived = await deriveKeyFrameThumbnailJpeg({
19887
20027
  ...input,
@@ -20368,6 +20508,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20368
20508
  };
20369
20509
  },
20370
20510
  dispatcher: {
20511
+ getZoneNames: async (deviceId) => {
20512
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20513
+ id: z.id,
20514
+ name: z.name
20515
+ }));
20516
+ },
20371
20517
  getZonePolygons: async (deviceId, zoneIds) => {
20372
20518
  const zones = await api.zones.listZones.query({ deviceId });
20373
20519
  const wanted = new Set(zoneIds);
@@ -20745,6 +20891,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20745
20891
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
20746
20892
  processor.setZones(liveZones);
20747
20893
  processor.setDetectionRules(liveRules);
20894
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
20748
20895
  const result = processor.process({
20749
20896
  timestamp: frame.timestamp,
20750
20897
  frame
@@ -20975,7 +21122,29 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20975
21122
  } });
20976
21123
  }
20977
21124
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
20978
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21125
+ if (this.notificationCenter !== null) {
21126
+ const overlaps = processor.getLastZoneOverlaps();
21127
+ for (const e of result.objectEvents) {
21128
+ if (e.zones && e.zones.length > 0) {
21129
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21130
+ this.ctx.logger.info("zone membership stamped on event", {
21131
+ tags: { deviceId },
21132
+ meta: {
21133
+ eventId: e.id,
21134
+ trackId: e.trackId,
21135
+ className: e.className,
21136
+ minOverlap: trk.zoneMembershipMinOverlap,
21137
+ zones: (m ?? []).map((z) => ({
21138
+ id: z.zoneId,
21139
+ name: z.zoneName,
21140
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21141
+ }))
21142
+ }
21143
+ });
21144
+ }
21145
+ this.notificationCenter.onObjectEventPersisted(e);
21146
+ }
21147
+ }
20979
21148
  const objectEmbeddingBests = [];
20980
21149
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
20981
21150
  if (!isClipObjectEmbedding(t)) continue;
@@ -21327,19 +21496,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21327
21496
  }),
21328
21497
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
21329
21498
  onDecision: (decision) => {
21499
+ const meta = {
21500
+ trackId: decision.trackId,
21501
+ reason: decision.reason,
21502
+ className: decision.className,
21503
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21504
+ ...decision.bestIncompatibleClass !== void 0 ? {
21505
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21506
+ bestIncompatibleScore: decision.bestIncompatibleScore
21507
+ } : {},
21508
+ minConfidence: config.minConfidence
21509
+ };
21330
21510
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
21331
21511
  tags: { deviceId },
21332
- meta: {
21333
- trackId: decision.trackId,
21334
- reason: decision.reason
21335
- }
21512
+ meta
21336
21513
  });
21337
21514
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
21338
21515
  tags: { deviceId },
21339
- meta: {
21340
- trackId: decision.trackId,
21341
- reason: decision.reason
21342
- }
21516
+ meta
21517
+ });
21518
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
21519
+ tags: { deviceId },
21520
+ meta
21343
21521
  });
21344
21522
  }
21345
21523
  });
@@ -21356,6 +21534,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21356
21534
  async resolveDeviceStationarySettings(deviceId) {
21357
21535
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
21358
21536
  }
21537
+ /**
21538
+ * Count SHORT + MOTIONLESS track closes per frame cell.
21539
+ *
21540
+ * The stationary registry cannot see this class of phantom. Promotion needs a
21541
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
21542
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
21543
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
21544
+ * fifteen of them in a day, all at the same spot. Each one is individually
21545
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
21546
+ * to notice it.
21547
+ *
21548
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
21549
+ * standing still at a door is not declared scenery.
21550
+ *
21551
+ * This is the measurement half: it establishes how often a cell repeats
21552
+ * before any suppression is built, so "N closes in what window" comes from
21553
+ * data rather than intuition. It suppresses NOTHING.
21554
+ */
21555
+ noteShortMotionlessTrack(track) {
21556
+ const lifeMs = track.lastSeen - track.firstSeen;
21557
+ const moved = track.totalDistance ?? 0;
21558
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
21559
+ const first = track.positions?.[0];
21560
+ if (!first) return;
21561
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
21562
+ const key = `${track.deviceId}:${track.className}:${cell}`;
21563
+ const now = Date.now();
21564
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
21565
+ seen.push(now);
21566
+ this.shortMotionlessCells.set(key, seen);
21567
+ this.ctx.logger.info("short motionless track closed", {
21568
+ tags: { deviceId: track.deviceId },
21569
+ meta: {
21570
+ trackId: track.trackId,
21571
+ className: track.className,
21572
+ lifeMs,
21573
+ movedPx: Math.round(moved * 10) / 10,
21574
+ cell,
21575
+ repeatsInWindow: seen.length
21576
+ }
21577
+ });
21578
+ }
21359
21579
  stationarySettingsFromCache(deviceId) {
21360
21580
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
21361
21581
  }
@@ -21365,9 +21585,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21365
21585
  /**
21366
21586
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
21367
21587
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
21368
- * when the cached slice is empty, forces one refresh. The `package` slice
21369
- * is written by the orchestrator's package-stage provider (a later slice);
21370
- * until then this returns `[]` and no package events fire.
21588
+ * when the cached slice is empty, forces one refresh.
21589
+ *
21590
+ * The `package` slice is written through the `zone-rules` capability
21591
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
21592
+ * version of this comment claimed the provider did not exist yet and that
21593
+ * "no package events fire" — that was stale, and believing it produced a
21594
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
21595
+ * operator has drawn no package zone rule, nothing more.
21371
21596
  */
21372
21597
  async resolveDevicePackageRules(deviceId) {
21373
21598
  const proxy = await this.ensureProxy(deviceId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.18",
3
+ "version": "1.2.19",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",