@camstack/addon-post-analysis 1.1.36 → 1.1.38
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-8DTQLWKO.mjs → dist-CSMfGdnz.mjs} +13 -55
- package/dist/{dist-Bc4L7FGf.js → dist-_h-RM5Lr.js} +13 -55
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-BRwocT7C.js → node-BiZGDArd.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-B2MjF8pA.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-B4AjHkeF.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-Bhr2CNHt.mjs → hostInit-Ckp6XV52.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +893 -83
- package/dist/pipeline-analytics/index.mjs +891 -83
- 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-_h-RM5Lr.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -1125,8 +1125,29 @@ var DEFAULT_TRACKER_CONFIG = {
|
|
|
1125
1125
|
rescueIouThreshold: .1,
|
|
1126
1126
|
rescueCentroidFactor: .75,
|
|
1127
1127
|
resurrectionWindowMs: 8e3,
|
|
1128
|
-
stationarySpeedPx: 2
|
|
1128
|
+
stationarySpeedPx: 2,
|
|
1129
|
+
dedupEnabled: true,
|
|
1130
|
+
dedupSpawnIou: .6,
|
|
1131
|
+
dedupMergeIou: .6,
|
|
1132
|
+
dedupMergeFrames: 5,
|
|
1133
|
+
personAnimalDedup: true,
|
|
1134
|
+
animalOverPersonMaxScore: .6,
|
|
1135
|
+
animalOverPersonIou: .2,
|
|
1136
|
+
classVotingEnabled: true,
|
|
1137
|
+
classVoteMinFraction: .5,
|
|
1138
|
+
perClassMinScoreEnabled: true,
|
|
1139
|
+
classMinScores: {
|
|
1140
|
+
animal: .45,
|
|
1141
|
+
vehicle: .45
|
|
1142
|
+
},
|
|
1143
|
+
confirmBypassEnabled: false,
|
|
1144
|
+
confirmBypassScore: .9
|
|
1129
1145
|
};
|
|
1146
|
+
/** Macro-classes the duplicate resolver may collapse across (people in
|
|
1147
|
+
* non-upright poses misdetected as animals). Any other cross-class pair is
|
|
1148
|
+
* NEVER a duplicate. */
|
|
1149
|
+
var PERSON_CLASS = "person";
|
|
1150
|
+
var ANIMAL_CLASS = "animal";
|
|
1130
1151
|
var MAX_PATH_LENGTH = 300;
|
|
1131
1152
|
function clamp(value, min, max) {
|
|
1132
1153
|
return Math.max(min, Math.min(max, value));
|
|
@@ -1149,16 +1170,102 @@ function containment(inner, outer) {
|
|
|
1149
1170
|
const innerArea = inner.w * inner.h;
|
|
1150
1171
|
return innerArea > 0 ? iw * ih / innerArea : 0;
|
|
1151
1172
|
}
|
|
1152
|
-
var SortTracker = class {
|
|
1173
|
+
var SortTracker = class SortTracker {
|
|
1153
1174
|
config;
|
|
1154
1175
|
tracks = [];
|
|
1155
1176
|
lostTracks = [];
|
|
1177
|
+
/**
|
|
1178
|
+
* Consecutive-frame overlap streak per unordered live-track pair
|
|
1179
|
+
* (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
|
|
1180
|
+
* merged only once its streak reaches `dedupMergeFrames`. Entries reset the
|
|
1181
|
+
* moment the pair stops overlapping and are pruned when either track dies.
|
|
1182
|
+
*/
|
|
1183
|
+
dupStreaks = /* @__PURE__ */ new Map();
|
|
1156
1184
|
constructor(config = {}) {
|
|
1157
1185
|
this.config = {
|
|
1158
1186
|
...DEFAULT_TRACKER_CONFIG,
|
|
1159
1187
|
...config
|
|
1160
1188
|
};
|
|
1161
1189
|
}
|
|
1190
|
+
/** Whether two macro-classes may be collapsed as the SAME subject. Same class
|
|
1191
|
+
* always; the {person,animal} pair only when `personAnimalDedup` is on. */
|
|
1192
|
+
dedupCompatible(a, b) {
|
|
1193
|
+
if (a === b) return true;
|
|
1194
|
+
if (!this.config.personAnimalDedup) return false;
|
|
1195
|
+
return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
|
|
1196
|
+
}
|
|
1197
|
+
/** Unordered, stable key for a track pair (UUIDs never contain `|`). */
|
|
1198
|
+
static pairKey(a, b) {
|
|
1199
|
+
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* True when spawning a fresh track for `det` would duplicate an already-live
|
|
1203
|
+
* track: (a) it overlaps a concurrent compatible-class track by
|
|
1204
|
+
* `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
|
|
1205
|
+
* concurrent `person` track (person-in-odd-pose false animal). `live` is the
|
|
1206
|
+
* set of tracks already surviving this frame (matched, coasting, resurrected,
|
|
1207
|
+
* and earlier same-frame spawns).
|
|
1208
|
+
*/
|
|
1209
|
+
isDuplicateSpawn(det, live) {
|
|
1210
|
+
for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
|
|
1211
|
+
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
1212
|
+
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
/** The lower-importance track of a duplicate pair — the one to drop. A
|
|
1217
|
+
* `person` always beats a cross-class `animal`; otherwise more hits wins,
|
|
1218
|
+
* ties break to the older track, then the higher score. */
|
|
1219
|
+
static duplicateLoser(a, b) {
|
|
1220
|
+
if (a.class !== b.class) {
|
|
1221
|
+
if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
|
|
1222
|
+
if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
|
|
1223
|
+
}
|
|
1224
|
+
if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
|
|
1225
|
+
if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
|
|
1226
|
+
return a.score >= b.score ? b : a;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Merge concurrent duplicate tracks. Two live compatible-class tracks that
|
|
1230
|
+
* stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
|
|
1231
|
+
* frames are collapsed: the lower-importance one is dropped (NOT graveyarded
|
|
1232
|
+
* — a confirmed duplicate must not resurrect). This is the fix for a subject
|
|
1233
|
+
* the detector double-fires (two boxes each frame, each feeding its OWN
|
|
1234
|
+
* track, so neither is ever "unmatched" for spawn-suppression to catch) and
|
|
1235
|
+
* for a person misdetected as `animal` alongside the real person track.
|
|
1236
|
+
*/
|
|
1237
|
+
resolveDuplicates() {
|
|
1238
|
+
if (!this.config.dedupEnabled) return;
|
|
1239
|
+
const live = this.tracks;
|
|
1240
|
+
const liveIds = new Set(live.map((t) => t.id));
|
|
1241
|
+
for (const key of this.dupStreaks.keys()) {
|
|
1242
|
+
const [a, b] = key.split("|");
|
|
1243
|
+
if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
|
|
1244
|
+
}
|
|
1245
|
+
const dropIds = /* @__PURE__ */ new Set();
|
|
1246
|
+
for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
|
|
1247
|
+
const a = live[i];
|
|
1248
|
+
const b = live[j];
|
|
1249
|
+
if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
|
|
1250
|
+
if (!this.dedupCompatible(a.class, b.class)) continue;
|
|
1251
|
+
const key = SortTracker.pairKey(a.id, b.id);
|
|
1252
|
+
if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
|
|
1253
|
+
this.dupStreaks.delete(key);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const streak = (this.dupStreaks.get(key) ?? 0) + 1;
|
|
1257
|
+
if (streak >= this.config.dedupMergeFrames) {
|
|
1258
|
+
dropIds.add(SortTracker.duplicateLoser(a, b).id);
|
|
1259
|
+
this.dupStreaks.delete(key);
|
|
1260
|
+
} else this.dupStreaks.set(key, streak);
|
|
1261
|
+
}
|
|
1262
|
+
if (dropIds.size === 0) return;
|
|
1263
|
+
this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
|
|
1264
|
+
for (const key of this.dupStreaks.keys()) {
|
|
1265
|
+
const [a, b] = key.split("|");
|
|
1266
|
+
if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1162
1269
|
/** Where a track is expected this frame — extrapolated by velocity while
|
|
1163
1270
|
* coasting (predictiveCoasting), else its last known bbox. A stationary
|
|
1164
1271
|
* track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
|
|
@@ -1193,6 +1300,46 @@ var SortTracker = class {
|
|
|
1193
1300
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
1194
1301
|
return dist <= this.config.rescueCentroidFactor * diag;
|
|
1195
1302
|
}
|
|
1303
|
+
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
1304
|
+
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
1305
|
+
* zero-confidence frame contributes to the frame-count tiebreak. */
|
|
1306
|
+
addClassVote(track, det) {
|
|
1307
|
+
const weight = det.score > 0 ? det.score : Number.EPSILON;
|
|
1308
|
+
track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* The class to REPORT for a track: the confidence-weighted lifetime majority
|
|
1312
|
+
* when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
|
|
1313
|
+
* weight; otherwise the latest-frame class (ambiguous vote or voting off).
|
|
1314
|
+
*/
|
|
1315
|
+
resolveReportedClass(t) {
|
|
1316
|
+
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
|
|
1317
|
+
let bestClass = t.class;
|
|
1318
|
+
let bestVote = -1;
|
|
1319
|
+
let total = 0;
|
|
1320
|
+
for (const [cls, v] of t.classVotes) {
|
|
1321
|
+
total += v;
|
|
1322
|
+
if (v > bestVote) {
|
|
1323
|
+
bestVote = v;
|
|
1324
|
+
bestClass = cls;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (total <= 0) return t.class;
|
|
1328
|
+
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
|
|
1329
|
+
}
|
|
1330
|
+
/** Whether a detection clears its per-class spawn score floor. Only gates NEW
|
|
1331
|
+
* spawns — an established track still matches below its floor. */
|
|
1332
|
+
meetsClassScoreFloor(det) {
|
|
1333
|
+
if (!this.config.perClassMinScoreEnabled) return true;
|
|
1334
|
+
const floor = this.config.classMinScores[det.class] ?? 0;
|
|
1335
|
+
return det.score >= floor;
|
|
1336
|
+
}
|
|
1337
|
+
/** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
|
|
1338
|
+
* a single very-high-confidence detection bypassed the hit gate (fast car). */
|
|
1339
|
+
isConfirmedForEmit(t) {
|
|
1340
|
+
if (t.hits >= this.config.minHits) return true;
|
|
1341
|
+
return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
|
|
1342
|
+
}
|
|
1196
1343
|
update(detections, timestamp) {
|
|
1197
1344
|
if (this.config.maxTrackLifetimeMs > 0) {
|
|
1198
1345
|
const alive = [];
|
|
@@ -1277,6 +1424,7 @@ var SortTracker = class {
|
|
|
1277
1424
|
};
|
|
1278
1425
|
track.path.push(det.bbox);
|
|
1279
1426
|
if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
|
|
1427
|
+
this.addClassVote(track, det);
|
|
1280
1428
|
}
|
|
1281
1429
|
const occluderBoxes = [];
|
|
1282
1430
|
for (const track of matchedTracks) occluderBoxes.push(track.bbox);
|
|
@@ -1326,13 +1474,30 @@ var SortTracker = class {
|
|
|
1326
1474
|
};
|
|
1327
1475
|
best.path.push(det.bbox);
|
|
1328
1476
|
if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
|
|
1477
|
+
this.addClassVote(best, det);
|
|
1329
1478
|
surviving.push(best);
|
|
1330
1479
|
used.add(di);
|
|
1331
1480
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1481
|
+
const unmatchedIdx = [];
|
|
1482
|
+
for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
|
|
1483
|
+
const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
|
|
1484
|
+
unmatchedIdx.sort((ia, ib) => {
|
|
1485
|
+
const da = detections[ia];
|
|
1486
|
+
const db = detections[ib];
|
|
1487
|
+
const r = classRank(da.class) - classRank(db.class);
|
|
1488
|
+
return r !== 0 ? r : db.score - da.score;
|
|
1489
|
+
});
|
|
1490
|
+
for (const di of unmatchedIdx) {
|
|
1334
1491
|
const det = detections[di];
|
|
1492
|
+
if (!this.meetsClassScoreFloor(det)) {
|
|
1493
|
+
used.add(di);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1335
1496
|
if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
|
|
1497
|
+
if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
|
|
1498
|
+
used.add(di);
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1336
1501
|
surviving.push({
|
|
1337
1502
|
id: (0, node_crypto.randomUUID)(),
|
|
1338
1503
|
bbox: det.bbox,
|
|
@@ -1350,12 +1515,14 @@ var SortTracker = class {
|
|
|
1350
1515
|
},
|
|
1351
1516
|
lost: false,
|
|
1352
1517
|
lostAt: 0,
|
|
1353
|
-
resurrectable: true
|
|
1518
|
+
resurrectable: true,
|
|
1519
|
+
classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
|
|
1354
1520
|
});
|
|
1355
1521
|
}
|
|
1356
1522
|
this.tracks = surviving;
|
|
1357
|
-
|
|
1358
|
-
|
|
1523
|
+
this.resolveDuplicates();
|
|
1524
|
+
return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
|
|
1525
|
+
class: this.resolveReportedClass(t),
|
|
1359
1526
|
originalClass: t.originalClass,
|
|
1360
1527
|
score: t.score,
|
|
1361
1528
|
bbox: t.bbox,
|
|
@@ -1384,6 +1551,7 @@ var SortTracker = class {
|
|
|
1384
1551
|
reset() {
|
|
1385
1552
|
this.tracks = [];
|
|
1386
1553
|
this.lostTracks = [];
|
|
1554
|
+
this.dupStreaks.clear();
|
|
1387
1555
|
}
|
|
1388
1556
|
};
|
|
1389
1557
|
//#endregion
|
|
@@ -2137,6 +2305,29 @@ function isEdgeClear(input) {
|
|
|
2137
2305
|
return true;
|
|
2138
2306
|
}
|
|
2139
2307
|
/**
|
|
2308
|
+
* Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
|
|
2309
|
+
* centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
|
|
2310
|
+
* — a cheap proxy for "is the subject well-framed?" used as the best-frame
|
|
2311
|
+
* tie-breaker so a low-importance short track stops locking in an edge-of-frame
|
|
2312
|
+
* subject when a better-centred, near-equal-confidence frame is available.
|
|
2313
|
+
*
|
|
2314
|
+
* The score is `1 - normalizedDistance(centre → frameCentre)`, where the
|
|
2315
|
+
* distance is normalised by the max possible (centre → corner) so it is
|
|
2316
|
+
* scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
|
|
2317
|
+
* falls back to pure confidence, matching {@link isEdgeClear}).
|
|
2318
|
+
*/
|
|
2319
|
+
function bboxCenterScore(bbox, frameWidth, frameHeight) {
|
|
2320
|
+
if (frameWidth <= 0 || frameHeight <= 0) return 1;
|
|
2321
|
+
const cx = bbox.x + bbox.w / 2;
|
|
2322
|
+
const cy = bbox.y + bbox.h / 2;
|
|
2323
|
+
const fcx = frameWidth / 2;
|
|
2324
|
+
const fcy = frameHeight / 2;
|
|
2325
|
+
const dx = (cx - fcx) / fcx;
|
|
2326
|
+
const dy = (cy - fcy) / fcy;
|
|
2327
|
+
const dist = Math.hypot(dx, dy) / Math.SQRT2;
|
|
2328
|
+
return Math.max(0, Math.min(1, 1 - dist));
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2140
2331
|
* Edge-aware "is `candidate` a new best over `current`?" comparator.
|
|
2141
2332
|
*
|
|
2142
2333
|
* Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
|
|
@@ -2144,13 +2335,25 @@ function isEdgeClear(input) {
|
|
|
2144
2335
|
* confidence past the `hysteresis` margin wins. The tier upgrade
|
|
2145
2336
|
* (touching → clear) bypasses hysteresis — the first clear frame is always taken.
|
|
2146
2337
|
*
|
|
2338
|
+
* CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
|
|
2339
|
+
* wins on confidence (the two are within the `hysteresis` band) but the
|
|
2340
|
+
* candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
|
|
2341
|
+
* candidate wins. This only engages when both sides carry a `centerScore` (the
|
|
2342
|
+
* best-frame path), so low-importance short tracks stop keeping an edge-of-frame
|
|
2343
|
+
* subject over an equally-confident, better-framed one. The face /
|
|
2344
|
+
* object-embedding callers omit `centerScore` → identical legacy behaviour.
|
|
2345
|
+
*
|
|
2147
2346
|
* Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
|
|
2148
2347
|
* not here, so this stays a pure value comparison.
|
|
2149
2348
|
*/
|
|
2150
2349
|
function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
2151
2350
|
if (candidate.edgeClear && !current.edgeClear) return true;
|
|
2152
2351
|
if (!candidate.edgeClear && current.edgeClear) return false;
|
|
2153
|
-
|
|
2352
|
+
if (candidate.confidence > current.confidence + hysteresis) return true;
|
|
2353
|
+
if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
|
|
2354
|
+
if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
|
|
2355
|
+
}
|
|
2356
|
+
return false;
|
|
2154
2357
|
}
|
|
2155
2358
|
//#endregion
|
|
2156
2359
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
@@ -2186,6 +2389,10 @@ var BestDetectionTracker = class {
|
|
|
2186
2389
|
* the edge tier is not in play for the track (treated as clear → the legacy
|
|
2187
2390
|
* pure-confidence policy). */
|
|
2188
2391
|
edgeClear = /* @__PURE__ */ new Map();
|
|
2392
|
+
/** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
|
|
2393
|
+
* does not supply centering (face / object-embedding paths) → the centering
|
|
2394
|
+
* tie-break is disabled and the legacy confidence policy applies. */
|
|
2395
|
+
centerScore = /* @__PURE__ */ new Map();
|
|
2189
2396
|
constructor(options = {}) {
|
|
2190
2397
|
this.hysteresis = options.hysteresis ?? 0;
|
|
2191
2398
|
this.minGapMs = options.minGapMs ?? 0;
|
|
@@ -2202,7 +2409,7 @@ var BestDetectionTracker = class {
|
|
|
2202
2409
|
* the classic policy holds: a confidence past the `hysteresis` margin that also
|
|
2203
2410
|
* respects `minGapMs` wins. On acceptance the held peak advances.
|
|
2204
2411
|
*/
|
|
2205
|
-
observe(trackId, confidence, timestamp, edgeClear) {
|
|
2412
|
+
observe(trackId, confidence, timestamp, edgeClear, centerScore) {
|
|
2206
2413
|
const cur = this.best.get(trackId);
|
|
2207
2414
|
if (cur === void 0) {
|
|
2208
2415
|
this.best.set(trackId, {
|
|
@@ -2210,16 +2417,20 @@ var BestDetectionTracker = class {
|
|
|
2210
2417
|
atMs: timestamp
|
|
2211
2418
|
});
|
|
2212
2419
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2420
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2213
2421
|
return true;
|
|
2214
2422
|
}
|
|
2215
2423
|
const curClear = this.edgeClear.get(trackId) ?? true;
|
|
2216
2424
|
const candClear = edgeClear ?? true;
|
|
2425
|
+
const curCenter = this.centerScore.get(trackId);
|
|
2217
2426
|
const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
|
|
2218
2427
|
confidence: cur.confidence,
|
|
2219
|
-
edgeClear: curClear
|
|
2428
|
+
edgeClear: curClear,
|
|
2429
|
+
...curCenter !== void 0 ? { centerScore: curCenter } : {}
|
|
2220
2430
|
}, {
|
|
2221
2431
|
confidence,
|
|
2222
|
-
edgeClear: candClear
|
|
2432
|
+
edgeClear: candClear,
|
|
2433
|
+
...centerScore !== void 0 ? { centerScore } : {}
|
|
2223
2434
|
}, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
|
|
2224
2435
|
if (isNewBest) {
|
|
2225
2436
|
this.best.set(trackId, {
|
|
@@ -2227,6 +2438,7 @@ var BestDetectionTracker = class {
|
|
|
2227
2438
|
atMs: timestamp
|
|
2228
2439
|
});
|
|
2229
2440
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2441
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2230
2442
|
}
|
|
2231
2443
|
return isNewBest;
|
|
2232
2444
|
}
|
|
@@ -2238,10 +2450,12 @@ var BestDetectionTracker = class {
|
|
|
2238
2450
|
delete(trackId) {
|
|
2239
2451
|
this.best.delete(trackId);
|
|
2240
2452
|
this.edgeClear.delete(trackId);
|
|
2453
|
+
this.centerScore.delete(trackId);
|
|
2241
2454
|
}
|
|
2242
2455
|
clear() {
|
|
2243
2456
|
this.best.clear();
|
|
2244
2457
|
this.edgeClear.clear();
|
|
2458
|
+
this.centerScore.clear();
|
|
2245
2459
|
}
|
|
2246
2460
|
};
|
|
2247
2461
|
//#endregion
|
|
@@ -2394,6 +2608,9 @@ var WAKE_ASSOC_IOU = .1;
|
|
|
2394
2608
|
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2395
2609
|
*/
|
|
2396
2610
|
var PROMOTION_WINDOW_MS = 3e4;
|
|
2611
|
+
/** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
|
|
2612
|
+
* long (object removed while unobserved, or a long occlusion) → retire it. */
|
|
2613
|
+
var ENTRY_TTL_MS = 5 * 6e4;
|
|
2397
2614
|
var DEFAULT_MATCH_CONFIG = {
|
|
2398
2615
|
suppressIou: SUPPRESS_IOU,
|
|
2399
2616
|
wakeAssocIou: WAKE_ASSOC_IOU
|
|
@@ -2601,6 +2818,7 @@ var StationaryObjectRegistry = class {
|
|
|
2601
2818
|
logger;
|
|
2602
2819
|
matchConfig;
|
|
2603
2820
|
entryTtlMs;
|
|
2821
|
+
ttlForDevice;
|
|
2604
2822
|
onChange;
|
|
2605
2823
|
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2606
2824
|
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
@@ -2612,6 +2830,7 @@ var StationaryObjectRegistry = class {
|
|
|
2612
2830
|
this.logger = deps.logger;
|
|
2613
2831
|
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2614
2832
|
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2833
|
+
this.ttlForDevice = deps.ttlForDevice;
|
|
2615
2834
|
this.onChange = deps.onChange;
|
|
2616
2835
|
}
|
|
2617
2836
|
static async declare(store) {
|
|
@@ -2667,7 +2886,7 @@ var StationaryObjectRegistry = class {
|
|
|
2667
2886
|
* entries. PURE with respect to registry state — apply the outcome with
|
|
2668
2887
|
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2669
2888
|
*/
|
|
2670
|
-
filter(input) {
|
|
2889
|
+
filter(input, config) {
|
|
2671
2890
|
const entries = this.list(input.deviceId);
|
|
2672
2891
|
if (entries.length === 0) return {
|
|
2673
2892
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
@@ -2677,7 +2896,7 @@ var StationaryObjectRegistry = class {
|
|
|
2677
2896
|
return partitionDetectionsAgainstRegistry({
|
|
2678
2897
|
entries,
|
|
2679
2898
|
detections: input.detections,
|
|
2680
|
-
config: this.matchConfig
|
|
2899
|
+
config: config ?? this.matchConfig
|
|
2681
2900
|
});
|
|
2682
2901
|
}
|
|
2683
2902
|
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
@@ -2746,7 +2965,8 @@ var StationaryObjectRegistry = class {
|
|
|
2746
2965
|
for (const [deviceId, m] of this.byDevice) {
|
|
2747
2966
|
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2748
2967
|
if (observedAt === void 0) continue;
|
|
2749
|
-
|
|
2968
|
+
const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
|
|
2969
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
|
|
2750
2970
|
m.delete(id);
|
|
2751
2971
|
this.dirty.delete(id);
|
|
2752
2972
|
retired.push(e);
|
|
@@ -2895,6 +3115,55 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2895
3115
|
return matched;
|
|
2896
3116
|
}
|
|
2897
3117
|
//#endregion
|
|
3118
|
+
//#region src/pipeline-analytics/stationary-settings.ts
|
|
3119
|
+
/**
|
|
3120
|
+
* Per-device stationary-object (parked/idle suppression) settings. Cascade: a
|
|
3121
|
+
* per-device override on top of the global default, resolved per field (an
|
|
3122
|
+
* invalid/missing value falls back to its default — parse never throws).
|
|
3123
|
+
* Mirrors `media-settings` / `tracking-settings`.
|
|
3124
|
+
*
|
|
3125
|
+
* The defaults are the SAME constants the registry uses at runtime
|
|
3126
|
+
* (`stationary-types.ts`), imported (not copied) so an unset value and a reset
|
|
3127
|
+
* value both resolve to exactly today's behaviour — "reset == unset == today",
|
|
3128
|
+
* with no drift. Guarded by a unit test asserting the equality.
|
|
3129
|
+
*/
|
|
3130
|
+
var StationarySettingsSchema = require_dist.object({
|
|
3131
|
+
/** Master switch. Off ⇒ no promotion + no suppression for this camera (every
|
|
3132
|
+
* parked object keeps spawning normal tracks). */
|
|
3133
|
+
enabled: require_dist.boolean().default(true),
|
|
3134
|
+
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
3135
|
+
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
3136
|
+
suppressIou: require_dist.number().min(.3).max(.9).default(SUPPRESS_IOU),
|
|
3137
|
+
/** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
|
|
3138
|
+
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
3139
|
+
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
3140
|
+
wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
3141
|
+
/** Recent-window stillness a track must hold to be PROMOTED to a parked
|
|
3142
|
+
* entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
|
|
3143
|
+
promotionWindowMs: require_dist.number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
|
|
3144
|
+
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
3145
|
+
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
3146
|
+
entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
3147
|
+
});
|
|
3148
|
+
var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
|
|
3149
|
+
/**
|
|
3150
|
+
* Resolve a per-device store blob into typed stationary settings. Unknown/invalid
|
|
3151
|
+
* fields fall back to the default for that field (never throws on a bad blob).
|
|
3152
|
+
*/
|
|
3153
|
+
function resolveStationarySettings(raw) {
|
|
3154
|
+
const pick = (key) => {
|
|
3155
|
+
const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
|
|
3156
|
+
return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
|
|
3157
|
+
};
|
|
3158
|
+
return {
|
|
3159
|
+
enabled: pick("enabled"),
|
|
3160
|
+
suppressIou: pick("suppressIou"),
|
|
3161
|
+
wakeAssocIou: pick("wakeAssocIou"),
|
|
3162
|
+
promotionWindowMs: pick("promotionWindowMs"),
|
|
3163
|
+
entryTtlMs: pick("entryTtlMs")
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
//#endregion
|
|
2898
3167
|
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2899
3168
|
/**
|
|
2900
3169
|
* Pure: no side effects. `continuing` = still active from last frame;
|
|
@@ -5828,6 +6097,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
|
|
|
5828
6097
|
}
|
|
5829
6098
|
return inserted;
|
|
5830
6099
|
}
|
|
6100
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
6101
|
+
var FULL_FRAME_QUALITY = 80;
|
|
6102
|
+
/**
|
|
6103
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
6104
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
6105
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
6106
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
6107
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
6108
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
6109
|
+
*/
|
|
6110
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
6111
|
+
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
6112
|
+
fit: "inside",
|
|
6113
|
+
withoutEnlargement: true
|
|
6114
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
6115
|
+
}
|
|
5831
6116
|
//#endregion
|
|
5832
6117
|
//#region src/pipeline-analytics/services/synthetic-sensor-track.ts
|
|
5833
6118
|
/**
|
|
@@ -5892,7 +6177,13 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
5892
6177
|
force: true
|
|
5893
6178
|
});
|
|
5894
6179
|
if (snap !== null) {
|
|
5895
|
-
const
|
|
6180
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
6181
|
+
let data = raw;
|
|
6182
|
+
try {
|
|
6183
|
+
data = await downscaleFullFrameJpeg(raw);
|
|
6184
|
+
} catch (err) {
|
|
6185
|
+
this.deps.onError?.("downscaleSnapshot", err);
|
|
6186
|
+
}
|
|
5896
6187
|
mediaKey = await this.deps.media.put({
|
|
5897
6188
|
deviceId: input.cameraId,
|
|
5898
6189
|
ownerKind: "track",
|
|
@@ -5978,6 +6269,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
|
|
|
5978
6269
|
h: Math.round(ch)
|
|
5979
6270
|
};
|
|
5980
6271
|
}
|
|
6272
|
+
/**
|
|
6273
|
+
* The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
|
|
6274
|
+
* NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
|
|
6275
|
+
*
|
|
6276
|
+
* A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
|
|
6277
|
+
* aspect ratio (the native crop path downscales while preserving aspect), so the
|
|
6278
|
+
* region computed from the detection frame's dimensions addresses the exact same
|
|
6279
|
+
* ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
|
|
6280
|
+
* (single source of truth) and divides by the frame dimensions.
|
|
6281
|
+
*/
|
|
6282
|
+
function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
6283
|
+
const region = squareSafeCropRegion(bbox, frame, padding);
|
|
6284
|
+
return {
|
|
6285
|
+
x: region.x / frame.W,
|
|
6286
|
+
y: region.y / frame.H,
|
|
6287
|
+
w: region.w / frame.W,
|
|
6288
|
+
h: region.h / frame.H
|
|
6289
|
+
};
|
|
6290
|
+
}
|
|
5981
6291
|
//#endregion
|
|
5982
6292
|
//#region src/shared/frame/box-drawer.ts
|
|
5983
6293
|
var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
|
|
@@ -6055,10 +6365,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
|
|
|
6055
6365
|
* Small downscaled `thumbnail`s are intentionally left out for now — when we
|
|
6056
6366
|
* reintroduce them they'll be a separate small kind. */
|
|
6057
6367
|
var MEDIA_QUALITY = 88;
|
|
6058
|
-
/** Output dimensions for square-safe 16:9 crops
|
|
6368
|
+
/** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
|
|
6369
|
+
* (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
|
|
6059
6370
|
var CROP_WIDTH = 640;
|
|
6060
6371
|
var CROP_HEIGHT = 360;
|
|
6061
6372
|
var CROP_QUALITY = 80;
|
|
6373
|
+
/**
|
|
6374
|
+
* Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
|
|
6375
|
+
* `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
|
|
6376
|
+
* ≥224px classifier input straight from the runner's native surface, WITHOUT
|
|
6377
|
+
* hauling a full 1920px frame per subject (that width is reserved for the
|
|
6378
|
+
* full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
|
|
6379
|
+
* the ≤640 local crop, so quality never regresses below today's behaviour.
|
|
6380
|
+
*/
|
|
6381
|
+
var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
|
|
6062
6382
|
function caption(className, confidence, label) {
|
|
6063
6383
|
const base = label && label !== className ? `${className} ${label}` : className;
|
|
6064
6384
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
@@ -6111,7 +6431,10 @@ var EventMediaDispatcher = class {
|
|
|
6111
6431
|
async captureForFrame(input) {
|
|
6112
6432
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
6113
6433
|
const snapshots = input.snapshots ?? [];
|
|
6114
|
-
const empty = {
|
|
6434
|
+
const empty = {
|
|
6435
|
+
storedSnapshots: [],
|
|
6436
|
+
thumbnailTrackIds: []
|
|
6437
|
+
};
|
|
6115
6438
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
6116
6439
|
let decoded;
|
|
6117
6440
|
try {
|
|
@@ -6162,14 +6485,19 @@ var EventMediaDispatcher = class {
|
|
|
6162
6485
|
});
|
|
6163
6486
|
return empty;
|
|
6164
6487
|
}
|
|
6165
|
-
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
6488
|
+
for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
|
|
6166
6489
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
6167
6490
|
const storedSnapshots = [];
|
|
6491
|
+
const thumbnailTrackIds = [];
|
|
6168
6492
|
for (const sn of snapshots) {
|
|
6169
|
-
const
|
|
6170
|
-
if (
|
|
6493
|
+
const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
|
|
6494
|
+
if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
|
|
6495
|
+
if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
|
|
6171
6496
|
}
|
|
6172
|
-
return {
|
|
6497
|
+
return {
|
|
6498
|
+
storedSnapshots,
|
|
6499
|
+
thumbnailTrackIds
|
|
6500
|
+
};
|
|
6173
6501
|
}
|
|
6174
6502
|
/**
|
|
6175
6503
|
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
@@ -6180,10 +6508,14 @@ var EventMediaDispatcher = class {
|
|
|
6180
6508
|
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
6181
6509
|
* parked car), not the track's subject. Returns the appended snapshot for
|
|
6182
6510
|
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
6183
|
-
* failed).
|
|
6511
|
+
* failed). `thumbnailWritten` reports whether a best `thumbnail` actually
|
|
6512
|
+
* landed this frame (#27-A) so the caller can stop forcing retries.
|
|
6184
6513
|
*/
|
|
6185
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
6186
|
-
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return
|
|
6514
|
+
async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
|
|
6515
|
+
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
|
|
6516
|
+
storedSnapshot: null,
|
|
6517
|
+
thumbnailWritten: false
|
|
6518
|
+
};
|
|
6187
6519
|
let boxed = null;
|
|
6188
6520
|
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
6189
6521
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
@@ -6218,9 +6550,10 @@ var EventMediaDispatcher = class {
|
|
|
6218
6550
|
};
|
|
6219
6551
|
} catch {}
|
|
6220
6552
|
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
6553
|
+
let thumbnailWritten = false;
|
|
6221
6554
|
if (sn.bestThumbnail) try {
|
|
6222
|
-
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
6223
|
-
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6555
|
+
const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
|
|
6556
|
+
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6224
6557
|
} catch (err) {
|
|
6225
6558
|
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
6226
6559
|
tags: { deviceId },
|
|
@@ -6230,17 +6563,46 @@ var EventMediaDispatcher = class {
|
|
|
6230
6563
|
error: err instanceof Error ? err.message : String(err)
|
|
6231
6564
|
}
|
|
6232
6565
|
});
|
|
6233
|
-
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6566
|
+
if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6234
6567
|
}
|
|
6235
|
-
return
|
|
6568
|
+
return {
|
|
6569
|
+
storedSnapshot: stored,
|
|
6570
|
+
thumbnailWritten
|
|
6571
|
+
};
|
|
6236
6572
|
}
|
|
6237
6573
|
/**
|
|
6238
|
-
* Clean subject-centered crop of `bbox`
|
|
6239
|
-
*
|
|
6240
|
-
* square-safe 16:9 region around the bbox
|
|
6241
|
-
*
|
|
6574
|
+
* Clean subject-centered crop of `bbox` — the shared output contract of the
|
|
6575
|
+
* object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
|
|
6576
|
+
* `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
|
|
6577
|
+
*
|
|
6578
|
+
* NATIVE-FIRST: the region is requested from the runner's retained native
|
|
6579
|
+
* surface (normalized [0,1] coords map directly onto it), downscaled to
|
|
6580
|
+
* {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
|
|
6581
|
+
* miss/error (or a runner without the method) it FALLS BACK to cropping the
|
|
6582
|
+
* resolved ≤640 frame locally (today's behaviour), so quality never regresses.
|
|
6583
|
+
* Both paths run inside the live-handle window opened by `captureForFrame`.
|
|
6242
6584
|
*/
|
|
6243
|
-
async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
|
|
6585
|
+
async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
|
|
6586
|
+
if (this.deps.getNativeCropJpeg) try {
|
|
6587
|
+
const norm = squareSafeCropRegionNormalized(bbox, {
|
|
6588
|
+
W: fw,
|
|
6589
|
+
H: fh
|
|
6590
|
+
}, cropPadding);
|
|
6591
|
+
const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
|
|
6592
|
+
if (native) return native;
|
|
6593
|
+
} catch (err) {
|
|
6594
|
+
this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
|
|
6595
|
+
shmId: frameHandle.shmId,
|
|
6596
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6597
|
+
} });
|
|
6598
|
+
}
|
|
6599
|
+
return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
|
|
6600
|
+
}
|
|
6601
|
+
/**
|
|
6602
|
+
* Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
|
|
6603
|
+
* to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
|
|
6604
|
+
*/
|
|
6605
|
+
async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
|
|
6244
6606
|
const region = squareSafeCropRegion(bbox, {
|
|
6245
6607
|
W: fw,
|
|
6246
6608
|
H: fh
|
|
@@ -6270,6 +6632,7 @@ var EventMediaDispatcher = class {
|
|
|
6270
6632
|
timestamp,
|
|
6271
6633
|
data
|
|
6272
6634
|
});
|
|
6635
|
+
return true;
|
|
6273
6636
|
} catch (err) {
|
|
6274
6637
|
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
6275
6638
|
tags: { deviceId },
|
|
@@ -6279,15 +6642,16 @@ var EventMediaDispatcher = class {
|
|
|
6279
6642
|
error: err instanceof Error ? err.message : String(err)
|
|
6280
6643
|
}
|
|
6281
6644
|
});
|
|
6645
|
+
return false;
|
|
6282
6646
|
}
|
|
6283
6647
|
}
|
|
6284
|
-
async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
|
|
6648
|
+
async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
|
|
6285
6649
|
const box = {
|
|
6286
6650
|
...ev.bbox,
|
|
6287
6651
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
6288
6652
|
};
|
|
6289
6653
|
try {
|
|
6290
|
-
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
6654
|
+
const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
|
|
6291
6655
|
await this.deps.mediaStore.put({
|
|
6292
6656
|
deviceId,
|
|
6293
6657
|
ownerKind: "event",
|
|
@@ -6347,24 +6711,7 @@ var EventMediaDispatcher = class {
|
|
|
6347
6711
|
});
|
|
6348
6712
|
}
|
|
6349
6713
|
if (ev.childCrops) for (const child of ev.childCrops) try {
|
|
6350
|
-
const
|
|
6351
|
-
W: fw,
|
|
6352
|
-
H: fh
|
|
6353
|
-
}, cropPadding);
|
|
6354
|
-
const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
|
|
6355
|
-
const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
|
|
6356
|
-
const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
|
|
6357
|
-
const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
|
|
6358
|
-
const childCropData = await (0, sharp.default)(frameData, { raw: {
|
|
6359
|
-
width: fw,
|
|
6360
|
-
height: fh,
|
|
6361
|
-
channels: 3
|
|
6362
|
-
} }).extract({
|
|
6363
|
-
left: childLeft,
|
|
6364
|
-
top: childTop,
|
|
6365
|
-
width: childWidth,
|
|
6366
|
-
height: childHeight
|
|
6367
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
6714
|
+
const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
|
|
6368
6715
|
await this.deps.mediaStore.put({
|
|
6369
6716
|
deviceId,
|
|
6370
6717
|
ownerKind: "event",
|
|
@@ -7525,6 +7872,56 @@ var TrackingSettingsSchema = require_dist.object({
|
|
|
7525
7872
|
/** Speed (px/frame) below which a track's prediction is frozen (stationary
|
|
7526
7873
|
* jitter can't drift the box off a sitting object). */
|
|
7527
7874
|
stationarySpeedPx: require_dist.number().min(0).default(2),
|
|
7875
|
+
/** Suppress + merge concurrent duplicate tracks (one subject the detector
|
|
7876
|
+
* double-fires, or a person misdetected as `animal`, otherwise becomes two
|
|
7877
|
+
* time-overlapping tracks that never re-associate — each firing its own
|
|
7878
|
+
* event). Off = legacy behaviour. */
|
|
7879
|
+
dedupEnabled: require_dist.boolean().default(true),
|
|
7880
|
+
/** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
|
|
7881
|
+
* concurrent compatible-class track and suppressed. High so distinct
|
|
7882
|
+
* subjects appearing close together are not collapsed. */
|
|
7883
|
+
dedupSpawnIou: require_dist.number().min(0).max(1).default(.6),
|
|
7884
|
+
/** Envelope IoU at/above which two concurrent tracks count as overlapping for
|
|
7885
|
+
* the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
|
|
7886
|
+
* merged duplicate cannot re-spawn. */
|
|
7887
|
+
dedupMergeIou: require_dist.number().min(0).max(1).default(.6),
|
|
7888
|
+
/** Consecutive overlapping frames before two live tracks are merged (the
|
|
7889
|
+
* lower-importance one dropped). Higher = more conservative. */
|
|
7890
|
+
dedupMergeFrames: require_dist.number().int().min(1).default(5),
|
|
7891
|
+
/** Treat the {person,animal} class pair as duplicate-compatible — a person in
|
|
7892
|
+
* a non-upright pose is misdetected as `animal`; the false animal track
|
|
7893
|
+
* collapses into the real person track. */
|
|
7894
|
+
personAnimalDedup: require_dist.boolean().default(true),
|
|
7895
|
+
/** A low-confidence `animal` (score below this) overlapping a concurrent
|
|
7896
|
+
* person track is suppressed at spawn. */
|
|
7897
|
+
animalOverPersonMaxScore: require_dist.number().min(0).max(1).default(.6),
|
|
7898
|
+
/** IoU with a concurrent person track that triggers the low-confidence animal
|
|
7899
|
+
* spawn suppression. */
|
|
7900
|
+
animalOverPersonIou: require_dist.number().min(0).max(1).default(.2),
|
|
7901
|
+
/** Resolve a track's reported class by a confidence-weighted majority over its
|
|
7902
|
+
* lifetime (vs. the latest frame) — kills per-frame class flips (a person
|
|
7903
|
+
* read as `animal` on one crouch frame keeps the `person` label). */
|
|
7904
|
+
classVotingEnabled: require_dist.boolean().default(true),
|
|
7905
|
+
/** Winning class must hold at least this fraction of a track's total vote
|
|
7906
|
+
* weight to override the latest-frame class; below it the latest wins (so a
|
|
7907
|
+
* genuine mid-life reclassification is never frozen out). */
|
|
7908
|
+
classVoteMinFraction: require_dist.number().min(0).max(1).default(.5),
|
|
7909
|
+
/** Enforce a per-class minimum detection score at track SPAWN (an established
|
|
7910
|
+
* track still matches below its floor — only new spawns are gated). */
|
|
7911
|
+
perClassMinScoreEnabled: require_dist.boolean().default(true),
|
|
7912
|
+
/** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
|
|
7913
|
+
minScorePerson: require_dist.number().min(0).max(1).default(0),
|
|
7914
|
+
/** Minimum spawn score for `animal` (FP-prone — higher floor). */
|
|
7915
|
+
minScoreAnimal: require_dist.number().min(0).max(1).default(.45),
|
|
7916
|
+
/** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
|
|
7917
|
+
minScoreVehicle: require_dist.number().min(0).max(1).default(.45),
|
|
7918
|
+
/** Let a single very-high-confidence detection confirm a track for emission
|
|
7919
|
+
* before it reaches `minHits` (so a fast car crossing in 1-2 frames still
|
|
7920
|
+
* registers). Opt-in — default OFF keeps the strict N-hit gate. */
|
|
7921
|
+
confirmBypassEnabled: require_dist.boolean().default(false),
|
|
7922
|
+
/** Score at/above which a detection confirms its track immediately (bypasses
|
|
7923
|
+
* `minHits`). Only consulted when `confirmBypassEnabled`. */
|
|
7924
|
+
confirmBypassScore: require_dist.number().min(0).max(1).default(.9),
|
|
7528
7925
|
/** Skip frames where detections suddenly drop to zero (detector glitch). */
|
|
7529
7926
|
dropoutSkipEnabled: require_dist.boolean().default(true),
|
|
7530
7927
|
/** Max consecutive all-zero frames absorbed as a glitch before the scene is
|
|
@@ -7559,6 +7956,21 @@ function resolveTrackingSettings(raw) {
|
|
|
7559
7956
|
rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
|
|
7560
7957
|
resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
|
|
7561
7958
|
stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
|
|
7959
|
+
dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
|
|
7960
|
+
dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
|
|
7961
|
+
dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
|
|
7962
|
+
dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
|
|
7963
|
+
personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
|
|
7964
|
+
animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
|
|
7965
|
+
animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
|
|
7966
|
+
classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
|
|
7967
|
+
classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
|
|
7968
|
+
perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
|
|
7969
|
+
minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
|
|
7970
|
+
minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
|
|
7971
|
+
minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
|
|
7972
|
+
confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
|
|
7973
|
+
confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
|
|
7562
7974
|
dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
|
|
7563
7975
|
dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
|
|
7564
7976
|
};
|
|
@@ -7581,15 +7993,30 @@ var FaceSettingsSchema = require_dist.object({
|
|
|
7581
7993
|
*/
|
|
7582
7994
|
enabled: require_dist.boolean().default(true),
|
|
7583
7995
|
/** Cosine similarity (on L2-normalized arcface vectors) required to match. */
|
|
7584
|
-
similarityThreshold: require_dist.number().min(0).max(1).default(.
|
|
7996
|
+
similarityThreshold: require_dist.number().min(0).max(1).default(.55),
|
|
7585
7997
|
/** Reject ambiguous matches: require best − secondBest ≥ margin. */
|
|
7586
|
-
margin: require_dist.number().min(0).max(1).default(.
|
|
7998
|
+
margin: require_dist.number().min(0).max(1).default(.1),
|
|
7587
7999
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
7588
8000
|
minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
|
|
8001
|
+
/**
|
|
8002
|
+
* Minimum face bbox size (px, shorter side of the face box in detection-frame
|
|
8003
|
+
* space) for a face to be eligible for embedding-based auto-matching. Below
|
|
8004
|
+
* this, ArcFace resolution is unreliable and auto-assignment produces the
|
|
8005
|
+
* observed false positives (tiny/distant faces collapsing onto one identity).
|
|
8006
|
+
* Such faces are dropped BEFORE matching/enrolment (#26.1).
|
|
8007
|
+
*/
|
|
8008
|
+
minFacePx: require_dist.number().min(0).default(30),
|
|
8009
|
+
/**
|
|
8010
|
+
* Minimum enrolled-sample count an identity must have before it can be an
|
|
8011
|
+
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
8012
|
+
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
8013
|
+
* automatic matching until more samples are enrolled (#26.3).
|
|
8014
|
+
*/
|
|
8015
|
+
minIdentitySamples: require_dist.number().int().min(1).default(2),
|
|
7589
8016
|
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
7590
8017
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
7591
8018
|
* such a value falls back to the default). */
|
|
7592
|
-
confirmFrames: require_dist.number().int().min(1).default(
|
|
8019
|
+
confirmFrames: require_dist.number().int().min(1).default(3),
|
|
7593
8020
|
/** Recent-faces buffer retention (days). */
|
|
7594
8021
|
bufferRetentionDays: require_dist.number().min(0).default(3),
|
|
7595
8022
|
/** Max buffered faces kept per device. */
|
|
@@ -7606,6 +8033,8 @@ function resolveFaceSettings(raw) {
|
|
|
7606
8033
|
similarityThreshold: pick("similarityThreshold"),
|
|
7607
8034
|
margin: pick("margin"),
|
|
7608
8035
|
minFaceConfidence: pick("minFaceConfidence"),
|
|
8036
|
+
minFacePx: pick("minFacePx"),
|
|
8037
|
+
minIdentitySamples: pick("minIdentitySamples"),
|
|
7609
8038
|
confirmFrames: pick("confirmFrames"),
|
|
7610
8039
|
bufferRetentionDays: pick("bufferRetentionDays"),
|
|
7611
8040
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
@@ -7941,13 +8370,49 @@ function evaluatePeriodicSnapshot(input) {
|
|
|
7941
8370
|
*/
|
|
7942
8371
|
function planPeriodicMedia(input) {
|
|
7943
8372
|
const appendSnapshot = input.dueSnapshot;
|
|
8373
|
+
const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
|
|
8374
|
+
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
7944
8375
|
return {
|
|
7945
8376
|
appendSnapshot,
|
|
7946
|
-
rollingLastFrame
|
|
7947
|
-
bestThumbnail: input.isNewBest
|
|
8377
|
+
rollingLastFrame,
|
|
8378
|
+
bestThumbnail: input.isNewBest || !thumbnailLanded
|
|
7948
8379
|
};
|
|
7949
8380
|
}
|
|
7950
8381
|
//#endregion
|
|
8382
|
+
//#region src/pipeline-analytics/best-thumbnail-guard.ts
|
|
8383
|
+
/**
|
|
8384
|
+
* Void/envArea guard for best-`thumbnail` selection.
|
|
8385
|
+
*
|
|
8386
|
+
* ## Why this exists (the dawn/night "void" thumbnail)
|
|
8387
|
+
*
|
|
8388
|
+
* At dawn/night a moving subject's tracker box intermittently EXPLODES to
|
|
8389
|
+
* (near-)the whole frame — the "envelope exploded to full-frame" signature. If
|
|
8390
|
+
* that frame happens to win the best-detection race, the gallery/reel best
|
|
8391
|
+
* `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
|
|
8392
|
+
* washed-out frame), not the subject. This guard rejects such a frame from the
|
|
8393
|
+
* best-`thumbnail` decision so the track keeps a real subject-centered tile.
|
|
8394
|
+
*
|
|
8395
|
+
* Conservative by design: it only rejects boxes covering ≥ {@link
|
|
8396
|
+
* NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
|
|
8397
|
+
* well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
|
|
8398
|
+
* per-frame retry keeps trying until a plausible frame wins.
|
|
8399
|
+
*/
|
|
8400
|
+
/**
|
|
8401
|
+
* Area fraction at/above which a detection bbox is treated as an exploded
|
|
8402
|
+
* "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
|
|
8403
|
+
* guard conservative — only boxes covering ≥85% of the frame are rejected.
|
|
8404
|
+
*/
|
|
8405
|
+
var NEAR_FULL_FRAME_AREA = .85;
|
|
8406
|
+
/**
|
|
8407
|
+
* True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
|
|
8408
|
+
* area is below the near-full-frame threshold. Degenerate frame dimensions
|
|
8409
|
+
* (≤0) are treated as plausible (no info to reject on).
|
|
8410
|
+
*/
|
|
8411
|
+
function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
|
|
8412
|
+
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
8413
|
+
return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
|
|
8414
|
+
}
|
|
8415
|
+
//#endregion
|
|
7951
8416
|
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
7952
8417
|
/**
|
|
7953
8418
|
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
@@ -8927,6 +9392,21 @@ var ObjectEmbeddingStore = class {
|
|
|
8927
9392
|
//#endregion
|
|
8928
9393
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
8929
9394
|
/**
|
|
9395
|
+
* Count enrolled samples per identity for probes of a matching model+dimension.
|
|
9396
|
+
* Only identities meeting `minIdentitySamples` are eligible auto-match targets.
|
|
9397
|
+
*/
|
|
9398
|
+
function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
|
|
9399
|
+
const counts = /* @__PURE__ */ new Map();
|
|
9400
|
+
for (const s of gallery) {
|
|
9401
|
+
if (s.modelId !== probeModelId) continue;
|
|
9402
|
+
if (s.embedding.length !== probeDim) continue;
|
|
9403
|
+
counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
|
|
9404
|
+
}
|
|
9405
|
+
const eligible = /* @__PURE__ */ new Set();
|
|
9406
|
+
for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
|
|
9407
|
+
return eligible;
|
|
9408
|
+
}
|
|
9409
|
+
/**
|
|
8930
9410
|
* Assign at most one identity per track AND at most one track per identity for
|
|
8931
9411
|
* a single frame. Greedy by score: compute every candidate's full ranked match
|
|
8932
9412
|
* list, then repeatedly take the globally-highest (track, identity) pair whose
|
|
@@ -8937,10 +9417,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
|
|
|
8937
9417
|
const pairs = [];
|
|
8938
9418
|
candidates.forEach((c, trackIdx) => {
|
|
8939
9419
|
const probeVec = new Float32Array(c.embedding);
|
|
9420
|
+
const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
|
|
8940
9421
|
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
8941
9422
|
for (const s of gallery) {
|
|
8942
9423
|
if (s.modelId !== c.modelId) continue;
|
|
8943
9424
|
if (s.embedding.length !== c.embedding.length) continue;
|
|
9425
|
+
if (!eligible.has(s.identityId)) continue;
|
|
8944
9426
|
const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
|
|
8945
9427
|
const prev = bestByIdentity.get(s.identityId);
|
|
8946
9428
|
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
@@ -9086,7 +9568,7 @@ var FaceRecognizer = class {
|
|
|
9086
9568
|
}
|
|
9087
9569
|
async processFrame(input) {
|
|
9088
9570
|
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);
|
|
9571
|
+
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
9572
|
if (candidates.length === 0) return;
|
|
9091
9573
|
this.deps.logger.debug("face: frame candidates", {
|
|
9092
9574
|
tags: { deviceId: input.deviceId },
|
|
@@ -9102,7 +9584,8 @@ var FaceRecognizer = class {
|
|
|
9102
9584
|
modelId: c.embeddingModelId
|
|
9103
9585
|
})), this.gallery, {
|
|
9104
9586
|
threshold: settings.similarityThreshold,
|
|
9105
|
-
margin: settings.margin
|
|
9587
|
+
margin: settings.margin,
|
|
9588
|
+
minIdentitySamples: settings.minIdentitySamples
|
|
9106
9589
|
}) : /* @__PURE__ */ new Map();
|
|
9107
9590
|
const labelWork = [];
|
|
9108
9591
|
for (const c of candidates) {
|
|
@@ -11237,6 +11720,35 @@ function toAnalyticsDeviceSections(sections) {
|
|
|
11237
11720
|
}));
|
|
11238
11721
|
}
|
|
11239
11722
|
/**
|
|
11723
|
+
* Global-analytics section ids that are really per-camera DETECTION knobs and
|
|
11724
|
+
* belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
|
|
11725
|
+
* Object Detection — NOT under the generic `Analytics` top-tab:
|
|
11726
|
+
* - `detection-sensitivity` — minHits / cooldown / stationary threshold.
|
|
11727
|
+
* - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
|
|
11728
|
+
* animal dedup, class voting, confirm-bypass, per-class min score).
|
|
11729
|
+
* - `stationary-objects` — stationary promotion + occupancy tuning.
|
|
11730
|
+
* `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
|
|
11731
|
+
* structural Detection pipeline tab, so re-tagging is all that's needed —
|
|
11732
|
+
* there is no admin-ui change and no duplicate render (a section has one tab).
|
|
11733
|
+
*/
|
|
11734
|
+
var DETECTION_PIPELINE_SECTION_IDS = new Set([
|
|
11735
|
+
"detection-sensitivity",
|
|
11736
|
+
"tracking",
|
|
11737
|
+
"stationary-objects"
|
|
11738
|
+
]);
|
|
11739
|
+
/**
|
|
11740
|
+
* Re-home the detection-knob sections from the `Analytics` top-tab onto the
|
|
11741
|
+
* `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
|
|
11742
|
+
* section changes; every other section (media policy, retention, faces, track
|
|
11743
|
+
* history) stays on Analytics.
|
|
11744
|
+
*/
|
|
11745
|
+
function retagDetectionSections(sections) {
|
|
11746
|
+
return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
|
|
11747
|
+
...s,
|
|
11748
|
+
tab: "detection-pipeline"
|
|
11749
|
+
} : s);
|
|
11750
|
+
}
|
|
11751
|
+
/**
|
|
11240
11752
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
11241
11753
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
11242
11754
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -11349,6 +11861,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11349
11861
|
faceGlobalEnabledCache = null;
|
|
11350
11862
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
11351
11863
|
packageDropCacheByDevice = /* @__PURE__ */ new Map();
|
|
11864
|
+
/** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
|
|
11865
|
+
* The registry is a single shared instance, so per-(device) suppress/wake IoU,
|
|
11866
|
+
* promotion window, TTL and the master toggle are resolved from this cache at
|
|
11867
|
+
* the partition + promotion + sweep call sites — an operator change takes
|
|
11868
|
+
* effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
|
|
11869
|
+
stationaryCacheByDevice = /* @__PURE__ */ new Map();
|
|
11352
11870
|
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
11353
11871
|
packageDropDetector = null;
|
|
11354
11872
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
@@ -11381,6 +11899,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11381
11899
|
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
11382
11900
|
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
11383
11901
|
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
11902
|
+
/** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
|
|
11903
|
+
* track absent here keeps forcing a best-thumbnail capture every frame until
|
|
11904
|
+
* one lands, so a short / high-churn track whose first capture was dropped
|
|
11905
|
+
* (recycled/blank live frame) still gets a subject crop for the gallery
|
|
11906
|
+
* instead of degrading to a full-scene tile. Cleared on track end + reset. */
|
|
11907
|
+
thumbnailLandedTracks = /* @__PURE__ */ new Set();
|
|
11384
11908
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
11385
11909
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
11386
11910
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -11438,7 +11962,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11438
11962
|
let storage = this.ctx.kernel.storage;
|
|
11439
11963
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
11440
11964
|
if (mediaRoot) {
|
|
11441
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
11965
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BiZGDArd.js"));
|
|
11442
11966
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
11443
11967
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
11444
11968
|
}
|
|
@@ -11452,6 +11976,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11452
11976
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
11453
11977
|
store: api.settingsStore,
|
|
11454
11978
|
logger: logger.child("StationaryRegistry"),
|
|
11979
|
+
ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
|
|
11455
11980
|
onChange: ({ phase, entry, timestamp }) => {
|
|
11456
11981
|
this.ctx.eventBus.emit({
|
|
11457
11982
|
id: `pa-stationary-${entry.id}-${phase}`,
|
|
@@ -11605,11 +12130,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11605
12130
|
timestamp: 0
|
|
11606
12131
|
};
|
|
11607
12132
|
};
|
|
11608
|
-
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
11609
|
-
getRemoteFrame,
|
|
11610
|
-
mediaStore: this.mediaStore,
|
|
11611
|
-
logger: logger.child("EventMediaDispatcher")
|
|
11612
|
-
});
|
|
11613
12133
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
11614
12134
|
let nativeHits = 0;
|
|
11615
12135
|
let nativeFallbacks = 0;
|
|
@@ -11641,6 +12161,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11641
12161
|
return null;
|
|
11642
12162
|
}
|
|
11643
12163
|
};
|
|
12164
|
+
const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
|
|
12165
|
+
const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
|
|
12166
|
+
bumpCropMetric(jpeg !== null);
|
|
12167
|
+
return jpeg;
|
|
12168
|
+
};
|
|
12169
|
+
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
12170
|
+
getRemoteFrame,
|
|
12171
|
+
getNativeCropJpeg,
|
|
12172
|
+
mediaStore: this.mediaStore,
|
|
12173
|
+
logger: logger.child("EventMediaDispatcher")
|
|
12174
|
+
});
|
|
11644
12175
|
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
11645
12176
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
11646
12177
|
const paddedNorm = padBbox({
|
|
@@ -11827,6 +12358,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11827
12358
|
this.faceCacheByDevice.delete(data.deviceId);
|
|
11828
12359
|
this.mediaCacheByDevice.delete(data.deviceId);
|
|
11829
12360
|
this.packageDropCacheByDevice.delete(data.deviceId);
|
|
12361
|
+
this.stationaryCacheByDevice.delete(data.deviceId);
|
|
11830
12362
|
}
|
|
11831
12363
|
});
|
|
11832
12364
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
@@ -11843,6 +12375,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11843
12375
|
this.faceCacheByDevice.delete(deviceId);
|
|
11844
12376
|
this.mediaCacheByDevice.delete(deviceId);
|
|
11845
12377
|
this.packageDropCacheByDevice.delete(deviceId);
|
|
12378
|
+
this.stationaryCacheByDevice.delete(deviceId);
|
|
11846
12379
|
this.bindingCache?.invalidate(deviceId);
|
|
11847
12380
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
11848
12381
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -12181,6 +12714,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12181
12714
|
this.dropoutSkipsByKey.clear();
|
|
12182
12715
|
this.bestFrameTracker.clear();
|
|
12183
12716
|
this.lastFrameAtByTrack.clear();
|
|
12717
|
+
this.thumbnailLandedTracks.clear();
|
|
12184
12718
|
this.trackLifecycleUpdateMem.clear();
|
|
12185
12719
|
this.objectEmbeddingBestSelector.clear();
|
|
12186
12720
|
this.levelStateByDevice.clear();
|
|
@@ -12191,6 +12725,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12191
12725
|
this.faceGlobalEnabledCache = null;
|
|
12192
12726
|
this.mediaCacheByDevice.clear();
|
|
12193
12727
|
this.packageDropCacheByDevice.clear();
|
|
12728
|
+
this.stationaryCacheByDevice.clear();
|
|
12194
12729
|
this.trackStore?.clearAll();
|
|
12195
12730
|
this.stationaryRegistry = null;
|
|
12196
12731
|
this.bindingCache?.clearAll();
|
|
@@ -12235,6 +12770,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12235
12770
|
return;
|
|
12236
12771
|
}
|
|
12237
12772
|
this.dropoutSkipsByKey.set(key, 0);
|
|
12773
|
+
if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
|
|
12238
12774
|
const processor = await this.getOrCreateProcessor(deviceId, source);
|
|
12239
12775
|
const proxy = await this.ensureProxy(deviceId);
|
|
12240
12776
|
const liveZones = proxy?.state.zones.value?.zones ?? [];
|
|
@@ -12374,10 +12910,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12374
12910
|
} });
|
|
12375
12911
|
}
|
|
12376
12912
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
12377
|
-
|
|
12913
|
+
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
12914
|
+
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
12378
12915
|
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
12379
12916
|
if (dims && dims.w > 0 && dims.h > 0) {
|
|
12380
12917
|
const refDiag = Math.hypot(dims.w, dims.h);
|
|
12918
|
+
const promotionConfig = {
|
|
12919
|
+
...DEFAULT_PROMOTION_CONFIG,
|
|
12920
|
+
windowMs: stationarySettings.promotionWindowMs
|
|
12921
|
+
};
|
|
12381
12922
|
for (const t of result.tracked) {
|
|
12382
12923
|
const active = this.trackStore.peekActive(t.trackId);
|
|
12383
12924
|
if (!active) continue;
|
|
@@ -12385,7 +12926,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12385
12926
|
positions: active.positions,
|
|
12386
12927
|
referenceDiagonalPx: refDiag,
|
|
12387
12928
|
now: result.timestamp,
|
|
12388
|
-
config:
|
|
12929
|
+
config: promotionConfig
|
|
12389
12930
|
});
|
|
12390
12931
|
if (!promote) continue;
|
|
12391
12932
|
this.promoteToStationary({
|
|
@@ -12512,6 +13053,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12512
13053
|
},
|
|
12513
13054
|
mediaKey: s.mediaKey
|
|
12514
13055
|
});
|
|
13056
|
+
for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
|
|
12515
13057
|
}).catch(() => {});
|
|
12516
13058
|
}
|
|
12517
13059
|
}
|
|
@@ -12666,6 +13208,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12666
13208
|
});
|
|
12667
13209
|
return settings;
|
|
12668
13210
|
}
|
|
13211
|
+
async resolveDeviceStationarySettings(deviceId) {
|
|
13212
|
+
const now = Date.now();
|
|
13213
|
+
const cached = this.stationaryCacheByDevice.get(deviceId);
|
|
13214
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
13215
|
+
const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
13216
|
+
this.stationaryCacheByDevice.set(deviceId, {
|
|
13217
|
+
settings,
|
|
13218
|
+
expiresAt: now + SETTINGS_CACHE_TTL_MS
|
|
13219
|
+
});
|
|
13220
|
+
return settings;
|
|
13221
|
+
}
|
|
13222
|
+
stationarySettingsFromCache(deviceId) {
|
|
13223
|
+
return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
|
|
13224
|
+
}
|
|
12669
13225
|
async resolveDevicePackageDropSettings(deviceId) {
|
|
12670
13226
|
const now = Date.now();
|
|
12671
13227
|
const cached = this.packageDropCacheByDevice.get(deviceId);
|
|
@@ -12763,6 +13319,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12763
13319
|
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
12764
13320
|
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
12765
13321
|
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
13322
|
+
if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
|
|
12766
13323
|
await this.faceRecognizer.ingestFaceDetail({
|
|
12767
13324
|
deviceId,
|
|
12768
13325
|
trackId,
|
|
@@ -13041,19 +13598,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13041
13598
|
frameWidth,
|
|
13042
13599
|
frameHeight
|
|
13043
13600
|
});
|
|
13044
|
-
const
|
|
13601
|
+
const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
|
|
13602
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
|
|
13045
13603
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
13046
13604
|
const plan = planPeriodicMedia({
|
|
13047
13605
|
saveThumbnails: media.saveThumbnails,
|
|
13048
13606
|
dueSnapshot,
|
|
13049
13607
|
isNewBest,
|
|
13608
|
+
thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
|
|
13050
13609
|
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
13051
13610
|
now: timestamp,
|
|
13052
13611
|
intervalMs: media.snapshotIntervalMs
|
|
13053
13612
|
});
|
|
13054
13613
|
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
13055
13614
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
13056
|
-
|
|
13615
|
+
const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
|
|
13616
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
|
|
13057
13617
|
targets.push({
|
|
13058
13618
|
trackId: t.trackId,
|
|
13059
13619
|
timestamp,
|
|
@@ -13061,7 +13621,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13061
13621
|
...t.label ? { label: t.label } : {},
|
|
13062
13622
|
appendSnapshot: plan.appendSnapshot,
|
|
13063
13623
|
rollingLastFrame: plan.rollingLastFrame,
|
|
13064
|
-
bestThumbnail
|
|
13624
|
+
bestThumbnail
|
|
13065
13625
|
});
|
|
13066
13626
|
}
|
|
13067
13627
|
return targets;
|
|
@@ -13367,6 +13927,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13367
13927
|
this.bestFrameTracker.delete(t.trackId);
|
|
13368
13928
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
13369
13929
|
this.lastFrameAtByTrack.delete(t.trackId);
|
|
13930
|
+
this.thumbnailLandedTracks.delete(t.trackId);
|
|
13370
13931
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
13371
13932
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
13372
13933
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -13628,6 +14189,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13628
14189
|
this.bestFrameTracker.delete(track.trackId);
|
|
13629
14190
|
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
13630
14191
|
this.lastFrameAtByTrack.delete(track.trackId);
|
|
14192
|
+
this.thumbnailLandedTracks.delete(track.trackId);
|
|
13631
14193
|
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
13632
14194
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
13633
14195
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
@@ -13661,7 +14223,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13661
14223
|
rescueCentroidFactor: trk.rescueCentroidFactor,
|
|
13662
14224
|
resurrectionWindowMs: trk.resurrectionWindowMs,
|
|
13663
14225
|
stationarySpeedPx: trk.stationarySpeedPx,
|
|
13664
|
-
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
|
|
14226
|
+
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
|
|
14227
|
+
dedupEnabled: trk.dedupEnabled,
|
|
14228
|
+
dedupSpawnIou: trk.dedupSpawnIou,
|
|
14229
|
+
dedupMergeIou: trk.dedupMergeIou,
|
|
14230
|
+
dedupMergeFrames: trk.dedupMergeFrames,
|
|
14231
|
+
personAnimalDedup: trk.personAnimalDedup,
|
|
14232
|
+
animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
|
|
14233
|
+
animalOverPersonIou: trk.animalOverPersonIou,
|
|
14234
|
+
classVotingEnabled: trk.classVotingEnabled,
|
|
14235
|
+
classVoteMinFraction: trk.classVoteMinFraction,
|
|
14236
|
+
perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
|
|
14237
|
+
classMinScores: {
|
|
14238
|
+
person: trk.minScorePerson,
|
|
14239
|
+
animal: trk.minScoreAnimal,
|
|
14240
|
+
vehicle: trk.minScoreVehicle
|
|
14241
|
+
},
|
|
14242
|
+
confirmBypassEnabled: trk.confirmBypassEnabled,
|
|
14243
|
+
confirmBypassScore: trk.confirmBypassScore
|
|
13665
14244
|
}, { stationaryThresholdSec }, {
|
|
13666
14245
|
minTrackAge,
|
|
13667
14246
|
cooldownSec,
|
|
@@ -13669,15 +14248,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13669
14248
|
}, source);
|
|
13670
14249
|
if (source === "pipeline" && this.stationaryRegistry) {
|
|
13671
14250
|
const registry = this.stationaryRegistry;
|
|
13672
|
-
p.setStationaryGate({ filter: (input) =>
|
|
13673
|
-
deviceId
|
|
13674
|
-
|
|
13675
|
-
|
|
13676
|
-
|
|
13677
|
-
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
14251
|
+
p.setStationaryGate({ filter: (input) => {
|
|
14252
|
+
const s = this.stationarySettingsFromCache(deviceId);
|
|
14253
|
+
if (!s.enabled) return {
|
|
14254
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
14255
|
+
confirmed: [],
|
|
14256
|
+
wokenEntryIds: []
|
|
14257
|
+
};
|
|
14258
|
+
const matchConfig = {
|
|
14259
|
+
suppressIou: s.suppressIou,
|
|
14260
|
+
wakeAssocIou: s.wakeAssocIou
|
|
14261
|
+
};
|
|
14262
|
+
return registry.filter({
|
|
14263
|
+
deviceId,
|
|
14264
|
+
detections: input.detections.map((d) => ({
|
|
14265
|
+
bbox: d.bbox,
|
|
14266
|
+
className: d.class
|
|
14267
|
+
})),
|
|
14268
|
+
frameWidth: input.frameWidth,
|
|
14269
|
+
frameHeight: input.frameHeight
|
|
14270
|
+
}, matchConfig);
|
|
14271
|
+
} });
|
|
13681
14272
|
}
|
|
13682
14273
|
this.processors.set(key, p);
|
|
13683
14274
|
}
|
|
@@ -14740,6 +15331,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14740
15331
|
step: .05,
|
|
14741
15332
|
default: FACE_DEFAULTS.minFaceConfidence
|
|
14742
15333
|
},
|
|
15334
|
+
{
|
|
15335
|
+
type: "number",
|
|
15336
|
+
key: "minFacePx",
|
|
15337
|
+
label: "Min face size",
|
|
15338
|
+
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.",
|
|
15339
|
+
min: 0,
|
|
15340
|
+
step: 1,
|
|
15341
|
+
default: FACE_DEFAULTS.minFacePx,
|
|
15342
|
+
unit: "px"
|
|
15343
|
+
},
|
|
15344
|
+
{
|
|
15345
|
+
type: "number",
|
|
15346
|
+
key: "minIdentitySamples",
|
|
15347
|
+
label: "Min identity samples",
|
|
15348
|
+
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).",
|
|
15349
|
+
min: 1,
|
|
15350
|
+
step: 1,
|
|
15351
|
+
default: FACE_DEFAULTS.minIdentitySamples
|
|
15352
|
+
},
|
|
14743
15353
|
{
|
|
14744
15354
|
type: "number",
|
|
14745
15355
|
key: "confirmFrames",
|
|
@@ -14933,6 +15543,69 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14933
15543
|
default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
|
|
14934
15544
|
unit: "ms"
|
|
14935
15545
|
},
|
|
15546
|
+
{
|
|
15547
|
+
type: "boolean",
|
|
15548
|
+
key: "dedupEnabled",
|
|
15549
|
+
label: "Duplicate-track suppression",
|
|
15550
|
+
description: "Suppress and merge concurrent duplicate tracks. Stops one subject the detector double-fires (two boxes for one object, or a person misdetected as an animal) from becoming several time-overlapping tracks that each fire their own event. Off = legacy behaviour.",
|
|
15551
|
+
default: TRACKING_DEFAULTS.dedupEnabled
|
|
15552
|
+
},
|
|
15553
|
+
{
|
|
15554
|
+
type: "number",
|
|
15555
|
+
key: "dedupSpawnIou",
|
|
15556
|
+
label: "Duplicate spawn IoU",
|
|
15557
|
+
description: "Overlap at/above which a NEW track is treated as a duplicate of a concurrent same-kind track and not created. High so two distinct subjects appearing close together are still tracked separately.",
|
|
15558
|
+
min: 0,
|
|
15559
|
+
max: 1,
|
|
15560
|
+
step: .05,
|
|
15561
|
+
default: TRACKING_DEFAULTS.dedupSpawnIou
|
|
15562
|
+
},
|
|
15563
|
+
{
|
|
15564
|
+
type: "number",
|
|
15565
|
+
key: "dedupMergeIou",
|
|
15566
|
+
label: "Duplicate merge IoU",
|
|
15567
|
+
description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
|
|
15568
|
+
min: 0,
|
|
15569
|
+
max: 1,
|
|
15570
|
+
step: .05,
|
|
15571
|
+
default: TRACKING_DEFAULTS.dedupMergeIou
|
|
15572
|
+
},
|
|
15573
|
+
{
|
|
15574
|
+
type: "number",
|
|
15575
|
+
key: "dedupMergeFrames",
|
|
15576
|
+
label: "Duplicate merge frames",
|
|
15577
|
+
description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
|
|
15578
|
+
min: 1,
|
|
15579
|
+
step: 1,
|
|
15580
|
+
default: TRACKING_DEFAULTS.dedupMergeFrames
|
|
15581
|
+
},
|
|
15582
|
+
{
|
|
15583
|
+
type: "boolean",
|
|
15584
|
+
key: "personAnimalDedup",
|
|
15585
|
+
label: "Person/animal duplicate merge",
|
|
15586
|
+
description: "Treat an animal track overlapping a person track as the same subject (a person in a crouching / bending pose is often misdetected as an animal). The person track wins.",
|
|
15587
|
+
default: TRACKING_DEFAULTS.personAnimalDedup
|
|
15588
|
+
},
|
|
15589
|
+
{
|
|
15590
|
+
type: "number",
|
|
15591
|
+
key: "animalOverPersonMaxScore",
|
|
15592
|
+
label: "Animal-over-person max score",
|
|
15593
|
+
description: "A low-confidence animal detection (score below this) sitting on a concurrent person track is suppressed — the common person-in-odd-pose false animal.",
|
|
15594
|
+
min: 0,
|
|
15595
|
+
max: 1,
|
|
15596
|
+
step: .05,
|
|
15597
|
+
default: TRACKING_DEFAULTS.animalOverPersonMaxScore
|
|
15598
|
+
},
|
|
15599
|
+
{
|
|
15600
|
+
type: "number",
|
|
15601
|
+
key: "animalOverPersonIou",
|
|
15602
|
+
label: "Animal-over-person IoU",
|
|
15603
|
+
description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
|
|
15604
|
+
min: 0,
|
|
15605
|
+
max: 1,
|
|
15606
|
+
step: .05,
|
|
15607
|
+
default: TRACKING_DEFAULTS.animalOverPersonIou
|
|
15608
|
+
},
|
|
14936
15609
|
{
|
|
14937
15610
|
type: "boolean",
|
|
14938
15611
|
key: "dropoutSkipEnabled",
|
|
@@ -14948,6 +15621,141 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14948
15621
|
min: 0,
|
|
14949
15622
|
step: 1,
|
|
14950
15623
|
default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
|
|
15624
|
+
},
|
|
15625
|
+
{
|
|
15626
|
+
type: "boolean",
|
|
15627
|
+
key: "classVotingEnabled",
|
|
15628
|
+
label: "Per-track class voting",
|
|
15629
|
+
description: "Report a track’s class by a confidence-weighted majority over its whole life instead of the latest frame — a person read as an animal on a single crouch/bend frame keeps the person label.",
|
|
15630
|
+
default: TRACKING_DEFAULTS.classVotingEnabled
|
|
15631
|
+
},
|
|
15632
|
+
{
|
|
15633
|
+
type: "number",
|
|
15634
|
+
key: "classVoteMinFraction",
|
|
15635
|
+
label: "Class-vote min fraction",
|
|
15636
|
+
description: "The winning class must hold at least this fraction of a track’s total vote weight to override the latest frame; below it the latest frame wins (so a genuine mid-life reclassification is not frozen out).",
|
|
15637
|
+
min: 0,
|
|
15638
|
+
max: 1,
|
|
15639
|
+
step: .05,
|
|
15640
|
+
default: TRACKING_DEFAULTS.classVoteMinFraction
|
|
15641
|
+
},
|
|
15642
|
+
{
|
|
15643
|
+
type: "boolean",
|
|
15644
|
+
key: "perClassMinScoreEnabled",
|
|
15645
|
+
label: "Per-class spawn confidence",
|
|
15646
|
+
description: "Require a per-class minimum detection score before a NEW track is created. Kills static-object / reflection false spawns from the FP-prone classes. An already-tracked object still matches on a low-confidence frame.",
|
|
15647
|
+
default: TRACKING_DEFAULTS.perClassMinScoreEnabled
|
|
15648
|
+
},
|
|
15649
|
+
{
|
|
15650
|
+
type: "number",
|
|
15651
|
+
key: "minScorePerson",
|
|
15652
|
+
label: "Min spawn score — person",
|
|
15653
|
+
description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
|
|
15654
|
+
min: 0,
|
|
15655
|
+
max: 1,
|
|
15656
|
+
step: .05,
|
|
15657
|
+
default: TRACKING_DEFAULTS.minScorePerson
|
|
15658
|
+
},
|
|
15659
|
+
{
|
|
15660
|
+
type: "number",
|
|
15661
|
+
key: "minScoreAnimal",
|
|
15662
|
+
label: "Min spawn score — animal",
|
|
15663
|
+
description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
|
|
15664
|
+
min: 0,
|
|
15665
|
+
max: 1,
|
|
15666
|
+
step: .05,
|
|
15667
|
+
default: TRACKING_DEFAULTS.minScoreAnimal
|
|
15668
|
+
},
|
|
15669
|
+
{
|
|
15670
|
+
type: "number",
|
|
15671
|
+
key: "minScoreVehicle",
|
|
15672
|
+
label: "Min spawn score — vehicle",
|
|
15673
|
+
description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
|
|
15674
|
+
min: 0,
|
|
15675
|
+
max: 1,
|
|
15676
|
+
step: .05,
|
|
15677
|
+
default: TRACKING_DEFAULTS.minScoreVehicle
|
|
15678
|
+
},
|
|
15679
|
+
{
|
|
15680
|
+
type: "boolean",
|
|
15681
|
+
key: "confirmBypassEnabled",
|
|
15682
|
+
label: "Fast high-confidence confirm",
|
|
15683
|
+
description: "Let a single very-high-confidence detection confirm a track for events before it reaches the min-hits gate, so a fast subject crossing in 1-2 frames (a passing car) still registers. Off by default (strict N-hit confirmation).",
|
|
15684
|
+
default: TRACKING_DEFAULTS.confirmBypassEnabled
|
|
15685
|
+
},
|
|
15686
|
+
{
|
|
15687
|
+
type: "number",
|
|
15688
|
+
key: "confirmBypassScore",
|
|
15689
|
+
label: "Fast-confirm score",
|
|
15690
|
+
description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
|
|
15691
|
+
min: 0,
|
|
15692
|
+
max: 1,
|
|
15693
|
+
step: .05,
|
|
15694
|
+
default: TRACKING_DEFAULTS.confirmBypassScore
|
|
15695
|
+
}
|
|
15696
|
+
]
|
|
15697
|
+
},
|
|
15698
|
+
{
|
|
15699
|
+
id: "stationary-objects",
|
|
15700
|
+
title: "Stationary objects",
|
|
15701
|
+
tab: "analytics",
|
|
15702
|
+
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.",
|
|
15703
|
+
columns: 2,
|
|
15704
|
+
fields: [
|
|
15705
|
+
{
|
|
15706
|
+
type: "boolean",
|
|
15707
|
+
key: "enabled",
|
|
15708
|
+
label: "Suppress parked objects",
|
|
15709
|
+
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.",
|
|
15710
|
+
default: STATIONARY_DEFAULTS.enabled
|
|
15711
|
+
},
|
|
15712
|
+
{
|
|
15713
|
+
type: "slider",
|
|
15714
|
+
key: "suppressIou",
|
|
15715
|
+
label: "Suppress IoU",
|
|
15716
|
+
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.",
|
|
15717
|
+
min: .3,
|
|
15718
|
+
max: .9,
|
|
15719
|
+
step: .05,
|
|
15720
|
+
default: STATIONARY_DEFAULTS.suppressIou,
|
|
15721
|
+
showValue: true
|
|
15722
|
+
},
|
|
15723
|
+
{
|
|
15724
|
+
type: "slider",
|
|
15725
|
+
key: "wakeAssocIou",
|
|
15726
|
+
label: "Wake / associate IoU",
|
|
15727
|
+
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.",
|
|
15728
|
+
min: .02,
|
|
15729
|
+
max: .5,
|
|
15730
|
+
step: .02,
|
|
15731
|
+
default: STATIONARY_DEFAULTS.wakeAssocIou,
|
|
15732
|
+
showValue: true
|
|
15733
|
+
},
|
|
15734
|
+
{
|
|
15735
|
+
type: "slider",
|
|
15736
|
+
key: "promotionWindowMs",
|
|
15737
|
+
label: "Promotion stillness window",
|
|
15738
|
+
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.",
|
|
15739
|
+
min: 5e3,
|
|
15740
|
+
max: 12e4,
|
|
15741
|
+
step: 1e3,
|
|
15742
|
+
default: STATIONARY_DEFAULTS.promotionWindowMs,
|
|
15743
|
+
showValue: true,
|
|
15744
|
+
unit: "s",
|
|
15745
|
+
displayScale: 1e3
|
|
15746
|
+
},
|
|
15747
|
+
{
|
|
15748
|
+
type: "slider",
|
|
15749
|
+
key: "entryTtlMs",
|
|
15750
|
+
label: "Observed-time TTL",
|
|
15751
|
+
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.",
|
|
15752
|
+
min: 6e4,
|
|
15753
|
+
max: 18e5,
|
|
15754
|
+
step: 3e4,
|
|
15755
|
+
default: STATIONARY_DEFAULTS.entryTtlMs,
|
|
15756
|
+
showValue: true,
|
|
15757
|
+
unit: "s",
|
|
15758
|
+
displayScale: 1e3
|
|
14951
15759
|
}
|
|
14952
15760
|
]
|
|
14953
15761
|
}
|
|
@@ -14959,7 +15767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14959
15767
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
14960
15768
|
const baseSections = schema ? require_dist.hydrateSchema({
|
|
14961
15769
|
...schema,
|
|
14962
|
-
sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
|
|
15770
|
+
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
14963
15771
|
}, raw).sections : [];
|
|
14964
15772
|
const liveStatsSection = {
|
|
14965
15773
|
id: "live-stats",
|
|
@@ -15008,7 +15816,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
15008
15816
|
}
|
|
15009
15817
|
};
|
|
15010
15818
|
//#endregion
|
|
15819
|
+
exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
|
|
15011
15820
|
exports.default = PipelineAnalyticsAddon;
|
|
15012
15821
|
exports.pickCleanMedia = pickCleanMedia;
|
|
15822
|
+
exports.retagDetectionSections = retagDetectionSections;
|
|
15013
15823
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
15014
15824
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|