-
Notifications
You must be signed in to change notification settings - Fork 31
Expand file tree
/
Copy pathrollout.py
More file actions
423 lines (373 loc) Β· 17.1 KB
/
Copy pathrollout.py
File metadata and controls
423 lines (373 loc) Β· 17.1 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
# 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.
"""Inference mode: drives the SO-101 follower with a trained policy.
Mirrors `app/teleoperating.py` in shape β single global session, mutex
with teleoperation/recording (the follower's serial bus can only be
opened once), `lerobot.scripts.lerobot_rollout` running as a subprocess
for clean cancellation. Hub-checkpoint refs are resolved to a local dir
via huggingface_hub.snapshot_download before we spawn the subprocess.
"""
from __future__ import annotations
import contextlib
import logging
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from .utils.config import setup_follower_calibration_file
logger = logging.getLogger(__name__)
class InferenceRequest(BaseModel):
follower_port: str
follower_config: str
policy_ref: str # opaque ref returned by /jobs/{id}/checkpoints
task: str = ""
cameras: dict[str, dict[str, Any]] = {}
duration_s: int = 60
inference_active: bool = False
_inference_proc: subprocess.Popen | None = None
_inference_started_at: float | None = None
_inference_rollout_started_at: float | None = None
_inference_meta: dict[str, Any] = {}
# Guards mutations to the globals above; held only for the short critical
# sections in start/stop/status.
_state_lock = threading.Lock()
_HUB_REF_RE = re.compile(r"^(?P<repo>[^@]+)@checkpoints/(?P<step_dir>\d+)$")
_HUB_ROOT_REF_RE = re.compile(r"^(?P<repo>[^@]+)@root$")
# lerobot prints this once per run, the moment its main control loop is
# about to take over from the setup phase. We watch stdout for it so the
# UI can present a "rollout time" separate from the multi-second policy
# load + bus connect + camera connect setup overhead.
_ROLLOUT_START_MARKER = "Rollout setup complete"
def _pump_stdout(proc: subprocess.Popen, log_handle) -> None:
"""Tee the subprocess's stdout to the log file and watch for the
rollout-start marker."""
global _inference_rollout_started_at
try:
for raw in iter(proc.stdout.readline, b""):
try:
line = raw.decode("utf-8", errors="replace")
except Exception:
continue
try:
log_handle.write(line)
log_handle.flush()
except Exception:
pass
if _inference_rollout_started_at is None and _ROLLOUT_START_MARKER in line:
_inference_rollout_started_at = time.time()
logger.info(
"Inference rollout main loop started after %.1fs of setup",
_inference_rollout_started_at - (_inference_started_at or _inference_rollout_started_at),
)
except Exception as exc:
logger.exception("Inference stdout pump failed: %s", exc)
finally:
with contextlib.suppress(Exception):
log_handle.close()
def _detect_device() -> str:
"""cuda β mps β cpu, picked once at start time."""
try:
import torch
if torch.cuda.is_available():
return "cuda"
if torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
def _resolve_policy_path(policy_ref: str) -> str:
"""Turn a checkpoints API ref into a local path that lerobot accepts.
Local refs are already absolute paths to a pretrained_model dir.
Hub refs look like 'user/repo@checkpoints/<step_dir>' where
<step_dir> is lerobot's zero-padded directory name (e.g. 000050) β we
forward it verbatim into snapshot_download's allow_patterns and the
resolved local path.
A 'user/repo@root' ref means the whole repo IS the pretrained_model
(no checkpoints sub-tree); the full repo is downloaded via
snapshot_download and its root is returned directly."""
if Path(policy_ref).is_dir():
return policy_ref
from huggingface_hub import snapshot_download
m = _HUB_REF_RE.match(policy_ref)
if m:
repo_id, step_dir = m.group("repo"), m.group("step_dir")
local_root = snapshot_download(
repo_id=repo_id,
repo_type="model",
allow_patterns=[f"checkpoints/{step_dir}/pretrained_model/*"],
)
return str(Path(local_root) / "checkpoints" / step_dir / "pretrained_model")
m = _HUB_ROOT_REF_RE.match(policy_ref)
if m:
return snapshot_download(repo_id=m.group("repo"), repo_type="model")
raise ValueError(f"Unrecognised policy ref: {policy_ref!r}")
def _format_cameras_arg(cameras: dict[str, dict[str, Any]]) -> str:
"""Convert {name: {type, camera_index, width, height, fps}} into
lerobot's CLI dict syntax. The frontend key `camera_index` is
remapped to lerobot's `index_or_path`."""
parts = []
for name, cfg in cameras.items():
remapped = {
("index_or_path" if k == "camera_index" else k): v for k, v in cfg.items() if v is not None
}
body = ", ".join(f"{k}: {v}" for k, v in remapped.items())
parts.append(f"{name}: {{{body}}}")
return "{" + ", ".join(parts) + "}"
# Exception lines at the tail of a Python traceback look like
# "RuntimeError: ..." or "lerobot.errors.DeviceNotConnectedError: ...".
_EXC_LINE_RE = re.compile(r"^[A-Za-z_][\w.]*(?:Error|Exception|Interrupt|Timeout|Failure)\b")
def _extract_error_from_log(log_path: str | None) -> str | None:
"""Pull the meaningful error out of a failed rollout's log so the UI can
show it directly instead of telling the user to open a file in the cache."""
if not log_path:
return None
try:
# Only the tail matters; avoid materializing a multi-MB verbose log.
with open(log_path, "rb") as fh:
fh.seek(0, os.SEEK_END)
fh.seek(max(0, fh.tell() - 64 * 1024))
data = fh.read()
except OSError:
return None
tail = data.decode("utf-8", errors="replace").splitlines()[-50:]
# Prefer the last exception line + everything after it (the message body).
exc_idx = next((i for i in range(len(tail) - 1, -1, -1) if _EXC_LINE_RE.match(tail[i])), None)
if exc_idx is not None:
snippet = "\n".join(tail[exc_idx:]).strip()
else:
non_empty = [ln for ln in tail if ln.strip()]
snippet = "\n".join(non_empty[-6:]).strip()
snippet = re.sub(r"\n\s*\n+", "\n", snippet)
if len(snippet) > 500:
snippet = snippet[:500].rstrip() + "β¦"
return snippet or None
def _friendly_hint(error_text: str | None) -> str | None:
"""A plain-language, actionable headline for the common SO-101 failures."""
if not error_text:
return None
low = error_text.lower()
if "overload" in low or "torque_enable" in low:
return (
"A motor overloaded β usually the gripper holding an object too hard. Release the object / "
"open the gripper and power-cycle the arm before trying again."
)
if "missing motor ids" in low or "motor check failed" in low:
return (
"A follower motor isn't responding (often the gripper, id 6). If a skill was holding an object "
"it likely overloaded β remove it, power-cycle the arm, then try teleoperation first."
)
if "could not connect" in low or "failed to connect" in low or "not connected" in low:
return "Couldn't connect to the arm β make sure it's plugged in, powered on, and on the right port."
if "frame is too old" in low or "no frame" in low or "frame timeout" in low:
return (
"A camera can't keep up β frames are arriving too slowly. Lower its resolution/FPS, "
"set FOURCC=MJPG, and close other heavy apps, then try again."
)
if "failed to set capture_" in low or "actual_width" in low or "actual_height" in low:
return "A camera doesn't support the configured resolution β open camera settings and click Auto."
if "permission" in low and ("port" in low or "com" in low):
return "Couldn't open the serial port β close anything else using it, or run `lelab --stop`."
return None
# Errors that mean the policy actually ran and only shutdown/cleanup tripped β
# e.g. disabling torque on a gripper still holding an object. Connection-loss
# errors are deliberately excluded: a mid-run disconnect is a real failure.
_CLEANUP_MARKERS = ("overload", "torque_enable")
def _classify_outcome(rc: int | None, rollout_started: bool, error_text: str | None) -> str:
"""ok | ran_with_warning | failed.
A non-zero exit *after* the rollout main loop started, where the error is a
torque-disable/overload on shutdown, means the skill ran but a motor (usually
the loaded gripper) complained during cleanup β that's a warning, not a
failure, so the UI shouldn't call a working run "failed"."""
if not rc:
return "ok"
low = (error_text or "").lower()
if rollout_started and any(marker in low for marker in _CLEANUP_MARKERS):
return "ran_with_warning"
return "failed"
def handle_start_inference(request: InferenceRequest) -> dict[str, Any]:
"""Start a one-shot rollout subprocess. Returns a dict β the route
layer turns it into a JSON response or HTTPException as appropriate."""
global inference_active, _inference_proc, _inference_started_at
global _inference_rollout_started_at, _inference_meta
# Mutex with teleop and recording: all three drive the same serial bus.
from . import record as _record, teleoperate as _teleoperate
with _state_lock:
if _teleoperate.teleoperation_active:
return {
"success": False,
"status_code": 409,
"message": "Teleoperation is currently active. Stop it first.",
}
if _record.recording_active:
return {
"success": False,
"status_code": 409,
"message": "Recording is currently active. Stop it first.",
}
if inference_active:
return {
"success": False,
"status_code": 409,
"message": "Inference is already active. Stop it first.",
}
# Claim the slot now so a concurrent caller losing the race sees us.
inference_active = True
try:
# `setup_follower_calibration_file` returns the basename without the
# .json extension. We need that stripped form for `--robot.id`,
# because lerobot appends `.json` itself when constructing
# `calibration_dir / f"{id}.json"`.
follower_id = setup_follower_calibration_file(request.follower_config)
policy_path = _resolve_policy_path(request.policy_ref)
cmd = [
sys.executable,
"-m",
"lerobot.scripts.lerobot_rollout",
"--strategy.type=base",
f"--policy.path={policy_path}",
f"--policy.device={_detect_device()}",
"--robot.type=so101_follower",
f"--robot.port={request.follower_port}",
f"--robot.id={follower_id}",
f"--task={request.task}",
f"--duration={request.duration_s}",
]
if request.cameras:
cmd.append(f"--robot.cameras={_format_cameras_arg(request.cameras)}")
log_dir = Path.home() / ".cache" / "huggingface" / "lerobot" / "inference_logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{int(time.time())}.log"
log_handle = log_path.open("w", buffering=1)
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
# Feed a single newline into stdin so SOFollower.calibrate()'s
# `input("Press ENTER to use the calibration file ...")` returns "" and
# writes the existing calibration to the motors instead of hanging
# forever waiting for an interactive operator. Subsequent input()
# calls in the recalibration path get EOF and raise β which is fine,
# because we never want to enter that path from the UI.
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
try:
assert proc.stdin is not None
proc.stdin.write(b"\n")
proc.stdin.flush()
proc.stdin.close()
except Exception as exc:
logger.warning("Failed to seed stdin for inference subprocess: %s", exc)
threading.Thread(
target=_pump_stdout,
args=(proc, log_handle),
name="inference-stdout-pump",
daemon=True,
).start()
except Exception as exc:
logger.exception("Failed to start inference")
# Subprocess never started β release the slot.
with _state_lock:
inference_active = False
return {"success": False, "status_code": 500, "message": f"Failed to start inference: {exc}"}
with _state_lock:
_inference_proc = proc
_inference_started_at = time.time()
_inference_rollout_started_at = None
_inference_meta = {
"policy_ref": request.policy_ref,
"duration_s": request.duration_s,
"log_path": str(log_path),
}
logger.info("Inference started: pid=%s policy=%s", proc.pid, policy_path)
return {"success": True, "message": "Inference started", "log_path": str(log_path)}
def handle_stop_inference() -> dict[str, Any]:
global inference_active, _inference_proc, _inference_started_at
global _inference_rollout_started_at, _inference_meta
with _state_lock:
if not inference_active or _inference_proc is None:
return {"success": False, "status_code": 409, "message": "No inference is active"}
proc = _inference_proc
try:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning("Inference did not exit in 5s; killing")
proc.kill()
proc.wait()
except Exception as exc:
logger.exception("Stop inference: %s", exc)
with _state_lock:
inference_active = False
_inference_proc = None
_inference_started_at = None
_inference_rollout_started_at = None
_inference_meta = {}
return {"success": True, "message": "Inference stopped"}
def handle_inference_status() -> dict[str, Any]:
global inference_active, _inference_proc, _inference_started_at
global _inference_rollout_started_at, _inference_meta
# Finalise state lazily if the subprocess died on its own.
with _state_lock:
proc = _inference_proc
if proc is not None and proc.poll() is not None:
rc = proc.returncode
logger.info("Inference subprocess exited rc=%s", rc)
finished_meta = _inference_meta
finished_started = _inference_started_at
finished_rollout_started = _inference_rollout_started_at
inference_active = False
_inference_proc = None
_inference_started_at = None
_inference_rollout_started_at = None
_inference_meta = {}
# On failure, surface the real error from the log so the UI doesn't
# have to send the user digging through the cache.
error = _extract_error_from_log(finished_meta.get("log_path")) if rc else None
outcome = _classify_outcome(rc, finished_rollout_started is not None, error)
return {
"inference_active": False,
"exited": True,
"exit_code": rc,
"outcome": outcome,
"error": error,
"hint": _friendly_hint(error),
"policy_ref": finished_meta.get("policy_ref"),
"duration_s": finished_meta.get("duration_s"),
"log_path": finished_meta.get("log_path"),
"started_at": finished_started,
"rollout_started_at": finished_rollout_started,
"rollout_elapsed_s": 0,
"elapsed_s": 0,
}
elapsed = (time.time() - _inference_started_at) if _inference_started_at else 0
rollout_elapsed = time.time() - _inference_rollout_started_at if _inference_rollout_started_at else 0
return {
"inference_active": inference_active,
"started_at": _inference_started_at,
"rollout_started_at": _inference_rollout_started_at,
"elapsed_s": elapsed,
"rollout_elapsed_s": rollout_elapsed,
"duration_s": _inference_meta.get("duration_s"),
"policy_ref": _inference_meta.get("policy_ref"),
"log_path": _inference_meta.get("log_path"),
}