mirror of
https://github.com/huggingface/lerobot.git
synced 2026-05-11 14:49:43 +00:00
feat: add native Svelte UI for robot recording, teleop, and eval
Adds a generic web dashboard at ui/ that works with any robot/teleop type in lerobot. Calls lerobot-record, lerobot-teleoperate, and lerobot-eval as background subprocesses. Features: - Setup tab: robot type/port/id, dynamic camera configuration - Record tab: dataset config, teleop device, start/stop recording - Teleop tab: start/stop teleoperation - Evaluate tab: policy path, env type, device config - Live MJPEG camera preview (paused during subprocess, auto-restarts) - Terminal-style log panel streaming subprocess stdout/stderr - Hardware discovery: serial ports, CAN interfaces, OpenCV cameras - Config persistence via localStorage - Dark theme, responsive camera grid Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LeRobot UI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Launch LeRobot UI — backend (FastAPI) + frontend (Svelte/Vite)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ---- Python dependencies ----
|
||||
if ! python -c "import fastapi" 2>/dev/null; then
|
||||
echo "Installing Python dependencies..."
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# ---- Node dependencies ----
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing Node dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# ---- Start backend ----
|
||||
echo "Starting backend server on :8000 ..."
|
||||
python server.py &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Give the backend a moment to bind its port
|
||||
sleep 1
|
||||
|
||||
# ---- Start frontend ----
|
||||
echo "Starting frontend dev server on :5173 ..."
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# ---- Cleanup on exit ----
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down..."
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " LeRobot UI → http://localhost:5173"
|
||||
echo " Backend API → http://localhost:8000"
|
||||
echo " Press Ctrl+C to stop"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
wait
|
||||
Generated
+1299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "lerobot-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
opencv-python>=4.9.0
|
||||
pydantic>=2.0.0
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
"""
|
||||
LeRobot UI — FastAPI Backend
|
||||
|
||||
Manages subprocess lifecycle for lerobot CLI tools, camera streaming via
|
||||
OpenCV MJPEG, hardware discovery, and a circular log buffer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="LeRobot UI Server", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAX_LOG_LINES = 500
|
||||
|
||||
_state: dict[str, Any] = {
|
||||
"mode": "idle", # idle | recording | teleoperation | evaluating
|
||||
"message": "Ready",
|
||||
"error": None,
|
||||
"episode_count": 0,
|
||||
"subprocess": None, # active Popen
|
||||
"subprocess_lock": threading.Lock(),
|
||||
"logs": deque(maxlen=MAX_LOG_LINES),
|
||||
"log_lock": threading.Lock(),
|
||||
"cameras": {}, # name -> {cap, thread, frame, running}
|
||||
"camera_lock": threading.Lock(),
|
||||
"pending_cameras": [], # list of camera dicts to restart after subprocess ends
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
with _state["log_lock"]:
|
||||
_state["logs"].append({"ts": _ts(), "msg": msg})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Camera streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _camera_reader(name: str, index_or_path: str | int) -> None:
|
||||
"""Background thread: reads frames from a camera and stores the latest."""
|
||||
# Attempt numeric index first, then string path
|
||||
try:
|
||||
src: int | str = int(index_or_path)
|
||||
except (ValueError, TypeError):
|
||||
src = str(index_or_path)
|
||||
|
||||
cap = cv2.VideoCapture(src)
|
||||
if not cap.isOpened():
|
||||
_log(f"[camera/{name}] Could not open {index_or_path}")
|
||||
with _state["camera_lock"]:
|
||||
if name in _state["cameras"]:
|
||||
_state["cameras"][name]["running"] = False
|
||||
return
|
||||
|
||||
_log(f"[camera/{name}] Opened {index_or_path}")
|
||||
|
||||
with _state["camera_lock"]:
|
||||
if name in _state["cameras"]:
|
||||
_state["cameras"][name]["cap"] = cap
|
||||
|
||||
while True:
|
||||
with _state["camera_lock"]:
|
||||
if name not in _state["cameras"] or not _state["cameras"][name].get("running"):
|
||||
break
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||
with _state["camera_lock"]:
|
||||
if name in _state["cameras"]:
|
||||
_state["cameras"][name]["frame"] = buf.tobytes()
|
||||
|
||||
cap.release()
|
||||
_log(f"[camera/{name}] Stopped")
|
||||
|
||||
|
||||
def _start_cameras(cameras: list[dict]) -> None:
|
||||
"""Start MJPEG reader threads for a list of camera dicts."""
|
||||
with _state["camera_lock"]:
|
||||
# Stop any existing camera with same name
|
||||
for cam in cameras:
|
||||
name = cam["name"]
|
||||
if name in _state["cameras"]:
|
||||
_state["cameras"][name]["running"] = False
|
||||
|
||||
time.sleep(0.1) # let threads notice the stop flag
|
||||
|
||||
with _state["camera_lock"]:
|
||||
for cam in cameras:
|
||||
name = cam["name"]
|
||||
entry: dict[str, Any] = {
|
||||
"cap": None,
|
||||
"thread": None,
|
||||
"frame": None,
|
||||
"running": True,
|
||||
"index_or_path": cam.get("index_or_path", 0),
|
||||
}
|
||||
t = threading.Thread(
|
||||
target=_camera_reader,
|
||||
args=(name, cam.get("index_or_path", 0)),
|
||||
daemon=True,
|
||||
)
|
||||
entry["thread"] = t
|
||||
_state["cameras"][name] = entry
|
||||
t.start()
|
||||
|
||||
|
||||
def _stop_all_cameras() -> None:
|
||||
with _state["camera_lock"]:
|
||||
for name in list(_state["cameras"].keys()):
|
||||
_state["cameras"][name]["running"] = False
|
||||
_log("[cameras] All camera streams stopped")
|
||||
|
||||
|
||||
def _mjpeg_generator(name: str):
|
||||
"""Yield MJPEG frames for a given camera name."""
|
||||
while True:
|
||||
frame = None
|
||||
with _state["camera_lock"]:
|
||||
cam = _state["cameras"].get(name)
|
||||
if cam:
|
||||
frame = cam.get("frame")
|
||||
|
||||
if frame:
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
|
||||
)
|
||||
time.sleep(0.033) # ~30 fps ceiling
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subprocess management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_output(proc: subprocess.Popen, label: str) -> None:
|
||||
"""Read stdout/stderr from a subprocess and feed into log buffer."""
|
||||
streams = []
|
||||
if proc.stdout:
|
||||
streams.append(proc.stdout)
|
||||
if proc.stderr:
|
||||
streams.append(proc.stderr)
|
||||
|
||||
import select as _select
|
||||
|
||||
while True:
|
||||
if proc.poll() is not None:
|
||||
# Drain remaining output
|
||||
for s in streams:
|
||||
for line in s:
|
||||
_log(f"[{label}] {line.rstrip()}")
|
||||
break
|
||||
|
||||
try:
|
||||
readable, _, _ = _select.select(streams, [], [], 0.1)
|
||||
for s in readable:
|
||||
line = s.readline()
|
||||
if line:
|
||||
_log(f"[{label}] {line.rstrip()}")
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def _watch_subprocess(label: str) -> None:
|
||||
"""Watch the active subprocess; clean up state when it exits."""
|
||||
with _state["subprocess_lock"]:
|
||||
proc = _state["subprocess"]
|
||||
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
proc.wait()
|
||||
rc = proc.returncode
|
||||
_log(f"[{label}] Process exited with code {rc}")
|
||||
|
||||
with _state["subprocess_lock"]:
|
||||
if _state["subprocess"] is proc:
|
||||
_state["subprocess"] = None
|
||||
_state["mode"] = "idle"
|
||||
_state["message"] = f"Finished (exit {rc})"
|
||||
if rc not in (0, -15, -2): # not clean exit / SIGTERM / SIGINT
|
||||
_state["error"] = f"Process exited with code {rc}"
|
||||
|
||||
# Restart camera preview if cameras were pending
|
||||
pending = _state.get("pending_cameras", [])
|
||||
if pending:
|
||||
_log("[cameras] Restarting camera preview after subprocess ended")
|
||||
_start_cameras(pending)
|
||||
|
||||
|
||||
def _launch(cmd: list[str], mode: str, label: str) -> None:
|
||||
"""Launch a subprocess, stop cameras first, set state."""
|
||||
with _state["subprocess_lock"]:
|
||||
if _state["subprocess"] is not None and _state["subprocess"].poll() is None:
|
||||
raise RuntimeError("A process is already running. Stop it first.")
|
||||
|
||||
# Stop cameras so the subprocess can access them
|
||||
_stop_all_cameras()
|
||||
|
||||
_log(f"[server] Launching: {' '.join(cmd)}")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
with _state["subprocess_lock"]:
|
||||
_state["subprocess"] = proc
|
||||
_state["mode"] = mode
|
||||
_state["message"] = f"Running {label}..."
|
||||
_state["error"] = None
|
||||
|
||||
# Pipe output
|
||||
t_out = threading.Thread(target=_read_output, args=(proc, label), daemon=True)
|
||||
t_out.start()
|
||||
|
||||
# Watch for completion
|
||||
t_watch = threading.Thread(target=_watch_subprocess, args=(label,), daemon=True)
|
||||
t_watch.start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discover_robot_types() -> list[str]:
|
||||
try:
|
||||
from importlib.util import find_spec
|
||||
|
||||
spec = find_spec("lerobot")
|
||||
if spec is None or spec.origin is None:
|
||||
return []
|
||||
robots_dir = Path(spec.origin).parent / "robots"
|
||||
return sorted(
|
||||
d.name
|
||||
for d in robots_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_")
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _discover_teleop_types() -> list[str]:
|
||||
try:
|
||||
from importlib.util import find_spec
|
||||
|
||||
spec = find_spec("lerobot")
|
||||
if spec is None or spec.origin is None:
|
||||
return []
|
||||
teleops_dir = Path(spec.origin).parent / "teleoperators"
|
||||
return sorted(
|
||||
d.name
|
||||
for d in teleops_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_")
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _discover_serial_ports() -> list[str]:
|
||||
patterns = [
|
||||
"/dev/ttyUSB*",
|
||||
"/dev/ttyACM*",
|
||||
"/dev/cu.usbmodem*",
|
||||
"/dev/cu.usbserial*",
|
||||
]
|
||||
ports: list[str] = []
|
||||
for pat in patterns:
|
||||
ports.extend(glob.glob(pat))
|
||||
return sorted(set(ports))
|
||||
|
||||
|
||||
def _discover_can_interfaces() -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "link", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
interfaces = []
|
||||
for line in result.stdout.splitlines():
|
||||
# Lines like: "3: can0: <...>"
|
||||
parts = line.strip().split(":")
|
||||
if len(parts) >= 2:
|
||||
iface = parts[1].strip()
|
||||
if iface.startswith("can"):
|
||||
interfaces.append(iface)
|
||||
return sorted(interfaces)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _discover_cameras() -> list[dict]:
|
||||
"""Scan OpenCV indices 0-9 and /dev/video* paths."""
|
||||
found: list[dict] = []
|
||||
checked: set = set()
|
||||
|
||||
# Numeric indices
|
||||
for idx in range(10):
|
||||
cap = cv2.VideoCapture(idx)
|
||||
if cap.isOpened():
|
||||
found.append({"index_or_path": idx, "label": f"Camera {idx} (index {idx})"})
|
||||
checked.add(idx)
|
||||
cap.release()
|
||||
|
||||
# /dev/video* paths on Linux
|
||||
for path in sorted(glob.glob("/dev/video*")):
|
||||
cap = cv2.VideoCapture(path)
|
||||
if cap.isOpened():
|
||||
# Avoid duplicate if already found by index
|
||||
label = f"Camera ({path})"
|
||||
found.append({"index_or_path": path, "label": label})
|
||||
cap.release()
|
||||
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CameraEntry(BaseModel):
|
||||
name: str
|
||||
type: str = "opencv"
|
||||
index_or_path: str | int = 0
|
||||
width: int = 640
|
||||
height: int = 480
|
||||
fps: int = 30
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
cameras: list[CameraEntry]
|
||||
|
||||
|
||||
class RecordRequest(BaseModel):
|
||||
robot_type: str
|
||||
robot_port: str = ""
|
||||
robot_id: str = ""
|
||||
robot_cameras: list[CameraEntry] = []
|
||||
teleop_type: str
|
||||
teleop_port: str = ""
|
||||
teleop_id: str = ""
|
||||
repo_id: str
|
||||
single_task: str
|
||||
num_episodes: int = 10
|
||||
fps: int = 30
|
||||
episode_time_s: int = 60
|
||||
reset_time_s: int = 5
|
||||
push_to_hub: bool = True
|
||||
private: bool = False
|
||||
display_data: bool = False
|
||||
|
||||
|
||||
class TeleopRequest(BaseModel):
|
||||
robot_type: str
|
||||
robot_port: str = ""
|
||||
robot_cameras: list[CameraEntry] = []
|
||||
teleop_type: str
|
||||
teleop_port: str = ""
|
||||
display_data: bool = False
|
||||
|
||||
|
||||
class EvalRequest(BaseModel):
|
||||
policy_path: str
|
||||
env_type: str
|
||||
n_episodes: int = 10
|
||||
batch_size: int = 1
|
||||
device: str = "cpu"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build cameras JSON arg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cameras_arg(cameras: list[CameraEntry]) -> str:
|
||||
d: dict[str, dict] = {}
|
||||
for cam in cameras:
|
||||
d[cam.name] = {
|
||||
"type": cam.type,
|
||||
"index_or_path": cam.index_or_path,
|
||||
"width": cam.width,
|
||||
"height": cam.height,
|
||||
"fps": cam.fps,
|
||||
}
|
||||
return json.dumps(d)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/status")
|
||||
def get_status() -> dict:
|
||||
with _state["subprocess_lock"]:
|
||||
proc = _state["subprocess"]
|
||||
proc_running = proc is not None and proc.poll() is None
|
||||
|
||||
with _state["camera_lock"]:
|
||||
active_cams = [
|
||||
name
|
||||
for name, cam in _state["cameras"].items()
|
||||
if cam.get("running") and cam.get("frame") is not None
|
||||
]
|
||||
|
||||
return {
|
||||
"mode": _state["mode"],
|
||||
"message": _state["message"],
|
||||
"error": _state["error"],
|
||||
"episode_count": _state["episode_count"],
|
||||
"process_running": proc_running,
|
||||
"active_cameras": active_cams,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/robots")
|
||||
def list_robots() -> dict:
|
||||
return {"types": _discover_robot_types()}
|
||||
|
||||
|
||||
@app.get("/api/teleops")
|
||||
def list_teleops() -> dict:
|
||||
return {"types": _discover_teleop_types()}
|
||||
|
||||
|
||||
@app.get("/api/hardware/serial-ports")
|
||||
def serial_ports() -> dict:
|
||||
return {"ports": _discover_serial_ports()}
|
||||
|
||||
|
||||
@app.get("/api/hardware/can-interfaces")
|
||||
def can_interfaces() -> dict:
|
||||
return {"interfaces": _discover_can_interfaces()}
|
||||
|
||||
|
||||
@app.get("/api/hardware/cameras")
|
||||
def hardware_cameras() -> dict:
|
||||
return {"cameras": _discover_cameras()}
|
||||
|
||||
|
||||
@app.post("/api/cameras/preview")
|
||||
def start_preview(req: PreviewRequest) -> dict:
|
||||
cams = [c.model_dump() for c in req.cameras]
|
||||
_state["pending_cameras"] = cams
|
||||
_start_cameras(cams)
|
||||
return {"status": "ok", "started": len(cams)}
|
||||
|
||||
|
||||
@app.post("/api/cameras/stop")
|
||||
def stop_preview() -> dict:
|
||||
_stop_all_cameras()
|
||||
_state["pending_cameras"] = []
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/camera/stream/{name}")
|
||||
def camera_stream(name: str):
|
||||
with _state["camera_lock"]:
|
||||
if name not in _state["cameras"]:
|
||||
raise HTTPException(status_code=404, detail=f"Camera '{name}' not active")
|
||||
return StreamingResponse(
|
||||
_mjpeg_generator(name),
|
||||
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/record/start")
|
||||
def start_record(req: RecordRequest) -> dict:
|
||||
cmd = ["lerobot-record"]
|
||||
|
||||
cmd += [f"--robot.type={req.robot_type}"]
|
||||
if req.robot_port:
|
||||
cmd += [f"--robot.port={req.robot_port}"]
|
||||
if req.robot_id:
|
||||
cmd += [f"--robot.id={req.robot_id}"]
|
||||
if req.robot_cameras:
|
||||
cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"]
|
||||
|
||||
cmd += [f"--teleop.type={req.teleop_type}"]
|
||||
if req.teleop_port:
|
||||
cmd += [f"--teleop.port={req.teleop_port}"]
|
||||
if req.teleop_id:
|
||||
cmd += [f"--teleop.id={req.teleop_id}"]
|
||||
|
||||
cmd += [
|
||||
f"--dataset.repo_id={req.repo_id}",
|
||||
f"--dataset.single_task={req.single_task}",
|
||||
f"--dataset.num_episodes={req.num_episodes}",
|
||||
f"--dataset.fps={req.fps}",
|
||||
f"--dataset.episode_time_s={req.episode_time_s}",
|
||||
f"--dataset.reset_time_s={req.reset_time_s}",
|
||||
f"--dataset.push_to_hub={'true' if req.push_to_hub else 'false'}",
|
||||
f"--dataset.private={'true' if req.private else 'false'}",
|
||||
f"--display_data={'true' if req.display_data else 'false'}",
|
||||
]
|
||||
|
||||
_state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras]
|
||||
|
||||
try:
|
||||
_launch(cmd, "recording", "lerobot-record")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/teleop/start")
|
||||
def start_teleop(req: TeleopRequest) -> dict:
|
||||
cmd = ["lerobot-teleoperate"]
|
||||
|
||||
cmd += [f"--robot.type={req.robot_type}"]
|
||||
if req.robot_port:
|
||||
cmd += [f"--robot.port={req.robot_port}"]
|
||||
if req.robot_cameras:
|
||||
cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"]
|
||||
|
||||
cmd += [f"--teleop.type={req.teleop_type}"]
|
||||
if req.teleop_port:
|
||||
cmd += [f"--teleop.port={req.teleop_port}"]
|
||||
|
||||
cmd += [f"--display_data={'true' if req.display_data else 'false'}"]
|
||||
|
||||
_state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras]
|
||||
|
||||
try:
|
||||
_launch(cmd, "teleoperation", "lerobot-teleoperate")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/eval/start")
|
||||
def start_eval(req: EvalRequest) -> dict:
|
||||
cmd = [
|
||||
"lerobot-eval",
|
||||
f"--policy.path={req.policy_path}",
|
||||
f"--env.type={req.env_type}",
|
||||
f"--eval.n_episodes={req.n_episodes}",
|
||||
f"--eval.batch_size={req.batch_size}",
|
||||
f"--policy.device={req.device}",
|
||||
]
|
||||
|
||||
try:
|
||||
_launch(cmd, "evaluating", "lerobot-eval")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/process/stop")
|
||||
def stop_process() -> dict:
|
||||
with _state["subprocess_lock"]:
|
||||
proc = _state["subprocess"]
|
||||
if proc is None or proc.poll() is not None:
|
||||
return {"status": "no_process"}
|
||||
try:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_log("[server] Sent SIGTERM to active process")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/process/kill")
|
||||
def kill_process() -> dict:
|
||||
with _state["subprocess_lock"]:
|
||||
proc = _state["subprocess"]
|
||||
if proc is None or proc.poll() is not None:
|
||||
return {"status": "no_process"}
|
||||
try:
|
||||
proc.send_signal(signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_log("[server] Sent SIGKILL to active process")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/logs")
|
||||
def get_logs() -> dict:
|
||||
with _state["log_lock"]:
|
||||
return {"logs": list(_state["logs"])}
|
||||
|
||||
|
||||
@app.post("/api/counter/reset")
|
||||
def reset_counter() -> dict:
|
||||
_state["episode_count"] = 0
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("[server] LeRobot UI backend starting on :8000")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
||||
+1128
File diff suppressed because it is too large
Load Diff
+504
@@ -0,0 +1,504 @@
|
||||
/* =========================================================
|
||||
LeRobot UI — Global Styles (Dark Theme)
|
||||
========================================================= */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* ---- CSS Variables ---- */
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #1a1d27;
|
||||
--panel-hover: #1f2333;
|
||||
--border: #30363d;
|
||||
--border-light: #21262d;
|
||||
--text: #e1e4e8;
|
||||
--text-muted: #8b949e;
|
||||
--text-faint: #484f58;
|
||||
--accent: #6e56cf;
|
||||
--accent-hover: #7c3aed;
|
||||
--accent-dim: rgba(110, 86, 207, 0.15);
|
||||
--error: #f85149;
|
||||
--error-dim: rgba(248, 81, 73, 0.15);
|
||||
--success: #3fb950;
|
||||
--success-dim: rgba(63, 185, 80, 0.15);
|
||||
--warning: #e3b341;
|
||||
--warning-dim: rgba(227, 179, 65, 0.15);
|
||||
--info: #58a6ff;
|
||||
--info-dim: rgba(88, 166, 255, 0.15);
|
||||
--red: #f85149;
|
||||
--red-dim: rgba(248, 81, 73, 0.2);
|
||||
--blue: #58a6ff;
|
||||
--blue-dim: rgba(88, 166, 255, 0.15);
|
||||
--purple: #bc8cff;
|
||||
--purple-dim: rgba(188, 140, 255, 0.15);
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ---- Reset ---- */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---- Typography ---- */
|
||||
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 600; }
|
||||
h3 { font-size: 0.875rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
/* ---- Cards / Panels ---- */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #e03b31;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.4rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---- Status Badges ---- */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-idle { background: var(--success-dim); color: var(--success); }
|
||||
.badge-recording { background: var(--red-dim); color: var(--red); }
|
||||
.badge-teleoperation { background: var(--blue-dim); color: var(--blue); }
|
||||
.badge-evaluating { background: var(--purple-dim); color: var(--purple); }
|
||||
|
||||
/* ---- Inputs ---- */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
|
||||
/* ---- Toggle / Switch ---- */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toggle-sublabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* ---- Section divider ---- */
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
/* ---- Status / Alert boxes ---- */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-error { background: var(--error-dim); border: 1px solid var(--error); color: var(--error); }
|
||||
.alert-success { background: var(--success-dim); border: 1px solid var(--success); color: var(--success); }
|
||||
.alert-warning { background: var(--warning-dim); border: 1px solid var(--warning); color: var(--warning); }
|
||||
.alert-info { background: var(--info-dim); border: 1px solid var(--info); color: var(--info); }
|
||||
|
||||
/* ---- Recording pulse indicator ---- */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.rec-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
animation: pulse 1s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Log terminal ---- */
|
||||
.log-terminal {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.log-ts {
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- Camera grid ---- */
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.camera-grid.cams-2 { grid-template-columns: 1fr 1fr; }
|
||||
.camera-grid.cams-3 { grid-template-columns: 1fr 1fr; }
|
||||
.camera-grid.cams-4 { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.camera-feed {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4/3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-feed img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Misc utilities ---- */
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 0.8rem; }
|
||||
.text-xs { font-size: 0.72rem; }
|
||||
.font-mono { font-family: 'Menlo', 'Monaco', monospace; }
|
||||
@@ -0,0 +1,8 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user