@camstack/addon-post-analysis 1.1.23 → 1.1.25

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,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DbgDsnRE.js");
6
- const require_resolve_frame = require("../resolve-frame-nQMd6Z8B.js");
5
+ const require_dist = require("../dist-CCC79h7t.js");
6
+ const require_resolve_frame = require("../resolve-frame-sKYbstL-.js");
7
7
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
8
  let sharp = require("sharp");
9
9
  sharp = require_dist.__toESM(sharp);
@@ -699,17 +699,24 @@ function normalizeToPixel(p, width, height) {
699
699
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
700
700
  var DEFAULT_TRACKER_CONFIG = {
701
701
  iouThreshold: .3,
702
- maxMissedFrames: 30,
702
+ maxMissedMs: 4e3,
703
703
  minHits: 3,
704
704
  predictiveCoasting: true,
705
705
  classGating: true,
706
706
  occlusionEnabled: true,
707
707
  occlusionContainment: .6,
708
- occlusionMaxMissedFrames: 60,
709
- maxTrackLifetimeMs: 3e5
708
+ occlusionMaxMissedMs: 8e3,
709
+ maxTrackLifetimeMs: 3e5,
710
+ rescueIouThreshold: .1,
711
+ rescueCentroidFactor: .75,
712
+ resurrectionWindowMs: 8e3,
713
+ stationarySpeedPx: 2
710
714
  };
711
715
  var MAX_PATH_LENGTH = 300;
712
716
  var nextTrackId = 1;
717
+ function clamp(value, min, max) {
718
+ return Math.max(min, Math.min(max, value));
719
+ }
713
720
  function iou$1(a, b) {
714
721
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
715
722
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
@@ -739,26 +746,51 @@ var SortTracker = class {
739
746
  };
740
747
  }
741
748
  /** Where a track is expected this frame — extrapolated by velocity while
742
- * coasting (predictiveCoasting), else its last known bbox. */
749
+ * coasting (predictiveCoasting), else its last known bbox. A stationary
750
+ * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
751
+ * box off a sitting object; otherwise the drift is capped at one bbox size
752
+ * so a stale velocity can't fling the box across the frame. Public so the
753
+ * clamp is directly unit-testable. */
743
754
  predicted(t) {
744
755
  if (!this.config.predictiveCoasting) return t.bbox;
756
+ if (Math.hypot(t.velocity.dx, t.velocity.dy) < this.config.stationarySpeedPx) return t.bbox;
745
757
  const f = t.age + 1;
758
+ const driftX = clamp(t.velocity.dx * f, -t.bbox.w, t.bbox.w);
759
+ const driftY = clamp(t.velocity.dy * f, -t.bbox.h, t.bbox.h);
746
760
  return {
747
- x: t.bbox.x + t.velocity.dx * f,
748
- y: t.bbox.y + t.velocity.dy * f,
761
+ x: t.bbox.x + driftX,
762
+ y: t.bbox.y + driftY,
749
763
  w: t.bbox.w,
750
764
  h: t.bbox.h
751
765
  };
752
766
  }
767
+ /**
768
+ * Loose same-class gate used by the rescue and resurrection passes: accept
769
+ * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
770
+ * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
771
+ * gated (a rescue must never cross classes), independent of `classGating`.
772
+ */
773
+ looseMatch(track, det) {
774
+ if (track.class !== det.class) return false;
775
+ if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
776
+ const tc = bboxCentroid(track.bbox);
777
+ const dc = bboxCentroid(det.bbox);
778
+ const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
779
+ const diag = Math.hypot(track.bbox.w, track.bbox.h);
780
+ return dist <= this.config.rescueCentroidFactor * diag;
781
+ }
753
782
  update(detections, timestamp) {
754
783
  if (this.config.maxTrackLifetimeMs > 0) {
755
784
  const alive = [];
756
785
  for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
757
786
  track.lost = true;
787
+ track.lostAt = timestamp;
788
+ track.resurrectable = false;
758
789
  this.lostTracks.push(track);
759
790
  } else alive.push(track);
760
791
  this.tracks = alive;
761
792
  }
793
+ this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
762
794
  const used = /* @__PURE__ */ new Set();
763
795
  const matchedTracks = /* @__PURE__ */ new Set();
764
796
  const matched = /* @__PURE__ */ new Map();
@@ -783,6 +815,27 @@ var SortTracker = class {
783
815
  matchedTracks.add(pair.track);
784
816
  used.add(pair.detIdx);
785
817
  }
818
+ const rescuePairs = [];
819
+ for (const track of this.tracks) {
820
+ if (matchedTracks.has(track)) continue;
821
+ for (let di = 0; di < detections.length; di++) {
822
+ if (used.has(di)) continue;
823
+ const det = detections[di];
824
+ if (!this.looseMatch(track, det)) continue;
825
+ rescuePairs.push({
826
+ track,
827
+ detIdx: di,
828
+ score: iou$1(track.bbox, det.bbox)
829
+ });
830
+ }
831
+ }
832
+ rescuePairs.sort((a, b) => b.score - a.score);
833
+ for (const pair of rescuePairs) {
834
+ if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
835
+ matched.set(pair.track, detections[pair.detIdx]);
836
+ matchedTracks.add(pair.track);
837
+ used.add(pair.detIdx);
838
+ }
786
839
  for (const [track, det] of matched) {
787
840
  const prevCenter = bboxCentroid({
788
841
  x: track.bbox.x,
@@ -821,12 +874,47 @@ var SortTracker = class {
821
874
  }
822
875
  const occluded = this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(this.predicted(track), ob) >= this.config.occlusionContainment);
823
876
  track.age++;
824
- const limit = occluded ? this.config.occlusionMaxMissedFrames : this.config.maxMissedFrames;
825
- if (track.age > limit) {
877
+ if (timestamp - track.lastSeen > (occluded ? this.config.occlusionMaxMissedMs : this.config.maxMissedMs)) {
826
878
  track.lost = true;
879
+ track.lostAt = timestamp;
880
+ track.resurrectable = true;
827
881
  this.lostTracks.push(track);
828
882
  } else surviving.push(track);
829
883
  }
884
+ for (let di = 0; di < detections.length; di++) {
885
+ if (used.has(di)) continue;
886
+ const det = detections[di];
887
+ let best;
888
+ let bestScore = -1;
889
+ for (const lost of this.lostTracks) {
890
+ if (!lost.resurrectable) continue;
891
+ if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
892
+ if (!this.looseMatch(lost, det)) continue;
893
+ const score = iou$1(lost.bbox, det.bbox);
894
+ if (score > bestScore) {
895
+ best = lost;
896
+ bestScore = score;
897
+ }
898
+ }
899
+ if (!best) continue;
900
+ this.lostTracks = this.lostTracks.filter((t) => t !== best);
901
+ best.bbox = det.bbox;
902
+ best.class = det.class;
903
+ best.originalClass = det.originalClass;
904
+ best.score = det.score;
905
+ best.age = 0;
906
+ best.hits++;
907
+ best.lost = false;
908
+ best.lastSeen = timestamp;
909
+ best.velocity = {
910
+ dx: 0,
911
+ dy: 0
912
+ };
913
+ best.path.push(det.bbox);
914
+ if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
915
+ surviving.push(best);
916
+ used.add(di);
917
+ }
830
918
  for (let di = 0; di < detections.length; di++) {
831
919
  if (used.has(di)) continue;
832
920
  const det = detections[di];
@@ -846,7 +934,9 @@ var SortTracker = class {
846
934
  dx: 0,
847
935
  dy: 0
848
936
  },
849
- lost: false
937
+ lost: false,
938
+ lostAt: 0,
939
+ resurrectable: true
850
940
  });
851
941
  }
852
942
  this.tracks = surviving;
@@ -961,6 +1051,7 @@ var StateAnalyzer = class {
961
1051
  //#region src/pipeline-analytics/pipeline/events/event-filter.ts
962
1052
  var DEFAULT_EVENT_EMITTER_CONFIG = {
963
1053
  minTrackAge: 3,
1054
+ minTrackAgeMs: 0,
964
1055
  cooldownSec: 5,
965
1056
  enabledTypes: [
966
1057
  "object.entering",
@@ -991,6 +1082,13 @@ var DetectionEventEmitter = class {
991
1082
  config;
992
1083
  previousStates = /* @__PURE__ */ new Map();
993
1084
  lastEmitted = /* @__PURE__ */ new Map();
1085
+ /**
1086
+ * Last-known track per id. A `leaving` state fires for a track that is ABSENT
1087
+ * from the current frame, so it can't be resolved from `trackMap` — it is
1088
+ * resolved from this snapshot instead (without it, leaving events never
1089
+ * fired: the latent "0 `left` events in 24h" bug).
1090
+ */
1091
+ lastKnownTracks = /* @__PURE__ */ new Map();
994
1092
  constructor(config = {}) {
995
1093
  this.config = {
996
1094
  ...DEFAULT_EVENT_EMITTER_CONFIG,
@@ -1002,10 +1100,12 @@ var DetectionEventEmitter = class {
1002
1100
  const now = Date.now();
1003
1101
  const stateMap = new Map(states.map((s) => [s.trackId, s]));
1004
1102
  const trackMap = new Map(tracks.map((t) => [t.trackId, t]));
1103
+ for (const t of tracks) this.lastKnownTracks.set(t.trackId, t);
1005
1104
  for (const state of states) {
1006
- const track = trackMap.get(state.trackId);
1105
+ const track = trackMap.get(state.trackId) ?? this.lastKnownTracks.get(state.trackId);
1007
1106
  if (!track) continue;
1008
1107
  if (track.trackAge < this.config.minTrackAge) continue;
1108
+ if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
1009
1109
  const eventType = STATE_TO_EVENT[state.state];
1010
1110
  if (!eventType) continue;
1011
1111
  if (!this.config.enabledTypes.includes(eventType)) continue;
@@ -1051,12 +1151,16 @@ var DetectionEventEmitter = class {
1051
1151
  trackPath: [...track.path]
1052
1152
  });
1053
1153
  }
1054
- for (const state of states) if (state.state === "leaving") this.previousStates.delete(state.trackId);
1154
+ for (const state of states) if (state.state === "leaving") {
1155
+ this.previousStates.delete(state.trackId);
1156
+ this.lastKnownTracks.delete(state.trackId);
1157
+ }
1055
1158
  return events;
1056
1159
  }
1057
1160
  reset() {
1058
1161
  this.previousStates.clear();
1059
1162
  this.lastEmitted.clear();
1163
+ this.lastKnownTracks.clear();
1060
1164
  }
1061
1165
  };
1062
1166
  //#endregion
@@ -1428,6 +1532,43 @@ var FrameProcessor = class {
1428
1532
  }
1429
1533
  };
1430
1534
  //#endregion
1535
+ //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1536
+ var BestDetectionTracker = class {
1537
+ hysteresis;
1538
+ minGapMs;
1539
+ best = /* @__PURE__ */ new Map();
1540
+ constructor(options = {}) {
1541
+ this.hysteresis = options.hysteresis ?? 0;
1542
+ this.minGapMs = options.minGapMs ?? 0;
1543
+ }
1544
+ /**
1545
+ * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
1546
+ * Returns true when it becomes the track's new best — the first sighting, or a
1547
+ * confidence that beats the held peak by more than `hysteresis` AND respects
1548
+ * `minGapMs`. On acceptance the held peak is advanced to this observation.
1549
+ */
1550
+ observe(trackId, confidence, timestamp) {
1551
+ const cur = this.best.get(trackId);
1552
+ const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
1553
+ if (isNewBest) this.best.set(trackId, {
1554
+ confidence,
1555
+ atMs: timestamp
1556
+ });
1557
+ return isNewBest;
1558
+ }
1559
+ /** The held peak for a track (undefined if never observed). */
1560
+ peak(trackId) {
1561
+ return this.best.get(trackId);
1562
+ }
1563
+ /** Drop a track's peak (call at track end). */
1564
+ delete(trackId) {
1565
+ this.best.delete(trackId);
1566
+ }
1567
+ clear() {
1568
+ this.best.clear();
1569
+ }
1570
+ };
1571
+ //#endregion
1431
1572
  //#region src/pipeline-analytics/pipeline/native-detection.ts
1432
1573
  /**
1433
1574
  * Nominal frame size used to denormalize native `[0,1]` boxes when a
@@ -1697,6 +1838,16 @@ var TrackStore = class {
1697
1838
  lastSnapshotAt(trackId) {
1698
1839
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
1699
1840
  }
1841
+ /**
1842
+ * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
1843
+ * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
1844
+ * track begins rather than immediately — the `firstFrame` already covers the
1845
+ * track's start. No-op if a snapshot was already taken (clock already set).
1846
+ */
1847
+ seedSnapshotClock(trackId, timestamp) {
1848
+ const t = this.active.get(trackId);
1849
+ if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
1850
+ }
1700
1851
  getActive(deviceId) {
1701
1852
  const out = [];
1702
1853
  for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
@@ -1961,6 +2112,46 @@ var MediaStore = class {
1961
2112
  }
1962
2113
  }
1963
2114
  /**
2115
+ * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
2116
+ * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
2117
+ * each new capture replaces the previous one (blob + index row) rather than
2118
+ * accumulating a filmstrip the way `put` does. Deletes any existing rows of
2119
+ * that (owner, kind) first, then writes the fresh one. Returns the new key.
2120
+ */
2121
+ async putReplacing(params) {
2122
+ const existing = await this.store.query.query({
2123
+ collection: MEDIA_COLLECTION,
2124
+ filter: { where: {
2125
+ ownerKind: params.ownerKind,
2126
+ ownerId: params.ownerId,
2127
+ kind: params.kind
2128
+ } }
2129
+ });
2130
+ const newKey = await this.put(params);
2131
+ for (const row of existing) {
2132
+ if (row.id === newKey) continue;
2133
+ const path = String(row.data["path"] ?? "");
2134
+ if (path) try {
2135
+ await this.storage.delete({
2136
+ location: "eventMedia",
2137
+ relativePath: path
2138
+ });
2139
+ } catch {}
2140
+ try {
2141
+ await this.store.delete.mutate({
2142
+ collection: MEDIA_COLLECTION,
2143
+ key: row.id
2144
+ });
2145
+ } catch (err) {
2146
+ this.logger.debug("media putReplacing: stale row delete failed", { meta: {
2147
+ key: row.id,
2148
+ error: String(err)
2149
+ } });
2150
+ }
2151
+ }
2152
+ return newKey;
2153
+ }
2154
+ /**
1964
2155
  * Fetch one media entry by its key (id). Returns null if the key is not
1965
2156
  * found in the index or if the blob is missing from storage.
1966
2157
  */
@@ -2873,7 +3064,9 @@ var EventMediaDispatcher = class {
2873
3064
  }
2874
3065
  async captureForFrame(input) {
2875
3066
  const { deviceId, frameHandle, events, trackFrames } = input;
2876
- if (events.length === 0 && trackFrames.length === 0) return;
3067
+ const snapshots = input.snapshots ?? [];
3068
+ const empty = { storedSnapshots: [] };
3069
+ if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
2877
3070
  let decoded;
2878
3071
  try {
2879
3072
  decoded = await require_resolve_frame.resolveFrame(frameHandle, {
@@ -2890,7 +3083,7 @@ var EventMediaDispatcher = class {
2890
3083
  error: String(err)
2891
3084
  }
2892
3085
  });
2893
- return;
3086
+ return empty;
2894
3087
  }
2895
3088
  if (!decoded) {
2896
3089
  this.deps.logger.debug("event media: frame recycled before resolve", {
@@ -2900,7 +3093,7 @@ var EventMediaDispatcher = class {
2900
3093
  shmId: frameHandle.shmId
2901
3094
  }
2902
3095
  });
2903
- return;
3096
+ return empty;
2904
3097
  }
2905
3098
  if (decoded.format !== "rgb") {
2906
3099
  this.deps.logger.debug("event media: resolved frame is not RGB", {
@@ -2910,13 +3103,87 @@ var EventMediaDispatcher = class {
2910
3103
  format: decoded.format
2911
3104
  }
2912
3105
  });
2913
- return;
3106
+ return empty;
2914
3107
  }
2915
3108
  const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
2916
3109
  const fw = decoded.width;
2917
3110
  const fh = decoded.height;
2918
3111
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
2919
3112
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3113
+ const storedSnapshots = [];
3114
+ for (const sn of snapshots) {
3115
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
3116
+ if (stored) storedSnapshots.push(stored);
3117
+ }
3118
+ return { storedSnapshots };
3119
+ }
3120
+ /**
3121
+ * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
3122
+ * to whichever of the three destinations is requested: an appended `snapshot`
3123
+ * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
3124
+ * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
3125
+ * (null when `appendSnapshot` is false or the encode failed).
3126
+ */
3127
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
3128
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
3129
+ let boxed;
3130
+ try {
3131
+ boxed = await drawBoxedFrame(frameData, fw, fh, [{
3132
+ ...sn.bbox,
3133
+ ...sn.label ? { label: sn.label } : {}
3134
+ }], { quality: MEDIA_QUALITY });
3135
+ } catch (err) {
3136
+ this.deps.logger.warn("event media: track snapshot encode failed", {
3137
+ tags: { deviceId },
3138
+ meta: {
3139
+ deviceId,
3140
+ trackId: sn.trackId,
3141
+ error: err instanceof Error ? err.message : String(err)
3142
+ }
3143
+ });
3144
+ return null;
3145
+ }
3146
+ let stored = null;
3147
+ if (sn.appendSnapshot) try {
3148
+ const mediaKey = await this.deps.mediaStore.put({
3149
+ deviceId,
3150
+ ownerKind: "track",
3151
+ ownerId: sn.trackId,
3152
+ kind: "snapshot",
3153
+ timestamp: sn.timestamp,
3154
+ data: boxed
3155
+ });
3156
+ stored = {
3157
+ trackId: sn.trackId,
3158
+ mediaKey,
3159
+ timestamp: sn.timestamp,
3160
+ bbox: sn.bbox
3161
+ };
3162
+ } catch {}
3163
+ if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
3164
+ if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
3165
+ return stored;
3166
+ }
3167
+ async replaceKind(deviceId, trackId, kind, timestamp, data) {
3168
+ try {
3169
+ await this.deps.mediaStore.putReplacing({
3170
+ deviceId,
3171
+ ownerKind: "track",
3172
+ ownerId: trackId,
3173
+ kind,
3174
+ timestamp,
3175
+ data
3176
+ });
3177
+ } catch (err) {
3178
+ this.deps.logger.debug(`event media: ${kind} replace failed`, {
3179
+ tags: { deviceId },
3180
+ meta: {
3181
+ deviceId,
3182
+ trackId,
3183
+ error: err instanceof Error ? err.message : String(err)
3184
+ }
3185
+ });
3186
+ }
2920
3187
  }
2921
3188
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
2922
3189
  const box = {
@@ -3777,22 +4044,22 @@ function resolveDetectionSensitivitySettings(raw) {
3777
4044
  stationaryThresholdSec: pick("stationaryThresholdSec")
3778
4045
  };
3779
4046
  }
3780
- //#endregion
3781
- //#region src/pipeline-analytics/tracking-settings.ts
3782
- /**
3783
- * Per-device tracker tuning. These drive the SORT tracker (association,
3784
- * coasting, occlusion, lifetime) and the detector-dropout frame skip. Every
3785
- * field is independently overridable per camera from the admin UI; unknown or
3786
- * invalid values fall back to the field default (never throws on a bad blob).
3787
- *
3788
- * Note: `minHits` lives in detection-sensitivity-settings (it predates this
3789
- * file and already flows to the tracker) — it is NOT duplicated here.
3790
- */
3791
4047
  var TrackingSettingsSchema = require_dist.object({
3792
4048
  /** IoU required to match a (predicted) track to a detection. */
3793
4049
  iouThreshold: require_dist.number().min(0).max(1).default(.3),
3794
- /** Frames a track may miss detections (coast) before it is dropped. */
4050
+ /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
4051
+ * based so behaviour is identical across camera frame rates. */
4052
+ maxMissedMs: require_dist.number().min(0).default(4e3),
4053
+ /** Minimum wall-clock track age (ms) before it may produce events. */
4054
+ minTrackAgeMs: require_dist.number().min(0).default(1e3),
4055
+ /** Extended coasting budget (ms) while a track is occluded. */
4056
+ occlusionMaxMissedMs: require_dist.number().min(0).default(8e3),
4057
+ /** @deprecated frame-based alias for {@link maxMissedMs}. Retained so
4058
+ * existing per-device blobs keep working — resolved into `maxMissedMs`
4059
+ * (× {@link NOMINAL_FRAME_MS}) only when `maxMissedMs` is unset. */
3795
4060
  maxMissedFrames: require_dist.number().int().min(0).default(30),
4061
+ /** @deprecated frame-based alias for {@link occlusionMaxMissedMs}. */
4062
+ occlusionMaxMissedFrames: require_dist.number().int().min(0).default(60),
3796
4063
  /** Hard cap on total track lifetime — retire+restart beyond this to bound
3797
4064
  * drift / long-lived false positives. 0 = unlimited. */
3798
4065
  maxTrackLifetimeSec: require_dist.number().min(0).default(300),
@@ -3806,8 +4073,17 @@ var TrackingSettingsSchema = require_dist.object({
3806
4073
  /** Containment ratio (intersection / occluded-area) above which a missed
3807
4074
  * track is considered hidden behind another track. */
3808
4075
  occlusionContainment: require_dist.number().min(0).max(1).default(.6),
3809
- /** Extended coasting budget (frames) while a track is occluded. */
3810
- occlusionMaxMissedFrames: require_dist.number().int().min(0).default(60),
4076
+ /** Loose IoU gate (vs. last-known bbox) for the second (rescue) association
4077
+ * pass + graveyard resurrection. */
4078
+ rescueIouThreshold: require_dist.number().min(0).max(1).default(.1),
4079
+ /** Centroid gate for rescue/resurrection: fraction of the bbox diagonal. */
4080
+ rescueCentroidFactor: require_dist.number().min(0).default(.75),
4081
+ /** Wall-clock graveyard retention (ms): a dropped track re-attaches to a new
4082
+ * same-class detection (keeping its id) for this long. */
4083
+ resurrectionWindowMs: require_dist.number().min(0).default(8e3),
4084
+ /** Speed (px/frame) below which a track's prediction is frozen (stationary
4085
+ * jitter can't drift the box off a sitting object). */
4086
+ stationarySpeedPx: require_dist.number().min(0).default(2),
3811
4087
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
3812
4088
  dropoutSkipEnabled: require_dist.boolean().default(true),
3813
4089
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
@@ -3822,15 +4098,26 @@ var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
3822
4098
  */
3823
4099
  function resolveTrackingSettings(raw) {
3824
4100
  const s = TrackingSettingsSchema.shape;
4101
+ const maxMissedFrames = s.maxMissedFrames.catch(TRACKING_DEFAULTS.maxMissedFrames).parse(raw.maxMissedFrames);
4102
+ const occlusionMaxMissedFrames = s.occlusionMaxMissedFrames.catch(TRACKING_DEFAULTS.occlusionMaxMissedFrames).parse(raw.occlusionMaxMissedFrames);
4103
+ 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;
4104
+ 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;
3825
4105
  return {
3826
4106
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
3827
- maxMissedFrames: s.maxMissedFrames.catch(TRACKING_DEFAULTS.maxMissedFrames).parse(raw.maxMissedFrames),
4107
+ maxMissedMs,
4108
+ minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
4109
+ occlusionMaxMissedMs,
4110
+ maxMissedFrames,
4111
+ occlusionMaxMissedFrames,
3828
4112
  maxTrackLifetimeSec: s.maxTrackLifetimeSec.catch(TRACKING_DEFAULTS.maxTrackLifetimeSec).parse(raw.maxTrackLifetimeSec),
3829
4113
  predictiveCoasting: s.predictiveCoasting.catch(TRACKING_DEFAULTS.predictiveCoasting).parse(raw.predictiveCoasting),
3830
4114
  classGating: s.classGating.catch(TRACKING_DEFAULTS.classGating).parse(raw.classGating),
3831
4115
  occlusionEnabled: s.occlusionEnabled.catch(TRACKING_DEFAULTS.occlusionEnabled).parse(raw.occlusionEnabled),
3832
4116
  occlusionContainment: s.occlusionContainment.catch(TRACKING_DEFAULTS.occlusionContainment).parse(raw.occlusionContainment),
3833
- occlusionMaxMissedFrames: s.occlusionMaxMissedFrames.catch(TRACKING_DEFAULTS.occlusionMaxMissedFrames).parse(raw.occlusionMaxMissedFrames),
4117
+ rescueIouThreshold: s.rescueIouThreshold.catch(TRACKING_DEFAULTS.rescueIouThreshold).parse(raw.rescueIouThreshold),
4118
+ rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
4119
+ resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
4120
+ stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
3834
4121
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
3835
4122
  dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
3836
4123
  };
@@ -3891,11 +4178,20 @@ function resolveFaceSettings(raw) {
3891
4178
  * its default — parse never throws). Mirrors `face-settings` /
3892
4179
  * `audio-detection-settings`.
3893
4180
  */
3894
- var MediaSettingsSchema = require_dist.object({
3895
- /** Fractional padding added around a detection bbox before cropping.
3896
- * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
3897
- * max 2 (200%). */
3898
- cropPadding: require_dist.number().min(0).max(2).default(.15) });
4181
+ var MediaSettingsSchema = require_dist.object({
4182
+ /** Fractional padding added around a detection bbox before cropping.
4183
+ * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4184
+ * max 2 (200%). */
4185
+ cropPadding: require_dist.number().min(0).max(2).default(.15),
4186
+ /** Master switch for periodic per-track snapshots (the timeline filmstrip +
4187
+ * the rolling `lastFrame` + the best `thumbnail`). When false, only the
4188
+ * per-track `firstFrame` and per-event media are produced. */
4189
+ saveThumbnails: require_dist.boolean().default(true),
4190
+ /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
4191
+ * A snapshot is captured for an active track only after this much wall-clock
4192
+ * has elapsed since its previous one. */
4193
+ snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
4194
+ });
3899
4195
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
3900
4196
  /**
3901
4197
  * Resolve a per-device store blob into typed media settings. Unknown/invalid
@@ -3906,7 +4202,11 @@ function resolveMediaSettings(raw) {
3906
4202
  const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
3907
4203
  return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
3908
4204
  };
3909
- return { cropPadding: pick("cropPadding") };
4205
+ return {
4206
+ cropPadding: pick("cropPadding"),
4207
+ saveThumbnails: pick("saveThumbnails"),
4208
+ snapshotIntervalMs: pick("snapshotIntervalMs")
4209
+ };
3910
4210
  }
3911
4211
  //#endregion
3912
4212
  //#region src/pipeline-analytics/store/identity-store.ts
@@ -4802,6 +5102,11 @@ var FaceRecognizer = class {
4802
5102
  names = /* @__PURE__ */ new Map();
4803
5103
  aggregates = /* @__PURE__ */ new Map();
4804
5104
  bestFace = /* @__PURE__ */ new Map();
5105
+ /** The ONE best-detection-per-track policy, shared with the best-frame path
5106
+ * (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
5107
+ * true highest-confidence face (holding a buffer in memory is cheap, and a
5108
+ * track may last well under the best-frame rate-limit window). */
5109
+ bestTracker = new BestDetectionTracker();
4805
5110
  constructor(deps) {
4806
5111
  this.deps = deps;
4807
5112
  }
@@ -4835,55 +5140,25 @@ var FaceRecognizer = class {
4835
5140
  threshold: settings.similarityThreshold,
4836
5141
  margin: settings.margin
4837
5142
  }) : /* @__PURE__ */ new Map();
5143
+ const labelWork = [];
4838
5144
  for (const c of candidates) {
4839
5145
  const match = matches.get(c.trackId) ?? null;
4840
5146
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
4841
5147
  this.aggregates.set(c.trackId, state);
4842
- if (changed && state.assignedIdentityId !== null) {
4843
- const name = this.names.get(state.assignedIdentityId);
4844
- this.deps.logger.info("face: identity assigned", {
4845
- tags: {
4846
- deviceId: input.deviceId,
4847
- trackId: c.trackId
4848
- },
4849
- meta: {
4850
- identityId: state.assignedIdentityId,
4851
- name: name ?? null,
4852
- score: match?.score ?? null
4853
- }
4854
- });
4855
- if (name !== void 0) {
4856
- try {
4857
- await this.deps.trackStore.setLabel(c.trackId, name);
4858
- } catch (err) {
4859
- this.deps.logger.warn("setLabel failed", {
4860
- tags: { deviceId: input.deviceId },
4861
- meta: {
4862
- trackId: c.trackId,
4863
- error: String(err)
4864
- }
4865
- });
4866
- }
4867
- try {
4868
- await this.deps.eventStore.setLabelForTrack(c.trackId, name);
4869
- } catch (err) {
4870
- this.deps.logger.warn("setLabelForTrack failed", {
4871
- tags: { deviceId: input.deviceId },
4872
- meta: {
4873
- trackId: c.trackId,
4874
- error: String(err)
4875
- }
4876
- });
4877
- }
4878
- }
4879
- }
5148
+ if (changed && state.assignedIdentityId !== null) labelWork.push({
5149
+ trackId: c.trackId,
5150
+ assignedIdentityId: state.assignedIdentityId,
5151
+ matchScore: match?.score ?? null
5152
+ });
4880
5153
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
4881
5154
  const held = this.bestFace.get(c.trackId);
4882
- if (held === void 0 || c.confidence > held.score) {
5155
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
5156
+ const needsCrop = held !== void 0 && held.crop === void 0;
5157
+ if (isNewBest || needsCrop) {
4883
5158
  const cropBbox = c.faceBbox ?? c.bbox;
4884
5159
  let crop;
4885
- if (input.frameHandle !== void 0) try {
4886
- crop = await this.deps.captureCrop(input.frameHandle, cropBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5160
+ if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5161
+ crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
4887
5162
  } catch (err) {
4888
5163
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
4889
5164
  tags: { deviceId: input.deviceId },
@@ -4893,14 +5168,20 @@ var FaceRecognizer = class {
4893
5168
  }
4894
5169
  });
4895
5170
  }
4896
- this.bestFace.set(c.trackId, {
4897
- score: c.confidence,
4898
- embedding: c.embedding,
4899
- embeddingModelId: c.embeddingModelId,
4900
- bbox: cropBbox,
4901
- timestamp: input.timestamp,
4902
- ...crop !== void 0 ? { crop } : {},
4903
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5171
+ if (isNewBest) {
5172
+ const bestCrop = crop ?? held?.crop;
5173
+ this.bestFace.set(c.trackId, {
5174
+ score: c.confidence,
5175
+ embedding: c.embedding,
5176
+ embeddingModelId: c.embeddingModelId,
5177
+ bbox: cropBbox,
5178
+ timestamp: input.timestamp,
5179
+ ...bestCrop !== void 0 ? { crop: bestCrop } : {},
5180
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5181
+ });
5182
+ } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
5183
+ ...held,
5184
+ crop
4904
5185
  });
4905
5186
  this.deps.logger.debug("face: best-face held", {
4906
5187
  tags: {
@@ -4909,15 +5190,53 @@ var FaceRecognizer = class {
4909
5190
  },
4910
5191
  meta: {
4911
5192
  score: c.confidence,
4912
- hasCrop: crop !== void 0,
5193
+ isNewBest,
5194
+ hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
4913
5195
  recognizedIdentityId: recognizedIdentityId ?? null
4914
5196
  }
4915
5197
  });
4916
- } else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5198
+ } else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
4917
5199
  ...held,
4918
5200
  recognizedIdentityId
4919
5201
  });
4920
5202
  }
5203
+ for (const work of labelWork) {
5204
+ const name = this.names.get(work.assignedIdentityId);
5205
+ this.deps.logger.info("face: identity assigned", {
5206
+ tags: {
5207
+ deviceId: input.deviceId,
5208
+ trackId: work.trackId
5209
+ },
5210
+ meta: {
5211
+ identityId: work.assignedIdentityId,
5212
+ name: name ?? null,
5213
+ score: work.matchScore
5214
+ }
5215
+ });
5216
+ if (name === void 0) continue;
5217
+ try {
5218
+ await this.deps.trackStore.setLabel(work.trackId, name);
5219
+ } catch (err) {
5220
+ this.deps.logger.warn("setLabel failed", {
5221
+ tags: { deviceId: input.deviceId },
5222
+ meta: {
5223
+ trackId: work.trackId,
5224
+ error: String(err)
5225
+ }
5226
+ });
5227
+ }
5228
+ try {
5229
+ await this.deps.eventStore.setLabelForTrack(work.trackId, name);
5230
+ } catch (err) {
5231
+ this.deps.logger.warn("setLabelForTrack failed", {
5232
+ tags: { deviceId: input.deviceId },
5233
+ meta: {
5234
+ trackId: work.trackId,
5235
+ error: String(err)
5236
+ }
5237
+ });
5238
+ }
5239
+ }
4921
5240
  }
4922
5241
  /**
4923
5242
  * Persist the held best face for a finished track as ONE FaceStore buffer
@@ -4928,6 +5247,7 @@ var FaceRecognizer = class {
4928
5247
  const held = this.bestFace.get(trackId);
4929
5248
  this.aggregates.delete(trackId);
4930
5249
  this.bestFace.delete(trackId);
5250
+ this.bestTracker.delete(trackId);
4931
5251
  if (held === void 0) {
4932
5252
  this.deps.logger.debug("face: track ended without a held face", { tags: {
4933
5253
  deviceId,
@@ -5603,6 +5923,12 @@ function createEventMediaHandler(deps) {
5603
5923
  */
5604
5924
  var TTL_SWEEP_INTERVAL_MS = 5e3;
5605
5925
  var SETTINGS_CACHE_TTL_MS = 5e3;
5926
+ /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
5927
+ * detection confidence beats the held best by at least this margin (hysteresis
5928
+ * so jitter around a plateau doesn't churn the write). */
5929
+ var BEST_FRAME_HYSTERESIS = .05;
5930
+ /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
5931
+ var BEST_FRAME_MIN_GAP_MS = 2e3;
5606
5932
  /** Cluster setting key (in the centralized addon store) selecting the SINGLE
5607
5933
  * node that runs post-analysis (event/media/audio/motion generation). All
5608
5934
  * other nodes are fully inert. No multi-node balancing. Default: the hub. */
@@ -5714,6 +6040,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
5714
6040
  mediaCacheByDevice = /* @__PURE__ */ new Map();
5715
6041
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
5716
6042
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
6043
+ /** Best (highest-confidence) frame per track — drives the single overwrite
6044
+ * `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
6045
+ * face path (`face-recognizer.ts`); rate-limited here since each best-frame
6046
+ * capture re-encodes a full boxed frame. */
6047
+ bestFrameTracker = new BestDetectionTracker({
6048
+ hysteresis: BEST_FRAME_HYSTERESIS,
6049
+ minGapMs: BEST_FRAME_MIN_GAP_MS
6050
+ });
5717
6051
  shuttingDown = false;
5718
6052
  /** True only on the cluster's designated post-processing node. When false the
5719
6053
  * addon subscribes to NOTHING — fully inert (no event/media generation). */
@@ -5747,7 +6081,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
5747
6081
  let storage = this.ctx.kernel.storage;
5748
6082
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
5749
6083
  if (mediaRoot) {
5750
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CdzrYoKs.js"));
6084
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CIEkEv1F.js"));
5751
6085
  storage = new FilesystemStorageProvider(mediaRoot);
5752
6086
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
5753
6087
  }
@@ -6257,6 +6591,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6257
6591
  this.processors.clear();
6258
6592
  this.lastActiveTrackIds.clear();
6259
6593
  this.dropoutSkipsByKey.clear();
6594
+ this.bestFrameTracker.clear();
6260
6595
  this.levelStateByDevice.clear();
6261
6596
  this.settingsCacheByDevice.clear();
6262
6597
  this.sensitivityCacheByDevice.clear();
@@ -6363,12 +6698,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6363
6698
  className: t.className,
6364
6699
  source
6365
6700
  } });
6366
- if (this.eventMediaDispatcher && frameHandle) firstFrameTargets.push({
6367
- trackId: id,
6368
- timestamp: result.timestamp,
6369
- bbox: { ...t.bbox },
6370
- ...t.label ? { label: t.label } : {}
6371
- });
6701
+ if (this.eventMediaDispatcher && frameHandle) {
6702
+ firstFrameTargets.push({
6703
+ trackId: id,
6704
+ timestamp: result.timestamp,
6705
+ bbox: { ...t.bbox },
6706
+ ...t.label ? { label: t.label } : {}
6707
+ });
6708
+ this.trackStore.seedSnapshotClock(id, result.timestamp);
6709
+ }
6372
6710
  this.ctx.eventBus.emit({
6373
6711
  id: `pa-${(0, node_crypto.randomUUID)()}`,
6374
6712
  timestamp: new Date(result.timestamp),
@@ -6441,11 +6779,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6441
6779
  let plateCrops = 0;
6442
6780
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
6443
6781
  else plateCrops += 1;
6444
- if (eventTargets.length > 0 || firstFrameTargets.length > 0) {
6782
+ const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
6783
+ if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
6445
6784
  log.info("media capture", { meta: {
6446
6785
  source,
6447
6786
  events: eventTargets.length,
6448
6787
  trackFrames: firstFrameTargets.length,
6788
+ snapshots: snapshotTargets.length,
6449
6789
  faceCrops,
6450
6790
  plateCrops
6451
6791
  } });
@@ -6454,8 +6794,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6454
6794
  frameHandle,
6455
6795
  events: eventTargets,
6456
6796
  trackFrames: firstFrameTargets,
6797
+ snapshots: snapshotTargets,
6457
6798
  cropPadding: mediaSettings.cropPadding
6458
- });
6799
+ }).then((res) => {
6800
+ for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
6801
+ timestamp: s.timestamp,
6802
+ position: {
6803
+ x: s.bbox.x + s.bbox.w / 2,
6804
+ y: s.bbox.y + s.bbox.h / 2,
6805
+ timestamp: s.timestamp,
6806
+ bbox: s.bbox
6807
+ },
6808
+ mediaKey: s.mediaKey
6809
+ });
6810
+ }).catch(() => {});
6459
6811
  }
6460
6812
  }
6461
6813
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -6588,6 +6940,35 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6588
6940
  });
6589
6941
  return settings;
6590
6942
  }
6943
+ /**
6944
+ * §5 — decide which active tracks need periodic media THIS frame. Pure over
6945
+ * TrackStore.lastSnapshotAt + the per-track best-confidence map:
6946
+ * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
6947
+ * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
6948
+ * • `thumbnail` (best) fires when confidence beats the held best by the
6949
+ * hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
6950
+ * NOT gated by saveThumbnails (a single best still-frame is always useful).
6951
+ * The per-track best-confidence map is updated here as a side effect.
6952
+ */
6953
+ buildSnapshotTargets(tracked, timestamp, media) {
6954
+ const targets = [];
6955
+ for (const t of tracked) {
6956
+ const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
6957
+ const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
6958
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
6959
+ if (!dueSnapshot && !isNewBest) continue;
6960
+ targets.push({
6961
+ trackId: t.trackId,
6962
+ timestamp,
6963
+ bbox: { ...t.bbox },
6964
+ ...t.label ? { label: t.label } : {},
6965
+ appendSnapshot: dueSnapshot,
6966
+ rollingLastFrame: dueSnapshot,
6967
+ bestThumbnail: isNewBest
6968
+ });
6969
+ }
6970
+ return targets;
6971
+ }
6591
6972
  async handleAudioResult(data) {
6592
6973
  if (this.shuttingDown) return;
6593
6974
  const { deviceId, frame } = data;
@@ -6842,6 +7223,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6842
7223
  });
6843
7224
  this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
6844
7225
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7226
+ this.bestFrameTracker.delete(t.trackId);
6845
7227
  this.ctx.eventBus.emit({
6846
7228
  id: `pa-end-${t.trackId}`,
6847
7229
  timestamp: new Date(t.lastSeen),
@@ -6975,16 +7357,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6975
7357
  p = new FrameProcessor(deviceId, {
6976
7358
  minHits,
6977
7359
  iouThreshold: trk.iouThreshold,
6978
- maxMissedFrames: trk.maxMissedFrames,
7360
+ maxMissedMs: trk.maxMissedMs,
6979
7361
  predictiveCoasting: trk.predictiveCoasting,
6980
7362
  classGating: trk.classGating,
6981
7363
  occlusionEnabled: trk.occlusionEnabled,
6982
7364
  occlusionContainment: trk.occlusionContainment,
6983
- occlusionMaxMissedFrames: trk.occlusionMaxMissedFrames,
7365
+ occlusionMaxMissedMs: trk.occlusionMaxMissedMs,
7366
+ rescueIouThreshold: trk.rescueIouThreshold,
7367
+ rescueCentroidFactor: trk.rescueCentroidFactor,
7368
+ resurrectionWindowMs: trk.resurrectionWindowMs,
7369
+ stationarySpeedPx: trk.stationarySpeedPx,
6984
7370
  maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
6985
7371
  }, { stationaryThresholdSec }, {
6986
7372
  minTrackAge,
6987
- cooldownSec
7373
+ cooldownSec,
7374
+ minTrackAgeMs: trk.minTrackAgeMs
6988
7375
  }, source);
6989
7376
  this.processors.set(key, p);
6990
7377
  }
@@ -7270,7 +7657,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7270
7657
  min: 500,
7271
7658
  max: 6e4,
7272
7659
  step: 500,
7273
- default: 2e3,
7660
+ default: 5e3,
7274
7661
  showValue: true,
7275
7662
  unit: "s",
7276
7663
  displayScale: 1e3
@@ -7545,12 +7932,53 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7545
7932
  },
7546
7933
  {
7547
7934
  type: "number",
7548
- key: "maxMissedFrames",
7549
- label: "Max missed frames",
7550
- description: "Frames a track may miss detections (coast) before it is dropped.",
7935
+ key: "maxMissedMs",
7936
+ label: "Max coasting time",
7937
+ description: "How long (ms) a track may miss detections before it is dropped. Time-based so it behaves the same on a 5fps and a 23fps camera.",
7551
7938
  min: 0,
7552
- step: 1,
7553
- default: TRACKING_DEFAULTS.maxMissedFrames
7939
+ step: 100,
7940
+ default: TRACKING_DEFAULTS.maxMissedMs,
7941
+ unit: "ms"
7942
+ },
7943
+ {
7944
+ type: "number",
7945
+ key: "minTrackAgeMs",
7946
+ label: "Min track age",
7947
+ description: "A track must exist for at least this long (ms) before it can produce events — stops sub-second flicker fragments from becoming events.",
7948
+ min: 0,
7949
+ step: 100,
7950
+ default: TRACKING_DEFAULTS.minTrackAgeMs,
7951
+ unit: "ms"
7952
+ },
7953
+ {
7954
+ type: "number",
7955
+ key: "resurrectionWindowMs",
7956
+ label: "Resurrection window",
7957
+ description: "How long (ms) a dropped track can be re-attached to a re-appearing same-class detection (keeping its id) — the main fix for a stationary object fragmenting into many tracks.",
7958
+ min: 0,
7959
+ step: 500,
7960
+ default: TRACKING_DEFAULTS.resurrectionWindowMs,
7961
+ unit: "ms"
7962
+ },
7963
+ {
7964
+ type: "number",
7965
+ key: "rescueIouThreshold",
7966
+ label: "Rescue IoU threshold",
7967
+ description: "Looser overlap (vs. a track’s last-known box) for the second association + resurrection passes. Lower = more aggressive re-attachment.",
7968
+ min: 0,
7969
+ max: 1,
7970
+ step: .05,
7971
+ default: TRACKING_DEFAULTS.rescueIouThreshold
7972
+ },
7973
+ {
7974
+ type: "number",
7975
+ key: "stationarySpeedPx",
7976
+ label: "Stationary speed",
7977
+ description: "Speed (px/frame) below which a track is treated as stationary and its prediction is frozen, so bbox jitter cannot walk the box off a sitting object.",
7978
+ min: 0,
7979
+ step: .5,
7980
+ default: TRACKING_DEFAULTS.stationarySpeedPx,
7981
+ unit: "px"
7554
7982
  },
7555
7983
  {
7556
7984
  type: "number",
@@ -7595,12 +8023,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7595
8023
  },
7596
8024
  {
7597
8025
  type: "number",
7598
- key: "occlusionMaxMissedFrames",
7599
- label: "Occlusion max missed frames",
7600
- description: "Extended coasting budget (frames) while a track is occluded.",
8026
+ key: "occlusionMaxMissedMs",
8027
+ label: "Occlusion max coasting time",
8028
+ description: "Extended coasting time (ms) while a track is hidden behind another.",
7601
8029
  min: 0,
7602
- step: 1,
7603
- default: TRACKING_DEFAULTS.occlusionMaxMissedFrames
8030
+ step: 100,
8031
+ default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
8032
+ unit: "ms"
7604
8033
  },
7605
8034
  {
7606
8035
  type: "boolean",