elrobot-training / scripts /run_policy.py
venayc's picture
Upload 31 files
59653ee verified
Raw
History Blame Contribute Delete
13.2 kB
"""Interactive policy-driven hardware loop.
Per SPACE-press tick:
1. Fetch one Frame from `inference/normvla`.
2. Build obs (state from position_norm, both camera JPEGs → tensors).
3. Tokenize the task prompt, run policy.predict_action_chunk.
4. Unnormalize chunk[0] → 6 goal_norm in [0, 1] (clipped).
5. Map each joint to a raw servo tick via the frame's inline
(range_min, range_max), clipped to that range.
6. Print preview, then send immediately (one SPACE = one send).
7. Auto-abort if any joint delta exceeds --max-delta-ticks.
8. After send, fetch again and print motion (position delta).
Keys: SPACE = predict+send | q / Ctrl-C = quit
Run:
uv run python scripts/run_policy.py \\
--checkpoint checkpoints/run/final \\
--task "place the yellow cube on top of the other" \\
--bus-serial 5AB9068807
`--server` defaults to localhost; pass it if the station daemon is on a
different host (e.g. --server ab-rpi5.server). `--bus-serial` is the ST3215
bus id from the station web interface.
"""
from __future__ import annotations
import argparse
import asyncio
import io
import logging
import sys
import termios
import time
import tty
from pathlib import Path
import numpy as np
import torch
from PIL import Image
_HERE = Path(__file__).resolve()
_REPO = _HERE.parents[4]
sys.path.insert(0, str(_REPO / "software" / "station" / "shared"))
sys.path.insert(0, str(_REPO))
from station_py import new_station_client, send_commands # noqa: E402
from target.gen_python.protobuf.drivers.inferences import normvla # noqa: E402
from target.gen_python.protobuf.drivers.st3215 import st3215 # noqa: E402
from target.gen_python.protobuf.station import commands, drivers # noqa: E402
from smolvla import SmolVLAPolicy # noqa: E402
from smolvla.normalize import normalize_state, unnormalize_action # noqa: E402
from smolvla.stats import load_stats # noqa: E402
QUEUE_ID = "inference/normvla"
ST3215_TARGET_POS_REGISTER = 0x2A
IMAGE_KEYS = ("observation.images.cam0", "observation.images.cam1")
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser()
p.add_argument("--checkpoint", type=Path, required=True,
help="Saved checkpoint dir with config.json, model.safetensors, stats.safetensors.")
p.add_argument("--task", required=True,
help='Natural-language prompt the policy is conditioned on. '
'Example: --task "place the yellow cube on top of the other"')
p.add_argument("--server", default="localhost",
help="Hostname of the machine running the station daemon.")
p.add_argument("--bus-serial", required=True,
help='ST3215 bus id from the station web interface (open the station '
'viewer, look at the bus card). Example: --bus-serial 5AB9068807')
p.add_argument("--motor-ids", default="1,2,3,4,5,6",
help="Comma-separated motor IDs, same order as joints in the Frame.")
p.add_argument("--timeout", type=float, default=5.0)
p.add_argument("--max-delta-ticks", type=int, default=200,
help="Abort a tick if the predicted goal is more than this many ticks "
"from the current position on any joint. 0 to disable.")
p.add_argument("--auto", action="store_true",
help="Run continuously — predict + send each tick, no SPACE. Ctrl-C to stop.")
p.add_argument("--max-ticks", type=int, default=0,
help="Stop after N ticks in --auto mode. 0 = unlimited.")
return p.parse_args()
# ---------------------------------------------------------------------------
# Queue helpers
async def fetch_frame(client, timeout: float) -> normvla.FrameReader:
qr = client.read_from_tail(QUEUE_ID, offset=b"\x00", limit=1, step=1, buf_size=1)
entry = await asyncio.wait_for(qr.data.get(), timeout=timeout)
if entry is None:
raise RuntimeError(f"{QUEUE_ID} closed without delivering a frame ({qr.err})")
return normvla.FrameReader(memoryview(bytes(entry.Data)))
async def fetch_fresh_frame(
client, last_id: bytes, timeout: float, retry_ms: int = 20,
) -> tuple[normvla.FrameReader, bytes]:
"""Fetch a frame whose global_frame_id differs from last_id.
Inference/normvla updates at ~10 Hz; if we fetch faster than that we get
the same frame back. Polls with a short sleep until a new id appears.
"""
while True:
frame = await fetch_frame(client, timeout)
fid = bytes(frame.get_global_frame_id())
if fid != last_id:
return frame, fid
await asyncio.sleep(retry_ms / 1000.0)
# ---------------------------------------------------------------------------
# Frame → model batch
def frame_to_batch(
frame: normvla.FrameReader,
stats: dict[str, torch.Tensor],
image_keys: tuple[str, ...],
device: torch.device,
) -> tuple[dict, list[tuple[int, int]]]:
"""Return (batch, per-joint (range_min, range_max))."""
joints = frame.get_joints() or []
images = frame.get_images() or []
if len(images) < len(image_keys):
raise RuntimeError(f"Frame has {len(images)} images but policy expects {len(image_keys)}")
state = torch.tensor(
[j.get_position_norm() for j in joints], dtype=torch.float32, device=device
).unsqueeze(0)
batch = {"observation.state": normalize_state(state, stats)}
for i, key in enumerate(image_keys):
jpeg = bytes(images[i].get_jpeg())
with Image.open(io.BytesIO(jpeg)) as im:
arr = np.asarray(im.convert("RGB"), dtype=np.uint8)
batch[key] = (
torch.from_numpy(arr.copy()).permute(2, 0, 1).float().unsqueeze(0).to(device) / 255.0
)
ranges = [(int(j.get_range_min()), int(j.get_range_max())) for j in joints]
return batch, ranges
def build_sync_write_command(
bus_serial: str, motor_ids: list[int], raw_goals: list[int]
) -> commands.DriverCommand:
"""One sync-write: all joints update their target position atomically."""
motors = [
st3215.ST3215SyncWriteCommand_MotorWrite(
motor_id=mid,
value=raw.to_bytes(2, byteorder="little"),
)
for mid, raw in zip(motor_ids, raw_goals)
]
sync = st3215.ST3215SyncWriteCommand(
address=ST3215_TARGET_POS_REGISTER,
motors=motors,
)
cmd = st3215.Command(target_bus_serial=bus_serial, sync_write=sync)
return commands.DriverCommand(
type=drivers.StationCommandType.STC_ST3215_COMMAND,
body=cmd.encode(),
)
# ---------------------------------------------------------------------------
# One-tick inference + command build
@torch.no_grad()
def predict_commands(
frame: normvla.FrameReader,
policy: SmolVLAPolicy,
stats: dict[str, torch.Tensor],
task: str,
bus_serial: str,
motor_ids: list[int],
device: torch.device,
) -> tuple[commands.DriverCommand, np.ndarray, np.ndarray, list[tuple[int, int]]]:
"""Returns (single sync-write command, predicted_goal_norm[6], raw_ticks[6], ranges)."""
batch, ranges = frame_to_batch(frame, stats, IMAGE_KEYS, device)
tokens, mask = policy.tokenize_task(task, device=device)
batch["observation.language.tokens"] = tokens
batch["observation.language.attention_mask"] = mask
pred_norm = policy.predict_action_chunk(batch)[0] # (chunk_size, 6)
pred_goal = unnormalize_action(pred_norm, stats) # in [0, 1]-ish
next_goal = pred_goal[0].cpu().clamp(0.0, 1.0).numpy() # (6,)
raws: list[int] = []
for g_norm, (rmin, rmax) in zip(next_goal, ranges):
raw = int(round(rmin + float(g_norm) * (rmax - rmin)))
raws.append(max(rmin, min(rmax, raw)))
cmd = build_sync_write_command(bus_serial, motor_ids, raws)
return cmd, next_goal, np.asarray(raws, dtype=np.int64), ranges
def print_preview(
motor_ids: list[int],
frame: normvla.FrameReader,
pred_goal_norm: np.ndarray,
pred_raw: np.ndarray,
ranges: list[tuple[int, int]],
header: str = "",
) -> int:
"""Print a compact comparison table and return max |delta_ticks| across joints."""
joints = frame.get_joints() or []
deltas = [int(r) - int(j.get_position()) for r, j in zip(pred_raw, joints)]
max_delta = max(abs(d) for d in deltas) if deltas else 0
if header:
print(f"[{header}] max|Δ|={max_delta}")
print(f" {'motor':>5} {'pos':>6} {'pred':>6} {'Δ':>6} {'pred_norm':>9}")
for mid, j, gn, raw, d in zip(motor_ids, joints, pred_goal_norm, pred_raw, deltas):
print(f" {mid:>5} {int(j.get_position()):>6} {int(raw):>6} "
f"{d:>+6} {gn:>9.4f}")
return max_delta
async def read_key() -> str:
return await asyncio.get_event_loop().run_in_executor(None, sys.stdin.read, 1)
async def run_one_tick(
args, client, policy, stats, motor_ids, device, tick: int, last_id: bytes,
) -> bytes:
t_start = time.perf_counter()
frame, frame_id = await fetch_fresh_frame(client, last_id, args.timeout)
t_fetched = time.perf_counter()
cmd, pred_norm, pred_raw, ranges = predict_commands(
frame, policy, stats, args.task, args.bus_serial, motor_ids, device
)
t_predicted = time.perf_counter()
max_delta = print_preview(
motor_ids, frame, pred_norm, pred_raw, ranges, header=f"tick {tick}"
)
fetch_ms = (t_fetched - t_start) * 1000
predict_ms = (t_predicted - t_fetched) * 1000
if args.max_delta_ticks and max_delta > args.max_delta_ticks:
print(f" aborted: max|Δ|={max_delta} > {args.max_delta_ticks} ticks "
f"(fetch {fetch_ms:.0f}ms, predict {predict_ms:.0f}ms)\n")
else:
await send_commands(client, [cmd])
send_ms = (time.perf_counter() - t_predicted) * 1000
print(f" sent sync-write (fetch {fetch_ms:.0f}ms, "
f"predict {predict_ms:.0f}ms, send {send_ms:.0f}ms, "
f"total {(time.perf_counter() - t_start) * 1000:.0f}ms)\n")
return frame_id
async def auto_loop(args, client, policy, stats, motor_ids, device) -> None:
print("\nauto: predict + send continuously. Ctrl-C to stop.\n")
tick = 0
last_id = b""
try:
while True:
if args.max_ticks and tick >= args.max_ticks:
print(f"reached --max-ticks={args.max_ticks}, stopping.")
return
tick += 1
last_id = await run_one_tick(
args, client, policy, stats, motor_ids, device, tick, last_id,
)
except KeyboardInterrupt:
print("\ninterrupted — stopping.")
async def interactive_loop(args, client, policy, stats, motor_ids, device) -> None:
print("\nSPACE = predict + send | q / Ctrl-C = quit\n")
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
tick = 0
last_id = b""
while True:
ch = await read_key()
if ch in ("q", "Q", "\x03", "\x04", ""):
print("quitting.")
return
if ch != " ":
continue
tick += 1
last_id = await run_one_tick(
args, client, policy, stats, motor_ids, device, tick, last_id,
)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ---------------------------------------------------------------------------
async def main_async() -> None:
args = parse_args()
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("run_policy")
motor_ids = [int(x) for x in args.motor_ids.split(",")]
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
stats_path = args.checkpoint / "stats.safetensors"
if not stats_path.exists():
raise SystemExit(f"No stats.safetensors in {args.checkpoint}.")
stats = {k: v.to(device) for k, v in load_stats(stats_path).items()}
print(f"loading {args.checkpoint} on {device} ...")
policy = SmolVLAPolicy.from_pretrained(
args.checkpoint,
config_overrides={"load_vlm_weights": False},
strict=False,
).to(device)
policy.eval()
print(f"task: {args.task!r}")
print(f"server: {args.server}")
print(f"bus: {args.bus_serial}")
print(f"motors: {motor_ids}")
print(f"safety: max_delta_ticks={args.max_delta_ticks}")
client = await new_station_client(args.server, logger)
if args.auto:
await auto_loop(args, client, policy, stats, motor_ids, device)
else:
await interactive_loop(args, client, policy, stats, motor_ids, device)
def main() -> None:
asyncio.run(main_async())
if __name__ == "__main__":
main()