@blueharford/scrypted-spatial-awareness 0.1.16 → 0.2.1
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 +1443 -57
- 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 +137 -53
- package/src/main.ts +266 -3
- package/src/models/alert.ts +21 -1
- package/src/models/topology.ts +382 -9
- package/src/ui/editor-html.ts +328 -6
|
@@ -14,7 +14,7 @@ import sdk, {
|
|
|
14
14
|
Camera,
|
|
15
15
|
MediaObject,
|
|
16
16
|
} from '@scrypted/sdk';
|
|
17
|
-
import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
|
|
17
|
+
import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
|
|
18
18
|
import {
|
|
19
19
|
TrackedObject,
|
|
20
20
|
ObjectSighting,
|
|
@@ -25,6 +25,11 @@ import {
|
|
|
25
25
|
import { TrackingState } from '../state/tracking-state';
|
|
26
26
|
import { AlertManager } from '../alerts/alert-manager';
|
|
27
27
|
import { ObjectCorrelator } from './object-correlator';
|
|
28
|
+
import {
|
|
29
|
+
SpatialReasoningEngine,
|
|
30
|
+
SpatialReasoningConfig,
|
|
31
|
+
SpatialReasoningResult,
|
|
32
|
+
} from './spatial-reasoning';
|
|
28
33
|
|
|
29
34
|
const { systemManager } = sdk;
|
|
30
35
|
|
|
@@ -43,6 +48,10 @@ export interface TrackingEngineConfig {
|
|
|
43
48
|
objectAlertCooldown: number;
|
|
44
49
|
/** Use LLM for enhanced descriptions */
|
|
45
50
|
useLlmDescriptions: boolean;
|
|
51
|
+
/** Enable landmark learning from AI */
|
|
52
|
+
enableLandmarkLearning?: boolean;
|
|
53
|
+
/** Minimum confidence for landmark suggestions */
|
|
54
|
+
landmarkConfidenceThreshold?: number;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export class TrackingEngine {
|
|
@@ -52,13 +61,14 @@ export class TrackingEngine {
|
|
|
52
61
|
private config: TrackingEngineConfig;
|
|
53
62
|
private console: Console;
|
|
54
63
|
private correlator: ObjectCorrelator;
|
|
64
|
+
private spatialReasoning: SpatialReasoningEngine;
|
|
55
65
|
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
56
66
|
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
57
67
|
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
58
68
|
/** Track last alert time per object to enforce cooldown */
|
|
59
69
|
private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
|
|
60
|
-
/**
|
|
61
|
-
private
|
|
70
|
+
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
71
|
+
private onTopologyChange?: (topology: CameraTopology) => void;
|
|
62
72
|
|
|
63
73
|
constructor(
|
|
64
74
|
topology: CameraTopology,
|
|
@@ -73,6 +83,21 @@ export class TrackingEngine {
|
|
|
73
83
|
this.config = config;
|
|
74
84
|
this.console = console;
|
|
75
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;
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
/** Start listening to all cameras in topology */
|
|
@@ -207,58 +232,69 @@ export class TrackingEngine {
|
|
|
207
232
|
this.objectLastAlertTime.set(globalId, Date.now());
|
|
208
233
|
}
|
|
209
234
|
|
|
210
|
-
/**
|
|
211
|
-
private async
|
|
235
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) */
|
|
236
|
+
private async getSpatialDescription(
|
|
212
237
|
tracked: TrackedObject,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
238
|
+
fromCameraId: string,
|
|
239
|
+
toCameraId: string,
|
|
240
|
+
transitTime: number,
|
|
241
|
+
currentCameraId: string
|
|
242
|
+
): Promise<SpatialReasoningResult | null> {
|
|
219
243
|
try {
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.llmDevice = device as unknown as ObjectDetection;
|
|
227
|
-
this.console.log(`Found LLM device: ${device.name}`);
|
|
228
|
-
break;
|
|
229
|
-
}
|
|
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();
|
|
230
250
|
}
|
|
231
251
|
}
|
|
232
252
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
);
|
|
238
261
|
|
|
239
|
-
|
|
240
|
-
if (
|
|
262
|
+
// Optionally trigger landmark learning
|
|
263
|
+
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
264
|
+
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
265
|
+
}
|
|
241
266
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
return result;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
this.console.warn('Spatial reasoning failed:', e);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
248
273
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
);
|
|
252
289
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
);
|
|
256
295
|
}
|
|
257
|
-
|
|
258
|
-
return null;
|
|
259
296
|
} catch (e) {
|
|
260
|
-
|
|
261
|
-
return null;
|
|
297
|
+
// Landmark learning is best-effort, don't log errors
|
|
262
298
|
}
|
|
263
299
|
}
|
|
264
300
|
|
|
@@ -300,11 +336,12 @@ export class TrackingEngine {
|
|
|
300
336
|
|
|
301
337
|
// Check loitering threshold and per-object cooldown before alerting
|
|
302
338
|
if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
|
|
303
|
-
//
|
|
304
|
-
const
|
|
339
|
+
// Get spatial reasoning result with RAG context
|
|
340
|
+
const spatialResult = await this.getSpatialDescription(
|
|
305
341
|
tracked,
|
|
306
|
-
lastSighting.
|
|
307
|
-
sighting.
|
|
342
|
+
lastSighting.cameraId,
|
|
343
|
+
sighting.cameraId,
|
|
344
|
+
transitDuration,
|
|
308
345
|
sighting.cameraId
|
|
309
346
|
);
|
|
310
347
|
|
|
@@ -316,8 +353,12 @@ export class TrackingEngine {
|
|
|
316
353
|
toCameraName: sighting.cameraName,
|
|
317
354
|
transitTime: transitDuration,
|
|
318
355
|
objectClass: sighting.detection.className,
|
|
319
|
-
objectLabel:
|
|
356
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
320
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,
|
|
321
362
|
});
|
|
322
363
|
|
|
323
364
|
this.recordAlertTime(tracked.globalId);
|
|
@@ -354,10 +395,12 @@ export class TrackingEngine {
|
|
|
354
395
|
// Generate entry alert if this is an entry point
|
|
355
396
|
// Entry alerts also respect loitering threshold and cooldown
|
|
356
397
|
if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
|
|
357
|
-
|
|
398
|
+
// Get spatial reasoning for entry event
|
|
399
|
+
const spatialResult = await this.getSpatialDescription(
|
|
358
400
|
tracked,
|
|
359
|
-
'outside',
|
|
360
|
-
sighting.
|
|
401
|
+
'outside', // Virtual "outside" location for entry
|
|
402
|
+
sighting.cameraId,
|
|
403
|
+
0,
|
|
361
404
|
sighting.cameraId
|
|
362
405
|
);
|
|
363
406
|
|
|
@@ -365,8 +408,10 @@ export class TrackingEngine {
|
|
|
365
408
|
cameraId: sighting.cameraId,
|
|
366
409
|
cameraName: sighting.cameraName,
|
|
367
410
|
objectClass: sighting.detection.className,
|
|
368
|
-
objectLabel:
|
|
411
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
369
412
|
detectionId: sighting.detectionId,
|
|
413
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
414
|
+
usedLlm: spatialResult?.usedLlm,
|
|
370
415
|
});
|
|
371
416
|
|
|
372
417
|
this.recordAlertTime(globalId);
|
|
@@ -471,6 +516,45 @@ export class TrackingEngine {
|
|
|
471
516
|
updateTopology(topology: CameraTopology): void {
|
|
472
517
|
this.topology = topology;
|
|
473
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;
|
|
474
558
|
}
|
|
475
559
|
|
|
476
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';
|
|
@@ -106,7 +113,21 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
106
113
|
type: 'boolean',
|
|
107
114
|
defaultValue: true,
|
|
108
115
|
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
109
|
-
group: '
|
|
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',
|
|
110
131
|
},
|
|
111
132
|
|
|
112
133
|
// MQTT Settings
|
|
@@ -270,6 +291,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
270
291
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
|
|
271
292
|
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
|
|
272
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,
|
|
273
296
|
};
|
|
274
297
|
|
|
275
298
|
this.trackingEngine = new TrackingEngine(
|
|
@@ -280,6 +303,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
280
303
|
this.console
|
|
281
304
|
);
|
|
282
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
|
+
|
|
283
312
|
await this.trackingEngine.startTracking();
|
|
284
313
|
this.console.log('Tracking engine started');
|
|
285
314
|
}
|
|
@@ -546,7 +575,9 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
546
575
|
key === 'useVisualMatching' ||
|
|
547
576
|
key === 'loiteringThreshold' ||
|
|
548
577
|
key === 'objectAlertCooldown' ||
|
|
549
|
-
key === 'useLlmDescriptions'
|
|
578
|
+
key === 'useLlmDescriptions' ||
|
|
579
|
+
key === 'enableLandmarkLearning' ||
|
|
580
|
+
key === 'landmarkConfidenceThreshold'
|
|
550
581
|
) {
|
|
551
582
|
const topologyJson = this.storage.getItem('topology');
|
|
552
583
|
if (topologyJson) {
|
|
@@ -609,6 +640,34 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
609
640
|
return this.handleFloorPlanRequest(request, response);
|
|
610
641
|
}
|
|
611
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
|
+
|
|
612
671
|
// UI Routes
|
|
613
672
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
614
673
|
return this.serveEditorUI(response);
|
|
@@ -791,6 +850,210 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
791
850
|
}
|
|
792
851
|
}
|
|
793
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
|
+
|
|
794
1057
|
private serveEditorUI(response: HttpResponse): void {
|
|
795
1058
|
response.send(EDITOR_HTML, {
|
|
796
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':
|