@blueharford/scrypted-spatial-awareness 0.4.7 → 0.4.8-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- 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 +554 -137
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/object-correlator.ts +32 -7
- package/src/core/spatial-reasoning.ts +315 -44
- package/src/core/tracking-engine.ts +57 -19
- package/src/models/alert.ts +41 -14
package/out/main.nodejs.js
CHANGED
|
@@ -34499,25 +34499,31 @@ class AlertManager {
|
|
|
34499
34499
|
getNotificationTitle(alert) {
|
|
34500
34500
|
const prefix = alert.severity === 'critical' ? '🚨 ' :
|
|
34501
34501
|
alert.severity === 'warning' ? '⚠️ ' : '';
|
|
34502
|
+
// Use object class in title
|
|
34503
|
+
const objectType = alert.details.objectClass
|
|
34504
|
+
? alert.details.objectClass.charAt(0).toUpperCase() + alert.details.objectClass.slice(1)
|
|
34505
|
+
: 'Object';
|
|
34502
34506
|
switch (alert.type) {
|
|
34503
34507
|
case 'property_entry':
|
|
34504
|
-
return `${prefix}
|
|
34508
|
+
return `${prefix}${objectType} Arrived`;
|
|
34505
34509
|
case 'property_exit':
|
|
34506
|
-
return `${prefix}
|
|
34510
|
+
return `${prefix}${objectType} Left`;
|
|
34507
34511
|
case 'movement':
|
|
34508
|
-
|
|
34512
|
+
// Include destination in title
|
|
34513
|
+
const dest = alert.details.toCameraName || 'area';
|
|
34514
|
+
return `${prefix}${objectType} → ${dest}`;
|
|
34509
34515
|
case 'unusual_path':
|
|
34510
|
-
return `${prefix}Unusual
|
|
34516
|
+
return `${prefix}Unusual Route`;
|
|
34511
34517
|
case 'dwell_time':
|
|
34512
|
-
return `${prefix}
|
|
34518
|
+
return `${prefix}${objectType} Lingering`;
|
|
34513
34519
|
case 'restricted_zone':
|
|
34514
|
-
return `${prefix}Restricted Zone
|
|
34520
|
+
return `${prefix}Restricted Zone!`;
|
|
34515
34521
|
case 'lost_tracking':
|
|
34516
|
-
return `${prefix}Lost
|
|
34522
|
+
return `${prefix}${objectType} Lost`;
|
|
34517
34523
|
case 'reappearance':
|
|
34518
|
-
return `${prefix}
|
|
34524
|
+
return `${prefix}${objectType} Reappeared`;
|
|
34519
34525
|
default:
|
|
34520
|
-
return `${prefix}Spatial
|
|
34526
|
+
return `${prefix}Spatial Alert`;
|
|
34521
34527
|
}
|
|
34522
34528
|
}
|
|
34523
34529
|
/**
|
|
@@ -34751,8 +34757,20 @@ class ObjectCorrelator {
|
|
|
34751
34757
|
candidates.push(candidate);
|
|
34752
34758
|
}
|
|
34753
34759
|
}
|
|
34754
|
-
if (candidates.length === 0)
|
|
34760
|
+
if (candidates.length === 0) {
|
|
34761
|
+
// No candidates above threshold - try to find best match with relaxed criteria
|
|
34762
|
+
// This helps when there's only one object of this class active
|
|
34763
|
+
const sameClassObjects = activeObjects.filter(o => o.className === sighting.detection.className);
|
|
34764
|
+
if (sameClassObjects.length === 1) {
|
|
34765
|
+
// Only one object of this class - likely the same one
|
|
34766
|
+
const candidate = await this.evaluateCandidate(sameClassObjects[0], sighting);
|
|
34767
|
+
// Accept with lower threshold if timing is reasonable
|
|
34768
|
+
if (candidate.confidence >= 0.3 && candidate.factors.timing > 0) {
|
|
34769
|
+
return candidate;
|
|
34770
|
+
}
|
|
34771
|
+
}
|
|
34755
34772
|
return null;
|
|
34773
|
+
}
|
|
34756
34774
|
// Sort by confidence (highest first)
|
|
34757
34775
|
candidates.sort((a, b) => b.confidence - a.confidence);
|
|
34758
34776
|
// Return best match
|
|
@@ -34815,14 +34833,32 @@ class ObjectCorrelator {
|
|
|
34815
34833
|
if (lastSighting.cameraId === sighting.cameraId) {
|
|
34816
34834
|
return 1.0;
|
|
34817
34835
|
}
|
|
34836
|
+
// Calculate transit time first
|
|
34837
|
+
const transitTime = sighting.timestamp - lastSighting.timestamp;
|
|
34818
34838
|
// Find connection between cameras
|
|
34819
34839
|
const connection = (0, topology_1.findConnection)(this.topology, lastSighting.cameraId, sighting.cameraId);
|
|
34820
34840
|
if (!connection) {
|
|
34821
|
-
// No defined connection -
|
|
34822
|
-
// (
|
|
34823
|
-
|
|
34841
|
+
// No defined connection - still allow correlation based on reasonable timing
|
|
34842
|
+
// Allow up to 5 minutes transit between any cameras (property could be large)
|
|
34843
|
+
const MAX_UNCHARTED_TRANSIT = 300000; // 5 minutes
|
|
34844
|
+
if (transitTime > 0 && transitTime < MAX_UNCHARTED_TRANSIT) {
|
|
34845
|
+
// Score based on how reasonable the timing is
|
|
34846
|
+
// Give higher base score for reasonable transits (encourages matching)
|
|
34847
|
+
if (transitTime < 60000) {
|
|
34848
|
+
// Under 1 minute - very likely same object
|
|
34849
|
+
return 0.9;
|
|
34850
|
+
}
|
|
34851
|
+
else if (transitTime < 120000) {
|
|
34852
|
+
// Under 2 minutes - probably same object
|
|
34853
|
+
return 0.7;
|
|
34854
|
+
}
|
|
34855
|
+
else {
|
|
34856
|
+
// 2-5 minutes - possible but less certain
|
|
34857
|
+
return Math.max(0.4, 0.7 - (transitTime - 120000) / 180000 * 0.3);
|
|
34858
|
+
}
|
|
34859
|
+
}
|
|
34860
|
+
return 0.3; // Even long transits get some credit
|
|
34824
34861
|
}
|
|
34825
|
-
const transitTime = sighting.timestamp - lastSighting.timestamp;
|
|
34826
34862
|
const { min, typical, max } = connection.transitTime;
|
|
34827
34863
|
// Way outside range
|
|
34828
34864
|
if (transitTime < min * 0.5 || transitTime > max * 2) {
|
|
@@ -34878,7 +34914,8 @@ class ObjectCorrelator {
|
|
|
34878
34914
|
// Find connection
|
|
34879
34915
|
const connection = (0, topology_1.findConnection)(this.topology, lastSighting.cameraId, sighting.cameraId);
|
|
34880
34916
|
if (!connection) {
|
|
34881
|
-
|
|
34917
|
+
// No connection defined - give neutral score (don't penalize)
|
|
34918
|
+
return 0.5;
|
|
34882
34919
|
}
|
|
34883
34920
|
let score = 0;
|
|
34884
34921
|
// Check if last detection was in/near exit zone
|
|
@@ -35021,42 +35058,12 @@ exports.ObjectCorrelator = ObjectCorrelator;
|
|
|
35021
35058
|
* Uses RAG (Retrieval Augmented Generation) to provide rich contextual understanding
|
|
35022
35059
|
* of movement across the property topology
|
|
35023
35060
|
*/
|
|
35024
|
-
var
|
|
35025
|
-
|
|
35026
|
-
|
|
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
|
-
})();
|
|
35061
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
35062
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
35063
|
+
};
|
|
35057
35064
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
35058
35065
|
exports.SpatialReasoningEngine = void 0;
|
|
35059
|
-
const sdk_1 =
|
|
35066
|
+
const sdk_1 = __importDefault(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
35060
35067
|
const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
|
|
35061
35068
|
const { systemManager } = sdk_1.default;
|
|
35062
35069
|
class SpatialReasoningEngine {
|
|
@@ -35276,29 +35283,185 @@ class SpatialReasoningEngine {
|
|
|
35276
35283
|
}
|
|
35277
35284
|
return relevant;
|
|
35278
35285
|
}
|
|
35279
|
-
|
|
35286
|
+
llmSearched = false;
|
|
35287
|
+
llmProvider = null;
|
|
35288
|
+
/** Find or initialize LLM device - looks for ChatCompletion interface from @scrypted/llm plugin */
|
|
35280
35289
|
async findLlmDevice() {
|
|
35281
35290
|
if (this.llmDevice)
|
|
35282
35291
|
return this.llmDevice;
|
|
35292
|
+
if (this.llmSearched)
|
|
35293
|
+
return null; // Already searched and found nothing
|
|
35294
|
+
this.llmSearched = true;
|
|
35283
35295
|
try {
|
|
35296
|
+
// Look for devices with ChatCompletion interface (the correct interface for @scrypted/llm)
|
|
35284
35297
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
35285
35298
|
const device = systemManager.getDeviceById(id);
|
|
35286
|
-
if (device
|
|
35287
|
-
|
|
35288
|
-
|
|
35289
|
-
|
|
35290
|
-
|
|
35291
|
-
|
|
35292
|
-
|
|
35299
|
+
if (!device)
|
|
35300
|
+
continue;
|
|
35301
|
+
// Check if this device has ChatCompletion interface
|
|
35302
|
+
// The @scrypted/llm plugin exposes ChatCompletion, not ObjectDetection
|
|
35303
|
+
if (device.interfaces?.includes('ChatCompletion')) {
|
|
35304
|
+
const deviceName = device.name?.toLowerCase() || '';
|
|
35305
|
+
const pluginId = device.pluginId?.toLowerCase() || '';
|
|
35306
|
+
// Identify the provider type for logging
|
|
35307
|
+
let providerType = 'Unknown';
|
|
35308
|
+
if (pluginId.includes('@scrypted/llm') || pluginId.includes('llm')) {
|
|
35309
|
+
providerType = 'Scrypted LLM';
|
|
35293
35310
|
}
|
|
35311
|
+
if (deviceName.includes('openai') || deviceName.includes('gpt')) {
|
|
35312
|
+
providerType = 'OpenAI';
|
|
35313
|
+
}
|
|
35314
|
+
else if (deviceName.includes('anthropic') || deviceName.includes('claude')) {
|
|
35315
|
+
providerType = 'Anthropic';
|
|
35316
|
+
}
|
|
35317
|
+
else if (deviceName.includes('ollama')) {
|
|
35318
|
+
providerType = 'Ollama';
|
|
35319
|
+
}
|
|
35320
|
+
else if (deviceName.includes('gemini') || deviceName.includes('google')) {
|
|
35321
|
+
providerType = 'Google';
|
|
35322
|
+
}
|
|
35323
|
+
else if (deviceName.includes('llama')) {
|
|
35324
|
+
providerType = 'llama.cpp';
|
|
35325
|
+
}
|
|
35326
|
+
this.llmDevice = device;
|
|
35327
|
+
this.llmProvider = `${providerType} (${device.name})`;
|
|
35328
|
+
this.console.log(`[LLM] Connected to ${providerType}: ${device.name}`);
|
|
35329
|
+
this.console.log(`[LLM] Plugin: ${pluginId || 'N/A'}`);
|
|
35330
|
+
this.console.log(`[LLM] Interfaces: ${device.interfaces?.join(', ')}`);
|
|
35331
|
+
return this.llmDevice;
|
|
35294
35332
|
}
|
|
35295
35333
|
}
|
|
35334
|
+
// If we get here, no LLM plugin found
|
|
35335
|
+
this.console.warn('[LLM] No ChatCompletion device found. Install @scrypted/llm for enhanced descriptions.');
|
|
35336
|
+
this.console.warn('[LLM] Falling back to rule-based descriptions using topology data.');
|
|
35296
35337
|
}
|
|
35297
35338
|
catch (e) {
|
|
35298
|
-
this.console.
|
|
35339
|
+
this.console.error('[LLM] Error searching for LLM device:', e);
|
|
35299
35340
|
}
|
|
35300
35341
|
return null;
|
|
35301
35342
|
}
|
|
35343
|
+
/** Get the current LLM provider name */
|
|
35344
|
+
getLlmProvider() {
|
|
35345
|
+
return this.llmProvider;
|
|
35346
|
+
}
|
|
35347
|
+
/** Check if LLM is available */
|
|
35348
|
+
isLlmAvailable() {
|
|
35349
|
+
return this.llmDevice !== null;
|
|
35350
|
+
}
|
|
35351
|
+
/** Generate entry description when object enters property */
|
|
35352
|
+
generateEntryDescription(tracked, cameraId) {
|
|
35353
|
+
if (!this.topology) {
|
|
35354
|
+
return {
|
|
35355
|
+
description: `${this.capitalizeFirst(tracked.className)} entered property`,
|
|
35356
|
+
involvedLandmarks: [],
|
|
35357
|
+
confidence: 0.5,
|
|
35358
|
+
usedLlm: false,
|
|
35359
|
+
};
|
|
35360
|
+
}
|
|
35361
|
+
const camera = (0, topology_1.findCamera)(this.topology, cameraId);
|
|
35362
|
+
if (!camera) {
|
|
35363
|
+
return {
|
|
35364
|
+
description: `${this.capitalizeFirst(tracked.className)} entered property`,
|
|
35365
|
+
involvedLandmarks: [],
|
|
35366
|
+
confidence: 0.5,
|
|
35367
|
+
usedLlm: false,
|
|
35368
|
+
};
|
|
35369
|
+
}
|
|
35370
|
+
const landmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, cameraId);
|
|
35371
|
+
const objectType = this.capitalizeFirst(tracked.className);
|
|
35372
|
+
// Build entry description using topology context
|
|
35373
|
+
const location = this.describeLocation(camera, landmarks, 'to');
|
|
35374
|
+
// Check if we can determine where they came from (e.g., street, neighbor)
|
|
35375
|
+
const entryLandmark = landmarks.find(l => l.isEntryPoint);
|
|
35376
|
+
const streetLandmark = landmarks.find(l => l.type === 'street');
|
|
35377
|
+
const neighborLandmark = landmarks.find(l => l.type === 'neighbor');
|
|
35378
|
+
let source = '';
|
|
35379
|
+
if (streetLandmark) {
|
|
35380
|
+
source = ` from ${streetLandmark.name}`;
|
|
35381
|
+
}
|
|
35382
|
+
else if (neighborLandmark) {
|
|
35383
|
+
source = ` from ${neighborLandmark.name}`;
|
|
35384
|
+
}
|
|
35385
|
+
return {
|
|
35386
|
+
description: `${objectType} arrived at ${location}${source}`,
|
|
35387
|
+
involvedLandmarks: landmarks,
|
|
35388
|
+
confidence: 0.8,
|
|
35389
|
+
usedLlm: false,
|
|
35390
|
+
};
|
|
35391
|
+
}
|
|
35392
|
+
/** Generate exit description when object leaves property */
|
|
35393
|
+
generateExitDescription(tracked, cameraId) {
|
|
35394
|
+
if (!this.topology) {
|
|
35395
|
+
return {
|
|
35396
|
+
description: `${this.capitalizeFirst(tracked.className)} left property`,
|
|
35397
|
+
involvedLandmarks: [],
|
|
35398
|
+
confidence: 0.5,
|
|
35399
|
+
usedLlm: false,
|
|
35400
|
+
};
|
|
35401
|
+
}
|
|
35402
|
+
const camera = (0, topology_1.findCamera)(this.topology, cameraId);
|
|
35403
|
+
if (!camera) {
|
|
35404
|
+
return {
|
|
35405
|
+
description: `${this.capitalizeFirst(tracked.className)} left property`,
|
|
35406
|
+
involvedLandmarks: [],
|
|
35407
|
+
confidence: 0.5,
|
|
35408
|
+
usedLlm: false,
|
|
35409
|
+
};
|
|
35410
|
+
}
|
|
35411
|
+
const landmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, cameraId);
|
|
35412
|
+
const objectType = this.capitalizeFirst(tracked.className);
|
|
35413
|
+
// Build exit description
|
|
35414
|
+
const location = this.describeLocation(camera, landmarks, 'from');
|
|
35415
|
+
// Check for exit point landmarks
|
|
35416
|
+
const exitLandmark = landmarks.find(l => l.isExitPoint);
|
|
35417
|
+
const streetLandmark = landmarks.find(l => l.type === 'street');
|
|
35418
|
+
let destination = '';
|
|
35419
|
+
if (streetLandmark) {
|
|
35420
|
+
destination = ` towards ${streetLandmark.name}`;
|
|
35421
|
+
}
|
|
35422
|
+
else if (exitLandmark) {
|
|
35423
|
+
destination = ` via ${exitLandmark.name}`;
|
|
35424
|
+
}
|
|
35425
|
+
// Include time on property if available
|
|
35426
|
+
const dwellTime = Math.round((tracked.lastSeen - tracked.firstSeen) / 1000);
|
|
35427
|
+
let timeContext = '';
|
|
35428
|
+
if (dwellTime > 60) {
|
|
35429
|
+
timeContext = ` after ${Math.round(dwellTime / 60)}m on property`;
|
|
35430
|
+
}
|
|
35431
|
+
else if (dwellTime > 10) {
|
|
35432
|
+
timeContext = ` after ${dwellTime}s`;
|
|
35433
|
+
}
|
|
35434
|
+
// Summarize journey if they visited multiple cameras (use landmarks from topology)
|
|
35435
|
+
let journeyContext = '';
|
|
35436
|
+
if (tracked.journey.length > 0 && this.topology) {
|
|
35437
|
+
const visitedLandmarks = [];
|
|
35438
|
+
// Get landmarks from entry camera
|
|
35439
|
+
if (tracked.entryCamera) {
|
|
35440
|
+
const entryLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, tracked.entryCamera);
|
|
35441
|
+
const entryLandmark = entryLandmarks.find(l => l.isEntryPoint || l.type === 'access') || entryLandmarks[0];
|
|
35442
|
+
if (entryLandmark) {
|
|
35443
|
+
visitedLandmarks.push(entryLandmark.name);
|
|
35444
|
+
}
|
|
35445
|
+
}
|
|
35446
|
+
// Get landmarks from journey segments
|
|
35447
|
+
for (const segment of tracked.journey) {
|
|
35448
|
+
const segmentLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, segment.toCameraId);
|
|
35449
|
+
const segmentLandmark = segmentLandmarks.find(l => !visitedLandmarks.includes(l.name) && (l.type === 'access' || l.type === 'zone' || l.type === 'structure'));
|
|
35450
|
+
if (segmentLandmark && !visitedLandmarks.includes(segmentLandmark.name)) {
|
|
35451
|
+
visitedLandmarks.push(segmentLandmark.name);
|
|
35452
|
+
}
|
|
35453
|
+
}
|
|
35454
|
+
if (visitedLandmarks.length > 1) {
|
|
35455
|
+
journeyContext = ` — visited ${visitedLandmarks.join(' → ')}`;
|
|
35456
|
+
}
|
|
35457
|
+
}
|
|
35458
|
+
return {
|
|
35459
|
+
description: `${objectType} left ${location}${destination}${timeContext}${journeyContext}`,
|
|
35460
|
+
involvedLandmarks: landmarks,
|
|
35461
|
+
confidence: 0.8,
|
|
35462
|
+
usedLlm: false,
|
|
35463
|
+
};
|
|
35464
|
+
}
|
|
35302
35465
|
/** Generate rich movement description using LLM */
|
|
35303
35466
|
async generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject) {
|
|
35304
35467
|
if (!this.topology) {
|
|
@@ -35350,27 +35513,78 @@ class SpatialReasoningEngine {
|
|
|
35350
35513
|
buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks) {
|
|
35351
35514
|
const objectType = this.capitalizeFirst(tracked.className);
|
|
35352
35515
|
const transitSecs = Math.round(transitTime / 1000);
|
|
35353
|
-
//
|
|
35354
|
-
|
|
35355
|
-
|
|
35356
|
-
|
|
35357
|
-
|
|
35516
|
+
// Get connection for path context
|
|
35517
|
+
const connection = this.topology ? (0, topology_1.findConnection)(this.topology, fromCamera.deviceId, toCamera.deviceId) : null;
|
|
35518
|
+
// Build origin description using landmarks, camera context, or camera name
|
|
35519
|
+
let origin = this.describeLocation(fromCamera, fromLandmarks, 'from');
|
|
35520
|
+
// Build destination description
|
|
35521
|
+
let destination = this.describeLocation(toCamera, toLandmarks, 'to');
|
|
35522
|
+
// Check if we have a named path/connection
|
|
35523
|
+
let pathContext = '';
|
|
35524
|
+
if (connection?.name) {
|
|
35525
|
+
pathContext = ` via ${connection.name}`;
|
|
35526
|
+
}
|
|
35527
|
+
else if (connection?.pathLandmarks?.length && this.topology) {
|
|
35528
|
+
const pathNames = connection.pathLandmarks
|
|
35529
|
+
.map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
|
|
35530
|
+
.filter(Boolean);
|
|
35531
|
+
if (pathNames.length > 0) {
|
|
35532
|
+
pathContext = ` past ${pathNames.join(' and ')}`;
|
|
35533
|
+
}
|
|
35534
|
+
}
|
|
35535
|
+
// Include journey context if this is not the first camera
|
|
35536
|
+
let journeyContext = '';
|
|
35537
|
+
if (tracked.journey.length > 0) {
|
|
35538
|
+
const totalTime = Math.round((Date.now() - tracked.firstSeen) / 1000);
|
|
35539
|
+
if (totalTime > 60) {
|
|
35540
|
+
journeyContext = ` (${Math.round(totalTime / 60)}m on property)`;
|
|
35541
|
+
}
|
|
35358
35542
|
}
|
|
35359
|
-
|
|
35360
|
-
|
|
35543
|
+
// Determine movement verb based on transit time and object type
|
|
35544
|
+
const verb = this.getMovementVerb(tracked.className, transitSecs);
|
|
35545
|
+
return `${objectType} ${verb} ${origin} heading ${destination}${pathContext}${journeyContext}`;
|
|
35546
|
+
}
|
|
35547
|
+
/** Describe a location using landmarks, camera context, or camera name */
|
|
35548
|
+
describeLocation(camera, landmarks, direction) {
|
|
35549
|
+
// Priority 1: Use entry/exit landmarks
|
|
35550
|
+
const entryExitLandmark = landmarks.find(l => (direction === 'from' && l.isExitPoint) || (direction === 'to' && l.isEntryPoint));
|
|
35551
|
+
if (entryExitLandmark) {
|
|
35552
|
+
return direction === 'from' ? `the ${entryExitLandmark.name}` : `the ${entryExitLandmark.name}`;
|
|
35361
35553
|
}
|
|
35362
|
-
//
|
|
35363
|
-
|
|
35364
|
-
if (
|
|
35365
|
-
|
|
35366
|
-
destination = `towards ${nearLandmark.name}`;
|
|
35554
|
+
// Priority 2: Use access landmarks (driveway, walkway, etc.)
|
|
35555
|
+
const accessLandmark = landmarks.find(l => l.type === 'access');
|
|
35556
|
+
if (accessLandmark) {
|
|
35557
|
+
return `the ${accessLandmark.name}`;
|
|
35367
35558
|
}
|
|
35368
|
-
|
|
35369
|
-
|
|
35559
|
+
// Priority 3: Use zone landmarks (front yard, back yard)
|
|
35560
|
+
const zoneLandmark = landmarks.find(l => l.type === 'zone');
|
|
35561
|
+
if (zoneLandmark) {
|
|
35562
|
+
return `the ${zoneLandmark.name}`;
|
|
35370
35563
|
}
|
|
35371
|
-
//
|
|
35372
|
-
|
|
35373
|
-
|
|
35564
|
+
// Priority 4: Use any landmark
|
|
35565
|
+
if (landmarks.length > 0) {
|
|
35566
|
+
return `near ${landmarks[0].name}`;
|
|
35567
|
+
}
|
|
35568
|
+
// Priority 5: Use camera coverage description
|
|
35569
|
+
if (camera.context?.coverageDescription) {
|
|
35570
|
+
const desc = camera.context.coverageDescription.split('.')[0].toLowerCase();
|
|
35571
|
+
return `the ${desc}`;
|
|
35572
|
+
}
|
|
35573
|
+
// Fallback: Generic description (no camera name inference - use topology for context)
|
|
35574
|
+
return direction === 'from' ? 'property' : 'property';
|
|
35575
|
+
}
|
|
35576
|
+
/** Get appropriate movement verb based on context */
|
|
35577
|
+
getMovementVerb(className, transitSecs) {
|
|
35578
|
+
if (className === 'car' || className === 'vehicle' || className === 'truck') {
|
|
35579
|
+
return transitSecs < 10 ? 'driving from' : 'moved from';
|
|
35580
|
+
}
|
|
35581
|
+
if (transitSecs < 5) {
|
|
35582
|
+
return 'walking from';
|
|
35583
|
+
}
|
|
35584
|
+
if (transitSecs < 30) {
|
|
35585
|
+
return 'moved from';
|
|
35586
|
+
}
|
|
35587
|
+
return 'traveled from';
|
|
35374
35588
|
}
|
|
35375
35589
|
/** Build path description from connection */
|
|
35376
35590
|
buildPathDescription(fromCamera, toCamera) {
|
|
@@ -35389,10 +35603,10 @@ class SpatialReasoningEngine {
|
|
|
35389
35603
|
}
|
|
35390
35604
|
return connection.name || undefined;
|
|
35391
35605
|
}
|
|
35392
|
-
/** Get LLM-enhanced description */
|
|
35606
|
+
/** Get LLM-enhanced description using ChatCompletion interface */
|
|
35393
35607
|
async getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject) {
|
|
35394
35608
|
const llm = await this.findLlmDevice();
|
|
35395
|
-
if (!llm)
|
|
35609
|
+
if (!llm || !llm.getChatCompletion)
|
|
35396
35610
|
return null;
|
|
35397
35611
|
try {
|
|
35398
35612
|
// Retrieve relevant context for RAG
|
|
@@ -35401,13 +35615,21 @@ class SpatialReasoningEngine {
|
|
|
35401
35615
|
const ragContext = relevantChunks.map(c => c.content).join('\n\n');
|
|
35402
35616
|
// Build the prompt
|
|
35403
35617
|
const prompt = this.buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext);
|
|
35404
|
-
// Call LLM
|
|
35405
|
-
const result = await llm.
|
|
35406
|
-
|
|
35618
|
+
// Call LLM using ChatCompletion interface
|
|
35619
|
+
const result = await llm.getChatCompletion({
|
|
35620
|
+
messages: [
|
|
35621
|
+
{
|
|
35622
|
+
role: 'user',
|
|
35623
|
+
content: prompt,
|
|
35624
|
+
},
|
|
35625
|
+
],
|
|
35626
|
+
max_tokens: 150,
|
|
35627
|
+
temperature: 0.7,
|
|
35407
35628
|
});
|
|
35408
|
-
// Extract description from result
|
|
35409
|
-
|
|
35410
|
-
|
|
35629
|
+
// Extract description from ChatCompletion result
|
|
35630
|
+
const content = result?.choices?.[0]?.message?.content;
|
|
35631
|
+
if (content && typeof content === 'string') {
|
|
35632
|
+
return content.trim();
|
|
35411
35633
|
}
|
|
35412
35634
|
return null;
|
|
35413
35635
|
}
|
|
@@ -35444,12 +35666,12 @@ Examples of good descriptions:
|
|
|
35444
35666
|
|
|
35445
35667
|
Generate ONLY the description, nothing else:`;
|
|
35446
35668
|
}
|
|
35447
|
-
/** Suggest a new landmark based on AI analysis */
|
|
35669
|
+
/** Suggest a new landmark based on AI analysis using ChatCompletion */
|
|
35448
35670
|
async suggestLandmark(cameraId, mediaObject, objectClass, position) {
|
|
35449
35671
|
if (!this.config.enableLandmarkLearning)
|
|
35450
35672
|
return null;
|
|
35451
35673
|
const llm = await this.findLlmDevice();
|
|
35452
|
-
if (!llm)
|
|
35674
|
+
if (!llm || !llm.getChatCompletion)
|
|
35453
35675
|
return null;
|
|
35454
35676
|
try {
|
|
35455
35677
|
const prompt = `Analyze this security camera image. A ${objectClass} was detected.
|
|
@@ -35464,12 +35686,21 @@ If you can identify a clear landmark feature, respond with ONLY a JSON object:
|
|
|
35464
35686
|
{"name": "Landmark Name", "type": "structure|feature|boundary|access|vehicle|neighbor|zone|street", "description": "Brief description"}
|
|
35465
35687
|
|
|
35466
35688
|
If no clear landmark is identifiable, respond with: {"name": null}`;
|
|
35467
|
-
|
|
35468
|
-
|
|
35689
|
+
// Call LLM using ChatCompletion interface
|
|
35690
|
+
const result = await llm.getChatCompletion({
|
|
35691
|
+
messages: [
|
|
35692
|
+
{
|
|
35693
|
+
role: 'user',
|
|
35694
|
+
content: prompt,
|
|
35695
|
+
},
|
|
35696
|
+
],
|
|
35697
|
+
max_tokens: 100,
|
|
35698
|
+
temperature: 0.3,
|
|
35469
35699
|
});
|
|
35470
|
-
|
|
35700
|
+
const content = result?.choices?.[0]?.message?.content;
|
|
35701
|
+
if (content && typeof content === 'string') {
|
|
35471
35702
|
try {
|
|
35472
|
-
const parsed = JSON.parse(
|
|
35703
|
+
const parsed = JSON.parse(content.trim());
|
|
35473
35704
|
if (parsed.name && parsed.type) {
|
|
35474
35705
|
const suggestionId = `suggest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
35475
35706
|
const suggestion = {
|
|
@@ -35947,24 +36178,52 @@ class TrackingEngine {
|
|
|
35947
36178
|
const tracked = this.state.createObject(globalId, sighting, isEntryPoint);
|
|
35948
36179
|
this.console.log(`New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
|
|
35949
36180
|
`(ID: ${globalId.slice(0, 8)})`);
|
|
35950
|
-
//
|
|
35951
|
-
//
|
|
35952
|
-
|
|
35953
|
-
|
|
35954
|
-
|
|
35955
|
-
|
|
36181
|
+
// Schedule loitering check - alert after object passes loitering threshold
|
|
36182
|
+
// This ensures we don't miss alerts for brief appearances while still filtering noise
|
|
36183
|
+
this.scheduleLoiteringAlert(globalId, sighting, isEntryPoint);
|
|
36184
|
+
}
|
|
36185
|
+
}
|
|
36186
|
+
/** Schedule an alert after loitering threshold passes */
|
|
36187
|
+
scheduleLoiteringAlert(globalId, sighting, isEntryPoint) {
|
|
36188
|
+
// Check after loitering threshold if object is still being tracked
|
|
36189
|
+
setTimeout(async () => {
|
|
36190
|
+
const tracked = this.state.getObject(globalId);
|
|
36191
|
+
if (!tracked || tracked.state !== 'active')
|
|
36192
|
+
return;
|
|
36193
|
+
// Check if we've already alerted for this object
|
|
36194
|
+
if (this.isInAlertCooldown(globalId))
|
|
36195
|
+
return;
|
|
36196
|
+
// Generate spatial description
|
|
36197
|
+
const spatialResult = this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
36198
|
+
if (isEntryPoint) {
|
|
36199
|
+
// Entry point - generate property entry alert
|
|
35956
36200
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
35957
36201
|
cameraId: sighting.cameraId,
|
|
35958
36202
|
cameraName: sighting.cameraName,
|
|
35959
36203
|
objectClass: sighting.detection.className,
|
|
35960
|
-
objectLabel: spatialResult
|
|
36204
|
+
objectLabel: spatialResult.description,
|
|
35961
36205
|
detectionId: sighting.detectionId,
|
|
35962
|
-
involvedLandmarks: spatialResult
|
|
35963
|
-
usedLlm: spatialResult
|
|
36206
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
36207
|
+
usedLlm: spatialResult.usedLlm,
|
|
35964
36208
|
});
|
|
35965
|
-
this.recordAlertTime(globalId);
|
|
35966
36209
|
}
|
|
35967
|
-
|
|
36210
|
+
else {
|
|
36211
|
+
// Non-entry point - still alert about activity using movement alert type
|
|
36212
|
+
// This notifies about any activity around the property using topology context
|
|
36213
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
36214
|
+
cameraId: sighting.cameraId,
|
|
36215
|
+
cameraName: sighting.cameraName,
|
|
36216
|
+
toCameraId: sighting.cameraId,
|
|
36217
|
+
toCameraName: sighting.cameraName,
|
|
36218
|
+
objectClass: sighting.detection.className,
|
|
36219
|
+
objectLabel: spatialResult.description, // Use spatial reasoning description (topology-based)
|
|
36220
|
+
detectionId: sighting.detectionId,
|
|
36221
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
36222
|
+
usedLlm: spatialResult.usedLlm,
|
|
36223
|
+
});
|
|
36224
|
+
}
|
|
36225
|
+
this.recordAlertTime(globalId);
|
|
36226
|
+
}, this.config.loiteringThreshold);
|
|
35968
36227
|
}
|
|
35969
36228
|
/** Attempt to correlate a sighting with existing tracked objects */
|
|
35970
36229
|
async correlateDetection(sighting) {
|
|
@@ -36010,12 +36269,16 @@ class TrackingEngine {
|
|
|
36010
36269
|
const current = this.state.getObject(tracked.globalId);
|
|
36011
36270
|
if (current && current.state === 'pending') {
|
|
36012
36271
|
this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
|
|
36013
|
-
|
|
36272
|
+
// Generate rich exit description using topology context
|
|
36273
|
+
const spatialResult = this.spatialReasoning.generateExitDescription(current, sighting.cameraId);
|
|
36274
|
+
this.console.log(`Object ${tracked.globalId.slice(0, 8)} exited: ${spatialResult.description}`);
|
|
36014
36275
|
await this.alertManager.checkAndAlert('property_exit', current, {
|
|
36015
36276
|
cameraId: sighting.cameraId,
|
|
36016
36277
|
cameraName: sighting.cameraName,
|
|
36017
36278
|
objectClass: current.className,
|
|
36018
|
-
objectLabel:
|
|
36279
|
+
objectLabel: spatialResult.description,
|
|
36280
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
36281
|
+
usedLlm: spatialResult.usedLlm,
|
|
36019
36282
|
});
|
|
36020
36283
|
}
|
|
36021
36284
|
this.pendingTimers.delete(tracked.globalId);
|
|
@@ -37450,6 +37713,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
|
37450
37713
|
exports.SpatialAwarenessPlugin = void 0;
|
|
37451
37714
|
const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
37452
37715
|
const storage_settings_1 = __webpack_require__(/*! @scrypted/sdk/storage-settings */ "./node_modules/@scrypted/sdk/dist/src/storage-settings.js");
|
|
37716
|
+
const fs = __importStar(__webpack_require__(/*! fs */ "fs"));
|
|
37717
|
+
const path = __importStar(__webpack_require__(/*! path */ "path"));
|
|
37453
37718
|
const topology_1 = __webpack_require__(/*! ./models/topology */ "./src/models/topology.ts");
|
|
37454
37719
|
const alert_1 = __webpack_require__(/*! ./models/alert */ "./src/models/alert.ts");
|
|
37455
37720
|
const tracking_state_1 = __webpack_require__(/*! ./state/tracking-state */ "./src/state/tracking-state.ts");
|
|
@@ -37460,7 +37725,7 @@ const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./sr
|
|
|
37460
37725
|
const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
|
|
37461
37726
|
const editor_html_1 = __webpack_require__(/*! ./ui/editor-html */ "./src/ui/editor-html.ts");
|
|
37462
37727
|
const training_html_1 = __webpack_require__(/*! ./ui/training-html */ "./src/ui/training-html.ts");
|
|
37463
|
-
const { deviceManager, systemManager } = sdk_1.default;
|
|
37728
|
+
const { deviceManager, systemManager, mediaManager } = sdk_1.default;
|
|
37464
37729
|
const TRACKING_ZONE_PREFIX = 'tracking-zone:';
|
|
37465
37730
|
const GLOBAL_TRACKER_ID = 'global-tracker';
|
|
37466
37731
|
class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
@@ -37494,8 +37759,8 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37494
37759
|
correlationThreshold: {
|
|
37495
37760
|
title: 'Correlation Confidence Threshold',
|
|
37496
37761
|
type: 'number',
|
|
37497
|
-
defaultValue: 0.
|
|
37498
|
-
description: 'Minimum confidence (0-1) for automatic object correlation',
|
|
37762
|
+
defaultValue: 0.35,
|
|
37763
|
+
description: 'Minimum confidence (0-1) for automatic object correlation. Lower values allow more matches.',
|
|
37499
37764
|
group: 'Tracking',
|
|
37500
37765
|
},
|
|
37501
37766
|
lostTimeout: {
|
|
@@ -37722,7 +37987,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37722
37987
|
}
|
|
37723
37988
|
const config = {
|
|
37724
37989
|
correlationWindow: (this.storageSettings.values.correlationWindow || 30) * 1000,
|
|
37725
|
-
correlationThreshold: this.storageSettings.values.correlationThreshold || 0.
|
|
37990
|
+
correlationThreshold: this.storageSettings.values.correlationThreshold || 0.35,
|
|
37726
37991
|
lostTimeout: (this.storageSettings.values.lostTimeout || 300) * 1000,
|
|
37727
37992
|
useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
|
|
37728
37993
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
|
|
@@ -38373,15 +38638,37 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
38373
38638
|
});
|
|
38374
38639
|
}
|
|
38375
38640
|
}
|
|
38376
|
-
|
|
38641
|
+
async getFloorPlanPath() {
|
|
38642
|
+
// Use mediaManager.getFilesPath() for proper persistent storage
|
|
38643
|
+
const filesPath = await mediaManager.getFilesPath();
|
|
38644
|
+
this.console.log('Files path from mediaManager:', filesPath);
|
|
38645
|
+
// Ensure directory exists
|
|
38646
|
+
if (!fs.existsSync(filesPath)) {
|
|
38647
|
+
fs.mkdirSync(filesPath, { recursive: true });
|
|
38648
|
+
}
|
|
38649
|
+
return path.join(filesPath, 'floorplan.jpg');
|
|
38650
|
+
}
|
|
38651
|
+
async handleFloorPlanRequest(request, response) {
|
|
38377
38652
|
if (request.method === 'GET') {
|
|
38378
|
-
|
|
38379
|
-
|
|
38380
|
-
|
|
38381
|
-
|
|
38382
|
-
|
|
38653
|
+
try {
|
|
38654
|
+
const floorPlanPath = await this.getFloorPlanPath();
|
|
38655
|
+
this.console.log('Loading floor plan from:', floorPlanPath, 'exists:', fs.existsSync(floorPlanPath));
|
|
38656
|
+
if (fs.existsSync(floorPlanPath)) {
|
|
38657
|
+
const imageBuffer = fs.readFileSync(floorPlanPath);
|
|
38658
|
+
const imageData = 'data:image/jpeg;base64,' + imageBuffer.toString('base64');
|
|
38659
|
+
this.console.log('Floor plan loaded, size:', imageBuffer.length);
|
|
38660
|
+
response.send(JSON.stringify({ imageData }), {
|
|
38661
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38662
|
+
});
|
|
38663
|
+
}
|
|
38664
|
+
else {
|
|
38665
|
+
response.send(JSON.stringify({ imageData: null }), {
|
|
38666
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38667
|
+
});
|
|
38668
|
+
}
|
|
38383
38669
|
}
|
|
38384
|
-
|
|
38670
|
+
catch (e) {
|
|
38671
|
+
this.console.error('Failed to read floor plan:', e);
|
|
38385
38672
|
response.send(JSON.stringify({ imageData: null }), {
|
|
38386
38673
|
headers: { 'Content-Type': 'application/json' },
|
|
38387
38674
|
});
|
|
@@ -38390,14 +38677,21 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
38390
38677
|
else if (request.method === 'POST') {
|
|
38391
38678
|
try {
|
|
38392
38679
|
const body = JSON.parse(request.body);
|
|
38393
|
-
|
|
38680
|
+
const imageData = body.imageData;
|
|
38681
|
+
// Extract base64 data (remove data:image/xxx;base64, prefix)
|
|
38682
|
+
const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
|
|
38683
|
+
const imageBuffer = Buffer.from(base64Data, 'base64');
|
|
38684
|
+
const floorPlanPath = await this.getFloorPlanPath();
|
|
38685
|
+
fs.writeFileSync(floorPlanPath, imageBuffer);
|
|
38686
|
+
this.console.log('Floor plan saved to:', floorPlanPath, 'size:', imageBuffer.length);
|
|
38394
38687
|
response.send(JSON.stringify({ success: true }), {
|
|
38395
38688
|
headers: { 'Content-Type': 'application/json' },
|
|
38396
38689
|
});
|
|
38397
38690
|
}
|
|
38398
38691
|
catch (e) {
|
|
38399
|
-
|
|
38400
|
-
|
|
38692
|
+
this.console.error('Failed to save floor plan:', e);
|
|
38693
|
+
response.send(JSON.stringify({ error: 'Failed to save floor plan' }), {
|
|
38694
|
+
code: 500,
|
|
38401
38695
|
headers: { 'Content-Type': 'application/json' },
|
|
38402
38696
|
});
|
|
38403
38697
|
}
|
|
@@ -39031,26 +39325,53 @@ function generateAlertMessage(type, details) {
|
|
|
39031
39325
|
: capitalize(details.objectClass || '');
|
|
39032
39326
|
switch (type) {
|
|
39033
39327
|
case 'property_entry':
|
|
39034
|
-
|
|
39328
|
+
// Use the rich description from spatial reasoning if available
|
|
39329
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
39330
|
+
return details.objectLabel;
|
|
39331
|
+
}
|
|
39332
|
+
// Fallback to basic description
|
|
39333
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
39334
|
+
return `${objectDesc} entered property near ${details.involvedLandmarks[0]}`;
|
|
39335
|
+
}
|
|
39336
|
+
return `${objectDesc} entered property at ${details.cameraName || 'entrance'}`;
|
|
39035
39337
|
case 'property_exit':
|
|
39036
|
-
|
|
39338
|
+
// Use the rich description from spatial reasoning if available
|
|
39339
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
39340
|
+
return details.objectLabel;
|
|
39341
|
+
}
|
|
39342
|
+
// Fallback to basic description
|
|
39343
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
39344
|
+
return `${objectDesc} left property via ${details.involvedLandmarks[0]}`;
|
|
39345
|
+
}
|
|
39346
|
+
return `${objectDesc} left property`;
|
|
39037
39347
|
case 'movement':
|
|
39038
|
-
// If
|
|
39039
|
-
if (details.objectLabel && details.
|
|
39348
|
+
// If objectLabel contains a full description, use it directly
|
|
39349
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
39350
|
+
// Check if this is a cross-camera movement or initial detection
|
|
39351
|
+
if (details.fromCameraId && details.fromCameraId !== details.toCameraId && details.transitTime) {
|
|
39352
|
+
const transitSecs = Math.round(details.transitTime / 1000);
|
|
39353
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
39354
|
+
const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
|
|
39355
|
+
return `${details.objectLabel}${pathContext}${transitStr}`;
|
|
39356
|
+
}
|
|
39357
|
+
// Initial detection - use the label directly
|
|
39358
|
+
return details.objectLabel;
|
|
39359
|
+
}
|
|
39360
|
+
// Cross-camera movement with basic info
|
|
39361
|
+
if (details.fromCameraId && details.fromCameraId !== details.toCameraId) {
|
|
39040
39362
|
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
39041
|
-
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
39042
|
-
|
|
39043
|
-
|
|
39044
|
-
|
|
39045
|
-
|
|
39046
|
-
|
|
39047
|
-
|
|
39048
|
-
|
|
39049
|
-
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
39363
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
39364
|
+
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
39365
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
39366
|
+
movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
|
|
39367
|
+
}
|
|
39368
|
+
return `${movementDesc}${transitStr}`;
|
|
39369
|
+
}
|
|
39370
|
+
// Initial detection without full label
|
|
39050
39371
|
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
39051
|
-
|
|
39372
|
+
return `${objectDesc} detected near ${details.involvedLandmarks[0]}`;
|
|
39052
39373
|
}
|
|
39053
|
-
return `${
|
|
39374
|
+
return `${objectDesc} detected at ${details.cameraName || 'camera'}`;
|
|
39054
39375
|
case 'unusual_path':
|
|
39055
39376
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
39056
39377
|
case 'dwell_time':
|
|
@@ -40120,10 +40441,27 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
40120
40441
|
if (response.ok) {
|
|
40121
40442
|
topology = await response.json();
|
|
40122
40443
|
if (!topology.drawings) topology.drawings = [];
|
|
40444
|
+
// Load floor plan from separate storage (handles legacy imageData in topology too)
|
|
40123
40445
|
if (topology.floorPlan?.imageData) {
|
|
40446
|
+
// Legacy: imageData was stored in topology
|
|
40124
40447
|
await loadFloorPlanImage(topology.floorPlan.imageData);
|
|
40125
40448
|
} else if (topology.floorPlan?.type === 'blank') {
|
|
40126
40449
|
blankCanvasMode = true;
|
|
40450
|
+
} else {
|
|
40451
|
+
// Always try to load from floor-plan endpoint (handles uploaded and missing cases)
|
|
40452
|
+
try {
|
|
40453
|
+
const fpResponse = await fetch('../api/floor-plan');
|
|
40454
|
+
if (fpResponse.ok) {
|
|
40455
|
+
const fpData = await fpResponse.json();
|
|
40456
|
+
if (fpData.imageData) {
|
|
40457
|
+
await loadFloorPlanImage(fpData.imageData);
|
|
40458
|
+
// Update topology reference if not set
|
|
40459
|
+
if (!topology.floorPlan || topology.floorPlan.type !== 'uploaded') {
|
|
40460
|
+
topology.floorPlan = { type: 'uploaded', width: floorPlanImage.width, height: floorPlanImage.height };
|
|
40461
|
+
}
|
|
40462
|
+
}
|
|
40463
|
+
}
|
|
40464
|
+
} catch (err) { console.error('Failed to load floor plan:', err); }
|
|
40127
40465
|
}
|
|
40128
40466
|
}
|
|
40129
40467
|
} catch (e) { console.error('Failed to load topology:', e); }
|
|
@@ -40747,16 +41085,84 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
40747
41085
|
|
|
40748
41086
|
function uploadFloorPlan() { document.getElementById('upload-modal').classList.add('active'); }
|
|
40749
41087
|
|
|
41088
|
+
// Compress and resize image to avoid 413 errors (Scrypted has ~50KB limit)
|
|
41089
|
+
function compressImage(img, maxSize = 800, quality = 0.5) {
|
|
41090
|
+
return new Promise((resolve) => {
|
|
41091
|
+
const canvas = document.createElement('canvas');
|
|
41092
|
+
let width = img.width;
|
|
41093
|
+
let height = img.height;
|
|
41094
|
+
|
|
41095
|
+
// Always resize to fit within maxSize
|
|
41096
|
+
if (width > maxSize || height > maxSize) {
|
|
41097
|
+
if (width > height) {
|
|
41098
|
+
height = Math.round(height * maxSize / width);
|
|
41099
|
+
width = maxSize;
|
|
41100
|
+
} else {
|
|
41101
|
+
width = Math.round(width * maxSize / height);
|
|
41102
|
+
height = maxSize;
|
|
41103
|
+
}
|
|
41104
|
+
}
|
|
41105
|
+
|
|
41106
|
+
canvas.width = width;
|
|
41107
|
+
canvas.height = height;
|
|
41108
|
+
const ctx = canvas.getContext('2d');
|
|
41109
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
41110
|
+
|
|
41111
|
+
// Convert to JPEG with aggressive compression
|
|
41112
|
+
let compressed = canvas.toDataURL('image/jpeg', quality);
|
|
41113
|
+
console.log('Compressed image from', img.width, 'x', img.height, 'to', width, 'x', height, 'size:', Math.round(compressed.length / 1024), 'KB');
|
|
41114
|
+
|
|
41115
|
+
// If still too large, compress more
|
|
41116
|
+
let q = quality;
|
|
41117
|
+
while (compressed.length > 50000 && q > 0.1) {
|
|
41118
|
+
q -= 0.1;
|
|
41119
|
+
compressed = canvas.toDataURL('image/jpeg', q);
|
|
41120
|
+
console.log('Re-compressed at quality', q.toFixed(1), 'size:', Math.round(compressed.length / 1024), 'KB');
|
|
41121
|
+
}
|
|
41122
|
+
|
|
41123
|
+
resolve(compressed);
|
|
41124
|
+
});
|
|
41125
|
+
}
|
|
41126
|
+
|
|
40750
41127
|
async function handleFloorPlanUpload(event) {
|
|
40751
41128
|
const file = event.target.files[0];
|
|
40752
41129
|
if (!file) return;
|
|
40753
41130
|
const reader = new FileReader();
|
|
40754
41131
|
reader.onload = async (e) => {
|
|
40755
|
-
const
|
|
40756
|
-
|
|
40757
|
-
|
|
40758
|
-
|
|
40759
|
-
|
|
41132
|
+
const originalData = e.target.result;
|
|
41133
|
+
|
|
41134
|
+
// Load image to get dimensions
|
|
41135
|
+
const img = new Image();
|
|
41136
|
+
img.onload = async () => {
|
|
41137
|
+
// Compress image to reduce size
|
|
41138
|
+
const imageData = await compressImage(img);
|
|
41139
|
+
await loadFloorPlanImage(imageData);
|
|
41140
|
+
|
|
41141
|
+
// Store floor plan separately via API
|
|
41142
|
+
try {
|
|
41143
|
+
setStatus('Uploading floor plan...', 'warning');
|
|
41144
|
+
const response = await fetch('../api/floor-plan', {
|
|
41145
|
+
method: 'POST',
|
|
41146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41147
|
+
body: JSON.stringify({ imageData })
|
|
41148
|
+
});
|
|
41149
|
+
if (response.ok) {
|
|
41150
|
+
setStatus('Floor plan saved', 'success');
|
|
41151
|
+
} else {
|
|
41152
|
+
setStatus('Failed to save floor plan: ' + response.status, 'error');
|
|
41153
|
+
console.error('Floor plan upload failed:', response.status, response.statusText);
|
|
41154
|
+
}
|
|
41155
|
+
} catch (err) {
|
|
41156
|
+
console.error('Failed to save floor plan:', err);
|
|
41157
|
+
setStatus('Failed to save floor plan', 'error');
|
|
41158
|
+
}
|
|
41159
|
+
|
|
41160
|
+
// Store reference in topology (without the large imageData)
|
|
41161
|
+
topology.floorPlan = { type: 'uploaded', width: floorPlanImage.width, height: floorPlanImage.height };
|
|
41162
|
+
closeModal('upload-modal');
|
|
41163
|
+
render();
|
|
41164
|
+
};
|
|
41165
|
+
img.src = originalData;
|
|
40760
41166
|
};
|
|
40761
41167
|
reader.readAsDataURL(file);
|
|
40762
41168
|
}
|
|
@@ -40916,7 +41322,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
40916
41322
|
function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
|
|
40917
41323
|
function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
|
|
40918
41324
|
function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
|
|
40919
|
-
function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
|
|
41325
|
+
function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateCameraSelects(); updateUI(); render(); }
|
|
40920
41326
|
function deleteConnection(id) { if (!confirm('Delete this connection?')) return; topology.connections = topology.connections.filter(c => c.id !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
|
|
40921
41327
|
function setTool(tool) {
|
|
40922
41328
|
currentTool = tool;
|
|
@@ -42192,6 +42598,17 @@ module.exports = require("os");
|
|
|
42192
42598
|
|
|
42193
42599
|
/***/ },
|
|
42194
42600
|
|
|
42601
|
+
/***/ "path"
|
|
42602
|
+
/*!***********************!*\
|
|
42603
|
+
!*** external "path" ***!
|
|
42604
|
+
\***********************/
|
|
42605
|
+
(module) {
|
|
42606
|
+
|
|
42607
|
+
"use strict";
|
|
42608
|
+
module.exports = require("path");
|
|
42609
|
+
|
|
42610
|
+
/***/ },
|
|
42611
|
+
|
|
42195
42612
|
/***/ "stream"
|
|
42196
42613
|
/*!*************************!*\
|
|
42197
42614
|
!*** external "stream" ***!
|