@camstack/addon-post-analysis 1.1.37 → 1.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-_h-RM5Lr.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,29 @@ 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
1129
1145
  };
1146
+ /** Macro-classes the duplicate resolver may collapse across (people in
1147
+ * non-upright poses misdetected as animals). Any other cross-class pair is
1148
+ * NEVER a duplicate. */
1149
+ var PERSON_CLASS = "person";
1150
+ var ANIMAL_CLASS = "animal";
1130
1151
  var MAX_PATH_LENGTH = 300;
1131
1152
  function clamp(value, min, max) {
1132
1153
  return Math.max(min, Math.min(max, value));
@@ -1149,16 +1170,102 @@ function containment(inner, outer) {
1149
1170
  const innerArea = inner.w * inner.h;
1150
1171
  return innerArea > 0 ? iw * ih / innerArea : 0;
1151
1172
  }
1152
- var SortTracker = class {
1173
+ var SortTracker = class SortTracker {
1153
1174
  config;
1154
1175
  tracks = [];
1155
1176
  lostTracks = [];
1177
+ /**
1178
+ * Consecutive-frame overlap streak per unordered live-track pair
1179
+ * (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
1180
+ * merged only once its streak reaches `dedupMergeFrames`. Entries reset the
1181
+ * moment the pair stops overlapping and are pruned when either track dies.
1182
+ */
1183
+ dupStreaks = /* @__PURE__ */ new Map();
1156
1184
  constructor(config = {}) {
1157
1185
  this.config = {
1158
1186
  ...DEFAULT_TRACKER_CONFIG,
1159
1187
  ...config
1160
1188
  };
1161
1189
  }
1190
+ /** Whether two macro-classes may be collapsed as the SAME subject. Same class
1191
+ * always; the {person,animal} pair only when `personAnimalDedup` is on. */
1192
+ dedupCompatible(a, b) {
1193
+ if (a === b) return true;
1194
+ if (!this.config.personAnimalDedup) return false;
1195
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
1196
+ }
1197
+ /** Unordered, stable key for a track pair (UUIDs never contain `|`). */
1198
+ static pairKey(a, b) {
1199
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
1200
+ }
1201
+ /**
1202
+ * True when spawning a fresh track for `det` would duplicate an already-live
1203
+ * track: (a) it overlaps a concurrent compatible-class track by
1204
+ * `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
1205
+ * concurrent `person` track (person-in-odd-pose false animal). `live` is the
1206
+ * set of tracks already surviving this frame (matched, coasting, resurrected,
1207
+ * and earlier same-frame spawns).
1208
+ */
1209
+ isDuplicateSpawn(det, live) {
1210
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
1211
+ if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
1212
+ for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
1213
+ }
1214
+ return false;
1215
+ }
1216
+ /** The lower-importance track of a duplicate pair — the one to drop. A
1217
+ * `person` always beats a cross-class `animal`; otherwise more hits wins,
1218
+ * ties break to the older track, then the higher score. */
1219
+ static duplicateLoser(a, b) {
1220
+ if (a.class !== b.class) {
1221
+ if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
1222
+ if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
1223
+ }
1224
+ if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
1225
+ if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
1226
+ return a.score >= b.score ? b : a;
1227
+ }
1228
+ /**
1229
+ * Merge concurrent duplicate tracks. Two live compatible-class tracks that
1230
+ * stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
1231
+ * frames are collapsed: the lower-importance one is dropped (NOT graveyarded
1232
+ * — a confirmed duplicate must not resurrect). This is the fix for a subject
1233
+ * the detector double-fires (two boxes each frame, each feeding its OWN
1234
+ * track, so neither is ever "unmatched" for spawn-suppression to catch) and
1235
+ * for a person misdetected as `animal` alongside the real person track.
1236
+ */
1237
+ resolveDuplicates() {
1238
+ if (!this.config.dedupEnabled) return;
1239
+ const live = this.tracks;
1240
+ const liveIds = new Set(live.map((t) => t.id));
1241
+ for (const key of this.dupStreaks.keys()) {
1242
+ const [a, b] = key.split("|");
1243
+ if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
1244
+ }
1245
+ const dropIds = /* @__PURE__ */ new Set();
1246
+ for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
1247
+ const a = live[i];
1248
+ const b = live[j];
1249
+ if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
1250
+ if (!this.dedupCompatible(a.class, b.class)) continue;
1251
+ const key = SortTracker.pairKey(a.id, b.id);
1252
+ if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
1253
+ this.dupStreaks.delete(key);
1254
+ continue;
1255
+ }
1256
+ const streak = (this.dupStreaks.get(key) ?? 0) + 1;
1257
+ if (streak >= this.config.dedupMergeFrames) {
1258
+ dropIds.add(SortTracker.duplicateLoser(a, b).id);
1259
+ this.dupStreaks.delete(key);
1260
+ } else this.dupStreaks.set(key, streak);
1261
+ }
1262
+ if (dropIds.size === 0) return;
1263
+ this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
1264
+ for (const key of this.dupStreaks.keys()) {
1265
+ const [a, b] = key.split("|");
1266
+ if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
1267
+ }
1268
+ }
1162
1269
  /** Where a track is expected this frame — extrapolated by velocity while
1163
1270
  * coasting (predictiveCoasting), else its last known bbox. A stationary
1164
1271
  * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
@@ -1193,6 +1300,46 @@ var SortTracker = class {
1193
1300
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
1194
1301
  return dist <= this.config.rescueCentroidFactor * diag;
1195
1302
  }
1303
+ /** Fold a matched detection's class + score into a track's lifetime vote
1304
+ * tally. A non-positive score still counts as an infinitesimal vote so a
1305
+ * zero-confidence frame contributes to the frame-count tiebreak. */
1306
+ addClassVote(track, det) {
1307
+ const weight = det.score > 0 ? det.score : Number.EPSILON;
1308
+ track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
1309
+ }
1310
+ /**
1311
+ * The class to REPORT for a track: the confidence-weighted lifetime majority
1312
+ * when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
1313
+ * weight; otherwise the latest-frame class (ambiguous vote or voting off).
1314
+ */
1315
+ resolveReportedClass(t) {
1316
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
1317
+ let bestClass = t.class;
1318
+ let bestVote = -1;
1319
+ let total = 0;
1320
+ for (const [cls, v] of t.classVotes) {
1321
+ total += v;
1322
+ if (v > bestVote) {
1323
+ bestVote = v;
1324
+ bestClass = cls;
1325
+ }
1326
+ }
1327
+ if (total <= 0) return t.class;
1328
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
1329
+ }
1330
+ /** Whether a detection clears its per-class spawn score floor. Only gates NEW
1331
+ * spawns — an established track still matches below its floor. */
1332
+ meetsClassScoreFloor(det) {
1333
+ if (!this.config.perClassMinScoreEnabled) return true;
1334
+ const floor = this.config.classMinScores[det.class] ?? 0;
1335
+ return det.score >= floor;
1336
+ }
1337
+ /** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
1338
+ * a single very-high-confidence detection bypassed the hit gate (fast car). */
1339
+ isConfirmedForEmit(t) {
1340
+ if (t.hits >= this.config.minHits) return true;
1341
+ return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1342
+ }
1196
1343
  update(detections, timestamp) {
1197
1344
  if (this.config.maxTrackLifetimeMs > 0) {
1198
1345
  const alive = [];
@@ -1277,6 +1424,7 @@ var SortTracker = class {
1277
1424
  };
1278
1425
  track.path.push(det.bbox);
1279
1426
  if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
1427
+ this.addClassVote(track, det);
1280
1428
  }
1281
1429
  const occluderBoxes = [];
1282
1430
  for (const track of matchedTracks) occluderBoxes.push(track.bbox);
@@ -1326,13 +1474,30 @@ var SortTracker = class {
1326
1474
  };
1327
1475
  best.path.push(det.bbox);
1328
1476
  if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
1477
+ this.addClassVote(best, det);
1329
1478
  surviving.push(best);
1330
1479
  used.add(di);
1331
1480
  }
1332
- for (let di = 0; di < detections.length; di++) {
1333
- if (used.has(di)) continue;
1481
+ const unmatchedIdx = [];
1482
+ for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
1483
+ const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
1484
+ unmatchedIdx.sort((ia, ib) => {
1485
+ const da = detections[ia];
1486
+ const db = detections[ib];
1487
+ const r = classRank(da.class) - classRank(db.class);
1488
+ return r !== 0 ? r : db.score - da.score;
1489
+ });
1490
+ for (const di of unmatchedIdx) {
1334
1491
  const det = detections[di];
1492
+ if (!this.meetsClassScoreFloor(det)) {
1493
+ used.add(di);
1494
+ continue;
1495
+ }
1335
1496
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1497
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1498
+ used.add(di);
1499
+ continue;
1500
+ }
1336
1501
  surviving.push({
1337
1502
  id: (0, node_crypto.randomUUID)(),
1338
1503
  bbox: det.bbox,
@@ -1350,12 +1515,14 @@ var SortTracker = class {
1350
1515
  },
1351
1516
  lost: false,
1352
1517
  lostAt: 0,
1353
- resurrectable: true
1518
+ resurrectable: true,
1519
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
1354
1520
  });
1355
1521
  }
1356
1522
  this.tracks = surviving;
1357
- return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
1358
- class: t.class,
1523
+ this.resolveDuplicates();
1524
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
1525
+ class: this.resolveReportedClass(t),
1359
1526
  originalClass: t.originalClass,
1360
1527
  score: t.score,
1361
1528
  bbox: t.bbox,
@@ -1384,6 +1551,7 @@ var SortTracker = class {
1384
1551
  reset() {
1385
1552
  this.tracks = [];
1386
1553
  this.lostTracks = [];
1554
+ this.dupStreaks.clear();
1387
1555
  }
1388
1556
  };
1389
1557
  //#endregion
@@ -6101,6 +6269,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
6101
6269
  h: Math.round(ch)
6102
6270
  };
6103
6271
  }
6272
+ /**
6273
+ * The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
6274
+ * NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
6275
+ *
6276
+ * A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
6277
+ * aspect ratio (the native crop path downscales while preserving aspect), so the
6278
+ * region computed from the detection frame's dimensions addresses the exact same
6279
+ * ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
6280
+ * (single source of truth) and divides by the frame dimensions.
6281
+ */
6282
+ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6283
+ const region = squareSafeCropRegion(bbox, frame, padding);
6284
+ return {
6285
+ x: region.x / frame.W,
6286
+ y: region.y / frame.H,
6287
+ w: region.w / frame.W,
6288
+ h: region.h / frame.H
6289
+ };
6290
+ }
6104
6291
  //#endregion
6105
6292
  //#region src/shared/frame/box-drawer.ts
6106
6293
  var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
@@ -6178,10 +6365,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
6178
6365
  * Small downscaled `thumbnail`s are intentionally left out for now — when we
6179
6366
  * reintroduce them they'll be a separate small kind. */
6180
6367
  var MEDIA_QUALITY = 88;
6181
- /** Output dimensions for square-safe 16:9 crops (crop/faceCrop/plateCrop). */
6368
+ /** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
6369
+ * (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
6182
6370
  var CROP_WIDTH = 640;
6183
6371
  var CROP_HEIGHT = 360;
6184
6372
  var CROP_QUALITY = 80;
6373
+ /**
6374
+ * Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
6375
+ * `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
6376
+ * ≥224px classifier input straight from the runner's native surface, WITHOUT
6377
+ * hauling a full 1920px frame per subject (that width is reserved for the
6378
+ * full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
6379
+ * the ≤640 local crop, so quality never regresses below today's behaviour.
6380
+ */
6381
+ var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
6185
6382
  function caption(className, confidence, label) {
6186
6383
  const base = label && label !== className ? `${className} ${label}` : className;
6187
6384
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
@@ -6288,12 +6485,12 @@ var EventMediaDispatcher = class {
6288
6485
  });
6289
6486
  return empty;
6290
6487
  }
6291
- for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6488
+ for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
6292
6489
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6293
6490
  const storedSnapshots = [];
6294
6491
  const thumbnailTrackIds = [];
6295
6492
  for (const sn of snapshots) {
6296
- const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6493
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6297
6494
  if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6298
6495
  if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6299
6496
  }
@@ -6314,7 +6511,7 @@ var EventMediaDispatcher = class {
6314
6511
  * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6315
6512
  * landed this frame (#27-A) so the caller can stop forcing retries.
6316
6513
  */
6317
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6514
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6318
6515
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6319
6516
  storedSnapshot: null,
6320
6517
  thumbnailWritten: false
@@ -6355,7 +6552,7 @@ var EventMediaDispatcher = class {
6355
6552
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6356
6553
  let thumbnailWritten = false;
6357
6554
  if (sn.bestThumbnail) try {
6358
- const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6555
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
6359
6556
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6360
6557
  } catch (err) {
6361
6558
  this.deps.logger.warn("event media: track thumbnail crop failed", {
@@ -6374,12 +6571,38 @@ var EventMediaDispatcher = class {
6374
6571
  };
6375
6572
  }
6376
6573
  /**
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.
6574
+ * Clean subject-centered crop of `bbox` the shared output contract of the
6575
+ * object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
6576
+ * `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
6577
+ *
6578
+ * NATIVE-FIRST: the region is requested from the runner's retained native
6579
+ * surface (normalized [0,1] coords map directly onto it), downscaled to
6580
+ * {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
6581
+ * miss/error (or a runner without the method) it FALLS BACK to cropping the
6582
+ * resolved ≤640 frame locally (today's behaviour), so quality never regresses.
6583
+ * Both paths run inside the live-handle window opened by `captureForFrame`.
6584
+ */
6585
+ async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
6586
+ if (this.deps.getNativeCropJpeg) try {
6587
+ const norm = squareSafeCropRegionNormalized(bbox, {
6588
+ W: fw,
6589
+ H: fh
6590
+ }, cropPadding);
6591
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
6592
+ if (native) return native;
6593
+ } catch (err) {
6594
+ this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
6595
+ shmId: frameHandle.shmId,
6596
+ error: err instanceof Error ? err.message : String(err)
6597
+ } });
6598
+ }
6599
+ return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
6600
+ }
6601
+ /**
6602
+ * Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
6603
+ * to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
6381
6604
  */
6382
- async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
6605
+ async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
6383
6606
  const region = squareSafeCropRegion(bbox, {
6384
6607
  W: fw,
6385
6608
  H: fh
@@ -6422,13 +6645,13 @@ var EventMediaDispatcher = class {
6422
6645
  return false;
6423
6646
  }
6424
6647
  }
6425
- async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
6648
+ async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
6426
6649
  const box = {
6427
6650
  ...ev.bbox,
6428
6651
  label: caption(ev.className, ev.confidence, ev.label)
6429
6652
  };
6430
6653
  try {
6431
- const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
6654
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
6432
6655
  await this.deps.mediaStore.put({
6433
6656
  deviceId,
6434
6657
  ownerKind: "event",
@@ -6488,24 +6711,7 @@ var EventMediaDispatcher = class {
6488
6711
  });
6489
6712
  }
6490
6713
  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();
6714
+ const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
6509
6715
  await this.deps.mediaStore.put({
6510
6716
  deviceId,
6511
6717
  ownerKind: "event",
@@ -7666,6 +7872,56 @@ var TrackingSettingsSchema = require_dist.object({
7666
7872
  /** Speed (px/frame) below which a track's prediction is frozen (stationary
7667
7873
  * jitter can't drift the box off a sitting object). */
7668
7874
  stationarySpeedPx: require_dist.number().min(0).default(2),
7875
+ /** Suppress + merge concurrent duplicate tracks (one subject the detector
7876
+ * double-fires, or a person misdetected as `animal`, otherwise becomes two
7877
+ * time-overlapping tracks that never re-associate — each firing its own
7878
+ * event). Off = legacy behaviour. */
7879
+ dedupEnabled: require_dist.boolean().default(true),
7880
+ /** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
7881
+ * concurrent compatible-class track and suppressed. High so distinct
7882
+ * subjects appearing close together are not collapsed. */
7883
+ dedupSpawnIou: require_dist.number().min(0).max(1).default(.6),
7884
+ /** Envelope IoU at/above which two concurrent tracks count as overlapping for
7885
+ * the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
7886
+ * merged duplicate cannot re-spawn. */
7887
+ dedupMergeIou: require_dist.number().min(0).max(1).default(.6),
7888
+ /** Consecutive overlapping frames before two live tracks are merged (the
7889
+ * lower-importance one dropped). Higher = more conservative. */
7890
+ dedupMergeFrames: require_dist.number().int().min(1).default(5),
7891
+ /** Treat the {person,animal} class pair as duplicate-compatible — a person in
7892
+ * a non-upright pose is misdetected as `animal`; the false animal track
7893
+ * collapses into the real person track. */
7894
+ personAnimalDedup: require_dist.boolean().default(true),
7895
+ /** A low-confidence `animal` (score below this) overlapping a concurrent
7896
+ * person track is suppressed at spawn. */
7897
+ animalOverPersonMaxScore: require_dist.number().min(0).max(1).default(.6),
7898
+ /** IoU with a concurrent person track that triggers the low-confidence animal
7899
+ * spawn suppression. */
7900
+ animalOverPersonIou: require_dist.number().min(0).max(1).default(.2),
7901
+ /** Resolve a track's reported class by a confidence-weighted majority over its
7902
+ * lifetime (vs. the latest frame) — kills per-frame class flips (a person
7903
+ * read as `animal` on one crouch frame keeps the `person` label). */
7904
+ classVotingEnabled: require_dist.boolean().default(true),
7905
+ /** Winning class must hold at least this fraction of a track's total vote
7906
+ * weight to override the latest-frame class; below it the latest wins (so a
7907
+ * genuine mid-life reclassification is never frozen out). */
7908
+ classVoteMinFraction: require_dist.number().min(0).max(1).default(.5),
7909
+ /** Enforce a per-class minimum detection score at track SPAWN (an established
7910
+ * track still matches below its floor — only new spawns are gated). */
7911
+ perClassMinScoreEnabled: require_dist.boolean().default(true),
7912
+ /** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
7913
+ minScorePerson: require_dist.number().min(0).max(1).default(0),
7914
+ /** Minimum spawn score for `animal` (FP-prone — higher floor). */
7915
+ minScoreAnimal: require_dist.number().min(0).max(1).default(.45),
7916
+ /** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
7917
+ minScoreVehicle: require_dist.number().min(0).max(1).default(.45),
7918
+ /** Let a single very-high-confidence detection confirm a track for emission
7919
+ * before it reaches `minHits` (so a fast car crossing in 1-2 frames still
7920
+ * registers). Opt-in — default OFF keeps the strict N-hit gate. */
7921
+ confirmBypassEnabled: require_dist.boolean().default(false),
7922
+ /** Score at/above which a detection confirms its track immediately (bypasses
7923
+ * `minHits`). Only consulted when `confirmBypassEnabled`. */
7924
+ confirmBypassScore: require_dist.number().min(0).max(1).default(.9),
7669
7925
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
7670
7926
  dropoutSkipEnabled: require_dist.boolean().default(true),
7671
7927
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
@@ -7700,6 +7956,21 @@ function resolveTrackingSettings(raw) {
7700
7956
  rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
7701
7957
  resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
7702
7958
  stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
7959
+ dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
7960
+ dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
7961
+ dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
7962
+ dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
7963
+ personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
7964
+ animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
7965
+ animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
7966
+ classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
7967
+ classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
7968
+ perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
7969
+ minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
7970
+ minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
7971
+ minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
7972
+ confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
7973
+ confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7703
7974
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7704
7975
  dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
7705
7976
  };
@@ -8108,6 +8379,40 @@ function planPeriodicMedia(input) {
8108
8379
  };
8109
8380
  }
8110
8381
  //#endregion
8382
+ //#region src/pipeline-analytics/best-thumbnail-guard.ts
8383
+ /**
8384
+ * Void/envArea guard for best-`thumbnail` selection.
8385
+ *
8386
+ * ## Why this exists (the dawn/night "void" thumbnail)
8387
+ *
8388
+ * At dawn/night a moving subject's tracker box intermittently EXPLODES to
8389
+ * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
8390
+ * that frame happens to win the best-detection race, the gallery/reel best
8391
+ * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
8392
+ * washed-out frame), not the subject. This guard rejects such a frame from the
8393
+ * best-`thumbnail` decision so the track keeps a real subject-centered tile.
8394
+ *
8395
+ * Conservative by design: it only rejects boxes covering ≥ {@link
8396
+ * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
8397
+ * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
8398
+ * per-frame retry keeps trying until a plausible frame wins.
8399
+ */
8400
+ /**
8401
+ * Area fraction at/above which a detection bbox is treated as an exploded
8402
+ * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
8403
+ * guard conservative — only boxes covering ≥85% of the frame are rejected.
8404
+ */
8405
+ var NEAR_FULL_FRAME_AREA = .85;
8406
+ /**
8407
+ * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
8408
+ * area is below the near-full-frame threshold. Degenerate frame dimensions
8409
+ * (≤0) are treated as plausible (no info to reject on).
8410
+ */
8411
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8412
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
8413
+ return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
8414
+ }
8415
+ //#endregion
8111
8416
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
8112
8417
  /**
8113
8418
  * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
@@ -11415,6 +11720,35 @@ function toAnalyticsDeviceSections(sections) {
11415
11720
  }));
11416
11721
  }
11417
11722
  /**
11723
+ * Global-analytics section ids that are really per-camera DETECTION knobs and
11724
+ * belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
11725
+ * Object Detection — NOT under the generic `Analytics` top-tab:
11726
+ * - `detection-sensitivity` — minHits / cooldown / stationary threshold.
11727
+ * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
11728
+ * animal dedup, class voting, confirm-bypass, per-class min score).
11729
+ * - `stationary-objects` — stationary promotion + occupancy tuning.
11730
+ * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
11731
+ * structural Detection pipeline tab, so re-tagging is all that's needed —
11732
+ * there is no admin-ui change and no duplicate render (a section has one tab).
11733
+ */
11734
+ var DETECTION_PIPELINE_SECTION_IDS = new Set([
11735
+ "detection-sensitivity",
11736
+ "tracking",
11737
+ "stationary-objects"
11738
+ ]);
11739
+ /**
11740
+ * Re-home the detection-knob sections from the `Analytics` top-tab onto the
11741
+ * `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
11742
+ * section changes; every other section (media policy, retention, faces, track
11743
+ * history) stays on Analytics.
11744
+ */
11745
+ function retagDetectionSections(sections) {
11746
+ return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
11747
+ ...s,
11748
+ tab: "detection-pipeline"
11749
+ } : s);
11750
+ }
11751
+ /**
11418
11752
  * Fields that live ONLY on the global settings page and must never surface in a
11419
11753
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
11420
11754
  * master kill for the whole subsystem — per-camera face production is governed
@@ -11628,7 +11962,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11628
11962
  let storage = this.ctx.kernel.storage;
11629
11963
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
11630
11964
  if (mediaRoot) {
11631
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CDbDhtGa.js"));
11965
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BiZGDArd.js"));
11632
11966
  storage = new FilesystemStorageProvider(mediaRoot);
11633
11967
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11634
11968
  }
@@ -11796,11 +12130,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11796
12130
  timestamp: 0
11797
12131
  };
11798
12132
  };
11799
- this.eventMediaDispatcher = new EventMediaDispatcher({
11800
- getRemoteFrame,
11801
- mediaStore: this.mediaStore,
11802
- logger: logger.child("EventMediaDispatcher")
11803
- });
11804
12133
  const cropMetricLogger = logger.child("NativeCrop");
11805
12134
  let nativeHits = 0;
11806
12135
  let nativeFallbacks = 0;
@@ -11832,6 +12161,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11832
12161
  return null;
11833
12162
  }
11834
12163
  };
12164
+ const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12165
+ const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
12166
+ bumpCropMetric(jpeg !== null);
12167
+ return jpeg;
12168
+ };
12169
+ this.eventMediaDispatcher = new EventMediaDispatcher({
12170
+ getRemoteFrame,
12171
+ getNativeCropJpeg,
12172
+ mediaStore: this.mediaStore,
12173
+ logger: logger.child("EventMediaDispatcher")
12174
+ });
11835
12175
  const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
11836
12176
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
11837
12177
  const paddedNorm = padBbox({
@@ -13272,7 +13612,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13272
13612
  });
13273
13613
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13274
13614
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13275
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
13615
+ const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13616
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
13276
13617
  targets.push({
13277
13618
  trackId: t.trackId,
13278
13619
  timestamp,
@@ -13280,7 +13621,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13280
13621
  ...t.label ? { label: t.label } : {},
13281
13622
  appendSnapshot: plan.appendSnapshot,
13282
13623
  rollingLastFrame: plan.rollingLastFrame,
13283
- bestThumbnail: plan.bestThumbnail
13624
+ bestThumbnail
13284
13625
  });
13285
13626
  }
13286
13627
  return targets;
@@ -13882,7 +14223,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13882
14223
  rescueCentroidFactor: trk.rescueCentroidFactor,
13883
14224
  resurrectionWindowMs: trk.resurrectionWindowMs,
13884
14225
  stationarySpeedPx: trk.stationarySpeedPx,
13885
- maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
14226
+ maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
14227
+ dedupEnabled: trk.dedupEnabled,
14228
+ dedupSpawnIou: trk.dedupSpawnIou,
14229
+ dedupMergeIou: trk.dedupMergeIou,
14230
+ dedupMergeFrames: trk.dedupMergeFrames,
14231
+ personAnimalDedup: trk.personAnimalDedup,
14232
+ animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
14233
+ animalOverPersonIou: trk.animalOverPersonIou,
14234
+ classVotingEnabled: trk.classVotingEnabled,
14235
+ classVoteMinFraction: trk.classVoteMinFraction,
14236
+ perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
14237
+ classMinScores: {
14238
+ person: trk.minScorePerson,
14239
+ animal: trk.minScoreAnimal,
14240
+ vehicle: trk.minScoreVehicle
14241
+ },
14242
+ confirmBypassEnabled: trk.confirmBypassEnabled,
14243
+ confirmBypassScore: trk.confirmBypassScore
13886
14244
  }, { stationaryThresholdSec }, {
13887
14245
  minTrackAge,
13888
14246
  cooldownSec,
@@ -15185,6 +15543,69 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15185
15543
  default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
15186
15544
  unit: "ms"
15187
15545
  },
15546
+ {
15547
+ type: "boolean",
15548
+ key: "dedupEnabled",
15549
+ label: "Duplicate-track suppression",
15550
+ 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.",
15551
+ default: TRACKING_DEFAULTS.dedupEnabled
15552
+ },
15553
+ {
15554
+ type: "number",
15555
+ key: "dedupSpawnIou",
15556
+ label: "Duplicate spawn IoU",
15557
+ 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.",
15558
+ min: 0,
15559
+ max: 1,
15560
+ step: .05,
15561
+ default: TRACKING_DEFAULTS.dedupSpawnIou
15562
+ },
15563
+ {
15564
+ type: "number",
15565
+ key: "dedupMergeIou",
15566
+ label: "Duplicate merge IoU",
15567
+ description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
15568
+ min: 0,
15569
+ max: 1,
15570
+ step: .05,
15571
+ default: TRACKING_DEFAULTS.dedupMergeIou
15572
+ },
15573
+ {
15574
+ type: "number",
15575
+ key: "dedupMergeFrames",
15576
+ label: "Duplicate merge frames",
15577
+ description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
15578
+ min: 1,
15579
+ step: 1,
15580
+ default: TRACKING_DEFAULTS.dedupMergeFrames
15581
+ },
15582
+ {
15583
+ type: "boolean",
15584
+ key: "personAnimalDedup",
15585
+ label: "Person/animal duplicate merge",
15586
+ 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.",
15587
+ default: TRACKING_DEFAULTS.personAnimalDedup
15588
+ },
15589
+ {
15590
+ type: "number",
15591
+ key: "animalOverPersonMaxScore",
15592
+ label: "Animal-over-person max score",
15593
+ 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.",
15594
+ min: 0,
15595
+ max: 1,
15596
+ step: .05,
15597
+ default: TRACKING_DEFAULTS.animalOverPersonMaxScore
15598
+ },
15599
+ {
15600
+ type: "number",
15601
+ key: "animalOverPersonIou",
15602
+ label: "Animal-over-person IoU",
15603
+ description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
15604
+ min: 0,
15605
+ max: 1,
15606
+ step: .05,
15607
+ default: TRACKING_DEFAULTS.animalOverPersonIou
15608
+ },
15188
15609
  {
15189
15610
  type: "boolean",
15190
15611
  key: "dropoutSkipEnabled",
@@ -15200,6 +15621,77 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15200
15621
  min: 0,
15201
15622
  step: 1,
15202
15623
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
15624
+ },
15625
+ {
15626
+ type: "boolean",
15627
+ key: "classVotingEnabled",
15628
+ label: "Per-track class voting",
15629
+ 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.",
15630
+ default: TRACKING_DEFAULTS.classVotingEnabled
15631
+ },
15632
+ {
15633
+ type: "number",
15634
+ key: "classVoteMinFraction",
15635
+ label: "Class-vote min fraction",
15636
+ 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).",
15637
+ min: 0,
15638
+ max: 1,
15639
+ step: .05,
15640
+ default: TRACKING_DEFAULTS.classVoteMinFraction
15641
+ },
15642
+ {
15643
+ type: "boolean",
15644
+ key: "perClassMinScoreEnabled",
15645
+ label: "Per-class spawn confidence",
15646
+ 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.",
15647
+ default: TRACKING_DEFAULTS.perClassMinScoreEnabled
15648
+ },
15649
+ {
15650
+ type: "number",
15651
+ key: "minScorePerson",
15652
+ label: "Min spawn score — person",
15653
+ description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
15654
+ min: 0,
15655
+ max: 1,
15656
+ step: .05,
15657
+ default: TRACKING_DEFAULTS.minScorePerson
15658
+ },
15659
+ {
15660
+ type: "number",
15661
+ key: "minScoreAnimal",
15662
+ label: "Min spawn score — animal",
15663
+ description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
15664
+ min: 0,
15665
+ max: 1,
15666
+ step: .05,
15667
+ default: TRACKING_DEFAULTS.minScoreAnimal
15668
+ },
15669
+ {
15670
+ type: "number",
15671
+ key: "minScoreVehicle",
15672
+ label: "Min spawn score — vehicle",
15673
+ description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
15674
+ min: 0,
15675
+ max: 1,
15676
+ step: .05,
15677
+ default: TRACKING_DEFAULTS.minScoreVehicle
15678
+ },
15679
+ {
15680
+ type: "boolean",
15681
+ key: "confirmBypassEnabled",
15682
+ label: "Fast high-confidence confirm",
15683
+ 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).",
15684
+ default: TRACKING_DEFAULTS.confirmBypassEnabled
15685
+ },
15686
+ {
15687
+ type: "number",
15688
+ key: "confirmBypassScore",
15689
+ label: "Fast-confirm score",
15690
+ description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
15691
+ min: 0,
15692
+ max: 1,
15693
+ step: .05,
15694
+ default: TRACKING_DEFAULTS.confirmBypassScore
15203
15695
  }
15204
15696
  ]
15205
15697
  },
@@ -15275,7 +15767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15275
15767
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
15276
15768
  const baseSections = schema ? require_dist.hydrateSchema({
15277
15769
  ...schema,
15278
- sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
15770
+ sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
15279
15771
  }, raw).sections : [];
15280
15772
  const liveStatsSection = {
15281
15773
  id: "live-stats",
@@ -15324,7 +15816,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15324
15816
  }
15325
15817
  };
15326
15818
  //#endregion
15819
+ exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
15327
15820
  exports.default = PipelineAnalyticsAddon;
15328
15821
  exports.pickCleanMedia = pickCleanMedia;
15822
+ exports.retagDetectionSections = retagDetectionSections;
15329
15823
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
15330
15824
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;