@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CThBV9dq.js");
5
+ const require_dist = require("../dist-DUcGHr9E.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -1125,8 +1125,35 @@ var DEFAULT_TRACKER_CONFIG = {
1125
1125
  rescueIouThreshold: .1,
1126
1126
  rescueCentroidFactor: .75,
1127
1127
  resurrectionWindowMs: 8e3,
1128
- stationarySpeedPx: 2
1128
+ stationarySpeedPx: 2,
1129
+ dedupEnabled: true,
1130
+ dedupSpawnIou: .6,
1131
+ dedupMergeIou: .6,
1132
+ dedupMergeFrames: 5,
1133
+ personAnimalDedup: true,
1134
+ animalOverPersonMaxScore: .6,
1135
+ animalOverPersonIou: .2,
1136
+ classVotingEnabled: true,
1137
+ classVoteMinFraction: .5,
1138
+ perClassMinScoreEnabled: true,
1139
+ classMinScores: {
1140
+ animal: .45,
1141
+ vehicle: .45
1142
+ },
1143
+ confirmBypassEnabled: false,
1144
+ confirmBypassScore: .9,
1145
+ byteTrackEnabled: true,
1146
+ byteTrackHighThreshold: .4,
1147
+ byteTrackLowIouThreshold: .2,
1148
+ minSpawnAreaEnabled: true,
1149
+ classSpawnAreaFracs: { person: 5e-4 },
1150
+ classGroupAssoc: true
1129
1151
  };
1152
+ /** Macro-classes the duplicate resolver may collapse across (people in
1153
+ * non-upright poses misdetected as animals). Any other cross-class pair is
1154
+ * NEVER a duplicate. */
1155
+ var PERSON_CLASS = "person";
1156
+ var ANIMAL_CLASS = "animal";
1130
1157
  var MAX_PATH_LENGTH = 300;
1131
1158
  function clamp(value, min, max) {
1132
1159
  return Math.max(min, Math.min(max, value));
@@ -1149,16 +1176,127 @@ function containment(inner, outer) {
1149
1176
  const innerArea = inner.w * inner.h;
1150
1177
  return innerArea > 0 ? iw * ih / innerArea : 0;
1151
1178
  }
1152
- var SortTracker = class {
1179
+ var SortTracker = class SortTracker {
1153
1180
  config;
1154
1181
  tracks = [];
1155
1182
  lostTracks = [];
1183
+ /**
1184
+ * Consecutive-frame overlap streak per unordered live-track pair
1185
+ * (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
1186
+ * merged only once its streak reaches `dedupMergeFrames`. Entries reset the
1187
+ * moment the pair stops overlapping and are pruned when either track dies.
1188
+ */
1189
+ dupStreaks = /* @__PURE__ */ new Map();
1156
1190
  constructor(config = {}) {
1157
1191
  this.config = {
1158
1192
  ...DEFAULT_TRACKER_CONFIG,
1159
1193
  ...config
1160
1194
  };
1161
1195
  }
1196
+ /** Whether two macro-classes may be collapsed as the SAME subject. Same class
1197
+ * always; the {person,animal} pair only when `personAnimalDedup` is on. */
1198
+ dedupCompatible(a, b) {
1199
+ if (a === b) return true;
1200
+ if (!this.config.personAnimalDedup) return false;
1201
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
1202
+ }
1203
+ /** Unordered, stable key for a track pair (UUIDs never contain `|`). */
1204
+ static pairKey(a, b) {
1205
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
1206
+ }
1207
+ /** Association-gating group of a class. When `classGroupAssoc` is on the
1208
+ * {person,animal} pair collapses to one group so a per-frame class flip
1209
+ * (crouch person → animal) re-matches the existing track instead of spawning a
1210
+ * concurrent id. Every other class is its own group. */
1211
+ assocGroup(cls) {
1212
+ if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
1213
+ return cls;
1214
+ }
1215
+ /** Whether a detection may associate to a track under the current class gate:
1216
+ * exact class when `classGating` is off (no gate at all) — matching the legacy
1217
+ * contract — otherwise same association group (group == exact class unless
1218
+ * `classGroupAssoc` widened it to {person,animal}). */
1219
+ associable(trackClass, detClass) {
1220
+ if (!this.config.classGating) return true;
1221
+ return this.assocGroup(trackClass) === this.assocGroup(detClass);
1222
+ }
1223
+ /** Whether a spawn candidate clears its per-class minimum bbox-area floor
1224
+ * (fraction of frame area). Ungated when disabled, when the class has no floor,
1225
+ * or when the caller supplied no frame dimensions (fraction is uncomputable). */
1226
+ meetsSpawnAreaFloor(det, frameArea) {
1227
+ if (!this.config.minSpawnAreaEnabled) return true;
1228
+ const floor = this.config.classSpawnAreaFracs[det.class] ?? 0;
1229
+ if (floor <= 0 || frameArea <= 0) return true;
1230
+ return det.bbox.w * det.bbox.h / frameArea >= floor;
1231
+ }
1232
+ /**
1233
+ * True when spawning a fresh track for `det` would duplicate an already-live
1234
+ * track: (a) it overlaps a concurrent compatible-class track by
1235
+ * `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
1236
+ * concurrent `person` track (person-in-odd-pose false animal). `live` is the
1237
+ * set of tracks already surviving this frame (matched, coasting, resurrected,
1238
+ * and earlier same-frame spawns).
1239
+ */
1240
+ isDuplicateSpawn(det, live) {
1241
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
1242
+ if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
1243
+ for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
1244
+ }
1245
+ return false;
1246
+ }
1247
+ /** The lower-importance track of a duplicate pair — the one to drop. A
1248
+ * `person` always beats a cross-class `animal`; otherwise more hits wins,
1249
+ * ties break to the older track, then the higher score. */
1250
+ static duplicateLoser(a, b) {
1251
+ if (a.class !== b.class) {
1252
+ if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
1253
+ if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
1254
+ }
1255
+ if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
1256
+ if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
1257
+ return a.score >= b.score ? b : a;
1258
+ }
1259
+ /**
1260
+ * Merge concurrent duplicate tracks. Two live compatible-class tracks that
1261
+ * stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
1262
+ * frames are collapsed: the lower-importance one is dropped (NOT graveyarded
1263
+ * — a confirmed duplicate must not resurrect). This is the fix for a subject
1264
+ * the detector double-fires (two boxes each frame, each feeding its OWN
1265
+ * track, so neither is ever "unmatched" for spawn-suppression to catch) and
1266
+ * for a person misdetected as `animal` alongside the real person track.
1267
+ */
1268
+ resolveDuplicates() {
1269
+ if (!this.config.dedupEnabled) return;
1270
+ const live = this.tracks;
1271
+ const liveIds = new Set(live.map((t) => t.id));
1272
+ for (const key of this.dupStreaks.keys()) {
1273
+ const [a, b] = key.split("|");
1274
+ if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
1275
+ }
1276
+ const dropIds = /* @__PURE__ */ new Set();
1277
+ for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
1278
+ const a = live[i];
1279
+ const b = live[j];
1280
+ if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
1281
+ if (!this.dedupCompatible(a.class, b.class)) continue;
1282
+ const key = SortTracker.pairKey(a.id, b.id);
1283
+ if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
1284
+ this.dupStreaks.delete(key);
1285
+ continue;
1286
+ }
1287
+ const streak = (this.dupStreaks.get(key) ?? 0) + 1;
1288
+ if (streak >= this.config.dedupMergeFrames) {
1289
+ dropIds.add(SortTracker.duplicateLoser(a, b).id);
1290
+ this.dupStreaks.delete(key);
1291
+ } else this.dupStreaks.set(key, streak);
1292
+ }
1293
+ if (dropIds.size === 0) return;
1294
+ this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
1295
+ for (const key of this.dupStreaks.keys()) {
1296
+ const [a, b] = key.split("|");
1297
+ if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
1298
+ }
1299
+ }
1162
1300
  /** Where a track is expected this frame — extrapolated by velocity while
1163
1301
  * coasting (predictiveCoasting), else its last known bbox. A stationary
1164
1302
  * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
@@ -1179,13 +1317,16 @@ var SortTracker = class {
1179
1317
  };
1180
1318
  }
1181
1319
  /**
1182
- * Loose same-class gate used by the rescue and resurrection passes: accept
1320
+ * Loose same-group gate used by the rescue and resurrection passes: accept
1183
1321
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
1184
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
1185
- * gated (a rescue must never cross classes), independent of `classGating`.
1322
+ * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
1323
+ * gated (a rescue must never cross association groups) independent of
1324
+ * `classGating`, so a coasting person track can be rescued by its own
1325
+ * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
1326
+ * class.
1186
1327
  */
1187
1328
  looseMatch(track, det) {
1188
- if (track.class !== det.class) return false;
1329
+ if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return false;
1189
1330
  if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1190
1331
  const tc = bboxCentroid(track.bbox);
1191
1332
  const dc = bboxCentroid(det.bbox);
@@ -1193,29 +1334,65 @@ var SortTracker = class {
1193
1334
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
1194
1335
  return dist <= this.config.rescueCentroidFactor * diag;
1195
1336
  }
1196
- update(detections, timestamp) {
1197
- if (this.config.maxTrackLifetimeMs > 0) {
1198
- const alive = [];
1199
- for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1200
- track.lost = true;
1201
- track.lostAt = timestamp;
1202
- track.resurrectable = false;
1203
- this.lostTracks.push(track);
1204
- } else alive.push(track);
1205
- this.tracks = alive;
1337
+ /** Fold a matched detection's class + score into a track's lifetime vote
1338
+ * tally. A non-positive score still counts as an infinitesimal vote so a
1339
+ * zero-confidence frame contributes to the frame-count tiebreak. */
1340
+ addClassVote(track, det) {
1341
+ const weight = det.score > 0 ? det.score : Number.EPSILON;
1342
+ track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
1343
+ }
1344
+ /**
1345
+ * The class to REPORT for a track: the confidence-weighted lifetime majority
1346
+ * when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
1347
+ * weight; otherwise the latest-frame class (ambiguous vote or voting off).
1348
+ */
1349
+ resolveReportedClass(t) {
1350
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
1351
+ let bestClass = t.class;
1352
+ let bestVote = -1;
1353
+ let total = 0;
1354
+ for (const [cls, v] of t.classVotes) {
1355
+ total += v;
1356
+ if (v > bestVote) {
1357
+ bestVote = v;
1358
+ bestClass = cls;
1359
+ }
1206
1360
  }
1207
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1208
- const used = /* @__PURE__ */ new Set();
1209
- const matchedTracks = /* @__PURE__ */ new Set();
1210
- const matched = /* @__PURE__ */ new Map();
1361
+ if (total <= 0) return t.class;
1362
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
1363
+ }
1364
+ /** Whether a detection clears its per-class spawn score floor. Only gates NEW
1365
+ * spawns — an established track still matches below its floor. */
1366
+ meetsClassScoreFloor(det) {
1367
+ if (!this.config.perClassMinScoreEnabled) return true;
1368
+ const floor = this.config.classMinScores[det.class] ?? 0;
1369
+ return det.score >= floor;
1370
+ }
1371
+ /** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
1372
+ * a single very-high-confidence detection bypassed the hit gate (fast car). */
1373
+ isConfirmedForEmit(t) {
1374
+ if (t.hits >= this.config.minHits) return true;
1375
+ return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1376
+ }
1377
+ /**
1378
+ * One greedy IoU association pass over a candidate detection subset against the
1379
+ * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
1380
+ * bind first; a track/detection binds at most once. Shared by the high-score
1381
+ * first stage and the low-score second stage (different candidate sets + IoU
1382
+ * gates) so the two-tier ByteTrack association stays a single implementation.
1383
+ * Mutates `used` / `matchedTracks` / `matched` in place.
1384
+ */
1385
+ greedyAssociate(detections, candidateIdxs, iouThreshold, used, matchedTracks, matched) {
1211
1386
  const pairs = [];
1212
1387
  for (const track of this.tracks) {
1388
+ if (matchedTracks.has(track)) continue;
1213
1389
  const pbox = this.predicted(track);
1214
- for (let di = 0; di < detections.length; di++) {
1390
+ for (const di of candidateIdxs) {
1391
+ if (used.has(di)) continue;
1215
1392
  const det = detections[di];
1216
- if (this.config.classGating && track.class !== det.class) continue;
1393
+ if (!this.associable(track.class, det.class)) continue;
1217
1394
  const score = iou$2(pbox, det.bbox);
1218
- if (score >= this.config.iouThreshold) pairs.push({
1395
+ if (score >= iouThreshold) pairs.push({
1219
1396
  track,
1220
1397
  detIdx: di,
1221
1398
  score
@@ -1229,11 +1406,34 @@ var SortTracker = class {
1229
1406
  matchedTracks.add(pair.track);
1230
1407
  used.add(pair.detIdx);
1231
1408
  }
1409
+ }
1410
+ update(detections, timestamp, frameContext) {
1411
+ const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
1412
+ if (this.config.maxTrackLifetimeMs > 0) {
1413
+ const alive = [];
1414
+ for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1415
+ track.lost = true;
1416
+ track.lostAt = timestamp;
1417
+ track.resurrectable = false;
1418
+ this.lostTracks.push(track);
1419
+ } else alive.push(track);
1420
+ this.tracks = alive;
1421
+ }
1422
+ this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1423
+ const used = /* @__PURE__ */ new Set();
1424
+ const matchedTracks = /* @__PURE__ */ new Set();
1425
+ const matched = /* @__PURE__ */ new Map();
1426
+ const highIdxs = [];
1427
+ const lowIdxs = [];
1428
+ for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
1429
+ else highIdxs.push(di);
1430
+ const lowSet = new Set(lowIdxs);
1431
+ this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, used, matchedTracks, matched);
1232
1432
  const rescuePairs = [];
1233
1433
  for (const track of this.tracks) {
1234
1434
  if (matchedTracks.has(track)) continue;
1235
1435
  for (let di = 0; di < detections.length; di++) {
1236
- if (used.has(di)) continue;
1436
+ if (used.has(di) || lowSet.has(di)) continue;
1237
1437
  const det = detections[di];
1238
1438
  if (!this.looseMatch(track, det)) continue;
1239
1439
  rescuePairs.push({
@@ -1250,6 +1450,7 @@ var SortTracker = class {
1250
1450
  matchedTracks.add(pair.track);
1251
1451
  used.add(pair.detIdx);
1252
1452
  }
1453
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
1253
1454
  for (const [track, det] of matched) {
1254
1455
  const prevCenter = bboxCentroid({
1255
1456
  x: track.bbox.x,
@@ -1277,6 +1478,7 @@ var SortTracker = class {
1277
1478
  };
1278
1479
  track.path.push(det.bbox);
1279
1480
  if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
1481
+ this.addClassVote(track, det);
1280
1482
  }
1281
1483
  const occluderBoxes = [];
1282
1484
  for (const track of matchedTracks) occluderBoxes.push(track.bbox);
@@ -1326,13 +1528,34 @@ var SortTracker = class {
1326
1528
  };
1327
1529
  best.path.push(det.bbox);
1328
1530
  if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
1531
+ this.addClassVote(best, det);
1329
1532
  surviving.push(best);
1330
1533
  used.add(di);
1331
1534
  }
1332
- for (let di = 0; di < detections.length; di++) {
1333
- if (used.has(di)) continue;
1535
+ const unmatchedIdx = [];
1536
+ for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
1537
+ const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
1538
+ unmatchedIdx.sort((ia, ib) => {
1539
+ const da = detections[ia];
1540
+ const db = detections[ib];
1541
+ const r = classRank(da.class) - classRank(db.class);
1542
+ return r !== 0 ? r : db.score - da.score;
1543
+ });
1544
+ for (const di of unmatchedIdx) {
1334
1545
  const det = detections[di];
1546
+ if (!this.meetsClassScoreFloor(det)) {
1547
+ used.add(di);
1548
+ continue;
1549
+ }
1550
+ if (!this.meetsSpawnAreaFloor(det, frameArea)) {
1551
+ used.add(di);
1552
+ continue;
1553
+ }
1335
1554
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1555
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1556
+ used.add(di);
1557
+ continue;
1558
+ }
1336
1559
  surviving.push({
1337
1560
  id: (0, node_crypto.randomUUID)(),
1338
1561
  bbox: det.bbox,
@@ -1350,19 +1573,22 @@ var SortTracker = class {
1350
1573
  },
1351
1574
  lost: false,
1352
1575
  lostAt: 0,
1353
- resurrectable: true
1576
+ resurrectable: true,
1577
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
1354
1578
  });
1355
1579
  }
1356
1580
  this.tracks = surviving;
1357
- return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
1358
- class: t.class,
1581
+ this.resolveDuplicates();
1582
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
1583
+ class: this.resolveReportedClass(t),
1359
1584
  originalClass: t.originalClass,
1360
1585
  score: t.score,
1361
1586
  bbox: t.bbox,
1362
1587
  trackId: t.id,
1363
1588
  trackAge: t.hits,
1364
1589
  velocity: t.velocity,
1365
- path: [...t.path]
1590
+ path: [...t.path],
1591
+ matchedThisFrame: t.lastSeen === timestamp
1366
1592
  }));
1367
1593
  }
1368
1594
  /**
@@ -1384,6 +1610,7 @@ var SortTracker = class {
1384
1610
  reset() {
1385
1611
  this.tracks = [];
1386
1612
  this.lostTracks = [];
1613
+ this.dupStreaks.clear();
1387
1614
  }
1388
1615
  };
1389
1616
  //#endregion
@@ -1943,7 +2170,10 @@ var FrameProcessor = class {
1943
2170
  wokenEntryIds: []
1944
2171
  };
1945
2172
  const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1946
- const trackedDetections = this.tracker.update(trackerInput, timestamp);
2173
+ const trackedDetections = this.tracker.update(trackerInput, timestamp, {
2174
+ frameWidth,
2175
+ frameHeight
2176
+ });
1947
2177
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1948
2178
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1949
2179
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1972,6 +2202,7 @@ var FrameProcessor = class {
1972
2202
  bbox: { ...td.bbox },
1973
2203
  zones: zonesByTrack.get(td.trackId) ?? [],
1974
2204
  state,
2205
+ matchedThisFrame: td.matchedThisFrame !== false,
1975
2206
  ...label ? { label } : {},
1976
2207
  ...emb !== void 0 ? {
1977
2208
  embedding: emb.embedding,
@@ -4244,6 +4475,26 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
4244
4475
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
4245
4476
  * of a set of tracks without a `trackId` column on media rows. */
4246
4477
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
4478
+ /**
4479
+ * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
4480
+ * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
4481
+ * DETERMINISTIC (`owner:kind`, NO timestamp) and written via an UPSERT
4482
+ * (`store.set`, atomic `INSERT … ON CONFLICT DO UPDATE`), so N concurrent
4483
+ * captures for the same `(track, kind)` all address the SAME row/blob — one can
4484
+ * never become thirteen regardless of interleaving (RC-1). Accumulating kinds
4485
+ * (`snapshot`, event `crop`/`fullFrame*`, `faceCrop`/`plateCrop`) keep their
4486
+ * per-instance timestamped id so the filmstrip / per-event / per-enrollment rows
4487
+ * still accumulate.
4488
+ */
4489
+ var SINGLE_INSTANCE_KINDS = new Set([
4490
+ "keyFrame",
4491
+ "thumbnail",
4492
+ "firstFrame",
4493
+ "lastFrame"
4494
+ ]);
4495
+ function isSingleInstanceKind(kind) {
4496
+ return SINGLE_INSTANCE_KINDS.has(kind);
4497
+ }
4247
4498
  var MEDIA_COLUMNS = [
4248
4499
  {
4249
4500
  name: "id",
@@ -4295,10 +4546,11 @@ var MEDIA_INDEXES = [{
4295
4546
  columns: ["deviceId", "timestamp"]
4296
4547
  }];
4297
4548
  function buildKey(params) {
4298
- return `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4549
+ return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4299
4550
  }
4300
4551
  function buildPath(params) {
4301
- return `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}/${params.kind}-${params.timestamp}.jpg`;
4552
+ const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
4553
+ return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
4302
4554
  }
4303
4555
  var MediaStore = class {
4304
4556
  storage;
@@ -4321,25 +4573,31 @@ var MediaStore = class {
4321
4573
  async put(params) {
4322
4574
  const key = buildKey(params);
4323
4575
  const path = buildPath(params);
4576
+ const record = {
4577
+ deviceId: params.deviceId,
4578
+ ownerKind: params.ownerKind,
4579
+ ownerId: params.ownerId,
4580
+ kind: params.kind,
4581
+ timestamp: params.timestamp,
4582
+ path,
4583
+ sizeBytes: params.data.length
4584
+ };
4324
4585
  try {
4325
4586
  await this.storage.write({
4326
4587
  location: "eventMedia",
4327
4588
  relativePath: path,
4328
4589
  data: params.data
4329
4590
  });
4330
- await this.store.insert.mutate({
4591
+ if (isSingleInstanceKind(params.kind)) await this.store.set.mutate({
4592
+ collection: MEDIA_COLLECTION,
4593
+ key,
4594
+ value: record
4595
+ });
4596
+ else await this.store.insert.mutate({
4331
4597
  collection: MEDIA_COLLECTION,
4332
4598
  record: {
4333
4599
  id: key,
4334
- data: {
4335
- deviceId: params.deviceId,
4336
- ownerKind: params.ownerKind,
4337
- ownerId: params.ownerId,
4338
- kind: params.kind,
4339
- timestamp: params.timestamp,
4340
- path,
4341
- sizeBytes: params.data.length
4342
- }
4600
+ data: record
4343
4601
  }
4344
4602
  });
4345
4603
  return key;
@@ -4358,8 +4616,14 @@ var MediaStore = class {
4358
4616
  * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
4359
4617
  * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
4360
4618
  * each new capture replaces the previous one (blob + index row) rather than
4361
- * accumulating a filmstrip the way `put` does. Deletes any existing rows of
4362
- * that (owner, kind) first, then writes the fresh one. Returns the new key.
4619
+ * accumulating a filmstrip the way `put` does.
4620
+ *
4621
+ * These kinds are {@link SINGLE_INSTANCE_KINDS}, so `put` writes them to a
4622
+ * DETERMINISTIC `owner:kind` row/blob via an UPSERT — N concurrent racing
4623
+ * calls therefore all collapse onto the SAME row (RC-1), no query/insert/delete
4624
+ * interleaving can leave survivors. The query+delete pass below is retained
4625
+ * ONLY to reap LEGACY timestamped rows/blobs written before the deterministic
4626
+ * scheme (transition back-compat); once migrated it is a no-op.
4363
4627
  */
4364
4628
  async putReplacing(params) {
4365
4629
  const existing = await this.store.query.query({
@@ -6101,6 +6365,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
6101
6365
  h: Math.round(ch)
6102
6366
  };
6103
6367
  }
6368
+ /**
6369
+ * The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
6370
+ * NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
6371
+ *
6372
+ * A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
6373
+ * aspect ratio (the native crop path downscales while preserving aspect), so the
6374
+ * region computed from the detection frame's dimensions addresses the exact same
6375
+ * ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
6376
+ * (single source of truth) and divides by the frame dimensions.
6377
+ */
6378
+ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6379
+ const region = squareSafeCropRegion(bbox, frame, padding);
6380
+ return {
6381
+ x: region.x / frame.W,
6382
+ y: region.y / frame.H,
6383
+ w: region.w / frame.W,
6384
+ h: region.h / frame.H
6385
+ };
6386
+ }
6104
6387
  //#endregion
6105
6388
  //#region src/shared/frame/box-drawer.ts
6106
6389
  var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
@@ -6178,10 +6461,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
6178
6461
  * Small downscaled `thumbnail`s are intentionally left out for now — when we
6179
6462
  * reintroduce them they'll be a separate small kind. */
6180
6463
  var MEDIA_QUALITY = 88;
6181
- /** Output dimensions for square-safe 16:9 crops (crop/faceCrop/plateCrop). */
6464
+ /** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
6465
+ * (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
6182
6466
  var CROP_WIDTH = 640;
6183
6467
  var CROP_HEIGHT = 360;
6184
6468
  var CROP_QUALITY = 80;
6469
+ /**
6470
+ * Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
6471
+ * `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
6472
+ * ≥224px classifier input straight from the runner's native surface, WITHOUT
6473
+ * hauling a full 1920px frame per subject (that width is reserved for the
6474
+ * full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
6475
+ * the ≤640 local crop, so quality never regresses below today's behaviour.
6476
+ */
6477
+ var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
6185
6478
  function caption(className, confidence, label) {
6186
6479
  const base = label && label !== className ? `${className} ${label}` : className;
6187
6480
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
@@ -6288,12 +6581,12 @@ var EventMediaDispatcher = class {
6288
6581
  });
6289
6582
  return empty;
6290
6583
  }
6291
- for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6584
+ for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
6292
6585
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6293
6586
  const storedSnapshots = [];
6294
6587
  const thumbnailTrackIds = [];
6295
6588
  for (const sn of snapshots) {
6296
- const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6589
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6297
6590
  if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6298
6591
  if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6299
6592
  }
@@ -6314,7 +6607,7 @@ var EventMediaDispatcher = class {
6314
6607
  * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6315
6608
  * landed this frame (#27-A) so the caller can stop forcing retries.
6316
6609
  */
6317
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6610
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6318
6611
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6319
6612
  storedSnapshot: null,
6320
6613
  thumbnailWritten: false
@@ -6355,7 +6648,7 @@ var EventMediaDispatcher = class {
6355
6648
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6356
6649
  let thumbnailWritten = false;
6357
6650
  if (sn.bestThumbnail) try {
6358
- const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6651
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
6359
6652
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6360
6653
  } catch (err) {
6361
6654
  this.deps.logger.warn("event media: track thumbnail crop failed", {
@@ -6374,12 +6667,38 @@ var EventMediaDispatcher = class {
6374
6667
  };
6375
6668
  }
6376
6669
  /**
6377
- * Clean subject-centered crop of `bbox` out of the raw frame the shared
6378
- * output contract of the object-event `crop` kind and the track `thumbnail`:
6379
- * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
6380
- * (no box drawn), resized to 640×360, JPEG q80.
6670
+ * Clean subject-centered crop of `bbox` the shared output contract of the
6671
+ * object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
6672
+ * `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
6673
+ *
6674
+ * NATIVE-FIRST: the region is requested from the runner's retained native
6675
+ * surface (normalized [0,1] coords map directly onto it), downscaled to
6676
+ * {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
6677
+ * miss/error (or a runner without the method) it FALLS BACK to cropping the
6678
+ * resolved ≤640 frame locally (today's behaviour), so quality never regresses.
6679
+ * Both paths run inside the live-handle window opened by `captureForFrame`.
6680
+ */
6681
+ async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
6682
+ if (this.deps.getNativeCropJpeg) try {
6683
+ const norm = squareSafeCropRegionNormalized(bbox, {
6684
+ W: fw,
6685
+ H: fh
6686
+ }, cropPadding);
6687
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
6688
+ if (native) return native;
6689
+ } catch (err) {
6690
+ this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
6691
+ shmId: frameHandle.shmId,
6692
+ error: err instanceof Error ? err.message : String(err)
6693
+ } });
6694
+ }
6695
+ return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
6696
+ }
6697
+ /**
6698
+ * Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
6699
+ * to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
6381
6700
  */
6382
- async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
6701
+ async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
6383
6702
  const region = squareSafeCropRegion(bbox, {
6384
6703
  W: fw,
6385
6704
  H: fh
@@ -6422,13 +6741,13 @@ var EventMediaDispatcher = class {
6422
6741
  return false;
6423
6742
  }
6424
6743
  }
6425
- async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
6744
+ async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
6426
6745
  const box = {
6427
6746
  ...ev.bbox,
6428
6747
  label: caption(ev.className, ev.confidence, ev.label)
6429
6748
  };
6430
6749
  try {
6431
- const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
6750
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
6432
6751
  await this.deps.mediaStore.put({
6433
6752
  deviceId,
6434
6753
  ownerKind: "event",
@@ -6488,24 +6807,7 @@ var EventMediaDispatcher = class {
6488
6807
  });
6489
6808
  }
6490
6809
  if (ev.childCrops) for (const child of ev.childCrops) try {
6491
- const childRegion = squareSafeCropRegion(child.bbox, {
6492
- W: fw,
6493
- H: fh
6494
- }, cropPadding);
6495
- const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
6496
- const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
6497
- const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
6498
- const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
6499
- const childCropData = await (0, sharp.default)(frameData, { raw: {
6500
- width: fw,
6501
- height: fh,
6502
- channels: 3
6503
- } }).extract({
6504
- left: childLeft,
6505
- top: childTop,
6506
- width: childWidth,
6507
- height: childHeight
6508
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
6810
+ const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
6509
6811
  await this.deps.mediaStore.put({
6510
6812
  deviceId,
6511
6813
  ownerKind: "event",
@@ -7666,11 +7968,89 @@ var TrackingSettingsSchema = require_dist.object({
7666
7968
  /** Speed (px/frame) below which a track's prediction is frozen (stationary
7667
7969
  * jitter can't drift the box off a sitting object). */
7668
7970
  stationarySpeedPx: require_dist.number().min(0).default(2),
7971
+ /** Suppress + merge concurrent duplicate tracks (one subject the detector
7972
+ * double-fires, or a person misdetected as `animal`, otherwise becomes two
7973
+ * time-overlapping tracks that never re-associate — each firing its own
7974
+ * event). Off = legacy behaviour. */
7975
+ dedupEnabled: require_dist.boolean().default(true),
7976
+ /** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
7977
+ * concurrent compatible-class track and suppressed. High so distinct
7978
+ * subjects appearing close together are not collapsed. */
7979
+ dedupSpawnIou: require_dist.number().min(0).max(1).default(.6),
7980
+ /** Envelope IoU at/above which two concurrent tracks count as overlapping for
7981
+ * the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
7982
+ * merged duplicate cannot re-spawn. */
7983
+ dedupMergeIou: require_dist.number().min(0).max(1).default(.6),
7984
+ /** Consecutive overlapping frames before two live tracks are merged (the
7985
+ * lower-importance one dropped). Higher = more conservative. */
7986
+ dedupMergeFrames: require_dist.number().int().min(1).default(5),
7987
+ /** Treat the {person,animal} class pair as duplicate-compatible — a person in
7988
+ * a non-upright pose is misdetected as `animal`; the false animal track
7989
+ * collapses into the real person track. */
7990
+ personAnimalDedup: require_dist.boolean().default(true),
7991
+ /** A low-confidence `animal` (score below this) overlapping a concurrent
7992
+ * person track is suppressed at spawn. */
7993
+ animalOverPersonMaxScore: require_dist.number().min(0).max(1).default(.6),
7994
+ /** IoU with a concurrent person track that triggers the low-confidence animal
7995
+ * spawn suppression. */
7996
+ animalOverPersonIou: require_dist.number().min(0).max(1).default(.2),
7997
+ /** Resolve a track's reported class by a confidence-weighted majority over its
7998
+ * lifetime (vs. the latest frame) — kills per-frame class flips (a person
7999
+ * read as `animal` on one crouch frame keeps the `person` label). */
8000
+ classVotingEnabled: require_dist.boolean().default(true),
8001
+ /** Winning class must hold at least this fraction of a track's total vote
8002
+ * weight to override the latest-frame class; below it the latest wins (so a
8003
+ * genuine mid-life reclassification is never frozen out). */
8004
+ classVoteMinFraction: require_dist.number().min(0).max(1).default(.5),
8005
+ /** Enforce a per-class minimum detection score at track SPAWN (an established
8006
+ * track still matches below its floor — only new spawns are gated). */
8007
+ perClassMinScoreEnabled: require_dist.boolean().default(true),
8008
+ /** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
8009
+ minScorePerson: require_dist.number().min(0).max(1).default(0),
8010
+ /** Minimum spawn score for `animal` (FP-prone — higher floor). */
8011
+ minScoreAnimal: require_dist.number().min(0).max(1).default(.45),
8012
+ /** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
8013
+ minScoreVehicle: require_dist.number().min(0).max(1).default(.45),
8014
+ /** Let a single very-high-confidence detection confirm a track for emission
8015
+ * before it reaches `minHits` (so a fast car crossing in 1-2 frames still
8016
+ * registers). Opt-in — default OFF keeps the strict N-hit gate. */
8017
+ confirmBypassEnabled: require_dist.boolean().default(false),
8018
+ /** Score at/above which a detection confirms its track immediately (bypasses
8019
+ * `minHits`). Only consulted when `confirmBypassEnabled`. */
8020
+ confirmBypassScore: require_dist.number().min(0).max(1).default(.9),
7669
8021
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
7670
8022
  dropoutSkipEnabled: require_dist.boolean().default(true),
7671
8023
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
7672
8024
  * treated as genuinely empty. */
7673
- dropoutMaxSkipFrames: require_dist.number().int().min(0).default(5)
8025
+ dropoutMaxSkipFrames: require_dist.number().int().min(0).default(5),
8026
+ /** Two-stage (ByteTrack) association: match high-confidence detections first,
8027
+ * then recover coasting tracks with the low-confidence leftovers before they
8028
+ * die. The single biggest fragmentation reducer. Off = legacy single pass. */
8029
+ byteTrackEnabled: require_dist.boolean().default(true),
8030
+ /** Score at/above which a detection is HIGH (matched first); below it a detection
8031
+ * is LOW and only gets the second-stage recovery match. Does not gate spawning
8032
+ * (the per-class score/area floors do). */
8033
+ byteTrackHighThreshold: require_dist.number().min(0).max(1).default(.4),
8034
+ /** Looser IoU gate for the low-score second (recovery) association stage — a
8035
+ * coasting box is stale, and the match only re-attaches an existing track. */
8036
+ byteTrackLowIouThreshold: require_dist.number().min(0).max(1).default(.2),
8037
+ /** Enforce a per-class minimum bbox-area (fraction of frame) at track SPAWN —
8038
+ * kills tiny far-field / vanishing-point noise tracks. An established track
8039
+ * still matches a shrinking box; only spawns are gated. */
8040
+ minSpawnAreaEnabled: require_dist.boolean().default(true),
8041
+ /** Min spawn bbox area for `person` as a fraction of the frame (0 = ungated).
8042
+ * Person is the only spawn-score-ungated class, so far-field person noise is
8043
+ * the worst offender; a conservative 0.05% rejects only vanishing-point blips. */
8044
+ minSpawnAreaFracPerson: require_dist.number().min(0).max(1).default(5e-4),
8045
+ /** Min spawn bbox area for `animal` as a fraction of the frame (0 = ungated). */
8046
+ minSpawnAreaFracAnimal: require_dist.number().min(0).max(1).default(0),
8047
+ /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
8048
+ minSpawnAreaFracVehicle: require_dist.number().min(0).max(1).default(0),
8049
+ /** Gate association on a class GROUP ({person,animal}) instead of the exact
8050
+ * per-frame class, so a person that flips to `animal` for a frame re-matches
8051
+ * its existing track (no concurrent animal id). Reported label stays via the
8052
+ * class vote. Off = exact per-frame class gating. */
8053
+ classGroupAssoc: require_dist.boolean().default(true)
7674
8054
  });
7675
8055
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
7676
8056
  /**
@@ -7700,8 +8080,31 @@ function resolveTrackingSettings(raw) {
7700
8080
  rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
7701
8081
  resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
7702
8082
  stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
8083
+ dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
8084
+ dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
8085
+ dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
8086
+ dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
8087
+ personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
8088
+ animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
8089
+ animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
8090
+ classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
8091
+ classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
8092
+ perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
8093
+ minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
8094
+ minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
8095
+ minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
8096
+ confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
8097
+ confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7703
8098
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7704
- dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
8099
+ dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames),
8100
+ byteTrackEnabled: s.byteTrackEnabled.catch(TRACKING_DEFAULTS.byteTrackEnabled).parse(raw.byteTrackEnabled),
8101
+ byteTrackHighThreshold: s.byteTrackHighThreshold.catch(TRACKING_DEFAULTS.byteTrackHighThreshold).parse(raw.byteTrackHighThreshold),
8102
+ byteTrackLowIouThreshold: s.byteTrackLowIouThreshold.catch(TRACKING_DEFAULTS.byteTrackLowIouThreshold).parse(raw.byteTrackLowIouThreshold),
8103
+ minSpawnAreaEnabled: s.minSpawnAreaEnabled.catch(TRACKING_DEFAULTS.minSpawnAreaEnabled).parse(raw.minSpawnAreaEnabled),
8104
+ minSpawnAreaFracPerson: s.minSpawnAreaFracPerson.catch(TRACKING_DEFAULTS.minSpawnAreaFracPerson).parse(raw.minSpawnAreaFracPerson),
8105
+ minSpawnAreaFracAnimal: s.minSpawnAreaFracAnimal.catch(TRACKING_DEFAULTS.minSpawnAreaFracAnimal).parse(raw.minSpawnAreaFracAnimal),
8106
+ minSpawnAreaFracVehicle: s.minSpawnAreaFracVehicle.catch(TRACKING_DEFAULTS.minSpawnAreaFracVehicle).parse(raw.minSpawnAreaFracVehicle),
8107
+ classGroupAssoc: s.classGroupAssoc.catch(TRACKING_DEFAULTS.classGroupAssoc).parse(raw.classGroupAssoc)
7705
8108
  };
7706
8109
  }
7707
8110
  //#endregion
@@ -8108,6 +8511,40 @@ function planPeriodicMedia(input) {
8108
8511
  };
8109
8512
  }
8110
8513
  //#endregion
8514
+ //#region src/pipeline-analytics/best-thumbnail-guard.ts
8515
+ /**
8516
+ * Void/envArea guard for best-`thumbnail` selection.
8517
+ *
8518
+ * ## Why this exists (the dawn/night "void" thumbnail)
8519
+ *
8520
+ * At dawn/night a moving subject's tracker box intermittently EXPLODES to
8521
+ * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
8522
+ * that frame happens to win the best-detection race, the gallery/reel best
8523
+ * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
8524
+ * washed-out frame), not the subject. This guard rejects such a frame from the
8525
+ * best-`thumbnail` decision so the track keeps a real subject-centered tile.
8526
+ *
8527
+ * Conservative by design: it only rejects boxes covering ≥ {@link
8528
+ * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
8529
+ * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
8530
+ * per-frame retry keeps trying until a plausible frame wins.
8531
+ */
8532
+ /**
8533
+ * Area fraction at/above which a detection bbox is treated as an exploded
8534
+ * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
8535
+ * guard conservative — only boxes covering ≥85% of the frame are rejected.
8536
+ */
8537
+ var NEAR_FULL_FRAME_AREA = .85;
8538
+ /**
8539
+ * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
8540
+ * area is below the near-full-frame threshold. Degenerate frame dimensions
8541
+ * (≤0) are treated as plausible (no info to reject on).
8542
+ */
8543
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8544
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
8545
+ return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
8546
+ }
8547
+ //#endregion
8111
8548
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
8112
8549
  /**
8113
8550
  * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
@@ -8139,11 +8576,12 @@ function planPeriodicMedia(input) {
8139
8576
  var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8140
8577
  /**
8141
8578
  * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
8142
- * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
8143
- * downstream keeps one `keyFrame` per track (the current peak).
8579
+ * the tracks that hit a GENUINE new best-frame moment (`keyFrame`), NOT the
8580
+ * per-frame best-thumbnail retry. `putReplacing` downstream keeps one `keyFrame`
8581
+ * per track (the current peak).
8144
8582
  */
8145
8583
  function selectKeyFrameTrackIds(targets) {
8146
- return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
8584
+ return targets.filter((t) => t.keyFrame).map((t) => t.trackId);
8147
8585
  }
8148
8586
  /**
8149
8587
  * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
@@ -8163,6 +8601,15 @@ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
8163
8601
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
8164
8602
  };
8165
8603
  }
8604
+ /**
8605
+ * True when a native subject crop of `width` px is a plausible native hit worth
8606
+ * keeping, rather than a sub-threshold fallback stamp. Clamps the floor to the
8607
+ * requested `maxWidth` so a caller that legitimately asked for a narrow crop
8608
+ * (`maxWidth < MIN`) is not rejected for honouring its own cap.
8609
+ */
8610
+ function nativeSubjectCropMeetsFloor(width, maxWidth) {
8611
+ return width >= Math.min(320, maxWidth);
8612
+ }
8166
8613
  //#endregion
8167
8614
  //#region src/pipeline-analytics/track-retention-sweep.ts
8168
8615
  /**
@@ -9245,6 +9692,7 @@ var FaceRecognizer = class {
9245
9692
  bbox: input.parentBbox,
9246
9693
  zones: [],
9247
9694
  state: "moving",
9695
+ matchedThisFrame: true,
9248
9696
  embedding: input.embedding,
9249
9697
  embeddingModelId: modelId,
9250
9698
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -11415,6 +11863,35 @@ function toAnalyticsDeviceSections(sections) {
11415
11863
  }));
11416
11864
  }
11417
11865
  /**
11866
+ * Global-analytics section ids that are really per-camera DETECTION knobs and
11867
+ * belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
11868
+ * Object Detection — NOT under the generic `Analytics` top-tab:
11869
+ * - `detection-sensitivity` — minHits / cooldown / stationary threshold.
11870
+ * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
11871
+ * animal dedup, class voting, confirm-bypass, per-class min score).
11872
+ * - `stationary-objects` — stationary promotion + occupancy tuning.
11873
+ * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
11874
+ * structural Detection pipeline tab, so re-tagging is all that's needed —
11875
+ * there is no admin-ui change and no duplicate render (a section has one tab).
11876
+ */
11877
+ var DETECTION_PIPELINE_SECTION_IDS = new Set([
11878
+ "detection-sensitivity",
11879
+ "tracking",
11880
+ "stationary-objects"
11881
+ ]);
11882
+ /**
11883
+ * Re-home the detection-knob sections from the `Analytics` top-tab onto the
11884
+ * `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
11885
+ * section changes; every other section (media policy, retention, faces, track
11886
+ * history) stays on Analytics.
11887
+ */
11888
+ function retagDetectionSections(sections) {
11889
+ return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
11890
+ ...s,
11891
+ tab: "detection-pipeline"
11892
+ } : s);
11893
+ }
11894
+ /**
11418
11895
  * Fields that live ONLY on the global settings page and must never surface in a
11419
11896
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
11420
11897
  * master kill for the whole subsystem — per-camera face production is governed
@@ -11571,6 +12048,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11571
12048
  * (recycled/blank live frame) still gets a subject crop for the gallery
11572
12049
  * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11573
12050
  thumbnailLandedTracks = /* @__PURE__ */ new Set();
12051
+ /** Tracks with a best-`thumbnail` capture CURRENTLY in flight (RC-1). The
12052
+ * #27-A retry re-fired a fresh best-thumbnail capture every frame while one
12053
+ * was still resolving (capture latency stacks 0.1–3s under the native path),
12054
+ * so a short track issued N overlapping captures. While a track sits here
12055
+ * `buildSnapshotTargets` suppresses a new best-thumbnail request; the pending
12056
+ * result clears the flag (and, if it landed, sets `thumbnailLandedTracks`).
12057
+ * Cleared on track end + reset. */
12058
+ thumbnailInFlight = /* @__PURE__ */ new Set();
12059
+ /** Tracks with a native `keyFrame` capture CURRENTLY in flight (RC-1). Guards
12060
+ * the fire-and-forget `persistKeyFrames` so a burst of new-best frames issues
12061
+ * at most ONE outstanding 1920px native capture per track instead of one per
12062
+ * frame. Cleared when the capture settles (+ on track end / reset). */
12063
+ keyFrameInFlight = /* @__PURE__ */ new Set();
11574
12064
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11575
12065
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11576
12066
  * area) + emit time, so a material improvement is measured against the
@@ -11628,7 +12118,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11628
12118
  let storage = this.ctx.kernel.storage;
11629
12119
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
11630
12120
  if (mediaRoot) {
11631
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CDbDhtGa.js"));
12121
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B7HfyyIy.js"));
11632
12122
  storage = new FilesystemStorageProvider(mediaRoot);
11633
12123
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11634
12124
  }
@@ -11796,11 +12286,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11796
12286
  timestamp: 0
11797
12287
  };
11798
12288
  };
11799
- this.eventMediaDispatcher = new EventMediaDispatcher({
11800
- getRemoteFrame,
11801
- mediaStore: this.mediaStore,
11802
- logger: logger.child("EventMediaDispatcher")
11803
- });
11804
12289
  const cropMetricLogger = logger.child("NativeCrop");
11805
12290
  let nativeHits = 0;
11806
12291
  let nativeFallbacks = 0;
@@ -11817,7 +12302,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11817
12302
  detectionFrameFallbacks: nativeFallbacks
11818
12303
  } });
11819
12304
  };
11820
- const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12305
+ const fetchNativeCropRgb = async (frameHandle, paddedNorm, maxWidth) => {
11821
12306
  if (!pipelineRunnerApi?.getNativeCrop) return null;
11822
12307
  try {
11823
12308
  const native = await pipelineRunnerApi.getNativeCrop.query({
@@ -11826,12 +12311,42 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11826
12311
  ...maxWidth !== void 0 ? { maxWidth } : {}
11827
12312
  }, require_dist.nodePin(frameHandle.nodeId));
11828
12313
  if (!native || native.width <= 0 || native.height <= 0) return null;
11829
- return await encodeRgbCropToJpeg(Buffer.from(native.bytes), native.width, native.height);
12314
+ return {
12315
+ bytes: Buffer.from(native.bytes),
12316
+ width: native.width,
12317
+ height: native.height
12318
+ };
11830
12319
  } catch (err) {
11831
12320
  cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: require_dist.errMsg(err) } });
11832
12321
  return null;
11833
12322
  }
11834
12323
  };
12324
+ const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12325
+ const native = await fetchNativeCropRgb(frameHandle, paddedNorm, maxWidth);
12326
+ if (!native) return null;
12327
+ return await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12328
+ };
12329
+ const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12330
+ const native = await fetchNativeCropRgb(frameHandle, normalizedBbox, maxWidth);
12331
+ if (!native || !nativeSubjectCropMeetsFloor(native.width, maxWidth)) {
12332
+ if (native) cropMetricLogger.debug("native subject crop below floor — local fallback", { meta: {
12333
+ width: native.width,
12334
+ height: native.height,
12335
+ maxWidth
12336
+ } });
12337
+ bumpCropMetric(false);
12338
+ return null;
12339
+ }
12340
+ const jpeg = await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12341
+ bumpCropMetric(jpeg !== null);
12342
+ return jpeg;
12343
+ };
12344
+ this.eventMediaDispatcher = new EventMediaDispatcher({
12345
+ getRemoteFrame,
12346
+ getNativeCropJpeg,
12347
+ mediaStore: this.mediaStore,
12348
+ logger: logger.child("EventMediaDispatcher")
12349
+ });
11835
12350
  const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
11836
12351
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
11837
12352
  const paddedNorm = padBbox({
@@ -12375,6 +12890,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12375
12890
  this.bestFrameTracker.clear();
12376
12891
  this.lastFrameAtByTrack.clear();
12377
12892
  this.thumbnailLandedTracks.clear();
12893
+ this.thumbnailInFlight.clear();
12894
+ this.keyFrameInFlight.clear();
12378
12895
  this.trackLifecycleUpdateMem.clear();
12379
12896
  this.objectEmbeddingBestSelector.clear();
12380
12897
  this.levelStateByDevice.clear();
@@ -12695,6 +13212,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12695
13212
  frames: captureAgg.frames,
12696
13213
  ...captureAgg.sums
12697
13214
  } });
13215
+ const thumbInFlightTrackIds = snapshotTargets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
13216
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.add(trackId);
12698
13217
  this.eventMediaDispatcher.captureForFrame({
12699
13218
  deviceId,
12700
13219
  frameHandle,
@@ -12714,7 +13233,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12714
13233
  mediaKey: s.mediaKey
12715
13234
  });
12716
13235
  for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
12717
- }).catch(() => {});
13236
+ }).catch(() => {}).finally(() => {
13237
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.delete(trackId);
13238
+ });
12718
13239
  }
12719
13240
  }
12720
13241
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -13159,7 +13680,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13159
13680
  const mediaStore = this.mediaStore;
13160
13681
  if (!capture || !mediaStore) return;
13161
13682
  const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
13162
- await Promise.all(trackIds.map(async (trackId) => {
13683
+ const pending = trackIds.filter((id) => !this.keyFrameInFlight.has(id));
13684
+ if (pending.length === 0) return;
13685
+ for (const id of pending) this.keyFrameInFlight.add(id);
13686
+ await Promise.all(pending.map(async (trackId) => {
13163
13687
  try {
13164
13688
  const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
13165
13689
  if (!keyFrame) return;
@@ -13180,6 +13704,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13180
13704
  error: require_dist.errMsg(err)
13181
13705
  }
13182
13706
  });
13707
+ } finally {
13708
+ this.keyFrameInFlight.delete(trackId);
13183
13709
  }
13184
13710
  }));
13185
13711
  }
@@ -13241,6 +13767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13241
13767
  buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
13242
13768
  const targets = [];
13243
13769
  for (const t of tracked) {
13770
+ if (t.matchedThisFrame === false) continue;
13244
13771
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
13245
13772
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
13246
13773
  lastSnapshotAt: lastSnap,
@@ -13272,7 +13799,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13272
13799
  });
13273
13800
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13274
13801
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13275
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
13802
+ const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13803
+ const bestThumbnail = plan.bestThumbnail && plausibleBox && !this.thumbnailInFlight.has(t.trackId);
13804
+ const keyFrame = isNewBest && plausibleBox;
13805
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail && !keyFrame) continue;
13276
13806
  targets.push({
13277
13807
  trackId: t.trackId,
13278
13808
  timestamp,
@@ -13280,7 +13810,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13280
13810
  ...t.label ? { label: t.label } : {},
13281
13811
  appendSnapshot: plan.appendSnapshot,
13282
13812
  rollingLastFrame: plan.rollingLastFrame,
13283
- bestThumbnail: plan.bestThumbnail
13813
+ bestThumbnail,
13814
+ keyFrame
13284
13815
  });
13285
13816
  }
13286
13817
  return targets;
@@ -13587,6 +14118,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13587
14118
  this.objectEmbeddingBestSelector.delete(t.trackId);
13588
14119
  this.lastFrameAtByTrack.delete(t.trackId);
13589
14120
  this.thumbnailLandedTracks.delete(t.trackId);
14121
+ this.thumbnailInFlight.delete(t.trackId);
14122
+ this.keyFrameInFlight.delete(t.trackId);
13590
14123
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13591
14124
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13592
14125
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -13849,6 +14382,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13849
14382
  this.objectEmbeddingBestSelector.delete(track.trackId);
13850
14383
  this.lastFrameAtByTrack.delete(track.trackId);
13851
14384
  this.thumbnailLandedTracks.delete(track.trackId);
14385
+ this.thumbnailInFlight.delete(track.trackId);
14386
+ this.keyFrameInFlight.delete(track.trackId);
13852
14387
  this.trackLifecycleUpdateMem.delete(track.trackId);
13853
14388
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
13854
14389
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -13882,7 +14417,34 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13882
14417
  rescueCentroidFactor: trk.rescueCentroidFactor,
13883
14418
  resurrectionWindowMs: trk.resurrectionWindowMs,
13884
14419
  stationarySpeedPx: trk.stationarySpeedPx,
13885
- maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
14420
+ maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
14421
+ dedupEnabled: trk.dedupEnabled,
14422
+ dedupSpawnIou: trk.dedupSpawnIou,
14423
+ dedupMergeIou: trk.dedupMergeIou,
14424
+ dedupMergeFrames: trk.dedupMergeFrames,
14425
+ personAnimalDedup: trk.personAnimalDedup,
14426
+ animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
14427
+ animalOverPersonIou: trk.animalOverPersonIou,
14428
+ classVotingEnabled: trk.classVotingEnabled,
14429
+ classVoteMinFraction: trk.classVoteMinFraction,
14430
+ perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
14431
+ classMinScores: {
14432
+ person: trk.minScorePerson,
14433
+ animal: trk.minScoreAnimal,
14434
+ vehicle: trk.minScoreVehicle
14435
+ },
14436
+ confirmBypassEnabled: trk.confirmBypassEnabled,
14437
+ confirmBypassScore: trk.confirmBypassScore,
14438
+ byteTrackEnabled: trk.byteTrackEnabled,
14439
+ byteTrackHighThreshold: trk.byteTrackHighThreshold,
14440
+ byteTrackLowIouThreshold: trk.byteTrackLowIouThreshold,
14441
+ minSpawnAreaEnabled: trk.minSpawnAreaEnabled,
14442
+ classSpawnAreaFracs: {
14443
+ person: trk.minSpawnAreaFracPerson,
14444
+ animal: trk.minSpawnAreaFracAnimal,
14445
+ vehicle: trk.minSpawnAreaFracVehicle
14446
+ },
14447
+ classGroupAssoc: trk.classGroupAssoc
13886
14448
  }, { stationaryThresholdSec }, {
13887
14449
  minTrackAge,
13888
14450
  cooldownSec,
@@ -15185,6 +15747,69 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15185
15747
  default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
15186
15748
  unit: "ms"
15187
15749
  },
15750
+ {
15751
+ type: "boolean",
15752
+ key: "dedupEnabled",
15753
+ label: "Duplicate-track suppression",
15754
+ 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.",
15755
+ default: TRACKING_DEFAULTS.dedupEnabled
15756
+ },
15757
+ {
15758
+ type: "number",
15759
+ key: "dedupSpawnIou",
15760
+ label: "Duplicate spawn IoU",
15761
+ 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.",
15762
+ min: 0,
15763
+ max: 1,
15764
+ step: .05,
15765
+ default: TRACKING_DEFAULTS.dedupSpawnIou
15766
+ },
15767
+ {
15768
+ type: "number",
15769
+ key: "dedupMergeIou",
15770
+ label: "Duplicate merge IoU",
15771
+ description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
15772
+ min: 0,
15773
+ max: 1,
15774
+ step: .05,
15775
+ default: TRACKING_DEFAULTS.dedupMergeIou
15776
+ },
15777
+ {
15778
+ type: "number",
15779
+ key: "dedupMergeFrames",
15780
+ label: "Duplicate merge frames",
15781
+ description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
15782
+ min: 1,
15783
+ step: 1,
15784
+ default: TRACKING_DEFAULTS.dedupMergeFrames
15785
+ },
15786
+ {
15787
+ type: "boolean",
15788
+ key: "personAnimalDedup",
15789
+ label: "Person/animal duplicate merge",
15790
+ 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.",
15791
+ default: TRACKING_DEFAULTS.personAnimalDedup
15792
+ },
15793
+ {
15794
+ type: "number",
15795
+ key: "animalOverPersonMaxScore",
15796
+ label: "Animal-over-person max score",
15797
+ 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.",
15798
+ min: 0,
15799
+ max: 1,
15800
+ step: .05,
15801
+ default: TRACKING_DEFAULTS.animalOverPersonMaxScore
15802
+ },
15803
+ {
15804
+ type: "number",
15805
+ key: "animalOverPersonIou",
15806
+ label: "Animal-over-person IoU",
15807
+ description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
15808
+ min: 0,
15809
+ max: 1,
15810
+ step: .05,
15811
+ default: TRACKING_DEFAULTS.animalOverPersonIou
15812
+ },
15188
15813
  {
15189
15814
  type: "boolean",
15190
15815
  key: "dropoutSkipEnabled",
@@ -15200,6 +15825,148 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15200
15825
  min: 0,
15201
15826
  step: 1,
15202
15827
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
15828
+ },
15829
+ {
15830
+ type: "boolean",
15831
+ key: "classVotingEnabled",
15832
+ label: "Per-track class voting",
15833
+ 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.",
15834
+ default: TRACKING_DEFAULTS.classVotingEnabled
15835
+ },
15836
+ {
15837
+ type: "number",
15838
+ key: "classVoteMinFraction",
15839
+ label: "Class-vote min fraction",
15840
+ 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).",
15841
+ min: 0,
15842
+ max: 1,
15843
+ step: .05,
15844
+ default: TRACKING_DEFAULTS.classVoteMinFraction
15845
+ },
15846
+ {
15847
+ type: "boolean",
15848
+ key: "perClassMinScoreEnabled",
15849
+ label: "Per-class spawn confidence",
15850
+ 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.",
15851
+ default: TRACKING_DEFAULTS.perClassMinScoreEnabled
15852
+ },
15853
+ {
15854
+ type: "number",
15855
+ key: "minScorePerson",
15856
+ label: "Min spawn score — person",
15857
+ description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
15858
+ min: 0,
15859
+ max: 1,
15860
+ step: .05,
15861
+ default: TRACKING_DEFAULTS.minScorePerson
15862
+ },
15863
+ {
15864
+ type: "number",
15865
+ key: "minScoreAnimal",
15866
+ label: "Min spawn score — animal",
15867
+ description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
15868
+ min: 0,
15869
+ max: 1,
15870
+ step: .05,
15871
+ default: TRACKING_DEFAULTS.minScoreAnimal
15872
+ },
15873
+ {
15874
+ type: "number",
15875
+ key: "minScoreVehicle",
15876
+ label: "Min spawn score — vehicle",
15877
+ description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
15878
+ min: 0,
15879
+ max: 1,
15880
+ step: .05,
15881
+ default: TRACKING_DEFAULTS.minScoreVehicle
15882
+ },
15883
+ {
15884
+ type: "boolean",
15885
+ key: "confirmBypassEnabled",
15886
+ label: "Fast high-confidence confirm",
15887
+ 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).",
15888
+ default: TRACKING_DEFAULTS.confirmBypassEnabled
15889
+ },
15890
+ {
15891
+ type: "number",
15892
+ key: "confirmBypassScore",
15893
+ label: "Fast-confirm score",
15894
+ description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
15895
+ min: 0,
15896
+ max: 1,
15897
+ step: .05,
15898
+ default: TRACKING_DEFAULTS.confirmBypassScore
15899
+ },
15900
+ {
15901
+ type: "boolean",
15902
+ key: "byteTrackEnabled",
15903
+ label: "Two-stage association (ByteTrack)",
15904
+ 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.",
15905
+ default: TRACKING_DEFAULTS.byteTrackEnabled
15906
+ },
15907
+ {
15908
+ type: "number",
15909
+ key: "byteTrackHighThreshold",
15910
+ label: "Two-stage high threshold",
15911
+ 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.",
15912
+ min: 0,
15913
+ max: 1,
15914
+ step: .05,
15915
+ default: TRACKING_DEFAULTS.byteTrackHighThreshold
15916
+ },
15917
+ {
15918
+ type: "number",
15919
+ key: "byteTrackLowIouThreshold",
15920
+ label: "Two-stage low IoU",
15921
+ 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.",
15922
+ min: 0,
15923
+ max: 1,
15924
+ step: .05,
15925
+ default: TRACKING_DEFAULTS.byteTrackLowIouThreshold
15926
+ },
15927
+ {
15928
+ type: "boolean",
15929
+ key: "minSpawnAreaEnabled",
15930
+ label: "Minimum spawn size",
15931
+ 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.",
15932
+ default: TRACKING_DEFAULTS.minSpawnAreaEnabled
15933
+ },
15934
+ {
15935
+ type: "number",
15936
+ key: "minSpawnAreaFracPerson",
15937
+ label: "Min spawn area — person",
15938
+ 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.",
15939
+ min: 0,
15940
+ max: 1,
15941
+ step: 5e-4,
15942
+ default: TRACKING_DEFAULTS.minSpawnAreaFracPerson
15943
+ },
15944
+ {
15945
+ type: "number",
15946
+ key: "minSpawnAreaFracAnimal",
15947
+ label: "Min spawn area — animal",
15948
+ description: "Smallest animal box (fraction of frame area) allowed to start a NEW track. 0 = ungated (animal already has a spawn-score floor).",
15949
+ min: 0,
15950
+ max: 1,
15951
+ step: 5e-4,
15952
+ default: TRACKING_DEFAULTS.minSpawnAreaFracAnimal
15953
+ },
15954
+ {
15955
+ type: "number",
15956
+ key: "minSpawnAreaFracVehicle",
15957
+ label: "Min spawn area — vehicle",
15958
+ description: "Smallest vehicle box (fraction of frame area) allowed to start a NEW track. 0 = ungated (vehicle already has a spawn-score floor).",
15959
+ min: 0,
15960
+ max: 1,
15961
+ step: 5e-4,
15962
+ default: TRACKING_DEFAULTS.minSpawnAreaFracVehicle
15963
+ },
15964
+ {
15965
+ type: "boolean",
15966
+ key: "classGroupAssoc",
15967
+ label: "Person/animal group matching",
15968
+ 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.",
15969
+ default: TRACKING_DEFAULTS.classGroupAssoc
15203
15970
  }
15204
15971
  ]
15205
15972
  },
@@ -15275,7 +16042,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15275
16042
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
15276
16043
  const baseSections = schema ? require_dist.hydrateSchema({
15277
16044
  ...schema,
15278
- sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
16045
+ sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
15279
16046
  }, raw).sections : [];
15280
16047
  const liveStatsSection = {
15281
16048
  id: "live-stats",
@@ -15324,7 +16091,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15324
16091
  }
15325
16092
  };
15326
16093
  //#endregion
16094
+ exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
15327
16095
  exports.default = PipelineAnalyticsAddon;
15328
16096
  exports.pickCleanMedia = pickCleanMedia;
16097
+ exports.retagDetectionSections = retagDetectionSections;
15329
16098
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
15330
16099
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;