@blueharford/scrypted-spatial-awareness 0.1.16 → 0.2.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 +152 -35
- 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 +1443 -57
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/spatial-reasoning.ts +700 -0
- package/src/core/tracking-engine.ts +137 -53
- package/src/main.ts +266 -3
- package/src/models/alert.ts +21 -1
- package/src/models/topology.ts +382 -9
- package/src/ui/editor-html.ts +328 -6
package/out/main.nodejs.js
CHANGED
|
@@ -35006,6 +35006,575 @@ class ObjectCorrelator {
|
|
|
35006
35006
|
exports.ObjectCorrelator = ObjectCorrelator;
|
|
35007
35007
|
|
|
35008
35008
|
|
|
35009
|
+
/***/ },
|
|
35010
|
+
|
|
35011
|
+
/***/ "./src/core/spatial-reasoning.ts"
|
|
35012
|
+
/*!***************************************!*\
|
|
35013
|
+
!*** ./src/core/spatial-reasoning.ts ***!
|
|
35014
|
+
\***************************************/
|
|
35015
|
+
(__unused_webpack_module, exports, __webpack_require__) {
|
|
35016
|
+
|
|
35017
|
+
"use strict";
|
|
35018
|
+
|
|
35019
|
+
/**
|
|
35020
|
+
* Spatial Reasoning Engine
|
|
35021
|
+
* Uses RAG (Retrieval Augmented Generation) to provide rich contextual understanding
|
|
35022
|
+
* of movement across the property topology
|
|
35023
|
+
*/
|
|
35024
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
35025
|
+
if (k2 === undefined) k2 = k;
|
|
35026
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
35027
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
35028
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
35029
|
+
}
|
|
35030
|
+
Object.defineProperty(o, k2, desc);
|
|
35031
|
+
}) : (function(o, m, k, k2) {
|
|
35032
|
+
if (k2 === undefined) k2 = k;
|
|
35033
|
+
o[k2] = m[k];
|
|
35034
|
+
}));
|
|
35035
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
35036
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35037
|
+
}) : function(o, v) {
|
|
35038
|
+
o["default"] = v;
|
|
35039
|
+
});
|
|
35040
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
35041
|
+
var ownKeys = function(o) {
|
|
35042
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35043
|
+
var ar = [];
|
|
35044
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35045
|
+
return ar;
|
|
35046
|
+
};
|
|
35047
|
+
return ownKeys(o);
|
|
35048
|
+
};
|
|
35049
|
+
return function (mod) {
|
|
35050
|
+
if (mod && mod.__esModule) return mod;
|
|
35051
|
+
var result = {};
|
|
35052
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35053
|
+
__setModuleDefault(result, mod);
|
|
35054
|
+
return result;
|
|
35055
|
+
};
|
|
35056
|
+
})();
|
|
35057
|
+
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
35058
|
+
exports.SpatialReasoningEngine = void 0;
|
|
35059
|
+
const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
35060
|
+
const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
|
|
35061
|
+
const { systemManager } = sdk_1.default;
|
|
35062
|
+
class SpatialReasoningEngine {
|
|
35063
|
+
config;
|
|
35064
|
+
console;
|
|
35065
|
+
topology = null;
|
|
35066
|
+
llmDevice = null;
|
|
35067
|
+
contextChunks = [];
|
|
35068
|
+
topologyContextCache = null;
|
|
35069
|
+
contextCacheTime = 0;
|
|
35070
|
+
landmarkSuggestions = new Map();
|
|
35071
|
+
constructor(config, console) {
|
|
35072
|
+
this.config = config;
|
|
35073
|
+
this.console = console;
|
|
35074
|
+
}
|
|
35075
|
+
/** Update the topology and rebuild context */
|
|
35076
|
+
updateTopology(topology) {
|
|
35077
|
+
this.topology = topology;
|
|
35078
|
+
this.rebuildContextChunks();
|
|
35079
|
+
this.topologyContextCache = null;
|
|
35080
|
+
this.contextCacheTime = 0;
|
|
35081
|
+
}
|
|
35082
|
+
/** Build context chunks for RAG retrieval */
|
|
35083
|
+
rebuildContextChunks() {
|
|
35084
|
+
if (!this.topology)
|
|
35085
|
+
return;
|
|
35086
|
+
this.contextChunks = [];
|
|
35087
|
+
// Property context
|
|
35088
|
+
if (this.topology.property) {
|
|
35089
|
+
this.contextChunks.push({
|
|
35090
|
+
id: 'property',
|
|
35091
|
+
type: 'property',
|
|
35092
|
+
content: this.buildPropertyContext(),
|
|
35093
|
+
metadata: { ...this.topology.property },
|
|
35094
|
+
});
|
|
35095
|
+
}
|
|
35096
|
+
// Camera contexts
|
|
35097
|
+
for (const camera of this.topology.cameras) {
|
|
35098
|
+
this.contextChunks.push({
|
|
35099
|
+
id: `camera_${camera.deviceId}`,
|
|
35100
|
+
type: 'camera',
|
|
35101
|
+
content: this.buildCameraContext(camera),
|
|
35102
|
+
metadata: {
|
|
35103
|
+
deviceId: camera.deviceId,
|
|
35104
|
+
name: camera.name,
|
|
35105
|
+
isEntryPoint: camera.isEntryPoint,
|
|
35106
|
+
isExitPoint: camera.isExitPoint,
|
|
35107
|
+
},
|
|
35108
|
+
});
|
|
35109
|
+
}
|
|
35110
|
+
// Landmark contexts
|
|
35111
|
+
for (const landmark of this.topology.landmarks) {
|
|
35112
|
+
this.contextChunks.push({
|
|
35113
|
+
id: `landmark_${landmark.id}`,
|
|
35114
|
+
type: 'landmark',
|
|
35115
|
+
content: this.buildLandmarkContext(landmark),
|
|
35116
|
+
metadata: {
|
|
35117
|
+
id: landmark.id,
|
|
35118
|
+
name: landmark.name,
|
|
35119
|
+
type: landmark.type,
|
|
35120
|
+
isEntryPoint: landmark.isEntryPoint,
|
|
35121
|
+
isExitPoint: landmark.isExitPoint,
|
|
35122
|
+
},
|
|
35123
|
+
});
|
|
35124
|
+
}
|
|
35125
|
+
// Connection contexts
|
|
35126
|
+
for (const connection of this.topology.connections) {
|
|
35127
|
+
this.contextChunks.push({
|
|
35128
|
+
id: `connection_${connection.id}`,
|
|
35129
|
+
type: 'connection',
|
|
35130
|
+
content: this.buildConnectionContext(connection),
|
|
35131
|
+
metadata: {
|
|
35132
|
+
id: connection.id,
|
|
35133
|
+
fromCameraId: connection.fromCameraId,
|
|
35134
|
+
toCameraId: connection.toCameraId,
|
|
35135
|
+
},
|
|
35136
|
+
});
|
|
35137
|
+
}
|
|
35138
|
+
this.console.log(`Built ${this.contextChunks.length} context chunks for spatial reasoning`);
|
|
35139
|
+
}
|
|
35140
|
+
/** Build property context string */
|
|
35141
|
+
buildPropertyContext() {
|
|
35142
|
+
if (!this.topology?.property)
|
|
35143
|
+
return '';
|
|
35144
|
+
const p = this.topology.property;
|
|
35145
|
+
const parts = [];
|
|
35146
|
+
if (p.propertyType)
|
|
35147
|
+
parts.push(`Property type: ${p.propertyType}`);
|
|
35148
|
+
if (p.description)
|
|
35149
|
+
parts.push(p.description);
|
|
35150
|
+
if (p.frontFacing)
|
|
35151
|
+
parts.push(`Front faces ${p.frontFacing}`);
|
|
35152
|
+
if (p.features?.length)
|
|
35153
|
+
parts.push(`Features: ${p.features.join(', ')}`);
|
|
35154
|
+
return parts.join('. ');
|
|
35155
|
+
}
|
|
35156
|
+
/** Build camera context string */
|
|
35157
|
+
buildCameraContext(camera) {
|
|
35158
|
+
const parts = [`Camera: ${camera.name}`];
|
|
35159
|
+
if (camera.context?.mountLocation) {
|
|
35160
|
+
parts.push(`Mounted at: ${camera.context.mountLocation}`);
|
|
35161
|
+
}
|
|
35162
|
+
if (camera.context?.coverageDescription) {
|
|
35163
|
+
parts.push(`Coverage: ${camera.context.coverageDescription}`);
|
|
35164
|
+
}
|
|
35165
|
+
if (camera.context?.mountHeight) {
|
|
35166
|
+
parts.push(`Height: ${camera.context.mountHeight} feet`);
|
|
35167
|
+
}
|
|
35168
|
+
if (camera.isEntryPoint)
|
|
35169
|
+
parts.push('Watches property entry point');
|
|
35170
|
+
if (camera.isExitPoint)
|
|
35171
|
+
parts.push('Watches property exit point');
|
|
35172
|
+
// Visible landmarks
|
|
35173
|
+
if (this.topology && camera.context?.visibleLandmarks?.length) {
|
|
35174
|
+
const landmarkNames = camera.context.visibleLandmarks
|
|
35175
|
+
.map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
|
|
35176
|
+
.filter(Boolean);
|
|
35177
|
+
if (landmarkNames.length) {
|
|
35178
|
+
parts.push(`Can see: ${landmarkNames.join(', ')}`);
|
|
35179
|
+
}
|
|
35180
|
+
}
|
|
35181
|
+
return parts.join('. ');
|
|
35182
|
+
}
|
|
35183
|
+
/** Build landmark context string */
|
|
35184
|
+
buildLandmarkContext(landmark) {
|
|
35185
|
+
const parts = [`${landmark.name} (${landmark.type})`];
|
|
35186
|
+
if (landmark.description)
|
|
35187
|
+
parts.push(landmark.description);
|
|
35188
|
+
if (landmark.isEntryPoint)
|
|
35189
|
+
parts.push('Property entry point');
|
|
35190
|
+
if (landmark.isExitPoint)
|
|
35191
|
+
parts.push('Property exit point');
|
|
35192
|
+
// Adjacent landmarks
|
|
35193
|
+
if (this.topology && landmark.adjacentTo?.length) {
|
|
35194
|
+
const adjacentNames = landmark.adjacentTo
|
|
35195
|
+
.map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
|
|
35196
|
+
.filter(Boolean);
|
|
35197
|
+
if (adjacentNames.length) {
|
|
35198
|
+
parts.push(`Adjacent to: ${adjacentNames.join(', ')}`);
|
|
35199
|
+
}
|
|
35200
|
+
}
|
|
35201
|
+
return parts.join('. ');
|
|
35202
|
+
}
|
|
35203
|
+
/** Build connection context string */
|
|
35204
|
+
buildConnectionContext(connection) {
|
|
35205
|
+
if (!this.topology)
|
|
35206
|
+
return '';
|
|
35207
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, connection.fromCameraId);
|
|
35208
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, connection.toCameraId);
|
|
35209
|
+
if (!fromCamera || !toCamera)
|
|
35210
|
+
return '';
|
|
35211
|
+
const parts = [
|
|
35212
|
+
`Path from ${fromCamera.name} to ${toCamera.name}`,
|
|
35213
|
+
];
|
|
35214
|
+
if (connection.name)
|
|
35215
|
+
parts.push(`Called: ${connection.name}`);
|
|
35216
|
+
const transitSecs = Math.round(connection.transitTime.typical / 1000);
|
|
35217
|
+
parts.push(`Typical transit: ${transitSecs} seconds`);
|
|
35218
|
+
if (connection.bidirectional)
|
|
35219
|
+
parts.push('Bidirectional path');
|
|
35220
|
+
// Path landmarks
|
|
35221
|
+
if (connection.pathLandmarks?.length) {
|
|
35222
|
+
const landmarkNames = connection.pathLandmarks
|
|
35223
|
+
.map((id) => (0, topology_1.findLandmark)(this.topology, id)?.name)
|
|
35224
|
+
.filter(Boolean);
|
|
35225
|
+
if (landmarkNames.length) {
|
|
35226
|
+
parts.push(`Passes: ${landmarkNames.join(' → ')}`);
|
|
35227
|
+
}
|
|
35228
|
+
}
|
|
35229
|
+
return parts.join('. ');
|
|
35230
|
+
}
|
|
35231
|
+
/** Get cached or generate topology description */
|
|
35232
|
+
getTopologyContext() {
|
|
35233
|
+
const now = Date.now();
|
|
35234
|
+
if (this.topologyContextCache && (now - this.contextCacheTime) < this.config.contextCacheTtl) {
|
|
35235
|
+
return this.topologyContextCache;
|
|
35236
|
+
}
|
|
35237
|
+
if (!this.topology)
|
|
35238
|
+
return '';
|
|
35239
|
+
this.topologyContextCache = (0, topology_1.generateTopologyDescription)(this.topology);
|
|
35240
|
+
this.contextCacheTime = now;
|
|
35241
|
+
return this.topologyContextCache;
|
|
35242
|
+
}
|
|
35243
|
+
/** Retrieve relevant context chunks for a movement query */
|
|
35244
|
+
retrieveRelevantContext(fromCameraId, toCameraId) {
|
|
35245
|
+
const relevant = [];
|
|
35246
|
+
// Always include property context
|
|
35247
|
+
const propertyChunk = this.contextChunks.find(c => c.type === 'property');
|
|
35248
|
+
if (propertyChunk)
|
|
35249
|
+
relevant.push(propertyChunk);
|
|
35250
|
+
// Include both camera contexts
|
|
35251
|
+
const fromChunk = this.contextChunks.find(c => c.id === `camera_${fromCameraId}`);
|
|
35252
|
+
const toChunk = this.contextChunks.find(c => c.id === `camera_${toCameraId}`);
|
|
35253
|
+
if (fromChunk)
|
|
35254
|
+
relevant.push(fromChunk);
|
|
35255
|
+
if (toChunk)
|
|
35256
|
+
relevant.push(toChunk);
|
|
35257
|
+
// Include direct connection if exists
|
|
35258
|
+
const connectionChunk = this.contextChunks.find(c => c.type === 'connection' &&
|
|
35259
|
+
((c.metadata.fromCameraId === fromCameraId && c.metadata.toCameraId === toCameraId) ||
|
|
35260
|
+
(c.metadata.fromCameraId === toCameraId && c.metadata.toCameraId === fromCameraId)));
|
|
35261
|
+
if (connectionChunk)
|
|
35262
|
+
relevant.push(connectionChunk);
|
|
35263
|
+
// Include visible landmarks from both cameras
|
|
35264
|
+
if (this.topology) {
|
|
35265
|
+
const fromLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, fromCameraId);
|
|
35266
|
+
const toLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, toCameraId);
|
|
35267
|
+
const allLandmarkIds = new Set([
|
|
35268
|
+
...fromLandmarks.map(l => l.id),
|
|
35269
|
+
...toLandmarks.map(l => l.id),
|
|
35270
|
+
]);
|
|
35271
|
+
for (const landmarkId of allLandmarkIds) {
|
|
35272
|
+
const chunk = this.contextChunks.find(c => c.id === `landmark_${landmarkId}`);
|
|
35273
|
+
if (chunk)
|
|
35274
|
+
relevant.push(chunk);
|
|
35275
|
+
}
|
|
35276
|
+
}
|
|
35277
|
+
return relevant;
|
|
35278
|
+
}
|
|
35279
|
+
/** Find or initialize LLM device */
|
|
35280
|
+
async findLlmDevice() {
|
|
35281
|
+
if (this.llmDevice)
|
|
35282
|
+
return this.llmDevice;
|
|
35283
|
+
try {
|
|
35284
|
+
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
35285
|
+
const device = systemManager.getDeviceById(id);
|
|
35286
|
+
if (device?.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetection)) {
|
|
35287
|
+
const name = device.name?.toLowerCase() || '';
|
|
35288
|
+
if (name.includes('llm') || name.includes('gpt') || name.includes('claude') ||
|
|
35289
|
+
name.includes('ollama') || name.includes('gemini')) {
|
|
35290
|
+
this.llmDevice = device;
|
|
35291
|
+
this.console.log(`Found LLM device: ${device.name}`);
|
|
35292
|
+
return this.llmDevice;
|
|
35293
|
+
}
|
|
35294
|
+
}
|
|
35295
|
+
}
|
|
35296
|
+
}
|
|
35297
|
+
catch (e) {
|
|
35298
|
+
this.console.warn('Error finding LLM device:', e);
|
|
35299
|
+
}
|
|
35300
|
+
return null;
|
|
35301
|
+
}
|
|
35302
|
+
/** Generate rich movement description using LLM */
|
|
35303
|
+
async generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject) {
|
|
35304
|
+
if (!this.topology) {
|
|
35305
|
+
return {
|
|
35306
|
+
description: `${tracked.className} moving between cameras`,
|
|
35307
|
+
involvedLandmarks: [],
|
|
35308
|
+
confidence: 0.5,
|
|
35309
|
+
usedLlm: false,
|
|
35310
|
+
};
|
|
35311
|
+
}
|
|
35312
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
|
|
35313
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
|
|
35314
|
+
if (!fromCamera || !toCamera) {
|
|
35315
|
+
return {
|
|
35316
|
+
description: `${tracked.className} moving between cameras`,
|
|
35317
|
+
involvedLandmarks: [],
|
|
35318
|
+
confidence: 0.5,
|
|
35319
|
+
usedLlm: false,
|
|
35320
|
+
};
|
|
35321
|
+
}
|
|
35322
|
+
// Get involved landmarks
|
|
35323
|
+
const fromLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, fromCameraId);
|
|
35324
|
+
const toLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, toCameraId);
|
|
35325
|
+
const allLandmarks = [...new Set([...fromLandmarks, ...toLandmarks])];
|
|
35326
|
+
// Build basic description without LLM
|
|
35327
|
+
let basicDescription = this.buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks);
|
|
35328
|
+
// Try LLM for enhanced description
|
|
35329
|
+
if (this.config.enableLlm && mediaObject) {
|
|
35330
|
+
const llmDescription = await this.getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject);
|
|
35331
|
+
if (llmDescription) {
|
|
35332
|
+
return {
|
|
35333
|
+
description: llmDescription,
|
|
35334
|
+
involvedLandmarks: allLandmarks,
|
|
35335
|
+
pathDescription: this.buildPathDescription(fromCamera, toCamera),
|
|
35336
|
+
confidence: 0.9,
|
|
35337
|
+
usedLlm: true,
|
|
35338
|
+
};
|
|
35339
|
+
}
|
|
35340
|
+
}
|
|
35341
|
+
return {
|
|
35342
|
+
description: basicDescription,
|
|
35343
|
+
involvedLandmarks: allLandmarks,
|
|
35344
|
+
pathDescription: this.buildPathDescription(fromCamera, toCamera),
|
|
35345
|
+
confidence: 0.7,
|
|
35346
|
+
usedLlm: false,
|
|
35347
|
+
};
|
|
35348
|
+
}
|
|
35349
|
+
/** Build basic movement description without LLM */
|
|
35350
|
+
buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks) {
|
|
35351
|
+
const objectType = this.capitalizeFirst(tracked.className);
|
|
35352
|
+
const transitSecs = Math.round(transitTime / 1000);
|
|
35353
|
+
// Build origin description
|
|
35354
|
+
let origin = fromCamera.name;
|
|
35355
|
+
if (fromLandmarks.length > 0) {
|
|
35356
|
+
const nearLandmark = fromLandmarks[0];
|
|
35357
|
+
origin = `near ${nearLandmark.name}`;
|
|
35358
|
+
}
|
|
35359
|
+
else if (fromCamera.context?.coverageDescription) {
|
|
35360
|
+
origin = fromCamera.context.coverageDescription.split('.')[0];
|
|
35361
|
+
}
|
|
35362
|
+
// Build destination description
|
|
35363
|
+
let destination = toCamera.name;
|
|
35364
|
+
if (toLandmarks.length > 0) {
|
|
35365
|
+
const nearLandmark = toLandmarks[0];
|
|
35366
|
+
destination = `towards ${nearLandmark.name}`;
|
|
35367
|
+
}
|
|
35368
|
+
else if (toCamera.context?.coverageDescription) {
|
|
35369
|
+
destination = `towards ${toCamera.context.coverageDescription.split('.')[0]}`;
|
|
35370
|
+
}
|
|
35371
|
+
// Build transit string
|
|
35372
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
35373
|
+
return `${objectType} moving from ${origin} ${destination}${transitStr}`;
|
|
35374
|
+
}
|
|
35375
|
+
/** Build path description from connection */
|
|
35376
|
+
buildPathDescription(fromCamera, toCamera) {
|
|
35377
|
+
if (!this.topology)
|
|
35378
|
+
return undefined;
|
|
35379
|
+
const connection = (0, topology_1.findConnection)(this.topology, fromCamera.deviceId, toCamera.deviceId);
|
|
35380
|
+
if (!connection)
|
|
35381
|
+
return undefined;
|
|
35382
|
+
if (connection.pathLandmarks?.length) {
|
|
35383
|
+
const landmarkNames = connection.pathLandmarks
|
|
35384
|
+
.map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
|
|
35385
|
+
.filter(Boolean);
|
|
35386
|
+
if (landmarkNames.length) {
|
|
35387
|
+
return `Via ${landmarkNames.join(' → ')}`;
|
|
35388
|
+
}
|
|
35389
|
+
}
|
|
35390
|
+
return connection.name || undefined;
|
|
35391
|
+
}
|
|
35392
|
+
/** Get LLM-enhanced description */
|
|
35393
|
+
async getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject) {
|
|
35394
|
+
const llm = await this.findLlmDevice();
|
|
35395
|
+
if (!llm)
|
|
35396
|
+
return null;
|
|
35397
|
+
try {
|
|
35398
|
+
// Retrieve relevant context for RAG
|
|
35399
|
+
const relevantChunks = this.retrieveRelevantContext(fromCamera.deviceId, toCamera.deviceId);
|
|
35400
|
+
// Build RAG context
|
|
35401
|
+
const ragContext = relevantChunks.map(c => c.content).join('\n\n');
|
|
35402
|
+
// Build the prompt
|
|
35403
|
+
const prompt = this.buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext);
|
|
35404
|
+
// Call LLM
|
|
35405
|
+
const result = await llm.detectObjects(mediaObject, {
|
|
35406
|
+
settings: { prompt }
|
|
35407
|
+
});
|
|
35408
|
+
// Extract description from result
|
|
35409
|
+
if (result.detections?.[0]?.label) {
|
|
35410
|
+
return result.detections[0].label;
|
|
35411
|
+
}
|
|
35412
|
+
return null;
|
|
35413
|
+
}
|
|
35414
|
+
catch (e) {
|
|
35415
|
+
this.console.warn('LLM description generation failed:', e);
|
|
35416
|
+
return null;
|
|
35417
|
+
}
|
|
35418
|
+
}
|
|
35419
|
+
/** Build LLM prompt with RAG context */
|
|
35420
|
+
buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext) {
|
|
35421
|
+
const transitSecs = Math.round(transitTime / 1000);
|
|
35422
|
+
return `You are a security camera system describing movement on a property.
|
|
35423
|
+
|
|
35424
|
+
PROPERTY CONTEXT:
|
|
35425
|
+
${ragContext}
|
|
35426
|
+
|
|
35427
|
+
CURRENT EVENT:
|
|
35428
|
+
- Object type: ${tracked.className}
|
|
35429
|
+
- Moving from: ${fromCamera.name}${fromLandmarks.length ? ` (near ${fromLandmarks.map(l => l.name).join(', ')})` : ''}
|
|
35430
|
+
- Moving to: ${toCamera.name}${toLandmarks.length ? ` (near ${toLandmarks.map(l => l.name).join(', ')})` : ''}
|
|
35431
|
+
- Transit time: ${transitSecs} seconds
|
|
35432
|
+
|
|
35433
|
+
INSTRUCTIONS:
|
|
35434
|
+
Generate a single, concise sentence describing this movement. Include:
|
|
35435
|
+
1. Description of the ${tracked.className} (if person: gender, clothing; if vehicle: color, type)
|
|
35436
|
+
2. Where they came from (using landmark names if available)
|
|
35437
|
+
3. Where they're heading (using landmark names if available)
|
|
35438
|
+
|
|
35439
|
+
Examples of good descriptions:
|
|
35440
|
+
- "Man in blue jacket walking from the driveway towards the front door"
|
|
35441
|
+
- "Black SUV pulling into the driveway from the street"
|
|
35442
|
+
- "Woman with dog walking from the backyard towards the side gate"
|
|
35443
|
+
- "Delivery person approaching the front porch from the mailbox"
|
|
35444
|
+
|
|
35445
|
+
Generate ONLY the description, nothing else:`;
|
|
35446
|
+
}
|
|
35447
|
+
/** Suggest a new landmark based on AI analysis */
|
|
35448
|
+
async suggestLandmark(cameraId, mediaObject, objectClass, position) {
|
|
35449
|
+
if (!this.config.enableLandmarkLearning)
|
|
35450
|
+
return null;
|
|
35451
|
+
const llm = await this.findLlmDevice();
|
|
35452
|
+
if (!llm)
|
|
35453
|
+
return null;
|
|
35454
|
+
try {
|
|
35455
|
+
const prompt = `Analyze this security camera image. A ${objectClass} was detected.
|
|
35456
|
+
|
|
35457
|
+
Looking at the surroundings and environment, identify any notable landmarks or features visible that could help describe this location. Consider:
|
|
35458
|
+
- Structures (house, garage, shed, porch)
|
|
35459
|
+
- Features (mailbox, tree, pool, garden)
|
|
35460
|
+
- Access points (driveway, walkway, gate, door)
|
|
35461
|
+
- Boundaries (fence, wall, hedge)
|
|
35462
|
+
|
|
35463
|
+
If you can identify a clear landmark feature, respond with ONLY a JSON object:
|
|
35464
|
+
{"name": "Landmark Name", "type": "structure|feature|boundary|access|vehicle|neighbor|zone|street", "description": "Brief description"}
|
|
35465
|
+
|
|
35466
|
+
If no clear landmark is identifiable, respond with: {"name": null}`;
|
|
35467
|
+
const result = await llm.detectObjects(mediaObject, {
|
|
35468
|
+
settings: { prompt }
|
|
35469
|
+
});
|
|
35470
|
+
if (result.detections?.[0]?.label) {
|
|
35471
|
+
try {
|
|
35472
|
+
const parsed = JSON.parse(result.detections[0].label);
|
|
35473
|
+
if (parsed.name && parsed.type) {
|
|
35474
|
+
const suggestionId = `suggest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
35475
|
+
const suggestion = {
|
|
35476
|
+
id: suggestionId,
|
|
35477
|
+
landmark: {
|
|
35478
|
+
id: `landmark_${Date.now()}`,
|
|
35479
|
+
name: parsed.name,
|
|
35480
|
+
type: parsed.type,
|
|
35481
|
+
position,
|
|
35482
|
+
description: parsed.description,
|
|
35483
|
+
aiSuggested: true,
|
|
35484
|
+
aiConfidence: 0.7,
|
|
35485
|
+
visibleFromCameras: [cameraId],
|
|
35486
|
+
},
|
|
35487
|
+
detectedByCameras: [cameraId],
|
|
35488
|
+
timestamp: Date.now(),
|
|
35489
|
+
detectionCount: 1,
|
|
35490
|
+
status: 'pending',
|
|
35491
|
+
};
|
|
35492
|
+
// Store suggestion
|
|
35493
|
+
const existingKey = this.findSimilarSuggestion(parsed.name, position);
|
|
35494
|
+
if (existingKey) {
|
|
35495
|
+
// Increment count for similar suggestion
|
|
35496
|
+
const existing = this.landmarkSuggestions.get(existingKey);
|
|
35497
|
+
existing.detectionCount++;
|
|
35498
|
+
existing.landmark.aiConfidence = Math.min(0.95, existing.landmark.aiConfidence + 0.05);
|
|
35499
|
+
if (!existing.detectedByCameras.includes(cameraId)) {
|
|
35500
|
+
existing.detectedByCameras.push(cameraId);
|
|
35501
|
+
}
|
|
35502
|
+
return existing;
|
|
35503
|
+
}
|
|
35504
|
+
else {
|
|
35505
|
+
this.landmarkSuggestions.set(suggestionId, suggestion);
|
|
35506
|
+
return suggestion;
|
|
35507
|
+
}
|
|
35508
|
+
}
|
|
35509
|
+
}
|
|
35510
|
+
catch (parseError) {
|
|
35511
|
+
// LLM didn't return valid JSON
|
|
35512
|
+
}
|
|
35513
|
+
}
|
|
35514
|
+
return null;
|
|
35515
|
+
}
|
|
35516
|
+
catch (e) {
|
|
35517
|
+
this.console.warn('Landmark suggestion failed:', e);
|
|
35518
|
+
return null;
|
|
35519
|
+
}
|
|
35520
|
+
}
|
|
35521
|
+
/** Find similar existing suggestion by name proximity and position */
|
|
35522
|
+
findSimilarSuggestion(name, position) {
|
|
35523
|
+
const nameLower = name.toLowerCase();
|
|
35524
|
+
const POSITION_THRESHOLD = 100; // pixels
|
|
35525
|
+
for (const [key, suggestion] of this.landmarkSuggestions) {
|
|
35526
|
+
if (suggestion.status !== 'pending')
|
|
35527
|
+
continue;
|
|
35528
|
+
const suggestionName = suggestion.landmark.name.toLowerCase();
|
|
35529
|
+
const distance = Math.sqrt(Math.pow(suggestion.landmark.position.x - position.x, 2) +
|
|
35530
|
+
Math.pow(suggestion.landmark.position.y - position.y, 2));
|
|
35531
|
+
// Similar name and nearby position
|
|
35532
|
+
if ((suggestionName.includes(nameLower) || nameLower.includes(suggestionName)) &&
|
|
35533
|
+
distance < POSITION_THRESHOLD) {
|
|
35534
|
+
return key;
|
|
35535
|
+
}
|
|
35536
|
+
}
|
|
35537
|
+
return null;
|
|
35538
|
+
}
|
|
35539
|
+
/** Get pending landmark suggestions above confidence threshold */
|
|
35540
|
+
getPendingSuggestions() {
|
|
35541
|
+
return Array.from(this.landmarkSuggestions.values())
|
|
35542
|
+
.filter(s => s.status === 'pending' &&
|
|
35543
|
+
s.landmark.aiConfidence >= this.config.landmarkConfidenceThreshold)
|
|
35544
|
+
.sort((a, b) => b.detectionCount - a.detectionCount);
|
|
35545
|
+
}
|
|
35546
|
+
/** Accept a landmark suggestion */
|
|
35547
|
+
acceptSuggestion(suggestionId) {
|
|
35548
|
+
const suggestion = this.landmarkSuggestions.get(suggestionId);
|
|
35549
|
+
if (!suggestion)
|
|
35550
|
+
return null;
|
|
35551
|
+
suggestion.status = 'accepted';
|
|
35552
|
+
const landmark = { ...suggestion.landmark };
|
|
35553
|
+
landmark.aiSuggested = false; // Mark as confirmed
|
|
35554
|
+
this.landmarkSuggestions.delete(suggestionId);
|
|
35555
|
+
return landmark;
|
|
35556
|
+
}
|
|
35557
|
+
/** Reject a landmark suggestion */
|
|
35558
|
+
rejectSuggestion(suggestionId) {
|
|
35559
|
+
const suggestion = this.landmarkSuggestions.get(suggestionId);
|
|
35560
|
+
if (!suggestion)
|
|
35561
|
+
return false;
|
|
35562
|
+
suggestion.status = 'rejected';
|
|
35563
|
+
this.landmarkSuggestions.delete(suggestionId);
|
|
35564
|
+
return true;
|
|
35565
|
+
}
|
|
35566
|
+
/** Utility to capitalize first letter */
|
|
35567
|
+
capitalizeFirst(str) {
|
|
35568
|
+
return str ? str.charAt(0).toUpperCase() + str.slice(1) : 'Object';
|
|
35569
|
+
}
|
|
35570
|
+
/** Get landmark templates for UI */
|
|
35571
|
+
getLandmarkTemplates() {
|
|
35572
|
+
return topology_1.LANDMARK_TEMPLATES;
|
|
35573
|
+
}
|
|
35574
|
+
}
|
|
35575
|
+
exports.SpatialReasoningEngine = SpatialReasoningEngine;
|
|
35576
|
+
|
|
35577
|
+
|
|
35009
35578
|
/***/ },
|
|
35010
35579
|
|
|
35011
35580
|
/***/ "./src/core/tracking-engine.ts"
|
|
@@ -35059,6 +35628,7 @@ const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modu
|
|
|
35059
35628
|
const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
|
|
35060
35629
|
const tracked_object_1 = __webpack_require__(/*! ../models/tracked-object */ "./src/models/tracked-object.ts");
|
|
35061
35630
|
const object_correlator_1 = __webpack_require__(/*! ./object-correlator */ "./src/core/object-correlator.ts");
|
|
35631
|
+
const spatial_reasoning_1 = __webpack_require__(/*! ./spatial-reasoning */ "./src/core/spatial-reasoning.ts");
|
|
35062
35632
|
const { systemManager } = sdk_1.default;
|
|
35063
35633
|
class TrackingEngine {
|
|
35064
35634
|
topology;
|
|
@@ -35067,13 +35637,14 @@ class TrackingEngine {
|
|
|
35067
35637
|
config;
|
|
35068
35638
|
console;
|
|
35069
35639
|
correlator;
|
|
35640
|
+
spatialReasoning;
|
|
35070
35641
|
listeners = new Map();
|
|
35071
35642
|
pendingTimers = new Map();
|
|
35072
35643
|
lostCheckInterval = null;
|
|
35073
35644
|
/** Track last alert time per object to enforce cooldown */
|
|
35074
35645
|
objectLastAlertTime = new Map();
|
|
35075
|
-
/**
|
|
35076
|
-
|
|
35646
|
+
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
35647
|
+
onTopologyChange;
|
|
35077
35648
|
constructor(topology, state, alertManager, config, console) {
|
|
35078
35649
|
this.topology = topology;
|
|
35079
35650
|
this.state = state;
|
|
@@ -35081,6 +35652,19 @@ class TrackingEngine {
|
|
|
35081
35652
|
this.config = config;
|
|
35082
35653
|
this.console = console;
|
|
35083
35654
|
this.correlator = new object_correlator_1.ObjectCorrelator(topology, config);
|
|
35655
|
+
// Initialize spatial reasoning engine
|
|
35656
|
+
const spatialConfig = {
|
|
35657
|
+
enableLlm: config.useLlmDescriptions,
|
|
35658
|
+
enableLandmarkLearning: config.enableLandmarkLearning ?? true,
|
|
35659
|
+
landmarkConfidenceThreshold: config.landmarkConfidenceThreshold ?? 0.7,
|
|
35660
|
+
contextCacheTtl: 60000, // 1 minute cache
|
|
35661
|
+
};
|
|
35662
|
+
this.spatialReasoning = new spatial_reasoning_1.SpatialReasoningEngine(spatialConfig, console);
|
|
35663
|
+
this.spatialReasoning.updateTopology(topology);
|
|
35664
|
+
}
|
|
35665
|
+
/** Set callback for topology changes */
|
|
35666
|
+
setTopologyChangeCallback(callback) {
|
|
35667
|
+
this.onTopologyChange = callback;
|
|
35084
35668
|
}
|
|
35085
35669
|
/** Start listening to all cameras in topology */
|
|
35086
35670
|
async startTracking() {
|
|
@@ -35192,52 +35776,45 @@ class TrackingEngine {
|
|
|
35192
35776
|
recordAlertTime(globalId) {
|
|
35193
35777
|
this.objectLastAlertTime.set(globalId, Date.now());
|
|
35194
35778
|
}
|
|
35195
|
-
/**
|
|
35196
|
-
async
|
|
35197
|
-
if (!this.config.useLlmDescriptions)
|
|
35198
|
-
return null;
|
|
35779
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) */
|
|
35780
|
+
async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
|
|
35199
35781
|
try {
|
|
35200
|
-
//
|
|
35201
|
-
|
|
35202
|
-
|
|
35203
|
-
|
|
35204
|
-
|
|
35205
|
-
|
|
35206
|
-
this.llmDevice = device;
|
|
35207
|
-
this.console.log(`Found LLM device: ${device.name}`);
|
|
35208
|
-
break;
|
|
35209
|
-
}
|
|
35782
|
+
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
35783
|
+
let mediaObject;
|
|
35784
|
+
if (this.config.useLlmDescriptions) {
|
|
35785
|
+
const camera = systemManager.getDeviceById(currentCameraId);
|
|
35786
|
+
if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
|
|
35787
|
+
mediaObject = await camera.takePicture();
|
|
35210
35788
|
}
|
|
35211
35789
|
}
|
|
35212
|
-
|
|
35213
|
-
|
|
35214
|
-
//
|
|
35215
|
-
|
|
35216
|
-
|
|
35217
|
-
return null;
|
|
35218
|
-
const picture = await camera.takePicture();
|
|
35219
|
-
if (!picture)
|
|
35220
|
-
return null;
|
|
35221
|
-
// Ask LLM to describe the movement
|
|
35222
|
-
const prompt = `Describe this ${tracked.className} in one short sentence. ` +
|
|
35223
|
-
`They are moving from the ${fromCamera} area towards the ${toCamera}. ` +
|
|
35224
|
-
`Include details like: gender (man/woman), clothing color, vehicle color/type if applicable. ` +
|
|
35225
|
-
`Example: "Man in blue jacket walking from garage towards front door" or ` +
|
|
35226
|
-
`"Black SUV driving from driveway towards street"`;
|
|
35227
|
-
const result = await this.llmDevice.detectObjects(picture, {
|
|
35228
|
-
settings: { prompt }
|
|
35229
|
-
});
|
|
35230
|
-
// Extract description from LLM response
|
|
35231
|
-
if (result.detections?.[0]?.label) {
|
|
35232
|
-
return result.detections[0].label;
|
|
35790
|
+
// Use spatial reasoning engine for rich context-aware description
|
|
35791
|
+
const result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
|
|
35792
|
+
// Optionally trigger landmark learning
|
|
35793
|
+
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
35794
|
+
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
35233
35795
|
}
|
|
35234
|
-
return
|
|
35796
|
+
return result;
|
|
35235
35797
|
}
|
|
35236
35798
|
catch (e) {
|
|
35237
|
-
this.console.warn('
|
|
35799
|
+
this.console.warn('Spatial reasoning failed:', e);
|
|
35238
35800
|
return null;
|
|
35239
35801
|
}
|
|
35240
35802
|
}
|
|
35803
|
+
/** Try to learn new landmarks from detections (background task) */
|
|
35804
|
+
async tryLearnLandmark(cameraId, mediaObject, objectClass) {
|
|
35805
|
+
try {
|
|
35806
|
+
// Position is approximate - could be improved with object position from detection
|
|
35807
|
+
const position = { x: 50, y: 50 };
|
|
35808
|
+
const suggestion = await this.spatialReasoning.suggestLandmark(cameraId, mediaObject, objectClass, position);
|
|
35809
|
+
if (suggestion) {
|
|
35810
|
+
this.console.log(`AI suggested landmark: ${suggestion.landmark.name} ` +
|
|
35811
|
+
`(${suggestion.landmark.type}, confidence: ${suggestion.landmark.aiConfidence?.toFixed(2)})`);
|
|
35812
|
+
}
|
|
35813
|
+
}
|
|
35814
|
+
catch (e) {
|
|
35815
|
+
// Landmark learning is best-effort, don't log errors
|
|
35816
|
+
}
|
|
35817
|
+
}
|
|
35241
35818
|
/** Process a single sighting */
|
|
35242
35819
|
async processSighting(sighting, isEntryPoint, isExitPoint) {
|
|
35243
35820
|
// Try to correlate with existing tracked objects
|
|
@@ -35265,8 +35842,8 @@ class TrackingEngine {
|
|
|
35265
35842
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
35266
35843
|
// Check loitering threshold and per-object cooldown before alerting
|
|
35267
35844
|
if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
|
|
35268
|
-
//
|
|
35269
|
-
const
|
|
35845
|
+
// Get spatial reasoning result with RAG context
|
|
35846
|
+
const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
|
|
35270
35847
|
// Generate movement alert for cross-camera transition
|
|
35271
35848
|
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
35272
35849
|
fromCameraId: lastSighting.cameraId,
|
|
@@ -35275,8 +35852,12 @@ class TrackingEngine {
|
|
|
35275
35852
|
toCameraName: sighting.cameraName,
|
|
35276
35853
|
transitTime: transitDuration,
|
|
35277
35854
|
objectClass: sighting.detection.className,
|
|
35278
|
-
objectLabel:
|
|
35855
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
35279
35856
|
detectionId: sighting.detectionId,
|
|
35857
|
+
// Include spatial context for enriched alerts
|
|
35858
|
+
pathDescription: spatialResult?.pathDescription,
|
|
35859
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
35860
|
+
usedLlm: spatialResult?.usedLlm,
|
|
35280
35861
|
});
|
|
35281
35862
|
this.recordAlertTime(tracked.globalId);
|
|
35282
35863
|
}
|
|
@@ -35305,13 +35886,17 @@ class TrackingEngine {
|
|
|
35305
35886
|
// Generate entry alert if this is an entry point
|
|
35306
35887
|
// Entry alerts also respect loitering threshold and cooldown
|
|
35307
35888
|
if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
|
|
35308
|
-
|
|
35889
|
+
// Get spatial reasoning for entry event
|
|
35890
|
+
const spatialResult = await this.getSpatialDescription(tracked, 'outside', // Virtual "outside" location for entry
|
|
35891
|
+
sighting.cameraId, 0, sighting.cameraId);
|
|
35309
35892
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
35310
35893
|
cameraId: sighting.cameraId,
|
|
35311
35894
|
cameraName: sighting.cameraName,
|
|
35312
35895
|
objectClass: sighting.detection.className,
|
|
35313
|
-
objectLabel:
|
|
35896
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
35314
35897
|
detectionId: sighting.detectionId,
|
|
35898
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
35899
|
+
usedLlm: spatialResult?.usedLlm,
|
|
35315
35900
|
});
|
|
35316
35901
|
this.recordAlertTime(globalId);
|
|
35317
35902
|
}
|
|
@@ -35394,6 +35979,39 @@ class TrackingEngine {
|
|
|
35394
35979
|
updateTopology(topology) {
|
|
35395
35980
|
this.topology = topology;
|
|
35396
35981
|
this.correlator = new object_correlator_1.ObjectCorrelator(topology, this.config);
|
|
35982
|
+
this.spatialReasoning.updateTopology(topology);
|
|
35983
|
+
}
|
|
35984
|
+
/** Get pending landmark suggestions */
|
|
35985
|
+
getPendingLandmarkSuggestions() {
|
|
35986
|
+
return this.spatialReasoning.getPendingSuggestions();
|
|
35987
|
+
}
|
|
35988
|
+
/** Accept a landmark suggestion, adding it to topology */
|
|
35989
|
+
acceptLandmarkSuggestion(suggestionId) {
|
|
35990
|
+
const landmark = this.spatialReasoning.acceptSuggestion(suggestionId);
|
|
35991
|
+
if (landmark && this.topology) {
|
|
35992
|
+
// Add the accepted landmark to topology
|
|
35993
|
+
if (!this.topology.landmarks) {
|
|
35994
|
+
this.topology.landmarks = [];
|
|
35995
|
+
}
|
|
35996
|
+
this.topology.landmarks.push(landmark);
|
|
35997
|
+
// Notify about topology change
|
|
35998
|
+
if (this.onTopologyChange) {
|
|
35999
|
+
this.onTopologyChange(this.topology);
|
|
36000
|
+
}
|
|
36001
|
+
}
|
|
36002
|
+
return landmark;
|
|
36003
|
+
}
|
|
36004
|
+
/** Reject a landmark suggestion */
|
|
36005
|
+
rejectLandmarkSuggestion(suggestionId) {
|
|
36006
|
+
return this.spatialReasoning.rejectSuggestion(suggestionId);
|
|
36007
|
+
}
|
|
36008
|
+
/** Get landmark templates for UI */
|
|
36009
|
+
getLandmarkTemplates() {
|
|
36010
|
+
return this.spatialReasoning.getLandmarkTemplates();
|
|
36011
|
+
}
|
|
36012
|
+
/** Get the spatial reasoning engine for direct access */
|
|
36013
|
+
getSpatialReasoningEngine() {
|
|
36014
|
+
return this.spatialReasoning;
|
|
35397
36015
|
}
|
|
35398
36016
|
/** Get current topology */
|
|
35399
36017
|
getTopology() {
|
|
@@ -36186,7 +36804,21 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36186
36804
|
type: 'boolean',
|
|
36187
36805
|
defaultValue: true,
|
|
36188
36806
|
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
36189
|
-
group: '
|
|
36807
|
+
group: 'AI & Spatial Reasoning',
|
|
36808
|
+
},
|
|
36809
|
+
enableLandmarkLearning: {
|
|
36810
|
+
title: 'Learn Landmarks from AI',
|
|
36811
|
+
type: 'boolean',
|
|
36812
|
+
defaultValue: true,
|
|
36813
|
+
description: 'Allow AI to suggest new landmarks based on detected objects and camera context',
|
|
36814
|
+
group: 'AI & Spatial Reasoning',
|
|
36815
|
+
},
|
|
36816
|
+
landmarkConfidenceThreshold: {
|
|
36817
|
+
title: 'Landmark Suggestion Confidence',
|
|
36818
|
+
type: 'number',
|
|
36819
|
+
defaultValue: 0.7,
|
|
36820
|
+
description: 'Minimum AI confidence (0-1) to suggest a landmark',
|
|
36821
|
+
group: 'AI & Spatial Reasoning',
|
|
36190
36822
|
},
|
|
36191
36823
|
// MQTT Settings
|
|
36192
36824
|
enableMqtt: {
|
|
@@ -36333,8 +36965,15 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36333
36965
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
|
|
36334
36966
|
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
|
|
36335
36967
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
36968
|
+
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
36969
|
+
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
|
|
36336
36970
|
};
|
|
36337
36971
|
this.trackingEngine = new tracking_engine_1.TrackingEngine(topology, this.trackingState, this.alertManager, config, this.console);
|
|
36972
|
+
// Set up callback to save topology changes (e.g., from accepted landmark suggestions)
|
|
36973
|
+
this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
|
|
36974
|
+
this.storage.setItem('topology', JSON.stringify(updatedTopology));
|
|
36975
|
+
this.console.log('Topology auto-saved after change');
|
|
36976
|
+
});
|
|
36338
36977
|
await this.trackingEngine.startTracking();
|
|
36339
36978
|
this.console.log('Tracking engine started');
|
|
36340
36979
|
}
|
|
@@ -36574,7 +37213,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36574
37213
|
key === 'useVisualMatching' ||
|
|
36575
37214
|
key === 'loiteringThreshold' ||
|
|
36576
37215
|
key === 'objectAlertCooldown' ||
|
|
36577
|
-
key === 'useLlmDescriptions'
|
|
37216
|
+
key === 'useLlmDescriptions' ||
|
|
37217
|
+
key === 'enableLandmarkLearning' ||
|
|
37218
|
+
key === 'landmarkConfidenceThreshold') {
|
|
36578
37219
|
const topologyJson = this.storage.getItem('topology');
|
|
36579
37220
|
if (topologyJson) {
|
|
36580
37221
|
try {
|
|
@@ -36626,6 +37267,28 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36626
37267
|
if (path.endsWith('/api/floor-plan')) {
|
|
36627
37268
|
return this.handleFloorPlanRequest(request, response);
|
|
36628
37269
|
}
|
|
37270
|
+
if (path.endsWith('/api/landmarks')) {
|
|
37271
|
+
return this.handleLandmarksRequest(request, response);
|
|
37272
|
+
}
|
|
37273
|
+
if (path.match(/\/api\/landmarks\/[\w-]+$/)) {
|
|
37274
|
+
const landmarkId = path.split('/').pop();
|
|
37275
|
+
return this.handleLandmarkRequest(landmarkId, request, response);
|
|
37276
|
+
}
|
|
37277
|
+
if (path.endsWith('/api/landmark-suggestions')) {
|
|
37278
|
+
return this.handleLandmarkSuggestionsRequest(request, response);
|
|
37279
|
+
}
|
|
37280
|
+
if (path.match(/\/api\/landmark-suggestions\/[\w-]+\/(accept|reject)$/)) {
|
|
37281
|
+
const parts = path.split('/');
|
|
37282
|
+
const action = parts.pop();
|
|
37283
|
+
const suggestionId = parts.pop();
|
|
37284
|
+
return this.handleSuggestionActionRequest(suggestionId, action, response);
|
|
37285
|
+
}
|
|
37286
|
+
if (path.endsWith('/api/landmark-templates')) {
|
|
37287
|
+
return this.handleLandmarkTemplatesRequest(response);
|
|
37288
|
+
}
|
|
37289
|
+
if (path.endsWith('/api/infer-relationships')) {
|
|
37290
|
+
return this.handleInferRelationshipsRequest(response);
|
|
37291
|
+
}
|
|
36629
37292
|
// UI Routes
|
|
36630
37293
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
36631
37294
|
return this.serveEditorUI(response);
|
|
@@ -36807,6 +37470,202 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36807
37470
|
}
|
|
36808
37471
|
}
|
|
36809
37472
|
}
|
|
37473
|
+
handleLandmarksRequest(request, response) {
|
|
37474
|
+
const topology = this.getTopology();
|
|
37475
|
+
if (!topology) {
|
|
37476
|
+
response.send(JSON.stringify({ landmarks: [] }), {
|
|
37477
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37478
|
+
});
|
|
37479
|
+
return;
|
|
37480
|
+
}
|
|
37481
|
+
if (request.method === 'GET') {
|
|
37482
|
+
response.send(JSON.stringify({
|
|
37483
|
+
landmarks: topology.landmarks || [],
|
|
37484
|
+
}), {
|
|
37485
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37486
|
+
});
|
|
37487
|
+
}
|
|
37488
|
+
else if (request.method === 'POST') {
|
|
37489
|
+
try {
|
|
37490
|
+
const landmark = JSON.parse(request.body);
|
|
37491
|
+
if (!landmark.id) {
|
|
37492
|
+
landmark.id = `landmark_${Date.now()}`;
|
|
37493
|
+
}
|
|
37494
|
+
if (!topology.landmarks) {
|
|
37495
|
+
topology.landmarks = [];
|
|
37496
|
+
}
|
|
37497
|
+
topology.landmarks.push(landmark);
|
|
37498
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
37499
|
+
if (this.trackingEngine) {
|
|
37500
|
+
this.trackingEngine.updateTopology(topology);
|
|
37501
|
+
}
|
|
37502
|
+
response.send(JSON.stringify({ success: true, landmark }), {
|
|
37503
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37504
|
+
});
|
|
37505
|
+
}
|
|
37506
|
+
catch (e) {
|
|
37507
|
+
response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
|
|
37508
|
+
code: 400,
|
|
37509
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37510
|
+
});
|
|
37511
|
+
}
|
|
37512
|
+
}
|
|
37513
|
+
}
|
|
37514
|
+
handleLandmarkRequest(landmarkId, request, response) {
|
|
37515
|
+
const topology = this.getTopology();
|
|
37516
|
+
if (!topology) {
|
|
37517
|
+
response.send(JSON.stringify({ error: 'No topology configured' }), {
|
|
37518
|
+
code: 404,
|
|
37519
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37520
|
+
});
|
|
37521
|
+
return;
|
|
37522
|
+
}
|
|
37523
|
+
const landmarkIndex = topology.landmarks?.findIndex(l => l.id === landmarkId) ?? -1;
|
|
37524
|
+
if (request.method === 'GET') {
|
|
37525
|
+
const landmark = topology.landmarks?.[landmarkIndex];
|
|
37526
|
+
if (landmark) {
|
|
37527
|
+
response.send(JSON.stringify(landmark), {
|
|
37528
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37529
|
+
});
|
|
37530
|
+
}
|
|
37531
|
+
else {
|
|
37532
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
37533
|
+
code: 404,
|
|
37534
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37535
|
+
});
|
|
37536
|
+
}
|
|
37537
|
+
}
|
|
37538
|
+
else if (request.method === 'PUT') {
|
|
37539
|
+
try {
|
|
37540
|
+
const updates = JSON.parse(request.body);
|
|
37541
|
+
if (landmarkIndex >= 0) {
|
|
37542
|
+
topology.landmarks[landmarkIndex] = {
|
|
37543
|
+
...topology.landmarks[landmarkIndex],
|
|
37544
|
+
...updates,
|
|
37545
|
+
id: landmarkId, // Preserve ID
|
|
37546
|
+
};
|
|
37547
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
37548
|
+
if (this.trackingEngine) {
|
|
37549
|
+
this.trackingEngine.updateTopology(topology);
|
|
37550
|
+
}
|
|
37551
|
+
response.send(JSON.stringify({ success: true, landmark: topology.landmarks[landmarkIndex] }), {
|
|
37552
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37553
|
+
});
|
|
37554
|
+
}
|
|
37555
|
+
else {
|
|
37556
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
37557
|
+
code: 404,
|
|
37558
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37559
|
+
});
|
|
37560
|
+
}
|
|
37561
|
+
}
|
|
37562
|
+
catch (e) {
|
|
37563
|
+
response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
|
|
37564
|
+
code: 400,
|
|
37565
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37566
|
+
});
|
|
37567
|
+
}
|
|
37568
|
+
}
|
|
37569
|
+
else if (request.method === 'DELETE') {
|
|
37570
|
+
if (landmarkIndex >= 0) {
|
|
37571
|
+
topology.landmarks.splice(landmarkIndex, 1);
|
|
37572
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
37573
|
+
if (this.trackingEngine) {
|
|
37574
|
+
this.trackingEngine.updateTopology(topology);
|
|
37575
|
+
}
|
|
37576
|
+
response.send(JSON.stringify({ success: true }), {
|
|
37577
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37578
|
+
});
|
|
37579
|
+
}
|
|
37580
|
+
else {
|
|
37581
|
+
response.send(JSON.stringify({ error: 'Landmark not found' }), {
|
|
37582
|
+
code: 404,
|
|
37583
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37584
|
+
});
|
|
37585
|
+
}
|
|
37586
|
+
}
|
|
37587
|
+
}
|
|
37588
|
+
handleLandmarkSuggestionsRequest(request, response) {
|
|
37589
|
+
if (!this.trackingEngine) {
|
|
37590
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
37591
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37592
|
+
});
|
|
37593
|
+
return;
|
|
37594
|
+
}
|
|
37595
|
+
const suggestions = this.trackingEngine.getPendingLandmarkSuggestions();
|
|
37596
|
+
response.send(JSON.stringify({
|
|
37597
|
+
suggestions,
|
|
37598
|
+
count: suggestions.length,
|
|
37599
|
+
}), {
|
|
37600
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37601
|
+
});
|
|
37602
|
+
}
|
|
37603
|
+
handleSuggestionActionRequest(suggestionId, action, response) {
|
|
37604
|
+
if (!this.trackingEngine) {
|
|
37605
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
37606
|
+
code: 500,
|
|
37607
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37608
|
+
});
|
|
37609
|
+
return;
|
|
37610
|
+
}
|
|
37611
|
+
if (action === 'accept') {
|
|
37612
|
+
const landmark = this.trackingEngine.acceptLandmarkSuggestion(suggestionId);
|
|
37613
|
+
if (landmark) {
|
|
37614
|
+
response.send(JSON.stringify({ success: true, landmark }), {
|
|
37615
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37616
|
+
});
|
|
37617
|
+
}
|
|
37618
|
+
else {
|
|
37619
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
37620
|
+
code: 404,
|
|
37621
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37622
|
+
});
|
|
37623
|
+
}
|
|
37624
|
+
}
|
|
37625
|
+
else if (action === 'reject') {
|
|
37626
|
+
const success = this.trackingEngine.rejectLandmarkSuggestion(suggestionId);
|
|
37627
|
+
if (success) {
|
|
37628
|
+
response.send(JSON.stringify({ success: true }), {
|
|
37629
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37630
|
+
});
|
|
37631
|
+
}
|
|
37632
|
+
else {
|
|
37633
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
37634
|
+
code: 404,
|
|
37635
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37636
|
+
});
|
|
37637
|
+
}
|
|
37638
|
+
}
|
|
37639
|
+
else {
|
|
37640
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
37641
|
+
code: 400,
|
|
37642
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37643
|
+
});
|
|
37644
|
+
}
|
|
37645
|
+
}
|
|
37646
|
+
handleLandmarkTemplatesRequest(response) {
|
|
37647
|
+
response.send(JSON.stringify({
|
|
37648
|
+
templates: topology_1.LANDMARK_TEMPLATES,
|
|
37649
|
+
}), {
|
|
37650
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37651
|
+
});
|
|
37652
|
+
}
|
|
37653
|
+
handleInferRelationshipsRequest(response) {
|
|
37654
|
+
const topology = this.getTopology();
|
|
37655
|
+
if (!topology) {
|
|
37656
|
+
response.send(JSON.stringify({ relationships: [] }), {
|
|
37657
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37658
|
+
});
|
|
37659
|
+
return;
|
|
37660
|
+
}
|
|
37661
|
+
const inferred = (0, topology_1.inferRelationships)(topology);
|
|
37662
|
+
response.send(JSON.stringify({
|
|
37663
|
+
relationships: inferred,
|
|
37664
|
+
count: inferred.length,
|
|
37665
|
+
}), {
|
|
37666
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37667
|
+
});
|
|
37668
|
+
}
|
|
36810
37669
|
serveEditorUI(response) {
|
|
36811
37670
|
response.send(editor_html_1.EDITOR_HTML, {
|
|
36812
37671
|
headers: { 'Content-Type': 'text/html' },
|
|
@@ -36985,9 +37844,22 @@ function generateAlertMessage(type, details) {
|
|
|
36985
37844
|
case 'property_exit':
|
|
36986
37845
|
return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
|
|
36987
37846
|
case 'movement':
|
|
37847
|
+
// If we have a rich description from LLM/RAG, use it
|
|
37848
|
+
if (details.objectLabel && details.usedLlm) {
|
|
37849
|
+
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
37850
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
37851
|
+
// Include path/landmark context if available
|
|
37852
|
+
const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
|
|
37853
|
+
return `${details.objectLabel}${pathContext}${transitStr}`;
|
|
37854
|
+
}
|
|
37855
|
+
// Fallback to basic message with landmark info
|
|
36988
37856
|
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
36989
37857
|
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
36990
|
-
|
|
37858
|
+
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
37859
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
37860
|
+
movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
|
|
37861
|
+
}
|
|
37862
|
+
return `${movementDesc}${transitStr}`;
|
|
36991
37863
|
case 'unusual_path':
|
|
36992
37864
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
36993
37865
|
case 'dwell_time':
|
|
@@ -37035,21 +37907,44 @@ function createAlert(type, trackedObjectId, details, severity = 'info', ruleId)
|
|
|
37035
37907
|
|
|
37036
37908
|
/**
|
|
37037
37909
|
* Camera Topology Models
|
|
37038
|
-
* Defines the spatial relationships between cameras
|
|
37910
|
+
* Defines the spatial relationships between cameras, landmarks, and zones
|
|
37039
37911
|
*/
|
|
37040
37912
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
37913
|
+
exports.LANDMARK_TEMPLATES = void 0;
|
|
37041
37914
|
exports.createEmptyTopology = createEmptyTopology;
|
|
37042
37915
|
exports.findCamera = findCamera;
|
|
37916
|
+
exports.findLandmark = findLandmark;
|
|
37043
37917
|
exports.findConnectionsFrom = findConnectionsFrom;
|
|
37044
37918
|
exports.findConnection = findConnection;
|
|
37045
37919
|
exports.getEntryPoints = getEntryPoints;
|
|
37046
37920
|
exports.getExitPoints = getExitPoints;
|
|
37921
|
+
exports.getLandmarksVisibleFromCamera = getLandmarksVisibleFromCamera;
|
|
37922
|
+
exports.getCamerasWithLandmarkVisibility = getCamerasWithLandmarkVisibility;
|
|
37923
|
+
exports.getAdjacentLandmarks = getAdjacentLandmarks;
|
|
37924
|
+
exports.calculateDistance = calculateDistance;
|
|
37925
|
+
exports.inferRelationships = inferRelationships;
|
|
37926
|
+
exports.generateTopologyDescription = generateTopologyDescription;
|
|
37927
|
+
exports.generateMovementContext = generateMovementContext;
|
|
37928
|
+
/** Common landmark templates for quick setup */
|
|
37929
|
+
exports.LANDMARK_TEMPLATES = [
|
|
37930
|
+
{ type: 'structure', suggestions: ['House', 'Garage', 'Shed', 'Porch', 'Deck', 'Patio', 'Gazebo', 'Pool House'] },
|
|
37931
|
+
{ type: 'feature', suggestions: ['Mailbox', 'Tree', 'Firepit', 'Pool', 'Hot Tub', 'Garden', 'Fountain', 'Flagpole'] },
|
|
37932
|
+
{ type: 'boundary', suggestions: ['Front Fence', 'Back Fence', 'Side Fence', 'Hedge', 'Wall', 'Property Line'] },
|
|
37933
|
+
{ type: 'access', suggestions: ['Driveway', 'Front Walkway', 'Back Walkway', 'Front Door', 'Back Door', 'Side Door', 'Gate', 'Stairs'] },
|
|
37934
|
+
{ type: 'vehicle', suggestions: ['Car Parking', 'Boat', 'RV Pad', 'Motorcycle Spot'] },
|
|
37935
|
+
{ type: 'neighbor', suggestions: ["Neighbor's House", "Neighbor's Driveway", "Neighbor's Yard"] },
|
|
37936
|
+
{ type: 'zone', suggestions: ['Front Yard', 'Back Yard', 'Side Yard', 'Courtyard'] },
|
|
37937
|
+
{ type: 'street', suggestions: ['Street', 'Sidewalk', 'Alley', 'Cul-de-sac'] },
|
|
37938
|
+
];
|
|
37939
|
+
// ==================== Helper Functions ====================
|
|
37047
37940
|
/** Creates an empty topology */
|
|
37048
37941
|
function createEmptyTopology() {
|
|
37049
37942
|
return {
|
|
37050
|
-
version: '
|
|
37943
|
+
version: '2.0',
|
|
37051
37944
|
cameras: [],
|
|
37052
37945
|
connections: [],
|
|
37946
|
+
landmarks: [],
|
|
37947
|
+
relationships: [],
|
|
37053
37948
|
globalZones: [],
|
|
37054
37949
|
};
|
|
37055
37950
|
}
|
|
@@ -37057,6 +37952,10 @@ function createEmptyTopology() {
|
|
|
37057
37952
|
function findCamera(topology, deviceId) {
|
|
37058
37953
|
return topology.cameras.find(c => c.deviceId === deviceId);
|
|
37059
37954
|
}
|
|
37955
|
+
/** Finds a landmark by ID */
|
|
37956
|
+
function findLandmark(topology, landmarkId) {
|
|
37957
|
+
return topology.landmarks.find(l => l.id === landmarkId);
|
|
37958
|
+
}
|
|
37060
37959
|
/** Finds connections from a camera */
|
|
37061
37960
|
function findConnectionsFrom(topology, cameraId) {
|
|
37062
37961
|
return topology.connections.filter(c => c.fromCameraId === cameraId ||
|
|
@@ -37064,8 +37963,8 @@ function findConnectionsFrom(topology, cameraId) {
|
|
|
37064
37963
|
}
|
|
37065
37964
|
/** Finds a connection between two cameras */
|
|
37066
37965
|
function findConnection(topology, fromCameraId, toCameraId) {
|
|
37067
|
-
return topology.connections.
|
|
37068
|
-
(c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId));
|
|
37966
|
+
return topology.connections.filter(c => (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
|
|
37967
|
+
(c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId))[0];
|
|
37069
37968
|
}
|
|
37070
37969
|
/** Gets all entry point cameras */
|
|
37071
37970
|
function getEntryPoints(topology) {
|
|
@@ -37075,6 +37974,171 @@ function getEntryPoints(topology) {
|
|
|
37075
37974
|
function getExitPoints(topology) {
|
|
37076
37975
|
return topology.cameras.filter(c => c.isExitPoint);
|
|
37077
37976
|
}
|
|
37977
|
+
/** Gets landmarks visible from a camera */
|
|
37978
|
+
function getLandmarksVisibleFromCamera(topology, cameraId) {
|
|
37979
|
+
const camera = findCamera(topology, cameraId);
|
|
37980
|
+
if (!camera?.context?.visibleLandmarks)
|
|
37981
|
+
return [];
|
|
37982
|
+
return camera.context.visibleLandmarks
|
|
37983
|
+
.map(id => findLandmark(topology, id))
|
|
37984
|
+
.filter((l) => l !== undefined);
|
|
37985
|
+
}
|
|
37986
|
+
/** Gets cameras that can see a landmark */
|
|
37987
|
+
function getCamerasWithLandmarkVisibility(topology, landmarkId) {
|
|
37988
|
+
return topology.cameras.filter(c => c.context?.visibleLandmarks?.includes(landmarkId));
|
|
37989
|
+
}
|
|
37990
|
+
/** Gets adjacent landmarks */
|
|
37991
|
+
function getAdjacentLandmarks(topology, landmarkId) {
|
|
37992
|
+
const landmark = findLandmark(topology, landmarkId);
|
|
37993
|
+
if (!landmark?.adjacentTo)
|
|
37994
|
+
return [];
|
|
37995
|
+
return landmark.adjacentTo
|
|
37996
|
+
.map(id => findLandmark(topology, id))
|
|
37997
|
+
.filter((l) => l !== undefined);
|
|
37998
|
+
}
|
|
37999
|
+
/** Calculates distance between two floor plan positions */
|
|
38000
|
+
function calculateDistance(posA, posB) {
|
|
38001
|
+
const dx = posB.x - posA.x;
|
|
38002
|
+
const dy = posB.y - posA.y;
|
|
38003
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
38004
|
+
}
|
|
38005
|
+
/** Auto-infers relationships based on positions and proximity */
|
|
38006
|
+
function inferRelationships(topology, proximityThreshold = 50) {
|
|
38007
|
+
const relationships = [];
|
|
38008
|
+
const entities = [];
|
|
38009
|
+
// Collect all positioned entities
|
|
38010
|
+
for (const camera of topology.cameras) {
|
|
38011
|
+
if (camera.floorPlanPosition) {
|
|
38012
|
+
entities.push({ id: camera.deviceId, position: camera.floorPlanPosition, type: 'camera' });
|
|
38013
|
+
}
|
|
38014
|
+
}
|
|
38015
|
+
for (const landmark of topology.landmarks) {
|
|
38016
|
+
entities.push({ id: landmark.id, position: landmark.position, type: 'landmark' });
|
|
38017
|
+
}
|
|
38018
|
+
// Find adjacent entities based on proximity
|
|
38019
|
+
for (let i = 0; i < entities.length; i++) {
|
|
38020
|
+
for (let j = i + 1; j < entities.length; j++) {
|
|
38021
|
+
const distance = calculateDistance(entities[i].position, entities[j].position);
|
|
38022
|
+
if (distance <= proximityThreshold) {
|
|
38023
|
+
relationships.push({
|
|
38024
|
+
id: `auto_${entities[i].id}_${entities[j].id}`,
|
|
38025
|
+
type: distance <= proximityThreshold / 2 ? 'adjacent' : 'near',
|
|
38026
|
+
entityA: entities[i].id,
|
|
38027
|
+
entityB: entities[j].id,
|
|
38028
|
+
autoInferred: true,
|
|
38029
|
+
});
|
|
38030
|
+
}
|
|
38031
|
+
}
|
|
38032
|
+
}
|
|
38033
|
+
return relationships;
|
|
38034
|
+
}
|
|
38035
|
+
/** Generates a natural language description of the topology for LLM context */
|
|
38036
|
+
function generateTopologyDescription(topology) {
|
|
38037
|
+
const lines = [];
|
|
38038
|
+
// Property description
|
|
38039
|
+
if (topology.property?.description) {
|
|
38040
|
+
lines.push(`Property: ${topology.property.description}`);
|
|
38041
|
+
}
|
|
38042
|
+
if (topology.property?.frontFacing) {
|
|
38043
|
+
lines.push(`Front of property faces ${topology.property.frontFacing}.`);
|
|
38044
|
+
}
|
|
38045
|
+
// Landmarks
|
|
38046
|
+
if (topology.landmarks.length > 0) {
|
|
38047
|
+
lines.push('\nLandmarks on property:');
|
|
38048
|
+
for (const landmark of topology.landmarks) {
|
|
38049
|
+
let desc = `- ${landmark.name} (${landmark.type})`;
|
|
38050
|
+
if (landmark.description)
|
|
38051
|
+
desc += `: ${landmark.description}`;
|
|
38052
|
+
if (landmark.isEntryPoint)
|
|
38053
|
+
desc += ' [Entry point]';
|
|
38054
|
+
if (landmark.isExitPoint)
|
|
38055
|
+
desc += ' [Exit point]';
|
|
38056
|
+
lines.push(desc);
|
|
38057
|
+
}
|
|
38058
|
+
}
|
|
38059
|
+
// Cameras
|
|
38060
|
+
if (topology.cameras.length > 0) {
|
|
38061
|
+
lines.push('\nCamera coverage:');
|
|
38062
|
+
for (const camera of topology.cameras) {
|
|
38063
|
+
let desc = `- ${camera.name}`;
|
|
38064
|
+
if (camera.context?.mountLocation)
|
|
38065
|
+
desc += ` (mounted at ${camera.context.mountLocation})`;
|
|
38066
|
+
if (camera.context?.coverageDescription)
|
|
38067
|
+
desc += `: ${camera.context.coverageDescription}`;
|
|
38068
|
+
if (camera.isEntryPoint)
|
|
38069
|
+
desc += ' [Watches entry point]';
|
|
38070
|
+
if (camera.isExitPoint)
|
|
38071
|
+
desc += ' [Watches exit point]';
|
|
38072
|
+
// List visible landmarks
|
|
38073
|
+
if (camera.context?.visibleLandmarks && camera.context.visibleLandmarks.length > 0) {
|
|
38074
|
+
const landmarkNames = camera.context.visibleLandmarks
|
|
38075
|
+
.map(id => findLandmark(topology, id)?.name)
|
|
38076
|
+
.filter(Boolean);
|
|
38077
|
+
if (landmarkNames.length > 0) {
|
|
38078
|
+
desc += ` Can see: ${landmarkNames.join(', ')}`;
|
|
38079
|
+
}
|
|
38080
|
+
}
|
|
38081
|
+
lines.push(desc);
|
|
38082
|
+
}
|
|
38083
|
+
}
|
|
38084
|
+
// Connections/paths
|
|
38085
|
+
if (topology.connections.length > 0) {
|
|
38086
|
+
lines.push('\nMovement paths:');
|
|
38087
|
+
for (const conn of topology.connections) {
|
|
38088
|
+
const fromCam = findCamera(topology, conn.fromCameraId);
|
|
38089
|
+
const toCam = findCamera(topology, conn.toCameraId);
|
|
38090
|
+
if (fromCam && toCam) {
|
|
38091
|
+
let desc = `- ${fromCam.name} → ${toCam.name}`;
|
|
38092
|
+
if (conn.name)
|
|
38093
|
+
desc += ` (${conn.name})`;
|
|
38094
|
+
desc += ` [${conn.transitTime.min / 1000}-${conn.transitTime.max / 1000}s transit]`;
|
|
38095
|
+
if (conn.bidirectional)
|
|
38096
|
+
desc += ' [bidirectional]';
|
|
38097
|
+
lines.push(desc);
|
|
38098
|
+
}
|
|
38099
|
+
}
|
|
38100
|
+
}
|
|
38101
|
+
return lines.join('\n');
|
|
38102
|
+
}
|
|
38103
|
+
/** Generates context for a specific movement between cameras */
|
|
38104
|
+
function generateMovementContext(topology, fromCameraId, toCameraId, objectClass) {
|
|
38105
|
+
const fromCamera = findCamera(topology, fromCameraId);
|
|
38106
|
+
const toCamera = findCamera(topology, toCameraId);
|
|
38107
|
+
const connection = findConnection(topology, fromCameraId, toCameraId);
|
|
38108
|
+
if (!fromCamera || !toCamera) {
|
|
38109
|
+
return `${objectClass} moving between cameras`;
|
|
38110
|
+
}
|
|
38111
|
+
const lines = [];
|
|
38112
|
+
// Source context
|
|
38113
|
+
lines.push(`Origin: ${fromCamera.name}`);
|
|
38114
|
+
if (fromCamera.context?.coverageDescription) {
|
|
38115
|
+
lines.push(` Coverage: ${fromCamera.context.coverageDescription}`);
|
|
38116
|
+
}
|
|
38117
|
+
// Destination context
|
|
38118
|
+
lines.push(`Destination: ${toCamera.name}`);
|
|
38119
|
+
if (toCamera.context?.coverageDescription) {
|
|
38120
|
+
lines.push(` Coverage: ${toCamera.context.coverageDescription}`);
|
|
38121
|
+
}
|
|
38122
|
+
// Path context
|
|
38123
|
+
if (connection) {
|
|
38124
|
+
if (connection.name)
|
|
38125
|
+
lines.push(`Path: ${connection.name}`);
|
|
38126
|
+
if (connection.pathLandmarks && connection.pathLandmarks.length > 0) {
|
|
38127
|
+
const landmarkNames = connection.pathLandmarks
|
|
38128
|
+
.map(id => findLandmark(topology, id)?.name)
|
|
38129
|
+
.filter(Boolean);
|
|
38130
|
+
if (landmarkNames.length > 0) {
|
|
38131
|
+
lines.push(`Passing: ${landmarkNames.join(' → ')}`);
|
|
38132
|
+
}
|
|
38133
|
+
}
|
|
38134
|
+
}
|
|
38135
|
+
// Nearby landmarks at destination
|
|
38136
|
+
const destLandmarks = getLandmarksVisibleFromCamera(topology, toCameraId);
|
|
38137
|
+
if (destLandmarks.length > 0) {
|
|
38138
|
+
lines.push(`Near: ${destLandmarks.map(l => l.name).join(', ')}`);
|
|
38139
|
+
}
|
|
38140
|
+
return lines.join('\n');
|
|
38141
|
+
}
|
|
37078
38142
|
|
|
37079
38143
|
|
|
37080
38144
|
/***/ },
|
|
@@ -37535,6 +38599,22 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37535
38599
|
<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
|
|
37536
38600
|
</div>
|
|
37537
38601
|
</div>
|
|
38602
|
+
<div class="section">
|
|
38603
|
+
<div class="section-title">
|
|
38604
|
+
<span>Landmarks</span>
|
|
38605
|
+
<button class="btn btn-small" onclick="openAddLandmarkModal()">+ Add</button>
|
|
38606
|
+
</div>
|
|
38607
|
+
<div id="landmark-list">
|
|
38608
|
+
<div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>
|
|
38609
|
+
</div>
|
|
38610
|
+
</div>
|
|
38611
|
+
<div class="section" id="suggestions-section" style="display: none;">
|
|
38612
|
+
<div class="section-title">
|
|
38613
|
+
<span>AI Suggestions</span>
|
|
38614
|
+
<button class="btn btn-small" onclick="loadSuggestions()">Refresh</button>
|
|
38615
|
+
</div>
|
|
38616
|
+
<div id="suggestions-list"></div>
|
|
38617
|
+
</div>
|
|
37538
38618
|
</div>
|
|
37539
38619
|
</div>
|
|
37540
38620
|
<div class="editor">
|
|
@@ -37548,6 +38628,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37548
38628
|
<button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
|
|
37549
38629
|
<button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
|
|
37550
38630
|
<button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
|
|
38631
|
+
<button class="btn" id="tool-landmark" onclick="setTool('landmark')">Place Landmark</button>
|
|
37551
38632
|
<button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
|
|
37552
38633
|
</div>
|
|
37553
38634
|
<div class="toolbar-group">
|
|
@@ -37575,7 +38656,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37575
38656
|
<span id="status-text">Ready</span>
|
|
37576
38657
|
</div>
|
|
37577
38658
|
<div>
|
|
37578
|
-
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections
|
|
38659
|
+
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections | <span id="landmark-count">0</span> landmarks
|
|
37579
38660
|
</div>
|
|
37580
38661
|
</div>
|
|
37581
38662
|
</div>
|
|
@@ -37669,12 +38750,61 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37669
38750
|
</div>
|
|
37670
38751
|
</div>
|
|
37671
38752
|
|
|
38753
|
+
<div class="modal-overlay" id="add-landmark-modal">
|
|
38754
|
+
<div class="modal">
|
|
38755
|
+
<h2>Add Landmark</h2>
|
|
38756
|
+
<div class="form-group">
|
|
38757
|
+
<label>Landmark Type</label>
|
|
38758
|
+
<select id="landmark-type-select" onchange="updateLandmarkSuggestions()">
|
|
38759
|
+
<option value="structure">Structure (House, Garage, Shed)</option>
|
|
38760
|
+
<option value="feature">Feature (Mailbox, Tree, Pool)</option>
|
|
38761
|
+
<option value="boundary">Boundary (Fence, Wall, Hedge)</option>
|
|
38762
|
+
<option value="access">Access (Driveway, Walkway, Gate)</option>
|
|
38763
|
+
<option value="vehicle">Vehicle (Parking, Boat, RV)</option>
|
|
38764
|
+
<option value="neighbor">Neighbor (House, Driveway)</option>
|
|
38765
|
+
<option value="zone">Zone (Front Yard, Back Yard)</option>
|
|
38766
|
+
<option value="street">Street (Street, Sidewalk, Alley)</option>
|
|
38767
|
+
</select>
|
|
38768
|
+
</div>
|
|
38769
|
+
<div class="form-group">
|
|
38770
|
+
<label>Quick Templates</label>
|
|
38771
|
+
<div id="landmark-templates" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
|
|
38772
|
+
</div>
|
|
38773
|
+
<div class="form-group">
|
|
38774
|
+
<label>Name</label>
|
|
38775
|
+
<input type="text" id="landmark-name-input" placeholder="e.g., Front Porch, Red Shed">
|
|
38776
|
+
</div>
|
|
38777
|
+
<div class="form-group">
|
|
38778
|
+
<label>Description (optional)</label>
|
|
38779
|
+
<input type="text" id="landmark-desc-input" placeholder="Brief description for AI context">
|
|
38780
|
+
</div>
|
|
38781
|
+
<div class="form-group">
|
|
38782
|
+
<label class="checkbox-group">
|
|
38783
|
+
<input type="checkbox" id="landmark-entry-checkbox">
|
|
38784
|
+
Entry Point (people can enter property here)
|
|
38785
|
+
</label>
|
|
38786
|
+
</div>
|
|
38787
|
+
<div class="form-group">
|
|
38788
|
+
<label class="checkbox-group">
|
|
38789
|
+
<input type="checkbox" id="landmark-exit-checkbox">
|
|
38790
|
+
Exit Point (people can exit property here)
|
|
38791
|
+
</label>
|
|
38792
|
+
</div>
|
|
38793
|
+
<div class="modal-actions">
|
|
38794
|
+
<button class="btn" onclick="closeModal('add-landmark-modal')">Cancel</button>
|
|
38795
|
+
<button class="btn btn-primary" onclick="addLandmark()">Add Landmark</button>
|
|
38796
|
+
</div>
|
|
38797
|
+
</div>
|
|
38798
|
+
</div>
|
|
38799
|
+
|
|
37672
38800
|
<script>
|
|
37673
|
-
let topology = { version: '
|
|
38801
|
+
let topology = { version: '2.0', cameras: [], connections: [], globalZones: [], landmarks: [], relationships: [], floorPlan: null, drawings: [] };
|
|
37674
38802
|
let selectedItem = null;
|
|
37675
38803
|
let currentTool = 'select';
|
|
37676
38804
|
let floorPlanImage = null;
|
|
37677
38805
|
let availableCameras = [];
|
|
38806
|
+
let landmarkTemplates = [];
|
|
38807
|
+
let pendingSuggestions = [];
|
|
37678
38808
|
let isDrawing = false;
|
|
37679
38809
|
let drawStart = null;
|
|
37680
38810
|
let currentDrawing = null;
|
|
@@ -37685,6 +38815,8 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37685
38815
|
async function init() {
|
|
37686
38816
|
await loadTopology();
|
|
37687
38817
|
await loadAvailableCameras();
|
|
38818
|
+
await loadLandmarkTemplates();
|
|
38819
|
+
await loadSuggestions();
|
|
37688
38820
|
resizeCanvas();
|
|
37689
38821
|
render();
|
|
37690
38822
|
updateUI();
|
|
@@ -37720,6 +38852,192 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37720
38852
|
updateCameraSelects();
|
|
37721
38853
|
}
|
|
37722
38854
|
|
|
38855
|
+
async function loadLandmarkTemplates() {
|
|
38856
|
+
try {
|
|
38857
|
+
const response = await fetch('../api/landmark-templates');
|
|
38858
|
+
if (response.ok) {
|
|
38859
|
+
const data = await response.json();
|
|
38860
|
+
landmarkTemplates = data.templates || [];
|
|
38861
|
+
}
|
|
38862
|
+
} catch (e) { console.error('Failed to load landmark templates:', e); }
|
|
38863
|
+
}
|
|
38864
|
+
|
|
38865
|
+
async function loadSuggestions() {
|
|
38866
|
+
try {
|
|
38867
|
+
const response = await fetch('../api/landmark-suggestions');
|
|
38868
|
+
if (response.ok) {
|
|
38869
|
+
const data = await response.json();
|
|
38870
|
+
pendingSuggestions = data.suggestions || [];
|
|
38871
|
+
updateSuggestionsUI();
|
|
38872
|
+
}
|
|
38873
|
+
} catch (e) { console.error('Failed to load suggestions:', e); }
|
|
38874
|
+
}
|
|
38875
|
+
|
|
38876
|
+
function updateSuggestionsUI() {
|
|
38877
|
+
const section = document.getElementById('suggestions-section');
|
|
38878
|
+
const list = document.getElementById('suggestions-list');
|
|
38879
|
+
if (pendingSuggestions.length === 0) {
|
|
38880
|
+
section.style.display = 'none';
|
|
38881
|
+
return;
|
|
38882
|
+
}
|
|
38883
|
+
section.style.display = 'block';
|
|
38884
|
+
list.innerHTML = pendingSuggestions.map(s =>
|
|
38885
|
+
'<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
|
|
38886
|
+
'<div><div class="camera-name">' + s.landmark.name + '</div>' +
|
|
38887
|
+
'<div class="camera-info">' + s.landmark.type + ' - ' + Math.round((s.landmark.aiConfidence || 0) * 100) + '% confidence</div></div>' +
|
|
38888
|
+
'<div style="display: flex; gap: 5px;">' +
|
|
38889
|
+
'<button class="btn btn-small btn-primary" onclick="acceptSuggestion(\\'' + s.id + '\\')">Accept</button>' +
|
|
38890
|
+
'<button class="btn btn-small" onclick="rejectSuggestion(\\'' + s.id + '\\')">Reject</button>' +
|
|
38891
|
+
'</div></div>'
|
|
38892
|
+
).join('');
|
|
38893
|
+
}
|
|
38894
|
+
|
|
38895
|
+
async function acceptSuggestion(id) {
|
|
38896
|
+
try {
|
|
38897
|
+
const response = await fetch('../api/landmark-suggestions/' + id + '/accept', { method: 'POST' });
|
|
38898
|
+
if (response.ok) {
|
|
38899
|
+
const data = await response.json();
|
|
38900
|
+
if (data.landmark) {
|
|
38901
|
+
topology.landmarks.push(data.landmark);
|
|
38902
|
+
updateUI();
|
|
38903
|
+
render();
|
|
38904
|
+
}
|
|
38905
|
+
await loadSuggestions();
|
|
38906
|
+
setStatus('Landmark accepted', 'success');
|
|
38907
|
+
}
|
|
38908
|
+
} catch (e) { console.error('Failed to accept suggestion:', e); }
|
|
38909
|
+
}
|
|
38910
|
+
|
|
38911
|
+
async function rejectSuggestion(id) {
|
|
38912
|
+
try {
|
|
38913
|
+
await fetch('../api/landmark-suggestions/' + id + '/reject', { method: 'POST' });
|
|
38914
|
+
await loadSuggestions();
|
|
38915
|
+
setStatus('Suggestion rejected', 'success');
|
|
38916
|
+
} catch (e) { console.error('Failed to reject suggestion:', e); }
|
|
38917
|
+
}
|
|
38918
|
+
|
|
38919
|
+
function openAddLandmarkModal() {
|
|
38920
|
+
updateLandmarkSuggestions();
|
|
38921
|
+
document.getElementById('add-landmark-modal').classList.add('active');
|
|
38922
|
+
}
|
|
38923
|
+
|
|
38924
|
+
function updateLandmarkSuggestions() {
|
|
38925
|
+
const type = document.getElementById('landmark-type-select').value;
|
|
38926
|
+
const template = landmarkTemplates.find(t => t.type === type);
|
|
38927
|
+
const container = document.getElementById('landmark-templates');
|
|
38928
|
+
if (template) {
|
|
38929
|
+
container.innerHTML = template.suggestions.map(s =>
|
|
38930
|
+
'<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
|
|
38931
|
+
).join('');
|
|
38932
|
+
} else {
|
|
38933
|
+
container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
|
|
38934
|
+
}
|
|
38935
|
+
}
|
|
38936
|
+
|
|
38937
|
+
function setLandmarkName(name) {
|
|
38938
|
+
document.getElementById('landmark-name-input').value = name;
|
|
38939
|
+
}
|
|
38940
|
+
|
|
38941
|
+
function addLandmark() {
|
|
38942
|
+
const name = document.getElementById('landmark-name-input').value;
|
|
38943
|
+
if (!name) { alert('Please enter a landmark name'); return; }
|
|
38944
|
+
const type = document.getElementById('landmark-type-select').value;
|
|
38945
|
+
const description = document.getElementById('landmark-desc-input').value;
|
|
38946
|
+
const isEntry = document.getElementById('landmark-entry-checkbox').checked;
|
|
38947
|
+
const isExit = document.getElementById('landmark-exit-checkbox').checked;
|
|
38948
|
+
const pos = topology._pendingLandmarkPos || { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 };
|
|
38949
|
+
delete topology._pendingLandmarkPos;
|
|
38950
|
+
const landmark = {
|
|
38951
|
+
id: 'landmark_' + Date.now(),
|
|
38952
|
+
name,
|
|
38953
|
+
type,
|
|
38954
|
+
position: pos,
|
|
38955
|
+
description: description || undefined,
|
|
38956
|
+
isEntryPoint: isEntry,
|
|
38957
|
+
isExitPoint: isExit,
|
|
38958
|
+
visibleFromCameras: [],
|
|
38959
|
+
};
|
|
38960
|
+
if (!topology.landmarks) topology.landmarks = [];
|
|
38961
|
+
topology.landmarks.push(landmark);
|
|
38962
|
+
closeModal('add-landmark-modal');
|
|
38963
|
+
document.getElementById('landmark-name-input').value = '';
|
|
38964
|
+
document.getElementById('landmark-desc-input').value = '';
|
|
38965
|
+
document.getElementById('landmark-entry-checkbox').checked = false;
|
|
38966
|
+
document.getElementById('landmark-exit-checkbox').checked = false;
|
|
38967
|
+
updateUI();
|
|
38968
|
+
render();
|
|
38969
|
+
}
|
|
38970
|
+
|
|
38971
|
+
function selectLandmark(id) {
|
|
38972
|
+
selectedItem = { type: 'landmark', id };
|
|
38973
|
+
const landmark = topology.landmarks.find(l => l.id === id);
|
|
38974
|
+
showLandmarkProperties(landmark);
|
|
38975
|
+
updateUI();
|
|
38976
|
+
render();
|
|
38977
|
+
}
|
|
38978
|
+
|
|
38979
|
+
function showLandmarkProperties(landmark) {
|
|
38980
|
+
const panel = document.getElementById('properties-panel');
|
|
38981
|
+
const cameraOptions = topology.cameras.map(c =>
|
|
38982
|
+
'<label class="checkbox-group" style="margin-bottom: 5px;"><input type="checkbox" ' +
|
|
38983
|
+
((landmark.visibleFromCameras || []).includes(c.deviceId) ? 'checked' : '') +
|
|
38984
|
+
' onchange="toggleLandmarkCamera(\\'' + landmark.id + '\\', \\'' + c.deviceId + '\\', this.checked)">' +
|
|
38985
|
+
c.name + '</label>'
|
|
38986
|
+
).join('');
|
|
38987
|
+
panel.innerHTML = '<h3>Landmark Properties</h3>' +
|
|
38988
|
+
'<div class="form-group"><label>Name</label><input type="text" value="' + landmark.name + '" onchange="updateLandmarkName(\\'' + landmark.id + '\\', this.value)"></div>' +
|
|
38989
|
+
'<div class="form-group"><label>Type</label><select onchange="updateLandmarkType(\\'' + landmark.id + '\\', this.value)">' +
|
|
38990
|
+
'<option value="structure"' + (landmark.type === 'structure' ? ' selected' : '') + '>Structure</option>' +
|
|
38991
|
+
'<option value="feature"' + (landmark.type === 'feature' ? ' selected' : '') + '>Feature</option>' +
|
|
38992
|
+
'<option value="boundary"' + (landmark.type === 'boundary' ? ' selected' : '') + '>Boundary</option>' +
|
|
38993
|
+
'<option value="access"' + (landmark.type === 'access' ? ' selected' : '') + '>Access</option>' +
|
|
38994
|
+
'<option value="vehicle"' + (landmark.type === 'vehicle' ? ' selected' : '') + '>Vehicle</option>' +
|
|
38995
|
+
'<option value="neighbor"' + (landmark.type === 'neighbor' ? ' selected' : '') + '>Neighbor</option>' +
|
|
38996
|
+
'<option value="zone"' + (landmark.type === 'zone' ? ' selected' : '') + '>Zone</option>' +
|
|
38997
|
+
'<option value="street"' + (landmark.type === 'street' ? ' selected' : '') + '>Street</option>' +
|
|
38998
|
+
'</select></div>' +
|
|
38999
|
+
'<div class="form-group"><label>Description</label><input type="text" value="' + (landmark.description || '') + '" onchange="updateLandmarkDesc(\\'' + landmark.id + '\\', this.value)"></div>' +
|
|
39000
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isEntryPoint ? 'checked' : '') + ' onchange="updateLandmarkEntry(\\'' + landmark.id + '\\', this.checked)">Entry Point</label></div>' +
|
|
39001
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isExitPoint ? 'checked' : '') + ' onchange="updateLandmarkExit(\\'' + landmark.id + '\\', this.checked)">Exit Point</label></div>' +
|
|
39002
|
+
'<div class="form-group"><label>Visible from Cameras</label>' + (cameraOptions || '<span style="color:#666;font-size:12px;">Add cameras first</span>') + '</div>' +
|
|
39003
|
+
'<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteLandmark(\\'' + landmark.id + '\\')">Delete Landmark</button></div>';
|
|
39004
|
+
}
|
|
39005
|
+
|
|
39006
|
+
function updateLandmarkName(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.name = value; updateUI(); }
|
|
39007
|
+
function updateLandmarkType(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.type = value; render(); }
|
|
39008
|
+
function updateLandmarkDesc(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.description = value || undefined; }
|
|
39009
|
+
function updateLandmarkEntry(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isEntryPoint = value; }
|
|
39010
|
+
function updateLandmarkExit(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isExitPoint = value; }
|
|
39011
|
+
function toggleLandmarkCamera(landmarkId, cameraId, visible) {
|
|
39012
|
+
const l = topology.landmarks.find(x => x.id === landmarkId);
|
|
39013
|
+
if (!l) return;
|
|
39014
|
+
if (!l.visibleFromCameras) l.visibleFromCameras = [];
|
|
39015
|
+
if (visible && !l.visibleFromCameras.includes(cameraId)) {
|
|
39016
|
+
l.visibleFromCameras.push(cameraId);
|
|
39017
|
+
} else if (!visible) {
|
|
39018
|
+
l.visibleFromCameras = l.visibleFromCameras.filter(id => id !== cameraId);
|
|
39019
|
+
}
|
|
39020
|
+
// Also update camera's visibleLandmarks
|
|
39021
|
+
const camera = topology.cameras.find(c => c.deviceId === cameraId);
|
|
39022
|
+
if (camera) {
|
|
39023
|
+
if (!camera.context) camera.context = {};
|
|
39024
|
+
if (!camera.context.visibleLandmarks) camera.context.visibleLandmarks = [];
|
|
39025
|
+
if (visible && !camera.context.visibleLandmarks.includes(landmarkId)) {
|
|
39026
|
+
camera.context.visibleLandmarks.push(landmarkId);
|
|
39027
|
+
} else if (!visible) {
|
|
39028
|
+
camera.context.visibleLandmarks = camera.context.visibleLandmarks.filter(id => id !== landmarkId);
|
|
39029
|
+
}
|
|
39030
|
+
}
|
|
39031
|
+
}
|
|
39032
|
+
function deleteLandmark(id) {
|
|
39033
|
+
if (!confirm('Delete this landmark?')) return;
|
|
39034
|
+
topology.landmarks = topology.landmarks.filter(l => l.id !== id);
|
|
39035
|
+
selectedItem = null;
|
|
39036
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
|
|
39037
|
+
updateUI();
|
|
39038
|
+
render();
|
|
39039
|
+
}
|
|
39040
|
+
|
|
37723
39041
|
async function saveTopology() {
|
|
37724
39042
|
try {
|
|
37725
39043
|
setStatus('Saving...', 'warning');
|
|
@@ -37807,6 +39125,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37807
39125
|
ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
|
|
37808
39126
|
}
|
|
37809
39127
|
}
|
|
39128
|
+
// Draw landmarks first (below cameras and connections)
|
|
39129
|
+
for (const landmark of (topology.landmarks || [])) {
|
|
39130
|
+
if (landmark.position) { drawLandmark(landmark); }
|
|
39131
|
+
}
|
|
37810
39132
|
for (const conn of topology.connections) {
|
|
37811
39133
|
const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
|
|
37812
39134
|
const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
|
|
@@ -37819,6 +39141,46 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37819
39141
|
}
|
|
37820
39142
|
}
|
|
37821
39143
|
|
|
39144
|
+
function drawLandmark(landmark) {
|
|
39145
|
+
const pos = landmark.position;
|
|
39146
|
+
const isSelected = selectedItem?.type === 'landmark' && selectedItem?.id === landmark.id;
|
|
39147
|
+
// Color by type
|
|
39148
|
+
const colors = {
|
|
39149
|
+
structure: '#8b5cf6', // purple
|
|
39150
|
+
feature: '#10b981', // green
|
|
39151
|
+
boundary: '#f59e0b', // amber
|
|
39152
|
+
access: '#3b82f6', // blue
|
|
39153
|
+
vehicle: '#6366f1', // indigo
|
|
39154
|
+
neighbor: '#ec4899', // pink
|
|
39155
|
+
zone: '#14b8a6', // teal
|
|
39156
|
+
street: '#6b7280', // gray
|
|
39157
|
+
};
|
|
39158
|
+
const color = colors[landmark.type] || '#888';
|
|
39159
|
+
// Draw landmark marker
|
|
39160
|
+
ctx.beginPath();
|
|
39161
|
+
ctx.moveTo(pos.x, pos.y - 15);
|
|
39162
|
+
ctx.lineTo(pos.x + 12, pos.y + 8);
|
|
39163
|
+
ctx.lineTo(pos.x - 12, pos.y + 8);
|
|
39164
|
+
ctx.closePath();
|
|
39165
|
+
ctx.fillStyle = isSelected ? '#e94560' : color;
|
|
39166
|
+
ctx.fill();
|
|
39167
|
+
ctx.strokeStyle = '#fff';
|
|
39168
|
+
ctx.lineWidth = 2;
|
|
39169
|
+
ctx.stroke();
|
|
39170
|
+
// Entry/exit indicators
|
|
39171
|
+
if (landmark.isEntryPoint || landmark.isExitPoint) {
|
|
39172
|
+
ctx.beginPath();
|
|
39173
|
+
ctx.arc(pos.x, pos.y - 20, 5, 0, Math.PI * 2);
|
|
39174
|
+
ctx.fillStyle = landmark.isEntryPoint ? '#4caf50' : '#ff9800';
|
|
39175
|
+
ctx.fill();
|
|
39176
|
+
}
|
|
39177
|
+
// Label
|
|
39178
|
+
ctx.fillStyle = '#fff';
|
|
39179
|
+
ctx.font = '11px sans-serif';
|
|
39180
|
+
ctx.textAlign = 'center';
|
|
39181
|
+
ctx.fillText(landmark.name, pos.x, pos.y + 25);
|
|
39182
|
+
}
|
|
39183
|
+
|
|
37822
39184
|
function drawCamera(camera) {
|
|
37823
39185
|
const pos = camera.floorPlanPosition;
|
|
37824
39186
|
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
@@ -37985,8 +39347,17 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37985
39347
|
} else {
|
|
37986
39348
|
connectionList.innerHTML = topology.connections.map(c => '<div class="connection-item ' + (selectedItem?.type === 'connection' && selectedItem?.id === c.id ? 'selected' : '') + '" onclick="selectConnection(\\'' + c.id + '\\')"><div class="camera-name">' + c.name + '</div><div class="camera-info">' + (c.transitTime.typical / 1000) + 's typical ' + (c.bidirectional ? '<->' : '->') + '</div></div>').join('');
|
|
37987
39349
|
}
|
|
39350
|
+
// Landmark list
|
|
39351
|
+
const landmarkList = document.getElementById('landmark-list');
|
|
39352
|
+
const landmarks = topology.landmarks || [];
|
|
39353
|
+
if (landmarks.length === 0) {
|
|
39354
|
+
landmarkList.innerHTML = '<div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>';
|
|
39355
|
+
} else {
|
|
39356
|
+
landmarkList.innerHTML = landmarks.map(l => '<div class="camera-item ' + (selectedItem?.type === 'landmark' && selectedItem?.id === l.id ? 'selected' : '') + '" onclick="selectLandmark(\\'' + l.id + '\\')"><div class="camera-name">' + l.name + '</div><div class="camera-info">' + l.type + (l.isEntryPoint ? ' | Entry' : '') + (l.isExitPoint ? ' | Exit' : '') + '</div></div>').join('');
|
|
39357
|
+
}
|
|
37988
39358
|
document.getElementById('camera-count').textContent = topology.cameras.length;
|
|
37989
39359
|
document.getElementById('connection-count').textContent = topology.connections.length;
|
|
39360
|
+
document.getElementById('landmark-count').textContent = landmarks.length;
|
|
37990
39361
|
}
|
|
37991
39362
|
|
|
37992
39363
|
function selectCamera(deviceId) {
|
|
@@ -38057,10 +39428,18 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38057
39428
|
const y = e.clientY - rect.top;
|
|
38058
39429
|
|
|
38059
39430
|
if (currentTool === 'select') {
|
|
39431
|
+
// Check cameras first
|
|
38060
39432
|
for (const camera of topology.cameras) {
|
|
38061
39433
|
if (camera.floorPlanPosition) {
|
|
38062
39434
|
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
38063
|
-
if (dist < 25) { selectCamera(camera.deviceId); dragging = camera; return; }
|
|
39435
|
+
if (dist < 25) { selectCamera(camera.deviceId); dragging = { type: 'camera', item: camera }; return; }
|
|
39436
|
+
}
|
|
39437
|
+
}
|
|
39438
|
+
// Check landmarks
|
|
39439
|
+
for (const landmark of (topology.landmarks || [])) {
|
|
39440
|
+
if (landmark.position) {
|
|
39441
|
+
const dist = Math.hypot(x - landmark.position.x, y - landmark.position.y);
|
|
39442
|
+
if (dist < 20) { selectLandmark(landmark.id); dragging = { type: 'landmark', item: landmark }; return; }
|
|
38064
39443
|
}
|
|
38065
39444
|
}
|
|
38066
39445
|
} else if (currentTool === 'wall') {
|
|
@@ -38073,8 +39452,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38073
39452
|
currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
|
|
38074
39453
|
} else if (currentTool === 'camera') {
|
|
38075
39454
|
openAddCameraModal();
|
|
38076
|
-
// Will position camera at click location after adding
|
|
38077
39455
|
topology._pendingCameraPos = { x, y };
|
|
39456
|
+
} else if (currentTool === 'landmark') {
|
|
39457
|
+
openAddLandmarkModal();
|
|
39458
|
+
topology._pendingLandmarkPos = { x, y };
|
|
38078
39459
|
}
|
|
38079
39460
|
});
|
|
38080
39461
|
|
|
@@ -38084,8 +39465,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38084
39465
|
const y = e.clientY - rect.top;
|
|
38085
39466
|
|
|
38086
39467
|
if (dragging) {
|
|
38087
|
-
dragging.
|
|
38088
|
-
|
|
39468
|
+
if (dragging.type === 'camera') {
|
|
39469
|
+
dragging.item.floorPlanPosition.x = x;
|
|
39470
|
+
dragging.item.floorPlanPosition.y = y;
|
|
39471
|
+
} else if (dragging.type === 'landmark') {
|
|
39472
|
+
dragging.item.position.x = x;
|
|
39473
|
+
dragging.item.position.y = y;
|
|
39474
|
+
}
|
|
38089
39475
|
render();
|
|
38090
39476
|
} else if (isDrawing && currentDrawing) {
|
|
38091
39477
|
if (currentDrawing.type === 'wall') {
|