@blueharford/scrypted-spatial-awareness 0.1.15 → 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 +1514 -25
- 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 +207 -14
- package/src/main.ts +294 -2
- 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,9 +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;
|
|
35644
|
+
/** Track last alert time per object to enforce cooldown */
|
|
35645
|
+
objectLastAlertTime = new Map();
|
|
35646
|
+
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
35647
|
+
onTopologyChange;
|
|
35073
35648
|
constructor(topology, state, alertManager, config, console) {
|
|
35074
35649
|
this.topology = topology;
|
|
35075
35650
|
this.state = state;
|
|
@@ -35077,6 +35652,19 @@ class TrackingEngine {
|
|
|
35077
35652
|
this.config = config;
|
|
35078
35653
|
this.console = console;
|
|
35079
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;
|
|
35080
35668
|
}
|
|
35081
35669
|
/** Start listening to all cameras in topology */
|
|
35082
35670
|
async startTracking() {
|
|
@@ -35172,6 +35760,61 @@ class TrackingEngine {
|
|
|
35172
35760
|
await this.processSighting(sighting, camera.isEntryPoint, camera.isExitPoint);
|
|
35173
35761
|
}
|
|
35174
35762
|
}
|
|
35763
|
+
/** Check if object passes loitering threshold */
|
|
35764
|
+
passesLoiteringThreshold(tracked) {
|
|
35765
|
+
const visibleDuration = tracked.lastSeen - tracked.firstSeen;
|
|
35766
|
+
return visibleDuration >= this.config.loiteringThreshold;
|
|
35767
|
+
}
|
|
35768
|
+
/** Check if object is in alert cooldown */
|
|
35769
|
+
isInAlertCooldown(globalId) {
|
|
35770
|
+
const lastAlertTime = this.objectLastAlertTime.get(globalId);
|
|
35771
|
+
if (!lastAlertTime)
|
|
35772
|
+
return false;
|
|
35773
|
+
return (Date.now() - lastAlertTime) < this.config.objectAlertCooldown;
|
|
35774
|
+
}
|
|
35775
|
+
/** Record that we alerted for this object */
|
|
35776
|
+
recordAlertTime(globalId) {
|
|
35777
|
+
this.objectLastAlertTime.set(globalId, Date.now());
|
|
35778
|
+
}
|
|
35779
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) */
|
|
35780
|
+
async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
|
|
35781
|
+
try {
|
|
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();
|
|
35788
|
+
}
|
|
35789
|
+
}
|
|
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);
|
|
35795
|
+
}
|
|
35796
|
+
return result;
|
|
35797
|
+
}
|
|
35798
|
+
catch (e) {
|
|
35799
|
+
this.console.warn('Spatial reasoning failed:', e);
|
|
35800
|
+
return null;
|
|
35801
|
+
}
|
|
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
|
+
}
|
|
35175
35818
|
/** Process a single sighting */
|
|
35176
35819
|
async processSighting(sighting, isEntryPoint, isExitPoint) {
|
|
35177
35820
|
// Try to correlate with existing tracked objects
|
|
@@ -35197,17 +35840,27 @@ class TrackingEngine {
|
|
|
35197
35840
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
35198
35841
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
35199
35842
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
35200
|
-
//
|
|
35201
|
-
|
|
35202
|
-
|
|
35203
|
-
|
|
35204
|
-
|
|
35205
|
-
|
|
35206
|
-
|
|
35207
|
-
|
|
35208
|
-
|
|
35209
|
-
|
|
35210
|
-
|
|
35843
|
+
// Check loitering threshold and per-object cooldown before alerting
|
|
35844
|
+
if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
|
|
35845
|
+
// Get spatial reasoning result with RAG context
|
|
35846
|
+
const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
|
|
35847
|
+
// Generate movement alert for cross-camera transition
|
|
35848
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
35849
|
+
fromCameraId: lastSighting.cameraId,
|
|
35850
|
+
fromCameraName: lastSighting.cameraName,
|
|
35851
|
+
toCameraId: sighting.cameraId,
|
|
35852
|
+
toCameraName: sighting.cameraName,
|
|
35853
|
+
transitTime: transitDuration,
|
|
35854
|
+
objectClass: sighting.detection.className,
|
|
35855
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
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,
|
|
35861
|
+
});
|
|
35862
|
+
this.recordAlertTime(tracked.globalId);
|
|
35863
|
+
}
|
|
35211
35864
|
}
|
|
35212
35865
|
// Add sighting to tracked object
|
|
35213
35866
|
this.state.addSighting(tracked.globalId, sighting);
|
|
@@ -35231,14 +35884,21 @@ class TrackingEngine {
|
|
|
35231
35884
|
this.console.log(`New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
|
|
35232
35885
|
`(ID: ${globalId.slice(0, 8)})`);
|
|
35233
35886
|
// Generate entry alert if this is an entry point
|
|
35234
|
-
|
|
35887
|
+
// Entry alerts also respect loitering threshold and cooldown
|
|
35888
|
+
if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
|
|
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);
|
|
35235
35892
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
35236
35893
|
cameraId: sighting.cameraId,
|
|
35237
35894
|
cameraName: sighting.cameraName,
|
|
35238
35895
|
objectClass: sighting.detection.className,
|
|
35239
|
-
objectLabel: sighting.detection.label,
|
|
35896
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
35240
35897
|
detectionId: sighting.detectionId,
|
|
35898
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
35899
|
+
usedLlm: spatialResult?.usedLlm,
|
|
35241
35900
|
});
|
|
35901
|
+
this.recordAlertTime(globalId);
|
|
35242
35902
|
}
|
|
35243
35903
|
}
|
|
35244
35904
|
}
|
|
@@ -35319,6 +35979,39 @@ class TrackingEngine {
|
|
|
35319
35979
|
updateTopology(topology) {
|
|
35320
35980
|
this.topology = topology;
|
|
35321
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;
|
|
35322
36015
|
}
|
|
35323
36016
|
/** Get current topology */
|
|
35324
36017
|
getTopology() {
|
|
@@ -36091,6 +36784,42 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36091
36784
|
description: 'Use visual embeddings for object correlation (requires compatible detectors)',
|
|
36092
36785
|
group: 'Tracking',
|
|
36093
36786
|
},
|
|
36787
|
+
loiteringThreshold: {
|
|
36788
|
+
title: 'Loitering Threshold (seconds)',
|
|
36789
|
+
type: 'number',
|
|
36790
|
+
defaultValue: 3,
|
|
36791
|
+
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
36792
|
+
group: 'Tracking',
|
|
36793
|
+
},
|
|
36794
|
+
objectAlertCooldown: {
|
|
36795
|
+
title: 'Per-Object Alert Cooldown (seconds)',
|
|
36796
|
+
type: 'number',
|
|
36797
|
+
defaultValue: 30,
|
|
36798
|
+
description: 'Minimum time between alerts for the same tracked object',
|
|
36799
|
+
group: 'Tracking',
|
|
36800
|
+
},
|
|
36801
|
+
// LLM Integration
|
|
36802
|
+
useLlmDescriptions: {
|
|
36803
|
+
title: 'Use LLM for Rich Descriptions',
|
|
36804
|
+
type: 'boolean',
|
|
36805
|
+
defaultValue: true,
|
|
36806
|
+
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
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',
|
|
36822
|
+
},
|
|
36094
36823
|
// MQTT Settings
|
|
36095
36824
|
enableMqtt: {
|
|
36096
36825
|
title: 'Enable MQTT',
|
|
@@ -36233,8 +36962,18 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36233
36962
|
correlationThreshold: this.storageSettings.values.correlationThreshold || 0.6,
|
|
36234
36963
|
lostTimeout: (this.storageSettings.values.lostTimeout || 300) * 1000,
|
|
36235
36964
|
useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
|
|
36965
|
+
loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
|
|
36966
|
+
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
|
|
36967
|
+
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
36968
|
+
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
36969
|
+
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
|
|
36236
36970
|
};
|
|
36237
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
|
+
});
|
|
36238
36977
|
await this.trackingEngine.startTracking();
|
|
36239
36978
|
this.console.log('Tracking engine started');
|
|
36240
36979
|
}
|
|
@@ -36471,7 +37210,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36471
37210
|
key === 'correlationWindow' ||
|
|
36472
37211
|
key === 'correlationThreshold' ||
|
|
36473
37212
|
key === 'lostTimeout' ||
|
|
36474
|
-
key === 'useVisualMatching'
|
|
37213
|
+
key === 'useVisualMatching' ||
|
|
37214
|
+
key === 'loiteringThreshold' ||
|
|
37215
|
+
key === 'objectAlertCooldown' ||
|
|
37216
|
+
key === 'useLlmDescriptions' ||
|
|
37217
|
+
key === 'enableLandmarkLearning' ||
|
|
37218
|
+
key === 'landmarkConfidenceThreshold') {
|
|
36475
37219
|
const topologyJson = this.storage.getItem('topology');
|
|
36476
37220
|
if (topologyJson) {
|
|
36477
37221
|
try {
|
|
@@ -36523,6 +37267,28 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36523
37267
|
if (path.endsWith('/api/floor-plan')) {
|
|
36524
37268
|
return this.handleFloorPlanRequest(request, response);
|
|
36525
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
|
+
}
|
|
36526
37292
|
// UI Routes
|
|
36527
37293
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
36528
37294
|
return this.serveEditorUI(response);
|
|
@@ -36704,6 +37470,202 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36704
37470
|
}
|
|
36705
37471
|
}
|
|
36706
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
|
+
}
|
|
36707
37669
|
serveEditorUI(response) {
|
|
36708
37670
|
response.send(editor_html_1.EDITOR_HTML, {
|
|
36709
37671
|
headers: { 'Content-Type': 'text/html' },
|
|
@@ -36882,9 +37844,22 @@ function generateAlertMessage(type, details) {
|
|
|
36882
37844
|
case 'property_exit':
|
|
36883
37845
|
return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
|
|
36884
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
|
|
36885
37856
|
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
36886
37857
|
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
36887
|
-
|
|
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}`;
|
|
36888
37863
|
case 'unusual_path':
|
|
36889
37864
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
36890
37865
|
case 'dwell_time':
|
|
@@ -36932,21 +37907,44 @@ function createAlert(type, trackedObjectId, details, severity = 'info', ruleId)
|
|
|
36932
37907
|
|
|
36933
37908
|
/**
|
|
36934
37909
|
* Camera Topology Models
|
|
36935
|
-
* Defines the spatial relationships between cameras
|
|
37910
|
+
* Defines the spatial relationships between cameras, landmarks, and zones
|
|
36936
37911
|
*/
|
|
36937
37912
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
37913
|
+
exports.LANDMARK_TEMPLATES = void 0;
|
|
36938
37914
|
exports.createEmptyTopology = createEmptyTopology;
|
|
36939
37915
|
exports.findCamera = findCamera;
|
|
37916
|
+
exports.findLandmark = findLandmark;
|
|
36940
37917
|
exports.findConnectionsFrom = findConnectionsFrom;
|
|
36941
37918
|
exports.findConnection = findConnection;
|
|
36942
37919
|
exports.getEntryPoints = getEntryPoints;
|
|
36943
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 ====================
|
|
36944
37940
|
/** Creates an empty topology */
|
|
36945
37941
|
function createEmptyTopology() {
|
|
36946
37942
|
return {
|
|
36947
|
-
version: '
|
|
37943
|
+
version: '2.0',
|
|
36948
37944
|
cameras: [],
|
|
36949
37945
|
connections: [],
|
|
37946
|
+
landmarks: [],
|
|
37947
|
+
relationships: [],
|
|
36950
37948
|
globalZones: [],
|
|
36951
37949
|
};
|
|
36952
37950
|
}
|
|
@@ -36954,6 +37952,10 @@ function createEmptyTopology() {
|
|
|
36954
37952
|
function findCamera(topology, deviceId) {
|
|
36955
37953
|
return topology.cameras.find(c => c.deviceId === deviceId);
|
|
36956
37954
|
}
|
|
37955
|
+
/** Finds a landmark by ID */
|
|
37956
|
+
function findLandmark(topology, landmarkId) {
|
|
37957
|
+
return topology.landmarks.find(l => l.id === landmarkId);
|
|
37958
|
+
}
|
|
36957
37959
|
/** Finds connections from a camera */
|
|
36958
37960
|
function findConnectionsFrom(topology, cameraId) {
|
|
36959
37961
|
return topology.connections.filter(c => c.fromCameraId === cameraId ||
|
|
@@ -36961,8 +37963,8 @@ function findConnectionsFrom(topology, cameraId) {
|
|
|
36961
37963
|
}
|
|
36962
37964
|
/** Finds a connection between two cameras */
|
|
36963
37965
|
function findConnection(topology, fromCameraId, toCameraId) {
|
|
36964
|
-
return topology.connections.
|
|
36965
|
-
(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];
|
|
36966
37968
|
}
|
|
36967
37969
|
/** Gets all entry point cameras */
|
|
36968
37970
|
function getEntryPoints(topology) {
|
|
@@ -36972,6 +37974,171 @@ function getEntryPoints(topology) {
|
|
|
36972
37974
|
function getExitPoints(topology) {
|
|
36973
37975
|
return topology.cameras.filter(c => c.isExitPoint);
|
|
36974
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
|
+
}
|
|
36975
38142
|
|
|
36976
38143
|
|
|
36977
38144
|
/***/ },
|
|
@@ -37432,6 +38599,22 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37432
38599
|
<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
|
|
37433
38600
|
</div>
|
|
37434
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>
|
|
37435
38618
|
</div>
|
|
37436
38619
|
</div>
|
|
37437
38620
|
<div class="editor">
|
|
@@ -37445,6 +38628,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37445
38628
|
<button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
|
|
37446
38629
|
<button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
|
|
37447
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>
|
|
37448
38632
|
<button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
|
|
37449
38633
|
</div>
|
|
37450
38634
|
<div class="toolbar-group">
|
|
@@ -37472,7 +38656,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37472
38656
|
<span id="status-text">Ready</span>
|
|
37473
38657
|
</div>
|
|
37474
38658
|
<div>
|
|
37475
|
-
<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
|
|
37476
38660
|
</div>
|
|
37477
38661
|
</div>
|
|
37478
38662
|
</div>
|
|
@@ -37566,12 +38750,61 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37566
38750
|
</div>
|
|
37567
38751
|
</div>
|
|
37568
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
|
+
|
|
37569
38800
|
<script>
|
|
37570
|
-
let topology = { version: '
|
|
38801
|
+
let topology = { version: '2.0', cameras: [], connections: [], globalZones: [], landmarks: [], relationships: [], floorPlan: null, drawings: [] };
|
|
37571
38802
|
let selectedItem = null;
|
|
37572
38803
|
let currentTool = 'select';
|
|
37573
38804
|
let floorPlanImage = null;
|
|
37574
38805
|
let availableCameras = [];
|
|
38806
|
+
let landmarkTemplates = [];
|
|
38807
|
+
let pendingSuggestions = [];
|
|
37575
38808
|
let isDrawing = false;
|
|
37576
38809
|
let drawStart = null;
|
|
37577
38810
|
let currentDrawing = null;
|
|
@@ -37582,6 +38815,8 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37582
38815
|
async function init() {
|
|
37583
38816
|
await loadTopology();
|
|
37584
38817
|
await loadAvailableCameras();
|
|
38818
|
+
await loadLandmarkTemplates();
|
|
38819
|
+
await loadSuggestions();
|
|
37585
38820
|
resizeCanvas();
|
|
37586
38821
|
render();
|
|
37587
38822
|
updateUI();
|
|
@@ -37617,6 +38852,192 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37617
38852
|
updateCameraSelects();
|
|
37618
38853
|
}
|
|
37619
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
|
+
|
|
37620
39041
|
async function saveTopology() {
|
|
37621
39042
|
try {
|
|
37622
39043
|
setStatus('Saving...', 'warning');
|
|
@@ -37704,6 +39125,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37704
39125
|
ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
|
|
37705
39126
|
}
|
|
37706
39127
|
}
|
|
39128
|
+
// Draw landmarks first (below cameras and connections)
|
|
39129
|
+
for (const landmark of (topology.landmarks || [])) {
|
|
39130
|
+
if (landmark.position) { drawLandmark(landmark); }
|
|
39131
|
+
}
|
|
37707
39132
|
for (const conn of topology.connections) {
|
|
37708
39133
|
const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
|
|
37709
39134
|
const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
|
|
@@ -37716,6 +39141,46 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37716
39141
|
}
|
|
37717
39142
|
}
|
|
37718
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
|
+
|
|
37719
39184
|
function drawCamera(camera) {
|
|
37720
39185
|
const pos = camera.floorPlanPosition;
|
|
37721
39186
|
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
@@ -37882,8 +39347,17 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37882
39347
|
} else {
|
|
37883
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('');
|
|
37884
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
|
+
}
|
|
37885
39358
|
document.getElementById('camera-count').textContent = topology.cameras.length;
|
|
37886
39359
|
document.getElementById('connection-count').textContent = topology.connections.length;
|
|
39360
|
+
document.getElementById('landmark-count').textContent = landmarks.length;
|
|
37887
39361
|
}
|
|
37888
39362
|
|
|
37889
39363
|
function selectCamera(deviceId) {
|
|
@@ -37954,10 +39428,18 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37954
39428
|
const y = e.clientY - rect.top;
|
|
37955
39429
|
|
|
37956
39430
|
if (currentTool === 'select') {
|
|
39431
|
+
// Check cameras first
|
|
37957
39432
|
for (const camera of topology.cameras) {
|
|
37958
39433
|
if (camera.floorPlanPosition) {
|
|
37959
39434
|
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
37960
|
-
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; }
|
|
37961
39443
|
}
|
|
37962
39444
|
}
|
|
37963
39445
|
} else if (currentTool === 'wall') {
|
|
@@ -37970,8 +39452,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37970
39452
|
currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
|
|
37971
39453
|
} else if (currentTool === 'camera') {
|
|
37972
39454
|
openAddCameraModal();
|
|
37973
|
-
// Will position camera at click location after adding
|
|
37974
39455
|
topology._pendingCameraPos = { x, y };
|
|
39456
|
+
} else if (currentTool === 'landmark') {
|
|
39457
|
+
openAddLandmarkModal();
|
|
39458
|
+
topology._pendingLandmarkPos = { x, y };
|
|
37975
39459
|
}
|
|
37976
39460
|
});
|
|
37977
39461
|
|
|
@@ -37981,8 +39465,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
37981
39465
|
const y = e.clientY - rect.top;
|
|
37982
39466
|
|
|
37983
39467
|
if (dragging) {
|
|
37984
|
-
dragging.
|
|
37985
|
-
|
|
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
|
+
}
|
|
37986
39475
|
render();
|
|
37987
39476
|
} else if (isDrawing && currentDrawing) {
|
|
37988
39477
|
if (currentDrawing.type === 'wall') {
|