@blueharford/scrypted-spatial-awareness 0.6.31 → 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 +381 -113
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +4 -2
- package/session-manager-plugin.pkg +0 -0
- package/src/alerts/alert-manager.ts +149 -21
- package/src/alerts/alert-utils.ts +32 -0
- package/src/core/tracking-engine.ts +161 -46
- package/src/main.ts +55 -13
- 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
|
}
|
|
Binary file
|
|
@@ -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') {
|
|
@@ -328,15 +346,24 @@ export class TrackingEngine {
|
|
|
328
346
|
}
|
|
329
347
|
|
|
330
348
|
/** Check and record LLM call - returns false if rate limited */
|
|
331
|
-
private tryLlmCall(): boolean {
|
|
349
|
+
private tryLlmCall(silent: boolean = false): boolean {
|
|
332
350
|
if (!this.isLlmCallAllowed()) {
|
|
333
|
-
|
|
351
|
+
// Only log once per rate limit window, not every call
|
|
352
|
+
if (!silent && !this.rateLimitLogged) {
|
|
353
|
+
const remaining = Math.ceil((this.config.llmDebounceInterval || 30000) - (Date.now() - this.lastLlmCallTime)) / 1000;
|
|
354
|
+
this.console.log(`[LLM] Rate limited, ${remaining.toFixed(0)}s until next call allowed`);
|
|
355
|
+
this.rateLimitLogged = true;
|
|
356
|
+
}
|
|
334
357
|
return false;
|
|
335
358
|
}
|
|
359
|
+
this.rateLimitLogged = false;
|
|
336
360
|
this.recordLlmCall();
|
|
337
361
|
return true;
|
|
338
362
|
}
|
|
339
363
|
|
|
364
|
+
/** Track if we've already logged rate limit message */
|
|
365
|
+
private rateLimitLogged: boolean = false;
|
|
366
|
+
|
|
340
367
|
/** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
|
|
341
368
|
private async getSpatialDescription(
|
|
342
369
|
tracked: TrackedObject,
|
|
@@ -349,6 +376,15 @@ export class TrackingEngine {
|
|
|
349
376
|
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
350
377
|
|
|
351
378
|
try {
|
|
379
|
+
if (!this.config.useLlmDescriptions) {
|
|
380
|
+
return this.spatialReasoning.generateMovementDescription(
|
|
381
|
+
tracked,
|
|
382
|
+
fromCameraId,
|
|
383
|
+
toCameraId,
|
|
384
|
+
transitTime
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
352
388
|
// Check rate limiting - if not allowed, return null to use basic description
|
|
353
389
|
if (!this.tryLlmCall()) {
|
|
354
390
|
this.console.log('[Movement] LLM rate-limited, using basic notification');
|
|
@@ -357,11 +393,9 @@ export class TrackingEngine {
|
|
|
357
393
|
|
|
358
394
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
359
395
|
let mediaObject: MediaObject | undefined;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
mediaObject = await camera.takePicture();
|
|
364
|
-
}
|
|
396
|
+
const camera = systemManager.getDeviceById<Camera>(currentCameraId);
|
|
397
|
+
if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
398
|
+
mediaObject = await camera.takePicture();
|
|
365
399
|
}
|
|
366
400
|
|
|
367
401
|
// Use spatial reasoning engine for rich context-aware description
|
|
@@ -451,6 +485,8 @@ export class TrackingEngine {
|
|
|
451
485
|
// Check if this is a cross-camera transition
|
|
452
486
|
const lastSighting = getLastSighting(tracked);
|
|
453
487
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
488
|
+
// Cancel any pending loitering alert if object already transitioned
|
|
489
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
454
490
|
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
455
491
|
|
|
456
492
|
// Update cached snapshot from new camera (object is now visible here)
|
|
@@ -482,33 +518,57 @@ export class TrackingEngine {
|
|
|
482
518
|
);
|
|
483
519
|
|
|
484
520
|
// Check loitering threshold and per-object cooldown before alerting
|
|
485
|
-
if (this.passesLoiteringThreshold(tracked)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
}
|
|
512
572
|
}
|
|
513
573
|
}
|
|
514
574
|
|
|
@@ -560,18 +620,49 @@ export class TrackingEngine {
|
|
|
560
620
|
}
|
|
561
621
|
|
|
562
622
|
// Check after loitering threshold if object is still being tracked
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
623
|
+
const existing = this.loiteringTimers.get(globalId);
|
|
624
|
+
if (existing) {
|
|
625
|
+
clearTimeout(existing);
|
|
626
|
+
this.loiteringTimers.delete(globalId);
|
|
627
|
+
}
|
|
566
628
|
|
|
567
|
-
|
|
568
|
-
|
|
629
|
+
const timer = setTimeout(async () => {
|
|
630
|
+
try {
|
|
631
|
+
const tracked = this.state.getObject(globalId);
|
|
632
|
+
if (!tracked || tracked.state !== 'active') return;
|
|
633
|
+
|
|
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
|
+
}
|
|
569
660
|
|
|
570
661
|
// Use prefetched LLM result if available (started when snapshot was captured)
|
|
571
662
|
let spatialResult: SpatialReasoningResult;
|
|
572
663
|
const pendingDescription = this.pendingDescriptions.get(globalId);
|
|
573
664
|
|
|
574
|
-
|
|
665
|
+
if (pendingDescription) {
|
|
575
666
|
this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
|
|
576
667
|
try {
|
|
577
668
|
spatialResult = await pendingDescription;
|
|
@@ -582,7 +673,7 @@ export class TrackingEngine {
|
|
|
582
673
|
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
583
674
|
}
|
|
584
675
|
this.pendingDescriptions.delete(globalId);
|
|
585
|
-
|
|
676
|
+
} else {
|
|
586
677
|
// No prefetch available - only call LLM if rate limit allows
|
|
587
678
|
if (this.tryLlmCall()) {
|
|
588
679
|
this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
|
|
@@ -598,7 +689,8 @@ export class TrackingEngine {
|
|
|
598
689
|
|
|
599
690
|
// Always use movement alert type for smart notifications with LLM descriptions
|
|
600
691
|
// The property_entry/property_exit types are legacy and disabled by default
|
|
601
|
-
|
|
692
|
+
const mediaObject = this.snapshotCache.get(globalId);
|
|
693
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
602
694
|
cameraId: sighting.cameraId,
|
|
603
695
|
cameraName: sighting.cameraName,
|
|
604
696
|
toCameraId: sighting.cameraId,
|
|
@@ -608,10 +700,15 @@ export class TrackingEngine {
|
|
|
608
700
|
detectionId: sighting.detectionId,
|
|
609
701
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
610
702
|
usedLlm: spatialResult.usedLlm,
|
|
611
|
-
|
|
703
|
+
}, mediaObject);
|
|
612
704
|
|
|
613
|
-
|
|
705
|
+
this.recordAlertTime(globalId);
|
|
706
|
+
} finally {
|
|
707
|
+
this.loiteringTimers.delete(globalId);
|
|
708
|
+
}
|
|
614
709
|
}, this.config.loiteringThreshold);
|
|
710
|
+
|
|
711
|
+
this.loiteringTimers.set(globalId, timer);
|
|
615
712
|
}
|
|
616
713
|
|
|
617
714
|
/** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
|
|
@@ -703,6 +800,9 @@ export class TrackingEngine {
|
|
|
703
800
|
// Mark as pending and set timer
|
|
704
801
|
this.state.markPending(tracked.globalId);
|
|
705
802
|
|
|
803
|
+
// Cancel any pending loitering alert
|
|
804
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
805
|
+
|
|
706
806
|
// Capture a fresh snapshot now while object is still visible (before they leave)
|
|
707
807
|
// Also starts LLM analysis immediately in parallel
|
|
708
808
|
if (this.config.useLlmDescriptions) {
|
|
@@ -747,6 +847,7 @@ export class TrackingEngine {
|
|
|
747
847
|
}
|
|
748
848
|
|
|
749
849
|
// Use movement alert for exit too - smart notifications with LLM descriptions
|
|
850
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
750
851
|
await this.alertManager.checkAndAlert('movement', current, {
|
|
751
852
|
cameraId: sighting.cameraId,
|
|
752
853
|
cameraName: sighting.cameraName,
|
|
@@ -756,7 +857,9 @@ export class TrackingEngine {
|
|
|
756
857
|
objectLabel: spatialResult.description,
|
|
757
858
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
758
859
|
usedLlm: spatialResult.usedLlm,
|
|
759
|
-
});
|
|
860
|
+
}, mediaObject);
|
|
861
|
+
|
|
862
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
760
863
|
|
|
761
864
|
// Clean up cached snapshot and pending descriptions after exit alert
|
|
762
865
|
this.snapshotCache.delete(tracked.globalId);
|
|
@@ -778,6 +881,7 @@ export class TrackingEngine {
|
|
|
778
881
|
|
|
779
882
|
if (timeSinceSeen > this.config.lostTimeout) {
|
|
780
883
|
this.state.markLost(tracked.globalId);
|
|
884
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
781
885
|
this.console.log(
|
|
782
886
|
`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
783
887
|
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
|
|
@@ -791,10 +895,21 @@ export class TrackingEngine {
|
|
|
791
895
|
objectClass: tracked.className,
|
|
792
896
|
objectLabel: tracked.label,
|
|
793
897
|
});
|
|
898
|
+
|
|
899
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
794
900
|
}
|
|
795
901
|
}
|
|
796
902
|
}
|
|
797
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
|
+
|
|
798
913
|
/** Update topology configuration */
|
|
799
914
|
updateTopology(topology: CameraTopology): void {
|
|
800
915
|
this.topology = topology;
|