File size: 6,049 Bytes
9510f61
 
 
 
 
 
 
 
 
 
 
 
 
8b1d746
9510f61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b1d746
 
 
 
9510f61
 
 
 
 
 
 
 
 
e58984a
 
 
 
9510f61
e58984a
 
9510f61
 
8b1d746
9510f61
 
 
 
 
e58984a
 
8b1d746
9510f61
 
 
 
8b1d746
 
 
e58984a
 
 
9510f61
8b1d746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9510f61
8b1d746
 
 
 
 
 
 
 
 
 
 
 
 
 
9510f61
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
"""Watch a checkpoint walk: rebuild the Brax PPO policy from a saved params
pickle, run it on one Himalaya/snow environment, and encode the frames to MP4.

A reward curve says whether training is moving; only a rollout says whether the
robot walks. `ppo.train` returns `(make_inference_fn, params, metrics)`, and the
checkpoints written by `policy_params_fn` are the same `params` tuple
`(normalizer_params, policy_params, value_params)`, so the policy is rebuilt
exactly the way `ppo.train` builds it: the same `network_factory` config on the
same env, then `make_inference_fn(params, deterministic=True)`.
"""

from __future__ import annotations

import os
import pickle
from pathlib import Path

import numpy as np


def make_inference_fn(env, net_cfg: dict | None):
    """The `make_inference_fn` that `ppo.train(...)` returns, without training."""
    from brax.training.acme import running_statistics
    from brax.training.agents.ppo import networks as ppo_networks

    ppo_network = ppo_networks.make_ppo_networks(
        observation_size=env.observation_size,
        action_size=env.action_size,
        preprocess_observations_fn=running_statistics.normalize,
        **dict(net_cfg or {}),
    )
    return ppo_networks.make_inference_fn(ppo_network)


def load_params(path: Path):
    with open(path, "rb") as f:
        params = pickle.load(f)
    # policy_params_fn and the final return both give (normalizer, policy, value);
    # the inference fn wants the first two.
    return tuple(params)[:2] if len(params) == 3 else params


def apply_snow(env, friction: float, depth: float) -> None:
    """Fixed snow on a single (non-randomized) Playground env: same foot-floor
    contact-pair parameters `snow_randomizer` draws per env, then rebuild the
    MJX model like `apply_terrain` does."""
    from mujoco import mjx

    from himalaya_terrain import snow_params

    mj = env._mj_model
    solref, solimp = snow_params(float(depth))
    mj.pair_friction[0:2, 0:2] = friction
    mj.pair_solref[0:2] = np.asarray(solref)
    mj.pair_solimp[0:2] = np.asarray(solimp)
    floor = mj.geom("floor").id                      # make it look like snow too
    mj.geom_matid[floor] = -1
    mj.geom_rgba[floor] = (0.92, 0.94, 0.98, 1.0)
    env._mjx_model = mjx.put_model(mj, impl=env._config.impl)


def rollout(env, inference_fn, seconds: float = 8.0, command=(0.5, 0.0, 0.0), seed: int = 0):
    """Run the policy for `seconds` with a fixed joystick command. Returns
    (qpos, info): qpos is a (T, nq) trajectory at the env's control rate, info
    has distance walked and when (if) it fell."""
    import jax
    import jax.numpy as jnp

    jit_reset = jax.jit(env.reset)
    jit_step = jax.jit(env.step)
    jit_policy = jax.jit(inference_fn)

    rng = jax.random.PRNGKey(seed)
    state = jit_reset(rng)
    # The joystick task steers by a "command" in info; the get-up task has no
    # such key, and adding one would change the State pytree the step function
    # was traced against.
    steerable = "command" in state.info
    cmd = jnp.asarray(command, dtype=jnp.float32)
    if steerable:
        state.info["command"] = cmd

    n_steps = int(seconds / env.dt)
    qpos = [np.asarray(state.data.qpos)]
    fell_at = None
    for i in range(n_steps):
        rng, key = jax.random.split(rng)
        act, _ = jit_policy(state.obs, key)
        state = jit_step(state, act)
        if steerable:
            state.info["command"] = cmd      # joystick envs resample on reset only
        qpos.append(np.asarray(state.data.qpos))
        if fell_at is None and float(state.done) > 0.5:
            fell_at = (i + 1) * env.dt
            break

    qpos = np.stack(qpos)
    info = {"seconds": len(qpos) * env.dt,
            "distance_m": float(np.linalg.norm((qpos[-1, :2] - qpos[0, :2]))),
            "start_height_m": float(qpos[0, 2]),
            "end_height_m": float(qpos[-1, 2]),
            "peak_height_m": float(qpos[:, 2].max()),
            "fell_at": fell_at}
    return qpos, info


def offscreen_gl_backend() -> str | None:
    """Which MuJoCo offscreen backend this machine can actually load. The
    backend is fixed when `mujoco` is imported, so rendering happens in a
    subprocess with MUJOCO_GL set to this."""
    import ctypes.util
    import sys

    forced = os.environ.get("ROLLOUT_GL")
    if forced:
        return forced
    if sys.platform == "darwin":
        return "glfw"
    if ctypes.util.find_library("EGL"):
        return "egl"
    if ctypes.util.find_library("OSMesa"):
        return "osmesa"
    return None


def write_video(env, qpos: np.ndarray, path: Path, fps: float,
                width: int = 640, height: int = 480, camera: str = "track") -> Path:
    """Render a qpos trajectory of `env` to MP4 in a subprocess (see
    render_worker.py), so a missing or different GL backend can never take the
    training process down."""
    import subprocess
    import sys
    import tempfile

    backend = offscreen_gl_backend()
    if backend is None:
        raise RuntimeError("no offscreen GL backend (libEGL / libOSMesa) in this container -- "
                           "add `libegl1 libosmesa6` to packages.txt")
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory() as tmp:
        import mujoco
        mjb = Path(tmp) / "model.mjb"
        mujoco.mj_saveModel(env._mj_model, str(mjb), None)
        traj = Path(tmp) / "qpos.npy"
        np.save(traj, qpos)
        worker = Path(__file__).with_name("render_worker.py")
        env_vars = dict(os.environ, MUJOCO_GL=backend, PYOPENGL_PLATFORM=backend)
        r = subprocess.run([sys.executable, str(worker), str(mjb), str(traj), str(path),
                            str(fps), str(width), str(height), camera],
                           env=env_vars, capture_output=True, text=True, timeout=600)
    if r.returncode != 0:
        tail = (r.stderr or r.stdout).strip().splitlines()[-6:]
        raise RuntimeError(f"render ({backend}) failed: " + " | ".join(tail))
    return path