marionette-js / tests /scripts /dance_compare.py
RemiFabre
tests/scripts: dance playback comparison (python_native vs marionette_style)
7ea69d7
Raw
History Blame Contribute Delete
7.16 kB
"""Compare dance playback: Python's play_move vs Marionette-style stream.
Plays the same dance from pollen-robotics/reachy-mini-dances-library
via two different playback strategies, captures the actual robot
state during each run, dumps CSVs.
Strategies:
- python_native : reachy.play_move(move, play_frequency=100). Internally
that's three separate set_target_* commands per tick at 100 Hz.
- marionette_style : one combined set_target() per tick at 50 Hz, with
lead compensation (90 ms antennas, 205 ms head) applied to the
sampled-from-motion time. This mirrors what the JS Marionette app
does over WebRTC.
N runs per strategy; soft return to base between runs.
"""
import csv
import math
import sys
import threading
import time
from pathlib import Path
import numpy as np
from reachy_mini import ReachyMini
from reachy_mini.motion.recorded_move import RecordedMove, RecordedMoves
DATASET = "pollen-robotics/reachy-mini-dances-library"
DANCE = sys.argv[1] if len(sys.argv) > 1 else "head_tilt_roll"
N_RUNS = 5
CAPTURE_HZ = 50.0
CAPTURE_PERIOD = 1.0 / CAPTURE_HZ
HEAD_LEAD_S = 0.205
ANTENNA_LEAD_S = 0.090
MARIONETTE_STREAM_HZ = 50.0
MARIONETTE_STREAM_PERIOD = 1.0 / MARIONETTE_STREAM_HZ
OUT_DIR = Path("/Users/remi/Downloads/dance-compare")
OUT_DIR.mkdir(parents=True, exist_ok=True)
def sample_move_at(move: RecordedMove, t: float):
"""Evaluate the move at t (clipped) — returns (head 4x4, antennas, body_yaw)."""
t_clipped = min(max(0.0, t), move.timestamps[-1] - 1e-3)
return move.evaluate(t_clipped)
def capture_loop(mini: ReachyMini, samples: list, stop_event: threading.Event, t0: float):
"""Background thread: poll cached state at CAPTURE_HZ, append (t, head_flat16, antennas)."""
tick = 0
while not stop_event.is_set():
now = time.perf_counter()
t = now - t0
try:
head = mini.get_current_head_pose() # 4x4 ndarray
ant = mini.get_present_antenna_joint_positions()
samples.append((t, head.flatten().tolist(), list(ant)))
except Exception as e:
print(f" capture read error at t={t:.3f}: {e}")
tick += 1
next_tick = t0 + tick * CAPTURE_PERIOD
sleep_for = next_tick - time.perf_counter()
if sleep_for > 0:
time.sleep(sleep_for)
def write_csv(path: Path, move: RecordedMove, samples: list):
"""CSV columns: t_s, cmd_head[16], cmd_ant_l, cmd_ant_r, act_head[16], act_ant_l, act_ant_r.
Commanded values are evaluated analytically from the move at each
measured t — same way the JS benchmark CSVs are structured.
"""
headers = ["t_s"]
headers += [f"cmd_h{i}" for i in range(16)]
headers += ["cmd_ant_l_deg", "cmd_ant_r_deg"]
headers += [f"act_h{i}" for i in range(16)]
headers += ["act_ant_l_deg", "act_ant_r_deg"]
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(headers)
for t, head_actual_flat, ant_actual in samples:
if t < 0 or t > move.duration + 0.2:
continue
head_cmd, ant_cmd, _ = sample_move_at(move, t)
row = [f"{t:.4f}"]
row += [f"{v:.6f}" for v in head_cmd.flatten()]
row += [f"{math.degrees(ant_cmd[0]):.3f}", f"{math.degrees(ant_cmd[1]):.3f}"]
row += [f"{v:.6f}" for v in head_actual_flat]
row += [f"{math.degrees(ant_actual[0]):.3f}", f"{math.degrees(ant_actual[1]):.3f}"]
w.writerow(row)
def reset_to_base(mini: ReachyMini):
"""Smooth goto base + small settle. Run between each measurement run."""
base = np.eye(4)
mini.goto_target(base, antennas=[-0.1745, 0.1745], duration=0.8)
time.sleep(1.0)
def run_python_native(mini: ReachyMini, move: RecordedMove, run_idx: int):
"""play_move at 100 Hz; the SDK's stock playback path."""
samples = []
stop = threading.Event()
t0 = time.perf_counter()
capture_thread = threading.Thread(
target=capture_loop, args=(mini, samples, stop, t0), daemon=True,
)
capture_thread.start()
try:
mini.play_move(move, play_frequency=100.0, initial_goto_duration=0.0)
# Let the actuator settle for a beat so the tail of the motion
# appears in capture.
time.sleep(0.3)
finally:
stop.set()
capture_thread.join(timeout=1.0)
out = OUT_DIR / f"{DANCE}-python_native-run{run_idx:02d}.csv"
write_csv(out, move, samples)
return out
def run_marionette_style(mini: ReachyMini, move: RecordedMove, run_idx: int):
"""One combined set_target per tick at 50 Hz with lead compensation."""
samples = []
stop = threading.Event()
t0 = time.perf_counter()
capture_thread = threading.Thread(
target=capture_loop, args=(mini, samples, stop, t0), daemon=True,
)
capture_thread.start()
try:
tick = 0
while True:
now = time.perf_counter()
t = now - t0
if t > move.duration:
break
t_head = min(t + HEAD_LEAD_S, move.duration - 1e-3)
t_ant = min(t + ANTENNA_LEAD_S, move.duration - 1e-3)
head_for_head, _, body_yaw_h = sample_move_at(move, t_head)
_, ant_for_ant, _ = sample_move_at(move, t_ant)
mini.set_target(
head=head_for_head,
antennas=list(ant_for_ant),
body_yaw=float(body_yaw_h),
)
tick += 1
next_tick = t0 + tick * MARIONETTE_STREAM_PERIOD
sleep_for = next_tick - time.perf_counter()
if sleep_for > 0:
time.sleep(sleep_for)
time.sleep(0.3)
finally:
stop.set()
capture_thread.join(timeout=1.0)
out = OUT_DIR / f"{DANCE}-marionette_style-run{run_idx:02d}.csv"
write_csv(out, move, samples)
return out
def main():
print(f"Loading dance: {DANCE} from {DATASET}")
library = RecordedMoves(DATASET)
if DANCE not in library.list_moves():
print(f"Dance '{DANCE}' not found. Available: {library.list_moves()}")
sys.exit(2)
move = library.get(DANCE)
print(f" duration: {move.duration:.2f} s, frames: {len(move.timestamps)}")
print("Connecting to robot…")
with ReachyMini() as mini:
print("Connected. Initial reset to base…")
reset_to_base(mini)
all_outputs = []
# Interleave the two methods so any drift in conditions (WiFi,
# robot temp) affects both equally rather than biasing one.
for run_idx in range(1, N_RUNS + 1):
print(f"\n--- Run {run_idx}/{N_RUNS}: python_native ---")
out = run_python_native(mini, move, run_idx)
print(f" → {out.name}")
all_outputs.append(out)
reset_to_base(mini)
print(f"--- Run {run_idx}/{N_RUNS}: marionette_style ---")
out = run_marionette_style(mini, move, run_idx)
print(f" → {out.name}")
all_outputs.append(out)
reset_to_base(mini)
print(f"\nWrote {len(all_outputs)} CSVs to {OUT_DIR}")
if __name__ == "__main__":
main()