Skip to content

Add G1 visual locomotion training and deployment pipeline - #8776

Open
Double7sBurger wants to merge 17 commits into
isaac-sim:mainfrom
Double7sBurger:feat/g1-visual-locomotion-deploy
Open

Double7sBurger wants to merge 17 commits into
isaac-sim:mainfrom
Double7sBurger:feat/g1-visual-locomotion-deploy

Conversation

@Double7sBurger

@Double7sBurger Double7sBurger commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

Add a G1 visual locomotion workflow covering teacher training, depth-student distillation, LEAPP export, and Unitree DDS deployment. Includes reusable core MDP terms, G1 symmetry, curriculum scheduling, asset-derived action parameters, and a training/deployment guide. The canonical rough G1 task uses the teacher configuration; scene, rewards, events, curriculum, terminations, and student observations are composed declaratively.

Require a teacher checkpoint for custom distillation algorithms and export only the selected inference observation groups. Deployment dependencies and camera setup are documented in the guide.

Rough PPO consumes proprio and height_scan; the depth student consumes proprio_history and depth. The shared G1 terrain preset lives in its own configuration module.

Flat G1 now exposes proprio and supports RSL-RL only. Update group overrides from policy to proprio and select --rl_library rsl_rl; the flat SKRL entry point and recipe have been removed.

Type of change

  • New feature
  • Bug fix
  • Documentation update

Validation

  • G1 task registration/configuration and ownership gates: 2 tests passed.
  • Native-actuator selection: 2 existing tests passed, including implicit groups retaining solver-owned drives without gain writes or native-path activation.
  • Flat G1 actor/critic state dictionaries load strictly with identical outputs after the observation-group rename; all 9 resolved configuration snapshots matched after accounting for renamed groups and the shared actuator default.
  • Core termination coverage: 2 tests passed; moved MDP terms retain numerical parity and student observation history/reset behavior is unchanged.
  • Custom distillation checkpoint guard: 1 test passed.
  • LEAPP PPO/student export and replay: 2 tests passed.
  • Repository formatting, changelog validation against main, and diff checks passed.
  • Environment registry metadata: verified for 141 training environments.

Existing TorchScript and actuator deprecation warnings remain. Hardware deployment performance has not been validated. The Newton collision fix is tracked separately in newton-physics/newton#4870 and is not included here.

Checklist

  • I have read the contribution guidelines
  • I have run the pre-commit checks through uv run isaaclab --format
  • I have made corresponding documentation changes
  • My changes generate no new warnings
  • I have validated the changed behavior with focused coverage and applied the test-audit criteria
  • I have added changelog fragments for every touched package
  • My name already exists in CONTRIBUTORS.md

@Double7sBurger
Double7sBurger requested a review from a team October 22, 2026 01:51
@github-actions github-actions Bot added documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team labels Oct 22, 2026
@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[High impact] The PR adds a new path from simulation training to physical G1 motor control.

Do not merge the hardware deployment path until its timing, stop behavior, stale-state checks, operator controls, and camera trust are fixed.

Findings

  1. P1 The startup ramp skips waits ▶
  2. P1 Startup aborts skip damping ▶
  3. P1 Old robot readings never expire ▶
  4. P1 Any response starts the policy ▶
  5. P1 Space does not stop immediately ▶
  6. P1 Security Other hosts can replace depth ▶
  7. P2 The camera publisher is missing ▶
  8. P2 The IMU correction is absent ▶

Summary

Adds G1 teacher training, depth-student distillation, LEAPP export support, and a physical-robot deployment script.

  • G1 teacher and depth-student tasks train a mirrored walking policy.
  • A deployment script connects the exported G1 depth policy to robot controls.
  • Distillation training and LEAPP export use the student policy inputs.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  T[Teacher PPO training] --> C[Teacher checkpoint]
  C --> D[Depth-student distillation]
  D --> S[Student checkpoint]
  S --> E[LEAPP export]
  E --> G[Deployment graph]
  R[Robot joint and IMU readings] --> G
  K[Keyboard commands] --> G
  U[UDP depth frames] --> G
  G --> M[Physical motor targets]
Loading

Reviews (1) · Last reviewed commit: "Clarify G1 training and deployment guide..." · Reviewed by Greptile

Comment on lines +290 to +291
self._next += period
time.sleep(max(0.0, self._next - time.monotonic()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The startup ramp skips waits

G1Link.wait() counts from construction time, before the three-second take_lowcmd() probe, camera startup, and operator confirmation. Its deadline is already several seconds behind when the ramp starts. The ramp then sends successive targets without the intended waits while the deadline catches up, moving the robot faster than the advertised three seconds. Reset the deadline when control starts and skip missed deadlines rather than replaying them.

Comment on lines +478 to +480
bring_up(link, policy.default_pose, policy.kp, policy.kd, ramp_s=3.0)

try:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Startup aborts skip damping

bring_up() runs outside the try/finally that calls link.damp(). Pressing an abort combination during the ramp, hold, or engagement countdown raises OperatorAbort and exits without sending damping commands. An upright-check failure or Ctrl+C during these stages also skips cleanup. Move bring_up() inside the protected block so stopping during startup sends the promised damping response.

Comment on lines +241 to +246
def _on_state(self, message) -> None:
self.state = message

def read(self) -> dict:
"""Joint state and IMU, in the frames the graph expects."""
state = self.state

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old robot readings never expire

G1Link keeps the last rt/lowstate message forever without checking its age. If DDS state updates stop while depth frames continue, the policy keeps sending motor targets based on old joint angles and IMU readings. The fall and remote-abort checks also read that old message. Record the receive time and stop with damping when robot state becomes stale, including during bring-up.

print("\n>>> HOLDING the start pose. Square the robot up and lower it onto its feet.")
print(">>> KEEP IT SUPPORTED: this is PD with no policy, and the pose is not statically stable.")
held = threading.Event()
threading.Thread(target=lambda: (input(">>> type 'go2' and press Enter: "), held.set()), daemon=True).start()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Any response starts the policy

The second confirmation accepts any line, not just go2: held.set() runs unconditionally after input(). Pressing Enter or typing an unrelated response starts the engagement countdown while the operator may still be positioning the robot. Check the entered word before setting the event and send rejected confirmations through the damping cleanup.

the measured orientation. Steering a heading rather than a rate keeps a teleoperated run on
the distribution a fixed command trains on.
"""
key = sys.stdin.read(1) if select.select([sys.stdin], [], [], 0.0)[0] else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Space does not stop immediately

Teleop.command() reads single characters, but the script never switches the terminal out of its normal line-buffered mode. With the documented launch command, w, space, and x do not reach this code until Enter is pressed. The advertised space-to-stop control therefore does not stop on a keypress. Enable single-key terminal input after the startup prompts and restore the terminal on exit.

Comment on lines +152 to +157
payload = self._sock.recv(1 << 16)
except TimeoutError:
continue
if len(payload) < count * 2:
continue
raw = np.frombuffer(payload, dtype=np.uint16, count=count, offset=len(payload) - count * 2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Other hosts can replace depth

DepthStream binds every network interface and accepts frames from any sender. A host that can reach the UDP port can repeatedly send fabricated distances. Each packet replaces the camera view and refreshes _at, preventing the stale-camera stop even when the real publisher is down. The policy uses these frames to produce physical motor targets. Restrict the listener to the intended network path and authenticate the publisher before accepting frames or refreshing their age.

How this was verified: Any sufficiently large UDP packet replaces the depth frame and its freshness time, and the control loop passes that frame into the policy before writing its targets to the motors.

Comment on lines +212 to +213
ssh unitree@192.168.123.164 \
'/usr/bin/python3 ~/depth_publisher.py --host <WORKSTATION_IP>'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The camera publisher is missing

The camera instructions run ~/depth_publisher.py, but the PR neither supplies that file nor explains where to obtain it. Deployment waits for its frames, and the guide requires a particular crop and image size. A new user cannot complete this setup from the provided instructions. Include the publisher or link to an available implementation with its setup and crop settings.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


**Quaternion order.** The IMU publishes ``(w, x, y, z)`` and the graph expects ``(x, y, z, w)``.

**IMU mounting offset.** Subtracted before the quaternion reaches the graph.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The IMU correction is absent

The guide says the IMU mounting offset is subtracted before inference, but G1Link.read() only reorders the quaternion and passes it through. Users may skip a needed correction because they are told it already happens. Remove this claim or document and implement the calibration step.

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isaac Lab Review Bot

Needs attention: Design and architecture (2), Scope and over-engineering (1), Implementation (5), Style consistency (2). Other reviewed categories look good.

  • PR description: Correct the checkpoint claim: enforcement covers every DistillationRunner; add baseline/candidate raycast-reward throughput with revisions, command, hardware, backend, environment count, warmup, and step-time metric.
  • Design and architecture: Config construction downloads and parses USD, while the reward initializes private sensor state; move derivation and initialization to the existing asset and sensor lifecycle owners.
  • Scope and over-engineering: Minimal implementation: extend rough_env_cfg.py, shared MDP owners, and RSL-RL exporters; keep symmetry/g1.py and g1_deploy.py for distinct contracts, and keep distillation.py while collapsing AugmentedStorage into MirrorDistillation. Suggested PR breakdown: 1) shared MDP terms/tests; 2) RSL-RL guard/export fixes; 3) G1 configs, symmetry, distillation/tests; 4) hardware script and guide.
  • API: API changes look good.
  • Compatibility and deprecation: No breaking changes identified.
  • Implementation: Hardware bring-up sits outside damping cleanup and accepts any engage response, so aborts can leave active commands or start walking unexpectedly; guard bring-up and require go2.
  • Style consistency: The deployment entry point uses forbidden prefixed prints for status; configure repository console logging and reserve print for operator prompts.
  • Test quality: Test quality looks good.

Needs rework. Posted 10 inline findings.

Automated review; human maintainers own approval decisions.

print("\n>>> HOLDING the start pose. Square the robot up and lower it onto its feet.")
print(">>> KEEP IT SUPPORTED: this is PD with no policy, and the pose is not statically stable.")
held = threading.Event()
threading.Thread(target=lambda: (input(">>> type 'go2' and press Enter: "), held.set()), daemon=True).start()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Implementation — Any input engages the policy

The hold thread's lambda calls input(...) then held.set() unconditionally, so a blank or mistyped line engages the walking policy. Validate that the response equals go2, as confirm does.

print(f"[ok] mode_machine={link.read()['mode_machine']} policy at {1 / CONTROL_DT:.0f} Hz")
print(f"[..] teleop: {Teleop.HELP}")
teleop = Teleop()
bring_up(link, policy.default_pose, policy.kp, policy.kd, ramp_s=3.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Implementation — Bring-up runs outside damping cleanup

bring_up commands motors before the try/finally, so an abort, interrupt, or the upright check exits without damp, contradicting its "every exit goes through here" contract. Move it inside the guarded block.

"joint_pos": np.array([m.q for m in motors[: len(MOTOR_NAMES)]], dtype=np.float32),
"joint_vel": np.array([m.dq for m in motors[: len(MOTOR_NAMES)]], dtype=np.float32),
# The IMU publishes (w, x, y, z); the graph declares (x, y, z, w).
"root_quat_xyzw": quat[[1, 2, 3, 0]],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Implementation — IMU mounting offset never applied

read only reorders quaternion components, while the guide states the mounting offset is subtracted before the quaternion reaches the graph. Apply the calibrated transform here or correct the documentation.


def damp(self, kd, seconds: float = 1.5) -> None:
"""Bleed off energy with no stiffness, then stop commanding. Every exit goes through here."""
print("[..] damping down")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Style Consistency — Use logging instead of prefixed prints

Status and failure output uses print with [ok]/[..]/[cmd] prefixes, which the contribution guide forbids. Configure console logging and emit logger.info/logger.error, keeping print only for operator prompts.

return actions[..., self.perm] * self.sign


class AugmentedStorage:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Scope Complexity — Delete single-consumer storage wrapper

AugmentedStorage only forwards attributes and overrides generator(), whose sole consumer is the overridden update(). Apply the mirroring inline there and delete the wrapper and its handoff.

yield out


class MirrorDistillation(Distillation):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Style Consistency — Document and annotate the new algorithm

MirrorDistillation is selected through a public resolvable string yet has no docstrings or type hints; symmetry/g1.py likewise omits module and public-function documentation that anymal.py provides. Add Google-style docstrings and annotations.

for name in ("pyramid_stairs", "pyramid_stairs_inv"):
terrain.sub_terrains[name].step_height_range = (0.05, 0.35)
terrain.sub_terrains[name].step_width = 0.3
self.actions.joint_pos.scale, sole_size, sole_offset = get_action_parameters(self.scene.robot)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Design Architecture — Config post-init downloads and parses USD

get_action_parameters runs inside __post_init__, so merely constructing this config retrieves and composes a USD stage. Keep configs declarative: declare the scales and sole geometry, or resolve them at environment construction.

sensor = env.scene[sensor_cfg.name]
positions = robot.data.body_pos_w.torch[:, asset_cfg.body_ids]
quaternions = robot.data.body_quat_w.torch[:, asset_cfg.body_ids]
xy = torch.cartesian_prod(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Implementation — Reward rebuilds static geometry each step

linspace, cartesian_prod, and the sole size/offset tensors are reallocated on every step for 4096 environments. Precompute the device-local sole points once and reuse them in this hot path.

if key not in scanner.meshes:
from isaaclab.sensors.ray_caster.base_ray_caster import BaseRayCaster

BaseRayCaster._initialize_warp_meshes(scanner)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Design Architecture — Reward initializes sensor's private meshes

This per-step reward hardcodes scene["height_scanner"] and calls private BaseRayCaster._initialize_warp_meshes, taking over the sensor's lifecycle. Let the sensor own initialization and pass the ray caster through a scene-entity config.

)
if not torch.equal(perm[perm], torch.arange(len(names), device=device)):
raise RuntimeError("the joint permutation is not an involution")
env._g1_joint_mirror_map = (perm, sign)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Implementation — Cache mirrors without mutating the env

_build_map injects _g1_joint_mirror_map and siblings onto the environment through getattr/attribute assignment. Sibling anymal.py caches the same permutation and sign tensors with functools.cache keyed by joint names and device; reuse that.

@ooctipus ooctipus self-assigned this Oct 22, 2026
@ooctipus ooctipus added the ci:run-docker Trigger the on-demand Docker and GPU CI workflow label Oct 22, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run-docker Trigger the on-demand Docker and GPU CI workflow documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants