@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.
@@ -1,4 +1,4 @@
1
- import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-DPBet4IQ.mjs";
1
+ import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-CSMfGdnz.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -1120,8 +1120,29 @@ var DEFAULT_TRACKER_CONFIG = {
1120
1120
  rescueIouThreshold: .1,
1121
1121
  rescueCentroidFactor: .75,
1122
1122
  resurrectionWindowMs: 8e3,
1123
- stationarySpeedPx: 2
1123
+ stationarySpeedPx: 2,
1124
+ dedupEnabled: true,
1125
+ dedupSpawnIou: .6,
1126
+ dedupMergeIou: .6,
1127
+ dedupMergeFrames: 5,
1128
+ personAnimalDedup: true,
1129
+ animalOverPersonMaxScore: .6,
1130
+ animalOverPersonIou: .2,
1131
+ classVotingEnabled: true,
1132
+ classVoteMinFraction: .5,
1133
+ perClassMinScoreEnabled: true,
1134
+ classMinScores: {
1135
+ animal: .45,
1136
+ vehicle: .45
1137
+ },
1138
+ confirmBypassEnabled: false,
1139
+ confirmBypassScore: .9
1124
1140
  };
1141
+ /** Macro-classes the duplicate resolver may collapse across (people in
1142
+ * non-upright poses misdetected as animals). Any other cross-class pair is
1143
+ * NEVER a duplicate. */
1144
+ var PERSON_CLASS = "person";
1145
+ var ANIMAL_CLASS = "animal";
1125
1146
  var MAX_PATH_LENGTH = 300;
1126
1147
  function clamp(value, min, max) {
1127
1148
  return Math.max(min, Math.min(max, value));
@@ -1144,16 +1165,102 @@ function containment(inner, outer) {
1144
1165
  const innerArea = inner.w * inner.h;
1145
1166
  return innerArea > 0 ? iw * ih / innerArea : 0;
1146
1167
  }
1147
- var SortTracker = class {
1168
+ var SortTracker = class SortTracker {
1148
1169
  config;
1149
1170
  tracks = [];
1150
1171
  lostTracks = [];
1172
+ /**
1173
+ * Consecutive-frame overlap streak per unordered live-track pair
1174
+ * (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
1175
+ * merged only once its streak reaches `dedupMergeFrames`. Entries reset the
1176
+ * moment the pair stops overlapping and are pruned when either track dies.
1177
+ */
1178
+ dupStreaks = /* @__PURE__ */ new Map();
1151
1179
  constructor(config = {}) {
1152
1180
  this.config = {
1153
1181
  ...DEFAULT_TRACKER_CONFIG,
1154
1182
  ...config
1155
1183
  };
1156
1184
  }
1185
+ /** Whether two macro-classes may be collapsed as the SAME subject. Same class
1186
+ * always; the {person,animal} pair only when `personAnimalDedup` is on. */
1187
+ dedupCompatible(a, b) {
1188
+ if (a === b) return true;
1189
+ if (!this.config.personAnimalDedup) return false;
1190
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
1191
+ }
1192
+ /** Unordered, stable key for a track pair (UUIDs never contain `|`). */
1193
+ static pairKey(a, b) {
1194
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
1195
+ }
1196
+ /**
1197
+ * True when spawning a fresh track for `det` would duplicate an already-live
1198
+ * track: (a) it overlaps a concurrent compatible-class track by
1199
+ * `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
1200
+ * concurrent `person` track (person-in-odd-pose false animal). `live` is the
1201
+ * set of tracks already surviving this frame (matched, coasting, resurrected,
1202
+ * and earlier same-frame spawns).
1203
+ */
1204
+ isDuplicateSpawn(det, live) {
1205
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
1206
+ if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
1207
+ for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
1208
+ }
1209
+ return false;
1210
+ }
1211
+ /** The lower-importance track of a duplicate pair — the one to drop. A
1212
+ * `person` always beats a cross-class `animal`; otherwise more hits wins,
1213
+ * ties break to the older track, then the higher score. */
1214
+ static duplicateLoser(a, b) {
1215
+ if (a.class !== b.class) {
1216
+ if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
1217
+ if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
1218
+ }
1219
+ if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
1220
+ if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
1221
+ return a.score >= b.score ? b : a;
1222
+ }
1223
+ /**
1224
+ * Merge concurrent duplicate tracks. Two live compatible-class tracks that
1225
+ * stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
1226
+ * frames are collapsed: the lower-importance one is dropped (NOT graveyarded
1227
+ * — a confirmed duplicate must not resurrect). This is the fix for a subject
1228
+ * the detector double-fires (two boxes each frame, each feeding its OWN
1229
+ * track, so neither is ever "unmatched" for spawn-suppression to catch) and
1230
+ * for a person misdetected as `animal` alongside the real person track.
1231
+ */
1232
+ resolveDuplicates() {
1233
+ if (!this.config.dedupEnabled) return;
1234
+ const live = this.tracks;
1235
+ const liveIds = new Set(live.map((t) => t.id));
1236
+ for (const key of this.dupStreaks.keys()) {
1237
+ const [a, b] = key.split("|");
1238
+ if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
1239
+ }
1240
+ const dropIds = /* @__PURE__ */ new Set();
1241
+ for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
1242
+ const a = live[i];
1243
+ const b = live[j];
1244
+ if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
1245
+ if (!this.dedupCompatible(a.class, b.class)) continue;
1246
+ const key = SortTracker.pairKey(a.id, b.id);
1247
+ if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
1248
+ this.dupStreaks.delete(key);
1249
+ continue;
1250
+ }
1251
+ const streak = (this.dupStreaks.get(key) ?? 0) + 1;
1252
+ if (streak >= this.config.dedupMergeFrames) {
1253
+ dropIds.add(SortTracker.duplicateLoser(a, b).id);
1254
+ this.dupStreaks.delete(key);
1255
+ } else this.dupStreaks.set(key, streak);
1256
+ }
1257
+ if (dropIds.size === 0) return;
1258
+ this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
1259
+ for (const key of this.dupStreaks.keys()) {
1260
+ const [a, b] = key.split("|");
1261
+ if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
1262
+ }
1263
+ }
1157
1264
  /** Where a track is expected this frame — extrapolated by velocity while
1158
1265
  * coasting (predictiveCoasting), else its last known bbox. A stationary
1159
1266
  * track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
@@ -1188,6 +1295,46 @@ var SortTracker = class {
1188
1295
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
1189
1296
  return dist <= this.config.rescueCentroidFactor * diag;
1190
1297
  }
1298
+ /** Fold a matched detection's class + score into a track's lifetime vote
1299
+ * tally. A non-positive score still counts as an infinitesimal vote so a
1300
+ * zero-confidence frame contributes to the frame-count tiebreak. */
1301
+ addClassVote(track, det) {
1302
+ const weight = det.score > 0 ? det.score : Number.EPSILON;
1303
+ track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
1304
+ }
1305
+ /**
1306
+ * The class to REPORT for a track: the confidence-weighted lifetime majority
1307
+ * when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
1308
+ * weight; otherwise the latest-frame class (ambiguous vote or voting off).
1309
+ */
1310
+ resolveReportedClass(t) {
1311
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
1312
+ let bestClass = t.class;
1313
+ let bestVote = -1;
1314
+ let total = 0;
1315
+ for (const [cls, v] of t.classVotes) {
1316
+ total += v;
1317
+ if (v > bestVote) {
1318
+ bestVote = v;
1319
+ bestClass = cls;
1320
+ }
1321
+ }
1322
+ if (total <= 0) return t.class;
1323
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
1324
+ }
1325
+ /** Whether a detection clears its per-class spawn score floor. Only gates NEW
1326
+ * spawns — an established track still matches below its floor. */
1327
+ meetsClassScoreFloor(det) {
1328
+ if (!this.config.perClassMinScoreEnabled) return true;
1329
+ const floor = this.config.classMinScores[det.class] ?? 0;
1330
+ return det.score >= floor;
1331
+ }
1332
+ /** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
1333
+ * a single very-high-confidence detection bypassed the hit gate (fast car). */
1334
+ isConfirmedForEmit(t) {
1335
+ if (t.hits >= this.config.minHits) return true;
1336
+ return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1337
+ }
1191
1338
  update(detections, timestamp) {
1192
1339
  if (this.config.maxTrackLifetimeMs > 0) {
1193
1340
  const alive = [];
@@ -1272,6 +1419,7 @@ var SortTracker = class {
1272
1419
  };
1273
1420
  track.path.push(det.bbox);
1274
1421
  if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
1422
+ this.addClassVote(track, det);
1275
1423
  }
1276
1424
  const occluderBoxes = [];
1277
1425
  for (const track of matchedTracks) occluderBoxes.push(track.bbox);
@@ -1321,13 +1469,30 @@ var SortTracker = class {
1321
1469
  };
1322
1470
  best.path.push(det.bbox);
1323
1471
  if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
1472
+ this.addClassVote(best, det);
1324
1473
  surviving.push(best);
1325
1474
  used.add(di);
1326
1475
  }
1327
- for (let di = 0; di < detections.length; di++) {
1328
- if (used.has(di)) continue;
1476
+ const unmatchedIdx = [];
1477
+ for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
1478
+ const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
1479
+ unmatchedIdx.sort((ia, ib) => {
1480
+ const da = detections[ia];
1481
+ const db = detections[ib];
1482
+ const r = classRank(da.class) - classRank(db.class);
1483
+ return r !== 0 ? r : db.score - da.score;
1484
+ });
1485
+ for (const di of unmatchedIdx) {
1329
1486
  const det = detections[di];
1487
+ if (!this.meetsClassScoreFloor(det)) {
1488
+ used.add(di);
1489
+ continue;
1490
+ }
1330
1491
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1492
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1493
+ used.add(di);
1494
+ continue;
1495
+ }
1331
1496
  surviving.push({
1332
1497
  id: randomUUID(),
1333
1498
  bbox: det.bbox,
@@ -1345,12 +1510,14 @@ var SortTracker = class {
1345
1510
  },
1346
1511
  lost: false,
1347
1512
  lostAt: 0,
1348
- resurrectable: true
1513
+ resurrectable: true,
1514
+ classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
1349
1515
  });
1350
1516
  }
1351
1517
  this.tracks = surviving;
1352
- return this.tracks.filter((t) => t.hits >= this.config.minHits).map((t) => ({
1353
- class: t.class,
1518
+ this.resolveDuplicates();
1519
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
1520
+ class: this.resolveReportedClass(t),
1354
1521
  originalClass: t.originalClass,
1355
1522
  score: t.score,
1356
1523
  bbox: t.bbox,
@@ -1379,6 +1546,7 @@ var SortTracker = class {
1379
1546
  reset() {
1380
1547
  this.tracks = [];
1381
1548
  this.lostTracks = [];
1549
+ this.dupStreaks.clear();
1382
1550
  }
1383
1551
  };
1384
1552
  //#endregion
@@ -6096,6 +6264,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
6096
6264
  h: Math.round(ch)
6097
6265
  };
6098
6266
  }
6267
+ /**
6268
+ * The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
6269
+ * NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
6270
+ *
6271
+ * A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
6272
+ * aspect ratio (the native crop path downscales while preserving aspect), so the
6273
+ * region computed from the detection frame's dimensions addresses the exact same
6274
+ * ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
6275
+ * (single source of truth) and divides by the frame dimensions.
6276
+ */
6277
+ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6278
+ const region = squareSafeCropRegion(bbox, frame, padding);
6279
+ return {
6280
+ x: region.x / frame.W,
6281
+ y: region.y / frame.H,
6282
+ w: region.w / frame.W,
6283
+ h: region.h / frame.H
6284
+ };
6285
+ }
6099
6286
  //#endregion
6100
6287
  //#region src/shared/frame/box-drawer.ts
6101
6288
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
@@ -6173,10 +6360,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
6173
6360
  * Small downscaled `thumbnail`s are intentionally left out for now — when we
6174
6361
  * reintroduce them they'll be a separate small kind. */
6175
6362
  var MEDIA_QUALITY = 88;
6176
- /** Output dimensions for square-safe 16:9 crops (crop/faceCrop/plateCrop). */
6363
+ /** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
6364
+ * (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
6177
6365
  var CROP_WIDTH = 640;
6178
6366
  var CROP_HEIGHT = 360;
6179
6367
  var CROP_QUALITY = 80;
6368
+ /**
6369
+ * Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
6370
+ * `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
6371
+ * ≥224px classifier input straight from the runner's native surface, WITHOUT
6372
+ * hauling a full 1920px frame per subject (that width is reserved for the
6373
+ * full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
6374
+ * the ≤640 local crop, so quality never regresses below today's behaviour.
6375
+ */
6376
+ var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
6180
6377
  function caption(className, confidence, label) {
6181
6378
  const base = label && label !== className ? `${className} ${label}` : className;
6182
6379
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
@@ -6283,12 +6480,12 @@ var EventMediaDispatcher = class {
6283
6480
  });
6284
6481
  return empty;
6285
6482
  }
6286
- for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
6483
+ for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
6287
6484
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
6288
6485
  const storedSnapshots = [];
6289
6486
  const thumbnailTrackIds = [];
6290
6487
  for (const sn of snapshots) {
6291
- const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
6488
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6292
6489
  if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6293
6490
  if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6294
6491
  }
@@ -6309,7 +6506,7 @@ var EventMediaDispatcher = class {
6309
6506
  * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6310
6507
  * landed this frame (#27-A) so the caller can stop forcing retries.
6311
6508
  */
6312
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
6509
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6313
6510
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6314
6511
  storedSnapshot: null,
6315
6512
  thumbnailWritten: false
@@ -6350,7 +6547,7 @@ var EventMediaDispatcher = class {
6350
6547
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6351
6548
  let thumbnailWritten = false;
6352
6549
  if (sn.bestThumbnail) try {
6353
- const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
6550
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
6354
6551
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6355
6552
  } catch (err) {
6356
6553
  this.deps.logger.warn("event media: track thumbnail crop failed", {
@@ -6369,12 +6566,38 @@ var EventMediaDispatcher = class {
6369
6566
  };
6370
6567
  }
6371
6568
  /**
6372
- * Clean subject-centered crop of `bbox` out of the raw frame the shared
6373
- * output contract of the object-event `crop` kind and the track `thumbnail`:
6374
- * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
6375
- * (no box drawn), resized to 640×360, JPEG q80.
6569
+ * Clean subject-centered crop of `bbox` the shared output contract of the
6570
+ * object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
6571
+ * `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
6572
+ *
6573
+ * NATIVE-FIRST: the region is requested from the runner's retained native
6574
+ * surface (normalized [0,1] coords map directly onto it), downscaled to
6575
+ * {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
6576
+ * miss/error (or a runner without the method) it FALLS BACK to cropping the
6577
+ * resolved ≤640 frame locally (today's behaviour), so quality never regresses.
6578
+ * Both paths run inside the live-handle window opened by `captureForFrame`.
6579
+ */
6580
+ async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
6581
+ if (this.deps.getNativeCropJpeg) try {
6582
+ const norm = squareSafeCropRegionNormalized(bbox, {
6583
+ W: fw,
6584
+ H: fh
6585
+ }, cropPadding);
6586
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
6587
+ if (native) return native;
6588
+ } catch (err) {
6589
+ this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
6590
+ shmId: frameHandle.shmId,
6591
+ error: err instanceof Error ? err.message : String(err)
6592
+ } });
6593
+ }
6594
+ return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
6595
+ }
6596
+ /**
6597
+ * Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
6598
+ * to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
6376
6599
  */
6377
- async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
6600
+ async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
6378
6601
  const region = squareSafeCropRegion(bbox, {
6379
6602
  W: fw,
6380
6603
  H: fh
@@ -6417,13 +6640,13 @@ var EventMediaDispatcher = class {
6417
6640
  return false;
6418
6641
  }
6419
6642
  }
6420
- async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
6643
+ async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
6421
6644
  const box = {
6422
6645
  ...ev.bbox,
6423
6646
  label: caption(ev.className, ev.confidence, ev.label)
6424
6647
  };
6425
6648
  try {
6426
- const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
6649
+ const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
6427
6650
  await this.deps.mediaStore.put({
6428
6651
  deviceId,
6429
6652
  ownerKind: "event",
@@ -6483,24 +6706,7 @@ var EventMediaDispatcher = class {
6483
6706
  });
6484
6707
  }
6485
6708
  if (ev.childCrops) for (const child of ev.childCrops) try {
6486
- const childRegion = squareSafeCropRegion(child.bbox, {
6487
- W: fw,
6488
- H: fh
6489
- }, cropPadding);
6490
- const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
6491
- const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
6492
- const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
6493
- const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
6494
- const childCropData = await sharp(frameData, { raw: {
6495
- width: fw,
6496
- height: fh,
6497
- channels: 3
6498
- } }).extract({
6499
- left: childLeft,
6500
- top: childTop,
6501
- width: childWidth,
6502
- height: childHeight
6503
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
6709
+ const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
6504
6710
  await this.deps.mediaStore.put({
6505
6711
  deviceId,
6506
6712
  ownerKind: "event",
@@ -7661,6 +7867,56 @@ var TrackingSettingsSchema = object({
7661
7867
  /** Speed (px/frame) below which a track's prediction is frozen (stationary
7662
7868
  * jitter can't drift the box off a sitting object). */
7663
7869
  stationarySpeedPx: number().min(0).default(2),
7870
+ /** Suppress + merge concurrent duplicate tracks (one subject the detector
7871
+ * double-fires, or a person misdetected as `animal`, otherwise becomes two
7872
+ * time-overlapping tracks that never re-associate — each firing its own
7873
+ * event). Off = legacy behaviour. */
7874
+ dedupEnabled: boolean().default(true),
7875
+ /** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
7876
+ * concurrent compatible-class track and suppressed. High so distinct
7877
+ * subjects appearing close together are not collapsed. */
7878
+ dedupSpawnIou: number().min(0).max(1).default(.6),
7879
+ /** Envelope IoU at/above which two concurrent tracks count as overlapping for
7880
+ * the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
7881
+ * merged duplicate cannot re-spawn. */
7882
+ dedupMergeIou: number().min(0).max(1).default(.6),
7883
+ /** Consecutive overlapping frames before two live tracks are merged (the
7884
+ * lower-importance one dropped). Higher = more conservative. */
7885
+ dedupMergeFrames: number().int().min(1).default(5),
7886
+ /** Treat the {person,animal} class pair as duplicate-compatible — a person in
7887
+ * a non-upright pose is misdetected as `animal`; the false animal track
7888
+ * collapses into the real person track. */
7889
+ personAnimalDedup: boolean().default(true),
7890
+ /** A low-confidence `animal` (score below this) overlapping a concurrent
7891
+ * person track is suppressed at spawn. */
7892
+ animalOverPersonMaxScore: number().min(0).max(1).default(.6),
7893
+ /** IoU with a concurrent person track that triggers the low-confidence animal
7894
+ * spawn suppression. */
7895
+ animalOverPersonIou: number().min(0).max(1).default(.2),
7896
+ /** Resolve a track's reported class by a confidence-weighted majority over its
7897
+ * lifetime (vs. the latest frame) — kills per-frame class flips (a person
7898
+ * read as `animal` on one crouch frame keeps the `person` label). */
7899
+ classVotingEnabled: boolean().default(true),
7900
+ /** Winning class must hold at least this fraction of a track's total vote
7901
+ * weight to override the latest-frame class; below it the latest wins (so a
7902
+ * genuine mid-life reclassification is never frozen out). */
7903
+ classVoteMinFraction: number().min(0).max(1).default(.5),
7904
+ /** Enforce a per-class minimum detection score at track SPAWN (an established
7905
+ * track still matches below its floor — only new spawns are gated). */
7906
+ perClassMinScoreEnabled: boolean().default(true),
7907
+ /** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
7908
+ minScorePerson: number().min(0).max(1).default(0),
7909
+ /** Minimum spawn score for `animal` (FP-prone — higher floor). */
7910
+ minScoreAnimal: number().min(0).max(1).default(.45),
7911
+ /** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
7912
+ minScoreVehicle: number().min(0).max(1).default(.45),
7913
+ /** Let a single very-high-confidence detection confirm a track for emission
7914
+ * before it reaches `minHits` (so a fast car crossing in 1-2 frames still
7915
+ * registers). Opt-in — default OFF keeps the strict N-hit gate. */
7916
+ confirmBypassEnabled: boolean().default(false),
7917
+ /** Score at/above which a detection confirms its track immediately (bypasses
7918
+ * `minHits`). Only consulted when `confirmBypassEnabled`. */
7919
+ confirmBypassScore: number().min(0).max(1).default(.9),
7664
7920
  /** Skip frames where detections suddenly drop to zero (detector glitch). */
7665
7921
  dropoutSkipEnabled: boolean().default(true),
7666
7922
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
@@ -7695,6 +7951,21 @@ function resolveTrackingSettings(raw) {
7695
7951
  rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
7696
7952
  resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
7697
7953
  stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
7954
+ dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
7955
+ dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
7956
+ dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
7957
+ dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
7958
+ personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
7959
+ animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
7960
+ animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
7961
+ classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
7962
+ classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
7963
+ perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
7964
+ minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
7965
+ minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
7966
+ minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
7967
+ confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
7968
+ confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7698
7969
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7699
7970
  dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
7700
7971
  };
@@ -8103,6 +8374,40 @@ function planPeriodicMedia(input) {
8103
8374
  };
8104
8375
  }
8105
8376
  //#endregion
8377
+ //#region src/pipeline-analytics/best-thumbnail-guard.ts
8378
+ /**
8379
+ * Void/envArea guard for best-`thumbnail` selection.
8380
+ *
8381
+ * ## Why this exists (the dawn/night "void" thumbnail)
8382
+ *
8383
+ * At dawn/night a moving subject's tracker box intermittently EXPLODES to
8384
+ * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
8385
+ * that frame happens to win the best-detection race, the gallery/reel best
8386
+ * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
8387
+ * washed-out frame), not the subject. This guard rejects such a frame from the
8388
+ * best-`thumbnail` decision so the track keeps a real subject-centered tile.
8389
+ *
8390
+ * Conservative by design: it only rejects boxes covering ≥ {@link
8391
+ * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
8392
+ * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
8393
+ * per-frame retry keeps trying until a plausible frame wins.
8394
+ */
8395
+ /**
8396
+ * Area fraction at/above which a detection bbox is treated as an exploded
8397
+ * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
8398
+ * guard conservative — only boxes covering ≥85% of the frame are rejected.
8399
+ */
8400
+ var NEAR_FULL_FRAME_AREA = .85;
8401
+ /**
8402
+ * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
8403
+ * area is below the near-full-frame threshold. Degenerate frame dimensions
8404
+ * (≤0) are treated as plausible (no info to reject on).
8405
+ */
8406
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8407
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
8408
+ return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
8409
+ }
8410
+ //#endregion
8106
8411
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
8107
8412
  /**
8108
8413
  * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
@@ -11410,6 +11715,35 @@ function toAnalyticsDeviceSections(sections) {
11410
11715
  }));
11411
11716
  }
11412
11717
  /**
11718
+ * Global-analytics section ids that are really per-camera DETECTION knobs and
11719
+ * belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
11720
+ * Object Detection — NOT under the generic `Analytics` top-tab:
11721
+ * - `detection-sensitivity` — minHits / cooldown / stationary threshold.
11722
+ * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
11723
+ * animal dedup, class voting, confirm-bypass, per-class min score).
11724
+ * - `stationary-objects` — stationary promotion + occupancy tuning.
11725
+ * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
11726
+ * structural Detection pipeline tab, so re-tagging is all that's needed —
11727
+ * there is no admin-ui change and no duplicate render (a section has one tab).
11728
+ */
11729
+ var DETECTION_PIPELINE_SECTION_IDS = new Set([
11730
+ "detection-sensitivity",
11731
+ "tracking",
11732
+ "stationary-objects"
11733
+ ]);
11734
+ /**
11735
+ * Re-home the detection-knob sections from the `Analytics` top-tab onto the
11736
+ * `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
11737
+ * section changes; every other section (media policy, retention, faces, track
11738
+ * history) stays on Analytics.
11739
+ */
11740
+ function retagDetectionSections(sections) {
11741
+ return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
11742
+ ...s,
11743
+ tab: "detection-pipeline"
11744
+ } : s);
11745
+ }
11746
+ /**
11413
11747
  * Fields that live ONLY on the global settings page and must never surface in a
11414
11748
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
11415
11749
  * master kill for the whole subsystem — per-camera face production is governed
@@ -11791,11 +12125,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11791
12125
  timestamp: 0
11792
12126
  };
11793
12127
  };
11794
- this.eventMediaDispatcher = new EventMediaDispatcher({
11795
- getRemoteFrame,
11796
- mediaStore: this.mediaStore,
11797
- logger: logger.child("EventMediaDispatcher")
11798
- });
11799
12128
  const cropMetricLogger = logger.child("NativeCrop");
11800
12129
  let nativeHits = 0;
11801
12130
  let nativeFallbacks = 0;
@@ -11827,6 +12156,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11827
12156
  return null;
11828
12157
  }
11829
12158
  };
12159
+ const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12160
+ const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
12161
+ bumpCropMetric(jpeg !== null);
12162
+ return jpeg;
12163
+ };
12164
+ this.eventMediaDispatcher = new EventMediaDispatcher({
12165
+ getRemoteFrame,
12166
+ getNativeCropJpeg,
12167
+ mediaStore: this.mediaStore,
12168
+ logger: logger.child("EventMediaDispatcher")
12169
+ });
11830
12170
  const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
11831
12171
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
11832
12172
  const paddedNorm = padBbox({
@@ -13267,7 +13607,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13267
13607
  });
13268
13608
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13269
13609
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13270
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
13610
+ const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13611
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
13271
13612
  targets.push({
13272
13613
  trackId: t.trackId,
13273
13614
  timestamp,
@@ -13275,7 +13616,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13275
13616
  ...t.label ? { label: t.label } : {},
13276
13617
  appendSnapshot: plan.appendSnapshot,
13277
13618
  rollingLastFrame: plan.rollingLastFrame,
13278
- bestThumbnail: plan.bestThumbnail
13619
+ bestThumbnail
13279
13620
  });
13280
13621
  }
13281
13622
  return targets;
@@ -13877,7 +14218,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13877
14218
  rescueCentroidFactor: trk.rescueCentroidFactor,
13878
14219
  resurrectionWindowMs: trk.resurrectionWindowMs,
13879
14220
  stationarySpeedPx: trk.stationarySpeedPx,
13880
- maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
14221
+ maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
14222
+ dedupEnabled: trk.dedupEnabled,
14223
+ dedupSpawnIou: trk.dedupSpawnIou,
14224
+ dedupMergeIou: trk.dedupMergeIou,
14225
+ dedupMergeFrames: trk.dedupMergeFrames,
14226
+ personAnimalDedup: trk.personAnimalDedup,
14227
+ animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
14228
+ animalOverPersonIou: trk.animalOverPersonIou,
14229
+ classVotingEnabled: trk.classVotingEnabled,
14230
+ classVoteMinFraction: trk.classVoteMinFraction,
14231
+ perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
14232
+ classMinScores: {
14233
+ person: trk.minScorePerson,
14234
+ animal: trk.minScoreAnimal,
14235
+ vehicle: trk.minScoreVehicle
14236
+ },
14237
+ confirmBypassEnabled: trk.confirmBypassEnabled,
14238
+ confirmBypassScore: trk.confirmBypassScore
13881
14239
  }, { stationaryThresholdSec }, {
13882
14240
  minTrackAge,
13883
14241
  cooldownSec,
@@ -15180,6 +15538,69 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15180
15538
  default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
15181
15539
  unit: "ms"
15182
15540
  },
15541
+ {
15542
+ type: "boolean",
15543
+ key: "dedupEnabled",
15544
+ label: "Duplicate-track suppression",
15545
+ description: "Suppress and merge concurrent duplicate tracks. Stops one subject the detector double-fires (two boxes for one object, or a person misdetected as an animal) from becoming several time-overlapping tracks that each fire their own event. Off = legacy behaviour.",
15546
+ default: TRACKING_DEFAULTS.dedupEnabled
15547
+ },
15548
+ {
15549
+ type: "number",
15550
+ key: "dedupSpawnIou",
15551
+ label: "Duplicate spawn IoU",
15552
+ description: "Overlap at/above which a NEW track is treated as a duplicate of a concurrent same-kind track and not created. High so two distinct subjects appearing close together are still tracked separately.",
15553
+ min: 0,
15554
+ max: 1,
15555
+ step: .05,
15556
+ default: TRACKING_DEFAULTS.dedupSpawnIou
15557
+ },
15558
+ {
15559
+ type: "number",
15560
+ key: "dedupMergeIou",
15561
+ label: "Duplicate merge IoU",
15562
+ description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
15563
+ min: 0,
15564
+ max: 1,
15565
+ step: .05,
15566
+ default: TRACKING_DEFAULTS.dedupMergeIou
15567
+ },
15568
+ {
15569
+ type: "number",
15570
+ key: "dedupMergeFrames",
15571
+ label: "Duplicate merge frames",
15572
+ description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
15573
+ min: 1,
15574
+ step: 1,
15575
+ default: TRACKING_DEFAULTS.dedupMergeFrames
15576
+ },
15577
+ {
15578
+ type: "boolean",
15579
+ key: "personAnimalDedup",
15580
+ label: "Person/animal duplicate merge",
15581
+ description: "Treat an animal track overlapping a person track as the same subject (a person in a crouching / bending pose is often misdetected as an animal). The person track wins.",
15582
+ default: TRACKING_DEFAULTS.personAnimalDedup
15583
+ },
15584
+ {
15585
+ type: "number",
15586
+ key: "animalOverPersonMaxScore",
15587
+ label: "Animal-over-person max score",
15588
+ description: "A low-confidence animal detection (score below this) sitting on a concurrent person track is suppressed — the common person-in-odd-pose false animal.",
15589
+ min: 0,
15590
+ max: 1,
15591
+ step: .05,
15592
+ default: TRACKING_DEFAULTS.animalOverPersonMaxScore
15593
+ },
15594
+ {
15595
+ type: "number",
15596
+ key: "animalOverPersonIou",
15597
+ label: "Animal-over-person IoU",
15598
+ description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
15599
+ min: 0,
15600
+ max: 1,
15601
+ step: .05,
15602
+ default: TRACKING_DEFAULTS.animalOverPersonIou
15603
+ },
15183
15604
  {
15184
15605
  type: "boolean",
15185
15606
  key: "dropoutSkipEnabled",
@@ -15195,6 +15616,77 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15195
15616
  min: 0,
15196
15617
  step: 1,
15197
15618
  default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
15619
+ },
15620
+ {
15621
+ type: "boolean",
15622
+ key: "classVotingEnabled",
15623
+ label: "Per-track class voting",
15624
+ description: "Report a track’s class by a confidence-weighted majority over its whole life instead of the latest frame — a person read as an animal on a single crouch/bend frame keeps the person label.",
15625
+ default: TRACKING_DEFAULTS.classVotingEnabled
15626
+ },
15627
+ {
15628
+ type: "number",
15629
+ key: "classVoteMinFraction",
15630
+ label: "Class-vote min fraction",
15631
+ description: "The winning class must hold at least this fraction of a track’s total vote weight to override the latest frame; below it the latest frame wins (so a genuine mid-life reclassification is not frozen out).",
15632
+ min: 0,
15633
+ max: 1,
15634
+ step: .05,
15635
+ default: TRACKING_DEFAULTS.classVoteMinFraction
15636
+ },
15637
+ {
15638
+ type: "boolean",
15639
+ key: "perClassMinScoreEnabled",
15640
+ label: "Per-class spawn confidence",
15641
+ description: "Require a per-class minimum detection score before a NEW track is created. Kills static-object / reflection false spawns from the FP-prone classes. An already-tracked object still matches on a low-confidence frame.",
15642
+ default: TRACKING_DEFAULTS.perClassMinScoreEnabled
15643
+ },
15644
+ {
15645
+ type: "number",
15646
+ key: "minScorePerson",
15647
+ label: "Min spawn score — person",
15648
+ description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
15649
+ min: 0,
15650
+ max: 1,
15651
+ step: .05,
15652
+ default: TRACKING_DEFAULTS.minScorePerson
15653
+ },
15654
+ {
15655
+ type: "number",
15656
+ key: "minScoreAnimal",
15657
+ label: "Min spawn score — animal",
15658
+ description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
15659
+ min: 0,
15660
+ max: 1,
15661
+ step: .05,
15662
+ default: TRACKING_DEFAULTS.minScoreAnimal
15663
+ },
15664
+ {
15665
+ type: "number",
15666
+ key: "minScoreVehicle",
15667
+ label: "Min spawn score — vehicle",
15668
+ description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
15669
+ min: 0,
15670
+ max: 1,
15671
+ step: .05,
15672
+ default: TRACKING_DEFAULTS.minScoreVehicle
15673
+ },
15674
+ {
15675
+ type: "boolean",
15676
+ key: "confirmBypassEnabled",
15677
+ label: "Fast high-confidence confirm",
15678
+ description: "Let a single very-high-confidence detection confirm a track for events before it reaches the min-hits gate, so a fast subject crossing in 1-2 frames (a passing car) still registers. Off by default (strict N-hit confirmation).",
15679
+ default: TRACKING_DEFAULTS.confirmBypassEnabled
15680
+ },
15681
+ {
15682
+ type: "number",
15683
+ key: "confirmBypassScore",
15684
+ label: "Fast-confirm score",
15685
+ description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
15686
+ min: 0,
15687
+ max: 1,
15688
+ step: .05,
15689
+ default: TRACKING_DEFAULTS.confirmBypassScore
15198
15690
  }
15199
15691
  ]
15200
15692
  },
@@ -15270,7 +15762,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15270
15762
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
15271
15763
  const baseSections = schema ? hydrateSchema({
15272
15764
  ...schema,
15273
- sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
15765
+ sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
15274
15766
  }, raw).sections : [];
15275
15767
  const liveStatsSection = {
15276
15768
  id: "live-stats",
@@ -15319,4 +15811,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15319
15811
  }
15320
15812
  };
15321
15813
  //#endregion
15322
- export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
15814
+ export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };