@blueharford/scrypted-spatial-awareness 0.3.0 → 0.4.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/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.3.0",
3
+ "version": "0.4.0",
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",
@@ -14,7 +14,7 @@ import sdk, {
14
14
  Camera,
15
15
  MediaObject,
16
16
  } from '@scrypted/sdk';
17
- import { CameraTopology, CameraConnection, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
17
+ import { CameraTopology, CameraConnection, CameraNode, CameraZoneMapping, LandmarkType, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
18
18
  import {
19
19
  TrackedObject,
20
20
  ObjectSighting,
@@ -22,6 +22,21 @@ import {
22
22
  CorrelationCandidate,
23
23
  getLastSighting,
24
24
  } from '../models/tracked-object';
25
+ import {
26
+ TrainingSession,
27
+ TrainingSessionState,
28
+ TrainingCameraVisit,
29
+ TrainingTransit,
30
+ TrainingLandmark,
31
+ TrainingOverlap,
32
+ TrainingStructure,
33
+ TrainingConfig,
34
+ TrainingStatusUpdate,
35
+ TrainingApplicationResult,
36
+ DEFAULT_TRAINING_CONFIG,
37
+ createTrainingSession,
38
+ calculateTrainingStats,
39
+ } from '../models/training';
25
40
  import { TrackingState } from '../state/tracking-state';
26
41
  import { AlertManager } from '../alerts/alert-manager';
27
42
  import { ObjectCorrelator } from './object-correlator';
@@ -115,6 +130,14 @@ export class TrackingEngine {
115
130
  /** Minimum observations before suggesting a connection */
116
131
  private readonly MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
117
132
 
133
+ // ==================== Training Mode ====================
134
+ /** Current training session (null if not training) */
135
+ private trainingSession: TrainingSession | null = null;
136
+ /** Training configuration */
137
+ private trainingConfig: TrainingConfig = DEFAULT_TRAINING_CONFIG;
138
+ /** Callback for training status updates */
139
+ private onTrainingStatusUpdate?: (status: TrainingStatusUpdate) => void;
140
+
118
141
  constructor(
119
142
  topology: CameraTopology,
120
143
  state: TrackingState,
@@ -234,6 +257,11 @@ export class TrackingEngine {
234
257
  // Skip low-confidence detections
235
258
  if (detection.score < 0.5) continue;
236
259
 
260
+ // If in training mode, record trainer detections
261
+ if (this.isTrainingActive() && detection.className === 'person') {
262
+ this.recordTrainerDetection(cameraId, detection, detection.score);
263
+ }
264
+
237
265
  // Skip classes we're not tracking on this camera
238
266
  if (camera.trackClasses.length > 0 &&
239
267
  !camera.trackClasses.includes(detection.className)) {
@@ -937,4 +965,563 @@ export class TrackingEngine {
937
965
 
938
966
  return { segments, currentLocation };
939
967
  }
968
+
969
+ // ==================== Training Mode Methods ====================
970
+
971
+ /** Set callback for training status updates */
972
+ setTrainingStatusCallback(callback: (status: TrainingStatusUpdate) => void): void {
973
+ this.onTrainingStatusUpdate = callback;
974
+ }
975
+
976
+ /** Get current training session (if any) */
977
+ getTrainingSession(): TrainingSession | null {
978
+ return this.trainingSession;
979
+ }
980
+
981
+ /** Check if training mode is active */
982
+ isTrainingActive(): boolean {
983
+ return this.trainingSession !== null && this.trainingSession.state === 'active';
984
+ }
985
+
986
+ /** Start a new training session */
987
+ startTrainingSession(trainerName?: string, config?: Partial<TrainingConfig>): TrainingSession {
988
+ // End any existing session
989
+ if (this.trainingSession && this.trainingSession.state === 'active') {
990
+ this.endTrainingSession();
991
+ }
992
+
993
+ // Apply custom config
994
+ if (config) {
995
+ this.trainingConfig = { ...DEFAULT_TRAINING_CONFIG, ...config };
996
+ }
997
+
998
+ // Create new session
999
+ this.trainingSession = createTrainingSession(trainerName);
1000
+ this.trainingSession.state = 'active';
1001
+ this.console.log(`Training session started: ${this.trainingSession.id}`);
1002
+
1003
+ this.emitTrainingStatus();
1004
+ return this.trainingSession;
1005
+ }
1006
+
1007
+ /** Pause the current training session */
1008
+ pauseTrainingSession(): boolean {
1009
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
1010
+ return false;
1011
+ }
1012
+
1013
+ this.trainingSession.state = 'paused';
1014
+ this.trainingSession.updatedAt = Date.now();
1015
+ this.console.log('Training session paused');
1016
+ this.emitTrainingStatus();
1017
+ return true;
1018
+ }
1019
+
1020
+ /** Resume a paused training session */
1021
+ resumeTrainingSession(): boolean {
1022
+ if (!this.trainingSession || this.trainingSession.state !== 'paused') {
1023
+ return false;
1024
+ }
1025
+
1026
+ this.trainingSession.state = 'active';
1027
+ this.trainingSession.updatedAt = Date.now();
1028
+ this.console.log('Training session resumed');
1029
+ this.emitTrainingStatus();
1030
+ return true;
1031
+ }
1032
+
1033
+ /** End the current training session */
1034
+ endTrainingSession(): TrainingSession | null {
1035
+ if (!this.trainingSession) {
1036
+ return null;
1037
+ }
1038
+
1039
+ this.trainingSession.state = 'completed';
1040
+ this.trainingSession.completedAt = Date.now();
1041
+ this.trainingSession.updatedAt = Date.now();
1042
+ this.trainingSession.stats = calculateTrainingStats(
1043
+ this.trainingSession,
1044
+ this.topology.cameras.length
1045
+ );
1046
+
1047
+ this.console.log(
1048
+ `Training session completed: ${this.trainingSession.stats.camerasVisited} cameras, ` +
1049
+ `${this.trainingSession.stats.transitsRecorded} transits, ` +
1050
+ `${this.trainingSession.stats.landmarksMarked} landmarks`
1051
+ );
1052
+
1053
+ const session = this.trainingSession;
1054
+ this.emitTrainingStatus();
1055
+ return session;
1056
+ }
1057
+
1058
+ /** Record that trainer was detected on a camera */
1059
+ recordTrainerDetection(
1060
+ cameraId: string,
1061
+ detection: ObjectDetectionResult,
1062
+ detectionConfidence: number
1063
+ ): void {
1064
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
1065
+ return;
1066
+ }
1067
+
1068
+ // Only process person detections during training
1069
+ if (detection.className !== 'person') {
1070
+ return;
1071
+ }
1072
+
1073
+ // Check confidence threshold
1074
+ if (detectionConfidence < this.trainingConfig.minDetectionConfidence) {
1075
+ return;
1076
+ }
1077
+
1078
+ const camera = findCamera(this.topology, cameraId);
1079
+ const cameraName = camera?.name || cameraId;
1080
+ const now = Date.now();
1081
+
1082
+ // Check if this is a new camera or same camera
1083
+ if (this.trainingSession.currentCameraId === cameraId) {
1084
+ // Update existing visit
1085
+ const currentVisit = this.trainingSession.visits.find(
1086
+ v => v.cameraId === cameraId && v.departedAt === null
1087
+ );
1088
+ if (currentVisit) {
1089
+ currentVisit.detectionConfidence = Math.max(currentVisit.detectionConfidence, detectionConfidence);
1090
+ if (detection.boundingBox) {
1091
+ currentVisit.boundingBox = detection.boundingBox;
1092
+ }
1093
+ }
1094
+ } else {
1095
+ // This is a new camera - check for transition
1096
+ if (this.trainingSession.currentCameraId && this.trainingSession.transitStartTime) {
1097
+ // Complete the transit
1098
+ const transitDuration = now - this.trainingSession.transitStartTime;
1099
+ const fromCameraId = this.trainingSession.previousCameraId || this.trainingSession.currentCameraId;
1100
+ const fromCamera = findCamera(this.topology, fromCameraId);
1101
+
1102
+ // Mark departure from previous camera
1103
+ const prevVisit = this.trainingSession.visits.find(
1104
+ v => v.cameraId === fromCameraId && v.departedAt === null
1105
+ );
1106
+ if (prevVisit) {
1107
+ prevVisit.departedAt = this.trainingSession.transitStartTime;
1108
+ }
1109
+
1110
+ // Check for overlap (both cameras detecting at same time)
1111
+ const hasOverlap = this.checkTrainingOverlap(fromCameraId, cameraId, now);
1112
+
1113
+ // Record the transit
1114
+ const transit: TrainingTransit = {
1115
+ id: `transit-${now}`,
1116
+ fromCameraId,
1117
+ toCameraId: cameraId,
1118
+ startTime: this.trainingSession.transitStartTime,
1119
+ endTime: now,
1120
+ transitSeconds: Math.round(transitDuration / 1000),
1121
+ hasOverlap,
1122
+ };
1123
+ this.trainingSession.transits.push(transit);
1124
+
1125
+ this.console.log(
1126
+ `Training transit: ${fromCamera?.name || fromCameraId} → ${cameraName} ` +
1127
+ `(${transit.transitSeconds}s${hasOverlap ? ', overlap detected' : ''})`
1128
+ );
1129
+
1130
+ // If overlap detected, record it
1131
+ if (hasOverlap && this.trainingConfig.autoDetectOverlaps) {
1132
+ this.recordTrainingOverlap(fromCameraId, cameraId);
1133
+ }
1134
+ }
1135
+
1136
+ // Record new camera visit
1137
+ const visit: TrainingCameraVisit = {
1138
+ cameraId,
1139
+ cameraName,
1140
+ arrivedAt: now,
1141
+ departedAt: null,
1142
+ trainerEmbedding: detection.embedding,
1143
+ detectionConfidence,
1144
+ boundingBox: detection.boundingBox,
1145
+ floorPlanPosition: camera?.floorPlanPosition,
1146
+ };
1147
+ this.trainingSession.visits.push(visit);
1148
+
1149
+ // Update session state
1150
+ this.trainingSession.previousCameraId = this.trainingSession.currentCameraId;
1151
+ this.trainingSession.currentCameraId = cameraId;
1152
+ this.trainingSession.transitStartTime = now;
1153
+
1154
+ // Store trainer embedding if not already captured
1155
+ if (!this.trainingSession.trainerEmbedding && detection.embedding) {
1156
+ this.trainingSession.trainerEmbedding = detection.embedding;
1157
+ }
1158
+ }
1159
+
1160
+ this.trainingSession.updatedAt = now;
1161
+ this.trainingSession.stats = calculateTrainingStats(
1162
+ this.trainingSession,
1163
+ this.topology.cameras.length
1164
+ );
1165
+ this.emitTrainingStatus();
1166
+ }
1167
+
1168
+ /** Check if there's overlap between two cameras during training */
1169
+ private checkTrainingOverlap(fromCameraId: string, toCameraId: string, now: number): boolean {
1170
+ // Check if both cameras have recent visits overlapping in time
1171
+ const fromVisit = this.trainingSession?.visits.find(
1172
+ v => v.cameraId === fromCameraId &&
1173
+ (v.departedAt === null || v.departedAt > now - 5000) // Within 5 seconds
1174
+ );
1175
+ const toVisit = this.trainingSession?.visits.find(
1176
+ v => v.cameraId === toCameraId &&
1177
+ v.arrivedAt <= now &&
1178
+ v.arrivedAt >= now - 5000 // Arrived within last 5 seconds
1179
+ );
1180
+
1181
+ return !!(fromVisit && toVisit);
1182
+ }
1183
+
1184
+ /** Record a camera overlap detected during training */
1185
+ private recordTrainingOverlap(camera1Id: string, camera2Id: string): void {
1186
+ if (!this.trainingSession) return;
1187
+
1188
+ // Check if we already have this overlap
1189
+ const existingOverlap = this.trainingSession.overlaps.find(
1190
+ o => (o.camera1Id === camera1Id && o.camera2Id === camera2Id) ||
1191
+ (o.camera1Id === camera2Id && o.camera2Id === camera1Id)
1192
+ );
1193
+ if (existingOverlap) return;
1194
+
1195
+ const camera1 = findCamera(this.topology, camera1Id);
1196
+ const camera2 = findCamera(this.topology, camera2Id);
1197
+
1198
+ // Calculate approximate position (midpoint of both camera positions)
1199
+ let position = { x: 50, y: 50 };
1200
+ if (camera1?.floorPlanPosition && camera2?.floorPlanPosition) {
1201
+ position = {
1202
+ x: (camera1.floorPlanPosition.x + camera2.floorPlanPosition.x) / 2,
1203
+ y: (camera1.floorPlanPosition.y + camera2.floorPlanPosition.y) / 2,
1204
+ };
1205
+ }
1206
+
1207
+ const overlap: TrainingOverlap = {
1208
+ id: `overlap-${Date.now()}`,
1209
+ camera1Id,
1210
+ camera2Id,
1211
+ position,
1212
+ radius: 30, // Default radius
1213
+ markedAt: Date.now(),
1214
+ };
1215
+ this.trainingSession.overlaps.push(overlap);
1216
+
1217
+ this.console.log(`Camera overlap detected: ${camera1?.name} ↔ ${camera2?.name}`);
1218
+ }
1219
+
1220
+ /** Manually mark a landmark during training */
1221
+ markTrainingLandmark(landmark: Omit<TrainingLandmark, 'id' | 'markedAt'>): TrainingLandmark | null {
1222
+ if (!this.trainingSession) return null;
1223
+
1224
+ const newLandmark: TrainingLandmark = {
1225
+ ...landmark,
1226
+ id: `landmark-${Date.now()}`,
1227
+ markedAt: Date.now(),
1228
+ };
1229
+ this.trainingSession.landmarks.push(newLandmark);
1230
+ this.trainingSession.updatedAt = Date.now();
1231
+ this.trainingSession.stats = calculateTrainingStats(
1232
+ this.trainingSession,
1233
+ this.topology.cameras.length
1234
+ );
1235
+
1236
+ this.console.log(`Landmark marked: ${newLandmark.name} (${newLandmark.type})`);
1237
+ this.emitTrainingStatus();
1238
+ return newLandmark;
1239
+ }
1240
+
1241
+ /** Manually mark a structure during training */
1242
+ markTrainingStructure(structure: Omit<TrainingStructure, 'id' | 'markedAt'>): TrainingStructure | null {
1243
+ if (!this.trainingSession) return null;
1244
+
1245
+ const newStructure: TrainingStructure = {
1246
+ ...structure,
1247
+ id: `structure-${Date.now()}`,
1248
+ markedAt: Date.now(),
1249
+ };
1250
+ this.trainingSession.structures.push(newStructure);
1251
+ this.trainingSession.updatedAt = Date.now();
1252
+ this.trainingSession.stats = calculateTrainingStats(
1253
+ this.trainingSession,
1254
+ this.topology.cameras.length
1255
+ );
1256
+
1257
+ this.console.log(`Structure marked: ${newStructure.name} (${newStructure.type})`);
1258
+ this.emitTrainingStatus();
1259
+ return newStructure;
1260
+ }
1261
+
1262
+ /** Confirm camera position on floor plan during training */
1263
+ confirmCameraPosition(cameraId: string, position: { x: number; y: number }): boolean {
1264
+ if (!this.trainingSession) return false;
1265
+
1266
+ // Update in current session
1267
+ const visit = this.trainingSession.visits.find(v => v.cameraId === cameraId);
1268
+ if (visit) {
1269
+ visit.floorPlanPosition = position;
1270
+ }
1271
+
1272
+ // Update in topology
1273
+ const camera = findCamera(this.topology, cameraId);
1274
+ if (camera) {
1275
+ camera.floorPlanPosition = position;
1276
+ if (this.onTopologyChange) {
1277
+ this.onTopologyChange(this.topology);
1278
+ }
1279
+ }
1280
+
1281
+ this.trainingSession.updatedAt = Date.now();
1282
+ this.emitTrainingStatus();
1283
+ return true;
1284
+ }
1285
+
1286
+ /** Get training status for UI updates */
1287
+ getTrainingStatus(): TrainingStatusUpdate | null {
1288
+ if (!this.trainingSession) return null;
1289
+
1290
+ const currentCamera = this.trainingSession.currentCameraId
1291
+ ? findCamera(this.topology, this.trainingSession.currentCameraId)
1292
+ : null;
1293
+
1294
+ const previousCamera = this.trainingSession.previousCameraId
1295
+ ? findCamera(this.topology, this.trainingSession.previousCameraId)
1296
+ : null;
1297
+
1298
+ // Generate suggestions for next actions
1299
+ const suggestions: string[] = [];
1300
+ const visitedCameras = new Set(this.trainingSession.visits.map(v => v.cameraId));
1301
+ const unvisitedCameras = this.topology.cameras.filter(c => !visitedCameras.has(c.deviceId));
1302
+
1303
+ if (unvisitedCameras.length > 0) {
1304
+ // Suggest nearest unvisited camera based on connections
1305
+ const currentConnections = currentCamera
1306
+ ? findConnectionsFrom(this.topology, currentCamera.deviceId)
1307
+ : [];
1308
+ const connectedUnvisited = currentConnections
1309
+ .map(c => c.toCameraId)
1310
+ .filter(id => !visitedCameras.has(id));
1311
+
1312
+ if (connectedUnvisited.length > 0) {
1313
+ const nextCam = findCamera(this.topology, connectedUnvisited[0]);
1314
+ if (nextCam) {
1315
+ suggestions.push(`Walk to ${nextCam.name}`);
1316
+ }
1317
+ } else {
1318
+ suggestions.push(`${unvisitedCameras.length} cameras not yet visited`);
1319
+ }
1320
+ }
1321
+
1322
+ if (this.trainingSession.visits.length >= 2 && this.trainingSession.landmarks.length === 0) {
1323
+ suggestions.push('Consider marking some landmarks');
1324
+ }
1325
+
1326
+ if (visitedCameras.size >= this.topology.cameras.length) {
1327
+ suggestions.push('All cameras visited! You can end training.');
1328
+ }
1329
+
1330
+ const status: TrainingStatusUpdate = {
1331
+ sessionId: this.trainingSession.id,
1332
+ state: this.trainingSession.state,
1333
+ currentCamera: currentCamera ? {
1334
+ id: currentCamera.deviceId,
1335
+ name: currentCamera.name,
1336
+ detectedAt: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.arrivedAt || Date.now(),
1337
+ confidence: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.detectionConfidence || 0,
1338
+ } : undefined,
1339
+ activeTransit: this.trainingSession.transitStartTime && previousCamera ? {
1340
+ fromCameraId: previousCamera.deviceId,
1341
+ fromCameraName: previousCamera.name,
1342
+ startTime: this.trainingSession.transitStartTime,
1343
+ elapsedSeconds: Math.round((Date.now() - this.trainingSession.transitStartTime) / 1000),
1344
+ } : undefined,
1345
+ stats: this.trainingSession.stats,
1346
+ suggestions,
1347
+ };
1348
+
1349
+ return status;
1350
+ }
1351
+
1352
+ /** Emit training status update to callback */
1353
+ private emitTrainingStatus(): void {
1354
+ if (this.onTrainingStatusUpdate) {
1355
+ const status = this.getTrainingStatus();
1356
+ if (status) {
1357
+ this.onTrainingStatusUpdate(status);
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ /** Apply training results to topology */
1363
+ applyTrainingToTopology(): TrainingApplicationResult {
1364
+ const result: TrainingApplicationResult = {
1365
+ camerasAdded: 0,
1366
+ connectionsCreated: 0,
1367
+ connectionsUpdated: 0,
1368
+ landmarksAdded: 0,
1369
+ zonesCreated: 0,
1370
+ warnings: [],
1371
+ success: false,
1372
+ };
1373
+
1374
+ if (!this.trainingSession) {
1375
+ result.warnings.push('No training session to apply');
1376
+ return result;
1377
+ }
1378
+
1379
+ try {
1380
+ // 1. Update camera positions from training visits
1381
+ for (const visit of this.trainingSession.visits) {
1382
+ const camera = findCamera(this.topology, visit.cameraId);
1383
+ if (camera && visit.floorPlanPosition) {
1384
+ if (!camera.floorPlanPosition) {
1385
+ camera.floorPlanPosition = visit.floorPlanPosition;
1386
+ result.camerasAdded++;
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ // 2. Create or update connections from training transits
1392
+ for (const transit of this.trainingSession.transits) {
1393
+ const existingConnection = findConnection(
1394
+ this.topology,
1395
+ transit.fromCameraId,
1396
+ transit.toCameraId
1397
+ );
1398
+
1399
+ if (existingConnection) {
1400
+ // Update existing connection with observed transit time
1401
+ const transitMs = transit.transitSeconds * 1000;
1402
+ existingConnection.transitTime = {
1403
+ min: Math.min(existingConnection.transitTime.min, transitMs * 0.7),
1404
+ typical: transitMs,
1405
+ max: Math.max(existingConnection.transitTime.max, transitMs * 1.3),
1406
+ };
1407
+ result.connectionsUpdated++;
1408
+ } else {
1409
+ // Create new connection
1410
+ const fromCamera = findCamera(this.topology, transit.fromCameraId);
1411
+ const toCamera = findCamera(this.topology, transit.toCameraId);
1412
+
1413
+ if (fromCamera && toCamera) {
1414
+ const transitMs = transit.transitSeconds * 1000;
1415
+ const newConnection: CameraConnection = {
1416
+ id: `conn-training-${Date.now()}-${result.connectionsCreated}`,
1417
+ fromCameraId: transit.fromCameraId,
1418
+ toCameraId: transit.toCameraId,
1419
+ name: `${fromCamera.name} to ${toCamera.name}`,
1420
+ exitZone: [], // Will be refined in topology editor
1421
+ entryZone: [], // Will be refined in topology editor
1422
+ transitTime: {
1423
+ min: transitMs * 0.7,
1424
+ typical: transitMs,
1425
+ max: transitMs * 1.3,
1426
+ },
1427
+ bidirectional: true,
1428
+ };
1429
+ this.topology.connections.push(newConnection);
1430
+ result.connectionsCreated++;
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ // 3. Add landmarks from training
1436
+ for (const trainLandmark of this.trainingSession.landmarks) {
1437
+ // Map training landmark type to topology landmark type
1438
+ const typeMapping: Record<string, LandmarkType> = {
1439
+ mailbox: 'feature',
1440
+ garage: 'structure',
1441
+ shed: 'structure',
1442
+ tree: 'feature',
1443
+ gate: 'access',
1444
+ door: 'access',
1445
+ driveway: 'access',
1446
+ pathway: 'access',
1447
+ garden: 'feature',
1448
+ pool: 'feature',
1449
+ deck: 'structure',
1450
+ patio: 'structure',
1451
+ other: 'feature',
1452
+ };
1453
+
1454
+ // Convert training landmark to topology landmark
1455
+ const landmark: Landmark = {
1456
+ id: trainLandmark.id,
1457
+ name: trainLandmark.name,
1458
+ type: typeMapping[trainLandmark.type] || 'feature',
1459
+ position: trainLandmark.position,
1460
+ visibleFromCameras: trainLandmark.visibleFromCameras.length > 0
1461
+ ? trainLandmark.visibleFromCameras
1462
+ : undefined,
1463
+ description: trainLandmark.description,
1464
+ };
1465
+
1466
+ if (!this.topology.landmarks) {
1467
+ this.topology.landmarks = [];
1468
+ }
1469
+ this.topology.landmarks.push(landmark);
1470
+ result.landmarksAdded++;
1471
+ }
1472
+
1473
+ // 4. Create zones from overlaps
1474
+ for (const overlap of this.trainingSession.overlaps) {
1475
+ const camera1 = findCamera(this.topology, overlap.camera1Id);
1476
+ const camera2 = findCamera(this.topology, overlap.camera2Id);
1477
+
1478
+ if (camera1 && camera2) {
1479
+ // Create global zone for overlap area
1480
+ const zoneName = `${camera1.name}/${camera2.name} Overlap`;
1481
+ const existingZone = this.topology.globalZones?.find(z => z.name === zoneName);
1482
+
1483
+ if (!existingZone) {
1484
+ if (!this.topology.globalZones) {
1485
+ this.topology.globalZones = [];
1486
+ }
1487
+
1488
+ // Create camera zone mappings (placeholder zones to be refined in editor)
1489
+ const cameraZones: CameraZoneMapping[] = [
1490
+ { cameraId: overlap.camera1Id, zone: [] },
1491
+ { cameraId: overlap.camera2Id, zone: [] },
1492
+ ];
1493
+
1494
+ this.topology.globalZones.push({
1495
+ id: `zone-overlap-${overlap.id}`,
1496
+ name: zoneName,
1497
+ type: 'dwell', // Overlap zones are good for tracking dwell time
1498
+ cameraZones,
1499
+ });
1500
+ result.zonesCreated++;
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ // Notify about topology change
1506
+ if (this.onTopologyChange) {
1507
+ this.onTopologyChange(this.topology);
1508
+ }
1509
+
1510
+ result.success = true;
1511
+ this.console.log(
1512
+ `Training applied: ${result.connectionsCreated} connections created, ` +
1513
+ `${result.connectionsUpdated} updated, ${result.landmarksAdded} landmarks added`
1514
+ );
1515
+ } catch (e) {
1516
+ result.warnings.push(`Error applying training: ${e}`);
1517
+ }
1518
+
1519
+ return result;
1520
+ }
1521
+
1522
+ /** Clear the current training session without applying */
1523
+ clearTrainingSession(): void {
1524
+ this.trainingSession = null;
1525
+ this.emitTrainingStatus();
1526
+ }
940
1527
  }