@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
|
@@ -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-V8XOHDoj.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -4712,10 +4859,21 @@ var EventStore = class {
|
|
|
4712
4859
|
}
|
|
4713
4860
|
/**
|
|
4714
4861
|
* The track's highest-confidence object event, its bbox area (as a fraction of
|
|
4715
|
-
* frame area), and
|
|
4716
|
-
* 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
|
|
4717
4864
|
* `idx_object_track`). Returns zeros + undefined id when the track has none.
|
|
4718
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.
|
|
4719
4877
|
*/
|
|
4720
4878
|
async peakForTrack(trackId) {
|
|
4721
4879
|
const rows = await this.store.query.query({
|
|
@@ -4723,8 +4881,11 @@ var EventStore = class {
|
|
|
4723
4881
|
filter: { where: { trackId } }
|
|
4724
4882
|
});
|
|
4725
4883
|
let bestConf = -1;
|
|
4726
|
-
let bestEventId;
|
|
4727
4884
|
let peakBboxAreaFrac = 0;
|
|
4885
|
+
let activityConf = -1;
|
|
4886
|
+
let activityId;
|
|
4887
|
+
let entryConf = -1;
|
|
4888
|
+
let entryId;
|
|
4728
4889
|
for (const row of rows) {
|
|
4729
4890
|
const bbox = row.data["bbox"];
|
|
4730
4891
|
if (bbox !== null && typeof bbox === "object") {
|
|
@@ -4733,15 +4894,24 @@ var EventStore = class {
|
|
|
4733
4894
|
if (bw <= 0 || bh <= 0) continue;
|
|
4734
4895
|
}
|
|
4735
4896
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
4736
|
-
if (conf
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
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
|
+
}
|
|
4740
4910
|
}
|
|
4741
4911
|
return {
|
|
4742
4912
|
peakConfidence: bestConf < 0 ? 0 : bestConf,
|
|
4743
4913
|
peakBboxAreaFrac,
|
|
4744
|
-
bestEventId
|
|
4914
|
+
bestEventId: activityId ?? entryId
|
|
4745
4915
|
};
|
|
4746
4916
|
}
|
|
4747
4917
|
/**
|
|
@@ -5231,6 +5401,7 @@ var PERSON_COLOR = "#22c55e";
|
|
|
5231
5401
|
var VEHICLE_COLOR = "#3b82f6";
|
|
5232
5402
|
var ANIMAL_COLOR = "#f97316";
|
|
5233
5403
|
var GENERIC_DETECTION_COLOR = "#64748b";
|
|
5404
|
+
var PACKAGE_COLOR = "#a855f7";
|
|
5234
5405
|
var VEHICLE_CLASSES = new Set([
|
|
5235
5406
|
"vehicle",
|
|
5236
5407
|
"car",
|
|
@@ -5308,6 +5479,34 @@ async function composeEventKinds(deps, deviceId) {
|
|
|
5308
5479
|
} catch (err) {
|
|
5309
5480
|
deps.onError?.("observedClassNames", err);
|
|
5310
5481
|
}
|
|
5482
|
+
try {
|
|
5483
|
+
if (deps.packageZonesEnabled && await deps.packageZonesEnabled(deviceId)) {
|
|
5484
|
+
out.push({
|
|
5485
|
+
kind: "package-delivered",
|
|
5486
|
+
label: "Package delivered",
|
|
5487
|
+
color: PACKAGE_COLOR,
|
|
5488
|
+
icon: "package",
|
|
5489
|
+
category: "package",
|
|
5490
|
+
source: {
|
|
5491
|
+
capName: "pipeline-analytics",
|
|
5492
|
+
deviceId
|
|
5493
|
+
}
|
|
5494
|
+
});
|
|
5495
|
+
out.push({
|
|
5496
|
+
kind: "package-picked-up",
|
|
5497
|
+
label: "Package picked up",
|
|
5498
|
+
color: PACKAGE_COLOR,
|
|
5499
|
+
icon: "package",
|
|
5500
|
+
category: "package",
|
|
5501
|
+
source: {
|
|
5502
|
+
capName: "pipeline-analytics",
|
|
5503
|
+
deviceId
|
|
5504
|
+
}
|
|
5505
|
+
});
|
|
5506
|
+
}
|
|
5507
|
+
} catch (err) {
|
|
5508
|
+
deps.onError?.("packageZonesEnabled", err);
|
|
5509
|
+
}
|
|
5311
5510
|
try {
|
|
5312
5511
|
const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
|
|
5313
5512
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -7160,6 +7359,208 @@ function resolveMediaSettings(raw) {
|
|
|
7160
7359
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
7161
7360
|
};
|
|
7162
7361
|
}
|
|
7362
|
+
//#endregion
|
|
7363
|
+
//#region src/pipeline-analytics/package-settings.ts
|
|
7364
|
+
/**
|
|
7365
|
+
* Per-device package-drop detector settings (surface C of the
|
|
7366
|
+
* detection-config-exposure plan — a per-device post-analysis section).
|
|
7367
|
+
* Cascade: a per-device override on top of the declared default, resolved
|
|
7368
|
+
* per field (an invalid/missing value falls back to its default — parse
|
|
7369
|
+
* never throws). Mirrors `media-settings` / `tracking-settings`.
|
|
7370
|
+
*
|
|
7371
|
+
* Keys are namespaced (`packageDrop*`) because the device store is a FLAT
|
|
7372
|
+
* blob shared across every post-analysis section — a bare `enabled` would
|
|
7373
|
+
* collide with another section.
|
|
7374
|
+
*
|
|
7375
|
+
* See docs/superpowers/specs/2026-07-17-package-zones-design.md §3.3 +
|
|
7376
|
+
* docs/superpowers/specs/2026-07-17-detection-config-exposure-design.md
|
|
7377
|
+
* (surface C). Dwell default is 15s per operator decision (the design's
|
|
7378
|
+
* §3.3 draft said 45s).
|
|
7379
|
+
*/
|
|
7380
|
+
var PackageDropSettingsSchema = require_dist.object({
|
|
7381
|
+
/** Master switch — off by default; opt-in per camera (porch/door cams). */
|
|
7382
|
+
packageDropEnabled: require_dist.boolean().default(false),
|
|
7383
|
+
/**
|
|
7384
|
+
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
7385
|
+
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
7386
|
+
* set down and snatched back. Operator default 15s. Values beyond the
|
|
7387
|
+
* stationary promotion window (30s) are satisfied only once the entry
|
|
7388
|
+
* has actually been observed that long.
|
|
7389
|
+
*/
|
|
7390
|
+
packageDropDwellSec: require_dist.number().int().min(0).max(3600).default(15),
|
|
7391
|
+
/** Reject tiny far-field blobs: min bbox area as a fraction of the frame. */
|
|
7392
|
+
packageDropMinBboxAreaFrac: require_dist.number().min(0).max(1).default(.004),
|
|
7393
|
+
/** Emit `package-picked-up` when a delivered package's entry departs. */
|
|
7394
|
+
packageDropPickupEnabled: require_dist.boolean().default(true),
|
|
7395
|
+
/**
|
|
7396
|
+
* Stationary classes that count as a package. MODEL-AGNOSTIC: defaults to
|
|
7397
|
+
* the single `package` class a dedicated parcel model emits; the interim
|
|
7398
|
+
* COCO vector (suitcase/backpack/handbag) already maps to `package`
|
|
7399
|
+
* upstream, so this stays `['package']`.
|
|
7400
|
+
*/
|
|
7401
|
+
packageDropClassFilter: require_dist.array(require_dist.string()).default(["package"])
|
|
7402
|
+
});
|
|
7403
|
+
var PACKAGE_DROP_DEFAULTS = PackageDropSettingsSchema.parse({});
|
|
7404
|
+
/**
|
|
7405
|
+
* Resolve a per-device store blob into typed package-drop settings.
|
|
7406
|
+
* Unknown/invalid fields fall back to the default for that field (never
|
|
7407
|
+
* throws on a bad blob).
|
|
7408
|
+
*/
|
|
7409
|
+
function resolvePackageDropSettings(raw) {
|
|
7410
|
+
const pick = (key) => {
|
|
7411
|
+
const parsed = PackageDropSettingsSchema.shape[key].safeParse(raw[key]);
|
|
7412
|
+
return parsed.success ? parsed.data : PACKAGE_DROP_DEFAULTS[key];
|
|
7413
|
+
};
|
|
7414
|
+
return {
|
|
7415
|
+
packageDropEnabled: pick("packageDropEnabled"),
|
|
7416
|
+
packageDropDwellSec: pick("packageDropDwellSec"),
|
|
7417
|
+
packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
|
|
7418
|
+
packageDropPickupEnabled: pick("packageDropPickupEnabled"),
|
|
7419
|
+
packageDropClassFilter: pick("packageDropClassFilter")
|
|
7420
|
+
};
|
|
7421
|
+
}
|
|
7422
|
+
//#endregion
|
|
7423
|
+
//#region src/pipeline-analytics/pipeline/package-drop-detector.ts
|
|
7424
|
+
/** The class every durable package event carries. */
|
|
7425
|
+
var PACKAGE_EVENT_CLASS = "package";
|
|
7426
|
+
/** Fixed high importance — package delivery is inherently high-signal (§6). */
|
|
7427
|
+
var PACKAGE_IMPORTANCE = 1;
|
|
7428
|
+
/** Object-event `state` used for a delivery (a parked package) / a pick-up
|
|
7429
|
+
* (its departure). Both are valid `TrackState` enum members so the events
|
|
7430
|
+
* stay `getObjectEvents`-output-valid. */
|
|
7431
|
+
var DELIVERED_STATE = "idle";
|
|
7432
|
+
var PICKED_UP_STATE = "left";
|
|
7433
|
+
/** Deterministic durable-event ids keyed on the stationary entry id. */
|
|
7434
|
+
function deliveredEventId(entryId) {
|
|
7435
|
+
return `pa-pkg-${entryId}-delivered`;
|
|
7436
|
+
}
|
|
7437
|
+
function pickedUpEventId(entryId) {
|
|
7438
|
+
return `pa-pkg-${entryId}-pickedup`;
|
|
7439
|
+
}
|
|
7440
|
+
var PackageDropDetector = class {
|
|
7441
|
+
deps;
|
|
7442
|
+
constructor(deps) {
|
|
7443
|
+
this.deps = deps;
|
|
7444
|
+
}
|
|
7445
|
+
/** Single entrypoint — bridge the registry's `onChange` here. Never
|
|
7446
|
+
* throws (telemetry-lossy, D8): a failure only misses one package event. */
|
|
7447
|
+
async onStationaryChange(change) {
|
|
7448
|
+
try {
|
|
7449
|
+
if (change.phase === "appeared") await this.onAppeared(change.entry, change.timestamp);
|
|
7450
|
+
else await this.onDeparted(change.entry, change.timestamp);
|
|
7451
|
+
} catch (err) {
|
|
7452
|
+
this.deps.onError?.("onStationaryChange", err);
|
|
7453
|
+
this.deps.logger.warn("package-drop detector failed on change", {
|
|
7454
|
+
tags: { deviceId: change.entry.deviceId },
|
|
7455
|
+
meta: {
|
|
7456
|
+
phase: change.phase,
|
|
7457
|
+
entryId: change.entry.id,
|
|
7458
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7459
|
+
}
|
|
7460
|
+
});
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7463
|
+
async onAppeared(entry, timestamp) {
|
|
7464
|
+
const settings = await this.deps.resolveSettings(entry.deviceId);
|
|
7465
|
+
if (!settings.packageDropEnabled) return;
|
|
7466
|
+
if (!settings.packageDropClassFilter.includes(entry.className)) return;
|
|
7467
|
+
const rules = (await this.deps.resolvePackageRules(entry.deviceId)).filter((r) => r.enabled !== false);
|
|
7468
|
+
if (rules.length === 0) return;
|
|
7469
|
+
const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
|
|
7470
|
+
const hitZones = computeStationaryEntryZones(entry, await this.deps.resolveZones(entry.deviceId)).filter((z) => ruleZoneIds.has(z));
|
|
7471
|
+
if (hitZones.length === 0) return;
|
|
7472
|
+
if (timestamp - entry.firstSeenAt < settings.packageDropDwellSec * 1e3) return;
|
|
7473
|
+
const frameArea = entry.frameWidth * entry.frameHeight;
|
|
7474
|
+
if (frameArea <= 0) return;
|
|
7475
|
+
if (entry.bbox.w * entry.bbox.h / frameArea < settings.packageDropMinBboxAreaFrac) return;
|
|
7476
|
+
const eventId = deliveredEventId(entry.id);
|
|
7477
|
+
if ((await this.deps.events.queryObject({
|
|
7478
|
+
deviceId: entry.deviceId,
|
|
7479
|
+
classFilter: "package"
|
|
7480
|
+
})).some((e) => e.id === eventId)) return;
|
|
7481
|
+
const ev = {
|
|
7482
|
+
id: eventId,
|
|
7483
|
+
kind: "object",
|
|
7484
|
+
deviceId: entry.deviceId,
|
|
7485
|
+
timestamp,
|
|
7486
|
+
source: "pipeline",
|
|
7487
|
+
trackId: entry.sourceTrackId ?? entry.id,
|
|
7488
|
+
className: PACKAGE_EVENT_CLASS,
|
|
7489
|
+
...entry.label !== void 0 ? { label: entry.label } : {},
|
|
7490
|
+
confidence: PACKAGE_IMPORTANCE,
|
|
7491
|
+
bbox: {
|
|
7492
|
+
x: entry.bbox.x,
|
|
7493
|
+
y: entry.bbox.y,
|
|
7494
|
+
w: entry.bbox.w,
|
|
7495
|
+
h: entry.bbox.h
|
|
7496
|
+
},
|
|
7497
|
+
zones: hitZones,
|
|
7498
|
+
state: DELIVERED_STATE,
|
|
7499
|
+
frameWidth: entry.frameWidth,
|
|
7500
|
+
frameHeight: entry.frameHeight,
|
|
7501
|
+
...entry.keyFrameMediaKey !== void 0 ? { mediaKey: entry.keyFrameMediaKey } : {},
|
|
7502
|
+
importance: PACKAGE_IMPORTANCE
|
|
7503
|
+
};
|
|
7504
|
+
await this.deps.events.insertObject(ev);
|
|
7505
|
+
this.deps.emit.delivered({
|
|
7506
|
+
deviceId: entry.deviceId,
|
|
7507
|
+
entryId: entry.id,
|
|
7508
|
+
eventId,
|
|
7509
|
+
className: entry.className,
|
|
7510
|
+
zoneIds: hitZones,
|
|
7511
|
+
...entry.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: entry.keyFrameMediaKey } : {},
|
|
7512
|
+
bbox: {
|
|
7513
|
+
x: entry.bbox.x,
|
|
7514
|
+
y: entry.bbox.y,
|
|
7515
|
+
w: entry.bbox.w,
|
|
7516
|
+
h: entry.bbox.h
|
|
7517
|
+
},
|
|
7518
|
+
timestamp
|
|
7519
|
+
});
|
|
7520
|
+
}
|
|
7521
|
+
async onDeparted(entry, timestamp) {
|
|
7522
|
+
if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) return;
|
|
7523
|
+
const deliveredId = deliveredEventId(entry.id);
|
|
7524
|
+
const pickedUpId = pickedUpEventId(entry.id);
|
|
7525
|
+
const rows = await this.deps.events.queryObject({
|
|
7526
|
+
deviceId: entry.deviceId,
|
|
7527
|
+
classFilter: PACKAGE_EVENT_CLASS
|
|
7528
|
+
});
|
|
7529
|
+
const delivered = rows.find((e) => e.id === deliveredId);
|
|
7530
|
+
if (delivered === void 0) return;
|
|
7531
|
+
if (rows.some((e) => e.id === pickedUpId)) return;
|
|
7532
|
+
const ev = {
|
|
7533
|
+
id: pickedUpId,
|
|
7534
|
+
kind: "object",
|
|
7535
|
+
deviceId: entry.deviceId,
|
|
7536
|
+
timestamp,
|
|
7537
|
+
source: "pipeline",
|
|
7538
|
+
trackId: entry.sourceTrackId ?? entry.id,
|
|
7539
|
+
className: PACKAGE_EVENT_CLASS,
|
|
7540
|
+
...entry.label !== void 0 ? { label: entry.label } : {},
|
|
7541
|
+
confidence: PACKAGE_IMPORTANCE,
|
|
7542
|
+
bbox: {
|
|
7543
|
+
x: entry.bbox.x,
|
|
7544
|
+
y: entry.bbox.y,
|
|
7545
|
+
w: entry.bbox.w,
|
|
7546
|
+
h: entry.bbox.h
|
|
7547
|
+
},
|
|
7548
|
+
...delivered.zones !== void 0 ? { zones: delivered.zones } : {},
|
|
7549
|
+
state: PICKED_UP_STATE,
|
|
7550
|
+
frameWidth: entry.frameWidth,
|
|
7551
|
+
frameHeight: entry.frameHeight,
|
|
7552
|
+
importance: PACKAGE_IMPORTANCE
|
|
7553
|
+
};
|
|
7554
|
+
await this.deps.events.insertObject(ev);
|
|
7555
|
+
this.deps.emit.pickedUp({
|
|
7556
|
+
deviceId: entry.deviceId,
|
|
7557
|
+
entryId: entry.id,
|
|
7558
|
+
deliveredEventId: deliveredId,
|
|
7559
|
+
className: entry.className,
|
|
7560
|
+
timestamp
|
|
7561
|
+
});
|
|
7562
|
+
}
|
|
7563
|
+
};
|
|
7163
7564
|
function centroidOf(b) {
|
|
7164
7565
|
return {
|
|
7165
7566
|
x: b.x + b.w / 2,
|
|
@@ -10474,10 +10875,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
|
10474
10875
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
10475
10876
|
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
10476
10877
|
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
10477
|
-
* `
|
|
10878
|
+
* `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
10879
|
+
*
|
|
10880
|
+
* `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
|
|
10881
|
+
* now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
|
|
10882
|
+
* tracks (no object event → no `crop`) it is the ONLY subject-centered media —
|
|
10883
|
+
* without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
|
|
10884
|
+
* which is how 65% of the timeline tiles rendered as whole scenes. It sits
|
|
10885
|
+
* right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
|
|
10478
10886
|
*/
|
|
10479
10887
|
var CLEAN_MEDIA_KINDS = [
|
|
10480
10888
|
"crop",
|
|
10889
|
+
"thumbnail",
|
|
10481
10890
|
"fullFrame",
|
|
10482
10891
|
"keyFrame"
|
|
10483
10892
|
];
|
|
@@ -10632,6 +11041,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10632
11041
|
/** GLOBAL face-recognition master switch (addon store), TTL-cached. */
|
|
10633
11042
|
faceGlobalEnabledCache = null;
|
|
10634
11043
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
11044
|
+
packageDropCacheByDevice = /* @__PURE__ */ new Map();
|
|
11045
|
+
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
11046
|
+
packageDropDetector = null;
|
|
10635
11047
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
10636
11048
|
dropoutSkipsByKey = /* @__PURE__ */ new Map();
|
|
10637
11049
|
/** Best (highest-confidence) frame per track — drives the single overwrite
|
|
@@ -10718,7 +11130,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10718
11130
|
let storage = this.ctx.kernel.storage;
|
|
10719
11131
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
10720
11132
|
if (mediaRoot) {
|
|
10721
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
11133
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-E-m2CYYM.js"));
|
|
10722
11134
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
10723
11135
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
10724
11136
|
}
|
|
@@ -10749,6 +11161,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10749
11161
|
timestamp
|
|
10750
11162
|
}
|
|
10751
11163
|
});
|
|
11164
|
+
this.packageDropDetector?.onStationaryChange({
|
|
11165
|
+
phase,
|
|
11166
|
+
entry,
|
|
11167
|
+
timestamp
|
|
11168
|
+
});
|
|
10752
11169
|
}
|
|
10753
11170
|
});
|
|
10754
11171
|
await this.stationaryRegistry.load();
|
|
@@ -10757,15 +11174,55 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10757
11174
|
store: api.settingsStore,
|
|
10758
11175
|
logger: logger.child("MediaStore")
|
|
10759
11176
|
});
|
|
10760
|
-
|
|
11177
|
+
const eventStore = new EventStore({
|
|
10761
11178
|
store: api.settingsStore,
|
|
10762
11179
|
logger: logger.child("EventStore"),
|
|
10763
11180
|
media: this.mediaStore
|
|
10764
11181
|
});
|
|
11182
|
+
this.eventStore = eventStore;
|
|
10765
11183
|
this.sensorEventStore = new SensorEventStore({
|
|
10766
11184
|
store: api.settingsStore,
|
|
10767
11185
|
logger: logger.child("SensorEventStore")
|
|
10768
11186
|
});
|
|
11187
|
+
this.packageDropDetector = new PackageDropDetector({
|
|
11188
|
+
events: eventStore,
|
|
11189
|
+
emit: {
|
|
11190
|
+
delivered: (payload) => {
|
|
11191
|
+
this.ctx.eventBus.emit({
|
|
11192
|
+
id: `pa-package-delivered-${payload.entryId}`,
|
|
11193
|
+
timestamp: new Date(payload.timestamp),
|
|
11194
|
+
source: {
|
|
11195
|
+
type: "addon",
|
|
11196
|
+
id: "pipeline-analytics",
|
|
11197
|
+
addonId: "pipeline-analytics"
|
|
11198
|
+
},
|
|
11199
|
+
category: require_dist.EventCategory.PipelineAnalyticsPackageDelivered,
|
|
11200
|
+
data: { ...payload }
|
|
11201
|
+
});
|
|
11202
|
+
},
|
|
11203
|
+
pickedUp: (payload) => {
|
|
11204
|
+
this.ctx.eventBus.emit({
|
|
11205
|
+
id: `pa-package-pickedup-${payload.entryId}`,
|
|
11206
|
+
timestamp: new Date(payload.timestamp),
|
|
11207
|
+
source: {
|
|
11208
|
+
type: "addon",
|
|
11209
|
+
id: "pipeline-analytics",
|
|
11210
|
+
addonId: "pipeline-analytics"
|
|
11211
|
+
},
|
|
11212
|
+
category: require_dist.EventCategory.PipelineAnalyticsPackagePickedUp,
|
|
11213
|
+
data: { ...payload }
|
|
11214
|
+
});
|
|
11215
|
+
}
|
|
11216
|
+
},
|
|
11217
|
+
logger: logger.child("PackageDropDetector"),
|
|
11218
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
|
|
11219
|
+
resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
|
|
11220
|
+
resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
|
|
11221
|
+
onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
|
|
11222
|
+
scope,
|
|
11223
|
+
error: require_dist.errMsg(err)
|
|
11224
|
+
} })
|
|
11225
|
+
});
|
|
10769
11226
|
this.linkedCamerasCache = new LinkedCamerasCache({
|
|
10770
11227
|
cameras: { listCameraIds: async () => {
|
|
10771
11228
|
return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
|
|
@@ -11045,6 +11502,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11045
11502
|
this.trackingCacheByDevice.delete(data.deviceId);
|
|
11046
11503
|
this.faceCacheByDevice.delete(data.deviceId);
|
|
11047
11504
|
this.mediaCacheByDevice.delete(data.deviceId);
|
|
11505
|
+
this.packageDropCacheByDevice.delete(data.deviceId);
|
|
11048
11506
|
}
|
|
11049
11507
|
});
|
|
11050
11508
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
@@ -11060,6 +11518,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11060
11518
|
this.trackingCacheByDevice.delete(deviceId);
|
|
11061
11519
|
this.faceCacheByDevice.delete(deviceId);
|
|
11062
11520
|
this.mediaCacheByDevice.delete(deviceId);
|
|
11521
|
+
this.packageDropCacheByDevice.delete(deviceId);
|
|
11063
11522
|
this.bindingCache?.invalidate(deviceId);
|
|
11064
11523
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
11065
11524
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -11407,6 +11866,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11407
11866
|
this.faceCacheByDevice.clear();
|
|
11408
11867
|
this.faceGlobalEnabledCache = null;
|
|
11409
11868
|
this.mediaCacheByDevice.clear();
|
|
11869
|
+
this.packageDropCacheByDevice.clear();
|
|
11410
11870
|
this.trackStore?.clearAll();
|
|
11411
11871
|
this.stationaryRegistry = null;
|
|
11412
11872
|
this.bindingCache?.clearAll();
|
|
@@ -11638,7 +12098,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11638
12098
|
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
11639
12099
|
}
|
|
11640
12100
|
}
|
|
11641
|
-
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
12101
|
+
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
|
|
11642
12102
|
const byState = {};
|
|
11643
12103
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
11644
12104
|
log.info("frame processed", { meta: {
|
|
@@ -11648,10 +12108,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11648
12108
|
newTracks: newTrackCount,
|
|
11649
12109
|
lostTracks: lostTrackCount,
|
|
11650
12110
|
objectEvents: result.objectEvents.length,
|
|
12111
|
+
appearanceEvents: result.appearanceEvents.length,
|
|
11651
12112
|
byState
|
|
11652
12113
|
} });
|
|
11653
12114
|
}
|
|
11654
|
-
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
12115
|
+
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
11655
12116
|
const objectEmbeddingBests = [];
|
|
11656
12117
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
11657
12118
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -11669,8 +12130,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11669
12130
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
11670
12131
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
11671
12132
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
11672
|
-
const
|
|
11673
|
-
const
|
|
12133
|
+
const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
|
|
12134
|
+
const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
|
|
12135
|
+
const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
11674
12136
|
const childCrops = childCropsByEvent.get(e.id);
|
|
11675
12137
|
return {
|
|
11676
12138
|
eventId: e.id,
|
|
@@ -11880,6 +12342,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11880
12342
|
});
|
|
11881
12343
|
return settings;
|
|
11882
12344
|
}
|
|
12345
|
+
async resolveDevicePackageDropSettings(deviceId) {
|
|
12346
|
+
const now = Date.now();
|
|
12347
|
+
const cached = this.packageDropCacheByDevice.get(deviceId);
|
|
12348
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
12349
|
+
const settings = resolvePackageDropSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
12350
|
+
this.packageDropCacheByDevice.set(deviceId, {
|
|
12351
|
+
settings,
|
|
12352
|
+
expiresAt: now + SETTINGS_CACHE_TTL_MS
|
|
12353
|
+
});
|
|
12354
|
+
return settings;
|
|
12355
|
+
}
|
|
12356
|
+
/**
|
|
12357
|
+
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
12358
|
+
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
12359
|
+
* when the cached slice is empty, forces one refresh. The `package` slice
|
|
12360
|
+
* is written by the orchestrator's package-stage provider (a later slice);
|
|
12361
|
+
* until then this returns `[]` and no package events fire.
|
|
12362
|
+
*/
|
|
12363
|
+
async resolveDevicePackageRules(deviceId) {
|
|
12364
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12365
|
+
if (!proxy) return [];
|
|
12366
|
+
const cached = proxy.state.zoneRules.value?.package;
|
|
12367
|
+
if (cached && cached.length > 0) return cached;
|
|
12368
|
+
await proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
12369
|
+
return proxy.state.zoneRules.value?.package ?? [];
|
|
12370
|
+
}
|
|
11883
12371
|
/**
|
|
11884
12372
|
* Route one track's `runDetailSubtree` results (two-plane detail dispatch)
|
|
11885
12373
|
* into the EXISTING per-track consumers, discriminated by payload SHAPE:
|
|
@@ -12224,7 +12712,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12224
12712
|
movementThreshold: media.snapshotMovementThreshold,
|
|
12225
12713
|
maxIdleMs: media.snapshotMaxIdleMs
|
|
12226
12714
|
}).capture;
|
|
12227
|
-
const
|
|
12715
|
+
const edgeClear = isEdgeClear({
|
|
12716
|
+
bbox: t.bbox,
|
|
12717
|
+
frameWidth,
|
|
12718
|
+
frameHeight
|
|
12719
|
+
});
|
|
12720
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
|
|
12228
12721
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
12229
12722
|
const plan = planPeriodicMedia({
|
|
12230
12723
|
saveThumbnails: media.saveThumbnails,
|
|
@@ -12976,6 +13469,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12976
13469
|
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
12977
13470
|
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
12978
13471
|
observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
|
|
13472
|
+
packageZonesEnabled: async (deviceId) => {
|
|
13473
|
+
return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
|
|
13474
|
+
},
|
|
12979
13475
|
onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
|
|
12980
13476
|
tags: { deviceId: input.deviceId },
|
|
12981
13477
|
meta: {
|
|
@@ -13639,6 +14135,51 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13639
14135
|
}
|
|
13640
14136
|
]
|
|
13641
14137
|
},
|
|
14138
|
+
{
|
|
14139
|
+
id: "package-drop",
|
|
14140
|
+
title: "Package detection",
|
|
14141
|
+
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.",
|
|
14142
|
+
columns: 2,
|
|
14143
|
+
fields: [
|
|
14144
|
+
{
|
|
14145
|
+
type: "boolean",
|
|
14146
|
+
key: "packageDropEnabled",
|
|
14147
|
+
label: "Enable package detection",
|
|
14148
|
+
description: "Turn package delivered / picked-up detection on for this camera (off elsewhere).",
|
|
14149
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropEnabled
|
|
14150
|
+
},
|
|
14151
|
+
{
|
|
14152
|
+
type: "boolean",
|
|
14153
|
+
key: "packageDropPickupEnabled",
|
|
14154
|
+
label: "Emit pick-up",
|
|
14155
|
+
description: "Also emit a package-picked-up event when the delivered package leaves.",
|
|
14156
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
|
|
14157
|
+
},
|
|
14158
|
+
{
|
|
14159
|
+
type: "slider",
|
|
14160
|
+
key: "packageDropDwellSec",
|
|
14161
|
+
label: "Minimum dwell",
|
|
14162
|
+
description: "How long a package must stay parked before it counts as a delivery. Higher = fewer false positives from bags briefly set down.",
|
|
14163
|
+
min: 0,
|
|
14164
|
+
max: 300,
|
|
14165
|
+
step: 5,
|
|
14166
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropDwellSec,
|
|
14167
|
+
showValue: true,
|
|
14168
|
+
unit: "s"
|
|
14169
|
+
},
|
|
14170
|
+
{
|
|
14171
|
+
type: "slider",
|
|
14172
|
+
key: "packageDropMinBboxAreaFrac",
|
|
14173
|
+
label: "Minimum package size",
|
|
14174
|
+
description: "Reject tiny far-field blobs: the package box must cover at least this fraction of the frame.",
|
|
14175
|
+
min: 0,
|
|
14176
|
+
max: .1,
|
|
14177
|
+
step: .001,
|
|
14178
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropMinBboxAreaFrac,
|
|
14179
|
+
showValue: true
|
|
14180
|
+
}
|
|
14181
|
+
]
|
|
14182
|
+
},
|
|
13642
14183
|
{
|
|
13643
14184
|
id: "audio-detection",
|
|
13644
14185
|
title: "Audio detection",
|