@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.
@@ -1,4 +1,4 @@
1
- import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-8DTQLWKO.mjs";
1
+ import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-DPBet4IQ.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -2132,6 +2132,29 @@ function isEdgeClear(input) {
2132
2132
  return true;
2133
2133
  }
2134
2134
  /**
2135
+ * Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
2136
+ * centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
2137
+ * — a cheap proxy for "is the subject well-framed?" used as the best-frame
2138
+ * tie-breaker so a low-importance short track stops locking in an edge-of-frame
2139
+ * subject when a better-centred, near-equal-confidence frame is available.
2140
+ *
2141
+ * The score is `1 - normalizedDistance(centre → frameCentre)`, where the
2142
+ * distance is normalised by the max possible (centre → corner) so it is
2143
+ * scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
2144
+ * falls back to pure confidence, matching {@link isEdgeClear}).
2145
+ */
2146
+ function bboxCenterScore(bbox, frameWidth, frameHeight) {
2147
+ if (frameWidth <= 0 || frameHeight <= 0) return 1;
2148
+ const cx = bbox.x + bbox.w / 2;
2149
+ const cy = bbox.y + bbox.h / 2;
2150
+ const fcx = frameWidth / 2;
2151
+ const fcy = frameHeight / 2;
2152
+ const dx = (cx - fcx) / fcx;
2153
+ const dy = (cy - fcy) / fcy;
2154
+ const dist = Math.hypot(dx, dy) / Math.SQRT2;
2155
+ return Math.max(0, Math.min(1, 1 - dist));
2156
+ }
2157
+ /**
2135
2158
  * Edge-aware "is `candidate` a new best over `current`?" comparator.
2136
2159
  *
2137
2160
  * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
@@ -2139,13 +2162,25 @@ function isEdgeClear(input) {
2139
2162
  * confidence past the `hysteresis` margin wins. The tier upgrade
2140
2163
  * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2141
2164
  *
2165
+ * CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
2166
+ * wins on confidence (the two are within the `hysteresis` band) but the
2167
+ * candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
2168
+ * candidate wins. This only engages when both sides carry a `centerScore` (the
2169
+ * best-frame path), so low-importance short tracks stop keeping an edge-of-frame
2170
+ * subject over an equally-confident, better-framed one. The face /
2171
+ * object-embedding callers omit `centerScore` → identical legacy behaviour.
2172
+ *
2142
2173
  * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2143
2174
  * not here, so this stays a pure value comparison.
2144
2175
  */
2145
2176
  function isEdgeAwareNewBest(current, candidate, hysteresis) {
2146
2177
  if (candidate.edgeClear && !current.edgeClear) return true;
2147
2178
  if (!candidate.edgeClear && current.edgeClear) return false;
2148
- return candidate.confidence > current.confidence + hysteresis;
2179
+ if (candidate.confidence > current.confidence + hysteresis) return true;
2180
+ if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
2181
+ if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
2182
+ }
2183
+ return false;
2149
2184
  }
2150
2185
  //#endregion
2151
2186
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
@@ -2181,6 +2216,10 @@ var BestDetectionTracker = class {
2181
2216
  * the edge tier is not in play for the track (treated as clear → the legacy
2182
2217
  * pure-confidence policy). */
2183
2218
  edgeClear = /* @__PURE__ */ new Map();
2219
+ /** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
2220
+ * does not supply centering (face / object-embedding paths) → the centering
2221
+ * tie-break is disabled and the legacy confidence policy applies. */
2222
+ centerScore = /* @__PURE__ */ new Map();
2184
2223
  constructor(options = {}) {
2185
2224
  this.hysteresis = options.hysteresis ?? 0;
2186
2225
  this.minGapMs = options.minGapMs ?? 0;
@@ -2197,7 +2236,7 @@ var BestDetectionTracker = class {
2197
2236
  * the classic policy holds: a confidence past the `hysteresis` margin that also
2198
2237
  * respects `minGapMs` wins. On acceptance the held peak advances.
2199
2238
  */
2200
- observe(trackId, confidence, timestamp, edgeClear) {
2239
+ observe(trackId, confidence, timestamp, edgeClear, centerScore) {
2201
2240
  const cur = this.best.get(trackId);
2202
2241
  if (cur === void 0) {
2203
2242
  this.best.set(trackId, {
@@ -2205,16 +2244,20 @@ var BestDetectionTracker = class {
2205
2244
  atMs: timestamp
2206
2245
  });
2207
2246
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2247
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2208
2248
  return true;
2209
2249
  }
2210
2250
  const curClear = this.edgeClear.get(trackId) ?? true;
2211
2251
  const candClear = edgeClear ?? true;
2252
+ const curCenter = this.centerScore.get(trackId);
2212
2253
  const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2213
2254
  confidence: cur.confidence,
2214
- edgeClear: curClear
2255
+ edgeClear: curClear,
2256
+ ...curCenter !== void 0 ? { centerScore: curCenter } : {}
2215
2257
  }, {
2216
2258
  confidence,
2217
- edgeClear: candClear
2259
+ edgeClear: candClear,
2260
+ ...centerScore !== void 0 ? { centerScore } : {}
2218
2261
  }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2219
2262
  if (isNewBest) {
2220
2263
  this.best.set(trackId, {
@@ -2222,6 +2265,7 @@ var BestDetectionTracker = class {
2222
2265
  atMs: timestamp
2223
2266
  });
2224
2267
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2268
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2225
2269
  }
2226
2270
  return isNewBest;
2227
2271
  }
@@ -2233,10 +2277,12 @@ var BestDetectionTracker = class {
2233
2277
  delete(trackId) {
2234
2278
  this.best.delete(trackId);
2235
2279
  this.edgeClear.delete(trackId);
2280
+ this.centerScore.delete(trackId);
2236
2281
  }
2237
2282
  clear() {
2238
2283
  this.best.clear();
2239
2284
  this.edgeClear.clear();
2285
+ this.centerScore.clear();
2240
2286
  }
2241
2287
  };
2242
2288
  //#endregion
@@ -2389,6 +2435,9 @@ var WAKE_ASSOC_IOU = .1;
2389
2435
  * long) so a freshly-spawned static blob isn't promoted instantly.
2390
2436
  */
2391
2437
  var PROMOTION_WINDOW_MS = 3e4;
2438
+ /** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
2439
+ * long (object removed while unobserved, or a long occlusion) → retire it. */
2440
+ var ENTRY_TTL_MS = 5 * 6e4;
2392
2441
  var DEFAULT_MATCH_CONFIG = {
2393
2442
  suppressIou: SUPPRESS_IOU,
2394
2443
  wakeAssocIou: WAKE_ASSOC_IOU
@@ -2596,6 +2645,7 @@ var StationaryObjectRegistry = class {
2596
2645
  logger;
2597
2646
  matchConfig;
2598
2647
  entryTtlMs;
2648
+ ttlForDevice;
2599
2649
  onChange;
2600
2650
  /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2601
2651
  * time, not wall-clock. A session-dispatch camera produces no frames
@@ -2607,6 +2657,7 @@ var StationaryObjectRegistry = class {
2607
2657
  this.logger = deps.logger;
2608
2658
  this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2609
2659
  this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2660
+ this.ttlForDevice = deps.ttlForDevice;
2610
2661
  this.onChange = deps.onChange;
2611
2662
  }
2612
2663
  static async declare(store) {
@@ -2662,7 +2713,7 @@ var StationaryObjectRegistry = class {
2662
2713
  * entries. PURE with respect to registry state — apply the outcome with
2663
2714
  * {@link applyFrameOutcome} once the frame result is assembled.
2664
2715
  */
2665
- filter(input) {
2716
+ filter(input, config) {
2666
2717
  const entries = this.list(input.deviceId);
2667
2718
  if (entries.length === 0) return {
2668
2719
  suppressedIndices: /* @__PURE__ */ new Set(),
@@ -2672,7 +2723,7 @@ var StationaryObjectRegistry = class {
2672
2723
  return partitionDetectionsAgainstRegistry({
2673
2724
  entries,
2674
2725
  detections: input.detections,
2675
- config: this.matchConfig
2726
+ config: config ?? this.matchConfig
2676
2727
  });
2677
2728
  }
2678
2729
  /** Fold a frame's gate result back into state: advance confirmed entries'
@@ -2741,7 +2792,8 @@ var StationaryObjectRegistry = class {
2741
2792
  for (const [deviceId, m] of this.byDevice) {
2742
2793
  const observedAt = this.lastFrameAtByDevice.get(deviceId);
2743
2794
  if (observedAt === void 0) continue;
2744
- for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2795
+ const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
2796
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
2745
2797
  m.delete(id);
2746
2798
  this.dirty.delete(id);
2747
2799
  retired.push(e);
@@ -2890,6 +2942,55 @@ function computeStationaryEntryZones(entry, zones) {
2890
2942
  return matched;
2891
2943
  }
2892
2944
  //#endregion
2945
+ //#region src/pipeline-analytics/stationary-settings.ts
2946
+ /**
2947
+ * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
2948
+ * per-device override on top of the global default, resolved per field (an
2949
+ * invalid/missing value falls back to its default — parse never throws).
2950
+ * Mirrors `media-settings` / `tracking-settings`.
2951
+ *
2952
+ * The defaults are the SAME constants the registry uses at runtime
2953
+ * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
2954
+ * value both resolve to exactly today's behaviour — "reset == unset == today",
2955
+ * with no drift. Guarded by a unit test asserting the equality.
2956
+ */
2957
+ var StationarySettingsSchema = object({
2958
+ /** Master switch. Off ⇒ no promotion + no suppression for this camera (every
2959
+ * parked object keeps spawning normal tracks). */
2960
+ enabled: boolean().default(true),
2961
+ /** IoU at/above which a detection is the same parked object, unmoved →
2962
+ * suppress its spawn. `SUPPRESS_IOU`. */
2963
+ suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
2964
+ /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
2965
+ * wake it) — the overlap gate that stops a different vehicle from waking a
2966
+ * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
2967
+ wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
2968
+ /** Recent-window stillness a track must hold to be PROMOTED to a parked
2969
+ * entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
2970
+ promotionWindowMs: number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
2971
+ /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
2972
+ * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
2973
+ entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
2974
+ });
2975
+ var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
2976
+ /**
2977
+ * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
2978
+ * fields fall back to the default for that field (never throws on a bad blob).
2979
+ */
2980
+ function resolveStationarySettings(raw) {
2981
+ const pick = (key) => {
2982
+ const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
2983
+ return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
2984
+ };
2985
+ return {
2986
+ enabled: pick("enabled"),
2987
+ suppressIou: pick("suppressIou"),
2988
+ wakeAssocIou: pick("wakeAssocIou"),
2989
+ promotionWindowMs: pick("promotionWindowMs"),
2990
+ entryTtlMs: pick("entryTtlMs")
2991
+ };
2992
+ }
2993
+ //#endregion
2893
2994
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2894
2995
  /**
2895
2996
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -5823,6 +5924,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
5823
5924
  }
5824
5925
  return inserted;
5825
5926
  }
5927
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
5928
+ var FULL_FRAME_QUALITY = 80;
5929
+ /**
5930
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
5931
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5932
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5933
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5934
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5935
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5936
+ */
5937
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5938
+ return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5939
+ fit: "inside",
5940
+ withoutEnlargement: true
5941
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5942
+ }
5826
5943
  //#endregion
5827
5944
  //#region src/pipeline-analytics/services/synthetic-sensor-track.ts
5828
5945
  /**
@@ -5887,7 +6004,13 @@ var SyntheticSensorTrackMaterializer = class {
5887
6004
  force: true
5888
6005
  });
5889
6006
  if (snap !== null) {
5890
- const data = Buffer.from(snap.base64, "base64");
6007
+ const raw = Buffer.from(snap.base64, "base64");
6008
+ let data = raw;
6009
+ try {
6010
+ data = await downscaleFullFrameJpeg(raw);
6011
+ } catch (err) {
6012
+ this.deps.onError?.("downscaleSnapshot", err);
6013
+ }
5891
6014
  mediaKey = await this.deps.media.put({
5892
6015
  deviceId: input.cameraId,
5893
6016
  ownerKind: "track",
@@ -6106,7 +6229,10 @@ var EventMediaDispatcher = class {
6106
6229
  async captureForFrame(input) {
6107
6230
  const { deviceId, frameHandle, events, trackFrames } = input;
6108
6231
  const snapshots = input.snapshots ?? [];
6109
- const empty = { storedSnapshots: [] };
6232
+ const empty = {
6233
+ storedSnapshots: [],
6234
+ thumbnailTrackIds: []
6235
+ };
6110
6236
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
6111
6237
  let decoded;
6112
6238
  try {
@@ -6160,11 +6286,16 @@ var EventMediaDispatcher = class {
6160
6286
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6161
6287
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6162
6288
  const storedSnapshots = [];
6289
+ const thumbnailTrackIds = [];
6163
6290
  for (const sn of snapshots) {
6164
- const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6165
- if (stored) storedSnapshots.push(stored);
6291
+ const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6292
+ if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6293
+ if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6166
6294
  }
6167
- return { storedSnapshots };
6295
+ return {
6296
+ storedSnapshots,
6297
+ thumbnailTrackIds
6298
+ };
6168
6299
  }
6169
6300
  /**
6170
6301
  * Periodic per-track media (§5). The boxed FULL frame is encoded once and
@@ -6175,10 +6306,14 @@ var EventMediaDispatcher = class {
6175
6306
  * object event, and a full frame there shows the scene (e.g. a foreground
6176
6307
  * parked car), not the track's subject. Returns the appended snapshot for
6177
6308
  * TrackStore wiring (null when `appendSnapshot` is false or the encode
6178
- * failed).
6309
+ * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6310
+ * landed this frame (#27-A) so the caller can stop forcing retries.
6179
6311
  */
6180
6312
  async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6181
- if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
6313
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6314
+ storedSnapshot: null,
6315
+ thumbnailWritten: false
6316
+ };
6182
6317
  let boxed = null;
6183
6318
  if (sn.appendSnapshot || sn.rollingLastFrame) try {
6184
6319
  boxed = await drawBoxedFrame(frameData, fw, fh, [{
@@ -6213,9 +6348,10 @@ var EventMediaDispatcher = class {
6213
6348
  };
6214
6349
  } catch {}
6215
6350
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6351
+ let thumbnailWritten = false;
6216
6352
  if (sn.bestThumbnail) try {
6217
6353
  const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6218
- await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6354
+ thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6219
6355
  } catch (err) {
6220
6356
  this.deps.logger.warn("event media: track thumbnail crop failed", {
6221
6357
  tags: { deviceId },
@@ -6225,9 +6361,12 @@ var EventMediaDispatcher = class {
6225
6361
  error: err instanceof Error ? err.message : String(err)
6226
6362
  }
6227
6363
  });
6228
- if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6364
+ if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6229
6365
  }
6230
- return stored;
6366
+ return {
6367
+ storedSnapshot: stored,
6368
+ thumbnailWritten
6369
+ };
6231
6370
  }
6232
6371
  /**
6233
6372
  * Clean subject-centered crop of `bbox` out of the raw frame — the shared
@@ -6265,6 +6404,7 @@ var EventMediaDispatcher = class {
6265
6404
  timestamp,
6266
6405
  data
6267
6406
  });
6407
+ return true;
6268
6408
  } catch (err) {
6269
6409
  this.deps.logger.debug(`event media: ${kind} replace failed`, {
6270
6410
  tags: { deviceId },
@@ -6274,6 +6414,7 @@ var EventMediaDispatcher = class {
6274
6414
  error: err instanceof Error ? err.message : String(err)
6275
6415
  }
6276
6416
  });
6417
+ return false;
6277
6418
  }
6278
6419
  }
6279
6420
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
@@ -7576,15 +7717,30 @@ var FaceSettingsSchema = object({
7576
7717
  */
7577
7718
  enabled: boolean().default(true),
7578
7719
  /** Cosine similarity (on L2-normalized arcface vectors) required to match. */
7579
- similarityThreshold: number().min(0).max(1).default(.45),
7720
+ similarityThreshold: number().min(0).max(1).default(.55),
7580
7721
  /** Reject ambiguous matches: require best − secondBest ≥ margin. */
7581
- margin: number().min(0).max(1).default(.05),
7722
+ margin: number().min(0).max(1).default(.1),
7582
7723
  /** Minimum face-detection confidence for a face to be considered. */
7583
7724
  minFaceConfidence: number().min(0).max(1).default(.5),
7725
+ /**
7726
+ * Minimum face bbox size (px, shorter side of the face box in detection-frame
7727
+ * space) for a face to be eligible for embedding-based auto-matching. Below
7728
+ * this, ArcFace resolution is unreliable and auto-assignment produces the
7729
+ * observed false positives (tiny/distant faces collapsing onto one identity).
7730
+ * Such faces are dropped BEFORE matching/enrolment (#26.1).
7731
+ */
7732
+ minFacePx: number().min(0).default(30),
7733
+ /**
7734
+ * Minimum enrolled-sample count an identity must have before it can be an
7735
+ * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
7736
+ * enrolment attracted 81% of matches); identities below this are excluded from
7737
+ * automatic matching until more samples are enrolled (#26.3).
7738
+ */
7739
+ minIdentitySamples: number().int().min(1).default(2),
7584
7740
  /** Frames an identity must be confirmed before a track is assigned. Floor of
7585
7741
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
7586
7742
  * such a value falls back to the default). */
7587
- confirmFrames: number().int().min(1).default(2),
7743
+ confirmFrames: number().int().min(1).default(3),
7588
7744
  /** Recent-faces buffer retention (days). */
7589
7745
  bufferRetentionDays: number().min(0).default(3),
7590
7746
  /** Max buffered faces kept per device. */
@@ -7601,6 +7757,8 @@ function resolveFaceSettings(raw) {
7601
7757
  similarityThreshold: pick("similarityThreshold"),
7602
7758
  margin: pick("margin"),
7603
7759
  minFaceConfidence: pick("minFaceConfidence"),
7760
+ minFacePx: pick("minFacePx"),
7761
+ minIdentitySamples: pick("minIdentitySamples"),
7604
7762
  confirmFrames: pick("confirmFrames"),
7605
7763
  bufferRetentionDays: pick("bufferRetentionDays"),
7606
7764
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
@@ -7936,10 +8094,12 @@ function evaluatePeriodicSnapshot(input) {
7936
8094
  */
7937
8095
  function planPeriodicMedia(input) {
7938
8096
  const appendSnapshot = input.dueSnapshot;
8097
+ const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
8098
+ const thumbnailLanded = input.thumbnailLanded ?? true;
7939
8099
  return {
7940
8100
  appendSnapshot,
7941
- rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
7942
- bestThumbnail: input.isNewBest
8101
+ rollingLastFrame,
8102
+ bestThumbnail: input.isNewBest || !thumbnailLanded
7943
8103
  };
7944
8104
  }
7945
8105
  //#endregion
@@ -8922,6 +9082,21 @@ var ObjectEmbeddingStore = class {
8922
9082
  //#endregion
8923
9083
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
8924
9084
  /**
9085
+ * Count enrolled samples per identity for probes of a matching model+dimension.
9086
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
9087
+ */
9088
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
9089
+ const counts = /* @__PURE__ */ new Map();
9090
+ for (const s of gallery) {
9091
+ if (s.modelId !== probeModelId) continue;
9092
+ if (s.embedding.length !== probeDim) continue;
9093
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
9094
+ }
9095
+ const eligible = /* @__PURE__ */ new Set();
9096
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
9097
+ return eligible;
9098
+ }
9099
+ /**
8925
9100
  * Assign at most one identity per track AND at most one track per identity for
8926
9101
  * a single frame. Greedy by score: compute every candidate's full ranked match
8927
9102
  * list, then repeatedly take the globally-highest (track, identity) pair whose
@@ -8932,10 +9107,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
8932
9107
  const pairs = [];
8933
9108
  candidates.forEach((c, trackIdx) => {
8934
9109
  const probeVec = new Float32Array(c.embedding);
9110
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
8935
9111
  const bestByIdentity = /* @__PURE__ */ new Map();
8936
9112
  for (const s of gallery) {
8937
9113
  if (s.modelId !== c.modelId) continue;
8938
9114
  if (s.embedding.length !== c.embedding.length) continue;
9115
+ if (!eligible.has(s.identityId)) continue;
8939
9116
  const score = cosineSimilarity(probeVec, new Float32Array(s.embedding));
8940
9117
  const prev = bestByIdentity.get(s.identityId);
8941
9118
  if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
@@ -9081,7 +9258,7 @@ var FaceRecognizer = class {
9081
9258
  }
9082
9259
  async processFrame(input) {
9083
9260
  const { settings } = input;
9084
- const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
9261
+ 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));
9085
9262
  if (candidates.length === 0) return;
9086
9263
  this.deps.logger.debug("face: frame candidates", {
9087
9264
  tags: { deviceId: input.deviceId },
@@ -9097,7 +9274,8 @@ var FaceRecognizer = class {
9097
9274
  modelId: c.embeddingModelId
9098
9275
  })), this.gallery, {
9099
9276
  threshold: settings.similarityThreshold,
9100
- margin: settings.margin
9277
+ margin: settings.margin,
9278
+ minIdentitySamples: settings.minIdentitySamples
9101
9279
  }) : /* @__PURE__ */ new Map();
9102
9280
  const labelWork = [];
9103
9281
  for (const c of candidates) {
@@ -11344,6 +11522,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11344
11522
  faceGlobalEnabledCache = null;
11345
11523
  mediaCacheByDevice = /* @__PURE__ */ new Map();
11346
11524
  packageDropCacheByDevice = /* @__PURE__ */ new Map();
11525
+ /** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
11526
+ * The registry is a single shared instance, so per-(device) suppress/wake IoU,
11527
+ * promotion window, TTL and the master toggle are resolved from this cache at
11528
+ * the partition + promotion + sweep call sites — an operator change takes
11529
+ * effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
11530
+ stationaryCacheByDevice = /* @__PURE__ */ new Map();
11347
11531
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
11348
11532
  packageDropDetector = null;
11349
11533
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
@@ -11376,6 +11560,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11376
11560
  * where no `snapshot` is appended, so it is never byte-identical to a stored
11377
11561
  * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
11378
11562
  lastFrameAtByTrack = /* @__PURE__ */ new Map();
11563
+ /** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
11564
+ * track absent here keeps forcing a best-thumbnail capture every frame until
11565
+ * one lands, so a short / high-churn track whose first capture was dropped
11566
+ * (recycled/blank live frame) still gets a subject crop for the gallery
11567
+ * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11568
+ thumbnailLandedTracks = /* @__PURE__ */ new Set();
11379
11569
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11380
11570
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11381
11571
  * area) + emit time, so a material improvement is measured against the
@@ -11447,6 +11637,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11447
11637
  this.stationaryRegistry = new StationaryObjectRegistry({
11448
11638
  store: api.settingsStore,
11449
11639
  logger: logger.child("StationaryRegistry"),
11640
+ ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
11450
11641
  onChange: ({ phase, entry, timestamp }) => {
11451
11642
  this.ctx.eventBus.emit({
11452
11643
  id: `pa-stationary-${entry.id}-${phase}`,
@@ -11822,6 +12013,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11822
12013
  this.faceCacheByDevice.delete(data.deviceId);
11823
12014
  this.mediaCacheByDevice.delete(data.deviceId);
11824
12015
  this.packageDropCacheByDevice.delete(data.deviceId);
12016
+ this.stationaryCacheByDevice.delete(data.deviceId);
11825
12017
  }
11826
12018
  });
11827
12019
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
@@ -11838,6 +12030,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11838
12030
  this.faceCacheByDevice.delete(deviceId);
11839
12031
  this.mediaCacheByDevice.delete(deviceId);
11840
12032
  this.packageDropCacheByDevice.delete(deviceId);
12033
+ this.stationaryCacheByDevice.delete(deviceId);
11841
12034
  this.bindingCache?.invalidate(deviceId);
11842
12035
  this.zoneAnalytics?.forgetDevice(deviceId);
11843
12036
  this.audioMetrics?.forgetDevice(deviceId);
@@ -12176,6 +12369,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12176
12369
  this.dropoutSkipsByKey.clear();
12177
12370
  this.bestFrameTracker.clear();
12178
12371
  this.lastFrameAtByTrack.clear();
12372
+ this.thumbnailLandedTracks.clear();
12179
12373
  this.trackLifecycleUpdateMem.clear();
12180
12374
  this.objectEmbeddingBestSelector.clear();
12181
12375
  this.levelStateByDevice.clear();
@@ -12186,6 +12380,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12186
12380
  this.faceGlobalEnabledCache = null;
12187
12381
  this.mediaCacheByDevice.clear();
12188
12382
  this.packageDropCacheByDevice.clear();
12383
+ this.stationaryCacheByDevice.clear();
12189
12384
  this.trackStore?.clearAll();
12190
12385
  this.stationaryRegistry = null;
12191
12386
  this.bindingCache?.clearAll();
@@ -12230,6 +12425,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12230
12425
  return;
12231
12426
  }
12232
12427
  this.dropoutSkipsByKey.set(key, 0);
12428
+ if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
12233
12429
  const processor = await this.getOrCreateProcessor(deviceId, source);
12234
12430
  const proxy = await this.ensureProxy(deviceId);
12235
12431
  const liveZones = proxy?.state.zones.value?.zones ?? [];
@@ -12369,10 +12565,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12369
12565
  } });
12370
12566
  }
12371
12567
  this.lastActiveTrackIds.set(key, currentTrackIds);
12372
- if (source === "pipeline" && this.stationaryRegistry) {
12568
+ const stationarySettings = this.stationarySettingsFromCache(deviceId);
12569
+ if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
12373
12570
  const dims = this.lastFrameDimsByDevice.get(deviceId);
12374
12571
  if (dims && dims.w > 0 && dims.h > 0) {
12375
12572
  const refDiag = Math.hypot(dims.w, dims.h);
12573
+ const promotionConfig = {
12574
+ ...DEFAULT_PROMOTION_CONFIG,
12575
+ windowMs: stationarySettings.promotionWindowMs
12576
+ };
12376
12577
  for (const t of result.tracked) {
12377
12578
  const active = this.trackStore.peekActive(t.trackId);
12378
12579
  if (!active) continue;
@@ -12380,7 +12581,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12380
12581
  positions: active.positions,
12381
12582
  referenceDiagonalPx: refDiag,
12382
12583
  now: result.timestamp,
12383
- config: DEFAULT_PROMOTION_CONFIG
12584
+ config: promotionConfig
12384
12585
  });
12385
12586
  if (!promote) continue;
12386
12587
  this.promoteToStationary({
@@ -12507,6 +12708,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12507
12708
  },
12508
12709
  mediaKey: s.mediaKey
12509
12710
  });
12711
+ for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
12510
12712
  }).catch(() => {});
12511
12713
  }
12512
12714
  }
@@ -12661,6 +12863,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12661
12863
  });
12662
12864
  return settings;
12663
12865
  }
12866
+ async resolveDeviceStationarySettings(deviceId) {
12867
+ const now = Date.now();
12868
+ const cached = this.stationaryCacheByDevice.get(deviceId);
12869
+ if (cached && now < cached.expiresAt) return cached.settings;
12870
+ const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
12871
+ this.stationaryCacheByDevice.set(deviceId, {
12872
+ settings,
12873
+ expiresAt: now + SETTINGS_CACHE_TTL_MS
12874
+ });
12875
+ return settings;
12876
+ }
12877
+ stationarySettingsFromCache(deviceId) {
12878
+ return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
12879
+ }
12664
12880
  async resolveDevicePackageDropSettings(deviceId) {
12665
12881
  const now = Date.now();
12666
12882
  const cached = this.packageDropCacheByDevice.get(deviceId);
@@ -12758,6 +12974,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12758
12974
  if (!this.faceRecognizer || detail.embedding === void 0) return;
12759
12975
  if (!await this.resolveGlobalFaceEnabled()) return;
12760
12976
  const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
12977
+ if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
12761
12978
  await this.faceRecognizer.ingestFaceDetail({
12762
12979
  deviceId,
12763
12980
  trackId,
@@ -13036,12 +13253,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13036
13253
  frameWidth,
13037
13254
  frameHeight
13038
13255
  });
13039
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
13256
+ const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
13257
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
13040
13258
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
13041
13259
  const plan = planPeriodicMedia({
13042
13260
  saveThumbnails: media.saveThumbnails,
13043
13261
  dueSnapshot,
13044
13262
  isNewBest,
13263
+ thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
13045
13264
  lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
13046
13265
  now: timestamp,
13047
13266
  intervalMs: media.snapshotIntervalMs
@@ -13362,6 +13581,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13362
13581
  this.bestFrameTracker.delete(t.trackId);
13363
13582
  this.objectEmbeddingBestSelector.delete(t.trackId);
13364
13583
  this.lastFrameAtByTrack.delete(t.trackId);
13584
+ this.thumbnailLandedTracks.delete(t.trackId);
13365
13585
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13366
13586
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13367
13587
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -13623,6 +13843,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13623
13843
  this.bestFrameTracker.delete(track.trackId);
13624
13844
  this.objectEmbeddingBestSelector.delete(track.trackId);
13625
13845
  this.lastFrameAtByTrack.delete(track.trackId);
13846
+ this.thumbnailLandedTracks.delete(track.trackId);
13626
13847
  this.trackLifecycleUpdateMem.delete(track.trackId);
13627
13848
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
13628
13849
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -13664,15 +13885,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13664
13885
  }, source);
13665
13886
  if (source === "pipeline" && this.stationaryRegistry) {
13666
13887
  const registry = this.stationaryRegistry;
13667
- p.setStationaryGate({ filter: (input) => registry.filter({
13668
- deviceId,
13669
- detections: input.detections.map((d) => ({
13670
- bbox: d.bbox,
13671
- className: d.class
13672
- })),
13673
- frameWidth: input.frameWidth,
13674
- frameHeight: input.frameHeight
13675
- }) });
13888
+ p.setStationaryGate({ filter: (input) => {
13889
+ const s = this.stationarySettingsFromCache(deviceId);
13890
+ if (!s.enabled) return {
13891
+ suppressedIndices: /* @__PURE__ */ new Set(),
13892
+ confirmed: [],
13893
+ wokenEntryIds: []
13894
+ };
13895
+ const matchConfig = {
13896
+ suppressIou: s.suppressIou,
13897
+ wakeAssocIou: s.wakeAssocIou
13898
+ };
13899
+ return registry.filter({
13900
+ deviceId,
13901
+ detections: input.detections.map((d) => ({
13902
+ bbox: d.bbox,
13903
+ className: d.class
13904
+ })),
13905
+ frameWidth: input.frameWidth,
13906
+ frameHeight: input.frameHeight
13907
+ }, matchConfig);
13908
+ } });
13676
13909
  }
13677
13910
  this.processors.set(key, p);
13678
13911
  }
@@ -14735,6 +14968,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14735
14968
  step: .05,
14736
14969
  default: FACE_DEFAULTS.minFaceConfidence
14737
14970
  },
14971
+ {
14972
+ type: "number",
14973
+ key: "minFacePx",
14974
+ label: "Min face size",
14975
+ 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.",
14976
+ min: 0,
14977
+ step: 1,
14978
+ default: FACE_DEFAULTS.minFacePx,
14979
+ unit: "px"
14980
+ },
14981
+ {
14982
+ type: "number",
14983
+ key: "minIdentitySamples",
14984
+ label: "Min identity samples",
14985
+ 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).",
14986
+ min: 1,
14987
+ step: 1,
14988
+ default: FACE_DEFAULTS.minIdentitySamples
14989
+ },
14738
14990
  {
14739
14991
  type: "number",
14740
14992
  key: "confirmFrames",
@@ -14945,6 +15197,70 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14945
15197
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
14946
15198
  }
14947
15199
  ]
15200
+ },
15201
+ {
15202
+ id: "stationary-objects",
15203
+ title: "Stationary objects",
15204
+ tab: "analytics",
15205
+ 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.",
15206
+ columns: 2,
15207
+ fields: [
15208
+ {
15209
+ type: "boolean",
15210
+ key: "enabled",
15211
+ label: "Suppress parked objects",
15212
+ 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.",
15213
+ default: STATIONARY_DEFAULTS.enabled
15214
+ },
15215
+ {
15216
+ type: "slider",
15217
+ key: "suppressIou",
15218
+ label: "Suppress IoU",
15219
+ 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.",
15220
+ min: .3,
15221
+ max: .9,
15222
+ step: .05,
15223
+ default: STATIONARY_DEFAULTS.suppressIou,
15224
+ showValue: true
15225
+ },
15226
+ {
15227
+ type: "slider",
15228
+ key: "wakeAssocIou",
15229
+ label: "Wake / associate IoU",
15230
+ 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.",
15231
+ min: .02,
15232
+ max: .5,
15233
+ step: .02,
15234
+ default: STATIONARY_DEFAULTS.wakeAssocIou,
15235
+ showValue: true
15236
+ },
15237
+ {
15238
+ type: "slider",
15239
+ key: "promotionWindowMs",
15240
+ label: "Promotion stillness window",
15241
+ 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.",
15242
+ min: 5e3,
15243
+ max: 12e4,
15244
+ step: 1e3,
15245
+ default: STATIONARY_DEFAULTS.promotionWindowMs,
15246
+ showValue: true,
15247
+ unit: "s",
15248
+ displayScale: 1e3
15249
+ },
15250
+ {
15251
+ type: "slider",
15252
+ key: "entryTtlMs",
15253
+ label: "Observed-time TTL",
15254
+ 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.",
15255
+ min: 6e4,
15256
+ max: 18e5,
15257
+ step: 3e4,
15258
+ default: STATIONARY_DEFAULTS.entryTtlMs,
15259
+ showValue: true,
15260
+ unit: "s",
15261
+ displayScale: 1e3
15262
+ }
15263
+ ]
14948
15264
  }
14949
15265
  ] });
14950
15266
  }