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

FrameUp axisWhere it livesNotes
Wanderland dataset+Yepisodes.json on diskRight-handed, +X forward, +Z lateral.
Isaac world+ZRuntime (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) applies ROT_X_NEG_90 (a -90 deg rotation 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_rotation quaternion via dataset_quat_to_yaw, with a +pi offset (see "yaw quirk" below). Fall back to ref-path heading + pi if 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 axisAligns 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 inverse world_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 wantUse
Dataset position [x, y, z]Isaac world positiondataset_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 yawBody-frame XY deltaworld_to_body_xy(delta_xy, yaw)
Body-frame XY velocity + ego yawWorld-frame XY velocitybody_to_world_xy(v_xy, yaw)
Any angle in radiansWrapped 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) bodyWorld velocity write to PhysXWanderlandEnv._apply_action (env runtime; do not call directly)

Action flow at runtime

Source: WanderlandEnv._apply_action in src/wanderland_lab/envs/wanderland_env.py.

  1. Policy outputs body-frame (vx, vy, yaw_rate).
  2. _pre_physics_step clamps to (vx_max, vy_max, wz_max).
  3. _apply_action rotates (vx, vy) body -> world using (-c, -s; -s, c) (the same matrix as body_to_world_xy).
  4. Yaw integrates as yaw += yaw_rate * dt, wrapped to (-pi, pi] via the same (angle + pi) mod 2pi - pi trick as wrap_pi.
  5. World velocity + an explicit pinned root quaternion (built from the integrated yaw) get written to PhysX via write_root_velocity_to_sim and write_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 +X vs -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 (writes start_yaw and the dataset-derived spawn pose; z is then raycast-snapped onto the scene collider by _snap_spawn_z, since the dataset start_position is a camera pose ~1 m above the ground).
  • src/wanderland_lab/datasets/episodes.py — Y-up -> Z-up on load, plus the +pi start-yaw offset.