@blueharford/scrypted-spatial-awareness 0.6.32 → 0.6.33
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/CHANGELOG.md +18 -0
- package/README.md +5 -2
- 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 +370 -110
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +4 -2
- package/src/alerts/alert-manager.ts +149 -21
- package/src/alerts/alert-utils.ts +32 -0
- package/src/core/tracking-engine.ts +150 -44
- package/src/main.ts +54 -12
- package/src/models/tracked-object.ts +1 -1
- package/src/state/tracking-state.ts +18 -5
- package/tests/run-tests.ts +50 -0
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blueharford/scrypted-spatial-awareness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.33",
|
|
4
4
|
"description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
|
|
5
5
|
"author": "Joshua Seidel <blueharford>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"scrypted-debug": "node node_modules/@scrypted/sdk/bin/scrypted-debug.js",
|
|
25
25
|
"scrypted-deploy": "node node_modules/@scrypted/sdk/bin/scrypted-deploy.js",
|
|
26
26
|
"scrypted-readme": "node node_modules/@scrypted/sdk/bin/scrypted-readme.js",
|
|
27
|
-
"scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js"
|
|
27
|
+
"scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js",
|
|
28
|
+
"test": "node -r ts-node/register/transpile-only ./tests/run-tests.ts"
|
|
28
29
|
},
|
|
29
30
|
"keywords": [
|
|
30
31
|
"scrypted",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@scrypted/common": "^1.0.0",
|
|
56
57
|
"@types/node": "^20.11.0",
|
|
58
|
+
"ts-node": "^10.9.2",
|
|
57
59
|
"typescript": "^5.3.0"
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Generates and dispatches alerts based on tracking events
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import type { Notifier, Camera, MediaObject } from '@scrypted/sdk';
|
|
7
7
|
import {
|
|
8
8
|
Alert,
|
|
9
9
|
AlertRule,
|
|
@@ -12,15 +12,31 @@ import {
|
|
|
12
12
|
AlertCondition,
|
|
13
13
|
createAlert,
|
|
14
14
|
createDefaultRules,
|
|
15
|
+
generateAlertMessage,
|
|
15
16
|
} from '../models/alert';
|
|
16
17
|
import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
import {
|
|
19
|
+
getActiveAlertKey,
|
|
20
|
+
hasMeaningfulAlertChange,
|
|
21
|
+
shouldSendUpdateNotification,
|
|
22
|
+
} from './alert-utils';
|
|
23
|
+
|
|
24
|
+
let sdkModule: typeof import('@scrypted/sdk') | null = null;
|
|
25
|
+
const getSdk = async () => {
|
|
26
|
+
if (!sdkModule) {
|
|
27
|
+
sdkModule = await import('@scrypted/sdk');
|
|
28
|
+
}
|
|
29
|
+
return sdkModule;
|
|
30
|
+
};
|
|
19
31
|
|
|
20
32
|
export class AlertManager {
|
|
21
33
|
private rules: AlertRule[] = [];
|
|
22
34
|
private recentAlerts: Alert[] = [];
|
|
23
35
|
private cooldowns: Map<string, number> = new Map();
|
|
36
|
+
private activeAlerts: Map<string, { alert: Alert; lastUpdate: number; lastNotified: number }> = new Map();
|
|
37
|
+
private readonly activeAlertTtlMs: number = 10 * 60 * 1000;
|
|
38
|
+
private notifyOnUpdates: boolean = false;
|
|
39
|
+
private updateNotificationCooldownMs: number = 60000;
|
|
24
40
|
private console: Console;
|
|
25
41
|
private storage: Storage;
|
|
26
42
|
private maxAlerts: number = 100;
|
|
@@ -37,7 +53,8 @@ export class AlertManager {
|
|
|
37
53
|
async checkAndAlert(
|
|
38
54
|
type: AlertType,
|
|
39
55
|
tracked: TrackedObject,
|
|
40
|
-
details: Partial<AlertDetails
|
|
56
|
+
details: Partial<AlertDetails>,
|
|
57
|
+
mediaObjectOverride?: MediaObject
|
|
41
58
|
): Promise<Alert | null> {
|
|
42
59
|
// Find matching rule
|
|
43
60
|
const rule = this.rules.find(r => r.type === type && r.enabled);
|
|
@@ -57,15 +74,21 @@ export class AlertManager {
|
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
|
|
60
|
-
// Check
|
|
61
|
-
|
|
62
|
-
const lastAlert = this.cooldowns.get(cooldownKey) || 0;
|
|
63
|
-
if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
|
|
77
|
+
// Check conditions
|
|
78
|
+
if (!this.evaluateConditions(rule.conditions, tracked)) {
|
|
64
79
|
return null;
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
//
|
|
68
|
-
if (
|
|
82
|
+
// Update existing movement alert if active (prevents alert spam)
|
|
83
|
+
if (type === 'movement') {
|
|
84
|
+
const updated = await this.updateActiveAlert(type, rule.id, tracked, details);
|
|
85
|
+
if (updated) return updated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check cooldown (only for new alerts)
|
|
89
|
+
const cooldownKey = `${rule.id}:${tracked.globalId}`;
|
|
90
|
+
const lastAlert = this.cooldowns.get(cooldownKey) || 0;
|
|
91
|
+
if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
|
|
69
92
|
return null;
|
|
70
93
|
}
|
|
71
94
|
|
|
@@ -91,21 +114,59 @@ export class AlertManager {
|
|
|
91
114
|
this.recentAlerts.pop();
|
|
92
115
|
}
|
|
93
116
|
|
|
117
|
+
if (type === 'movement') {
|
|
118
|
+
const key = getActiveAlertKey(type, rule.id, tracked.globalId);
|
|
119
|
+
this.activeAlerts.set(key, { alert, lastUpdate: Date.now(), lastNotified: alert.timestamp });
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
// Update cooldown
|
|
95
123
|
this.cooldowns.set(cooldownKey, Date.now());
|
|
96
124
|
|
|
97
125
|
// Send notifications
|
|
98
|
-
await this.sendNotifications(alert, rule);
|
|
126
|
+
await this.sendNotifications(alert, rule, mediaObjectOverride);
|
|
99
127
|
|
|
100
128
|
this.console.log(`Alert generated: [${alert.severity}] ${alert.message}`);
|
|
101
129
|
|
|
102
130
|
return alert;
|
|
103
131
|
}
|
|
104
132
|
|
|
133
|
+
async updateMovementAlert(
|
|
134
|
+
tracked: TrackedObject,
|
|
135
|
+
details: Partial<AlertDetails>
|
|
136
|
+
): Promise<Alert | null> {
|
|
137
|
+
const rule = this.rules.find(r => r.type === 'movement' && r.enabled);
|
|
138
|
+
if (!rule) return null;
|
|
139
|
+
|
|
140
|
+
if (rule.objectClasses && rule.objectClasses.length > 0) {
|
|
141
|
+
if (!rule.objectClasses.includes(tracked.className)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (rule.cameraIds && rule.cameraIds.length > 0 && details.cameraId) {
|
|
147
|
+
if (!rule.cameraIds.includes(details.cameraId)) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.evaluateConditions(rule.conditions, tracked)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return this.updateActiveAlert('movement', rule.id, tracked, details);
|
|
157
|
+
}
|
|
158
|
+
|
|
105
159
|
/**
|
|
106
160
|
* Send notifications for an alert
|
|
107
161
|
*/
|
|
108
|
-
private async sendNotifications(
|
|
162
|
+
private async sendNotifications(
|
|
163
|
+
alert: Alert,
|
|
164
|
+
rule: AlertRule,
|
|
165
|
+
mediaObjectOverride?: MediaObject
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const sdkModule = await getSdk();
|
|
168
|
+
const { ScryptedInterface } = sdkModule;
|
|
169
|
+
const { systemManager } = sdkModule.default;
|
|
109
170
|
const notifierIds = rule.notifiers.length > 0
|
|
110
171
|
? rule.notifiers
|
|
111
172
|
: this.getDefaultNotifiers();
|
|
@@ -119,16 +180,18 @@ export class AlertManager {
|
|
|
119
180
|
}
|
|
120
181
|
|
|
121
182
|
// Try to get a thumbnail from the camera
|
|
122
|
-
let mediaObject: MediaObject | undefined;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
183
|
+
let mediaObject: MediaObject | undefined = mediaObjectOverride;
|
|
184
|
+
if (!mediaObject) {
|
|
185
|
+
const cameraId = alert.details.toCameraId || alert.details.cameraId;
|
|
186
|
+
if (cameraId) {
|
|
187
|
+
try {
|
|
188
|
+
const camera = systemManager.getDeviceById<Camera>(cameraId);
|
|
189
|
+
if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
190
|
+
mediaObject = await camera.takePicture();
|
|
191
|
+
}
|
|
192
|
+
} catch (e) {
|
|
193
|
+
this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
|
|
129
194
|
}
|
|
130
|
-
} catch (e) {
|
|
131
|
-
this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
|
|
132
195
|
}
|
|
133
196
|
}
|
|
134
197
|
|
|
@@ -161,6 +224,71 @@ export class AlertManager {
|
|
|
161
224
|
}
|
|
162
225
|
}
|
|
163
226
|
|
|
227
|
+
clearActiveAlertsForObject(globalId: GlobalTrackingId): void {
|
|
228
|
+
for (const [key, entry] of this.activeAlerts.entries()) {
|
|
229
|
+
if (entry.alert.trackedObjectId === globalId) {
|
|
230
|
+
this.activeAlerts.delete(key);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setUpdateNotificationOptions(enabled: boolean, cooldownMs: number): void {
|
|
236
|
+
this.notifyOnUpdates = enabled;
|
|
237
|
+
this.updateNotificationCooldownMs = Math.max(0, cooldownMs);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async updateActiveAlert(
|
|
241
|
+
type: AlertType,
|
|
242
|
+
ruleId: string,
|
|
243
|
+
tracked: TrackedObject,
|
|
244
|
+
details: Partial<AlertDetails>
|
|
245
|
+
): Promise<Alert | null> {
|
|
246
|
+
const key = getActiveAlertKey(type, ruleId, tracked.globalId);
|
|
247
|
+
const existing = this.activeAlerts.get(key);
|
|
248
|
+
if (!existing) return null;
|
|
249
|
+
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
if (now - existing.lastUpdate > this.activeAlertTtlMs) {
|
|
252
|
+
this.activeAlerts.delete(key);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const updatedDetails: AlertDetails = {
|
|
257
|
+
...existing.alert.details,
|
|
258
|
+
...details,
|
|
259
|
+
objectClass: tracked.className,
|
|
260
|
+
objectLabel: details.objectLabel || tracked.label,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const shouldUpdate = hasMeaningfulAlertChange(existing.alert.details, updatedDetails);
|
|
264
|
+
if (!shouldUpdate) return existing.alert;
|
|
265
|
+
|
|
266
|
+
existing.alert.details = updatedDetails;
|
|
267
|
+
existing.alert.message = generateAlertMessage(type, updatedDetails);
|
|
268
|
+
existing.alert.timestamp = now;
|
|
269
|
+
existing.lastUpdate = now;
|
|
270
|
+
|
|
271
|
+
const idx = this.recentAlerts.findIndex(a => a.id === existing.alert.id);
|
|
272
|
+
if (idx >= 0) {
|
|
273
|
+
this.recentAlerts.splice(idx, 1);
|
|
274
|
+
}
|
|
275
|
+
this.recentAlerts.unshift(existing.alert);
|
|
276
|
+
if (this.recentAlerts.length > this.maxAlerts) {
|
|
277
|
+
this.recentAlerts.pop();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (this.notifyOnUpdates) {
|
|
281
|
+
const rule = this.rules.find(r => r.id === ruleId);
|
|
282
|
+
if (rule && shouldSendUpdateNotification(this.notifyOnUpdates, existing.lastNotified, now, this.updateNotificationCooldownMs)) {
|
|
283
|
+
existing.lastNotified = now;
|
|
284
|
+
await this.sendNotifications(existing.alert, rule);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return existing.alert;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
164
292
|
/**
|
|
165
293
|
* Get notification title based on alert type
|
|
166
294
|
* For movement alerts with LLM descriptions, use the smart description as title
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AlertDetails, AlertType } from '../models/alert';
|
|
2
|
+
import { GlobalTrackingId } from '../models/tracked-object';
|
|
3
|
+
|
|
4
|
+
export function hasMeaningfulAlertChange(prev: AlertDetails, next: AlertDetails): boolean {
|
|
5
|
+
return (
|
|
6
|
+
prev.fromCameraId !== next.fromCameraId ||
|
|
7
|
+
prev.toCameraId !== next.toCameraId ||
|
|
8
|
+
prev.cameraId !== next.cameraId ||
|
|
9
|
+
prev.objectLabel !== next.objectLabel ||
|
|
10
|
+
prev.pathDescription !== next.pathDescription ||
|
|
11
|
+
JSON.stringify(prev.involvedLandmarks || []) !== JSON.stringify(next.involvedLandmarks || [])
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getActiveAlertKey(
|
|
16
|
+
type: AlertType,
|
|
17
|
+
ruleId: string,
|
|
18
|
+
trackedId: GlobalTrackingId
|
|
19
|
+
): string {
|
|
20
|
+
return `${type}:${ruleId}:${trackedId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shouldSendUpdateNotification(
|
|
24
|
+
enabled: boolean,
|
|
25
|
+
lastNotified: number,
|
|
26
|
+
now: number,
|
|
27
|
+
cooldownMs: number
|
|
28
|
+
): boolean {
|
|
29
|
+
if (!enabled) return false;
|
|
30
|
+
if (cooldownMs <= 0) return true;
|
|
31
|
+
return now - lastNotified >= cooldownMs;
|
|
32
|
+
}
|
|
@@ -61,6 +61,8 @@ export interface TrackingEngineConfig {
|
|
|
61
61
|
loiteringThreshold: number;
|
|
62
62
|
/** Per-object alert cooldown (ms) */
|
|
63
63
|
objectAlertCooldown: number;
|
|
64
|
+
/** Minimum detection score to consider */
|
|
65
|
+
minDetectionScore: number;
|
|
64
66
|
/** Use LLM for enhanced descriptions */
|
|
65
67
|
useLlmDescriptions: boolean;
|
|
66
68
|
/** Specific LLM device IDs to use (if not set, auto-discovers all for load balancing) */
|
|
@@ -112,7 +114,9 @@ export class TrackingEngine {
|
|
|
112
114
|
private spatialReasoning: SpatialReasoningEngine;
|
|
113
115
|
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
114
116
|
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
117
|
+
private loiteringTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
115
118
|
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
119
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
116
120
|
/** Track last alert time per object to enforce cooldown */
|
|
117
121
|
private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
|
|
118
122
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
@@ -215,6 +219,10 @@ export class TrackingEngine {
|
|
|
215
219
|
this.checkForLostObjects();
|
|
216
220
|
}, 30000); // Check every 30 seconds
|
|
217
221
|
|
|
222
|
+
this.cleanupInterval = setInterval(() => {
|
|
223
|
+
this.state.cleanup();
|
|
224
|
+
}, 300000); // Cleanup every 5 minutes
|
|
225
|
+
|
|
218
226
|
this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
|
|
219
227
|
}
|
|
220
228
|
|
|
@@ -236,12 +244,22 @@ export class TrackingEngine {
|
|
|
236
244
|
}
|
|
237
245
|
this.pendingTimers.clear();
|
|
238
246
|
|
|
247
|
+
for (const timer of this.loiteringTimers.values()) {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
this.loiteringTimers.clear();
|
|
251
|
+
|
|
239
252
|
// Stop lost check interval
|
|
240
253
|
if (this.lostCheckInterval) {
|
|
241
254
|
clearInterval(this.lostCheckInterval);
|
|
242
255
|
this.lostCheckInterval = null;
|
|
243
256
|
}
|
|
244
257
|
|
|
258
|
+
if (this.cleanupInterval) {
|
|
259
|
+
clearInterval(this.cleanupInterval);
|
|
260
|
+
this.cleanupInterval = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
this.console.log('Tracking engine stopped');
|
|
246
264
|
}
|
|
247
265
|
|
|
@@ -264,7 +282,7 @@ export class TrackingEngine {
|
|
|
264
282
|
|
|
265
283
|
for (const detection of detected.detections) {
|
|
266
284
|
// Skip low-confidence detections
|
|
267
|
-
if (detection.score <
|
|
285
|
+
if (detection.score < this.config.minDetectionScore) continue;
|
|
268
286
|
|
|
269
287
|
// If in training mode, record trainer detections
|
|
270
288
|
if (this.isTrainingActive() && detection.className === 'person') {
|
|
@@ -358,6 +376,15 @@ export class TrackingEngine {
|
|
|
358
376
|
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
359
377
|
|
|
360
378
|
try {
|
|
379
|
+
if (!this.config.useLlmDescriptions) {
|
|
380
|
+
return this.spatialReasoning.generateMovementDescription(
|
|
381
|
+
tracked,
|
|
382
|
+
fromCameraId,
|
|
383
|
+
toCameraId,
|
|
384
|
+
transitTime
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
361
388
|
// Check rate limiting - if not allowed, return null to use basic description
|
|
362
389
|
if (!this.tryLlmCall()) {
|
|
363
390
|
this.console.log('[Movement] LLM rate-limited, using basic notification');
|
|
@@ -366,11 +393,9 @@ export class TrackingEngine {
|
|
|
366
393
|
|
|
367
394
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
368
395
|
let mediaObject: MediaObject | undefined;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
mediaObject = await camera.takePicture();
|
|
373
|
-
}
|
|
396
|
+
const camera = systemManager.getDeviceById<Camera>(currentCameraId);
|
|
397
|
+
if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
398
|
+
mediaObject = await camera.takePicture();
|
|
374
399
|
}
|
|
375
400
|
|
|
376
401
|
// Use spatial reasoning engine for rich context-aware description
|
|
@@ -460,6 +485,8 @@ export class TrackingEngine {
|
|
|
460
485
|
// Check if this is a cross-camera transition
|
|
461
486
|
const lastSighting = getLastSighting(tracked);
|
|
462
487
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
488
|
+
// Cancel any pending loitering alert if object already transitioned
|
|
489
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
463
490
|
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
464
491
|
|
|
465
492
|
// Update cached snapshot from new camera (object is now visible here)
|
|
@@ -491,33 +518,57 @@ export class TrackingEngine {
|
|
|
491
518
|
);
|
|
492
519
|
|
|
493
520
|
// Check loitering threshold and per-object cooldown before alerting
|
|
494
|
-
if (this.passesLoiteringThreshold(tracked)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
+
if (this.passesLoiteringThreshold(tracked)) {
|
|
522
|
+
if (this.isInAlertCooldown(tracked.globalId)) {
|
|
523
|
+
const spatialResult = await this.spatialReasoning.generateMovementDescription(
|
|
524
|
+
tracked,
|
|
525
|
+
lastSighting.cameraId,
|
|
526
|
+
sighting.cameraId,
|
|
527
|
+
transitDuration
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
531
|
+
fromCameraId: lastSighting.cameraId,
|
|
532
|
+
fromCameraName: lastSighting.cameraName,
|
|
533
|
+
toCameraId: sighting.cameraId,
|
|
534
|
+
toCameraName: sighting.cameraName,
|
|
535
|
+
transitTime: transitDuration,
|
|
536
|
+
objectClass: sighting.detection.className,
|
|
537
|
+
objectLabel: spatialResult.description || sighting.detection.label,
|
|
538
|
+
detectionId: sighting.detectionId,
|
|
539
|
+
pathDescription: spatialResult.pathDescription,
|
|
540
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
541
|
+
usedLlm: spatialResult.usedLlm,
|
|
542
|
+
});
|
|
543
|
+
} else {
|
|
544
|
+
// Get spatial reasoning result with RAG context
|
|
545
|
+
const spatialResult = await this.getSpatialDescription(
|
|
546
|
+
tracked,
|
|
547
|
+
lastSighting.cameraId,
|
|
548
|
+
sighting.cameraId,
|
|
549
|
+
transitDuration,
|
|
550
|
+
sighting.cameraId
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// Generate movement alert for cross-camera transition
|
|
554
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
555
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
556
|
+
fromCameraId: lastSighting.cameraId,
|
|
557
|
+
fromCameraName: lastSighting.cameraName,
|
|
558
|
+
toCameraId: sighting.cameraId,
|
|
559
|
+
toCameraName: sighting.cameraName,
|
|
560
|
+
transitTime: transitDuration,
|
|
561
|
+
objectClass: sighting.detection.className,
|
|
562
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
563
|
+
detectionId: sighting.detectionId,
|
|
564
|
+
// Include spatial context for enriched alerts
|
|
565
|
+
pathDescription: spatialResult?.pathDescription,
|
|
566
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
567
|
+
usedLlm: spatialResult?.usedLlm,
|
|
568
|
+
}, mediaObject);
|
|
569
|
+
|
|
570
|
+
this.recordAlertTime(tracked.globalId);
|
|
571
|
+
}
|
|
521
572
|
}
|
|
522
573
|
}
|
|
523
574
|
|
|
@@ -569,18 +620,49 @@ export class TrackingEngine {
|
|
|
569
620
|
}
|
|
570
621
|
|
|
571
622
|
// Check after loitering threshold if object is still being tracked
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
623
|
+
const existing = this.loiteringTimers.get(globalId);
|
|
624
|
+
if (existing) {
|
|
625
|
+
clearTimeout(existing);
|
|
626
|
+
this.loiteringTimers.delete(globalId);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const timer = setTimeout(async () => {
|
|
630
|
+
try {
|
|
631
|
+
const tracked = this.state.getObject(globalId);
|
|
632
|
+
if (!tracked || tracked.state !== 'active') return;
|
|
575
633
|
|
|
576
|
-
|
|
577
|
-
|
|
634
|
+
const lastSighting = getLastSighting(tracked);
|
|
635
|
+
if (!lastSighting || lastSighting.cameraId !== sighting.cameraId) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const maxStaleMs = Math.max(10000, this.config.loiteringThreshold * 2);
|
|
640
|
+
if (Date.now() - lastSighting.timestamp > maxStaleMs) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Check if we've already alerted for this object
|
|
645
|
+
if (this.isInAlertCooldown(globalId)) {
|
|
646
|
+
const spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
647
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
648
|
+
cameraId: sighting.cameraId,
|
|
649
|
+
cameraName: sighting.cameraName,
|
|
650
|
+
toCameraId: sighting.cameraId,
|
|
651
|
+
toCameraName: sighting.cameraName,
|
|
652
|
+
objectClass: sighting.detection.className,
|
|
653
|
+
objectLabel: spatialResult.description,
|
|
654
|
+
detectionId: sighting.detectionId,
|
|
655
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
656
|
+
usedLlm: spatialResult.usedLlm,
|
|
657
|
+
});
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
578
660
|
|
|
579
661
|
// Use prefetched LLM result if available (started when snapshot was captured)
|
|
580
662
|
let spatialResult: SpatialReasoningResult;
|
|
581
663
|
const pendingDescription = this.pendingDescriptions.get(globalId);
|
|
582
664
|
|
|
583
|
-
|
|
665
|
+
if (pendingDescription) {
|
|
584
666
|
this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
|
|
585
667
|
try {
|
|
586
668
|
spatialResult = await pendingDescription;
|
|
@@ -591,7 +673,7 @@ export class TrackingEngine {
|
|
|
591
673
|
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
592
674
|
}
|
|
593
675
|
this.pendingDescriptions.delete(globalId);
|
|
594
|
-
|
|
676
|
+
} else {
|
|
595
677
|
// No prefetch available - only call LLM if rate limit allows
|
|
596
678
|
if (this.tryLlmCall()) {
|
|
597
679
|
this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
|
|
@@ -607,7 +689,8 @@ export class TrackingEngine {
|
|
|
607
689
|
|
|
608
690
|
// Always use movement alert type for smart notifications with LLM descriptions
|
|
609
691
|
// The property_entry/property_exit types are legacy and disabled by default
|
|
610
|
-
|
|
692
|
+
const mediaObject = this.snapshotCache.get(globalId);
|
|
693
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
611
694
|
cameraId: sighting.cameraId,
|
|
612
695
|
cameraName: sighting.cameraName,
|
|
613
696
|
toCameraId: sighting.cameraId,
|
|
@@ -617,10 +700,15 @@ export class TrackingEngine {
|
|
|
617
700
|
detectionId: sighting.detectionId,
|
|
618
701
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
619
702
|
usedLlm: spatialResult.usedLlm,
|
|
620
|
-
|
|
703
|
+
}, mediaObject);
|
|
621
704
|
|
|
622
|
-
|
|
705
|
+
this.recordAlertTime(globalId);
|
|
706
|
+
} finally {
|
|
707
|
+
this.loiteringTimers.delete(globalId);
|
|
708
|
+
}
|
|
623
709
|
}, this.config.loiteringThreshold);
|
|
710
|
+
|
|
711
|
+
this.loiteringTimers.set(globalId, timer);
|
|
624
712
|
}
|
|
625
713
|
|
|
626
714
|
/** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
|
|
@@ -712,6 +800,9 @@ export class TrackingEngine {
|
|
|
712
800
|
// Mark as pending and set timer
|
|
713
801
|
this.state.markPending(tracked.globalId);
|
|
714
802
|
|
|
803
|
+
// Cancel any pending loitering alert
|
|
804
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
805
|
+
|
|
715
806
|
// Capture a fresh snapshot now while object is still visible (before they leave)
|
|
716
807
|
// Also starts LLM analysis immediately in parallel
|
|
717
808
|
if (this.config.useLlmDescriptions) {
|
|
@@ -756,6 +847,7 @@ export class TrackingEngine {
|
|
|
756
847
|
}
|
|
757
848
|
|
|
758
849
|
// Use movement alert for exit too - smart notifications with LLM descriptions
|
|
850
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
759
851
|
await this.alertManager.checkAndAlert('movement', current, {
|
|
760
852
|
cameraId: sighting.cameraId,
|
|
761
853
|
cameraName: sighting.cameraName,
|
|
@@ -765,7 +857,9 @@ export class TrackingEngine {
|
|
|
765
857
|
objectLabel: spatialResult.description,
|
|
766
858
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
767
859
|
usedLlm: spatialResult.usedLlm,
|
|
768
|
-
});
|
|
860
|
+
}, mediaObject);
|
|
861
|
+
|
|
862
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
769
863
|
|
|
770
864
|
// Clean up cached snapshot and pending descriptions after exit alert
|
|
771
865
|
this.snapshotCache.delete(tracked.globalId);
|
|
@@ -787,6 +881,7 @@ export class TrackingEngine {
|
|
|
787
881
|
|
|
788
882
|
if (timeSinceSeen > this.config.lostTimeout) {
|
|
789
883
|
this.state.markLost(tracked.globalId);
|
|
884
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
790
885
|
this.console.log(
|
|
791
886
|
`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
792
887
|
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
|
|
@@ -800,10 +895,21 @@ export class TrackingEngine {
|
|
|
800
895
|
objectClass: tracked.className,
|
|
801
896
|
objectLabel: tracked.label,
|
|
802
897
|
});
|
|
898
|
+
|
|
899
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
803
900
|
}
|
|
804
901
|
}
|
|
805
902
|
}
|
|
806
903
|
|
|
904
|
+
/** Clear a pending loitering timer if present */
|
|
905
|
+
private clearLoiteringTimer(globalId: GlobalTrackingId): void {
|
|
906
|
+
const timer = this.loiteringTimers.get(globalId);
|
|
907
|
+
if (timer) {
|
|
908
|
+
clearTimeout(timer);
|
|
909
|
+
this.loiteringTimers.delete(globalId);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
807
913
|
/** Update topology configuration */
|
|
808
914
|
updateTopology(topology: CameraTopology): void {
|
|
809
915
|
this.topology = topology;
|