@camstack/addon-post-analysis 1.1.26 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-DytVmDZg.mjs → dist-Blpsv-M0.mjs} +1042 -56
- package/dist/{dist-BEx5ST1W.js → dist-Bnb58pyL.js} +1051 -89
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Cg_cGqs0.js → node-Cvhwrf43.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +107 -107
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-p-r6oXq8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZZpqsjV.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C6LbOCa9.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DBgBsiUM.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-2UZIpU0W.mjs → hostInit-D-KSUwyU.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +2845 -129
- package/dist/pipeline-analytics/index.mjs +2841 -125
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -19
- package/dist/enrichment-engine/index.js +0 -857
- package/dist/enrichment-engine/index.mjs +0 -852
- package/dist/resolve-frame-CT1T1tWy.mjs +0 -44
- package/dist/resolve-frame-Cbm_NFuq.js +0 -57
|
@@ -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,7 +1771,9 @@ 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();
|
|
1776
|
+
const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
|
|
1530
1777
|
const plateByBbox = /* @__PURE__ */ new Map();
|
|
1531
1778
|
const maskByBbox = /* @__PURE__ */ new Map();
|
|
1532
1779
|
const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
|
|
@@ -1553,6 +1800,7 @@ var FrameProcessor = class {
|
|
|
1553
1800
|
height: det.maskHeight
|
|
1554
1801
|
});
|
|
1555
1802
|
firstLevelBboxById.set(det.id, bbox);
|
|
1803
|
+
sourceIdByBbox.set(bbox, det.id);
|
|
1556
1804
|
return {
|
|
1557
1805
|
...bbox,
|
|
1558
1806
|
detection,
|
|
@@ -1569,6 +1817,7 @@ var FrameProcessor = class {
|
|
|
1569
1817
|
w: det.bbox.width,
|
|
1570
1818
|
h: det.bbox.height
|
|
1571
1819
|
});
|
|
1820
|
+
if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
|
|
1572
1821
|
if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
|
|
1573
1822
|
embedding: det.embedding,
|
|
1574
1823
|
...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
|
|
@@ -1611,9 +1860,12 @@ var FrameProcessor = class {
|
|
|
1611
1860
|
});
|
|
1612
1861
|
const emb = embeddingByBbox.get(td.bbox);
|
|
1613
1862
|
const faceBbox = faceBboxByBbox.get(td.bbox);
|
|
1863
|
+
const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
|
|
1614
1864
|
const plate = plateByBbox.get(td.bbox);
|
|
1865
|
+
const sourceDetectionId = sourceIdByBbox.get(td.bbox);
|
|
1615
1866
|
return {
|
|
1616
1867
|
trackId: td.trackId,
|
|
1868
|
+
...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
|
|
1617
1869
|
className: td.class,
|
|
1618
1870
|
confidence: td.score,
|
|
1619
1871
|
bbox: { ...td.bbox },
|
|
@@ -1625,6 +1877,7 @@ var FrameProcessor = class {
|
|
|
1625
1877
|
...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
|
|
1626
1878
|
} : {},
|
|
1627
1879
|
...faceBbox !== void 0 ? { faceBbox } : {},
|
|
1880
|
+
...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
|
|
1628
1881
|
...plate !== void 0 ? {
|
|
1629
1882
|
plateText: plate.text,
|
|
1630
1883
|
plateScore: plate.score,
|
|
@@ -1675,6 +1928,84 @@ var FrameProcessor = class {
|
|
|
1675
1928
|
};
|
|
1676
1929
|
}
|
|
1677
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
|
+
}
|
|
1678
2009
|
//#endregion
|
|
1679
2010
|
//#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
|
|
1680
2011
|
var BestDetectionTracker = class {
|
|
@@ -1879,6 +2210,50 @@ function nativeDetectionsToFrame(input) {
|
|
|
1879
2210
|
};
|
|
1880
2211
|
}
|
|
1881
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
|
|
1882
2257
|
//#region src/pipeline-analytics/runtime/binding-cache.ts
|
|
1883
2258
|
var BindingCache = class {
|
|
1884
2259
|
api;
|
|
@@ -1984,6 +2359,10 @@ var TRACKS_COLUMNS = [
|
|
|
1984
2359
|
name: "zonesVisited",
|
|
1985
2360
|
type: "JSON"
|
|
1986
2361
|
},
|
|
2362
|
+
{
|
|
2363
|
+
name: "classes",
|
|
2364
|
+
type: "JSON"
|
|
2365
|
+
},
|
|
1987
2366
|
{
|
|
1988
2367
|
name: "totalDistance",
|
|
1989
2368
|
type: "REAL"
|
|
@@ -2032,6 +2411,7 @@ function cloneTrack(t) {
|
|
|
2032
2411
|
}
|
|
2033
2412
|
})),
|
|
2034
2413
|
zonesVisited: [...t.zonesVisited],
|
|
2414
|
+
classes: [...t.classes],
|
|
2035
2415
|
totalDistance: t.totalDistance,
|
|
2036
2416
|
state: t.state,
|
|
2037
2417
|
active: t.active,
|
|
@@ -2076,6 +2456,7 @@ var TrackStore = class {
|
|
|
2076
2456
|
existing.positions.push(params.position);
|
|
2077
2457
|
} else existing.positions.push(params.position);
|
|
2078
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);
|
|
2079
2460
|
return existing;
|
|
2080
2461
|
}
|
|
2081
2462
|
const fresh = {
|
|
@@ -2088,6 +2469,7 @@ var TrackStore = class {
|
|
|
2088
2469
|
positions: [params.position],
|
|
2089
2470
|
snapshots: [],
|
|
2090
2471
|
zonesVisited: [...params.zones],
|
|
2472
|
+
classes: [params.className],
|
|
2091
2473
|
totalDistance: 0,
|
|
2092
2474
|
state: params.state,
|
|
2093
2475
|
active: true,
|
|
@@ -2230,6 +2612,92 @@ var TrackStore = class {
|
|
|
2230
2612
|
clearAll() {
|
|
2231
2613
|
this.active.clear();
|
|
2232
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
|
+
}
|
|
2233
2701
|
/** Historical query — hits the persisted collection. */
|
|
2234
2702
|
async queryHistorical(params) {
|
|
2235
2703
|
const filter = { where: { deviceId: params.deviceId } };
|
|
@@ -2271,6 +2739,7 @@ var TrackStore = class {
|
|
|
2271
2739
|
positions: [...t.positions],
|
|
2272
2740
|
snapshots: [...t.snapshots],
|
|
2273
2741
|
zonesVisited: [...t.zonesVisited],
|
|
2742
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
2274
2743
|
totalDistance: t.totalDistance,
|
|
2275
2744
|
state: t.state,
|
|
2276
2745
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
@@ -2283,6 +2752,7 @@ var TrackStore = class {
|
|
|
2283
2752
|
const positions = data["positions"] ?? [];
|
|
2284
2753
|
const snapshots = data["snapshots"] ?? [];
|
|
2285
2754
|
const zones = data["zonesVisited"] ?? [];
|
|
2755
|
+
const classes = data["classes"];
|
|
2286
2756
|
const label = data["label"];
|
|
2287
2757
|
const importance = data["importance"];
|
|
2288
2758
|
const bestEventId = data["bestEventId"];
|
|
@@ -2297,6 +2767,7 @@ var TrackStore = class {
|
|
|
2297
2767
|
positions,
|
|
2298
2768
|
snapshots,
|
|
2299
2769
|
zonesVisited: zones,
|
|
2770
|
+
...classes !== null ? { classes } : {},
|
|
2300
2771
|
totalDistance: Number(data["totalDistance"] ?? 0),
|
|
2301
2772
|
state: data["state"] ?? "idle",
|
|
2302
2773
|
active: false,
|
|
@@ -2309,6 +2780,18 @@ var TrackStore = class {
|
|
|
2309
2780
|
//#endregion
|
|
2310
2781
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
2311
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-";
|
|
2312
2795
|
var MEDIA_COLUMNS = [
|
|
2313
2796
|
{
|
|
2314
2797
|
name: "id",
|
|
@@ -2673,6 +3156,28 @@ var MediaStore = class {
|
|
|
2673
3156
|
async deleteForEvents(eventIds) {
|
|
2674
3157
|
return this.deleteForOwner("event", eventIds);
|
|
2675
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
|
+
}
|
|
2676
3181
|
/** Retention sweep: delete any media row + blob older than cutoff.
|
|
2677
3182
|
* Returns number of entries removed. */
|
|
2678
3183
|
async evictBefore(cutoffMs) {
|
|
@@ -2835,9 +3340,11 @@ var COMMON_INDEXES = (prefix) => [{
|
|
|
2835
3340
|
var EventStore = class {
|
|
2836
3341
|
store;
|
|
2837
3342
|
logger;
|
|
3343
|
+
media;
|
|
2838
3344
|
constructor(deps) {
|
|
2839
3345
|
this.store = deps.store;
|
|
2840
3346
|
this.logger = deps.logger;
|
|
3347
|
+
this.media = deps.media;
|
|
2841
3348
|
}
|
|
2842
3349
|
static async declare(store) {
|
|
2843
3350
|
await store.declareCollection.mutate({
|
|
@@ -3050,6 +3557,12 @@ var EventStore = class {
|
|
|
3050
3557
|
let bestEventId;
|
|
3051
3558
|
let peakBboxAreaFrac = 0;
|
|
3052
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
|
+
}
|
|
3053
3566
|
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
3054
3567
|
if (conf <= bestConf) continue;
|
|
3055
3568
|
bestConf = conf;
|
|
@@ -3239,6 +3752,72 @@ var EventStore = class {
|
|
|
3239
3752
|
]
|
|
3240
3753
|
};
|
|
3241
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
|
+
}
|
|
3242
3821
|
};
|
|
3243
3822
|
function slimMotion(id, data) {
|
|
3244
3823
|
return {
|
|
@@ -3305,6 +3884,18 @@ function stripNulls(data) {
|
|
|
3305
3884
|
return out;
|
|
3306
3885
|
}
|
|
3307
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
|
|
3308
3899
|
//#region src/shared/frame/square-safe-crop.ts
|
|
3309
3900
|
/**
|
|
3310
3901
|
* Compute a square-safe 16:9 crop region in pixel space.
|
|
@@ -3427,6 +4018,34 @@ function caption(className, confidence, label) {
|
|
|
3427
4018
|
return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
|
|
3428
4019
|
}
|
|
3429
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
|
+
/**
|
|
3430
4049
|
* Generates object-event + track media FROM the decoded detection-pipeline
|
|
3431
4050
|
* frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
|
|
3432
4051
|
* snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
|
|
@@ -3450,7 +4069,7 @@ var EventMediaDispatcher = class {
|
|
|
3450
4069
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
3451
4070
|
let decoded;
|
|
3452
4071
|
try {
|
|
3453
|
-
decoded = await
|
|
4072
|
+
decoded = await resolveFrame(frameHandle, {
|
|
3454
4073
|
ownNodeId: this.deps.ownNodeId,
|
|
3455
4074
|
readers: this.deps.readers,
|
|
3456
4075
|
getRemoteFrame: this.deps.getRemoteFrame
|
|
@@ -3486,9 +4105,21 @@ var EventMediaDispatcher = class {
|
|
|
3486
4105
|
});
|
|
3487
4106
|
return empty;
|
|
3488
4107
|
}
|
|
3489
|
-
const frameData = Buffer.
|
|
4108
|
+
const frameData = Buffer.from(decoded.data);
|
|
3490
4109
|
const fw = decoded.width;
|
|
3491
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
|
+
}
|
|
3492
4123
|
for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
|
|
3493
4124
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
3494
4125
|
const storedSnapshots = [];
|
|
@@ -3714,8 +4345,264 @@ var EventMediaDispatcher = class {
|
|
|
3714
4345
|
}
|
|
3715
4346
|
};
|
|
3716
4347
|
//#endregion
|
|
3717
|
-
//#region src/
|
|
3718
|
-
|
|
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 {
|
|
3719
4606
|
opts;
|
|
3720
4607
|
lastWrittenAt = /* @__PURE__ */ new Map();
|
|
3721
4608
|
lastWritten = /* @__PURE__ */ new Map();
|
|
@@ -4590,6 +5477,56 @@ function resolveMediaSettings(raw) {
|
|
|
4590
5477
|
};
|
|
4591
5478
|
}
|
|
4592
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
|
|
4593
5530
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
4594
5531
|
/**
|
|
4595
5532
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -4639,7 +5576,7 @@ var IDENTITY_COLUMNS = [
|
|
|
4639
5576
|
type: "TEXT"
|
|
4640
5577
|
}
|
|
4641
5578
|
];
|
|
4642
|
-
var SAMPLE_COLUMNS = [
|
|
5579
|
+
var SAMPLE_COLUMNS$1 = [
|
|
4643
5580
|
{
|
|
4644
5581
|
name: "id",
|
|
4645
5582
|
type: "TEXT",
|
|
@@ -4700,7 +5637,7 @@ var IdentityStore = class {
|
|
|
4700
5637
|
});
|
|
4701
5638
|
await store.declareCollection.mutate({
|
|
4702
5639
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
4703
|
-
columns: [...SAMPLE_COLUMNS],
|
|
5640
|
+
columns: [...SAMPLE_COLUMNS$1],
|
|
4704
5641
|
indexes: [{
|
|
4705
5642
|
name: "idx_sample_identity",
|
|
4706
5643
|
columns: ["identityId"]
|
|
@@ -5135,6 +6072,38 @@ var FaceStore = class {
|
|
|
5135
6072
|
} });
|
|
5136
6073
|
}
|
|
5137
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
|
+
}
|
|
5138
6107
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
5139
6108
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
5140
6109
|
async delete(faceId) {
|
|
@@ -5406,6 +6375,27 @@ var ObjectEmbeddingStore = class {
|
|
|
5406
6375
|
}
|
|
5407
6376
|
return ids;
|
|
5408
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
|
+
}
|
|
5409
6399
|
};
|
|
5410
6400
|
//#endregion
|
|
5411
6401
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
@@ -5500,6 +6490,14 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
5500
6490
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
5501
6491
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
5502
6492
|
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
6493
|
+
/**
|
|
6494
|
+
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
6495
|
+
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
6496
|
+
* (the two-plane `DetailResult` schema omits it), so recognition uses the
|
|
6497
|
+
* gallery's own model id (all enrolled samples share one) and this constant is
|
|
6498
|
+
* only a placeholder for the collect-only case where the id is never compared.
|
|
6499
|
+
*/
|
|
6500
|
+
var FALLBACK_FACE_MODEL_ID = "arcface";
|
|
5503
6501
|
var FaceRecognizer = class {
|
|
5504
6502
|
deps;
|
|
5505
6503
|
gallery = [];
|
|
@@ -5527,6 +6525,38 @@ var FaceRecognizer = class {
|
|
|
5527
6525
|
this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
|
|
5528
6526
|
}
|
|
5529
6527
|
}
|
|
6528
|
+
/**
|
|
6529
|
+
* Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
|
|
6530
|
+
* track and run it through the SAME `processFrame` logic (candidate → best
|
|
6531
|
+
* face → gallery match → crop hold). The result is synthesized into a single
|
|
6532
|
+
* `TrackedDetectionOut` candidate so no recognizer logic changes — only the
|
|
6533
|
+
* input source moves from the per-frame plane to this per-track call.
|
|
6534
|
+
*/
|
|
6535
|
+
async ingestFaceDetail(input) {
|
|
6536
|
+
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
6537
|
+
const candidate = {
|
|
6538
|
+
trackId: input.trackId,
|
|
6539
|
+
className: "face",
|
|
6540
|
+
confidence: input.score,
|
|
6541
|
+
bbox: input.parentBbox,
|
|
6542
|
+
zones: [],
|
|
6543
|
+
state: "moving",
|
|
6544
|
+
embedding: input.embedding,
|
|
6545
|
+
embeddingModelId: modelId,
|
|
6546
|
+
...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
|
|
6547
|
+
...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
|
|
6548
|
+
};
|
|
6549
|
+
await this.processFrame({
|
|
6550
|
+
deviceId: input.deviceId,
|
|
6551
|
+
timestamp: input.timestamp,
|
|
6552
|
+
frameWidth: input.frameWidth,
|
|
6553
|
+
frameHeight: input.frameHeight,
|
|
6554
|
+
tracked: [candidate],
|
|
6555
|
+
settings: input.settings,
|
|
6556
|
+
cropPadding: input.cropPadding,
|
|
6557
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
6558
|
+
});
|
|
6559
|
+
}
|
|
5530
6560
|
async processFrame(input) {
|
|
5531
6561
|
const { settings } = input;
|
|
5532
6562
|
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
|
|
@@ -5564,7 +6594,8 @@ var FaceRecognizer = class {
|
|
|
5564
6594
|
if (isNewBest || needsCrop) {
|
|
5565
6595
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
5566
6596
|
let crop;
|
|
5567
|
-
if (c.
|
|
6597
|
+
if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
|
|
6598
|
+
else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
|
|
5568
6599
|
crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
5569
6600
|
} catch (err) {
|
|
5570
6601
|
this.deps.logger.debug("FaceRecognizer crop capture failed", {
|
|
@@ -5743,6 +6774,24 @@ var FaceRecognizer = class {
|
|
|
5743
6774
|
score: held.score
|
|
5744
6775
|
}
|
|
5745
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
|
+
}
|
|
5746
6795
|
} catch (err) {
|
|
5747
6796
|
this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
|
|
5748
6797
|
tags: { deviceId },
|
|
@@ -5755,103 +6804,667 @@ var FaceRecognizer = class {
|
|
|
5755
6804
|
}
|
|
5756
6805
|
};
|
|
5757
6806
|
//#endregion
|
|
5758
|
-
//#region src/pipeline-analytics/
|
|
5759
|
-
|
|
5760
|
-
var
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
}
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
/** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
|
|
5824
|
-
async insert(plate) {
|
|
5825
|
-
const { id, ...rest } = plate;
|
|
5826
|
-
try {
|
|
5827
|
-
await this.store.set.mutate({
|
|
5828
|
-
collection: PLATES_COLLECTION,
|
|
5829
|
-
key: id,
|
|
5830
|
-
value: rest
|
|
5831
|
-
});
|
|
5832
|
-
} catch (err) {
|
|
5833
|
-
this.logger.warn("PlateStore.insert failed", {
|
|
5834
|
-
tags: { deviceId: plate.deviceId },
|
|
5835
|
-
meta: {
|
|
5836
|
-
plateId: id,
|
|
5837
|
-
error: String(err)
|
|
6807
|
+
//#region src/pipeline-analytics/detail-scheduler.ts
|
|
6808
|
+
/** Default backoff/period when a step's cadence omits `minIntervalMs`. */
|
|
6809
|
+
var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
6810
|
+
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
6811
|
+
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
6812
|
+
/**
|
|
6813
|
+
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
6814
|
+
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
6815
|
+
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
6816
|
+
* step should run for a given track — independent of transport, I/O, or
|
|
6817
|
+
* timers. The caller drives it with wall-clock `nowMs` and dispatches the
|
|
6818
|
+
* returned `DetailRequest[]`.
|
|
6819
|
+
*/
|
|
6820
|
+
var DetailScheduler = class {
|
|
6821
|
+
tracks = /* @__PURE__ */ new Map();
|
|
6822
|
+
/** Track appeared with class + announce; returns immediate requests. */
|
|
6823
|
+
onTrackStarted(trackId, className, announce, nowMs) {
|
|
6824
|
+
const steps = /* @__PURE__ */ new Map();
|
|
6825
|
+
const requests = [];
|
|
6826
|
+
for (const stepAnnounce of announce) {
|
|
6827
|
+
if (!stepAnnounce.inputClasses.includes(className)) continue;
|
|
6828
|
+
const state = {
|
|
6829
|
+
announce: stepAnnounce,
|
|
6830
|
+
firedCount: 1,
|
|
6831
|
+
lastFiredAt: nowMs,
|
|
6832
|
+
sticky: false,
|
|
6833
|
+
retryPending: false
|
|
6834
|
+
};
|
|
6835
|
+
steps.set(stepAnnounce.stepId, state);
|
|
6836
|
+
requests.push({
|
|
6837
|
+
trackId,
|
|
6838
|
+
stepId: stepAnnounce.stepId,
|
|
6839
|
+
reason: "new-track"
|
|
6840
|
+
});
|
|
6841
|
+
}
|
|
6842
|
+
this.tracks.set(trackId, steps);
|
|
6843
|
+
return requests;
|
|
6844
|
+
}
|
|
6845
|
+
/** Better candidate crop observed for the track. */
|
|
6846
|
+
onCandidateImproved(trackId, nowMs) {
|
|
6847
|
+
const steps = this.tracks.get(trackId);
|
|
6848
|
+
if (!steps) return [];
|
|
6849
|
+
const requests = [];
|
|
6850
|
+
for (const state of steps.values()) {
|
|
6851
|
+
if (state.announce.cadence.trigger !== "improve") continue;
|
|
6852
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
6853
|
+
this.markFired(state, nowMs);
|
|
6854
|
+
requests.push({
|
|
6855
|
+
trackId,
|
|
6856
|
+
stepId: state.announce.stepId,
|
|
6857
|
+
reason: "improve"
|
|
6858
|
+
});
|
|
6859
|
+
}
|
|
6860
|
+
return requests;
|
|
6861
|
+
}
|
|
6862
|
+
/** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
|
|
6863
|
+
tick(nowMs) {
|
|
6864
|
+
const requests = [];
|
|
6865
|
+
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
6866
|
+
if (state.sticky) continue;
|
|
6867
|
+
if (state.retryPending) {
|
|
6868
|
+
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
6869
|
+
if (!this.underMaxPerTrack(state)) {
|
|
6870
|
+
state.retryPending = false;
|
|
6871
|
+
continue;
|
|
5838
6872
|
}
|
|
6873
|
+
this.markFired(state, nowMs);
|
|
6874
|
+
requests.push({
|
|
6875
|
+
trackId,
|
|
6876
|
+
stepId: state.announce.stepId,
|
|
6877
|
+
reason: "retry"
|
|
6878
|
+
});
|
|
6879
|
+
continue;
|
|
6880
|
+
}
|
|
6881
|
+
if (state.announce.cadence.trigger !== "periodic") continue;
|
|
6882
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
6883
|
+
this.markFired(state, nowMs);
|
|
6884
|
+
requests.push({
|
|
6885
|
+
trackId,
|
|
6886
|
+
stepId: state.announce.stepId,
|
|
6887
|
+
reason: "periodic"
|
|
5839
6888
|
});
|
|
5840
6889
|
}
|
|
6890
|
+
return requests;
|
|
5841
6891
|
}
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
6892
|
+
/**
|
|
6893
|
+
* Result arrived; confidence drives sticky/retry. null = failed (retry per
|
|
6894
|
+
* policy). `_nowMs` is part of the public signature for symmetry with the
|
|
6895
|
+
* other methods but isn't needed here — retry backoff is anchored to
|
|
6896
|
+
* `lastFiredAt` (set when the step was actually dispatched), not to when
|
|
6897
|
+
* its result came back.
|
|
6898
|
+
*/
|
|
6899
|
+
onResult(trackId, stepId, confidence, _nowMs) {
|
|
6900
|
+
const steps = this.tracks.get(trackId);
|
|
6901
|
+
if (!steps) return;
|
|
6902
|
+
const state = steps.get(stepId);
|
|
6903
|
+
if (!state) return;
|
|
6904
|
+
if (state.sticky) return;
|
|
6905
|
+
const { stickyOnConfidence } = state.announce.cadence;
|
|
6906
|
+
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
6907
|
+
state.sticky = true;
|
|
6908
|
+
state.retryPending = false;
|
|
6909
|
+
return;
|
|
6910
|
+
}
|
|
6911
|
+
if (confidence === null) {
|
|
6912
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6913
|
+
return;
|
|
6914
|
+
}
|
|
6915
|
+
if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
|
|
6916
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
6917
|
+
}
|
|
5851
6918
|
}
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
6919
|
+
onTrackEnded(trackId) {
|
|
6920
|
+
this.tracks.delete(trackId);
|
|
6921
|
+
}
|
|
6922
|
+
canFire(state, nowMs) {
|
|
6923
|
+
if (state.sticky) return false;
|
|
6924
|
+
if (!this.underMaxPerTrack(state)) return false;
|
|
6925
|
+
return this.intervalElapsed(state, nowMs);
|
|
6926
|
+
}
|
|
6927
|
+
intervalElapsed(state, nowMs) {
|
|
6928
|
+
if (state.lastFiredAt === null) return true;
|
|
6929
|
+
const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
6930
|
+
return nowMs - state.lastFiredAt >= minIntervalMs;
|
|
6931
|
+
}
|
|
6932
|
+
underMaxPerTrack(state) {
|
|
6933
|
+
const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
|
|
6934
|
+
return state.firedCount < maxPerTrack;
|
|
6935
|
+
}
|
|
6936
|
+
markFired(state, nowMs) {
|
|
6937
|
+
state.firedCount += 1;
|
|
6938
|
+
state.lastFiredAt = nowMs;
|
|
6939
|
+
state.retryPending = false;
|
|
6940
|
+
}
|
|
6941
|
+
};
|
|
6942
|
+
//#endregion
|
|
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
|
+
}
|
|
6963
|
+
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6964
|
+
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
6965
|
+
var TrackDetailDispatcher = class {
|
|
6966
|
+
deps;
|
|
6967
|
+
devices = /* @__PURE__ */ new Map();
|
|
6968
|
+
maxInFlight;
|
|
6969
|
+
tickIntervalMs;
|
|
6970
|
+
disposed = false;
|
|
6971
|
+
constructor(deps) {
|
|
6972
|
+
this.deps = deps;
|
|
6973
|
+
this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
|
|
6974
|
+
this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
|
|
6975
|
+
}
|
|
6976
|
+
/** A track appeared: record its frame, seed its best-confidence, and dispatch
|
|
6977
|
+
* the scheduler's immediate (new-track) requests. */
|
|
6978
|
+
onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
|
|
6979
|
+
if (this.disposed) return;
|
|
6980
|
+
const dev = this.ensureDevice(deviceId);
|
|
6981
|
+
dev.tracks.set(trackId, frame);
|
|
6982
|
+
dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
|
|
6983
|
+
const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
|
|
6984
|
+
this.enqueue(deviceId, dev, requests);
|
|
6985
|
+
this.ensureTimer(deviceId, dev);
|
|
6986
|
+
}
|
|
6987
|
+
/** A subsequent frame for a live track: refresh its frame + fire
|
|
6988
|
+
* `improve`-cadence steps when the detector confidence strictly improves.
|
|
6989
|
+
*
|
|
6990
|
+
* `announce` is the frame's currently-announced detail chain. When a track
|
|
6991
|
+
* is alive but has NO dispatcher state yet — it existed before `detailSteps`
|
|
6992
|
+
* first appeared (a mid-track redeploy / config change) — this adopts it as a
|
|
6993
|
+
* new track so it starts getting scheduled instead of starving for its whole
|
|
6994
|
+
* life. Idempotent: guarded by the per-track state check, so a track that
|
|
6995
|
+
* already has state is never reset. */
|
|
6996
|
+
onFrame(deviceId, trackId, announce, frame, nowMs) {
|
|
6997
|
+
if (this.disposed) return;
|
|
6998
|
+
const dev = this.devices.get(deviceId);
|
|
6999
|
+
if (!dev || !dev.tracks.has(trackId)) {
|
|
7000
|
+
if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
|
|
7001
|
+
return;
|
|
7002
|
+
}
|
|
7003
|
+
dev.tracks.set(trackId, frame);
|
|
7004
|
+
if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
|
|
7005
|
+
const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
|
|
7006
|
+
this.enqueue(deviceId, dev, requests);
|
|
7007
|
+
}
|
|
7008
|
+
/** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
|
|
7009
|
+
* queued request for it is discarded at dequeue. */
|
|
7010
|
+
onTrackEnded(deviceId, trackId) {
|
|
7011
|
+
const dev = this.devices.get(deviceId);
|
|
7012
|
+
if (!dev) return;
|
|
7013
|
+
dev.scheduler.onTrackEnded(trackId);
|
|
7014
|
+
dev.tracks.delete(trackId);
|
|
7015
|
+
dev.candidateBest.delete(trackId);
|
|
7016
|
+
if (dev.tracks.size === 0) this.clearTimer(dev);
|
|
7017
|
+
}
|
|
7018
|
+
dispose() {
|
|
7019
|
+
this.disposed = true;
|
|
7020
|
+
for (const dev of this.devices.values()) {
|
|
7021
|
+
this.clearTimer(dev);
|
|
7022
|
+
dev.queue.length = 0;
|
|
7023
|
+
dev.tracks.clear();
|
|
7024
|
+
dev.candidateBest.clear();
|
|
7025
|
+
}
|
|
7026
|
+
this.devices.clear();
|
|
7027
|
+
}
|
|
7028
|
+
ensureDevice(deviceId) {
|
|
7029
|
+
let dev = this.devices.get(deviceId);
|
|
7030
|
+
if (!dev) {
|
|
7031
|
+
dev = {
|
|
7032
|
+
scheduler: new DetailScheduler(),
|
|
7033
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
7034
|
+
candidateBest: new BestDetectionTracker(),
|
|
7035
|
+
queue: [],
|
|
7036
|
+
inFlight: 0,
|
|
7037
|
+
timer: null,
|
|
7038
|
+
lastFailWarnAt: 0
|
|
7039
|
+
};
|
|
7040
|
+
this.devices.set(deviceId, dev);
|
|
7041
|
+
}
|
|
7042
|
+
return dev;
|
|
7043
|
+
}
|
|
7044
|
+
ensureTimer(deviceId, dev) {
|
|
7045
|
+
if (dev.timer) return;
|
|
7046
|
+
const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
|
|
7047
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
7048
|
+
dev.timer = timer;
|
|
7049
|
+
}
|
|
7050
|
+
clearTimer(dev) {
|
|
7051
|
+
if (dev.timer) {
|
|
7052
|
+
clearInterval(dev.timer);
|
|
7053
|
+
dev.timer = null;
|
|
7054
|
+
}
|
|
7055
|
+
}
|
|
7056
|
+
tick(deviceId, dev) {
|
|
7057
|
+
if (this.disposed) return;
|
|
7058
|
+
const requests = dev.scheduler.tick(Date.now());
|
|
7059
|
+
this.enqueue(deviceId, dev, requests);
|
|
7060
|
+
}
|
|
7061
|
+
enqueue(deviceId, dev, requests) {
|
|
7062
|
+
if (requests.length === 0) return;
|
|
7063
|
+
for (const r of requests) dev.queue.push(r);
|
|
7064
|
+
this.pump(deviceId, dev);
|
|
7065
|
+
}
|
|
7066
|
+
pump(deviceId, dev) {
|
|
7067
|
+
while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
|
|
7068
|
+
const req = dev.queue.shift();
|
|
7069
|
+
if (req === void 0) break;
|
|
7070
|
+
const frame = dev.tracks.get(req.trackId);
|
|
7071
|
+
if (frame === void 0) continue;
|
|
7072
|
+
dev.inFlight += 1;
|
|
7073
|
+
this.dispatch(deviceId, dev, req, frame).finally(() => {
|
|
7074
|
+
dev.inFlight -= 1;
|
|
7075
|
+
this.pump(deviceId, dev);
|
|
7076
|
+
});
|
|
7077
|
+
}
|
|
7078
|
+
}
|
|
7079
|
+
async dispatch(deviceId, dev, req, frame) {
|
|
7080
|
+
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
7081
|
+
let topScore = null;
|
|
7082
|
+
if (details !== null && details.length > 0) {
|
|
7083
|
+
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7084
|
+
try {
|
|
7085
|
+
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7086
|
+
} catch (err) {
|
|
7087
|
+
this.deps.logger.warn("detail result routing failed", {
|
|
7088
|
+
tags: { deviceId },
|
|
7089
|
+
meta: {
|
|
7090
|
+
trackId: req.trackId,
|
|
7091
|
+
stepId: req.stepId,
|
|
7092
|
+
error: String(err)
|
|
7093
|
+
}
|
|
7094
|
+
});
|
|
7095
|
+
}
|
|
7096
|
+
}
|
|
7097
|
+
dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* Run the request once via the frameHandle, and — on a miss (null OR throw)
|
|
7101
|
+
* — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
|
|
7102
|
+
* the detail list, or `null` when both attempts fail to produce a result.
|
|
7103
|
+
*/
|
|
7104
|
+
async runOnce(deviceId, dev, req, frame) {
|
|
7105
|
+
const parent = {
|
|
7106
|
+
bbox: { ...frame.bbox },
|
|
7107
|
+
className: frame.className
|
|
7108
|
+
};
|
|
7109
|
+
const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
|
|
7110
|
+
if (frame.frameHandle !== void 0) try {
|
|
7111
|
+
const primary = await this.deps.runDetailSubtree({
|
|
7112
|
+
deviceId,
|
|
7113
|
+
frameHandle: frame.frameHandle,
|
|
7114
|
+
parent,
|
|
7115
|
+
steps
|
|
7116
|
+
}, frame.nodeId);
|
|
7117
|
+
if (primary !== null) return primary.details;
|
|
7118
|
+
} catch (err) {
|
|
7119
|
+
this.deps.logger.debug("detail primary call failed — trying crop fallback", {
|
|
7120
|
+
tags: { deviceId },
|
|
7121
|
+
meta: {
|
|
7122
|
+
trackId: req.trackId,
|
|
7123
|
+
stepId: req.stepId,
|
|
7124
|
+
error: String(err)
|
|
7125
|
+
}
|
|
7126
|
+
});
|
|
7127
|
+
}
|
|
7128
|
+
if (this.deps.captureCropBase64 !== void 0) try {
|
|
7129
|
+
const cropJpeg = await this.deps.captureCropBase64(frame);
|
|
7130
|
+
if (cropJpeg !== null) {
|
|
7131
|
+
const retry = await this.deps.runDetailSubtree({
|
|
7132
|
+
deviceId,
|
|
7133
|
+
cropJpeg,
|
|
7134
|
+
parent,
|
|
7135
|
+
steps
|
|
7136
|
+
}, frame.nodeId);
|
|
7137
|
+
if (retry !== null) return retry.details;
|
|
7138
|
+
}
|
|
7139
|
+
} catch (err) {
|
|
7140
|
+
this.deps.logger.debug("detail crop-fallback call failed", {
|
|
7141
|
+
tags: { deviceId },
|
|
7142
|
+
meta: {
|
|
7143
|
+
trackId: req.trackId,
|
|
7144
|
+
stepId: req.stepId,
|
|
7145
|
+
error: String(err)
|
|
7146
|
+
}
|
|
7147
|
+
});
|
|
7148
|
+
}
|
|
7149
|
+
this.warnFailThrottled(deviceId, dev, req);
|
|
7150
|
+
return null;
|
|
7151
|
+
}
|
|
7152
|
+
warnFailThrottled(deviceId, dev, req) {
|
|
7153
|
+
const now = Date.now();
|
|
7154
|
+
if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
7155
|
+
dev.lastFailWarnAt = now;
|
|
7156
|
+
this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
|
|
7157
|
+
tags: { deviceId },
|
|
7158
|
+
meta: {
|
|
7159
|
+
trackId: req.trackId,
|
|
7160
|
+
stepId: req.stepId,
|
|
7161
|
+
reason: req.reason
|
|
7162
|
+
}
|
|
7163
|
+
});
|
|
7164
|
+
}
|
|
7165
|
+
};
|
|
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
|
|
7345
|
+
//#region src/pipeline-analytics/store/plate-store.ts
|
|
7346
|
+
var PLATES_COLLECTION = "pipeline-analytics:plates";
|
|
7347
|
+
var PLATE_COLUMNS = [
|
|
7348
|
+
{
|
|
7349
|
+
name: "id",
|
|
7350
|
+
type: "TEXT",
|
|
7351
|
+
primaryKey: true,
|
|
7352
|
+
notNull: true
|
|
7353
|
+
},
|
|
7354
|
+
{
|
|
7355
|
+
name: "deviceId",
|
|
7356
|
+
type: "INTEGER",
|
|
7357
|
+
notNull: true
|
|
7358
|
+
},
|
|
7359
|
+
{
|
|
7360
|
+
name: "trackId",
|
|
7361
|
+
type: "TEXT",
|
|
7362
|
+
notNull: true
|
|
7363
|
+
},
|
|
7364
|
+
{
|
|
7365
|
+
name: "timestamp",
|
|
7366
|
+
type: "INTEGER",
|
|
7367
|
+
notNull: true
|
|
7368
|
+
},
|
|
7369
|
+
{
|
|
7370
|
+
name: "text",
|
|
7371
|
+
type: "TEXT",
|
|
7372
|
+
notNull: true
|
|
7373
|
+
},
|
|
7374
|
+
{
|
|
7375
|
+
name: "score",
|
|
7376
|
+
type: "REAL",
|
|
7377
|
+
notNull: true
|
|
7378
|
+
},
|
|
7379
|
+
{
|
|
7380
|
+
name: "mediaKey",
|
|
7381
|
+
type: "TEXT"
|
|
7382
|
+
},
|
|
7383
|
+
{
|
|
7384
|
+
name: "corrected",
|
|
7385
|
+
type: "BOOLEAN",
|
|
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"
|
|
7408
|
+
}
|
|
7409
|
+
];
|
|
7410
|
+
var PLATE_INDEXES = [{
|
|
7411
|
+
name: "idx_plates_device_ts",
|
|
7412
|
+
columns: ["deviceId", "timestamp"]
|
|
7413
|
+
}, {
|
|
7414
|
+
name: "idx_plates_track",
|
|
7415
|
+
columns: ["trackId"]
|
|
7416
|
+
}];
|
|
7417
|
+
var PlateStore = class {
|
|
7418
|
+
store;
|
|
7419
|
+
logger;
|
|
7420
|
+
constructor(deps) {
|
|
7421
|
+
this.store = deps.store;
|
|
7422
|
+
this.logger = deps.logger;
|
|
7423
|
+
}
|
|
7424
|
+
static async declare(store) {
|
|
7425
|
+
await store.declareCollection.mutate({
|
|
7426
|
+
collection: PLATES_COLLECTION,
|
|
7427
|
+
columns: [...PLATE_COLUMNS],
|
|
7428
|
+
indexes: [...PLATE_INDEXES]
|
|
7429
|
+
});
|
|
7430
|
+
}
|
|
7431
|
+
/** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
|
|
7432
|
+
async insert(plate) {
|
|
7433
|
+
const { id, ...rest } = plate;
|
|
7434
|
+
try {
|
|
7435
|
+
await this.store.set.mutate({
|
|
7436
|
+
collection: PLATES_COLLECTION,
|
|
7437
|
+
key: id,
|
|
7438
|
+
value: rest
|
|
7439
|
+
});
|
|
7440
|
+
} catch (err) {
|
|
7441
|
+
this.logger.warn("PlateStore.insert failed", {
|
|
7442
|
+
tags: { deviceId: plate.deviceId },
|
|
7443
|
+
meta: {
|
|
7444
|
+
plateId: id,
|
|
7445
|
+
error: String(err)
|
|
7446
|
+
}
|
|
7447
|
+
});
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
normalizeRow(r) {
|
|
7451
|
+
const data = r.data;
|
|
7452
|
+
return {
|
|
7453
|
+
id: r.id,
|
|
7454
|
+
...data,
|
|
7455
|
+
score: Number(r.data.score ?? 0),
|
|
7456
|
+
corrected: Boolean(r.data.corrected),
|
|
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
|
|
7463
|
+
};
|
|
7464
|
+
}
|
|
7465
|
+
/** Recent plate reads for one device (or all when deviceId omitted), newest first. */
|
|
7466
|
+
async listRecentPlates(input) {
|
|
7467
|
+
const where = {};
|
|
5855
7468
|
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
5856
7469
|
return (await this.store.query.query({
|
|
5857
7470
|
collection: PLATES_COLLECTION,
|
|
@@ -5917,6 +7530,76 @@ var PlateStore = class {
|
|
|
5917
7530
|
} });
|
|
5918
7531
|
}
|
|
5919
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
|
+
}
|
|
5920
7603
|
/**
|
|
5921
7604
|
* Retention across ALL devices: delete reads older than `cutoffMs` AND any
|
|
5922
7605
|
* beyond the newest `maxPerDevice` per device. Corrected reads are exempt
|
|
@@ -5933,7 +7616,7 @@ var PlateStore = class {
|
|
|
5933
7616
|
const byDevice = /* @__PURE__ */ new Map();
|
|
5934
7617
|
for (const r of rows) {
|
|
5935
7618
|
const data = r.data;
|
|
5936
|
-
if (data.corrected) continue;
|
|
7619
|
+
if (data.corrected || data.assigned) continue;
|
|
5937
7620
|
const deviceId = Number(data.deviceId ?? -1);
|
|
5938
7621
|
const list = byDevice.get(deviceId) ?? [];
|
|
5939
7622
|
list.push({
|
|
@@ -5963,10 +7646,320 @@ var PlateStore = class {
|
|
|
5963
7646
|
error: String(err)
|
|
5964
7647
|
} });
|
|
5965
7648
|
}
|
|
5966
|
-
return deleted;
|
|
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
|
+
});
|
|
7859
|
+
}
|
|
7860
|
+
return sample;
|
|
7861
|
+
}
|
|
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() {
|
|
7900
|
+
const rows = await this.store.query.query({
|
|
7901
|
+
collection: VEHICLE_SAMPLES_COLLECTION,
|
|
7902
|
+
filter: {}
|
|
7903
|
+
});
|
|
7904
|
+
const gallery = [];
|
|
7905
|
+
for (const r of rows) {
|
|
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
|
|
7911
|
+
});
|
|
7912
|
+
}
|
|
7913
|
+
return gallery;
|
|
5967
7914
|
}
|
|
5968
7915
|
};
|
|
5969
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
|
|
5970
7963
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
5971
7964
|
/**
|
|
5972
7965
|
* PlateRecognizer — buffers the best license-plate OCR read per vehicle track
|
|
@@ -5978,8 +7971,38 @@ var PlateStore = class {
|
|
|
5978
7971
|
var PlateRecognizer = class {
|
|
5979
7972
|
deps;
|
|
5980
7973
|
bestPlate = /* @__PURE__ */ new Map();
|
|
7974
|
+
gallery = [];
|
|
7975
|
+
nameById = /* @__PURE__ */ new Map();
|
|
7976
|
+
matchOpts;
|
|
5981
7977
|
constructor(deps) {
|
|
5982
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;
|
|
5983
8006
|
}
|
|
5984
8007
|
async processFrame(input) {
|
|
5985
8008
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -6035,6 +8058,8 @@ var PlateRecognizer = class {
|
|
|
6035
8058
|
}
|
|
6036
8059
|
});
|
|
6037
8060
|
}
|
|
8061
|
+
const match = this.matchVehicle(held.text, held.score);
|
|
8062
|
+
const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
|
|
6038
8063
|
try {
|
|
6039
8064
|
await this.deps.plateStore.insert({
|
|
6040
8065
|
id: plateId,
|
|
@@ -6044,7 +8069,16 @@ var PlateRecognizer = class {
|
|
|
6044
8069
|
text: normalizePlate(held.text),
|
|
6045
8070
|
score: held.score,
|
|
6046
8071
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
6047
|
-
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"
|
|
6048
8082
|
});
|
|
6049
8083
|
this.deps.logger.info("plate: buffered to gallery", {
|
|
6050
8084
|
tags: {
|
|
@@ -6055,7 +8089,8 @@ var PlateRecognizer = class {
|
|
|
6055
8089
|
plateId,
|
|
6056
8090
|
text: normalizePlate(held.text),
|
|
6057
8091
|
score: held.score,
|
|
6058
|
-
hasCrop: mediaKey !== void 0
|
|
8092
|
+
hasCrop: mediaKey !== void 0,
|
|
8093
|
+
recognizedVehicleId: match?.vehicleId ?? null
|
|
6059
8094
|
}
|
|
6060
8095
|
});
|
|
6061
8096
|
} catch (err) {
|
|
@@ -6399,6 +8434,12 @@ function createEventMediaHandler(deps) {
|
|
|
6399
8434
|
* surface to turn the refinement pipeline on/off for a camera.
|
|
6400
8435
|
*/
|
|
6401
8436
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
8437
|
+
/** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
|
|
8438
|
+
* scheduled detail call's frameHandle lease is already gone. */
|
|
8439
|
+
var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
8440
|
+
/** How long the active CLIP model id (from the embedding-encoder) is cached
|
|
8441
|
+
* before re-reading. */
|
|
8442
|
+
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
6402
8443
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
6403
8444
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
6404
8445
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
@@ -6426,11 +8467,23 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
|
|
|
6426
8467
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
6427
8468
|
* narrowing the old raw read applied inline. */
|
|
6428
8469
|
var PostProcessingNodeIdSchema = require_dist.string().min(1);
|
|
8470
|
+
var EmbeddingEnabledSchema = require_dist.boolean();
|
|
6429
8471
|
var SILENCE_FLOOR_DBFS = -55;
|
|
6430
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;
|
|
6431
8475
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
6432
8476
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
6433
8477
|
/**
|
|
8478
|
+
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
8479
|
+
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
8480
|
+
*/
|
|
8481
|
+
function decodeEmbeddingBase64(base64) {
|
|
8482
|
+
const bytes = Buffer.from(base64, "base64");
|
|
8483
|
+
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
8484
|
+
return Array.from(view);
|
|
8485
|
+
}
|
|
8486
|
+
/**
|
|
6434
8487
|
* Re-home the global analytics sections into the per-device `Analytics`
|
|
6435
8488
|
* top-tab. Every section defaults to the `Analytics` tab (so the
|
|
6436
8489
|
* device-manager aggregator groups them) AND is marked
|
|
@@ -6478,8 +8531,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6478
8531
|
faceStore = null;
|
|
6479
8532
|
faceRecognizer = null;
|
|
6480
8533
|
plateStore = null;
|
|
8534
|
+
vehicleStore = null;
|
|
6481
8535
|
plateRecognizer = null;
|
|
6482
8536
|
objectEmbeddingStore = null;
|
|
8537
|
+
/** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
|
|
8538
|
+
* classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
|
|
8539
|
+
* the per-frame child consumption the executor no longer emits. */
|
|
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();
|
|
8545
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
8546
|
+
* Stamped on object-embedding rows from the detail plane so semantic search's
|
|
8547
|
+
* same-model gate keeps matching. */
|
|
8548
|
+
clipModelIdCache = null;
|
|
6483
8549
|
/** Frame-based event/track media (crop + boxed full-frame) from the
|
|
6484
8550
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
6485
8551
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
@@ -6487,6 +8553,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6487
8553
|
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
6488
8554
|
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
6489
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;
|
|
6490
8562
|
bindingCache = null;
|
|
6491
8563
|
zoneAnalytics = null;
|
|
6492
8564
|
audioMetrics = null;
|
|
@@ -6537,6 +8609,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6537
8609
|
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6538
8610
|
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6539
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();
|
|
6540
8616
|
/** Best (highest-confidence) CLIP-object detection per track — drives ONE
|
|
6541
8617
|
* tight object-crop capture whose media key is written onto the embedding
|
|
6542
8618
|
* row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
|
|
@@ -6548,6 +8624,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6548
8624
|
* at track end so a face row links to the SAME single key frame. Cleared on
|
|
6549
8625
|
* track end. */
|
|
6550
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();
|
|
6551
8632
|
/** The shared crop extractor (native-res first, detection-frame fallback),
|
|
6552
8633
|
* captured in the constructor so `processFrame` can crop object thumbnails in
|
|
6553
8634
|
* the same live-frame window as the face/plate/event-media captures. The
|
|
@@ -6563,6 +8644,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6563
8644
|
if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
|
|
6564
8645
|
return this._postProcessingNodeState;
|
|
6565
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
|
+
}
|
|
6566
8656
|
/** GLOBAL face-recognition master switch (`enabled` key). Schema +
|
|
6567
8657
|
* fallback derived from the face-settings source of truth. */
|
|
6568
8658
|
_faceGlobalEnabledState = null;
|
|
@@ -6582,12 +8672,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6582
8672
|
await IdentityStore.declare(api.settingsStore);
|
|
6583
8673
|
await FaceStore.declare(api.settingsStore);
|
|
6584
8674
|
await PlateStore.declare(api.settingsStore);
|
|
8675
|
+
await VehicleStore.declare(api.settingsStore);
|
|
6585
8676
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
6586
8677
|
const logger = this.ctx.logger;
|
|
6587
8678
|
let storage = this.ctx.kernel.storage;
|
|
6588
8679
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
6589
8680
|
if (mediaRoot) {
|
|
6590
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
8681
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
|
|
6591
8682
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
6592
8683
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
6593
8684
|
}
|
|
@@ -6603,7 +8694,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6603
8694
|
});
|
|
6604
8695
|
this.eventStore = new EventStore({
|
|
6605
8696
|
store: api.settingsStore,
|
|
6606
|
-
logger: logger.child("EventStore")
|
|
8697
|
+
logger: logger.child("EventStore"),
|
|
8698
|
+
media: this.mediaStore
|
|
6607
8699
|
});
|
|
6608
8700
|
this.identityStore = new IdentityStore({
|
|
6609
8701
|
store: api.settingsStore,
|
|
@@ -6617,6 +8709,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6617
8709
|
store: api.settingsStore,
|
|
6618
8710
|
logger: logger.child("PlateStore")
|
|
6619
8711
|
});
|
|
8712
|
+
this.vehicleStore = new VehicleStore({
|
|
8713
|
+
store: api.settingsStore,
|
|
8714
|
+
logger: logger.child("VehicleStore")
|
|
8715
|
+
});
|
|
6620
8716
|
const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
6621
8717
|
const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
|
|
6622
8718
|
{
|
|
@@ -6685,7 +8781,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6685
8781
|
return null;
|
|
6686
8782
|
}
|
|
6687
8783
|
};
|
|
6688
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) =>
|
|
8784
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
6689
8785
|
ownNodeId: ownNodeIdForFaces,
|
|
6690
8786
|
readers: frameReadersForFaces,
|
|
6691
8787
|
getRemoteFrame
|
|
@@ -6705,7 +8801,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6705
8801
|
bumpCropMetric(false);
|
|
6706
8802
|
const decoded = await resolveFrameShared(frameHandle);
|
|
6707
8803
|
if (!decoded || decoded.format !== "rgb") return null;
|
|
6708
|
-
const { crop } = await
|
|
8804
|
+
const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
|
|
6709
8805
|
return crop;
|
|
6710
8806
|
};
|
|
6711
8807
|
this.captureCrop = captureCrop;
|
|
@@ -6726,19 +8822,43 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6726
8822
|
}, trackId);
|
|
6727
8823
|
},
|
|
6728
8824
|
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8825
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
|
|
6729
8826
|
logger: logger.child("FaceRecognizer")
|
|
6730
8827
|
});
|
|
6731
8828
|
this.faceRecognizer.refreshGallery();
|
|
6732
8829
|
this.plateRecognizer = new PlateRecognizer({
|
|
6733
8830
|
plateStore: this.plateStore,
|
|
8831
|
+
vehicleStore: this.vehicleStore,
|
|
6734
8832
|
mediaStore: this.mediaStore,
|
|
6735
8833
|
captureCrop,
|
|
8834
|
+
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
8835
|
+
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
|
|
6736
8836
|
logger: logger.child("PlateRecognizer")
|
|
6737
8837
|
});
|
|
8838
|
+
this.plateRecognizer.refreshGallery();
|
|
6738
8839
|
this.objectEmbeddingStore = new ObjectEmbeddingStore({
|
|
6739
8840
|
store: api.settingsStore,
|
|
6740
8841
|
logger: logger.child("ObjectEmbeddingStore")
|
|
6741
8842
|
});
|
|
8843
|
+
const runnerApi = api.pipelineRunner;
|
|
8844
|
+
this.detailDispatcher = new TrackDetailDispatcher({
|
|
8845
|
+
logger: logger.child("DetailDispatcher"),
|
|
8846
|
+
runDetailSubtree: async (input, nodeId) => {
|
|
8847
|
+
if (!runnerApi?.runDetailSubtree) return null;
|
|
8848
|
+
if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, require_dist.nodePin(nodeId));
|
|
8849
|
+
return runnerApi.runDetailSubtree.mutate(input);
|
|
8850
|
+
},
|
|
8851
|
+
routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
|
|
8852
|
+
captureCropBase64: async (frame) => {
|
|
8853
|
+
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
8854
|
+
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
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;
|
|
8860
|
+
}
|
|
8861
|
+
});
|
|
6742
8862
|
this.bindingCache = new BindingCache({
|
|
6743
8863
|
api,
|
|
6744
8864
|
logger: logger.child("BindingCache")
|
|
@@ -6804,12 +8924,37 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6804
8924
|
const data = ev.data;
|
|
6805
8925
|
this.handleNativeDetection(data);
|
|
6806
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)");
|
|
6807
8950
|
}
|
|
6808
8951
|
this.unsubBindings = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (ev) => {
|
|
6809
8952
|
const data = ev.data;
|
|
6810
8953
|
this.bindingCache?.onBindingsChanged(data);
|
|
6811
8954
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
6812
8955
|
this.trackStore?.clearDevice(data.deviceId);
|
|
8956
|
+
this.overlayState.clearDevice(data.deviceId);
|
|
8957
|
+
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
6813
8958
|
this.forgetDeviceProcessors(data.deviceId);
|
|
6814
8959
|
this.levelStateByDevice.delete(data.deviceId);
|
|
6815
8960
|
this.settingsCacheByDevice.delete(data.deviceId);
|
|
@@ -6822,6 +8967,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6822
8967
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
6823
8968
|
const { deviceId } = ev.data;
|
|
6824
8969
|
this.trackStore?.clearDevice(deviceId);
|
|
8970
|
+
this.overlayState.clearDevice(deviceId);
|
|
8971
|
+
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
6825
8972
|
this.forgetDeviceProcessors(deviceId);
|
|
6826
8973
|
this.levelStateByDevice.delete(deviceId);
|
|
6827
8974
|
this.settingsCacheByDevice.delete(deviceId);
|
|
@@ -6839,6 +8986,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
6839
8986
|
}, TTL_SWEEP_INTERVAL_MS);
|
|
6840
8987
|
this.retentionSweepTimer = setInterval(() => {
|
|
6841
8988
|
this.sweepRetention();
|
|
8989
|
+
this.runTrackRetentionSweep();
|
|
6842
8990
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
6843
8991
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
6844
8992
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -7079,11 +9227,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7079
9227
|
mediaStore: this.mediaStore,
|
|
7080
9228
|
trackStore: this.trackStore,
|
|
7081
9229
|
eventStore: this.eventStore,
|
|
7082
|
-
refreshGallery: () => this.faceRecognizer?.refreshGallery()
|
|
9230
|
+
refreshGallery: () => this.faceRecognizer?.refreshGallery(),
|
|
9231
|
+
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
|
|
7083
9232
|
});
|
|
7084
9233
|
const plateGallery = new PlateGalleryProvider({
|
|
7085
9234
|
plateStore: this.plateStore,
|
|
7086
|
-
|
|
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)
|
|
7087
9243
|
});
|
|
7088
9244
|
return [
|
|
7089
9245
|
{
|
|
@@ -7132,6 +9288,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7132
9288
|
this.unsubBindings = null;
|
|
7133
9289
|
this.unsubDeviceUnreg?.();
|
|
7134
9290
|
this.unsubDeviceUnreg = null;
|
|
9291
|
+
await this.embeddingDispatcher?.stop();
|
|
9292
|
+
this.embeddingDispatcher = null;
|
|
7135
9293
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
7136
9294
|
if (this.ttlSweepTimer) {
|
|
7137
9295
|
clearInterval(this.ttlSweepTimer);
|
|
@@ -7143,10 +9301,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7143
9301
|
}
|
|
7144
9302
|
this.zoneAnalytics?.destroy();
|
|
7145
9303
|
this.audioMetrics?.destroy();
|
|
9304
|
+
this.detailDispatcher?.dispose();
|
|
9305
|
+
this.detailDispatcher = null;
|
|
9306
|
+
this.overlayState.clear();
|
|
9307
|
+
this.overlaySynthesisWarnAt.clear();
|
|
7146
9308
|
this.processors.clear();
|
|
7147
9309
|
this.lastActiveTrackIds.clear();
|
|
7148
9310
|
this.dropoutSkipsByKey.clear();
|
|
7149
9311
|
this.bestFrameTracker.clear();
|
|
9312
|
+
this.trackLifecycleUpdateMem.clear();
|
|
7150
9313
|
this.objectEmbeddingBestSelector.clear();
|
|
7151
9314
|
this.levelStateByDevice.clear();
|
|
7152
9315
|
this.settingsCacheByDevice.clear();
|
|
@@ -7167,7 +9330,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7167
9330
|
async handleInferenceResult(data) {
|
|
7168
9331
|
if (this.shuttingDown) return;
|
|
7169
9332
|
const { deviceId, frame } = data;
|
|
7170
|
-
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
|
|
9333
|
+
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
|
|
7171
9334
|
}
|
|
7172
9335
|
/**
|
|
7173
9336
|
* Run one detection frame through the analysis layers for a given
|
|
@@ -7177,7 +9340,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7177
9340
|
* tracking/zone/event state never crosses between sources. Emits the
|
|
7178
9341
|
* SAME canonical events, distinguished only by `source`.
|
|
7179
9342
|
*/
|
|
7180
|
-
async processFrame(deviceId, frame, source, frameHandle) {
|
|
9343
|
+
async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
|
|
7181
9344
|
if (this.shuttingDown) return;
|
|
7182
9345
|
if (!await this.bindingCache.isActive(deviceId)) return;
|
|
7183
9346
|
const key = this.procKey(deviceId, source);
|
|
@@ -7278,6 +9441,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7278
9441
|
className: t.className
|
|
7279
9442
|
}
|
|
7280
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
|
+
});
|
|
7281
9462
|
}
|
|
7282
9463
|
}
|
|
7283
9464
|
let lostTrackCount = 0;
|
|
@@ -7289,6 +9470,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7289
9470
|
} });
|
|
7290
9471
|
}
|
|
7291
9472
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
9473
|
+
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9474
|
+
const dispatcher = this.detailDispatcher;
|
|
9475
|
+
const steps = detailSteps;
|
|
9476
|
+
for (const t of result.tracked) {
|
|
9477
|
+
const detailFrame = {
|
|
9478
|
+
bbox: { ...t.bbox },
|
|
9479
|
+
frameWidth: result.frameWidth,
|
|
9480
|
+
frameHeight: result.frameHeight,
|
|
9481
|
+
className: t.className,
|
|
9482
|
+
confidence: t.confidence,
|
|
9483
|
+
timestamp: result.timestamp,
|
|
9484
|
+
...frameHandle !== void 0 ? {
|
|
9485
|
+
frameHandle,
|
|
9486
|
+
nodeId: frameHandle.nodeId
|
|
9487
|
+
} : {}
|
|
9488
|
+
};
|
|
9489
|
+
if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
|
|
9490
|
+
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
9491
|
+
}
|
|
9492
|
+
}
|
|
7292
9493
|
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
7293
9494
|
const byState = {};
|
|
7294
9495
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
@@ -7321,7 +9522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7321
9522
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
7322
9523
|
if (this.eventMediaDispatcher && frameHandle) {
|
|
7323
9524
|
const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
|
|
7324
|
-
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) => {
|
|
7325
9526
|
const childCrops = childCropsByEvent.get(e.id);
|
|
7326
9527
|
return {
|
|
7327
9528
|
eventId: e.id,
|
|
@@ -7337,15 +9538,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7337
9538
|
let plateCrops = 0;
|
|
7338
9539
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
7339
9540
|
else plateCrops += 1;
|
|
7340
|
-
const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
|
|
9541
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
7341
9542
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
7342
|
-
|
|
7343
|
-
source,
|
|
9543
|
+
const captureCounts = {
|
|
7344
9544
|
events: eventTargets.length,
|
|
7345
9545
|
trackFrames: firstFrameTargets.length,
|
|
7346
9546
|
snapshots: snapshotTargets.length,
|
|
7347
9547
|
faceCrops,
|
|
7348
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
|
|
7349
9560
|
} });
|
|
7350
9561
|
this.eventMediaDispatcher.captureForFrame({
|
|
7351
9562
|
deviceId,
|
|
@@ -7408,6 +9619,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7408
9619
|
zones: e.zones ?? []
|
|
7409
9620
|
}
|
|
7410
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
|
+
}
|
|
7411
9642
|
this.ctx.eventBus.emit({
|
|
7412
9643
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
7413
9644
|
timestamp: new Date(result.timestamp),
|
|
@@ -7422,7 +9653,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7422
9653
|
timestamp: result.timestamp,
|
|
7423
9654
|
frameWidth: result.frameWidth,
|
|
7424
9655
|
frameHeight: result.frameHeight,
|
|
7425
|
-
detections:
|
|
9656
|
+
detections: overlayDetections
|
|
7426
9657
|
}
|
|
7427
9658
|
});
|
|
7428
9659
|
}
|
|
@@ -7500,6 +9731,158 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7500
9731
|
return settings;
|
|
7501
9732
|
}
|
|
7502
9733
|
/**
|
|
9734
|
+
* Route one track's `runDetailSubtree` results (two-plane detail dispatch)
|
|
9735
|
+
* into the EXISTING per-track consumers, discriminated by payload SHAPE:
|
|
9736
|
+
* • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
|
|
9737
|
+
* candidate/best-face/gallery/keyFrame path (input source cut-over);
|
|
9738
|
+
* • embedding only → CLIP object embedding → the object-embedding store
|
|
9739
|
+
* (semantic search);
|
|
9740
|
+
* • label only → classifier answer / plate OCR text → the track's
|
|
9741
|
+
* enrichment label the notifier/UI already read.
|
|
9742
|
+
* Best-effort (D8): a per-detail failure is logged and never propagated.
|
|
9743
|
+
*/
|
|
9744
|
+
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
9745
|
+
for (const d of details) try {
|
|
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);
|
|
9754
|
+
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
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
|
+
}
|
|
9765
|
+
} catch (err) {
|
|
9766
|
+
this.ctx.logger.warn("detail result route failed", {
|
|
9767
|
+
tags: { deviceId },
|
|
9768
|
+
meta: {
|
|
9769
|
+
trackId,
|
|
9770
|
+
stepId: d.stepId,
|
|
9771
|
+
error: require_dist.errMsg(err)
|
|
9772
|
+
}
|
|
9773
|
+
});
|
|
9774
|
+
}
|
|
9775
|
+
}
|
|
9776
|
+
/** Face-embedding detail → the FaceRecognizer (same gate + logic as the
|
|
9777
|
+
* former per-frame face path; only the input source moved). */
|
|
9778
|
+
async routeFaceDetail(deviceId, trackId, detail, frame) {
|
|
9779
|
+
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
9780
|
+
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
9781
|
+
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
9782
|
+
await this.faceRecognizer.ingestFaceDetail({
|
|
9783
|
+
deviceId,
|
|
9784
|
+
trackId,
|
|
9785
|
+
timestamp: frame.timestamp,
|
|
9786
|
+
frameWidth: frame.frameWidth,
|
|
9787
|
+
frameHeight: frame.frameHeight,
|
|
9788
|
+
score: detail.score,
|
|
9789
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
9790
|
+
parentBbox: { ...frame.bbox },
|
|
9791
|
+
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
9792
|
+
...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
|
|
9793
|
+
settings,
|
|
9794
|
+
cropPadding: media.cropPadding,
|
|
9795
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
9796
|
+
});
|
|
9797
|
+
}
|
|
9798
|
+
/** CLIP object-embedding detail → the object-embedding store (semantic
|
|
9799
|
+
* search). Stamps the active encoder's model id so the same-model search
|
|
9800
|
+
* gate keeps matching. */
|
|
9801
|
+
async routeClipDetail(deviceId, trackId, detail, timestamp) {
|
|
9802
|
+
const store = this.objectEmbeddingStore;
|
|
9803
|
+
if (!store || detail.embedding === void 0) return;
|
|
9804
|
+
const modelId = await this.resolveClipModelId();
|
|
9805
|
+
if (modelId === null) {
|
|
9806
|
+
this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
|
|
9807
|
+
tags: { deviceId },
|
|
9808
|
+
meta: {
|
|
9809
|
+
trackId,
|
|
9810
|
+
stepId: detail.stepId
|
|
9811
|
+
}
|
|
9812
|
+
});
|
|
9813
|
+
return;
|
|
9814
|
+
}
|
|
9815
|
+
await store.upsertIfBetter({
|
|
9816
|
+
trackId,
|
|
9817
|
+
deviceId,
|
|
9818
|
+
timestamp,
|
|
9819
|
+
className: detail.className,
|
|
9820
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
9821
|
+
modelId,
|
|
9822
|
+
confidence: detail.score
|
|
9823
|
+
});
|
|
9824
|
+
}
|
|
9825
|
+
/** Classifier answer / plate OCR text → the track's enrichment label
|
|
9826
|
+
* (TrackStore + persisted events + importance), mirroring the FaceRecognizer
|
|
9827
|
+
* label-propagation path. */
|
|
9828
|
+
async applyTrackEnrichmentLabel(deviceId, trackId, label) {
|
|
9829
|
+
try {
|
|
9830
|
+
await this.trackStore?.setLabel(trackId, label);
|
|
9831
|
+
} catch (err) {
|
|
9832
|
+
this.ctx.logger.warn("detail label setLabel failed", {
|
|
9833
|
+
tags: { deviceId },
|
|
9834
|
+
meta: {
|
|
9835
|
+
trackId,
|
|
9836
|
+
error: require_dist.errMsg(err)
|
|
9837
|
+
}
|
|
9838
|
+
});
|
|
9839
|
+
}
|
|
9840
|
+
try {
|
|
9841
|
+
await this.eventStore?.setLabelForTrack(trackId, label);
|
|
9842
|
+
} catch (err) {
|
|
9843
|
+
this.ctx.logger.warn("detail label setLabelForTrack failed", {
|
|
9844
|
+
tags: { deviceId },
|
|
9845
|
+
meta: {
|
|
9846
|
+
trackId,
|
|
9847
|
+
error: require_dist.errMsg(err)
|
|
9848
|
+
}
|
|
9849
|
+
});
|
|
9850
|
+
}
|
|
9851
|
+
try {
|
|
9852
|
+
const trackStore = this.trackStore;
|
|
9853
|
+
const eventStore = this.eventStore;
|
|
9854
|
+
if (trackStore && eventStore) await recomputeTrackImportance({
|
|
9855
|
+
trackStore,
|
|
9856
|
+
eventStore
|
|
9857
|
+
}, trackId);
|
|
9858
|
+
} catch (err) {
|
|
9859
|
+
this.ctx.logger.debug("detail label recomputeImportance failed", {
|
|
9860
|
+
tags: { deviceId },
|
|
9861
|
+
meta: {
|
|
9862
|
+
trackId,
|
|
9863
|
+
error: require_dist.errMsg(err)
|
|
9864
|
+
}
|
|
9865
|
+
});
|
|
9866
|
+
}
|
|
9867
|
+
}
|
|
9868
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
9869
|
+
* Returns null when the embedding-encoder cap is unavailable. */
|
|
9870
|
+
async resolveClipModelId() {
|
|
9871
|
+
const now = Date.now();
|
|
9872
|
+
if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
|
|
9873
|
+
let value = null;
|
|
9874
|
+
try {
|
|
9875
|
+
value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
|
|
9876
|
+
} catch (err) {
|
|
9877
|
+
this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: require_dist.errMsg(err) } });
|
|
9878
|
+
}
|
|
9879
|
+
this.clipModelIdCache = {
|
|
9880
|
+
value,
|
|
9881
|
+
expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
|
|
9882
|
+
};
|
|
9883
|
+
return value;
|
|
9884
|
+
}
|
|
9885
|
+
/**
|
|
7503
9886
|
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
7504
9887
|
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
7505
9888
|
* • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
|
|
@@ -7586,12 +9969,67 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7586
9969
|
});
|
|
7587
9970
|
}));
|
|
7588
9971
|
}
|
|
7589
|
-
|
|
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) {
|
|
7590
10027
|
const targets = [];
|
|
7591
10028
|
for (const t of tracked) {
|
|
7592
10029
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
7593
10030
|
const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
|
|
7594
10031
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10032
|
+
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
7595
10033
|
if (!dueSnapshot && !isNewBest) continue;
|
|
7596
10034
|
targets.push({
|
|
7597
10035
|
trackId: t.trackId,
|
|
@@ -7857,6 +10295,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7857
10295
|
positions: t.positions.length
|
|
7858
10296
|
}
|
|
7859
10297
|
});
|
|
10298
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
7860
10299
|
const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7861
10300
|
const dropKeyFrameKey = () => {
|
|
7862
10301
|
this.keyFrameKeyByTrackId.delete(t.trackId);
|
|
@@ -7864,11 +10303,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7864
10303
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
7865
10304
|
else dropKeyFrameKey();
|
|
7866
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;
|
|
7867
10310
|
try {
|
|
7868
10311
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
7869
10312
|
if (peak) {
|
|
10313
|
+
endBestEventId = peak.bestEventId;
|
|
7870
10314
|
const { importance, reason } = computeImportance({
|
|
7871
|
-
peakConfidence:
|
|
10315
|
+
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
7872
10316
|
className: t.className,
|
|
7873
10317
|
durationMs: duration,
|
|
7874
10318
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
@@ -7876,6 +10320,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7876
10320
|
zonesVisited: t.zonesVisited,
|
|
7877
10321
|
...t.label !== void 0 ? { label: t.label } : {}
|
|
7878
10322
|
});
|
|
10323
|
+
endImportance = importance;
|
|
10324
|
+
endImportanceReason = reason;
|
|
7879
10325
|
await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
|
|
7880
10326
|
if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
|
|
7881
10327
|
}
|
|
@@ -7887,6 +10333,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7887
10333
|
}
|
|
7888
10334
|
this.bestFrameTracker.delete(t.trackId);
|
|
7889
10335
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
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
|
+
}
|
|
7890
10349
|
this.ctx.eventBus.emit({
|
|
7891
10350
|
id: `pa-end-${t.trackId}`,
|
|
7892
10351
|
timestamp: new Date(t.lastSeen),
|
|
@@ -7903,6 +10362,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7903
10362
|
durationMs: duration
|
|
7904
10363
|
}
|
|
7905
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);
|
|
7906
10385
|
}
|
|
7907
10386
|
} catch (err) {
|
|
7908
10387
|
if (this.shuttingDown) return;
|
|
@@ -7994,6 +10473,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
7994
10473
|
if (!frame) return;
|
|
7995
10474
|
await this.processFrame(payload.cameraId, frame, "onboard");
|
|
7996
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
|
+
}
|
|
7997
10532
|
/** Composite key for the per-(device, source) processor + track maps. */
|
|
7998
10533
|
procKey(deviceId, source) {
|
|
7999
10534
|
return `${deviceId}:${source}`;
|
|
@@ -8093,6 +10628,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8093
10628
|
}
|
|
8094
10629
|
async clearTracks(input) {
|
|
8095
10630
|
this.trackStore?.clearDevice(input.deviceId);
|
|
10631
|
+
this.overlayState.clearDevice(input.deviceId);
|
|
10632
|
+
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
8096
10633
|
const prefix = `${input.deviceId}:`;
|
|
8097
10634
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
8098
10635
|
}
|
|
@@ -8269,6 +10806,168 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8269
10806
|
return counts;
|
|
8270
10807
|
}
|
|
8271
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
|
+
/**
|
|
8272
10971
|
* Decode a stored event image into `EventMedia` for the data-plane handler.
|
|
8273
10972
|
* Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
|
|
8274
10973
|
* (native-res boxed frame) → any available file. Returns `null` if the
|
|
@@ -8329,6 +11028,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8329
11028
|
label: "Post-processing node",
|
|
8330
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.",
|
|
8331
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
|
|
8332
11037
|
}]
|
|
8333
11038
|
},
|
|
8334
11039
|
{
|
|
@@ -8435,9 +11140,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8435
11140
|
{
|
|
8436
11141
|
id: "retention",
|
|
8437
11142
|
title: "Retention",
|
|
8438
|
-
description: "How long
|
|
11143
|
+
description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
|
|
8439
11144
|
columns: 3,
|
|
8440
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
|
+
},
|
|
8441
11157
|
{
|
|
8442
11158
|
type: "number",
|
|
8443
11159
|
key: "retentionMotionDays",
|