@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,229 @@
1
+ /**
2
+ * Alert Models
3
+ * Defines alerts and notification rules for tracking events
4
+ */
5
+
6
+ import { GlobalTrackingId, ObjectClass } from './tracked-object';
7
+
8
+ /** Alert severity levels */
9
+ export type AlertSeverity = 'info' | 'warning' | 'critical';
10
+
11
+ /** Types of alerts the system can generate */
12
+ export type AlertType =
13
+ | 'property_entry'
14
+ | 'property_exit'
15
+ | 'unusual_path'
16
+ | 'dwell_time'
17
+ | 'restricted_zone'
18
+ | 'lost_tracking'
19
+ | 'reappearance'
20
+ | 'zone_entry'
21
+ | 'zone_exit';
22
+
23
+ /** Alert condition operators */
24
+ export type AlertOperator = 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than';
25
+
26
+ /** An alert generated by the system */
27
+ export interface Alert {
28
+ /** Unique alert ID */
29
+ id: string;
30
+ /** Type of alert */
31
+ type: AlertType;
32
+ /** Severity level */
33
+ severity: AlertSeverity;
34
+ /** Timestamp when alert was generated */
35
+ timestamp: number;
36
+ /** ID of the tracked object that triggered this alert */
37
+ trackedObjectId: GlobalTrackingId;
38
+ /** Human-readable message */
39
+ message: string;
40
+ /** Additional details */
41
+ details: AlertDetails;
42
+ /** Whether the alert has been acknowledged */
43
+ acknowledged: boolean;
44
+ /** Rule ID that generated this alert (if any) */
45
+ ruleId?: string;
46
+ }
47
+
48
+ /** Additional details for an alert */
49
+ export interface AlertDetails {
50
+ /** Camera device ID */
51
+ cameraId?: string;
52
+ /** Camera display name */
53
+ cameraName?: string;
54
+ /** Zone name (for zone-related alerts) */
55
+ zoneName?: string;
56
+ /** Dwell time in milliseconds (for dwell alerts) */
57
+ dwellTime?: number;
58
+ /** Expected path (for unusual path alerts) */
59
+ expectedPath?: string;
60
+ /** Actual path taken */
61
+ actualPath?: string;
62
+ /** Detection ID for thumbnail */
63
+ detectionId?: string;
64
+ /** Object class */
65
+ objectClass?: ObjectClass;
66
+ /** Object label (if recognized) */
67
+ objectLabel?: string;
68
+ /** Thumbnail image URL or data */
69
+ thumbnailUrl?: string;
70
+ }
71
+
72
+ /** A condition for alert rules */
73
+ export interface AlertCondition {
74
+ /** Field path to evaluate (e.g., "className", "label", "totalDwellTime") */
75
+ field: string;
76
+ /** Comparison operator */
77
+ operator: AlertOperator;
78
+ /** Value to compare against */
79
+ value: string | number | boolean;
80
+ }
81
+
82
+ /** Alert rule configuration */
83
+ export interface AlertRule {
84
+ /** Unique rule ID */
85
+ id: string;
86
+ /** Rule display name */
87
+ name: string;
88
+ /** Whether the rule is enabled */
89
+ enabled: boolean;
90
+ /** Alert type this rule generates */
91
+ type: AlertType;
92
+ /** Conditions that must be met */
93
+ conditions: AlertCondition[];
94
+ /** Severity of alerts generated */
95
+ severity: AlertSeverity;
96
+ /** Scrypted Notifier device IDs to send alerts to */
97
+ notifiers: string[];
98
+ /** Minimum time between repeat alerts (cooldown) in milliseconds */
99
+ cooldown: number;
100
+ /** Object classes this rule applies to (empty = all) */
101
+ objectClasses?: ObjectClass[];
102
+ /** Specific cameras this rule applies to (empty = all) */
103
+ cameraIds?: string[];
104
+ /** Specific zones this rule applies to (empty = all) */
105
+ zoneIds?: string[];
106
+ }
107
+
108
+ /** Creates default alert rules */
109
+ export function createDefaultRules(): AlertRule[] {
110
+ return [
111
+ {
112
+ id: 'property-entry',
113
+ name: 'Property Entry',
114
+ enabled: true,
115
+ type: 'property_entry',
116
+ conditions: [],
117
+ severity: 'info',
118
+ notifiers: [],
119
+ cooldown: 300000, // 5 minutes
120
+ },
121
+ {
122
+ id: 'property-exit',
123
+ name: 'Property Exit',
124
+ enabled: true,
125
+ type: 'property_exit',
126
+ conditions: [],
127
+ severity: 'info',
128
+ notifiers: [],
129
+ cooldown: 300000,
130
+ },
131
+ {
132
+ id: 'unusual-path',
133
+ name: 'Unusual Path Detected',
134
+ enabled: true,
135
+ type: 'unusual_path',
136
+ conditions: [],
137
+ severity: 'warning',
138
+ notifiers: [],
139
+ cooldown: 600000, // 10 minutes
140
+ },
141
+ {
142
+ id: 'dwell-time',
143
+ name: 'Extended Dwell Time',
144
+ enabled: true,
145
+ type: 'dwell_time',
146
+ conditions: [
147
+ { field: 'totalDwellTime', operator: 'greater_than', value: 300000 } // 5 minutes
148
+ ],
149
+ severity: 'warning',
150
+ notifiers: [],
151
+ cooldown: 600000,
152
+ },
153
+ {
154
+ id: 'restricted-zone',
155
+ name: 'Restricted Zone Entry',
156
+ enabled: true,
157
+ type: 'restricted_zone',
158
+ conditions: [],
159
+ severity: 'critical',
160
+ notifiers: [],
161
+ cooldown: 0, // Always alert immediately
162
+ },
163
+ {
164
+ id: 'lost-tracking',
165
+ name: 'Lost Object Tracking',
166
+ enabled: false, // Disabled by default (can be noisy)
167
+ type: 'lost_tracking',
168
+ conditions: [],
169
+ severity: 'info',
170
+ notifiers: [],
171
+ cooldown: 300000,
172
+ },
173
+ ];
174
+ }
175
+
176
+ /** Generates a human-readable message for an alert */
177
+ export function generateAlertMessage(
178
+ type: AlertType,
179
+ details: AlertDetails
180
+ ): string {
181
+ const objectDesc = details.objectLabel
182
+ ? `${details.objectClass} (${details.objectLabel})`
183
+ : details.objectClass || 'Object';
184
+
185
+ switch (type) {
186
+ case 'property_entry':
187
+ return `${objectDesc} entered property via ${details.cameraName || 'unknown camera'}`;
188
+ case 'property_exit':
189
+ return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
190
+ case 'unusual_path':
191
+ return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
192
+ case 'dwell_time':
193
+ const dwellMinutes = Math.round((details.dwellTime || 0) / 60000);
194
+ return `${objectDesc} has been present for ${dwellMinutes} minutes in ${details.zoneName || details.cameraName || 'area'}`;
195
+ case 'restricted_zone':
196
+ return `${objectDesc} entered restricted zone: ${details.zoneName}`;
197
+ case 'lost_tracking':
198
+ return `Lost tracking of ${objectDesc} near ${details.cameraName || 'unknown location'}`;
199
+ case 'reappearance':
200
+ return `${objectDesc} reappeared on ${details.cameraName}`;
201
+ case 'zone_entry':
202
+ return `${objectDesc} entered ${details.zoneName}`;
203
+ case 'zone_exit':
204
+ return `${objectDesc} exited ${details.zoneName}`;
205
+ default:
206
+ return `Tracking event: ${type}`;
207
+ }
208
+ }
209
+
210
+ /** Creates an alert instance */
211
+ export function createAlert(
212
+ type: AlertType,
213
+ trackedObjectId: GlobalTrackingId,
214
+ details: AlertDetails,
215
+ severity: AlertSeverity = 'info',
216
+ ruleId?: string
217
+ ): Alert {
218
+ return {
219
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
220
+ type,
221
+ severity,
222
+ timestamp: Date.now(),
223
+ trackedObjectId,
224
+ message: generateAlertMessage(type, details),
225
+ details,
226
+ acknowledged: false,
227
+ ruleId,
228
+ };
229
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Camera Topology Models
3
+ * Defines the spatial relationships between cameras in the NVR system
4
+ */
5
+
6
+ /** A point in 2D space (normalized 0-100 or pixel coordinates) */
7
+ export type Point = [number, number];
8
+
9
+ /** A polygon path defined by an array of points */
10
+ export type ClipPath = Point[];
11
+
12
+ /** Position on a floor plan */
13
+ export interface FloorPlanPosition {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ /** Camera field of view configuration */
19
+ export interface CameraFOV {
20
+ /** FOV angle in degrees */
21
+ angle: number;
22
+ /** Direction the camera faces in degrees from north (0 = north, 90 = east) */
23
+ direction: number;
24
+ }
25
+
26
+ /** Transit time configuration between cameras */
27
+ export interface TransitTime {
28
+ /** Minimum expected transit time in milliseconds */
29
+ min: number;
30
+ /** Typical/average transit time in milliseconds */
31
+ typical: number;
32
+ /** Maximum expected transit time in milliseconds */
33
+ max: number;
34
+ }
35
+
36
+ /** Represents a camera in the topology */
37
+ export interface CameraNode {
38
+ /** Scrypted device ID */
39
+ deviceId: string;
40
+ /** Native ID for plugin reference */
41
+ nativeId: string;
42
+ /** Display name */
43
+ name: string;
44
+ /** Position on floor plan (optional) */
45
+ floorPlanPosition?: FloorPlanPosition;
46
+ /** Camera field of view configuration (optional) */
47
+ fov?: CameraFOV;
48
+ /** Is this an entry point to the property */
49
+ isEntryPoint: boolean;
50
+ /** Is this an exit point from the property */
51
+ isExitPoint: boolean;
52
+ /** Detection classes to track on this camera */
53
+ trackClasses: string[];
54
+ }
55
+
56
+ /** Represents a connection between two cameras */
57
+ export interface CameraConnection {
58
+ /** Unique identifier for this connection */
59
+ id: string;
60
+ /** Source camera device ID */
61
+ fromCameraId: string;
62
+ /** Target camera device ID */
63
+ toCameraId: string;
64
+ /** Exit zone in source camera (normalized coordinates 0-100) */
65
+ exitZone: ClipPath;
66
+ /** Entry zone in target camera (normalized coordinates 0-100) */
67
+ entryZone: ClipPath;
68
+ /** Expected transit time configuration */
69
+ transitTime: TransitTime;
70
+ /** Whether this connection works both ways */
71
+ bidirectional: boolean;
72
+ /** Human-readable path name (e.g., "Driveway to Front Door") */
73
+ name: string;
74
+ }
75
+
76
+ /** Zone type for alerting purposes */
77
+ export type GlobalZoneType = 'entry' | 'exit' | 'dwell' | 'restricted';
78
+
79
+ /** A zone that spans multiple cameras */
80
+ export interface GlobalZone {
81
+ /** Unique identifier */
82
+ id: string;
83
+ /** Display name */
84
+ name: string;
85
+ /** Zone type for alerting */
86
+ type: GlobalZoneType;
87
+ /** Camera zones that comprise this global zone */
88
+ cameraZones: CameraZoneMapping[];
89
+ }
90
+
91
+ /** Maps a zone to a specific camera */
92
+ export interface CameraZoneMapping {
93
+ /** Camera device ID */
94
+ cameraId: string;
95
+ /** Zone polygon on this camera */
96
+ zone: ClipPath;
97
+ }
98
+
99
+ /** Floor plan image configuration */
100
+ export interface FloorPlanConfig {
101
+ /** Base64 encoded image data or URL */
102
+ imageData?: string;
103
+ /** Image width in pixels */
104
+ width: number;
105
+ /** Image height in pixels */
106
+ height: number;
107
+ /** Scale factor (pixels per real-world unit) */
108
+ scale?: number;
109
+ }
110
+
111
+ /** Complete camera topology configuration */
112
+ export interface CameraTopology {
113
+ /** Version for migration support */
114
+ version: string;
115
+ /** All cameras in the system */
116
+ cameras: CameraNode[];
117
+ /** Connections between cameras */
118
+ connections: CameraConnection[];
119
+ /** Named zones spanning multiple cameras */
120
+ globalZones: GlobalZone[];
121
+ /** Floor plan configuration (optional) */
122
+ floorPlan?: FloorPlanConfig;
123
+ }
124
+
125
+ /** Creates an empty topology */
126
+ export function createEmptyTopology(): CameraTopology {
127
+ return {
128
+ version: '1.0',
129
+ cameras: [],
130
+ connections: [],
131
+ globalZones: [],
132
+ };
133
+ }
134
+
135
+ /** Finds a camera by device ID */
136
+ export function findCamera(topology: CameraTopology, deviceId: string): CameraNode | undefined {
137
+ return topology.cameras.find(c => c.deviceId === deviceId);
138
+ }
139
+
140
+ /** Finds connections from a camera */
141
+ export function findConnectionsFrom(topology: CameraTopology, cameraId: string): CameraConnection[] {
142
+ return topology.connections.filter(c =>
143
+ c.fromCameraId === cameraId ||
144
+ (c.bidirectional && c.toCameraId === cameraId)
145
+ );
146
+ }
147
+
148
+ /** Finds a connection between two cameras */
149
+ export function findConnection(
150
+ topology: CameraTopology,
151
+ fromCameraId: string,
152
+ toCameraId: string
153
+ ): CameraConnection | undefined {
154
+ return topology.connections.find(c =>
155
+ (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
156
+ (c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId)
157
+ );
158
+ }
159
+
160
+ /** Gets all entry point cameras */
161
+ export function getEntryPoints(topology: CameraTopology): CameraNode[] {
162
+ return topology.cameras.filter(c => c.isEntryPoint);
163
+ }
164
+
165
+ /** Gets all exit point cameras */
166
+ export function getExitPoints(topology: CameraTopology): CameraNode[] {
167
+ return topology.cameras.filter(c => c.isExitPoint);
168
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tracked Object Models
3
+ * Defines objects being tracked across multiple cameras
4
+ */
5
+
6
+ import { ObjectDetectionResult } from '@scrypted/sdk';
7
+
8
+ /** Unique identifier for a globally tracked object */
9
+ export type GlobalTrackingId = string;
10
+
11
+ /** Object detection class types */
12
+ export type ObjectClass = 'person' | 'car' | 'animal' | 'package' | 'vehicle' | string;
13
+
14
+ /** Tracking state of an object */
15
+ export type TrackingState = 'active' | 'pending' | 'exited' | 'lost';
16
+
17
+ /** A sighting of an object on a specific camera */
18
+ export interface ObjectSighting {
19
+ /** Detection from the camera */
20
+ detection: ObjectDetectionResult;
21
+ /** Camera device ID */
22
+ cameraId: string;
23
+ /** Camera name for display */
24
+ cameraName?: string;
25
+ /** Timestamp of detection */
26
+ timestamp: number;
27
+ /** Detection confidence */
28
+ confidence: number;
29
+ /** Visual embedding if available */
30
+ embedding?: string;
31
+ /** Detection image reference ID */
32
+ detectionId?: string;
33
+ /** Position in the camera frame (normalized 0-1) */
34
+ position?: {
35
+ x: number;
36
+ y: number;
37
+ };
38
+ }
39
+
40
+ /** Complete journey segment between cameras */
41
+ export interface JourneySegment {
42
+ /** Starting camera device ID */
43
+ fromCameraId: string;
44
+ /** Starting camera name */
45
+ fromCameraName?: string;
46
+ /** Ending camera device ID */
47
+ toCameraId: string;
48
+ /** Ending camera name */
49
+ toCameraName?: string;
50
+ /** Exit timestamp from source camera */
51
+ exitTime: number;
52
+ /** Entry timestamp on target camera */
53
+ entryTime: number;
54
+ /** Transit duration in milliseconds */
55
+ transitDuration: number;
56
+ /** Correlation confidence score (0-1) */
57
+ correlationConfidence: number;
58
+ }
59
+
60
+ /** A globally tracked object across cameras */
61
+ export interface TrackedObject {
62
+ /** Unique global tracking ID */
63
+ globalId: GlobalTrackingId;
64
+ /** Object class (person, car, etc.) */
65
+ className: ObjectClass;
66
+ /** Optional recognized label (face name, license plate) */
67
+ label?: string;
68
+ /** All sightings across cameras (chronological order) */
69
+ sightings: ObjectSighting[];
70
+ /** Journey segments between cameras */
71
+ journey: JourneySegment[];
72
+ /** First seen timestamp */
73
+ firstSeen: number;
74
+ /** Last seen timestamp */
75
+ lastSeen: number;
76
+ /** Currently active on camera(s) */
77
+ activeOnCameras: string[];
78
+ /** Entry point camera (if known) */
79
+ entryCamera?: string;
80
+ /** Entry point camera name */
81
+ entryCameraName?: string;
82
+ /** Has exited the property */
83
+ hasExited: boolean;
84
+ /** Exit camera (if known) */
85
+ exitCamera?: string;
86
+ /** Exit camera name */
87
+ exitCameraName?: string;
88
+ /** Total dwell time in milliseconds */
89
+ totalDwellTime: number;
90
+ /** Tracking state */
91
+ state: TrackingState;
92
+ /** Visual descriptor for re-identification (aggregated embedding) */
93
+ visualDescriptor?: string;
94
+ /** Best thumbnail detection ID */
95
+ bestThumbnailId?: string;
96
+ }
97
+
98
+ /** Pending correlation candidate */
99
+ export interface CorrelationCandidate {
100
+ /** Existing tracked object */
101
+ trackedObject: TrackedObject;
102
+ /** New sighting to potentially correlate */
103
+ newSighting: ObjectSighting;
104
+ /** Correlation confidence score 0-1 */
105
+ confidence: number;
106
+ /** Factors contributing to confidence */
107
+ factors: CorrelationFactors;
108
+ }
109
+
110
+ /** Factors used in correlation scoring */
111
+ export interface CorrelationFactors {
112
+ /** Timing factor: how well transit time matches expected range */
113
+ timing: number;
114
+ /** Visual factor: embedding similarity score */
115
+ visual: number;
116
+ /** Spatial factor: exit zone → entry zone coherence */
117
+ spatial: number;
118
+ /** Class factor: object class match */
119
+ class: number;
120
+ }
121
+
122
+ /** Creates a new tracked object from an initial sighting */
123
+ export function createTrackedObject(
124
+ globalId: GlobalTrackingId,
125
+ sighting: ObjectSighting,
126
+ isEntryPoint: boolean
127
+ ): TrackedObject {
128
+ return {
129
+ globalId,
130
+ className: sighting.detection.className as ObjectClass,
131
+ label: sighting.detection.label,
132
+ sightings: [sighting],
133
+ journey: [],
134
+ firstSeen: sighting.timestamp,
135
+ lastSeen: sighting.timestamp,
136
+ activeOnCameras: [sighting.cameraId],
137
+ entryCamera: isEntryPoint ? sighting.cameraId : undefined,
138
+ entryCameraName: isEntryPoint ? sighting.cameraName : undefined,
139
+ hasExited: false,
140
+ totalDwellTime: 0,
141
+ state: 'active',
142
+ visualDescriptor: sighting.embedding,
143
+ bestThumbnailId: sighting.detectionId,
144
+ };
145
+ }
146
+
147
+ /** Adds a sighting to an existing tracked object */
148
+ export function addSighting(tracked: TrackedObject, sighting: ObjectSighting): void {
149
+ tracked.sightings.push(sighting);
150
+ tracked.lastSeen = sighting.timestamp;
151
+
152
+ // Update active cameras
153
+ if (!tracked.activeOnCameras.includes(sighting.cameraId)) {
154
+ tracked.activeOnCameras.push(sighting.cameraId);
155
+ }
156
+
157
+ // Update visual descriptor if we have a new embedding
158
+ if (sighting.embedding && !tracked.visualDescriptor) {
159
+ tracked.visualDescriptor = sighting.embedding;
160
+ }
161
+
162
+ // Update best thumbnail if this has higher confidence
163
+ if (sighting.detectionId && sighting.confidence > 0.8) {
164
+ tracked.bestThumbnailId = sighting.detectionId;
165
+ }
166
+
167
+ // Update label if recognized
168
+ if (sighting.detection.label && !tracked.label) {
169
+ tracked.label = sighting.detection.label;
170
+ }
171
+ }
172
+
173
+ /** Adds a journey segment when object moves between cameras */
174
+ export function addJourneySegment(
175
+ tracked: TrackedObject,
176
+ segment: JourneySegment
177
+ ): void {
178
+ tracked.journey.push(segment);
179
+
180
+ // Remove from old camera, add to new
181
+ tracked.activeOnCameras = tracked.activeOnCameras.filter(
182
+ id => id !== segment.fromCameraId
183
+ );
184
+ if (!tracked.activeOnCameras.includes(segment.toCameraId)) {
185
+ tracked.activeOnCameras.push(segment.toCameraId);
186
+ }
187
+ }
188
+
189
+ /** Calculates total time an object has been tracked */
190
+ export function calculateDwellTime(tracked: TrackedObject): number {
191
+ if (tracked.sightings.length === 0) return 0;
192
+ return tracked.lastSeen - tracked.firstSeen;
193
+ }
194
+
195
+ /** Gets the last known camera for an object */
196
+ export function getLastCamera(tracked: TrackedObject): string | undefined {
197
+ if (tracked.sightings.length === 0) return undefined;
198
+ return tracked.sightings[tracked.sightings.length - 1].cameraId;
199
+ }
200
+
201
+ /** Gets the last sighting for an object */
202
+ export function getLastSighting(tracked: TrackedObject): ObjectSighting | undefined {
203
+ if (tracked.sightings.length === 0) return undefined;
204
+ return tracked.sightings[tracked.sightings.length - 1];
205
+ }
206
+
207
+ /** Generates a summary of the object's journey */
208
+ export function getJourneySummary(tracked: TrackedObject): string {
209
+ const cameras: string[] = [];
210
+
211
+ if (tracked.entryCamera) {
212
+ cameras.push(tracked.entryCameraName || tracked.entryCamera);
213
+ }
214
+
215
+ for (const segment of tracked.journey) {
216
+ if (!cameras.includes(segment.toCameraName || segment.toCameraId)) {
217
+ cameras.push(segment.toCameraName || segment.toCameraId);
218
+ }
219
+ }
220
+
221
+ if (tracked.exitCamera && !cameras.includes(tracked.exitCameraName || tracked.exitCamera)) {
222
+ cameras.push(tracked.exitCameraName || tracked.exitCamera);
223
+ }
224
+
225
+ return cameras.join(' → ');
226
+ }