@blueharford/scrypted-spatial-awareness 0.4.7 → 0.5.0-beta
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 +156 -0
- package/package.json +1 -1
- package/src/core/object-correlator.ts +32 -7
- package/src/core/spatial-reasoning.ts +372 -45
- package/src/core/topology-discovery.ts +641 -0
- package/src/core/tracking-engine.ts +57 -19
- package/src/main.ts +289 -1
- package/src/models/alert.ts +41 -14
- package/src/models/discovery.ts +210 -0
- package/src/models/topology.ts +53 -0
- package/src/ui/editor-html.ts +467 -1
- package/dist/main.nodejs.js +0 -3
- package/dist/main.nodejs.js.LICENSE.txt +0 -1
- package/dist/main.nodejs.js.map +0 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +0 -42336
- package/out/main.nodejs.js.map +0 -1
|
@@ -516,31 +516,61 @@ export class TrackingEngine {
|
|
|
516
516
|
`(ID: ${globalId.slice(0, 8)})`
|
|
517
517
|
);
|
|
518
518
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
519
|
+
// Schedule loitering check - alert after object passes loitering threshold
|
|
520
|
+
// This ensures we don't miss alerts for brief appearances while still filtering noise
|
|
521
|
+
this.scheduleLoiteringAlert(globalId, sighting, isEntryPoint);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Schedule an alert after loitering threshold passes */
|
|
526
|
+
private scheduleLoiteringAlert(
|
|
527
|
+
globalId: GlobalTrackingId,
|
|
528
|
+
sighting: ObjectSighting,
|
|
529
|
+
isEntryPoint: boolean
|
|
530
|
+
): void {
|
|
531
|
+
// Check after loitering threshold if object is still being tracked
|
|
532
|
+
setTimeout(async () => {
|
|
533
|
+
const tracked = this.state.getObject(globalId);
|
|
534
|
+
if (!tracked || tracked.state !== 'active') return;
|
|
535
|
+
|
|
536
|
+
// Check if we've already alerted for this object
|
|
537
|
+
if (this.isInAlertCooldown(globalId)) return;
|
|
538
|
+
|
|
539
|
+
// Generate spatial description
|
|
540
|
+
const spatialResult = this.spatialReasoning.generateEntryDescription(
|
|
541
|
+
tracked,
|
|
542
|
+
sighting.cameraId
|
|
543
|
+
);
|
|
530
544
|
|
|
545
|
+
if (isEntryPoint) {
|
|
546
|
+
// Entry point - generate property entry alert
|
|
531
547
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
532
548
|
cameraId: sighting.cameraId,
|
|
533
549
|
cameraName: sighting.cameraName,
|
|
534
550
|
objectClass: sighting.detection.className,
|
|
535
|
-
objectLabel: spatialResult
|
|
551
|
+
objectLabel: spatialResult.description,
|
|
536
552
|
detectionId: sighting.detectionId,
|
|
537
|
-
involvedLandmarks: spatialResult
|
|
538
|
-
usedLlm: spatialResult
|
|
553
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
554
|
+
usedLlm: spatialResult.usedLlm,
|
|
555
|
+
});
|
|
556
|
+
} else {
|
|
557
|
+
// Non-entry point - still alert about activity using movement alert type
|
|
558
|
+
// This notifies about any activity around the property using topology context
|
|
559
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
560
|
+
cameraId: sighting.cameraId,
|
|
561
|
+
cameraName: sighting.cameraName,
|
|
562
|
+
toCameraId: sighting.cameraId,
|
|
563
|
+
toCameraName: sighting.cameraName,
|
|
564
|
+
objectClass: sighting.detection.className,
|
|
565
|
+
objectLabel: spatialResult.description, // Use spatial reasoning description (topology-based)
|
|
566
|
+
detectionId: sighting.detectionId,
|
|
567
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
568
|
+
usedLlm: spatialResult.usedLlm,
|
|
539
569
|
});
|
|
540
|
-
|
|
541
|
-
this.recordAlertTime(globalId);
|
|
542
570
|
}
|
|
543
|
-
|
|
571
|
+
|
|
572
|
+
this.recordAlertTime(globalId);
|
|
573
|
+
}, this.config.loiteringThreshold);
|
|
544
574
|
}
|
|
545
575
|
|
|
546
576
|
/** Attempt to correlate a sighting with existing tracked objects */
|
|
@@ -596,15 +626,23 @@ export class TrackingEngine {
|
|
|
596
626
|
if (current && current.state === 'pending') {
|
|
597
627
|
this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
|
|
598
628
|
|
|
629
|
+
// Generate rich exit description using topology context
|
|
630
|
+
const spatialResult = this.spatialReasoning.generateExitDescription(
|
|
631
|
+
current,
|
|
632
|
+
sighting.cameraId
|
|
633
|
+
);
|
|
634
|
+
|
|
599
635
|
this.console.log(
|
|
600
|
-
`Object ${tracked.globalId.slice(0, 8)} exited
|
|
636
|
+
`Object ${tracked.globalId.slice(0, 8)} exited: ${spatialResult.description}`
|
|
601
637
|
);
|
|
602
638
|
|
|
603
639
|
await this.alertManager.checkAndAlert('property_exit', current, {
|
|
604
640
|
cameraId: sighting.cameraId,
|
|
605
641
|
cameraName: sighting.cameraName,
|
|
606
642
|
objectClass: current.className,
|
|
607
|
-
objectLabel:
|
|
643
|
+
objectLabel: spatialResult.description,
|
|
644
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
645
|
+
usedLlm: spatialResult.usedLlm,
|
|
608
646
|
});
|
|
609
647
|
}
|
|
610
648
|
this.pendingTimers.delete(tracked.globalId);
|
package/src/main.ts
CHANGED
|
@@ -36,6 +36,8 @@ import { MqttPublisher, MqttConfig } from './integrations/mqtt-publisher';
|
|
|
36
36
|
import { EDITOR_HTML } from './ui/editor-html';
|
|
37
37
|
import { TRAINING_HTML } from './ui/training-html';
|
|
38
38
|
import { TrainingConfig, TrainingLandmark } from './models/training';
|
|
39
|
+
import { TopologyDiscoveryEngine } from './core/topology-discovery';
|
|
40
|
+
import { DiscoveryConfig, DiscoverySuggestion } from './models/discovery';
|
|
39
41
|
|
|
40
42
|
const { deviceManager, systemManager, mediaManager } = sdk;
|
|
41
43
|
|
|
@@ -49,6 +51,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
49
51
|
private trackingState: TrackingState;
|
|
50
52
|
private alertManager: AlertManager;
|
|
51
53
|
private mqttPublisher: MqttPublisher | null = null;
|
|
54
|
+
private discoveryEngine: TopologyDiscoveryEngine | null = null;
|
|
52
55
|
private devices: Map<string, any> = new Map();
|
|
53
56
|
|
|
54
57
|
storageSettings = new StorageSettings(this, {
|
|
@@ -169,6 +172,36 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
169
172
|
group: 'AI & Spatial Reasoning',
|
|
170
173
|
},
|
|
171
174
|
|
|
175
|
+
// Auto-Topology Discovery Settings
|
|
176
|
+
discoveryIntervalHours: {
|
|
177
|
+
title: 'Auto-Discovery Interval (hours)',
|
|
178
|
+
type: 'number',
|
|
179
|
+
defaultValue: 0,
|
|
180
|
+
description: 'Automatically scan cameras to discover landmarks and connections. Set to 0 to disable. Uses vision LLM to analyze camera views.',
|
|
181
|
+
group: 'Auto-Topology Discovery',
|
|
182
|
+
},
|
|
183
|
+
minLandmarkConfidence: {
|
|
184
|
+
title: 'Min Landmark Confidence',
|
|
185
|
+
type: 'number',
|
|
186
|
+
defaultValue: 0.6,
|
|
187
|
+
description: 'Minimum confidence (0-1) for discovered landmarks',
|
|
188
|
+
group: 'Auto-Topology Discovery',
|
|
189
|
+
},
|
|
190
|
+
minConnectionConfidence: {
|
|
191
|
+
title: 'Min Connection Confidence',
|
|
192
|
+
type: 'number',
|
|
193
|
+
defaultValue: 0.5,
|
|
194
|
+
description: 'Minimum confidence (0-1) for suggested camera connections',
|
|
195
|
+
group: 'Auto-Topology Discovery',
|
|
196
|
+
},
|
|
197
|
+
autoAcceptThreshold: {
|
|
198
|
+
title: 'Auto-Accept Threshold',
|
|
199
|
+
type: 'number',
|
|
200
|
+
defaultValue: 0.85,
|
|
201
|
+
description: 'Suggestions above this confidence are automatically accepted (0-1)',
|
|
202
|
+
group: 'Auto-Topology Discovery',
|
|
203
|
+
},
|
|
204
|
+
|
|
172
205
|
// MQTT Settings
|
|
173
206
|
enableMqtt: {
|
|
174
207
|
title: 'Enable MQTT',
|
|
@@ -355,6 +388,33 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
355
388
|
|
|
356
389
|
await this.trackingEngine.startTracking();
|
|
357
390
|
this.console.log('Tracking engine started');
|
|
391
|
+
|
|
392
|
+
// Initialize or update discovery engine
|
|
393
|
+
await this.initializeDiscoveryEngine(topology);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async initializeDiscoveryEngine(topology: CameraTopology): Promise<void> {
|
|
397
|
+
const discoveryConfig: DiscoveryConfig = {
|
|
398
|
+
discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours as number ?? 0,
|
|
399
|
+
autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold as number ?? 0.85,
|
|
400
|
+
minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence as number ?? 0.6,
|
|
401
|
+
minConnectionConfidence: this.storageSettings.values.minConnectionConfidence as number ?? 0.5,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
if (this.discoveryEngine) {
|
|
405
|
+
// Update existing engine
|
|
406
|
+
this.discoveryEngine.updateConfig(discoveryConfig);
|
|
407
|
+
this.discoveryEngine.updateTopology(topology);
|
|
408
|
+
} else {
|
|
409
|
+
// Create new engine
|
|
410
|
+
this.discoveryEngine = new TopologyDiscoveryEngine(discoveryConfig, this.console);
|
|
411
|
+
this.discoveryEngine.updateTopology(topology);
|
|
412
|
+
|
|
413
|
+
// Start periodic discovery if enabled
|
|
414
|
+
if (discoveryConfig.discoveryIntervalHours > 0) {
|
|
415
|
+
this.discoveryEngine.startPeriodicDiscovery();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
358
418
|
}
|
|
359
419
|
|
|
360
420
|
// ==================== DeviceProvider Implementation ====================
|
|
@@ -874,6 +934,27 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
874
934
|
return this.handleTrainingApplyRequest(response);
|
|
875
935
|
}
|
|
876
936
|
|
|
937
|
+
// Discovery endpoints
|
|
938
|
+
if (path.endsWith('/api/discovery/scan')) {
|
|
939
|
+
return this.handleDiscoveryScanRequest(response);
|
|
940
|
+
}
|
|
941
|
+
if (path.endsWith('/api/discovery/status')) {
|
|
942
|
+
return this.handleDiscoveryStatusRequest(response);
|
|
943
|
+
}
|
|
944
|
+
if (path.endsWith('/api/discovery/suggestions')) {
|
|
945
|
+
return this.handleDiscoverySuggestionsRequest(response);
|
|
946
|
+
}
|
|
947
|
+
if (path.match(/\/api\/discovery\/suggestions\/[\w-]+\/(accept|reject)$/)) {
|
|
948
|
+
const parts = path.split('/');
|
|
949
|
+
const action = parts.pop()!;
|
|
950
|
+
const suggestionId = parts.pop()!;
|
|
951
|
+
return this.handleDiscoverySuggestionActionRequest(suggestionId, action, response);
|
|
952
|
+
}
|
|
953
|
+
if (path.match(/\/api\/discovery\/camera\/[\w-]+$/)) {
|
|
954
|
+
const cameraId = path.split('/').pop()!;
|
|
955
|
+
return this.handleDiscoveryCameraAnalysisRequest(cameraId, response);
|
|
956
|
+
}
|
|
957
|
+
|
|
877
958
|
// UI Routes
|
|
878
959
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
879
960
|
return this.serveEditorUI(response);
|
|
@@ -890,7 +971,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
890
971
|
// Default: return info page
|
|
891
972
|
response.send(JSON.stringify({
|
|
892
973
|
name: 'Spatial Awareness Plugin',
|
|
893
|
-
version: '0.
|
|
974
|
+
version: '0.5.0-beta',
|
|
894
975
|
endpoints: {
|
|
895
976
|
api: {
|
|
896
977
|
trackedObjects: '/api/tracked-objects',
|
|
@@ -911,6 +992,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
911
992
|
landmark: '/api/training/landmark',
|
|
912
993
|
apply: '/api/training/apply',
|
|
913
994
|
},
|
|
995
|
+
discovery: {
|
|
996
|
+
scan: '/api/discovery/scan',
|
|
997
|
+
status: '/api/discovery/status',
|
|
998
|
+
suggestions: '/api/discovery/suggestions',
|
|
999
|
+
camera: '/api/discovery/camera/{cameraId}',
|
|
1000
|
+
},
|
|
914
1001
|
},
|
|
915
1002
|
ui: {
|
|
916
1003
|
editor: '/ui/editor',
|
|
@@ -1581,6 +1668,207 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1581
1668
|
});
|
|
1582
1669
|
}
|
|
1583
1670
|
|
|
1671
|
+
// ==================== Discovery Handlers ====================
|
|
1672
|
+
|
|
1673
|
+
private async handleDiscoveryScanRequest(response: HttpResponse): Promise<void> {
|
|
1674
|
+
if (!this.discoveryEngine) {
|
|
1675
|
+
response.send(JSON.stringify({ error: 'Discovery engine not initialized. Configure topology first.' }), {
|
|
1676
|
+
code: 500,
|
|
1677
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1678
|
+
});
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
try {
|
|
1683
|
+
this.console.log('[Discovery] Manual scan triggered via API');
|
|
1684
|
+
const correlation = await this.discoveryEngine.runFullDiscovery();
|
|
1685
|
+
const status = this.discoveryEngine.getStatus();
|
|
1686
|
+
const suggestions = this.discoveryEngine.getPendingSuggestions();
|
|
1687
|
+
|
|
1688
|
+
response.send(JSON.stringify({
|
|
1689
|
+
success: true,
|
|
1690
|
+
status,
|
|
1691
|
+
correlation,
|
|
1692
|
+
suggestions,
|
|
1693
|
+
}), {
|
|
1694
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1695
|
+
});
|
|
1696
|
+
} catch (e) {
|
|
1697
|
+
this.console.error('[Discovery] Scan failed:', e);
|
|
1698
|
+
response.send(JSON.stringify({ error: `Scan failed: ${(e as Error).message}` }), {
|
|
1699
|
+
code: 500,
|
|
1700
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
private handleDiscoveryStatusRequest(response: HttpResponse): void {
|
|
1706
|
+
if (!this.discoveryEngine) {
|
|
1707
|
+
response.send(JSON.stringify({
|
|
1708
|
+
isRunning: false,
|
|
1709
|
+
isScanning: false,
|
|
1710
|
+
lastScanTime: null,
|
|
1711
|
+
nextScanTime: null,
|
|
1712
|
+
camerasAnalyzed: 0,
|
|
1713
|
+
pendingSuggestions: 0,
|
|
1714
|
+
}), {
|
|
1715
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1716
|
+
});
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const status = this.discoveryEngine.getStatus();
|
|
1721
|
+
response.send(JSON.stringify(status), {
|
|
1722
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private handleDiscoverySuggestionsRequest(response: HttpResponse): void {
|
|
1727
|
+
if (!this.discoveryEngine) {
|
|
1728
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
1729
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1730
|
+
});
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const suggestions = this.discoveryEngine.getPendingSuggestions();
|
|
1735
|
+
response.send(JSON.stringify({ suggestions }), {
|
|
1736
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
private handleDiscoverySuggestionActionRequest(
|
|
1741
|
+
suggestionId: string,
|
|
1742
|
+
action: string,
|
|
1743
|
+
response: HttpResponse
|
|
1744
|
+
): void {
|
|
1745
|
+
if (!this.discoveryEngine) {
|
|
1746
|
+
response.send(JSON.stringify({ error: 'Discovery engine not initialized' }), {
|
|
1747
|
+
code: 500,
|
|
1748
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1749
|
+
});
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
if (action === 'accept') {
|
|
1754
|
+
const suggestion = this.discoveryEngine.acceptSuggestion(suggestionId);
|
|
1755
|
+
if (suggestion) {
|
|
1756
|
+
// Apply accepted suggestion to topology
|
|
1757
|
+
this.applyDiscoverySuggestion(suggestion);
|
|
1758
|
+
response.send(JSON.stringify({ success: true, suggestion }), {
|
|
1759
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1760
|
+
});
|
|
1761
|
+
} else {
|
|
1762
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1763
|
+
code: 404,
|
|
1764
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
} else if (action === 'reject') {
|
|
1768
|
+
const success = this.discoveryEngine.rejectSuggestion(suggestionId);
|
|
1769
|
+
if (success) {
|
|
1770
|
+
response.send(JSON.stringify({ success: true }), {
|
|
1771
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1772
|
+
});
|
|
1773
|
+
} else {
|
|
1774
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
1775
|
+
code: 404,
|
|
1776
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
} else {
|
|
1780
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
1781
|
+
code: 400,
|
|
1782
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private async handleDiscoveryCameraAnalysisRequest(
|
|
1788
|
+
cameraId: string,
|
|
1789
|
+
response: HttpResponse
|
|
1790
|
+
): Promise<void> {
|
|
1791
|
+
if (!this.discoveryEngine) {
|
|
1792
|
+
response.send(JSON.stringify({ error: 'Discovery engine not initialized' }), {
|
|
1793
|
+
code: 500,
|
|
1794
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1795
|
+
});
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
try {
|
|
1800
|
+
const analysis = await this.discoveryEngine.analyzeScene(cameraId);
|
|
1801
|
+
response.send(JSON.stringify(analysis), {
|
|
1802
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1803
|
+
});
|
|
1804
|
+
} catch (e) {
|
|
1805
|
+
response.send(JSON.stringify({ error: `Analysis failed: ${(e as Error).message}` }), {
|
|
1806
|
+
code: 500,
|
|
1807
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
private applyDiscoverySuggestion(suggestion: DiscoverySuggestion): void {
|
|
1813
|
+
if (!this.trackingEngine) return;
|
|
1814
|
+
|
|
1815
|
+
const topology = this.trackingEngine.getTopology();
|
|
1816
|
+
let updated = false;
|
|
1817
|
+
|
|
1818
|
+
if (suggestion.type === 'landmark' && suggestion.landmark) {
|
|
1819
|
+
// Add new landmark to topology
|
|
1820
|
+
const landmark: Landmark = {
|
|
1821
|
+
id: `landmark_${Date.now()}`,
|
|
1822
|
+
name: suggestion.landmark.name!,
|
|
1823
|
+
type: suggestion.landmark.type!,
|
|
1824
|
+
position: suggestion.landmark.position || { x: 0, y: 0 },
|
|
1825
|
+
description: suggestion.landmark.description,
|
|
1826
|
+
visibleFromCameras: suggestion.landmark.visibleFromCameras,
|
|
1827
|
+
aiSuggested: true,
|
|
1828
|
+
aiConfidence: suggestion.confidence,
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
if (!topology.landmarks) {
|
|
1832
|
+
topology.landmarks = [];
|
|
1833
|
+
}
|
|
1834
|
+
topology.landmarks.push(landmark);
|
|
1835
|
+
updated = true;
|
|
1836
|
+
|
|
1837
|
+
this.console.log(`[Discovery] Added landmark: ${landmark.name}`);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (suggestion.type === 'connection' && suggestion.connection) {
|
|
1841
|
+
// Add new connection to topology
|
|
1842
|
+
const conn = suggestion.connection;
|
|
1843
|
+
const newConnection = {
|
|
1844
|
+
id: `conn_${Date.now()}`,
|
|
1845
|
+
fromCameraId: conn.fromCameraId,
|
|
1846
|
+
toCameraId: conn.toCameraId,
|
|
1847
|
+
bidirectional: conn.bidirectional,
|
|
1848
|
+
// Default exit/entry zones covering full frame
|
|
1849
|
+
exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
|
|
1850
|
+
entryZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
|
|
1851
|
+
transitTime: {
|
|
1852
|
+
typical: conn.transitSeconds * 1000,
|
|
1853
|
+
min: Math.max(1000, conn.transitSeconds * 500),
|
|
1854
|
+
max: conn.transitSeconds * 2000,
|
|
1855
|
+
},
|
|
1856
|
+
name: conn.via ? `Via ${conn.via}` : undefined,
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
topology.connections.push(newConnection);
|
|
1860
|
+
updated = true;
|
|
1861
|
+
|
|
1862
|
+
this.console.log(`[Discovery] Added connection: ${conn.fromCameraId} -> ${conn.toCameraId}`);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if (updated) {
|
|
1866
|
+
// Save updated topology
|
|
1867
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
1868
|
+
this.trackingEngine.updateTopology(topology);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1584
1872
|
private serveEditorUI(response: HttpResponse): void {
|
|
1585
1873
|
response.send(EDITOR_HTML, {
|
|
1586
1874
|
headers: { 'Content-Type': 'text/html' },
|
package/src/models/alert.ts
CHANGED
|
@@ -214,26 +214,53 @@ export function generateAlertMessage(
|
|
|
214
214
|
|
|
215
215
|
switch (type) {
|
|
216
216
|
case 'property_entry':
|
|
217
|
-
|
|
217
|
+
// Use the rich description from spatial reasoning if available
|
|
218
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
219
|
+
return details.objectLabel;
|
|
220
|
+
}
|
|
221
|
+
// Fallback to basic description
|
|
222
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
223
|
+
return `${objectDesc} entered property near ${details.involvedLandmarks[0]}`;
|
|
224
|
+
}
|
|
225
|
+
return `${objectDesc} entered property at ${details.cameraName || 'entrance'}`;
|
|
218
226
|
case 'property_exit':
|
|
219
|
-
|
|
227
|
+
// Use the rich description from spatial reasoning if available
|
|
228
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
229
|
+
return details.objectLabel;
|
|
230
|
+
}
|
|
231
|
+
// Fallback to basic description
|
|
232
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
233
|
+
return `${objectDesc} left property via ${details.involvedLandmarks[0]}`;
|
|
234
|
+
}
|
|
235
|
+
return `${objectDesc} left property`;
|
|
220
236
|
case 'movement':
|
|
221
|
-
// If
|
|
222
|
-
if (details.objectLabel && details.
|
|
237
|
+
// If objectLabel contains a full description, use it directly
|
|
238
|
+
if (details.objectLabel && details.objectLabel !== details.objectClass) {
|
|
239
|
+
// Check if this is a cross-camera movement or initial detection
|
|
240
|
+
if (details.fromCameraId && details.fromCameraId !== details.toCameraId && details.transitTime) {
|
|
241
|
+
const transitSecs = Math.round(details.transitTime / 1000);
|
|
242
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
243
|
+
const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
|
|
244
|
+
return `${details.objectLabel}${pathContext}${transitStr}`;
|
|
245
|
+
}
|
|
246
|
+
// Initial detection - use the label directly
|
|
247
|
+
return details.objectLabel;
|
|
248
|
+
}
|
|
249
|
+
// Cross-camera movement with basic info
|
|
250
|
+
if (details.fromCameraId && details.fromCameraId !== details.toCameraId) {
|
|
223
251
|
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
224
|
-
const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
252
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
253
|
+
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
254
|
+
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
255
|
+
movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
|
|
256
|
+
}
|
|
257
|
+
return `${movementDesc}${transitStr}`;
|
|
228
258
|
}
|
|
229
|
-
//
|
|
230
|
-
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
231
|
-
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
232
|
-
let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
|
|
259
|
+
// Initial detection without full label
|
|
233
260
|
if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
|
|
234
|
-
|
|
261
|
+
return `${objectDesc} detected near ${details.involvedLandmarks[0]}`;
|
|
235
262
|
}
|
|
236
|
-
return `${
|
|
263
|
+
return `${objectDesc} detected at ${details.cameraName || 'camera'}`;
|
|
237
264
|
case 'unusual_path':
|
|
238
265
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
239
266
|
case 'dwell_time':
|