@blueharford/scrypted-spatial-awareness 0.3.0 → 0.4.1
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/README.md +94 -3
- 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 +1889 -20
- package/out/main.nodejs.js.map +1 -1
- package/package.json +1 -1
- package/src/core/tracking-engine.ts +588 -1
- package/src/main.ts +345 -20
- package/src/models/training.ts +300 -0
- package/src/ui/training-html.ts +1007 -0
- package/out/plugin.zip +0 -0
package/package.json
CHANGED
|
@@ -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
|
}
|