@camstack/addon-post-analysis 1.1.37 → 1.1.39

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-DPBet4IQ.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-CO07v2sR.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,35 @@ 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,
1140
+ byteTrackEnabled: true,
1141
+ byteTrackHighThreshold: .4,
1142
+ byteTrackLowIouThreshold: .2,
1143
+ minSpawnAreaEnabled: true,
1144
+ classSpawnAreaFracs: { person: 5e-4 },
1145
+ classGroupAssoc: true
1124
1146
  };
1147
+ /** Macro-classes the duplicate resolver may collapse across (people in
1148
+ * non-upright poses misdetected as animals). Any other cross-class pair is
1149
+ * NEVER a duplicate. */
1150
+ var PERSON_CLASS = "person";
1151
+ var ANIMAL_CLASS = "animal";
1125
1152
  var MAX_PATH_LENGTH = 300;
1126
1153
  function clamp(value, min, max) {
1127
1154
  return Math.max(min, Math.min(max, value));
@@ -1144,16 +1171,127 @@ function containment(inner, outer) {
1144
1171
  const innerArea = inner.w * inner.h;
1145
1172
  return innerArea > 0 ? iw * ih / innerArea : 0;
1146
1173
  }
1147
- var SortTracker = class {
1174
+ var SortTracker = class SortTracker {
1148
1175
  config;
1149
1176
  tracks = [];
1150
1177
  lostTracks = [];
1178
+ /**
1179
+ * Consecutive-frame overlap streak per unordered live-track pair
1180
+ * (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
1181
+ * merged only once its streak reaches `dedupMergeFrames`. Entries reset the
1182
+ * moment the pair stops overlapping and are pruned when either track dies.
1183
+ */
1184
+ dupStreaks = /* @__PURE__ */ new Map();
1151
1185
  constructor(config = {}) {
1152
1186
  this.config = {
1153
1187
  ...DEFAULT_TRACKER_CONFIG,
1154
1188
  ...config
1155
1189
  };
1156
1190
  }
1191
+ /** Whether two macro-classes may be collapsed as the SAME subject. Same class
1192
+ * always; the {person,animal} pair only when `personAnimalDedup` is on. */
1193
+ dedupCompatible(a, b) {
1194
+ if (a === b) return true;
1195
+ if (!this.config.personAnimalDedup) return false;
1196
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
1197
+ }
1198
+ /** Unordered, stable key for a track pair (UUIDs never contain `|`). */
1199
+ static pairKey(a, b) {
1200
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
1201
+ }
1202
+ /** Association-gating group of a class. When `classGroupAssoc` is on the
1203
+ * {person,animal} pair collapses to one group so a per-frame class flip
1204
+ * (crouch person → animal) re-matches the existing track instead of spawning a
1205
+ * concurrent id. Every other class is its own group. */
1206
+ assocGroup(cls) {
1207
+ if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
1208
+ return cls;
1209
+ }
1210
+ /** Whether a detection may associate to a track under the current class gate:
1211
+ * exact class when `classGating` is off (no gate at all) — matching the legacy
1212
+ * contract — otherwise same association group (group == exact class unless
1213
+ * `classGroupAssoc` widened it to {person,animal}). */
1214
+ associable(trackClass, detClass) {
1215
+ if (!this.config.classGating) return true;
1216
+ return this.assocGroup(trackClass) === this.assocGroup(detClass);
1217
+ }
1218
+ /** Whether a spawn candidate clears its per-class minimum bbox-area floor
1219
+ * (fraction of frame area). Ungated when disabled, when the class has no floor,
1220
+ * or when the caller supplied no frame dimensions (fraction is uncomputable). */
1221
+ meetsSpawnAreaFloor(det, frameArea) {
1222
+ if (!this.config.minSpawnAreaEnabled) return true;
1223
+ const floor = this.config.classSpawnAreaFracs[det.class] ?? 0;
1224
+ if (floor <= 0 || frameArea <= 0) return true;
1225
+ return det.bbox.w * det.bbox.h / frameArea >= floor;
1226
+ }
1227
+ /**
1228
+ * True when spawning a fresh track for `det` would duplicate an already-live
1229
+ * track: (a) it overlaps a concurrent compatible-class track by
1230
+ * `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
1231
+ * concurrent `person` track (person-in-odd-pose false animal). `live` is the
1232
+ * set of tracks already surviving this frame (matched, coasting, resurrected,
1233
+ * and earlier same-frame spawns).
1234
+ */
1235
+ isDuplicateSpawn(det, live) {
1236
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
1237
+ if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
1238
+ for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
1239
+ }
1240
+ return false;
1241
+ }
1242
+ /** The lower-importance track of a duplicate pair — the one to drop. A
1243
+ * `person` always beats a cross-class `animal`; otherwise more hits wins,
1244
+ * ties break to the older track, then the higher score. */
1245
+ static duplicateLoser(a, b) {
1246
+ if (a.class !== b.class) {
1247
+ if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
1248
+ if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
1249
+ }
1250
+ if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
1251
+ if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
1252
+ return a.score >= b.score ? b : a;
1253
+ }
1254
+ /**
1255
+ * Merge concurrent duplicate tracks. Two live compatible-class tracks that
1256
+ * stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
1257
+ * frames are collapsed: the lower-importance one is dropped (NOT graveyarded
1258
+ * — a confirmed duplicate must not resurrect). This is the fix for a subject
1259
+ * the detector double-fires (two boxes each frame, each feeding its OWN
1260
+ * track, so neither is ever "unmatched" for spawn-suppression to catch) and
1261
+ * for a person misdetected as `animal` alongside the real person track.
1262
+ */
1263
+ resolveDuplicates() {
1264
+ if (!this.config.dedupEnabled) return;
1265
+ const live = this.tracks;
1266
+ const liveIds = new Set(live.map((t) => t.id));
1267
+ for (const key of this.dupStreaks.keys()) {
1268
+ const [a, b] = key.split("|");
1269
+ if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
1270
+ }
1271
+ const dropIds = /* @__PURE__ */ new Set();
1272
+ for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
1273
+ const a = live[i];
1274
+ const b = live[j];
1275
+ if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
1276
+ if (!this.dedupCompatible(a.class, b.class)) continue;
1277
+ const key = SortTracker.pairKey(a.id, b.id);
1278
+ if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
1279
+ this.dupStreaks.delete(key);
1280
+ continue;
1281
+ }
1282
+ const streak = (this.dupStreaks.get(key) ?? 0) + 1;
1283
+ if (streak >= this.config.dedupMergeFrames) {
1284
+ dropIds.add(SortTracker.duplicateLoser(a, b).id);
1285
+ this.dupStreaks.delete(key);
1286
+ } else this.dupStreaks.set(key, streak);
1287
+ }
1288
+ if (dropIds.size === 0) return;
1289
+ this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
1290
+ for (const key of this.dupStreaks.keys()) {
1291
+ const [a, b] = key.split("|");
1292
+ if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
1293
+ }
1294
+ }
1157
1295
  /** Where a track is expected this frame — extrapolated by velocity while
1158
1296
  * coasting (predictiveCoasting), else its last known bbox. A stationary
1159
1297
  * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
@@ -1174,13 +1312,16 @@ var SortTracker = class {
1174
1312
  };
1175
1313
  }
1176
1314
  /**
1177
- * Loose same-class gate used by the rescue and resurrection passes: accept
1315
+ * Loose same-group gate used by the rescue and resurrection passes: accept
1178
1316
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
1179
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
1180
- * gated (a rescue must never cross classes), independent of `classGating`.
1317
+ * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
1318
+ * gated (a rescue must never cross association groups) independent of
1319
+ * `classGating`, so a coasting person track can be rescued by its own
1320
+ * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
1321
+ * class.
1181
1322
  */
1182
1323
  looseMatch(track, det) {
1183
- if (track.class !== det.class) return false;
1324
+ if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return false;
1184
1325
  if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1185
1326
  const tc = bboxCentroid(track.bbox);
1186
1327
  const dc = bboxCentroid(det.bbox);
@@ -1188,29 +1329,65 @@ var SortTracker = class {
1188
1329
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
1189
1330
  return dist <= this.config.rescueCentroidFactor * diag;
1190
1331
  }
1191
- update(detections, timestamp) {
1192
- if (this.config.maxTrackLifetimeMs > 0) {
1193
- const alive = [];
1194
- for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1195
- track.lost = true;
1196
- track.lostAt = timestamp;
1197
- track.resurrectable = false;
1198
- this.lostTracks.push(track);
1199
- } else alive.push(track);
1200
- this.tracks = alive;
1332
+ /** Fold a matched detection's class + score into a track's lifetime vote
1333
+ * tally. A non-positive score still counts as an infinitesimal vote so a
1334
+ * zero-confidence frame contributes to the frame-count tiebreak. */
1335
+ addClassVote(track, det) {
1336
+ const weight = det.score > 0 ? det.score : Number.EPSILON;
1337
+ track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
1338
+ }
1339
+ /**
1340
+ * The class to REPORT for a track: the confidence-weighted lifetime majority
1341
+ * when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
1342
+ * weight; otherwise the latest-frame class (ambiguous vote or voting off).
1343
+ */
1344
+ resolveReportedClass(t) {
1345
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
1346
+ let bestClass = t.class;
1347
+ let bestVote = -1;
1348
+ let total = 0;
1349
+ for (const [cls, v] of t.classVotes) {
1350
+ total += v;
1351
+ if (v > bestVote) {
1352
+ bestVote = v;
1353
+ bestClass = cls;
1354
+ }
1201
1355
  }
1202
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1203
- const used = /* @__PURE__ */ new Set();
1204
- const matchedTracks = /* @__PURE__ */ new Set();
1205
- const matched = /* @__PURE__ */ new Map();
1356
+ if (total <= 0) return t.class;
1357
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
1358
+ }
1359
+ /** Whether a detection clears its per-class spawn score floor. Only gates NEW
1360
+ * spawns — an established track still matches below its floor. */
1361
+ meetsClassScoreFloor(det) {
1362
+ if (!this.config.perClassMinScoreEnabled) return true;
1363
+ const floor = this.config.classMinScores[det.class] ?? 0;
1364
+ return det.score >= floor;
1365
+ }
1366
+ /** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
1367
+ * a single very-high-confidence detection bypassed the hit gate (fast car). */
1368
+ isConfirmedForEmit(t) {
1369
+ if (t.hits >= this.config.minHits) return true;
1370
+ return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1371
+ }
1372
+ /**
1373
+ * One greedy IoU association pass over a candidate detection subset against the
1374
+ * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
1375
+ * bind first; a track/detection binds at most once. Shared by the high-score
1376
+ * first stage and the low-score second stage (different candidate sets + IoU
1377
+ * gates) so the two-tier ByteTrack association stays a single implementation.
1378
+ * Mutates `used` / `matchedTracks` / `matched` in place.
1379
+ */
1380
+ greedyAssociate(detections, candidateIdxs, iouThreshold, used, matchedTracks, matched) {
1206
1381
  const pairs = [];
1207
1382
  for (const track of this.tracks) {
1383
+ if (matchedTracks.has(track)) continue;
1208
1384
  const pbox = this.predicted(track);
1209
- for (let di = 0; di < detections.length; di++) {
1385
+ for (const di of candidateIdxs) {
1386
+ if (used.has(di)) continue;
1210
1387
  const det = detections[di];
1211
- if (this.config.classGating && track.class !== det.class) continue;
1388
+ if (!this.associable(track.class, det.class)) continue;
1212
1389
  const score = iou$2(pbox, det.bbox);
1213
- if (score >= this.config.iouThreshold) pairs.push({
1390
+ if (score >= iouThreshold) pairs.push({
1214
1391
  track,
1215
1392
  detIdx: di,
1216
1393
  score
@@ -1224,11 +1401,34 @@ var SortTracker = class {
1224
1401
  matchedTracks.add(pair.track);
1225
1402
  used.add(pair.detIdx);
1226
1403
  }
1404
+ }
1405
+ update(detections, timestamp, frameContext) {
1406
+ const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
1407
+ if (this.config.maxTrackLifetimeMs > 0) {
1408
+ const alive = [];
1409
+ for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1410
+ track.lost = true;
1411
+ track.lostAt = timestamp;
1412
+ track.resurrectable = false;
1413
+ this.lostTracks.push(track);
1414
+ } else alive.push(track);
1415
+ this.tracks = alive;
1416
+ }
1417
+ this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1418
+ const used = /* @__PURE__ */ new Set();
1419
+ const matchedTracks = /* @__PURE__ */ new Set();
1420
+ const matched = /* @__PURE__ */ new Map();
1421
+ const highIdxs = [];
1422
+ const lowIdxs = [];
1423
+ for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
1424
+ else highIdxs.push(di);
1425
+ const lowSet = new Set(lowIdxs);
1426
+ this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, used, matchedTracks, matched);
1227
1427
  const rescuePairs = [];
1228
1428
  for (const track of this.tracks) {
1229
1429
  if (matchedTracks.has(track)) continue;
1230
1430
  for (let di = 0; di < detections.length; di++) {
1231
- if (used.has(di)) continue;
1431
+ if (used.has(di) || lowSet.has(di)) continue;
1232
1432
  const det = detections[di];
1233
1433
  if (!this.looseMatch(track, det)) continue;
1234
1434
  rescuePairs.push({
@@ -1245,6 +1445,7 @@ var SortTracker = class {
1245
1445
  matchedTracks.add(pair.track);
1246
1446
  used.add(pair.detIdx);
1247
1447
  }
1448
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
1248
1449
  for (const [track, det] of matched) {
1249
1450
  const prevCenter = bboxCentroid({
1250
1451
  x: track.bbox.x,
@@ -1272,6 +1473,7 @@ var SortTracker = class {
1272
1473
  };
1273
1474
  track.path.push(det.bbox);
1274
1475
  if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
1476
+ this.addClassVote(track, det);
1275
1477
  }
1276
1478
  const occluderBoxes = [];
1277
1479
  for (const track of matchedTracks) occluderBoxes.push(track.bbox);
@@ -1321,13 +1523,34 @@ var SortTracker = class {
1321
1523
  };
1322
1524
  best.path.push(det.bbox);
1323
1525
  if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
1526
+ this.addClassVote(best, det);
1324
1527
  surviving.push(best);
1325
1528
  used.add(di);
1326
1529
  }
1327
- for (let di = 0; di < detections.length; di++) {
1328
- if (used.has(di)) continue;
1530
+ const unmatchedIdx = [];
1531
+ for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
1532
+ const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
1533
+ unmatchedIdx.sort((ia, ib) => {
1534
+ const da = detections[ia];
1535
+ const db = detections[ib];
1536
+ const r = classRank(da.class) - classRank(db.class);
1537
+ return r !== 0 ? r : db.score - da.score;
1538
+ });
1539
+ for (const di of unmatchedIdx) {
1329
1540
  const det = detections[di];
1541
+ if (!this.meetsClassScoreFloor(det)) {
1542
+ used.add(di);
1543
+ continue;
1544
+ }
1545
+ if (!this.meetsSpawnAreaFloor(det, frameArea)) {
1546
+ used.add(di);
1547
+ continue;
1548
+ }
1330
1549
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1550
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1551
+ used.add(di);
1552
+ continue;
1553
+ }
1331
1554
  surviving.push({
1332
1555
  id: randomUUID(),
1333
1556
  bbox: det.bbox,
@@ -1345,19 +1568,22 @@ var SortTracker = class {
1345
1568
  },
1346
1569
  lost: false,
1347
1570
  lostAt: 0,
1348
- resurrectable: true
1571
+ resurrectable: true,
1572
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
1349
1573
  });
1350
1574
  }
1351
1575
  this.tracks = surviving;
1352
- return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
1353
- class: t.class,
1576
+ this.resolveDuplicates();
1577
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
1578
+ class: this.resolveReportedClass(t),
1354
1579
  originalClass: t.originalClass,
1355
1580
  score: t.score,
1356
1581
  bbox: t.bbox,
1357
1582
  trackId: t.id,
1358
1583
  trackAge: t.hits,
1359
1584
  velocity: t.velocity,
1360
- path: [...t.path]
1585
+ path: [...t.path],
1586
+ matchedThisFrame: t.lastSeen === timestamp
1361
1587
  }));
1362
1588
  }
1363
1589
  /**
@@ -1379,6 +1605,7 @@ var SortTracker = class {
1379
1605
  reset() {
1380
1606
  this.tracks = [];
1381
1607
  this.lostTracks = [];
1608
+ this.dupStreaks.clear();
1382
1609
  }
1383
1610
  };
1384
1611
  //#endregion
@@ -1938,7 +2165,10 @@ var FrameProcessor = class {
1938
2165
  wokenEntryIds: []
1939
2166
  };
1940
2167
  const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1941
- const trackedDetections = this.tracker.update(trackerInput, timestamp);
2168
+ const trackedDetections = this.tracker.update(trackerInput, timestamp, {
2169
+ frameWidth,
2170
+ frameHeight
2171
+ });
1942
2172
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1943
2173
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1944
2174
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1967,6 +2197,7 @@ var FrameProcessor = class {
1967
2197
  bbox: { ...td.bbox },
1968
2198
  zones: zonesByTrack.get(td.trackId) ?? [],
1969
2199
  state,
2200
+ matchedThisFrame: td.matchedThisFrame !== false,
1970
2201
  ...label ? { label } : {},
1971
2202
  ...emb !== void 0 ? {
1972
2203
  embedding: emb.embedding,
@@ -4239,6 +4470,26 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
4239
4470
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
4240
4471
  * of a set of tracks without a `trackId` column on media rows. */
4241
4472
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
4473
+ /**
4474
+ * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
4475
+ * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
4476
+ * DETERMINISTIC (`owner:kind`, NO timestamp) and written via an UPSERT
4477
+ * (`store.set`, atomic `INSERT … ON CONFLICT DO UPDATE`), so N concurrent
4478
+ * captures for the same `(track, kind)` all address the SAME row/blob — one can
4479
+ * never become thirteen regardless of interleaving (RC-1). Accumulating kinds
4480
+ * (`snapshot`, event `crop`/`fullFrame*`, `faceCrop`/`plateCrop`) keep their
4481
+ * per-instance timestamped id so the filmstrip / per-event / per-enrollment rows
4482
+ * still accumulate.
4483
+ */
4484
+ var SINGLE_INSTANCE_KINDS = new Set([
4485
+ "keyFrame",
4486
+ "thumbnail",
4487
+ "firstFrame",
4488
+ "lastFrame"
4489
+ ]);
4490
+ function isSingleInstanceKind(kind) {
4491
+ return SINGLE_INSTANCE_KINDS.has(kind);
4492
+ }
4242
4493
  var MEDIA_COLUMNS = [
4243
4494
  {
4244
4495
  name: "id",
@@ -4290,10 +4541,11 @@ var MEDIA_INDEXES = [{
4290
4541
  columns: ["deviceId", "timestamp"]
4291
4542
  }];
4292
4543
  function buildKey(params) {
4293
- return `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4544
+ return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4294
4545
  }
4295
4546
  function buildPath(params) {
4296
- return `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}/${params.kind}-${params.timestamp}.jpg`;
4547
+ const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
4548
+ return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
4297
4549
  }
4298
4550
  var MediaStore = class {
4299
4551
  storage;
@@ -4316,25 +4568,31 @@ var MediaStore = class {
4316
4568
  async put(params) {
4317
4569
  const key = buildKey(params);
4318
4570
  const path = buildPath(params);
4571
+ const record = {
4572
+ deviceId: params.deviceId,
4573
+ ownerKind: params.ownerKind,
4574
+ ownerId: params.ownerId,
4575
+ kind: params.kind,
4576
+ timestamp: params.timestamp,
4577
+ path,
4578
+ sizeBytes: params.data.length
4579
+ };
4319
4580
  try {
4320
4581
  await this.storage.write({
4321
4582
  location: "eventMedia",
4322
4583
  relativePath: path,
4323
4584
  data: params.data
4324
4585
  });
4325
- await this.store.insert.mutate({
4586
+ if (isSingleInstanceKind(params.kind)) await this.store.set.mutate({
4587
+ collection: MEDIA_COLLECTION,
4588
+ key,
4589
+ value: record
4590
+ });
4591
+ else await this.store.insert.mutate({
4326
4592
  collection: MEDIA_COLLECTION,
4327
4593
  record: {
4328
4594
  id: key,
4329
- data: {
4330
- deviceId: params.deviceId,
4331
- ownerKind: params.ownerKind,
4332
- ownerId: params.ownerId,
4333
- kind: params.kind,
4334
- timestamp: params.timestamp,
4335
- path,
4336
- sizeBytes: params.data.length
4337
- }
4595
+ data: record
4338
4596
  }
4339
4597
  });
4340
4598
  return key;
@@ -4353,8 +4611,14 @@ var MediaStore = class {
4353
4611
  * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
4354
4612
  * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
4355
4613
  * each new capture replaces the previous one (blob + index row) rather than
4356
- * accumulating a filmstrip the way `put` does. Deletes any existing rows of
4357
- * that (owner, kind) first, then writes the fresh one. Returns the new key.
4614
+ * accumulating a filmstrip the way `put` does.
4615
+ *
4616
+ * These kinds are {@link SINGLE_INSTANCE_KINDS}, so `put` writes them to a
4617
+ * DETERMINISTIC `owner:kind` row/blob via an UPSERT — N concurrent racing
4618
+ * calls therefore all collapse onto the SAME row (RC-1), no query/insert/delete
4619
+ * interleaving can leave survivors. The query+delete pass below is retained
4620
+ * ONLY to reap LEGACY timestamped rows/blobs written before the deterministic
4621
+ * scheme (transition back-compat); once migrated it is a no-op.
4358
4622
  */
4359
4623
  async putReplacing(params) {
4360
4624
  const existing = await this.store.query.query({
@@ -6096,6 +6360,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
6096
6360
  h: Math.round(ch)
6097
6361
  };
6098
6362
  }
6363
+ /**
6364
+ * The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
6365
+ * NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
6366
+ *
6367
+ * A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
6368
+ * aspect ratio (the native crop path downscales while preserving aspect), so the
6369
+ * region computed from the detection frame's dimensions addresses the exact same
6370
+ * ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
6371
+ * (single source of truth) and divides by the frame dimensions.
6372
+ */
6373
+ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6374
+ const region = squareSafeCropRegion(bbox, frame, padding);
6375
+ return {
6376
+ x: region.x / frame.W,
6377
+ y: region.y / frame.H,
6378
+ w: region.w / frame.W,
6379
+ h: region.h / frame.H
6380
+ };
6381
+ }
6099
6382
  //#endregion
6100
6383
  //#region src/shared/frame/box-drawer.ts
6101
6384
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
@@ -6173,10 +6456,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
6173
6456
  * Small downscaled `thumbnail`s are intentionally left out for now — when we
6174
6457
  * reintroduce them they'll be a separate small kind. */
6175
6458
  var MEDIA_QUALITY = 88;
6176
- /** Output dimensions for square-safe 16:9 crops (crop/faceCrop/plateCrop). */
6459
+ /** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
6460
+ * (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
6177
6461
  var CROP_WIDTH = 640;
6178
6462
  var CROP_HEIGHT = 360;
6179
6463
  var CROP_QUALITY = 80;
6464
+ /**
6465
+ * Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
6466
+ * `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
6467
+ * ≥224px classifier input straight from the runner's native surface, WITHOUT
6468
+ * hauling a full 1920px frame per subject (that width is reserved for the
6469
+ * full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
6470
+ * the ≤640 local crop, so quality never regresses below today's behaviour.
6471
+ */
6472
+ var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
6180
6473
  function caption(className, confidence, label) {
6181
6474
  const base = label && label !== className ? `${className} ${label}` : className;
6182
6475
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
@@ -6283,12 +6576,12 @@ var EventMediaDispatcher = class {
6283
6576
  });
6284
6577
  return empty;
6285
6578
  }
6286
- for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6579
+ for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
6287
6580
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6288
6581
  const storedSnapshots = [];
6289
6582
  const thumbnailTrackIds = [];
6290
6583
  for (const sn of snapshots) {
6291
- const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6584
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6292
6585
  if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6293
6586
  if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6294
6587
  }
@@ -6309,7 +6602,7 @@ var EventMediaDispatcher = class {
6309
6602
  * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6310
6603
  * landed this frame (#27-A) so the caller can stop forcing retries.
6311
6604
  */
6312
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6605
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6313
6606
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6314
6607
  storedSnapshot: null,
6315
6608
  thumbnailWritten: false
@@ -6350,7 +6643,7 @@ var EventMediaDispatcher = class {
6350
6643
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6351
6644
  let thumbnailWritten = false;
6352
6645
  if (sn.bestThumbnail) try {
6353
- const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6646
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
6354
6647
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6355
6648
  } catch (err) {
6356
6649
  this.deps.logger.warn("event media: track thumbnail crop failed", {
@@ -6369,12 +6662,38 @@ var EventMediaDispatcher = class {
6369
6662
  };
6370
6663
  }
6371
6664
  /**
6372
- * Clean subject-centered crop of `bbox` out of the raw frame the shared
6373
- * output contract of the object-event `crop` kind and the track `thumbnail`:
6374
- * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
6375
- * (no box drawn), resized to 640×360, JPEG q80.
6665
+ * Clean subject-centered crop of `bbox` the shared output contract of the
6666
+ * object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
6667
+ * `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
6668
+ *
6669
+ * NATIVE-FIRST: the region is requested from the runner's retained native
6670
+ * surface (normalized [0,1] coords map directly onto it), downscaled to
6671
+ * {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
6672
+ * miss/error (or a runner without the method) it FALLS BACK to cropping the
6673
+ * resolved ≤640 frame locally (today's behaviour), so quality never regresses.
6674
+ * Both paths run inside the live-handle window opened by `captureForFrame`.
6675
+ */
6676
+ async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
6677
+ if (this.deps.getNativeCropJpeg) try {
6678
+ const norm = squareSafeCropRegionNormalized(bbox, {
6679
+ W: fw,
6680
+ H: fh
6681
+ }, cropPadding);
6682
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
6683
+ if (native) return native;
6684
+ } catch (err) {
6685
+ this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
6686
+ shmId: frameHandle.shmId,
6687
+ error: err instanceof Error ? err.message : String(err)
6688
+ } });
6689
+ }
6690
+ return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
6691
+ }
6692
+ /**
6693
+ * Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
6694
+ * to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
6376
6695
  */
6377
- async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
6696
+ async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
6378
6697
  const region = squareSafeCropRegion(bbox, {
6379
6698
  W: fw,
6380
6699
  H: fh
@@ -6417,13 +6736,13 @@ var EventMediaDispatcher = class {
6417
6736
  return false;
6418
6737
  }
6419
6738
  }
6420
- async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
6739
+ async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
6421
6740
  const box = {
6422
6741
  ...ev.bbox,
6423
6742
  label: caption(ev.className, ev.confidence, ev.label)
6424
6743
  };
6425
6744
  try {
6426
- const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
6745
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
6427
6746
  await this.deps.mediaStore.put({
6428
6747
  deviceId,
6429
6748
  ownerKind: "event",
@@ -6483,24 +6802,7 @@ var EventMediaDispatcher = class {
6483
6802
  });
6484
6803
  }
6485
6804
  if (ev.childCrops) for (const child of ev.childCrops) try {
6486
- const childRegion = squareSafeCropRegion(child.bbox, {
6487
- W: fw,
6488
- H: fh
6489
- }, cropPadding);
6490
- const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
6491
- const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
6492
- const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
6493
- const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
6494
- const childCropData = await sharp(frameData, { raw: {
6495
- width: fw,
6496
- height: fh,
6497
- channels: 3
6498
- } }).extract({
6499
- left: childLeft,
6500
- top: childTop,
6501
- width: childWidth,
6502
- height: childHeight
6503
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
6805
+ const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
6504
6806
  await this.deps.mediaStore.put({
6505
6807
  deviceId,
6506
6808
  ownerKind: "event",
@@ -7661,11 +7963,89 @@ var TrackingSettingsSchema = object({
7661
7963
  /** Speed (px/frame) below which a track's prediction is frozen (stationary
7662
7964
  * jitter can't drift the box off a sitting object). */
7663
7965
  stationarySpeedPx: number().min(0).default(2),
7966
+ /** Suppress + merge concurrent duplicate tracks (one subject the detector
7967
+ * double-fires, or a person misdetected as `animal`, otherwise becomes two
7968
+ * time-overlapping tracks that never re-associate — each firing its own
7969
+ * event). Off = legacy behaviour. */
7970
+ dedupEnabled: boolean().default(true),
7971
+ /** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
7972
+ * concurrent compatible-class track and suppressed. High so distinct
7973
+ * subjects appearing close together are not collapsed. */
7974
+ dedupSpawnIou: number().min(0).max(1).default(.6),
7975
+ /** Envelope IoU at/above which two concurrent tracks count as overlapping for
7976
+ * the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
7977
+ * merged duplicate cannot re-spawn. */
7978
+ dedupMergeIou: number().min(0).max(1).default(.6),
7979
+ /** Consecutive overlapping frames before two live tracks are merged (the
7980
+ * lower-importance one dropped). Higher = more conservative. */
7981
+ dedupMergeFrames: number().int().min(1).default(5),
7982
+ /** Treat the {person,animal} class pair as duplicate-compatible — a person in
7983
+ * a non-upright pose is misdetected as `animal`; the false animal track
7984
+ * collapses into the real person track. */
7985
+ personAnimalDedup: boolean().default(true),
7986
+ /** A low-confidence `animal` (score below this) overlapping a concurrent
7987
+ * person track is suppressed at spawn. */
7988
+ animalOverPersonMaxScore: number().min(0).max(1).default(.6),
7989
+ /** IoU with a concurrent person track that triggers the low-confidence animal
7990
+ * spawn suppression. */
7991
+ animalOverPersonIou: number().min(0).max(1).default(.2),
7992
+ /** Resolve a track's reported class by a confidence-weighted majority over its
7993
+ * lifetime (vs. the latest frame) — kills per-frame class flips (a person
7994
+ * read as `animal` on one crouch frame keeps the `person` label). */
7995
+ classVotingEnabled: boolean().default(true),
7996
+ /** Winning class must hold at least this fraction of a track's total vote
7997
+ * weight to override the latest-frame class; below it the latest wins (so a
7998
+ * genuine mid-life reclassification is never frozen out). */
7999
+ classVoteMinFraction: number().min(0).max(1).default(.5),
8000
+ /** Enforce a per-class minimum detection score at track SPAWN (an established
8001
+ * track still matches below its floor — only new spawns are gated). */
8002
+ perClassMinScoreEnabled: boolean().default(true),
8003
+ /** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
8004
+ minScorePerson: number().min(0).max(1).default(0),
8005
+ /** Minimum spawn score for `animal` (FP-prone — higher floor). */
8006
+ minScoreAnimal: number().min(0).max(1).default(.45),
8007
+ /** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
8008
+ minScoreVehicle: number().min(0).max(1).default(.45),
8009
+ /** Let a single very-high-confidence detection confirm a track for emission
8010
+ * before it reaches `minHits` (so a fast car crossing in 1-2 frames still
8011
+ * registers). Opt-in — default OFF keeps the strict N-hit gate. */
8012
+ confirmBypassEnabled: boolean().default(false),
8013
+ /** Score at/above which a detection confirms its track immediately (bypasses
8014
+ * `minHits`). Only consulted when `confirmBypassEnabled`. */
8015
+ confirmBypassScore: number().min(0).max(1).default(.9),
7664
8016
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
7665
8017
  dropoutSkipEnabled: boolean().default(true),
7666
8018
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
7667
8019
  * treated as genuinely empty. */
7668
- dropoutMaxSkipFrames: number().int().min(0).default(5)
8020
+ dropoutMaxSkipFrames: number().int().min(0).default(5),
8021
+ /** Two-stage (ByteTrack) association: match high-confidence detections first,
8022
+ * then recover coasting tracks with the low-confidence leftovers before they
8023
+ * die. The single biggest fragmentation reducer. Off = legacy single pass. */
8024
+ byteTrackEnabled: boolean().default(true),
8025
+ /** Score at/above which a detection is HIGH (matched first); below it a detection
8026
+ * is LOW and only gets the second-stage recovery match. Does not gate spawning
8027
+ * (the per-class score/area floors do). */
8028
+ byteTrackHighThreshold: number().min(0).max(1).default(.4),
8029
+ /** Looser IoU gate for the low-score second (recovery) association stage — a
8030
+ * coasting box is stale, and the match only re-attaches an existing track. */
8031
+ byteTrackLowIouThreshold: number().min(0).max(1).default(.2),
8032
+ /** Enforce a per-class minimum bbox-area (fraction of frame) at track SPAWN —
8033
+ * kills tiny far-field / vanishing-point noise tracks. An established track
8034
+ * still matches a shrinking box; only spawns are gated. */
8035
+ minSpawnAreaEnabled: boolean().default(true),
8036
+ /** Min spawn bbox area for `person` as a fraction of the frame (0 = ungated).
8037
+ * Person is the only spawn-score-ungated class, so far-field person noise is
8038
+ * the worst offender; a conservative 0.05% rejects only vanishing-point blips. */
8039
+ minSpawnAreaFracPerson: number().min(0).max(1).default(5e-4),
8040
+ /** Min spawn bbox area for `animal` as a fraction of the frame (0 = ungated). */
8041
+ minSpawnAreaFracAnimal: number().min(0).max(1).default(0),
8042
+ /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
8043
+ minSpawnAreaFracVehicle: number().min(0).max(1).default(0),
8044
+ /** Gate association on a class GROUP ({person,animal}) instead of the exact
8045
+ * per-frame class, so a person that flips to `animal` for a frame re-matches
8046
+ * its existing track (no concurrent animal id). Reported label stays via the
8047
+ * class vote. Off = exact per-frame class gating. */
8048
+ classGroupAssoc: boolean().default(true)
7669
8049
  });
7670
8050
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
7671
8051
  /**
@@ -7695,8 +8075,31 @@ function resolveTrackingSettings(raw) {
7695
8075
  rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
7696
8076
  resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
7697
8077
  stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
8078
+ dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
8079
+ dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
8080
+ dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
8081
+ dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
8082
+ personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
8083
+ animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
8084
+ animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
8085
+ classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
8086
+ classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
8087
+ perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
8088
+ minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
8089
+ minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
8090
+ minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
8091
+ confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
8092
+ confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7698
8093
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7699
- dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
8094
+ dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames),
8095
+ byteTrackEnabled: s.byteTrackEnabled.catch(TRACKING_DEFAULTS.byteTrackEnabled).parse(raw.byteTrackEnabled),
8096
+ byteTrackHighThreshold: s.byteTrackHighThreshold.catch(TRACKING_DEFAULTS.byteTrackHighThreshold).parse(raw.byteTrackHighThreshold),
8097
+ byteTrackLowIouThreshold: s.byteTrackLowIouThreshold.catch(TRACKING_DEFAULTS.byteTrackLowIouThreshold).parse(raw.byteTrackLowIouThreshold),
8098
+ minSpawnAreaEnabled: s.minSpawnAreaEnabled.catch(TRACKING_DEFAULTS.minSpawnAreaEnabled).parse(raw.minSpawnAreaEnabled),
8099
+ minSpawnAreaFracPerson: s.minSpawnAreaFracPerson.catch(TRACKING_DEFAULTS.minSpawnAreaFracPerson).parse(raw.minSpawnAreaFracPerson),
8100
+ minSpawnAreaFracAnimal: s.minSpawnAreaFracAnimal.catch(TRACKING_DEFAULTS.minSpawnAreaFracAnimal).parse(raw.minSpawnAreaFracAnimal),
8101
+ minSpawnAreaFracVehicle: s.minSpawnAreaFracVehicle.catch(TRACKING_DEFAULTS.minSpawnAreaFracVehicle).parse(raw.minSpawnAreaFracVehicle),
8102
+ classGroupAssoc: s.classGroupAssoc.catch(TRACKING_DEFAULTS.classGroupAssoc).parse(raw.classGroupAssoc)
7700
8103
  };
7701
8104
  }
7702
8105
  //#endregion
@@ -8103,6 +8506,40 @@ function planPeriodicMedia(input) {
8103
8506
  };
8104
8507
  }
8105
8508
  //#endregion
8509
+ //#region src/pipeline-analytics/best-thumbnail-guard.ts
8510
+ /**
8511
+ * Void/envArea guard for best-`thumbnail` selection.
8512
+ *
8513
+ * ## Why this exists (the dawn/night "void" thumbnail)
8514
+ *
8515
+ * At dawn/night a moving subject's tracker box intermittently EXPLODES to
8516
+ * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
8517
+ * that frame happens to win the best-detection race, the gallery/reel best
8518
+ * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
8519
+ * washed-out frame), not the subject. This guard rejects such a frame from the
8520
+ * best-`thumbnail` decision so the track keeps a real subject-centered tile.
8521
+ *
8522
+ * Conservative by design: it only rejects boxes covering ≥ {@link
8523
+ * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
8524
+ * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
8525
+ * per-frame retry keeps trying until a plausible frame wins.
8526
+ */
8527
+ /**
8528
+ * Area fraction at/above which a detection bbox is treated as an exploded
8529
+ * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
8530
+ * guard conservative — only boxes covering ≥85% of the frame are rejected.
8531
+ */
8532
+ var NEAR_FULL_FRAME_AREA = .85;
8533
+ /**
8534
+ * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
8535
+ * area is below the near-full-frame threshold. Degenerate frame dimensions
8536
+ * (≤0) are treated as plausible (no info to reject on).
8537
+ */
8538
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8539
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
8540
+ return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
8541
+ }
8542
+ //#endregion
8106
8543
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
8107
8544
  /**
8108
8545
  * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
@@ -8134,11 +8571,12 @@ function planPeriodicMedia(input) {
8134
8571
  var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8135
8572
  /**
8136
8573
  * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
8137
- * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
8138
- * downstream keeps one `keyFrame` per track (the current peak).
8574
+ * the tracks that hit a GENUINE new best-frame moment (`keyFrame`), NOT the
8575
+ * per-frame best-thumbnail retry. `putReplacing` downstream keeps one `keyFrame`
8576
+ * per track (the current peak).
8139
8577
  */
8140
8578
  function selectKeyFrameTrackIds(targets) {
8141
- return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
8579
+ return targets.filter((t) => t.keyFrame).map((t) => t.trackId);
8142
8580
  }
8143
8581
  /**
8144
8582
  * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
@@ -8158,6 +8596,15 @@ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
8158
8596
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
8159
8597
  };
8160
8598
  }
8599
+ /**
8600
+ * True when a native subject crop of `width` px is a plausible native hit worth
8601
+ * keeping, rather than a sub-threshold fallback stamp. Clamps the floor to the
8602
+ * requested `maxWidth` so a caller that legitimately asked for a narrow crop
8603
+ * (`maxWidth < MIN`) is not rejected for honouring its own cap.
8604
+ */
8605
+ function nativeSubjectCropMeetsFloor(width, maxWidth) {
8606
+ return width >= Math.min(320, maxWidth);
8607
+ }
8161
8608
  //#endregion
8162
8609
  //#region src/pipeline-analytics/track-retention-sweep.ts
8163
8610
  /**
@@ -9240,6 +9687,7 @@ var FaceRecognizer = class {
9240
9687
  bbox: input.parentBbox,
9241
9688
  zones: [],
9242
9689
  state: "moving",
9690
+ matchedThisFrame: true,
9243
9691
  embedding: input.embedding,
9244
9692
  embeddingModelId: modelId,
9245
9693
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -11410,6 +11858,35 @@ function toAnalyticsDeviceSections(sections) {
11410
11858
  }));
11411
11859
  }
11412
11860
  /**
11861
+ * Global-analytics section ids that are really per-camera DETECTION knobs and
11862
+ * belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
11863
+ * Object Detection — NOT under the generic `Analytics` top-tab:
11864
+ * - `detection-sensitivity` — minHits / cooldown / stationary threshold.
11865
+ * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
11866
+ * animal dedup, class voting, confirm-bypass, per-class min score).
11867
+ * - `stationary-objects` — stationary promotion + occupancy tuning.
11868
+ * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
11869
+ * structural Detection pipeline tab, so re-tagging is all that's needed —
11870
+ * there is no admin-ui change and no duplicate render (a section has one tab).
11871
+ */
11872
+ var DETECTION_PIPELINE_SECTION_IDS = new Set([
11873
+ "detection-sensitivity",
11874
+ "tracking",
11875
+ "stationary-objects"
11876
+ ]);
11877
+ /**
11878
+ * Re-home the detection-knob sections from the `Analytics` top-tab onto the
11879
+ * `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
11880
+ * section changes; every other section (media policy, retention, faces, track
11881
+ * history) stays on Analytics.
11882
+ */
11883
+ function retagDetectionSections(sections) {
11884
+ return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
11885
+ ...s,
11886
+ tab: "detection-pipeline"
11887
+ } : s);
11888
+ }
11889
+ /**
11413
11890
  * Fields that live ONLY on the global settings page and must never surface in a
11414
11891
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
11415
11892
  * master kill for the whole subsystem — per-camera face production is governed
@@ -11566,6 +12043,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11566
12043
  * (recycled/blank live frame) still gets a subject crop for the gallery
11567
12044
  * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11568
12045
  thumbnailLandedTracks = /* @__PURE__ */ new Set();
12046
+ /** Tracks with a best-`thumbnail` capture CURRENTLY in flight (RC-1). The
12047
+ * #27-A retry re-fired a fresh best-thumbnail capture every frame while one
12048
+ * was still resolving (capture latency stacks 0.1–3s under the native path),
12049
+ * so a short track issued N overlapping captures. While a track sits here
12050
+ * `buildSnapshotTargets` suppresses a new best-thumbnail request; the pending
12051
+ * result clears the flag (and, if it landed, sets `thumbnailLandedTracks`).
12052
+ * Cleared on track end + reset. */
12053
+ thumbnailInFlight = /* @__PURE__ */ new Set();
12054
+ /** Tracks with a native `keyFrame` capture CURRENTLY in flight (RC-1). Guards
12055
+ * the fire-and-forget `persistKeyFrames` so a burst of new-best frames issues
12056
+ * at most ONE outstanding 1920px native capture per track instead of one per
12057
+ * frame. Cleared when the capture settles (+ on track end / reset). */
12058
+ keyFrameInFlight = /* @__PURE__ */ new Set();
11569
12059
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11570
12060
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11571
12061
  * area) + emit time, so a material improvement is measured against the
@@ -11791,11 +12281,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11791
12281
  timestamp: 0
11792
12282
  };
11793
12283
  };
11794
- this.eventMediaDispatcher = new EventMediaDispatcher({
11795
- getRemoteFrame,
11796
- mediaStore: this.mediaStore,
11797
- logger: logger.child("EventMediaDispatcher")
11798
- });
11799
12284
  const cropMetricLogger = logger.child("NativeCrop");
11800
12285
  let nativeHits = 0;
11801
12286
  let nativeFallbacks = 0;
@@ -11812,7 +12297,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11812
12297
  detectionFrameFallbacks: nativeFallbacks
11813
12298
  } });
11814
12299
  };
11815
- const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12300
+ const fetchNativeCropRgb = async (frameHandle, paddedNorm, maxWidth) => {
11816
12301
  if (!pipelineRunnerApi?.getNativeCrop) return null;
11817
12302
  try {
11818
12303
  const native = await pipelineRunnerApi.getNativeCrop.query({
@@ -11821,12 +12306,42 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11821
12306
  ...maxWidth !== void 0 ? { maxWidth } : {}
11822
12307
  }, nodePin(frameHandle.nodeId));
11823
12308
  if (!native || native.width <= 0 || native.height <= 0) return null;
11824
- return await encodeRgbCropToJpeg(Buffer.from(native.bytes), native.width, native.height);
12309
+ return {
12310
+ bytes: Buffer.from(native.bytes),
12311
+ width: native.width,
12312
+ height: native.height
12313
+ };
11825
12314
  } catch (err) {
11826
12315
  cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: errMsg(err) } });
11827
12316
  return null;
11828
12317
  }
11829
12318
  };
12319
+ const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12320
+ const native = await fetchNativeCropRgb(frameHandle, paddedNorm, maxWidth);
12321
+ if (!native) return null;
12322
+ return await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12323
+ };
12324
+ const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12325
+ const native = await fetchNativeCropRgb(frameHandle, normalizedBbox, maxWidth);
12326
+ if (!native || !nativeSubjectCropMeetsFloor(native.width, maxWidth)) {
12327
+ if (native) cropMetricLogger.debug("native subject crop below floor — local fallback", { meta: {
12328
+ width: native.width,
12329
+ height: native.height,
12330
+ maxWidth
12331
+ } });
12332
+ bumpCropMetric(false);
12333
+ return null;
12334
+ }
12335
+ const jpeg = await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12336
+ bumpCropMetric(jpeg !== null);
12337
+ return jpeg;
12338
+ };
12339
+ this.eventMediaDispatcher = new EventMediaDispatcher({
12340
+ getRemoteFrame,
12341
+ getNativeCropJpeg,
12342
+ mediaStore: this.mediaStore,
12343
+ logger: logger.child("EventMediaDispatcher")
12344
+ });
11830
12345
  const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
11831
12346
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
11832
12347
  const paddedNorm = padBbox({
@@ -12370,6 +12885,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12370
12885
  this.bestFrameTracker.clear();
12371
12886
  this.lastFrameAtByTrack.clear();
12372
12887
  this.thumbnailLandedTracks.clear();
12888
+ this.thumbnailInFlight.clear();
12889
+ this.keyFrameInFlight.clear();
12373
12890
  this.trackLifecycleUpdateMem.clear();
12374
12891
  this.objectEmbeddingBestSelector.clear();
12375
12892
  this.levelStateByDevice.clear();
@@ -12690,6 +13207,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12690
13207
  frames: captureAgg.frames,
12691
13208
  ...captureAgg.sums
12692
13209
  } });
13210
+ const thumbInFlightTrackIds = snapshotTargets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
13211
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.add(trackId);
12693
13212
  this.eventMediaDispatcher.captureForFrame({
12694
13213
  deviceId,
12695
13214
  frameHandle,
@@ -12709,7 +13228,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12709
13228
  mediaKey: s.mediaKey
12710
13229
  });
12711
13230
  for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
12712
- }).catch(() => {});
13231
+ }).catch(() => {}).finally(() => {
13232
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.delete(trackId);
13233
+ });
12713
13234
  }
12714
13235
  }
12715
13236
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -13154,7 +13675,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13154
13675
  const mediaStore = this.mediaStore;
13155
13676
  if (!capture || !mediaStore) return;
13156
13677
  const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
13157
- await Promise.all(trackIds.map(async (trackId) => {
13678
+ const pending = trackIds.filter((id) => !this.keyFrameInFlight.has(id));
13679
+ if (pending.length === 0) return;
13680
+ for (const id of pending) this.keyFrameInFlight.add(id);
13681
+ await Promise.all(pending.map(async (trackId) => {
13158
13682
  try {
13159
13683
  const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
13160
13684
  if (!keyFrame) return;
@@ -13175,6 +13699,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13175
13699
  error: errMsg(err)
13176
13700
  }
13177
13701
  });
13702
+ } finally {
13703
+ this.keyFrameInFlight.delete(trackId);
13178
13704
  }
13179
13705
  }));
13180
13706
  }
@@ -13236,6 +13762,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13236
13762
  buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
13237
13763
  const targets = [];
13238
13764
  for (const t of tracked) {
13765
+ if (t.matchedThisFrame === false) continue;
13239
13766
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
13240
13767
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
13241
13768
  lastSnapshotAt: lastSnap,
@@ -13267,7 +13794,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13267
13794
  });
13268
13795
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13269
13796
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13270
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
13797
+ const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13798
+ const bestThumbnail = plan.bestThumbnail && plausibleBox && !this.thumbnailInFlight.has(t.trackId);
13799
+ const keyFrame = isNewBest && plausibleBox;
13800
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail && !keyFrame) continue;
13271
13801
  targets.push({
13272
13802
  trackId: t.trackId,
13273
13803
  timestamp,
@@ -13275,7 +13805,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13275
13805
  ...t.label ? { label: t.label } : {},
13276
13806
  appendSnapshot: plan.appendSnapshot,
13277
13807
  rollingLastFrame: plan.rollingLastFrame,
13278
- bestThumbnail: plan.bestThumbnail
13808
+ bestThumbnail,
13809
+ keyFrame
13279
13810
  });
13280
13811
  }
13281
13812
  return targets;
@@ -13582,6 +14113,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13582
14113
  this.objectEmbeddingBestSelector.delete(t.trackId);
13583
14114
  this.lastFrameAtByTrack.delete(t.trackId);
13584
14115
  this.thumbnailLandedTracks.delete(t.trackId);
14116
+ this.thumbnailInFlight.delete(t.trackId);
14117
+ this.keyFrameInFlight.delete(t.trackId);
13585
14118
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13586
14119
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13587
14120
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -13844,6 +14377,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13844
14377
  this.objectEmbeddingBestSelector.delete(track.trackId);
13845
14378
  this.lastFrameAtByTrack.delete(track.trackId);
13846
14379
  this.thumbnailLandedTracks.delete(track.trackId);
14380
+ this.thumbnailInFlight.delete(track.trackId);
14381
+ this.keyFrameInFlight.delete(track.trackId);
13847
14382
  this.trackLifecycleUpdateMem.delete(track.trackId);
13848
14383
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
13849
14384
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -13877,7 +14412,34 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13877
14412
  rescueCentroidFactor: trk.rescueCentroidFactor,
13878
14413
  resurrectionWindowMs: trk.resurrectionWindowMs,
13879
14414
  stationarySpeedPx: trk.stationarySpeedPx,
13880
- maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
14415
+ maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
14416
+ dedupEnabled: trk.dedupEnabled,
14417
+ dedupSpawnIou: trk.dedupSpawnIou,
14418
+ dedupMergeIou: trk.dedupMergeIou,
14419
+ dedupMergeFrames: trk.dedupMergeFrames,
14420
+ personAnimalDedup: trk.personAnimalDedup,
14421
+ animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
14422
+ animalOverPersonIou: trk.animalOverPersonIou,
14423
+ classVotingEnabled: trk.classVotingEnabled,
14424
+ classVoteMinFraction: trk.classVoteMinFraction,
14425
+ perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
14426
+ classMinScores: {
14427
+ person: trk.minScorePerson,
14428
+ animal: trk.minScoreAnimal,
14429
+ vehicle: trk.minScoreVehicle
14430
+ },
14431
+ confirmBypassEnabled: trk.confirmBypassEnabled,
14432
+ confirmBypassScore: trk.confirmBypassScore,
14433
+ byteTrackEnabled: trk.byteTrackEnabled,
14434
+ byteTrackHighThreshold: trk.byteTrackHighThreshold,
14435
+ byteTrackLowIouThreshold: trk.byteTrackLowIouThreshold,
14436
+ minSpawnAreaEnabled: trk.minSpawnAreaEnabled,
14437
+ classSpawnAreaFracs: {
14438
+ person: trk.minSpawnAreaFracPerson,
14439
+ animal: trk.minSpawnAreaFracAnimal,
14440
+ vehicle: trk.minSpawnAreaFracVehicle
14441
+ },
14442
+ classGroupAssoc: trk.classGroupAssoc
13881
14443
  }, { stationaryThresholdSec }, {
13882
14444
  minTrackAge,
13883
14445
  cooldownSec,
@@ -15180,6 +15742,69 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15180
15742
  default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
15181
15743
  unit: "ms"
15182
15744
  },
15745
+ {
15746
+ type: "boolean",
15747
+ key: "dedupEnabled",
15748
+ label: "Duplicate-track suppression",
15749
+ 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.",
15750
+ default: TRACKING_DEFAULTS.dedupEnabled
15751
+ },
15752
+ {
15753
+ type: "number",
15754
+ key: "dedupSpawnIou",
15755
+ label: "Duplicate spawn IoU",
15756
+ 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.",
15757
+ min: 0,
15758
+ max: 1,
15759
+ step: .05,
15760
+ default: TRACKING_DEFAULTS.dedupSpawnIou
15761
+ },
15762
+ {
15763
+ type: "number",
15764
+ key: "dedupMergeIou",
15765
+ label: "Duplicate merge IoU",
15766
+ description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
15767
+ min: 0,
15768
+ max: 1,
15769
+ step: .05,
15770
+ default: TRACKING_DEFAULTS.dedupMergeIou
15771
+ },
15772
+ {
15773
+ type: "number",
15774
+ key: "dedupMergeFrames",
15775
+ label: "Duplicate merge frames",
15776
+ description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
15777
+ min: 1,
15778
+ step: 1,
15779
+ default: TRACKING_DEFAULTS.dedupMergeFrames
15780
+ },
15781
+ {
15782
+ type: "boolean",
15783
+ key: "personAnimalDedup",
15784
+ label: "Person/animal duplicate merge",
15785
+ 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.",
15786
+ default: TRACKING_DEFAULTS.personAnimalDedup
15787
+ },
15788
+ {
15789
+ type: "number",
15790
+ key: "animalOverPersonMaxScore",
15791
+ label: "Animal-over-person max score",
15792
+ 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.",
15793
+ min: 0,
15794
+ max: 1,
15795
+ step: .05,
15796
+ default: TRACKING_DEFAULTS.animalOverPersonMaxScore
15797
+ },
15798
+ {
15799
+ type: "number",
15800
+ key: "animalOverPersonIou",
15801
+ label: "Animal-over-person IoU",
15802
+ description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
15803
+ min: 0,
15804
+ max: 1,
15805
+ step: .05,
15806
+ default: TRACKING_DEFAULTS.animalOverPersonIou
15807
+ },
15183
15808
  {
15184
15809
  type: "boolean",
15185
15810
  key: "dropoutSkipEnabled",
@@ -15195,6 +15820,148 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15195
15820
  min: 0,
15196
15821
  step: 1,
15197
15822
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
15823
+ },
15824
+ {
15825
+ type: "boolean",
15826
+ key: "classVotingEnabled",
15827
+ label: "Per-track class voting",
15828
+ 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.",
15829
+ default: TRACKING_DEFAULTS.classVotingEnabled
15830
+ },
15831
+ {
15832
+ type: "number",
15833
+ key: "classVoteMinFraction",
15834
+ label: "Class-vote min fraction",
15835
+ 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).",
15836
+ min: 0,
15837
+ max: 1,
15838
+ step: .05,
15839
+ default: TRACKING_DEFAULTS.classVoteMinFraction
15840
+ },
15841
+ {
15842
+ type: "boolean",
15843
+ key: "perClassMinScoreEnabled",
15844
+ label: "Per-class spawn confidence",
15845
+ 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.",
15846
+ default: TRACKING_DEFAULTS.perClassMinScoreEnabled
15847
+ },
15848
+ {
15849
+ type: "number",
15850
+ key: "minScorePerson",
15851
+ label: "Min spawn score — person",
15852
+ description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
15853
+ min: 0,
15854
+ max: 1,
15855
+ step: .05,
15856
+ default: TRACKING_DEFAULTS.minScorePerson
15857
+ },
15858
+ {
15859
+ type: "number",
15860
+ key: "minScoreAnimal",
15861
+ label: "Min spawn score — animal",
15862
+ description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
15863
+ min: 0,
15864
+ max: 1,
15865
+ step: .05,
15866
+ default: TRACKING_DEFAULTS.minScoreAnimal
15867
+ },
15868
+ {
15869
+ type: "number",
15870
+ key: "minScoreVehicle",
15871
+ label: "Min spawn score — vehicle",
15872
+ description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
15873
+ min: 0,
15874
+ max: 1,
15875
+ step: .05,
15876
+ default: TRACKING_DEFAULTS.minScoreVehicle
15877
+ },
15878
+ {
15879
+ type: "boolean",
15880
+ key: "confirmBypassEnabled",
15881
+ label: "Fast high-confidence confirm",
15882
+ 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).",
15883
+ default: TRACKING_DEFAULTS.confirmBypassEnabled
15884
+ },
15885
+ {
15886
+ type: "number",
15887
+ key: "confirmBypassScore",
15888
+ label: "Fast-confirm score",
15889
+ description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
15890
+ min: 0,
15891
+ max: 1,
15892
+ step: .05,
15893
+ default: TRACKING_DEFAULTS.confirmBypassScore
15894
+ },
15895
+ {
15896
+ type: "boolean",
15897
+ key: "byteTrackEnabled",
15898
+ label: "Two-stage association (ByteTrack)",
15899
+ description: "Match high-confidence detections first, then recover coasting tracks with the low-confidence leftovers before they die. The single biggest fix for a subject fragmenting into many tracks. Off = legacy single pass.",
15900
+ default: TRACKING_DEFAULTS.byteTrackEnabled
15901
+ },
15902
+ {
15903
+ type: "number",
15904
+ key: "byteTrackHighThreshold",
15905
+ label: "Two-stage high threshold",
15906
+ description: "Score at/above which a detection is matched in the first stage. Below it a detection is LOW and gets a second, looser recovery match against still-unmatched (coasting) tracks. This does not change which detections can start a track (the spawn confidence/size floors do). Only used with two-stage association.",
15907
+ min: 0,
15908
+ max: 1,
15909
+ step: .05,
15910
+ default: TRACKING_DEFAULTS.byteTrackHighThreshold
15911
+ },
15912
+ {
15913
+ type: "number",
15914
+ key: "byteTrackLowIouThreshold",
15915
+ label: "Two-stage low IoU",
15916
+ description: "Looser overlap gate for re-attaching a low-confidence detection to a coasting track in the second stage. Only used with two-stage association.",
15917
+ min: 0,
15918
+ max: 1,
15919
+ step: .05,
15920
+ default: TRACKING_DEFAULTS.byteTrackLowIouThreshold
15921
+ },
15922
+ {
15923
+ type: "boolean",
15924
+ key: "minSpawnAreaEnabled",
15925
+ label: "Minimum spawn size",
15926
+ description: "Require a new track’s box to cover at least a minimum fraction of the frame before it is created — stops tiny far-field / vanishing-point blips from spawning full tracks. An already-tracked subject that shrinks into the distance is unaffected.",
15927
+ default: TRACKING_DEFAULTS.minSpawnAreaEnabled
15928
+ },
15929
+ {
15930
+ type: "number",
15931
+ key: "minSpawnAreaFracPerson",
15932
+ label: "Min spawn area — person",
15933
+ description: "Smallest person box (as a fraction of the frame area) allowed to start a NEW track. 0 = ungated. Conservative default (0.0005 = 0.05% of frame) drops only vanishing-point noise. Raise on cameras with a distant road / horizon.",
15934
+ min: 0,
15935
+ max: 1,
15936
+ step: 5e-4,
15937
+ default: TRACKING_DEFAULTS.minSpawnAreaFracPerson
15938
+ },
15939
+ {
15940
+ type: "number",
15941
+ key: "minSpawnAreaFracAnimal",
15942
+ label: "Min spawn area — animal",
15943
+ description: "Smallest animal box (fraction of frame area) allowed to start a NEW track. 0 = ungated (animal already has a spawn-score floor).",
15944
+ min: 0,
15945
+ max: 1,
15946
+ step: 5e-4,
15947
+ default: TRACKING_DEFAULTS.minSpawnAreaFracAnimal
15948
+ },
15949
+ {
15950
+ type: "number",
15951
+ key: "minSpawnAreaFracVehicle",
15952
+ label: "Min spawn area — vehicle",
15953
+ description: "Smallest vehicle box (fraction of frame area) allowed to start a NEW track. 0 = ungated (vehicle already has a spawn-score floor).",
15954
+ min: 0,
15955
+ max: 1,
15956
+ step: 5e-4,
15957
+ default: TRACKING_DEFAULTS.minSpawnAreaFracVehicle
15958
+ },
15959
+ {
15960
+ type: "boolean",
15961
+ key: "classGroupAssoc",
15962
+ label: "Person/animal group matching",
15963
+ description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15964
+ default: TRACKING_DEFAULTS.classGroupAssoc
15198
15965
  }
15199
15966
  ]
15200
15967
  },
@@ -15270,7 +16037,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15270
16037
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
15271
16038
  const baseSections = schema ? hydrateSchema({
15272
16039
  ...schema,
15273
- sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
16040
+ sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
15274
16041
  }, raw).sections : [];
15275
16042
  const liveStatsSection = {
15276
16043
  id: "live-stats",
@@ -15319,4 +16086,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15319
16086
  }
15320
16087
  };
15321
16088
  //#endregion
15322
- export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
16089
+ export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };