@camstack/addon-post-analysis 1.1.36 → 1.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bc4L7FGf.js");
5
+ const require_dist = require("../dist-CThBV9dq.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -2137,6 +2137,29 @@ function isEdgeClear(input) {
2137
2137
  return true;
2138
2138
  }
2139
2139
  /**
2140
+ * Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
2141
+ * centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
2142
+ * — a cheap proxy for "is the subject well-framed?" used as the best-frame
2143
+ * tie-breaker so a low-importance short track stops locking in an edge-of-frame
2144
+ * subject when a better-centred, near-equal-confidence frame is available.
2145
+ *
2146
+ * The score is `1 - normalizedDistance(centre → frameCentre)`, where the
2147
+ * distance is normalised by the max possible (centre → corner) so it is
2148
+ * scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
2149
+ * falls back to pure confidence, matching {@link isEdgeClear}).
2150
+ */
2151
+ function bboxCenterScore(bbox, frameWidth, frameHeight) {
2152
+ if (frameWidth <= 0 || frameHeight <= 0) return 1;
2153
+ const cx = bbox.x + bbox.w / 2;
2154
+ const cy = bbox.y + bbox.h / 2;
2155
+ const fcx = frameWidth / 2;
2156
+ const fcy = frameHeight / 2;
2157
+ const dx = (cx - fcx) / fcx;
2158
+ const dy = (cy - fcy) / fcy;
2159
+ const dist = Math.hypot(dx, dy) / Math.SQRT2;
2160
+ return Math.max(0, Math.min(1, 1 - dist));
2161
+ }
2162
+ /**
2140
2163
  * Edge-aware "is `candidate` a new best over `current`?" comparator.
2141
2164
  *
2142
2165
  * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
@@ -2144,13 +2167,25 @@ function isEdgeClear(input) {
2144
2167
  * confidence past the `hysteresis` margin wins. The tier upgrade
2145
2168
  * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2146
2169
  *
2170
+ * CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
2171
+ * wins on confidence (the two are within the `hysteresis` band) but the
2172
+ * candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
2173
+ * candidate wins. This only engages when both sides carry a `centerScore` (the
2174
+ * best-frame path), so low-importance short tracks stop keeping an edge-of-frame
2175
+ * subject over an equally-confident, better-framed one. The face /
2176
+ * object-embedding callers omit `centerScore` → identical legacy behaviour.
2177
+ *
2147
2178
  * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2148
2179
  * not here, so this stays a pure value comparison.
2149
2180
  */
2150
2181
  function isEdgeAwareNewBest(current, candidate, hysteresis) {
2151
2182
  if (candidate.edgeClear && !current.edgeClear) return true;
2152
2183
  if (!candidate.edgeClear && current.edgeClear) return false;
2153
- return candidate.confidence > current.confidence + hysteresis;
2184
+ if (candidate.confidence > current.confidence + hysteresis) return true;
2185
+ if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
2186
+ if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
2187
+ }
2188
+ return false;
2154
2189
  }
2155
2190
  //#endregion
2156
2191
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
@@ -2186,6 +2221,10 @@ var BestDetectionTracker = class {
2186
2221
  * the edge tier is not in play for the track (treated as clear → the legacy
2187
2222
  * pure-confidence policy). */
2188
2223
  edgeClear = /* @__PURE__ */ new Map();
2224
+ /** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
2225
+ * does not supply centering (face / object-embedding paths) → the centering
2226
+ * tie-break is disabled and the legacy confidence policy applies. */
2227
+ centerScore = /* @__PURE__ */ new Map();
2189
2228
  constructor(options = {}) {
2190
2229
  this.hysteresis = options.hysteresis ?? 0;
2191
2230
  this.minGapMs = options.minGapMs ?? 0;
@@ -2202,7 +2241,7 @@ var BestDetectionTracker = class {
2202
2241
  * the classic policy holds: a confidence past the `hysteresis` margin that also
2203
2242
  * respects `minGapMs` wins. On acceptance the held peak advances.
2204
2243
  */
2205
- observe(trackId, confidence, timestamp, edgeClear) {
2244
+ observe(trackId, confidence, timestamp, edgeClear, centerScore) {
2206
2245
  const cur = this.best.get(trackId);
2207
2246
  if (cur === void 0) {
2208
2247
  this.best.set(trackId, {
@@ -2210,16 +2249,20 @@ var BestDetectionTracker = class {
2210
2249
  atMs: timestamp
2211
2250
  });
2212
2251
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2252
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2213
2253
  return true;
2214
2254
  }
2215
2255
  const curClear = this.edgeClear.get(trackId) ?? true;
2216
2256
  const candClear = edgeClear ?? true;
2257
+ const curCenter = this.centerScore.get(trackId);
2217
2258
  const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2218
2259
  confidence: cur.confidence,
2219
- edgeClear: curClear
2260
+ edgeClear: curClear,
2261
+ ...curCenter !== void 0 ? { centerScore: curCenter } : {}
2220
2262
  }, {
2221
2263
  confidence,
2222
- edgeClear: candClear
2264
+ edgeClear: candClear,
2265
+ ...centerScore !== void 0 ? { centerScore } : {}
2223
2266
  }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2224
2267
  if (isNewBest) {
2225
2268
  this.best.set(trackId, {
@@ -2227,6 +2270,7 @@ var BestDetectionTracker = class {
2227
2270
  atMs: timestamp
2228
2271
  });
2229
2272
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2273
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2230
2274
  }
2231
2275
  return isNewBest;
2232
2276
  }
@@ -2238,10 +2282,12 @@ var BestDetectionTracker = class {
2238
2282
  delete(trackId) {
2239
2283
  this.best.delete(trackId);
2240
2284
  this.edgeClear.delete(trackId);
2285
+ this.centerScore.delete(trackId);
2241
2286
  }
2242
2287
  clear() {
2243
2288
  this.best.clear();
2244
2289
  this.edgeClear.clear();
2290
+ this.centerScore.clear();
2245
2291
  }
2246
2292
  };
2247
2293
  //#endregion
@@ -2394,6 +2440,9 @@ var WAKE_ASSOC_IOU = .1;
2394
2440
  * long) so a freshly-spawned static blob isn't promoted instantly.
2395
2441
  */
2396
2442
  var PROMOTION_WINDOW_MS = 3e4;
2443
+ /** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
2444
+ * long (object removed while unobserved, or a long occlusion) → retire it. */
2445
+ var ENTRY_TTL_MS = 5 * 6e4;
2397
2446
  var DEFAULT_MATCH_CONFIG = {
2398
2447
  suppressIou: SUPPRESS_IOU,
2399
2448
  wakeAssocIou: WAKE_ASSOC_IOU
@@ -2601,6 +2650,7 @@ var StationaryObjectRegistry = class {
2601
2650
  logger;
2602
2651
  matchConfig;
2603
2652
  entryTtlMs;
2653
+ ttlForDevice;
2604
2654
  onChange;
2605
2655
  /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2606
2656
  * time, not wall-clock. A session-dispatch camera produces no frames
@@ -2612,6 +2662,7 @@ var StationaryObjectRegistry = class {
2612
2662
  this.logger = deps.logger;
2613
2663
  this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2614
2664
  this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2665
+ this.ttlForDevice = deps.ttlForDevice;
2615
2666
  this.onChange = deps.onChange;
2616
2667
  }
2617
2668
  static async declare(store) {
@@ -2667,7 +2718,7 @@ var StationaryObjectRegistry = class {
2667
2718
  * entries. PURE with respect to registry state — apply the outcome with
2668
2719
  * {@link applyFrameOutcome} once the frame result is assembled.
2669
2720
  */
2670
- filter(input) {
2721
+ filter(input, config) {
2671
2722
  const entries = this.list(input.deviceId);
2672
2723
  if (entries.length === 0) return {
2673
2724
  suppressedIndices: /* @__PURE__ */ new Set(),
@@ -2677,7 +2728,7 @@ var StationaryObjectRegistry = class {
2677
2728
  return partitionDetectionsAgainstRegistry({
2678
2729
  entries,
2679
2730
  detections: input.detections,
2680
- config: this.matchConfig
2731
+ config: config ?? this.matchConfig
2681
2732
  });
2682
2733
  }
2683
2734
  /** Fold a frame's gate result back into state: advance confirmed entries'
@@ -2746,7 +2797,8 @@ var StationaryObjectRegistry = class {
2746
2797
  for (const [deviceId, m] of this.byDevice) {
2747
2798
  const observedAt = this.lastFrameAtByDevice.get(deviceId);
2748
2799
  if (observedAt === void 0) continue;
2749
- for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2800
+ const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
2801
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
2750
2802
  m.delete(id);
2751
2803
  this.dirty.delete(id);
2752
2804
  retired.push(e);
@@ -2895,6 +2947,55 @@ function computeStationaryEntryZones(entry, zones) {
2895
2947
  return matched;
2896
2948
  }
2897
2949
  //#endregion
2950
+ //#region src/pipeline-analytics/stationary-settings.ts
2951
+ /**
2952
+ * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
2953
+ * per-device override on top of the global default, resolved per field (an
2954
+ * invalid/missing value falls back to its default — parse never throws).
2955
+ * Mirrors `media-settings` / `tracking-settings`.
2956
+ *
2957
+ * The defaults are the SAME constants the registry uses at runtime
2958
+ * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
2959
+ * value both resolve to exactly today's behaviour — "reset == unset == today",
2960
+ * with no drift. Guarded by a unit test asserting the equality.
2961
+ */
2962
+ var StationarySettingsSchema = require_dist.object({
2963
+ /** Master switch. Off ⇒ no promotion + no suppression for this camera (every
2964
+ * parked object keeps spawning normal tracks). */
2965
+ enabled: require_dist.boolean().default(true),
2966
+ /** IoU at/above which a detection is the same parked object, unmoved →
2967
+ * suppress its spawn. `SUPPRESS_IOU`. */
2968
+ suppressIou: require_dist.number().min(.3).max(.9).default(SUPPRESS_IOU),
2969
+ /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
2970
+ * wake it) — the overlap gate that stops a different vehicle from waking a
2971
+ * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
2972
+ wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
2973
+ /** Recent-window stillness a track must hold to be PROMOTED to a parked
2974
+ * entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
2975
+ promotionWindowMs: require_dist.number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
2976
+ /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
2977
+ * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
2978
+ entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
2979
+ });
2980
+ var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
2981
+ /**
2982
+ * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
2983
+ * fields fall back to the default for that field (never throws on a bad blob).
2984
+ */
2985
+ function resolveStationarySettings(raw) {
2986
+ const pick = (key) => {
2987
+ const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
2988
+ return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
2989
+ };
2990
+ return {
2991
+ enabled: pick("enabled"),
2992
+ suppressIou: pick("suppressIou"),
2993
+ wakeAssocIou: pick("wakeAssocIou"),
2994
+ promotionWindowMs: pick("promotionWindowMs"),
2995
+ entryTtlMs: pick("entryTtlMs")
2996
+ };
2997
+ }
2998
+ //#endregion
2898
2999
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2899
3000
  /**
2900
3001
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -5828,6 +5929,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
5828
5929
  }
5829
5930
  return inserted;
5830
5931
  }
5932
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
5933
+ var FULL_FRAME_QUALITY = 80;
5934
+ /**
5935
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
5936
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5937
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5938
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5939
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5940
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5941
+ */
5942
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5943
+ return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5944
+ fit: "inside",
5945
+ withoutEnlargement: true
5946
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5947
+ }
5831
5948
  //#endregion
5832
5949
  //#region src/pipeline-analytics/services/synthetic-sensor-track.ts
5833
5950
  /**
@@ -5892,7 +6009,13 @@ var SyntheticSensorTrackMaterializer = class {
5892
6009
  force: true
5893
6010
  });
5894
6011
  if (snap !== null) {
5895
- const data = Buffer.from(snap.base64, "base64");
6012
+ const raw = Buffer.from(snap.base64, "base64");
6013
+ let data = raw;
6014
+ try {
6015
+ data = await downscaleFullFrameJpeg(raw);
6016
+ } catch (err) {
6017
+ this.deps.onError?.("downscaleSnapshot", err);
6018
+ }
5896
6019
  mediaKey = await this.deps.media.put({
5897
6020
  deviceId: input.cameraId,
5898
6021
  ownerKind: "track",
@@ -6111,7 +6234,10 @@ var EventMediaDispatcher = class {
6111
6234
  async captureForFrame(input) {
6112
6235
  const { deviceId, frameHandle, events, trackFrames } = input;
6113
6236
  const snapshots = input.snapshots ?? [];
6114
- const empty = { storedSnapshots: [] };
6237
+ const empty = {
6238
+ storedSnapshots: [],
6239
+ thumbnailTrackIds: []
6240
+ };
6115
6241
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
6116
6242
  let decoded;
6117
6243
  try {
@@ -6165,11 +6291,16 @@ var EventMediaDispatcher = class {
6165
6291
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6166
6292
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6167
6293
  const storedSnapshots = [];
6294
+ const thumbnailTrackIds = [];
6168
6295
  for (const sn of snapshots) {
6169
- const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6170
- if (stored) storedSnapshots.push(stored);
6296
+ const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6297
+ if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6298
+ if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6171
6299
  }
6172
- return { storedSnapshots };
6300
+ return {
6301
+ storedSnapshots,
6302
+ thumbnailTrackIds
6303
+ };
6173
6304
  }
6174
6305
  /**
6175
6306
  * Periodic per-track media (§5). The boxed FULL frame is encoded once and
@@ -6180,10 +6311,14 @@ var EventMediaDispatcher = class {
6180
6311
  * object event, and a full frame there shows the scene (e.g. a foreground
6181
6312
  * parked car), not the track's subject. Returns the appended snapshot for
6182
6313
  * TrackStore wiring (null when `appendSnapshot` is false or the encode
6183
- * failed).
6314
+ * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6315
+ * landed this frame (#27-A) so the caller can stop forcing retries.
6184
6316
  */
6185
6317
  async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6186
- if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
6318
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6319
+ storedSnapshot: null,
6320
+ thumbnailWritten: false
6321
+ };
6187
6322
  let boxed = null;
6188
6323
  if (sn.appendSnapshot || sn.rollingLastFrame) try {
6189
6324
  boxed = await drawBoxedFrame(frameData, fw, fh, [{
@@ -6218,9 +6353,10 @@ var EventMediaDispatcher = class {
6218
6353
  };
6219
6354
  } catch {}
6220
6355
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6356
+ let thumbnailWritten = false;
6221
6357
  if (sn.bestThumbnail) try {
6222
6358
  const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6223
- await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6359
+ thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6224
6360
  } catch (err) {
6225
6361
  this.deps.logger.warn("event media: track thumbnail crop failed", {
6226
6362
  tags: { deviceId },
@@ -6230,9 +6366,12 @@ var EventMediaDispatcher = class {
6230
6366
  error: err instanceof Error ? err.message : String(err)
6231
6367
  }
6232
6368
  });
6233
- if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6369
+ if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6234
6370
  }
6235
- return stored;
6371
+ return {
6372
+ storedSnapshot: stored,
6373
+ thumbnailWritten
6374
+ };
6236
6375
  }
6237
6376
  /**
6238
6377
  * Clean subject-centered crop of `bbox` out of the raw frame — the shared
@@ -6270,6 +6409,7 @@ var EventMediaDispatcher = class {
6270
6409
  timestamp,
6271
6410
  data
6272
6411
  });
6412
+ return true;
6273
6413
  } catch (err) {
6274
6414
  this.deps.logger.debug(`event media: ${kind} replace failed`, {
6275
6415
  tags: { deviceId },
@@ -6279,6 +6419,7 @@ var EventMediaDispatcher = class {
6279
6419
  error: err instanceof Error ? err.message : String(err)
6280
6420
  }
6281
6421
  });
6422
+ return false;
6282
6423
  }
6283
6424
  }
6284
6425
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
@@ -7581,15 +7722,30 @@ var FaceSettingsSchema = require_dist.object({
7581
7722
  */
7582
7723
  enabled: require_dist.boolean().default(true),
7583
7724
  /** Cosine similarity (on L2-normalized arcface vectors) required to match. */
7584
- similarityThreshold: require_dist.number().min(0).max(1).default(.45),
7725
+ similarityThreshold: require_dist.number().min(0).max(1).default(.55),
7585
7726
  /** Reject ambiguous matches: require best − secondBest ≥ margin. */
7586
- margin: require_dist.number().min(0).max(1).default(.05),
7727
+ margin: require_dist.number().min(0).max(1).default(.1),
7587
7728
  /** Minimum face-detection confidence for a face to be considered. */
7588
7729
  minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
7730
+ /**
7731
+ * Minimum face bbox size (px, shorter side of the face box in detection-frame
7732
+ * space) for a face to be eligible for embedding-based auto-matching. Below
7733
+ * this, ArcFace resolution is unreliable and auto-assignment produces the
7734
+ * observed false positives (tiny/distant faces collapsing onto one identity).
7735
+ * Such faces are dropped BEFORE matching/enrolment (#26.1).
7736
+ */
7737
+ minFacePx: require_dist.number().min(0).default(30),
7738
+ /**
7739
+ * Minimum enrolled-sample count an identity must have before it can be an
7740
+ * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
7741
+ * enrolment attracted 81% of matches); identities below this are excluded from
7742
+ * automatic matching until more samples are enrolled (#26.3).
7743
+ */
7744
+ minIdentitySamples: require_dist.number().int().min(1).default(2),
7589
7745
  /** Frames an identity must be confirmed before a track is assigned. Floor of
7590
7746
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
7591
7747
  * such a value falls back to the default). */
7592
- confirmFrames: require_dist.number().int().min(1).default(2),
7748
+ confirmFrames: require_dist.number().int().min(1).default(3),
7593
7749
  /** Recent-faces buffer retention (days). */
7594
7750
  bufferRetentionDays: require_dist.number().min(0).default(3),
7595
7751
  /** Max buffered faces kept per device. */
@@ -7606,6 +7762,8 @@ function resolveFaceSettings(raw) {
7606
7762
  similarityThreshold: pick("similarityThreshold"),
7607
7763
  margin: pick("margin"),
7608
7764
  minFaceConfidence: pick("minFaceConfidence"),
7765
+ minFacePx: pick("minFacePx"),
7766
+ minIdentitySamples: pick("minIdentitySamples"),
7609
7767
  confirmFrames: pick("confirmFrames"),
7610
7768
  bufferRetentionDays: pick("bufferRetentionDays"),
7611
7769
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
@@ -7941,10 +8099,12 @@ function evaluatePeriodicSnapshot(input) {
7941
8099
  */
7942
8100
  function planPeriodicMedia(input) {
7943
8101
  const appendSnapshot = input.dueSnapshot;
8102
+ const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
8103
+ const thumbnailLanded = input.thumbnailLanded ?? true;
7944
8104
  return {
7945
8105
  appendSnapshot,
7946
- rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
7947
- bestThumbnail: input.isNewBest
8106
+ rollingLastFrame,
8107
+ bestThumbnail: input.isNewBest || !thumbnailLanded
7948
8108
  };
7949
8109
  }
7950
8110
  //#endregion
@@ -8927,6 +9087,21 @@ var ObjectEmbeddingStore = class {
8927
9087
  //#endregion
8928
9088
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
8929
9089
  /**
9090
+ * Count enrolled samples per identity for probes of a matching model+dimension.
9091
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
9092
+ */
9093
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
9094
+ const counts = /* @__PURE__ */ new Map();
9095
+ for (const s of gallery) {
9096
+ if (s.modelId !== probeModelId) continue;
9097
+ if (s.embedding.length !== probeDim) continue;
9098
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
9099
+ }
9100
+ const eligible = /* @__PURE__ */ new Set();
9101
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
9102
+ return eligible;
9103
+ }
9104
+ /**
8930
9105
  * Assign at most one identity per track AND at most one track per identity for
8931
9106
  * a single frame. Greedy by score: compute every candidate's full ranked match
8932
9107
  * list, then repeatedly take the globally-highest (track, identity) pair whose
@@ -8937,10 +9112,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
8937
9112
  const pairs = [];
8938
9113
  candidates.forEach((c, trackIdx) => {
8939
9114
  const probeVec = new Float32Array(c.embedding);
9115
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
8940
9116
  const bestByIdentity = /* @__PURE__ */ new Map();
8941
9117
  for (const s of gallery) {
8942
9118
  if (s.modelId !== c.modelId) continue;
8943
9119
  if (s.embedding.length !== c.embedding.length) continue;
9120
+ if (!eligible.has(s.identityId)) continue;
8944
9121
  const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
8945
9122
  const prev = bestByIdentity.get(s.identityId);
8946
9123
  if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
@@ -9086,7 +9263,7 @@ var FaceRecognizer = class {
9086
9263
  }
9087
9264
  async processFrame(input) {
9088
9265
  const { settings } = input;
9089
- const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
9266
+ const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.faceBbox === void 0 || Math.min(t.faceBbox.w, t.faceBbox.h) >= settings.minFacePx));
9090
9267
  if (candidates.length === 0) return;
9091
9268
  this.deps.logger.debug("face: frame candidates", {
9092
9269
  tags: { deviceId: input.deviceId },
@@ -9102,7 +9279,8 @@ var FaceRecognizer = class {
9102
9279
  modelId: c.embeddingModelId
9103
9280
  })), this.gallery, {
9104
9281
  threshold: settings.similarityThreshold,
9105
- margin: settings.margin
9282
+ margin: settings.margin,
9283
+ minIdentitySamples: settings.minIdentitySamples
9106
9284
  }) : /* @__PURE__ */ new Map();
9107
9285
  const labelWork = [];
9108
9286
  for (const c of candidates) {
@@ -11349,6 +11527,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11349
11527
  faceGlobalEnabledCache = null;
11350
11528
  mediaCacheByDevice = /* @__PURE__ */ new Map();
11351
11529
  packageDropCacheByDevice = /* @__PURE__ */ new Map();
11530
+ /** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
11531
+ * The registry is a single shared instance, so per-(device) suppress/wake IoU,
11532
+ * promotion window, TTL and the master toggle are resolved from this cache at
11533
+ * the partition + promotion + sweep call sites — an operator change takes
11534
+ * effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
11535
+ stationaryCacheByDevice = /* @__PURE__ */ new Map();
11352
11536
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
11353
11537
  packageDropDetector = null;
11354
11538
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
@@ -11381,6 +11565,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11381
11565
  * where no `snapshot` is appended, so it is never byte-identical to a stored
11382
11566
  * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
11383
11567
  lastFrameAtByTrack = /* @__PURE__ */ new Map();
11568
+ /** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
11569
+ * track absent here keeps forcing a best-thumbnail capture every frame until
11570
+ * one lands, so a short / high-churn track whose first capture was dropped
11571
+ * (recycled/blank live frame) still gets a subject crop for the gallery
11572
+ * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11573
+ thumbnailLandedTracks = /* @__PURE__ */ new Set();
11384
11574
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11385
11575
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11386
11576
  * area) + emit time, so a material improvement is measured against the
@@ -11438,7 +11628,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11438
11628
  let storage = this.ctx.kernel.storage;
11439
11629
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
11440
11630
  if (mediaRoot) {
11441
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BRwocT7C.js"));
11631
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CDbDhtGa.js"));
11442
11632
  storage = new FilesystemStorageProvider(mediaRoot);
11443
11633
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11444
11634
  }
@@ -11452,6 +11642,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11452
11642
  this.stationaryRegistry = new StationaryObjectRegistry({
11453
11643
  store: api.settingsStore,
11454
11644
  logger: logger.child("StationaryRegistry"),
11645
+ ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
11455
11646
  onChange: ({ phase, entry, timestamp }) => {
11456
11647
  this.ctx.eventBus.emit({
11457
11648
  id: `pa-stationary-${entry.id}-${phase}`,
@@ -11827,6 +12018,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11827
12018
  this.faceCacheByDevice.delete(data.deviceId);
11828
12019
  this.mediaCacheByDevice.delete(data.deviceId);
11829
12020
  this.packageDropCacheByDevice.delete(data.deviceId);
12021
+ this.stationaryCacheByDevice.delete(data.deviceId);
11830
12022
  }
11831
12023
  });
11832
12024
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
@@ -11843,6 +12035,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11843
12035
  this.faceCacheByDevice.delete(deviceId);
11844
12036
  this.mediaCacheByDevice.delete(deviceId);
11845
12037
  this.packageDropCacheByDevice.delete(deviceId);
12038
+ this.stationaryCacheByDevice.delete(deviceId);
11846
12039
  this.bindingCache?.invalidate(deviceId);
11847
12040
  this.zoneAnalytics?.forgetDevice(deviceId);
11848
12041
  this.audioMetrics?.forgetDevice(deviceId);
@@ -12181,6 +12374,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12181
12374
  this.dropoutSkipsByKey.clear();
12182
12375
  this.bestFrameTracker.clear();
12183
12376
  this.lastFrameAtByTrack.clear();
12377
+ this.thumbnailLandedTracks.clear();
12184
12378
  this.trackLifecycleUpdateMem.clear();
12185
12379
  this.objectEmbeddingBestSelector.clear();
12186
12380
  this.levelStateByDevice.clear();
@@ -12191,6 +12385,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12191
12385
  this.faceGlobalEnabledCache = null;
12192
12386
  this.mediaCacheByDevice.clear();
12193
12387
  this.packageDropCacheByDevice.clear();
12388
+ this.stationaryCacheByDevice.clear();
12194
12389
  this.trackStore?.clearAll();
12195
12390
  this.stationaryRegistry = null;
12196
12391
  this.bindingCache?.clearAll();
@@ -12235,6 +12430,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12235
12430
  return;
12236
12431
  }
12237
12432
  this.dropoutSkipsByKey.set(key, 0);
12433
+ if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
12238
12434
  const processor = await this.getOrCreateProcessor(deviceId, source);
12239
12435
  const proxy = await this.ensureProxy(deviceId);
12240
12436
  const liveZones = proxy?.state.zones.value?.zones ?? [];
@@ -12374,10 +12570,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12374
12570
  } });
12375
12571
  }
12376
12572
  this.lastActiveTrackIds.set(key, currentTrackIds);
12377
- if (source === "pipeline" && this.stationaryRegistry) {
12573
+ const stationarySettings = this.stationarySettingsFromCache(deviceId);
12574
+ if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
12378
12575
  const dims = this.lastFrameDimsByDevice.get(deviceId);
12379
12576
  if (dims && dims.w > 0 && dims.h > 0) {
12380
12577
  const refDiag = Math.hypot(dims.w, dims.h);
12578
+ const promotionConfig = {
12579
+ ...DEFAULT_PROMOTION_CONFIG,
12580
+ windowMs: stationarySettings.promotionWindowMs
12581
+ };
12381
12582
  for (const t of result.tracked) {
12382
12583
  const active = this.trackStore.peekActive(t.trackId);
12383
12584
  if (!active) continue;
@@ -12385,7 +12586,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12385
12586
  positions: active.positions,
12386
12587
  referenceDiagonalPx: refDiag,
12387
12588
  now: result.timestamp,
12388
- config: DEFAULT_PROMOTION_CONFIG
12589
+ config: promotionConfig
12389
12590
  });
12390
12591
  if (!promote) continue;
12391
12592
  this.promoteToStationary({
@@ -12512,6 +12713,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12512
12713
  },
12513
12714
  mediaKey: s.mediaKey
12514
12715
  });
12716
+ for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
12515
12717
  }).catch(() => {});
12516
12718
  }
12517
12719
  }
@@ -12666,6 +12868,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12666
12868
  });
12667
12869
  return settings;
12668
12870
  }
12871
+ async resolveDeviceStationarySettings(deviceId) {
12872
+ const now = Date.now();
12873
+ const cached = this.stationaryCacheByDevice.get(deviceId);
12874
+ if (cached && now < cached.expiresAt) return cached.settings;
12875
+ const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
12876
+ this.stationaryCacheByDevice.set(deviceId, {
12877
+ settings,
12878
+ expiresAt: now + SETTINGS_CACHE_TTL_MS
12879
+ });
12880
+ return settings;
12881
+ }
12882
+ stationarySettingsFromCache(deviceId) {
12883
+ return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
12884
+ }
12669
12885
  async resolveDevicePackageDropSettings(deviceId) {
12670
12886
  const now = Date.now();
12671
12887
  const cached = this.packageDropCacheByDevice.get(deviceId);
@@ -12763,6 +12979,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12763
12979
  if (!this.faceRecognizer || detail.embedding === void 0) return;
12764
12980
  if (!await this.resolveGlobalFaceEnabled()) return;
12765
12981
  const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
12982
+ if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
12766
12983
  await this.faceRecognizer.ingestFaceDetail({
12767
12984
  deviceId,
12768
12985
  trackId,
@@ -13041,12 +13258,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13041
13258
  frameWidth,
13042
13259
  frameHeight
13043
13260
  });
13044
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
13261
+ const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
13262
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
13045
13263
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
13046
13264
  const plan = planPeriodicMedia({
13047
13265
  saveThumbnails: media.saveThumbnails,
13048
13266
  dueSnapshot,
13049
13267
  isNewBest,
13268
+ thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
13050
13269
  lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
13051
13270
  now: timestamp,
13052
13271
  intervalMs: media.snapshotIntervalMs
@@ -13367,6 +13586,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13367
13586
  this.bestFrameTracker.delete(t.trackId);
13368
13587
  this.objectEmbeddingBestSelector.delete(t.trackId);
13369
13588
  this.lastFrameAtByTrack.delete(t.trackId);
13589
+ this.thumbnailLandedTracks.delete(t.trackId);
13370
13590
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13371
13591
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13372
13592
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -13628,6 +13848,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13628
13848
  this.bestFrameTracker.delete(track.trackId);
13629
13849
  this.objectEmbeddingBestSelector.delete(track.trackId);
13630
13850
  this.lastFrameAtByTrack.delete(track.trackId);
13851
+ this.thumbnailLandedTracks.delete(track.trackId);
13631
13852
  this.trackLifecycleUpdateMem.delete(track.trackId);
13632
13853
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
13633
13854
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -13669,15 +13890,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13669
13890
  }, source);
13670
13891
  if (source === "pipeline" && this.stationaryRegistry) {
13671
13892
  const registry = this.stationaryRegistry;
13672
- p.setStationaryGate({ filter: (input) => registry.filter({
13673
- deviceId,
13674
- detections: input.detections.map((d) => ({
13675
- bbox: d.bbox,
13676
- className: d.class
13677
- })),
13678
- frameWidth: input.frameWidth,
13679
- frameHeight: input.frameHeight
13680
- }) });
13893
+ p.setStationaryGate({ filter: (input) => {
13894
+ const s = this.stationarySettingsFromCache(deviceId);
13895
+ if (!s.enabled) return {
13896
+ suppressedIndices: /* @__PURE__ */ new Set(),
13897
+ confirmed: [],
13898
+ wokenEntryIds: []
13899
+ };
13900
+ const matchConfig = {
13901
+ suppressIou: s.suppressIou,
13902
+ wakeAssocIou: s.wakeAssocIou
13903
+ };
13904
+ return registry.filter({
13905
+ deviceId,
13906
+ detections: input.detections.map((d) => ({
13907
+ bbox: d.bbox,
13908
+ className: d.class
13909
+ })),
13910
+ frameWidth: input.frameWidth,
13911
+ frameHeight: input.frameHeight
13912
+ }, matchConfig);
13913
+ } });
13681
13914
  }
13682
13915
  this.processors.set(key, p);
13683
13916
  }
@@ -14740,6 +14973,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
14740
14973
  step: .05,
14741
14974
  default: FACE_DEFAULTS.minFaceConfidence
14742
14975
  },
14976
+ {
14977
+ type: "number",
14978
+ key: "minFacePx",
14979
+ label: "Min face size",
14980
+ description: "Minimum face box size (shorter side, pixels) for a face to be eligible for auto-matching. Faces smaller than this are too low-resolution for reliable recognition and are ignored. Raise it to suppress false matches on tiny/distant faces.",
14981
+ min: 0,
14982
+ step: 1,
14983
+ default: FACE_DEFAULTS.minFacePx,
14984
+ unit: "px"
14985
+ },
14986
+ {
14987
+ type: "number",
14988
+ key: "minIdentitySamples",
14989
+ label: "Min identity samples",
14990
+ description: "Minimum enrolled sample count before an identity can be an automatic match target. Identities with fewer samples are ignored by auto-matching (prevents a single unreliable sample from attracting many faces).",
14991
+ min: 1,
14992
+ step: 1,
14993
+ default: FACE_DEFAULTS.minIdentitySamples
14994
+ },
14743
14995
  {
14744
14996
  type: "number",
14745
14997
  key: "confirmFrames",
@@ -14950,6 +15202,70 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
14950
15202
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
14951
15203
  }
14952
15204
  ]
15205
+ },
15206
+ {
15207
+ id: "stationary-objects",
15208
+ title: "Stationary objects",
15209
+ tab: "analytics",
15210
+ description: "Parked / idle object suppression — keeps a stopped car from re-spawning tracks and re-flooding events. Per-device overrides on each camera. Defaults match today’s behaviour.",
15211
+ columns: 2,
15212
+ fields: [
15213
+ {
15214
+ type: "boolean",
15215
+ key: "enabled",
15216
+ label: "Suppress parked objects",
15217
+ description: "Promote a stopped object to a lightweight registry entry and suppress its detections from re-spawning tracks. Off = every parked object keeps spawning normal tracks on this camera.",
15218
+ default: STATIONARY_DEFAULTS.enabled
15219
+ },
15220
+ {
15221
+ type: "slider",
15222
+ key: "suppressIou",
15223
+ label: "Suppress IoU",
15224
+ description: "Overlap at/above which a detection is the SAME parked object (unmoved) → its spawn is suppressed. Lower can suppress genuine new tracks near a parked object.",
15225
+ min: .3,
15226
+ max: .9,
15227
+ step: .05,
15228
+ default: STATIONARY_DEFAULTS.suppressIou,
15229
+ showValue: true
15230
+ },
15231
+ {
15232
+ type: "slider",
15233
+ key: "wakeAssocIou",
15234
+ label: "Wake / associate IoU",
15235
+ description: "Minimum overlap for a detection to be treated as a parked entry’s OWN object (confirm or wake it). Too low lets a passing object wake a parked entry (re-flood); too high can miss the real departure.",
15236
+ min: .02,
15237
+ max: .5,
15238
+ step: .02,
15239
+ default: STATIONARY_DEFAULTS.wakeAssocIou,
15240
+ showValue: true
15241
+ },
15242
+ {
15243
+ type: "slider",
15244
+ key: "promotionWindowMs",
15245
+ label: "Promotion stillness window",
15246
+ description: "How long (ms) an object must stay put — and the track must have existed — before it is promoted to a parked entry. Longer = slower to stop a re-spawn flood.",
15247
+ min: 5e3,
15248
+ max: 12e4,
15249
+ step: 1e3,
15250
+ default: STATIONARY_DEFAULTS.promotionWindowMs,
15251
+ showValue: true,
15252
+ unit: "s",
15253
+ displayScale: 1e3
15254
+ },
15255
+ {
15256
+ type: "slider",
15257
+ key: "entryTtlMs",
15258
+ label: "Observed-time TTL",
15259
+ description: "Retire a parked entry after this much OBSERVED (frames-flowing) time without a confirming detection — the object was removed while watched, or a long occlusion.",
15260
+ min: 6e4,
15261
+ max: 18e5,
15262
+ step: 3e4,
15263
+ default: STATIONARY_DEFAULTS.entryTtlMs,
15264
+ showValue: true,
15265
+ unit: "s",
15266
+ displayScale: 1e3
15267
+ }
15268
+ ]
14953
15269
  }
14954
15270
  ] });
14955
15271
  }