@camstack/addon-post-analysis 1.1.38 → 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-_h-RM5Lr.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);
@@ -1141,7 +1141,13 @@ var DEFAULT_TRACKER_CONFIG = {
1141
1141
  vehicle: .45
1142
1142
  },
1143
1143
  confirmBypassEnabled: false,
1144
- confirmBypassScore: .9
1144
+ confirmBypassScore: .9,
1145
+ byteTrackEnabled: true,
1146
+ byteTrackHighThreshold: .4,
1147
+ byteTrackLowIouThreshold: .2,
1148
+ minSpawnAreaEnabled: true,
1149
+ classSpawnAreaFracs: { person: 5e-4 },
1150
+ classGroupAssoc: true
1145
1151
  };
1146
1152
  /** Macro-classes the duplicate resolver may collapse across (people in
1147
1153
  * non-upright poses misdetected as animals). Any other cross-class pair is
@@ -1198,6 +1204,31 @@ var SortTracker = class SortTracker {
1198
1204
  static pairKey(a, b) {
1199
1205
  return a < b ? `${a}|${b}` : `${b}|${a}`;
1200
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
+ }
1201
1232
  /**
1202
1233
  * True when spawning a fresh track for `det` would duplicate an already-live
1203
1234
  * track: (a) it overlaps a concurrent compatible-class track by
@@ -1286,13 +1317,16 @@ var SortTracker = class SortTracker {
1286
1317
  };
1287
1318
  }
1288
1319
  /**
1289
- * 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
1290
1321
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
1291
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
1292
- * 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.
1293
1327
  */
1294
1328
  looseMatch(track, det) {
1295
- if (track.class !== det.class) return false;
1329
+ if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return false;
1296
1330
  if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1297
1331
  const tc = bboxCentroid(track.bbox);
1298
1332
  const dc = bboxCentroid(det.bbox);
@@ -1340,29 +1374,25 @@ var SortTracker = class SortTracker {
1340
1374
  if (t.hits >= this.config.minHits) return true;
1341
1375
  return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1342
1376
  }
1343
- update(detections, timestamp) {
1344
- if (this.config.maxTrackLifetimeMs > 0) {
1345
- const alive = [];
1346
- for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1347
- track.lost = true;
1348
- track.lostAt = timestamp;
1349
- track.resurrectable = false;
1350
- this.lostTracks.push(track);
1351
- } else alive.push(track);
1352
- this.tracks = alive;
1353
- }
1354
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1355
- const used = /* @__PURE__ */ new Set();
1356
- const matchedTracks = /* @__PURE__ */ new Set();
1357
- const matched = /* @__PURE__ */ new Map();
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) {
1358
1386
  const pairs = [];
1359
1387
  for (const track of this.tracks) {
1388
+ if (matchedTracks.has(track)) continue;
1360
1389
  const pbox = this.predicted(track);
1361
- for (let di = 0; di < detections.length; di++) {
1390
+ for (const di of candidateIdxs) {
1391
+ if (used.has(di)) continue;
1362
1392
  const det = detections[di];
1363
- if (this.config.classGating && track.class !== det.class) continue;
1393
+ if (!this.associable(track.class, det.class)) continue;
1364
1394
  const score = iou$2(pbox, det.bbox);
1365
- if (score >= this.config.iouThreshold) pairs.push({
1395
+ if (score >= iouThreshold) pairs.push({
1366
1396
  track,
1367
1397
  detIdx: di,
1368
1398
  score
@@ -1376,11 +1406,34 @@ var SortTracker = class SortTracker {
1376
1406
  matchedTracks.add(pair.track);
1377
1407
  used.add(pair.detIdx);
1378
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);
1379
1432
  const rescuePairs = [];
1380
1433
  for (const track of this.tracks) {
1381
1434
  if (matchedTracks.has(track)) continue;
1382
1435
  for (let di = 0; di < detections.length; di++) {
1383
- if (used.has(di)) continue;
1436
+ if (used.has(di) || lowSet.has(di)) continue;
1384
1437
  const det = detections[di];
1385
1438
  if (!this.looseMatch(track, det)) continue;
1386
1439
  rescuePairs.push({
@@ -1397,6 +1450,7 @@ var SortTracker = class SortTracker {
1397
1450
  matchedTracks.add(pair.track);
1398
1451
  used.add(pair.detIdx);
1399
1452
  }
1453
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
1400
1454
  for (const [track, det] of matched) {
1401
1455
  const prevCenter = bboxCentroid({
1402
1456
  x: track.bbox.x,
@@ -1493,6 +1547,10 @@ var SortTracker = class SortTracker {
1493
1547
  used.add(di);
1494
1548
  continue;
1495
1549
  }
1550
+ if (!this.meetsSpawnAreaFloor(det, frameArea)) {
1551
+ used.add(di);
1552
+ continue;
1553
+ }
1496
1554
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1497
1555
  if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1498
1556
  used.add(di);
@@ -1529,7 +1587,8 @@ var SortTracker = class SortTracker {
1529
1587
  trackId: t.id,
1530
1588
  trackAge: t.hits,
1531
1589
  velocity: t.velocity,
1532
- path: [...t.path]
1590
+ path: [...t.path],
1591
+ matchedThisFrame: t.lastSeen === timestamp
1533
1592
  }));
1534
1593
  }
1535
1594
  /**
@@ -2111,7 +2170,10 @@ var FrameProcessor = class {
2111
2170
  wokenEntryIds: []
2112
2171
  };
2113
2172
  const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
2114
- const trackedDetections = this.tracker.update(trackerInput, timestamp);
2173
+ const trackedDetections = this.tracker.update(trackerInput, timestamp, {
2174
+ frameWidth,
2175
+ frameHeight
2176
+ });
2115
2177
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2116
2178
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2117
2179
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -2140,6 +2202,7 @@ var FrameProcessor = class {
2140
2202
  bbox: { ...td.bbox },
2141
2203
  zones: zonesByTrack.get(td.trackId) ?? [],
2142
2204
  state,
2205
+ matchedThisFrame: td.matchedThisFrame !== false,
2143
2206
  ...label ? { label } : {},
2144
2207
  ...emb !== void 0 ? {
2145
2208
  embedding: emb.embedding,
@@ -4412,6 +4475,26 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
4412
4475
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
4413
4476
  * of a set of tracks without a `trackId` column on media rows. */
4414
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
+ }
4415
4498
  var MEDIA_COLUMNS = [
4416
4499
  {
4417
4500
  name: "id",
@@ -4463,10 +4546,11 @@ var MEDIA_INDEXES = [{
4463
4546
  columns: ["deviceId", "timestamp"]
4464
4547
  }];
4465
4548
  function buildKey(params) {
4466
- 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}`;
4467
4550
  }
4468
4551
  function buildPath(params) {
4469
- 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`;
4470
4554
  }
4471
4555
  var MediaStore = class {
4472
4556
  storage;
@@ -4489,25 +4573,31 @@ var MediaStore = class {
4489
4573
  async put(params) {
4490
4574
  const key = buildKey(params);
4491
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
+ };
4492
4585
  try {
4493
4586
  await this.storage.write({
4494
4587
  location: "eventMedia",
4495
4588
  relativePath: path,
4496
4589
  data: params.data
4497
4590
  });
4498
- 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({
4499
4597
  collection: MEDIA_COLLECTION,
4500
4598
  record: {
4501
4599
  id: key,
4502
- data: {
4503
- deviceId: params.deviceId,
4504
- ownerKind: params.ownerKind,
4505
- ownerId: params.ownerId,
4506
- kind: params.kind,
4507
- timestamp: params.timestamp,
4508
- path,
4509
- sizeBytes: params.data.length
4510
- }
4600
+ data: record
4511
4601
  }
4512
4602
  });
4513
4603
  return key;
@@ -4526,8 +4616,14 @@ var MediaStore = class {
4526
4616
  * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
4527
4617
  * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
4528
4618
  * each new capture replaces the previous one (blob + index row) rather than
4529
- * accumulating a filmstrip the way `put` does. Deletes any existing rows of
4530
- * 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.
4531
4627
  */
4532
4628
  async putReplacing(params) {
4533
4629
  const existing = await this.store.query.query({
@@ -7926,7 +8022,35 @@ var TrackingSettingsSchema = require_dist.object({
7926
8022
  dropoutSkipEnabled: require_dist.boolean().default(true),
7927
8023
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
7928
8024
  * treated as genuinely empty. */
7929
- 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)
7930
8054
  });
7931
8055
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
7932
8056
  /**
@@ -7972,7 +8096,15 @@ function resolveTrackingSettings(raw) {
7972
8096
  confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
7973
8097
  confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7974
8098
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7975
- 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)
7976
8108
  };
7977
8109
  }
7978
8110
  //#endregion
@@ -8444,11 +8576,12 @@ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8444
8576
  var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8445
8577
  /**
8446
8578
  * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
8447
- * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
8448
- * 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).
8449
8582
  */
8450
8583
  function selectKeyFrameTrackIds(targets) {
8451
- return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
8584
+ return targets.filter((t) => t.keyFrame).map((t) => t.trackId);
8452
8585
  }
8453
8586
  /**
8454
8587
  * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
@@ -8468,6 +8601,15 @@ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
8468
8601
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
8469
8602
  };
8470
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
+ }
8471
8613
  //#endregion
8472
8614
  //#region src/pipeline-analytics/track-retention-sweep.ts
8473
8615
  /**
@@ -9550,6 +9692,7 @@ var FaceRecognizer = class {
9550
9692
  bbox: input.parentBbox,
9551
9693
  zones: [],
9552
9694
  state: "moving",
9695
+ matchedThisFrame: true,
9553
9696
  embedding: input.embedding,
9554
9697
  embeddingModelId: modelId,
9555
9698
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -11905,6 +12048,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11905
12048
  * (recycled/blank live frame) still gets a subject crop for the gallery
11906
12049
  * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11907
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();
11908
12064
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11909
12065
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11910
12066
  * area) + emit time, so a material improvement is measured against the
@@ -11962,7 +12118,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11962
12118
  let storage = this.ctx.kernel.storage;
11963
12119
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
11964
12120
  if (mediaRoot) {
11965
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BiZGDArd.js"));
12121
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B7HfyyIy.js"));
11966
12122
  storage = new FilesystemStorageProvider(mediaRoot);
11967
12123
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11968
12124
  }
@@ -12146,7 +12302,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12146
12302
  detectionFrameFallbacks: nativeFallbacks
12147
12303
  } });
12148
12304
  };
12149
- const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12305
+ const fetchNativeCropRgb = async (frameHandle, paddedNorm, maxWidth) => {
12150
12306
  if (!pipelineRunnerApi?.getNativeCrop) return null;
12151
12307
  try {
12152
12308
  const native = await pipelineRunnerApi.getNativeCrop.query({
@@ -12155,14 +12311,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12155
12311
  ...maxWidth !== void 0 ? { maxWidth } : {}
12156
12312
  }, require_dist.nodePin(frameHandle.nodeId));
12157
12313
  if (!native || native.width <= 0 || native.height <= 0) return null;
12158
- 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
+ };
12159
12319
  } catch (err) {
12160
12320
  cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: require_dist.errMsg(err) } });
12161
12321
  return null;
12162
12322
  }
12163
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
+ };
12164
12329
  const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12165
- const jpeg = await tryNativeCrop(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);
12166
12341
  bumpCropMetric(jpeg !== null);
12167
12342
  return jpeg;
12168
12343
  };
@@ -12715,6 +12890,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12715
12890
  this.bestFrameTracker.clear();
12716
12891
  this.lastFrameAtByTrack.clear();
12717
12892
  this.thumbnailLandedTracks.clear();
12893
+ this.thumbnailInFlight.clear();
12894
+ this.keyFrameInFlight.clear();
12718
12895
  this.trackLifecycleUpdateMem.clear();
12719
12896
  this.objectEmbeddingBestSelector.clear();
12720
12897
  this.levelStateByDevice.clear();
@@ -13035,6 +13212,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13035
13212
  frames: captureAgg.frames,
13036
13213
  ...captureAgg.sums
13037
13214
  } });
13215
+ const thumbInFlightTrackIds = snapshotTargets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
13216
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.add(trackId);
13038
13217
  this.eventMediaDispatcher.captureForFrame({
13039
13218
  deviceId,
13040
13219
  frameHandle,
@@ -13054,7 +13233,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13054
13233
  mediaKey: s.mediaKey
13055
13234
  });
13056
13235
  for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
13057
- }).catch(() => {});
13236
+ }).catch(() => {}).finally(() => {
13237
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.delete(trackId);
13238
+ });
13058
13239
  }
13059
13240
  }
13060
13241
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -13499,7 +13680,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13499
13680
  const mediaStore = this.mediaStore;
13500
13681
  if (!capture || !mediaStore) return;
13501
13682
  const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
13502
- 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) => {
13503
13687
  try {
13504
13688
  const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
13505
13689
  if (!keyFrame) return;
@@ -13520,6 +13704,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13520
13704
  error: require_dist.errMsg(err)
13521
13705
  }
13522
13706
  });
13707
+ } finally {
13708
+ this.keyFrameInFlight.delete(trackId);
13523
13709
  }
13524
13710
  }));
13525
13711
  }
@@ -13581,6 +13767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13581
13767
  buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
13582
13768
  const targets = [];
13583
13769
  for (const t of tracked) {
13770
+ if (t.matchedThisFrame === false) continue;
13584
13771
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
13585
13772
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
13586
13773
  lastSnapshotAt: lastSnap,
@@ -13612,8 +13799,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13612
13799
  });
13613
13800
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13614
13801
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13615
- const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13616
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !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;
13617
13806
  targets.push({
13618
13807
  trackId: t.trackId,
13619
13808
  timestamp,
@@ -13621,7 +13810,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13621
13810
  ...t.label ? { label: t.label } : {},
13622
13811
  appendSnapshot: plan.appendSnapshot,
13623
13812
  rollingLastFrame: plan.rollingLastFrame,
13624
- bestThumbnail
13813
+ bestThumbnail,
13814
+ keyFrame
13625
13815
  });
13626
13816
  }
13627
13817
  return targets;
@@ -13928,6 +14118,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13928
14118
  this.objectEmbeddingBestSelector.delete(t.trackId);
13929
14119
  this.lastFrameAtByTrack.delete(t.trackId);
13930
14120
  this.thumbnailLandedTracks.delete(t.trackId);
14121
+ this.thumbnailInFlight.delete(t.trackId);
14122
+ this.keyFrameInFlight.delete(t.trackId);
13931
14123
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13932
14124
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13933
14125
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -14190,6 +14382,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
14190
14382
  this.objectEmbeddingBestSelector.delete(track.trackId);
14191
14383
  this.lastFrameAtByTrack.delete(track.trackId);
14192
14384
  this.thumbnailLandedTracks.delete(track.trackId);
14385
+ this.thumbnailInFlight.delete(track.trackId);
14386
+ this.keyFrameInFlight.delete(track.trackId);
14193
14387
  this.trackLifecycleUpdateMem.delete(track.trackId);
14194
14388
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
14195
14389
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -14240,7 +14434,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
14240
14434
  vehicle: trk.minScoreVehicle
14241
14435
  },
14242
14436
  confirmBypassEnabled: trk.confirmBypassEnabled,
14243
- confirmBypassScore: trk.confirmBypassScore
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
14244
14448
  }, { stationaryThresholdSec }, {
14245
14449
  minTrackAge,
14246
14450
  cooldownSec,
@@ -15692,6 +15896,77 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
15692
15896
  max: 1,
15693
15897
  step: .05,
15694
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
15695
15970
  }
15696
15971
  ]
15697
15972
  },