foodforthought-cli 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
Files changed (116) hide show
  1. ate/__init__.py +6 -0
  2. ate/__main__.py +16 -0
  3. ate/auth/__init__.py +1 -0
  4. ate/auth/device_flow.py +141 -0
  5. ate/auth/token_store.py +96 -0
  6. ate/behaviors/__init__.py +12 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/cli.py +855 -4551
  9. ate/client.py +90 -0
  10. ate/commands/__init__.py +168 -0
  11. ate/commands/auth.py +389 -0
  12. ate/commands/bridge.py +448 -0
  13. ate/commands/data.py +185 -0
  14. ate/commands/deps.py +111 -0
  15. ate/commands/generate.py +384 -0
  16. ate/commands/memory.py +907 -0
  17. ate/commands/parts.py +166 -0
  18. ate/commands/primitive.py +399 -0
  19. ate/commands/protocol.py +288 -0
  20. ate/commands/recording.py +524 -0
  21. ate/commands/repo.py +154 -0
  22. ate/commands/simulation.py +291 -0
  23. ate/commands/skill.py +303 -0
  24. ate/commands/skills.py +487 -0
  25. ate/commands/team.py +147 -0
  26. ate/commands/workflow.py +271 -0
  27. ate/detection/__init__.py +38 -0
  28. ate/detection/base.py +142 -0
  29. ate/detection/color_detector.py +399 -0
  30. ate/detection/trash_detector.py +322 -0
  31. ate/drivers/__init__.py +18 -6
  32. ate/drivers/ble_transport.py +405 -0
  33. ate/drivers/mechdog.py +360 -24
  34. ate/drivers/wifi_camera.py +477 -0
  35. ate/interfaces/__init__.py +16 -0
  36. ate/interfaces/base.py +2 -0
  37. ate/interfaces/sensors.py +247 -0
  38. ate/llm_proxy.py +239 -0
  39. ate/memory/__init__.py +35 -0
  40. ate/memory/cloud.py +244 -0
  41. ate/memory/context.py +269 -0
  42. ate/memory/embeddings.py +184 -0
  43. ate/memory/export.py +26 -0
  44. ate/memory/merge.py +146 -0
  45. ate/memory/migrate/__init__.py +34 -0
  46. ate/memory/migrate/base.py +89 -0
  47. ate/memory/migrate/pipeline.py +189 -0
  48. ate/memory/migrate/sources/__init__.py +13 -0
  49. ate/memory/migrate/sources/chroma.py +170 -0
  50. ate/memory/migrate/sources/pinecone.py +120 -0
  51. ate/memory/migrate/sources/qdrant.py +110 -0
  52. ate/memory/migrate/sources/weaviate.py +160 -0
  53. ate/memory/reranker.py +353 -0
  54. ate/memory/search.py +26 -0
  55. ate/memory/store.py +548 -0
  56. ate/recording/__init__.py +42 -3
  57. ate/recording/session.py +12 -2
  58. ate/recording/visual.py +416 -0
  59. ate/robot/__init__.py +142 -0
  60. ate/robot/agentic_servo.py +856 -0
  61. ate/robot/behaviors.py +493 -0
  62. ate/robot/ble_capture.py +1000 -0
  63. ate/robot/ble_enumerate.py +506 -0
  64. ate/robot/calibration.py +88 -3
  65. ate/robot/calibration_state.py +388 -0
  66. ate/robot/commands.py +143 -11
  67. ate/robot/direction_calibration.py +554 -0
  68. ate/robot/discovery.py +104 -2
  69. ate/robot/llm_system_id.py +654 -0
  70. ate/robot/locomotion_calibration.py +508 -0
  71. ate/robot/marker_generator.py +611 -0
  72. ate/robot/perception.py +502 -0
  73. ate/robot/primitives.py +614 -0
  74. ate/robot/profiles.py +6 -0
  75. ate/robot/registry.py +5 -2
  76. ate/robot/servo_mapper.py +1153 -0
  77. ate/robot/skill_upload.py +285 -3
  78. ate/robot/target_calibration.py +500 -0
  79. ate/robot/teach.py +515 -0
  80. ate/robot/types.py +242 -0
  81. ate/robot/visual_labeler.py +9 -0
  82. ate/robot/visual_servo_loop.py +494 -0
  83. ate/robot/visual_servoing.py +570 -0
  84. ate/robot/visual_system_id.py +906 -0
  85. ate/transports/__init__.py +121 -0
  86. ate/transports/base.py +394 -0
  87. ate/transports/ble.py +405 -0
  88. ate/transports/hybrid.py +444 -0
  89. ate/transports/serial.py +345 -0
  90. ate/urdf/__init__.py +30 -0
  91. ate/urdf/capture.py +582 -0
  92. ate/urdf/cloud.py +491 -0
  93. ate/urdf/collision.py +271 -0
  94. ate/urdf/commands.py +708 -0
  95. ate/urdf/depth.py +360 -0
  96. ate/urdf/inertial.py +312 -0
  97. ate/urdf/kinematics.py +330 -0
  98. ate/urdf/lifting.py +415 -0
  99. ate/urdf/meshing.py +300 -0
  100. ate/urdf/models/__init__.py +110 -0
  101. ate/urdf/models/depth_anything.py +253 -0
  102. ate/urdf/models/sam2.py +324 -0
  103. ate/urdf/motion_analysis.py +396 -0
  104. ate/urdf/pipeline.py +468 -0
  105. ate/urdf/scale.py +256 -0
  106. ate/urdf/scan_session.py +411 -0
  107. ate/urdf/segmentation.py +299 -0
  108. ate/urdf/synthesis.py +319 -0
  109. ate/urdf/topology.py +336 -0
  110. ate/urdf/validation.py +371 -0
  111. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +1 -1
  112. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  113. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  114. foodforthought_cli-0.2.8.dist-info/RECORD +0 -73
  115. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  116. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/urdf/capture.py ADDED
@@ -0,0 +1,582 @@
1
+ """
2
+ Video capture and link annotation for URDF generation.
3
+
4
+ This module provides the interactive capture UI for Phase 1 of the
5
+ markerless URDF pipeline:
6
+
7
+ 1. Open webcam feed with overlay guidance
8
+ 2. Capture video of robot moving through range of motion
9
+ 3. Pause on first frame for link annotation
10
+ 4. Save video, link annotations, and metadata
11
+
12
+ Usage:
13
+ ate urdf scan capture --output ./my_robot_scan/
14
+ ate urdf scan capture --video ./existing_video.mp4 --output ./my_robot_scan/
15
+ """
16
+
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import List, Optional, Tuple, Callable
22
+ import logging
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ try:
27
+ import cv2
28
+ import numpy as np
29
+ CV2_AVAILABLE = True
30
+ except ImportError:
31
+ CV2_AVAILABLE = False
32
+ cv2 = None
33
+ np = None
34
+
35
+
36
+ class CaptureError(Exception):
37
+ """Error during video capture."""
38
+ pass
39
+
40
+
41
+ @dataclass
42
+ class CaptureConfig:
43
+ """Configuration for video capture."""
44
+ camera_id: int = 0
45
+ width: int = 1280
46
+ height: int = 720
47
+ fps: float = 30.0
48
+ codec: str = "mp4v"
49
+ flip_horizontal: bool = False
50
+
51
+
52
+ class LinkAnnotator:
53
+ """
54
+ Interactive link annotation on a video frame.
55
+
56
+ Allows user to click on robot links to define segmentation points.
57
+ """
58
+
59
+ def __init__(self, frame: "np.ndarray"):
60
+ """
61
+ Initialize annotator with a frame.
62
+
63
+ Args:
64
+ frame: BGR image from OpenCV
65
+ """
66
+ if not CV2_AVAILABLE:
67
+ raise CaptureError("OpenCV not installed. Run: pip install opencv-python")
68
+
69
+ self.frame = frame.copy()
70
+ self.display_frame = frame.copy()
71
+ self.links: List[Tuple[str, List[float], bool]] = [] # (name, [x,y], is_fixed)
72
+ self.current_point: Optional[Tuple[int, int]] = None
73
+ self.window_name = "URDF Scan - Link Annotation"
74
+ self._running = True
75
+ self._last_click_time = 0.0 # Debounce for double-click handling
76
+
77
+ def _draw_overlay(self) -> "np.ndarray":
78
+ """Draw annotation overlay on frame."""
79
+ display = self.display_frame.copy()
80
+ h, w = display.shape[:2]
81
+
82
+ # Draw existing link points
83
+ for i, (name, point, is_fixed) in enumerate(self.links):
84
+ x, y = int(point[0]), int(point[1])
85
+ color = (0, 255, 0) if is_fixed else (255, 165, 0) # Green for fixed, orange for movable
86
+ cv2.circle(display, (x, y), 8, color, -1)
87
+ cv2.circle(display, (x, y), 10, (255, 255, 255), 2)
88
+
89
+ # Label
90
+ label = f"{i+1}. {name}"
91
+ if is_fixed:
92
+ label += " (base)"
93
+ cv2.putText(
94
+ display, label, (x + 15, y + 5),
95
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2
96
+ )
97
+ cv2.putText(
98
+ display, label, (x + 15, y + 5),
99
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 1
100
+ )
101
+
102
+ # Draw current point if hovering
103
+ if self.current_point:
104
+ x, y = self.current_point
105
+ cv2.circle(display, (x, y), 8, (100, 100, 255), 2)
106
+
107
+ # Instructions panel
108
+ panel_h = 150
109
+ overlay = display.copy()
110
+ cv2.rectangle(overlay, (10, 10), (w - 10, panel_h), (0, 0, 0), -1)
111
+ cv2.addWeighted(overlay, 0.7, display, 0.3, 0, display)
112
+
113
+ # Instructions text
114
+ instructions = [
115
+ "LINK ANNOTATION - Click on each rigid robot part",
116
+ "",
117
+ f"Links annotated: {len(self.links)}",
118
+ "",
119
+ "Controls:",
120
+ " [Click] Add link point [Enter/Space] Finish",
121
+ " [U] Undo last point [Esc] Cancel",
122
+ " [B] Mark last as base (fixed link)",
123
+ ]
124
+
125
+ y_offset = 30
126
+ for line in instructions:
127
+ cv2.putText(
128
+ display, line, (20, y_offset),
129
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1
130
+ )
131
+ y_offset += 18
132
+
133
+ return display
134
+
135
+ def _mouse_callback(self, event, x, y, flags, param):
136
+ """Handle mouse events."""
137
+ if event == cv2.EVENT_MOUSEMOVE:
138
+ self.current_point = (x, y)
139
+ elif event == cv2.EVENT_LBUTTONDOWN:
140
+ # Debounce: ignore clicks within 300ms of previous click
141
+ current_time = time.time()
142
+ if current_time - self._last_click_time < 0.3:
143
+ return # Ignore this click (too fast, likely double-click artifact)
144
+ self._last_click_time = current_time
145
+ self._add_link_at(x, y)
146
+
147
+ def _add_link_at(self, x: int, y: int) -> None:
148
+ """Add a link annotation at the given coordinates."""
149
+ # Prompt for link name
150
+ link_num = len(self.links) + 1
151
+ default_names = ["base", "shoulder", "upper_arm", "forearm", "wrist", "gripper"]
152
+
153
+ if link_num <= len(default_names):
154
+ default_name = default_names[link_num - 1]
155
+ else:
156
+ default_name = f"link_{link_num}"
157
+
158
+ # For now, use default name (interactive naming could be added)
159
+ name = default_name
160
+ is_fixed = (link_num == 1) # First link is typically the base
161
+
162
+ self.links.append((name, [float(x), float(y)], is_fixed))
163
+ logger.info(f"Added link: {name} at ({x}, {y}), fixed={is_fixed}")
164
+
165
+ def run(self) -> List[Tuple[str, List[float], bool]]:
166
+ """
167
+ Run the interactive annotation session.
168
+
169
+ Returns:
170
+ List of (name, [x, y], is_fixed) tuples
171
+ """
172
+ cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
173
+ cv2.setMouseCallback(self.window_name, self._mouse_callback)
174
+
175
+ print("\n" + "=" * 60)
176
+ print("LINK ANNOTATION MODE")
177
+ print("=" * 60)
178
+ print("Click on each rigid part of the robot, starting with the base.")
179
+ print("Press [Enter] or [Space] when done.")
180
+ print("=" * 60 + "\n")
181
+
182
+ while self._running:
183
+ display = self._draw_overlay()
184
+ cv2.imshow(self.window_name, display)
185
+
186
+ key = cv2.waitKey(30) & 0xFF
187
+
188
+ if key == 27: # Escape
189
+ print("Annotation cancelled")
190
+ self.links = []
191
+ break
192
+ elif key in [13, 32]: # Enter or Space
193
+ if len(self.links) < 2:
194
+ print("Please annotate at least 2 links (base + 1 movable)")
195
+ else:
196
+ print(f"Annotation complete: {len(self.links)} links")
197
+ break
198
+ elif key == ord('u') or key == ord('U'):
199
+ if self.links:
200
+ removed = self.links.pop()
201
+ print(f"Removed link: {removed[0]}")
202
+ elif key == ord('b') or key == ord('B'):
203
+ if self.links:
204
+ name, point, _ = self.links[-1]
205
+ self.links[-1] = (name, point, True)
206
+ print(f"Marked '{name}' as base (fixed)")
207
+
208
+ cv2.destroyWindow(self.window_name)
209
+ return self.links
210
+
211
+
212
+ class VideoCapture:
213
+ """
214
+ Webcam video capture with guidance overlay.
215
+
216
+ Provides real-time feedback during robot motion capture.
217
+ """
218
+
219
+ def __init__(self, config: CaptureConfig):
220
+ """
221
+ Initialize video capture.
222
+
223
+ Args:
224
+ config: Capture configuration
225
+ """
226
+ if not CV2_AVAILABLE:
227
+ raise CaptureError("OpenCV not installed. Run: pip install opencv-python")
228
+
229
+ self.config = config
230
+ self.cap: Optional[cv2.VideoCapture] = None
231
+ self.writer: Optional[cv2.VideoWriter] = None
232
+ self.frames: List["np.ndarray"] = []
233
+ self.recording = False
234
+ self.frame_count = 0
235
+ self.start_time: Optional[float] = None
236
+
237
+ def open(self) -> None:
238
+ """Open the camera."""
239
+ self.cap = cv2.VideoCapture(self.config.camera_id)
240
+
241
+ if not self.cap.isOpened():
242
+ raise CaptureError(
243
+ f"Could not open camera {self.config.camera_id}. "
244
+ "Check that camera is connected and not in use."
245
+ )
246
+
247
+ # Set resolution
248
+ self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width)
249
+ self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height)
250
+ self.cap.set(cv2.CAP_PROP_FPS, self.config.fps)
251
+
252
+ # Get actual resolution (may differ from requested)
253
+ actual_w = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
254
+ actual_h = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
255
+ actual_fps = self.cap.get(cv2.CAP_PROP_FPS)
256
+
257
+ logger.info(f"Camera opened: {actual_w}x{actual_h} @ {actual_fps:.1f} FPS")
258
+
259
+ def close(self) -> None:
260
+ """Release camera and writer resources."""
261
+ if self.writer:
262
+ self.writer.release()
263
+ self.writer = None
264
+ if self.cap:
265
+ self.cap.release()
266
+ self.cap = None
267
+
268
+ def read_frame(self) -> Optional["np.ndarray"]:
269
+ """Read a single frame from camera."""
270
+ if not self.cap:
271
+ return None
272
+
273
+ ret, frame = self.cap.read()
274
+ if not ret:
275
+ return None
276
+
277
+ if self.config.flip_horizontal:
278
+ frame = cv2.flip(frame, 1)
279
+
280
+ return frame
281
+
282
+ def _draw_guidance_overlay(
283
+ self,
284
+ frame: "np.ndarray",
285
+ phase: str,
286
+ elapsed: float,
287
+ ) -> "np.ndarray":
288
+ """Draw capture guidance overlay."""
289
+ display = frame.copy()
290
+ h, w = display.shape[:2]
291
+
292
+ # Semi-transparent panel at top
293
+ panel_h = 100
294
+ overlay = display.copy()
295
+ cv2.rectangle(overlay, (10, 10), (w - 10, panel_h), (0, 0, 0), -1)
296
+ cv2.addWeighted(overlay, 0.7, display, 0.3, 0, display)
297
+
298
+ # Status text
299
+ if phase == "preview":
300
+ status = "PREVIEW - Press [Space] to start recording"
301
+ color = (255, 255, 0) # Yellow
302
+ elif phase == "recording":
303
+ status = f"RECORDING - {elapsed:.1f}s - Press [Space] to stop"
304
+ color = (0, 0, 255) # Red
305
+
306
+ # Recording indicator
307
+ if int(elapsed * 2) % 2 == 0:
308
+ cv2.circle(display, (w - 40, 30), 10, (0, 0, 255), -1)
309
+ else:
310
+ status = phase
311
+ color = (255, 255, 255)
312
+
313
+ cv2.putText(
314
+ display, status, (20, 35),
315
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2
316
+ )
317
+
318
+ # Guidance
319
+ guidance = [
320
+ "1. Move robot through FULL range of motion",
321
+ "2. Move camera around robot (or rotate robot)",
322
+ " to capture from multiple angles",
323
+ ]
324
+
325
+ y_offset = 55
326
+ for line in guidance:
327
+ cv2.putText(
328
+ display, line, (20, y_offset),
329
+ cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200, 200, 200), 1
330
+ )
331
+ y_offset += 16
332
+
333
+ # Frame counter during recording
334
+ if self.recording:
335
+ cv2.putText(
336
+ display, f"Frames: {self.frame_count}", (w - 150, h - 20),
337
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1
338
+ )
339
+
340
+ return display
341
+
342
+ def capture_interactive(
343
+ self,
344
+ output_path: Path,
345
+ min_duration: float = 5.0,
346
+ max_duration: float = 60.0,
347
+ ) -> Tuple["np.ndarray", int, float]:
348
+ """
349
+ Run interactive capture session.
350
+
351
+ Args:
352
+ output_path: Path to save video file
353
+ min_duration: Minimum recording duration (seconds)
354
+ max_duration: Maximum recording duration (seconds)
355
+
356
+ Returns:
357
+ Tuple of (first_frame, frame_count, fps)
358
+ """
359
+ if not self.cap:
360
+ self.open()
361
+
362
+ window_name = "URDF Scan - Video Capture"
363
+ cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
364
+
365
+ print("\n" + "=" * 60)
366
+ print("VIDEO CAPTURE MODE")
367
+ print("=" * 60)
368
+ print("Press [Space] to start/stop recording")
369
+ print("Press [Esc] to cancel")
370
+ print("=" * 60 + "\n")
371
+
372
+ first_frame = None
373
+ phase = "preview"
374
+
375
+ fourcc = cv2.VideoWriter_fourcc(*self.config.codec)
376
+
377
+ try:
378
+ while True:
379
+ frame = self.read_frame()
380
+ if frame is None:
381
+ raise CaptureError("Failed to read frame from camera")
382
+
383
+ if first_frame is None and self.recording:
384
+ first_frame = frame.copy()
385
+
386
+ # Compute elapsed time
387
+ elapsed = 0.0
388
+ if self.start_time:
389
+ elapsed = time.time() - self.start_time
390
+
391
+ # Draw overlay
392
+ display = self._draw_guidance_overlay(frame, phase, elapsed)
393
+ cv2.imshow(window_name, display)
394
+
395
+ # Handle recording
396
+ if self.recording:
397
+ if self.writer is None:
398
+ h, w = frame.shape[:2]
399
+ self.writer = cv2.VideoWriter(
400
+ str(output_path), fourcc,
401
+ self.config.fps, (w, h)
402
+ )
403
+ self.writer.write(frame)
404
+ self.frame_count += 1
405
+
406
+ # Auto-stop at max duration
407
+ if elapsed >= max_duration:
408
+ print(f"\nMax duration reached ({max_duration}s)")
409
+ break
410
+
411
+ # Handle keyboard input
412
+ key = cv2.waitKey(1) & 0xFF
413
+
414
+ if key == 27: # Escape
415
+ print("\nCapture cancelled")
416
+ self.close()
417
+ cv2.destroyWindow(window_name)
418
+ raise CaptureError("Capture cancelled by user")
419
+
420
+ elif key == 32: # Space
421
+ if not self.recording:
422
+ # Start recording
423
+ self.recording = True
424
+ self.start_time = time.time()
425
+ phase = "recording"
426
+ print("Recording started...")
427
+ else:
428
+ # Stop recording
429
+ if elapsed < min_duration:
430
+ print(f"Please record at least {min_duration}s "
431
+ f"(current: {elapsed:.1f}s)")
432
+ else:
433
+ print(f"\nRecording stopped: {self.frame_count} frames")
434
+ break
435
+
436
+ finally:
437
+ cv2.destroyWindow(window_name)
438
+
439
+ if first_frame is None:
440
+ raise CaptureError("No frames captured")
441
+
442
+ # Compute actual FPS
443
+ if self.start_time:
444
+ actual_duration = time.time() - self.start_time
445
+ actual_fps = self.frame_count / actual_duration if actual_duration > 0 else self.config.fps
446
+ else:
447
+ actual_fps = self.config.fps
448
+
449
+ self.close()
450
+ return first_frame, self.frame_count, actual_fps
451
+
452
+
453
+ def load_video(video_path: str) -> Tuple["np.ndarray", int, float, Tuple[int, int]]:
454
+ """
455
+ Load an existing video file.
456
+
457
+ Args:
458
+ video_path: Path to video file
459
+
460
+ Returns:
461
+ Tuple of (first_frame, frame_count, fps, (width, height))
462
+ """
463
+ if not CV2_AVAILABLE:
464
+ raise CaptureError("OpenCV not installed. Run: pip install opencv-python")
465
+
466
+ cap = cv2.VideoCapture(video_path)
467
+ if not cap.isOpened():
468
+ raise CaptureError(f"Could not open video: {video_path}")
469
+
470
+ # Get video properties
471
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
472
+ fps = cap.get(cv2.CAP_PROP_FPS)
473
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
474
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
475
+
476
+ # Read first frame
477
+ ret, first_frame = cap.read()
478
+ cap.release()
479
+
480
+ if not ret:
481
+ raise CaptureError(f"Could not read first frame from: {video_path}")
482
+
483
+ logger.info(f"Loaded video: {frame_count} frames, {width}x{height} @ {fps:.1f} FPS")
484
+ return first_frame, frame_count, fps, (width, height)
485
+
486
+
487
+ def run_capture(
488
+ output_dir: str,
489
+ video_path: Optional[str] = None,
490
+ camera_id: int = 0,
491
+ robot_name: Optional[str] = None,
492
+ scale_ref: Optional[str] = None,
493
+ ) -> "ScanSession":
494
+ """
495
+ Run the capture phase of URDF scanning.
496
+
497
+ Args:
498
+ output_dir: Directory to save session data
499
+ video_path: Optional path to existing video (skip capture)
500
+ camera_id: Camera device ID for live capture
501
+ robot_name: Name for the robot
502
+ scale_ref: Scale reference (e.g., "gripper:85mm")
503
+
504
+ Returns:
505
+ ScanSession with captured data
506
+ """
507
+ from .scan_session import ScanSession, LinkAnnotation
508
+
509
+ # Create session
510
+ session = ScanSession.create(
511
+ output_dir=output_dir,
512
+ robot_name=robot_name,
513
+ scale_ref=scale_ref,
514
+ )
515
+
516
+ # Get video (capture or load)
517
+ if video_path:
518
+ # Load existing video
519
+ first_frame, frame_count, fps, (width, height) = load_video(video_path)
520
+
521
+ # Copy video to session directory
522
+ import shutil
523
+ shutil.copy(video_path, session.video_path)
524
+ print(f"Loaded video: {video_path}")
525
+
526
+ else:
527
+ # Interactive capture
528
+ config = CaptureConfig(camera_id=camera_id)
529
+ capture = VideoCapture(config)
530
+ capture.open()
531
+
532
+ first_frame, frame_count, fps = capture.capture_interactive(
533
+ session.video_path,
534
+ min_duration=5.0,
535
+ max_duration=60.0,
536
+ )
537
+ height, width = first_frame.shape[:2]
538
+
539
+ # Update metadata
540
+ session.metadata.frame_count = frame_count
541
+ session.metadata.fps = fps
542
+ session.metadata.resolution = [width, height]
543
+ session.metadata.video_path = str(session.video_path)
544
+
545
+ # Run link annotation
546
+ print("\nStarting link annotation...")
547
+ annotator = LinkAnnotator(first_frame)
548
+ link_data = annotator.run()
549
+
550
+ if not link_data:
551
+ raise CaptureError("No links annotated. Capture incomplete.")
552
+
553
+ # Convert to LinkAnnotation objects
554
+ session.links = [
555
+ LinkAnnotation(name=name, point=point, is_fixed=is_fixed)
556
+ for name, point, is_fixed in link_data
557
+ ]
558
+
559
+ # Save session data
560
+ session.save_links()
561
+ session.metadata.capture_complete = True
562
+ session.save_metadata()
563
+
564
+ print(f"\nCapture complete!")
565
+ print(f" Video: {session.video_path}")
566
+ print(f" Frames: {frame_count}")
567
+ print(f" Links: {len(session.links)}")
568
+ for link in session.links:
569
+ marker = " (base)" if link.is_fixed else ""
570
+ print(f" - {link.name}{marker} at {link.point}")
571
+
572
+ return session
573
+
574
+
575
+ __all__ = [
576
+ "CaptureError",
577
+ "CaptureConfig",
578
+ "LinkAnnotator",
579
+ "VideoCapture",
580
+ "load_video",
581
+ "run_capture",
582
+ ]