@blueharford/scrypted-spatial-awareness 0.6.3 → 0.6.5
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/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 +169 -49
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/topology-discovery.ts +74 -18
- package/src/main.ts +122 -39
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -83,13 +83,15 @@ Identify:
|
|
|
83
83
|
2. Camera connections - How someone could move between camera views and estimated walking time
|
|
84
84
|
3. Overall layout - Describe the property layout based on what you see
|
|
85
85
|
|
|
86
|
+
IMPORTANT: For camera references, use the EXACT device ID shown in parentheses (e.g., "device_123"), NOT the camera name.
|
|
87
|
+
|
|
86
88
|
Respond with ONLY valid JSON:
|
|
87
89
|
{
|
|
88
90
|
"sharedLandmarks": [
|
|
89
|
-
{"name": "Driveway", "type": "access", "seenByCameras": ["
|
|
91
|
+
{"name": "Driveway", "type": "access", "seenByCameras": ["device_123", "device_456"], "confidence": 0.8, "description": "Concrete driveway"}
|
|
90
92
|
],
|
|
91
93
|
"connections": [
|
|
92
|
-
{"from": "
|
|
94
|
+
{"from": "device_123", "to": "device_456", "transitSeconds": 10, "via": "driveway", "confidence": 0.7, "bidirectional": true}
|
|
93
95
|
],
|
|
94
96
|
"layoutDescription": "Single-story house with front yard facing street, driveway on the left side, backyard accessible through side gate"
|
|
95
97
|
}`;
|
|
@@ -447,6 +449,40 @@ export class TopologyDiscoveryEngine {
|
|
|
447
449
|
return 'unknown';
|
|
448
450
|
}
|
|
449
451
|
|
|
452
|
+
/** Resolve a camera reference (name or deviceId) to its deviceId */
|
|
453
|
+
private resolveCameraRef(ref: string): string | null {
|
|
454
|
+
if (!this.topology?.cameras || !ref) return null;
|
|
455
|
+
|
|
456
|
+
// Try exact deviceId match first
|
|
457
|
+
const byId = this.topology.cameras.find(c => c.deviceId === ref);
|
|
458
|
+
if (byId) return byId.deviceId;
|
|
459
|
+
|
|
460
|
+
// Try exact name match
|
|
461
|
+
const byName = this.topology.cameras.find(c => c.name === ref);
|
|
462
|
+
if (byName) return byName.deviceId;
|
|
463
|
+
|
|
464
|
+
// Try case-insensitive name match
|
|
465
|
+
const refLower = ref.toLowerCase();
|
|
466
|
+
const byNameLower = this.topology.cameras.find(c => c.name.toLowerCase() === refLower);
|
|
467
|
+
if (byNameLower) return byNameLower.deviceId;
|
|
468
|
+
|
|
469
|
+
// Try partial name match (LLM might truncate or abbreviate)
|
|
470
|
+
const byPartial = this.topology.cameras.find(c =>
|
|
471
|
+
c.name.toLowerCase().includes(refLower) || refLower.includes(c.name.toLowerCase())
|
|
472
|
+
);
|
|
473
|
+
if (byPartial) return byPartial.deviceId;
|
|
474
|
+
|
|
475
|
+
this.console.warn(`[Discovery] Could not resolve camera reference: "${ref}"`);
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Normalize camera references in an array to deviceIds */
|
|
480
|
+
private normalizeCameraRefs(refs: string[]): string[] {
|
|
481
|
+
return refs
|
|
482
|
+
.map(ref => this.resolveCameraRef(ref))
|
|
483
|
+
.filter((id): id is string => id !== null);
|
|
484
|
+
}
|
|
485
|
+
|
|
450
486
|
/** Analyze all cameras and correlate findings */
|
|
451
487
|
async runFullDiscovery(): Promise<TopologyCorrelation | null> {
|
|
452
488
|
if (!this.topology?.cameras?.length) {
|
|
@@ -552,24 +588,42 @@ export class TopologyDiscoveryEngine {
|
|
|
552
588
|
};
|
|
553
589
|
|
|
554
590
|
if (Array.isArray(parsed.sharedLandmarks)) {
|
|
555
|
-
correlation.sharedLandmarks = parsed.sharedLandmarks.map((l: any) =>
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
591
|
+
correlation.sharedLandmarks = parsed.sharedLandmarks.map((l: any) => {
|
|
592
|
+
// Normalize camera references to deviceIds
|
|
593
|
+
const rawRefs = Array.isArray(l.seenByCameras) ? l.seenByCameras : [];
|
|
594
|
+
const normalizedRefs = this.normalizeCameraRefs(rawRefs);
|
|
595
|
+
if (rawRefs.length > 0 && normalizedRefs.length === 0) {
|
|
596
|
+
this.console.warn(`[Discovery] Landmark "${l.name}" has no resolvable camera refs: ${JSON.stringify(rawRefs)}`);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
name: l.name || 'Unknown',
|
|
600
|
+
type: this.mapLandmarkType(l.type),
|
|
601
|
+
seenByCameras: normalizedRefs,
|
|
602
|
+
confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
|
|
603
|
+
description: l.description,
|
|
604
|
+
};
|
|
605
|
+
});
|
|
562
606
|
}
|
|
563
607
|
|
|
564
608
|
if (Array.isArray(parsed.connections)) {
|
|
565
|
-
correlation.suggestedConnections = parsed.connections.map((c: any) =>
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
609
|
+
correlation.suggestedConnections = parsed.connections.map((c: any) => {
|
|
610
|
+
// Normalize camera references to deviceIds
|
|
611
|
+
const fromRef = c.from || c.fromCameraId || '';
|
|
612
|
+
const toRef = c.to || c.toCameraId || '';
|
|
613
|
+
const fromId = this.resolveCameraRef(fromRef);
|
|
614
|
+
const toId = this.resolveCameraRef(toRef);
|
|
615
|
+
if (!fromId || !toId) {
|
|
616
|
+
this.console.warn(`[Discovery] Connection has unresolvable camera refs: from="${fromRef}" to="${toRef}"`);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
fromCameraId: fromId || fromRef,
|
|
620
|
+
toCameraId: toId || toRef,
|
|
621
|
+
transitSeconds: typeof c.transitSeconds === 'number' ? c.transitSeconds : 15,
|
|
622
|
+
via: c.via || '',
|
|
623
|
+
confidence: typeof c.confidence === 'number' ? c.confidence : 0.6,
|
|
624
|
+
bidirectional: c.bidirectional !== false,
|
|
625
|
+
};
|
|
626
|
+
});
|
|
573
627
|
}
|
|
574
628
|
|
|
575
629
|
this.console.log(`[Discovery] Correlation found ${correlation.sharedLandmarks.length} shared landmarks, ${correlation.suggestedConnections.length} connections`);
|
|
@@ -605,7 +659,9 @@ export class TopologyDiscoveryEngine {
|
|
|
605
659
|
type: landmark.type,
|
|
606
660
|
description: landmark.description,
|
|
607
661
|
visibleFromCameras: [analysis.cameraId],
|
|
608
|
-
|
|
662
|
+
// Include bounding box for positioning (will be used by applyDiscoverySuggestion)
|
|
663
|
+
boundingBox: landmark.boundingBox,
|
|
664
|
+
} as any, // boundingBox is extra metadata not in Landmark interface
|
|
609
665
|
};
|
|
610
666
|
this.suggestions.set(suggestion.id, suggestion);
|
|
611
667
|
}
|
package/src/main.ts
CHANGED
|
@@ -1829,21 +1829,29 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1829
1829
|
if (!position || (position.x === 0 && position.y === 0)) {
|
|
1830
1830
|
// Debug logging
|
|
1831
1831
|
this.console.log(`[Discovery] Processing landmark "${suggestion.landmark.name}"`);
|
|
1832
|
+
this.console.log(`[Discovery] sourceCameras: ${JSON.stringify(suggestion.sourceCameras)}`);
|
|
1832
1833
|
this.console.log(`[Discovery] visibleFromCameras: ${JSON.stringify(suggestion.landmark.visibleFromCameras)}`);
|
|
1833
1834
|
this.console.log(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name}(${c.deviceId})`).join(', ')}`);
|
|
1834
1835
|
|
|
1835
|
-
// Find a camera that can see this landmark
|
|
1836
|
+
// Find a camera that can see this landmark
|
|
1837
|
+
// PREFER sourceCameras (set from analysis.cameraId) over visibleFromCameras (from LLM parsing)
|
|
1838
|
+
const sourceCameraRef = suggestion.sourceCameras?.[0];
|
|
1836
1839
|
const visibleCameraRef = suggestion.landmark.visibleFromCameras?.[0];
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1840
|
+
const cameraRef = sourceCameraRef || visibleCameraRef;
|
|
1841
|
+
|
|
1842
|
+
// Use flexible matching (deviceId, name, or case-insensitive)
|
|
1843
|
+
const camera = cameraRef ? topology.cameras.find(c =>
|
|
1844
|
+
c.deviceId === cameraRef ||
|
|
1845
|
+
c.name === cameraRef ||
|
|
1846
|
+
c.name.toLowerCase() === cameraRef.toLowerCase()
|
|
1841
1847
|
) : null;
|
|
1842
1848
|
|
|
1843
1849
|
if (camera) {
|
|
1844
|
-
this.console.log(`[Discovery] Matched camera: ${camera.name}
|
|
1850
|
+
this.console.log(`[Discovery] Matched camera: ${camera.name} (${camera.deviceId})`);
|
|
1851
|
+
this.console.log(`[Discovery] Camera position: ${JSON.stringify(camera.floorPlanPosition)}`);
|
|
1852
|
+
this.console.log(`[Discovery] Camera FOV: ${JSON.stringify(camera.fov)}`);
|
|
1845
1853
|
} else {
|
|
1846
|
-
this.console.warn(`[Discovery] No camera matched for "${visibleCameraRef}"`);
|
|
1854
|
+
this.console.warn(`[Discovery] No camera matched for ref="${cameraRef}" (source="${sourceCameraRef}", visible="${visibleCameraRef}")`);
|
|
1847
1855
|
}
|
|
1848
1856
|
|
|
1849
1857
|
if (camera?.floorPlanPosition) {
|
|
@@ -1853,21 +1861,47 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1853
1861
|
const range = fov.range || 80;
|
|
1854
1862
|
const fovAngle = fov.angle || 90;
|
|
1855
1863
|
|
|
1856
|
-
// Count existing landmarks from this camera to spread them out
|
|
1857
|
-
const cameraDeviceId = camera.deviceId;
|
|
1858
|
-
const existingFromCamera = (topology.landmarks || []).filter(l =>
|
|
1859
|
-
l.visibleFromCameras?.includes(cameraDeviceId) ||
|
|
1860
|
-
l.visibleFromCameras?.includes(camera.name)
|
|
1861
|
-
).length;
|
|
1862
|
-
|
|
1863
1864
|
// Calculate position in front of camera within its FOV
|
|
1864
1865
|
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
1865
1866
|
const dirRad = (direction - 90) * Math.PI / 180;
|
|
1866
1867
|
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
1867
1868
|
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
const
|
|
1869
|
+
// Use bounding box if available to position landmark accurately within FOV
|
|
1870
|
+
// boundingBox format: [x, y, width, height] normalized 0-1
|
|
1871
|
+
const bbox = (suggestion.landmark as any).boundingBox as [number, number, number, number] | undefined;
|
|
1872
|
+
|
|
1873
|
+
let angleOffset: number;
|
|
1874
|
+
let distanceMultiplier: number;
|
|
1875
|
+
|
|
1876
|
+
if (bbox && bbox.length >= 2) {
|
|
1877
|
+
// Use bounding box center to determine position in FOV
|
|
1878
|
+
const bboxCenterX = bbox[0] + (bbox[2] || 0) / 2; // 0 = left edge, 1 = right edge
|
|
1879
|
+
const bboxCenterY = bbox[1] + (bbox[3] || 0) / 2; // 0 = top (far), 1 = bottom (close)
|
|
1880
|
+
|
|
1881
|
+
// Map X position to angle within FOV
|
|
1882
|
+
// bboxCenterX 0 → left side of FOV (-halfFov)
|
|
1883
|
+
// bboxCenterX 0.5 → center of FOV (0)
|
|
1884
|
+
// bboxCenterX 1 → right side of FOV (+halfFov)
|
|
1885
|
+
angleOffset = (bboxCenterX - 0.5) * 2 * halfFov;
|
|
1886
|
+
|
|
1887
|
+
// Map Y position to distance from camera
|
|
1888
|
+
// bboxCenterY 0 (top of frame) → far from camera (90% of range)
|
|
1889
|
+
// bboxCenterY 1 (bottom of frame) → close to camera (30% of range)
|
|
1890
|
+
distanceMultiplier = 0.9 - (bboxCenterY * 0.6);
|
|
1891
|
+
|
|
1892
|
+
this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] → center (${bboxCenterX.toFixed(2)}, ${bboxCenterY.toFixed(2)})`);
|
|
1893
|
+
} else {
|
|
1894
|
+
// Fallback: spread landmarks across FOV if no bounding box
|
|
1895
|
+
const cameraDeviceId = camera.deviceId;
|
|
1896
|
+
const existingFromCamera = (topology.landmarks || []).filter(l =>
|
|
1897
|
+
l.visibleFromCameras?.includes(cameraDeviceId) ||
|
|
1898
|
+
l.visibleFromCameras?.includes(camera.name)
|
|
1899
|
+
).length;
|
|
1900
|
+
|
|
1901
|
+
angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6;
|
|
1902
|
+
distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3;
|
|
1903
|
+
this.console.log(`[Discovery] No bounding box, using fallback spread (existing: ${existingFromCamera})`);
|
|
1904
|
+
}
|
|
1871
1905
|
|
|
1872
1906
|
const finalAngle = dirRad + angleOffset;
|
|
1873
1907
|
const distance = range * distanceMultiplier;
|
|
@@ -1877,7 +1911,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1877
1911
|
y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
|
|
1878
1912
|
};
|
|
1879
1913
|
|
|
1880
|
-
this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, dist=${distance.toFixed(0)}px`);
|
|
1914
|
+
this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, angle=${(angleOffset * 180 / Math.PI).toFixed(1)}°, dist=${distance.toFixed(0)}px`);
|
|
1881
1915
|
} else {
|
|
1882
1916
|
// Position in a grid pattern starting from center
|
|
1883
1917
|
const landmarkCount = topology.landmarks?.length || 0;
|
|
@@ -1917,10 +1951,22 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1917
1951
|
|
|
1918
1952
|
// Find cameras that see this zone
|
|
1919
1953
|
const sourceCameras = suggestion.sourceCameras || [];
|
|
1920
|
-
const
|
|
1921
|
-
|
|
1954
|
+
const cameraRef = sourceCameras[0];
|
|
1955
|
+
const camera = cameraRef
|
|
1956
|
+
? topology.cameras.find(c =>
|
|
1957
|
+
c.deviceId === cameraRef ||
|
|
1958
|
+
c.name === cameraRef ||
|
|
1959
|
+
c.name.toLowerCase() === cameraRef.toLowerCase()
|
|
1960
|
+
)
|
|
1922
1961
|
: null;
|
|
1923
1962
|
|
|
1963
|
+
this.console.log(`[Discovery] Processing zone "${zone.name}" from camera ref="${cameraRef}"`);
|
|
1964
|
+
if (camera) {
|
|
1965
|
+
this.console.log(`[Discovery] Matched camera: ${camera.name} (${camera.deviceId})`);
|
|
1966
|
+
} else if (cameraRef) {
|
|
1967
|
+
this.console.warn(`[Discovery] No camera matched for zone source ref="${cameraRef}"`);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1924
1970
|
// Create zone polygon WITHIN the camera's field of view
|
|
1925
1971
|
let polygon: { x: number; y: number }[] = [];
|
|
1926
1972
|
const timestamp = Date.now();
|
|
@@ -1936,35 +1982,65 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1936
1982
|
const dirRad = (direction - 90) * Math.PI / 180;
|
|
1937
1983
|
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
1938
1984
|
|
|
1939
|
-
|
|
1940
|
-
const
|
|
1941
|
-
z.linkedCameras?.includes(sourceCameras[0])
|
|
1942
|
-
).length;
|
|
1985
|
+
const camX = camera.floorPlanPosition.x;
|
|
1986
|
+
const camY = camera.floorPlanPosition.y;
|
|
1943
1987
|
|
|
1944
|
-
//
|
|
1945
|
-
|
|
1946
|
-
const innerRadius = range * 0.3 + existingFromCamera * 20;
|
|
1947
|
-
const outerRadius = range * 0.8 + existingFromCamera * 20;
|
|
1988
|
+
// Use bounding box if available to position zone accurately within FOV
|
|
1989
|
+
const bbox = zone.boundingBox; // [x, y, width, height] normalized 0-1
|
|
1948
1990
|
|
|
1949
|
-
|
|
1950
|
-
|
|
1991
|
+
let innerRadius: number;
|
|
1992
|
+
let outerRadius: number;
|
|
1993
|
+
let angleStart: number;
|
|
1994
|
+
let angleEnd: number;
|
|
1951
1995
|
|
|
1952
|
-
|
|
1953
|
-
|
|
1996
|
+
if (bbox && bbox.length >= 4) {
|
|
1997
|
+
// Map bounding box to position within FOV
|
|
1998
|
+
const bboxLeft = bbox[0];
|
|
1999
|
+
const bboxRight = bbox[0] + bbox[2];
|
|
2000
|
+
const bboxTop = bbox[1];
|
|
2001
|
+
const bboxBottom = bbox[1] + bbox[3];
|
|
2002
|
+
|
|
2003
|
+
// Map X to angle within FOV (0 = left edge, 1 = right edge)
|
|
2004
|
+
angleStart = dirRad + (bboxLeft - 0.5) * 2 * halfFov;
|
|
2005
|
+
angleEnd = dirRad + (bboxRight - 0.5) * 2 * halfFov;
|
|
1954
2006
|
|
|
1955
|
-
|
|
2007
|
+
// Map Y to distance (0 = far, 1 = close)
|
|
2008
|
+
innerRadius = range * (0.9 - bboxBottom * 0.6);
|
|
2009
|
+
outerRadius = range * (0.9 - bboxTop * 0.6);
|
|
2010
|
+
|
|
2011
|
+
// Ensure min size
|
|
2012
|
+
if (outerRadius - innerRadius < 20) {
|
|
2013
|
+
outerRadius = innerRadius + 20;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
this.console.log(`[Discovery] Zone "${zone.name}" using bbox [${bbox.join(',')}] → angles ${(angleStart * 180 / Math.PI).toFixed(1)}° to ${(angleEnd * 180 / Math.PI).toFixed(1)}°`);
|
|
2017
|
+
} else {
|
|
2018
|
+
// Fallback: wedge-shaped zone offset by existing count
|
|
2019
|
+
const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
|
|
2020
|
+
z.linkedCameras?.includes(sourceCameras[0])
|
|
2021
|
+
).length;
|
|
2022
|
+
|
|
2023
|
+
innerRadius = range * 0.3 + existingFromCamera * 20;
|
|
2024
|
+
outerRadius = range * 0.8 + existingFromCamera * 20;
|
|
2025
|
+
angleStart = dirRad - halfFov * 0.7;
|
|
2026
|
+
angleEnd = dirRad + halfFov * 0.7;
|
|
2027
|
+
|
|
2028
|
+
this.console.log(`[Discovery] Zone "${zone.name}" using fallback spread (existing: ${existingFromCamera})`);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Create arc polygon
|
|
1956
2032
|
const steps = 8;
|
|
1957
|
-
// Inner arc (from
|
|
2033
|
+
// Inner arc (from start angle to end angle)
|
|
1958
2034
|
for (let i = 0; i <= steps; i++) {
|
|
1959
|
-
const angle =
|
|
2035
|
+
const angle = angleStart + (angleEnd - angleStart) * i / steps;
|
|
1960
2036
|
polygon.push({
|
|
1961
2037
|
x: camX + Math.cos(angle) * innerRadius,
|
|
1962
2038
|
y: camY + Math.sin(angle) * innerRadius,
|
|
1963
2039
|
});
|
|
1964
2040
|
}
|
|
1965
|
-
// Outer arc (from
|
|
2041
|
+
// Outer arc (from end angle to start angle)
|
|
1966
2042
|
for (let i = steps; i >= 0; i--) {
|
|
1967
|
-
const angle =
|
|
2043
|
+
const angle = angleStart + (angleEnd - angleStart) * i / steps;
|
|
1968
2044
|
polygon.push({
|
|
1969
2045
|
x: camX + Math.cos(angle) * outerRadius,
|
|
1970
2046
|
y: camY + Math.sin(angle) * outerRadius,
|
|
@@ -2004,8 +2080,15 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
2004
2080
|
const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
|
|
2005
2081
|
const cameraZones: CameraZoneMapping[] = sourceCameras
|
|
2006
2082
|
.map(camRef => {
|
|
2007
|
-
const cam = topology.cameras.find(c =>
|
|
2008
|
-
|
|
2083
|
+
const cam = topology.cameras.find(c =>
|
|
2084
|
+
c.deviceId === camRef ||
|
|
2085
|
+
c.name === camRef ||
|
|
2086
|
+
c.name.toLowerCase() === camRef.toLowerCase()
|
|
2087
|
+
);
|
|
2088
|
+
if (!cam) {
|
|
2089
|
+
this.console.warn(`[Discovery] GlobalZone: No camera matched for ref="${camRef}"`);
|
|
2090
|
+
return null;
|
|
2091
|
+
}
|
|
2009
2092
|
return {
|
|
2010
2093
|
cameraId: cam.deviceId,
|
|
2011
2094
|
zone: [[0, 0], [100, 0], [100, 100], [0, 100]] as ClipPath, // Full frame default
|