[Physical AI W2D1] 3/6 — RViz2 실습: GCP VM 헤드리스 GUI로 센서·TF 시각화

2026. 6. 20. 12:38·피지컬AI

[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 Frame odom, 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 Frame odom + 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
'피지컬AI' 카테고리의 다른 글
  • [Physical AI W1D2] 톺아보기 — ROS 2 통신·패키지 20문제 완전 풀이
  • [Physical AI W2D1] 6/6 — 좌표 변환 실습: Python 변환 + TF Publisher + RViz
  • [Physical AI W2D1] 5/6 — 좌표계와 동차 변환 행렬 ②: 3D·TF·Quaternion·기구학
  • [Physical AI W2D1] 4/6 — 좌표계와 동차 변환 행렬 ①: 좌표계·2D 변환·동차좌표
hyeseong-dev
hyeseong-dev
안녕하세요. 백엔드 개발자 이혜성입니다.
  • hyeseong-dev
    어제 오늘 그리고 내일
    hyeseong-dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (342) N
      • 여러가지 (11) N
        • 알고리즘 & 자료구조 (73)
        • 오류 (4)
        • 이것저것 (29)
        • 일기 (1)
      • 프레임워크 (39)
        • 자바 스프링 (39)
        • React Native (0)
      • 프로그래밍 언어 (39)
        • 파이썬 (31)
        • 자바 (3)
        • 스프링부트 (5)
      • 컴퓨터 구조와 운영체제 (3)
      • DB (17)
        • SQL (0)
        • Redis (17)
      • 클라우드 컴퓨팅 (21)
        • 도커 (2)
        • AWS (19)
      • 스케쥴 (65)
        • 세미나 (0)
        • 수료 (0)
        • 스터디 (24)
        • 시험 (41)
      • 트러블슈팅 (1)
      • 자격증 (2) N
        • 정보처리기사 (0)
        • 정보보안기사 (1)
        • 네트워크관리사 (1) N
      • 재태크 (0)
        • 암호화폐 (0)
        • 기타 (0)
      • 피지컬AI (26)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    완전탐색
    TF
    네트워크
    rclpy
    WebFlux
    Spring Boot
    자바
    celery
    docker
    클라우드
    운동학
    moveit
    AWS
    java
    그리디
    로봇팔
    항해99
    FastAPI
    EC2
    Spring WebFlux
    취업리부트
    ROS2
    동차변환행렬
    Python
    SAA
    피지컬ai
    프로그래머스
    Redis
    AWS네트워크계층으로읽기
    역운동학
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
hyeseong-dev
[Physical AI W2D1] 3/6 — RViz2 실습: GCP VM 헤드리스 GUI로 센서·TF 시각화
상단으로

티스토리툴바