Teleoperation, data recording, and policy deployment for a single AgileX Piper arm.
One command drives everything: robotserver. It connects to the arm over CAN, runs a
control loop at fixed rate, optionally streams and records cameras, and takes input from a
gamepad, a keyboard, a leader arm, or a trained policy.
Run everything from the repo root.
conda env create -f environment_piper_new.yml
conda activate piper_new
pip install -e .This creates the piper_new env (Pinocchio from conda-forge, LeRobot + Piper SDK via pip)
and registers the robotserver CLI. The CLI is available whenever the env is active.
Add a convenience alias for the CAN bring-up script:
echo 'alias restart-can="'"$PWD"'/scripts/restart_can.sh"' >> ~/.bash_aliasesrestart-can # bring up the CAN bus (asks for your sudo password)
conda activate piper_new
robotserver --gamepad # drive the armCheck the bus came up before blaming the arm — you want left_piper, state UP:
ip -brief link show type canIf you see can0 instead, or state DOWN, run restart-can again. If the arm stops
responding mid-session, unplug the USB-CAN adapter, plug it back in, and re-run it.
robotserver --no-robot --vis --gamepad --no-camerasOpens a Meshcat viewer at http://127.0.0.1:7000/static/. Useful for checking IK and collision geometry with no arm and no cameras attached.
robotserver --gamepad| Input | Action |
|---|---|
| Left stick | Move in X / Y (world frame) |
| L2 / R2 | Move down / up (Z) |
| Right stick | Roll (↔) and pitch (↕) |
| L1 / R1 | Yaw |
| Cross ✕ | Close gripper |
| Circle ○ | Open gripper |
| Square ▢ | Reset to start pose |
| Triangle △ | Go to deposit pose |
| D-pad Up | Next episode (while --record) |
| D-pad Down | Discard episode (while --record) |
Translation is always in the world frame. Rotation defaults to the end-effector frame —
add --ee-world to rotate in the world frame instead.
robotserver --keyboard| Action | Key |
|---|---|
| Forward / back (±X) | w / s |
| Left / right (±Y) | a / d |
| Up / down (±Z) | q / e |
| Toggle gripper | Space (or v) |
| Reset position | x |
Steps are in the world/base frame. Use v if your terminal swallows Space.
Cameras start automatically when configured. Two flags adjust that:
robotserver --gamepad --show-cameras # local preview windows
robotserver --gamepad --no-cameras # skip capture entirelyUse --no-cameras when no RealSense is plugged in — otherwise startup fails with
No Intel RealSense devices detected.
# Pick up PET bottle
robotserver --show-cameras --gamepad --record --task pet --name sebastien
# Pick up aluminium can
robotserver --show-cameras --gamepad --record --task aluminium --name sebastien
# Free-form — you are prompted for the sentence on stdin
robotserver --show-cameras --gamepad --record --task custom --name sebastienThe three presets and the sentences they write:
--task |
Primary task | Secondary task |
|---|---|---|
pet |
Pick up PET bottle | Pick up aluminium can |
aluminium |
Pick up aluminium can | Pick up PET bottle |
custom |
typed at the prompt | — |
The two presets are the same pair in opposite order — the secondary is what the gamepad's
PS button selects when deploying a language-conditioned policy. Omit --task entirely
and it falls back to config.task.
| Flag | Meaning |
|---|---|
--record |
Write a LeRobot dataset |
--task |
pet, aluminium, or custom — see above |
--name |
Operator tag appended to the session folder |
--resume |
Continue the previous session |
Hotkeys while recording:
| Action | Keyboard | Gamepad |
|---|---|---|
| Next episode | → |
D-pad Up |
| Discard episode | ← |
D-pad Down |
| End session | Esc |
— |
Note the axes differ: the keyboard uses left/right, the gamepad uses up/down.
The --task string is written into tasks.parquet and is what language-conditioned
policies train against, so keep it consistent across sessions.
Sessions land in data/raw/YYYY-MM-DD_HH-MM-SS_<name>/. The convention is to keep raw
recordings immutable and promote good ones by hand:
data/
raw/ # every --record session lands here, untouched
reviewed/ # passed manual QC
rejected/ # bad demos, kept for reference
1_good_datasets/ # merged, release-quality — what training consumes
Recording is the first step of five. The order matters — each step assumes the one before it.
Dump it the moment a demo goes wrong — ← on the keyboard, D-pad Down on the gamepad.
This is by far the cheapest place to remove a bad episode: nothing is written, nothing has
to be renumbered, and no statistics go stale. Since you record with --gamepad in hand,
the D-pad is the one you will actually use.
If a bad demo made it to disk, drop it by index. This renumbers the survivors and updates the metadata in one shot:
lerobot-edit-dataset \
--repo_id 2026-08-18_pet_sebastien \
--root data/raw/2026-08-18_14-22-01_sebastien \
--operation.type delete_episodes \
--operation.episode_indices "[2, 4, 17]"To decide which indices, step through the episodes first:
lerobot-dataset-viz --root <dataset> --repo-id <name> --episode-index 0Each call opens a rerun window with the wrist video, joint states, and actions on one
timeline. Increment --episode-index until it errors, which is how you find the end.
If
delete_episodesaborts withEpisode length mismatch, the dataset has an episode whose video is longer than its data. See Faulty recordings from before the switch — it has to be handled before any deletion will succeed.
Recording writes PNG frames, not video — use_video = False in
config.py. Encode afterwards, once the session is safely on
disk and deletions are done:
python scripts/convert_images_to_video.py data/raw/2026-08-18_14-22-01_sebastienWrites a new dataset suffixed _video, leaving the frames untouched. --output <dir> to
redirect, --dry-run to preview. LeRobot has an equivalent operation if you prefer it:
lerobot-edit-dataset --repo_id <name> --root <dataset> \
--operation.type convert_image_to_videoSetting convert_images_to_video = True in config.py
converts automatically at the end of each session. The manual route is safer — automatic
conversion runs while the session is still open, so a crash there takes the session too.
It looks like it saves a post-processing step. It does not — it corrupts recordings.
LeRobot's live streaming encoder runs in a thread parallel to the data buffer, and the two desync on discard/re-record and at episode boundaries. The result is episodes whose video holds more frames than the data has rows: an aborted take's video leaks into the saved episode while its data is correctly dropped. Verified by inspection — the excess frames are real moving video with a hard scene cut exactly where the data ends.
With
use_video = Falseeach frame is written 1:1 with its data row, andconvert_images_to_videorebuilds the video from the parquet rows, so video and data match by construction. It cannot glitch. The cost is disk space and one command.
Datasets recorded while use_video = True may contain glitched episodes — video
frame-span greater than data row count. The data is clean and those episodes train fine;
only the video carries an unreferenced tail.
The damage shows up at deletion time. delete_episodes re-slices any mp4 holding both kept
and deleted episodes, and asserts episode length == video frame span for every kept
episode. One glitched kept episode aborts the whole operation:
AssertionError: Episode length mismatch: 412 vs 448
Detect — per episode, compare length against
round((to_timestamp - from_timestamp) * fps) for each camera in meta/episodes/*.parquet.
Fix — either include the glitched episodes in the same delete list as your real targets
(the re-encode trims the bad tails), or repair first by setting
to_timestamp = from_timestamp + length/fps.
Delete everything in one command with a multi-index list. Separate commands renumber
indices in between, so the second command targets the wrong episodes. In-place edits
auto-create a <name>_old backup.
Combine several sessions into one release dataset. Inputs must share a feature schema — same cameras, same state/action shapes:
lerobot-edit-dataset \
--new_repo_id pet_plus_aluminium_merged \
--new_root data/1_good_datasets/pet_plus_aluminium_merged \
--operation.type merge \
--operation.repo_ids "['session_a', 'session_b']" \
--operation.roots "['data/reviewed/session_a', 'data/reviewed/session_b']"Merging computes fresh statistics across all inputs, so step 5 is usually unnecessary afterwards.
Rebuilds meta/stats.json from the data actually on disk. Required whenever you change a
dataset's contents by any means other than the commands above — editing parquet columns
by hand, for instance. Skipping it is a silent failure: normalization keeps dividing by the
old σ and nothing errors.
lerobot-edit-dataset --repo_id <name> --root <dataset> \
--operation.type recompute_statsscripts/trim_episodes_to_pickup.py (by Aliser) cuts
each episode to [t_close - pre, t_close + post], where t_close is the first frame the
gripper closes — dropping long approach segments. It writes a new dataset; the source is
untouched.
# check the detection on one episode first
python scripts/trim_episodes_to_pickup.py inspect \
--src-repo-id <name> --src-root <dataset> --episode 0 --closed-threshold 0.048
# then trim everything
python scripts/trim_episodes_to_pickup.py trim \
--src-repo-id <name> --src-root <dataset> \
--dst-repo-id <name>_trimmed --dst-root <output> \
--pre 3.0 --post 1.0 --closed-threshold 0.048Override the default
--closed-threshold 0.5— it assumes a 0–1 gripper channel. Ours is metres, so every frame reads as closed and episodes get trimmed from frame 0, silently. Use 0.048 for datasets recorded after the gripper calibration (0 → 0.096 m), or 0.035 for older ones (0 → 0.07 raw).
Episode count, frames, features, fps — worth running before anything destructive:
lerobot-edit-dataset --repo_id <name> --root <dataset> \
--operation.type info --operation.show_features trueDrops a camera properly, updating videos, parquet, info.json, and stats together. Check
the exact feature name in meta/info.json first:
lerobot-edit-dataset --repo_id <name> --root <dataset> \
--operation.type remove_feature \
--operation.feature_names "['observation.images.topdown']"Add --new_repo_id and --new_root to write a copy instead of mutating the original.
robotserver --show-cameras --policy --gamepad --record \
--task pet \
--policy-type act \
--policy-path <checkpoint dir> \
--policy-repo-id <dataset the policy was trained on>--policy-type is one of act, diffusion, smolvla, multitask-Dit. It controls how
camera features are renamed for the checkpoint, so it has to match how the model was
trained. --policy-repo-id supplies the normalization stats and must point at the
training dataset.
Keeping --gamepad alongside --policy gives you manual override: the arm starts in
gamepad mode, Share enables the policy with the primary task, PS with the secondary
task. Press again to hand control back. Always run with the gamepad connected so you can
take over.
Slow policies (Diffusion, multi-task DiT, VLA) block the control loop on every forward
pass. --async runs the policy in LeRobot's policy_server and pulls action chunks from a
queue instead:
robotserver --show-cameras --policy --gamepad --async \
--policy-type diffusion \
--policy-path <ckpt> --policy-repo-id <dataset> \
--actions-per-chunk 50 --chunk-threshold 0.5Raise --chunk-threshold toward 0.7 if the arm still stalls between chunks.
config.yaml holds cameras, IK settings, deposit poses, and paths. Camera
setup is the part you are most likely to touch:
- OpenCV cameras: set
cam_indexafter runningpython scripts/find_cameras.py - RealSense cameras: set
backend: realsenseandserial_numberafter runningpython scripts/find_realsense_cameras.py
Binding by serial number is deterministic; /dev/video* ordering is not.
The firmware misreports jaw opening — both its origin and its scale are wrong for the
gripper actually fitted. gripper_calibration.py
converts between firmware units and true metres, and is the only place the two meet.
Everything else in the codebase speaks true metres (0 closed → 0.096 m open).
After a gripper swap, re-fit it with two measurements and no code: drive the jaws to each stop unloaded, read the firmware value, measure the opening with a tape, and replace the three constants at the top of that file.
| Symptom | Cause |
|---|---|
CAN bus connection was not properly initialized |
Bus is down — run restart-can, then check ip -brief link show type can |
No Intel RealSense devices detected |
Camera unplugged — add --no-cameras or fix the connection |
| Arm enables but won't move | Another robotserver may still hold the bus; check for a stale process |
| Readings are all zeros | No CAN frames arriving — check 24 V power, E-stop, and cable at both ends |
src/piper_teleop/robot_server/ control loop, controllers, cameras, policy, recorder
core/ IK, kinematics, Piper SDK interface, gripper calibration
scripts/ CAN bring-up, camera discovery, dataset helpers
URDF/Piper/ arm model + collision geometry for IK
config.yaml cameras, IK, paths, deposit poses
data/ recordings (gitignored)
outputs/ training checkpoints (gitignored)
Useful scripts:
| Script | Purpose |
|---|---|
scripts/restart_can.sh |
Bring up the CAN bus as left_piper at 1 Mbit/s |
scripts/find_cameras.py |
List OpenCV camera indices |
scripts/find_realsense_cameras.py |
List RealSense serial numbers |
scripts/piper_joint_diag.py |
Per-joint current, temperature, and fault diagnostics |
scripts/upload_to_huggingface.py |
Push a dataset to the Hub |
scripts/convert_images_to_video.py |
Convert recorded frames to video |