@blueharford/scrypted-spatial-awareness 0.2.1 → 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/README.md +175 -10
- 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 +2585 -60
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/tracking-engine.ts +963 -10
- package/src/main.ts +492 -19
- package/src/models/training.ts +300 -0
- package/src/ui/editor-html.ts +256 -0
- package/src/ui/training-html.ts +1007 -0
package/out/main.nodejs.js
CHANGED
|
@@ -35627,6 +35627,7 @@ exports.TrackingEngine = void 0;
|
|
|
35627
35627
|
const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
35628
35628
|
const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
|
|
35629
35629
|
const tracked_object_1 = __webpack_require__(/*! ../models/tracked-object */ "./src/models/tracked-object.ts");
|
|
35630
|
+
const training_1 = __webpack_require__(/*! ../models/training */ "./src/models/training.ts");
|
|
35630
35631
|
const object_correlator_1 = __webpack_require__(/*! ./object-correlator */ "./src/core/object-correlator.ts");
|
|
35631
35632
|
const spatial_reasoning_1 = __webpack_require__(/*! ./spatial-reasoning */ "./src/core/spatial-reasoning.ts");
|
|
35632
35633
|
const { systemManager } = sdk_1.default;
|
|
@@ -35645,6 +35646,25 @@ class TrackingEngine {
|
|
|
35645
35646
|
objectLastAlertTime = new Map();
|
|
35646
35647
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
35647
35648
|
onTopologyChange;
|
|
35649
|
+
// ==================== LLM Debouncing ====================
|
|
35650
|
+
/** Last time LLM was called */
|
|
35651
|
+
lastLlmCallTime = 0;
|
|
35652
|
+
/** Queue of pending LLM requests (we only keep latest) */
|
|
35653
|
+
llmDebounceTimer = null;
|
|
35654
|
+
// ==================== Transit Time Learning ====================
|
|
35655
|
+
/** Observed transit times for learning */
|
|
35656
|
+
observedTransits = new Map();
|
|
35657
|
+
/** Connection suggestions based on observed patterns */
|
|
35658
|
+
connectionSuggestions = new Map();
|
|
35659
|
+
/** Minimum observations before suggesting a connection */
|
|
35660
|
+
MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
|
|
35661
|
+
// ==================== Training Mode ====================
|
|
35662
|
+
/** Current training session (null if not training) */
|
|
35663
|
+
trainingSession = null;
|
|
35664
|
+
/** Training configuration */
|
|
35665
|
+
trainingConfig = training_1.DEFAULT_TRAINING_CONFIG;
|
|
35666
|
+
/** Callback for training status updates */
|
|
35667
|
+
onTrainingStatusUpdate;
|
|
35648
35668
|
constructor(topology, state, alertManager, config, console) {
|
|
35649
35669
|
this.topology = topology;
|
|
35650
35670
|
this.state = state;
|
|
@@ -35738,6 +35758,10 @@ class TrackingEngine {
|
|
|
35738
35758
|
// Skip low-confidence detections
|
|
35739
35759
|
if (detection.score < 0.5)
|
|
35740
35760
|
continue;
|
|
35761
|
+
// If in training mode, record trainer detections
|
|
35762
|
+
if (this.isTrainingActive() && detection.className === 'person') {
|
|
35763
|
+
this.recordTrainerDetection(cameraId, detection, detection.score);
|
|
35764
|
+
}
|
|
35741
35765
|
// Skip classes we're not tracking on this camera
|
|
35742
35766
|
if (camera.trackClasses.length > 0 &&
|
|
35743
35767
|
!camera.trackClasses.includes(detection.className)) {
|
|
@@ -35776,9 +35800,28 @@ class TrackingEngine {
|
|
|
35776
35800
|
recordAlertTime(globalId) {
|
|
35777
35801
|
this.objectLastAlertTime.set(globalId, Date.now());
|
|
35778
35802
|
}
|
|
35779
|
-
/**
|
|
35803
|
+
/** Check if LLM call is allowed (rate limiting) */
|
|
35804
|
+
isLlmCallAllowed() {
|
|
35805
|
+
const debounceInterval = this.config.llmDebounceInterval || 0;
|
|
35806
|
+
if (debounceInterval <= 0)
|
|
35807
|
+
return true;
|
|
35808
|
+
const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
|
|
35809
|
+
return timeSinceLastCall >= debounceInterval;
|
|
35810
|
+
}
|
|
35811
|
+
/** Record that an LLM call was made */
|
|
35812
|
+
recordLlmCall() {
|
|
35813
|
+
this.lastLlmCallTime = Date.now();
|
|
35814
|
+
}
|
|
35815
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
|
|
35780
35816
|
async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
|
|
35817
|
+
const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
|
|
35818
|
+
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
35781
35819
|
try {
|
|
35820
|
+
// Check rate limiting - if not allowed, return null to use basic description
|
|
35821
|
+
if (!this.isLlmCallAllowed()) {
|
|
35822
|
+
this.console.log('LLM rate-limited, using basic notification');
|
|
35823
|
+
return null;
|
|
35824
|
+
}
|
|
35782
35825
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
35783
35826
|
let mediaObject;
|
|
35784
35827
|
if (this.config.useLlmDescriptions) {
|
|
@@ -35787,9 +35830,28 @@ class TrackingEngine {
|
|
|
35787
35830
|
mediaObject = await camera.takePicture();
|
|
35788
35831
|
}
|
|
35789
35832
|
}
|
|
35833
|
+
// Record that we're making an LLM call
|
|
35834
|
+
this.recordLlmCall();
|
|
35790
35835
|
// Use spatial reasoning engine for rich context-aware description
|
|
35791
|
-
|
|
35792
|
-
|
|
35836
|
+
// Apply timeout if fallback is enabled
|
|
35837
|
+
let result;
|
|
35838
|
+
if (fallbackEnabled && mediaObject) {
|
|
35839
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
35840
|
+
setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
|
|
35841
|
+
});
|
|
35842
|
+
const descriptionPromise = this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
|
|
35843
|
+
try {
|
|
35844
|
+
result = await Promise.race([descriptionPromise, timeoutPromise]);
|
|
35845
|
+
}
|
|
35846
|
+
catch (timeoutError) {
|
|
35847
|
+
this.console.log('LLM timed out, using basic notification');
|
|
35848
|
+
return null;
|
|
35849
|
+
}
|
|
35850
|
+
}
|
|
35851
|
+
else {
|
|
35852
|
+
result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
|
|
35853
|
+
}
|
|
35854
|
+
// Optionally trigger landmark learning (background, non-blocking)
|
|
35793
35855
|
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
35794
35856
|
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
35795
35857
|
}
|
|
@@ -35837,6 +35899,8 @@ class TrackingEngine {
|
|
|
35837
35899
|
transitDuration,
|
|
35838
35900
|
correlationConfidence: correlation.confidence,
|
|
35839
35901
|
});
|
|
35902
|
+
// Record for transit time learning
|
|
35903
|
+
this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
|
|
35840
35904
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
35841
35905
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
35842
35906
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
@@ -36025,6 +36089,669 @@ class TrackingEngine {
|
|
|
36025
36089
|
getTrackedObject(globalId) {
|
|
36026
36090
|
return this.state.getObject(globalId);
|
|
36027
36091
|
}
|
|
36092
|
+
// ==================== Transit Time Learning ====================
|
|
36093
|
+
/** Record an observed transit time for learning */
|
|
36094
|
+
recordObservedTransit(fromCameraId, toCameraId, transitTime) {
|
|
36095
|
+
if (!this.config.enableTransitTimeLearning)
|
|
36096
|
+
return;
|
|
36097
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
36098
|
+
const observation = {
|
|
36099
|
+
fromCameraId,
|
|
36100
|
+
toCameraId,
|
|
36101
|
+
transitTime,
|
|
36102
|
+
timestamp: Date.now(),
|
|
36103
|
+
};
|
|
36104
|
+
// Add to observations
|
|
36105
|
+
if (!this.observedTransits.has(key)) {
|
|
36106
|
+
this.observedTransits.set(key, []);
|
|
36107
|
+
}
|
|
36108
|
+
const observations = this.observedTransits.get(key);
|
|
36109
|
+
observations.push(observation);
|
|
36110
|
+
// Keep only last 100 observations per connection
|
|
36111
|
+
if (observations.length > 100) {
|
|
36112
|
+
observations.shift();
|
|
36113
|
+
}
|
|
36114
|
+
// Check if we should update existing connection
|
|
36115
|
+
const existingConnection = (0, topology_1.findConnection)(this.topology, fromCameraId, toCameraId);
|
|
36116
|
+
if (existingConnection) {
|
|
36117
|
+
this.maybeUpdateConnectionTransitTime(existingConnection, observations);
|
|
36118
|
+
}
|
|
36119
|
+
else if (this.config.enableConnectionSuggestions) {
|
|
36120
|
+
// No existing connection - suggest one
|
|
36121
|
+
this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
|
|
36122
|
+
}
|
|
36123
|
+
}
|
|
36124
|
+
/** Update an existing connection's transit time based on observations */
|
|
36125
|
+
maybeUpdateConnectionTransitTime(connection, observations) {
|
|
36126
|
+
if (observations.length < 5)
|
|
36127
|
+
return; // Need minimum observations
|
|
36128
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
36129
|
+
// Calculate percentiles
|
|
36130
|
+
const newMin = times[Math.floor(times.length * 0.1)];
|
|
36131
|
+
const newTypical = times[Math.floor(times.length * 0.5)];
|
|
36132
|
+
const newMax = times[Math.floor(times.length * 0.9)];
|
|
36133
|
+
// Only update if significantly different (>20% change)
|
|
36134
|
+
const currentTypical = connection.transitTime.typical;
|
|
36135
|
+
const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
|
|
36136
|
+
if (percentChange > 0.2 && observations.length >= 10) {
|
|
36137
|
+
this.console.log(`Updating transit time for ${connection.name}: ` +
|
|
36138
|
+
`${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`);
|
|
36139
|
+
connection.transitTime = {
|
|
36140
|
+
min: newMin,
|
|
36141
|
+
typical: newTypical,
|
|
36142
|
+
max: newMax,
|
|
36143
|
+
};
|
|
36144
|
+
// Notify about topology change
|
|
36145
|
+
if (this.onTopologyChange) {
|
|
36146
|
+
this.onTopologyChange(this.topology);
|
|
36147
|
+
}
|
|
36148
|
+
}
|
|
36149
|
+
}
|
|
36150
|
+
/** Create or update a connection suggestion based on observations */
|
|
36151
|
+
maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations) {
|
|
36152
|
+
if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION)
|
|
36153
|
+
return;
|
|
36154
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
|
|
36155
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
|
|
36156
|
+
if (!fromCamera || !toCamera)
|
|
36157
|
+
return;
|
|
36158
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
36159
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
36160
|
+
// Calculate transit time suggestion
|
|
36161
|
+
const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
|
|
36162
|
+
const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
|
|
36163
|
+
const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
|
|
36164
|
+
// Calculate confidence based on consistency and count
|
|
36165
|
+
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
|
36166
|
+
const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
|
|
36167
|
+
const stdDev = Math.sqrt(variance);
|
|
36168
|
+
const coefficientOfVariation = stdDev / avgTime;
|
|
36169
|
+
// Higher confidence with more observations and lower variance
|
|
36170
|
+
const countFactor = Math.min(observations.length / 10, 1);
|
|
36171
|
+
const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
|
|
36172
|
+
const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
|
|
36173
|
+
const suggestion = {
|
|
36174
|
+
id: `suggest_${key}`,
|
|
36175
|
+
fromCameraId,
|
|
36176
|
+
fromCameraName: fromCamera.name,
|
|
36177
|
+
toCameraId,
|
|
36178
|
+
toCameraName: toCamera.name,
|
|
36179
|
+
observedTransits: observations.slice(-10), // Keep last 10
|
|
36180
|
+
suggestedTransitTime: {
|
|
36181
|
+
min: suggestedMin,
|
|
36182
|
+
typical: suggestedTypical,
|
|
36183
|
+
max: suggestedMax,
|
|
36184
|
+
},
|
|
36185
|
+
confidence,
|
|
36186
|
+
timestamp: Date.now(),
|
|
36187
|
+
};
|
|
36188
|
+
this.connectionSuggestions.set(key, suggestion);
|
|
36189
|
+
if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
|
|
36190
|
+
this.console.log(`New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
|
|
36191
|
+
`(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`);
|
|
36192
|
+
}
|
|
36193
|
+
}
|
|
36194
|
+
/** Get pending connection suggestions */
|
|
36195
|
+
getConnectionSuggestions() {
|
|
36196
|
+
return Array.from(this.connectionSuggestions.values())
|
|
36197
|
+
.filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
|
|
36198
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
36199
|
+
}
|
|
36200
|
+
/** Accept a connection suggestion, adding it to topology */
|
|
36201
|
+
acceptConnectionSuggestion(suggestionId) {
|
|
36202
|
+
const key = suggestionId.replace('suggest_', '');
|
|
36203
|
+
const suggestion = this.connectionSuggestions.get(key);
|
|
36204
|
+
if (!suggestion)
|
|
36205
|
+
return null;
|
|
36206
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, suggestion.fromCameraId);
|
|
36207
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, suggestion.toCameraId);
|
|
36208
|
+
if (!fromCamera || !toCamera)
|
|
36209
|
+
return null;
|
|
36210
|
+
const connection = {
|
|
36211
|
+
id: `conn-${Date.now()}`,
|
|
36212
|
+
fromCameraId: suggestion.fromCameraId,
|
|
36213
|
+
toCameraId: suggestion.toCameraId,
|
|
36214
|
+
name: `${fromCamera.name} to ${toCamera.name}`,
|
|
36215
|
+
exitZone: [],
|
|
36216
|
+
entryZone: [],
|
|
36217
|
+
transitTime: suggestion.suggestedTransitTime,
|
|
36218
|
+
bidirectional: true, // Default to bidirectional
|
|
36219
|
+
};
|
|
36220
|
+
this.topology.connections.push(connection);
|
|
36221
|
+
this.connectionSuggestions.delete(key);
|
|
36222
|
+
// Notify about topology change
|
|
36223
|
+
if (this.onTopologyChange) {
|
|
36224
|
+
this.onTopologyChange(this.topology);
|
|
36225
|
+
}
|
|
36226
|
+
this.console.log(`Connection accepted: ${connection.name}`);
|
|
36227
|
+
return connection;
|
|
36228
|
+
}
|
|
36229
|
+
/** Reject a connection suggestion */
|
|
36230
|
+
rejectConnectionSuggestion(suggestionId) {
|
|
36231
|
+
const key = suggestionId.replace('suggest_', '');
|
|
36232
|
+
if (!this.connectionSuggestions.has(key))
|
|
36233
|
+
return false;
|
|
36234
|
+
this.connectionSuggestions.delete(key);
|
|
36235
|
+
// Also clear observations so it doesn't get re-suggested immediately
|
|
36236
|
+
this.observedTransits.delete(key);
|
|
36237
|
+
return true;
|
|
36238
|
+
}
|
|
36239
|
+
// ==================== Live Tracking State ====================
|
|
36240
|
+
/** Get current state of all tracked objects for live overlay */
|
|
36241
|
+
getLiveTrackingState() {
|
|
36242
|
+
const activeObjects = this.state.getActiveObjects();
|
|
36243
|
+
const objects = activeObjects.map(tracked => {
|
|
36244
|
+
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
36245
|
+
const camera = lastSighting ? (0, topology_1.findCamera)(this.topology, lastSighting.cameraId) : null;
|
|
36246
|
+
return {
|
|
36247
|
+
globalId: tracked.globalId,
|
|
36248
|
+
className: tracked.className,
|
|
36249
|
+
label: tracked.label,
|
|
36250
|
+
lastCameraId: lastSighting?.cameraId || '',
|
|
36251
|
+
lastCameraName: lastSighting?.cameraName || '',
|
|
36252
|
+
lastSeen: tracked.lastSeen,
|
|
36253
|
+
state: tracked.state,
|
|
36254
|
+
cameraPosition: camera?.floorPlanPosition,
|
|
36255
|
+
};
|
|
36256
|
+
});
|
|
36257
|
+
return {
|
|
36258
|
+
objects,
|
|
36259
|
+
timestamp: Date.now(),
|
|
36260
|
+
};
|
|
36261
|
+
}
|
|
36262
|
+
/** Get journey path for visualization */
|
|
36263
|
+
getJourneyPath(globalId) {
|
|
36264
|
+
const tracked = this.state.getObject(globalId);
|
|
36265
|
+
if (!tracked)
|
|
36266
|
+
return null;
|
|
36267
|
+
const segments = tracked.journey.map(j => {
|
|
36268
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, j.fromCameraId);
|
|
36269
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, j.toCameraId);
|
|
36270
|
+
return {
|
|
36271
|
+
fromCamera: {
|
|
36272
|
+
id: j.fromCameraId,
|
|
36273
|
+
name: j.fromCameraName,
|
|
36274
|
+
position: fromCamera?.floorPlanPosition,
|
|
36275
|
+
},
|
|
36276
|
+
toCamera: {
|
|
36277
|
+
id: j.toCameraId,
|
|
36278
|
+
name: j.toCameraName,
|
|
36279
|
+
position: toCamera?.floorPlanPosition,
|
|
36280
|
+
},
|
|
36281
|
+
transitTime: j.transitDuration,
|
|
36282
|
+
timestamp: j.entryTime,
|
|
36283
|
+
};
|
|
36284
|
+
});
|
|
36285
|
+
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
36286
|
+
let currentLocation;
|
|
36287
|
+
if (lastSighting) {
|
|
36288
|
+
const camera = (0, topology_1.findCamera)(this.topology, lastSighting.cameraId);
|
|
36289
|
+
currentLocation = {
|
|
36290
|
+
cameraId: lastSighting.cameraId,
|
|
36291
|
+
cameraName: lastSighting.cameraName,
|
|
36292
|
+
position: camera?.floorPlanPosition,
|
|
36293
|
+
};
|
|
36294
|
+
}
|
|
36295
|
+
return { segments, currentLocation };
|
|
36296
|
+
}
|
|
36297
|
+
// ==================== Training Mode Methods ====================
|
|
36298
|
+
/** Set callback for training status updates */
|
|
36299
|
+
setTrainingStatusCallback(callback) {
|
|
36300
|
+
this.onTrainingStatusUpdate = callback;
|
|
36301
|
+
}
|
|
36302
|
+
/** Get current training session (if any) */
|
|
36303
|
+
getTrainingSession() {
|
|
36304
|
+
return this.trainingSession;
|
|
36305
|
+
}
|
|
36306
|
+
/** Check if training mode is active */
|
|
36307
|
+
isTrainingActive() {
|
|
36308
|
+
return this.trainingSession !== null && this.trainingSession.state === 'active';
|
|
36309
|
+
}
|
|
36310
|
+
/** Start a new training session */
|
|
36311
|
+
startTrainingSession(trainerName, config) {
|
|
36312
|
+
// End any existing session
|
|
36313
|
+
if (this.trainingSession && this.trainingSession.state === 'active') {
|
|
36314
|
+
this.endTrainingSession();
|
|
36315
|
+
}
|
|
36316
|
+
// Apply custom config
|
|
36317
|
+
if (config) {
|
|
36318
|
+
this.trainingConfig = { ...training_1.DEFAULT_TRAINING_CONFIG, ...config };
|
|
36319
|
+
}
|
|
36320
|
+
// Create new session
|
|
36321
|
+
this.trainingSession = (0, training_1.createTrainingSession)(trainerName);
|
|
36322
|
+
this.trainingSession.state = 'active';
|
|
36323
|
+
this.console.log(`Training session started: ${this.trainingSession.id}`);
|
|
36324
|
+
this.emitTrainingStatus();
|
|
36325
|
+
return this.trainingSession;
|
|
36326
|
+
}
|
|
36327
|
+
/** Pause the current training session */
|
|
36328
|
+
pauseTrainingSession() {
|
|
36329
|
+
if (!this.trainingSession || this.trainingSession.state !== 'active') {
|
|
36330
|
+
return false;
|
|
36331
|
+
}
|
|
36332
|
+
this.trainingSession.state = 'paused';
|
|
36333
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36334
|
+
this.console.log('Training session paused');
|
|
36335
|
+
this.emitTrainingStatus();
|
|
36336
|
+
return true;
|
|
36337
|
+
}
|
|
36338
|
+
/** Resume a paused training session */
|
|
36339
|
+
resumeTrainingSession() {
|
|
36340
|
+
if (!this.trainingSession || this.trainingSession.state !== 'paused') {
|
|
36341
|
+
return false;
|
|
36342
|
+
}
|
|
36343
|
+
this.trainingSession.state = 'active';
|
|
36344
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36345
|
+
this.console.log('Training session resumed');
|
|
36346
|
+
this.emitTrainingStatus();
|
|
36347
|
+
return true;
|
|
36348
|
+
}
|
|
36349
|
+
/** End the current training session */
|
|
36350
|
+
endTrainingSession() {
|
|
36351
|
+
if (!this.trainingSession) {
|
|
36352
|
+
return null;
|
|
36353
|
+
}
|
|
36354
|
+
this.trainingSession.state = 'completed';
|
|
36355
|
+
this.trainingSession.completedAt = Date.now();
|
|
36356
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36357
|
+
this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
|
|
36358
|
+
this.console.log(`Training session completed: ${this.trainingSession.stats.camerasVisited} cameras, ` +
|
|
36359
|
+
`${this.trainingSession.stats.transitsRecorded} transits, ` +
|
|
36360
|
+
`${this.trainingSession.stats.landmarksMarked} landmarks`);
|
|
36361
|
+
const session = this.trainingSession;
|
|
36362
|
+
this.emitTrainingStatus();
|
|
36363
|
+
return session;
|
|
36364
|
+
}
|
|
36365
|
+
/** Record that trainer was detected on a camera */
|
|
36366
|
+
recordTrainerDetection(cameraId, detection, detectionConfidence) {
|
|
36367
|
+
if (!this.trainingSession || this.trainingSession.state !== 'active') {
|
|
36368
|
+
return;
|
|
36369
|
+
}
|
|
36370
|
+
// Only process person detections during training
|
|
36371
|
+
if (detection.className !== 'person') {
|
|
36372
|
+
return;
|
|
36373
|
+
}
|
|
36374
|
+
// Check confidence threshold
|
|
36375
|
+
if (detectionConfidence < this.trainingConfig.minDetectionConfidence) {
|
|
36376
|
+
return;
|
|
36377
|
+
}
|
|
36378
|
+
const camera = (0, topology_1.findCamera)(this.topology, cameraId);
|
|
36379
|
+
const cameraName = camera?.name || cameraId;
|
|
36380
|
+
const now = Date.now();
|
|
36381
|
+
// Check if this is a new camera or same camera
|
|
36382
|
+
if (this.trainingSession.currentCameraId === cameraId) {
|
|
36383
|
+
// Update existing visit
|
|
36384
|
+
const currentVisit = this.trainingSession.visits.find(v => v.cameraId === cameraId && v.departedAt === null);
|
|
36385
|
+
if (currentVisit) {
|
|
36386
|
+
currentVisit.detectionConfidence = Math.max(currentVisit.detectionConfidence, detectionConfidence);
|
|
36387
|
+
if (detection.boundingBox) {
|
|
36388
|
+
currentVisit.boundingBox = detection.boundingBox;
|
|
36389
|
+
}
|
|
36390
|
+
}
|
|
36391
|
+
}
|
|
36392
|
+
else {
|
|
36393
|
+
// This is a new camera - check for transition
|
|
36394
|
+
if (this.trainingSession.currentCameraId && this.trainingSession.transitStartTime) {
|
|
36395
|
+
// Complete the transit
|
|
36396
|
+
const transitDuration = now - this.trainingSession.transitStartTime;
|
|
36397
|
+
const fromCameraId = this.trainingSession.previousCameraId || this.trainingSession.currentCameraId;
|
|
36398
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
|
|
36399
|
+
// Mark departure from previous camera
|
|
36400
|
+
const prevVisit = this.trainingSession.visits.find(v => v.cameraId === fromCameraId && v.departedAt === null);
|
|
36401
|
+
if (prevVisit) {
|
|
36402
|
+
prevVisit.departedAt = this.trainingSession.transitStartTime;
|
|
36403
|
+
}
|
|
36404
|
+
// Check for overlap (both cameras detecting at same time)
|
|
36405
|
+
const hasOverlap = this.checkTrainingOverlap(fromCameraId, cameraId, now);
|
|
36406
|
+
// Record the transit
|
|
36407
|
+
const transit = {
|
|
36408
|
+
id: `transit-${now}`,
|
|
36409
|
+
fromCameraId,
|
|
36410
|
+
toCameraId: cameraId,
|
|
36411
|
+
startTime: this.trainingSession.transitStartTime,
|
|
36412
|
+
endTime: now,
|
|
36413
|
+
transitSeconds: Math.round(transitDuration / 1000),
|
|
36414
|
+
hasOverlap,
|
|
36415
|
+
};
|
|
36416
|
+
this.trainingSession.transits.push(transit);
|
|
36417
|
+
this.console.log(`Training transit: ${fromCamera?.name || fromCameraId} → ${cameraName} ` +
|
|
36418
|
+
`(${transit.transitSeconds}s${hasOverlap ? ', overlap detected' : ''})`);
|
|
36419
|
+
// If overlap detected, record it
|
|
36420
|
+
if (hasOverlap && this.trainingConfig.autoDetectOverlaps) {
|
|
36421
|
+
this.recordTrainingOverlap(fromCameraId, cameraId);
|
|
36422
|
+
}
|
|
36423
|
+
}
|
|
36424
|
+
// Record new camera visit
|
|
36425
|
+
const visit = {
|
|
36426
|
+
cameraId,
|
|
36427
|
+
cameraName,
|
|
36428
|
+
arrivedAt: now,
|
|
36429
|
+
departedAt: null,
|
|
36430
|
+
trainerEmbedding: detection.embedding,
|
|
36431
|
+
detectionConfidence,
|
|
36432
|
+
boundingBox: detection.boundingBox,
|
|
36433
|
+
floorPlanPosition: camera?.floorPlanPosition,
|
|
36434
|
+
};
|
|
36435
|
+
this.trainingSession.visits.push(visit);
|
|
36436
|
+
// Update session state
|
|
36437
|
+
this.trainingSession.previousCameraId = this.trainingSession.currentCameraId;
|
|
36438
|
+
this.trainingSession.currentCameraId = cameraId;
|
|
36439
|
+
this.trainingSession.transitStartTime = now;
|
|
36440
|
+
// Store trainer embedding if not already captured
|
|
36441
|
+
if (!this.trainingSession.trainerEmbedding && detection.embedding) {
|
|
36442
|
+
this.trainingSession.trainerEmbedding = detection.embedding;
|
|
36443
|
+
}
|
|
36444
|
+
}
|
|
36445
|
+
this.trainingSession.updatedAt = now;
|
|
36446
|
+
this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
|
|
36447
|
+
this.emitTrainingStatus();
|
|
36448
|
+
}
|
|
36449
|
+
/** Check if there's overlap between two cameras during training */
|
|
36450
|
+
checkTrainingOverlap(fromCameraId, toCameraId, now) {
|
|
36451
|
+
// Check if both cameras have recent visits overlapping in time
|
|
36452
|
+
const fromVisit = this.trainingSession?.visits.find(v => v.cameraId === fromCameraId &&
|
|
36453
|
+
(v.departedAt === null || v.departedAt > now - 5000) // Within 5 seconds
|
|
36454
|
+
);
|
|
36455
|
+
const toVisit = this.trainingSession?.visits.find(v => v.cameraId === toCameraId &&
|
|
36456
|
+
v.arrivedAt <= now &&
|
|
36457
|
+
v.arrivedAt >= now - 5000 // Arrived within last 5 seconds
|
|
36458
|
+
);
|
|
36459
|
+
return !!(fromVisit && toVisit);
|
|
36460
|
+
}
|
|
36461
|
+
/** Record a camera overlap detected during training */
|
|
36462
|
+
recordTrainingOverlap(camera1Id, camera2Id) {
|
|
36463
|
+
if (!this.trainingSession)
|
|
36464
|
+
return;
|
|
36465
|
+
// Check if we already have this overlap
|
|
36466
|
+
const existingOverlap = this.trainingSession.overlaps.find(o => (o.camera1Id === camera1Id && o.camera2Id === camera2Id) ||
|
|
36467
|
+
(o.camera1Id === camera2Id && o.camera2Id === camera1Id));
|
|
36468
|
+
if (existingOverlap)
|
|
36469
|
+
return;
|
|
36470
|
+
const camera1 = (0, topology_1.findCamera)(this.topology, camera1Id);
|
|
36471
|
+
const camera2 = (0, topology_1.findCamera)(this.topology, camera2Id);
|
|
36472
|
+
// Calculate approximate position (midpoint of both camera positions)
|
|
36473
|
+
let position = { x: 50, y: 50 };
|
|
36474
|
+
if (camera1?.floorPlanPosition && camera2?.floorPlanPosition) {
|
|
36475
|
+
position = {
|
|
36476
|
+
x: (camera1.floorPlanPosition.x + camera2.floorPlanPosition.x) / 2,
|
|
36477
|
+
y: (camera1.floorPlanPosition.y + camera2.floorPlanPosition.y) / 2,
|
|
36478
|
+
};
|
|
36479
|
+
}
|
|
36480
|
+
const overlap = {
|
|
36481
|
+
id: `overlap-${Date.now()}`,
|
|
36482
|
+
camera1Id,
|
|
36483
|
+
camera2Id,
|
|
36484
|
+
position,
|
|
36485
|
+
radius: 30, // Default radius
|
|
36486
|
+
markedAt: Date.now(),
|
|
36487
|
+
};
|
|
36488
|
+
this.trainingSession.overlaps.push(overlap);
|
|
36489
|
+
this.console.log(`Camera overlap detected: ${camera1?.name} ↔ ${camera2?.name}`);
|
|
36490
|
+
}
|
|
36491
|
+
/** Manually mark a landmark during training */
|
|
36492
|
+
markTrainingLandmark(landmark) {
|
|
36493
|
+
if (!this.trainingSession)
|
|
36494
|
+
return null;
|
|
36495
|
+
const newLandmark = {
|
|
36496
|
+
...landmark,
|
|
36497
|
+
id: `landmark-${Date.now()}`,
|
|
36498
|
+
markedAt: Date.now(),
|
|
36499
|
+
};
|
|
36500
|
+
this.trainingSession.landmarks.push(newLandmark);
|
|
36501
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36502
|
+
this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
|
|
36503
|
+
this.console.log(`Landmark marked: ${newLandmark.name} (${newLandmark.type})`);
|
|
36504
|
+
this.emitTrainingStatus();
|
|
36505
|
+
return newLandmark;
|
|
36506
|
+
}
|
|
36507
|
+
/** Manually mark a structure during training */
|
|
36508
|
+
markTrainingStructure(structure) {
|
|
36509
|
+
if (!this.trainingSession)
|
|
36510
|
+
return null;
|
|
36511
|
+
const newStructure = {
|
|
36512
|
+
...structure,
|
|
36513
|
+
id: `structure-${Date.now()}`,
|
|
36514
|
+
markedAt: Date.now(),
|
|
36515
|
+
};
|
|
36516
|
+
this.trainingSession.structures.push(newStructure);
|
|
36517
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36518
|
+
this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
|
|
36519
|
+
this.console.log(`Structure marked: ${newStructure.name} (${newStructure.type})`);
|
|
36520
|
+
this.emitTrainingStatus();
|
|
36521
|
+
return newStructure;
|
|
36522
|
+
}
|
|
36523
|
+
/** Confirm camera position on floor plan during training */
|
|
36524
|
+
confirmCameraPosition(cameraId, position) {
|
|
36525
|
+
if (!this.trainingSession)
|
|
36526
|
+
return false;
|
|
36527
|
+
// Update in current session
|
|
36528
|
+
const visit = this.trainingSession.visits.find(v => v.cameraId === cameraId);
|
|
36529
|
+
if (visit) {
|
|
36530
|
+
visit.floorPlanPosition = position;
|
|
36531
|
+
}
|
|
36532
|
+
// Update in topology
|
|
36533
|
+
const camera = (0, topology_1.findCamera)(this.topology, cameraId);
|
|
36534
|
+
if (camera) {
|
|
36535
|
+
camera.floorPlanPosition = position;
|
|
36536
|
+
if (this.onTopologyChange) {
|
|
36537
|
+
this.onTopologyChange(this.topology);
|
|
36538
|
+
}
|
|
36539
|
+
}
|
|
36540
|
+
this.trainingSession.updatedAt = Date.now();
|
|
36541
|
+
this.emitTrainingStatus();
|
|
36542
|
+
return true;
|
|
36543
|
+
}
|
|
36544
|
+
/** Get training status for UI updates */
|
|
36545
|
+
getTrainingStatus() {
|
|
36546
|
+
if (!this.trainingSession)
|
|
36547
|
+
return null;
|
|
36548
|
+
const currentCamera = this.trainingSession.currentCameraId
|
|
36549
|
+
? (0, topology_1.findCamera)(this.topology, this.trainingSession.currentCameraId)
|
|
36550
|
+
: null;
|
|
36551
|
+
const previousCamera = this.trainingSession.previousCameraId
|
|
36552
|
+
? (0, topology_1.findCamera)(this.topology, this.trainingSession.previousCameraId)
|
|
36553
|
+
: null;
|
|
36554
|
+
// Generate suggestions for next actions
|
|
36555
|
+
const suggestions = [];
|
|
36556
|
+
const visitedCameras = new Set(this.trainingSession.visits.map(v => v.cameraId));
|
|
36557
|
+
const unvisitedCameras = this.topology.cameras.filter(c => !visitedCameras.has(c.deviceId));
|
|
36558
|
+
if (unvisitedCameras.length > 0) {
|
|
36559
|
+
// Suggest nearest unvisited camera based on connections
|
|
36560
|
+
const currentConnections = currentCamera
|
|
36561
|
+
? (0, topology_1.findConnectionsFrom)(this.topology, currentCamera.deviceId)
|
|
36562
|
+
: [];
|
|
36563
|
+
const connectedUnvisited = currentConnections
|
|
36564
|
+
.map(c => c.toCameraId)
|
|
36565
|
+
.filter(id => !visitedCameras.has(id));
|
|
36566
|
+
if (connectedUnvisited.length > 0) {
|
|
36567
|
+
const nextCam = (0, topology_1.findCamera)(this.topology, connectedUnvisited[0]);
|
|
36568
|
+
if (nextCam) {
|
|
36569
|
+
suggestions.push(`Walk to ${nextCam.name}`);
|
|
36570
|
+
}
|
|
36571
|
+
}
|
|
36572
|
+
else {
|
|
36573
|
+
suggestions.push(`${unvisitedCameras.length} cameras not yet visited`);
|
|
36574
|
+
}
|
|
36575
|
+
}
|
|
36576
|
+
if (this.trainingSession.visits.length >= 2 && this.trainingSession.landmarks.length === 0) {
|
|
36577
|
+
suggestions.push('Consider marking some landmarks');
|
|
36578
|
+
}
|
|
36579
|
+
if (visitedCameras.size >= this.topology.cameras.length) {
|
|
36580
|
+
suggestions.push('All cameras visited! You can end training.');
|
|
36581
|
+
}
|
|
36582
|
+
const status = {
|
|
36583
|
+
sessionId: this.trainingSession.id,
|
|
36584
|
+
state: this.trainingSession.state,
|
|
36585
|
+
currentCamera: currentCamera ? {
|
|
36586
|
+
id: currentCamera.deviceId,
|
|
36587
|
+
name: currentCamera.name,
|
|
36588
|
+
detectedAt: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.arrivedAt || Date.now(),
|
|
36589
|
+
confidence: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.detectionConfidence || 0,
|
|
36590
|
+
} : undefined,
|
|
36591
|
+
activeTransit: this.trainingSession.transitStartTime && previousCamera ? {
|
|
36592
|
+
fromCameraId: previousCamera.deviceId,
|
|
36593
|
+
fromCameraName: previousCamera.name,
|
|
36594
|
+
startTime: this.trainingSession.transitStartTime,
|
|
36595
|
+
elapsedSeconds: Math.round((Date.now() - this.trainingSession.transitStartTime) / 1000),
|
|
36596
|
+
} : undefined,
|
|
36597
|
+
stats: this.trainingSession.stats,
|
|
36598
|
+
suggestions,
|
|
36599
|
+
};
|
|
36600
|
+
return status;
|
|
36601
|
+
}
|
|
36602
|
+
/** Emit training status update to callback */
|
|
36603
|
+
emitTrainingStatus() {
|
|
36604
|
+
if (this.onTrainingStatusUpdate) {
|
|
36605
|
+
const status = this.getTrainingStatus();
|
|
36606
|
+
if (status) {
|
|
36607
|
+
this.onTrainingStatusUpdate(status);
|
|
36608
|
+
}
|
|
36609
|
+
}
|
|
36610
|
+
}
|
|
36611
|
+
/** Apply training results to topology */
|
|
36612
|
+
applyTrainingToTopology() {
|
|
36613
|
+
const result = {
|
|
36614
|
+
camerasAdded: 0,
|
|
36615
|
+
connectionsCreated: 0,
|
|
36616
|
+
connectionsUpdated: 0,
|
|
36617
|
+
landmarksAdded: 0,
|
|
36618
|
+
zonesCreated: 0,
|
|
36619
|
+
warnings: [],
|
|
36620
|
+
success: false,
|
|
36621
|
+
};
|
|
36622
|
+
if (!this.trainingSession) {
|
|
36623
|
+
result.warnings.push('No training session to apply');
|
|
36624
|
+
return result;
|
|
36625
|
+
}
|
|
36626
|
+
try {
|
|
36627
|
+
// 1. Update camera positions from training visits
|
|
36628
|
+
for (const visit of this.trainingSession.visits) {
|
|
36629
|
+
const camera = (0, topology_1.findCamera)(this.topology, visit.cameraId);
|
|
36630
|
+
if (camera && visit.floorPlanPosition) {
|
|
36631
|
+
if (!camera.floorPlanPosition) {
|
|
36632
|
+
camera.floorPlanPosition = visit.floorPlanPosition;
|
|
36633
|
+
result.camerasAdded++;
|
|
36634
|
+
}
|
|
36635
|
+
}
|
|
36636
|
+
}
|
|
36637
|
+
// 2. Create or update connections from training transits
|
|
36638
|
+
for (const transit of this.trainingSession.transits) {
|
|
36639
|
+
const existingConnection = (0, topology_1.findConnection)(this.topology, transit.fromCameraId, transit.toCameraId);
|
|
36640
|
+
if (existingConnection) {
|
|
36641
|
+
// Update existing connection with observed transit time
|
|
36642
|
+
const transitMs = transit.transitSeconds * 1000;
|
|
36643
|
+
existingConnection.transitTime = {
|
|
36644
|
+
min: Math.min(existingConnection.transitTime.min, transitMs * 0.7),
|
|
36645
|
+
typical: transitMs,
|
|
36646
|
+
max: Math.max(existingConnection.transitTime.max, transitMs * 1.3),
|
|
36647
|
+
};
|
|
36648
|
+
result.connectionsUpdated++;
|
|
36649
|
+
}
|
|
36650
|
+
else {
|
|
36651
|
+
// Create new connection
|
|
36652
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, transit.fromCameraId);
|
|
36653
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, transit.toCameraId);
|
|
36654
|
+
if (fromCamera && toCamera) {
|
|
36655
|
+
const transitMs = transit.transitSeconds * 1000;
|
|
36656
|
+
const newConnection = {
|
|
36657
|
+
id: `conn-training-${Date.now()}-${result.connectionsCreated}`,
|
|
36658
|
+
fromCameraId: transit.fromCameraId,
|
|
36659
|
+
toCameraId: transit.toCameraId,
|
|
36660
|
+
name: `${fromCamera.name} to ${toCamera.name}`,
|
|
36661
|
+
exitZone: [], // Will be refined in topology editor
|
|
36662
|
+
entryZone: [], // Will be refined in topology editor
|
|
36663
|
+
transitTime: {
|
|
36664
|
+
min: transitMs * 0.7,
|
|
36665
|
+
typical: transitMs,
|
|
36666
|
+
max: transitMs * 1.3,
|
|
36667
|
+
},
|
|
36668
|
+
bidirectional: true,
|
|
36669
|
+
};
|
|
36670
|
+
this.topology.connections.push(newConnection);
|
|
36671
|
+
result.connectionsCreated++;
|
|
36672
|
+
}
|
|
36673
|
+
}
|
|
36674
|
+
}
|
|
36675
|
+
// 3. Add landmarks from training
|
|
36676
|
+
for (const trainLandmark of this.trainingSession.landmarks) {
|
|
36677
|
+
// Map training landmark type to topology landmark type
|
|
36678
|
+
const typeMapping = {
|
|
36679
|
+
mailbox: 'feature',
|
|
36680
|
+
garage: 'structure',
|
|
36681
|
+
shed: 'structure',
|
|
36682
|
+
tree: 'feature',
|
|
36683
|
+
gate: 'access',
|
|
36684
|
+
door: 'access',
|
|
36685
|
+
driveway: 'access',
|
|
36686
|
+
pathway: 'access',
|
|
36687
|
+
garden: 'feature',
|
|
36688
|
+
pool: 'feature',
|
|
36689
|
+
deck: 'structure',
|
|
36690
|
+
patio: 'structure',
|
|
36691
|
+
other: 'feature',
|
|
36692
|
+
};
|
|
36693
|
+
// Convert training landmark to topology landmark
|
|
36694
|
+
const landmark = {
|
|
36695
|
+
id: trainLandmark.id,
|
|
36696
|
+
name: trainLandmark.name,
|
|
36697
|
+
type: typeMapping[trainLandmark.type] || 'feature',
|
|
36698
|
+
position: trainLandmark.position,
|
|
36699
|
+
visibleFromCameras: trainLandmark.visibleFromCameras.length > 0
|
|
36700
|
+
? trainLandmark.visibleFromCameras
|
|
36701
|
+
: undefined,
|
|
36702
|
+
description: trainLandmark.description,
|
|
36703
|
+
};
|
|
36704
|
+
if (!this.topology.landmarks) {
|
|
36705
|
+
this.topology.landmarks = [];
|
|
36706
|
+
}
|
|
36707
|
+
this.topology.landmarks.push(landmark);
|
|
36708
|
+
result.landmarksAdded++;
|
|
36709
|
+
}
|
|
36710
|
+
// 4. Create zones from overlaps
|
|
36711
|
+
for (const overlap of this.trainingSession.overlaps) {
|
|
36712
|
+
const camera1 = (0, topology_1.findCamera)(this.topology, overlap.camera1Id);
|
|
36713
|
+
const camera2 = (0, topology_1.findCamera)(this.topology, overlap.camera2Id);
|
|
36714
|
+
if (camera1 && camera2) {
|
|
36715
|
+
// Create global zone for overlap area
|
|
36716
|
+
const zoneName = `${camera1.name}/${camera2.name} Overlap`;
|
|
36717
|
+
const existingZone = this.topology.globalZones?.find(z => z.name === zoneName);
|
|
36718
|
+
if (!existingZone) {
|
|
36719
|
+
if (!this.topology.globalZones) {
|
|
36720
|
+
this.topology.globalZones = [];
|
|
36721
|
+
}
|
|
36722
|
+
// Create camera zone mappings (placeholder zones to be refined in editor)
|
|
36723
|
+
const cameraZones = [
|
|
36724
|
+
{ cameraId: overlap.camera1Id, zone: [] },
|
|
36725
|
+
{ cameraId: overlap.camera2Id, zone: [] },
|
|
36726
|
+
];
|
|
36727
|
+
this.topology.globalZones.push({
|
|
36728
|
+
id: `zone-overlap-${overlap.id}`,
|
|
36729
|
+
name: zoneName,
|
|
36730
|
+
type: 'dwell', // Overlap zones are good for tracking dwell time
|
|
36731
|
+
cameraZones,
|
|
36732
|
+
});
|
|
36733
|
+
result.zonesCreated++;
|
|
36734
|
+
}
|
|
36735
|
+
}
|
|
36736
|
+
}
|
|
36737
|
+
// Notify about topology change
|
|
36738
|
+
if (this.onTopologyChange) {
|
|
36739
|
+
this.onTopologyChange(this.topology);
|
|
36740
|
+
}
|
|
36741
|
+
result.success = true;
|
|
36742
|
+
this.console.log(`Training applied: ${result.connectionsCreated} connections created, ` +
|
|
36743
|
+
`${result.connectionsUpdated} updated, ${result.landmarksAdded} landmarks added`);
|
|
36744
|
+
}
|
|
36745
|
+
catch (e) {
|
|
36746
|
+
result.warnings.push(`Error applying training: ${e}`);
|
|
36747
|
+
}
|
|
36748
|
+
return result;
|
|
36749
|
+
}
|
|
36750
|
+
/** Clear the current training session without applying */
|
|
36751
|
+
clearTrainingSession() {
|
|
36752
|
+
this.trainingSession = null;
|
|
36753
|
+
this.emitTrainingStatus();
|
|
36754
|
+
}
|
|
36028
36755
|
}
|
|
36029
36756
|
exports.TrackingEngine = TrackingEngine;
|
|
36030
36757
|
|
|
@@ -36732,6 +37459,7 @@ const global_tracker_sensor_1 = __webpack_require__(/*! ./devices/global-tracker
|
|
|
36732
37459
|
const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./src/devices/tracking-zone.ts");
|
|
36733
37460
|
const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
|
|
36734
37461
|
const editor_html_1 = __webpack_require__(/*! ./ui/editor-html */ "./src/ui/editor-html.ts");
|
|
37462
|
+
const training_html_1 = __webpack_require__(/*! ./ui/training-html */ "./src/ui/training-html.ts");
|
|
36735
37463
|
const { deviceManager, systemManager } = sdk_1.default;
|
|
36736
37464
|
const TRACKING_ZONE_PREFIX = 'tracking-zone:';
|
|
36737
37465
|
const GLOBAL_TRACKER_ID = 'global-tracker';
|
|
@@ -36806,6 +37534,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36806
37534
|
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
36807
37535
|
group: 'AI & Spatial Reasoning',
|
|
36808
37536
|
},
|
|
37537
|
+
llmDebounceInterval: {
|
|
37538
|
+
title: 'LLM Rate Limit (seconds)',
|
|
37539
|
+
type: 'number',
|
|
37540
|
+
defaultValue: 10,
|
|
37541
|
+
description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
|
|
37542
|
+
group: 'AI & Spatial Reasoning',
|
|
37543
|
+
},
|
|
37544
|
+
llmFallbackEnabled: {
|
|
37545
|
+
title: 'Fallback to Basic Notifications',
|
|
37546
|
+
type: 'boolean',
|
|
37547
|
+
defaultValue: true,
|
|
37548
|
+
description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
|
|
37549
|
+
group: 'AI & Spatial Reasoning',
|
|
37550
|
+
},
|
|
37551
|
+
llmFallbackTimeout: {
|
|
37552
|
+
title: 'LLM Timeout (seconds)',
|
|
37553
|
+
type: 'number',
|
|
37554
|
+
defaultValue: 3,
|
|
37555
|
+
description: 'Maximum time to wait for LLM response before falling back to basic notification',
|
|
37556
|
+
group: 'AI & Spatial Reasoning',
|
|
37557
|
+
},
|
|
37558
|
+
enableTransitTimeLearning: {
|
|
37559
|
+
title: 'Learn Transit Times',
|
|
37560
|
+
type: 'boolean',
|
|
37561
|
+
defaultValue: true,
|
|
37562
|
+
description: 'Automatically adjust connection transit times based on observed movement patterns',
|
|
37563
|
+
group: 'AI & Spatial Reasoning',
|
|
37564
|
+
},
|
|
37565
|
+
enableConnectionSuggestions: {
|
|
37566
|
+
title: 'Suggest Camera Connections',
|
|
37567
|
+
type: 'boolean',
|
|
37568
|
+
defaultValue: true,
|
|
37569
|
+
description: 'Automatically suggest new camera connections based on observed movement patterns',
|
|
37570
|
+
group: 'AI & Spatial Reasoning',
|
|
37571
|
+
},
|
|
36809
37572
|
enableLandmarkLearning: {
|
|
36810
37573
|
title: 'Learn Landmarks from AI',
|
|
36811
37574
|
type: 'boolean',
|
|
@@ -36965,6 +37728,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36965
37728
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
|
|
36966
37729
|
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
|
|
36967
37730
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
37731
|
+
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval || 10) * 1000,
|
|
37732
|
+
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
|
|
37733
|
+
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout || 3) * 1000,
|
|
37734
|
+
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
|
|
37735
|
+
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
|
|
36968
37736
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
36969
37737
|
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
|
|
36970
37738
|
};
|
|
@@ -37043,8 +37811,89 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37043
37811
|
// ==================== Settings Implementation ====================
|
|
37044
37812
|
async getSettings() {
|
|
37045
37813
|
const settings = await this.storageSettings.getSettings();
|
|
37814
|
+
// Training Mode button that opens mobile-friendly training UI in modal
|
|
37815
|
+
const trainingOnclickCode = `(function(){var e=document.getElementById('sa-training-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-training-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:min(420px,95vw);height:92vh;max-height:900px;background:#121212;border-radius:8px;overflow:hidden;position:relative;box-shadow:0 8px 32px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:8px;right:8px;z-index:2147483647;background:rgba(255,255,255,0.1);color:white;border:none;width:32px;height:32px;border-radius:4px;font-size:18px;cursor:pointer;line-height:1;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/training';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
|
|
37816
|
+
settings.push({
|
|
37817
|
+
key: 'trainingMode',
|
|
37818
|
+
title: 'Training Mode',
|
|
37819
|
+
type: 'html',
|
|
37820
|
+
value: `
|
|
37821
|
+
<style>
|
|
37822
|
+
.sa-training-container {
|
|
37823
|
+
padding: 16px;
|
|
37824
|
+
background: rgba(255,255,255,0.03);
|
|
37825
|
+
border-radius: 4px;
|
|
37826
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
37827
|
+
}
|
|
37828
|
+
.sa-training-title {
|
|
37829
|
+
color: #4fc3f7;
|
|
37830
|
+
font-size: 14px;
|
|
37831
|
+
font-weight: 500;
|
|
37832
|
+
margin-bottom: 8px;
|
|
37833
|
+
font-family: inherit;
|
|
37834
|
+
}
|
|
37835
|
+
.sa-training-desc {
|
|
37836
|
+
color: rgba(255,255,255,0.6);
|
|
37837
|
+
margin-bottom: 12px;
|
|
37838
|
+
font-size: 13px;
|
|
37839
|
+
line-height: 1.5;
|
|
37840
|
+
font-family: inherit;
|
|
37841
|
+
}
|
|
37842
|
+
.sa-training-btn {
|
|
37843
|
+
background: #4fc3f7;
|
|
37844
|
+
color: #000;
|
|
37845
|
+
border: none;
|
|
37846
|
+
padding: 10px 20px;
|
|
37847
|
+
border-radius: 4px;
|
|
37848
|
+
font-size: 14px;
|
|
37849
|
+
font-weight: 500;
|
|
37850
|
+
cursor: pointer;
|
|
37851
|
+
display: inline-flex;
|
|
37852
|
+
align-items: center;
|
|
37853
|
+
gap: 8px;
|
|
37854
|
+
transition: background 0.2s;
|
|
37855
|
+
font-family: inherit;
|
|
37856
|
+
}
|
|
37857
|
+
.sa-training-btn:hover {
|
|
37858
|
+
background: #81d4fa;
|
|
37859
|
+
}
|
|
37860
|
+
.sa-training-steps {
|
|
37861
|
+
color: rgba(255,255,255,0.5);
|
|
37862
|
+
font-size: 12px;
|
|
37863
|
+
margin-top: 12px;
|
|
37864
|
+
padding-top: 12px;
|
|
37865
|
+
border-top: 1px solid rgba(255,255,255,0.05);
|
|
37866
|
+
font-family: inherit;
|
|
37867
|
+
}
|
|
37868
|
+
.sa-training-steps ol {
|
|
37869
|
+
margin: 6px 0 0 16px;
|
|
37870
|
+
padding: 0;
|
|
37871
|
+
}
|
|
37872
|
+
.sa-training-steps li {
|
|
37873
|
+
margin-bottom: 2px;
|
|
37874
|
+
}
|
|
37875
|
+
</style>
|
|
37876
|
+
<div class="sa-training-container">
|
|
37877
|
+
<div class="sa-training-title">Guided Property Training</div>
|
|
37878
|
+
<p class="sa-training-desc">Walk your property while the system learns your camera layout, transit times, and landmarks automatically.</p>
|
|
37879
|
+
<button class="sa-training-btn" onclick="${trainingOnclickCode}">
|
|
37880
|
+
Start Training Mode
|
|
37881
|
+
</button>
|
|
37882
|
+
<div class="sa-training-steps">
|
|
37883
|
+
<strong>How it works:</strong>
|
|
37884
|
+
<ol>
|
|
37885
|
+
<li>Start training and walk to each camera</li>
|
|
37886
|
+
<li>System auto-detects you and records transit times</li>
|
|
37887
|
+
<li>Mark landmarks as you encounter them</li>
|
|
37888
|
+
<li>Apply results to generate your topology</li>
|
|
37889
|
+
</ol>
|
|
37890
|
+
</div>
|
|
37891
|
+
</div>
|
|
37892
|
+
`,
|
|
37893
|
+
group: 'Getting Started',
|
|
37894
|
+
});
|
|
37046
37895
|
// Topology editor button that opens modal overlay (appended to body for proper z-index)
|
|
37047
|
-
const onclickCode = `(function(){var e=document.getElementById('sa-topology-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-topology-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.
|
|
37896
|
+
const onclickCode = `(function(){var e=document.getElementById('sa-topology-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-topology-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:95vw;height:92vh;max-width:1800px;background:#121212;border-radius:8px;overflow:hidden;position:relative;box-shadow:0 8px 32px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:8px;right:8px;z-index:2147483647;background:rgba(255,255,255,0.1);color:white;border:none;width:32px;height:32px;border-radius:4px;font-size:18px;cursor:pointer;line-height:1;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
|
|
37048
37897
|
settings.push({
|
|
37049
37898
|
key: 'topologyEditor',
|
|
37050
37899
|
title: 'Topology Editor',
|
|
@@ -37052,39 +37901,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37052
37901
|
value: `
|
|
37053
37902
|
<style>
|
|
37054
37903
|
.sa-open-btn {
|
|
37055
|
-
background:
|
|
37056
|
-
color:
|
|
37904
|
+
background: #4fc3f7;
|
|
37905
|
+
color: #000;
|
|
37057
37906
|
border: none;
|
|
37058
|
-
padding:
|
|
37059
|
-
border-radius:
|
|
37060
|
-
font-size:
|
|
37061
|
-
font-weight:
|
|
37907
|
+
padding: 10px 20px;
|
|
37908
|
+
border-radius: 4px;
|
|
37909
|
+
font-size: 14px;
|
|
37910
|
+
font-weight: 500;
|
|
37062
37911
|
cursor: pointer;
|
|
37063
37912
|
display: inline-flex;
|
|
37064
37913
|
align-items: center;
|
|
37065
|
-
gap:
|
|
37066
|
-
transition:
|
|
37914
|
+
gap: 8px;
|
|
37915
|
+
transition: background 0.2s;
|
|
37916
|
+
font-family: inherit;
|
|
37067
37917
|
}
|
|
37068
37918
|
.sa-open-btn:hover {
|
|
37069
|
-
|
|
37070
|
-
box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
|
|
37919
|
+
background: #81d4fa;
|
|
37071
37920
|
}
|
|
37072
37921
|
.sa-btn-container {
|
|
37073
|
-
padding:
|
|
37074
|
-
background:
|
|
37075
|
-
border-radius:
|
|
37922
|
+
padding: 16px;
|
|
37923
|
+
background: rgba(255,255,255,0.03);
|
|
37924
|
+
border-radius: 4px;
|
|
37076
37925
|
text-align: center;
|
|
37926
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
37077
37927
|
}
|
|
37078
37928
|
.sa-btn-desc {
|
|
37079
|
-
color:
|
|
37080
|
-
margin-bottom:
|
|
37081
|
-
font-size:
|
|
37929
|
+
color: rgba(255,255,255,0.6);
|
|
37930
|
+
margin-bottom: 12px;
|
|
37931
|
+
font-size: 13px;
|
|
37932
|
+
font-family: inherit;
|
|
37082
37933
|
}
|
|
37083
37934
|
</style>
|
|
37084
37935
|
<div class="sa-btn-container">
|
|
37085
37936
|
<p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
|
|
37086
37937
|
<button class="sa-open-btn" onclick="${onclickCode}">
|
|
37087
|
-
|
|
37938
|
+
Open Topology Editor
|
|
37088
37939
|
</button>
|
|
37089
37940
|
</div>
|
|
37090
37941
|
`,
|
|
@@ -37214,6 +38065,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37214
38065
|
key === 'loiteringThreshold' ||
|
|
37215
38066
|
key === 'objectAlertCooldown' ||
|
|
37216
38067
|
key === 'useLlmDescriptions' ||
|
|
38068
|
+
key === 'llmDebounceInterval' ||
|
|
38069
|
+
key === 'llmFallbackEnabled' ||
|
|
38070
|
+
key === 'llmFallbackTimeout' ||
|
|
38071
|
+
key === 'enableTransitTimeLearning' ||
|
|
38072
|
+
key === 'enableConnectionSuggestions' ||
|
|
37217
38073
|
key === 'enableLandmarkLearning' ||
|
|
37218
38074
|
key === 'landmarkConfidenceThreshold') {
|
|
37219
38075
|
const topologyJson = this.storage.getItem('topology');
|
|
@@ -37289,27 +38145,85 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37289
38145
|
if (path.endsWith('/api/infer-relationships')) {
|
|
37290
38146
|
return this.handleInferRelationshipsRequest(response);
|
|
37291
38147
|
}
|
|
38148
|
+
// Connection suggestions
|
|
38149
|
+
if (path.endsWith('/api/connection-suggestions')) {
|
|
38150
|
+
return this.handleConnectionSuggestionsRequest(request, response);
|
|
38151
|
+
}
|
|
38152
|
+
if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
|
|
38153
|
+
const parts = path.split('/');
|
|
38154
|
+
const action = parts.pop();
|
|
38155
|
+
const suggestionId = parts.pop();
|
|
38156
|
+
return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
|
|
38157
|
+
}
|
|
38158
|
+
// Live tracking state
|
|
38159
|
+
if (path.endsWith('/api/live-tracking')) {
|
|
38160
|
+
return this.handleLiveTrackingRequest(response);
|
|
38161
|
+
}
|
|
38162
|
+
// Journey visualization
|
|
38163
|
+
if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
|
|
38164
|
+
const globalId = path.split('/').pop();
|
|
38165
|
+
return this.handleJourneyPathRequest(globalId, response);
|
|
38166
|
+
}
|
|
38167
|
+
// Training Mode endpoints
|
|
38168
|
+
if (path.endsWith('/api/training/start')) {
|
|
38169
|
+
return this.handleTrainingStartRequest(request, response);
|
|
38170
|
+
}
|
|
38171
|
+
if (path.endsWith('/api/training/pause')) {
|
|
38172
|
+
return this.handleTrainingPauseRequest(response);
|
|
38173
|
+
}
|
|
38174
|
+
if (path.endsWith('/api/training/resume')) {
|
|
38175
|
+
return this.handleTrainingResumeRequest(response);
|
|
38176
|
+
}
|
|
38177
|
+
if (path.endsWith('/api/training/end')) {
|
|
38178
|
+
return this.handleTrainingEndRequest(response);
|
|
38179
|
+
}
|
|
38180
|
+
if (path.endsWith('/api/training/status')) {
|
|
38181
|
+
return this.handleTrainingStatusRequest(response);
|
|
38182
|
+
}
|
|
38183
|
+
if (path.endsWith('/api/training/landmark')) {
|
|
38184
|
+
return this.handleTrainingLandmarkRequest(request, response);
|
|
38185
|
+
}
|
|
38186
|
+
if (path.endsWith('/api/training/apply')) {
|
|
38187
|
+
return this.handleTrainingApplyRequest(response);
|
|
38188
|
+
}
|
|
37292
38189
|
// UI Routes
|
|
37293
38190
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
37294
38191
|
return this.serveEditorUI(response);
|
|
37295
38192
|
}
|
|
38193
|
+
if (path.endsWith('/ui/training') || path.endsWith('/ui/training/')) {
|
|
38194
|
+
return this.serveTrainingUI(response);
|
|
38195
|
+
}
|
|
37296
38196
|
if (path.includes('/ui/')) {
|
|
37297
38197
|
return this.serveStaticFile(path, response);
|
|
37298
38198
|
}
|
|
37299
38199
|
// Default: return info page
|
|
37300
38200
|
response.send(JSON.stringify({
|
|
37301
38201
|
name: 'Spatial Awareness Plugin',
|
|
37302
|
-
version: '0.
|
|
38202
|
+
version: '0.4.0',
|
|
37303
38203
|
endpoints: {
|
|
37304
38204
|
api: {
|
|
37305
38205
|
trackedObjects: '/api/tracked-objects',
|
|
37306
38206
|
journey: '/api/journey/{globalId}',
|
|
38207
|
+
journeyPath: '/api/journey-path/{globalId}',
|
|
37307
38208
|
topology: '/api/topology',
|
|
37308
38209
|
alerts: '/api/alerts',
|
|
37309
38210
|
floorPlan: '/api/floor-plan',
|
|
38211
|
+
liveTracking: '/api/live-tracking',
|
|
38212
|
+
connectionSuggestions: '/api/connection-suggestions',
|
|
38213
|
+
landmarkSuggestions: '/api/landmark-suggestions',
|
|
38214
|
+
training: {
|
|
38215
|
+
start: '/api/training/start',
|
|
38216
|
+
pause: '/api/training/pause',
|
|
38217
|
+
resume: '/api/training/resume',
|
|
38218
|
+
end: '/api/training/end',
|
|
38219
|
+
status: '/api/training/status',
|
|
38220
|
+
landmark: '/api/training/landmark',
|
|
38221
|
+
apply: '/api/training/apply',
|
|
38222
|
+
},
|
|
37310
38223
|
},
|
|
37311
38224
|
ui: {
|
|
37312
38225
|
editor: '/ui/editor',
|
|
38226
|
+
training: '/ui/training',
|
|
37313
38227
|
},
|
|
37314
38228
|
},
|
|
37315
38229
|
}), {
|
|
@@ -37666,32 +38580,290 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37666
38580
|
headers: { 'Content-Type': 'application/json' },
|
|
37667
38581
|
});
|
|
37668
38582
|
}
|
|
37669
|
-
|
|
37670
|
-
|
|
37671
|
-
|
|
38583
|
+
handleConnectionSuggestionsRequest(request, response) {
|
|
38584
|
+
if (!this.trackingEngine) {
|
|
38585
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
38586
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38587
|
+
});
|
|
38588
|
+
return;
|
|
38589
|
+
}
|
|
38590
|
+
const suggestions = this.trackingEngine.getConnectionSuggestions();
|
|
38591
|
+
response.send(JSON.stringify({
|
|
38592
|
+
suggestions,
|
|
38593
|
+
count: suggestions.length,
|
|
38594
|
+
}), {
|
|
38595
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37672
38596
|
});
|
|
37673
38597
|
}
|
|
37674
|
-
|
|
37675
|
-
|
|
37676
|
-
|
|
37677
|
-
|
|
37678
|
-
|
|
37679
|
-
|
|
37680
|
-
|
|
37681
|
-
|
|
37682
|
-
|
|
37683
|
-
|
|
37684
|
-
|
|
37685
|
-
|
|
37686
|
-
|
|
37687
|
-
|
|
37688
|
-
|
|
37689
|
-
-
|
|
37690
|
-
|
|
37691
|
-
|
|
37692
|
-
|
|
37693
|
-
|
|
37694
|
-
|
|
38598
|
+
handleConnectionSuggestionActionRequest(suggestionId, action, response) {
|
|
38599
|
+
if (!this.trackingEngine) {
|
|
38600
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38601
|
+
code: 500,
|
|
38602
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38603
|
+
});
|
|
38604
|
+
return;
|
|
38605
|
+
}
|
|
38606
|
+
if (action === 'accept') {
|
|
38607
|
+
const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
|
|
38608
|
+
if (connection) {
|
|
38609
|
+
// Save updated topology
|
|
38610
|
+
const topology = this.trackingEngine.getTopology();
|
|
38611
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
38612
|
+
response.send(JSON.stringify({ success: true, connection }), {
|
|
38613
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38614
|
+
});
|
|
38615
|
+
}
|
|
38616
|
+
else {
|
|
38617
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
38618
|
+
code: 404,
|
|
38619
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38620
|
+
});
|
|
38621
|
+
}
|
|
38622
|
+
}
|
|
38623
|
+
else if (action === 'reject') {
|
|
38624
|
+
const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
|
|
38625
|
+
if (success) {
|
|
38626
|
+
response.send(JSON.stringify({ success: true }), {
|
|
38627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38628
|
+
});
|
|
38629
|
+
}
|
|
38630
|
+
else {
|
|
38631
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
38632
|
+
code: 404,
|
|
38633
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38634
|
+
});
|
|
38635
|
+
}
|
|
38636
|
+
}
|
|
38637
|
+
else {
|
|
38638
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
38639
|
+
code: 400,
|
|
38640
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38641
|
+
});
|
|
38642
|
+
}
|
|
38643
|
+
}
|
|
38644
|
+
handleLiveTrackingRequest(response) {
|
|
38645
|
+
if (!this.trackingEngine) {
|
|
38646
|
+
response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
|
|
38647
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38648
|
+
});
|
|
38649
|
+
return;
|
|
38650
|
+
}
|
|
38651
|
+
const liveState = this.trackingEngine.getLiveTrackingState();
|
|
38652
|
+
response.send(JSON.stringify(liveState), {
|
|
38653
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38654
|
+
});
|
|
38655
|
+
}
|
|
38656
|
+
handleJourneyPathRequest(globalId, response) {
|
|
38657
|
+
if (!this.trackingEngine) {
|
|
38658
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38659
|
+
code: 500,
|
|
38660
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38661
|
+
});
|
|
38662
|
+
return;
|
|
38663
|
+
}
|
|
38664
|
+
const journeyPath = this.trackingEngine.getJourneyPath(globalId);
|
|
38665
|
+
if (journeyPath) {
|
|
38666
|
+
response.send(JSON.stringify(journeyPath), {
|
|
38667
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38668
|
+
});
|
|
38669
|
+
}
|
|
38670
|
+
else {
|
|
38671
|
+
response.send(JSON.stringify({ error: 'Object not found' }), {
|
|
38672
|
+
code: 404,
|
|
38673
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38674
|
+
});
|
|
38675
|
+
}
|
|
38676
|
+
}
|
|
38677
|
+
// ==================== Training Mode Handlers ====================
|
|
38678
|
+
handleTrainingStartRequest(request, response) {
|
|
38679
|
+
if (!this.trackingEngine) {
|
|
38680
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running. Configure topology first.' }), {
|
|
38681
|
+
code: 500,
|
|
38682
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38683
|
+
});
|
|
38684
|
+
return;
|
|
38685
|
+
}
|
|
38686
|
+
try {
|
|
38687
|
+
let config;
|
|
38688
|
+
let trainerName;
|
|
38689
|
+
if (request.body) {
|
|
38690
|
+
const body = JSON.parse(request.body);
|
|
38691
|
+
trainerName = body.trainerName;
|
|
38692
|
+
config = body.config;
|
|
38693
|
+
}
|
|
38694
|
+
const session = this.trackingEngine.startTrainingSession(trainerName, config);
|
|
38695
|
+
response.send(JSON.stringify(session), {
|
|
38696
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38697
|
+
});
|
|
38698
|
+
}
|
|
38699
|
+
catch (e) {
|
|
38700
|
+
response.send(JSON.stringify({ error: e.message }), {
|
|
38701
|
+
code: 500,
|
|
38702
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38703
|
+
});
|
|
38704
|
+
}
|
|
38705
|
+
}
|
|
38706
|
+
handleTrainingPauseRequest(response) {
|
|
38707
|
+
if (!this.trackingEngine) {
|
|
38708
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38709
|
+
code: 500,
|
|
38710
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38711
|
+
});
|
|
38712
|
+
return;
|
|
38713
|
+
}
|
|
38714
|
+
const success = this.trackingEngine.pauseTrainingSession();
|
|
38715
|
+
if (success) {
|
|
38716
|
+
response.send(JSON.stringify({ success: true }), {
|
|
38717
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38718
|
+
});
|
|
38719
|
+
}
|
|
38720
|
+
else {
|
|
38721
|
+
response.send(JSON.stringify({ error: 'No active training session to pause' }), {
|
|
38722
|
+
code: 400,
|
|
38723
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38724
|
+
});
|
|
38725
|
+
}
|
|
38726
|
+
}
|
|
38727
|
+
handleTrainingResumeRequest(response) {
|
|
38728
|
+
if (!this.trackingEngine) {
|
|
38729
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38730
|
+
code: 500,
|
|
38731
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38732
|
+
});
|
|
38733
|
+
return;
|
|
38734
|
+
}
|
|
38735
|
+
const success = this.trackingEngine.resumeTrainingSession();
|
|
38736
|
+
if (success) {
|
|
38737
|
+
response.send(JSON.stringify({ success: true }), {
|
|
38738
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38739
|
+
});
|
|
38740
|
+
}
|
|
38741
|
+
else {
|
|
38742
|
+
response.send(JSON.stringify({ error: 'No paused training session to resume' }), {
|
|
38743
|
+
code: 400,
|
|
38744
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38745
|
+
});
|
|
38746
|
+
}
|
|
38747
|
+
}
|
|
38748
|
+
handleTrainingEndRequest(response) {
|
|
38749
|
+
if (!this.trackingEngine) {
|
|
38750
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38751
|
+
code: 500,
|
|
38752
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38753
|
+
});
|
|
38754
|
+
return;
|
|
38755
|
+
}
|
|
38756
|
+
const session = this.trackingEngine.endTrainingSession();
|
|
38757
|
+
if (session) {
|
|
38758
|
+
response.send(JSON.stringify(session), {
|
|
38759
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38760
|
+
});
|
|
38761
|
+
}
|
|
38762
|
+
else {
|
|
38763
|
+
response.send(JSON.stringify({ error: 'No training session to end' }), {
|
|
38764
|
+
code: 400,
|
|
38765
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38766
|
+
});
|
|
38767
|
+
}
|
|
38768
|
+
}
|
|
38769
|
+
handleTrainingStatusRequest(response) {
|
|
38770
|
+
if (!this.trackingEngine) {
|
|
38771
|
+
response.send(JSON.stringify({ state: 'idle', stats: null }), {
|
|
38772
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38773
|
+
});
|
|
38774
|
+
return;
|
|
38775
|
+
}
|
|
38776
|
+
const status = this.trackingEngine.getTrainingStatus();
|
|
38777
|
+
if (status) {
|
|
38778
|
+
response.send(JSON.stringify(status), {
|
|
38779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38780
|
+
});
|
|
38781
|
+
}
|
|
38782
|
+
else {
|
|
38783
|
+
response.send(JSON.stringify({ state: 'idle', stats: null }), {
|
|
38784
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38785
|
+
});
|
|
38786
|
+
}
|
|
38787
|
+
}
|
|
38788
|
+
handleTrainingLandmarkRequest(request, response) {
|
|
38789
|
+
if (!this.trackingEngine) {
|
|
38790
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38791
|
+
code: 500,
|
|
38792
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38793
|
+
});
|
|
38794
|
+
return;
|
|
38795
|
+
}
|
|
38796
|
+
try {
|
|
38797
|
+
const body = JSON.parse(request.body);
|
|
38798
|
+
const landmark = this.trackingEngine.markTrainingLandmark(body);
|
|
38799
|
+
if (landmark) {
|
|
38800
|
+
response.send(JSON.stringify({ success: true, landmark }), {
|
|
38801
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38802
|
+
});
|
|
38803
|
+
}
|
|
38804
|
+
else {
|
|
38805
|
+
response.send(JSON.stringify({ error: 'No active training session' }), {
|
|
38806
|
+
code: 400,
|
|
38807
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38808
|
+
});
|
|
38809
|
+
}
|
|
38810
|
+
}
|
|
38811
|
+
catch (e) {
|
|
38812
|
+
response.send(JSON.stringify({ error: 'Invalid request body' }), {
|
|
38813
|
+
code: 400,
|
|
38814
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38815
|
+
});
|
|
38816
|
+
}
|
|
38817
|
+
}
|
|
38818
|
+
handleTrainingApplyRequest(response) {
|
|
38819
|
+
if (!this.trackingEngine) {
|
|
38820
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38821
|
+
code: 500,
|
|
38822
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38823
|
+
});
|
|
38824
|
+
return;
|
|
38825
|
+
}
|
|
38826
|
+
const result = this.trackingEngine.applyTrainingToTopology();
|
|
38827
|
+
if (result.success) {
|
|
38828
|
+
// Save the updated topology
|
|
38829
|
+
const topology = this.trackingEngine.getTopology();
|
|
38830
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
38831
|
+
}
|
|
38832
|
+
response.send(JSON.stringify(result), {
|
|
38833
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38834
|
+
});
|
|
38835
|
+
}
|
|
38836
|
+
serveEditorUI(response) {
|
|
38837
|
+
response.send(editor_html_1.EDITOR_HTML, {
|
|
38838
|
+
headers: { 'Content-Type': 'text/html' },
|
|
38839
|
+
});
|
|
38840
|
+
}
|
|
38841
|
+
serveTrainingUI(response) {
|
|
38842
|
+
response.send(training_html_1.TRAINING_HTML, {
|
|
38843
|
+
headers: { 'Content-Type': 'text/html' },
|
|
38844
|
+
});
|
|
38845
|
+
}
|
|
38846
|
+
serveStaticFile(path, response) {
|
|
38847
|
+
// Serve static files for the UI
|
|
38848
|
+
response.send('Not found', { code: 404 });
|
|
38849
|
+
}
|
|
38850
|
+
// ==================== Readme Implementation ====================
|
|
38851
|
+
async getReadmeMarkdown() {
|
|
38852
|
+
return `
|
|
38853
|
+
# Spatial Awareness Plugin
|
|
38854
|
+
|
|
38855
|
+
This plugin enables cross-camera object tracking across your entire NVR system.
|
|
38856
|
+
|
|
38857
|
+
## Features
|
|
38858
|
+
|
|
38859
|
+
- **Cross-Camera Tracking**: Correlate objects as they move between cameras
|
|
38860
|
+
- **Journey History**: Complete path history for each tracked object
|
|
38861
|
+
- **Entry/Exit Detection**: Know when objects enter or leave your property
|
|
38862
|
+
- **Visual Floor Plan**: Configure camera topology with a visual editor
|
|
38863
|
+
- **MQTT Integration**: Export tracking data to Home Assistant
|
|
38864
|
+
- **REST API**: Query tracked objects and journeys programmatically
|
|
38865
|
+
- **Smart Alerts**: Get notified about property entry/exit, unusual paths, and more
|
|
38866
|
+
|
|
37695
38867
|
## Setup
|
|
37696
38868
|
|
|
37697
38869
|
1. **Add Cameras**: Select cameras with object detection in the plugin settings
|
|
@@ -38249,6 +39421,83 @@ function getJourneySummary(tracked) {
|
|
|
38249
39421
|
}
|
|
38250
39422
|
|
|
38251
39423
|
|
|
39424
|
+
/***/ },
|
|
39425
|
+
|
|
39426
|
+
/***/ "./src/models/training.ts"
|
|
39427
|
+
/*!********************************!*\
|
|
39428
|
+
!*** ./src/models/training.ts ***!
|
|
39429
|
+
\********************************/
|
|
39430
|
+
(__unused_webpack_module, exports) {
|
|
39431
|
+
|
|
39432
|
+
"use strict";
|
|
39433
|
+
|
|
39434
|
+
/**
|
|
39435
|
+
* Training Mode Types
|
|
39436
|
+
*
|
|
39437
|
+
* These types support the guided training system where a user physically
|
|
39438
|
+
* walks around their property to train the system on camera positions,
|
|
39439
|
+
* transit times, overlaps, landmarks, and structures.
|
|
39440
|
+
*/
|
|
39441
|
+
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
39442
|
+
exports.DEFAULT_TRAINING_CONFIG = void 0;
|
|
39443
|
+
exports.createTrainingSession = createTrainingSession;
|
|
39444
|
+
exports.calculateTrainingStats = calculateTrainingStats;
|
|
39445
|
+
/** Default training configuration */
|
|
39446
|
+
exports.DEFAULT_TRAINING_CONFIG = {
|
|
39447
|
+
minDetectionConfidence: 0.7,
|
|
39448
|
+
maxTransitWait: 120, // 2 minutes
|
|
39449
|
+
autoDetectOverlaps: true,
|
|
39450
|
+
autoSuggestLandmarks: true,
|
|
39451
|
+
minOverlapDuration: 2, // 2 seconds
|
|
39452
|
+
};
|
|
39453
|
+
/** Create a new empty training session */
|
|
39454
|
+
function createTrainingSession(trainerName) {
|
|
39455
|
+
const now = Date.now();
|
|
39456
|
+
return {
|
|
39457
|
+
id: `training-${now}-${Math.random().toString(36).substr(2, 9)}`,
|
|
39458
|
+
state: 'idle',
|
|
39459
|
+
startedAt: now,
|
|
39460
|
+
updatedAt: now,
|
|
39461
|
+
trainerName,
|
|
39462
|
+
visits: [],
|
|
39463
|
+
transits: [],
|
|
39464
|
+
landmarks: [],
|
|
39465
|
+
overlaps: [],
|
|
39466
|
+
structures: [],
|
|
39467
|
+
stats: {
|
|
39468
|
+
totalDuration: 0,
|
|
39469
|
+
camerasVisited: 0,
|
|
39470
|
+
transitsRecorded: 0,
|
|
39471
|
+
landmarksMarked: 0,
|
|
39472
|
+
overlapsDetected: 0,
|
|
39473
|
+
structuresMarked: 0,
|
|
39474
|
+
averageTransitTime: 0,
|
|
39475
|
+
coveragePercentage: 0,
|
|
39476
|
+
},
|
|
39477
|
+
};
|
|
39478
|
+
}
|
|
39479
|
+
/** Calculate session statistics */
|
|
39480
|
+
function calculateTrainingStats(session, totalCameras) {
|
|
39481
|
+
const uniqueCameras = new Set(session.visits.map(v => v.cameraId));
|
|
39482
|
+
const transitTimes = session.transits.map(t => t.transitSeconds);
|
|
39483
|
+
const avgTransit = transitTimes.length > 0
|
|
39484
|
+
? transitTimes.reduce((a, b) => a + b, 0) / transitTimes.length
|
|
39485
|
+
: 0;
|
|
39486
|
+
return {
|
|
39487
|
+
totalDuration: (session.completedAt || Date.now()) - session.startedAt,
|
|
39488
|
+
camerasVisited: uniqueCameras.size,
|
|
39489
|
+
transitsRecorded: session.transits.length,
|
|
39490
|
+
landmarksMarked: session.landmarks.length,
|
|
39491
|
+
overlapsDetected: session.overlaps.length,
|
|
39492
|
+
structuresMarked: session.structures.length,
|
|
39493
|
+
averageTransitTime: Math.round(avgTransit),
|
|
39494
|
+
coveragePercentage: totalCameras > 0
|
|
39495
|
+
? Math.round((uniqueCameras.size / totalCameras) * 100)
|
|
39496
|
+
: 0,
|
|
39497
|
+
};
|
|
39498
|
+
}
|
|
39499
|
+
|
|
39500
|
+
|
|
38252
39501
|
/***/ },
|
|
38253
39502
|
|
|
38254
39503
|
/***/ "./src/state/tracking-state.ts"
|
|
@@ -38615,6 +39864,23 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38615
39864
|
</div>
|
|
38616
39865
|
<div id="suggestions-list"></div>
|
|
38617
39866
|
</div>
|
|
39867
|
+
<div class="section" id="connection-suggestions-section" style="display: none;">
|
|
39868
|
+
<div class="section-title">
|
|
39869
|
+
<span>Connection Suggestions</span>
|
|
39870
|
+
<button class="btn btn-small" onclick="loadConnectionSuggestions()">Refresh</button>
|
|
39871
|
+
</div>
|
|
39872
|
+
<div id="connection-suggestions-list"></div>
|
|
39873
|
+
</div>
|
|
39874
|
+
<div class="section" id="live-tracking-section">
|
|
39875
|
+
<div class="section-title">
|
|
39876
|
+
<span>Live Tracking</span>
|
|
39877
|
+
<label class="checkbox-group" style="font-size: 11px; font-weight: normal; text-transform: none;">
|
|
39878
|
+
<input type="checkbox" id="live-tracking-toggle" onchange="toggleLiveTracking(this.checked)">
|
|
39879
|
+
Enable
|
|
39880
|
+
</label>
|
|
39881
|
+
</div>
|
|
39882
|
+
<div id="live-tracking-list" style="max-height: 150px; overflow-y: auto;"></div>
|
|
39883
|
+
</div>
|
|
38618
39884
|
</div>
|
|
38619
39885
|
</div>
|
|
38620
39886
|
<div class="editor">
|
|
@@ -38805,6 +40071,12 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38805
40071
|
let availableCameras = [];
|
|
38806
40072
|
let landmarkTemplates = [];
|
|
38807
40073
|
let pendingSuggestions = [];
|
|
40074
|
+
let connectionSuggestions = [];
|
|
40075
|
+
let liveTrackingData = { objects: [], timestamp: 0 };
|
|
40076
|
+
let liveTrackingEnabled = false;
|
|
40077
|
+
let liveTrackingInterval = null;
|
|
40078
|
+
let selectedJourneyId = null;
|
|
40079
|
+
let journeyPath = null;
|
|
38808
40080
|
let isDrawing = false;
|
|
38809
40081
|
let drawStart = null;
|
|
38810
40082
|
let currentDrawing = null;
|
|
@@ -38817,6 +40089,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38817
40089
|
await loadAvailableCameras();
|
|
38818
40090
|
await loadLandmarkTemplates();
|
|
38819
40091
|
await loadSuggestions();
|
|
40092
|
+
await loadConnectionSuggestions();
|
|
38820
40093
|
resizeCanvas();
|
|
38821
40094
|
render();
|
|
38822
40095
|
updateUI();
|
|
@@ -38916,22 +40189,148 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38916
40189
|
} catch (e) { console.error('Failed to reject suggestion:', e); }
|
|
38917
40190
|
}
|
|
38918
40191
|
|
|
38919
|
-
|
|
38920
|
-
|
|
38921
|
-
|
|
40192
|
+
// ==================== Connection Suggestions ====================
|
|
40193
|
+
async function loadConnectionSuggestions() {
|
|
40194
|
+
try {
|
|
40195
|
+
const response = await fetch('../api/connection-suggestions');
|
|
40196
|
+
if (response.ok) {
|
|
40197
|
+
const data = await response.json();
|
|
40198
|
+
connectionSuggestions = data.suggestions || [];
|
|
40199
|
+
updateConnectionSuggestionsUI();
|
|
40200
|
+
}
|
|
40201
|
+
} catch (e) { console.error('Failed to load connection suggestions:', e); }
|
|
38922
40202
|
}
|
|
38923
40203
|
|
|
38924
|
-
function
|
|
38925
|
-
const
|
|
38926
|
-
const
|
|
38927
|
-
|
|
38928
|
-
|
|
38929
|
-
|
|
38930
|
-
|
|
38931
|
-
|
|
38932
|
-
|
|
38933
|
-
|
|
38934
|
-
|
|
40204
|
+
function updateConnectionSuggestionsUI() {
|
|
40205
|
+
const section = document.getElementById('connection-suggestions-section');
|
|
40206
|
+
const list = document.getElementById('connection-suggestions-list');
|
|
40207
|
+
if (connectionSuggestions.length === 0) {
|
|
40208
|
+
section.style.display = 'none';
|
|
40209
|
+
return;
|
|
40210
|
+
}
|
|
40211
|
+
section.style.display = 'block';
|
|
40212
|
+
list.innerHTML = connectionSuggestions.map(s =>
|
|
40213
|
+
'<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
|
|
40214
|
+
'<div><div class="camera-name">' + s.fromCameraName + ' → ' + s.toCameraName + '</div>' +
|
|
40215
|
+
'<div class="camera-info">' + Math.round(s.suggestedTransitTime.typical / 1000) + 's typical, ' +
|
|
40216
|
+
Math.round(s.confidence * 100) + '% confidence</div></div>' +
|
|
40217
|
+
'<div style="display: flex; gap: 5px;">' +
|
|
40218
|
+
'<button class="btn btn-small btn-primary" onclick="acceptConnectionSuggestion(\\'' + s.id + '\\')">Accept</button>' +
|
|
40219
|
+
'<button class="btn btn-small" onclick="rejectConnectionSuggestion(\\'' + s.id + '\\')">Reject</button>' +
|
|
40220
|
+
'</div></div>'
|
|
40221
|
+
).join('');
|
|
40222
|
+
}
|
|
40223
|
+
|
|
40224
|
+
async function acceptConnectionSuggestion(id) {
|
|
40225
|
+
try {
|
|
40226
|
+
const response = await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/accept', { method: 'POST' });
|
|
40227
|
+
if (response.ok) {
|
|
40228
|
+
const data = await response.json();
|
|
40229
|
+
if (data.connection) {
|
|
40230
|
+
topology.connections.push(data.connection);
|
|
40231
|
+
updateUI();
|
|
40232
|
+
render();
|
|
40233
|
+
}
|
|
40234
|
+
await loadConnectionSuggestions();
|
|
40235
|
+
setStatus('Connection accepted', 'success');
|
|
40236
|
+
}
|
|
40237
|
+
} catch (e) { console.error('Failed to accept connection suggestion:', e); }
|
|
40238
|
+
}
|
|
40239
|
+
|
|
40240
|
+
async function rejectConnectionSuggestion(id) {
|
|
40241
|
+
try {
|
|
40242
|
+
await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/reject', { method: 'POST' });
|
|
40243
|
+
await loadConnectionSuggestions();
|
|
40244
|
+
setStatus('Connection suggestion rejected', 'success');
|
|
40245
|
+
} catch (e) { console.error('Failed to reject connection suggestion:', e); }
|
|
40246
|
+
}
|
|
40247
|
+
|
|
40248
|
+
// ==================== Live Tracking ====================
|
|
40249
|
+
function toggleLiveTracking(enabled) {
|
|
40250
|
+
liveTrackingEnabled = enabled;
|
|
40251
|
+
if (enabled) {
|
|
40252
|
+
loadLiveTracking();
|
|
40253
|
+
liveTrackingInterval = setInterval(loadLiveTracking, 2000); // Poll every 2 seconds
|
|
40254
|
+
} else {
|
|
40255
|
+
if (liveTrackingInterval) {
|
|
40256
|
+
clearInterval(liveTrackingInterval);
|
|
40257
|
+
liveTrackingInterval = null;
|
|
40258
|
+
}
|
|
40259
|
+
liveTrackingData = { objects: [], timestamp: 0 };
|
|
40260
|
+
selectedJourneyId = null;
|
|
40261
|
+
journeyPath = null;
|
|
40262
|
+
updateLiveTrackingUI();
|
|
40263
|
+
render();
|
|
40264
|
+
}
|
|
40265
|
+
}
|
|
40266
|
+
|
|
40267
|
+
async function loadLiveTracking() {
|
|
40268
|
+
try {
|
|
40269
|
+
const response = await fetch('../api/live-tracking');
|
|
40270
|
+
if (response.ok) {
|
|
40271
|
+
liveTrackingData = await response.json();
|
|
40272
|
+
updateLiveTrackingUI();
|
|
40273
|
+
render();
|
|
40274
|
+
}
|
|
40275
|
+
} catch (e) { console.error('Failed to load live tracking:', e); }
|
|
40276
|
+
}
|
|
40277
|
+
|
|
40278
|
+
function updateLiveTrackingUI() {
|
|
40279
|
+
const list = document.getElementById('live-tracking-list');
|
|
40280
|
+
if (liveTrackingData.objects.length === 0) {
|
|
40281
|
+
list.innerHTML = '<div style="color: #666; font-size: 12px; text-align: center; padding: 10px;">No active objects</div>';
|
|
40282
|
+
return;
|
|
40283
|
+
}
|
|
40284
|
+
list.innerHTML = liveTrackingData.objects.map(obj => {
|
|
40285
|
+
const isSelected = selectedJourneyId === obj.globalId;
|
|
40286
|
+
const ageSeconds = Math.round((Date.now() - obj.lastSeen) / 1000);
|
|
40287
|
+
const ageStr = ageSeconds < 60 ? ageSeconds + 's ago' : Math.round(ageSeconds / 60) + 'm ago';
|
|
40288
|
+
return '<div class="camera-item' + (isSelected ? ' selected' : '') + '" ' +
|
|
40289
|
+
'onclick="selectTrackedObject(\\'' + obj.globalId + '\\')" ' +
|
|
40290
|
+
'style="padding: 8px; cursor: pointer;">' +
|
|
40291
|
+
'<div class="camera-name" style="font-size: 12px;">' +
|
|
40292
|
+
(obj.className.charAt(0).toUpperCase() + obj.className.slice(1)) +
|
|
40293
|
+
(obj.label ? ' (' + obj.label + ')' : '') + '</div>' +
|
|
40294
|
+
'<div class="camera-info">' + obj.lastCameraName + ' • ' + ageStr + '</div>' +
|
|
40295
|
+
'</div>';
|
|
40296
|
+
}).join('');
|
|
40297
|
+
}
|
|
40298
|
+
|
|
40299
|
+
async function selectTrackedObject(globalId) {
|
|
40300
|
+
if (selectedJourneyId === globalId) {
|
|
40301
|
+
// Deselect
|
|
40302
|
+
selectedJourneyId = null;
|
|
40303
|
+
journeyPath = null;
|
|
40304
|
+
} else {
|
|
40305
|
+
selectedJourneyId = globalId;
|
|
40306
|
+
// Load journey path
|
|
40307
|
+
try {
|
|
40308
|
+
const response = await fetch('../api/journey-path/' + globalId);
|
|
40309
|
+
if (response.ok) {
|
|
40310
|
+
journeyPath = await response.json();
|
|
40311
|
+
}
|
|
40312
|
+
} catch (e) { console.error('Failed to load journey path:', e); }
|
|
40313
|
+
}
|
|
40314
|
+
updateLiveTrackingUI();
|
|
40315
|
+
render();
|
|
40316
|
+
}
|
|
40317
|
+
|
|
40318
|
+
function openAddLandmarkModal() {
|
|
40319
|
+
updateLandmarkSuggestions();
|
|
40320
|
+
document.getElementById('add-landmark-modal').classList.add('active');
|
|
40321
|
+
}
|
|
40322
|
+
|
|
40323
|
+
function updateLandmarkSuggestions() {
|
|
40324
|
+
const type = document.getElementById('landmark-type-select').value;
|
|
40325
|
+
const template = landmarkTemplates.find(t => t.type === type);
|
|
40326
|
+
const container = document.getElementById('landmark-templates');
|
|
40327
|
+
if (template) {
|
|
40328
|
+
container.innerHTML = template.suggestions.map(s =>
|
|
40329
|
+
'<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
|
|
40330
|
+
).join('');
|
|
40331
|
+
} else {
|
|
40332
|
+
container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
|
|
40333
|
+
}
|
|
38935
40334
|
}
|
|
38936
40335
|
|
|
38937
40336
|
function setLandmarkName(name) {
|
|
@@ -39139,6 +40538,112 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
39139
40538
|
for (const camera of topology.cameras) {
|
|
39140
40539
|
if (camera.floorPlanPosition) { drawCamera(camera); }
|
|
39141
40540
|
}
|
|
40541
|
+
|
|
40542
|
+
// Draw journey path if selected
|
|
40543
|
+
if (journeyPath && journeyPath.segments.length > 0) {
|
|
40544
|
+
drawJourneyPath();
|
|
40545
|
+
}
|
|
40546
|
+
|
|
40547
|
+
// Draw live tracking objects
|
|
40548
|
+
if (liveTrackingEnabled && liveTrackingData.objects.length > 0) {
|
|
40549
|
+
drawLiveTrackingObjects();
|
|
40550
|
+
}
|
|
40551
|
+
}
|
|
40552
|
+
|
|
40553
|
+
function drawJourneyPath() {
|
|
40554
|
+
if (!journeyPath) return;
|
|
40555
|
+
|
|
40556
|
+
ctx.strokeStyle = '#ff6b6b';
|
|
40557
|
+
ctx.lineWidth = 3;
|
|
40558
|
+
ctx.setLineDash([8, 4]);
|
|
40559
|
+
|
|
40560
|
+
// Draw path segments
|
|
40561
|
+
for (const segment of journeyPath.segments) {
|
|
40562
|
+
if (segment.fromCamera.position && segment.toCamera.position) {
|
|
40563
|
+
ctx.beginPath();
|
|
40564
|
+
ctx.moveTo(segment.fromCamera.position.x, segment.fromCamera.position.y);
|
|
40565
|
+
ctx.lineTo(segment.toCamera.position.x, segment.toCamera.position.y);
|
|
40566
|
+
ctx.stroke();
|
|
40567
|
+
|
|
40568
|
+
// Draw timestamp indicator
|
|
40569
|
+
const midX = (segment.fromCamera.position.x + segment.toCamera.position.x) / 2;
|
|
40570
|
+
const midY = (segment.fromCamera.position.y + segment.toCamera.position.y) / 2;
|
|
40571
|
+
ctx.fillStyle = 'rgba(255, 107, 107, 0.9)';
|
|
40572
|
+
ctx.beginPath();
|
|
40573
|
+
ctx.arc(midX, midY, 4, 0, Math.PI * 2);
|
|
40574
|
+
ctx.fill();
|
|
40575
|
+
}
|
|
40576
|
+
}
|
|
40577
|
+
|
|
40578
|
+
ctx.setLineDash([]);
|
|
40579
|
+
|
|
40580
|
+
// Draw current location indicator
|
|
40581
|
+
if (journeyPath.currentLocation?.position) {
|
|
40582
|
+
const pos = journeyPath.currentLocation.position;
|
|
40583
|
+
// Pulsing dot effect
|
|
40584
|
+
const pulse = (Date.now() % 1000) / 1000;
|
|
40585
|
+
const radius = 10 + pulse * 5;
|
|
40586
|
+
const alpha = 1 - pulse * 0.5;
|
|
40587
|
+
|
|
40588
|
+
ctx.beginPath();
|
|
40589
|
+
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
|
|
40590
|
+
ctx.fillStyle = 'rgba(255, 107, 107, ' + alpha + ')';
|
|
40591
|
+
ctx.fill();
|
|
40592
|
+
|
|
40593
|
+
ctx.beginPath();
|
|
40594
|
+
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2);
|
|
40595
|
+
ctx.fillStyle = '#ff6b6b';
|
|
40596
|
+
ctx.fill();
|
|
40597
|
+
ctx.strokeStyle = '#fff';
|
|
40598
|
+
ctx.lineWidth = 2;
|
|
40599
|
+
ctx.stroke();
|
|
40600
|
+
}
|
|
40601
|
+
}
|
|
40602
|
+
|
|
40603
|
+
function drawLiveTrackingObjects() {
|
|
40604
|
+
const objectColors = {
|
|
40605
|
+
person: '#4caf50',
|
|
40606
|
+
car: '#2196f3',
|
|
40607
|
+
animal: '#ff9800',
|
|
40608
|
+
default: '#9c27b0'
|
|
40609
|
+
};
|
|
40610
|
+
|
|
40611
|
+
for (const obj of liveTrackingData.objects) {
|
|
40612
|
+
if (!obj.cameraPosition) continue;
|
|
40613
|
+
|
|
40614
|
+
// Skip if this is the selected journey object (drawn separately with path)
|
|
40615
|
+
if (obj.globalId === selectedJourneyId) continue;
|
|
40616
|
+
|
|
40617
|
+
const pos = obj.cameraPosition;
|
|
40618
|
+
const color = objectColors[obj.className] || objectColors.default;
|
|
40619
|
+
const ageSeconds = (Date.now() - obj.lastSeen) / 1000;
|
|
40620
|
+
|
|
40621
|
+
// Fade old objects
|
|
40622
|
+
const alpha = Math.max(0.3, 1 - ageSeconds / 60);
|
|
40623
|
+
|
|
40624
|
+
// Draw object indicator
|
|
40625
|
+
ctx.beginPath();
|
|
40626
|
+
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
|
|
40627
|
+
ctx.fillStyle = color.replace(')', ', ' + alpha + ')').replace('rgb', 'rgba');
|
|
40628
|
+
ctx.fill();
|
|
40629
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, ' + alpha + ')';
|
|
40630
|
+
ctx.lineWidth = 2;
|
|
40631
|
+
ctx.stroke();
|
|
40632
|
+
|
|
40633
|
+
// Draw class icon
|
|
40634
|
+
ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')';
|
|
40635
|
+
ctx.font = 'bold 10px sans-serif';
|
|
40636
|
+
ctx.textAlign = 'center';
|
|
40637
|
+
ctx.textBaseline = 'middle';
|
|
40638
|
+
const icon = obj.className === 'person' ? 'P' : obj.className === 'car' ? 'C' : obj.className === 'animal' ? 'A' : '?';
|
|
40639
|
+
ctx.fillText(icon, pos.x, pos.y);
|
|
40640
|
+
|
|
40641
|
+
// Draw label below
|
|
40642
|
+
if (obj.label) {
|
|
40643
|
+
ctx.font = '9px sans-serif';
|
|
40644
|
+
ctx.fillText(obj.label.slice(0, 10), pos.x, pos.y + 20);
|
|
40645
|
+
}
|
|
40646
|
+
}
|
|
39142
40647
|
}
|
|
39143
40648
|
|
|
39144
40649
|
function drawLandmark(landmark) {
|
|
@@ -39525,6 +41030,1026 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
39525
41030
|
</html>`;
|
|
39526
41031
|
|
|
39527
41032
|
|
|
41033
|
+
/***/ },
|
|
41034
|
+
|
|
41035
|
+
/***/ "./src/ui/training-html.ts"
|
|
41036
|
+
/*!*********************************!*\
|
|
41037
|
+
!*** ./src/ui/training-html.ts ***!
|
|
41038
|
+
\*********************************/
|
|
41039
|
+
(__unused_webpack_module, exports) {
|
|
41040
|
+
|
|
41041
|
+
"use strict";
|
|
41042
|
+
|
|
41043
|
+
/**
|
|
41044
|
+
* Training Mode UI - Mobile-optimized walkthrough interface
|
|
41045
|
+
* Designed for phone use while walking around property
|
|
41046
|
+
*/
|
|
41047
|
+
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
41048
|
+
exports.TRAINING_HTML = void 0;
|
|
41049
|
+
exports.TRAINING_HTML = `<!DOCTYPE html>
|
|
41050
|
+
<html lang="en">
|
|
41051
|
+
<head>
|
|
41052
|
+
<meta charset="UTF-8">
|
|
41053
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
41054
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
41055
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
41056
|
+
<title>Spatial Awareness - Training Mode</title>
|
|
41057
|
+
<style>
|
|
41058
|
+
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
41059
|
+
html, body { height: 100%; overflow: hidden; }
|
|
41060
|
+
body {
|
|
41061
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
41062
|
+
background: #121212;
|
|
41063
|
+
color: rgba(255,255,255,0.87);
|
|
41064
|
+
min-height: 100vh;
|
|
41065
|
+
display: flex;
|
|
41066
|
+
flex-direction: column;
|
|
41067
|
+
}
|
|
41068
|
+
|
|
41069
|
+
/* Header */
|
|
41070
|
+
.header {
|
|
41071
|
+
background: rgba(255,255,255,0.03);
|
|
41072
|
+
padding: 12px 16px;
|
|
41073
|
+
display: flex;
|
|
41074
|
+
justify-content: space-between;
|
|
41075
|
+
align-items: center;
|
|
41076
|
+
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
41077
|
+
}
|
|
41078
|
+
.header h1 { font-size: 16px; font-weight: 500; }
|
|
41079
|
+
.header-status {
|
|
41080
|
+
display: flex;
|
|
41081
|
+
align-items: center;
|
|
41082
|
+
gap: 8px;
|
|
41083
|
+
font-size: 13px;
|
|
41084
|
+
}
|
|
41085
|
+
.status-badge {
|
|
41086
|
+
padding: 4px 10px;
|
|
41087
|
+
border-radius: 4px;
|
|
41088
|
+
font-size: 11px;
|
|
41089
|
+
font-weight: 500;
|
|
41090
|
+
text-transform: uppercase;
|
|
41091
|
+
}
|
|
41092
|
+
.status-badge.idle { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
|
|
41093
|
+
.status-badge.active { background: #4fc3f7; color: #000; animation: pulse 2s infinite; }
|
|
41094
|
+
.status-badge.paused { background: #ffb74d; color: #000; }
|
|
41095
|
+
.status-badge.completed { background: #81c784; color: #000; }
|
|
41096
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
|
41097
|
+
|
|
41098
|
+
/* Main content area */
|
|
41099
|
+
.main-content {
|
|
41100
|
+
flex: 1;
|
|
41101
|
+
display: flex;
|
|
41102
|
+
flex-direction: column;
|
|
41103
|
+
padding: 12px;
|
|
41104
|
+
overflow-y: auto;
|
|
41105
|
+
gap: 12px;
|
|
41106
|
+
}
|
|
41107
|
+
|
|
41108
|
+
/* Camera detection card */
|
|
41109
|
+
.detection-card {
|
|
41110
|
+
background: rgba(255,255,255,0.03);
|
|
41111
|
+
border-radius: 8px;
|
|
41112
|
+
padding: 16px;
|
|
41113
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
41114
|
+
transition: all 0.3s;
|
|
41115
|
+
}
|
|
41116
|
+
.detection-card.detecting {
|
|
41117
|
+
border-color: #4fc3f7;
|
|
41118
|
+
background: rgba(79, 195, 247, 0.1);
|
|
41119
|
+
}
|
|
41120
|
+
.detection-card.in-transit {
|
|
41121
|
+
border-color: #ffb74d;
|
|
41122
|
+
background: rgba(255, 183, 77, 0.1);
|
|
41123
|
+
}
|
|
41124
|
+
|
|
41125
|
+
.detection-icon {
|
|
41126
|
+
width: 64px;
|
|
41127
|
+
height: 64px;
|
|
41128
|
+
border-radius: 8px;
|
|
41129
|
+
background: rgba(255,255,255,0.05);
|
|
41130
|
+
display: flex;
|
|
41131
|
+
align-items: center;
|
|
41132
|
+
justify-content: center;
|
|
41133
|
+
margin: 0 auto 12px;
|
|
41134
|
+
font-size: 28px;
|
|
41135
|
+
}
|
|
41136
|
+
.detection-card.detecting .detection-icon {
|
|
41137
|
+
background: #4fc3f7;
|
|
41138
|
+
animation: detectPulse 1.5s infinite;
|
|
41139
|
+
}
|
|
41140
|
+
@keyframes detectPulse {
|
|
41141
|
+
0%, 100% { transform: scale(1); }
|
|
41142
|
+
50% { transform: scale(1.03); }
|
|
41143
|
+
}
|
|
41144
|
+
|
|
41145
|
+
.detection-title {
|
|
41146
|
+
font-size: 18px;
|
|
41147
|
+
font-weight: 500;
|
|
41148
|
+
text-align: center;
|
|
41149
|
+
margin-bottom: 4px;
|
|
41150
|
+
}
|
|
41151
|
+
.detection-subtitle {
|
|
41152
|
+
font-size: 13px;
|
|
41153
|
+
color: rgba(255,255,255,0.5);
|
|
41154
|
+
text-align: center;
|
|
41155
|
+
}
|
|
41156
|
+
.detection-confidence {
|
|
41157
|
+
margin-top: 8px;
|
|
41158
|
+
text-align: center;
|
|
41159
|
+
font-size: 12px;
|
|
41160
|
+
color: rgba(255,255,255,0.4);
|
|
41161
|
+
}
|
|
41162
|
+
|
|
41163
|
+
/* Transit timer */
|
|
41164
|
+
.transit-timer {
|
|
41165
|
+
display: flex;
|
|
41166
|
+
align-items: center;
|
|
41167
|
+
justify-content: center;
|
|
41168
|
+
gap: 8px;
|
|
41169
|
+
margin-top: 12px;
|
|
41170
|
+
padding: 10px;
|
|
41171
|
+
background: rgba(0,0,0,0.2);
|
|
41172
|
+
border-radius: 6px;
|
|
41173
|
+
}
|
|
41174
|
+
.transit-timer-icon { font-size: 18px; }
|
|
41175
|
+
.transit-timer-text { font-size: 16px; font-weight: 500; }
|
|
41176
|
+
.transit-timer-from { font-size: 12px; color: rgba(255,255,255,0.5); }
|
|
41177
|
+
|
|
41178
|
+
/* Stats grid */
|
|
41179
|
+
.stats-grid {
|
|
41180
|
+
display: grid;
|
|
41181
|
+
grid-template-columns: repeat(3, 1fr);
|
|
41182
|
+
gap: 8px;
|
|
41183
|
+
}
|
|
41184
|
+
.stat-item {
|
|
41185
|
+
background: rgba(255,255,255,0.03);
|
|
41186
|
+
border-radius: 6px;
|
|
41187
|
+
padding: 12px 8px;
|
|
41188
|
+
text-align: center;
|
|
41189
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
41190
|
+
}
|
|
41191
|
+
.stat-value {
|
|
41192
|
+
font-size: 24px;
|
|
41193
|
+
font-weight: 500;
|
|
41194
|
+
color: #4fc3f7;
|
|
41195
|
+
}
|
|
41196
|
+
.stat-label {
|
|
41197
|
+
font-size: 10px;
|
|
41198
|
+
color: rgba(255,255,255,0.4);
|
|
41199
|
+
text-transform: uppercase;
|
|
41200
|
+
margin-top: 2px;
|
|
41201
|
+
}
|
|
41202
|
+
|
|
41203
|
+
/* Progress bar */
|
|
41204
|
+
.progress-section {
|
|
41205
|
+
background: rgba(255,255,255,0.03);
|
|
41206
|
+
border-radius: 6px;
|
|
41207
|
+
padding: 12px;
|
|
41208
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
41209
|
+
}
|
|
41210
|
+
.progress-header {
|
|
41211
|
+
display: flex;
|
|
41212
|
+
justify-content: space-between;
|
|
41213
|
+
margin-bottom: 8px;
|
|
41214
|
+
font-size: 13px;
|
|
41215
|
+
}
|
|
41216
|
+
.progress-bar {
|
|
41217
|
+
height: 6px;
|
|
41218
|
+
background: rgba(255,255,255,0.1);
|
|
41219
|
+
border-radius: 3px;
|
|
41220
|
+
overflow: hidden;
|
|
41221
|
+
}
|
|
41222
|
+
.progress-fill {
|
|
41223
|
+
height: 100%;
|
|
41224
|
+
background: #4fc3f7;
|
|
41225
|
+
border-radius: 3px;
|
|
41226
|
+
transition: width 0.5s;
|
|
41227
|
+
}
|
|
41228
|
+
|
|
41229
|
+
/* Suggestions */
|
|
41230
|
+
.suggestions-section {
|
|
41231
|
+
background: rgba(79, 195, 247, 0.08);
|
|
41232
|
+
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
41233
|
+
border-radius: 6px;
|
|
41234
|
+
padding: 12px;
|
|
41235
|
+
}
|
|
41236
|
+
.suggestions-title {
|
|
41237
|
+
font-size: 11px;
|
|
41238
|
+
text-transform: uppercase;
|
|
41239
|
+
color: #4fc3f7;
|
|
41240
|
+
margin-bottom: 6px;
|
|
41241
|
+
font-weight: 500;
|
|
41242
|
+
}
|
|
41243
|
+
.suggestion-item {
|
|
41244
|
+
font-size: 13px;
|
|
41245
|
+
padding: 6px 0;
|
|
41246
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
41247
|
+
color: rgba(255,255,255,0.7);
|
|
41248
|
+
}
|
|
41249
|
+
.suggestion-item:last-child { border-bottom: none; }
|
|
41250
|
+
.suggestion-item::before {
|
|
41251
|
+
content: "→ ";
|
|
41252
|
+
color: #4fc3f7;
|
|
41253
|
+
}
|
|
41254
|
+
|
|
41255
|
+
/* Action buttons */
|
|
41256
|
+
.action-buttons {
|
|
41257
|
+
display: flex;
|
|
41258
|
+
gap: 8px;
|
|
41259
|
+
padding: 8px 0;
|
|
41260
|
+
}
|
|
41261
|
+
.btn {
|
|
41262
|
+
flex: 1;
|
|
41263
|
+
padding: 14px 16px;
|
|
41264
|
+
border: none;
|
|
41265
|
+
border-radius: 6px;
|
|
41266
|
+
font-size: 14px;
|
|
41267
|
+
font-weight: 500;
|
|
41268
|
+
cursor: pointer;
|
|
41269
|
+
transition: all 0.2s;
|
|
41270
|
+
display: flex;
|
|
41271
|
+
align-items: center;
|
|
41272
|
+
justify-content: center;
|
|
41273
|
+
gap: 6px;
|
|
41274
|
+
}
|
|
41275
|
+
.btn:active { transform: scale(0.98); }
|
|
41276
|
+
.btn-primary { background: #4fc3f7; color: #000; }
|
|
41277
|
+
.btn-secondary { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.87); }
|
|
41278
|
+
.btn-danger { background: #ef5350; color: #fff; }
|
|
41279
|
+
.btn-warning { background: #ffb74d; color: #000; }
|
|
41280
|
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
41281
|
+
|
|
41282
|
+
/* Full-width button */
|
|
41283
|
+
.btn-full { flex: none; width: 100%; }
|
|
41284
|
+
|
|
41285
|
+
/* Mark landmark/structure panel */
|
|
41286
|
+
.mark-panel {
|
|
41287
|
+
background: rgba(255,255,255,0.03);
|
|
41288
|
+
border-radius: 6px;
|
|
41289
|
+
padding: 16px;
|
|
41290
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
41291
|
+
}
|
|
41292
|
+
.mark-panel-title {
|
|
41293
|
+
font-size: 14px;
|
|
41294
|
+
font-weight: 500;
|
|
41295
|
+
margin-bottom: 12px;
|
|
41296
|
+
}
|
|
41297
|
+
.mark-type-grid {
|
|
41298
|
+
display: grid;
|
|
41299
|
+
grid-template-columns: repeat(4, 1fr);
|
|
41300
|
+
gap: 6px;
|
|
41301
|
+
margin-bottom: 12px;
|
|
41302
|
+
}
|
|
41303
|
+
.mark-type-btn {
|
|
41304
|
+
padding: 10px 6px;
|
|
41305
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
41306
|
+
border-radius: 6px;
|
|
41307
|
+
background: transparent;
|
|
41308
|
+
color: rgba(255,255,255,0.7);
|
|
41309
|
+
font-size: 10px;
|
|
41310
|
+
text-align: center;
|
|
41311
|
+
cursor: pointer;
|
|
41312
|
+
transition: all 0.2s;
|
|
41313
|
+
}
|
|
41314
|
+
.mark-type-btn.selected {
|
|
41315
|
+
border-color: #4fc3f7;
|
|
41316
|
+
background: rgba(79, 195, 247, 0.15);
|
|
41317
|
+
color: #fff;
|
|
41318
|
+
}
|
|
41319
|
+
.mark-type-btn .icon { font-size: 18px; margin-bottom: 2px; display: block; }
|
|
41320
|
+
|
|
41321
|
+
/* Input field */
|
|
41322
|
+
.input-group { margin-bottom: 12px; }
|
|
41323
|
+
.input-group label {
|
|
41324
|
+
display: block;
|
|
41325
|
+
font-size: 12px;
|
|
41326
|
+
color: rgba(255,255,255,0.5);
|
|
41327
|
+
margin-bottom: 4px;
|
|
41328
|
+
}
|
|
41329
|
+
.input-group input {
|
|
41330
|
+
width: 100%;
|
|
41331
|
+
padding: 12px;
|
|
41332
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
41333
|
+
border-radius: 6px;
|
|
41334
|
+
background: rgba(0,0,0,0.2);
|
|
41335
|
+
color: #fff;
|
|
41336
|
+
font-size: 14px;
|
|
41337
|
+
}
|
|
41338
|
+
.input-group input:focus {
|
|
41339
|
+
outline: none;
|
|
41340
|
+
border-color: #4fc3f7;
|
|
41341
|
+
}
|
|
41342
|
+
|
|
41343
|
+
/* History list */
|
|
41344
|
+
.history-section {
|
|
41345
|
+
background: rgba(255,255,255,0.03);
|
|
41346
|
+
border-radius: 6px;
|
|
41347
|
+
padding: 12px;
|
|
41348
|
+
max-height: 180px;
|
|
41349
|
+
overflow-y: auto;
|
|
41350
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
41351
|
+
}
|
|
41352
|
+
.history-title {
|
|
41353
|
+
font-size: 13px;
|
|
41354
|
+
font-weight: 500;
|
|
41355
|
+
margin-bottom: 8px;
|
|
41356
|
+
display: flex;
|
|
41357
|
+
justify-content: space-between;
|
|
41358
|
+
}
|
|
41359
|
+
.history-item {
|
|
41360
|
+
padding: 8px;
|
|
41361
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
41362
|
+
font-size: 13px;
|
|
41363
|
+
}
|
|
41364
|
+
.history-item:last-child { border-bottom: none; }
|
|
41365
|
+
.history-item-time {
|
|
41366
|
+
font-size: 10px;
|
|
41367
|
+
color: rgba(255,255,255,0.4);
|
|
41368
|
+
}
|
|
41369
|
+
.history-item-camera { color: #4fc3f7; font-weight: 500; }
|
|
41370
|
+
.history-item-transit { color: #ffb74d; }
|
|
41371
|
+
|
|
41372
|
+
/* Bottom action bar */
|
|
41373
|
+
.bottom-bar {
|
|
41374
|
+
background: rgba(255,255,255,0.03);
|
|
41375
|
+
padding: 12px 16px;
|
|
41376
|
+
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
|
41377
|
+
border-top: 1px solid rgba(255,255,255,0.08);
|
|
41378
|
+
}
|
|
41379
|
+
|
|
41380
|
+
/* Tabs */
|
|
41381
|
+
.tabs {
|
|
41382
|
+
display: flex;
|
|
41383
|
+
background: rgba(0,0,0,0.2);
|
|
41384
|
+
border-radius: 6px;
|
|
41385
|
+
padding: 3px;
|
|
41386
|
+
margin-bottom: 12px;
|
|
41387
|
+
}
|
|
41388
|
+
.tab {
|
|
41389
|
+
flex: 1;
|
|
41390
|
+
padding: 10px;
|
|
41391
|
+
border: none;
|
|
41392
|
+
background: transparent;
|
|
41393
|
+
color: rgba(255,255,255,0.5);
|
|
41394
|
+
font-size: 13px;
|
|
41395
|
+
font-weight: 500;
|
|
41396
|
+
cursor: pointer;
|
|
41397
|
+
border-radius: 4px;
|
|
41398
|
+
transition: all 0.2s;
|
|
41399
|
+
}
|
|
41400
|
+
.tab.active {
|
|
41401
|
+
background: rgba(255,255,255,0.08);
|
|
41402
|
+
color: rgba(255,255,255,0.87);
|
|
41403
|
+
}
|
|
41404
|
+
|
|
41405
|
+
/* Tab content */
|
|
41406
|
+
.tab-content { display: none; }
|
|
41407
|
+
.tab-content.active { display: block; }
|
|
41408
|
+
|
|
41409
|
+
/* Apply results modal */
|
|
41410
|
+
.modal-overlay {
|
|
41411
|
+
display: none;
|
|
41412
|
+
position: fixed;
|
|
41413
|
+
top: 0;
|
|
41414
|
+
left: 0;
|
|
41415
|
+
right: 0;
|
|
41416
|
+
bottom: 0;
|
|
41417
|
+
background: rgba(0,0,0,0.85);
|
|
41418
|
+
z-index: 100;
|
|
41419
|
+
align-items: center;
|
|
41420
|
+
justify-content: center;
|
|
41421
|
+
padding: 16px;
|
|
41422
|
+
}
|
|
41423
|
+
.modal-overlay.active { display: flex; }
|
|
41424
|
+
.modal {
|
|
41425
|
+
background: #1e1e1e;
|
|
41426
|
+
border-radius: 8px;
|
|
41427
|
+
padding: 20px;
|
|
41428
|
+
max-width: 360px;
|
|
41429
|
+
width: 100%;
|
|
41430
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
41431
|
+
}
|
|
41432
|
+
.modal h2 { font-size: 18px; margin-bottom: 12px; font-weight: 500; }
|
|
41433
|
+
.modal-result-item {
|
|
41434
|
+
display: flex;
|
|
41435
|
+
justify-content: space-between;
|
|
41436
|
+
padding: 8px 0;
|
|
41437
|
+
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
41438
|
+
font-size: 13px;
|
|
41439
|
+
}
|
|
41440
|
+
.modal-result-value { color: #4fc3f7; font-weight: 500; }
|
|
41441
|
+
.modal-buttons { display: flex; gap: 8px; margin-top: 16px; }
|
|
41442
|
+
|
|
41443
|
+
/* Idle state */
|
|
41444
|
+
.idle-content {
|
|
41445
|
+
flex: 1;
|
|
41446
|
+
display: flex;
|
|
41447
|
+
flex-direction: column;
|
|
41448
|
+
align-items: center;
|
|
41449
|
+
justify-content: center;
|
|
41450
|
+
text-align: center;
|
|
41451
|
+
padding: 32px 16px;
|
|
41452
|
+
}
|
|
41453
|
+
.idle-icon {
|
|
41454
|
+
font-size: 56px;
|
|
41455
|
+
margin-bottom: 16px;
|
|
41456
|
+
opacity: 0.7;
|
|
41457
|
+
}
|
|
41458
|
+
.idle-title {
|
|
41459
|
+
font-size: 20px;
|
|
41460
|
+
font-weight: 500;
|
|
41461
|
+
margin-bottom: 8px;
|
|
41462
|
+
}
|
|
41463
|
+
.idle-desc {
|
|
41464
|
+
font-size: 14px;
|
|
41465
|
+
color: rgba(255,255,255,0.5);
|
|
41466
|
+
max-width: 280px;
|
|
41467
|
+
line-height: 1.5;
|
|
41468
|
+
}
|
|
41469
|
+
.idle-instructions {
|
|
41470
|
+
margin-top: 24px;
|
|
41471
|
+
text-align: left;
|
|
41472
|
+
background: rgba(255,255,255,0.03);
|
|
41473
|
+
border-radius: 6px;
|
|
41474
|
+
padding: 16px;
|
|
41475
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
41476
|
+
}
|
|
41477
|
+
.idle-instructions h3 {
|
|
41478
|
+
font-size: 12px;
|
|
41479
|
+
margin-bottom: 10px;
|
|
41480
|
+
color: #4fc3f7;
|
|
41481
|
+
font-weight: 500;
|
|
41482
|
+
}
|
|
41483
|
+
.idle-instructions ol {
|
|
41484
|
+
padding-left: 18px;
|
|
41485
|
+
font-size: 13px;
|
|
41486
|
+
line-height: 1.8;
|
|
41487
|
+
color: rgba(255,255,255,0.6);
|
|
41488
|
+
}
|
|
41489
|
+
</style>
|
|
41490
|
+
</head>
|
|
41491
|
+
<body>
|
|
41492
|
+
<div class="header">
|
|
41493
|
+
<h1>Training Mode</h1>
|
|
41494
|
+
<div class="header-status">
|
|
41495
|
+
<span class="status-badge" id="status-badge">Idle</span>
|
|
41496
|
+
</div>
|
|
41497
|
+
</div>
|
|
41498
|
+
|
|
41499
|
+
<!-- Idle State -->
|
|
41500
|
+
<div class="main-content" id="idle-content">
|
|
41501
|
+
<div class="idle-content">
|
|
41502
|
+
<div class="idle-icon">🚶</div>
|
|
41503
|
+
<div class="idle-title">Train Your System</div>
|
|
41504
|
+
<div class="idle-desc">Walk around your property to teach the system about camera positions, transit times, and landmarks.</div>
|
|
41505
|
+
|
|
41506
|
+
<div class="idle-instructions">
|
|
41507
|
+
<h3>How it works:</h3>
|
|
41508
|
+
<ol>
|
|
41509
|
+
<li>Tap <strong>Start Training</strong> below</li>
|
|
41510
|
+
<li>Walk to each camera on your property</li>
|
|
41511
|
+
<li>The system detects you automatically</li>
|
|
41512
|
+
<li>Mark landmarks as you encounter them</li>
|
|
41513
|
+
<li>End training when you're done</li>
|
|
41514
|
+
</ol>
|
|
41515
|
+
</div>
|
|
41516
|
+
</div>
|
|
41517
|
+
|
|
41518
|
+
<div class="bottom-bar">
|
|
41519
|
+
<button class="btn btn-primary btn-full" onclick="startTraining()">
|
|
41520
|
+
▶ Start Training
|
|
41521
|
+
</button>
|
|
41522
|
+
</div>
|
|
41523
|
+
</div>
|
|
41524
|
+
|
|
41525
|
+
<!-- Active Training State -->
|
|
41526
|
+
<div class="main-content" id="active-content" style="display: none;">
|
|
41527
|
+
<!-- Detection Card -->
|
|
41528
|
+
<div class="detection-card" id="detection-card">
|
|
41529
|
+
<div class="detection-icon" id="detection-icon">👤</div>
|
|
41530
|
+
<div class="detection-title" id="detection-title">Waiting for detection...</div>
|
|
41531
|
+
<div class="detection-subtitle" id="detection-subtitle">Walk to any camera to begin</div>
|
|
41532
|
+
<div class="detection-confidence" id="detection-confidence"></div>
|
|
41533
|
+
|
|
41534
|
+
<div class="transit-timer" id="transit-timer" style="display: none;">
|
|
41535
|
+
<span class="transit-timer-icon">⏱</span>
|
|
41536
|
+
<span class="transit-timer-text" id="transit-time">0s</span>
|
|
41537
|
+
<span class="transit-timer-from" id="transit-from"></span>
|
|
41538
|
+
</div>
|
|
41539
|
+
</div>
|
|
41540
|
+
|
|
41541
|
+
<!-- Tabs -->
|
|
41542
|
+
<div class="tabs">
|
|
41543
|
+
<button class="tab active" onclick="switchTab('status')">Status</button>
|
|
41544
|
+
<button class="tab" onclick="switchTab('mark')">Mark</button>
|
|
41545
|
+
<button class="tab" onclick="switchTab('history')">History</button>
|
|
41546
|
+
</div>
|
|
41547
|
+
|
|
41548
|
+
<!-- Status Tab -->
|
|
41549
|
+
<div class="tab-content active" id="tab-status">
|
|
41550
|
+
<!-- Stats -->
|
|
41551
|
+
<div class="stats-grid">
|
|
41552
|
+
<div class="stat-item">
|
|
41553
|
+
<div class="stat-value" id="stat-cameras">0</div>
|
|
41554
|
+
<div class="stat-label">Cameras</div>
|
|
41555
|
+
</div>
|
|
41556
|
+
<div class="stat-item">
|
|
41557
|
+
<div class="stat-value" id="stat-transits">0</div>
|
|
41558
|
+
<div class="stat-label">Transits</div>
|
|
41559
|
+
</div>
|
|
41560
|
+
<div class="stat-item">
|
|
41561
|
+
<div class="stat-value" id="stat-landmarks">0</div>
|
|
41562
|
+
<div class="stat-label">Landmarks</div>
|
|
41563
|
+
</div>
|
|
41564
|
+
</div>
|
|
41565
|
+
|
|
41566
|
+
<!-- Progress -->
|
|
41567
|
+
<div class="progress-section" style="margin-top: 15px;">
|
|
41568
|
+
<div class="progress-header">
|
|
41569
|
+
<span>Coverage</span>
|
|
41570
|
+
<span id="progress-percent">0%</span>
|
|
41571
|
+
</div>
|
|
41572
|
+
<div class="progress-bar">
|
|
41573
|
+
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
|
41574
|
+
</div>
|
|
41575
|
+
</div>
|
|
41576
|
+
|
|
41577
|
+
<!-- Suggestions -->
|
|
41578
|
+
<div class="suggestions-section" style="margin-top: 15px;" id="suggestions-section">
|
|
41579
|
+
<div class="suggestions-title">Suggestions</div>
|
|
41580
|
+
<div id="suggestions-list">
|
|
41581
|
+
<div class="suggestion-item">Start walking to a camera</div>
|
|
41582
|
+
</div>
|
|
41583
|
+
</div>
|
|
41584
|
+
</div>
|
|
41585
|
+
|
|
41586
|
+
<!-- Mark Tab -->
|
|
41587
|
+
<div class="tab-content" id="tab-mark">
|
|
41588
|
+
<div class="mark-panel">
|
|
41589
|
+
<div class="mark-panel-title">Mark a Landmark</div>
|
|
41590
|
+
<div class="mark-type-grid" id="landmark-type-grid">
|
|
41591
|
+
<button class="mark-type-btn selected" data-type="mailbox" onclick="selectLandmarkType('mailbox')">
|
|
41592
|
+
<span class="icon">📬</span>
|
|
41593
|
+
Mailbox
|
|
41594
|
+
</button>
|
|
41595
|
+
<button class="mark-type-btn" data-type="garage" onclick="selectLandmarkType('garage')">
|
|
41596
|
+
<span class="icon">🏠</span>
|
|
41597
|
+
Garage
|
|
41598
|
+
</button>
|
|
41599
|
+
<button class="mark-type-btn" data-type="shed" onclick="selectLandmarkType('shed')">
|
|
41600
|
+
<span class="icon">🏚</span>
|
|
41601
|
+
Shed
|
|
41602
|
+
</button>
|
|
41603
|
+
<button class="mark-type-btn" data-type="tree" onclick="selectLandmarkType('tree')">
|
|
41604
|
+
<span class="icon">🌳</span>
|
|
41605
|
+
Tree
|
|
41606
|
+
</button>
|
|
41607
|
+
<button class="mark-type-btn" data-type="gate" onclick="selectLandmarkType('gate')">
|
|
41608
|
+
<span class="icon">🚪</span>
|
|
41609
|
+
Gate
|
|
41610
|
+
</button>
|
|
41611
|
+
<button class="mark-type-btn" data-type="driveway" onclick="selectLandmarkType('driveway')">
|
|
41612
|
+
<span class="icon">🛣</span>
|
|
41613
|
+
Driveway
|
|
41614
|
+
</button>
|
|
41615
|
+
<button class="mark-type-btn" data-type="pool" onclick="selectLandmarkType('pool')">
|
|
41616
|
+
<span class="icon">🏊</span>
|
|
41617
|
+
Pool
|
|
41618
|
+
</button>
|
|
41619
|
+
<button class="mark-type-btn" data-type="other" onclick="selectLandmarkType('other')">
|
|
41620
|
+
<span class="icon">📍</span>
|
|
41621
|
+
Other
|
|
41622
|
+
</button>
|
|
41623
|
+
</div>
|
|
41624
|
+
|
|
41625
|
+
<div class="input-group">
|
|
41626
|
+
<label>Landmark Name</label>
|
|
41627
|
+
<input type="text" id="landmark-name" placeholder="e.g., Front Mailbox">
|
|
41628
|
+
</div>
|
|
41629
|
+
|
|
41630
|
+
<button class="btn btn-primary btn-full" onclick="markLandmark()">
|
|
41631
|
+
📍 Mark Landmark Here
|
|
41632
|
+
</button>
|
|
41633
|
+
</div>
|
|
41634
|
+
</div>
|
|
41635
|
+
|
|
41636
|
+
<!-- History Tab -->
|
|
41637
|
+
<div class="tab-content" id="tab-history">
|
|
41638
|
+
<div class="history-section">
|
|
41639
|
+
<div class="history-title">
|
|
41640
|
+
<span>Recent Activity</span>
|
|
41641
|
+
<span id="history-count">0 events</span>
|
|
41642
|
+
</div>
|
|
41643
|
+
<div id="history-list">
|
|
41644
|
+
<div class="history-item" style="color: rgba(255,255,255,0.4); text-align: center;">
|
|
41645
|
+
No activity yet
|
|
41646
|
+
</div>
|
|
41647
|
+
</div>
|
|
41648
|
+
</div>
|
|
41649
|
+
</div>
|
|
41650
|
+
|
|
41651
|
+
<!-- Bottom Actions -->
|
|
41652
|
+
<div class="bottom-bar">
|
|
41653
|
+
<div class="action-buttons">
|
|
41654
|
+
<button class="btn btn-warning" id="pause-btn" onclick="togglePause()">
|
|
41655
|
+
⏸ Pause
|
|
41656
|
+
</button>
|
|
41657
|
+
<button class="btn btn-danger" onclick="endTraining()">
|
|
41658
|
+
⏹ End
|
|
41659
|
+
</button>
|
|
41660
|
+
</div>
|
|
41661
|
+
</div>
|
|
41662
|
+
</div>
|
|
41663
|
+
|
|
41664
|
+
<!-- Completed State -->
|
|
41665
|
+
<div class="main-content" id="completed-content" style="display: none;">
|
|
41666
|
+
<div class="idle-content">
|
|
41667
|
+
<div class="idle-icon">✅</div>
|
|
41668
|
+
<div class="idle-title">Training Complete!</div>
|
|
41669
|
+
<div class="idle-desc">Review the results and apply them to your topology.</div>
|
|
41670
|
+
</div>
|
|
41671
|
+
|
|
41672
|
+
<!-- Final Stats -->
|
|
41673
|
+
<div class="stats-grid">
|
|
41674
|
+
<div class="stat-item">
|
|
41675
|
+
<div class="stat-value" id="final-cameras">0</div>
|
|
41676
|
+
<div class="stat-label">Cameras</div>
|
|
41677
|
+
</div>
|
|
41678
|
+
<div class="stat-item">
|
|
41679
|
+
<div class="stat-value" id="final-transits">0</div>
|
|
41680
|
+
<div class="stat-label">Transits</div>
|
|
41681
|
+
</div>
|
|
41682
|
+
<div class="stat-item">
|
|
41683
|
+
<div class="stat-value" id="final-landmarks">0</div>
|
|
41684
|
+
<div class="stat-label">Landmarks</div>
|
|
41685
|
+
</div>
|
|
41686
|
+
</div>
|
|
41687
|
+
|
|
41688
|
+
<div class="stats-grid" style="margin-top: 10px;">
|
|
41689
|
+
<div class="stat-item">
|
|
41690
|
+
<div class="stat-value" id="final-overlaps">0</div>
|
|
41691
|
+
<div class="stat-label">Overlaps</div>
|
|
41692
|
+
</div>
|
|
41693
|
+
<div class="stat-item">
|
|
41694
|
+
<div class="stat-value" id="final-avg-transit">0s</div>
|
|
41695
|
+
<div class="stat-label">Avg Transit</div>
|
|
41696
|
+
</div>
|
|
41697
|
+
<div class="stat-item">
|
|
41698
|
+
<div class="stat-value" id="final-coverage">0%</div>
|
|
41699
|
+
<div class="stat-label">Coverage</div>
|
|
41700
|
+
</div>
|
|
41701
|
+
</div>
|
|
41702
|
+
|
|
41703
|
+
<div class="bottom-bar" style="margin-top: auto;">
|
|
41704
|
+
<div class="action-buttons">
|
|
41705
|
+
<button class="btn btn-secondary" onclick="resetTraining()">
|
|
41706
|
+
↻ Start Over
|
|
41707
|
+
</button>
|
|
41708
|
+
<button class="btn btn-primary" onclick="applyTraining()">
|
|
41709
|
+
✓ Apply Results
|
|
41710
|
+
</button>
|
|
41711
|
+
</div>
|
|
41712
|
+
</div>
|
|
41713
|
+
</div>
|
|
41714
|
+
|
|
41715
|
+
<!-- Apply Results Modal -->
|
|
41716
|
+
<div class="modal-overlay" id="results-modal">
|
|
41717
|
+
<div class="modal">
|
|
41718
|
+
<h2>Training Applied!</h2>
|
|
41719
|
+
<div id="results-content">
|
|
41720
|
+
<div class="modal-result-item">
|
|
41721
|
+
<span>Connections Created</span>
|
|
41722
|
+
<span class="modal-result-value" id="result-connections">0</span>
|
|
41723
|
+
</div>
|
|
41724
|
+
<div class="modal-result-item">
|
|
41725
|
+
<span>Connections Updated</span>
|
|
41726
|
+
<span class="modal-result-value" id="result-updated">0</span>
|
|
41727
|
+
</div>
|
|
41728
|
+
<div class="modal-result-item">
|
|
41729
|
+
<span>Landmarks Added</span>
|
|
41730
|
+
<span class="modal-result-value" id="result-landmarks">0</span>
|
|
41731
|
+
</div>
|
|
41732
|
+
<div class="modal-result-item">
|
|
41733
|
+
<span>Zones Created</span>
|
|
41734
|
+
<span class="modal-result-value" id="result-zones">0</span>
|
|
41735
|
+
</div>
|
|
41736
|
+
</div>
|
|
41737
|
+
<div class="modal-buttons">
|
|
41738
|
+
<button class="btn btn-secondary" style="flex: 1;" onclick="closeResultsModal()">Close</button>
|
|
41739
|
+
<button class="btn btn-primary" style="flex: 1;" onclick="openEditor()">Open Editor</button>
|
|
41740
|
+
</div>
|
|
41741
|
+
</div>
|
|
41742
|
+
</div>
|
|
41743
|
+
|
|
41744
|
+
<script>
|
|
41745
|
+
let trainingState = 'idle'; // idle, active, paused, completed
|
|
41746
|
+
let session = null;
|
|
41747
|
+
let pollInterval = null;
|
|
41748
|
+
let transitInterval = null;
|
|
41749
|
+
let selectedLandmarkType = 'mailbox';
|
|
41750
|
+
let historyItems = [];
|
|
41751
|
+
|
|
41752
|
+
// Initialize
|
|
41753
|
+
async function init() {
|
|
41754
|
+
// Check if there's an existing session
|
|
41755
|
+
const status = await fetchTrainingStatus();
|
|
41756
|
+
if (status && (status.state === 'active' || status.state === 'paused')) {
|
|
41757
|
+
session = status;
|
|
41758
|
+
trainingState = status.state;
|
|
41759
|
+
updateUI();
|
|
41760
|
+
startPolling();
|
|
41761
|
+
}
|
|
41762
|
+
}
|
|
41763
|
+
|
|
41764
|
+
// API calls
|
|
41765
|
+
async function fetchTrainingStatus() {
|
|
41766
|
+
try {
|
|
41767
|
+
const response = await fetch('../api/training/status');
|
|
41768
|
+
if (response.ok) {
|
|
41769
|
+
return await response.json();
|
|
41770
|
+
}
|
|
41771
|
+
} catch (e) { console.error('Failed to fetch status:', e); }
|
|
41772
|
+
return null;
|
|
41773
|
+
}
|
|
41774
|
+
|
|
41775
|
+
async function startTraining() {
|
|
41776
|
+
try {
|
|
41777
|
+
const response = await fetch('../api/training/start', { method: 'POST' });
|
|
41778
|
+
if (response.ok) {
|
|
41779
|
+
session = await response.json();
|
|
41780
|
+
trainingState = 'active';
|
|
41781
|
+
updateUI();
|
|
41782
|
+
startPolling();
|
|
41783
|
+
addHistoryItem('Training started', 'start');
|
|
41784
|
+
}
|
|
41785
|
+
} catch (e) {
|
|
41786
|
+
console.error('Failed to start training:', e);
|
|
41787
|
+
alert('Failed to start training. Please try again.');
|
|
41788
|
+
}
|
|
41789
|
+
}
|
|
41790
|
+
|
|
41791
|
+
async function togglePause() {
|
|
41792
|
+
const endpoint = trainingState === 'active' ? 'pause' : 'resume';
|
|
41793
|
+
try {
|
|
41794
|
+
const response = await fetch('../api/training/' + endpoint, { method: 'POST' });
|
|
41795
|
+
if (response.ok) {
|
|
41796
|
+
trainingState = trainingState === 'active' ? 'paused' : 'active';
|
|
41797
|
+
updateUI();
|
|
41798
|
+
addHistoryItem('Training ' + (trainingState === 'paused' ? 'paused' : 'resumed'), 'control');
|
|
41799
|
+
}
|
|
41800
|
+
} catch (e) { console.error('Failed to toggle pause:', e); }
|
|
41801
|
+
}
|
|
41802
|
+
|
|
41803
|
+
async function endTraining() {
|
|
41804
|
+
if (!confirm('End training session?')) return;
|
|
41805
|
+
try {
|
|
41806
|
+
const response = await fetch('../api/training/end', { method: 'POST' });
|
|
41807
|
+
if (response.ok) {
|
|
41808
|
+
session = await response.json();
|
|
41809
|
+
trainingState = 'completed';
|
|
41810
|
+
stopPolling();
|
|
41811
|
+
updateUI();
|
|
41812
|
+
}
|
|
41813
|
+
} catch (e) { console.error('Failed to end training:', e); }
|
|
41814
|
+
}
|
|
41815
|
+
|
|
41816
|
+
async function applyTraining() {
|
|
41817
|
+
try {
|
|
41818
|
+
const response = await fetch('../api/training/apply', { method: 'POST' });
|
|
41819
|
+
if (response.ok) {
|
|
41820
|
+
const result = await response.json();
|
|
41821
|
+
document.getElementById('result-connections').textContent = result.connectionsCreated;
|
|
41822
|
+
document.getElementById('result-updated').textContent = result.connectionsUpdated;
|
|
41823
|
+
document.getElementById('result-landmarks').textContent = result.landmarksAdded;
|
|
41824
|
+
document.getElementById('result-zones').textContent = result.zonesCreated;
|
|
41825
|
+
document.getElementById('results-modal').classList.add('active');
|
|
41826
|
+
}
|
|
41827
|
+
} catch (e) {
|
|
41828
|
+
console.error('Failed to apply training:', e);
|
|
41829
|
+
alert('Failed to apply training results.');
|
|
41830
|
+
}
|
|
41831
|
+
}
|
|
41832
|
+
|
|
41833
|
+
function closeResultsModal() {
|
|
41834
|
+
document.getElementById('results-modal').classList.remove('active');
|
|
41835
|
+
}
|
|
41836
|
+
|
|
41837
|
+
function openEditor() {
|
|
41838
|
+
window.location.href = '../ui/editor';
|
|
41839
|
+
}
|
|
41840
|
+
|
|
41841
|
+
function resetTraining() {
|
|
41842
|
+
trainingState = 'idle';
|
|
41843
|
+
session = null;
|
|
41844
|
+
historyItems = [];
|
|
41845
|
+
updateUI();
|
|
41846
|
+
}
|
|
41847
|
+
|
|
41848
|
+
async function markLandmark() {
|
|
41849
|
+
const name = document.getElementById('landmark-name').value.trim();
|
|
41850
|
+
if (!name) {
|
|
41851
|
+
alert('Please enter a landmark name');
|
|
41852
|
+
return;
|
|
41853
|
+
}
|
|
41854
|
+
|
|
41855
|
+
const currentCameraId = session?.currentCamera?.id;
|
|
41856
|
+
const visibleFromCameras = currentCameraId ? [currentCameraId] : [];
|
|
41857
|
+
|
|
41858
|
+
try {
|
|
41859
|
+
const response = await fetch('../api/training/landmark', {
|
|
41860
|
+
method: 'POST',
|
|
41861
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41862
|
+
body: JSON.stringify({
|
|
41863
|
+
name,
|
|
41864
|
+
type: selectedLandmarkType,
|
|
41865
|
+
visibleFromCameras,
|
|
41866
|
+
position: { x: 50, y: 50 }, // Will be refined when applied
|
|
41867
|
+
})
|
|
41868
|
+
});
|
|
41869
|
+
if (response.ok) {
|
|
41870
|
+
document.getElementById('landmark-name').value = '';
|
|
41871
|
+
addHistoryItem('Marked: ' + name + ' (' + selectedLandmarkType + ')', 'landmark');
|
|
41872
|
+
// Refresh status
|
|
41873
|
+
const status = await fetchTrainingStatus();
|
|
41874
|
+
if (status) {
|
|
41875
|
+
session = status;
|
|
41876
|
+
updateStatsUI();
|
|
41877
|
+
}
|
|
41878
|
+
}
|
|
41879
|
+
} catch (e) { console.error('Failed to mark landmark:', e); }
|
|
41880
|
+
}
|
|
41881
|
+
|
|
41882
|
+
function selectLandmarkType(type) {
|
|
41883
|
+
selectedLandmarkType = type;
|
|
41884
|
+
document.querySelectorAll('.mark-type-btn').forEach(btn => {
|
|
41885
|
+
btn.classList.toggle('selected', btn.dataset.type === type);
|
|
41886
|
+
});
|
|
41887
|
+
}
|
|
41888
|
+
|
|
41889
|
+
// Polling
|
|
41890
|
+
function startPolling() {
|
|
41891
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
41892
|
+
pollInterval = setInterval(async () => {
|
|
41893
|
+
const status = await fetchTrainingStatus();
|
|
41894
|
+
if (status) {
|
|
41895
|
+
session = status;
|
|
41896
|
+
updateDetectionUI();
|
|
41897
|
+
updateStatsUI();
|
|
41898
|
+
updateSuggestionsUI();
|
|
41899
|
+
}
|
|
41900
|
+
}, 1000);
|
|
41901
|
+
}
|
|
41902
|
+
|
|
41903
|
+
function stopPolling() {
|
|
41904
|
+
if (pollInterval) {
|
|
41905
|
+
clearInterval(pollInterval);
|
|
41906
|
+
pollInterval = null;
|
|
41907
|
+
}
|
|
41908
|
+
if (transitInterval) {
|
|
41909
|
+
clearInterval(transitInterval);
|
|
41910
|
+
transitInterval = null;
|
|
41911
|
+
}
|
|
41912
|
+
}
|
|
41913
|
+
|
|
41914
|
+
// UI Updates
|
|
41915
|
+
function updateUI() {
|
|
41916
|
+
// Show/hide content sections
|
|
41917
|
+
document.getElementById('idle-content').style.display = trainingState === 'idle' ? 'flex' : 'none';
|
|
41918
|
+
document.getElementById('active-content').style.display = (trainingState === 'active' || trainingState === 'paused') ? 'flex' : 'none';
|
|
41919
|
+
document.getElementById('completed-content').style.display = trainingState === 'completed' ? 'flex' : 'none';
|
|
41920
|
+
|
|
41921
|
+
// Update status badge
|
|
41922
|
+
const badge = document.getElementById('status-badge');
|
|
41923
|
+
badge.textContent = trainingState.charAt(0).toUpperCase() + trainingState.slice(1);
|
|
41924
|
+
badge.className = 'status-badge ' + trainingState;
|
|
41925
|
+
|
|
41926
|
+
// Update pause button
|
|
41927
|
+
const pauseBtn = document.getElementById('pause-btn');
|
|
41928
|
+
if (pauseBtn) {
|
|
41929
|
+
pauseBtn.innerHTML = trainingState === 'paused' ? '▶ Resume' : '⏸ Pause';
|
|
41930
|
+
}
|
|
41931
|
+
|
|
41932
|
+
// Update completed stats
|
|
41933
|
+
if (trainingState === 'completed' && session) {
|
|
41934
|
+
document.getElementById('final-cameras').textContent = session.stats?.camerasVisited || 0;
|
|
41935
|
+
document.getElementById('final-transits').textContent = session.stats?.transitsRecorded || 0;
|
|
41936
|
+
document.getElementById('final-landmarks').textContent = session.stats?.landmarksMarked || 0;
|
|
41937
|
+
document.getElementById('final-overlaps').textContent = session.stats?.overlapsDetected || 0;
|
|
41938
|
+
document.getElementById('final-avg-transit').textContent = (session.stats?.averageTransitTime || 0) + 's';
|
|
41939
|
+
document.getElementById('final-coverage').textContent = (session.stats?.coveragePercentage || 0) + '%';
|
|
41940
|
+
}
|
|
41941
|
+
}
|
|
41942
|
+
|
|
41943
|
+
function updateDetectionUI() {
|
|
41944
|
+
if (!session) return;
|
|
41945
|
+
|
|
41946
|
+
const card = document.getElementById('detection-card');
|
|
41947
|
+
const icon = document.getElementById('detection-icon');
|
|
41948
|
+
const title = document.getElementById('detection-title');
|
|
41949
|
+
const subtitle = document.getElementById('detection-subtitle');
|
|
41950
|
+
const confidence = document.getElementById('detection-confidence');
|
|
41951
|
+
const transitTimer = document.getElementById('transit-timer');
|
|
41952
|
+
|
|
41953
|
+
if (session.currentCamera) {
|
|
41954
|
+
// Detected on a camera
|
|
41955
|
+
card.className = 'detection-card detecting';
|
|
41956
|
+
icon.textContent = '📷';
|
|
41957
|
+
title.textContent = session.currentCamera.name;
|
|
41958
|
+
subtitle.textContent = 'You are visible on this camera';
|
|
41959
|
+
confidence.textContent = 'Confidence: ' + Math.round(session.currentCamera.confidence * 100) + '%';
|
|
41960
|
+
transitTimer.style.display = 'none';
|
|
41961
|
+
|
|
41962
|
+
// Check for new camera detection to add to history
|
|
41963
|
+
const lastHistoryCamera = historyItems.find(h => h.type === 'camera');
|
|
41964
|
+
if (!lastHistoryCamera || lastHistoryCamera.cameraId !== session.currentCamera.id) {
|
|
41965
|
+
addHistoryItem('Detected on: ' + session.currentCamera.name, 'camera', session.currentCamera.id);
|
|
41966
|
+
}
|
|
41967
|
+
} else if (session.activeTransit) {
|
|
41968
|
+
// In transit
|
|
41969
|
+
card.className = 'detection-card in-transit';
|
|
41970
|
+
icon.textContent = '🚶';
|
|
41971
|
+
title.textContent = 'In Transit';
|
|
41972
|
+
subtitle.textContent = 'Walking to next camera...';
|
|
41973
|
+
confidence.textContent = '';
|
|
41974
|
+
transitTimer.style.display = 'flex';
|
|
41975
|
+
document.getElementById('transit-from').textContent = 'from ' + session.activeTransit.fromCameraName;
|
|
41976
|
+
|
|
41977
|
+
// Start transit timer if not already running
|
|
41978
|
+
if (!transitInterval) {
|
|
41979
|
+
transitInterval = setInterval(() => {
|
|
41980
|
+
if (session?.activeTransit) {
|
|
41981
|
+
document.getElementById('transit-time').textContent = session.activeTransit.elapsedSeconds + 's';
|
|
41982
|
+
}
|
|
41983
|
+
}, 1000);
|
|
41984
|
+
}
|
|
41985
|
+
} else {
|
|
41986
|
+
// Waiting
|
|
41987
|
+
card.className = 'detection-card';
|
|
41988
|
+
icon.textContent = '👤';
|
|
41989
|
+
title.textContent = 'Waiting for detection...';
|
|
41990
|
+
subtitle.textContent = 'Walk to any camera to begin';
|
|
41991
|
+
confidence.textContent = '';
|
|
41992
|
+
transitTimer.style.display = 'none';
|
|
41993
|
+
|
|
41994
|
+
if (transitInterval) {
|
|
41995
|
+
clearInterval(transitInterval);
|
|
41996
|
+
transitInterval = null;
|
|
41997
|
+
}
|
|
41998
|
+
}
|
|
41999
|
+
}
|
|
42000
|
+
|
|
42001
|
+
function updateStatsUI() {
|
|
42002
|
+
if (!session?.stats) return;
|
|
42003
|
+
|
|
42004
|
+
document.getElementById('stat-cameras').textContent = session.stats.camerasVisited;
|
|
42005
|
+
document.getElementById('stat-transits').textContent = session.stats.transitsRecorded;
|
|
42006
|
+
document.getElementById('stat-landmarks').textContent = session.stats.landmarksMarked;
|
|
42007
|
+
document.getElementById('progress-percent').textContent = session.stats.coveragePercentage + '%';
|
|
42008
|
+
document.getElementById('progress-fill').style.width = session.stats.coveragePercentage + '%';
|
|
42009
|
+
}
|
|
42010
|
+
|
|
42011
|
+
function updateSuggestionsUI() {
|
|
42012
|
+
if (!session?.suggestions || session.suggestions.length === 0) return;
|
|
42013
|
+
|
|
42014
|
+
const list = document.getElementById('suggestions-list');
|
|
42015
|
+
list.innerHTML = session.suggestions.map(s =>
|
|
42016
|
+
'<div class="suggestion-item">' + s + '</div>'
|
|
42017
|
+
).join('');
|
|
42018
|
+
}
|
|
42019
|
+
|
|
42020
|
+
function addHistoryItem(text, type, cameraId) {
|
|
42021
|
+
const time = new Date().toLocaleTimeString();
|
|
42022
|
+
historyItems.unshift({ text, type, time, cameraId });
|
|
42023
|
+
if (historyItems.length > 50) historyItems.pop();
|
|
42024
|
+
|
|
42025
|
+
const list = document.getElementById('history-list');
|
|
42026
|
+
document.getElementById('history-count').textContent = historyItems.length + ' events';
|
|
42027
|
+
|
|
42028
|
+
list.innerHTML = historyItems.map(item => {
|
|
42029
|
+
let className = '';
|
|
42030
|
+
if (item.type === 'camera') className = 'history-item-camera';
|
|
42031
|
+
if (item.type === 'transit') className = 'history-item-transit';
|
|
42032
|
+
return '<div class="history-item"><span class="' + className + '">' + item.text + '</span>' +
|
|
42033
|
+
'<div class="history-item-time">' + item.time + '</div></div>';
|
|
42034
|
+
}).join('');
|
|
42035
|
+
}
|
|
42036
|
+
|
|
42037
|
+
function switchTab(tabName) {
|
|
42038
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
42039
|
+
tab.classList.toggle('active', tab.textContent.toLowerCase() === tabName);
|
|
42040
|
+
});
|
|
42041
|
+
document.querySelectorAll('.tab-content').forEach(content => {
|
|
42042
|
+
content.classList.toggle('active', content.id === 'tab-' + tabName);
|
|
42043
|
+
});
|
|
42044
|
+
}
|
|
42045
|
+
|
|
42046
|
+
// Initialize on load
|
|
42047
|
+
init();
|
|
42048
|
+
</script>
|
|
42049
|
+
</body>
|
|
42050
|
+
</html>`;
|
|
42051
|
+
|
|
42052
|
+
|
|
39528
42053
|
/***/ },
|
|
39529
42054
|
|
|
39530
42055
|
/***/ "assert"
|