@blueharford/scrypted-spatial-awareness 0.2.1 → 0.4.0

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.
@@ -14,7 +14,7 @@ import sdk, {
14
14
  Camera,
15
15
  MediaObject,
16
16
  } from '@scrypted/sdk';
17
- import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
17
+ import { CameraTopology, CameraConnection, CameraNode, CameraZoneMapping, LandmarkType, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
18
18
  import {
19
19
  TrackedObject,
20
20
  ObjectSighting,
@@ -22,6 +22,21 @@ import {
22
22
  CorrelationCandidate,
23
23
  getLastSighting,
24
24
  } from '../models/tracked-object';
25
+ import {
26
+ TrainingSession,
27
+ TrainingSessionState,
28
+ TrainingCameraVisit,
29
+ TrainingTransit,
30
+ TrainingLandmark,
31
+ TrainingOverlap,
32
+ TrainingStructure,
33
+ TrainingConfig,
34
+ TrainingStatusUpdate,
35
+ TrainingApplicationResult,
36
+ DEFAULT_TRAINING_CONFIG,
37
+ createTrainingSession,
38
+ calculateTrainingStats,
39
+ } from '../models/training';
25
40
  import { TrackingState } from '../state/tracking-state';
26
41
  import { AlertManager } from '../alerts/alert-manager';
27
42
  import { ObjectCorrelator } from './object-correlator';
@@ -48,12 +63,43 @@ export interface TrackingEngineConfig {
48
63
  objectAlertCooldown: number;
49
64
  /** Use LLM for enhanced descriptions */
50
65
  useLlmDescriptions: boolean;
66
+ /** LLM rate limit interval (ms) - minimum time between LLM calls */
67
+ llmDebounceInterval?: number;
68
+ /** Whether to fall back to basic notifications when LLM is unavailable or slow */
69
+ llmFallbackEnabled?: boolean;
70
+ /** Timeout for LLM responses (ms) */
71
+ llmFallbackTimeout?: number;
72
+ /** Enable automatic transit time learning from observations */
73
+ enableTransitTimeLearning?: boolean;
74
+ /** Enable automatic camera connection suggestions */
75
+ enableConnectionSuggestions?: boolean;
51
76
  /** Enable landmark learning from AI */
52
77
  enableLandmarkLearning?: boolean;
53
78
  /** Minimum confidence for landmark suggestions */
54
79
  landmarkConfidenceThreshold?: number;
55
80
  }
56
81
 
82
+ /** Observed transit time for learning */
83
+ interface ObservedTransit {
84
+ fromCameraId: string;
85
+ toCameraId: string;
86
+ transitTime: number;
87
+ timestamp: number;
88
+ }
89
+
90
+ /** Suggested camera connection based on observed patterns */
91
+ export interface ConnectionSuggestion {
92
+ id: string;
93
+ fromCameraId: string;
94
+ fromCameraName: string;
95
+ toCameraId: string;
96
+ toCameraName: string;
97
+ observedTransits: ObservedTransit[];
98
+ suggestedTransitTime: { min: number; typical: number; max: number };
99
+ confidence: number;
100
+ timestamp: number;
101
+ }
102
+
57
103
  export class TrackingEngine {
58
104
  private topology: CameraTopology;
59
105
  private state: TrackingState;
@@ -70,6 +116,28 @@ export class TrackingEngine {
70
116
  /** Callback for topology changes (e.g., landmark suggestions) */
71
117
  private onTopologyChange?: (topology: CameraTopology) => void;
72
118
 
119
+ // ==================== LLM Debouncing ====================
120
+ /** Last time LLM was called */
121
+ private lastLlmCallTime: number = 0;
122
+ /** Queue of pending LLM requests (we only keep latest) */
123
+ private llmDebounceTimer: NodeJS.Timeout | null = null;
124
+
125
+ // ==================== Transit Time Learning ====================
126
+ /** Observed transit times for learning */
127
+ private observedTransits: Map<string, ObservedTransit[]> = new Map();
128
+ /** Connection suggestions based on observed patterns */
129
+ private connectionSuggestions: Map<string, ConnectionSuggestion> = new Map();
130
+ /** Minimum observations before suggesting a connection */
131
+ private readonly MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
132
+
133
+ // ==================== Training Mode ====================
134
+ /** Current training session (null if not training) */
135
+ private trainingSession: TrainingSession | null = null;
136
+ /** Training configuration */
137
+ private trainingConfig: TrainingConfig = DEFAULT_TRAINING_CONFIG;
138
+ /** Callback for training status updates */
139
+ private onTrainingStatusUpdate?: (status: TrainingStatusUpdate) => void;
140
+
73
141
  constructor(
74
142
  topology: CameraTopology,
75
143
  state: TrackingState,
@@ -189,6 +257,11 @@ export class TrackingEngine {
189
257
  // Skip low-confidence detections
190
258
  if (detection.score < 0.5) continue;
191
259
 
260
+ // If in training mode, record trainer detections
261
+ if (this.isTrainingActive() && detection.className === 'person') {
262
+ this.recordTrainerDetection(cameraId, detection, detection.score);
263
+ }
264
+
192
265
  // Skip classes we're not tracking on this camera
193
266
  if (camera.trackClasses.length > 0 &&
194
267
  !camera.trackClasses.includes(detection.className)) {
@@ -232,7 +305,20 @@ export class TrackingEngine {
232
305
  this.objectLastAlertTime.set(globalId, Date.now());
233
306
  }
234
307
 
235
- /** Get spatial reasoning result for movement (uses RAG + LLM) */
308
+ /** Check if LLM call is allowed (rate limiting) */
309
+ private isLlmCallAllowed(): boolean {
310
+ const debounceInterval = this.config.llmDebounceInterval || 0;
311
+ if (debounceInterval <= 0) return true;
312
+ const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
313
+ return timeSinceLastCall >= debounceInterval;
314
+ }
315
+
316
+ /** Record that an LLM call was made */
317
+ private recordLlmCall(): void {
318
+ this.lastLlmCallTime = Date.now();
319
+ }
320
+
321
+ /** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
236
322
  private async getSpatialDescription(
237
323
  tracked: TrackedObject,
238
324
  fromCameraId: string,
@@ -240,7 +326,16 @@ export class TrackingEngine {
240
326
  transitTime: number,
241
327
  currentCameraId: string
242
328
  ): Promise<SpatialReasoningResult | null> {
329
+ const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
330
+ const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
331
+
243
332
  try {
333
+ // Check rate limiting - if not allowed, return null to use basic description
334
+ if (!this.isLlmCallAllowed()) {
335
+ this.console.log('LLM rate-limited, using basic notification');
336
+ return null;
337
+ }
338
+
244
339
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
245
340
  let mediaObject: MediaObject | undefined;
246
341
  if (this.config.useLlmDescriptions) {
@@ -250,16 +345,42 @@ export class TrackingEngine {
250
345
  }
251
346
  }
252
347
 
348
+ // Record that we're making an LLM call
349
+ this.recordLlmCall();
350
+
253
351
  // Use spatial reasoning engine for rich context-aware description
254
- const result = await this.spatialReasoning.generateMovementDescription(
255
- tracked,
256
- fromCameraId,
257
- toCameraId,
258
- transitTime,
259
- mediaObject
260
- );
352
+ // Apply timeout if fallback is enabled
353
+ let result: SpatialReasoningResult;
354
+ if (fallbackEnabled && mediaObject) {
355
+ const timeoutPromise = new Promise<SpatialReasoningResult | null>((_, reject) => {
356
+ setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
357
+ });
358
+
359
+ const descriptionPromise = this.spatialReasoning.generateMovementDescription(
360
+ tracked,
361
+ fromCameraId,
362
+ toCameraId,
363
+ transitTime,
364
+ mediaObject
365
+ );
366
+
367
+ try {
368
+ result = await Promise.race([descriptionPromise, timeoutPromise]) as SpatialReasoningResult;
369
+ } catch (timeoutError) {
370
+ this.console.log('LLM timed out, using basic notification');
371
+ return null;
372
+ }
373
+ } else {
374
+ result = await this.spatialReasoning.generateMovementDescription(
375
+ tracked,
376
+ fromCameraId,
377
+ toCameraId,
378
+ transitTime,
379
+ mediaObject
380
+ );
381
+ }
261
382
 
262
- // Optionally trigger landmark learning
383
+ // Optionally trigger landmark learning (background, non-blocking)
263
384
  if (this.config.enableLandmarkLearning && mediaObject) {
264
385
  this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
265
386
  }
@@ -328,6 +449,9 @@ export class TrackingEngine {
328
449
  correlationConfidence: correlation.confidence,
329
450
  });
330
451
 
452
+ // Record for transit time learning
453
+ this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
454
+
331
455
  this.console.log(
332
456
  `Object ${tracked.globalId.slice(0, 8)} transited: ` +
333
457
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
@@ -571,4 +695,833 @@ export class TrackingEngine {
571
695
  getTrackedObject(globalId: GlobalTrackingId): TrackedObject | undefined {
572
696
  return this.state.getObject(globalId);
573
697
  }
698
+
699
+ // ==================== Transit Time Learning ====================
700
+
701
+ /** Record an observed transit time for learning */
702
+ private recordObservedTransit(
703
+ fromCameraId: string,
704
+ toCameraId: string,
705
+ transitTime: number
706
+ ): void {
707
+ if (!this.config.enableTransitTimeLearning) return;
708
+
709
+ const key = `${fromCameraId}->${toCameraId}`;
710
+ const observation: ObservedTransit = {
711
+ fromCameraId,
712
+ toCameraId,
713
+ transitTime,
714
+ timestamp: Date.now(),
715
+ };
716
+
717
+ // Add to observations
718
+ if (!this.observedTransits.has(key)) {
719
+ this.observedTransits.set(key, []);
720
+ }
721
+ const observations = this.observedTransits.get(key)!;
722
+ observations.push(observation);
723
+
724
+ // Keep only last 100 observations per connection
725
+ if (observations.length > 100) {
726
+ observations.shift();
727
+ }
728
+
729
+ // Check if we should update existing connection
730
+ const existingConnection = findConnection(this.topology, fromCameraId, toCameraId);
731
+ if (existingConnection) {
732
+ this.maybeUpdateConnectionTransitTime(existingConnection, observations);
733
+ } else if (this.config.enableConnectionSuggestions) {
734
+ // No existing connection - suggest one
735
+ this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
736
+ }
737
+ }
738
+
739
+ /** Update an existing connection's transit time based on observations */
740
+ private maybeUpdateConnectionTransitTime(
741
+ connection: CameraConnection,
742
+ observations: ObservedTransit[]
743
+ ): void {
744
+ if (observations.length < 5) return; // Need minimum observations
745
+
746
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
747
+
748
+ // Calculate percentiles
749
+ const newMin = times[Math.floor(times.length * 0.1)];
750
+ const newTypical = times[Math.floor(times.length * 0.5)];
751
+ const newMax = times[Math.floor(times.length * 0.9)];
752
+
753
+ // Only update if significantly different (>20% change)
754
+ const currentTypical = connection.transitTime.typical;
755
+ const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
756
+
757
+ if (percentChange > 0.2 && observations.length >= 10) {
758
+ this.console.log(
759
+ `Updating transit time for ${connection.name}: ` +
760
+ `${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`
761
+ );
762
+
763
+ connection.transitTime = {
764
+ min: newMin,
765
+ typical: newTypical,
766
+ max: newMax,
767
+ };
768
+
769
+ // Notify about topology change
770
+ if (this.onTopologyChange) {
771
+ this.onTopologyChange(this.topology);
772
+ }
773
+ }
774
+ }
775
+
776
+ /** Create or update a connection suggestion based on observations */
777
+ private maybeCreateConnectionSuggestion(
778
+ fromCameraId: string,
779
+ toCameraId: string,
780
+ observations: ObservedTransit[]
781
+ ): void {
782
+ if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION) return;
783
+
784
+ const fromCamera = findCamera(this.topology, fromCameraId);
785
+ const toCamera = findCamera(this.topology, toCameraId);
786
+ if (!fromCamera || !toCamera) return;
787
+
788
+ const key = `${fromCameraId}->${toCameraId}`;
789
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
790
+
791
+ // Calculate transit time suggestion
792
+ const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
793
+ const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
794
+ const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
795
+
796
+ // Calculate confidence based on consistency and count
797
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
798
+ const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
799
+ const stdDev = Math.sqrt(variance);
800
+ const coefficientOfVariation = stdDev / avgTime;
801
+
802
+ // Higher confidence with more observations and lower variance
803
+ const countFactor = Math.min(observations.length / 10, 1);
804
+ const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
805
+ const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
806
+
807
+ const suggestion: ConnectionSuggestion = {
808
+ id: `suggest_${key}`,
809
+ fromCameraId,
810
+ fromCameraName: fromCamera.name,
811
+ toCameraId,
812
+ toCameraName: toCamera.name,
813
+ observedTransits: observations.slice(-10), // Keep last 10
814
+ suggestedTransitTime: {
815
+ min: suggestedMin,
816
+ typical: suggestedTypical,
817
+ max: suggestedMax,
818
+ },
819
+ confidence,
820
+ timestamp: Date.now(),
821
+ };
822
+
823
+ this.connectionSuggestions.set(key, suggestion);
824
+
825
+ if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
826
+ this.console.log(
827
+ `New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
828
+ `(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`
829
+ );
830
+ }
831
+ }
832
+
833
+ /** Get pending connection suggestions */
834
+ getConnectionSuggestions(): ConnectionSuggestion[] {
835
+ return Array.from(this.connectionSuggestions.values())
836
+ .filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
837
+ .sort((a, b) => b.confidence - a.confidence);
838
+ }
839
+
840
+ /** Accept a connection suggestion, adding it to topology */
841
+ acceptConnectionSuggestion(suggestionId: string): CameraConnection | null {
842
+ const key = suggestionId.replace('suggest_', '');
843
+ const suggestion = this.connectionSuggestions.get(key);
844
+ if (!suggestion) return null;
845
+
846
+ const fromCamera = findCamera(this.topology, suggestion.fromCameraId);
847
+ const toCamera = findCamera(this.topology, suggestion.toCameraId);
848
+ if (!fromCamera || !toCamera) return null;
849
+
850
+ const connection: CameraConnection = {
851
+ id: `conn-${Date.now()}`,
852
+ fromCameraId: suggestion.fromCameraId,
853
+ toCameraId: suggestion.toCameraId,
854
+ name: `${fromCamera.name} to ${toCamera.name}`,
855
+ exitZone: [],
856
+ entryZone: [],
857
+ transitTime: suggestion.suggestedTransitTime,
858
+ bidirectional: true, // Default to bidirectional
859
+ };
860
+
861
+ this.topology.connections.push(connection);
862
+ this.connectionSuggestions.delete(key);
863
+
864
+ // Notify about topology change
865
+ if (this.onTopologyChange) {
866
+ this.onTopologyChange(this.topology);
867
+ }
868
+
869
+ this.console.log(`Connection accepted: ${connection.name}`);
870
+ return connection;
871
+ }
872
+
873
+ /** Reject a connection suggestion */
874
+ rejectConnectionSuggestion(suggestionId: string): boolean {
875
+ const key = suggestionId.replace('suggest_', '');
876
+ if (!this.connectionSuggestions.has(key)) return false;
877
+ this.connectionSuggestions.delete(key);
878
+ // Also clear observations so it doesn't get re-suggested immediately
879
+ this.observedTransits.delete(key);
880
+ return true;
881
+ }
882
+
883
+ // ==================== Live Tracking State ====================
884
+
885
+ /** Get current state of all tracked objects for live overlay */
886
+ getLiveTrackingState(): {
887
+ objects: Array<{
888
+ globalId: string;
889
+ className: string;
890
+ label?: string;
891
+ lastCameraId: string;
892
+ lastCameraName: string;
893
+ lastSeen: number;
894
+ state: string;
895
+ cameraPosition?: { x: number; y: number };
896
+ }>;
897
+ timestamp: number;
898
+ } {
899
+ const activeObjects = this.state.getActiveObjects();
900
+ const objects = activeObjects.map(tracked => {
901
+ const lastSighting = getLastSighting(tracked);
902
+ const camera = lastSighting ? findCamera(this.topology, lastSighting.cameraId) : null;
903
+
904
+ return {
905
+ globalId: tracked.globalId,
906
+ className: tracked.className,
907
+ label: tracked.label,
908
+ lastCameraId: lastSighting?.cameraId || '',
909
+ lastCameraName: lastSighting?.cameraName || '',
910
+ lastSeen: tracked.lastSeen,
911
+ state: tracked.state,
912
+ cameraPosition: camera?.floorPlanPosition,
913
+ };
914
+ });
915
+
916
+ return {
917
+ objects,
918
+ timestamp: Date.now(),
919
+ };
920
+ }
921
+
922
+ /** Get journey path for visualization */
923
+ getJourneyPath(globalId: GlobalTrackingId): {
924
+ segments: Array<{
925
+ fromCamera: { id: string; name: string; position?: { x: number; y: number } };
926
+ toCamera: { id: string; name: string; position?: { x: number; y: number } };
927
+ transitTime: number;
928
+ timestamp: number;
929
+ }>;
930
+ currentLocation?: { cameraId: string; cameraName: string; position?: { x: number; y: number } };
931
+ } | null {
932
+ const tracked = this.state.getObject(globalId);
933
+ if (!tracked) return null;
934
+
935
+ const segments = tracked.journey.map(j => {
936
+ const fromCamera = findCamera(this.topology, j.fromCameraId);
937
+ const toCamera = findCamera(this.topology, j.toCameraId);
938
+
939
+ return {
940
+ fromCamera: {
941
+ id: j.fromCameraId,
942
+ name: j.fromCameraName,
943
+ position: fromCamera?.floorPlanPosition,
944
+ },
945
+ toCamera: {
946
+ id: j.toCameraId,
947
+ name: j.toCameraName,
948
+ position: toCamera?.floorPlanPosition,
949
+ },
950
+ transitTime: j.transitDuration,
951
+ timestamp: j.entryTime,
952
+ };
953
+ });
954
+
955
+ const lastSighting = getLastSighting(tracked);
956
+ let currentLocation;
957
+ if (lastSighting) {
958
+ const camera = findCamera(this.topology, lastSighting.cameraId);
959
+ currentLocation = {
960
+ cameraId: lastSighting.cameraId,
961
+ cameraName: lastSighting.cameraName,
962
+ position: camera?.floorPlanPosition,
963
+ };
964
+ }
965
+
966
+ return { segments, currentLocation };
967
+ }
968
+
969
+ // ==================== Training Mode Methods ====================
970
+
971
+ /** Set callback for training status updates */
972
+ setTrainingStatusCallback(callback: (status: TrainingStatusUpdate) => void): void {
973
+ this.onTrainingStatusUpdate = callback;
974
+ }
975
+
976
+ /** Get current training session (if any) */
977
+ getTrainingSession(): TrainingSession | null {
978
+ return this.trainingSession;
979
+ }
980
+
981
+ /** Check if training mode is active */
982
+ isTrainingActive(): boolean {
983
+ return this.trainingSession !== null && this.trainingSession.state === 'active';
984
+ }
985
+
986
+ /** Start a new training session */
987
+ startTrainingSession(trainerName?: string, config?: Partial<TrainingConfig>): TrainingSession {
988
+ // End any existing session
989
+ if (this.trainingSession && this.trainingSession.state === 'active') {
990
+ this.endTrainingSession();
991
+ }
992
+
993
+ // Apply custom config
994
+ if (config) {
995
+ this.trainingConfig = { ...DEFAULT_TRAINING_CONFIG, ...config };
996
+ }
997
+
998
+ // Create new session
999
+ this.trainingSession = createTrainingSession(trainerName);
1000
+ this.trainingSession.state = 'active';
1001
+ this.console.log(`Training session started: ${this.trainingSession.id}`);
1002
+
1003
+ this.emitTrainingStatus();
1004
+ return this.trainingSession;
1005
+ }
1006
+
1007
+ /** Pause the current training session */
1008
+ pauseTrainingSession(): boolean {
1009
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
1010
+ return false;
1011
+ }
1012
+
1013
+ this.trainingSession.state = 'paused';
1014
+ this.trainingSession.updatedAt = Date.now();
1015
+ this.console.log('Training session paused');
1016
+ this.emitTrainingStatus();
1017
+ return true;
1018
+ }
1019
+
1020
+ /** Resume a paused training session */
1021
+ resumeTrainingSession(): boolean {
1022
+ if (!this.trainingSession || this.trainingSession.state !== 'paused') {
1023
+ return false;
1024
+ }
1025
+
1026
+ this.trainingSession.state = 'active';
1027
+ this.trainingSession.updatedAt = Date.now();
1028
+ this.console.log('Training session resumed');
1029
+ this.emitTrainingStatus();
1030
+ return true;
1031
+ }
1032
+
1033
+ /** End the current training session */
1034
+ endTrainingSession(): TrainingSession | null {
1035
+ if (!this.trainingSession) {
1036
+ return null;
1037
+ }
1038
+
1039
+ this.trainingSession.state = 'completed';
1040
+ this.trainingSession.completedAt = Date.now();
1041
+ this.trainingSession.updatedAt = Date.now();
1042
+ this.trainingSession.stats = calculateTrainingStats(
1043
+ this.trainingSession,
1044
+ this.topology.cameras.length
1045
+ );
1046
+
1047
+ this.console.log(
1048
+ `Training session completed: ${this.trainingSession.stats.camerasVisited} cameras, ` +
1049
+ `${this.trainingSession.stats.transitsRecorded} transits, ` +
1050
+ `${this.trainingSession.stats.landmarksMarked} landmarks`
1051
+ );
1052
+
1053
+ const session = this.trainingSession;
1054
+ this.emitTrainingStatus();
1055
+ return session;
1056
+ }
1057
+
1058
+ /** Record that trainer was detected on a camera */
1059
+ recordTrainerDetection(
1060
+ cameraId: string,
1061
+ detection: ObjectDetectionResult,
1062
+ detectionConfidence: number
1063
+ ): void {
1064
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
1065
+ return;
1066
+ }
1067
+
1068
+ // Only process person detections during training
1069
+ if (detection.className !== 'person') {
1070
+ return;
1071
+ }
1072
+
1073
+ // Check confidence threshold
1074
+ if (detectionConfidence < this.trainingConfig.minDetectionConfidence) {
1075
+ return;
1076
+ }
1077
+
1078
+ const camera = findCamera(this.topology, cameraId);
1079
+ const cameraName = camera?.name || cameraId;
1080
+ const now = Date.now();
1081
+
1082
+ // Check if this is a new camera or same camera
1083
+ if (this.trainingSession.currentCameraId === cameraId) {
1084
+ // Update existing visit
1085
+ const currentVisit = this.trainingSession.visits.find(
1086
+ v => v.cameraId === cameraId && v.departedAt === null
1087
+ );
1088
+ if (currentVisit) {
1089
+ currentVisit.detectionConfidence = Math.max(currentVisit.detectionConfidence, detectionConfidence);
1090
+ if (detection.boundingBox) {
1091
+ currentVisit.boundingBox = detection.boundingBox;
1092
+ }
1093
+ }
1094
+ } else {
1095
+ // This is a new camera - check for transition
1096
+ if (this.trainingSession.currentCameraId && this.trainingSession.transitStartTime) {
1097
+ // Complete the transit
1098
+ const transitDuration = now - this.trainingSession.transitStartTime;
1099
+ const fromCameraId = this.trainingSession.previousCameraId || this.trainingSession.currentCameraId;
1100
+ const fromCamera = findCamera(this.topology, fromCameraId);
1101
+
1102
+ // Mark departure from previous camera
1103
+ const prevVisit = this.trainingSession.visits.find(
1104
+ v => v.cameraId === fromCameraId && v.departedAt === null
1105
+ );
1106
+ if (prevVisit) {
1107
+ prevVisit.departedAt = this.trainingSession.transitStartTime;
1108
+ }
1109
+
1110
+ // Check for overlap (both cameras detecting at same time)
1111
+ const hasOverlap = this.checkTrainingOverlap(fromCameraId, cameraId, now);
1112
+
1113
+ // Record the transit
1114
+ const transit: TrainingTransit = {
1115
+ id: `transit-${now}`,
1116
+ fromCameraId,
1117
+ toCameraId: cameraId,
1118
+ startTime: this.trainingSession.transitStartTime,
1119
+ endTime: now,
1120
+ transitSeconds: Math.round(transitDuration / 1000),
1121
+ hasOverlap,
1122
+ };
1123
+ this.trainingSession.transits.push(transit);
1124
+
1125
+ this.console.log(
1126
+ `Training transit: ${fromCamera?.name || fromCameraId} → ${cameraName} ` +
1127
+ `(${transit.transitSeconds}s${hasOverlap ? ', overlap detected' : ''})`
1128
+ );
1129
+
1130
+ // If overlap detected, record it
1131
+ if (hasOverlap && this.trainingConfig.autoDetectOverlaps) {
1132
+ this.recordTrainingOverlap(fromCameraId, cameraId);
1133
+ }
1134
+ }
1135
+
1136
+ // Record new camera visit
1137
+ const visit: TrainingCameraVisit = {
1138
+ cameraId,
1139
+ cameraName,
1140
+ arrivedAt: now,
1141
+ departedAt: null,
1142
+ trainerEmbedding: detection.embedding,
1143
+ detectionConfidence,
1144
+ boundingBox: detection.boundingBox,
1145
+ floorPlanPosition: camera?.floorPlanPosition,
1146
+ };
1147
+ this.trainingSession.visits.push(visit);
1148
+
1149
+ // Update session state
1150
+ this.trainingSession.previousCameraId = this.trainingSession.currentCameraId;
1151
+ this.trainingSession.currentCameraId = cameraId;
1152
+ this.trainingSession.transitStartTime = now;
1153
+
1154
+ // Store trainer embedding if not already captured
1155
+ if (!this.trainingSession.trainerEmbedding && detection.embedding) {
1156
+ this.trainingSession.trainerEmbedding = detection.embedding;
1157
+ }
1158
+ }
1159
+
1160
+ this.trainingSession.updatedAt = now;
1161
+ this.trainingSession.stats = calculateTrainingStats(
1162
+ this.trainingSession,
1163
+ this.topology.cameras.length
1164
+ );
1165
+ this.emitTrainingStatus();
1166
+ }
1167
+
1168
+ /** Check if there's overlap between two cameras during training */
1169
+ private checkTrainingOverlap(fromCameraId: string, toCameraId: string, now: number): boolean {
1170
+ // Check if both cameras have recent visits overlapping in time
1171
+ const fromVisit = this.trainingSession?.visits.find(
1172
+ v => v.cameraId === fromCameraId &&
1173
+ (v.departedAt === null || v.departedAt > now - 5000) // Within 5 seconds
1174
+ );
1175
+ const toVisit = this.trainingSession?.visits.find(
1176
+ v => v.cameraId === toCameraId &&
1177
+ v.arrivedAt <= now &&
1178
+ v.arrivedAt >= now - 5000 // Arrived within last 5 seconds
1179
+ );
1180
+
1181
+ return !!(fromVisit && toVisit);
1182
+ }
1183
+
1184
+ /** Record a camera overlap detected during training */
1185
+ private recordTrainingOverlap(camera1Id: string, camera2Id: string): void {
1186
+ if (!this.trainingSession) return;
1187
+
1188
+ // Check if we already have this overlap
1189
+ const existingOverlap = this.trainingSession.overlaps.find(
1190
+ o => (o.camera1Id === camera1Id && o.camera2Id === camera2Id) ||
1191
+ (o.camera1Id === camera2Id && o.camera2Id === camera1Id)
1192
+ );
1193
+ if (existingOverlap) return;
1194
+
1195
+ const camera1 = findCamera(this.topology, camera1Id);
1196
+ const camera2 = findCamera(this.topology, camera2Id);
1197
+
1198
+ // Calculate approximate position (midpoint of both camera positions)
1199
+ let position = { x: 50, y: 50 };
1200
+ if (camera1?.floorPlanPosition && camera2?.floorPlanPosition) {
1201
+ position = {
1202
+ x: (camera1.floorPlanPosition.x + camera2.floorPlanPosition.x) / 2,
1203
+ y: (camera1.floorPlanPosition.y + camera2.floorPlanPosition.y) / 2,
1204
+ };
1205
+ }
1206
+
1207
+ const overlap: TrainingOverlap = {
1208
+ id: `overlap-${Date.now()}`,
1209
+ camera1Id,
1210
+ camera2Id,
1211
+ position,
1212
+ radius: 30, // Default radius
1213
+ markedAt: Date.now(),
1214
+ };
1215
+ this.trainingSession.overlaps.push(overlap);
1216
+
1217
+ this.console.log(`Camera overlap detected: ${camera1?.name} ↔ ${camera2?.name}`);
1218
+ }
1219
+
1220
+ /** Manually mark a landmark during training */
1221
+ markTrainingLandmark(landmark: Omit<TrainingLandmark, 'id' | 'markedAt'>): TrainingLandmark | null {
1222
+ if (!this.trainingSession) return null;
1223
+
1224
+ const newLandmark: TrainingLandmark = {
1225
+ ...landmark,
1226
+ id: `landmark-${Date.now()}`,
1227
+ markedAt: Date.now(),
1228
+ };
1229
+ this.trainingSession.landmarks.push(newLandmark);
1230
+ this.trainingSession.updatedAt = Date.now();
1231
+ this.trainingSession.stats = calculateTrainingStats(
1232
+ this.trainingSession,
1233
+ this.topology.cameras.length
1234
+ );
1235
+
1236
+ this.console.log(`Landmark marked: ${newLandmark.name} (${newLandmark.type})`);
1237
+ this.emitTrainingStatus();
1238
+ return newLandmark;
1239
+ }
1240
+
1241
+ /** Manually mark a structure during training */
1242
+ markTrainingStructure(structure: Omit<TrainingStructure, 'id' | 'markedAt'>): TrainingStructure | null {
1243
+ if (!this.trainingSession) return null;
1244
+
1245
+ const newStructure: TrainingStructure = {
1246
+ ...structure,
1247
+ id: `structure-${Date.now()}`,
1248
+ markedAt: Date.now(),
1249
+ };
1250
+ this.trainingSession.structures.push(newStructure);
1251
+ this.trainingSession.updatedAt = Date.now();
1252
+ this.trainingSession.stats = calculateTrainingStats(
1253
+ this.trainingSession,
1254
+ this.topology.cameras.length
1255
+ );
1256
+
1257
+ this.console.log(`Structure marked: ${newStructure.name} (${newStructure.type})`);
1258
+ this.emitTrainingStatus();
1259
+ return newStructure;
1260
+ }
1261
+
1262
+ /** Confirm camera position on floor plan during training */
1263
+ confirmCameraPosition(cameraId: string, position: { x: number; y: number }): boolean {
1264
+ if (!this.trainingSession) return false;
1265
+
1266
+ // Update in current session
1267
+ const visit = this.trainingSession.visits.find(v => v.cameraId === cameraId);
1268
+ if (visit) {
1269
+ visit.floorPlanPosition = position;
1270
+ }
1271
+
1272
+ // Update in topology
1273
+ const camera = findCamera(this.topology, cameraId);
1274
+ if (camera) {
1275
+ camera.floorPlanPosition = position;
1276
+ if (this.onTopologyChange) {
1277
+ this.onTopologyChange(this.topology);
1278
+ }
1279
+ }
1280
+
1281
+ this.trainingSession.updatedAt = Date.now();
1282
+ this.emitTrainingStatus();
1283
+ return true;
1284
+ }
1285
+
1286
+ /** Get training status for UI updates */
1287
+ getTrainingStatus(): TrainingStatusUpdate | null {
1288
+ if (!this.trainingSession) return null;
1289
+
1290
+ const currentCamera = this.trainingSession.currentCameraId
1291
+ ? findCamera(this.topology, this.trainingSession.currentCameraId)
1292
+ : null;
1293
+
1294
+ const previousCamera = this.trainingSession.previousCameraId
1295
+ ? findCamera(this.topology, this.trainingSession.previousCameraId)
1296
+ : null;
1297
+
1298
+ // Generate suggestions for next actions
1299
+ const suggestions: string[] = [];
1300
+ const visitedCameras = new Set(this.trainingSession.visits.map(v => v.cameraId));
1301
+ const unvisitedCameras = this.topology.cameras.filter(c => !visitedCameras.has(c.deviceId));
1302
+
1303
+ if (unvisitedCameras.length > 0) {
1304
+ // Suggest nearest unvisited camera based on connections
1305
+ const currentConnections = currentCamera
1306
+ ? findConnectionsFrom(this.topology, currentCamera.deviceId)
1307
+ : [];
1308
+ const connectedUnvisited = currentConnections
1309
+ .map(c => c.toCameraId)
1310
+ .filter(id => !visitedCameras.has(id));
1311
+
1312
+ if (connectedUnvisited.length > 0) {
1313
+ const nextCam = findCamera(this.topology, connectedUnvisited[0]);
1314
+ if (nextCam) {
1315
+ suggestions.push(`Walk to ${nextCam.name}`);
1316
+ }
1317
+ } else {
1318
+ suggestions.push(`${unvisitedCameras.length} cameras not yet visited`);
1319
+ }
1320
+ }
1321
+
1322
+ if (this.trainingSession.visits.length >= 2 && this.trainingSession.landmarks.length === 0) {
1323
+ suggestions.push('Consider marking some landmarks');
1324
+ }
1325
+
1326
+ if (visitedCameras.size >= this.topology.cameras.length) {
1327
+ suggestions.push('All cameras visited! You can end training.');
1328
+ }
1329
+
1330
+ const status: TrainingStatusUpdate = {
1331
+ sessionId: this.trainingSession.id,
1332
+ state: this.trainingSession.state,
1333
+ currentCamera: currentCamera ? {
1334
+ id: currentCamera.deviceId,
1335
+ name: currentCamera.name,
1336
+ detectedAt: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.arrivedAt || Date.now(),
1337
+ confidence: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.detectionConfidence || 0,
1338
+ } : undefined,
1339
+ activeTransit: this.trainingSession.transitStartTime && previousCamera ? {
1340
+ fromCameraId: previousCamera.deviceId,
1341
+ fromCameraName: previousCamera.name,
1342
+ startTime: this.trainingSession.transitStartTime,
1343
+ elapsedSeconds: Math.round((Date.now() - this.trainingSession.transitStartTime) / 1000),
1344
+ } : undefined,
1345
+ stats: this.trainingSession.stats,
1346
+ suggestions,
1347
+ };
1348
+
1349
+ return status;
1350
+ }
1351
+
1352
+ /** Emit training status update to callback */
1353
+ private emitTrainingStatus(): void {
1354
+ if (this.onTrainingStatusUpdate) {
1355
+ const status = this.getTrainingStatus();
1356
+ if (status) {
1357
+ this.onTrainingStatusUpdate(status);
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ /** Apply training results to topology */
1363
+ applyTrainingToTopology(): TrainingApplicationResult {
1364
+ const result: TrainingApplicationResult = {
1365
+ camerasAdded: 0,
1366
+ connectionsCreated: 0,
1367
+ connectionsUpdated: 0,
1368
+ landmarksAdded: 0,
1369
+ zonesCreated: 0,
1370
+ warnings: [],
1371
+ success: false,
1372
+ };
1373
+
1374
+ if (!this.trainingSession) {
1375
+ result.warnings.push('No training session to apply');
1376
+ return result;
1377
+ }
1378
+
1379
+ try {
1380
+ // 1. Update camera positions from training visits
1381
+ for (const visit of this.trainingSession.visits) {
1382
+ const camera = findCamera(this.topology, visit.cameraId);
1383
+ if (camera && visit.floorPlanPosition) {
1384
+ if (!camera.floorPlanPosition) {
1385
+ camera.floorPlanPosition = visit.floorPlanPosition;
1386
+ result.camerasAdded++;
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ // 2. Create or update connections from training transits
1392
+ for (const transit of this.trainingSession.transits) {
1393
+ const existingConnection = findConnection(
1394
+ this.topology,
1395
+ transit.fromCameraId,
1396
+ transit.toCameraId
1397
+ );
1398
+
1399
+ if (existingConnection) {
1400
+ // Update existing connection with observed transit time
1401
+ const transitMs = transit.transitSeconds * 1000;
1402
+ existingConnection.transitTime = {
1403
+ min: Math.min(existingConnection.transitTime.min, transitMs * 0.7),
1404
+ typical: transitMs,
1405
+ max: Math.max(existingConnection.transitTime.max, transitMs * 1.3),
1406
+ };
1407
+ result.connectionsUpdated++;
1408
+ } else {
1409
+ // Create new connection
1410
+ const fromCamera = findCamera(this.topology, transit.fromCameraId);
1411
+ const toCamera = findCamera(this.topology, transit.toCameraId);
1412
+
1413
+ if (fromCamera && toCamera) {
1414
+ const transitMs = transit.transitSeconds * 1000;
1415
+ const newConnection: CameraConnection = {
1416
+ id: `conn-training-${Date.now()}-${result.connectionsCreated}`,
1417
+ fromCameraId: transit.fromCameraId,
1418
+ toCameraId: transit.toCameraId,
1419
+ name: `${fromCamera.name} to ${toCamera.name}`,
1420
+ exitZone: [], // Will be refined in topology editor
1421
+ entryZone: [], // Will be refined in topology editor
1422
+ transitTime: {
1423
+ min: transitMs * 0.7,
1424
+ typical: transitMs,
1425
+ max: transitMs * 1.3,
1426
+ },
1427
+ bidirectional: true,
1428
+ };
1429
+ this.topology.connections.push(newConnection);
1430
+ result.connectionsCreated++;
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ // 3. Add landmarks from training
1436
+ for (const trainLandmark of this.trainingSession.landmarks) {
1437
+ // Map training landmark type to topology landmark type
1438
+ const typeMapping: Record<string, LandmarkType> = {
1439
+ mailbox: 'feature',
1440
+ garage: 'structure',
1441
+ shed: 'structure',
1442
+ tree: 'feature',
1443
+ gate: 'access',
1444
+ door: 'access',
1445
+ driveway: 'access',
1446
+ pathway: 'access',
1447
+ garden: 'feature',
1448
+ pool: 'feature',
1449
+ deck: 'structure',
1450
+ patio: 'structure',
1451
+ other: 'feature',
1452
+ };
1453
+
1454
+ // Convert training landmark to topology landmark
1455
+ const landmark: Landmark = {
1456
+ id: trainLandmark.id,
1457
+ name: trainLandmark.name,
1458
+ type: typeMapping[trainLandmark.type] || 'feature',
1459
+ position: trainLandmark.position,
1460
+ visibleFromCameras: trainLandmark.visibleFromCameras.length > 0
1461
+ ? trainLandmark.visibleFromCameras
1462
+ : undefined,
1463
+ description: trainLandmark.description,
1464
+ };
1465
+
1466
+ if (!this.topology.landmarks) {
1467
+ this.topology.landmarks = [];
1468
+ }
1469
+ this.topology.landmarks.push(landmark);
1470
+ result.landmarksAdded++;
1471
+ }
1472
+
1473
+ // 4. Create zones from overlaps
1474
+ for (const overlap of this.trainingSession.overlaps) {
1475
+ const camera1 = findCamera(this.topology, overlap.camera1Id);
1476
+ const camera2 = findCamera(this.topology, overlap.camera2Id);
1477
+
1478
+ if (camera1 && camera2) {
1479
+ // Create global zone for overlap area
1480
+ const zoneName = `${camera1.name}/${camera2.name} Overlap`;
1481
+ const existingZone = this.topology.globalZones?.find(z => z.name === zoneName);
1482
+
1483
+ if (!existingZone) {
1484
+ if (!this.topology.globalZones) {
1485
+ this.topology.globalZones = [];
1486
+ }
1487
+
1488
+ // Create camera zone mappings (placeholder zones to be refined in editor)
1489
+ const cameraZones: CameraZoneMapping[] = [
1490
+ { cameraId: overlap.camera1Id, zone: [] },
1491
+ { cameraId: overlap.camera2Id, zone: [] },
1492
+ ];
1493
+
1494
+ this.topology.globalZones.push({
1495
+ id: `zone-overlap-${overlap.id}`,
1496
+ name: zoneName,
1497
+ type: 'dwell', // Overlap zones are good for tracking dwell time
1498
+ cameraZones,
1499
+ });
1500
+ result.zonesCreated++;
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ // Notify about topology change
1506
+ if (this.onTopologyChange) {
1507
+ this.onTopologyChange(this.topology);
1508
+ }
1509
+
1510
+ result.success = true;
1511
+ this.console.log(
1512
+ `Training applied: ${result.connectionsCreated} connections created, ` +
1513
+ `${result.connectionsUpdated} updated, ${result.landmarksAdded} landmarks added`
1514
+ );
1515
+ } catch (e) {
1516
+ result.warnings.push(`Error applying training: ${e}`);
1517
+ }
1518
+
1519
+ return result;
1520
+ }
1521
+
1522
+ /** Clear the current training session without applying */
1523
+ clearTrainingSession(): void {
1524
+ this.trainingSession = null;
1525
+ this.emitTrainingStatus();
1526
+ }
574
1527
  }