@blueharford/scrypted-spatial-awareness 0.1.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/.vscode/settings.json +3 -0
- package/CLAUDE.md +168 -0
- package/README.md +152 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +1 -0
- package/dist/main.nodejs.js.map +1 -0
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +37376 -0
- package/out/main.nodejs.js.map +1 -0
- package/out/plugin.zip +0 -0
- package/package.json +59 -0
- package/src/alerts/alert-manager.ts +347 -0
- package/src/core/object-correlator.ts +376 -0
- package/src/core/tracking-engine.ts +367 -0
- package/src/devices/global-tracker-sensor.ts +191 -0
- package/src/devices/tracking-zone.ts +245 -0
- package/src/integrations/mqtt-publisher.ts +320 -0
- package/src/main.ts +690 -0
- package/src/models/alert.ts +229 -0
- package/src/models/topology.ts +168 -0
- package/src/models/tracked-object.ts +226 -0
- package/src/state/tracking-state.ts +285 -0
- package/src/ui/editor.html +1051 -0
- package/src/utils/id-generator.ts +36 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object Correlator
|
|
3
|
+
* Matches objects across cameras using multi-factor scoring
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CameraTopology,
|
|
8
|
+
CameraConnection,
|
|
9
|
+
findConnection,
|
|
10
|
+
ClipPath,
|
|
11
|
+
} from '../models/topology';
|
|
12
|
+
import {
|
|
13
|
+
TrackedObject,
|
|
14
|
+
ObjectSighting,
|
|
15
|
+
CorrelationCandidate,
|
|
16
|
+
CorrelationFactors,
|
|
17
|
+
getLastSighting,
|
|
18
|
+
} from '../models/tracked-object';
|
|
19
|
+
import { TrackingEngineConfig } from './tracking-engine';
|
|
20
|
+
|
|
21
|
+
export class ObjectCorrelator {
|
|
22
|
+
private topology: CameraTopology;
|
|
23
|
+
private config: TrackingEngineConfig;
|
|
24
|
+
|
|
25
|
+
constructor(topology: CameraTopology, config: TrackingEngineConfig) {
|
|
26
|
+
this.topology = topology;
|
|
27
|
+
this.config = config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Find best matching tracked object for a new sighting
|
|
32
|
+
* Returns null if no suitable match found
|
|
33
|
+
*/
|
|
34
|
+
async findBestMatch(
|
|
35
|
+
sighting: ObjectSighting,
|
|
36
|
+
activeObjects: TrackedObject[]
|
|
37
|
+
): Promise<CorrelationCandidate | null> {
|
|
38
|
+
const candidates: CorrelationCandidate[] = [];
|
|
39
|
+
|
|
40
|
+
for (const tracked of activeObjects) {
|
|
41
|
+
const candidate = await this.evaluateCandidate(tracked, sighting);
|
|
42
|
+
|
|
43
|
+
// Only consider if above threshold
|
|
44
|
+
if (candidate.confidence >= this.config.correlationThreshold) {
|
|
45
|
+
candidates.push(candidate);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (candidates.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
// Sort by confidence (highest first)
|
|
52
|
+
candidates.sort((a, b) => b.confidence - a.confidence);
|
|
53
|
+
|
|
54
|
+
// Return best match
|
|
55
|
+
return candidates[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate correlation confidence between tracked object and new sighting
|
|
60
|
+
*/
|
|
61
|
+
private async evaluateCandidate(
|
|
62
|
+
tracked: TrackedObject,
|
|
63
|
+
sighting: ObjectSighting
|
|
64
|
+
): Promise<CorrelationCandidate> {
|
|
65
|
+
const factors: CorrelationFactors = {
|
|
66
|
+
timing: this.evaluateTimingFactor(tracked, sighting),
|
|
67
|
+
visual: await this.evaluateVisualFactor(tracked, sighting),
|
|
68
|
+
spatial: this.evaluateSpatialFactor(tracked, sighting),
|
|
69
|
+
class: this.evaluateClassFactor(tracked, sighting),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Calculate weighted confidence
|
|
73
|
+
// Class mismatch is a hard veto
|
|
74
|
+
if (factors.class === 0) {
|
|
75
|
+
return {
|
|
76
|
+
trackedObject: tracked,
|
|
77
|
+
newSighting: sighting,
|
|
78
|
+
confidence: 0,
|
|
79
|
+
factors,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Timing completely off is also a veto
|
|
84
|
+
if (factors.timing === 0) {
|
|
85
|
+
return {
|
|
86
|
+
trackedObject: tracked,
|
|
87
|
+
newSighting: sighting,
|
|
88
|
+
confidence: 0,
|
|
89
|
+
factors,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Weighted combination:
|
|
94
|
+
// - Timing: 30% - Transit time matches expected range
|
|
95
|
+
// - Visual: 35% - Embedding similarity
|
|
96
|
+
// - Spatial: 25% - Exit/entry zone coherence
|
|
97
|
+
// - Class: 10% - Object class match (already vetoed if 0)
|
|
98
|
+
const confidence =
|
|
99
|
+
factors.timing * 0.30 +
|
|
100
|
+
factors.visual * 0.35 +
|
|
101
|
+
factors.spatial * 0.25 +
|
|
102
|
+
factors.class * 0.10;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
trackedObject: tracked,
|
|
106
|
+
newSighting: sighting,
|
|
107
|
+
confidence,
|
|
108
|
+
factors,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate timing-based correlation
|
|
114
|
+
* High score if transit time matches expected range
|
|
115
|
+
*/
|
|
116
|
+
private evaluateTimingFactor(
|
|
117
|
+
tracked: TrackedObject,
|
|
118
|
+
sighting: ObjectSighting
|
|
119
|
+
): number {
|
|
120
|
+
const lastSighting = getLastSighting(tracked);
|
|
121
|
+
if (!lastSighting) return 0;
|
|
122
|
+
|
|
123
|
+
// Same camera - always good timing
|
|
124
|
+
if (lastSighting.cameraId === sighting.cameraId) {
|
|
125
|
+
return 1.0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Find connection between cameras
|
|
129
|
+
const connection = findConnection(
|
|
130
|
+
this.topology,
|
|
131
|
+
lastSighting.cameraId,
|
|
132
|
+
sighting.cameraId
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (!connection) {
|
|
136
|
+
// No defined connection - low score but not zero
|
|
137
|
+
// (allows for uncharted paths)
|
|
138
|
+
return 0.2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const transitTime = sighting.timestamp - lastSighting.timestamp;
|
|
142
|
+
const { min, typical, max } = connection.transitTime;
|
|
143
|
+
|
|
144
|
+
// Way outside range
|
|
145
|
+
if (transitTime < min * 0.5 || transitTime > max * 2) {
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Slightly outside range
|
|
150
|
+
if (transitTime < min || transitTime > max) {
|
|
151
|
+
if (transitTime < min) {
|
|
152
|
+
return 0.3 * (transitTime / min);
|
|
153
|
+
}
|
|
154
|
+
return 0.3 * (max / transitTime);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Within range - score based on proximity to typical
|
|
158
|
+
const deviation = Math.abs(transitTime - typical);
|
|
159
|
+
const range = (max - min) / 2;
|
|
160
|
+
|
|
161
|
+
if (range === 0) return 1.0;
|
|
162
|
+
|
|
163
|
+
return Math.max(0.5, 1 - (deviation / range) * 0.5);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Evaluate visual similarity using embeddings
|
|
168
|
+
*/
|
|
169
|
+
private async evaluateVisualFactor(
|
|
170
|
+
tracked: TrackedObject,
|
|
171
|
+
sighting: ObjectSighting
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
// If visual matching is disabled, return neutral score
|
|
174
|
+
if (!this.config.useVisualMatching) {
|
|
175
|
+
return 0.5;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// No embeddings available
|
|
179
|
+
if (!tracked.visualDescriptor || !sighting.embedding) {
|
|
180
|
+
return 0.5; // Neutral - don't penalize or reward
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
// Calculate cosine similarity between embeddings
|
|
185
|
+
const similarity = this.cosineSimilarity(
|
|
186
|
+
tracked.visualDescriptor,
|
|
187
|
+
sighting.embedding
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Convert similarity [-1, 1] to score [0, 1]
|
|
191
|
+
return (similarity + 1) / 2;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return 0.5;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Evaluate spatial coherence (exit zone -> entry zone match)
|
|
199
|
+
*/
|
|
200
|
+
private evaluateSpatialFactor(
|
|
201
|
+
tracked: TrackedObject,
|
|
202
|
+
sighting: ObjectSighting
|
|
203
|
+
): number {
|
|
204
|
+
const lastSighting = getLastSighting(tracked);
|
|
205
|
+
if (!lastSighting) return 0.5;
|
|
206
|
+
|
|
207
|
+
// Same camera - full spatial coherence
|
|
208
|
+
if (lastSighting.cameraId === sighting.cameraId) {
|
|
209
|
+
return 1.0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Find connection
|
|
213
|
+
const connection = findConnection(
|
|
214
|
+
this.topology,
|
|
215
|
+
lastSighting.cameraId,
|
|
216
|
+
sighting.cameraId
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (!connection) {
|
|
220
|
+
return 0.3; // No connection defined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let score = 0;
|
|
224
|
+
|
|
225
|
+
// Check if last detection was in/near exit zone
|
|
226
|
+
if (lastSighting.position && connection.exitZone.length > 0) {
|
|
227
|
+
const inExitZone = this.isPointNearZone(
|
|
228
|
+
lastSighting.position,
|
|
229
|
+
connection.exitZone,
|
|
230
|
+
0.2 // 20% tolerance
|
|
231
|
+
);
|
|
232
|
+
if (inExitZone) score += 0.5;
|
|
233
|
+
} else {
|
|
234
|
+
score += 0.25; // No position data - give partial credit
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check if new detection is in/near entry zone
|
|
238
|
+
if (sighting.position && connection.entryZone.length > 0) {
|
|
239
|
+
const inEntryZone = this.isPointNearZone(
|
|
240
|
+
sighting.position,
|
|
241
|
+
connection.entryZone,
|
|
242
|
+
0.2
|
|
243
|
+
);
|
|
244
|
+
if (inEntryZone) score += 0.5;
|
|
245
|
+
} else {
|
|
246
|
+
score += 0.25;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return score;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Evaluate object class match
|
|
254
|
+
*/
|
|
255
|
+
private evaluateClassFactor(
|
|
256
|
+
tracked: TrackedObject,
|
|
257
|
+
sighting: ObjectSighting
|
|
258
|
+
): number {
|
|
259
|
+
// Exact match
|
|
260
|
+
if (tracked.className === sighting.detection.className) {
|
|
261
|
+
return 1.0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Similar classes (e.g., 'car' and 'vehicle')
|
|
265
|
+
const similarClasses: Record<string, string[]> = {
|
|
266
|
+
car: ['vehicle', 'truck', 'suv'],
|
|
267
|
+
vehicle: ['car', 'truck', 'suv'],
|
|
268
|
+
truck: ['vehicle', 'car'],
|
|
269
|
+
person: ['human'],
|
|
270
|
+
human: ['person'],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const similar = similarClasses[tracked.className] || [];
|
|
274
|
+
if (similar.includes(sighting.detection.className)) {
|
|
275
|
+
return 0.8;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Class mismatch
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Calculate cosine similarity between two base64-encoded embeddings
|
|
284
|
+
*/
|
|
285
|
+
private cosineSimilarity(embedding1: string, embedding2: string): number {
|
|
286
|
+
try {
|
|
287
|
+
const vec1 = this.decodeEmbedding(embedding1);
|
|
288
|
+
const vec2 = this.decodeEmbedding(embedding2);
|
|
289
|
+
|
|
290
|
+
if (vec1.length !== vec2.length || vec1.length === 0) {
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let dotProduct = 0;
|
|
295
|
+
let mag1 = 0;
|
|
296
|
+
let mag2 = 0;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < vec1.length; i++) {
|
|
299
|
+
dotProduct += vec1[i] * vec2[i];
|
|
300
|
+
mag1 += vec1[i] * vec1[i];
|
|
301
|
+
mag2 += vec2[i] * vec2[i];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
mag1 = Math.sqrt(mag1);
|
|
305
|
+
mag2 = Math.sqrt(mag2);
|
|
306
|
+
|
|
307
|
+
if (mag1 === 0 || mag2 === 0) return 0;
|
|
308
|
+
|
|
309
|
+
return dotProduct / (mag1 * mag2);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Decode base64 embedding to float array
|
|
317
|
+
*/
|
|
318
|
+
private decodeEmbedding(base64: string): number[] {
|
|
319
|
+
try {
|
|
320
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
321
|
+
const floats: number[] = [];
|
|
322
|
+
|
|
323
|
+
for (let i = 0; i < buffer.length; i += 4) {
|
|
324
|
+
floats.push(buffer.readFloatLE(i));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return floats;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if a point is near/inside a polygon zone
|
|
335
|
+
*/
|
|
336
|
+
private isPointNearZone(
|
|
337
|
+
point: { x: number; y: number },
|
|
338
|
+
zone: ClipPath,
|
|
339
|
+
tolerance: number
|
|
340
|
+
): boolean {
|
|
341
|
+
if (zone.length < 3) return false;
|
|
342
|
+
|
|
343
|
+
// Convert normalized point to zone coordinates (0-100)
|
|
344
|
+
const px = point.x * 100;
|
|
345
|
+
const py = point.y * 100;
|
|
346
|
+
|
|
347
|
+
// Point in polygon test (ray casting)
|
|
348
|
+
let inside = false;
|
|
349
|
+
for (let i = 0, j = zone.length - 1; i < zone.length; j = i++) {
|
|
350
|
+
const xi = zone[i][0];
|
|
351
|
+
const yi = zone[i][1];
|
|
352
|
+
const xj = zone[j][0];
|
|
353
|
+
const yj = zone[j][1];
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
yi > py !== yj > py &&
|
|
357
|
+
px < ((xj - xi) * (py - yi)) / (yj - yi) + xi
|
|
358
|
+
) {
|
|
359
|
+
inside = !inside;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (inside) return true;
|
|
364
|
+
|
|
365
|
+
// Check if near the zone (within tolerance)
|
|
366
|
+
for (const vertex of zone) {
|
|
367
|
+
const dx = Math.abs(px - vertex[0]) / 100;
|
|
368
|
+
const dy = Math.abs(py - vertex[1]) / 100;
|
|
369
|
+
if (dx < tolerance && dy < tolerance) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|