@camstack/addon-post-analysis 1.1.28 → 1.1.30
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.
- package/dist/{dist-Bnb58pyL.js → dist-U51kCBdm.js} +4740 -4644
- package/dist/{dist-Blpsv-M0.mjs → dist-yPsKFcJL.mjs} +4741 -4645
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-Cvhwrf43.js → node-BFF5_uIc.js} +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZZpqsjV.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DeoVBjEB.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-D-KSUwyU.mjs → hostInit-Bd2d1PYo.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1734 -205
- package/dist/pipeline-analytics/index.mjs +1733 -205
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -2
|
@@ -2,11 +2,10 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-U51kCBdm.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
9
|
-
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
10
9
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
11
10
|
var SOURCE = "analytics";
|
|
12
11
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -151,6 +150,17 @@ var WEIGHT_IDENTITY = .05;
|
|
|
151
150
|
var DWELL_FULL_MS = 6e4;
|
|
152
151
|
/** Peak bbox area (as a fraction of frame area) that saturates the size term. */
|
|
153
152
|
var SIZE_FULL = .15;
|
|
153
|
+
/** A track whose net centroid displacement stays below this fraction of the
|
|
154
|
+
* frame diagonal over its whole life counts as stationary on the net signal. */
|
|
155
|
+
var STATIC_DISPLACEMENT_FRAC = .02;
|
|
156
|
+
/** ...and whose entire centroid path fits within this fraction of the frame
|
|
157
|
+
* diagonal counts as stationary on the span signal. Both must hold. */
|
|
158
|
+
var STATIC_SPAN_FRAC = .04;
|
|
159
|
+
/** Importance multiplier applied to a stationary track that has NO resolved
|
|
160
|
+
* identity — strongly demotes "ghost" detections on static objects (a pile of
|
|
161
|
+
* clothes read as a person, a kitchen object read as an animal) below the
|
|
162
|
+
* key-event threshold. Identified tracks (face/plate) are never suppressed. */
|
|
163
|
+
var STATIC_IMPORTANCE_MULTIPLIER = .1;
|
|
154
164
|
var CLASS_RANK_VEHICLE = .8;
|
|
155
165
|
var CLASS_RANK_ANIMAL = .5;
|
|
156
166
|
var CLASS_RANK_DEFAULT = .25;
|
|
@@ -225,6 +235,11 @@ function computeImportance(input) {
|
|
|
225
235
|
sum += term.value;
|
|
226
236
|
if (term.value > best.value) best = term;
|
|
227
237
|
}
|
|
238
|
+
const identified = input.label !== void 0 && input.label.length > 0;
|
|
239
|
+
if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
|
|
240
|
+
importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
|
|
241
|
+
reason: best.reason
|
|
242
|
+
};
|
|
228
243
|
return {
|
|
229
244
|
importance: clamp01(sum),
|
|
230
245
|
reason: best.reason
|
|
@@ -618,6 +633,20 @@ function normalizePlate(text) {
|
|
|
618
633
|
return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
619
634
|
}
|
|
620
635
|
/**
|
|
636
|
+
* Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
|
|
637
|
+
* gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
|
|
638
|
+
* "@em") that otherwise flood track labels (live-observed on the parking
|
|
639
|
+
* camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
|
|
640
|
+
* alphanumerics and mixes letters AND digits; anything else — or a read below
|
|
641
|
+
* {@link PLATE_MIN_SCORE} — is discarded, not stored.
|
|
642
|
+
*/
|
|
643
|
+
function isPlausiblePlateRead(text, score) {
|
|
644
|
+
if (score < .4) return false;
|
|
645
|
+
const norm = normalizePlate(text);
|
|
646
|
+
if (norm.length < 4) return false;
|
|
647
|
+
return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
621
650
|
* Fold characters that OCR routinely confuses onto a single canonical symbol, so
|
|
622
651
|
* "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
|
|
623
652
|
* comparison only — never stored. Conservative set covering the common Latin
|
|
@@ -1102,7 +1131,7 @@ var MAX_PATH_LENGTH = 300;
|
|
|
1102
1131
|
function clamp(value, min, max) {
|
|
1103
1132
|
return Math.max(min, Math.min(max, value));
|
|
1104
1133
|
}
|
|
1105
|
-
function iou$
|
|
1134
|
+
function iou$2(a, b) {
|
|
1106
1135
|
const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
|
|
1107
1136
|
const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
|
|
1108
1137
|
const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
|
|
@@ -1157,7 +1186,7 @@ var SortTracker = class {
|
|
|
1157
1186
|
*/
|
|
1158
1187
|
looseMatch(track, det) {
|
|
1159
1188
|
if (track.class !== det.class) return false;
|
|
1160
|
-
if (iou$
|
|
1189
|
+
if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
|
|
1161
1190
|
const tc = bboxCentroid(track.bbox);
|
|
1162
1191
|
const dc = bboxCentroid(det.bbox);
|
|
1163
1192
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
@@ -1185,7 +1214,7 @@ var SortTracker = class {
|
|
|
1185
1214
|
for (let di = 0; di < detections.length; di++) {
|
|
1186
1215
|
const det = detections[di];
|
|
1187
1216
|
if (this.config.classGating && track.class !== det.class) continue;
|
|
1188
|
-
const score = iou$
|
|
1217
|
+
const score = iou$2(pbox, det.bbox);
|
|
1189
1218
|
if (score >= this.config.iouThreshold) pairs.push({
|
|
1190
1219
|
track,
|
|
1191
1220
|
detIdx: di,
|
|
@@ -1210,7 +1239,7 @@ var SortTracker = class {
|
|
|
1210
1239
|
rescuePairs.push({
|
|
1211
1240
|
track,
|
|
1212
1241
|
detIdx: di,
|
|
1213
|
-
score: iou$
|
|
1242
|
+
score: iou$2(track.bbox, det.bbox)
|
|
1214
1243
|
});
|
|
1215
1244
|
}
|
|
1216
1245
|
}
|
|
@@ -1275,7 +1304,7 @@ var SortTracker = class {
|
|
|
1275
1304
|
if (!lost.resurrectable) continue;
|
|
1276
1305
|
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
1277
1306
|
if (!this.looseMatch(lost, det)) continue;
|
|
1278
|
-
const score = iou$
|
|
1307
|
+
const score = iou$2(lost.bbox, det.bbox);
|
|
1279
1308
|
if (score > bestScore) {
|
|
1280
1309
|
best = lost;
|
|
1281
1310
|
bestScore = score;
|
|
@@ -1336,6 +1365,16 @@ var SortTracker = class {
|
|
|
1336
1365
|
path: [...t.path]
|
|
1337
1366
|
}));
|
|
1338
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Remove a track from BOTH the live and graveyard sets so it neither emits
|
|
1370
|
+
* nor resurrects. Used when a track is PROMOTED to a stationary-object
|
|
1371
|
+
* registry entry: the registry now owns that parked object, and the tracker
|
|
1372
|
+
* must forget it so the object's detections don't re-spawn a duplicate track.
|
|
1373
|
+
*/
|
|
1374
|
+
dropTrack(trackId) {
|
|
1375
|
+
this.tracks = this.tracks.filter((t) => t.id !== trackId);
|
|
1376
|
+
this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
|
|
1377
|
+
}
|
|
1339
1378
|
getActiveTracks() {
|
|
1340
1379
|
return this.tracks;
|
|
1341
1380
|
}
|
|
@@ -1749,6 +1788,9 @@ var FrameProcessor = class {
|
|
|
1749
1788
|
*/
|
|
1750
1789
|
detectionRules;
|
|
1751
1790
|
zoneEngine = new ZoneEngine();
|
|
1791
|
+
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
1792
|
+
* the addon wires it via {@link setStationaryGate}. */
|
|
1793
|
+
stationaryGate = null;
|
|
1752
1794
|
constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
|
|
1753
1795
|
this.deviceId = deviceId;
|
|
1754
1796
|
this.source = source;
|
|
@@ -1764,6 +1806,16 @@ var FrameProcessor = class {
|
|
|
1764
1806
|
setDetectionRules(rules) {
|
|
1765
1807
|
this.detectionRules = rules;
|
|
1766
1808
|
}
|
|
1809
|
+
/** Wire (or clear) the stationary-object gate. Called by the addon per
|
|
1810
|
+
* device so parked-object suppression applies from the next frame. */
|
|
1811
|
+
setStationaryGate(gate) {
|
|
1812
|
+
this.stationaryGate = gate;
|
|
1813
|
+
}
|
|
1814
|
+
/** Forget a track in the underlying tracker (used when the addon promotes it
|
|
1815
|
+
* to a stationary-object registry entry). */
|
|
1816
|
+
dropTrack(trackId) {
|
|
1817
|
+
this.tracker.dropTrack(trackId);
|
|
1818
|
+
}
|
|
1767
1819
|
process(input) {
|
|
1768
1820
|
const { timestamp, frame } = input;
|
|
1769
1821
|
const frameWidth = frame.width;
|
|
@@ -1842,7 +1894,17 @@ var FrameProcessor = class {
|
|
|
1842
1894
|
}
|
|
1843
1895
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
1844
1896
|
const filteredDetections = passed.map((fd) => fd.detection);
|
|
1845
|
-
const
|
|
1897
|
+
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
1898
|
+
detections: filteredDetections,
|
|
1899
|
+
frameWidth,
|
|
1900
|
+
frameHeight
|
|
1901
|
+
}) : {
|
|
1902
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
1903
|
+
confirmed: [],
|
|
1904
|
+
wokenEntryIds: []
|
|
1905
|
+
};
|
|
1906
|
+
const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
|
|
1907
|
+
const trackedDetections = this.tracker.update(trackerInput, timestamp);
|
|
1846
1908
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
1847
1909
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
1848
1910
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
@@ -1924,7 +1986,9 @@ var FrameProcessor = class {
|
|
|
1924
1986
|
frameHeight,
|
|
1925
1987
|
tracked,
|
|
1926
1988
|
objectEvents,
|
|
1927
|
-
rawTrackedDetections: trackedDetections
|
|
1989
|
+
rawTrackedDetections: trackedDetections,
|
|
1990
|
+
stationaryConfirmed: gate.confirmed,
|
|
1991
|
+
stationaryWoken: gate.wokenEntryIds
|
|
1928
1992
|
};
|
|
1929
1993
|
}
|
|
1930
1994
|
};
|
|
@@ -2001,6 +2065,7 @@ function buildTrackLifecyclePayload(input) {
|
|
|
2001
2065
|
...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
|
|
2002
2066
|
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
2003
2067
|
...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
|
|
2068
|
+
...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
|
|
2004
2069
|
...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
|
|
2005
2070
|
...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
|
|
2006
2071
|
...hasMedia ? { media } : {}
|
|
@@ -2117,6 +2182,583 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
2117
2182
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
2118
2183
|
}
|
|
2119
2184
|
//#endregion
|
|
2185
|
+
//#region src/pipeline-analytics/pipeline/static-track-gate.ts
|
|
2186
|
+
/**
|
|
2187
|
+
* Net displacement + path span for a track's centroid path, normalized to
|
|
2188
|
+
* `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
|
|
2189
|
+
* measure (fewer than two points, or a degenerate ≤0 reference) so the caller
|
|
2190
|
+
* leaves the importance score untouched.
|
|
2191
|
+
*/
|
|
2192
|
+
function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
|
|
2193
|
+
if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
|
|
2194
|
+
const first = centroids[0];
|
|
2195
|
+
const last = centroids[centroids.length - 1];
|
|
2196
|
+
const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
|
|
2197
|
+
let minX = Infinity;
|
|
2198
|
+
let minY = Infinity;
|
|
2199
|
+
let maxX = -Infinity;
|
|
2200
|
+
let maxY = -Infinity;
|
|
2201
|
+
for (const c of centroids) {
|
|
2202
|
+
if (c.x < minX) minX = c.x;
|
|
2203
|
+
if (c.x > maxX) maxX = c.x;
|
|
2204
|
+
if (c.y < minY) minY = c.y;
|
|
2205
|
+
if (c.y > maxY) maxY = c.y;
|
|
2206
|
+
}
|
|
2207
|
+
return {
|
|
2208
|
+
netDisplacementFrac,
|
|
2209
|
+
pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
/** Average bbox diagonal (px) across a track's positions — the scale reference
|
|
2213
|
+
* when frame dimensions aren't available. Returns 0 for an empty list. */
|
|
2214
|
+
function averageBboxDiagonal(boxes) {
|
|
2215
|
+
if (boxes.length === 0) return 0;
|
|
2216
|
+
let sum = 0;
|
|
2217
|
+
for (const b of boxes) sum += Math.hypot(b.w, b.h);
|
|
2218
|
+
return sum / boxes.length;
|
|
2219
|
+
}
|
|
2220
|
+
//#endregion
|
|
2221
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
|
|
2222
|
+
function entryToView(e) {
|
|
2223
|
+
return {
|
|
2224
|
+
id: e.id,
|
|
2225
|
+
className: e.className,
|
|
2226
|
+
bbox: { ...e.bbox },
|
|
2227
|
+
frameWidth: e.frameWidth,
|
|
2228
|
+
frameHeight: e.frameHeight,
|
|
2229
|
+
firstSeenAt: e.firstSeenAt,
|
|
2230
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2231
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2232
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2233
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
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). */
|
|
2238
|
+
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;
|
|
2246
|
+
/**
|
|
2247
|
+
* Look-back window over which a track must have stayed put to be PROMOTED. A
|
|
2248
|
+
* car that drives in then parks has a large whole-life displacement but a tiny
|
|
2249
|
+
* last-`windowMs` displacement — so promotion is judged on the recent window,
|
|
2250
|
+
* not the full path. Also the minimum age (the track must have EXISTED this
|
|
2251
|
+
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2252
|
+
*/
|
|
2253
|
+
var PROMOTION_WINDOW_MS = 3e4;
|
|
2254
|
+
var DEFAULT_MATCH_CONFIG = {
|
|
2255
|
+
suppressIou: SUPPRESS_IOU,
|
|
2256
|
+
wakeMoveFrac: WAKE_MOVE_FRAC,
|
|
2257
|
+
wakeSearchFrac: WAKE_SEARCH_FRAC
|
|
2258
|
+
};
|
|
2259
|
+
//#endregion
|
|
2260
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
|
|
2261
|
+
/**
|
|
2262
|
+
* stationary-match — PURE geometry for the stationary-object registry.
|
|
2263
|
+
*
|
|
2264
|
+
* Two decisions, both unit-testable in isolation:
|
|
2265
|
+
*
|
|
2266
|
+
* 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
|
|
2267
|
+
* and this frame's (zone-filtered) detections, decides which detections are
|
|
2268
|
+
* suppressed (they keep confirming a known parked object → no track spawns),
|
|
2269
|
+
* which entries are confirmed present, and which entries WOKE (their object
|
|
2270
|
+
* moved → retire the entry and let the detection spawn a normal track).
|
|
2271
|
+
*
|
|
2272
|
+
* 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
|
|
2273
|
+
* decides whether it has stayed put long enough to become a stationary
|
|
2274
|
+
* entry. Reuses the static-track-gate metrics (net displacement + path span
|
|
2275
|
+
* normalised to the frame diagonal) over the recent look-back window.
|
|
2276
|
+
*/
|
|
2277
|
+
/** Default promotion tunables — static thresholds shared with the key-event
|
|
2278
|
+
* static gate so "stationary" means the same thing in both places. */
|
|
2279
|
+
var DEFAULT_PROMOTION_CONFIG = {
|
|
2280
|
+
windowMs: PROMOTION_WINDOW_MS,
|
|
2281
|
+
netFracMax: STATIC_DISPLACEMENT_FRAC,
|
|
2282
|
+
spanFracMax: STATIC_SPAN_FRAC,
|
|
2283
|
+
minPoints: 4
|
|
2284
|
+
};
|
|
2285
|
+
function iou$1(a, b) {
|
|
2286
|
+
const ax2 = a.x + a.w;
|
|
2287
|
+
const ay2 = a.y + a.h;
|
|
2288
|
+
const bx2 = b.x + b.w;
|
|
2289
|
+
const by2 = b.y + b.h;
|
|
2290
|
+
const ix1 = Math.max(a.x, b.x);
|
|
2291
|
+
const iy1 = Math.max(a.y, b.y);
|
|
2292
|
+
const ix2 = Math.min(ax2, bx2);
|
|
2293
|
+
const iy2 = Math.min(ay2, by2);
|
|
2294
|
+
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
2295
|
+
const union = a.w * a.h + b.w * b.h - inter;
|
|
2296
|
+
return union > 0 ? inter / union : 0;
|
|
2297
|
+
}
|
|
2298
|
+
function centroid(b) {
|
|
2299
|
+
return {
|
|
2300
|
+
x: b.x + b.w / 2,
|
|
2301
|
+
y: b.y + b.h / 2
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
function diagonalOf(width, height) {
|
|
2305
|
+
return Math.hypot(width, height);
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Decide, per stationary entry, whether the current frame confirms it, wakes
|
|
2309
|
+
* it, or misses it (no matching detection — leave it for the TTL sweep).
|
|
2310
|
+
*
|
|
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.
|
|
2319
|
+
*
|
|
2320
|
+
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
2321
|
+
* (`suppressedIndices` is a set).
|
|
2322
|
+
*/
|
|
2323
|
+
function partitionDetectionsAgainstRegistry(input) {
|
|
2324
|
+
const { entries, detections, referenceDiagonalPx, config } = input;
|
|
2325
|
+
const suppressed = /* @__PURE__ */ new Set();
|
|
2326
|
+
const confirmed = [];
|
|
2327
|
+
const woken = [];
|
|
2328
|
+
const diag = referenceDiagonalPx;
|
|
2329
|
+
for (const entry of entries) {
|
|
2330
|
+
const ec = centroid(entry.bbox);
|
|
2331
|
+
let bestIou = 0;
|
|
2332
|
+
let bestIdx = -1;
|
|
2333
|
+
let wakeCandidate = false;
|
|
2334
|
+
for (let di = 0; di < detections.length; di++) {
|
|
2335
|
+
const det = detections[di];
|
|
2336
|
+
if (det.className !== entry.className) continue;
|
|
2337
|
+
const o = iou$1(entry.bbox, det.bbox);
|
|
2338
|
+
if (o > bestIou) {
|
|
2339
|
+
bestIou = o;
|
|
2340
|
+
bestIdx = di;
|
|
2341
|
+
}
|
|
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
|
+
}
|
|
2348
|
+
if (bestIou >= config.suppressIou && bestIdx >= 0) {
|
|
2349
|
+
suppressed.add(bestIdx);
|
|
2350
|
+
confirmed.push({
|
|
2351
|
+
entryId: entry.id,
|
|
2352
|
+
className: entry.className,
|
|
2353
|
+
bbox: { ...entry.bbox }
|
|
2354
|
+
});
|
|
2355
|
+
} else if (wakeCandidate) woken.push(entry.id);
|
|
2356
|
+
}
|
|
2357
|
+
return {
|
|
2358
|
+
suppressedIndices: suppressed,
|
|
2359
|
+
confirmed,
|
|
2360
|
+
wokenEntryIds: woken
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* A track is promoted when, over the recent `windowMs`, its centroid barely
|
|
2365
|
+
* moved (both net displacement and path span below the static thresholds) AND
|
|
2366
|
+
* the track has actually EXISTED for at least `windowMs` (so a car that just
|
|
2367
|
+
* arrived isn't parked yet). Judging on the recent window — not the whole life
|
|
2368
|
+
* — is what lets a car that drove in then parked be recognised as stationary.
|
|
2369
|
+
*/
|
|
2370
|
+
function evaluateStationaryPromotion(input) {
|
|
2371
|
+
const { positions, referenceDiagonalPx, now, config } = input;
|
|
2372
|
+
if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
|
|
2373
|
+
if (now - positions[0].timestamp < config.windowMs) return { promote: false };
|
|
2374
|
+
const cutoff = now - config.windowMs;
|
|
2375
|
+
const window = positions.filter((p) => p.timestamp >= cutoff);
|
|
2376
|
+
if (window.length < config.minPoints) return { promote: false };
|
|
2377
|
+
const metrics = computeStaticTrackMetrics(window.map((p) => ({
|
|
2378
|
+
x: p.x,
|
|
2379
|
+
y: p.y
|
|
2380
|
+
})), referenceDiagonalPx);
|
|
2381
|
+
if (metrics === void 0) return { promote: false };
|
|
2382
|
+
return {
|
|
2383
|
+
promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
|
|
2384
|
+
netFrac: metrics.netDisplacementFrac,
|
|
2385
|
+
spanFrac: metrics.pathSpanFrac
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
//#endregion
|
|
2389
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
|
|
2390
|
+
var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
|
|
2391
|
+
var STATIONARY_COLUMNS = [
|
|
2392
|
+
{
|
|
2393
|
+
name: "id",
|
|
2394
|
+
type: "TEXT",
|
|
2395
|
+
primaryKey: true,
|
|
2396
|
+
notNull: true
|
|
2397
|
+
},
|
|
2398
|
+
{
|
|
2399
|
+
name: "deviceId",
|
|
2400
|
+
type: "INTEGER",
|
|
2401
|
+
notNull: true
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
name: "className",
|
|
2405
|
+
type: "TEXT",
|
|
2406
|
+
notNull: true
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
name: "bbox",
|
|
2410
|
+
type: "JSON"
|
|
2411
|
+
},
|
|
2412
|
+
{
|
|
2413
|
+
name: "frameWidth",
|
|
2414
|
+
type: "INTEGER"
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
name: "frameHeight",
|
|
2418
|
+
type: "INTEGER"
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
name: "firstSeenAt",
|
|
2422
|
+
type: "INTEGER"
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
name: "becameStationaryAt",
|
|
2426
|
+
type: "INTEGER"
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
name: "lastConfirmedAt",
|
|
2430
|
+
type: "INTEGER"
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
name: "sourceTrackId",
|
|
2434
|
+
type: "TEXT"
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
name: "label",
|
|
2438
|
+
type: "TEXT"
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
name: "keyFrameMediaKey",
|
|
2442
|
+
type: "TEXT"
|
|
2443
|
+
}
|
|
2444
|
+
];
|
|
2445
|
+
var STATIONARY_INDEXES = [{
|
|
2446
|
+
name: "idx_stationary_device",
|
|
2447
|
+
columns: ["deviceId"]
|
|
2448
|
+
}];
|
|
2449
|
+
var StationaryObjectRegistry = class {
|
|
2450
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
2451
|
+
dirty = /* @__PURE__ */ new Set();
|
|
2452
|
+
store;
|
|
2453
|
+
logger;
|
|
2454
|
+
matchConfig;
|
|
2455
|
+
entryTtlMs;
|
|
2456
|
+
onChange;
|
|
2457
|
+
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2458
|
+
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
2459
|
+
* between motion sessions; that silence is not evidence the object left,
|
|
2460
|
+
* so quiet minutes must not age the entries (see {@link sweep}). */
|
|
2461
|
+
lastFrameAtByDevice = /* @__PURE__ */ new Map();
|
|
2462
|
+
constructor(deps) {
|
|
2463
|
+
this.store = deps.store;
|
|
2464
|
+
this.logger = deps.logger;
|
|
2465
|
+
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2466
|
+
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2467
|
+
this.onChange = deps.onChange;
|
|
2468
|
+
}
|
|
2469
|
+
static async declare(store) {
|
|
2470
|
+
await store.declareCollection.mutate({
|
|
2471
|
+
collection: STATIONARY_COLLECTION,
|
|
2472
|
+
columns: [...STATIONARY_COLUMNS],
|
|
2473
|
+
indexes: [...STATIONARY_INDEXES]
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
/** Hydrate all persisted entries into memory (call once at boot, after
|
|
2477
|
+
* `declare`). Best-effort — a query failure leaves the registry empty. */
|
|
2478
|
+
async load() {
|
|
2479
|
+
try {
|
|
2480
|
+
const rows = await this.store.query.query({
|
|
2481
|
+
collection: STATIONARY_COLLECTION,
|
|
2482
|
+
filter: { limit: 1e5 }
|
|
2483
|
+
});
|
|
2484
|
+
for (const row of rows) {
|
|
2485
|
+
const entry = rowToEntry(row.id, row.data);
|
|
2486
|
+
if (!entry) continue;
|
|
2487
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2488
|
+
}
|
|
2489
|
+
this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
list(deviceId) {
|
|
2495
|
+
const m = this.byDevice.get(deviceId);
|
|
2496
|
+
return m ? [...m.values()] : [];
|
|
2497
|
+
}
|
|
2498
|
+
listViews(deviceId) {
|
|
2499
|
+
return this.list(deviceId).map(entryToView);
|
|
2500
|
+
}
|
|
2501
|
+
count(deviceId) {
|
|
2502
|
+
return this.byDevice.get(deviceId)?.size ?? 0;
|
|
2503
|
+
}
|
|
2504
|
+
/** Device ids that currently hold at least one parked entry — drives the
|
|
2505
|
+
* occupancy baseline sampler (a detached camera with parked cars still
|
|
2506
|
+
* gets a flat history baseline). */
|
|
2507
|
+
deviceIds() {
|
|
2508
|
+
const ids = [];
|
|
2509
|
+
for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
|
|
2510
|
+
return ids;
|
|
2511
|
+
}
|
|
2512
|
+
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2513
|
+
* clock that drives entry expiry in {@link sweep}. */
|
|
2514
|
+
noteFrame(deviceId, timestamp) {
|
|
2515
|
+
if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Per-frame gate: partition this frame's detections against the device's
|
|
2519
|
+
* entries. PURE with respect to registry state — apply the outcome with
|
|
2520
|
+
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2521
|
+
*/
|
|
2522
|
+
filter(input) {
|
|
2523
|
+
const entries = this.list(input.deviceId);
|
|
2524
|
+
if (entries.length === 0) return {
|
|
2525
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
2526
|
+
confirmed: [],
|
|
2527
|
+
wokenEntryIds: []
|
|
2528
|
+
};
|
|
2529
|
+
return partitionDetectionsAgainstRegistry({
|
|
2530
|
+
entries,
|
|
2531
|
+
detections: input.detections,
|
|
2532
|
+
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2533
|
+
config: this.matchConfig
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
2537
|
+
* `lastConfirmedAt` and retire woken entries (their object departed). */
|
|
2538
|
+
applyFrameOutcome(input) {
|
|
2539
|
+
const m = this.byDevice.get(input.deviceId);
|
|
2540
|
+
if (!m) return;
|
|
2541
|
+
for (const c of input.confirmed) {
|
|
2542
|
+
const e = m.get(c.entryId);
|
|
2543
|
+
if (!e) continue;
|
|
2544
|
+
m.set(c.entryId, {
|
|
2545
|
+
...e,
|
|
2546
|
+
lastConfirmedAt: input.timestamp
|
|
2547
|
+
});
|
|
2548
|
+
this.dirty.add(c.entryId);
|
|
2549
|
+
}
|
|
2550
|
+
for (const id of input.wokenEntryIds) {
|
|
2551
|
+
const e = m.get(id);
|
|
2552
|
+
if (!e) continue;
|
|
2553
|
+
m.delete(id);
|
|
2554
|
+
this.dirty.delete(id);
|
|
2555
|
+
this.deletePersisted(id);
|
|
2556
|
+
this.onChange?.({
|
|
2557
|
+
phase: "departed",
|
|
2558
|
+
entry: e,
|
|
2559
|
+
timestamp: input.timestamp
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
/** Promote a parked track into a persisted stationary entry. */
|
|
2564
|
+
async promote(entry) {
|
|
2565
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2566
|
+
this.dirty.delete(entry.id);
|
|
2567
|
+
try {
|
|
2568
|
+
await this.persist(entry);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
this.logger.warn("stationary promote persist failed", {
|
|
2571
|
+
tags: { deviceId: entry.deviceId },
|
|
2572
|
+
meta: {
|
|
2573
|
+
entryId: entry.id,
|
|
2574
|
+
error: String(err)
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
this.onChange?.({
|
|
2579
|
+
phase: "appeared",
|
|
2580
|
+
entry,
|
|
2581
|
+
timestamp: entry.becameStationaryAt
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
|
|
2586
|
+
* flush any advanced `lastConfirmedAt`s to the store. Returns retired
|
|
2587
|
+
* entries (for logging).
|
|
2588
|
+
*
|
|
2589
|
+
* Expiry is measured against the device's latest processed-frame timestamp
|
|
2590
|
+
* ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
|
|
2591
|
+
* no frames between motion sessions, and that silence says nothing about
|
|
2592
|
+
* the object. Only when the camera has actually been WATCHING for `ttl`
|
|
2593
|
+
* beyond the last confirmation (frames flowed, object never matched) is the
|
|
2594
|
+
* object considered removed. A device with no recorded frame yet never
|
|
2595
|
+
* expires its entries. `now` only stamps the departed telemetry.
|
|
2596
|
+
*/
|
|
2597
|
+
async sweep(now) {
|
|
2598
|
+
const retired = [];
|
|
2599
|
+
for (const [deviceId, m] of this.byDevice) {
|
|
2600
|
+
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2601
|
+
if (observedAt === void 0) continue;
|
|
2602
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
|
|
2603
|
+
m.delete(id);
|
|
2604
|
+
this.dirty.delete(id);
|
|
2605
|
+
retired.push(e);
|
|
2606
|
+
this.deletePersisted(id);
|
|
2607
|
+
this.onChange?.({
|
|
2608
|
+
phase: "departed",
|
|
2609
|
+
entry: e,
|
|
2610
|
+
timestamp: now
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
if (m.size === 0) this.byDevice.delete(deviceId);
|
|
2614
|
+
}
|
|
2615
|
+
for (const id of [...this.dirty]) {
|
|
2616
|
+
this.dirty.delete(id);
|
|
2617
|
+
const entry = this.findById(id);
|
|
2618
|
+
if (!entry) continue;
|
|
2619
|
+
try {
|
|
2620
|
+
await this.store.update.mutate({
|
|
2621
|
+
collection: STATIONARY_COLLECTION,
|
|
2622
|
+
id,
|
|
2623
|
+
data: { lastConfirmedAt: entry.lastConfirmedAt }
|
|
2624
|
+
});
|
|
2625
|
+
} catch (err) {
|
|
2626
|
+
this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
|
|
2627
|
+
entryId: id,
|
|
2628
|
+
error: String(err)
|
|
2629
|
+
} });
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
return retired;
|
|
2633
|
+
}
|
|
2634
|
+
/** Drop a device's entries from memory WITHOUT deleting persisted rows.
|
|
2635
|
+
* Used on device unbind; a rebind reloads from the store. */
|
|
2636
|
+
forgetDevice(deviceId) {
|
|
2637
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2638
|
+
const m = this.byDevice.get(deviceId);
|
|
2639
|
+
if (!m) return;
|
|
2640
|
+
for (const id of m.keys()) this.dirty.delete(id);
|
|
2641
|
+
this.byDevice.delete(deviceId);
|
|
2642
|
+
}
|
|
2643
|
+
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
2644
|
+
async clearDevice(deviceId) {
|
|
2645
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2646
|
+
const m = this.byDevice.get(deviceId);
|
|
2647
|
+
if (m) {
|
|
2648
|
+
for (const id of [...m.keys()]) {
|
|
2649
|
+
this.dirty.delete(id);
|
|
2650
|
+
this.deletePersisted(id);
|
|
2651
|
+
}
|
|
2652
|
+
this.byDevice.delete(deviceId);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
deviceMap(deviceId) {
|
|
2656
|
+
let m = this.byDevice.get(deviceId);
|
|
2657
|
+
if (!m) {
|
|
2658
|
+
m = /* @__PURE__ */ new Map();
|
|
2659
|
+
this.byDevice.set(deviceId, m);
|
|
2660
|
+
}
|
|
2661
|
+
return m;
|
|
2662
|
+
}
|
|
2663
|
+
findById(id) {
|
|
2664
|
+
for (const m of this.byDevice.values()) {
|
|
2665
|
+
const e = m.get(id);
|
|
2666
|
+
if (e) return e;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
async persist(e) {
|
|
2670
|
+
await this.store.set.mutate({
|
|
2671
|
+
collection: STATIONARY_COLLECTION,
|
|
2672
|
+
key: e.id,
|
|
2673
|
+
value: {
|
|
2674
|
+
deviceId: e.deviceId,
|
|
2675
|
+
className: e.className,
|
|
2676
|
+
bbox: { ...e.bbox },
|
|
2677
|
+
frameWidth: e.frameWidth,
|
|
2678
|
+
frameHeight: e.frameHeight,
|
|
2679
|
+
firstSeenAt: e.firstSeenAt,
|
|
2680
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2681
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2682
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
|
|
2683
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2684
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2685
|
+
}
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
async deletePersisted(id) {
|
|
2689
|
+
try {
|
|
2690
|
+
await this.store.delete.mutate({
|
|
2691
|
+
collection: STATIONARY_COLLECTION,
|
|
2692
|
+
key: id
|
|
2693
|
+
});
|
|
2694
|
+
} catch (err) {
|
|
2695
|
+
this.logger.debug("stationary delete failed", { meta: {
|
|
2696
|
+
entryId: id,
|
|
2697
|
+
error: String(err)
|
|
2698
|
+
} });
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
};
|
|
2702
|
+
function rowToEntry(id, data) {
|
|
2703
|
+
const deviceId = Number(data["deviceId"]);
|
|
2704
|
+
const className = data["className"];
|
|
2705
|
+
const bbox = data["bbox"];
|
|
2706
|
+
if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
|
|
2707
|
+
const sourceTrackId = data["sourceTrackId"];
|
|
2708
|
+
const label = data["label"];
|
|
2709
|
+
const keyFrameMediaKey = data["keyFrameMediaKey"];
|
|
2710
|
+
return {
|
|
2711
|
+
id,
|
|
2712
|
+
deviceId,
|
|
2713
|
+
className,
|
|
2714
|
+
bbox: {
|
|
2715
|
+
x: Number(bbox.x),
|
|
2716
|
+
y: Number(bbox.y),
|
|
2717
|
+
w: Number(bbox.w),
|
|
2718
|
+
h: Number(bbox.h)
|
|
2719
|
+
},
|
|
2720
|
+
frameWidth: Number(data["frameWidth"] ?? 0),
|
|
2721
|
+
frameHeight: Number(data["frameHeight"] ?? 0),
|
|
2722
|
+
firstSeenAt: Number(data["firstSeenAt"] ?? 0),
|
|
2723
|
+
becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
|
|
2724
|
+
lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
|
|
2725
|
+
...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
|
|
2726
|
+
...typeof label === "string" ? { label } : {},
|
|
2727
|
+
...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
//#endregion
|
|
2731
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
|
|
2732
|
+
/**
|
|
2733
|
+
* Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
|
|
2734
|
+
* Empty when the entry has no frame dims (can't normalise) or no zone matches.
|
|
2735
|
+
*/
|
|
2736
|
+
function computeStationaryEntryZones(entry, zones) {
|
|
2737
|
+
if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
|
|
2738
|
+
const centroidPx = bboxCentroid(entry.bbox);
|
|
2739
|
+
const point = {
|
|
2740
|
+
x: centroidPx.x / entry.frameWidth,
|
|
2741
|
+
y: centroidPx.y / entry.frameHeight
|
|
2742
|
+
};
|
|
2743
|
+
const matched = [];
|
|
2744
|
+
for (const zone of zones) {
|
|
2745
|
+
if (zone.polygon.length < 3) continue;
|
|
2746
|
+
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2747
|
+
}
|
|
2748
|
+
return matched;
|
|
2749
|
+
}
|
|
2750
|
+
//#endregion
|
|
2751
|
+
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2752
|
+
/**
|
|
2753
|
+
* Pure: no side effects. `continuing` = still active from last frame;
|
|
2754
|
+
* `birth` = a brand-new track's first sighting; `resurrection` = a known track
|
|
2755
|
+
* re-entering the active set after being lost.
|
|
2756
|
+
*/
|
|
2757
|
+
function classifyTrackAppearance(input) {
|
|
2758
|
+
if (input.inPrevActive) return "continuing";
|
|
2759
|
+
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
2760
|
+
}
|
|
2761
|
+
//#endregion
|
|
2120
2762
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
2121
2763
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
2122
2764
|
const scored = [];
|
|
@@ -2126,6 +2768,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2126
2768
|
let bestEventId = t.bestEventId;
|
|
2127
2769
|
if (importance === void 0) {
|
|
2128
2770
|
const peak = await peakLookup(t.trackId);
|
|
2771
|
+
const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
2772
|
+
x: p.x,
|
|
2773
|
+
y: p.y
|
|
2774
|
+
})), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
|
|
2129
2775
|
importance = computeImportance({
|
|
2130
2776
|
peakConfidence: peak.peakConfidence,
|
|
2131
2777
|
className: t.className,
|
|
@@ -2133,7 +2779,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2133
2779
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
2134
2780
|
totalDistance: t.totalDistance,
|
|
2135
2781
|
zonesVisited: t.zonesVisited,
|
|
2136
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
2782
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
2783
|
+
...staticMetrics ? {
|
|
2784
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
2785
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
2786
|
+
} : {}
|
|
2137
2787
|
}).importance;
|
|
2138
2788
|
bestEventId = bestEventId ?? peak.bestEventId;
|
|
2139
2789
|
}
|
|
@@ -2145,7 +2795,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2145
2795
|
className: t.className,
|
|
2146
2796
|
...t.label !== void 0 ? { label: t.label } : {},
|
|
2147
2797
|
importance,
|
|
2148
|
-
bestEventId: bestEventId ??
|
|
2798
|
+
bestEventId: bestEventId ?? t.trackId,
|
|
2149
2799
|
windowMs: t.lastSeen - t.firstSeen
|
|
2150
2800
|
});
|
|
2151
2801
|
}
|
|
@@ -2382,6 +3032,10 @@ var TRACKS_COLUMNS = [
|
|
|
2382
3032
|
{
|
|
2383
3033
|
name: "importanceReason",
|
|
2384
3034
|
type: "TEXT"
|
|
3035
|
+
},
|
|
3036
|
+
{
|
|
3037
|
+
name: "audioLabels",
|
|
3038
|
+
type: "JSON"
|
|
2385
3039
|
}
|
|
2386
3040
|
];
|
|
2387
3041
|
var TRACKS_INDEXES = [{
|
|
@@ -2391,6 +3045,17 @@ var TRACKS_INDEXES = [{
|
|
|
2391
3045
|
name: "idx_tracks_device_firstSeen",
|
|
2392
3046
|
columns: ["deviceId", "firstSeen"]
|
|
2393
3047
|
}];
|
|
3048
|
+
/** Serialize the per-label aggregate map into the `Track.audioLabels`
|
|
3049
|
+
* array shape, most-frequent label first. */
|
|
3050
|
+
function audioLabelsToArray(agg) {
|
|
3051
|
+
return [...agg.entries()].map(([label, a]) => ({
|
|
3052
|
+
label,
|
|
3053
|
+
peakScore: a.peakScore,
|
|
3054
|
+
count: a.count,
|
|
3055
|
+
firstAt: a.firstAt,
|
|
3056
|
+
lastAt: a.lastAt
|
|
3057
|
+
})).sort((a, b) => b.count - a.count);
|
|
3058
|
+
}
|
|
2394
3059
|
function cloneTrack(t) {
|
|
2395
3060
|
return {
|
|
2396
3061
|
trackId: t.trackId,
|
|
@@ -2417,7 +3082,8 @@ function cloneTrack(t) {
|
|
|
2417
3082
|
active: t.active,
|
|
2418
3083
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2419
3084
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2420
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3085
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3086
|
+
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
|
|
2421
3087
|
};
|
|
2422
3088
|
}
|
|
2423
3089
|
var TrackStore = class {
|
|
@@ -2478,25 +3144,81 @@ var TrackStore = class {
|
|
|
2478
3144
|
this.active.set(params.trackId, fresh);
|
|
2479
3145
|
return fresh;
|
|
2480
3146
|
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Record one audio-classification EPISODE against every track currently
|
|
3149
|
+
* active on the device — "what was heard on this camera while the track
|
|
3150
|
+
* was alive". Called from the confident-classification audio-event insert
|
|
3151
|
+
* (score ≥ device `classificationMinScore`, class-change-or-heartbeat
|
|
3152
|
+
* coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
|
|
3153
|
+
*/
|
|
3154
|
+
addAudioLabelEpisode(deviceId, label, score, timestamp) {
|
|
3155
|
+
for (const t of this.active.values()) {
|
|
3156
|
+
if (t.deviceId !== deviceId || !t.active) continue;
|
|
3157
|
+
const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
|
|
3158
|
+
const entry = agg.get(label);
|
|
3159
|
+
if (entry) {
|
|
3160
|
+
entry.peakScore = Math.max(entry.peakScore, score);
|
|
3161
|
+
entry.count += 1;
|
|
3162
|
+
entry.lastAt = timestamp;
|
|
3163
|
+
} else agg.set(label, {
|
|
3164
|
+
peakScore: score,
|
|
3165
|
+
count: 1,
|
|
3166
|
+
firstAt: timestamp,
|
|
3167
|
+
lastAt: timestamp
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
2481
3171
|
/** Attach a snapshot reference to an active track. */
|
|
2482
3172
|
addSnapshot(trackId, snapshot) {
|
|
2483
3173
|
const t = this.active.get(trackId);
|
|
2484
3174
|
if (!t) return;
|
|
2485
3175
|
t.snapshots.push(snapshot);
|
|
2486
3176
|
t.lastSnapshotAt = snapshot.timestamp;
|
|
3177
|
+
t.lastSnapshotBbox = { ...snapshot.position.bbox };
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
|
|
3181
|
+
* the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
|
|
3182
|
+
* lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
|
|
3183
|
+
* only advances when the dispatcher round-trip returns (~50-200ms of sharp
|
|
3184
|
+
* encode), so at 10-25fps several consecutive frames pass
|
|
3185
|
+
* `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
|
|
3186
|
+
* snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
|
|
3187
|
+
* rolling `lastFrame`.
|
|
3188
|
+
*
|
|
3189
|
+
* No rollback: if the write later fails the slot is simply lost (a rare dropped
|
|
3190
|
+
* snapshot is preferable to a burst). `addSnapshot` re-stamps the same
|
|
3191
|
+
* clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
|
|
3192
|
+
* an unknown/expired track (the active entry is dropped on expiry, so there is
|
|
3193
|
+
* no separate map to leak).
|
|
3194
|
+
*/
|
|
3195
|
+
markSnapshotPending(trackId, timestamp, bbox) {
|
|
3196
|
+
const t = this.active.get(trackId);
|
|
3197
|
+
if (!t) return;
|
|
3198
|
+
t.lastSnapshotAt = timestamp;
|
|
3199
|
+
t.lastSnapshotBbox = { ...bbox };
|
|
2487
3200
|
}
|
|
2488
3201
|
lastSnapshotAt(trackId) {
|
|
2489
3202
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
2490
3203
|
}
|
|
3204
|
+
/** Bbox reference of the last captured snapshot (or the seed bbox), for the
|
|
3205
|
+
* periodic-snapshot movement gate. Undefined until the clock is seeded. */
|
|
3206
|
+
lastSnapshotBbox(trackId) {
|
|
3207
|
+
const b = this.active.get(trackId)?.lastSnapshotBbox;
|
|
3208
|
+
return b ? { ...b } : void 0;
|
|
3209
|
+
}
|
|
2491
3210
|
/**
|
|
2492
3211
|
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
2493
3212
|
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
2494
3213
|
* track begins rather than immediately — the `firstFrame` already covers the
|
|
2495
3214
|
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
2496
3215
|
*/
|
|
2497
|
-
seedSnapshotClock(trackId, timestamp) {
|
|
3216
|
+
seedSnapshotClock(trackId, timestamp, bbox) {
|
|
2498
3217
|
const t = this.active.get(trackId);
|
|
2499
|
-
if (t && t.lastSnapshotAt === 0)
|
|
3218
|
+
if (t && t.lastSnapshotAt === 0) {
|
|
3219
|
+
t.lastSnapshotAt = timestamp;
|
|
3220
|
+
if (bbox) t.lastSnapshotBbox = { ...bbox };
|
|
3221
|
+
}
|
|
2500
3222
|
}
|
|
2501
3223
|
getActive(deviceId) {
|
|
2502
3224
|
const out = [];
|
|
@@ -2507,6 +3229,21 @@ var TrackStore = class {
|
|
|
2507
3229
|
const t = this.active.get(trackId);
|
|
2508
3230
|
return t && t.active ? cloneTrack(t) : null;
|
|
2509
3231
|
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Cheap read of an active track's promotion-relevant fields WITHOUT the deep
|
|
3234
|
+
* clone `getActiveByTrack` does — the returned `positions` is the live
|
|
3235
|
+
* internal array (read-only; callers must not mutate). Feeds the per-frame
|
|
3236
|
+
* stationary-promotion check, called at inference fps. Null if unknown/expired.
|
|
3237
|
+
*/
|
|
3238
|
+
peekActive(trackId) {
|
|
3239
|
+
const t = this.active.get(trackId);
|
|
3240
|
+
if (!t || !t.active) return null;
|
|
3241
|
+
return {
|
|
3242
|
+
firstSeen: t.firstSeen,
|
|
3243
|
+
positions: t.positions,
|
|
3244
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
2510
3247
|
/** Expire tracks whose `lastSeen` is older than TTL. Persists each
|
|
2511
3248
|
* expired track to the declared collection and returns them. */
|
|
2512
3249
|
async expireStale(nowMs) {
|
|
@@ -2612,6 +3349,15 @@ var TrackStore = class {
|
|
|
2612
3349
|
clearAll() {
|
|
2613
3350
|
this.active.clear();
|
|
2614
3351
|
}
|
|
3352
|
+
/**
|
|
3353
|
+
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
3354
|
+
* when a track is PROMOTED to a stationary-object registry entry: the durable
|
|
3355
|
+
* record for a parked object is the registry entry, not a Track, so the track
|
|
3356
|
+
* must NOT land in the key-event feed. No-op for an unknown/expired track.
|
|
3357
|
+
*/
|
|
3358
|
+
dropActive(trackId) {
|
|
3359
|
+
this.active.delete(trackId);
|
|
3360
|
+
}
|
|
2615
3361
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2616
3362
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2617
3363
|
async deletePersisted(trackId) {
|
|
@@ -2744,7 +3490,8 @@ var TrackStore = class {
|
|
|
2744
3490
|
state: t.state,
|
|
2745
3491
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2746
3492
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2747
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3493
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3494
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
2748
3495
|
}
|
|
2749
3496
|
});
|
|
2750
3497
|
}
|
|
@@ -2757,6 +3504,7 @@ var TrackStore = class {
|
|
|
2757
3504
|
const importance = data["importance"];
|
|
2758
3505
|
const bestEventId = data["bestEventId"];
|
|
2759
3506
|
const importanceReason = data["importanceReason"];
|
|
3507
|
+
const audioLabels = data["audioLabels"];
|
|
2760
3508
|
return {
|
|
2761
3509
|
trackId: id,
|
|
2762
3510
|
deviceId: Number(data["deviceId"]),
|
|
@@ -2773,7 +3521,8 @@ var TrackStore = class {
|
|
|
2773
3521
|
active: false,
|
|
2774
3522
|
...typeof importance === "number" ? { importance } : {},
|
|
2775
3523
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
2776
|
-
...typeof importanceReason === "string" ? { importanceReason } : {}
|
|
3524
|
+
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3525
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
2777
3526
|
};
|
|
2778
3527
|
}
|
|
2779
3528
|
};
|
|
@@ -3886,13 +4635,10 @@ function stripNulls(data) {
|
|
|
3886
4635
|
//#endregion
|
|
3887
4636
|
//#region src/shared/frame/resolve-frame.ts
|
|
3888
4637
|
/**
|
|
3889
|
-
* Resolve the pixels a `FrameHandle` refers to
|
|
3890
|
-
*
|
|
3891
|
-
* `deps.getRemoteFrame`. Returns `null` when the frame is no longer
|
|
3892
|
-
* available (slot recycled locally, or the remote node reports no frame).
|
|
4638
|
+
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
4639
|
+
* Returns `null` when the frame is no longer available.
|
|
3893
4640
|
*/
|
|
3894
4641
|
async function resolveFrame(handle, deps) {
|
|
3895
|
-
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3896
4642
|
return deps.getRemoteFrame(handle);
|
|
3897
4643
|
}
|
|
3898
4644
|
//#endregion
|
|
@@ -4069,11 +4815,7 @@ var EventMediaDispatcher = class {
|
|
|
4069
4815
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
4070
4816
|
let decoded;
|
|
4071
4817
|
try {
|
|
4072
|
-
decoded = await resolveFrame(frameHandle, {
|
|
4073
|
-
ownNodeId: this.deps.ownNodeId,
|
|
4074
|
-
readers: this.deps.readers,
|
|
4075
|
-
getRemoteFrame: this.deps.getRemoteFrame
|
|
4076
|
-
});
|
|
4818
|
+
decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
4077
4819
|
} catch (err) {
|
|
4078
4820
|
this.deps.logger.debug("event media: resolveFrame threw", {
|
|
4079
4821
|
tags: { deviceId },
|
|
@@ -4384,8 +5126,6 @@ var EmbeddingDispatcher = class {
|
|
|
4384
5126
|
encoder;
|
|
4385
5127
|
eventBus;
|
|
4386
5128
|
logger;
|
|
4387
|
-
ownNodeId;
|
|
4388
|
-
readers;
|
|
4389
5129
|
getRemoteFrame;
|
|
4390
5130
|
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4391
5131
|
pendingCrops = /* @__PURE__ */ new Map();
|
|
@@ -4398,8 +5138,6 @@ var EmbeddingDispatcher = class {
|
|
|
4398
5138
|
this.encoder = deps.encoder;
|
|
4399
5139
|
this.eventBus = deps.eventBus;
|
|
4400
5140
|
this.logger = deps.logger;
|
|
4401
|
-
this.ownNodeId = deps.ownNodeId;
|
|
4402
|
-
this.readers = deps.readers;
|
|
4403
5141
|
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4404
5142
|
}
|
|
4405
5143
|
async start() {
|
|
@@ -4451,11 +5189,7 @@ var EmbeddingDispatcher = class {
|
|
|
4451
5189
|
}
|
|
4452
5190
|
let decoded;
|
|
4453
5191
|
try {
|
|
4454
|
-
decoded = await resolveFrame(handle, {
|
|
4455
|
-
ownNodeId: this.ownNodeId,
|
|
4456
|
-
readers: this.readers,
|
|
4457
|
-
getRemoteFrame: this.getRemoteFrame
|
|
4458
|
-
});
|
|
5192
|
+
decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
|
|
4459
5193
|
} catch (err) {
|
|
4460
5194
|
this.logger.debug("skip: resolveFrame threw", {
|
|
4461
5195
|
tags: { deviceId: Number(deviceId) },
|
|
@@ -4702,6 +5436,17 @@ var RESOLUTION_MS = {
|
|
|
4702
5436
|
* latest state is never lost.
|
|
4703
5437
|
*/
|
|
4704
5438
|
var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
|
|
5439
|
+
/**
|
|
5440
|
+
* Cadence of the synthetic occupancy baseline. When a camera is detached (no
|
|
5441
|
+
* inference frames) but has persisted parked objects, the history ring would
|
|
5442
|
+
* otherwise stay empty and the chart would read "No occupancy history yet". A
|
|
5443
|
+
* device WITH parked entries gets one hydrated sample per this interval — a
|
|
5444
|
+
* flat baseline of the parked count — so the graph shows the parking lot's
|
|
5445
|
+
* standing occupancy instead of a gap. No sample is emitted for a device
|
|
5446
|
+
* without entries, and a real `recordFrame` in the same window suppresses the
|
|
5447
|
+
* baseline (it already appended a richer sample).
|
|
5448
|
+
*/
|
|
5449
|
+
var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
|
|
4705
5450
|
var ZoneAnalyticsProvider = class {
|
|
4706
5451
|
ctx;
|
|
4707
5452
|
snapshots = /* @__PURE__ */ new Map();
|
|
@@ -4718,8 +5463,17 @@ var ZoneAnalyticsProvider = class {
|
|
|
4718
5463
|
/** Last logged frame-wide occupancy total per device — so the occupancy log
|
|
4719
5464
|
* fires only when the count actually changes, not every inference frame. */
|
|
4720
5465
|
lastOccupancyTotal = /* @__PURE__ */ new Map();
|
|
5466
|
+
/** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
|
|
5467
|
+
* device with parked objects but no live frames. `null` when disabled. */
|
|
5468
|
+
baselineTimer = null;
|
|
4721
5469
|
constructor(ctx) {
|
|
4722
5470
|
this.ctx = ctx;
|
|
5471
|
+
if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
|
|
5472
|
+
this.baselineTimer = setInterval(() => {
|
|
5473
|
+
this.appendBaselineSamples();
|
|
5474
|
+
}, BASELINE_SAMPLE_INTERVAL_MS);
|
|
5475
|
+
this.baselineTimer.unref?.();
|
|
5476
|
+
}
|
|
4723
5477
|
this.sliceThrottle = new SliceThrottler({
|
|
4724
5478
|
intervalMs: SLICE_WRITE_INTERVAL_MS$1,
|
|
4725
5479
|
equalsIgnoringTs: snapshotEqualsIgnoringTs,
|
|
@@ -4741,9 +5495,10 @@ var ZoneAnalyticsProvider = class {
|
|
|
4741
5495
|
/** Stop pending throttle timers — called from addon shutdown. */
|
|
4742
5496
|
destroy() {
|
|
4743
5497
|
this.sliceThrottle.destroy();
|
|
5498
|
+
if (this.baselineTimer) clearInterval(this.baselineTimer);
|
|
4744
5499
|
}
|
|
4745
5500
|
async getCurrentSnapshot({ deviceId }) {
|
|
4746
|
-
return this.snapshots.get(deviceId) ??
|
|
5501
|
+
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
4747
5502
|
}
|
|
4748
5503
|
async getZoneHistory(input) {
|
|
4749
5504
|
return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
|
|
@@ -4796,6 +5551,65 @@ var ZoneAnalyticsProvider = class {
|
|
|
4796
5551
|
this.lastOccupancyTotal.delete(deviceId);
|
|
4797
5552
|
this.sliceThrottle.forgetDevice(deviceId);
|
|
4798
5553
|
}
|
|
5554
|
+
/**
|
|
5555
|
+
* Build an occupancy snapshot for a device purely from its parked-object
|
|
5556
|
+
* registry (no live frame). Returns `null` when hydration is unavailable or
|
|
5557
|
+
* the device has no parked objects — a device with neither frames nor entries
|
|
5558
|
+
* legitimately reports `null`. The snapshot's `ts` is the most recent
|
|
5559
|
+
* `lastConfirmedAt` across entries, falling back to the current tick.
|
|
5560
|
+
*/
|
|
5561
|
+
async hydrateFromRegistry(deviceId) {
|
|
5562
|
+
const listStationary = this.ctx.listStationaryObjects;
|
|
5563
|
+
if (!listStationary) return null;
|
|
5564
|
+
const entries = listStationary(deviceId);
|
|
5565
|
+
if (entries.length === 0) return null;
|
|
5566
|
+
let zones = [];
|
|
5567
|
+
try {
|
|
5568
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5569
|
+
} catch (err) {
|
|
5570
|
+
this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
|
|
5571
|
+
tags: { deviceId },
|
|
5572
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
5573
|
+
});
|
|
5574
|
+
}
|
|
5575
|
+
const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
|
|
5576
|
+
return buildStationarySnapshot({
|
|
5577
|
+
deviceId,
|
|
5578
|
+
entries,
|
|
5579
|
+
zones,
|
|
5580
|
+
timestamp: ts
|
|
5581
|
+
});
|
|
5582
|
+
}
|
|
5583
|
+
/**
|
|
5584
|
+
* Baseline sampler tick: for every device with parked objects, append a
|
|
5585
|
+
* hydrated sample to the history ring at the CURRENT time — but only when a
|
|
5586
|
+
* real frame hasn't already appended a sample within this interval (frames
|
|
5587
|
+
* flowing = richer samples, no synthetic baseline needed).
|
|
5588
|
+
*/
|
|
5589
|
+
async appendBaselineSamples() {
|
|
5590
|
+
const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
|
|
5591
|
+
const now = Date.now();
|
|
5592
|
+
for (const deviceId of deviceIds) {
|
|
5593
|
+
const ring = this.history.get(deviceId);
|
|
5594
|
+
if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
|
|
5595
|
+
const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
|
|
5596
|
+
if (entries.length === 0) continue;
|
|
5597
|
+
let zones = [];
|
|
5598
|
+
try {
|
|
5599
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5600
|
+
} catch {}
|
|
5601
|
+
const snapshot = buildStationarySnapshot({
|
|
5602
|
+
deviceId,
|
|
5603
|
+
entries,
|
|
5604
|
+
zones,
|
|
5605
|
+
timestamp: now
|
|
5606
|
+
});
|
|
5607
|
+
if (snapshot) {
|
|
5608
|
+
this.appendHistory(deviceId, snapshot);
|
|
5609
|
+
this.sliceThrottle.push(deviceId, snapshot);
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
4799
5613
|
appendHistory(deviceId, snapshot) {
|
|
4800
5614
|
const ring = this.history.get(deviceId) ?? [];
|
|
4801
5615
|
const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
|
|
@@ -4882,9 +5696,43 @@ function computeSnapshot(input) {
|
|
|
4882
5696
|
unzoned: {
|
|
4883
5697
|
totalObjects: unzonedTotal,
|
|
4884
5698
|
byClass: unzonedByClass
|
|
4885
|
-
}
|
|
5699
|
+
},
|
|
5700
|
+
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
4886
5701
|
};
|
|
4887
5702
|
}
|
|
5703
|
+
/** Most recent `lastConfirmedAt` across parked entries (0 when none). */
|
|
5704
|
+
function mostRecentStationaryConfirmedAt(entries) {
|
|
5705
|
+
let max = 0;
|
|
5706
|
+
for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
|
|
5707
|
+
return max;
|
|
5708
|
+
}
|
|
5709
|
+
/**
|
|
5710
|
+
* Build an occupancy snapshot from parked-object registry entries alone —
|
|
5711
|
+
* used when no live inference frame is available (fresh respawn, camera
|
|
5712
|
+
* detached). Each entry is folded into the frame aggregate AND attributed to
|
|
5713
|
+
* the zones its normalised bbox centroid falls inside (via
|
|
5714
|
+
* {@link computeStationaryEntryZones}), so a zone drawn over a parked car
|
|
5715
|
+
* reports a count of 1. Returns `null` for an empty entry list. Reuses
|
|
5716
|
+
* {@link computeSnapshot} — the SAME aggregation the live frame path runs.
|
|
5717
|
+
*/
|
|
5718
|
+
function buildStationarySnapshot(input) {
|
|
5719
|
+
if (input.entries.length === 0) return null;
|
|
5720
|
+
const tracked = input.entries.map((e) => ({
|
|
5721
|
+
trackId: `stationary:${e.id}`,
|
|
5722
|
+
className: e.className,
|
|
5723
|
+
zones: computeStationaryEntryZones(e, input.zones)
|
|
5724
|
+
}));
|
|
5725
|
+
const first = input.entries[0];
|
|
5726
|
+
return computeSnapshot({
|
|
5727
|
+
deviceId: input.deviceId,
|
|
5728
|
+
timestamp: input.timestamp,
|
|
5729
|
+
frameWidth: first.frameWidth,
|
|
5730
|
+
frameHeight: first.frameHeight,
|
|
5731
|
+
tracked,
|
|
5732
|
+
zones: input.zones,
|
|
5733
|
+
stationaryObjects: input.entries
|
|
5734
|
+
});
|
|
5735
|
+
}
|
|
4888
5736
|
//#endregion
|
|
4889
5737
|
//#region src/pipeline-analytics/audio-metrics-provider.ts
|
|
4890
5738
|
var AUDIO_METRICS_CAP_NAME = "audio-metrics";
|
|
@@ -5458,7 +6306,18 @@ var MediaSettingsSchema = require_dist.object({
|
|
|
5458
6306
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
5459
6307
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
5460
6308
|
* has elapsed since its previous one. */
|
|
5461
|
-
snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
|
|
6309
|
+
snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3),
|
|
6310
|
+
/** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
|
|
6311
|
+
* elapsed, a fresh snapshot is only captured when the track's centroid moved
|
|
6312
|
+
* at least this fraction of the frame DIAGONAL since the last captured
|
|
6313
|
+
* snapshot. Suppresses near-identical frames from a long-lived / stationary
|
|
6314
|
+
* track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
|
|
6315
|
+
* frame diagonal (~66px on 1080p). */
|
|
6316
|
+
snapshotMovementThreshold: require_dist.number().min(0).max(1).default(.03),
|
|
6317
|
+
/** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
|
|
6318
|
+
* still-present track after this much wall-clock without one, so its
|
|
6319
|
+
* filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
|
|
6320
|
+
snapshotMaxIdleMs: require_dist.number().int().min(1e3).max(6e5).default(3e4)
|
|
5462
6321
|
});
|
|
5463
6322
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
5464
6323
|
/**
|
|
@@ -5473,7 +6332,148 @@ function resolveMediaSettings(raw) {
|
|
|
5473
6332
|
return {
|
|
5474
6333
|
cropPadding: pick("cropPadding"),
|
|
5475
6334
|
saveThumbnails: pick("saveThumbnails"),
|
|
5476
|
-
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
6335
|
+
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
6336
|
+
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
6337
|
+
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
6338
|
+
};
|
|
6339
|
+
}
|
|
6340
|
+
function centroidOf(b) {
|
|
6341
|
+
return {
|
|
6342
|
+
x: b.x + b.w / 2,
|
|
6343
|
+
y: b.y + b.h / 2
|
|
6344
|
+
};
|
|
6345
|
+
}
|
|
6346
|
+
/** Centroid displacement between two boxes as a fraction of the frame diagonal.
|
|
6347
|
+
* Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
|
|
6348
|
+
* to pure-time behaviour instead of dividing by zero. */
|
|
6349
|
+
function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
6350
|
+
const diag = Math.hypot(frameWidth, frameHeight);
|
|
6351
|
+
if (diag <= 0) return 0;
|
|
6352
|
+
const ca = centroidOf(a);
|
|
6353
|
+
const cb = centroidOf(b);
|
|
6354
|
+
return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
|
|
6355
|
+
}
|
|
6356
|
+
/**
|
|
6357
|
+
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
6358
|
+
* frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
|
|
6359
|
+
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
6360
|
+
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
6361
|
+
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
6362
|
+
* until the object moves.
|
|
6363
|
+
*/
|
|
6364
|
+
function evaluatePeriodicSnapshot(input) {
|
|
6365
|
+
const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
|
|
6366
|
+
if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
|
|
6367
|
+
if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
|
|
6368
|
+
const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6369
|
+
if (fastMoved >= .08) return {
|
|
6370
|
+
capture: true,
|
|
6371
|
+
reason: "fast-mover",
|
|
6372
|
+
movedFraction: fastMoved
|
|
6373
|
+
};
|
|
6374
|
+
}
|
|
6375
|
+
return {
|
|
6376
|
+
capture: false,
|
|
6377
|
+
reason: "interval-not-elapsed",
|
|
6378
|
+
movedFraction: 0
|
|
6379
|
+
};
|
|
6380
|
+
}
|
|
6381
|
+
if (lastSnapshotBbox === void 0) return {
|
|
6382
|
+
capture: true,
|
|
6383
|
+
reason: "no-reference",
|
|
6384
|
+
movedFraction: 0
|
|
6385
|
+
};
|
|
6386
|
+
const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6387
|
+
if (movedFraction >= movementThreshold) return {
|
|
6388
|
+
capture: true,
|
|
6389
|
+
reason: "moved",
|
|
6390
|
+
movedFraction
|
|
6391
|
+
};
|
|
6392
|
+
const idleLimit = Math.max(maxIdleMs, intervalMs);
|
|
6393
|
+
if (now - lastSnapshotAt >= idleLimit) return {
|
|
6394
|
+
capture: true,
|
|
6395
|
+
reason: "idle-forced",
|
|
6396
|
+
movedFraction
|
|
6397
|
+
};
|
|
6398
|
+
return {
|
|
6399
|
+
capture: false,
|
|
6400
|
+
reason: "stationary-skip",
|
|
6401
|
+
movedFraction
|
|
6402
|
+
};
|
|
6403
|
+
}
|
|
6404
|
+
//#endregion
|
|
6405
|
+
//#region src/pipeline-analytics/periodic-media-plan.ts
|
|
6406
|
+
/**
|
|
6407
|
+
* Decide the periodic media writes for one track on one frame. Pure: no side
|
|
6408
|
+
* effects. The caller advances its own `lastFrameAt` clock only when the
|
|
6409
|
+
* returned `rollingLastFrame` is true.
|
|
6410
|
+
*
|
|
6411
|
+
* INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
|
|
6412
|
+
* rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
|
|
6413
|
+
* can never duplicate one.
|
|
6414
|
+
*/
|
|
6415
|
+
function planPeriodicMedia(input) {
|
|
6416
|
+
const appendSnapshot = input.dueSnapshot;
|
|
6417
|
+
return {
|
|
6418
|
+
appendSnapshot,
|
|
6419
|
+
rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
|
|
6420
|
+
bestThumbnail: input.isNewBest
|
|
6421
|
+
};
|
|
6422
|
+
}
|
|
6423
|
+
//#endregion
|
|
6424
|
+
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
6425
|
+
/**
|
|
6426
|
+
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
6427
|
+
* (Design B — one native full-frame per track at its best-detection moment).
|
|
6428
|
+
*
|
|
6429
|
+
* ## Why this exists (the missing native keyFrame)
|
|
6430
|
+
*
|
|
6431
|
+
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
6432
|
+
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
6433
|
+
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
6434
|
+
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
6435
|
+
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
6436
|
+
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
6437
|
+
*
|
|
6438
|
+
* The fix decouples the `keyFrame` from the clip path: it is captured on the
|
|
6439
|
+
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
6440
|
+
* `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
|
|
6441
|
+
* `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
|
|
6442
|
+
* retained NATIVE surface and only falls back to the detection frame on a miss).
|
|
6443
|
+
* A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
|
|
6444
|
+
* LARGER than the detection raster (up to the cap), which is the whole point of
|
|
6445
|
+
* the `keyFrame` kind.
|
|
6446
|
+
*/
|
|
6447
|
+
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
6448
|
+
* Native resolution is the point, but a full 4K RGB surface over the transport
|
|
6449
|
+
* per new-best is wasteful for a web detail view — 1920px keeps a sharp native
|
|
6450
|
+
* frame while bounding the copy (a miss falls back to the detection-res frame,
|
|
6451
|
+
* which is already ≤640px). */
|
|
6452
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
6453
|
+
/**
|
|
6454
|
+
* The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
|
|
6455
|
+
* the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
|
|
6456
|
+
* downstream keeps one `keyFrame` per track (the current peak).
|
|
6457
|
+
*/
|
|
6458
|
+
function selectKeyFrameTrackIds(targets) {
|
|
6459
|
+
return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
|
|
6460
|
+
}
|
|
6461
|
+
/**
|
|
6462
|
+
* Build the `captureCrop` request for a track's native `keyFrame`: the FULL
|
|
6463
|
+
* frame (no padding) at the native width cap. The full-frame box is what makes
|
|
6464
|
+
* the capture route through the native surface at native resolution instead of
|
|
6465
|
+
* a tight ≤640 detection crop.
|
|
6466
|
+
*/
|
|
6467
|
+
function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
|
|
6468
|
+
return {
|
|
6469
|
+
bbox: {
|
|
6470
|
+
x: 0,
|
|
6471
|
+
y: 0,
|
|
6472
|
+
w: frameWidth,
|
|
6473
|
+
h: frameHeight
|
|
6474
|
+
},
|
|
6475
|
+
padding: 0,
|
|
6476
|
+
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
|
|
5477
6477
|
};
|
|
5478
6478
|
}
|
|
5479
6479
|
//#endregion
|
|
@@ -6810,6 +7810,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
|
6810
7810
|
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
6811
7811
|
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
6812
7812
|
/**
|
|
7813
|
+
* Consecutive frame-plane misses ("frame + crop both missed") after which a step
|
|
7814
|
+
* is ABANDONED for the track. The decode worker serves native crops from a RAM
|
|
7815
|
+
* lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
|
|
7816
|
+
* from an evicted handle and is a guaranteed miss forever. Retrying a
|
|
7817
|
+
* permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
|
|
7818
|
+
* consecutive misses (each ≥ one tick apart) confidently means the frame is gone
|
|
7819
|
+
* for good, while still tolerating a single transient decode-worker hiccup /
|
|
7820
|
+
* respawn on a genuinely live track (the counter resets on any resolved result).
|
|
7821
|
+
*/
|
|
7822
|
+
var MAX_CONSECUTIVE_FRAME_MISSES = 3;
|
|
7823
|
+
/**
|
|
6813
7824
|
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
6814
7825
|
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
6815
7826
|
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
@@ -6830,7 +7841,9 @@ var DetailScheduler = class {
|
|
|
6830
7841
|
firedCount: 1,
|
|
6831
7842
|
lastFiredAt: nowMs,
|
|
6832
7843
|
sticky: false,
|
|
6833
|
-
retryPending: false
|
|
7844
|
+
retryPending: false,
|
|
7845
|
+
consecutiveFrameMisses: 0,
|
|
7846
|
+
abandoned: false
|
|
6834
7847
|
};
|
|
6835
7848
|
steps.set(stepAnnounce.stepId, state);
|
|
6836
7849
|
requests.push({
|
|
@@ -6863,7 +7876,7 @@ var DetailScheduler = class {
|
|
|
6863
7876
|
tick(nowMs) {
|
|
6864
7877
|
const requests = [];
|
|
6865
7878
|
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
6866
|
-
if (state.sticky) continue;
|
|
7879
|
+
if (state.sticky || state.abandoned) continue;
|
|
6867
7880
|
if (state.retryPending) {
|
|
6868
7881
|
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
6869
7882
|
if (!this.underMaxPerTrack(state)) {
|
|
@@ -6901,7 +7914,8 @@ var DetailScheduler = class {
|
|
|
6901
7914
|
if (!steps) return;
|
|
6902
7915
|
const state = steps.get(stepId);
|
|
6903
7916
|
if (!state) return;
|
|
6904
|
-
if (state.sticky) return;
|
|
7917
|
+
if (state.sticky || state.abandoned) return;
|
|
7918
|
+
state.consecutiveFrameMisses = 0;
|
|
6905
7919
|
const { stickyOnConfidence } = state.announce.cadence;
|
|
6906
7920
|
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
6907
7921
|
state.sticky = true;
|
|
@@ -6916,11 +7930,37 @@ var DetailScheduler = class {
|
|
|
6916
7930
|
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6917
7931
|
}
|
|
6918
7932
|
}
|
|
7933
|
+
/**
|
|
7934
|
+
* A dispatched request could not resolve a frame AT ALL — the frame handle
|
|
7935
|
+
* lease was evicted AND the crop fallback was unavailable (the "frame + crop
|
|
7936
|
+
* both missed" outcome). This is fundamentally different from `onResult(null)`:
|
|
7937
|
+
* there the frame plane WORKED and the model merely returned nothing (worth a
|
|
7938
|
+
* retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
|
|
7939
|
+
* handle every time, so it can never recover from this request. It is
|
|
7940
|
+
* retry-eligible only for a bounded number of CONSECUTIVE attempts; after
|
|
7941
|
+
* {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
|
|
7942
|
+
* track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
|
|
7943
|
+
* is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
|
|
7944
|
+
*/
|
|
7945
|
+
onFrameMiss(trackId, stepId, _nowMs) {
|
|
7946
|
+
const steps = this.tracks.get(trackId);
|
|
7947
|
+
if (!steps) return;
|
|
7948
|
+
const state = steps.get(stepId);
|
|
7949
|
+
if (!state) return;
|
|
7950
|
+
if (state.sticky || state.abandoned) return;
|
|
7951
|
+
state.consecutiveFrameMisses += 1;
|
|
7952
|
+
if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
|
|
7953
|
+
state.abandoned = true;
|
|
7954
|
+
state.retryPending = false;
|
|
7955
|
+
return;
|
|
7956
|
+
}
|
|
7957
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
7958
|
+
}
|
|
6919
7959
|
onTrackEnded(trackId) {
|
|
6920
7960
|
this.tracks.delete(trackId);
|
|
6921
7961
|
}
|
|
6922
7962
|
canFire(state, nowMs) {
|
|
6923
|
-
if (state.sticky) return false;
|
|
7963
|
+
if (state.sticky || state.abandoned) return false;
|
|
6924
7964
|
if (!this.underMaxPerTrack(state)) return false;
|
|
6925
7965
|
return this.intervalElapsed(state, nowMs);
|
|
6926
7966
|
}
|
|
@@ -6943,7 +7983,7 @@ var DetailScheduler = class {
|
|
|
6943
7983
|
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6944
7984
|
/**
|
|
6945
7985
|
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6946
|
-
*
|
|
7986
|
+
* chain-aware for the multi-step detail subtrees.
|
|
6947
7987
|
*
|
|
6948
7988
|
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6949
7989
|
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
@@ -6952,13 +7992,35 @@ var DetailScheduler = class {
|
|
|
6952
7992
|
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6953
7993
|
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6954
7994
|
*
|
|
6955
|
-
*
|
|
6956
|
-
*
|
|
6957
|
-
* `
|
|
7995
|
+
* A `plate-detection` request ALWAYS includes `'plate-ocr'` — symmetric to the
|
|
7996
|
+
* face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
|
|
7997
|
+
* pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
|
|
7998
|
+
* plate never gets read and no plate text is ever produced. (The dispatcher
|
|
7999
|
+
* cannot import the pipeline catalog to derive the child chain — this hardcode
|
|
8000
|
+
* mirrors it; keep the two in sync when the catalog's plate subtree changes.)
|
|
8001
|
+
*
|
|
8002
|
+
* Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
8003
|
+
* strict-`steps` pruning — naming the nested child here is what keeps it in the
|
|
8004
|
+
* executed chain.
|
|
6958
8005
|
*/
|
|
6959
8006
|
function composeDetailSteps(req, hasTrackLabel) {
|
|
6960
|
-
if (req.stepId
|
|
6961
|
-
|
|
8007
|
+
if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
8008
|
+
if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
|
|
8009
|
+
return [req.stepId];
|
|
8010
|
+
}
|
|
8011
|
+
/**
|
|
8012
|
+
* Does `steps` name a nested-enrichment chain (root detector + a child that
|
|
8013
|
+
* produces a `label`/`embedding`), i.e. more than the bare root step? Used to
|
|
8014
|
+
* surface a silent enrichment miss (BUG C): a plate detected but never read, a
|
|
8015
|
+
* face detected but never embedded — the root detail still routes so the miss
|
|
8016
|
+
* is otherwise invisible. `['plate-detection']` alone is NOT a chain.
|
|
8017
|
+
*/
|
|
8018
|
+
function isEnrichmentChain(steps) {
|
|
8019
|
+
return steps.length > 1;
|
|
8020
|
+
}
|
|
8021
|
+
/** Does any returned detail carry the enrichment a chain request asked for? */
|
|
8022
|
+
function detailsCarryEnrichment(details) {
|
|
8023
|
+
return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
|
|
6962
8024
|
}
|
|
6963
8025
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6964
8026
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
@@ -7035,7 +8097,8 @@ var TrackDetailDispatcher = class {
|
|
|
7035
8097
|
queue: [],
|
|
7036
8098
|
inFlight: 0,
|
|
7037
8099
|
timer: null,
|
|
7038
|
-
lastFailWarnAt: 0
|
|
8100
|
+
lastFailWarnAt: 0,
|
|
8101
|
+
lastEnrichWarnAt: 0
|
|
7039
8102
|
};
|
|
7040
8103
|
this.devices.set(deviceId, dev);
|
|
7041
8104
|
}
|
|
@@ -7078,9 +8141,15 @@ var TrackDetailDispatcher = class {
|
|
|
7078
8141
|
}
|
|
7079
8142
|
async dispatch(deviceId, dev, req, frame) {
|
|
7080
8143
|
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
8144
|
+
if (details === null) {
|
|
8145
|
+
dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
|
|
8146
|
+
return;
|
|
8147
|
+
}
|
|
7081
8148
|
let topScore = null;
|
|
7082
|
-
if (details
|
|
8149
|
+
if (details.length > 0) {
|
|
7083
8150
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
8151
|
+
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
8152
|
+
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
7084
8153
|
try {
|
|
7085
8154
|
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7086
8155
|
} catch (err) {
|
|
@@ -7162,6 +8231,20 @@ var TrackDetailDispatcher = class {
|
|
|
7162
8231
|
}
|
|
7163
8232
|
});
|
|
7164
8233
|
}
|
|
8234
|
+
warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
|
|
8235
|
+
const now = Date.now();
|
|
8236
|
+
if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
8237
|
+
dev.lastEnrichWarnAt = now;
|
|
8238
|
+
this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
|
|
8239
|
+
tags: { deviceId },
|
|
8240
|
+
meta: {
|
|
8241
|
+
trackId: req.trackId,
|
|
8242
|
+
stepId: req.stepId,
|
|
8243
|
+
reason: req.reason,
|
|
8244
|
+
steps
|
|
8245
|
+
}
|
|
8246
|
+
});
|
|
8247
|
+
}
|
|
7165
8248
|
};
|
|
7166
8249
|
//#endregion
|
|
7167
8250
|
//#region src/pipeline-analytics/overlay-state.ts
|
|
@@ -7999,38 +9082,68 @@ var PlateRecognizer = class {
|
|
|
7999
9082
|
name
|
|
8000
9083
|
} : null;
|
|
8001
9084
|
}
|
|
8002
|
-
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
8003
|
-
* the raw OCR text
|
|
9085
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
9086
|
+
* else the raw OCR text. Returns `null` for an implausible read (junk OCR
|
|
9087
|
+
* off a distant/oblique plate) — the caller must NOT stamp a label then. */
|
|
8004
9088
|
resolveLabel(text, score) {
|
|
9089
|
+
if (!isPlausiblePlateRead(text, score)) return null;
|
|
8005
9090
|
return this.matchVehicle(text, score)?.name ?? text;
|
|
8006
9091
|
}
|
|
8007
9092
|
async processFrame(input) {
|
|
8008
9093
|
const minConfidence = input.minConfidence ?? 0;
|
|
8009
9094
|
const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
|
|
8010
9095
|
if (candidates.length === 0) return;
|
|
8011
|
-
for (const c of candidates) {
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
9096
|
+
for (const c of candidates) await this.holdBest({
|
|
9097
|
+
deviceId: input.deviceId,
|
|
9098
|
+
trackId: c.trackId,
|
|
9099
|
+
text: c.plateText,
|
|
9100
|
+
score: c.plateScore,
|
|
9101
|
+
bbox: c.plateBbox,
|
|
9102
|
+
timestamp: input.timestamp,
|
|
9103
|
+
frameWidth: input.frameWidth,
|
|
9104
|
+
frameHeight: input.frameHeight,
|
|
9105
|
+
cropPadding: input.cropPadding,
|
|
9106
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
9107
|
+
});
|
|
9108
|
+
}
|
|
9109
|
+
/**
|
|
9110
|
+
* Detail-plane entry point (two-plane design): plate-ocr runs on demand per
|
|
9111
|
+
* track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
|
|
9112
|
+
* never lands on the per-frame `tracked[]` that {@link processFrame} scans.
|
|
9113
|
+
* The dispatcher's result router calls this with each plate detail so the
|
|
9114
|
+
* gallery still collects the best read + tight crop (persisted on
|
|
9115
|
+
* {@link onTrackEnd}). Without it the plate label rides the event but the
|
|
9116
|
+
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
9117
|
+
*/
|
|
9118
|
+
async observePlateRead(input) {
|
|
9119
|
+
if (!isPlausiblePlateRead(input.text, input.score)) return;
|
|
9120
|
+
if (input.score < (input.minConfidence ?? 0)) return;
|
|
9121
|
+
await this.holdBest(input);
|
|
9122
|
+
}
|
|
9123
|
+
/** Hold the highest-scoring plate read per track, capturing a tight crop the
|
|
9124
|
+
* first time a new best is seen (shared by the per-frame + detail-plane paths). */
|
|
9125
|
+
async holdBest(input) {
|
|
9126
|
+
const held = this.bestPlate.get(input.trackId);
|
|
9127
|
+
if (held !== void 0 && input.score <= held.score) return;
|
|
9128
|
+
let crop;
|
|
9129
|
+
if (input.frameHandle !== void 0) try {
|
|
9130
|
+
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
9131
|
+
} catch (err) {
|
|
9132
|
+
this.deps.logger.debug("PlateRecognizer crop capture failed", {
|
|
9133
|
+
tags: { deviceId: input.deviceId },
|
|
9134
|
+
meta: {
|
|
9135
|
+
trackId: input.trackId,
|
|
9136
|
+
error: String(err)
|
|
9137
|
+
}
|
|
8032
9138
|
});
|
|
8033
9139
|
}
|
|
9140
|
+
this.bestPlate.set(input.trackId, {
|
|
9141
|
+
text: input.text,
|
|
9142
|
+
score: input.score,
|
|
9143
|
+
bbox: input.bbox,
|
|
9144
|
+
timestamp: input.timestamp,
|
|
9145
|
+
...crop !== void 0 ? { crop } : {}
|
|
9146
|
+
});
|
|
8034
9147
|
}
|
|
8035
9148
|
/** Persist the held best plate for a finished track as one PlateStore row
|
|
8036
9149
|
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
|
|
@@ -8366,6 +9479,40 @@ function classifyAudioFrame(top, cfg) {
|
|
|
8366
9479
|
//#endregion
|
|
8367
9480
|
//#region src/pipeline-analytics/event-media-handler.ts
|
|
8368
9481
|
var CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
9482
|
+
/** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
|
|
9483
|
+
* `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
|
|
9484
|
+
var THUMB_DEFAULT_SIZE = 160;
|
|
9485
|
+
var THUMB_MIN_SIZE$1 = 64;
|
|
9486
|
+
var THUMB_MAX_SIZE$1 = 320;
|
|
9487
|
+
/**
|
|
9488
|
+
* Parse the `?kind=…` query into a preferred stored media kind. Returns null
|
|
9489
|
+
* when unset. The value is a free-form kind token (e.g. `crop`); the resolver
|
|
9490
|
+
* validates it against the known kinds.
|
|
9491
|
+
*/
|
|
9492
|
+
function parseEventMediaKind(query) {
|
|
9493
|
+
const kind = new URLSearchParams(query).get("kind");
|
|
9494
|
+
return kind !== null && kind.length > 0 ? kind : null;
|
|
9495
|
+
}
|
|
9496
|
+
/**
|
|
9497
|
+
* Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
|
|
9498
|
+
* when no small-square rendering was requested (the caller then serves the
|
|
9499
|
+
* stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
|
|
9500
|
+
* `size` / `w` / `h` (clamped to [64, 320], default 160).
|
|
9501
|
+
*/
|
|
9502
|
+
function parseEventMediaVariant(query) {
|
|
9503
|
+
const params = new URLSearchParams(query);
|
|
9504
|
+
if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
|
|
9505
|
+
const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
|
|
9506
|
+
let size = THUMB_DEFAULT_SIZE;
|
|
9507
|
+
if (sizeRaw !== null) {
|
|
9508
|
+
const n = Number.parseInt(sizeRaw, 10);
|
|
9509
|
+
if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
|
|
9510
|
+
}
|
|
9511
|
+
return {
|
|
9512
|
+
kind: "thumb",
|
|
9513
|
+
size
|
|
9514
|
+
};
|
|
9515
|
+
}
|
|
8369
9516
|
/**
|
|
8370
9517
|
* Create a data-plane handler that serves event thumbnails as JPEG images.
|
|
8371
9518
|
*
|
|
@@ -8379,14 +9526,20 @@ function createEventMediaHandler(deps) {
|
|
|
8379
9526
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
8380
9527
|
return;
|
|
8381
9528
|
}
|
|
8382
|
-
const
|
|
9529
|
+
const url = req.url ?? "/";
|
|
9530
|
+
const qIdx = url.indexOf("?");
|
|
9531
|
+
const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
9532
|
+
const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
|
|
9533
|
+
const eventId = rawPath.replace(/^\/+/, "");
|
|
8383
9534
|
if (!eventId || eventId.includes("/")) {
|
|
8384
9535
|
res.writeHead(404).end();
|
|
8385
9536
|
return;
|
|
8386
9537
|
}
|
|
9538
|
+
const variant = parseEventMediaVariant(query);
|
|
9539
|
+
const preferKind = parseEventMediaKind(query);
|
|
8387
9540
|
let media = null;
|
|
8388
9541
|
try {
|
|
8389
|
-
media = await deps.getMedia(eventId);
|
|
9542
|
+
media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
|
|
8390
9543
|
} catch {
|
|
8391
9544
|
const body = "Internal server error";
|
|
8392
9545
|
res.writeHead(500, {
|
|
@@ -8419,6 +9572,27 @@ function createEventMediaHandler(deps) {
|
|
|
8419
9572
|
else res.end(Buffer.from(media.bytes));
|
|
8420
9573
|
};
|
|
8421
9574
|
}
|
|
9575
|
+
/** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
|
|
9576
|
+
var THUMB_QUALITY = 70;
|
|
9577
|
+
/**
|
|
9578
|
+
* Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
|
|
9579
|
+
* stored 640×360 `crop`). Center-crop cover to a square then downscale to
|
|
9580
|
+
* `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
|
|
9581
|
+
* showing the object, not the full 16:9 crop. Output is a fraction of the source
|
|
9582
|
+
* (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
|
|
9583
|
+
* from tiny HTTP-cached tiles instead of full base64 payloads.
|
|
9584
|
+
*
|
|
9585
|
+
* `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
|
|
9586
|
+
* crops the overflow symmetrically — the center square of a square-safe crop
|
|
9587
|
+
* fully contains the detector bbox, so the object stays framed.
|
|
9588
|
+
*/
|
|
9589
|
+
async function makeSquareThumb(bytes, size) {
|
|
9590
|
+
const edge = Math.max(64, Math.min(320, Math.round(size)));
|
|
9591
|
+
return (0, sharp.default)(Buffer.from(bytes)).resize(edge, edge, {
|
|
9592
|
+
fit: "cover",
|
|
9593
|
+
position: "centre"
|
|
9594
|
+
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
9595
|
+
}
|
|
8422
9596
|
//#endregion
|
|
8423
9597
|
//#region src/pipeline-analytics/index.ts
|
|
8424
9598
|
/**
|
|
@@ -8441,18 +9615,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
|
8441
9615
|
* before re-reading. */
|
|
8442
9616
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
8443
9617
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
9618
|
+
/** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
|
|
9619
|
+
* The `device.state-changed` push doesn't reliably reach a forked child, so
|
|
9620
|
+
* each cached proxy re-pulls both slices on this timer (see ensureProxy) —
|
|
9621
|
+
* a zone drawn in the editor shows up in per-zone stats within one tick. */
|
|
9622
|
+
var ZONE_SLICE_RECONCILE_MS = 3e4;
|
|
8444
9623
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
8445
9624
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
8446
9625
|
* so jitter around a plateau doesn't churn the write). */
|
|
8447
9626
|
var BEST_FRAME_HYSTERESIS = .05;
|
|
8448
9627
|
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
8449
9628
|
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
8450
|
-
/** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
|
|
8451
|
-
* capture). Native resolution is the point, but a full 4K RGB surface over the
|
|
8452
|
-
* transport per new-best is wasteful for a web detail view — 1920px keeps a
|
|
8453
|
-
* sharp native frame while bounding the copy (a miss falls back to the
|
|
8454
|
-
* detection-res frame, which is already ≤640px). */
|
|
8455
|
-
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
8456
9629
|
/** getKeyEvents: max completed tracks pulled from a window before importance
|
|
8457
9630
|
* ranking. Ordering is by importance (not firstSeen) and legacy rows score on
|
|
8458
9631
|
* read, so we over-fetch candidates and trim to `limit` after sorting. */
|
|
@@ -8475,6 +9648,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
8475
9648
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
8476
9649
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
8477
9650
|
/**
|
|
9651
|
+
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
9652
|
+
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
9653
|
+
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
9654
|
+
* `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
9655
|
+
*/
|
|
9656
|
+
var CLEAN_MEDIA_KINDS = [
|
|
9657
|
+
"crop",
|
|
9658
|
+
"fullFrame",
|
|
9659
|
+
"keyFrame"
|
|
9660
|
+
];
|
|
9661
|
+
/**
|
|
9662
|
+
* Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
|
|
9663
|
+
* `preferKind` if it is itself clean and present, else the first available
|
|
9664
|
+
* {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
|
|
9665
|
+
* exists (caller 404s → the viewer shows an icon).
|
|
9666
|
+
*/
|
|
9667
|
+
function pickCleanMedia(files, preferKind) {
|
|
9668
|
+
const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
|
|
9669
|
+
if (isClean(preferKind)) {
|
|
9670
|
+
const exact = files.find((f) => f.kind === preferKind);
|
|
9671
|
+
if (exact) return exact;
|
|
9672
|
+
}
|
|
9673
|
+
for (const kind of CLEAN_MEDIA_KINDS) {
|
|
9674
|
+
const found = files.find((f) => f.kind === kind);
|
|
9675
|
+
if (found) return found;
|
|
9676
|
+
}
|
|
9677
|
+
}
|
|
9678
|
+
/**
|
|
8478
9679
|
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
8479
9680
|
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
8480
9681
|
*/
|
|
@@ -8525,6 +9726,10 @@ function stripGlobalOnlyFields(sections) {
|
|
|
8525
9726
|
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
8526
9727
|
processors = /* @__PURE__ */ new Map();
|
|
8527
9728
|
trackStore = null;
|
|
9729
|
+
/** Parked-object registry: promotes a track that stopped moving into a
|
|
9730
|
+
* lightweight entry, suppresses its detections from re-spawning tracks, and
|
|
9731
|
+
* wakes it when the object departs. Null until onInitialize. */
|
|
9732
|
+
stationaryRegistry = null;
|
|
8528
9733
|
mediaStore = null;
|
|
8529
9734
|
eventStore = null;
|
|
8530
9735
|
identityStore = null;
|
|
@@ -8550,9 +9755,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8550
9755
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
8551
9756
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
8552
9757
|
eventMediaDispatcher = null;
|
|
8553
|
-
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
8554
|
-
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
8555
|
-
frameReaders = null;
|
|
8556
9758
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
8557
9759
|
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8558
9760
|
* detection it resolves the frame, crops the ROI, and calls the
|
|
@@ -8589,6 +9791,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8589
9791
|
* dataPlane facility in the current environment). */
|
|
8590
9792
|
eventMediaBaseUrl = null;
|
|
8591
9793
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
9794
|
+
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
8592
9795
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
8593
9796
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
8594
9797
|
levelStateByDevice = /* @__PURE__ */ new Map();
|
|
@@ -8620,10 +9823,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8620
9823
|
* cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
|
|
8621
9824
|
objectEmbeddingBestSelector = new TrackBestSelector();
|
|
8622
9825
|
/** Design B: the track's shared native key-frame media key, captured at the
|
|
8623
|
-
* best-detection moment (
|
|
8624
|
-
*
|
|
8625
|
-
* track end. */
|
|
9826
|
+
* best-detection moment (general best-frame path). Read by the face / plate /
|
|
9827
|
+
* object-embedding rows so they LINK the SAME single native key frame.
|
|
9828
|
+
* Cleared on track end. */
|
|
8626
9829
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
9830
|
+
/** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
|
|
9831
|
+
* rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
|
|
9832
|
+
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
9833
|
+
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
9834
|
+
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
8627
9835
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8628
9836
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8629
9837
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -8674,11 +9882,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8674
9882
|
await PlateStore.declare(api.settingsStore);
|
|
8675
9883
|
await VehicleStore.declare(api.settingsStore);
|
|
8676
9884
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
9885
|
+
await StationaryObjectRegistry.declare(api.settingsStore);
|
|
8677
9886
|
const logger = this.ctx.logger;
|
|
8678
9887
|
let storage = this.ctx.kernel.storage;
|
|
8679
9888
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
8680
9889
|
if (mediaRoot) {
|
|
8681
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
9890
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BFF5_uIc.js"));
|
|
8682
9891
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
8683
9892
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
8684
9893
|
}
|
|
@@ -8687,6 +9896,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8687
9896
|
store: api.settingsStore,
|
|
8688
9897
|
logger: logger.child("TrackStore")
|
|
8689
9898
|
});
|
|
9899
|
+
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9900
|
+
store: api.settingsStore,
|
|
9901
|
+
logger: logger.child("StationaryRegistry"),
|
|
9902
|
+
onChange: ({ phase, entry, timestamp }) => {
|
|
9903
|
+
this.ctx.eventBus.emit({
|
|
9904
|
+
id: `pa-stationary-${entry.id}-${phase}`,
|
|
9905
|
+
timestamp: new Date(timestamp),
|
|
9906
|
+
source: {
|
|
9907
|
+
type: "addon",
|
|
9908
|
+
id: "pipeline-analytics",
|
|
9909
|
+
addonId: "pipeline-analytics"
|
|
9910
|
+
},
|
|
9911
|
+
category: require_dist.EventCategory.PipelineAnalyticsStationaryChanged,
|
|
9912
|
+
data: {
|
|
9913
|
+
deviceId: entry.deviceId,
|
|
9914
|
+
entryId: entry.id,
|
|
9915
|
+
className: entry.className,
|
|
9916
|
+
phase,
|
|
9917
|
+
timestamp
|
|
9918
|
+
}
|
|
9919
|
+
});
|
|
9920
|
+
}
|
|
9921
|
+
});
|
|
9922
|
+
await this.stationaryRegistry.load();
|
|
8690
9923
|
this.mediaStore = new MediaStore({
|
|
8691
9924
|
storage,
|
|
8692
9925
|
store: api.settingsStore,
|
|
@@ -8723,33 +9956,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8723
9956
|
designatedNode: designated
|
|
8724
9957
|
} });
|
|
8725
9958
|
}
|
|
8726
|
-
|
|
8727
|
-
const decoderApi = api.decoder;
|
|
9959
|
+
const pipelineRunnerApi = api.pipelineRunner;
|
|
8728
9960
|
const getRemoteFrame = async (handle) => {
|
|
8729
|
-
if (!
|
|
8730
|
-
const
|
|
9961
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
9962
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
8731
9963
|
handle,
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
9964
|
+
bbox: {
|
|
9965
|
+
x: 0,
|
|
9966
|
+
y: 0,
|
|
9967
|
+
w: 1,
|
|
9968
|
+
h: 1
|
|
9969
|
+
},
|
|
9970
|
+
maxWidth: handle.width
|
|
9971
|
+
}, require_dist.nodePin(handle.nodeId));
|
|
9972
|
+
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
8735
9973
|
return {
|
|
8736
|
-
data: Buffer.from(
|
|
8737
|
-
width:
|
|
8738
|
-
height:
|
|
8739
|
-
format:
|
|
8740
|
-
timestamp:
|
|
9974
|
+
data: Buffer.from(full.bytes),
|
|
9975
|
+
width: full.width,
|
|
9976
|
+
height: full.height,
|
|
9977
|
+
format: "rgb",
|
|
9978
|
+
timestamp: 0
|
|
8741
9979
|
};
|
|
8742
9980
|
};
|
|
8743
9981
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
8744
|
-
ownNodeId,
|
|
8745
|
-
readers: this.frameReaders,
|
|
8746
9982
|
getRemoteFrame,
|
|
8747
9983
|
mediaStore: this.mediaStore,
|
|
8748
9984
|
logger: logger.child("EventMediaDispatcher")
|
|
8749
9985
|
});
|
|
8750
|
-
const ownNodeIdForFaces = ownNodeId;
|
|
8751
|
-
const frameReadersForFaces = this.frameReaders;
|
|
8752
|
-
const pipelineRunnerApi = api.pipelineRunner;
|
|
8753
9986
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
8754
9987
|
let nativeHits = 0;
|
|
8755
9988
|
let nativeFallbacks = 0;
|
|
@@ -8781,11 +10014,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8781
10014
|
return null;
|
|
8782
10015
|
}
|
|
8783
10016
|
};
|
|
8784
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
8785
|
-
ownNodeId: ownNodeIdForFaces,
|
|
8786
|
-
readers: frameReadersForFaces,
|
|
8787
|
-
getRemoteFrame
|
|
8788
|
-
}));
|
|
10017
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
8789
10018
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
8790
10019
|
const paddedNorm = padBbox({
|
|
8791
10020
|
x: bbox.x / frameWidth,
|
|
@@ -8865,7 +10094,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8865
10094
|
});
|
|
8866
10095
|
this.zoneAnalytics = new ZoneAnalyticsProvider({
|
|
8867
10096
|
logger: logger.child("ZoneAnalytics"),
|
|
8868
|
-
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
|
|
10097
|
+
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
10098
|
+
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
10099
|
+
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
10100
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
|
|
8869
10101
|
});
|
|
8870
10102
|
this.audioMetrics = new AudioMetricsProvider({
|
|
8871
10103
|
logger: logger.child("AudioMetrics"),
|
|
@@ -8881,9 +10113,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8881
10113
|
}
|
|
8882
10114
|
});
|
|
8883
10115
|
try {
|
|
8884
|
-
const handler = createEventMediaHandler({ getMedia: async (id) => {
|
|
10116
|
+
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
8885
10117
|
try {
|
|
8886
|
-
return await this.readMediaByEventOrKey(id);
|
|
10118
|
+
return await this.readMediaByEventOrKey(id, variant, preferKind);
|
|
8887
10119
|
} catch (err) {
|
|
8888
10120
|
this.ctx.logger.warn("readEventThumbnail failed", { meta: {
|
|
8889
10121
|
eventId: id,
|
|
@@ -8941,8 +10173,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8941
10173
|
encoder: encoderClient,
|
|
8942
10174
|
eventBus: this.ctx.eventBus,
|
|
8943
10175
|
logger: logger.child("EmbeddingDispatcher"),
|
|
8944
|
-
ownNodeId,
|
|
8945
|
-
readers: frameReadersForFaces,
|
|
8946
10176
|
getRemoteFrame
|
|
8947
10177
|
});
|
|
8948
10178
|
await this.embeddingDispatcher.start();
|
|
@@ -8953,6 +10183,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8953
10183
|
this.bindingCache?.onBindingsChanged(data);
|
|
8954
10184
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
8955
10185
|
this.trackStore?.clearDevice(data.deviceId);
|
|
10186
|
+
this.stationaryRegistry?.forgetDevice(data.deviceId);
|
|
8956
10187
|
this.overlayState.clearDevice(data.deviceId);
|
|
8957
10188
|
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
8958
10189
|
this.forgetDeviceProcessors(data.deviceId);
|
|
@@ -8967,6 +10198,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8967
10198
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
8968
10199
|
const { deviceId } = ev.data;
|
|
8969
10200
|
this.trackStore?.clearDevice(deviceId);
|
|
10201
|
+
this.stationaryRegistry?.forgetDevice(deviceId);
|
|
8970
10202
|
this.overlayState.clearDevice(deviceId);
|
|
8971
10203
|
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
8972
10204
|
this.forgetDeviceProcessors(deviceId);
|
|
@@ -8987,6 +10219,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8987
10219
|
this.retentionSweepTimer = setInterval(() => {
|
|
8988
10220
|
this.sweepRetention();
|
|
8989
10221
|
this.runTrackRetentionSweep();
|
|
10222
|
+
this.stationaryRegistry?.sweep(Date.now());
|
|
8990
10223
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
8991
10224
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
8992
10225
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -9307,8 +10540,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9307
10540
|
this.overlaySynthesisWarnAt.clear();
|
|
9308
10541
|
this.processors.clear();
|
|
9309
10542
|
this.lastActiveTrackIds.clear();
|
|
10543
|
+
this.lastFrameDimsByDevice.clear();
|
|
9310
10544
|
this.dropoutSkipsByKey.clear();
|
|
9311
10545
|
this.bestFrameTracker.clear();
|
|
10546
|
+
this.lastFrameAtByTrack.clear();
|
|
9312
10547
|
this.trackLifecycleUpdateMem.clear();
|
|
9313
10548
|
this.objectEmbeddingBestSelector.clear();
|
|
9314
10549
|
this.levelStateByDevice.clear();
|
|
@@ -9319,13 +10554,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9319
10554
|
this.faceGlobalEnabledCache = null;
|
|
9320
10555
|
this.mediaCacheByDevice.clear();
|
|
9321
10556
|
this.trackStore?.clearAll();
|
|
10557
|
+
this.stationaryRegistry = null;
|
|
9322
10558
|
this.bindingCache?.clearAll();
|
|
9323
10559
|
await this.eventMediaDataPlane?.dispose();
|
|
9324
10560
|
this.eventMediaDataPlane = null;
|
|
9325
10561
|
this.eventMediaBaseUrl = null;
|
|
9326
10562
|
this.eventMediaDispatcher = null;
|
|
9327
|
-
this.frameReaders?.close();
|
|
9328
|
-
this.frameReaders = null;
|
|
9329
10563
|
}
|
|
9330
10564
|
async handleInferenceResult(data) {
|
|
9331
10565
|
if (this.shuttingDown) return;
|
|
@@ -9373,22 +10607,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9373
10607
|
timestamp: frame.timestamp,
|
|
9374
10608
|
frame
|
|
9375
10609
|
});
|
|
10610
|
+
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
10611
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
10612
|
+
deviceId,
|
|
10613
|
+
confirmed: result.stationaryConfirmed,
|
|
10614
|
+
wokenEntryIds: result.stationaryWoken,
|
|
10615
|
+
timestamp: result.timestamp
|
|
10616
|
+
});
|
|
10617
|
+
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
10618
|
+
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10619
|
+
trackId: `stationary:${v.id}`,
|
|
10620
|
+
className: v.className,
|
|
10621
|
+
zones: computeStationaryEntryZones(v, liveZones)
|
|
10622
|
+
}));
|
|
9376
10623
|
this.zoneAnalytics?.recordFrame({
|
|
9377
10624
|
deviceId,
|
|
9378
10625
|
timestamp: result.timestamp,
|
|
9379
10626
|
frameWidth: result.frameWidth,
|
|
9380
10627
|
frameHeight: result.frameHeight,
|
|
9381
|
-
tracked: result.tracked,
|
|
9382
|
-
zones: liveZones
|
|
10628
|
+
tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
|
|
10629
|
+
zones: liveZones,
|
|
10630
|
+
...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
|
|
10631
|
+
});
|
|
10632
|
+
if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
|
|
10633
|
+
w: result.frameWidth,
|
|
10634
|
+
h: result.frameHeight
|
|
9383
10635
|
});
|
|
9384
10636
|
const currentTrackIds = /* @__PURE__ */ new Set();
|
|
10637
|
+
const positionsCountById = /* @__PURE__ */ new Map();
|
|
9385
10638
|
for (const t of result.tracked) {
|
|
9386
10639
|
currentTrackIds.add(t.trackId);
|
|
9387
10640
|
const center = {
|
|
9388
10641
|
x: t.bbox.x + t.bbox.w / 2,
|
|
9389
10642
|
y: t.bbox.y + t.bbox.h / 2
|
|
9390
10643
|
};
|
|
9391
|
-
this.trackStore.upsert({
|
|
10644
|
+
const upserted = this.trackStore.upsert({
|
|
9392
10645
|
trackId: t.trackId,
|
|
9393
10646
|
deviceId,
|
|
9394
10647
|
className: t.className,
|
|
@@ -9403,6 +10656,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9403
10656
|
zones: t.zones,
|
|
9404
10657
|
state: t.state
|
|
9405
10658
|
});
|
|
10659
|
+
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
9406
10660
|
}
|
|
9407
10661
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
9408
10662
|
const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -9411,6 +10665,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9411
10665
|
for (const id of currentTrackIds) if (!prevIds.has(id)) {
|
|
9412
10666
|
const t = result.tracked.find((x) => x.trackId === id);
|
|
9413
10667
|
if (t) {
|
|
10668
|
+
if (classifyTrackAppearance({
|
|
10669
|
+
inPrevActive: false,
|
|
10670
|
+
positionsCount: positionsCountById.get(id) ?? 1
|
|
10671
|
+
}) === "resurrection") {
|
|
10672
|
+
log.info("track resumed", { meta: {
|
|
10673
|
+
trackId: id,
|
|
10674
|
+
className: t.className,
|
|
10675
|
+
source,
|
|
10676
|
+
resurrected: true
|
|
10677
|
+
} });
|
|
10678
|
+
continue;
|
|
10679
|
+
}
|
|
9414
10680
|
newTrackCount += 1;
|
|
9415
10681
|
log.info("track started", { meta: {
|
|
9416
10682
|
trackId: id,
|
|
@@ -9424,7 +10690,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9424
10690
|
bbox: { ...t.bbox },
|
|
9425
10691
|
...t.label ? { label: t.label } : {}
|
|
9426
10692
|
});
|
|
9427
|
-
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
10693
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
|
|
9428
10694
|
}
|
|
9429
10695
|
this.ctx.eventBus.emit({
|
|
9430
10696
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
@@ -9470,6 +10736,34 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9470
10736
|
} });
|
|
9471
10737
|
}
|
|
9472
10738
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
10739
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
10740
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
10741
|
+
if (dims && dims.w > 0 && dims.h > 0) {
|
|
10742
|
+
const refDiag = Math.hypot(dims.w, dims.h);
|
|
10743
|
+
for (const t of result.tracked) {
|
|
10744
|
+
const active = this.trackStore.peekActive(t.trackId);
|
|
10745
|
+
if (!active) continue;
|
|
10746
|
+
const { promote } = evaluateStationaryPromotion({
|
|
10747
|
+
positions: active.positions,
|
|
10748
|
+
referenceDiagonalPx: refDiag,
|
|
10749
|
+
now: result.timestamp,
|
|
10750
|
+
config: DEFAULT_PROMOTION_CONFIG
|
|
10751
|
+
});
|
|
10752
|
+
if (!promote) continue;
|
|
10753
|
+
this.promoteToStationary({
|
|
10754
|
+
deviceId,
|
|
10755
|
+
key,
|
|
10756
|
+
processor,
|
|
10757
|
+
track: t,
|
|
10758
|
+
firstSeen: active.firstSeen,
|
|
10759
|
+
label: active.label,
|
|
10760
|
+
frameWidth: result.frameWidth,
|
|
10761
|
+
frameHeight: result.frameHeight,
|
|
10762
|
+
timestamp: result.timestamp
|
|
10763
|
+
});
|
|
10764
|
+
}
|
|
10765
|
+
}
|
|
10766
|
+
}
|
|
9473
10767
|
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9474
10768
|
const dispatcher = this.detailDispatcher;
|
|
9475
10769
|
const steps = detailSteps;
|
|
@@ -9538,7 +10832,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9538
10832
|
let plateCrops = 0;
|
|
9539
10833
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
9540
10834
|
else plateCrops += 1;
|
|
9541
|
-
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
10835
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
10836
|
+
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
10837
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
|
|
9542
10838
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
9543
10839
|
const captureCounts = {
|
|
9544
10840
|
events: eventTargets.length,
|
|
@@ -9753,14 +11049,36 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9753
11049
|
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
9754
11050
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
9755
11051
|
else if (d.label !== void 0 && d.label.length > 0) {
|
|
9756
|
-
if (d.className === "plate" && d.bbox !== void 0)
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
9760
|
-
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
|
|
11052
|
+
if (d.className === "plate" && d.bbox !== void 0) {
|
|
11053
|
+
this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
11054
|
+
x: d.bbox.x,
|
|
11055
|
+
y: d.bbox.y,
|
|
11056
|
+
w: d.bbox.w,
|
|
11057
|
+
h: d.bbox.h
|
|
11058
|
+
}, d.score, d.label, frame.timestamp);
|
|
11059
|
+
if (this.plateRecognizer) {
|
|
11060
|
+
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
11061
|
+
await this.plateRecognizer.observePlateRead({
|
|
11062
|
+
deviceId,
|
|
11063
|
+
trackId,
|
|
11064
|
+
text: d.label,
|
|
11065
|
+
score: d.score,
|
|
11066
|
+
bbox: {
|
|
11067
|
+
x: d.bbox.x,
|
|
11068
|
+
y: d.bbox.y,
|
|
11069
|
+
w: d.bbox.w,
|
|
11070
|
+
h: d.bbox.h
|
|
11071
|
+
},
|
|
11072
|
+
timestamp: frame.timestamp,
|
|
11073
|
+
frameWidth: frame.frameWidth,
|
|
11074
|
+
frameHeight: frame.frameHeight,
|
|
11075
|
+
cropPadding: mediaSettings.cropPadding,
|
|
11076
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
11077
|
+
});
|
|
11078
|
+
}
|
|
11079
|
+
}
|
|
11080
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
|
|
11081
|
+
if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9764
11082
|
}
|
|
9765
11083
|
} catch (err) {
|
|
9766
11084
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -9907,55 +11225,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9907
11225
|
await Promise.all(bests.map(async (t) => {
|
|
9908
11226
|
if (!isClipObjectEmbedding(t)) return;
|
|
9909
11227
|
let mediaKey;
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
trackId: t.trackId,
|
|
9927
|
-
error: require_dist.errMsg(err)
|
|
9928
|
-
}
|
|
9929
|
-
});
|
|
9930
|
-
}
|
|
9931
|
-
try {
|
|
9932
|
-
const keyFrame = await this.captureCrop(frameHandle, {
|
|
9933
|
-
x: 0,
|
|
9934
|
-
y: 0,
|
|
9935
|
-
w: frameWidth,
|
|
9936
|
-
h: frameHeight
|
|
9937
|
-
}, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
|
|
9938
|
-
if (keyFrame) {
|
|
9939
|
-
keyFrameMediaKey = await this.mediaStore.putReplacing({
|
|
9940
|
-
deviceId,
|
|
9941
|
-
ownerKind: "track",
|
|
9942
|
-
ownerId: t.trackId,
|
|
9943
|
-
kind: "keyFrame",
|
|
9944
|
-
timestamp,
|
|
9945
|
-
data: keyFrame
|
|
9946
|
-
});
|
|
9947
|
-
this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
|
|
11228
|
+
if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
|
|
11229
|
+
const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
|
|
11230
|
+
if (crop) mediaKey = await this.mediaStore.putReplacing({
|
|
11231
|
+
deviceId,
|
|
11232
|
+
ownerKind: "track",
|
|
11233
|
+
ownerId: t.trackId,
|
|
11234
|
+
kind: "crop",
|
|
11235
|
+
timestamp,
|
|
11236
|
+
data: crop
|
|
11237
|
+
});
|
|
11238
|
+
} catch (err) {
|
|
11239
|
+
this.ctx.logger.debug("object-embedding crop capture failed", {
|
|
11240
|
+
tags: { deviceId },
|
|
11241
|
+
meta: {
|
|
11242
|
+
trackId: t.trackId,
|
|
11243
|
+
error: require_dist.errMsg(err)
|
|
9948
11244
|
}
|
|
9949
|
-
}
|
|
9950
|
-
this.ctx.logger.debug("key-frame capture failed", {
|
|
9951
|
-
tags: { deviceId },
|
|
9952
|
-
meta: {
|
|
9953
|
-
trackId: t.trackId,
|
|
9954
|
-
error: require_dist.errMsg(err)
|
|
9955
|
-
}
|
|
9956
|
-
});
|
|
9957
|
-
}
|
|
11245
|
+
});
|
|
9958
11246
|
}
|
|
11247
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
9959
11248
|
await store.upsertIfBetter({
|
|
9960
11249
|
trackId: t.trackId,
|
|
9961
11250
|
deviceId,
|
|
@@ -9969,6 +11258,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9969
11258
|
});
|
|
9970
11259
|
}));
|
|
9971
11260
|
}
|
|
11261
|
+
/**
|
|
11262
|
+
* Capture ONE native-resolution KEY FRAME per given track at this best-
|
|
11263
|
+
* detection frame and store it (`putReplacing` → one keyFrame per track).
|
|
11264
|
+
*
|
|
11265
|
+
* The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
|
|
11266
|
+
* FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
|
|
11267
|
+
* `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
|
|
11268
|
+
* and only falls back to the ≤640 detection frame when the native lease is
|
|
11269
|
+
* gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
|
|
11270
|
+
* plate / object-embedding rows LINK the SAME native key frame (Design B).
|
|
11271
|
+
* Issued in the live-frame window so the native lease is still held. Best-
|
|
11272
|
+
* effort (D8) — a per-track failure is logged and never thrown.
|
|
11273
|
+
*/
|
|
11274
|
+
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
|
|
11275
|
+
const capture = this.captureCrop;
|
|
11276
|
+
const mediaStore = this.mediaStore;
|
|
11277
|
+
if (!capture || !mediaStore) return;
|
|
11278
|
+
const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
|
|
11279
|
+
await Promise.all(trackIds.map(async (trackId) => {
|
|
11280
|
+
try {
|
|
11281
|
+
const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
|
|
11282
|
+
if (!keyFrame) return;
|
|
11283
|
+
const key = await mediaStore.putReplacing({
|
|
11284
|
+
deviceId,
|
|
11285
|
+
ownerKind: "track",
|
|
11286
|
+
ownerId: trackId,
|
|
11287
|
+
kind: "keyFrame",
|
|
11288
|
+
timestamp,
|
|
11289
|
+
data: keyFrame
|
|
11290
|
+
});
|
|
11291
|
+
this.keyFrameKeyByTrackId.set(trackId, key);
|
|
11292
|
+
} catch (err) {
|
|
11293
|
+
this.ctx.logger.debug("key-frame capture failed", {
|
|
11294
|
+
tags: { deviceId },
|
|
11295
|
+
meta: {
|
|
11296
|
+
trackId,
|
|
11297
|
+
error: require_dist.errMsg(err)
|
|
11298
|
+
}
|
|
11299
|
+
});
|
|
11300
|
+
}
|
|
11301
|
+
}));
|
|
11302
|
+
}
|
|
9972
11303
|
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9973
11304
|
emitTrackLifecycle(payload, timestamp) {
|
|
9974
11305
|
this.ctx.eventBus.emit({
|
|
@@ -10018,27 +11349,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10018
11349
|
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10019
11350
|
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10020
11351
|
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
11352
|
+
...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
|
|
10021
11353
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10022
11354
|
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10023
11355
|
});
|
|
10024
11356
|
this.emitTrackLifecycle(payload, timestamp);
|
|
10025
11357
|
}
|
|
10026
|
-
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
11358
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
|
|
10027
11359
|
const targets = [];
|
|
10028
11360
|
for (const t of tracked) {
|
|
10029
11361
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
10030
|
-
const dueSnapshot = media.saveThumbnails &&
|
|
11362
|
+
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
11363
|
+
lastSnapshotAt: lastSnap,
|
|
11364
|
+
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
11365
|
+
currentBbox: t.bbox,
|
|
11366
|
+
now: timestamp,
|
|
11367
|
+
frameWidth,
|
|
11368
|
+
frameHeight,
|
|
11369
|
+
intervalMs: media.snapshotIntervalMs,
|
|
11370
|
+
movementThreshold: media.snapshotMovementThreshold,
|
|
11371
|
+
maxIdleMs: media.snapshotMaxIdleMs
|
|
11372
|
+
}).capture;
|
|
10031
11373
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10032
11374
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
10033
|
-
|
|
11375
|
+
const plan = planPeriodicMedia({
|
|
11376
|
+
saveThumbnails: media.saveThumbnails,
|
|
11377
|
+
dueSnapshot,
|
|
11378
|
+
isNewBest,
|
|
11379
|
+
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
11380
|
+
now: timestamp,
|
|
11381
|
+
intervalMs: media.snapshotIntervalMs
|
|
11382
|
+
});
|
|
11383
|
+
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
11384
|
+
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
11385
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
|
|
10034
11386
|
targets.push({
|
|
10035
11387
|
trackId: t.trackId,
|
|
10036
11388
|
timestamp,
|
|
10037
11389
|
bbox: { ...t.bbox },
|
|
10038
11390
|
...t.label ? { label: t.label } : {},
|
|
10039
|
-
appendSnapshot:
|
|
10040
|
-
rollingLastFrame:
|
|
10041
|
-
bestThumbnail:
|
|
11391
|
+
appendSnapshot: plan.appendSnapshot,
|
|
11392
|
+
rollingLastFrame: plan.rollingLastFrame,
|
|
11393
|
+
bestThumbnail: plan.bestThumbnail
|
|
10042
11394
|
});
|
|
10043
11395
|
}
|
|
10044
11396
|
return targets;
|
|
@@ -10094,6 +11446,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10094
11446
|
atMs: timestamp
|
|
10095
11447
|
});
|
|
10096
11448
|
await this.eventStore.insertAudio(ev);
|
|
11449
|
+
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
10097
11450
|
this.ctx.eventBus.emit({
|
|
10098
11451
|
id: `pa-${ev.id}`,
|
|
10099
11452
|
timestamp: new Date(ev.timestamp),
|
|
@@ -10311,6 +11664,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10311
11664
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
10312
11665
|
if (peak) {
|
|
10313
11666
|
endBestEventId = peak.bestEventId;
|
|
11667
|
+
const dims = this.lastFrameDimsByDevice.get(t.deviceId);
|
|
11668
|
+
const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
11669
|
+
x: p.x,
|
|
11670
|
+
y: p.y
|
|
11671
|
+
})), Math.hypot(dims.w, dims.h)) : void 0;
|
|
10314
11672
|
const { importance, reason } = computeImportance({
|
|
10315
11673
|
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
10316
11674
|
className: t.className,
|
|
@@ -10318,7 +11676,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10318
11676
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
10319
11677
|
totalDistance: t.totalDistance,
|
|
10320
11678
|
zonesVisited: t.zonesVisited,
|
|
10321
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
11679
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
11680
|
+
...staticMetrics ? {
|
|
11681
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
11682
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
11683
|
+
} : {}
|
|
10322
11684
|
});
|
|
10323
11685
|
endImportance = importance;
|
|
10324
11686
|
endImportanceReason = reason;
|
|
@@ -10333,6 +11695,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10333
11695
|
}
|
|
10334
11696
|
this.bestFrameTracker.delete(t.trackId);
|
|
10335
11697
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
11698
|
+
this.lastFrameAtByTrack.delete(t.trackId);
|
|
10336
11699
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10337
11700
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10338
11701
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -10377,6 +11740,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10377
11740
|
positionsCount: t.positions.length,
|
|
10378
11741
|
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10379
11742
|
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
11743
|
+
...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
|
|
10380
11744
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10381
11745
|
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10382
11746
|
});
|
|
@@ -10539,6 +11903,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10539
11903
|
for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
|
|
10540
11904
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
10541
11905
|
for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
|
|
11906
|
+
this.lastFrameDimsByDevice.delete(deviceId);
|
|
10542
11907
|
}
|
|
10543
11908
|
/** Apply a mutation to every live source-processor of a device (zones/rules
|
|
10544
11909
|
* are device-level and must reach all sources). */
|
|
@@ -10546,6 +11911,57 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10546
11911
|
const prefix = `${deviceId}:`;
|
|
10547
11912
|
for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
|
|
10548
11913
|
}
|
|
11914
|
+
/**
|
|
11915
|
+
* Turn a parked track into a stationary-registry entry and tear the track
|
|
11916
|
+
* down WITHOUT firing an 'end' key-event / importance scoring / media flush
|
|
11917
|
+
* (a parked object is not a highlight; the durable record is the entry). The
|
|
11918
|
+
* tracker + store forget the track so its detections stop re-spawning tracks,
|
|
11919
|
+
* and the registry suppresses them from the next frame on.
|
|
11920
|
+
*/
|
|
11921
|
+
promoteToStationary(input) {
|
|
11922
|
+
const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
|
|
11923
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
|
|
11924
|
+
const entry = {
|
|
11925
|
+
id: (0, node_crypto.randomUUID)(),
|
|
11926
|
+
deviceId,
|
|
11927
|
+
className: track.className,
|
|
11928
|
+
bbox: { ...track.bbox },
|
|
11929
|
+
frameWidth,
|
|
11930
|
+
frameHeight,
|
|
11931
|
+
firstSeenAt: firstSeen,
|
|
11932
|
+
becameStationaryAt: timestamp,
|
|
11933
|
+
lastConfirmedAt: timestamp,
|
|
11934
|
+
sourceTrackId: track.trackId,
|
|
11935
|
+
...label !== void 0 ? { label } : {},
|
|
11936
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
11937
|
+
};
|
|
11938
|
+
this.stationaryRegistry?.promote(entry);
|
|
11939
|
+
processor.dropTrack(track.trackId);
|
|
11940
|
+
this.trackStore?.dropActive(track.trackId);
|
|
11941
|
+
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11942
|
+
const dropKeyFrame = () => {
|
|
11943
|
+
this.keyFrameKeyByTrackId.delete(track.trackId);
|
|
11944
|
+
};
|
|
11945
|
+
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
11946
|
+
else dropKeyFrame();
|
|
11947
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11948
|
+
this.bestFrameTracker.delete(track.trackId);
|
|
11949
|
+
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
11950
|
+
this.lastFrameAtByTrack.delete(track.trackId);
|
|
11951
|
+
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
11952
|
+
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
11953
|
+
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
11954
|
+
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
11955
|
+
this.ctx.logger.info("track promoted to stationary", {
|
|
11956
|
+
tags: { deviceId },
|
|
11957
|
+
meta: {
|
|
11958
|
+
trackId: track.trackId,
|
|
11959
|
+
className: track.className,
|
|
11960
|
+
entryId: entry.id,
|
|
11961
|
+
...label ? { label } : {}
|
|
11962
|
+
}
|
|
11963
|
+
});
|
|
11964
|
+
}
|
|
10549
11965
|
async getOrCreateProcessor(deviceId, source) {
|
|
10550
11966
|
const key = this.procKey(deviceId, source);
|
|
10551
11967
|
let p = this.processors.get(key);
|
|
@@ -10571,6 +11987,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10571
11987
|
cooldownSec,
|
|
10572
11988
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
10573
11989
|
}, source);
|
|
11990
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
11991
|
+
const registry = this.stationaryRegistry;
|
|
11992
|
+
p.setStationaryGate({ filter: (input) => registry.filter({
|
|
11993
|
+
deviceId,
|
|
11994
|
+
detections: input.detections.map((d) => ({
|
|
11995
|
+
bbox: d.bbox,
|
|
11996
|
+
className: d.class
|
|
11997
|
+
})),
|
|
11998
|
+
frameWidth: input.frameWidth,
|
|
11999
|
+
frameHeight: input.frameHeight
|
|
12000
|
+
}) });
|
|
12001
|
+
}
|
|
10574
12002
|
this.processors.set(key, p);
|
|
10575
12003
|
}
|
|
10576
12004
|
return p;
|
|
@@ -10582,6 +12010,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10582
12010
|
* forward updates to the per-device FrameProcessor so a rule change
|
|
10583
12011
|
* applies to the very next frame even when frames stop briefly
|
|
10584
12012
|
* (e.g. during binding flips).
|
|
12013
|
+
*
|
|
12014
|
+
* RECONCILE: the push channel behind `subscribe` (`device.state-changed`
|
|
12015
|
+
* via `live.onEvent`) does not reliably reach a forked addon child — a
|
|
12016
|
+
* zone created AFTER the proxy's cold read stayed invisible until the
|
|
12017
|
+
* addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
|
|
12018
|
+
* populated hub-side, `zones: []` in every snapshot). Events are lossy
|
|
12019
|
+
* telemetry (D8); the durable channel is RPC + reconcile — so each
|
|
12020
|
+
* proxy also refreshes its two slices on a slow timer. `refresh()`
|
|
12021
|
+
* round-trips `deviceState.getCapSlice` and fans out through the SAME
|
|
12022
|
+
* subscribe callbacks above, so a zone edit lands within one interval.
|
|
10585
12023
|
*/
|
|
10586
12024
|
async ensureProxy(deviceId) {
|
|
10587
12025
|
const cached = this.proxies.get(deviceId);
|
|
@@ -10590,13 +12028,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10590
12028
|
const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
|
|
10591
12029
|
if (!proxy) return null;
|
|
10592
12030
|
this.proxies.set(deviceId, proxy);
|
|
10593
|
-
const
|
|
10594
|
-
|
|
10595
|
-
|
|
10596
|
-
}
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
12031
|
+
const reconcile = setInterval(() => {
|
|
12032
|
+
proxy.state.zones.refresh().catch(() => void 0);
|
|
12033
|
+
proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
12034
|
+
}, ZONE_SLICE_RECONCILE_MS);
|
|
12035
|
+
reconcile.unref?.();
|
|
12036
|
+
const unsubs = [
|
|
12037
|
+
proxy.state.zones.subscribe((slice) => {
|
|
12038
|
+
const zones = slice?.zones ?? [];
|
|
12039
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
|
|
12040
|
+
}),
|
|
12041
|
+
proxy.state.zoneRules.subscribe((slice) => {
|
|
12042
|
+
const rules = slice?.detection ?? [];
|
|
12043
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
|
|
12044
|
+
}),
|
|
12045
|
+
() => clearInterval(reconcile)
|
|
12046
|
+
];
|
|
10600
12047
|
this.proxyUnsubs.set(deviceId, unsubs);
|
|
10601
12048
|
return proxy;
|
|
10602
12049
|
} catch (err) {
|
|
@@ -10607,6 +12054,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10607
12054
|
return null;
|
|
10608
12055
|
}
|
|
10609
12056
|
}
|
|
12057
|
+
/**
|
|
12058
|
+
* Resolve a device's current 0–1 zone catalogue independent of the live
|
|
12059
|
+
* frame path — used by zone-analytics snapshot hydration + the occupancy
|
|
12060
|
+
* baseline sampler when the camera is detached (no frames). Warms the proxy
|
|
12061
|
+
* (cold read via `fetchDevice`) and, when the cached slice is empty, forces
|
|
12062
|
+
* one `refresh()` round-trip so a just-created proxy returns real zones.
|
|
12063
|
+
*/
|
|
12064
|
+
async resolveDeviceZones(deviceId) {
|
|
12065
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12066
|
+
if (!proxy) return [];
|
|
12067
|
+
const cached = proxy.state.zones.value?.zones;
|
|
12068
|
+
if (cached && cached.length > 0) return cached;
|
|
12069
|
+
await proxy.state.zones.refresh().catch(() => void 0);
|
|
12070
|
+
return proxy.state.zones.value?.zones ?? [];
|
|
12071
|
+
}
|
|
10610
12072
|
releaseProxy(deviceId) {
|
|
10611
12073
|
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
10612
12074
|
if (unsubs) for (const u of unsubs) try {
|
|
@@ -10628,6 +12090,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10628
12090
|
}
|
|
10629
12091
|
async clearTracks(input) {
|
|
10630
12092
|
this.trackStore?.clearDevice(input.deviceId);
|
|
12093
|
+
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10631
12094
|
this.overlayState.clearDevice(input.deviceId);
|
|
10632
12095
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
10633
12096
|
const prefix = `${input.deviceId}:`;
|
|
@@ -10929,6 +12392,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10929
12392
|
* Enrolled gallery + identity media are exempt.
|
|
10930
12393
|
*/
|
|
10931
12394
|
async wipeAllAnalytics(input) {
|
|
12395
|
+
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10932
12396
|
return this.pruneTracksBefore({
|
|
10933
12397
|
deviceId: input.deviceId,
|
|
10934
12398
|
cutoffMs: Date.now()
|
|
@@ -10980,24 +12444,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10980
12444
|
* points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
|
|
10981
12445
|
* key), so this resolves that crop; event ids stay on the event-crop path.
|
|
10982
12446
|
*/
|
|
10983
|
-
async readMediaByEventOrKey(id) {
|
|
10984
|
-
|
|
10985
|
-
|
|
10986
|
-
|
|
12447
|
+
async readMediaByEventOrKey(id, variant, preferKind) {
|
|
12448
|
+
const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
|
|
12449
|
+
if (base === null || variant === void 0) return base;
|
|
12450
|
+
return this.applyThumbVariant(base, variant);
|
|
12451
|
+
}
|
|
12452
|
+
async readMediaByKey(id) {
|
|
12453
|
+
const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
|
|
12454
|
+
if (!file) return null;
|
|
12455
|
+
return {
|
|
12456
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
12457
|
+
key: file.key
|
|
12458
|
+
};
|
|
12459
|
+
}
|
|
12460
|
+
/**
|
|
12461
|
+
* Render a small center-cropped square from a resolved event media blob for
|
|
12462
|
+
* the reel / list surfaces. The returned `key` is variant-distinct so the
|
|
12463
|
+
* data-plane ETag never collides with the full-size blob's. On any encode
|
|
12464
|
+
* failure the full blob is served (a thumb must never 500 / blank a tile).
|
|
12465
|
+
*/
|
|
12466
|
+
async applyThumbVariant(media, variant) {
|
|
12467
|
+
try {
|
|
10987
12468
|
return {
|
|
10988
|
-
bytes:
|
|
10989
|
-
key:
|
|
12469
|
+
bytes: await makeSquareThumb(media.bytes, variant.size),
|
|
12470
|
+
key: `${media.key}|t${variant.size}`
|
|
10990
12471
|
};
|
|
12472
|
+
} catch (err) {
|
|
12473
|
+
this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
|
|
12474
|
+
key: media.key,
|
|
12475
|
+
size: variant.size,
|
|
12476
|
+
error: require_dist.errMsg(err)
|
|
12477
|
+
} });
|
|
12478
|
+
return media;
|
|
10991
12479
|
}
|
|
10992
|
-
return this.readEventThumbnail(id);
|
|
10993
12480
|
}
|
|
10994
|
-
async readEventThumbnail(
|
|
10995
|
-
const
|
|
10996
|
-
|
|
10997
|
-
|
|
12481
|
+
async readEventThumbnail(id, preferKind) {
|
|
12482
|
+
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
12483
|
+
if (preferKind !== void 0 && preferKind.length > 0) {
|
|
12484
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12485
|
+
const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
|
|
12486
|
+
if (!clean) return null;
|
|
12487
|
+
return {
|
|
12488
|
+
bytes: Buffer.from(clean.base64, "base64"),
|
|
12489
|
+
key: clean.key
|
|
12490
|
+
};
|
|
12491
|
+
}
|
|
12492
|
+
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12493
|
+
if (chosenEvent) return {
|
|
12494
|
+
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
12495
|
+
key: chosenEvent.key
|
|
12496
|
+
};
|
|
12497
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12498
|
+
const chosenTrack = trackFiles.find((f) => f.kind === "thumbnail") ?? trackFiles.find((f) => f.kind === "lastFrame") ?? trackFiles.find((f) => f.kind === "firstFrame") ?? [...trackFiles].reverse().find((f) => f.kind === "snapshot") ?? trackFiles[trackFiles.length - 1];
|
|
12499
|
+
if (!chosenTrack) return null;
|
|
10998
12500
|
return {
|
|
10999
|
-
bytes: Buffer.from(
|
|
11000
|
-
key:
|
|
12501
|
+
bytes: Buffer.from(chosenTrack.base64, "base64"),
|
|
12502
|
+
key: chosenTrack.key
|
|
11001
12503
|
};
|
|
11002
12504
|
}
|
|
11003
12505
|
/**
|
|
@@ -11090,6 +12592,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11090
12592
|
unit: "s",
|
|
11091
12593
|
displayScale: 1e3
|
|
11092
12594
|
},
|
|
12595
|
+
{
|
|
12596
|
+
type: "slider",
|
|
12597
|
+
key: "snapshotMovementThreshold",
|
|
12598
|
+
label: "Snapshot movement gate",
|
|
12599
|
+
description: "After the interval elapses, only capture a snapshot if the object moved this fraction of the frame since the last one. Higher = fewer near-identical frames. 0 disables the gate.",
|
|
12600
|
+
min: 0,
|
|
12601
|
+
max: .15,
|
|
12602
|
+
step: .005,
|
|
12603
|
+
default: .03,
|
|
12604
|
+
showValue: true,
|
|
12605
|
+
unit: "%",
|
|
12606
|
+
displayScale: .01
|
|
12607
|
+
},
|
|
12608
|
+
{
|
|
12609
|
+
type: "slider",
|
|
12610
|
+
key: "snapshotMaxIdleMs",
|
|
12611
|
+
label: "Snapshot max idle",
|
|
12612
|
+
description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
|
|
12613
|
+
min: 5e3,
|
|
12614
|
+
max: 12e4,
|
|
12615
|
+
step: 5e3,
|
|
12616
|
+
default: 3e4,
|
|
12617
|
+
showValue: true,
|
|
12618
|
+
unit: "s",
|
|
12619
|
+
displayScale: 1e3
|
|
12620
|
+
},
|
|
11093
12621
|
{
|
|
11094
12622
|
type: "select",
|
|
11095
12623
|
key: "mediaAttachPolicy",
|
|
@@ -11546,5 +13074,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11546
13074
|
};
|
|
11547
13075
|
//#endregion
|
|
11548
13076
|
exports.default = PipelineAnalyticsAddon;
|
|
13077
|
+
exports.pickCleanMedia = pickCleanMedia;
|
|
11549
13078
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
11550
13079
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|