@camstack/addon-post-analysis 1.2.218 → 1.2.219

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 { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-BqkhuC85.mjs";
1
+ import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-D0l3xZqa.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -8334,8 +8334,55 @@ function incomingFromSystemEvent(event, lookup) {
8334
8334
  default: return null;
8335
8335
  }
8336
8336
  }
8337
+ /** The shells whose update has its own sentence. Anything else gets the plain
8338
+ * one: a body that names a shell nobody observed is worse than a vague body. */
8339
+ var WRAPPER_BODY_KINDS = new Set([
8340
+ "docker",
8341
+ "electron",
8342
+ "native"
8343
+ ]);
8344
+ /**
8345
+ * The SHELL announcement (D437) — the container / app bundle a node runs
8346
+ * inside is behind the release contract.
8347
+ *
8348
+ * It is deliberately NOT folded into `server-update-available`: the versions
8349
+ * here are the shell's (a seed closure, an app bundle), not the running code's,
8350
+ * and on 2026-08-02 the running code was perfectly current on a hub whose image
8351
+ * was 13 days old from a repository nothing rebuilt. It also mints no button —
8352
+ * no process can replace the shell it is running inside — so the BODY is the
8353
+ * whole delivery, and it is chosen by `wrapperKind` so a docker operator is
8354
+ * handed the exact command instead of a button that cannot exist.
8355
+ */
8356
+ function incomingFromWrapperUpdate(facts, data) {
8357
+ const packages = updatePackages(data);
8358
+ if (packages.length === 0) return null;
8359
+ const head = packages[0];
8360
+ const nodeId = head.nodeId ?? stringField(data, "nodeId");
8361
+ const wrapperKind = stringField(data, "wrapperKind");
8362
+ const bodySuffix = wrapperKind !== void 0 && WRAPPER_BODY_KINDS.has(wrapperKind) ? wrapperKind : null;
8363
+ return systemSubject({
8364
+ facts,
8365
+ kind: "wrapper-update-available",
8366
+ subject: `wrapper:${nodeId ?? "hub"}`,
8367
+ titleKey: "system.wrapper-update-available.title",
8368
+ bodyKey: bodySuffix === null ? "system.wrapper-update-available.body" : `system.wrapper-update-available.body.${bodySuffix}`,
8369
+ textParams: {
8370
+ currentVersion: head.currentVersion,
8371
+ latestVersion: head.latestVersion,
8372
+ ...nodeId !== void 0 ? { nodeId } : {}
8373
+ },
8374
+ textCount: 1,
8375
+ packageName: head.packageName,
8376
+ packageNames: [head.packageName],
8377
+ ...nodeId !== void 0 ? {
8378
+ nodeId,
8379
+ nodeIds: [nodeId]
8380
+ } : {}
8381
+ });
8382
+ }
8337
8383
  function incomingFromUpdateAvailable(facts, data) {
8338
8384
  const target = stringField(data, "target");
8385
+ if (target === "wrapper") return incomingFromWrapperUpdate(facts, data);
8339
8386
  if (target !== "addon" && target !== "server") return null;
8340
8387
  const packages = updatePackages(data);
8341
8388
  if (packages.length === 0) return null;
@@ -9445,7 +9492,7 @@ var CAMERA_DEVICE_TYPE = "camera";
9445
9492
  /** Kinds whose action is "go and apply an ADDON update". */
9446
9493
  var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
9447
9494
  /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
9448
- var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
9495
+ var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
9449
9496
  /**
9450
9497
  * Kinds whose action is "go and look at this device's stream".
9451
9498
  *
@@ -10010,6 +10057,11 @@ var en_default = {
10010
10057
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
10011
10058
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
10012
10059
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
10060
+ "system.wrapper-update-available.title": "The container or app on {{nodeId}} is out of date",
10061
+ "system.wrapper-update-available.body": "The shell this node runs inside carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Updating the server keeps the code current but never replaces the shell.",
10062
+ "system.wrapper-update-available.body.docker": "This node's image carries seed {{currentVersion}}; the release contract delivers {{latestVersion}}. A container cannot replace its own image — recreate it from the contract image: docker compose pull && docker compose up -d",
10063
+ "system.wrapper-update-available.body.electron": "The desktop app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. The app downloads the new build on its own and installs it the next time it quits.",
10064
+ "system.wrapper-update-available.body.native": "The installed app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Reinstall it from the current release to converge.",
10013
10065
  "system.addon-updated.title": {
10014
10066
  "one": "{{count}} addon updated",
10015
10067
  "other": "{{count}} addons updated"
@@ -10265,6 +10317,11 @@ var it_default = {
10265
10317
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
10266
10318
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
10267
10319
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
10320
+ "system.wrapper-update-available.title": "Il contenitore o l'app di {{nodeId}} è indietro",
10321
+ "system.wrapper-update-available.body": "Il guscio in cui gira questo nodo porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Aggiornare il server tiene il codice aggiornato ma non sostituisce mai il guscio.",
10322
+ "system.wrapper-update-available.body.docker": "L'immagine di questo nodo porta il seed {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Un container non può sostituire la propria immagine — ricrealo dall'immagine del contratto: docker compose pull && docker compose up -d",
10323
+ "system.wrapper-update-available.body.electron": "L'app desktop su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. L'app scarica da sola la nuova build e la installa alla prossima chiusura.",
10324
+ "system.wrapper-update-available.body.native": "L'app installata su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Reinstallala dal rilascio corrente per allinearla.",
10268
10325
  "system.addon-updated.title": {
10269
10326
  "one": "{{count}} addon aggiornato",
10270
10327
  "other": "{{count}} addon aggiornati"
@@ -25802,6 +25859,7 @@ function systemEventSubject(kind, scope) {
25802
25859
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
25803
25860
  if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
25804
25861
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
25862
+ if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
25805
25863
  if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
25806
25864
  if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
25807
25865
  if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
@@ -33403,7 +33461,12 @@ var FaceGalleryProvider = class {
33403
33461
  identityId: input.identityId
33404
33462
  });
33405
33463
  assigned += 1;
33406
- } catch {
33464
+ } catch (err) {
33465
+ this.logger.error("assignFaces refused a face", { meta: {
33466
+ faceId,
33467
+ identityId: input.identityId,
33468
+ error: err instanceof Error ? `${err.name}: ${err.message}` : String(err)
33469
+ } });
33407
33470
  failed.push(faceId);
33408
33471
  }
33409
33472
  return {
@@ -34785,6 +34848,21 @@ var SpawnBesideCoastingSampler = class {
34785
34848
  this.counts.clear();
34786
34849
  }
34787
34850
  };
34851
+ /** Per-cause counter + sampling decision. One instance per tracker, so the
34852
+ * counts are per camera and per detection source. */
34853
+ var TrackLossSampler = class {
34854
+ counts = /* @__PURE__ */ new Map();
34855
+ /** The running count when this loss should be emitted, else `undefined`. */
34856
+ next(cause) {
34857
+ const count = (this.counts.get(cause) ?? 0) + 1;
34858
+ this.counts.set(cause, count);
34859
+ if (count <= 10) return count;
34860
+ return count % 50 === 0 ? count : void 0;
34861
+ }
34862
+ reset() {
34863
+ this.counts.clear();
34864
+ }
34865
+ };
34788
34866
  /** Per-reason counter + sampling decision. One instance per tracker, so the
34789
34867
  * counts are per camera and per detection source. */
34790
34868
  var SpawnRefusalSampler = class {
@@ -34893,6 +34971,16 @@ var SortTracker = class SortTracker {
34893
34971
  refusalSink = null;
34894
34972
  /** Per-reason counters behind the sampled spawn-beside-coasting diagnostics. */
34895
34973
  spawnBesideSampler = new SpawnBesideCoastingSampler();
34974
+ /**
34975
+ * Deliveries to {@link update} since this tracker was constructed. One per
34976
+ * frame the pipeline actually hands over — which is exactly what "the camera
34977
+ * was looking" means, and is NOT derivable from wall clock: a quiet camera
34978
+ * and a busy one span the same seconds (D438).
34979
+ */
34980
+ frameSeq = 0;
34981
+ /** Sink for track deaths — see {@link setTrackLossObserver}. */
34982
+ trackLossObserver = null;
34983
+ trackLossSampler = new TrackLossSampler();
34896
34984
  /** Sink for fragmentation diagnostics — see {@link setSpawnBesideCoastingObserver}. */
34897
34985
  spawnBesideObserver = null;
34898
34986
  constructor(config = {}) {
@@ -34925,6 +35013,13 @@ var SortTracker = class SortTracker {
34925
35013
  * Same contract as the other two sinks — the tracker has no `deviceId`, the
34926
35014
  * caller stamps it, and nothing is computed when no observer is wired.
34927
35015
  */
35016
+ /**
35017
+ * Wire the sink for {@link TrackLossRecord}. Nothing is computed when no
35018
+ * observer is attached.
35019
+ */
35020
+ setTrackLossObserver(observer) {
35021
+ this.trackLossObserver = observer;
35022
+ }
34928
35023
  setSpawnBesideCoastingObserver(observer) {
34929
35024
  this.spawnBesideObserver = observer;
34930
35025
  }
@@ -34939,13 +35034,13 @@ var SortTracker = class SortTracker {
34939
35034
  reportSpawnBesideCoasting(spawnedId, det, coasting, detections, frameArea, timestamp) {
34940
35035
  const observer = this.spawnBesideObserver;
34941
35036
  if (!observer) return;
34942
- const group = this.assocGroup(det.class);
35037
+ const family = SortTracker.spawnBesideFamily(det.class);
34943
35038
  const dc = bboxCentroid(det.bbox);
34944
35039
  let best;
34945
35040
  let bestDist = Number.POSITIVE_INFINITY;
34946
35041
  let bestReach = 0;
34947
35042
  for (const track of coasting) {
34948
- if (this.assocGroup(track.class) !== group) continue;
35043
+ if (SortTracker.spawnBesideFamily(track.class) !== family) continue;
34949
35044
  const tc = bboxCentroid(track.bbox);
34950
35045
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
34951
35046
  const reach = this.reacquireReach(track, timestamp, true);
@@ -34960,6 +35055,7 @@ var SortTracker = class SortTracker {
34960
35055
  const declinedBy = this.explainDecline(best, det, detections, frameArea, timestamp, bestDist);
34961
35056
  const spawnCount = this.spawnBesideSampler.next(declinedBy);
34962
35057
  if (spawnCount === void 0) return;
35058
+ const crowd = declinedBy === "crowd-guard" ? this.crowdAround(best, det, detections) : void 0;
34963
35059
  observer({
34964
35060
  spawnedTrackId: spawnedId,
34965
35061
  spawnedClass: det.class,
@@ -34976,6 +35072,8 @@ var SortTracker = class SortTracker {
34976
35072
  gapMs: timestamp - best.lastSeen,
34977
35073
  trackAgeMs: timestamp - best.firstSeen,
34978
35074
  declinedBy,
35075
+ ...crowd === void 0 ? {} : { competitorCount: crowd.count },
35076
+ ...crowd?.nearestPx === void 0 ? {} : { nearestCompetitorPx: crowd.nearestPx },
34979
35077
  spawnCount,
34980
35078
  sampledEvery: 10
34981
35079
  });
@@ -34990,8 +35088,7 @@ var SortTracker = class SortTracker {
34990
35088
  * `already-claimed` means.
34991
35089
  */
34992
35090
  explainDecline(track, det, detections, frameArea, timestamp, centroidDist) {
34993
- if (!this.sameGroupAsMeasured(track.class, det)) return "native-measured-class";
34994
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return "assoc-group";
35091
+ if (track.class !== det.class) return det.nativeRecovery !== void 0 ? "native-measured-class" : "cross-class";
34995
35092
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "already-claimed";
34996
35093
  if (centroidDist <= this.reacquireReach(track, timestamp, true)) {
34997
35094
  if (!this.isPlausibleBirth(det, frameArea)) return "already-claimed";
@@ -35050,14 +35147,44 @@ var SortTracker = class SortTracker {
35050
35147
  * come back — a graveyarded track may still resurrect, and a line about work
35051
35148
  * that was not lost is worse than no line.
35052
35149
  */
35150
+ /**
35151
+ * Report one track that ended with nothing taking over (D438).
35152
+ *
35153
+ * Deliberately separate from {@link reportTrackLoss}, which speaks only for
35154
+ * spawns that never reached `minHits`. The population under investigation —
35155
+ * animal tracks dying mid-frame after six frames — had all EMITTED, so that
35156
+ * path never saw a single one of them.
35157
+ *
35158
+ * `timestamp` is the frame doing the sweeping, not the track's own clock: the
35159
+ * question is how much passed while nobody was looking.
35160
+ */
35161
+ reportTrackDeath(track, cause, timestamp) {
35162
+ const observer = this.trackLossObserver;
35163
+ if (!observer) return;
35164
+ const lossCount = this.trackLossSampler.next(cause);
35165
+ if (lossCount === void 0) return;
35166
+ observer({
35167
+ trackId: track.id,
35168
+ trackClass: track.class,
35169
+ cause,
35170
+ framesSinceLastSeen: this.frameSeq - track.lastSeenFrameSeq,
35171
+ msSinceLastSeen: timestamp - track.lastSeen,
35172
+ lifeMs: track.lastSeen - track.firstSeen,
35173
+ positions: track.hits,
35174
+ emitted: track.emitted,
35175
+ lossCount,
35176
+ sampledEvery: 50
35177
+ });
35178
+ }
35053
35179
  reportTrackLoss(track) {
35054
35180
  if (track.emitted || track.lossReported) return;
35055
35181
  track.lossReported = true;
35056
35182
  this.reportRefusal("unconfirmed-spawn-lost", {
35057
- class: track.class,
35183
+ class: track.frameClass,
35058
35184
  originalClass: track.originalClass,
35059
35185
  score: track.score,
35060
- bbox: track.bbox
35186
+ bbox: track.bbox,
35187
+ ...track.lastNativeRecovery !== void 0 ? { nativeRecovery: track.lastNativeRecovery } : {}
35061
35188
  }, {
35062
35189
  threshold: this.config.minHits,
35063
35190
  measured: track.hits,
@@ -35112,43 +35239,73 @@ var SortTracker = class SortTracker {
35112
35239
  static pairKey(a, b) {
35113
35240
  return a < b ? `${a}|${b}` : `${b}|${a}`;
35114
35241
  }
35115
- /** Association-gating group of a class. When `classGroupAssoc` is on the
35116
- * {person,animal} pair collapses to one group so a per-frame class flip
35117
- * (crouch person animal) re-matches the existing track instead of spawning a
35118
- * concurrent id. Every other class is its own group. */
35119
- assocGroup(cls) {
35120
- if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35242
+ /** The two macro-classes a coarse pass confuses on a non-upright person. */
35243
+ static isPersonAnimalPair(a, b) {
35244
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
35245
+ }
35246
+ /**
35247
+ * The family a spawn-beside-coasting line is reported AGAINST {person,
35248
+ * animal} as one, every other class its own. Reporting only, never a binding
35249
+ * rule: a cross-class pair is exactly what that line exists to name (the
35250
+ * D378 veto, the D439 salvage rule), so a pair the tracker refused must still
35251
+ * be reportable. Independent of `classGroupAssoc` for the same reason.
35252
+ */
35253
+ static spawnBesideFamily(cls) {
35254
+ if (cls === PERSON_CLASS || cls === ANIMAL_CLASS) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35121
35255
  return cls;
35122
35256
  }
35123
- /** Whether a detection may associate to a track under the current class gate:
35124
- * exact class when `classGating` is off (no gate at all) matching the legacy
35125
- * contract — otherwise same association group (group == exact class unless
35126
- * `classGroupAssoc` widened it to {person,animal}). */
35257
+ /**
35258
+ * A CROSS-CLASS BIND IS A FLIP CLAIM, AND A FLIP IS WEAK (D439).
35259
+ *
35260
+ * `classGroupAssoc` exists for one failure of the COARSE full-frame pass: a
35261
+ * bending person re-labelled `animal` for a frame, over the person's own
35262
+ * box, at the 0.4-0.6 the coarse pass gives a shape it half recognises. That
35263
+ * claim has three parts, and this predicate is all three:
35264
+ *
35265
+ * - the box was measured by the coarse pass — never `nativeRecovery`. The
35266
+ * runner's second pass measures the OPPOSITE thing: a 640-800 px native
35267
+ * square around a motion region the full-frame boxes do NOT account for,
35268
+ * which is by construction not the subject the full pass already has.
35269
+ * 2026-09-07 07:51:33, an `animal` at `nativeRecoveryScore=0.833`
35270
+ * absorbed into a person: the D378 half of this rule;
35271
+ * - it is a {person, animal} pair;
35272
+ * - it is WEAK — below {@link TrackerConfig.animalOverPersonMaxScore}, the
35273
+ * same bar the spawn gate uses for the same claim. A `person` at 0.86 or
35274
+ * an `animal` at 0.92 is not a half-recognised shape, it is a subject,
35275
+ * and binding it to the other class's track hands one subject's id to
35276
+ * another (camera 615, 17:11:01: person 0.8616 → animal track
35277
+ * `a28c1557`, which was then born as a person).
35278
+ *
35279
+ * Where the caller may use it is the fourth part, and it is the caller's:
35280
+ * only the predicted-box passes ({@link greedyAssociate}), where "same place"
35281
+ * is measured against a prediction on the strict `iouThreshold`. The salvage
35282
+ * passes ({@link looseMatch}) never cross classes — their whole premise is
35283
+ * weak overlap, and a weak-overlap cross-class bind is a dog beside its
35284
+ * owner, every time.
35285
+ */
35286
+ crossClassFlip(trackClass, det) {
35287
+ if (!this.config.classGroupAssoc) return false;
35288
+ if (det.nativeRecovery !== void 0) return false;
35289
+ if (!SortTracker.isPersonAnimalPair(trackClass, det.class)) return false;
35290
+ return det.score < this.config.animalOverPersonMaxScore;
35291
+ }
35292
+ /** Whether a detection may associate to a track on a PREDICTED-BOX pass:
35293
+ * its own class, or — with `classGroupAssoc` — a weak coarse flip of the
35294
+ * {person, animal} pair ({@link crossClassFlip}). `classGating` off means no
35295
+ * gate at all, matching the legacy contract, except that a native-measured
35296
+ * box still binds only its own class (D378). */
35127
35297
  associable(trackClass, det) {
35128
- if (!this.sameGroupAsMeasured(trackClass, det)) return false;
35298
+ if (trackClass === det.class) return true;
35299
+ if (det.nativeRecovery !== void 0) return false;
35129
35300
  if (!this.config.classGating) return true;
35130
- return this.assocGroup(trackClass) === this.assocGroup(det.class);
35301
+ return this.crossClassFlip(trackClass, det);
35131
35302
  }
35132
35303
  /**
35133
- * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378).
35134
- *
35135
- * `classGroupAssoc` collapses {person, animal} into one association group for
35136
- * one failure of the COARSE full-frame pass: a bending person re-labelled
35137
- * `animal` for a frame, over the person's own box. Letting that box re-match
35138
- * the person track is strictly better than minting a concurrent id.
35139
- *
35140
- * The runner's second pass makes the opposite measurement — the same detector
35141
- * on a 640-800 px native square around a motion region the full-frame boxes
35142
- * do NOT account for. Such a box is, by construction, not the subject the
35143
- * full pass already has; absorbing it into a person track means the animal is
35144
- * never seen at all, which on 2026-09-07 07:51:33 is exactly what happened to
35145
- * an `animal` at `nativeRecoveryScore=0.833`.
35146
- *
35147
- * So the widening does not apply to a detection carrying `nativeRecovery`:
35148
- * it associates to its OWN class or it spawns. Every other guard on that
35149
- * spawn still runs — the per-class score floor, the area floor, the
35150
- * `dedupSpawnIou` overlap, and `resolveDuplicates` afterwards, where a person
35151
- * still beats a cross-class animal.
35304
+ * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378) — the
35305
+ * occlusion gate's copy of the first clause of {@link crossClassFlip}. A
35306
+ * cross-class occluder is not a same-subject claim for a box the native
35307
+ * pass measured: a dog at its owner's knee is 100% contained in the person's
35308
+ * box and is not the person.
35152
35309
  */
35153
35310
  sameGroupAsMeasured(trackClass, det) {
35154
35311
  if (det.nativeRecovery === void 0) return true;
@@ -35321,9 +35478,16 @@ var SortTracker = class SortTracker {
35321
35478
  };
35322
35479
  }
35323
35480
  /**
35324
- * How many same-association-group subjects OTHER than `corpse` share the
35325
- * scene with it: every track LIVE on this frame, plus any other resurrectable
35326
- * corpse whose lifetime OVERLAPPED the candidate's.
35481
+ * How many SAME-CLASS subjects OTHER than `corpse` share the scene with it:
35482
+ * every track LIVE on this frame, plus any other resurrectable corpse whose
35483
+ * lifetime OVERLAPPED the candidate's.
35484
+ *
35485
+ * Exact class, not the {person, animal} family (D439): a competitor is a
35486
+ * subject the id could actually be handed to, and since a salvage pass binds
35487
+ * its own class only, a person cannot take a dog's id there. Counting the
35488
+ * owner as the dog's competitor split one dog's id 12 times on camera 615 in
35489
+ * 6.6 h — every bind the reach clause accepted, the guard then refused
35490
+ * because the person was on screen.
35327
35491
  *
35328
35492
  * The overlap clause on the graveyard half is what keeps this guard off the
35329
35493
  * case the resurrection pass exists for. A single flickering subject leaves a
@@ -35337,30 +35501,30 @@ var SortTracker = class SortTracker {
35337
35501
  * steals an id is very often one whose OWN track was born after the corpse
35338
35502
  * died (camera 618 — the toddler's track post-dates the older boy's).
35339
35503
  */
35340
- sameGroupPeerCount(corpse, live) {
35341
- const group = this.assocGroup(corpse.class);
35504
+ sameClassPeerCount(corpse, live) {
35342
35505
  let peers = 0;
35343
- for (const t of live) if (t !== corpse && this.assocGroup(t.class) === group) peers++;
35506
+ for (const t of live) if (t !== corpse && t.class === corpse.class) peers++;
35344
35507
  for (const other of this.lostTracks) {
35345
35508
  if (other === corpse) continue;
35346
35509
  if (!other.resurrectable) continue;
35347
- if (this.assocGroup(other.class) !== group) continue;
35510
+ if (other.class !== corpse.class) continue;
35348
35511
  if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
35349
35512
  }
35350
35513
  return peers;
35351
35514
  }
35352
35515
  /**
35353
- * Loose same-group gate used by the rescue and resurrection passes: accept
35354
- * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
35355
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
35356
- * gated (a rescue must never cross association groups) independent of
35357
- * `classGating`, so a coasting person track can be rescued by its own
35358
- * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
35359
- * class.
35516
+ * Loose gate used by the rescue and resurrection passes: accept when the
35517
+ * detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or its
35518
+ * centroid is within reach. EXACT-class, always, independent of
35519
+ * `classGating` and of `classGroupAssoc` (D439): a salvage pass binds on
35520
+ * weak overlap by definition, and a weak-overlap bind across {person,
35521
+ * animal} is not a crouch flip a flip sits on the subject's own box — it
35522
+ * is a dog beside its owner, or the owner beside the dog. Until 2026-09-10
35523
+ * this gate crossed the group, and it is where a `person` at 0.86 took over
35524
+ * an animal's id (camera 615, 17:11:01).
35360
35525
  */
35361
35526
  looseMatch(track, det, timestamp, timeScaled = true) {
35362
- if (!this.sameGroupAsMeasured(track.class, det)) return null;
35363
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
35527
+ if (track.class !== det.class) return null;
35364
35528
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
35365
35529
  const tc = bboxCentroid(track.bbox);
35366
35530
  const dc = bboxCentroid(det.bbox);
@@ -35406,12 +35570,41 @@ var SortTracker = class SortTracker {
35406
35570
  * bind time and at report time — the spawn-beside-coasting line has to be able
35407
35571
  * to say which gate declined without re-running the greedy competition.
35408
35572
  */
35573
+ /**
35574
+ * WHO the crowd was, for the record only — this changes no decision.
35575
+ *
35576
+ * {@link isUnambiguousReach} refuses on PRESENCE, with no distance in the
35577
+ * question, so a person at the far corner of a room splits a track as surely
35578
+ * as one standing beside it. Counting them, and measuring the nearest, is
35579
+ * what makes narrowing that rule a decision rather than a guess (D436).
35580
+ */
35581
+ crowdAround(track, det, detections) {
35582
+ const cx = det.bbox.x + det.bbox.w / 2;
35583
+ const cy = det.bbox.y + det.bbox.h / 2;
35584
+ const counted = [];
35585
+ let nearest;
35586
+ const consider = (bbox) => {
35587
+ counted.push(bbox);
35588
+ const d = Math.hypot(bbox.x + bbox.w / 2 - cx, bbox.y + bbox.h / 2 - cy);
35589
+ if (nearest === void 0 || d < nearest) nearest = d;
35590
+ };
35591
+ for (const t of this.tracks) if (t !== track && t.class === track.class) consider(t.bbox);
35592
+ for (const other of detections) {
35593
+ if (other === det) continue;
35594
+ if (other.class !== track.class) continue;
35595
+ if (counted.some((b) => iou$3(b, other.bbox) > 0)) continue;
35596
+ consider(other.bbox);
35597
+ }
35598
+ return {
35599
+ count: counted.length,
35600
+ nearestPx: nearest
35601
+ };
35602
+ }
35409
35603
  isUnambiguousReach(track, det, detections) {
35410
- if (this.sameGroupPeerCount(track, this.tracks) > 0) return false;
35411
- const group = this.assocGroup(track.class);
35604
+ if (this.sameClassPeerCount(track, this.tracks) > 0) return false;
35412
35605
  for (const other of detections) {
35413
35606
  if (other === det) continue;
35414
- if (this.assocGroup(other.class) === group) return false;
35607
+ if (other.class === track.class) return false;
35415
35608
  }
35416
35609
  return true;
35417
35610
  }
@@ -35448,8 +35641,8 @@ var SortTracker = class SortTracker {
35448
35641
  * weight; otherwise the latest-frame class (ambiguous vote or voting off).
35449
35642
  */
35450
35643
  resolveReportedClass(t) {
35451
- if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
35452
- let bestClass = t.class;
35644
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.frameClass;
35645
+ let bestClass = t.frameClass;
35453
35646
  let bestVote = -1;
35454
35647
  let total = 0;
35455
35648
  for (const [cls, v] of t.classVotes) {
@@ -35459,8 +35652,8 @@ var SortTracker = class SortTracker {
35459
35652
  bestClass = cls;
35460
35653
  }
35461
35654
  }
35462
- if (total <= 0) return t.class;
35463
- return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
35655
+ if (total <= 0) return t.frameClass;
35656
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.frameClass;
35464
35657
  }
35465
35658
  /** Whether a detection clears its per-class spawn score floor. Only gates NEW
35466
35659
  * spawns — an established track still matches below its floor. */
@@ -35476,16 +35669,67 @@ var SortTracker = class SortTracker {
35476
35669
  return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
35477
35670
  }
35478
35671
  /**
35672
+ * The live tracks split into the two tiers that claim a box in a fixed
35673
+ * order (D442, see {@link update}): those already confirmed for emission,
35674
+ * and the provisional ones still under `minHits`. Decided on the state at
35675
+ * the start of the frame, as ByteTrack does. With `minHits: 1` the second
35676
+ * tier is always empty and every pass over it is a no-op.
35677
+ */
35678
+ associationTiers() {
35679
+ const established = [];
35680
+ const provisional = [];
35681
+ for (const track of this.tracks) if (this.isConfirmedForEmit(track)) established.push(track);
35682
+ else provisional.push(track);
35683
+ return [established, provisional];
35684
+ }
35685
+ /**
35686
+ * The rescue pass. HIGH-score detections the greedy pass left unmatched get
35687
+ * a second, looser chance against the still-unmatched (coasting) tracks of
35688
+ * `tracks` — measured on the track's LAST-KNOWN bbox, never the drifted
35689
+ * prediction. Recovers half-box re-acquisition and small-drift flicker
35690
+ * without spawning a duplicate. Best (highest last-known IoU) pairs first;
35691
+ * LOW detections are reserved for the ByteTrack low stage.
35692
+ */
35693
+ rescueAssociate(tracks, gates, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly) {
35694
+ const rescuePairs = [];
35695
+ for (const track of tracks) {
35696
+ if (matchedTracks.has(track)) continue;
35697
+ for (let di = 0; di < detections.length; di++) {
35698
+ if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35699
+ const det = detections[di];
35700
+ const gate = this.looseMatch(track, det, timestamp);
35701
+ if (gate === null || !gates.has(gate)) continue;
35702
+ if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35703
+ rescuePairs.push({
35704
+ track,
35705
+ detIdx: di,
35706
+ score: iou$3(track.bbox, det.bbox),
35707
+ gate
35708
+ });
35709
+ }
35710
+ }
35711
+ rescuePairs.sort((a, b) => b.score - a.score);
35712
+ for (const pair of rescuePairs) {
35713
+ if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35714
+ const det = detections[pair.detIdx];
35715
+ matched.set(pair.track, det);
35716
+ matchedTracks.add(pair.track);
35717
+ used.add(pair.detIdx);
35718
+ this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35719
+ }
35720
+ }
35721
+ /**
35479
35722
  * One greedy IoU association pass over a candidate detection subset against the
35480
- * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
35723
+ * still-UNMATCHED tracks of `tracks` (one association tier see
35724
+ * {@link associationTiers}), on each track's PREDICTED box. Highest-IoU pairs
35481
35725
  * bind first; a track/detection binds at most once. Shared by the high-score
35482
35726
  * first stage and the low-score second stage (different candidate sets + IoU
35483
35727
  * gates) so the two-tier ByteTrack association stays a single implementation.
35484
35728
  * Mutates `used` / `matchedTracks` / `matched` in place.
35485
35729
  */
35486
- greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35730
+ greedyAssociate(tracks, detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35487
35731
  const pairs = [];
35488
- for (const track of this.tracks) {
35732
+ for (const track of tracks) {
35489
35733
  if (matchedTracks.has(track)) continue;
35490
35734
  const pbox = this.predicted(track, timestamp);
35491
35735
  const confirmed = this.isConfirmedForEmit(track);
@@ -35511,6 +35755,7 @@ var SortTracker = class SortTracker {
35511
35755
  }
35512
35756
  }
35513
35757
  update(detections, timestamp, frameContext) {
35758
+ this.frameSeq++;
35514
35759
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
35515
35760
  const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
35516
35761
  const resurrectedScene = /* @__PURE__ */ new Map();
@@ -35521,6 +35766,7 @@ var SortTracker = class SortTracker {
35521
35766
  track.lostAt = timestamp;
35522
35767
  track.resurrectable = false;
35523
35768
  this.reportTrackLoss(track);
35769
+ this.reportTrackDeath(track, "lifetime-cap", timestamp);
35524
35770
  this.lostTracks.push(track);
35525
35771
  } else alive.push(track);
35526
35772
  this.tracks = alive;
@@ -35533,13 +35779,17 @@ var SortTracker = class SortTracker {
35533
35779
  track.lostAt = track.lastSeen;
35534
35780
  track.resurrectable = false;
35535
35781
  this.reportTrackLoss(track);
35782
+ this.reportTrackDeath(track, "stale-beyond-budgets", timestamp);
35536
35783
  this.lostTracks.push(track);
35537
35784
  } else alive.push(track);
35538
35785
  this.tracks = alive;
35539
35786
  }
35540
35787
  const stillResurrectable = [];
35541
35788
  for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
35542
- else this.reportTrackLoss(t);
35789
+ else {
35790
+ this.reportTrackLoss(t);
35791
+ this.reportTrackDeath(t, "graveyard-expired", timestamp);
35792
+ }
35543
35793
  this.lostTracks = stillResurrectable;
35544
35794
  const used = /* @__PURE__ */ new Set();
35545
35795
  const matchedTracks = /* @__PURE__ */ new Set();
@@ -35549,36 +35799,21 @@ var SortTracker = class SortTracker {
35549
35799
  for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
35550
35800
  else highIdxs.push(di);
35551
35801
  const lowSet = new Set(lowIdxs);
35552
- this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35553
- const rescuePairs = [];
35554
- for (const track of this.tracks) {
35555
- if (matchedTracks.has(track)) continue;
35556
- for (let di = 0; di < detections.length; di++) {
35557
- if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35558
- const det = detections[di];
35559
- const gate = this.looseMatch(track, det, timestamp);
35560
- if (gate === null) continue;
35561
- if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35562
- rescuePairs.push({
35563
- track,
35564
- detIdx: di,
35565
- score: iou$3(track.bbox, det.bbox),
35566
- gate
35567
- });
35568
- }
35569
- }
35570
- rescuePairs.sort((a, b) => b.score - a.score);
35571
- for (const pair of rescuePairs) {
35572
- if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35573
- const det = detections[pair.detIdx];
35574
- matched.set(pair.track, det);
35575
- matchedTracks.add(pair.track);
35576
- used.add(pair.detIdx);
35577
- this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35578
- }
35579
- if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35802
+ const [established, provisional] = this.associationTiers();
35803
+ const OVERLAP = new Set(["iou"]);
35804
+ const PROXIMITY = new Set(["centroid", "growth"]);
35805
+ this.greedyAssociate(established, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35806
+ this.rescueAssociate(established, OVERLAP, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35807
+ this.greedyAssociate(provisional, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35808
+ this.rescueAssociate(established, PROXIMITY, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35809
+ this.rescueAssociate(provisional, new Set([...OVERLAP, ...PROXIMITY]), detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35810
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) for (const tier of [established, provisional]) this.greedyAssociate(tier, detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35580
35811
  for (const [track, det] of matched) {
35581
- if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, { against: track });
35812
+ if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
35813
+ against: track,
35814
+ measured: iou$3(this.predicted(track, timestamp), det.bbox),
35815
+ threshold: this.config.animalOverPersonMaxScore
35816
+ });
35582
35817
  const prevCenter = bboxCentroid({
35583
35818
  x: track.bbox.x,
35584
35819
  y: track.bbox.y,
@@ -35594,12 +35829,15 @@ var SortTracker = class SortTracker {
35594
35829
  const frames = track.age + 1;
35595
35830
  const dtMs = timestamp - track.lastSeen;
35596
35831
  track.bbox = det.bbox;
35597
- track.class = det.class;
35832
+ if (!this.crossClassFlip(track.class, det)) track.class = det.class;
35833
+ track.frameClass = det.class;
35598
35834
  track.originalClass = det.originalClass;
35599
35835
  track.score = det.score;
35836
+ track.lastNativeRecovery = det.nativeRecovery;
35600
35837
  track.age = 0;
35601
35838
  track.hits++;
35602
35839
  track.lastSeen = timestamp;
35840
+ track.lastSeenFrameSeq = this.frameSeq;
35603
35841
  if (dtMs > 0) {
35604
35842
  track.velocity = {
35605
35843
  dx: (newCenter.x - prevCenter.x) / dtMs,
@@ -35648,7 +35886,7 @@ var SortTracker = class SortTracker {
35648
35886
  if (gate === null) continue;
35649
35887
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
35650
35888
  const score = iou$3(lost.bbox, det.bbox);
35651
- const peers = this.sameGroupPeerCount(lost, surviving);
35889
+ const peers = this.sameClassPeerCount(lost, surviving);
35652
35890
  if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
35653
35891
  if (score > refusedScore) {
35654
35892
  refused = lost;
@@ -35673,12 +35911,15 @@ var SortTracker = class SortTracker {
35673
35911
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
35674
35912
  best.bbox = det.bbox;
35675
35913
  best.class = det.class;
35914
+ best.frameClass = det.class;
35676
35915
  best.originalClass = det.originalClass;
35677
35916
  best.score = det.score;
35917
+ best.lastNativeRecovery = det.nativeRecovery;
35678
35918
  best.age = 0;
35679
35919
  best.hits++;
35680
35920
  best.lost = false;
35681
35921
  best.lastSeen = timestamp;
35922
+ best.lastSeenFrameSeq = this.frameSeq;
35682
35923
  best.velocity = {
35683
35924
  dx: 0,
35684
35925
  dy: 0
@@ -35735,6 +35976,7 @@ var SortTracker = class SortTracker {
35735
35976
  id: spawnedId,
35736
35977
  bbox: det.bbox,
35737
35978
  class: det.class,
35979
+ frameClass: det.class,
35738
35980
  originalClass: det.originalClass,
35739
35981
  score: det.score,
35740
35982
  age: 0,
@@ -35742,6 +35984,7 @@ var SortTracker = class SortTracker {
35742
35984
  path: [det.bbox],
35743
35985
  firstSeen: timestamp,
35744
35986
  lastSeen: timestamp,
35987
+ lastSeenFrameSeq: this.frameSeq,
35745
35988
  velocity: {
35746
35989
  dx: 0,
35747
35990
  dy: 0
@@ -35752,7 +35995,8 @@ var SortTracker = class SortTracker {
35752
35995
  resurrectable: true,
35753
35996
  classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
35754
35997
  emitted: false,
35755
- lossReported: false
35998
+ lossReported: false,
35999
+ lastNativeRecovery: det.nativeRecovery
35756
36000
  });
35757
36001
  }
35758
36002
  this.tracks = surviving;
@@ -36282,6 +36526,10 @@ var FrameProcessor = class {
36282
36526
  /** Wire (or clear) the tracker's fragmentation sink — a new id minted beside a
36283
36527
  * live coasting track that could have claimed it (D399). Same `deviceId`
36284
36528
  * contract as the two sinks above. */
36529
+ /** Wire the sink for track deaths (D438) — pure passthrough to the tracker. */
36530
+ setTrackLossObserver(observer) {
36531
+ this.tracker.setTrackLossObserver(observer);
36532
+ }
36285
36533
  setSpawnBesideCoastingObserver(observer) {
36286
36534
  this.tracker.setSpawnBesideCoastingObserver(observer);
36287
36535
  }
@@ -66622,10 +66870,12 @@ var TrackingSettingsSchema = object({
66622
66870
  minSpawnAreaFracAnimal: number().min(0).max(1).default(0),
66623
66871
  /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
66624
66872
  minSpawnAreaFracVehicle: number().min(0).max(1).default(0),
66625
- /** Gate association on a class GROUP ({person,animal}) instead of the exact
66626
- * per-frame class, so a person that flips to `animal` for a frame re-matches
66627
- * its existing track (no concurrent animal id). Reported label stays via the
66628
- * class vote. Off = exact per-frame class gating. */
66873
+ /** Let a person that flips to `animal` for a frame re-match its existing
66874
+ * track (no concurrent animal id). A flip is the same subject in the same
66875
+ * place, weakly read (D439): the bind is allowed only on the predicted-box
66876
+ * passes, only for a coarse box below `animalOverPersonMaxScore`, never on
66877
+ * the rescue / resurrection passes. Reported label stays via the class
66878
+ * vote. Off = exact per-frame class gating. */
66629
66879
  classGroupAssoc: boolean().default(true)
66630
66880
  });
66631
66881
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
@@ -67215,7 +67465,7 @@ function buildDetectionSettingsSections() {
67215
67465
  type: "boolean",
67216
67466
  key: "classGroupAssoc",
67217
67467
  label: "Person/animal group matching",
67218
- 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.",
67468
+ 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. A flip is the same subject in the same place, weakly read: the cross-class match happens only on the strict predicted-box pass and only for a full-frame box below the \"animal over person max score\" bar — a confident animal beside a person, or a person beside an animal, is its own subject and keeps its own track. The reported class is still decided by the per-track class vote.",
67219
67469
  default: TRACKING_DEFAULTS.classGroupAssoc
67220
67470
  },
67221
67471
  {
@@ -77686,6 +77936,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77686
77936
  }
77687
77937
  });
77688
77938
  });
77939
+ p.setTrackLossObserver((record) => {
77940
+ this.ctx.logger.info("tracker: a track ended and nothing took over", {
77941
+ tags: { deviceId },
77942
+ meta: {
77943
+ source,
77944
+ ...record
77945
+ }
77946
+ });
77947
+ });
77689
77948
  p.setSpawnBesideCoastingObserver((record) => {
77690
77949
  this.ctx.logger.info("tracker: new id minted beside a live coasting track", {
77691
77950
  tags: { deviceId },