@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.
- package/README.md +175 -10
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +2585 -60
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/tracking-engine.ts +963 -10
- package/src/main.ts +492 -19
- package/src/models/training.ts +300 -0
- package/src/ui/editor-html.ts +256 -0
- package/src/ui/training-html.ts +1007 -0
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
}
|