@blueharford/scrypted-spatial-awareness 0.6.17 → 0.6.19

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.17",
3
+ "version": "0.6.19",
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",
@@ -105,7 +105,10 @@ For EACH landmark, estimate its DISTANCE from the camera:
105
105
  - Street/road
106
106
  - Neighbor's yard
107
107
 
108
- For each zone, estimate what percentage of the image it covers (0.0 to 1.0).
108
+ For each zone, provide:
109
+ - coverage: percentage of the image it covers (0.0 to 1.0)
110
+ - distance: how far the CENTER of the zone is from camera ("close", "near", "medium", "far", "distant")
111
+ - boundingBox: [x, y, width, height] in normalized coordinates (0-1) where the zone appears in the image
109
112
 
110
113
  ## 3. EDGES - What's at each edge of the frame:
111
114
  This helps understand what's just out of view.
@@ -123,8 +126,9 @@ Respond with ONLY valid JSON:
123
126
  {"name": "Large Oak Tree", "type": "feature", "distance": "far", "confidence": 0.85, "description": "Mature oak tree near property line, roughly 80 feet from camera"}
124
127
  ],
125
128
  "zones": [
126
- {"name": "Front Yard", "type": "yard", "coverage": 0.5, "description": "Grass lawn with some bare patches"},
127
- {"name": "Driveway", "type": "driveway", "coverage": 0.25, "description": "Concrete driveway leading to garage"}
129
+ {"name": "Front Yard", "type": "yard", "coverage": 0.5, "distance": "medium", "boundingBox": [0.2, 0.4, 0.6, 0.4], "description": "Grass lawn with some bare patches"},
130
+ {"name": "Driveway", "type": "driveway", "coverage": 0.25, "distance": "near", "boundingBox": [0.6, 0.5, 0.3, 0.4], "description": "Concrete driveway leading to garage"},
131
+ {"name": "Street", "type": "street", "coverage": 0.1, "distance": "distant", "boundingBox": [0.0, 0.1, 1.0, 0.15], "description": "Public road beyond property line"}
128
132
  ],
129
133
  "edges": {
130
134
  "top": "sky, tree canopy",
@@ -440,7 +444,8 @@ Use the mount height to help estimate distances - objects at ground level will a
440
444
  coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
441
445
  description: z.description || '',
442
446
  boundingBox: z.boundingBox,
443
- }));
447
+ distance: this.mapDistance(z.distance), // Parse distance for zones too
448
+ } as DiscoveredZone & { distance?: DistanceEstimate }));
444
449
  }
445
450
 
446
451
  if (parsed.edges && typeof parsed.edges === 'object') {
@@ -560,6 +565,27 @@ Use the mount height to help estimate distances - objects at ground level will a
560
565
  return 'medium'; // Default to medium if not specified
561
566
  }
562
567
 
568
+ /** Get default distance in feet based on zone type */
569
+ private getDefaultZoneDistance(zoneType: string): number {
570
+ switch (zoneType) {
571
+ case 'patio':
572
+ case 'walkway':
573
+ return 10; // Close zones
574
+ case 'driveway':
575
+ case 'parking':
576
+ return 25; // Near zones
577
+ case 'yard':
578
+ case 'garden':
579
+ case 'pool':
580
+ return 40; // Medium zones
581
+ case 'street':
582
+ return 100; // Far zones
583
+ case 'unknown':
584
+ default:
585
+ return 50; // Default to medium distance
586
+ }
587
+ }
588
+
563
589
  /** Try to parse JSON with recovery for truncated responses */
564
590
  private parseJsonWithRecovery(jsonStr: string, context: string): any {
565
591
  // First, try direct parse
@@ -882,6 +908,10 @@ Use the mount height to help estimate distances - objects at ground level will a
882
908
  // Generate zone suggestions (even for smaller coverage - 10% is enough)
883
909
  for (const zone of analysis.zones) {
884
910
  if (zone.coverage >= 0.1) {
911
+ // Calculate distance in feet from distance estimate (for zones with distance info)
912
+ const zoneWithDist = zone as DiscoveredZone & { distance?: DistanceEstimate };
913
+ const distanceFeet = zoneWithDist.distance ? distanceToFeet(zoneWithDist.distance) : this.getDefaultZoneDistance(zone.type);
914
+
885
915
  const suggestion: DiscoverySuggestion = {
886
916
  id: `zone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
887
917
  type: 'zone',
@@ -889,10 +919,14 @@ Use the mount height to help estimate distances - objects at ground level will a
889
919
  sourceCameras: [analysis.cameraId],
890
920
  confidence: Math.min(0.9, 0.5 + zone.coverage), // Higher coverage = higher confidence
891
921
  status: 'pending',
892
- zone: zone,
922
+ zone: {
923
+ ...zone,
924
+ // Include distance metadata for positioning
925
+ distanceFeet: distanceFeet,
926
+ } as any,
893
927
  };
894
928
  this.suggestions.set(suggestion.id, suggestion);
895
- this.console.log(`[Discovery] Zone suggestion: ${zone.name} (${zone.type}, ${Math.round(zone.coverage * 100)}% coverage)`);
929
+ this.console.log(`[Discovery] Zone suggestion: ${zone.name} (${zone.type}, ${Math.round(zone.coverage * 100)}% coverage, ~${distanceFeet}ft)`);
896
930
  }
897
931
  }
898
932
  }
package/src/main.ts CHANGED
@@ -1904,71 +1904,46 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1904
1904
  const dirRad = (direction - 90) * Math.PI / 180;
1905
1905
  const halfFov = (fovAngle / 2) * Math.PI / 180;
1906
1906
 
1907
- // Use landmark TYPE to determine distance - some things are inherently far away
1908
- // regardless of where they appear in the camera frame
1909
- const landmarkType = suggestion.landmark.type;
1910
- const farTypes = ['neighbor', 'boundary', 'street']; // Always place at edge of FOV
1911
- const isFarType = farTypes.includes(landmarkType || '');
1907
+ // Get floor plan scale (pixels per foot) - default 5
1908
+ const floorPlanScale = topology.floorPlanScale || 5;
1912
1909
 
1913
- // Use bounding box if available for horizontal positioning
1914
- // boundingBox format: [x, y, width, height] normalized 0-1
1915
- const bbox = (suggestion.landmark as any).boundingBox as [number, number, number, number] | undefined;
1910
+ // Use the ACTUAL distance from LLM analysis (distanceFeet) - this is the key fix!
1911
+ // The LLM estimates distance based on object size, perspective, and camera context
1912
+ const landmarkData = suggestion.landmark as any;
1913
+ const distanceFeet = landmarkData.distanceFeet || 50; // Default 50ft if not set
1914
+ const distanceInPixels = distanceFeet * floorPlanScale;
1915
+
1916
+ // Use bounding box for horizontal positioning within the FOV
1917
+ const bbox = landmarkData.boundingBox as [number, number, number, number] | undefined;
1916
1918
 
1917
1919
  let angleOffset: number;
1918
- let distanceMultiplier: number;
1919
1920
 
1920
1921
  if (bbox && bbox.length >= 2) {
1921
1922
  // Use bounding box X for horizontal position in FOV
1922
1923
  const bboxCenterX = bbox[0] + (bbox[2] || 0) / 2; // 0 = left edge, 1 = right edge
1923
-
1924
1924
  // Map X position to angle within FOV
1925
1925
  angleOffset = (bboxCenterX - 0.5) * 2 * halfFov;
1926
-
1927
- // For distance: use TYPE first, then bbox Y as hint
1928
- if (isFarType) {
1929
- // Neighbors, boundaries, streets are BEYOND the camera's normal range
1930
- // Place at 150-200% of range to indicate they're distant background features
1931
- distanceMultiplier = 1.5 + Math.random() * 0.5; // 150-200% of range
1932
- this.console.log(`[Discovery] Far-type landmark "${landmarkType}" placed BEYOND FOV (${(distanceMultiplier * 100).toFixed(0)}% of range)`);
1933
- } else {
1934
- // For other types, use bbox Y as a hint (but cap minimum distance)
1935
- const bboxCenterY = bbox[1] + (bbox[3] || 0) / 2;
1936
- // Map Y to distance: 0 (top) = far, 1 (bottom) = closer (but not too close)
1937
- distanceMultiplier = Math.max(0.4, 0.9 - (bboxCenterY * 0.5));
1938
- }
1939
-
1940
- this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] for horizontal position`);
1926
+ this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] for horizontal angle offset`);
1941
1927
  } else {
1942
- // No bounding box - use type and spread pattern
1928
+ // No bounding box - spread horizontally based on existing landmarks
1943
1929
  const cameraDeviceId = camera.deviceId;
1944
1930
  const existingFromCamera = (topology.landmarks || []).filter(l =>
1945
1931
  l.visibleFromCameras?.includes(cameraDeviceId) ||
1946
1932
  l.visibleFromCameras?.includes(camera.name)
1947
1933
  ).length;
1948
-
1949
1934
  // Spread horizontally across FOV
1950
1935
  angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6;
1951
-
1952
- // Distance based on type
1953
- if (isFarType) {
1954
- // Neighbors, boundaries, streets are BEYOND the camera's normal range
1955
- distanceMultiplier = 1.5 + Math.random() * 0.5; // 150-200% of range
1956
- this.console.log(`[Discovery] Far-type landmark "${landmarkType}" (no bbox) placed BEYOND FOV`);
1957
- } else {
1958
- distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3;
1959
- this.console.log(`[Discovery] No bounding box, using fallback spread (existing: ${existingFromCamera})`);
1960
- }
1936
+ this.console.log(`[Discovery] No bounding box, spreading horizontally (existing: ${existingFromCamera})`);
1961
1937
  }
1962
1938
 
1963
1939
  const finalAngle = dirRad + angleOffset;
1964
- const distance = range * distanceMultiplier;
1965
1940
 
1966
1941
  position = {
1967
- x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
1968
- y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
1942
+ x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distanceInPixels,
1943
+ y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distanceInPixels,
1969
1944
  };
1970
1945
 
1971
- 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`);
1946
+ this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" at ${distanceFeet}ft (${distanceInPixels}px) from ${camera.name}: dir=${direction}°, angle=${(angleOffset * 180 / Math.PI).toFixed(1)}°`);
1972
1947
  } else {
1973
1948
  // Position in a grid pattern starting from center
1974
1949
  const landmarkCount = topology.landmarks?.length || 0;
@@ -2029,12 +2004,14 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2029
2004
  const timestamp = Date.now();
2030
2005
 
2031
2006
  if (camera?.floorPlanPosition) {
2032
- // Get camera's FOV direction and range (cast to any for flexible access)
2007
+ // Get camera's FOV direction (cast to any for flexible access)
2033
2008
  const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
2034
2009
  const direction = fov.direction || 0;
2035
- const range = fov.range || 80;
2036
2010
  const fovAngle = fov.angle || 90;
2037
2011
 
2012
+ // Get floor plan scale (pixels per foot)
2013
+ const floorPlanScale = topology.floorPlanScale || 5;
2014
+
2038
2015
  // Convert direction to radians (0 = up/north, 90 = right/east)
2039
2016
  const dirRad = (direction - 90) * Math.PI / 180;
2040
2017
  const halfFov = (fovAngle / 2) * Math.PI / 180;
@@ -2042,47 +2019,52 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2042
2019
  const camX = camera.floorPlanPosition.x;
2043
2020
  const camY = camera.floorPlanPosition.y;
2044
2021
 
2045
- // Use bounding box if available to position zone accurately within FOV
2022
+ // Use distanceFeet from the zone metadata for accurate positioning
2023
+ const zoneData = zone as any;
2024
+ const distanceFeet = zoneData.distanceFeet || 40; // Default 40ft if not set
2025
+ const distanceInPixels = distanceFeet * floorPlanScale;
2026
+
2027
+ // Zone size based on coverage (larger coverage = wider zone)
2028
+ const zoneWidthFeet = Math.sqrt(zone.coverage) * 30; // e.g., 50% coverage = ~21ft wide
2029
+ const zoneWidthPixels = zoneWidthFeet * floorPlanScale;
2030
+ const zoneDepthPixels = (zone.coverage * 20) * floorPlanScale; // Depth based on coverage
2031
+
2032
+ // Use bounding box for horizontal positioning if available
2046
2033
  const bbox = zone.boundingBox; // [x, y, width, height] normalized 0-1
2047
2034
 
2048
- let innerRadius: number;
2049
- let outerRadius: number;
2050
2035
  let angleStart: number;
2051
2036
  let angleEnd: number;
2037
+ let innerRadius: number;
2038
+ let outerRadius: number;
2052
2039
 
2053
2040
  if (bbox && bbox.length >= 4) {
2054
- // Map bounding box to position within FOV
2041
+ // Map bounding box X to angle within FOV
2055
2042
  const bboxLeft = bbox[0];
2056
2043
  const bboxRight = bbox[0] + bbox[2];
2057
- const bboxTop = bbox[1];
2058
- const bboxBottom = bbox[1] + bbox[3];
2059
-
2060
- // Map X to angle within FOV (0 = left edge, 1 = right edge)
2061
2044
  angleStart = dirRad + (bboxLeft - 0.5) * 2 * halfFov;
2062
2045
  angleEnd = dirRad + (bboxRight - 0.5) * 2 * halfFov;
2063
2046
 
2064
- // Map Y to distance (0 = far, 1 = close)
2065
- innerRadius = range * (0.9 - bboxBottom * 0.6);
2066
- outerRadius = range * (0.9 - bboxTop * 0.6);
2067
-
2068
- // Ensure min size
2069
- if (outerRadius - innerRadius < 20) {
2070
- outerRadius = innerRadius + 20;
2071
- }
2047
+ // Use distanceFeet for depth, with a spread based on coverage
2048
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2049
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2072
2050
 
2073
- 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)}°`);
2051
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using bbox [${bbox.join(',')}]`);
2074
2052
  } else {
2075
- // Fallback: wedge-shaped zone offset by existing count
2053
+ // Fallback: wedge-shaped zone covering portion of FOV
2076
2054
  const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
2077
2055
  z.linkedCameras?.includes(sourceCameras[0])
2078
2056
  ).length;
2079
2057
 
2080
- innerRadius = range * 0.3 + existingFromCamera * 20;
2081
- outerRadius = range * 0.8 + existingFromCamera * 20;
2082
- angleStart = dirRad - halfFov * 0.7;
2083
- angleEnd = dirRad + halfFov * 0.7;
2058
+ // Spread horizontally based on existing zones
2059
+ const offset = (existingFromCamera % 3 - 1) * halfFov * 0.5;
2060
+ angleStart = dirRad + offset - halfFov * 0.3;
2061
+ angleEnd = dirRad + offset + halfFov * 0.3;
2062
+
2063
+ // Use distanceFeet for depth
2064
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2065
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2084
2066
 
2085
- this.console.log(`[Discovery] Zone "${zone.name}" using fallback spread (existing: ${existingFromCamera})`);
2067
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using fallback spread`);
2086
2068
  }
2087
2069
 
2088
2070
  // Create arc polygon
@@ -2104,7 +2086,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2104
2086
  });
2105
2087
  }
2106
2088
 
2107
- this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
2089
+ this.console.log(`[Discovery] Creating zone "${zone.name}" at ${distanceFeet}ft (${distanceInPixels}px) from ${camera.name}`);
2108
2090
  } else {
2109
2091
  // Fallback: rectangular zone at default location
2110
2092
  const centerX = 300 + (topology.drawnZones?.length || 0) * 120;
@@ -238,7 +238,7 @@ export interface DrawnZone {
238
238
  /** Zone type */
239
239
  type: DrawnZoneType;
240
240
  /** Polygon points on floor plan (x, y coordinates) */
241
- polygon: Point[];
241
+ polygon: FloorPlanPosition[];
242
242
  /** Custom color override (optional) */
243
243
  color?: string;
244
244
  /** Linked camera IDs that can see this zone */
@@ -354,6 +354,8 @@ export interface CameraTopology {
354
354
  floorPlan?: FloorPlanConfig;
355
355
  /** Pending AI landmark suggestions */
356
356
  pendingSuggestions?: LandmarkSuggestion[];
357
+ /** Floor plan scale in pixels per foot (for distance calculations) */
358
+ floorPlanScale?: number;
357
359
  }
358
360
 
359
361
  // ==================== Helper Functions ====================
@@ -454,6 +454,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
454
454
  const response = await fetch('../api/topology');
455
455
  if (response.ok) {
456
456
  topology = await response.json();
457
+ console.log('[Load] Loaded topology with', (topology.drawnZones || []).length, 'zones');
457
458
  if (!topology.drawings) topology.drawings = [];
458
459
  // Load floor plan scale if saved
459
460
  if (topology.floorPlanScale) {
@@ -971,14 +972,24 @@ export const EDITOR_HTML = `<!DOCTYPE html>
971
972
  async function saveTopology() {
972
973
  try {
973
974
  setStatus('Saving...', 'warning');
975
+ console.log('[Save] Saving topology with', (topology.drawnZones || []).length, 'zones');
974
976
  const response = await fetch('../api/topology', {
975
977
  method: 'PUT',
976
978
  headers: { 'Content-Type': 'application/json' },
977
979
  body: JSON.stringify(topology)
978
980
  });
979
- if (response.ok) { setStatus('Saved successfully', 'success'); }
980
- else { setStatus('Failed to save', 'error'); }
981
- } catch (e) { console.error('Failed to save topology:', e); setStatus('Failed to save', 'error'); }
981
+ if (response.ok) {
982
+ console.log('[Save] Save successful');
983
+ setStatus('Saved successfully', 'success');
984
+ } else {
985
+ const errorText = await response.text();
986
+ console.error('[Save] Save failed:', response.status, errorText);
987
+ setStatus('Failed to save: ' + errorText, 'error');
988
+ }
989
+ } catch (e) {
990
+ console.error('Failed to save topology:', e);
991
+ setStatus('Failed to save', 'error');
992
+ }
982
993
  }
983
994
 
984
995
  function resizeCanvas() {
@@ -1778,9 +1789,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1778
1789
  polygon: currentZonePoints.map(pt => ({ x: pt.x, y: pt.y })),
1779
1790
  };
1780
1791
 
1792
+ console.log('[Zone] Creating zone:', zone.name, 'with', zone.polygon.length, 'points');
1793
+
1781
1794
  if (!topology.drawnZones) topology.drawnZones = [];
1782
1795
  topology.drawnZones.push(zone);
1783
1796
 
1797
+ console.log('[Zone] Total zones now:', topology.drawnZones.length);
1798
+
1784
1799
  // Reset state
1785
1800
  zoneDrawingMode = false;
1786
1801
  currentZonePoints = [];
@@ -1789,7 +1804,19 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1789
1804
  setTool('select');
1790
1805
  updateUI();
1791
1806
  render();
1792
- setStatus('Zone "' + zone.name + '" created with ' + zone.polygon.length + ' points', 'success');
1807
+
1808
+ console.log('[Zone] After updateUI/render, zones in topology:', (topology.drawnZones || []).length);
1809
+
1810
+ setStatus('Zone "' + zone.name + '" created - saving...', 'success');
1811
+
1812
+ // Auto-save after creating a zone
1813
+ saveTopology().then(() => {
1814
+ console.log('[Zone] Save completed, zones in topology:', (topology.drawnZones || []).length);
1815
+ setStatus('Zone "' + zone.name + '" saved', 'success');
1816
+ }).catch(err => {
1817
+ console.error('Failed to auto-save zone:', err);
1818
+ setStatus('Zone created but auto-save failed - click Save', 'warning');
1819
+ });
1793
1820
  }
1794
1821
 
1795
1822
  function selectZone(id) {
@@ -1814,9 +1841,9 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1814
1841
  '<button class="btn btn-primary" onclick="deleteZone(\\'' + id + '\\')" style="background: #dc2626; width: 100%;">Delete Zone</button>';
1815
1842
  }
1816
1843
 
1817
- function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); } }
1818
- function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); } }
1819
- function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; } }
1844
+ function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); saveTopology(); } }
1845
+ function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); saveTopology(); } }
1846
+ function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; saveTopology(); } }
1820
1847
  function deleteZone(id) {
1821
1848
  if (!confirm('Delete this zone?')) return;
1822
1849
  topology.drawnZones = (topology.drawnZones || []).filter(z => z.id !== id);
@@ -1824,7 +1851,8 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1824
1851
  document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1825
1852
  updateUI();
1826
1853
  render();
1827
- setStatus('Zone deleted', 'success');
1854
+ setStatus('Zone deleted - saving...', 'success');
1855
+ saveTopology().then(() => setStatus('Zone deleted', 'success'));
1828
1856
  }
1829
1857
 
1830
1858
  function useBlankCanvas() {