August 10, 2026
Understanding Reference Motion Generation for Imitation Learning with OpenDuckMini
A guide on how to construct the reference motion for the OpenDuckMini which is key to learning to walk via imitation learning

By Dillon de Silva
12 min read
A guide on how to construct the reference motion for the OpenDuckMini which is key to learning to walk via imitation learning
In October 2023, Disney captured the imagination of many people through their presentation of a bipedal, emotive BDX droid based on the Star Wars films. An interesting aspect about their design was the robot's smooth and robust bipedal motion. Creating systems which have such motion is a central challenge in robotics and notably advancements in simulation + reinforcement learning have been a core enabler in current progress.
The OpenDuckMini is an open source project inspired by the BDX droid and provides guidance on how to assemble such a system. It was originally created by Antoine Pirrone and has inspired hobbyists around the world. Understanding robotics code can be intimidating, particularly the imitation learning aspects. This is an amazing project and I found working through its internals to be a rewarding challenge in terms of understanding how this robot learned to walk.
In short, I'm hoping to cover a few things here which can be especially useful for ML novices new to understanding how imitation learning in robots works. In this article specifically, we will focus on:
- Unpack the
.urdfrobot format - Use PlaCo to generate a reference walking pattern for the OpenDuckMini
- Fit polynomial coefficients to that motion so it's ready to be used as a training signal for imitation learning
Through doing this, we essentially unpack a key component enabling walking in the original OpenDuckMini project.
At any point in this article, full source code (where snippets are taken from) can be found in my re-implementation of the walk engine.
Alternatively, it is also possible to navigate the original walk engine in the OpenDuckMini repo.
Understanding the Motion Learning Pipeline
Designing robots that have intentional, natural and expressive motion is key to making them usable within real-world applications [1, 2, 3]. However, doing this for complex motions (such as a bipedal gait cycle) is challenging through traditional physics based modelling approaches alone.
This is for a few reasons:
- A large number of joints and actuators exist within complex robots โ designing robust control systems that can operate across all of them is a challenging endeavour.
- For highly specific motion types (such as those in creative applications), there can be a translational barrier between designers wishing to achieve a specific target motion and the computational modelling required to realise this.
Initial Setup
Before we get started, there are some recommended setup things we need to knock out of the way:
- Create a new project directory for our custom walk engine from scratch.
- Clone the repo from https://github.com/apirrone/Open_Duck_Mini and copy all the robot files into your directory.
- Let's do a sanity check and have a snoop around some of the robot
.urdffile.
As mentioned previously, please also feel free to use my re-implementation of the walk engine or the original reference motion generation code for further examples.
Building the Walk Engine with PlaCo
PlaCo is a motion planning and control library by the Rhoban research institute in France. We can take robot models that have been converted to a .urdf format and use this library to create trajectories that can act as references for downstream tasks we wish to do (e.g. imitation learning) [4].
To ground why we are using a platform like PlaCo, I'll try to answer some questions that might arise. Understanding these can help build a further intuition about why reference motion is key.
Why do we need PlaCo? Could we not just use a physics simulator directly such as MuJoCo?
The OpenDuckMini repository also uses MuJoCo and it can be confusing to understand how PlaCo ties into all this. After all, could we not just generate the reference motion in MuJoCo ONLY?
This is an interesting question and one way I explained it to a friend was to think about it this way โ Suppose you had a quadruped dog (much like Spot by Boston Dynamics) and you want to train it to walk on both slightly rough and flat terrain across BOTH Mars and Earth.
- Ideally, its gait shouldn't change significantly regardless of the physical environment we are in. Think about the way you walk for instance โ good chance that even under different terrains or even the extreme case of experiencing different gravity, your motion would still follow something stylistic of the human gait.
- The environmental physics however IS subject to change โ this is what we want to learn and adapt to.
Consequently, it serves as a useful pipeline to have a reference motion we wish to learn (which we obtain via PlaCo) and then later use a dedicated physics simulator which subjects us to desired physics as our robot learns to try to achieve the reference motion. This is a central idea behind getting robots to learn motion via imitation learning.
Crucially, it is worth noting that PlaCo is NOT a comprehensive physics simulator. Instead, it is a tool that uses inverse kinematics/dynamics (IK/ID) to compute a trajectory for a moving body based on a set of motion tasks we prescribe to it. Since it is simpler in nature, we can use it to generate motions which whilst they may lack underlying physical realism, still serve as a good reference point.
The main benefit of using MuJoCo later on for imitation learning is we can compare it to this desired reference motion (which can be defined much more freely and creatively even if some of its physics are slightly mismatched) and then have a model learn to replicate the motion in a physics-constrained environment.
What is PlaCo doing under the hood?
In the diagram above, I've provided a simplified layout of what PlaCo is doing internally. This is largely just to ensure it is less of a black box while we use it and I highly recommend cloning their original repo and snooping around. Some of the core maths/logic can be intimidating, however upon navigating this repo you will have a much stronger understanding of PlaCo's model and how what is happening when we generate a walking pattern.
Inspecting and Loading the .urdf Model
What's in a Link?
Let's take a moment to briefly examine the .urdf file. I found this to be quite helpful for seeing what is required to define a robot model. Compared to the MJCF XML format (i.e. MuJoCo modelling), it is worth noting that the URDF schema is significantly simpler to understand because there appears to be fewer physics-related specifications that need to be provided within the file.
<link name="left_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="1e-9" />
<inertia ixx="0" ixy="0" ixz="0" iyy="0" iyz="0" izz="0" />
</inertial>
</link><link name="left_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="1e-9" />
<inertia ixx="0" ixy="0" ixz="0" iyy="0" iyz="0" izz="0" />
</inertial>
</link>All links in a .urdf file must have an inertial property which describes the mass of the component in addition to its moment of inertia. Through examining other links (such as the one below), we can also understand their associated properties better:
- Meshes: These describe the 3D model associated with the link
- Material name: Includes colours or visual textures to be applied to the geometry
<link name="foot_assembly">
...
<visual>
<origin xyz="0.016060000000000015236 0.22230000000000008087 0.10905000000000000804" rpy="-1.570796326794896558 -1.5415933572433602064e-29 0" />
<geometry>
<mesh filename="package:///foot_top.stl"/>
</geometry>
<material name="foot_top_material">
<color rgba="0.98039215686274505668 0.71372549019607844922 0.0039215686274509803377 1.0"/>
</material>
</visual>
<collision>
<origin xyz="0.016060000000000015236 0.22230000000000008087 0.10905000000000000804" rpy="-1.570796326794896558 -1.5415933572433602064e-29 0" />
<geometry>
<mesh filename="package:///foot_top.stl"/>
</geometry>
</collision>
<inertial>
<origin xyz="0.011071805405810810838 -0.024660828914054331445 0.019062631505101720886" rpy="0 0 0"/>
<mass value="0.075240000000000001323" />
<inertia ixx="1.8748234895611842787e-05" ixy="1.5883913417291485579e-06" ixz="-6.5235393392554439271e-09" iyy="6.7442711017631486138e-05" iyz="5.6739084804828818974e-08" izz="6.0609999941114232999e-05" />
</inertial>
</link><link name="foot_assembly">
...
<visual>
<origin xyz="0.016060000000000015236 0.22230000000000008087 0.10905000000000000804" rpy="-1.570796326794896558 -1.5415933572433602064e-29 0" />
<geometry>
<mesh filename="package:///foot_top.stl"/>
</geometry>
<material name="foot_top_material">
<color rgba="0.98039215686274505668 0.71372549019607844922 0.0039215686274509803377 1.0"/>
</material>
</visual>
<collision>
<origin xyz="0.016060000000000015236 0.22230000000000008087 0.10905000000000000804" rpy="-1.570796326794896558 -1.5415933572433602064e-29 0" />
<geometry>
<mesh filename="package:///foot_top.stl"/>
</geometry>
</collision>
<inertial>
<origin xyz="0.011071805405810810838 -0.024660828914054331445 0.019062631505101720886" rpy="0 0 0"/>
<mass value="0.075240000000000001323" />
<inertia ixx="1.8748234895611842787e-05" ixy="1.5883913417291485579e-06" ixz="-6.5235393392554439271e-09" iyy="6.7442711017631486138e-05" iyz="5.6739084804828818974e-08" izz="6.0609999941114232999e-05" />
</inertial>
</link>What's in a Joint?
Another key constituent of .urdf models are joints. These describe connections between different links and also physical properties such as:
- Velocity/position properties
- Friction properties
<joint name="left_knee" type="revolute">
<origin xyz="1.3877787807814456755e-17 -0.078650000000000011569 9.9999999999961231012e-05" rpy="-2.6413756781799972404e-16 -2.7339812777890022088e-26 0" />
<parent link="knee_and_ankle_assembly" />
<child link="knee_and_ankle_assembly_2" />
<axis xyz="0 0 1"/>
<limit effort="1" velocity="20" lower="-1.570796326794896558" upper="1.570796326794896558"/>
<joint_properties friction="0.0"/>
</joint><joint name="left_knee" type="revolute">
<origin xyz="1.3877787807814456755e-17 -0.078650000000000011569 9.9999999999961231012e-05" rpy="-2.6413756781799972404e-16 -2.7339812777890022088e-26 0" />
<parent link="knee_and_ankle_assembly" />
<child link="knee_and_ankle_assembly_2" />
<axis xyz="0 0 1"/>
<limit effort="1" velocity="20" lower="-1.570796326794896558" upper="1.570796326794896558"/>
<joint_properties friction="0.0"/>
</joint>Creating the Walk Engine
In this section, we are now going to start learning how to use PlaCo to generate a gait cycle for the OpenDuckMini. Before we begin, there are a few small requirements that would be good to outline:
- We want to generate an idealised gait pattern which captures how we would want our robot to move.
- We want to exclude specific joints from IK/ID computation as they should remain fixed throughout the reference motion. For example, the neck/head is NOT a component we wish to have non-sensically moving in a reference motion.
Conveniently, PlaCo has a HumanoidRobot class which we can create an instance from a URDF with. However, there's one key point to remember here which is that the urdf passed into HumanoidRobot MUST contain left_foot and right_foot joint links. This can be found quite easily using Ctrl+F in the robot.urdf file.
import placo
import numpy as np
from placo_utils.visualization import robot_viz
class OpenDuckMiniWalkEngine():
def __init__(self, urdf_path: str):
self.urdf_path = urdf_path
self.robot = placo.HumanoidRobot(self.urdf_path)
self.humanoid_params = self.load_default_humanoid_params()
self.viz = None
self.solver = None
self.tasks = None
self.trajectory = None
self.restricted_joints = [
"left_hip_pitch",
"right_hip_pitch",
"left_knee",
"right_knee",
"left_ankle",
"right_ankle",
]import placo
import numpy as np
from placo_utils.visualization import robot_viz
class OpenDuckMiniWalkEngine():
def __init__(self, urdf_path: str):
self.urdf_path = urdf_path
self.robot = placo.HumanoidRobot(self.urdf_path)
self.humanoid_params = self.load_default_humanoid_params()
self.viz = None
self.solver = None
self.tasks = None
self.trajectory = None
self.restricted_joints = [
"left_hip_pitch",
"right_hip_pitch",
"left_knee",
"right_knee",
"left_ankle",
"right_ankle",
]Configuring the Humanoid Parameters
The HumanoidParameters for humanoid robots in PlaCo allow us to specify parameters which are used by the underlying IK/ID solver during the generation of a walking motion. Specifically, these parameters will outline key geometries within the .urdf and also timing cycle information for gait generation.
Rather than walking through every field individually, it's more useful to think of these as a set of dials that collectively control the gait: how the robot times its steps, how it holds its posture, the geometry of its feet and the limits on how far it can step.
def load_default_humanoid_params(self):
params = placo.HumanoidParameters()
# --- Timing ---
params.single_support_duration = 0.1 # seconds per single-support phase
params.single_support_timesteps = 8 # planning resolution per step
params.double_support_ratio = (
0.0 # 0 = no pause between steps (simpler to start)
)
params.startend_double_support_ratio = 1.0 # longer double-support at start/end
params.planned_timesteps = 48 # how far ahead the WPG plans
# --- Posture (from your URDF) ---
params.walk_com_height = 0.23 # match measured com_world z (~0.23)
params.walk_foot_height = 0.03 # how high the swing foot lifts [m]
params.walk_trunk_pitch = 0.10 # slight forward lean [rad] (~6ยฐ)
params.walk_foot_rise_ratio = 0.2 # fraction of swing spent at peak height
# --- Foot geometry ---
params.foot_length = 0.08 # estimate for duck foot [m]
params.foot_width = 0.06
params.feet_spacing = 0.09 # half your 0.18 m stance width
params.zmp_margin = 0.01 # keep ZMP inside foot polygon
params.foot_zmp_target_x = 0.0
params.foot_zmp_target_y = 0.0
# --- Step limits (clip planner requests) ---
params.walk_max_dx_forward = 0.04 # max forward step [m]
params.walk_max_dx_backward = 0.02
params.walk_max_dy = 0.03 # max lateral step [m]
params.walk_max_dtheta = 0.30 # max rotation per step [rad]
return paramsdef load_default_humanoid_params(self):
params = placo.HumanoidParameters()
# --- Timing ---
params.single_support_duration = 0.1 # seconds per single-support phase
params.single_support_timesteps = 8 # planning resolution per step
params.double_support_ratio = (
0.0 # 0 = no pause between steps (simpler to start)
)
params.startend_double_support_ratio = 1.0 # longer double-support at start/end
params.planned_timesteps = 48 # how far ahead the WPG plans
# --- Posture (from your URDF) ---
params.walk_com_height = 0.23 # match measured com_world z (~0.23)
params.walk_foot_height = 0.03 # how high the swing foot lifts [m]
params.walk_trunk_pitch = 0.10 # slight forward lean [rad] (~6ยฐ)
params.walk_foot_rise_ratio = 0.2 # fraction of swing spent at peak height
# --- Foot geometry ---
params.foot_length = 0.08 # estimate for duck foot [m]
params.foot_width = 0.06
params.feet_spacing = 0.09 # half your 0.18 m stance width
params.zmp_margin = 0.01 # keep ZMP inside foot polygon
params.foot_zmp_target_x = 0.0
params.foot_zmp_target_y = 0.0
# --- Step limits (clip planner requests) ---
params.walk_max_dx_forward = 0.04 # max forward step [m]
params.walk_max_dx_backward = 0.02
params.walk_max_dy = 0.03 # max lateral step [m]
params.walk_max_dtheta = 0.30 # max rotation per step [rad]
return paramsUnderstanding Tasks, Solver and Trajectory in PlaCo
We have now configured our robot and its parameters โ time for motion planning! Before we dive through some of the key code snippets, there are a few core abstractions in PlaCo worth understanding:
- Tasks: These define what the constraints of the motion you wish to achieve are. Effectively, these translate to equalities and inequalities that go into the underlying IK/ID solver.
- Trajectory: Given a set of tasks, the trajectory is what we obtain after planning our trajectory.
- Solver: Given the robot and its parameters, the solver will look at the current tasks as per the trajectory and construct a QP problem whose solution is applied to the robot model.
Configuring the WalkTasks and Solver
After creating a humanoid and configuring its parameters, we are ready to generate a walk cycle.
PlaCo has a few key layers of abstraction that contribute towards generating a walking pattern:
FootstepsPlanner: Given the humanoid parameters and desired dx, dy and dฮธ parameters, plans out the sequence of footsteps for the walk.WalkPatternGenerator: Given the robot, its parameters and also the supports from each footstep, this will map into a trajectory.
I think it's a lot easier to understand how this works in code so let's take a look at it in the subsequent block:
# Inside your OpenDuckMiniWalkEngine, let's create the following method
def configure_walk_solver(self, step_dx: float, step_dy: float, step_dtheta: float, nsteps: int):
self.solver = placo.KinematicsSolver(self.robot)
self.solver.enable_velocity_limits(True)
self.solver.dt = 1e-3
self.tasks = placo.WalkTasks()
self.tasks.initialize_tasks(self.solver, self.robot)
# FIRST TASK: Keeping our head/neck joints fixed
upper_body_restricted_joints = self.solver.add_joints_task()
upper_body_restricted_joints.set_joints({
joint_name: 0.0 for joint_name in self.restricted_joints
})
upper_body_restricted_joints.configure("restricted_upper_body", "soft", 1.0)
# NEXT: Set up our robot to be in an initial standing pose
self.tasks.reach_initial_pose(
np.eye(4),
self.humanoid_params.feet_spacing,
self.humanoid_params.walk_com_height,
self.humanoid_params.walk_trunk_pitch,
)
planner = placo.FootstepsPlannerRepetitive(self.humanoid_params)
# FINAL TASK: Setting up the walk plan
planner.configure(
step_dx,
step_dy,
step_dtheta,
nsteps
)
T_world_left = placo.flatten_on_floor(self.robot.get_T_world_left())
T_world_right = placo.flatten_on_floor(self.robot.get_T_world_right())
footsteps = planner.plan(
placo.HumanoidRobot_Side.left,
T_world_left,
T_world_right,
)
# Supports are obtained from the footsteps
supports = placo.FootstepsPlanner.make_supports(
footsteps,
0.0,
True,
self.humanoid_params.has_double_support(),
True,
)
# Walk pattern generator is used to plan the trajectory
walk = placo.WalkPatternGenerator(self.robot, self.humanoid_params)
self.trajectory = walk.plan(supports, self.robot.com_world(), 0.0)# Inside your OpenDuckMiniWalkEngine, let's create the following method
def configure_walk_solver(self, step_dx: float, step_dy: float, step_dtheta: float, nsteps: int):
self.solver = placo.KinematicsSolver(self.robot)
self.solver.enable_velocity_limits(True)
self.solver.dt = 1e-3
self.tasks = placo.WalkTasks()
self.tasks.initialize_tasks(self.solver, self.robot)
# FIRST TASK: Keeping our head/neck joints fixed
upper_body_restricted_joints = self.solver.add_joints_task()
upper_body_restricted_joints.set_joints({
joint_name: 0.0 for joint_name in self.restricted_joints
})
upper_body_restricted_joints.configure("restricted_upper_body", "soft", 1.0)
# NEXT: Set up our robot to be in an initial standing pose
self.tasks.reach_initial_pose(
np.eye(4),
self.humanoid_params.feet_spacing,
self.humanoid_params.walk_com_height,
self.humanoid_params.walk_trunk_pitch,
)
planner = placo.FootstepsPlannerRepetitive(self.humanoid_params)
# FINAL TASK: Setting up the walk plan
planner.configure(
step_dx,
step_dy,
step_dtheta,
nsteps
)
T_world_left = placo.flatten_on_floor(self.robot.get_T_world_left())
T_world_right = placo.flatten_on_floor(self.robot.get_T_world_right())
footsteps = planner.plan(
placo.HumanoidRobot_Side.left,
T_world_left,
T_world_right,
)
# Supports are obtained from the footsteps
supports = placo.FootstepsPlanner.make_supports(
footsteps,
0.0,
True,
self.humanoid_params.has_double_support(),
True,
)
# Walk pattern generator is used to plan the trajectory
walk = placo.WalkPatternGenerator(self.robot, self.humanoid_params)
self.trajectory = walk.plan(supports, self.robot.com_world(), 0.0)Couple extra notes here:
- A full gait cycle is equivalent to
period = (double_support_duration * 2) + (single_support_duration * 2). dx,dy, anddthetadefine the per-step displacement: how far each footstep should move forward/backward (x), sideways (y), and rotate (theta) relative to the previous support foot.FootstepsPlannerRepetitive.configure()uses these values, along with the step count, to lay out the full sequence of footsteps for the walk - they're effectively the "joystick" controls for the gait (walk straight, strafe, turn), bounded by thewalk_max_dx_forward,walk_max_dy, andwalk_max_dthetalimits set in the humanoid parameters.
Executing the Trajectory
In the previous section, we created a function that enables us to configure a walk trajectory for the robot. This leaves us with a couple of things left to do:
- Visualisation: It is nice to sanity-check the trajectory and see what it will look like as a way to validate we are getting the desired motion.
- Executing the trajectory: This involves stepping through the trajectory in time, updating the solver's tasks at each timestep, and solving for the resulting joint configuration.
- Exporting robot position and joint data: For imitation learning/MuJoCo in the next writeup, we are going to want to dump the robot's joint angles (and other relevant state) at each timestep of the trajectory, so they can later be fed into the polynomial fitting step and used as a reference signal during training.
We can write a very simple trajectory visualiser by introducing the following method into our walk engine class:
def view_trajectory(self):
self.viz = robot_viz(self.robot)
t = 0.0
while t < self.trajectory.t_end:
self.tasks.update_tasks_from_trajectory(self.trajectory, t)
self.solver.solve(True)
self.robot.update_kinematics()
if not self.trajectory.support_is_both(t):
self.robot.update_support_side(str(self.trajectory.support_side(t)))
self.robot.ensure_on_floor()
self.viz.display(self.robot.state.q)def view_trajectory(self):
self.viz = robot_viz(self.robot)
t = 0.0
while t < self.trajectory.t_end:
self.tasks.update_tasks_from_trajectory(self.trajectory, t)
self.solver.solve(True)
self.robot.update_kinematics()
if not self.trajectory.support_is_both(t):
self.robot.update_support_side(str(self.trajectory.support_side(t)))
self.robot.ensure_on_floor()
self.viz.display(self.robot.state.q)There's a couple of things to understand here:
- Our trajectory involves the robot experiencing a change in position and velocity at different time points. Consequently, we need to update the tasks to solve via
self.tasks.update_tasks_from_trajectory - Then we need to solve for the new robot state โ this is done via
self.solver.solveand we also pass in True to update the robot state with the new computed result - Visualisation utilities (via
self.viz) are using MeshCat under the hood and use the robot state which is updated based on the first few lines inside the loop
Polynomial Fitting (for Imitation Learning)
We have now generated some reference motion which represents the idealised way we would like our robot to move. However, we need to do a bit more work to make this reference motion usable in an RL simulation.
Using our walk engine, we wrote some functionality that allowed us to dump joint angles and key motion data for the robot over the course of the desired trajectory. These same joints are going to be present (and actuated) within the RL world of MuJoCo, meaning if we have some mechanism to compare these joints then it becomes possible to obtain a reward signal.
To do this, we can use polynomial fitting over each of the joint positions. Essentially, the pipeline we are going to create can be simplified to the following:
- Create a dataframe of all joint data over the course of the trajectory
- Process this into a |q| element tensor which for each dim, stores the trajectory data of the given joint corresponding to the dim
- For each dim, run a polynomial fit for an N-degree polynomial and extract coefficients which represent the trajectory
- LATER IN DOWNSTREAM: We can then use these polynomial coefficients to compare the MuJoCo trajectory (which remember is in a physics based environment) performed in simulation
To better see this pipeline in practice, here's how it looks in code. First, we load every dumped frame from the walk engine into a single dataframe, one row per timestep:
from pathlib import Path
import json
from dataclasses import dataclass
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
@dataclass
class WalkPatternPolyFitParams:
degree: int
poly_coeffs: dict[str, np.ndarray]
joint_name_to_dim: dict[str, int]
recordings_directory = Path("./recordings/walk_engine")
joint_names = set()
time_based_rows = []
for path in sorted(recordings_directory.glob("dump_*.json")):
with open(path, "r") as f:
data = json.load(f)
joint_angles = {k: np.array(v) for k, v in data["joint_angles"].items()}
joint_names.update(joint_angles.keys())
time_based_rows.append({**joint_angles})
recordings_df = pd.DataFrame(time_based_rows)from pathlib import Path
import json
from dataclasses import dataclass
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
@dataclass
class WalkPatternPolyFitParams:
degree: int
poly_coeffs: dict[str, np.ndarray]
joint_name_to_dim: dict[str, int]
recordings_directory = Path("./recordings/walk_engine")
joint_names = set()
time_based_rows = []
for path in sorted(recordings_directory.glob("dump_*.json")):
with open(path, "r") as f:
data = json.load(f)
joint_angles = {k: np.array(v) for k, v in data["joint_angles"].items()}
joint_names.update(joint_angles.keys())
time_based_rows.append({**joint_angles})
recordings_df = pd.DataFrame(time_based_rows)Each dump_*.json file is a frame-by-frame export from the walk engine, so recordings_df ends up with one column per joint and one row per timestep in the trajectory.
Next, we stack each joint's trajectory into a single |q| x T matrix - one row per joint, one column per timestep - and normalise time to the range [0, 1] so the fit doesn't depend on how long the trajectory happened to run for:
joint_name_to_dim = {jname: dim for dim, jname in enumerate(joint_names)}
vector_elements = np.stack([recordings_df[jname].to_numpy() for jname in joint_names])
X = np.linspace(0, 1, vector_elements.shape[1])joint_name_to_dim = {jname: dim for dim, jname in enumerate(joint_names)}
vector_elements = np.stack([recordings_df[jname].to_numpy() for jname in joint_names])
X = np.linspace(0, 1, vector_elements.shape[1])With that in place, we can fit an N-degree polynomial to each joint's trajectory independently, and store the coefficients in ascending power order (c0, c1, c2, ...) so they're easier to reason about downstream:
degree = 15
poly_coeffs = {}
for dim in range(vector_elements.shape[0]):
coeffs = np.polyfit(X, vector_elements[dim].astype(float), degree)
poly_coeffs[f"dim_{dim}"] = np.flip(coeffs)
params = WalkPatternPolyFitParams(
degree=degree,
poly_coeffs=poly_coeffs,
joint_name_to_dim=joint_name_to_dim,
)degree = 15
poly_coeffs = {}
for dim in range(vector_elements.shape[0]):
coeffs = np.polyfit(X, vector_elements[dim].astype(float), degree)
poly_coeffs[f"dim_{dim}"] = np.flip(coeffs)
params = WalkPatternPolyFitParams(
degree=degree,
poly_coeffs=poly_coeffs,
joint_name_to_dim=joint_name_to_dim,
)Finally, as a sanity check, we can plot the fitted polynomial for each joint against the same time axis and eyeball it against what we'd expect a smooth gait cycle to look like:
for dim_key, coeffs in poly_coeffs.items():
plt.plot(X, np.polyval(np.flip(coeffs), X), label=dim_key)
plt.legend()
plt.show()for dim_key, coeffs in poly_coeffs.items():
plt.plot(X, np.polyval(np.flip(coeffs), X), label=dim_key)
plt.legend()
plt.show()Since poly_coeffs is stored in ascending power order, we flip it back before passing it to np.polyval, which expects coefficients from highest power to lowest.
As a final step, once we have these polynomial coefficients we can then proceed to dump them into a .pkl file which will serve as a target for later performing imitation learning in MuJoCo.
Summary
In this article, we walked through the essentials of generating reference motion for imitation learning. We started by looking at the structure of .urdf files, and how links and joints come together to describe a robot's kinematics. From there, we dug into PlaCo's internals to understand how it uses tasks and an underlying IK/ID solver to produce reference motion. We then brought a .urdf model of the OpenDuckMini into PlaCo and used it to generate an actual walking pattern, and finally reduced that trajectory down into a compact set of polynomial coefficients representing the gait cycle.
With this reference motion in hand, the natural next step is to actually get the robot to physically achieve it. That means taking these polynomial coefficients into a physics simulator such as MuJoCo and using imitation learning to train a policy that reproduces the reference gait under real physical constraints โ which is exactly what we'll cover in the next article.
References
[1] https://la.disneyresearch.com/wp-content/uploads/BD_X_paper.pdf
[2] https://arxiv.org/pdf/2009.02846