@blueharford/scrypted-spatial-awareness 0.2.0 → 0.3.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 +83 -9
- 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 +684 -9
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/spatial-reasoning.ts +1 -1
- package/src/core/tracking-engine.ts +376 -10
- package/src/main.ts +175 -1
- package/src/models/topology.ts +4 -4
- package/src/ui/editor-html.ts +256 -0
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -116,7 +116,7 @@ export class SpatialReasoningEngine {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// Landmark contexts
|
|
119
|
-
for (const landmark of this.topology.landmarks) {
|
|
119
|
+
for (const landmark of this.topology.landmarks || []) {
|
|
120
120
|
this.contextChunks.push({
|
|
121
121
|
id: `landmark_${landmark.id}`,
|
|
122
122
|
type: 'landmark',
|
|
@@ -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, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
|
|
18
18
|
import {
|
|
19
19
|
TrackedObject,
|
|
20
20
|
ObjectSighting,
|
|
@@ -48,12 +48,43 @@ export interface TrackingEngineConfig {
|
|
|
48
48
|
objectAlertCooldown: number;
|
|
49
49
|
/** Use LLM for enhanced descriptions */
|
|
50
50
|
useLlmDescriptions: boolean;
|
|
51
|
+
/** LLM rate limit interval (ms) - minimum time between LLM calls */
|
|
52
|
+
llmDebounceInterval?: number;
|
|
53
|
+
/** Whether to fall back to basic notifications when LLM is unavailable or slow */
|
|
54
|
+
llmFallbackEnabled?: boolean;
|
|
55
|
+
/** Timeout for LLM responses (ms) */
|
|
56
|
+
llmFallbackTimeout?: number;
|
|
57
|
+
/** Enable automatic transit time learning from observations */
|
|
58
|
+
enableTransitTimeLearning?: boolean;
|
|
59
|
+
/** Enable automatic camera connection suggestions */
|
|
60
|
+
enableConnectionSuggestions?: boolean;
|
|
51
61
|
/** Enable landmark learning from AI */
|
|
52
62
|
enableLandmarkLearning?: boolean;
|
|
53
63
|
/** Minimum confidence for landmark suggestions */
|
|
54
64
|
landmarkConfidenceThreshold?: number;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** Observed transit time for learning */
|
|
68
|
+
interface ObservedTransit {
|
|
69
|
+
fromCameraId: string;
|
|
70
|
+
toCameraId: string;
|
|
71
|
+
transitTime: number;
|
|
72
|
+
timestamp: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Suggested camera connection based on observed patterns */
|
|
76
|
+
export interface ConnectionSuggestion {
|
|
77
|
+
id: string;
|
|
78
|
+
fromCameraId: string;
|
|
79
|
+
fromCameraName: string;
|
|
80
|
+
toCameraId: string;
|
|
81
|
+
toCameraName: string;
|
|
82
|
+
observedTransits: ObservedTransit[];
|
|
83
|
+
suggestedTransitTime: { min: number; typical: number; max: number };
|
|
84
|
+
confidence: number;
|
|
85
|
+
timestamp: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
export class TrackingEngine {
|
|
58
89
|
private topology: CameraTopology;
|
|
59
90
|
private state: TrackingState;
|
|
@@ -70,6 +101,20 @@ export class TrackingEngine {
|
|
|
70
101
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
71
102
|
private onTopologyChange?: (topology: CameraTopology) => void;
|
|
72
103
|
|
|
104
|
+
// ==================== LLM Debouncing ====================
|
|
105
|
+
/** Last time LLM was called */
|
|
106
|
+
private lastLlmCallTime: number = 0;
|
|
107
|
+
/** Queue of pending LLM requests (we only keep latest) */
|
|
108
|
+
private llmDebounceTimer: NodeJS.Timeout | null = null;
|
|
109
|
+
|
|
110
|
+
// ==================== Transit Time Learning ====================
|
|
111
|
+
/** Observed transit times for learning */
|
|
112
|
+
private observedTransits: Map<string, ObservedTransit[]> = new Map();
|
|
113
|
+
/** Connection suggestions based on observed patterns */
|
|
114
|
+
private connectionSuggestions: Map<string, ConnectionSuggestion> = new Map();
|
|
115
|
+
/** Minimum observations before suggesting a connection */
|
|
116
|
+
private readonly MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
|
|
117
|
+
|
|
73
118
|
constructor(
|
|
74
119
|
topology: CameraTopology,
|
|
75
120
|
state: TrackingState,
|
|
@@ -232,7 +277,20 @@ export class TrackingEngine {
|
|
|
232
277
|
this.objectLastAlertTime.set(globalId, Date.now());
|
|
233
278
|
}
|
|
234
279
|
|
|
235
|
-
/**
|
|
280
|
+
/** Check if LLM call is allowed (rate limiting) */
|
|
281
|
+
private isLlmCallAllowed(): boolean {
|
|
282
|
+
const debounceInterval = this.config.llmDebounceInterval || 0;
|
|
283
|
+
if (debounceInterval <= 0) return true;
|
|
284
|
+
const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
|
|
285
|
+
return timeSinceLastCall >= debounceInterval;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Record that an LLM call was made */
|
|
289
|
+
private recordLlmCall(): void {
|
|
290
|
+
this.lastLlmCallTime = Date.now();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
|
|
236
294
|
private async getSpatialDescription(
|
|
237
295
|
tracked: TrackedObject,
|
|
238
296
|
fromCameraId: string,
|
|
@@ -240,7 +298,16 @@ export class TrackingEngine {
|
|
|
240
298
|
transitTime: number,
|
|
241
299
|
currentCameraId: string
|
|
242
300
|
): Promise<SpatialReasoningResult | null> {
|
|
301
|
+
const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
|
|
302
|
+
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
303
|
+
|
|
243
304
|
try {
|
|
305
|
+
// Check rate limiting - if not allowed, return null to use basic description
|
|
306
|
+
if (!this.isLlmCallAllowed()) {
|
|
307
|
+
this.console.log('LLM rate-limited, using basic notification');
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
244
311
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
245
312
|
let mediaObject: MediaObject | undefined;
|
|
246
313
|
if (this.config.useLlmDescriptions) {
|
|
@@ -250,16 +317,42 @@ export class TrackingEngine {
|
|
|
250
317
|
}
|
|
251
318
|
}
|
|
252
319
|
|
|
320
|
+
// Record that we're making an LLM call
|
|
321
|
+
this.recordLlmCall();
|
|
322
|
+
|
|
253
323
|
// Use spatial reasoning engine for rich context-aware description
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
324
|
+
// Apply timeout if fallback is enabled
|
|
325
|
+
let result: SpatialReasoningResult;
|
|
326
|
+
if (fallbackEnabled && mediaObject) {
|
|
327
|
+
const timeoutPromise = new Promise<SpatialReasoningResult | null>((_, reject) => {
|
|
328
|
+
setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const descriptionPromise = this.spatialReasoning.generateMovementDescription(
|
|
332
|
+
tracked,
|
|
333
|
+
fromCameraId,
|
|
334
|
+
toCameraId,
|
|
335
|
+
transitTime,
|
|
336
|
+
mediaObject
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
result = await Promise.race([descriptionPromise, timeoutPromise]) as SpatialReasoningResult;
|
|
341
|
+
} catch (timeoutError) {
|
|
342
|
+
this.console.log('LLM timed out, using basic notification');
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
result = await this.spatialReasoning.generateMovementDescription(
|
|
347
|
+
tracked,
|
|
348
|
+
fromCameraId,
|
|
349
|
+
toCameraId,
|
|
350
|
+
transitTime,
|
|
351
|
+
mediaObject
|
|
352
|
+
);
|
|
353
|
+
}
|
|
261
354
|
|
|
262
|
-
// Optionally trigger landmark learning
|
|
355
|
+
// Optionally trigger landmark learning (background, non-blocking)
|
|
263
356
|
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
264
357
|
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
265
358
|
}
|
|
@@ -328,6 +421,9 @@ export class TrackingEngine {
|
|
|
328
421
|
correlationConfidence: correlation.confidence,
|
|
329
422
|
});
|
|
330
423
|
|
|
424
|
+
// Record for transit time learning
|
|
425
|
+
this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
|
|
426
|
+
|
|
331
427
|
this.console.log(
|
|
332
428
|
`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
333
429
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
@@ -571,4 +667,274 @@ export class TrackingEngine {
|
|
|
571
667
|
getTrackedObject(globalId: GlobalTrackingId): TrackedObject | undefined {
|
|
572
668
|
return this.state.getObject(globalId);
|
|
573
669
|
}
|
|
670
|
+
|
|
671
|
+
// ==================== Transit Time Learning ====================
|
|
672
|
+
|
|
673
|
+
/** Record an observed transit time for learning */
|
|
674
|
+
private recordObservedTransit(
|
|
675
|
+
fromCameraId: string,
|
|
676
|
+
toCameraId: string,
|
|
677
|
+
transitTime: number
|
|
678
|
+
): void {
|
|
679
|
+
if (!this.config.enableTransitTimeLearning) return;
|
|
680
|
+
|
|
681
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
682
|
+
const observation: ObservedTransit = {
|
|
683
|
+
fromCameraId,
|
|
684
|
+
toCameraId,
|
|
685
|
+
transitTime,
|
|
686
|
+
timestamp: Date.now(),
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// Add to observations
|
|
690
|
+
if (!this.observedTransits.has(key)) {
|
|
691
|
+
this.observedTransits.set(key, []);
|
|
692
|
+
}
|
|
693
|
+
const observations = this.observedTransits.get(key)!;
|
|
694
|
+
observations.push(observation);
|
|
695
|
+
|
|
696
|
+
// Keep only last 100 observations per connection
|
|
697
|
+
if (observations.length > 100) {
|
|
698
|
+
observations.shift();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Check if we should update existing connection
|
|
702
|
+
const existingConnection = findConnection(this.topology, fromCameraId, toCameraId);
|
|
703
|
+
if (existingConnection) {
|
|
704
|
+
this.maybeUpdateConnectionTransitTime(existingConnection, observations);
|
|
705
|
+
} else if (this.config.enableConnectionSuggestions) {
|
|
706
|
+
// No existing connection - suggest one
|
|
707
|
+
this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Update an existing connection's transit time based on observations */
|
|
712
|
+
private maybeUpdateConnectionTransitTime(
|
|
713
|
+
connection: CameraConnection,
|
|
714
|
+
observations: ObservedTransit[]
|
|
715
|
+
): void {
|
|
716
|
+
if (observations.length < 5) return; // Need minimum observations
|
|
717
|
+
|
|
718
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
719
|
+
|
|
720
|
+
// Calculate percentiles
|
|
721
|
+
const newMin = times[Math.floor(times.length * 0.1)];
|
|
722
|
+
const newTypical = times[Math.floor(times.length * 0.5)];
|
|
723
|
+
const newMax = times[Math.floor(times.length * 0.9)];
|
|
724
|
+
|
|
725
|
+
// Only update if significantly different (>20% change)
|
|
726
|
+
const currentTypical = connection.transitTime.typical;
|
|
727
|
+
const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
|
|
728
|
+
|
|
729
|
+
if (percentChange > 0.2 && observations.length >= 10) {
|
|
730
|
+
this.console.log(
|
|
731
|
+
`Updating transit time for ${connection.name}: ` +
|
|
732
|
+
`${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
connection.transitTime = {
|
|
736
|
+
min: newMin,
|
|
737
|
+
typical: newTypical,
|
|
738
|
+
max: newMax,
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// Notify about topology change
|
|
742
|
+
if (this.onTopologyChange) {
|
|
743
|
+
this.onTopologyChange(this.topology);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Create or update a connection suggestion based on observations */
|
|
749
|
+
private maybeCreateConnectionSuggestion(
|
|
750
|
+
fromCameraId: string,
|
|
751
|
+
toCameraId: string,
|
|
752
|
+
observations: ObservedTransit[]
|
|
753
|
+
): void {
|
|
754
|
+
if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION) return;
|
|
755
|
+
|
|
756
|
+
const fromCamera = findCamera(this.topology, fromCameraId);
|
|
757
|
+
const toCamera = findCamera(this.topology, toCameraId);
|
|
758
|
+
if (!fromCamera || !toCamera) return;
|
|
759
|
+
|
|
760
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
761
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
762
|
+
|
|
763
|
+
// Calculate transit time suggestion
|
|
764
|
+
const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
|
|
765
|
+
const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
|
|
766
|
+
const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
|
|
767
|
+
|
|
768
|
+
// Calculate confidence based on consistency and count
|
|
769
|
+
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
|
770
|
+
const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
|
|
771
|
+
const stdDev = Math.sqrt(variance);
|
|
772
|
+
const coefficientOfVariation = stdDev / avgTime;
|
|
773
|
+
|
|
774
|
+
// Higher confidence with more observations and lower variance
|
|
775
|
+
const countFactor = Math.min(observations.length / 10, 1);
|
|
776
|
+
const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
|
|
777
|
+
const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
|
|
778
|
+
|
|
779
|
+
const suggestion: ConnectionSuggestion = {
|
|
780
|
+
id: `suggest_${key}`,
|
|
781
|
+
fromCameraId,
|
|
782
|
+
fromCameraName: fromCamera.name,
|
|
783
|
+
toCameraId,
|
|
784
|
+
toCameraName: toCamera.name,
|
|
785
|
+
observedTransits: observations.slice(-10), // Keep last 10
|
|
786
|
+
suggestedTransitTime: {
|
|
787
|
+
min: suggestedMin,
|
|
788
|
+
typical: suggestedTypical,
|
|
789
|
+
max: suggestedMax,
|
|
790
|
+
},
|
|
791
|
+
confidence,
|
|
792
|
+
timestamp: Date.now(),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
this.connectionSuggestions.set(key, suggestion);
|
|
796
|
+
|
|
797
|
+
if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
|
|
798
|
+
this.console.log(
|
|
799
|
+
`New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
|
|
800
|
+
`(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Get pending connection suggestions */
|
|
806
|
+
getConnectionSuggestions(): ConnectionSuggestion[] {
|
|
807
|
+
return Array.from(this.connectionSuggestions.values())
|
|
808
|
+
.filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
|
|
809
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Accept a connection suggestion, adding it to topology */
|
|
813
|
+
acceptConnectionSuggestion(suggestionId: string): CameraConnection | null {
|
|
814
|
+
const key = suggestionId.replace('suggest_', '');
|
|
815
|
+
const suggestion = this.connectionSuggestions.get(key);
|
|
816
|
+
if (!suggestion) return null;
|
|
817
|
+
|
|
818
|
+
const fromCamera = findCamera(this.topology, suggestion.fromCameraId);
|
|
819
|
+
const toCamera = findCamera(this.topology, suggestion.toCameraId);
|
|
820
|
+
if (!fromCamera || !toCamera) return null;
|
|
821
|
+
|
|
822
|
+
const connection: CameraConnection = {
|
|
823
|
+
id: `conn-${Date.now()}`,
|
|
824
|
+
fromCameraId: suggestion.fromCameraId,
|
|
825
|
+
toCameraId: suggestion.toCameraId,
|
|
826
|
+
name: `${fromCamera.name} to ${toCamera.name}`,
|
|
827
|
+
exitZone: [],
|
|
828
|
+
entryZone: [],
|
|
829
|
+
transitTime: suggestion.suggestedTransitTime,
|
|
830
|
+
bidirectional: true, // Default to bidirectional
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
this.topology.connections.push(connection);
|
|
834
|
+
this.connectionSuggestions.delete(key);
|
|
835
|
+
|
|
836
|
+
// Notify about topology change
|
|
837
|
+
if (this.onTopologyChange) {
|
|
838
|
+
this.onTopologyChange(this.topology);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
this.console.log(`Connection accepted: ${connection.name}`);
|
|
842
|
+
return connection;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Reject a connection suggestion */
|
|
846
|
+
rejectConnectionSuggestion(suggestionId: string): boolean {
|
|
847
|
+
const key = suggestionId.replace('suggest_', '');
|
|
848
|
+
if (!this.connectionSuggestions.has(key)) return false;
|
|
849
|
+
this.connectionSuggestions.delete(key);
|
|
850
|
+
// Also clear observations so it doesn't get re-suggested immediately
|
|
851
|
+
this.observedTransits.delete(key);
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ==================== Live Tracking State ====================
|
|
856
|
+
|
|
857
|
+
/** Get current state of all tracked objects for live overlay */
|
|
858
|
+
getLiveTrackingState(): {
|
|
859
|
+
objects: Array<{
|
|
860
|
+
globalId: string;
|
|
861
|
+
className: string;
|
|
862
|
+
label?: string;
|
|
863
|
+
lastCameraId: string;
|
|
864
|
+
lastCameraName: string;
|
|
865
|
+
lastSeen: number;
|
|
866
|
+
state: string;
|
|
867
|
+
cameraPosition?: { x: number; y: number };
|
|
868
|
+
}>;
|
|
869
|
+
timestamp: number;
|
|
870
|
+
} {
|
|
871
|
+
const activeObjects = this.state.getActiveObjects();
|
|
872
|
+
const objects = activeObjects.map(tracked => {
|
|
873
|
+
const lastSighting = getLastSighting(tracked);
|
|
874
|
+
const camera = lastSighting ? findCamera(this.topology, lastSighting.cameraId) : null;
|
|
875
|
+
|
|
876
|
+
return {
|
|
877
|
+
globalId: tracked.globalId,
|
|
878
|
+
className: tracked.className,
|
|
879
|
+
label: tracked.label,
|
|
880
|
+
lastCameraId: lastSighting?.cameraId || '',
|
|
881
|
+
lastCameraName: lastSighting?.cameraName || '',
|
|
882
|
+
lastSeen: tracked.lastSeen,
|
|
883
|
+
state: tracked.state,
|
|
884
|
+
cameraPosition: camera?.floorPlanPosition,
|
|
885
|
+
};
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
objects,
|
|
890
|
+
timestamp: Date.now(),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Get journey path for visualization */
|
|
895
|
+
getJourneyPath(globalId: GlobalTrackingId): {
|
|
896
|
+
segments: Array<{
|
|
897
|
+
fromCamera: { id: string; name: string; position?: { x: number; y: number } };
|
|
898
|
+
toCamera: { id: string; name: string; position?: { x: number; y: number } };
|
|
899
|
+
transitTime: number;
|
|
900
|
+
timestamp: number;
|
|
901
|
+
}>;
|
|
902
|
+
currentLocation?: { cameraId: string; cameraName: string; position?: { x: number; y: number } };
|
|
903
|
+
} | null {
|
|
904
|
+
const tracked = this.state.getObject(globalId);
|
|
905
|
+
if (!tracked) return null;
|
|
906
|
+
|
|
907
|
+
const segments = tracked.journey.map(j => {
|
|
908
|
+
const fromCamera = findCamera(this.topology, j.fromCameraId);
|
|
909
|
+
const toCamera = findCamera(this.topology, j.toCameraId);
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
fromCamera: {
|
|
913
|
+
id: j.fromCameraId,
|
|
914
|
+
name: j.fromCameraName,
|
|
915
|
+
position: fromCamera?.floorPlanPosition,
|
|
916
|
+
},
|
|
917
|
+
toCamera: {
|
|
918
|
+
id: j.toCameraId,
|
|
919
|
+
name: j.toCameraName,
|
|
920
|
+
position: toCamera?.floorPlanPosition,
|
|
921
|
+
},
|
|
922
|
+
transitTime: j.transitDuration,
|
|
923
|
+
timestamp: j.entryTime,
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
const lastSighting = getLastSighting(tracked);
|
|
928
|
+
let currentLocation;
|
|
929
|
+
if (lastSighting) {
|
|
930
|
+
const camera = findCamera(this.topology, lastSighting.cameraId);
|
|
931
|
+
currentLocation = {
|
|
932
|
+
cameraId: lastSighting.cameraId,
|
|
933
|
+
cameraName: lastSighting.cameraName,
|
|
934
|
+
position: camera?.floorPlanPosition,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return { segments, currentLocation };
|
|
939
|
+
}
|
|
574
940
|
}
|
package/src/main.ts
CHANGED
|
@@ -115,6 +115,41 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
115
115
|
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
116
116
|
group: 'AI & Spatial Reasoning',
|
|
117
117
|
},
|
|
118
|
+
llmDebounceInterval: {
|
|
119
|
+
title: 'LLM Rate Limit (seconds)',
|
|
120
|
+
type: 'number',
|
|
121
|
+
defaultValue: 10,
|
|
122
|
+
description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
|
|
123
|
+
group: 'AI & Spatial Reasoning',
|
|
124
|
+
},
|
|
125
|
+
llmFallbackEnabled: {
|
|
126
|
+
title: 'Fallback to Basic Notifications',
|
|
127
|
+
type: 'boolean',
|
|
128
|
+
defaultValue: true,
|
|
129
|
+
description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
|
|
130
|
+
group: 'AI & Spatial Reasoning',
|
|
131
|
+
},
|
|
132
|
+
llmFallbackTimeout: {
|
|
133
|
+
title: 'LLM Timeout (seconds)',
|
|
134
|
+
type: 'number',
|
|
135
|
+
defaultValue: 3,
|
|
136
|
+
description: 'Maximum time to wait for LLM response before falling back to basic notification',
|
|
137
|
+
group: 'AI & Spatial Reasoning',
|
|
138
|
+
},
|
|
139
|
+
enableTransitTimeLearning: {
|
|
140
|
+
title: 'Learn Transit Times',
|
|
141
|
+
type: 'boolean',
|
|
142
|
+
defaultValue: true,
|
|
143
|
+
description: 'Automatically adjust connection transit times based on observed movement patterns',
|
|
144
|
+
group: 'AI & Spatial Reasoning',
|
|
145
|
+
},
|
|
146
|
+
enableConnectionSuggestions: {
|
|
147
|
+
title: 'Suggest Camera Connections',
|
|
148
|
+
type: 'boolean',
|
|
149
|
+
defaultValue: true,
|
|
150
|
+
description: 'Automatically suggest new camera connections based on observed movement patterns',
|
|
151
|
+
group: 'AI & Spatial Reasoning',
|
|
152
|
+
},
|
|
118
153
|
enableLandmarkLearning: {
|
|
119
154
|
title: 'Learn Landmarks from AI',
|
|
120
155
|
type: 'boolean',
|
|
@@ -291,6 +326,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
291
326
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
|
|
292
327
|
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
|
|
293
328
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
|
|
329
|
+
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval as number || 10) * 1000,
|
|
330
|
+
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled as boolean ?? true,
|
|
331
|
+
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout as number || 3) * 1000,
|
|
332
|
+
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning as boolean ?? true,
|
|
333
|
+
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions as boolean ?? true,
|
|
294
334
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
|
|
295
335
|
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold as number ?? 0.7,
|
|
296
336
|
};
|
|
@@ -576,6 +616,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
576
616
|
key === 'loiteringThreshold' ||
|
|
577
617
|
key === 'objectAlertCooldown' ||
|
|
578
618
|
key === 'useLlmDescriptions' ||
|
|
619
|
+
key === 'llmDebounceInterval' ||
|
|
620
|
+
key === 'llmFallbackEnabled' ||
|
|
621
|
+
key === 'llmFallbackTimeout' ||
|
|
622
|
+
key === 'enableTransitTimeLearning' ||
|
|
623
|
+
key === 'enableConnectionSuggestions' ||
|
|
579
624
|
key === 'enableLandmarkLearning' ||
|
|
580
625
|
key === 'landmarkConfidenceThreshold'
|
|
581
626
|
) {
|
|
@@ -668,6 +713,29 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
668
713
|
return this.handleInferRelationshipsRequest(response);
|
|
669
714
|
}
|
|
670
715
|
|
|
716
|
+
// Connection suggestions
|
|
717
|
+
if (path.endsWith('/api/connection-suggestions')) {
|
|
718
|
+
return this.handleConnectionSuggestionsRequest(request, response);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
|
|
722
|
+
const parts = path.split('/');
|
|
723
|
+
const action = parts.pop()!;
|
|
724
|
+
const suggestionId = parts.pop()!;
|
|
725
|
+
return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Live tracking state
|
|
729
|
+
if (path.endsWith('/api/live-tracking')) {
|
|
730
|
+
return this.handleLiveTrackingRequest(response);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Journey visualization
|
|
734
|
+
if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
|
|
735
|
+
const globalId = path.split('/').pop()!;
|
|
736
|
+
return this.handleJourneyPathRequest(globalId, response);
|
|
737
|
+
}
|
|
738
|
+
|
|
671
739
|
// UI Routes
|
|
672
740
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
673
741
|
return this.serveEditorUI(response);
|
|
@@ -680,14 +748,18 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
680
748
|
// Default: return info page
|
|
681
749
|
response.send(JSON.stringify({
|
|
682
750
|
name: 'Spatial Awareness Plugin',
|
|
683
|
-
version: '0.
|
|
751
|
+
version: '0.3.0',
|
|
684
752
|
endpoints: {
|
|
685
753
|
api: {
|
|
686
754
|
trackedObjects: '/api/tracked-objects',
|
|
687
755
|
journey: '/api/journey/{globalId}',
|
|
756
|
+
journeyPath: '/api/journey-path/{globalId}',
|
|
688
757
|
topology: '/api/topology',
|
|
689
758
|
alerts: '/api/alerts',
|
|
690
759
|
floorPlan: '/api/floor-plan',
|
|
760
|
+
liveTracking: '/api/live-tracking',
|
|
761
|
+
connectionSuggestions: '/api/connection-suggestions',
|
|
762
|
+
landmarkSuggestions: '/api/landmark-suggestions',
|
|
691
763
|
},
|
|
692
764
|
ui: {
|
|
693
765
|
editor: '/ui/editor',
|
|
@@ -1054,6 +1126,108 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1054
1126
|
});
|
|
1055
1127
|
}
|
|
1056
1128
|
|
|
1129
|
+
private handleConnectionSuggestionsRequest(request: HttpRequest, response: HttpResponse): void {
|
|
1130
|
+
if (!this.trackingEngine) {
|
|
1131
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
1132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1133
|
+
});
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const suggestions = this.trackingEngine.getConnectionSuggestions();
|
|
1138
|
+
response.send(JSON.stringify({
|
|
1139
|
+
suggestions,
|
|
1140
|
+
count: suggestions.length,
|
|
1141
|
+
}), {
|
|
1142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private handleConnectionSuggestionActionRequest(
|
|
1147
|
+
suggestionId: string,
|
|
1148
|
+
action: string,
|
|
1149
|
+
response: HttpResponse
|
|
1150
|
+
): void {
|
|
1151
|
+
if (!this.trackingEngine) {
|
|
1152
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
1153
|
+
code: 500,
|
|
1154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1155
|
+
});
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (action === 'accept') {
|
|
1160
|
+
const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
|
|
1161
|
+
if (connection) {
|
|
1162
|
+
// Save updated topology
|
|
1163
|
+
const topology = this.trackingEngine.getTopology();
|
|
1164
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
1165
|
+
|
|
1166
|
+
response.send(JSON.stringify({ success: true, connection }), {
|
|
1167
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1168
|
+
});
|
|
1169
|
+
} else {
|
|
1170
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1171
|
+
code: 404,
|
|
1172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
} else if (action === 'reject') {
|
|
1176
|
+
const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
|
|
1177
|
+
if (success) {
|
|
1178
|
+
response.send(JSON.stringify({ success: true }), {
|
|
1179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1180
|
+
});
|
|
1181
|
+
} else {
|
|
1182
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1183
|
+
code: 404,
|
|
1184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
} else {
|
|
1188
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
1189
|
+
code: 400,
|
|
1190
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private handleLiveTrackingRequest(response: HttpResponse): void {
|
|
1196
|
+
if (!this.trackingEngine) {
|
|
1197
|
+
response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
|
|
1198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1199
|
+
});
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const liveState = this.trackingEngine.getLiveTrackingState();
|
|
1204
|
+
response.send(JSON.stringify(liveState), {
|
|
1205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private handleJourneyPathRequest(globalId: string, response: HttpResponse): void {
|
|
1210
|
+
if (!this.trackingEngine) {
|
|
1211
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
1212
|
+
code: 500,
|
|
1213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1214
|
+
});
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const journeyPath = this.trackingEngine.getJourneyPath(globalId);
|
|
1219
|
+
if (journeyPath) {
|
|
1220
|
+
response.send(JSON.stringify(journeyPath), {
|
|
1221
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1222
|
+
});
|
|
1223
|
+
} else {
|
|
1224
|
+
response.send(JSON.stringify({ error: 'Object not found' }), {
|
|
1225
|
+
code: 404,
|
|
1226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1057
1231
|
private serveEditorUI(response: HttpResponse): void {
|
|
1058
1232
|
response.send(EDITOR_HTML, {
|
|
1059
1233
|
headers: { 'Content-Type': 'text/html' },
|