@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-
|
|
1
|
+
import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-CSMfGdnz.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
@@ -1120,8 +1120,29 @@ var DEFAULT_TRACKER_CONFIG = {
|
|
|
1120
1120
|
rescueIouThreshold: .1,
|
|
1121
1121
|
rescueCentroidFactor: .75,
|
|
1122
1122
|
resurrectionWindowMs: 8e3,
|
|
1123
|
-
stationarySpeedPx: 2
|
|
1123
|
+
stationarySpeedPx: 2,
|
|
1124
|
+
dedupEnabled: true,
|
|
1125
|
+
dedupSpawnIou: .6,
|
|
1126
|
+
dedupMergeIou: .6,
|
|
1127
|
+
dedupMergeFrames: 5,
|
|
1128
|
+
personAnimalDedup: true,
|
|
1129
|
+
animalOverPersonMaxScore: .6,
|
|
1130
|
+
animalOverPersonIou: .2,
|
|
1131
|
+
classVotingEnabled: true,
|
|
1132
|
+
classVoteMinFraction: .5,
|
|
1133
|
+
perClassMinScoreEnabled: true,
|
|
1134
|
+
classMinScores: {
|
|
1135
|
+
animal: .45,
|
|
1136
|
+
vehicle: .45
|
|
1137
|
+
},
|
|
1138
|
+
confirmBypassEnabled: false,
|
|
1139
|
+
confirmBypassScore: .9
|
|
1124
1140
|
};
|
|
1141
|
+
/** Macro-classes the duplicate resolver may collapse across (people in
|
|
1142
|
+
* non-upright poses misdetected as animals). Any other cross-class pair is
|
|
1143
|
+
* NEVER a duplicate. */
|
|
1144
|
+
var PERSON_CLASS = "person";
|
|
1145
|
+
var ANIMAL_CLASS = "animal";
|
|
1125
1146
|
var MAX_PATH_LENGTH = 300;
|
|
1126
1147
|
function clamp(value, min, max) {
|
|
1127
1148
|
return Math.max(min, Math.min(max, value));
|
|
@@ -1144,16 +1165,102 @@ function containment(inner, outer) {
|
|
|
1144
1165
|
const innerArea = inner.w * inner.h;
|
|
1145
1166
|
return innerArea > 0 ? iw * ih / innerArea : 0;
|
|
1146
1167
|
}
|
|
1147
|
-
var SortTracker = class {
|
|
1168
|
+
var SortTracker = class SortTracker {
|
|
1148
1169
|
config;
|
|
1149
1170
|
tracks = [];
|
|
1150
1171
|
lostTracks = [];
|
|
1172
|
+
/**
|
|
1173
|
+
* Consecutive-frame overlap streak per unordered live-track pair
|
|
1174
|
+
* (`idA|idB`, ids sorted). Drives the sustained-overlap merge: a pair is
|
|
1175
|
+
* merged only once its streak reaches `dedupMergeFrames`. Entries reset the
|
|
1176
|
+
* moment the pair stops overlapping and are pruned when either track dies.
|
|
1177
|
+
*/
|
|
1178
|
+
dupStreaks = /* @__PURE__ */ new Map();
|
|
1151
1179
|
constructor(config = {}) {
|
|
1152
1180
|
this.config = {
|
|
1153
1181
|
...DEFAULT_TRACKER_CONFIG,
|
|
1154
1182
|
...config
|
|
1155
1183
|
};
|
|
1156
1184
|
}
|
|
1185
|
+
/** Whether two macro-classes may be collapsed as the SAME subject. Same class
|
|
1186
|
+
* always; the {person,animal} pair only when `personAnimalDedup` is on. */
|
|
1187
|
+
dedupCompatible(a, b) {
|
|
1188
|
+
if (a === b) return true;
|
|
1189
|
+
if (!this.config.personAnimalDedup) return false;
|
|
1190
|
+
return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
|
|
1191
|
+
}
|
|
1192
|
+
/** Unordered, stable key for a track pair (UUIDs never contain `|`). */
|
|
1193
|
+
static pairKey(a, b) {
|
|
1194
|
+
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* True when spawning a fresh track for `det` would duplicate an already-live
|
|
1198
|
+
* track: (a) it overlaps a concurrent compatible-class track by
|
|
1199
|
+
* `dedupSpawnIou`, or (b) it is a low-confidence `animal` sitting on a
|
|
1200
|
+
* concurrent `person` track (person-in-odd-pose false animal). `live` is the
|
|
1201
|
+
* set of tracks already surviving this frame (matched, coasting, resurrected,
|
|
1202
|
+
* and earlier same-frame spawns).
|
|
1203
|
+
*/
|
|
1204
|
+
isDuplicateSpawn(det, live) {
|
|
1205
|
+
for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
|
|
1206
|
+
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
1207
|
+
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
1208
|
+
}
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
/** The lower-importance track of a duplicate pair — the one to drop. A
|
|
1212
|
+
* `person` always beats a cross-class `animal`; otherwise more hits wins,
|
|
1213
|
+
* ties break to the older track, then the higher score. */
|
|
1214
|
+
static duplicateLoser(a, b) {
|
|
1215
|
+
if (a.class !== b.class) {
|
|
1216
|
+
if (a.class === PERSON_CLASS && b.class === ANIMAL_CLASS) return b;
|
|
1217
|
+
if (b.class === PERSON_CLASS && a.class === ANIMAL_CLASS) return a;
|
|
1218
|
+
}
|
|
1219
|
+
if (a.hits !== b.hits) return a.hits > b.hits ? b : a;
|
|
1220
|
+
if (a.firstSeen !== b.firstSeen) return a.firstSeen < b.firstSeen ? b : a;
|
|
1221
|
+
return a.score >= b.score ? b : a;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Merge concurrent duplicate tracks. Two live compatible-class tracks that
|
|
1225
|
+
* stay overlapped (≥ `dedupMergeIou`) for `dedupMergeFrames` consecutive
|
|
1226
|
+
* frames are collapsed: the lower-importance one is dropped (NOT graveyarded
|
|
1227
|
+
* — a confirmed duplicate must not resurrect). This is the fix for a subject
|
|
1228
|
+
* the detector double-fires (two boxes each frame, each feeding its OWN
|
|
1229
|
+
* track, so neither is ever "unmatched" for spawn-suppression to catch) and
|
|
1230
|
+
* for a person misdetected as `animal` alongside the real person track.
|
|
1231
|
+
*/
|
|
1232
|
+
resolveDuplicates() {
|
|
1233
|
+
if (!this.config.dedupEnabled) return;
|
|
1234
|
+
const live = this.tracks;
|
|
1235
|
+
const liveIds = new Set(live.map((t) => t.id));
|
|
1236
|
+
for (const key of this.dupStreaks.keys()) {
|
|
1237
|
+
const [a, b] = key.split("|");
|
|
1238
|
+
if (a === void 0 || b === void 0 || !liveIds.has(a) || !liveIds.has(b)) this.dupStreaks.delete(key);
|
|
1239
|
+
}
|
|
1240
|
+
const dropIds = /* @__PURE__ */ new Set();
|
|
1241
|
+
for (let i = 0; i < live.length; i++) for (let j = i + 1; j < live.length; j++) {
|
|
1242
|
+
const a = live[i];
|
|
1243
|
+
const b = live[j];
|
|
1244
|
+
if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
|
|
1245
|
+
if (!this.dedupCompatible(a.class, b.class)) continue;
|
|
1246
|
+
const key = SortTracker.pairKey(a.id, b.id);
|
|
1247
|
+
if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
|
|
1248
|
+
this.dupStreaks.delete(key);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const streak = (this.dupStreaks.get(key) ?? 0) + 1;
|
|
1252
|
+
if (streak >= this.config.dedupMergeFrames) {
|
|
1253
|
+
dropIds.add(SortTracker.duplicateLoser(a, b).id);
|
|
1254
|
+
this.dupStreaks.delete(key);
|
|
1255
|
+
} else this.dupStreaks.set(key, streak);
|
|
1256
|
+
}
|
|
1257
|
+
if (dropIds.size === 0) return;
|
|
1258
|
+
this.tracks = this.tracks.filter((t) => !dropIds.has(t.id));
|
|
1259
|
+
for (const key of this.dupStreaks.keys()) {
|
|
1260
|
+
const [a, b] = key.split("|");
|
|
1261
|
+
if (a !== void 0 && dropIds.has(a) || b !== void 0 && dropIds.has(b)) this.dupStreaks.delete(key);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1157
1264
|
/** Where a track is expected this frame — extrapolated by velocity while
|
|
1158
1265
|
* coasting (predictiveCoasting), else its last known bbox. A stationary
|
|
1159
1266
|
* track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
|
|
@@ -1188,6 +1295,46 @@ var SortTracker = class {
|
|
|
1188
1295
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
1189
1296
|
return dist <= this.config.rescueCentroidFactor * diag;
|
|
1190
1297
|
}
|
|
1298
|
+
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
1299
|
+
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
1300
|
+
* zero-confidence frame contributes to the frame-count tiebreak. */
|
|
1301
|
+
addClassVote(track, det) {
|
|
1302
|
+
const weight = det.score > 0 ? det.score : Number.EPSILON;
|
|
1303
|
+
track.classVotes.set(det.class, (track.classVotes.get(det.class) ?? 0) + weight);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* The class to REPORT for a track: the confidence-weighted lifetime majority
|
|
1307
|
+
* when voting is on and the winner holds ≥ `classVoteMinFraction` of the total
|
|
1308
|
+
* weight; otherwise the latest-frame class (ambiguous vote or voting off).
|
|
1309
|
+
*/
|
|
1310
|
+
resolveReportedClass(t) {
|
|
1311
|
+
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
|
|
1312
|
+
let bestClass = t.class;
|
|
1313
|
+
let bestVote = -1;
|
|
1314
|
+
let total = 0;
|
|
1315
|
+
for (const [cls, v] of t.classVotes) {
|
|
1316
|
+
total += v;
|
|
1317
|
+
if (v > bestVote) {
|
|
1318
|
+
bestVote = v;
|
|
1319
|
+
bestClass = cls;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
if (total <= 0) return t.class;
|
|
1323
|
+
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
|
|
1324
|
+
}
|
|
1325
|
+
/** Whether a detection clears its per-class spawn score floor. Only gates NEW
|
|
1326
|
+
* spawns — an established track still matches below its floor. */
|
|
1327
|
+
meetsClassScoreFloor(det) {
|
|
1328
|
+
if (!this.config.perClassMinScoreEnabled) return true;
|
|
1329
|
+
const floor = this.config.classMinScores[det.class] ?? 0;
|
|
1330
|
+
return det.score >= floor;
|
|
1331
|
+
}
|
|
1332
|
+
/** Whether a track is confirmed for EMISSION: it reached `minHits`, or (opt-in)
|
|
1333
|
+
* a single very-high-confidence detection bypassed the hit gate (fast car). */
|
|
1334
|
+
isConfirmedForEmit(t) {
|
|
1335
|
+
if (t.hits >= this.config.minHits) return true;
|
|
1336
|
+
return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
|
|
1337
|
+
}
|
|
1191
1338
|
update(detections, timestamp) {
|
|
1192
1339
|
if (this.config.maxTrackLifetimeMs > 0) {
|
|
1193
1340
|
const alive = [];
|
|
@@ -1272,6 +1419,7 @@ var SortTracker = class {
|
|
|
1272
1419
|
};
|
|
1273
1420
|
track.path.push(det.bbox);
|
|
1274
1421
|
if (track.path.length > MAX_PATH_LENGTH) track.path.shift();
|
|
1422
|
+
this.addClassVote(track, det);
|
|
1275
1423
|
}
|
|
1276
1424
|
const occluderBoxes = [];
|
|
1277
1425
|
for (const track of matchedTracks) occluderBoxes.push(track.bbox);
|
|
@@ -1321,13 +1469,30 @@ var SortTracker = class {
|
|
|
1321
1469
|
};
|
|
1322
1470
|
best.path.push(det.bbox);
|
|
1323
1471
|
if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
|
|
1472
|
+
this.addClassVote(best, det);
|
|
1324
1473
|
surviving.push(best);
|
|
1325
1474
|
used.add(di);
|
|
1326
1475
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1476
|
+
const unmatchedIdx = [];
|
|
1477
|
+
for (let di = 0; di < detections.length; di++) if (!used.has(di)) unmatchedIdx.push(di);
|
|
1478
|
+
const classRank = (cls) => cls === PERSON_CLASS ? 0 : cls === ANIMAL_CLASS ? 2 : 1;
|
|
1479
|
+
unmatchedIdx.sort((ia, ib) => {
|
|
1480
|
+
const da = detections[ia];
|
|
1481
|
+
const db = detections[ib];
|
|
1482
|
+
const r = classRank(da.class) - classRank(db.class);
|
|
1483
|
+
return r !== 0 ? r : db.score - da.score;
|
|
1484
|
+
});
|
|
1485
|
+
for (const di of unmatchedIdx) {
|
|
1329
1486
|
const det = detections[di];
|
|
1487
|
+
if (!this.meetsClassScoreFloor(det)) {
|
|
1488
|
+
used.add(di);
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1330
1491
|
if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
|
|
1492
|
+
if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
|
|
1493
|
+
used.add(di);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1331
1496
|
surviving.push({
|
|
1332
1497
|
id: randomUUID(),
|
|
1333
1498
|
bbox: det.bbox,
|
|
@@ -1345,12 +1510,14 @@ var SortTracker = class {
|
|
|
1345
1510
|
},
|
|
1346
1511
|
lost: false,
|
|
1347
1512
|
lostAt: 0,
|
|
1348
|
-
resurrectable: true
|
|
1513
|
+
resurrectable: true,
|
|
1514
|
+
classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]])
|
|
1349
1515
|
});
|
|
1350
1516
|
}
|
|
1351
1517
|
this.tracks = surviving;
|
|
1352
|
-
|
|
1353
|
-
|
|
1518
|
+
this.resolveDuplicates();
|
|
1519
|
+
return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
|
|
1520
|
+
class: this.resolveReportedClass(t),
|
|
1354
1521
|
originalClass: t.originalClass,
|
|
1355
1522
|
score: t.score,
|
|
1356
1523
|
bbox: t.bbox,
|
|
@@ -1379,6 +1546,7 @@ var SortTracker = class {
|
|
|
1379
1546
|
reset() {
|
|
1380
1547
|
this.tracks = [];
|
|
1381
1548
|
this.lostTracks = [];
|
|
1549
|
+
this.dupStreaks.clear();
|
|
1382
1550
|
}
|
|
1383
1551
|
};
|
|
1384
1552
|
//#endregion
|
|
@@ -2132,6 +2300,29 @@ function isEdgeClear(input) {
|
|
|
2132
2300
|
return true;
|
|
2133
2301
|
}
|
|
2134
2302
|
/**
|
|
2303
|
+
* Centeredness of a bbox: 1 when the subject's centre sits exactly at the frame
|
|
2304
|
+
* centre, decaying toward 0 as it approaches a corner. Pure geometry (no pixels)
|
|
2305
|
+
* — a cheap proxy for "is the subject well-framed?" used as the best-frame
|
|
2306
|
+
* tie-breaker so a low-importance short track stops locking in an edge-of-frame
|
|
2307
|
+
* subject when a better-centred, near-equal-confidence frame is available.
|
|
2308
|
+
*
|
|
2309
|
+
* The score is `1 - normalizedDistance(centre → frameCentre)`, where the
|
|
2310
|
+
* distance is normalised by the max possible (centre → corner) so it is
|
|
2311
|
+
* scale-invariant. Degenerate/unknown dims (≤ 0) return 1 (neutral — the gate
|
|
2312
|
+
* falls back to pure confidence, matching {@link isEdgeClear}).
|
|
2313
|
+
*/
|
|
2314
|
+
function bboxCenterScore(bbox, frameWidth, frameHeight) {
|
|
2315
|
+
if (frameWidth <= 0 || frameHeight <= 0) return 1;
|
|
2316
|
+
const cx = bbox.x + bbox.w / 2;
|
|
2317
|
+
const cy = bbox.y + bbox.h / 2;
|
|
2318
|
+
const fcx = frameWidth / 2;
|
|
2319
|
+
const fcy = frameHeight / 2;
|
|
2320
|
+
const dx = (cx - fcx) / fcx;
|
|
2321
|
+
const dy = (cy - fcy) / fcy;
|
|
2322
|
+
const dist = Math.hypot(dx, dy) / Math.SQRT2;
|
|
2323
|
+
return Math.max(0, Math.min(1, 1 - dist));
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2135
2326
|
* Edge-aware "is `candidate` a new best over `current`?" comparator.
|
|
2136
2327
|
*
|
|
2137
2328
|
* Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
|
|
@@ -2139,13 +2330,25 @@ function isEdgeClear(input) {
|
|
|
2139
2330
|
* confidence past the `hysteresis` margin wins. The tier upgrade
|
|
2140
2331
|
* (touching → clear) bypasses hysteresis — the first clear frame is always taken.
|
|
2141
2332
|
*
|
|
2333
|
+
* CENTERING TIE-BREAK (#27-D): within the same tier, when neither frame clearly
|
|
2334
|
+
* wins on confidence (the two are within the `hysteresis` band) but the
|
|
2335
|
+
* candidate is meaningfully better CENTRED ({@link CENTER_TIE_BREAK_MARGIN}), the
|
|
2336
|
+
* candidate wins. This only engages when both sides carry a `centerScore` (the
|
|
2337
|
+
* best-frame path), so low-importance short tracks stop keeping an edge-of-frame
|
|
2338
|
+
* subject over an equally-confident, better-framed one. The face /
|
|
2339
|
+
* object-embedding callers omit `centerScore` → identical legacy behaviour.
|
|
2340
|
+
*
|
|
2142
2341
|
* Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
|
|
2143
2342
|
* not here, so this stays a pure value comparison.
|
|
2144
2343
|
*/
|
|
2145
2344
|
function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
2146
2345
|
if (candidate.edgeClear && !current.edgeClear) return true;
|
|
2147
2346
|
if (!candidate.edgeClear && current.edgeClear) return false;
|
|
2148
|
-
|
|
2347
|
+
if (candidate.confidence > current.confidence + hysteresis) return true;
|
|
2348
|
+
if (candidate.centerScore !== void 0 && current.centerScore !== void 0) {
|
|
2349
|
+
if (Math.abs(candidate.confidence - current.confidence) <= hysteresis && candidate.centerScore > current.centerScore + .1) return true;
|
|
2350
|
+
}
|
|
2351
|
+
return false;
|
|
2149
2352
|
}
|
|
2150
2353
|
//#endregion
|
|
2151
2354
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
@@ -2181,6 +2384,10 @@ var BestDetectionTracker = class {
|
|
|
2181
2384
|
* the edge tier is not in play for the track (treated as clear → the legacy
|
|
2182
2385
|
* pure-confidence policy). */
|
|
2183
2386
|
edgeClear = /* @__PURE__ */ new Map();
|
|
2387
|
+
/** Held peak's centeredness (0..1), PARALLEL to `best`. Absent = the caller
|
|
2388
|
+
* does not supply centering (face / object-embedding paths) → the centering
|
|
2389
|
+
* tie-break is disabled and the legacy confidence policy applies. */
|
|
2390
|
+
centerScore = /* @__PURE__ */ new Map();
|
|
2184
2391
|
constructor(options = {}) {
|
|
2185
2392
|
this.hysteresis = options.hysteresis ?? 0;
|
|
2186
2393
|
this.minGapMs = options.minGapMs ?? 0;
|
|
@@ -2197,7 +2404,7 @@ var BestDetectionTracker = class {
|
|
|
2197
2404
|
* the classic policy holds: a confidence past the `hysteresis` margin that also
|
|
2198
2405
|
* respects `minGapMs` wins. On acceptance the held peak advances.
|
|
2199
2406
|
*/
|
|
2200
|
-
observe(trackId, confidence, timestamp, edgeClear) {
|
|
2407
|
+
observe(trackId, confidence, timestamp, edgeClear, centerScore) {
|
|
2201
2408
|
const cur = this.best.get(trackId);
|
|
2202
2409
|
if (cur === void 0) {
|
|
2203
2410
|
this.best.set(trackId, {
|
|
@@ -2205,16 +2412,20 @@ var BestDetectionTracker = class {
|
|
|
2205
2412
|
atMs: timestamp
|
|
2206
2413
|
});
|
|
2207
2414
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2415
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2208
2416
|
return true;
|
|
2209
2417
|
}
|
|
2210
2418
|
const curClear = this.edgeClear.get(trackId) ?? true;
|
|
2211
2419
|
const candClear = edgeClear ?? true;
|
|
2420
|
+
const curCenter = this.centerScore.get(trackId);
|
|
2212
2421
|
const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
|
|
2213
2422
|
confidence: cur.confidence,
|
|
2214
|
-
edgeClear: curClear
|
|
2423
|
+
edgeClear: curClear,
|
|
2424
|
+
...curCenter !== void 0 ? { centerScore: curCenter } : {}
|
|
2215
2425
|
}, {
|
|
2216
2426
|
confidence,
|
|
2217
|
-
edgeClear: candClear
|
|
2427
|
+
edgeClear: candClear,
|
|
2428
|
+
...centerScore !== void 0 ? { centerScore } : {}
|
|
2218
2429
|
}, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
|
|
2219
2430
|
if (isNewBest) {
|
|
2220
2431
|
this.best.set(trackId, {
|
|
@@ -2222,6 +2433,7 @@ var BestDetectionTracker = class {
|
|
|
2222
2433
|
atMs: timestamp
|
|
2223
2434
|
});
|
|
2224
2435
|
if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
|
|
2436
|
+
if (centerScore !== void 0) this.centerScore.set(trackId, centerScore);
|
|
2225
2437
|
}
|
|
2226
2438
|
return isNewBest;
|
|
2227
2439
|
}
|
|
@@ -2233,10 +2445,12 @@ var BestDetectionTracker = class {
|
|
|
2233
2445
|
delete(trackId) {
|
|
2234
2446
|
this.best.delete(trackId);
|
|
2235
2447
|
this.edgeClear.delete(trackId);
|
|
2448
|
+
this.centerScore.delete(trackId);
|
|
2236
2449
|
}
|
|
2237
2450
|
clear() {
|
|
2238
2451
|
this.best.clear();
|
|
2239
2452
|
this.edgeClear.clear();
|
|
2453
|
+
this.centerScore.clear();
|
|
2240
2454
|
}
|
|
2241
2455
|
};
|
|
2242
2456
|
//#endregion
|
|
@@ -2389,6 +2603,9 @@ var WAKE_ASSOC_IOU = .1;
|
|
|
2389
2603
|
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2390
2604
|
*/
|
|
2391
2605
|
var PROMOTION_WINDOW_MS = 3e4;
|
|
2606
|
+
/** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
|
|
2607
|
+
* long (object removed while unobserved, or a long occlusion) → retire it. */
|
|
2608
|
+
var ENTRY_TTL_MS = 5 * 6e4;
|
|
2392
2609
|
var DEFAULT_MATCH_CONFIG = {
|
|
2393
2610
|
suppressIou: SUPPRESS_IOU,
|
|
2394
2611
|
wakeAssocIou: WAKE_ASSOC_IOU
|
|
@@ -2596,6 +2813,7 @@ var StationaryObjectRegistry = class {
|
|
|
2596
2813
|
logger;
|
|
2597
2814
|
matchConfig;
|
|
2598
2815
|
entryTtlMs;
|
|
2816
|
+
ttlForDevice;
|
|
2599
2817
|
onChange;
|
|
2600
2818
|
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2601
2819
|
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
@@ -2607,6 +2825,7 @@ var StationaryObjectRegistry = class {
|
|
|
2607
2825
|
this.logger = deps.logger;
|
|
2608
2826
|
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2609
2827
|
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2828
|
+
this.ttlForDevice = deps.ttlForDevice;
|
|
2610
2829
|
this.onChange = deps.onChange;
|
|
2611
2830
|
}
|
|
2612
2831
|
static async declare(store) {
|
|
@@ -2662,7 +2881,7 @@ var StationaryObjectRegistry = class {
|
|
|
2662
2881
|
* entries. PURE with respect to registry state — apply the outcome with
|
|
2663
2882
|
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2664
2883
|
*/
|
|
2665
|
-
filter(input) {
|
|
2884
|
+
filter(input, config) {
|
|
2666
2885
|
const entries = this.list(input.deviceId);
|
|
2667
2886
|
if (entries.length === 0) return {
|
|
2668
2887
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
@@ -2672,7 +2891,7 @@ var StationaryObjectRegistry = class {
|
|
|
2672
2891
|
return partitionDetectionsAgainstRegistry({
|
|
2673
2892
|
entries,
|
|
2674
2893
|
detections: input.detections,
|
|
2675
|
-
config: this.matchConfig
|
|
2894
|
+
config: config ?? this.matchConfig
|
|
2676
2895
|
});
|
|
2677
2896
|
}
|
|
2678
2897
|
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
@@ -2741,7 +2960,8 @@ var StationaryObjectRegistry = class {
|
|
|
2741
2960
|
for (const [deviceId, m] of this.byDevice) {
|
|
2742
2961
|
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2743
2962
|
if (observedAt === void 0) continue;
|
|
2744
|
-
|
|
2963
|
+
const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
|
|
2964
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > ttl) {
|
|
2745
2965
|
m.delete(id);
|
|
2746
2966
|
this.dirty.delete(id);
|
|
2747
2967
|
retired.push(e);
|
|
@@ -2890,6 +3110,55 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2890
3110
|
return matched;
|
|
2891
3111
|
}
|
|
2892
3112
|
//#endregion
|
|
3113
|
+
//#region src/pipeline-analytics/stationary-settings.ts
|
|
3114
|
+
/**
|
|
3115
|
+
* Per-device stationary-object (parked/idle suppression) settings. Cascade: a
|
|
3116
|
+
* per-device override on top of the global default, resolved per field (an
|
|
3117
|
+
* invalid/missing value falls back to its default — parse never throws).
|
|
3118
|
+
* Mirrors `media-settings` / `tracking-settings`.
|
|
3119
|
+
*
|
|
3120
|
+
* The defaults are the SAME constants the registry uses at runtime
|
|
3121
|
+
* (`stationary-types.ts`), imported (not copied) so an unset value and a reset
|
|
3122
|
+
* value both resolve to exactly today's behaviour — "reset == unset == today",
|
|
3123
|
+
* with no drift. Guarded by a unit test asserting the equality.
|
|
3124
|
+
*/
|
|
3125
|
+
var StationarySettingsSchema = object({
|
|
3126
|
+
/** Master switch. Off ⇒ no promotion + no suppression for this camera (every
|
|
3127
|
+
* parked object keeps spawning normal tracks). */
|
|
3128
|
+
enabled: boolean().default(true),
|
|
3129
|
+
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
3130
|
+
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
3131
|
+
suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
|
|
3132
|
+
/** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
|
|
3133
|
+
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
3134
|
+
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
3135
|
+
wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
3136
|
+
/** Recent-window stillness a track must hold to be PROMOTED to a parked
|
|
3137
|
+
* entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
|
|
3138
|
+
promotionWindowMs: number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
|
|
3139
|
+
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
3140
|
+
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
3141
|
+
entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
3142
|
+
});
|
|
3143
|
+
var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
|
|
3144
|
+
/**
|
|
3145
|
+
* Resolve a per-device store blob into typed stationary settings. Unknown/invalid
|
|
3146
|
+
* fields fall back to the default for that field (never throws on a bad blob).
|
|
3147
|
+
*/
|
|
3148
|
+
function resolveStationarySettings(raw) {
|
|
3149
|
+
const pick = (key) => {
|
|
3150
|
+
const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
|
|
3151
|
+
return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
|
|
3152
|
+
};
|
|
3153
|
+
return {
|
|
3154
|
+
enabled: pick("enabled"),
|
|
3155
|
+
suppressIou: pick("suppressIou"),
|
|
3156
|
+
wakeAssocIou: pick("wakeAssocIou"),
|
|
3157
|
+
promotionWindowMs: pick("promotionWindowMs"),
|
|
3158
|
+
entryTtlMs: pick("entryTtlMs")
|
|
3159
|
+
};
|
|
3160
|
+
}
|
|
3161
|
+
//#endregion
|
|
2893
3162
|
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2894
3163
|
/**
|
|
2895
3164
|
* Pure: no side effects. `continuing` = still active from last frame;
|
|
@@ -5823,6 +6092,22 @@ async function ingestSensorStateChange(deps, data, timestamp) {
|
|
|
5823
6092
|
}
|
|
5824
6093
|
return inserted;
|
|
5825
6094
|
}
|
|
6095
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
6096
|
+
var FULL_FRAME_QUALITY = 80;
|
|
6097
|
+
/**
|
|
6098
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
6099
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
6100
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
6101
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
6102
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
6103
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
6104
|
+
*/
|
|
6105
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
6106
|
+
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
6107
|
+
fit: "inside",
|
|
6108
|
+
withoutEnlargement: true
|
|
6109
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
6110
|
+
}
|
|
5826
6111
|
//#endregion
|
|
5827
6112
|
//#region src/pipeline-analytics/services/synthetic-sensor-track.ts
|
|
5828
6113
|
/**
|
|
@@ -5887,7 +6172,13 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
5887
6172
|
force: true
|
|
5888
6173
|
});
|
|
5889
6174
|
if (snap !== null) {
|
|
5890
|
-
const
|
|
6175
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
6176
|
+
let data = raw;
|
|
6177
|
+
try {
|
|
6178
|
+
data = await downscaleFullFrameJpeg(raw);
|
|
6179
|
+
} catch (err) {
|
|
6180
|
+
this.deps.onError?.("downscaleSnapshot", err);
|
|
6181
|
+
}
|
|
5891
6182
|
mediaKey = await this.deps.media.put({
|
|
5892
6183
|
deviceId: input.cameraId,
|
|
5893
6184
|
ownerKind: "track",
|
|
@@ -5973,6 +6264,25 @@ function squareSafeCropRegion(bbox, frame, padding) {
|
|
|
5973
6264
|
h: Math.round(ch)
|
|
5974
6265
|
};
|
|
5975
6266
|
}
|
|
6267
|
+
/**
|
|
6268
|
+
* The same square-safe 16:9 region as {@link squareSafeCropRegion}, expressed in
|
|
6269
|
+
* NORMALIZED [0,1]×[0,1] coordinates instead of pixels.
|
|
6270
|
+
*
|
|
6271
|
+
* A normalized box maps DIRECTLY onto a native-resolution surface of the SAME
|
|
6272
|
+
* aspect ratio (the native crop path downscales while preserving aspect), so the
|
|
6273
|
+
* region computed from the detection frame's dimensions addresses the exact same
|
|
6274
|
+
* ROI on the runner's retained native frame. Reuses the pixel geometry verbatim
|
|
6275
|
+
* (single source of truth) and divides by the frame dimensions.
|
|
6276
|
+
*/
|
|
6277
|
+
function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
6278
|
+
const region = squareSafeCropRegion(bbox, frame, padding);
|
|
6279
|
+
return {
|
|
6280
|
+
x: region.x / frame.W,
|
|
6281
|
+
y: region.y / frame.H,
|
|
6282
|
+
w: region.w / frame.W,
|
|
6283
|
+
h: region.h / frame.H
|
|
6284
|
+
};
|
|
6285
|
+
}
|
|
5976
6286
|
//#endregion
|
|
5977
6287
|
//#region src/shared/frame/box-drawer.ts
|
|
5978
6288
|
var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
|
|
@@ -6050,10 +6360,20 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
|
|
|
6050
6360
|
* Small downscaled `thumbnail`s are intentionally left out for now — when we
|
|
6051
6361
|
* reintroduce them they'll be a separate small kind. */
|
|
6052
6362
|
var MEDIA_QUALITY = 88;
|
|
6053
|
-
/** Output dimensions for square-safe 16:9 crops
|
|
6363
|
+
/** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
|
|
6364
|
+
* (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
|
|
6054
6365
|
var CROP_WIDTH = 640;
|
|
6055
6366
|
var CROP_HEIGHT = 360;
|
|
6056
6367
|
var CROP_QUALITY = 80;
|
|
6368
|
+
/**
|
|
6369
|
+
* Native-surface crop width cap for subject crops (`crop`/`thumbnail`/
|
|
6370
|
+
* `faceCrop`/`plateCrop`). ~960px yields a sharp gallery/reel tile AND a
|
|
6371
|
+
* ≥224px classifier input straight from the runner's native surface, WITHOUT
|
|
6372
|
+
* hauling a full 1920px frame per subject (that width is reserved for the
|
|
6373
|
+
* full-frame `keyFrame`). It is an UPPER bound only: a native miss falls back to
|
|
6374
|
+
* the ≤640 local crop, so quality never regresses below today's behaviour.
|
|
6375
|
+
*/
|
|
6376
|
+
var NATIVE_SUBJECT_CROP_MAX_WIDTH = 960;
|
|
6057
6377
|
function caption(className, confidence, label) {
|
|
6058
6378
|
const base = label && label !== className ? `${className} ${label}` : className;
|
|
6059
6379
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
@@ -6106,7 +6426,10 @@ var EventMediaDispatcher = class {
|
|
|
6106
6426
|
async captureForFrame(input) {
|
|
6107
6427
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
6108
6428
|
const snapshots = input.snapshots ?? [];
|
|
6109
|
-
const empty = {
|
|
6429
|
+
const empty = {
|
|
6430
|
+
storedSnapshots: [],
|
|
6431
|
+
thumbnailTrackIds: []
|
|
6432
|
+
};
|
|
6110
6433
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
6111
6434
|
let decoded;
|
|
6112
6435
|
try {
|
|
@@ -6157,14 +6480,19 @@ var EventMediaDispatcher = class {
|
|
|
6157
6480
|
});
|
|
6158
6481
|
return empty;
|
|
6159
6482
|
}
|
|
6160
|
-
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
6483
|
+
for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, input.cropPadding);
|
|
6161
6484
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
6162
6485
|
const storedSnapshots = [];
|
|
6486
|
+
const thumbnailTrackIds = [];
|
|
6163
6487
|
for (const sn of snapshots) {
|
|
6164
|
-
const
|
|
6165
|
-
if (
|
|
6488
|
+
const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
|
|
6489
|
+
if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
|
|
6490
|
+
if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
|
|
6166
6491
|
}
|
|
6167
|
-
return {
|
|
6492
|
+
return {
|
|
6493
|
+
storedSnapshots,
|
|
6494
|
+
thumbnailTrackIds
|
|
6495
|
+
};
|
|
6168
6496
|
}
|
|
6169
6497
|
/**
|
|
6170
6498
|
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
@@ -6175,10 +6503,14 @@ var EventMediaDispatcher = class {
|
|
|
6175
6503
|
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
6176
6504
|
* parked car), not the track's subject. Returns the appended snapshot for
|
|
6177
6505
|
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
6178
|
-
* failed).
|
|
6506
|
+
* failed). `thumbnailWritten` reports whether a best `thumbnail` actually
|
|
6507
|
+
* landed this frame (#27-A) so the caller can stop forcing retries.
|
|
6179
6508
|
*/
|
|
6180
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
6181
|
-
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return
|
|
6509
|
+
async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
|
|
6510
|
+
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
|
|
6511
|
+
storedSnapshot: null,
|
|
6512
|
+
thumbnailWritten: false
|
|
6513
|
+
};
|
|
6182
6514
|
let boxed = null;
|
|
6183
6515
|
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
6184
6516
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
@@ -6213,9 +6545,10 @@ var EventMediaDispatcher = class {
|
|
|
6213
6545
|
};
|
|
6214
6546
|
} catch {}
|
|
6215
6547
|
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
6548
|
+
let thumbnailWritten = false;
|
|
6216
6549
|
if (sn.bestThumbnail) try {
|
|
6217
|
-
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
6218
|
-
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6550
|
+
const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, sn.bbox, cropPadding);
|
|
6551
|
+
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6219
6552
|
} catch (err) {
|
|
6220
6553
|
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
6221
6554
|
tags: { deviceId },
|
|
@@ -6225,17 +6558,46 @@ var EventMediaDispatcher = class {
|
|
|
6225
6558
|
error: err instanceof Error ? err.message : String(err)
|
|
6226
6559
|
}
|
|
6227
6560
|
});
|
|
6228
|
-
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6561
|
+
if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6229
6562
|
}
|
|
6230
|
-
return
|
|
6563
|
+
return {
|
|
6564
|
+
storedSnapshot: stored,
|
|
6565
|
+
thumbnailWritten
|
|
6566
|
+
};
|
|
6231
6567
|
}
|
|
6232
6568
|
/**
|
|
6233
|
-
* Clean subject-centered crop of `bbox`
|
|
6234
|
-
*
|
|
6235
|
-
* square-safe 16:9 region around the bbox
|
|
6236
|
-
*
|
|
6569
|
+
* Clean subject-centered crop of `bbox` — the shared output contract of the
|
|
6570
|
+
* object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
|
|
6571
|
+
* `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
|
|
6572
|
+
*
|
|
6573
|
+
* NATIVE-FIRST: the region is requested from the runner's retained native
|
|
6574
|
+
* surface (normalized [0,1] coords map directly onto it), downscaled to
|
|
6575
|
+
* {@link NATIVE_SUBJECT_CROP_MAX_WIDTH} — a sharp tile at native detail. On any
|
|
6576
|
+
* miss/error (or a runner without the method) it FALLS BACK to cropping the
|
|
6577
|
+
* resolved ≤640 frame locally (today's behaviour), so quality never regresses.
|
|
6578
|
+
* Both paths run inside the live-handle window opened by `captureForFrame`.
|
|
6237
6579
|
*/
|
|
6238
|
-
async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
|
|
6580
|
+
async cropSubjectRegion(frameHandle, frameData, fw, fh, bbox, cropPadding) {
|
|
6581
|
+
if (this.deps.getNativeCropJpeg) try {
|
|
6582
|
+
const norm = squareSafeCropRegionNormalized(bbox, {
|
|
6583
|
+
W: fw,
|
|
6584
|
+
H: fh
|
|
6585
|
+
}, cropPadding);
|
|
6586
|
+
const native = await this.deps.getNativeCropJpeg(frameHandle, norm, NATIVE_SUBJECT_CROP_MAX_WIDTH);
|
|
6587
|
+
if (native) return native;
|
|
6588
|
+
} catch (err) {
|
|
6589
|
+
this.deps.logger.debug("event media: native subject crop failed — local fallback", { meta: {
|
|
6590
|
+
shmId: frameHandle.shmId,
|
|
6591
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6592
|
+
} });
|
|
6593
|
+
}
|
|
6594
|
+
return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
|
|
6595
|
+
}
|
|
6596
|
+
/**
|
|
6597
|
+
* Local-fallback square-safe 16:9 crop out of the resolved ≤640 frame, resized
|
|
6598
|
+
* to 640×360, JPEG q80. Used when the native surface is unavailable/missed.
|
|
6599
|
+
*/
|
|
6600
|
+
async cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding) {
|
|
6239
6601
|
const region = squareSafeCropRegion(bbox, {
|
|
6240
6602
|
W: fw,
|
|
6241
6603
|
H: fh
|
|
@@ -6265,6 +6627,7 @@ var EventMediaDispatcher = class {
|
|
|
6265
6627
|
timestamp,
|
|
6266
6628
|
data
|
|
6267
6629
|
});
|
|
6630
|
+
return true;
|
|
6268
6631
|
} catch (err) {
|
|
6269
6632
|
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
6270
6633
|
tags: { deviceId },
|
|
@@ -6274,15 +6637,16 @@ var EventMediaDispatcher = class {
|
|
|
6274
6637
|
error: err instanceof Error ? err.message : String(err)
|
|
6275
6638
|
}
|
|
6276
6639
|
});
|
|
6640
|
+
return false;
|
|
6277
6641
|
}
|
|
6278
6642
|
}
|
|
6279
|
-
async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
|
|
6643
|
+
async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
|
|
6280
6644
|
const box = {
|
|
6281
6645
|
...ev.bbox,
|
|
6282
6646
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
6283
6647
|
};
|
|
6284
6648
|
try {
|
|
6285
|
-
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
6649
|
+
const crop = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, ev.bbox, cropPadding);
|
|
6286
6650
|
await this.deps.mediaStore.put({
|
|
6287
6651
|
deviceId,
|
|
6288
6652
|
ownerKind: "event",
|
|
@@ -6342,24 +6706,7 @@ var EventMediaDispatcher = class {
|
|
|
6342
6706
|
});
|
|
6343
6707
|
}
|
|
6344
6708
|
if (ev.childCrops) for (const child of ev.childCrops) try {
|
|
6345
|
-
const
|
|
6346
|
-
W: fw,
|
|
6347
|
-
H: fh
|
|
6348
|
-
}, cropPadding);
|
|
6349
|
-
const childLeft = Math.max(0, Math.min(childRegion.x, fw - 1));
|
|
6350
|
-
const childTop = Math.max(0, Math.min(childRegion.y, fh - 1));
|
|
6351
|
-
const childWidth = Math.max(1, Math.min(childRegion.w, fw - childLeft));
|
|
6352
|
-
const childHeight = Math.max(1, Math.min(childRegion.h, fh - childTop));
|
|
6353
|
-
const childCropData = await sharp(frameData, { raw: {
|
|
6354
|
-
width: fw,
|
|
6355
|
-
height: fh,
|
|
6356
|
-
channels: 3
|
|
6357
|
-
} }).extract({
|
|
6358
|
-
left: childLeft,
|
|
6359
|
-
top: childTop,
|
|
6360
|
-
width: childWidth,
|
|
6361
|
-
height: childHeight
|
|
6362
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
6709
|
+
const childCropData = await this.cropSubjectRegion(frameHandle, frameData, fw, fh, child.bbox, cropPadding);
|
|
6363
6710
|
await this.deps.mediaStore.put({
|
|
6364
6711
|
deviceId,
|
|
6365
6712
|
ownerKind: "event",
|
|
@@ -7520,6 +7867,56 @@ var TrackingSettingsSchema = object({
|
|
|
7520
7867
|
/** Speed (px/frame) below which a track's prediction is frozen (stationary
|
|
7521
7868
|
* jitter can't drift the box off a sitting object). */
|
|
7522
7869
|
stationarySpeedPx: number().min(0).default(2),
|
|
7870
|
+
/** Suppress + merge concurrent duplicate tracks (one subject the detector
|
|
7871
|
+
* double-fires, or a person misdetected as `animal`, otherwise becomes two
|
|
7872
|
+
* time-overlapping tracks that never re-associate — each firing its own
|
|
7873
|
+
* event). Off = legacy behaviour. */
|
|
7874
|
+
dedupEnabled: boolean().default(true),
|
|
7875
|
+
/** Envelope IoU at/above which a NEW spawn is treated as a duplicate of a
|
|
7876
|
+
* concurrent compatible-class track and suppressed. High so distinct
|
|
7877
|
+
* subjects appearing close together are not collapsed. */
|
|
7878
|
+
dedupSpawnIou: number().min(0).max(1).default(.6),
|
|
7879
|
+
/** Envelope IoU at/above which two concurrent tracks count as overlapping for
|
|
7880
|
+
* the sustained-merge streak. Kept equal to `dedupSpawnIou` by default so a
|
|
7881
|
+
* merged duplicate cannot re-spawn. */
|
|
7882
|
+
dedupMergeIou: number().min(0).max(1).default(.6),
|
|
7883
|
+
/** Consecutive overlapping frames before two live tracks are merged (the
|
|
7884
|
+
* lower-importance one dropped). Higher = more conservative. */
|
|
7885
|
+
dedupMergeFrames: number().int().min(1).default(5),
|
|
7886
|
+
/** Treat the {person,animal} class pair as duplicate-compatible — a person in
|
|
7887
|
+
* a non-upright pose is misdetected as `animal`; the false animal track
|
|
7888
|
+
* collapses into the real person track. */
|
|
7889
|
+
personAnimalDedup: boolean().default(true),
|
|
7890
|
+
/** A low-confidence `animal` (score below this) overlapping a concurrent
|
|
7891
|
+
* person track is suppressed at spawn. */
|
|
7892
|
+
animalOverPersonMaxScore: number().min(0).max(1).default(.6),
|
|
7893
|
+
/** IoU with a concurrent person track that triggers the low-confidence animal
|
|
7894
|
+
* spawn suppression. */
|
|
7895
|
+
animalOverPersonIou: number().min(0).max(1).default(.2),
|
|
7896
|
+
/** Resolve a track's reported class by a confidence-weighted majority over its
|
|
7897
|
+
* lifetime (vs. the latest frame) — kills per-frame class flips (a person
|
|
7898
|
+
* read as `animal` on one crouch frame keeps the `person` label). */
|
|
7899
|
+
classVotingEnabled: boolean().default(true),
|
|
7900
|
+
/** Winning class must hold at least this fraction of a track's total vote
|
|
7901
|
+
* weight to override the latest-frame class; below it the latest wins (so a
|
|
7902
|
+
* genuine mid-life reclassification is never frozen out). */
|
|
7903
|
+
classVoteMinFraction: number().min(0).max(1).default(.5),
|
|
7904
|
+
/** Enforce a per-class minimum detection score at track SPAWN (an established
|
|
7905
|
+
* track still matches below its floor — only new spawns are gated). */
|
|
7906
|
+
perClassMinScoreEnabled: boolean().default(true),
|
|
7907
|
+
/** Minimum spawn score for `person`. 0 = ungated (kept sensitive). */
|
|
7908
|
+
minScorePerson: number().min(0).max(1).default(0),
|
|
7909
|
+
/** Minimum spawn score for `animal` (FP-prone — higher floor). */
|
|
7910
|
+
minScoreAnimal: number().min(0).max(1).default(.45),
|
|
7911
|
+
/** Minimum spawn score for `vehicle` (FP-prone — higher floor). */
|
|
7912
|
+
minScoreVehicle: number().min(0).max(1).default(.45),
|
|
7913
|
+
/** Let a single very-high-confidence detection confirm a track for emission
|
|
7914
|
+
* before it reaches `minHits` (so a fast car crossing in 1-2 frames still
|
|
7915
|
+
* registers). Opt-in — default OFF keeps the strict N-hit gate. */
|
|
7916
|
+
confirmBypassEnabled: boolean().default(false),
|
|
7917
|
+
/** Score at/above which a detection confirms its track immediately (bypasses
|
|
7918
|
+
* `minHits`). Only consulted when `confirmBypassEnabled`. */
|
|
7919
|
+
confirmBypassScore: number().min(0).max(1).default(.9),
|
|
7523
7920
|
/** Skip frames where detections suddenly drop to zero (detector glitch). */
|
|
7524
7921
|
dropoutSkipEnabled: boolean().default(true),
|
|
7525
7922
|
/** Max consecutive all-zero frames absorbed as a glitch before the scene is
|
|
@@ -7554,6 +7951,21 @@ function resolveTrackingSettings(raw) {
|
|
|
7554
7951
|
rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
|
|
7555
7952
|
resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
|
|
7556
7953
|
stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
|
|
7954
|
+
dedupEnabled: s.dedupEnabled.catch(TRACKING_DEFAULTS.dedupEnabled).parse(raw.dedupEnabled),
|
|
7955
|
+
dedupSpawnIou: s.dedupSpawnIou.catch(TRACKING_DEFAULTS.dedupSpawnIou).parse(raw.dedupSpawnIou),
|
|
7956
|
+
dedupMergeIou: s.dedupMergeIou.catch(TRACKING_DEFAULTS.dedupMergeIou).parse(raw.dedupMergeIou),
|
|
7957
|
+
dedupMergeFrames: s.dedupMergeFrames.catch(TRACKING_DEFAULTS.dedupMergeFrames).parse(raw.dedupMergeFrames),
|
|
7958
|
+
personAnimalDedup: s.personAnimalDedup.catch(TRACKING_DEFAULTS.personAnimalDedup).parse(raw.personAnimalDedup),
|
|
7959
|
+
animalOverPersonMaxScore: s.animalOverPersonMaxScore.catch(TRACKING_DEFAULTS.animalOverPersonMaxScore).parse(raw.animalOverPersonMaxScore),
|
|
7960
|
+
animalOverPersonIou: s.animalOverPersonIou.catch(TRACKING_DEFAULTS.animalOverPersonIou).parse(raw.animalOverPersonIou),
|
|
7961
|
+
classVotingEnabled: s.classVotingEnabled.catch(TRACKING_DEFAULTS.classVotingEnabled).parse(raw.classVotingEnabled),
|
|
7962
|
+
classVoteMinFraction: s.classVoteMinFraction.catch(TRACKING_DEFAULTS.classVoteMinFraction).parse(raw.classVoteMinFraction),
|
|
7963
|
+
perClassMinScoreEnabled: s.perClassMinScoreEnabled.catch(TRACKING_DEFAULTS.perClassMinScoreEnabled).parse(raw.perClassMinScoreEnabled),
|
|
7964
|
+
minScorePerson: s.minScorePerson.catch(TRACKING_DEFAULTS.minScorePerson).parse(raw.minScorePerson),
|
|
7965
|
+
minScoreAnimal: s.minScoreAnimal.catch(TRACKING_DEFAULTS.minScoreAnimal).parse(raw.minScoreAnimal),
|
|
7966
|
+
minScoreVehicle: s.minScoreVehicle.catch(TRACKING_DEFAULTS.minScoreVehicle).parse(raw.minScoreVehicle),
|
|
7967
|
+
confirmBypassEnabled: s.confirmBypassEnabled.catch(TRACKING_DEFAULTS.confirmBypassEnabled).parse(raw.confirmBypassEnabled),
|
|
7968
|
+
confirmBypassScore: s.confirmBypassScore.catch(TRACKING_DEFAULTS.confirmBypassScore).parse(raw.confirmBypassScore),
|
|
7557
7969
|
dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
|
|
7558
7970
|
dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
|
|
7559
7971
|
};
|
|
@@ -7576,15 +7988,30 @@ var FaceSettingsSchema = object({
|
|
|
7576
7988
|
*/
|
|
7577
7989
|
enabled: boolean().default(true),
|
|
7578
7990
|
/** Cosine similarity (on L2-normalized arcface vectors) required to match. */
|
|
7579
|
-
similarityThreshold: number().min(0).max(1).default(.
|
|
7991
|
+
similarityThreshold: number().min(0).max(1).default(.55),
|
|
7580
7992
|
/** Reject ambiguous matches: require best − secondBest ≥ margin. */
|
|
7581
|
-
margin: number().min(0).max(1).default(.
|
|
7993
|
+
margin: number().min(0).max(1).default(.1),
|
|
7582
7994
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
7583
7995
|
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
7996
|
+
/**
|
|
7997
|
+
* Minimum face bbox size (px, shorter side of the face box in detection-frame
|
|
7998
|
+
* space) for a face to be eligible for embedding-based auto-matching. Below
|
|
7999
|
+
* this, ArcFace resolution is unreliable and auto-assignment produces the
|
|
8000
|
+
* observed false positives (tiny/distant faces collapsing onto one identity).
|
|
8001
|
+
* Such faces are dropped BEFORE matching/enrolment (#26.1).
|
|
8002
|
+
*/
|
|
8003
|
+
minFacePx: number().min(0).default(30),
|
|
8004
|
+
/**
|
|
8005
|
+
* Minimum enrolled-sample count an identity must have before it can be an
|
|
8006
|
+
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
8007
|
+
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
8008
|
+
* automatic matching until more samples are enrolled (#26.3).
|
|
8009
|
+
*/
|
|
8010
|
+
minIdentitySamples: number().int().min(1).default(2),
|
|
7584
8011
|
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
7585
8012
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
7586
8013
|
* such a value falls back to the default). */
|
|
7587
|
-
confirmFrames: number().int().min(1).default(
|
|
8014
|
+
confirmFrames: number().int().min(1).default(3),
|
|
7588
8015
|
/** Recent-faces buffer retention (days). */
|
|
7589
8016
|
bufferRetentionDays: number().min(0).default(3),
|
|
7590
8017
|
/** Max buffered faces kept per device. */
|
|
@@ -7601,6 +8028,8 @@ function resolveFaceSettings(raw) {
|
|
|
7601
8028
|
similarityThreshold: pick("similarityThreshold"),
|
|
7602
8029
|
margin: pick("margin"),
|
|
7603
8030
|
minFaceConfidence: pick("minFaceConfidence"),
|
|
8031
|
+
minFacePx: pick("minFacePx"),
|
|
8032
|
+
minIdentitySamples: pick("minIdentitySamples"),
|
|
7604
8033
|
confirmFrames: pick("confirmFrames"),
|
|
7605
8034
|
bufferRetentionDays: pick("bufferRetentionDays"),
|
|
7606
8035
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
@@ -7936,13 +8365,49 @@ function evaluatePeriodicSnapshot(input) {
|
|
|
7936
8365
|
*/
|
|
7937
8366
|
function planPeriodicMedia(input) {
|
|
7938
8367
|
const appendSnapshot = input.dueSnapshot;
|
|
8368
|
+
const rollingLastFrame = input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot;
|
|
8369
|
+
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
7939
8370
|
return {
|
|
7940
8371
|
appendSnapshot,
|
|
7941
|
-
rollingLastFrame
|
|
7942
|
-
bestThumbnail: input.isNewBest
|
|
8372
|
+
rollingLastFrame,
|
|
8373
|
+
bestThumbnail: input.isNewBest || !thumbnailLanded
|
|
7943
8374
|
};
|
|
7944
8375
|
}
|
|
7945
8376
|
//#endregion
|
|
8377
|
+
//#region src/pipeline-analytics/best-thumbnail-guard.ts
|
|
8378
|
+
/**
|
|
8379
|
+
* Void/envArea guard for best-`thumbnail` selection.
|
|
8380
|
+
*
|
|
8381
|
+
* ## Why this exists (the dawn/night "void" thumbnail)
|
|
8382
|
+
*
|
|
8383
|
+
* At dawn/night a moving subject's tracker box intermittently EXPLODES to
|
|
8384
|
+
* (near-)the whole frame — the "envelope exploded to full-frame" signature. If
|
|
8385
|
+
* that frame happens to win the best-detection race, the gallery/reel best
|
|
8386
|
+
* `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
|
|
8387
|
+
* washed-out frame), not the subject. This guard rejects such a frame from the
|
|
8388
|
+
* best-`thumbnail` decision so the track keeps a real subject-centered tile.
|
|
8389
|
+
*
|
|
8390
|
+
* Conservative by design: it only rejects boxes covering ≥ {@link
|
|
8391
|
+
* NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
|
|
8392
|
+
* well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
|
|
8393
|
+
* per-frame retry keeps trying until a plausible frame wins.
|
|
8394
|
+
*/
|
|
8395
|
+
/**
|
|
8396
|
+
* Area fraction at/above which a detection bbox is treated as an exploded
|
|
8397
|
+
* "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
|
|
8398
|
+
* guard conservative — only boxes covering ≥85% of the frame are rejected.
|
|
8399
|
+
*/
|
|
8400
|
+
var NEAR_FULL_FRAME_AREA = .85;
|
|
8401
|
+
/**
|
|
8402
|
+
* True when `bbox` is a plausible SUBJECT box for a best `thumbnail` — i.e. its
|
|
8403
|
+
* area is below the near-full-frame threshold. Degenerate frame dimensions
|
|
8404
|
+
* (≤0) are treated as plausible (no info to reject on).
|
|
8405
|
+
*/
|
|
8406
|
+
function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
|
|
8407
|
+
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
8408
|
+
return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
|
|
8409
|
+
}
|
|
8410
|
+
//#endregion
|
|
7946
8411
|
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
7947
8412
|
/**
|
|
7948
8413
|
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
@@ -8922,6 +9387,21 @@ var ObjectEmbeddingStore = class {
|
|
|
8922
9387
|
//#endregion
|
|
8923
9388
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
8924
9389
|
/**
|
|
9390
|
+
* Count enrolled samples per identity for probes of a matching model+dimension.
|
|
9391
|
+
* Only identities meeting `minIdentitySamples` are eligible auto-match targets.
|
|
9392
|
+
*/
|
|
9393
|
+
function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
|
|
9394
|
+
const counts = /* @__PURE__ */ new Map();
|
|
9395
|
+
for (const s of gallery) {
|
|
9396
|
+
if (s.modelId !== probeModelId) continue;
|
|
9397
|
+
if (s.embedding.length !== probeDim) continue;
|
|
9398
|
+
counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
|
|
9399
|
+
}
|
|
9400
|
+
const eligible = /* @__PURE__ */ new Set();
|
|
9401
|
+
for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
|
|
9402
|
+
return eligible;
|
|
9403
|
+
}
|
|
9404
|
+
/**
|
|
8925
9405
|
* Assign at most one identity per track AND at most one track per identity for
|
|
8926
9406
|
* a single frame. Greedy by score: compute every candidate's full ranked match
|
|
8927
9407
|
* list, then repeatedly take the globally-highest (track, identity) pair whose
|
|
@@ -8932,10 +9412,12 @@ function assignUniquePerFrame(candidates, gallery, opts) {
|
|
|
8932
9412
|
const pairs = [];
|
|
8933
9413
|
candidates.forEach((c, trackIdx) => {
|
|
8934
9414
|
const probeVec = new Float32Array(c.embedding);
|
|
9415
|
+
const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
|
|
8935
9416
|
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
8936
9417
|
for (const s of gallery) {
|
|
8937
9418
|
if (s.modelId !== c.modelId) continue;
|
|
8938
9419
|
if (s.embedding.length !== c.embedding.length) continue;
|
|
9420
|
+
if (!eligible.has(s.identityId)) continue;
|
|
8939
9421
|
const score = cosineSimilarity(probeVec, new Float32Array(s.embedding));
|
|
8940
9422
|
const prev = bestByIdentity.get(s.identityId);
|
|
8941
9423
|
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
@@ -9081,7 +9563,7 @@ var FaceRecognizer = class {
|
|
|
9081
9563
|
}
|
|
9082
9564
|
async processFrame(input) {
|
|
9083
9565
|
const { settings } = input;
|
|
9084
|
-
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
|
|
9566
|
+
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));
|
|
9085
9567
|
if (candidates.length === 0) return;
|
|
9086
9568
|
this.deps.logger.debug("face: frame candidates", {
|
|
9087
9569
|
tags: { deviceId: input.deviceId },
|
|
@@ -9097,7 +9579,8 @@ var FaceRecognizer = class {
|
|
|
9097
9579
|
modelId: c.embeddingModelId
|
|
9098
9580
|
})), this.gallery, {
|
|
9099
9581
|
threshold: settings.similarityThreshold,
|
|
9100
|
-
margin: settings.margin
|
|
9582
|
+
margin: settings.margin,
|
|
9583
|
+
minIdentitySamples: settings.minIdentitySamples
|
|
9101
9584
|
}) : /* @__PURE__ */ new Map();
|
|
9102
9585
|
const labelWork = [];
|
|
9103
9586
|
for (const c of candidates) {
|
|
@@ -11232,6 +11715,35 @@ function toAnalyticsDeviceSections(sections) {
|
|
|
11232
11715
|
}));
|
|
11233
11716
|
}
|
|
11234
11717
|
/**
|
|
11718
|
+
* Global-analytics section ids that are really per-camera DETECTION knobs and
|
|
11719
|
+
* belong in the consolidated `Detection pipeline → Settings` sub-tab alongside
|
|
11720
|
+
* Object Detection — NOT under the generic `Analytics` top-tab:
|
|
11721
|
+
* - `detection-sensitivity` — minHits / cooldown / stationary threshold.
|
|
11722
|
+
* - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
|
|
11723
|
+
* animal dedup, class voting, confirm-bypass, per-class min score).
|
|
11724
|
+
* - `stationary-objects` — stationary promotion + occupancy tuning.
|
|
11725
|
+
* `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
|
|
11726
|
+
* structural Detection pipeline tab, so re-tagging is all that's needed —
|
|
11727
|
+
* there is no admin-ui change and no duplicate render (a section has one tab).
|
|
11728
|
+
*/
|
|
11729
|
+
var DETECTION_PIPELINE_SECTION_IDS = new Set([
|
|
11730
|
+
"detection-sensitivity",
|
|
11731
|
+
"tracking",
|
|
11732
|
+
"stationary-objects"
|
|
11733
|
+
]);
|
|
11734
|
+
/**
|
|
11735
|
+
* Re-home the detection-knob sections from the `Analytics` top-tab onto the
|
|
11736
|
+
* `detection-pipeline` top-tab. Pure copy — only the `tab` of a matched
|
|
11737
|
+
* section changes; every other section (media policy, retention, faces, track
|
|
11738
|
+
* history) stays on Analytics.
|
|
11739
|
+
*/
|
|
11740
|
+
function retagDetectionSections(sections) {
|
|
11741
|
+
return sections.map((s) => s.id !== void 0 && DETECTION_PIPELINE_SECTION_IDS.has(s.id) ? {
|
|
11742
|
+
...s,
|
|
11743
|
+
tab: "detection-pipeline"
|
|
11744
|
+
} : s);
|
|
11745
|
+
}
|
|
11746
|
+
/**
|
|
11235
11747
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
11236
11748
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
11237
11749
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -11344,6 +11856,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11344
11856
|
faceGlobalEnabledCache = null;
|
|
11345
11857
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
11346
11858
|
packageDropCacheByDevice = /* @__PURE__ */ new Map();
|
|
11859
|
+
/** Per-device stationary settings cache (#31), TTL-mirrored like media/tracking.
|
|
11860
|
+
* The registry is a single shared instance, so per-(device) suppress/wake IoU,
|
|
11861
|
+
* promotion window, TTL and the master toggle are resolved from this cache at
|
|
11862
|
+
* the partition + promotion + sweep call sites — an operator change takes
|
|
11863
|
+
* effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
|
|
11864
|
+
stationaryCacheByDevice = /* @__PURE__ */ new Map();
|
|
11347
11865
|
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
11348
11866
|
packageDropDetector = null;
|
|
11349
11867
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
@@ -11376,6 +11894,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11376
11894
|
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
11377
11895
|
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
11378
11896
|
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
11897
|
+
/** Track ids whose best `thumbnail` has ACTUALLY been persisted (#27-A). A
|
|
11898
|
+
* track absent here keeps forcing a best-thumbnail capture every frame until
|
|
11899
|
+
* one lands, so a short / high-churn track whose first capture was dropped
|
|
11900
|
+
* (recycled/blank live frame) still gets a subject crop for the gallery
|
|
11901
|
+
* instead of degrading to a full-scene tile. Cleared on track end + reset. */
|
|
11902
|
+
thumbnailLandedTracks = /* @__PURE__ */ new Set();
|
|
11379
11903
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
11380
11904
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
11381
11905
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -11447,6 +11971,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11447
11971
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
11448
11972
|
store: api.settingsStore,
|
|
11449
11973
|
logger: logger.child("StationaryRegistry"),
|
|
11974
|
+
ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
|
|
11450
11975
|
onChange: ({ phase, entry, timestamp }) => {
|
|
11451
11976
|
this.ctx.eventBus.emit({
|
|
11452
11977
|
id: `pa-stationary-${entry.id}-${phase}`,
|
|
@@ -11600,11 +12125,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11600
12125
|
timestamp: 0
|
|
11601
12126
|
};
|
|
11602
12127
|
};
|
|
11603
|
-
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
11604
|
-
getRemoteFrame,
|
|
11605
|
-
mediaStore: this.mediaStore,
|
|
11606
|
-
logger: logger.child("EventMediaDispatcher")
|
|
11607
|
-
});
|
|
11608
12128
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
11609
12129
|
let nativeHits = 0;
|
|
11610
12130
|
let nativeFallbacks = 0;
|
|
@@ -11636,6 +12156,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11636
12156
|
return null;
|
|
11637
12157
|
}
|
|
11638
12158
|
};
|
|
12159
|
+
const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
|
|
12160
|
+
const jpeg = await tryNativeCrop(frameHandle, normalizedBbox, maxWidth);
|
|
12161
|
+
bumpCropMetric(jpeg !== null);
|
|
12162
|
+
return jpeg;
|
|
12163
|
+
};
|
|
12164
|
+
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
12165
|
+
getRemoteFrame,
|
|
12166
|
+
getNativeCropJpeg,
|
|
12167
|
+
mediaStore: this.mediaStore,
|
|
12168
|
+
logger: logger.child("EventMediaDispatcher")
|
|
12169
|
+
});
|
|
11639
12170
|
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
11640
12171
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
11641
12172
|
const paddedNorm = padBbox({
|
|
@@ -11822,6 +12353,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11822
12353
|
this.faceCacheByDevice.delete(data.deviceId);
|
|
11823
12354
|
this.mediaCacheByDevice.delete(data.deviceId);
|
|
11824
12355
|
this.packageDropCacheByDevice.delete(data.deviceId);
|
|
12356
|
+
this.stationaryCacheByDevice.delete(data.deviceId);
|
|
11825
12357
|
}
|
|
11826
12358
|
});
|
|
11827
12359
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
@@ -11838,6 +12370,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
11838
12370
|
this.faceCacheByDevice.delete(deviceId);
|
|
11839
12371
|
this.mediaCacheByDevice.delete(deviceId);
|
|
11840
12372
|
this.packageDropCacheByDevice.delete(deviceId);
|
|
12373
|
+
this.stationaryCacheByDevice.delete(deviceId);
|
|
11841
12374
|
this.bindingCache?.invalidate(deviceId);
|
|
11842
12375
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
11843
12376
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -12176,6 +12709,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12176
12709
|
this.dropoutSkipsByKey.clear();
|
|
12177
12710
|
this.bestFrameTracker.clear();
|
|
12178
12711
|
this.lastFrameAtByTrack.clear();
|
|
12712
|
+
this.thumbnailLandedTracks.clear();
|
|
12179
12713
|
this.trackLifecycleUpdateMem.clear();
|
|
12180
12714
|
this.objectEmbeddingBestSelector.clear();
|
|
12181
12715
|
this.levelStateByDevice.clear();
|
|
@@ -12186,6 +12720,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12186
12720
|
this.faceGlobalEnabledCache = null;
|
|
12187
12721
|
this.mediaCacheByDevice.clear();
|
|
12188
12722
|
this.packageDropCacheByDevice.clear();
|
|
12723
|
+
this.stationaryCacheByDevice.clear();
|
|
12189
12724
|
this.trackStore?.clearAll();
|
|
12190
12725
|
this.stationaryRegistry = null;
|
|
12191
12726
|
this.bindingCache?.clearAll();
|
|
@@ -12230,6 +12765,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12230
12765
|
return;
|
|
12231
12766
|
}
|
|
12232
12767
|
this.dropoutSkipsByKey.set(key, 0);
|
|
12768
|
+
if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
|
|
12233
12769
|
const processor = await this.getOrCreateProcessor(deviceId, source);
|
|
12234
12770
|
const proxy = await this.ensureProxy(deviceId);
|
|
12235
12771
|
const liveZones = proxy?.state.zones.value?.zones ?? [];
|
|
@@ -12369,10 +12905,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12369
12905
|
} });
|
|
12370
12906
|
}
|
|
12371
12907
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
12372
|
-
|
|
12908
|
+
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
12909
|
+
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
12373
12910
|
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
12374
12911
|
if (dims && dims.w > 0 && dims.h > 0) {
|
|
12375
12912
|
const refDiag = Math.hypot(dims.w, dims.h);
|
|
12913
|
+
const promotionConfig = {
|
|
12914
|
+
...DEFAULT_PROMOTION_CONFIG,
|
|
12915
|
+
windowMs: stationarySettings.promotionWindowMs
|
|
12916
|
+
};
|
|
12376
12917
|
for (const t of result.tracked) {
|
|
12377
12918
|
const active = this.trackStore.peekActive(t.trackId);
|
|
12378
12919
|
if (!active) continue;
|
|
@@ -12380,7 +12921,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12380
12921
|
positions: active.positions,
|
|
12381
12922
|
referenceDiagonalPx: refDiag,
|
|
12382
12923
|
now: result.timestamp,
|
|
12383
|
-
config:
|
|
12924
|
+
config: promotionConfig
|
|
12384
12925
|
});
|
|
12385
12926
|
if (!promote) continue;
|
|
12386
12927
|
this.promoteToStationary({
|
|
@@ -12507,6 +13048,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12507
13048
|
},
|
|
12508
13049
|
mediaKey: s.mediaKey
|
|
12509
13050
|
});
|
|
13051
|
+
for (const trackId of res.thumbnailTrackIds) this.thumbnailLandedTracks.add(trackId);
|
|
12510
13052
|
}).catch(() => {});
|
|
12511
13053
|
}
|
|
12512
13054
|
}
|
|
@@ -12661,6 +13203,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12661
13203
|
});
|
|
12662
13204
|
return settings;
|
|
12663
13205
|
}
|
|
13206
|
+
async resolveDeviceStationarySettings(deviceId) {
|
|
13207
|
+
const now = Date.now();
|
|
13208
|
+
const cached = this.stationaryCacheByDevice.get(deviceId);
|
|
13209
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
13210
|
+
const settings = resolveStationarySettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
13211
|
+
this.stationaryCacheByDevice.set(deviceId, {
|
|
13212
|
+
settings,
|
|
13213
|
+
expiresAt: now + SETTINGS_CACHE_TTL_MS
|
|
13214
|
+
});
|
|
13215
|
+
return settings;
|
|
13216
|
+
}
|
|
13217
|
+
stationarySettingsFromCache(deviceId) {
|
|
13218
|
+
return this.stationaryCacheByDevice.get(deviceId)?.settings ?? STATIONARY_DEFAULTS;
|
|
13219
|
+
}
|
|
12664
13220
|
async resolveDevicePackageDropSettings(deviceId) {
|
|
12665
13221
|
const now = Date.now();
|
|
12666
13222
|
const cached = this.packageDropCacheByDevice.get(deviceId);
|
|
@@ -12758,6 +13314,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
12758
13314
|
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
12759
13315
|
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
12760
13316
|
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
13317
|
+
if (detail.bbox !== void 0 && Math.min(detail.bbox.w, detail.bbox.h) < settings.minFacePx) return;
|
|
12761
13318
|
await this.faceRecognizer.ingestFaceDetail({
|
|
12762
13319
|
deviceId,
|
|
12763
13320
|
trackId,
|
|
@@ -13036,19 +13593,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13036
13593
|
frameWidth,
|
|
13037
13594
|
frameHeight
|
|
13038
13595
|
});
|
|
13039
|
-
const
|
|
13596
|
+
const centerScore = bboxCenterScore(t.bbox, frameWidth, frameHeight);
|
|
13597
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear, centerScore);
|
|
13040
13598
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
13041
13599
|
const plan = planPeriodicMedia({
|
|
13042
13600
|
saveThumbnails: media.saveThumbnails,
|
|
13043
13601
|
dueSnapshot,
|
|
13044
13602
|
isNewBest,
|
|
13603
|
+
thumbnailLanded: this.thumbnailLandedTracks.has(t.trackId),
|
|
13045
13604
|
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
13046
13605
|
now: timestamp,
|
|
13047
13606
|
intervalMs: media.snapshotIntervalMs
|
|
13048
13607
|
});
|
|
13049
13608
|
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
13050
13609
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
13051
|
-
|
|
13610
|
+
const bestThumbnail = plan.bestThumbnail && isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
|
|
13611
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !bestThumbnail) continue;
|
|
13052
13612
|
targets.push({
|
|
13053
13613
|
trackId: t.trackId,
|
|
13054
13614
|
timestamp,
|
|
@@ -13056,7 +13616,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13056
13616
|
...t.label ? { label: t.label } : {},
|
|
13057
13617
|
appendSnapshot: plan.appendSnapshot,
|
|
13058
13618
|
rollingLastFrame: plan.rollingLastFrame,
|
|
13059
|
-
bestThumbnail
|
|
13619
|
+
bestThumbnail
|
|
13060
13620
|
});
|
|
13061
13621
|
}
|
|
13062
13622
|
return targets;
|
|
@@ -13362,6 +13922,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13362
13922
|
this.bestFrameTracker.delete(t.trackId);
|
|
13363
13923
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
13364
13924
|
this.lastFrameAtByTrack.delete(t.trackId);
|
|
13925
|
+
this.thumbnailLandedTracks.delete(t.trackId);
|
|
13365
13926
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
13366
13927
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
13367
13928
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -13623,6 +14184,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13623
14184
|
this.bestFrameTracker.delete(track.trackId);
|
|
13624
14185
|
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
13625
14186
|
this.lastFrameAtByTrack.delete(track.trackId);
|
|
14187
|
+
this.thumbnailLandedTracks.delete(track.trackId);
|
|
13626
14188
|
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
13627
14189
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
13628
14190
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
@@ -13656,7 +14218,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13656
14218
|
rescueCentroidFactor: trk.rescueCentroidFactor,
|
|
13657
14219
|
resurrectionWindowMs: trk.resurrectionWindowMs,
|
|
13658
14220
|
stationarySpeedPx: trk.stationarySpeedPx,
|
|
13659
|
-
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
|
|
14221
|
+
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3,
|
|
14222
|
+
dedupEnabled: trk.dedupEnabled,
|
|
14223
|
+
dedupSpawnIou: trk.dedupSpawnIou,
|
|
14224
|
+
dedupMergeIou: trk.dedupMergeIou,
|
|
14225
|
+
dedupMergeFrames: trk.dedupMergeFrames,
|
|
14226
|
+
personAnimalDedup: trk.personAnimalDedup,
|
|
14227
|
+
animalOverPersonMaxScore: trk.animalOverPersonMaxScore,
|
|
14228
|
+
animalOverPersonIou: trk.animalOverPersonIou,
|
|
14229
|
+
classVotingEnabled: trk.classVotingEnabled,
|
|
14230
|
+
classVoteMinFraction: trk.classVoteMinFraction,
|
|
14231
|
+
perClassMinScoreEnabled: trk.perClassMinScoreEnabled,
|
|
14232
|
+
classMinScores: {
|
|
14233
|
+
person: trk.minScorePerson,
|
|
14234
|
+
animal: trk.minScoreAnimal,
|
|
14235
|
+
vehicle: trk.minScoreVehicle
|
|
14236
|
+
},
|
|
14237
|
+
confirmBypassEnabled: trk.confirmBypassEnabled,
|
|
14238
|
+
confirmBypassScore: trk.confirmBypassScore
|
|
13660
14239
|
}, { stationaryThresholdSec }, {
|
|
13661
14240
|
minTrackAge,
|
|
13662
14241
|
cooldownSec,
|
|
@@ -13664,15 +14243,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
13664
14243
|
}, source);
|
|
13665
14244
|
if (source === "pipeline" && this.stationaryRegistry) {
|
|
13666
14245
|
const registry = this.stationaryRegistry;
|
|
13667
|
-
p.setStationaryGate({ filter: (input) =>
|
|
13668
|
-
deviceId
|
|
13669
|
-
|
|
13670
|
-
|
|
13671
|
-
|
|
13672
|
-
|
|
13673
|
-
|
|
13674
|
-
|
|
13675
|
-
|
|
14246
|
+
p.setStationaryGate({ filter: (input) => {
|
|
14247
|
+
const s = this.stationarySettingsFromCache(deviceId);
|
|
14248
|
+
if (!s.enabled) return {
|
|
14249
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
14250
|
+
confirmed: [],
|
|
14251
|
+
wokenEntryIds: []
|
|
14252
|
+
};
|
|
14253
|
+
const matchConfig = {
|
|
14254
|
+
suppressIou: s.suppressIou,
|
|
14255
|
+
wakeAssocIou: s.wakeAssocIou
|
|
14256
|
+
};
|
|
14257
|
+
return registry.filter({
|
|
14258
|
+
deviceId,
|
|
14259
|
+
detections: input.detections.map((d) => ({
|
|
14260
|
+
bbox: d.bbox,
|
|
14261
|
+
className: d.class
|
|
14262
|
+
})),
|
|
14263
|
+
frameWidth: input.frameWidth,
|
|
14264
|
+
frameHeight: input.frameHeight
|
|
14265
|
+
}, matchConfig);
|
|
14266
|
+
} });
|
|
13676
14267
|
}
|
|
13677
14268
|
this.processors.set(key, p);
|
|
13678
14269
|
}
|
|
@@ -14735,6 +15326,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
14735
15326
|
step: .05,
|
|
14736
15327
|
default: FACE_DEFAULTS.minFaceConfidence
|
|
14737
15328
|
},
|
|
15329
|
+
{
|
|
15330
|
+
type: "number",
|
|
15331
|
+
key: "minFacePx",
|
|
15332
|
+
label: "Min face size",
|
|
15333
|
+
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.",
|
|
15334
|
+
min: 0,
|
|
15335
|
+
step: 1,
|
|
15336
|
+
default: FACE_DEFAULTS.minFacePx,
|
|
15337
|
+
unit: "px"
|
|
15338
|
+
},
|
|
15339
|
+
{
|
|
15340
|
+
type: "number",
|
|
15341
|
+
key: "minIdentitySamples",
|
|
15342
|
+
label: "Min identity samples",
|
|
15343
|
+
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).",
|
|
15344
|
+
min: 1,
|
|
15345
|
+
step: 1,
|
|
15346
|
+
default: FACE_DEFAULTS.minIdentitySamples
|
|
15347
|
+
},
|
|
14738
15348
|
{
|
|
14739
15349
|
type: "number",
|
|
14740
15350
|
key: "confirmFrames",
|
|
@@ -14928,6 +15538,69 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
14928
15538
|
default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
|
|
14929
15539
|
unit: "ms"
|
|
14930
15540
|
},
|
|
15541
|
+
{
|
|
15542
|
+
type: "boolean",
|
|
15543
|
+
key: "dedupEnabled",
|
|
15544
|
+
label: "Duplicate-track suppression",
|
|
15545
|
+
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.",
|
|
15546
|
+
default: TRACKING_DEFAULTS.dedupEnabled
|
|
15547
|
+
},
|
|
15548
|
+
{
|
|
15549
|
+
type: "number",
|
|
15550
|
+
key: "dedupSpawnIou",
|
|
15551
|
+
label: "Duplicate spawn IoU",
|
|
15552
|
+
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.",
|
|
15553
|
+
min: 0,
|
|
15554
|
+
max: 1,
|
|
15555
|
+
step: .05,
|
|
15556
|
+
default: TRACKING_DEFAULTS.dedupSpawnIou
|
|
15557
|
+
},
|
|
15558
|
+
{
|
|
15559
|
+
type: "number",
|
|
15560
|
+
key: "dedupMergeIou",
|
|
15561
|
+
label: "Duplicate merge IoU",
|
|
15562
|
+
description: "Overlap at/above which two concurrent same-kind tracks count as overlapping for the sustained-merge test.",
|
|
15563
|
+
min: 0,
|
|
15564
|
+
max: 1,
|
|
15565
|
+
step: .05,
|
|
15566
|
+
default: TRACKING_DEFAULTS.dedupMergeIou
|
|
15567
|
+
},
|
|
15568
|
+
{
|
|
15569
|
+
type: "number",
|
|
15570
|
+
key: "dedupMergeFrames",
|
|
15571
|
+
label: "Duplicate merge frames",
|
|
15572
|
+
description: "Consecutive overlapping frames before two concurrent tracks are merged (the shorter / lower-importance one is dropped). Higher = more conservative (only merge sustained overlaps).",
|
|
15573
|
+
min: 1,
|
|
15574
|
+
step: 1,
|
|
15575
|
+
default: TRACKING_DEFAULTS.dedupMergeFrames
|
|
15576
|
+
},
|
|
15577
|
+
{
|
|
15578
|
+
type: "boolean",
|
|
15579
|
+
key: "personAnimalDedup",
|
|
15580
|
+
label: "Person/animal duplicate merge",
|
|
15581
|
+
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.",
|
|
15582
|
+
default: TRACKING_DEFAULTS.personAnimalDedup
|
|
15583
|
+
},
|
|
15584
|
+
{
|
|
15585
|
+
type: "number",
|
|
15586
|
+
key: "animalOverPersonMaxScore",
|
|
15587
|
+
label: "Animal-over-person max score",
|
|
15588
|
+
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.",
|
|
15589
|
+
min: 0,
|
|
15590
|
+
max: 1,
|
|
15591
|
+
step: .05,
|
|
15592
|
+
default: TRACKING_DEFAULTS.animalOverPersonMaxScore
|
|
15593
|
+
},
|
|
15594
|
+
{
|
|
15595
|
+
type: "number",
|
|
15596
|
+
key: "animalOverPersonIou",
|
|
15597
|
+
label: "Animal-over-person IoU",
|
|
15598
|
+
description: "Overlap with a concurrent person track that triggers the low-confidence animal spawn suppression.",
|
|
15599
|
+
min: 0,
|
|
15600
|
+
max: 1,
|
|
15601
|
+
step: .05,
|
|
15602
|
+
default: TRACKING_DEFAULTS.animalOverPersonIou
|
|
15603
|
+
},
|
|
14931
15604
|
{
|
|
14932
15605
|
type: "boolean",
|
|
14933
15606
|
key: "dropoutSkipEnabled",
|
|
@@ -14943,6 +15616,141 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
14943
15616
|
min: 0,
|
|
14944
15617
|
step: 1,
|
|
14945
15618
|
default: TRACKING_DEFAULTS.dropoutMaxSkipFrames
|
|
15619
|
+
},
|
|
15620
|
+
{
|
|
15621
|
+
type: "boolean",
|
|
15622
|
+
key: "classVotingEnabled",
|
|
15623
|
+
label: "Per-track class voting",
|
|
15624
|
+
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.",
|
|
15625
|
+
default: TRACKING_DEFAULTS.classVotingEnabled
|
|
15626
|
+
},
|
|
15627
|
+
{
|
|
15628
|
+
type: "number",
|
|
15629
|
+
key: "classVoteMinFraction",
|
|
15630
|
+
label: "Class-vote min fraction",
|
|
15631
|
+
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).",
|
|
15632
|
+
min: 0,
|
|
15633
|
+
max: 1,
|
|
15634
|
+
step: .05,
|
|
15635
|
+
default: TRACKING_DEFAULTS.classVoteMinFraction
|
|
15636
|
+
},
|
|
15637
|
+
{
|
|
15638
|
+
type: "boolean",
|
|
15639
|
+
key: "perClassMinScoreEnabled",
|
|
15640
|
+
label: "Per-class spawn confidence",
|
|
15641
|
+
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.",
|
|
15642
|
+
default: TRACKING_DEFAULTS.perClassMinScoreEnabled
|
|
15643
|
+
},
|
|
15644
|
+
{
|
|
15645
|
+
type: "number",
|
|
15646
|
+
key: "minScorePerson",
|
|
15647
|
+
label: "Min spawn score — person",
|
|
15648
|
+
description: "Minimum score to spawn a person track. 0 keeps person fully sensitive.",
|
|
15649
|
+
min: 0,
|
|
15650
|
+
max: 1,
|
|
15651
|
+
step: .05,
|
|
15652
|
+
default: TRACKING_DEFAULTS.minScorePerson
|
|
15653
|
+
},
|
|
15654
|
+
{
|
|
15655
|
+
type: "number",
|
|
15656
|
+
key: "minScoreAnimal",
|
|
15657
|
+
label: "Min spawn score — animal",
|
|
15658
|
+
description: "Minimum score to spawn an animal track (FP-prone — a higher floor drops low-confidence static-object false animals).",
|
|
15659
|
+
min: 0,
|
|
15660
|
+
max: 1,
|
|
15661
|
+
step: .05,
|
|
15662
|
+
default: TRACKING_DEFAULTS.minScoreAnimal
|
|
15663
|
+
},
|
|
15664
|
+
{
|
|
15665
|
+
type: "number",
|
|
15666
|
+
key: "minScoreVehicle",
|
|
15667
|
+
label: "Min spawn score — vehicle",
|
|
15668
|
+
description: "Minimum score to spawn a vehicle track (FP-prone — a higher floor drops low-confidence false vehicles).",
|
|
15669
|
+
min: 0,
|
|
15670
|
+
max: 1,
|
|
15671
|
+
step: .05,
|
|
15672
|
+
default: TRACKING_DEFAULTS.minScoreVehicle
|
|
15673
|
+
},
|
|
15674
|
+
{
|
|
15675
|
+
type: "boolean",
|
|
15676
|
+
key: "confirmBypassEnabled",
|
|
15677
|
+
label: "Fast high-confidence confirm",
|
|
15678
|
+
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).",
|
|
15679
|
+
default: TRACKING_DEFAULTS.confirmBypassEnabled
|
|
15680
|
+
},
|
|
15681
|
+
{
|
|
15682
|
+
type: "number",
|
|
15683
|
+
key: "confirmBypassScore",
|
|
15684
|
+
label: "Fast-confirm score",
|
|
15685
|
+
description: "Score at/above which a detection confirms its track immediately, bypassing min-hits. Only used when fast high-confidence confirm is on.",
|
|
15686
|
+
min: 0,
|
|
15687
|
+
max: 1,
|
|
15688
|
+
step: .05,
|
|
15689
|
+
default: TRACKING_DEFAULTS.confirmBypassScore
|
|
15690
|
+
}
|
|
15691
|
+
]
|
|
15692
|
+
},
|
|
15693
|
+
{
|
|
15694
|
+
id: "stationary-objects",
|
|
15695
|
+
title: "Stationary objects",
|
|
15696
|
+
tab: "analytics",
|
|
15697
|
+
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.",
|
|
15698
|
+
columns: 2,
|
|
15699
|
+
fields: [
|
|
15700
|
+
{
|
|
15701
|
+
type: "boolean",
|
|
15702
|
+
key: "enabled",
|
|
15703
|
+
label: "Suppress parked objects",
|
|
15704
|
+
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.",
|
|
15705
|
+
default: STATIONARY_DEFAULTS.enabled
|
|
15706
|
+
},
|
|
15707
|
+
{
|
|
15708
|
+
type: "slider",
|
|
15709
|
+
key: "suppressIou",
|
|
15710
|
+
label: "Suppress IoU",
|
|
15711
|
+
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.",
|
|
15712
|
+
min: .3,
|
|
15713
|
+
max: .9,
|
|
15714
|
+
step: .05,
|
|
15715
|
+
default: STATIONARY_DEFAULTS.suppressIou,
|
|
15716
|
+
showValue: true
|
|
15717
|
+
},
|
|
15718
|
+
{
|
|
15719
|
+
type: "slider",
|
|
15720
|
+
key: "wakeAssocIou",
|
|
15721
|
+
label: "Wake / associate IoU",
|
|
15722
|
+
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.",
|
|
15723
|
+
min: .02,
|
|
15724
|
+
max: .5,
|
|
15725
|
+
step: .02,
|
|
15726
|
+
default: STATIONARY_DEFAULTS.wakeAssocIou,
|
|
15727
|
+
showValue: true
|
|
15728
|
+
},
|
|
15729
|
+
{
|
|
15730
|
+
type: "slider",
|
|
15731
|
+
key: "promotionWindowMs",
|
|
15732
|
+
label: "Promotion stillness window",
|
|
15733
|
+
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.",
|
|
15734
|
+
min: 5e3,
|
|
15735
|
+
max: 12e4,
|
|
15736
|
+
step: 1e3,
|
|
15737
|
+
default: STATIONARY_DEFAULTS.promotionWindowMs,
|
|
15738
|
+
showValue: true,
|
|
15739
|
+
unit: "s",
|
|
15740
|
+
displayScale: 1e3
|
|
15741
|
+
},
|
|
15742
|
+
{
|
|
15743
|
+
type: "slider",
|
|
15744
|
+
key: "entryTtlMs",
|
|
15745
|
+
label: "Observed-time TTL",
|
|
15746
|
+
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.",
|
|
15747
|
+
min: 6e4,
|
|
15748
|
+
max: 18e5,
|
|
15749
|
+
step: 3e4,
|
|
15750
|
+
default: STATIONARY_DEFAULTS.entryTtlMs,
|
|
15751
|
+
showValue: true,
|
|
15752
|
+
unit: "s",
|
|
15753
|
+
displayScale: 1e3
|
|
14946
15754
|
}
|
|
14947
15755
|
]
|
|
14948
15756
|
}
|
|
@@ -14954,7 +15762,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
14954
15762
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
14955
15763
|
const baseSections = schema ? hydrateSchema({
|
|
14956
15764
|
...schema,
|
|
14957
|
-
sections: stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))
|
|
15765
|
+
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
14958
15766
|
}, raw).sections : [];
|
|
14959
15767
|
const liveStatsSection = {
|
|
14960
15768
|
id: "live-stats",
|
|
@@ -15003,4 +15811,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
15003
15811
|
}
|
|
15004
15812
|
};
|
|
15005
15813
|
//#endregion
|
|
15006
|
-
export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
15814
|
+
export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|