[Physical AI W2D1 · 3/6]
RViz는 GUI라 순수 터미널론 못 띄운다. GCP VM에 Xvfb+VNC+noVNC+Cloudflared 스택을 세워 브라우저로 RViz2를 띄우고, LaserScan·Odometry·Path·TF를 발행하는 Publisher 노드를 만들어 원형으로 도는 로봇을 화면에서 직접 확인한다.
이 글에서 직접 해보는 것
1. GCP VM(Ubuntu 22.04)에 ROS 2 Humble + RViz2 설치
2. 헤드리스 GUI 스택: Xvfb + fluxbox + x11vnc + noVNC + Cloudflared → 브라우저로 RViz2
3..rviz설정 파일로 RViz2 실행(Fixed Frameodom, Grid·TF·LaserScan·Odometry·Path)
4.sensor_demo_publisher.py— LaserScan·Odometry·Path·TF를 발행하는 노드
5.tf2_echo로odom→base_link→laser변환 확인 + RViz 화면 검증
(1·2편의 RViz 개념을 직접 띄워 확인하는 단계입니다. W1D2의 패키지 빌드·Cloudflare Tunnel 경험이 그대로 이어집니다.)
들어가며 — RViz는 GUI라 "화면"이 필요하다
RViz2는 3D 창을 그리는 GUI 프로그램입니다. 그런데 GCP VM(클라우드 서버)엔 모니터가 없습니다. 그래서 가상 화면(Xvfb) 을 만들고, 그걸 VNC → noVNC로 브라우저에 띄우고, Cloudflared로 외부에서 접속 가능한 URL을 뽑습니다(W1D1·D2의 터널 패턴을 GUI로 확장).
RViz2 ─▶ Xvfb(가상화면 :1) ─▶ x11vnc(VNC 공유) ─▶ noVNC/websockify(웹 변환 :6080) ─▶ Cloudflared(외부 URL) ─▶ 브라우저
💡 터미널 역할 분리 — ROS 2는 여러 노드가 동시에 도니 SSH 터미널을 나눕니다.
터미널 1: 설치·빌드·RViz2,
터미널 2: Publisher 실행,
터미널 3: 토픽/TF 확인.
1. ROS 2 Humble + RViz2 설치 (GCP VM Ubuntu 22.04)
lsb_release -a # Ubuntu 22.04.x (jammy) 확인
python3 --version # Python 3.10.x
# 기본 도구
sudo apt update
sudo apt install -y software-properties-common curl gnupg lsb-release wget nano
sudo add-apt-repository universe -y && sudo apt update
# ROS 2 Humble 저장소 (amd64·jammy 고정)
sudo rm -f /etc/apt/sources.list.d/ros2.list
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu jammy main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
sudo apt update
# ROS 2 + RViz2 + 빌드 도구
sudo apt install -y ros-humble-ros-base ros-humble-rviz2
sudo apt install -y python3-colcon-common-extensions python3-rosdep python3-argcomplete
# 환경 자동 적용 + 확인
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc && source ~/.bashrc
which rviz2 # /opt/ros/humble/bin/rviz2
which colcon
# 작업공간
mkdir -p ~/rviz_ws/src && cd ~/rviz_ws
⚠️ 저장소는 반드시 jammy(22.04). noble/jazzy가 섞이면 안 됩니다. (W1D2와 동일 —
-o로 키 저장은 공식 방식)
2. 헤드리스 GUI 스택 — 화면을 브라우저로
GCP VM엔 GUI가 없으니, RViz 창을 띄울 가상 화면과 브라우저 전달 도구를 설치합니다.
sudo apt update
sudo apt install -y xvfb fluxbox x11vnc novnc websockify xterm mesa-utils dbus-x11
| 도구 | 역할 |
|---|---|
| Xvfb | VM 안에 가상 X 화면 생성 |
| fluxbox | 그 화면에서 창 배치·조작(창 관리자) |
| x11vnc | X 화면을 VNC로 공유 |
| noVNC / websockify | VNC를 브라우저(웹소켓)로 변환 |
| xterm | 화면 전달이 정상인지 확인용 테스트 창 |
Cloudflared 설치(외부 URL용):
curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-archive-keyring.gpg >/dev/null
sudo rm -f /etc/apt/sources.list.d/cloudflared.list
echo "deb [signed-by=/usr/share/keyrings/cloudflare-archive-keyring.gpg] https://pkg.cloudflare.com/cloudflared jammy main" | sudo tee /etc/apt/sources.list.d/cloudflared.list > /dev/null
sudo apt update && sudo apt install -y cloudflared
스택 실행 (순서대로)
# (재시작 시) 기존 프로세스 정리
pkill -f "rviz2"; pkill -f "Xvfb :1"; pkill -f "fluxbox"; pkill -f "x11vnc"; pkill -f "websockify"; pkill -f "cloudflared tunnel"; true
# 1) 가상 화면
Xvfb :1 -screen 0 1280x800x24 > ~/xvfb.log 2>&1 &
export DISPLAY=:1 # 이후 GUI는 이 화면(:1)에 그려짐
# 2) 창 관리자 + 테스트 창
fluxbox > ~/fluxbox.log 2>&1 &
xterm > ~/xterm.log 2>&1 &
# 3) VNC 공유(5900) → 웹 변환(6080)
x11vnc -display :1 -forever -shared -nopw -listen 0.0.0.0 -xkb -rfbport 5900 > ~/x11vnc.log 2>&1 &
websockify --web=/usr/share/novnc/ 6080 localhost:5900 > ~/novnc.log 2>&1 &
# 4) 외부 URL
nohup cloudflared tunnel --url http://localhost:6080 > ~/tunnel.log 2>&1 &
sleep 8 && tail -n 20 ~/tunnel.log # https://xxxx.trycloudflare.com 확인
브라우저에서 https://xxxx.trycloudflare.com/vnc.html 접속 → Connect → 화면에 xterm 창이 보이면 전달 성공입니다(여기에 곧 RViz2가 뜹니다).
⚠️
5900(VNC)은 브라우저가 직접 접속하는 포트가 아니라 noVNC가6080(웹)으로 변환합니다. Cloudflared는6080을 외부 URL로 연결. URL은 실행마다 바뀝니다.
3. RViz2 설정 파일 + 실행
매번 Display를 수동 추가하면 사람마다 화면이 달라지니, .rviz 설정 파일로 고정합니다(2편의 설정 파일).
mkdir -p ~/rviz_ws/rviz
nano ~/rviz_ws/rviz/demo.rviz
핵심만(전체는 길지만 요지는 이렇습니다): Fixed Frame odom + Grid·TF·LaserScan(/demo/scan)·Odometry(/demo/odom)·Path(/demo/path) Display.
Visualization Manager:
Global Options:
Fixed Frame: odom # ← 모든 데이터의 기준
Displays:
- Class: rviz_default_plugins/Grid
- Class: rviz_default_plugins/TF # Show Axes/Arrows/Names: true
- Class: rviz_default_plugins/LaserScan
Topic: { Value: /demo/scan }
Style: Points
Size (m): 0.05
- Class: rviz_default_plugins/Odometry
Topic: { Value: /demo/odom }
Shape: Arrow
- Class: rviz_default_plugins/Path
Topic: { Value: /demo/path }
Line Style: Lines
실행:
source /opt/ros/humble/setup.bash
export DISPLAY=:1
export LIBGL_ALWAYS_SOFTWARE=1 # ← GPU 없는 VM에서 소프트웨어 렌더링(중요)
rviz2 -d ~/rviz_ws/rviz/demo.rviz > ~/rviz2.log 2>&1 &
브라우저 noVNC 화면에 RViz2 창이 뜹니다. 아직 데이터는 없습니다 — /demo/*와 TF를 발행할 Publisher 노드가 아직 안 떴기 때문입니다(다음 단계).
4. Publisher 노드 — 센서·TF 발행
RViz에 그릴 데이터를 발행하는 노드를 만듭니다. 원형으로 도는 로봇을 흉내 내며 LaserScan·Odometry·Path·TF(static+dynamic) 를 발행합니다.
cd ~/rviz_ws/src && source /opt/ros/humble/setup.bash
ros2 pkg create rviz_sensor_demo --build-type ament_python \
--dependencies rclpy sensor_msgs nav_msgs geometry_msgs std_msgs tf2_ros
cd ~/rviz_ws/src/rviz_sensor_demo/rviz_sensor_demo
nano sensor_demo_publisher.py
import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped, PoseStamped
from nav_msgs.msg import Odometry, Path
from sensor_msgs.msg import LaserScan
from tf2_ros import TransformBroadcaster, StaticTransformBroadcaster
class SensorDemoPublisher(Node):
def __init__(self):
super().__init__('sensor_demo_publisher')
self.scan_pub = self.create_publisher(LaserScan, '/demo/scan', 10)
self.odom_pub = self.create_publisher(Odometry, '/demo/odom', 10)
self.path_pub = self.create_publisher(Path, '/demo/path', 10)
self.tf_broadcaster = TransformBroadcaster(self)
self.static_tf_broadcaster = StaticTransformBroadcaster(self)
self.path_msg = Path()
self.path_msg.header.frame_id = 'odom'
self.t = 0.0
self.publish_static_tf()
self.timer = self.create_timer(0.1, self.publish_data)
self.get_logger().info('RViz sensor demo publisher started')
def publish_static_tf(self):
static_tf = TransformStamped()
static_tf.header.stamp = self.get_clock().now().to_msg()
static_tf.header.frame_id = 'base_link'
static_tf.child_frame_id = 'laser'
static_tf.transform.translation.x = 0.25
static_tf.transform.translation.y = 0.0
static_tf.transform.translation.z = 0.15
static_tf.transform.rotation.w = 1.0
self.static_tf_broadcaster.sendTransform(static_tf)
def publish_data(self):
now = self.get_clock().now().to_msg()
x = 2.0 * math.cos(self.t)
y = 2.0 * math.sin(self.t)
yaw = self.t + math.pi / 2.0
self.publish_dynamic_tf(now, x, y, yaw)
self.publish_odom(now, x, y, yaw)
self.publish_path(now, x, y, yaw)
self.publish_scan(now)
self.t += 0.03
def publish_dynamic_tf(self, stamp, x, y, yaw):
tf_msg = TransformStamped()
tf_msg.header.stamp = stamp
tf_msg.header.frame_id = 'odom'
tf_msg.child_frame_id = 'base_link'
tf_msg.transform.translation.x = x
tf_msg.transform.translation.y = y
tf_msg.transform.rotation.z = math.sin(yaw / 2.0)
tf_msg.transform.rotation.w = math.cos(yaw / 2.0)
self.tf_broadcaster.sendTransform(tf_msg)
def publish_odom(self, stamp, x, y, yaw):
odom = Odometry()
odom.header.stamp = stamp
odom.header.frame_id = 'odom'
odom.child_frame_id = 'base_link'
odom.pose.pose.position.x = x
odom.pose.pose.position.y = y
odom.pose.pose.orientation.z = math.sin(yaw / 2.0)
odom.pose.pose.orientation.w = math.cos(yaw / 2.0)
odom.twist.twist.linear.x = 0.6
odom.twist.twist.angular.z = 0.3
self.odom_pub.publish(odom)
def publish_path(self, stamp, x, y, yaw):
pose = PoseStamped()
pose.header.stamp = stamp
pose.header.frame_id = 'odom'
pose.pose.position.x = x
pose.pose.position.y = y
pose.pose.orientation.z = math.sin(yaw / 2.0)
pose.pose.orientation.w = math.cos(yaw / 2.0)
self.path_msg.header.stamp = stamp
self.path_msg.poses.append(pose)
if len(self.path_msg.poses) > 300:
self.path_msg.poses.pop(0)
self.path_pub.publish(self.path_msg)
def publish_scan(self, stamp):
scan = LaserScan()
scan.header.stamp = stamp
scan.header.frame_id = 'laser'
scan.angle_min = -math.pi
scan.angle_max = math.pi
scan.angle_increment = math.pi / 180.0
scan.range_min = 0.1
scan.range_max = 8.0
count = int((scan.angle_max - scan.angle_min) / scan.angle_increment)
ranges = []
for i in range(count):
angle = scan.angle_min + i * scan.angle_increment
wave = 0.5 * math.sin(4.0 * angle + self.t)
front_object = 1.2 if abs(angle) < 0.25 else 0.0
distance = 3.0 + wave - front_object
distance = max(scan.range_min, min(scan.range_max, distance))
ranges.append(distance)
scan.ranges = ranges
scan.intensities = [1.0 for _ in ranges]
self.scan_pub.publish(scan)
def main(args=None):
rclpy.init(args=args)
node = SensorDemoPublisher()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
핵심 구조:
publish_static_tf()→base_link → laser(센서 고정 장착, x 0.25·z 0.15)publish_dynamic_tf()→odom → base_link(로봇 이동, 0.1초마다 갱신)publish_odom/path/scan()→/demo/odom·/demo/path·/demo/scan발행- 회전은 yaw를 quaternion(
z=sin(yaw/2),w=cos(yaw/2))으로 — 5편에서 자세히.
5. 등록 · 빌드 · 실행
setup.py의 entry_points에 등록:
entry_points={
'console_scripts': [
'sensor_demo_publisher = rviz_sensor_demo.sensor_demo_publisher:main',
],
},
(package.xml엔 rclpy·sensor_msgs·nav_msgs·geometry_msgs·std_msgs·tf2_ros 의존성이 있어야 합니다.)
빌드 + 실행(터미널 2):
cd ~/rviz_ws && source /opt/ros/humble/setup.bash
colcon build --packages-select rviz_sensor_demo
source install/setup.bash
ros2 run rviz_sensor_demo sensor_demo_publisher
# [INFO] [sensor_demo_publisher]: RViz sensor demo publisher started
→ 이 노드가 /demo/scan·/demo/odom·/demo/path·/tf·/tf_static을 계속 발행합니다. 이 터미널은 켜둡니다.
6. 확인 — 토픽·TF·화면
터미널 3에서:
cd ~/rviz_ws && source /opt/ros/humble/setup.bash && source install/setup.bash
ros2 topic list # /demo/scan, /demo/odom, /demo/path, /tf, /tf_static
ros2 topic echo /demo/scan --once # header.frame_id: laser 확인
ros2 topic hz /demo/scan # ≈ 10Hz
ros2 topic echo /demo/odom --once # frame_id: odom, child_frame_id: base_link
TF 변환을 숫자로 확인(tf2_echo):
ros2 run tf2_ros tf2_echo odom base_link # 로봇이 돌며 Translation/Rotation 변함
ros2 run tf2_ros tf2_echo base_link laser
# - Translation: [0.250, 0.000, 0.150] ← LiDAR가 본체 앞 0.25m·위 0.15m
# - Rotation: [0.000, 0.000, 0.000, 1.000]
이번 실습의 TF 구조:
odom
└── base_link (dynamic, 로봇 이동)
└── laser (static, 센서 고정)
이제 브라우저 noVNC의 RViz2 화면을 보면 — 좌표축(TF), 화살표(Odometry), 원형으로 누적되는 선(Path), 주변 LiDAR 점(LaserScan)이 함께 보이고, 로봇이 원을 그리며 도는 게 시각화됩니다. (Fixed Frame이 odom이라 모두 odom 기준으로 모입니다 — 1·2편의 개념이 눈으로 확인되는 순간!)
7. 흔한 오류 (핵심만)
| 증상 | 원인 / 해결 |
|---|---|
| noVNC는 열리는데 검은 화면 | Xvfb 미실행 또는 export DISPLAY=:1 누락 → 스택 순서대로 재실행 |
| RViz는 떴는데 데이터 안 보임 | Publisher 미실행, 또는 Fixed Frame/TF 문제(2편 6단계 점검) |
| RViz 창이 안 뜸/크래시 | export LIBGL_ALWAYS_SOFTWARE=1 누락(GPU 없는 VM) → 설정 후 재실행 |
LaserScan만 안 보임 |
/demo/scan의 frame_id: laser ↔ base_link → laser TF(/tf_static) 확인 |
ros2 run 실패 |
setup.py entry_points 누락 또는 빌드 후 source install/setup.bash 안 함 |
| URL 접속 안 됨 | Cloudflared URL은 매 실행 바뀜 → tail ~/tunnel.log로 새 URL 확인 |
3편 정리
- RViz는 GUI → GCP VM 헤드리스 스택(Xvfb→x11vnc→noVNC→Cloudflared)으로 브라우저에 띄움.
.rviz설정 파일로 Fixed Frameodom+ Display 고정.sensor_demo_publisher.py가 LaserScan·Odometry·Path + static/dynamic TF를 발행.tf2_echo로odom→base_link→laser변환을 숫자로, RViz로 그림으로 검증.- 핵심 환경변수:
DISPLAY=:1,LIBGL_ALWAYS_SOFTWARE=1(소프트웨어 렌더링).
다음 편 예고
화면에서 본 좌표축과 변환 — 그 수학적 정체가 동차 변환 행렬입니다. 4편부터 이론으로 들어가, 좌표계가 왜 필요한지, 2D 회전·이동을 어떻게 한 행렬로 합치는지(동차좌표·동차변환행렬)를 차근차근 잡습니다.
📚 Week2 Day1 전체 목차 (총 6편)
- 1/6 RViz로 로봇을 눈으로 보다 — 시각화 흐름·Fixed Frame·Display
- 2/6 RViz 개념 ② — TF·좌표계·URDF·센서 Display·디버깅
- 3/6 RViz2 실습 — GCP VM 헤드리스 GUI + Publisher + 시각화 — 이번 글
- 4/6 좌표계와 동차 변환 행렬 ① — 좌표계·2D 변환·동차좌표
- 5/6 좌표계와 동차 변환 행렬 ② — 3D·TF·Quaternion·기구학
- 6/6 좌표 변환 실습 — Python 변환 + TF Publisher + RViz
'피지컬AI' 카테고리의 다른 글
| [Physical AI W1D2] 톺아보기 — ROS 2 통신·패키지 20문제 완전 풀이 (0) | 2026.06.20 |
|---|---|
| [Physical AI W2D1] 6/6 — 좌표 변환 실습: Python 변환 + TF Publisher + RViz (0) | 2026.06.20 |
| [Physical AI W2D1] 5/6 — 좌표계와 동차 변환 행렬 ②: 3D·TF·Quaternion·기구학 (0) | 2026.06.20 |
| [Physical AI W2D1] 4/6 — 좌표계와 동차 변환 행렬 ①: 좌표계·2D 변환·동차좌표 (0) | 2026.06.20 |
| [Physical AI W2D1] 2/6 — RViz 개념 ②: TF·좌표계·URDF·센서 Display·디버깅 (0) | 2026.06.20 |