@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
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-DU_JRm-j.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -165,13 +165,13 @@ var CLASS_RANK_VEHICLE = .8;
|
|
|
165
165
|
var CLASS_RANK_ANIMAL = .5;
|
|
166
166
|
var CLASS_RANK_DEFAULT = .25;
|
|
167
167
|
var PERSON_CLASSES = new Set(["person", "face"]);
|
|
168
|
-
var VEHICLE_CLASSES = new Set([
|
|
168
|
+
var VEHICLE_CLASSES$1 = new Set([
|
|
169
169
|
"vehicle",
|
|
170
170
|
"car",
|
|
171
171
|
"truck",
|
|
172
172
|
"bus"
|
|
173
173
|
]);
|
|
174
|
-
var ANIMAL_CLASSES = new Set([
|
|
174
|
+
var ANIMAL_CLASSES$1 = new Set([
|
|
175
175
|
"animal",
|
|
176
176
|
"dog",
|
|
177
177
|
"cat"
|
|
@@ -187,8 +187,8 @@ function clamp01(x) {
|
|
|
187
187
|
function classRank(className) {
|
|
188
188
|
const c = className.toLowerCase();
|
|
189
189
|
if (PERSON_CLASSES.has(c)) return 1;
|
|
190
|
-
if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
|
|
191
|
-
if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
|
|
190
|
+
if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
|
|
191
|
+
if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
|
|
192
192
|
return CLASS_RANK_DEFAULT;
|
|
193
193
|
}
|
|
194
194
|
/**
|
|
@@ -1086,7 +1086,7 @@ function resolveDetectionLabel(input) {
|
|
|
1086
1086
|
//#endregion
|
|
1087
1087
|
//#region src/pipeline-analytics/pipeline/zones/geometry.ts
|
|
1088
1088
|
/** Ray-casting point-in-polygon test */
|
|
1089
|
-
function pointInPolygon(point, polygon) {
|
|
1089
|
+
function pointInPolygon$1(point, polygon) {
|
|
1090
1090
|
if (polygon.length < 3) return false;
|
|
1091
1091
|
let inside = false;
|
|
1092
1092
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
@@ -1477,6 +1477,7 @@ var DEFAULT_EVENT_EMITTER_CONFIG = {
|
|
|
1477
1477
|
minTrackAge: 3,
|
|
1478
1478
|
minTrackAgeMs: 0,
|
|
1479
1479
|
cooldownSec: 5,
|
|
1480
|
+
emitAppearance: true,
|
|
1480
1481
|
enabledTypes: [
|
|
1481
1482
|
"object.entering",
|
|
1482
1483
|
"object.leaving",
|
|
@@ -1501,6 +1502,14 @@ var ZONE_TYPE_TO_EVENT = {
|
|
|
1501
1502
|
"zone-loiter": "zone.enter",
|
|
1502
1503
|
"tripwire-cross": "tripwire.cross"
|
|
1503
1504
|
};
|
|
1505
|
+
/**
|
|
1506
|
+
* DetectionEventType used for the synthetic per-track APPEARANCE event
|
|
1507
|
+
* (Requirement 1). `object.detected` is otherwise unused by the emitter (it maps
|
|
1508
|
+
* from no state and no zone type), so repurposing it as the appearance marker
|
|
1509
|
+
* needs no new event-type vocabulary. The addon routes these into their own
|
|
1510
|
+
* media/persistence path and keeps them OFF the notification bus.
|
|
1511
|
+
*/
|
|
1512
|
+
var APPEARANCE_EVENT_TYPE = "object.detected";
|
|
1504
1513
|
var eventIdCounter = 0;
|
|
1505
1514
|
var DetectionEventEmitter = class {
|
|
1506
1515
|
config;
|
|
@@ -1513,6 +1522,13 @@ var DetectionEventEmitter = class {
|
|
|
1513
1522
|
* fired: the latent "0 `left` events in 24h" bug).
|
|
1514
1523
|
*/
|
|
1515
1524
|
lastKnownTracks = /* @__PURE__ */ new Map();
|
|
1525
|
+
/**
|
|
1526
|
+
* Track ids that have already been VALUED — either by a crop-bearing
|
|
1527
|
+
* transition/zone event or by a synthetic appearance event. Ensures a track
|
|
1528
|
+
* gets at most ONE appearance and never a redundant appearance on top of a
|
|
1529
|
+
* real crop-bearing event (Requirement 1).
|
|
1530
|
+
*/
|
|
1531
|
+
valuedTracks = /* @__PURE__ */ new Set();
|
|
1516
1532
|
constructor(config = {}) {
|
|
1517
1533
|
this.config = {
|
|
1518
1534
|
...DEFAULT_EVENT_EMITTER_CONFIG,
|
|
@@ -1538,6 +1554,7 @@ var DetectionEventEmitter = class {
|
|
|
1538
1554
|
if ((now - (this.lastEmitted.get(cooldownKey) ?? 0)) / 1e3 < this.config.cooldownSec) continue;
|
|
1539
1555
|
this.previousStates.set(state.trackId, state.state);
|
|
1540
1556
|
this.lastEmitted.set(cooldownKey, now);
|
|
1557
|
+
if (trackMap.has(state.trackId)) this.valuedTracks.add(state.trackId);
|
|
1541
1558
|
events.push({
|
|
1542
1559
|
id: `evt-${++eventIdCounter}`,
|
|
1543
1560
|
type: eventType,
|
|
@@ -1557,6 +1574,7 @@ var DetectionEventEmitter = class {
|
|
|
1557
1574
|
const track = trackMap.get(ze.trackId);
|
|
1558
1575
|
if (!track || track.trackAge < this.config.minTrackAge) continue;
|
|
1559
1576
|
const state = stateMap.get(ze.trackId);
|
|
1577
|
+
this.valuedTracks.add(ze.trackId);
|
|
1560
1578
|
events.push({
|
|
1561
1579
|
id: `evt-${++eventIdCounter}`,
|
|
1562
1580
|
type: eventType,
|
|
@@ -1575,9 +1593,29 @@ var DetectionEventEmitter = class {
|
|
|
1575
1593
|
trackPath: [...track.path]
|
|
1576
1594
|
});
|
|
1577
1595
|
}
|
|
1596
|
+
if (this.config.emitAppearance) for (const track of tracks) {
|
|
1597
|
+
if (this.valuedTracks.has(track.trackId)) continue;
|
|
1598
|
+
if (track.trackAge < this.config.minTrackAge) continue;
|
|
1599
|
+
const state = stateMap.get(track.trackId);
|
|
1600
|
+
if (!state) continue;
|
|
1601
|
+
if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
|
|
1602
|
+
this.valuedTracks.add(track.trackId);
|
|
1603
|
+
events.push({
|
|
1604
|
+
id: `evt-${++eventIdCounter}`,
|
|
1605
|
+
type: APPEARANCE_EVENT_TYPE,
|
|
1606
|
+
timestamp: now,
|
|
1607
|
+
deviceId,
|
|
1608
|
+
detection: track,
|
|
1609
|
+
classifications,
|
|
1610
|
+
objectState: state,
|
|
1611
|
+
zoneEvents: [],
|
|
1612
|
+
trackPath: [...track.path]
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1578
1615
|
for (const state of states) if (state.state === "leaving") {
|
|
1579
1616
|
this.previousStates.delete(state.trackId);
|
|
1580
1617
|
this.lastKnownTracks.delete(state.trackId);
|
|
1618
|
+
this.valuedTracks.delete(state.trackId);
|
|
1581
1619
|
}
|
|
1582
1620
|
return events;
|
|
1583
1621
|
}
|
|
@@ -1585,6 +1623,7 @@ var DetectionEventEmitter = class {
|
|
|
1585
1623
|
this.previousStates.clear();
|
|
1586
1624
|
this.lastEmitted.clear();
|
|
1587
1625
|
this.lastKnownTracks.clear();
|
|
1626
|
+
this.valuedTracks.clear();
|
|
1588
1627
|
}
|
|
1589
1628
|
};
|
|
1590
1629
|
//#endregion
|
|
@@ -1599,7 +1638,7 @@ function bboxPolygonOverlap(bbox, polygon) {
|
|
|
1599
1638
|
const gridSize = 8;
|
|
1600
1639
|
let inside = 0;
|
|
1601
1640
|
const total = gridSize * gridSize;
|
|
1602
|
-
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon({
|
|
1641
|
+
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon$1({
|
|
1603
1642
|
x: bbox.x + (col + .5) * (bbox.w / gridSize),
|
|
1604
1643
|
y: bbox.y + (row + .5) * (bbox.h / gridSize)
|
|
1605
1644
|
}, polygon)) inside++;
|
|
@@ -1618,7 +1657,7 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
1618
1657
|
for (let my = 0; my < maskHeight; my++) for (let mx = 0; mx < maskWidth; mx++) {
|
|
1619
1658
|
if (mask[my * maskWidth + mx] === 0) continue;
|
|
1620
1659
|
totalMaskPixels++;
|
|
1621
|
-
if (pointInPolygon({
|
|
1660
|
+
if (pointInPolygon$1({
|
|
1622
1661
|
x: bbox.x + mx / maskWidth * bbox.w,
|
|
1623
1662
|
y: bbox.y + my / maskHeight * bbox.h
|
|
1624
1663
|
}, polygon)) insidePolygon++;
|
|
@@ -1947,9 +1986,9 @@ var FrameProcessor = class {
|
|
|
1947
1986
|
} : {}
|
|
1948
1987
|
};
|
|
1949
1988
|
});
|
|
1950
|
-
const
|
|
1989
|
+
const toObjectEvent = (e, forcedState) => {
|
|
1951
1990
|
const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
|
|
1952
|
-
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
1991
|
+
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
1953
1992
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
1954
1993
|
const label = td ? resolveDetectionLabel({
|
|
1955
1994
|
className: td.class,
|
|
@@ -1978,7 +2017,10 @@ var FrameProcessor = class {
|
|
|
1978
2017
|
frameWidth,
|
|
1979
2018
|
frameHeight
|
|
1980
2019
|
};
|
|
1981
|
-
}
|
|
2020
|
+
};
|
|
2021
|
+
const withTrackId = rawEvents.filter((e) => e.detection.trackId);
|
|
2022
|
+
const objectEvents = withTrackId.filter((e) => e.type !== "object.detected").map((e) => toObjectEvent(e));
|
|
2023
|
+
const appearanceEvents = withTrackId.filter((e) => e.type === "object.detected").map((e) => toObjectEvent(e, "entered"));
|
|
1982
2024
|
return {
|
|
1983
2025
|
deviceId: this.deviceId,
|
|
1984
2026
|
timestamp,
|
|
@@ -1986,6 +2028,7 @@ var FrameProcessor = class {
|
|
|
1986
2028
|
frameHeight,
|
|
1987
2029
|
tracked,
|
|
1988
2030
|
objectEvents,
|
|
2031
|
+
appearanceEvents,
|
|
1989
2032
|
rawTrackedDetections: trackedDetections,
|
|
1990
2033
|
stationaryConfirmed: gate.confirmed,
|
|
1991
2034
|
stationaryWoken: gate.wokenEntryIds
|
|
@@ -2071,29 +2114,120 @@ function buildTrackLifecyclePayload(input) {
|
|
|
2071
2114
|
...hasMedia ? { media } : {}
|
|
2072
2115
|
};
|
|
2073
2116
|
}
|
|
2117
|
+
/**
|
|
2118
|
+
* True when `bbox` sits fully inside the frame — no side within the tolerance
|
|
2119
|
+
* band of any border. Degenerate/unknown frame dims (≤ 0) return true so the
|
|
2120
|
+
* gate stays neutral (falls back to pure confidence) rather than demoting every
|
|
2121
|
+
* frame of a dims-less source.
|
|
2122
|
+
*/
|
|
2123
|
+
function isEdgeClear(input) {
|
|
2124
|
+
const { bbox, frameWidth, frameHeight } = input;
|
|
2125
|
+
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
2126
|
+
const tolerance = input.tolerance ?? .01;
|
|
2127
|
+
const tolX = tolerance * frameWidth;
|
|
2128
|
+
const tolY = tolerance * frameHeight;
|
|
2129
|
+
const left = bbox.x;
|
|
2130
|
+
const top = bbox.y;
|
|
2131
|
+
const right = bbox.x + bbox.w;
|
|
2132
|
+
const bottom = bbox.y + bbox.h;
|
|
2133
|
+
if (left <= tolX) return false;
|
|
2134
|
+
if (top <= tolY) return false;
|
|
2135
|
+
if (right >= frameWidth - tolX) return false;
|
|
2136
|
+
if (bottom >= frameHeight - tolY) return false;
|
|
2137
|
+
return true;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Edge-aware "is `candidate` a new best over `current`?" comparator.
|
|
2141
|
+
*
|
|
2142
|
+
* Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
|
|
2143
|
+
* clipped one regardless of confidence). WITHIN the same tier, a strictly-higher
|
|
2144
|
+
* confidence past the `hysteresis` margin wins. The tier upgrade
|
|
2145
|
+
* (touching → clear) bypasses hysteresis — the first clear frame is always taken.
|
|
2146
|
+
*
|
|
2147
|
+
* Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
|
|
2148
|
+
* not here, so this stays a pure value comparison.
|
|
2149
|
+
*/
|
|
2150
|
+
function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
2151
|
+
if (candidate.edgeClear && !current.edgeClear) return true;
|
|
2152
|
+
if (!candidate.edgeClear && current.edgeClear) return false;
|
|
2153
|
+
return candidate.confidence > current.confidence + hysteresis;
|
|
2154
|
+
}
|
|
2074
2155
|
//#endregion
|
|
2075
2156
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
2157
|
+
/**
|
|
2158
|
+
* `BestDetectionTracker` — the SINGLE canonical policy for "the best detection
|
|
2159
|
+
* per track" (highest detector confidence per `trackId`).
|
|
2160
|
+
*
|
|
2161
|
+
* Post-analysis derives three per-track "best" artefacts, all keyed by
|
|
2162
|
+
* trackId + confidence, that used to each re-implement the same ranking inline:
|
|
2163
|
+
* 1. the best FRAME (a boxed `thumbnail`) — `index.ts`,
|
|
2164
|
+
* 2. the best FACE crop + its arcface embedding — `face-recognizer.ts`,
|
|
2165
|
+
* 3. the best CLIP object embedding — `store/object-embedding-store.ts`.
|
|
2166
|
+
*
|
|
2167
|
+
* Consumers 1 & 2 now share THIS one implementation so a track's best frame and
|
|
2168
|
+
* its best face crop derive from the same confidence ranking (and, when the peak
|
|
2169
|
+
* frame carries a detected face, the same frame). Consumer 3 (CLIP) is a
|
|
2170
|
+
* DOCUMENTED SEAM: its `upsertIfBetter` is persistence-backed (its best survives
|
|
2171
|
+
* an addon restart, which this in-memory tracker deliberately does not), so it
|
|
2172
|
+
* keeps its own store-side "if better" gate rather than reading this tracker.
|
|
2173
|
+
*
|
|
2174
|
+
* The FACE seam: the best FRAME is ranked over ALL tracked detections, while the
|
|
2175
|
+
* best FACE can only be captured on a frame that actually produced a face
|
|
2176
|
+
* embedding — so the two legitimately diverge when the peak-confidence frame has
|
|
2177
|
+
* no detected face. Each consumer therefore keeps its OWN payload (crop /
|
|
2178
|
+
* embedding / boxed frame); only the ranking DECISION is unified here.
|
|
2179
|
+
*/
|
|
2076
2180
|
var BestDetectionTracker = class {
|
|
2077
2181
|
hysteresis;
|
|
2078
2182
|
minGapMs;
|
|
2079
2183
|
best = /* @__PURE__ */ new Map();
|
|
2184
|
+
/** Held peak's edge-clear tier, kept PARALLEL to `best` so `peak()`'s
|
|
2185
|
+
* `{ confidence, atMs }` shape (a public contract) stays unchanged. Absent =
|
|
2186
|
+
* the edge tier is not in play for the track (treated as clear → the legacy
|
|
2187
|
+
* pure-confidence policy). */
|
|
2188
|
+
edgeClear = /* @__PURE__ */ new Map();
|
|
2080
2189
|
constructor(options = {}) {
|
|
2081
2190
|
this.hysteresis = options.hysteresis ?? 0;
|
|
2082
2191
|
this.minGapMs = options.minGapMs ?? 0;
|
|
2083
2192
|
}
|
|
2084
2193
|
/**
|
|
2085
2194
|
* Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
|
|
2086
|
-
* Returns true when it becomes the track's new best
|
|
2087
|
-
*
|
|
2088
|
-
* `
|
|
2195
|
+
* Returns true when it becomes the track's new best.
|
|
2196
|
+
*
|
|
2197
|
+
* When `edgeClear` is supplied, an EDGE-AWARE policy applies: an edge-clear
|
|
2198
|
+
* frame (whole subject in view) ALWAYS outranks an edge-touching one (a
|
|
2199
|
+
* clipped, partial subject); the tier upgrade bypasses hysteresis + `minGapMs`
|
|
2200
|
+
* so the first clear frame is always taken. WITHIN the same tier — and when
|
|
2201
|
+
* `edgeClear` is omitted (legacy callers: the face + object-embedding paths) —
|
|
2202
|
+
* the classic policy holds: a confidence past the `hysteresis` margin that also
|
|
2203
|
+
* respects `minGapMs` wins. On acceptance the held peak advances.
|
|
2089
2204
|
*/
|
|
2090
|
-
observe(trackId, confidence, timestamp) {
|
|
2205
|
+
observe(trackId, confidence, timestamp, edgeClear) {
|
|
2091
2206
|
const cur = this.best.get(trackId);
|
|
2092
|
-
|
|
2093
|
-
|
|
2207
|
+
if (cur === void 0) {
|
|
2208
|
+
this.best.set(trackId, {
|
|
2209
|
+
confidence,
|
|
2210
|
+
atMs: timestamp
|
|
2211
|
+
});
|
|
2212
|
+
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2213
|
+
return true;
|
|
2214
|
+
}
|
|
2215
|
+
const curClear = this.edgeClear.get(trackId) ?? true;
|
|
2216
|
+
const candClear = edgeClear ?? true;
|
|
2217
|
+
const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
|
|
2218
|
+
confidence: cur.confidence,
|
|
2219
|
+
edgeClear: curClear
|
|
2220
|
+
}, {
|
|
2094
2221
|
confidence,
|
|
2095
|
-
|
|
2096
|
-
});
|
|
2222
|
+
edgeClear: candClear
|
|
2223
|
+
}, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
|
|
2224
|
+
if (isNewBest) {
|
|
2225
|
+
this.best.set(trackId, {
|
|
2226
|
+
confidence,
|
|
2227
|
+
atMs: timestamp
|
|
2228
|
+
});
|
|
2229
|
+
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2230
|
+
}
|
|
2097
2231
|
return isNewBest;
|
|
2098
2232
|
}
|
|
2099
2233
|
/** The held peak for a track (undefined if never observed). */
|
|
@@ -2103,9 +2237,11 @@ var BestDetectionTracker = class {
|
|
|
2103
2237
|
/** Drop a track's peak (call at track end). */
|
|
2104
2238
|
delete(trackId) {
|
|
2105
2239
|
this.best.delete(trackId);
|
|
2240
|
+
this.edgeClear.delete(trackId);
|
|
2106
2241
|
}
|
|
2107
2242
|
clear() {
|
|
2108
2243
|
this.best.clear();
|
|
2244
|
+
this.edgeClear.clear();
|
|
2109
2245
|
}
|
|
2110
2246
|
};
|
|
2111
2247
|
//#endregion
|
|
@@ -2233,16 +2369,23 @@ function entryToView(e) {
|
|
|
2233
2369
|
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2234
2370
|
};
|
|
2235
2371
|
}
|
|
2236
|
-
/** IoU at/above which a detection is "the same parked object
|
|
2237
|
-
* spawn and refresh
|
|
2372
|
+
/** IoU at/above which a detection is unambiguously "the same parked object,
|
|
2373
|
+
* unmoved" → suppress its spawn and refresh `lastConfirmedAt`. Decision #2. */
|
|
2238
2374
|
var SUPPRESS_IOU = .6;
|
|
2239
|
-
/**
|
|
2240
|
-
*
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
|
|
2375
|
+
/**
|
|
2376
|
+
* Minimum IoU for a same-class detection to be ASSOCIATED with an entry — i.e.
|
|
2377
|
+
* to be considered THIS parked object's own (possibly jittered/shifted) box
|
|
2378
|
+
* rather than a DIFFERENT vehicle. Below this floor a detection can neither
|
|
2379
|
+
* confirm nor wake the entry (it is a different object).
|
|
2380
|
+
*
|
|
2381
|
+
* This overlap gate is the fix for the 617 parking-lot flood: the previous
|
|
2382
|
+
* wake test used a centroid-distance radius of half the frame diagonal, so any
|
|
2383
|
+
* other vehicle merely PRESENT in the lot (a car driving through, or a second
|
|
2384
|
+
* car parked ~120px away) satisfied "near + moved" and retired the parked
|
|
2385
|
+
* entry — the parked car then re-spawned a fresh track and re-flooded, over and
|
|
2386
|
+
* over. Requiring real box overlap means only the entry's OWN box can wake it.
|
|
2387
|
+
*/
|
|
2388
|
+
var WAKE_ASSOC_IOU = .1;
|
|
2246
2389
|
/**
|
|
2247
2390
|
* Look-back window over which a track must have stayed put to be PROMOTED. A
|
|
2248
2391
|
* car that drives in then parks has a large whole-life displacement but a tiny
|
|
@@ -2253,8 +2396,7 @@ var WAKE_SEARCH_FRAC = .5;
|
|
|
2253
2396
|
var PROMOTION_WINDOW_MS = 3e4;
|
|
2254
2397
|
var DEFAULT_MATCH_CONFIG = {
|
|
2255
2398
|
suppressIou: SUPPRESS_IOU,
|
|
2256
|
-
|
|
2257
|
-
wakeSearchFrac: WAKE_SEARCH_FRAC
|
|
2399
|
+
wakeAssocIou: WAKE_ASSOC_IOU
|
|
2258
2400
|
};
|
|
2259
2401
|
//#endregion
|
|
2260
2402
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
|
|
@@ -2301,36 +2443,45 @@ function centroid(b) {
|
|
|
2301
2443
|
y: b.y + b.h / 2
|
|
2302
2444
|
};
|
|
2303
2445
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2446
|
+
/** Is point `p` inside box `b`? Used to distinguish box JITTER (centre stays
|
|
2447
|
+
* inside the parked box → suppress) from the object DEPARTING (centre slides
|
|
2448
|
+
* out while the box still overlaps → wake). */
|
|
2449
|
+
function centroidInside(b, p) {
|
|
2450
|
+
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
|
|
2306
2451
|
}
|
|
2307
2452
|
/**
|
|
2308
2453
|
* Decide, per stationary entry, whether the current frame confirms it, wakes
|
|
2309
2454
|
* it, or misses it (no matching detection — leave it for the TTL sweep).
|
|
2310
2455
|
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2317
|
-
*
|
|
2318
|
-
*
|
|
2456
|
+
* Matching is ASSOCIATION-GATED by box overlap so an entry can only ever be
|
|
2457
|
+
* confirmed or woken by ITS OWN object — never by a different same-class
|
|
2458
|
+
* vehicle merely present elsewhere in the frame (the 617 parking-lot flood:
|
|
2459
|
+
* passing/neighbouring cars used to retire parked entries, forcing endless
|
|
2460
|
+
* re-spawns). Per entry, over the best-overlapping same-class detection:
|
|
2461
|
+
*
|
|
2462
|
+
* - best IoU ≥ `suppressIou` → SUPPRESS (the parked object, near-identical box)
|
|
2463
|
+
* and mark the entry confirmed.
|
|
2464
|
+
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid still INSIDE the parked
|
|
2465
|
+
* box → SUPPRESS (detector box jitter — the box shrank/grew around the same
|
|
2466
|
+
* centre; still the parked object, must not spawn a duplicate track).
|
|
2467
|
+
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
|
|
2468
|
+
* → WAKE (the object's box slid off its spot → it is departing). Not
|
|
2469
|
+
* suppressed, so it spawns a fresh moving track.
|
|
2470
|
+
* - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
|
|
2471
|
+
* different object, or the parked object is momentarily undetected). Neither
|
|
2472
|
+
* suppress nor wake; the TTL sweep retires it only if the absence persists.
|
|
2319
2473
|
*
|
|
2320
2474
|
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
2321
2475
|
* (`suppressedIndices` is a set).
|
|
2322
2476
|
*/
|
|
2323
2477
|
function partitionDetectionsAgainstRegistry(input) {
|
|
2324
|
-
const { entries, detections,
|
|
2478
|
+
const { entries, detections, config } = input;
|
|
2325
2479
|
const suppressed = /* @__PURE__ */ new Set();
|
|
2326
2480
|
const confirmed = [];
|
|
2327
2481
|
const woken = [];
|
|
2328
|
-
const diag = referenceDiagonalPx;
|
|
2329
2482
|
for (const entry of entries) {
|
|
2330
|
-
const ec = centroid(entry.bbox);
|
|
2331
2483
|
let bestIou = 0;
|
|
2332
2484
|
let bestIdx = -1;
|
|
2333
|
-
let wakeCandidate = false;
|
|
2334
2485
|
for (let di = 0; di < detections.length; di++) {
|
|
2335
2486
|
const det = detections[di];
|
|
2336
2487
|
if (det.className !== entry.className) continue;
|
|
@@ -2339,20 +2490,17 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
2339
2490
|
bestIou = o;
|
|
2340
2491
|
bestIdx = di;
|
|
2341
2492
|
}
|
|
2342
|
-
if (diag > 0) {
|
|
2343
|
-
const dc = centroid(det.bbox);
|
|
2344
|
-
const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
|
|
2345
|
-
if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
|
|
2346
|
-
}
|
|
2347
2493
|
}
|
|
2348
|
-
if (
|
|
2494
|
+
if (bestIdx < 0 || bestIou < config.wakeAssocIou) continue;
|
|
2495
|
+
const detCentroid = centroid(detections[bestIdx].bbox);
|
|
2496
|
+
if (bestIou >= config.suppressIou || centroidInside(entry.bbox, detCentroid)) {
|
|
2349
2497
|
suppressed.add(bestIdx);
|
|
2350
2498
|
confirmed.push({
|
|
2351
2499
|
entryId: entry.id,
|
|
2352
2500
|
className: entry.className,
|
|
2353
2501
|
bbox: { ...entry.bbox }
|
|
2354
2502
|
});
|
|
2355
|
-
} else
|
|
2503
|
+
} else woken.push(entry.id);
|
|
2356
2504
|
}
|
|
2357
2505
|
return {
|
|
2358
2506
|
suppressedIndices: suppressed,
|
|
@@ -2529,7 +2677,6 @@ var StationaryObjectRegistry = class {
|
|
|
2529
2677
|
return partitionDetectionsAgainstRegistry({
|
|
2530
2678
|
entries,
|
|
2531
2679
|
detections: input.detections,
|
|
2532
|
-
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2533
2680
|
config: this.matchConfig
|
|
2534
2681
|
});
|
|
2535
2682
|
}
|
|
@@ -2743,7 +2890,7 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2743
2890
|
const matched = [];
|
|
2744
2891
|
for (const zone of zones) {
|
|
2745
2892
|
if (zone.polygon.length < 3) continue;
|
|
2746
|
-
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2893
|
+
if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
|
|
2747
2894
|
}
|
|
2748
2895
|
return matched;
|
|
2749
2896
|
}
|
|
@@ -2960,11 +3107,196 @@ var BindingCache = class {
|
|
|
2960
3107
|
}
|
|
2961
3108
|
};
|
|
2962
3109
|
//#endregion
|
|
3110
|
+
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
3111
|
+
function encodeRecentCursor(cursor) {
|
|
3112
|
+
return Buffer.from(JSON.stringify({
|
|
3113
|
+
l: cursor.lastSeen,
|
|
3114
|
+
i: cursor.trackId
|
|
3115
|
+
}), "utf8").toString("base64url");
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Decode + validate an opaque cursor. Fails fast with a clear error on any
|
|
3119
|
+
* malformed input (bad base64, bad JSON, wrong field types) — a garbage
|
|
3120
|
+
* cursor must never silently degrade into a full-history first page.
|
|
3121
|
+
*/
|
|
3122
|
+
function decodeRecentCursor(raw) {
|
|
3123
|
+
let parsed;
|
|
3124
|
+
try {
|
|
3125
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
3126
|
+
} catch {
|
|
3127
|
+
throw new Error("listRecentTracks: malformed cursor");
|
|
3128
|
+
}
|
|
3129
|
+
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");
|
|
3130
|
+
return {
|
|
3131
|
+
lastSeen: parsed.l,
|
|
3132
|
+
trackId: parsed.i
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
/** Comparator for the (lastSeen DESC, id DESC) total order. */
|
|
3136
|
+
function compareRecentDesc(a, b) {
|
|
3137
|
+
if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
|
|
3138
|
+
if (a.id === b.id) return 0;
|
|
3139
|
+
return a.id < b.id ? 1 : -1;
|
|
3140
|
+
}
|
|
3141
|
+
/** True when `row` sits strictly AFTER the cursor position in DESC order
|
|
3142
|
+
* (i.e. belongs to the next page). */
|
|
3143
|
+
function isAfterCursor(row, cursor) {
|
|
3144
|
+
if (row.lastSeen < cursor.lastSeen) return true;
|
|
3145
|
+
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
3146
|
+
}
|
|
3147
|
+
//#endregion
|
|
3148
|
+
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
3149
|
+
/**
|
|
3150
|
+
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
3151
|
+
* when the frame dimensions are unknown/degenerate or there are no
|
|
3152
|
+
* positions — the caller persists NULL envelope columns in that case.
|
|
3153
|
+
*/
|
|
3154
|
+
function computeTrackEnvelope(positions, frameWidth, frameHeight) {
|
|
3155
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
|
|
3156
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3157
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3158
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3159
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3160
|
+
for (const p of positions) {
|
|
3161
|
+
const x0 = p.bbox.x / frameWidth;
|
|
3162
|
+
const y0 = p.bbox.y / frameHeight;
|
|
3163
|
+
const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
|
|
3164
|
+
const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
|
|
3165
|
+
if (x0 < minX) minX = x0;
|
|
3166
|
+
if (y0 < minY) minY = y0;
|
|
3167
|
+
if (x1 > maxX) maxX = x1;
|
|
3168
|
+
if (y1 > maxY) maxY = y1;
|
|
3169
|
+
}
|
|
3170
|
+
return {
|
|
3171
|
+
minX,
|
|
3172
|
+
minY,
|
|
3173
|
+
maxX,
|
|
3174
|
+
maxY
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
|
|
3179
|
+
* min/max). A degenerate polygon (< 3 points) yields the full frame so the
|
|
3180
|
+
* SQL prefilter never silently drops rows the precise test would keep.
|
|
3181
|
+
*/
|
|
3182
|
+
function zoneBounds(zone) {
|
|
3183
|
+
if (zone.kind === "rect") return {
|
|
3184
|
+
minX: zone.x,
|
|
3185
|
+
minY: zone.y,
|
|
3186
|
+
maxX: zone.x + zone.width,
|
|
3187
|
+
maxY: zone.y + zone.height
|
|
3188
|
+
};
|
|
3189
|
+
if (zone.points.length < 3) return {
|
|
3190
|
+
minX: 0,
|
|
3191
|
+
minY: 0,
|
|
3192
|
+
maxX: 1,
|
|
3193
|
+
maxY: 1
|
|
3194
|
+
};
|
|
3195
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3196
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3197
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3198
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3199
|
+
for (const p of zone.points) {
|
|
3200
|
+
if (p.x < minX) minX = p.x;
|
|
3201
|
+
if (p.y < minY) minY = p.y;
|
|
3202
|
+
if (p.x > maxX) maxX = p.x;
|
|
3203
|
+
if (p.y > maxY) maxY = p.y;
|
|
3204
|
+
}
|
|
3205
|
+
return {
|
|
3206
|
+
minX,
|
|
3207
|
+
minY,
|
|
3208
|
+
maxX,
|
|
3209
|
+
maxY
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
/** Whether two axis-aligned envelopes overlap (touching edges count). */
|
|
3213
|
+
function envelopesOverlap(a, b) {
|
|
3214
|
+
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Ray-casting point-in-polygon (even-odd rule). Points on an edge may
|
|
3218
|
+
* resolve either way — acceptable for zone filtering. A polygon with
|
|
3219
|
+
* fewer than 3 vertices contains nothing.
|
|
3220
|
+
*/
|
|
3221
|
+
function pointInPolygon(point, polygon) {
|
|
3222
|
+
if (polygon.length < 3) return false;
|
|
3223
|
+
let inside = false;
|
|
3224
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
3225
|
+
const a = polygon[i];
|
|
3226
|
+
const b = polygon[j];
|
|
3227
|
+
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;
|
|
3228
|
+
}
|
|
3229
|
+
return inside;
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Precise per-position zone test.
|
|
3233
|
+
*
|
|
3234
|
+
* - rect zone → any position bbox (normalized) intersects the rect.
|
|
3235
|
+
* - polygon zone → any position CENTER (normalized `x`/`y` — positions
|
|
3236
|
+
* store the bbox center) falls inside the polygon.
|
|
3237
|
+
*
|
|
3238
|
+
* Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
|
|
3239
|
+
* PASSES — mirroring the NULL-envelope-matches rule).
|
|
3240
|
+
*/
|
|
3241
|
+
function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
|
|
3242
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
|
|
3243
|
+
if (zone.kind === "rect") {
|
|
3244
|
+
const rect = zoneBounds(zone);
|
|
3245
|
+
for (const p of positions) if (envelopesOverlap({
|
|
3246
|
+
minX: p.bbox.x / frameWidth,
|
|
3247
|
+
minY: p.bbox.y / frameHeight,
|
|
3248
|
+
maxX: (p.bbox.x + p.bbox.w) / frameWidth,
|
|
3249
|
+
maxY: (p.bbox.y + p.bbox.h) / frameHeight
|
|
3250
|
+
}, rect)) return true;
|
|
3251
|
+
return false;
|
|
3252
|
+
}
|
|
3253
|
+
for (const p of positions) if (pointInPolygon({
|
|
3254
|
+
x: p.x / frameWidth,
|
|
3255
|
+
y: p.y / frameHeight
|
|
3256
|
+
}, zone.points)) return true;
|
|
3257
|
+
return false;
|
|
3258
|
+
}
|
|
3259
|
+
//#endregion
|
|
2963
3260
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
2964
3261
|
var DEFAULT_CONFIG = {
|
|
2965
3262
|
ttlMs: 3e4,
|
|
2966
3263
|
maxPositionHistory: 300
|
|
2967
3264
|
};
|
|
3265
|
+
/** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
|
|
3266
|
+
var RECENT_DEFAULT_LIMIT = 200;
|
|
3267
|
+
var RECENT_MAX_LIMIT = 1e3;
|
|
3268
|
+
/** Wide inclusive bound for the envelope-overlap `BETWEEN` prefilter.
|
|
3269
|
+
* Envelope values are normalized ~0..1 but a bbox can spill slightly past
|
|
3270
|
+
* the frame edge; ±1e6 keeps every real value inside the range while the
|
|
3271
|
+
* opposing bound does the actual overlap cut. */
|
|
3272
|
+
var ENV_RANGE_SLACK = 1e6;
|
|
3273
|
+
function rowMatchesZone(data, zone) {
|
|
3274
|
+
const fw = data["frameWidth"];
|
|
3275
|
+
const fh = data["frameHeight"];
|
|
3276
|
+
if (typeof fw !== "number" || typeof fh !== "number") return true;
|
|
3277
|
+
const positions = data["positions"];
|
|
3278
|
+
if (!Array.isArray(positions)) return true;
|
|
3279
|
+
const positionRows = [];
|
|
3280
|
+
for (const p of positions) {
|
|
3281
|
+
if (p === null || typeof p !== "object") continue;
|
|
3282
|
+
if (!("x" in p) || typeof p.x !== "number" || !("y" in p) || typeof p.y !== "number") continue;
|
|
3283
|
+
if (!("bbox" in p) || p.bbox === null || typeof p.bbox !== "object") continue;
|
|
3284
|
+
const b = p.bbox;
|
|
3285
|
+
if (!("x" in b) || typeof b.x !== "number" || !("y" in b) || typeof b.y !== "number") continue;
|
|
3286
|
+
if (!("w" in b) || typeof b.w !== "number" || !("h" in b) || typeof b.h !== "number") continue;
|
|
3287
|
+
positionRows.push({
|
|
3288
|
+
x: p.x,
|
|
3289
|
+
y: p.y,
|
|
3290
|
+
bbox: {
|
|
3291
|
+
x: b.x,
|
|
3292
|
+
y: b.y,
|
|
3293
|
+
w: b.w,
|
|
3294
|
+
h: b.h
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
return positionsIntersectZone(positionRows, fw, fh, zone);
|
|
3299
|
+
}
|
|
2968
3300
|
var TRACKS_COLLECTION = "pipeline-analytics:tracks";
|
|
2969
3301
|
var TRACKS_COLUMNS = [
|
|
2970
3302
|
{
|
|
@@ -3036,6 +3368,30 @@ var TRACKS_COLUMNS = [
|
|
|
3036
3368
|
{
|
|
3037
3369
|
name: "audioLabels",
|
|
3038
3370
|
type: "JSON"
|
|
3371
|
+
},
|
|
3372
|
+
{
|
|
3373
|
+
name: "envMinX",
|
|
3374
|
+
type: "REAL"
|
|
3375
|
+
},
|
|
3376
|
+
{
|
|
3377
|
+
name: "envMinY",
|
|
3378
|
+
type: "REAL"
|
|
3379
|
+
},
|
|
3380
|
+
{
|
|
3381
|
+
name: "envMaxX",
|
|
3382
|
+
type: "REAL"
|
|
3383
|
+
},
|
|
3384
|
+
{
|
|
3385
|
+
name: "envMaxY",
|
|
3386
|
+
type: "REAL"
|
|
3387
|
+
},
|
|
3388
|
+
{
|
|
3389
|
+
name: "frameWidth",
|
|
3390
|
+
type: "INTEGER"
|
|
3391
|
+
},
|
|
3392
|
+
{
|
|
3393
|
+
name: "frameHeight",
|
|
3394
|
+
type: "INTEGER"
|
|
3039
3395
|
}
|
|
3040
3396
|
];
|
|
3041
3397
|
var TRACKS_INDEXES = [{
|
|
@@ -3091,6 +3447,7 @@ var TrackStore = class {
|
|
|
3091
3447
|
config;
|
|
3092
3448
|
logger;
|
|
3093
3449
|
store;
|
|
3450
|
+
frameDims;
|
|
3094
3451
|
constructor(deps) {
|
|
3095
3452
|
this.logger = deps.logger;
|
|
3096
3453
|
this.store = deps.store;
|
|
@@ -3098,6 +3455,7 @@ var TrackStore = class {
|
|
|
3098
3455
|
...DEFAULT_CONFIG,
|
|
3099
3456
|
...deps.config
|
|
3100
3457
|
};
|
|
3458
|
+
this.frameDims = deps.frameDims;
|
|
3101
3459
|
}
|
|
3102
3460
|
/** One-time collection declaration. Call from addon onInitialize. */
|
|
3103
3461
|
static async declare(store) {
|
|
@@ -3444,21 +3802,200 @@ var TrackStore = class {
|
|
|
3444
3802
|
}
|
|
3445
3803
|
return [...seenDevices];
|
|
3446
3804
|
}
|
|
3447
|
-
/** Historical query — hits the persisted collection.
|
|
3805
|
+
/** Historical query — hits the persisted collection. With `zone` set,
|
|
3806
|
+
* candidates are SQL-prefiltered on the envelope columns (overlap test via
|
|
3807
|
+
* `whereBetween`), NULL-envelope rows are re-fetched separately (they must
|
|
3808
|
+
* still MATCH — `BETWEEN` excludes NULL), and survivors run the precise
|
|
3809
|
+
* per-position test against the zone. `projection: 'slim'` drops the heavy
|
|
3810
|
+
* `positions[]` / `snapshots[]` JSON from the returned rows (empty arrays);
|
|
3811
|
+
* the zone test still runs on the stored positions before the drop. */
|
|
3448
3812
|
async queryHistorical(params) {
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
return (await this.store.query.query({
|
|
3813
|
+
const limit = params.limit ?? 50;
|
|
3814
|
+
const timeBetween = params.since !== void 0 || params.until !== void 0 ? { firstSeen: [params.since ?? 0, params.until ?? Date.now()] } : {};
|
|
3815
|
+
if (params.zone === void 0) return (await this.store.query.query({
|
|
3816
|
+
collection: TRACKS_COLLECTION,
|
|
3817
|
+
filter: {
|
|
3818
|
+
where: { deviceId: params.deviceId },
|
|
3819
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3820
|
+
orderBy: {
|
|
3821
|
+
field: "firstSeen",
|
|
3822
|
+
direction: "desc"
|
|
3823
|
+
},
|
|
3824
|
+
limit
|
|
3825
|
+
}
|
|
3826
|
+
})).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3827
|
+
const zone = params.zone;
|
|
3828
|
+
const bounds = zoneBounds(zone);
|
|
3829
|
+
const overlapQuery = this.store.query.query({
|
|
3830
|
+
collection: TRACKS_COLLECTION,
|
|
3831
|
+
filter: {
|
|
3832
|
+
where: { deviceId: params.deviceId },
|
|
3833
|
+
whereBetween: {
|
|
3834
|
+
...timeBetween,
|
|
3835
|
+
envMinX: [-1e6, bounds.maxX],
|
|
3836
|
+
envMaxX: [bounds.minX, ENV_RANGE_SLACK],
|
|
3837
|
+
envMinY: [-1e6, bounds.maxY],
|
|
3838
|
+
envMaxY: [bounds.minY, ENV_RANGE_SLACK]
|
|
3839
|
+
},
|
|
3840
|
+
orderBy: {
|
|
3841
|
+
field: "firstSeen",
|
|
3842
|
+
direction: "desc"
|
|
3843
|
+
},
|
|
3844
|
+
limit
|
|
3845
|
+
}
|
|
3846
|
+
});
|
|
3847
|
+
const nullEnvQuery = this.store.query.query({
|
|
3452
3848
|
collection: TRACKS_COLLECTION,
|
|
3453
3849
|
filter: {
|
|
3454
|
-
|
|
3850
|
+
where: { deviceId: params.deviceId },
|
|
3851
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3455
3852
|
orderBy: {
|
|
3456
3853
|
field: "firstSeen",
|
|
3457
3854
|
direction: "desc"
|
|
3458
3855
|
},
|
|
3459
|
-
limit
|
|
3856
|
+
limit
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
const [overlapRows, windowRows] = await Promise.all([overlapQuery, nullEnvQuery]);
|
|
3860
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
3861
|
+
for (const r of overlapRows) if (typeof r.data["envMinX"] === "number") candidates.set(r.id, r.data);
|
|
3862
|
+
for (const r of windowRows) if (r.data["envMinX"] === null || r.data["envMinX"] === void 0) candidates.set(r.id, r.data);
|
|
3863
|
+
const matched = [];
|
|
3864
|
+
for (const [id, data] of candidates) if (rowMatchesZone(data, zone)) matched.push({
|
|
3865
|
+
id,
|
|
3866
|
+
data
|
|
3867
|
+
});
|
|
3868
|
+
matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
|
|
3869
|
+
return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3870
|
+
}
|
|
3871
|
+
/**
|
|
3872
|
+
* Batched multi-device recent-tracks page (`listRecentTracks`): the
|
|
3873
|
+
* persisted completed tracks of every requested device, merged and ordered
|
|
3874
|
+
* by (`lastSeen` DESC, `trackId` DESC) with a stable opaque cursor.
|
|
3875
|
+
*
|
|
3876
|
+
* Approach — per-device indexed page + k-way merge (documented for the
|
|
3877
|
+
* cap): each device is fetched with ONE indexed query on
|
|
3878
|
+
* `idx_tracks_device_lastSeen` (`WHERE deviceId = ? AND lastSeen BETWEEN
|
|
3879
|
+
* ? AND ? ORDER BY lastSeen DESC LIMIT limit+1`), then the pages are
|
|
3880
|
+
* merged in memory and cut to `limit`. At events-page cardinalities
|
|
3881
|
+
* (≤ dozens of devices × ≤ 1000 rows) the in-memory merge is negligible
|
|
3882
|
+
* next to the row I/O, and every row fetched is a candidate (no scan
|
|
3883
|
+
* waste). The +1 overfetch makes `nextCursor` exact: when the merged
|
|
3884
|
+
* candidate set exceeds `limit` more rows are KNOWN to exist; when it
|
|
3885
|
+
* does not, every device returned fewer rows than asked for and is
|
|
3886
|
+
* therefore exhausted — so the final page always ends with
|
|
3887
|
+
* `nextCursor: null` (no empty trailing page).
|
|
3888
|
+
*
|
|
3889
|
+
* Cursor correctness: SQL can only bound `lastSeen`, and rows sharing the
|
|
3890
|
+
* cursor's exact millisecond have no defined SQL order — so a cursor page
|
|
3891
|
+
* runs TWO ranges per device: an exhaustive same-millisecond tie query
|
|
3892
|
+
* (`lastSeen = cursor.lastSeen`, id tie-break applied in memory) plus the
|
|
3893
|
+
* strictly-older indexed page (`lastSeen ≤ cursor.lastSeen - 1`). Tie
|
|
3894
|
+
* clusters are same-ms track expiries on one camera — physically tiny —
|
|
3895
|
+
* so the unbounded tie query stays O(1) in practice. Known accepted edge:
|
|
3896
|
+
* on a NON-cursor page, a same-ms tie cluster straddling a device's
|
|
3897
|
+
* `limit+1` SQL cut could omit a tie row (needs > limit rows sharing one
|
|
3898
|
+
* millisecond on one camera — unreachable at these cardinalities).
|
|
3899
|
+
*
|
|
3900
|
+
* Errors propagate (no partial merges): a failed device query fails the
|
|
3901
|
+
* page rather than silently returning an incomplete window.
|
|
3902
|
+
*/
|
|
3903
|
+
async queryRecent(params) {
|
|
3904
|
+
const limit = Math.min(Math.max(params.limit ?? RECENT_DEFAULT_LIMIT, 1), RECENT_MAX_LIMIT);
|
|
3905
|
+
const deviceIds = [...new Set(params.deviceIds)];
|
|
3906
|
+
if (deviceIds.length === 0) return {
|
|
3907
|
+
tracks: [],
|
|
3908
|
+
nextCursor: null
|
|
3909
|
+
};
|
|
3910
|
+
const cursor = params.cursor !== void 0 ? decodeRecentCursor(params.cursor) : null;
|
|
3911
|
+
const lo = params.since ?? 0;
|
|
3912
|
+
const winHi = params.until ?? Number.MAX_SAFE_INTEGER;
|
|
3913
|
+
const hi = cursor !== null ? Math.min(cursor.lastSeen, winHi) : winHi;
|
|
3914
|
+
if (hi < lo) return {
|
|
3915
|
+
tracks: [],
|
|
3916
|
+
nextCursor: null
|
|
3917
|
+
};
|
|
3918
|
+
const fetchRange = async (deviceId, range, pageLimit) => this.store.query.query({
|
|
3919
|
+
collection: TRACKS_COLLECTION,
|
|
3920
|
+
filter: {
|
|
3921
|
+
where: { deviceId },
|
|
3922
|
+
whereBetween: { lastSeen: range },
|
|
3923
|
+
orderBy: {
|
|
3924
|
+
field: "lastSeen",
|
|
3925
|
+
direction: "desc"
|
|
3926
|
+
},
|
|
3927
|
+
...pageLimit !== void 0 ? { limit: pageLimit } : {}
|
|
3928
|
+
}
|
|
3929
|
+
});
|
|
3930
|
+
const perDevice = await Promise.all(deviceIds.map(async (deviceId) => {
|
|
3931
|
+
if (cursor === null || cursor.lastSeen > hi) return fetchRange(deviceId, [lo, hi], limit + 1);
|
|
3932
|
+
const tieRange = [cursor.lastSeen, cursor.lastSeen];
|
|
3933
|
+
const belowHi = cursor.lastSeen - 1;
|
|
3934
|
+
const [ties, below] = await Promise.all([cursor.lastSeen >= lo ? fetchRange(deviceId, tieRange) : Promise.resolve([]), belowHi >= lo ? fetchRange(deviceId, [lo, belowHi], limit + 1) : Promise.resolve([])]);
|
|
3935
|
+
return [...ties, ...below];
|
|
3936
|
+
}));
|
|
3937
|
+
const candidates = [];
|
|
3938
|
+
for (const rows of perDevice) for (const r of rows) {
|
|
3939
|
+
const lastSeen = Number(r.data["lastSeen"] ?? 0);
|
|
3940
|
+
if (cursor !== null && !isAfterCursor({
|
|
3941
|
+
lastSeen,
|
|
3942
|
+
id: r.id
|
|
3943
|
+
}, cursor)) continue;
|
|
3944
|
+
candidates.push({
|
|
3945
|
+
id: r.id,
|
|
3946
|
+
lastSeen,
|
|
3947
|
+
data: r.data
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
candidates.sort(compareRecentDesc);
|
|
3951
|
+
const page = candidates.slice(0, limit);
|
|
3952
|
+
const tracks = page.map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3953
|
+
const last = page[page.length - 1];
|
|
3954
|
+
return {
|
|
3955
|
+
tracks,
|
|
3956
|
+
nextCursor: candidates.length > limit && last !== void 0 ? encodeRecentCursor({
|
|
3957
|
+
lastSeen: last.lastSeen,
|
|
3958
|
+
trackId: last.id
|
|
3959
|
+
}) : null
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
/**
|
|
3963
|
+
* Deduplicated detector class names observed on a device's RECENT persisted
|
|
3964
|
+
* tracks (one indexed page, `lastSeen` desc). Feeds `listEventKinds` — a
|
|
3965
|
+
* representative "what has this camera actually seen" set, not an exhaustive
|
|
3966
|
+
* all-time DISTINCT (the query cap has none). Unions the primary `className`
|
|
3967
|
+
* with the accumulated `classes` array. Best-effort: [] on error.
|
|
3968
|
+
*/
|
|
3969
|
+
async observedClassNames(deviceId, limit = 500) {
|
|
3970
|
+
try {
|
|
3971
|
+
const rows = await this.store.query.query({
|
|
3972
|
+
collection: TRACKS_COLLECTION,
|
|
3973
|
+
filter: {
|
|
3974
|
+
where: { deviceId },
|
|
3975
|
+
orderBy: {
|
|
3976
|
+
field: "lastSeen",
|
|
3977
|
+
direction: "desc"
|
|
3978
|
+
},
|
|
3979
|
+
limit
|
|
3980
|
+
}
|
|
3981
|
+
});
|
|
3982
|
+
const names = /* @__PURE__ */ new Set();
|
|
3983
|
+
for (const r of rows) {
|
|
3984
|
+
const className = r.data["className"];
|
|
3985
|
+
if (typeof className === "string" && className.length > 0) names.add(className);
|
|
3986
|
+
const classes = r.data["classes"];
|
|
3987
|
+
if (Array.isArray(classes)) {
|
|
3988
|
+
for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
|
|
3989
|
+
}
|
|
3460
3990
|
}
|
|
3461
|
-
|
|
3991
|
+
return [...names];
|
|
3992
|
+
} catch (err) {
|
|
3993
|
+
this.logger.warn("TrackStore.observedClassNames failed", { meta: {
|
|
3994
|
+
deviceId,
|
|
3995
|
+
error: String(err)
|
|
3996
|
+
} });
|
|
3997
|
+
return [];
|
|
3998
|
+
}
|
|
3462
3999
|
}
|
|
3463
4000
|
async getPersistedByTrackId(trackId) {
|
|
3464
4001
|
const records = await this.store.query.query({
|
|
@@ -3473,6 +4010,8 @@ var TrackStore = class {
|
|
|
3473
4010
|
return this.rowToTrack(row.id, row.data);
|
|
3474
4011
|
}
|
|
3475
4012
|
async persistCompleted(t) {
|
|
4013
|
+
const dims = this.frameDims?.(t.deviceId);
|
|
4014
|
+
const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
|
|
3476
4015
|
await this.store.set.mutate({
|
|
3477
4016
|
collection: TRACKS_COLLECTION,
|
|
3478
4017
|
key: t.trackId,
|
|
@@ -3491,13 +4030,30 @@ var TrackStore = class {
|
|
|
3491
4030
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
3492
4031
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
3493
4032
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3494
|
-
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
4033
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
4034
|
+
...envelope !== null && dims !== void 0 ? {
|
|
4035
|
+
envMinX: envelope.minX,
|
|
4036
|
+
envMinY: envelope.minY,
|
|
4037
|
+
envMaxX: envelope.maxX,
|
|
4038
|
+
envMaxY: envelope.maxY,
|
|
4039
|
+
frameWidth: dims.w,
|
|
4040
|
+
frameHeight: dims.h
|
|
4041
|
+
} : {}
|
|
3495
4042
|
}
|
|
3496
4043
|
});
|
|
3497
4044
|
}
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
4045
|
+
/**
|
|
4046
|
+
* Map a persisted row onto the cap `Track` shape. `projection: 'slim'`
|
|
4047
|
+
* drops the heavy `positions[]` / `snapshots[]` JSON (returned as empty
|
|
4048
|
+
* arrays — they are required on the schema) while keeping every scalar
|
|
4049
|
+
* the list surfaces render; `full` (default) is byte-compatible with the
|
|
4050
|
+
* pre-projection behaviour. The persisted envelope columns surface as the
|
|
4051
|
+
* optional `envelope` object in BOTH projections (four light numbers).
|
|
4052
|
+
*/
|
|
4053
|
+
rowToTrack(id, data, projection) {
|
|
4054
|
+
const slim = projection === "slim";
|
|
4055
|
+
const positions = slim ? [] : data["positions"] ?? [];
|
|
4056
|
+
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
3501
4057
|
const zones = data["zonesVisited"] ?? [];
|
|
3502
4058
|
const classes = data["classes"];
|
|
3503
4059
|
const label = data["label"];
|
|
@@ -3505,6 +4061,16 @@ var TrackStore = class {
|
|
|
3505
4061
|
const bestEventId = data["bestEventId"];
|
|
3506
4062
|
const importanceReason = data["importanceReason"];
|
|
3507
4063
|
const audioLabels = data["audioLabels"];
|
|
4064
|
+
const envMinX = data["envMinX"];
|
|
4065
|
+
const envMinY = data["envMinY"];
|
|
4066
|
+
const envMaxX = data["envMaxX"];
|
|
4067
|
+
const envMaxY = data["envMaxY"];
|
|
4068
|
+
const envelope = typeof envMinX === "number" && typeof envMinY === "number" && typeof envMaxX === "number" && typeof envMaxY === "number" ? {
|
|
4069
|
+
minX: envMinX,
|
|
4070
|
+
minY: envMinY,
|
|
4071
|
+
maxX: envMaxX,
|
|
4072
|
+
maxY: envMaxY
|
|
4073
|
+
} : null;
|
|
3508
4074
|
return {
|
|
3509
4075
|
trackId: id,
|
|
3510
4076
|
deviceId: Number(data["deviceId"]),
|
|
@@ -3522,7 +4088,8 @@ var TrackStore = class {
|
|
|
3522
4088
|
...typeof importance === "number" ? { importance } : {},
|
|
3523
4089
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
3524
4090
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3525
|
-
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
4091
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
4092
|
+
...envelope !== null ? { envelope } : {}
|
|
3526
4093
|
};
|
|
3527
4094
|
}
|
|
3528
4095
|
};
|
|
@@ -4292,10 +4859,21 @@ var EventStore = class {
|
|
|
4292
4859
|
}
|
|
4293
4860
|
/**
|
|
4294
4861
|
* The track's highest-confidence object event, its bbox area (as a fraction of
|
|
4295
|
-
* frame area), and
|
|
4296
|
-
* for the best frame, read back from the persisted object events (index
|
|
4862
|
+
* frame area), and its representative event id — the SHARED per-track ranking
|
|
4863
|
+
* used for the best frame, read back from the persisted object events (index
|
|
4297
4864
|
* `idx_object_track`). Returns zeros + undefined id when the track has none.
|
|
4298
4865
|
* Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
|
|
4866
|
+
*
|
|
4867
|
+
* `peakConfidence` + `peakBboxAreaFrac` always track the TRUE confidence peak
|
|
4868
|
+
* (over every crop-bearing row) — importance reads them.
|
|
4869
|
+
*
|
|
4870
|
+
* `bestEventId` treats the synthetic APPEARANCE / entry event (state
|
|
4871
|
+
* `'entered'`, Requirement 1) as a FLOOR: it represents the track only when no
|
|
4872
|
+
* genuine ACTIVITY event (any other state — a zone / stationary / moving
|
|
4873
|
+
* moment) exists. So a degenerate passer-by whose only crop-bearing event is
|
|
4874
|
+
* its appearance still gets a representative id, while a track that later
|
|
4875
|
+
* produced a real activity event surfaces THAT as its best. Within a tier the
|
|
4876
|
+
* highest confidence wins.
|
|
4299
4877
|
*/
|
|
4300
4878
|
async peakForTrack(trackId) {
|
|
4301
4879
|
const rows = await this.store.query.query({
|
|
@@ -4303,8 +4881,11 @@ var EventStore = class {
|
|
|
4303
4881
|
filter: { where: { trackId } }
|
|
4304
4882
|
});
|
|
4305
4883
|
let bestConf = -1;
|
|
4306
|
-
let bestEventId;
|
|
4307
4884
|
let peakBboxAreaFrac = 0;
|
|
4885
|
+
let activityConf = -1;
|
|
4886
|
+
let activityId;
|
|
4887
|
+
let entryConf = -1;
|
|
4888
|
+
let entryId;
|
|
4308
4889
|
for (const row of rows) {
|
|
4309
4890
|
const bbox = row.data["bbox"];
|
|
4310
4891
|
if (bbox !== null && typeof bbox === "object") {
|
|
@@ -4313,15 +4894,24 @@ var EventStore = class {
|
|
|
4313
4894
|
if (bw <= 0 || bh <= 0) continue;
|
|
4314
4895
|
}
|
|
4315
4896
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
4316
|
-
if (conf
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4897
|
+
if (conf > bestConf) {
|
|
4898
|
+
bestConf = conf;
|
|
4899
|
+
peakBboxAreaFrac = bboxAreaFrac(row.data);
|
|
4900
|
+
}
|
|
4901
|
+
if (row.data["state"] === "entered") {
|
|
4902
|
+
if (conf > entryConf) {
|
|
4903
|
+
entryConf = conf;
|
|
4904
|
+
entryId = row.id;
|
|
4905
|
+
}
|
|
4906
|
+
} else if (conf > activityConf) {
|
|
4907
|
+
activityConf = conf;
|
|
4908
|
+
activityId = row.id;
|
|
4909
|
+
}
|
|
4320
4910
|
}
|
|
4321
4911
|
return {
|
|
4322
4912
|
peakConfidence: bestConf < 0 ? 0 : bestConf,
|
|
4323
4913
|
peakBboxAreaFrac,
|
|
4324
|
-
bestEventId
|
|
4914
|
+
bestEventId: activityId ?? entryId
|
|
4325
4915
|
};
|
|
4326
4916
|
}
|
|
4327
4917
|
/**
|
|
@@ -4633,6 +5223,384 @@ function stripNulls(data) {
|
|
|
4633
5223
|
return out;
|
|
4634
5224
|
}
|
|
4635
5225
|
//#endregion
|
|
5226
|
+
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
5227
|
+
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
5228
|
+
var SENSOR_EVENT_COLUMNS = [
|
|
5229
|
+
{
|
|
5230
|
+
name: "id",
|
|
5231
|
+
type: "TEXT",
|
|
5232
|
+
primaryKey: true,
|
|
5233
|
+
notNull: true
|
|
5234
|
+
},
|
|
5235
|
+
(
|
|
5236
|
+
/** The CAMERA the event is attributed to. */
|
|
5237
|
+
{
|
|
5238
|
+
name: "deviceId",
|
|
5239
|
+
type: "INTEGER",
|
|
5240
|
+
notNull: true
|
|
5241
|
+
}),
|
|
5242
|
+
(
|
|
5243
|
+
/** The linked sensor device whose state changed. */
|
|
5244
|
+
{
|
|
5245
|
+
name: "sourceDeviceId",
|
|
5246
|
+
type: "INTEGER",
|
|
5247
|
+
notNull: true
|
|
5248
|
+
}),
|
|
5249
|
+
(
|
|
5250
|
+
/** Event kind id (matches an `EventKindDescriptor.kind`). */
|
|
5251
|
+
{
|
|
5252
|
+
name: "kind",
|
|
5253
|
+
type: "TEXT",
|
|
5254
|
+
notNull: true
|
|
5255
|
+
}),
|
|
5256
|
+
(
|
|
5257
|
+
/** Snapshot of the sensor cap's runtime-state slice at the change. */
|
|
5258
|
+
{
|
|
5259
|
+
name: "value",
|
|
5260
|
+
type: "JSON"
|
|
5261
|
+
}),
|
|
5262
|
+
{
|
|
5263
|
+
name: "timestamp",
|
|
5264
|
+
type: "INTEGER",
|
|
5265
|
+
notNull: true
|
|
5266
|
+
}
|
|
5267
|
+
];
|
|
5268
|
+
var SENSOR_EVENT_INDEXES = [{
|
|
5269
|
+
name: "idx_sensor_events_device_ts",
|
|
5270
|
+
columns: ["deviceId", "timestamp"]
|
|
5271
|
+
}];
|
|
5272
|
+
var DEFAULT_QUERY_LIMIT = 1e3;
|
|
5273
|
+
var SensorEventStore = class {
|
|
5274
|
+
store;
|
|
5275
|
+
logger;
|
|
5276
|
+
constructor(deps) {
|
|
5277
|
+
this.store = deps.store;
|
|
5278
|
+
this.logger = deps.logger;
|
|
5279
|
+
}
|
|
5280
|
+
/** One-time collection declaration. Call from addon onInitialize. */
|
|
5281
|
+
static async declare(store) {
|
|
5282
|
+
await store.declareCollection.mutate({
|
|
5283
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5284
|
+
columns: [...SENSOR_EVENT_COLUMNS],
|
|
5285
|
+
indexes: [...SENSOR_EVENT_INDEXES]
|
|
5286
|
+
});
|
|
5287
|
+
}
|
|
5288
|
+
/** Insert one attributed sensor event. Best-effort (telemetry-lossy). */
|
|
5289
|
+
async insert(ev) {
|
|
5290
|
+
try {
|
|
5291
|
+
await this.store.insert.mutate({
|
|
5292
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5293
|
+
record: {
|
|
5294
|
+
id: ev.id,
|
|
5295
|
+
data: {
|
|
5296
|
+
deviceId: ev.deviceId,
|
|
5297
|
+
sourceDeviceId: ev.sourceDeviceId,
|
|
5298
|
+
kind: ev.kind,
|
|
5299
|
+
value: ev.value,
|
|
5300
|
+
timestamp: ev.timestamp
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
5303
|
+
});
|
|
5304
|
+
} catch (err) {
|
|
5305
|
+
this.logger.warn("SensorEventStore.insert failed", {
|
|
5306
|
+
tags: { deviceId: ev.deviceId },
|
|
5307
|
+
meta: {
|
|
5308
|
+
eventId: ev.id,
|
|
5309
|
+
error: String(err)
|
|
5310
|
+
}
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
}
|
|
5314
|
+
/** Per-camera sensor-event history, newest first. Mirrors the
|
|
5315
|
+
* motion/object/audio query semantics; `kinds` narrows via `whereIn`. */
|
|
5316
|
+
async query(q) {
|
|
5317
|
+
const filter = {
|
|
5318
|
+
where: { deviceId: q.deviceId },
|
|
5319
|
+
orderBy: {
|
|
5320
|
+
field: "timestamp",
|
|
5321
|
+
direction: "desc"
|
|
5322
|
+
},
|
|
5323
|
+
limit: q.limit ?? DEFAULT_QUERY_LIMIT
|
|
5324
|
+
};
|
|
5325
|
+
if (q.since !== void 0 || q.until !== void 0) filter["whereBetween"] = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
|
|
5326
|
+
if (q.kinds !== void 0 && q.kinds.length > 0) filter["whereIn"] = { kind: [...q.kinds] };
|
|
5327
|
+
return (await this.store.query.query({
|
|
5328
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5329
|
+
filter
|
|
5330
|
+
})).map((r) => rowToSensorEvent(r.id, r.data));
|
|
5331
|
+
}
|
|
5332
|
+
/**
|
|
5333
|
+
* Delete every row with `timestamp ≤ cutoffMs`, draining a page at a time
|
|
5334
|
+
* (mirrors `EventStore.evictBefore`, including the infinite-loop guard).
|
|
5335
|
+
* Returns the number of rows deleted. Rides the analytics retention sweep.
|
|
5336
|
+
*/
|
|
5337
|
+
async evictBefore(cutoffMs) {
|
|
5338
|
+
let deleted = 0;
|
|
5339
|
+
for (;;) {
|
|
5340
|
+
const rows = await this.store.query.query({
|
|
5341
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5342
|
+
filter: {
|
|
5343
|
+
whereBetween: { timestamp: [0, cutoffMs] },
|
|
5344
|
+
limit: EVICT_PAGE_SIZE
|
|
5345
|
+
}
|
|
5346
|
+
});
|
|
5347
|
+
if (rows.length === 0) break;
|
|
5348
|
+
let deletedInPage = 0;
|
|
5349
|
+
for (const row of rows) {
|
|
5350
|
+
if (typeof row.id !== "string") continue;
|
|
5351
|
+
try {
|
|
5352
|
+
await this.store.delete.mutate({
|
|
5353
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5354
|
+
key: row.id
|
|
5355
|
+
});
|
|
5356
|
+
deleted++;
|
|
5357
|
+
deletedInPage++;
|
|
5358
|
+
} catch {}
|
|
5359
|
+
}
|
|
5360
|
+
if (deletedInPage === 0) break;
|
|
5361
|
+
}
|
|
5362
|
+
return deleted;
|
|
5363
|
+
}
|
|
5364
|
+
};
|
|
5365
|
+
/** Page size for the eviction drain loop (mirrors EventStore.PRUNE_PAGE_SIZE). */
|
|
5366
|
+
var EVICT_PAGE_SIZE = 500;
|
|
5367
|
+
function rowToSensorEvent(id, data) {
|
|
5368
|
+
const value = data["value"];
|
|
5369
|
+
return {
|
|
5370
|
+
id,
|
|
5371
|
+
deviceId: Number(data["deviceId"]),
|
|
5372
|
+
sourceDeviceId: Number(data["sourceDeviceId"]),
|
|
5373
|
+
kind: String(data["kind"]),
|
|
5374
|
+
value: isRecord(value) ? value : null,
|
|
5375
|
+
timestamp: Number(data["timestamp"])
|
|
5376
|
+
};
|
|
5377
|
+
}
|
|
5378
|
+
function isRecord(x) {
|
|
5379
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
5380
|
+
}
|
|
5381
|
+
//#endregion
|
|
5382
|
+
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
5383
|
+
/**
|
|
5384
|
+
* Extensible per-device event kinds (Part B).
|
|
5385
|
+
*
|
|
5386
|
+
* `composeEventKinds` builds the `listEventKinds` payload for a camera:
|
|
5387
|
+
* (a) built-ins — motion + audio, always present;
|
|
5388
|
+
* (b) detection classes actually OBSERVED on the device (track history);
|
|
5389
|
+
* (c) sensor kinds contributed by LINKED devices (device-manager
|
|
5390
|
+
* `getLinkedDevices`), one descriptor per bound sensor cap present in
|
|
5391
|
+
* the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
|
|
5392
|
+
* (`getBindings`) — never a global cap enumeration (D12).
|
|
5393
|
+
*
|
|
5394
|
+
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
5395
|
+
* linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
|
|
5396
|
+
* cheap at bus rate.
|
|
5397
|
+
*/
|
|
5398
|
+
var MOTION_COLOR = "#f59e0b";
|
|
5399
|
+
var AUDIO_COLOR = "#06b6d4";
|
|
5400
|
+
var PERSON_COLOR = "#22c55e";
|
|
5401
|
+
var VEHICLE_COLOR = "#3b82f6";
|
|
5402
|
+
var ANIMAL_COLOR = "#f97316";
|
|
5403
|
+
var GENERIC_DETECTION_COLOR = "#64748b";
|
|
5404
|
+
var VEHICLE_CLASSES = new Set([
|
|
5405
|
+
"vehicle",
|
|
5406
|
+
"car",
|
|
5407
|
+
"truck",
|
|
5408
|
+
"bus",
|
|
5409
|
+
"motorcycle",
|
|
5410
|
+
"bicycle",
|
|
5411
|
+
"boat",
|
|
5412
|
+
"train"
|
|
5413
|
+
]);
|
|
5414
|
+
var ANIMAL_CLASSES = new Set([
|
|
5415
|
+
"animal",
|
|
5416
|
+
"dog",
|
|
5417
|
+
"cat",
|
|
5418
|
+
"bird",
|
|
5419
|
+
"horse",
|
|
5420
|
+
"cow",
|
|
5421
|
+
"sheep"
|
|
5422
|
+
]);
|
|
5423
|
+
function detectionIcon(className) {
|
|
5424
|
+
if (className === "person") return "person";
|
|
5425
|
+
if (VEHICLE_CLASSES.has(className)) return "vehicle";
|
|
5426
|
+
if (ANIMAL_CLASSES.has(className)) return "animal";
|
|
5427
|
+
return "generic";
|
|
5428
|
+
}
|
|
5429
|
+
function detectionColor(className) {
|
|
5430
|
+
if (className === "person") return PERSON_COLOR;
|
|
5431
|
+
if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
|
|
5432
|
+
if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
|
|
5433
|
+
return GENERIC_DETECTION_COLOR;
|
|
5434
|
+
}
|
|
5435
|
+
function titleCase(s) {
|
|
5436
|
+
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
5437
|
+
}
|
|
5438
|
+
/**
|
|
5439
|
+
* Full event-kind list for a camera. Sensor kinds are deduped per
|
|
5440
|
+
* (kind, source deviceId) — two linked contact sensors each contribute
|
|
5441
|
+
* their own entry, distinguishable by `source.deviceId`.
|
|
5442
|
+
*/
|
|
5443
|
+
async function composeEventKinds(deps, deviceId) {
|
|
5444
|
+
const out = [{
|
|
5445
|
+
kind: "motion",
|
|
5446
|
+
label: "Motion",
|
|
5447
|
+
color: MOTION_COLOR,
|
|
5448
|
+
icon: "motion",
|
|
5449
|
+
category: "motion",
|
|
5450
|
+
source: {
|
|
5451
|
+
capName: "pipeline-analytics",
|
|
5452
|
+
deviceId
|
|
5453
|
+
}
|
|
5454
|
+
}, {
|
|
5455
|
+
kind: "audio",
|
|
5456
|
+
label: "Audio",
|
|
5457
|
+
color: AUDIO_COLOR,
|
|
5458
|
+
icon: "audio",
|
|
5459
|
+
category: "audio",
|
|
5460
|
+
source: {
|
|
5461
|
+
capName: "pipeline-analytics",
|
|
5462
|
+
deviceId
|
|
5463
|
+
}
|
|
5464
|
+
}];
|
|
5465
|
+
try {
|
|
5466
|
+
const classNames = await deps.observedClassNames(deviceId);
|
|
5467
|
+
for (const className of [...classNames].sort()) out.push({
|
|
5468
|
+
kind: className,
|
|
5469
|
+
label: titleCase(className),
|
|
5470
|
+
color: detectionColor(className),
|
|
5471
|
+
icon: detectionIcon(className),
|
|
5472
|
+
category: "detection",
|
|
5473
|
+
source: {
|
|
5474
|
+
capName: "pipeline-analytics",
|
|
5475
|
+
deviceId
|
|
5476
|
+
}
|
|
5477
|
+
});
|
|
5478
|
+
} catch (err) {
|
|
5479
|
+
deps.onError?.("observedClassNames", err);
|
|
5480
|
+
}
|
|
5481
|
+
try {
|
|
5482
|
+
const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
|
|
5483
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5484
|
+
for (const linked of devices) {
|
|
5485
|
+
let capNames;
|
|
5486
|
+
try {
|
|
5487
|
+
const { entries } = await deps.bindings.getBindings({ deviceId: linked.deviceId });
|
|
5488
|
+
capNames = entries.map((e) => e.capName);
|
|
5489
|
+
} catch (err) {
|
|
5490
|
+
deps.onError?.("getBindings", err);
|
|
5491
|
+
continue;
|
|
5492
|
+
}
|
|
5493
|
+
for (const capName of capNames) {
|
|
5494
|
+
const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
|
|
5495
|
+
if (descriptor === void 0) continue;
|
|
5496
|
+
const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
|
|
5497
|
+
if (seen.has(dedupeKey)) continue;
|
|
5498
|
+
seen.add(dedupeKey);
|
|
5499
|
+
out.push({
|
|
5500
|
+
kind: descriptor.kind,
|
|
5501
|
+
label: descriptor.label,
|
|
5502
|
+
color: descriptor.color,
|
|
5503
|
+
icon: descriptor.icon,
|
|
5504
|
+
category: descriptor.category,
|
|
5505
|
+
source: {
|
|
5506
|
+
capName,
|
|
5507
|
+
deviceId: linked.deviceId
|
|
5508
|
+
}
|
|
5509
|
+
});
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
} catch (err) {
|
|
5513
|
+
deps.onError?.("getLinkedDevices", err);
|
|
5514
|
+
}
|
|
5515
|
+
return out;
|
|
5516
|
+
}
|
|
5517
|
+
var DEFAULT_CACHE_TTL_MS = 6e4;
|
|
5518
|
+
/**
|
|
5519
|
+
* TTL-cached reverse index: source deviceId → camera ids it is linked to.
|
|
5520
|
+
* Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
|
|
5521
|
+
* handler pays one map lookup per event in the common case.
|
|
5522
|
+
*/
|
|
5523
|
+
var LinkedCamerasCache = class {
|
|
5524
|
+
deps;
|
|
5525
|
+
ttlMs;
|
|
5526
|
+
index = /* @__PURE__ */ new Map();
|
|
5527
|
+
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
5528
|
+
builtAt = null;
|
|
5529
|
+
building = null;
|
|
5530
|
+
constructor(deps) {
|
|
5531
|
+
this.deps = deps;
|
|
5532
|
+
this.ttlMs = deps.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
5533
|
+
}
|
|
5534
|
+
/** Camera ids linked to `sourceDeviceId` ([] when none). */
|
|
5535
|
+
async camerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
5536
|
+
if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
|
|
5537
|
+
this.building ??= this.rebuild(nowMs).finally(() => {
|
|
5538
|
+
this.building = null;
|
|
5539
|
+
});
|
|
5540
|
+
await this.building;
|
|
5541
|
+
}
|
|
5542
|
+
return this.index.get(sourceDeviceId) ?? [];
|
|
5543
|
+
}
|
|
5544
|
+
/** Drop the cached index (e.g. on link-topology change events). */
|
|
5545
|
+
invalidate() {
|
|
5546
|
+
this.builtAt = null;
|
|
5547
|
+
}
|
|
5548
|
+
/** Test/maintenance hook: replace the index directly. */
|
|
5549
|
+
seed(index, builtAt) {
|
|
5550
|
+
this.index = new Map(index);
|
|
5551
|
+
this.builtAt = builtAt;
|
|
5552
|
+
}
|
|
5553
|
+
async rebuild(nowMs) {
|
|
5554
|
+
try {
|
|
5555
|
+
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
5556
|
+
const next = /* @__PURE__ */ new Map();
|
|
5557
|
+
for (const cameraId of cameraIds) try {
|
|
5558
|
+
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
5559
|
+
for (const d of devices) {
|
|
5560
|
+
const list = next.get(d.deviceId);
|
|
5561
|
+
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
5562
|
+
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
5563
|
+
}
|
|
5564
|
+
} catch (err) {
|
|
5565
|
+
this.deps.onError?.("getLinkedDevices", err);
|
|
5566
|
+
}
|
|
5567
|
+
this.index = next;
|
|
5568
|
+
this.builtAt = nowMs;
|
|
5569
|
+
} catch (err) {
|
|
5570
|
+
this.deps.onError?.("listCameraIds", err);
|
|
5571
|
+
this.builtAt = nowMs;
|
|
5572
|
+
}
|
|
5573
|
+
}
|
|
5574
|
+
};
|
|
5575
|
+
/**
|
|
5576
|
+
* One `DeviceStateChanged` → N history rows (one per linked camera). The
|
|
5577
|
+
* EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
|
|
5578
|
+
* lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
|
|
5579
|
+
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
5580
|
+
*/
|
|
5581
|
+
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
5582
|
+
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
5583
|
+
if (descriptor === void 0) return 0;
|
|
5584
|
+
const cameraIds = await deps.cache.camerasFor(data.deviceId);
|
|
5585
|
+
if (cameraIds.length === 0) return 0;
|
|
5586
|
+
const slice = data.slice;
|
|
5587
|
+
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
5588
|
+
const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
|
|
5589
|
+
let inserted = 0;
|
|
5590
|
+
for (const cameraId of cameraIds) {
|
|
5591
|
+
await deps.sink.insert({
|
|
5592
|
+
id: makeId(),
|
|
5593
|
+
deviceId: cameraId,
|
|
5594
|
+
sourceDeviceId: data.deviceId,
|
|
5595
|
+
kind: descriptor.kind,
|
|
5596
|
+
value,
|
|
5597
|
+
timestamp
|
|
5598
|
+
});
|
|
5599
|
+
inserted++;
|
|
5600
|
+
}
|
|
5601
|
+
return inserted;
|
|
5602
|
+
}
|
|
5603
|
+
//#endregion
|
|
4636
5604
|
//#region src/shared/frame/resolve-frame.ts
|
|
4637
5605
|
/**
|
|
4638
5606
|
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
@@ -4866,22 +5834,26 @@ var EventMediaDispatcher = class {
|
|
|
4866
5834
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
4867
5835
|
const storedSnapshots = [];
|
|
4868
5836
|
for (const sn of snapshots) {
|
|
4869
|
-
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
5837
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
|
|
4870
5838
|
if (stored) storedSnapshots.push(stored);
|
|
4871
5839
|
}
|
|
4872
5840
|
return { storedSnapshots };
|
|
4873
5841
|
}
|
|
4874
5842
|
/**
|
|
4875
|
-
* Periodic per-track media (§5).
|
|
4876
|
-
*
|
|
4877
|
-
* (
|
|
4878
|
-
*
|
|
4879
|
-
*
|
|
5843
|
+
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
5844
|
+
* shared by the appended `snapshot` (timeline filmstrip) and the rolling
|
|
5845
|
+
* `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
|
|
5846
|
+
* subject-centered crop (same output contract as the object-event `crop`
|
|
5847
|
+
* kind) — it is the gallery/reel fallback for tracks that never produced an
|
|
5848
|
+
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
5849
|
+
* parked car), not the track's subject. Returns the appended snapshot for
|
|
5850
|
+
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
5851
|
+
* failed).
|
|
4880
5852
|
*/
|
|
4881
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
5853
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
4882
5854
|
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
4883
|
-
let boxed;
|
|
4884
|
-
try {
|
|
5855
|
+
let boxed = null;
|
|
5856
|
+
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
4885
5857
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
4886
5858
|
...sn.bbox,
|
|
4887
5859
|
...sn.label ? { label: sn.label } : {}
|
|
@@ -4895,10 +5867,9 @@ var EventMediaDispatcher = class {
|
|
|
4895
5867
|
error: err instanceof Error ? err.message : String(err)
|
|
4896
5868
|
}
|
|
4897
5869
|
});
|
|
4898
|
-
return null;
|
|
4899
5870
|
}
|
|
4900
5871
|
let stored = null;
|
|
4901
|
-
if (sn.appendSnapshot) try {
|
|
5872
|
+
if (sn.appendSnapshot && boxed) try {
|
|
4902
5873
|
const mediaKey = await this.deps.mediaStore.put({
|
|
4903
5874
|
deviceId,
|
|
4904
5875
|
ownerKind: "track",
|
|
@@ -4914,10 +5885,49 @@ var EventMediaDispatcher = class {
|
|
|
4914
5885
|
bbox: sn.bbox
|
|
4915
5886
|
};
|
|
4916
5887
|
} catch {}
|
|
4917
|
-
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
4918
|
-
if (sn.bestThumbnail)
|
|
5888
|
+
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
5889
|
+
if (sn.bestThumbnail) try {
|
|
5890
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
5891
|
+
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
5892
|
+
} catch (err) {
|
|
5893
|
+
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
5894
|
+
tags: { deviceId },
|
|
5895
|
+
meta: {
|
|
5896
|
+
deviceId,
|
|
5897
|
+
trackId: sn.trackId,
|
|
5898
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5899
|
+
}
|
|
5900
|
+
});
|
|
5901
|
+
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
5902
|
+
}
|
|
4919
5903
|
return stored;
|
|
4920
5904
|
}
|
|
5905
|
+
/**
|
|
5906
|
+
* Clean subject-centered crop of `bbox` out of the raw frame — the shared
|
|
5907
|
+
* output contract of the object-event `crop` kind and the track `thumbnail`:
|
|
5908
|
+
* square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
|
|
5909
|
+
* (no box drawn), resized to 640×360, JPEG q80.
|
|
5910
|
+
*/
|
|
5911
|
+
async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
|
|
5912
|
+
const region = squareSafeCropRegion(bbox, {
|
|
5913
|
+
W: fw,
|
|
5914
|
+
H: fh
|
|
5915
|
+
}, cropPadding);
|
|
5916
|
+
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
5917
|
+
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
5918
|
+
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
5919
|
+
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
5920
|
+
return await (0, sharp.default)(frameData, { raw: {
|
|
5921
|
+
width: fw,
|
|
5922
|
+
height: fh,
|
|
5923
|
+
channels: 3
|
|
5924
|
+
} }).extract({
|
|
5925
|
+
left,
|
|
5926
|
+
top,
|
|
5927
|
+
width,
|
|
5928
|
+
height
|
|
5929
|
+
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5930
|
+
}
|
|
4921
5931
|
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
4922
5932
|
try {
|
|
4923
5933
|
await this.deps.mediaStore.putReplacing({
|
|
@@ -4945,24 +5955,7 @@ var EventMediaDispatcher = class {
|
|
|
4945
5955
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
4946
5956
|
};
|
|
4947
5957
|
try {
|
|
4948
|
-
const
|
|
4949
|
-
W: fw,
|
|
4950
|
-
H: fh
|
|
4951
|
-
}, cropPadding);
|
|
4952
|
-
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
4953
|
-
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
4954
|
-
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
4955
|
-
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
4956
|
-
const crop = await (0, sharp.default)(frameData, { raw: {
|
|
4957
|
-
width: fw,
|
|
4958
|
-
height: fh,
|
|
4959
|
-
channels: 3
|
|
4960
|
-
} }).extract({
|
|
4961
|
-
left,
|
|
4962
|
-
top,
|
|
4963
|
-
width,
|
|
4964
|
-
height
|
|
4965
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5958
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
4966
5959
|
await this.deps.mediaStore.put({
|
|
4967
5960
|
deviceId,
|
|
4968
5961
|
ownerKind: "event",
|
|
@@ -9651,10 +10644,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
|
9651
10644
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
9652
10645
|
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
9653
10646
|
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
9654
|
-
* `
|
|
10647
|
+
* `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
10648
|
+
*
|
|
10649
|
+
* `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
|
|
10650
|
+
* now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
|
|
10651
|
+
* tracks (no object event → no `crop`) it is the ONLY subject-centered media —
|
|
10652
|
+
* without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
|
|
10653
|
+
* which is how 65% of the timeline tiles rendered as whole scenes. It sits
|
|
10654
|
+
* right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
|
|
9655
10655
|
*/
|
|
9656
10656
|
var CLEAN_MEDIA_KINDS = [
|
|
9657
10657
|
"crop",
|
|
10658
|
+
"thumbnail",
|
|
9658
10659
|
"fullFrame",
|
|
9659
10660
|
"keyFrame"
|
|
9660
10661
|
];
|
|
@@ -9732,6 +10733,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9732
10733
|
stationaryRegistry = null;
|
|
9733
10734
|
mediaStore = null;
|
|
9734
10735
|
eventStore = null;
|
|
10736
|
+
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
10737
|
+
sensorEventStore = null;
|
|
10738
|
+
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
10739
|
+
* so the DeviceStateChanged handler stays cheap. */
|
|
10740
|
+
linkedCamerasCache = null;
|
|
9735
10741
|
identityStore = null;
|
|
9736
10742
|
faceStore = null;
|
|
9737
10743
|
faceRecognizer = null;
|
|
@@ -9782,6 +10788,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9782
10788
|
unsubNativeDetection = null;
|
|
9783
10789
|
unsubBindings = null;
|
|
9784
10790
|
unsubDeviceUnreg = null;
|
|
10791
|
+
/** DeviceStateChanged subscription feeding the sensor-event history. */
|
|
10792
|
+
unsubDeviceState = null;
|
|
9785
10793
|
ttlSweepTimer = null;
|
|
9786
10794
|
retentionSweepTimer = null;
|
|
9787
10795
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
@@ -9877,6 +10885,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9877
10885
|
await TrackStore.declare(api.settingsStore);
|
|
9878
10886
|
await MediaStore.declare(api.settingsStore);
|
|
9879
10887
|
await EventStore.declare(api.settingsStore);
|
|
10888
|
+
await SensorEventStore.declare(api.settingsStore);
|
|
9880
10889
|
await IdentityStore.declare(api.settingsStore);
|
|
9881
10890
|
await FaceStore.declare(api.settingsStore);
|
|
9882
10891
|
await PlateStore.declare(api.settingsStore);
|
|
@@ -9887,14 +10896,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9887
10896
|
let storage = this.ctx.kernel.storage;
|
|
9888
10897
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
9889
10898
|
if (mediaRoot) {
|
|
9890
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
10899
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-K1C_KC3d.js"));
|
|
9891
10900
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
9892
10901
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
9893
10902
|
}
|
|
9894
10903
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
9895
10904
|
this.trackStore = new TrackStore({
|
|
9896
10905
|
store: api.settingsStore,
|
|
9897
|
-
logger: logger.child("TrackStore")
|
|
10906
|
+
logger: logger.child("TrackStore"),
|
|
10907
|
+
frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
|
|
9898
10908
|
});
|
|
9899
10909
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9900
10910
|
store: api.settingsStore,
|
|
@@ -9930,6 +10940,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9930
10940
|
logger: logger.child("EventStore"),
|
|
9931
10941
|
media: this.mediaStore
|
|
9932
10942
|
});
|
|
10943
|
+
this.sensorEventStore = new SensorEventStore({
|
|
10944
|
+
store: api.settingsStore,
|
|
10945
|
+
logger: logger.child("SensorEventStore")
|
|
10946
|
+
});
|
|
10947
|
+
this.linkedCamerasCache = new LinkedCamerasCache({
|
|
10948
|
+
cameras: { listCameraIds: async () => {
|
|
10949
|
+
return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
|
|
10950
|
+
} },
|
|
10951
|
+
linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
|
|
10952
|
+
onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
|
|
10953
|
+
scope,
|
|
10954
|
+
error: require_dist.errMsg(err)
|
|
10955
|
+
} })
|
|
10956
|
+
});
|
|
9933
10957
|
this.identityStore = new IdentityStore({
|
|
9934
10958
|
store: api.settingsStore,
|
|
9935
10959
|
logger: logger.child("IdentityStore")
|
|
@@ -10156,6 +11180,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10156
11180
|
const data = ev.data;
|
|
10157
11181
|
this.handleNativeDetection(data);
|
|
10158
11182
|
});
|
|
11183
|
+
this.unsubDeviceState = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (ev) => {
|
|
11184
|
+
const data = ev.data;
|
|
11185
|
+
if (require_dist.EVENT_KIND_BY_CAP[data.capName] === void 0) return;
|
|
11186
|
+
const timestamp = ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
|
|
11187
|
+
this.handleSensorStateChanged(data, timestamp);
|
|
11188
|
+
});
|
|
10159
11189
|
if (await this.embeddingEnabledState.get()) {
|
|
10160
11190
|
const encoderClient = {
|
|
10161
11191
|
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
@@ -10521,6 +11551,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10521
11551
|
this.unsubBindings = null;
|
|
10522
11552
|
this.unsubDeviceUnreg?.();
|
|
10523
11553
|
this.unsubDeviceUnreg = null;
|
|
11554
|
+
this.unsubDeviceState?.();
|
|
11555
|
+
this.unsubDeviceState = null;
|
|
10524
11556
|
await this.embeddingDispatcher?.stop();
|
|
10525
11557
|
this.embeddingDispatcher = null;
|
|
10526
11558
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
@@ -10784,7 +11816,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10784
11816
|
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
10785
11817
|
}
|
|
10786
11818
|
}
|
|
10787
|
-
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
11819
|
+
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
|
|
10788
11820
|
const byState = {};
|
|
10789
11821
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
10790
11822
|
log.info("frame processed", { meta: {
|
|
@@ -10794,10 +11826,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10794
11826
|
newTracks: newTrackCount,
|
|
10795
11827
|
lostTracks: lostTrackCount,
|
|
10796
11828
|
objectEvents: result.objectEvents.length,
|
|
11829
|
+
appearanceEvents: result.appearanceEvents.length,
|
|
10797
11830
|
byState
|
|
10798
11831
|
} });
|
|
10799
11832
|
}
|
|
10800
|
-
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
11833
|
+
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
10801
11834
|
const objectEmbeddingBests = [];
|
|
10802
11835
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
10803
11836
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -10815,8 +11848,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10815
11848
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
10816
11849
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
10817
11850
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
10818
|
-
const
|
|
10819
|
-
const
|
|
11851
|
+
const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
|
|
11852
|
+
const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
|
|
11853
|
+
const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
10820
11854
|
const childCrops = childCropsByEvent.get(e.id);
|
|
10821
11855
|
return {
|
|
10822
11856
|
eventId: e.id,
|
|
@@ -11370,7 +12404,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11370
12404
|
movementThreshold: media.snapshotMovementThreshold,
|
|
11371
12405
|
maxIdleMs: media.snapshotMaxIdleMs
|
|
11372
12406
|
}).capture;
|
|
11373
|
-
const
|
|
12407
|
+
const edgeClear = isEdgeClear({
|
|
12408
|
+
bbox: t.bbox,
|
|
12409
|
+
frameWidth,
|
|
12410
|
+
frameHeight
|
|
12411
|
+
});
|
|
12412
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
|
|
11374
12413
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
11375
12414
|
const plan = planPeriodicMedia({
|
|
11376
12415
|
saveThumbnails: media.saveThumbnails,
|
|
@@ -11783,6 +12822,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11783
12822
|
} });
|
|
11784
12823
|
}
|
|
11785
12824
|
await this.mediaStore.evictBefore(now - 31 * day);
|
|
12825
|
+
if (this.sensorEventStore) try {
|
|
12826
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
|
|
12827
|
+
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
12828
|
+
deleted: sensorDeleted,
|
|
12829
|
+
cutoffMs: objectCutoffMs
|
|
12830
|
+
} });
|
|
12831
|
+
} catch (err) {
|
|
12832
|
+
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
12833
|
+
}
|
|
11786
12834
|
if (this.faceStore) try {
|
|
11787
12835
|
const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
|
|
11788
12836
|
const deletedFaceIds = await this.faceStore.pruneAll({
|
|
@@ -12088,6 +13136,68 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12088
13136
|
async listTracks(input) {
|
|
12089
13137
|
return this.trackStore?.queryHistorical(input) ?? [];
|
|
12090
13138
|
}
|
|
13139
|
+
/**
|
|
13140
|
+
* Batched cluster-wide track listing — one merged (`lastSeen` DESC,
|
|
13141
|
+
* `trackId` DESC) page across the requested devices with a stable opaque
|
|
13142
|
+
* cursor. Replaces the per-camera `listTracks` fan-out for the events page
|
|
13143
|
+
* first paint + the reel. See `TrackStore.queryRecent` for the per-device
|
|
13144
|
+
* indexed page + k-way merge and the cursor encoding.
|
|
13145
|
+
*/
|
|
13146
|
+
async listRecentTracks(input) {
|
|
13147
|
+
return this.trackStore?.queryRecent(input) ?? {
|
|
13148
|
+
tracks: [],
|
|
13149
|
+
nextCursor: null
|
|
13150
|
+
};
|
|
13151
|
+
}
|
|
13152
|
+
/**
|
|
13153
|
+
* Every event kind the device can produce: built-ins (motion + audio),
|
|
13154
|
+
* detection classes actually observed on the device, and sensor kinds
|
|
13155
|
+
* from LINKED devices (device-manager `getLinkedDevices`, binding-driven
|
|
13156
|
+
* per linked device). Degrades to the built-ins on error.
|
|
13157
|
+
*/
|
|
13158
|
+
async listEventKinds(input) {
|
|
13159
|
+
const api = this.ctx.api;
|
|
13160
|
+
return composeEventKinds({
|
|
13161
|
+
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
13162
|
+
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
13163
|
+
observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
|
|
13164
|
+
onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
|
|
13165
|
+
tags: { deviceId: input.deviceId },
|
|
13166
|
+
meta: {
|
|
13167
|
+
scope,
|
|
13168
|
+
error: require_dist.errMsg(err)
|
|
13169
|
+
}
|
|
13170
|
+
})
|
|
13171
|
+
}, input.deviceId);
|
|
13172
|
+
}
|
|
13173
|
+
/** Per-camera sensor-event history (state changes of linked devices). */
|
|
13174
|
+
async getSensorEvents(input) {
|
|
13175
|
+
return this.sensorEventStore?.query(input) ?? [];
|
|
13176
|
+
}
|
|
13177
|
+
/**
|
|
13178
|
+
* Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
|
|
13179
|
+
* mapped sensor cap. Resolves the linked-camera set through the TTL cache
|
|
13180
|
+
* and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
|
|
13181
|
+
*/
|
|
13182
|
+
async handleSensorStateChanged(data, timestamp) {
|
|
13183
|
+
const store = this.sensorEventStore;
|
|
13184
|
+
const cache = this.linkedCamerasCache;
|
|
13185
|
+
if (store === null || cache === null) return;
|
|
13186
|
+
try {
|
|
13187
|
+
await ingestSensorStateChange({
|
|
13188
|
+
sink: store,
|
|
13189
|
+
cache
|
|
13190
|
+
}, data, timestamp);
|
|
13191
|
+
} catch (err) {
|
|
13192
|
+
this.ctx.logger.warn("sensor-event ingest failed", {
|
|
13193
|
+
tags: { deviceId: data.deviceId },
|
|
13194
|
+
meta: {
|
|
13195
|
+
capName: data.capName,
|
|
13196
|
+
error: require_dist.errMsg(err)
|
|
13197
|
+
}
|
|
13198
|
+
});
|
|
13199
|
+
}
|
|
13200
|
+
}
|
|
12091
13201
|
async clearTracks(input) {
|
|
12092
13202
|
this.trackStore?.clearDevice(input.deviceId);
|
|
12093
13203
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|