@camstack/addon-post-analysis 1.1.36 → 1.1.38

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-CSMfGdnz.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -1120,8 +1120,29 @@ var DEFAULT_TRACKER_CONFIG = {
1120
1120
  rescueIouThreshold: .1,
1121
1121
  rescueCentroidFactor: .75,
1122
1122
  resurrectionWindowMs: 8e3,
1123
- stationarySpeedPx: 2
1123
+ stationarySpeedPx: 2,
1124
+ dedupEnabled: true,
1125
+ dedupSpawnIou: .6,
1126
+ dedupMergeIou: .6,
1127
+ dedupMergeFrames: 5,
1128
+ personAnimalDedup: true,
1129
+ animalOverPersonMaxScore: .6,
1130
+ animalOverPersonIou: .2,
1131
+ classVotingEnabled: true,
1132
+ classVoteMinFraction: .5,
1133
+ perClassMinScoreEnabled: true,
1134
+ classMinScores: {
1135
+ animal: .45,
1136
+ vehicle: .45
1137
+ },
1138
+ confirmBypassEnabled: false,
1139
+ confirmBypassScore: .9
1124
1140
  };
1141
+ /** Macro-classes the duplicate resolver may collapse across (people in
1142
+ * non-upright poses misdetected as animals). Any other cross-class pair is
1143
+ * NEVER a duplicate. */
1144
+ var PERSON_CLASS = "person";
1145
+ var ANIMAL_CLASS = "animal";
1125
1146
  var MAX_PATH_LENGTH = 300;
1126
1147
  function clamp(value, min, max) {
1127
1148
  return Math.max(min, Math.min(max, value));
@@ -1144,16 +1165,102 @@ function containment(inner, outer) {
1144
1165
  const innerArea = inner.w * inner.h;
1145
1166
  return innerArea > 0 ? iw * ih / innerArea : 0;
1146
1167
  }
1147
- var SortTracker = class {
1168
+ var SortTracker = class SortTracker {
1148
1169
  config;
1149
1170
  tracks = [];
1150
1171
  lostTracks = [];
1172
+ /**
1173
+ * Consecutive-frame overlap streak per unordered live-track pair
1174
+ * (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
1175
+ * merged only once its streak reaches `dedupMergeFrames`. Entries reset the
1176
+ * moment the pair stops overlapping and are pruned when either track dies.
1177
+ */
1178
+ dupStreaks = /* @__PURE__ */ new Map();
1151
1179
  constructor(config = {}) {
1152
1180
  this.config = {
1153
1181
  ...DEFAULT_TRACKER_CONFIG,
1154
1182
  ...config
1155
1183
  };
1156
1184
  }
1185
+ /** Whether two macro-classes may be collapsed as the SAME subject. Same class
1186
+ * always; the {person,animal} pair only when `personAnimalDedup` is on. */
1187
+ dedupCompatible(a, b) {
1188
+ if (a === b) return true;
1189
+ if (!this.config.personAnimalDedup) return false;
1190
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
1191
+ }
1192
+ /** Unordered, stable key for a track pair (UUIDs never contain `|`). */
1193
+ static pairKey(a, b) {
1194
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
1195
+ }
1196
+ /**
1197
+ * True when spawning a fresh track for `det` would duplicate an already-live
1198
+ * track: (a) it overlaps a concurrent compatible-class track by
1199
+ * `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
1200
+ * concurrent `person` track (person-in-odd-pose false animal). `live` is the
1201
+ * set of tracks already surviving this frame (matched, coasting, resurrected,
1202
+ * and earlier same-frame spawns).
1203
+ */
1204
+ isDuplicateSpawn(det, live) {
1205
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
1206
+ if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
1207
+ for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
1208
+ }
1209
+ return false;
1210
+ }
1211
+ /** The lower-importance track of a duplicate pair — the one to drop. A
1212
+ * `person` always beats a cross-class `animal`; otherwise more hits wins,
1213
+ * ties break to the older track, then the higher score. */
1214
+ static duplicateLoser(a, b) {
1215
+ if (a.class !== b.class) {
1216
+ if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
1217
+ if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
1218
+ }
1219
+ if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
1220
+ if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
1221
+ return a.score >= b.score ? b : a;
1222
+ }
1223
+ /**
1224
+ * Merge concurrent duplicate tracks. Two live compatible-class tracks that
1225
+ * stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
1226
+ * frames are collapsed: the lower-importance one is dropped (NOT graveyarded
1227
+ * — a confirmed duplicate must not resurrect). This is the fix for a subject
1228
+ * the detector double-fires (two boxes each frame, each feeding its OWN
1229
+ * track, so neither is ever "unmatched" for spawn-suppression to catch) and
1230
+ * for a person misdetected as `animal` alongside the real person track.
1231
+ */
1232
+ resolveDuplicates() {
1233
+ if (!this.config.dedupEnabled) return;
1234
+ const live = this.tracks;
1235
+ const liveIds = new Set(live.map((t) => t.id));
1236
+ for (const key of this.dupStreaks.keys()) {
1237
+ const [a, b] = key.split("|");
1238
+ if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
1239
+ }
1240
+ const dropIds = /* @__PURE__ */ new Set();
1241
+ for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
1242
+ const a = live[i];
1243
+ const b = live[j];
1244
+ if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
1245
+ if (!this.dedupCompatible(a.class, b.class)) continue;
1246
+ const key = SortTracker.pairKey(a.id, b.id);
1247
+ if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
1248
+ this.dupStreaks.delete(key);
1249
+ continue;
1250
+ }
1251
+ const streak = (this.dupStreaks.get(key) ?? 0) + 1;
1252
+ if (streak >= this.config.dedupMergeFrames) {
1253
+ dropIds.add(SortTracker.duplicateLoser(a, b).id);
1254
+ this.dupStreaks.delete(key);
1255
+ } else this.dupStreaks.set(key, streak);
1256
+ }
1257
+ if (dropIds.size === 0) return;
1258
+ this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
1259
+ for (const key of this.dupStreaks.keys()) {
1260
+ const [a, b] = key.split("|");
1261
+ if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
1262
+ }
1263
+ }
1157
1264
  /** Where a track is expected this frame — extrapolated by velocity while
1158
1265
  * coasting (predictiveCoasting), else its last known bbox. A stationary
1159
1266
  * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
@@ -1188,6 +1295,46 @@ var SortTracker = class {
1188
1295
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
1189
1296
  return dist <= this.config.rescueCentroidFactor * diag;
1190
1297
  }
1298
+ /** Fold a matched detection's class + score into a track's lifetime vote
1299
+ * tally. A non-positive score still counts as an infinitesimal vote so a
1300
+ * zero-confidence frame contributes to the frame-count tiebreak. */
1301
+ addClassVote(track, det) {
1302
+ const weight = det.score > 0 ? det.score : Number.EPSILON;
1303
+ track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
1304
+ }
1305
+ /**
1306
+ * The class to REPORT for a track: the confidence-weighted lifetime majority
1307
+ * when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
1308
+ * weight; otherwise the latest-frame class (ambiguous vote or voting off).
1309
+ */
1310
+ resolveReportedClass(t) {
1311
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
1312
+ let bestClass = t.class;
1313
+ let bestVote = -1;
1314
+ let total = 0;
1315
+ for (const [cls, v] of t.classVotes) {
1316
+ total += v;
1317
+ if (v > bestVote) {
1318
+ bestVote = v;
1319
+ bestClass = cls;
1320
+ }
1321
+ }
1322
+ if (total <= 0) return t.class;
1323
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
1324
+ }
1325
+ /** Whether a detection clears its per-class spawn score floor. Only gates NEW
1326
+ * spawns — an established track still matches below its floor. */
1327
+ meetsClassScoreFloor(det) {
1328
+ if (!this.config.perClassMinScoreEnabled) return true;
1329
+ const floor = this.config.classMinScores[det.class] ?? 0;
1330
+ return det.score >= floor;
1331
+ }
1332
+ /** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
1333
+ * a single very-high-confidence detection bypassed the hit gate (fast car). */
1334
+ isConfirmedForEmit(t) {
1335
+ if (t.hits >= this.config.minHits) return true;
1336
+ return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1337
+ }
1191
1338
  update(detections, timestamp) {
1192
1339
  if (this.config.maxTrackLifetimeMs > 0) {
1193
1340
  const alive = [];
@@ -1272,6 +1419,7 @@ var SortTracker = class {
1272
1419
  };
1273
1420
  track.path.push(det.bbox);
1274
1421
  if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
1422
+ this.addClassVote(track, det);
1275
1423
  }
1276
1424
  const occluderBoxes = [];
1277
1425
  for (const track of matchedTracks) occluderBoxes.push(track.bbox);
@@ -1321,13 +1469,30 @@ var SortTracker = class {
1321
1469
  };
1322
1470
  best.path.push(det.bbox);
1323
1471
  if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
1472
+ this.addClassVote(best, det);
1324
1473
  surviving.push(best);
1325
1474
  used.add(di);
1326
1475
  }
1327
- for (let di = 0; di < detections.length; di++) {
1328
- if (used.has(di)) continue;
1476
+ const unmatchedIdx = [];
1477
+ for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
1478
+ const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
1479
+ unmatchedIdx.sort((ia, ib) => {
1480
+ const da = detections[ia];
1481
+ const db = detections[ib];
1482
+ const r = classRank(da.class) - classRank(db.class);
1483
+ return r !== 0 ? r : db.score - da.score;
1484
+ });
1485
+ for (const di of unmatchedIdx) {
1329
1486
  const det = detections[di];
1487
+ if (!this.meetsClassScoreFloor(det)) {
1488
+ used.add(di);
1489
+ continue;
1490
+ }
1330
1491
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1492
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1493
+ used.add(di);
1494
+ continue;
1495
+ }
1331
1496
  surviving.push({
1332
1497
  id: randomUUID(),
1333
1498
  bbox: det.bbox,
@@ -1345,12 +1510,14 @@ var SortTracker = class {
1345
1510
  },
1346
1511
  lost: false,
1347
1512
  lostAt: 0,
1348
- resurrectable: true
1513
+ resurrectable: true,
1514
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
1349
1515
  });
1350
1516
  }
1351
1517
  this.tracks = surviving;
1352
- return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
1353
- class: t.class,
1518
+ this.resolveDuplicates();
1519
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
1520
+ class: this.resolveReportedClass(t),
1354
1521
  originalClass: t.originalClass,
1355
1522
  score: t.score,
1356
1523
  bbox: t.bbox,
@@ -1379,6 +1546,7 @@ var SortTracker = class {
1379
1546
  reset() {
1380
1547
  this.tracks = [];
1381
1548
  this.lostTracks = [];
1549
+ this.dupStreaks.clear();
1382
1550
  }
1383
1551
  };
1384
1552
  //#endregion
@@ -2132,6 +2300,29 @@ function isEdgeClear(input) {
2132
2300
  return true;
2133
2301
  }
2134
2302
  /**
2303
+ * Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
2304
+ * centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
2305
+ * — a cheap proxy for "is the subject well-framed?" used as the best-frame
2306
+ * tie-breaker so a low-importance short track stops locking in an edge-of-frame
2307
+ * subject when a better-centred, near-equal-confidence frame is available.
2308
+ *
2309
+ * The score is `1 - normalizedDistance(centre → frameCentre)`, where the
2310
+ * distance is normalised by the max possible (centre → corner) so it is
2311
+ * scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
2312
+ * falls back to pure confidence, matching {@link isEdgeClear}).
2313
+ */
2314
+ function bboxCenterScore(bbox, frameWidth, frameHeight) {
2315
+ if (frameWidth <= 0 || frameHeight <= 0) return 1;
2316
+ const cx = bbox.x + bbox.w / 2;
2317
+ const cy = bbox.y + bbox.h / 2;
2318
+ const fcx = frameWidth / 2;
2319
+ const fcy = frameHeight / 2;
2320
+ const dx = (cx - fcx) / fcx;
2321
+ const dy = (cy - fcy) / fcy;
2322
+ const dist = Math.hypot(dx, dy) / Math.SQRT2;
2323
+ return Math.max(0, Math.min(1, 1 - dist));
2324
+ }
2325
+ /**
2135
2326
  * Edge-aware "is `candidate` a new best over `current`?" comparator.
2136
2327
  *
2137
2328
  * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
@@ -2139,13 +2330,25 @@ function isEdgeClear(input) {
2139
2330
  * confidence past the `hysteresis` margin wins. The tier upgrade
2140
2331
  * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2141
2332
  *
2333
+ * CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
2334
+ * wins on confidence (the two are within the `hysteresis` band) but the
2335
+ * candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
2336
+ * candidate wins. This only engages when both sides carry a `centerScore` (the
2337
+ * best-frame path), so low-importance short tracks stop keeping an edge-of-frame
2338
+ * subject over an equally-confident, better-framed one. The face /
2339
+ * object-embedding callers omit `centerScore` → identical legacy behaviour.
2340
+ *
2142
2341
  * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2143
2342
  * not here, so this stays a pure value comparison.
2144
2343
  */
2145
2344
  function isEdgeAwareNewBest(current, candidate, hysteresis) {
2146
2345
  if (candidate.edgeClear && !current.edgeClear) return true;
2147
2346
  if (!candidate.edgeClear && current.edgeClear) return false;
2148
- return candidate.confidence > current.confidence + hysteresis;
2347
+ if (candidate.confidence > current.confidence + hysteresis) return true;
2348
+ if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
2349
+ if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
2350
+ }
2351
+ return false;
2149
2352
  }
2150
2353
  //#endregion
2151
2354
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
@@ -2181,6 +2384,10 @@ var BestDetectionTracker = class {
2181
2384
  * the edge tier is not in play for the track (treated as clear → the legacy
2182
2385
  * pure-confidence policy). */
2183
2386
  edgeClear = /* @__PURE__ */ new Map();
2387
+ /** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
2388
+ * does not supply centering (face / object-embedding paths) → the centering
2389
+ * tie-break is disabled and the legacy confidence policy applies. */
2390
+ centerScore = /* @__PURE__ */ new Map();
2184
2391
  constructor(options = {}) {
2185
2392
  this.hysteresis = options.hysteresis ?? 0;
2186
2393
  this.minGapMs = options.minGapMs ?? 0;
@@ -2197,7 +2404,7 @@ var BestDetectionTracker = class {
2197
2404
  * the classic policy holds: a confidence past the `hysteresis` margin that also
2198
2405
  * respects `minGapMs` wins. On acceptance the held peak advances.
2199
2406
  */
2200
- observe(trackId, confidence, timestamp, edgeClear) {
2407
+ observe(trackId, confidence, timestamp, edgeClear, centerScore) {
2201
2408
  const cur = this.best.get(trackId);
2202
2409
  if (cur === void 0) {
2203
2410
  this.best.set(trackId, {
@@ -2205,16 +2412,20 @@ var BestDetectionTracker = class {
2205
2412
  atMs: timestamp
2206
2413
  });
2207
2414
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2415
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2208
2416
  return true;
2209
2417
  }
2210
2418
  const curClear = this.edgeClear.get(trackId) ?? true;
2211
2419
  const candClear = edgeClear ?? true;
2420
+ const curCenter = this.centerScore.get(trackId);
2212
2421
  const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2213
2422
  confidence: cur.confidence,
2214
- edgeClear: curClear
2423
+ edgeClear: curClear,
2424
+ ...curCenter !== void 0 ? { centerScore: curCenter } : {}
2215
2425
  }, {
2216
2426
  confidence,
2217
- edgeClear: candClear
2427
+ edgeClear: candClear,
2428
+ ...centerScore !== void 0 ? { centerScore } : {}
2218
2429
  }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2219
2430
  if (isNewBest) {
2220
2431
  this.best.set(trackId, {
@@ -2222,6 +2433,7 @@ var BestDetectionTracker = class {
2222
2433
  atMs: timestamp
2223
2434
  });
2224
2435
  if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2436
+ if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
2225
2437
  }
2226
2438
  return isNewBest;
2227
2439
  }
@@ -2233,10 +2445,12 @@ var BestDetectionTracker = class {
2233
2445
  delete(trackId) {
2234
2446
  this.best.delete(trackId);
2235
2447
  this.edgeClear.delete(trackId);
2448
+ this.centerScore.delete(trackId);
2236
2449
  }
2237
2450
  clear() {
2238
2451
  this.best.clear();
2239
2452
  this.edgeClear.clear();
2453
+ this.centerScore.clear();
2240
2454
  }
2241
2455
  };
2242
2456
  //#endregion
@@ -2389,6 +2603,9 @@ var WAKE_ASSOC_IOU = .1;
2389
2603
  * long) so a freshly-spawned static blob isn't promoted instantly.
2390
2604
  */
2391
2605
  var PROMOTION_WINDOW_MS = 3e4;
2606
+ /** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
2607
+ * long (object removed while unobserved, or a long occlusion) → retire it. */
2608
+ var ENTRY_TTL_MS = 5 * 6e4;
2392
2609
  var DEFAULT_MATCH_CONFIG = {
2393
2610
  suppressIou: SUPPRESS_IOU,
2394
2611
  wakeAssocIou: WAKE_ASSOC_IOU
@@ -2596,6 +2813,7 @@ var StationaryObjectRegistry = class {
2596
2813
  logger;
2597
2814
  matchConfig;
2598
2815
  entryTtlMs;
2816
+ ttlForDevice;
2599
2817
  onChange;
2600
2818
  /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2601
2819
  * time, not wall-clock. A session-dispatch camera produces no frames
@@ -2607,6 +2825,7 @@ var StationaryObjectRegistry = class {
2607
2825
  this.logger = deps.logger;
2608
2826
  this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2609
2827
  this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2828
+ this.ttlForDevice = deps.ttlForDevice;
2610
2829
  this.onChange = deps.onChange;
2611
2830
  }
2612
2831
  static async declare(store) {
@@ -2662,7 +2881,7 @@ var StationaryObjectRegistry = class {
2662
2881
  * entries. PURE with respect to registry state — apply the outcome with
2663
2882
  * {@link applyFrameOutcome} once the frame result is assembled.
2664
2883
  */
2665
- filter(input) {
2884
+ filter(input, config) {
2666
2885
  const entries = this.list(input.deviceId);
2667
2886
  if (entries.length === 0) return {
2668
2887
  suppressedIndices: /* @__PURE__ */ new Set(),
@@ -2672,7 +2891,7 @@ var StationaryObjectRegistry = class {
2672
2891
  return partitionDetectionsAgainstRegistry({
2673
2892
  entries,
2674
2893
  detections: input.detections,
2675
- config: this.matchConfig
2894
+ config: config ?? this.matchConfig
2676
2895
  });
2677
2896
  }
2678
2897
  /** Fold a frame's gate result back into state: advance confirmed entries'
@@ -2741,7 +2960,8 @@ var StationaryObjectRegistry = class {
2741
2960
  for (const [deviceId, m] of this.byDevice) {
2742
2961
  const observedAt = this.lastFrameAtByDevice.get(deviceId);
2743
2962
  if (observedAt === void 0) continue;
2744
- for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2963
+ const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
2964
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
2745
2965
  m.delete(id);
2746
2966
  this.dirty.delete(id);
2747
2967
  retired.push(e);
@@ -2890,6 +3110,55 @@ function computeStationaryEntryZones(entry, zones) {
2890
3110
  return matched;
2891
3111
  }
2892
3112
  //#endregion
3113
+ //#region src/pipeline-analytics/stationary-settings.ts
3114
+ /**
3115
+ * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
3116
+ * per-device override on top of the global default, resolved per field (an
3117
+ * invalid/missing value falls back to its default — parse never throws).
3118
+ * Mirrors `media-settings` / `tracking-settings`.
3119
+ *
3120
+ * The defaults are the SAME constants the registry uses at runtime
3121
+ * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
3122
+ * value both resolve to exactly today's behaviour — "reset == unset == today",
3123
+ * with no drift. Guarded by a unit test asserting the equality.
3124
+ */
3125
+ var StationarySettingsSchema = object({
3126
+ /** Master switch. Off ⇒ no promotion + no suppression for this camera (every
3127
+ * parked object keeps spawning normal tracks). */
3128
+ enabled: boolean().default(true),
3129
+ /** IoU at/above which a detection is the same parked object, unmoved →
3130
+ * suppress its spawn. `SUPPRESS_IOU`. */
3131
+ suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
3132
+ /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
3133
+ * wake it) — the overlap gate that stops a different vehicle from waking a
3134
+ * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
3135
+ wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
3136
+ /** Recent-window stillness a track must hold to be PROMOTED to a parked
3137
+ * entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
3138
+ promotionWindowMs: number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
3139
+ /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
3140
+ * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
3141
+ entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
3142
+ });
3143
+ var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
3144
+ /**
3145
+ * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
3146
+ * fields fall back to the default for that field (never throws on a bad blob).
3147
+ */
3148
+ function resolveStationarySettings(raw) {
3149
+ const pick = (key) => {
3150
+ const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
3151
+ return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
3152
+ };
3153
+ return {
3154
+ enabled: pick("enabled"),
3155
+ suppressIou: pick("suppressIou"),
3156
+ wakeAssocIou: pick("wakeAssocIou"),
3157
+ promotionWindowMs: pick("promotionWindowMs"),
3158
+ entryTtlMs: pick("entryTtlMs")
3159
+ };
3160
+ }
3161
+ //#endregion
2893
3162
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2894
3163
  /**
2895
3164
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -5823,6 +6092,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
5823
6092
  }
5824
6093
  return inserted;
5825
6094
  }
6095
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
6096
+ var FULL_FRAME_QUALITY = 80;
6097
+ /**
6098
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
6099
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
6100
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
6101
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
6102
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
6103
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
6104
+ */
6105
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
6106
+ return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
6107
+ fit: "inside",
6108
+ withoutEnlargement: true
6109
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
6110
+ }
5826
6111
  //#endregion
5827
6112
  //#region src/pipeline-analytics/services/synthetic-sensor-track.ts
5828
6113
  /**
@@ -5887,7 +6172,13 @@ var SyntheticSensorTrackMaterializer = class {
5887
6172
  force: true
5888
6173
  });
5889
6174
  if (snap !== null) {
5890
- const data = Buffer.from(snap.base64, "base64");
6175
+ const raw = Buffer.from(snap.base64, "base64");
6176
+ let data = raw;
6177
+ try {
6178
+ data = await downscaleFullFrameJpeg(raw);
6179
+ } catch (err) {
6180
+ this.deps.onError?.("downscaleSnapshot", err);
6181
+ }
5891
6182
  mediaKey = await this.deps.media.put({
5892
6183
  deviceId: input.cameraId,
5893
6184
  ownerKind: "track",
@@ -5973,6 +6264,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
5973
6264
  h: Math.round(ch)
5974
6265
  };
5975
6266
  }
6267
+ /**
6268
+ * The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
6269
+ * NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
6270
+ *
6271
+ * A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
6272
+ * aspect ratio (the native crop path downscales while preserving aspect), so the
6273
+ * region computed from the detection frame's dimensions addresses the exact same
6274
+ * ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
6275
+ * (single source of truth) and divides by the frame dimensions.
6276
+ */
6277
+ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6278
+ const region = squareSafeCropRegion(bbox, frame, padding);
6279
+ return {
6280
+ x: region.x / frame.W,
6281
+ y: region.y / frame.H,
6282
+ w: region.w / frame.W,
6283
+ h: region.h / frame.H
6284
+ };
6285
+ }
5976
6286
  //#endregion
5977
6287
  //#region src/shared/frame/box-drawer.ts
5978
6288
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
@@ -6050,10 +6360,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
6050
6360
  * Small downscaled `thumbnail`s are intentionally left out for now — when we
6051
6361
  * reintroduce them they'll be a separate small kind. */
6052
6362
  var MEDIA_QUALITY = 88;
6053
- /** Output dimensions for square-safe 16:9 crops (crop/faceCrop/plateCrop). */
6363
+ /** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
6364
+ * (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
6054
6365
  var CROP_WIDTH = 640;
6055
6366
  var CROP_HEIGHT = 360;
6056
6367
  var CROP_QUALITY = 80;
6368
+ /**
6369
+ * Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
6370
+ * `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
6371
+ * ≥224px classifier input straight from the runner's native surface, WITHOUT
6372
+ * hauling a full 1920px frame per subject (that width is reserved for the
6373
+ * full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
6374
+ * the ≤640 local crop, so quality never regresses below today's behaviour.
6375
+ */
6376
+ var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
6057
6377
  function caption(className, confidence, label) {
6058
6378
  const base = label && label !== className ? `${className} ${label}` : className;
6059
6379
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
@@ -6106,7 +6426,10 @@ var EventMediaDispatcher = class {
6106
6426
  async captureForFrame(input) {
6107
6427
  const { deviceId, frameHandle, events, trackFrames } = input;
6108
6428
  const snapshots = input.snapshots ?? [];
6109
- const empty = { storedSnapshots: [] };
6429
+ const empty = {
6430
+ storedSnapshots: [],
6431
+ thumbnailTrackIds: []
6432
+ };
6110
6433
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
6111
6434
  let decoded;
6112
6435
  try {
@@ -6157,14 +6480,19 @@ var EventMediaDispatcher = class {
6157
6480
  });
6158
6481
  return empty;
6159
6482
  }
6160
- for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6483
+ for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
6161
6484
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6162
6485
  const storedSnapshots = [];
6486
+ const thumbnailTrackIds = [];
6163
6487
  for (const sn of snapshots) {
6164
- const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6165
- if (stored) storedSnapshots.push(stored);
6488
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6489
+ if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6490
+ if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6166
6491
  }
6167
- return { storedSnapshots };
6492
+ return {
6493
+ storedSnapshots,
6494
+ thumbnailTrackIds
6495
+ };
6168
6496
  }
6169
6497
  /**
6170
6498
  * Periodic per-track media (§5). The boxed FULL frame is encoded once and
@@ -6175,10 +6503,14 @@ var EventMediaDispatcher = class {
6175
6503
  * object event, and a full frame there shows the scene (e.g. a foreground
6176
6504
  * parked car), not the track's subject. Returns the appended snapshot for
6177
6505
  * TrackStore wiring (null when `appendSnapshot` is false or the encode
6178
- * failed).
6506
+ * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6507
+ * landed this frame (#27-A) so the caller can stop forcing retries.
6179
6508
  */
6180
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6181
- if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
6509
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6510
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6511
+ storedSnapshot: null,
6512
+ thumbnailWritten: false
6513
+ };
6182
6514
  let boxed = null;
6183
6515
  if (sn.appendSnapshot || sn.rollingLastFrame) try {
6184
6516
  boxed = await drawBoxedFrame(frameData, fw, fh, [{
@@ -6213,9 +6545,10 @@ var EventMediaDispatcher = class {
6213
6545
  };
6214
6546
  } catch {}
6215
6547
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6548
+ let thumbnailWritten = false;
6216
6549
  if (sn.bestThumbnail) try {
6217
- const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6218
- await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6550
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
6551
+ thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6219
6552
  } catch (err) {
6220
6553
  this.deps.logger.warn("event media: track thumbnail crop failed", {
6221
6554
  tags: { deviceId },
@@ -6225,17 +6558,46 @@ var EventMediaDispatcher = class {
6225
6558
  error: err instanceof Error ? err.message : String(err)
6226
6559
  }
6227
6560
  });
6228
- if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6561
+ if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
6229
6562
  }
6230
- return stored;
6563
+ return {
6564
+ storedSnapshot: stored,
6565
+ thumbnailWritten
6566
+ };
6231
6567
  }
6232
6568
  /**
6233
- * Clean subject-centered crop of `bbox` out of the raw frame the shared
6234
- * output contract of the object-event `crop` kind and the track `thumbnail`:
6235
- * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
6236
- * (no box drawn), resized to 640×360, JPEG q80.
6569
+ * Clean subject-centered crop of `bbox` the shared output contract of the
6570
+ * object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
6571
+ * `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
6572
+ *
6573
+ * NATIVE-FIRST: the region is requested from the runner's retained native
6574
+ * surface (normalized [0,1] coords map directly onto it), downscaled to
6575
+ * {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
6576
+ * miss/error (or a runner without the method) it FALLS BACK to cropping the
6577
+ * resolved ≤640 frame locally (today's behaviour), so quality never regresses.
6578
+ * Both paths run inside the live-handle window opened by `captureForFrame`.
6237
6579
  */
6238
- async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
6580
+ async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
6581
+ if (this.deps.getNativeCropJpeg) try {
6582
+ const norm = squareSafeCropRegionNormalized(bbox, {
6583
+ W: fw,
6584
+ H: fh
6585
+ }, cropPadding);
6586
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
6587
+ if (native) return native;
6588
+ } catch (err) {
6589
+ this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
6590
+ shmId: frameHandle.shmId,
6591
+ error: err instanceof Error ? err.message : String(err)
6592
+ } });
6593
+ }
6594
+ return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
6595
+ }
6596
+ /**
6597
+ * Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
6598
+ * to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
6599
+ */
6600
+ async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
6239
6601
  const region = squareSafeCropRegion(bbox, {
6240
6602
  W: fw,
6241
6603
  H: fh
@@ -6265,6 +6627,7 @@ var EventMediaDispatcher = class {
6265
6627
  timestamp,
6266
6628
  data
6267
6629
  });
6630
+ return true;
6268
6631
  } catch (err) {
6269
6632
  this.deps.logger.debug(`event media: ${kind} replace failed`, {
6270
6633
  tags: { deviceId },
@@ -6274,15 +6637,16 @@ var EventMediaDispatcher = class {
6274
6637
  error: err instanceof Error ? err.message : String(err)
6275
6638
  }
6276
6639
  });
6640
+ return false;
6277
6641
  }
6278
6642
  }
6279
- async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
6643
+ async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
6280
6644
  const box = {
6281
6645
  ...ev.bbox,
6282
6646
  label: caption(ev.className, ev.confidence, ev.label)
6283
6647
  };
6284
6648
  try {
6285
- const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
6649
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
6286
6650
  await this.deps.mediaStore.put({
6287
6651
  deviceId,
6288
6652
  ownerKind: "event",
@@ -6342,24 +6706,7 @@ var EventMediaDispatcher = class {
6342
6706
  });
6343
6707
  }
6344
6708
  if (ev.childCrops) for (const child of ev.childCrops) try {
6345
- const childRegion = squareSafeCropRegion(child.bbox, {
6346
- W: fw,
6347
- H: fh
6348
- }, cropPadding);
6349
- const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
6350
- const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
6351
- const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
6352
- const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
6353
- const childCropData = await sharp(frameData, { raw: {
6354
- width: fw,
6355
- height: fh,
6356
- channels: 3
6357
- } }).extract({
6358
- left: childLeft,
6359
- top: childTop,
6360
- width: childWidth,
6361
- height: childHeight
6362
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
6709
+ const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
6363
6710
  await this.deps.mediaStore.put({
6364
6711
  deviceId,
6365
6712
  ownerKind: "event",
@@ -7520,6 +7867,56 @@ var TrackingSettingsSchema = object({
7520
7867
  /** Speed (px/frame) below which a track's prediction is frozen (stationary
7521
7868
  * jitter can't drift the box off a sitting object). */
7522
7869
  stationarySpeedPx: number().min(0).default(2),
7870
+ /** Suppress + merge concurrent duplicate tracks (one subject the detector
7871
+ * double-fires, or a person misdetected as `animal`, otherwise becomes two
7872
+ * time-overlapping tracks that never re-associate — each firing its own
7873
+ * event). Off = legacy behaviour. */
7874
+ dedupEnabled: boolean().default(true),
7875
+ /** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
7876
+ * concurrent compatible-class track and suppressed. High so distinct
7877
+ * subjects appearing close together are not collapsed. */
7878
+ dedupSpawnIou: number().min(0).max(1).default(.6),
7879
+ /** Envelope IoU at/above which two concurrent tracks count as overlapping for
7880
+ * the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
7881
+ * merged duplicate cannot re-spawn. */
7882
+ dedupMergeIou: number().min(0).max(1).default(.6),
7883
+ /** Consecutive overlapping frames before two live tracks are merged (the
7884
+ * lower-importance one dropped). Higher = more conservative. */
7885
+ dedupMergeFrames: number().int().min(1).default(5),
7886
+ /** Treat the {person,animal} class pair as duplicate-compatible — a person in
7887
+ * a non-upright pose is misdetected as `animal`; the false animal track
7888
+ * collapses into the real person track. */
7889
+ personAnimalDedup: boolean().default(true),
7890
+ /** A low-confidence `animal` (score below this) overlapping a concurrent
7891
+ * person track is suppressed at spawn. */
7892
+ animalOverPersonMaxScore: number().min(0).max(1).default(.6),
7893
+ /** IoU with a concurrent person track that triggers the low-confidence animal
7894
+ * spawn suppression. */
7895
+ animalOverPersonIou: number().min(0).max(1).default(.2),
7896
+ /** Resolve a track's reported class by a confidence-weighted majority over its
7897
+ * lifetime (vs. the latest frame) — kills per-frame class flips (a person
7898
+ * read as `animal` on one crouch frame keeps the `person` label). */
7899
+ classVotingEnabled: boolean().default(true),
7900
+ /** Winning class must hold at least this fraction of a track's total vote
7901
+ * weight to override the latest-frame class; below it the latest wins (so a
7902
+ * genuine mid-life reclassification is never frozen out). */
7903
+ classVoteMinFraction: number().min(0).max(1).default(.5),
7904
+ /** Enforce a per-class minimum detection score at track SPAWN (an established
7905
+ * track still matches below its floor — only new spawns are gated). */
7906
+ perClassMinScoreEnabled: boolean().default(true),
7907
+ /** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
7908
+ minScorePerson: number().min(0).max(1).default(0),
7909
+ /** Minimum spawn score for `animal` (FP-prone — higher floor). */
7910
+ minScoreAnimal: number().min(0).max(1).default(.45),
7911
+ /** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
7912
+ minScoreVehicle: number().min(0).max(1).default(.45),
7913
+ /** Let a single very-high-confidence detection confirm a track for emission
7914
+ * before it reaches `minHits` (so a fast car crossing in 1-2 frames still
7915
+ * registers). Opt-in — default OFF keeps the strict N-hit gate. */
7916
+ confirmBypassEnabled: boolean().default(false),
7917
+ /** Score at/above which a detection confirms its track immediately (bypasses
7918
+ * `minHits`). Only consulted when `confirmBypassEnabled`. */
7919
+ confirmBypassScore: number().min(0).max(1).default(.9),
7523
7920
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
7524
7921
  dropoutSkipEnabled: boolean().default(true),
7525
7922
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
@@ -7554,6 +7951,21 @@ function resolveTrackingSettings(raw) {
7554
7951
  rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
7555
7952
  resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
7556
7953
  stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
7954
+ dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
7955
+ dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
7956
+ dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
7957
+ dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
7958
+ personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
7959
+ animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
7960
+ animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
7961
+ classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
7962
+ classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
7963
+ perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
7964
+ minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
7965
+ minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
7966
+ minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
7967
+ confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
7968
+ confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7557
7969
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7558
7970
  dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
7559
7971
  };
@@ -7576,15 +7988,30 @@ var FaceSettingsSchema = object({
7576
7988
  */
7577
7989
  enabled: boolean().default(true),
7578
7990
  /** Cosine similarity (on L2-normalized arcface vectors) required to match. */
7579
- similarityThreshold: number().min(0).max(1).default(.45),
7991
+ similarityThreshold: number().min(0).max(1).default(.55),
7580
7992
  /** Reject ambiguous matches: require best − secondBest ≥ margin. */
7581
- margin: number().min(0).max(1).default(.05),
7993
+ margin: number().min(0).max(1).default(.1),
7582
7994
  /** Minimum face-detection confidence for a face to be considered. */
7583
7995
  minFaceConfidence: number().min(0).max(1).default(.5),
7996
+ /**
7997
+ * Minimum face bbox size (px, shorter side of the face box in detection-frame
7998
+ * space) for a face to be eligible for embedding-based auto-matching. Below
7999
+ * this, ArcFace resolution is unreliable and auto-assignment produces the
8000
+ * observed false positives (tiny/distant faces collapsing onto one identity).
8001
+ * Such faces are dropped BEFORE matching/enrolment (#26.1).
8002
+ */
8003
+ minFacePx: number().min(0).default(30),
8004
+ /**
8005
+ * Minimum enrolled-sample count an identity must have before it can be an
8006
+ * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
8007
+ * enrolment attracted 81% of matches); identities below this are excluded from
8008
+ * automatic matching until more samples are enrolled (#26.3).
8009
+ */
8010
+ minIdentitySamples: number().int().min(1).default(2),
7584
8011
  /** Frames an identity must be confirmed before a track is assigned. Floor of
7585
8012
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
7586
8013
  * such a value falls back to the default). */
7587
- confirmFrames: number().int().min(1).default(2),
8014
+ confirmFrames: number().int().min(1).default(3),
7588
8015
  /** Recent-faces buffer retention (days). */
7589
8016
  bufferRetentionDays: number().min(0).default(3),
7590
8017
  /** Max buffered faces kept per device. */
@@ -7601,6 +8028,8 @@ function resolveFaceSettings(raw) {
7601
8028
  similarityThreshold: pick("similarityThreshold"),
7602
8029
  margin: pick("margin"),
7603
8030
  minFaceConfidence: pick("minFaceConfidence"),
8031
+ minFacePx: pick("minFacePx"),
8032
+ minIdentitySamples: pick("minIdentitySamples"),
7604
8033
  confirmFrames: pick("confirmFrames"),
7605
8034
  bufferRetentionDays: pick("bufferRetentionDays"),
7606
8035
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
@@ -7936,13 +8365,49 @@ function evaluatePeriodicSnapshot(input) {
7936
8365
  */
7937
8366
  function planPeriodicMedia(input) {
7938
8367
  const appendSnapshot = input.dueSnapshot;
8368
+ const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
8369
+ const thumbnailLanded = input.thumbnailLanded ?? true;
7939
8370
  return {
7940
8371
  appendSnapshot,
7941
- rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
7942
- bestThumbnail: input.isNewBest
8372
+ rollingLastFrame,
8373
+ bestThumbnail: input.isNewBest || !thumbnailLanded
7943
8374
  };
7944
8375
  }
7945
8376
  //#endregion
8377
+ //#region src/pipeline-analytics/best-thumbnail-guard.ts
8378
+ /**
8379
+ * Void/envArea guard for best-`thumbnail` selection.
8380
+ *
8381
+ * ## Why this exists (the dawn/night "void" thumbnail)
8382
+ *
8383
+ * At dawn/night a moving subject's tracker box intermittently EXPLODES to
8384
+ * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
8385
+ * that frame happens to win the best-detection race, the gallery/reel best
8386
+ * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
8387
+ * washed-out frame), not the subject. This guard rejects such a frame from the
8388
+ * best-`thumbnail` decision so the track keeps a real subject-centered tile.
8389
+ *
8390
+ * Conservative by design: it only rejects boxes covering ≥ {@link
8391
+ * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
8392
+ * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
8393
+ * per-frame retry keeps trying until a plausible frame wins.
8394
+ */
8395
+ /**
8396
+ * Area fraction at/above which a detection bbox is treated as an exploded
8397
+ * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
8398
+ * guard conservative — only boxes covering ≥85% of the frame are rejected.
8399
+ */
8400
+ var NEAR_FULL_FRAME_AREA = .85;
8401
+ /**
8402
+ * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
8403
+ * area is below the near-full-frame threshold. Degenerate frame dimensions
8404
+ * (≤0) are treated as plausible (no info to reject on).
8405
+ */
8406
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8407
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
8408
+ return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
8409
+ }
8410
+ //#endregion
7946
8411
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
7947
8412
  /**
7948
8413
  * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
@@ -8922,6 +9387,21 @@ var ObjectEmbeddingStore = class {
8922
9387
  //#endregion
8923
9388
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
8924
9389
  /**
9390
+ * Count enrolled samples per identity for probes of a matching model+dimension.
9391
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
9392
+ */
9393
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
9394
+ const counts = /* @__PURE__ */ new Map();
9395
+ for (const s of gallery) {
9396
+ if (s.modelId !== probeModelId) continue;
9397
+ if (s.embedding.length !== probeDim) continue;
9398
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
9399
+ }
9400
+ const eligible = /* @__PURE__ */ new Set();
9401
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
9402
+ return eligible;
9403
+ }
9404
+ /**
8925
9405
  * Assign at most one identity per track AND at most one track per identity for
8926
9406
  * a single frame. Greedy by score: compute every candidate's full ranked match
8927
9407
  * list, then repeatedly take the globally-highest (track, identity) pair whose
@@ -8932,10 +9412,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
8932
9412
  const pairs = [];
8933
9413
  candidates.forEach((c, trackIdx) => {
8934
9414
  const probeVec = new Float32Array(c.embedding);
9415
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
8935
9416
  const bestByIdentity = /* @__PURE__ */ new Map();
8936
9417
  for (const s of gallery) {
8937
9418
  if (s.modelId !== c.modelId) continue;
8938
9419
  if (s.embedding.length !== c.embedding.length) continue;
9420
+ if (!eligible.has(s.identityId)) continue;
8939
9421
  const score = cosineSimilarity(probeVec, new Float32Array(s.embedding));
8940
9422
  const prev = bestByIdentity.get(s.identityId);
8941
9423
  if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
@@ -9081,7 +9563,7 @@ var FaceRecognizer = class {
9081
9563
  }
9082
9564
  async processFrame(input) {
9083
9565
  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);
9566
+ 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
9567
  if (candidates.length === 0) return;
9086
9568
  this.deps.logger.debug("face: frame candidates", {
9087
9569
  tags: { deviceId: input.deviceId },
@@ -9097,7 +9579,8 @@ var FaceRecognizer = class {
9097
9579
  modelId: c.embeddingModelId
9098
9580
  })), this.gallery, {
9099
9581
  threshold: settings.similarityThreshold,
9100
- margin: settings.margin
9582
+ margin: settings.margin,
9583
+ minIdentitySamples: settings.minIdentitySamples
9101
9584
  }) : /* @__PURE__ */ new Map();
9102
9585
  const labelWork = [];
9103
9586
  for (const c of candidates) {
@@ -11232,6 +11715,35 @@ function toAnalyticsDeviceSections(sections) {
11232
11715
  }));
11233
11716
  }
11234
11717
  /**
11718
+ * Global-analytics section ids that are really per-camera DETECTION knobs and
11719
+ * belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
11720
+ * Object Detection — NOT under the generic `Analytics` top-tab:
11721
+ * - `detection-sensitivity` — minHits / cooldown / stationary threshold.
11722
+ * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
11723
+ * animal dedup, class voting, confirm-bypass, per-class min score).
11724
+ * - `stationary-objects` — stationary promotion + occupancy tuning.
11725
+ * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
11726
+ * structural Detection pipeline tab, so re-tagging is all that's needed —
11727
+ * there is no admin-ui change and no duplicate render (a section has one tab).
11728
+ */
11729
+ var DETECTION_PIPELINE_SECTION_IDS = new Set([
11730
+ "detection-sensitivity",
11731
+ "tracking",
11732
+ "stationary-objects"
11733
+ ]);
11734
+ /**
11735
+ * Re-home the detection-knob sections from the `Analytics` top-tab onto the
11736
+ * `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
11737
+ * section changes; every other section (media policy, retention, faces, track
11738
+ * history) stays on Analytics.
11739
+ */
11740
+ function retagDetectionSections(sections) {
11741
+ return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
11742
+ ...s,
11743
+ tab: "detection-pipeline"
11744
+ } : s);
11745
+ }
11746
+ /**
11235
11747
  * Fields that live ONLY on the global settings page and must never surface in a
11236
11748
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
11237
11749
  * master kill for the whole subsystem — per-camera face production is governed
@@ -11344,6 +11856,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11344
11856
  faceGlobalEnabledCache = null;
11345
11857
  mediaCacheByDevice = /* @__PURE__ */ new Map();
11346
11858
  packageDropCacheByDevice = /* @__PURE__ */ new Map();
11859
+ /** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
11860
+ * The registry is a single shared instance, so per-(device) suppress/wake IoU,
11861
+ * promotion window, TTL and the master toggle are resolved from this cache at
11862
+ * the partition + promotion + sweep call sites — an operator change takes
11863
+ * effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
11864
+ stationaryCacheByDevice = /* @__PURE__ */ new Map();
11347
11865
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
11348
11866
  packageDropDetector = null;
11349
11867
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
@@ -11376,6 +11894,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11376
11894
  * where no `snapshot` is appended, so it is never byte-identical to a stored
11377
11895
  * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
11378
11896
  lastFrameAtByTrack = /* @__PURE__ */ new Map();
11897
+ /** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
11898
+ * track absent here keeps forcing a best-thumbnail capture every frame until
11899
+ * one lands, so a short / high-churn track whose first capture was dropped
11900
+ * (recycled/blank live frame) still gets a subject crop for the gallery
11901
+ * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11902
+ thumbnailLandedTracks = /* @__PURE__ */ new Set();
11379
11903
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11380
11904
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11381
11905
  * area) + emit time, so a material improvement is measured against the
@@ -11447,6 +11971,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11447
11971
  this.stationaryRegistry = new StationaryObjectRegistry({
11448
11972
  store: api.settingsStore,
11449
11973
  logger: logger.child("StationaryRegistry"),
11974
+ ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
11450
11975
  onChange: ({ phase, entry, timestamp }) => {
11451
11976
  this.ctx.eventBus.emit({
11452
11977
  id: `pa-stationary-${entry.id}-${phase}`,
@@ -11600,11 +12125,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11600
12125
  timestamp: 0
11601
12126
  };
11602
12127
  };
11603
- this.eventMediaDispatcher = new EventMediaDispatcher({
11604
- getRemoteFrame,
11605
- mediaStore: this.mediaStore,
11606
- logger: logger.child("EventMediaDispatcher")
11607
- });
11608
12128
  const cropMetricLogger = logger.child("NativeCrop");
11609
12129
  let nativeHits = 0;
11610
12130
  let nativeFallbacks = 0;
@@ -11636,6 +12156,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11636
12156
  return null;
11637
12157
  }
11638
12158
  };
12159
+ const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12160
+ const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
12161
+ bumpCropMetric(jpeg !== null);
12162
+ return jpeg;
12163
+ };
12164
+ this.eventMediaDispatcher = new EventMediaDispatcher({
12165
+ getRemoteFrame,
12166
+ getNativeCropJpeg,
12167
+ mediaStore: this.mediaStore,
12168
+ logger: logger.child("EventMediaDispatcher")
12169
+ });
11639
12170
  const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
11640
12171
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
11641
12172
  const paddedNorm = padBbox({
@@ -11822,6 +12353,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11822
12353
  this.faceCacheByDevice.delete(data.deviceId);
11823
12354
  this.mediaCacheByDevice.delete(data.deviceId);
11824
12355
  this.packageDropCacheByDevice.delete(data.deviceId);
12356
+ this.stationaryCacheByDevice.delete(data.deviceId);
11825
12357
  }
11826
12358
  });
11827
12359
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
@@ -11838,6 +12370,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11838
12370
  this.faceCacheByDevice.delete(deviceId);
11839
12371
  this.mediaCacheByDevice.delete(deviceId);
11840
12372
  this.packageDropCacheByDevice.delete(deviceId);
12373
+ this.stationaryCacheByDevice.delete(deviceId);
11841
12374
  this.bindingCache?.invalidate(deviceId);
11842
12375
  this.zoneAnalytics?.forgetDevice(deviceId);
11843
12376
  this.audioMetrics?.forgetDevice(deviceId);
@@ -12176,6 +12709,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12176
12709
  this.dropoutSkipsByKey.clear();
12177
12710
  this.bestFrameTracker.clear();
12178
12711
  this.lastFrameAtByTrack.clear();
12712
+ this.thumbnailLandedTracks.clear();
12179
12713
  this.trackLifecycleUpdateMem.clear();
12180
12714
  this.objectEmbeddingBestSelector.clear();
12181
12715
  this.levelStateByDevice.clear();
@@ -12186,6 +12720,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12186
12720
  this.faceGlobalEnabledCache = null;
12187
12721
  this.mediaCacheByDevice.clear();
12188
12722
  this.packageDropCacheByDevice.clear();
12723
+ this.stationaryCacheByDevice.clear();
12189
12724
  this.trackStore?.clearAll();
12190
12725
  this.stationaryRegistry = null;
12191
12726
  this.bindingCache?.clearAll();
@@ -12230,6 +12765,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12230
12765
  return;
12231
12766
  }
12232
12767
  this.dropoutSkipsByKey.set(key, 0);
12768
+ if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
12233
12769
  const processor = await this.getOrCreateProcessor(deviceId, source);
12234
12770
  const proxy = await this.ensureProxy(deviceId);
12235
12771
  const liveZones = proxy?.state.zones.value?.zones ?? [];
@@ -12369,10 +12905,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12369
12905
  } });
12370
12906
  }
12371
12907
  this.lastActiveTrackIds.set(key, currentTrackIds);
12372
- if (source === "pipeline" && this.stationaryRegistry) {
12908
+ const stationarySettings = this.stationarySettingsFromCache(deviceId);
12909
+ if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
12373
12910
  const dims = this.lastFrameDimsByDevice.get(deviceId);
12374
12911
  if (dims && dims.w > 0 && dims.h > 0) {
12375
12912
  const refDiag = Math.hypot(dims.w, dims.h);
12913
+ const promotionConfig = {
12914
+ ...DEFAULT_PROMOTION_CONFIG,
12915
+ windowMs: stationarySettings.promotionWindowMs
12916
+ };
12376
12917
  for (const t of result.tracked) {
12377
12918
  const active = this.trackStore.peekActive(t.trackId);
12378
12919
  if (!active) continue;
@@ -12380,7 +12921,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12380
12921
  positions: active.positions,
12381
12922
  referenceDiagonalPx: refDiag,
12382
12923
  now: result.timestamp,
12383
- config: DEFAULT_PROMOTION_CONFIG
12924
+ config: promotionConfig
12384
12925
  });
12385
12926
  if (!promote) continue;
12386
12927
  this.promoteToStationary({
@@ -12507,6 +13048,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12507
13048
  },
12508
13049
  mediaKey: s.mediaKey
12509
13050
  });
13051
+ for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
12510
13052
  }).catch(() => {});
12511
13053
  }
12512
13054
  }
@@ -12661,6 +13203,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12661
13203
  });
12662
13204
  return settings;
12663
13205
  }
13206
+ async resolveDeviceStationarySettings(deviceId) {
13207
+ const now = Date.now();
13208
+ const cached = this.stationaryCacheByDevice.get(deviceId);
13209
+ if (cached && now < cached.expiresAt) return cached.settings;
13210
+ const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
13211
+ this.stationaryCacheByDevice.set(deviceId, {
13212
+ settings,
13213
+ expiresAt: now + SETTINGS_CACHE_TTL_MS
13214
+ });
13215
+ return settings;
13216
+ }
13217
+ stationarySettingsFromCache(deviceId) {
13218
+ return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
13219
+ }
12664
13220
  async resolveDevicePackageDropSettings(deviceId) {
12665
13221
  const now = Date.now();
12666
13222
  const cached = this.packageDropCacheByDevice.get(deviceId);
@@ -12758,6 +13314,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12758
13314
  if (!this.faceRecognizer || detail.embedding === void 0) return;
12759
13315
  if (!await this.resolveGlobalFaceEnabled()) return;
12760
13316
  const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
13317
+ if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
12761
13318
  await this.faceRecognizer.ingestFaceDetail({
12762
13319
  deviceId,
12763
13320
  trackId,
@@ -13036,19 +13593,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13036
13593
  frameWidth,
13037
13594
  frameHeight
13038
13595
  });
13039
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
13596
+ const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
13597
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
13040
13598
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
13041
13599
  const plan = planPeriodicMedia({
13042
13600
  saveThumbnails: media.saveThumbnails,
13043
13601
  dueSnapshot,
13044
13602
  isNewBest,
13603
+ thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
13045
13604
  lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
13046
13605
  now: timestamp,
13047
13606
  intervalMs: media.snapshotIntervalMs
13048
13607
  });
13049
13608
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13050
13609
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13051
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
13610
+ const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13611
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
13052
13612
  targets.push({
13053
13613
  trackId: t.trackId,
13054
13614
  timestamp,
@@ -13056,7 +13616,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13056
13616
  ...t.label ? { label: t.label } : {},
13057
13617
  appendSnapshot: plan.appendSnapshot,
13058
13618
  rollingLastFrame: plan.rollingLastFrame,
13059
- bestThumbnail: plan.bestThumbnail
13619
+ bestThumbnail
13060
13620
  });
13061
13621
  }
13062
13622
  return targets;
@@ -13362,6 +13922,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13362
13922
  this.bestFrameTracker.delete(t.trackId);
13363
13923
  this.objectEmbeddingBestSelector.delete(t.trackId);
13364
13924
  this.lastFrameAtByTrack.delete(t.trackId);
13925
+ this.thumbnailLandedTracks.delete(t.trackId);
13365
13926
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13366
13927
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13367
13928
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -13623,6 +14184,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13623
14184
  this.bestFrameTracker.delete(track.trackId);
13624
14185
  this.objectEmbeddingBestSelector.delete(track.trackId);
13625
14186
  this.lastFrameAtByTrack.delete(track.trackId);
14187
+ this.thumbnailLandedTracks.delete(track.trackId);
13626
14188
  this.trackLifecycleUpdateMem.delete(track.trackId);
13627
14189
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
13628
14190
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -13656,7 +14218,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13656
14218
  rescueCentroidFactor: trk.rescueCentroidFactor,
13657
14219
  resurrectionWindowMs: trk.resurrectionWindowMs,
13658
14220
  stationarySpeedPx: trk.stationarySpeedPx,
13659
- maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
14221
+ maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
14222
+ dedupEnabled: trk.dedupEnabled,
14223
+ dedupSpawnIou: trk.dedupSpawnIou,
14224
+ dedupMergeIou: trk.dedupMergeIou,
14225
+ dedupMergeFrames: trk.dedupMergeFrames,
14226
+ personAnimalDedup: trk.personAnimalDedup,
14227
+ animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
14228
+ animalOverPersonIou: trk.animalOverPersonIou,
14229
+ classVotingEnabled: trk.classVotingEnabled,
14230
+ classVoteMinFraction: trk.classVoteMinFraction,
14231
+ perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
14232
+ classMinScores: {
14233
+ person: trk.minScorePerson,
14234
+ animal: trk.minScoreAnimal,
14235
+ vehicle: trk.minScoreVehicle
14236
+ },
14237
+ confirmBypassEnabled: trk.confirmBypassEnabled,
14238
+ confirmBypassScore: trk.confirmBypassScore
13660
14239
  }, { stationaryThresholdSec }, {
13661
14240
  minTrackAge,
13662
14241
  cooldownSec,
@@ -13664,15 +14243,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13664
14243
  }, source);
13665
14244
  if (source === "pipeline" && this.stationaryRegistry) {
13666
14245
  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
- }) });
14246
+ p.setStationaryGate({ filter: (input) => {
14247
+ const s = this.stationarySettingsFromCache(deviceId);
14248
+ if (!s.enabled) return {
14249
+ suppressedIndices: /* @__PURE__ */ new Set(),
14250
+ confirmed: [],
14251
+ wokenEntryIds: []
14252
+ };
14253
+ const matchConfig = {
14254
+ suppressIou: s.suppressIou,
14255
+ wakeAssocIou: s.wakeAssocIou
14256
+ };
14257
+ return registry.filter({
14258
+ deviceId,
14259
+ detections: input.detections.map((d) => ({
14260
+ bbox: d.bbox,
14261
+ className: d.class
14262
+ })),
14263
+ frameWidth: input.frameWidth,
14264
+ frameHeight: input.frameHeight
14265
+ }, matchConfig);
14266
+ } });
13676
14267
  }
13677
14268
  this.processors.set(key, p);
13678
14269
  }
@@ -14735,6 +15326,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14735
15326
  step: .05,
14736
15327
  default: FACE_DEFAULTS.minFaceConfidence
14737
15328
  },
15329
+ {
15330
+ type: "number",
15331
+ key: "minFacePx",
15332
+ label: "Min face size",
15333
+ 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.",
15334
+ min: 0,
15335
+ step: 1,
15336
+ default: FACE_DEFAULTS.minFacePx,
15337
+ unit: "px"
15338
+ },
15339
+ {
15340
+ type: "number",
15341
+ key: "minIdentitySamples",
15342
+ label: "Min identity samples",
15343
+ 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).",
15344
+ min: 1,
15345
+ step: 1,
15346
+ default: FACE_DEFAULTS.minIdentitySamples
15347
+ },
14738
15348
  {
14739
15349
  type: "number",
14740
15350
  key: "confirmFrames",
@@ -14928,6 +15538,69 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14928
15538
  default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
14929
15539
  unit: "ms"
14930
15540
  },
15541
+ {
15542
+ type: "boolean",
15543
+ key: "dedupEnabled",
15544
+ label: "Duplicate-track suppression",
15545
+ description: "Suppress and merge concurrent duplicate tracks. Stops one subject the detector double-fires (two boxes for one object, or a person misdetected as an animal) from becoming several time-overlapping tracks that each fire their own event. Off = legacy behaviour.",
15546
+ default: TRACKING_DEFAULTS.dedupEnabled
15547
+ },
15548
+ {
15549
+ type: "number",
15550
+ key: "dedupSpawnIou",
15551
+ label: "Duplicate spawn IoU",
15552
+ description: "Overlap at/above which a NEW track is treated as a duplicate of a concurrent same-kind track and not created. High so two distinct subjects appearing close together are still tracked separately.",
15553
+ min: 0,
15554
+ max: 1,
15555
+ step: .05,
15556
+ default: TRACKING_DEFAULTS.dedupSpawnIou
15557
+ },
15558
+ {
15559
+ type: "number",
15560
+ key: "dedupMergeIou",
15561
+ label: "Duplicate merge IoU",
15562
+ description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
15563
+ min: 0,
15564
+ max: 1,
15565
+ step: .05,
15566
+ default: TRACKING_DEFAULTS.dedupMergeIou
15567
+ },
15568
+ {
15569
+ type: "number",
15570
+ key: "dedupMergeFrames",
15571
+ label: "Duplicate merge frames",
15572
+ description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
15573
+ min: 1,
15574
+ step: 1,
15575
+ default: TRACKING_DEFAULTS.dedupMergeFrames
15576
+ },
15577
+ {
15578
+ type: "boolean",
15579
+ key: "personAnimalDedup",
15580
+ label: "Person/animal duplicate merge",
15581
+ description: "Treat an animal track overlapping a person track as the same subject (a person in a crouching / bending pose is often misdetected as an animal). The person track wins.",
15582
+ default: TRACKING_DEFAULTS.personAnimalDedup
15583
+ },
15584
+ {
15585
+ type: "number",
15586
+ key: "animalOverPersonMaxScore",
15587
+ label: "Animal-over-person max score",
15588
+ description: "A low-confidence animal detection (score below this) sitting on a concurrent person track is suppressed — the common person-in-odd-pose false animal.",
15589
+ min: 0,
15590
+ max: 1,
15591
+ step: .05,
15592
+ default: TRACKING_DEFAULTS.animalOverPersonMaxScore
15593
+ },
15594
+ {
15595
+ type: "number",
15596
+ key: "animalOverPersonIou",
15597
+ label: "Animal-over-person IoU",
15598
+ description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
15599
+ min: 0,
15600
+ max: 1,
15601
+ step: .05,
15602
+ default: TRACKING_DEFAULTS.animalOverPersonIou
15603
+ },
14931
15604
  {
14932
15605
  type: "boolean",
14933
15606
  key: "dropoutSkipEnabled",
@@ -14943,6 +15616,141 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14943
15616
  min: 0,
14944
15617
  step: 1,
14945
15618
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
15619
+ },
15620
+ {
15621
+ type: "boolean",
15622
+ key: "classVotingEnabled",
15623
+ label: "Per-track class voting",
15624
+ description: "Report a track’s class by a confidence-weighted majority over its whole life instead of the latest frame — a person read as an animal on a single crouch/bend frame keeps the person label.",
15625
+ default: TRACKING_DEFAULTS.classVotingEnabled
15626
+ },
15627
+ {
15628
+ type: "number",
15629
+ key: "classVoteMinFraction",
15630
+ label: "Class-vote min fraction",
15631
+ description: "The winning class must hold at least this fraction of a track’s total vote weight to override the latest frame; below it the latest frame wins (so a genuine mid-life reclassification is not frozen out).",
15632
+ min: 0,
15633
+ max: 1,
15634
+ step: .05,
15635
+ default: TRACKING_DEFAULTS.classVoteMinFraction
15636
+ },
15637
+ {
15638
+ type: "boolean",
15639
+ key: "perClassMinScoreEnabled",
15640
+ label: "Per-class spawn confidence",
15641
+ description: "Require a per-class minimum detection score before a NEW track is created. Kills static-object / reflection false spawns from the FP-prone classes. An already-tracked object still matches on a low-confidence frame.",
15642
+ default: TRACKING_DEFAULTS.perClassMinScoreEnabled
15643
+ },
15644
+ {
15645
+ type: "number",
15646
+ key: "minScorePerson",
15647
+ label: "Min spawn score — person",
15648
+ description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
15649
+ min: 0,
15650
+ max: 1,
15651
+ step: .05,
15652
+ default: TRACKING_DEFAULTS.minScorePerson
15653
+ },
15654
+ {
15655
+ type: "number",
15656
+ key: "minScoreAnimal",
15657
+ label: "Min spawn score — animal",
15658
+ description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
15659
+ min: 0,
15660
+ max: 1,
15661
+ step: .05,
15662
+ default: TRACKING_DEFAULTS.minScoreAnimal
15663
+ },
15664
+ {
15665
+ type: "number",
15666
+ key: "minScoreVehicle",
15667
+ label: "Min spawn score — vehicle",
15668
+ description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
15669
+ min: 0,
15670
+ max: 1,
15671
+ step: .05,
15672
+ default: TRACKING_DEFAULTS.minScoreVehicle
15673
+ },
15674
+ {
15675
+ type: "boolean",
15676
+ key: "confirmBypassEnabled",
15677
+ label: "Fast high-confidence confirm",
15678
+ description: "Let a single very-high-confidence detection confirm a track for events before it reaches the min-hits gate, so a fast subject crossing in 1-2 frames (a passing car) still registers. Off by default (strict N-hit confirmation).",
15679
+ default: TRACKING_DEFAULTS.confirmBypassEnabled
15680
+ },
15681
+ {
15682
+ type: "number",
15683
+ key: "confirmBypassScore",
15684
+ label: "Fast-confirm score",
15685
+ description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
15686
+ min: 0,
15687
+ max: 1,
15688
+ step: .05,
15689
+ default: TRACKING_DEFAULTS.confirmBypassScore
15690
+ }
15691
+ ]
15692
+ },
15693
+ {
15694
+ id: "stationary-objects",
15695
+ title: "Stationary objects",
15696
+ tab: "analytics",
15697
+ 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.",
15698
+ columns: 2,
15699
+ fields: [
15700
+ {
15701
+ type: "boolean",
15702
+ key: "enabled",
15703
+ label: "Suppress parked objects",
15704
+ 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.",
15705
+ default: STATIONARY_DEFAULTS.enabled
15706
+ },
15707
+ {
15708
+ type: "slider",
15709
+ key: "suppressIou",
15710
+ label: "Suppress IoU",
15711
+ 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.",
15712
+ min: .3,
15713
+ max: .9,
15714
+ step: .05,
15715
+ default: STATIONARY_DEFAULTS.suppressIou,
15716
+ showValue: true
15717
+ },
15718
+ {
15719
+ type: "slider",
15720
+ key: "wakeAssocIou",
15721
+ label: "Wake / associate IoU",
15722
+ 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.",
15723
+ min: .02,
15724
+ max: .5,
15725
+ step: .02,
15726
+ default: STATIONARY_DEFAULTS.wakeAssocIou,
15727
+ showValue: true
15728
+ },
15729
+ {
15730
+ type: "slider",
15731
+ key: "promotionWindowMs",
15732
+ label: "Promotion stillness window",
15733
+ 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.",
15734
+ min: 5e3,
15735
+ max: 12e4,
15736
+ step: 1e3,
15737
+ default: STATIONARY_DEFAULTS.promotionWindowMs,
15738
+ showValue: true,
15739
+ unit: "s",
15740
+ displayScale: 1e3
15741
+ },
15742
+ {
15743
+ type: "slider",
15744
+ key: "entryTtlMs",
15745
+ label: "Observed-time TTL",
15746
+ 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.",
15747
+ min: 6e4,
15748
+ max: 18e5,
15749
+ step: 3e4,
15750
+ default: STATIONARY_DEFAULTS.entryTtlMs,
15751
+ showValue: true,
15752
+ unit: "s",
15753
+ displayScale: 1e3
14946
15754
  }
14947
15755
  ]
14948
15756
  }
@@ -14954,7 +15762,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14954
15762
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
14955
15763
  const baseSections = schema ? hydrateSchema({
14956
15764
  ...schema,
14957
- sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
15765
+ sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
14958
15766
  }, raw).sections : [];
14959
15767
  const liveStatsSection = {
14960
15768
  id: "live-stats",
@@ -15003,4 +15811,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15003
15811
  }
15004
15812
  };
15005
15813
  //#endregion
15006
- export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
15814
+ export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };