@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.
@@ -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-CSMfGdnz.mjs";
1
+ import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-CO07v2sR.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -1136,7 +1136,13 @@ var DEFAULT_TRACKER_CONFIG = {
1136
1136
  vehicle: .45
1137
1137
  },
1138
1138
  confirmBypassEnabled: false,
1139
- confirmBypassScore: .9
1139
+ confirmBypassScore: .9,
1140
+ byteTrackEnabled: true,
1141
+ byteTrackHighThreshold: .4,
1142
+ byteTrackLowIouThreshold: .2,
1143
+ minSpawnAreaEnabled: true,
1144
+ classSpawnAreaFracs: { person: 5e-4 },
1145
+ classGroupAssoc: true
1140
1146
  };
1141
1147
  /** Macro-classes the duplicate resolver may collapse across (people in
1142
1148
  * non-upright poses misdetected as animals). Any other cross-class pair is
@@ -1193,6 +1199,31 @@ var SortTracker = class SortTracker {
1193
1199
  static pairKey(a, b) {
1194
1200
  return a < b ? `${a}|${b}` : `${b}|${a}`;
1195
1201
  }
1202
+ /** Association-gating group of a class. When `classGroupAssoc` is on the
1203
+ * {person,animal} pair collapses to one group so a per-frame class flip
1204
+ * (crouch person → animal) re-matches the existing track instead of spawning a
1205
+ * concurrent id. Every other class is its own group. */
1206
+ assocGroup(cls) {
1207
+ if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
1208
+ return cls;
1209
+ }
1210
+ /** Whether a detection may associate to a track under the current class gate:
1211
+ * exact class when `classGating` is off (no gate at all) — matching the legacy
1212
+ * contract — otherwise same association group (group == exact class unless
1213
+ * `classGroupAssoc` widened it to {person,animal}). */
1214
+ associable(trackClass, detClass) {
1215
+ if (!this.config.classGating) return true;
1216
+ return this.assocGroup(trackClass) === this.assocGroup(detClass);
1217
+ }
1218
+ /** Whether a spawn candidate clears its per-class minimum bbox-area floor
1219
+ * (fraction of frame area). Ungated when disabled, when the class has no floor,
1220
+ * or when the caller supplied no frame dimensions (fraction is uncomputable). */
1221
+ meetsSpawnAreaFloor(det, frameArea) {
1222
+ if (!this.config.minSpawnAreaEnabled) return true;
1223
+ const floor = this.config.classSpawnAreaFracs[det.class] ?? 0;
1224
+ if (floor <= 0 || frameArea <= 0) return true;
1225
+ return det.bbox.w * det.bbox.h / frameArea >= floor;
1226
+ }
1196
1227
  /**
1197
1228
  * True when spawning a fresh track for `det` would duplicate an already-live
1198
1229
  * track: (a) it overlaps a concurrent compatible-class track by
@@ -1281,13 +1312,16 @@ var SortTracker = class SortTracker {
1281
1312
  };
1282
1313
  }
1283
1314
  /**
1284
- * Loose same-class gate used by the rescue and resurrection passes: accept
1315
+ * Loose same-group gate used by the rescue and resurrection passes: accept
1285
1316
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
1286
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
1287
- * gated (a rescue must never cross classes), independent of `classGating`.
1317
+ * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
1318
+ * gated (a rescue must never cross association groups) independent of
1319
+ * `classGating`, so a coasting person track can be rescued by its own
1320
+ * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
1321
+ * class.
1288
1322
  */
1289
1323
  looseMatch(track, det) {
1290
- if (track.class !== det.class) return false;
1324
+ if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return false;
1291
1325
  if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1292
1326
  const tc = bboxCentroid(track.bbox);
1293
1327
  const dc = bboxCentroid(det.bbox);
@@ -1335,29 +1369,25 @@ var SortTracker = class SortTracker {
1335
1369
  if (t.hits >= this.config.minHits) return true;
1336
1370
  return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
1337
1371
  }
1338
- update(detections, timestamp) {
1339
- if (this.config.maxTrackLifetimeMs > 0) {
1340
- const alive = [];
1341
- for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1342
- track.lost = true;
1343
- track.lostAt = timestamp;
1344
- track.resurrectable = false;
1345
- this.lostTracks.push(track);
1346
- } else alive.push(track);
1347
- this.tracks = alive;
1348
- }
1349
- this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1350
- const used = /* @__PURE__ */ new Set();
1351
- const matchedTracks = /* @__PURE__ */ new Set();
1352
- const matched = /* @__PURE__ */ new Map();
1372
+ /**
1373
+ * One greedy IoU association pass over a candidate detection subset against the
1374
+ * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
1375
+ * bind first; a track/detection binds at most once. Shared by the high-score
1376
+ * first stage and the low-score second stage (different candidate sets + IoU
1377
+ * gates) so the two-tier ByteTrack association stays a single implementation.
1378
+ * Mutates `used` / `matchedTracks` / `matched` in place.
1379
+ */
1380
+ greedyAssociate(detections, candidateIdxs, iouThreshold, used, matchedTracks, matched) {
1353
1381
  const pairs = [];
1354
1382
  for (const track of this.tracks) {
1383
+ if (matchedTracks.has(track)) continue;
1355
1384
  const pbox = this.predicted(track);
1356
- for (let di = 0; di < detections.length; di++) {
1385
+ for (const di of candidateIdxs) {
1386
+ if (used.has(di)) continue;
1357
1387
  const det = detections[di];
1358
- if (this.config.classGating && track.class !== det.class) continue;
1388
+ if (!this.associable(track.class, det.class)) continue;
1359
1389
  const score = iou$2(pbox, det.bbox);
1360
- if (score >= this.config.iouThreshold) pairs.push({
1390
+ if (score >= iouThreshold) pairs.push({
1361
1391
  track,
1362
1392
  detIdx: di,
1363
1393
  score
@@ -1371,11 +1401,34 @@ var SortTracker = class SortTracker {
1371
1401
  matchedTracks.add(pair.track);
1372
1402
  used.add(pair.detIdx);
1373
1403
  }
1404
+ }
1405
+ update(detections, timestamp, frameContext) {
1406
+ const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
1407
+ if (this.config.maxTrackLifetimeMs > 0) {
1408
+ const alive = [];
1409
+ for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
1410
+ track.lost = true;
1411
+ track.lostAt = timestamp;
1412
+ track.resurrectable = false;
1413
+ this.lostTracks.push(track);
1414
+ } else alive.push(track);
1415
+ this.tracks = alive;
1416
+ }
1417
+ this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
1418
+ const used = /* @__PURE__ */ new Set();
1419
+ const matchedTracks = /* @__PURE__ */ new Set();
1420
+ const matched = /* @__PURE__ */ new Map();
1421
+ const highIdxs = [];
1422
+ const lowIdxs = [];
1423
+ for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
1424
+ else highIdxs.push(di);
1425
+ const lowSet = new Set(lowIdxs);
1426
+ this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, used, matchedTracks, matched);
1374
1427
  const rescuePairs = [];
1375
1428
  for (const track of this.tracks) {
1376
1429
  if (matchedTracks.has(track)) continue;
1377
1430
  for (let di = 0; di < detections.length; di++) {
1378
- if (used.has(di)) continue;
1431
+ if (used.has(di) || lowSet.has(di)) continue;
1379
1432
  const det = detections[di];
1380
1433
  if (!this.looseMatch(track, det)) continue;
1381
1434
  rescuePairs.push({
@@ -1392,6 +1445,7 @@ var SortTracker = class SortTracker {
1392
1445
  matchedTracks.add(pair.track);
1393
1446
  used.add(pair.detIdx);
1394
1447
  }
1448
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
1395
1449
  for (const [track, det] of matched) {
1396
1450
  const prevCenter = bboxCentroid({
1397
1451
  x: track.bbox.x,
@@ -1488,6 +1542,10 @@ var SortTracker = class SortTracker {
1488
1542
  used.add(di);
1489
1543
  continue;
1490
1544
  }
1545
+ if (!this.meetsSpawnAreaFloor(det, frameArea)) {
1546
+ used.add(di);
1547
+ continue;
1548
+ }
1491
1549
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1492
1550
  if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
1493
1551
  used.add(di);
@@ -1524,7 +1582,8 @@ var SortTracker = class SortTracker {
1524
1582
  trackId: t.id,
1525
1583
  trackAge: t.hits,
1526
1584
  velocity: t.velocity,
1527
- path: [...t.path]
1585
+ path: [...t.path],
1586
+ matchedThisFrame: t.lastSeen === timestamp
1528
1587
  }));
1529
1588
  }
1530
1589
  /**
@@ -2106,7 +2165,10 @@ var FrameProcessor = class {
2106
2165
  wokenEntryIds: []
2107
2166
  };
2108
2167
  const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
2109
- const trackedDetections = this.tracker.update(trackerInput, timestamp);
2168
+ const trackedDetections = this.tracker.update(trackerInput, timestamp, {
2169
+ frameWidth,
2170
+ frameHeight
2171
+ });
2110
2172
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2111
2173
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2112
2174
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -2135,6 +2197,7 @@ var FrameProcessor = class {
2135
2197
  bbox: { ...td.bbox },
2136
2198
  zones: zonesByTrack.get(td.trackId) ?? [],
2137
2199
  state,
2200
+ matchedThisFrame: td.matchedThisFrame !== false,
2138
2201
  ...label ? { label } : {},
2139
2202
  ...emb !== void 0 ? {
2140
2203
  embedding: emb.embedding,
@@ -4407,6 +4470,26 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
4407
4470
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
4408
4471
  * of a set of tracks without a `trackId` column on media rows. */
4409
4472
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
4473
+ /**
4474
+ * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
4475
+ * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
4476
+ * DETERMINISTIC (`owner:kind`, NO timestamp) and written via an UPSERT
4477
+ * (`store.set`, atomic `INSERT … ON CONFLICT DO UPDATE`), so N concurrent
4478
+ * captures for the same `(track, kind)` all address the SAME row/blob — one can
4479
+ * never become thirteen regardless of interleaving (RC-1). Accumulating kinds
4480
+ * (`snapshot`, event `crop`/`fullFrame*`, `faceCrop`/`plateCrop`) keep their
4481
+ * per-instance timestamped id so the filmstrip / per-event / per-enrollment rows
4482
+ * still accumulate.
4483
+ */
4484
+ var SINGLE_INSTANCE_KINDS = new Set([
4485
+ "keyFrame",
4486
+ "thumbnail",
4487
+ "firstFrame",
4488
+ "lastFrame"
4489
+ ]);
4490
+ function isSingleInstanceKind(kind) {
4491
+ return SINGLE_INSTANCE_KINDS.has(kind);
4492
+ }
4410
4493
  var MEDIA_COLUMNS = [
4411
4494
  {
4412
4495
  name: "id",
@@ -4458,10 +4541,11 @@ var MEDIA_INDEXES = [{
4458
4541
  columns: ["deviceId", "timestamp"]
4459
4542
  }];
4460
4543
  function buildKey(params) {
4461
- return `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4544
+ return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
4462
4545
  }
4463
4546
  function buildPath(params) {
4464
- return `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}/${params.kind}-${params.timestamp}.jpg`;
4547
+ const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
4548
+ return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
4465
4549
  }
4466
4550
  var MediaStore = class {
4467
4551
  storage;
@@ -4484,25 +4568,31 @@ var MediaStore = class {
4484
4568
  async put(params) {
4485
4569
  const key = buildKey(params);
4486
4570
  const path = buildPath(params);
4571
+ const record = {
4572
+ deviceId: params.deviceId,
4573
+ ownerKind: params.ownerKind,
4574
+ ownerId: params.ownerId,
4575
+ kind: params.kind,
4576
+ timestamp: params.timestamp,
4577
+ path,
4578
+ sizeBytes: params.data.length
4579
+ };
4487
4580
  try {
4488
4581
  await this.storage.write({
4489
4582
  location: "eventMedia",
4490
4583
  relativePath: path,
4491
4584
  data: params.data
4492
4585
  });
4493
- await this.store.insert.mutate({
4586
+ if (isSingleInstanceKind(params.kind)) await this.store.set.mutate({
4587
+ collection: MEDIA_COLLECTION,
4588
+ key,
4589
+ value: record
4590
+ });
4591
+ else await this.store.insert.mutate({
4494
4592
  collection: MEDIA_COLLECTION,
4495
4593
  record: {
4496
4594
  id: key,
4497
- data: {
4498
- deviceId: params.deviceId,
4499
- ownerKind: params.ownerKind,
4500
- ownerId: params.ownerId,
4501
- kind: params.kind,
4502
- timestamp: params.timestamp,
4503
- path,
4504
- sizeBytes: params.data.length
4505
- }
4595
+ data: record
4506
4596
  }
4507
4597
  });
4508
4598
  return key;
@@ -4521,8 +4611,14 @@ var MediaStore = class {
4521
4611
  * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
4522
4612
  * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
4523
4613
  * each new capture replaces the previous one (blob + index row) rather than
4524
- * accumulating a filmstrip the way `put` does. Deletes any existing rows of
4525
- * that (owner, kind) first, then writes the fresh one. Returns the new key.
4614
+ * accumulating a filmstrip the way `put` does.
4615
+ *
4616
+ * These kinds are {@link SINGLE_INSTANCE_KINDS}, so `put` writes them to a
4617
+ * DETERMINISTIC `owner:kind` row/blob via an UPSERT — N concurrent racing
4618
+ * calls therefore all collapse onto the SAME row (RC-1), no query/insert/delete
4619
+ * interleaving can leave survivors. The query+delete pass below is retained
4620
+ * ONLY to reap LEGACY timestamped rows/blobs written before the deterministic
4621
+ * scheme (transition back-compat); once migrated it is a no-op.
4526
4622
  */
4527
4623
  async putReplacing(params) {
4528
4624
  const existing = await this.store.query.query({
@@ -7921,7 +8017,35 @@ var TrackingSettingsSchema = object({
7921
8017
  dropoutSkipEnabled: boolean().default(true),
7922
8018
  /** Max consecutive all-zero frames absorbed as a glitch before the scene is
7923
8019
  * treated as genuinely empty. */
7924
- dropoutMaxSkipFrames: number().int().min(0).default(5)
8020
+ dropoutMaxSkipFrames: number().int().min(0).default(5),
8021
+ /** Two-stage (ByteTrack) association: match high-confidence detections first,
8022
+ * then recover coasting tracks with the low-confidence leftovers before they
8023
+ * die. The single biggest fragmentation reducer. Off = legacy single pass. */
8024
+ byteTrackEnabled: boolean().default(true),
8025
+ /** Score at/above which a detection is HIGH (matched first); below it a detection
8026
+ * is LOW and only gets the second-stage recovery match. Does not gate spawning
8027
+ * (the per-class score/area floors do). */
8028
+ byteTrackHighThreshold: number().min(0).max(1).default(.4),
8029
+ /** Looser IoU gate for the low-score second (recovery) association stage — a
8030
+ * coasting box is stale, and the match only re-attaches an existing track. */
8031
+ byteTrackLowIouThreshold: number().min(0).max(1).default(.2),
8032
+ /** Enforce a per-class minimum bbox-area (fraction of frame) at track SPAWN —
8033
+ * kills tiny far-field / vanishing-point noise tracks. An established track
8034
+ * still matches a shrinking box; only spawns are gated. */
8035
+ minSpawnAreaEnabled: boolean().default(true),
8036
+ /** Min spawn bbox area for `person` as a fraction of the frame (0 = ungated).
8037
+ * Person is the only spawn-score-ungated class, so far-field person noise is
8038
+ * the worst offender; a conservative 0.05% rejects only vanishing-point blips. */
8039
+ minSpawnAreaFracPerson: number().min(0).max(1).default(5e-4),
8040
+ /** Min spawn bbox area for `animal` as a fraction of the frame (0 = ungated). */
8041
+ minSpawnAreaFracAnimal: number().min(0).max(1).default(0),
8042
+ /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
8043
+ minSpawnAreaFracVehicle: number().min(0).max(1).default(0),
8044
+ /** Gate association on a class GROUP ({person,animal}) instead of the exact
8045
+ * per-frame class, so a person that flips to `animal` for a frame re-matches
8046
+ * its existing track (no concurrent animal id). Reported label stays via the
8047
+ * class vote. Off = exact per-frame class gating. */
8048
+ classGroupAssoc: boolean().default(true)
7925
8049
  });
7926
8050
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
7927
8051
  /**
@@ -7967,7 +8091,15 @@ function resolveTrackingSettings(raw) {
7967
8091
  confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
7968
8092
  confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
7969
8093
  dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
7970
- dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
8094
+ dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames),
8095
+ byteTrackEnabled: s.byteTrackEnabled.catch(TRACKING_DEFAULTS.byteTrackEnabled).parse(raw.byteTrackEnabled),
8096
+ byteTrackHighThreshold: s.byteTrackHighThreshold.catch(TRACKING_DEFAULTS.byteTrackHighThreshold).parse(raw.byteTrackHighThreshold),
8097
+ byteTrackLowIouThreshold: s.byteTrackLowIouThreshold.catch(TRACKING_DEFAULTS.byteTrackLowIouThreshold).parse(raw.byteTrackLowIouThreshold),
8098
+ minSpawnAreaEnabled: s.minSpawnAreaEnabled.catch(TRACKING_DEFAULTS.minSpawnAreaEnabled).parse(raw.minSpawnAreaEnabled),
8099
+ minSpawnAreaFracPerson: s.minSpawnAreaFracPerson.catch(TRACKING_DEFAULTS.minSpawnAreaFracPerson).parse(raw.minSpawnAreaFracPerson),
8100
+ minSpawnAreaFracAnimal: s.minSpawnAreaFracAnimal.catch(TRACKING_DEFAULTS.minSpawnAreaFracAnimal).parse(raw.minSpawnAreaFracAnimal),
8101
+ minSpawnAreaFracVehicle: s.minSpawnAreaFracVehicle.catch(TRACKING_DEFAULTS.minSpawnAreaFracVehicle).parse(raw.minSpawnAreaFracVehicle),
8102
+ classGroupAssoc: s.classGroupAssoc.catch(TRACKING_DEFAULTS.classGroupAssoc).parse(raw.classGroupAssoc)
7971
8103
  };
7972
8104
  }
7973
8105
  //#endregion
@@ -8439,11 +8571,12 @@ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
8439
8571
  var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8440
8572
  /**
8441
8573
  * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
8442
- * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
8443
- * downstream keeps one `keyFrame` per track (the current peak).
8574
+ * the tracks that hit a GENUINE new best-frame moment (`keyFrame`), NOT the
8575
+ * per-frame best-thumbnail retry. `putReplacing` downstream keeps one `keyFrame`
8576
+ * per track (the current peak).
8444
8577
  */
8445
8578
  function selectKeyFrameTrackIds(targets) {
8446
- return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
8579
+ return targets.filter((t) => t.keyFrame).map((t) => t.trackId);
8447
8580
  }
8448
8581
  /**
8449
8582
  * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
@@ -8463,6 +8596,15 @@ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
8463
8596
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
8464
8597
  };
8465
8598
  }
8599
+ /**
8600
+ * True when a native subject crop of `width` px is a plausible native hit worth
8601
+ * keeping, rather than a sub-threshold fallback stamp. Clamps the floor to the
8602
+ * requested `maxWidth` so a caller that legitimately asked for a narrow crop
8603
+ * (`maxWidth < MIN`) is not rejected for honouring its own cap.
8604
+ */
8605
+ function nativeSubjectCropMeetsFloor(width, maxWidth) {
8606
+ return width >= Math.min(320, maxWidth);
8607
+ }
8466
8608
  //#endregion
8467
8609
  //#region src/pipeline-analytics/track-retention-sweep.ts
8468
8610
  /**
@@ -9545,6 +9687,7 @@ var FaceRecognizer = class {
9545
9687
  bbox: input.parentBbox,
9546
9688
  zones: [],
9547
9689
  state: "moving",
9690
+ matchedThisFrame: true,
9548
9691
  embedding: input.embedding,
9549
9692
  embeddingModelId: modelId,
9550
9693
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -11900,6 +12043,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11900
12043
  * (recycled/blank live frame) still gets a subject crop for the gallery
11901
12044
  * instead of degrading to a full-scene tile. Cleared on track end + reset. */
11902
12045
  thumbnailLandedTracks = /* @__PURE__ */ new Set();
12046
+ /** Tracks with a best-`thumbnail` capture CURRENTLY in flight (RC-1). The
12047
+ * #27-A retry re-fired a fresh best-thumbnail capture every frame while one
12048
+ * was still resolving (capture latency stacks 0.1–3s under the native path),
12049
+ * so a short track issued N overlapping captures. While a track sits here
12050
+ * `buildSnapshotTargets` suppresses a new best-thumbnail request; the pending
12051
+ * result clears the flag (and, if it landed, sets `thumbnailLandedTracks`).
12052
+ * Cleared on track end + reset. */
12053
+ thumbnailInFlight = /* @__PURE__ */ new Set();
12054
+ /** Tracks with a native `keyFrame` capture CURRENTLY in flight (RC-1). Guards
12055
+ * the fire-and-forget `persistKeyFrames` so a burst of new-best frames issues
12056
+ * at most ONE outstanding 1920px native capture per track instead of one per
12057
+ * frame. Cleared when the capture settles (+ on track end / reset). */
12058
+ keyFrameInFlight = /* @__PURE__ */ new Set();
11903
12059
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
11904
12060
  * `phase:'update'` — the last-emitted best (confidence / label / crop
11905
12061
  * area) + emit time, so a material improvement is measured against the
@@ -12141,7 +12297,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12141
12297
  detectionFrameFallbacks: nativeFallbacks
12142
12298
  } });
12143
12299
  };
12144
- const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12300
+ const fetchNativeCropRgb = async (frameHandle, paddedNorm, maxWidth) => {
12145
12301
  if (!pipelineRunnerApi?.getNativeCrop) return null;
12146
12302
  try {
12147
12303
  const native = await pipelineRunnerApi.getNativeCrop.query({
@@ -12150,14 +12306,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12150
12306
  ...maxWidth !== void 0 ? { maxWidth } : {}
12151
12307
  }, nodePin(frameHandle.nodeId));
12152
12308
  if (!native || native.width <= 0 || native.height <= 0) return null;
12153
- return await encodeRgbCropToJpeg(Buffer.from(native.bytes), native.width, native.height);
12309
+ return {
12310
+ bytes: Buffer.from(native.bytes),
12311
+ width: native.width,
12312
+ height: native.height
12313
+ };
12154
12314
  } catch (err) {
12155
12315
  cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: errMsg(err) } });
12156
12316
  return null;
12157
12317
  }
12158
12318
  };
12319
+ const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
12320
+ const native = await fetchNativeCropRgb(frameHandle, paddedNorm, maxWidth);
12321
+ if (!native) return null;
12322
+ return await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12323
+ };
12159
12324
  const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
12160
- const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
12325
+ const native = await fetchNativeCropRgb(frameHandle, normalizedBbox, maxWidth);
12326
+ if (!native || !nativeSubjectCropMeetsFloor(native.width, maxWidth)) {
12327
+ if (native) cropMetricLogger.debug("native subject crop below floor — local fallback", { meta: {
12328
+ width: native.width,
12329
+ height: native.height,
12330
+ maxWidth
12331
+ } });
12332
+ bumpCropMetric(false);
12333
+ return null;
12334
+ }
12335
+ const jpeg = await encodeRgbCropToJpeg(native.bytes, native.width, native.height);
12161
12336
  bumpCropMetric(jpeg !== null);
12162
12337
  return jpeg;
12163
12338
  };
@@ -12710,6 +12885,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12710
12885
  this.bestFrameTracker.clear();
12711
12886
  this.lastFrameAtByTrack.clear();
12712
12887
  this.thumbnailLandedTracks.clear();
12888
+ this.thumbnailInFlight.clear();
12889
+ this.keyFrameInFlight.clear();
12713
12890
  this.trackLifecycleUpdateMem.clear();
12714
12891
  this.objectEmbeddingBestSelector.clear();
12715
12892
  this.levelStateByDevice.clear();
@@ -13030,6 +13207,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13030
13207
  frames: captureAgg.frames,
13031
13208
  ...captureAgg.sums
13032
13209
  } });
13210
+ const thumbInFlightTrackIds = snapshotTargets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
13211
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.add(trackId);
13033
13212
  this.eventMediaDispatcher.captureForFrame({
13034
13213
  deviceId,
13035
13214
  frameHandle,
@@ -13049,7 +13228,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13049
13228
  mediaKey: s.mediaKey
13050
13229
  });
13051
13230
  for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
13052
- }).catch(() => {});
13231
+ }).catch(() => {}).finally(() => {
13232
+ for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.delete(trackId);
13233
+ });
13053
13234
  }
13054
13235
  }
13055
13236
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -13494,7 +13675,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13494
13675
  const mediaStore = this.mediaStore;
13495
13676
  if (!capture || !mediaStore) return;
13496
13677
  const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
13497
- await Promise.all(trackIds.map(async (trackId) => {
13678
+ const pending = trackIds.filter((id) => !this.keyFrameInFlight.has(id));
13679
+ if (pending.length === 0) return;
13680
+ for (const id of pending) this.keyFrameInFlight.add(id);
13681
+ await Promise.all(pending.map(async (trackId) => {
13498
13682
  try {
13499
13683
  const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
13500
13684
  if (!keyFrame) return;
@@ -13515,6 +13699,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13515
13699
  error: errMsg(err)
13516
13700
  }
13517
13701
  });
13702
+ } finally {
13703
+ this.keyFrameInFlight.delete(trackId);
13518
13704
  }
13519
13705
  }));
13520
13706
  }
@@ -13576,6 +13762,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13576
13762
  buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
13577
13763
  const targets = [];
13578
13764
  for (const t of tracked) {
13765
+ if (t.matchedThisFrame === false) continue;
13579
13766
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
13580
13767
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
13581
13768
  lastSnapshotAt: lastSnap,
@@ -13607,8 +13794,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13607
13794
  });
13608
13795
  if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
13609
13796
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
13610
- const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13611
- if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
13797
+ const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
13798
+ const bestThumbnail = plan.bestThumbnail && plausibleBox && !this.thumbnailInFlight.has(t.trackId);
13799
+ const keyFrame = isNewBest && plausibleBox;
13800
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail && !keyFrame) continue;
13612
13801
  targets.push({
13613
13802
  trackId: t.trackId,
13614
13803
  timestamp,
@@ -13616,7 +13805,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13616
13805
  ...t.label ? { label: t.label } : {},
13617
13806
  appendSnapshot: plan.appendSnapshot,
13618
13807
  rollingLastFrame: plan.rollingLastFrame,
13619
- bestThumbnail
13808
+ bestThumbnail,
13809
+ keyFrame
13620
13810
  });
13621
13811
  }
13622
13812
  return targets;
@@ -13923,6 +14113,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13923
14113
  this.objectEmbeddingBestSelector.delete(t.trackId);
13924
14114
  this.lastFrameAtByTrack.delete(t.trackId);
13925
14115
  this.thumbnailLandedTracks.delete(t.trackId);
14116
+ this.thumbnailInFlight.delete(t.trackId);
14117
+ this.keyFrameInFlight.delete(t.trackId);
13926
14118
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
13927
14119
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
13928
14120
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -14185,6 +14377,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14185
14377
  this.objectEmbeddingBestSelector.delete(track.trackId);
14186
14378
  this.lastFrameAtByTrack.delete(track.trackId);
14187
14379
  this.thumbnailLandedTracks.delete(track.trackId);
14380
+ this.thumbnailInFlight.delete(track.trackId);
14381
+ this.keyFrameInFlight.delete(track.trackId);
14188
14382
  this.trackLifecycleUpdateMem.delete(track.trackId);
14189
14383
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
14190
14384
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -14235,7 +14429,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14235
14429
  vehicle: trk.minScoreVehicle
14236
14430
  },
14237
14431
  confirmBypassEnabled: trk.confirmBypassEnabled,
14238
- confirmBypassScore: trk.confirmBypassScore
14432
+ confirmBypassScore: trk.confirmBypassScore,
14433
+ byteTrackEnabled: trk.byteTrackEnabled,
14434
+ byteTrackHighThreshold: trk.byteTrackHighThreshold,
14435
+ byteTrackLowIouThreshold: trk.byteTrackLowIouThreshold,
14436
+ minSpawnAreaEnabled: trk.minSpawnAreaEnabled,
14437
+ classSpawnAreaFracs: {
14438
+ person: trk.minSpawnAreaFracPerson,
14439
+ animal: trk.minSpawnAreaFracAnimal,
14440
+ vehicle: trk.minSpawnAreaFracVehicle
14441
+ },
14442
+ classGroupAssoc: trk.classGroupAssoc
14239
14443
  }, { stationaryThresholdSec }, {
14240
14444
  minTrackAge,
14241
14445
  cooldownSec,
@@ -15687,6 +15891,77 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15687
15891
  max: 1,
15688
15892
  step: .05,
15689
15893
  default: TRACKING_DEFAULTS.confirmBypassScore
15894
+ },
15895
+ {
15896
+ type: "boolean",
15897
+ key: "byteTrackEnabled",
15898
+ label: "Two-stage association (ByteTrack)",
15899
+ description: "Match high-confidence detections first, then recover coasting tracks with the low-confidence leftovers before they die. The single biggest fix for a subject fragmenting into many tracks. Off = legacy single pass.",
15900
+ default: TRACKING_DEFAULTS.byteTrackEnabled
15901
+ },
15902
+ {
15903
+ type: "number",
15904
+ key: "byteTrackHighThreshold",
15905
+ label: "Two-stage high threshold",
15906
+ description: "Score at/above which a detection is matched in the first stage. Below it a detection is LOW and gets a second, looser recovery match against still-unmatched (coasting) tracks. This does not change which detections can start a track (the spawn confidence/size floors do). Only used with two-stage association.",
15907
+ min: 0,
15908
+ max: 1,
15909
+ step: .05,
15910
+ default: TRACKING_DEFAULTS.byteTrackHighThreshold
15911
+ },
15912
+ {
15913
+ type: "number",
15914
+ key: "byteTrackLowIouThreshold",
15915
+ label: "Two-stage low IoU",
15916
+ description: "Looser overlap gate for re-attaching a low-confidence detection to a coasting track in the second stage. Only used with two-stage association.",
15917
+ min: 0,
15918
+ max: 1,
15919
+ step: .05,
15920
+ default: TRACKING_DEFAULTS.byteTrackLowIouThreshold
15921
+ },
15922
+ {
15923
+ type: "boolean",
15924
+ key: "minSpawnAreaEnabled",
15925
+ label: "Minimum spawn size",
15926
+ description: "Require a new track’s box to cover at least a minimum fraction of the frame before it is created — stops tiny far-field / vanishing-point blips from spawning full tracks. An already-tracked subject that shrinks into the distance is unaffected.",
15927
+ default: TRACKING_DEFAULTS.minSpawnAreaEnabled
15928
+ },
15929
+ {
15930
+ type: "number",
15931
+ key: "minSpawnAreaFracPerson",
15932
+ label: "Min spawn area — person",
15933
+ description: "Smallest person box (as a fraction of the frame area) allowed to start a NEW track. 0 = ungated. Conservative default (0.0005 = 0.05% of frame) drops only vanishing-point noise. Raise on cameras with a distant road / horizon.",
15934
+ min: 0,
15935
+ max: 1,
15936
+ step: 5e-4,
15937
+ default: TRACKING_DEFAULTS.minSpawnAreaFracPerson
15938
+ },
15939
+ {
15940
+ type: "number",
15941
+ key: "minSpawnAreaFracAnimal",
15942
+ label: "Min spawn area — animal",
15943
+ description: "Smallest animal box (fraction of frame area) allowed to start a NEW track. 0 = ungated (animal already has a spawn-score floor).",
15944
+ min: 0,
15945
+ max: 1,
15946
+ step: 5e-4,
15947
+ default: TRACKING_DEFAULTS.minSpawnAreaFracAnimal
15948
+ },
15949
+ {
15950
+ type: "number",
15951
+ key: "minSpawnAreaFracVehicle",
15952
+ label: "Min spawn area — vehicle",
15953
+ description: "Smallest vehicle box (fraction of frame area) allowed to start a NEW track. 0 = ungated (vehicle already has a spawn-score floor).",
15954
+ min: 0,
15955
+ max: 1,
15956
+ step: 5e-4,
15957
+ default: TRACKING_DEFAULTS.minSpawnAreaFracVehicle
15958
+ },
15959
+ {
15960
+ type: "boolean",
15961
+ key: "classGroupAssoc",
15962
+ label: "Person/animal group matching",
15963
+ description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15964
+ default: TRACKING_DEFAULTS.classGroupAssoc
15690
15965
  }
15691
15966
  ]
15692
15967
  },
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B2MjF8pA.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DrIydlxk.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.1.38",
3
+ "version": "1.1.39",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",