@camstack/addon-post-analysis 1.1.31 → 1.1.32

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.
@@ -7251,6 +7251,24 @@ var RecordingRetentionSchema = object({
7251
7251
  maxSizeGb: number().min(0).optional()
7252
7252
  });
7253
7253
  /**
7254
+ * Scrub-thumbnail fidelity preset — the single per-camera selector bundling the
7255
+ * sprite tile RESOLUTION + JPEG QUALITY the recorder packs timeline-scrub
7256
+ * previews at. Five graduated steps; absent on a config = `standard` (the
7257
+ * shipped default, matching `sheet-geometry`/`sheet-composer`).
7258
+ *
7259
+ * Existing sheets are IMMUTABLE — a changed preset applies to NEW windows only.
7260
+ * Each window's index sidecar carries its own tile dims, so a camera whose
7261
+ * preset changed over time renders every historical window at the dims it was
7262
+ * written with.
7263
+ */
7264
+ var ScrubThumbnailPresetSchema = _enum([
7265
+ "minimal",
7266
+ "low",
7267
+ "standard",
7268
+ "high",
7269
+ "max"
7270
+ ]);
7271
+ /**
7254
7272
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7255
7273
  *
7256
7274
  * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
@@ -7287,7 +7305,14 @@ var RecordingConfigSchema = object({
7287
7305
  * derived into bands once via `migrateConfigToBands`.
7288
7306
  */
7289
7307
  bands: array(RecordingBandSchema).optional(),
7290
- retention: RecordingRetentionSchema.optional()
7308
+ retention: RecordingRetentionSchema.optional(),
7309
+ /**
7310
+ * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
7311
+ * timeline-scrub sprite previews). Absent = `standard`. Applies to NEW
7312
+ * windows only — existing sheets are immutable, and each window's index
7313
+ * carries its own tile dims so mixed-preset history renders correctly.
7314
+ */
7315
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7291
7316
  });
7292
7317
  /**
7293
7318
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -7273,6 +7273,24 @@ var RecordingRetentionSchema = object({
7273
7273
  maxSizeGb: number().min(0).optional()
7274
7274
  });
7275
7275
  /**
7276
+ * Scrub-thumbnail fidelity preset — the single per-camera selector bundling the
7277
+ * sprite tile RESOLUTION + JPEG QUALITY the recorder packs timeline-scrub
7278
+ * previews at. Five graduated steps; absent on a config = `standard` (the
7279
+ * shipped default, matching `sheet-geometry`/`sheet-composer`).
7280
+ *
7281
+ * Existing sheets are IMMUTABLE — a changed preset applies to NEW windows only.
7282
+ * Each window's index sidecar carries its own tile dims, so a camera whose
7283
+ * preset changed over time renders every historical window at the dims it was
7284
+ * written with.
7285
+ */
7286
+ var ScrubThumbnailPresetSchema = _enum([
7287
+ "minimal",
7288
+ "low",
7289
+ "standard",
7290
+ "high",
7291
+ "max"
7292
+ ]);
7293
+ /**
7276
7294
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7277
7295
  *
7278
7296
  * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
@@ -7309,7 +7327,14 @@ var RecordingConfigSchema = object({
7309
7327
  * derived into bands once via `migrateConfigToBands`.
7310
7328
  */
7311
7329
  bands: array(RecordingBandSchema).optional(),
7312
- retention: RecordingRetentionSchema.optional()
7330
+ retention: RecordingRetentionSchema.optional(),
7331
+ /**
7332
+ * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
7333
+ * timeline-scrub sprite previews). Absent = `standard`. Applies to NEW
7334
+ * windows only — existing sheets are immutable, and each window's index
7335
+ * carries its own tile dims so mixed-preset history renders correctly.
7336
+ */
7337
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7313
7338
  });
7314
7339
  /**
7315
7340
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -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-Cy4Tp-yp.js");
5
+ const require_dist = require("../dist-DU_JRm-j.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { c as hfModelUrl, h as BaseAddon, o as embeddingEncoderCapability } from "../dist-D1cY_vlY.mjs";
1
+ import { c as hfModelUrl, h as BaseAddon, o as embeddingEncoderCapability } from "../dist-CyyCe4TK.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Cy4Tp-yp.js");
1
+ const require_dist = require("./dist-DU_JRm-j.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs, 1);
4
4
  let node_path = require("node:path");
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.1.26",
6
+ version: "1.1.27",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.47",
21
+ version: "1.1.48",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.1.38",
36
+ version: "1.1.39",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.47",
39
+ version: "1.1.48",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.1.26",
48
+ version: "1.1.27",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.1.38",
84
+ version: "1.1.39",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -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-Cy4Tp-yp.js");
5
+ const require_dist = require("../dist-DU_JRm-j.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -1477,6 +1477,7 @@ var DEFAULT_EVENT_EMITTER_CONFIG = {
1477
1477
  minTrackAge: 3,
1478
1478
  minTrackAgeMs: 0,
1479
1479
  cooldownSec: 5,
1480
+ emitAppearance: true,
1480
1481
  enabledTypes: [
1481
1482
  "object.entering",
1482
1483
  "object.leaving",
@@ -1501,6 +1502,14 @@ var ZONE_TYPE_TO_EVENT = {
1501
1502
  "zone-loiter": "zone.enter",
1502
1503
  "tripwire-cross": "tripwire.cross"
1503
1504
  };
1505
+ /**
1506
+ * DetectionEventType used for the synthetic per-track APPEARANCE event
1507
+ * (Requirement 1). `object.detected` is otherwise unused by the emitter (it maps
1508
+ * from no state and no zone type), so repurposing it as the appearance marker
1509
+ * needs no new event-type vocabulary. The addon routes these into their own
1510
+ * media/persistence path and keeps them OFF the notification bus.
1511
+ */
1512
+ var APPEARANCE_EVENT_TYPE = "object.detected";
1504
1513
  var eventIdCounter = 0;
1505
1514
  var DetectionEventEmitter = class {
1506
1515
  config;
@@ -1513,6 +1522,13 @@ var DetectionEventEmitter = class {
1513
1522
  * fired: the latent "0 `left` events in 24h" bug).
1514
1523
  */
1515
1524
  lastKnownTracks = /* @__PURE__ */ new Map();
1525
+ /**
1526
+ * Track ids that have already been VALUED — either by a crop-bearing
1527
+ * transition/zone event or by a synthetic appearance event. Ensures a track
1528
+ * gets at most ONE appearance and never a redundant appearance on top of a
1529
+ * real crop-bearing event (Requirement 1).
1530
+ */
1531
+ valuedTracks = /* @__PURE__ */ new Set();
1516
1532
  constructor(config = {}) {
1517
1533
  this.config = {
1518
1534
  ...DEFAULT_EVENT_EMITTER_CONFIG,
@@ -1538,6 +1554,7 @@ var DetectionEventEmitter = class {
1538
1554
  if ((now - (this.lastEmitted.get(cooldownKey) ?? 0)) / 1e3 < this.config.cooldownSec) continue;
1539
1555
  this.previousStates.set(state.trackId, state.state);
1540
1556
  this.lastEmitted.set(cooldownKey, now);
1557
+ if (trackMap.has(state.trackId)) this.valuedTracks.add(state.trackId);
1541
1558
  events.push({
1542
1559
  id: `evt-${++eventIdCounter}`,
1543
1560
  type: eventType,
@@ -1557,6 +1574,7 @@ var DetectionEventEmitter = class {
1557
1574
  const track = trackMap.get(ze.trackId);
1558
1575
  if (!track || track.trackAge < this.config.minTrackAge) continue;
1559
1576
  const state = stateMap.get(ze.trackId);
1577
+ this.valuedTracks.add(ze.trackId);
1560
1578
  events.push({
1561
1579
  id: `evt-${++eventIdCounter}`,
1562
1580
  type: eventType,
@@ -1575,9 +1593,29 @@ var DetectionEventEmitter = class {
1575
1593
  trackPath: [...track.path]
1576
1594
  });
1577
1595
  }
1596
+ if (this.config.emitAppearance) for (const track of tracks) {
1597
+ if (this.valuedTracks.has(track.trackId)) continue;
1598
+ if (track.trackAge < this.config.minTrackAge) continue;
1599
+ const state = stateMap.get(track.trackId);
1600
+ if (!state) continue;
1601
+ if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
1602
+ this.valuedTracks.add(track.trackId);
1603
+ events.push({
1604
+ id: `evt-${++eventIdCounter}`,
1605
+ type: APPEARANCE_EVENT_TYPE,
1606
+ timestamp: now,
1607
+ deviceId,
1608
+ detection: track,
1609
+ classifications,
1610
+ objectState: state,
1611
+ zoneEvents: [],
1612
+ trackPath: [...track.path]
1613
+ });
1614
+ }
1578
1615
  for (const state of states) if (state.state === "leaving") {
1579
1616
  this.previousStates.delete(state.trackId);
1580
1617
  this.lastKnownTracks.delete(state.trackId);
1618
+ this.valuedTracks.delete(state.trackId);
1581
1619
  }
1582
1620
  return events;
1583
1621
  }
@@ -1585,6 +1623,7 @@ var DetectionEventEmitter = class {
1585
1623
  this.previousStates.clear();
1586
1624
  this.lastEmitted.clear();
1587
1625
  this.lastKnownTracks.clear();
1626
+ this.valuedTracks.clear();
1588
1627
  }
1589
1628
  };
1590
1629
  //#endregion
@@ -1947,9 +1986,9 @@ var FrameProcessor = class {
1947
1986
  } : {}
1948
1987
  };
1949
1988
  });
1950
- const objectEvents = rawEvents.filter((e) => e.detection.trackId).map((e) => {
1989
+ const toObjectEvent = (e, forcedState) => {
1951
1990
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
1952
- const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1991
+ const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1953
1992
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
1954
1993
  const label = td ? resolveDetectionLabel({
1955
1994
  className: td.class,
@@ -1978,7 +2017,10 @@ var FrameProcessor = class {
1978
2017
  frameWidth,
1979
2018
  frameHeight
1980
2019
  };
1981
- });
2020
+ };
2021
+ const withTrackId = rawEvents.filter((e) => e.detection.trackId);
2022
+ const objectEvents = withTrackId.filter((e) => e.type !== "object.detected").map((e) => toObjectEvent(e));
2023
+ const appearanceEvents = withTrackId.filter((e) => e.type === "object.detected").map((e) => toObjectEvent(e, "entered"));
1982
2024
  return {
1983
2025
  deviceId: this.deviceId,
1984
2026
  timestamp,
@@ -1986,6 +2028,7 @@ var FrameProcessor = class {
1986
2028
  frameHeight,
1987
2029
  tracked,
1988
2030
  objectEvents,
2031
+ appearanceEvents,
1989
2032
  rawTrackedDetections: trackedDetections,
1990
2033
  stationaryConfirmed: gate.confirmed,
1991
2034
  stationaryWoken: gate.wokenEntryIds
@@ -2071,29 +2114,120 @@ function buildTrackLifecyclePayload(input) {
2071
2114
  ...hasMedia ? { media } : {}
2072
2115
  };
2073
2116
  }
2117
+ /**
2118
+ * True when `bbox` sits fully inside the frame — no side within the tolerance
2119
+ * band of any border. Degenerate/unknown frame dims (≤ 0) return true so the
2120
+ * gate stays neutral (falls back to pure confidence) rather than demoting every
2121
+ * frame of a dims-less source.
2122
+ */
2123
+ function isEdgeClear(input) {
2124
+ const { bbox, frameWidth, frameHeight } = input;
2125
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
2126
+ const tolerance = input.tolerance ?? .01;
2127
+ const tolX = tolerance * frameWidth;
2128
+ const tolY = tolerance * frameHeight;
2129
+ const left = bbox.x;
2130
+ const top = bbox.y;
2131
+ const right = bbox.x + bbox.w;
2132
+ const bottom = bbox.y + bbox.h;
2133
+ if (left <= tolX) return false;
2134
+ if (top <= tolY) return false;
2135
+ if (right >= frameWidth - tolX) return false;
2136
+ if (bottom >= frameHeight - tolY) return false;
2137
+ return true;
2138
+ }
2139
+ /**
2140
+ * Edge-aware "is `candidate` a new best over `current`?" comparator.
2141
+ *
2142
+ * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
2143
+ * clipped one regardless of confidence). WITHIN the same tier, a strictly-higher
2144
+ * confidence past the `hysteresis` margin wins. The tier upgrade
2145
+ * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2146
+ *
2147
+ * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2148
+ * not here, so this stays a pure value comparison.
2149
+ */
2150
+ function isEdgeAwareNewBest(current, candidate, hysteresis) {
2151
+ if (candidate.edgeClear && !current.edgeClear) return true;
2152
+ if (!candidate.edgeClear && current.edgeClear) return false;
2153
+ return candidate.confidence > current.confidence + hysteresis;
2154
+ }
2074
2155
  //#endregion
2075
2156
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
2157
+ /**
2158
+ * `BestDetectionTracker` — the SINGLE canonical policy for "the best detection
2159
+ * per track" (highest detector confidence per `trackId`).
2160
+ *
2161
+ * Post-analysis derives three per-track "best" artefacts, all keyed by
2162
+ * trackId + confidence, that used to each re-implement the same ranking inline:
2163
+ * 1. the best FRAME (a boxed `thumbnail`) — `index.ts`,
2164
+ * 2. the best FACE crop + its arcface embedding — `face-recognizer.ts`,
2165
+ * 3. the best CLIP object embedding — `store/object-embedding-store.ts`.
2166
+ *
2167
+ * Consumers 1 & 2 now share THIS one implementation so a track's best frame and
2168
+ * its best face crop derive from the same confidence ranking (and, when the peak
2169
+ * frame carries a detected face, the same frame). Consumer 3 (CLIP) is a
2170
+ * DOCUMENTED SEAM: its `upsertIfBetter` is persistence-backed (its best survives
2171
+ * an addon restart, which this in-memory tracker deliberately does not), so it
2172
+ * keeps its own store-side "if better" gate rather than reading this tracker.
2173
+ *
2174
+ * The FACE seam: the best FRAME is ranked over ALL tracked detections, while the
2175
+ * best FACE can only be captured on a frame that actually produced a face
2176
+ * embedding — so the two legitimately diverge when the peak-confidence frame has
2177
+ * no detected face. Each consumer therefore keeps its OWN payload (crop /
2178
+ * embedding / boxed frame); only the ranking DECISION is unified here.
2179
+ */
2076
2180
  var BestDetectionTracker = class {
2077
2181
  hysteresis;
2078
2182
  minGapMs;
2079
2183
  best = /* @__PURE__ */ new Map();
2184
+ /** Held peak's edge-clear tier, kept PARALLEL to `best` so `peak()`'s
2185
+ * `{ confidence, atMs }` shape (a public contract) stays unchanged. Absent =
2186
+ * the edge tier is not in play for the track (treated as clear → the legacy
2187
+ * pure-confidence policy). */
2188
+ edgeClear = /* @__PURE__ */ new Map();
2080
2189
  constructor(options = {}) {
2081
2190
  this.hysteresis = options.hysteresis ?? 0;
2082
2191
  this.minGapMs = options.minGapMs ?? 0;
2083
2192
  }
2084
2193
  /**
2085
2194
  * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
2086
- * Returns true when it becomes the track's new best — the first sighting, or a
2087
- * confidence that beats the held peak by more than `hysteresis` AND respects
2088
- * `minGapMs`. On acceptance the held peak is advanced to this observation.
2195
+ * Returns true when it becomes the track's new best.
2196
+ *
2197
+ * When `edgeClear` is supplied, an EDGE-AWARE policy applies: an edge-clear
2198
+ * frame (whole subject in view) ALWAYS outranks an edge-touching one (a
2199
+ * clipped, partial subject); the tier upgrade bypasses hysteresis + `minGapMs`
2200
+ * so the first clear frame is always taken. WITHIN the same tier — and when
2201
+ * `edgeClear` is omitted (legacy callers: the face + object-embedding paths) —
2202
+ * the classic policy holds: a confidence past the `hysteresis` margin that also
2203
+ * respects `minGapMs` wins. On acceptance the held peak advances.
2089
2204
  */
2090
- observe(trackId, confidence, timestamp) {
2205
+ observe(trackId, confidence, timestamp, edgeClear) {
2091
2206
  const cur = this.best.get(trackId);
2092
- const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
2093
- if (isNewBest) this.best.set(trackId, {
2207
+ if (cur === void 0) {
2208
+ this.best.set(trackId, {
2209
+ confidence,
2210
+ atMs: timestamp
2211
+ });
2212
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2213
+ return true;
2214
+ }
2215
+ const curClear = this.edgeClear.get(trackId) ?? true;
2216
+ const candClear = edgeClear ?? true;
2217
+ const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2218
+ confidence: cur.confidence,
2219
+ edgeClear: curClear
2220
+ }, {
2094
2221
  confidence,
2095
- atMs: timestamp
2096
- });
2222
+ edgeClear: candClear
2223
+ }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2224
+ if (isNewBest) {
2225
+ this.best.set(trackId, {
2226
+ confidence,
2227
+ atMs: timestamp
2228
+ });
2229
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2230
+ }
2097
2231
  return isNewBest;
2098
2232
  }
2099
2233
  /** The held peak for a track (undefined if never observed). */
@@ -2103,9 +2237,11 @@ var BestDetectionTracker = class {
2103
2237
  /** Drop a track's peak (call at track end). */
2104
2238
  delete(trackId) {
2105
2239
  this.best.delete(trackId);
2240
+ this.edgeClear.delete(trackId);
2106
2241
  }
2107
2242
  clear() {
2108
2243
  this.best.clear();
2244
+ this.edgeClear.clear();
2109
2245
  }
2110
2246
  };
2111
2247
  //#endregion
@@ -2233,16 +2369,23 @@ function entryToView(e) {
2233
2369
  ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2234
2370
  };
2235
2371
  }
2236
- /** IoU at/above which a detection is "the same parked object" → suppress its
2237
- * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2372
+ /** IoU at/above which a detection is unambiguously "the same parked object,
2373
+ * unmoved" → suppress its spawn and refresh `lastConfirmedAt`. Decision #2. */
2238
2374
  var SUPPRESS_IOU = .6;
2239
- /** Centroid move (fraction of the frame diagonal) beyond which a near
2240
- * same-class detection means the parked object actually MOVED wake. */
2241
- var WAKE_MOVE_FRAC = .08;
2242
- /** How near (fraction of frame diagonal) a same-class detection's centroid must
2243
- * be to an entry to be considered "this entry's object" when testing for a
2244
- * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2245
- var WAKE_SEARCH_FRAC = .5;
2375
+ /**
2376
+ * Minimum IoU for a same-class detection to be ASSOCIATED with an entry i.e.
2377
+ * to be considered THIS parked object's own (possibly jittered/shifted) box
2378
+ * rather than a DIFFERENT vehicle. Below this floor a detection can neither
2379
+ * confirm nor wake the entry (it is a different object).
2380
+ *
2381
+ * This overlap gate is the fix for the 617 parking-lot flood: the previous
2382
+ * wake test used a centroid-distance radius of half the frame diagonal, so any
2383
+ * other vehicle merely PRESENT in the lot (a car driving through, or a second
2384
+ * car parked ~120px away) satisfied "near + moved" and retired the parked
2385
+ * entry — the parked car then re-spawned a fresh track and re-flooded, over and
2386
+ * over. Requiring real box overlap means only the entry's OWN box can wake it.
2387
+ */
2388
+ var WAKE_ASSOC_IOU = .1;
2246
2389
  /**
2247
2390
  * Look-back window over which a track must have stayed put to be PROMOTED. A
2248
2391
  * car that drives in then parks has a large whole-life displacement but a tiny
@@ -2253,8 +2396,7 @@ var WAKE_SEARCH_FRAC = .5;
2253
2396
  var PROMOTION_WINDOW_MS = 3e4;
2254
2397
  var DEFAULT_MATCH_CONFIG = {
2255
2398
  suppressIou: SUPPRESS_IOU,
2256
- wakeMoveFrac: WAKE_MOVE_FRAC,
2257
- wakeSearchFrac: WAKE_SEARCH_FRAC
2399
+ wakeAssocIou: WAKE_ASSOC_IOU
2258
2400
  };
2259
2401
  //#endregion
2260
2402
  //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
@@ -2301,36 +2443,45 @@ function centroid(b) {
2301
2443
  y: b.y + b.h / 2
2302
2444
  };
2303
2445
  }
2304
- function diagonalOf(width, height) {
2305
- return Math.hypot(width, height);
2446
+ /** Is point `p` inside box `b`? Used to distinguish box JITTER (centre stays
2447
+ * inside the parked box → suppress) from the object DEPARTING (centre slides
2448
+ * out while the box still overlaps → wake). */
2449
+ function centroidInside(b, p) {
2450
+ return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
2306
2451
  }
2307
2452
  /**
2308
2453
  * Decide, per stationary entry, whether the current frame confirms it, wakes
2309
2454
  * it, or misses it (no matching detection — leave it for the TTL sweep).
2310
2455
  *
2311
- * Per entry, over same-class detections:
2312
- * - best IoU `suppressIou` SUPPRESS the best-overlap detection (it is the
2313
- * parked object, unmoved) and mark the entry confirmed.
2314
- * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2315
- * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2316
- * object slid out of its parked box). The detection is NOT suppressed, so it
2317
- * spawns a fresh moving track.
2318
- * - else MISS (occlusion / brief absence): neither suppress nor wake.
2456
+ * Matching is ASSOCIATION-GATED by box overlap so an entry can only ever be
2457
+ * confirmed or woken by ITS OWN object never by a different same-class
2458
+ * vehicle merely present elsewhere in the frame (the 617 parking-lot flood:
2459
+ * passing/neighbouring cars used to retire parked entries, forcing endless
2460
+ * re-spawns). Per entry, over the best-overlapping same-class detection:
2461
+ *
2462
+ * - best IoU `suppressIou` → SUPPRESS (the parked object, near-identical box)
2463
+ * and mark the entry confirmed.
2464
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid still INSIDE the parked
2465
+ * box → SUPPRESS (detector box jitter — the box shrank/grew around the same
2466
+ * centre; still the parked object, must not spawn a duplicate track).
2467
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
2468
+ * → WAKE (the object's box slid off its spot → it is departing). Not
2469
+ * suppressed, so it spawns a fresh moving track.
2470
+ * - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
2471
+ * different object, or the parked object is momentarily undetected). Neither
2472
+ * suppress nor wake; the TTL sweep retires it only if the absence persists.
2319
2473
  *
2320
2474
  * A detection can suppress at most one spawn even if it overlaps two entries
2321
2475
  * (`suppressedIndices` is a set).
2322
2476
  */
2323
2477
  function partitionDetectionsAgainstRegistry(input) {
2324
- const { entries, detections, referenceDiagonalPx, config } = input;
2478
+ const { entries, detections, config } = input;
2325
2479
  const suppressed = /* @__PURE__ */ new Set();
2326
2480
  const confirmed = [];
2327
2481
  const woken = [];
2328
- const diag = referenceDiagonalPx;
2329
2482
  for (const entry of entries) {
2330
- const ec = centroid(entry.bbox);
2331
2483
  let bestIou = 0;
2332
2484
  let bestIdx = -1;
2333
- let wakeCandidate = false;
2334
2485
  for (let di = 0; di < detections.length; di++) {
2335
2486
  const det = detections[di];
2336
2487
  if (det.className !== entry.className) continue;
@@ -2339,20 +2490,17 @@ function partitionDetectionsAgainstRegistry(input) {
2339
2490
  bestIou = o;
2340
2491
  bestIdx = di;
2341
2492
  }
2342
- if (diag > 0) {
2343
- const dc = centroid(det.bbox);
2344
- const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2345
- if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2346
- }
2347
2493
  }
2348
- if (bestIou >= config.suppressIou && bestIdx >= 0) {
2494
+ if (bestIdx < 0 || bestIou < config.wakeAssocIou) continue;
2495
+ const detCentroid = centroid(detections[bestIdx].bbox);
2496
+ if (bestIou >= config.suppressIou || centroidInside(entry.bbox, detCentroid)) {
2349
2497
  suppressed.add(bestIdx);
2350
2498
  confirmed.push({
2351
2499
  entryId: entry.id,
2352
2500
  className: entry.className,
2353
2501
  bbox: { ...entry.bbox }
2354
2502
  });
2355
- } else if (wakeCandidate) woken.push(entry.id);
2503
+ } else woken.push(entry.id);
2356
2504
  }
2357
2505
  return {
2358
2506
  suppressedIndices: suppressed,
@@ -2529,7 +2677,6 @@ var StationaryObjectRegistry = class {
2529
2677
  return partitionDetectionsAgainstRegistry({
2530
2678
  entries,
2531
2679
  detections: input.detections,
2532
- referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2533
2680
  config: this.matchConfig
2534
2681
  });
2535
2682
  }
@@ -4712,10 +4859,21 @@ var EventStore = class {
4712
4859
  }
4713
4860
  /**
4714
4861
  * The track's highest-confidence object event, its bbox area (as a fraction of
4715
- * frame area), and that event's id — the SHARED per-track ranking already used
4716
- * for the best frame, read back from the persisted object events (index
4862
+ * frame area), and its representative event id — the SHARED per-track ranking
4863
+ * used for the best frame, read back from the persisted object events (index
4717
4864
  * `idx_object_track`). Returns zeros + undefined id when the track has none.
4718
4865
  * Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
4866
+ *
4867
+ * `peakConfidence` + `peakBboxAreaFrac` always track the TRUE confidence peak
4868
+ * (over every crop-bearing row) — importance reads them.
4869
+ *
4870
+ * `bestEventId` treats the synthetic APPEARANCE / entry event (state
4871
+ * `'entered'`, Requirement 1) as a FLOOR: it represents the track only when no
4872
+ * genuine ACTIVITY event (any other state — a zone / stationary / moving
4873
+ * moment) exists. So a degenerate passer-by whose only crop-bearing event is
4874
+ * its appearance still gets a representative id, while a track that later
4875
+ * produced a real activity event surfaces THAT as its best. Within a tier the
4876
+ * highest confidence wins.
4719
4877
  */
4720
4878
  async peakForTrack(trackId) {
4721
4879
  const rows = await this.store.query.query({
@@ -4723,8 +4881,11 @@ var EventStore = class {
4723
4881
  filter: { where: { trackId } }
4724
4882
  });
4725
4883
  let bestConf = -1;
4726
- let bestEventId;
4727
4884
  let peakBboxAreaFrac = 0;
4885
+ let activityConf = -1;
4886
+ let activityId;
4887
+ let entryConf = -1;
4888
+ let entryId;
4728
4889
  for (const row of rows) {
4729
4890
  const bbox = row.data["bbox"];
4730
4891
  if (bbox !== null && typeof bbox === "object") {
@@ -4733,15 +4894,24 @@ var EventStore = class {
4733
4894
  if (bw <= 0 || bh <= 0) continue;
4734
4895
  }
4735
4896
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
4736
- if (conf <= bestConf) continue;
4737
- bestConf = conf;
4738
- bestEventId = row.id;
4739
- peakBboxAreaFrac = bboxAreaFrac(row.data);
4897
+ if (conf > bestConf) {
4898
+ bestConf = conf;
4899
+ peakBboxAreaFrac = bboxAreaFrac(row.data);
4900
+ }
4901
+ if (row.data["state"] === "entered") {
4902
+ if (conf > entryConf) {
4903
+ entryConf = conf;
4904
+ entryId = row.id;
4905
+ }
4906
+ } else if (conf > activityConf) {
4907
+ activityConf = conf;
4908
+ activityId = row.id;
4909
+ }
4740
4910
  }
4741
4911
  return {
4742
4912
  peakConfidence: bestConf < 0 ? 0 : bestConf,
4743
4913
  peakBboxAreaFrac,
4744
- bestEventId
4914
+ bestEventId: activityId ?? entryId
4745
4915
  };
4746
4916
  }
4747
4917
  /**
@@ -10474,10 +10644,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
10474
10644
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
10475
10645
  * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
10476
10646
  * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
10477
- * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10647
+ * `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10648
+ *
10649
+ * `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
10650
+ * now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
10651
+ * tracks (no object event → no `crop`) it is the ONLY subject-centered media —
10652
+ * without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
10653
+ * which is how 65% of the timeline tiles rendered as whole scenes. It sits
10654
+ * right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
10478
10655
  */
10479
10656
  var CLEAN_MEDIA_KINDS = [
10480
10657
  "crop",
10658
+ "thumbnail",
10481
10659
  "fullFrame",
10482
10660
  "keyFrame"
10483
10661
  ];
@@ -10718,7 +10896,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10718
10896
  let storage = this.ctx.kernel.storage;
10719
10897
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
10720
10898
  if (mediaRoot) {
10721
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-qtD7WAqr.js"));
10899
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-K1C_KC3d.js"));
10722
10900
  storage = new FilesystemStorageProvider(mediaRoot);
10723
10901
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
10724
10902
  }
@@ -11638,7 +11816,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11638
11816
  else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
11639
11817
  }
11640
11818
  }
11641
- if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
11819
+ if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
11642
11820
  const byState = {};
11643
11821
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
11644
11822
  log.info("frame processed", { meta: {
@@ -11648,10 +11826,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11648
11826
  newTracks: newTrackCount,
11649
11827
  lostTracks: lostTrackCount,
11650
11828
  objectEvents: result.objectEvents.length,
11829
+ appearanceEvents: result.appearanceEvents.length,
11651
11830
  byState
11652
11831
  } });
11653
11832
  }
11654
- await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
11833
+ await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
11655
11834
  const objectEmbeddingBests = [];
11656
11835
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
11657
11836
  if (!isClipObjectEmbedding(t)) continue;
@@ -11669,8 +11848,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11669
11848
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
11670
11849
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
11671
11850
  if (this.eventMediaDispatcher && frameHandle) {
11672
- const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
11673
- const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
11851
+ const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
11852
+ const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
11853
+ const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
11674
11854
  const childCrops = childCropsByEvent.get(e.id);
11675
11855
  return {
11676
11856
  eventId: e.id,
@@ -12224,7 +12404,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12224
12404
  movementThreshold: media.snapshotMovementThreshold,
12225
12405
  maxIdleMs: media.snapshotMaxIdleMs
12226
12406
  }).capture;
12227
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
12407
+ const edgeClear = isEdgeClear({
12408
+ bbox: t.bbox,
12409
+ frameWidth,
12410
+ frameHeight
12411
+ });
12412
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
12228
12413
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
12229
12414
  const plan = planPeriodicMedia({
12230
12415
  saveThumbnails: media.saveThumbnails,
@@ -1,4 +1,4 @@
1
- import { C as EventCategory, S as string, _ as createEvent, a as cosineSimilarity, b as number, d as plateGalleryCapability, f as videoclipsCapability, g as DeviceType, h as BaseAddon, i as audioMetricsCapability, l as nodePin, m as errMsg, n as EVENT_PAD_MS, p as zoneAnalyticsCapability, r as addonWidgetsSourceCapability, s as faceGalleryCapability, t as EVENT_KIND_BY_CAP, u as pipelineAnalyticsCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-D1cY_vlY.mjs";
1
+ import { C as EventCategory, S as string, _ as createEvent, a as cosineSimilarity, b as number, d as plateGalleryCapability, f as videoclipsCapability, g as DeviceType, h as BaseAddon, i as audioMetricsCapability, l as nodePin, m as errMsg, n as EVENT_PAD_MS, p as zoneAnalyticsCapability, r as addonWidgetsSourceCapability, s as faceGalleryCapability, t as EVENT_KIND_BY_CAP, u as pipelineAnalyticsCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CyyCe4TK.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -1472,6 +1472,7 @@ var DEFAULT_EVENT_EMITTER_CONFIG = {
1472
1472
  minTrackAge: 3,
1473
1473
  minTrackAgeMs: 0,
1474
1474
  cooldownSec: 5,
1475
+ emitAppearance: true,
1475
1476
  enabledTypes: [
1476
1477
  "object.entering",
1477
1478
  "object.leaving",
@@ -1496,6 +1497,14 @@ var ZONE_TYPE_TO_EVENT = {
1496
1497
  "zone-loiter": "zone.enter",
1497
1498
  "tripwire-cross": "tripwire.cross"
1498
1499
  };
1500
+ /**
1501
+ * DetectionEventType used for the synthetic per-track APPEARANCE event
1502
+ * (Requirement 1). `object.detected` is otherwise unused by the emitter (it maps
1503
+ * from no state and no zone type), so repurposing it as the appearance marker
1504
+ * needs no new event-type vocabulary. The addon routes these into their own
1505
+ * media/persistence path and keeps them OFF the notification bus.
1506
+ */
1507
+ var APPEARANCE_EVENT_TYPE = "object.detected";
1499
1508
  var eventIdCounter = 0;
1500
1509
  var DetectionEventEmitter = class {
1501
1510
  config;
@@ -1508,6 +1517,13 @@ var DetectionEventEmitter = class {
1508
1517
  * fired: the latent "0 `left` events in 24h" bug).
1509
1518
  */
1510
1519
  lastKnownTracks = /* @__PURE__ */ new Map();
1520
+ /**
1521
+ * Track ids that have already been VALUED — either by a crop-bearing
1522
+ * transition/zone event or by a synthetic appearance event. Ensures a track
1523
+ * gets at most ONE appearance and never a redundant appearance on top of a
1524
+ * real crop-bearing event (Requirement 1).
1525
+ */
1526
+ valuedTracks = /* @__PURE__ */ new Set();
1511
1527
  constructor(config = {}) {
1512
1528
  this.config = {
1513
1529
  ...DEFAULT_EVENT_EMITTER_CONFIG,
@@ -1533,6 +1549,7 @@ var DetectionEventEmitter = class {
1533
1549
  if ((now - (this.lastEmitted.get(cooldownKey) ?? 0)) / 1e3 < this.config.cooldownSec) continue;
1534
1550
  this.previousStates.set(state.trackId, state.state);
1535
1551
  this.lastEmitted.set(cooldownKey, now);
1552
+ if (trackMap.has(state.trackId)) this.valuedTracks.add(state.trackId);
1536
1553
  events.push({
1537
1554
  id: `evt-${++eventIdCounter}`,
1538
1555
  type: eventType,
@@ -1552,6 +1569,7 @@ var DetectionEventEmitter = class {
1552
1569
  const track = trackMap.get(ze.trackId);
1553
1570
  if (!track || track.trackAge < this.config.minTrackAge) continue;
1554
1571
  const state = stateMap.get(ze.trackId);
1572
+ this.valuedTracks.add(ze.trackId);
1555
1573
  events.push({
1556
1574
  id: `evt-${++eventIdCounter}`,
1557
1575
  type: eventType,
@@ -1570,9 +1588,29 @@ var DetectionEventEmitter = class {
1570
1588
  trackPath: [...track.path]
1571
1589
  });
1572
1590
  }
1591
+ if (this.config.emitAppearance) for (const track of tracks) {
1592
+ if (this.valuedTracks.has(track.trackId)) continue;
1593
+ if (track.trackAge < this.config.minTrackAge) continue;
1594
+ const state = stateMap.get(track.trackId);
1595
+ if (!state) continue;
1596
+ if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
1597
+ this.valuedTracks.add(track.trackId);
1598
+ events.push({
1599
+ id: `evt-${++eventIdCounter}`,
1600
+ type: APPEARANCE_EVENT_TYPE,
1601
+ timestamp: now,
1602
+ deviceId,
1603
+ detection: track,
1604
+ classifications,
1605
+ objectState: state,
1606
+ zoneEvents: [],
1607
+ trackPath: [...track.path]
1608
+ });
1609
+ }
1573
1610
  for (const state of states) if (state.state === "leaving") {
1574
1611
  this.previousStates.delete(state.trackId);
1575
1612
  this.lastKnownTracks.delete(state.trackId);
1613
+ this.valuedTracks.delete(state.trackId);
1576
1614
  }
1577
1615
  return events;
1578
1616
  }
@@ -1580,6 +1618,7 @@ var DetectionEventEmitter = class {
1580
1618
  this.previousStates.clear();
1581
1619
  this.lastEmitted.clear();
1582
1620
  this.lastKnownTracks.clear();
1621
+ this.valuedTracks.clear();
1583
1622
  }
1584
1623
  };
1585
1624
  //#endregion
@@ -1942,9 +1981,9 @@ var FrameProcessor = class {
1942
1981
  } : {}
1943
1982
  };
1944
1983
  });
1945
- const objectEvents = rawEvents.filter((e) => e.detection.trackId).map((e) => {
1984
+ const toObjectEvent = (e, forcedState) => {
1946
1985
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
1947
- const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1986
+ const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1948
1987
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
1949
1988
  const label = td ? resolveDetectionLabel({
1950
1989
  className: td.class,
@@ -1973,7 +2012,10 @@ var FrameProcessor = class {
1973
2012
  frameWidth,
1974
2013
  frameHeight
1975
2014
  };
1976
- });
2015
+ };
2016
+ const withTrackId = rawEvents.filter((e) => e.detection.trackId);
2017
+ const objectEvents = withTrackId.filter((e) => e.type !== "object.detected").map((e) => toObjectEvent(e));
2018
+ const appearanceEvents = withTrackId.filter((e) => e.type === "object.detected").map((e) => toObjectEvent(e, "entered"));
1977
2019
  return {
1978
2020
  deviceId: this.deviceId,
1979
2021
  timestamp,
@@ -1981,6 +2023,7 @@ var FrameProcessor = class {
1981
2023
  frameHeight,
1982
2024
  tracked,
1983
2025
  objectEvents,
2026
+ appearanceEvents,
1984
2027
  rawTrackedDetections: trackedDetections,
1985
2028
  stationaryConfirmed: gate.confirmed,
1986
2029
  stationaryWoken: gate.wokenEntryIds
@@ -2066,29 +2109,120 @@ function buildTrackLifecyclePayload(input) {
2066
2109
  ...hasMedia ? { media } : {}
2067
2110
  };
2068
2111
  }
2112
+ /**
2113
+ * True when `bbox` sits fully inside the frame — no side within the tolerance
2114
+ * band of any border. Degenerate/unknown frame dims (≤ 0) return true so the
2115
+ * gate stays neutral (falls back to pure confidence) rather than demoting every
2116
+ * frame of a dims-less source.
2117
+ */
2118
+ function isEdgeClear(input) {
2119
+ const { bbox, frameWidth, frameHeight } = input;
2120
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
2121
+ const tolerance = input.tolerance ?? .01;
2122
+ const tolX = tolerance * frameWidth;
2123
+ const tolY = tolerance * frameHeight;
2124
+ const left = bbox.x;
2125
+ const top = bbox.y;
2126
+ const right = bbox.x + bbox.w;
2127
+ const bottom = bbox.y + bbox.h;
2128
+ if (left <= tolX) return false;
2129
+ if (top <= tolY) return false;
2130
+ if (right >= frameWidth - tolX) return false;
2131
+ if (bottom >= frameHeight - tolY) return false;
2132
+ return true;
2133
+ }
2134
+ /**
2135
+ * Edge-aware "is `candidate` a new best over `current`?" comparator.
2136
+ *
2137
+ * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
2138
+ * clipped one regardless of confidence). WITHIN the same tier, a strictly-higher
2139
+ * confidence past the `hysteresis` margin wins. The tier upgrade
2140
+ * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2141
+ *
2142
+ * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2143
+ * not here, so this stays a pure value comparison.
2144
+ */
2145
+ function isEdgeAwareNewBest(current, candidate, hysteresis) {
2146
+ if (candidate.edgeClear && !current.edgeClear) return true;
2147
+ if (!candidate.edgeClear && current.edgeClear) return false;
2148
+ return candidate.confidence > current.confidence + hysteresis;
2149
+ }
2069
2150
  //#endregion
2070
2151
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
2152
+ /**
2153
+ * `BestDetectionTracker` — the SINGLE canonical policy for "the best detection
2154
+ * per track" (highest detector confidence per `trackId`).
2155
+ *
2156
+ * Post-analysis derives three per-track "best" artefacts, all keyed by
2157
+ * trackId + confidence, that used to each re-implement the same ranking inline:
2158
+ * 1. the best FRAME (a boxed `thumbnail`) — `index.ts`,
2159
+ * 2. the best FACE crop + its arcface embedding — `face-recognizer.ts`,
2160
+ * 3. the best CLIP object embedding — `store/object-embedding-store.ts`.
2161
+ *
2162
+ * Consumers 1 & 2 now share THIS one implementation so a track's best frame and
2163
+ * its best face crop derive from the same confidence ranking (and, when the peak
2164
+ * frame carries a detected face, the same frame). Consumer 3 (CLIP) is a
2165
+ * DOCUMENTED SEAM: its `upsertIfBetter` is persistence-backed (its best survives
2166
+ * an addon restart, which this in-memory tracker deliberately does not), so it
2167
+ * keeps its own store-side "if better" gate rather than reading this tracker.
2168
+ *
2169
+ * The FACE seam: the best FRAME is ranked over ALL tracked detections, while the
2170
+ * best FACE can only be captured on a frame that actually produced a face
2171
+ * embedding — so the two legitimately diverge when the peak-confidence frame has
2172
+ * no detected face. Each consumer therefore keeps its OWN payload (crop /
2173
+ * embedding / boxed frame); only the ranking DECISION is unified here.
2174
+ */
2071
2175
  var BestDetectionTracker = class {
2072
2176
  hysteresis;
2073
2177
  minGapMs;
2074
2178
  best = /* @__PURE__ */ new Map();
2179
+ /** Held peak's edge-clear tier, kept PARALLEL to `best` so `peak()`'s
2180
+ * `{ confidence, atMs }` shape (a public contract) stays unchanged. Absent =
2181
+ * the edge tier is not in play for the track (treated as clear → the legacy
2182
+ * pure-confidence policy). */
2183
+ edgeClear = /* @__PURE__ */ new Map();
2075
2184
  constructor(options = {}) {
2076
2185
  this.hysteresis = options.hysteresis ?? 0;
2077
2186
  this.minGapMs = options.minGapMs ?? 0;
2078
2187
  }
2079
2188
  /**
2080
2189
  * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
2081
- * Returns true when it becomes the track's new best — the first sighting, or a
2082
- * confidence that beats the held peak by more than `hysteresis` AND respects
2083
- * `minGapMs`. On acceptance the held peak is advanced to this observation.
2190
+ * Returns true when it becomes the track's new best.
2191
+ *
2192
+ * When `edgeClear` is supplied, an EDGE-AWARE policy applies: an edge-clear
2193
+ * frame (whole subject in view) ALWAYS outranks an edge-touching one (a
2194
+ * clipped, partial subject); the tier upgrade bypasses hysteresis + `minGapMs`
2195
+ * so the first clear frame is always taken. WITHIN the same tier — and when
2196
+ * `edgeClear` is omitted (legacy callers: the face + object-embedding paths) —
2197
+ * the classic policy holds: a confidence past the `hysteresis` margin that also
2198
+ * respects `minGapMs` wins. On acceptance the held peak advances.
2084
2199
  */
2085
- observe(trackId, confidence, timestamp) {
2200
+ observe(trackId, confidence, timestamp, edgeClear) {
2086
2201
  const cur = this.best.get(trackId);
2087
- const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
2088
- if (isNewBest) this.best.set(trackId, {
2202
+ if (cur === void 0) {
2203
+ this.best.set(trackId, {
2204
+ confidence,
2205
+ atMs: timestamp
2206
+ });
2207
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2208
+ return true;
2209
+ }
2210
+ const curClear = this.edgeClear.get(trackId) ?? true;
2211
+ const candClear = edgeClear ?? true;
2212
+ const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2213
+ confidence: cur.confidence,
2214
+ edgeClear: curClear
2215
+ }, {
2089
2216
  confidence,
2090
- atMs: timestamp
2091
- });
2217
+ edgeClear: candClear
2218
+ }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2219
+ if (isNewBest) {
2220
+ this.best.set(trackId, {
2221
+ confidence,
2222
+ atMs: timestamp
2223
+ });
2224
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2225
+ }
2092
2226
  return isNewBest;
2093
2227
  }
2094
2228
  /** The held peak for a track (undefined if never observed). */
@@ -2098,9 +2232,11 @@ var BestDetectionTracker = class {
2098
2232
  /** Drop a track's peak (call at track end). */
2099
2233
  delete(trackId) {
2100
2234
  this.best.delete(trackId);
2235
+ this.edgeClear.delete(trackId);
2101
2236
  }
2102
2237
  clear() {
2103
2238
  this.best.clear();
2239
+ this.edgeClear.clear();
2104
2240
  }
2105
2241
  };
2106
2242
  //#endregion
@@ -2228,16 +2364,23 @@ function entryToView(e) {
2228
2364
  ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2229
2365
  };
2230
2366
  }
2231
- /** IoU at/above which a detection is "the same parked object" → suppress its
2232
- * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2367
+ /** IoU at/above which a detection is unambiguously "the same parked object,
2368
+ * unmoved" → suppress its spawn and refresh `lastConfirmedAt`. Decision #2. */
2233
2369
  var SUPPRESS_IOU = .6;
2234
- /** Centroid move (fraction of the frame diagonal) beyond which a near
2235
- * same-class detection means the parked object actually MOVED wake. */
2236
- var WAKE_MOVE_FRAC = .08;
2237
- /** How near (fraction of frame diagonal) a same-class detection's centroid must
2238
- * be to an entry to be considered "this entry's object" when testing for a
2239
- * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2240
- var WAKE_SEARCH_FRAC = .5;
2370
+ /**
2371
+ * Minimum IoU for a same-class detection to be ASSOCIATED with an entry i.e.
2372
+ * to be considered THIS parked object's own (possibly jittered/shifted) box
2373
+ * rather than a DIFFERENT vehicle. Below this floor a detection can neither
2374
+ * confirm nor wake the entry (it is a different object).
2375
+ *
2376
+ * This overlap gate is the fix for the 617 parking-lot flood: the previous
2377
+ * wake test used a centroid-distance radius of half the frame diagonal, so any
2378
+ * other vehicle merely PRESENT in the lot (a car driving through, or a second
2379
+ * car parked ~120px away) satisfied "near + moved" and retired the parked
2380
+ * entry — the parked car then re-spawned a fresh track and re-flooded, over and
2381
+ * over. Requiring real box overlap means only the entry's OWN box can wake it.
2382
+ */
2383
+ var WAKE_ASSOC_IOU = .1;
2241
2384
  /**
2242
2385
  * Look-back window over which a track must have stayed put to be PROMOTED. A
2243
2386
  * car that drives in then parks has a large whole-life displacement but a tiny
@@ -2248,8 +2391,7 @@ var WAKE_SEARCH_FRAC = .5;
2248
2391
  var PROMOTION_WINDOW_MS = 3e4;
2249
2392
  var DEFAULT_MATCH_CONFIG = {
2250
2393
  suppressIou: SUPPRESS_IOU,
2251
- wakeMoveFrac: WAKE_MOVE_FRAC,
2252
- wakeSearchFrac: WAKE_SEARCH_FRAC
2394
+ wakeAssocIou: WAKE_ASSOC_IOU
2253
2395
  };
2254
2396
  //#endregion
2255
2397
  //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
@@ -2296,36 +2438,45 @@ function centroid(b) {
2296
2438
  y: b.y + b.h / 2
2297
2439
  };
2298
2440
  }
2299
- function diagonalOf(width, height) {
2300
- return Math.hypot(width, height);
2441
+ /** Is point `p` inside box `b`? Used to distinguish box JITTER (centre stays
2442
+ * inside the parked box → suppress) from the object DEPARTING (centre slides
2443
+ * out while the box still overlaps → wake). */
2444
+ function centroidInside(b, p) {
2445
+ return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
2301
2446
  }
2302
2447
  /**
2303
2448
  * Decide, per stationary entry, whether the current frame confirms it, wakes
2304
2449
  * it, or misses it (no matching detection — leave it for the TTL sweep).
2305
2450
  *
2306
- * Per entry, over same-class detections:
2307
- * - best IoU `suppressIou` SUPPRESS the best-overlap detection (it is the
2308
- * parked object, unmoved) and mark the entry confirmed.
2309
- * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2310
- * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2311
- * object slid out of its parked box). The detection is NOT suppressed, so it
2312
- * spawns a fresh moving track.
2313
- * - else MISS (occlusion / brief absence): neither suppress nor wake.
2451
+ * Matching is ASSOCIATION-GATED by box overlap so an entry can only ever be
2452
+ * confirmed or woken by ITS OWN object never by a different same-class
2453
+ * vehicle merely present elsewhere in the frame (the 617 parking-lot flood:
2454
+ * passing/neighbouring cars used to retire parked entries, forcing endless
2455
+ * re-spawns). Per entry, over the best-overlapping same-class detection:
2456
+ *
2457
+ * - best IoU `suppressIou` → SUPPRESS (the parked object, near-identical box)
2458
+ * and mark the entry confirmed.
2459
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid still INSIDE the parked
2460
+ * box → SUPPRESS (detector box jitter — the box shrank/grew around the same
2461
+ * centre; still the parked object, must not spawn a duplicate track).
2462
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
2463
+ * → WAKE (the object's box slid off its spot → it is departing). Not
2464
+ * suppressed, so it spawns a fresh moving track.
2465
+ * - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
2466
+ * different object, or the parked object is momentarily undetected). Neither
2467
+ * suppress nor wake; the TTL sweep retires it only if the absence persists.
2314
2468
  *
2315
2469
  * A detection can suppress at most one spawn even if it overlaps two entries
2316
2470
  * (`suppressedIndices` is a set).
2317
2471
  */
2318
2472
  function partitionDetectionsAgainstRegistry(input) {
2319
- const { entries, detections, referenceDiagonalPx, config } = input;
2473
+ const { entries, detections, config } = input;
2320
2474
  const suppressed = /* @__PURE__ */ new Set();
2321
2475
  const confirmed = [];
2322
2476
  const woken = [];
2323
- const diag = referenceDiagonalPx;
2324
2477
  for (const entry of entries) {
2325
- const ec = centroid(entry.bbox);
2326
2478
  let bestIou = 0;
2327
2479
  let bestIdx = -1;
2328
- let wakeCandidate = false;
2329
2480
  for (let di = 0; di < detections.length; di++) {
2330
2481
  const det = detections[di];
2331
2482
  if (det.className !== entry.className) continue;
@@ -2334,20 +2485,17 @@ function partitionDetectionsAgainstRegistry(input) {
2334
2485
  bestIou = o;
2335
2486
  bestIdx = di;
2336
2487
  }
2337
- if (diag > 0) {
2338
- const dc = centroid(det.bbox);
2339
- const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2340
- if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2341
- }
2342
2488
  }
2343
- if (bestIou >= config.suppressIou && bestIdx >= 0) {
2489
+ if (bestIdx < 0 || bestIou < config.wakeAssocIou) continue;
2490
+ const detCentroid = centroid(detections[bestIdx].bbox);
2491
+ if (bestIou >= config.suppressIou || centroidInside(entry.bbox, detCentroid)) {
2344
2492
  suppressed.add(bestIdx);
2345
2493
  confirmed.push({
2346
2494
  entryId: entry.id,
2347
2495
  className: entry.className,
2348
2496
  bbox: { ...entry.bbox }
2349
2497
  });
2350
- } else if (wakeCandidate) woken.push(entry.id);
2498
+ } else woken.push(entry.id);
2351
2499
  }
2352
2500
  return {
2353
2501
  suppressedIndices: suppressed,
@@ -2524,7 +2672,6 @@ var StationaryObjectRegistry = class {
2524
2672
  return partitionDetectionsAgainstRegistry({
2525
2673
  entries,
2526
2674
  detections: input.detections,
2527
- referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2528
2675
  config: this.matchConfig
2529
2676
  });
2530
2677
  }
@@ -4707,10 +4854,21 @@ var EventStore = class {
4707
4854
  }
4708
4855
  /**
4709
4856
  * The track's highest-confidence object event, its bbox area (as a fraction of
4710
- * frame area), and that event's id — the SHARED per-track ranking already used
4711
- * for the best frame, read back from the persisted object events (index
4857
+ * frame area), and its representative event id — the SHARED per-track ranking
4858
+ * used for the best frame, read back from the persisted object events (index
4712
4859
  * `idx_object_track`). Returns zeros + undefined id when the track has none.
4713
4860
  * Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
4861
+ *
4862
+ * `peakConfidence` + `peakBboxAreaFrac` always track the TRUE confidence peak
4863
+ * (over every crop-bearing row) — importance reads them.
4864
+ *
4865
+ * `bestEventId` treats the synthetic APPEARANCE / entry event (state
4866
+ * `'entered'`, Requirement 1) as a FLOOR: it represents the track only when no
4867
+ * genuine ACTIVITY event (any other state — a zone / stationary / moving
4868
+ * moment) exists. So a degenerate passer-by whose only crop-bearing event is
4869
+ * its appearance still gets a representative id, while a track that later
4870
+ * produced a real activity event surfaces THAT as its best. Within a tier the
4871
+ * highest confidence wins.
4714
4872
  */
4715
4873
  async peakForTrack(trackId) {
4716
4874
  const rows = await this.store.query.query({
@@ -4718,8 +4876,11 @@ var EventStore = class {
4718
4876
  filter: { where: { trackId } }
4719
4877
  });
4720
4878
  let bestConf = -1;
4721
- let bestEventId;
4722
4879
  let peakBboxAreaFrac = 0;
4880
+ let activityConf = -1;
4881
+ let activityId;
4882
+ let entryConf = -1;
4883
+ let entryId;
4723
4884
  for (const row of rows) {
4724
4885
  const bbox = row.data["bbox"];
4725
4886
  if (bbox !== null && typeof bbox === "object") {
@@ -4728,15 +4889,24 @@ var EventStore = class {
4728
4889
  if (bw <= 0 || bh <= 0) continue;
4729
4890
  }
4730
4891
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
4731
- if (conf <= bestConf) continue;
4732
- bestConf = conf;
4733
- bestEventId = row.id;
4734
- peakBboxAreaFrac = bboxAreaFrac(row.data);
4892
+ if (conf > bestConf) {
4893
+ bestConf = conf;
4894
+ peakBboxAreaFrac = bboxAreaFrac(row.data);
4895
+ }
4896
+ if (row.data["state"] === "entered") {
4897
+ if (conf > entryConf) {
4898
+ entryConf = conf;
4899
+ entryId = row.id;
4900
+ }
4901
+ } else if (conf > activityConf) {
4902
+ activityConf = conf;
4903
+ activityId = row.id;
4904
+ }
4735
4905
  }
4736
4906
  return {
4737
4907
  peakConfidence: bestConf < 0 ? 0 : bestConf,
4738
4908
  peakBboxAreaFrac,
4739
- bestEventId
4909
+ bestEventId: activityId ?? entryId
4740
4910
  };
4741
4911
  }
4742
4912
  /**
@@ -10469,10 +10639,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
10469
10639
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
10470
10640
  * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
10471
10641
  * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
10472
- * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10642
+ * `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10643
+ *
10644
+ * `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
10645
+ * now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
10646
+ * tracks (no object event → no `crop`) it is the ONLY subject-centered media —
10647
+ * without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
10648
+ * which is how 65% of the timeline tiles rendered as whole scenes. It sits
10649
+ * right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
10473
10650
  */
10474
10651
  var CLEAN_MEDIA_KINDS = [
10475
10652
  "crop",
10653
+ "thumbnail",
10476
10654
  "fullFrame",
10477
10655
  "keyFrame"
10478
10656
  ];
@@ -11633,7 +11811,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11633
11811
  else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
11634
11812
  }
11635
11813
  }
11636
- if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
11814
+ if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
11637
11815
  const byState = {};
11638
11816
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
11639
11817
  log.info("frame processed", { meta: {
@@ -11643,10 +11821,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11643
11821
  newTracks: newTrackCount,
11644
11822
  lostTracks: lostTrackCount,
11645
11823
  objectEvents: result.objectEvents.length,
11824
+ appearanceEvents: result.appearanceEvents.length,
11646
11825
  byState
11647
11826
  } });
11648
11827
  }
11649
- await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
11828
+ await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
11650
11829
  const objectEmbeddingBests = [];
11651
11830
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
11652
11831
  if (!isClipObjectEmbedding(t)) continue;
@@ -11664,8 +11843,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11664
11843
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
11665
11844
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
11666
11845
  if (this.eventMediaDispatcher && frameHandle) {
11667
- const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
11668
- const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
11846
+ const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
11847
+ const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
11848
+ const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
11669
11849
  const childCrops = childCropsByEvent.get(e.id);
11670
11850
  return {
11671
11851
  eventId: e.id,
@@ -12219,7 +12399,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12219
12399
  movementThreshold: media.snapshotMovementThreshold,
12220
12400
  maxIdleMs: media.snapshotMaxIdleMs
12221
12401
  }).capture;
12222
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
12402
+ const edgeClear = isEdgeClear({
12403
+ bbox: t.bbox,
12404
+ frameWidth,
12405
+ frameHeight
12406
+ });
12407
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
12223
12408
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
12224
12409
  const plan = planPeriodicMedia({
12225
12410
  saveThumbnails: media.saveThumbnails,
@@ -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-jTZY8fyP.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-aNR1K97R.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.31",
3
+ "version": "1.1.32",
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",