File size: 10,760 Bytes
24fe873 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | """Video-watching gait verifier: does the policy walk slowly on two legs?
Renders a rollout at a held forward command, then judges what is visible in
the frames, not what the reward says. Feet render pink, so each frame is split
into a left and a right foot cluster by pink-pixel mass and the two vertical
traces are correlated. Out of phase means stepping. In phase means hopping.
Contact-sensor duties are measured on the same rollout as a cross-check, but
the verdict is driven by the picture. Inconclusive counts as FAIL.
uv run python scripts/verify_walk.py --checkpoint <path/to/model.pt>
uv run python scripts/verify_walk.py --checkpoint <pt> --command 0.1 --self-test
Needs MUJOCO_GL=glfw on this host (egl and osmesa both fail here).
Exit code 0 = WALKS, 1 = does not walk, 2 = error. A JSON sidecar lands next
to the video for loop runners.
"""
import argparse
import csv
import json
import os
import statistics as st
import sys
from dataclasses import asdict
import numpy as np
import torch
TASK = "Mjlab-Velocity-Flat-TwoLeg"
# PASS predicate. min-duty kills the timing-only false positive seen at H8
# (alt -0.39 with the left foot down 8 pct of steps).
DUTY_FLOOR = 0.20
FRAME_ALT_CEIL = -0.20
CONTACT_ALT_CEIL = -0.20
SPEED_TOL = 0.05
MIN_VALID_FRAC = 0.60
def foot_traces(frames):
"""Per-frame vertical centroid of the left and right pink foot clusters.
Returns two lists (may contain None) plus the fraction of usable frames.
Pure numpy so the self-test runs without a simulator.
"""
left, right = [], []
for img in frames:
h, w, _ = img.shape
lower = img[h // 2 :, :, :].astype(np.int32)
r, g, b = lower[:, :, 0], lower[:, :, 1], lower[:, :, 2]
mask = (r > 140) & (b > 140) & (g < 130)
ys, xs = np.nonzero(mask)
if len(xs) < 80:
left.append(None)
right.append(None)
continue
xmid = float(np.median(xs))
l = ys[xs < xmid]
r_ = ys[xs >= xmid]
if len(l) < 40 or len(r_) < 40:
left.append(None)
right.append(None)
continue
left.append(float(np.mean(l)))
right.append(float(np.mean(r_)))
return left, right
def alt_corr(a, b):
"""Correlation of two traces over frames where both are present."""
pairs = [(x, y) for x, y in zip(a, b) if x is not None and y is not None]
if len(pairs) < 20:
return float("nan"), 0.0
xs = np.array([p[0] for p in pairs])
ys = np.array([p[1] for p in pairs])
if xs.std() < 1e-6 or ys.std() < 1e-6:
return float("nan"), len(pairs) / max(len(a), 1)
return float(np.corrcoef(xs, ys)[0, 1]), len(pairs) / len(a)
def verdict(speed, cmd, lrate, rrate, contact_alt, frame_alt, valid_frac):
"""PASS needs forward motion, both feet sharing the work, and the picture
showing alternation. Anything inconclusive fails."""
reasons = []
if not (abs(speed - cmd) <= SPEED_TOL and speed > 0.03):
reasons.append(f"speed {speed:+.3f} not within {SPEED_TOL} of {cmd:.2f}")
if min(lrate, rrate) < DUTY_FLOOR:
reasons.append(f"min duty {min(lrate, rrate):.2f} below {DUTY_FLOOR}")
if not (contact_alt == contact_alt and contact_alt <= CONTACT_ALT_CEIL):
reasons.append(f"contact alt {contact_alt:+.2f} above {CONTACT_ALT_CEIL}")
if valid_frac < MIN_VALID_FRAC:
reasons.append(f"usable frames {valid_frac:.0%} below {MIN_VALID_FRAC:.0%}")
elif not (frame_alt == frame_alt and frame_alt <= FRAME_ALT_CEIL):
reasons.append(f"frame alt {frame_alt:+.2f} above {FRAME_ALT_CEIL}")
return (not reasons), reasons
def self_test():
"""Synthetic pink-blob frames: alternating must read ~-1, together ~+1."""
h, w = 64, 64
n = 60
def frame(ly, ry):
img = np.zeros((h, w, 3), dtype=np.uint8)
img[0:5, 0:5] = (229, 51, 229)
img[int(ly) : int(ly) + 6, 10:20] = (229, 51, 229)
img[int(ry) : int(ry) + 6, 44:54] = (229, 51, 229)
return img
t = np.arange(n)
alt_frames = [frame(40 + 8 * np.sin(2 * np.pi * i / 20),
40 + 8 * np.sin(2 * np.pi * i / 20 + np.pi)) for i in t]
hop_frames = [frame(40 + 8 * np.sin(2 * np.pi * i / 20),
40 + 8 * np.sin(2 * np.pi * i / 20)) for i in t]
la, ra = foot_traces(alt_frames)
lh, rh = foot_traces(hop_frames)
ca, _ = alt_corr(la, ra)
ch, _ = alt_corr(lh, rh)
print(f"self-test alternating frames: alt {ca:+.3f} (want near -1)")
print(f"self-test together frames : alt {ch:+.3f} (want near +1)")
ok = ca < -0.8 and ch > 0.8
print("self-test:", "PASS" if ok else "FAIL")
return ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--checkpoint", required=True)
ap.add_argument("--command", type=float, default=0.1)
ap.add_argument("--steps", type=int, default=250)
ap.add_argument("--envs", type=int, default=4)
ap.add_argument("--device", default="cuda:0")
ap.add_argument("--out", default=None)
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
sys.exit(0 if self_test() else 1)
import imageio.v2 as imageio
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import MjlabOnPolicyRunner, RslRlVecEnvWrapper
from mjlab.tasks.registry import load_env_cfg, load_rl_cfg, load_runner_cls
env_cfg = load_env_cfg(TASK, play=True)
env_cfg.scene.num_envs = args.envs
agent_cfg = load_rl_cfg(TASK)
env = ManagerBasedRlEnv(cfg=env_cfg, device=args.device, render_mode="rgb_array")
venv = RslRlVecEnvWrapper(env)
runner_cls = load_runner_cls(TASK) or MjlabOnPolicyRunner
runner = runner_cls(venv, asdict(agent_cfg), device=args.device)
runner.load(args.checkpoint, load_cfg={"actor": True}, strict=True,
map_location=args.device)
policy = runner.get_inference_policy(device=args.device)
robot = env.scene["robot"]
# This model's forward is body -y (face side). mjlab hardcodes forward_vec_b to
# body+x, so point it at the real forward axis; otherwise the travel
# metric scores side-sway like a side-stepping gait.
robot.data.forward_vec_b[:] = 0.0
robot.data.forward_vec_b[:, 1] = -1.0
twist = env.command_manager.get_term("twist")
step_dt = env.step_dt
obs, _ = venv.reset()
for _ in range(50):
cmd = torch.zeros_like(twist.command)
cmd[:, 1] = -args.command # forward is body -y (face side)
twist.command[:] = cmd
with torch.no_grad():
obs, _, _, _ = venv.step(policy(obs))
out = args.out or os.path.join(
os.path.dirname(args.checkpoint), f"verify_cmd{args.command:.2f}.mp4")
writer = imageio.get_writer(out, fps=int(1 / step_dt), macro_block_size=1)
frames, P, H, C = [], [], [], []
for _ in range(args.steps):
cmd = torch.zeros_like(twist.command)
cmd[:, 1] = -args.command # forward is body -y (face side)
twist.command[:] = cmd
with torch.no_grad():
obs, _, _, _ = venv.step(policy(obs))
img = env.render()
frame = img[0] if img.ndim == 4 else img
writer.append_data(frame)
frames.append(frame)
P.append(robot.data.root_link_pos_w[:, :2].clone())
H.append(robot.data.heading_w.clone())
C.append(env.scene["feet_ground_contact"].data.found[:].clone())
writer.close()
P, H, C = torch.stack(P), torch.stack(H), torch.stack(C)
d = P[1:] - P[:-1]
h = H[:-1]
fwd = torch.stack([torch.cos(h), torch.sin(h)], -1)
travel = (d * fwd).sum(-1).sum(0)
dur = (P.shape[0] - 1) * step_dt
speed = (travel / dur).mean().item()
left = (C[..., 0] > 0.5)
right = (C[..., 1] > 0.5)
lrate = left.float().mean().item()
rrate = right.float().mean().item()
lf = left.float().mean(1).cpu().numpy()
rf = right.float().mean(1).cpu().numpy()
contact_alt = (float(np.corrcoef(lf, rf)[0, 1])
if lf.std() > 1e-6 and rf.std() > 1e-6 else float("nan"))
ly, ry = foot_traces(frames)
frame_alt, valid_frac = alt_corr(ly, ry)
# ---- LAST-FRAMES CHECK (end-state grounding) ----
# The loop must not pass on a mean that hides a late collapse (or a late
# recovery). Require the final N frames to be upright (frame_alt ok) AND
# both feet sharing load (duty floor). This is what a human sees in the
# last frames of the video.
LAST_N = max(20, args.steps // 5)
lf_last = lf[-LAST_N:]
rf_last = rf[-LAST_N:]
lrate_last = float(np.mean(lf_last)) if len(lf_last) else 0.0
rrate_last = float(np.mean(rf_last)) if len(rf_last) else 0.0
ly_last = ly[-LAST_N:]; ry_last = ry[-LAST_N:]
frame_alt_last, valid_frac_last = alt_corr(ly_last, ry_last)
last_ok = (
min(lrate_last, rrate_last) >= DUTY_FLOOR
and frame_alt_last == frame_alt_last
and frame_alt_last <= FRAME_ALT_CEIL
)
passed, reasons = verdict(speed, args.command, lrate, rrate,
contact_alt, frame_alt, valid_frac)
if not last_ok:
reasons.append(
f"last{LAST_N} frames: min duty {min(lrate_last, rrate_last):.2f} "
f"below {DUTY_FLOOR} or frame alt {frame_alt_last:+.2f} above {FRAME_ALT_CEIL} "
f"(end-state not walking)")
sidecar = {"verdict": "WALKS" if passed else "NO-WALK",
"reasons": reasons, "speed": round(speed, 4),
"command": args.command, "left_duty": round(lrate, 3),
"right_duty": round(rrate, 3),
"contact_alt": round(contact_alt, 3),
"frame_alt": round(frame_alt, 3),
"valid_frames": round(valid_frac, 3),
"last_ok": bool(last_ok),
"last_min_duty": round(min(lrate_last, rrate_last), 3),
"last_frame_alt": round(frame_alt_last, 3),
"video": out, "checkpoint": args.checkpoint}
with open(os.path.splitext(out)[0] + ".json", "w") as fh:
json.dump(sidecar, fh, indent=1)
print(f"checkpoint : {os.path.basename(args.checkpoint)}")
print(f"command : {args.command:.2f} m/s, {args.steps} steps, {args.envs} envs")
print(f"speed : {speed:+.3f} m/s along own axis")
print(f"duty : left {lrate:.2f} right {rrate:.2f}")
print(f"contact alt: {contact_alt:+.2f} frame alt: {frame_alt:+.2f} "
f"({valid_frac:.0%} frames usable)")
print(f"video : {out}")
print(f"verdict : {'WALKS' if passed else 'NO-WALK'}")
for r in reasons:
print(f" because: {r}")
sys.exit(0 if passed else 1)
if __name__ == "__main__":
main()
|