Coordinate Frames in wanderland-lab
May 30, 2026 · View on GitHub
TL;DR: Isaac world is Z-up. Wanderland dataset is Y-up (converted on load via ROT_X_NEG_90). Body-frame actions (vx, vy, yaw_rate) are what policies emit; at yaw=0, body +X points to world -X (a load-bearing legacy quirk). The env handles all conversions; policies should think in body frame only.
Frames at a glance
| Frame | Up axis | Where it lives | Notes |
|---|---|---|---|
| Wanderland dataset | +Y | episodes.json on disk | Right-handed, +X forward, +Z lateral. |
| Isaac world | +Z | Runtime (PhysX, USD stage) | Right-handed. Output of dataset loader. |
| Body | +Z (parallel to world +Z) | Action space (vx, vy, yaw_rate) | At yaw=0: body +X -> world -X, body +Y -> world +Y. |
ASCII layout (top-down, Isaac world, looking down -Z)
world +Y
|
| body +Y (when yaw=0)
| /
| /
<-------------+-------------- world +X
body +X |
(when yaw=0) |
|
world -Y
Yaw is measured CCW around world +Z, in the range (-pi, pi].
Dataset -> Isaac conversion (load time)
Source: src/wanderland_lab/datasets/episodes.py (_convert_entry), uses helpers from src/wanderland_lab/envs/coords.py.
- Positions:
dataset_to_isaac_position(p)appliesROT_X_NEG_90(a-90 degrotation about X), so(x, y, z)_dataset -> (x, z, -y)_isaac. - Reference paths: each waypoint goes through the same
dataset_to_isaac_position. - Start yaw: prefer
start_rotationquaternion viadataset_quat_to_yaw, with a+pioffset (see "yaw quirk" below). Fall back to ref-path heading +piif the quaternion is missing.
ROT_X_NEG_90 = [[1, 0, 0],
[0, 0, 1],
[0, -1, 0]]
COS_NEG_45 / SIN_NEG_45 (in coords.py) are the half-angle components of the same -90 deg USD orient op applied when adding the scene to the stage; they're kept here for reuse.
Body-frame velocity convention (the load-bearing quirk)
At yaw=0:
| Body axis | Aligns with |
|---|---|
+X (forward) | world -X |
+Y (left) | world +Y |
+Z (up) | world +Z |
The body-to-world rotation is therefore a pi flip on top of the standard yaw rotation:
vx_world = -cos(yaw) * vx_body - sin(yaw) * vy_body
vy_world = -sin(yaw) * vx_body + cos(yaw) * vy_body
This appears verbatim in two places:
coords.body_to_world_xy(and inverseworld_to_body_xy).WanderlandEnv._apply_action(the in-loop tensor version).
Why the unusual sign?
Legacy CityWalker convention. The dataset's first reference-path tangent (and the recorded start_rotation quaternion's "forward" direction), once rotated into Isaac world, points along world -X. We define body +X = forward, so body +X aligns with world -X at yaw=0. The episode loader bakes this in: start_yaw = wrap_pi(yaw_world + pi). Documented for posterity; do not "fix" it without retraining all baselines.
What converts what
All transforms live in src/wanderland_lab/envs/coords.py unless noted.
| If you have... | ...and you want | Use |
|---|---|---|
Dataset position [x, y, z] | Isaac world position | dataset_to_isaac_position(pos) |
Dataset quaternion (x, y, z, w) | Isaac yaw (rad) | dataset_quat_to_yaw(quat) (already adds the pi offset upstream in episodes.py) |
| World-frame XY delta + ego yaw | Body-frame XY delta | world_to_body_xy(delta_xy, yaw) |
| Body-frame XY velocity + ego yaw | World-frame XY velocity | body_to_world_xy(v_xy, yaw) |
| Any angle in radians | Wrapped to (-pi, pi] | wrap_pi(a) |
Quaternion (x, y, z, w) | (w, x, y, z) | quat_xyzw_to_wxyz(q) |
Action (vx, vy, yaw_rate) body | World velocity write to PhysX | WanderlandEnv._apply_action (env runtime; do not call directly) |
Action flow at runtime
Source: WanderlandEnv._apply_action in src/wanderland_lab/envs/wanderland_env.py.
- Policy outputs body-frame
(vx, vy, yaw_rate). _pre_physics_stepclamps to(vx_max, vy_max, wz_max)._apply_actionrotates(vx, vy)body -> world using(-c, -s; -s, c)(the same matrix asbody_to_world_xy).- Yaw integrates as
yaw += yaw_rate * dt, wrapped to(-pi, pi]via the same(angle + pi) mod 2pi - pitrick aswrap_pi. - World velocity + an explicit pinned root quaternion (built from the integrated yaw) get written to PhysX via
write_root_velocity_to_simandwrite_root_pose_to_sim. The pinned orientation prevents PhysX from drifting the agent's yaw between control steps.
Quick mental model for policy authors
- You only ever see body frame.
+vx= "go forward" (relative to where the agent is currently looking).+vy= strafe left.+yaw_rate= turn CCW. - Don't reason about world
+Xvs-X. The env handles it. - If you're computing a goal-relative heading inside a policy, use
world_to_body_xy(goal_xy - ego_xy, ego_yaw)and the resulting(dx_body, dy_body)is what your policy should consume.
Files referenced
src/wanderland_lab/envs/coords.py— single source of truth for all transforms.src/wanderland_lab/envs/wanderland_env.py—_apply_action(body->world) and_reset_idx(writesstart_yawand the dataset-derived spawn pose;zis then raycast-snapped onto the scene collider by_snap_spawn_z, since the datasetstart_positionis a camera pose ~1 m above the ground).src/wanderland_lab/datasets/episodes.py— Y-up -> Z-up on load, plus the+pistart-yaw offset.