feat(tracking): v0.1.2 双手轨迹回放与 GUI 预览
支持 L20 右手模型身份与严格浮动控制覆盖层,新增完整轨迹受限回放、时间拉伸及可选 GUI 显示。 验证:88 项 CPU/USD 回归、Ruff、格式与独立暂存审查通过。历史 80 倍降速回放完成 2x32000 步;整合后 GUI E2E 和完整 pre-commit 未执行,相关边界见 L20_TRACKING.md。 右手 USD、示教数据、媒体和日志未纳入提交;资产存储及许可仍待确认。保留原控制与安全阈值。
This commit is contained in:
+110
-10
@@ -2,38 +2,94 @@
|
||||
|
||||
Requires inspected PhysX 110.1.13 + current Isaac Lab ProxyArray/xyzw API. Runtime
|
||||
coupling is tested from passive follower response, not inferred from schema names.
|
||||
Use an external timeout <=300s. Do not launch alongside another Kit/GPU job.
|
||||
Use an externally bounded timeout (default budget <=300s; longer runs need approval).
|
||||
Do not launch alongside another Kit/GPU job.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import traceback
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
from isaaclab.app import AppLauncher
|
||||
def validate_replay_request(steps, full_episode, has_hdf5, workspace_radius):
|
||||
"""Stdlib-only launch bounds, evaluated before Kit or tracking imports."""
|
||||
maximum = 32000 if full_episode else 1200
|
||||
if not isinstance(steps, int) or not 100 <= steps <= maximum:
|
||||
raise ValueError(f"Require 100 <= steps <= {maximum}")
|
||||
if full_episode and not has_hdf5:
|
||||
raise ValueError("Full-episode mode requires --hdf5")
|
||||
if workspace_radius is not None and (not math.isfinite(workspace_radius) or workspace_radius <= 0):
|
||||
raise ValueError("Workspace radius must be finite positive")
|
||||
|
||||
|
||||
def validate_replay_duration(steps, dt, duration, full_episode):
|
||||
"""Reject extrapolation and partial/off-grid full-episode requests before sampling."""
|
||||
if not steps * dt <= duration:
|
||||
raise ValueError("Reference shorter than requested steps")
|
||||
if full_episode and abs(steps * dt - duration) > 1e-9:
|
||||
raise ValueError("Full episode requires exact on-grid end and steps")
|
||||
|
||||
|
||||
def configure_presentation(args):
|
||||
"""Resolve opt-in GUI without silently overriding explicit display conflicts."""
|
||||
if args.gui:
|
||||
if args.headless:
|
||||
raise ValueError("--gui conflicts with --headless")
|
||||
if (getattr(args, "visualizer_explicit", False) or args.visualizer is not None) and args.visualizer != ["kit"]:
|
||||
raise ValueError("--gui requires the Kit visualizer; conflicts with --visualizer/--viz")
|
||||
args.headless, args.visualizer = False, ["kit"]
|
||||
else:
|
||||
args.headless = True # Preserve the diagnostic's legacy headless default.
|
||||
if args.visualizer is None:
|
||||
args.visualizer = "none"
|
||||
|
||||
|
||||
def build_parser(add_app_launcher_args):
|
||||
"""Keep replay argument parsing testable without importing or launching Kit."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("asset", type=Path, help="Experimental prepared overlay, never original fixed preview")
|
||||
parser.add_argument("--manifest", type=Path, required=True, help="Original data/source manifest")
|
||||
parser.add_argument("--hdf5", type=Path, help="Omit for explicitly synthetic diagnostic reference")
|
||||
parser.add_argument("--episode", default="demo_000000")
|
||||
parser.add_argument("--steps", type=int, default=480, help="Per repetition at 240Hz; two repetitions")
|
||||
parser.add_argument(
|
||||
"--full-episode", action="store_true", help="Require complete HDF5 duration; allow up to 32000 steps"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace-radius", type=float, help="Metres from initial root; overrides only workspace_radius"
|
||||
)
|
||||
parser.add_argument("--execute-experimental", action="store_true", help="Acknowledge uncalibrated controller")
|
||||
parser.add_argument("--limits", type=Path, help="JSON fields of control.Limits, SI units; UNCALIBRATED")
|
||||
AppLauncher.add_app_launcher_args(parser)
|
||||
parser.set_defaults(headless=True, visualizer="none")
|
||||
parser.add_argument("--gui", action="store_true", help="Opt-in bright-hand/dark-backdrop Kit preview")
|
||||
add_app_launcher_args(parser)
|
||||
# None distinguishes omitted flags from explicit --headless / --viz none.
|
||||
parser.set_defaults(headless=None, visualizer=None)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
from isaaclab.app import AppLauncher
|
||||
|
||||
parser = build_parser(AppLauncher.add_app_launcher_args)
|
||||
args = parser.parse_args()
|
||||
if not args.execute_experimental or not 100 <= args.steps <= 1200:
|
||||
parser.error("Require --execute-experimental and 100 <= steps <= 1200 per repetition")
|
||||
if not args.execute_experimental:
|
||||
parser.error("Require --execute-experimental")
|
||||
try:
|
||||
configure_presentation(args)
|
||||
validate_replay_request(args.steps, args.full_episode, args.hdf5 is not None, args.workspace_radius)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
|
||||
# Kit must choose/register its USD libraries before any pxr-dependent imports.
|
||||
launcher = AppLauncher(args)
|
||||
app = launcher.app
|
||||
exit_code = 0
|
||||
failure_context = {"phase": "setup", "completed_repetitions": 0}
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -52,11 +108,13 @@ def main():
|
||||
manifest = json.loads(args.manifest.read_text())
|
||||
prepared = inspect_prepared(args.asset, manifest)
|
||||
limits = Limits(**json.loads(args.limits.read_text())) if args.limits else Limits()
|
||||
if args.workspace_radius is not None:
|
||||
limits = replace(limits, workspace_radius=args.workspace_radius)
|
||||
data = load(args.hdf5, manifest) if args.hdf5 else synthetic(manifest)
|
||||
episode = data.episodes[args.episode]
|
||||
validate_reference(episode, limits)
|
||||
dt = 1 / 240
|
||||
require(args.steps * dt <= episode.time[-1], "Reference shorter than requested steps")
|
||||
validate_replay_duration(args.steps, dt, episode.time[-1], args.full_episode)
|
||||
reference = sample(episode, np.arange(args.steps + 1) * dt)
|
||||
# Hold-start reset uses zero velocities; require a genuinely moving diagnostic
|
||||
# for every mimic pair, so an ignored/disabled constraint cannot pass at zero.
|
||||
@@ -119,10 +177,25 @@ def main():
|
||||
anchor_path = "/World/Hand" + manifest["world_fixed_joints"][0]["path"][len(manifest["default_prim"]) :]
|
||||
anchor = sim.stage.GetPrimAtPath(anchor_path)
|
||||
require(anchor.IsValid() and not anchor.IsActive(), "Obsolete world anchor must be inactive")
|
||||
effective_roots = [p for p in sim.stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)]
|
||||
require(len(effective_roots) == 1, "Expected exactly one effective articulation root")
|
||||
require(
|
||||
str(effective_roots[0].GetPath())
|
||||
== "/World/Hand" + manifest["root_body_path"][len(manifest["default_prim"]) :],
|
||||
"Effective articulation root is not the root body",
|
||||
)
|
||||
active_joints = [p for p in sim.stage.Traverse() if p.IsA(UsdPhysics.Joint)]
|
||||
require(len(active_joints) == len(names), "Unexpected active joint count")
|
||||
require(all(p.IsA(UsdPhysics.RevoluteJoint) for p in active_joints), "Unexpected active constraint")
|
||||
if args.gui:
|
||||
from dex_workbench_tracking.preview import create_preview
|
||||
|
||||
eye, center = create_preview(sim.stage, episode.wrist_position)
|
||||
sim.set_camera_view(eye, center)
|
||||
sim.reset()
|
||||
if args.gui:
|
||||
sim.render()
|
||||
print("GUI_READY: cyan line is the wrist reference; bounded physical replay", flush=True)
|
||||
require(not hand.is_fixed_base and hand.num_instances == 1, "Expected one floating PhysX articulation")
|
||||
require(
|
||||
set(hand.joint_names) == set(names) and len(hand.joint_names) == len(names), "Runtime DOF mapping mismatch"
|
||||
@@ -157,6 +230,7 @@ def main():
|
||||
upper = np.array([j["upper_rad"] for j in manifest["joints"]])
|
||||
traces, reset_states, summaries = [], [], []
|
||||
for repetition in range(2):
|
||||
failure_context = {"phase": "reset", "completed_repetitions": repetition, "repetition": repetition}
|
||||
hand.reset()
|
||||
hand.permanent_wrench_composer.reset()
|
||||
hand.instantaneous_wrench_composer.reset()
|
||||
@@ -184,6 +258,12 @@ def main():
|
||||
reset_states.append(np.r_[reset_pose, reset_vel, reset_q])
|
||||
trace, errors = [], []
|
||||
for step in range(args.steps):
|
||||
failure_context = {
|
||||
"phase": "stepping",
|
||||
"completed_repetitions": repetition,
|
||||
"repetition": repetition,
|
||||
"completed_steps_in_repetition": step,
|
||||
}
|
||||
require(app.is_running(), "Application stopped before finite test completed")
|
||||
pose, velocity, q = state()
|
||||
force, torque = wrench(
|
||||
@@ -206,11 +286,17 @@ def main():
|
||||
target=tensor(reference.joint_position[step : step + 1, master_columns]), joint_ids=master_ids
|
||||
)
|
||||
hand.write_data_to_sim()
|
||||
sim.step(render=False)
|
||||
sim.step(render=args.gui and step % 8 == 0)
|
||||
hand.update(dt)
|
||||
pose, velocity, q = state()
|
||||
qdot = array(hand.data.joint_vel)[0]
|
||||
failure_context["completed_steps_in_repetition"] = step + 1
|
||||
require(all(np.isfinite(v).all() for v in (pose, velocity, q, qdot)), "Nonfinite dynamic state")
|
||||
failure_context["measured_speeds_m_s_rad_s_rad_s"] = [
|
||||
float(np.linalg.norm(velocity[:3])),
|
||||
float(np.linalg.norm(velocity[3:])),
|
||||
float(np.max(np.abs(qdot))),
|
||||
]
|
||||
require(
|
||||
np.linalg.norm(velocity[:3]) < 0.5
|
||||
and np.linalg.norm(velocity[3:]) < 3
|
||||
@@ -229,6 +315,12 @@ def main():
|
||||
position_error = np.linalg.norm(pose[:3] - reference.wrist_position[step + 1])
|
||||
angle_error = np.linalg.norm(rotation_error(reference.wrist_quaternion[step + 1], pose[3:]))
|
||||
joint_error = np.max(np.abs(q - reference.joint_position[step + 1]))
|
||||
failure_context["last_errors_m_rad_rad_rad"] = [
|
||||
float(position_error),
|
||||
float(angle_error),
|
||||
float(joint_error),
|
||||
float(residual),
|
||||
]
|
||||
require(residual < 0.002, "Mimic runtime residual >0.002rad; parser/constraint not verified")
|
||||
require(
|
||||
position_error < 0.05 and angle_error < 0.5 and joint_error < 0.2,
|
||||
@@ -255,6 +347,7 @@ def main():
|
||||
"rms_errors_m_rad_rad_rad": np.sqrt((errors**2).mean(axis=0)).tolist(),
|
||||
}
|
||||
)
|
||||
failure_context = {"phase": "repeatability", "completed_repetitions": 2, "steps_per_repetition": args.steps}
|
||||
np.testing.assert_allclose(reset_states[0], reset_states[1], atol=1e-6, rtol=0)
|
||||
position_end = 7 + len(names)
|
||||
np.testing.assert_allclose(traces[0][:, :position_end], traces[1][:, :position_end], atol=1e-3, rtol=0)
|
||||
@@ -272,11 +365,17 @@ def main():
|
||||
"reference_hdf5_sha256": hashlib.sha256(args.hdf5.read_bytes()).hexdigest() if args.hdf5 else None,
|
||||
"reference_description": data.metadata["source_description"],
|
||||
"world_anchor_inactive": not anchor.IsActive(),
|
||||
"hand_side": data.metadata["hand_side"],
|
||||
"effective_articulation_root_count": len(effective_roots),
|
||||
"active_state_joint_count": len(active_joints),
|
||||
"runtime_is_fixed_base": hand.is_fixed_base,
|
||||
"seed": 42,
|
||||
"num_envs": 1,
|
||||
"steps_per_repetition": args.steps,
|
||||
"reference_duration_s": float(episode.time[-1]),
|
||||
"replayed_duration_s": args.steps * dt,
|
||||
"reference_coverage": "full" if abs(args.steps * dt - episode.time[-1]) <= 1e-9 else "partial",
|
||||
"full_episode_requested": args.full_episode,
|
||||
"repetitions": 2,
|
||||
"physx_version": version,
|
||||
"prepared": prepared,
|
||||
@@ -287,10 +386,11 @@ def main():
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
except BaseException:
|
||||
except BaseException as error:
|
||||
# Kit fast shutdown may not return; emit the failure before closing.
|
||||
exit_code = 1
|
||||
traceback.print_exc()
|
||||
print(json.dumps({"status": "FAIL", "error": str(error), "progress": failure_context}), flush=True)
|
||||
raise
|
||||
finally:
|
||||
app.close(exit_code=exit_code)
|
||||
|
||||
Reference in New Issue
Block a user