@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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -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": ["camera1", "camera2"], "confidence": 0.8, "description": "Concrete driveway"}
91
+ {"name": "Driveway", "type": "access", "seenByCameras": ["device_123", "device_456"], "confidence": 0.8, "description": "Concrete driveway"}
90
92
  ],
91
93
  "connections": [
92
- {"from": "camera1", "to": "camera2", "transitSeconds": 10, "via": "driveway", "confidence": 0.7, "bidirectional": true}
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
- name: l.name || 'Unknown',
557
- type: this.mapLandmarkType(l.type),
558
- seenByCameras: Array.isArray(l.seenByCameras) ? l.seenByCameras : [],
559
- confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
560
- description: l.description,
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
- fromCameraId: c.from || c.fromCameraId || '',
567
- toCameraId: c.to || c.toCameraId || '',
568
- transitSeconds: typeof c.transitSeconds === 'number' ? c.transitSeconds : 15,
569
- via: c.via || '',
570
- confidence: typeof c.confidence === 'number' ? c.confidence : 0.6,
571
- bidirectional: c.bidirectional !== false,
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 - use flexible matching (deviceId, name, or case-insensitive)
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 camera = visibleCameraRef ? topology.cameras.find(c =>
1838
- c.deviceId === visibleCameraRef ||
1839
- c.name === visibleCameraRef ||
1840
- c.name.toLowerCase() === visibleCameraRef.toLowerCase()
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}, position: ${JSON.stringify(camera.floorPlanPosition)}, fov: ${JSON.stringify(camera.fov)}`);
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
- // Spread landmarks across the FOV cone at varying distances
1869
- const angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6; // -0.6, 0, +0.6 of half FOV
1870
- const distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3; // 50% or 80% of range
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 camera = sourceCameras[0]
1921
- ? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
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
- // Count existing zones from this camera to offset new ones
1940
- const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
1941
- z.linkedCameras?.includes(sourceCameras[0])
1942
- ).length;
1985
+ const camX = camera.floorPlanPosition.x;
1986
+ const camY = camera.floorPlanPosition.y;
1943
1987
 
1944
- // Create a wedge-shaped zone within the camera's FOV
1945
- // Offset based on existing zones to avoid overlap
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
- // Use a portion of the FOV for each zone
1950
- const zoneSpread = halfFov * 0.7; // 70% of half FOV
1991
+ let innerRadius: number;
1992
+ let outerRadius: number;
1993
+ let angleStart: number;
1994
+ let angleEnd: number;
1951
1995
 
1952
- const camX = camera.floorPlanPosition.x;
1953
- const camY = camera.floorPlanPosition.y;
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
- // Create arc polygon (wedge shape)
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 left to right)
2033
+ // Inner arc (from start angle to end angle)
1958
2034
  for (let i = 0; i <= steps; i++) {
1959
- const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
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 right to left)
2041
+ // Outer arc (from end angle to start angle)
1966
2042
  for (let i = steps; i >= 0; i--) {
1967
- const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
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 => c.deviceId === camRef || c.name === camRef);
2008
- if (!cam) return null;
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