@camstack/addon-post-analysis 1.1.23 → 1.1.25
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-DbgDsnRE.js → dist-CCC79h7t.js} +574 -251
- package/dist/{dist-DiIUcFdN.mjs → dist-Csk_yJr_.mjs} +574 -251
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/enrichment-engine/index.js +2 -2
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/{node-CdzrYoKs.js → node-CIEkEv1F.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BaXPYx2C.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cb6Z2H5K.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtOrjE2U.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-CZ8URdDr.mjs → hostInit-BrqCuPYQ.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +542 -113
- package/dist/pipeline-analytics/index.mjs +540 -111
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/{resolve-frame-nQMd6Z8B.js → resolve-frame-sKYbstL-.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-BEL-L4wR.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as object, S as number, c as pipelineAnalyticsCapability, d as zoneAnalyticsCapability, f as errMsg, h as EventCategory, i as cosineSimilarity, l as plateGalleryCapability, m as DeviceType, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as BaseAddon, r as audioMetricsCapability, t as EVENT_PAD_MS, u as videoclipsCapability, v as hydrateSchema, w as string, x as boolean } from "../dist-
|
|
1
|
+
import { C as object, S as number, c as pipelineAnalyticsCapability, d as zoneAnalyticsCapability, f as errMsg, h as EventCategory, i as cosineSimilarity, l as plateGalleryCapability, m as DeviceType, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as BaseAddon, r as audioMetricsCapability, t as EVENT_PAD_MS, u as videoclipsCapability, v as hydrateSchema, w as string, x as boolean } from "../dist-Csk_yJr_.mjs";
|
|
2
2
|
import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
|
|
3
3
|
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
4
4
|
import sharp from "sharp";
|
|
@@ -694,17 +694,24 @@ function normalizeToPixel(p, width, height) {
|
|
|
694
694
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
695
695
|
var DEFAULT_TRACKER_CONFIG = {
|
|
696
696
|
iouThreshold: .3,
|
|
697
|
-
|
|
697
|
+
maxMissedMs: 4e3,
|
|
698
698
|
minHits: 3,
|
|
699
699
|
predictiveCoasting: true,
|
|
700
700
|
classGating: true,
|
|
701
701
|
occlusionEnabled: true,
|
|
702
702
|
occlusionContainment: .6,
|
|
703
|
-
|
|
704
|
-
maxTrackLifetimeMs: 3e5
|
|
703
|
+
occlusionMaxMissedMs: 8e3,
|
|
704
|
+
maxTrackLifetimeMs: 3e5,
|
|
705
|
+
rescueIouThreshold: .1,
|
|
706
|
+
rescueCentroidFactor: .75,
|
|
707
|
+
resurrectionWindowMs: 8e3,
|
|
708
|
+
stationarySpeedPx: 2
|
|
705
709
|
};
|
|
706
710
|
var MAX_PATH_LENGTH = 300;
|
|
707
711
|
var nextTrackId = 1;
|
|
712
|
+
function clamp(value, min, max) {
|
|
713
|
+
return Math.max(min, Math.min(max, value));
|
|
714
|
+
}
|
|
708
715
|
function iou$1(a, b) {
|
|
709
716
|
const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
|
|
710
717
|
const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
|
|
@@ -734,26 +741,51 @@ var SortTracker = class {
|
|
|
734
741
|
};
|
|
735
742
|
}
|
|
736
743
|
/** Where a track is expected this frame — extrapolated by velocity while
|
|
737
|
-
* coasting (predictiveCoasting), else its last known bbox.
|
|
744
|
+
* coasting (predictiveCoasting), else its last known bbox. A stationary
|
|
745
|
+
* track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
|
|
746
|
+
* box off a sitting object; otherwise the drift is capped at one bbox size
|
|
747
|
+
* so a stale velocity can't fling the box across the frame. Public so the
|
|
748
|
+
* clamp is directly unit-testable. */
|
|
738
749
|
predicted(t) {
|
|
739
750
|
if (!this.config.predictiveCoasting) return t.bbox;
|
|
751
|
+
if (Math.hypot(t.velocity.dx, t.velocity.dy) < this.config.stationarySpeedPx) return t.bbox;
|
|
740
752
|
const f = t.age + 1;
|
|
753
|
+
const driftX = clamp(t.velocity.dx * f, -t.bbox.w, t.bbox.w);
|
|
754
|
+
const driftY = clamp(t.velocity.dy * f, -t.bbox.h, t.bbox.h);
|
|
741
755
|
return {
|
|
742
|
-
x: t.bbox.x +
|
|
743
|
-
y: t.bbox.y +
|
|
756
|
+
x: t.bbox.x + driftX,
|
|
757
|
+
y: t.bbox.y + driftY,
|
|
744
758
|
w: t.bbox.w,
|
|
745
759
|
h: t.bbox.h
|
|
746
760
|
};
|
|
747
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* Loose same-class gate used by the rescue and resurrection passes: accept
|
|
764
|
+
* when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
|
|
765
|
+
* its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
|
|
766
|
+
* gated (a rescue must never cross classes), independent of `classGating`.
|
|
767
|
+
*/
|
|
768
|
+
looseMatch(track, det) {
|
|
769
|
+
if (track.class !== det.class) return false;
|
|
770
|
+
if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
|
|
771
|
+
const tc = bboxCentroid(track.bbox);
|
|
772
|
+
const dc = bboxCentroid(det.bbox);
|
|
773
|
+
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
774
|
+
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
775
|
+
return dist <= this.config.rescueCentroidFactor * diag;
|
|
776
|
+
}
|
|
748
777
|
update(detections, timestamp) {
|
|
749
778
|
if (this.config.maxTrackLifetimeMs > 0) {
|
|
750
779
|
const alive = [];
|
|
751
780
|
for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
|
|
752
781
|
track.lost = true;
|
|
782
|
+
track.lostAt = timestamp;
|
|
783
|
+
track.resurrectable = false;
|
|
753
784
|
this.lostTracks.push(track);
|
|
754
785
|
} else alive.push(track);
|
|
755
786
|
this.tracks = alive;
|
|
756
787
|
}
|
|
788
|
+
this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
|
|
757
789
|
const used = /* @__PURE__ */ new Set();
|
|
758
790
|
const matchedTracks = /* @__PURE__ */ new Set();
|
|
759
791
|
const matched = /* @__PURE__ */ new Map();
|
|
@@ -778,6 +810,27 @@ var SortTracker = class {
|
|
|
778
810
|
matchedTracks.add(pair.track);
|
|
779
811
|
used.add(pair.detIdx);
|
|
780
812
|
}
|
|
813
|
+
const rescuePairs = [];
|
|
814
|
+
for (const track of this.tracks) {
|
|
815
|
+
if (matchedTracks.has(track)) continue;
|
|
816
|
+
for (let di = 0; di < detections.length; di++) {
|
|
817
|
+
if (used.has(di)) continue;
|
|
818
|
+
const det = detections[di];
|
|
819
|
+
if (!this.looseMatch(track, det)) continue;
|
|
820
|
+
rescuePairs.push({
|
|
821
|
+
track,
|
|
822
|
+
detIdx: di,
|
|
823
|
+
score: iou$1(track.bbox, det.bbox)
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
rescuePairs.sort((a, b) => b.score - a.score);
|
|
828
|
+
for (const pair of rescuePairs) {
|
|
829
|
+
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
830
|
+
matched.set(pair.track, detections[pair.detIdx]);
|
|
831
|
+
matchedTracks.add(pair.track);
|
|
832
|
+
used.add(pair.detIdx);
|
|
833
|
+
}
|
|
781
834
|
for (const [track, det] of matched) {
|
|
782
835
|
const prevCenter = bboxCentroid({
|
|
783
836
|
x: track.bbox.x,
|
|
@@ -816,12 +869,47 @@ var SortTracker = class {
|
|
|
816
869
|
}
|
|
817
870
|
const occluded = this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(this.predicted(track), ob) >= this.config.occlusionContainment);
|
|
818
871
|
track.age++;
|
|
819
|
-
|
|
820
|
-
if (track.age > limit) {
|
|
872
|
+
if (timestamp - track.lastSeen > (occluded ? this.config.occlusionMaxMissedMs : this.config.maxMissedMs)) {
|
|
821
873
|
track.lost = true;
|
|
874
|
+
track.lostAt = timestamp;
|
|
875
|
+
track.resurrectable = true;
|
|
822
876
|
this.lostTracks.push(track);
|
|
823
877
|
} else surviving.push(track);
|
|
824
878
|
}
|
|
879
|
+
for (let di = 0; di < detections.length; di++) {
|
|
880
|
+
if (used.has(di)) continue;
|
|
881
|
+
const det = detections[di];
|
|
882
|
+
let best;
|
|
883
|
+
let bestScore = -1;
|
|
884
|
+
for (const lost of this.lostTracks) {
|
|
885
|
+
if (!lost.resurrectable) continue;
|
|
886
|
+
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
887
|
+
if (!this.looseMatch(lost, det)) continue;
|
|
888
|
+
const score = iou$1(lost.bbox, det.bbox);
|
|
889
|
+
if (score > bestScore) {
|
|
890
|
+
best = lost;
|
|
891
|
+
bestScore = score;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (!best) continue;
|
|
895
|
+
this.lostTracks = this.lostTracks.filter((t) => t !== best);
|
|
896
|
+
best.bbox = det.bbox;
|
|
897
|
+
best.class = det.class;
|
|
898
|
+
best.originalClass = det.originalClass;
|
|
899
|
+
best.score = det.score;
|
|
900
|
+
best.age = 0;
|
|
901
|
+
best.hits++;
|
|
902
|
+
best.lost = false;
|
|
903
|
+
best.lastSeen = timestamp;
|
|
904
|
+
best.velocity = {
|
|
905
|
+
dx: 0,
|
|
906
|
+
dy: 0
|
|
907
|
+
};
|
|
908
|
+
best.path.push(det.bbox);
|
|
909
|
+
if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
|
|
910
|
+
surviving.push(best);
|
|
911
|
+
used.add(di);
|
|
912
|
+
}
|
|
825
913
|
for (let di = 0; di < detections.length; di++) {
|
|
826
914
|
if (used.has(di)) continue;
|
|
827
915
|
const det = detections[di];
|
|
@@ -841,7 +929,9 @@ var SortTracker = class {
|
|
|
841
929
|
dx: 0,
|
|
842
930
|
dy: 0
|
|
843
931
|
},
|
|
844
|
-
lost: false
|
|
932
|
+
lost: false,
|
|
933
|
+
lostAt: 0,
|
|
934
|
+
resurrectable: true
|
|
845
935
|
});
|
|
846
936
|
}
|
|
847
937
|
this.tracks = surviving;
|
|
@@ -956,6 +1046,7 @@ var StateAnalyzer = class {
|
|
|
956
1046
|
//#region src/pipeline-analytics/pipeline/events/event-filter.ts
|
|
957
1047
|
var DEFAULT_EVENT_EMITTER_CONFIG = {
|
|
958
1048
|
minTrackAge: 3,
|
|
1049
|
+
minTrackAgeMs: 0,
|
|
959
1050
|
cooldownSec: 5,
|
|
960
1051
|
enabledTypes: [
|
|
961
1052
|
"object.entering",
|
|
@@ -986,6 +1077,13 @@ var DetectionEventEmitter = class {
|
|
|
986
1077
|
config;
|
|
987
1078
|
previousStates = /* @__PURE__ */ new Map();
|
|
988
1079
|
lastEmitted = /* @__PURE__ */ new Map();
|
|
1080
|
+
/**
|
|
1081
|
+
* Last-known track per id. A `leaving` state fires for a track that is ABSENT
|
|
1082
|
+
* from the current frame, so it can't be resolved from `trackMap` — it is
|
|
1083
|
+
* resolved from this snapshot instead (without it, leaving events never
|
|
1084
|
+
* fired: the latent "0 `left` events in 24h" bug).
|
|
1085
|
+
*/
|
|
1086
|
+
lastKnownTracks = /* @__PURE__ */ new Map();
|
|
989
1087
|
constructor(config = {}) {
|
|
990
1088
|
this.config = {
|
|
991
1089
|
...DEFAULT_EVENT_EMITTER_CONFIG,
|
|
@@ -997,10 +1095,12 @@ var DetectionEventEmitter = class {
|
|
|
997
1095
|
const now = Date.now();
|
|
998
1096
|
const stateMap = new Map(states.map((s) => [s.trackId, s]));
|
|
999
1097
|
const trackMap = new Map(tracks.map((t) => [t.trackId, t]));
|
|
1098
|
+
for (const t of tracks) this.lastKnownTracks.set(t.trackId, t);
|
|
1000
1099
|
for (const state of states) {
|
|
1001
|
-
const track = trackMap.get(state.trackId);
|
|
1100
|
+
const track = trackMap.get(state.trackId) ?? this.lastKnownTracks.get(state.trackId);
|
|
1002
1101
|
if (!track) continue;
|
|
1003
1102
|
if (track.trackAge < this.config.minTrackAge) continue;
|
|
1103
|
+
if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
|
|
1004
1104
|
const eventType = STATE_TO_EVENT[state.state];
|
|
1005
1105
|
if (!eventType) continue;
|
|
1006
1106
|
if (!this.config.enabledTypes.includes(eventType)) continue;
|
|
@@ -1046,12 +1146,16 @@ var DetectionEventEmitter = class {
|
|
|
1046
1146
|
trackPath: [...track.path]
|
|
1047
1147
|
});
|
|
1048
1148
|
}
|
|
1049
|
-
for (const state of states) if (state.state === "leaving")
|
|
1149
|
+
for (const state of states) if (state.state === "leaving") {
|
|
1150
|
+
this.previousStates.delete(state.trackId);
|
|
1151
|
+
this.lastKnownTracks.delete(state.trackId);
|
|
1152
|
+
}
|
|
1050
1153
|
return events;
|
|
1051
1154
|
}
|
|
1052
1155
|
reset() {
|
|
1053
1156
|
this.previousStates.clear();
|
|
1054
1157
|
this.lastEmitted.clear();
|
|
1158
|
+
this.lastKnownTracks.clear();
|
|
1055
1159
|
}
|
|
1056
1160
|
};
|
|
1057
1161
|
//#endregion
|
|
@@ -1423,6 +1527,43 @@ var FrameProcessor = class {
|
|
|
1423
1527
|
}
|
|
1424
1528
|
};
|
|
1425
1529
|
//#endregion
|
|
1530
|
+
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1531
|
+
var BestDetectionTracker = class {
|
|
1532
|
+
hysteresis;
|
|
1533
|
+
minGapMs;
|
|
1534
|
+
best = /* @__PURE__ */ new Map();
|
|
1535
|
+
constructor(options = {}) {
|
|
1536
|
+
this.hysteresis = options.hysteresis ?? 0;
|
|
1537
|
+
this.minGapMs = options.minGapMs ?? 0;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
|
|
1541
|
+
* Returns true when it becomes the track's new best — the first sighting, or a
|
|
1542
|
+
* confidence that beats the held peak by more than `hysteresis` AND respects
|
|
1543
|
+
* `minGapMs`. On acceptance the held peak is advanced to this observation.
|
|
1544
|
+
*/
|
|
1545
|
+
observe(trackId, confidence, timestamp) {
|
|
1546
|
+
const cur = this.best.get(trackId);
|
|
1547
|
+
const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
|
|
1548
|
+
if (isNewBest) this.best.set(trackId, {
|
|
1549
|
+
confidence,
|
|
1550
|
+
atMs: timestamp
|
|
1551
|
+
});
|
|
1552
|
+
return isNewBest;
|
|
1553
|
+
}
|
|
1554
|
+
/** The held peak for a track (undefined if never observed). */
|
|
1555
|
+
peak(trackId) {
|
|
1556
|
+
return this.best.get(trackId);
|
|
1557
|
+
}
|
|
1558
|
+
/** Drop a track's peak (call at track end). */
|
|
1559
|
+
delete(trackId) {
|
|
1560
|
+
this.best.delete(trackId);
|
|
1561
|
+
}
|
|
1562
|
+
clear() {
|
|
1563
|
+
this.best.clear();
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
//#endregion
|
|
1426
1567
|
//#region src/pipeline-analytics/pipeline/native-detection.ts
|
|
1427
1568
|
/**
|
|
1428
1569
|
* Nominal frame size used to denormalize native `[0,1]` boxes when a
|
|
@@ -1692,6 +1833,16 @@ var TrackStore = class {
|
|
|
1692
1833
|
lastSnapshotAt(trackId) {
|
|
1693
1834
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
1694
1835
|
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
1838
|
+
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
1839
|
+
* track begins rather than immediately — the `firstFrame` already covers the
|
|
1840
|
+
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
1841
|
+
*/
|
|
1842
|
+
seedSnapshotClock(trackId, timestamp) {
|
|
1843
|
+
const t = this.active.get(trackId);
|
|
1844
|
+
if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
|
|
1845
|
+
}
|
|
1695
1846
|
getActive(deviceId) {
|
|
1696
1847
|
const out = [];
|
|
1697
1848
|
for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
|
|
@@ -1956,6 +2107,46 @@ var MediaStore = class {
|
|
|
1956
2107
|
}
|
|
1957
2108
|
}
|
|
1958
2109
|
/**
|
|
2110
|
+
* Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
|
|
2111
|
+
* kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
|
|
2112
|
+
* each new capture replaces the previous one (blob + index row) rather than
|
|
2113
|
+
* accumulating a filmstrip the way `put` does. Deletes any existing rows of
|
|
2114
|
+
* that (owner, kind) first, then writes the fresh one. Returns the new key.
|
|
2115
|
+
*/
|
|
2116
|
+
async putReplacing(params) {
|
|
2117
|
+
const existing = await this.store.query.query({
|
|
2118
|
+
collection: MEDIA_COLLECTION,
|
|
2119
|
+
filter: { where: {
|
|
2120
|
+
ownerKind: params.ownerKind,
|
|
2121
|
+
ownerId: params.ownerId,
|
|
2122
|
+
kind: params.kind
|
|
2123
|
+
} }
|
|
2124
|
+
});
|
|
2125
|
+
const newKey = await this.put(params);
|
|
2126
|
+
for (const row of existing) {
|
|
2127
|
+
if (row.id === newKey) continue;
|
|
2128
|
+
const path = String(row.data["path"] ?? "");
|
|
2129
|
+
if (path) try {
|
|
2130
|
+
await this.storage.delete({
|
|
2131
|
+
location: "eventMedia",
|
|
2132
|
+
relativePath: path
|
|
2133
|
+
});
|
|
2134
|
+
} catch {}
|
|
2135
|
+
try {
|
|
2136
|
+
await this.store.delete.mutate({
|
|
2137
|
+
collection: MEDIA_COLLECTION,
|
|
2138
|
+
key: row.id
|
|
2139
|
+
});
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
this.logger.debug("media putReplacing: stale row delete failed", { meta: {
|
|
2142
|
+
key: row.id,
|
|
2143
|
+
error: String(err)
|
|
2144
|
+
} });
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
return newKey;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
1959
2150
|
* Fetch one media entry by its key (id). Returns null if the key is not
|
|
1960
2151
|
* found in the index or if the blob is missing from storage.
|
|
1961
2152
|
*/
|
|
@@ -2868,7 +3059,9 @@ var EventMediaDispatcher = class {
|
|
|
2868
3059
|
}
|
|
2869
3060
|
async captureForFrame(input) {
|
|
2870
3061
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
2871
|
-
|
|
3062
|
+
const snapshots = input.snapshots ?? [];
|
|
3063
|
+
const empty = { storedSnapshots: [] };
|
|
3064
|
+
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
2872
3065
|
let decoded;
|
|
2873
3066
|
try {
|
|
2874
3067
|
decoded = await resolveFrame(frameHandle, {
|
|
@@ -2885,7 +3078,7 @@ var EventMediaDispatcher = class {
|
|
|
2885
3078
|
error: String(err)
|
|
2886
3079
|
}
|
|
2887
3080
|
});
|
|
2888
|
-
return;
|
|
3081
|
+
return empty;
|
|
2889
3082
|
}
|
|
2890
3083
|
if (!decoded) {
|
|
2891
3084
|
this.deps.logger.debug("event media: frame recycled before resolve", {
|
|
@@ -2895,7 +3088,7 @@ var EventMediaDispatcher = class {
|
|
|
2895
3088
|
shmId: frameHandle.shmId
|
|
2896
3089
|
}
|
|
2897
3090
|
});
|
|
2898
|
-
return;
|
|
3091
|
+
return empty;
|
|
2899
3092
|
}
|
|
2900
3093
|
if (decoded.format !== "rgb") {
|
|
2901
3094
|
this.deps.logger.debug("event media: resolved frame is not RGB", {
|
|
@@ -2905,13 +3098,87 @@ var EventMediaDispatcher = class {
|
|
|
2905
3098
|
format: decoded.format
|
|
2906
3099
|
}
|
|
2907
3100
|
});
|
|
2908
|
-
return;
|
|
3101
|
+
return empty;
|
|
2909
3102
|
}
|
|
2910
3103
|
const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
|
|
2911
3104
|
const fw = decoded.width;
|
|
2912
3105
|
const fh = decoded.height;
|
|
2913
3106
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
2914
3107
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3108
|
+
const storedSnapshots = [];
|
|
3109
|
+
for (const sn of snapshots) {
|
|
3110
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
3111
|
+
if (stored) storedSnapshots.push(stored);
|
|
3112
|
+
}
|
|
3113
|
+
return { storedSnapshots };
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
|
|
3117
|
+
* to whichever of the three destinations is requested: an appended `snapshot`
|
|
3118
|
+
* (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
|
|
3119
|
+
* `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
|
|
3120
|
+
* (null when `appendSnapshot` is false or the encode failed).
|
|
3121
|
+
*/
|
|
3122
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
3123
|
+
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
3124
|
+
let boxed;
|
|
3125
|
+
try {
|
|
3126
|
+
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
3127
|
+
...sn.bbox,
|
|
3128
|
+
...sn.label ? { label: sn.label } : {}
|
|
3129
|
+
}], { quality: MEDIA_QUALITY });
|
|
3130
|
+
} catch (err) {
|
|
3131
|
+
this.deps.logger.warn("event media: track snapshot encode failed", {
|
|
3132
|
+
tags: { deviceId },
|
|
3133
|
+
meta: {
|
|
3134
|
+
deviceId,
|
|
3135
|
+
trackId: sn.trackId,
|
|
3136
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
return null;
|
|
3140
|
+
}
|
|
3141
|
+
let stored = null;
|
|
3142
|
+
if (sn.appendSnapshot) try {
|
|
3143
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
3144
|
+
deviceId,
|
|
3145
|
+
ownerKind: "track",
|
|
3146
|
+
ownerId: sn.trackId,
|
|
3147
|
+
kind: "snapshot",
|
|
3148
|
+
timestamp: sn.timestamp,
|
|
3149
|
+
data: boxed
|
|
3150
|
+
});
|
|
3151
|
+
stored = {
|
|
3152
|
+
trackId: sn.trackId,
|
|
3153
|
+
mediaKey,
|
|
3154
|
+
timestamp: sn.timestamp,
|
|
3155
|
+
bbox: sn.bbox
|
|
3156
|
+
};
|
|
3157
|
+
} catch {}
|
|
3158
|
+
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
3159
|
+
if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
3160
|
+
return stored;
|
|
3161
|
+
}
|
|
3162
|
+
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
3163
|
+
try {
|
|
3164
|
+
await this.deps.mediaStore.putReplacing({
|
|
3165
|
+
deviceId,
|
|
3166
|
+
ownerKind: "track",
|
|
3167
|
+
ownerId: trackId,
|
|
3168
|
+
kind,
|
|
3169
|
+
timestamp,
|
|
3170
|
+
data
|
|
3171
|
+
});
|
|
3172
|
+
} catch (err) {
|
|
3173
|
+
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
3174
|
+
tags: { deviceId },
|
|
3175
|
+
meta: {
|
|
3176
|
+
deviceId,
|
|
3177
|
+
trackId,
|
|
3178
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3179
|
+
}
|
|
3180
|
+
});
|
|
3181
|
+
}
|
|
2915
3182
|
}
|
|
2916
3183
|
async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
|
|
2917
3184
|
const box = {
|
|
@@ -3772,22 +4039,22 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
3772
4039
|
stationaryThresholdSec: pick("stationaryThresholdSec")
|
|
3773
4040
|
};
|
|
3774
4041
|
}
|
|
3775
|
-
//#endregion
|
|
3776
|
-
//#region src/pipeline-analytics/tracking-settings.ts
|
|
3777
|
-
/**
|
|
3778
|
-
* Per-device tracker tuning. These drive the SORT tracker (association,
|
|
3779
|
-
* coasting, occlusion, lifetime) and the detector-dropout frame skip. Every
|
|
3780
|
-
* field is independently overridable per camera from the admin UI; unknown or
|
|
3781
|
-
* invalid values fall back to the field default (never throws on a bad blob).
|
|
3782
|
-
*
|
|
3783
|
-
* Note: `minHits` lives in detection-sensitivity-settings (it predates this
|
|
3784
|
-
* file and already flows to the tracker) — it is NOT duplicated here.
|
|
3785
|
-
*/
|
|
3786
4042
|
var TrackingSettingsSchema = object({
|
|
3787
4043
|
/** IoU required to match a (predicted) track to a detection. */
|
|
3788
4044
|
iouThreshold: number().min(0).max(1).default(.3),
|
|
3789
|
-
/**
|
|
4045
|
+
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
4046
|
+
* based so behaviour is identical across camera frame rates. */
|
|
4047
|
+
maxMissedMs: number().min(0).default(4e3),
|
|
4048
|
+
/** Minimum wall-clock track age (ms) before it may produce events. */
|
|
4049
|
+
minTrackAgeMs: number().min(0).default(1e3),
|
|
4050
|
+
/** Extended coasting budget (ms) while a track is occluded. */
|
|
4051
|
+
occlusionMaxMissedMs: number().min(0).default(8e3),
|
|
4052
|
+
/** @deprecated frame-based alias for {@link maxMissedMs}. Retained so
|
|
4053
|
+
* existing per-device blobs keep working — resolved into `maxMissedMs`
|
|
4054
|
+
* (× {@link NOMINAL_FRAME_MS}) only when `maxMissedMs` is unset. */
|
|
3790
4055
|
maxMissedFrames: number().int().min(0).default(30),
|
|
4056
|
+
/** @deprecated frame-based alias for {@link occlusionMaxMissedMs}. */
|
|
4057
|
+
occlusionMaxMissedFrames: number().int().min(0).default(60),
|
|
3791
4058
|
/** Hard cap on total track lifetime — retire+restart beyond this to bound
|
|
3792
4059
|
* drift / long-lived false positives. 0 = unlimited. */
|
|
3793
4060
|
maxTrackLifetimeSec: number().min(0).default(300),
|
|
@@ -3801,8 +4068,17 @@ var TrackingSettingsSchema = object({
|
|
|
3801
4068
|
/** Containment ratio (intersection / occluded-area) above which a missed
|
|
3802
4069
|
* track is considered hidden behind another track. */
|
|
3803
4070
|
occlusionContainment: number().min(0).max(1).default(.6),
|
|
3804
|
-
/**
|
|
3805
|
-
|
|
4071
|
+
/** Loose IoU gate (vs. last-known bbox) for the second (rescue) association
|
|
4072
|
+
* pass + graveyard resurrection. */
|
|
4073
|
+
rescueIouThreshold: number().min(0).max(1).default(.1),
|
|
4074
|
+
/** Centroid gate for rescue/resurrection: fraction of the bbox diagonal. */
|
|
4075
|
+
rescueCentroidFactor: number().min(0).default(.75),
|
|
4076
|
+
/** Wall-clock graveyard retention (ms): a dropped track re-attaches to a new
|
|
4077
|
+
* same-class detection (keeping its id) for this long. */
|
|
4078
|
+
resurrectionWindowMs: number().min(0).default(8e3),
|
|
4079
|
+
/** Speed (px/frame) below which a track's prediction is frozen (stationary
|
|
4080
|
+
* jitter can't drift the box off a sitting object). */
|
|
4081
|
+
stationarySpeedPx: number().min(0).default(2),
|
|
3806
4082
|
/** Skip frames where detections suddenly drop to zero (detector glitch). */
|
|
3807
4083
|
dropoutSkipEnabled: boolean().default(true),
|
|
3808
4084
|
/** Max consecutive all-zero frames absorbed as a glitch before the scene is
|
|
@@ -3817,15 +4093,26 @@ var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
|
|
|
3817
4093
|
*/
|
|
3818
4094
|
function resolveTrackingSettings(raw) {
|
|
3819
4095
|
const s = TrackingSettingsSchema.shape;
|
|
4096
|
+
const maxMissedFrames = s.maxMissedFrames.catch(TRACKING_DEFAULTS.maxMissedFrames).parse(raw.maxMissedFrames);
|
|
4097
|
+
const occlusionMaxMissedFrames = s.occlusionMaxMissedFrames.catch(TRACKING_DEFAULTS.occlusionMaxMissedFrames).parse(raw.occlusionMaxMissedFrames);
|
|
4098
|
+
const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
|
|
4099
|
+
const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
|
|
3820
4100
|
return {
|
|
3821
4101
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
3822
|
-
|
|
4102
|
+
maxMissedMs,
|
|
4103
|
+
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
4104
|
+
occlusionMaxMissedMs,
|
|
4105
|
+
maxMissedFrames,
|
|
4106
|
+
occlusionMaxMissedFrames,
|
|
3823
4107
|
maxTrackLifetimeSec: s.maxTrackLifetimeSec.catch(TRACKING_DEFAULTS.maxTrackLifetimeSec).parse(raw.maxTrackLifetimeSec),
|
|
3824
4108
|
predictiveCoasting: s.predictiveCoasting.catch(TRACKING_DEFAULTS.predictiveCoasting).parse(raw.predictiveCoasting),
|
|
3825
4109
|
classGating: s.classGating.catch(TRACKING_DEFAULTS.classGating).parse(raw.classGating),
|
|
3826
4110
|
occlusionEnabled: s.occlusionEnabled.catch(TRACKING_DEFAULTS.occlusionEnabled).parse(raw.occlusionEnabled),
|
|
3827
4111
|
occlusionContainment: s.occlusionContainment.catch(TRACKING_DEFAULTS.occlusionContainment).parse(raw.occlusionContainment),
|
|
3828
|
-
|
|
4112
|
+
rescueIouThreshold: s.rescueIouThreshold.catch(TRACKING_DEFAULTS.rescueIouThreshold).parse(raw.rescueIouThreshold),
|
|
4113
|
+
rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
|
|
4114
|
+
resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
|
|
4115
|
+
stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
|
|
3829
4116
|
dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
|
|
3830
4117
|
dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
|
|
3831
4118
|
};
|
|
@@ -3886,11 +4173,20 @@ function resolveFaceSettings(raw) {
|
|
|
3886
4173
|
* its default — parse never throws). Mirrors `face-settings` /
|
|
3887
4174
|
* `audio-detection-settings`.
|
|
3888
4175
|
*/
|
|
3889
|
-
var MediaSettingsSchema = object({
|
|
3890
|
-
/** Fractional padding added around a detection bbox before cropping.
|
|
3891
|
-
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
3892
|
-
* max 2 (200%). */
|
|
3893
|
-
cropPadding: number().min(0).max(2).default(.15)
|
|
4176
|
+
var MediaSettingsSchema = object({
|
|
4177
|
+
/** Fractional padding added around a detection bbox before cropping.
|
|
4178
|
+
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
4179
|
+
* max 2 (200%). */
|
|
4180
|
+
cropPadding: number().min(0).max(2).default(.15),
|
|
4181
|
+
/** Master switch for periodic per-track snapshots (the timeline filmstrip +
|
|
4182
|
+
* the rolling `lastFrame` + the best `thumbnail`). When false, only the
|
|
4183
|
+
* per-track `firstFrame` and per-event media are produced. */
|
|
4184
|
+
saveThumbnails: boolean().default(true),
|
|
4185
|
+
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
4186
|
+
* A snapshot is captured for an active track only after this much wall-clock
|
|
4187
|
+
* has elapsed since its previous one. */
|
|
4188
|
+
snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
|
|
4189
|
+
});
|
|
3894
4190
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
3895
4191
|
/**
|
|
3896
4192
|
* Resolve a per-device store blob into typed media settings. Unknown/invalid
|
|
@@ -3901,7 +4197,11 @@ function resolveMediaSettings(raw) {
|
|
|
3901
4197
|
const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
|
|
3902
4198
|
return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
|
|
3903
4199
|
};
|
|
3904
|
-
return {
|
|
4200
|
+
return {
|
|
4201
|
+
cropPadding: pick("cropPadding"),
|
|
4202
|
+
saveThumbnails: pick("saveThumbnails"),
|
|
4203
|
+
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
4204
|
+
};
|
|
3905
4205
|
}
|
|
3906
4206
|
//#endregion
|
|
3907
4207
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
@@ -4797,6 +5097,11 @@ var FaceRecognizer = class {
|
|
|
4797
5097
|
names = /* @__PURE__ */ new Map();
|
|
4798
5098
|
aggregates = /* @__PURE__ */ new Map();
|
|
4799
5099
|
bestFace = /* @__PURE__ */ new Map();
|
|
5100
|
+
/** The ONE best-detection-per-track policy, shared with the best-frame path
|
|
5101
|
+
* (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
|
|
5102
|
+
* true highest-confidence face (holding a buffer in memory is cheap, and a
|
|
5103
|
+
* track may last well under the best-frame rate-limit window). */
|
|
5104
|
+
bestTracker = new BestDetectionTracker();
|
|
4800
5105
|
constructor(deps) {
|
|
4801
5106
|
this.deps = deps;
|
|
4802
5107
|
}
|
|
@@ -4830,55 +5135,25 @@ var FaceRecognizer = class {
|
|
|
4830
5135
|
threshold: settings.similarityThreshold,
|
|
4831
5136
|
margin: settings.margin
|
|
4832
5137
|
}) : /* @__PURE__ */ new Map();
|
|
5138
|
+
const labelWork = [];
|
|
4833
5139
|
for (const c of candidates) {
|
|
4834
5140
|
const match = matches.get(c.trackId) ?? null;
|
|
4835
5141
|
const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
|
|
4836
5142
|
this.aggregates.set(c.trackId, state);
|
|
4837
|
-
if (changed && state.assignedIdentityId !== null) {
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
trackId: c.trackId
|
|
4843
|
-
},
|
|
4844
|
-
meta: {
|
|
4845
|
-
identityId: state.assignedIdentityId,
|
|
4846
|
-
name: name ?? null,
|
|
4847
|
-
score: match?.score ?? null
|
|
4848
|
-
}
|
|
4849
|
-
});
|
|
4850
|
-
if (name !== void 0) {
|
|
4851
|
-
try {
|
|
4852
|
-
await this.deps.trackStore.setLabel(c.trackId, name);
|
|
4853
|
-
} catch (err) {
|
|
4854
|
-
this.deps.logger.warn("setLabel failed", {
|
|
4855
|
-
tags: { deviceId: input.deviceId },
|
|
4856
|
-
meta: {
|
|
4857
|
-
trackId: c.trackId,
|
|
4858
|
-
error: String(err)
|
|
4859
|
-
}
|
|
4860
|
-
});
|
|
4861
|
-
}
|
|
4862
|
-
try {
|
|
4863
|
-
await this.deps.eventStore.setLabelForTrack(c.trackId, name);
|
|
4864
|
-
} catch (err) {
|
|
4865
|
-
this.deps.logger.warn("setLabelForTrack failed", {
|
|
4866
|
-
tags: { deviceId: input.deviceId },
|
|
4867
|
-
meta: {
|
|
4868
|
-
trackId: c.trackId,
|
|
4869
|
-
error: String(err)
|
|
4870
|
-
}
|
|
4871
|
-
});
|
|
4872
|
-
}
|
|
4873
|
-
}
|
|
4874
|
-
}
|
|
5143
|
+
if (changed && state.assignedIdentityId !== null) labelWork.push({
|
|
5144
|
+
trackId: c.trackId,
|
|
5145
|
+
assignedIdentityId: state.assignedIdentityId,
|
|
5146
|
+
matchScore: match?.score ?? null
|
|
5147
|
+
});
|
|
4875
5148
|
const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
|
|
4876
5149
|
const held = this.bestFace.get(c.trackId);
|
|
4877
|
-
|
|
5150
|
+
const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
|
|
5151
|
+
const needsCrop = held !== void 0 && held.crop === void 0;
|
|
5152
|
+
if (isNewBest || needsCrop) {
|
|
4878
5153
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
4879
5154
|
let crop;
|
|
4880
|
-
if (input.frameHandle !== void 0) try {
|
|
4881
|
-
crop = await this.deps.captureCrop(input.frameHandle,
|
|
5155
|
+
if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
|
|
5156
|
+
crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
4882
5157
|
} catch (err) {
|
|
4883
5158
|
this.deps.logger.debug("FaceRecognizer crop capture failed", {
|
|
4884
5159
|
tags: { deviceId: input.deviceId },
|
|
@@ -4888,14 +5163,20 @@ var FaceRecognizer = class {
|
|
|
4888
5163
|
}
|
|
4889
5164
|
});
|
|
4890
5165
|
}
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
5166
|
+
if (isNewBest) {
|
|
5167
|
+
const bestCrop = crop ?? held?.crop;
|
|
5168
|
+
this.bestFace.set(c.trackId, {
|
|
5169
|
+
score: c.confidence,
|
|
5170
|
+
embedding: c.embedding,
|
|
5171
|
+
embeddingModelId: c.embeddingModelId,
|
|
5172
|
+
bbox: cropBbox,
|
|
5173
|
+
timestamp: input.timestamp,
|
|
5174
|
+
...bestCrop !== void 0 ? { crop: bestCrop } : {},
|
|
5175
|
+
...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
|
|
5176
|
+
});
|
|
5177
|
+
} else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
|
|
5178
|
+
...held,
|
|
5179
|
+
crop
|
|
4899
5180
|
});
|
|
4900
5181
|
this.deps.logger.debug("face: best-face held", {
|
|
4901
5182
|
tags: {
|
|
@@ -4904,15 +5185,53 @@ var FaceRecognizer = class {
|
|
|
4904
5185
|
},
|
|
4905
5186
|
meta: {
|
|
4906
5187
|
score: c.confidence,
|
|
4907
|
-
|
|
5188
|
+
isNewBest,
|
|
5189
|
+
hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
|
|
4908
5190
|
recognizedIdentityId: recognizedIdentityId ?? null
|
|
4909
5191
|
}
|
|
4910
5192
|
});
|
|
4911
|
-
} else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
|
|
5193
|
+
} else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
|
|
4912
5194
|
...held,
|
|
4913
5195
|
recognizedIdentityId
|
|
4914
5196
|
});
|
|
4915
5197
|
}
|
|
5198
|
+
for (const work of labelWork) {
|
|
5199
|
+
const name = this.names.get(work.assignedIdentityId);
|
|
5200
|
+
this.deps.logger.info("face: identity assigned", {
|
|
5201
|
+
tags: {
|
|
5202
|
+
deviceId: input.deviceId,
|
|
5203
|
+
trackId: work.trackId
|
|
5204
|
+
},
|
|
5205
|
+
meta: {
|
|
5206
|
+
identityId: work.assignedIdentityId,
|
|
5207
|
+
name: name ?? null,
|
|
5208
|
+
score: work.matchScore
|
|
5209
|
+
}
|
|
5210
|
+
});
|
|
5211
|
+
if (name === void 0) continue;
|
|
5212
|
+
try {
|
|
5213
|
+
await this.deps.trackStore.setLabel(work.trackId, name);
|
|
5214
|
+
} catch (err) {
|
|
5215
|
+
this.deps.logger.warn("setLabel failed", {
|
|
5216
|
+
tags: { deviceId: input.deviceId },
|
|
5217
|
+
meta: {
|
|
5218
|
+
trackId: work.trackId,
|
|
5219
|
+
error: String(err)
|
|
5220
|
+
}
|
|
5221
|
+
});
|
|
5222
|
+
}
|
|
5223
|
+
try {
|
|
5224
|
+
await this.deps.eventStore.setLabelForTrack(work.trackId, name);
|
|
5225
|
+
} catch (err) {
|
|
5226
|
+
this.deps.logger.warn("setLabelForTrack failed", {
|
|
5227
|
+
tags: { deviceId: input.deviceId },
|
|
5228
|
+
meta: {
|
|
5229
|
+
trackId: work.trackId,
|
|
5230
|
+
error: String(err)
|
|
5231
|
+
}
|
|
5232
|
+
});
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
4916
5235
|
}
|
|
4917
5236
|
/**
|
|
4918
5237
|
* Persist the held best face for a finished track as ONE FaceStore buffer
|
|
@@ -4923,6 +5242,7 @@ var FaceRecognizer = class {
|
|
|
4923
5242
|
const held = this.bestFace.get(trackId);
|
|
4924
5243
|
this.aggregates.delete(trackId);
|
|
4925
5244
|
this.bestFace.delete(trackId);
|
|
5245
|
+
this.bestTracker.delete(trackId);
|
|
4926
5246
|
if (held === void 0) {
|
|
4927
5247
|
this.deps.logger.debug("face: track ended without a held face", { tags: {
|
|
4928
5248
|
deviceId,
|
|
@@ -5598,6 +5918,12 @@ function createEventMediaHandler(deps) {
|
|
|
5598
5918
|
*/
|
|
5599
5919
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
5600
5920
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
5921
|
+
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
5922
|
+
* detection confidence beats the held best by at least this margin (hysteresis
|
|
5923
|
+
* so jitter around a plateau doesn't churn the write). */
|
|
5924
|
+
var BEST_FRAME_HYSTERESIS = .05;
|
|
5925
|
+
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
5926
|
+
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
5601
5927
|
/** Cluster setting key (in the centralized addon store) selecting the SINGLE
|
|
5602
5928
|
* node that runs post-analysis (event/media/audio/motion generation). All
|
|
5603
5929
|
* other nodes are fully inert. No multi-node balancing. Default: the hub. */
|
|
@@ -5709,6 +6035,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
5709
6035
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
5710
6036
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
5711
6037
|
dropoutSkipsByKey = /* @__PURE__ */ new Map();
|
|
6038
|
+
/** Best (highest-confidence) frame per track — drives the single overwrite
|
|
6039
|
+
* `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
|
|
6040
|
+
* face path (`face-recognizer.ts`); rate-limited here since each best-frame
|
|
6041
|
+
* capture re-encodes a full boxed frame. */
|
|
6042
|
+
bestFrameTracker = new BestDetectionTracker({
|
|
6043
|
+
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6044
|
+
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6045
|
+
});
|
|
5712
6046
|
shuttingDown = false;
|
|
5713
6047
|
/** True only on the cluster's designated post-processing node. When false the
|
|
5714
6048
|
* addon subscribes to NOTHING — fully inert (no event/media generation). */
|
|
@@ -6252,6 +6586,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6252
6586
|
this.processors.clear();
|
|
6253
6587
|
this.lastActiveTrackIds.clear();
|
|
6254
6588
|
this.dropoutSkipsByKey.clear();
|
|
6589
|
+
this.bestFrameTracker.clear();
|
|
6255
6590
|
this.levelStateByDevice.clear();
|
|
6256
6591
|
this.settingsCacheByDevice.clear();
|
|
6257
6592
|
this.sensitivityCacheByDevice.clear();
|
|
@@ -6358,12 +6693,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6358
6693
|
className: t.className,
|
|
6359
6694
|
source
|
|
6360
6695
|
} });
|
|
6361
|
-
if (this.eventMediaDispatcher && frameHandle)
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6696
|
+
if (this.eventMediaDispatcher && frameHandle) {
|
|
6697
|
+
firstFrameTargets.push({
|
|
6698
|
+
trackId: id,
|
|
6699
|
+
timestamp: result.timestamp,
|
|
6700
|
+
bbox: { ...t.bbox },
|
|
6701
|
+
...t.label ? { label: t.label } : {}
|
|
6702
|
+
});
|
|
6703
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
6704
|
+
}
|
|
6367
6705
|
this.ctx.eventBus.emit({
|
|
6368
6706
|
id: `pa-${randomUUID()}`,
|
|
6369
6707
|
timestamp: new Date(result.timestamp),
|
|
@@ -6436,11 +6774,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6436
6774
|
let plateCrops = 0;
|
|
6437
6775
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
6438
6776
|
else plateCrops += 1;
|
|
6439
|
-
|
|
6777
|
+
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
6778
|
+
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
6440
6779
|
log.info("media capture", { meta: {
|
|
6441
6780
|
source,
|
|
6442
6781
|
events: eventTargets.length,
|
|
6443
6782
|
trackFrames: firstFrameTargets.length,
|
|
6783
|
+
snapshots: snapshotTargets.length,
|
|
6444
6784
|
faceCrops,
|
|
6445
6785
|
plateCrops
|
|
6446
6786
|
} });
|
|
@@ -6449,8 +6789,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6449
6789
|
frameHandle,
|
|
6450
6790
|
events: eventTargets,
|
|
6451
6791
|
trackFrames: firstFrameTargets,
|
|
6792
|
+
snapshots: snapshotTargets,
|
|
6452
6793
|
cropPadding: mediaSettings.cropPadding
|
|
6453
|
-
})
|
|
6794
|
+
}).then((res) => {
|
|
6795
|
+
for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
|
|
6796
|
+
timestamp: s.timestamp,
|
|
6797
|
+
position: {
|
|
6798
|
+
x: s.bbox.x + s.bbox.w / 2,
|
|
6799
|
+
y: s.bbox.y + s.bbox.h / 2,
|
|
6800
|
+
timestamp: s.timestamp,
|
|
6801
|
+
bbox: s.bbox
|
|
6802
|
+
},
|
|
6803
|
+
mediaKey: s.mediaKey
|
|
6804
|
+
});
|
|
6805
|
+
}).catch(() => {});
|
|
6454
6806
|
}
|
|
6455
6807
|
}
|
|
6456
6808
|
if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
|
|
@@ -6583,6 +6935,35 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6583
6935
|
});
|
|
6584
6936
|
return settings;
|
|
6585
6937
|
}
|
|
6938
|
+
/**
|
|
6939
|
+
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
6940
|
+
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
6941
|
+
* • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
|
|
6942
|
+
* the snapshotIntervalMs cadence, gated by `saveThumbnails`;
|
|
6943
|
+
* • `thumbnail` (best) fires when confidence beats the held best by the
|
|
6944
|
+
* hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
|
|
6945
|
+
* NOT gated by saveThumbnails (a single best still-frame is always useful).
|
|
6946
|
+
* The per-track best-confidence map is updated here as a side effect.
|
|
6947
|
+
*/
|
|
6948
|
+
buildSnapshotTargets(tracked, timestamp, media) {
|
|
6949
|
+
const targets = [];
|
|
6950
|
+
for (const t of tracked) {
|
|
6951
|
+
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
6952
|
+
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
6953
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
6954
|
+
if (!dueSnapshot && !isNewBest) continue;
|
|
6955
|
+
targets.push({
|
|
6956
|
+
trackId: t.trackId,
|
|
6957
|
+
timestamp,
|
|
6958
|
+
bbox: { ...t.bbox },
|
|
6959
|
+
...t.label ? { label: t.label } : {},
|
|
6960
|
+
appendSnapshot: dueSnapshot,
|
|
6961
|
+
rollingLastFrame: dueSnapshot,
|
|
6962
|
+
bestThumbnail: isNewBest
|
|
6963
|
+
});
|
|
6964
|
+
}
|
|
6965
|
+
return targets;
|
|
6966
|
+
}
|
|
6586
6967
|
async handleAudioResult(data) {
|
|
6587
6968
|
if (this.shuttingDown) return;
|
|
6588
6969
|
const { deviceId, frame } = data;
|
|
@@ -6837,6 +7218,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6837
7218
|
});
|
|
6838
7219
|
this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
6839
7220
|
this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7221
|
+
this.bestFrameTracker.delete(t.trackId);
|
|
6840
7222
|
this.ctx.eventBus.emit({
|
|
6841
7223
|
id: `pa-end-${t.trackId}`,
|
|
6842
7224
|
timestamp: new Date(t.lastSeen),
|
|
@@ -6970,16 +7352,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6970
7352
|
p = new FrameProcessor(deviceId, {
|
|
6971
7353
|
minHits,
|
|
6972
7354
|
iouThreshold: trk.iouThreshold,
|
|
6973
|
-
|
|
7355
|
+
maxMissedMs: trk.maxMissedMs,
|
|
6974
7356
|
predictiveCoasting: trk.predictiveCoasting,
|
|
6975
7357
|
classGating: trk.classGating,
|
|
6976
7358
|
occlusionEnabled: trk.occlusionEnabled,
|
|
6977
7359
|
occlusionContainment: trk.occlusionContainment,
|
|
6978
|
-
|
|
7360
|
+
occlusionMaxMissedMs: trk.occlusionMaxMissedMs,
|
|
7361
|
+
rescueIouThreshold: trk.rescueIouThreshold,
|
|
7362
|
+
rescueCentroidFactor: trk.rescueCentroidFactor,
|
|
7363
|
+
resurrectionWindowMs: trk.resurrectionWindowMs,
|
|
7364
|
+
stationarySpeedPx: trk.stationarySpeedPx,
|
|
6979
7365
|
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
|
|
6980
7366
|
}, { stationaryThresholdSec }, {
|
|
6981
7367
|
minTrackAge,
|
|
6982
|
-
cooldownSec
|
|
7368
|
+
cooldownSec,
|
|
7369
|
+
minTrackAgeMs: trk.minTrackAgeMs
|
|
6983
7370
|
}, source);
|
|
6984
7371
|
this.processors.set(key, p);
|
|
6985
7372
|
}
|
|
@@ -7265,7 +7652,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7265
7652
|
min: 500,
|
|
7266
7653
|
max: 6e4,
|
|
7267
7654
|
step: 500,
|
|
7268
|
-
default:
|
|
7655
|
+
default: 5e3,
|
|
7269
7656
|
showValue: true,
|
|
7270
7657
|
unit: "s",
|
|
7271
7658
|
displayScale: 1e3
|
|
@@ -7540,12 +7927,53 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7540
7927
|
},
|
|
7541
7928
|
{
|
|
7542
7929
|
type: "number",
|
|
7543
|
-
key: "
|
|
7544
|
-
label: "Max
|
|
7545
|
-
description: "
|
|
7930
|
+
key: "maxMissedMs",
|
|
7931
|
+
label: "Max coasting time",
|
|
7932
|
+
description: "How long (ms) a track may miss detections before it is dropped. Time-based so it behaves the same on a 5fps and a 23fps camera.",
|
|
7546
7933
|
min: 0,
|
|
7547
|
-
step:
|
|
7548
|
-
default: TRACKING_DEFAULTS.
|
|
7934
|
+
step: 100,
|
|
7935
|
+
default: TRACKING_DEFAULTS.maxMissedMs,
|
|
7936
|
+
unit: "ms"
|
|
7937
|
+
},
|
|
7938
|
+
{
|
|
7939
|
+
type: "number",
|
|
7940
|
+
key: "minTrackAgeMs",
|
|
7941
|
+
label: "Min track age",
|
|
7942
|
+
description: "A track must exist for at least this long (ms) before it can produce events — stops sub-second flicker fragments from becoming events.",
|
|
7943
|
+
min: 0,
|
|
7944
|
+
step: 100,
|
|
7945
|
+
default: TRACKING_DEFAULTS.minTrackAgeMs,
|
|
7946
|
+
unit: "ms"
|
|
7947
|
+
},
|
|
7948
|
+
{
|
|
7949
|
+
type: "number",
|
|
7950
|
+
key: "resurrectionWindowMs",
|
|
7951
|
+
label: "Resurrection window",
|
|
7952
|
+
description: "How long (ms) a dropped track can be re-attached to a re-appearing same-class detection (keeping its id) — the main fix for a stationary object fragmenting into many tracks.",
|
|
7953
|
+
min: 0,
|
|
7954
|
+
step: 500,
|
|
7955
|
+
default: TRACKING_DEFAULTS.resurrectionWindowMs,
|
|
7956
|
+
unit: "ms"
|
|
7957
|
+
},
|
|
7958
|
+
{
|
|
7959
|
+
type: "number",
|
|
7960
|
+
key: "rescueIouThreshold",
|
|
7961
|
+
label: "Rescue IoU threshold",
|
|
7962
|
+
description: "Looser overlap (vs. a track’s last-known box) for the second association + resurrection passes. Lower = more aggressive re-attachment.",
|
|
7963
|
+
min: 0,
|
|
7964
|
+
max: 1,
|
|
7965
|
+
step: .05,
|
|
7966
|
+
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
7967
|
+
},
|
|
7968
|
+
{
|
|
7969
|
+
type: "number",
|
|
7970
|
+
key: "stationarySpeedPx",
|
|
7971
|
+
label: "Stationary speed",
|
|
7972
|
+
description: "Speed (px/frame) below which a track is treated as stationary and its prediction is frozen, so bbox jitter cannot walk the box off a sitting object.",
|
|
7973
|
+
min: 0,
|
|
7974
|
+
step: .5,
|
|
7975
|
+
default: TRACKING_DEFAULTS.stationarySpeedPx,
|
|
7976
|
+
unit: "px"
|
|
7549
7977
|
},
|
|
7550
7978
|
{
|
|
7551
7979
|
type: "number",
|
|
@@ -7590,12 +8018,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7590
8018
|
},
|
|
7591
8019
|
{
|
|
7592
8020
|
type: "number",
|
|
7593
|
-
key: "
|
|
7594
|
-
label: "Occlusion max
|
|
7595
|
-
description: "Extended coasting
|
|
8021
|
+
key: "occlusionMaxMissedMs",
|
|
8022
|
+
label: "Occlusion max coasting time",
|
|
8023
|
+
description: "Extended coasting time (ms) while a track is hidden behind another.",
|
|
7596
8024
|
min: 0,
|
|
7597
|
-
step:
|
|
7598
|
-
default: TRACKING_DEFAULTS.
|
|
8025
|
+
step: 100,
|
|
8026
|
+
default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
|
|
8027
|
+
unit: "ms"
|
|
7599
8028
|
},
|
|
7600
8029
|
{
|
|
7601
8030
|
type: "boolean",
|