Repository navigation
Expand file tree
/
Copy pathcalibrate.py
More file actions
530 lines (435 loc) · 21.7 KB
/
Copy pathcalibrate.py
File metadata and controls
530 lines (435 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Calibration module for the web interface.
This module provides calibration functionality similar to the CLI calibrate.py,
but adapted for the web interface with step-by-step guidance.
"""
import logging
import threading
import time
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Literal
from lerobot.motors import MotorCalibration
from lerobot.robots import (
Robot,
make_robot_from_config,
)
from lerobot.teleoperators import (
Teleoperator,
make_teleoperator_from_config,
)
from lerobot.utils.utils import init_logging
from .utils.config import is_valid_robot_name
from .utils.devices import safe_disconnect_device
logger = logging.getLogger(__name__)
# Feetech sts3215 Present_Position readings are 12-bit (0-4095). A reading at or
# below 0, or at/above this loose ceiling, is a bad frame (disconnected motor or
# an encoder wrap-around) rather than a real joint position, so it's filtered out
# everywhere a raw position is consumed.
_MIN_VALID_POSITION = 0
_MAX_VALID_POSITION = 5000
# A single-frame jump larger than this is the encoder wrapping past 0/4095
# (a ~4096-step delta), not real motion — see CalibrationDiscontinuityError.
_MAX_POSITION_JUMP = 2000
# A recorded min..max sweep smaller than this many encoder steps means the joint
# barely moved; the user is warned their range of motion looks insufficient.
_MIN_CALIBRATION_RANGE = 100
def _is_valid_position(pos: float) -> bool:
"""True when a raw Present_Position reading sits within the plausible encoder
range, filtering out 0/negative/extreme bad frames before they pollute the
recorded min/max."""
return _MIN_VALID_POSITION < pos < _MAX_VALID_POSITION
class CalibrationDiscontinuityError(Exception):
"""Raised when a motor position reading jumps across the encoder wrap-around.
The Feetech encoder is 12-bit (0-4095); if calibration starts with a joint
near a boundary, moving it past 0 or 4095 produces a single-frame delta of
~4096. The user-side fix is to start with all joints in the middle of their
range, as documented in the SO-101 docs.
"""
@dataclass
class CalibrationStatus:
"""Status information for calibration process"""
calibration_active: bool = False
status: str = "idle" # "idle", "connecting", "recording", "completed", "error", "stopping"
device_type: str | None = None
error: str | None = None
message: str = ""
step: int = 0 # Current calibration step
total_steps: int = 1 # Total number of calibration steps
current_positions: dict[str, float] = None
recorded_ranges: dict[str, dict[str, float]] = None # {motor: {min: val, max: val, current: val}}
@dataclass
class CalibrationRequest:
"""Request parameters for starting calibration"""
device_type: Literal["robot", "teleop"]
port: str
config_file: str
robot_name: str | None = None # When set, write port + config back into the robot record on success
class CalibrationManager:
"""Manages calibration process for the web interface"""
def __init__(self):
self.status = CalibrationStatus()
self.device: Robot | Teleoperator | None = None
self.calibration_thread: threading.Thread | None = None
self.stop_calibration = False
self._status_lock = threading.Lock()
self._step_complete = threading.Event()
self._recording_active = False
self._start_positions = {}
self._mins = {}
self._maxes = {}
self._homing_offsets = {}
self._current_request: CalibrationRequest | None = None
# Initialize logging
init_logging()
def get_status(self) -> CalibrationStatus:
"""Get current calibration status"""
with self._status_lock:
# Return a snapshot so status serialization cannot race with
# range updates from the calibration worker.
return deepcopy(self.status)
def _update_status(self, **kwargs):
"""Update calibration status thread-safely"""
with self._status_lock:
for key, value in kwargs.items():
if hasattr(self.status, key):
setattr(self.status, key, value)
def _cache_recorded_ranges(self, positions: dict[str, float]) -> None:
"""Update cached range data for status polling."""
# Build the update outside the lock to keep status polling responsive.
updates = {
motor: {
"min": self._mins[motor],
"max": self._maxes[motor],
"current": pos,
}
for motor, pos in positions.items()
if motor in self._mins
}
with self._status_lock:
if self.status.status != "recording":
return
recorded_ranges = dict(self.status.recorded_ranges or {})
recorded_ranges.update(updates)
self.status.recorded_ranges = recorded_ranges
def start_calibration(self, request: CalibrationRequest) -> dict[str, Any]:
"""Start calibration process"""
try:
# config_file becomes the LeRobot device id, and LeRobot writes the calibration to
# calibration_dir / f"{id}.json", so reject path-traversal characters (/, \, ..) first.
# Same guard as the calibration-config delete endpoint and robot records.
if not is_valid_robot_name(request.config_file):
return {"success": False, "message": "Invalid configuration name"}
if self.status.calibration_active:
return {"success": False, "message": "Calibration already active"}
# Reset status and clear any previous calibration data
self._start_positions = {}
self._mins = {}
self._maxes = {}
self._homing_offsets = {}
self._update_status(
calibration_active=True,
status="connecting",
device_type=request.device_type,
error=None,
message=f"Starting calibration for {request.device_type}",
step=0,
current_positions=None,
recorded_ranges=None,
)
self._current_request = request
# Start calibration in a separate thread
self.calibration_thread = threading.Thread(
target=self._calibration_worker, args=(request,), daemon=True
)
self.stop_calibration = False
self._step_complete.clear()
self.calibration_thread.start()
return {"success": True, "message": "Calibration started"}
except Exception as e:
logger.error(f"Error starting calibration: {e}")
self._update_status(
calibration_active=False, status="error", error=str(e), message="Failed to start calibration"
)
return {"success": False, "message": str(e)}
def complete_step(self) -> dict[str, Any]:
"""Complete the current calibration step"""
try:
if not self.status.calibration_active:
return {"success": False, "message": "No calibration active"}
if self.status.status == "recording":
# Complete recording step
self._recording_active = False
self._step_complete.set()
return {"success": True, "message": "Range recording completed"}
else:
return {"success": False, "message": f"Cannot complete step in status: {self.status.status}"}
except Exception as e:
logger.error(f"Error completing step: {e}")
return {"success": False, "message": str(e)}
def stop_calibration_process(self) -> dict[str, Any]:
"""Stop calibration process"""
try:
if not self.status.calibration_active:
return {"success": False, "message": "No calibration active"}
logger.info("Stopping calibration process...")
self.stop_calibration = True
self._recording_active = False
self._step_complete.set() # Unblock any waiting step
self._update_status(status="stopping", message="Stopping calibration...")
# Wait for thread to finish
if self.calibration_thread and self.calibration_thread.is_alive():
self.calibration_thread.join(timeout=5.0)
# Ensure cleanup is called if thread didn't finish properly
if self.calibration_thread and self.calibration_thread.is_alive():
logger.warning("Calibration thread did not finish within timeout, forcing cleanup")
# Force cleanup and finish
self._cleanup_and_finish("Calibration stopped", status="idle")
logger.info("Calibration stop completed")
return {"success": True, "message": "Calibration stopped"}
except Exception as e:
logger.error(f"Error stopping calibration: {e}")
# Force cleanup on error too
self._cleanup_and_finish("Calibration stopped with error", status="error")
return {"success": False, "message": str(e)}
def _calibration_worker(self, request: CalibrationRequest):
"""Worker thread for calibration process"""
try:
logger.info(f"Starting calibration worker for {request.device_type}")
# Create device configuration
if request.device_type == "robot":
from lerobot.robots.so_follower import SO101FollowerConfig
config = SO101FollowerConfig(port=request.port, id=request.config_file)
elif request.device_type == "teleop":
from lerobot.teleoperators.so_leader import SO101LeaderConfig
config = SO101LeaderConfig(port=request.port, id=request.config_file)
else:
raise ValueError(f"Unknown device type: {request.device_type}")
self._update_status(status="connecting", message="Connecting to device...")
# Create and connect device
if request.device_type == "robot":
self.device = make_robot_from_config(config)
else:
self.device = make_teleoperator_from_config(config)
logger.info("Connecting to device...")
self.device.connect(calibrate=False)
if self.stop_calibration:
logger.info("Calibration stopped after device connection")
self._cleanup_and_finish("Calibration cancelled")
return
# Start Step 1: Homing
self._step_homing()
if self.stop_calibration:
logger.info("Calibration stopped after homing step")
self._cleanup_and_finish("Calibration cancelled")
return
# Start Step 2: Range Recording
self._step_range_recording()
if self.stop_calibration:
logger.info("Calibration stopped after recording step")
self._cleanup_and_finish("Calibration cancelled")
return
# Complete calibration
self._complete_calibration()
logger.info("Calibration completed successfully")
self._cleanup_and_finish("Calibration completed successfully", status="completed")
except CalibrationDiscontinuityError as e:
logger.error(f"Calibration discontinuity: {e}")
self._update_status(error=str(e))
self._cleanup_and_finish(str(e), status="error")
except Exception as e:
logger.exception(f"Calibration error: {e}")
# Ensure cleanup happens even on error
self._cleanup_and_finish(f"Calibration failed: {e}", status="error")
finally:
# Ensure we always clean up and reset the active flag
logger.info("Calibration worker thread finishing")
if self.status.calibration_active:
logger.warning(
"Worker thread ending but calibration still marked as active - forcing cleanup"
)
self._cleanup_and_finish("Calibration stopped", status="idle")
def _step_homing(self):
"""Auto-capture homing offsets from the device's current position."""
logger.info("Setting homing offsets from current position")
# Disable torque to allow manual movement during recording
self.device.bus.disable_torque()
self.device.bus.set_operating_mode("position")
# Resets the calibration, then writes the offsets that center each present position.
self._homing_offsets = self.device.bus.set_half_turn_homings()
logger.info(f"Calculated homing offsets: {self._homing_offsets}")
def _step_range_recording(self):
"""Record range of motion as the user moves all joints."""
logger.info("Starting range recording step")
# Initialize range tracking with retry and validation
self._start_positions = {}
for attempt in range(5): # Try multiple times to get valid initial positions
try:
positions = self.device.bus.sync_read("Present_Position", normalize=False)
# Validate initial positions
valid_positions = {}
for motor, pos in positions.items():
if _is_valid_position(pos):
valid_positions[motor] = pos
if len(valid_positions) == len(positions): # All positions are valid
self._start_positions = valid_positions
break
else:
logger.warning(f"Attempt {attempt + 1}: Got invalid initial positions, retrying...")
time.sleep(0.1)
except Exception as e:
logger.warning(f"Attempt {attempt + 1}: Failed to read initial positions: {e}")
time.sleep(0.1)
if not self._start_positions:
raise RuntimeError("Could not get valid initial positions after multiple attempts")
logger.info(f"Starting positions for range recording: {self._start_positions}")
self._mins = self._start_positions.copy()
self._maxes = self._start_positions.copy()
logger.info(f"Initialized mins: {self._mins}")
logger.info(f"Initialized maxes: {self._maxes}")
self._update_status(
status="recording",
step=1,
message="Move ALL joints through their FULL ranges of motion - from minimum to maximum positions. Ensure each joint moves significantly from its starting position.",
recorded_ranges={
motor: {"min": pos, "max": pos, "current": pos}
for motor, pos in self._start_positions.items()
},
)
self._recording_active = True
prev_positions: dict[str, int] = dict(self._start_positions)
# Record positions until user completes step
while not self._step_complete.is_set() and not self.stop_calibration:
try:
positions = self.device.bus.sync_read("Present_Position", normalize=False)
if positions:
# Validate the readings - filter out invalid/zero values
valid_positions = {}
for motor, pos in positions.items():
# Filter out clearly invalid readings (0, negative, or extreme values)
if _is_valid_position(pos):
valid_positions[motor] = pos
else:
logger.debug(f"Filtered invalid position for {motor}: {pos}")
# Only update if we have valid readings
if valid_positions:
for motor, pos in valid_positions.items():
if (
motor in prev_positions
and abs(pos - prev_positions[motor]) > _MAX_POSITION_JUMP
):
raise CalibrationDiscontinuityError(
"Motor discontinuity detected. Make sure to start "
"the calibration with the robot in a middle position "
"- all joints in the middle of their ranges."
)
prev_positions[motor] = pos
if motor in self._mins:
self._mins[motor] = min(self._mins[motor], pos)
self._maxes[motor] = max(self._maxes[motor], pos)
# Update cached ranges for the status endpoint.
self._cache_recorded_ranges(valid_positions)
time.sleep(0.05) # 20Hz update rate
except CalibrationDiscontinuityError:
raise
except Exception as e:
logger.warning(f"Error reading positions during recording: {e}")
time.sleep(0.2)
if self.stop_calibration:
logger.info("Range recording step cancelled due to stop request")
return
# Log the final recorded ranges for debugging
logger.info("Final recorded ranges:")
for motor in self._mins:
logger.info(
f" {motor}: min={self._mins[motor]}, max={self._maxes[motor]}, range={self._maxes[motor] - self._mins[motor]}"
)
# Validate ranges
same_min_max = [motor for motor in self._mins if self._mins[motor] == self._maxes[motor]]
if same_min_max:
raise ValueError(f"Some motors have the same min and max values: {same_min_max}")
# Check for insufficient range movement (less than 100 motor steps)
insufficient_range = []
for motor in self._mins:
range_diff = self._maxes[motor] - self._mins[motor]
if range_diff < _MIN_CALIBRATION_RANGE:
insufficient_range.append(f"{motor}: {range_diff}")
if insufficient_range:
logger.warning(
f"Some motors may not have been moved through sufficient range: {insufficient_range}"
)
logger.warning("Consider moving all joints through their full range of motion during calibration")
self._step_complete.clear()
logger.info("Range recording step completed")
def _complete_calibration(self):
"""Complete the calibration and save results"""
logger.info("Completing calibration...")
# Log motor information for debugging
logger.info("Motor configuration:")
for motor, m in self.device.bus.motors.items():
logger.info(f" {motor}: ID={m.id}, Model={m.model}")
# Create calibration dict
calibration = {}
for motor, m in self.device.bus.motors.items():
calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=0,
homing_offset=self._homing_offsets[motor],
range_min=self._mins[motor],
range_max=self._maxes[motor],
)
logger.info(
f"Calibration for {motor}: "
f"ID={m.id}, "
f"homing_offset={self._homing_offsets[motor]}, "
f"range_min={self._mins[motor]}, "
f"range_max={self._maxes[motor]}"
)
# Write and save calibration
self.device.calibration = calibration
self.device.bus.write_calibration(calibration)
self.device._save_calibration()
logger.info(f"Calibration saved to {self.device.calibration_fpath}")
# Robot-record write-back: if this calibration was launched from a tile,
# update the robot's port + config field for the side that was just calibrated.
request = self._current_request
if request is not None and request.robot_name:
from .utils.config import save_robot_record
if request.device_type == "teleop":
patch = {"leader_port": request.port, "leader_config": f"{request.config_file}.json"}
else:
patch = {"follower_port": request.port, "follower_config": f"{request.config_file}.json"}
try:
save_robot_record(request.robot_name, patch, allow_create=False)
except Exception as e:
logger.warning(f"Robot-record write-back failed for {request.robot_name}: {e}")
def _cleanup_and_finish(self, message: str, status: str = "completed"):
"""Clean up and finish calibration"""
self._cleanup_device()
self._recording_active = False
self._update_status(calibration_active=False, status=status, message=message)
def _cleanup_device(self):
"""Clean up device connection.
Uses safe_disconnect_device so a failed disconnect (flaky USB/serial)
force-releases the port/cameras instead of leaving the device busy and
blocking the next calibration/teleop/record run.
"""
if self.device:
logger.info("Disconnecting device...")
safe_disconnect_device(self.device, logger, context="calibration cleanup")
self.device = None
# Global calibration manager instance
calibration_manager = CalibrationManager()