@camstack/addon-post-analysis 1.1.36 → 1.1.37
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-Bc4L7FGf.js → dist-CThBV9dq.js} +0 -55
- package/dist/{dist-8DTQLWKO.mjs → dist-DPBet4IQ.mjs} +0 -55
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-BRwocT7C.js → node-CDbDhtGa.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CMH9Gs68.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-oxNx9gzC.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DdC1GJfs.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-Bhr2CNHt.mjs → hostInit-Do2pU2J8.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +354 -38
- package/dist/pipeline-analytics/index.mjs +353 -37
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DLzy78dH.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-CThBV9dq.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -2137,6 +2137,29 @@ function isEdgeClear(input) {
|
|
|
2137
2137
|
return true;
|
|
2138
2138
|
}
|
|
2139
2139
|
/**
|
|
2140
|
+
* Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
|
|
2141
|
+
* centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
|
|
2142
|
+
* — a cheap proxy for "is the subject well-framed?" used as the best-frame
|
|
2143
|
+
* tie-breaker so a low-importance short track stops locking in an edge-of-frame
|
|
2144
|
+
* subject when a better-centred, near-equal-confidence frame is available.
|
|
2145
|
+
*
|
|
2146
|
+
* The score is `1 - normalizedDistance(centre → frameCentre)`, where the
|
|
2147
|
+
* distance is normalised by the max possible (centre → corner) so it is
|
|
2148
|
+
* scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
|
|
2149
|
+
* falls back to pure confidence, matching {@link isEdgeClear}).
|
|
2150
|
+
*/
|
|
2151
|
+
function bboxCenterScore(bbox, frameWidth, frameHeight) {
|
|
2152
|
+
if (frameWidth <= 0 || frameHeight <= 0) return 1;
|
|
2153
|
+
const cx = bbox.x + bbox.w / 2;
|
|
2154
|
+
const cy = bbox.y + bbox.h / 2;
|
|
2155
|
+
const fcx = frameWidth / 2;
|
|
2156
|
+
const fcy = frameHeight / 2;
|
|
2157
|
+
const dx = (cx - fcx) / fcx;
|
|
2158
|
+
const dy = (cy - fcy) / fcy;
|
|
2159
|
+
const dist = Math.hypot(dx, dy) / Math.SQRT2;
|
|
2160
|
+
return Math.max(0, Math.min(1, 1 - dist));
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2140
2163
|
* Edge-aware "is `candidate` a new best over `current`?" comparator.
|
|
2141
2164
|
*
|
|
2142
2165
|
* Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
|
|
@@ -2144,13 +2167,25 @@ function isEdgeClear(input) {
|
|
|
2144
2167
|
* confidence past the `hysteresis` margin wins. The tier upgrade
|
|
2145
2168
|
* (touching → clear) bypasses hysteresis — the first clear frame is always taken.
|
|
2146
2169
|
*
|
|
2170
|
+
* CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
|
|
2171
|
+
* wins on confidence (the two are within the `hysteresis` band) but the
|
|
2172
|
+
* candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
|
|
2173
|
+
* candidate wins. This only engages when both sides carry a `centerScore` (the
|
|
2174
|
+
* best-frame path), so low-importance short tracks stop keeping an edge-of-frame
|
|
2175
|
+
* subject over an equally-confident, better-framed one. The face /
|
|
2176
|
+
* object-embedding callers omit `centerScore` → identical legacy behaviour.
|
|
2177
|
+
*
|
|
2147
2178
|
* Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
|
|
2148
2179
|
* not here, so this stays a pure value comparison.
|
|
2149
2180
|
*/
|
|
2150
2181
|
function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
2151
2182
|
if (candidate.edgeClear && !current.edgeClear) return true;
|
|
2152
2183
|
if (!candidate.edgeClear && current.edgeClear) return false;
|
|
2153
|
-
|
|
2184
|
+
if (candidate.confidence > current.confidence + hysteresis) return true;
|
|
2185
|
+
if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
|
|
2186
|
+
if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
|
|
2187
|
+
}
|
|
2188
|
+
return false;
|
|
2154
2189
|
}
|
|
2155
2190
|
//#endregion
|
|
2156
2191
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
@@ -2186,6 +2221,10 @@ var BestDetectionTracker = class {
|
|
|
2186
2221
|
* the edge tier is not in play for the track (treated as clear → the legacy
|
|
2187
2222
|
* pure-confidence policy). */
|
|
2188
2223
|
edgeClear = /* @__PURE__ */ new Map();
|
|
2224
|
+
/** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
|
|
2225
|
+
* does not supply centering (face / object-embedding paths) → the centering
|
|
2226
|
+
* tie-break is disabled and the legacy confidence policy applies. */
|
|
2227
|
+
centerScore = /* @__PURE__ */ new Map();
|
|
2189
2228
|
constructor(options = {}) {
|
|
2190
2229
|
this.hysteresis = options.hysteresis ?? 0;
|
|
2191
2230
|
this.minGapMs = options.minGapMs ?? 0;
|
|
@@ -2202,7 +2241,7 @@ var BestDetectionTracker = class {
|
|
|
2202
2241
|
* the classic policy holds: a confidence past the `hysteresis` margin that also
|
|
2203
2242
|
* respects `minGapMs` wins. On acceptance the held peak advances.
|
|
2204
2243
|
*/
|
|
2205
|
-
observe(trackId, confidence, timestamp, edgeClear) {
|
|
2244
|
+
observe(trackId, confidence, timestamp, edgeClear, centerScore) {
|
|
2206
2245
|
const cur = this.best.get(trackId);
|
|
2207
2246
|
if (cur === void 0) {
|
|
2208
2247
|
this.best.set(trackId, {
|
|
@@ -2210,16 +2249,20 @@ var BestDetectionTracker = class {
|
|
|
2210
2249
|
atMs: timestamp
|
|
2211
2250
|
});
|
|
2212
2251
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2252
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2213
2253
|
return true;
|
|
2214
2254
|
}
|
|
2215
2255
|
const curClear = this.edgeClear.get(trackId) ?? true;
|
|
2216
2256
|
const candClear = edgeClear ?? true;
|
|
2257
|
+
const curCenter = this.centerScore.get(trackId);
|
|
2217
2258
|
const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
|
|
2218
2259
|
confidence: cur.confidence,
|
|
2219
|
-
edgeClear: curClear
|
|
2260
|
+
edgeClear: curClear,
|
|
2261
|
+
...curCenter !== void 0 ? { centerScore: curCenter } : {}
|
|
2220
2262
|
}, {
|
|
2221
2263
|
confidence,
|
|
2222
|
-
edgeClear: candClear
|
|
2264
|
+
edgeClear: candClear,
|
|
2265
|
+
...centerScore !== void 0 ? { centerScore } : {}
|
|
2223
2266
|
}, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
|
|
2224
2267
|
if (isNewBest) {
|
|
2225
2268
|
this.best.set(trackId, {
|
|
@@ -2227,6 +2270,7 @@ var BestDetectionTracker = class {
|
|
|
2227
2270
|
atMs: timestamp
|
|
2228
2271
|
});
|
|
2229
2272
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2273
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2230
2274
|
}
|
|
2231
2275
|
return isNewBest;
|
|
2232
2276
|
}
|
|
@@ -2238,10 +2282,12 @@ var BestDetectionTracker = class {
|
|
|
2238
2282
|
delete(trackId) {
|
|
2239
2283
|
this.best.delete(trackId);
|
|
2240
2284
|
this.edgeClear.delete(trackId);
|
|
2285
|
+
this.centerScore.delete(trackId);
|
|
2241
2286
|
}
|
|
2242
2287
|
clear() {
|
|
2243
2288
|
this.best.clear();
|
|
2244
2289
|
this.edgeClear.clear();
|
|
2290
|
+
this.centerScore.clear();
|
|
2245
2291
|
}
|
|
2246
2292
|
};
|
|
2247
2293
|
//#endregion
|
|
@@ -2394,6 +2440,9 @@ var WAKE_ASSOC_IOU = .1;
|
|
|
2394
2440
|
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2395
2441
|
*/
|
|
2396
2442
|
var PROMOTION_WINDOW_MS = 3e4;
|
|
2443
|
+
/** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
|
|
2444
|
+
* long (object removed while unobserved, or a long occlusion) → retire it. */
|
|
2445
|
+
var ENTRY_TTL_MS = 5 * 6e4;
|
|
2397
2446
|
var DEFAULT_MATCH_CONFIG = {
|
|
2398
2447
|
suppressIou: SUPPRESS_IOU,
|
|
2399
2448
|
wakeAssocIou: WAKE_ASSOC_IOU
|
|
@@ -2601,6 +2650,7 @@ var StationaryObjectRegistry = class {
|
|
|
2601
2650
|
logger;
|
|
2602
2651
|
matchConfig;
|
|
2603
2652
|
entryTtlMs;
|
|
2653
|
+
ttlForDevice;
|
|
2604
2654
|
onChange;
|
|
2605
2655
|
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2606
2656
|
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
@@ -2612,6 +2662,7 @@ var StationaryObjectRegistry = class {
|
|
|
2612
2662
|
this.logger = deps.logger;
|
|
2613
2663
|
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2614
2664
|
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2665
|
+
this.ttlForDevice = deps.ttlForDevice;
|
|
2615
2666
|
this.onChange = deps.onChange;
|
|
2616
2667
|
}
|
|
2617
2668
|
static async declare(store) {
|
|
@@ -2667,7 +2718,7 @@ var StationaryObjectRegistry = class {
|
|
|
2667
2718
|
* entries. PURE with respect to registry state — apply the outcome with
|
|
2668
2719
|
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2669
2720
|
*/
|
|
2670
|
-
filter(input) {
|
|
2721
|
+
filter(input, config) {
|
|
2671
2722
|
const entries = this.list(input.deviceId);
|
|
2672
2723
|
if (entries.length === 0) return {
|
|
2673
2724
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
@@ -2677,7 +2728,7 @@ var StationaryObjectRegistry = class {
|
|
|
2677
2728
|
return partitionDetectionsAgainstRegistry({
|
|
2678
2729
|
entries,
|
|
2679
2730
|
detections: input.detections,
|
|
2680
|
-
config: this.matchConfig
|
|
2731
|
+
config: config ?? this.matchConfig
|
|
2681
2732
|
});
|
|
2682
2733
|
}
|
|
2683
2734
|
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
@@ -2746,7 +2797,8 @@ var StationaryObjectRegistry = class {
|
|
|
2746
2797
|
for (const [deviceId, m] of this.byDevice) {
|
|
2747
2798
|
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2748
2799
|
if (observedAt === void 0) continue;
|
|
2749
|
-
|
|
2800
|
+
const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
|
|
2801
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
|
|
2750
2802
|
m.delete(id);
|
|
2751
2803
|
this.dirty.delete(id);
|
|
2752
2804
|
retired.push(e);
|
|
@@ -2895,6 +2947,55 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2895
2947
|
return matched;
|
|
2896
2948
|
}
|
|
2897
2949
|
//#endregion
|
|
2950
|
+
//#region src/pipeline-analytics/stationary-settings.ts
|
|
2951
|
+
/**
|
|
2952
|
+
* Per-device stationary-object (parked/idle suppression) settings. Cascade: a
|
|
2953
|
+
* per-device override on top of the global default, resolved per field (an
|
|
2954
|
+
* invalid/missing value falls back to its default — parse never throws).
|
|
2955
|
+
* Mirrors `media-settings` / `tracking-settings`.
|
|
2956
|
+
*
|
|
2957
|
+
* The defaults are the SAME constants the registry uses at runtime
|
|
2958
|
+
* (`stationary-types.ts`), imported (not copied) so an unset value and a reset
|
|
2959
|
+
* value both resolve to exactly today's behaviour — "reset == unset == today",
|
|
2960
|
+
* with no drift. Guarded by a unit test asserting the equality.
|
|
2961
|
+
*/
|
|
2962
|
+
var StationarySettingsSchema = require_dist.object({
|
|
2963
|
+
/** Master switch. Off ⇒ no promotion + no suppression for this camera (every
|
|
2964
|
+
* parked object keeps spawning normal tracks). */
|
|
2965
|
+
enabled: require_dist.boolean().default(true),
|
|
2966
|
+
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
2967
|
+
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
2968
|
+
suppressIou: require_dist.number().min(.3).max(.9).default(SUPPRESS_IOU),
|
|
2969
|
+
/** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
|
|
2970
|
+
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
2971
|
+
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
2972
|
+
wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
2973
|
+
/** Recent-window stillness a track must hold to be PROMOTED to a parked
|
|
2974
|
+
* entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
|
|
2975
|
+
promotionWindowMs: require_dist.number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
|
|
2976
|
+
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
2977
|
+
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
2978
|
+
entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
2979
|
+
});
|
|
2980
|
+
var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
|
|
2981
|
+
/**
|
|
2982
|
+
* Resolve a per-device store blob into typed stationary settings. Unknown/invalid
|
|
2983
|
+
* fields fall back to the default for that field (never throws on a bad blob).
|
|
2984
|
+
*/
|
|
2985
|
+
function resolveStationarySettings(raw) {
|
|
2986
|
+
const pick = (key) => {
|
|
2987
|
+
const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
|
|
2988
|
+
return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
|
|
2989
|
+
};
|
|
2990
|
+
return {
|
|
2991
|
+
enabled: pick("enabled"),
|
|
2992
|
+
suppressIou: pick("suppressIou"),
|
|
2993
|
+
wakeAssocIou: pick("wakeAssocIou"),
|
|
2994
|
+
promotionWindowMs: pick("promotionWindowMs"),
|
|
2995
|
+
entryTtlMs: pick("entryTtlMs")
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
//#endregion
|
|
2898
2999
|
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2899
3000
|
/**
|
|
2900
3001
|
* Pure: no side effects. `continuing` = still active from last frame;
|
|
@@ -5828,6 +5929,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
|
|
|
5828
5929
|
}
|
|
5829
5930
|
return inserted;
|
|
5830
5931
|
}
|
|
5932
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
5933
|
+
var FULL_FRAME_QUALITY = 80;
|
|
5934
|
+
/**
|
|
5935
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
5936
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
5937
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
5938
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
5939
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
5940
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
5941
|
+
*/
|
|
5942
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
5943
|
+
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
5944
|
+
fit: "inside",
|
|
5945
|
+
withoutEnlargement: true
|
|
5946
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5947
|
+
}
|
|
5831
5948
|
//#endregion
|
|
5832
5949
|
//#region src/pipeline-analytics/services/synthetic-sensor-track.ts
|
|
5833
5950
|
/**
|
|
@@ -5892,7 +6009,13 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
5892
6009
|
force: true
|
|
5893
6010
|
});
|
|
5894
6011
|
if (snap !== null) {
|
|
5895
|
-
const
|
|
6012
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
6013
|
+
let data = raw;
|
|
6014
|
+
try {
|
|
6015
|
+
data = await downscaleFullFrameJpeg(raw);
|
|
6016
|
+
} catch (err) {
|
|
6017
|
+
this.deps.onError?.("downscaleSnapshot", err);
|
|
6018
|
+
}
|
|
5896
6019
|
mediaKey = await this.deps.media.put({
|
|
5897
6020
|
deviceId: input.cameraId,
|
|
5898
6021
|
ownerKind: "track",
|
|
@@ -6111,7 +6234,10 @@ var EventMediaDispatcher = class {
|
|
|
6111
6234
|
async captureForFrame(input) {
|
|
6112
6235
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
6113
6236
|
const snapshots = input.snapshots ?? [];
|
|
6114
|
-
const empty = {
|
|
6237
|
+
const empty = {
|
|
6238
|
+
storedSnapshots: [],
|
|
6239
|
+
thumbnailTrackIds: []
|
|
6240
|
+
};
|
|
6115
6241
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
6116
6242
|
let decoded;
|
|
6117
6243
|
try {
|
|
@@ -6165,11 +6291,16 @@ var EventMediaDispatcher = class {
|
|
|
6165
6291
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
6166
6292
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
6167
6293
|
const storedSnapshots = [];
|
|
6294
|
+
const thumbnailTrackIds = [];
|
|
6168
6295
|
for (const sn of snapshots) {
|
|
6169
|
-
const
|
|
6170
|
-
if (
|
|
6296
|
+
const res = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
|
|
6297
|
+
if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
|
|
6298
|
+
if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
|
|
6171
6299
|
}
|
|
6172
|
-
return {
|
|
6300
|
+
return {
|
|
6301
|
+
storedSnapshots,
|
|
6302
|
+
thumbnailTrackIds
|
|
6303
|
+
};
|
|
6173
6304
|
}
|
|
6174
6305
|
/**
|
|
6175
6306
|
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
@@ -6180,10 +6311,14 @@ var EventMediaDispatcher = class {
|
|
|
6180
6311
|
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
6181
6312
|
* parked car), not the track's subject. Returns the appended snapshot for
|
|
6182
6313
|
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
6183
|
-
* failed).
|
|
6314
|
+
* failed). `thumbnailWritten` reports whether a best `thumbnail` actually
|
|
6315
|
+
* landed this frame (#27-A) so the caller can stop forcing retries.
|
|
6184
6316
|
*/
|
|
6185
6317
|
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
6186
|
-
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return
|
|
6318
|
+
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
|
|
6319
|
+
storedSnapshot: null,
|
|
6320
|
+
thumbnailWritten: false
|
|
6321
|
+
};
|
|
6187
6322
|
let boxed = null;
|
|
6188
6323
|
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
6189
6324
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
@@ -6218,9 +6353,10 @@ var EventMediaDispatcher = class {
|
|
|
6218
6353
|
};
|
|
6219
6354
|
} catch {}
|
|
6220
6355
|
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
6356
|
+
let thumbnailWritten = false;
|
|
6221
6357
|
if (sn.bestThumbnail) try {
|
|
6222
6358
|
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
6223
|
-
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6359
|
+
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6224
6360
|
} catch (err) {
|
|
6225
6361
|
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
6226
6362
|
tags: { deviceId },
|
|
@@ -6230,9 +6366,12 @@ var EventMediaDispatcher = class {
|
|
|
6230
6366
|
error: err instanceof Error ? err.message : String(err)
|
|
6231
6367
|
}
|
|
6232
6368
|
});
|
|
6233
|
-
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6369
|
+
if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6234
6370
|
}
|
|
6235
|
-
return
|
|
6371
|
+
return {
|
|
6372
|
+
storedSnapshot: stored,
|
|
6373
|
+
thumbnailWritten
|
|
6374
|
+
};
|
|
6236
6375
|
}
|
|
6237
6376
|
/**
|
|
6238
6377
|
* Clean subject-centered crop of `bbox` out of the raw frame — the shared
|
|
@@ -6270,6 +6409,7 @@ var EventMediaDispatcher = class {
|
|
|
6270
6409
|
timestamp,
|
|
6271
6410
|
data
|
|
6272
6411
|
});
|
|
6412
|
+
return true;
|
|
6273
6413
|
} catch (err) {
|
|
6274
6414
|
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
6275
6415
|
tags: { deviceId },
|
|
@@ -6279,6 +6419,7 @@ var EventMediaDispatcher = class {
|
|
|
6279
6419
|
error: err instanceof Error ? err.message : String(err)
|
|
6280
6420
|
}
|
|
6281
6421
|
});
|
|
6422
|
+
return false;
|
|
6282
6423
|
}
|
|
6283
6424
|
}
|
|
6284
6425
|
async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
|
|
@@ -7581,15 +7722,30 @@ var FaceSettingsSchema = require_dist.object({
|
|
|
7581
7722
|
*/
|
|
7582
7723
|
enabled: require_dist.boolean().default(true),
|
|
7583
7724
|
/** Cosine similarity (on L2-normalized arcface vectors) required to match. */
|
|
7584
|
-
similarityThreshold: require_dist.number().min(0).max(1).default(.
|
|
7725
|
+
similarityThreshold: require_dist.number().min(0).max(1).default(.55),
|
|
7585
7726
|
/** Reject ambiguous matches: require best − secondBest ≥ margin. */
|
|
7586
|
-
margin: require_dist.number().min(0).max(1).default(.
|
|
7727
|
+
margin: require_dist.number().min(0).max(1).default(.1),
|
|
7587
7728
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
7588
7729
|
minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
|
|
7730
|
+
/**
|
|
7731
|
+
* Minimum face bbox size (px, shorter side of the face box in detection-frame
|
|
7732
|
+
* space) for a face to be eligible for embedding-based auto-matching. Below
|
|
7733
|
+
* this, ArcFace resolution is unreliable and auto-assignment produces the
|
|
7734
|
+
* observed false positives (tiny/distant faces collapsing onto one identity).
|
|
7735
|
+
* Such faces are dropped BEFORE matching/enrolment (#26.1).
|
|
7736
|
+
*/
|
|
7737
|
+
minFacePx: require_dist.number().min(0).default(30),
|
|
7738
|
+
/**
|
|
7739
|
+
* Minimum enrolled-sample count an identity must have before it can be an
|
|
7740
|
+
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
7741
|
+
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
7742
|
+
* automatic matching until more samples are enrolled (#26.3).
|
|
7743
|
+
*/
|
|
7744
|
+
minIdentitySamples: require_dist.number().int().min(1).default(2),
|
|
7589
7745
|
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
7590
7746
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
7591
7747
|
* such a value falls back to the default). */
|
|
7592
|
-
confirmFrames: require_dist.number().int().min(1).default(
|
|
7748
|
+
confirmFrames: require_dist.number().int().min(1).default(3),
|
|
7593
7749
|
/** Recent-faces buffer retention (days). */
|
|
7594
7750
|
bufferRetentionDays: require_dist.number().min(0).default(3),
|
|
7595
7751
|
/** Max buffered faces kept per device. */
|
|
@@ -7606,6 +7762,8 @@ function resolveFaceSettings(raw) {
|
|
|
7606
7762
|
similarityThreshold: pick("similarityThreshold"),
|
|
7607
7763
|
margin: pick("margin"),
|
|
7608
7764
|
minFaceConfidence: pick("minFaceConfidence"),
|
|
7765
|
+
minFacePx: pick("minFacePx"),
|
|
7766
|
+
minIdentitySamples: pick("minIdentitySamples"),
|
|
7609
7767
|
confirmFrames: pick("confirmFrames"),
|
|
7610
7768
|
bufferRetentionDays: pick("bufferRetentionDays"),
|
|
7611
7769
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
@@ -7941,10 +8099,12 @@ function evaluatePeriodicSnapshot(input) {
|
|
|
7941
8099
|
*/
|
|
7942
8100
|
function planPeriodicMedia(input) {
|
|
7943
8101
|
const appendSnapshot = input.dueSnapshot;
|
|
8102
|
+
const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
|
|
8103
|
+
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
7944
8104
|
return {
|
|
7945
8105
|
appendSnapshot,
|
|
7946
|
-
rollingLastFrame
|
|
7947
|
-
bestThumbnail: input.isNewBest
|
|
8106
|
+
rollingLastFrame,
|
|
8107
|
+
bestThumbnail: input.isNewBest || !thumbnailLanded
|
|
7948
8108
|
};
|
|
7949
8109
|
}
|
|
7950
8110
|
//#endregion
|
|
@@ -8927,6 +9087,21 @@ var ObjectEmbeddingStore = class {
|
|
|
8927
9087
|
//#endregion
|
|
8928
9088
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
8929
9089
|
/**
|
|
9090
|
+
* Count enrolled samples per identity for probes of a matching model+dimension.
|
|
9091
|
+
* Only identities meeting `minIdentitySamples` are eligible auto-match targets.
|
|
9092
|
+
*/
|
|
9093
|
+
function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
|
|
9094
|
+
const counts = /* @__PURE__ */ new Map();
|
|
9095
|
+
for (const s of gallery) {
|
|
9096
|
+
if (s.modelId !== probeModelId) continue;
|
|
9097
|
+
if (s.embedding.length !== probeDim) continue;
|
|
9098
|
+
counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
|
|
9099
|
+
}
|
|
9100
|
+
const eligible = /* @__PURE__ */ new Set();
|
|
9101
|
+
for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
|
|
9102
|
+
return eligible;
|
|
9103
|
+
}
|
|
9104
|
+
/**
|
|
8930
9105
|
* Assign at most one identity per track AND at most one track per identity for
|
|
8931
9106
|
* a single frame. Greedy by score: compute every candidate's full ranked match
|
|
8932
9107
|
* list, then repeatedly take the globally-highest (track, identity) pair whose
|
|
@@ -8937,10 +9112,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
|
|
|
8937
9112
|
const pairs = [];
|
|
8938
9113
|
candidates.forEach((c, trackIdx) => {
|
|
8939
9114
|
const probeVec = new Float32Array(c.embedding);
|
|
9115
|
+
const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
|
|
8940
9116
|
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
8941
9117
|
for (const s of gallery) {
|
|
8942
9118
|
if (s.modelId !== c.modelId) continue;
|
|
8943
9119
|
if (s.embedding.length !== c.embedding.length) continue;
|
|
9120
|
+
if (!eligible.has(s.identityId)) continue;
|
|
8944
9121
|
const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
|
|
8945
9122
|
const prev = bestByIdentity.get(s.identityId);
|
|
8946
9123
|
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
@@ -9086,7 +9263,7 @@ var FaceRecognizer = class {
|
|
|
9086
9263
|
}
|
|
9087
9264
|
async processFrame(input) {
|
|
9088
9265
|
const { settings } = input;
|
|
9089
|
-
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
|
|
9266
|
+
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.faceBbox === void 0 || Math.min(t.faceBbox.w, t.faceBbox.h) >= settings.minFacePx));
|
|
9090
9267
|
if (candidates.length === 0) return;
|
|
9091
9268
|
this.deps.logger.debug("face: frame candidates", {
|
|
9092
9269
|
tags: { deviceId: input.deviceId },
|
|
@@ -9102,7 +9279,8 @@ var FaceRecognizer = class {
|
|
|
9102
9279
|
modelId: c.embeddingModelId
|
|
9103
9280
|
})), this.gallery, {
|
|
9104
9281
|
threshold: settings.similarityThreshold,
|
|
9105
|
-
margin: settings.margin
|
|
9282
|
+
margin: settings.margin,
|
|
9283
|
+
minIdentitySamples: settings.minIdentitySamples
|
|
9106
9284
|
}) : /* @__PURE__ */ new Map();
|
|
9107
9285
|
const labelWork = [];
|
|
9108
9286
|
for (const c of candidates) {
|
|
@@ -11349,6 +11527,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11349
11527
|
faceGlobalEnabledCache = null;
|
|
11350
11528
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
11351
11529
|
packageDropCacheByDevice = /* @__PURE__ */ new Map();
|
|
11530
|
+
/** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
|
|
11531
|
+
* The registry is a single shared instance, so per-(device) suppress/wake IoU,
|
|
11532
|
+
* promotion window, TTL and the master toggle are resolved from this cache at
|
|
11533
|
+
* the partition + promotion + sweep call sites — an operator change takes
|
|
11534
|
+
* effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
|
|
11535
|
+
stationaryCacheByDevice = /* @__PURE__ */ new Map();
|
|
11352
11536
|
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
11353
11537
|
packageDropDetector = null;
|
|
11354
11538
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
@@ -11381,6 +11565,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11381
11565
|
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
11382
11566
|
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
11383
11567
|
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
11568
|
+
/** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
|
|
11569
|
+
* track absent here keeps forcing a best-thumbnail capture every frame until
|
|
11570
|
+
* one lands, so a short / high-churn track whose first capture was dropped
|
|
11571
|
+
* (recycled/blank live frame) still gets a subject crop for the gallery
|
|
11572
|
+
* instead of degrading to a full-scene tile. Cleared on track end + reset. */
|
|
11573
|
+
thumbnailLandedTracks = /* @__PURE__ */ new Set();
|
|
11384
11574
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
11385
11575
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
11386
11576
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -11438,7 +11628,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11438
11628
|
let storage = this.ctx.kernel.storage;
|
|
11439
11629
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
11440
11630
|
if (mediaRoot) {
|
|
11441
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
11631
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CDbDhtGa.js"));
|
|
11442
11632
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
11443
11633
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
11444
11634
|
}
|
|
@@ -11452,6 +11642,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11452
11642
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
11453
11643
|
store: api.settingsStore,
|
|
11454
11644
|
logger: logger.child("StationaryRegistry"),
|
|
11645
|
+
ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
|
|
11455
11646
|
onChange: ({ phase, entry, timestamp }) => {
|
|
11456
11647
|
this.ctx.eventBus.emit({
|
|
11457
11648
|
id: `pa-stationary-${entry.id}-${phase}`,
|
|
@@ -11827,6 +12018,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11827
12018
|
this.faceCacheByDevice.delete(data.deviceId);
|
|
11828
12019
|
this.mediaCacheByDevice.delete(data.deviceId);
|
|
11829
12020
|
this.packageDropCacheByDevice.delete(data.deviceId);
|
|
12021
|
+
this.stationaryCacheByDevice.delete(data.deviceId);
|
|
11830
12022
|
}
|
|
11831
12023
|
});
|
|
11832
12024
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
@@ -11843,6 +12035,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11843
12035
|
this.faceCacheByDevice.delete(deviceId);
|
|
11844
12036
|
this.mediaCacheByDevice.delete(deviceId);
|
|
11845
12037
|
this.packageDropCacheByDevice.delete(deviceId);
|
|
12038
|
+
this.stationaryCacheByDevice.delete(deviceId);
|
|
11846
12039
|
this.bindingCache?.invalidate(deviceId);
|
|
11847
12040
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
11848
12041
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -12181,6 +12374,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12181
12374
|
this.dropoutSkipsByKey.clear();
|
|
12182
12375
|
this.bestFrameTracker.clear();
|
|
12183
12376
|
this.lastFrameAtByTrack.clear();
|
|
12377
|
+
this.thumbnailLandedTracks.clear();
|
|
12184
12378
|
this.trackLifecycleUpdateMem.clear();
|
|
12185
12379
|
this.objectEmbeddingBestSelector.clear();
|
|
12186
12380
|
this.levelStateByDevice.clear();
|
|
@@ -12191,6 +12385,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12191
12385
|
this.faceGlobalEnabledCache = null;
|
|
12192
12386
|
this.mediaCacheByDevice.clear();
|
|
12193
12387
|
this.packageDropCacheByDevice.clear();
|
|
12388
|
+
this.stationaryCacheByDevice.clear();
|
|
12194
12389
|
this.trackStore?.clearAll();
|
|
12195
12390
|
this.stationaryRegistry = null;
|
|
12196
12391
|
this.bindingCache?.clearAll();
|
|
@@ -12235,6 +12430,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12235
12430
|
return;
|
|
12236
12431
|
}
|
|
12237
12432
|
this.dropoutSkipsByKey.set(key, 0);
|
|
12433
|
+
if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
|
|
12238
12434
|
const processor = await this.getOrCreateProcessor(deviceId, source);
|
|
12239
12435
|
const proxy = await this.ensureProxy(deviceId);
|
|
12240
12436
|
const liveZones = proxy?.state.zones.value?.zones ?? [];
|
|
@@ -12374,10 +12570,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12374
12570
|
} });
|
|
12375
12571
|
}
|
|
12376
12572
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
12377
|
-
|
|
12573
|
+
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
12574
|
+
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
12378
12575
|
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
12379
12576
|
if (dims && dims.w > 0 && dims.h > 0) {
|
|
12380
12577
|
const refDiag = Math.hypot(dims.w, dims.h);
|
|
12578
|
+
const promotionConfig = {
|
|
12579
|
+
...DEFAULT_PROMOTION_CONFIG,
|
|
12580
|
+
windowMs: stationarySettings.promotionWindowMs
|
|
12581
|
+
};
|
|
12381
12582
|
for (const t of result.tracked) {
|
|
12382
12583
|
const active = this.trackStore.peekActive(t.trackId);
|
|
12383
12584
|
if (!active) continue;
|
|
@@ -12385,7 +12586,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12385
12586
|
positions: active.positions,
|
|
12386
12587
|
referenceDiagonalPx: refDiag,
|
|
12387
12588
|
now: result.timestamp,
|
|
12388
|
-
config:
|
|
12589
|
+
config: promotionConfig
|
|
12389
12590
|
});
|
|
12390
12591
|
if (!promote) continue;
|
|
12391
12592
|
this.promoteToStationary({
|
|
@@ -12512,6 +12713,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12512
12713
|
},
|
|
12513
12714
|
mediaKey: s.mediaKey
|
|
12514
12715
|
});
|
|
12716
|
+
for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
|
|
12515
12717
|
}).catch(() => {});
|
|
12516
12718
|
}
|
|
12517
12719
|
}
|
|
@@ -12666,6 +12868,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12666
12868
|
});
|
|
12667
12869
|
return settings;
|
|
12668
12870
|
}
|
|
12871
|
+
async resolveDeviceStationarySettings(deviceId) {
|
|
12872
|
+
const now = Date.now();
|
|
12873
|
+
const cached = this.stationaryCacheByDevice.get(deviceId);
|
|
12874
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
12875
|
+
const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
12876
|
+
this.stationaryCacheByDevice.set(deviceId, {
|
|
12877
|
+
settings,
|
|
12878
|
+
expiresAt: now + SETTINGS_CACHE_TTL_MS
|
|
12879
|
+
});
|
|
12880
|
+
return settings;
|
|
12881
|
+
}
|
|
12882
|
+
stationarySettingsFromCache(deviceId) {
|
|
12883
|
+
return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
|
|
12884
|
+
}
|
|
12669
12885
|
async resolveDevicePackageDropSettings(deviceId) {
|
|
12670
12886
|
const now = Date.now();
|
|
12671
12887
|
const cached = this.packageDropCacheByDevice.get(deviceId);
|
|
@@ -12763,6 +12979,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12763
12979
|
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
12764
12980
|
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
12765
12981
|
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
12982
|
+
if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
|
|
12766
12983
|
await this.faceRecognizer.ingestFaceDetail({
|
|
12767
12984
|
deviceId,
|
|
12768
12985
|
trackId,
|
|
@@ -13041,12 +13258,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13041
13258
|
frameWidth,
|
|
13042
13259
|
frameHeight
|
|
13043
13260
|
});
|
|
13044
|
-
const
|
|
13261
|
+
const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
|
|
13262
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
|
|
13045
13263
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
13046
13264
|
const plan = planPeriodicMedia({
|
|
13047
13265
|
saveThumbnails: media.saveThumbnails,
|
|
13048
13266
|
dueSnapshot,
|
|
13049
13267
|
isNewBest,
|
|
13268
|
+
thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
|
|
13050
13269
|
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
13051
13270
|
now: timestamp,
|
|
13052
13271
|
intervalMs: media.snapshotIntervalMs
|
|
@@ -13367,6 +13586,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13367
13586
|
this.bestFrameTracker.delete(t.trackId);
|
|
13368
13587
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
13369
13588
|
this.lastFrameAtByTrack.delete(t.trackId);
|
|
13589
|
+
this.thumbnailLandedTracks.delete(t.trackId);
|
|
13370
13590
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
13371
13591
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
13372
13592
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -13628,6 +13848,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13628
13848
|
this.bestFrameTracker.delete(track.trackId);
|
|
13629
13849
|
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
13630
13850
|
this.lastFrameAtByTrack.delete(track.trackId);
|
|
13851
|
+
this.thumbnailLandedTracks.delete(track.trackId);
|
|
13631
13852
|
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
13632
13853
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
13633
13854
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
@@ -13669,15 +13890,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13669
13890
|
}, source);
|
|
13670
13891
|
if (source === "pipeline" && this.stationaryRegistry) {
|
|
13671
13892
|
const registry = this.stationaryRegistry;
|
|
13672
|
-
p.setStationaryGate({ filter: (input) =>
|
|
13673
|
-
deviceId
|
|
13674
|
-
|
|
13675
|
-
|
|
13676
|
-
|
|
13677
|
-
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13893
|
+
p.setStationaryGate({ filter: (input) => {
|
|
13894
|
+
const s = this.stationarySettingsFromCache(deviceId);
|
|
13895
|
+
if (!s.enabled) return {
|
|
13896
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
13897
|
+
confirmed: [],
|
|
13898
|
+
wokenEntryIds: []
|
|
13899
|
+
};
|
|
13900
|
+
const matchConfig = {
|
|
13901
|
+
suppressIou: s.suppressIou,
|
|
13902
|
+
wakeAssocIou: s.wakeAssocIou
|
|
13903
|
+
};
|
|
13904
|
+
return registry.filter({
|
|
13905
|
+
deviceId,
|
|
13906
|
+
detections: input.detections.map((d) => ({
|
|
13907
|
+
bbox: d.bbox,
|
|
13908
|
+
className: d.class
|
|
13909
|
+
})),
|
|
13910
|
+
frameWidth: input.frameWidth,
|
|
13911
|
+
frameHeight: input.frameHeight
|
|
13912
|
+
}, matchConfig);
|
|
13913
|
+
} });
|
|
13681
13914
|
}
|
|
13682
13915
|
this.processors.set(key, p);
|
|
13683
13916
|
}
|
|
@@ -14740,6 +14973,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14740
14973
|
step: .05,
|
|
14741
14974
|
default: FACE_DEFAULTS.minFaceConfidence
|
|
14742
14975
|
},
|
|
14976
|
+
{
|
|
14977
|
+
type: "number",
|
|
14978
|
+
key: "minFacePx",
|
|
14979
|
+
label: "Min face size",
|
|
14980
|
+
description: "Minimum face box size (shorter side, pixels) for a face to be eligible for auto-matching. Faces smaller than this are too low-resolution for reliable recognition and are ignored. Raise it to suppress false matches on tiny/distant faces.",
|
|
14981
|
+
min: 0,
|
|
14982
|
+
step: 1,
|
|
14983
|
+
default: FACE_DEFAULTS.minFacePx,
|
|
14984
|
+
unit: "px"
|
|
14985
|
+
},
|
|
14986
|
+
{
|
|
14987
|
+
type: "number",
|
|
14988
|
+
key: "minIdentitySamples",
|
|
14989
|
+
label: "Min identity samples",
|
|
14990
|
+
description: "Minimum enrolled sample count before an identity can be an automatic match target. Identities with fewer samples are ignored by auto-matching (prevents a single unreliable sample from attracting many faces).",
|
|
14991
|
+
min: 1,
|
|
14992
|
+
step: 1,
|
|
14993
|
+
default: FACE_DEFAULTS.minIdentitySamples
|
|
14994
|
+
},
|
|
14743
14995
|
{
|
|
14744
14996
|
type: "number",
|
|
14745
14997
|
key: "confirmFrames",
|
|
@@ -14950,6 +15202,70 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14950
15202
|
default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
|
|
14951
15203
|
}
|
|
14952
15204
|
]
|
|
15205
|
+
},
|
|
15206
|
+
{
|
|
15207
|
+
id: "stationary-objects",
|
|
15208
|
+
title: "Stationary objects",
|
|
15209
|
+
tab: "analytics",
|
|
15210
|
+
description: "Parked / idle object suppression — keeps a stopped car from re-spawning tracks and re-flooding events. Per-device overrides on each camera. Defaults match today’s behaviour.",
|
|
15211
|
+
columns: 2,
|
|
15212
|
+
fields: [
|
|
15213
|
+
{
|
|
15214
|
+
type: "boolean",
|
|
15215
|
+
key: "enabled",
|
|
15216
|
+
label: "Suppress parked objects",
|
|
15217
|
+
description: "Promote a stopped object to a lightweight registry entry and suppress its detections from re-spawning tracks. Off = every parked object keeps spawning normal tracks on this camera.",
|
|
15218
|
+
default: STATIONARY_DEFAULTS.enabled
|
|
15219
|
+
},
|
|
15220
|
+
{
|
|
15221
|
+
type: "slider",
|
|
15222
|
+
key: "suppressIou",
|
|
15223
|
+
label: "Suppress IoU",
|
|
15224
|
+
description: "Overlap at/above which a detection is the SAME parked object (unmoved) → its spawn is suppressed. Lower can suppress genuine new tracks near a parked object.",
|
|
15225
|
+
min: .3,
|
|
15226
|
+
max: .9,
|
|
15227
|
+
step: .05,
|
|
15228
|
+
default: STATIONARY_DEFAULTS.suppressIou,
|
|
15229
|
+
showValue: true
|
|
15230
|
+
},
|
|
15231
|
+
{
|
|
15232
|
+
type: "slider",
|
|
15233
|
+
key: "wakeAssocIou",
|
|
15234
|
+
label: "Wake / associate IoU",
|
|
15235
|
+
description: "Minimum overlap for a detection to be treated as a parked entry’s OWN object (confirm or wake it). Too low lets a passing object wake a parked entry (re-flood); too high can miss the real departure.",
|
|
15236
|
+
min: .02,
|
|
15237
|
+
max: .5,
|
|
15238
|
+
step: .02,
|
|
15239
|
+
default: STATIONARY_DEFAULTS.wakeAssocIou,
|
|
15240
|
+
showValue: true
|
|
15241
|
+
},
|
|
15242
|
+
{
|
|
15243
|
+
type: "slider",
|
|
15244
|
+
key: "promotionWindowMs",
|
|
15245
|
+
label: "Promotion stillness window",
|
|
15246
|
+
description: "How long (ms) an object must stay put — and the track must have existed — before it is promoted to a parked entry. Longer = slower to stop a re-spawn flood.",
|
|
15247
|
+
min: 5e3,
|
|
15248
|
+
max: 12e4,
|
|
15249
|
+
step: 1e3,
|
|
15250
|
+
default: STATIONARY_DEFAULTS.promotionWindowMs,
|
|
15251
|
+
showValue: true,
|
|
15252
|
+
unit: "s",
|
|
15253
|
+
displayScale: 1e3
|
|
15254
|
+
},
|
|
15255
|
+
{
|
|
15256
|
+
type: "slider",
|
|
15257
|
+
key: "entryTtlMs",
|
|
15258
|
+
label: "Observed-time TTL",
|
|
15259
|
+
description: "Retire a parked entry after this much OBSERVED (frames-flowing) time without a confirming detection — the object was removed while watched, or a long occlusion.",
|
|
15260
|
+
min: 6e4,
|
|
15261
|
+
max: 18e5,
|
|
15262
|
+
step: 3e4,
|
|
15263
|
+
default: STATIONARY_DEFAULTS.entryTtlMs,
|
|
15264
|
+
showValue: true,
|
|
15265
|
+
unit: "s",
|
|
15266
|
+
displayScale: 1e3
|
|
15267
|
+
}
|
|
15268
|
+
]
|
|
14953
15269
|
}
|
|
14954
15270
|
] });
|
|
14955
15271
|
}
|