@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
|
@@ -2,12 +2,11 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
6
|
-
|
|
7
|
-
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
5
|
+
const require_dist = require("../dist-Bnb58pyL.js");
|
|
6
|
+
let node_crypto = require("node:crypto");
|
|
8
7
|
let sharp = require("sharp");
|
|
9
8
|
sharp = require_dist.__toESM(sharp);
|
|
10
|
-
let
|
|
9
|
+
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
11
10
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
12
11
|
var SOURCE = "analytics";
|
|
13
12
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -266,6 +265,7 @@ var FaceGalleryProvider = class {
|
|
|
266
265
|
trackStore;
|
|
267
266
|
eventStore;
|
|
268
267
|
refreshGallery;
|
|
268
|
+
emitFaceGalleryChanged;
|
|
269
269
|
constructor(deps) {
|
|
270
270
|
this.identityStore = deps.identityStore;
|
|
271
271
|
this.faceStore = deps.faceStore;
|
|
@@ -273,6 +273,13 @@ var FaceGalleryProvider = class {
|
|
|
273
273
|
this.trackStore = deps.trackStore;
|
|
274
274
|
this.eventStore = deps.eventStore;
|
|
275
275
|
this.refreshGallery = deps.refreshGallery;
|
|
276
|
+
this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
|
|
277
|
+
}
|
|
278
|
+
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
279
|
+
safeEmitFaceGalleryChanged(payload) {
|
|
280
|
+
try {
|
|
281
|
+
this.emitFaceGalleryChanged(payload);
|
|
282
|
+
} catch {}
|
|
276
283
|
}
|
|
277
284
|
async listIdentities() {
|
|
278
285
|
const rows = await this.identityStore.listIdentities();
|
|
@@ -491,6 +498,11 @@ var FaceGalleryProvider = class {
|
|
|
491
498
|
}, currentFace.trackId);
|
|
492
499
|
}
|
|
493
500
|
this.refreshGallery();
|
|
501
|
+
this.safeEmitFaceGalleryChanged({
|
|
502
|
+
deviceId: currentFace.deviceId,
|
|
503
|
+
faceId,
|
|
504
|
+
kind: "assigned"
|
|
505
|
+
});
|
|
494
506
|
}
|
|
495
507
|
/**
|
|
496
508
|
* Unassign a buffered face from its identity:
|
|
@@ -521,6 +533,11 @@ var FaceGalleryProvider = class {
|
|
|
521
533
|
await this.eventStore.clearLabelForTrack(face.trackId);
|
|
522
534
|
await this.trackStore.clearLabel(face.trackId);
|
|
523
535
|
this.refreshGallery();
|
|
536
|
+
this.safeEmitFaceGalleryChanged({
|
|
537
|
+
deviceId: face.deviceId,
|
|
538
|
+
faceId,
|
|
539
|
+
kind: "unassigned"
|
|
540
|
+
});
|
|
524
541
|
}
|
|
525
542
|
/**
|
|
526
543
|
* Delete a buffered face entirely (row + its crop media). If the face is
|
|
@@ -534,6 +551,11 @@ var FaceGalleryProvider = class {
|
|
|
534
551
|
await this.mediaStore.deleteForOwner("face", [input.faceId]);
|
|
535
552
|
await this.faceStore.delete(input.faceId);
|
|
536
553
|
this.refreshGallery();
|
|
554
|
+
this.safeEmitFaceGalleryChanged({
|
|
555
|
+
deviceId: face.deviceId,
|
|
556
|
+
faceId: input.faceId,
|
|
557
|
+
kind: "deleted"
|
|
558
|
+
});
|
|
537
559
|
}
|
|
538
560
|
/** Batch-assign many faces to one identity. Per-face failures are collected,
|
|
539
561
|
* not thrown, so one bad face doesn't abort the rest. */
|
|
@@ -691,10 +713,26 @@ function clusterByText(items, maxDistance = 1) {
|
|
|
691
713
|
//#region src/pipeline-analytics/plate-gallery-provider.ts
|
|
692
714
|
var PlateGalleryProvider = class {
|
|
693
715
|
plateStore;
|
|
716
|
+
vehicleStore;
|
|
694
717
|
mediaStore;
|
|
718
|
+
trackStore;
|
|
719
|
+
eventStore;
|
|
720
|
+
refreshGallery;
|
|
721
|
+
emitPlateGalleryChanged;
|
|
695
722
|
constructor(deps) {
|
|
696
723
|
this.plateStore = deps.plateStore;
|
|
724
|
+
this.vehicleStore = deps.vehicleStore;
|
|
697
725
|
this.mediaStore = deps.mediaStore;
|
|
726
|
+
this.trackStore = deps.trackStore;
|
|
727
|
+
this.eventStore = deps.eventStore;
|
|
728
|
+
this.refreshGallery = deps.refreshGallery;
|
|
729
|
+
this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
|
|
730
|
+
}
|
|
731
|
+
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
732
|
+
safeEmitPlateGalleryChanged(payload) {
|
|
733
|
+
try {
|
|
734
|
+
this.emitPlateGalleryChanged(payload);
|
|
735
|
+
} catch {}
|
|
698
736
|
}
|
|
699
737
|
async plateCropBase64(plate) {
|
|
700
738
|
if (plate.mediaKey) {
|
|
@@ -704,8 +742,9 @@ var PlateGalleryProvider = class {
|
|
|
704
742
|
const media = await this.mediaStore.listByOwner("plate", plate.id);
|
|
705
743
|
return media.length > 0 ? media[0].base64 : void 0;
|
|
706
744
|
}
|
|
707
|
-
async toPlateInfo(plate) {
|
|
745
|
+
async toPlateInfo(plate, nameMap) {
|
|
708
746
|
const base64 = await this.plateCropBase64(plate);
|
|
747
|
+
const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
|
|
709
748
|
return {
|
|
710
749
|
plateId: plate.id,
|
|
711
750
|
deviceId: plate.deviceId,
|
|
@@ -714,20 +753,31 @@ var PlateGalleryProvider = class {
|
|
|
714
753
|
text: plate.text,
|
|
715
754
|
score: plate.score,
|
|
716
755
|
corrected: plate.corrected,
|
|
756
|
+
assigned: plate.assigned,
|
|
757
|
+
...plate.recognizedVehicleId != null ? { recognizedVehicleId: plate.recognizedVehicleId } : {},
|
|
758
|
+
...vehicleName !== void 0 ? { vehicleName } : {},
|
|
759
|
+
...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
|
|
760
|
+
...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
|
|
717
761
|
...base64 !== void 0 ? { base64 } : {}
|
|
718
762
|
};
|
|
719
763
|
}
|
|
764
|
+
async vehicleNameMap() {
|
|
765
|
+
const vehicles = await this.vehicleStore.listVehicles();
|
|
766
|
+
return new Map(vehicles.map((v) => [v.id, v.name]));
|
|
767
|
+
}
|
|
720
768
|
async listPlates(input) {
|
|
721
769
|
const rows = await this.plateStore.listRecentPlates({
|
|
722
770
|
...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
723
771
|
...input?.limit !== void 0 ? { limit: input.limit } : {}
|
|
724
772
|
});
|
|
725
|
-
|
|
773
|
+
const nameMap = await this.vehicleNameMap();
|
|
774
|
+
return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap)));
|
|
726
775
|
}
|
|
727
776
|
async getPlateByTrack(input) {
|
|
728
777
|
const plate = await this.plateStore.get(`plate-${input.trackId}`);
|
|
729
778
|
if (!plate || plate.deviceId !== input.deviceId) return null;
|
|
730
|
-
|
|
779
|
+
const nameMap = await this.vehicleNameMap();
|
|
780
|
+
return this.toPlateInfo(plate, nameMap);
|
|
731
781
|
}
|
|
732
782
|
async getPlateMedia(input) {
|
|
733
783
|
return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
|
|
@@ -745,7 +795,8 @@ var PlateGalleryProvider = class {
|
|
|
745
795
|
dist: plateDistance(input.text, p.text)
|
|
746
796
|
})).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
|
|
747
797
|
const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
|
|
748
|
-
|
|
798
|
+
const nameMap = await this.vehicleNameMap();
|
|
799
|
+
return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap)));
|
|
749
800
|
}
|
|
750
801
|
async suggestPlateClusters(input) {
|
|
751
802
|
const maxDistance = input?.maxDistance ?? 1;
|
|
@@ -764,8 +815,203 @@ var PlateGalleryProvider = class {
|
|
|
764
815
|
});
|
|
765
816
|
}
|
|
766
817
|
async deletePlate(input) {
|
|
818
|
+
const plate = await this.plateStore.get(input.plateId);
|
|
819
|
+
if (plate?.assigned) await this.unassignPlate({ plateId: input.plateId });
|
|
767
820
|
await this.plateStore.delete(input.plateId);
|
|
768
821
|
await this.mediaStore.deleteForOwner("plate", [input.plateId]);
|
|
822
|
+
this.refreshGallery();
|
|
823
|
+
if (plate) this.safeEmitPlateGalleryChanged({
|
|
824
|
+
deviceId: plate.deviceId,
|
|
825
|
+
plateId: input.plateId,
|
|
826
|
+
kind: "deleted"
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async listVehicles() {
|
|
830
|
+
const rows = await this.vehicleStore.listVehicles();
|
|
831
|
+
const result = [];
|
|
832
|
+
for (const r of rows) {
|
|
833
|
+
let coverKey = r.coverMediaKey ?? void 0;
|
|
834
|
+
if (!coverKey) coverKey = (await this.vehicleStore.listSamples(r.id)).find((s) => s.mediaKey)?.mediaKey;
|
|
835
|
+
let coverBase64;
|
|
836
|
+
if (coverKey) {
|
|
837
|
+
const media = await this.mediaStore.getByKey(coverKey);
|
|
838
|
+
if (media) coverBase64 = media.base64;
|
|
839
|
+
}
|
|
840
|
+
result.push({
|
|
841
|
+
id: r.id,
|
|
842
|
+
name: r.name,
|
|
843
|
+
sampleCount: r.sampleCount,
|
|
844
|
+
...coverKey != null ? { coverMediaKey: coverKey } : {},
|
|
845
|
+
...coverBase64 !== void 0 ? { coverBase64 } : {}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
async createVehicle(input) {
|
|
851
|
+
const raw = await this.vehicleStore.createVehicle({ name: input.name });
|
|
852
|
+
this.refreshGallery();
|
|
853
|
+
return {
|
|
854
|
+
id: raw.id,
|
|
855
|
+
name: raw.name,
|
|
856
|
+
sampleCount: raw.sampleCount,
|
|
857
|
+
...raw.coverMediaKey !== void 0 ? { coverMediaKey: raw.coverMediaKey } : {}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
async renameVehicle(input) {
|
|
861
|
+
await this.vehicleStore.renameVehicle(input.id, input.name);
|
|
862
|
+
}
|
|
863
|
+
async deleteVehicle(input) {
|
|
864
|
+
const plates = await this.plateStore.listAllRecentPlates({});
|
|
865
|
+
for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
|
|
866
|
+
await this.mediaStore.deleteForOwner("vehicle", [input.id]);
|
|
867
|
+
await this.vehicleStore.deleteVehicle(input.id);
|
|
868
|
+
this.refreshGallery();
|
|
869
|
+
}
|
|
870
|
+
async listVehicleSamples(input) {
|
|
871
|
+
const rows = await this.vehicleStore.listSamples(input.vehicleId);
|
|
872
|
+
const result = [];
|
|
873
|
+
for (const row of rows) {
|
|
874
|
+
let base64;
|
|
875
|
+
if (row.mediaKey) {
|
|
876
|
+
const media = await this.mediaStore.getByKey(row.mediaKey);
|
|
877
|
+
if (media) base64 = media.base64;
|
|
878
|
+
}
|
|
879
|
+
result.push({
|
|
880
|
+
id: row.id,
|
|
881
|
+
text: row.text,
|
|
882
|
+
score: row.score,
|
|
883
|
+
addedAt: row.addedAt,
|
|
884
|
+
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
885
|
+
...base64 !== void 0 ? { base64 } : {}
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
890
|
+
async removeVehicleSample(input) {
|
|
891
|
+
const sample = (await this.vehicleStore.listSamples(input.vehicleId)).find((s) => s.id === input.sampleId);
|
|
892
|
+
if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
|
|
893
|
+
await this.vehicleStore.removeSample(input.vehicleId, input.sampleId);
|
|
894
|
+
}
|
|
895
|
+
async assignPlate(input) {
|
|
896
|
+
const { plateId, vehicleId } = input;
|
|
897
|
+
const plate = await this.plateStore.get(plateId);
|
|
898
|
+
if (!plate) throw new Error(`PlateGalleryProvider.assignPlate: plate not found: ${plateId}`);
|
|
899
|
+
if (plate.assignedSampleId !== void 0 && plate.recognizedVehicleId !== void 0) {
|
|
900
|
+
await this.removeVehicleSample({
|
|
901
|
+
vehicleId: plate.recognizedVehicleId,
|
|
902
|
+
sampleId: plate.assignedSampleId
|
|
903
|
+
});
|
|
904
|
+
await this.plateStore.clearAssignment(plateId);
|
|
905
|
+
await this.plateStore.update(plateId, { mediaKey: null });
|
|
906
|
+
}
|
|
907
|
+
const current = await this.plateStore.get(plateId);
|
|
908
|
+
if (!current) throw new Error(`PlateGalleryProvider.assignPlate: plate disappeared: ${plateId}`);
|
|
909
|
+
let newKey;
|
|
910
|
+
if (current.mediaKey) {
|
|
911
|
+
newKey = await this.mediaStore.reown({
|
|
912
|
+
fromOwnerKind: "plate",
|
|
913
|
+
fromOwnerId: plateId,
|
|
914
|
+
toOwnerKind: "vehicle",
|
|
915
|
+
toOwnerId: vehicleId,
|
|
916
|
+
key: current.mediaKey
|
|
917
|
+
});
|
|
918
|
+
await this.plateStore.update(plateId, { mediaKey: newKey });
|
|
919
|
+
}
|
|
920
|
+
let sampleId;
|
|
921
|
+
try {
|
|
922
|
+
sampleId = (await this.vehicleStore.addSample({
|
|
923
|
+
vehicleId,
|
|
924
|
+
text: current.text,
|
|
925
|
+
score: current.score,
|
|
926
|
+
sourcePlateId: plateId,
|
|
927
|
+
...newKey !== void 0 ? { mediaKey: newKey } : {},
|
|
928
|
+
...current.deviceId !== void 0 ? { deviceId: current.deviceId } : {}
|
|
929
|
+
})).id;
|
|
930
|
+
await this.plateStore.markAssigned(plateId, vehicleId, sampleId);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
if (newKey !== void 0) try {
|
|
933
|
+
await this.mediaStore.reown({
|
|
934
|
+
fromOwnerKind: "vehicle",
|
|
935
|
+
fromOwnerId: vehicleId,
|
|
936
|
+
toOwnerKind: "plate",
|
|
937
|
+
toOwnerId: plateId,
|
|
938
|
+
key: newKey
|
|
939
|
+
});
|
|
940
|
+
} catch {}
|
|
941
|
+
throw err;
|
|
942
|
+
}
|
|
943
|
+
const vehicle = (await this.vehicleStore.listVehicles()).find((v) => v.id === vehicleId);
|
|
944
|
+
if (vehicle?.name !== void 0) {
|
|
945
|
+
await this.trackStore.setLabel(current.trackId, vehicle.name);
|
|
946
|
+
await this.eventStore.setLabelForTrack(current.trackId, vehicle.name);
|
|
947
|
+
await recomputeTrackImportance({
|
|
948
|
+
trackStore: this.trackStore,
|
|
949
|
+
eventStore: this.eventStore
|
|
950
|
+
}, current.trackId);
|
|
951
|
+
}
|
|
952
|
+
this.refreshGallery();
|
|
953
|
+
this.safeEmitPlateGalleryChanged({
|
|
954
|
+
deviceId: current.deviceId,
|
|
955
|
+
plateId,
|
|
956
|
+
kind: "assigned"
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
async unassignPlate(input) {
|
|
960
|
+
const { plateId } = input;
|
|
961
|
+
const plate = await this.plateStore.get(plateId);
|
|
962
|
+
if (!plate) throw new Error(`PlateGalleryProvider.unassignPlate: plate not found: ${plateId}`);
|
|
963
|
+
const owningVehicleId = plate.recognizedVehicleId;
|
|
964
|
+
if (plate.mediaKey && owningVehicleId !== void 0) try {
|
|
965
|
+
const restoredKey = await this.mediaStore.reown({
|
|
966
|
+
fromOwnerKind: "vehicle",
|
|
967
|
+
fromOwnerId: owningVehicleId,
|
|
968
|
+
toOwnerKind: "plate",
|
|
969
|
+
toOwnerId: plateId,
|
|
970
|
+
key: plate.mediaKey
|
|
971
|
+
});
|
|
972
|
+
await this.plateStore.update(plateId, { mediaKey: restoredKey });
|
|
973
|
+
} catch {}
|
|
974
|
+
if (plate.assignedSampleId !== void 0 && owningVehicleId !== void 0) await this.vehicleStore.removeSample(owningVehicleId, plate.assignedSampleId);
|
|
975
|
+
await this.plateStore.clearAssignment(plateId);
|
|
976
|
+
await this.eventStore.clearLabelForTrack(plate.trackId);
|
|
977
|
+
await this.trackStore.clearLabel(plate.trackId);
|
|
978
|
+
this.refreshGallery();
|
|
979
|
+
this.safeEmitPlateGalleryChanged({
|
|
980
|
+
deviceId: plate.deviceId,
|
|
981
|
+
plateId,
|
|
982
|
+
kind: "unassigned"
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
async assignPlates(input) {
|
|
986
|
+
let assigned = 0;
|
|
987
|
+
const failed = [];
|
|
988
|
+
for (const plateId of input.plateIds) try {
|
|
989
|
+
await this.assignPlate({
|
|
990
|
+
plateId,
|
|
991
|
+
vehicleId: input.vehicleId
|
|
992
|
+
});
|
|
993
|
+
assigned += 1;
|
|
994
|
+
} catch {
|
|
995
|
+
failed.push(plateId);
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
assigned,
|
|
999
|
+
failed
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
async unassignPlates(input) {
|
|
1003
|
+
let unassigned = 0;
|
|
1004
|
+
const failed = [];
|
|
1005
|
+
for (const plateId of input.plateIds) try {
|
|
1006
|
+
await this.unassignPlate({ plateId });
|
|
1007
|
+
unassigned += 1;
|
|
1008
|
+
} catch {
|
|
1009
|
+
failed.push(plateId);
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
unassigned,
|
|
1013
|
+
failed
|
|
1014
|
+
};
|
|
769
1015
|
}
|
|
770
1016
|
};
|
|
771
1017
|
//#endregion
|
|
@@ -853,7 +1099,6 @@ var DEFAULT_TRACKER_CONFIG = {
|
|
|
853
1099
|
stationarySpeedPx: 2
|
|
854
1100
|
};
|
|
855
1101
|
var MAX_PATH_LENGTH = 300;
|
|
856
|
-
var nextTrackId = 1;
|
|
857
1102
|
function clamp(value, min, max) {
|
|
858
1103
|
return Math.max(min, Math.min(max, value));
|
|
859
1104
|
}
|
|
@@ -1060,7 +1305,7 @@ var SortTracker = class {
|
|
|
1060
1305
|
const det = detections[di];
|
|
1061
1306
|
if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
|
|
1062
1307
|
surviving.push({
|
|
1063
|
-
id:
|
|
1308
|
+
id: (0, node_crypto.randomUUID)(),
|
|
1064
1309
|
bbox: det.bbox,
|
|
1065
1310
|
class: det.class,
|
|
1066
1311
|
originalClass: det.originalClass,
|
|
@@ -1526,6 +1771,7 @@ var FrameProcessor = class {
|
|
|
1526
1771
|
const labelsByBbox = /* @__PURE__ */ new Map();
|
|
1527
1772
|
const embeddingByBbox = /* @__PURE__ */ new Map();
|
|
1528
1773
|
const firstLevelBboxById = /* @__PURE__ */ new Map();
|
|
1774
|
+
const sourceIdByBbox = /* @__PURE__ */ new Map();
|
|
1529
1775
|
const faceBboxByBbox = /* @__PURE__ */ new Map();
|
|
1530
1776
|
const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
|
|
1531
1777
|
const plateByBbox = /* @__PURE__ */ new Map();
|
|
@@ -1554,6 +1800,7 @@ var FrameProcessor = class {
|
|
|
1554
1800
|
height: det.maskHeight
|
|
1555
1801
|
});
|
|
1556
1802
|
firstLevelBboxById.set(det.id, bbox);
|
|
1803
|
+
sourceIdByBbox.set(bbox, det.id);
|
|
1557
1804
|
return {
|
|
1558
1805
|
...bbox,
|
|
1559
1806
|
detection,
|
|
@@ -1615,8 +1862,10 @@ var FrameProcessor = class {
|
|
|
1615
1862
|
const faceBbox = faceBboxByBbox.get(td.bbox);
|
|
1616
1863
|
const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
|
|
1617
1864
|
const plate = plateByBbox.get(td.bbox);
|
|
1865
|
+
const sourceDetectionId = sourceIdByBbox.get(td.bbox);
|
|
1618
1866
|
return {
|
|
1619
1867
|
trackId: td.trackId,
|
|
1868
|
+
...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
|
|
1620
1869
|
className: td.class,
|
|
1621
1870
|
confidence: td.score,
|
|
1622
1871
|
bbox: { ...td.bbox },
|
|
@@ -1679,6 +1928,84 @@ var FrameProcessor = class {
|
|
|
1679
1928
|
};
|
|
1680
1929
|
}
|
|
1681
1930
|
};
|
|
1931
|
+
var DEFAULT_UPDATE_GATE_CONFIG = {
|
|
1932
|
+
minConfDelta: .1,
|
|
1933
|
+
minIntervalMs: 4e3,
|
|
1934
|
+
minCropAreaGrowth: .15
|
|
1935
|
+
};
|
|
1936
|
+
/**
|
|
1937
|
+
* Decide whether a `phase:'update'` event should fire this frame.
|
|
1938
|
+
*
|
|
1939
|
+
* Priority of triggers: newly-resolved identity/plate > confidence
|
|
1940
|
+
* improvement > materially larger crop. All triggers are debounced by
|
|
1941
|
+
* `minIntervalMs` relative to the last emit. Deltas are measured against
|
|
1942
|
+
* the LAST-EMITTED memory, so a run of sub-threshold new-bests accumulates
|
|
1943
|
+
* and fires once it crosses `minConfDelta`.
|
|
1944
|
+
*/
|
|
1945
|
+
function evaluateTrackLifecycleUpdate(signal, memory, config) {
|
|
1946
|
+
const identityTrigger = signal.label !== void 0 && signal.label !== "" && (memory === void 0 || memory.lastLabel !== signal.label);
|
|
1947
|
+
const confidenceTrigger = signal.isNewBest && (memory === void 0 || signal.confidence - memory.lastConfidence >= config.minConfDelta);
|
|
1948
|
+
const cropTrigger = signal.bboxArea !== void 0 && memory?.lastBboxArea !== void 0 && memory.lastBboxArea > 0 && signal.bboxArea >= memory.lastBboxArea * (1 + config.minCropAreaGrowth);
|
|
1949
|
+
const reason = identityTrigger ? "identity" : confidenceTrigger ? "confidence" : cropTrigger ? "crop" : void 0;
|
|
1950
|
+
const keptMemory = memory ?? {
|
|
1951
|
+
lastConfidence: signal.confidence,
|
|
1952
|
+
lastEmitAt: signal.now
|
|
1953
|
+
};
|
|
1954
|
+
if (reason === void 0) return {
|
|
1955
|
+
emit: false,
|
|
1956
|
+
memory: keptMemory
|
|
1957
|
+
};
|
|
1958
|
+
if (memory !== void 0 && signal.now - memory.lastEmitAt < config.minIntervalMs) return {
|
|
1959
|
+
emit: false,
|
|
1960
|
+
memory
|
|
1961
|
+
};
|
|
1962
|
+
const nextLabel = signal.label ?? memory?.lastLabel;
|
|
1963
|
+
const nextBboxArea = signal.bboxArea ?? memory?.lastBboxArea;
|
|
1964
|
+
return {
|
|
1965
|
+
emit: true,
|
|
1966
|
+
reason,
|
|
1967
|
+
memory: {
|
|
1968
|
+
lastConfidence: memory === void 0 ? signal.confidence : Math.max(memory.lastConfidence, signal.confidence),
|
|
1969
|
+
lastEmitAt: signal.now,
|
|
1970
|
+
...nextLabel !== void 0 ? { lastLabel: nextLabel } : {},
|
|
1971
|
+
...nextBboxArea !== void 0 ? { lastBboxArea: nextBboxArea } : {}
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Assemble the typed lifecycle payload. Optional fields (and the whole
|
|
1977
|
+
* `media` object) are omitted when absent so consumers get a clean shape.
|
|
1978
|
+
*/
|
|
1979
|
+
function buildTrackLifecyclePayload(input) {
|
|
1980
|
+
const media = {
|
|
1981
|
+
...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {},
|
|
1982
|
+
...input.bestCropMediaKey !== void 0 ? { bestCropMediaKey: input.bestCropMediaKey } : {},
|
|
1983
|
+
...input.bestEventId !== void 0 ? { bestEventId: input.bestEventId } : {}
|
|
1984
|
+
};
|
|
1985
|
+
const hasMedia = Object.keys(media).length > 0;
|
|
1986
|
+
return {
|
|
1987
|
+
deviceId: input.deviceId,
|
|
1988
|
+
trackId: input.trackId,
|
|
1989
|
+
phase: input.phase,
|
|
1990
|
+
classes: [...input.classes],
|
|
1991
|
+
bestClassName: input.bestClassName,
|
|
1992
|
+
bestConfidence: input.bestConfidence,
|
|
1993
|
+
firstSeen: input.firstSeen,
|
|
1994
|
+
lastSeen: input.lastSeen,
|
|
1995
|
+
durationMs: Math.max(0, input.lastSeen - input.firstSeen),
|
|
1996
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
1997
|
+
...input.identityId !== void 0 ? { identityId: input.identityId } : {},
|
|
1998
|
+
...input.plateText !== void 0 ? { plateText: input.plateText } : {},
|
|
1999
|
+
...input.zonesVisited !== void 0 ? { zonesVisited: [...input.zonesVisited] } : {},
|
|
2000
|
+
...input.totalDistance !== void 0 ? { totalDistance: input.totalDistance } : {},
|
|
2001
|
+
...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
|
|
2002
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
2003
|
+
...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
|
|
2004
|
+
...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
|
|
2005
|
+
...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
|
|
2006
|
+
...hasMedia ? { media } : {}
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
1682
2009
|
//#endregion
|
|
1683
2010
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1684
2011
|
var BestDetectionTracker = class {
|
|
@@ -1883,6 +2210,50 @@ function nativeDetectionsToFrame(input) {
|
|
|
1883
2210
|
};
|
|
1884
2211
|
}
|
|
1885
2212
|
//#endregion
|
|
2213
|
+
//#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
|
|
2214
|
+
/**
|
|
2215
|
+
* Extensible whole-track deletion cascade. Runs every registered
|
|
2216
|
+
* `TrackScopedStore` (each removes its ephemeral rows + owned media for the
|
|
2217
|
+
* tracks), THEN deletes the track ROOT rows LAST.
|
|
2218
|
+
*
|
|
2219
|
+
* ORDER MATTERS: every leaf store first, the track row last. The intended
|
|
2220
|
+
* production registry (leaves first, root last):
|
|
2221
|
+
*
|
|
2222
|
+
* [ eventStore, // object events + their event-owned media
|
|
2223
|
+
* mediaStore, // track + unassigned face/plate crops (NEVER identity/vehicle)
|
|
2224
|
+
* faceStore, // unassigned face rows (assigned/enrolled exempt)
|
|
2225
|
+
* plateStore, // unassigned plate reads (assigned/enrolled exempt)
|
|
2226
|
+
* objectEmbeddingStore, // per-track CLIP search vectors
|
|
2227
|
+
* ]
|
|
2228
|
+
*/
|
|
2229
|
+
async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
2230
|
+
for (const store of registry) await store.deleteByTracks(trackIds);
|
|
2231
|
+
for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Widened batch whole-track deletion — the ONE engine behind the three §5 entry
|
|
2235
|
+
* points. Runs the FULL registry cascade per track (via `cascadeDeleteTracks`
|
|
2236
|
+
* with a single-id list so the track root is deleted last), fires cleanup on
|
|
2237
|
+
* success only, and collects the ids whose cascade threw. Per-track ISOLATED —
|
|
2238
|
+
* one bad id never aborts the rest of the batch.
|
|
2239
|
+
*/
|
|
2240
|
+
async function runTrackCascadeBatch(deps, trackIds) {
|
|
2241
|
+
let deleted = 0;
|
|
2242
|
+
const failed = [];
|
|
2243
|
+
for (const trackId of trackIds) try {
|
|
2244
|
+
await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
|
|
2245
|
+
deps.onTrackCleanup(deps.deviceId, trackId);
|
|
2246
|
+
deleted += 1;
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
deps.onFailure?.(trackId, err);
|
|
2249
|
+
failed.push(trackId);
|
|
2250
|
+
}
|
|
2251
|
+
return {
|
|
2252
|
+
deleted,
|
|
2253
|
+
failed
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
//#endregion
|
|
1886
2257
|
//#region src/pipeline-analytics/runtime/binding-cache.ts
|
|
1887
2258
|
var BindingCache = class {
|
|
1888
2259
|
api;
|
|
@@ -1988,6 +2359,10 @@ var TRACKS_COLUMNS = [
|
|
|
1988
2359
|
name: "zonesVisited",
|
|
1989
2360
|
type: "JSON"
|
|
1990
2361
|
},
|
|
2362
|
+
{
|
|
2363
|
+
name: "classes",
|
|
2364
|
+
type: "JSON"
|
|
2365
|
+
},
|
|
1991
2366
|
{
|
|
1992
2367
|
name: "totalDistance",
|
|
1993
2368
|
type: "REAL"
|
|
@@ -2036,6 +2411,7 @@ function cloneTrack(t) {
|
|
|
2036
2411
|
}
|
|
2037
2412
|
})),
|
|
2038
2413
|
zonesVisited: [...t.zonesVisited],
|
|
2414
|
+
classes: [...t.classes],
|
|
2039
2415
|
totalDistance: t.totalDistance,
|
|
2040
2416
|
state: t.state,
|
|
2041
2417
|
active: t.active,
|
|
@@ -2080,6 +2456,7 @@ var TrackStore = class {
|
|
|
2080
2456
|
existing.positions.push(params.position);
|
|
2081
2457
|
} else existing.positions.push(params.position);
|
|
2082
2458
|
for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
|
|
2459
|
+
if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
|
|
2083
2460
|
return existing;
|
|
2084
2461
|
}
|
|
2085
2462
|
const fresh = {
|
|
@@ -2092,6 +2469,7 @@ var TrackStore = class {
|
|
|
2092
2469
|
positions: [params.position],
|
|
2093
2470
|
snapshots: [],
|
|
2094
2471
|
zonesVisited: [...params.zones],
|
|
2472
|
+
classes: [params.className],
|
|
2095
2473
|
totalDistance: 0,
|
|
2096
2474
|
state: params.state,
|
|
2097
2475
|
active: true,
|
|
@@ -2234,6 +2612,92 @@ var TrackStore = class {
|
|
|
2234
2612
|
clearAll() {
|
|
2235
2613
|
this.active.clear();
|
|
2236
2614
|
}
|
|
2615
|
+
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2616
|
+
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2617
|
+
async deletePersisted(trackId) {
|
|
2618
|
+
await this.store.delete.mutate({
|
|
2619
|
+
collection: TRACKS_COLLECTION,
|
|
2620
|
+
key: trackId
|
|
2621
|
+
});
|
|
2622
|
+
this.active.delete(trackId);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* A page of persisted track ids for a device whose `lastSeen < cutoffMs`
|
|
2626
|
+
* (exclusive), oldest first. Feeds the `pruneTracksBefore` drain loop (design
|
|
2627
|
+
* §5.1): the caller cascades the returned ids (which deletes their rows) and
|
|
2628
|
+
* calls again until an empty page. Backed by `idx_tracks_device_lastSeen`.
|
|
2629
|
+
*
|
|
2630
|
+
* Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
|
|
2631
|
+
* → strictly `< cutoffMs`. Best-effort — a query failure yields [].
|
|
2632
|
+
*/
|
|
2633
|
+
async listIdsBefore(deviceId, cutoffMs, limit) {
|
|
2634
|
+
try {
|
|
2635
|
+
return (await this.store.query.query({
|
|
2636
|
+
collection: TRACKS_COLLECTION,
|
|
2637
|
+
filter: {
|
|
2638
|
+
where: { deviceId },
|
|
2639
|
+
whereBetween: { lastSeen: [0, cutoffMs - 1] },
|
|
2640
|
+
orderBy: {
|
|
2641
|
+
field: "lastSeen",
|
|
2642
|
+
direction: "asc"
|
|
2643
|
+
},
|
|
2644
|
+
limit
|
|
2645
|
+
}
|
|
2646
|
+
})).map((r) => r.id).filter((id) => typeof id === "string");
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
this.logger.warn("TrackStore.listIdsBefore failed", { meta: {
|
|
2649
|
+
deviceId,
|
|
2650
|
+
cutoffMs,
|
|
2651
|
+
error: String(err)
|
|
2652
|
+
} });
|
|
2653
|
+
return [];
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* The distinct set of deviceIds that currently have persisted tracks. Feeds
|
|
2658
|
+
* the per-device retention sweep (design §6) so a device that stopped
|
|
2659
|
+
* producing frames still has its aged track debris pruned. Paged scan +
|
|
2660
|
+
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
2661
|
+
*/
|
|
2662
|
+
async listDeviceIds() {
|
|
2663
|
+
const seenDevices = /* @__PURE__ */ new Set();
|
|
2664
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2665
|
+
const PAGE = 500;
|
|
2666
|
+
let cursor = 0;
|
|
2667
|
+
try {
|
|
2668
|
+
for (;;) {
|
|
2669
|
+
const rows = await this.store.query.query({
|
|
2670
|
+
collection: TRACKS_COLLECTION,
|
|
2671
|
+
filter: {
|
|
2672
|
+
whereBetween: { lastSeen: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
2673
|
+
orderBy: {
|
|
2674
|
+
field: "lastSeen",
|
|
2675
|
+
direction: "asc"
|
|
2676
|
+
},
|
|
2677
|
+
limit: PAGE
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
if (rows.length === 0) break;
|
|
2681
|
+
let newInPage = 0;
|
|
2682
|
+
let maxLastSeen = cursor;
|
|
2683
|
+
for (const r of rows) {
|
|
2684
|
+
if (typeof r.id === "string" && !seenIds.has(r.id)) {
|
|
2685
|
+
seenIds.add(r.id);
|
|
2686
|
+
newInPage++;
|
|
2687
|
+
}
|
|
2688
|
+
const ls = Number(r.data["lastSeen"]);
|
|
2689
|
+
if (Number.isFinite(ls) && ls > maxLastSeen) maxLastSeen = ls;
|
|
2690
|
+
const deviceId = Number(r.data["deviceId"]);
|
|
2691
|
+
if (Number.isFinite(deviceId)) seenDevices.add(deviceId);
|
|
2692
|
+
}
|
|
2693
|
+
if (rows.length < PAGE || newInPage === 0) break;
|
|
2694
|
+
cursor = maxLastSeen;
|
|
2695
|
+
}
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
this.logger.warn("TrackStore.listDeviceIds failed", { meta: { error: String(err) } });
|
|
2698
|
+
}
|
|
2699
|
+
return [...seenDevices];
|
|
2700
|
+
}
|
|
2237
2701
|
/** Historical query — hits the persisted collection. */
|
|
2238
2702
|
async queryHistorical(params) {
|
|
2239
2703
|
const filter = { where: { deviceId: params.deviceId } };
|
|
@@ -2275,6 +2739,7 @@ var TrackStore = class {
|
|
|
2275
2739
|
positions: [...t.positions],
|
|
2276
2740
|
snapshots: [...t.snapshots],
|
|
2277
2741
|
zonesVisited: [...t.zonesVisited],
|
|
2742
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
2278
2743
|
totalDistance: t.totalDistance,
|
|
2279
2744
|
state: t.state,
|
|
2280
2745
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
@@ -2287,6 +2752,7 @@ var TrackStore = class {
|
|
|
2287
2752
|
const positions = data["positions"] ?? [];
|
|
2288
2753
|
const snapshots = data["snapshots"] ?? [];
|
|
2289
2754
|
const zones = data["zonesVisited"] ?? [];
|
|
2755
|
+
const classes = data["classes"];
|
|
2290
2756
|
const label = data["label"];
|
|
2291
2757
|
const importance = data["importance"];
|
|
2292
2758
|
const bestEventId = data["bestEventId"];
|
|
@@ -2301,6 +2767,7 @@ var TrackStore = class {
|
|
|
2301
2767
|
positions,
|
|
2302
2768
|
snapshots,
|
|
2303
2769
|
zonesVisited: zones,
|
|
2770
|
+
...classes !== null ? { classes } : {},
|
|
2304
2771
|
totalDistance: Number(data["totalDistance"] ?? 0),
|
|
2305
2772
|
state: data["state"] ?? "idle",
|
|
2306
2773
|
active: false,
|
|
@@ -2313,6 +2780,18 @@ var TrackStore = class {
|
|
|
2313
2780
|
//#endregion
|
|
2314
2781
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
2315
2782
|
var MEDIA_COLLECTION = "pipeline-analytics:media";
|
|
2783
|
+
/** Owner-id prefix for a track's buffered FACE crop. Invariant established by
|
|
2784
|
+
* the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
|
|
2785
|
+
* (one buffered face per track), so a track's face crops are owned by
|
|
2786
|
+
* `face-<trackId>`. Used by `deleteByTracks` to derive the face crop owners of
|
|
2787
|
+
* a set of tracks without a `trackId` column on media rows. */
|
|
2788
|
+
var FACE_MEDIA_OWNER_PREFIX = "face-";
|
|
2789
|
+
/** Owner-id prefix for a track's buffered PLATE crop. Invariant established by
|
|
2790
|
+
* the plate recognizer (`plate-recognizer.ts`): `plateId === 'plate-' + trackId`
|
|
2791
|
+
* (one buffered plate per track), so a track's plate crops are owned by
|
|
2792
|
+
* `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
|
|
2793
|
+
* of a set of tracks without a `trackId` column on media rows. */
|
|
2794
|
+
var PLATE_MEDIA_OWNER_PREFIX = "plate-";
|
|
2316
2795
|
var MEDIA_COLUMNS = [
|
|
2317
2796
|
{
|
|
2318
2797
|
name: "id",
|
|
@@ -2677,6 +3156,28 @@ var MediaStore = class {
|
|
|
2677
3156
|
async deleteForEvents(eventIds) {
|
|
2678
3157
|
return this.deleteForOwner("event", eventIds);
|
|
2679
3158
|
}
|
|
3159
|
+
/** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
|
|
3160
|
+
async deleteForTracks(trackIds) {
|
|
3161
|
+
return this.deleteForOwner("track", trackIds);
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
3165
|
+
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
3166
|
+
* the UNassigned face crops (`ownerKind:'face'`, owner `face-<trackId>`).
|
|
3167
|
+
*
|
|
3168
|
+
* NEVER deletes `ownerKind:'identity'` or `ownerKind:'vehicle'` media: an
|
|
3169
|
+
* ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
|
|
3170
|
+
* assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
|
|
3171
|
+
* crops and leaves the enrolled gallery intact. Event-owned media
|
|
3172
|
+
* (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
|
|
3173
|
+
* set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
|
|
3174
|
+
* batch.
|
|
3175
|
+
*/
|
|
3176
|
+
async deleteByTracks(trackIds) {
|
|
3177
|
+
await this.deleteForOwner("track", trackIds);
|
|
3178
|
+
await this.deleteForOwner("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
3179
|
+
await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
3180
|
+
}
|
|
2680
3181
|
/** Retention sweep: delete any media row + blob older than cutoff.
|
|
2681
3182
|
* Returns number of entries removed. */
|
|
2682
3183
|
async evictBefore(cutoffMs) {
|
|
@@ -2839,9 +3340,11 @@ var COMMON_INDEXES = (prefix) => [{
|
|
|
2839
3340
|
var EventStore = class {
|
|
2840
3341
|
store;
|
|
2841
3342
|
logger;
|
|
3343
|
+
media;
|
|
2842
3344
|
constructor(deps) {
|
|
2843
3345
|
this.store = deps.store;
|
|
2844
3346
|
this.logger = deps.logger;
|
|
3347
|
+
this.media = deps.media;
|
|
2845
3348
|
}
|
|
2846
3349
|
static async declare(store) {
|
|
2847
3350
|
await store.declareCollection.mutate({
|
|
@@ -3054,6 +3557,12 @@ var EventStore = class {
|
|
|
3054
3557
|
let bestEventId;
|
|
3055
3558
|
let peakBboxAreaFrac = 0;
|
|
3056
3559
|
for (const row of rows) {
|
|
3560
|
+
const bbox = row.data["bbox"];
|
|
3561
|
+
if (bbox !== null && typeof bbox === "object") {
|
|
3562
|
+
const bw = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
|
|
3563
|
+
const bh = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
|
|
3564
|
+
if (bw <= 0 || bh <= 0) continue;
|
|
3565
|
+
}
|
|
3057
3566
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
3058
3567
|
if (conf <= bestConf) continue;
|
|
3059
3568
|
bestConf = conf;
|
|
@@ -3243,6 +3752,72 @@ var EventStore = class {
|
|
|
3243
3752
|
]
|
|
3244
3753
|
};
|
|
3245
3754
|
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Delete every OBJECT_EVENTS_COLLECTION row for a single track and RETURN the
|
|
3757
|
+
* deleted event ids so the caller can delete each event's media in lockstep
|
|
3758
|
+
* (the whole-track delete cascade). Motion/audio collections have no
|
|
3759
|
+
* `trackId` column and are never touched.
|
|
3760
|
+
*
|
|
3761
|
+
* The underlying settings-store `query` cap applies a default row limit
|
|
3762
|
+
* (~500). This drains a page at a time — repeatedly querying up to
|
|
3763
|
+
* `PRUNE_PAGE_SIZE` rows for the track and deleting them — until a page
|
|
3764
|
+
* returns 0 rows, so ALL of the track's events are removed regardless of
|
|
3765
|
+
* count. Mirrors the drain loop in `pruneBefore`, including the
|
|
3766
|
+
* infinite-loop guard (a non-empty page that deletes nothing stops the loop).
|
|
3767
|
+
*/
|
|
3768
|
+
async deleteByTrack(trackId) {
|
|
3769
|
+
const ids = [];
|
|
3770
|
+
for (;;) {
|
|
3771
|
+
const rows = await this.store.query.query({
|
|
3772
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3773
|
+
filter: {
|
|
3774
|
+
where: { trackId },
|
|
3775
|
+
limit: 500
|
|
3776
|
+
}
|
|
3777
|
+
});
|
|
3778
|
+
if (rows.length === 0) break;
|
|
3779
|
+
let deletedInPage = 0;
|
|
3780
|
+
for (const row of rows) {
|
|
3781
|
+
const id = row.id;
|
|
3782
|
+
if (typeof id !== "string") continue;
|
|
3783
|
+
try {
|
|
3784
|
+
await this.store.delete.mutate({
|
|
3785
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3786
|
+
key: id
|
|
3787
|
+
});
|
|
3788
|
+
ids.push(id);
|
|
3789
|
+
deletedInPage++;
|
|
3790
|
+
} catch {}
|
|
3791
|
+
}
|
|
3792
|
+
if (deletedInPage === 0) break;
|
|
3793
|
+
}
|
|
3794
|
+
return ids;
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* `TrackScopedStore` contract for the retention cascade: delete every object
|
|
3798
|
+
* event of the given tracks AND (folded in) the event-owned media of those
|
|
3799
|
+
* events, so a caller can never skip the media wipe. Motion/audio collections
|
|
3800
|
+
* carry no `trackId` and are untouched. Reuses the paged `deleteByTrack` drain
|
|
3801
|
+
* per track and is per-track ISOLATED — a failure on one track never aborts
|
|
3802
|
+
* the others (mirrors the best-effort contract in §4 of the design).
|
|
3803
|
+
*/
|
|
3804
|
+
async deleteByTracks(trackIds) {
|
|
3805
|
+
const eventIds = [];
|
|
3806
|
+
for (const trackId of trackIds) try {
|
|
3807
|
+
const ids = await this.deleteByTrack(trackId);
|
|
3808
|
+
eventIds.push(...ids);
|
|
3809
|
+
} catch (err) {
|
|
3810
|
+
this.logger.warn("EventStore.deleteByTracks: track failed", { meta: {
|
|
3811
|
+
trackId,
|
|
3812
|
+
error: String(err)
|
|
3813
|
+
} });
|
|
3814
|
+
}
|
|
3815
|
+
if (eventIds.length > 0 && this.media !== void 0) try {
|
|
3816
|
+
await this.media.deleteForEvents(eventIds);
|
|
3817
|
+
} catch (err) {
|
|
3818
|
+
this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3246
3821
|
};
|
|
3247
3822
|
function slimMotion(id, data) {
|
|
3248
3823
|
return {
|
|
@@ -3309,6 +3884,18 @@ function stripNulls(data) {
|
|
|
3309
3884
|
return out;
|
|
3310
3885
|
}
|
|
3311
3886
|
//#endregion
|
|
3887
|
+
//#region src/shared/frame/resolve-frame.ts
|
|
3888
|
+
/**
|
|
3889
|
+
* Resolve the pixels a `FrameHandle` refers to. Local shm read when the
|
|
3890
|
+
* handle's `nodeId` matches `deps.ownNodeId`, else routed via
|
|
3891
|
+
* `deps.getRemoteFrame`. Returns `null` when the frame is no longer
|
|
3892
|
+
* available (slot recycled locally, or the remote node reports no frame).
|
|
3893
|
+
*/
|
|
3894
|
+
async function resolveFrame(handle, deps) {
|
|
3895
|
+
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3896
|
+
return deps.getRemoteFrame(handle);
|
|
3897
|
+
}
|
|
3898
|
+
//#endregion
|
|
3312
3899
|
//#region src/shared/frame/square-safe-crop.ts
|
|
3313
3900
|
/**
|
|
3314
3901
|
* Compute a square-safe 16:9 crop region in pixel space.
|
|
@@ -3431,6 +4018,34 @@ function caption(className, confidence, label) {
|
|
|
3431
4018
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
3432
4019
|
}
|
|
3433
4020
|
/**
|
|
4021
|
+
* True when a packed-RGB frame is (near-)uniform in every channel — the
|
|
4022
|
+
* signature of a blank / hwaccel-unmapped decode frame. Samples a strided set
|
|
4023
|
+
* of pixels and checks each channel's min→max spread stays within a small
|
|
4024
|
+
* epsilon; a real image always has spread in at least one channel. Cheap
|
|
4025
|
+
* (~512 samples) so it runs on every capture.
|
|
4026
|
+
*/
|
|
4027
|
+
function isUniformRgbFrame(data, width, height) {
|
|
4028
|
+
const pixels = width * height;
|
|
4029
|
+
if (pixels === 0 || data.length < pixels * 3) return false;
|
|
4030
|
+
const step = Math.max(1, Math.floor(pixels / 512)) * 3;
|
|
4031
|
+
const mins = [
|
|
4032
|
+
255,
|
|
4033
|
+
255,
|
|
4034
|
+
255
|
|
4035
|
+
];
|
|
4036
|
+
const maxs = [
|
|
4037
|
+
0,
|
|
4038
|
+
0,
|
|
4039
|
+
0
|
|
4040
|
+
];
|
|
4041
|
+
for (let i = 0; i + 2 < data.length; i += step) for (let c = 0; c < 3; c++) {
|
|
4042
|
+
const v = data[i + c];
|
|
4043
|
+
mins[c] = Math.min(mins[c], v);
|
|
4044
|
+
maxs[c] = Math.max(maxs[c], v);
|
|
4045
|
+
}
|
|
4046
|
+
return maxs.every((mx, c) => mx - mins[c] <= 6);
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
3434
4049
|
* Generates object-event + track media FROM the decoded detection-pipeline
|
|
3435
4050
|
* frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
|
|
3436
4051
|
* snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
|
|
@@ -3454,7 +4069,7 @@ var EventMediaDispatcher = class {
|
|
|
3454
4069
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
3455
4070
|
let decoded;
|
|
3456
4071
|
try {
|
|
3457
|
-
decoded = await
|
|
4072
|
+
decoded = await resolveFrame(frameHandle, {
|
|
3458
4073
|
ownNodeId: this.deps.ownNodeId,
|
|
3459
4074
|
readers: this.deps.readers,
|
|
3460
4075
|
getRemoteFrame: this.deps.getRemoteFrame
|
|
@@ -3490,9 +4105,21 @@ var EventMediaDispatcher = class {
|
|
|
3490
4105
|
});
|
|
3491
4106
|
return empty;
|
|
3492
4107
|
}
|
|
3493
|
-
const frameData = Buffer.
|
|
4108
|
+
const frameData = Buffer.from(decoded.data);
|
|
3494
4109
|
const fw = decoded.width;
|
|
3495
4110
|
const fh = decoded.height;
|
|
4111
|
+
if (isUniformRgbFrame(frameData, fw, fh)) {
|
|
4112
|
+
this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
|
|
4113
|
+
tags: { deviceId },
|
|
4114
|
+
meta: {
|
|
4115
|
+
deviceId,
|
|
4116
|
+
shmId: frameHandle.shmId,
|
|
4117
|
+
width: fw,
|
|
4118
|
+
height: fh
|
|
4119
|
+
}
|
|
4120
|
+
});
|
|
4121
|
+
return empty;
|
|
4122
|
+
}
|
|
3496
4123
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
3497
4124
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3498
4125
|
const storedSnapshots = [];
|
|
@@ -3718,8 +4345,264 @@ var EventMediaDispatcher = class {
|
|
|
3718
4345
|
}
|
|
3719
4346
|
};
|
|
3720
4347
|
//#endregion
|
|
3721
|
-
//#region src/
|
|
3722
|
-
|
|
4348
|
+
//#region src/shared/frame/crop-extractor.ts
|
|
4349
|
+
/**
|
|
4350
|
+
* Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
|
|
4351
|
+
* Coordinates are clamped to frame bounds to avoid out-of-range errors.
|
|
4352
|
+
*/
|
|
4353
|
+
async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
|
|
4354
|
+
const rawLeft = Math.round(bbox.x * frameWidth);
|
|
4355
|
+
const rawTop = Math.round(bbox.y * frameHeight);
|
|
4356
|
+
const rawWidth = Math.round(bbox.w * frameWidth);
|
|
4357
|
+
const rawHeight = Math.round(bbox.h * frameHeight);
|
|
4358
|
+
const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
|
|
4359
|
+
const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
|
|
4360
|
+
const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
|
|
4361
|
+
const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
|
|
4362
|
+
return {
|
|
4363
|
+
crop: await (0, sharp.default)(frameData, { raw: {
|
|
4364
|
+
width: frameWidth,
|
|
4365
|
+
height: frameHeight,
|
|
4366
|
+
channels: 3
|
|
4367
|
+
} }).extract({
|
|
4368
|
+
left,
|
|
4369
|
+
top,
|
|
4370
|
+
width,
|
|
4371
|
+
height
|
|
4372
|
+
}).jpeg({ quality: 90 }).toBuffer(),
|
|
4373
|
+
width,
|
|
4374
|
+
height
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
4377
|
+
//#endregion
|
|
4378
|
+
//#region src/pipeline-analytics/embedding/embedding-dispatcher.ts
|
|
4379
|
+
function selectCropBbox(detection) {
|
|
4380
|
+
return detection.refinedBbox ?? detection.bbox;
|
|
4381
|
+
}
|
|
4382
|
+
var EmbeddingDispatcher = class {
|
|
4383
|
+
config;
|
|
4384
|
+
encoder;
|
|
4385
|
+
eventBus;
|
|
4386
|
+
logger;
|
|
4387
|
+
ownNodeId;
|
|
4388
|
+
readers;
|
|
4389
|
+
getRemoteFrame;
|
|
4390
|
+
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4391
|
+
pendingCrops = /* @__PURE__ */ new Map();
|
|
4392
|
+
flushTimer = null;
|
|
4393
|
+
unsubscribe = null;
|
|
4394
|
+
_processedCount = 0;
|
|
4395
|
+
_totalInferenceMs = 0;
|
|
4396
|
+
constructor(deps) {
|
|
4397
|
+
this.config = deps.config;
|
|
4398
|
+
this.encoder = deps.encoder;
|
|
4399
|
+
this.eventBus = deps.eventBus;
|
|
4400
|
+
this.logger = deps.logger;
|
|
4401
|
+
this.ownNodeId = deps.ownNodeId;
|
|
4402
|
+
this.readers = deps.readers;
|
|
4403
|
+
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4404
|
+
}
|
|
4405
|
+
async start() {
|
|
4406
|
+
if (!this.config.enabled) {
|
|
4407
|
+
this.logger.info("EmbeddingDispatcher disabled");
|
|
4408
|
+
return;
|
|
4409
|
+
}
|
|
4410
|
+
this.unsubscribe = this.eventBus.subscribe({ category: require_dist.EventCategory.DetectionResult }, (event) => {
|
|
4411
|
+
this.handleDetectionResult(event);
|
|
4412
|
+
});
|
|
4413
|
+
if (this.config.cropStrategy === "best-confidence") this.flushTimer = setInterval(() => {
|
|
4414
|
+
this.flushPending();
|
|
4415
|
+
}, 1e3);
|
|
4416
|
+
this.logger.info("EmbeddingDispatcher started", { meta: {
|
|
4417
|
+
strategy: this.config.cropStrategy,
|
|
4418
|
+
maxPerSec: this.config.maxPerSecPerCamera
|
|
4419
|
+
} });
|
|
4420
|
+
}
|
|
4421
|
+
async stop() {
|
|
4422
|
+
this.unsubscribe?.();
|
|
4423
|
+
this.unsubscribe = null;
|
|
4424
|
+
if (this.flushTimer) {
|
|
4425
|
+
clearInterval(this.flushTimer);
|
|
4426
|
+
this.flushTimer = null;
|
|
4427
|
+
}
|
|
4428
|
+
await this.flushPending();
|
|
4429
|
+
}
|
|
4430
|
+
get processedCount() {
|
|
4431
|
+
return this._processedCount;
|
|
4432
|
+
}
|
|
4433
|
+
get avgInferenceMs() {
|
|
4434
|
+
return this._processedCount > 0 ? this._totalInferenceMs / this._processedCount : 0;
|
|
4435
|
+
}
|
|
4436
|
+
get queueDepth() {
|
|
4437
|
+
return this.pendingCrops.size;
|
|
4438
|
+
}
|
|
4439
|
+
async handleDetectionResult(event) {
|
|
4440
|
+
const data = event.data;
|
|
4441
|
+
const deviceId = event.source.id !== void 0 ? String(event.source.id) : "";
|
|
4442
|
+
if (!deviceId) return;
|
|
4443
|
+
const detections = data.analysisResults ?? [];
|
|
4444
|
+
const handle = data.frameHandle;
|
|
4445
|
+
if (!handle) {
|
|
4446
|
+
this.logger.debug("skip: no frameHandle on DetectionResult", {
|
|
4447
|
+
tags: { deviceId: Number(deviceId) },
|
|
4448
|
+
meta: { deviceId }
|
|
4449
|
+
});
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
let decoded;
|
|
4453
|
+
try {
|
|
4454
|
+
decoded = await resolveFrame(handle, {
|
|
4455
|
+
ownNodeId: this.ownNodeId,
|
|
4456
|
+
readers: this.readers,
|
|
4457
|
+
getRemoteFrame: this.getRemoteFrame
|
|
4458
|
+
});
|
|
4459
|
+
} catch (err) {
|
|
4460
|
+
this.logger.debug("skip: resolveFrame threw", {
|
|
4461
|
+
tags: { deviceId: Number(deviceId) },
|
|
4462
|
+
meta: {
|
|
4463
|
+
deviceId,
|
|
4464
|
+
shmId: handle.shmId,
|
|
4465
|
+
error: String(err)
|
|
4466
|
+
}
|
|
4467
|
+
});
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
if (!decoded) {
|
|
4471
|
+
this.logger.debug("skip: frame recycled before resolve", {
|
|
4472
|
+
tags: { deviceId: Number(deviceId) },
|
|
4473
|
+
meta: {
|
|
4474
|
+
deviceId,
|
|
4475
|
+
shmId: handle.shmId
|
|
4476
|
+
}
|
|
4477
|
+
});
|
|
4478
|
+
return;
|
|
4479
|
+
}
|
|
4480
|
+
if (decoded.format !== "rgb") {
|
|
4481
|
+
this.logger.debug("skip: resolved frame is not RGB", {
|
|
4482
|
+
tags: { deviceId: Number(deviceId) },
|
|
4483
|
+
meta: {
|
|
4484
|
+
deviceId,
|
|
4485
|
+
format: decoded.format
|
|
4486
|
+
}
|
|
4487
|
+
});
|
|
4488
|
+
return;
|
|
4489
|
+
}
|
|
4490
|
+
const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
|
|
4491
|
+
const frameWidth = decoded.width;
|
|
4492
|
+
const frameHeight = decoded.height;
|
|
4493
|
+
for (const det of detections) {
|
|
4494
|
+
const detection = det.detection;
|
|
4495
|
+
if (!detection) continue;
|
|
4496
|
+
if (this.config.classes.length > 0 && !this.config.classes.includes(detection.class)) continue;
|
|
4497
|
+
if (detection.score < this.config.minConfidence) continue;
|
|
4498
|
+
const now = Date.now();
|
|
4499
|
+
const minInterval = 1e3 / this.config.maxPerSecPerCamera;
|
|
4500
|
+
if (now - (this.lastEmbedTime.get(deviceId) ?? 0) < minInterval) continue;
|
|
4501
|
+
const trackId = detection.trackId ?? `${deviceId}-${now}`;
|
|
4502
|
+
const pending = {
|
|
4503
|
+
trackId,
|
|
4504
|
+
deviceId,
|
|
4505
|
+
class: detection.class,
|
|
4506
|
+
confidence: detection.score,
|
|
4507
|
+
frameData,
|
|
4508
|
+
frameWidth,
|
|
4509
|
+
frameHeight,
|
|
4510
|
+
bbox: selectCropBbox(detection),
|
|
4511
|
+
receivedAt: now
|
|
4512
|
+
};
|
|
4513
|
+
switch (this.config.cropStrategy) {
|
|
4514
|
+
case "first":
|
|
4515
|
+
if (!this.pendingCrops.has(trackId)) {
|
|
4516
|
+
this.pendingCrops.set(trackId, pending);
|
|
4517
|
+
this.processOne(pending);
|
|
4518
|
+
}
|
|
4519
|
+
break;
|
|
4520
|
+
case "best-confidence": {
|
|
4521
|
+
const existing = this.pendingCrops.get(trackId);
|
|
4522
|
+
if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
|
|
4523
|
+
break;
|
|
4524
|
+
}
|
|
4525
|
+
case "track-end":
|
|
4526
|
+
if (det.objectState?.state === "leaving") {
|
|
4527
|
+
this.pendingCrops.set(trackId, pending);
|
|
4528
|
+
this.processOne(pending);
|
|
4529
|
+
} else {
|
|
4530
|
+
const existing = this.pendingCrops.get(trackId);
|
|
4531
|
+
if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
|
|
4532
|
+
}
|
|
4533
|
+
break;
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
async flushPending() {
|
|
4538
|
+
const now = Date.now();
|
|
4539
|
+
const toFlush = [];
|
|
4540
|
+
for (const [trackId, pending] of this.pendingCrops) if (now - pending.receivedAt > 3e3) {
|
|
4541
|
+
toFlush.push(pending);
|
|
4542
|
+
this.pendingCrops.delete(trackId);
|
|
4543
|
+
}
|
|
4544
|
+
await Promise.all(toFlush.map((p) => this.processOne(p)));
|
|
4545
|
+
}
|
|
4546
|
+
async processOne(pending) {
|
|
4547
|
+
try {
|
|
4548
|
+
const { crop, width, height } = await extractCrop(pending.frameData, pending.frameWidth, pending.frameHeight, pending.bbox);
|
|
4549
|
+
const { embedding: _embedding, inferenceMs } = await this.encoder.encode(crop, width, height);
|
|
4550
|
+
const info = await this.encoder.getInfo();
|
|
4551
|
+
this._processedCount++;
|
|
4552
|
+
this._totalInferenceMs += inferenceMs;
|
|
4553
|
+
this.lastEmbedTime.set(pending.deviceId, Date.now());
|
|
4554
|
+
this.pendingCrops.delete(pending.trackId);
|
|
4555
|
+
const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
|
|
4556
|
+
const payload = {
|
|
4557
|
+
deviceId: Number(pending.deviceId),
|
|
4558
|
+
trackId: pending.trackId,
|
|
4559
|
+
class: pending.class,
|
|
4560
|
+
embeddingId,
|
|
4561
|
+
modelId: info.modelId,
|
|
4562
|
+
embeddingDim: info.embeddingDim,
|
|
4563
|
+
inferenceMs,
|
|
4564
|
+
timestamp: Date.now()
|
|
4565
|
+
};
|
|
4566
|
+
this.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.EnrichmentEmbeddingStored, {
|
|
4567
|
+
type: "addon",
|
|
4568
|
+
id: "pipeline-analytics"
|
|
4569
|
+
}, payload));
|
|
4570
|
+
this.logger.debug("Embedded track", {
|
|
4571
|
+
tags: { deviceId: Number(pending.deviceId) },
|
|
4572
|
+
meta: {
|
|
4573
|
+
class: pending.class,
|
|
4574
|
+
trackId: pending.trackId,
|
|
4575
|
+
inferenceMs: Number(inferenceMs.toFixed(1))
|
|
4576
|
+
}
|
|
4577
|
+
});
|
|
4578
|
+
} catch (err) {
|
|
4579
|
+
this.logger.warn("Failed to embed track", {
|
|
4580
|
+
tags: { deviceId: Number(pending.deviceId) },
|
|
4581
|
+
meta: {
|
|
4582
|
+
trackId: pending.trackId,
|
|
4583
|
+
error: String(err)
|
|
4584
|
+
}
|
|
4585
|
+
});
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
};
|
|
4589
|
+
//#endregion
|
|
4590
|
+
//#region src/pipeline-analytics/embedding/embedding-config.ts
|
|
4591
|
+
var DEFAULT_EMBEDDING_CONFIG = {
|
|
4592
|
+
enabled: true,
|
|
4593
|
+
modelId: "clip-vit-b32",
|
|
4594
|
+
agentId: "local",
|
|
4595
|
+
runtime: "node",
|
|
4596
|
+
backend: "cpu",
|
|
4597
|
+
classes: [],
|
|
4598
|
+
minConfidence: .5,
|
|
4599
|
+
maxPerSecPerCamera: 1,
|
|
4600
|
+
cropStrategy: "first",
|
|
4601
|
+
retentionDays: 30
|
|
4602
|
+
};
|
|
4603
|
+
//#endregion
|
|
4604
|
+
//#region src/pipeline-analytics/runtime/slice-throttler.ts
|
|
4605
|
+
var SliceThrottler = class {
|
|
3723
4606
|
opts;
|
|
3724
4607
|
lastWrittenAt = /* @__PURE__ */ new Map();
|
|
3725
4608
|
lastWritten = /* @__PURE__ */ new Map();
|
|
@@ -4594,6 +5477,56 @@ function resolveMediaSettings(raw) {
|
|
|
4594
5477
|
};
|
|
4595
5478
|
}
|
|
4596
5479
|
//#endregion
|
|
5480
|
+
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
5481
|
+
/**
|
|
5482
|
+
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
5483
|
+
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
5484
|
+
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
5485
|
+
* retention interval.
|
|
5486
|
+
*
|
|
5487
|
+
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
5488
|
+
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
5489
|
+
* cascade store layer (design §4), not here.
|
|
5490
|
+
*
|
|
5491
|
+
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
5492
|
+
* without booting the addon.
|
|
5493
|
+
*/
|
|
5494
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
5495
|
+
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
5496
|
+
var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
|
|
5497
|
+
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
5498
|
+
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
5499
|
+
* missing value falls back to the default (parse never throws). */
|
|
5500
|
+
function resolveTrackRetentionDays(raw) {
|
|
5501
|
+
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
5502
|
+
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
5503
|
+
}
|
|
5504
|
+
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
5505
|
+
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
5506
|
+
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
5507
|
+
if (retentionDays <= 0) return null;
|
|
5508
|
+
return nowMs - retentionDays * DAY_MS;
|
|
5509
|
+
}
|
|
5510
|
+
/**
|
|
5511
|
+
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
5512
|
+
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
5513
|
+
* never aborts the sweep. Returns the total tracks pruned across devices.
|
|
5514
|
+
*/
|
|
5515
|
+
async function sweepTrackRetention(deps) {
|
|
5516
|
+
const devices = await deps.listDeviceIds();
|
|
5517
|
+
const nowMs = deps.now();
|
|
5518
|
+
let totalTracks = 0;
|
|
5519
|
+
for (const deviceId of devices) try {
|
|
5520
|
+
const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
|
|
5521
|
+
if (cutoffMs === null) continue;
|
|
5522
|
+
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
5523
|
+
totalTracks += counts.tracks;
|
|
5524
|
+
} catch (err) {
|
|
5525
|
+
deps.onError?.(deviceId, err);
|
|
5526
|
+
}
|
|
5527
|
+
return totalTracks;
|
|
5528
|
+
}
|
|
5529
|
+
//#endregion
|
|
4597
5530
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
4598
5531
|
/**
|
|
4599
5532
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -4643,7 +5576,7 @@ var IDENTITY_COLUMNS = [
|
|
|
4643
5576
|
type: "TEXT"
|
|
4644
5577
|
}
|
|
4645
5578
|
];
|
|
4646
|
-
var SAMPLE_COLUMNS = [
|
|
5579
|
+
var SAMPLE_COLUMNS$1 = [
|
|
4647
5580
|
{
|
|
4648
5581
|
name: "id",
|
|
4649
5582
|
type: "TEXT",
|
|
@@ -4704,7 +5637,7 @@ var IdentityStore = class {
|
|
|
4704
5637
|
});
|
|
4705
5638
|
await store.declareCollection.mutate({
|
|
4706
5639
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
4707
|
-
columns: [...SAMPLE_COLUMNS],
|
|
5640
|
+
columns: [...SAMPLE_COLUMNS$1],
|
|
4708
5641
|
indexes: [{
|
|
4709
5642
|
name: "idx_sample_identity",
|
|
4710
5643
|
columns: ["identityId"]
|
|
@@ -5139,6 +6072,38 @@ var FaceStore = class {
|
|
|
5139
6072
|
} });
|
|
5140
6073
|
}
|
|
5141
6074
|
}
|
|
6075
|
+
/**
|
|
6076
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
6077
|
+
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
6078
|
+
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
6079
|
+
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
6080
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
6081
|
+
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
6082
|
+
*/
|
|
6083
|
+
async deleteByTracks(trackIds) {
|
|
6084
|
+
for (const trackId of trackIds) try {
|
|
6085
|
+
const eligible = (await this.store.query.query({
|
|
6086
|
+
collection: FACES_COLLECTION,
|
|
6087
|
+
filter: { where: { trackId } }
|
|
6088
|
+
})).filter((r) => !r.data.assigned);
|
|
6089
|
+
for (const row of eligible) try {
|
|
6090
|
+
await this.store.delete.mutate({
|
|
6091
|
+
collection: FACES_COLLECTION,
|
|
6092
|
+
key: row.id
|
|
6093
|
+
});
|
|
6094
|
+
} catch (err) {
|
|
6095
|
+
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
6096
|
+
faceId: row.id,
|
|
6097
|
+
error: String(err)
|
|
6098
|
+
} });
|
|
6099
|
+
}
|
|
6100
|
+
} catch (err) {
|
|
6101
|
+
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
6102
|
+
trackId,
|
|
6103
|
+
error: String(err)
|
|
6104
|
+
} });
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
5142
6107
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
5143
6108
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
5144
6109
|
async delete(faceId) {
|
|
@@ -5410,6 +6375,27 @@ var ObjectEmbeddingStore = class {
|
|
|
5410
6375
|
}
|
|
5411
6376
|
return ids;
|
|
5412
6377
|
}
|
|
6378
|
+
/**
|
|
6379
|
+
* `TrackScopedStore` contract for the retention cascade: delete the per-track
|
|
6380
|
+
* CLIP search vector for the given tracks. The row id IS the trackId (one row
|
|
6381
|
+
* per track), so this deletes by key directly. Pure EPHEMERAL track state —
|
|
6382
|
+
* the identity MATCHING embeddings (ArcFace) live in a SEPARATE durable store
|
|
6383
|
+
* (`identity-samples`) and are never touched here, so pruning this never
|
|
6384
|
+
* breaks recognition (design §2/§4). Per-track ISOLATED + best-effort.
|
|
6385
|
+
*/
|
|
6386
|
+
async deleteByTracks(trackIds) {
|
|
6387
|
+
for (const trackId of trackIds) try {
|
|
6388
|
+
await this.store.delete.mutate({
|
|
6389
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
6390
|
+
key: trackId
|
|
6391
|
+
});
|
|
6392
|
+
} catch (err) {
|
|
6393
|
+
this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
|
|
6394
|
+
trackId,
|
|
6395
|
+
error: String(err)
|
|
6396
|
+
} });
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
5413
6399
|
};
|
|
5414
6400
|
//#endregion
|
|
5415
6401
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
@@ -5788,6 +6774,24 @@ var FaceRecognizer = class {
|
|
|
5788
6774
|
score: held.score
|
|
5789
6775
|
}
|
|
5790
6776
|
});
|
|
6777
|
+
try {
|
|
6778
|
+
this.deps.emitFaceGalleryChanged?.({
|
|
6779
|
+
deviceId,
|
|
6780
|
+
faceId,
|
|
6781
|
+
kind: "buffered"
|
|
6782
|
+
});
|
|
6783
|
+
} catch (err) {
|
|
6784
|
+
this.deps.logger.debug("face-gallery-changed emit failed", {
|
|
6785
|
+
tags: {
|
|
6786
|
+
deviceId,
|
|
6787
|
+
trackId
|
|
6788
|
+
},
|
|
6789
|
+
meta: {
|
|
6790
|
+
faceId,
|
|
6791
|
+
error: String(err)
|
|
6792
|
+
}
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
5791
6795
|
} catch (err) {
|
|
5792
6796
|
this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
|
|
5793
6797
|
tags: { deviceId },
|
|
@@ -5937,6 +6941,25 @@ var DetailScheduler = class {
|
|
|
5937
6941
|
};
|
|
5938
6942
|
//#endregion
|
|
5939
6943
|
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6944
|
+
/**
|
|
6945
|
+
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6946
|
+
* identity-aware for the face chain.
|
|
6947
|
+
*
|
|
6948
|
+
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6949
|
+
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
6950
|
+
* track that already carries an identity label: re-embedding a known face every
|
|
6951
|
+
* second is wasted ArcFace inference (250-1900ms per call on N100 agents), so
|
|
6952
|
+
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6953
|
+
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6954
|
+
*
|
|
6955
|
+
* Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
6956
|
+
* strict-`steps` pruning (`pruneChildStepsToRequested`) — including
|
|
6957
|
+
* `'face-embedding'` here is what keeps the nested child in the executed chain.
|
|
6958
|
+
*/
|
|
6959
|
+
function composeDetailSteps(req, hasTrackLabel) {
|
|
6960
|
+
if (req.stepId !== "face-detection") return [req.stepId];
|
|
6961
|
+
return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
6962
|
+
}
|
|
5940
6963
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
5941
6964
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
5942
6965
|
var TrackDetailDispatcher = class {
|
|
@@ -6083,12 +7106,13 @@ var TrackDetailDispatcher = class {
|
|
|
6083
7106
|
bbox: { ...frame.bbox },
|
|
6084
7107
|
className: frame.className
|
|
6085
7108
|
};
|
|
7109
|
+
const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
|
|
6086
7110
|
if (frame.frameHandle !== void 0) try {
|
|
6087
7111
|
const primary = await this.deps.runDetailSubtree({
|
|
6088
7112
|
deviceId,
|
|
6089
7113
|
frameHandle: frame.frameHandle,
|
|
6090
7114
|
parent,
|
|
6091
|
-
steps
|
|
7115
|
+
steps
|
|
6092
7116
|
}, frame.nodeId);
|
|
6093
7117
|
if (primary !== null) return primary.details;
|
|
6094
7118
|
} catch (err) {
|
|
@@ -6108,7 +7132,7 @@ var TrackDetailDispatcher = class {
|
|
|
6108
7132
|
deviceId,
|
|
6109
7133
|
cropJpeg,
|
|
6110
7134
|
parent,
|
|
6111
|
-
steps
|
|
7135
|
+
steps
|
|
6112
7136
|
}, frame.nodeId);
|
|
6113
7137
|
if (retry !== null) return retry.details;
|
|
6114
7138
|
}
|
|
@@ -6140,6 +7164,184 @@ var TrackDetailDispatcher = class {
|
|
|
6140
7164
|
}
|
|
6141
7165
|
};
|
|
6142
7166
|
//#endregion
|
|
7167
|
+
//#region src/pipeline-analytics/overlay-state.ts
|
|
7168
|
+
function key(deviceId, trackId) {
|
|
7169
|
+
return `${deviceId}:${trackId}`;
|
|
7170
|
+
}
|
|
7171
|
+
var OverlayDetailStateStore = class {
|
|
7172
|
+
byTrack = /* @__PURE__ */ new Map();
|
|
7173
|
+
noteFaceDetail(deviceId, trackId, faceBbox, score, capturedAt) {
|
|
7174
|
+
const k = key(deviceId, trackId);
|
|
7175
|
+
const prev = this.byTrack.get(k);
|
|
7176
|
+
this.byTrack.set(k, {
|
|
7177
|
+
...prev,
|
|
7178
|
+
face: {
|
|
7179
|
+
bbox: faceBbox,
|
|
7180
|
+
score,
|
|
7181
|
+
capturedAt
|
|
7182
|
+
}
|
|
7183
|
+
});
|
|
7184
|
+
}
|
|
7185
|
+
notePlateDetail(deviceId, trackId, plateBbox, score, text, capturedAt) {
|
|
7186
|
+
const k = key(deviceId, trackId);
|
|
7187
|
+
const prev = this.byTrack.get(k);
|
|
7188
|
+
this.byTrack.set(k, {
|
|
7189
|
+
...prev,
|
|
7190
|
+
plate: {
|
|
7191
|
+
bbox: plateBbox,
|
|
7192
|
+
score,
|
|
7193
|
+
capturedAt,
|
|
7194
|
+
text
|
|
7195
|
+
}
|
|
7196
|
+
});
|
|
7197
|
+
}
|
|
7198
|
+
get(deviceId, trackId) {
|
|
7199
|
+
return this.byTrack.get(key(deviceId, trackId));
|
|
7200
|
+
}
|
|
7201
|
+
onTrackEnded(deviceId, trackId) {
|
|
7202
|
+
this.byTrack.delete(key(deviceId, trackId));
|
|
7203
|
+
}
|
|
7204
|
+
clearDevice(deviceId) {
|
|
7205
|
+
const prefix = `${deviceId}:`;
|
|
7206
|
+
for (const k of this.byTrack.keys()) if (k.startsWith(prefix)) this.byTrack.delete(k);
|
|
7207
|
+
}
|
|
7208
|
+
clear() {
|
|
7209
|
+
this.byTrack.clear();
|
|
7210
|
+
}
|
|
7211
|
+
};
|
|
7212
|
+
function toDetectionBbox(bbox) {
|
|
7213
|
+
return {
|
|
7214
|
+
x: bbox.x,
|
|
7215
|
+
y: bbox.y,
|
|
7216
|
+
width: bbox.w,
|
|
7217
|
+
height: bbox.h
|
|
7218
|
+
};
|
|
7219
|
+
}
|
|
7220
|
+
function buildOverlayDetections(input) {
|
|
7221
|
+
const trackedBySourceId = /* @__PURE__ */ new Map();
|
|
7222
|
+
for (const t of input.tracked) if (t.sourceDetectionId !== void 0) trackedBySourceId.set(t.sourceDetectionId, t);
|
|
7223
|
+
const realDetailKindsByParent = /* @__PURE__ */ new Map();
|
|
7224
|
+
for (const d of input.frameDetections) {
|
|
7225
|
+
if (d.kind !== "detail" || d.parentId === void 0) continue;
|
|
7226
|
+
const set = realDetailKindsByParent.get(d.parentId) ?? /* @__PURE__ */ new Set();
|
|
7227
|
+
set.add(d.macroClass);
|
|
7228
|
+
realDetailKindsByParent.set(d.parentId, set);
|
|
7229
|
+
}
|
|
7230
|
+
const out = [];
|
|
7231
|
+
const synthesized = [];
|
|
7232
|
+
for (const det of input.frameDetections) {
|
|
7233
|
+
const t = det.kind === "first-level" ? trackedBySourceId.get(det.id) : void 0;
|
|
7234
|
+
if (t === void 0) {
|
|
7235
|
+
out.push(det);
|
|
7236
|
+
continue;
|
|
7237
|
+
}
|
|
7238
|
+
const name = input.labelFor(t.trackId) ?? "";
|
|
7239
|
+
const hasName = name.length > 0;
|
|
7240
|
+
const alreadyLabeled = hasName && det.labels.some((l) => l.label === name);
|
|
7241
|
+
out.push({
|
|
7242
|
+
...det,
|
|
7243
|
+
track: {
|
|
7244
|
+
trackId: t.trackId,
|
|
7245
|
+
zones: t.zones
|
|
7246
|
+
},
|
|
7247
|
+
labels: hasName && !alreadyLabeled ? [...det.labels, {
|
|
7248
|
+
label: name,
|
|
7249
|
+
score: 1
|
|
7250
|
+
}] : det.labels
|
|
7251
|
+
});
|
|
7252
|
+
const overlay = input.overlayFor(t.trackId);
|
|
7253
|
+
if (overlay === void 0) continue;
|
|
7254
|
+
const realKinds = realDetailKindsByParent.get(det.id);
|
|
7255
|
+
if (overlay.face !== void 0 && input.nowMs - overlay.face.capturedAt <= 2500 && overlay.face !== void 0 && realKinds?.has("face") !== true) synthesized.push({
|
|
7256
|
+
id: `detail-${t.trackId}-face`,
|
|
7257
|
+
kind: "detail",
|
|
7258
|
+
macroClass: "face",
|
|
7259
|
+
score: overlay.face.score,
|
|
7260
|
+
bbox: toDetectionBbox(overlay.face.bbox),
|
|
7261
|
+
parentId: det.id,
|
|
7262
|
+
labels: hasName ? [{
|
|
7263
|
+
label: name,
|
|
7264
|
+
score: overlay.face.score
|
|
7265
|
+
}] : []
|
|
7266
|
+
});
|
|
7267
|
+
if (overlay.plate !== void 0 && input.nowMs - overlay.plate.capturedAt <= 2500 && overlay.plate !== void 0 && realKinds?.has("plate") !== true) synthesized.push({
|
|
7268
|
+
id: `detail-${t.trackId}-plate`,
|
|
7269
|
+
kind: "detail",
|
|
7270
|
+
macroClass: "plate",
|
|
7271
|
+
score: overlay.plate.score,
|
|
7272
|
+
bbox: toDetectionBbox(overlay.plate.bbox),
|
|
7273
|
+
parentId: det.id,
|
|
7274
|
+
labels: [{
|
|
7275
|
+
label: overlay.plate.text,
|
|
7276
|
+
score: overlay.plate.score
|
|
7277
|
+
}]
|
|
7278
|
+
});
|
|
7279
|
+
}
|
|
7280
|
+
return [...out, ...synthesized];
|
|
7281
|
+
}
|
|
7282
|
+
//#endregion
|
|
7283
|
+
//#region src/pipeline-analytics/media-capture-log.ts
|
|
7284
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 6e4;
|
|
7285
|
+
var MediaCaptureLogAggregator = class {
|
|
7286
|
+
flushIntervalMs;
|
|
7287
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
7288
|
+
constructor(flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS) {
|
|
7289
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
7290
|
+
}
|
|
7291
|
+
/**
|
|
7292
|
+
* Record one frame's capture counts. Returns the window aggregate (and
|
|
7293
|
+
* resets the window) once `flushIntervalMs` has elapsed since the window
|
|
7294
|
+
* opened; `null` while still accumulating.
|
|
7295
|
+
*/
|
|
7296
|
+
note(deviceId, counts, nowMs) {
|
|
7297
|
+
const w = this.byDevice.get(deviceId);
|
|
7298
|
+
if (w === void 0) {
|
|
7299
|
+
this.byDevice.set(deviceId, {
|
|
7300
|
+
sinceMs: nowMs,
|
|
7301
|
+
frames: 1,
|
|
7302
|
+
events: counts.events,
|
|
7303
|
+
trackFrames: counts.trackFrames,
|
|
7304
|
+
snapshots: counts.snapshots,
|
|
7305
|
+
faceCrops: counts.faceCrops,
|
|
7306
|
+
plateCrops: counts.plateCrops
|
|
7307
|
+
});
|
|
7308
|
+
return null;
|
|
7309
|
+
}
|
|
7310
|
+
w.frames += 1;
|
|
7311
|
+
w.events += counts.events;
|
|
7312
|
+
w.trackFrames += counts.trackFrames;
|
|
7313
|
+
w.snapshots += counts.snapshots;
|
|
7314
|
+
w.faceCrops += counts.faceCrops;
|
|
7315
|
+
w.plateCrops += counts.plateCrops;
|
|
7316
|
+
if (nowMs - w.sinceMs < this.flushIntervalMs) return null;
|
|
7317
|
+
return this.drain(deviceId, w, nowMs);
|
|
7318
|
+
}
|
|
7319
|
+
/**
|
|
7320
|
+
* Drain a device's pending window (e.g. when its last live track ends),
|
|
7321
|
+
* so short activity bursts still surface an aggregate. Returns `null`
|
|
7322
|
+
* when nothing is pending.
|
|
7323
|
+
*/
|
|
7324
|
+
flush(deviceId, nowMs) {
|
|
7325
|
+
const w = this.byDevice.get(deviceId);
|
|
7326
|
+
if (w === void 0) return null;
|
|
7327
|
+
return this.drain(deviceId, w, nowMs);
|
|
7328
|
+
}
|
|
7329
|
+
drain(deviceId, w, nowMs) {
|
|
7330
|
+
this.byDevice.delete(deviceId);
|
|
7331
|
+
return {
|
|
7332
|
+
frames: w.frames,
|
|
7333
|
+
windowMs: nowMs - w.sinceMs,
|
|
7334
|
+
sums: {
|
|
7335
|
+
events: w.events,
|
|
7336
|
+
trackFrames: w.trackFrames,
|
|
7337
|
+
snapshots: w.snapshots,
|
|
7338
|
+
faceCrops: w.faceCrops,
|
|
7339
|
+
plateCrops: w.plateCrops
|
|
7340
|
+
}
|
|
7341
|
+
};
|
|
7342
|
+
}
|
|
7343
|
+
};
|
|
7344
|
+
//#endregion
|
|
6143
7345
|
//#region src/pipeline-analytics/store/plate-store.ts
|
|
6144
7346
|
var PLATES_COLLECTION = "pipeline-analytics:plates";
|
|
6145
7347
|
var PLATE_COLUMNS = [
|
|
@@ -6182,6 +7384,27 @@ var PLATE_COLUMNS = [
|
|
|
6182
7384
|
name: "corrected",
|
|
6183
7385
|
type: "BOOLEAN",
|
|
6184
7386
|
notNull: true
|
|
7387
|
+
},
|
|
7388
|
+
{
|
|
7389
|
+
name: "recognizedVehicleId",
|
|
7390
|
+
type: "TEXT"
|
|
7391
|
+
},
|
|
7392
|
+
{
|
|
7393
|
+
name: "assigned",
|
|
7394
|
+
type: "BOOLEAN",
|
|
7395
|
+
notNull: true
|
|
7396
|
+
},
|
|
7397
|
+
{
|
|
7398
|
+
name: "assignedSampleId",
|
|
7399
|
+
type: "TEXT"
|
|
7400
|
+
},
|
|
7401
|
+
{
|
|
7402
|
+
name: "keyFrameMediaKey",
|
|
7403
|
+
type: "TEXT"
|
|
7404
|
+
},
|
|
7405
|
+
{
|
|
7406
|
+
name: "plateBbox",
|
|
7407
|
+
type: "JSON"
|
|
6185
7408
|
}
|
|
6186
7409
|
];
|
|
6187
7410
|
var PLATE_INDEXES = [{
|
|
@@ -6231,7 +7454,12 @@ var PlateStore = class {
|
|
|
6231
7454
|
...data,
|
|
6232
7455
|
score: Number(r.data.score ?? 0),
|
|
6233
7456
|
corrected: Boolean(r.data.corrected),
|
|
6234
|
-
|
|
7457
|
+
assigned: Boolean(r.data.assigned),
|
|
7458
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
7459
|
+
recognizedVehicleId: data.recognizedVehicleId ?? void 0,
|
|
7460
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
7461
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
7462
|
+
plateBbox: data.plateBbox ?? void 0
|
|
6235
7463
|
};
|
|
6236
7464
|
}
|
|
6237
7465
|
/** Recent plate reads for one device (or all when deviceId omitted), newest first. */
|
|
@@ -6295,63 +7523,443 @@ var PlateStore = class {
|
|
|
6295
7523
|
collection: PLATES_COLLECTION,
|
|
6296
7524
|
key: plateId
|
|
6297
7525
|
});
|
|
6298
|
-
} catch (err) {
|
|
6299
|
-
this.logger.warn("PlateStore.delete failed", { meta: {
|
|
6300
|
-
plateId,
|
|
6301
|
-
error: String(err)
|
|
6302
|
-
} });
|
|
7526
|
+
} catch (err) {
|
|
7527
|
+
this.logger.warn("PlateStore.delete failed", { meta: {
|
|
7528
|
+
plateId,
|
|
7529
|
+
error: String(err)
|
|
7530
|
+
} });
|
|
7531
|
+
}
|
|
7532
|
+
}
|
|
7533
|
+
/** Flip `assigned` to true and record the vehicle + enrolled sample. */
|
|
7534
|
+
async markAssigned(plateId, vehicleId, sampleId) {
|
|
7535
|
+
const data = {
|
|
7536
|
+
assigned: true,
|
|
7537
|
+
recognizedVehicleId: vehicleId
|
|
7538
|
+
};
|
|
7539
|
+
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
7540
|
+
try {
|
|
7541
|
+
await this.store.update.mutate({
|
|
7542
|
+
collection: PLATES_COLLECTION,
|
|
7543
|
+
id: plateId,
|
|
7544
|
+
data
|
|
7545
|
+
});
|
|
7546
|
+
} catch (err) {
|
|
7547
|
+
this.logger.warn("PlateStore.markAssigned failed", { meta: {
|
|
7548
|
+
plateId,
|
|
7549
|
+
error: String(err)
|
|
7550
|
+
} });
|
|
7551
|
+
}
|
|
7552
|
+
}
|
|
7553
|
+
/** Clear the assignment (assigned=false, null recognizedVehicleId + assignedSampleId). */
|
|
7554
|
+
async clearAssignment(plateId) {
|
|
7555
|
+
try {
|
|
7556
|
+
await this.store.update.mutate({
|
|
7557
|
+
collection: PLATES_COLLECTION,
|
|
7558
|
+
id: plateId,
|
|
7559
|
+
data: {
|
|
7560
|
+
assigned: false,
|
|
7561
|
+
recognizedVehicleId: null,
|
|
7562
|
+
assignedSampleId: null
|
|
7563
|
+
}
|
|
7564
|
+
});
|
|
7565
|
+
} catch (err) {
|
|
7566
|
+
this.logger.warn("PlateStore.clearAssignment failed", { meta: {
|
|
7567
|
+
plateId,
|
|
7568
|
+
error: String(err)
|
|
7569
|
+
} });
|
|
7570
|
+
}
|
|
7571
|
+
}
|
|
7572
|
+
/**
|
|
7573
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
7574
|
+
* plate rows of the given tracks that are NOT assigned. Mirrors
|
|
7575
|
+
* `FaceStore.deleteByTracks` — ENROLLED (`assigned`) reads SURVIVE retention.
|
|
7576
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never aborts
|
|
7577
|
+
* the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
7578
|
+
*/
|
|
7579
|
+
async deleteByTracks(trackIds) {
|
|
7580
|
+
for (const trackId of trackIds) try {
|
|
7581
|
+
const eligible = (await this.store.query.query({
|
|
7582
|
+
collection: PLATES_COLLECTION,
|
|
7583
|
+
filter: { where: { trackId } }
|
|
7584
|
+
})).filter((r) => !r.data.assigned);
|
|
7585
|
+
for (const row of eligible) try {
|
|
7586
|
+
await this.store.delete.mutate({
|
|
7587
|
+
collection: PLATES_COLLECTION,
|
|
7588
|
+
key: row.id
|
|
7589
|
+
});
|
|
7590
|
+
} catch (err) {
|
|
7591
|
+
this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
|
|
7592
|
+
plateId: row.id,
|
|
7593
|
+
error: String(err)
|
|
7594
|
+
} });
|
|
7595
|
+
}
|
|
7596
|
+
} catch (err) {
|
|
7597
|
+
this.logger.warn("PlateStore.deleteByTracks query failed", { meta: {
|
|
7598
|
+
trackId,
|
|
7599
|
+
error: String(err)
|
|
7600
|
+
} });
|
|
7601
|
+
}
|
|
7602
|
+
}
|
|
7603
|
+
/**
|
|
7604
|
+
* Retention across ALL devices: delete reads older than `cutoffMs` AND any
|
|
7605
|
+
* beyond the newest `maxPerDevice` per device. Corrected reads are exempt
|
|
7606
|
+
* (human-confirmed). Returns deleted ids so the caller removes their crops.
|
|
7607
|
+
*/
|
|
7608
|
+
async pruneAll(input) {
|
|
7609
|
+
const rows = await this.store.query.query({
|
|
7610
|
+
collection: PLATES_COLLECTION,
|
|
7611
|
+
filter: { orderBy: {
|
|
7612
|
+
field: "timestamp",
|
|
7613
|
+
direction: "desc"
|
|
7614
|
+
} }
|
|
7615
|
+
});
|
|
7616
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
7617
|
+
for (const r of rows) {
|
|
7618
|
+
const data = r.data;
|
|
7619
|
+
if (data.corrected || data.assigned) continue;
|
|
7620
|
+
const deviceId = Number(data.deviceId ?? -1);
|
|
7621
|
+
const list = byDevice.get(deviceId) ?? [];
|
|
7622
|
+
list.push({
|
|
7623
|
+
id: r.id,
|
|
7624
|
+
timestamp: Number(data.timestamp ?? 0)
|
|
7625
|
+
});
|
|
7626
|
+
byDevice.set(deviceId, list);
|
|
7627
|
+
}
|
|
7628
|
+
const toDelete = /* @__PURE__ */ new Set();
|
|
7629
|
+
for (const list of byDevice.values()) {
|
|
7630
|
+
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
7631
|
+
list.forEach((row, idx) => {
|
|
7632
|
+
if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
|
|
7633
|
+
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
7634
|
+
});
|
|
7635
|
+
}
|
|
7636
|
+
const deleted = [];
|
|
7637
|
+
for (const id of toDelete) try {
|
|
7638
|
+
await this.store.delete.mutate({
|
|
7639
|
+
collection: PLATES_COLLECTION,
|
|
7640
|
+
key: id
|
|
7641
|
+
});
|
|
7642
|
+
deleted.push(id);
|
|
7643
|
+
} catch (err) {
|
|
7644
|
+
this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
|
|
7645
|
+
plateId: id,
|
|
7646
|
+
error: String(err)
|
|
7647
|
+
} });
|
|
7648
|
+
}
|
|
7649
|
+
return deleted;
|
|
7650
|
+
}
|
|
7651
|
+
};
|
|
7652
|
+
//#endregion
|
|
7653
|
+
//#region src/pipeline-analytics/store/vehicle-store.ts
|
|
7654
|
+
/**
|
|
7655
|
+
* VehicleStore — per-vehicle identity registry for license-plate recognition.
|
|
7656
|
+
*
|
|
7657
|
+
* Two SQL-backed collections (parallel to IdentityStore, text-not-embedding):
|
|
7658
|
+
* - pipeline-analytics:vehicles — named vehicle records
|
|
7659
|
+
* - pipeline-analytics:vehicle-samples — enrolled plate reads (text + score)
|
|
7660
|
+
*
|
|
7661
|
+
* A plate is self-labeling (the OCR text IS the key), so a sample carries
|
|
7662
|
+
* `text` + `score` instead of an embedding + modelId + dim — no model-version
|
|
7663
|
+
* gate is needed. deleteVehicle cascades: removes all sample rows first.
|
|
7664
|
+
*/
|
|
7665
|
+
var VEHICLES_COLLECTION = "pipeline-analytics:vehicles";
|
|
7666
|
+
var VEHICLE_SAMPLES_COLLECTION = "pipeline-analytics:vehicle-samples";
|
|
7667
|
+
var VEHICLE_COLUMNS = [
|
|
7668
|
+
{
|
|
7669
|
+
name: "id",
|
|
7670
|
+
type: "TEXT",
|
|
7671
|
+
primaryKey: true,
|
|
7672
|
+
notNull: true
|
|
7673
|
+
},
|
|
7674
|
+
{
|
|
7675
|
+
name: "name",
|
|
7676
|
+
type: "TEXT",
|
|
7677
|
+
notNull: true
|
|
7678
|
+
},
|
|
7679
|
+
{
|
|
7680
|
+
name: "createdAt",
|
|
7681
|
+
type: "INTEGER",
|
|
7682
|
+
notNull: true
|
|
7683
|
+
},
|
|
7684
|
+
{
|
|
7685
|
+
name: "updatedAt",
|
|
7686
|
+
type: "INTEGER",
|
|
7687
|
+
notNull: true
|
|
7688
|
+
},
|
|
7689
|
+
{
|
|
7690
|
+
name: "sampleCount",
|
|
7691
|
+
type: "INTEGER",
|
|
7692
|
+
notNull: true
|
|
7693
|
+
},
|
|
7694
|
+
{
|
|
7695
|
+
name: "coverMediaKey",
|
|
7696
|
+
type: "TEXT"
|
|
7697
|
+
}
|
|
7698
|
+
];
|
|
7699
|
+
var SAMPLE_COLUMNS = [
|
|
7700
|
+
{
|
|
7701
|
+
name: "id",
|
|
7702
|
+
type: "TEXT",
|
|
7703
|
+
primaryKey: true,
|
|
7704
|
+
notNull: true
|
|
7705
|
+
},
|
|
7706
|
+
{
|
|
7707
|
+
name: "vehicleId",
|
|
7708
|
+
type: "TEXT",
|
|
7709
|
+
notNull: true
|
|
7710
|
+
},
|
|
7711
|
+
{
|
|
7712
|
+
name: "text",
|
|
7713
|
+
type: "TEXT",
|
|
7714
|
+
notNull: true
|
|
7715
|
+
},
|
|
7716
|
+
{
|
|
7717
|
+
name: "score",
|
|
7718
|
+
type: "REAL",
|
|
7719
|
+
notNull: true
|
|
7720
|
+
},
|
|
7721
|
+
{
|
|
7722
|
+
name: "sourcePlateId",
|
|
7723
|
+
type: "TEXT"
|
|
7724
|
+
},
|
|
7725
|
+
{
|
|
7726
|
+
name: "mediaKey",
|
|
7727
|
+
type: "TEXT"
|
|
7728
|
+
},
|
|
7729
|
+
{
|
|
7730
|
+
name: "deviceId",
|
|
7731
|
+
type: "INTEGER"
|
|
7732
|
+
},
|
|
7733
|
+
{
|
|
7734
|
+
name: "addedAt",
|
|
7735
|
+
type: "INTEGER",
|
|
7736
|
+
notNull: true
|
|
7737
|
+
}
|
|
7738
|
+
];
|
|
7739
|
+
var VehicleStore = class {
|
|
7740
|
+
store;
|
|
7741
|
+
logger;
|
|
7742
|
+
constructor(deps) {
|
|
7743
|
+
this.store = deps.store;
|
|
7744
|
+
this.logger = deps.logger;
|
|
7745
|
+
}
|
|
7746
|
+
static async declare(store) {
|
|
7747
|
+
await store.declareCollection.mutate({
|
|
7748
|
+
collection: VEHICLES_COLLECTION,
|
|
7749
|
+
columns: [...VEHICLE_COLUMNS],
|
|
7750
|
+
indexes: []
|
|
7751
|
+
});
|
|
7752
|
+
await store.declareCollection.mutate({
|
|
7753
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7754
|
+
columns: [...SAMPLE_COLUMNS],
|
|
7755
|
+
indexes: [{
|
|
7756
|
+
name: "idx_vehicle_sample",
|
|
7757
|
+
columns: ["vehicleId"]
|
|
7758
|
+
}]
|
|
7759
|
+
});
|
|
7760
|
+
}
|
|
7761
|
+
async createVehicle(input) {
|
|
7762
|
+
const now = Date.now();
|
|
7763
|
+
const vehicle = {
|
|
7764
|
+
id: (0, node_crypto.randomUUID)(),
|
|
7765
|
+
name: input.name,
|
|
7766
|
+
createdAt: now,
|
|
7767
|
+
updatedAt: now,
|
|
7768
|
+
sampleCount: 0
|
|
7769
|
+
};
|
|
7770
|
+
const { id, ...rest } = vehicle;
|
|
7771
|
+
await this.store.insert.mutate({
|
|
7772
|
+
collection: VEHICLES_COLLECTION,
|
|
7773
|
+
record: {
|
|
7774
|
+
id,
|
|
7775
|
+
data: rest
|
|
7776
|
+
}
|
|
7777
|
+
});
|
|
7778
|
+
return vehicle;
|
|
7779
|
+
}
|
|
7780
|
+
async renameVehicle(id, name) {
|
|
7781
|
+
await this.store.update.mutate({
|
|
7782
|
+
collection: VEHICLES_COLLECTION,
|
|
7783
|
+
id,
|
|
7784
|
+
data: {
|
|
7785
|
+
name,
|
|
7786
|
+
updatedAt: Date.now()
|
|
7787
|
+
}
|
|
7788
|
+
});
|
|
7789
|
+
}
|
|
7790
|
+
async deleteVehicle(id) {
|
|
7791
|
+
const samples = await this.store.query.query({
|
|
7792
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7793
|
+
filter: { where: { vehicleId: id } }
|
|
7794
|
+
});
|
|
7795
|
+
for (const s of samples) try {
|
|
7796
|
+
await this.store.delete.mutate({
|
|
7797
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7798
|
+
key: s.id
|
|
7799
|
+
});
|
|
7800
|
+
} catch (err) {
|
|
7801
|
+
this.logger.warn("deleteVehicle sample delete failed", { meta: {
|
|
7802
|
+
vehicleId: id,
|
|
7803
|
+
sampleId: s.id,
|
|
7804
|
+
error: String(err)
|
|
7805
|
+
} });
|
|
7806
|
+
}
|
|
7807
|
+
await this.store.delete.mutate({
|
|
7808
|
+
collection: VEHICLES_COLLECTION,
|
|
7809
|
+
key: id
|
|
7810
|
+
});
|
|
7811
|
+
}
|
|
7812
|
+
async listVehicles() {
|
|
7813
|
+
return (await this.store.query.query({
|
|
7814
|
+
collection: VEHICLES_COLLECTION,
|
|
7815
|
+
filter: { orderBy: {
|
|
7816
|
+
field: "createdAt",
|
|
7817
|
+
direction: "asc"
|
|
7818
|
+
} }
|
|
7819
|
+
})).map((r) => {
|
|
7820
|
+
const data = r.data;
|
|
7821
|
+
return {
|
|
7822
|
+
id: r.id,
|
|
7823
|
+
...data,
|
|
7824
|
+
coverMediaKey: data.coverMediaKey ?? void 0
|
|
7825
|
+
};
|
|
7826
|
+
});
|
|
7827
|
+
}
|
|
7828
|
+
async addSample(input) {
|
|
7829
|
+
const sample = {
|
|
7830
|
+
id: (0, node_crypto.randomUUID)(),
|
|
7831
|
+
vehicleId: input.vehicleId,
|
|
7832
|
+
text: input.text,
|
|
7833
|
+
score: input.score,
|
|
7834
|
+
...input.sourcePlateId !== void 0 ? { sourcePlateId: input.sourcePlateId } : {},
|
|
7835
|
+
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
|
|
7836
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
7837
|
+
addedAt: Date.now()
|
|
7838
|
+
};
|
|
7839
|
+
const { id, ...rest } = sample;
|
|
7840
|
+
await this.store.insert.mutate({
|
|
7841
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7842
|
+
record: {
|
|
7843
|
+
id,
|
|
7844
|
+
data: rest
|
|
7845
|
+
}
|
|
7846
|
+
});
|
|
7847
|
+
const vehicle = (await this.listVehicles()).find((v) => v.id === input.vehicleId);
|
|
7848
|
+
if (vehicle) {
|
|
7849
|
+
const patch = {
|
|
7850
|
+
sampleCount: vehicle.sampleCount + 1,
|
|
7851
|
+
updatedAt: Date.now()
|
|
7852
|
+
};
|
|
7853
|
+
if (vehicle.coverMediaKey == null && input.mediaKey !== void 0) patch["coverMediaKey"] = input.mediaKey;
|
|
7854
|
+
await this.store.update.mutate({
|
|
7855
|
+
collection: VEHICLES_COLLECTION,
|
|
7856
|
+
id: input.vehicleId,
|
|
7857
|
+
data: patch
|
|
7858
|
+
});
|
|
6303
7859
|
}
|
|
7860
|
+
return sample;
|
|
6304
7861
|
}
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
7862
|
+
async listSamples(vehicleId) {
|
|
7863
|
+
return (await this.store.query.query({
|
|
7864
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7865
|
+
filter: {
|
|
7866
|
+
where: { vehicleId },
|
|
7867
|
+
orderBy: {
|
|
7868
|
+
field: "addedAt",
|
|
7869
|
+
direction: "asc"
|
|
7870
|
+
}
|
|
7871
|
+
}
|
|
7872
|
+
})).map((r) => {
|
|
7873
|
+
const d = r.data;
|
|
7874
|
+
return {
|
|
7875
|
+
id: r.id,
|
|
7876
|
+
text: String(d.text ?? ""),
|
|
7877
|
+
score: Number(d.score ?? 0),
|
|
7878
|
+
addedAt: Number(d.addedAt ?? 0),
|
|
7879
|
+
...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
|
|
7880
|
+
...d.deviceId != null ? { deviceId: d.deviceId } : {}
|
|
7881
|
+
};
|
|
7882
|
+
});
|
|
7883
|
+
}
|
|
7884
|
+
async removeSample(vehicleId, sampleId) {
|
|
7885
|
+
await this.store.delete.mutate({
|
|
7886
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7887
|
+
key: sampleId
|
|
7888
|
+
});
|
|
7889
|
+
const vehicle = (await this.listVehicles()).find((v) => v.id === vehicleId);
|
|
7890
|
+
if (vehicle) await this.store.update.mutate({
|
|
7891
|
+
collection: VEHICLES_COLLECTION,
|
|
7892
|
+
id: vehicleId,
|
|
7893
|
+
data: {
|
|
7894
|
+
sampleCount: Math.max(0, vehicle.sampleCount - 1),
|
|
7895
|
+
updatedAt: Date.now()
|
|
7896
|
+
}
|
|
7897
|
+
});
|
|
7898
|
+
}
|
|
7899
|
+
async loadGallery() {
|
|
6311
7900
|
const rows = await this.store.query.query({
|
|
6312
|
-
collection:
|
|
6313
|
-
filter: {
|
|
6314
|
-
field: "timestamp",
|
|
6315
|
-
direction: "desc"
|
|
6316
|
-
} }
|
|
7901
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7902
|
+
filter: {}
|
|
6317
7903
|
});
|
|
6318
|
-
const
|
|
7904
|
+
const gallery = [];
|
|
6319
7905
|
for (const r of rows) {
|
|
6320
|
-
const
|
|
6321
|
-
if (
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
id: r.id,
|
|
6326
|
-
timestamp: Number(data.timestamp ?? 0)
|
|
6327
|
-
});
|
|
6328
|
-
byDevice.set(deviceId, list);
|
|
6329
|
-
}
|
|
6330
|
-
const toDelete = /* @__PURE__ */ new Set();
|
|
6331
|
-
for (const list of byDevice.values()) {
|
|
6332
|
-
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
6333
|
-
list.forEach((row, idx) => {
|
|
6334
|
-
if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
|
|
6335
|
-
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
6336
|
-
});
|
|
6337
|
-
}
|
|
6338
|
-
const deleted = [];
|
|
6339
|
-
for (const id of toDelete) try {
|
|
6340
|
-
await this.store.delete.mutate({
|
|
6341
|
-
collection: PLATES_COLLECTION,
|
|
6342
|
-
key: id
|
|
7906
|
+
const d = r.data;
|
|
7907
|
+
if (typeof d.text !== "string" || typeof d.vehicleId !== "string") continue;
|
|
7908
|
+
gallery.push({
|
|
7909
|
+
vehicleId: d.vehicleId,
|
|
7910
|
+
text: d.text
|
|
6343
7911
|
});
|
|
6344
|
-
deleted.push(id);
|
|
6345
|
-
} catch (err) {
|
|
6346
|
-
this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
|
|
6347
|
-
plateId: id,
|
|
6348
|
-
error: String(err)
|
|
6349
|
-
} });
|
|
6350
7912
|
}
|
|
6351
|
-
return
|
|
7913
|
+
return gallery;
|
|
6352
7914
|
}
|
|
6353
7915
|
};
|
|
6354
7916
|
//#endregion
|
|
7917
|
+
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
7918
|
+
/**
|
|
7919
|
+
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
7920
|
+
*
|
|
7921
|
+
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
7922
|
+
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
7923
|
+
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
7924
|
+
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
7925
|
+
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
7926
|
+
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
7927
|
+
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
7928
|
+
* "Prefer unassigned over wrong vehicle."
|
|
7929
|
+
*/
|
|
7930
|
+
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
7931
|
+
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
7932
|
+
maxDistance: 1,
|
|
7933
|
+
lowScoreFloor: .5,
|
|
7934
|
+
lowScoreMaxDistance: 0
|
|
7935
|
+
};
|
|
7936
|
+
function matchPlateText(probe, gallery, opts) {
|
|
7937
|
+
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
7938
|
+
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
7939
|
+
for (const s of gallery) {
|
|
7940
|
+
const dist = plateDistance(probe.text, s.text);
|
|
7941
|
+
const prev = bestByVehicle.get(s.vehicleId);
|
|
7942
|
+
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
7943
|
+
}
|
|
7944
|
+
if (bestByVehicle.size === 0) return null;
|
|
7945
|
+
let bestId = null;
|
|
7946
|
+
let bestDist = Infinity;
|
|
7947
|
+
let tie = false;
|
|
7948
|
+
for (const [vehicleId, dist] of bestByVehicle) {
|
|
7949
|
+
if (dist > allowed) continue;
|
|
7950
|
+
if (dist < bestDist) {
|
|
7951
|
+
bestDist = dist;
|
|
7952
|
+
bestId = vehicleId;
|
|
7953
|
+
tie = false;
|
|
7954
|
+
} else if (dist === bestDist) tie = true;
|
|
7955
|
+
}
|
|
7956
|
+
if (bestId === null || tie) return null;
|
|
7957
|
+
return {
|
|
7958
|
+
vehicleId: bestId,
|
|
7959
|
+
distance: bestDist
|
|
7960
|
+
};
|
|
7961
|
+
}
|
|
7962
|
+
//#endregion
|
|
6355
7963
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
6356
7964
|
/**
|
|
6357
7965
|
* PlateRecognizer — buffers the best license-plate OCR read per vehicle track
|
|
@@ -6363,8 +7971,38 @@ var PlateStore = class {
|
|
|
6363
7971
|
var PlateRecognizer = class {
|
|
6364
7972
|
deps;
|
|
6365
7973
|
bestPlate = /* @__PURE__ */ new Map();
|
|
7974
|
+
gallery = [];
|
|
7975
|
+
nameById = /* @__PURE__ */ new Map();
|
|
7976
|
+
matchOpts;
|
|
6366
7977
|
constructor(deps) {
|
|
6367
7978
|
this.deps = deps;
|
|
7979
|
+
this.matchOpts = deps.matchOpts ?? DEFAULT_PLATE_MATCH_OPTS;
|
|
7980
|
+
}
|
|
7981
|
+
/** Reload the in-memory vehicle gallery + name map (after a gallery mutation). */
|
|
7982
|
+
async refreshGallery() {
|
|
7983
|
+
try {
|
|
7984
|
+
this.gallery = await this.deps.vehicleStore.loadGallery();
|
|
7985
|
+
this.nameById = new Map((await this.deps.vehicleStore.listVehicles()).map((v) => [v.id, v.name]));
|
|
7986
|
+
} catch (err) {
|
|
7987
|
+
this.deps.logger.warn("PlateRecognizer.refreshGallery failed", { meta: { error: String(err) } });
|
|
7988
|
+
}
|
|
7989
|
+
}
|
|
7990
|
+
matchVehicle(text, score) {
|
|
7991
|
+
const m = matchPlateText({
|
|
7992
|
+
text: normalizePlate(text),
|
|
7993
|
+
score
|
|
7994
|
+
}, this.gallery, this.matchOpts);
|
|
7995
|
+
if (m === null) return null;
|
|
7996
|
+
const name = this.nameById.get(m.vehicleId);
|
|
7997
|
+
return name !== void 0 ? {
|
|
7998
|
+
vehicleId: m.vehicleId,
|
|
7999
|
+
name
|
|
8000
|
+
} : null;
|
|
8001
|
+
}
|
|
8002
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched, else
|
|
8003
|
+
* the raw OCR text (today's behavior). Used by the ingest label path. */
|
|
8004
|
+
resolveLabel(text, score) {
|
|
8005
|
+
return this.matchVehicle(text, score)?.name ?? text;
|
|
6368
8006
|
}
|
|
6369
8007
|
async processFrame(input) {
|
|
6370
8008
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -6420,6 +8058,8 @@ var PlateRecognizer = class {
|
|
|
6420
8058
|
}
|
|
6421
8059
|
});
|
|
6422
8060
|
}
|
|
8061
|
+
const match = this.matchVehicle(held.text, held.score);
|
|
8062
|
+
const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
|
|
6423
8063
|
try {
|
|
6424
8064
|
await this.deps.plateStore.insert({
|
|
6425
8065
|
id: plateId,
|
|
@@ -6429,7 +8069,16 @@ var PlateRecognizer = class {
|
|
|
6429
8069
|
text: normalizePlate(held.text),
|
|
6430
8070
|
score: held.score,
|
|
6431
8071
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
6432
|
-
corrected: false
|
|
8072
|
+
corrected: false,
|
|
8073
|
+
assigned: false,
|
|
8074
|
+
...match !== null ? { recognizedVehicleId: match.vehicleId } : {},
|
|
8075
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
8076
|
+
plateBbox: held.bbox
|
|
8077
|
+
});
|
|
8078
|
+
this.deps.emitPlateGalleryChanged?.({
|
|
8079
|
+
deviceId,
|
|
8080
|
+
plateId,
|
|
8081
|
+
kind: "buffered"
|
|
6433
8082
|
});
|
|
6434
8083
|
this.deps.logger.info("plate: buffered to gallery", {
|
|
6435
8084
|
tags: {
|
|
@@ -6440,7 +8089,8 @@ var PlateRecognizer = class {
|
|
|
6440
8089
|
plateId,
|
|
6441
8090
|
text: normalizePlate(held.text),
|
|
6442
8091
|
score: held.score,
|
|
6443
|
-
hasCrop: mediaKey !== void 0
|
|
8092
|
+
hasCrop: mediaKey !== void 0,
|
|
8093
|
+
recognizedVehicleId: match?.vehicleId ?? null
|
|
6444
8094
|
}
|
|
6445
8095
|
});
|
|
6446
8096
|
} catch (err) {
|
|
@@ -6817,8 +8467,11 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
|
|
|
6817
8467
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
6818
8468
|
* narrowing the old raw read applied inline. */
|
|
6819
8469
|
var PostProcessingNodeIdSchema = require_dist.string().min(1);
|
|
8470
|
+
var EmbeddingEnabledSchema = require_dist.boolean();
|
|
6820
8471
|
var SILENCE_FLOOR_DBFS = -55;
|
|
6821
8472
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
8473
|
+
/** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
|
|
8474
|
+
var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
6822
8475
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
6823
8476
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
6824
8477
|
/**
|
|
@@ -6878,12 +8531,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6878
8531
|
faceStore = null;
|
|
6879
8532
|
faceRecognizer = null;
|
|
6880
8533
|
plateStore = null;
|
|
8534
|
+
vehicleStore = null;
|
|
6881
8535
|
plateRecognizer = null;
|
|
6882
8536
|
objectEmbeddingStore = null;
|
|
6883
8537
|
/** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
|
|
6884
8538
|
* classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
|
|
6885
8539
|
* the per-frame child consumption the executor no longer emits. */
|
|
6886
8540
|
detailDispatcher = null;
|
|
8541
|
+
/** Per-track detail geometry for the live-overlay synthesis (two-plane). */
|
|
8542
|
+
overlayState = new OverlayDetailStateStore();
|
|
8543
|
+
/** Throttle state for the "overlay synthesis failed" warn, one per device. */
|
|
8544
|
+
overlaySynthesisWarnAt = /* @__PURE__ */ new Map();
|
|
6887
8545
|
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
6888
8546
|
* Stamped on object-embedding rows from the detail plane so semantic search's
|
|
6889
8547
|
* same-model gate keeps matching. */
|
|
@@ -6895,6 +8553,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6895
8553
|
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
6896
8554
|
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
6897
8555
|
frameReaders = null;
|
|
8556
|
+
/** Object/face embedding dispatcher — migrated from the retired
|
|
8557
|
+
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8558
|
+
* detection it resolves the frame, crops the ROI, and calls the
|
|
8559
|
+
* embedding-encoder cap (which persists the vector for the search/face
|
|
8560
|
+
* path). Null on non-post-processing nodes or when disabled. */
|
|
8561
|
+
embeddingDispatcher = null;
|
|
6898
8562
|
bindingCache = null;
|
|
6899
8563
|
zoneAnalytics = null;
|
|
6900
8564
|
audioMetrics = null;
|
|
@@ -6945,6 +8609,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6945
8609
|
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6946
8610
|
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6947
8611
|
});
|
|
8612
|
+
/** Windowed per-device aggregation of the "media capture" diagnostic — the
|
|
8613
|
+
* per-capture line is debug; a 60s per-counter SUM lands at info (~1
|
|
8614
|
+
* line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
|
|
8615
|
+
mediaCaptureLog = new MediaCaptureLogAggregator();
|
|
6948
8616
|
/** Best (highest-confidence) CLIP-object detection per track — drives ONE
|
|
6949
8617
|
* tight object-crop capture whose media key is written onto the embedding
|
|
6950
8618
|
* row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
|
|
@@ -6956,6 +8624,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6956
8624
|
* at track end so a face row links to the SAME single key frame. Cleared on
|
|
6957
8625
|
* track end. */
|
|
6958
8626
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
8627
|
+
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8628
|
+
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8629
|
+
* area) + emit time, so a material improvement is measured against the
|
|
8630
|
+
* last emit and debounced. Seeded at `start`, dropped at `end`. */
|
|
8631
|
+
trackLifecycleUpdateMem = /* @__PURE__ */ new Map();
|
|
6959
8632
|
/** The shared crop extractor (native-res first, detection-frame fallback),
|
|
6960
8633
|
* captured in the constructor so `processFrame` can crop object thumbnails in
|
|
6961
8634
|
* the same live-frame window as the face/plate/event-media captures. The
|
|
@@ -6971,6 +8644,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6971
8644
|
if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
|
|
6972
8645
|
return this._postProcessingNodeState;
|
|
6973
8646
|
}
|
|
8647
|
+
/** Master toggle for the migrated object/face EmbeddingDispatcher (default
|
|
8648
|
+
* ON — the enrichment-engine default was `embeddingEnabled:true`). Read at
|
|
8649
|
+
* init; a change takes effect on addon restart (same convention as
|
|
8650
|
+
* `postProcessingNodeId`). */
|
|
8651
|
+
_embeddingEnabledState = null;
|
|
8652
|
+
get embeddingEnabledState() {
|
|
8653
|
+
if (!this._embeddingEnabledState) this._embeddingEnabledState = this.state("embeddingEnabled", EmbeddingEnabledSchema, true);
|
|
8654
|
+
return this._embeddingEnabledState;
|
|
8655
|
+
}
|
|
6974
8656
|
/** GLOBAL face-recognition master switch (`enabled` key). Schema +
|
|
6975
8657
|
* fallback derived from the face-settings source of truth. */
|
|
6976
8658
|
_faceGlobalEnabledState = null;
|
|
@@ -6990,12 +8672,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6990
8672
|
await IdentityStore.declare(api.settingsStore);
|
|
6991
8673
|
await FaceStore.declare(api.settingsStore);
|
|
6992
8674
|
await PlateStore.declare(api.settingsStore);
|
|
8675
|
+
await VehicleStore.declare(api.settingsStore);
|
|
6993
8676
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
6994
8677
|
const logger = this.ctx.logger;
|
|
6995
8678
|
let storage = this.ctx.kernel.storage;
|
|
6996
8679
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
6997
8680
|
if (mediaRoot) {
|
|
6998
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
8681
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
|
|
6999
8682
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
7000
8683
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
7001
8684
|
}
|
|
@@ -7011,7 +8694,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7011
8694
|
});
|
|
7012
8695
|
this.eventStore = new EventStore({
|
|
7013
8696
|
store: api.settingsStore,
|
|
7014
|
-
logger: logger.child("EventStore")
|
|
8697
|
+
logger: logger.child("EventStore"),
|
|
8698
|
+
media: this.mediaStore
|
|
7015
8699
|
});
|
|
7016
8700
|
this.identityStore = new IdentityStore({
|
|
7017
8701
|
store: api.settingsStore,
|
|
@@ -7025,6 +8709,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7025
8709
|
store: api.settingsStore,
|
|
7026
8710
|
logger: logger.child("PlateStore")
|
|
7027
8711
|
});
|
|
8712
|
+
this.vehicleStore = new VehicleStore({
|
|
8713
|
+
store: api.settingsStore,
|
|
8714
|
+
logger: logger.child("VehicleStore")
|
|
8715
|
+
});
|
|
7028
8716
|
const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
7029
8717
|
const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
|
|
7030
8718
|
{
|
|
@@ -7093,7 +8781,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7093
8781
|
return null;
|
|
7094
8782
|
}
|
|
7095
8783
|
};
|
|
7096
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) =>
|
|
8784
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
7097
8785
|
ownNodeId: ownNodeIdForFaces,
|
|
7098
8786
|
readers: frameReadersForFaces,
|
|
7099
8787
|
getRemoteFrame
|
|
@@ -7113,7 +8801,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7113
8801
|
bumpCropMetric(false);
|
|
7114
8802
|
const decoded = await resolveFrameShared(frameHandle);
|
|
7115
8803
|
if (!decoded || decoded.format !== "rgb") return null;
|
|
7116
|
-
const { crop } = await
|
|
8804
|
+
const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
|
|
7117
8805
|
return crop;
|
|
7118
8806
|
};
|
|
7119
8807
|
this.captureCrop = captureCrop;
|
|
@@ -7134,15 +8822,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7134
8822
|
}, trackId);
|
|
7135
8823
|
},
|
|
7136
8824
|
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8825
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
|
|
7137
8826
|
logger: logger.child("FaceRecognizer")
|
|
7138
8827
|
});
|
|
7139
8828
|
this.faceRecognizer.refreshGallery();
|
|
7140
8829
|
this.plateRecognizer = new PlateRecognizer({
|
|
7141
8830
|
plateStore: this.plateStore,
|
|
8831
|
+
vehicleStore: this.vehicleStore,
|
|
7142
8832
|
mediaStore: this.mediaStore,
|
|
7143
8833
|
captureCrop,
|
|
8834
|
+
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8835
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
|
|
7144
8836
|
logger: logger.child("PlateRecognizer")
|
|
7145
8837
|
});
|
|
8838
|
+
this.plateRecognizer.refreshGallery();
|
|
7146
8839
|
this.objectEmbeddingStore = new ObjectEmbeddingStore({
|
|
7147
8840
|
store: api.settingsStore,
|
|
7148
8841
|
logger: logger.child("ObjectEmbeddingStore")
|
|
@@ -7160,6 +8853,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7160
8853
|
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
7161
8854
|
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
7162
8855
|
return buf ? buf.toString("base64") : null;
|
|
8856
|
+
},
|
|
8857
|
+
hasTrackLabel: (trackId) => {
|
|
8858
|
+
const label = this.trackStore?.getActiveByTrack(trackId)?.label;
|
|
8859
|
+
return label !== void 0 && label.length > 0;
|
|
7163
8860
|
}
|
|
7164
8861
|
});
|
|
7165
8862
|
this.bindingCache = new BindingCache({
|
|
@@ -7227,12 +8924,37 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7227
8924
|
const data = ev.data;
|
|
7228
8925
|
this.handleNativeDetection(data);
|
|
7229
8926
|
});
|
|
8927
|
+
if (await this.embeddingEnabledState.get()) {
|
|
8928
|
+
const encoderClient = {
|
|
8929
|
+
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
8930
|
+
crop: new Uint8Array(crop),
|
|
8931
|
+
width,
|
|
8932
|
+
height
|
|
8933
|
+
}),
|
|
8934
|
+
getInfo: () => this.ctx.api.embeddingEncoder.getInfo.query()
|
|
8935
|
+
};
|
|
8936
|
+
this.embeddingDispatcher = new EmbeddingDispatcher({
|
|
8937
|
+
config: {
|
|
8938
|
+
...DEFAULT_EMBEDDING_CONFIG,
|
|
8939
|
+
enabled: true
|
|
8940
|
+
},
|
|
8941
|
+
encoder: encoderClient,
|
|
8942
|
+
eventBus: this.ctx.eventBus,
|
|
8943
|
+
logger: logger.child("EmbeddingDispatcher"),
|
|
8944
|
+
ownNodeId,
|
|
8945
|
+
readers: frameReadersForFaces,
|
|
8946
|
+
getRemoteFrame
|
|
8947
|
+
});
|
|
8948
|
+
await this.embeddingDispatcher.start();
|
|
8949
|
+
} else logger.info("EmbeddingDispatcher disabled via settings (embeddingEnabled=false)");
|
|
7230
8950
|
}
|
|
7231
8951
|
this.unsubBindings = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (ev) => {
|
|
7232
8952
|
const data = ev.data;
|
|
7233
8953
|
this.bindingCache?.onBindingsChanged(data);
|
|
7234
8954
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
7235
8955
|
this.trackStore?.clearDevice(data.deviceId);
|
|
8956
|
+
this.overlayState.clearDevice(data.deviceId);
|
|
8957
|
+
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
7236
8958
|
this.forgetDeviceProcessors(data.deviceId);
|
|
7237
8959
|
this.levelStateByDevice.delete(data.deviceId);
|
|
7238
8960
|
this.settingsCacheByDevice.delete(data.deviceId);
|
|
@@ -7245,6 +8967,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7245
8967
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
7246
8968
|
const { deviceId } = ev.data;
|
|
7247
8969
|
this.trackStore?.clearDevice(deviceId);
|
|
8970
|
+
this.overlayState.clearDevice(deviceId);
|
|
8971
|
+
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
7248
8972
|
this.forgetDeviceProcessors(deviceId);
|
|
7249
8973
|
this.levelStateByDevice.delete(deviceId);
|
|
7250
8974
|
this.settingsCacheByDevice.delete(deviceId);
|
|
@@ -7262,6 +8986,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7262
8986
|
}, TTL_SWEEP_INTERVAL_MS);
|
|
7263
8987
|
this.retentionSweepTimer = setInterval(() => {
|
|
7264
8988
|
this.sweepRetention();
|
|
8989
|
+
this.runTrackRetentionSweep();
|
|
7265
8990
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
7266
8991
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
7267
8992
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -7502,11 +9227,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7502
9227
|
mediaStore: this.mediaStore,
|
|
7503
9228
|
trackStore: this.trackStore,
|
|
7504
9229
|
eventStore: this.eventStore,
|
|
7505
|
-
refreshGallery: () => this.faceRecognizer?.refreshGallery()
|
|
9230
|
+
refreshGallery: () => this.faceRecognizer?.refreshGallery(),
|
|
9231
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
|
|
7506
9232
|
});
|
|
7507
9233
|
const plateGallery = new PlateGalleryProvider({
|
|
7508
9234
|
plateStore: this.plateStore,
|
|
7509
|
-
|
|
9235
|
+
vehicleStore: this.vehicleStore,
|
|
9236
|
+
mediaStore: this.mediaStore,
|
|
9237
|
+
trackStore: this.trackStore,
|
|
9238
|
+
eventStore: this.eventStore,
|
|
9239
|
+
refreshGallery: () => {
|
|
9240
|
+
this.plateRecognizer?.refreshGallery();
|
|
9241
|
+
},
|
|
9242
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
|
|
7510
9243
|
});
|
|
7511
9244
|
return [
|
|
7512
9245
|
{
|
|
@@ -7555,6 +9288,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7555
9288
|
this.unsubBindings = null;
|
|
7556
9289
|
this.unsubDeviceUnreg?.();
|
|
7557
9290
|
this.unsubDeviceUnreg = null;
|
|
9291
|
+
await this.embeddingDispatcher?.stop();
|
|
9292
|
+
this.embeddingDispatcher = null;
|
|
7558
9293
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
7559
9294
|
if (this.ttlSweepTimer) {
|
|
7560
9295
|
clearInterval(this.ttlSweepTimer);
|
|
@@ -7568,10 +9303,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7568
9303
|
this.audioMetrics?.destroy();
|
|
7569
9304
|
this.detailDispatcher?.dispose();
|
|
7570
9305
|
this.detailDispatcher = null;
|
|
9306
|
+
this.overlayState.clear();
|
|
9307
|
+
this.overlaySynthesisWarnAt.clear();
|
|
7571
9308
|
this.processors.clear();
|
|
7572
9309
|
this.lastActiveTrackIds.clear();
|
|
7573
9310
|
this.dropoutSkipsByKey.clear();
|
|
7574
9311
|
this.bestFrameTracker.clear();
|
|
9312
|
+
this.trackLifecycleUpdateMem.clear();
|
|
7575
9313
|
this.objectEmbeddingBestSelector.clear();
|
|
7576
9314
|
this.levelStateByDevice.clear();
|
|
7577
9315
|
this.settingsCacheByDevice.clear();
|
|
@@ -7703,6 +9441,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7703
9441
|
className: t.className
|
|
7704
9442
|
}
|
|
7705
9443
|
});
|
|
9444
|
+
const startPayload = buildTrackLifecyclePayload({
|
|
9445
|
+
deviceId,
|
|
9446
|
+
trackId: id,
|
|
9447
|
+
phase: "start",
|
|
9448
|
+
classes: [t.className],
|
|
9449
|
+
bestClassName: t.className,
|
|
9450
|
+
bestConfidence: t.confidence,
|
|
9451
|
+
firstSeen: result.timestamp,
|
|
9452
|
+
lastSeen: result.timestamp,
|
|
9453
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
9454
|
+
});
|
|
9455
|
+
this.emitTrackLifecycle(startPayload, result.timestamp);
|
|
9456
|
+
this.trackLifecycleUpdateMem.set(id, {
|
|
9457
|
+
lastConfidence: t.confidence,
|
|
9458
|
+
lastEmitAt: result.timestamp,
|
|
9459
|
+
...t.label !== void 0 ? { lastLabel: t.label } : {},
|
|
9460
|
+
lastBboxArea: t.bbox.w * t.bbox.h
|
|
9461
|
+
});
|
|
7706
9462
|
}
|
|
7707
9463
|
}
|
|
7708
9464
|
let lostTrackCount = 0;
|
|
@@ -7766,7 +9522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7766
9522
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
7767
9523
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
7768
9524
|
const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
|
|
7769
|
-
const eventTargets = result.objectEvents.filter((e) => e.bbox).map((e) => {
|
|
9525
|
+
const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
|
|
7770
9526
|
const childCrops = childCropsByEvent.get(e.id);
|
|
7771
9527
|
return {
|
|
7772
9528
|
eventId: e.id,
|
|
@@ -7782,15 +9538,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7782
9538
|
let plateCrops = 0;
|
|
7783
9539
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
7784
9540
|
else plateCrops += 1;
|
|
7785
|
-
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
9541
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
7786
9542
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
7787
|
-
|
|
7788
|
-
source,
|
|
9543
|
+
const captureCounts = {
|
|
7789
9544
|
events: eventTargets.length,
|
|
7790
9545
|
trackFrames: firstFrameTargets.length,
|
|
7791
9546
|
snapshots: snapshotTargets.length,
|
|
7792
9547
|
faceCrops,
|
|
7793
9548
|
plateCrops
|
|
9549
|
+
};
|
|
9550
|
+
log.debug("media capture", { meta: {
|
|
9551
|
+
source,
|
|
9552
|
+
...captureCounts
|
|
9553
|
+
} });
|
|
9554
|
+
const captureAgg = this.mediaCaptureLog.note(deviceId, captureCounts, Date.now());
|
|
9555
|
+
if (captureAgg !== null) log.info("media capture (window)", { meta: {
|
|
9556
|
+
source,
|
|
9557
|
+
windowSec: Math.round(captureAgg.windowMs / 1e3),
|
|
9558
|
+
frames: captureAgg.frames,
|
|
9559
|
+
...captureAgg.sums
|
|
7794
9560
|
} });
|
|
7795
9561
|
this.eventMediaDispatcher.captureForFrame({
|
|
7796
9562
|
deviceId,
|
|
@@ -7853,6 +9619,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7853
9619
|
zones: e.zones ?? []
|
|
7854
9620
|
}
|
|
7855
9621
|
});
|
|
9622
|
+
let overlayDetections;
|
|
9623
|
+
try {
|
|
9624
|
+
overlayDetections = buildOverlayDetections({
|
|
9625
|
+
frameDetections: frame.detections,
|
|
9626
|
+
tracked: result.tracked,
|
|
9627
|
+
overlayFor: (trackId) => this.overlayState.get(deviceId, trackId),
|
|
9628
|
+
labelFor: (trackId) => this.trackStore?.getActiveByTrack(trackId)?.label,
|
|
9629
|
+
nowMs: Date.now()
|
|
9630
|
+
});
|
|
9631
|
+
} catch (err) {
|
|
9632
|
+
const now = Date.now();
|
|
9633
|
+
if (now - (this.overlaySynthesisWarnAt.get(deviceId) ?? 0) >= OVERLAY_SYNTHESIS_WARN_THROTTLE_MS) {
|
|
9634
|
+
this.overlaySynthesisWarnAt.set(deviceId, now);
|
|
9635
|
+
this.ctx.logger.warn("overlay synthesis failed — emitting unenriched frame detections", {
|
|
9636
|
+
tags: { deviceId },
|
|
9637
|
+
meta: { error: String(err) }
|
|
9638
|
+
});
|
|
9639
|
+
}
|
|
9640
|
+
overlayDetections = frame.detections;
|
|
9641
|
+
}
|
|
7856
9642
|
this.ctx.eventBus.emit({
|
|
7857
9643
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
7858
9644
|
timestamp: new Date(result.timestamp),
|
|
@@ -7867,7 +9653,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7867
9653
|
timestamp: result.timestamp,
|
|
7868
9654
|
frameWidth: result.frameWidth,
|
|
7869
9655
|
frameHeight: result.frameHeight,
|
|
7870
|
-
detections:
|
|
9656
|
+
detections: overlayDetections
|
|
7871
9657
|
}
|
|
7872
9658
|
});
|
|
7873
9659
|
}
|
|
@@ -7957,9 +9743,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7957
9743
|
*/
|
|
7958
9744
|
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
7959
9745
|
for (const d of details) try {
|
|
7960
|
-
|
|
9746
|
+
const isFaceDetail = d.className === "face";
|
|
9747
|
+
if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
|
|
9748
|
+
x: d.bbox.x,
|
|
9749
|
+
y: d.bbox.y,
|
|
9750
|
+
w: d.bbox.w,
|
|
9751
|
+
h: d.bbox.h
|
|
9752
|
+
}, d.score, frame.timestamp);
|
|
9753
|
+
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
7961
9754
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
7962
|
-
else if (d.label !== void 0 && d.label.length > 0)
|
|
9755
|
+
else if (d.label !== void 0 && d.label.length > 0) {
|
|
9756
|
+
if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
9757
|
+
x: d.bbox.x,
|
|
9758
|
+
y: d.bbox.y,
|
|
9759
|
+
w: d.bbox.w,
|
|
9760
|
+
h: d.bbox.h
|
|
9761
|
+
}, d.score, d.label, frame.timestamp);
|
|
9762
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
|
|
9763
|
+
await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9764
|
+
}
|
|
7963
9765
|
} catch (err) {
|
|
7964
9766
|
this.ctx.logger.warn("detail result route failed", {
|
|
7965
9767
|
tags: { deviceId },
|
|
@@ -8167,12 +9969,67 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8167
9969
|
});
|
|
8168
9970
|
}));
|
|
8169
9971
|
}
|
|
8170
|
-
|
|
9972
|
+
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9973
|
+
emitTrackLifecycle(payload, timestamp) {
|
|
9974
|
+
this.ctx.eventBus.emit({
|
|
9975
|
+
id: `pa-life-${(0, node_crypto.randomUUID)()}`,
|
|
9976
|
+
timestamp: new Date(timestamp),
|
|
9977
|
+
source: {
|
|
9978
|
+
type: "addon",
|
|
9979
|
+
id: "pipeline-analytics",
|
|
9980
|
+
addonId: "pipeline-analytics"
|
|
9981
|
+
},
|
|
9982
|
+
category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle,
|
|
9983
|
+
data: payload
|
|
9984
|
+
});
|
|
9985
|
+
}
|
|
9986
|
+
/**
|
|
9987
|
+
* Fire a `phase:'update'` lifecycle event when this frame's observation is
|
|
9988
|
+
* a MATERIAL improvement over the last emit (confidence delta, a
|
|
9989
|
+
* newly-resolved identity/plate label, or a materially larger crop),
|
|
9990
|
+
* debounced per `DEFAULT_UPDATE_GATE_CONFIG`. Reuses the already-computed
|
|
9991
|
+
* `isNewBest`; the gate is pure and the per-track memory is advanced in
|
|
9992
|
+
* place. Carries the CURRENT best incl. the improved key-frame media key.
|
|
9993
|
+
*/
|
|
9994
|
+
maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest) {
|
|
9995
|
+
const memory = this.trackLifecycleUpdateMem.get(t.trackId);
|
|
9996
|
+
const decision = evaluateTrackLifecycleUpdate({
|
|
9997
|
+
isNewBest,
|
|
9998
|
+
confidence: t.confidence,
|
|
9999
|
+
now: timestamp,
|
|
10000
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
10001
|
+
bboxArea: t.bbox.w * t.bbox.h
|
|
10002
|
+
}, memory, DEFAULT_UPDATE_GATE_CONFIG);
|
|
10003
|
+
this.trackLifecycleUpdateMem.set(t.trackId, decision.memory);
|
|
10004
|
+
if (!decision.emit) return;
|
|
10005
|
+
const track = this.trackStore?.getActiveByTrack(t.trackId);
|
|
10006
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
10007
|
+
const payload = buildTrackLifecyclePayload({
|
|
10008
|
+
deviceId,
|
|
10009
|
+
trackId: t.trackId,
|
|
10010
|
+
phase: "update",
|
|
10011
|
+
classes: track?.classes ?? [t.className],
|
|
10012
|
+
bestClassName: t.className,
|
|
10013
|
+
bestConfidence: t.confidence,
|
|
10014
|
+
firstSeen: track?.firstSeen ?? timestamp,
|
|
10015
|
+
lastSeen: track?.lastSeen ?? timestamp,
|
|
10016
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
10017
|
+
...t.plateText !== void 0 ? { plateText: t.plateText } : {},
|
|
10018
|
+
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10019
|
+
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10020
|
+
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
10021
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10022
|
+
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10023
|
+
});
|
|
10024
|
+
this.emitTrackLifecycle(payload, timestamp);
|
|
10025
|
+
}
|
|
10026
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
8171
10027
|
const targets = [];
|
|
8172
10028
|
for (const t of tracked) {
|
|
8173
10029
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
8174
10030
|
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
8175
10031
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10032
|
+
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
8176
10033
|
if (!dueSnapshot && !isNewBest) continue;
|
|
8177
10034
|
targets.push({
|
|
8178
10035
|
trackId: t.trackId,
|
|
@@ -8438,6 +10295,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8438
10295
|
positions: t.positions.length
|
|
8439
10296
|
}
|
|
8440
10297
|
});
|
|
10298
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
8441
10299
|
const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
8442
10300
|
const dropKeyFrameKey = () => {
|
|
8443
10301
|
this.keyFrameKeyByTrackId.delete(t.trackId);
|
|
@@ -8445,11 +10303,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8445
10303
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
8446
10304
|
else dropKeyFrameKey();
|
|
8447
10305
|
this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
10306
|
+
const trackerPeak = this.bestFrameTracker.peak(t.trackId);
|
|
10307
|
+
let endImportance;
|
|
10308
|
+
let endImportanceReason;
|
|
10309
|
+
let endBestEventId;
|
|
8448
10310
|
try {
|
|
8449
10311
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
8450
10312
|
if (peak) {
|
|
10313
|
+
endBestEventId = peak.bestEventId;
|
|
8451
10314
|
const { importance, reason } = computeImportance({
|
|
8452
|
-
peakConfidence:
|
|
10315
|
+
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
8453
10316
|
className: t.className,
|
|
8454
10317
|
durationMs: duration,
|
|
8455
10318
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
@@ -8457,6 +10320,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8457
10320
|
zonesVisited: t.zonesVisited,
|
|
8458
10321
|
...t.label !== void 0 ? { label: t.label } : {}
|
|
8459
10322
|
});
|
|
10323
|
+
endImportance = importance;
|
|
10324
|
+
endImportanceReason = reason;
|
|
8460
10325
|
await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
|
|
8461
10326
|
if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
|
|
8462
10327
|
}
|
|
@@ -8469,6 +10334,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8469
10334
|
this.bestFrameTracker.delete(t.trackId);
|
|
8470
10335
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
8471
10336
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10337
|
+
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10338
|
+
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
10339
|
+
const captureAgg = this.mediaCaptureLog.flush(t.deviceId, Date.now());
|
|
10340
|
+
if (captureAgg !== null) this.ctx.logger.info("media capture (window)", {
|
|
10341
|
+
tags: { deviceId: t.deviceId },
|
|
10342
|
+
meta: {
|
|
10343
|
+
windowSec: Math.round(captureAgg.windowMs / 1e3),
|
|
10344
|
+
frames: captureAgg.frames,
|
|
10345
|
+
...captureAgg.sums
|
|
10346
|
+
}
|
|
10347
|
+
});
|
|
10348
|
+
}
|
|
8472
10349
|
this.ctx.eventBus.emit({
|
|
8473
10350
|
id: `pa-end-${t.trackId}`,
|
|
8474
10351
|
timestamp: new Date(t.lastSeen),
|
|
@@ -8485,6 +10362,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8485
10362
|
durationMs: duration
|
|
8486
10363
|
}
|
|
8487
10364
|
});
|
|
10365
|
+
const endPayload = buildTrackLifecyclePayload({
|
|
10366
|
+
deviceId: t.deviceId,
|
|
10367
|
+
trackId: t.trackId,
|
|
10368
|
+
phase: "end",
|
|
10369
|
+
classes: t.classes ?? [t.className],
|
|
10370
|
+
bestClassName: t.className,
|
|
10371
|
+
bestConfidence: trackerPeak?.confidence ?? 0,
|
|
10372
|
+
firstSeen: t.firstSeen,
|
|
10373
|
+
lastSeen: t.lastSeen,
|
|
10374
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
10375
|
+
zonesVisited: t.zonesVisited,
|
|
10376
|
+
totalDistance: t.totalDistance,
|
|
10377
|
+
positionsCount: t.positions.length,
|
|
10378
|
+
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10379
|
+
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
10380
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10381
|
+
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10382
|
+
});
|
|
10383
|
+
this.emitTrackLifecycle(endPayload, t.lastSeen);
|
|
10384
|
+
this.trackLifecycleUpdateMem.delete(t.trackId);
|
|
8488
10385
|
}
|
|
8489
10386
|
} catch (err) {
|
|
8490
10387
|
if (this.shuttingDown) return;
|
|
@@ -8576,6 +10473,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8576
10473
|
if (!frame) return;
|
|
8577
10474
|
await this.processFrame(payload.cameraId, frame, "onboard");
|
|
8578
10475
|
}
|
|
10476
|
+
/**
|
|
10477
|
+
* Best-effort bus notification for a gallery face-row change (buffer /
|
|
10478
|
+
* assign / unassign / delete). Passed into the `FaceRecognizer` and
|
|
10479
|
+
* `FaceGalleryProvider` as their `emitFaceGalleryChanged` dep. Telemetry
|
|
10480
|
+
* (D8): a lost/failed emit only delays the admin-ui live-refresh — never
|
|
10481
|
+
* allowed to fail the caller's mutation.
|
|
10482
|
+
*/
|
|
10483
|
+
emitFaceGalleryChanged(payload) {
|
|
10484
|
+
try {
|
|
10485
|
+
this.ctx.eventBus.emit({
|
|
10486
|
+
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
10487
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10488
|
+
source: {
|
|
10489
|
+
type: "addon",
|
|
10490
|
+
id: "pipeline-analytics",
|
|
10491
|
+
addonId: "pipeline-analytics"
|
|
10492
|
+
},
|
|
10493
|
+
category: require_dist.EventCategory.PipelineAnalyticsFaceGalleryChanged,
|
|
10494
|
+
data: payload
|
|
10495
|
+
});
|
|
10496
|
+
} catch (err) {
|
|
10497
|
+
this.ctx.logger.debug("face-gallery-changed emit failed", { meta: {
|
|
10498
|
+
deviceId: payload.deviceId,
|
|
10499
|
+
faceId: payload.faceId,
|
|
10500
|
+
error: String(err)
|
|
10501
|
+
} });
|
|
10502
|
+
}
|
|
10503
|
+
}
|
|
10504
|
+
/**
|
|
10505
|
+
* Best-effort bus notification for a gallery plate-row change (buffer /
|
|
10506
|
+
* assign / unassign / delete). Passed into the `PlateRecognizer` and
|
|
10507
|
+
* `PlateGalleryProvider` as their `emitPlateGalleryChanged` dep. Telemetry
|
|
10508
|
+
* (D8): a lost/failed emit only delays the admin-ui live-refresh — never
|
|
10509
|
+
* allowed to fail the caller's mutation.
|
|
10510
|
+
*/
|
|
10511
|
+
emitPlateGalleryChanged(payload) {
|
|
10512
|
+
try {
|
|
10513
|
+
this.ctx.eventBus.emit({
|
|
10514
|
+
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
10515
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10516
|
+
source: {
|
|
10517
|
+
type: "addon",
|
|
10518
|
+
id: "pipeline-analytics",
|
|
10519
|
+
addonId: "pipeline-analytics"
|
|
10520
|
+
},
|
|
10521
|
+
category: require_dist.EventCategory.PipelineAnalyticsPlateGalleryChanged,
|
|
10522
|
+
data: payload
|
|
10523
|
+
});
|
|
10524
|
+
} catch (err) {
|
|
10525
|
+
this.ctx.logger.debug("plate-gallery-changed emit failed", { meta: {
|
|
10526
|
+
deviceId: payload.deviceId,
|
|
10527
|
+
plateId: payload.plateId,
|
|
10528
|
+
error: String(err)
|
|
10529
|
+
} });
|
|
10530
|
+
}
|
|
10531
|
+
}
|
|
8579
10532
|
/** Composite key for the per-(device, source) processor + track maps. */
|
|
8580
10533
|
procKey(deviceId, source) {
|
|
8581
10534
|
return `${deviceId}:${source}`;
|
|
@@ -8675,6 +10628,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8675
10628
|
}
|
|
8676
10629
|
async clearTracks(input) {
|
|
8677
10630
|
this.trackStore?.clearDevice(input.deviceId);
|
|
10631
|
+
this.overlayState.clearDevice(input.deviceId);
|
|
10632
|
+
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
8678
10633
|
const prefix = `${input.deviceId}:`;
|
|
8679
10634
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
8680
10635
|
}
|
|
@@ -8851,6 +10806,168 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8851
10806
|
return counts;
|
|
8852
10807
|
}
|
|
8853
10808
|
/**
|
|
10809
|
+
* The widened, extensible track-deletion cascade registry (design §3): every
|
|
10810
|
+
* leaf store that holds track-scoped rows, ORDERED leaves-first (the track
|
|
10811
|
+
* root is deleted internally, last, by `cascadeDeleteTracks`). eventStore
|
|
10812
|
+
* folds in its event-owned media; mediaStore removes track/face/plate crops
|
|
10813
|
+
* (never identity); faceStore removes unassigned (non-enrolled) reads;
|
|
10814
|
+
* objectEmbeddingStore removes the per-track CLIP vector. The plate leg is
|
|
10815
|
+
* deferred until the plate `assigned` enrollment model lands (design §11.3).
|
|
10816
|
+
* Returns null when the required stores aren't ready.
|
|
10817
|
+
*/
|
|
10818
|
+
buildTrackCascadeRegistry() {
|
|
10819
|
+
const eventStore = this.eventStore;
|
|
10820
|
+
const mediaStore = this.mediaStore;
|
|
10821
|
+
const trackStore = this.trackStore;
|
|
10822
|
+
if (!eventStore || !mediaStore || !trackStore) return null;
|
|
10823
|
+
const registry = [eventStore, mediaStore];
|
|
10824
|
+
if (this.faceStore) registry.push(this.faceStore);
|
|
10825
|
+
if (this.plateStore) registry.push(this.plateStore);
|
|
10826
|
+
if (this.objectEmbeddingStore) registry.push(this.objectEmbeddingStore);
|
|
10827
|
+
return {
|
|
10828
|
+
registry,
|
|
10829
|
+
trackStore
|
|
10830
|
+
};
|
|
10831
|
+
}
|
|
10832
|
+
/**
|
|
10833
|
+
* Clear the addon-level per-track in-memory scheduler/overlay state that a
|
|
10834
|
+
* natural track-end (see `sweepExpiredTracks`) clears — otherwise a pruned
|
|
10835
|
+
* track keeps drawing overlay boxes and the dispatcher keeps scheduling detail
|
|
10836
|
+
* work until it ends on its own. Fired per successfully-cascaded track by all
|
|
10837
|
+
* three retention entry points (design §5).
|
|
10838
|
+
*/
|
|
10839
|
+
clearLiveTrackState(deviceId, trackId) {
|
|
10840
|
+
this.detailDispatcher?.onTrackEnded(deviceId, trackId);
|
|
10841
|
+
this.overlayState.onTrackEnded(deviceId, trackId);
|
|
10842
|
+
}
|
|
10843
|
+
async deleteTracks(input) {
|
|
10844
|
+
const cascade = this.buildTrackCascadeRegistry();
|
|
10845
|
+
if (!cascade) return {
|
|
10846
|
+
deleted: 0,
|
|
10847
|
+
failed: [...input.trackIds]
|
|
10848
|
+
};
|
|
10849
|
+
const { deleted, failed } = await runTrackCascadeBatch({
|
|
10850
|
+
registry: cascade.registry,
|
|
10851
|
+
trackStore: cascade.trackStore,
|
|
10852
|
+
deviceId: input.deviceId,
|
|
10853
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
10854
|
+
onFailure: (trackId, err) => {
|
|
10855
|
+
this.ctx.logger.debug("deleteTracks failed", { meta: {
|
|
10856
|
+
trackId,
|
|
10857
|
+
error: String(err)
|
|
10858
|
+
} });
|
|
10859
|
+
}
|
|
10860
|
+
}, input.trackIds);
|
|
10861
|
+
if (deleted > 0) this.ctx.logger.info("analytics tracks deleted", {
|
|
10862
|
+
tags: { deviceId: input.deviceId },
|
|
10863
|
+
meta: {
|
|
10864
|
+
deviceId: input.deviceId,
|
|
10865
|
+
deleted,
|
|
10866
|
+
failed: failed.length
|
|
10867
|
+
}
|
|
10868
|
+
});
|
|
10869
|
+
return {
|
|
10870
|
+
deleted,
|
|
10871
|
+
failed
|
|
10872
|
+
};
|
|
10873
|
+
}
|
|
10874
|
+
/**
|
|
10875
|
+
* Track-centric time-based retention (design §5.1). Drains every persisted
|
|
10876
|
+
* track for the device whose `lastSeen < cutoffMs`, page by page, through the
|
|
10877
|
+
* widened cascade — enrolled faces/plates + identity media are exempt (design
|
|
10878
|
+
* §4). `tracks` is the authoritative count; leaf families are best-effort 0
|
|
10879
|
+
* under the void `TrackScopedStore` contract (see cap doc).
|
|
10880
|
+
*/
|
|
10881
|
+
async pruneTracksBefore(input) {
|
|
10882
|
+
const empty = {
|
|
10883
|
+
tracks: 0,
|
|
10884
|
+
events: 0,
|
|
10885
|
+
media: 0,
|
|
10886
|
+
faces: 0,
|
|
10887
|
+
plates: 0,
|
|
10888
|
+
embeddings: 0
|
|
10889
|
+
};
|
|
10890
|
+
const cascade = this.buildTrackCascadeRegistry();
|
|
10891
|
+
if (!cascade) return empty;
|
|
10892
|
+
const PAGE = 200;
|
|
10893
|
+
let totalDeleted = 0;
|
|
10894
|
+
for (;;) {
|
|
10895
|
+
if (this.shuttingDown) break;
|
|
10896
|
+
const ids = await cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, PAGE);
|
|
10897
|
+
if (ids.length === 0) break;
|
|
10898
|
+
const { deleted } = await runTrackCascadeBatch({
|
|
10899
|
+
registry: cascade.registry,
|
|
10900
|
+
trackStore: cascade.trackStore,
|
|
10901
|
+
deviceId: input.deviceId,
|
|
10902
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
10903
|
+
onFailure: (trackId, err) => {
|
|
10904
|
+
this.ctx.logger.debug("pruneTracksBefore track failed", { meta: {
|
|
10905
|
+
trackId,
|
|
10906
|
+
error: String(err)
|
|
10907
|
+
} });
|
|
10908
|
+
}
|
|
10909
|
+
}, ids);
|
|
10910
|
+
totalDeleted += deleted;
|
|
10911
|
+
if (deleted === 0) break;
|
|
10912
|
+
}
|
|
10913
|
+
if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
|
|
10914
|
+
tags: { deviceId: input.deviceId },
|
|
10915
|
+
meta: {
|
|
10916
|
+
deviceId: input.deviceId,
|
|
10917
|
+
cutoffMs: input.cutoffMs,
|
|
10918
|
+
tracks: totalDeleted
|
|
10919
|
+
}
|
|
10920
|
+
});
|
|
10921
|
+
return {
|
|
10922
|
+
...empty,
|
|
10923
|
+
tracks: totalDeleted
|
|
10924
|
+
};
|
|
10925
|
+
}
|
|
10926
|
+
/**
|
|
10927
|
+
* Operator "clean slate" for a device (design §5.3): prune EVERY persisted
|
|
10928
|
+
* track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
|
|
10929
|
+
* Enrolled gallery + identity media are exempt.
|
|
10930
|
+
*/
|
|
10931
|
+
async wipeAllAnalytics(input) {
|
|
10932
|
+
return this.pruneTracksBefore({
|
|
10933
|
+
deviceId: input.deviceId,
|
|
10934
|
+
cutoffMs: Date.now()
|
|
10935
|
+
});
|
|
10936
|
+
}
|
|
10937
|
+
/**
|
|
10938
|
+
* Periodic per-device track retention sweep (design §6): for every device
|
|
10939
|
+
* that has persisted tracks, prune those older than its `trackRetentionDays`
|
|
10940
|
+
* window (default 7 days; 0 = keep forever → skipped). Runs on the shared
|
|
10941
|
+
* retention interval. Enrolled gallery is exempt by the cascade store layer.
|
|
10942
|
+
*/
|
|
10943
|
+
async runTrackRetentionSweep() {
|
|
10944
|
+
if (this.shuttingDown || !this.trackStore) return;
|
|
10945
|
+
const trackStore = this.trackStore;
|
|
10946
|
+
try {
|
|
10947
|
+
const total = await sweepTrackRetention({
|
|
10948
|
+
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
10949
|
+
resolveRetentionDays: async (deviceId) => {
|
|
10950
|
+
return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
10951
|
+
},
|
|
10952
|
+
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
10953
|
+
deviceId,
|
|
10954
|
+
cutoffMs
|
|
10955
|
+
}),
|
|
10956
|
+
now: () => Date.now(),
|
|
10957
|
+
onError: (deviceId, err) => {
|
|
10958
|
+
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
10959
|
+
deviceId,
|
|
10960
|
+
error: String(err)
|
|
10961
|
+
} });
|
|
10962
|
+
}
|
|
10963
|
+
});
|
|
10964
|
+
if (total > 0) this.ctx.logger.info("analytics track retention sweep", { meta: { tracks: total } });
|
|
10965
|
+
} catch (err) {
|
|
10966
|
+
if (this.shuttingDown) return;
|
|
10967
|
+
this.ctx.logger.debug("runTrackRetentionSweep failed", { meta: { error: String(err) } });
|
|
10968
|
+
}
|
|
10969
|
+
}
|
|
10970
|
+
/**
|
|
8854
10971
|
* Decode a stored event image into `EventMedia` for the data-plane handler.
|
|
8855
10972
|
* Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
|
|
8856
10973
|
* (native-res boxed frame) → any available file. Returns `null` if the
|
|
@@ -8911,6 +11028,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8911
11028
|
label: "Post-processing node",
|
|
8912
11029
|
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.",
|
|
8913
11030
|
default: POST_PROCESSING_NODE_DEFAULT
|
|
11031
|
+
}, {
|
|
11032
|
+
type: "boolean",
|
|
11033
|
+
key: "embeddingEnabled",
|
|
11034
|
+
label: "Object/face embeddings",
|
|
11035
|
+
description: "Compute object/face embeddings from detection crops (feeds similarity search + face recognition). Runs on the post-processing node. Takes effect on addon restart.",
|
|
11036
|
+
default: true
|
|
8914
11037
|
}]
|
|
8915
11038
|
},
|
|
8916
11039
|
{
|
|
@@ -9017,9 +11140,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9017
11140
|
{
|
|
9018
11141
|
id: "retention",
|
|
9019
11142
|
title: "Retention",
|
|
9020
|
-
description: "How long
|
|
11143
|
+
description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
|
|
9021
11144
|
columns: 3,
|
|
9022
11145
|
fields: [
|
|
11146
|
+
{
|
|
11147
|
+
type: "number",
|
|
11148
|
+
key: "trackRetentionDays",
|
|
11149
|
+
label: "Tracks",
|
|
11150
|
+
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.",
|
|
11151
|
+
min: 0,
|
|
11152
|
+
max: 365,
|
|
11153
|
+
step: 1,
|
|
11154
|
+
default: TRACK_RETENTION_DEFAULT_DAYS,
|
|
11155
|
+
unit: "days"
|
|
11156
|
+
},
|
|
9023
11157
|
{
|
|
9024
11158
|
type: "number",
|
|
9025
11159
|
key: "retentionMotionDays",
|