ppo-pusher-v5-vecnormalize / safe_mujoco_render.py
arabellako22's picture
Add reusable subprocess-isolated MuJoCo rendering module
d3e15e7 verified
Raw History Blame Contribute Delete
14.4 kB
"""Safe subprocess-isolated PPO evaluation and MuJoCo rendering.
This module prevents native OpenGL conflicts by keeping Stable-Baselines3
model inference and MuJoCo frame rendering in separate Python processes.
It targets Gymnasium MuJoCo environments with continuous Box actions,
Stable-Baselines3 PPO checkpoints, and VecNormalize statistics.
"""
__version__ = "1.0.0"
__all__ = ["evaluate_and_render_ppo_mujoco"]
# Q3. μž¬μ‚¬μš© κ°€λŠ₯ν•œ PPO/MuJoCo 뢄리 ν‰κ°€Β·λ Œλ”λ§ 도ꡬ
#
# λŒ€μƒ:
# - Gymnasium MuJoCo 연속 행동(Box) ν™˜κ²½
# - Stable-Baselines3 PPO 체크포인트
# - VecNormalize 톡계
#
# 핡심:
# - SB3 ν”„λ‘œμ„ΈμŠ€λŠ” 평가와 행동 μƒμ„±λ§Œ μˆ˜ν–‰
# - MuJoCo ν”„λ‘œμ„ΈμŠ€λŠ” λ Œλ”λ§λ§Œ μˆ˜ν–‰
# - 동일 seed/ν–‰λ™μ˜ 보상 일치λ₯Ό μžλ™ 검증
import json
import math
import os
import subprocess
import sys
from pathlib import Path
GENERIC_EVALUATION_WORKER = r"""
import json
from pathlib import Path
import gymnasium as gym
import numpy as np
from stable_baselines3 import PPO
from stable_baselines3.common.env_util import make_vec_env
from stable_baselines3.common.evaluation import evaluate_policy
from stable_baselines3.common.vec_env import VecNormalize
request = json.loads(input())
env_id = request["env_id"]
model_path = Path(request["model_path"])
stats_path = Path(request["stats_path"])
device = request["device"]
n_eval_episodes = int(
request["n_eval_episodes"]
)
n_random_episodes = int(
request["n_random_episodes"]
)
eval_seed = int(request["eval_seed"])
random_seed = int(request["random_seed"])
replay_seed = int(request["replay_seed"])
max_replay_steps = request[
"max_replay_steps"
]
if max_replay_steps is not None:
max_replay_steps = int(max_replay_steps)
model = PPO.load(
model_path,
device=device,
)
# ------------------------------------------------------------
# 1. μ •κ·œν™” 톡계λ₯Ό μ μš©ν•œ PPO 평가
# ------------------------------------------------------------
eval_raw_env = make_vec_env(
env_id,
n_envs=1,
seed=eval_seed,
)
eval_env = VecNormalize.load(
stats_path,
eval_raw_env,
)
eval_env.training = False
eval_env.norm_reward = False
try:
mean_reward, std_reward = evaluate_policy(
model,
eval_env,
n_eval_episodes=n_eval_episodes,
deterministic=True,
warn=False,
)
finally:
eval_env.close()
# ------------------------------------------------------------
# 2. 랜덀 μ •μ±… κΈ°μ€€μ„ 
# ------------------------------------------------------------
random_env = gym.make(env_id)
random_rewards = []
try:
for episode_index in range(
n_random_episodes
):
episode_seed = (
random_seed + episode_index
)
obs, info = random_env.reset(
seed=episode_seed
)
random_env.action_space.seed(
episode_seed
)
episode_reward = 0.0
terminated = False
truncated = False
while not (terminated or truncated):
action = (
random_env.action_space.sample()
)
(
obs,
reward,
terminated,
truncated,
info,
) = random_env.step(action)
episode_reward += float(reward)
random_rewards.append(episode_reward)
finally:
random_env.close()
random_mean_reward = float(
np.mean(random_rewards)
)
random_std_reward = float(
np.std(random_rewards)
)
# ------------------------------------------------------------
# 3. 결정둠적 μž¬μƒ 행동 생성
# ------------------------------------------------------------
replay_raw_env = make_vec_env(
env_id,
n_envs=1,
seed=replay_seed,
)
replay_env = VecNormalize.load(
stats_path,
replay_raw_env,
)
replay_env.training = False
replay_env.norm_reward = False
actions = []
reference_reward = 0.0
try:
obs = replay_env.reset()
environment_limit = int(
replay_raw_env.get_attr(
"spec"
)[0].max_episode_steps
or 1000
)
replay_limit = environment_limit
if max_replay_steps is not None:
replay_limit = min(
replay_limit,
max_replay_steps,
)
for step_index in range(replay_limit):
action, state = model.predict(
obs,
deterministic=True,
)
if not hasattr(
replay_env.action_space,
"low",
):
raise TypeError(
"이 λ„κ΅¬λŠ” 연속 행동(Box) ν™˜κ²½μ„ "
"λŒ€μƒμœΌλ‘œ ν•©λ‹ˆλ‹€."
)
env_action = np.clip(
action[0],
replay_env.action_space.low,
replay_env.action_space.high,
).astype(np.float64)
actions.append(env_action.tolist())
obs, rewards, dones, infos = (
replay_env.step(
env_action[None, :]
)
)
reference_reward += float(rewards[0])
if bool(dones[0]):
break
finally:
replay_env.close()
if not actions:
raise RuntimeError(
"μž¬μƒν•  행동을 μƒμ„±ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."
)
improvement = (
float(mean_reward) - random_mean_reward
)
result = {
"env_id": env_id,
"algorithm": "PPO",
"mean_reward": float(mean_reward),
"std_reward": float(std_reward),
"n_eval_episodes": n_eval_episodes,
"eval_seed": eval_seed,
"random_mean_reward": random_mean_reward,
"random_std_reward": random_std_reward,
"n_random_episodes": n_random_episodes,
"random_seed": random_seed,
"improvement_over_random": improvement,
"training_success": improvement > 0.0,
"replay_seed": replay_seed,
"reference_reward": reference_reward,
"actions": actions,
}
print(
json.dumps(
result,
ensure_ascii=False,
),
flush=True,
)
"""
GENERIC_RENDER_WORKER = r"""
import json
import os
from pathlib import Path
import gymnasium as gym
import numpy as np
request = json.loads(input())
env_id = request["env_id"]
replay_seed = int(request["replay_seed"])
actions = request["actions"]
width = int(request["width"])
height = int(request["height"])
video_path_value = request["video_path"]
video_path = (
Path(video_path_value)
if video_path_value is not None
else None
)
render_env = gym.make(
env_id,
render_mode="rgb_array",
width=width,
height=height,
)
frames = []
episode_reward = 0.0
steps = 0
fps = int(
render_env.metadata.get(
"render_fps",
30,
)
)
try:
obs, info = render_env.reset(
seed=replay_seed
)
first_frame = render_env.render()
if first_frame is None:
raise RuntimeError(
"초기 RGB ν”„λ ˆμž„ 생성 μ‹€νŒ¨"
)
frames.append(
np.asarray(
first_frame,
dtype=np.uint8,
)
)
for step_index, action_values in enumerate(
actions
):
action = np.asarray(
action_values,
dtype=np.float64,
)
action = np.clip(
action,
render_env.action_space.low,
render_env.action_space.high,
)
(
obs,
reward,
terminated,
truncated,
info,
) = render_env.step(action)
episode_reward += float(reward)
steps = step_index + 1
frame = render_env.render()
if frame is None:
raise RuntimeError(
f"{steps}번째 RGB ν”„λ ˆμž„ 생성 μ‹€νŒ¨"
)
frames.append(
np.asarray(
frame,
dtype=np.uint8,
)
)
if terminated or truncated:
break
finally:
render_env.close()
if steps != len(actions):
raise RuntimeError(
"행동 배열을 λκΉŒμ§€ μž¬μƒν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."
)
video_bytes = None
if video_path is not None:
import imageio.v2 as imageio
video_path.parent.mkdir(
parents=True,
exist_ok=True,
)
imageio.mimsave(
video_path,
frames,
fps=fps,
codec="libx264",
quality=8,
macro_block_size=16,
)
if (
not video_path.is_file()
or video_path.stat().st_size == 0
):
raise RuntimeError(
"λΉ„λ””μ˜€ 파일 생성 μ‹€νŒ¨"
)
video_bytes = video_path.stat().st_size
result = {
"render_backend": os.environ["MUJOCO_GL"],
"rendered_reward": episode_reward,
"steps": steps,
"frames": len(frames),
"fps": fps,
"width": width,
"height": height,
"video_path": (
str(video_path)
if video_path is not None
else None
),
"video_bytes": video_bytes,
}
print(
json.dumps(
result,
ensure_ascii=False,
),
flush=True,
)
"""
def _run_json_worker(
*,
code,
payload,
worker_env,
label,
timeout_seconds,
):
result = subprocess.run(
[
sys.executable,
"-u",
"-X",
"faulthandler",
"-c",
code,
],
env=worker_env,
input=json.dumps(
payload,
ensure_ascii=False,
),
text=True,
capture_output=True,
timeout=timeout_seconds,
)
if result.returncode != 0:
raise RuntimeError(
f"{label} μ‹€νŒ¨ "
f"(return code {result.returncode})\n"
f"stdout tail:\n"
f"{result.stdout[-3000:]}\n"
f"stderr tail:\n"
f"{result.stderr[-6000:]}"
)
try:
return json.loads(
result.stdout.strip().splitlines()[-1]
)
except (
IndexError,
json.JSONDecodeError,
) as exc:
raise RuntimeError(
f"{label} κ²°κ³Ό JSON νŒŒμ‹± μ‹€νŒ¨"
) from exc
def evaluate_and_render_ppo_mujoco(
*,
env_id,
model_path,
vecnormalize_path,
video_path=None,
device="cpu",
n_eval_episodes=30,
n_random_episodes=30,
eval_seed=1000,
random_seed=2000,
replay_seed=42,
max_replay_steps=None,
width=640,
height=480,
backends=("egl", "osmesa"),
timeout_seconds=180,
):
"""
PPO/VecNormalize λͺ¨λΈμ„ ν‰κ°€ν•˜κ³  MuJoCo μ˜μƒμ„
별도 ν”„λ‘œμ„ΈμŠ€μ—μ„œ μ•ˆμ „ν•˜κ²Œ λ Œλ”λ§ν•©λ‹ˆλ‹€.
video_path=None이면 ν”„λ ˆμž„ μƒμ„±κΉŒμ§€λ§Œ κ²€μ‚¬ν•˜κ³ 
νŒŒμΌμ€ λ§Œλ“€μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
"""
model_path = Path(model_path)
vecnormalize_path = Path(
vecnormalize_path
)
for path in (
model_path,
vecnormalize_path,
):
if not path.is_file():
raise FileNotFoundError(path)
if n_eval_episodes < 1:
raise ValueError(
"n_eval_episodesλŠ” 1 이상이어야 ν•©λ‹ˆλ‹€."
)
if n_random_episodes < 1:
raise ValueError(
"n_random_episodesλŠ” 1 이상이어야 ν•©λ‹ˆλ‹€."
)
if not backends:
raise ValueError(
"λ Œλ”λ§ λ°±μ—”λ“œκ°€ ν•˜λ‚˜ 이상 ν•„μš”ν•©λ‹ˆλ‹€."
)
evaluation_payload = {
"env_id": env_id,
"model_path": str(model_path),
"stats_path": str(vecnormalize_path),
"device": device,
"n_eval_episodes": n_eval_episodes,
"n_random_episodes": (
n_random_episodes
),
"eval_seed": eval_seed,
"random_seed": random_seed,
"replay_seed": replay_seed,
"max_replay_steps": (
max_replay_steps
),
}
evaluation_env = os.environ.copy()
evaluation_env.pop("MUJOCO_GL", None)
evaluation_env.pop(
"PYOPENGL_PLATFORM",
None,
)
evaluation_result = _run_json_worker(
code=GENERIC_EVALUATION_WORKER,
payload=evaluation_payload,
worker_env=evaluation_env,
label="SB3 평가·행동 생성",
timeout_seconds=timeout_seconds,
)
actions = evaluation_result.pop(
"actions"
)
render_payload = {
"env_id": env_id,
"replay_seed": replay_seed,
"actions": actions,
"width": width,
"height": height,
"video_path": (
str(Path(video_path))
if video_path is not None
else None
),
}
render_errors = {}
render_result = None
for backend in backends:
worker_env = os.environ.copy()
worker_env["MUJOCO_GL"] = backend
worker_env[
"PYOPENGL_PLATFORM"
] = backend
worker_env.pop("DISPLAY", None)
try:
render_result = _run_json_worker(
code=GENERIC_RENDER_WORKER,
payload=render_payload,
worker_env=worker_env,
label=(
"MuJoCo λ Œλ”λ§ "
f"({backend})"
),
timeout_seconds=(
timeout_seconds
),
)
break
except RuntimeError as exc:
render_errors[backend] = str(exc)
if render_result is None:
raise RuntimeError(
"λͺ¨λ“  λ Œλ”λ§ λ°±μ—”λ“œκ°€ μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€:\n"
+ json.dumps(
render_errors,
indent=2,
ensure_ascii=False,
)
)
reference_reward = float(
evaluation_result[
"reference_reward"
]
)
rendered_reward = float(
render_result["rendered_reward"]
)
replay_verified = math.isclose(
reference_reward,
rendered_reward,
rel_tol=1e-7,
abs_tol=1e-7,
)
if not replay_verified:
raise RuntimeError(
"동일 행동 μž¬μƒ 보상 뢈일치: "
f"reference={reference_reward}, "
f"rendered={rendered_reward}"
)
if (
render_result["frames"]
!= render_result["steps"] + 1
):
raise RuntimeError(
"ν”„λ ˆμž„ μˆ˜κ°€ steps + 1κ³Ό "
"μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."
)
return {
**evaluation_result,
**render_result,
"replay_reward_verified": True,
}