@camstack/addon-post-analysis 1.1.27 → 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-BqOBYSWs.mjs → dist-Blpsv-M0.mjs} +998 -54
- package/dist/{dist-CtnFKuWh.js → dist-Bnb58pyL.js} +997 -77
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-BN31iDiA.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-Dvz_QBOD.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZZpqsjV.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BXQHc7Tq.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-C7nUOwbi.mjs → hostInit-D-KSUwyU.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2218 -84
- package/dist/pipeline-analytics/index.mjs +2214 -80
- 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-BAdpVnlx.js +0 -57
- package/dist/resolve-frame-CT1T1tWy.mjs +0 -44
|
@@ -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,6 +1766,7 @@ 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();
|
|
1525
1771
|
const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
|
|
1526
1772
|
const plateByBbox = /* @__PURE__ */ new Map();
|
|
@@ -1549,6 +1795,7 @@ var FrameProcessor = class {
|
|
|
1549
1795
|
height: det.maskHeight
|
|
1550
1796
|
});
|
|
1551
1797
|
firstLevelBboxById.set(det.id, bbox);
|
|
1798
|
+
sourceIdByBbox.set(bbox, det.id);
|
|
1552
1799
|
return {
|
|
1553
1800
|
...bbox,
|
|
1554
1801
|
detection,
|
|
@@ -1610,8 +1857,10 @@ var FrameProcessor = class {
|
|
|
1610
1857
|
const faceBbox = faceBboxByBbox.get(td.bbox);
|
|
1611
1858
|
const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
|
|
1612
1859
|
const plate = plateByBbox.get(td.bbox);
|
|
1860
|
+
const sourceDetectionId = sourceIdByBbox.get(td.bbox);
|
|
1613
1861
|
return {
|
|
1614
1862
|
trackId: td.trackId,
|
|
1863
|
+
...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
|
|
1615
1864
|
className: td.class,
|
|
1616
1865
|
confidence: td.score,
|
|
1617
1866
|
bbox: { ...td.bbox },
|
|
@@ -1674,6 +1923,84 @@ var FrameProcessor = class {
|
|
|
1674
1923
|
};
|
|
1675
1924
|
}
|
|
1676
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
|
+
}
|
|
1677
2004
|
//#endregion
|
|
1678
2005
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1679
2006
|
var BestDetectionTracker = class {
|
|
@@ -1878,6 +2205,50 @@ function nativeDetectionsToFrame(input) {
|
|
|
1878
2205
|
};
|
|
1879
2206
|
}
|
|
1880
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
|
|
1881
2252
|
//#region src/pipeline-analytics/runtime/binding-cache.ts
|
|
1882
2253
|
var BindingCache = class {
|
|
1883
2254
|
api;
|
|
@@ -1983,6 +2354,10 @@ var TRACKS_COLUMNS = [
|
|
|
1983
2354
|
name: "zonesVisited",
|
|
1984
2355
|
type: "JSON"
|
|
1985
2356
|
},
|
|
2357
|
+
{
|
|
2358
|
+
name: "classes",
|
|
2359
|
+
type: "JSON"
|
|
2360
|
+
},
|
|
1986
2361
|
{
|
|
1987
2362
|
name: "totalDistance",
|
|
1988
2363
|
type: "REAL"
|
|
@@ -2031,6 +2406,7 @@ function cloneTrack(t) {
|
|
|
2031
2406
|
}
|
|
2032
2407
|
})),
|
|
2033
2408
|
zonesVisited: [...t.zonesVisited],
|
|
2409
|
+
classes: [...t.classes],
|
|
2034
2410
|
totalDistance: t.totalDistance,
|
|
2035
2411
|
state: t.state,
|
|
2036
2412
|
active: t.active,
|
|
@@ -2075,6 +2451,7 @@ var TrackStore = class {
|
|
|
2075
2451
|
existing.positions.push(params.position);
|
|
2076
2452
|
} else existing.positions.push(params.position);
|
|
2077
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);
|
|
2078
2455
|
return existing;
|
|
2079
2456
|
}
|
|
2080
2457
|
const fresh = {
|
|
@@ -2087,6 +2464,7 @@ var TrackStore = class {
|
|
|
2087
2464
|
positions: [params.position],
|
|
2088
2465
|
snapshots: [],
|
|
2089
2466
|
zonesVisited: [...params.zones],
|
|
2467
|
+
classes: [params.className],
|
|
2090
2468
|
totalDistance: 0,
|
|
2091
2469
|
state: params.state,
|
|
2092
2470
|
active: true,
|
|
@@ -2229,6 +2607,92 @@ var TrackStore = class {
|
|
|
2229
2607
|
clearAll() {
|
|
2230
2608
|
this.active.clear();
|
|
2231
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
|
+
}
|
|
2232
2696
|
/** Historical query — hits the persisted collection. */
|
|
2233
2697
|
async queryHistorical(params) {
|
|
2234
2698
|
const filter = { where: { deviceId: params.deviceId } };
|
|
@@ -2270,6 +2734,7 @@ var TrackStore = class {
|
|
|
2270
2734
|
positions: [...t.positions],
|
|
2271
2735
|
snapshots: [...t.snapshots],
|
|
2272
2736
|
zonesVisited: [...t.zonesVisited],
|
|
2737
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
2273
2738
|
totalDistance: t.totalDistance,
|
|
2274
2739
|
state: t.state,
|
|
2275
2740
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
@@ -2282,6 +2747,7 @@ var TrackStore = class {
|
|
|
2282
2747
|
const positions = data["positions"] ?? [];
|
|
2283
2748
|
const snapshots = data["snapshots"] ?? [];
|
|
2284
2749
|
const zones = data["zonesVisited"] ?? [];
|
|
2750
|
+
const classes = data["classes"];
|
|
2285
2751
|
const label = data["label"];
|
|
2286
2752
|
const importance = data["importance"];
|
|
2287
2753
|
const bestEventId = data["bestEventId"];
|
|
@@ -2296,6 +2762,7 @@ var TrackStore = class {
|
|
|
2296
2762
|
positions,
|
|
2297
2763
|
snapshots,
|
|
2298
2764
|
zonesVisited: zones,
|
|
2765
|
+
...classes !== null ? { classes } : {},
|
|
2299
2766
|
totalDistance: Number(data["totalDistance"] ?? 0),
|
|
2300
2767
|
state: data["state"] ?? "idle",
|
|
2301
2768
|
active: false,
|
|
@@ -2308,6 +2775,18 @@ var TrackStore = class {
|
|
|
2308
2775
|
//#endregion
|
|
2309
2776
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
2310
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-";
|
|
2311
2790
|
var MEDIA_COLUMNS = [
|
|
2312
2791
|
{
|
|
2313
2792
|
name: "id",
|
|
@@ -2672,6 +3151,28 @@ var MediaStore = class {
|
|
|
2672
3151
|
async deleteForEvents(eventIds) {
|
|
2673
3152
|
return this.deleteForOwner("event", eventIds);
|
|
2674
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
|
+
}
|
|
2675
3176
|
/** Retention sweep: delete any media row + blob older than cutoff.
|
|
2676
3177
|
* Returns number of entries removed. */
|
|
2677
3178
|
async evictBefore(cutoffMs) {
|
|
@@ -2834,9 +3335,11 @@ var COMMON_INDEXES = (prefix) => [{
|
|
|
2834
3335
|
var EventStore = class {
|
|
2835
3336
|
store;
|
|
2836
3337
|
logger;
|
|
3338
|
+
media;
|
|
2837
3339
|
constructor(deps) {
|
|
2838
3340
|
this.store = deps.store;
|
|
2839
3341
|
this.logger = deps.logger;
|
|
3342
|
+
this.media = deps.media;
|
|
2840
3343
|
}
|
|
2841
3344
|
static async declare(store) {
|
|
2842
3345
|
await store.declareCollection.mutate({
|
|
@@ -3049,6 +3552,12 @@ var EventStore = class {
|
|
|
3049
3552
|
let bestEventId;
|
|
3050
3553
|
let peakBboxAreaFrac = 0;
|
|
3051
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
|
+
}
|
|
3052
3561
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
3053
3562
|
if (conf <= bestConf) continue;
|
|
3054
3563
|
bestConf = conf;
|
|
@@ -3238,6 +3747,72 @@ var EventStore = class {
|
|
|
3238
3747
|
]
|
|
3239
3748
|
};
|
|
3240
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
|
+
}
|
|
3241
3816
|
};
|
|
3242
3817
|
function slimMotion(id, data) {
|
|
3243
3818
|
return {
|
|
@@ -3304,6 +3879,18 @@ function stripNulls(data) {
|
|
|
3304
3879
|
return out;
|
|
3305
3880
|
}
|
|
3306
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
|
|
3307
3894
|
//#region src/shared/frame/square-safe-crop.ts
|
|
3308
3895
|
/**
|
|
3309
3896
|
* Compute a square-safe 16:9 crop region in pixel space.
|
|
@@ -3426,6 +4013,34 @@ function caption(className, confidence, label) {
|
|
|
3426
4013
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
3427
4014
|
}
|
|
3428
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
|
+
/**
|
|
3429
4044
|
* Generates object-event + track media FROM the decoded detection-pipeline
|
|
3430
4045
|
* frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
|
|
3431
4046
|
* snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
|
|
@@ -3485,9 +4100,21 @@ var EventMediaDispatcher = class {
|
|
|
3485
4100
|
});
|
|
3486
4101
|
return empty;
|
|
3487
4102
|
}
|
|
3488
|
-
const frameData = Buffer.
|
|
4103
|
+
const frameData = Buffer.from(decoded.data);
|
|
3489
4104
|
const fw = decoded.width;
|
|
3490
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
|
+
}
|
|
3491
4118
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
3492
4119
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3493
4120
|
const storedSnapshots = [];
|
|
@@ -3713,8 +4340,264 @@ var EventMediaDispatcher = class {
|
|
|
3713
4340
|
}
|
|
3714
4341
|
};
|
|
3715
4342
|
//#endregion
|
|
3716
|
-
//#region src/
|
|
3717
|
-
|
|
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 {
|
|
3718
4601
|
opts;
|
|
3719
4602
|
lastWrittenAt = /* @__PURE__ */ new Map();
|
|
3720
4603
|
lastWritten = /* @__PURE__ */ new Map();
|
|
@@ -4589,6 +5472,56 @@ function resolveMediaSettings(raw) {
|
|
|
4589
5472
|
};
|
|
4590
5473
|
}
|
|
4591
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
|
|
4592
5525
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
4593
5526
|
/**
|
|
4594
5527
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -4638,7 +5571,7 @@ var IDENTITY_COLUMNS = [
|
|
|
4638
5571
|
type: "TEXT"
|
|
4639
5572
|
}
|
|
4640
5573
|
];
|
|
4641
|
-
var SAMPLE_COLUMNS = [
|
|
5574
|
+
var SAMPLE_COLUMNS$1 = [
|
|
4642
5575
|
{
|
|
4643
5576
|
name: "id",
|
|
4644
5577
|
type: "TEXT",
|
|
@@ -4699,7 +5632,7 @@ var IdentityStore = class {
|
|
|
4699
5632
|
});
|
|
4700
5633
|
await store.declareCollection.mutate({
|
|
4701
5634
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
4702
|
-
columns: [...SAMPLE_COLUMNS],
|
|
5635
|
+
columns: [...SAMPLE_COLUMNS$1],
|
|
4703
5636
|
indexes: [{
|
|
4704
5637
|
name: "idx_sample_identity",
|
|
4705
5638
|
columns: ["identityId"]
|
|
@@ -5134,6 +6067,38 @@ var FaceStore = class {
|
|
|
5134
6067
|
} });
|
|
5135
6068
|
}
|
|
5136
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
|
+
}
|
|
5137
6102
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
5138
6103
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
5139
6104
|
async delete(faceId) {
|
|
@@ -5405,6 +6370,27 @@ var ObjectEmbeddingStore = class {
|
|
|
5405
6370
|
}
|
|
5406
6371
|
return ids;
|
|
5407
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
|
+
}
|
|
5408
6394
|
};
|
|
5409
6395
|
//#endregion
|
|
5410
6396
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
@@ -5783,6 +6769,24 @@ var FaceRecognizer = class {
|
|
|
5783
6769
|
score: held.score
|
|
5784
6770
|
}
|
|
5785
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
|
+
}
|
|
5786
6790
|
} catch (err) {
|
|
5787
6791
|
this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
|
|
5788
6792
|
tags: { deviceId },
|
|
@@ -5932,6 +6936,25 @@ var DetailScheduler = class {
|
|
|
5932
6936
|
};
|
|
5933
6937
|
//#endregion
|
|
5934
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
|
+
}
|
|
5935
6958
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
5936
6959
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
5937
6960
|
var TrackDetailDispatcher = class {
|
|
@@ -6078,12 +7101,13 @@ var TrackDetailDispatcher = class {
|
|
|
6078
7101
|
bbox: { ...frame.bbox },
|
|
6079
7102
|
className: frame.className
|
|
6080
7103
|
};
|
|
7104
|
+
const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
|
|
6081
7105
|
if (frame.frameHandle !== void 0) try {
|
|
6082
7106
|
const primary = await this.deps.runDetailSubtree({
|
|
6083
7107
|
deviceId,
|
|
6084
7108
|
frameHandle: frame.frameHandle,
|
|
6085
7109
|
parent,
|
|
6086
|
-
steps
|
|
7110
|
+
steps
|
|
6087
7111
|
}, frame.nodeId);
|
|
6088
7112
|
if (primary !== null) return primary.details;
|
|
6089
7113
|
} catch (err) {
|
|
@@ -6103,7 +7127,7 @@ var TrackDetailDispatcher = class {
|
|
|
6103
7127
|
deviceId,
|
|
6104
7128
|
cropJpeg,
|
|
6105
7129
|
parent,
|
|
6106
|
-
steps
|
|
7130
|
+
steps
|
|
6107
7131
|
}, frame.nodeId);
|
|
6108
7132
|
if (retry !== null) return retry.details;
|
|
6109
7133
|
}
|
|
@@ -6135,6 +7159,184 @@ var TrackDetailDispatcher = class {
|
|
|
6135
7159
|
}
|
|
6136
7160
|
};
|
|
6137
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
|
|
6138
7340
|
//#region src/pipeline-analytics/store/plate-store.ts
|
|
6139
7341
|
var PLATES_COLLECTION = "pipeline-analytics:plates";
|
|
6140
7342
|
var PLATE_COLUMNS = [
|
|
@@ -6177,6 +7379,27 @@ var PLATE_COLUMNS = [
|
|
|
6177
7379
|
name: "corrected",
|
|
6178
7380
|
type: "BOOLEAN",
|
|
6179
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"
|
|
6180
7403
|
}
|
|
6181
7404
|
];
|
|
6182
7405
|
var PLATE_INDEXES = [{
|
|
@@ -6226,7 +7449,12 @@ var PlateStore = class {
|
|
|
6226
7449
|
...data,
|
|
6227
7450
|
score: Number(r.data.score ?? 0),
|
|
6228
7451
|
corrected: Boolean(r.data.corrected),
|
|
6229
|
-
|
|
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
|
|
6230
7458
|
};
|
|
6231
7459
|
}
|
|
6232
7460
|
/** Recent plate reads for one device (or all when deviceId omitted), newest first. */
|
|
@@ -6290,63 +7518,443 @@ var PlateStore = class {
|
|
|
6290
7518
|
collection: PLATES_COLLECTION,
|
|
6291
7519
|
key: plateId
|
|
6292
7520
|
});
|
|
6293
|
-
} catch (err) {
|
|
6294
|
-
this.logger.warn("PlateStore.delete failed", { meta: {
|
|
6295
|
-
plateId,
|
|
6296
|
-
error: String(err)
|
|
6297
|
-
} });
|
|
7521
|
+
} catch (err) {
|
|
7522
|
+
this.logger.warn("PlateStore.delete failed", { meta: {
|
|
7523
|
+
plateId,
|
|
7524
|
+
error: String(err)
|
|
7525
|
+
} });
|
|
7526
|
+
}
|
|
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
|
+
}
|
|
7598
|
+
/**
|
|
7599
|
+
* Retention across ALL devices: delete reads older than `cutoffMs` AND any
|
|
7600
|
+
* beyond the newest `maxPerDevice` per device. Corrected reads are exempt
|
|
7601
|
+
* (human-confirmed). Returns deleted ids so the caller removes their crops.
|
|
7602
|
+
*/
|
|
7603
|
+
async pruneAll(input) {
|
|
7604
|
+
const rows = await this.store.query.query({
|
|
7605
|
+
collection: PLATES_COLLECTION,
|
|
7606
|
+
filter: { orderBy: {
|
|
7607
|
+
field: "timestamp",
|
|
7608
|
+
direction: "desc"
|
|
7609
|
+
} }
|
|
7610
|
+
});
|
|
7611
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
7612
|
+
for (const r of rows) {
|
|
7613
|
+
const data = r.data;
|
|
7614
|
+
if (data.corrected || data.assigned) continue;
|
|
7615
|
+
const deviceId = Number(data.deviceId ?? -1);
|
|
7616
|
+
const list = byDevice.get(deviceId) ?? [];
|
|
7617
|
+
list.push({
|
|
7618
|
+
id: r.id,
|
|
7619
|
+
timestamp: Number(data.timestamp ?? 0)
|
|
7620
|
+
});
|
|
7621
|
+
byDevice.set(deviceId, list);
|
|
7622
|
+
}
|
|
7623
|
+
const toDelete = /* @__PURE__ */ new Set();
|
|
7624
|
+
for (const list of byDevice.values()) {
|
|
7625
|
+
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
7626
|
+
list.forEach((row, idx) => {
|
|
7627
|
+
if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
|
|
7628
|
+
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
7629
|
+
});
|
|
7630
|
+
}
|
|
7631
|
+
const deleted = [];
|
|
7632
|
+
for (const id of toDelete) try {
|
|
7633
|
+
await this.store.delete.mutate({
|
|
7634
|
+
collection: PLATES_COLLECTION,
|
|
7635
|
+
key: id
|
|
7636
|
+
});
|
|
7637
|
+
deleted.push(id);
|
|
7638
|
+
} catch (err) {
|
|
7639
|
+
this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
|
|
7640
|
+
plateId: id,
|
|
7641
|
+
error: String(err)
|
|
7642
|
+
} });
|
|
7643
|
+
}
|
|
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
|
+
});
|
|
6298
7854
|
}
|
|
7855
|
+
return sample;
|
|
6299
7856
|
}
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
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() {
|
|
6306
7895
|
const rows = await this.store.query.query({
|
|
6307
|
-
collection:
|
|
6308
|
-
filter: {
|
|
6309
|
-
field: "timestamp",
|
|
6310
|
-
direction: "desc"
|
|
6311
|
-
} }
|
|
7896
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7897
|
+
filter: {}
|
|
6312
7898
|
});
|
|
6313
|
-
const
|
|
7899
|
+
const gallery = [];
|
|
6314
7900
|
for (const r of rows) {
|
|
6315
|
-
const
|
|
6316
|
-
if (
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
id: r.id,
|
|
6321
|
-
timestamp: Number(data.timestamp ?? 0)
|
|
6322
|
-
});
|
|
6323
|
-
byDevice.set(deviceId, list);
|
|
6324
|
-
}
|
|
6325
|
-
const toDelete = /* @__PURE__ */ new Set();
|
|
6326
|
-
for (const list of byDevice.values()) {
|
|
6327
|
-
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
6328
|
-
list.forEach((row, idx) => {
|
|
6329
|
-
if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
|
|
6330
|
-
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
6331
|
-
});
|
|
6332
|
-
}
|
|
6333
|
-
const deleted = [];
|
|
6334
|
-
for (const id of toDelete) try {
|
|
6335
|
-
await this.store.delete.mutate({
|
|
6336
|
-
collection: PLATES_COLLECTION,
|
|
6337
|
-
key: id
|
|
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
|
|
6338
7906
|
});
|
|
6339
|
-
deleted.push(id);
|
|
6340
|
-
} catch (err) {
|
|
6341
|
-
this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
|
|
6342
|
-
plateId: id,
|
|
6343
|
-
error: String(err)
|
|
6344
|
-
} });
|
|
6345
7907
|
}
|
|
6346
|
-
return
|
|
7908
|
+
return gallery;
|
|
6347
7909
|
}
|
|
6348
7910
|
};
|
|
6349
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
|
|
6350
7958
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
6351
7959
|
/**
|
|
6352
7960
|
* PlateRecognizer — buffers the best license-plate OCR read per vehicle track
|
|
@@ -6358,8 +7966,38 @@ var PlateStore = class {
|
|
|
6358
7966
|
var PlateRecognizer = class {
|
|
6359
7967
|
deps;
|
|
6360
7968
|
bestPlate = /* @__PURE__ */ new Map();
|
|
7969
|
+
gallery = [];
|
|
7970
|
+
nameById = /* @__PURE__ */ new Map();
|
|
7971
|
+
matchOpts;
|
|
6361
7972
|
constructor(deps) {
|
|
6362
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;
|
|
6363
8001
|
}
|
|
6364
8002
|
async processFrame(input) {
|
|
6365
8003
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -6415,6 +8053,8 @@ var PlateRecognizer = class {
|
|
|
6415
8053
|
}
|
|
6416
8054
|
});
|
|
6417
8055
|
}
|
|
8056
|
+
const match = this.matchVehicle(held.text, held.score);
|
|
8057
|
+
const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
|
|
6418
8058
|
try {
|
|
6419
8059
|
await this.deps.plateStore.insert({
|
|
6420
8060
|
id: plateId,
|
|
@@ -6424,7 +8064,16 @@ var PlateRecognizer = class {
|
|
|
6424
8064
|
text: normalizePlate(held.text),
|
|
6425
8065
|
score: held.score,
|
|
6426
8066
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
6427
|
-
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"
|
|
6428
8077
|
});
|
|
6429
8078
|
this.deps.logger.info("plate: buffered to gallery", {
|
|
6430
8079
|
tags: {
|
|
@@ -6435,7 +8084,8 @@ var PlateRecognizer = class {
|
|
|
6435
8084
|
plateId,
|
|
6436
8085
|
text: normalizePlate(held.text),
|
|
6437
8086
|
score: held.score,
|
|
6438
|
-
hasCrop: mediaKey !== void 0
|
|
8087
|
+
hasCrop: mediaKey !== void 0,
|
|
8088
|
+
recognizedVehicleId: match?.vehicleId ?? null
|
|
6439
8089
|
}
|
|
6440
8090
|
});
|
|
6441
8091
|
} catch (err) {
|
|
@@ -6812,8 +8462,11 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
|
|
|
6812
8462
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
6813
8463
|
* narrowing the old raw read applied inline. */
|
|
6814
8464
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
8465
|
+
var EmbeddingEnabledSchema = boolean();
|
|
6815
8466
|
var SILENCE_FLOOR_DBFS = -55;
|
|
6816
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;
|
|
6817
8470
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
6818
8471
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
6819
8472
|
/**
|
|
@@ -6873,12 +8526,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6873
8526
|
faceStore = null;
|
|
6874
8527
|
faceRecognizer = null;
|
|
6875
8528
|
plateStore = null;
|
|
8529
|
+
vehicleStore = null;
|
|
6876
8530
|
plateRecognizer = null;
|
|
6877
8531
|
objectEmbeddingStore = null;
|
|
6878
8532
|
/** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
|
|
6879
8533
|
* classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
|
|
6880
8534
|
* the per-frame child consumption the executor no longer emits. */
|
|
6881
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();
|
|
6882
8540
|
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
6883
8541
|
* Stamped on object-embedding rows from the detail plane so semantic search's
|
|
6884
8542
|
* same-model gate keeps matching. */
|
|
@@ -6890,6 +8548,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6890
8548
|
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
6891
8549
|
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
6892
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;
|
|
6893
8557
|
bindingCache = null;
|
|
6894
8558
|
zoneAnalytics = null;
|
|
6895
8559
|
audioMetrics = null;
|
|
@@ -6940,6 +8604,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6940
8604
|
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6941
8605
|
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6942
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();
|
|
6943
8611
|
/** Best (highest-confidence) CLIP-object detection per track — drives ONE
|
|
6944
8612
|
* tight object-crop capture whose media key is written onto the embedding
|
|
6945
8613
|
* row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
|
|
@@ -6951,6 +8619,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6951
8619
|
* at track end so a face row links to the SAME single key frame. Cleared on
|
|
6952
8620
|
* track end. */
|
|
6953
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();
|
|
6954
8627
|
/** The shared crop extractor (native-res first, detection-frame fallback),
|
|
6955
8628
|
* captured in the constructor so `processFrame` can crop object thumbnails in
|
|
6956
8629
|
* the same live-frame window as the face/plate/event-media captures. The
|
|
@@ -6966,6 +8639,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6966
8639
|
if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
|
|
6967
8640
|
return this._postProcessingNodeState;
|
|
6968
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
|
+
}
|
|
6969
8651
|
/** GLOBAL face-recognition master switch (`enabled` key). Schema +
|
|
6970
8652
|
* fallback derived from the face-settings source of truth. */
|
|
6971
8653
|
_faceGlobalEnabledState = null;
|
|
@@ -6985,6 +8667,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6985
8667
|
await IdentityStore.declare(api.settingsStore);
|
|
6986
8668
|
await FaceStore.declare(api.settingsStore);
|
|
6987
8669
|
await PlateStore.declare(api.settingsStore);
|
|
8670
|
+
await VehicleStore.declare(api.settingsStore);
|
|
6988
8671
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
6989
8672
|
const logger = this.ctx.logger;
|
|
6990
8673
|
let storage = this.ctx.kernel.storage;
|
|
@@ -7006,7 +8689,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7006
8689
|
});
|
|
7007
8690
|
this.eventStore = new EventStore({
|
|
7008
8691
|
store: api.settingsStore,
|
|
7009
|
-
logger: logger.child("EventStore")
|
|
8692
|
+
logger: logger.child("EventStore"),
|
|
8693
|
+
media: this.mediaStore
|
|
7010
8694
|
});
|
|
7011
8695
|
this.identityStore = new IdentityStore({
|
|
7012
8696
|
store: api.settingsStore,
|
|
@@ -7020,6 +8704,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7020
8704
|
store: api.settingsStore,
|
|
7021
8705
|
logger: logger.child("PlateStore")
|
|
7022
8706
|
});
|
|
8707
|
+
this.vehicleStore = new VehicleStore({
|
|
8708
|
+
store: api.settingsStore,
|
|
8709
|
+
logger: logger.child("VehicleStore")
|
|
8710
|
+
});
|
|
7023
8711
|
const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
7024
8712
|
const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
|
|
7025
8713
|
{
|
|
@@ -7129,15 +8817,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7129
8817
|
}, trackId);
|
|
7130
8818
|
},
|
|
7131
8819
|
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8820
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
|
|
7132
8821
|
logger: logger.child("FaceRecognizer")
|
|
7133
8822
|
});
|
|
7134
8823
|
this.faceRecognizer.refreshGallery();
|
|
7135
8824
|
this.plateRecognizer = new PlateRecognizer({
|
|
7136
8825
|
plateStore: this.plateStore,
|
|
8826
|
+
vehicleStore: this.vehicleStore,
|
|
7137
8827
|
mediaStore: this.mediaStore,
|
|
7138
8828
|
captureCrop,
|
|
8829
|
+
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8830
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
|
|
7139
8831
|
logger: logger.child("PlateRecognizer")
|
|
7140
8832
|
});
|
|
8833
|
+
this.plateRecognizer.refreshGallery();
|
|
7141
8834
|
this.objectEmbeddingStore = new ObjectEmbeddingStore({
|
|
7142
8835
|
store: api.settingsStore,
|
|
7143
8836
|
logger: logger.child("ObjectEmbeddingStore")
|
|
@@ -7155,6 +8848,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7155
8848
|
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
7156
8849
|
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
7157
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;
|
|
7158
8855
|
}
|
|
7159
8856
|
});
|
|
7160
8857
|
this.bindingCache = new BindingCache({
|
|
@@ -7222,12 +8919,37 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7222
8919
|
const data = ev.data;
|
|
7223
8920
|
this.handleNativeDetection(data);
|
|
7224
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)");
|
|
7225
8945
|
}
|
|
7226
8946
|
this.unsubBindings = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (ev) => {
|
|
7227
8947
|
const data = ev.data;
|
|
7228
8948
|
this.bindingCache?.onBindingsChanged(data);
|
|
7229
8949
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
7230
8950
|
this.trackStore?.clearDevice(data.deviceId);
|
|
8951
|
+
this.overlayState.clearDevice(data.deviceId);
|
|
8952
|
+
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
7231
8953
|
this.forgetDeviceProcessors(data.deviceId);
|
|
7232
8954
|
this.levelStateByDevice.delete(data.deviceId);
|
|
7233
8955
|
this.settingsCacheByDevice.delete(data.deviceId);
|
|
@@ -7240,6 +8962,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7240
8962
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
|
|
7241
8963
|
const { deviceId } = ev.data;
|
|
7242
8964
|
this.trackStore?.clearDevice(deviceId);
|
|
8965
|
+
this.overlayState.clearDevice(deviceId);
|
|
8966
|
+
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
7243
8967
|
this.forgetDeviceProcessors(deviceId);
|
|
7244
8968
|
this.levelStateByDevice.delete(deviceId);
|
|
7245
8969
|
this.settingsCacheByDevice.delete(deviceId);
|
|
@@ -7257,6 +8981,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7257
8981
|
}, TTL_SWEEP_INTERVAL_MS);
|
|
7258
8982
|
this.retentionSweepTimer = setInterval(() => {
|
|
7259
8983
|
this.sweepRetention();
|
|
8984
|
+
this.runTrackRetentionSweep();
|
|
7260
8985
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
7261
8986
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
7262
8987
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -7497,11 +9222,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7497
9222
|
mediaStore: this.mediaStore,
|
|
7498
9223
|
trackStore: this.trackStore,
|
|
7499
9224
|
eventStore: this.eventStore,
|
|
7500
|
-
refreshGallery: () => this.faceRecognizer?.refreshGallery()
|
|
9225
|
+
refreshGallery: () => this.faceRecognizer?.refreshGallery(),
|
|
9226
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
|
|
7501
9227
|
});
|
|
7502
9228
|
const plateGallery = new PlateGalleryProvider({
|
|
7503
9229
|
plateStore: this.plateStore,
|
|
7504
|
-
|
|
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)
|
|
7505
9238
|
});
|
|
7506
9239
|
return [
|
|
7507
9240
|
{
|
|
@@ -7550,6 +9283,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7550
9283
|
this.unsubBindings = null;
|
|
7551
9284
|
this.unsubDeviceUnreg?.();
|
|
7552
9285
|
this.unsubDeviceUnreg = null;
|
|
9286
|
+
await this.embeddingDispatcher?.stop();
|
|
9287
|
+
this.embeddingDispatcher = null;
|
|
7553
9288
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
7554
9289
|
if (this.ttlSweepTimer) {
|
|
7555
9290
|
clearInterval(this.ttlSweepTimer);
|
|
@@ -7563,10 +9298,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7563
9298
|
this.audioMetrics?.destroy();
|
|
7564
9299
|
this.detailDispatcher?.dispose();
|
|
7565
9300
|
this.detailDispatcher = null;
|
|
9301
|
+
this.overlayState.clear();
|
|
9302
|
+
this.overlaySynthesisWarnAt.clear();
|
|
7566
9303
|
this.processors.clear();
|
|
7567
9304
|
this.lastActiveTrackIds.clear();
|
|
7568
9305
|
this.dropoutSkipsByKey.clear();
|
|
7569
9306
|
this.bestFrameTracker.clear();
|
|
9307
|
+
this.trackLifecycleUpdateMem.clear();
|
|
7570
9308
|
this.objectEmbeddingBestSelector.clear();
|
|
7571
9309
|
this.levelStateByDevice.clear();
|
|
7572
9310
|
this.settingsCacheByDevice.clear();
|
|
@@ -7698,6 +9436,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7698
9436
|
className: t.className
|
|
7699
9437
|
}
|
|
7700
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
|
+
});
|
|
7701
9457
|
}
|
|
7702
9458
|
}
|
|
7703
9459
|
let lostTrackCount = 0;
|
|
@@ -7761,7 +9517,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7761
9517
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
7762
9518
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
7763
9519
|
const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
|
|
7764
|
-
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) => {
|
|
7765
9521
|
const childCrops = childCropsByEvent.get(e.id);
|
|
7766
9522
|
return {
|
|
7767
9523
|
eventId: e.id,
|
|
@@ -7777,15 +9533,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7777
9533
|
let plateCrops = 0;
|
|
7778
9534
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
7779
9535
|
else plateCrops += 1;
|
|
7780
|
-
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
9536
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
7781
9537
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
7782
|
-
|
|
7783
|
-
source,
|
|
9538
|
+
const captureCounts = {
|
|
7784
9539
|
events: eventTargets.length,
|
|
7785
9540
|
trackFrames: firstFrameTargets.length,
|
|
7786
9541
|
snapshots: snapshotTargets.length,
|
|
7787
9542
|
faceCrops,
|
|
7788
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
|
|
7789
9555
|
} });
|
|
7790
9556
|
this.eventMediaDispatcher.captureForFrame({
|
|
7791
9557
|
deviceId,
|
|
@@ -7848,6 +9614,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7848
9614
|
zones: e.zones ?? []
|
|
7849
9615
|
}
|
|
7850
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
|
+
}
|
|
7851
9637
|
this.ctx.eventBus.emit({
|
|
7852
9638
|
id: `pa-${randomUUID()}`,
|
|
7853
9639
|
timestamp: new Date(result.timestamp),
|
|
@@ -7862,7 +9648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7862
9648
|
timestamp: result.timestamp,
|
|
7863
9649
|
frameWidth: result.frameWidth,
|
|
7864
9650
|
frameHeight: result.frameHeight,
|
|
7865
|
-
detections:
|
|
9651
|
+
detections: overlayDetections
|
|
7866
9652
|
}
|
|
7867
9653
|
});
|
|
7868
9654
|
}
|
|
@@ -7952,9 +9738,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7952
9738
|
*/
|
|
7953
9739
|
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
7954
9740
|
for (const d of details) try {
|
|
7955
|
-
|
|
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);
|
|
7956
9749
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
7957
|
-
else if (d.label !== void 0 && d.label.length > 0)
|
|
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
|
+
}
|
|
7958
9760
|
} catch (err) {
|
|
7959
9761
|
this.ctx.logger.warn("detail result route failed", {
|
|
7960
9762
|
tags: { deviceId },
|
|
@@ -8162,12 +9964,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8162
9964
|
});
|
|
8163
9965
|
}));
|
|
8164
9966
|
}
|
|
8165
|
-
|
|
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) {
|
|
8166
10022
|
const targets = [];
|
|
8167
10023
|
for (const t of tracked) {
|
|
8168
10024
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
8169
10025
|
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
8170
10026
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10027
|
+
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
8171
10028
|
if (!dueSnapshot && !isNewBest) continue;
|
|
8172
10029
|
targets.push({
|
|
8173
10030
|
trackId: t.trackId,
|
|
@@ -8433,6 +10290,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8433
10290
|
positions: t.positions.length
|
|
8434
10291
|
}
|
|
8435
10292
|
});
|
|
10293
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
8436
10294
|
const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
8437
10295
|
const dropKeyFrameKey = () => {
|
|
8438
10296
|
this.keyFrameKeyByTrackId.delete(t.trackId);
|
|
@@ -8440,11 +10298,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8440
10298
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
8441
10299
|
else dropKeyFrameKey();
|
|
8442
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;
|
|
8443
10305
|
try {
|
|
8444
10306
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
8445
10307
|
if (peak) {
|
|
10308
|
+
endBestEventId = peak.bestEventId;
|
|
8446
10309
|
const { importance, reason } = computeImportance({
|
|
8447
|
-
peakConfidence:
|
|
10310
|
+
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
8448
10311
|
className: t.className,
|
|
8449
10312
|
durationMs: duration,
|
|
8450
10313
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
@@ -8452,6 +10315,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8452
10315
|
zonesVisited: t.zonesVisited,
|
|
8453
10316
|
...t.label !== void 0 ? { label: t.label } : {}
|
|
8454
10317
|
});
|
|
10318
|
+
endImportance = importance;
|
|
10319
|
+
endImportanceReason = reason;
|
|
8455
10320
|
await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
|
|
8456
10321
|
if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
|
|
8457
10322
|
}
|
|
@@ -8464,6 +10329,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8464
10329
|
this.bestFrameTracker.delete(t.trackId);
|
|
8465
10330
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
8466
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
|
+
}
|
|
8467
10344
|
this.ctx.eventBus.emit({
|
|
8468
10345
|
id: `pa-end-${t.trackId}`,
|
|
8469
10346
|
timestamp: new Date(t.lastSeen),
|
|
@@ -8480,6 +10357,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8480
10357
|
durationMs: duration
|
|
8481
10358
|
}
|
|
8482
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);
|
|
8483
10380
|
}
|
|
8484
10381
|
} catch (err) {
|
|
8485
10382
|
if (this.shuttingDown) return;
|
|
@@ -8571,6 +10468,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8571
10468
|
if (!frame) return;
|
|
8572
10469
|
await this.processFrame(payload.cameraId, frame, "onboard");
|
|
8573
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
|
+
}
|
|
8574
10527
|
/** Composite key for the per-(device, source) processor + track maps. */
|
|
8575
10528
|
procKey(deviceId, source) {
|
|
8576
10529
|
return `${deviceId}:${source}`;
|
|
@@ -8670,6 +10623,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8670
10623
|
}
|
|
8671
10624
|
async clearTracks(input) {
|
|
8672
10625
|
this.trackStore?.clearDevice(input.deviceId);
|
|
10626
|
+
this.overlayState.clearDevice(input.deviceId);
|
|
10627
|
+
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
8673
10628
|
const prefix = `${input.deviceId}:`;
|
|
8674
10629
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
8675
10630
|
}
|
|
@@ -8846,6 +10801,168 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8846
10801
|
return counts;
|
|
8847
10802
|
}
|
|
8848
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
|
+
/**
|
|
8849
10966
|
* Decode a stored event image into `EventMedia` for the data-plane handler.
|
|
8850
10967
|
* Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
|
|
8851
10968
|
* (native-res boxed frame) → any available file. Returns `null` if the
|
|
@@ -8906,6 +11023,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
8906
11023
|
label: "Post-processing node",
|
|
8907
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.",
|
|
8908
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
|
|
8909
11032
|
}]
|
|
8910
11033
|
},
|
|
8911
11034
|
{
|
|
@@ -9012,9 +11135,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
9012
11135
|
{
|
|
9013
11136
|
id: "retention",
|
|
9014
11137
|
title: "Retention",
|
|
9015
|
-
description: "How long
|
|
11138
|
+
description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
|
|
9016
11139
|
columns: 3,
|
|
9017
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
|
+
},
|
|
9018
11152
|
{
|
|
9019
11153
|
type: "number",
|
|
9020
11154
|
key: "retentionMotionDays",
|