@blueharford/scrypted-spatial-awareness 0.1.15 → 0.2.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 +152 -35
- 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 +1514 -25
- 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 +700 -0
- package/src/core/tracking-engine.ts +207 -14
- package/src/main.ts +294 -2
- package/src/models/alert.ts +21 -1
- package/src/models/topology.ts +382 -9
- package/src/ui/editor-html.ts +328 -6
|
@@ -10,8 +10,11 @@ import sdk, {
|
|
|
10
10
|
ObjectDetectionResult,
|
|
11
11
|
ScryptedInterface,
|
|
12
12
|
EventListenerRegister,
|
|
13
|
+
ObjectDetection,
|
|
14
|
+
Camera,
|
|
15
|
+
MediaObject,
|
|
13
16
|
} from '@scrypted/sdk';
|
|
14
|
-
import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
|
|
17
|
+
import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
|
|
15
18
|
import {
|
|
16
19
|
TrackedObject,
|
|
17
20
|
ObjectSighting,
|
|
@@ -22,6 +25,11 @@ import {
|
|
|
22
25
|
import { TrackingState } from '../state/tracking-state';
|
|
23
26
|
import { AlertManager } from '../alerts/alert-manager';
|
|
24
27
|
import { ObjectCorrelator } from './object-correlator';
|
|
28
|
+
import {
|
|
29
|
+
SpatialReasoningEngine,
|
|
30
|
+
SpatialReasoningConfig,
|
|
31
|
+
SpatialReasoningResult,
|
|
32
|
+
} from './spatial-reasoning';
|
|
25
33
|
|
|
26
34
|
const { systemManager } = sdk;
|
|
27
35
|
|
|
@@ -34,6 +42,16 @@ export interface TrackingEngineConfig {
|
|
|
34
42
|
lostTimeout: number;
|
|
35
43
|
/** Enable visual embedding matching */
|
|
36
44
|
useVisualMatching: boolean;
|
|
45
|
+
/** Loitering threshold - object must be visible this long before alerting (ms) */
|
|
46
|
+
loiteringThreshold: number;
|
|
47
|
+
/** Per-object alert cooldown (ms) */
|
|
48
|
+
objectAlertCooldown: number;
|
|
49
|
+
/** Use LLM for enhanced descriptions */
|
|
50
|
+
useLlmDescriptions: boolean;
|
|
51
|
+
/** Enable landmark learning from AI */
|
|
52
|
+
enableLandmarkLearning?: boolean;
|
|
53
|
+
/** Minimum confidence for landmark suggestions */
|
|
54
|
+
landmarkConfidenceThreshold?: number;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
export class TrackingEngine {
|
|
@@ -43,9 +61,14 @@ export class TrackingEngine {
|
|
|
43
61
|
private config: TrackingEngineConfig;
|
|
44
62
|
private console: Console;
|
|
45
63
|
private correlator: ObjectCorrelator;
|
|
64
|
+
private spatialReasoning: SpatialReasoningEngine;
|
|
46
65
|
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
47
66
|
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
48
67
|
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
68
|
+
/** Track last alert time per object to enforce cooldown */
|
|
69
|
+
private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
|
|
70
|
+
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
71
|
+
private onTopologyChange?: (topology: CameraTopology) => void;
|
|
49
72
|
|
|
50
73
|
constructor(
|
|
51
74
|
topology: CameraTopology,
|
|
@@ -60,6 +83,21 @@ export class TrackingEngine {
|
|
|
60
83
|
this.config = config;
|
|
61
84
|
this.console = console;
|
|
62
85
|
this.correlator = new ObjectCorrelator(topology, config);
|
|
86
|
+
|
|
87
|
+
// Initialize spatial reasoning engine
|
|
88
|
+
const spatialConfig: SpatialReasoningConfig = {
|
|
89
|
+
enableLlm: config.useLlmDescriptions,
|
|
90
|
+
enableLandmarkLearning: config.enableLandmarkLearning ?? true,
|
|
91
|
+
landmarkConfidenceThreshold: config.landmarkConfidenceThreshold ?? 0.7,
|
|
92
|
+
contextCacheTtl: 60000, // 1 minute cache
|
|
93
|
+
};
|
|
94
|
+
this.spatialReasoning = new SpatialReasoningEngine(spatialConfig, console);
|
|
95
|
+
this.spatialReasoning.updateTopology(topology);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Set callback for topology changes */
|
|
99
|
+
setTopologyChangeCallback(callback: (topology: CameraTopology) => void): void {
|
|
100
|
+
this.onTopologyChange = callback;
|
|
63
101
|
}
|
|
64
102
|
|
|
65
103
|
/** Start listening to all cameras in topology */
|
|
@@ -176,6 +214,90 @@ export class TrackingEngine {
|
|
|
176
214
|
}
|
|
177
215
|
}
|
|
178
216
|
|
|
217
|
+
/** Check if object passes loitering threshold */
|
|
218
|
+
private passesLoiteringThreshold(tracked: TrackedObject): boolean {
|
|
219
|
+
const visibleDuration = tracked.lastSeen - tracked.firstSeen;
|
|
220
|
+
return visibleDuration >= this.config.loiteringThreshold;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Check if object is in alert cooldown */
|
|
224
|
+
private isInAlertCooldown(globalId: GlobalTrackingId): boolean {
|
|
225
|
+
const lastAlertTime = this.objectLastAlertTime.get(globalId);
|
|
226
|
+
if (!lastAlertTime) return false;
|
|
227
|
+
return (Date.now() - lastAlertTime) < this.config.objectAlertCooldown;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Record that we alerted for this object */
|
|
231
|
+
private recordAlertTime(globalId: GlobalTrackingId): void {
|
|
232
|
+
this.objectLastAlertTime.set(globalId, Date.now());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) */
|
|
236
|
+
private async getSpatialDescription(
|
|
237
|
+
tracked: TrackedObject,
|
|
238
|
+
fromCameraId: string,
|
|
239
|
+
toCameraId: string,
|
|
240
|
+
transitTime: number,
|
|
241
|
+
currentCameraId: string
|
|
242
|
+
): Promise<SpatialReasoningResult | null> {
|
|
243
|
+
try {
|
|
244
|
+
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
245
|
+
let mediaObject: MediaObject | undefined;
|
|
246
|
+
if (this.config.useLlmDescriptions) {
|
|
247
|
+
const camera = systemManager.getDeviceById<Camera>(currentCameraId);
|
|
248
|
+
if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
249
|
+
mediaObject = await camera.takePicture();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 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
|
+
);
|
|
261
|
+
|
|
262
|
+
// Optionally trigger landmark learning
|
|
263
|
+
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
264
|
+
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return result;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
this.console.warn('Spatial reasoning failed:', e);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Try to learn new landmarks from detections (background task) */
|
|
275
|
+
private async tryLearnLandmark(
|
|
276
|
+
cameraId: string,
|
|
277
|
+
mediaObject: MediaObject,
|
|
278
|
+
objectClass: string
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
try {
|
|
281
|
+
// Position is approximate - could be improved with object position from detection
|
|
282
|
+
const position = { x: 50, y: 50 };
|
|
283
|
+
const suggestion = await this.spatialReasoning.suggestLandmark(
|
|
284
|
+
cameraId,
|
|
285
|
+
mediaObject,
|
|
286
|
+
objectClass,
|
|
287
|
+
position
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (suggestion) {
|
|
291
|
+
this.console.log(
|
|
292
|
+
`AI suggested landmark: ${suggestion.landmark.name} ` +
|
|
293
|
+
`(${suggestion.landmark.type}, confidence: ${suggestion.landmark.aiConfidence?.toFixed(2)})`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
// Landmark learning is best-effort, don't log errors
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
179
301
|
/** Process a single sighting */
|
|
180
302
|
private async processSighting(
|
|
181
303
|
sighting: ObjectSighting,
|
|
@@ -212,17 +334,35 @@ export class TrackingEngine {
|
|
|
212
334
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
|
|
213
335
|
);
|
|
214
336
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
337
|
+
// Check loitering threshold and per-object cooldown before alerting
|
|
338
|
+
if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
|
|
339
|
+
// Get spatial reasoning result with RAG context
|
|
340
|
+
const spatialResult = await this.getSpatialDescription(
|
|
341
|
+
tracked,
|
|
342
|
+
lastSighting.cameraId,
|
|
343
|
+
sighting.cameraId,
|
|
344
|
+
transitDuration,
|
|
345
|
+
sighting.cameraId
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Generate movement alert for cross-camera transition
|
|
349
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
350
|
+
fromCameraId: lastSighting.cameraId,
|
|
351
|
+
fromCameraName: lastSighting.cameraName,
|
|
352
|
+
toCameraId: sighting.cameraId,
|
|
353
|
+
toCameraName: sighting.cameraName,
|
|
354
|
+
transitTime: transitDuration,
|
|
355
|
+
objectClass: sighting.detection.className,
|
|
356
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
357
|
+
detectionId: sighting.detectionId,
|
|
358
|
+
// Include spatial context for enriched alerts
|
|
359
|
+
pathDescription: spatialResult?.pathDescription,
|
|
360
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
361
|
+
usedLlm: spatialResult?.usedLlm,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
this.recordAlertTime(tracked.globalId);
|
|
365
|
+
}
|
|
226
366
|
}
|
|
227
367
|
|
|
228
368
|
// Add sighting to tracked object
|
|
@@ -253,14 +393,28 @@ export class TrackingEngine {
|
|
|
253
393
|
);
|
|
254
394
|
|
|
255
395
|
// Generate entry alert if this is an entry point
|
|
256
|
-
|
|
396
|
+
// Entry alerts also respect loitering threshold and cooldown
|
|
397
|
+
if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
|
|
398
|
+
// Get spatial reasoning for entry event
|
|
399
|
+
const spatialResult = await this.getSpatialDescription(
|
|
400
|
+
tracked,
|
|
401
|
+
'outside', // Virtual "outside" location for entry
|
|
402
|
+
sighting.cameraId,
|
|
403
|
+
0,
|
|
404
|
+
sighting.cameraId
|
|
405
|
+
);
|
|
406
|
+
|
|
257
407
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
258
408
|
cameraId: sighting.cameraId,
|
|
259
409
|
cameraName: sighting.cameraName,
|
|
260
410
|
objectClass: sighting.detection.className,
|
|
261
|
-
objectLabel: sighting.detection.label,
|
|
411
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
262
412
|
detectionId: sighting.detectionId,
|
|
413
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
414
|
+
usedLlm: spatialResult?.usedLlm,
|
|
263
415
|
});
|
|
416
|
+
|
|
417
|
+
this.recordAlertTime(globalId);
|
|
264
418
|
}
|
|
265
419
|
}
|
|
266
420
|
}
|
|
@@ -362,6 +516,45 @@ export class TrackingEngine {
|
|
|
362
516
|
updateTopology(topology: CameraTopology): void {
|
|
363
517
|
this.topology = topology;
|
|
364
518
|
this.correlator = new ObjectCorrelator(topology, this.config);
|
|
519
|
+
this.spatialReasoning.updateTopology(topology);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Get pending landmark suggestions */
|
|
523
|
+
getPendingLandmarkSuggestions(): import('../models/topology').LandmarkSuggestion[] {
|
|
524
|
+
return this.spatialReasoning.getPendingSuggestions();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Accept a landmark suggestion, adding it to topology */
|
|
528
|
+
acceptLandmarkSuggestion(suggestionId: string): Landmark | null {
|
|
529
|
+
const landmark = this.spatialReasoning.acceptSuggestion(suggestionId);
|
|
530
|
+
if (landmark && this.topology) {
|
|
531
|
+
// Add the accepted landmark to topology
|
|
532
|
+
if (!this.topology.landmarks) {
|
|
533
|
+
this.topology.landmarks = [];
|
|
534
|
+
}
|
|
535
|
+
this.topology.landmarks.push(landmark);
|
|
536
|
+
|
|
537
|
+
// Notify about topology change
|
|
538
|
+
if (this.onTopologyChange) {
|
|
539
|
+
this.onTopologyChange(this.topology);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return landmark;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Reject a landmark suggestion */
|
|
546
|
+
rejectLandmarkSuggestion(suggestionId: string): boolean {
|
|
547
|
+
return this.spatialReasoning.rejectSuggestion(suggestionId);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Get landmark templates for UI */
|
|
551
|
+
getLandmarkTemplates(): typeof import('../models/topology').LANDMARK_TEMPLATES {
|
|
552
|
+
return this.spatialReasoning.getLandmarkTemplates();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Get the spatial reasoning engine for direct access */
|
|
556
|
+
getSpatialReasoningEngine(): SpatialReasoningEngine {
|
|
557
|
+
return this.spatialReasoning;
|
|
365
558
|
}
|
|
366
559
|
|
|
367
560
|
/** Get current topology */
|
package/src/main.ts
CHANGED
|
@@ -15,7 +15,14 @@ import sdk, {
|
|
|
15
15
|
Readme,
|
|
16
16
|
} from '@scrypted/sdk';
|
|
17
17
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
CameraTopology,
|
|
20
|
+
createEmptyTopology,
|
|
21
|
+
Landmark,
|
|
22
|
+
LandmarkSuggestion,
|
|
23
|
+
LANDMARK_TEMPLATES,
|
|
24
|
+
inferRelationships,
|
|
25
|
+
} from './models/topology';
|
|
19
26
|
import { TrackedObject } from './models/tracked-object';
|
|
20
27
|
import { Alert, AlertRule, createDefaultRules } from './models/alert';
|
|
21
28
|
import { TrackingState } from './state/tracking-state';
|
|
@@ -85,6 +92,43 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
85
92
|
description: 'Use visual embeddings for object correlation (requires compatible detectors)',
|
|
86
93
|
group: 'Tracking',
|
|
87
94
|
},
|
|
95
|
+
loiteringThreshold: {
|
|
96
|
+
title: 'Loitering Threshold (seconds)',
|
|
97
|
+
type: 'number',
|
|
98
|
+
defaultValue: 3,
|
|
99
|
+
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
100
|
+
group: 'Tracking',
|
|
101
|
+
},
|
|
102
|
+
objectAlertCooldown: {
|
|
103
|
+
title: 'Per-Object Alert Cooldown (seconds)',
|
|
104
|
+
type: 'number',
|
|
105
|
+
defaultValue: 30,
|
|
106
|
+
description: 'Minimum time between alerts for the same tracked object',
|
|
107
|
+
group: 'Tracking',
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// LLM Integration
|
|
111
|
+
useLlmDescriptions: {
|
|
112
|
+
title: 'Use LLM for Rich Descriptions',
|
|
113
|
+
type: 'boolean',
|
|
114
|
+
defaultValue: true,
|
|
115
|
+
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
116
|
+
group: 'AI & Spatial Reasoning',
|
|
117
|
+
},
|
|
118
|
+
enableLandmarkLearning: {
|
|
119
|
+
title: 'Learn Landmarks from AI',
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
defaultValue: true,
|
|
122
|
+
description: 'Allow AI to suggest new landmarks based on detected objects and camera context',
|
|
123
|
+
group: 'AI & Spatial Reasoning',
|
|
124
|
+
},
|
|
125
|
+
landmarkConfidenceThreshold: {
|
|
126
|
+
title: 'Landmark Suggestion Confidence',
|
|
127
|
+
type: 'number',
|
|
128
|
+
defaultValue: 0.7,
|
|
129
|
+
description: 'Minimum AI confidence (0-1) to suggest a landmark',
|
|
130
|
+
group: 'AI & Spatial Reasoning',
|
|
131
|
+
},
|
|
88
132
|
|
|
89
133
|
// MQTT Settings
|
|
90
134
|
enableMqtt: {
|
|
@@ -244,6 +288,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
244
288
|
correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.6,
|
|
245
289
|
lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
|
|
246
290
|
useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
|
|
291
|
+
loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
|
|
292
|
+
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
|
|
293
|
+
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
|
|
294
|
+
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
|
|
295
|
+
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold as number ?? 0.7,
|
|
247
296
|
};
|
|
248
297
|
|
|
249
298
|
this.trackingEngine = new TrackingEngine(
|
|
@@ -254,6 +303,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
254
303
|
this.console
|
|
255
304
|
);
|
|
256
305
|
|
|
306
|
+
// Set up callback to save topology changes (e.g., from accepted landmark suggestions)
|
|
307
|
+
this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
|
|
308
|
+
this.storage.setItem('topology', JSON.stringify(updatedTopology));
|
|
309
|
+
this.console.log('Topology auto-saved after change');
|
|
310
|
+
});
|
|
311
|
+
|
|
257
312
|
await this.trackingEngine.startTracking();
|
|
258
313
|
this.console.log('Tracking engine started');
|
|
259
314
|
}
|
|
@@ -517,7 +572,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
517
572
|
key === 'correlationWindow' ||
|
|
518
573
|
key === 'correlationThreshold' ||
|
|
519
574
|
key === 'lostTimeout' ||
|
|
520
|
-
key === 'useVisualMatching'
|
|
575
|
+
key === 'useVisualMatching' ||
|
|
576
|
+
key === 'loiteringThreshold' ||
|
|
577
|
+
key === 'objectAlertCooldown' ||
|
|
578
|
+
key === 'useLlmDescriptions' ||
|
|
579
|
+
key === 'enableLandmarkLearning' ||
|
|
580
|
+
key === 'landmarkConfidenceThreshold'
|
|
521
581
|
) {
|
|
522
582
|
const topologyJson = this.storage.getItem('topology');
|
|
523
583
|
if (topologyJson) {
|
|
@@ -580,6 +640,34 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
580
640
|
return this.handleFloorPlanRequest(request, response);
|
|
581
641
|
}
|
|
582
642
|
|
|
643
|
+
if (path.endsWith('/api/landmarks')) {
|
|
644
|
+
return this.handleLandmarksRequest(request, response);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (path.match(/\/api\/landmarks\/[\w-]+$/)) {
|
|
648
|
+
const landmarkId = path.split('/').pop()!;
|
|
649
|
+
return this.handleLandmarkRequest(landmarkId, request, response);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (path.endsWith('/api/landmark-suggestions')) {
|
|
653
|
+
return this.handleLandmarkSuggestionsRequest(request, response);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (path.match(/\/api\/landmark-suggestions\/[\w-]+\/(accept|reject)$/)) {
|
|
657
|
+
const parts = path.split('/');
|
|
658
|
+
const action = parts.pop()!;
|
|
659
|
+
const suggestionId = parts.pop()!;
|
|
660
|
+
return this.handleSuggestionActionRequest(suggestionId, action, response);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (path.endsWith('/api/landmark-templates')) {
|
|
664
|
+
return this.handleLandmarkTemplatesRequest(response);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (path.endsWith('/api/infer-relationships')) {
|
|
668
|
+
return this.handleInferRelationshipsRequest(response);
|
|
669
|
+
}
|
|
670
|
+
|
|
583
671
|
// UI Routes
|
|
584
672
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
585
673
|
return this.serveEditorUI(response);
|
|
@@ -762,6 +850,210 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
762
850
|
}
|
|
763
851
|
}
|
|
764
852
|
|
|
853
|
+
private handleLandmarksRequest(request: HttpRequest, response: HttpResponse): void {
|
|
854
|
+
const topology = this.getTopology();
|
|
855
|
+
if (!topology) {
|
|
856
|
+
response.send(JSON.stringify({ landmarks: [] }), {
|
|
857
|
+
headers: { 'Content-Type': 'application/json' },
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (request.method === 'GET') {
|
|
863
|
+
response.send(JSON.stringify({
|
|
864
|
+
landmarks: topology.landmarks || [],
|
|
865
|
+
}), {
|
|
866
|
+
headers: { 'Content-Type': 'application/json' },
|
|
867
|
+
});
|
|
868
|
+
} else if (request.method === 'POST') {
|
|
869
|
+
try {
|
|
870
|
+
const landmark = JSON.parse(request.body!) as Landmark;
|
|
871
|
+
if (!landmark.id) {
|
|
872
|
+
landmark.id = `landmark_${Date.now()}`;
|
|
873
|
+
}
|
|
874
|
+
if (!topology.landmarks) {
|
|
875
|
+
topology.landmarks = [];
|
|
876
|
+
}
|
|
877
|
+
topology.landmarks.push(landmark);
|
|
878
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
879
|
+
if (this.trackingEngine) {
|
|
880
|
+
this.trackingEngine.updateTopology(topology);
|
|
881
|
+
}
|
|
882
|
+
response.send(JSON.stringify({ success: true, landmark }), {
|
|
883
|
+
headers: { 'Content-Type': 'application/json' },
|
|
884
|
+
});
|
|
885
|
+
} catch (e) {
|
|
886
|
+
response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
|
|
887
|
+
code: 400,
|
|
888
|
+
headers: { 'Content-Type': 'application/json' },
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private handleLandmarkRequest(
|
|
895
|
+
landmarkId: string,
|
|
896
|
+
request: HttpRequest,
|
|
897
|
+
response: HttpResponse
|
|
898
|
+
): void {
|
|
899
|
+
const topology = this.getTopology();
|
|
900
|
+
if (!topology) {
|
|
901
|
+
response.send(JSON.stringify({ error: 'No topology configured' }), {
|
|
902
|
+
code: 404,
|
|
903
|
+
headers: { 'Content-Type': 'application/json' },
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const landmarkIndex = topology.landmarks?.findIndex(l => l.id === landmarkId) ?? -1;
|
|
909
|
+
|
|
910
|
+
if (request.method === 'GET') {
|
|
911
|
+
const landmark = topology.landmarks?.[landmarkIndex];
|
|
912
|
+
if (landmark) {
|
|
913
|
+
response.send(JSON.stringify(landmark), {
|
|
914
|
+
headers: { 'Content-Type': 'application/json' },
|
|
915
|
+
});
|
|
916
|
+
} else {
|
|
917
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
918
|
+
code: 404,
|
|
919
|
+
headers: { 'Content-Type': 'application/json' },
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
} else if (request.method === 'PUT') {
|
|
923
|
+
try {
|
|
924
|
+
const updates = JSON.parse(request.body!) as Partial<Landmark>;
|
|
925
|
+
if (landmarkIndex >= 0) {
|
|
926
|
+
topology.landmarks![landmarkIndex] = {
|
|
927
|
+
...topology.landmarks![landmarkIndex],
|
|
928
|
+
...updates,
|
|
929
|
+
id: landmarkId, // Preserve ID
|
|
930
|
+
};
|
|
931
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
932
|
+
if (this.trackingEngine) {
|
|
933
|
+
this.trackingEngine.updateTopology(topology);
|
|
934
|
+
}
|
|
935
|
+
response.send(JSON.stringify({ success: true, landmark: topology.landmarks![landmarkIndex] }), {
|
|
936
|
+
headers: { 'Content-Type': 'application/json' },
|
|
937
|
+
});
|
|
938
|
+
} else {
|
|
939
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
940
|
+
code: 404,
|
|
941
|
+
headers: { 'Content-Type': 'application/json' },
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
} catch (e) {
|
|
945
|
+
response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
|
|
946
|
+
code: 400,
|
|
947
|
+
headers: { 'Content-Type': 'application/json' },
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
} else if (request.method === 'DELETE') {
|
|
951
|
+
if (landmarkIndex >= 0) {
|
|
952
|
+
topology.landmarks!.splice(landmarkIndex, 1);
|
|
953
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
954
|
+
if (this.trackingEngine) {
|
|
955
|
+
this.trackingEngine.updateTopology(topology);
|
|
956
|
+
}
|
|
957
|
+
response.send(JSON.stringify({ success: true }), {
|
|
958
|
+
headers: { 'Content-Type': 'application/json' },
|
|
959
|
+
});
|
|
960
|
+
} else {
|
|
961
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
962
|
+
code: 404,
|
|
963
|
+
headers: { 'Content-Type': 'application/json' },
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private handleLandmarkSuggestionsRequest(request: HttpRequest, response: HttpResponse): void {
|
|
970
|
+
if (!this.trackingEngine) {
|
|
971
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
972
|
+
headers: { 'Content-Type': 'application/json' },
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const suggestions = this.trackingEngine.getPendingLandmarkSuggestions();
|
|
978
|
+
response.send(JSON.stringify({
|
|
979
|
+
suggestions,
|
|
980
|
+
count: suggestions.length,
|
|
981
|
+
}), {
|
|
982
|
+
headers: { 'Content-Type': 'application/json' },
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private handleSuggestionActionRequest(
|
|
987
|
+
suggestionId: string,
|
|
988
|
+
action: string,
|
|
989
|
+
response: HttpResponse
|
|
990
|
+
): void {
|
|
991
|
+
if (!this.trackingEngine) {
|
|
992
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
993
|
+
code: 500,
|
|
994
|
+
headers: { 'Content-Type': 'application/json' },
|
|
995
|
+
});
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (action === 'accept') {
|
|
1000
|
+
const landmark = this.trackingEngine.acceptLandmarkSuggestion(suggestionId);
|
|
1001
|
+
if (landmark) {
|
|
1002
|
+
response.send(JSON.stringify({ success: true, landmark }), {
|
|
1003
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1004
|
+
});
|
|
1005
|
+
} else {
|
|
1006
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1007
|
+
code: 404,
|
|
1008
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
} else if (action === 'reject') {
|
|
1012
|
+
const success = this.trackingEngine.rejectLandmarkSuggestion(suggestionId);
|
|
1013
|
+
if (success) {
|
|
1014
|
+
response.send(JSON.stringify({ success: true }), {
|
|
1015
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1016
|
+
});
|
|
1017
|
+
} else {
|
|
1018
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1019
|
+
code: 404,
|
|
1020
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
} else {
|
|
1024
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
1025
|
+
code: 400,
|
|
1026
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private handleLandmarkTemplatesRequest(response: HttpResponse): void {
|
|
1032
|
+
response.send(JSON.stringify({
|
|
1033
|
+
templates: LANDMARK_TEMPLATES,
|
|
1034
|
+
}), {
|
|
1035
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private handleInferRelationshipsRequest(response: HttpResponse): void {
|
|
1040
|
+
const topology = this.getTopology();
|
|
1041
|
+
if (!topology) {
|
|
1042
|
+
response.send(JSON.stringify({ relationships: [] }), {
|
|
1043
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1044
|
+
});
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const inferred = inferRelationships(topology);
|
|
1049
|
+
response.send(JSON.stringify({
|
|
1050
|
+
relationships: inferred,
|
|
1051
|
+
count: inferred.length,
|
|
1052
|
+
}), {
|
|
1053
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
765
1057
|
private serveEditorUI(response: HttpResponse): void {
|
|
766
1058
|
response.send(EDITOR_HTML, {
|
|
767
1059
|
headers: { 'Content-Type': 'text/html' },
|
package/src/models/alert.ts
CHANGED
|
@@ -78,6 +78,13 @@ export interface AlertDetails {
|
|
|
78
78
|
objectLabel?: string;
|
|
79
79
|
/** Thumbnail image URL or data */
|
|
80
80
|
thumbnailUrl?: string;
|
|
81
|
+
// --- Spatial Context from RAG ---
|
|
82
|
+
/** Description of the path taken (from spatial reasoning) */
|
|
83
|
+
pathDescription?: string;
|
|
84
|
+
/** Names of landmarks involved in the movement */
|
|
85
|
+
involvedLandmarks?: string[];
|
|
86
|
+
/** Whether LLM was used for description */
|
|
87
|
+
usedLlm?: boolean;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/** A condition for alert rules */
|
|
@@ -211,9 +218,22 @@ export function generateAlertMessage(
|
|
|
211
218
|
case 'property_exit':
|
|
212
219
|
return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
|
|
213
220
|
case 'movement':
|
|
221
|
+
// If we have a rich description from LLM/RAG, use it
|
|
222
|
+
if (details.objectLabel && details.usedLlm) {
|
|
223
|
+
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
224
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
225
|
+
// Include path/landmark context if available
|
|
226
|
+
const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
|
|
227
|
+
return `${details.objectLabel}${pathContext}${transitStr}`;
|
|
228
|
+
}
|
|
229
|
+
// Fallback to basic message with landmark info
|
|
214
230
|
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
215
231
|
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
216
|
-
|
|
232
|
+
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
233
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
234
|
+
movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
|
|
235
|
+
}
|
|
236
|
+
return `${movementDesc}${transitStr}`;
|
|
217
237
|
case 'unusual_path':
|
|
218
238
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
219
239
|
case 'dwell_time':
|