@camstack/addon-post-analysis 1.2.198 → 1.2.200

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.
@@ -12814,6 +12814,23 @@ function recordToRow$2(subject, data) {
12814
12814
  };
12815
12815
  }
12816
12816
  //#endregion
12817
+ //#region src/notification-center/no-match-report-key.ts
12818
+ /**
12819
+ * Stand-in for "this subject named no class". A distinct token rather than an
12820
+ * empty string so a class-less subject occupies its own bucket instead of
12821
+ * sharing one with every other class-less kind by accident.
12822
+ */
12823
+ var NO_CLASSES = "∅";
12824
+ /**
12825
+ * The bucket key. Stable across calls, insensitive to class ORDER, DUPLICATES
12826
+ * and CASE — a detector that re-orders its class list, repeats one, or changes
12827
+ * its capitalisation must not fork the bucket and double the log volume.
12828
+ */
12829
+ function noMatchReportKey(subject) {
12830
+ const classes = [...new Set(subject.classNames.map((c) => c.toLowerCase()))].sort();
12831
+ return `${subject.deviceId}|${subject.kind}|${classes.length > 0 ? classes.join(",") : NO_CLASSES}`;
12832
+ }
12833
+ //#endregion
12817
12834
  //#region src/notification-center/occupancy-watcher.ts
12818
12835
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
12819
12836
  var FRAME_SCOPE = "@frame";
@@ -21945,10 +21962,15 @@ var NotificationCenter = class NotificationCenter {
21945
21962
  * which leaves those notifications text-only rather than broken.
21946
21963
  */
21947
21964
  stills;
21948
- /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
21965
+ /**
21966
+ * Rate limit for the "matched NO rule" report — see `reportNoMatch`. Keyed by
21967
+ * {@link noMatchReportKey} (device + subject kind + class set), NOT by device:
21968
+ * a person's verdict must never consume the window a cat's verdict needed on
21969
+ * the same camera ([D390]).
21970
+ */
21949
21971
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
21950
21972
  noMatchSuppressed = /* @__PURE__ */ new Map();
21951
- /** Same rate-limit pair for the per-camera mute drop — see `reportMutedDrop`. */
21973
+ /** Same rate-limit pair, same key, for the per-camera mute drop — see `reportMutedDrop`. */
21952
21974
  lastMutedReportAt = /* @__PURE__ */ new Map();
21953
21975
  mutedSuppressed = /* @__PURE__ */ new Map();
21954
21976
  /**
@@ -24719,14 +24741,19 @@ var NotificationCenter = class NotificationCenter {
24719
24741
  * is identical: "why did this camera not notify me?".
24720
24742
  */
24721
24743
  reportMutedDrop(subject, kind, now) {
24722
- const last = this.lastMutedReportAt.get(subject.deviceId) ?? 0;
24723
- const suppressed = this.mutedSuppressed.get(subject.deviceId) ?? 0;
24744
+ const bucket = noMatchReportKey({
24745
+ deviceId: subject.deviceId,
24746
+ kind,
24747
+ classNames: subject.classNames
24748
+ });
24749
+ const last = this.lastMutedReportAt.get(bucket) ?? 0;
24750
+ const suppressed = this.mutedSuppressed.get(bucket) ?? 0;
24724
24751
  if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
24725
- this.mutedSuppressed.set(subject.deviceId, suppressed + 1);
24752
+ this.mutedSuppressed.set(bucket, suppressed + 1);
24726
24753
  return;
24727
24754
  }
24728
- this.lastMutedReportAt.set(subject.deviceId, now);
24729
- this.mutedSuppressed.set(subject.deviceId, 0);
24755
+ this.lastMutedReportAt.set(bucket, now);
24756
+ this.mutedSuppressed.set(bucket, 0);
24730
24757
  this.logger.info("notifications muted for this camera — event dropped before rule evaluation", {
24731
24758
  tags: { deviceId: subject.deviceId },
24732
24759
  meta: {
@@ -24738,16 +24765,23 @@ var NotificationCenter = class NotificationCenter {
24738
24765
  }
24739
24766
  });
24740
24767
  }
24741
- /** See the call site. Bounded to one line per device per window. */
24768
+ /** See the call site. Bounded to one line per {@link noMatchReportKey} bucket
24769
+ * per window — device, subject kind and class set, so one subject's verdict
24770
+ * cannot stand in for another's on the same camera ([D390]). */
24742
24771
  reportNoMatch(subject, kind, rejections, now) {
24743
- const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
24744
- const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
24772
+ const bucket = noMatchReportKey({
24773
+ deviceId: subject.deviceId,
24774
+ kind,
24775
+ classNames: subject.classNames
24776
+ });
24777
+ const last = this.lastNoMatchReportAt.get(bucket) ?? 0;
24778
+ const suppressed = this.noMatchSuppressed.get(bucket) ?? 0;
24745
24779
  if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
24746
- this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
24780
+ this.noMatchSuppressed.set(bucket, suppressed + 1);
24747
24781
  return;
24748
24782
  }
24749
- this.lastNoMatchReportAt.set(subject.deviceId, now);
24750
- this.noMatchSuppressed.set(subject.deviceId, 0);
24783
+ this.lastNoMatchReportAt.set(bucket, now);
24784
+ this.noMatchSuppressed.set(bucket, 0);
24751
24785
  this.logger.info("event matched NO rule", {
24752
24786
  tags: { deviceId: subject.deviceId },
24753
24787
  meta: {
@@ -34027,6 +34061,22 @@ function pairRiders(detections) {
34027
34061
  }
34028
34062
  return pairs;
34029
34063
  }
34064
+ /** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
34065
+ * the counts are per camera and per detection source. */
34066
+ var LooseBindSampler = class {
34067
+ counts = /* @__PURE__ */ new Map();
34068
+ /** Increment the (stage, gate) counter and answer with the running total when
34069
+ * this decision should be reported, or `undefined` when it is sampled out. */
34070
+ next(stage, gate) {
34071
+ const key = `${stage}|${gate}`;
34072
+ const count = (this.counts.get(key) ?? 0) + 1;
34073
+ this.counts.set(key, count);
34074
+ if (count === 1 || count % 50 === 0) return count;
34075
+ }
34076
+ reset() {
34077
+ this.counts.clear();
34078
+ }
34079
+ };
34030
34080
  //#endregion
34031
34081
  //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
34032
34082
  /**
@@ -34084,22 +34134,6 @@ function growthAdjacentMatch(track, det, ctx) {
34084
34134
  const dc = bboxCentroid(det);
34085
34135
  return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
34086
34136
  }
34087
- /** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
34088
- * the counts are per camera and per detection source. */
34089
- var LooseBindSampler = class {
34090
- counts = /* @__PURE__ */ new Map();
34091
- /** Increment the (stage, gate) counter and answer with the running total when
34092
- * this decision should be reported, or `undefined` when it is sampled out. */
34093
- next(stage, gate) {
34094
- const key = `${stage}|${gate}`;
34095
- const count = (this.counts.get(key) ?? 0) + 1;
34096
- this.counts.set(key, count);
34097
- if (count === 1 || count % 50 === 0) return count;
34098
- }
34099
- reset() {
34100
- this.counts.clear();
34101
- }
34102
- };
34103
34137
  /** Per-reason counter + sampling decision. One instance per tracker, so the
34104
34138
  * counts are per camera and per detection source. */
34105
34139
  var SpawnRefusalSampler = class {
@@ -34243,7 +34277,35 @@ var SortTracker = class SortTracker {
34243
34277
  againstTrackClass: extra.against.class
34244
34278
  } : {},
34245
34279
  refusalCount,
34246
- sampledEvery: 10
34280
+ sampledEvery: 10,
34281
+ ...extra.droppedTrackId !== void 0 ? { droppedTrackId: extra.droppedTrackId } : {},
34282
+ ...extra.trackLifeMs !== void 0 ? { trackLifeMs: extra.trackLifeMs } : {}
34283
+ });
34284
+ }
34285
+ /**
34286
+ * Report a TRACK that ends having emitted nothing — the last silent branch
34287
+ * on the detection→position path (D391).
34288
+ *
34289
+ * The subject is the track, not one box, so the record carries the track's
34290
+ * own last class/score/bbox: those ARE the detection that fed it last, and
34291
+ * they are what an operator matches against the recovery line that produced
34292
+ * it. Reported once (`lossReported`) and only where the track can no longer
34293
+ * come back — a graveyarded track may still resurrect, and a line about work
34294
+ * that was not lost is worse than no line.
34295
+ */
34296
+ reportTrackLoss(track) {
34297
+ if (track.emitted || track.lossReported) return;
34298
+ track.lossReported = true;
34299
+ this.reportRefusal("unconfirmed-spawn-lost", {
34300
+ class: track.class,
34301
+ originalClass: track.originalClass,
34302
+ score: track.score,
34303
+ bbox: track.bbox
34304
+ }, {
34305
+ threshold: this.config.minHits,
34306
+ measured: track.hits,
34307
+ droppedTrackId: track.id,
34308
+ trackLifeMs: track.lastSeen - track.firstSeen
34247
34309
  });
34248
34310
  }
34249
34311
  /** Emit one salvage decision, subject to {@link LOOSE_BIND_LOG_SAMPLE_EVERY}.
@@ -34438,7 +34500,22 @@ var SortTracker = class SortTracker {
34438
34500
  }
34439
34501
  const streak = (this.dupStreaks.get(key) ?? 0) + 1;
34440
34502
  if (streak >= this.config.dedupMergeFrames) {
34441
- dropIds.add(SortTracker.duplicateLoser(a, b).id);
34503
+ const loser = SortTracker.duplicateLoser(a, b);
34504
+ const winner = loser === a ? b : a;
34505
+ dropIds.add(loser.id);
34506
+ this.reportRefusal("merged-duplicate", {
34507
+ class: loser.class,
34508
+ originalClass: loser.originalClass,
34509
+ score: loser.score,
34510
+ bbox: loser.bbox
34511
+ }, {
34512
+ threshold: this.config.dedupMergeIou,
34513
+ measured: iou$3(a.bbox, b.bbox),
34514
+ against: winner,
34515
+ droppedTrackId: loser.id,
34516
+ trackLifeMs: loser.lastSeen - loser.firstSeen
34517
+ });
34518
+ loser.lossReported = true;
34442
34519
  this.dupStreaks.delete(key);
34443
34520
  } else this.dupStreaks.set(key, streak);
34444
34521
  }
@@ -34620,6 +34697,7 @@ var SortTracker = class SortTracker {
34620
34697
  track.lost = true;
34621
34698
  track.lostAt = timestamp;
34622
34699
  track.resurrectable = false;
34700
+ this.reportTrackLoss(track);
34623
34701
  this.lostTracks.push(track);
34624
34702
  } else alive.push(track);
34625
34703
  this.tracks = alive;
@@ -34631,11 +34709,15 @@ var SortTracker = class SortTracker {
34631
34709
  track.lost = true;
34632
34710
  track.lostAt = track.lastSeen;
34633
34711
  track.resurrectable = false;
34712
+ this.reportTrackLoss(track);
34634
34713
  this.lostTracks.push(track);
34635
34714
  } else alive.push(track);
34636
34715
  this.tracks = alive;
34637
34716
  }
34638
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
34717
+ const stillResurrectable = [];
34718
+ for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
34719
+ else this.reportTrackLoss(t);
34720
+ this.lostTracks = stillResurrectable;
34639
34721
  const used = /* @__PURE__ */ new Set();
34640
34722
  const matchedTracks = /* @__PURE__ */ new Set();
34641
34723
  const matched = /* @__PURE__ */ new Map();
@@ -34833,12 +34915,15 @@ var SortTracker = class SortTracker {
34833
34915
  lost: false,
34834
34916
  lostAt: 0,
34835
34917
  resurrectable: true,
34836
- classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
34918
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
34919
+ emitted: false,
34920
+ lossReported: false
34837
34921
  });
34838
34922
  }
34839
34923
  this.tracks = surviving;
34840
34924
  this.resolveDuplicates();
34841
34925
  return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => {
34926
+ t.emitted = true;
34842
34927
  const scene = resurrectedScene.get(t);
34843
34928
  return {
34844
34929
  class: this.resolveReportedClass(t),
@@ -35206,6 +35291,10 @@ var FrameProcessor = class {
35206
35291
  */
35207
35292
  reportedRefusalByTrack = /* @__PURE__ */ new Map();
35208
35293
  zoneEngine = new ZoneEngine();
35294
+ /** Sink + sampler for the drops this stage owns (the detection-stage zone
35295
+ * filter). Shares the tracker's vocabulary — see {@link SpawnRefusalReason}. */
35296
+ spawnRefusalObserver = null;
35297
+ zoneRefusalSampler = new SpawnRefusalSampler();
35209
35298
  /** Optional stationary-object gate (parked-object suppression). Null until
35210
35299
  * the addon wires it via {@link setStationaryGate}. */
35211
35300
  stationaryGate = null;
@@ -35300,6 +35389,7 @@ var FrameProcessor = class {
35300
35389
  */
35301
35390
  setSpawnRefusalObserver(observer) {
35302
35391
  this.tracker.setSpawnRefusalObserver(observer);
35392
+ this.spawnRefusalObserver = observer;
35303
35393
  }
35304
35394
  setBindObserver(observer) {
35305
35395
  this.tracker.setBindObserver(observer);
@@ -35309,6 +35399,32 @@ var FrameProcessor = class {
35309
35399
  dropTrack(trackId) {
35310
35400
  this.tracker.dropTrack(trackId);
35311
35401
  }
35402
+ /**
35403
+ * Say which detections a detection-stage zone rule excluded (D391).
35404
+ *
35405
+ * `filterDetections` has always ANSWERED this — it returns `excluded` — and
35406
+ * this stage dropped the answer on the floor, so a camera whose subject the
35407
+ * rules exclude was indistinguishable in the log from a camera whose tracker
35408
+ * ate it. Sampled like every other refusal: an excluded box is excluded on
35409
+ * every frame of a passage.
35410
+ */
35411
+ reportZoneFiltered(excluded) {
35412
+ const observer = this.spawnRefusalObserver;
35413
+ if (!observer || excluded.length === 0) return;
35414
+ for (const fd of excluded) {
35415
+ const refusalCount = this.zoneRefusalSampler.next("zone-filtered");
35416
+ if (refusalCount === void 0) continue;
35417
+ observer({
35418
+ reason: "zone-filtered",
35419
+ detClass: fd.detection.class,
35420
+ detScore: fd.detection.score,
35421
+ bbox: fd.detection.bbox,
35422
+ ...fd.detection.nativeRecovery !== void 0 ? { nativeRecoveryScore: fd.detection.nativeRecovery.score } : {},
35423
+ refusalCount,
35424
+ sampledEvery: 10
35425
+ });
35426
+ }
35427
+ }
35312
35428
  process(input) {
35313
35429
  const { timestamp, frame } = input;
35314
35430
  const frameWidth = frame.width;
@@ -35394,7 +35510,8 @@ var FrameProcessor = class {
35394
35510
  }
35395
35511
  });
35396
35512
  }
35397
- const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
35513
+ const { passed, excluded } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
35514
+ this.reportZoneFiltered(excluded);
35398
35515
  let filteredDetections = passed.map((fd) => fd.detection);
35399
35516
  const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
35400
35517
  id: String(i),
@@ -74304,7 +74421,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74304
74421
  });
74305
74422
  });
74306
74423
  p.setSpawnRefusalObserver((record) => {
74307
- this.ctx.logger.info("tracker: detection dropped — no track was born from it", {
74424
+ this.ctx.logger.info("tracker: detection dropped — nothing downstream received it", {
74308
74425
  tags: { deviceId },
74309
74426
  meta: {
74310
74427
  source,
@@ -12807,6 +12807,23 @@ function recordToRow$2(subject, data) {
12807
12807
  };
12808
12808
  }
12809
12809
  //#endregion
12810
+ //#region src/notification-center/no-match-report-key.ts
12811
+ /**
12812
+ * Stand-in for "this subject named no class". A distinct token rather than an
12813
+ * empty string so a class-less subject occupies its own bucket instead of
12814
+ * sharing one with every other class-less kind by accident.
12815
+ */
12816
+ var NO_CLASSES = "∅";
12817
+ /**
12818
+ * The bucket key. Stable across calls, insensitive to class ORDER, DUPLICATES
12819
+ * and CASE — a detector that re-orders its class list, repeats one, or changes
12820
+ * its capitalisation must not fork the bucket and double the log volume.
12821
+ */
12822
+ function noMatchReportKey(subject) {
12823
+ const classes = [...new Set(subject.classNames.map((c) => c.toLowerCase()))].sort();
12824
+ return `${subject.deviceId}|${subject.kind}|${classes.length > 0 ? classes.join(",") : NO_CLASSES}`;
12825
+ }
12826
+ //#endregion
12810
12827
  //#region src/notification-center/occupancy-watcher.ts
12811
12828
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
12812
12829
  var FRAME_SCOPE = "@frame";
@@ -21923,10 +21940,15 @@ var NotificationCenter = class NotificationCenter {
21923
21940
  * which leaves those notifications text-only rather than broken.
21924
21941
  */
21925
21942
  stills;
21926
- /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
21943
+ /**
21944
+ * Rate limit for the "matched NO rule" report — see `reportNoMatch`. Keyed by
21945
+ * {@link noMatchReportKey} (device + subject kind + class set), NOT by device:
21946
+ * a person's verdict must never consume the window a cat's verdict needed on
21947
+ * the same camera ([D390]).
21948
+ */
21927
21949
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
21928
21950
  noMatchSuppressed = /* @__PURE__ */ new Map();
21929
- /** Same rate-limit pair for the per-camera mute drop — see `reportMutedDrop`. */
21951
+ /** Same rate-limit pair, same key, for the per-camera mute drop — see `reportMutedDrop`. */
21930
21952
  lastMutedReportAt = /* @__PURE__ */ new Map();
21931
21953
  mutedSuppressed = /* @__PURE__ */ new Map();
21932
21954
  /**
@@ -24697,14 +24719,19 @@ var NotificationCenter = class NotificationCenter {
24697
24719
  * is identical: "why did this camera not notify me?".
24698
24720
  */
24699
24721
  reportMutedDrop(subject, kind, now) {
24700
- const last = this.lastMutedReportAt.get(subject.deviceId) ?? 0;
24701
- const suppressed = this.mutedSuppressed.get(subject.deviceId) ?? 0;
24722
+ const bucket = noMatchReportKey({
24723
+ deviceId: subject.deviceId,
24724
+ kind,
24725
+ classNames: subject.classNames
24726
+ });
24727
+ const last = this.lastMutedReportAt.get(bucket) ?? 0;
24728
+ const suppressed = this.mutedSuppressed.get(bucket) ?? 0;
24702
24729
  if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
24703
- this.mutedSuppressed.set(subject.deviceId, suppressed + 1);
24730
+ this.mutedSuppressed.set(bucket, suppressed + 1);
24704
24731
  return;
24705
24732
  }
24706
- this.lastMutedReportAt.set(subject.deviceId, now);
24707
- this.mutedSuppressed.set(subject.deviceId, 0);
24733
+ this.lastMutedReportAt.set(bucket, now);
24734
+ this.mutedSuppressed.set(bucket, 0);
24708
24735
  this.logger.info("notifications muted for this camera — event dropped before rule evaluation", {
24709
24736
  tags: { deviceId: subject.deviceId },
24710
24737
  meta: {
@@ -24716,16 +24743,23 @@ var NotificationCenter = class NotificationCenter {
24716
24743
  }
24717
24744
  });
24718
24745
  }
24719
- /** See the call site. Bounded to one line per device per window. */
24746
+ /** See the call site. Bounded to one line per {@link noMatchReportKey} bucket
24747
+ * per window — device, subject kind and class set, so one subject's verdict
24748
+ * cannot stand in for another's on the same camera ([D390]). */
24720
24749
  reportNoMatch(subject, kind, rejections, now) {
24721
- const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
24722
- const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
24750
+ const bucket = noMatchReportKey({
24751
+ deviceId: subject.deviceId,
24752
+ kind,
24753
+ classNames: subject.classNames
24754
+ });
24755
+ const last = this.lastNoMatchReportAt.get(bucket) ?? 0;
24756
+ const suppressed = this.noMatchSuppressed.get(bucket) ?? 0;
24723
24757
  if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
24724
- this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
24758
+ this.noMatchSuppressed.set(bucket, suppressed + 1);
24725
24759
  return;
24726
24760
  }
24727
- this.lastNoMatchReportAt.set(subject.deviceId, now);
24728
- this.noMatchSuppressed.set(subject.deviceId, 0);
24761
+ this.lastNoMatchReportAt.set(bucket, now);
24762
+ this.noMatchSuppressed.set(bucket, 0);
24729
24763
  this.logger.info("event matched NO rule", {
24730
24764
  tags: { deviceId: subject.deviceId },
24731
24765
  meta: {
@@ -34002,6 +34036,22 @@ function pairRiders(detections) {
34002
34036
  }
34003
34037
  return pairs;
34004
34038
  }
34039
+ /** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
34040
+ * the counts are per camera and per detection source. */
34041
+ var LooseBindSampler = class {
34042
+ counts = /* @__PURE__ */ new Map();
34043
+ /** Increment the (stage, gate) counter and answer with the running total when
34044
+ * this decision should be reported, or `undefined` when it is sampled out. */
34045
+ next(stage, gate) {
34046
+ const key = `${stage}|${gate}`;
34047
+ const count = (this.counts.get(key) ?? 0) + 1;
34048
+ this.counts.set(key, count);
34049
+ if (count === 1 || count % 50 === 0) return count;
34050
+ }
34051
+ reset() {
34052
+ this.counts.clear();
34053
+ }
34054
+ };
34005
34055
  //#endregion
34006
34056
  //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
34007
34057
  /**
@@ -34059,22 +34109,6 @@ function growthAdjacentMatch(track, det, ctx) {
34059
34109
  const dc = bboxCentroid(det);
34060
34110
  return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
34061
34111
  }
34062
- /** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
34063
- * the counts are per camera and per detection source. */
34064
- var LooseBindSampler = class {
34065
- counts = /* @__PURE__ */ new Map();
34066
- /** Increment the (stage, gate) counter and answer with the running total when
34067
- * this decision should be reported, or `undefined` when it is sampled out. */
34068
- next(stage, gate) {
34069
- const key = `${stage}|${gate}`;
34070
- const count = (this.counts.get(key) ?? 0) + 1;
34071
- this.counts.set(key, count);
34072
- if (count === 1 || count % 50 === 0) return count;
34073
- }
34074
- reset() {
34075
- this.counts.clear();
34076
- }
34077
- };
34078
34112
  /** Per-reason counter + sampling decision. One instance per tracker, so the
34079
34113
  * counts are per camera and per detection source. */
34080
34114
  var SpawnRefusalSampler = class {
@@ -34218,7 +34252,35 @@ var SortTracker = class SortTracker {
34218
34252
  againstTrackClass: extra.against.class
34219
34253
  } : {},
34220
34254
  refusalCount,
34221
- sampledEvery: 10
34255
+ sampledEvery: 10,
34256
+ ...extra.droppedTrackId !== void 0 ? { droppedTrackId: extra.droppedTrackId } : {},
34257
+ ...extra.trackLifeMs !== void 0 ? { trackLifeMs: extra.trackLifeMs } : {}
34258
+ });
34259
+ }
34260
+ /**
34261
+ * Report a TRACK that ends having emitted nothing — the last silent branch
34262
+ * on the detection→position path (D391).
34263
+ *
34264
+ * The subject is the track, not one box, so the record carries the track's
34265
+ * own last class/score/bbox: those ARE the detection that fed it last, and
34266
+ * they are what an operator matches against the recovery line that produced
34267
+ * it. Reported once (`lossReported`) and only where the track can no longer
34268
+ * come back — a graveyarded track may still resurrect, and a line about work
34269
+ * that was not lost is worse than no line.
34270
+ */
34271
+ reportTrackLoss(track) {
34272
+ if (track.emitted || track.lossReported) return;
34273
+ track.lossReported = true;
34274
+ this.reportRefusal("unconfirmed-spawn-lost", {
34275
+ class: track.class,
34276
+ originalClass: track.originalClass,
34277
+ score: track.score,
34278
+ bbox: track.bbox
34279
+ }, {
34280
+ threshold: this.config.minHits,
34281
+ measured: track.hits,
34282
+ droppedTrackId: track.id,
34283
+ trackLifeMs: track.lastSeen - track.firstSeen
34222
34284
  });
34223
34285
  }
34224
34286
  /** Emit one salvage decision, subject to {@link LOOSE_BIND_LOG_SAMPLE_EVERY}.
@@ -34413,7 +34475,22 @@ var SortTracker = class SortTracker {
34413
34475
  }
34414
34476
  const streak = (this.dupStreaks.get(key) ?? 0) + 1;
34415
34477
  if (streak >= this.config.dedupMergeFrames) {
34416
- dropIds.add(SortTracker.duplicateLoser(a, b).id);
34478
+ const loser = SortTracker.duplicateLoser(a, b);
34479
+ const winner = loser === a ? b : a;
34480
+ dropIds.add(loser.id);
34481
+ this.reportRefusal("merged-duplicate", {
34482
+ class: loser.class,
34483
+ originalClass: loser.originalClass,
34484
+ score: loser.score,
34485
+ bbox: loser.bbox
34486
+ }, {
34487
+ threshold: this.config.dedupMergeIou,
34488
+ measured: iou$3(a.bbox, b.bbox),
34489
+ against: winner,
34490
+ droppedTrackId: loser.id,
34491
+ trackLifeMs: loser.lastSeen - loser.firstSeen
34492
+ });
34493
+ loser.lossReported = true;
34417
34494
  this.dupStreaks.delete(key);
34418
34495
  } else this.dupStreaks.set(key, streak);
34419
34496
  }
@@ -34595,6 +34672,7 @@ var SortTracker = class SortTracker {
34595
34672
  track.lost = true;
34596
34673
  track.lostAt = timestamp;
34597
34674
  track.resurrectable = false;
34675
+ this.reportTrackLoss(track);
34598
34676
  this.lostTracks.push(track);
34599
34677
  } else alive.push(track);
34600
34678
  this.tracks = alive;
@@ -34606,11 +34684,15 @@ var SortTracker = class SortTracker {
34606
34684
  track.lost = true;
34607
34685
  track.lostAt = track.lastSeen;
34608
34686
  track.resurrectable = false;
34687
+ this.reportTrackLoss(track);
34609
34688
  this.lostTracks.push(track);
34610
34689
  } else alive.push(track);
34611
34690
  this.tracks = alive;
34612
34691
  }
34613
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
34692
+ const stillResurrectable = [];
34693
+ for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
34694
+ else this.reportTrackLoss(t);
34695
+ this.lostTracks = stillResurrectable;
34614
34696
  const used = /* @__PURE__ */ new Set();
34615
34697
  const matchedTracks = /* @__PURE__ */ new Set();
34616
34698
  const matched = /* @__PURE__ */ new Map();
@@ -34808,12 +34890,15 @@ var SortTracker = class SortTracker {
34808
34890
  lost: false,
34809
34891
  lostAt: 0,
34810
34892
  resurrectable: true,
34811
- classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
34893
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
34894
+ emitted: false,
34895
+ lossReported: false
34812
34896
  });
34813
34897
  }
34814
34898
  this.tracks = surviving;
34815
34899
  this.resolveDuplicates();
34816
34900
  return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => {
34901
+ t.emitted = true;
34817
34902
  const scene = resurrectedScene.get(t);
34818
34903
  return {
34819
34904
  class: this.resolveReportedClass(t),
@@ -35181,6 +35266,10 @@ var FrameProcessor = class {
35181
35266
  */
35182
35267
  reportedRefusalByTrack = /* @__PURE__ */ new Map();
35183
35268
  zoneEngine = new ZoneEngine();
35269
+ /** Sink + sampler for the drops this stage owns (the detection-stage zone
35270
+ * filter). Shares the tracker's vocabulary — see {@link SpawnRefusalReason}. */
35271
+ spawnRefusalObserver = null;
35272
+ zoneRefusalSampler = new SpawnRefusalSampler();
35184
35273
  /** Optional stationary-object gate (parked-object suppression). Null until
35185
35274
  * the addon wires it via {@link setStationaryGate}. */
35186
35275
  stationaryGate = null;
@@ -35275,6 +35364,7 @@ var FrameProcessor = class {
35275
35364
  */
35276
35365
  setSpawnRefusalObserver(observer) {
35277
35366
  this.tracker.setSpawnRefusalObserver(observer);
35367
+ this.spawnRefusalObserver = observer;
35278
35368
  }
35279
35369
  setBindObserver(observer) {
35280
35370
  this.tracker.setBindObserver(observer);
@@ -35284,6 +35374,32 @@ var FrameProcessor = class {
35284
35374
  dropTrack(trackId) {
35285
35375
  this.tracker.dropTrack(trackId);
35286
35376
  }
35377
+ /**
35378
+ * Say which detections a detection-stage zone rule excluded (D391).
35379
+ *
35380
+ * `filterDetections` has always ANSWERED this — it returns `excluded` — and
35381
+ * this stage dropped the answer on the floor, so a camera whose subject the
35382
+ * rules exclude was indistinguishable in the log from a camera whose tracker
35383
+ * ate it. Sampled like every other refusal: an excluded box is excluded on
35384
+ * every frame of a passage.
35385
+ */
35386
+ reportZoneFiltered(excluded) {
35387
+ const observer = this.spawnRefusalObserver;
35388
+ if (!observer || excluded.length === 0) return;
35389
+ for (const fd of excluded) {
35390
+ const refusalCount = this.zoneRefusalSampler.next("zone-filtered");
35391
+ if (refusalCount === void 0) continue;
35392
+ observer({
35393
+ reason: "zone-filtered",
35394
+ detClass: fd.detection.class,
35395
+ detScore: fd.detection.score,
35396
+ bbox: fd.detection.bbox,
35397
+ ...fd.detection.nativeRecovery !== void 0 ? { nativeRecoveryScore: fd.detection.nativeRecovery.score } : {},
35398
+ refusalCount,
35399
+ sampledEvery: 10
35400
+ });
35401
+ }
35402
+ }
35287
35403
  process(input) {
35288
35404
  const { timestamp, frame } = input;
35289
35405
  const frameWidth = frame.width;
@@ -35369,7 +35485,8 @@ var FrameProcessor = class {
35369
35485
  }
35370
35486
  });
35371
35487
  }
35372
- const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
35488
+ const { passed, excluded } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
35489
+ this.reportZoneFiltered(excluded);
35373
35490
  let filteredDetections = passed.map((fd) => fd.detection);
35374
35491
  const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
35375
35492
  id: String(i),
@@ -74230,7 +74347,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
74230
74347
  });
74231
74348
  });
74232
74349
  p.setSpawnRefusalObserver((record) => {
74233
- this.ctx.logger.info("tracker: detection dropped — no track was born from it", {
74350
+ this.ctx.logger.info("tracker: detection dropped — nothing downstream received it", {
74234
74351
  tags: { deviceId },
74235
74352
  meta: {
74236
74353
  source,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.198",
3
+ "version": "1.2.200",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",