@camstack/addon-post-analysis 1.1.26 → 1.1.28
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-DytVmDZg.mjs → dist-Blpsv-M0.mjs} +1042 -56
- package/dist/{dist-BEx5ST1W.js → dist-Bnb58pyL.js} +1051 -89
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Cg_cGqs0.js → node-Cvhwrf43.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +107 -107
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-p-r6oXq8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZZpqsjV.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-C6LbOCa9.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DBgBsiUM.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-2UZIpU0W.mjs → hostInit-D-KSUwyU.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +2845 -129
- package/dist/pipeline-analytics/index.mjs +2841 -125
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -19
- package/dist/enrichment-engine/index.js +0 -857
- package/dist/enrichment-engine/index.mjs +0 -852
- package/dist/resolve-frame-CT1T1tWy.mjs +0 -44
- package/dist/resolve-frame-Cbm_NFuq.js +0 -57
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
|
|
3
|
-
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
4
|
-
import sharp from "sharp";
|
|
1
|
+
import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-Blpsv-M0.mjs";
|
|
5
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
6
5
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
7
6
|
var SOURCE = "analytics";
|
|
8
7
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -261,6 +260,7 @@ var FaceGalleryProvider = class {
|
|
|
261
260
|
trackStore;
|
|
262
261
|
eventStore;
|
|
263
262
|
refreshGallery;
|
|
263
|
+
emitFaceGalleryChanged;
|
|
264
264
|
constructor(deps) {
|
|
265
265
|
this.identityStore = deps.identityStore;
|
|
266
266
|
this.faceStore = deps.faceStore;
|
|
@@ -268,6 +268,13 @@ var FaceGalleryProvider = class {
|
|
|
268
268
|
this.trackStore = deps.trackStore;
|
|
269
269
|
this.eventStore = deps.eventStore;
|
|
270
270
|
this.refreshGallery = deps.refreshGallery;
|
|
271
|
+
this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
|
|
272
|
+
}
|
|
273
|
+
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
274
|
+
safeEmitFaceGalleryChanged(payload) {
|
|
275
|
+
try {
|
|
276
|
+
this.emitFaceGalleryChanged(payload);
|
|
277
|
+
} catch {}
|
|
271
278
|
}
|
|
272
279
|
async listIdentities() {
|
|
273
280
|
const rows = await this.identityStore.listIdentities();
|
|
@@ -486,6 +493,11 @@ var FaceGalleryProvider = class {
|
|
|
486
493
|
}, currentFace.trackId);
|
|
487
494
|
}
|
|
488
495
|
this.refreshGallery();
|
|
496
|
+
this.safeEmitFaceGalleryChanged({
|
|
497
|
+
deviceId: currentFace.deviceId,
|
|
498
|
+
faceId,
|
|
499
|
+
kind: "assigned"
|
|
500
|
+
});
|
|
489
501
|
}
|
|
490
502
|
/**
|
|
491
503
|
* Unassign a buffered face from its identity:
|
|
@@ -516,6 +528,11 @@ var FaceGalleryProvider = class {
|
|
|
516
528
|
await this.eventStore.clearLabelForTrack(face.trackId);
|
|
517
529
|
await this.trackStore.clearLabel(face.trackId);
|
|
518
530
|
this.refreshGallery();
|
|
531
|
+
this.safeEmitFaceGalleryChanged({
|
|
532
|
+
deviceId: face.deviceId,
|
|
533
|
+
faceId,
|
|
534
|
+
kind: "unassigned"
|
|
535
|
+
});
|
|
519
536
|
}
|
|
520
537
|
/**
|
|
521
538
|
* Delete a buffered face entirely (row + its crop media). If the face is
|
|
@@ -529,6 +546,11 @@ var FaceGalleryProvider = class {
|
|
|
529
546
|
await this.mediaStore.deleteForOwner("face", [input.faceId]);
|
|
530
547
|
await this.faceStore.delete(input.faceId);
|
|
531
548
|
this.refreshGallery();
|
|
549
|
+
this.safeEmitFaceGalleryChanged({
|
|
550
|
+
deviceId: face.deviceId,
|
|
551
|
+
faceId: input.faceId,
|
|
552
|
+
kind: "deleted"
|
|
553
|
+
});
|
|
532
554
|
}
|
|
533
555
|
/** Batch-assign many faces to one identity. Per-face failures are collected,
|
|
534
556
|
* not thrown, so one bad face doesn't abort the rest. */
|
|
@@ -686,10 +708,26 @@ function clusterByText(items, maxDistance = 1) {
|
|
|
686
708
|
//#region src/pipeline-analytics/plate-gallery-provider.ts
|
|
687
709
|
var PlateGalleryProvider = class {
|
|
688
710
|
plateStore;
|
|
711
|
+
vehicleStore;
|
|
689
712
|
mediaStore;
|
|
713
|
+
trackStore;
|
|
714
|
+
eventStore;
|
|
715
|
+
refreshGallery;
|
|
716
|
+
emitPlateGalleryChanged;
|
|
690
717
|
constructor(deps) {
|
|
691
718
|
this.plateStore = deps.plateStore;
|
|
719
|
+
this.vehicleStore = deps.vehicleStore;
|
|
692
720
|
this.mediaStore = deps.mediaStore;
|
|
721
|
+
this.trackStore = deps.trackStore;
|
|
722
|
+
this.eventStore = deps.eventStore;
|
|
723
|
+
this.refreshGallery = deps.refreshGallery;
|
|
724
|
+
this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
|
|
725
|
+
}
|
|
726
|
+
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
727
|
+
safeEmitPlateGalleryChanged(payload) {
|
|
728
|
+
try {
|
|
729
|
+
this.emitPlateGalleryChanged(payload);
|
|
730
|
+
} catch {}
|
|
693
731
|
}
|
|
694
732
|
async plateCropBase64(plate) {
|
|
695
733
|
if (plate.mediaKey) {
|
|
@@ -699,8 +737,9 @@ var PlateGalleryProvider = class {
|
|
|
699
737
|
const media = await this.mediaStore.listByOwner("plate", plate.id);
|
|
700
738
|
return media.length > 0 ? media[0].base64 : void 0;
|
|
701
739
|
}
|
|
702
|
-
async toPlateInfo(plate) {
|
|
740
|
+
async toPlateInfo(plate, nameMap) {
|
|
703
741
|
const base64 = await this.plateCropBase64(plate);
|
|
742
|
+
const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
|
|
704
743
|
return {
|
|
705
744
|
plateId: plate.id,
|
|
706
745
|
deviceId: plate.deviceId,
|
|
@@ -709,20 +748,31 @@ var PlateGalleryProvider = class {
|
|
|
709
748
|
text: plate.text,
|
|
710
749
|
score: plate.score,
|
|
711
750
|
corrected: plate.corrected,
|
|
751
|
+
assigned: plate.assigned,
|
|
752
|
+
...plate.recognizedVehicleId != null ? { recognizedVehicleId: plate.recognizedVehicleId } : {},
|
|
753
|
+
...vehicleName !== void 0 ? { vehicleName } : {},
|
|
754
|
+
...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
|
|
755
|
+
...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
|
|
712
756
|
...base64 !== void 0 ? { base64 } : {}
|
|
713
757
|
};
|
|
714
758
|
}
|
|
759
|
+
async vehicleNameMap() {
|
|
760
|
+
const vehicles = await this.vehicleStore.listVehicles();
|
|
761
|
+
return new Map(vehicles.map((v) => [v.id, v.name]));
|
|
762
|
+
}
|
|
715
763
|
async listPlates(input) {
|
|
716
764
|
const rows = await this.plateStore.listRecentPlates({
|
|
717
765
|
...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
718
766
|
...input?.limit !== void 0 ? { limit: input.limit } : {}
|
|
719
767
|
});
|
|
720
|
-
|
|
768
|
+
const nameMap = await this.vehicleNameMap();
|
|
769
|
+
return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap)));
|
|
721
770
|
}
|
|
722
771
|
async getPlateByTrack(input) {
|
|
723
772
|
const plate = await this.plateStore.get(`plate-${input.trackId}`);
|
|
724
773
|
if (!plate || plate.deviceId !== input.deviceId) return null;
|
|
725
|
-
|
|
774
|
+
const nameMap = await this.vehicleNameMap();
|
|
775
|
+
return this.toPlateInfo(plate, nameMap);
|
|
726
776
|
}
|
|
727
777
|
async getPlateMedia(input) {
|
|
728
778
|
return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
|
|
@@ -740,7 +790,8 @@ var PlateGalleryProvider = class {
|
|
|
740
790
|
dist: plateDistance(input.text, p.text)
|
|
741
791
|
})).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
|
|
742
792
|
const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
|
|
743
|
-
|
|
793
|
+
const nameMap = await this.vehicleNameMap();
|
|
794
|
+
return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap)));
|
|
744
795
|
}
|
|
745
796
|
async suggestPlateClusters(input) {
|
|
746
797
|
const maxDistance = input?.maxDistance ?? 1;
|
|
@@ -759,8 +810,203 @@ var PlateGalleryProvider = class {
|
|
|
759
810
|
});
|
|
760
811
|
}
|
|
761
812
|
async deletePlate(input) {
|
|
813
|
+
const plate = await this.plateStore.get(input.plateId);
|
|
814
|
+
if (plate?.assigned) await this.unassignPlate({ plateId: input.plateId });
|
|
762
815
|
await this.plateStore.delete(input.plateId);
|
|
763
816
|
await this.mediaStore.deleteForOwner("plate", [input.plateId]);
|
|
817
|
+
this.refreshGallery();
|
|
818
|
+
if (plate) this.safeEmitPlateGalleryChanged({
|
|
819
|
+
deviceId: plate.deviceId,
|
|
820
|
+
plateId: input.plateId,
|
|
821
|
+
kind: "deleted"
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async listVehicles() {
|
|
825
|
+
const rows = await this.vehicleStore.listVehicles();
|
|
826
|
+
const result = [];
|
|
827
|
+
for (const r of rows) {
|
|
828
|
+
let coverKey = r.coverMediaKey ?? void 0;
|
|
829
|
+
if (!coverKey) coverKey = (await this.vehicleStore.listSamples(r.id)).find((s) => s.mediaKey)?.mediaKey;
|
|
830
|
+
let coverBase64;
|
|
831
|
+
if (coverKey) {
|
|
832
|
+
const media = await this.mediaStore.getByKey(coverKey);
|
|
833
|
+
if (media) coverBase64 = media.base64;
|
|
834
|
+
}
|
|
835
|
+
result.push({
|
|
836
|
+
id: r.id,
|
|
837
|
+
name: r.name,
|
|
838
|
+
sampleCount: r.sampleCount,
|
|
839
|
+
...coverKey != null ? { coverMediaKey: coverKey } : {},
|
|
840
|
+
...coverBase64 !== void 0 ? { coverBase64 } : {}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
async createVehicle(input) {
|
|
846
|
+
const raw = await this.vehicleStore.createVehicle({ name: input.name });
|
|
847
|
+
this.refreshGallery();
|
|
848
|
+
return {
|
|
849
|
+
id: raw.id,
|
|
850
|
+
name: raw.name,
|
|
851
|
+
sampleCount: raw.sampleCount,
|
|
852
|
+
...raw.coverMediaKey !== void 0 ? { coverMediaKey: raw.coverMediaKey } : {}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
async renameVehicle(input) {
|
|
856
|
+
await this.vehicleStore.renameVehicle(input.id, input.name);
|
|
857
|
+
}
|
|
858
|
+
async deleteVehicle(input) {
|
|
859
|
+
const plates = await this.plateStore.listAllRecentPlates({});
|
|
860
|
+
for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
|
|
861
|
+
await this.mediaStore.deleteForOwner("vehicle", [input.id]);
|
|
862
|
+
await this.vehicleStore.deleteVehicle(input.id);
|
|
863
|
+
this.refreshGallery();
|
|
864
|
+
}
|
|
865
|
+
async listVehicleSamples(input) {
|
|
866
|
+
const rows = await this.vehicleStore.listSamples(input.vehicleId);
|
|
867
|
+
const result = [];
|
|
868
|
+
for (const row of rows) {
|
|
869
|
+
let base64;
|
|
870
|
+
if (row.mediaKey) {
|
|
871
|
+
const media = await this.mediaStore.getByKey(row.mediaKey);
|
|
872
|
+
if (media) base64 = media.base64;
|
|
873
|
+
}
|
|
874
|
+
result.push({
|
|
875
|
+
id: row.id,
|
|
876
|
+
text: row.text,
|
|
877
|
+
score: row.score,
|
|
878
|
+
addedAt: row.addedAt,
|
|
879
|
+
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
880
|
+
...base64 !== void 0 ? { base64 } : {}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
async removeVehicleSample(input) {
|
|
886
|
+
const sample = (await this.vehicleStore.listSamples(input.vehicleId)).find((s) => s.id === input.sampleId);
|
|
887
|
+
if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
|
|
888
|
+
await this.vehicleStore.removeSample(input.vehicleId, input.sampleId);
|
|
889
|
+
}
|
|
890
|
+
async assignPlate(input) {
|
|
891
|
+
const { plateId, vehicleId } = input;
|
|
892
|
+
const plate = await this.plateStore.get(plateId);
|
|
893
|
+
if (!plate) throw new Error(`PlateGalleryProvider.assignPlate: plate not found: ${plateId}`);
|
|
894
|
+
if (plate.assignedSampleId !== void 0 && plate.recognizedVehicleId !== void 0) {
|
|
895
|
+
await this.removeVehicleSample({
|
|
896
|
+
vehicleId: plate.recognizedVehicleId,
|
|
897
|
+
sampleId: plate.assignedSampleId
|
|
898
|
+
});
|
|
899
|
+
await this.plateStore.clearAssignment(plateId);
|
|
900
|
+
await this.plateStore.update(plateId, { mediaKey: null });
|
|
901
|
+
}
|
|
902
|
+
const current = await this.plateStore.get(plateId);
|
|
903
|
+
if (!current) throw new Error(`PlateGalleryProvider.assignPlate: plate disappeared: ${plateId}`);
|
|
904
|
+
let newKey;
|
|
905
|
+
if (current.mediaKey) {
|
|
906
|
+
newKey = await this.mediaStore.reown({
|
|
907
|
+
fromOwnerKind: "plate",
|
|
908
|
+
fromOwnerId: plateId,
|
|
909
|
+
toOwnerKind: "vehicle",
|
|
910
|
+
toOwnerId: vehicleId,
|
|
911
|
+
key: current.mediaKey
|
|
912
|
+
});
|
|
913
|
+
await this.plateStore.update(plateId, { mediaKey: newKey });
|
|
914
|
+
}
|
|
915
|
+
let sampleId;
|
|
916
|
+
try {
|
|
917
|
+
sampleId = (await this.vehicleStore.addSample({
|
|
918
|
+
vehicleId,
|
|
919
|
+
text: current.text,
|
|
920
|
+
score: current.score,
|
|
921
|
+
sourcePlateId: plateId,
|
|
922
|
+
...newKey !== void 0 ? { mediaKey: newKey } : {},
|
|
923
|
+
...current.deviceId !== void 0 ? { deviceId: current.deviceId } : {}
|
|
924
|
+
})).id;
|
|
925
|
+
await this.plateStore.markAssigned(plateId, vehicleId, sampleId);
|
|
926
|
+
} catch (err) {
|
|
927
|
+
if (newKey !== void 0) try {
|
|
928
|
+
await this.mediaStore.reown({
|
|
929
|
+
fromOwnerKind: "vehicle",
|
|
930
|
+
fromOwnerId: vehicleId,
|
|
931
|
+
toOwnerKind: "plate",
|
|
932
|
+
toOwnerId: plateId,
|
|
933
|
+
key: newKey
|
|
934
|
+
});
|
|
935
|
+
} catch {}
|
|
936
|
+
throw err;
|
|
937
|
+
}
|
|
938
|
+
const vehicle = (await this.vehicleStore.listVehicles()).find((v) => v.id === vehicleId);
|
|
939
|
+
if (vehicle?.name !== void 0) {
|
|
940
|
+
await this.trackStore.setLabel(current.trackId, vehicle.name);
|
|
941
|
+
await this.eventStore.setLabelForTrack(current.trackId, vehicle.name);
|
|
942
|
+
await recomputeTrackImportance({
|
|
943
|
+
trackStore: this.trackStore,
|
|
944
|
+
eventStore: this.eventStore
|
|
945
|
+
}, current.trackId);
|
|
946
|
+
}
|
|
947
|
+
this.refreshGallery();
|
|
948
|
+
this.safeEmitPlateGalleryChanged({
|
|
949
|
+
deviceId: current.deviceId,
|
|
950
|
+
plateId,
|
|
951
|
+
kind: "assigned"
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
async unassignPlate(input) {
|
|
955
|
+
const { plateId } = input;
|
|
956
|
+
const plate = await this.plateStore.get(plateId);
|
|
957
|
+
if (!plate) throw new Error(`PlateGalleryProvider.unassignPlate: plate not found: ${plateId}`);
|
|
958
|
+
const owningVehicleId = plate.recognizedVehicleId;
|
|
959
|
+
if (plate.mediaKey && owningVehicleId !== void 0) try {
|
|
960
|
+
const restoredKey = await this.mediaStore.reown({
|
|
961
|
+
fromOwnerKind: "vehicle",
|
|
962
|
+
fromOwnerId: owningVehicleId,
|
|
963
|
+
toOwnerKind: "plate",
|
|
964
|
+
toOwnerId: plateId,
|
|
965
|
+
key: plate.mediaKey
|
|
966
|
+
});
|
|
967
|
+
await this.plateStore.update(plateId, { mediaKey: restoredKey });
|
|
968
|
+
} catch {}
|
|
969
|
+
if (plate.assignedSampleId !== void 0 && owningVehicleId !== void 0) await this.vehicleStore.removeSample(owningVehicleId, plate.assignedSampleId);
|
|
970
|
+
await this.plateStore.clearAssignment(plateId);
|
|
971
|
+
await this.eventStore.clearLabelForTrack(plate.trackId);
|
|
972
|
+
await this.trackStore.clearLabel(plate.trackId);
|
|
973
|
+
this.refreshGallery();
|
|
974
|
+
this.safeEmitPlateGalleryChanged({
|
|
975
|
+
deviceId: plate.deviceId,
|
|
976
|
+
plateId,
|
|
977
|
+
kind: "unassigned"
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
async assignPlates(input) {
|
|
981
|
+
let assigned = 0;
|
|
982
|
+
const failed = [];
|
|
983
|
+
for (const plateId of input.plateIds) try {
|
|
984
|
+
await this.assignPlate({
|
|
985
|
+
plateId,
|
|
986
|
+
vehicleId: input.vehicleId
|
|
987
|
+
});
|
|
988
|
+
assigned += 1;
|
|
989
|
+
} catch {
|
|
990
|
+
failed.push(plateId);
|
|
991
|
+
}
|
|
992
|
+
return {
|
|
993
|
+
assigned,
|
|
994
|
+
failed
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
async unassignPlates(input) {
|
|
998
|
+
let unassigned = 0;
|
|
999
|
+
const failed = [];
|
|
1000
|
+
for (const plateId of input.plateIds) try {
|
|
1001
|
+
await this.unassignPlate({ plateId });
|
|
1002
|
+
unassigned += 1;
|
|
1003
|
+
} catch {
|
|
1004
|
+
failed.push(plateId);
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
unassigned,
|
|
1008
|
+
failed
|
|
1009
|
+
};
|
|
764
1010
|
}
|
|
765
1011
|
};
|
|
766
1012
|
//#endregion
|
|
@@ -848,7 +1094,6 @@ var DEFAULT_TRACKER_CONFIG = {
|
|
|
848
1094
|
stationarySpeedPx: 2
|
|
849
1095
|
};
|
|
850
1096
|
var MAX_PATH_LENGTH = 300;
|
|
851
|
-
var nextTrackId = 1;
|
|
852
1097
|
function clamp(value, min, max) {
|
|
853
1098
|
return Math.max(min, Math.min(max, value));
|
|
854
1099
|
}
|
|
@@ -1055,7 +1300,7 @@ var SortTracker = class {
|
|
|
1055
1300
|
const det = detections[di];
|
|
1056
1301
|
if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
|
|
1057
1302
|
surviving.push({
|
|
1058
|
-
id:
|
|
1303
|
+
id: randomUUID(),
|
|
1059
1304
|
bbox: det.bbox,
|
|
1060
1305
|
class: det.class,
|
|
1061
1306
|
originalClass: det.originalClass,
|
|
@@ -1521,7 +1766,9 @@ var FrameProcessor = class {
|
|
|
1521
1766
|
const labelsByBbox = /* @__PURE__ */ new Map();
|
|
1522
1767
|
const embeddingByBbox = /* @__PURE__ */ new Map();
|
|
1523
1768
|
const firstLevelBboxById = /* @__PURE__ */ new Map();
|
|
1769
|
+
const sourceIdByBbox = /* @__PURE__ */ new Map();
|
|
1524
1770
|
const faceBboxByBbox = /* @__PURE__ */ new Map();
|
|
1771
|
+
const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
|
|
1525
1772
|
const plateByBbox = /* @__PURE__ */ new Map();
|
|
1526
1773
|
const maskByBbox = /* @__PURE__ */ new Map();
|
|
1527
1774
|
const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
|
|
@@ -1548,6 +1795,7 @@ var FrameProcessor = class {
|
|
|
1548
1795
|
height: det.maskHeight
|
|
1549
1796
|
});
|
|
1550
1797
|
firstLevelBboxById.set(det.id, bbox);
|
|
1798
|
+
sourceIdByBbox.set(bbox, det.id);
|
|
1551
1799
|
return {
|
|
1552
1800
|
...bbox,
|
|
1553
1801
|
detection,
|
|
@@ -1564,6 +1812,7 @@ var FrameProcessor = class {
|
|
|
1564
1812
|
w: det.bbox.width,
|
|
1565
1813
|
h: det.bbox.height
|
|
1566
1814
|
});
|
|
1815
|
+
if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
|
|
1567
1816
|
if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
|
|
1568
1817
|
embedding: det.embedding,
|
|
1569
1818
|
...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
|
|
@@ -1606,9 +1855,12 @@ var FrameProcessor = class {
|
|
|
1606
1855
|
});
|
|
1607
1856
|
const emb = embeddingByBbox.get(td.bbox);
|
|
1608
1857
|
const faceBbox = faceBboxByBbox.get(td.bbox);
|
|
1858
|
+
const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
|
|
1609
1859
|
const plate = plateByBbox.get(td.bbox);
|
|
1860
|
+
const sourceDetectionId = sourceIdByBbox.get(td.bbox);
|
|
1610
1861
|
return {
|
|
1611
1862
|
trackId: td.trackId,
|
|
1863
|
+
...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
|
|
1612
1864
|
className: td.class,
|
|
1613
1865
|
confidence: td.score,
|
|
1614
1866
|
bbox: { ...td.bbox },
|
|
@@ -1620,6 +1872,7 @@ var FrameProcessor = class {
|
|
|
1620
1872
|
...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
|
|
1621
1873
|
} : {},
|
|
1622
1874
|
...faceBbox !== void 0 ? { faceBbox } : {},
|
|
1875
|
+
...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
|
|
1623
1876
|
...plate !== void 0 ? {
|
|
1624
1877
|
plateText: plate.text,
|
|
1625
1878
|
plateScore: plate.score,
|
|
@@ -1670,6 +1923,84 @@ var FrameProcessor = class {
|
|
|
1670
1923
|
};
|
|
1671
1924
|
}
|
|
1672
1925
|
};
|
|
1926
|
+
var DEFAULT_UPDATE_GATE_CONFIG = {
|
|
1927
|
+
minConfDelta: .1,
|
|
1928
|
+
minIntervalMs: 4e3,
|
|
1929
|
+
minCropAreaGrowth: .15
|
|
1930
|
+
};
|
|
1931
|
+
/**
|
|
1932
|
+
* Decide whether a `phase:'update'` event should fire this frame.
|
|
1933
|
+
*
|
|
1934
|
+
* Priority of triggers: newly-resolved identity/plate > confidence
|
|
1935
|
+
* improvement > materially larger crop. All triggers are debounced by
|
|
1936
|
+
* `minIntervalMs` relative to the last emit. Deltas are measured against
|
|
1937
|
+
* the LAST-EMITTED memory, so a run of sub-threshold new-bests accumulates
|
|
1938
|
+
* and fires once it crosses `minConfDelta`.
|
|
1939
|
+
*/
|
|
1940
|
+
function evaluateTrackLifecycleUpdate(signal, memory, config) {
|
|
1941
|
+
const identityTrigger = signal.label !== void 0 && signal.label !== "" && (memory === void 0 || memory.lastLabel !== signal.label);
|
|
1942
|
+
const confidenceTrigger = signal.isNewBest && (memory === void 0 || signal.confidence - memory.lastConfidence >= config.minConfDelta);
|
|
1943
|
+
const cropTrigger = signal.bboxArea !== void 0 && memory?.lastBboxArea !== void 0 && memory.lastBboxArea > 0 && signal.bboxArea >= memory.lastBboxArea * (1 + config.minCropAreaGrowth);
|
|
1944
|
+
const reason = identityTrigger ? "identity" : confidenceTrigger ? "confidence" : cropTrigger ? "crop" : void 0;
|
|
1945
|
+
const keptMemory = memory ?? {
|
|
1946
|
+
lastConfidence: signal.confidence,
|
|
1947
|
+
lastEmitAt: signal.now
|
|
1948
|
+
};
|
|
1949
|
+
if (reason === void 0) return {
|
|
1950
|
+
emit: false,
|
|
1951
|
+
memory: keptMemory
|
|
1952
|
+
};
|
|
1953
|
+
if (memory !== void 0 && signal.now - memory.lastEmitAt < config.minIntervalMs) return {
|
|
1954
|
+
emit: false,
|
|
1955
|
+
memory
|
|
1956
|
+
};
|
|
1957
|
+
const nextLabel = signal.label ?? memory?.lastLabel;
|
|
1958
|
+
const nextBboxArea = signal.bboxArea ?? memory?.lastBboxArea;
|
|
1959
|
+
return {
|
|
1960
|
+
emit: true,
|
|
1961
|
+
reason,
|
|
1962
|
+
memory: {
|
|
1963
|
+
lastConfidence: memory === void 0 ? signal.confidence : Math.max(memory.lastConfidence, signal.confidence),
|
|
1964
|
+
lastEmitAt: signal.now,
|
|
1965
|
+
...nextLabel !== void 0 ? { lastLabel: nextLabel } : {},
|
|
1966
|
+
...nextBboxArea !== void 0 ? { lastBboxArea: nextBboxArea } : {}
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Assemble the typed lifecycle payload. Optional fields (and the whole
|
|
1972
|
+
* `media` object) are omitted when absent so consumers get a clean shape.
|
|
1973
|
+
*/
|
|
1974
|
+
function buildTrackLifecyclePayload(input) {
|
|
1975
|
+
const media = {
|
|
1976
|
+
...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {},
|
|
1977
|
+
...input.bestCropMediaKey !== void 0 ? { bestCropMediaKey: input.bestCropMediaKey } : {},
|
|
1978
|
+
...input.bestEventId !== void 0 ? { bestEventId: input.bestEventId } : {}
|
|
1979
|
+
};
|
|
1980
|
+
const hasMedia = Object.keys(media).length > 0;
|
|
1981
|
+
return {
|
|
1982
|
+
deviceId: input.deviceId,
|
|
1983
|
+
trackId: input.trackId,
|
|
1984
|
+
phase: input.phase,
|
|
1985
|
+
classes: [...input.classes],
|
|
1986
|
+
bestClassName: input.bestClassName,
|
|
1987
|
+
bestConfidence: input.bestConfidence,
|
|
1988
|
+
firstSeen: input.firstSeen,
|
|
1989
|
+
lastSeen: input.lastSeen,
|
|
1990
|
+
durationMs: Math.max(0, input.lastSeen - input.firstSeen),
|
|
1991
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
1992
|
+
...input.identityId !== void 0 ? { identityId: input.identityId } : {},
|
|
1993
|
+
...input.plateText !== void 0 ? { plateText: input.plateText } : {},
|
|
1994
|
+
...input.zonesVisited !== void 0 ? { zonesVisited: [...input.zonesVisited] } : {},
|
|
1995
|
+
...input.totalDistance !== void 0 ? { totalDistance: input.totalDistance } : {},
|
|
1996
|
+
...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
|
|
1997
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
1998
|
+
...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
|
|
1999
|
+
...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
|
|
2000
|
+
...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
|
|
2001
|
+
...hasMedia ? { media } : {}
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
1673
2004
|
//#endregion
|
|
1674
2005
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1675
2006
|
var BestDetectionTracker = class {
|
|
@@ -1874,6 +2205,50 @@ function nativeDetectionsToFrame(input) {
|
|
|
1874
2205
|
};
|
|
1875
2206
|
}
|
|
1876
2207
|
//#endregion
|
|
2208
|
+
//#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
|
|
2209
|
+
/**
|
|
2210
|
+
* Extensible whole-track deletion cascade. Runs every registered
|
|
2211
|
+
* `TrackScopedStore` (each removes its ephemeral rows + owned media for the
|
|
2212
|
+
* tracks), THEN deletes the track ROOT rows LAST.
|
|
2213
|
+
*
|
|
2214
|
+
* ORDER MATTERS: every leaf store first, the track row last. The intended
|
|
2215
|
+
* production registry (leaves first, root last):
|
|
2216
|
+
*
|
|
2217
|
+
* [ eventStore, // object events + their event-owned media
|
|
2218
|
+
* mediaStore, // track + unassigned face/plate crops (NEVER identity/vehicle)
|
|
2219
|
+
* faceStore, // unassigned face rows (assigned/enrolled exempt)
|
|
2220
|
+
* plateStore, // unassigned plate reads (assigned/enrolled exempt)
|
|
2221
|
+
* objectEmbeddingStore, // per-track CLIP search vectors
|
|
2222
|
+
* ]
|
|
2223
|
+
*/
|
|
2224
|
+
async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
2225
|
+
for (const store of registry) await store.deleteByTracks(trackIds);
|
|
2226
|
+
for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Widened batch whole-track deletion — the ONE engine behind the three §5 entry
|
|
2230
|
+
* points. Runs the FULL registry cascade per track (via `cascadeDeleteTracks`
|
|
2231
|
+
* with a single-id list so the track root is deleted last), fires cleanup on
|
|
2232
|
+
* success only, and collects the ids whose cascade threw. Per-track ISOLATED —
|
|
2233
|
+
* one bad id never aborts the rest of the batch.
|
|
2234
|
+
*/
|
|
2235
|
+
async function runTrackCascadeBatch(deps, trackIds) {
|
|
2236
|
+
let deleted = 0;
|
|
2237
|
+
const failed = [];
|
|
2238
|
+
for (const trackId of trackIds) try {
|
|
2239
|
+
await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
|
|
2240
|
+
deps.onTrackCleanup(deps.deviceId, trackId);
|
|
2241
|
+
deleted += 1;
|
|
2242
|
+
} catch (err) {
|
|
2243
|
+
deps.onFailure?.(trackId, err);
|
|
2244
|
+
failed.push(trackId);
|
|
2245
|
+
}
|
|
2246
|
+
return {
|
|
2247
|
+
deleted,
|
|
2248
|
+
failed
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
//#endregion
|
|
1877
2252
|
//#region src/pipeline-analytics/runtime/binding-cache.ts
|
|
1878
2253
|
var BindingCache = class {
|
|
1879
2254
|
api;
|
|
@@ -1979,6 +2354,10 @@ var TRACKS_COLUMNS = [
|
|
|
1979
2354
|
name: "zonesVisited",
|
|
1980
2355
|
type: "JSON"
|
|
1981
2356
|
},
|
|
2357
|
+
{
|
|
2358
|
+
name: "classes",
|
|
2359
|
+
type: "JSON"
|
|
2360
|
+
},
|
|
1982
2361
|
{
|
|
1983
2362
|
name: "totalDistance",
|
|
1984
2363
|
type: "REAL"
|
|
@@ -2027,6 +2406,7 @@ function cloneTrack(t) {
|
|
|
2027
2406
|
}
|
|
2028
2407
|
})),
|
|
2029
2408
|
zonesVisited: [...t.zonesVisited],
|
|
2409
|
+
classes: [...t.classes],
|
|
2030
2410
|
totalDistance: t.totalDistance,
|
|
2031
2411
|
state: t.state,
|
|
2032
2412
|
active: t.active,
|
|
@@ -2071,6 +2451,7 @@ var TrackStore = class {
|
|
|
2071
2451
|
existing.positions.push(params.position);
|
|
2072
2452
|
} else existing.positions.push(params.position);
|
|
2073
2453
|
for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
|
|
2454
|
+
if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
|
|
2074
2455
|
return existing;
|
|
2075
2456
|
}
|
|
2076
2457
|
const fresh = {
|
|
@@ -2083,6 +2464,7 @@ var TrackStore = class {
|
|
|
2083
2464
|
positions: [params.position],
|
|
2084
2465
|
snapshots: [],
|
|
2085
2466
|
zonesVisited: [...params.zones],
|
|
2467
|
+
classes: [params.className],
|
|
2086
2468
|
totalDistance: 0,
|
|
2087
2469
|
state: params.state,
|
|
2088
2470
|
active: true,
|
|
@@ -2225,6 +2607,92 @@ var TrackStore = class {
|
|
|
2225
2607
|
clearAll() {
|
|
2226
2608
|
this.active.clear();
|
|
2227
2609
|
}
|
|
2610
|
+
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2611
|
+
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2612
|
+
async deletePersisted(trackId) {
|
|
2613
|
+
await this.store.delete.mutate({
|
|
2614
|
+
collection: TRACKS_COLLECTION,
|
|
2615
|
+
key: trackId
|
|
2616
|
+
});
|
|
2617
|
+
this.active.delete(trackId);
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* A page of persisted track ids for a device whose `lastSeen < cutoffMs`
|
|
2621
|
+
* (exclusive), oldest first. Feeds the `pruneTracksBefore` drain loop (design
|
|
2622
|
+
* §5.1): the caller cascades the returned ids (which deletes their rows) and
|
|
2623
|
+
* calls again until an empty page. Backed by `idx_tracks_device_lastSeen`.
|
|
2624
|
+
*
|
|
2625
|
+
* Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
|
|
2626
|
+
* → strictly `< cutoffMs`. Best-effort — a query failure yields [].
|
|
2627
|
+
*/
|
|
2628
|
+
async listIdsBefore(deviceId, cutoffMs, limit) {
|
|
2629
|
+
try {
|
|
2630
|
+
return (await this.store.query.query({
|
|
2631
|
+
collection: TRACKS_COLLECTION,
|
|
2632
|
+
filter: {
|
|
2633
|
+
where: { deviceId },
|
|
2634
|
+
whereBetween: { lastSeen: [0, cutoffMs - 1] },
|
|
2635
|
+
orderBy: {
|
|
2636
|
+
field: "lastSeen",
|
|
2637
|
+
direction: "asc"
|
|
2638
|
+
},
|
|
2639
|
+
limit
|
|
2640
|
+
}
|
|
2641
|
+
})).map((r) => r.id).filter((id) => typeof id === "string");
|
|
2642
|
+
} catch (err) {
|
|
2643
|
+
this.logger.warn("TrackStore.listIdsBefore failed", { meta: {
|
|
2644
|
+
deviceId,
|
|
2645
|
+
cutoffMs,
|
|
2646
|
+
error: String(err)
|
|
2647
|
+
} });
|
|
2648
|
+
return [];
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* The distinct set of deviceIds that currently have persisted tracks. Feeds
|
|
2653
|
+
* the per-device retention sweep (design §6) so a device that stopped
|
|
2654
|
+
* producing frames still has its aged track debris pruned. Paged scan +
|
|
2655
|
+
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
2656
|
+
*/
|
|
2657
|
+
async listDeviceIds() {
|
|
2658
|
+
const seenDevices = /* @__PURE__ */ new Set();
|
|
2659
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2660
|
+
const PAGE = 500;
|
|
2661
|
+
let cursor = 0;
|
|
2662
|
+
try {
|
|
2663
|
+
for (;;) {
|
|
2664
|
+
const rows = await this.store.query.query({
|
|
2665
|
+
collection: TRACKS_COLLECTION,
|
|
2666
|
+
filter: {
|
|
2667
|
+
whereBetween: { lastSeen: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
2668
|
+
orderBy: {
|
|
2669
|
+
field: "lastSeen",
|
|
2670
|
+
direction: "asc"
|
|
2671
|
+
},
|
|
2672
|
+
limit: PAGE
|
|
2673
|
+
}
|
|
2674
|
+
});
|
|
2675
|
+
if (rows.length === 0) break;
|
|
2676
|
+
let newInPage = 0;
|
|
2677
|
+
let maxLastSeen = cursor;
|
|
2678
|
+
for (const r of rows) {
|
|
2679
|
+
if (typeof r.id === "string" && !seenIds.has(r.id)) {
|
|
2680
|
+
seenIds.add(r.id);
|
|
2681
|
+
newInPage++;
|
|
2682
|
+
}
|
|
2683
|
+
const ls = Number(r.data["lastSeen"]);
|
|
2684
|
+
if (Number.isFinite(ls) && ls > maxLastSeen) maxLastSeen = ls;
|
|
2685
|
+
const deviceId = Number(r.data["deviceId"]);
|
|
2686
|
+
if (Number.isFinite(deviceId)) seenDevices.add(deviceId);
|
|
2687
|
+
}
|
|
2688
|
+
if (rows.length < PAGE || newInPage === 0) break;
|
|
2689
|
+
cursor = maxLastSeen;
|
|
2690
|
+
}
|
|
2691
|
+
} catch (err) {
|
|
2692
|
+
this.logger.warn("TrackStore.listDeviceIds failed", { meta: { error: String(err) } });
|
|
2693
|
+
}
|
|
2694
|
+
return [...seenDevices];
|
|
2695
|
+
}
|
|
2228
2696
|
/** Historical query — hits the persisted collection. */
|
|
2229
2697
|
async queryHistorical(params) {
|
|
2230
2698
|
const filter = { where: { deviceId: params.deviceId } };
|
|
@@ -2266,6 +2734,7 @@ var TrackStore = class {
|
|
|
2266
2734
|
positions: [...t.positions],
|
|
2267
2735
|
snapshots: [...t.snapshots],
|
|
2268
2736
|
zonesVisited: [...t.zonesVisited],
|
|
2737
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
2269
2738
|
totalDistance: t.totalDistance,
|
|
2270
2739
|
state: t.state,
|
|
2271
2740
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
@@ -2278,6 +2747,7 @@ var TrackStore = class {
|
|
|
2278
2747
|
const positions = data["positions"] ?? [];
|
|
2279
2748
|
const snapshots = data["snapshots"] ?? [];
|
|
2280
2749
|
const zones = data["zonesVisited"] ?? [];
|
|
2750
|
+
const classes = data["classes"];
|
|
2281
2751
|
const label = data["label"];
|
|
2282
2752
|
const importance = data["importance"];
|
|
2283
2753
|
const bestEventId = data["bestEventId"];
|
|
@@ -2292,6 +2762,7 @@ var TrackStore = class {
|
|
|
2292
2762
|
positions,
|
|
2293
2763
|
snapshots,
|
|
2294
2764
|
zonesVisited: zones,
|
|
2765
|
+
...classes !== null ? { classes } : {},
|
|
2295
2766
|
totalDistance: Number(data["totalDistance"] ?? 0),
|
|
2296
2767
|
state: data["state"] ?? "idle",
|
|
2297
2768
|
active: false,
|
|
@@ -2304,6 +2775,18 @@ var TrackStore = class {
|
|
|
2304
2775
|
//#endregion
|
|
2305
2776
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
2306
2777
|
var MEDIA_COLLECTION = "pipeline-analytics:media";
|
|
2778
|
+
/** Owner-id prefix for a track's buffered FACE crop. Invariant established by
|
|
2779
|
+
* the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
|
|
2780
|
+
* (one buffered face per track), so a track's face crops are owned by
|
|
2781
|
+
* `face-<trackId>`. Used by `deleteByTracks` to derive the face crop owners of
|
|
2782
|
+
* a set of tracks without a `trackId` column on media rows. */
|
|
2783
|
+
var FACE_MEDIA_OWNER_PREFIX = "face-";
|
|
2784
|
+
/** Owner-id prefix for a track's buffered PLATE crop. Invariant established by
|
|
2785
|
+
* the plate recognizer (`plate-recognizer.ts`): `plateId === 'plate-' + trackId`
|
|
2786
|
+
* (one buffered plate per track), so a track's plate crops are owned by
|
|
2787
|
+
* `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
|
|
2788
|
+
* of a set of tracks without a `trackId` column on media rows. */
|
|
2789
|
+
var PLATE_MEDIA_OWNER_PREFIX = "plate-";
|
|
2307
2790
|
var MEDIA_COLUMNS = [
|
|
2308
2791
|
{
|
|
2309
2792
|
name: "id",
|
|
@@ -2668,6 +3151,28 @@ var MediaStore = class {
|
|
|
2668
3151
|
async deleteForEvents(eventIds) {
|
|
2669
3152
|
return this.deleteForOwner("event", eventIds);
|
|
2670
3153
|
}
|
|
3154
|
+
/** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
|
|
3155
|
+
async deleteForTracks(trackIds) {
|
|
3156
|
+
return this.deleteForOwner("track", trackIds);
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
3160
|
+
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
3161
|
+
* the UNassigned face crops (`ownerKind:'face'`, owner `face-<trackId>`).
|
|
3162
|
+
*
|
|
3163
|
+
* NEVER deletes `ownerKind:'identity'` or `ownerKind:'vehicle'` media: an
|
|
3164
|
+
* ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
|
|
3165
|
+
* assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
|
|
3166
|
+
* crops and leaves the enrolled gallery intact. Event-owned media
|
|
3167
|
+
* (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
|
|
3168
|
+
* set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
|
|
3169
|
+
* batch.
|
|
3170
|
+
*/
|
|
3171
|
+
async deleteByTracks(trackIds) {
|
|
3172
|
+
await this.deleteForOwner("track", trackIds);
|
|
3173
|
+
await this.deleteForOwner("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
3174
|
+
await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
3175
|
+
}
|
|
2671
3176
|
/** Retention sweep: delete any media row + blob older than cutoff.
|
|
2672
3177
|
* Returns number of entries removed. */
|
|
2673
3178
|
async evictBefore(cutoffMs) {
|
|
@@ -2830,9 +3335,11 @@ var COMMON_INDEXES = (prefix) => [{
|
|
|
2830
3335
|
var EventStore = class {
|
|
2831
3336
|
store;
|
|
2832
3337
|
logger;
|
|
3338
|
+
media;
|
|
2833
3339
|
constructor(deps) {
|
|
2834
3340
|
this.store = deps.store;
|
|
2835
3341
|
this.logger = deps.logger;
|
|
3342
|
+
this.media = deps.media;
|
|
2836
3343
|
}
|
|
2837
3344
|
static async declare(store) {
|
|
2838
3345
|
await store.declareCollection.mutate({
|
|
@@ -3045,6 +3552,12 @@ var EventStore = class {
|
|
|
3045
3552
|
let bestEventId;
|
|
3046
3553
|
let peakBboxAreaFrac = 0;
|
|
3047
3554
|
for (const row of rows) {
|
|
3555
|
+
const bbox = row.data["bbox"];
|
|
3556
|
+
if (bbox !== null && typeof bbox === "object") {
|
|
3557
|
+
const bw = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
|
|
3558
|
+
const bh = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
|
|
3559
|
+
if (bw <= 0 || bh <= 0) continue;
|
|
3560
|
+
}
|
|
3048
3561
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
3049
3562
|
if (conf <= bestConf) continue;
|
|
3050
3563
|
bestConf = conf;
|
|
@@ -3234,6 +3747,72 @@ var EventStore = class {
|
|
|
3234
3747
|
]
|
|
3235
3748
|
};
|
|
3236
3749
|
}
|
|
3750
|
+
/**
|
|
3751
|
+
* Delete every OBJECT_EVENTS_COLLECTION row for a single track and RETURN the
|
|
3752
|
+
* deleted event ids so the caller can delete each event's media in lockstep
|
|
3753
|
+
* (the whole-track delete cascade). Motion/audio collections have no
|
|
3754
|
+
* `trackId` column and are never touched.
|
|
3755
|
+
*
|
|
3756
|
+
* The underlying settings-store `query` cap applies a default row limit
|
|
3757
|
+
* (~500). This drains a page at a time — repeatedly querying up to
|
|
3758
|
+
* `PRUNE_PAGE_SIZE` rows for the track and deleting them — until a page
|
|
3759
|
+
* returns 0 rows, so ALL of the track's events are removed regardless of
|
|
3760
|
+
* count. Mirrors the drain loop in `pruneBefore`, including the
|
|
3761
|
+
* infinite-loop guard (a non-empty page that deletes nothing stops the loop).
|
|
3762
|
+
*/
|
|
3763
|
+
async deleteByTrack(trackId) {
|
|
3764
|
+
const ids = [];
|
|
3765
|
+
for (;;) {
|
|
3766
|
+
const rows = await this.store.query.query({
|
|
3767
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3768
|
+
filter: {
|
|
3769
|
+
where: { trackId },
|
|
3770
|
+
limit: 500
|
|
3771
|
+
}
|
|
3772
|
+
});
|
|
3773
|
+
if (rows.length === 0) break;
|
|
3774
|
+
let deletedInPage = 0;
|
|
3775
|
+
for (const row of rows) {
|
|
3776
|
+
const id = row.id;
|
|
3777
|
+
if (typeof id !== "string") continue;
|
|
3778
|
+
try {
|
|
3779
|
+
await this.store.delete.mutate({
|
|
3780
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3781
|
+
key: id
|
|
3782
|
+
});
|
|
3783
|
+
ids.push(id);
|
|
3784
|
+
deletedInPage++;
|
|
3785
|
+
} catch {}
|
|
3786
|
+
}
|
|
3787
|
+
if (deletedInPage === 0) break;
|
|
3788
|
+
}
|
|
3789
|
+
return ids;
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* `TrackScopedStore` contract for the retention cascade: delete every object
|
|
3793
|
+
* event of the given tracks AND (folded in) the event-owned media of those
|
|
3794
|
+
* events, so a caller can never skip the media wipe. Motion/audio collections
|
|
3795
|
+
* carry no `trackId` and are untouched. Reuses the paged `deleteByTrack` drain
|
|
3796
|
+
* per track and is per-track ISOLATED — a failure on one track never aborts
|
|
3797
|
+
* the others (mirrors the best-effort contract in §4 of the design).
|
|
3798
|
+
*/
|
|
3799
|
+
async deleteByTracks(trackIds) {
|
|
3800
|
+
const eventIds = [];
|
|
3801
|
+
for (const trackId of trackIds) try {
|
|
3802
|
+
const ids = await this.deleteByTrack(trackId);
|
|
3803
|
+
eventIds.push(...ids);
|
|
3804
|
+
} catch (err) {
|
|
3805
|
+
this.logger.warn("EventStore.deleteByTracks: track failed", { meta: {
|
|
3806
|
+
trackId,
|
|
3807
|
+
error: String(err)
|
|
3808
|
+
} });
|
|
3809
|
+
}
|
|
3810
|
+
if (eventIds.length > 0 && this.media !== void 0) try {
|
|
3811
|
+
await this.media.deleteForEvents(eventIds);
|
|
3812
|
+
} catch (err) {
|
|
3813
|
+
this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3237
3816
|
};
|
|
3238
3817
|
function slimMotion(id, data) {
|
|
3239
3818
|
return {
|
|
@@ -3300,6 +3879,18 @@ function stripNulls(data) {
|
|
|
3300
3879
|
return out;
|
|
3301
3880
|
}
|
|
3302
3881
|
//#endregion
|
|
3882
|
+
//#region src/shared/frame/resolve-frame.ts
|
|
3883
|
+
/**
|
|
3884
|
+
* Resolve the pixels a `FrameHandle` refers to. Local shm read when the
|
|
3885
|
+
* handle's `nodeId` matches `deps.ownNodeId`, else routed via
|
|
3886
|
+
* `deps.getRemoteFrame`. Returns `null` when the frame is no longer
|
|
3887
|
+
* available (slot recycled locally, or the remote node reports no frame).
|
|
3888
|
+
*/
|
|
3889
|
+
async function resolveFrame(handle, deps) {
|
|
3890
|
+
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3891
|
+
return deps.getRemoteFrame(handle);
|
|
3892
|
+
}
|
|
3893
|
+
//#endregion
|
|
3303
3894
|
//#region src/shared/frame/square-safe-crop.ts
|
|
3304
3895
|
/**
|
|
3305
3896
|
* Compute a square-safe 16:9 crop region in pixel space.
|
|
@@ -3422,6 +4013,34 @@ function caption(className, confidence, label) {
|
|
|
3422
4013
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
3423
4014
|
}
|
|
3424
4015
|
/**
|
|
4016
|
+
* True when a packed-RGB frame is (near-)uniform in every channel — the
|
|
4017
|
+
* signature of a blank / hwaccel-unmapped decode frame. Samples a strided set
|
|
4018
|
+
* of pixels and checks each channel's min→max spread stays within a small
|
|
4019
|
+
* epsilon; a real image always has spread in at least one channel. Cheap
|
|
4020
|
+
* (~512 samples) so it runs on every capture.
|
|
4021
|
+
*/
|
|
4022
|
+
function isUniformRgbFrame(data, width, height) {
|
|
4023
|
+
const pixels = width * height;
|
|
4024
|
+
if (pixels === 0 || data.length < pixels * 3) return false;
|
|
4025
|
+
const step = Math.max(1, Math.floor(pixels / 512)) * 3;
|
|
4026
|
+
const mins = [
|
|
4027
|
+
255,
|
|
4028
|
+
255,
|
|
4029
|
+
255
|
|
4030
|
+
];
|
|
4031
|
+
const maxs = [
|
|
4032
|
+
0,
|
|
4033
|
+
0,
|
|
4034
|
+
0
|
|
4035
|
+
];
|
|
4036
|
+
for (let i = 0; i + 2 < data.length; i += step) for (let c = 0; c < 3; c++) {
|
|
4037
|
+
const v = data[i + c];
|
|
4038
|
+
mins[c] = Math.min(mins[c], v);
|
|
4039
|
+
maxs[c] = Math.max(maxs[c], v);
|
|
4040
|
+
}
|
|
4041
|
+
return maxs.every((mx, c) => mx - mins[c] <= 6);
|
|
4042
|
+
}
|
|
4043
|
+
/**
|
|
3425
4044
|
* Generates object-event + track media FROM the decoded detection-pipeline
|
|
3426
4045
|
* frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
|
|
3427
4046
|
* snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
|
|
@@ -3481,9 +4100,21 @@ var EventMediaDispatcher = class {
|
|
|
3481
4100
|
});
|
|
3482
4101
|
return empty;
|
|
3483
4102
|
}
|
|
3484
|
-
const frameData = Buffer.
|
|
4103
|
+
const frameData = Buffer.from(decoded.data);
|
|
3485
4104
|
const fw = decoded.width;
|
|
3486
4105
|
const fh = decoded.height;
|
|
4106
|
+
if (isUniformRgbFrame(frameData, fw, fh)) {
|
|
4107
|
+
this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
|
|
4108
|
+
tags: { deviceId },
|
|
4109
|
+
meta: {
|
|
4110
|
+
deviceId,
|
|
4111
|
+
shmId: frameHandle.shmId,
|
|
4112
|
+
width: fw,
|
|
4113
|
+
height: fh
|
|
4114
|
+
}
|
|
4115
|
+
});
|
|
4116
|
+
return empty;
|
|
4117
|
+
}
|
|
3487
4118
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
3488
4119
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3489
4120
|
const storedSnapshots = [];
|
|
@@ -3709,8 +4340,264 @@ var EventMediaDispatcher = class {
|
|
|
3709
4340
|
}
|
|
3710
4341
|
};
|
|
3711
4342
|
//#endregion
|
|
3712
|
-
//#region src/
|
|
3713
|
-
|
|
4343
|
+
//#region src/shared/frame/crop-extractor.ts
|
|
4344
|
+
/**
|
|
4345
|
+
* Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
|
|
4346
|
+
* Coordinates are clamped to frame bounds to avoid out-of-range errors.
|
|
4347
|
+
*/
|
|
4348
|
+
async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
4349
|
+
const rawLeft = Math.round(bbox.x * frameWidth);
|
|
4350
|
+
const rawTop = Math.round(bbox.y * frameHeight);
|
|
4351
|
+
const rawWidth = Math.round(bbox.w * frameWidth);
|
|
4352
|
+
const rawHeight = Math.round(bbox.h * frameHeight);
|
|
4353
|
+
const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
|
|
4354
|
+
const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
|
|
4355
|
+
const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
|
|
4356
|
+
const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
|
|
4357
|
+
return {
|
|
4358
|
+
crop: await sharp(frameData, { raw: {
|
|
4359
|
+
width: frameWidth,
|
|
4360
|
+
height: frameHeight,
|
|
4361
|
+
channels: 3
|
|
4362
|
+
} }).extract({
|
|
4363
|
+
left,
|
|
4364
|
+
top,
|
|
4365
|
+
width,
|
|
4366
|
+
height
|
|
4367
|
+
}).jpeg({ quality: 90 }).toBuffer(),
|
|
4368
|
+
width,
|
|
4369
|
+
height
|
|
4370
|
+
};
|
|
4371
|
+
}
|
|
4372
|
+
//#endregion
|
|
4373
|
+
//#region src/pipeline-analytics/embedding/embedding-dispatcher.ts
|
|
4374
|
+
function selectCropBbox(detection) {
|
|
4375
|
+
return detection.refinedBbox ?? detection.bbox;
|
|
4376
|
+
}
|
|
4377
|
+
var EmbeddingDispatcher = class {
|
|
4378
|
+
config;
|
|
4379
|
+
encoder;
|
|
4380
|
+
eventBus;
|
|
4381
|
+
logger;
|
|
4382
|
+
ownNodeId;
|
|
4383
|
+
readers;
|
|
4384
|
+
getRemoteFrame;
|
|
4385
|
+
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4386
|
+
pendingCrops = /* @__PURE__ */ new Map();
|
|
4387
|
+
flushTimer = null;
|
|
4388
|
+
unsubscribe = null;
|
|
4389
|
+
_processedCount = 0;
|
|
4390
|
+
_totalInferenceMs = 0;
|
|
4391
|
+
constructor(deps) {
|
|
4392
|
+
this.config = deps.config;
|
|
4393
|
+
this.encoder = deps.encoder;
|
|
4394
|
+
this.eventBus = deps.eventBus;
|
|
4395
|
+
this.logger = deps.logger;
|
|
4396
|
+
this.ownNodeId = deps.ownNodeId;
|
|
4397
|
+
this.readers = deps.readers;
|
|
4398
|
+
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4399
|
+
}
|
|
4400
|
+
async start() {
|
|
4401
|
+
if (!this.config.enabled) {
|
|
4402
|
+
this.logger.info("EmbeddingDispatcher disabled");
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
this.unsubscribe = this.eventBus.subscribe({ category: EventCategory.DetectionResult }, (event) => {
|
|
4406
|
+
this.handleDetectionResult(event);
|
|
4407
|
+
});
|
|
4408
|
+
if (this.config.cropStrategy === "best-confidence") this.flushTimer = setInterval(() => {
|
|
4409
|
+
this.flushPending();
|
|
4410
|
+
}, 1e3);
|
|
4411
|
+
this.logger.info("EmbeddingDispatcher started", { meta: {
|
|
4412
|
+
strategy: this.config.cropStrategy,
|
|
4413
|
+
maxPerSec: this.config.maxPerSecPerCamera
|
|
4414
|
+
} });
|
|
4415
|
+
}
|
|
4416
|
+
async stop() {
|
|
4417
|
+
this.unsubscribe?.();
|
|
4418
|
+
this.unsubscribe = null;
|
|
4419
|
+
if (this.flushTimer) {
|
|
4420
|
+
clearInterval(this.flushTimer);
|
|
4421
|
+
this.flushTimer = null;
|
|
4422
|
+
}
|
|
4423
|
+
await this.flushPending();
|
|
4424
|
+
}
|
|
4425
|
+
get processedCount() {
|
|
4426
|
+
return this._processedCount;
|
|
4427
|
+
}
|
|
4428
|
+
get avgInferenceMs() {
|
|
4429
|
+
return this._processedCount > 0 ? this._totalInferenceMs / this._processedCount : 0;
|
|
4430
|
+
}
|
|
4431
|
+
get queueDepth() {
|
|
4432
|
+
return this.pendingCrops.size;
|
|
4433
|
+
}
|
|
4434
|
+
async handleDetectionResult(event) {
|
|
4435
|
+
const data = event.data;
|
|
4436
|
+
const deviceId = event.source.id !== void 0 ? String(event.source.id) : "";
|
|
4437
|
+
if (!deviceId) return;
|
|
4438
|
+
const detections = data.analysisResults ?? [];
|
|
4439
|
+
const handle = data.frameHandle;
|
|
4440
|
+
if (!handle) {
|
|
4441
|
+
this.logger.debug("skip: no frameHandle on DetectionResult", {
|
|
4442
|
+
tags: { deviceId: Number(deviceId) },
|
|
4443
|
+
meta: { deviceId }
|
|
4444
|
+
});
|
|
4445
|
+
return;
|
|
4446
|
+
}
|
|
4447
|
+
let decoded;
|
|
4448
|
+
try {
|
|
4449
|
+
decoded = await resolveFrame(handle, {
|
|
4450
|
+
ownNodeId: this.ownNodeId,
|
|
4451
|
+
readers: this.readers,
|
|
4452
|
+
getRemoteFrame: this.getRemoteFrame
|
|
4453
|
+
});
|
|
4454
|
+
} catch (err) {
|
|
4455
|
+
this.logger.debug("skip: resolveFrame threw", {
|
|
4456
|
+
tags: { deviceId: Number(deviceId) },
|
|
4457
|
+
meta: {
|
|
4458
|
+
deviceId,
|
|
4459
|
+
shmId: handle.shmId,
|
|
4460
|
+
error: String(err)
|
|
4461
|
+
}
|
|
4462
|
+
});
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
if (!decoded) {
|
|
4466
|
+
this.logger.debug("skip: frame recycled before resolve", {
|
|
4467
|
+
tags: { deviceId: Number(deviceId) },
|
|
4468
|
+
meta: {
|
|
4469
|
+
deviceId,
|
|
4470
|
+
shmId: handle.shmId
|
|
4471
|
+
}
|
|
4472
|
+
});
|
|
4473
|
+
return;
|
|
4474
|
+
}
|
|
4475
|
+
if (decoded.format !== "rgb") {
|
|
4476
|
+
this.logger.debug("skip: resolved frame is not RGB", {
|
|
4477
|
+
tags: { deviceId: Number(deviceId) },
|
|
4478
|
+
meta: {
|
|
4479
|
+
deviceId,
|
|
4480
|
+
format: decoded.format
|
|
4481
|
+
}
|
|
4482
|
+
});
|
|
4483
|
+
return;
|
|
4484
|
+
}
|
|
4485
|
+
const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
|
|
4486
|
+
const frameWidth = decoded.width;
|
|
4487
|
+
const frameHeight = decoded.height;
|
|
4488
|
+
for (const det of detections) {
|
|
4489
|
+
const detection = det.detection;
|
|
4490
|
+
if (!detection) continue;
|
|
4491
|
+
if (this.config.classes.length > 0 && !this.config.classes.includes(detection.class)) continue;
|
|
4492
|
+
if (detection.score < this.config.minConfidence) continue;
|
|
4493
|
+
const now = Date.now();
|
|
4494
|
+
const minInterval = 1e3 / this.config.maxPerSecPerCamera;
|
|
4495
|
+
if (now - (this.lastEmbedTime.get(deviceId) ?? 0) < minInterval) continue;
|
|
4496
|
+
const trackId = detection.trackId ?? `${deviceId}-${now}`;
|
|
4497
|
+
const pending = {
|
|
4498
|
+
trackId,
|
|
4499
|
+
deviceId,
|
|
4500
|
+
class: detection.class,
|
|
4501
|
+
confidence: detection.score,
|
|
4502
|
+
frameData,
|
|
4503
|
+
frameWidth,
|
|
4504
|
+
frameHeight,
|
|
4505
|
+
bbox: selectCropBbox(detection),
|
|
4506
|
+
receivedAt: now
|
|
4507
|
+
};
|
|
4508
|
+
switch (this.config.cropStrategy) {
|
|
4509
|
+
case "first":
|
|
4510
|
+
if (!this.pendingCrops.has(trackId)) {
|
|
4511
|
+
this.pendingCrops.set(trackId, pending);
|
|
4512
|
+
this.processOne(pending);
|
|
4513
|
+
}
|
|
4514
|
+
break;
|
|
4515
|
+
case "best-confidence": {
|
|
4516
|
+
const existing = this.pendingCrops.get(trackId);
|
|
4517
|
+
if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
|
|
4518
|
+
break;
|
|
4519
|
+
}
|
|
4520
|
+
case "track-end":
|
|
4521
|
+
if (det.objectState?.state === "leaving") {
|
|
4522
|
+
this.pendingCrops.set(trackId, pending);
|
|
4523
|
+
this.processOne(pending);
|
|
4524
|
+
} else {
|
|
4525
|
+
const existing = this.pendingCrops.get(trackId);
|
|
4526
|
+
if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
|
|
4527
|
+
}
|
|
4528
|
+
break;
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
async flushPending() {
|
|
4533
|
+
const now = Date.now();
|
|
4534
|
+
const toFlush = [];
|
|
4535
|
+
for (const [trackId, pending] of this.pendingCrops) if (now - pending.receivedAt > 3e3) {
|
|
4536
|
+
toFlush.push(pending);
|
|
4537
|
+
this.pendingCrops.delete(trackId);
|
|
4538
|
+
}
|
|
4539
|
+
await Promise.all(toFlush.map((p) => this.processOne(p)));
|
|
4540
|
+
}
|
|
4541
|
+
async processOne(pending) {
|
|
4542
|
+
try {
|
|
4543
|
+
const { crop, width, height } = await extractCrop(pending.frameData, pending.frameWidth, pending.frameHeight, pending.bbox);
|
|
4544
|
+
const { embedding: _embedding, inferenceMs } = await this.encoder.encode(crop, width, height);
|
|
4545
|
+
const info = await this.encoder.getInfo();
|
|
4546
|
+
this._processedCount++;
|
|
4547
|
+
this._totalInferenceMs += inferenceMs;
|
|
4548
|
+
this.lastEmbedTime.set(pending.deviceId, Date.now());
|
|
4549
|
+
this.pendingCrops.delete(pending.trackId);
|
|
4550
|
+
const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
|
|
4551
|
+
const payload = {
|
|
4552
|
+
deviceId: Number(pending.deviceId),
|
|
4553
|
+
trackId: pending.trackId,
|
|
4554
|
+
class: pending.class,
|
|
4555
|
+
embeddingId,
|
|
4556
|
+
modelId: info.modelId,
|
|
4557
|
+
embeddingDim: info.embeddingDim,
|
|
4558
|
+
inferenceMs,
|
|
4559
|
+
timestamp: Date.now()
|
|
4560
|
+
};
|
|
4561
|
+
this.eventBus.emit(createEvent(EventCategory.EnrichmentEmbeddingStored, {
|
|
4562
|
+
type: "addon",
|
|
4563
|
+
id: "pipeline-analytics"
|
|
4564
|
+
}, payload));
|
|
4565
|
+
this.logger.debug("Embedded track", {
|
|
4566
|
+
tags: { deviceId: Number(pending.deviceId) },
|
|
4567
|
+
meta: {
|
|
4568
|
+
class: pending.class,
|
|
4569
|
+
trackId: pending.trackId,
|
|
4570
|
+
inferenceMs: Number(inferenceMs.toFixed(1))
|
|
4571
|
+
}
|
|
4572
|
+
});
|
|
4573
|
+
} catch (err) {
|
|
4574
|
+
this.logger.warn("Failed to embed track", {
|
|
4575
|
+
tags: { deviceId: Number(pending.deviceId) },
|
|
4576
|
+
meta: {
|
|
4577
|
+
trackId: pending.trackId,
|
|
4578
|
+
error: String(err)
|
|
4579
|
+
}
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
};
|
|
4584
|
+
//#endregion
|
|
4585
|
+
//#region src/pipeline-analytics/embedding/embedding-config.ts
|
|
4586
|
+
var DEFAULT_EMBEDDING_CONFIG = {
|
|
4587
|
+
enabled: true,
|
|
4588
|
+
modelId: "clip-vit-b32",
|
|
4589
|
+
agentId: "local",
|
|
4590
|
+
runtime: "node",
|
|
4591
|
+
backend: "cpu",
|
|
4592
|
+
classes: [],
|
|
4593
|
+
minConfidence: .5,
|
|
4594
|
+
maxPerSecPerCamera: 1,
|
|
4595
|
+
cropStrategy: "first",
|
|
4596
|
+
retentionDays: 30
|
|
4597
|
+
};
|
|
4598
|
+
//#endregion
|
|
4599
|
+
//#region src/pipeline-analytics/runtime/slice-throttler.ts
|
|
4600
|
+
var SliceThrottler = class {
|
|
3714
4601
|
opts;
|
|
3715
4602
|
lastWrittenAt = /* @__PURE__ */ new Map();
|
|
3716
4603
|
lastWritten = /* @__PURE__ */ new Map();
|
|
@@ -4585,6 +5472,56 @@ function resolveMediaSettings(raw) {
|
|
|
4585
5472
|
};
|
|
4586
5473
|
}
|
|
4587
5474
|
//#endregion
|
|
5475
|
+
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
5476
|
+
/**
|
|
5477
|
+
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
5478
|
+
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
5479
|
+
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
5480
|
+
* retention interval.
|
|
5481
|
+
*
|
|
5482
|
+
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
5483
|
+
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
5484
|
+
* cascade store layer (design §4), not here.
|
|
5485
|
+
*
|
|
5486
|
+
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
5487
|
+
* without booting the addon.
|
|
5488
|
+
*/
|
|
5489
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
5490
|
+
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
5491
|
+
var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
|
|
5492
|
+
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
5493
|
+
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
5494
|
+
* missing value falls back to the default (parse never throws). */
|
|
5495
|
+
function resolveTrackRetentionDays(raw) {
|
|
5496
|
+
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
5497
|
+
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
5498
|
+
}
|
|
5499
|
+
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
5500
|
+
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
5501
|
+
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
5502
|
+
if (retentionDays <= 0) return null;
|
|
5503
|
+
return nowMs - retentionDays * DAY_MS;
|
|
5504
|
+
}
|
|
5505
|
+
/**
|
|
5506
|
+
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
5507
|
+
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
5508
|
+
* never aborts the sweep. Returns the total tracks pruned across devices.
|
|
5509
|
+
*/
|
|
5510
|
+
async function sweepTrackRetention(deps) {
|
|
5511
|
+
const devices = await deps.listDeviceIds();
|
|
5512
|
+
const nowMs = deps.now();
|
|
5513
|
+
let totalTracks = 0;
|
|
5514
|
+
for (const deviceId of devices) try {
|
|
5515
|
+
const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
|
|
5516
|
+
if (cutoffMs === null) continue;
|
|
5517
|
+
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
5518
|
+
totalTracks += counts.tracks;
|
|
5519
|
+
} catch (err) {
|
|
5520
|
+
deps.onError?.(deviceId, err);
|
|
5521
|
+
}
|
|
5522
|
+
return totalTracks;
|
|
5523
|
+
}
|
|
5524
|
+
//#endregion
|
|
4588
5525
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
4589
5526
|
/**
|
|
4590
5527
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -4634,7 +5571,7 @@ var IDENTITY_COLUMNS = [
|
|
|
4634
5571
|
type: "TEXT"
|
|
4635
5572
|
}
|
|
4636
5573
|
];
|
|
4637
|
-
var SAMPLE_COLUMNS = [
|
|
5574
|
+
var SAMPLE_COLUMNS$1 = [
|
|
4638
5575
|
{
|
|
4639
5576
|
name: "id",
|
|
4640
5577
|
type: "TEXT",
|
|
@@ -4695,7 +5632,7 @@ var IdentityStore = class {
|
|
|
4695
5632
|
});
|
|
4696
5633
|
await store.declareCollection.mutate({
|
|
4697
5634
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
4698
|
-
columns: [...SAMPLE_COLUMNS],
|
|
5635
|
+
columns: [...SAMPLE_COLUMNS$1],
|
|
4699
5636
|
indexes: [{
|
|
4700
5637
|
name: "idx_sample_identity",
|
|
4701
5638
|
columns: ["identityId"]
|
|
@@ -5130,6 +6067,38 @@ var FaceStore = class {
|
|
|
5130
6067
|
} });
|
|
5131
6068
|
}
|
|
5132
6069
|
}
|
|
6070
|
+
/**
|
|
6071
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
6072
|
+
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
6073
|
+
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
6074
|
+
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
6075
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
6076
|
+
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
6077
|
+
*/
|
|
6078
|
+
async deleteByTracks(trackIds) {
|
|
6079
|
+
for (const trackId of trackIds) try {
|
|
6080
|
+
const eligible = (await this.store.query.query({
|
|
6081
|
+
collection: FACES_COLLECTION,
|
|
6082
|
+
filter: { where: { trackId } }
|
|
6083
|
+
})).filter((r) => !r.data.assigned);
|
|
6084
|
+
for (const row of eligible) try {
|
|
6085
|
+
await this.store.delete.mutate({
|
|
6086
|
+
collection: FACES_COLLECTION,
|
|
6087
|
+
key: row.id
|
|
6088
|
+
});
|
|
6089
|
+
} catch (err) {
|
|
6090
|
+
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
6091
|
+
faceId: row.id,
|
|
6092
|
+
error: String(err)
|
|
6093
|
+
} });
|
|
6094
|
+
}
|
|
6095
|
+
} catch (err) {
|
|
6096
|
+
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
6097
|
+
trackId,
|
|
6098
|
+
error: String(err)
|
|
6099
|
+
} });
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
5133
6102
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
5134
6103
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
5135
6104
|
async delete(faceId) {
|
|
@@ -5401,6 +6370,27 @@ var ObjectEmbeddingStore = class {
|
|
|
5401
6370
|
}
|
|
5402
6371
|
return ids;
|
|
5403
6372
|
}
|
|
6373
|
+
/**
|
|
6374
|
+
* `TrackScopedStore` contract for the retention cascade: delete the per-track
|
|
6375
|
+
* CLIP search vector for the given tracks. The row id IS the trackId (one row
|
|
6376
|
+
* per track), so this deletes by key directly. Pure EPHEMERAL track state —
|
|
6377
|
+
* the identity MATCHING embeddings (ArcFace) live in a SEPARATE durable store
|
|
6378
|
+
* (`identity-samples`) and are never touched here, so pruning this never
|
|
6379
|
+
* breaks recognition (design §2/§4). Per-track ISOLATED + best-effort.
|
|
6380
|
+
*/
|
|
6381
|
+
async deleteByTracks(trackIds) {
|
|
6382
|
+
for (const trackId of trackIds) try {
|
|
6383
|
+
await this.store.delete.mutate({
|
|
6384
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
6385
|
+
key: trackId
|
|
6386
|
+
});
|
|
6387
|
+
} catch (err) {
|
|
6388
|
+
this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
|
|
6389
|
+
trackId,
|
|
6390
|
+
error: String(err)
|
|
6391
|
+
} });
|
|
6392
|
+
}
|
|
6393
|
+
}
|
|
5404
6394
|
};
|
|
5405
6395
|
//#endregion
|
|
5406
6396
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
@@ -5495,6 +6485,14 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
5495
6485
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
5496
6486
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
5497
6487
|
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
6488
|
+
/**
|
|
6489
|
+
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
6490
|
+
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
6491
|
+
* (the two-plane `DetailResult` schema omits it), so recognition uses the
|
|
6492
|
+
* gallery's own model id (all enrolled samples share one) and this constant is
|
|
6493
|
+
* only a placeholder for the collect-only case where the id is never compared.
|
|
6494
|
+
*/
|
|
6495
|
+
var FALLBACK_FACE_MODEL_ID = "arcface";
|
|
5498
6496
|
var FaceRecognizer = class {
|
|
5499
6497
|
deps;
|
|
5500
6498
|
gallery = [];
|
|
@@ -5522,6 +6520,38 @@ var FaceRecognizer = class {
|
|
|
5522
6520
|
this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
|
|
5523
6521
|
}
|
|
5524
6522
|
}
|
|
6523
|
+
/**
|
|
6524
|
+
* Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
|
|
6525
|
+
* track and run it through the SAME `processFrame` logic (candidate → best
|
|
6526
|
+
* face → gallery match → crop hold). The result is synthesized into a single
|
|
6527
|
+
* `TrackedDetectionOut` candidate so no recognizer logic changes — only the
|
|
6528
|
+
* input source moves from the per-frame plane to this per-track call.
|
|
6529
|
+
*/
|
|
6530
|
+
async ingestFaceDetail(input) {
|
|
6531
|
+
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
6532
|
+
const candidate = {
|
|
6533
|
+
trackId: input.trackId,
|
|
6534
|
+
className: "face",
|
|
6535
|
+
confidence: input.score,
|
|
6536
|
+
bbox: input.parentBbox,
|
|
6537
|
+
zones: [],
|
|
6538
|
+
state: "moving",
|
|
6539
|
+
embedding: input.embedding,
|
|
6540
|
+
embeddingModelId: modelId,
|
|
6541
|
+
...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
|
|
6542
|
+
...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
|
|
6543
|
+
};
|
|
6544
|
+
await this.processFrame({
|
|
6545
|
+
deviceId: input.deviceId,
|
|
6546
|
+
timestamp: input.timestamp,
|
|
6547
|
+
frameWidth: input.frameWidth,
|
|
6548
|
+
frameHeight: input.frameHeight,
|
|
6549
|
+
tracked: [candidate],
|
|
6550
|
+
settings: input.settings,
|
|
6551
|
+
cropPadding: input.cropPadding,
|
|
6552
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
6553
|
+
});
|
|
6554
|
+
}
|
|
5525
6555
|
async processFrame(input) {
|
|
5526
6556
|
const { settings } = input;
|
|
5527
6557
|
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
|
|
@@ -5559,7 +6589,8 @@ var FaceRecognizer = class {
|
|
|
5559
6589
|
if (isNewBest || needsCrop) {
|
|
5560
6590
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
5561
6591
|
let crop;
|
|
5562
|
-
if (c.
|
|
6592
|
+
if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
|
|
6593
|
+
else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
|
|
5563
6594
|
crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
5564
6595
|
} catch (err) {
|
|
5565
6596
|
this.deps.logger.debug("FaceRecognizer crop capture failed", {
|
|
@@ -5738,6 +6769,24 @@ var FaceRecognizer = class {
|
|
|
5738
6769
|
score: held.score
|
|
5739
6770
|
}
|
|
5740
6771
|
});
|
|
6772
|
+
try {
|
|
6773
|
+
this.deps.emitFaceGalleryChanged?.({
|
|
6774
|
+
deviceId,
|
|
6775
|
+
faceId,
|
|
6776
|
+
kind: "buffered"
|
|
6777
|
+
});
|
|
6778
|
+
} catch (err) {
|
|
6779
|
+
this.deps.logger.debug("face-gallery-changed emit failed", {
|
|
6780
|
+
tags: {
|
|
6781
|
+
deviceId,
|
|
6782
|
+
trackId
|
|
6783
|
+
},
|
|
6784
|
+
meta: {
|
|
6785
|
+
faceId,
|
|
6786
|
+
error: String(err)
|
|
6787
|
+
}
|
|
6788
|
+
});
|
|
6789
|
+
}
|
|
5741
6790
|
} catch (err) {
|
|
5742
6791
|
this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
|
|
5743
6792
|
tags: { deviceId },
|
|
@@ -5750,103 +6799,667 @@ var FaceRecognizer = class {
|
|
|
5750
6799
|
}
|
|
5751
6800
|
};
|
|
5752
6801
|
//#endregion
|
|
5753
|
-
//#region src/pipeline-analytics/
|
|
5754
|
-
|
|
5755
|
-
var
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
}
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
/** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
|
|
5819
|
-
async insert(plate) {
|
|
5820
|
-
const { id, ...rest } = plate;
|
|
5821
|
-
try {
|
|
5822
|
-
await this.store.set.mutate({
|
|
5823
|
-
collection: PLATES_COLLECTION,
|
|
5824
|
-
key: id,
|
|
5825
|
-
value: rest
|
|
5826
|
-
});
|
|
5827
|
-
} catch (err) {
|
|
5828
|
-
this.logger.warn("PlateStore.insert failed", {
|
|
5829
|
-
tags: { deviceId: plate.deviceId },
|
|
5830
|
-
meta: {
|
|
5831
|
-
plateId: id,
|
|
5832
|
-
error: String(err)
|
|
6802
|
+
//#region src/pipeline-analytics/detail-scheduler.ts
|
|
6803
|
+
/** Default backoff/period when a step's cadence omits `minIntervalMs`. */
|
|
6804
|
+
var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
6805
|
+
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
6806
|
+
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
6807
|
+
/**
|
|
6808
|
+
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
6809
|
+
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
6810
|
+
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
6811
|
+
* step should run for a given track — independent of transport, I/O, or
|
|
6812
|
+
* timers. The caller drives it with wall-clock `nowMs` and dispatches the
|
|
6813
|
+
* returned `DetailRequest[]`.
|
|
6814
|
+
*/
|
|
6815
|
+
var DetailScheduler = class {
|
|
6816
|
+
tracks = /* @__PURE__ */ new Map();
|
|
6817
|
+
/** Track appeared with class + announce; returns immediate requests. */
|
|
6818
|
+
onTrackStarted(trackId, className, announce, nowMs) {
|
|
6819
|
+
const steps = /* @__PURE__ */ new Map();
|
|
6820
|
+
const requests = [];
|
|
6821
|
+
for (const stepAnnounce of announce) {
|
|
6822
|
+
if (!stepAnnounce.inputClasses.includes(className)) continue;
|
|
6823
|
+
const state = {
|
|
6824
|
+
announce: stepAnnounce,
|
|
6825
|
+
firedCount: 1,
|
|
6826
|
+
lastFiredAt: nowMs,
|
|
6827
|
+
sticky: false,
|
|
6828
|
+
retryPending: false
|
|
6829
|
+
};
|
|
6830
|
+
steps.set(stepAnnounce.stepId, state);
|
|
6831
|
+
requests.push({
|
|
6832
|
+
trackId,
|
|
6833
|
+
stepId: stepAnnounce.stepId,
|
|
6834
|
+
reason: "new-track"
|
|
6835
|
+
});
|
|
6836
|
+
}
|
|
6837
|
+
this.tracks.set(trackId, steps);
|
|
6838
|
+
return requests;
|
|
6839
|
+
}
|
|
6840
|
+
/** Better candidate crop observed for the track. */
|
|
6841
|
+
onCandidateImproved(trackId, nowMs) {
|
|
6842
|
+
const steps = this.tracks.get(trackId);
|
|
6843
|
+
if (!steps) return [];
|
|
6844
|
+
const requests = [];
|
|
6845
|
+
for (const state of steps.values()) {
|
|
6846
|
+
if (state.announce.cadence.trigger !== "improve") continue;
|
|
6847
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
6848
|
+
this.markFired(state, nowMs);
|
|
6849
|
+
requests.push({
|
|
6850
|
+
trackId,
|
|
6851
|
+
stepId: state.announce.stepId,
|
|
6852
|
+
reason: "improve"
|
|
6853
|
+
});
|
|
6854
|
+
}
|
|
6855
|
+
return requests;
|
|
6856
|
+
}
|
|
6857
|
+
/** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
|
|
6858
|
+
tick(nowMs) {
|
|
6859
|
+
const requests = [];
|
|
6860
|
+
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
6861
|
+
if (state.sticky) continue;
|
|
6862
|
+
if (state.retryPending) {
|
|
6863
|
+
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
6864
|
+
if (!this.underMaxPerTrack(state)) {
|
|
6865
|
+
state.retryPending = false;
|
|
6866
|
+
continue;
|
|
5833
6867
|
}
|
|
6868
|
+
this.markFired(state, nowMs);
|
|
6869
|
+
requests.push({
|
|
6870
|
+
trackId,
|
|
6871
|
+
stepId: state.announce.stepId,
|
|
6872
|
+
reason: "retry"
|
|
6873
|
+
});
|
|
6874
|
+
continue;
|
|
6875
|
+
}
|
|
6876
|
+
if (state.announce.cadence.trigger !== "periodic") continue;
|
|
6877
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
6878
|
+
this.markFired(state, nowMs);
|
|
6879
|
+
requests.push({
|
|
6880
|
+
trackId,
|
|
6881
|
+
stepId: state.announce.stepId,
|
|
6882
|
+
reason: "periodic"
|
|
5834
6883
|
});
|
|
5835
6884
|
}
|
|
6885
|
+
return requests;
|
|
5836
6886
|
}
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
6887
|
+
/**
|
|
6888
|
+
* Result arrived; confidence drives sticky/retry. null = failed (retry per
|
|
6889
|
+
* policy). `_nowMs` is part of the public signature for symmetry with the
|
|
6890
|
+
* other methods but isn't needed here — retry backoff is anchored to
|
|
6891
|
+
* `lastFiredAt` (set when the step was actually dispatched), not to when
|
|
6892
|
+
* its result came back.
|
|
6893
|
+
*/
|
|
6894
|
+
onResult(trackId, stepId, confidence, _nowMs) {
|
|
6895
|
+
const steps = this.tracks.get(trackId);
|
|
6896
|
+
if (!steps) return;
|
|
6897
|
+
const state = steps.get(stepId);
|
|
6898
|
+
if (!state) return;
|
|
6899
|
+
if (state.sticky) return;
|
|
6900
|
+
const { stickyOnConfidence } = state.announce.cadence;
|
|
6901
|
+
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
6902
|
+
state.sticky = true;
|
|
6903
|
+
state.retryPending = false;
|
|
6904
|
+
return;
|
|
6905
|
+
}
|
|
6906
|
+
if (confidence === null) {
|
|
6907
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6908
|
+
return;
|
|
6909
|
+
}
|
|
6910
|
+
if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
|
|
6911
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6912
|
+
}
|
|
5846
6913
|
}
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
6914
|
+
onTrackEnded(trackId) {
|
|
6915
|
+
this.tracks.delete(trackId);
|
|
6916
|
+
}
|
|
6917
|
+
canFire(state, nowMs) {
|
|
6918
|
+
if (state.sticky) return false;
|
|
6919
|
+
if (!this.underMaxPerTrack(state)) return false;
|
|
6920
|
+
return this.intervalElapsed(state, nowMs);
|
|
6921
|
+
}
|
|
6922
|
+
intervalElapsed(state, nowMs) {
|
|
6923
|
+
if (state.lastFiredAt === null) return true;
|
|
6924
|
+
const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
6925
|
+
return nowMs - state.lastFiredAt >= minIntervalMs;
|
|
6926
|
+
}
|
|
6927
|
+
underMaxPerTrack(state) {
|
|
6928
|
+
const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
|
|
6929
|
+
return state.firedCount < maxPerTrack;
|
|
6930
|
+
}
|
|
6931
|
+
markFired(state, nowMs) {
|
|
6932
|
+
state.firedCount += 1;
|
|
6933
|
+
state.lastFiredAt = nowMs;
|
|
6934
|
+
state.retryPending = false;
|
|
6935
|
+
}
|
|
6936
|
+
};
|
|
6937
|
+
//#endregion
|
|
6938
|
+
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6939
|
+
/**
|
|
6940
|
+
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6941
|
+
* identity-aware for the face chain.
|
|
6942
|
+
*
|
|
6943
|
+
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6944
|
+
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
6945
|
+
* track that already carries an identity label: re-embedding a known face every
|
|
6946
|
+
* second is wasted ArcFace inference (250-1900ms per call on N100 agents), so
|
|
6947
|
+
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6948
|
+
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6949
|
+
*
|
|
6950
|
+
* Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
6951
|
+
* strict-`steps` pruning (`pruneChildStepsToRequested`) — including
|
|
6952
|
+
* `'face-embedding'` here is what keeps the nested child in the executed chain.
|
|
6953
|
+
*/
|
|
6954
|
+
function composeDetailSteps(req, hasTrackLabel) {
|
|
6955
|
+
if (req.stepId !== "face-detection") return [req.stepId];
|
|
6956
|
+
return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
6957
|
+
}
|
|
6958
|
+
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6959
|
+
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
6960
|
+
var TrackDetailDispatcher = class {
|
|
6961
|
+
deps;
|
|
6962
|
+
devices = /* @__PURE__ */ new Map();
|
|
6963
|
+
maxInFlight;
|
|
6964
|
+
tickIntervalMs;
|
|
6965
|
+
disposed = false;
|
|
6966
|
+
constructor(deps) {
|
|
6967
|
+
this.deps = deps;
|
|
6968
|
+
this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
|
|
6969
|
+
this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
|
|
6970
|
+
}
|
|
6971
|
+
/** A track appeared: record its frame, seed its best-confidence, and dispatch
|
|
6972
|
+
* the scheduler's immediate (new-track) requests. */
|
|
6973
|
+
onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
|
|
6974
|
+
if (this.disposed) return;
|
|
6975
|
+
const dev = this.ensureDevice(deviceId);
|
|
6976
|
+
dev.tracks.set(trackId, frame);
|
|
6977
|
+
dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
|
|
6978
|
+
const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
|
|
6979
|
+
this.enqueue(deviceId, dev, requests);
|
|
6980
|
+
this.ensureTimer(deviceId, dev);
|
|
6981
|
+
}
|
|
6982
|
+
/** A subsequent frame for a live track: refresh its frame + fire
|
|
6983
|
+
* `improve`-cadence steps when the detector confidence strictly improves.
|
|
6984
|
+
*
|
|
6985
|
+
* `announce` is the frame's currently-announced detail chain. When a track
|
|
6986
|
+
* is alive but has NO dispatcher state yet — it existed before `detailSteps`
|
|
6987
|
+
* first appeared (a mid-track redeploy / config change) — this adopts it as a
|
|
6988
|
+
* new track so it starts getting scheduled instead of starving for its whole
|
|
6989
|
+
* life. Idempotent: guarded by the per-track state check, so a track that
|
|
6990
|
+
* already has state is never reset. */
|
|
6991
|
+
onFrame(deviceId, trackId, announce, frame, nowMs) {
|
|
6992
|
+
if (this.disposed) return;
|
|
6993
|
+
const dev = this.devices.get(deviceId);
|
|
6994
|
+
if (!dev || !dev.tracks.has(trackId)) {
|
|
6995
|
+
if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
|
|
6996
|
+
return;
|
|
6997
|
+
}
|
|
6998
|
+
dev.tracks.set(trackId, frame);
|
|
6999
|
+
if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
|
|
7000
|
+
const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
|
|
7001
|
+
this.enqueue(deviceId, dev, requests);
|
|
7002
|
+
}
|
|
7003
|
+
/** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
|
|
7004
|
+
* queued request for it is discarded at dequeue. */
|
|
7005
|
+
onTrackEnded(deviceId, trackId) {
|
|
7006
|
+
const dev = this.devices.get(deviceId);
|
|
7007
|
+
if (!dev) return;
|
|
7008
|
+
dev.scheduler.onTrackEnded(trackId);
|
|
7009
|
+
dev.tracks.delete(trackId);
|
|
7010
|
+
dev.candidateBest.delete(trackId);
|
|
7011
|
+
if (dev.tracks.size === 0) this.clearTimer(dev);
|
|
7012
|
+
}
|
|
7013
|
+
dispose() {
|
|
7014
|
+
this.disposed = true;
|
|
7015
|
+
for (const dev of this.devices.values()) {
|
|
7016
|
+
this.clearTimer(dev);
|
|
7017
|
+
dev.queue.length = 0;
|
|
7018
|
+
dev.tracks.clear();
|
|
7019
|
+
dev.candidateBest.clear();
|
|
7020
|
+
}
|
|
7021
|
+
this.devices.clear();
|
|
7022
|
+
}
|
|
7023
|
+
ensureDevice(deviceId) {
|
|
7024
|
+
let dev = this.devices.get(deviceId);
|
|
7025
|
+
if (!dev) {
|
|
7026
|
+
dev = {
|
|
7027
|
+
scheduler: new DetailScheduler(),
|
|
7028
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
7029
|
+
candidateBest: new BestDetectionTracker(),
|
|
7030
|
+
queue: [],
|
|
7031
|
+
inFlight: 0,
|
|
7032
|
+
timer: null,
|
|
7033
|
+
lastFailWarnAt: 0
|
|
7034
|
+
};
|
|
7035
|
+
this.devices.set(deviceId, dev);
|
|
7036
|
+
}
|
|
7037
|
+
return dev;
|
|
7038
|
+
}
|
|
7039
|
+
ensureTimer(deviceId, dev) {
|
|
7040
|
+
if (dev.timer) return;
|
|
7041
|
+
const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
|
|
7042
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
7043
|
+
dev.timer = timer;
|
|
7044
|
+
}
|
|
7045
|
+
clearTimer(dev) {
|
|
7046
|
+
if (dev.timer) {
|
|
7047
|
+
clearInterval(dev.timer);
|
|
7048
|
+
dev.timer = null;
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
7051
|
+
tick(deviceId, dev) {
|
|
7052
|
+
if (this.disposed) return;
|
|
7053
|
+
const requests = dev.scheduler.tick(Date.now());
|
|
7054
|
+
this.enqueue(deviceId, dev, requests);
|
|
7055
|
+
}
|
|
7056
|
+
enqueue(deviceId, dev, requests) {
|
|
7057
|
+
if (requests.length === 0) return;
|
|
7058
|
+
for (const r of requests) dev.queue.push(r);
|
|
7059
|
+
this.pump(deviceId, dev);
|
|
7060
|
+
}
|
|
7061
|
+
pump(deviceId, dev) {
|
|
7062
|
+
while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
|
|
7063
|
+
const req = dev.queue.shift();
|
|
7064
|
+
if (req === void 0) break;
|
|
7065
|
+
const frame = dev.tracks.get(req.trackId);
|
|
7066
|
+
if (frame === void 0) continue;
|
|
7067
|
+
dev.inFlight += 1;
|
|
7068
|
+
this.dispatch(deviceId, dev, req, frame).finally(() => {
|
|
7069
|
+
dev.inFlight -= 1;
|
|
7070
|
+
this.pump(deviceId, dev);
|
|
7071
|
+
});
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
async dispatch(deviceId, dev, req, frame) {
|
|
7075
|
+
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
7076
|
+
let topScore = null;
|
|
7077
|
+
if (details !== null && details.length > 0) {
|
|
7078
|
+
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7079
|
+
try {
|
|
7080
|
+
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7081
|
+
} catch (err) {
|
|
7082
|
+
this.deps.logger.warn("detail result routing failed", {
|
|
7083
|
+
tags: { deviceId },
|
|
7084
|
+
meta: {
|
|
7085
|
+
trackId: req.trackId,
|
|
7086
|
+
stepId: req.stepId,
|
|
7087
|
+
error: String(err)
|
|
7088
|
+
}
|
|
7089
|
+
});
|
|
7090
|
+
}
|
|
7091
|
+
}
|
|
7092
|
+
dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
|
|
7093
|
+
}
|
|
7094
|
+
/**
|
|
7095
|
+
* Run the request once via the frameHandle, and — on a miss (null OR throw)
|
|
7096
|
+
* — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
|
|
7097
|
+
* the detail list, or `null` when both attempts fail to produce a result.
|
|
7098
|
+
*/
|
|
7099
|
+
async runOnce(deviceId, dev, req, frame) {
|
|
7100
|
+
const parent = {
|
|
7101
|
+
bbox: { ...frame.bbox },
|
|
7102
|
+
className: frame.className
|
|
7103
|
+
};
|
|
7104
|
+
const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
|
|
7105
|
+
if (frame.frameHandle !== void 0) try {
|
|
7106
|
+
const primary = await this.deps.runDetailSubtree({
|
|
7107
|
+
deviceId,
|
|
7108
|
+
frameHandle: frame.frameHandle,
|
|
7109
|
+
parent,
|
|
7110
|
+
steps
|
|
7111
|
+
}, frame.nodeId);
|
|
7112
|
+
if (primary !== null) return primary.details;
|
|
7113
|
+
} catch (err) {
|
|
7114
|
+
this.deps.logger.debug("detail primary call failed — trying crop fallback", {
|
|
7115
|
+
tags: { deviceId },
|
|
7116
|
+
meta: {
|
|
7117
|
+
trackId: req.trackId,
|
|
7118
|
+
stepId: req.stepId,
|
|
7119
|
+
error: String(err)
|
|
7120
|
+
}
|
|
7121
|
+
});
|
|
7122
|
+
}
|
|
7123
|
+
if (this.deps.captureCropBase64 !== void 0) try {
|
|
7124
|
+
const cropJpeg = await this.deps.captureCropBase64(frame);
|
|
7125
|
+
if (cropJpeg !== null) {
|
|
7126
|
+
const retry = await this.deps.runDetailSubtree({
|
|
7127
|
+
deviceId,
|
|
7128
|
+
cropJpeg,
|
|
7129
|
+
parent,
|
|
7130
|
+
steps
|
|
7131
|
+
}, frame.nodeId);
|
|
7132
|
+
if (retry !== null) return retry.details;
|
|
7133
|
+
}
|
|
7134
|
+
} catch (err) {
|
|
7135
|
+
this.deps.logger.debug("detail crop-fallback call failed", {
|
|
7136
|
+
tags: { deviceId },
|
|
7137
|
+
meta: {
|
|
7138
|
+
trackId: req.trackId,
|
|
7139
|
+
stepId: req.stepId,
|
|
7140
|
+
error: String(err)
|
|
7141
|
+
}
|
|
7142
|
+
});
|
|
7143
|
+
}
|
|
7144
|
+
this.warnFailThrottled(deviceId, dev, req);
|
|
7145
|
+
return null;
|
|
7146
|
+
}
|
|
7147
|
+
warnFailThrottled(deviceId, dev, req) {
|
|
7148
|
+
const now = Date.now();
|
|
7149
|
+
if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
7150
|
+
dev.lastFailWarnAt = now;
|
|
7151
|
+
this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
|
|
7152
|
+
tags: { deviceId },
|
|
7153
|
+
meta: {
|
|
7154
|
+
trackId: req.trackId,
|
|
7155
|
+
stepId: req.stepId,
|
|
7156
|
+
reason: req.reason
|
|
7157
|
+
}
|
|
7158
|
+
});
|
|
7159
|
+
}
|
|
7160
|
+
};
|
|
7161
|
+
//#endregion
|
|
7162
|
+
//#region src/pipeline-analytics/overlay-state.ts
|
|
7163
|
+
function key(deviceId, trackId) {
|
|
7164
|
+
return `${deviceId}:${trackId}`;
|
|
7165
|
+
}
|
|
7166
|
+
var OverlayDetailStateStore = class {
|
|
7167
|
+
byTrack = /* @__PURE__ */ new Map();
|
|
7168
|
+
noteFaceDetail(deviceId, trackId, faceBbox, score, capturedAt) {
|
|
7169
|
+
const k = key(deviceId, trackId);
|
|
7170
|
+
const prev = this.byTrack.get(k);
|
|
7171
|
+
this.byTrack.set(k, {
|
|
7172
|
+
...prev,
|
|
7173
|
+
face: {
|
|
7174
|
+
bbox: faceBbox,
|
|
7175
|
+
score,
|
|
7176
|
+
capturedAt
|
|
7177
|
+
}
|
|
7178
|
+
});
|
|
7179
|
+
}
|
|
7180
|
+
notePlateDetail(deviceId, trackId, plateBbox, score, text, capturedAt) {
|
|
7181
|
+
const k = key(deviceId, trackId);
|
|
7182
|
+
const prev = this.byTrack.get(k);
|
|
7183
|
+
this.byTrack.set(k, {
|
|
7184
|
+
...prev,
|
|
7185
|
+
plate: {
|
|
7186
|
+
bbox: plateBbox,
|
|
7187
|
+
score,
|
|
7188
|
+
capturedAt,
|
|
7189
|
+
text
|
|
7190
|
+
}
|
|
7191
|
+
});
|
|
7192
|
+
}
|
|
7193
|
+
get(deviceId, trackId) {
|
|
7194
|
+
return this.byTrack.get(key(deviceId, trackId));
|
|
7195
|
+
}
|
|
7196
|
+
onTrackEnded(deviceId, trackId) {
|
|
7197
|
+
this.byTrack.delete(key(deviceId, trackId));
|
|
7198
|
+
}
|
|
7199
|
+
clearDevice(deviceId) {
|
|
7200
|
+
const prefix = `${deviceId}:`;
|
|
7201
|
+
for (const k of this.byTrack.keys()) if (k.startsWith(prefix)) this.byTrack.delete(k);
|
|
7202
|
+
}
|
|
7203
|
+
clear() {
|
|
7204
|
+
this.byTrack.clear();
|
|
7205
|
+
}
|
|
7206
|
+
};
|
|
7207
|
+
function toDetectionBbox(bbox) {
|
|
7208
|
+
return {
|
|
7209
|
+
x: bbox.x,
|
|
7210
|
+
y: bbox.y,
|
|
7211
|
+
width: bbox.w,
|
|
7212
|
+
height: bbox.h
|
|
7213
|
+
};
|
|
7214
|
+
}
|
|
7215
|
+
function buildOverlayDetections(input) {
|
|
7216
|
+
const trackedBySourceId = /* @__PURE__ */ new Map();
|
|
7217
|
+
for (const t of input.tracked) if (t.sourceDetectionId !== void 0) trackedBySourceId.set(t.sourceDetectionId, t);
|
|
7218
|
+
const realDetailKindsByParent = /* @__PURE__ */ new Map();
|
|
7219
|
+
for (const d of input.frameDetections) {
|
|
7220
|
+
if (d.kind !== "detail" || d.parentId === void 0) continue;
|
|
7221
|
+
const set = realDetailKindsByParent.get(d.parentId) ?? /* @__PURE__ */ new Set();
|
|
7222
|
+
set.add(d.macroClass);
|
|
7223
|
+
realDetailKindsByParent.set(d.parentId, set);
|
|
7224
|
+
}
|
|
7225
|
+
const out = [];
|
|
7226
|
+
const synthesized = [];
|
|
7227
|
+
for (const det of input.frameDetections) {
|
|
7228
|
+
const t = det.kind === "first-level" ? trackedBySourceId.get(det.id) : void 0;
|
|
7229
|
+
if (t === void 0) {
|
|
7230
|
+
out.push(det);
|
|
7231
|
+
continue;
|
|
7232
|
+
}
|
|
7233
|
+
const name = input.labelFor(t.trackId) ?? "";
|
|
7234
|
+
const hasName = name.length > 0;
|
|
7235
|
+
const alreadyLabeled = hasName && det.labels.some((l) => l.label === name);
|
|
7236
|
+
out.push({
|
|
7237
|
+
...det,
|
|
7238
|
+
track: {
|
|
7239
|
+
trackId: t.trackId,
|
|
7240
|
+
zones: t.zones
|
|
7241
|
+
},
|
|
7242
|
+
labels: hasName && !alreadyLabeled ? [...det.labels, {
|
|
7243
|
+
label: name,
|
|
7244
|
+
score: 1
|
|
7245
|
+
}] : det.labels
|
|
7246
|
+
});
|
|
7247
|
+
const overlay = input.overlayFor(t.trackId);
|
|
7248
|
+
if (overlay === void 0) continue;
|
|
7249
|
+
const realKinds = realDetailKindsByParent.get(det.id);
|
|
7250
|
+
if (overlay.face !== void 0 && input.nowMs - overlay.face.capturedAt <= 2500 && overlay.face !== void 0 && realKinds?.has("face") !== true) synthesized.push({
|
|
7251
|
+
id: `detail-${t.trackId}-face`,
|
|
7252
|
+
kind: "detail",
|
|
7253
|
+
macroClass: "face",
|
|
7254
|
+
score: overlay.face.score,
|
|
7255
|
+
bbox: toDetectionBbox(overlay.face.bbox),
|
|
7256
|
+
parentId: det.id,
|
|
7257
|
+
labels: hasName ? [{
|
|
7258
|
+
label: name,
|
|
7259
|
+
score: overlay.face.score
|
|
7260
|
+
}] : []
|
|
7261
|
+
});
|
|
7262
|
+
if (overlay.plate !== void 0 && input.nowMs - overlay.plate.capturedAt <= 2500 && overlay.plate !== void 0 && realKinds?.has("plate") !== true) synthesized.push({
|
|
7263
|
+
id: `detail-${t.trackId}-plate`,
|
|
7264
|
+
kind: "detail",
|
|
7265
|
+
macroClass: "plate",
|
|
7266
|
+
score: overlay.plate.score,
|
|
7267
|
+
bbox: toDetectionBbox(overlay.plate.bbox),
|
|
7268
|
+
parentId: det.id,
|
|
7269
|
+
labels: [{
|
|
7270
|
+
label: overlay.plate.text,
|
|
7271
|
+
score: overlay.plate.score
|
|
7272
|
+
}]
|
|
7273
|
+
});
|
|
7274
|
+
}
|
|
7275
|
+
return [...out, ...synthesized];
|
|
7276
|
+
}
|
|
7277
|
+
//#endregion
|
|
7278
|
+
//#region src/pipeline-analytics/media-capture-log.ts
|
|
7279
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 6e4;
|
|
7280
|
+
var MediaCaptureLogAggregator = class {
|
|
7281
|
+
flushIntervalMs;
|
|
7282
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
7283
|
+
constructor(flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS) {
|
|
7284
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
7285
|
+
}
|
|
7286
|
+
/**
|
|
7287
|
+
* Record one frame's capture counts. Returns the window aggregate (and
|
|
7288
|
+
* resets the window) once `flushIntervalMs` has elapsed since the window
|
|
7289
|
+
* opened; `null` while still accumulating.
|
|
7290
|
+
*/
|
|
7291
|
+
note(deviceId, counts, nowMs) {
|
|
7292
|
+
const w = this.byDevice.get(deviceId);
|
|
7293
|
+
if (w === void 0) {
|
|
7294
|
+
this.byDevice.set(deviceId, {
|
|
7295
|
+
sinceMs: nowMs,
|
|
7296
|
+
frames: 1,
|
|
7297
|
+
events: counts.events,
|
|
7298
|
+
trackFrames: counts.trackFrames,
|
|
7299
|
+
snapshots: counts.snapshots,
|
|
7300
|
+
faceCrops: counts.faceCrops,
|
|
7301
|
+
plateCrops: counts.plateCrops
|
|
7302
|
+
});
|
|
7303
|
+
return null;
|
|
7304
|
+
}
|
|
7305
|
+
w.frames += 1;
|
|
7306
|
+
w.events += counts.events;
|
|
7307
|
+
w.trackFrames += counts.trackFrames;
|
|
7308
|
+
w.snapshots += counts.snapshots;
|
|
7309
|
+
w.faceCrops += counts.faceCrops;
|
|
7310
|
+
w.plateCrops += counts.plateCrops;
|
|
7311
|
+
if (nowMs - w.sinceMs < this.flushIntervalMs) return null;
|
|
7312
|
+
return this.drain(deviceId, w, nowMs);
|
|
7313
|
+
}
|
|
7314
|
+
/**
|
|
7315
|
+
* Drain a device's pending window (e.g. when its last live track ends),
|
|
7316
|
+
* so short activity bursts still surface an aggregate. Returns `null`
|
|
7317
|
+
* when nothing is pending.
|
|
7318
|
+
*/
|
|
7319
|
+
flush(deviceId, nowMs) {
|
|
7320
|
+
const w = this.byDevice.get(deviceId);
|
|
7321
|
+
if (w === void 0) return null;
|
|
7322
|
+
return this.drain(deviceId, w, nowMs);
|
|
7323
|
+
}
|
|
7324
|
+
drain(deviceId, w, nowMs) {
|
|
7325
|
+
this.byDevice.delete(deviceId);
|
|
7326
|
+
return {
|
|
7327
|
+
frames: w.frames,
|
|
7328
|
+
windowMs: nowMs - w.sinceMs,
|
|
7329
|
+
sums: {
|
|
7330
|
+
events: w.events,
|
|
7331
|
+
trackFrames: w.trackFrames,
|
|
7332
|
+
snapshots: w.snapshots,
|
|
7333
|
+
faceCrops: w.faceCrops,
|
|
7334
|
+
plateCrops: w.plateCrops
|
|
7335
|
+
}
|
|
7336
|
+
};
|
|
7337
|
+
}
|
|
7338
|
+
};
|
|
7339
|
+
//#endregion
|
|
7340
|
+
//#region src/pipeline-analytics/store/plate-store.ts
|
|
7341
|
+
var PLATES_COLLECTION = "pipeline-analytics:plates";
|
|
7342
|
+
var PLATE_COLUMNS = [
|
|
7343
|
+
{
|
|
7344
|
+
name: "id",
|
|
7345
|
+
type: "TEXT",
|
|
7346
|
+
primaryKey: true,
|
|
7347
|
+
notNull: true
|
|
7348
|
+
},
|
|
7349
|
+
{
|
|
7350
|
+
name: "deviceId",
|
|
7351
|
+
type: "INTEGER",
|
|
7352
|
+
notNull: true
|
|
7353
|
+
},
|
|
7354
|
+
{
|
|
7355
|
+
name: "trackId",
|
|
7356
|
+
type: "TEXT",
|
|
7357
|
+
notNull: true
|
|
7358
|
+
},
|
|
7359
|
+
{
|
|
7360
|
+
name: "timestamp",
|
|
7361
|
+
type: "INTEGER",
|
|
7362
|
+
notNull: true
|
|
7363
|
+
},
|
|
7364
|
+
{
|
|
7365
|
+
name: "text",
|
|
7366
|
+
type: "TEXT",
|
|
7367
|
+
notNull: true
|
|
7368
|
+
},
|
|
7369
|
+
{
|
|
7370
|
+
name: "score",
|
|
7371
|
+
type: "REAL",
|
|
7372
|
+
notNull: true
|
|
7373
|
+
},
|
|
7374
|
+
{
|
|
7375
|
+
name: "mediaKey",
|
|
7376
|
+
type: "TEXT"
|
|
7377
|
+
},
|
|
7378
|
+
{
|
|
7379
|
+
name: "corrected",
|
|
7380
|
+
type: "BOOLEAN",
|
|
7381
|
+
notNull: true
|
|
7382
|
+
},
|
|
7383
|
+
{
|
|
7384
|
+
name: "recognizedVehicleId",
|
|
7385
|
+
type: "TEXT"
|
|
7386
|
+
},
|
|
7387
|
+
{
|
|
7388
|
+
name: "assigned",
|
|
7389
|
+
type: "BOOLEAN",
|
|
7390
|
+
notNull: true
|
|
7391
|
+
},
|
|
7392
|
+
{
|
|
7393
|
+
name: "assignedSampleId",
|
|
7394
|
+
type: "TEXT"
|
|
7395
|
+
},
|
|
7396
|
+
{
|
|
7397
|
+
name: "keyFrameMediaKey",
|
|
7398
|
+
type: "TEXT"
|
|
7399
|
+
},
|
|
7400
|
+
{
|
|
7401
|
+
name: "plateBbox",
|
|
7402
|
+
type: "JSON"
|
|
7403
|
+
}
|
|
7404
|
+
];
|
|
7405
|
+
var PLATE_INDEXES = [{
|
|
7406
|
+
name: "idx_plates_device_ts",
|
|
7407
|
+
columns: ["deviceId", "timestamp"]
|
|
7408
|
+
}, {
|
|
7409
|
+
name: "idx_plates_track",
|
|
7410
|
+
columns: ["trackId"]
|
|
7411
|
+
}];
|
|
7412
|
+
var PlateStore = class {
|
|
7413
|
+
store;
|
|
7414
|
+
logger;
|
|
7415
|
+
constructor(deps) {
|
|
7416
|
+
this.store = deps.store;
|
|
7417
|
+
this.logger = deps.logger;
|
|
7418
|
+
}
|
|
7419
|
+
static async declare(store) {
|
|
7420
|
+
await store.declareCollection.mutate({
|
|
7421
|
+
collection: PLATES_COLLECTION,
|
|
7422
|
+
columns: [...PLATE_COLUMNS],
|
|
7423
|
+
indexes: [...PLATE_INDEXES]
|
|
7424
|
+
});
|
|
7425
|
+
}
|
|
7426
|
+
/** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
|
|
7427
|
+
async insert(plate) {
|
|
7428
|
+
const { id, ...rest } = plate;
|
|
7429
|
+
try {
|
|
7430
|
+
await this.store.set.mutate({
|
|
7431
|
+
collection: PLATES_COLLECTION,
|
|
7432
|
+
key: id,
|
|
7433
|
+
value: rest
|
|
7434
|
+
});
|
|
7435
|
+
} catch (err) {
|
|
7436
|
+
this.logger.warn("PlateStore.insert failed", {
|
|
7437
|
+
tags: { deviceId: plate.deviceId },
|
|
7438
|
+
meta: {
|
|
7439
|
+
plateId: id,
|
|
7440
|
+
error: String(err)
|
|
7441
|
+
}
|
|
7442
|
+
});
|
|
7443
|
+
}
|
|
7444
|
+
}
|
|
7445
|
+
normalizeRow(r) {
|
|
7446
|
+
const data = r.data;
|
|
7447
|
+
return {
|
|
7448
|
+
id: r.id,
|
|
7449
|
+
...data,
|
|
7450
|
+
score: Number(r.data.score ?? 0),
|
|
7451
|
+
corrected: Boolean(r.data.corrected),
|
|
7452
|
+
assigned: Boolean(r.data.assigned),
|
|
7453
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
7454
|
+
recognizedVehicleId: data.recognizedVehicleId ?? void 0,
|
|
7455
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
7456
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
7457
|
+
plateBbox: data.plateBbox ?? void 0
|
|
7458
|
+
};
|
|
7459
|
+
}
|
|
7460
|
+
/** Recent plate reads for one device (or all when deviceId omitted), newest first. */
|
|
7461
|
+
async listRecentPlates(input) {
|
|
7462
|
+
const where = {};
|
|
5850
7463
|
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
5851
7464
|
return (await this.store.query.query({
|
|
5852
7465
|
collection: PLATES_COLLECTION,
|
|
@@ -5912,6 +7525,76 @@ var PlateStore = class {
|
|
|
5912
7525
|
} });
|
|
5913
7526
|
}
|
|
5914
7527
|
}
|
|
7528
|
+
/** Flip `assigned` to true and record the vehicle + enrolled sample. */
|
|
7529
|
+
async markAssigned(plateId, vehicleId, sampleId) {
|
|
7530
|
+
const data = {
|
|
7531
|
+
assigned: true,
|
|
7532
|
+
recognizedVehicleId: vehicleId
|
|
7533
|
+
};
|
|
7534
|
+
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
7535
|
+
try {
|
|
7536
|
+
await this.store.update.mutate({
|
|
7537
|
+
collection: PLATES_COLLECTION,
|
|
7538
|
+
id: plateId,
|
|
7539
|
+
data
|
|
7540
|
+
});
|
|
7541
|
+
} catch (err) {
|
|
7542
|
+
this.logger.warn("PlateStore.markAssigned failed", { meta: {
|
|
7543
|
+
plateId,
|
|
7544
|
+
error: String(err)
|
|
7545
|
+
} });
|
|
7546
|
+
}
|
|
7547
|
+
}
|
|
7548
|
+
/** Clear the assignment (assigned=false, null recognizedVehicleId + assignedSampleId). */
|
|
7549
|
+
async clearAssignment(plateId) {
|
|
7550
|
+
try {
|
|
7551
|
+
await this.store.update.mutate({
|
|
7552
|
+
collection: PLATES_COLLECTION,
|
|
7553
|
+
id: plateId,
|
|
7554
|
+
data: {
|
|
7555
|
+
assigned: false,
|
|
7556
|
+
recognizedVehicleId: null,
|
|
7557
|
+
assignedSampleId: null
|
|
7558
|
+
}
|
|
7559
|
+
});
|
|
7560
|
+
} catch (err) {
|
|
7561
|
+
this.logger.warn("PlateStore.clearAssignment failed", { meta: {
|
|
7562
|
+
plateId,
|
|
7563
|
+
error: String(err)
|
|
7564
|
+
} });
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
7567
|
+
/**
|
|
7568
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
7569
|
+
* plate rows of the given tracks that are NOT assigned. Mirrors
|
|
7570
|
+
* `FaceStore.deleteByTracks` — ENROLLED (`assigned`) reads SURVIVE retention.
|
|
7571
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never aborts
|
|
7572
|
+
* the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
7573
|
+
*/
|
|
7574
|
+
async deleteByTracks(trackIds) {
|
|
7575
|
+
for (const trackId of trackIds) try {
|
|
7576
|
+
const eligible = (await this.store.query.query({
|
|
7577
|
+
collection: PLATES_COLLECTION,
|
|
7578
|
+
filter: { where: { trackId } }
|
|
7579
|
+
})).filter((r) => !r.data.assigned);
|
|
7580
|
+
for (const row of eligible) try {
|
|
7581
|
+
await this.store.delete.mutate({
|
|
7582
|
+
collection: PLATES_COLLECTION,
|
|
7583
|
+
key: row.id
|
|
7584
|
+
});
|
|
7585
|
+
} catch (err) {
|
|
7586
|
+
this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
|
|
7587
|
+
plateId: row.id,
|
|
7588
|
+
error: String(err)
|
|
7589
|
+
} });
|
|
7590
|
+
}
|
|
7591
|
+
} catch (err) {
|
|
7592
|
+
this.logger.warn("PlateStore.deleteByTracks query failed", { meta: {
|
|
7593
|
+
trackId,
|
|
7594
|
+
error: String(err)
|
|
7595
|
+
} });
|
|
7596
|
+
}
|
|
7597
|
+
}
|
|
5915
7598
|
/**
|
|
5916
7599
|
* Retention across ALL devices: delete reads older than `cutoffMs` AND any
|
|
5917
7600
|
* beyond the newest `maxPerDevice` per device. Corrected reads are exempt
|
|
@@ -5928,7 +7611,7 @@ var PlateStore = class {
|
|
|
5928
7611
|
const byDevice = /* @__PURE__ */ new Map();
|
|
5929
7612
|
for (const r of rows) {
|
|
5930
7613
|
const data = r.data;
|
|
5931
|
-
if (data.corrected) continue;
|
|
7614
|
+
if (data.corrected || data.assigned) continue;
|
|
5932
7615
|
const deviceId = Number(data.deviceId ?? -1);
|
|
5933
7616
|
const list = byDevice.get(deviceId) ?? [];
|
|
5934
7617
|
list.push({
|
|
@@ -5958,10 +7641,320 @@ var PlateStore = class {
|
|
|
5958
7641
|
error: String(err)
|
|
5959
7642
|
} });
|
|
5960
7643
|
}
|
|
5961
|
-
return deleted;
|
|
7644
|
+
return deleted;
|
|
7645
|
+
}
|
|
7646
|
+
};
|
|
7647
|
+
//#endregion
|
|
7648
|
+
//#region src/pipeline-analytics/store/vehicle-store.ts
|
|
7649
|
+
/**
|
|
7650
|
+
* VehicleStore — per-vehicle identity registry for license-plate recognition.
|
|
7651
|
+
*
|
|
7652
|
+
* Two SQL-backed collections (parallel to IdentityStore, text-not-embedding):
|
|
7653
|
+
* - pipeline-analytics:vehicles — named vehicle records
|
|
7654
|
+
* - pipeline-analytics:vehicle-samples — enrolled plate reads (text + score)
|
|
7655
|
+
*
|
|
7656
|
+
* A plate is self-labeling (the OCR text IS the key), so a sample carries
|
|
7657
|
+
* `text` + `score` instead of an embedding + modelId + dim — no model-version
|
|
7658
|
+
* gate is needed. deleteVehicle cascades: removes all sample rows first.
|
|
7659
|
+
*/
|
|
7660
|
+
var VEHICLES_COLLECTION = "pipeline-analytics:vehicles";
|
|
7661
|
+
var VEHICLE_SAMPLES_COLLECTION = "pipeline-analytics:vehicle-samples";
|
|
7662
|
+
var VEHICLE_COLUMNS = [
|
|
7663
|
+
{
|
|
7664
|
+
name: "id",
|
|
7665
|
+
type: "TEXT",
|
|
7666
|
+
primaryKey: true,
|
|
7667
|
+
notNull: true
|
|
7668
|
+
},
|
|
7669
|
+
{
|
|
7670
|
+
name: "name",
|
|
7671
|
+
type: "TEXT",
|
|
7672
|
+
notNull: true
|
|
7673
|
+
},
|
|
7674
|
+
{
|
|
7675
|
+
name: "createdAt",
|
|
7676
|
+
type: "INTEGER",
|
|
7677
|
+
notNull: true
|
|
7678
|
+
},
|
|
7679
|
+
{
|
|
7680
|
+
name: "updatedAt",
|
|
7681
|
+
type: "INTEGER",
|
|
7682
|
+
notNull: true
|
|
7683
|
+
},
|
|
7684
|
+
{
|
|
7685
|
+
name: "sampleCount",
|
|
7686
|
+
type: "INTEGER",
|
|
7687
|
+
notNull: true
|
|
7688
|
+
},
|
|
7689
|
+
{
|
|
7690
|
+
name: "coverMediaKey",
|
|
7691
|
+
type: "TEXT"
|
|
7692
|
+
}
|
|
7693
|
+
];
|
|
7694
|
+
var SAMPLE_COLUMNS = [
|
|
7695
|
+
{
|
|
7696
|
+
name: "id",
|
|
7697
|
+
type: "TEXT",
|
|
7698
|
+
primaryKey: true,
|
|
7699
|
+
notNull: true
|
|
7700
|
+
},
|
|
7701
|
+
{
|
|
7702
|
+
name: "vehicleId",
|
|
7703
|
+
type: "TEXT",
|
|
7704
|
+
notNull: true
|
|
7705
|
+
},
|
|
7706
|
+
{
|
|
7707
|
+
name: "text",
|
|
7708
|
+
type: "TEXT",
|
|
7709
|
+
notNull: true
|
|
7710
|
+
},
|
|
7711
|
+
{
|
|
7712
|
+
name: "score",
|
|
7713
|
+
type: "REAL",
|
|
7714
|
+
notNull: true
|
|
7715
|
+
},
|
|
7716
|
+
{
|
|
7717
|
+
name: "sourcePlateId",
|
|
7718
|
+
type: "TEXT"
|
|
7719
|
+
},
|
|
7720
|
+
{
|
|
7721
|
+
name: "mediaKey",
|
|
7722
|
+
type: "TEXT"
|
|
7723
|
+
},
|
|
7724
|
+
{
|
|
7725
|
+
name: "deviceId",
|
|
7726
|
+
type: "INTEGER"
|
|
7727
|
+
},
|
|
7728
|
+
{
|
|
7729
|
+
name: "addedAt",
|
|
7730
|
+
type: "INTEGER",
|
|
7731
|
+
notNull: true
|
|
7732
|
+
}
|
|
7733
|
+
];
|
|
7734
|
+
var VehicleStore = class {
|
|
7735
|
+
store;
|
|
7736
|
+
logger;
|
|
7737
|
+
constructor(deps) {
|
|
7738
|
+
this.store = deps.store;
|
|
7739
|
+
this.logger = deps.logger;
|
|
7740
|
+
}
|
|
7741
|
+
static async declare(store) {
|
|
7742
|
+
await store.declareCollection.mutate({
|
|
7743
|
+
collection: VEHICLES_COLLECTION,
|
|
7744
|
+
columns: [...VEHICLE_COLUMNS],
|
|
7745
|
+
indexes: []
|
|
7746
|
+
});
|
|
7747
|
+
await store.declareCollection.mutate({
|
|
7748
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7749
|
+
columns: [...SAMPLE_COLUMNS],
|
|
7750
|
+
indexes: [{
|
|
7751
|
+
name: "idx_vehicle_sample",
|
|
7752
|
+
columns: ["vehicleId"]
|
|
7753
|
+
}]
|
|
7754
|
+
});
|
|
7755
|
+
}
|
|
7756
|
+
async createVehicle(input) {
|
|
7757
|
+
const now = Date.now();
|
|
7758
|
+
const vehicle = {
|
|
7759
|
+
id: randomUUID(),
|
|
7760
|
+
name: input.name,
|
|
7761
|
+
createdAt: now,
|
|
7762
|
+
updatedAt: now,
|
|
7763
|
+
sampleCount: 0
|
|
7764
|
+
};
|
|
7765
|
+
const { id, ...rest } = vehicle;
|
|
7766
|
+
await this.store.insert.mutate({
|
|
7767
|
+
collection: VEHICLES_COLLECTION,
|
|
7768
|
+
record: {
|
|
7769
|
+
id,
|
|
7770
|
+
data: rest
|
|
7771
|
+
}
|
|
7772
|
+
});
|
|
7773
|
+
return vehicle;
|
|
7774
|
+
}
|
|
7775
|
+
async renameVehicle(id, name) {
|
|
7776
|
+
await this.store.update.mutate({
|
|
7777
|
+
collection: VEHICLES_COLLECTION,
|
|
7778
|
+
id,
|
|
7779
|
+
data: {
|
|
7780
|
+
name,
|
|
7781
|
+
updatedAt: Date.now()
|
|
7782
|
+
}
|
|
7783
|
+
});
|
|
7784
|
+
}
|
|
7785
|
+
async deleteVehicle(id) {
|
|
7786
|
+
const samples = await this.store.query.query({
|
|
7787
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7788
|
+
filter: { where: { vehicleId: id } }
|
|
7789
|
+
});
|
|
7790
|
+
for (const s of samples) try {
|
|
7791
|
+
await this.store.delete.mutate({
|
|
7792
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7793
|
+
key: s.id
|
|
7794
|
+
});
|
|
7795
|
+
} catch (err) {
|
|
7796
|
+
this.logger.warn("deleteVehicle sample delete failed", { meta: {
|
|
7797
|
+
vehicleId: id,
|
|
7798
|
+
sampleId: s.id,
|
|
7799
|
+
error: String(err)
|
|
7800
|
+
} });
|
|
7801
|
+
}
|
|
7802
|
+
await this.store.delete.mutate({
|
|
7803
|
+
collection: VEHICLES_COLLECTION,
|
|
7804
|
+
key: id
|
|
7805
|
+
});
|
|
7806
|
+
}
|
|
7807
|
+
async listVehicles() {
|
|
7808
|
+
return (await this.store.query.query({
|
|
7809
|
+
collection: VEHICLES_COLLECTION,
|
|
7810
|
+
filter: { orderBy: {
|
|
7811
|
+
field: "createdAt",
|
|
7812
|
+
direction: "asc"
|
|
7813
|
+
} }
|
|
7814
|
+
})).map((r) => {
|
|
7815
|
+
const data = r.data;
|
|
7816
|
+
return {
|
|
7817
|
+
id: r.id,
|
|
7818
|
+
...data,
|
|
7819
|
+
coverMediaKey: data.coverMediaKey ?? void 0
|
|
7820
|
+
};
|
|
7821
|
+
});
|
|
7822
|
+
}
|
|
7823
|
+
async addSample(input) {
|
|
7824
|
+
const sample = {
|
|
7825
|
+
id: randomUUID(),
|
|
7826
|
+
vehicleId: input.vehicleId,
|
|
7827
|
+
text: input.text,
|
|
7828
|
+
score: input.score,
|
|
7829
|
+
...input.sourcePlateId !== void 0 ? { sourcePlateId: input.sourcePlateId } : {},
|
|
7830
|
+
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
|
|
7831
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
7832
|
+
addedAt: Date.now()
|
|
7833
|
+
};
|
|
7834
|
+
const { id, ...rest } = sample;
|
|
7835
|
+
await this.store.insert.mutate({
|
|
7836
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7837
|
+
record: {
|
|
7838
|
+
id,
|
|
7839
|
+
data: rest
|
|
7840
|
+
}
|
|
7841
|
+
});
|
|
7842
|
+
const vehicle = (await this.listVehicles()).find((v) => v.id === input.vehicleId);
|
|
7843
|
+
if (vehicle) {
|
|
7844
|
+
const patch = {
|
|
7845
|
+
sampleCount: vehicle.sampleCount + 1,
|
|
7846
|
+
updatedAt: Date.now()
|
|
7847
|
+
};
|
|
7848
|
+
if (vehicle.coverMediaKey == null && input.mediaKey !== void 0) patch["coverMediaKey"] = input.mediaKey;
|
|
7849
|
+
await this.store.update.mutate({
|
|
7850
|
+
collection: VEHICLES_COLLECTION,
|
|
7851
|
+
id: input.vehicleId,
|
|
7852
|
+
data: patch
|
|
7853
|
+
});
|
|
7854
|
+
}
|
|
7855
|
+
return sample;
|
|
7856
|
+
}
|
|
7857
|
+
async listSamples(vehicleId) {
|
|
7858
|
+
return (await this.store.query.query({
|
|
7859
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7860
|
+
filter: {
|
|
7861
|
+
where: { vehicleId },
|
|
7862
|
+
orderBy: {
|
|
7863
|
+
field: "addedAt",
|
|
7864
|
+
direction: "asc"
|
|
7865
|
+
}
|
|
7866
|
+
}
|
|
7867
|
+
})).map((r) => {
|
|
7868
|
+
const d = r.data;
|
|
7869
|
+
return {
|
|
7870
|
+
id: r.id,
|
|
7871
|
+
text: String(d.text ?? ""),
|
|
7872
|
+
score: Number(d.score ?? 0),
|
|
7873
|
+
addedAt: Number(d.addedAt ?? 0),
|
|
7874
|
+
...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
|
|
7875
|
+
...d.deviceId != null ? { deviceId: d.deviceId } : {}
|
|
7876
|
+
};
|
|
7877
|
+
});
|
|
7878
|
+
}
|
|
7879
|
+
async removeSample(vehicleId, sampleId) {
|
|
7880
|
+
await this.store.delete.mutate({
|
|
7881
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7882
|
+
key: sampleId
|
|
7883
|
+
});
|
|
7884
|
+
const vehicle = (await this.listVehicles()).find((v) => v.id === vehicleId);
|
|
7885
|
+
if (vehicle) await this.store.update.mutate({
|
|
7886
|
+
collection: VEHICLES_COLLECTION,
|
|
7887
|
+
id: vehicleId,
|
|
7888
|
+
data: {
|
|
7889
|
+
sampleCount: Math.max(0, vehicle.sampleCount - 1),
|
|
7890
|
+
updatedAt: Date.now()
|
|
7891
|
+
}
|
|
7892
|
+
});
|
|
7893
|
+
}
|
|
7894
|
+
async loadGallery() {
|
|
7895
|
+
const rows = await this.store.query.query({
|
|
7896
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7897
|
+
filter: {}
|
|
7898
|
+
});
|
|
7899
|
+
const gallery = [];
|
|
7900
|
+
for (const r of rows) {
|
|
7901
|
+
const d = r.data;
|
|
7902
|
+
if (typeof d.text !== "string" || typeof d.vehicleId !== "string") continue;
|
|
7903
|
+
gallery.push({
|
|
7904
|
+
vehicleId: d.vehicleId,
|
|
7905
|
+
text: d.text
|
|
7906
|
+
});
|
|
7907
|
+
}
|
|
7908
|
+
return gallery;
|
|
5962
7909
|
}
|
|
5963
7910
|
};
|
|
5964
7911
|
//#endregion
|
|
7912
|
+
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
7913
|
+
/**
|
|
7914
|
+
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
7915
|
+
*
|
|
7916
|
+
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
7917
|
+
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
7918
|
+
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
7919
|
+
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
7920
|
+
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
7921
|
+
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
7922
|
+
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
7923
|
+
* "Prefer unassigned over wrong vehicle."
|
|
7924
|
+
*/
|
|
7925
|
+
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
7926
|
+
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
7927
|
+
maxDistance: 1,
|
|
7928
|
+
lowScoreFloor: .5,
|
|
7929
|
+
lowScoreMaxDistance: 0
|
|
7930
|
+
};
|
|
7931
|
+
function matchPlateText(probe, gallery, opts) {
|
|
7932
|
+
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
7933
|
+
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
7934
|
+
for (const s of gallery) {
|
|
7935
|
+
const dist = plateDistance(probe.text, s.text);
|
|
7936
|
+
const prev = bestByVehicle.get(s.vehicleId);
|
|
7937
|
+
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
7938
|
+
}
|
|
7939
|
+
if (bestByVehicle.size === 0) return null;
|
|
7940
|
+
let bestId = null;
|
|
7941
|
+
let bestDist = Infinity;
|
|
7942
|
+
let tie = false;
|
|
7943
|
+
for (const [vehicleId, dist] of bestByVehicle) {
|
|
7944
|
+
if (dist > allowed) continue;
|
|
7945
|
+
if (dist < bestDist) {
|
|
7946
|
+
bestDist = dist;
|
|
7947
|
+
bestId = vehicleId;
|
|
7948
|
+
tie = false;
|
|
7949
|
+
} else if (dist === bestDist) tie = true;
|
|
7950
|
+
}
|
|
7951
|
+
if (bestId === null || tie) return null;
|
|
7952
|
+
return {
|
|
7953
|
+
vehicleId: bestId,
|
|
7954
|
+
distance: bestDist
|
|
7955
|
+
};
|
|
7956
|
+
}
|
|
7957
|
+
//#endregion
|
|
5965
7958
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
5966
7959
|
/**
|
|
5967
7960
|
* PlateRecognizer — buffers the best license-plate OCR read per vehicle track
|
|
@@ -5973,8 +7966,38 @@ var PlateStore = class {
|
|
|
5973
7966
|
var PlateRecognizer = class {
|
|
5974
7967
|
deps;
|
|
5975
7968
|
bestPlate = /* @__PURE__ */ new Map();
|
|
7969
|
+
gallery = [];
|
|
7970
|
+
nameById = /* @__PURE__ */ new Map();
|
|
7971
|
+
matchOpts;
|
|
5976
7972
|
constructor(deps) {
|
|
5977
7973
|
this.deps = deps;
|
|
7974
|
+
this.matchOpts = deps.matchOpts ?? DEFAULT_PLATE_MATCH_OPTS;
|
|
7975
|
+
}
|
|
7976
|
+
/** Reload the in-memory vehicle gallery + name map (after a gallery mutation). */
|
|
7977
|
+
async refreshGallery() {
|
|
7978
|
+
try {
|
|
7979
|
+
this.gallery = await this.deps.vehicleStore.loadGallery();
|
|
7980
|
+
this.nameById = new Map((await this.deps.vehicleStore.listVehicles()).map((v) => [v.id, v.name]));
|
|
7981
|
+
} catch (err) {
|
|
7982
|
+
this.deps.logger.warn("PlateRecognizer.refreshGallery failed", { meta: { error: String(err) } });
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7985
|
+
matchVehicle(text, score) {
|
|
7986
|
+
const m = matchPlateText({
|
|
7987
|
+
text: normalizePlate(text),
|
|
7988
|
+
score
|
|
7989
|
+
}, this.gallery, this.matchOpts);
|
|
7990
|
+
if (m === null) return null;
|
|
7991
|
+
const name = this.nameById.get(m.vehicleId);
|
|
7992
|
+
return name !== void 0 ? {
|
|
7993
|
+
vehicleId: m.vehicleId,
|
|
7994
|
+
name
|
|
7995
|
+
} : null;
|
|
7996
|
+
}
|
|
7997
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched, else
|
|
7998
|
+
* the raw OCR text (today's behavior). Used by the ingest label path. */
|
|
7999
|
+
resolveLabel(text, score) {
|
|
8000
|
+
return this.matchVehicle(text, score)?.name ?? text;
|
|
5978
8001
|
}
|
|
5979
8002
|
async processFrame(input) {
|
|
5980
8003
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -6030,6 +8053,8 @@ var PlateRecognizer = class {
|
|
|
6030
8053
|
}
|
|
6031
8054
|
});
|
|
6032
8055
|
}
|
|
8056
|
+
const match = this.matchVehicle(held.text, held.score);
|
|
8057
|
+
const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
|
|
6033
8058
|
try {
|
|
6034
8059
|
await this.deps.plateStore.insert({
|
|
6035
8060
|
id: plateId,
|
|
@@ -6039,7 +8064,16 @@ var PlateRecognizer = class {
|
|
|
6039
8064
|
text: normalizePlate(held.text),
|
|
6040
8065
|
score: held.score,
|
|
6041
8066
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
6042
|
-
corrected: false
|
|
8067
|
+
corrected: false,
|
|
8068
|
+
assigned: false,
|
|
8069
|
+
...match !== null ? { recognizedVehicleId: match.vehicleId } : {},
|
|
8070
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
8071
|
+
plateBbox: held.bbox
|
|
8072
|
+
});
|
|
8073
|
+
this.deps.emitPlateGalleryChanged?.({
|
|
8074
|
+
deviceId,
|
|
8075
|
+
plateId,
|
|
8076
|
+
kind: "buffered"
|
|
6043
8077
|
});
|
|
6044
8078
|
this.deps.logger.info("plate: buffered to gallery", {
|
|
6045
8079
|
tags: {
|
|
@@ -6050,7 +8084,8 @@ var PlateRecognizer = class {
|
|
|
6050
8084
|
plateId,
|
|
6051
8085
|
text: normalizePlate(held.text),
|
|
6052
8086
|
score: held.score,
|
|
6053
|
-
hasCrop: mediaKey !== void 0
|
|
8087
|
+
hasCrop: mediaKey !== void 0,
|
|
8088
|
+
recognizedVehicleId: match?.vehicleId ?? null
|
|
6054
8089
|
}
|
|
6055
8090
|
});
|
|
6056
8091
|
} catch (err) {
|
|
@@ -6394,6 +8429,12 @@ function createEventMediaHandler(deps) {
|
|
|
6394
8429
|
* surface to turn the refinement pipeline on/off for a camera.
|
|
6395
8430
|
*/
|
|
6396
8431
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
8432
|
+
/** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
|
|
8433
|
+
* scheduled detail call's frameHandle lease is already gone. */
|
|
8434
|
+
var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
8435
|
+
/** How long the active CLIP model id (from the embedding-encoder) is cached
|
|
8436
|
+
* before re-reading. */
|
|
8437
|
+
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
6397
8438
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
6398
8439
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
6399
8440
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
@@ -6421,11 +8462,23 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
|
|
|
6421
8462
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
6422
8463
|
* narrowing the old raw read applied inline. */
|
|
6423
8464
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
8465
|
+
var EmbeddingEnabledSchema = boolean();
|
|
6424
8466
|
var SILENCE_FLOOR_DBFS = -55;
|
|
6425
8467
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
8468
|
+
/** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
|
|
8469
|
+
var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
6426
8470
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
6427
8471
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
6428
8472
|
/**
|
|
8473
|
+
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
8474
|
+
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
8475
|
+
*/
|
|
8476
|
+
function decodeEmbeddingBase64(base64) {
|
|
8477
|
+
const bytes = Buffer.from(base64, "base64");
|
|
8478
|
+
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
8479
|
+
return Array.from(view);
|
|
8480
|
+
}
|
|
8481
|
+
/**
|
|
6429
8482
|
* Re-home the global analytics sections into the per-device `Analytics`
|
|
6430
8483
|
* top-tab. Every section defaults to the `Analytics` tab (so the
|
|
6431
8484
|
* device-manager aggregator groups them) AND is marked
|
|
@@ -6473,8 +8526,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6473
8526
|
faceStore = null;
|
|
6474
8527
|
faceRecognizer = null;
|
|
6475
8528
|
plateStore = null;
|
|
8529
|
+
vehicleStore = null;
|
|
6476
8530
|
plateRecognizer = null;
|
|
6477
8531
|
objectEmbeddingStore = null;
|
|
8532
|
+
/** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
|
|
8533
|
+
* classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
|
|
8534
|
+
* the per-frame child consumption the executor no longer emits. */
|
|
8535
|
+
detailDispatcher = null;
|
|
8536
|
+
/** Per-track detail geometry for the live-overlay synthesis (two-plane). */
|
|
8537
|
+
overlayState = new OverlayDetailStateStore();
|
|
8538
|
+
/** Throttle state for the "overlay synthesis failed" warn, one per device. */
|
|
8539
|
+
overlaySynthesisWarnAt = /* @__PURE__ */ new Map();
|
|
8540
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
8541
|
+
* Stamped on object-embedding rows from the detail plane so semantic search's
|
|
8542
|
+
* same-model gate keeps matching. */
|
|
8543
|
+
clipModelIdCache = null;
|
|
6478
8544
|
/** Frame-based event/track media (crop + boxed full-frame) from the
|
|
6479
8545
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
6480
8546
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
@@ -6482,6 +8548,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6482
8548
|
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
6483
8549
|
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
6484
8550
|
frameReaders = null;
|
|
8551
|
+
/** Object/face embedding dispatcher — migrated from the retired
|
|
8552
|
+
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8553
|
+
* detection it resolves the frame, crops the ROI, and calls the
|
|
8554
|
+
* embedding-encoder cap (which persists the vector for the search/face
|
|
8555
|
+
* path). Null on non-post-processing nodes or when disabled. */
|
|
8556
|
+
embeddingDispatcher = null;
|
|
6485
8557
|
bindingCache = null;
|
|
6486
8558
|
zoneAnalytics = null;
|
|
6487
8559
|
audioMetrics = null;
|
|
@@ -6532,6 +8604,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6532
8604
|
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6533
8605
|
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6534
8606
|
});
|
|
8607
|
+
/** Windowed per-device aggregation of the "media capture" diagnostic — the
|
|
8608
|
+
* per-capture line is debug; a 60s per-counter SUM lands at info (~1
|
|
8609
|
+
* line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
|
|
8610
|
+
mediaCaptureLog = new MediaCaptureLogAggregator();
|
|
6535
8611
|
/** Best (highest-confidence) CLIP-object detection per track — drives ONE
|
|
6536
8612
|
* tight object-crop capture whose media key is written onto the embedding
|
|
6537
8613
|
* row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
|
|
@@ -6543,6 +8619,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6543
8619
|
* at track end so a face row links to the SAME single key frame. Cleared on
|
|
6544
8620
|
* track end. */
|
|
6545
8621
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
8622
|
+
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8623
|
+
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8624
|
+
* area) + emit time, so a material improvement is measured against the
|
|
8625
|
+
* last emit and debounced. Seeded at `start`, dropped at `end`. */
|
|
8626
|
+
trackLifecycleUpdateMem = /* @__PURE__ */ new Map();
|
|
6546
8627
|
/** The shared crop extractor (native-res first, detection-frame fallback),
|
|
6547
8628
|
* captured in the constructor so `processFrame` can crop object thumbnails in
|
|
6548
8629
|
* the same live-frame window as the face/plate/event-media captures. The
|
|
@@ -6558,6 +8639,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6558
8639
|
if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
|
|
6559
8640
|
return this._postProcessingNodeState;
|
|
6560
8641
|
}
|
|
8642
|
+
/** Master toggle for the migrated object/face EmbeddingDispatcher (default
|
|
8643
|
+
* ON — the enrichment-engine default was `embeddingEnabled:true`). Read at
|
|
8644
|
+
* init; a change takes effect on addon restart (same convention as
|
|
8645
|
+
* `postProcessingNodeId`). */
|
|
8646
|
+
_embeddingEnabledState = null;
|
|
8647
|
+
get embeddingEnabledState() {
|
|
8648
|
+
if (!this._embeddingEnabledState) this._embeddingEnabledState = this.state("embeddingEnabled", EmbeddingEnabledSchema, true);
|
|
8649
|
+
return this._embeddingEnabledState;
|
|
8650
|
+
}
|
|
6561
8651
|
/** GLOBAL face-recognition master switch (`enabled` key). Schema +
|
|
6562
8652
|
* fallback derived from the face-settings source of truth. */
|
|
6563
8653
|
_faceGlobalEnabledState = null;
|
|
@@ -6577,6 +8667,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6577
8667
|
await IdentityStore.declare(api.settingsStore);
|
|
6578
8668
|
await FaceStore.declare(api.settingsStore);
|
|
6579
8669
|
await PlateStore.declare(api.settingsStore);
|
|
8670
|
+
await VehicleStore.declare(api.settingsStore);
|
|
6580
8671
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
6581
8672
|
const logger = this.ctx.logger;
|
|
6582
8673
|
let storage = this.ctx.kernel.storage;
|
|
@@ -6598,7 +8689,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6598
8689
|
});
|
|
6599
8690
|
this.eventStore = new EventStore({
|
|
6600
8691
|
store: api.settingsStore,
|
|
6601
|
-
logger: logger.child("EventStore")
|
|
8692
|
+
logger: logger.child("EventStore"),
|
|
8693
|
+
media: this.mediaStore
|
|
6602
8694
|
});
|
|
6603
8695
|
this.identityStore = new IdentityStore({
|
|
6604
8696
|
store: api.settingsStore,
|
|
@@ -6612,6 +8704,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6612
8704
|
store: api.settingsStore,
|
|
6613
8705
|
logger: logger.child("PlateStore")
|
|
6614
8706
|
});
|
|
8707
|
+
this.vehicleStore = new VehicleStore({
|
|
8708
|
+
store: api.settingsStore,
|
|
8709
|
+
logger: logger.child("VehicleStore")
|
|
8710
|
+
});
|
|
6615
8711
|
const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
6616
8712
|
const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
|
|
6617
8713
|
{
|
|
@@ -6721,19 +8817,43 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6721
8817
|
}, trackId);
|
|
6722
8818
|
},
|
|
6723
8819
|
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8820
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
|
|
6724
8821
|
logger: logger.child("FaceRecognizer")
|
|
6725
8822
|
});
|
|
6726
8823
|
this.faceRecognizer.refreshGallery();
|
|
6727
8824
|
this.plateRecognizer = new PlateRecognizer({
|
|
6728
8825
|
plateStore: this.plateStore,
|
|
8826
|
+
vehicleStore: this.vehicleStore,
|
|
6729
8827
|
mediaStore: this.mediaStore,
|
|
6730
8828
|
captureCrop,
|
|
8829
|
+
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8830
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
|
|
6731
8831
|
logger: logger.child("PlateRecognizer")
|
|
6732
8832
|
});
|
|
8833
|
+
this.plateRecognizer.refreshGallery();
|
|
6733
8834
|
this.objectEmbeddingStore = new ObjectEmbeddingStore({
|
|
6734
8835
|
store: api.settingsStore,
|
|
6735
8836
|
logger: logger.child("ObjectEmbeddingStore")
|
|
6736
8837
|
});
|
|
8838
|
+
const runnerApi = api.pipelineRunner;
|
|
8839
|
+
this.detailDispatcher = new TrackDetailDispatcher({
|
|
8840
|
+
logger: logger.child("DetailDispatcher"),
|
|
8841
|
+
runDetailSubtree: async (input, nodeId) => {
|
|
8842
|
+
if (!runnerApi?.runDetailSubtree) return null;
|
|
8843
|
+
if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, nodePin(nodeId));
|
|
8844
|
+
return runnerApi.runDetailSubtree.mutate(input);
|
|
8845
|
+
},
|
|
8846
|
+
routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
|
|
8847
|
+
captureCropBase64: async (frame) => {
|
|
8848
|
+
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
8849
|
+
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
8850
|
+
return buf ? buf.toString("base64") : null;
|
|
8851
|
+
},
|
|
8852
|
+
hasTrackLabel: (trackId) => {
|
|
8853
|
+
const label = this.trackStore?.getActiveByTrack(trackId)?.label;
|
|
8854
|
+
return label !== void 0 && label.length > 0;
|
|
8855
|
+
}
|
|
8856
|
+
});
|
|
6737
8857
|
this.bindingCache = new BindingCache({
|
|
6738
8858
|
api,
|
|
6739
8859
|
logger: logger.child("BindingCache")
|
|
@@ -6799,12 +8919,37 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6799
8919
|
const data = ev.data;
|
|
6800
8920
|
this.handleNativeDetection(data);
|
|
6801
8921
|
});
|
|
8922
|
+
if (await this.embeddingEnabledState.get()) {
|
|
8923
|
+
const encoderClient = {
|
|
8924
|
+
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
8925
|
+
crop: new Uint8Array(crop),
|
|
8926
|
+
width,
|
|
8927
|
+
height
|
|
8928
|
+
}),
|
|
8929
|
+
getInfo: () => this.ctx.api.embeddingEncoder.getInfo.query()
|
|
8930
|
+
};
|
|
8931
|
+
this.embeddingDispatcher = new EmbeddingDispatcher({
|
|
8932
|
+
config: {
|
|
8933
|
+
...DEFAULT_EMBEDDING_CONFIG,
|
|
8934
|
+
enabled: true
|
|
8935
|
+
},
|
|
8936
|
+
encoder: encoderClient,
|
|
8937
|
+
eventBus: this.ctx.eventBus,
|
|
8938
|
+
logger: logger.child("EmbeddingDispatcher"),
|
|
8939
|
+
ownNodeId,
|
|
8940
|
+
readers: frameReadersForFaces,
|
|
8941
|
+
getRemoteFrame
|
|
8942
|
+
});
|
|
8943
|
+
await this.embeddingDispatcher.start();
|
|
8944
|
+
} else logger.info("EmbeddingDispatcher disabled via settings (embeddingEnabled=false)");
|
|
6802
8945
|
}
|
|
6803
8946
|
this.unsubBindings = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (ev) => {
|
|
6804
8947
|
const data = ev.data;
|
|
6805
8948
|
this.bindingCache?.onBindingsChanged(data);
|
|
6806
8949
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
6807
8950
|
this.trackStore?.clearDevice(data.deviceId);
|
|
8951
|
+
this.overlayState.clearDevice(data.deviceId);
|
|
8952
|
+
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
6808
8953
|
this.forgetDeviceProcessors(data.deviceId);
|
|
6809
8954
|
this.levelStateByDevice.delete(data.deviceId);
|
|
6810
8955
|
this.settingsCacheByDevice.delete(data.deviceId);
|
|
@@ -6817,6 +8962,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6817
8962
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
6818
8963
|
const { deviceId } = ev.data;
|
|
6819
8964
|
this.trackStore?.clearDevice(deviceId);
|
|
8965
|
+
this.overlayState.clearDevice(deviceId);
|
|
8966
|
+
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
6820
8967
|
this.forgetDeviceProcessors(deviceId);
|
|
6821
8968
|
this.levelStateByDevice.delete(deviceId);
|
|
6822
8969
|
this.settingsCacheByDevice.delete(deviceId);
|
|
@@ -6834,6 +8981,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6834
8981
|
}, TTL_SWEEP_INTERVAL_MS);
|
|
6835
8982
|
this.retentionSweepTimer = setInterval(() => {
|
|
6836
8983
|
this.sweepRetention();
|
|
8984
|
+
this.runTrackRetentionSweep();
|
|
6837
8985
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
6838
8986
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
6839
8987
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -7074,11 +9222,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7074
9222
|
mediaStore: this.mediaStore,
|
|
7075
9223
|
trackStore: this.trackStore,
|
|
7076
9224
|
eventStore: this.eventStore,
|
|
7077
|
-
refreshGallery: () => this.faceRecognizer?.refreshGallery()
|
|
9225
|
+
refreshGallery: () => this.faceRecognizer?.refreshGallery(),
|
|
9226
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
|
|
7078
9227
|
});
|
|
7079
9228
|
const plateGallery = new PlateGalleryProvider({
|
|
7080
9229
|
plateStore: this.plateStore,
|
|
7081
|
-
|
|
9230
|
+
vehicleStore: this.vehicleStore,
|
|
9231
|
+
mediaStore: this.mediaStore,
|
|
9232
|
+
trackStore: this.trackStore,
|
|
9233
|
+
eventStore: this.eventStore,
|
|
9234
|
+
refreshGallery: () => {
|
|
9235
|
+
this.plateRecognizer?.refreshGallery();
|
|
9236
|
+
},
|
|
9237
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
|
|
7082
9238
|
});
|
|
7083
9239
|
return [
|
|
7084
9240
|
{
|
|
@@ -7127,6 +9283,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7127
9283
|
this.unsubBindings = null;
|
|
7128
9284
|
this.unsubDeviceUnreg?.();
|
|
7129
9285
|
this.unsubDeviceUnreg = null;
|
|
9286
|
+
await this.embeddingDispatcher?.stop();
|
|
9287
|
+
this.embeddingDispatcher = null;
|
|
7130
9288
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
7131
9289
|
if (this.ttlSweepTimer) {
|
|
7132
9290
|
clearInterval(this.ttlSweepTimer);
|
|
@@ -7138,10 +9296,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7138
9296
|
}
|
|
7139
9297
|
this.zoneAnalytics?.destroy();
|
|
7140
9298
|
this.audioMetrics?.destroy();
|
|
9299
|
+
this.detailDispatcher?.dispose();
|
|
9300
|
+
this.detailDispatcher = null;
|
|
9301
|
+
this.overlayState.clear();
|
|
9302
|
+
this.overlaySynthesisWarnAt.clear();
|
|
7141
9303
|
this.processors.clear();
|
|
7142
9304
|
this.lastActiveTrackIds.clear();
|
|
7143
9305
|
this.dropoutSkipsByKey.clear();
|
|
7144
9306
|
this.bestFrameTracker.clear();
|
|
9307
|
+
this.trackLifecycleUpdateMem.clear();
|
|
7145
9308
|
this.objectEmbeddingBestSelector.clear();
|
|
7146
9309
|
this.levelStateByDevice.clear();
|
|
7147
9310
|
this.settingsCacheByDevice.clear();
|
|
@@ -7162,7 +9325,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7162
9325
|
async handleInferenceResult(data) {
|
|
7163
9326
|
if (this.shuttingDown) return;
|
|
7164
9327
|
const { deviceId, frame } = data;
|
|
7165
|
-
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
|
|
9328
|
+
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
|
|
7166
9329
|
}
|
|
7167
9330
|
/**
|
|
7168
9331
|
* Run one detection frame through the analysis layers for a given
|
|
@@ -7172,7 +9335,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7172
9335
|
* tracking/zone/event state never crosses between sources. Emits the
|
|
7173
9336
|
* SAME canonical events, distinguished only by `source`.
|
|
7174
9337
|
*/
|
|
7175
|
-
async processFrame(deviceId, frame, source, frameHandle) {
|
|
9338
|
+
async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
|
|
7176
9339
|
if (this.shuttingDown) return;
|
|
7177
9340
|
if (!await this.bindingCache.isActive(deviceId)) return;
|
|
7178
9341
|
const key = this.procKey(deviceId, source);
|
|
@@ -7273,6 +9436,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7273
9436
|
className: t.className
|
|
7274
9437
|
}
|
|
7275
9438
|
});
|
|
9439
|
+
const startPayload = buildTrackLifecyclePayload({
|
|
9440
|
+
deviceId,
|
|
9441
|
+
trackId: id,
|
|
9442
|
+
phase: "start",
|
|
9443
|
+
classes: [t.className],
|
|
9444
|
+
bestClassName: t.className,
|
|
9445
|
+
bestConfidence: t.confidence,
|
|
9446
|
+
firstSeen: result.timestamp,
|
|
9447
|
+
lastSeen: result.timestamp,
|
|
9448
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
9449
|
+
});
|
|
9450
|
+
this.emitTrackLifecycle(startPayload, result.timestamp);
|
|
9451
|
+
this.trackLifecycleUpdateMem.set(id, {
|
|
9452
|
+
lastConfidence: t.confidence,
|
|
9453
|
+
lastEmitAt: result.timestamp,
|
|
9454
|
+
...t.label !== void 0 ? { lastLabel: t.label } : {},
|
|
9455
|
+
lastBboxArea: t.bbox.w * t.bbox.h
|
|
9456
|
+
});
|
|
7276
9457
|
}
|
|
7277
9458
|
}
|
|
7278
9459
|
let lostTrackCount = 0;
|
|
@@ -7284,6 +9465,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7284
9465
|
} });
|
|
7285
9466
|
}
|
|
7286
9467
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
9468
|
+
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9469
|
+
const dispatcher = this.detailDispatcher;
|
|
9470
|
+
const steps = detailSteps;
|
|
9471
|
+
for (const t of result.tracked) {
|
|
9472
|
+
const detailFrame = {
|
|
9473
|
+
bbox: { ...t.bbox },
|
|
9474
|
+
frameWidth: result.frameWidth,
|
|
9475
|
+
frameHeight: result.frameHeight,
|
|
9476
|
+
className: t.className,
|
|
9477
|
+
confidence: t.confidence,
|
|
9478
|
+
timestamp: result.timestamp,
|
|
9479
|
+
...frameHandle !== void 0 ? {
|
|
9480
|
+
frameHandle,
|
|
9481
|
+
nodeId: frameHandle.nodeId
|
|
9482
|
+
} : {}
|
|
9483
|
+
};
|
|
9484
|
+
if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
|
|
9485
|
+
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
9486
|
+
}
|
|
9487
|
+
}
|
|
7287
9488
|
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
7288
9489
|
const byState = {};
|
|
7289
9490
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
@@ -7316,7 +9517,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7316
9517
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
7317
9518
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
7318
9519
|
const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
|
|
7319
|
-
const eventTargets = result.objectEvents.filter((e) => e.bbox).map((e) => {
|
|
9520
|
+
const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
7320
9521
|
const childCrops = childCropsByEvent.get(e.id);
|
|
7321
9522
|
return {
|
|
7322
9523
|
eventId: e.id,
|
|
@@ -7332,15 +9533,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7332
9533
|
let plateCrops = 0;
|
|
7333
9534
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
7334
9535
|
else plateCrops += 1;
|
|
7335
|
-
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
9536
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
7336
9537
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
7337
|
-
|
|
7338
|
-
source,
|
|
9538
|
+
const captureCounts = {
|
|
7339
9539
|
events: eventTargets.length,
|
|
7340
9540
|
trackFrames: firstFrameTargets.length,
|
|
7341
9541
|
snapshots: snapshotTargets.length,
|
|
7342
9542
|
faceCrops,
|
|
7343
9543
|
plateCrops
|
|
9544
|
+
};
|
|
9545
|
+
log.debug("media capture", { meta: {
|
|
9546
|
+
source,
|
|
9547
|
+
...captureCounts
|
|
9548
|
+
} });
|
|
9549
|
+
const captureAgg = this.mediaCaptureLog.note(deviceId, captureCounts, Date.now());
|
|
9550
|
+
if (captureAgg !== null) log.info("media capture (window)", { meta: {
|
|
9551
|
+
source,
|
|
9552
|
+
windowSec: Math.round(captureAgg.windowMs / 1e3),
|
|
9553
|
+
frames: captureAgg.frames,
|
|
9554
|
+
...captureAgg.sums
|
|
7344
9555
|
} });
|
|
7345
9556
|
this.eventMediaDispatcher.captureForFrame({
|
|
7346
9557
|
deviceId,
|
|
@@ -7403,6 +9614,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7403
9614
|
zones: e.zones ?? []
|
|
7404
9615
|
}
|
|
7405
9616
|
});
|
|
9617
|
+
let overlayDetections;
|
|
9618
|
+
try {
|
|
9619
|
+
overlayDetections = buildOverlayDetections({
|
|
9620
|
+
frameDetections: frame.detections,
|
|
9621
|
+
tracked: result.tracked,
|
|
9622
|
+
overlayFor: (trackId) => this.overlayState.get(deviceId, trackId),
|
|
9623
|
+
labelFor: (trackId) => this.trackStore?.getActiveByTrack(trackId)?.label,
|
|
9624
|
+
nowMs: Date.now()
|
|
9625
|
+
});
|
|
9626
|
+
} catch (err) {
|
|
9627
|
+
const now = Date.now();
|
|
9628
|
+
if (now - (this.overlaySynthesisWarnAt.get(deviceId) ?? 0) >= OVERLAY_SYNTHESIS_WARN_THROTTLE_MS) {
|
|
9629
|
+
this.overlaySynthesisWarnAt.set(deviceId, now);
|
|
9630
|
+
this.ctx.logger.warn("overlay synthesis failed — emitting unenriched frame detections", {
|
|
9631
|
+
tags: { deviceId },
|
|
9632
|
+
meta: { error: String(err) }
|
|
9633
|
+
});
|
|
9634
|
+
}
|
|
9635
|
+
overlayDetections = frame.detections;
|
|
9636
|
+
}
|
|
7406
9637
|
this.ctx.eventBus.emit({
|
|
7407
9638
|
id: `pa-${randomUUID()}`,
|
|
7408
9639
|
timestamp: new Date(result.timestamp),
|
|
@@ -7417,7 +9648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7417
9648
|
timestamp: result.timestamp,
|
|
7418
9649
|
frameWidth: result.frameWidth,
|
|
7419
9650
|
frameHeight: result.frameHeight,
|
|
7420
|
-
detections:
|
|
9651
|
+
detections: overlayDetections
|
|
7421
9652
|
}
|
|
7422
9653
|
});
|
|
7423
9654
|
}
|
|
@@ -7495,6 +9726,158 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7495
9726
|
return settings;
|
|
7496
9727
|
}
|
|
7497
9728
|
/**
|
|
9729
|
+
* Route one track's `runDetailSubtree` results (two-plane detail dispatch)
|
|
9730
|
+
* into the EXISTING per-track consumers, discriminated by payload SHAPE:
|
|
9731
|
+
* • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
|
|
9732
|
+
* candidate/best-face/gallery/keyFrame path (input source cut-over);
|
|
9733
|
+
* • embedding only → CLIP object embedding → the object-embedding store
|
|
9734
|
+
* (semantic search);
|
|
9735
|
+
* • label only → classifier answer / plate OCR text → the track's
|
|
9736
|
+
* enrichment label the notifier/UI already read.
|
|
9737
|
+
* Best-effort (D8): a per-detail failure is logged and never propagated.
|
|
9738
|
+
*/
|
|
9739
|
+
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
9740
|
+
for (const d of details) try {
|
|
9741
|
+
const isFaceDetail = d.className === "face";
|
|
9742
|
+
if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
|
|
9743
|
+
x: d.bbox.x,
|
|
9744
|
+
y: d.bbox.y,
|
|
9745
|
+
w: d.bbox.w,
|
|
9746
|
+
h: d.bbox.h
|
|
9747
|
+
}, d.score, frame.timestamp);
|
|
9748
|
+
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
9749
|
+
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
9750
|
+
else if (d.label !== void 0 && d.label.length > 0) {
|
|
9751
|
+
if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
9752
|
+
x: d.bbox.x,
|
|
9753
|
+
y: d.bbox.y,
|
|
9754
|
+
w: d.bbox.w,
|
|
9755
|
+
h: d.bbox.h
|
|
9756
|
+
}, d.score, d.label, frame.timestamp);
|
|
9757
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
|
|
9758
|
+
await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9759
|
+
}
|
|
9760
|
+
} catch (err) {
|
|
9761
|
+
this.ctx.logger.warn("detail result route failed", {
|
|
9762
|
+
tags: { deviceId },
|
|
9763
|
+
meta: {
|
|
9764
|
+
trackId,
|
|
9765
|
+
stepId: d.stepId,
|
|
9766
|
+
error: errMsg(err)
|
|
9767
|
+
}
|
|
9768
|
+
});
|
|
9769
|
+
}
|
|
9770
|
+
}
|
|
9771
|
+
/** Face-embedding detail → the FaceRecognizer (same gate + logic as the
|
|
9772
|
+
* former per-frame face path; only the input source moved). */
|
|
9773
|
+
async routeFaceDetail(deviceId, trackId, detail, frame) {
|
|
9774
|
+
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
9775
|
+
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
9776
|
+
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
9777
|
+
await this.faceRecognizer.ingestFaceDetail({
|
|
9778
|
+
deviceId,
|
|
9779
|
+
trackId,
|
|
9780
|
+
timestamp: frame.timestamp,
|
|
9781
|
+
frameWidth: frame.frameWidth,
|
|
9782
|
+
frameHeight: frame.frameHeight,
|
|
9783
|
+
score: detail.score,
|
|
9784
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
9785
|
+
parentBbox: { ...frame.bbox },
|
|
9786
|
+
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
9787
|
+
...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
|
|
9788
|
+
settings,
|
|
9789
|
+
cropPadding: media.cropPadding,
|
|
9790
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
9791
|
+
});
|
|
9792
|
+
}
|
|
9793
|
+
/** CLIP object-embedding detail → the object-embedding store (semantic
|
|
9794
|
+
* search). Stamps the active encoder's model id so the same-model search
|
|
9795
|
+
* gate keeps matching. */
|
|
9796
|
+
async routeClipDetail(deviceId, trackId, detail, timestamp) {
|
|
9797
|
+
const store = this.objectEmbeddingStore;
|
|
9798
|
+
if (!store || detail.embedding === void 0) return;
|
|
9799
|
+
const modelId = await this.resolveClipModelId();
|
|
9800
|
+
if (modelId === null) {
|
|
9801
|
+
this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
|
|
9802
|
+
tags: { deviceId },
|
|
9803
|
+
meta: {
|
|
9804
|
+
trackId,
|
|
9805
|
+
stepId: detail.stepId
|
|
9806
|
+
}
|
|
9807
|
+
});
|
|
9808
|
+
return;
|
|
9809
|
+
}
|
|
9810
|
+
await store.upsertIfBetter({
|
|
9811
|
+
trackId,
|
|
9812
|
+
deviceId,
|
|
9813
|
+
timestamp,
|
|
9814
|
+
className: detail.className,
|
|
9815
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
9816
|
+
modelId,
|
|
9817
|
+
confidence: detail.score
|
|
9818
|
+
});
|
|
9819
|
+
}
|
|
9820
|
+
/** Classifier answer / plate OCR text → the track's enrichment label
|
|
9821
|
+
* (TrackStore + persisted events + importance), mirroring the FaceRecognizer
|
|
9822
|
+
* label-propagation path. */
|
|
9823
|
+
async applyTrackEnrichmentLabel(deviceId, trackId, label) {
|
|
9824
|
+
try {
|
|
9825
|
+
await this.trackStore?.setLabel(trackId, label);
|
|
9826
|
+
} catch (err) {
|
|
9827
|
+
this.ctx.logger.warn("detail label setLabel failed", {
|
|
9828
|
+
tags: { deviceId },
|
|
9829
|
+
meta: {
|
|
9830
|
+
trackId,
|
|
9831
|
+
error: errMsg(err)
|
|
9832
|
+
}
|
|
9833
|
+
});
|
|
9834
|
+
}
|
|
9835
|
+
try {
|
|
9836
|
+
await this.eventStore?.setLabelForTrack(trackId, label);
|
|
9837
|
+
} catch (err) {
|
|
9838
|
+
this.ctx.logger.warn("detail label setLabelForTrack failed", {
|
|
9839
|
+
tags: { deviceId },
|
|
9840
|
+
meta: {
|
|
9841
|
+
trackId,
|
|
9842
|
+
error: errMsg(err)
|
|
9843
|
+
}
|
|
9844
|
+
});
|
|
9845
|
+
}
|
|
9846
|
+
try {
|
|
9847
|
+
const trackStore = this.trackStore;
|
|
9848
|
+
const eventStore = this.eventStore;
|
|
9849
|
+
if (trackStore && eventStore) await recomputeTrackImportance({
|
|
9850
|
+
trackStore,
|
|
9851
|
+
eventStore
|
|
9852
|
+
}, trackId);
|
|
9853
|
+
} catch (err) {
|
|
9854
|
+
this.ctx.logger.debug("detail label recomputeImportance failed", {
|
|
9855
|
+
tags: { deviceId },
|
|
9856
|
+
meta: {
|
|
9857
|
+
trackId,
|
|
9858
|
+
error: errMsg(err)
|
|
9859
|
+
}
|
|
9860
|
+
});
|
|
9861
|
+
}
|
|
9862
|
+
}
|
|
9863
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
9864
|
+
* Returns null when the embedding-encoder cap is unavailable. */
|
|
9865
|
+
async resolveClipModelId() {
|
|
9866
|
+
const now = Date.now();
|
|
9867
|
+
if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
|
|
9868
|
+
let value = null;
|
|
9869
|
+
try {
|
|
9870
|
+
value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
|
|
9871
|
+
} catch (err) {
|
|
9872
|
+
this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: errMsg(err) } });
|
|
9873
|
+
}
|
|
9874
|
+
this.clipModelIdCache = {
|
|
9875
|
+
value,
|
|
9876
|
+
expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
|
|
9877
|
+
};
|
|
9878
|
+
return value;
|
|
9879
|
+
}
|
|
9880
|
+
/**
|
|
7498
9881
|
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
7499
9882
|
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
7500
9883
|
* • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
|
|
@@ -7581,12 +9964,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7581
9964
|
});
|
|
7582
9965
|
}));
|
|
7583
9966
|
}
|
|
7584
|
-
|
|
9967
|
+
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9968
|
+
emitTrackLifecycle(payload, timestamp) {
|
|
9969
|
+
this.ctx.eventBus.emit({
|
|
9970
|
+
id: `pa-life-${randomUUID()}`,
|
|
9971
|
+
timestamp: new Date(timestamp),
|
|
9972
|
+
source: {
|
|
9973
|
+
type: "addon",
|
|
9974
|
+
id: "pipeline-analytics",
|
|
9975
|
+
addonId: "pipeline-analytics"
|
|
9976
|
+
},
|
|
9977
|
+
category: EventCategory.PipelineAnalyticsTrackLifecycle,
|
|
9978
|
+
data: payload
|
|
9979
|
+
});
|
|
9980
|
+
}
|
|
9981
|
+
/**
|
|
9982
|
+
* Fire a `phase:'update'` lifecycle event when this frame's observation is
|
|
9983
|
+
* a MATERIAL improvement over the last emit (confidence delta, a
|
|
9984
|
+
* newly-resolved identity/plate label, or a materially larger crop),
|
|
9985
|
+
* debounced per `DEFAULT_UPDATE_GATE_CONFIG`. Reuses the already-computed
|
|
9986
|
+
* `isNewBest`; the gate is pure and the per-track memory is advanced in
|
|
9987
|
+
* place. Carries the CURRENT best incl. the improved key-frame media key.
|
|
9988
|
+
*/
|
|
9989
|
+
maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest) {
|
|
9990
|
+
const memory = this.trackLifecycleUpdateMem.get(t.trackId);
|
|
9991
|
+
const decision = evaluateTrackLifecycleUpdate({
|
|
9992
|
+
isNewBest,
|
|
9993
|
+
confidence: t.confidence,
|
|
9994
|
+
now: timestamp,
|
|
9995
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
9996
|
+
bboxArea: t.bbox.w * t.bbox.h
|
|
9997
|
+
}, memory, DEFAULT_UPDATE_GATE_CONFIG);
|
|
9998
|
+
this.trackLifecycleUpdateMem.set(t.trackId, decision.memory);
|
|
9999
|
+
if (!decision.emit) return;
|
|
10000
|
+
const track = this.trackStore?.getActiveByTrack(t.trackId);
|
|
10001
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
10002
|
+
const payload = buildTrackLifecyclePayload({
|
|
10003
|
+
deviceId,
|
|
10004
|
+
trackId: t.trackId,
|
|
10005
|
+
phase: "update",
|
|
10006
|
+
classes: track?.classes ?? [t.className],
|
|
10007
|
+
bestClassName: t.className,
|
|
10008
|
+
bestConfidence: t.confidence,
|
|
10009
|
+
firstSeen: track?.firstSeen ?? timestamp,
|
|
10010
|
+
lastSeen: track?.lastSeen ?? timestamp,
|
|
10011
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
10012
|
+
...t.plateText !== void 0 ? { plateText: t.plateText } : {},
|
|
10013
|
+
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10014
|
+
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10015
|
+
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
10016
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10017
|
+
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10018
|
+
});
|
|
10019
|
+
this.emitTrackLifecycle(payload, timestamp);
|
|
10020
|
+
}
|
|
10021
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
7585
10022
|
const targets = [];
|
|
7586
10023
|
for (const t of tracked) {
|
|
7587
10024
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
7588
10025
|
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
7589
10026
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10027
|
+
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
7590
10028
|
if (!dueSnapshot && !isNewBest) continue;
|
|
7591
10029
|
targets.push({
|
|
7592
10030
|
trackId: t.trackId,
|
|
@@ -7852,6 +10290,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7852
10290
|
positions: t.positions.length
|
|
7853
10291
|
}
|
|
7854
10292
|
});
|
|
10293
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
7855
10294
|
const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7856
10295
|
const dropKeyFrameKey = () => {
|
|
7857
10296
|
this.keyFrameKeyByTrackId.delete(t.trackId);
|
|
@@ -7859,11 +10298,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7859
10298
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
7860
10299
|
else dropKeyFrameKey();
|
|
7861
10300
|
this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
10301
|
+
const trackerPeak = this.bestFrameTracker.peak(t.trackId);
|
|
10302
|
+
let endImportance;
|
|
10303
|
+
let endImportanceReason;
|
|
10304
|
+
let endBestEventId;
|
|
7862
10305
|
try {
|
|
7863
10306
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
7864
10307
|
if (peak) {
|
|
10308
|
+
endBestEventId = peak.bestEventId;
|
|
7865
10309
|
const { importance, reason } = computeImportance({
|
|
7866
|
-
peakConfidence:
|
|
10310
|
+
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
7867
10311
|
className: t.className,
|
|
7868
10312
|
durationMs: duration,
|
|
7869
10313
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
@@ -7871,6 +10315,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7871
10315
|
zonesVisited: t.zonesVisited,
|
|
7872
10316
|
...t.label !== void 0 ? { label: t.label } : {}
|
|
7873
10317
|
});
|
|
10318
|
+
endImportance = importance;
|
|
10319
|
+
endImportanceReason = reason;
|
|
7874
10320
|
await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
|
|
7875
10321
|
if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
|
|
7876
10322
|
}
|
|
@@ -7882,6 +10328,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7882
10328
|
}
|
|
7883
10329
|
this.bestFrameTracker.delete(t.trackId);
|
|
7884
10330
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
10331
|
+
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10332
|
+
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10333
|
+
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
10334
|
+
const captureAgg = this.mediaCaptureLog.flush(t.deviceId, Date.now());
|
|
10335
|
+
if (captureAgg !== null) this.ctx.logger.info("media capture (window)", {
|
|
10336
|
+
tags: { deviceId: t.deviceId },
|
|
10337
|
+
meta: {
|
|
10338
|
+
windowSec: Math.round(captureAgg.windowMs / 1e3),
|
|
10339
|
+
frames: captureAgg.frames,
|
|
10340
|
+
...captureAgg.sums
|
|
10341
|
+
}
|
|
10342
|
+
});
|
|
10343
|
+
}
|
|
7885
10344
|
this.ctx.eventBus.emit({
|
|
7886
10345
|
id: `pa-end-${t.trackId}`,
|
|
7887
10346
|
timestamp: new Date(t.lastSeen),
|
|
@@ -7898,6 +10357,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7898
10357
|
durationMs: duration
|
|
7899
10358
|
}
|
|
7900
10359
|
});
|
|
10360
|
+
const endPayload = buildTrackLifecyclePayload({
|
|
10361
|
+
deviceId: t.deviceId,
|
|
10362
|
+
trackId: t.trackId,
|
|
10363
|
+
phase: "end",
|
|
10364
|
+
classes: t.classes ?? [t.className],
|
|
10365
|
+
bestClassName: t.className,
|
|
10366
|
+
bestConfidence: trackerPeak?.confidence ?? 0,
|
|
10367
|
+
firstSeen: t.firstSeen,
|
|
10368
|
+
lastSeen: t.lastSeen,
|
|
10369
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
10370
|
+
zonesVisited: t.zonesVisited,
|
|
10371
|
+
totalDistance: t.totalDistance,
|
|
10372
|
+
positionsCount: t.positions.length,
|
|
10373
|
+
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10374
|
+
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
10375
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10376
|
+
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10377
|
+
});
|
|
10378
|
+
this.emitTrackLifecycle(endPayload, t.lastSeen);
|
|
10379
|
+
this.trackLifecycleUpdateMem.delete(t.trackId);
|
|
7901
10380
|
}
|
|
7902
10381
|
} catch (err) {
|
|
7903
10382
|
if (this.shuttingDown) return;
|
|
@@ -7989,6 +10468,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7989
10468
|
if (!frame) return;
|
|
7990
10469
|
await this.processFrame(payload.cameraId, frame, "onboard");
|
|
7991
10470
|
}
|
|
10471
|
+
/**
|
|
10472
|
+
* Best-effort bus notification for a gallery face-row change (buffer /
|
|
10473
|
+
* assign / unassign / delete). Passed into the `FaceRecognizer` and
|
|
10474
|
+
* `FaceGalleryProvider` as their `emitFaceGalleryChanged` dep. Telemetry
|
|
10475
|
+
* (D8): a lost/failed emit only delays the admin-ui live-refresh — never
|
|
10476
|
+
* allowed to fail the caller's mutation.
|
|
10477
|
+
*/
|
|
10478
|
+
emitFaceGalleryChanged(payload) {
|
|
10479
|
+
try {
|
|
10480
|
+
this.ctx.eventBus.emit({
|
|
10481
|
+
id: `pa-${randomUUID()}`,
|
|
10482
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10483
|
+
source: {
|
|
10484
|
+
type: "addon",
|
|
10485
|
+
id: "pipeline-analytics",
|
|
10486
|
+
addonId: "pipeline-analytics"
|
|
10487
|
+
},
|
|
10488
|
+
category: EventCategory.PipelineAnalyticsFaceGalleryChanged,
|
|
10489
|
+
data: payload
|
|
10490
|
+
});
|
|
10491
|
+
} catch (err) {
|
|
10492
|
+
this.ctx.logger.debug("face-gallery-changed emit failed", { meta: {
|
|
10493
|
+
deviceId: payload.deviceId,
|
|
10494
|
+
faceId: payload.faceId,
|
|
10495
|
+
error: String(err)
|
|
10496
|
+
} });
|
|
10497
|
+
}
|
|
10498
|
+
}
|
|
10499
|
+
/**
|
|
10500
|
+
* Best-effort bus notification for a gallery plate-row change (buffer /
|
|
10501
|
+
* assign / unassign / delete). Passed into the `PlateRecognizer` and
|
|
10502
|
+
* `PlateGalleryProvider` as their `emitPlateGalleryChanged` dep. Telemetry
|
|
10503
|
+
* (D8): a lost/failed emit only delays the admin-ui live-refresh — never
|
|
10504
|
+
* allowed to fail the caller's mutation.
|
|
10505
|
+
*/
|
|
10506
|
+
emitPlateGalleryChanged(payload) {
|
|
10507
|
+
try {
|
|
10508
|
+
this.ctx.eventBus.emit({
|
|
10509
|
+
id: `pa-${randomUUID()}`,
|
|
10510
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10511
|
+
source: {
|
|
10512
|
+
type: "addon",
|
|
10513
|
+
id: "pipeline-analytics",
|
|
10514
|
+
addonId: "pipeline-analytics"
|
|
10515
|
+
},
|
|
10516
|
+
category: EventCategory.PipelineAnalyticsPlateGalleryChanged,
|
|
10517
|
+
data: payload
|
|
10518
|
+
});
|
|
10519
|
+
} catch (err) {
|
|
10520
|
+
this.ctx.logger.debug("plate-gallery-changed emit failed", { meta: {
|
|
10521
|
+
deviceId: payload.deviceId,
|
|
10522
|
+
plateId: payload.plateId,
|
|
10523
|
+
error: String(err)
|
|
10524
|
+
} });
|
|
10525
|
+
}
|
|
10526
|
+
}
|
|
7992
10527
|
/** Composite key for the per-(device, source) processor + track maps. */
|
|
7993
10528
|
procKey(deviceId, source) {
|
|
7994
10529
|
return `${deviceId}:${source}`;
|
|
@@ -8088,6 +10623,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8088
10623
|
}
|
|
8089
10624
|
async clearTracks(input) {
|
|
8090
10625
|
this.trackStore?.clearDevice(input.deviceId);
|
|
10626
|
+
this.overlayState.clearDevice(input.deviceId);
|
|
10627
|
+
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
8091
10628
|
const prefix = `${input.deviceId}:`;
|
|
8092
10629
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
8093
10630
|
}
|
|
@@ -8264,6 +10801,168 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8264
10801
|
return counts;
|
|
8265
10802
|
}
|
|
8266
10803
|
/**
|
|
10804
|
+
* The widened, extensible track-deletion cascade registry (design §3): every
|
|
10805
|
+
* leaf store that holds track-scoped rows, ORDERED leaves-first (the track
|
|
10806
|
+
* root is deleted internally, last, by `cascadeDeleteTracks`). eventStore
|
|
10807
|
+
* folds in its event-owned media; mediaStore removes track/face/plate crops
|
|
10808
|
+
* (never identity); faceStore removes unassigned (non-enrolled) reads;
|
|
10809
|
+
* objectEmbeddingStore removes the per-track CLIP vector. The plate leg is
|
|
10810
|
+
* deferred until the plate `assigned` enrollment model lands (design §11.3).
|
|
10811
|
+
* Returns null when the required stores aren't ready.
|
|
10812
|
+
*/
|
|
10813
|
+
buildTrackCascadeRegistry() {
|
|
10814
|
+
const eventStore = this.eventStore;
|
|
10815
|
+
const mediaStore = this.mediaStore;
|
|
10816
|
+
const trackStore = this.trackStore;
|
|
10817
|
+
if (!eventStore || !mediaStore || !trackStore) return null;
|
|
10818
|
+
const registry = [eventStore, mediaStore];
|
|
10819
|
+
if (this.faceStore) registry.push(this.faceStore);
|
|
10820
|
+
if (this.plateStore) registry.push(this.plateStore);
|
|
10821
|
+
if (this.objectEmbeddingStore) registry.push(this.objectEmbeddingStore);
|
|
10822
|
+
return {
|
|
10823
|
+
registry,
|
|
10824
|
+
trackStore
|
|
10825
|
+
};
|
|
10826
|
+
}
|
|
10827
|
+
/**
|
|
10828
|
+
* Clear the addon-level per-track in-memory scheduler/overlay state that a
|
|
10829
|
+
* natural track-end (see `sweepExpiredTracks`) clears — otherwise a pruned
|
|
10830
|
+
* track keeps drawing overlay boxes and the dispatcher keeps scheduling detail
|
|
10831
|
+
* work until it ends on its own. Fired per successfully-cascaded track by all
|
|
10832
|
+
* three retention entry points (design §5).
|
|
10833
|
+
*/
|
|
10834
|
+
clearLiveTrackState(deviceId, trackId) {
|
|
10835
|
+
this.detailDispatcher?.onTrackEnded(deviceId, trackId);
|
|
10836
|
+
this.overlayState.onTrackEnded(deviceId, trackId);
|
|
10837
|
+
}
|
|
10838
|
+
async deleteTracks(input) {
|
|
10839
|
+
const cascade = this.buildTrackCascadeRegistry();
|
|
10840
|
+
if (!cascade) return {
|
|
10841
|
+
deleted: 0,
|
|
10842
|
+
failed: [...input.trackIds]
|
|
10843
|
+
};
|
|
10844
|
+
const { deleted, failed } = await runTrackCascadeBatch({
|
|
10845
|
+
registry: cascade.registry,
|
|
10846
|
+
trackStore: cascade.trackStore,
|
|
10847
|
+
deviceId: input.deviceId,
|
|
10848
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
10849
|
+
onFailure: (trackId, err) => {
|
|
10850
|
+
this.ctx.logger.debug("deleteTracks failed", { meta: {
|
|
10851
|
+
trackId,
|
|
10852
|
+
error: String(err)
|
|
10853
|
+
} });
|
|
10854
|
+
}
|
|
10855
|
+
}, input.trackIds);
|
|
10856
|
+
if (deleted > 0) this.ctx.logger.info("analytics tracks deleted", {
|
|
10857
|
+
tags: { deviceId: input.deviceId },
|
|
10858
|
+
meta: {
|
|
10859
|
+
deviceId: input.deviceId,
|
|
10860
|
+
deleted,
|
|
10861
|
+
failed: failed.length
|
|
10862
|
+
}
|
|
10863
|
+
});
|
|
10864
|
+
return {
|
|
10865
|
+
deleted,
|
|
10866
|
+
failed
|
|
10867
|
+
};
|
|
10868
|
+
}
|
|
10869
|
+
/**
|
|
10870
|
+
* Track-centric time-based retention (design §5.1). Drains every persisted
|
|
10871
|
+
* track for the device whose `lastSeen < cutoffMs`, page by page, through the
|
|
10872
|
+
* widened cascade — enrolled faces/plates + identity media are exempt (design
|
|
10873
|
+
* §4). `tracks` is the authoritative count; leaf families are best-effort 0
|
|
10874
|
+
* under the void `TrackScopedStore` contract (see cap doc).
|
|
10875
|
+
*/
|
|
10876
|
+
async pruneTracksBefore(input) {
|
|
10877
|
+
const empty = {
|
|
10878
|
+
tracks: 0,
|
|
10879
|
+
events: 0,
|
|
10880
|
+
media: 0,
|
|
10881
|
+
faces: 0,
|
|
10882
|
+
plates: 0,
|
|
10883
|
+
embeddings: 0
|
|
10884
|
+
};
|
|
10885
|
+
const cascade = this.buildTrackCascadeRegistry();
|
|
10886
|
+
if (!cascade) return empty;
|
|
10887
|
+
const PAGE = 200;
|
|
10888
|
+
let totalDeleted = 0;
|
|
10889
|
+
for (;;) {
|
|
10890
|
+
if (this.shuttingDown) break;
|
|
10891
|
+
const ids = await cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, PAGE);
|
|
10892
|
+
if (ids.length === 0) break;
|
|
10893
|
+
const { deleted } = await runTrackCascadeBatch({
|
|
10894
|
+
registry: cascade.registry,
|
|
10895
|
+
trackStore: cascade.trackStore,
|
|
10896
|
+
deviceId: input.deviceId,
|
|
10897
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
10898
|
+
onFailure: (trackId, err) => {
|
|
10899
|
+
this.ctx.logger.debug("pruneTracksBefore track failed", { meta: {
|
|
10900
|
+
trackId,
|
|
10901
|
+
error: String(err)
|
|
10902
|
+
} });
|
|
10903
|
+
}
|
|
10904
|
+
}, ids);
|
|
10905
|
+
totalDeleted += deleted;
|
|
10906
|
+
if (deleted === 0) break;
|
|
10907
|
+
}
|
|
10908
|
+
if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
|
|
10909
|
+
tags: { deviceId: input.deviceId },
|
|
10910
|
+
meta: {
|
|
10911
|
+
deviceId: input.deviceId,
|
|
10912
|
+
cutoffMs: input.cutoffMs,
|
|
10913
|
+
tracks: totalDeleted
|
|
10914
|
+
}
|
|
10915
|
+
});
|
|
10916
|
+
return {
|
|
10917
|
+
...empty,
|
|
10918
|
+
tracks: totalDeleted
|
|
10919
|
+
};
|
|
10920
|
+
}
|
|
10921
|
+
/**
|
|
10922
|
+
* Operator "clean slate" for a device (design §5.3): prune EVERY persisted
|
|
10923
|
+
* track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
|
|
10924
|
+
* Enrolled gallery + identity media are exempt.
|
|
10925
|
+
*/
|
|
10926
|
+
async wipeAllAnalytics(input) {
|
|
10927
|
+
return this.pruneTracksBefore({
|
|
10928
|
+
deviceId: input.deviceId,
|
|
10929
|
+
cutoffMs: Date.now()
|
|
10930
|
+
});
|
|
10931
|
+
}
|
|
10932
|
+
/**
|
|
10933
|
+
* Periodic per-device track retention sweep (design §6): for every device
|
|
10934
|
+
* that has persisted tracks, prune those older than its `trackRetentionDays`
|
|
10935
|
+
* window (default 7 days; 0 = keep forever → skipped). Runs on the shared
|
|
10936
|
+
* retention interval. Enrolled gallery is exempt by the cascade store layer.
|
|
10937
|
+
*/
|
|
10938
|
+
async runTrackRetentionSweep() {
|
|
10939
|
+
if (this.shuttingDown || !this.trackStore) return;
|
|
10940
|
+
const trackStore = this.trackStore;
|
|
10941
|
+
try {
|
|
10942
|
+
const total = await sweepTrackRetention({
|
|
10943
|
+
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
10944
|
+
resolveRetentionDays: async (deviceId) => {
|
|
10945
|
+
return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
10946
|
+
},
|
|
10947
|
+
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
10948
|
+
deviceId,
|
|
10949
|
+
cutoffMs
|
|
10950
|
+
}),
|
|
10951
|
+
now: () => Date.now(),
|
|
10952
|
+
onError: (deviceId, err) => {
|
|
10953
|
+
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
10954
|
+
deviceId,
|
|
10955
|
+
error: String(err)
|
|
10956
|
+
} });
|
|
10957
|
+
}
|
|
10958
|
+
});
|
|
10959
|
+
if (total > 0) this.ctx.logger.info("analytics track retention sweep", { meta: { tracks: total } });
|
|
10960
|
+
} catch (err) {
|
|
10961
|
+
if (this.shuttingDown) return;
|
|
10962
|
+
this.ctx.logger.debug("runTrackRetentionSweep failed", { meta: { error: String(err) } });
|
|
10963
|
+
}
|
|
10964
|
+
}
|
|
10965
|
+
/**
|
|
8267
10966
|
* Decode a stored event image into `EventMedia` for the data-plane handler.
|
|
8268
10967
|
* Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
|
|
8269
10968
|
* (native-res boxed frame) → any available file. Returns `null` if the
|
|
@@ -8324,6 +11023,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8324
11023
|
label: "Post-processing node",
|
|
8325
11024
|
description: "Node id that persists events + media (e.g. \"hub\"). Leave \"hub\" unless you intentionally move post-processing to an agent. Takes effect on addon restart.",
|
|
8326
11025
|
default: POST_PROCESSING_NODE_DEFAULT
|
|
11026
|
+
}, {
|
|
11027
|
+
type: "boolean",
|
|
11028
|
+
key: "embeddingEnabled",
|
|
11029
|
+
label: "Object/face embeddings",
|
|
11030
|
+
description: "Compute object/face embeddings from detection crops (feeds similarity search + face recognition). Runs on the post-processing node. Takes effect on addon restart.",
|
|
11031
|
+
default: true
|
|
8327
11032
|
}]
|
|
8328
11033
|
},
|
|
8329
11034
|
{
|
|
@@ -8430,9 +11135,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8430
11135
|
{
|
|
8431
11136
|
id: "retention",
|
|
8432
11137
|
title: "Retention",
|
|
8433
|
-
description: "How long
|
|
11138
|
+
description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
|
|
8434
11139
|
columns: 3,
|
|
8435
11140
|
fields: [
|
|
11141
|
+
{
|
|
11142
|
+
type: "number",
|
|
11143
|
+
key: "trackRetentionDays",
|
|
11144
|
+
label: "Tracks",
|
|
11145
|
+
description: "Age out persisted tracks and everything derived from them (events, media, faces, embeddings) beyond this window. 0 = keep forever. Enrolled faces/plates are never pruned.",
|
|
11146
|
+
min: 0,
|
|
11147
|
+
max: 365,
|
|
11148
|
+
step: 1,
|
|
11149
|
+
default: TRACK_RETENTION_DEFAULT_DAYS,
|
|
11150
|
+
unit: "days"
|
|
11151
|
+
},
|
|
8436
11152
|
{
|
|
8437
11153
|
type: "number",
|
|
8438
11154
|
key: "retentionMotionDays",
|