@camstack/addon-post-analysis 1.1.31 → 1.1.33
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-D1cY_vlY.mjs → dist-CA0GikiM.mjs} +219 -6
- package/dist/{dist-Cy4Tp-yp.js → dist-V8XOHDoj.js} +224 -5
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-qtD7WAqr.js → node-E-m2CYYM.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-jTZY8fyP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BbAKLasA.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 → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-ai-NpPOn.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-TIRXUvf0.mjs → hostInit-lUENPwl7.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +602 -61
- package/dist/pipeline-analytics/index.mjs +601 -60
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { C as string, S as object, _ as createEvent, a as cosineSimilarity, b as boolean, 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, w as EventCategory, x as number, y as array } from "../dist-CA0GikiM.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -4707,10 +4854,21 @@ var EventStore = class {
|
|
|
4707
4854
|
}
|
|
4708
4855
|
/**
|
|
4709
4856
|
* The track's highest-confidence object event, its bbox area (as a fraction of
|
|
4710
|
-
* frame area), and
|
|
4711
|
-
* 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
|
|
4712
4859
|
* `idx_object_track`). Returns zeros + undefined id when the track has none.
|
|
4713
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.
|
|
4714
4872
|
*/
|
|
4715
4873
|
async peakForTrack(trackId) {
|
|
4716
4874
|
const rows = await this.store.query.query({
|
|
@@ -4718,8 +4876,11 @@ var EventStore = class {
|
|
|
4718
4876
|
filter: { where: { trackId } }
|
|
4719
4877
|
});
|
|
4720
4878
|
let bestConf = -1;
|
|
4721
|
-
let bestEventId;
|
|
4722
4879
|
let peakBboxAreaFrac = 0;
|
|
4880
|
+
let activityConf = -1;
|
|
4881
|
+
let activityId;
|
|
4882
|
+
let entryConf = -1;
|
|
4883
|
+
let entryId;
|
|
4723
4884
|
for (const row of rows) {
|
|
4724
4885
|
const bbox = row.data["bbox"];
|
|
4725
4886
|
if (bbox !== null && typeof bbox === "object") {
|
|
@@ -4728,15 +4889,24 @@ var EventStore = class {
|
|
|
4728
4889
|
if (bw <= 0 || bh <= 0) continue;
|
|
4729
4890
|
}
|
|
4730
4891
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
4731
|
-
if (conf
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
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
|
+
}
|
|
4735
4905
|
}
|
|
4736
4906
|
return {
|
|
4737
4907
|
peakConfidence: bestConf < 0 ? 0 : bestConf,
|
|
4738
4908
|
peakBboxAreaFrac,
|
|
4739
|
-
bestEventId
|
|
4909
|
+
bestEventId: activityId ?? entryId
|
|
4740
4910
|
};
|
|
4741
4911
|
}
|
|
4742
4912
|
/**
|
|
@@ -5226,6 +5396,7 @@ var PERSON_COLOR = "#22c55e";
|
|
|
5226
5396
|
var VEHICLE_COLOR = "#3b82f6";
|
|
5227
5397
|
var ANIMAL_COLOR = "#f97316";
|
|
5228
5398
|
var GENERIC_DETECTION_COLOR = "#64748b";
|
|
5399
|
+
var PACKAGE_COLOR = "#a855f7";
|
|
5229
5400
|
var VEHICLE_CLASSES = new Set([
|
|
5230
5401
|
"vehicle",
|
|
5231
5402
|
"car",
|
|
@@ -5303,6 +5474,34 @@ async function composeEventKinds(deps, deviceId) {
|
|
|
5303
5474
|
} catch (err) {
|
|
5304
5475
|
deps.onError?.("observedClassNames", err);
|
|
5305
5476
|
}
|
|
5477
|
+
try {
|
|
5478
|
+
if (deps.packageZonesEnabled && await deps.packageZonesEnabled(deviceId)) {
|
|
5479
|
+
out.push({
|
|
5480
|
+
kind: "package-delivered",
|
|
5481
|
+
label: "Package delivered",
|
|
5482
|
+
color: PACKAGE_COLOR,
|
|
5483
|
+
icon: "package",
|
|
5484
|
+
category: "package",
|
|
5485
|
+
source: {
|
|
5486
|
+
capName: "pipeline-analytics",
|
|
5487
|
+
deviceId
|
|
5488
|
+
}
|
|
5489
|
+
});
|
|
5490
|
+
out.push({
|
|
5491
|
+
kind: "package-picked-up",
|
|
5492
|
+
label: "Package picked up",
|
|
5493
|
+
color: PACKAGE_COLOR,
|
|
5494
|
+
icon: "package",
|
|
5495
|
+
category: "package",
|
|
5496
|
+
source: {
|
|
5497
|
+
capName: "pipeline-analytics",
|
|
5498
|
+
deviceId
|
|
5499
|
+
}
|
|
5500
|
+
});
|
|
5501
|
+
}
|
|
5502
|
+
} catch (err) {
|
|
5503
|
+
deps.onError?.("packageZonesEnabled", err);
|
|
5504
|
+
}
|
|
5306
5505
|
try {
|
|
5307
5506
|
const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
|
|
5308
5507
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -7155,6 +7354,208 @@ function resolveMediaSettings(raw) {
|
|
|
7155
7354
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
7156
7355
|
};
|
|
7157
7356
|
}
|
|
7357
|
+
//#endregion
|
|
7358
|
+
//#region src/pipeline-analytics/package-settings.ts
|
|
7359
|
+
/**
|
|
7360
|
+
* Per-device package-drop detector settings (surface C of the
|
|
7361
|
+
* detection-config-exposure plan — a per-device post-analysis section).
|
|
7362
|
+
* Cascade: a per-device override on top of the declared default, resolved
|
|
7363
|
+
* per field (an invalid/missing value falls back to its default — parse
|
|
7364
|
+
* never throws). Mirrors `media-settings` / `tracking-settings`.
|
|
7365
|
+
*
|
|
7366
|
+
* Keys are namespaced (`packageDrop*`) because the device store is a FLAT
|
|
7367
|
+
* blob shared across every post-analysis section — a bare `enabled` would
|
|
7368
|
+
* collide with another section.
|
|
7369
|
+
*
|
|
7370
|
+
* See docs/superpowers/specs/2026-07-17-package-zones-design.md §3.3 +
|
|
7371
|
+
* docs/superpowers/specs/2026-07-17-detection-config-exposure-design.md
|
|
7372
|
+
* (surface C). Dwell default is 15s per operator decision (the design's
|
|
7373
|
+
* §3.3 draft said 45s).
|
|
7374
|
+
*/
|
|
7375
|
+
var PackageDropSettingsSchema = object({
|
|
7376
|
+
/** Master switch — off by default; opt-in per camera (porch/door cams). */
|
|
7377
|
+
packageDropEnabled: boolean().default(false),
|
|
7378
|
+
/**
|
|
7379
|
+
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
7380
|
+
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
7381
|
+
* set down and snatched back. Operator default 15s. Values beyond the
|
|
7382
|
+
* stationary promotion window (30s) are satisfied only once the entry
|
|
7383
|
+
* has actually been observed that long.
|
|
7384
|
+
*/
|
|
7385
|
+
packageDropDwellSec: number().int().min(0).max(3600).default(15),
|
|
7386
|
+
/** Reject tiny far-field blobs: min bbox area as a fraction of the frame. */
|
|
7387
|
+
packageDropMinBboxAreaFrac: number().min(0).max(1).default(.004),
|
|
7388
|
+
/** Emit `package-picked-up` when a delivered package's entry departs. */
|
|
7389
|
+
packageDropPickupEnabled: boolean().default(true),
|
|
7390
|
+
/**
|
|
7391
|
+
* Stationary classes that count as a package. MODEL-AGNOSTIC: defaults to
|
|
7392
|
+
* the single `package` class a dedicated parcel model emits; the interim
|
|
7393
|
+
* COCO vector (suitcase/backpack/handbag) already maps to `package`
|
|
7394
|
+
* upstream, so this stays `['package']`.
|
|
7395
|
+
*/
|
|
7396
|
+
packageDropClassFilter: array(string()).default(["package"])
|
|
7397
|
+
});
|
|
7398
|
+
var PACKAGE_DROP_DEFAULTS = PackageDropSettingsSchema.parse({});
|
|
7399
|
+
/**
|
|
7400
|
+
* Resolve a per-device store blob into typed package-drop settings.
|
|
7401
|
+
* Unknown/invalid fields fall back to the default for that field (never
|
|
7402
|
+
* throws on a bad blob).
|
|
7403
|
+
*/
|
|
7404
|
+
function resolvePackageDropSettings(raw) {
|
|
7405
|
+
const pick = (key) => {
|
|
7406
|
+
const parsed = PackageDropSettingsSchema.shape[key].safeParse(raw[key]);
|
|
7407
|
+
return parsed.success ? parsed.data : PACKAGE_DROP_DEFAULTS[key];
|
|
7408
|
+
};
|
|
7409
|
+
return {
|
|
7410
|
+
packageDropEnabled: pick("packageDropEnabled"),
|
|
7411
|
+
packageDropDwellSec: pick("packageDropDwellSec"),
|
|
7412
|
+
packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
|
|
7413
|
+
packageDropPickupEnabled: pick("packageDropPickupEnabled"),
|
|
7414
|
+
packageDropClassFilter: pick("packageDropClassFilter")
|
|
7415
|
+
};
|
|
7416
|
+
}
|
|
7417
|
+
//#endregion
|
|
7418
|
+
//#region src/pipeline-analytics/pipeline/package-drop-detector.ts
|
|
7419
|
+
/** The class every durable package event carries. */
|
|
7420
|
+
var PACKAGE_EVENT_CLASS = "package";
|
|
7421
|
+
/** Fixed high importance — package delivery is inherently high-signal (§6). */
|
|
7422
|
+
var PACKAGE_IMPORTANCE = 1;
|
|
7423
|
+
/** Object-event `state` used for a delivery (a parked package) / a pick-up
|
|
7424
|
+
* (its departure). Both are valid `TrackState` enum members so the events
|
|
7425
|
+
* stay `getObjectEvents`-output-valid. */
|
|
7426
|
+
var DELIVERED_STATE = "idle";
|
|
7427
|
+
var PICKED_UP_STATE = "left";
|
|
7428
|
+
/** Deterministic durable-event ids keyed on the stationary entry id. */
|
|
7429
|
+
function deliveredEventId(entryId) {
|
|
7430
|
+
return `pa-pkg-${entryId}-delivered`;
|
|
7431
|
+
}
|
|
7432
|
+
function pickedUpEventId(entryId) {
|
|
7433
|
+
return `pa-pkg-${entryId}-pickedup`;
|
|
7434
|
+
}
|
|
7435
|
+
var PackageDropDetector = class {
|
|
7436
|
+
deps;
|
|
7437
|
+
constructor(deps) {
|
|
7438
|
+
this.deps = deps;
|
|
7439
|
+
}
|
|
7440
|
+
/** Single entrypoint — bridge the registry's `onChange` here. Never
|
|
7441
|
+
* throws (telemetry-lossy, D8): a failure only misses one package event. */
|
|
7442
|
+
async onStationaryChange(change) {
|
|
7443
|
+
try {
|
|
7444
|
+
if (change.phase === "appeared") await this.onAppeared(change.entry, change.timestamp);
|
|
7445
|
+
else await this.onDeparted(change.entry, change.timestamp);
|
|
7446
|
+
} catch (err) {
|
|
7447
|
+
this.deps.onError?.("onStationaryChange", err);
|
|
7448
|
+
this.deps.logger.warn("package-drop detector failed on change", {
|
|
7449
|
+
tags: { deviceId: change.entry.deviceId },
|
|
7450
|
+
meta: {
|
|
7451
|
+
phase: change.phase,
|
|
7452
|
+
entryId: change.entry.id,
|
|
7453
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7454
|
+
}
|
|
7455
|
+
});
|
|
7456
|
+
}
|
|
7457
|
+
}
|
|
7458
|
+
async onAppeared(entry, timestamp) {
|
|
7459
|
+
const settings = await this.deps.resolveSettings(entry.deviceId);
|
|
7460
|
+
if (!settings.packageDropEnabled) return;
|
|
7461
|
+
if (!settings.packageDropClassFilter.includes(entry.className)) return;
|
|
7462
|
+
const rules = (await this.deps.resolvePackageRules(entry.deviceId)).filter((r) => r.enabled !== false);
|
|
7463
|
+
if (rules.length === 0) return;
|
|
7464
|
+
const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
|
|
7465
|
+
const hitZones = computeStationaryEntryZones(entry, await this.deps.resolveZones(entry.deviceId)).filter((z) => ruleZoneIds.has(z));
|
|
7466
|
+
if (hitZones.length === 0) return;
|
|
7467
|
+
if (timestamp - entry.firstSeenAt < settings.packageDropDwellSec * 1e3) return;
|
|
7468
|
+
const frameArea = entry.frameWidth * entry.frameHeight;
|
|
7469
|
+
if (frameArea <= 0) return;
|
|
7470
|
+
if (entry.bbox.w * entry.bbox.h / frameArea < settings.packageDropMinBboxAreaFrac) return;
|
|
7471
|
+
const eventId = deliveredEventId(entry.id);
|
|
7472
|
+
if ((await this.deps.events.queryObject({
|
|
7473
|
+
deviceId: entry.deviceId,
|
|
7474
|
+
classFilter: "package"
|
|
7475
|
+
})).some((e) => e.id === eventId)) return;
|
|
7476
|
+
const ev = {
|
|
7477
|
+
id: eventId,
|
|
7478
|
+
kind: "object",
|
|
7479
|
+
deviceId: entry.deviceId,
|
|
7480
|
+
timestamp,
|
|
7481
|
+
source: "pipeline",
|
|
7482
|
+
trackId: entry.sourceTrackId ?? entry.id,
|
|
7483
|
+
className: PACKAGE_EVENT_CLASS,
|
|
7484
|
+
...entry.label !== void 0 ? { label: entry.label } : {},
|
|
7485
|
+
confidence: PACKAGE_IMPORTANCE,
|
|
7486
|
+
bbox: {
|
|
7487
|
+
x: entry.bbox.x,
|
|
7488
|
+
y: entry.bbox.y,
|
|
7489
|
+
w: entry.bbox.w,
|
|
7490
|
+
h: entry.bbox.h
|
|
7491
|
+
},
|
|
7492
|
+
zones: hitZones,
|
|
7493
|
+
state: DELIVERED_STATE,
|
|
7494
|
+
frameWidth: entry.frameWidth,
|
|
7495
|
+
frameHeight: entry.frameHeight,
|
|
7496
|
+
...entry.keyFrameMediaKey !== void 0 ? { mediaKey: entry.keyFrameMediaKey } : {},
|
|
7497
|
+
importance: PACKAGE_IMPORTANCE
|
|
7498
|
+
};
|
|
7499
|
+
await this.deps.events.insertObject(ev);
|
|
7500
|
+
this.deps.emit.delivered({
|
|
7501
|
+
deviceId: entry.deviceId,
|
|
7502
|
+
entryId: entry.id,
|
|
7503
|
+
eventId,
|
|
7504
|
+
className: entry.className,
|
|
7505
|
+
zoneIds: hitZones,
|
|
7506
|
+
...entry.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: entry.keyFrameMediaKey } : {},
|
|
7507
|
+
bbox: {
|
|
7508
|
+
x: entry.bbox.x,
|
|
7509
|
+
y: entry.bbox.y,
|
|
7510
|
+
w: entry.bbox.w,
|
|
7511
|
+
h: entry.bbox.h
|
|
7512
|
+
},
|
|
7513
|
+
timestamp
|
|
7514
|
+
});
|
|
7515
|
+
}
|
|
7516
|
+
async onDeparted(entry, timestamp) {
|
|
7517
|
+
if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) return;
|
|
7518
|
+
const deliveredId = deliveredEventId(entry.id);
|
|
7519
|
+
const pickedUpId = pickedUpEventId(entry.id);
|
|
7520
|
+
const rows = await this.deps.events.queryObject({
|
|
7521
|
+
deviceId: entry.deviceId,
|
|
7522
|
+
classFilter: PACKAGE_EVENT_CLASS
|
|
7523
|
+
});
|
|
7524
|
+
const delivered = rows.find((e) => e.id === deliveredId);
|
|
7525
|
+
if (delivered === void 0) return;
|
|
7526
|
+
if (rows.some((e) => e.id === pickedUpId)) return;
|
|
7527
|
+
const ev = {
|
|
7528
|
+
id: pickedUpId,
|
|
7529
|
+
kind: "object",
|
|
7530
|
+
deviceId: entry.deviceId,
|
|
7531
|
+
timestamp,
|
|
7532
|
+
source: "pipeline",
|
|
7533
|
+
trackId: entry.sourceTrackId ?? entry.id,
|
|
7534
|
+
className: PACKAGE_EVENT_CLASS,
|
|
7535
|
+
...entry.label !== void 0 ? { label: entry.label } : {},
|
|
7536
|
+
confidence: PACKAGE_IMPORTANCE,
|
|
7537
|
+
bbox: {
|
|
7538
|
+
x: entry.bbox.x,
|
|
7539
|
+
y: entry.bbox.y,
|
|
7540
|
+
w: entry.bbox.w,
|
|
7541
|
+
h: entry.bbox.h
|
|
7542
|
+
},
|
|
7543
|
+
...delivered.zones !== void 0 ? { zones: delivered.zones } : {},
|
|
7544
|
+
state: PICKED_UP_STATE,
|
|
7545
|
+
frameWidth: entry.frameWidth,
|
|
7546
|
+
frameHeight: entry.frameHeight,
|
|
7547
|
+
importance: PACKAGE_IMPORTANCE
|
|
7548
|
+
};
|
|
7549
|
+
await this.deps.events.insertObject(ev);
|
|
7550
|
+
this.deps.emit.pickedUp({
|
|
7551
|
+
deviceId: entry.deviceId,
|
|
7552
|
+
entryId: entry.id,
|
|
7553
|
+
deliveredEventId: deliveredId,
|
|
7554
|
+
className: entry.className,
|
|
7555
|
+
timestamp
|
|
7556
|
+
});
|
|
7557
|
+
}
|
|
7558
|
+
};
|
|
7158
7559
|
function centroidOf(b) {
|
|
7159
7560
|
return {
|
|
7160
7561
|
x: b.x + b.w / 2,
|
|
@@ -10469,10 +10870,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
|
10469
10870
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
10470
10871
|
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
10471
10872
|
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
10472
|
-
* `
|
|
10873
|
+
* `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
10874
|
+
*
|
|
10875
|
+
* `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
|
|
10876
|
+
* now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
|
|
10877
|
+
* tracks (no object event → no `crop`) it is the ONLY subject-centered media —
|
|
10878
|
+
* without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
|
|
10879
|
+
* which is how 65% of the timeline tiles rendered as whole scenes. It sits
|
|
10880
|
+
* right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
|
|
10473
10881
|
*/
|
|
10474
10882
|
var CLEAN_MEDIA_KINDS = [
|
|
10475
10883
|
"crop",
|
|
10884
|
+
"thumbnail",
|
|
10476
10885
|
"fullFrame",
|
|
10477
10886
|
"keyFrame"
|
|
10478
10887
|
];
|
|
@@ -10627,6 +11036,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10627
11036
|
/** GLOBAL face-recognition master switch (addon store), TTL-cached. */
|
|
10628
11037
|
faceGlobalEnabledCache = null;
|
|
10629
11038
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
11039
|
+
packageDropCacheByDevice = /* @__PURE__ */ new Map();
|
|
11040
|
+
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
11041
|
+
packageDropDetector = null;
|
|
10630
11042
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
10631
11043
|
dropoutSkipsByKey = /* @__PURE__ */ new Map();
|
|
10632
11044
|
/** Best (highest-confidence) frame per track — drives the single overwrite
|
|
@@ -10744,6 +11156,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10744
11156
|
timestamp
|
|
10745
11157
|
}
|
|
10746
11158
|
});
|
|
11159
|
+
this.packageDropDetector?.onStationaryChange({
|
|
11160
|
+
phase,
|
|
11161
|
+
entry,
|
|
11162
|
+
timestamp
|
|
11163
|
+
});
|
|
10747
11164
|
}
|
|
10748
11165
|
});
|
|
10749
11166
|
await this.stationaryRegistry.load();
|
|
@@ -10752,15 +11169,55 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
10752
11169
|
store: api.settingsStore,
|
|
10753
11170
|
logger: logger.child("MediaStore")
|
|
10754
11171
|
});
|
|
10755
|
-
|
|
11172
|
+
const eventStore = new EventStore({
|
|
10756
11173
|
store: api.settingsStore,
|
|
10757
11174
|
logger: logger.child("EventStore"),
|
|
10758
11175
|
media: this.mediaStore
|
|
10759
11176
|
});
|
|
11177
|
+
this.eventStore = eventStore;
|
|
10760
11178
|
this.sensorEventStore = new SensorEventStore({
|
|
10761
11179
|
store: api.settingsStore,
|
|
10762
11180
|
logger: logger.child("SensorEventStore")
|
|
10763
11181
|
});
|
|
11182
|
+
this.packageDropDetector = new PackageDropDetector({
|
|
11183
|
+
events: eventStore,
|
|
11184
|
+
emit: {
|
|
11185
|
+
delivered: (payload) => {
|
|
11186
|
+
this.ctx.eventBus.emit({
|
|
11187
|
+
id: `pa-package-delivered-${payload.entryId}`,
|
|
11188
|
+
timestamp: new Date(payload.timestamp),
|
|
11189
|
+
source: {
|
|
11190
|
+
type: "addon",
|
|
11191
|
+
id: "pipeline-analytics",
|
|
11192
|
+
addonId: "pipeline-analytics"
|
|
11193
|
+
},
|
|
11194
|
+
category: EventCategory.PipelineAnalyticsPackageDelivered,
|
|
11195
|
+
data: { ...payload }
|
|
11196
|
+
});
|
|
11197
|
+
},
|
|
11198
|
+
pickedUp: (payload) => {
|
|
11199
|
+
this.ctx.eventBus.emit({
|
|
11200
|
+
id: `pa-package-pickedup-${payload.entryId}`,
|
|
11201
|
+
timestamp: new Date(payload.timestamp),
|
|
11202
|
+
source: {
|
|
11203
|
+
type: "addon",
|
|
11204
|
+
id: "pipeline-analytics",
|
|
11205
|
+
addonId: "pipeline-analytics"
|
|
11206
|
+
},
|
|
11207
|
+
category: EventCategory.PipelineAnalyticsPackagePickedUp,
|
|
11208
|
+
data: { ...payload }
|
|
11209
|
+
});
|
|
11210
|
+
}
|
|
11211
|
+
},
|
|
11212
|
+
logger: logger.child("PackageDropDetector"),
|
|
11213
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
11214
|
+
resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
|
|
11215
|
+
resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
|
|
11216
|
+
onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
|
|
11217
|
+
scope,
|
|
11218
|
+
error: errMsg(err)
|
|
11219
|
+
} })
|
|
11220
|
+
});
|
|
10764
11221
|
this.linkedCamerasCache = new LinkedCamerasCache({
|
|
10765
11222
|
cameras: { listCameraIds: async () => {
|
|
10766
11223
|
return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
|
|
@@ -11040,6 +11497,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11040
11497
|
this.trackingCacheByDevice.delete(data.deviceId);
|
|
11041
11498
|
this.faceCacheByDevice.delete(data.deviceId);
|
|
11042
11499
|
this.mediaCacheByDevice.delete(data.deviceId);
|
|
11500
|
+
this.packageDropCacheByDevice.delete(data.deviceId);
|
|
11043
11501
|
}
|
|
11044
11502
|
});
|
|
11045
11503
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
@@ -11055,6 +11513,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11055
11513
|
this.trackingCacheByDevice.delete(deviceId);
|
|
11056
11514
|
this.faceCacheByDevice.delete(deviceId);
|
|
11057
11515
|
this.mediaCacheByDevice.delete(deviceId);
|
|
11516
|
+
this.packageDropCacheByDevice.delete(deviceId);
|
|
11058
11517
|
this.bindingCache?.invalidate(deviceId);
|
|
11059
11518
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
11060
11519
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -11402,6 +11861,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11402
11861
|
this.faceCacheByDevice.clear();
|
|
11403
11862
|
this.faceGlobalEnabledCache = null;
|
|
11404
11863
|
this.mediaCacheByDevice.clear();
|
|
11864
|
+
this.packageDropCacheByDevice.clear();
|
|
11405
11865
|
this.trackStore?.clearAll();
|
|
11406
11866
|
this.stationaryRegistry = null;
|
|
11407
11867
|
this.bindingCache?.clearAll();
|
|
@@ -11633,7 +12093,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11633
12093
|
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
11634
12094
|
}
|
|
11635
12095
|
}
|
|
11636
|
-
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
12096
|
+
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
|
|
11637
12097
|
const byState = {};
|
|
11638
12098
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
11639
12099
|
log.info("frame processed", { meta: {
|
|
@@ -11643,10 +12103,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11643
12103
|
newTracks: newTrackCount,
|
|
11644
12104
|
lostTracks: lostTrackCount,
|
|
11645
12105
|
objectEvents: result.objectEvents.length,
|
|
12106
|
+
appearanceEvents: result.appearanceEvents.length,
|
|
11646
12107
|
byState
|
|
11647
12108
|
} });
|
|
11648
12109
|
}
|
|
11649
|
-
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
12110
|
+
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
11650
12111
|
const objectEmbeddingBests = [];
|
|
11651
12112
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
11652
12113
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -11664,8 +12125,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11664
12125
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
11665
12126
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
11666
12127
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
11667
|
-
const
|
|
11668
|
-
const
|
|
12128
|
+
const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
|
|
12129
|
+
const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
|
|
12130
|
+
const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
11669
12131
|
const childCrops = childCropsByEvent.get(e.id);
|
|
11670
12132
|
return {
|
|
11671
12133
|
eventId: e.id,
|
|
@@ -11875,6 +12337,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11875
12337
|
});
|
|
11876
12338
|
return settings;
|
|
11877
12339
|
}
|
|
12340
|
+
async resolveDevicePackageDropSettings(deviceId) {
|
|
12341
|
+
const now = Date.now();
|
|
12342
|
+
const cached = this.packageDropCacheByDevice.get(deviceId);
|
|
12343
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
12344
|
+
const settings = resolvePackageDropSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
12345
|
+
this.packageDropCacheByDevice.set(deviceId, {
|
|
12346
|
+
settings,
|
|
12347
|
+
expiresAt: now + SETTINGS_CACHE_TTL_MS
|
|
12348
|
+
});
|
|
12349
|
+
return settings;
|
|
12350
|
+
}
|
|
12351
|
+
/**
|
|
12352
|
+
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
12353
|
+
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
12354
|
+
* when the cached slice is empty, forces one refresh. The `package` slice
|
|
12355
|
+
* is written by the orchestrator's package-stage provider (a later slice);
|
|
12356
|
+
* until then this returns `[]` and no package events fire.
|
|
12357
|
+
*/
|
|
12358
|
+
async resolveDevicePackageRules(deviceId) {
|
|
12359
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12360
|
+
if (!proxy) return [];
|
|
12361
|
+
const cached = proxy.state.zoneRules.value?.package;
|
|
12362
|
+
if (cached && cached.length > 0) return cached;
|
|
12363
|
+
await proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
12364
|
+
return proxy.state.zoneRules.value?.package ?? [];
|
|
12365
|
+
}
|
|
11878
12366
|
/**
|
|
11879
12367
|
* Route one track's `runDetailSubtree` results (two-plane detail dispatch)
|
|
11880
12368
|
* into the EXISTING per-track consumers, discriminated by payload SHAPE:
|
|
@@ -12219,7 +12707,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12219
12707
|
movementThreshold: media.snapshotMovementThreshold,
|
|
12220
12708
|
maxIdleMs: media.snapshotMaxIdleMs
|
|
12221
12709
|
}).capture;
|
|
12222
|
-
const
|
|
12710
|
+
const edgeClear = isEdgeClear({
|
|
12711
|
+
bbox: t.bbox,
|
|
12712
|
+
frameWidth,
|
|
12713
|
+
frameHeight
|
|
12714
|
+
});
|
|
12715
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
|
|
12223
12716
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
12224
12717
|
const plan = planPeriodicMedia({
|
|
12225
12718
|
saveThumbnails: media.saveThumbnails,
|
|
@@ -12971,6 +13464,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12971
13464
|
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
12972
13465
|
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
12973
13466
|
observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
|
|
13467
|
+
packageZonesEnabled: async (deviceId) => {
|
|
13468
|
+
return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
|
|
13469
|
+
},
|
|
12974
13470
|
onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
|
|
12975
13471
|
tags: { deviceId: input.deviceId },
|
|
12976
13472
|
meta: {
|
|
@@ -13634,6 +14130,51 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13634
14130
|
}
|
|
13635
14131
|
]
|
|
13636
14132
|
},
|
|
14133
|
+
{
|
|
14134
|
+
id: "package-drop",
|
|
14135
|
+
title: "Package detection",
|
|
14136
|
+
description: "Detect a delivered package (a package-class object left parked inside a package zone) and, symmetrically, its pick-up. Draw the package zone in the zone editor; enable the package class in the object-detection step. Per-device override.",
|
|
14137
|
+
columns: 2,
|
|
14138
|
+
fields: [
|
|
14139
|
+
{
|
|
14140
|
+
type: "boolean",
|
|
14141
|
+
key: "packageDropEnabled",
|
|
14142
|
+
label: "Enable package detection",
|
|
14143
|
+
description: "Turn package delivered / picked-up detection on for this camera (off elsewhere).",
|
|
14144
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropEnabled
|
|
14145
|
+
},
|
|
14146
|
+
{
|
|
14147
|
+
type: "boolean",
|
|
14148
|
+
key: "packageDropPickupEnabled",
|
|
14149
|
+
label: "Emit pick-up",
|
|
14150
|
+
description: "Also emit a package-picked-up event when the delivered package leaves.",
|
|
14151
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
|
|
14152
|
+
},
|
|
14153
|
+
{
|
|
14154
|
+
type: "slider",
|
|
14155
|
+
key: "packageDropDwellSec",
|
|
14156
|
+
label: "Minimum dwell",
|
|
14157
|
+
description: "How long a package must stay parked before it counts as a delivery. Higher = fewer false positives from bags briefly set down.",
|
|
14158
|
+
min: 0,
|
|
14159
|
+
max: 300,
|
|
14160
|
+
step: 5,
|
|
14161
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropDwellSec,
|
|
14162
|
+
showValue: true,
|
|
14163
|
+
unit: "s"
|
|
14164
|
+
},
|
|
14165
|
+
{
|
|
14166
|
+
type: "slider",
|
|
14167
|
+
key: "packageDropMinBboxAreaFrac",
|
|
14168
|
+
label: "Minimum package size",
|
|
14169
|
+
description: "Reject tiny far-field blobs: the package box must cover at least this fraction of the frame.",
|
|
14170
|
+
min: 0,
|
|
14171
|
+
max: .1,
|
|
14172
|
+
step: .001,
|
|
14173
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropMinBboxAreaFrac,
|
|
14174
|
+
showValue: true
|
|
14175
|
+
}
|
|
14176
|
+
]
|
|
14177
|
+
},
|
|
13637
14178
|
{
|
|
13638
14179
|
id: "audio-detection",
|
|
13639
14180
|
title: "Audio detection",
|