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.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +12 -0
- ate/behaviors/approach.py +399 -0
- ate/cli.py +855 -4551
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +18 -6
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +360 -24
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +16 -0
- ate/interfaces/base.py +2 -0
- ate/interfaces/sensors.py +247 -0
- ate/llm_proxy.py +239 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +42 -3
- ate/recording/session.py +12 -2
- ate/recording/visual.py +416 -0
- ate/robot/__init__.py +142 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +88 -3
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +143 -11
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +104 -2
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +6 -0
- ate/robot/registry.py +5 -2
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +285 -3
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +9 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +1 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.8.dist-info/RECORD +0 -73
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
]
|