IsaacGym에서 학습한 정책을 Gazebo에서 검증하기
2026-03-26
| 회차 | 주제 |
|---|---|
| Lec 02 | 4족 보행 로봇 & IsaacGym 시뮬레이터 |
| Lec 03 | PPO 알고리즘 — 보행 정책 학습 |
| Lec 04 | VAE & DreamWaQ — 지형 인식 없이 걷기 |
| Lec 05 (오늘) | Gazebo Sim2Sim — 배포 전 검증 |
오늘 목표: quadruped_sim2sim 레포의 load_dreamwaq_policy.py를 분석하고 Gazebo에서 검증
학습 환경 검증 환경 실제 환경
(IsaacGym) ──▶ (Gazebo) ──▶ (실물 로봇)
Sim2Sim Sim2Real
IsaacGym만으로는 부족한 이유
Gazebo의 역할
quadruped_sim2sim/
├── policy_model/
│ ├── dreamwaq/model_5000.pt # 학습된 체크포인트
│ ├── himloco/model_10000.pt
│ └── walk-these-ways/... # WTW 모델
├── robot_controller/ # (ament_python) 정책 실행 노드
│ └── robot_controller/
│ ├── load_dreamwaq_policy.py # ★ DreamWaQ 컨트롤러
│ ├── load_wtw_policy.py # Walk-These-Ways 컨트롤러
│ └── load_him_policy.py # HIMLoco 컨트롤러
├── robot_description/ # (ament_cmake) Go1 URDF, meshes
│ ├── urdf/go1/go1.urdf.xacro
│ └── meshes/ # STL, DAE 파일
├── robot_gazebo/ # (ament_cmake) Gazebo launch, world
│ ├── launch/go1_gazebo.launch.py
│ ├── config/go1_ros_control.yaml
│ └── worlds/go1_world.world
└── robot_UI/ # (ament_python) 속도 명령 UI
└── robot_UI/UI_controller.py # PyQt5 조이스틱
| 패키지 | 빌드 | 역할 |
|---|---|---|
robot_controller |
ament_python | 정책 배포 노드 (3종) |
robot_description |
ament_cmake | Go1 URDF/xacro, 메쉬 (Go2 URDF로 교체 가능) |
robot_gazebo |
ament_cmake | Gazebo launch, world, ros2_control |
robot_UI |
ament_python | PyQt5 속도 명령 UI |
┌──────────────┐ /robot_velocity_command ┌──────────────┐
│ robot_UI │ ──────────────────────────────▶│ policy_node │
│ (PyQt5 UI) │ PoseStamped (vx, vy, vyaw) │ (DreamWaQ) │
└──────────────┘ │ │
│ │
┌──────────────┐ /joint_states │ │
│ Gazebo │ ──────────────────────────────▶│ │
│ (Physics) │ JointState (pos, vel) │ │
│ │ │ │
│ │ /imu_plugin/out │ │
│ │ ──────────────────────────────▶│ │
│ │ Imu (orientation, ang_vel) │ │
│ │ │ │
│ │ /joint_effort_controller │ │
│ │◀──────────────────────────────│ │
│ │ Float64MultiArray (torques) └──────────────┘
└──────────────┘
가장 흔한 Sim2Sim 실수: 관절 순서가 다름!
IsaacGym 순서 (go2_dreamwaq)
0: FL_hip 6: RL_hip
1: FL_thigh 7: RL_thigh
2: FL_calf 8: RL_calf
3: FR_hip 9: RR_hip
4: FR_thigh 10: RR_thigh
5: FR_calf 11: RR_calf
Gazebo 순서 (/joint_states)
0: FR_calf 6: FL_thigh
1: RR_calf 7: FL_hip
2: FR_thigh 8: RL_calf
3: RL_thigh 9: FL_calf
4: RR_thigh 10: RL_hip
5: RL_hip 11: RR_hip
(URDF 파싱 순서에 따라 결정)
Tip
관절 순서가 맞지 않으면 로봇이 즉시 넘어지거나 비정상 동작을 합니다. Sim2Sim에서 가장 먼저 확인해야 할 항목입니다.
# load_dreamwaq_policy.py — 노드 안에 직접 정의
class RunningMeanStd(nn.Module):
"""관측 정규화 — 체크포인트에서 로드"""
def __init__(self, shape):
super().__init__()
self.register_buffer("mean", torch.zeros(shape))
self.register_buffer("var", torch.ones(shape))
self.register_buffer("count", torch.tensor(1e-4))
def normalize(self, x):
return (x - self.mean) / (self.var.sqrt() + 1e-8)
class CENet(nn.Module):
"""Context-aided Estimator — go2_dreamwaq와 동일 구조"""
def __init__(self):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(225, 128), nn.ELU(), # obs_history 5×45
nn.Linear(128, 64), nn.ELU(),
nn.Linear(64, 35), # est_vel(3) + mu(16) + logvar(16)
)
# Decoder는 추론 시 사용 안 함
self.decoder = nn.Sequential(
nn.Linear(19, 64), nn.ELU(),
nn.Linear(64, 128), nn.ELU(),
nn.Linear(128, 48), nn.ELU(),
nn.Linear(48, 45),
)
class Actor(nn.Module):
"""정책 네트워크 — [512, 256, 128]"""
def __init__(self):
super().__init__()
self.actor = nn.Sequential(
nn.Linear(64, 512), nn.ELU(), # obs(45) + est_vel(3) + mu(16)
nn.Linear(512, 256), nn.ELU(),
nn.Linear(256, 128), nn.ELU(),
nn.Linear(128, 12), # 12 actions
)
def act_inference(self, obs):
return self.actor(obs)# load_dreamwaq_policy.py
def load_learned_models(pt_file, cenet, actor):
"""go2_dreamwaq 체크포인트에서 가중치 로드"""
# ★ 트릭: checkpoint에 RunningMeanStd가 rsl_rl.utils.rms 모듈로 저장됨
# → 가짜 모듈을 sys.modules에 등록하여 torch.load가 역직렬화 가능하게 함
import types
fake_module = types.ModuleType("rsl_rl.utils.rms")
fake_module.RunningMeanStd = RunningMeanStd
sys.modules["rsl_rl.utils.rms"] = fake_module
sys.modules["rsl_rl"] = types.ModuleType("rsl_rl")
sys.modules["rsl_rl.utils"] = types.ModuleType("rsl_rl.utils")
loaded = torch.load(pt_file, map_location="cpu")
# CENet 가중치 로드
cenet.load_state_dict(loaded["cenet_state_dict"])
# Actor 가중치 로드
actor.load_state_dict(loaded["model_state_dict"], strict=False)
# RunningMeanStd (obs 정규화)
obs_rms = loaded["rms"] # mean, var 포함
return obs_rmsclass AgentNode(Node):
def __init__(self):
super().__init__("agent_node")
# 네트워크 로드
self.cenet = CENet()
self.actor = Actor()
self.obs_rms = load_learned_models(
"policy_model/dreamwaq/model_5000.pt",
self.cenet, self.actor
)
self.cenet.eval()
self.actor.eval()
# 상태 버퍼
self.dof_pos = torch.zeros(1, 12)
self.dof_vel = torch.zeros(1, 12)
self.actions = torch.zeros(1, 12)
self.obs_history = torch.zeros(1, 5, 45) # 5스텝 히스토리
# 기본 관절 자세
self.default_dof_pos = torch.tensor([[
0.1, 0.8, -1.5, -0.1, 0.8, -1.5, # FL, FR
0.1, 1.0, -1.5, -0.1, 1.0, -1.5, # RL, RR
]])
# PD 게인 (Go2 기준)
self.Kp = 20.0
self.Kd = 0.5 # 구독
self.joint_sub = self.create_subscription(
JointState, "/joint_states",
self.joint_callback, 10)
self.imu_sub = self.create_subscription(
Imu, "/imu_plugin/out",
self.imu_callback, 10)
self.cmd_sub = self.create_subscription(
PoseStamped, "/robot_velocity_command",
self.cmd_callback, 10)
# 퍼블리시
self.effort_pub = self.create_publisher(
Float64MultiArray,
"/joint_effort_controller/commands", 10)
# 제어 루프 — 200Hz (정책은 4스텝마다 = 50Hz)
self.timer = self.create_timer(0.005, self.control_loop)
self.decimation_counter = 0def build_observation(self):
"""IsaacGym과 동일한 45차원 관측 벡터"""
# IMU — 각속도 (body frame)
base_ang_vel = self.imu_ang_vel # [1, 3]
# 중력 벡터 (body frame 투영)
projected_gravity = quat_rotate( # [1, 3]
self.base_quat, torch.tensor([[0., 0., -1.]]))
# 속도 명령
commands = self.velocity_commands # [1, 3]
# 관측 벡터 조합 — go2_dreamwaq와 동일 순서
obs = torch.cat([
base_ang_vel, projected_gravity, commands,
self.dof_pos - self.default_dof_pos, # [1, 12]
self.dof_vel, # [1, 12]
self.actions, # [1, 12]
], dim=-1) # [1, 45]
return obsTip
quat_rotate를 사용하는 것에 주의 — go2_dreamwaq의 학습 코드와 일치해야 합니다. (HIMLoco는 quat_rotate_inverse 사용)
def control_loop(self):
"""200Hz 제어 루프 — 정책은 50Hz (4스텝마다)"""
self.decimation_counter += 1
if self.decimation_counter >= 4: # 50Hz 정책 실행
self.decimation_counter = 0
# 1. 관측값 구성
obs = self.build_observation()
# 2. 히스토리 업데이트 & 정규화
self.obs_history = torch.roll(self.obs_history, shifts=-1, dims=1)
self.obs_history[:, -1] = obs
obs_norm = self.obs_rms.normalize(obs)
obs_history_norm = self.obs_rms.normalize(
self.obs_history.flatten(1).reshape(-1, 45)
).reshape(1, -1) # [1, 225]
# 3. CENet — est_vel + mu (context_vec)
h = self.cenet.encoder(obs_history_norm) # [1, 35]
est_vel = h[:, :3] # [1, 3]
mu = h[:, 3:19] # [1, 16] (context)
# 4. Actor 추론
actor_obs = torch.cat([obs_norm, est_vel, mu], dim=-1) # [1, 64]
raw_actions = self.actor.act_inference(actor_obs) # [1, 12]
# 5. Action scaling (hip × 0.5)
self.actions = raw_actions * 0.25
self.actions[:, [0, 3, 6, 9]] *= 0.5 # hip 관절 스케일 감소
# 6. PD 제어 → 토크 계산 (매 스텝, 200Hz)
q_des = self.default_dof_pos + self.actions
torques = self.Kp * (q_des - self.dof_pos) - self.Kd * self.dof_vel
# 7. 토크 퍼블리시
msg = Float64MultiArray()
msg.data = torques.squeeze().tolist()
self.effort_pub.publish(msg)┌─────────────────────────────────────────────────────┐
│ 200Hz (0.005s) │
│ ├── PD 제어 (토크 계산 & 퍼블리시) ← 매 스텝 │
│ │ │
│ └── 50Hz (0.02s = 4스텝마다) │
│ ├── 관측값 구성 │
│ ├── CENet 추론 (context_vec 생성) │
│ ├── Actor 추론 (action 결정) │
│ └── Action scaling │
└─────────────────────────────────────────────────────┘
PD 제어는 200Hz로 더 빈번하게 실행 → 물리적 안정성 향상
<!-- robot_description/urdf/go1/go1.urdf.xacro -->
<robot name="go1" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- 몸체 -->
<link name="trunk">
<inertial>
<mass value="4.8"/> <!-- 4.8 kg -->
<origin xyz="0.0116 0.0041 -0.0003"/>
</inertial>
</link>
<!-- 관절 예시 (FL_hip) -->
<joint name="FL_hip_joint" type="revolute">
<axis xyz="1 0 0"/>
<limit effort="23.7" velocity="30.1"
lower="-0.863" upper="0.863"/>
</joint>
<!-- IMU 센서 (1000Hz) -->
<gazebo reference="imu_link">
<sensor name="imu_sensor" type="imu">
<update_rate>1000</update_rate>
<plugin filename="libgazebo_ros_imu_sensor.so"
name="imu_plugin">
<topicName>imu_plugin/out</topicName>
</plugin>
</sensor>
</gazebo># robot_gazebo/config/go1_ros_control.yaml
controller_manager:
ros__parameters:
update_rate: 1000 # 1kHz 컨트롤러 매니저
use_sim_time: true
# 관절 상태 브로드캐스터
joint_states_controller:
ros__parameters:
type: joint_state_broadcaster/JointStateBroadcaster
# 토크 제어기 (effort)
joint_effort_controller:
ros__parameters:
type: effort_controllers/JointGroupEffortController
joints:
- FL_hip_joint
- FL_thigh_joint
- FL_calf_joint
- FR_hip_joint
- FR_thigh_joint
- FR_calf_joint
- RL_hip_joint
- RL_thigh_joint
- RL_calf_joint
- RR_hip_joint
- RR_thigh_joint
- RR_calf_joint# robot_gazebo/launch/go1_gazebo.launch.py
def generate_launch_description():
return LaunchDescription([
# 1. Gazebo 실행 (go1_world.world)
IncludeLaunchDescription(
"gazebo_ros", "launch/gazebo.launch.py",
launch_arguments={"world": world_path}.items()
),
# 2. robot_state_publisher (URDF → TF)
Node(package="robot_state_publisher",
executable="robot_state_publisher",
parameters=[{"robot_description": robot_desc}]),
# 3. 로봇 스폰 (z=0.4m 높이)
Node(package="gazebo_ros",
executable="spawn_entity.py",
arguments=["-entity", "go1", "-topic", "robot_description",
"-x", "0", "-y", "0", "-z", "0.4"]),
# 4. 컨트롤러 스폰
Node(package="controller_manager",
executable="spawner",
arguments=["joint_effort_controller"]),
Node(package="controller_manager",
executable="spawner",
arguments=["joint_states_controller"]),
])# 1. 워크스페이스 빌드
cd ~/ros2_ws
colcon build --packages-select \
robot_description robot_gazebo robot_controller robot_UI
source install/setup.bash
# 2. Gazebo + Go1 로봇 실행
ros2 launch robot_gazebo go1_gazebo.launch.py
# 3. DreamWaQ 정책 실행 (별도 터미널)
ros2 run robot_controller run_dreamwaq_policy
# 4. 속도 명령 UI (별도 터미널)
ros2 run robot_UI run_ui# robot_UI/UI_controller.py
class UIController(Node):
"""속도 명령 UI — PyQt5 기반"""
def __init__(self):
super().__init__("velocity_command_ui")
self.publisher = self.create_publisher(
PoseStamped, "/robot_velocity_command", 10)
# robot_UI/submodule/robot_UI.py
class JoystickWidget(QWidget):
"""원형 조이스틱 위젯"""
commandChanged = pyqtSignal(float, float, float)
# 수직 드래그 → command_x (전진 속도, -1.5 ~ 1.5)
# 수평 드래그 → command_z (yaw 각속도, -1.0 ~ 1.0)
# command_y = 0.0 (항상)position.x/y/z에 (vx, vy, vyaw) 매핑| DreamWaQ | Walk-These-Ways | HIMLoco | |
|---|---|---|---|
| 관측 차원 | 45 | 70 | 45 |
| 히스토리 | 5 × 45 = 225 | 30 × 70 = 2100 | 6 × 45 = 270 |
| 추정기 | CENet (VAE) | Adaptation Module (JIT) | HIMEstimator (L2-norm) |
| Actor 입력 | obs + est_vel + mu = 64 | obs_history + latent | obs + vel + latent = 64 |
| 명령 | 3 (vx, vy, vyaw) | 15 (gait params 포함) | 3 (vx, vy, vyaw) |
| Clock inputs | 없음 | 있음 (4 sin signals) | 없음 |
| PD Kp / Kd | 20 / 0.5 | 20 / 0.5 | 40 / 1.0 |
| PD 주파수 | 200Hz | 50Hz | 500Hz |
| 정책 주파수 | 50Hz | 50Hz | 50Hz |
| Hip 스케일 | × 0.5 | × 0.5 | 없음 |
| 토크 클리핑 | 100 Nm | 10 (action clip) | 80 Nm |
Tip
학습 코드와 배포 코드에서 동일한 함수를 사용해야 합니다. 함수가 다르면 중력 벡터가 뒤집혀서 로봇이 넘어집니다.
| 증상 | 원인 | 해결 |
|---|---|---|
| 로봇이 즉시 넘어짐 | 관절 순서 불일치 | ISAAC_IDX 매핑 확인 |
| 다리가 심하게 떨림 | PD 게인 차이 | Kp, Kd를 학습 설정과 일치 |
| 제자리에서 진동 | action_scale 차이 | 0.25 + hip × 0.5 확인 |
| 한쪽으로 돌아감 | 중력 회전 함수 | quat_rotate vs inverse |
| 지면에 빠짐 | URDF collision 누락 | collision 메쉬 확인 |
| 정책 로드 실패 | rsl_rl 모듈 없음 | sys.modules 가짜 모듈 등록 |
| obs_rms 불일치 | 체크포인트 버전 차이 | mean/var shape 확인 |
논문 이해 go2_dreamwaq IsaacGym quadruped_sim2sim
(DreamWaQ) → (CENet + PPO 구현) → (4096 env 학습) → (Gazebo 검증)
│
▼
실물 로봇 배포
(동일 ROS2 노드)
Gazebo 노드(
load_dreamwaq_policy.py)를 그대로 실물 로봇에 연결할 수 있습니다. 토픽명과 URDF만 바꾸면 됩니다.
감사합니다!
Lecture 05 — Gazebo Sim2Sim | Curieux.JY