@blueharford/scrypted-spatial-awareness 0.6.13 → 0.6.15

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.13",
3
+ "version": "0.6.15",
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",
@@ -23,6 +23,8 @@ import {
23
23
  DiscoveryStatus,
24
24
  DEFAULT_DISCOVERY_STATUS,
25
25
  RATE_LIMIT_WARNING_THRESHOLD,
26
+ DistanceEstimate,
27
+ distanceToFeet,
26
28
  } from '../models/discovery';
27
29
  import {
28
30
  CameraTopology,
@@ -40,60 +42,141 @@ interface ChatCompletionDevice extends ScryptedDevice {
40
42
  }
41
43
 
42
44
  /** Scene analysis prompt for single camera */
43
- const SCENE_ANALYSIS_PROMPT = `Analyze this security camera image and identify what you see.
45
+ const SCENE_ANALYSIS_PROMPT = `You are analyzing a security camera image. Describe EVERYTHING you can see in detail.
46
+
47
+ ## INSTRUCTIONS
48
+ Look at this image carefully and identify ALL visible objects, structures, and areas. Be thorough - even small or partially visible items are important for security awareness.
49
+
50
+ ## 1. LANDMARKS - List EVERY distinct object or feature you can see:
51
+
52
+ **Structures** (buildings, parts of buildings):
53
+ - Houses, garages, sheds, porches, decks, patios, carports, gazebos
54
+ - Walls, pillars, columns, railings, stairs, steps
55
+
56
+ **Vegetation** (plants, trees, landscaping):
57
+ - Trees (describe type if identifiable: oak, palm, pine, etc.)
58
+ - Bushes, shrubs, hedges
59
+ - Flower beds, gardens, planters, potted plants
60
+ - Grass/lawn areas, mulch beds
61
+
62
+ **Boundaries & Barriers**:
63
+ - Fences (wood, chain-link, aluminum, vinyl, iron, privacy)
64
+ - Walls (brick, stone, concrete, retaining)
65
+ - Gates, gate posts
66
+ - Hedges used as boundaries
67
+
68
+ **Access Points & Pathways**:
69
+ - Doors (front, side, garage, screen)
70
+ - Driveways (concrete, asphalt, gravel, pavers)
71
+ - Walkways, sidewalks, paths, stepping stones
72
+ - Stairs, ramps, porches
73
+
74
+ **Utility & Fixtures**:
75
+ - Mailboxes, package boxes
76
+ - Light fixtures, lamp posts, solar lights
77
+ - A/C units, utility boxes, meters
78
+ - Trash cans, recycling bins
79
+ - Hoses, spigots, sprinklers
80
+
81
+ **Outdoor Items**:
82
+ - Vehicles (cars, trucks, motorcycles, boats, trailers)
83
+ - Furniture (chairs, tables, benches, swings)
84
+ - Grills, fire pits, outdoor kitchens
85
+ - Play equipment, trampolines, pools
86
+ - Decorations, flags, signs
87
+
88
+ **Off-Property Elements** (important for security context):
89
+ - Street, road, sidewalk
90
+ - Neighbor's property/fence/house
91
+ - Public areas visible
92
+
93
+ For EACH landmark, estimate its DISTANCE from the camera:
94
+ - "close" = 0-10 feet (within arm's reach of camera)
95
+ - "near" = 10-30 feet
96
+ - "medium" = 30-60 feet
97
+ - "far" = 60-100 feet
98
+ - "distant" = 100+ feet (edge of property or beyond)
99
+
100
+ ## 2. ZONES - Identify distinct AREAS visible:
101
+ - Front yard, backyard, side yard
102
+ - Driveway, parking area
103
+ - Patio, deck, porch
104
+ - Garden area, lawn
105
+ - Street/road
106
+ - Neighbor's yard
107
+
108
+ For each zone, estimate what percentage of the image it covers (0.0 to 1.0).
109
+
110
+ ## 3. EDGES - What's at each edge of the frame:
111
+ This helps understand what's just out of view.
112
+
113
+ ## 4. CAMERA CONTEXT:
114
+ - Estimated mounting height (ground level, 8ft, 12ft, roofline, etc.)
115
+ - Approximate field of view (narrow, medium, wide)
116
+ - Facing direction if determinable (north, south, street-facing, etc.)
44
117
 
45
- 1. LANDMARKS - Identify fixed features visible:
46
- - Structures (house, garage, shed, porch, deck)
47
- - Features (mailbox, tree, pool, garden, fountain)
48
- - Access points (door, gate, driveway entrance, walkway)
49
- - Boundaries (fence, wall, hedge)
50
-
51
- 2. ZONES - Identify area types visible:
52
- - What type of area is this? (front yard, backyard, driveway, street, patio, walkway)
53
- - Estimate what percentage of the frame each zone covers (0.0 to 1.0)
54
-
55
- 3. EDGES - What's visible at the frame edges:
56
- - Top edge: (sky, roof, trees, etc.)
57
- - Left edge: (fence, neighbor, street, etc.)
58
- - Right edge: (fence, garage, etc.)
59
- - Bottom edge: (ground, driveway, grass, etc.)
60
-
61
- 4. ORIENTATION - Estimate camera facing direction based on shadows, sun position, or landmarks
62
-
63
- Respond with ONLY valid JSON in this exact format:
118
+ Respond with ONLY valid JSON:
64
119
  {
65
120
  "landmarks": [
66
- {"name": "Front Door", "type": "access", "confidence": 0.9, "description": "White front door with black frame"}
121
+ {"name": "Mailbox", "type": "feature", "distance": "medium", "confidence": 0.95, "description": "Black metal mailbox on wooden post, approximately 40 feet from camera"},
122
+ {"name": "Aluminum Fence", "type": "boundary", "distance": "near", "confidence": 0.9, "description": "Silver aluminum fence running along left side of property, about 15-20 feet away"},
123
+ {"name": "Large Oak Tree", "type": "feature", "distance": "far", "confidence": 0.85, "description": "Mature oak tree near property line, roughly 80 feet from camera"}
67
124
  ],
68
125
  "zones": [
69
- {"name": "Front Yard", "type": "yard", "coverage": 0.4, "description": "Grass lawn area"}
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"}
70
128
  ],
71
- "edges": {"top": "sky with clouds", "left": "fence and trees", "right": "garage wall", "bottom": "concrete walkway"},
72
- "orientation": "north"
73
- }`;
129
+ "edges": {
130
+ "top": "sky, tree canopy",
131
+ "left": "aluminum fence, neighbor's yard beyond",
132
+ "right": "side of house, garage door",
133
+ "bottom": "concrete walkway, grass edge"
134
+ },
135
+ "cameraContext": {
136
+ "mountHeight": "8 feet",
137
+ "fieldOfView": "wide",
138
+ "facingDirection": "street-facing"
139
+ }
140
+ }
141
+
142
+ BE THOROUGH. List every distinct item you can identify. A typical outdoor scene should have 5-15+ landmarks.`;
74
143
 
75
144
  /** Multi-camera correlation prompt */
76
- const CORRELATION_PROMPT = `I have scene analyses from multiple security cameras at the same property. Help me correlate them to understand the property layout.
145
+ const CORRELATION_PROMPT = `I have detailed scene analyses from multiple security cameras at the same property. Help me understand which landmarks appear in multiple camera views.
77
146
 
78
147
  CAMERA SCENES:
79
148
  {scenes}
80
149
 
81
- Identify:
82
- 1. Shared landmarks - Features that appear in multiple camera views
83
- 2. Camera connections - How someone could move between camera views and estimated walking time
84
- 3. Overall layout - Describe the property layout based on what you see
150
+ ## PRIORITY ORDER (most important first):
151
+
152
+ ### 1. SHARED LANDMARKS (HIGHEST PRIORITY)
153
+ Identify features that are visible from MULTIPLE cameras. This is crucial for understanding the property layout.
154
+ - Look for the SAME fence, tree, mailbox, driveway, structure, etc. appearing in different camera views
155
+ - Even partial visibility counts (e.g., a tree visible in full from one camera and just the edge from another)
156
+ - Include landmarks that are at the boundary between camera views
157
+
158
+ ### 2. PROPERTY LAYOUT
159
+ Based on what each camera sees and their overlapping features, describe:
160
+ - Which areas each camera covers
161
+ - How the cameras relate spatially (e.g., "Camera A looks toward Camera B's direction")
162
+ - Overall property shape and features
163
+
164
+ ### 3. CONNECTIONS (Lower Priority)
165
+ Only if clearly determinable, suggest walking paths between camera views.
85
166
 
86
167
  IMPORTANT: For camera references, use the EXACT device ID shown in parentheses (e.g., "device_123"), NOT the camera name.
87
168
 
88
169
  Respond with ONLY valid JSON:
89
170
  {
90
171
  "sharedLandmarks": [
91
- {"name": "Driveway", "type": "access", "seenByCameras": ["device_123", "device_456"], "confidence": 0.8, "description": "Concrete driveway"}
172
+ {"name": "Aluminum Fence", "type": "boundary", "seenByCameras": ["device_123", "device_456"], "confidence": 0.85, "description": "Silver aluminum fence visible on right edge of Camera A and left edge of Camera B"},
173
+ {"name": "Large Oak Tree", "type": "feature", "seenByCameras": ["device_123", "device_789"], "confidence": 0.9, "description": "Mature oak tree in front yard, visible from both front and side cameras"},
174
+ {"name": "Concrete Driveway", "type": "access", "seenByCameras": ["device_123", "device_456", "device_789"], "confidence": 0.95, "description": "Driveway visible from multiple angles"}
92
175
  ],
93
176
  "connections": [
94
- {"from": "device_123", "to": "device_456", "transitSeconds": 10, "via": "driveway", "confidence": 0.7, "bidirectional": true}
177
+ {"from": "device_123", "to": "device_456", "transitSeconds": 8, "via": "along driveway", "confidence": 0.6, "bidirectional": true}
95
178
  ],
96
- "layoutDescription": "Single-story house with front yard facing street, driveway on the left side, backyard accessible through side gate"
179
+ "layoutDescription": "Ranch-style house. Front camera covers front yard and street. Garage camera covers driveway entrance. Side camera covers side yard with aluminum fence separating from neighbor. Backyard camera shows deck and pool area."
97
180
  }`;
98
181
 
99
182
  export class TopologyDiscoveryEngine {
@@ -328,6 +411,7 @@ export class TopologyDiscoveryEngine {
328
411
  name: l.name || 'Unknown',
329
412
  type: this.mapLandmarkType(l.type),
330
413
  confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
414
+ distance: this.mapDistance(l.distance),
331
415
  description: l.description || '',
332
416
  boundingBox: l.boundingBox,
333
417
  }));
@@ -449,6 +533,17 @@ export class TopologyDiscoveryEngine {
449
533
  return 'unknown';
450
534
  }
451
535
 
536
+ /** Map LLM distance to our type */
537
+ private mapDistance(distance: string): DistanceEstimate {
538
+ const dist = distance?.toLowerCase();
539
+ if (dist?.includes('close')) return 'close';
540
+ if (dist?.includes('near')) return 'near';
541
+ if (dist?.includes('medium')) return 'medium';
542
+ if (dist?.includes('far') && !dist?.includes('distant')) return 'far';
543
+ if (dist?.includes('distant')) return 'distant';
544
+ return 'medium'; // Default to medium if not specified
545
+ }
546
+
452
547
  /** Resolve a camera reference (name or deviceId) to its deviceId */
453
548
  private resolveCameraRef(ref: string): string | null {
454
549
  if (!this.topology?.cameras || !ref) return null;
@@ -644,9 +739,14 @@ export class TopologyDiscoveryEngine {
644
739
  private generateSuggestionsFromAnalysis(analysis: SceneAnalysis): void {
645
740
  if (!analysis.isValid) return;
646
741
 
742
+ this.console.log(`[Discovery] Generating suggestions from ${analysis.landmarks.length} landmarks, ${analysis.zones.length} zones`);
743
+
647
744
  // Generate landmark suggestions
648
745
  for (const landmark of analysis.landmarks) {
649
746
  if (landmark.confidence >= this.config.minLandmarkConfidence) {
747
+ // Calculate distance in feet from distance estimate
748
+ const distanceFeet = landmark.distance ? distanceToFeet(landmark.distance) : 50;
749
+
650
750
  const suggestion: DiscoverySuggestion = {
651
751
  id: `landmark_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
652
752
  type: 'landmark',
@@ -659,27 +759,31 @@ export class TopologyDiscoveryEngine {
659
759
  type: landmark.type,
660
760
  description: landmark.description,
661
761
  visibleFromCameras: [analysis.cameraId],
662
- // Include bounding box for positioning (will be used by applyDiscoverySuggestion)
762
+ // Include extra metadata for positioning
663
763
  boundingBox: landmark.boundingBox,
664
- } as any, // boundingBox is extra metadata not in Landmark interface
764
+ distance: landmark.distance,
765
+ distanceFeet: distanceFeet,
766
+ } as any, // Extra metadata not in base Landmark interface
665
767
  };
666
768
  this.suggestions.set(suggestion.id, suggestion);
769
+ this.console.log(`[Discovery] Landmark suggestion: ${landmark.name} (${landmark.type}, ${landmark.distance || 'medium'}, ~${distanceFeet}ft)`);
667
770
  }
668
771
  }
669
772
 
670
- // Generate zone suggestions
773
+ // Generate zone suggestions (even for smaller coverage - 10% is enough)
671
774
  for (const zone of analysis.zones) {
672
- if (zone.coverage >= 0.2) {
775
+ if (zone.coverage >= 0.1) {
673
776
  const suggestion: DiscoverySuggestion = {
674
777
  id: `zone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
675
778
  type: 'zone',
676
779
  timestamp: Date.now(),
677
780
  sourceCameras: [analysis.cameraId],
678
- confidence: 0.7,
781
+ confidence: Math.min(0.9, 0.5 + zone.coverage), // Higher coverage = higher confidence
679
782
  status: 'pending',
680
783
  zone: zone,
681
784
  };
682
785
  this.suggestions.set(suggestion.id, suggestion);
786
+ this.console.log(`[Discovery] Zone suggestion: ${zone.name} (${zone.type}, ${Math.round(zone.coverage * 100)}% coverage)`);
683
787
  }
684
788
  }
685
789
  }
@@ -141,6 +141,8 @@ export class TrackingEngine {
141
141
  // ==================== Snapshot Cache ====================
142
142
  /** Cached snapshots for tracked objects (for faster notifications) */
143
143
  private snapshotCache: Map<GlobalTrackingId, MediaObject> = new Map();
144
+ /** Pending LLM description promises (started when snapshot is captured) */
145
+ private pendingDescriptions: Map<GlobalTrackingId, Promise<SpatialReasoningResult>> = new Map();
144
146
 
145
147
  constructor(
146
148
  topology: CameraTopology,
@@ -555,18 +557,27 @@ export class TrackingEngine {
555
557
  // Check if we've already alerted for this object
556
558
  if (this.isInAlertCooldown(globalId)) return;
557
559
 
558
- // Use cached snapshot (captured when object was first detected)
559
- let mediaObject = this.snapshotCache.get(globalId);
560
- this.console.log(`[Entry Alert] Using cached snapshot: ${!!mediaObject}`);
560
+ // Use prefetched LLM result if available (started when snapshot was captured)
561
+ let spatialResult: SpatialReasoningResult;
562
+ const pendingDescription = this.pendingDescriptions.get(globalId);
561
563
 
562
- // Generate spatial description (now async with LLM support)
563
- this.console.log(`[Entry Alert] Calling generateEntryDescription with mediaObject=${!!mediaObject}`);
564
- const spatialResult = await this.spatialReasoning.generateEntryDescription(
565
- tracked,
566
- sighting.cameraId,
567
- mediaObject
568
- );
569
- this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
564
+ if (pendingDescription) {
565
+ this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
566
+ try {
567
+ spatialResult = await pendingDescription;
568
+ this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
569
+ } catch (e) {
570
+ this.console.warn(`[Entry Alert] Prefetch failed, generating fallback: ${e}`);
571
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
572
+ }
573
+ this.pendingDescriptions.delete(globalId);
574
+ } else {
575
+ // Fallback: generate description now (slower path)
576
+ this.console.log(`[Entry Alert] No prefetch available, generating now`);
577
+ const mediaObject = this.snapshotCache.get(globalId);
578
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId, mediaObject);
579
+ this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
580
+ }
570
581
 
571
582
  if (isEntryPoint) {
572
583
  // Entry point - generate property entry alert
@@ -599,8 +610,12 @@ export class TrackingEngine {
599
610
  }, this.config.loiteringThreshold);
600
611
  }
601
612
 
602
- /** Capture and cache a snapshot for a tracked object */
603
- private async captureAndCacheSnapshot(globalId: GlobalTrackingId, cameraId: string): Promise<void> {
613
+ /** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
614
+ private async captureAndCacheSnapshot(
615
+ globalId: GlobalTrackingId,
616
+ cameraId: string,
617
+ eventType: 'entry' | 'exit' | 'movement' = 'entry'
618
+ ): Promise<void> {
604
619
  try {
605
620
  const camera = systemManager.getDeviceById<Camera>(cameraId);
606
621
  if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
@@ -608,6 +623,24 @@ export class TrackingEngine {
608
623
  if (mediaObject) {
609
624
  this.snapshotCache.set(globalId, mediaObject);
610
625
  this.console.log(`[Snapshot] Cached snapshot for ${globalId.slice(0, 8)} from ${cameraId}`);
626
+
627
+ // Start LLM analysis immediately in parallel (don't await)
628
+ const tracked = this.state.getObject(globalId);
629
+ if (tracked && this.config.useLlmDescriptions) {
630
+ this.console.log(`[LLM Prefetch] Starting ${eventType} analysis for ${globalId.slice(0, 8)}`);
631
+ const descriptionPromise = eventType === 'exit'
632
+ ? this.spatialReasoning.generateExitDescription(tracked, cameraId, mediaObject)
633
+ : this.spatialReasoning.generateEntryDescription(tracked, cameraId, mediaObject);
634
+
635
+ this.pendingDescriptions.set(globalId, descriptionPromise);
636
+
637
+ // Log when complete (for debugging)
638
+ descriptionPromise.then(result => {
639
+ this.console.log(`[LLM Prefetch] ${eventType} analysis ready for ${globalId.slice(0, 8)}: "${result.description.substring(0, 40)}..."`);
640
+ }).catch(e => {
641
+ this.console.warn(`[LLM Prefetch] Failed for ${globalId.slice(0, 8)}: ${e}`);
642
+ });
643
+ }
611
644
  }
612
645
  }
613
646
  } catch (e) {
@@ -663,8 +696,9 @@ export class TrackingEngine {
663
696
  this.state.markPending(tracked.globalId);
664
697
 
665
698
  // Capture a fresh snapshot now while object is still visible (before they leave)
699
+ // Also starts LLM analysis immediately in parallel
666
700
  if (this.config.useLlmDescriptions) {
667
- this.captureAndCacheSnapshot(tracked.globalId, sighting.cameraId).catch(e => {
701
+ this.captureAndCacheSnapshot(tracked.globalId, sighting.cameraId, 'exit').catch(e => {
668
702
  this.console.warn(`[Exit Snapshot] Failed to update snapshot: ${e}`);
669
703
  });
670
704
  }
@@ -675,21 +709,27 @@ export class TrackingEngine {
675
709
  if (current && current.state === 'pending') {
676
710
  this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
677
711
 
678
- // Use cached snapshot (captured when exit was first detected, while object was still visible)
679
- let mediaObject = this.snapshotCache.get(tracked.globalId);
680
- this.console.log(`[Exit Alert] Using cached snapshot: ${!!mediaObject}`);
681
-
682
- // Generate rich exit description using topology context (now async with LLM support)
683
- this.console.log(`[Exit Alert] Calling generateExitDescription with mediaObject=${!!mediaObject}`);
684
- const spatialResult = await this.spatialReasoning.generateExitDescription(
685
- current,
686
- sighting.cameraId,
687
- mediaObject
688
- );
689
-
690
- this.console.log(
691
- `[Exit Alert] Object ${tracked.globalId.slice(0, 8)} exited: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`
692
- );
712
+ // Use prefetched LLM result if available (started when exit was first detected)
713
+ let spatialResult: SpatialReasoningResult;
714
+ const pendingDescription = this.pendingDescriptions.get(tracked.globalId);
715
+
716
+ if (pendingDescription) {
717
+ this.console.log(`[Exit Alert] Using prefetched LLM result for ${tracked.globalId.slice(0, 8)}`);
718
+ try {
719
+ spatialResult = await pendingDescription;
720
+ this.console.log(`[Exit Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
721
+ } catch (e) {
722
+ this.console.warn(`[Exit Alert] Prefetch failed, generating fallback: ${e}`);
723
+ spatialResult = await this.spatialReasoning.generateExitDescription(current, sighting.cameraId);
724
+ }
725
+ this.pendingDescriptions.delete(tracked.globalId);
726
+ } else {
727
+ // Fallback: generate description now (slower path)
728
+ this.console.log(`[Exit Alert] No prefetch available, generating now`);
729
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
730
+ spatialResult = await this.spatialReasoning.generateExitDescription(current, sighting.cameraId, mediaObject);
731
+ this.console.log(`[Exit Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
732
+ }
693
733
 
694
734
  await this.alertManager.checkAndAlert('property_exit', current, {
695
735
  cameraId: sighting.cameraId,
@@ -700,8 +740,9 @@ export class TrackingEngine {
700
740
  usedLlm: spatialResult.usedLlm,
701
741
  });
702
742
 
703
- // Clean up cached snapshot after exit alert
743
+ // Clean up cached snapshot and pending descriptions after exit alert
704
744
  this.snapshotCache.delete(tracked.globalId);
745
+ this.pendingDescriptions.delete(tracked.globalId);
705
746
  }
706
747
  this.pendingTimers.delete(tracked.globalId);
707
748
  }, this.config.correlationWindow);
@@ -724,8 +765,9 @@ export class TrackingEngine {
724
765
  `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
725
766
  );
726
767
 
727
- // Clean up cached snapshot
768
+ // Clean up cached snapshot and pending descriptions
728
769
  this.snapshotCache.delete(tracked.globalId);
770
+ this.pendingDescriptions.delete(tracked.globalId);
729
771
 
730
772
  this.alertManager.checkAndAlert('lost_tracking', tracked, {
731
773
  objectClass: tracked.className,
@@ -58,6 +58,21 @@ export interface DiscoveredZone {
58
58
  boundingBox?: [number, number, number, number];
59
59
  }
60
60
 
61
+ /** Distance estimate from camera */
62
+ export type DistanceEstimate = 'close' | 'near' | 'medium' | 'far' | 'distant';
63
+
64
+ /** Convert distance estimate to approximate feet */
65
+ export function distanceToFeet(distance: DistanceEstimate): number {
66
+ switch (distance) {
67
+ case 'close': return 5; // 0-10 feet
68
+ case 'near': return 20; // 10-30 feet
69
+ case 'medium': return 45; // 30-60 feet
70
+ case 'far': return 80; // 60-100 feet
71
+ case 'distant': return 150; // 100+ feet
72
+ default: return 50;
73
+ }
74
+ }
75
+
61
76
  /** A landmark discovered in a camera view */
62
77
  export interface DiscoveredLandmark {
63
78
  /** Name of the landmark */
@@ -66,6 +81,8 @@ export interface DiscoveredLandmark {
66
81
  type: LandmarkType;
67
82
  /** Confidence score from LLM (0-1) */
68
83
  confidence: number;
84
+ /** Estimated distance from camera */
85
+ distance?: DistanceEstimate;
69
86
  /** Bounding box in normalized coordinates [x, y, width, height] (0-1) */
70
87
  boundingBox?: [number, number, number, number];
71
88
  /** Description from LLM analysis */