@camstack/addon-post-analysis 1.1.30 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-yPsKFcJL.mjs → dist-CyyCe4TK.mjs} +272 -10
- package/dist/{dist-U51kCBdm.js → dist-DU_JRm-j.js} +277 -9
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-BFF5_uIc.js → node-K1C_KC3d.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DeoVBjEB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-aNR1K97R.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D6C5R_2c.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-Bd2d1PYo.mjs → hostInit-B3eluJsX.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1222 -112
- package/dist/pipeline-analytics/index.mjs +1221 -111
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DBgBsiUM.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as EventCategory, S as string, _ as createEvent, a as cosineSimilarity, b as number, d as plateGalleryCapability, f as videoclipsCapability, g as DeviceType, h as BaseAddon, i as audioMetricsCapability, l as nodePin, m as errMsg, n as EVENT_PAD_MS, p as zoneAnalyticsCapability, r as addonWidgetsSourceCapability, s as faceGalleryCapability, t as EVENT_KIND_BY_CAP, u as pipelineAnalyticsCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CyyCe4TK.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
@@ -160,13 +160,13 @@ var CLASS_RANK_VEHICLE = .8;
|
|
|
160
160
|
var CLASS_RANK_ANIMAL = .5;
|
|
161
161
|
var CLASS_RANK_DEFAULT = .25;
|
|
162
162
|
var PERSON_CLASSES = new Set(["person", "face"]);
|
|
163
|
-
var VEHICLE_CLASSES = new Set([
|
|
163
|
+
var VEHICLE_CLASSES$1 = new Set([
|
|
164
164
|
"vehicle",
|
|
165
165
|
"car",
|
|
166
166
|
"truck",
|
|
167
167
|
"bus"
|
|
168
168
|
]);
|
|
169
|
-
var ANIMAL_CLASSES = new Set([
|
|
169
|
+
var ANIMAL_CLASSES$1 = new Set([
|
|
170
170
|
"animal",
|
|
171
171
|
"dog",
|
|
172
172
|
"cat"
|
|
@@ -182,8 +182,8 @@ function clamp01(x) {
|
|
|
182
182
|
function classRank(className) {
|
|
183
183
|
const c = className.toLowerCase();
|
|
184
184
|
if (PERSON_CLASSES.has(c)) return 1;
|
|
185
|
-
if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
|
|
186
|
-
if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
|
|
185
|
+
if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
|
|
186
|
+
if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
|
|
187
187
|
return CLASS_RANK_DEFAULT;
|
|
188
188
|
}
|
|
189
189
|
/**
|
|
@@ -1081,7 +1081,7 @@ function resolveDetectionLabel(input) {
|
|
|
1081
1081
|
//#endregion
|
|
1082
1082
|
//#region src/pipeline-analytics/pipeline/zones/geometry.ts
|
|
1083
1083
|
/** Ray-casting point-in-polygon test */
|
|
1084
|
-
function pointInPolygon(point, polygon) {
|
|
1084
|
+
function pointInPolygon$1(point, polygon) {
|
|
1085
1085
|
if (polygon.length < 3) return false;
|
|
1086
1086
|
let inside = false;
|
|
1087
1087
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
@@ -1472,6 +1472,7 @@ var DEFAULT_EVENT_EMITTER_CONFIG = {
|
|
|
1472
1472
|
minTrackAge: 3,
|
|
1473
1473
|
minTrackAgeMs: 0,
|
|
1474
1474
|
cooldownSec: 5,
|
|
1475
|
+
emitAppearance: true,
|
|
1475
1476
|
enabledTypes: [
|
|
1476
1477
|
"object.entering",
|
|
1477
1478
|
"object.leaving",
|
|
@@ -1496,6 +1497,14 @@ var ZONE_TYPE_TO_EVENT = {
|
|
|
1496
1497
|
"zone-loiter": "zone.enter",
|
|
1497
1498
|
"tripwire-cross": "tripwire.cross"
|
|
1498
1499
|
};
|
|
1500
|
+
/**
|
|
1501
|
+
* DetectionEventType used for the synthetic per-track APPEARANCE event
|
|
1502
|
+
* (Requirement 1). `object.detected` is otherwise unused by the emitter (it maps
|
|
1503
|
+
* from no state and no zone type), so repurposing it as the appearance marker
|
|
1504
|
+
* needs no new event-type vocabulary. The addon routes these into their own
|
|
1505
|
+
* media/persistence path and keeps them OFF the notification bus.
|
|
1506
|
+
*/
|
|
1507
|
+
var APPEARANCE_EVENT_TYPE = "object.detected";
|
|
1499
1508
|
var eventIdCounter = 0;
|
|
1500
1509
|
var DetectionEventEmitter = class {
|
|
1501
1510
|
config;
|
|
@@ -1508,6 +1517,13 @@ var DetectionEventEmitter = class {
|
|
|
1508
1517
|
* fired: the latent "0 `left` events in 24h" bug).
|
|
1509
1518
|
*/
|
|
1510
1519
|
lastKnownTracks = /* @__PURE__ */ new Map();
|
|
1520
|
+
/**
|
|
1521
|
+
* Track ids that have already been VALUED — either by a crop-bearing
|
|
1522
|
+
* transition/zone event or by a synthetic appearance event. Ensures a track
|
|
1523
|
+
* gets at most ONE appearance and never a redundant appearance on top of a
|
|
1524
|
+
* real crop-bearing event (Requirement 1).
|
|
1525
|
+
*/
|
|
1526
|
+
valuedTracks = /* @__PURE__ */ new Set();
|
|
1511
1527
|
constructor(config = {}) {
|
|
1512
1528
|
this.config = {
|
|
1513
1529
|
...DEFAULT_EVENT_EMITTER_CONFIG,
|
|
@@ -1533,6 +1549,7 @@ var DetectionEventEmitter = class {
|
|
|
1533
1549
|
if ((now - (this.lastEmitted.get(cooldownKey) ?? 0)) / 1e3 < this.config.cooldownSec) continue;
|
|
1534
1550
|
this.previousStates.set(state.trackId, state.state);
|
|
1535
1551
|
this.lastEmitted.set(cooldownKey, now);
|
|
1552
|
+
if (trackMap.has(state.trackId)) this.valuedTracks.add(state.trackId);
|
|
1536
1553
|
events.push({
|
|
1537
1554
|
id: `evt-${++eventIdCounter}`,
|
|
1538
1555
|
type: eventType,
|
|
@@ -1552,6 +1569,7 @@ var DetectionEventEmitter = class {
|
|
|
1552
1569
|
const track = trackMap.get(ze.trackId);
|
|
1553
1570
|
if (!track || track.trackAge < this.config.minTrackAge) continue;
|
|
1554
1571
|
const state = stateMap.get(ze.trackId);
|
|
1572
|
+
this.valuedTracks.add(ze.trackId);
|
|
1555
1573
|
events.push({
|
|
1556
1574
|
id: `evt-${++eventIdCounter}`,
|
|
1557
1575
|
type: eventType,
|
|
@@ -1570,9 +1588,29 @@ var DetectionEventEmitter = class {
|
|
|
1570
1588
|
trackPath: [...track.path]
|
|
1571
1589
|
});
|
|
1572
1590
|
}
|
|
1591
|
+
if (this.config.emitAppearance) for (const track of tracks) {
|
|
1592
|
+
if (this.valuedTracks.has(track.trackId)) continue;
|
|
1593
|
+
if (track.trackAge < this.config.minTrackAge) continue;
|
|
1594
|
+
const state = stateMap.get(track.trackId);
|
|
1595
|
+
if (!state) continue;
|
|
1596
|
+
if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
|
|
1597
|
+
this.valuedTracks.add(track.trackId);
|
|
1598
|
+
events.push({
|
|
1599
|
+
id: `evt-${++eventIdCounter}`,
|
|
1600
|
+
type: APPEARANCE_EVENT_TYPE,
|
|
1601
|
+
timestamp: now,
|
|
1602
|
+
deviceId,
|
|
1603
|
+
detection: track,
|
|
1604
|
+
classifications,
|
|
1605
|
+
objectState: state,
|
|
1606
|
+
zoneEvents: [],
|
|
1607
|
+
trackPath: [...track.path]
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1573
1610
|
for (const state of states) if (state.state === "leaving") {
|
|
1574
1611
|
this.previousStates.delete(state.trackId);
|
|
1575
1612
|
this.lastKnownTracks.delete(state.trackId);
|
|
1613
|
+
this.valuedTracks.delete(state.trackId);
|
|
1576
1614
|
}
|
|
1577
1615
|
return events;
|
|
1578
1616
|
}
|
|
@@ -1580,6 +1618,7 @@ var DetectionEventEmitter = class {
|
|
|
1580
1618
|
this.previousStates.clear();
|
|
1581
1619
|
this.lastEmitted.clear();
|
|
1582
1620
|
this.lastKnownTracks.clear();
|
|
1621
|
+
this.valuedTracks.clear();
|
|
1583
1622
|
}
|
|
1584
1623
|
};
|
|
1585
1624
|
//#endregion
|
|
@@ -1594,7 +1633,7 @@ function bboxPolygonOverlap(bbox, polygon) {
|
|
|
1594
1633
|
const gridSize = 8;
|
|
1595
1634
|
let inside = 0;
|
|
1596
1635
|
const total = gridSize * gridSize;
|
|
1597
|
-
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon({
|
|
1636
|
+
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon$1({
|
|
1598
1637
|
x: bbox.x + (col + .5) * (bbox.w / gridSize),
|
|
1599
1638
|
y: bbox.y + (row + .5) * (bbox.h / gridSize)
|
|
1600
1639
|
}, polygon)) inside++;
|
|
@@ -1613,7 +1652,7 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
1613
1652
|
for (let my = 0; my < maskHeight; my++) for (let mx = 0; mx < maskWidth; mx++) {
|
|
1614
1653
|
if (mask[my * maskWidth + mx] === 0) continue;
|
|
1615
1654
|
totalMaskPixels++;
|
|
1616
|
-
if (pointInPolygon({
|
|
1655
|
+
if (pointInPolygon$1({
|
|
1617
1656
|
x: bbox.x + mx / maskWidth * bbox.w,
|
|
1618
1657
|
y: bbox.y + my / maskHeight * bbox.h
|
|
1619
1658
|
}, polygon)) insidePolygon++;
|
|
@@ -1942,9 +1981,9 @@ var FrameProcessor = class {
|
|
|
1942
1981
|
} : {}
|
|
1943
1982
|
};
|
|
1944
1983
|
});
|
|
1945
|
-
const
|
|
1984
|
+
const toObjectEvent = (e, forcedState) => {
|
|
1946
1985
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
1947
|
-
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
1986
|
+
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
1948
1987
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
1949
1988
|
const label = td ? resolveDetectionLabel({
|
|
1950
1989
|
className: td.class,
|
|
@@ -1973,7 +2012,10 @@ var FrameProcessor = class {
|
|
|
1973
2012
|
frameWidth,
|
|
1974
2013
|
frameHeight
|
|
1975
2014
|
};
|
|
1976
|
-
}
|
|
2015
|
+
};
|
|
2016
|
+
const withTrackId = rawEvents.filter((e) => e.detection.trackId);
|
|
2017
|
+
const objectEvents = withTrackId.filter((e) => e.type !== "object.detected").map((e) => toObjectEvent(e));
|
|
2018
|
+
const appearanceEvents = withTrackId.filter((e) => e.type === "object.detected").map((e) => toObjectEvent(e, "entered"));
|
|
1977
2019
|
return {
|
|
1978
2020
|
deviceId: this.deviceId,
|
|
1979
2021
|
timestamp,
|
|
@@ -1981,6 +2023,7 @@ var FrameProcessor = class {
|
|
|
1981
2023
|
frameHeight,
|
|
1982
2024
|
tracked,
|
|
1983
2025
|
objectEvents,
|
|
2026
|
+
appearanceEvents,
|
|
1984
2027
|
rawTrackedDetections: trackedDetections,
|
|
1985
2028
|
stationaryConfirmed: gate.confirmed,
|
|
1986
2029
|
stationaryWoken: gate.wokenEntryIds
|
|
@@ -2066,29 +2109,120 @@ function buildTrackLifecyclePayload(input) {
|
|
|
2066
2109
|
...hasMedia ? { media } : {}
|
|
2067
2110
|
};
|
|
2068
2111
|
}
|
|
2112
|
+
/**
|
|
2113
|
+
* True when `bbox` sits fully inside the frame — no side within the tolerance
|
|
2114
|
+
* band of any border. Degenerate/unknown frame dims (≤ 0) return true so the
|
|
2115
|
+
* gate stays neutral (falls back to pure confidence) rather than demoting every
|
|
2116
|
+
* frame of a dims-less source.
|
|
2117
|
+
*/
|
|
2118
|
+
function isEdgeClear(input) {
|
|
2119
|
+
const { bbox, frameWidth, frameHeight } = input;
|
|
2120
|
+
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
2121
|
+
const tolerance = input.tolerance ?? .01;
|
|
2122
|
+
const tolX = tolerance * frameWidth;
|
|
2123
|
+
const tolY = tolerance * frameHeight;
|
|
2124
|
+
const left = bbox.x;
|
|
2125
|
+
const top = bbox.y;
|
|
2126
|
+
const right = bbox.x + bbox.w;
|
|
2127
|
+
const bottom = bbox.y + bbox.h;
|
|
2128
|
+
if (left <= tolX) return false;
|
|
2129
|
+
if (top <= tolY) return false;
|
|
2130
|
+
if (right >= frameWidth - tolX) return false;
|
|
2131
|
+
if (bottom >= frameHeight - tolY) return false;
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Edge-aware "is `candidate` a new best over `current`?" comparator.
|
|
2136
|
+
*
|
|
2137
|
+
* Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
|
|
2138
|
+
* clipped one regardless of confidence). WITHIN the same tier, a strictly-higher
|
|
2139
|
+
* confidence past the `hysteresis` margin wins. The tier upgrade
|
|
2140
|
+
* (touching → clear) bypasses hysteresis — the first clear frame is always taken.
|
|
2141
|
+
*
|
|
2142
|
+
* Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
|
|
2143
|
+
* not here, so this stays a pure value comparison.
|
|
2144
|
+
*/
|
|
2145
|
+
function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
2146
|
+
if (candidate.edgeClear && !current.edgeClear) return true;
|
|
2147
|
+
if (!candidate.edgeClear && current.edgeClear) return false;
|
|
2148
|
+
return candidate.confidence > current.confidence + hysteresis;
|
|
2149
|
+
}
|
|
2069
2150
|
//#endregion
|
|
2070
2151
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
2152
|
+
/**
|
|
2153
|
+
* `BestDetectionTracker` — the SINGLE canonical policy for "the best detection
|
|
2154
|
+
* per track" (highest detector confidence per `trackId`).
|
|
2155
|
+
*
|
|
2156
|
+
* Post-analysis derives three per-track "best" artefacts, all keyed by
|
|
2157
|
+
* trackId + confidence, that used to each re-implement the same ranking inline:
|
|
2158
|
+
* 1. the best FRAME (a boxed `thumbnail`) — `index.ts`,
|
|
2159
|
+
* 2. the best FACE crop + its arcface embedding — `face-recognizer.ts`,
|
|
2160
|
+
* 3. the best CLIP object embedding — `store/object-embedding-store.ts`.
|
|
2161
|
+
*
|
|
2162
|
+
* Consumers 1 & 2 now share THIS one implementation so a track's best frame and
|
|
2163
|
+
* its best face crop derive from the same confidence ranking (and, when the peak
|
|
2164
|
+
* frame carries a detected face, the same frame). Consumer 3 (CLIP) is a
|
|
2165
|
+
* DOCUMENTED SEAM: its `upsertIfBetter` is persistence-backed (its best survives
|
|
2166
|
+
* an addon restart, which this in-memory tracker deliberately does not), so it
|
|
2167
|
+
* keeps its own store-side "if better" gate rather than reading this tracker.
|
|
2168
|
+
*
|
|
2169
|
+
* The FACE seam: the best FRAME is ranked over ALL tracked detections, while the
|
|
2170
|
+
* best FACE can only be captured on a frame that actually produced a face
|
|
2171
|
+
* embedding — so the two legitimately diverge when the peak-confidence frame has
|
|
2172
|
+
* no detected face. Each consumer therefore keeps its OWN payload (crop /
|
|
2173
|
+
* embedding / boxed frame); only the ranking DECISION is unified here.
|
|
2174
|
+
*/
|
|
2071
2175
|
var BestDetectionTracker = class {
|
|
2072
2176
|
hysteresis;
|
|
2073
2177
|
minGapMs;
|
|
2074
2178
|
best = /* @__PURE__ */ new Map();
|
|
2179
|
+
/** Held peak's edge-clear tier, kept PARALLEL to `best` so `peak()`'s
|
|
2180
|
+
* `{ confidence, atMs }` shape (a public contract) stays unchanged. Absent =
|
|
2181
|
+
* the edge tier is not in play for the track (treated as clear → the legacy
|
|
2182
|
+
* pure-confidence policy). */
|
|
2183
|
+
edgeClear = /* @__PURE__ */ new Map();
|
|
2075
2184
|
constructor(options = {}) {
|
|
2076
2185
|
this.hysteresis = options.hysteresis ?? 0;
|
|
2077
2186
|
this.minGapMs = options.minGapMs ?? 0;
|
|
2078
2187
|
}
|
|
2079
2188
|
/**
|
|
2080
2189
|
* Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
|
|
2081
|
-
* Returns true when it becomes the track's new best
|
|
2082
|
-
*
|
|
2083
|
-
* `
|
|
2190
|
+
* Returns true when it becomes the track's new best.
|
|
2191
|
+
*
|
|
2192
|
+
* When `edgeClear` is supplied, an EDGE-AWARE policy applies: an edge-clear
|
|
2193
|
+
* frame (whole subject in view) ALWAYS outranks an edge-touching one (a
|
|
2194
|
+
* clipped, partial subject); the tier upgrade bypasses hysteresis + `minGapMs`
|
|
2195
|
+
* so the first clear frame is always taken. WITHIN the same tier — and when
|
|
2196
|
+
* `edgeClear` is omitted (legacy callers: the face + object-embedding paths) —
|
|
2197
|
+
* the classic policy holds: a confidence past the `hysteresis` margin that also
|
|
2198
|
+
* respects `minGapMs` wins. On acceptance the held peak advances.
|
|
2084
2199
|
*/
|
|
2085
|
-
observe(trackId, confidence, timestamp) {
|
|
2200
|
+
observe(trackId, confidence, timestamp, edgeClear) {
|
|
2086
2201
|
const cur = this.best.get(trackId);
|
|
2087
|
-
|
|
2088
|
-
|
|
2202
|
+
if (cur === void 0) {
|
|
2203
|
+
this.best.set(trackId, {
|
|
2204
|
+
confidence,
|
|
2205
|
+
atMs: timestamp
|
|
2206
|
+
});
|
|
2207
|
+
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2208
|
+
return true;
|
|
2209
|
+
}
|
|
2210
|
+
const curClear = this.edgeClear.get(trackId) ?? true;
|
|
2211
|
+
const candClear = edgeClear ?? true;
|
|
2212
|
+
const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
|
|
2213
|
+
confidence: cur.confidence,
|
|
2214
|
+
edgeClear: curClear
|
|
2215
|
+
}, {
|
|
2089
2216
|
confidence,
|
|
2090
|
-
|
|
2091
|
-
});
|
|
2217
|
+
edgeClear: candClear
|
|
2218
|
+
}, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
|
|
2219
|
+
if (isNewBest) {
|
|
2220
|
+
this.best.set(trackId, {
|
|
2221
|
+
confidence,
|
|
2222
|
+
atMs: timestamp
|
|
2223
|
+
});
|
|
2224
|
+
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2225
|
+
}
|
|
2092
2226
|
return isNewBest;
|
|
2093
2227
|
}
|
|
2094
2228
|
/** The held peak for a track (undefined if never observed). */
|
|
@@ -2098,9 +2232,11 @@ var BestDetectionTracker = class {
|
|
|
2098
2232
|
/** Drop a track's peak (call at track end). */
|
|
2099
2233
|
delete(trackId) {
|
|
2100
2234
|
this.best.delete(trackId);
|
|
2235
|
+
this.edgeClear.delete(trackId);
|
|
2101
2236
|
}
|
|
2102
2237
|
clear() {
|
|
2103
2238
|
this.best.clear();
|
|
2239
|
+
this.edgeClear.clear();
|
|
2104
2240
|
}
|
|
2105
2241
|
};
|
|
2106
2242
|
//#endregion
|
|
@@ -2228,16 +2364,23 @@ function entryToView(e) {
|
|
|
2228
2364
|
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2229
2365
|
};
|
|
2230
2366
|
}
|
|
2231
|
-
/** IoU at/above which a detection is "the same parked object
|
|
2232
|
-
* spawn and refresh
|
|
2367
|
+
/** IoU at/above which a detection is unambiguously "the same parked object,
|
|
2368
|
+
* unmoved" → suppress its spawn and refresh `lastConfirmedAt`. Decision #2. */
|
|
2233
2369
|
var SUPPRESS_IOU = .6;
|
|
2234
|
-
/**
|
|
2235
|
-
*
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
*
|
|
2239
|
-
*
|
|
2240
|
-
|
|
2370
|
+
/**
|
|
2371
|
+
* Minimum IoU for a same-class detection to be ASSOCIATED with an entry — i.e.
|
|
2372
|
+
* to be considered THIS parked object's own (possibly jittered/shifted) box
|
|
2373
|
+
* rather than a DIFFERENT vehicle. Below this floor a detection can neither
|
|
2374
|
+
* confirm nor wake the entry (it is a different object).
|
|
2375
|
+
*
|
|
2376
|
+
* This overlap gate is the fix for the 617 parking-lot flood: the previous
|
|
2377
|
+
* wake test used a centroid-distance radius of half the frame diagonal, so any
|
|
2378
|
+
* other vehicle merely PRESENT in the lot (a car driving through, or a second
|
|
2379
|
+
* car parked ~120px away) satisfied "near + moved" and retired the parked
|
|
2380
|
+
* entry — the parked car then re-spawned a fresh track and re-flooded, over and
|
|
2381
|
+
* over. Requiring real box overlap means only the entry's OWN box can wake it.
|
|
2382
|
+
*/
|
|
2383
|
+
var WAKE_ASSOC_IOU = .1;
|
|
2241
2384
|
/**
|
|
2242
2385
|
* Look-back window over which a track must have stayed put to be PROMOTED. A
|
|
2243
2386
|
* car that drives in then parks has a large whole-life displacement but a tiny
|
|
@@ -2248,8 +2391,7 @@ var WAKE_SEARCH_FRAC = .5;
|
|
|
2248
2391
|
var PROMOTION_WINDOW_MS = 3e4;
|
|
2249
2392
|
var DEFAULT_MATCH_CONFIG = {
|
|
2250
2393
|
suppressIou: SUPPRESS_IOU,
|
|
2251
|
-
|
|
2252
|
-
wakeSearchFrac: WAKE_SEARCH_FRAC
|
|
2394
|
+
wakeAssocIou: WAKE_ASSOC_IOU
|
|
2253
2395
|
};
|
|
2254
2396
|
//#endregion
|
|
2255
2397
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
|
|
@@ -2296,36 +2438,45 @@ function centroid(b) {
|
|
|
2296
2438
|
y: b.y + b.h / 2
|
|
2297
2439
|
};
|
|
2298
2440
|
}
|
|
2299
|
-
|
|
2300
|
-
|
|
2441
|
+
/** Is point `p` inside box `b`? Used to distinguish box JITTER (centre stays
|
|
2442
|
+
* inside the parked box → suppress) from the object DEPARTING (centre slides
|
|
2443
|
+
* out while the box still overlaps → wake). */
|
|
2444
|
+
function centroidInside(b, p) {
|
|
2445
|
+
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
|
|
2301
2446
|
}
|
|
2302
2447
|
/**
|
|
2303
2448
|
* Decide, per stationary entry, whether the current frame confirms it, wakes
|
|
2304
2449
|
* it, or misses it (no matching detection — leave it for the TTL sweep).
|
|
2305
2450
|
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2451
|
+
* Matching is ASSOCIATION-GATED by box overlap so an entry can only ever be
|
|
2452
|
+
* confirmed or woken by ITS OWN object — never by a different same-class
|
|
2453
|
+
* vehicle merely present elsewhere in the frame (the 617 parking-lot flood:
|
|
2454
|
+
* passing/neighbouring cars used to retire parked entries, forcing endless
|
|
2455
|
+
* re-spawns). Per entry, over the best-overlapping same-class detection:
|
|
2456
|
+
*
|
|
2457
|
+
* - best IoU ≥ `suppressIou` → SUPPRESS (the parked object, near-identical box)
|
|
2458
|
+
* and mark the entry confirmed.
|
|
2459
|
+
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid still INSIDE the parked
|
|
2460
|
+
* box → SUPPRESS (detector box jitter — the box shrank/grew around the same
|
|
2461
|
+
* centre; still the parked object, must not spawn a duplicate track).
|
|
2462
|
+
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
|
|
2463
|
+
* → WAKE (the object's box slid off its spot → it is departing). Not
|
|
2464
|
+
* suppressed, so it spawns a fresh moving track.
|
|
2465
|
+
* - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
|
|
2466
|
+
* different object, or the parked object is momentarily undetected). Neither
|
|
2467
|
+
* suppress nor wake; the TTL sweep retires it only if the absence persists.
|
|
2314
2468
|
*
|
|
2315
2469
|
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
2316
2470
|
* (`suppressedIndices` is a set).
|
|
2317
2471
|
*/
|
|
2318
2472
|
function partitionDetectionsAgainstRegistry(input) {
|
|
2319
|
-
const { entries, detections,
|
|
2473
|
+
const { entries, detections, config } = input;
|
|
2320
2474
|
const suppressed = /* @__PURE__ */ new Set();
|
|
2321
2475
|
const confirmed = [];
|
|
2322
2476
|
const woken = [];
|
|
2323
|
-
const diag = referenceDiagonalPx;
|
|
2324
2477
|
for (const entry of entries) {
|
|
2325
|
-
const ec = centroid(entry.bbox);
|
|
2326
2478
|
let bestIou = 0;
|
|
2327
2479
|
let bestIdx = -1;
|
|
2328
|
-
let wakeCandidate = false;
|
|
2329
2480
|
for (let di = 0; di < detections.length; di++) {
|
|
2330
2481
|
const det = detections[di];
|
|
2331
2482
|
if (det.className !== entry.className) continue;
|
|
@@ -2334,20 +2485,17 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
2334
2485
|
bestIou = o;
|
|
2335
2486
|
bestIdx = di;
|
|
2336
2487
|
}
|
|
2337
|
-
if (diag > 0) {
|
|
2338
|
-
const dc = centroid(det.bbox);
|
|
2339
|
-
const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
|
|
2340
|
-
if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
|
|
2341
|
-
}
|
|
2342
2488
|
}
|
|
2343
|
-
if (
|
|
2489
|
+
if (bestIdx < 0 || bestIou < config.wakeAssocIou) continue;
|
|
2490
|
+
const detCentroid = centroid(detections[bestIdx].bbox);
|
|
2491
|
+
if (bestIou >= config.suppressIou || centroidInside(entry.bbox, detCentroid)) {
|
|
2344
2492
|
suppressed.add(bestIdx);
|
|
2345
2493
|
confirmed.push({
|
|
2346
2494
|
entryId: entry.id,
|
|
2347
2495
|
className: entry.className,
|
|
2348
2496
|
bbox: { ...entry.bbox }
|
|
2349
2497
|
});
|
|
2350
|
-
} else
|
|
2498
|
+
} else woken.push(entry.id);
|
|
2351
2499
|
}
|
|
2352
2500
|
return {
|
|
2353
2501
|
suppressedIndices: suppressed,
|
|
@@ -2524,7 +2672,6 @@ var StationaryObjectRegistry = class {
|
|
|
2524
2672
|
return partitionDetectionsAgainstRegistry({
|
|
2525
2673
|
entries,
|
|
2526
2674
|
detections: input.detections,
|
|
2527
|
-
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2528
2675
|
config: this.matchConfig
|
|
2529
2676
|
});
|
|
2530
2677
|
}
|
|
@@ -2738,7 +2885,7 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2738
2885
|
const matched = [];
|
|
2739
2886
|
for (const zone of zones) {
|
|
2740
2887
|
if (zone.polygon.length < 3) continue;
|
|
2741
|
-
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2888
|
+
if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
|
|
2742
2889
|
}
|
|
2743
2890
|
return matched;
|
|
2744
2891
|
}
|
|
@@ -2955,11 +3102,196 @@ var BindingCache = class {
|
|
|
2955
3102
|
}
|
|
2956
3103
|
};
|
|
2957
3104
|
//#endregion
|
|
3105
|
+
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
3106
|
+
function encodeRecentCursor(cursor) {
|
|
3107
|
+
return Buffer.from(JSON.stringify({
|
|
3108
|
+
l: cursor.lastSeen,
|
|
3109
|
+
i: cursor.trackId
|
|
3110
|
+
}), "utf8").toString("base64url");
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* Decode + validate an opaque cursor. Fails fast with a clear error on any
|
|
3114
|
+
* malformed input (bad base64, bad JSON, wrong field types) — a garbage
|
|
3115
|
+
* cursor must never silently degrade into a full-history first page.
|
|
3116
|
+
*/
|
|
3117
|
+
function decodeRecentCursor(raw) {
|
|
3118
|
+
let parsed;
|
|
3119
|
+
try {
|
|
3120
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
3121
|
+
} catch {
|
|
3122
|
+
throw new Error("listRecentTracks: malformed cursor");
|
|
3123
|
+
}
|
|
3124
|
+
if (parsed === null || typeof parsed !== "object" || !("l" in parsed) || !("i" in parsed) || typeof parsed.l !== "number" || !Number.isFinite(parsed.l) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listRecentTracks: malformed cursor");
|
|
3125
|
+
return {
|
|
3126
|
+
lastSeen: parsed.l,
|
|
3127
|
+
trackId: parsed.i
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
/** Comparator for the (lastSeen DESC, id DESC) total order. */
|
|
3131
|
+
function compareRecentDesc(a, b) {
|
|
3132
|
+
if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
|
|
3133
|
+
if (a.id === b.id) return 0;
|
|
3134
|
+
return a.id < b.id ? 1 : -1;
|
|
3135
|
+
}
|
|
3136
|
+
/** True when `row` sits strictly AFTER the cursor position in DESC order
|
|
3137
|
+
* (i.e. belongs to the next page). */
|
|
3138
|
+
function isAfterCursor(row, cursor) {
|
|
3139
|
+
if (row.lastSeen < cursor.lastSeen) return true;
|
|
3140
|
+
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
3141
|
+
}
|
|
3142
|
+
//#endregion
|
|
3143
|
+
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
3144
|
+
/**
|
|
3145
|
+
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
3146
|
+
* when the frame dimensions are unknown/degenerate or there are no
|
|
3147
|
+
* positions — the caller persists NULL envelope columns in that case.
|
|
3148
|
+
*/
|
|
3149
|
+
function computeTrackEnvelope(positions, frameWidth, frameHeight) {
|
|
3150
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
|
|
3151
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3152
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3153
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3154
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3155
|
+
for (const p of positions) {
|
|
3156
|
+
const x0 = p.bbox.x / frameWidth;
|
|
3157
|
+
const y0 = p.bbox.y / frameHeight;
|
|
3158
|
+
const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
|
|
3159
|
+
const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
|
|
3160
|
+
if (x0 < minX) minX = x0;
|
|
3161
|
+
if (y0 < minY) minY = y0;
|
|
3162
|
+
if (x1 > maxX) maxX = x1;
|
|
3163
|
+
if (y1 > maxY) maxY = y1;
|
|
3164
|
+
}
|
|
3165
|
+
return {
|
|
3166
|
+
minX,
|
|
3167
|
+
minY,
|
|
3168
|
+
maxX,
|
|
3169
|
+
maxY
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
/**
|
|
3173
|
+
* Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
|
|
3174
|
+
* min/max). A degenerate polygon (< 3 points) yields the full frame so the
|
|
3175
|
+
* SQL prefilter never silently drops rows the precise test would keep.
|
|
3176
|
+
*/
|
|
3177
|
+
function zoneBounds(zone) {
|
|
3178
|
+
if (zone.kind === "rect") return {
|
|
3179
|
+
minX: zone.x,
|
|
3180
|
+
minY: zone.y,
|
|
3181
|
+
maxX: zone.x + zone.width,
|
|
3182
|
+
maxY: zone.y + zone.height
|
|
3183
|
+
};
|
|
3184
|
+
if (zone.points.length < 3) return {
|
|
3185
|
+
minX: 0,
|
|
3186
|
+
minY: 0,
|
|
3187
|
+
maxX: 1,
|
|
3188
|
+
maxY: 1
|
|
3189
|
+
};
|
|
3190
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3191
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3192
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3193
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3194
|
+
for (const p of zone.points) {
|
|
3195
|
+
if (p.x < minX) minX = p.x;
|
|
3196
|
+
if (p.y < minY) minY = p.y;
|
|
3197
|
+
if (p.x > maxX) maxX = p.x;
|
|
3198
|
+
if (p.y > maxY) maxY = p.y;
|
|
3199
|
+
}
|
|
3200
|
+
return {
|
|
3201
|
+
minX,
|
|
3202
|
+
minY,
|
|
3203
|
+
maxX,
|
|
3204
|
+
maxY
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
/** Whether two axis-aligned envelopes overlap (touching edges count). */
|
|
3208
|
+
function envelopesOverlap(a, b) {
|
|
3209
|
+
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Ray-casting point-in-polygon (even-odd rule). Points on an edge may
|
|
3213
|
+
* resolve either way — acceptable for zone filtering. A polygon with
|
|
3214
|
+
* fewer than 3 vertices contains nothing.
|
|
3215
|
+
*/
|
|
3216
|
+
function pointInPolygon(point, polygon) {
|
|
3217
|
+
if (polygon.length < 3) return false;
|
|
3218
|
+
let inside = false;
|
|
3219
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
3220
|
+
const a = polygon[i];
|
|
3221
|
+
const b = polygon[j];
|
|
3222
|
+
if (a.y > point.y !== b.y > point.y && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
3223
|
+
}
|
|
3224
|
+
return inside;
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
3227
|
+
* Precise per-position zone test.
|
|
3228
|
+
*
|
|
3229
|
+
* - rect zone → any position bbox (normalized) intersects the rect.
|
|
3230
|
+
* - polygon zone → any position CENTER (normalized `x`/`y` — positions
|
|
3231
|
+
* store the bbox center) falls inside the polygon.
|
|
3232
|
+
*
|
|
3233
|
+
* Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
|
|
3234
|
+
* PASSES — mirroring the NULL-envelope-matches rule).
|
|
3235
|
+
*/
|
|
3236
|
+
function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
|
|
3237
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
|
|
3238
|
+
if (zone.kind === "rect") {
|
|
3239
|
+
const rect = zoneBounds(zone);
|
|
3240
|
+
for (const p of positions) if (envelopesOverlap({
|
|
3241
|
+
minX: p.bbox.x / frameWidth,
|
|
3242
|
+
minY: p.bbox.y / frameHeight,
|
|
3243
|
+
maxX: (p.bbox.x + p.bbox.w) / frameWidth,
|
|
3244
|
+
maxY: (p.bbox.y + p.bbox.h) / frameHeight
|
|
3245
|
+
}, rect)) return true;
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
for (const p of positions) if (pointInPolygon({
|
|
3249
|
+
x: p.x / frameWidth,
|
|
3250
|
+
y: p.y / frameHeight
|
|
3251
|
+
}, zone.points)) return true;
|
|
3252
|
+
return false;
|
|
3253
|
+
}
|
|
3254
|
+
//#endregion
|
|
2958
3255
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
2959
3256
|
var DEFAULT_CONFIG = {
|
|
2960
3257
|
ttlMs: 3e4,
|
|
2961
3258
|
maxPositionHistory: 300
|
|
2962
3259
|
};
|
|
3260
|
+
/** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
|
|
3261
|
+
var RECENT_DEFAULT_LIMIT = 200;
|
|
3262
|
+
var RECENT_MAX_LIMIT = 1e3;
|
|
3263
|
+
/** Wide inclusive bound for the envelope-overlap `BETWEEN` prefilter.
|
|
3264
|
+
* Envelope values are normalized ~0..1 but a bbox can spill slightly past
|
|
3265
|
+
* the frame edge; ±1e6 keeps every real value inside the range while the
|
|
3266
|
+
* opposing bound does the actual overlap cut. */
|
|
3267
|
+
var ENV_RANGE_SLACK = 1e6;
|
|
3268
|
+
function rowMatchesZone(data, zone) {
|
|
3269
|
+
const fw = data["frameWidth"];
|
|
3270
|
+
const fh = data["frameHeight"];
|
|
3271
|
+
if (typeof fw !== "number" || typeof fh !== "number") return true;
|
|
3272
|
+
const positions = data["positions"];
|
|
3273
|
+
if (!Array.isArray(positions)) return true;
|
|
3274
|
+
const positionRows = [];
|
|
3275
|
+
for (const p of positions) {
|
|
3276
|
+
if (p === null || typeof p !== "object") continue;
|
|
3277
|
+
if (!("x" in p) || typeof p.x !== "number" || !("y" in p) || typeof p.y !== "number") continue;
|
|
3278
|
+
if (!("bbox" in p) || p.bbox === null || typeof p.bbox !== "object") continue;
|
|
3279
|
+
const b = p.bbox;
|
|
3280
|
+
if (!("x" in b) || typeof b.x !== "number" || !("y" in b) || typeof b.y !== "number") continue;
|
|
3281
|
+
if (!("w" in b) || typeof b.w !== "number" || !("h" in b) || typeof b.h !== "number") continue;
|
|
3282
|
+
positionRows.push({
|
|
3283
|
+
x: p.x,
|
|
3284
|
+
y: p.y,
|
|
3285
|
+
bbox: {
|
|
3286
|
+
x: b.x,
|
|
3287
|
+
y: b.y,
|
|
3288
|
+
w: b.w,
|
|
3289
|
+
h: b.h
|
|
3290
|
+
}
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
return positionsIntersectZone(positionRows, fw, fh, zone);
|
|
3294
|
+
}
|
|
2963
3295
|
var TRACKS_COLLECTION = "pipeline-analytics:tracks";
|
|
2964
3296
|
var TRACKS_COLUMNS = [
|
|
2965
3297
|
{
|
|
@@ -3031,6 +3363,30 @@ var TRACKS_COLUMNS = [
|
|
|
3031
3363
|
{
|
|
3032
3364
|
name: "audioLabels",
|
|
3033
3365
|
type: "JSON"
|
|
3366
|
+
},
|
|
3367
|
+
{
|
|
3368
|
+
name: "envMinX",
|
|
3369
|
+
type: "REAL"
|
|
3370
|
+
},
|
|
3371
|
+
{
|
|
3372
|
+
name: "envMinY",
|
|
3373
|
+
type: "REAL"
|
|
3374
|
+
},
|
|
3375
|
+
{
|
|
3376
|
+
name: "envMaxX",
|
|
3377
|
+
type: "REAL"
|
|
3378
|
+
},
|
|
3379
|
+
{
|
|
3380
|
+
name: "envMaxY",
|
|
3381
|
+
type: "REAL"
|
|
3382
|
+
},
|
|
3383
|
+
{
|
|
3384
|
+
name: "frameWidth",
|
|
3385
|
+
type: "INTEGER"
|
|
3386
|
+
},
|
|
3387
|
+
{
|
|
3388
|
+
name: "frameHeight",
|
|
3389
|
+
type: "INTEGER"
|
|
3034
3390
|
}
|
|
3035
3391
|
];
|
|
3036
3392
|
var TRACKS_INDEXES = [{
|
|
@@ -3086,6 +3442,7 @@ var TrackStore = class {
|
|
|
3086
3442
|
config;
|
|
3087
3443
|
logger;
|
|
3088
3444
|
store;
|
|
3445
|
+
frameDims;
|
|
3089
3446
|
constructor(deps) {
|
|
3090
3447
|
this.logger = deps.logger;
|
|
3091
3448
|
this.store = deps.store;
|
|
@@ -3093,6 +3450,7 @@ var TrackStore = class {
|
|
|
3093
3450
|
...DEFAULT_CONFIG,
|
|
3094
3451
|
...deps.config
|
|
3095
3452
|
};
|
|
3453
|
+
this.frameDims = deps.frameDims;
|
|
3096
3454
|
}
|
|
3097
3455
|
/** One-time collection declaration. Call from addon onInitialize. */
|
|
3098
3456
|
static async declare(store) {
|
|
@@ -3439,21 +3797,200 @@ var TrackStore = class {
|
|
|
3439
3797
|
}
|
|
3440
3798
|
return [...seenDevices];
|
|
3441
3799
|
}
|
|
3442
|
-
/** Historical query — hits the persisted collection.
|
|
3800
|
+
/** Historical query — hits the persisted collection. With `zone` set,
|
|
3801
|
+
* candidates are SQL-prefiltered on the envelope columns (overlap test via
|
|
3802
|
+
* `whereBetween`), NULL-envelope rows are re-fetched separately (they must
|
|
3803
|
+
* still MATCH — `BETWEEN` excludes NULL), and survivors run the precise
|
|
3804
|
+
* per-position test against the zone. `projection: 'slim'` drops the heavy
|
|
3805
|
+
* `positions[]` / `snapshots[]` JSON from the returned rows (empty arrays);
|
|
3806
|
+
* the zone test still runs on the stored positions before the drop. */
|
|
3443
3807
|
async queryHistorical(params) {
|
|
3444
|
-
const
|
|
3445
|
-
|
|
3446
|
-
return (await this.store.query.query({
|
|
3808
|
+
const limit = params.limit ?? 50;
|
|
3809
|
+
const timeBetween = params.since !== void 0 || params.until !== void 0 ? { firstSeen: [params.since ?? 0, params.until ?? Date.now()] } : {};
|
|
3810
|
+
if (params.zone === void 0) return (await this.store.query.query({
|
|
3811
|
+
collection: TRACKS_COLLECTION,
|
|
3812
|
+
filter: {
|
|
3813
|
+
where: { deviceId: params.deviceId },
|
|
3814
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3815
|
+
orderBy: {
|
|
3816
|
+
field: "firstSeen",
|
|
3817
|
+
direction: "desc"
|
|
3818
|
+
},
|
|
3819
|
+
limit
|
|
3820
|
+
}
|
|
3821
|
+
})).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3822
|
+
const zone = params.zone;
|
|
3823
|
+
const bounds = zoneBounds(zone);
|
|
3824
|
+
const overlapQuery = this.store.query.query({
|
|
3825
|
+
collection: TRACKS_COLLECTION,
|
|
3826
|
+
filter: {
|
|
3827
|
+
where: { deviceId: params.deviceId },
|
|
3828
|
+
whereBetween: {
|
|
3829
|
+
...timeBetween,
|
|
3830
|
+
envMinX: [-1e6, bounds.maxX],
|
|
3831
|
+
envMaxX: [bounds.minX, ENV_RANGE_SLACK],
|
|
3832
|
+
envMinY: [-1e6, bounds.maxY],
|
|
3833
|
+
envMaxY: [bounds.minY, ENV_RANGE_SLACK]
|
|
3834
|
+
},
|
|
3835
|
+
orderBy: {
|
|
3836
|
+
field: "firstSeen",
|
|
3837
|
+
direction: "desc"
|
|
3838
|
+
},
|
|
3839
|
+
limit
|
|
3840
|
+
}
|
|
3841
|
+
});
|
|
3842
|
+
const nullEnvQuery = this.store.query.query({
|
|
3447
3843
|
collection: TRACKS_COLLECTION,
|
|
3448
3844
|
filter: {
|
|
3449
|
-
|
|
3845
|
+
where: { deviceId: params.deviceId },
|
|
3846
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3450
3847
|
orderBy: {
|
|
3451
3848
|
field: "firstSeen",
|
|
3452
3849
|
direction: "desc"
|
|
3453
3850
|
},
|
|
3454
|
-
limit
|
|
3851
|
+
limit
|
|
3852
|
+
}
|
|
3853
|
+
});
|
|
3854
|
+
const [overlapRows, windowRows] = await Promise.all([overlapQuery, nullEnvQuery]);
|
|
3855
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
3856
|
+
for (const r of overlapRows) if (typeof r.data["envMinX"] === "number") candidates.set(r.id, r.data);
|
|
3857
|
+
for (const r of windowRows) if (r.data["envMinX"] === null || r.data["envMinX"] === void 0) candidates.set(r.id, r.data);
|
|
3858
|
+
const matched = [];
|
|
3859
|
+
for (const [id, data] of candidates) if (rowMatchesZone(data, zone)) matched.push({
|
|
3860
|
+
id,
|
|
3861
|
+
data
|
|
3862
|
+
});
|
|
3863
|
+
matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
|
|
3864
|
+
return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Batched multi-device recent-tracks page (`listRecentTracks`): the
|
|
3868
|
+
* persisted completed tracks of every requested device, merged and ordered
|
|
3869
|
+
* by (`lastSeen` DESC, `trackId` DESC) with a stable opaque cursor.
|
|
3870
|
+
*
|
|
3871
|
+
* Approach — per-device indexed page + k-way merge (documented for the
|
|
3872
|
+
* cap): each device is fetched with ONE indexed query on
|
|
3873
|
+
* `idx_tracks_device_lastSeen` (`WHERE deviceId = ? AND lastSeen BETWEEN
|
|
3874
|
+
* ? AND ? ORDER BY lastSeen DESC LIMIT limit+1`), then the pages are
|
|
3875
|
+
* merged in memory and cut to `limit`. At events-page cardinalities
|
|
3876
|
+
* (≤ dozens of devices × ≤ 1000 rows) the in-memory merge is negligible
|
|
3877
|
+
* next to the row I/O, and every row fetched is a candidate (no scan
|
|
3878
|
+
* waste). The +1 overfetch makes `nextCursor` exact: when the merged
|
|
3879
|
+
* candidate set exceeds `limit` more rows are KNOWN to exist; when it
|
|
3880
|
+
* does not, every device returned fewer rows than asked for and is
|
|
3881
|
+
* therefore exhausted — so the final page always ends with
|
|
3882
|
+
* `nextCursor: null` (no empty trailing page).
|
|
3883
|
+
*
|
|
3884
|
+
* Cursor correctness: SQL can only bound `lastSeen`, and rows sharing the
|
|
3885
|
+
* cursor's exact millisecond have no defined SQL order — so a cursor page
|
|
3886
|
+
* runs TWO ranges per device: an exhaustive same-millisecond tie query
|
|
3887
|
+
* (`lastSeen = cursor.lastSeen`, id tie-break applied in memory) plus the
|
|
3888
|
+
* strictly-older indexed page (`lastSeen ≤ cursor.lastSeen - 1`). Tie
|
|
3889
|
+
* clusters are same-ms track expiries on one camera — physically tiny —
|
|
3890
|
+
* so the unbounded tie query stays O(1) in practice. Known accepted edge:
|
|
3891
|
+
* on a NON-cursor page, a same-ms tie cluster straddling a device's
|
|
3892
|
+
* `limit+1` SQL cut could omit a tie row (needs > limit rows sharing one
|
|
3893
|
+
* millisecond on one camera — unreachable at these cardinalities).
|
|
3894
|
+
*
|
|
3895
|
+
* Errors propagate (no partial merges): a failed device query fails the
|
|
3896
|
+
* page rather than silently returning an incomplete window.
|
|
3897
|
+
*/
|
|
3898
|
+
async queryRecent(params) {
|
|
3899
|
+
const limit = Math.min(Math.max(params.limit ?? RECENT_DEFAULT_LIMIT, 1), RECENT_MAX_LIMIT);
|
|
3900
|
+
const deviceIds = [...new Set(params.deviceIds)];
|
|
3901
|
+
if (deviceIds.length === 0) return {
|
|
3902
|
+
tracks: [],
|
|
3903
|
+
nextCursor: null
|
|
3904
|
+
};
|
|
3905
|
+
const cursor = params.cursor !== void 0 ? decodeRecentCursor(params.cursor) : null;
|
|
3906
|
+
const lo = params.since ?? 0;
|
|
3907
|
+
const winHi = params.until ?? Number.MAX_SAFE_INTEGER;
|
|
3908
|
+
const hi = cursor !== null ? Math.min(cursor.lastSeen, winHi) : winHi;
|
|
3909
|
+
if (hi < lo) return {
|
|
3910
|
+
tracks: [],
|
|
3911
|
+
nextCursor: null
|
|
3912
|
+
};
|
|
3913
|
+
const fetchRange = async (deviceId, range, pageLimit) => this.store.query.query({
|
|
3914
|
+
collection: TRACKS_COLLECTION,
|
|
3915
|
+
filter: {
|
|
3916
|
+
where: { deviceId },
|
|
3917
|
+
whereBetween: { lastSeen: range },
|
|
3918
|
+
orderBy: {
|
|
3919
|
+
field: "lastSeen",
|
|
3920
|
+
direction: "desc"
|
|
3921
|
+
},
|
|
3922
|
+
...pageLimit !== void 0 ? { limit: pageLimit } : {}
|
|
3923
|
+
}
|
|
3924
|
+
});
|
|
3925
|
+
const perDevice = await Promise.all(deviceIds.map(async (deviceId) => {
|
|
3926
|
+
if (cursor === null || cursor.lastSeen > hi) return fetchRange(deviceId, [lo, hi], limit + 1);
|
|
3927
|
+
const tieRange = [cursor.lastSeen, cursor.lastSeen];
|
|
3928
|
+
const belowHi = cursor.lastSeen - 1;
|
|
3929
|
+
const [ties, below] = await Promise.all([cursor.lastSeen >= lo ? fetchRange(deviceId, tieRange) : Promise.resolve([]), belowHi >= lo ? fetchRange(deviceId, [lo, belowHi], limit + 1) : Promise.resolve([])]);
|
|
3930
|
+
return [...ties, ...below];
|
|
3931
|
+
}));
|
|
3932
|
+
const candidates = [];
|
|
3933
|
+
for (const rows of perDevice) for (const r of rows) {
|
|
3934
|
+
const lastSeen = Number(r.data["lastSeen"] ?? 0);
|
|
3935
|
+
if (cursor !== null && !isAfterCursor({
|
|
3936
|
+
lastSeen,
|
|
3937
|
+
id: r.id
|
|
3938
|
+
}, cursor)) continue;
|
|
3939
|
+
candidates.push({
|
|
3940
|
+
id: r.id,
|
|
3941
|
+
lastSeen,
|
|
3942
|
+
data: r.data
|
|
3943
|
+
});
|
|
3944
|
+
}
|
|
3945
|
+
candidates.sort(compareRecentDesc);
|
|
3946
|
+
const page = candidates.slice(0, limit);
|
|
3947
|
+
const tracks = page.map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3948
|
+
const last = page[page.length - 1];
|
|
3949
|
+
return {
|
|
3950
|
+
tracks,
|
|
3951
|
+
nextCursor: candidates.length > limit && last !== void 0 ? encodeRecentCursor({
|
|
3952
|
+
lastSeen: last.lastSeen,
|
|
3953
|
+
trackId: last.id
|
|
3954
|
+
}) : null
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Deduplicated detector class names observed on a device's RECENT persisted
|
|
3959
|
+
* tracks (one indexed page, `lastSeen` desc). Feeds `listEventKinds` — a
|
|
3960
|
+
* representative "what has this camera actually seen" set, not an exhaustive
|
|
3961
|
+
* all-time DISTINCT (the query cap has none). Unions the primary `className`
|
|
3962
|
+
* with the accumulated `classes` array. Best-effort: [] on error.
|
|
3963
|
+
*/
|
|
3964
|
+
async observedClassNames(deviceId, limit = 500) {
|
|
3965
|
+
try {
|
|
3966
|
+
const rows = await this.store.query.query({
|
|
3967
|
+
collection: TRACKS_COLLECTION,
|
|
3968
|
+
filter: {
|
|
3969
|
+
where: { deviceId },
|
|
3970
|
+
orderBy: {
|
|
3971
|
+
field: "lastSeen",
|
|
3972
|
+
direction: "desc"
|
|
3973
|
+
},
|
|
3974
|
+
limit
|
|
3975
|
+
}
|
|
3976
|
+
});
|
|
3977
|
+
const names = /* @__PURE__ */ new Set();
|
|
3978
|
+
for (const r of rows) {
|
|
3979
|
+
const className = r.data["className"];
|
|
3980
|
+
if (typeof className === "string" && className.length > 0) names.add(className);
|
|
3981
|
+
const classes = r.data["classes"];
|
|
3982
|
+
if (Array.isArray(classes)) {
|
|
3983
|
+
for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
|
|
3984
|
+
}
|
|
3455
3985
|
}
|
|
3456
|
-
|
|
3986
|
+
return [...names];
|
|
3987
|
+
} catch (err) {
|
|
3988
|
+
this.logger.warn("TrackStore.observedClassNames failed", { meta: {
|
|
3989
|
+
deviceId,
|
|
3990
|
+
error: String(err)
|
|
3991
|
+
} });
|
|
3992
|
+
return [];
|
|
3993
|
+
}
|
|
3457
3994
|
}
|
|
3458
3995
|
async getPersistedByTrackId(trackId) {
|
|
3459
3996
|
const records = await this.store.query.query({
|
|
@@ -3468,6 +4005,8 @@ var TrackStore = class {
|
|
|
3468
4005
|
return this.rowToTrack(row.id, row.data);
|
|
3469
4006
|
}
|
|
3470
4007
|
async persistCompleted(t) {
|
|
4008
|
+
const dims = this.frameDims?.(t.deviceId);
|
|
4009
|
+
const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
|
|
3471
4010
|
await this.store.set.mutate({
|
|
3472
4011
|
collection: TRACKS_COLLECTION,
|
|
3473
4012
|
key: t.trackId,
|
|
@@ -3486,13 +4025,30 @@ var TrackStore = class {
|
|
|
3486
4025
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
3487
4026
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
3488
4027
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3489
|
-
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
4028
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
4029
|
+
...envelope !== null && dims !== void 0 ? {
|
|
4030
|
+
envMinX: envelope.minX,
|
|
4031
|
+
envMinY: envelope.minY,
|
|
4032
|
+
envMaxX: envelope.maxX,
|
|
4033
|
+
envMaxY: envelope.maxY,
|
|
4034
|
+
frameWidth: dims.w,
|
|
4035
|
+
frameHeight: dims.h
|
|
4036
|
+
} : {}
|
|
3490
4037
|
}
|
|
3491
4038
|
});
|
|
3492
4039
|
}
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
4040
|
+
/**
|
|
4041
|
+
* Map a persisted row onto the cap `Track` shape. `projection: 'slim'`
|
|
4042
|
+
* drops the heavy `positions[]` / `snapshots[]` JSON (returned as empty
|
|
4043
|
+
* arrays — they are required on the schema) while keeping every scalar
|
|
4044
|
+
* the list surfaces render; `full` (default) is byte-compatible with the
|
|
4045
|
+
* pre-projection behaviour. The persisted envelope columns surface as the
|
|
4046
|
+
* optional `envelope` object in BOTH projections (four light numbers).
|
|
4047
|
+
*/
|
|
4048
|
+
rowToTrack(id, data, projection) {
|
|
4049
|
+
const slim = projection === "slim";
|
|
4050
|
+
const positions = slim ? [] : data["positions"] ?? [];
|
|
4051
|
+
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
3496
4052
|
const zones = data["zonesVisited"] ?? [];
|
|
3497
4053
|
const classes = data["classes"];
|
|
3498
4054
|
const label = data["label"];
|
|
@@ -3500,6 +4056,16 @@ var TrackStore = class {
|
|
|
3500
4056
|
const bestEventId = data["bestEventId"];
|
|
3501
4057
|
const importanceReason = data["importanceReason"];
|
|
3502
4058
|
const audioLabels = data["audioLabels"];
|
|
4059
|
+
const envMinX = data["envMinX"];
|
|
4060
|
+
const envMinY = data["envMinY"];
|
|
4061
|
+
const envMaxX = data["envMaxX"];
|
|
4062
|
+
const envMaxY = data["envMaxY"];
|
|
4063
|
+
const envelope = typeof envMinX === "number" && typeof envMinY === "number" && typeof envMaxX === "number" && typeof envMaxY === "number" ? {
|
|
4064
|
+
minX: envMinX,
|
|
4065
|
+
minY: envMinY,
|
|
4066
|
+
maxX: envMaxX,
|
|
4067
|
+
maxY: envMaxY
|
|
4068
|
+
} : null;
|
|
3503
4069
|
return {
|
|
3504
4070
|
trackId: id,
|
|
3505
4071
|
deviceId: Number(data["deviceId"]),
|
|
@@ -3517,7 +4083,8 @@ var TrackStore = class {
|
|
|
3517
4083
|
...typeof importance === "number" ? { importance } : {},
|
|
3518
4084
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
3519
4085
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3520
|
-
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
4086
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
4087
|
+
...envelope !== null ? { envelope } : {}
|
|
3521
4088
|
};
|
|
3522
4089
|
}
|
|
3523
4090
|
};
|
|
@@ -4287,10 +4854,21 @@ var EventStore = class {
|
|
|
4287
4854
|
}
|
|
4288
4855
|
/**
|
|
4289
4856
|
* The track's highest-confidence object event, its bbox area (as a fraction of
|
|
4290
|
-
* frame area), and
|
|
4291
|
-
* for the best frame, read back from the persisted object events (index
|
|
4857
|
+
* frame area), and its representative event id — the SHARED per-track ranking
|
|
4858
|
+
* used for the best frame, read back from the persisted object events (index
|
|
4292
4859
|
* `idx_object_track`). Returns zeros + undefined id when the track has none.
|
|
4293
4860
|
* Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
|
|
4861
|
+
*
|
|
4862
|
+
* `peakConfidence` + `peakBboxAreaFrac` always track the TRUE confidence peak
|
|
4863
|
+
* (over every crop-bearing row) — importance reads them.
|
|
4864
|
+
*
|
|
4865
|
+
* `bestEventId` treats the synthetic APPEARANCE / entry event (state
|
|
4866
|
+
* `'entered'`, Requirement 1) as a FLOOR: it represents the track only when no
|
|
4867
|
+
* genuine ACTIVITY event (any other state — a zone / stationary / moving
|
|
4868
|
+
* moment) exists. So a degenerate passer-by whose only crop-bearing event is
|
|
4869
|
+
* its appearance still gets a representative id, while a track that later
|
|
4870
|
+
* produced a real activity event surfaces THAT as its best. Within a tier the
|
|
4871
|
+
* highest confidence wins.
|
|
4294
4872
|
*/
|
|
4295
4873
|
async peakForTrack(trackId) {
|
|
4296
4874
|
const rows = await this.store.query.query({
|
|
@@ -4298,8 +4876,11 @@ var EventStore = class {
|
|
|
4298
4876
|
filter: { where: { trackId } }
|
|
4299
4877
|
});
|
|
4300
4878
|
let bestConf = -1;
|
|
4301
|
-
let bestEventId;
|
|
4302
4879
|
let peakBboxAreaFrac = 0;
|
|
4880
|
+
let activityConf = -1;
|
|
4881
|
+
let activityId;
|
|
4882
|
+
let entryConf = -1;
|
|
4883
|
+
let entryId;
|
|
4303
4884
|
for (const row of rows) {
|
|
4304
4885
|
const bbox = row.data["bbox"];
|
|
4305
4886
|
if (bbox !== null && typeof bbox === "object") {
|
|
@@ -4308,15 +4889,24 @@ var EventStore = class {
|
|
|
4308
4889
|
if (bw <= 0 || bh <= 0) continue;
|
|
4309
4890
|
}
|
|
4310
4891
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
4311
|
-
if (conf
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4892
|
+
if (conf > bestConf) {
|
|
4893
|
+
bestConf = conf;
|
|
4894
|
+
peakBboxAreaFrac = bboxAreaFrac(row.data);
|
|
4895
|
+
}
|
|
4896
|
+
if (row.data["state"] === "entered") {
|
|
4897
|
+
if (conf > entryConf) {
|
|
4898
|
+
entryConf = conf;
|
|
4899
|
+
entryId = row.id;
|
|
4900
|
+
}
|
|
4901
|
+
} else if (conf > activityConf) {
|
|
4902
|
+
activityConf = conf;
|
|
4903
|
+
activityId = row.id;
|
|
4904
|
+
}
|
|
4315
4905
|
}
|
|
4316
4906
|
return {
|
|
4317
4907
|
peakConfidence: bestConf < 0 ? 0 : bestConf,
|
|
4318
4908
|
peakBboxAreaFrac,
|
|
4319
|
-
bestEventId
|
|
4909
|
+
bestEventId: activityId ?? entryId
|
|
4320
4910
|
};
|
|
4321
4911
|
}
|
|
4322
4912
|
/**
|
|
@@ -4628,6 +5218,384 @@ function stripNulls(data) {
|
|
|
4628
5218
|
return out;
|
|
4629
5219
|
}
|
|
4630
5220
|
//#endregion
|
|
5221
|
+
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
5222
|
+
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
5223
|
+
var SENSOR_EVENT_COLUMNS = [
|
|
5224
|
+
{
|
|
5225
|
+
name: "id",
|
|
5226
|
+
type: "TEXT",
|
|
5227
|
+
primaryKey: true,
|
|
5228
|
+
notNull: true
|
|
5229
|
+
},
|
|
5230
|
+
(
|
|
5231
|
+
/** The CAMERA the event is attributed to. */
|
|
5232
|
+
{
|
|
5233
|
+
name: "deviceId",
|
|
5234
|
+
type: "INTEGER",
|
|
5235
|
+
notNull: true
|
|
5236
|
+
}),
|
|
5237
|
+
(
|
|
5238
|
+
/** The linked sensor device whose state changed. */
|
|
5239
|
+
{
|
|
5240
|
+
name: "sourceDeviceId",
|
|
5241
|
+
type: "INTEGER",
|
|
5242
|
+
notNull: true
|
|
5243
|
+
}),
|
|
5244
|
+
(
|
|
5245
|
+
/** Event kind id (matches an `EventKindDescriptor.kind`). */
|
|
5246
|
+
{
|
|
5247
|
+
name: "kind",
|
|
5248
|
+
type: "TEXT",
|
|
5249
|
+
notNull: true
|
|
5250
|
+
}),
|
|
5251
|
+
(
|
|
5252
|
+
/** Snapshot of the sensor cap's runtime-state slice at the change. */
|
|
5253
|
+
{
|
|
5254
|
+
name: "value",
|
|
5255
|
+
type: "JSON"
|
|
5256
|
+
}),
|
|
5257
|
+
{
|
|
5258
|
+
name: "timestamp",
|
|
5259
|
+
type: "INTEGER",
|
|
5260
|
+
notNull: true
|
|
5261
|
+
}
|
|
5262
|
+
];
|
|
5263
|
+
var SENSOR_EVENT_INDEXES = [{
|
|
5264
|
+
name: "idx_sensor_events_device_ts",
|
|
5265
|
+
columns: ["deviceId", "timestamp"]
|
|
5266
|
+
}];
|
|
5267
|
+
var DEFAULT_QUERY_LIMIT = 1e3;
|
|
5268
|
+
var SensorEventStore = class {
|
|
5269
|
+
store;
|
|
5270
|
+
logger;
|
|
5271
|
+
constructor(deps) {
|
|
5272
|
+
this.store = deps.store;
|
|
5273
|
+
this.logger = deps.logger;
|
|
5274
|
+
}
|
|
5275
|
+
/** One-time collection declaration. Call from addon onInitialize. */
|
|
5276
|
+
static async declare(store) {
|
|
5277
|
+
await store.declareCollection.mutate({
|
|
5278
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5279
|
+
columns: [...SENSOR_EVENT_COLUMNS],
|
|
5280
|
+
indexes: [...SENSOR_EVENT_INDEXES]
|
|
5281
|
+
});
|
|
5282
|
+
}
|
|
5283
|
+
/** Insert one attributed sensor event. Best-effort (telemetry-lossy). */
|
|
5284
|
+
async insert(ev) {
|
|
5285
|
+
try {
|
|
5286
|
+
await this.store.insert.mutate({
|
|
5287
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5288
|
+
record: {
|
|
5289
|
+
id: ev.id,
|
|
5290
|
+
data: {
|
|
5291
|
+
deviceId: ev.deviceId,
|
|
5292
|
+
sourceDeviceId: ev.sourceDeviceId,
|
|
5293
|
+
kind: ev.kind,
|
|
5294
|
+
value: ev.value,
|
|
5295
|
+
timestamp: ev.timestamp
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
});
|
|
5299
|
+
} catch (err) {
|
|
5300
|
+
this.logger.warn("SensorEventStore.insert failed", {
|
|
5301
|
+
tags: { deviceId: ev.deviceId },
|
|
5302
|
+
meta: {
|
|
5303
|
+
eventId: ev.id,
|
|
5304
|
+
error: String(err)
|
|
5305
|
+
}
|
|
5306
|
+
});
|
|
5307
|
+
}
|
|
5308
|
+
}
|
|
5309
|
+
/** Per-camera sensor-event history, newest first. Mirrors the
|
|
5310
|
+
* motion/object/audio query semantics; `kinds` narrows via `whereIn`. */
|
|
5311
|
+
async query(q) {
|
|
5312
|
+
const filter = {
|
|
5313
|
+
where: { deviceId: q.deviceId },
|
|
5314
|
+
orderBy: {
|
|
5315
|
+
field: "timestamp",
|
|
5316
|
+
direction: "desc"
|
|
5317
|
+
},
|
|
5318
|
+
limit: q.limit ?? DEFAULT_QUERY_LIMIT
|
|
5319
|
+
};
|
|
5320
|
+
if (q.since !== void 0 || q.until !== void 0) filter["whereBetween"] = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
|
|
5321
|
+
if (q.kinds !== void 0 && q.kinds.length > 0) filter["whereIn"] = { kind: [...q.kinds] };
|
|
5322
|
+
return (await this.store.query.query({
|
|
5323
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5324
|
+
filter
|
|
5325
|
+
})).map((r) => rowToSensorEvent(r.id, r.data));
|
|
5326
|
+
}
|
|
5327
|
+
/**
|
|
5328
|
+
* Delete every row with `timestamp ≤ cutoffMs`, draining a page at a time
|
|
5329
|
+
* (mirrors `EventStore.evictBefore`, including the infinite-loop guard).
|
|
5330
|
+
* Returns the number of rows deleted. Rides the analytics retention sweep.
|
|
5331
|
+
*/
|
|
5332
|
+
async evictBefore(cutoffMs) {
|
|
5333
|
+
let deleted = 0;
|
|
5334
|
+
for (;;) {
|
|
5335
|
+
const rows = await this.store.query.query({
|
|
5336
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5337
|
+
filter: {
|
|
5338
|
+
whereBetween: { timestamp: [0, cutoffMs] },
|
|
5339
|
+
limit: EVICT_PAGE_SIZE
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
if (rows.length === 0) break;
|
|
5343
|
+
let deletedInPage = 0;
|
|
5344
|
+
for (const row of rows) {
|
|
5345
|
+
if (typeof row.id !== "string") continue;
|
|
5346
|
+
try {
|
|
5347
|
+
await this.store.delete.mutate({
|
|
5348
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5349
|
+
key: row.id
|
|
5350
|
+
});
|
|
5351
|
+
deleted++;
|
|
5352
|
+
deletedInPage++;
|
|
5353
|
+
} catch {}
|
|
5354
|
+
}
|
|
5355
|
+
if (deletedInPage === 0) break;
|
|
5356
|
+
}
|
|
5357
|
+
return deleted;
|
|
5358
|
+
}
|
|
5359
|
+
};
|
|
5360
|
+
/** Page size for the eviction drain loop (mirrors EventStore.PRUNE_PAGE_SIZE). */
|
|
5361
|
+
var EVICT_PAGE_SIZE = 500;
|
|
5362
|
+
function rowToSensorEvent(id, data) {
|
|
5363
|
+
const value = data["value"];
|
|
5364
|
+
return {
|
|
5365
|
+
id,
|
|
5366
|
+
deviceId: Number(data["deviceId"]),
|
|
5367
|
+
sourceDeviceId: Number(data["sourceDeviceId"]),
|
|
5368
|
+
kind: String(data["kind"]),
|
|
5369
|
+
value: isRecord(value) ? value : null,
|
|
5370
|
+
timestamp: Number(data["timestamp"])
|
|
5371
|
+
};
|
|
5372
|
+
}
|
|
5373
|
+
function isRecord(x) {
|
|
5374
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
5375
|
+
}
|
|
5376
|
+
//#endregion
|
|
5377
|
+
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
5378
|
+
/**
|
|
5379
|
+
* Extensible per-device event kinds (Part B).
|
|
5380
|
+
*
|
|
5381
|
+
* `composeEventKinds` builds the `listEventKinds` payload for a camera:
|
|
5382
|
+
* (a) built-ins — motion + audio, always present;
|
|
5383
|
+
* (b) detection classes actually OBSERVED on the device (track history);
|
|
5384
|
+
* (c) sensor kinds contributed by LINKED devices (device-manager
|
|
5385
|
+
* `getLinkedDevices`), one descriptor per bound sensor cap present in
|
|
5386
|
+
* the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
|
|
5387
|
+
* (`getBindings`) — never a global cap enumeration (D12).
|
|
5388
|
+
*
|
|
5389
|
+
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
5390
|
+
* linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
|
|
5391
|
+
* cheap at bus rate.
|
|
5392
|
+
*/
|
|
5393
|
+
var MOTION_COLOR = "#f59e0b";
|
|
5394
|
+
var AUDIO_COLOR = "#06b6d4";
|
|
5395
|
+
var PERSON_COLOR = "#22c55e";
|
|
5396
|
+
var VEHICLE_COLOR = "#3b82f6";
|
|
5397
|
+
var ANIMAL_COLOR = "#f97316";
|
|
5398
|
+
var GENERIC_DETECTION_COLOR = "#64748b";
|
|
5399
|
+
var VEHICLE_CLASSES = new Set([
|
|
5400
|
+
"vehicle",
|
|
5401
|
+
"car",
|
|
5402
|
+
"truck",
|
|
5403
|
+
"bus",
|
|
5404
|
+
"motorcycle",
|
|
5405
|
+
"bicycle",
|
|
5406
|
+
"boat",
|
|
5407
|
+
"train"
|
|
5408
|
+
]);
|
|
5409
|
+
var ANIMAL_CLASSES = new Set([
|
|
5410
|
+
"animal",
|
|
5411
|
+
"dog",
|
|
5412
|
+
"cat",
|
|
5413
|
+
"bird",
|
|
5414
|
+
"horse",
|
|
5415
|
+
"cow",
|
|
5416
|
+
"sheep"
|
|
5417
|
+
]);
|
|
5418
|
+
function detectionIcon(className) {
|
|
5419
|
+
if (className === "person") return "person";
|
|
5420
|
+
if (VEHICLE_CLASSES.has(className)) return "vehicle";
|
|
5421
|
+
if (ANIMAL_CLASSES.has(className)) return "animal";
|
|
5422
|
+
return "generic";
|
|
5423
|
+
}
|
|
5424
|
+
function detectionColor(className) {
|
|
5425
|
+
if (className === "person") return PERSON_COLOR;
|
|
5426
|
+
if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
|
|
5427
|
+
if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
|
|
5428
|
+
return GENERIC_DETECTION_COLOR;
|
|
5429
|
+
}
|
|
5430
|
+
function titleCase(s) {
|
|
5431
|
+
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
5432
|
+
}
|
|
5433
|
+
/**
|
|
5434
|
+
* Full event-kind list for a camera. Sensor kinds are deduped per
|
|
5435
|
+
* (kind, source deviceId) — two linked contact sensors each contribute
|
|
5436
|
+
* their own entry, distinguishable by `source.deviceId`.
|
|
5437
|
+
*/
|
|
5438
|
+
async function composeEventKinds(deps, deviceId) {
|
|
5439
|
+
const out = [{
|
|
5440
|
+
kind: "motion",
|
|
5441
|
+
label: "Motion",
|
|
5442
|
+
color: MOTION_COLOR,
|
|
5443
|
+
icon: "motion",
|
|
5444
|
+
category: "motion",
|
|
5445
|
+
source: {
|
|
5446
|
+
capName: "pipeline-analytics",
|
|
5447
|
+
deviceId
|
|
5448
|
+
}
|
|
5449
|
+
}, {
|
|
5450
|
+
kind: "audio",
|
|
5451
|
+
label: "Audio",
|
|
5452
|
+
color: AUDIO_COLOR,
|
|
5453
|
+
icon: "audio",
|
|
5454
|
+
category: "audio",
|
|
5455
|
+
source: {
|
|
5456
|
+
capName: "pipeline-analytics",
|
|
5457
|
+
deviceId
|
|
5458
|
+
}
|
|
5459
|
+
}];
|
|
5460
|
+
try {
|
|
5461
|
+
const classNames = await deps.observedClassNames(deviceId);
|
|
5462
|
+
for (const className of [...classNames].sort()) out.push({
|
|
5463
|
+
kind: className,
|
|
5464
|
+
label: titleCase(className),
|
|
5465
|
+
color: detectionColor(className),
|
|
5466
|
+
icon: detectionIcon(className),
|
|
5467
|
+
category: "detection",
|
|
5468
|
+
source: {
|
|
5469
|
+
capName: "pipeline-analytics",
|
|
5470
|
+
deviceId
|
|
5471
|
+
}
|
|
5472
|
+
});
|
|
5473
|
+
} catch (err) {
|
|
5474
|
+
deps.onError?.("observedClassNames", err);
|
|
5475
|
+
}
|
|
5476
|
+
try {
|
|
5477
|
+
const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
|
|
5478
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5479
|
+
for (const linked of devices) {
|
|
5480
|
+
let capNames;
|
|
5481
|
+
try {
|
|
5482
|
+
const { entries } = await deps.bindings.getBindings({ deviceId: linked.deviceId });
|
|
5483
|
+
capNames = entries.map((e) => e.capName);
|
|
5484
|
+
} catch (err) {
|
|
5485
|
+
deps.onError?.("getBindings", err);
|
|
5486
|
+
continue;
|
|
5487
|
+
}
|
|
5488
|
+
for (const capName of capNames) {
|
|
5489
|
+
const descriptor = EVENT_KIND_BY_CAP[capName];
|
|
5490
|
+
if (descriptor === void 0) continue;
|
|
5491
|
+
const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
|
|
5492
|
+
if (seen.has(dedupeKey)) continue;
|
|
5493
|
+
seen.add(dedupeKey);
|
|
5494
|
+
out.push({
|
|
5495
|
+
kind: descriptor.kind,
|
|
5496
|
+
label: descriptor.label,
|
|
5497
|
+
color: descriptor.color,
|
|
5498
|
+
icon: descriptor.icon,
|
|
5499
|
+
category: descriptor.category,
|
|
5500
|
+
source: {
|
|
5501
|
+
capName,
|
|
5502
|
+
deviceId: linked.deviceId
|
|
5503
|
+
}
|
|
5504
|
+
});
|
|
5505
|
+
}
|
|
5506
|
+
}
|
|
5507
|
+
} catch (err) {
|
|
5508
|
+
deps.onError?.("getLinkedDevices", err);
|
|
5509
|
+
}
|
|
5510
|
+
return out;
|
|
5511
|
+
}
|
|
5512
|
+
var DEFAULT_CACHE_TTL_MS = 6e4;
|
|
5513
|
+
/**
|
|
5514
|
+
* TTL-cached reverse index: source deviceId → camera ids it is linked to.
|
|
5515
|
+
* Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
|
|
5516
|
+
* handler pays one map lookup per event in the common case.
|
|
5517
|
+
*/
|
|
5518
|
+
var LinkedCamerasCache = class {
|
|
5519
|
+
deps;
|
|
5520
|
+
ttlMs;
|
|
5521
|
+
index = /* @__PURE__ */ new Map();
|
|
5522
|
+
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
5523
|
+
builtAt = null;
|
|
5524
|
+
building = null;
|
|
5525
|
+
constructor(deps) {
|
|
5526
|
+
this.deps = deps;
|
|
5527
|
+
this.ttlMs = deps.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
5528
|
+
}
|
|
5529
|
+
/** Camera ids linked to `sourceDeviceId` ([] when none). */
|
|
5530
|
+
async camerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
5531
|
+
if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
|
|
5532
|
+
this.building ??= this.rebuild(nowMs).finally(() => {
|
|
5533
|
+
this.building = null;
|
|
5534
|
+
});
|
|
5535
|
+
await this.building;
|
|
5536
|
+
}
|
|
5537
|
+
return this.index.get(sourceDeviceId) ?? [];
|
|
5538
|
+
}
|
|
5539
|
+
/** Drop the cached index (e.g. on link-topology change events). */
|
|
5540
|
+
invalidate() {
|
|
5541
|
+
this.builtAt = null;
|
|
5542
|
+
}
|
|
5543
|
+
/** Test/maintenance hook: replace the index directly. */
|
|
5544
|
+
seed(index, builtAt) {
|
|
5545
|
+
this.index = new Map(index);
|
|
5546
|
+
this.builtAt = builtAt;
|
|
5547
|
+
}
|
|
5548
|
+
async rebuild(nowMs) {
|
|
5549
|
+
try {
|
|
5550
|
+
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
5551
|
+
const next = /* @__PURE__ */ new Map();
|
|
5552
|
+
for (const cameraId of cameraIds) try {
|
|
5553
|
+
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
5554
|
+
for (const d of devices) {
|
|
5555
|
+
const list = next.get(d.deviceId);
|
|
5556
|
+
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
5557
|
+
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
5558
|
+
}
|
|
5559
|
+
} catch (err) {
|
|
5560
|
+
this.deps.onError?.("getLinkedDevices", err);
|
|
5561
|
+
}
|
|
5562
|
+
this.index = next;
|
|
5563
|
+
this.builtAt = nowMs;
|
|
5564
|
+
} catch (err) {
|
|
5565
|
+
this.deps.onError?.("listCameraIds", err);
|
|
5566
|
+
this.builtAt = nowMs;
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
};
|
|
5570
|
+
/**
|
|
5571
|
+
* One `DeviceStateChanged` → N history rows (one per linked camera). The
|
|
5572
|
+
* EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
|
|
5573
|
+
* lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
|
|
5574
|
+
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
5575
|
+
*/
|
|
5576
|
+
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
5577
|
+
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
5578
|
+
if (descriptor === void 0) return 0;
|
|
5579
|
+
const cameraIds = await deps.cache.camerasFor(data.deviceId);
|
|
5580
|
+
if (cameraIds.length === 0) return 0;
|
|
5581
|
+
const slice = data.slice;
|
|
5582
|
+
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
5583
|
+
const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
|
|
5584
|
+
let inserted = 0;
|
|
5585
|
+
for (const cameraId of cameraIds) {
|
|
5586
|
+
await deps.sink.insert({
|
|
5587
|
+
id: makeId(),
|
|
5588
|
+
deviceId: cameraId,
|
|
5589
|
+
sourceDeviceId: data.deviceId,
|
|
5590
|
+
kind: descriptor.kind,
|
|
5591
|
+
value,
|
|
5592
|
+
timestamp
|
|
5593
|
+
});
|
|
5594
|
+
inserted++;
|
|
5595
|
+
}
|
|
5596
|
+
return inserted;
|
|
5597
|
+
}
|
|
5598
|
+
//#endregion
|
|
4631
5599
|
//#region src/shared/frame/resolve-frame.ts
|
|
4632
5600
|
/**
|
|
4633
5601
|
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
@@ -4861,22 +5829,26 @@ var EventMediaDispatcher = class {
|
|
|
4861
5829
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
4862
5830
|
const storedSnapshots = [];
|
|
4863
5831
|
for (const sn of snapshots) {
|
|
4864
|
-
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
5832
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
|
|
4865
5833
|
if (stored) storedSnapshots.push(stored);
|
|
4866
5834
|
}
|
|
4867
5835
|
return { storedSnapshots };
|
|
4868
5836
|
}
|
|
4869
5837
|
/**
|
|
4870
|
-
* Periodic per-track media (§5).
|
|
4871
|
-
*
|
|
4872
|
-
* (
|
|
4873
|
-
*
|
|
4874
|
-
*
|
|
5838
|
+
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
5839
|
+
* shared by the appended `snapshot` (timeline filmstrip) and the rolling
|
|
5840
|
+
* `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
|
|
5841
|
+
* subject-centered crop (same output contract as the object-event `crop`
|
|
5842
|
+
* kind) — it is the gallery/reel fallback for tracks that never produced an
|
|
5843
|
+
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
5844
|
+
* parked car), not the track's subject. Returns the appended snapshot for
|
|
5845
|
+
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
5846
|
+
* failed).
|
|
4875
5847
|
*/
|
|
4876
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
5848
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
4877
5849
|
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
4878
|
-
let boxed;
|
|
4879
|
-
try {
|
|
5850
|
+
let boxed = null;
|
|
5851
|
+
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
4880
5852
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
4881
5853
|
...sn.bbox,
|
|
4882
5854
|
...sn.label ? { label: sn.label } : {}
|
|
@@ -4890,10 +5862,9 @@ var EventMediaDispatcher = class {
|
|
|
4890
5862
|
error: err instanceof Error ? err.message : String(err)
|
|
4891
5863
|
}
|
|
4892
5864
|
});
|
|
4893
|
-
return null;
|
|
4894
5865
|
}
|
|
4895
5866
|
let stored = null;
|
|
4896
|
-
if (sn.appendSnapshot) try {
|
|
5867
|
+
if (sn.appendSnapshot && boxed) try {
|
|
4897
5868
|
const mediaKey = await this.deps.mediaStore.put({
|
|
4898
5869
|
deviceId,
|
|
4899
5870
|
ownerKind: "track",
|
|
@@ -4909,10 +5880,49 @@ var EventMediaDispatcher = class {
|
|
|
4909
5880
|
bbox: sn.bbox
|
|
4910
5881
|
};
|
|
4911
5882
|
} catch {}
|
|
4912
|
-
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
4913
|
-
if (sn.bestThumbnail)
|
|
5883
|
+
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
5884
|
+
if (sn.bestThumbnail) try {
|
|
5885
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
5886
|
+
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
5887
|
+
} catch (err) {
|
|
5888
|
+
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
5889
|
+
tags: { deviceId },
|
|
5890
|
+
meta: {
|
|
5891
|
+
deviceId,
|
|
5892
|
+
trackId: sn.trackId,
|
|
5893
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5894
|
+
}
|
|
5895
|
+
});
|
|
5896
|
+
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
5897
|
+
}
|
|
4914
5898
|
return stored;
|
|
4915
5899
|
}
|
|
5900
|
+
/**
|
|
5901
|
+
* Clean subject-centered crop of `bbox` out of the raw frame — the shared
|
|
5902
|
+
* output contract of the object-event `crop` kind and the track `thumbnail`:
|
|
5903
|
+
* square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
|
|
5904
|
+
* (no box drawn), resized to 640×360, JPEG q80.
|
|
5905
|
+
*/
|
|
5906
|
+
async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
|
|
5907
|
+
const region = squareSafeCropRegion(bbox, {
|
|
5908
|
+
W: fw,
|
|
5909
|
+
H: fh
|
|
5910
|
+
}, cropPadding);
|
|
5911
|
+
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
5912
|
+
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
5913
|
+
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
5914
|
+
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
5915
|
+
return await sharp(frameData, { raw: {
|
|
5916
|
+
width: fw,
|
|
5917
|
+
height: fh,
|
|
5918
|
+
channels: 3
|
|
5919
|
+
} }).extract({
|
|
5920
|
+
left,
|
|
5921
|
+
top,
|
|
5922
|
+
width,
|
|
5923
|
+
height
|
|
5924
|
+
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5925
|
+
}
|
|
4916
5926
|
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
4917
5927
|
try {
|
|
4918
5928
|
await this.deps.mediaStore.putReplacing({
|
|
@@ -4940,24 +5950,7 @@ var EventMediaDispatcher = class {
|
|
|
4940
5950
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
4941
5951
|
};
|
|
4942
5952
|
try {
|
|
4943
|
-
const
|
|
4944
|
-
W: fw,
|
|
4945
|
-
H: fh
|
|
4946
|
-
}, cropPadding);
|
|
4947
|
-
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
4948
|
-
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
4949
|
-
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
4950
|
-
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
4951
|
-
const crop = await sharp(frameData, { raw: {
|
|
4952
|
-
width: fw,
|
|
4953
|
-
height: fh,
|
|
4954
|
-
channels: 3
|
|
4955
|
-
} }).extract({
|
|
4956
|
-
left,
|
|
4957
|
-
top,
|
|
4958
|
-
width,
|
|
4959
|
-
height
|
|
4960
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5953
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
4961
5954
|
await this.deps.mediaStore.put({
|
|
4962
5955
|
deviceId,
|
|
4963
5956
|
ownerKind: "event",
|
|
@@ -9646,10 +10639,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
|
9646
10639
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
9647
10640
|
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
9648
10641
|
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
9649
|
-
* `
|
|
10642
|
+
* `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
10643
|
+
*
|
|
10644
|
+
* `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
|
|
10645
|
+
* now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
|
|
10646
|
+
* tracks (no object event → no `crop`) it is the ONLY subject-centered media —
|
|
10647
|
+
* without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
|
|
10648
|
+
* which is how 65% of the timeline tiles rendered as whole scenes. It sits
|
|
10649
|
+
* right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
|
|
9650
10650
|
*/
|
|
9651
10651
|
var CLEAN_MEDIA_KINDS = [
|
|
9652
10652
|
"crop",
|
|
10653
|
+
"thumbnail",
|
|
9653
10654
|
"fullFrame",
|
|
9654
10655
|
"keyFrame"
|
|
9655
10656
|
];
|
|
@@ -9727,6 +10728,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9727
10728
|
stationaryRegistry = null;
|
|
9728
10729
|
mediaStore = null;
|
|
9729
10730
|
eventStore = null;
|
|
10731
|
+
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
10732
|
+
sensorEventStore = null;
|
|
10733
|
+
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
10734
|
+
* so the DeviceStateChanged handler stays cheap. */
|
|
10735
|
+
linkedCamerasCache = null;
|
|
9730
10736
|
identityStore = null;
|
|
9731
10737
|
faceStore = null;
|
|
9732
10738
|
faceRecognizer = null;
|
|
@@ -9777,6 +10783,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9777
10783
|
unsubNativeDetection = null;
|
|
9778
10784
|
unsubBindings = null;
|
|
9779
10785
|
unsubDeviceUnreg = null;
|
|
10786
|
+
/** DeviceStateChanged subscription feeding the sensor-event history. */
|
|
10787
|
+
unsubDeviceState = null;
|
|
9780
10788
|
ttlSweepTimer = null;
|
|
9781
10789
|
retentionSweepTimer = null;
|
|
9782
10790
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
@@ -9872,6 +10880,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9872
10880
|
await TrackStore.declare(api.settingsStore);
|
|
9873
10881
|
await MediaStore.declare(api.settingsStore);
|
|
9874
10882
|
await EventStore.declare(api.settingsStore);
|
|
10883
|
+
await SensorEventStore.declare(api.settingsStore);
|
|
9875
10884
|
await IdentityStore.declare(api.settingsStore);
|
|
9876
10885
|
await FaceStore.declare(api.settingsStore);
|
|
9877
10886
|
await PlateStore.declare(api.settingsStore);
|
|
@@ -9889,7 +10898,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9889
10898
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
9890
10899
|
this.trackStore = new TrackStore({
|
|
9891
10900
|
store: api.settingsStore,
|
|
9892
|
-
logger: logger.child("TrackStore")
|
|
10901
|
+
logger: logger.child("TrackStore"),
|
|
10902
|
+
frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
|
|
9893
10903
|
});
|
|
9894
10904
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9895
10905
|
store: api.settingsStore,
|
|
@@ -9925,6 +10935,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9925
10935
|
logger: logger.child("EventStore"),
|
|
9926
10936
|
media: this.mediaStore
|
|
9927
10937
|
});
|
|
10938
|
+
this.sensorEventStore = new SensorEventStore({
|
|
10939
|
+
store: api.settingsStore,
|
|
10940
|
+
logger: logger.child("SensorEventStore")
|
|
10941
|
+
});
|
|
10942
|
+
this.linkedCamerasCache = new LinkedCamerasCache({
|
|
10943
|
+
cameras: { listCameraIds: async () => {
|
|
10944
|
+
return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
|
|
10945
|
+
} },
|
|
10946
|
+
linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
|
|
10947
|
+
onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
|
|
10948
|
+
scope,
|
|
10949
|
+
error: errMsg(err)
|
|
10950
|
+
} })
|
|
10951
|
+
});
|
|
9928
10952
|
this.identityStore = new IdentityStore({
|
|
9929
10953
|
store: api.settingsStore,
|
|
9930
10954
|
logger: logger.child("IdentityStore")
|
|
@@ -10151,6 +11175,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10151
11175
|
const data = ev.data;
|
|
10152
11176
|
this.handleNativeDetection(data);
|
|
10153
11177
|
});
|
|
11178
|
+
this.unsubDeviceState = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (ev) => {
|
|
11179
|
+
const data = ev.data;
|
|
11180
|
+
if (EVENT_KIND_BY_CAP[data.capName] === void 0) return;
|
|
11181
|
+
const timestamp = ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
|
|
11182
|
+
this.handleSensorStateChanged(data, timestamp);
|
|
11183
|
+
});
|
|
10154
11184
|
if (await this.embeddingEnabledState.get()) {
|
|
10155
11185
|
const encoderClient = {
|
|
10156
11186
|
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
@@ -10516,6 +11546,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10516
11546
|
this.unsubBindings = null;
|
|
10517
11547
|
this.unsubDeviceUnreg?.();
|
|
10518
11548
|
this.unsubDeviceUnreg = null;
|
|
11549
|
+
this.unsubDeviceState?.();
|
|
11550
|
+
this.unsubDeviceState = null;
|
|
10519
11551
|
await this.embeddingDispatcher?.stop();
|
|
10520
11552
|
this.embeddingDispatcher = null;
|
|
10521
11553
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
@@ -10779,7 +11811,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10779
11811
|
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
10780
11812
|
}
|
|
10781
11813
|
}
|
|
10782
|
-
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
11814
|
+
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
|
|
10783
11815
|
const byState = {};
|
|
10784
11816
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
10785
11817
|
log.info("frame processed", { meta: {
|
|
@@ -10789,10 +11821,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10789
11821
|
newTracks: newTrackCount,
|
|
10790
11822
|
lostTracks: lostTrackCount,
|
|
10791
11823
|
objectEvents: result.objectEvents.length,
|
|
11824
|
+
appearanceEvents: result.appearanceEvents.length,
|
|
10792
11825
|
byState
|
|
10793
11826
|
} });
|
|
10794
11827
|
}
|
|
10795
|
-
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
11828
|
+
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
10796
11829
|
const objectEmbeddingBests = [];
|
|
10797
11830
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
10798
11831
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -10810,8 +11843,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10810
11843
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
10811
11844
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
10812
11845
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
10813
|
-
const
|
|
10814
|
-
const
|
|
11846
|
+
const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
|
|
11847
|
+
const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
|
|
11848
|
+
const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
10815
11849
|
const childCrops = childCropsByEvent.get(e.id);
|
|
10816
11850
|
return {
|
|
10817
11851
|
eventId: e.id,
|
|
@@ -11365,7 +12399,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11365
12399
|
movementThreshold: media.snapshotMovementThreshold,
|
|
11366
12400
|
maxIdleMs: media.snapshotMaxIdleMs
|
|
11367
12401
|
}).capture;
|
|
11368
|
-
const
|
|
12402
|
+
const edgeClear = isEdgeClear({
|
|
12403
|
+
bbox: t.bbox,
|
|
12404
|
+
frameWidth,
|
|
12405
|
+
frameHeight
|
|
12406
|
+
});
|
|
12407
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
|
|
11369
12408
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
11370
12409
|
const plan = planPeriodicMedia({
|
|
11371
12410
|
saveThumbnails: media.saveThumbnails,
|
|
@@ -11778,6 +12817,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11778
12817
|
} });
|
|
11779
12818
|
}
|
|
11780
12819
|
await this.mediaStore.evictBefore(now - 31 * day);
|
|
12820
|
+
if (this.sensorEventStore) try {
|
|
12821
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
|
|
12822
|
+
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
12823
|
+
deleted: sensorDeleted,
|
|
12824
|
+
cutoffMs: objectCutoffMs
|
|
12825
|
+
} });
|
|
12826
|
+
} catch (err) {
|
|
12827
|
+
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
12828
|
+
}
|
|
11781
12829
|
if (this.faceStore) try {
|
|
11782
12830
|
const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
|
|
11783
12831
|
const deletedFaceIds = await this.faceStore.pruneAll({
|
|
@@ -12083,6 +13131,68 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12083
13131
|
async listTracks(input) {
|
|
12084
13132
|
return this.trackStore?.queryHistorical(input) ?? [];
|
|
12085
13133
|
}
|
|
13134
|
+
/**
|
|
13135
|
+
* Batched cluster-wide track listing — one merged (`lastSeen` DESC,
|
|
13136
|
+
* `trackId` DESC) page across the requested devices with a stable opaque
|
|
13137
|
+
* cursor. Replaces the per-camera `listTracks` fan-out for the events page
|
|
13138
|
+
* first paint + the reel. See `TrackStore.queryRecent` for the per-device
|
|
13139
|
+
* indexed page + k-way merge and the cursor encoding.
|
|
13140
|
+
*/
|
|
13141
|
+
async listRecentTracks(input) {
|
|
13142
|
+
return this.trackStore?.queryRecent(input) ?? {
|
|
13143
|
+
tracks: [],
|
|
13144
|
+
nextCursor: null
|
|
13145
|
+
};
|
|
13146
|
+
}
|
|
13147
|
+
/**
|
|
13148
|
+
* Every event kind the device can produce: built-ins (motion + audio),
|
|
13149
|
+
* detection classes actually observed on the device, and sensor kinds
|
|
13150
|
+
* from LINKED devices (device-manager `getLinkedDevices`, binding-driven
|
|
13151
|
+
* per linked device). Degrades to the built-ins on error.
|
|
13152
|
+
*/
|
|
13153
|
+
async listEventKinds(input) {
|
|
13154
|
+
const api = this.ctx.api;
|
|
13155
|
+
return composeEventKinds({
|
|
13156
|
+
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
13157
|
+
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
13158
|
+
observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
|
|
13159
|
+
onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
|
|
13160
|
+
tags: { deviceId: input.deviceId },
|
|
13161
|
+
meta: {
|
|
13162
|
+
scope,
|
|
13163
|
+
error: errMsg(err)
|
|
13164
|
+
}
|
|
13165
|
+
})
|
|
13166
|
+
}, input.deviceId);
|
|
13167
|
+
}
|
|
13168
|
+
/** Per-camera sensor-event history (state changes of linked devices). */
|
|
13169
|
+
async getSensorEvents(input) {
|
|
13170
|
+
return this.sensorEventStore?.query(input) ?? [];
|
|
13171
|
+
}
|
|
13172
|
+
/**
|
|
13173
|
+
* Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
|
|
13174
|
+
* mapped sensor cap. Resolves the linked-camera set through the TTL cache
|
|
13175
|
+
* and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
|
|
13176
|
+
*/
|
|
13177
|
+
async handleSensorStateChanged(data, timestamp) {
|
|
13178
|
+
const store = this.sensorEventStore;
|
|
13179
|
+
const cache = this.linkedCamerasCache;
|
|
13180
|
+
if (store === null || cache === null) return;
|
|
13181
|
+
try {
|
|
13182
|
+
await ingestSensorStateChange({
|
|
13183
|
+
sink: store,
|
|
13184
|
+
cache
|
|
13185
|
+
}, data, timestamp);
|
|
13186
|
+
} catch (err) {
|
|
13187
|
+
this.ctx.logger.warn("sensor-event ingest failed", {
|
|
13188
|
+
tags: { deviceId: data.deviceId },
|
|
13189
|
+
meta: {
|
|
13190
|
+
capName: data.capName,
|
|
13191
|
+
error: errMsg(err)
|
|
13192
|
+
}
|
|
13193
|
+
});
|
|
13194
|
+
}
|
|
13195
|
+
}
|
|
12086
13196
|
async clearTracks(input) {
|
|
12087
13197
|
this.trackStore?.clearDevice(input.deviceId);
|
|
12088
13198
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|