@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
|
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
6
|
-
const require_resolve_frame = require("../resolve-frame-
|
|
5
|
+
const require_dist = require("../dist-CCC79h7t.js");
|
|
6
|
+
const require_resolve_frame = require("../resolve-frame-sKYbstL-.js");
|
|
7
7
|
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
8
8
|
let sharp = require("sharp");
|
|
9
9
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -699,17 +699,24 @@ function normalizeToPixel(p, width, height) {
|
|
|
699
699
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
700
700
|
var DEFAULT_TRACKER_CONFIG = {
|
|
701
701
|
iouThreshold: .3,
|
|
702
|
-
|
|
702
|
+
maxMissedMs: 4e3,
|
|
703
703
|
minHits: 3,
|
|
704
704
|
predictiveCoasting: true,
|
|
705
705
|
classGating: true,
|
|
706
706
|
occlusionEnabled: true,
|
|
707
707
|
occlusionContainment: .6,
|
|
708
|
-
|
|
709
|
-
maxTrackLifetimeMs: 3e5
|
|
708
|
+
occlusionMaxMissedMs: 8e3,
|
|
709
|
+
maxTrackLifetimeMs: 3e5,
|
|
710
|
+
rescueIouThreshold: .1,
|
|
711
|
+
rescueCentroidFactor: .75,
|
|
712
|
+
resurrectionWindowMs: 8e3,
|
|
713
|
+
stationarySpeedPx: 2
|
|
710
714
|
};
|
|
711
715
|
var MAX_PATH_LENGTH = 300;
|
|
712
716
|
var nextTrackId = 1;
|
|
717
|
+
function clamp(value, min, max) {
|
|
718
|
+
return Math.max(min, Math.min(max, value));
|
|
719
|
+
}
|
|
713
720
|
function iou$1(a, b) {
|
|
714
721
|
const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
|
|
715
722
|
const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
|
|
@@ -739,26 +746,51 @@ var SortTracker = class {
|
|
|
739
746
|
};
|
|
740
747
|
}
|
|
741
748
|
/** Where a track is expected this frame — extrapolated by velocity while
|
|
742
|
-
* coasting (predictiveCoasting), else its last known bbox.
|
|
749
|
+
* coasting (predictiveCoasting), else its last known bbox. A stationary
|
|
750
|
+
* track (speed < stationarySpeedPx) is frozen so bbox jitter can't walk the
|
|
751
|
+
* box off a sitting object; otherwise the drift is capped at one bbox size
|
|
752
|
+
* so a stale velocity can't fling the box across the frame. Public so the
|
|
753
|
+
* clamp is directly unit-testable. */
|
|
743
754
|
predicted(t) {
|
|
744
755
|
if (!this.config.predictiveCoasting) return t.bbox;
|
|
756
|
+
if (Math.hypot(t.velocity.dx, t.velocity.dy) < this.config.stationarySpeedPx) return t.bbox;
|
|
745
757
|
const f = t.age + 1;
|
|
758
|
+
const driftX = clamp(t.velocity.dx * f, -t.bbox.w, t.bbox.w);
|
|
759
|
+
const driftY = clamp(t.velocity.dy * f, -t.bbox.h, t.bbox.h);
|
|
746
760
|
return {
|
|
747
|
-
x: t.bbox.x +
|
|
748
|
-
y: t.bbox.y +
|
|
761
|
+
x: t.bbox.x + driftX,
|
|
762
|
+
y: t.bbox.y + driftY,
|
|
749
763
|
w: t.bbox.w,
|
|
750
764
|
h: t.bbox.h
|
|
751
765
|
};
|
|
752
766
|
}
|
|
767
|
+
/**
|
|
768
|
+
* Loose same-class gate used by the rescue and resurrection passes: accept
|
|
769
|
+
* when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
|
|
770
|
+
* its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always class-
|
|
771
|
+
* gated (a rescue must never cross classes), independent of `classGating`.
|
|
772
|
+
*/
|
|
773
|
+
looseMatch(track, det) {
|
|
774
|
+
if (track.class !== det.class) return false;
|
|
775
|
+
if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
|
|
776
|
+
const tc = bboxCentroid(track.bbox);
|
|
777
|
+
const dc = bboxCentroid(det.bbox);
|
|
778
|
+
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
779
|
+
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
780
|
+
return dist <= this.config.rescueCentroidFactor * diag;
|
|
781
|
+
}
|
|
753
782
|
update(detections, timestamp) {
|
|
754
783
|
if (this.config.maxTrackLifetimeMs > 0) {
|
|
755
784
|
const alive = [];
|
|
756
785
|
for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
|
|
757
786
|
track.lost = true;
|
|
787
|
+
track.lostAt = timestamp;
|
|
788
|
+
track.resurrectable = false;
|
|
758
789
|
this.lostTracks.push(track);
|
|
759
790
|
} else alive.push(track);
|
|
760
791
|
this.tracks = alive;
|
|
761
792
|
}
|
|
793
|
+
this.lostTracks = this.lostTracks.filter((t) => timestamp - t.lostAt <= this.config.resurrectionWindowMs);
|
|
762
794
|
const used = /* @__PURE__ */ new Set();
|
|
763
795
|
const matchedTracks = /* @__PURE__ */ new Set();
|
|
764
796
|
const matched = /* @__PURE__ */ new Map();
|
|
@@ -783,6 +815,27 @@ var SortTracker = class {
|
|
|
783
815
|
matchedTracks.add(pair.track);
|
|
784
816
|
used.add(pair.detIdx);
|
|
785
817
|
}
|
|
818
|
+
const rescuePairs = [];
|
|
819
|
+
for (const track of this.tracks) {
|
|
820
|
+
if (matchedTracks.has(track)) continue;
|
|
821
|
+
for (let di = 0; di < detections.length; di++) {
|
|
822
|
+
if (used.has(di)) continue;
|
|
823
|
+
const det = detections[di];
|
|
824
|
+
if (!this.looseMatch(track, det)) continue;
|
|
825
|
+
rescuePairs.push({
|
|
826
|
+
track,
|
|
827
|
+
detIdx: di,
|
|
828
|
+
score: iou$1(track.bbox, det.bbox)
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
rescuePairs.sort((a, b) => b.score - a.score);
|
|
833
|
+
for (const pair of rescuePairs) {
|
|
834
|
+
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
835
|
+
matched.set(pair.track, detections[pair.detIdx]);
|
|
836
|
+
matchedTracks.add(pair.track);
|
|
837
|
+
used.add(pair.detIdx);
|
|
838
|
+
}
|
|
786
839
|
for (const [track, det] of matched) {
|
|
787
840
|
const prevCenter = bboxCentroid({
|
|
788
841
|
x: track.bbox.x,
|
|
@@ -821,12 +874,47 @@ var SortTracker = class {
|
|
|
821
874
|
}
|
|
822
875
|
const occluded = this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(this.predicted(track), ob) >= this.config.occlusionContainment);
|
|
823
876
|
track.age++;
|
|
824
|
-
|
|
825
|
-
if (track.age > limit) {
|
|
877
|
+
if (timestamp - track.lastSeen > (occluded ? this.config.occlusionMaxMissedMs : this.config.maxMissedMs)) {
|
|
826
878
|
track.lost = true;
|
|
879
|
+
track.lostAt = timestamp;
|
|
880
|
+
track.resurrectable = true;
|
|
827
881
|
this.lostTracks.push(track);
|
|
828
882
|
} else surviving.push(track);
|
|
829
883
|
}
|
|
884
|
+
for (let di = 0; di < detections.length; di++) {
|
|
885
|
+
if (used.has(di)) continue;
|
|
886
|
+
const det = detections[di];
|
|
887
|
+
let best;
|
|
888
|
+
let bestScore = -1;
|
|
889
|
+
for (const lost of this.lostTracks) {
|
|
890
|
+
if (!lost.resurrectable) continue;
|
|
891
|
+
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
892
|
+
if (!this.looseMatch(lost, det)) continue;
|
|
893
|
+
const score = iou$1(lost.bbox, det.bbox);
|
|
894
|
+
if (score > bestScore) {
|
|
895
|
+
best = lost;
|
|
896
|
+
bestScore = score;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (!best) continue;
|
|
900
|
+
this.lostTracks = this.lostTracks.filter((t) => t !== best);
|
|
901
|
+
best.bbox = det.bbox;
|
|
902
|
+
best.class = det.class;
|
|
903
|
+
best.originalClass = det.originalClass;
|
|
904
|
+
best.score = det.score;
|
|
905
|
+
best.age = 0;
|
|
906
|
+
best.hits++;
|
|
907
|
+
best.lost = false;
|
|
908
|
+
best.lastSeen = timestamp;
|
|
909
|
+
best.velocity = {
|
|
910
|
+
dx: 0,
|
|
911
|
+
dy: 0
|
|
912
|
+
};
|
|
913
|
+
best.path.push(det.bbox);
|
|
914
|
+
if (best.path.length > MAX_PATH_LENGTH) best.path.shift();
|
|
915
|
+
surviving.push(best);
|
|
916
|
+
used.add(di);
|
|
917
|
+
}
|
|
830
918
|
for (let di = 0; di < detections.length; di++) {
|
|
831
919
|
if (used.has(di)) continue;
|
|
832
920
|
const det = detections[di];
|
|
@@ -846,7 +934,9 @@ var SortTracker = class {
|
|
|
846
934
|
dx: 0,
|
|
847
935
|
dy: 0
|
|
848
936
|
},
|
|
849
|
-
lost: false
|
|
937
|
+
lost: false,
|
|
938
|
+
lostAt: 0,
|
|
939
|
+
resurrectable: true
|
|
850
940
|
});
|
|
851
941
|
}
|
|
852
942
|
this.tracks = surviving;
|
|
@@ -961,6 +1051,7 @@ var StateAnalyzer = class {
|
|
|
961
1051
|
//#region src/pipeline-analytics/pipeline/events/event-filter.ts
|
|
962
1052
|
var DEFAULT_EVENT_EMITTER_CONFIG = {
|
|
963
1053
|
minTrackAge: 3,
|
|
1054
|
+
minTrackAgeMs: 0,
|
|
964
1055
|
cooldownSec: 5,
|
|
965
1056
|
enabledTypes: [
|
|
966
1057
|
"object.entering",
|
|
@@ -991,6 +1082,13 @@ var DetectionEventEmitter = class {
|
|
|
991
1082
|
config;
|
|
992
1083
|
previousStates = /* @__PURE__ */ new Map();
|
|
993
1084
|
lastEmitted = /* @__PURE__ */ new Map();
|
|
1085
|
+
/**
|
|
1086
|
+
* Last-known track per id. A `leaving` state fires for a track that is ABSENT
|
|
1087
|
+
* from the current frame, so it can't be resolved from `trackMap` — it is
|
|
1088
|
+
* resolved from this snapshot instead (without it, leaving events never
|
|
1089
|
+
* fired: the latent "0 `left` events in 24h" bug).
|
|
1090
|
+
*/
|
|
1091
|
+
lastKnownTracks = /* @__PURE__ */ new Map();
|
|
994
1092
|
constructor(config = {}) {
|
|
995
1093
|
this.config = {
|
|
996
1094
|
...DEFAULT_EVENT_EMITTER_CONFIG,
|
|
@@ -1002,10 +1100,12 @@ var DetectionEventEmitter = class {
|
|
|
1002
1100
|
const now = Date.now();
|
|
1003
1101
|
const stateMap = new Map(states.map((s) => [s.trackId, s]));
|
|
1004
1102
|
const trackMap = new Map(tracks.map((t) => [t.trackId, t]));
|
|
1103
|
+
for (const t of tracks) this.lastKnownTracks.set(t.trackId, t);
|
|
1005
1104
|
for (const state of states) {
|
|
1006
|
-
const track = trackMap.get(state.trackId);
|
|
1105
|
+
const track = trackMap.get(state.trackId) ?? this.lastKnownTracks.get(state.trackId);
|
|
1007
1106
|
if (!track) continue;
|
|
1008
1107
|
if (track.trackAge < this.config.minTrackAge) continue;
|
|
1108
|
+
if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
|
|
1009
1109
|
const eventType = STATE_TO_EVENT[state.state];
|
|
1010
1110
|
if (!eventType) continue;
|
|
1011
1111
|
if (!this.config.enabledTypes.includes(eventType)) continue;
|
|
@@ -1051,12 +1151,16 @@ var DetectionEventEmitter = class {
|
|
|
1051
1151
|
trackPath: [...track.path]
|
|
1052
1152
|
});
|
|
1053
1153
|
}
|
|
1054
|
-
for (const state of states) if (state.state === "leaving")
|
|
1154
|
+
for (const state of states) if (state.state === "leaving") {
|
|
1155
|
+
this.previousStates.delete(state.trackId);
|
|
1156
|
+
this.lastKnownTracks.delete(state.trackId);
|
|
1157
|
+
}
|
|
1055
1158
|
return events;
|
|
1056
1159
|
}
|
|
1057
1160
|
reset() {
|
|
1058
1161
|
this.previousStates.clear();
|
|
1059
1162
|
this.lastEmitted.clear();
|
|
1163
|
+
this.lastKnownTracks.clear();
|
|
1060
1164
|
}
|
|
1061
1165
|
};
|
|
1062
1166
|
//#endregion
|
|
@@ -1428,6 +1532,43 @@ var FrameProcessor = class {
|
|
|
1428
1532
|
}
|
|
1429
1533
|
};
|
|
1430
1534
|
//#endregion
|
|
1535
|
+
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1536
|
+
var BestDetectionTracker = class {
|
|
1537
|
+
hysteresis;
|
|
1538
|
+
minGapMs;
|
|
1539
|
+
best = /* @__PURE__ */ new Map();
|
|
1540
|
+
constructor(options = {}) {
|
|
1541
|
+
this.hysteresis = options.hysteresis ?? 0;
|
|
1542
|
+
this.minGapMs = options.minGapMs ?? 0;
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
|
|
1546
|
+
* Returns true when it becomes the track's new best — the first sighting, or a
|
|
1547
|
+
* confidence that beats the held peak by more than `hysteresis` AND respects
|
|
1548
|
+
* `minGapMs`. On acceptance the held peak is advanced to this observation.
|
|
1549
|
+
*/
|
|
1550
|
+
observe(trackId, confidence, timestamp) {
|
|
1551
|
+
const cur = this.best.get(trackId);
|
|
1552
|
+
const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
|
|
1553
|
+
if (isNewBest) this.best.set(trackId, {
|
|
1554
|
+
confidence,
|
|
1555
|
+
atMs: timestamp
|
|
1556
|
+
});
|
|
1557
|
+
return isNewBest;
|
|
1558
|
+
}
|
|
1559
|
+
/** The held peak for a track (undefined if never observed). */
|
|
1560
|
+
peak(trackId) {
|
|
1561
|
+
return this.best.get(trackId);
|
|
1562
|
+
}
|
|
1563
|
+
/** Drop a track's peak (call at track end). */
|
|
1564
|
+
delete(trackId) {
|
|
1565
|
+
this.best.delete(trackId);
|
|
1566
|
+
}
|
|
1567
|
+
clear() {
|
|
1568
|
+
this.best.clear();
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
//#endregion
|
|
1431
1572
|
//#region src/pipeline-analytics/pipeline/native-detection.ts
|
|
1432
1573
|
/**
|
|
1433
1574
|
* Nominal frame size used to denormalize native `[0,1]` boxes when a
|
|
@@ -1697,6 +1838,16 @@ var TrackStore = class {
|
|
|
1697
1838
|
lastSnapshotAt(trackId) {
|
|
1698
1839
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
1699
1840
|
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
1843
|
+
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
1844
|
+
* track begins rather than immediately — the `firstFrame` already covers the
|
|
1845
|
+
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
1846
|
+
*/
|
|
1847
|
+
seedSnapshotClock(trackId, timestamp) {
|
|
1848
|
+
const t = this.active.get(trackId);
|
|
1849
|
+
if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
|
|
1850
|
+
}
|
|
1700
1851
|
getActive(deviceId) {
|
|
1701
1852
|
const out = [];
|
|
1702
1853
|
for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
|
|
@@ -1961,6 +2112,46 @@ var MediaStore = class {
|
|
|
1961
2112
|
}
|
|
1962
2113
|
}
|
|
1963
2114
|
/**
|
|
2115
|
+
* Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
|
|
2116
|
+
* kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
|
|
2117
|
+
* each new capture replaces the previous one (blob + index row) rather than
|
|
2118
|
+
* accumulating a filmstrip the way `put` does. Deletes any existing rows of
|
|
2119
|
+
* that (owner, kind) first, then writes the fresh one. Returns the new key.
|
|
2120
|
+
*/
|
|
2121
|
+
async putReplacing(params) {
|
|
2122
|
+
const existing = await this.store.query.query({
|
|
2123
|
+
collection: MEDIA_COLLECTION,
|
|
2124
|
+
filter: { where: {
|
|
2125
|
+
ownerKind: params.ownerKind,
|
|
2126
|
+
ownerId: params.ownerId,
|
|
2127
|
+
kind: params.kind
|
|
2128
|
+
} }
|
|
2129
|
+
});
|
|
2130
|
+
const newKey = await this.put(params);
|
|
2131
|
+
for (const row of existing) {
|
|
2132
|
+
if (row.id === newKey) continue;
|
|
2133
|
+
const path = String(row.data["path"] ?? "");
|
|
2134
|
+
if (path) try {
|
|
2135
|
+
await this.storage.delete({
|
|
2136
|
+
location: "eventMedia",
|
|
2137
|
+
relativePath: path
|
|
2138
|
+
});
|
|
2139
|
+
} catch {}
|
|
2140
|
+
try {
|
|
2141
|
+
await this.store.delete.mutate({
|
|
2142
|
+
collection: MEDIA_COLLECTION,
|
|
2143
|
+
key: row.id
|
|
2144
|
+
});
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
this.logger.debug("media putReplacing: stale row delete failed", { meta: {
|
|
2147
|
+
key: row.id,
|
|
2148
|
+
error: String(err)
|
|
2149
|
+
} });
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
return newKey;
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
1964
2155
|
* Fetch one media entry by its key (id). Returns null if the key is not
|
|
1965
2156
|
* found in the index or if the blob is missing from storage.
|
|
1966
2157
|
*/
|
|
@@ -2873,7 +3064,9 @@ var EventMediaDispatcher = class {
|
|
|
2873
3064
|
}
|
|
2874
3065
|
async captureForFrame(input) {
|
|
2875
3066
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
2876
|
-
|
|
3067
|
+
const snapshots = input.snapshots ?? [];
|
|
3068
|
+
const empty = { storedSnapshots: [] };
|
|
3069
|
+
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
2877
3070
|
let decoded;
|
|
2878
3071
|
try {
|
|
2879
3072
|
decoded = await require_resolve_frame.resolveFrame(frameHandle, {
|
|
@@ -2890,7 +3083,7 @@ var EventMediaDispatcher = class {
|
|
|
2890
3083
|
error: String(err)
|
|
2891
3084
|
}
|
|
2892
3085
|
});
|
|
2893
|
-
return;
|
|
3086
|
+
return empty;
|
|
2894
3087
|
}
|
|
2895
3088
|
if (!decoded) {
|
|
2896
3089
|
this.deps.logger.debug("event media: frame recycled before resolve", {
|
|
@@ -2900,7 +3093,7 @@ var EventMediaDispatcher = class {
|
|
|
2900
3093
|
shmId: frameHandle.shmId
|
|
2901
3094
|
}
|
|
2902
3095
|
});
|
|
2903
|
-
return;
|
|
3096
|
+
return empty;
|
|
2904
3097
|
}
|
|
2905
3098
|
if (decoded.format !== "rgb") {
|
|
2906
3099
|
this.deps.logger.debug("event media: resolved frame is not RGB", {
|
|
@@ -2910,13 +3103,87 @@ var EventMediaDispatcher = class {
|
|
|
2910
3103
|
format: decoded.format
|
|
2911
3104
|
}
|
|
2912
3105
|
});
|
|
2913
|
-
return;
|
|
3106
|
+
return empty;
|
|
2914
3107
|
}
|
|
2915
3108
|
const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
|
|
2916
3109
|
const fw = decoded.width;
|
|
2917
3110
|
const fh = decoded.height;
|
|
2918
3111
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
2919
3112
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3113
|
+
const storedSnapshots = [];
|
|
3114
|
+
for (const sn of snapshots) {
|
|
3115
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
3116
|
+
if (stored) storedSnapshots.push(stored);
|
|
3117
|
+
}
|
|
3118
|
+
return { storedSnapshots };
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
|
|
3122
|
+
* to whichever of the three destinations is requested: an appended `snapshot`
|
|
3123
|
+
* (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
|
|
3124
|
+
* `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
|
|
3125
|
+
* (null when `appendSnapshot` is false or the encode failed).
|
|
3126
|
+
*/
|
|
3127
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
3128
|
+
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
3129
|
+
let boxed;
|
|
3130
|
+
try {
|
|
3131
|
+
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
3132
|
+
...sn.bbox,
|
|
3133
|
+
...sn.label ? { label: sn.label } : {}
|
|
3134
|
+
}], { quality: MEDIA_QUALITY });
|
|
3135
|
+
} catch (err) {
|
|
3136
|
+
this.deps.logger.warn("event media: track snapshot encode failed", {
|
|
3137
|
+
tags: { deviceId },
|
|
3138
|
+
meta: {
|
|
3139
|
+
deviceId,
|
|
3140
|
+
trackId: sn.trackId,
|
|
3141
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3142
|
+
}
|
|
3143
|
+
});
|
|
3144
|
+
return null;
|
|
3145
|
+
}
|
|
3146
|
+
let stored = null;
|
|
3147
|
+
if (sn.appendSnapshot) try {
|
|
3148
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
3149
|
+
deviceId,
|
|
3150
|
+
ownerKind: "track",
|
|
3151
|
+
ownerId: sn.trackId,
|
|
3152
|
+
kind: "snapshot",
|
|
3153
|
+
timestamp: sn.timestamp,
|
|
3154
|
+
data: boxed
|
|
3155
|
+
});
|
|
3156
|
+
stored = {
|
|
3157
|
+
trackId: sn.trackId,
|
|
3158
|
+
mediaKey,
|
|
3159
|
+
timestamp: sn.timestamp,
|
|
3160
|
+
bbox: sn.bbox
|
|
3161
|
+
};
|
|
3162
|
+
} catch {}
|
|
3163
|
+
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
3164
|
+
if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
3165
|
+
return stored;
|
|
3166
|
+
}
|
|
3167
|
+
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
3168
|
+
try {
|
|
3169
|
+
await this.deps.mediaStore.putReplacing({
|
|
3170
|
+
deviceId,
|
|
3171
|
+
ownerKind: "track",
|
|
3172
|
+
ownerId: trackId,
|
|
3173
|
+
kind,
|
|
3174
|
+
timestamp,
|
|
3175
|
+
data
|
|
3176
|
+
});
|
|
3177
|
+
} catch (err) {
|
|
3178
|
+
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
3179
|
+
tags: { deviceId },
|
|
3180
|
+
meta: {
|
|
3181
|
+
deviceId,
|
|
3182
|
+
trackId,
|
|
3183
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
2920
3187
|
}
|
|
2921
3188
|
async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
|
|
2922
3189
|
const box = {
|
|
@@ -3777,22 +4044,22 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
3777
4044
|
stationaryThresholdSec: pick("stationaryThresholdSec")
|
|
3778
4045
|
};
|
|
3779
4046
|
}
|
|
3780
|
-
//#endregion
|
|
3781
|
-
//#region src/pipeline-analytics/tracking-settings.ts
|
|
3782
|
-
/**
|
|
3783
|
-
* Per-device tracker tuning. These drive the SORT tracker (association,
|
|
3784
|
-
* coasting, occlusion, lifetime) and the detector-dropout frame skip. Every
|
|
3785
|
-
* field is independently overridable per camera from the admin UI; unknown or
|
|
3786
|
-
* invalid values fall back to the field default (never throws on a bad blob).
|
|
3787
|
-
*
|
|
3788
|
-
* Note: `minHits` lives in detection-sensitivity-settings (it predates this
|
|
3789
|
-
* file and already flows to the tracker) — it is NOT duplicated here.
|
|
3790
|
-
*/
|
|
3791
4047
|
var TrackingSettingsSchema = require_dist.object({
|
|
3792
4048
|
/** IoU required to match a (predicted) track to a detection. */
|
|
3793
4049
|
iouThreshold: require_dist.number().min(0).max(1).default(.3),
|
|
3794
|
-
/**
|
|
4050
|
+
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
4051
|
+
* based so behaviour is identical across camera frame rates. */
|
|
4052
|
+
maxMissedMs: require_dist.number().min(0).default(4e3),
|
|
4053
|
+
/** Minimum wall-clock track age (ms) before it may produce events. */
|
|
4054
|
+
minTrackAgeMs: require_dist.number().min(0).default(1e3),
|
|
4055
|
+
/** Extended coasting budget (ms) while a track is occluded. */
|
|
4056
|
+
occlusionMaxMissedMs: require_dist.number().min(0).default(8e3),
|
|
4057
|
+
/** @deprecated frame-based alias for {@link maxMissedMs}. Retained so
|
|
4058
|
+
* existing per-device blobs keep working — resolved into `maxMissedMs`
|
|
4059
|
+
* (× {@link NOMINAL_FRAME_MS}) only when `maxMissedMs` is unset. */
|
|
3795
4060
|
maxMissedFrames: require_dist.number().int().min(0).default(30),
|
|
4061
|
+
/** @deprecated frame-based alias for {@link occlusionMaxMissedMs}. */
|
|
4062
|
+
occlusionMaxMissedFrames: require_dist.number().int().min(0).default(60),
|
|
3796
4063
|
/** Hard cap on total track lifetime — retire+restart beyond this to bound
|
|
3797
4064
|
* drift / long-lived false positives. 0 = unlimited. */
|
|
3798
4065
|
maxTrackLifetimeSec: require_dist.number().min(0).default(300),
|
|
@@ -3806,8 +4073,17 @@ var TrackingSettingsSchema = require_dist.object({
|
|
|
3806
4073
|
/** Containment ratio (intersection / occluded-area) above which a missed
|
|
3807
4074
|
* track is considered hidden behind another track. */
|
|
3808
4075
|
occlusionContainment: require_dist.number().min(0).max(1).default(.6),
|
|
3809
|
-
/**
|
|
3810
|
-
|
|
4076
|
+
/** Loose IoU gate (vs. last-known bbox) for the second (rescue) association
|
|
4077
|
+
* pass + graveyard resurrection. */
|
|
4078
|
+
rescueIouThreshold: require_dist.number().min(0).max(1).default(.1),
|
|
4079
|
+
/** Centroid gate for rescue/resurrection: fraction of the bbox diagonal. */
|
|
4080
|
+
rescueCentroidFactor: require_dist.number().min(0).default(.75),
|
|
4081
|
+
/** Wall-clock graveyard retention (ms): a dropped track re-attaches to a new
|
|
4082
|
+
* same-class detection (keeping its id) for this long. */
|
|
4083
|
+
resurrectionWindowMs: require_dist.number().min(0).default(8e3),
|
|
4084
|
+
/** Speed (px/frame) below which a track's prediction is frozen (stationary
|
|
4085
|
+
* jitter can't drift the box off a sitting object). */
|
|
4086
|
+
stationarySpeedPx: require_dist.number().min(0).default(2),
|
|
3811
4087
|
/** Skip frames where detections suddenly drop to zero (detector glitch). */
|
|
3812
4088
|
dropoutSkipEnabled: require_dist.boolean().default(true),
|
|
3813
4089
|
/** Max consecutive all-zero frames absorbed as a glitch before the scene is
|
|
@@ -3822,15 +4098,26 @@ var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
|
|
|
3822
4098
|
*/
|
|
3823
4099
|
function resolveTrackingSettings(raw) {
|
|
3824
4100
|
const s = TrackingSettingsSchema.shape;
|
|
4101
|
+
const maxMissedFrames = s.maxMissedFrames.catch(TRACKING_DEFAULTS.maxMissedFrames).parse(raw.maxMissedFrames);
|
|
4102
|
+
const occlusionMaxMissedFrames = s.occlusionMaxMissedFrames.catch(TRACKING_DEFAULTS.occlusionMaxMissedFrames).parse(raw.occlusionMaxMissedFrames);
|
|
4103
|
+
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;
|
|
4104
|
+
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;
|
|
3825
4105
|
return {
|
|
3826
4106
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
3827
|
-
|
|
4107
|
+
maxMissedMs,
|
|
4108
|
+
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
4109
|
+
occlusionMaxMissedMs,
|
|
4110
|
+
maxMissedFrames,
|
|
4111
|
+
occlusionMaxMissedFrames,
|
|
3828
4112
|
maxTrackLifetimeSec: s.maxTrackLifetimeSec.catch(TRACKING_DEFAULTS.maxTrackLifetimeSec).parse(raw.maxTrackLifetimeSec),
|
|
3829
4113
|
predictiveCoasting: s.predictiveCoasting.catch(TRACKING_DEFAULTS.predictiveCoasting).parse(raw.predictiveCoasting),
|
|
3830
4114
|
classGating: s.classGating.catch(TRACKING_DEFAULTS.classGating).parse(raw.classGating),
|
|
3831
4115
|
occlusionEnabled: s.occlusionEnabled.catch(TRACKING_DEFAULTS.occlusionEnabled).parse(raw.occlusionEnabled),
|
|
3832
4116
|
occlusionContainment: s.occlusionContainment.catch(TRACKING_DEFAULTS.occlusionContainment).parse(raw.occlusionContainment),
|
|
3833
|
-
|
|
4117
|
+
rescueIouThreshold: s.rescueIouThreshold.catch(TRACKING_DEFAULTS.rescueIouThreshold).parse(raw.rescueIouThreshold),
|
|
4118
|
+
rescueCentroidFactor: s.rescueCentroidFactor.catch(TRACKING_DEFAULTS.rescueCentroidFactor).parse(raw.rescueCentroidFactor),
|
|
4119
|
+
resurrectionWindowMs: s.resurrectionWindowMs.catch(TRACKING_DEFAULTS.resurrectionWindowMs).parse(raw.resurrectionWindowMs),
|
|
4120
|
+
stationarySpeedPx: s.stationarySpeedPx.catch(TRACKING_DEFAULTS.stationarySpeedPx).parse(raw.stationarySpeedPx),
|
|
3834
4121
|
dropoutSkipEnabled: s.dropoutSkipEnabled.catch(TRACKING_DEFAULTS.dropoutSkipEnabled).parse(raw.dropoutSkipEnabled),
|
|
3835
4122
|
dropoutMaxSkipFrames: s.dropoutMaxSkipFrames.catch(TRACKING_DEFAULTS.dropoutMaxSkipFrames).parse(raw.dropoutMaxSkipFrames)
|
|
3836
4123
|
};
|
|
@@ -3891,11 +4178,20 @@ function resolveFaceSettings(raw) {
|
|
|
3891
4178
|
* its default — parse never throws). Mirrors `face-settings` /
|
|
3892
4179
|
* `audio-detection-settings`.
|
|
3893
4180
|
*/
|
|
3894
|
-
var MediaSettingsSchema = require_dist.object({
|
|
3895
|
-
/** Fractional padding added around a detection bbox before cropping.
|
|
3896
|
-
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
3897
|
-
* max 2 (200%). */
|
|
3898
|
-
cropPadding: require_dist.number().min(0).max(2).default(.15)
|
|
4181
|
+
var MediaSettingsSchema = require_dist.object({
|
|
4182
|
+
/** Fractional padding added around a detection bbox before cropping.
|
|
4183
|
+
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
4184
|
+
* max 2 (200%). */
|
|
4185
|
+
cropPadding: require_dist.number().min(0).max(2).default(.15),
|
|
4186
|
+
/** Master switch for periodic per-track snapshots (the timeline filmstrip +
|
|
4187
|
+
* the rolling `lastFrame` + the best `thumbnail`). When false, only the
|
|
4188
|
+
* per-track `firstFrame` and per-event media are produced. */
|
|
4189
|
+
saveThumbnails: require_dist.boolean().default(true),
|
|
4190
|
+
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
4191
|
+
* A snapshot is captured for an active track only after this much wall-clock
|
|
4192
|
+
* has elapsed since its previous one. */
|
|
4193
|
+
snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
|
|
4194
|
+
});
|
|
3899
4195
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
3900
4196
|
/**
|
|
3901
4197
|
* Resolve a per-device store blob into typed media settings. Unknown/invalid
|
|
@@ -3906,7 +4202,11 @@ function resolveMediaSettings(raw) {
|
|
|
3906
4202
|
const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
|
|
3907
4203
|
return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
|
|
3908
4204
|
};
|
|
3909
|
-
return {
|
|
4205
|
+
return {
|
|
4206
|
+
cropPadding: pick("cropPadding"),
|
|
4207
|
+
saveThumbnails: pick("saveThumbnails"),
|
|
4208
|
+
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
4209
|
+
};
|
|
3910
4210
|
}
|
|
3911
4211
|
//#endregion
|
|
3912
4212
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
@@ -4802,6 +5102,11 @@ var FaceRecognizer = class {
|
|
|
4802
5102
|
names = /* @__PURE__ */ new Map();
|
|
4803
5103
|
aggregates = /* @__PURE__ */ new Map();
|
|
4804
5104
|
bestFace = /* @__PURE__ */ new Map();
|
|
5105
|
+
/** The ONE best-detection-per-track policy, shared with the best-frame path
|
|
5106
|
+
* (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
|
|
5107
|
+
* true highest-confidence face (holding a buffer in memory is cheap, and a
|
|
5108
|
+
* track may last well under the best-frame rate-limit window). */
|
|
5109
|
+
bestTracker = new BestDetectionTracker();
|
|
4805
5110
|
constructor(deps) {
|
|
4806
5111
|
this.deps = deps;
|
|
4807
5112
|
}
|
|
@@ -4835,55 +5140,25 @@ var FaceRecognizer = class {
|
|
|
4835
5140
|
threshold: settings.similarityThreshold,
|
|
4836
5141
|
margin: settings.margin
|
|
4837
5142
|
}) : /* @__PURE__ */ new Map();
|
|
5143
|
+
const labelWork = [];
|
|
4838
5144
|
for (const c of candidates) {
|
|
4839
5145
|
const match = matches.get(c.trackId) ?? null;
|
|
4840
5146
|
const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
|
|
4841
5147
|
this.aggregates.set(c.trackId, state);
|
|
4842
|
-
if (changed && state.assignedIdentityId !== null) {
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
trackId: c.trackId
|
|
4848
|
-
},
|
|
4849
|
-
meta: {
|
|
4850
|
-
identityId: state.assignedIdentityId,
|
|
4851
|
-
name: name ?? null,
|
|
4852
|
-
score: match?.score ?? null
|
|
4853
|
-
}
|
|
4854
|
-
});
|
|
4855
|
-
if (name !== void 0) {
|
|
4856
|
-
try {
|
|
4857
|
-
await this.deps.trackStore.setLabel(c.trackId, name);
|
|
4858
|
-
} catch (err) {
|
|
4859
|
-
this.deps.logger.warn("setLabel failed", {
|
|
4860
|
-
tags: { deviceId: input.deviceId },
|
|
4861
|
-
meta: {
|
|
4862
|
-
trackId: c.trackId,
|
|
4863
|
-
error: String(err)
|
|
4864
|
-
}
|
|
4865
|
-
});
|
|
4866
|
-
}
|
|
4867
|
-
try {
|
|
4868
|
-
await this.deps.eventStore.setLabelForTrack(c.trackId, name);
|
|
4869
|
-
} catch (err) {
|
|
4870
|
-
this.deps.logger.warn("setLabelForTrack failed", {
|
|
4871
|
-
tags: { deviceId: input.deviceId },
|
|
4872
|
-
meta: {
|
|
4873
|
-
trackId: c.trackId,
|
|
4874
|
-
error: String(err)
|
|
4875
|
-
}
|
|
4876
|
-
});
|
|
4877
|
-
}
|
|
4878
|
-
}
|
|
4879
|
-
}
|
|
5148
|
+
if (changed && state.assignedIdentityId !== null) labelWork.push({
|
|
5149
|
+
trackId: c.trackId,
|
|
5150
|
+
assignedIdentityId: state.assignedIdentityId,
|
|
5151
|
+
matchScore: match?.score ?? null
|
|
5152
|
+
});
|
|
4880
5153
|
const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
|
|
4881
5154
|
const held = this.bestFace.get(c.trackId);
|
|
4882
|
-
|
|
5155
|
+
const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
|
|
5156
|
+
const needsCrop = held !== void 0 && held.crop === void 0;
|
|
5157
|
+
if (isNewBest || needsCrop) {
|
|
4883
5158
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
4884
5159
|
let crop;
|
|
4885
|
-
if (input.frameHandle !== void 0) try {
|
|
4886
|
-
crop = await this.deps.captureCrop(input.frameHandle,
|
|
5160
|
+
if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
|
|
5161
|
+
crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
4887
5162
|
} catch (err) {
|
|
4888
5163
|
this.deps.logger.debug("FaceRecognizer crop capture failed", {
|
|
4889
5164
|
tags: { deviceId: input.deviceId },
|
|
@@ -4893,14 +5168,20 @@ var FaceRecognizer = class {
|
|
|
4893
5168
|
}
|
|
4894
5169
|
});
|
|
4895
5170
|
}
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
5171
|
+
if (isNewBest) {
|
|
5172
|
+
const bestCrop = crop ?? held?.crop;
|
|
5173
|
+
this.bestFace.set(c.trackId, {
|
|
5174
|
+
score: c.confidence,
|
|
5175
|
+
embedding: c.embedding,
|
|
5176
|
+
embeddingModelId: c.embeddingModelId,
|
|
5177
|
+
bbox: cropBbox,
|
|
5178
|
+
timestamp: input.timestamp,
|
|
5179
|
+
...bestCrop !== void 0 ? { crop: bestCrop } : {},
|
|
5180
|
+
...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
|
|
5181
|
+
});
|
|
5182
|
+
} else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
|
|
5183
|
+
...held,
|
|
5184
|
+
crop
|
|
4904
5185
|
});
|
|
4905
5186
|
this.deps.logger.debug("face: best-face held", {
|
|
4906
5187
|
tags: {
|
|
@@ -4909,15 +5190,53 @@ var FaceRecognizer = class {
|
|
|
4909
5190
|
},
|
|
4910
5191
|
meta: {
|
|
4911
5192
|
score: c.confidence,
|
|
4912
|
-
|
|
5193
|
+
isNewBest,
|
|
5194
|
+
hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
|
|
4913
5195
|
recognizedIdentityId: recognizedIdentityId ?? null
|
|
4914
5196
|
}
|
|
4915
5197
|
});
|
|
4916
|
-
} else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
|
|
5198
|
+
} else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
|
|
4917
5199
|
...held,
|
|
4918
5200
|
recognizedIdentityId
|
|
4919
5201
|
});
|
|
4920
5202
|
}
|
|
5203
|
+
for (const work of labelWork) {
|
|
5204
|
+
const name = this.names.get(work.assignedIdentityId);
|
|
5205
|
+
this.deps.logger.info("face: identity assigned", {
|
|
5206
|
+
tags: {
|
|
5207
|
+
deviceId: input.deviceId,
|
|
5208
|
+
trackId: work.trackId
|
|
5209
|
+
},
|
|
5210
|
+
meta: {
|
|
5211
|
+
identityId: work.assignedIdentityId,
|
|
5212
|
+
name: name ?? null,
|
|
5213
|
+
score: work.matchScore
|
|
5214
|
+
}
|
|
5215
|
+
});
|
|
5216
|
+
if (name === void 0) continue;
|
|
5217
|
+
try {
|
|
5218
|
+
await this.deps.trackStore.setLabel(work.trackId, name);
|
|
5219
|
+
} catch (err) {
|
|
5220
|
+
this.deps.logger.warn("setLabel failed", {
|
|
5221
|
+
tags: { deviceId: input.deviceId },
|
|
5222
|
+
meta: {
|
|
5223
|
+
trackId: work.trackId,
|
|
5224
|
+
error: String(err)
|
|
5225
|
+
}
|
|
5226
|
+
});
|
|
5227
|
+
}
|
|
5228
|
+
try {
|
|
5229
|
+
await this.deps.eventStore.setLabelForTrack(work.trackId, name);
|
|
5230
|
+
} catch (err) {
|
|
5231
|
+
this.deps.logger.warn("setLabelForTrack failed", {
|
|
5232
|
+
tags: { deviceId: input.deviceId },
|
|
5233
|
+
meta: {
|
|
5234
|
+
trackId: work.trackId,
|
|
5235
|
+
error: String(err)
|
|
5236
|
+
}
|
|
5237
|
+
});
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
4921
5240
|
}
|
|
4922
5241
|
/**
|
|
4923
5242
|
* Persist the held best face for a finished track as ONE FaceStore buffer
|
|
@@ -4928,6 +5247,7 @@ var FaceRecognizer = class {
|
|
|
4928
5247
|
const held = this.bestFace.get(trackId);
|
|
4929
5248
|
this.aggregates.delete(trackId);
|
|
4930
5249
|
this.bestFace.delete(trackId);
|
|
5250
|
+
this.bestTracker.delete(trackId);
|
|
4931
5251
|
if (held === void 0) {
|
|
4932
5252
|
this.deps.logger.debug("face: track ended without a held face", { tags: {
|
|
4933
5253
|
deviceId,
|
|
@@ -5603,6 +5923,12 @@ function createEventMediaHandler(deps) {
|
|
|
5603
5923
|
*/
|
|
5604
5924
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
5605
5925
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
5926
|
+
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
5927
|
+
* detection confidence beats the held best by at least this margin (hysteresis
|
|
5928
|
+
* so jitter around a plateau doesn't churn the write). */
|
|
5929
|
+
var BEST_FRAME_HYSTERESIS = .05;
|
|
5930
|
+
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
5931
|
+
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
5606
5932
|
/** Cluster setting key (in the centralized addon store) selecting the SINGLE
|
|
5607
5933
|
* node that runs post-analysis (event/media/audio/motion generation). All
|
|
5608
5934
|
* other nodes are fully inert. No multi-node balancing. Default: the hub. */
|
|
@@ -5714,6 +6040,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
5714
6040
|
mediaCacheByDevice = /* @__PURE__ */ new Map();
|
|
5715
6041
|
/** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
|
|
5716
6042
|
dropoutSkipsByKey = /* @__PURE__ */ new Map();
|
|
6043
|
+
/** Best (highest-confidence) frame per track — drives the single overwrite
|
|
6044
|
+
* `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
|
|
6045
|
+
* face path (`face-recognizer.ts`); rate-limited here since each best-frame
|
|
6046
|
+
* capture re-encodes a full boxed frame. */
|
|
6047
|
+
bestFrameTracker = new BestDetectionTracker({
|
|
6048
|
+
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6049
|
+
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6050
|
+
});
|
|
5717
6051
|
shuttingDown = false;
|
|
5718
6052
|
/** True only on the cluster's designated post-processing node. When false the
|
|
5719
6053
|
* addon subscribes to NOTHING — fully inert (no event/media generation). */
|
|
@@ -5747,7 +6081,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
5747
6081
|
let storage = this.ctx.kernel.storage;
|
|
5748
6082
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
5749
6083
|
if (mediaRoot) {
|
|
5750
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
6084
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CIEkEv1F.js"));
|
|
5751
6085
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
5752
6086
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
5753
6087
|
}
|
|
@@ -6257,6 +6591,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6257
6591
|
this.processors.clear();
|
|
6258
6592
|
this.lastActiveTrackIds.clear();
|
|
6259
6593
|
this.dropoutSkipsByKey.clear();
|
|
6594
|
+
this.bestFrameTracker.clear();
|
|
6260
6595
|
this.levelStateByDevice.clear();
|
|
6261
6596
|
this.settingsCacheByDevice.clear();
|
|
6262
6597
|
this.sensitivityCacheByDevice.clear();
|
|
@@ -6363,12 +6698,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6363
6698
|
className: t.className,
|
|
6364
6699
|
source
|
|
6365
6700
|
} });
|
|
6366
|
-
if (this.eventMediaDispatcher && frameHandle)
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6701
|
+
if (this.eventMediaDispatcher && frameHandle) {
|
|
6702
|
+
firstFrameTargets.push({
|
|
6703
|
+
trackId: id,
|
|
6704
|
+
timestamp: result.timestamp,
|
|
6705
|
+
bbox: { ...t.bbox },
|
|
6706
|
+
...t.label ? { label: t.label } : {}
|
|
6707
|
+
});
|
|
6708
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
6709
|
+
}
|
|
6372
6710
|
this.ctx.eventBus.emit({
|
|
6373
6711
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
6374
6712
|
timestamp: new Date(result.timestamp),
|
|
@@ -6441,11 +6779,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6441
6779
|
let plateCrops = 0;
|
|
6442
6780
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
6443
6781
|
else plateCrops += 1;
|
|
6444
|
-
|
|
6782
|
+
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
6783
|
+
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
6445
6784
|
log.info("media capture", { meta: {
|
|
6446
6785
|
source,
|
|
6447
6786
|
events: eventTargets.length,
|
|
6448
6787
|
trackFrames: firstFrameTargets.length,
|
|
6788
|
+
snapshots: snapshotTargets.length,
|
|
6449
6789
|
faceCrops,
|
|
6450
6790
|
plateCrops
|
|
6451
6791
|
} });
|
|
@@ -6454,8 +6794,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6454
6794
|
frameHandle,
|
|
6455
6795
|
events: eventTargets,
|
|
6456
6796
|
trackFrames: firstFrameTargets,
|
|
6797
|
+
snapshots: snapshotTargets,
|
|
6457
6798
|
cropPadding: mediaSettings.cropPadding
|
|
6458
|
-
})
|
|
6799
|
+
}).then((res) => {
|
|
6800
|
+
for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
|
|
6801
|
+
timestamp: s.timestamp,
|
|
6802
|
+
position: {
|
|
6803
|
+
x: s.bbox.x + s.bbox.w / 2,
|
|
6804
|
+
y: s.bbox.y + s.bbox.h / 2,
|
|
6805
|
+
timestamp: s.timestamp,
|
|
6806
|
+
bbox: s.bbox
|
|
6807
|
+
},
|
|
6808
|
+
mediaKey: s.mediaKey
|
|
6809
|
+
});
|
|
6810
|
+
}).catch(() => {});
|
|
6459
6811
|
}
|
|
6460
6812
|
}
|
|
6461
6813
|
if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
|
|
@@ -6588,6 +6940,35 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6588
6940
|
});
|
|
6589
6941
|
return settings;
|
|
6590
6942
|
}
|
|
6943
|
+
/**
|
|
6944
|
+
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
6945
|
+
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
6946
|
+
* • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
|
|
6947
|
+
* the snapshotIntervalMs cadence, gated by `saveThumbnails`;
|
|
6948
|
+
* • `thumbnail` (best) fires when confidence beats the held best by the
|
|
6949
|
+
* hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
|
|
6950
|
+
* NOT gated by saveThumbnails (a single best still-frame is always useful).
|
|
6951
|
+
* The per-track best-confidence map is updated here as a side effect.
|
|
6952
|
+
*/
|
|
6953
|
+
buildSnapshotTargets(tracked, timestamp, media) {
|
|
6954
|
+
const targets = [];
|
|
6955
|
+
for (const t of tracked) {
|
|
6956
|
+
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
6957
|
+
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
6958
|
+
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
6959
|
+
if (!dueSnapshot && !isNewBest) continue;
|
|
6960
|
+
targets.push({
|
|
6961
|
+
trackId: t.trackId,
|
|
6962
|
+
timestamp,
|
|
6963
|
+
bbox: { ...t.bbox },
|
|
6964
|
+
...t.label ? { label: t.label } : {},
|
|
6965
|
+
appendSnapshot: dueSnapshot,
|
|
6966
|
+
rollingLastFrame: dueSnapshot,
|
|
6967
|
+
bestThumbnail: isNewBest
|
|
6968
|
+
});
|
|
6969
|
+
}
|
|
6970
|
+
return targets;
|
|
6971
|
+
}
|
|
6591
6972
|
async handleAudioResult(data) {
|
|
6592
6973
|
if (this.shuttingDown) return;
|
|
6593
6974
|
const { deviceId, frame } = data;
|
|
@@ -6842,6 +7223,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6842
7223
|
});
|
|
6843
7224
|
this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
6844
7225
|
this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7226
|
+
this.bestFrameTracker.delete(t.trackId);
|
|
6845
7227
|
this.ctx.eventBus.emit({
|
|
6846
7228
|
id: `pa-end-${t.trackId}`,
|
|
6847
7229
|
timestamp: new Date(t.lastSeen),
|
|
@@ -6975,16 +7357,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6975
7357
|
p = new FrameProcessor(deviceId, {
|
|
6976
7358
|
minHits,
|
|
6977
7359
|
iouThreshold: trk.iouThreshold,
|
|
6978
|
-
|
|
7360
|
+
maxMissedMs: trk.maxMissedMs,
|
|
6979
7361
|
predictiveCoasting: trk.predictiveCoasting,
|
|
6980
7362
|
classGating: trk.classGating,
|
|
6981
7363
|
occlusionEnabled: trk.occlusionEnabled,
|
|
6982
7364
|
occlusionContainment: trk.occlusionContainment,
|
|
6983
|
-
|
|
7365
|
+
occlusionMaxMissedMs: trk.occlusionMaxMissedMs,
|
|
7366
|
+
rescueIouThreshold: trk.rescueIouThreshold,
|
|
7367
|
+
rescueCentroidFactor: trk.rescueCentroidFactor,
|
|
7368
|
+
resurrectionWindowMs: trk.resurrectionWindowMs,
|
|
7369
|
+
stationarySpeedPx: trk.stationarySpeedPx,
|
|
6984
7370
|
maxTrackLifetimeMs: trk.maxTrackLifetimeSec * 1e3
|
|
6985
7371
|
}, { stationaryThresholdSec }, {
|
|
6986
7372
|
minTrackAge,
|
|
6987
|
-
cooldownSec
|
|
7373
|
+
cooldownSec,
|
|
7374
|
+
minTrackAgeMs: trk.minTrackAgeMs
|
|
6988
7375
|
}, source);
|
|
6989
7376
|
this.processors.set(key, p);
|
|
6990
7377
|
}
|
|
@@ -7270,7 +7657,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7270
7657
|
min: 500,
|
|
7271
7658
|
max: 6e4,
|
|
7272
7659
|
step: 500,
|
|
7273
|
-
default:
|
|
7660
|
+
default: 5e3,
|
|
7274
7661
|
showValue: true,
|
|
7275
7662
|
unit: "s",
|
|
7276
7663
|
displayScale: 1e3
|
|
@@ -7545,12 +7932,53 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7545
7932
|
},
|
|
7546
7933
|
{
|
|
7547
7934
|
type: "number",
|
|
7548
|
-
key: "
|
|
7549
|
-
label: "Max
|
|
7550
|
-
description: "
|
|
7935
|
+
key: "maxMissedMs",
|
|
7936
|
+
label: "Max coasting time",
|
|
7937
|
+
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.",
|
|
7551
7938
|
min: 0,
|
|
7552
|
-
step:
|
|
7553
|
-
default: TRACKING_DEFAULTS.
|
|
7939
|
+
step: 100,
|
|
7940
|
+
default: TRACKING_DEFAULTS.maxMissedMs,
|
|
7941
|
+
unit: "ms"
|
|
7942
|
+
},
|
|
7943
|
+
{
|
|
7944
|
+
type: "number",
|
|
7945
|
+
key: "minTrackAgeMs",
|
|
7946
|
+
label: "Min track age",
|
|
7947
|
+
description: "A track must exist for at least this long (ms) before it can produce events — stops sub-second flicker fragments from becoming events.",
|
|
7948
|
+
min: 0,
|
|
7949
|
+
step: 100,
|
|
7950
|
+
default: TRACKING_DEFAULTS.minTrackAgeMs,
|
|
7951
|
+
unit: "ms"
|
|
7952
|
+
},
|
|
7953
|
+
{
|
|
7954
|
+
type: "number",
|
|
7955
|
+
key: "resurrectionWindowMs",
|
|
7956
|
+
label: "Resurrection window",
|
|
7957
|
+
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.",
|
|
7958
|
+
min: 0,
|
|
7959
|
+
step: 500,
|
|
7960
|
+
default: TRACKING_DEFAULTS.resurrectionWindowMs,
|
|
7961
|
+
unit: "ms"
|
|
7962
|
+
},
|
|
7963
|
+
{
|
|
7964
|
+
type: "number",
|
|
7965
|
+
key: "rescueIouThreshold",
|
|
7966
|
+
label: "Rescue IoU threshold",
|
|
7967
|
+
description: "Looser overlap (vs. a track’s last-known box) for the second association + resurrection passes. Lower = more aggressive re-attachment.",
|
|
7968
|
+
min: 0,
|
|
7969
|
+
max: 1,
|
|
7970
|
+
step: .05,
|
|
7971
|
+
default: TRACKING_DEFAULTS.rescueIouThreshold
|
|
7972
|
+
},
|
|
7973
|
+
{
|
|
7974
|
+
type: "number",
|
|
7975
|
+
key: "stationarySpeedPx",
|
|
7976
|
+
label: "Stationary speed",
|
|
7977
|
+
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.",
|
|
7978
|
+
min: 0,
|
|
7979
|
+
step: .5,
|
|
7980
|
+
default: TRACKING_DEFAULTS.stationarySpeedPx,
|
|
7981
|
+
unit: "px"
|
|
7554
7982
|
},
|
|
7555
7983
|
{
|
|
7556
7984
|
type: "number",
|
|
@@ -7595,12 +8023,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7595
8023
|
},
|
|
7596
8024
|
{
|
|
7597
8025
|
type: "number",
|
|
7598
|
-
key: "
|
|
7599
|
-
label: "Occlusion max
|
|
7600
|
-
description: "Extended coasting
|
|
8026
|
+
key: "occlusionMaxMissedMs",
|
|
8027
|
+
label: "Occlusion max coasting time",
|
|
8028
|
+
description: "Extended coasting time (ms) while a track is hidden behind another.",
|
|
7601
8029
|
min: 0,
|
|
7602
|
-
step:
|
|
7603
|
-
default: TRACKING_DEFAULTS.
|
|
8030
|
+
step: 100,
|
|
8031
|
+
default: TRACKING_DEFAULTS.occlusionMaxMissedMs,
|
|
8032
|
+
unit: "ms"
|
|
7604
8033
|
},
|
|
7605
8034
|
{
|
|
7606
8035
|
type: "boolean",
|