Skip to main content
Summary

센서를 붙인다는 말의 진짜 의미

지난 장에서 우리는 TurtleBot 시뮬레이션을 실행하고, /cmd_vel로 로봇을 움직이며, /scan, /odom, /tf 같은 생체 신호가 살아 있는지 확인했다. 이번 장에서는 한 걸음 더 들어간다. 로봇 위에 눈, 귀, 균형 감각, 바퀴 감각을 붙일 것이다. 여기서 눈은 카메라, 주변 거리 감각은 LiDAR, 균형 감각은 IMU, 바퀴 감각은 엔코더에 해당한다.

자율주행은 거창한 알고리즘에서 시작하지 않는다. 첫 출발점은 언제나 센서 데이터다. 로봇이 장애물을 피하지 못한다면, 경로 계획 알고리즘이 나빠서일 수도 있지만 그보다 먼저 장애물이 센서 토픽에 제대로 들어오고 있는지 확인해야 한다. SLAM이 지도를 찌그러뜨린다면, SLAM 패키지를 의심하기 전에 LiDAR의 frame_id, 장착 높이, 스캔 방향이 맞는지 봐야 한다. Nav2가 목표 지점으로 가다가 벽을 긁는다면, Costmap 설정 이전에 /scan과 /odom이 정확히 해석되고 있는지 점검해야 한다.

TurtleBot3 문서에서도 RViz만 사용하는 가짜 노드 시뮬레이션은 센서를 지원하지 않으며, SLAM이나 Navigation을 하려면 Gazebo처럼 센서가 포함된 시뮬레이션 환경이 적합하다고 설명한다.1 이 말은 단순하다. 움직이는 로봇을 보는 것과, 센서 데이터를 내보내는 로봇을 만드는 것은 전혀 다른 단계다.

Gazebo 로봇 모델에 센서를 붙인다는 말은 보통 네 가지 작업을 함께 한다는 뜻이다.

  • URDF 또는 SDF에 센서가 달릴 link와 joint를 만든다.

  • Gazebo가 계산할 센서 모델과 업데이트 주기를 설정한다.

  • Gazebo에서 나온 데이터를 ROS2 토픽으로 내보낸다.

  • 메시지의 frame_id가 실제 TF 트리의 센서 링크와 일치하는지 확인한다.

센서 모양만 로봇 위에 올려놓는 것은 장식이다. 센서 토픽이 발행되고, RViz에서 제 위치에 보이며, 로봇이 움직일 때 데이터가 말이 되게 변해야 비로소 자율주행 로봇의 감각기관이 살아난다.

가상 센서 붙이기

Gazebo 센서의 공통 구조

Gazebo에서 센서는 보통 특정 링크에 붙는다. 카메라가 공중에 따로 떠 있는 것이 아니라 camera_link라는 링크에 붙고, 그 링크는 base_link나 chassis에 고정 조인트로 연결된다. LiDAR도 마찬가지다. laser_frame이라는 링크를 만들고, 로봇 몸체 위에 고정한다. IMU도 imu_link를 만들고, 로봇 중심 근처에 둔다.

Gazebo 센서 설정에는 반복해서 등장하는 항목이 있다. update_rate는 센서가 초당 몇 번 데이터를 만드는지 나타낸다. topic 또는 플러그인의 remapping은 데이터가 어느 토픽으로 나갈지 정한다. visualize는 Gazebo 화면에서 센서 광선이나 시야를 볼 수 있게 해 준다. Gazebo 센서 문서에서도 IMU, LiDAR 같은 센서는 링크에 추가되며, 업데이트 주기와 토픽을 통해 데이터를 생성한다고 설명한다.2

여기서 한 가지를 구분해야 한다. ROS2에서 Gazebo를 쓰는 방법은 크게 두 갈래가 있다. 하나는 Gazebo Classic과 gazebo_ros 플러그인을 사용하는 방식이다. 이 경우 URDF나 xacro 안에 libgazebo_ros_ray_sensor.so, libgazebo_ros_camera.so, libgazebo_ros_imu_sensor.so 같은 플러그인을 넣어 ROS2 토픽을 직접 만든다. 다른 하나는 최신 Gazebo Sim에서 Gazebo 내부 토픽을 만들고 ros_gz_bridge로 ROS2 토픽과 연결하는 방식이다.

이 책에서는 구조를 이해하기 쉽도록 URDF/xacro와 Gazebo Classic 플러그인 스타일을 중심으로 설명한다. 하지만 각 센서의 개념은 Gazebo Sim에서도 같다. 차이는 데이터가 ROS2로 나오는 통로가 플러그인인지, bridge인지에 있다.

LiDAR 링크 만들기

LiDAR는 이동 로봇에서 가장 중요한 센서 중 하나다. 실내 SLAM과 Nav2 실습에서 /scan 토픽은 거의 주인공에 가깝다. 2D LiDAR는 로봇 주변의 한 평면을 빙 둘러 스캔하고, 각 방향에서 가장 가까운 물체까지의 거리를 알려준다. ROS에서는 2D LiDAR 데이터를 보통 sensor_msgs/msg/LaserScan 메시지로 다룬다. LaserScan은 한 번의 스캔을 거리 배열과 각도 정보로 표현하며, 메시지 안의 프레임 정보 덕분에 시스템은 이 거리값이 로봇의 어느 위치에서 측정되었는지 알 수 있다.3

먼저 로봇 모델에 LiDAR가 달릴 링크를 만든다. 아래 예시는 base_link 위쪽에 작은 원통형 LiDAR를 올리는 xacro 조각이다.

<joint name='laser_joint' type='fixed'>
  <parent link='base_link'/>
  <child link='laser_frame'/>
  <origin xyz='0.12 0 0.18' rpy='0 0 0'/>
</joint>

<link name='laser_frame'>
  <visual>
    <geometry>
      <cylinder radius='0.04' length='0.04'/>
    </geometry>
    <material name='red'/>
  </visual>
  <collision>
    <geometry>
      <cylinder radius='0.04' length='0.04'/>
    </geometry>
  </collision>
</link>

origin xyz='0.12 0 0.18'은 로봇 기준으로 LiDAR가 앞쪽 12cm, 가운데, 바닥에서 18cm 높이에 있다는 뜻이다. 이 값은 단순한 장식 좌표가 아니다. 나중에 RViz에서 /scan 점들이 어디에 찍히는지, Costmap이 장애물을 어디에 그리는지에 직접 영향을 준다.

초보자가 자주 하는 실수는 센서 링크를 만들고도 TF 트리에 연결하지 않는 것이다. 링크가 있어도 부모 조인트가 없으면 로봇은 그 센서가 어디 달렸는지 모른다. RViz에서 LaserScan을 추가했는데 아무것도 보이지 않거나, No transform from laser_frame to odom 같은 오류가 나오면 대부분 이 연결이 끊겨 있다.

/scan을 만드는 LiDAR 플러그인

이제 LiDAR 모양에 실제 센서 기능을 넣어 보자. Gazebo Classic에서는 링크를 참조하는 <gazebo> 태그 안에 ray 센서를 넣고, ROS2로 내보내는 플러그인을 추가한다. 여러 예제에서 2D LiDAR는 ray 센서와 libgazebo_ros_ray_sensor.so 플러그인을 사용해 /scan 토픽으로 LaserScan을 발행한다.4

<gazebo reference='laser_frame'>
  <sensor name='laser' type='ray'>
    <pose>0 0 0 0 0 0</pose>
    <visualize>true</visualize>
    <update_rate>10</update_rate>

    <ray>
      <scan>
        <horizontal>
          <samples>360</samples>
          <resolution>1</resolution>
          <min_angle>-3.14159</min_angle>
          <max_angle>3.14159</max_angle>
        </horizontal>
      </scan>
      <range>
        <min>0.12</min>
        <max>12.0</max>
        <resolution>0.01</resolution>
      </range>
      <noise>
        <type>gaussian</type>
        <mean>0.0</mean>
        <stddev>0.01</stddev>
      </noise>
    </ray>

    <plugin name='laser_controller' filename='libgazebo_ros_ray_sensor.so'>
      <ros>
        <remapping>~/out:=scan</remapping>
      </ros>
      <output_type>sensor_msgs/LaserScan</output_type>
      <frame_name>laser_frame</frame_name>
    </plugin>
  </sensor>
</gazebo>

여기서 samples는 한 번 회전할 때 몇 개의 광선을 쏠지 정한다. 360이면 대략 1도 간격으로 거리를 측정한다. min_angle과 max_angle은 스캔 범위다. -3.14159부터 3.14159까지면 거의 360도 전체를 돈다. range의 min과 max는 센서가 믿을 수 있는 최소 거리와 최대 거리다. 너무 가까운 물체는 센서 바로 앞의 사각지대에 들어가고, 너무 먼 물체는 측정 범위를 벗어난다.

실행 후 가장 먼저 확인할 것은 토픽이다.

ros2 topic list | grep scan
ros2 topic info /scan
ros2 topic hz /scan
ros2 topic echo /scan --once

ros2 topic hz /scan이 약 10Hz를 보여 준다면 update_rate 설정과 비슷하게 데이터가 나오고 있다는 뜻이다. 빈 월드에서는 ranges 값이 대부분 inf로 보일 수 있다. 이것은 오류가 아니라, 해당 방향의 최대 거리 안에 부딪힌 물체가 없다는 뜻이다. Gazebo 월드에 벽이나 박스를 놓고 로봇을 가까이 움직이면 일부 값이 실제 거리로 바뀐다.

LaserScan 메시지 읽기

/scan을 눈으로만 보면 RViz의 점 구름처럼 보인다. 하지만 SLAM과 장애물 회피는 이 메시지의 숫자를 읽는다. 그러므로 LaserScan의 핵심 필드는 반드시 이해해야 한다.

angle_min은 첫 번째 거리값이 바라보는 각도다. angle_max는 마지막 거리값의 각도다. angle_increment는 인접한 두 광선 사이의 각도 차이다. 예를 들어 angle_min=-3.14159, angle_max=3.14159, ranges 길이가 360이면 한 칸은 대략 0.01745라디안, 즉 1도다.

range_min보다 가까운 값은 신뢰하기 어렵다. range_max보다 먼 값은 보통 무한대처럼 표현된다. ranges는 실제 거리 배열이다. ranges[0]은 angle_min 방향의 거리, ranges[10]은 angle_min + 10 × angle_increment 방향의 거리다. 그러므로 특정 인덱스가 로봇의 어느 방향을 의미하는지 계산하려면 아래처럼 생각하면 된다.

beam_angle = angle_min + index × angle_increment
beam_range = ranges[index]

ROS의 기본 로봇 좌표계에서는 보통 x축이 앞, y축이 왼쪽, z축이 위다. LiDAR 프레임도 이 방향을 따른다고 가정하면 angle 0은 센서의 정면이다. 만약 RViz에서 장애물이 앞에 있는데 점들이 옆이나 뒤에 찍힌다면, LaserScan 메시지가 틀렸다기보다 laser_frame의 방향이 잘못되었을 가능성이 크다.

RViz에서 확인할 때는 Global Options의 Fixed Frame을 odom이나 base_link로 설정하고, Add에서 LaserScan을 추가한 뒤 Topic을 /scan으로 맞춘다. ROS2와 Gazebo 사이에 bridge를 쓰는 환경에서는 Gazebo 내부 LiDAR 토픽을 sensor_msgs/msg/LaserScan으로 연결한 뒤 RViz에서 시각화한다. ROS2 Gazebo 튜토리얼도 bridge를 통해 LiDAR 데이터를 ROS2로 보내고 RViz에서 보는 흐름을 소개한다.5

LaserScan이 보이지 않을 때는 QoS도 확인하자. 일부 센서 토픽은 Best Effort QoS로 발행된다. RViz의 LaserScan 표시에서 Reliability Policy를 Best Effort로 바꾸면 갑자기 점들이 나타나는 경우가 있다.

RGB 카메라 붙이기

LiDAR가 거리의 윤곽을 본다면, 카메라는 색과 형태를 본다. 사람에게는 카메라가 가장 직관적인 센서지만, 로봇에게는 꽤 까다로운 센서다. 이미지에는 많은 정보가 담겨 있지만, 그 정보가 거리인지, 물체 종류인지, 특징점인지 해석하려면 별도의 알고리즘이 필요하다.

먼저 카메라 링크를 만든다. 카메라에는 보통 두 개의 프레임을 둔다. 하나는 물리적인 몸체를 나타내는 camera_link, 다른 하나는 이미지 좌표계에 맞춘 camera_link_optical이다. ROS 카메라 생태계에서는 optical frame을 쓰는 경우가 많다. 이 프레임에서는 z축이 카메라가 바라보는 방향, x축이 이미지의 오른쪽, y축이 이미지의 아래쪽을 향한다고 생각하면 된다.

<joint name='camera_joint' type='fixed'>
  <parent link='base_link'/>
  <child link='camera_link'/>
  <origin xyz='0.18 0 0.12' rpy='0 0 0'/>
</joint>

<link name='camera_link'>
  <visual>
    <geometry>
      <box size='0.03 0.06 0.03'/>
    </geometry>
    <material name='black'/>
  </visual>
</link>

<joint name='camera_optical_joint' type='fixed'>
  <parent link='camera_link'/>
  <child link='camera_link_optical'/>
  <origin xyz='0 0 0' rpy='-1.5708 0 -1.5708'/>
</joint>

<link name='camera_link_optical'/>

카메라 센서는 아래처럼 추가할 수 있다. 배포판과 플러그인 버전에 따라 토픽 이름이 /image_raw로 바로 나오기도 하고, namespace가 붙어 /camera/image_raw처럼 나오기도 한다. 중요한 것은 이미지 토픽과 카메라 보정 정보인 camera_info가 함께 나오는지 확인하는 것이다. Gazebo 카메라 예제들도 카메라 링크, optical frame, 해상도, 수평 시야각, 카메라 플러그인을 함께 설정하는 방식을 사용한다.6

<gazebo reference='camera_link'>
  <sensor name='rgb_camera' type='camera'>
    <pose>0 0 0 0 0 0</pose>
    <visualize>true</visualize>
    <update_rate>15</update_rate>

    <camera>
      <horizontal_fov>1.089</horizontal_fov>
      <image>
        <width>640</width>
        <height>480</height>
        <format>B8G8R8</format>
      </image>
      <clip>
        <near>0.05</near>
        <far>8.0</far>
      </clip>
    </camera>

    <plugin name='camera_controller' filename='libgazebo_ros_camera.so'>
      <frame_name>camera_link_optical</frame_name>
    </plugin>
  </sensor>
</gazebo>

실행 후에는 토픽을 확인한다.

ros2 topic list | grep camera
ros2 topic info /camera/image_raw
ros2 topic info /camera/camera_info
ros2 topic hz /camera/image_raw

이미지를 직접 보고 싶다면 rqt_image_view나 RViz의 Image 표시를 사용할 수 있다.

ros2 run rqt_image_view rqt_image_view

해상도는 이미지 한 장의 픽셀 수를 정한다. 640 × 480은 가로 640픽셀, 세로 480픽셀이다. 해상도가 높을수록 더 세밀하게 보이지만, 처리해야 할 데이터도 커진다. 시야각은 카메라가 얼마나 넓게 보는지를 정한다. 같은 해상도라도 시야각이 넓으면 한 픽셀이 담당하는 실제 공간이 넓어지고, 시야각이 좁으면 멀리 있는 물체가 더 크게 보인다.

camera_info는 카메라의 내부 파라미터를 담는다. 여기에는 초점거리, 주점, 왜곡 계수 같은 정보가 들어간다. 지금 당장은 숫자가 낯설어도 괜찮다. 나중에 Visual SLAM이나 영상 기반 물체 인식을 다룰 때, 이미지 하나만으로는 부족하고 camera_info가 함께 필요하다는 점만 기억하자.

Depth camera로 거리 이미지 보기

RGB 카메라는 색을 알려 주지만, 한 픽셀이 몇 미터 앞의 물체인지 바로 알려 주지는 않는다. Depth camera는 각 픽셀마다 거리값을 제공한다. 그래서 결과가 일반 사진처럼 보이기보다, 가까운 곳과 먼 곳이 값으로 표현된 깊이 이미지가 된다.

Depth camera를 쓰면 보통 다음과 같은 토픽이 생긴다.

  • /camera/image_raw 또는 /rgb/image_raw: RGB 이미지

  • /camera/camera_info 또는 /rgb/camera_info: RGB 카메라 보정 정보

  • /camera/depth/image_raw: 깊이 이미지

  • /camera/depth/camera_info: 깊이 카메라 보정 정보

Gazebo Sim을 쓰는 경우에는 카메라와 depth camera의 Gazebo 내부 토픽을 ROS2 토픽으로 bridge해야 한다. ros_gz_bridge는 ROS2와 Gazebo Transport 사이에서 메시지를 주고받는 역할을 하며, 토픽 이름과 ROS 메시지 타입, Gazebo 메시지 타입을 지정해 연결한다.7

예를 들어 Gazebo 내부 /camera/image를 ROS2의 /camera/image_raw로 가져오고 싶다면 launch나 YAML 설정에서 Image 타입 bridge를 만든다. 실제 타입 이름은 사용하는 Gazebo와 ROS 배포판에 따라 gz.msgs.Image 또는 이전 이름인 ignition.msgs.Image처럼 달라질 수 있다. 그러므로 예제를 그대로 외우기보다, 현재 환경의 문서를 보고 토픽과 메시지 타입을 맞추는 습관이 중요하다.

Depth image를 해석할 때 가장 흔한 착각은 색이 밝다고 반드시 먼 곳, 어둡다고 반드시 가까운 곳이라고 단정하는 것이다. RViz나 image viewer가 보기 좋게 색을 입혀 보여 줄 수 있지만, 실제 메시지의 핵심은 각 픽셀에 들어 있는 거리값이다. 단위는 보통 미터이며, 측정 불가능한 곳은 0, NaN, inf 등으로 표현될 수 있다.

해상도와 시야각은 depth camera에서도 중요하다. 시야각이 넓으면 한 번에 넓은 공간을 보지만, 멀리 있는 물체의 경계가 거칠게 보일 수 있다. 해상도를 높이면 점이 촘촘해지지만, SLAM이나 장애물 인식 노드가 처리해야 할 데이터량이 늘어난다. 이동 로봇에서는 화질이 좋다고 무조건 좋은 것이 아니다. 로봇의 CPU, GPU, 네트워크, 알고리즘 주기를 함께 생각해야 한다.

IMU 붙이기

IMU는 Inertial Measurement Unit의 약자로, 관성 측정 장치라고 부른다. 이름은 어렵지만 역할은 비교적 직관적이다. 로봇이 어느 방향을 향하는지, 얼마나 빠르게 회전하는지, 어떤 방향으로 가속되는지 알려 준다. 실제 IMU는 자이로스코프와 가속도계, 때로는 자기장 센서를 조합해 이런 값을 만든다.

Gazebo의 IMU 센서는 orientation, angular_velocity, linear_acceleration을 출력한다. Gazebo 센서 문서도 IMU가 쿼터니언 형태의 방향, 세 축 각속도, 세 축 선형 가속도를 제공한다고 설명한다.2 Gazebo Classic의 GazeboRosImuSensor 플러그인은 Gazebo IMU 센서에 붙어 ROS 메시지를 발행하는 센서 플러그인이다.8

먼저 IMU 링크를 만든다. 보통 로봇 중심에 가깝게 둔다.

<joint name='imu_joint' type='fixed'>
  <parent link='base_link'/>
  <child link='imu_link'/>
  <origin xyz='0 0 0.08' rpy='0 0 0'/>
</joint>

<link name='imu_link'>
  <visual>
    <geometry>
      <box size='0.03 0.03 0.01'/>
    </geometry>
    <material name='blue'/>
  </visual>
</link>

그리고 Gazebo 센서를 추가한다.

<gazebo reference='imu_link'>
  <sensor name='imu_sensor' type='imu'>
    <always_on>true</always_on>
    <update_rate>50</update_rate>
    <visualize>true</visualize>

    <plugin name='imu_plugin' filename='libgazebo_ros_imu_sensor.so'>
      <ros>
        <remapping>~/out:=imu</remapping>
      </ros>
      <frame_name>imu_link</frame_name>
    </plugin>
  </sensor>
</gazebo>

실행 후 /imu를 확인한다.

ros2 topic info /imu
ros2 topic hz /imu
ros2 topic echo /imu --once

orientation은 로봇의 자세를 쿼터니언으로 표현한다. 쿼터니언은 회전을 표현하는 네 개의 숫자다. 처음 보면 x, y, z, w가 왜 필요한지 당황스럽지만, 지금은 3차원 회전을 안정적으로 표현하기 위한 형식이라고 이해하면 충분하다. RViz에서는 IMU 표시를 추가해 방향 변화를 시각적으로 볼 수 있다.

angular_velocity는 각속도다. 로봇을 제자리에서 왼쪽으로 돌리면 보통 z축 각속도가 변한다. 값의 단위는 rad/s다. linear_acceleration은 선형 가속도다. 로봇이 앞으로 출발하거나 멈출 때 x축 가속도가 순간적으로 변한다.

IMU를 볼 때 중요한 함정은 중력이다. 정지한 로봇에서도 가속도 값이 완전히 0이 아닐 수 있다. 센서 모델과 설정에 따라 z축에 약 9.8m/s²에 가까운 중력 성분이 보이기도 한다. 그래서 IMU 데이터를 로봇 위치 추정에 쓰는 패키지는 중력 제거, 센서 방향, 공분산 설정 같은 문제를 함께 다룬다. 우리는 나중에 오도메트리와 센서 융합을 공부할 때 이 문제를 다시 만날 것이다.

바퀴 엔코더와 /joint_states

이동 로봇이 자기 위치를 추정하는 가장 기본적인 방법은 바퀴가 얼마나 돌았는지 보는 것이다. 실제 로봇에서는 모터에 엔코더가 달려 바퀴 회전량을 측정한다. Gazebo에서는 바퀴 조인트의 위치와 속도를 통해 비슷한 정보를 얻을 수 있다.

ROS2에서 관절 상태는 보통 /joint_states 토픽으로 나온다. 메시지 타입은 sensor_msgs/msg/JointState다.

ros2 topic info /joint_states
ros2 topic echo /joint_states --once

name에는 조인트 이름이 들어간다. 예를 들어 left_wheel_joint, right_wheel_joint가 보일 수 있다. position은 조인트 위치다. 바퀴처럼 계속 도는 continuous joint라면 회전 각도로 이해할 수 있다. velocity는 회전 속도다. effort는 힘이나 토크에 해당하지만, 시뮬레이션 설정에 따라 비어 있거나 큰 의미가 없을 수도 있다.

엔코더 기반 이동 추정은 간단한 아이디어에서 출발한다. 왼쪽 바퀴와 오른쪽 바퀴가 같은 거리만큼 앞으로 굴러가면 로봇은 직진한다. 오른쪽 바퀴가 왼쪽 바퀴보다 더 많이 굴러가면 로봇은 왼쪽으로 돈다. 차동구동 로봇에서는 바퀴 반지름과 바퀴 사이 거리만 알면 바퀴 회전량을 로봇의 이동량과 회전량으로 바꿀 수 있다.

왼쪽 이동거리  = 바퀴반지름 × 왼쪽 바퀴 회전량
오른쪽 이동거리 = 바퀴반지름 × 오른쪽 바퀴 회전량
로봇 전진거리  = 두 바퀴 이동거리의 평균
로봇 회전량    = 두 바퀴 이동거리 차이 ÷ 바퀴 사이 거리

물론 현실에서는 바퀴가 미끄러지고, 바닥이 고르지 않고, 바퀴 지름도 완벽하지 않다. 그래서 엔코더 오도메트리는 시간이 지날수록 조금씩 틀어진다. 시뮬레이션에서도 마찰, 충돌, 물리 엔진 설정에 따라 오차가 생길 수 있다. 이 누적 오차가 바로 다음 부에서 다룰 오도메트리와 SLAM의 중요한 배경이 된다.

/odom은 어떻게 만들어지는가

/joint_states가 바퀴 조인트의 원시 감각에 가깝다면, /odom은 그 감각을 로봇의 위치와 자세로 변환한 결과다. Gazebo의 차동구동 플러그인이나 ros2_control 기반 구동 설정은 바퀴 회전과 속도 명령을 바탕으로 오도메트리를 계산하고 /odom 토픽을 발행한다.

Gazebo Classic의 차동구동 플러그인은 보통 다음과 비슷한 설정을 가진다.

<plugin name='diff_drive' filename='libgazebo_ros_diff_drive.so'>
  <left_joint>left_wheel_joint</left_joint>
  <right_joint>right_wheel_joint</right_joint>
  <wheel_separation>0.16</wheel_separation>
  <wheel_diameter>0.066</wheel_diameter>
  <command_topic>cmd_vel</command_topic>
  <odometry_topic>odom</odometry_topic>
  <odometry_frame>odom</odometry_frame>
  <robot_base_frame>base_link</robot_base_frame>
  <publish_odom>true</publish_odom>
  <publish_odom_tf>true</publish_odom_tf>
</plugin>

wheel_separation은 왼쪽 바퀴와 오른쪽 바퀴 사이 거리다. wheel_diameter는 바퀴 지름이다. 이 값이 실제 모델과 다르면 /odom은 처음부터 틀어진다. 예를 들어 바퀴 지름을 실제보다 크게 설정하면, 로봇은 조금 움직였는데 오도메트리는 많이 움직였다고 계산할 수 있다.

/odom 메시지는 보통 nav_msgs/msg/Odometry 타입이다.

ros2 topic info /odom
ros2 topic echo /odom --once

여기에는 pose와 twist가 들어 있다. pose는 odom 좌표계에서 로봇이 어디에 있고 어느 방향을 보는지 나타낸다. twist는 선속도와 각속도를 나타낸다. header.frame_id는 보통 odom, child_frame_id는 보통 base_link다. 이 관계가 깨지면 RViz에서 로봇 모델, LiDAR 점, 오도메트리 화살표가 서로 따로 논다.

오도메트리는 지도 좌표계의 절대 위치가 아니다. 로봇이 처음 출발한 뒤 바퀴 감각으로 누적한 상대 위치에 가깝다. 그래서 /odom은 부드럽지만 시간이 지날수록 드리프트가 생길 수 있다. 나중에 SLAM이나 AMCL이 들어오면 map → odom 변환을 통해 이 드리프트를 보정하는 구조를 만들게 된다.

센서 frame_id와 TF 맞추기

센서 디버깅에서 가장 무서운 버그는 프로그램이 죽지 않는 버그다. 토픽도 나오고, RViz에도 뭔가 보이고, 명령도 잘 들어간다. 그런데 로봇은 계속 이상하게 행동한다. 이런 경우 상당수는 센서의 위치, 방향, 프레임 이름이 실제 모델과 맞지 않아서 생긴다.

LaserScan 메시지의 header.frame_id가 laser_frame이라면 TF 트리 안에도 laser_frame이 있어야 한다. 그리고 odom → base_link → laser_frame으로 이어지는 변환을 RViz가 찾을 수 있어야 한다. 카메라 이미지의 frame_id가 camera_link_optical이라면, 그 프레임도 TF 트리에 있어야 한다. IMU의 frame_id가 imu_link라면, IMU가 로봇 중심에서 어느 방향으로 장착되었는지 TF가 설명해야 한다.

확인은 명령어로 시작한다.

ros2 run tf2_ros tf2_echo base_link laser_frame
ros2 run tf2_ros tf2_echo base_link camera_link_optical
ros2 run tf2_ros tf2_echo base_link imu_link

TF 트리 전체를 보고 싶다면 다음 명령을 사용할 수 있다.

ros2 run tf2_tools view_frames

RViz에서는 TF 표시를 추가해 각 프레임이 어디에 있는지 볼 수 있다. laser_frame이 로봇 위에 있어야 하는데 바닥 아래에 있거나, camera_link_optical의 축이 엉뚱한 방향을 보고 있다면 URDF의 origin xyz 또는 rpy를 고쳐야 한다.

문제 상황은 대체로 비슷한 모습으로 나타난다.

  • LaserScan 점들이 벽이 아니라 로봇 옆이나 뒤에 찍히면 LiDAR 프레임의 yaw 방향을 확인한다.

  • LaserScan이 바닥이나 천장을 긁는 것처럼 보이면 LiDAR 높이와 roll, pitch를 확인한다.

  • 카메라 영상이 옆으로 누웠거나 뒤집혀 보이면 optical frame 회전을 확인한다.

  • /odom에서 로봇이 뒤로 움직인다고 나오면 바퀴 조인트 축과 좌우 바퀴 부호를 확인한다.

  • IMU의 회전 방향이 반대로 보이면 imu_link의 장착 방향과 frame_id를 확인한다.

중요한 원칙은 하나다. 센서 메시지의 숫자는 반드시 그 메시지의 frame_id 기준으로 해석된다. frame_id가 틀리면 숫자가 맞아도 의미가 틀린다.

센서 토픽 점검 순서

여러 센서를 한꺼번에 붙이면 어디서 문제가 생겼는지 헷갈리기 쉽다. 이럴 때는 센서를 하나씩 살리는 방식이 좋다. 먼저 로봇 모델과 TF가 정상인지 본다. 그다음 LiDAR, 카메라, IMU, 오도메트리 순서로 확인한다.

기본 점검 명령은 다음과 같다.

ros2 topic list
ros2 topic list | grep -E 'scan|image|camera|imu|odom|joint'
ros2 topic hz /scan
ros2 topic hz /camera/image_raw
ros2 topic hz /imu
ros2 topic hz /odom

각 토픽은 존재 여부, 메시지 타입, 발행 주기를 함께 봐야 한다. 토픽 이름만 있다고 안심하면 안 된다. /scan이 있어도 0Hz라면 데이터가 멈춘 것이다. /camera/image_raw가 있어도 RViz에서 보이지 않는다면 QoS, frame, image transport, 플러그인 namespace를 봐야 한다. /odom이 있어도 로봇이 제자리에서 빙빙 돌 때 자세가 반대로 변한다면 바퀴 설정이 틀렸을 수 있다.

RViz에서 한 번에 확인할 때는 다음 표시를 추가하면 좋다.

  • RobotModel: URDF 모델이 실제로 어떤 모양과 위치로 보이는지 확인한다.

  • TF: base_link, laser_frame, camera_link_optical, imu_link의 연결을 확인한다.

  • LaserScan: /scan 점들이 장애물 위치와 맞는지 확인한다.

  • Image: RGB 카메라가 앞을 보고 있는지 확인한다.

  • Odometry: /odom의 위치와 방향 변화가 로봇 움직임과 맞는지 확인한다.

이 점검 순서는 앞으로 계속 반복된다. SLAM이 안 될 때도, Nav2가 안 될 때도, rosbag을 재생할 때도 먼저 센서 토픽과 TF를 본다. 로봇 디버깅은 화려한 추측이 아니라, 살아 있는 데이터의 흐름을 차례대로 따라가는 일이다.

작은 실험: 장애물 하나로 센서 검증하기

센서를 붙였으면 넓은 월드에서 바로 SLAM을 시작하고 싶어진다. 하지만 처음에는 작은 장애물 하나가 더 좋은 선생님이다. Gazebo 월드에 박스 하나를 로봇 정면 1m 앞에 둔다. 로봇을 움직이지 말고 /scan을 본다. 정면 방향의 range 값이 약 1m 근처로 나오는지 확인한다. RViz에서 LaserScan 점이 박스 앞면에 찍히는지 본다.

그다음 로봇을 천천히 왼쪽으로 회전시킨다. 박스는 RViz의 고정 좌표계에서 거의 같은 위치에 남아 있어야 한다. 만약 로봇이 돌 때 LaserScan 점들이 로봇에 붙어서 함께 이상하게 회전한다면 Fixed Frame, TF, odom 발행을 확인해야 한다.

카메라도 같은 방식으로 검증한다. 박스를 정면에 두고 /camera/image_raw를 본다. 박스가 화면 중앙에 있어야 한다. 로봇을 조금 왼쪽으로 돌리면 박스는 화면 오른쪽으로 이동해야 한다. 반대로 움직이면 카메라 방향이나 optical frame이 잘못되었을 가능성이 있다.

IMU는 제자리 회전으로 확인한다. /cmd_vel에 각속도만 주고 로봇을 돌린다. /imu의 angular_velocity.z가 변해야 한다. 직진 출발과 정지를 반복하면 linear_acceleration.x가 순간적으로 흔들린다. /joint_states에서는 왼쪽과 오른쪽 바퀴의 회전값이 변하고, /odom에서는 로봇의 위치나 자세가 함께 바뀐다.

이 작은 실험을 통과한 로봇은 이제 데이터를 믿을 수 있는 상태에 가까워진다. 완벽하다는 뜻은 아니다. 적어도 센서 토픽이 나오고, 프레임이 연결되어 있고, 움직임에 따라 값이 상식적으로 변한다는 뜻이다. 자율주행 시스템에서는 이것이 아주 큰 차이를 만든다.

다음 장으로 가기 전에

이번 장에서 우리는 Gazebo 로봇 모델에 LiDAR, RGB 카메라, depth camera, IMU, 엔코더에 해당하는 가상 센서를 붙였다. 그리고 단순히 센서 플러그인을 추가하는 데서 멈추지 않고, /scan, /image_raw, /camera_info, /depth/image_raw, /imu, /joint_states, /odom이 어떤 의미를 가지는지 살펴보았다.

이제 로봇은 움직이는 장난감이 아니라 데이터를 내보내는 시스템이 되었다. LiDAR는 벽과 장애물의 거리를 말하고, 카메라는 앞에 무엇이 보이는지 알려 주며, depth camera는 픽셀마다 거리를 제공한다. IMU는 회전과 가속을 감지하고, 바퀴 엔코더와 오도메트리는 로봇이 얼마나 이동했다고 추정하는지 알려 준다.

하지만 센서 데이터는 흘러가 버린다. 방금 전 로봇이 벽 앞에서 어떤 /scan을 냈는지, 회전 중 /imu 값이 어떻게 흔들렸는지, SLAM이 실패한 순간 /odom이 튀었는지 나중에 다시 보고 싶다면 데이터를 기록해야 한다. 다음 장에서는 rosbag을 사용해 센서 데이터를 저장하고, 다시 재생하며, 같은 실험을 반복 가능한 형태로 만드는 방법을 배운다.

참고

1TurtleBot3 Simulation

2Gazebo sensors documentation

3Adding Lidar

4Set Up LIDAR for a Simulated Mobile Robot in ROS 2

5Setting up a robot simulation Gazebo

6Gazebo를 이용해 Camera 시뮬레이션 하기

7Use ROS 2 to interact with Gazebo

8GazeboRosImuSensor documentation

책 목록

ROS2로 완성하는 자율주행 로봇: 노드·TF·SLAM·Nav2 실전 설계

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기