@camstack/addon-post-analysis 1.1.28 → 1.1.29
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-AFLbpmAs.js} +94 -6
- package/dist/{dist-Blpsv-M0.mjs → dist-CFjLqX2m.mjs} +94 -6
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-Cvhwrf43.js → node-DtltlqrH.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-D0V4LPAq.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D-KSUwyU.mjs → hostInit-B71dWJNT.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1391 -187
- package/dist/pipeline-analytics/index.mjs +1390 -186
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -2
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, 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 hydrateSchema, x as object, y as boolean } from "../dist-
|
|
1
|
+
import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, 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 hydrateSchema, x as object, y as boolean } from "../dist-CFjLqX2m.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,555 @@ 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
|
+
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2500
|
+
* clock that drives entry expiry in {@link sweep}. */
|
|
2501
|
+
noteFrame(deviceId, timestamp) {
|
|
2502
|
+
if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Per-frame gate: partition this frame's detections against the device's
|
|
2506
|
+
* entries. PURE with respect to registry state — apply the outcome with
|
|
2507
|
+
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2508
|
+
*/
|
|
2509
|
+
filter(input) {
|
|
2510
|
+
const entries = this.list(input.deviceId);
|
|
2511
|
+
if (entries.length === 0) return {
|
|
2512
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
2513
|
+
confirmed: [],
|
|
2514
|
+
wokenEntryIds: []
|
|
2515
|
+
};
|
|
2516
|
+
return partitionDetectionsAgainstRegistry({
|
|
2517
|
+
entries,
|
|
2518
|
+
detections: input.detections,
|
|
2519
|
+
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2520
|
+
config: this.matchConfig
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
2524
|
+
* `lastConfirmedAt` and retire woken entries (their object departed). */
|
|
2525
|
+
applyFrameOutcome(input) {
|
|
2526
|
+
const m = this.byDevice.get(input.deviceId);
|
|
2527
|
+
if (!m) return;
|
|
2528
|
+
for (const c of input.confirmed) {
|
|
2529
|
+
const e = m.get(c.entryId);
|
|
2530
|
+
if (!e) continue;
|
|
2531
|
+
m.set(c.entryId, {
|
|
2532
|
+
...e,
|
|
2533
|
+
lastConfirmedAt: input.timestamp
|
|
2534
|
+
});
|
|
2535
|
+
this.dirty.add(c.entryId);
|
|
2536
|
+
}
|
|
2537
|
+
for (const id of input.wokenEntryIds) {
|
|
2538
|
+
const e = m.get(id);
|
|
2539
|
+
if (!e) continue;
|
|
2540
|
+
m.delete(id);
|
|
2541
|
+
this.dirty.delete(id);
|
|
2542
|
+
this.deletePersisted(id);
|
|
2543
|
+
this.onChange?.({
|
|
2544
|
+
phase: "departed",
|
|
2545
|
+
entry: e,
|
|
2546
|
+
timestamp: input.timestamp
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
/** Promote a parked track into a persisted stationary entry. */
|
|
2551
|
+
async promote(entry) {
|
|
2552
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2553
|
+
this.dirty.delete(entry.id);
|
|
2554
|
+
try {
|
|
2555
|
+
await this.persist(entry);
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
this.logger.warn("stationary promote persist failed", {
|
|
2558
|
+
tags: { deviceId: entry.deviceId },
|
|
2559
|
+
meta: {
|
|
2560
|
+
entryId: entry.id,
|
|
2561
|
+
error: String(err)
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
this.onChange?.({
|
|
2566
|
+
phase: "appeared",
|
|
2567
|
+
entry,
|
|
2568
|
+
timestamp: entry.becameStationaryAt
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
|
|
2573
|
+
* flush any advanced `lastConfirmedAt`s to the store. Returns retired
|
|
2574
|
+
* entries (for logging).
|
|
2575
|
+
*
|
|
2576
|
+
* Expiry is measured against the device's latest processed-frame timestamp
|
|
2577
|
+
* ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
|
|
2578
|
+
* no frames between motion sessions, and that silence says nothing about
|
|
2579
|
+
* the object. Only when the camera has actually been WATCHING for `ttl`
|
|
2580
|
+
* beyond the last confirmation (frames flowed, object never matched) is the
|
|
2581
|
+
* object considered removed. A device with no recorded frame yet never
|
|
2582
|
+
* expires its entries. `now` only stamps the departed telemetry.
|
|
2583
|
+
*/
|
|
2584
|
+
async sweep(now) {
|
|
2585
|
+
const retired = [];
|
|
2586
|
+
for (const [deviceId, m] of this.byDevice) {
|
|
2587
|
+
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2588
|
+
if (observedAt === void 0) continue;
|
|
2589
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
|
|
2590
|
+
m.delete(id);
|
|
2591
|
+
this.dirty.delete(id);
|
|
2592
|
+
retired.push(e);
|
|
2593
|
+
this.deletePersisted(id);
|
|
2594
|
+
this.onChange?.({
|
|
2595
|
+
phase: "departed",
|
|
2596
|
+
entry: e,
|
|
2597
|
+
timestamp: now
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
if (m.size === 0) this.byDevice.delete(deviceId);
|
|
2601
|
+
}
|
|
2602
|
+
for (const id of [...this.dirty]) {
|
|
2603
|
+
this.dirty.delete(id);
|
|
2604
|
+
const entry = this.findById(id);
|
|
2605
|
+
if (!entry) continue;
|
|
2606
|
+
try {
|
|
2607
|
+
await this.store.update.mutate({
|
|
2608
|
+
collection: STATIONARY_COLLECTION,
|
|
2609
|
+
id,
|
|
2610
|
+
data: { lastConfirmedAt: entry.lastConfirmedAt }
|
|
2611
|
+
});
|
|
2612
|
+
} catch (err) {
|
|
2613
|
+
this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
|
|
2614
|
+
entryId: id,
|
|
2615
|
+
error: String(err)
|
|
2616
|
+
} });
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
return retired;
|
|
2620
|
+
}
|
|
2621
|
+
/** Drop a device's entries from memory WITHOUT deleting persisted rows.
|
|
2622
|
+
* Used on device unbind; a rebind reloads from the store. */
|
|
2623
|
+
forgetDevice(deviceId) {
|
|
2624
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2625
|
+
const m = this.byDevice.get(deviceId);
|
|
2626
|
+
if (!m) return;
|
|
2627
|
+
for (const id of m.keys()) this.dirty.delete(id);
|
|
2628
|
+
this.byDevice.delete(deviceId);
|
|
2629
|
+
}
|
|
2630
|
+
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
2631
|
+
async clearDevice(deviceId) {
|
|
2632
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2633
|
+
const m = this.byDevice.get(deviceId);
|
|
2634
|
+
if (m) {
|
|
2635
|
+
for (const id of [...m.keys()]) {
|
|
2636
|
+
this.dirty.delete(id);
|
|
2637
|
+
this.deletePersisted(id);
|
|
2638
|
+
}
|
|
2639
|
+
this.byDevice.delete(deviceId);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
deviceMap(deviceId) {
|
|
2643
|
+
let m = this.byDevice.get(deviceId);
|
|
2644
|
+
if (!m) {
|
|
2645
|
+
m = /* @__PURE__ */ new Map();
|
|
2646
|
+
this.byDevice.set(deviceId, m);
|
|
2647
|
+
}
|
|
2648
|
+
return m;
|
|
2649
|
+
}
|
|
2650
|
+
findById(id) {
|
|
2651
|
+
for (const m of this.byDevice.values()) {
|
|
2652
|
+
const e = m.get(id);
|
|
2653
|
+
if (e) return e;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
async persist(e) {
|
|
2657
|
+
await this.store.set.mutate({
|
|
2658
|
+
collection: STATIONARY_COLLECTION,
|
|
2659
|
+
key: e.id,
|
|
2660
|
+
value: {
|
|
2661
|
+
deviceId: e.deviceId,
|
|
2662
|
+
className: e.className,
|
|
2663
|
+
bbox: { ...e.bbox },
|
|
2664
|
+
frameWidth: e.frameWidth,
|
|
2665
|
+
frameHeight: e.frameHeight,
|
|
2666
|
+
firstSeenAt: e.firstSeenAt,
|
|
2667
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2668
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2669
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
|
|
2670
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2671
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
async deletePersisted(id) {
|
|
2676
|
+
try {
|
|
2677
|
+
await this.store.delete.mutate({
|
|
2678
|
+
collection: STATIONARY_COLLECTION,
|
|
2679
|
+
key: id
|
|
2680
|
+
});
|
|
2681
|
+
} catch (err) {
|
|
2682
|
+
this.logger.debug("stationary delete failed", { meta: {
|
|
2683
|
+
entryId: id,
|
|
2684
|
+
error: String(err)
|
|
2685
|
+
} });
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
function rowToEntry(id, data) {
|
|
2690
|
+
const deviceId = Number(data["deviceId"]);
|
|
2691
|
+
const className = data["className"];
|
|
2692
|
+
const bbox = data["bbox"];
|
|
2693
|
+
if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
|
|
2694
|
+
const sourceTrackId = data["sourceTrackId"];
|
|
2695
|
+
const label = data["label"];
|
|
2696
|
+
const keyFrameMediaKey = data["keyFrameMediaKey"];
|
|
2697
|
+
return {
|
|
2698
|
+
id,
|
|
2699
|
+
deviceId,
|
|
2700
|
+
className,
|
|
2701
|
+
bbox: {
|
|
2702
|
+
x: Number(bbox.x),
|
|
2703
|
+
y: Number(bbox.y),
|
|
2704
|
+
w: Number(bbox.w),
|
|
2705
|
+
h: Number(bbox.h)
|
|
2706
|
+
},
|
|
2707
|
+
frameWidth: Number(data["frameWidth"] ?? 0),
|
|
2708
|
+
frameHeight: Number(data["frameHeight"] ?? 0),
|
|
2709
|
+
firstSeenAt: Number(data["firstSeenAt"] ?? 0),
|
|
2710
|
+
becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
|
|
2711
|
+
lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
|
|
2712
|
+
...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
|
|
2713
|
+
...typeof label === "string" ? { label } : {},
|
|
2714
|
+
...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
//#endregion
|
|
2718
|
+
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2719
|
+
/**
|
|
2720
|
+
* Pure: no side effects. `continuing` = still active from last frame;
|
|
2721
|
+
* `birth` = a brand-new track's first sighting; `resurrection` = a known track
|
|
2722
|
+
* re-entering the active set after being lost.
|
|
2723
|
+
*/
|
|
2724
|
+
function classifyTrackAppearance(input) {
|
|
2725
|
+
if (input.inPrevActive) return "continuing";
|
|
2726
|
+
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
2727
|
+
}
|
|
2728
|
+
//#endregion
|
|
2115
2729
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
2116
2730
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
2117
2731
|
const scored = [];
|
|
@@ -2121,6 +2735,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2121
2735
|
let bestEventId = t.bestEventId;
|
|
2122
2736
|
if (importance === void 0) {
|
|
2123
2737
|
const peak = await peakLookup(t.trackId);
|
|
2738
|
+
const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
2739
|
+
x: p.x,
|
|
2740
|
+
y: p.y
|
|
2741
|
+
})), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
|
|
2124
2742
|
importance = computeImportance({
|
|
2125
2743
|
peakConfidence: peak.peakConfidence,
|
|
2126
2744
|
className: t.className,
|
|
@@ -2128,7 +2746,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2128
2746
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
2129
2747
|
totalDistance: t.totalDistance,
|
|
2130
2748
|
zonesVisited: t.zonesVisited,
|
|
2131
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
2749
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
2750
|
+
...staticMetrics ? {
|
|
2751
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
2752
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
2753
|
+
} : {}
|
|
2132
2754
|
}).importance;
|
|
2133
2755
|
bestEventId = bestEventId ?? peak.bestEventId;
|
|
2134
2756
|
}
|
|
@@ -2140,7 +2762,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2140
2762
|
className: t.className,
|
|
2141
2763
|
...t.label !== void 0 ? { label: t.label } : {},
|
|
2142
2764
|
importance,
|
|
2143
|
-
bestEventId: bestEventId ??
|
|
2765
|
+
bestEventId: bestEventId ?? t.trackId,
|
|
2144
2766
|
windowMs: t.lastSeen - t.firstSeen
|
|
2145
2767
|
});
|
|
2146
2768
|
}
|
|
@@ -2377,6 +2999,10 @@ var TRACKS_COLUMNS = [
|
|
|
2377
2999
|
{
|
|
2378
3000
|
name: "importanceReason",
|
|
2379
3001
|
type: "TEXT"
|
|
3002
|
+
},
|
|
3003
|
+
{
|
|
3004
|
+
name: "audioLabels",
|
|
3005
|
+
type: "JSON"
|
|
2380
3006
|
}
|
|
2381
3007
|
];
|
|
2382
3008
|
var TRACKS_INDEXES = [{
|
|
@@ -2386,6 +3012,17 @@ var TRACKS_INDEXES = [{
|
|
|
2386
3012
|
name: "idx_tracks_device_firstSeen",
|
|
2387
3013
|
columns: ["deviceId", "firstSeen"]
|
|
2388
3014
|
}];
|
|
3015
|
+
/** Serialize the per-label aggregate map into the `Track.audioLabels`
|
|
3016
|
+
* array shape, most-frequent label first. */
|
|
3017
|
+
function audioLabelsToArray(agg) {
|
|
3018
|
+
return [...agg.entries()].map(([label, a]) => ({
|
|
3019
|
+
label,
|
|
3020
|
+
peakScore: a.peakScore,
|
|
3021
|
+
count: a.count,
|
|
3022
|
+
firstAt: a.firstAt,
|
|
3023
|
+
lastAt: a.lastAt
|
|
3024
|
+
})).sort((a, b) => b.count - a.count);
|
|
3025
|
+
}
|
|
2389
3026
|
function cloneTrack(t) {
|
|
2390
3027
|
return {
|
|
2391
3028
|
trackId: t.trackId,
|
|
@@ -2412,7 +3049,8 @@ function cloneTrack(t) {
|
|
|
2412
3049
|
active: t.active,
|
|
2413
3050
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2414
3051
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2415
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3052
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3053
|
+
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
|
|
2416
3054
|
};
|
|
2417
3055
|
}
|
|
2418
3056
|
var TrackStore = class {
|
|
@@ -2473,25 +3111,81 @@ var TrackStore = class {
|
|
|
2473
3111
|
this.active.set(params.trackId, fresh);
|
|
2474
3112
|
return fresh;
|
|
2475
3113
|
}
|
|
3114
|
+
/**
|
|
3115
|
+
* Record one audio-classification EPISODE against every track currently
|
|
3116
|
+
* active on the device — "what was heard on this camera while the track
|
|
3117
|
+
* was alive". Called from the confident-classification audio-event insert
|
|
3118
|
+
* (score ≥ device `classificationMinScore`, class-change-or-heartbeat
|
|
3119
|
+
* coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
|
|
3120
|
+
*/
|
|
3121
|
+
addAudioLabelEpisode(deviceId, label, score, timestamp) {
|
|
3122
|
+
for (const t of this.active.values()) {
|
|
3123
|
+
if (t.deviceId !== deviceId || !t.active) continue;
|
|
3124
|
+
const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
|
|
3125
|
+
const entry = agg.get(label);
|
|
3126
|
+
if (entry) {
|
|
3127
|
+
entry.peakScore = Math.max(entry.peakScore, score);
|
|
3128
|
+
entry.count += 1;
|
|
3129
|
+
entry.lastAt = timestamp;
|
|
3130
|
+
} else agg.set(label, {
|
|
3131
|
+
peakScore: score,
|
|
3132
|
+
count: 1,
|
|
3133
|
+
firstAt: timestamp,
|
|
3134
|
+
lastAt: timestamp
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
2476
3138
|
/** Attach a snapshot reference to an active track. */
|
|
2477
3139
|
addSnapshot(trackId, snapshot) {
|
|
2478
3140
|
const t = this.active.get(trackId);
|
|
2479
3141
|
if (!t) return;
|
|
2480
3142
|
t.snapshots.push(snapshot);
|
|
2481
3143
|
t.lastSnapshotAt = snapshot.timestamp;
|
|
3144
|
+
t.lastSnapshotBbox = { ...snapshot.position.bbox };
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
|
|
3148
|
+
* the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
|
|
3149
|
+
* lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
|
|
3150
|
+
* only advances when the dispatcher round-trip returns (~50-200ms of sharp
|
|
3151
|
+
* encode), so at 10-25fps several consecutive frames pass
|
|
3152
|
+
* `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
|
|
3153
|
+
* snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
|
|
3154
|
+
* rolling `lastFrame`.
|
|
3155
|
+
*
|
|
3156
|
+
* No rollback: if the write later fails the slot is simply lost (a rare dropped
|
|
3157
|
+
* snapshot is preferable to a burst). `addSnapshot` re-stamps the same
|
|
3158
|
+
* clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
|
|
3159
|
+
* an unknown/expired track (the active entry is dropped on expiry, so there is
|
|
3160
|
+
* no separate map to leak).
|
|
3161
|
+
*/
|
|
3162
|
+
markSnapshotPending(trackId, timestamp, bbox) {
|
|
3163
|
+
const t = this.active.get(trackId);
|
|
3164
|
+
if (!t) return;
|
|
3165
|
+
t.lastSnapshotAt = timestamp;
|
|
3166
|
+
t.lastSnapshotBbox = { ...bbox };
|
|
2482
3167
|
}
|
|
2483
3168
|
lastSnapshotAt(trackId) {
|
|
2484
3169
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
2485
3170
|
}
|
|
3171
|
+
/** Bbox reference of the last captured snapshot (or the seed bbox), for the
|
|
3172
|
+
* periodic-snapshot movement gate. Undefined until the clock is seeded. */
|
|
3173
|
+
lastSnapshotBbox(trackId) {
|
|
3174
|
+
const b = this.active.get(trackId)?.lastSnapshotBbox;
|
|
3175
|
+
return b ? { ...b } : void 0;
|
|
3176
|
+
}
|
|
2486
3177
|
/**
|
|
2487
3178
|
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
2488
3179
|
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
2489
3180
|
* track begins rather than immediately — the `firstFrame` already covers the
|
|
2490
3181
|
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
2491
3182
|
*/
|
|
2492
|
-
seedSnapshotClock(trackId, timestamp) {
|
|
3183
|
+
seedSnapshotClock(trackId, timestamp, bbox) {
|
|
2493
3184
|
const t = this.active.get(trackId);
|
|
2494
|
-
if (t && t.lastSnapshotAt === 0)
|
|
3185
|
+
if (t && t.lastSnapshotAt === 0) {
|
|
3186
|
+
t.lastSnapshotAt = timestamp;
|
|
3187
|
+
if (bbox) t.lastSnapshotBbox = { ...bbox };
|
|
3188
|
+
}
|
|
2495
3189
|
}
|
|
2496
3190
|
getActive(deviceId) {
|
|
2497
3191
|
const out = [];
|
|
@@ -2502,6 +3196,21 @@ var TrackStore = class {
|
|
|
2502
3196
|
const t = this.active.get(trackId);
|
|
2503
3197
|
return t && t.active ? cloneTrack(t) : null;
|
|
2504
3198
|
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Cheap read of an active track's promotion-relevant fields WITHOUT the deep
|
|
3201
|
+
* clone `getActiveByTrack` does — the returned `positions` is the live
|
|
3202
|
+
* internal array (read-only; callers must not mutate). Feeds the per-frame
|
|
3203
|
+
* stationary-promotion check, called at inference fps. Null if unknown/expired.
|
|
3204
|
+
*/
|
|
3205
|
+
peekActive(trackId) {
|
|
3206
|
+
const t = this.active.get(trackId);
|
|
3207
|
+
if (!t || !t.active) return null;
|
|
3208
|
+
return {
|
|
3209
|
+
firstSeen: t.firstSeen,
|
|
3210
|
+
positions: t.positions,
|
|
3211
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
2505
3214
|
/** Expire tracks whose `lastSeen` is older than TTL. Persists each
|
|
2506
3215
|
* expired track to the declared collection and returns them. */
|
|
2507
3216
|
async expireStale(nowMs) {
|
|
@@ -2607,6 +3316,15 @@ var TrackStore = class {
|
|
|
2607
3316
|
clearAll() {
|
|
2608
3317
|
this.active.clear();
|
|
2609
3318
|
}
|
|
3319
|
+
/**
|
|
3320
|
+
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
3321
|
+
* when a track is PROMOTED to a stationary-object registry entry: the durable
|
|
3322
|
+
* record for a parked object is the registry entry, not a Track, so the track
|
|
3323
|
+
* must NOT land in the key-event feed. No-op for an unknown/expired track.
|
|
3324
|
+
*/
|
|
3325
|
+
dropActive(trackId) {
|
|
3326
|
+
this.active.delete(trackId);
|
|
3327
|
+
}
|
|
2610
3328
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2611
3329
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2612
3330
|
async deletePersisted(trackId) {
|
|
@@ -2739,7 +3457,8 @@ var TrackStore = class {
|
|
|
2739
3457
|
state: t.state,
|
|
2740
3458
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2741
3459
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2742
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3460
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3461
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
2743
3462
|
}
|
|
2744
3463
|
});
|
|
2745
3464
|
}
|
|
@@ -2752,6 +3471,7 @@ var TrackStore = class {
|
|
|
2752
3471
|
const importance = data["importance"];
|
|
2753
3472
|
const bestEventId = data["bestEventId"];
|
|
2754
3473
|
const importanceReason = data["importanceReason"];
|
|
3474
|
+
const audioLabels = data["audioLabels"];
|
|
2755
3475
|
return {
|
|
2756
3476
|
trackId: id,
|
|
2757
3477
|
deviceId: Number(data["deviceId"]),
|
|
@@ -2768,7 +3488,8 @@ var TrackStore = class {
|
|
|
2768
3488
|
active: false,
|
|
2769
3489
|
...typeof importance === "number" ? { importance } : {},
|
|
2770
3490
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
2771
|
-
...typeof importanceReason === "string" ? { importanceReason } : {}
|
|
3491
|
+
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3492
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
2772
3493
|
};
|
|
2773
3494
|
}
|
|
2774
3495
|
};
|
|
@@ -3881,13 +4602,10 @@ function stripNulls(data) {
|
|
|
3881
4602
|
//#endregion
|
|
3882
4603
|
//#region src/shared/frame/resolve-frame.ts
|
|
3883
4604
|
/**
|
|
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).
|
|
4605
|
+
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
4606
|
+
* Returns `null` when the frame is no longer available.
|
|
3888
4607
|
*/
|
|
3889
4608
|
async function resolveFrame(handle, deps) {
|
|
3890
|
-
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3891
4609
|
return deps.getRemoteFrame(handle);
|
|
3892
4610
|
}
|
|
3893
4611
|
//#endregion
|
|
@@ -4064,11 +4782,7 @@ var EventMediaDispatcher = class {
|
|
|
4064
4782
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
4065
4783
|
let decoded;
|
|
4066
4784
|
try {
|
|
4067
|
-
decoded = await resolveFrame(frameHandle, {
|
|
4068
|
-
ownNodeId: this.deps.ownNodeId,
|
|
4069
|
-
readers: this.deps.readers,
|
|
4070
|
-
getRemoteFrame: this.deps.getRemoteFrame
|
|
4071
|
-
});
|
|
4785
|
+
decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
4072
4786
|
} catch (err) {
|
|
4073
4787
|
this.deps.logger.debug("event media: resolveFrame threw", {
|
|
4074
4788
|
tags: { deviceId },
|
|
@@ -4379,8 +5093,6 @@ var EmbeddingDispatcher = class {
|
|
|
4379
5093
|
encoder;
|
|
4380
5094
|
eventBus;
|
|
4381
5095
|
logger;
|
|
4382
|
-
ownNodeId;
|
|
4383
|
-
readers;
|
|
4384
5096
|
getRemoteFrame;
|
|
4385
5097
|
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4386
5098
|
pendingCrops = /* @__PURE__ */ new Map();
|
|
@@ -4393,8 +5105,6 @@ var EmbeddingDispatcher = class {
|
|
|
4393
5105
|
this.encoder = deps.encoder;
|
|
4394
5106
|
this.eventBus = deps.eventBus;
|
|
4395
5107
|
this.logger = deps.logger;
|
|
4396
|
-
this.ownNodeId = deps.ownNodeId;
|
|
4397
|
-
this.readers = deps.readers;
|
|
4398
5108
|
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4399
5109
|
}
|
|
4400
5110
|
async start() {
|
|
@@ -4446,11 +5156,7 @@ var EmbeddingDispatcher = class {
|
|
|
4446
5156
|
}
|
|
4447
5157
|
let decoded;
|
|
4448
5158
|
try {
|
|
4449
|
-
decoded = await resolveFrame(handle, {
|
|
4450
|
-
ownNodeId: this.ownNodeId,
|
|
4451
|
-
readers: this.readers,
|
|
4452
|
-
getRemoteFrame: this.getRemoteFrame
|
|
4453
|
-
});
|
|
5159
|
+
decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
|
|
4454
5160
|
} catch (err) {
|
|
4455
5161
|
this.logger.debug("skip: resolveFrame threw", {
|
|
4456
5162
|
tags: { deviceId: Number(deviceId) },
|
|
@@ -4877,7 +5583,8 @@ function computeSnapshot(input) {
|
|
|
4877
5583
|
unzoned: {
|
|
4878
5584
|
totalObjects: unzonedTotal,
|
|
4879
5585
|
byClass: unzonedByClass
|
|
4880
|
-
}
|
|
5586
|
+
},
|
|
5587
|
+
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
4881
5588
|
};
|
|
4882
5589
|
}
|
|
4883
5590
|
//#endregion
|
|
@@ -5453,7 +6160,18 @@ var MediaSettingsSchema = object({
|
|
|
5453
6160
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
5454
6161
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
5455
6162
|
* has elapsed since its previous one. */
|
|
5456
|
-
snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
|
|
6163
|
+
snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3),
|
|
6164
|
+
/** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
|
|
6165
|
+
* elapsed, a fresh snapshot is only captured when the track's centroid moved
|
|
6166
|
+
* at least this fraction of the frame DIAGONAL since the last captured
|
|
6167
|
+
* snapshot. Suppresses near-identical frames from a long-lived / stationary
|
|
6168
|
+
* track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
|
|
6169
|
+
* frame diagonal (~66px on 1080p). */
|
|
6170
|
+
snapshotMovementThreshold: number().min(0).max(1).default(.03),
|
|
6171
|
+
/** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
|
|
6172
|
+
* still-present track after this much wall-clock without one, so its
|
|
6173
|
+
* filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
|
|
6174
|
+
snapshotMaxIdleMs: number().int().min(1e3).max(6e5).default(3e4)
|
|
5457
6175
|
});
|
|
5458
6176
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
5459
6177
|
/**
|
|
@@ -5468,7 +6186,148 @@ function resolveMediaSettings(raw) {
|
|
|
5468
6186
|
return {
|
|
5469
6187
|
cropPadding: pick("cropPadding"),
|
|
5470
6188
|
saveThumbnails: pick("saveThumbnails"),
|
|
5471
|
-
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
6189
|
+
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
6190
|
+
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
6191
|
+
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
6192
|
+
};
|
|
6193
|
+
}
|
|
6194
|
+
function centroidOf(b) {
|
|
6195
|
+
return {
|
|
6196
|
+
x: b.x + b.w / 2,
|
|
6197
|
+
y: b.y + b.h / 2
|
|
6198
|
+
};
|
|
6199
|
+
}
|
|
6200
|
+
/** Centroid displacement between two boxes as a fraction of the frame diagonal.
|
|
6201
|
+
* Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
|
|
6202
|
+
* to pure-time behaviour instead of dividing by zero. */
|
|
6203
|
+
function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
6204
|
+
const diag = Math.hypot(frameWidth, frameHeight);
|
|
6205
|
+
if (diag <= 0) return 0;
|
|
6206
|
+
const ca = centroidOf(a);
|
|
6207
|
+
const cb = centroidOf(b);
|
|
6208
|
+
return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
6212
|
+
* frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
|
|
6213
|
+
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
6214
|
+
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
6215
|
+
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
6216
|
+
* until the object moves.
|
|
6217
|
+
*/
|
|
6218
|
+
function evaluatePeriodicSnapshot(input) {
|
|
6219
|
+
const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
|
|
6220
|
+
if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
|
|
6221
|
+
if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
|
|
6222
|
+
const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6223
|
+
if (fastMoved >= .08) return {
|
|
6224
|
+
capture: true,
|
|
6225
|
+
reason: "fast-mover",
|
|
6226
|
+
movedFraction: fastMoved
|
|
6227
|
+
};
|
|
6228
|
+
}
|
|
6229
|
+
return {
|
|
6230
|
+
capture: false,
|
|
6231
|
+
reason: "interval-not-elapsed",
|
|
6232
|
+
movedFraction: 0
|
|
6233
|
+
};
|
|
6234
|
+
}
|
|
6235
|
+
if (lastSnapshotBbox === void 0) return {
|
|
6236
|
+
capture: true,
|
|
6237
|
+
reason: "no-reference",
|
|
6238
|
+
movedFraction: 0
|
|
6239
|
+
};
|
|
6240
|
+
const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6241
|
+
if (movedFraction >= movementThreshold) return {
|
|
6242
|
+
capture: true,
|
|
6243
|
+
reason: "moved",
|
|
6244
|
+
movedFraction
|
|
6245
|
+
};
|
|
6246
|
+
const idleLimit = Math.max(maxIdleMs, intervalMs);
|
|
6247
|
+
if (now - lastSnapshotAt >= idleLimit) return {
|
|
6248
|
+
capture: true,
|
|
6249
|
+
reason: "idle-forced",
|
|
6250
|
+
movedFraction
|
|
6251
|
+
};
|
|
6252
|
+
return {
|
|
6253
|
+
capture: false,
|
|
6254
|
+
reason: "stationary-skip",
|
|
6255
|
+
movedFraction
|
|
6256
|
+
};
|
|
6257
|
+
}
|
|
6258
|
+
//#endregion
|
|
6259
|
+
//#region src/pipeline-analytics/periodic-media-plan.ts
|
|
6260
|
+
/**
|
|
6261
|
+
* Decide the periodic media writes for one track on one frame. Pure: no side
|
|
6262
|
+
* effects. The caller advances its own `lastFrameAt` clock only when the
|
|
6263
|
+
* returned `rollingLastFrame` is true.
|
|
6264
|
+
*
|
|
6265
|
+
* INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
|
|
6266
|
+
* rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
|
|
6267
|
+
* can never duplicate one.
|
|
6268
|
+
*/
|
|
6269
|
+
function planPeriodicMedia(input) {
|
|
6270
|
+
const appendSnapshot = input.dueSnapshot;
|
|
6271
|
+
return {
|
|
6272
|
+
appendSnapshot,
|
|
6273
|
+
rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
|
|
6274
|
+
bestThumbnail: input.isNewBest
|
|
6275
|
+
};
|
|
6276
|
+
}
|
|
6277
|
+
//#endregion
|
|
6278
|
+
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
6279
|
+
/**
|
|
6280
|
+
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
6281
|
+
* (Design B — one native full-frame per track at its best-detection moment).
|
|
6282
|
+
*
|
|
6283
|
+
* ## Why this exists (the missing native keyFrame)
|
|
6284
|
+
*
|
|
6285
|
+
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
6286
|
+
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
6287
|
+
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
6288
|
+
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
6289
|
+
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
6290
|
+
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
6291
|
+
*
|
|
6292
|
+
* The fix decouples the `keyFrame` from the clip path: it is captured on the
|
|
6293
|
+
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
6294
|
+
* `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
|
|
6295
|
+
* `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
|
|
6296
|
+
* retained NATIVE surface and only falls back to the detection frame on a miss).
|
|
6297
|
+
* A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
|
|
6298
|
+
* LARGER than the detection raster (up to the cap), which is the whole point of
|
|
6299
|
+
* the `keyFrame` kind.
|
|
6300
|
+
*/
|
|
6301
|
+
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
6302
|
+
* Native resolution is the point, but a full 4K RGB surface over the transport
|
|
6303
|
+
* per new-best is wasteful for a web detail view — 1920px keeps a sharp native
|
|
6304
|
+
* frame while bounding the copy (a miss falls back to the detection-res frame,
|
|
6305
|
+
* which is already ≤640px). */
|
|
6306
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
6307
|
+
/**
|
|
6308
|
+
* The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
|
|
6309
|
+
* the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
|
|
6310
|
+
* downstream keeps one `keyFrame` per track (the current peak).
|
|
6311
|
+
*/
|
|
6312
|
+
function selectKeyFrameTrackIds(targets) {
|
|
6313
|
+
return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
|
|
6314
|
+
}
|
|
6315
|
+
/**
|
|
6316
|
+
* Build the `captureCrop` request for a track's native `keyFrame`: the FULL
|
|
6317
|
+
* frame (no padding) at the native width cap. The full-frame box is what makes
|
|
6318
|
+
* the capture route through the native surface at native resolution instead of
|
|
6319
|
+
* a tight ≤640 detection crop.
|
|
6320
|
+
*/
|
|
6321
|
+
function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
|
|
6322
|
+
return {
|
|
6323
|
+
bbox: {
|
|
6324
|
+
x: 0,
|
|
6325
|
+
y: 0,
|
|
6326
|
+
w: frameWidth,
|
|
6327
|
+
h: frameHeight
|
|
6328
|
+
},
|
|
6329
|
+
padding: 0,
|
|
6330
|
+
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
|
|
5472
6331
|
};
|
|
5473
6332
|
}
|
|
5474
6333
|
//#endregion
|
|
@@ -6938,7 +7797,7 @@ var DetailScheduler = class {
|
|
|
6938
7797
|
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6939
7798
|
/**
|
|
6940
7799
|
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6941
|
-
*
|
|
7800
|
+
* chain-aware for the multi-step detail subtrees.
|
|
6942
7801
|
*
|
|
6943
7802
|
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6944
7803
|
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
@@ -6947,13 +7806,35 @@ var DetailScheduler = class {
|
|
|
6947
7806
|
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6948
7807
|
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6949
7808
|
*
|
|
6950
|
-
*
|
|
6951
|
-
*
|
|
6952
|
-
* `
|
|
7809
|
+
* A `plate-detection` request ALWAYS includes `'plate-ocr'` — symmetric to the
|
|
7810
|
+
* face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
|
|
7811
|
+
* pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
|
|
7812
|
+
* plate never gets read and no plate text is ever produced. (The dispatcher
|
|
7813
|
+
* cannot import the pipeline catalog to derive the child chain — this hardcode
|
|
7814
|
+
* mirrors it; keep the two in sync when the catalog's plate subtree changes.)
|
|
7815
|
+
*
|
|
7816
|
+
* Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
7817
|
+
* strict-`steps` pruning — naming the nested child here is what keeps it in the
|
|
7818
|
+
* executed chain.
|
|
6953
7819
|
*/
|
|
6954
7820
|
function composeDetailSteps(req, hasTrackLabel) {
|
|
6955
|
-
if (req.stepId
|
|
6956
|
-
|
|
7821
|
+
if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
7822
|
+
if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
|
|
7823
|
+
return [req.stepId];
|
|
7824
|
+
}
|
|
7825
|
+
/**
|
|
7826
|
+
* Does `steps` name a nested-enrichment chain (root detector + a child that
|
|
7827
|
+
* produces a `label`/`embedding`), i.e. more than the bare root step? Used to
|
|
7828
|
+
* surface a silent enrichment miss (BUG C): a plate detected but never read, a
|
|
7829
|
+
* face detected but never embedded — the root detail still routes so the miss
|
|
7830
|
+
* is otherwise invisible. `['plate-detection']` alone is NOT a chain.
|
|
7831
|
+
*/
|
|
7832
|
+
function isEnrichmentChain(steps) {
|
|
7833
|
+
return steps.length > 1;
|
|
7834
|
+
}
|
|
7835
|
+
/** Does any returned detail carry the enrichment a chain request asked for? */
|
|
7836
|
+
function detailsCarryEnrichment(details) {
|
|
7837
|
+
return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
|
|
6957
7838
|
}
|
|
6958
7839
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6959
7840
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
@@ -7030,7 +7911,8 @@ var TrackDetailDispatcher = class {
|
|
|
7030
7911
|
queue: [],
|
|
7031
7912
|
inFlight: 0,
|
|
7032
7913
|
timer: null,
|
|
7033
|
-
lastFailWarnAt: 0
|
|
7914
|
+
lastFailWarnAt: 0,
|
|
7915
|
+
lastEnrichWarnAt: 0
|
|
7034
7916
|
};
|
|
7035
7917
|
this.devices.set(deviceId, dev);
|
|
7036
7918
|
}
|
|
@@ -7076,6 +7958,8 @@ var TrackDetailDispatcher = class {
|
|
|
7076
7958
|
let topScore = null;
|
|
7077
7959
|
if (details !== null && details.length > 0) {
|
|
7078
7960
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7961
|
+
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
7962
|
+
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
7079
7963
|
try {
|
|
7080
7964
|
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7081
7965
|
} catch (err) {
|
|
@@ -7157,6 +8041,20 @@ var TrackDetailDispatcher = class {
|
|
|
7157
8041
|
}
|
|
7158
8042
|
});
|
|
7159
8043
|
}
|
|
8044
|
+
warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
|
|
8045
|
+
const now = Date.now();
|
|
8046
|
+
if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
8047
|
+
dev.lastEnrichWarnAt = now;
|
|
8048
|
+
this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
|
|
8049
|
+
tags: { deviceId },
|
|
8050
|
+
meta: {
|
|
8051
|
+
trackId: req.trackId,
|
|
8052
|
+
stepId: req.stepId,
|
|
8053
|
+
reason: req.reason,
|
|
8054
|
+
steps
|
|
8055
|
+
}
|
|
8056
|
+
});
|
|
8057
|
+
}
|
|
7160
8058
|
};
|
|
7161
8059
|
//#endregion
|
|
7162
8060
|
//#region src/pipeline-analytics/overlay-state.ts
|
|
@@ -7994,38 +8892,68 @@ var PlateRecognizer = class {
|
|
|
7994
8892
|
name
|
|
7995
8893
|
} : null;
|
|
7996
8894
|
}
|
|
7997
|
-
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
7998
|
-
* the raw OCR text
|
|
8895
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
8896
|
+
* else the raw OCR text. Returns `null` for an implausible read (junk OCR
|
|
8897
|
+
* off a distant/oblique plate) — the caller must NOT stamp a label then. */
|
|
7999
8898
|
resolveLabel(text, score) {
|
|
8899
|
+
if (!isPlausiblePlateRead(text, score)) return null;
|
|
8000
8900
|
return this.matchVehicle(text, score)?.name ?? text;
|
|
8001
8901
|
}
|
|
8002
8902
|
async processFrame(input) {
|
|
8003
8903
|
const minConfidence = input.minConfidence ?? 0;
|
|
8004
8904
|
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
8905
|
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
|
-
|
|
8906
|
+
for (const c of candidates) await this.holdBest({
|
|
8907
|
+
deviceId: input.deviceId,
|
|
8908
|
+
trackId: c.trackId,
|
|
8909
|
+
text: c.plateText,
|
|
8910
|
+
score: c.plateScore,
|
|
8911
|
+
bbox: c.plateBbox,
|
|
8912
|
+
timestamp: input.timestamp,
|
|
8913
|
+
frameWidth: input.frameWidth,
|
|
8914
|
+
frameHeight: input.frameHeight,
|
|
8915
|
+
cropPadding: input.cropPadding,
|
|
8916
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
8917
|
+
});
|
|
8918
|
+
}
|
|
8919
|
+
/**
|
|
8920
|
+
* Detail-plane entry point (two-plane design): plate-ocr runs on demand per
|
|
8921
|
+
* track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
|
|
8922
|
+
* never lands on the per-frame `tracked[]` that {@link processFrame} scans.
|
|
8923
|
+
* The dispatcher's result router calls this with each plate detail so the
|
|
8924
|
+
* gallery still collects the best read + tight crop (persisted on
|
|
8925
|
+
* {@link onTrackEnd}). Without it the plate label rides the event but the
|
|
8926
|
+
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
8927
|
+
*/
|
|
8928
|
+
async observePlateRead(input) {
|
|
8929
|
+
if (!isPlausiblePlateRead(input.text, input.score)) return;
|
|
8930
|
+
if (input.score < (input.minConfidence ?? 0)) return;
|
|
8931
|
+
await this.holdBest(input);
|
|
8932
|
+
}
|
|
8933
|
+
/** Hold the highest-scoring plate read per track, capturing a tight crop the
|
|
8934
|
+
* first time a new best is seen (shared by the per-frame + detail-plane paths). */
|
|
8935
|
+
async holdBest(input) {
|
|
8936
|
+
const held = this.bestPlate.get(input.trackId);
|
|
8937
|
+
if (held !== void 0 && input.score <= held.score) return;
|
|
8938
|
+
let crop;
|
|
8939
|
+
if (input.frameHandle !== void 0) try {
|
|
8940
|
+
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
8941
|
+
} catch (err) {
|
|
8942
|
+
this.deps.logger.debug("PlateRecognizer crop capture failed", {
|
|
8943
|
+
tags: { deviceId: input.deviceId },
|
|
8944
|
+
meta: {
|
|
8945
|
+
trackId: input.trackId,
|
|
8946
|
+
error: String(err)
|
|
8947
|
+
}
|
|
8027
8948
|
});
|
|
8028
8949
|
}
|
|
8950
|
+
this.bestPlate.set(input.trackId, {
|
|
8951
|
+
text: input.text,
|
|
8952
|
+
score: input.score,
|
|
8953
|
+
bbox: input.bbox,
|
|
8954
|
+
timestamp: input.timestamp,
|
|
8955
|
+
...crop !== void 0 ? { crop } : {}
|
|
8956
|
+
});
|
|
8029
8957
|
}
|
|
8030
8958
|
/** Persist the held best plate for a finished track as one PlateStore row
|
|
8031
8959
|
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
|
|
@@ -8436,18 +9364,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
|
8436
9364
|
* before re-reading. */
|
|
8437
9365
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
8438
9366
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
9367
|
+
/** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
|
|
9368
|
+
* The `device.state-changed` push doesn't reliably reach a forked child, so
|
|
9369
|
+
* each cached proxy re-pulls both slices on this timer (see ensureProxy) —
|
|
9370
|
+
* a zone drawn in the editor shows up in per-zone stats within one tick. */
|
|
9371
|
+
var ZONE_SLICE_RECONCILE_MS = 3e4;
|
|
8439
9372
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
8440
9373
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
8441
9374
|
* so jitter around a plateau doesn't churn the write). */
|
|
8442
9375
|
var BEST_FRAME_HYSTERESIS = .05;
|
|
8443
9376
|
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
8444
9377
|
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
9378
|
/** getKeyEvents: max completed tracks pulled from a window before importance
|
|
8452
9379
|
* ranking. Ordering is by importance (not firstSeen) and legacy rows score on
|
|
8453
9380
|
* read, so we over-fetch candidates and trim to `limit` after sorting. */
|
|
@@ -8520,6 +9447,10 @@ function stripGlobalOnlyFields(sections) {
|
|
|
8520
9447
|
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
8521
9448
|
processors = /* @__PURE__ */ new Map();
|
|
8522
9449
|
trackStore = null;
|
|
9450
|
+
/** Parked-object registry: promotes a track that stopped moving into a
|
|
9451
|
+
* lightweight entry, suppresses its detections from re-spawning tracks, and
|
|
9452
|
+
* wakes it when the object departs. Null until onInitialize. */
|
|
9453
|
+
stationaryRegistry = null;
|
|
8523
9454
|
mediaStore = null;
|
|
8524
9455
|
eventStore = null;
|
|
8525
9456
|
identityStore = null;
|
|
@@ -8545,9 +9476,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8545
9476
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
8546
9477
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
8547
9478
|
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
9479
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
8552
9480
|
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8553
9481
|
* detection it resolves the frame, crops the ROI, and calls the
|
|
@@ -8584,6 +9512,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8584
9512
|
* dataPlane facility in the current environment). */
|
|
8585
9513
|
eventMediaBaseUrl = null;
|
|
8586
9514
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
9515
|
+
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
8587
9516
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
8588
9517
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
8589
9518
|
levelStateByDevice = /* @__PURE__ */ new Map();
|
|
@@ -8615,10 +9544,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8615
9544
|
* cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
|
|
8616
9545
|
objectEmbeddingBestSelector = new TrackBestSelector();
|
|
8617
9546
|
/** Design B: the track's shared native key-frame media key, captured at the
|
|
8618
|
-
* best-detection moment (
|
|
8619
|
-
*
|
|
8620
|
-
* track end. */
|
|
9547
|
+
* best-detection moment (general best-frame path). Read by the face / plate /
|
|
9548
|
+
* object-embedding rows so they LINK the SAME single native key frame.
|
|
9549
|
+
* Cleared on track end. */
|
|
8621
9550
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
9551
|
+
/** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
|
|
9552
|
+
* rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
|
|
9553
|
+
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
9554
|
+
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
9555
|
+
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
8622
9556
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8623
9557
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8624
9558
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -8669,6 +9603,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8669
9603
|
await PlateStore.declare(api.settingsStore);
|
|
8670
9604
|
await VehicleStore.declare(api.settingsStore);
|
|
8671
9605
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
9606
|
+
await StationaryObjectRegistry.declare(api.settingsStore);
|
|
8672
9607
|
const logger = this.ctx.logger;
|
|
8673
9608
|
let storage = this.ctx.kernel.storage;
|
|
8674
9609
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
@@ -8682,6 +9617,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8682
9617
|
store: api.settingsStore,
|
|
8683
9618
|
logger: logger.child("TrackStore")
|
|
8684
9619
|
});
|
|
9620
|
+
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9621
|
+
store: api.settingsStore,
|
|
9622
|
+
logger: logger.child("StationaryRegistry"),
|
|
9623
|
+
onChange: ({ phase, entry, timestamp }) => {
|
|
9624
|
+
this.ctx.eventBus.emit({
|
|
9625
|
+
id: `pa-stationary-${entry.id}-${phase}`,
|
|
9626
|
+
timestamp: new Date(timestamp),
|
|
9627
|
+
source: {
|
|
9628
|
+
type: "addon",
|
|
9629
|
+
id: "pipeline-analytics",
|
|
9630
|
+
addonId: "pipeline-analytics"
|
|
9631
|
+
},
|
|
9632
|
+
category: EventCategory.PipelineAnalyticsStationaryChanged,
|
|
9633
|
+
data: {
|
|
9634
|
+
deviceId: entry.deviceId,
|
|
9635
|
+
entryId: entry.id,
|
|
9636
|
+
className: entry.className,
|
|
9637
|
+
phase,
|
|
9638
|
+
timestamp
|
|
9639
|
+
}
|
|
9640
|
+
});
|
|
9641
|
+
}
|
|
9642
|
+
});
|
|
9643
|
+
await this.stationaryRegistry.load();
|
|
8685
9644
|
this.mediaStore = new MediaStore({
|
|
8686
9645
|
storage,
|
|
8687
9646
|
store: api.settingsStore,
|
|
@@ -8718,33 +9677,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8718
9677
|
designatedNode: designated
|
|
8719
9678
|
} });
|
|
8720
9679
|
}
|
|
8721
|
-
|
|
8722
|
-
const decoderApi = api.decoder;
|
|
9680
|
+
const pipelineRunnerApi = api.pipelineRunner;
|
|
8723
9681
|
const getRemoteFrame = async (handle) => {
|
|
8724
|
-
if (!
|
|
8725
|
-
const
|
|
9682
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
9683
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
8726
9684
|
handle,
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
9685
|
+
bbox: {
|
|
9686
|
+
x: 0,
|
|
9687
|
+
y: 0,
|
|
9688
|
+
w: 1,
|
|
9689
|
+
h: 1
|
|
9690
|
+
},
|
|
9691
|
+
maxWidth: handle.width
|
|
9692
|
+
}, nodePin(handle.nodeId));
|
|
9693
|
+
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
8730
9694
|
return {
|
|
8731
|
-
data: Buffer.from(
|
|
8732
|
-
width:
|
|
8733
|
-
height:
|
|
8734
|
-
format:
|
|
8735
|
-
timestamp:
|
|
9695
|
+
data: Buffer.from(full.bytes),
|
|
9696
|
+
width: full.width,
|
|
9697
|
+
height: full.height,
|
|
9698
|
+
format: "rgb",
|
|
9699
|
+
timestamp: 0
|
|
8736
9700
|
};
|
|
8737
9701
|
};
|
|
8738
9702
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
8739
|
-
ownNodeId,
|
|
8740
|
-
readers: this.frameReaders,
|
|
8741
9703
|
getRemoteFrame,
|
|
8742
9704
|
mediaStore: this.mediaStore,
|
|
8743
9705
|
logger: logger.child("EventMediaDispatcher")
|
|
8744
9706
|
});
|
|
8745
|
-
const ownNodeIdForFaces = ownNodeId;
|
|
8746
|
-
const frameReadersForFaces = this.frameReaders;
|
|
8747
|
-
const pipelineRunnerApi = api.pipelineRunner;
|
|
8748
9707
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
8749
9708
|
let nativeHits = 0;
|
|
8750
9709
|
let nativeFallbacks = 0;
|
|
@@ -8776,11 +9735,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8776
9735
|
return null;
|
|
8777
9736
|
}
|
|
8778
9737
|
};
|
|
8779
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
8780
|
-
ownNodeId: ownNodeIdForFaces,
|
|
8781
|
-
readers: frameReadersForFaces,
|
|
8782
|
-
getRemoteFrame
|
|
8783
|
-
}));
|
|
9738
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
8784
9739
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
8785
9740
|
const paddedNorm = padBbox({
|
|
8786
9741
|
x: bbox.x / frameWidth,
|
|
@@ -8936,8 +9891,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8936
9891
|
encoder: encoderClient,
|
|
8937
9892
|
eventBus: this.ctx.eventBus,
|
|
8938
9893
|
logger: logger.child("EmbeddingDispatcher"),
|
|
8939
|
-
ownNodeId,
|
|
8940
|
-
readers: frameReadersForFaces,
|
|
8941
9894
|
getRemoteFrame
|
|
8942
9895
|
});
|
|
8943
9896
|
await this.embeddingDispatcher.start();
|
|
@@ -8948,6 +9901,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8948
9901
|
this.bindingCache?.onBindingsChanged(data);
|
|
8949
9902
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
8950
9903
|
this.trackStore?.clearDevice(data.deviceId);
|
|
9904
|
+
this.stationaryRegistry?.forgetDevice(data.deviceId);
|
|
8951
9905
|
this.overlayState.clearDevice(data.deviceId);
|
|
8952
9906
|
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
8953
9907
|
this.forgetDeviceProcessors(data.deviceId);
|
|
@@ -8962,6 +9916,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8962
9916
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
8963
9917
|
const { deviceId } = ev.data;
|
|
8964
9918
|
this.trackStore?.clearDevice(deviceId);
|
|
9919
|
+
this.stationaryRegistry?.forgetDevice(deviceId);
|
|
8965
9920
|
this.overlayState.clearDevice(deviceId);
|
|
8966
9921
|
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
8967
9922
|
this.forgetDeviceProcessors(deviceId);
|
|
@@ -8982,6 +9937,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8982
9937
|
this.retentionSweepTimer = setInterval(() => {
|
|
8983
9938
|
this.sweepRetention();
|
|
8984
9939
|
this.runTrackRetentionSweep();
|
|
9940
|
+
this.stationaryRegistry?.sweep(Date.now());
|
|
8985
9941
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
8986
9942
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
8987
9943
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -9302,8 +10258,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9302
10258
|
this.overlaySynthesisWarnAt.clear();
|
|
9303
10259
|
this.processors.clear();
|
|
9304
10260
|
this.lastActiveTrackIds.clear();
|
|
10261
|
+
this.lastFrameDimsByDevice.clear();
|
|
9305
10262
|
this.dropoutSkipsByKey.clear();
|
|
9306
10263
|
this.bestFrameTracker.clear();
|
|
10264
|
+
this.lastFrameAtByTrack.clear();
|
|
9307
10265
|
this.trackLifecycleUpdateMem.clear();
|
|
9308
10266
|
this.objectEmbeddingBestSelector.clear();
|
|
9309
10267
|
this.levelStateByDevice.clear();
|
|
@@ -9314,13 +10272,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9314
10272
|
this.faceGlobalEnabledCache = null;
|
|
9315
10273
|
this.mediaCacheByDevice.clear();
|
|
9316
10274
|
this.trackStore?.clearAll();
|
|
10275
|
+
this.stationaryRegistry = null;
|
|
9317
10276
|
this.bindingCache?.clearAll();
|
|
9318
10277
|
await this.eventMediaDataPlane?.dispose();
|
|
9319
10278
|
this.eventMediaDataPlane = null;
|
|
9320
10279
|
this.eventMediaBaseUrl = null;
|
|
9321
10280
|
this.eventMediaDispatcher = null;
|
|
9322
|
-
this.frameReaders?.close();
|
|
9323
|
-
this.frameReaders = null;
|
|
9324
10281
|
}
|
|
9325
10282
|
async handleInferenceResult(data) {
|
|
9326
10283
|
if (this.shuttingDown) return;
|
|
@@ -9368,22 +10325,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9368
10325
|
timestamp: frame.timestamp,
|
|
9369
10326
|
frame
|
|
9370
10327
|
});
|
|
10328
|
+
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
10329
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
10330
|
+
deviceId,
|
|
10331
|
+
confirmed: result.stationaryConfirmed,
|
|
10332
|
+
wokenEntryIds: result.stationaryWoken,
|
|
10333
|
+
timestamp: result.timestamp
|
|
10334
|
+
});
|
|
10335
|
+
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
10336
|
+
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10337
|
+
trackId: `stationary:${v.id}`,
|
|
10338
|
+
className: v.className,
|
|
10339
|
+
zones: []
|
|
10340
|
+
}));
|
|
9371
10341
|
this.zoneAnalytics?.recordFrame({
|
|
9372
10342
|
deviceId,
|
|
9373
10343
|
timestamp: result.timestamp,
|
|
9374
10344
|
frameWidth: result.frameWidth,
|
|
9375
10345
|
frameHeight: result.frameHeight,
|
|
9376
|
-
tracked: result.tracked,
|
|
9377
|
-
zones: liveZones
|
|
10346
|
+
tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
|
|
10347
|
+
zones: liveZones,
|
|
10348
|
+
...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
|
|
10349
|
+
});
|
|
10350
|
+
if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
|
|
10351
|
+
w: result.frameWidth,
|
|
10352
|
+
h: result.frameHeight
|
|
9378
10353
|
});
|
|
9379
10354
|
const currentTrackIds = /* @__PURE__ */ new Set();
|
|
10355
|
+
const positionsCountById = /* @__PURE__ */ new Map();
|
|
9380
10356
|
for (const t of result.tracked) {
|
|
9381
10357
|
currentTrackIds.add(t.trackId);
|
|
9382
10358
|
const center = {
|
|
9383
10359
|
x: t.bbox.x + t.bbox.w / 2,
|
|
9384
10360
|
y: t.bbox.y + t.bbox.h / 2
|
|
9385
10361
|
};
|
|
9386
|
-
this.trackStore.upsert({
|
|
10362
|
+
const upserted = this.trackStore.upsert({
|
|
9387
10363
|
trackId: t.trackId,
|
|
9388
10364
|
deviceId,
|
|
9389
10365
|
className: t.className,
|
|
@@ -9398,6 +10374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9398
10374
|
zones: t.zones,
|
|
9399
10375
|
state: t.state
|
|
9400
10376
|
});
|
|
10377
|
+
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
9401
10378
|
}
|
|
9402
10379
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
9403
10380
|
const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -9406,6 +10383,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9406
10383
|
for (const id of currentTrackIds) if (!prevIds.has(id)) {
|
|
9407
10384
|
const t = result.tracked.find((x) => x.trackId === id);
|
|
9408
10385
|
if (t) {
|
|
10386
|
+
if (classifyTrackAppearance({
|
|
10387
|
+
inPrevActive: false,
|
|
10388
|
+
positionsCount: positionsCountById.get(id) ?? 1
|
|
10389
|
+
}) === "resurrection") {
|
|
10390
|
+
log.info("track resumed", { meta: {
|
|
10391
|
+
trackId: id,
|
|
10392
|
+
className: t.className,
|
|
10393
|
+
source,
|
|
10394
|
+
resurrected: true
|
|
10395
|
+
} });
|
|
10396
|
+
continue;
|
|
10397
|
+
}
|
|
9409
10398
|
newTrackCount += 1;
|
|
9410
10399
|
log.info("track started", { meta: {
|
|
9411
10400
|
trackId: id,
|
|
@@ -9419,7 +10408,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9419
10408
|
bbox: { ...t.bbox },
|
|
9420
10409
|
...t.label ? { label: t.label } : {}
|
|
9421
10410
|
});
|
|
9422
|
-
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
10411
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
|
|
9423
10412
|
}
|
|
9424
10413
|
this.ctx.eventBus.emit({
|
|
9425
10414
|
id: `pa-${randomUUID()}`,
|
|
@@ -9465,6 +10454,34 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9465
10454
|
} });
|
|
9466
10455
|
}
|
|
9467
10456
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
10457
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
10458
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
10459
|
+
if (dims && dims.w > 0 && dims.h > 0) {
|
|
10460
|
+
const refDiag = Math.hypot(dims.w, dims.h);
|
|
10461
|
+
for (const t of result.tracked) {
|
|
10462
|
+
const active = this.trackStore.peekActive(t.trackId);
|
|
10463
|
+
if (!active) continue;
|
|
10464
|
+
const { promote } = evaluateStationaryPromotion({
|
|
10465
|
+
positions: active.positions,
|
|
10466
|
+
referenceDiagonalPx: refDiag,
|
|
10467
|
+
now: result.timestamp,
|
|
10468
|
+
config: DEFAULT_PROMOTION_CONFIG
|
|
10469
|
+
});
|
|
10470
|
+
if (!promote) continue;
|
|
10471
|
+
this.promoteToStationary({
|
|
10472
|
+
deviceId,
|
|
10473
|
+
key,
|
|
10474
|
+
processor,
|
|
10475
|
+
track: t,
|
|
10476
|
+
firstSeen: active.firstSeen,
|
|
10477
|
+
label: active.label,
|
|
10478
|
+
frameWidth: result.frameWidth,
|
|
10479
|
+
frameHeight: result.frameHeight,
|
|
10480
|
+
timestamp: result.timestamp
|
|
10481
|
+
});
|
|
10482
|
+
}
|
|
10483
|
+
}
|
|
10484
|
+
}
|
|
9468
10485
|
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9469
10486
|
const dispatcher = this.detailDispatcher;
|
|
9470
10487
|
const steps = detailSteps;
|
|
@@ -9533,7 +10550,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9533
10550
|
let plateCrops = 0;
|
|
9534
10551
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
9535
10552
|
else plateCrops += 1;
|
|
9536
|
-
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
10553
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
10554
|
+
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
10555
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
|
|
9537
10556
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
9538
10557
|
const captureCounts = {
|
|
9539
10558
|
events: eventTargets.length,
|
|
@@ -9748,14 +10767,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9748
10767
|
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
9749
10768
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
9750
10769
|
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
|
-
|
|
10770
|
+
if (d.className === "plate" && d.bbox !== void 0) {
|
|
10771
|
+
this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
10772
|
+
x: d.bbox.x,
|
|
10773
|
+
y: d.bbox.y,
|
|
10774
|
+
w: d.bbox.w,
|
|
10775
|
+
h: d.bbox.h
|
|
10776
|
+
}, d.score, d.label, frame.timestamp);
|
|
10777
|
+
if (this.plateRecognizer) {
|
|
10778
|
+
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
10779
|
+
await this.plateRecognizer.observePlateRead({
|
|
10780
|
+
deviceId,
|
|
10781
|
+
trackId,
|
|
10782
|
+
text: d.label,
|
|
10783
|
+
score: d.score,
|
|
10784
|
+
bbox: {
|
|
10785
|
+
x: d.bbox.x,
|
|
10786
|
+
y: d.bbox.y,
|
|
10787
|
+
w: d.bbox.w,
|
|
10788
|
+
h: d.bbox.h
|
|
10789
|
+
},
|
|
10790
|
+
timestamp: frame.timestamp,
|
|
10791
|
+
frameWidth: frame.frameWidth,
|
|
10792
|
+
frameHeight: frame.frameHeight,
|
|
10793
|
+
cropPadding: mediaSettings.cropPadding,
|
|
10794
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
10795
|
+
});
|
|
10796
|
+
}
|
|
10797
|
+
}
|
|
10798
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
|
|
10799
|
+
if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9759
10800
|
}
|
|
9760
10801
|
} catch (err) {
|
|
9761
10802
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -9902,55 +10943,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9902
10943
|
await Promise.all(bests.map(async (t) => {
|
|
9903
10944
|
if (!isClipObjectEmbedding(t)) return;
|
|
9904
10945
|
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);
|
|
10946
|
+
if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
|
|
10947
|
+
const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
|
|
10948
|
+
if (crop) mediaKey = await this.mediaStore.putReplacing({
|
|
10949
|
+
deviceId,
|
|
10950
|
+
ownerKind: "track",
|
|
10951
|
+
ownerId: t.trackId,
|
|
10952
|
+
kind: "crop",
|
|
10953
|
+
timestamp,
|
|
10954
|
+
data: crop
|
|
10955
|
+
});
|
|
10956
|
+
} catch (err) {
|
|
10957
|
+
this.ctx.logger.debug("object-embedding crop capture failed", {
|
|
10958
|
+
tags: { deviceId },
|
|
10959
|
+
meta: {
|
|
10960
|
+
trackId: t.trackId,
|
|
10961
|
+
error: errMsg(err)
|
|
9943
10962
|
}
|
|
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
|
-
}
|
|
10963
|
+
});
|
|
9953
10964
|
}
|
|
10965
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
9954
10966
|
await store.upsertIfBetter({
|
|
9955
10967
|
trackId: t.trackId,
|
|
9956
10968
|
deviceId,
|
|
@@ -9964,6 +10976,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9964
10976
|
});
|
|
9965
10977
|
}));
|
|
9966
10978
|
}
|
|
10979
|
+
/**
|
|
10980
|
+
* Capture ONE native-resolution KEY FRAME per given track at this best-
|
|
10981
|
+
* detection frame and store it (`putReplacing` → one keyFrame per track).
|
|
10982
|
+
*
|
|
10983
|
+
* The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
|
|
10984
|
+
* FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
|
|
10985
|
+
* `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
|
|
10986
|
+
* and only falls back to the ≤640 detection frame when the native lease is
|
|
10987
|
+
* gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
|
|
10988
|
+
* plate / object-embedding rows LINK the SAME native key frame (Design B).
|
|
10989
|
+
* Issued in the live-frame window so the native lease is still held. Best-
|
|
10990
|
+
* effort (D8) — a per-track failure is logged and never thrown.
|
|
10991
|
+
*/
|
|
10992
|
+
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
|
|
10993
|
+
const capture = this.captureCrop;
|
|
10994
|
+
const mediaStore = this.mediaStore;
|
|
10995
|
+
if (!capture || !mediaStore) return;
|
|
10996
|
+
const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
|
|
10997
|
+
await Promise.all(trackIds.map(async (trackId) => {
|
|
10998
|
+
try {
|
|
10999
|
+
const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
|
|
11000
|
+
if (!keyFrame) return;
|
|
11001
|
+
const key = await mediaStore.putReplacing({
|
|
11002
|
+
deviceId,
|
|
11003
|
+
ownerKind: "track",
|
|
11004
|
+
ownerId: trackId,
|
|
11005
|
+
kind: "keyFrame",
|
|
11006
|
+
timestamp,
|
|
11007
|
+
data: keyFrame
|
|
11008
|
+
});
|
|
11009
|
+
this.keyFrameKeyByTrackId.set(trackId, key);
|
|
11010
|
+
} catch (err) {
|
|
11011
|
+
this.ctx.logger.debug("key-frame capture failed", {
|
|
11012
|
+
tags: { deviceId },
|
|
11013
|
+
meta: {
|
|
11014
|
+
trackId,
|
|
11015
|
+
error: errMsg(err)
|
|
11016
|
+
}
|
|
11017
|
+
});
|
|
11018
|
+
}
|
|
11019
|
+
}));
|
|
11020
|
+
}
|
|
9967
11021
|
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9968
11022
|
emitTrackLifecycle(payload, timestamp) {
|
|
9969
11023
|
this.ctx.eventBus.emit({
|
|
@@ -10013,27 +11067,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10013
11067
|
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10014
11068
|
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10015
11069
|
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
11070
|
+
...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
|
|
10016
11071
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10017
11072
|
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10018
11073
|
});
|
|
10019
11074
|
this.emitTrackLifecycle(payload, timestamp);
|
|
10020
11075
|
}
|
|
10021
|
-
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
11076
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
|
|
10022
11077
|
const targets = [];
|
|
10023
11078
|
for (const t of tracked) {
|
|
10024
11079
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
10025
|
-
const dueSnapshot = media.saveThumbnails &&
|
|
11080
|
+
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
11081
|
+
lastSnapshotAt: lastSnap,
|
|
11082
|
+
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
11083
|
+
currentBbox: t.bbox,
|
|
11084
|
+
now: timestamp,
|
|
11085
|
+
frameWidth,
|
|
11086
|
+
frameHeight,
|
|
11087
|
+
intervalMs: media.snapshotIntervalMs,
|
|
11088
|
+
movementThreshold: media.snapshotMovementThreshold,
|
|
11089
|
+
maxIdleMs: media.snapshotMaxIdleMs
|
|
11090
|
+
}).capture;
|
|
10026
11091
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10027
11092
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
10028
|
-
|
|
11093
|
+
const plan = planPeriodicMedia({
|
|
11094
|
+
saveThumbnails: media.saveThumbnails,
|
|
11095
|
+
dueSnapshot,
|
|
11096
|
+
isNewBest,
|
|
11097
|
+
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
11098
|
+
now: timestamp,
|
|
11099
|
+
intervalMs: media.snapshotIntervalMs
|
|
11100
|
+
});
|
|
11101
|
+
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
11102
|
+
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
11103
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
|
|
10029
11104
|
targets.push({
|
|
10030
11105
|
trackId: t.trackId,
|
|
10031
11106
|
timestamp,
|
|
10032
11107
|
bbox: { ...t.bbox },
|
|
10033
11108
|
...t.label ? { label: t.label } : {},
|
|
10034
|
-
appendSnapshot:
|
|
10035
|
-
rollingLastFrame:
|
|
10036
|
-
bestThumbnail:
|
|
11109
|
+
appendSnapshot: plan.appendSnapshot,
|
|
11110
|
+
rollingLastFrame: plan.rollingLastFrame,
|
|
11111
|
+
bestThumbnail: plan.bestThumbnail
|
|
10037
11112
|
});
|
|
10038
11113
|
}
|
|
10039
11114
|
return targets;
|
|
@@ -10089,6 +11164,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10089
11164
|
atMs: timestamp
|
|
10090
11165
|
});
|
|
10091
11166
|
await this.eventStore.insertAudio(ev);
|
|
11167
|
+
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
10092
11168
|
this.ctx.eventBus.emit({
|
|
10093
11169
|
id: `pa-${ev.id}`,
|
|
10094
11170
|
timestamp: new Date(ev.timestamp),
|
|
@@ -10306,6 +11382,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10306
11382
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
10307
11383
|
if (peak) {
|
|
10308
11384
|
endBestEventId = peak.bestEventId;
|
|
11385
|
+
const dims = this.lastFrameDimsByDevice.get(t.deviceId);
|
|
11386
|
+
const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
11387
|
+
x: p.x,
|
|
11388
|
+
y: p.y
|
|
11389
|
+
})), Math.hypot(dims.w, dims.h)) : void 0;
|
|
10309
11390
|
const { importance, reason } = computeImportance({
|
|
10310
11391
|
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
10311
11392
|
className: t.className,
|
|
@@ -10313,7 +11394,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10313
11394
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
10314
11395
|
totalDistance: t.totalDistance,
|
|
10315
11396
|
zonesVisited: t.zonesVisited,
|
|
10316
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
11397
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
11398
|
+
...staticMetrics ? {
|
|
11399
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
11400
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
11401
|
+
} : {}
|
|
10317
11402
|
});
|
|
10318
11403
|
endImportance = importance;
|
|
10319
11404
|
endImportanceReason = reason;
|
|
@@ -10328,6 +11413,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10328
11413
|
}
|
|
10329
11414
|
this.bestFrameTracker.delete(t.trackId);
|
|
10330
11415
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
11416
|
+
this.lastFrameAtByTrack.delete(t.trackId);
|
|
10331
11417
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10332
11418
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10333
11419
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -10372,6 +11458,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10372
11458
|
positionsCount: t.positions.length,
|
|
10373
11459
|
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10374
11460
|
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
11461
|
+
...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
|
|
10375
11462
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10376
11463
|
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10377
11464
|
});
|
|
@@ -10534,6 +11621,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10534
11621
|
for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
|
|
10535
11622
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
10536
11623
|
for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
|
|
11624
|
+
this.lastFrameDimsByDevice.delete(deviceId);
|
|
10537
11625
|
}
|
|
10538
11626
|
/** Apply a mutation to every live source-processor of a device (zones/rules
|
|
10539
11627
|
* are device-level and must reach all sources). */
|
|
@@ -10541,6 +11629,57 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10541
11629
|
const prefix = `${deviceId}:`;
|
|
10542
11630
|
for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
|
|
10543
11631
|
}
|
|
11632
|
+
/**
|
|
11633
|
+
* Turn a parked track into a stationary-registry entry and tear the track
|
|
11634
|
+
* down WITHOUT firing an 'end' key-event / importance scoring / media flush
|
|
11635
|
+
* (a parked object is not a highlight; the durable record is the entry). The
|
|
11636
|
+
* tracker + store forget the track so its detections stop re-spawning tracks,
|
|
11637
|
+
* and the registry suppresses them from the next frame on.
|
|
11638
|
+
*/
|
|
11639
|
+
promoteToStationary(input) {
|
|
11640
|
+
const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
|
|
11641
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
|
|
11642
|
+
const entry = {
|
|
11643
|
+
id: randomUUID(),
|
|
11644
|
+
deviceId,
|
|
11645
|
+
className: track.className,
|
|
11646
|
+
bbox: { ...track.bbox },
|
|
11647
|
+
frameWidth,
|
|
11648
|
+
frameHeight,
|
|
11649
|
+
firstSeenAt: firstSeen,
|
|
11650
|
+
becameStationaryAt: timestamp,
|
|
11651
|
+
lastConfirmedAt: timestamp,
|
|
11652
|
+
sourceTrackId: track.trackId,
|
|
11653
|
+
...label !== void 0 ? { label } : {},
|
|
11654
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
11655
|
+
};
|
|
11656
|
+
this.stationaryRegistry?.promote(entry);
|
|
11657
|
+
processor.dropTrack(track.trackId);
|
|
11658
|
+
this.trackStore?.dropActive(track.trackId);
|
|
11659
|
+
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11660
|
+
const dropKeyFrame = () => {
|
|
11661
|
+
this.keyFrameKeyByTrackId.delete(track.trackId);
|
|
11662
|
+
};
|
|
11663
|
+
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
11664
|
+
else dropKeyFrame();
|
|
11665
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11666
|
+
this.bestFrameTracker.delete(track.trackId);
|
|
11667
|
+
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
11668
|
+
this.lastFrameAtByTrack.delete(track.trackId);
|
|
11669
|
+
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
11670
|
+
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
11671
|
+
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
11672
|
+
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
11673
|
+
this.ctx.logger.info("track promoted to stationary", {
|
|
11674
|
+
tags: { deviceId },
|
|
11675
|
+
meta: {
|
|
11676
|
+
trackId: track.trackId,
|
|
11677
|
+
className: track.className,
|
|
11678
|
+
entryId: entry.id,
|
|
11679
|
+
...label ? { label } : {}
|
|
11680
|
+
}
|
|
11681
|
+
});
|
|
11682
|
+
}
|
|
10544
11683
|
async getOrCreateProcessor(deviceId, source) {
|
|
10545
11684
|
const key = this.procKey(deviceId, source);
|
|
10546
11685
|
let p = this.processors.get(key);
|
|
@@ -10566,6 +11705,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10566
11705
|
cooldownSec,
|
|
10567
11706
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
10568
11707
|
}, source);
|
|
11708
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
11709
|
+
const registry = this.stationaryRegistry;
|
|
11710
|
+
p.setStationaryGate({ filter: (input) => registry.filter({
|
|
11711
|
+
deviceId,
|
|
11712
|
+
detections: input.detections.map((d) => ({
|
|
11713
|
+
bbox: d.bbox,
|
|
11714
|
+
className: d.class
|
|
11715
|
+
})),
|
|
11716
|
+
frameWidth: input.frameWidth,
|
|
11717
|
+
frameHeight: input.frameHeight
|
|
11718
|
+
}) });
|
|
11719
|
+
}
|
|
10569
11720
|
this.processors.set(key, p);
|
|
10570
11721
|
}
|
|
10571
11722
|
return p;
|
|
@@ -10577,6 +11728,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10577
11728
|
* forward updates to the per-device FrameProcessor so a rule change
|
|
10578
11729
|
* applies to the very next frame even when frames stop briefly
|
|
10579
11730
|
* (e.g. during binding flips).
|
|
11731
|
+
*
|
|
11732
|
+
* RECONCILE: the push channel behind `subscribe` (`device.state-changed`
|
|
11733
|
+
* via `live.onEvent`) does not reliably reach a forked addon child — a
|
|
11734
|
+
* zone created AFTER the proxy's cold read stayed invisible until the
|
|
11735
|
+
* addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
|
|
11736
|
+
* populated hub-side, `zones: []` in every snapshot). Events are lossy
|
|
11737
|
+
* telemetry (D8); the durable channel is RPC + reconcile — so each
|
|
11738
|
+
* proxy also refreshes its two slices on a slow timer. `refresh()`
|
|
11739
|
+
* round-trips `deviceState.getCapSlice` and fans out through the SAME
|
|
11740
|
+
* subscribe callbacks above, so a zone edit lands within one interval.
|
|
10580
11741
|
*/
|
|
10581
11742
|
async ensureProxy(deviceId) {
|
|
10582
11743
|
const cached = this.proxies.get(deviceId);
|
|
@@ -10585,13 +11746,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10585
11746
|
const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
|
|
10586
11747
|
if (!proxy) return null;
|
|
10587
11748
|
this.proxies.set(deviceId, proxy);
|
|
10588
|
-
const
|
|
10589
|
-
|
|
10590
|
-
|
|
10591
|
-
}
|
|
10592
|
-
|
|
10593
|
-
|
|
10594
|
-
|
|
11749
|
+
const reconcile = setInterval(() => {
|
|
11750
|
+
proxy.state.zones.refresh().catch(() => void 0);
|
|
11751
|
+
proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
11752
|
+
}, ZONE_SLICE_RECONCILE_MS);
|
|
11753
|
+
reconcile.unref?.();
|
|
11754
|
+
const unsubs = [
|
|
11755
|
+
proxy.state.zones.subscribe((slice) => {
|
|
11756
|
+
const zones = slice?.zones ?? [];
|
|
11757
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
|
|
11758
|
+
}),
|
|
11759
|
+
proxy.state.zoneRules.subscribe((slice) => {
|
|
11760
|
+
const rules = slice?.detection ?? [];
|
|
11761
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
|
|
11762
|
+
}),
|
|
11763
|
+
() => clearInterval(reconcile)
|
|
11764
|
+
];
|
|
10595
11765
|
this.proxyUnsubs.set(deviceId, unsubs);
|
|
10596
11766
|
return proxy;
|
|
10597
11767
|
} catch (err) {
|
|
@@ -10623,6 +11793,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10623
11793
|
}
|
|
10624
11794
|
async clearTracks(input) {
|
|
10625
11795
|
this.trackStore?.clearDevice(input.deviceId);
|
|
11796
|
+
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10626
11797
|
this.overlayState.clearDevice(input.deviceId);
|
|
10627
11798
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
10628
11799
|
const prefix = `${input.deviceId}:`;
|
|
@@ -10924,6 +12095,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10924
12095
|
* Enrolled gallery + identity media are exempt.
|
|
10925
12096
|
*/
|
|
10926
12097
|
async wipeAllAnalytics(input) {
|
|
12098
|
+
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10927
12099
|
return this.pruneTracksBefore({
|
|
10928
12100
|
deviceId: input.deviceId,
|
|
10929
12101
|
cutoffMs: Date.now()
|
|
@@ -10986,13 +12158,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10986
12158
|
}
|
|
10987
12159
|
return this.readEventThumbnail(id);
|
|
10988
12160
|
}
|
|
10989
|
-
async readEventThumbnail(
|
|
10990
|
-
const
|
|
10991
|
-
const
|
|
10992
|
-
if (
|
|
12161
|
+
async readEventThumbnail(id) {
|
|
12162
|
+
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
12163
|
+
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12164
|
+
if (chosenEvent) return {
|
|
12165
|
+
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
12166
|
+
key: chosenEvent.key
|
|
12167
|
+
};
|
|
12168
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12169
|
+
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];
|
|
12170
|
+
if (!chosenTrack) return null;
|
|
10993
12171
|
return {
|
|
10994
|
-
bytes: Buffer.from(
|
|
10995
|
-
key:
|
|
12172
|
+
bytes: Buffer.from(chosenTrack.base64, "base64"),
|
|
12173
|
+
key: chosenTrack.key
|
|
10996
12174
|
};
|
|
10997
12175
|
}
|
|
10998
12176
|
/**
|
|
@@ -11085,6 +12263,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11085
12263
|
unit: "s",
|
|
11086
12264
|
displayScale: 1e3
|
|
11087
12265
|
},
|
|
12266
|
+
{
|
|
12267
|
+
type: "slider",
|
|
12268
|
+
key: "snapshotMovementThreshold",
|
|
12269
|
+
label: "Snapshot movement gate",
|
|
12270
|
+
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.",
|
|
12271
|
+
min: 0,
|
|
12272
|
+
max: .15,
|
|
12273
|
+
step: .005,
|
|
12274
|
+
default: .03,
|
|
12275
|
+
showValue: true,
|
|
12276
|
+
unit: "%",
|
|
12277
|
+
displayScale: .01
|
|
12278
|
+
},
|
|
12279
|
+
{
|
|
12280
|
+
type: "slider",
|
|
12281
|
+
key: "snapshotMaxIdleMs",
|
|
12282
|
+
label: "Snapshot max idle",
|
|
12283
|
+
description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
|
|
12284
|
+
min: 5e3,
|
|
12285
|
+
max: 12e4,
|
|
12286
|
+
step: 5e3,
|
|
12287
|
+
default: 3e4,
|
|
12288
|
+
showValue: true,
|
|
12289
|
+
unit: "s",
|
|
12290
|
+
displayScale: 1e3
|
|
12291
|
+
},
|
|
11088
12292
|
{
|
|
11089
12293
|
type: "select",
|
|
11090
12294
|
key: "mediaAttachPolicy",
|