@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.
@@ -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
+ }