@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
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { S as
|
|
1
|
+
import { S as EventCategory, _ as hydrateSchema, b as object, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as createEvent, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as boolean, x as string, y as number } from "../dist-yPsKFcJL.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
|
-
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
5
4
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
6
5
|
var SOURCE = "analytics";
|
|
7
6
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -146,6 +145,17 @@ var WEIGHT_IDENTITY = .05;
|
|
|
146
145
|
var DWELL_FULL_MS = 6e4;
|
|
147
146
|
/** Peak bbox area (as a fraction of frame area) that saturates the size term. */
|
|
148
147
|
var SIZE_FULL = .15;
|
|
148
|
+
/** A track whose net centroid displacement stays below this fraction of the
|
|
149
|
+
* frame diagonal over its whole life counts as stationary on the net signal. */
|
|
150
|
+
var STATIC_DISPLACEMENT_FRAC = .02;
|
|
151
|
+
/** ...and whose entire centroid path fits within this fraction of the frame
|
|
152
|
+
* diagonal counts as stationary on the span signal. Both must hold. */
|
|
153
|
+
var STATIC_SPAN_FRAC = .04;
|
|
154
|
+
/** Importance multiplier applied to a stationary track that has NO resolved
|
|
155
|
+
* identity — strongly demotes "ghost" detections on static objects (a pile of
|
|
156
|
+
* clothes read as a person, a kitchen object read as an animal) below the
|
|
157
|
+
* key-event threshold. Identified tracks (face/plate) are never suppressed. */
|
|
158
|
+
var STATIC_IMPORTANCE_MULTIPLIER = .1;
|
|
149
159
|
var CLASS_RANK_VEHICLE = .8;
|
|
150
160
|
var CLASS_RANK_ANIMAL = .5;
|
|
151
161
|
var CLASS_RANK_DEFAULT = .25;
|
|
@@ -220,6 +230,11 @@ function computeImportance(input) {
|
|
|
220
230
|
sum += term.value;
|
|
221
231
|
if (term.value > best.value) best = term;
|
|
222
232
|
}
|
|
233
|
+
const identified = input.label !== void 0 && input.label.length > 0;
|
|
234
|
+
if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
|
|
235
|
+
importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
|
|
236
|
+
reason: best.reason
|
|
237
|
+
};
|
|
223
238
|
return {
|
|
224
239
|
importance: clamp01(sum),
|
|
225
240
|
reason: best.reason
|
|
@@ -613,6 +628,20 @@ function normalizePlate(text) {
|
|
|
613
628
|
return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
614
629
|
}
|
|
615
630
|
/**
|
|
631
|
+
* Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
|
|
632
|
+
* gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
|
|
633
|
+
* "@em") that otherwise flood track labels (live-observed on the parking
|
|
634
|
+
* camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
|
|
635
|
+
* alphanumerics and mixes letters AND digits; anything else — or a read below
|
|
636
|
+
* {@link PLATE_MIN_SCORE} — is discarded, not stored.
|
|
637
|
+
*/
|
|
638
|
+
function isPlausiblePlateRead(text, score) {
|
|
639
|
+
if (score < .4) return false;
|
|
640
|
+
const norm = normalizePlate(text);
|
|
641
|
+
if (norm.length < 4) return false;
|
|
642
|
+
return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
616
645
|
* Fold characters that OCR routinely confuses onto a single canonical symbol, so
|
|
617
646
|
* "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
|
|
618
647
|
* comparison only — never stored. Conservative set covering the common Latin
|
|
@@ -1097,7 +1126,7 @@ var MAX_PATH_LENGTH = 300;
|
|
|
1097
1126
|
function clamp(value, min, max) {
|
|
1098
1127
|
return Math.max(min, Math.min(max, value));
|
|
1099
1128
|
}
|
|
1100
|
-
function iou$
|
|
1129
|
+
function iou$2(a, b) {
|
|
1101
1130
|
const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
|
|
1102
1131
|
const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
|
|
1103
1132
|
const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
|
|
@@ -1152,7 +1181,7 @@ var SortTracker = class {
|
|
|
1152
1181
|
*/
|
|
1153
1182
|
looseMatch(track, det) {
|
|
1154
1183
|
if (track.class !== det.class) return false;
|
|
1155
|
-
if (iou$
|
|
1184
|
+
if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
|
|
1156
1185
|
const tc = bboxCentroid(track.bbox);
|
|
1157
1186
|
const dc = bboxCentroid(det.bbox);
|
|
1158
1187
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
@@ -1180,7 +1209,7 @@ var SortTracker = class {
|
|
|
1180
1209
|
for (let di = 0; di < detections.length; di++) {
|
|
1181
1210
|
const det = detections[di];
|
|
1182
1211
|
if (this.config.classGating && track.class !== det.class) continue;
|
|
1183
|
-
const score = iou$
|
|
1212
|
+
const score = iou$2(pbox, det.bbox);
|
|
1184
1213
|
if (score >= this.config.iouThreshold) pairs.push({
|
|
1185
1214
|
track,
|
|
1186
1215
|
detIdx: di,
|
|
@@ -1205,7 +1234,7 @@ var SortTracker = class {
|
|
|
1205
1234
|
rescuePairs.push({
|
|
1206
1235
|
track,
|
|
1207
1236
|
detIdx: di,
|
|
1208
|
-
score: iou$
|
|
1237
|
+
score: iou$2(track.bbox, det.bbox)
|
|
1209
1238
|
});
|
|
1210
1239
|
}
|
|
1211
1240
|
}
|
|
@@ -1270,7 +1299,7 @@ var SortTracker = class {
|
|
|
1270
1299
|
if (!lost.resurrectable) continue;
|
|
1271
1300
|
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
1272
1301
|
if (!this.looseMatch(lost, det)) continue;
|
|
1273
|
-
const score = iou$
|
|
1302
|
+
const score = iou$2(lost.bbox, det.bbox);
|
|
1274
1303
|
if (score > bestScore) {
|
|
1275
1304
|
best = lost;
|
|
1276
1305
|
bestScore = score;
|
|
@@ -1331,6 +1360,16 @@ var SortTracker = class {
|
|
|
1331
1360
|
path: [...t.path]
|
|
1332
1361
|
}));
|
|
1333
1362
|
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Remove a track from BOTH the live and graveyard sets so it neither emits
|
|
1365
|
+
* nor resurrects. Used when a track is PROMOTED to a stationary-object
|
|
1366
|
+
* registry entry: the registry now owns that parked object, and the tracker
|
|
1367
|
+
* must forget it so the object's detections don't re-spawn a duplicate track.
|
|
1368
|
+
*/
|
|
1369
|
+
dropTrack(trackId) {
|
|
1370
|
+
this.tracks = this.tracks.filter((t) => t.id !== trackId);
|
|
1371
|
+
this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
|
|
1372
|
+
}
|
|
1334
1373
|
getActiveTracks() {
|
|
1335
1374
|
return this.tracks;
|
|
1336
1375
|
}
|
|
@@ -1744,6 +1783,9 @@ var FrameProcessor = class {
|
|
|
1744
1783
|
*/
|
|
1745
1784
|
detectionRules;
|
|
1746
1785
|
zoneEngine = new ZoneEngine();
|
|
1786
|
+
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
1787
|
+
* the addon wires it via {@link setStationaryGate}. */
|
|
1788
|
+
stationaryGate = null;
|
|
1747
1789
|
constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
|
|
1748
1790
|
this.deviceId = deviceId;
|
|
1749
1791
|
this.source = source;
|
|
@@ -1759,6 +1801,16 @@ var FrameProcessor = class {
|
|
|
1759
1801
|
setDetectionRules(rules) {
|
|
1760
1802
|
this.detectionRules = rules;
|
|
1761
1803
|
}
|
|
1804
|
+
/** Wire (or clear) the stationary-object gate. Called by the addon per
|
|
1805
|
+
* device so parked-object suppression applies from the next frame. */
|
|
1806
|
+
setStationaryGate(gate) {
|
|
1807
|
+
this.stationaryGate = gate;
|
|
1808
|
+
}
|
|
1809
|
+
/** Forget a track in the underlying tracker (used when the addon promotes it
|
|
1810
|
+
* to a stationary-object registry entry). */
|
|
1811
|
+
dropTrack(trackId) {
|
|
1812
|
+
this.tracker.dropTrack(trackId);
|
|
1813
|
+
}
|
|
1762
1814
|
process(input) {
|
|
1763
1815
|
const { timestamp, frame } = input;
|
|
1764
1816
|
const frameWidth = frame.width;
|
|
@@ -1837,7 +1889,17 @@ var FrameProcessor = class {
|
|
|
1837
1889
|
}
|
|
1838
1890
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
1839
1891
|
const filteredDetections = passed.map((fd) => fd.detection);
|
|
1840
|
-
const
|
|
1892
|
+
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
1893
|
+
detections: filteredDetections,
|
|
1894
|
+
frameWidth,
|
|
1895
|
+
frameHeight
|
|
1896
|
+
}) : {
|
|
1897
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
1898
|
+
confirmed: [],
|
|
1899
|
+
wokenEntryIds: []
|
|
1900
|
+
};
|
|
1901
|
+
const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
|
|
1902
|
+
const trackedDetections = this.tracker.update(trackerInput, timestamp);
|
|
1841
1903
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
1842
1904
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
1843
1905
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
@@ -1919,7 +1981,9 @@ var FrameProcessor = class {
|
|
|
1919
1981
|
frameHeight,
|
|
1920
1982
|
tracked,
|
|
1921
1983
|
objectEvents,
|
|
1922
|
-
rawTrackedDetections: trackedDetections
|
|
1984
|
+
rawTrackedDetections: trackedDetections,
|
|
1985
|
+
stationaryConfirmed: gate.confirmed,
|
|
1986
|
+
stationaryWoken: gate.wokenEntryIds
|
|
1923
1987
|
};
|
|
1924
1988
|
}
|
|
1925
1989
|
};
|
|
@@ -1996,6 +2060,7 @@ function buildTrackLifecyclePayload(input) {
|
|
|
1996
2060
|
...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
|
|
1997
2061
|
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
1998
2062
|
...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
|
|
2063
|
+
...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
|
|
1999
2064
|
...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
|
|
2000
2065
|
...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
|
|
2001
2066
|
...hasMedia ? { media } : {}
|
|
@@ -2112,6 +2177,583 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
2112
2177
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
2113
2178
|
}
|
|
2114
2179
|
//#endregion
|
|
2180
|
+
//#region src/pipeline-analytics/pipeline/static-track-gate.ts
|
|
2181
|
+
/**
|
|
2182
|
+
* Net displacement + path span for a track's centroid path, normalized to
|
|
2183
|
+
* `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
|
|
2184
|
+
* measure (fewer than two points, or a degenerate ≤0 reference) so the caller
|
|
2185
|
+
* leaves the importance score untouched.
|
|
2186
|
+
*/
|
|
2187
|
+
function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
|
|
2188
|
+
if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
|
|
2189
|
+
const first = centroids[0];
|
|
2190
|
+
const last = centroids[centroids.length - 1];
|
|
2191
|
+
const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
|
|
2192
|
+
let minX = Infinity;
|
|
2193
|
+
let minY = Infinity;
|
|
2194
|
+
let maxX = -Infinity;
|
|
2195
|
+
let maxY = -Infinity;
|
|
2196
|
+
for (const c of centroids) {
|
|
2197
|
+
if (c.x < minX) minX = c.x;
|
|
2198
|
+
if (c.x > maxX) maxX = c.x;
|
|
2199
|
+
if (c.y < minY) minY = c.y;
|
|
2200
|
+
if (c.y > maxY) maxY = c.y;
|
|
2201
|
+
}
|
|
2202
|
+
return {
|
|
2203
|
+
netDisplacementFrac,
|
|
2204
|
+
pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
/** Average bbox diagonal (px) across a track's positions — the scale reference
|
|
2208
|
+
* when frame dimensions aren't available. Returns 0 for an empty list. */
|
|
2209
|
+
function averageBboxDiagonal(boxes) {
|
|
2210
|
+
if (boxes.length === 0) return 0;
|
|
2211
|
+
let sum = 0;
|
|
2212
|
+
for (const b of boxes) sum += Math.hypot(b.w, b.h);
|
|
2213
|
+
return sum / boxes.length;
|
|
2214
|
+
}
|
|
2215
|
+
//#endregion
|
|
2216
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
|
|
2217
|
+
function entryToView(e) {
|
|
2218
|
+
return {
|
|
2219
|
+
id: e.id,
|
|
2220
|
+
className: e.className,
|
|
2221
|
+
bbox: { ...e.bbox },
|
|
2222
|
+
frameWidth: e.frameWidth,
|
|
2223
|
+
frameHeight: e.frameHeight,
|
|
2224
|
+
firstSeenAt: e.firstSeenAt,
|
|
2225
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2226
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2227
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2228
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
/** IoU at/above which a detection is "the same parked object" → suppress its
|
|
2232
|
+
* spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
|
|
2233
|
+
var SUPPRESS_IOU = .6;
|
|
2234
|
+
/** Centroid move (fraction of the frame diagonal) beyond which a near
|
|
2235
|
+
* same-class detection means the parked object actually MOVED → wake. */
|
|
2236
|
+
var WAKE_MOVE_FRAC = .08;
|
|
2237
|
+
/** How near (fraction of frame diagonal) a same-class detection's centroid must
|
|
2238
|
+
* be to an entry to be considered "this entry's object" when testing for a
|
|
2239
|
+
* wake. Keeps an unrelated object elsewhere in the frame from waking it. */
|
|
2240
|
+
var WAKE_SEARCH_FRAC = .5;
|
|
2241
|
+
/**
|
|
2242
|
+
* Look-back window over which a track must have stayed put to be PROMOTED. A
|
|
2243
|
+
* car that drives in then parks has a large whole-life displacement but a tiny
|
|
2244
|
+
* last-`windowMs` displacement — so promotion is judged on the recent window,
|
|
2245
|
+
* not the full path. Also the minimum age (the track must have EXISTED this
|
|
2246
|
+
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2247
|
+
*/
|
|
2248
|
+
var PROMOTION_WINDOW_MS = 3e4;
|
|
2249
|
+
var DEFAULT_MATCH_CONFIG = {
|
|
2250
|
+
suppressIou: SUPPRESS_IOU,
|
|
2251
|
+
wakeMoveFrac: WAKE_MOVE_FRAC,
|
|
2252
|
+
wakeSearchFrac: WAKE_SEARCH_FRAC
|
|
2253
|
+
};
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
|
|
2256
|
+
/**
|
|
2257
|
+
* stationary-match — PURE geometry for the stationary-object registry.
|
|
2258
|
+
*
|
|
2259
|
+
* Two decisions, both unit-testable in isolation:
|
|
2260
|
+
*
|
|
2261
|
+
* 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
|
|
2262
|
+
* and this frame's (zone-filtered) detections, decides which detections are
|
|
2263
|
+
* suppressed (they keep confirming a known parked object → no track spawns),
|
|
2264
|
+
* which entries are confirmed present, and which entries WOKE (their object
|
|
2265
|
+
* moved → retire the entry and let the detection spawn a normal track).
|
|
2266
|
+
*
|
|
2267
|
+
* 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
|
|
2268
|
+
* decides whether it has stayed put long enough to become a stationary
|
|
2269
|
+
* entry. Reuses the static-track-gate metrics (net displacement + path span
|
|
2270
|
+
* normalised to the frame diagonal) over the recent look-back window.
|
|
2271
|
+
*/
|
|
2272
|
+
/** Default promotion tunables — static thresholds shared with the key-event
|
|
2273
|
+
* static gate so "stationary" means the same thing in both places. */
|
|
2274
|
+
var DEFAULT_PROMOTION_CONFIG = {
|
|
2275
|
+
windowMs: PROMOTION_WINDOW_MS,
|
|
2276
|
+
netFracMax: STATIC_DISPLACEMENT_FRAC,
|
|
2277
|
+
spanFracMax: STATIC_SPAN_FRAC,
|
|
2278
|
+
minPoints: 4
|
|
2279
|
+
};
|
|
2280
|
+
function iou$1(a, b) {
|
|
2281
|
+
const ax2 = a.x + a.w;
|
|
2282
|
+
const ay2 = a.y + a.h;
|
|
2283
|
+
const bx2 = b.x + b.w;
|
|
2284
|
+
const by2 = b.y + b.h;
|
|
2285
|
+
const ix1 = Math.max(a.x, b.x);
|
|
2286
|
+
const iy1 = Math.max(a.y, b.y);
|
|
2287
|
+
const ix2 = Math.min(ax2, bx2);
|
|
2288
|
+
const iy2 = Math.min(ay2, by2);
|
|
2289
|
+
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
2290
|
+
const union = a.w * a.h + b.w * b.h - inter;
|
|
2291
|
+
return union > 0 ? inter / union : 0;
|
|
2292
|
+
}
|
|
2293
|
+
function centroid(b) {
|
|
2294
|
+
return {
|
|
2295
|
+
x: b.x + b.w / 2,
|
|
2296
|
+
y: b.y + b.h / 2
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
function diagonalOf(width, height) {
|
|
2300
|
+
return Math.hypot(width, height);
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Decide, per stationary entry, whether the current frame confirms it, wakes
|
|
2304
|
+
* it, or misses it (no matching detection — leave it for the TTL sweep).
|
|
2305
|
+
*
|
|
2306
|
+
* Per entry, over same-class detections:
|
|
2307
|
+
* - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
|
|
2308
|
+
* parked object, unmoved) and mark the entry confirmed.
|
|
2309
|
+
* - else if a same-class detection sits within `wakeSearchFrac × diag` of the
|
|
2310
|
+
* entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
|
|
2311
|
+
* object slid out of its parked box). The detection is NOT suppressed, so it
|
|
2312
|
+
* spawns a fresh moving track.
|
|
2313
|
+
* - else → MISS (occlusion / brief absence): neither suppress nor wake.
|
|
2314
|
+
*
|
|
2315
|
+
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
2316
|
+
* (`suppressedIndices` is a set).
|
|
2317
|
+
*/
|
|
2318
|
+
function partitionDetectionsAgainstRegistry(input) {
|
|
2319
|
+
const { entries, detections, referenceDiagonalPx, config } = input;
|
|
2320
|
+
const suppressed = /* @__PURE__ */ new Set();
|
|
2321
|
+
const confirmed = [];
|
|
2322
|
+
const woken = [];
|
|
2323
|
+
const diag = referenceDiagonalPx;
|
|
2324
|
+
for (const entry of entries) {
|
|
2325
|
+
const ec = centroid(entry.bbox);
|
|
2326
|
+
let bestIou = 0;
|
|
2327
|
+
let bestIdx = -1;
|
|
2328
|
+
let wakeCandidate = false;
|
|
2329
|
+
for (let di = 0; di < detections.length; di++) {
|
|
2330
|
+
const det = detections[di];
|
|
2331
|
+
if (det.className !== entry.className) continue;
|
|
2332
|
+
const o = iou$1(entry.bbox, det.bbox);
|
|
2333
|
+
if (o > bestIou) {
|
|
2334
|
+
bestIou = o;
|
|
2335
|
+
bestIdx = di;
|
|
2336
|
+
}
|
|
2337
|
+
if (diag > 0) {
|
|
2338
|
+
const dc = centroid(det.bbox);
|
|
2339
|
+
const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
|
|
2340
|
+
if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
if (bestIou >= config.suppressIou && bestIdx >= 0) {
|
|
2344
|
+
suppressed.add(bestIdx);
|
|
2345
|
+
confirmed.push({
|
|
2346
|
+
entryId: entry.id,
|
|
2347
|
+
className: entry.className,
|
|
2348
|
+
bbox: { ...entry.bbox }
|
|
2349
|
+
});
|
|
2350
|
+
} else if (wakeCandidate) woken.push(entry.id);
|
|
2351
|
+
}
|
|
2352
|
+
return {
|
|
2353
|
+
suppressedIndices: suppressed,
|
|
2354
|
+
confirmed,
|
|
2355
|
+
wokenEntryIds: woken
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* A track is promoted when, over the recent `windowMs`, its centroid barely
|
|
2360
|
+
* moved (both net displacement and path span below the static thresholds) AND
|
|
2361
|
+
* the track has actually EXISTED for at least `windowMs` (so a car that just
|
|
2362
|
+
* arrived isn't parked yet). Judging on the recent window — not the whole life
|
|
2363
|
+
* — is what lets a car that drove in then parked be recognised as stationary.
|
|
2364
|
+
*/
|
|
2365
|
+
function evaluateStationaryPromotion(input) {
|
|
2366
|
+
const { positions, referenceDiagonalPx, now, config } = input;
|
|
2367
|
+
if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
|
|
2368
|
+
if (now - positions[0].timestamp < config.windowMs) return { promote: false };
|
|
2369
|
+
const cutoff = now - config.windowMs;
|
|
2370
|
+
const window = positions.filter((p) => p.timestamp >= cutoff);
|
|
2371
|
+
if (window.length < config.minPoints) return { promote: false };
|
|
2372
|
+
const metrics = computeStaticTrackMetrics(window.map((p) => ({
|
|
2373
|
+
x: p.x,
|
|
2374
|
+
y: p.y
|
|
2375
|
+
})), referenceDiagonalPx);
|
|
2376
|
+
if (metrics === void 0) return { promote: false };
|
|
2377
|
+
return {
|
|
2378
|
+
promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
|
|
2379
|
+
netFrac: metrics.netDisplacementFrac,
|
|
2380
|
+
spanFrac: metrics.pathSpanFrac
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
//#endregion
|
|
2384
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
|
|
2385
|
+
var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
|
|
2386
|
+
var STATIONARY_COLUMNS = [
|
|
2387
|
+
{
|
|
2388
|
+
name: "id",
|
|
2389
|
+
type: "TEXT",
|
|
2390
|
+
primaryKey: true,
|
|
2391
|
+
notNull: true
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
name: "deviceId",
|
|
2395
|
+
type: "INTEGER",
|
|
2396
|
+
notNull: true
|
|
2397
|
+
},
|
|
2398
|
+
{
|
|
2399
|
+
name: "className",
|
|
2400
|
+
type: "TEXT",
|
|
2401
|
+
notNull: true
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
name: "bbox",
|
|
2405
|
+
type: "JSON"
|
|
2406
|
+
},
|
|
2407
|
+
{
|
|
2408
|
+
name: "frameWidth",
|
|
2409
|
+
type: "INTEGER"
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "frameHeight",
|
|
2413
|
+
type: "INTEGER"
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
name: "firstSeenAt",
|
|
2417
|
+
type: "INTEGER"
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
name: "becameStationaryAt",
|
|
2421
|
+
type: "INTEGER"
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
name: "lastConfirmedAt",
|
|
2425
|
+
type: "INTEGER"
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
name: "sourceTrackId",
|
|
2429
|
+
type: "TEXT"
|
|
2430
|
+
},
|
|
2431
|
+
{
|
|
2432
|
+
name: "label",
|
|
2433
|
+
type: "TEXT"
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
name: "keyFrameMediaKey",
|
|
2437
|
+
type: "TEXT"
|
|
2438
|
+
}
|
|
2439
|
+
];
|
|
2440
|
+
var STATIONARY_INDEXES = [{
|
|
2441
|
+
name: "idx_stationary_device",
|
|
2442
|
+
columns: ["deviceId"]
|
|
2443
|
+
}];
|
|
2444
|
+
var StationaryObjectRegistry = class {
|
|
2445
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
2446
|
+
dirty = /* @__PURE__ */ new Set();
|
|
2447
|
+
store;
|
|
2448
|
+
logger;
|
|
2449
|
+
matchConfig;
|
|
2450
|
+
entryTtlMs;
|
|
2451
|
+
onChange;
|
|
2452
|
+
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2453
|
+
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
2454
|
+
* between motion sessions; that silence is not evidence the object left,
|
|
2455
|
+
* so quiet minutes must not age the entries (see {@link sweep}). */
|
|
2456
|
+
lastFrameAtByDevice = /* @__PURE__ */ new Map();
|
|
2457
|
+
constructor(deps) {
|
|
2458
|
+
this.store = deps.store;
|
|
2459
|
+
this.logger = deps.logger;
|
|
2460
|
+
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2461
|
+
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2462
|
+
this.onChange = deps.onChange;
|
|
2463
|
+
}
|
|
2464
|
+
static async declare(store) {
|
|
2465
|
+
await store.declareCollection.mutate({
|
|
2466
|
+
collection: STATIONARY_COLLECTION,
|
|
2467
|
+
columns: [...STATIONARY_COLUMNS],
|
|
2468
|
+
indexes: [...STATIONARY_INDEXES]
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
/** Hydrate all persisted entries into memory (call once at boot, after
|
|
2472
|
+
* `declare`). Best-effort — a query failure leaves the registry empty. */
|
|
2473
|
+
async load() {
|
|
2474
|
+
try {
|
|
2475
|
+
const rows = await this.store.query.query({
|
|
2476
|
+
collection: STATIONARY_COLLECTION,
|
|
2477
|
+
filter: { limit: 1e5 }
|
|
2478
|
+
});
|
|
2479
|
+
for (const row of rows) {
|
|
2480
|
+
const entry = rowToEntry(row.id, row.data);
|
|
2481
|
+
if (!entry) continue;
|
|
2482
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2483
|
+
}
|
|
2484
|
+
this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
list(deviceId) {
|
|
2490
|
+
const m = this.byDevice.get(deviceId);
|
|
2491
|
+
return m ? [...m.values()] : [];
|
|
2492
|
+
}
|
|
2493
|
+
listViews(deviceId) {
|
|
2494
|
+
return this.list(deviceId).map(entryToView);
|
|
2495
|
+
}
|
|
2496
|
+
count(deviceId) {
|
|
2497
|
+
return this.byDevice.get(deviceId)?.size ?? 0;
|
|
2498
|
+
}
|
|
2499
|
+
/** Device ids that currently hold at least one parked entry — drives the
|
|
2500
|
+
* occupancy baseline sampler (a detached camera with parked cars still
|
|
2501
|
+
* gets a flat history baseline). */
|
|
2502
|
+
deviceIds() {
|
|
2503
|
+
const ids = [];
|
|
2504
|
+
for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
|
|
2505
|
+
return ids;
|
|
2506
|
+
}
|
|
2507
|
+
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2508
|
+
* clock that drives entry expiry in {@link sweep}. */
|
|
2509
|
+
noteFrame(deviceId, timestamp) {
|
|
2510
|
+
if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Per-frame gate: partition this frame's detections against the device's
|
|
2514
|
+
* entries. PURE with respect to registry state — apply the outcome with
|
|
2515
|
+
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2516
|
+
*/
|
|
2517
|
+
filter(input) {
|
|
2518
|
+
const entries = this.list(input.deviceId);
|
|
2519
|
+
if (entries.length === 0) return {
|
|
2520
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
2521
|
+
confirmed: [],
|
|
2522
|
+
wokenEntryIds: []
|
|
2523
|
+
};
|
|
2524
|
+
return partitionDetectionsAgainstRegistry({
|
|
2525
|
+
entries,
|
|
2526
|
+
detections: input.detections,
|
|
2527
|
+
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2528
|
+
config: this.matchConfig
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
2532
|
+
* `lastConfirmedAt` and retire woken entries (their object departed). */
|
|
2533
|
+
applyFrameOutcome(input) {
|
|
2534
|
+
const m = this.byDevice.get(input.deviceId);
|
|
2535
|
+
if (!m) return;
|
|
2536
|
+
for (const c of input.confirmed) {
|
|
2537
|
+
const e = m.get(c.entryId);
|
|
2538
|
+
if (!e) continue;
|
|
2539
|
+
m.set(c.entryId, {
|
|
2540
|
+
...e,
|
|
2541
|
+
lastConfirmedAt: input.timestamp
|
|
2542
|
+
});
|
|
2543
|
+
this.dirty.add(c.entryId);
|
|
2544
|
+
}
|
|
2545
|
+
for (const id of input.wokenEntryIds) {
|
|
2546
|
+
const e = m.get(id);
|
|
2547
|
+
if (!e) continue;
|
|
2548
|
+
m.delete(id);
|
|
2549
|
+
this.dirty.delete(id);
|
|
2550
|
+
this.deletePersisted(id);
|
|
2551
|
+
this.onChange?.({
|
|
2552
|
+
phase: "departed",
|
|
2553
|
+
entry: e,
|
|
2554
|
+
timestamp: input.timestamp
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
/** Promote a parked track into a persisted stationary entry. */
|
|
2559
|
+
async promote(entry) {
|
|
2560
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2561
|
+
this.dirty.delete(entry.id);
|
|
2562
|
+
try {
|
|
2563
|
+
await this.persist(entry);
|
|
2564
|
+
} catch (err) {
|
|
2565
|
+
this.logger.warn("stationary promote persist failed", {
|
|
2566
|
+
tags: { deviceId: entry.deviceId },
|
|
2567
|
+
meta: {
|
|
2568
|
+
entryId: entry.id,
|
|
2569
|
+
error: String(err)
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
this.onChange?.({
|
|
2574
|
+
phase: "appeared",
|
|
2575
|
+
entry,
|
|
2576
|
+
timestamp: entry.becameStationaryAt
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
|
|
2581
|
+
* flush any advanced `lastConfirmedAt`s to the store. Returns retired
|
|
2582
|
+
* entries (for logging).
|
|
2583
|
+
*
|
|
2584
|
+
* Expiry is measured against the device's latest processed-frame timestamp
|
|
2585
|
+
* ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
|
|
2586
|
+
* no frames between motion sessions, and that silence says nothing about
|
|
2587
|
+
* the object. Only when the camera has actually been WATCHING for `ttl`
|
|
2588
|
+
* beyond the last confirmation (frames flowed, object never matched) is the
|
|
2589
|
+
* object considered removed. A device with no recorded frame yet never
|
|
2590
|
+
* expires its entries. `now` only stamps the departed telemetry.
|
|
2591
|
+
*/
|
|
2592
|
+
async sweep(now) {
|
|
2593
|
+
const retired = [];
|
|
2594
|
+
for (const [deviceId, m] of this.byDevice) {
|
|
2595
|
+
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2596
|
+
if (observedAt === void 0) continue;
|
|
2597
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
|
|
2598
|
+
m.delete(id);
|
|
2599
|
+
this.dirty.delete(id);
|
|
2600
|
+
retired.push(e);
|
|
2601
|
+
this.deletePersisted(id);
|
|
2602
|
+
this.onChange?.({
|
|
2603
|
+
phase: "departed",
|
|
2604
|
+
entry: e,
|
|
2605
|
+
timestamp: now
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
if (m.size === 0) this.byDevice.delete(deviceId);
|
|
2609
|
+
}
|
|
2610
|
+
for (const id of [...this.dirty]) {
|
|
2611
|
+
this.dirty.delete(id);
|
|
2612
|
+
const entry = this.findById(id);
|
|
2613
|
+
if (!entry) continue;
|
|
2614
|
+
try {
|
|
2615
|
+
await this.store.update.mutate({
|
|
2616
|
+
collection: STATIONARY_COLLECTION,
|
|
2617
|
+
id,
|
|
2618
|
+
data: { lastConfirmedAt: entry.lastConfirmedAt }
|
|
2619
|
+
});
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
|
|
2622
|
+
entryId: id,
|
|
2623
|
+
error: String(err)
|
|
2624
|
+
} });
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return retired;
|
|
2628
|
+
}
|
|
2629
|
+
/** Drop a device's entries from memory WITHOUT deleting persisted rows.
|
|
2630
|
+
* Used on device unbind; a rebind reloads from the store. */
|
|
2631
|
+
forgetDevice(deviceId) {
|
|
2632
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2633
|
+
const m = this.byDevice.get(deviceId);
|
|
2634
|
+
if (!m) return;
|
|
2635
|
+
for (const id of m.keys()) this.dirty.delete(id);
|
|
2636
|
+
this.byDevice.delete(deviceId);
|
|
2637
|
+
}
|
|
2638
|
+
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
2639
|
+
async clearDevice(deviceId) {
|
|
2640
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2641
|
+
const m = this.byDevice.get(deviceId);
|
|
2642
|
+
if (m) {
|
|
2643
|
+
for (const id of [...m.keys()]) {
|
|
2644
|
+
this.dirty.delete(id);
|
|
2645
|
+
this.deletePersisted(id);
|
|
2646
|
+
}
|
|
2647
|
+
this.byDevice.delete(deviceId);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
deviceMap(deviceId) {
|
|
2651
|
+
let m = this.byDevice.get(deviceId);
|
|
2652
|
+
if (!m) {
|
|
2653
|
+
m = /* @__PURE__ */ new Map();
|
|
2654
|
+
this.byDevice.set(deviceId, m);
|
|
2655
|
+
}
|
|
2656
|
+
return m;
|
|
2657
|
+
}
|
|
2658
|
+
findById(id) {
|
|
2659
|
+
for (const m of this.byDevice.values()) {
|
|
2660
|
+
const e = m.get(id);
|
|
2661
|
+
if (e) return e;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
async persist(e) {
|
|
2665
|
+
await this.store.set.mutate({
|
|
2666
|
+
collection: STATIONARY_COLLECTION,
|
|
2667
|
+
key: e.id,
|
|
2668
|
+
value: {
|
|
2669
|
+
deviceId: e.deviceId,
|
|
2670
|
+
className: e.className,
|
|
2671
|
+
bbox: { ...e.bbox },
|
|
2672
|
+
frameWidth: e.frameWidth,
|
|
2673
|
+
frameHeight: e.frameHeight,
|
|
2674
|
+
firstSeenAt: e.firstSeenAt,
|
|
2675
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2676
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2677
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
|
|
2678
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2679
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2680
|
+
}
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
async deletePersisted(id) {
|
|
2684
|
+
try {
|
|
2685
|
+
await this.store.delete.mutate({
|
|
2686
|
+
collection: STATIONARY_COLLECTION,
|
|
2687
|
+
key: id
|
|
2688
|
+
});
|
|
2689
|
+
} catch (err) {
|
|
2690
|
+
this.logger.debug("stationary delete failed", { meta: {
|
|
2691
|
+
entryId: id,
|
|
2692
|
+
error: String(err)
|
|
2693
|
+
} });
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
function rowToEntry(id, data) {
|
|
2698
|
+
const deviceId = Number(data["deviceId"]);
|
|
2699
|
+
const className = data["className"];
|
|
2700
|
+
const bbox = data["bbox"];
|
|
2701
|
+
if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
|
|
2702
|
+
const sourceTrackId = data["sourceTrackId"];
|
|
2703
|
+
const label = data["label"];
|
|
2704
|
+
const keyFrameMediaKey = data["keyFrameMediaKey"];
|
|
2705
|
+
return {
|
|
2706
|
+
id,
|
|
2707
|
+
deviceId,
|
|
2708
|
+
className,
|
|
2709
|
+
bbox: {
|
|
2710
|
+
x: Number(bbox.x),
|
|
2711
|
+
y: Number(bbox.y),
|
|
2712
|
+
w: Number(bbox.w),
|
|
2713
|
+
h: Number(bbox.h)
|
|
2714
|
+
},
|
|
2715
|
+
frameWidth: Number(data["frameWidth"] ?? 0),
|
|
2716
|
+
frameHeight: Number(data["frameHeight"] ?? 0),
|
|
2717
|
+
firstSeenAt: Number(data["firstSeenAt"] ?? 0),
|
|
2718
|
+
becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
|
|
2719
|
+
lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
|
|
2720
|
+
...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
|
|
2721
|
+
...typeof label === "string" ? { label } : {},
|
|
2722
|
+
...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
//#endregion
|
|
2726
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
|
|
2727
|
+
/**
|
|
2728
|
+
* Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
|
|
2729
|
+
* Empty when the entry has no frame dims (can't normalise) or no zone matches.
|
|
2730
|
+
*/
|
|
2731
|
+
function computeStationaryEntryZones(entry, zones) {
|
|
2732
|
+
if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
|
|
2733
|
+
const centroidPx = bboxCentroid(entry.bbox);
|
|
2734
|
+
const point = {
|
|
2735
|
+
x: centroidPx.x / entry.frameWidth,
|
|
2736
|
+
y: centroidPx.y / entry.frameHeight
|
|
2737
|
+
};
|
|
2738
|
+
const matched = [];
|
|
2739
|
+
for (const zone of zones) {
|
|
2740
|
+
if (zone.polygon.length < 3) continue;
|
|
2741
|
+
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2742
|
+
}
|
|
2743
|
+
return matched;
|
|
2744
|
+
}
|
|
2745
|
+
//#endregion
|
|
2746
|
+
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2747
|
+
/**
|
|
2748
|
+
* Pure: no side effects. `continuing` = still active from last frame;
|
|
2749
|
+
* `birth` = a brand-new track's first sighting; `resurrection` = a known track
|
|
2750
|
+
* re-entering the active set after being lost.
|
|
2751
|
+
*/
|
|
2752
|
+
function classifyTrackAppearance(input) {
|
|
2753
|
+
if (input.inPrevActive) return "continuing";
|
|
2754
|
+
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
2755
|
+
}
|
|
2756
|
+
//#endregion
|
|
2115
2757
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
2116
2758
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
2117
2759
|
const scored = [];
|
|
@@ -2121,6 +2763,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2121
2763
|
let bestEventId = t.bestEventId;
|
|
2122
2764
|
if (importance === void 0) {
|
|
2123
2765
|
const peak = await peakLookup(t.trackId);
|
|
2766
|
+
const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
2767
|
+
x: p.x,
|
|
2768
|
+
y: p.y
|
|
2769
|
+
})), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
|
|
2124
2770
|
importance = computeImportance({
|
|
2125
2771
|
peakConfidence: peak.peakConfidence,
|
|
2126
2772
|
className: t.className,
|
|
@@ -2128,7 +2774,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2128
2774
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
2129
2775
|
totalDistance: t.totalDistance,
|
|
2130
2776
|
zonesVisited: t.zonesVisited,
|
|
2131
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
2777
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
2778
|
+
...staticMetrics ? {
|
|
2779
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
2780
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
2781
|
+
} : {}
|
|
2132
2782
|
}).importance;
|
|
2133
2783
|
bestEventId = bestEventId ?? peak.bestEventId;
|
|
2134
2784
|
}
|
|
@@ -2140,7 +2790,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2140
2790
|
className: t.className,
|
|
2141
2791
|
...t.label !== void 0 ? { label: t.label } : {},
|
|
2142
2792
|
importance,
|
|
2143
|
-
bestEventId: bestEventId ??
|
|
2793
|
+
bestEventId: bestEventId ?? t.trackId,
|
|
2144
2794
|
windowMs: t.lastSeen - t.firstSeen
|
|
2145
2795
|
});
|
|
2146
2796
|
}
|
|
@@ -2377,6 +3027,10 @@ var TRACKS_COLUMNS = [
|
|
|
2377
3027
|
{
|
|
2378
3028
|
name: "importanceReason",
|
|
2379
3029
|
type: "TEXT"
|
|
3030
|
+
},
|
|
3031
|
+
{
|
|
3032
|
+
name: "audioLabels",
|
|
3033
|
+
type: "JSON"
|
|
2380
3034
|
}
|
|
2381
3035
|
];
|
|
2382
3036
|
var TRACKS_INDEXES = [{
|
|
@@ -2386,6 +3040,17 @@ var TRACKS_INDEXES = [{
|
|
|
2386
3040
|
name: "idx_tracks_device_firstSeen",
|
|
2387
3041
|
columns: ["deviceId", "firstSeen"]
|
|
2388
3042
|
}];
|
|
3043
|
+
/** Serialize the per-label aggregate map into the `Track.audioLabels`
|
|
3044
|
+
* array shape, most-frequent label first. */
|
|
3045
|
+
function audioLabelsToArray(agg) {
|
|
3046
|
+
return [...agg.entries()].map(([label, a]) => ({
|
|
3047
|
+
label,
|
|
3048
|
+
peakScore: a.peakScore,
|
|
3049
|
+
count: a.count,
|
|
3050
|
+
firstAt: a.firstAt,
|
|
3051
|
+
lastAt: a.lastAt
|
|
3052
|
+
})).sort((a, b) => b.count - a.count);
|
|
3053
|
+
}
|
|
2389
3054
|
function cloneTrack(t) {
|
|
2390
3055
|
return {
|
|
2391
3056
|
trackId: t.trackId,
|
|
@@ -2412,7 +3077,8 @@ function cloneTrack(t) {
|
|
|
2412
3077
|
active: t.active,
|
|
2413
3078
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2414
3079
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2415
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3080
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3081
|
+
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
|
|
2416
3082
|
};
|
|
2417
3083
|
}
|
|
2418
3084
|
var TrackStore = class {
|
|
@@ -2473,25 +3139,81 @@ var TrackStore = class {
|
|
|
2473
3139
|
this.active.set(params.trackId, fresh);
|
|
2474
3140
|
return fresh;
|
|
2475
3141
|
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Record one audio-classification EPISODE against every track currently
|
|
3144
|
+
* active on the device — "what was heard on this camera while the track
|
|
3145
|
+
* was alive". Called from the confident-classification audio-event insert
|
|
3146
|
+
* (score ≥ device `classificationMinScore`, class-change-or-heartbeat
|
|
3147
|
+
* coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
|
|
3148
|
+
*/
|
|
3149
|
+
addAudioLabelEpisode(deviceId, label, score, timestamp) {
|
|
3150
|
+
for (const t of this.active.values()) {
|
|
3151
|
+
if (t.deviceId !== deviceId || !t.active) continue;
|
|
3152
|
+
const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
|
|
3153
|
+
const entry = agg.get(label);
|
|
3154
|
+
if (entry) {
|
|
3155
|
+
entry.peakScore = Math.max(entry.peakScore, score);
|
|
3156
|
+
entry.count += 1;
|
|
3157
|
+
entry.lastAt = timestamp;
|
|
3158
|
+
} else agg.set(label, {
|
|
3159
|
+
peakScore: score,
|
|
3160
|
+
count: 1,
|
|
3161
|
+
firstAt: timestamp,
|
|
3162
|
+
lastAt: timestamp
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
2476
3166
|
/** Attach a snapshot reference to an active track. */
|
|
2477
3167
|
addSnapshot(trackId, snapshot) {
|
|
2478
3168
|
const t = this.active.get(trackId);
|
|
2479
3169
|
if (!t) return;
|
|
2480
3170
|
t.snapshots.push(snapshot);
|
|
2481
3171
|
t.lastSnapshotAt = snapshot.timestamp;
|
|
3172
|
+
t.lastSnapshotBbox = { ...snapshot.position.bbox };
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
|
|
3176
|
+
* the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
|
|
3177
|
+
* lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
|
|
3178
|
+
* only advances when the dispatcher round-trip returns (~50-200ms of sharp
|
|
3179
|
+
* encode), so at 10-25fps several consecutive frames pass
|
|
3180
|
+
* `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
|
|
3181
|
+
* snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
|
|
3182
|
+
* rolling `lastFrame`.
|
|
3183
|
+
*
|
|
3184
|
+
* No rollback: if the write later fails the slot is simply lost (a rare dropped
|
|
3185
|
+
* snapshot is preferable to a burst). `addSnapshot` re-stamps the same
|
|
3186
|
+
* clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
|
|
3187
|
+
* an unknown/expired track (the active entry is dropped on expiry, so there is
|
|
3188
|
+
* no separate map to leak).
|
|
3189
|
+
*/
|
|
3190
|
+
markSnapshotPending(trackId, timestamp, bbox) {
|
|
3191
|
+
const t = this.active.get(trackId);
|
|
3192
|
+
if (!t) return;
|
|
3193
|
+
t.lastSnapshotAt = timestamp;
|
|
3194
|
+
t.lastSnapshotBbox = { ...bbox };
|
|
2482
3195
|
}
|
|
2483
3196
|
lastSnapshotAt(trackId) {
|
|
2484
3197
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
2485
3198
|
}
|
|
3199
|
+
/** Bbox reference of the last captured snapshot (or the seed bbox), for the
|
|
3200
|
+
* periodic-snapshot movement gate. Undefined until the clock is seeded. */
|
|
3201
|
+
lastSnapshotBbox(trackId) {
|
|
3202
|
+
const b = this.active.get(trackId)?.lastSnapshotBbox;
|
|
3203
|
+
return b ? { ...b } : void 0;
|
|
3204
|
+
}
|
|
2486
3205
|
/**
|
|
2487
3206
|
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
2488
3207
|
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
2489
3208
|
* track begins rather than immediately — the `firstFrame` already covers the
|
|
2490
3209
|
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
2491
3210
|
*/
|
|
2492
|
-
seedSnapshotClock(trackId, timestamp) {
|
|
3211
|
+
seedSnapshotClock(trackId, timestamp, bbox) {
|
|
2493
3212
|
const t = this.active.get(trackId);
|
|
2494
|
-
if (t && t.lastSnapshotAt === 0)
|
|
3213
|
+
if (t && t.lastSnapshotAt === 0) {
|
|
3214
|
+
t.lastSnapshotAt = timestamp;
|
|
3215
|
+
if (bbox) t.lastSnapshotBbox = { ...bbox };
|
|
3216
|
+
}
|
|
2495
3217
|
}
|
|
2496
3218
|
getActive(deviceId) {
|
|
2497
3219
|
const out = [];
|
|
@@ -2502,6 +3224,21 @@ var TrackStore = class {
|
|
|
2502
3224
|
const t = this.active.get(trackId);
|
|
2503
3225
|
return t && t.active ? cloneTrack(t) : null;
|
|
2504
3226
|
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Cheap read of an active track's promotion-relevant fields WITHOUT the deep
|
|
3229
|
+
* clone `getActiveByTrack` does — the returned `positions` is the live
|
|
3230
|
+
* internal array (read-only; callers must not mutate). Feeds the per-frame
|
|
3231
|
+
* stationary-promotion check, called at inference fps. Null if unknown/expired.
|
|
3232
|
+
*/
|
|
3233
|
+
peekActive(trackId) {
|
|
3234
|
+
const t = this.active.get(trackId);
|
|
3235
|
+
if (!t || !t.active) return null;
|
|
3236
|
+
return {
|
|
3237
|
+
firstSeen: t.firstSeen,
|
|
3238
|
+
positions: t.positions,
|
|
3239
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
2505
3242
|
/** Expire tracks whose `lastSeen` is older than TTL. Persists each
|
|
2506
3243
|
* expired track to the declared collection and returns them. */
|
|
2507
3244
|
async expireStale(nowMs) {
|
|
@@ -2607,6 +3344,15 @@ var TrackStore = class {
|
|
|
2607
3344
|
clearAll() {
|
|
2608
3345
|
this.active.clear();
|
|
2609
3346
|
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
3349
|
+
* when a track is PROMOTED to a stationary-object registry entry: the durable
|
|
3350
|
+
* record for a parked object is the registry entry, not a Track, so the track
|
|
3351
|
+
* must NOT land in the key-event feed. No-op for an unknown/expired track.
|
|
3352
|
+
*/
|
|
3353
|
+
dropActive(trackId) {
|
|
3354
|
+
this.active.delete(trackId);
|
|
3355
|
+
}
|
|
2610
3356
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2611
3357
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2612
3358
|
async deletePersisted(trackId) {
|
|
@@ -2739,7 +3485,8 @@ var TrackStore = class {
|
|
|
2739
3485
|
state: t.state,
|
|
2740
3486
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2741
3487
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2742
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3488
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3489
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
2743
3490
|
}
|
|
2744
3491
|
});
|
|
2745
3492
|
}
|
|
@@ -2752,6 +3499,7 @@ var TrackStore = class {
|
|
|
2752
3499
|
const importance = data["importance"];
|
|
2753
3500
|
const bestEventId = data["bestEventId"];
|
|
2754
3501
|
const importanceReason = data["importanceReason"];
|
|
3502
|
+
const audioLabels = data["audioLabels"];
|
|
2755
3503
|
return {
|
|
2756
3504
|
trackId: id,
|
|
2757
3505
|
deviceId: Number(data["deviceId"]),
|
|
@@ -2768,7 +3516,8 @@ var TrackStore = class {
|
|
|
2768
3516
|
active: false,
|
|
2769
3517
|
...typeof importance === "number" ? { importance } : {},
|
|
2770
3518
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
2771
|
-
...typeof importanceReason === "string" ? { importanceReason } : {}
|
|
3519
|
+
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3520
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
2772
3521
|
};
|
|
2773
3522
|
}
|
|
2774
3523
|
};
|
|
@@ -3881,13 +4630,10 @@ function stripNulls(data) {
|
|
|
3881
4630
|
//#endregion
|
|
3882
4631
|
//#region src/shared/frame/resolve-frame.ts
|
|
3883
4632
|
/**
|
|
3884
|
-
* Resolve the pixels a `FrameHandle` refers to
|
|
3885
|
-
*
|
|
3886
|
-
* `deps.getRemoteFrame`. Returns `null` when the frame is no longer
|
|
3887
|
-
* available (slot recycled locally, or the remote node reports no frame).
|
|
4633
|
+
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
4634
|
+
* Returns `null` when the frame is no longer available.
|
|
3888
4635
|
*/
|
|
3889
4636
|
async function resolveFrame(handle, deps) {
|
|
3890
|
-
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3891
4637
|
return deps.getRemoteFrame(handle);
|
|
3892
4638
|
}
|
|
3893
4639
|
//#endregion
|
|
@@ -4064,11 +4810,7 @@ var EventMediaDispatcher = class {
|
|
|
4064
4810
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
4065
4811
|
let decoded;
|
|
4066
4812
|
try {
|
|
4067
|
-
decoded = await resolveFrame(frameHandle, {
|
|
4068
|
-
ownNodeId: this.deps.ownNodeId,
|
|
4069
|
-
readers: this.deps.readers,
|
|
4070
|
-
getRemoteFrame: this.deps.getRemoteFrame
|
|
4071
|
-
});
|
|
4813
|
+
decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
4072
4814
|
} catch (err) {
|
|
4073
4815
|
this.deps.logger.debug("event media: resolveFrame threw", {
|
|
4074
4816
|
tags: { deviceId },
|
|
@@ -4379,8 +5121,6 @@ var EmbeddingDispatcher = class {
|
|
|
4379
5121
|
encoder;
|
|
4380
5122
|
eventBus;
|
|
4381
5123
|
logger;
|
|
4382
|
-
ownNodeId;
|
|
4383
|
-
readers;
|
|
4384
5124
|
getRemoteFrame;
|
|
4385
5125
|
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4386
5126
|
pendingCrops = /* @__PURE__ */ new Map();
|
|
@@ -4393,8 +5133,6 @@ var EmbeddingDispatcher = class {
|
|
|
4393
5133
|
this.encoder = deps.encoder;
|
|
4394
5134
|
this.eventBus = deps.eventBus;
|
|
4395
5135
|
this.logger = deps.logger;
|
|
4396
|
-
this.ownNodeId = deps.ownNodeId;
|
|
4397
|
-
this.readers = deps.readers;
|
|
4398
5136
|
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4399
5137
|
}
|
|
4400
5138
|
async start() {
|
|
@@ -4446,11 +5184,7 @@ var EmbeddingDispatcher = class {
|
|
|
4446
5184
|
}
|
|
4447
5185
|
let decoded;
|
|
4448
5186
|
try {
|
|
4449
|
-
decoded = await resolveFrame(handle, {
|
|
4450
|
-
ownNodeId: this.ownNodeId,
|
|
4451
|
-
readers: this.readers,
|
|
4452
|
-
getRemoteFrame: this.getRemoteFrame
|
|
4453
|
-
});
|
|
5187
|
+
decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
|
|
4454
5188
|
} catch (err) {
|
|
4455
5189
|
this.logger.debug("skip: resolveFrame threw", {
|
|
4456
5190
|
tags: { deviceId: Number(deviceId) },
|
|
@@ -4697,6 +5431,17 @@ var RESOLUTION_MS = {
|
|
|
4697
5431
|
* latest state is never lost.
|
|
4698
5432
|
*/
|
|
4699
5433
|
var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
|
|
5434
|
+
/**
|
|
5435
|
+
* Cadence of the synthetic occupancy baseline. When a camera is detached (no
|
|
5436
|
+
* inference frames) but has persisted parked objects, the history ring would
|
|
5437
|
+
* otherwise stay empty and the chart would read "No occupancy history yet". A
|
|
5438
|
+
* device WITH parked entries gets one hydrated sample per this interval — a
|
|
5439
|
+
* flat baseline of the parked count — so the graph shows the parking lot's
|
|
5440
|
+
* standing occupancy instead of a gap. No sample is emitted for a device
|
|
5441
|
+
* without entries, and a real `recordFrame` in the same window suppresses the
|
|
5442
|
+
* baseline (it already appended a richer sample).
|
|
5443
|
+
*/
|
|
5444
|
+
var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
|
|
4700
5445
|
var ZoneAnalyticsProvider = class {
|
|
4701
5446
|
ctx;
|
|
4702
5447
|
snapshots = /* @__PURE__ */ new Map();
|
|
@@ -4713,8 +5458,17 @@ var ZoneAnalyticsProvider = class {
|
|
|
4713
5458
|
/** Last logged frame-wide occupancy total per device — so the occupancy log
|
|
4714
5459
|
* fires only when the count actually changes, not every inference frame. */
|
|
4715
5460
|
lastOccupancyTotal = /* @__PURE__ */ new Map();
|
|
5461
|
+
/** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
|
|
5462
|
+
* device with parked objects but no live frames. `null` when disabled. */
|
|
5463
|
+
baselineTimer = null;
|
|
4716
5464
|
constructor(ctx) {
|
|
4717
5465
|
this.ctx = ctx;
|
|
5466
|
+
if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
|
|
5467
|
+
this.baselineTimer = setInterval(() => {
|
|
5468
|
+
this.appendBaselineSamples();
|
|
5469
|
+
}, BASELINE_SAMPLE_INTERVAL_MS);
|
|
5470
|
+
this.baselineTimer.unref?.();
|
|
5471
|
+
}
|
|
4718
5472
|
this.sliceThrottle = new SliceThrottler({
|
|
4719
5473
|
intervalMs: SLICE_WRITE_INTERVAL_MS$1,
|
|
4720
5474
|
equalsIgnoringTs: snapshotEqualsIgnoringTs,
|
|
@@ -4736,9 +5490,10 @@ var ZoneAnalyticsProvider = class {
|
|
|
4736
5490
|
/** Stop pending throttle timers — called from addon shutdown. */
|
|
4737
5491
|
destroy() {
|
|
4738
5492
|
this.sliceThrottle.destroy();
|
|
5493
|
+
if (this.baselineTimer) clearInterval(this.baselineTimer);
|
|
4739
5494
|
}
|
|
4740
5495
|
async getCurrentSnapshot({ deviceId }) {
|
|
4741
|
-
return this.snapshots.get(deviceId) ??
|
|
5496
|
+
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
4742
5497
|
}
|
|
4743
5498
|
async getZoneHistory(input) {
|
|
4744
5499
|
return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
|
|
@@ -4791,6 +5546,65 @@ var ZoneAnalyticsProvider = class {
|
|
|
4791
5546
|
this.lastOccupancyTotal.delete(deviceId);
|
|
4792
5547
|
this.sliceThrottle.forgetDevice(deviceId);
|
|
4793
5548
|
}
|
|
5549
|
+
/**
|
|
5550
|
+
* Build an occupancy snapshot for a device purely from its parked-object
|
|
5551
|
+
* registry (no live frame). Returns `null` when hydration is unavailable or
|
|
5552
|
+
* the device has no parked objects — a device with neither frames nor entries
|
|
5553
|
+
* legitimately reports `null`. The snapshot's `ts` is the most recent
|
|
5554
|
+
* `lastConfirmedAt` across entries, falling back to the current tick.
|
|
5555
|
+
*/
|
|
5556
|
+
async hydrateFromRegistry(deviceId) {
|
|
5557
|
+
const listStationary = this.ctx.listStationaryObjects;
|
|
5558
|
+
if (!listStationary) return null;
|
|
5559
|
+
const entries = listStationary(deviceId);
|
|
5560
|
+
if (entries.length === 0) return null;
|
|
5561
|
+
let zones = [];
|
|
5562
|
+
try {
|
|
5563
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5564
|
+
} catch (err) {
|
|
5565
|
+
this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
|
|
5566
|
+
tags: { deviceId },
|
|
5567
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
5568
|
+
});
|
|
5569
|
+
}
|
|
5570
|
+
const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
|
|
5571
|
+
return buildStationarySnapshot({
|
|
5572
|
+
deviceId,
|
|
5573
|
+
entries,
|
|
5574
|
+
zones,
|
|
5575
|
+
timestamp: ts
|
|
5576
|
+
});
|
|
5577
|
+
}
|
|
5578
|
+
/**
|
|
5579
|
+
* Baseline sampler tick: for every device with parked objects, append a
|
|
5580
|
+
* hydrated sample to the history ring at the CURRENT time — but only when a
|
|
5581
|
+
* real frame hasn't already appended a sample within this interval (frames
|
|
5582
|
+
* flowing = richer samples, no synthetic baseline needed).
|
|
5583
|
+
*/
|
|
5584
|
+
async appendBaselineSamples() {
|
|
5585
|
+
const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
|
|
5586
|
+
const now = Date.now();
|
|
5587
|
+
for (const deviceId of deviceIds) {
|
|
5588
|
+
const ring = this.history.get(deviceId);
|
|
5589
|
+
if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
|
|
5590
|
+
const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
|
|
5591
|
+
if (entries.length === 0) continue;
|
|
5592
|
+
let zones = [];
|
|
5593
|
+
try {
|
|
5594
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5595
|
+
} catch {}
|
|
5596
|
+
const snapshot = buildStationarySnapshot({
|
|
5597
|
+
deviceId,
|
|
5598
|
+
entries,
|
|
5599
|
+
zones,
|
|
5600
|
+
timestamp: now
|
|
5601
|
+
});
|
|
5602
|
+
if (snapshot) {
|
|
5603
|
+
this.appendHistory(deviceId, snapshot);
|
|
5604
|
+
this.sliceThrottle.push(deviceId, snapshot);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
4794
5608
|
appendHistory(deviceId, snapshot) {
|
|
4795
5609
|
const ring = this.history.get(deviceId) ?? [];
|
|
4796
5610
|
const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
|
|
@@ -4877,9 +5691,43 @@ function computeSnapshot(input) {
|
|
|
4877
5691
|
unzoned: {
|
|
4878
5692
|
totalObjects: unzonedTotal,
|
|
4879
5693
|
byClass: unzonedByClass
|
|
4880
|
-
}
|
|
5694
|
+
},
|
|
5695
|
+
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
4881
5696
|
};
|
|
4882
5697
|
}
|
|
5698
|
+
/** Most recent `lastConfirmedAt` across parked entries (0 when none). */
|
|
5699
|
+
function mostRecentStationaryConfirmedAt(entries) {
|
|
5700
|
+
let max = 0;
|
|
5701
|
+
for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
|
|
5702
|
+
return max;
|
|
5703
|
+
}
|
|
5704
|
+
/**
|
|
5705
|
+
* Build an occupancy snapshot from parked-object registry entries alone —
|
|
5706
|
+
* used when no live inference frame is available (fresh respawn, camera
|
|
5707
|
+
* detached). Each entry is folded into the frame aggregate AND attributed to
|
|
5708
|
+
* the zones its normalised bbox centroid falls inside (via
|
|
5709
|
+
* {@link computeStationaryEntryZones}), so a zone drawn over a parked car
|
|
5710
|
+
* reports a count of 1. Returns `null` for an empty entry list. Reuses
|
|
5711
|
+
* {@link computeSnapshot} — the SAME aggregation the live frame path runs.
|
|
5712
|
+
*/
|
|
5713
|
+
function buildStationarySnapshot(input) {
|
|
5714
|
+
if (input.entries.length === 0) return null;
|
|
5715
|
+
const tracked = input.entries.map((e) => ({
|
|
5716
|
+
trackId: `stationary:${e.id}`,
|
|
5717
|
+
className: e.className,
|
|
5718
|
+
zones: computeStationaryEntryZones(e, input.zones)
|
|
5719
|
+
}));
|
|
5720
|
+
const first = input.entries[0];
|
|
5721
|
+
return computeSnapshot({
|
|
5722
|
+
deviceId: input.deviceId,
|
|
5723
|
+
timestamp: input.timestamp,
|
|
5724
|
+
frameWidth: first.frameWidth,
|
|
5725
|
+
frameHeight: first.frameHeight,
|
|
5726
|
+
tracked,
|
|
5727
|
+
zones: input.zones,
|
|
5728
|
+
stationaryObjects: input.entries
|
|
5729
|
+
});
|
|
5730
|
+
}
|
|
4883
5731
|
//#endregion
|
|
4884
5732
|
//#region src/pipeline-analytics/audio-metrics-provider.ts
|
|
4885
5733
|
var AUDIO_METRICS_CAP_NAME = "audio-metrics";
|
|
@@ -5453,7 +6301,18 @@ var MediaSettingsSchema = object({
|
|
|
5453
6301
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
5454
6302
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
5455
6303
|
* has elapsed since its previous one. */
|
|
5456
|
-
snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
|
|
6304
|
+
snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3),
|
|
6305
|
+
/** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
|
|
6306
|
+
* elapsed, a fresh snapshot is only captured when the track's centroid moved
|
|
6307
|
+
* at least this fraction of the frame DIAGONAL since the last captured
|
|
6308
|
+
* snapshot. Suppresses near-identical frames from a long-lived / stationary
|
|
6309
|
+
* track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
|
|
6310
|
+
* frame diagonal (~66px on 1080p). */
|
|
6311
|
+
snapshotMovementThreshold: number().min(0).max(1).default(.03),
|
|
6312
|
+
/** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
|
|
6313
|
+
* still-present track after this much wall-clock without one, so its
|
|
6314
|
+
* filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
|
|
6315
|
+
snapshotMaxIdleMs: number().int().min(1e3).max(6e5).default(3e4)
|
|
5457
6316
|
});
|
|
5458
6317
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
5459
6318
|
/**
|
|
@@ -5468,7 +6327,148 @@ function resolveMediaSettings(raw) {
|
|
|
5468
6327
|
return {
|
|
5469
6328
|
cropPadding: pick("cropPadding"),
|
|
5470
6329
|
saveThumbnails: pick("saveThumbnails"),
|
|
5471
|
-
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
6330
|
+
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
6331
|
+
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
6332
|
+
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
6333
|
+
};
|
|
6334
|
+
}
|
|
6335
|
+
function centroidOf(b) {
|
|
6336
|
+
return {
|
|
6337
|
+
x: b.x + b.w / 2,
|
|
6338
|
+
y: b.y + b.h / 2
|
|
6339
|
+
};
|
|
6340
|
+
}
|
|
6341
|
+
/** Centroid displacement between two boxes as a fraction of the frame diagonal.
|
|
6342
|
+
* Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
|
|
6343
|
+
* to pure-time behaviour instead of dividing by zero. */
|
|
6344
|
+
function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
6345
|
+
const diag = Math.hypot(frameWidth, frameHeight);
|
|
6346
|
+
if (diag <= 0) return 0;
|
|
6347
|
+
const ca = centroidOf(a);
|
|
6348
|
+
const cb = centroidOf(b);
|
|
6349
|
+
return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
|
|
6350
|
+
}
|
|
6351
|
+
/**
|
|
6352
|
+
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
6353
|
+
* frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
|
|
6354
|
+
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
6355
|
+
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
6356
|
+
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
6357
|
+
* until the object moves.
|
|
6358
|
+
*/
|
|
6359
|
+
function evaluatePeriodicSnapshot(input) {
|
|
6360
|
+
const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
|
|
6361
|
+
if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
|
|
6362
|
+
if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
|
|
6363
|
+
const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6364
|
+
if (fastMoved >= .08) return {
|
|
6365
|
+
capture: true,
|
|
6366
|
+
reason: "fast-mover",
|
|
6367
|
+
movedFraction: fastMoved
|
|
6368
|
+
};
|
|
6369
|
+
}
|
|
6370
|
+
return {
|
|
6371
|
+
capture: false,
|
|
6372
|
+
reason: "interval-not-elapsed",
|
|
6373
|
+
movedFraction: 0
|
|
6374
|
+
};
|
|
6375
|
+
}
|
|
6376
|
+
if (lastSnapshotBbox === void 0) return {
|
|
6377
|
+
capture: true,
|
|
6378
|
+
reason: "no-reference",
|
|
6379
|
+
movedFraction: 0
|
|
6380
|
+
};
|
|
6381
|
+
const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6382
|
+
if (movedFraction >= movementThreshold) return {
|
|
6383
|
+
capture: true,
|
|
6384
|
+
reason: "moved",
|
|
6385
|
+
movedFraction
|
|
6386
|
+
};
|
|
6387
|
+
const idleLimit = Math.max(maxIdleMs, intervalMs);
|
|
6388
|
+
if (now - lastSnapshotAt >= idleLimit) return {
|
|
6389
|
+
capture: true,
|
|
6390
|
+
reason: "idle-forced",
|
|
6391
|
+
movedFraction
|
|
6392
|
+
};
|
|
6393
|
+
return {
|
|
6394
|
+
capture: false,
|
|
6395
|
+
reason: "stationary-skip",
|
|
6396
|
+
movedFraction
|
|
6397
|
+
};
|
|
6398
|
+
}
|
|
6399
|
+
//#endregion
|
|
6400
|
+
//#region src/pipeline-analytics/periodic-media-plan.ts
|
|
6401
|
+
/**
|
|
6402
|
+
* Decide the periodic media writes for one track on one frame. Pure: no side
|
|
6403
|
+
* effects. The caller advances its own `lastFrameAt` clock only when the
|
|
6404
|
+
* returned `rollingLastFrame` is true.
|
|
6405
|
+
*
|
|
6406
|
+
* INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
|
|
6407
|
+
* rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
|
|
6408
|
+
* can never duplicate one.
|
|
6409
|
+
*/
|
|
6410
|
+
function planPeriodicMedia(input) {
|
|
6411
|
+
const appendSnapshot = input.dueSnapshot;
|
|
6412
|
+
return {
|
|
6413
|
+
appendSnapshot,
|
|
6414
|
+
rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
|
|
6415
|
+
bestThumbnail: input.isNewBest
|
|
6416
|
+
};
|
|
6417
|
+
}
|
|
6418
|
+
//#endregion
|
|
6419
|
+
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
6420
|
+
/**
|
|
6421
|
+
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
6422
|
+
* (Design B — one native full-frame per track at its best-detection moment).
|
|
6423
|
+
*
|
|
6424
|
+
* ## Why this exists (the missing native keyFrame)
|
|
6425
|
+
*
|
|
6426
|
+
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
6427
|
+
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
6428
|
+
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
6429
|
+
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
6430
|
+
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
6431
|
+
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
6432
|
+
*
|
|
6433
|
+
* The fix decouples the `keyFrame` from the clip path: it is captured on the
|
|
6434
|
+
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
6435
|
+
* `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
|
|
6436
|
+
* `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
|
|
6437
|
+
* retained NATIVE surface and only falls back to the detection frame on a miss).
|
|
6438
|
+
* A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
|
|
6439
|
+
* LARGER than the detection raster (up to the cap), which is the whole point of
|
|
6440
|
+
* the `keyFrame` kind.
|
|
6441
|
+
*/
|
|
6442
|
+
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
6443
|
+
* Native resolution is the point, but a full 4K RGB surface over the transport
|
|
6444
|
+
* per new-best is wasteful for a web detail view — 1920px keeps a sharp native
|
|
6445
|
+
* frame while bounding the copy (a miss falls back to the detection-res frame,
|
|
6446
|
+
* which is already ≤640px). */
|
|
6447
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
6448
|
+
/**
|
|
6449
|
+
* The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
|
|
6450
|
+
* the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
|
|
6451
|
+
* downstream keeps one `keyFrame` per track (the current peak).
|
|
6452
|
+
*/
|
|
6453
|
+
function selectKeyFrameTrackIds(targets) {
|
|
6454
|
+
return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
|
|
6455
|
+
}
|
|
6456
|
+
/**
|
|
6457
|
+
* Build the `captureCrop` request for a track's native `keyFrame`: the FULL
|
|
6458
|
+
* frame (no padding) at the native width cap. The full-frame box is what makes
|
|
6459
|
+
* the capture route through the native surface at native resolution instead of
|
|
6460
|
+
* a tight ≤640 detection crop.
|
|
6461
|
+
*/
|
|
6462
|
+
function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
|
|
6463
|
+
return {
|
|
6464
|
+
bbox: {
|
|
6465
|
+
x: 0,
|
|
6466
|
+
y: 0,
|
|
6467
|
+
w: frameWidth,
|
|
6468
|
+
h: frameHeight
|
|
6469
|
+
},
|
|
6470
|
+
padding: 0,
|
|
6471
|
+
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
|
|
5472
6472
|
};
|
|
5473
6473
|
}
|
|
5474
6474
|
//#endregion
|
|
@@ -6805,6 +7805,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
|
6805
7805
|
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
6806
7806
|
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
6807
7807
|
/**
|
|
7808
|
+
* Consecutive frame-plane misses ("frame + crop both missed") after which a step
|
|
7809
|
+
* is ABANDONED for the track. The decode worker serves native crops from a RAM
|
|
7810
|
+
* lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
|
|
7811
|
+
* from an evicted handle and is a guaranteed miss forever. Retrying a
|
|
7812
|
+
* permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
|
|
7813
|
+
* consecutive misses (each ≥ one tick apart) confidently means the frame is gone
|
|
7814
|
+
* for good, while still tolerating a single transient decode-worker hiccup /
|
|
7815
|
+
* respawn on a genuinely live track (the counter resets on any resolved result).
|
|
7816
|
+
*/
|
|
7817
|
+
var MAX_CONSECUTIVE_FRAME_MISSES = 3;
|
|
7818
|
+
/**
|
|
6808
7819
|
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
6809
7820
|
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
6810
7821
|
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
@@ -6825,7 +7836,9 @@ var DetailScheduler = class {
|
|
|
6825
7836
|
firedCount: 1,
|
|
6826
7837
|
lastFiredAt: nowMs,
|
|
6827
7838
|
sticky: false,
|
|
6828
|
-
retryPending: false
|
|
7839
|
+
retryPending: false,
|
|
7840
|
+
consecutiveFrameMisses: 0,
|
|
7841
|
+
abandoned: false
|
|
6829
7842
|
};
|
|
6830
7843
|
steps.set(stepAnnounce.stepId, state);
|
|
6831
7844
|
requests.push({
|
|
@@ -6858,7 +7871,7 @@ var DetailScheduler = class {
|
|
|
6858
7871
|
tick(nowMs) {
|
|
6859
7872
|
const requests = [];
|
|
6860
7873
|
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
6861
|
-
if (state.sticky) continue;
|
|
7874
|
+
if (state.sticky || state.abandoned) continue;
|
|
6862
7875
|
if (state.retryPending) {
|
|
6863
7876
|
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
6864
7877
|
if (!this.underMaxPerTrack(state)) {
|
|
@@ -6896,7 +7909,8 @@ var DetailScheduler = class {
|
|
|
6896
7909
|
if (!steps) return;
|
|
6897
7910
|
const state = steps.get(stepId);
|
|
6898
7911
|
if (!state) return;
|
|
6899
|
-
if (state.sticky) return;
|
|
7912
|
+
if (state.sticky || state.abandoned) return;
|
|
7913
|
+
state.consecutiveFrameMisses = 0;
|
|
6900
7914
|
const { stickyOnConfidence } = state.announce.cadence;
|
|
6901
7915
|
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
6902
7916
|
state.sticky = true;
|
|
@@ -6911,11 +7925,37 @@ var DetailScheduler = class {
|
|
|
6911
7925
|
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6912
7926
|
}
|
|
6913
7927
|
}
|
|
7928
|
+
/**
|
|
7929
|
+
* A dispatched request could not resolve a frame AT ALL — the frame handle
|
|
7930
|
+
* lease was evicted AND the crop fallback was unavailable (the "frame + crop
|
|
7931
|
+
* both missed" outcome). This is fundamentally different from `onResult(null)`:
|
|
7932
|
+
* there the frame plane WORKED and the model merely returned nothing (worth a
|
|
7933
|
+
* retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
|
|
7934
|
+
* handle every time, so it can never recover from this request. It is
|
|
7935
|
+
* retry-eligible only for a bounded number of CONSECUTIVE attempts; after
|
|
7936
|
+
* {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
|
|
7937
|
+
* track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
|
|
7938
|
+
* is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
|
|
7939
|
+
*/
|
|
7940
|
+
onFrameMiss(trackId, stepId, _nowMs) {
|
|
7941
|
+
const steps = this.tracks.get(trackId);
|
|
7942
|
+
if (!steps) return;
|
|
7943
|
+
const state = steps.get(stepId);
|
|
7944
|
+
if (!state) return;
|
|
7945
|
+
if (state.sticky || state.abandoned) return;
|
|
7946
|
+
state.consecutiveFrameMisses += 1;
|
|
7947
|
+
if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
|
|
7948
|
+
state.abandoned = true;
|
|
7949
|
+
state.retryPending = false;
|
|
7950
|
+
return;
|
|
7951
|
+
}
|
|
7952
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
7953
|
+
}
|
|
6914
7954
|
onTrackEnded(trackId) {
|
|
6915
7955
|
this.tracks.delete(trackId);
|
|
6916
7956
|
}
|
|
6917
7957
|
canFire(state, nowMs) {
|
|
6918
|
-
if (state.sticky) return false;
|
|
7958
|
+
if (state.sticky || state.abandoned) return false;
|
|
6919
7959
|
if (!this.underMaxPerTrack(state)) return false;
|
|
6920
7960
|
return this.intervalElapsed(state, nowMs);
|
|
6921
7961
|
}
|
|
@@ -6938,7 +7978,7 @@ var DetailScheduler = class {
|
|
|
6938
7978
|
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6939
7979
|
/**
|
|
6940
7980
|
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6941
|
-
*
|
|
7981
|
+
* chain-aware for the multi-step detail subtrees.
|
|
6942
7982
|
*
|
|
6943
7983
|
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6944
7984
|
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
@@ -6947,13 +7987,35 @@ var DetailScheduler = class {
|
|
|
6947
7987
|
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6948
7988
|
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6949
7989
|
*
|
|
6950
|
-
*
|
|
6951
|
-
*
|
|
6952
|
-
* `
|
|
7990
|
+
* A `plate-detection` request ALWAYS includes `'plate-ocr'` — symmetric to the
|
|
7991
|
+
* face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
|
|
7992
|
+
* pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
|
|
7993
|
+
* plate never gets read and no plate text is ever produced. (The dispatcher
|
|
7994
|
+
* cannot import the pipeline catalog to derive the child chain — this hardcode
|
|
7995
|
+
* mirrors it; keep the two in sync when the catalog's plate subtree changes.)
|
|
7996
|
+
*
|
|
7997
|
+
* Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
7998
|
+
* strict-`steps` pruning — naming the nested child here is what keeps it in the
|
|
7999
|
+
* executed chain.
|
|
6953
8000
|
*/
|
|
6954
8001
|
function composeDetailSteps(req, hasTrackLabel) {
|
|
6955
|
-
if (req.stepId
|
|
6956
|
-
|
|
8002
|
+
if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
8003
|
+
if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
|
|
8004
|
+
return [req.stepId];
|
|
8005
|
+
}
|
|
8006
|
+
/**
|
|
8007
|
+
* Does `steps` name a nested-enrichment chain (root detector + a child that
|
|
8008
|
+
* produces a `label`/`embedding`), i.e. more than the bare root step? Used to
|
|
8009
|
+
* surface a silent enrichment miss (BUG C): a plate detected but never read, a
|
|
8010
|
+
* face detected but never embedded — the root detail still routes so the miss
|
|
8011
|
+
* is otherwise invisible. `['plate-detection']` alone is NOT a chain.
|
|
8012
|
+
*/
|
|
8013
|
+
function isEnrichmentChain(steps) {
|
|
8014
|
+
return steps.length > 1;
|
|
8015
|
+
}
|
|
8016
|
+
/** Does any returned detail carry the enrichment a chain request asked for? */
|
|
8017
|
+
function detailsCarryEnrichment(details) {
|
|
8018
|
+
return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
|
|
6957
8019
|
}
|
|
6958
8020
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6959
8021
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
@@ -7030,7 +8092,8 @@ var TrackDetailDispatcher = class {
|
|
|
7030
8092
|
queue: [],
|
|
7031
8093
|
inFlight: 0,
|
|
7032
8094
|
timer: null,
|
|
7033
|
-
lastFailWarnAt: 0
|
|
8095
|
+
lastFailWarnAt: 0,
|
|
8096
|
+
lastEnrichWarnAt: 0
|
|
7034
8097
|
};
|
|
7035
8098
|
this.devices.set(deviceId, dev);
|
|
7036
8099
|
}
|
|
@@ -7073,9 +8136,15 @@ var TrackDetailDispatcher = class {
|
|
|
7073
8136
|
}
|
|
7074
8137
|
async dispatch(deviceId, dev, req, frame) {
|
|
7075
8138
|
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
8139
|
+
if (details === null) {
|
|
8140
|
+
dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
|
|
8141
|
+
return;
|
|
8142
|
+
}
|
|
7076
8143
|
let topScore = null;
|
|
7077
|
-
if (details
|
|
8144
|
+
if (details.length > 0) {
|
|
7078
8145
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
8146
|
+
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
8147
|
+
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
7079
8148
|
try {
|
|
7080
8149
|
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7081
8150
|
} catch (err) {
|
|
@@ -7157,6 +8226,20 @@ var TrackDetailDispatcher = class {
|
|
|
7157
8226
|
}
|
|
7158
8227
|
});
|
|
7159
8228
|
}
|
|
8229
|
+
warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
|
|
8230
|
+
const now = Date.now();
|
|
8231
|
+
if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
8232
|
+
dev.lastEnrichWarnAt = now;
|
|
8233
|
+
this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
|
|
8234
|
+
tags: { deviceId },
|
|
8235
|
+
meta: {
|
|
8236
|
+
trackId: req.trackId,
|
|
8237
|
+
stepId: req.stepId,
|
|
8238
|
+
reason: req.reason,
|
|
8239
|
+
steps
|
|
8240
|
+
}
|
|
8241
|
+
});
|
|
8242
|
+
}
|
|
7160
8243
|
};
|
|
7161
8244
|
//#endregion
|
|
7162
8245
|
//#region src/pipeline-analytics/overlay-state.ts
|
|
@@ -7994,38 +9077,68 @@ var PlateRecognizer = class {
|
|
|
7994
9077
|
name
|
|
7995
9078
|
} : null;
|
|
7996
9079
|
}
|
|
7997
|
-
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
7998
|
-
* the raw OCR text
|
|
9080
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
9081
|
+
* else the raw OCR text. Returns `null` for an implausible read (junk OCR
|
|
9082
|
+
* off a distant/oblique plate) — the caller must NOT stamp a label then. */
|
|
7999
9083
|
resolveLabel(text, score) {
|
|
9084
|
+
if (!isPlausiblePlateRead(text, score)) return null;
|
|
8000
9085
|
return this.matchVehicle(text, score)?.name ?? text;
|
|
8001
9086
|
}
|
|
8002
9087
|
async processFrame(input) {
|
|
8003
9088
|
const minConfidence = input.minConfidence ?? 0;
|
|
8004
9089
|
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);
|
|
8005
9090
|
if (candidates.length === 0) return;
|
|
8006
|
-
for (const c of candidates) {
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
9091
|
+
for (const c of candidates) await this.holdBest({
|
|
9092
|
+
deviceId: input.deviceId,
|
|
9093
|
+
trackId: c.trackId,
|
|
9094
|
+
text: c.plateText,
|
|
9095
|
+
score: c.plateScore,
|
|
9096
|
+
bbox: c.plateBbox,
|
|
9097
|
+
timestamp: input.timestamp,
|
|
9098
|
+
frameWidth: input.frameWidth,
|
|
9099
|
+
frameHeight: input.frameHeight,
|
|
9100
|
+
cropPadding: input.cropPadding,
|
|
9101
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
9102
|
+
});
|
|
9103
|
+
}
|
|
9104
|
+
/**
|
|
9105
|
+
* Detail-plane entry point (two-plane design): plate-ocr runs on demand per
|
|
9106
|
+
* track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
|
|
9107
|
+
* never lands on the per-frame `tracked[]` that {@link processFrame} scans.
|
|
9108
|
+
* The dispatcher's result router calls this with each plate detail so the
|
|
9109
|
+
* gallery still collects the best read + tight crop (persisted on
|
|
9110
|
+
* {@link onTrackEnd}). Without it the plate label rides the event but the
|
|
9111
|
+
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
9112
|
+
*/
|
|
9113
|
+
async observePlateRead(input) {
|
|
9114
|
+
if (!isPlausiblePlateRead(input.text, input.score)) return;
|
|
9115
|
+
if (input.score < (input.minConfidence ?? 0)) return;
|
|
9116
|
+
await this.holdBest(input);
|
|
9117
|
+
}
|
|
9118
|
+
/** Hold the highest-scoring plate read per track, capturing a tight crop the
|
|
9119
|
+
* first time a new best is seen (shared by the per-frame + detail-plane paths). */
|
|
9120
|
+
async holdBest(input) {
|
|
9121
|
+
const held = this.bestPlate.get(input.trackId);
|
|
9122
|
+
if (held !== void 0 && input.score <= held.score) return;
|
|
9123
|
+
let crop;
|
|
9124
|
+
if (input.frameHandle !== void 0) try {
|
|
9125
|
+
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
9126
|
+
} catch (err) {
|
|
9127
|
+
this.deps.logger.debug("PlateRecognizer crop capture failed", {
|
|
9128
|
+
tags: { deviceId: input.deviceId },
|
|
9129
|
+
meta: {
|
|
9130
|
+
trackId: input.trackId,
|
|
9131
|
+
error: String(err)
|
|
9132
|
+
}
|
|
8027
9133
|
});
|
|
8028
9134
|
}
|
|
9135
|
+
this.bestPlate.set(input.trackId, {
|
|
9136
|
+
text: input.text,
|
|
9137
|
+
score: input.score,
|
|
9138
|
+
bbox: input.bbox,
|
|
9139
|
+
timestamp: input.timestamp,
|
|
9140
|
+
...crop !== void 0 ? { crop } : {}
|
|
9141
|
+
});
|
|
8029
9142
|
}
|
|
8030
9143
|
/** Persist the held best plate for a finished track as one PlateStore row
|
|
8031
9144
|
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
|
|
@@ -8361,6 +9474,40 @@ function classifyAudioFrame(top, cfg) {
|
|
|
8361
9474
|
//#endregion
|
|
8362
9475
|
//#region src/pipeline-analytics/event-media-handler.ts
|
|
8363
9476
|
var CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
9477
|
+
/** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
|
|
9478
|
+
* `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
|
|
9479
|
+
var THUMB_DEFAULT_SIZE = 160;
|
|
9480
|
+
var THUMB_MIN_SIZE$1 = 64;
|
|
9481
|
+
var THUMB_MAX_SIZE$1 = 320;
|
|
9482
|
+
/**
|
|
9483
|
+
* Parse the `?kind=…` query into a preferred stored media kind. Returns null
|
|
9484
|
+
* when unset. The value is a free-form kind token (e.g. `crop`); the resolver
|
|
9485
|
+
* validates it against the known kinds.
|
|
9486
|
+
*/
|
|
9487
|
+
function parseEventMediaKind(query) {
|
|
9488
|
+
const kind = new URLSearchParams(query).get("kind");
|
|
9489
|
+
return kind !== null && kind.length > 0 ? kind : null;
|
|
9490
|
+
}
|
|
9491
|
+
/**
|
|
9492
|
+
* Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
|
|
9493
|
+
* when no small-square rendering was requested (the caller then serves the
|
|
9494
|
+
* stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
|
|
9495
|
+
* `size` / `w` / `h` (clamped to [64, 320], default 160).
|
|
9496
|
+
*/
|
|
9497
|
+
function parseEventMediaVariant(query) {
|
|
9498
|
+
const params = new URLSearchParams(query);
|
|
9499
|
+
if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
|
|
9500
|
+
const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
|
|
9501
|
+
let size = THUMB_DEFAULT_SIZE;
|
|
9502
|
+
if (sizeRaw !== null) {
|
|
9503
|
+
const n = Number.parseInt(sizeRaw, 10);
|
|
9504
|
+
if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
|
|
9505
|
+
}
|
|
9506
|
+
return {
|
|
9507
|
+
kind: "thumb",
|
|
9508
|
+
size
|
|
9509
|
+
};
|
|
9510
|
+
}
|
|
8364
9511
|
/**
|
|
8365
9512
|
* Create a data-plane handler that serves event thumbnails as JPEG images.
|
|
8366
9513
|
*
|
|
@@ -8374,14 +9521,20 @@ function createEventMediaHandler(deps) {
|
|
|
8374
9521
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
8375
9522
|
return;
|
|
8376
9523
|
}
|
|
8377
|
-
const
|
|
9524
|
+
const url = req.url ?? "/";
|
|
9525
|
+
const qIdx = url.indexOf("?");
|
|
9526
|
+
const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
9527
|
+
const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
|
|
9528
|
+
const eventId = rawPath.replace(/^\/+/, "");
|
|
8378
9529
|
if (!eventId || eventId.includes("/")) {
|
|
8379
9530
|
res.writeHead(404).end();
|
|
8380
9531
|
return;
|
|
8381
9532
|
}
|
|
9533
|
+
const variant = parseEventMediaVariant(query);
|
|
9534
|
+
const preferKind = parseEventMediaKind(query);
|
|
8382
9535
|
let media = null;
|
|
8383
9536
|
try {
|
|
8384
|
-
media = await deps.getMedia(eventId);
|
|
9537
|
+
media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
|
|
8385
9538
|
} catch {
|
|
8386
9539
|
const body = "Internal server error";
|
|
8387
9540
|
res.writeHead(500, {
|
|
@@ -8414,6 +9567,27 @@ function createEventMediaHandler(deps) {
|
|
|
8414
9567
|
else res.end(Buffer.from(media.bytes));
|
|
8415
9568
|
};
|
|
8416
9569
|
}
|
|
9570
|
+
/** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
|
|
9571
|
+
var THUMB_QUALITY = 70;
|
|
9572
|
+
/**
|
|
9573
|
+
* Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
|
|
9574
|
+
* stored 640×360 `crop`). Center-crop cover to a square then downscale to
|
|
9575
|
+
* `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
|
|
9576
|
+
* showing the object, not the full 16:9 crop. Output is a fraction of the source
|
|
9577
|
+
* (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
|
|
9578
|
+
* from tiny HTTP-cached tiles instead of full base64 payloads.
|
|
9579
|
+
*
|
|
9580
|
+
* `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
|
|
9581
|
+
* crops the overflow symmetrically — the center square of a square-safe crop
|
|
9582
|
+
* fully contains the detector bbox, so the object stays framed.
|
|
9583
|
+
*/
|
|
9584
|
+
async function makeSquareThumb(bytes, size) {
|
|
9585
|
+
const edge = Math.max(64, Math.min(320, Math.round(size)));
|
|
9586
|
+
return sharp(Buffer.from(bytes)).resize(edge, edge, {
|
|
9587
|
+
fit: "cover",
|
|
9588
|
+
position: "centre"
|
|
9589
|
+
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
9590
|
+
}
|
|
8417
9591
|
//#endregion
|
|
8418
9592
|
//#region src/pipeline-analytics/index.ts
|
|
8419
9593
|
/**
|
|
@@ -8436,18 +9610,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
|
8436
9610
|
* before re-reading. */
|
|
8437
9611
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
8438
9612
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
9613
|
+
/** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
|
|
9614
|
+
* The `device.state-changed` push doesn't reliably reach a forked child, so
|
|
9615
|
+
* each cached proxy re-pulls both slices on this timer (see ensureProxy) —
|
|
9616
|
+
* a zone drawn in the editor shows up in per-zone stats within one tick. */
|
|
9617
|
+
var ZONE_SLICE_RECONCILE_MS = 3e4;
|
|
8439
9618
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
8440
9619
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
8441
9620
|
* so jitter around a plateau doesn't churn the write). */
|
|
8442
9621
|
var BEST_FRAME_HYSTERESIS = .05;
|
|
8443
9622
|
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
8444
9623
|
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
8445
|
-
/** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
|
|
8446
|
-
* capture). Native resolution is the point, but a full 4K RGB surface over the
|
|
8447
|
-
* transport per new-best is wasteful for a web detail view — 1920px keeps a
|
|
8448
|
-
* sharp native frame while bounding the copy (a miss falls back to the
|
|
8449
|
-
* detection-res frame, which is already ≤640px). */
|
|
8450
|
-
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
8451
9624
|
/** getKeyEvents: max completed tracks pulled from a window before importance
|
|
8452
9625
|
* ranking. Ordering is by importance (not firstSeen) and legacy rows score on
|
|
8453
9626
|
* read, so we over-fetch candidates and trim to `limit` after sorting. */
|
|
@@ -8470,6 +9643,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
8470
9643
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
8471
9644
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
8472
9645
|
/**
|
|
9646
|
+
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
9647
|
+
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
9648
|
+
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
9649
|
+
* `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
9650
|
+
*/
|
|
9651
|
+
var CLEAN_MEDIA_KINDS = [
|
|
9652
|
+
"crop",
|
|
9653
|
+
"fullFrame",
|
|
9654
|
+
"keyFrame"
|
|
9655
|
+
];
|
|
9656
|
+
/**
|
|
9657
|
+
* Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
|
|
9658
|
+
* `preferKind` if it is itself clean and present, else the first available
|
|
9659
|
+
* {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
|
|
9660
|
+
* exists (caller 404s → the viewer shows an icon).
|
|
9661
|
+
*/
|
|
9662
|
+
function pickCleanMedia(files, preferKind) {
|
|
9663
|
+
const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
|
|
9664
|
+
if (isClean(preferKind)) {
|
|
9665
|
+
const exact = files.find((f) => f.kind === preferKind);
|
|
9666
|
+
if (exact) return exact;
|
|
9667
|
+
}
|
|
9668
|
+
for (const kind of CLEAN_MEDIA_KINDS) {
|
|
9669
|
+
const found = files.find((f) => f.kind === kind);
|
|
9670
|
+
if (found) return found;
|
|
9671
|
+
}
|
|
9672
|
+
}
|
|
9673
|
+
/**
|
|
8473
9674
|
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
8474
9675
|
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
8475
9676
|
*/
|
|
@@ -8520,6 +9721,10 @@ function stripGlobalOnlyFields(sections) {
|
|
|
8520
9721
|
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
8521
9722
|
processors = /* @__PURE__ */ new Map();
|
|
8522
9723
|
trackStore = null;
|
|
9724
|
+
/** Parked-object registry: promotes a track that stopped moving into a
|
|
9725
|
+
* lightweight entry, suppresses its detections from re-spawning tracks, and
|
|
9726
|
+
* wakes it when the object departs. Null until onInitialize. */
|
|
9727
|
+
stationaryRegistry = null;
|
|
8523
9728
|
mediaStore = null;
|
|
8524
9729
|
eventStore = null;
|
|
8525
9730
|
identityStore = null;
|
|
@@ -8545,9 +9750,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8545
9750
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
8546
9751
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
8547
9752
|
eventMediaDispatcher = null;
|
|
8548
|
-
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
8549
|
-
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
8550
|
-
frameReaders = null;
|
|
8551
9753
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
8552
9754
|
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8553
9755
|
* detection it resolves the frame, crops the ROI, and calls the
|
|
@@ -8584,6 +9786,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8584
9786
|
* dataPlane facility in the current environment). */
|
|
8585
9787
|
eventMediaBaseUrl = null;
|
|
8586
9788
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
9789
|
+
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
8587
9790
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
8588
9791
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
8589
9792
|
levelStateByDevice = /* @__PURE__ */ new Map();
|
|
@@ -8615,10 +9818,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8615
9818
|
* cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
|
|
8616
9819
|
objectEmbeddingBestSelector = new TrackBestSelector();
|
|
8617
9820
|
/** Design B: the track's shared native key-frame media key, captured at the
|
|
8618
|
-
* best-detection moment (
|
|
8619
|
-
*
|
|
8620
|
-
* track end. */
|
|
9821
|
+
* best-detection moment (general best-frame path). Read by the face / plate /
|
|
9822
|
+
* object-embedding rows so they LINK the SAME single native key frame.
|
|
9823
|
+
* Cleared on track end. */
|
|
8621
9824
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
9825
|
+
/** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
|
|
9826
|
+
* rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
|
|
9827
|
+
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
9828
|
+
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
9829
|
+
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
8622
9830
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8623
9831
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8624
9832
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -8669,6 +9877,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8669
9877
|
await PlateStore.declare(api.settingsStore);
|
|
8670
9878
|
await VehicleStore.declare(api.settingsStore);
|
|
8671
9879
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
9880
|
+
await StationaryObjectRegistry.declare(api.settingsStore);
|
|
8672
9881
|
const logger = this.ctx.logger;
|
|
8673
9882
|
let storage = this.ctx.kernel.storage;
|
|
8674
9883
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
@@ -8682,6 +9891,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8682
9891
|
store: api.settingsStore,
|
|
8683
9892
|
logger: logger.child("TrackStore")
|
|
8684
9893
|
});
|
|
9894
|
+
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9895
|
+
store: api.settingsStore,
|
|
9896
|
+
logger: logger.child("StationaryRegistry"),
|
|
9897
|
+
onChange: ({ phase, entry, timestamp }) => {
|
|
9898
|
+
this.ctx.eventBus.emit({
|
|
9899
|
+
id: `pa-stationary-${entry.id}-${phase}`,
|
|
9900
|
+
timestamp: new Date(timestamp),
|
|
9901
|
+
source: {
|
|
9902
|
+
type: "addon",
|
|
9903
|
+
id: "pipeline-analytics",
|
|
9904
|
+
addonId: "pipeline-analytics"
|
|
9905
|
+
},
|
|
9906
|
+
category: EventCategory.PipelineAnalyticsStationaryChanged,
|
|
9907
|
+
data: {
|
|
9908
|
+
deviceId: entry.deviceId,
|
|
9909
|
+
entryId: entry.id,
|
|
9910
|
+
className: entry.className,
|
|
9911
|
+
phase,
|
|
9912
|
+
timestamp
|
|
9913
|
+
}
|
|
9914
|
+
});
|
|
9915
|
+
}
|
|
9916
|
+
});
|
|
9917
|
+
await this.stationaryRegistry.load();
|
|
8685
9918
|
this.mediaStore = new MediaStore({
|
|
8686
9919
|
storage,
|
|
8687
9920
|
store: api.settingsStore,
|
|
@@ -8718,33 +9951,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8718
9951
|
designatedNode: designated
|
|
8719
9952
|
} });
|
|
8720
9953
|
}
|
|
8721
|
-
|
|
8722
|
-
const decoderApi = api.decoder;
|
|
9954
|
+
const pipelineRunnerApi = api.pipelineRunner;
|
|
8723
9955
|
const getRemoteFrame = async (handle) => {
|
|
8724
|
-
if (!
|
|
8725
|
-
const
|
|
9956
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
9957
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
8726
9958
|
handle,
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
9959
|
+
bbox: {
|
|
9960
|
+
x: 0,
|
|
9961
|
+
y: 0,
|
|
9962
|
+
w: 1,
|
|
9963
|
+
h: 1
|
|
9964
|
+
},
|
|
9965
|
+
maxWidth: handle.width
|
|
9966
|
+
}, nodePin(handle.nodeId));
|
|
9967
|
+
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
8730
9968
|
return {
|
|
8731
|
-
data: Buffer.from(
|
|
8732
|
-
width:
|
|
8733
|
-
height:
|
|
8734
|
-
format:
|
|
8735
|
-
timestamp:
|
|
9969
|
+
data: Buffer.from(full.bytes),
|
|
9970
|
+
width: full.width,
|
|
9971
|
+
height: full.height,
|
|
9972
|
+
format: "rgb",
|
|
9973
|
+
timestamp: 0
|
|
8736
9974
|
};
|
|
8737
9975
|
};
|
|
8738
9976
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
8739
|
-
ownNodeId,
|
|
8740
|
-
readers: this.frameReaders,
|
|
8741
9977
|
getRemoteFrame,
|
|
8742
9978
|
mediaStore: this.mediaStore,
|
|
8743
9979
|
logger: logger.child("EventMediaDispatcher")
|
|
8744
9980
|
});
|
|
8745
|
-
const ownNodeIdForFaces = ownNodeId;
|
|
8746
|
-
const frameReadersForFaces = this.frameReaders;
|
|
8747
|
-
const pipelineRunnerApi = api.pipelineRunner;
|
|
8748
9981
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
8749
9982
|
let nativeHits = 0;
|
|
8750
9983
|
let nativeFallbacks = 0;
|
|
@@ -8776,11 +10009,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8776
10009
|
return null;
|
|
8777
10010
|
}
|
|
8778
10011
|
};
|
|
8779
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
8780
|
-
ownNodeId: ownNodeIdForFaces,
|
|
8781
|
-
readers: frameReadersForFaces,
|
|
8782
|
-
getRemoteFrame
|
|
8783
|
-
}));
|
|
10012
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
8784
10013
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
8785
10014
|
const paddedNorm = padBbox({
|
|
8786
10015
|
x: bbox.x / frameWidth,
|
|
@@ -8860,7 +10089,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8860
10089
|
});
|
|
8861
10090
|
this.zoneAnalytics = new ZoneAnalyticsProvider({
|
|
8862
10091
|
logger: logger.child("ZoneAnalytics"),
|
|
8863
|
-
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
|
|
10092
|
+
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
10093
|
+
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
10094
|
+
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
10095
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
|
|
8864
10096
|
});
|
|
8865
10097
|
this.audioMetrics = new AudioMetricsProvider({
|
|
8866
10098
|
logger: logger.child("AudioMetrics"),
|
|
@@ -8876,9 +10108,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8876
10108
|
}
|
|
8877
10109
|
});
|
|
8878
10110
|
try {
|
|
8879
|
-
const handler = createEventMediaHandler({ getMedia: async (id) => {
|
|
10111
|
+
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
8880
10112
|
try {
|
|
8881
|
-
return await this.readMediaByEventOrKey(id);
|
|
10113
|
+
return await this.readMediaByEventOrKey(id, variant, preferKind);
|
|
8882
10114
|
} catch (err) {
|
|
8883
10115
|
this.ctx.logger.warn("readEventThumbnail failed", { meta: {
|
|
8884
10116
|
eventId: id,
|
|
@@ -8936,8 +10168,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8936
10168
|
encoder: encoderClient,
|
|
8937
10169
|
eventBus: this.ctx.eventBus,
|
|
8938
10170
|
logger: logger.child("EmbeddingDispatcher"),
|
|
8939
|
-
ownNodeId,
|
|
8940
|
-
readers: frameReadersForFaces,
|
|
8941
10171
|
getRemoteFrame
|
|
8942
10172
|
});
|
|
8943
10173
|
await this.embeddingDispatcher.start();
|
|
@@ -8948,6 +10178,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8948
10178
|
this.bindingCache?.onBindingsChanged(data);
|
|
8949
10179
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
8950
10180
|
this.trackStore?.clearDevice(data.deviceId);
|
|
10181
|
+
this.stationaryRegistry?.forgetDevice(data.deviceId);
|
|
8951
10182
|
this.overlayState.clearDevice(data.deviceId);
|
|
8952
10183
|
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
8953
10184
|
this.forgetDeviceProcessors(data.deviceId);
|
|
@@ -8962,6 +10193,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8962
10193
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
8963
10194
|
const { deviceId } = ev.data;
|
|
8964
10195
|
this.trackStore?.clearDevice(deviceId);
|
|
10196
|
+
this.stationaryRegistry?.forgetDevice(deviceId);
|
|
8965
10197
|
this.overlayState.clearDevice(deviceId);
|
|
8966
10198
|
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
8967
10199
|
this.forgetDeviceProcessors(deviceId);
|
|
@@ -8982,6 +10214,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8982
10214
|
this.retentionSweepTimer = setInterval(() => {
|
|
8983
10215
|
this.sweepRetention();
|
|
8984
10216
|
this.runTrackRetentionSweep();
|
|
10217
|
+
this.stationaryRegistry?.sweep(Date.now());
|
|
8985
10218
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
8986
10219
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
8987
10220
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -9302,8 +10535,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9302
10535
|
this.overlaySynthesisWarnAt.clear();
|
|
9303
10536
|
this.processors.clear();
|
|
9304
10537
|
this.lastActiveTrackIds.clear();
|
|
10538
|
+
this.lastFrameDimsByDevice.clear();
|
|
9305
10539
|
this.dropoutSkipsByKey.clear();
|
|
9306
10540
|
this.bestFrameTracker.clear();
|
|
10541
|
+
this.lastFrameAtByTrack.clear();
|
|
9307
10542
|
this.trackLifecycleUpdateMem.clear();
|
|
9308
10543
|
this.objectEmbeddingBestSelector.clear();
|
|
9309
10544
|
this.levelStateByDevice.clear();
|
|
@@ -9314,13 +10549,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9314
10549
|
this.faceGlobalEnabledCache = null;
|
|
9315
10550
|
this.mediaCacheByDevice.clear();
|
|
9316
10551
|
this.trackStore?.clearAll();
|
|
10552
|
+
this.stationaryRegistry = null;
|
|
9317
10553
|
this.bindingCache?.clearAll();
|
|
9318
10554
|
await this.eventMediaDataPlane?.dispose();
|
|
9319
10555
|
this.eventMediaDataPlane = null;
|
|
9320
10556
|
this.eventMediaBaseUrl = null;
|
|
9321
10557
|
this.eventMediaDispatcher = null;
|
|
9322
|
-
this.frameReaders?.close();
|
|
9323
|
-
this.frameReaders = null;
|
|
9324
10558
|
}
|
|
9325
10559
|
async handleInferenceResult(data) {
|
|
9326
10560
|
if (this.shuttingDown) return;
|
|
@@ -9368,22 +10602,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9368
10602
|
timestamp: frame.timestamp,
|
|
9369
10603
|
frame
|
|
9370
10604
|
});
|
|
10605
|
+
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
10606
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
10607
|
+
deviceId,
|
|
10608
|
+
confirmed: result.stationaryConfirmed,
|
|
10609
|
+
wokenEntryIds: result.stationaryWoken,
|
|
10610
|
+
timestamp: result.timestamp
|
|
10611
|
+
});
|
|
10612
|
+
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
10613
|
+
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10614
|
+
trackId: `stationary:${v.id}`,
|
|
10615
|
+
className: v.className,
|
|
10616
|
+
zones: computeStationaryEntryZones(v, liveZones)
|
|
10617
|
+
}));
|
|
9371
10618
|
this.zoneAnalytics?.recordFrame({
|
|
9372
10619
|
deviceId,
|
|
9373
10620
|
timestamp: result.timestamp,
|
|
9374
10621
|
frameWidth: result.frameWidth,
|
|
9375
10622
|
frameHeight: result.frameHeight,
|
|
9376
|
-
tracked: result.tracked,
|
|
9377
|
-
zones: liveZones
|
|
10623
|
+
tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
|
|
10624
|
+
zones: liveZones,
|
|
10625
|
+
...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
|
|
10626
|
+
});
|
|
10627
|
+
if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
|
|
10628
|
+
w: result.frameWidth,
|
|
10629
|
+
h: result.frameHeight
|
|
9378
10630
|
});
|
|
9379
10631
|
const currentTrackIds = /* @__PURE__ */ new Set();
|
|
10632
|
+
const positionsCountById = /* @__PURE__ */ new Map();
|
|
9380
10633
|
for (const t of result.tracked) {
|
|
9381
10634
|
currentTrackIds.add(t.trackId);
|
|
9382
10635
|
const center = {
|
|
9383
10636
|
x: t.bbox.x + t.bbox.w / 2,
|
|
9384
10637
|
y: t.bbox.y + t.bbox.h / 2
|
|
9385
10638
|
};
|
|
9386
|
-
this.trackStore.upsert({
|
|
10639
|
+
const upserted = this.trackStore.upsert({
|
|
9387
10640
|
trackId: t.trackId,
|
|
9388
10641
|
deviceId,
|
|
9389
10642
|
className: t.className,
|
|
@@ -9398,6 +10651,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9398
10651
|
zones: t.zones,
|
|
9399
10652
|
state: t.state
|
|
9400
10653
|
});
|
|
10654
|
+
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
9401
10655
|
}
|
|
9402
10656
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
9403
10657
|
const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -9406,6 +10660,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9406
10660
|
for (const id of currentTrackIds) if (!prevIds.has(id)) {
|
|
9407
10661
|
const t = result.tracked.find((x) => x.trackId === id);
|
|
9408
10662
|
if (t) {
|
|
10663
|
+
if (classifyTrackAppearance({
|
|
10664
|
+
inPrevActive: false,
|
|
10665
|
+
positionsCount: positionsCountById.get(id) ?? 1
|
|
10666
|
+
}) === "resurrection") {
|
|
10667
|
+
log.info("track resumed", { meta: {
|
|
10668
|
+
trackId: id,
|
|
10669
|
+
className: t.className,
|
|
10670
|
+
source,
|
|
10671
|
+
resurrected: true
|
|
10672
|
+
} });
|
|
10673
|
+
continue;
|
|
10674
|
+
}
|
|
9409
10675
|
newTrackCount += 1;
|
|
9410
10676
|
log.info("track started", { meta: {
|
|
9411
10677
|
trackId: id,
|
|
@@ -9419,7 +10685,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9419
10685
|
bbox: { ...t.bbox },
|
|
9420
10686
|
...t.label ? { label: t.label } : {}
|
|
9421
10687
|
});
|
|
9422
|
-
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
10688
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
|
|
9423
10689
|
}
|
|
9424
10690
|
this.ctx.eventBus.emit({
|
|
9425
10691
|
id: `pa-${randomUUID()}`,
|
|
@@ -9465,6 +10731,34 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9465
10731
|
} });
|
|
9466
10732
|
}
|
|
9467
10733
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
10734
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
10735
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
10736
|
+
if (dims && dims.w > 0 && dims.h > 0) {
|
|
10737
|
+
const refDiag = Math.hypot(dims.w, dims.h);
|
|
10738
|
+
for (const t of result.tracked) {
|
|
10739
|
+
const active = this.trackStore.peekActive(t.trackId);
|
|
10740
|
+
if (!active) continue;
|
|
10741
|
+
const { promote } = evaluateStationaryPromotion({
|
|
10742
|
+
positions: active.positions,
|
|
10743
|
+
referenceDiagonalPx: refDiag,
|
|
10744
|
+
now: result.timestamp,
|
|
10745
|
+
config: DEFAULT_PROMOTION_CONFIG
|
|
10746
|
+
});
|
|
10747
|
+
if (!promote) continue;
|
|
10748
|
+
this.promoteToStationary({
|
|
10749
|
+
deviceId,
|
|
10750
|
+
key,
|
|
10751
|
+
processor,
|
|
10752
|
+
track: t,
|
|
10753
|
+
firstSeen: active.firstSeen,
|
|
10754
|
+
label: active.label,
|
|
10755
|
+
frameWidth: result.frameWidth,
|
|
10756
|
+
frameHeight: result.frameHeight,
|
|
10757
|
+
timestamp: result.timestamp
|
|
10758
|
+
});
|
|
10759
|
+
}
|
|
10760
|
+
}
|
|
10761
|
+
}
|
|
9468
10762
|
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9469
10763
|
const dispatcher = this.detailDispatcher;
|
|
9470
10764
|
const steps = detailSteps;
|
|
@@ -9533,7 +10827,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9533
10827
|
let plateCrops = 0;
|
|
9534
10828
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
9535
10829
|
else plateCrops += 1;
|
|
9536
|
-
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
10830
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
10831
|
+
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
10832
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
|
|
9537
10833
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
9538
10834
|
const captureCounts = {
|
|
9539
10835
|
events: eventTargets.length,
|
|
@@ -9748,14 +11044,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9748
11044
|
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
9749
11045
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
9750
11046
|
else if (d.label !== void 0 && d.label.length > 0) {
|
|
9751
|
-
if (d.className === "plate" && d.bbox !== void 0)
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
11047
|
+
if (d.className === "plate" && d.bbox !== void 0) {
|
|
11048
|
+
this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
11049
|
+
x: d.bbox.x,
|
|
11050
|
+
y: d.bbox.y,
|
|
11051
|
+
w: d.bbox.w,
|
|
11052
|
+
h: d.bbox.h
|
|
11053
|
+
}, d.score, d.label, frame.timestamp);
|
|
11054
|
+
if (this.plateRecognizer) {
|
|
11055
|
+
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
11056
|
+
await this.plateRecognizer.observePlateRead({
|
|
11057
|
+
deviceId,
|
|
11058
|
+
trackId,
|
|
11059
|
+
text: d.label,
|
|
11060
|
+
score: d.score,
|
|
11061
|
+
bbox: {
|
|
11062
|
+
x: d.bbox.x,
|
|
11063
|
+
y: d.bbox.y,
|
|
11064
|
+
w: d.bbox.w,
|
|
11065
|
+
h: d.bbox.h
|
|
11066
|
+
},
|
|
11067
|
+
timestamp: frame.timestamp,
|
|
11068
|
+
frameWidth: frame.frameWidth,
|
|
11069
|
+
frameHeight: frame.frameHeight,
|
|
11070
|
+
cropPadding: mediaSettings.cropPadding,
|
|
11071
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
11072
|
+
});
|
|
11073
|
+
}
|
|
11074
|
+
}
|
|
11075
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
|
|
11076
|
+
if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9759
11077
|
}
|
|
9760
11078
|
} catch (err) {
|
|
9761
11079
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -9902,55 +11220,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9902
11220
|
await Promise.all(bests.map(async (t) => {
|
|
9903
11221
|
if (!isClipObjectEmbedding(t)) return;
|
|
9904
11222
|
let mediaKey;
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
trackId: t.trackId,
|
|
9922
|
-
error: errMsg(err)
|
|
9923
|
-
}
|
|
9924
|
-
});
|
|
9925
|
-
}
|
|
9926
|
-
try {
|
|
9927
|
-
const keyFrame = await this.captureCrop(frameHandle, {
|
|
9928
|
-
x: 0,
|
|
9929
|
-
y: 0,
|
|
9930
|
-
w: frameWidth,
|
|
9931
|
-
h: frameHeight
|
|
9932
|
-
}, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
|
|
9933
|
-
if (keyFrame) {
|
|
9934
|
-
keyFrameMediaKey = await this.mediaStore.putReplacing({
|
|
9935
|
-
deviceId,
|
|
9936
|
-
ownerKind: "track",
|
|
9937
|
-
ownerId: t.trackId,
|
|
9938
|
-
kind: "keyFrame",
|
|
9939
|
-
timestamp,
|
|
9940
|
-
data: keyFrame
|
|
9941
|
-
});
|
|
9942
|
-
this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
|
|
11223
|
+
if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
|
|
11224
|
+
const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
|
|
11225
|
+
if (crop) mediaKey = await this.mediaStore.putReplacing({
|
|
11226
|
+
deviceId,
|
|
11227
|
+
ownerKind: "track",
|
|
11228
|
+
ownerId: t.trackId,
|
|
11229
|
+
kind: "crop",
|
|
11230
|
+
timestamp,
|
|
11231
|
+
data: crop
|
|
11232
|
+
});
|
|
11233
|
+
} catch (err) {
|
|
11234
|
+
this.ctx.logger.debug("object-embedding crop capture failed", {
|
|
11235
|
+
tags: { deviceId },
|
|
11236
|
+
meta: {
|
|
11237
|
+
trackId: t.trackId,
|
|
11238
|
+
error: errMsg(err)
|
|
9943
11239
|
}
|
|
9944
|
-
}
|
|
9945
|
-
this.ctx.logger.debug("key-frame capture failed", {
|
|
9946
|
-
tags: { deviceId },
|
|
9947
|
-
meta: {
|
|
9948
|
-
trackId: t.trackId,
|
|
9949
|
-
error: errMsg(err)
|
|
9950
|
-
}
|
|
9951
|
-
});
|
|
9952
|
-
}
|
|
11240
|
+
});
|
|
9953
11241
|
}
|
|
11242
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
9954
11243
|
await store.upsertIfBetter({
|
|
9955
11244
|
trackId: t.trackId,
|
|
9956
11245
|
deviceId,
|
|
@@ -9964,6 +11253,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9964
11253
|
});
|
|
9965
11254
|
}));
|
|
9966
11255
|
}
|
|
11256
|
+
/**
|
|
11257
|
+
* Capture ONE native-resolution KEY FRAME per given track at this best-
|
|
11258
|
+
* detection frame and store it (`putReplacing` → one keyFrame per track).
|
|
11259
|
+
*
|
|
11260
|
+
* The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
|
|
11261
|
+
* FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
|
|
11262
|
+
* `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
|
|
11263
|
+
* and only falls back to the ≤640 detection frame when the native lease is
|
|
11264
|
+
* gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
|
|
11265
|
+
* plate / object-embedding rows LINK the SAME native key frame (Design B).
|
|
11266
|
+
* Issued in the live-frame window so the native lease is still held. Best-
|
|
11267
|
+
* effort (D8) — a per-track failure is logged and never thrown.
|
|
11268
|
+
*/
|
|
11269
|
+
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
|
|
11270
|
+
const capture = this.captureCrop;
|
|
11271
|
+
const mediaStore = this.mediaStore;
|
|
11272
|
+
if (!capture || !mediaStore) return;
|
|
11273
|
+
const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
|
|
11274
|
+
await Promise.all(trackIds.map(async (trackId) => {
|
|
11275
|
+
try {
|
|
11276
|
+
const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
|
|
11277
|
+
if (!keyFrame) return;
|
|
11278
|
+
const key = await mediaStore.putReplacing({
|
|
11279
|
+
deviceId,
|
|
11280
|
+
ownerKind: "track",
|
|
11281
|
+
ownerId: trackId,
|
|
11282
|
+
kind: "keyFrame",
|
|
11283
|
+
timestamp,
|
|
11284
|
+
data: keyFrame
|
|
11285
|
+
});
|
|
11286
|
+
this.keyFrameKeyByTrackId.set(trackId, key);
|
|
11287
|
+
} catch (err) {
|
|
11288
|
+
this.ctx.logger.debug("key-frame capture failed", {
|
|
11289
|
+
tags: { deviceId },
|
|
11290
|
+
meta: {
|
|
11291
|
+
trackId,
|
|
11292
|
+
error: errMsg(err)
|
|
11293
|
+
}
|
|
11294
|
+
});
|
|
11295
|
+
}
|
|
11296
|
+
}));
|
|
11297
|
+
}
|
|
9967
11298
|
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9968
11299
|
emitTrackLifecycle(payload, timestamp) {
|
|
9969
11300
|
this.ctx.eventBus.emit({
|
|
@@ -10013,27 +11344,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10013
11344
|
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10014
11345
|
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10015
11346
|
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
11347
|
+
...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
|
|
10016
11348
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10017
11349
|
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10018
11350
|
});
|
|
10019
11351
|
this.emitTrackLifecycle(payload, timestamp);
|
|
10020
11352
|
}
|
|
10021
|
-
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
11353
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
|
|
10022
11354
|
const targets = [];
|
|
10023
11355
|
for (const t of tracked) {
|
|
10024
11356
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
10025
|
-
const dueSnapshot = media.saveThumbnails &&
|
|
11357
|
+
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
11358
|
+
lastSnapshotAt: lastSnap,
|
|
11359
|
+
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
11360
|
+
currentBbox: t.bbox,
|
|
11361
|
+
now: timestamp,
|
|
11362
|
+
frameWidth,
|
|
11363
|
+
frameHeight,
|
|
11364
|
+
intervalMs: media.snapshotIntervalMs,
|
|
11365
|
+
movementThreshold: media.snapshotMovementThreshold,
|
|
11366
|
+
maxIdleMs: media.snapshotMaxIdleMs
|
|
11367
|
+
}).capture;
|
|
10026
11368
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10027
11369
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
10028
|
-
|
|
11370
|
+
const plan = planPeriodicMedia({
|
|
11371
|
+
saveThumbnails: media.saveThumbnails,
|
|
11372
|
+
dueSnapshot,
|
|
11373
|
+
isNewBest,
|
|
11374
|
+
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
11375
|
+
now: timestamp,
|
|
11376
|
+
intervalMs: media.snapshotIntervalMs
|
|
11377
|
+
});
|
|
11378
|
+
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
11379
|
+
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
11380
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
|
|
10029
11381
|
targets.push({
|
|
10030
11382
|
trackId: t.trackId,
|
|
10031
11383
|
timestamp,
|
|
10032
11384
|
bbox: { ...t.bbox },
|
|
10033
11385
|
...t.label ? { label: t.label } : {},
|
|
10034
|
-
appendSnapshot:
|
|
10035
|
-
rollingLastFrame:
|
|
10036
|
-
bestThumbnail:
|
|
11386
|
+
appendSnapshot: plan.appendSnapshot,
|
|
11387
|
+
rollingLastFrame: plan.rollingLastFrame,
|
|
11388
|
+
bestThumbnail: plan.bestThumbnail
|
|
10037
11389
|
});
|
|
10038
11390
|
}
|
|
10039
11391
|
return targets;
|
|
@@ -10089,6 +11441,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10089
11441
|
atMs: timestamp
|
|
10090
11442
|
});
|
|
10091
11443
|
await this.eventStore.insertAudio(ev);
|
|
11444
|
+
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
10092
11445
|
this.ctx.eventBus.emit({
|
|
10093
11446
|
id: `pa-${ev.id}`,
|
|
10094
11447
|
timestamp: new Date(ev.timestamp),
|
|
@@ -10306,6 +11659,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10306
11659
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
10307
11660
|
if (peak) {
|
|
10308
11661
|
endBestEventId = peak.bestEventId;
|
|
11662
|
+
const dims = this.lastFrameDimsByDevice.get(t.deviceId);
|
|
11663
|
+
const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
11664
|
+
x: p.x,
|
|
11665
|
+
y: p.y
|
|
11666
|
+
})), Math.hypot(dims.w, dims.h)) : void 0;
|
|
10309
11667
|
const { importance, reason } = computeImportance({
|
|
10310
11668
|
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
10311
11669
|
className: t.className,
|
|
@@ -10313,7 +11671,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10313
11671
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
10314
11672
|
totalDistance: t.totalDistance,
|
|
10315
11673
|
zonesVisited: t.zonesVisited,
|
|
10316
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
11674
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
11675
|
+
...staticMetrics ? {
|
|
11676
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
11677
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
11678
|
+
} : {}
|
|
10317
11679
|
});
|
|
10318
11680
|
endImportance = importance;
|
|
10319
11681
|
endImportanceReason = reason;
|
|
@@ -10328,6 +11690,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10328
11690
|
}
|
|
10329
11691
|
this.bestFrameTracker.delete(t.trackId);
|
|
10330
11692
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
11693
|
+
this.lastFrameAtByTrack.delete(t.trackId);
|
|
10331
11694
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10332
11695
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10333
11696
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -10372,6 +11735,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10372
11735
|
positionsCount: t.positions.length,
|
|
10373
11736
|
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10374
11737
|
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
11738
|
+
...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
|
|
10375
11739
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10376
11740
|
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10377
11741
|
});
|
|
@@ -10534,6 +11898,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10534
11898
|
for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
|
|
10535
11899
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
10536
11900
|
for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
|
|
11901
|
+
this.lastFrameDimsByDevice.delete(deviceId);
|
|
10537
11902
|
}
|
|
10538
11903
|
/** Apply a mutation to every live source-processor of a device (zones/rules
|
|
10539
11904
|
* are device-level and must reach all sources). */
|
|
@@ -10541,6 +11906,57 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10541
11906
|
const prefix = `${deviceId}:`;
|
|
10542
11907
|
for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
|
|
10543
11908
|
}
|
|
11909
|
+
/**
|
|
11910
|
+
* Turn a parked track into a stationary-registry entry and tear the track
|
|
11911
|
+
* down WITHOUT firing an 'end' key-event / importance scoring / media flush
|
|
11912
|
+
* (a parked object is not a highlight; the durable record is the entry). The
|
|
11913
|
+
* tracker + store forget the track so its detections stop re-spawning tracks,
|
|
11914
|
+
* and the registry suppresses them from the next frame on.
|
|
11915
|
+
*/
|
|
11916
|
+
promoteToStationary(input) {
|
|
11917
|
+
const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
|
|
11918
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
|
|
11919
|
+
const entry = {
|
|
11920
|
+
id: randomUUID(),
|
|
11921
|
+
deviceId,
|
|
11922
|
+
className: track.className,
|
|
11923
|
+
bbox: { ...track.bbox },
|
|
11924
|
+
frameWidth,
|
|
11925
|
+
frameHeight,
|
|
11926
|
+
firstSeenAt: firstSeen,
|
|
11927
|
+
becameStationaryAt: timestamp,
|
|
11928
|
+
lastConfirmedAt: timestamp,
|
|
11929
|
+
sourceTrackId: track.trackId,
|
|
11930
|
+
...label !== void 0 ? { label } : {},
|
|
11931
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
11932
|
+
};
|
|
11933
|
+
this.stationaryRegistry?.promote(entry);
|
|
11934
|
+
processor.dropTrack(track.trackId);
|
|
11935
|
+
this.trackStore?.dropActive(track.trackId);
|
|
11936
|
+
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11937
|
+
const dropKeyFrame = () => {
|
|
11938
|
+
this.keyFrameKeyByTrackId.delete(track.trackId);
|
|
11939
|
+
};
|
|
11940
|
+
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
11941
|
+
else dropKeyFrame();
|
|
11942
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11943
|
+
this.bestFrameTracker.delete(track.trackId);
|
|
11944
|
+
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
11945
|
+
this.lastFrameAtByTrack.delete(track.trackId);
|
|
11946
|
+
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
11947
|
+
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
11948
|
+
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
11949
|
+
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
11950
|
+
this.ctx.logger.info("track promoted to stationary", {
|
|
11951
|
+
tags: { deviceId },
|
|
11952
|
+
meta: {
|
|
11953
|
+
trackId: track.trackId,
|
|
11954
|
+
className: track.className,
|
|
11955
|
+
entryId: entry.id,
|
|
11956
|
+
...label ? { label } : {}
|
|
11957
|
+
}
|
|
11958
|
+
});
|
|
11959
|
+
}
|
|
10544
11960
|
async getOrCreateProcessor(deviceId, source) {
|
|
10545
11961
|
const key = this.procKey(deviceId, source);
|
|
10546
11962
|
let p = this.processors.get(key);
|
|
@@ -10566,6 +11982,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10566
11982
|
cooldownSec,
|
|
10567
11983
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
10568
11984
|
}, source);
|
|
11985
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
11986
|
+
const registry = this.stationaryRegistry;
|
|
11987
|
+
p.setStationaryGate({ filter: (input) => registry.filter({
|
|
11988
|
+
deviceId,
|
|
11989
|
+
detections: input.detections.map((d) => ({
|
|
11990
|
+
bbox: d.bbox,
|
|
11991
|
+
className: d.class
|
|
11992
|
+
})),
|
|
11993
|
+
frameWidth: input.frameWidth,
|
|
11994
|
+
frameHeight: input.frameHeight
|
|
11995
|
+
}) });
|
|
11996
|
+
}
|
|
10569
11997
|
this.processors.set(key, p);
|
|
10570
11998
|
}
|
|
10571
11999
|
return p;
|
|
@@ -10577,6 +12005,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10577
12005
|
* forward updates to the per-device FrameProcessor so a rule change
|
|
10578
12006
|
* applies to the very next frame even when frames stop briefly
|
|
10579
12007
|
* (e.g. during binding flips).
|
|
12008
|
+
*
|
|
12009
|
+
* RECONCILE: the push channel behind `subscribe` (`device.state-changed`
|
|
12010
|
+
* via `live.onEvent`) does not reliably reach a forked addon child — a
|
|
12011
|
+
* zone created AFTER the proxy's cold read stayed invisible until the
|
|
12012
|
+
* addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
|
|
12013
|
+
* populated hub-side, `zones: []` in every snapshot). Events are lossy
|
|
12014
|
+
* telemetry (D8); the durable channel is RPC + reconcile — so each
|
|
12015
|
+
* proxy also refreshes its two slices on a slow timer. `refresh()`
|
|
12016
|
+
* round-trips `deviceState.getCapSlice` and fans out through the SAME
|
|
12017
|
+
* subscribe callbacks above, so a zone edit lands within one interval.
|
|
10580
12018
|
*/
|
|
10581
12019
|
async ensureProxy(deviceId) {
|
|
10582
12020
|
const cached = this.proxies.get(deviceId);
|
|
@@ -10585,13 +12023,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10585
12023
|
const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
|
|
10586
12024
|
if (!proxy) return null;
|
|
10587
12025
|
this.proxies.set(deviceId, proxy);
|
|
10588
|
-
const
|
|
10589
|
-
|
|
10590
|
-
|
|
10591
|
-
}
|
|
10592
|
-
|
|
10593
|
-
|
|
10594
|
-
|
|
12026
|
+
const reconcile = setInterval(() => {
|
|
12027
|
+
proxy.state.zones.refresh().catch(() => void 0);
|
|
12028
|
+
proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
12029
|
+
}, ZONE_SLICE_RECONCILE_MS);
|
|
12030
|
+
reconcile.unref?.();
|
|
12031
|
+
const unsubs = [
|
|
12032
|
+
proxy.state.zones.subscribe((slice) => {
|
|
12033
|
+
const zones = slice?.zones ?? [];
|
|
12034
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
|
|
12035
|
+
}),
|
|
12036
|
+
proxy.state.zoneRules.subscribe((slice) => {
|
|
12037
|
+
const rules = slice?.detection ?? [];
|
|
12038
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
|
|
12039
|
+
}),
|
|
12040
|
+
() => clearInterval(reconcile)
|
|
12041
|
+
];
|
|
10595
12042
|
this.proxyUnsubs.set(deviceId, unsubs);
|
|
10596
12043
|
return proxy;
|
|
10597
12044
|
} catch (err) {
|
|
@@ -10602,6 +12049,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10602
12049
|
return null;
|
|
10603
12050
|
}
|
|
10604
12051
|
}
|
|
12052
|
+
/**
|
|
12053
|
+
* Resolve a device's current 0–1 zone catalogue independent of the live
|
|
12054
|
+
* frame path — used by zone-analytics snapshot hydration + the occupancy
|
|
12055
|
+
* baseline sampler when the camera is detached (no frames). Warms the proxy
|
|
12056
|
+
* (cold read via `fetchDevice`) and, when the cached slice is empty, forces
|
|
12057
|
+
* one `refresh()` round-trip so a just-created proxy returns real zones.
|
|
12058
|
+
*/
|
|
12059
|
+
async resolveDeviceZones(deviceId) {
|
|
12060
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12061
|
+
if (!proxy) return [];
|
|
12062
|
+
const cached = proxy.state.zones.value?.zones;
|
|
12063
|
+
if (cached && cached.length > 0) return cached;
|
|
12064
|
+
await proxy.state.zones.refresh().catch(() => void 0);
|
|
12065
|
+
return proxy.state.zones.value?.zones ?? [];
|
|
12066
|
+
}
|
|
10605
12067
|
releaseProxy(deviceId) {
|
|
10606
12068
|
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
10607
12069
|
if (unsubs) for (const u of unsubs) try {
|
|
@@ -10623,6 +12085,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10623
12085
|
}
|
|
10624
12086
|
async clearTracks(input) {
|
|
10625
12087
|
this.trackStore?.clearDevice(input.deviceId);
|
|
12088
|
+
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10626
12089
|
this.overlayState.clearDevice(input.deviceId);
|
|
10627
12090
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
10628
12091
|
const prefix = `${input.deviceId}:`;
|
|
@@ -10924,6 +12387,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10924
12387
|
* Enrolled gallery + identity media are exempt.
|
|
10925
12388
|
*/
|
|
10926
12389
|
async wipeAllAnalytics(input) {
|
|
12390
|
+
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10927
12391
|
return this.pruneTracksBefore({
|
|
10928
12392
|
deviceId: input.deviceId,
|
|
10929
12393
|
cutoffMs: Date.now()
|
|
@@ -10975,24 +12439,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10975
12439
|
* points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
|
|
10976
12440
|
* key), so this resolves that crop; event ids stay on the event-crop path.
|
|
10977
12441
|
*/
|
|
10978
|
-
async readMediaByEventOrKey(id) {
|
|
10979
|
-
|
|
10980
|
-
|
|
10981
|
-
|
|
12442
|
+
async readMediaByEventOrKey(id, variant, preferKind) {
|
|
12443
|
+
const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
|
|
12444
|
+
if (base === null || variant === void 0) return base;
|
|
12445
|
+
return this.applyThumbVariant(base, variant);
|
|
12446
|
+
}
|
|
12447
|
+
async readMediaByKey(id) {
|
|
12448
|
+
const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
|
|
12449
|
+
if (!file) return null;
|
|
12450
|
+
return {
|
|
12451
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
12452
|
+
key: file.key
|
|
12453
|
+
};
|
|
12454
|
+
}
|
|
12455
|
+
/**
|
|
12456
|
+
* Render a small center-cropped square from a resolved event media blob for
|
|
12457
|
+
* the reel / list surfaces. The returned `key` is variant-distinct so the
|
|
12458
|
+
* data-plane ETag never collides with the full-size blob's. On any encode
|
|
12459
|
+
* failure the full blob is served (a thumb must never 500 / blank a tile).
|
|
12460
|
+
*/
|
|
12461
|
+
async applyThumbVariant(media, variant) {
|
|
12462
|
+
try {
|
|
10982
12463
|
return {
|
|
10983
|
-
bytes:
|
|
10984
|
-
key:
|
|
12464
|
+
bytes: await makeSquareThumb(media.bytes, variant.size),
|
|
12465
|
+
key: `${media.key}|t${variant.size}`
|
|
10985
12466
|
};
|
|
12467
|
+
} catch (err) {
|
|
12468
|
+
this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
|
|
12469
|
+
key: media.key,
|
|
12470
|
+
size: variant.size,
|
|
12471
|
+
error: errMsg(err)
|
|
12472
|
+
} });
|
|
12473
|
+
return media;
|
|
10986
12474
|
}
|
|
10987
|
-
return this.readEventThumbnail(id);
|
|
10988
12475
|
}
|
|
10989
|
-
async readEventThumbnail(
|
|
10990
|
-
const
|
|
10991
|
-
|
|
10992
|
-
|
|
12476
|
+
async readEventThumbnail(id, preferKind) {
|
|
12477
|
+
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
12478
|
+
if (preferKind !== void 0 && preferKind.length > 0) {
|
|
12479
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12480
|
+
const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
|
|
12481
|
+
if (!clean) return null;
|
|
12482
|
+
return {
|
|
12483
|
+
bytes: Buffer.from(clean.base64, "base64"),
|
|
12484
|
+
key: clean.key
|
|
12485
|
+
};
|
|
12486
|
+
}
|
|
12487
|
+
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12488
|
+
if (chosenEvent) return {
|
|
12489
|
+
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
12490
|
+
key: chosenEvent.key
|
|
12491
|
+
};
|
|
12492
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12493
|
+
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];
|
|
12494
|
+
if (!chosenTrack) return null;
|
|
10993
12495
|
return {
|
|
10994
|
-
bytes: Buffer.from(
|
|
10995
|
-
key:
|
|
12496
|
+
bytes: Buffer.from(chosenTrack.base64, "base64"),
|
|
12497
|
+
key: chosenTrack.key
|
|
10996
12498
|
};
|
|
10997
12499
|
}
|
|
10998
12500
|
/**
|
|
@@ -11085,6 +12587,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11085
12587
|
unit: "s",
|
|
11086
12588
|
displayScale: 1e3
|
|
11087
12589
|
},
|
|
12590
|
+
{
|
|
12591
|
+
type: "slider",
|
|
12592
|
+
key: "snapshotMovementThreshold",
|
|
12593
|
+
label: "Snapshot movement gate",
|
|
12594
|
+
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.",
|
|
12595
|
+
min: 0,
|
|
12596
|
+
max: .15,
|
|
12597
|
+
step: .005,
|
|
12598
|
+
default: .03,
|
|
12599
|
+
showValue: true,
|
|
12600
|
+
unit: "%",
|
|
12601
|
+
displayScale: .01
|
|
12602
|
+
},
|
|
12603
|
+
{
|
|
12604
|
+
type: "slider",
|
|
12605
|
+
key: "snapshotMaxIdleMs",
|
|
12606
|
+
label: "Snapshot max idle",
|
|
12607
|
+
description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
|
|
12608
|
+
min: 5e3,
|
|
12609
|
+
max: 12e4,
|
|
12610
|
+
step: 5e3,
|
|
12611
|
+
default: 3e4,
|
|
12612
|
+
showValue: true,
|
|
12613
|
+
unit: "s",
|
|
12614
|
+
displayScale: 1e3
|
|
12615
|
+
},
|
|
11088
12616
|
{
|
|
11089
12617
|
type: "select",
|
|
11090
12618
|
key: "mediaAttachPolicy",
|
|
@@ -11540,4 +13068,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11540
13068
|
}
|
|
11541
13069
|
};
|
|
11542
13070
|
//#endregion
|
|
11543
|
-
export { PipelineAnalyticsAddon as default, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
13071
|
+
export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|