Skip to main content
Summary

로봇이 복도 끝에서 멈춰 선다. 사람 눈에는 충분히 지나갈 수 있는 문처럼 보인다. "왜 안 가지?" 하고 RViz를 들여다보면 벽 주변에 분홍색, 보라색, 파란색 띠가 부풀어 올라 있다. 로봇은 문을 보는 것이 아니라, 문 주변의 비용 지도, 즉 costmap을 보고 있다.

이 장의 핵심은 단순하다. Nav2가 길을 찾지 못하거나, 벽에서 너무 멀리 떨어져 가거나, 반대로 벽에 바짝 붙어 가는 이유는 대부분 costmap을 보면 설명된다. Planner는 costmap을 보고 "어디로 갈까?"를 결정하고, Controller는 costmap을 보고 "지금 이 속도로 움직여도 안전할까?"를 판단한다. 그래서 costmap은 Nav2의 눈이자, 겁 많은 안전 담당자이자, 때로는 로봇이 고집을 부리는 이유 그 자체다.

Costmap으로 장애물 표현하기

Costmap은 로봇이 보는 위험 지도다

costmap은 환경을 2차원 격자로 나누고, 각 칸에 비용을 부여한 지도다. 비용이 낮은 칸은 지나가기 좋은 공간이고, 비용이 높은 칸은 피하고 싶은 공간이다. 벽이나 장애물처럼 충돌이 확실한 곳은 치명적인 장애물로 취급된다. 아직 모르는 공간은 설정에 따라 지나갈 수 없는 미지 영역이 되기도 하고, 일단 비어 있다고 가정되는 공간이 되기도 한다.

이때 중요한 점은 costmap이 단순한 흑백 지도가 아니라는 것이다. SLAM으로 만든 지도는 보통 "여기는 벽, 여기는 빈 공간, 여기는 모름"에 가깝다. 하지만 로봇이 실제로 주행하려면 이보다 더 많은 판단이 필요하다. 벽 바로 옆은 지도상으로는 빈 공간일 수 있지만, 로봇 몸체가 조금만 흔들려도 부딪힐 수 있다. 복도 중앙은 벽 양쪽에서 멀기 때문에 더 안전하다. 사람이나 의자처럼 지도에는 없지만 방금 센서에 잡힌 장애물도 반영해야 한다.

Nav2의 Costmap 2D는 이런 환경 표현을 위해 2D 격자 기반 costmap을 만들고, 센서 처리 플러그인들을 조합해 Planner와 Controller가 충돌 검사와 경로 선택에 사용할 수 있도록 한다.1

한 칸짜리 지도처럼 생각하면 costmap은 어렵지 않다.

낮은 비용     : 지나가기 좋음
중간 비용     : 지나갈 수는 있지만 조심해야 함
높은 비용     : 가능하면 피해야 함
치명적 장애물 : 충돌하므로 지나가면 안 됨
미지 영역     : 설정에 따라 금지 또는 허용

로봇은 이 숫자의 바다 위에서 길을 찾는다. 사람은 복도를 "넓다" 또는 "좁다"라고 보지만, Nav2는 "왼쪽 벽에서 0.4m 떨어진 칸의 비용은 얼마인가?"처럼 본다. 그래서 좁은 문을 통과하지 못하는 문제는 대개 문이 좁아서가 아니라, costmap 위에서 그 문이 이미 높은 비용으로 막혀 있기 때문이다.

여러 레이어가 겹쳐 하나의 지도가 된다

Nav2 costmap은 하나의 그림처럼 보이지만, 내부적으로는 여러 레이어가 겹쳐진 결과다. 투명 필름을 여러 장 겹쳐 하나의 지도를 만드는 장면을 떠올리면 된다. 첫 번째 필름에는 저장된 벽 지도가 있고, 두 번째 필름에는 LiDAR가 방금 본 장애물이 있고, 세 번째 필름에는 깊이 카메라가 본 입체 장애물이 있고, 마지막 필름에는 장애물 주변의 안전 여유가 칠해져 있다.

Nav2 costmap 설정의 plugins 항목은 어떤 레이어를 어떤 이름으로 올릴지 정한다. 공식 예제에서도 static_layer, obstacle_layer, voxel_layer, inflation_layer처럼 여러 플러그인을 costmap에 등록하는 구조를 보여 준다.1

대표적인 레이어의 역할은 다음과 같다.

  • static_layer는 저장된 지도나 SLAM에서 온 정적 지도를 costmap에 반영한다.

  • obstacle_layer는 LiDAR의 LaserScan 또는 2D로 처리 가능한 센서 데이터를 이용해 현재 보이는 장애물을 표시하고 제거한다.

  • voxel_layer는 PointCloud2 같은 3D 센서 데이터를 높이 방향 격자, 즉 voxel 구조로 다룬 뒤 2D costmap에 반영한다.

  • inflation_layer는 장애물 주변으로 비용을 부풀려 로봇이 안전거리를 두도록 만든다.

여기서 static_layer는 건물의 벽과 기둥처럼 원래 지도에 있던 구조물을 담당한다. 어제 만든 실내 지도에서 벽으로 저장된 부분은 오늘도 벽으로 남아 있어야 한다. 로봇이 출발하자마자 전체 건물 구조를 다시 센서로 확인할 수는 없으므로, 저장된 지도는 전역 경로 계획의 뼈대가 된다.

obstacle_layer는 현재 센서가 보고 있는 현실을 반영한다. 지도에는 없던 의자가 복도 한가운데 놓였다고 하자. SLAM 지도만 보면 그곳은 지나갈 수 있는 공간이다. 하지만 LiDAR가 의자를 감지하면 obstacle layer가 해당 위치를 장애물로 표시한다. 반대로 아까 있던 사람이 걸어 나가면, 센서 빔이 그 공간을 다시 관측하면서 장애물을 제거할 수 있다.

voxel_layer는 3차원 정보를 다룰 때 빛난다. 2D LiDAR는 보통 한 높이의 단면만 본다. 하지만 RGB-D 카메라나 3D LiDAR는 높이 정보가 있는 점군을 만든다. 낮은 문턱, 책상 다리, 사람의 다리, 튀어나온 선반처럼 높이까지 고려해야 하는 환경에서는 voxel layer가 유용하다. 다만 모든 로봇에 무조건 필요한 것은 아니다. TurtleBot처럼 2D LiDAR 중심의 실습에서는 obstacle layer만으로도 많은 상황을 이해할 수 있다.

마지막으로 inflation_layer는 costmap을 "로봇다운 지도"로 바꾼다. 벽 자체만 장애물로 표시하면 Planner는 벽 바로 옆 칸도 자유 공간으로 볼 수 있다. 하지만 실제 로봇은 크기가 있고, 제어 오차도 있고, 바퀴가 미끄러질 수도 있다. 그래서 장애물 주변을 안전거리만큼 부풀려 비용을 높인다. 이 부풀림 덕분에 로봇은 벽에 딱 붙은 경로보다 복도 중앙을 선호한다.

Global costmap과 Local costmap은 보는 시간이 다르다

Nav2에는 보통 두 개의 costmap이 있다. global_costmap과 local_costmap이다. 이름은 비슷하지만 역할은 꽤 다르다.

global_costmap은 큰 지도를 본다. 저장된 지도와 정적 장애물, 필요하다면 동적 장애물 정보를 바탕으로 목표 지점까지의 전역 경로를 계산하는 데 쓰인다. Planner Server는 이 지도를 보고 "출발지에서 목표까지 어느 복도를 지나갈 것인가?"를 정한다. 이때 좌표계는 대개 map이다. 지난 장에서 본 map → odom → base_link 구조를 떠올리면, global costmap은 건물 전체 기준의 판단에 가깝다.

local_costmap은 로봇 주변을 본다. 로봇 바로 앞에 갑자기 사람이 나타났는지, 의자가 있는지, 벽과 얼마나 가까운지를 빠르게 반영한다. Controller Server는 이 지도를 보고 "지금 선택할 속도 명령이 충돌을 일으키지 않는가?"를 평가한다. local costmap은 보통 odom 좌표계를 기준으로 하고, rolling_window: true를 사용해 로봇을 따라다니는 작은 창처럼 움직인다.

비유하자면 global costmap은 내비게이션 앱의 전체 경로이고, local costmap은 운전자가 앞 유리창으로 보는 현재 도로다. 서울에서 부산까지 가는 길은 전체 지도가 정하지만, 바로 앞에 끼어든 차를 피하는 판단은 전방 시야가 한다.

global_costmap:
  global_costmap:
    ros__parameters:
      global_frame: map
      robot_base_frame: base_link
      update_frequency: 1.0
      publish_frequency: 1.0
      resolution: 0.05
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]

local_costmap:
  local_costmap:
    ros__parameters:
      global_frame: odom
      robot_base_frame: base_link
      rolling_window: true
      width: 3
      height: 3
      update_frequency: 5.0
      publish_frequency: 2.0
      resolution: 0.05
      plugins: ["obstacle_layer", "inflation_layer"]

위 설정은 전형적인 출발점이다. global costmap은 map 좌표계에서 넓은 환경을 보고, local costmap은 odom 좌표계에서 로봇 주변 3m × 3m 영역을 본다. 실제 프로젝트에서는 로봇 속도, 센서 거리, 컴퓨터 성능에 따라 width, height, update_frequency, resolution을 조정한다.

local costmap이 너무 작으면 로봇이 장애물을 늦게 본다. 너무 크면 계산량이 늘어난다. update frequency가 낮으면 반응이 둔해지고, 너무 높으면 CPU를 과하게 쓸 수 있다. costmap은 언제나 안전과 계산량 사이의 타협이다.

Static layer: 저장된 지도는 기본 바닥이다

SLAM으로 만든 지도는 Nav2에서 그냥 배경 그림이 아니다. static_layer를 통해 costmap의 기본 바닥이 된다. 벽, 방, 복도, 문틀처럼 환경의 큰 구조는 이 레이어에서 온다.

예를 들어 25장에서 저장한 map.yaml과 map.pgm을 map server가 불러오면, global costmap의 static layer는 이 /map 정보를 구독해 벽과 빈 공간을 반영한다. 이후 Planner는 static layer 위에서 목표까지 이어지는 길을 찾는다.

static layer가 빠져 있으면 어떤 일이 생길까? 로봇은 저장된 지도를 제대로 보지 못한다. LiDAR가 현재 보이는 벽 일부는 obstacle layer로 들어올 수 있지만, 먼 복도 구조나 아직 센서 범위 밖의 방 배치는 알 수 없다. 그 결과 전역 경로가 이상해지거나, 목표까지 길을 찾지 못할 수 있다.

반대로 static layer만 있고 obstacle layer가 없다면 지도에 없는 새 장애물을 무시한다. 어제는 비어 있던 복도에 오늘 상자가 놓여 있어도, 로봇은 저장된 지도만 믿고 그쪽으로 가려 한다. 그래서 실제 내비게이션에서는 정적 지도와 실시간 센서 레이어를 함께 쓰는 경우가 많다.

Obstacle layer: LiDAR가 본 장애물을 찍고 지운다

obstacle_layer는 실시간 센서 데이터를 costmap에 반영한다. Nav2의 obstacle layer는 2D raycasting을 사용해 2D LiDAR, 깊이 센서 또는 다른 센서의 관측을 처리할 수 있으며, 각 관측 소스는 observation_sources 아래에 등록된다.2

2D raycasting은 센서에서 장애물까지 가상의 선을 쏘는 방식이다. LiDAR가 어떤 방향에서 2m 거리에 물체를 봤다면, 센서와 물체 사이의 공간은 비어 있다고 추정할 수 있고, 끝점 근처는 장애물로 표시할 수 있다. 이때 두 가지 동작이 중요하다.

marking은 센서가 감지한 장애물을 costmap에 추가하는 동작이다. 의자 다리가 LiDAR에 잡히면 해당 위치가 장애물로 칠해진다.

clearing은 센서 빔이 지나간 빈 공간의 장애물 표시를 지우는 동작이다. 방금 전까지 사람이 서 있던 위치가 이제 비어 있다면, LiDAR 빔이 그 공간을 통과하면서 "여기는 이제 비었다"라고 알려 줄 수 있다.

가장 기본적인 /scan 연결 예시는 다음과 같다.

local_costmap:
  local_costmap:
    ros__parameters:
      plugins: ["obstacle_layer", "inflation_layer"]

      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        enabled: true
        observation_sources: scan
        scan:
          topic: /scan
          data_type: "LaserScan"
          marking: true
          clearing: true
          obstacle_max_range: 2.5
          obstacle_min_range: 0.0
          raytrace_max_range: 3.0
          raytrace_min_range: 0.0
          max_obstacle_height: 2.0
          min_obstacle_height: 0.0

      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        inflation_radius: 0.55
        cost_scaling_factor: 3.0

여기서 obstacle_max_range는 장애물로 표시할 최대 거리다. 예를 들어 2.5m로 설정하면, 2.5m보다 먼 물체는 장애물로 찍지 않는다. raytrace_max_range는 clearing을 위해 빔을 추적할 최대 거리다. 보통 raytrace_max_range를 obstacle_max_range보다 같거나 조금 크게 둔다. 그래야 장애물을 찍는 거리보다 약간 더 멀리까지 빈 공간을 지울 수 있다.

topic에도 주의해야 한다. /scan처럼 슬래시로 시작하는 절대 토픽을 쓰면 네임스페이스와 상관없이 /scan을 본다. 반대로 scan처럼 상대 토픽을 쓰면 로봇 네임스페이스 아래로 붙을 수 있다. 여러 대의 로봇을 /tb1, /tb2 네임스페이스로 실행하는 상황에서는 이 차이가 매우 중요하다. 공식 문서도 obstacle layer의 data source topic이 상대 토픽일 경우 노드의 부모 네임스페이스를 기준으로 해석된다고 설명한다.2

실습할 때는 먼저 토픽이 실제로 존재하는지 확인한다.

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

그리고 /scan 메시지의 header.frame_id를 본다. 보통 laser, base_scan, lidar_link 같은 이름이 들어 있다. 이 프레임에서 base_link, 그리고 costmap의 global_frame까지 TF가 이어져 있어야 한다. 센서 토픽은 살아 있는데 costmap에 장애물이 나타나지 않는다면, 토픽 이름보다 TF가 문제인 경우가 많다.

ros2 run tf2_ros tf2_echo base_link laser
ros2 run tf2_ros tf2_echo odom base_link

센서가 보는 장애물은 결국 좌표 변환을 거쳐 costmap 위의 어느 칸에 놓인다. TF가 끊어지면 Nav2는 "장애물을 봤지만 어디에 찍어야 하는지 모르겠다"는 상태가 된다.

Voxel layer: 3D 센서를 2D 주행 판단에 연결한다

voxel_layer는 3D 정보를 다룰 때 사용한다. 이름 그대로 공간을 작은 3차원 칸, 즉 voxel로 나누어 장애물을 관리한다. 이후 로봇 주행에 필요한 2D costmap으로 투영해 Planner와 Controller가 사용할 수 있게 한다.

2D LiDAR만 있는 로봇은 보통 바닥에서 일정 높이의 단면을 본다. 그래서 테이블 상판처럼 LiDAR 높이 위에 있는 물체는 보지 못할 수 있고, 반대로 얇은 유리문처럼 LiDAR 반사가 약한 물체도 놓칠 수 있다. 깊이 카메라나 3D LiDAR를 쓰면 이런 정보를 일부 보완할 수 있다.

예를 들어 깊이 카메라가 /camera/depth/points라는 PointCloud2 토픽을 낸다면 다음처럼 voxel layer의 관측 소스로 등록할 수 있다.

local_costmap:
  local_costmap:
    ros__parameters:
      plugins: ["voxel_layer", "inflation_layer"]

      voxel_layer:
        plugin: "nav2_costmap_2d::VoxelLayer"
        enabled: true
        publish_voxel_map: true
        origin_z: 0.0
        z_resolution: 0.05
        z_voxels: 16
        max_obstacle_height: 2.0
        observation_sources: depth_points
        depth_points:
          topic: /camera/depth/points
          data_type: "PointCloud2"
          marking: true
          clearing: true
          obstacle_max_range: 2.5
          raytrace_max_range: 3.0
          min_obstacle_height: 0.05
          max_obstacle_height: 1.5

min_obstacle_height와 max_obstacle_height는 높이 필터 역할을 한다. 바닥의 작은 노이즈를 장애물로 보지 않으려면 최소 높이를 조금 올릴 수 있다. 로봇이 지나갈 수 있는 높은 천장 구조물을 무시하려면 최대 높이를 제한할 수도 있다. 물론 이 값은 로봇의 실제 높이와 센서 위치에 맞춰 조심스럽게 정해야 한다.

voxel layer는 유용하지만 계산량이 더 들고 튜닝할 값도 많다. 처음 Nav2를 익힐 때는 2D LiDAR 기반 obstacle layer로 시작하고, 3D 센서가 꼭 필요한 이유가 생겼을 때 voxel layer를 추가하는 편이 좋다.

로봇의 몸집을 알려 주는 robot_radius와 footprint

costmap에서 가장 자주 실수하는 설정이 로봇 크기다. 지도상으로는 통로 폭이 0.8m이고 로봇 폭이 0.5m라면 지나갈 수 있을 것 같다. 하지만 Nav2가 로봇을 반지름 0.45m짜리 원으로 알고 있다면 어떻게 될까? 로봇 지름은 0.9m가 되어 통로보다 커진다. 사람 눈에는 지나갈 수 있어도 Nav2 입장에서는 충돌이다.

Nav2는 로봇의 외형을 크게 두 방식으로 표현한다. 하나는 robot_radius, 다른 하나는 footprint다. Costmap 2D 문서에 따르면 robot_radius는 footprint 좌표가 제공되지 않았을 때 원형 충돌 검사에 사용되고, footprint는 로봇 외곽 점들의 집합으로 전체 충돌 검사에 사용될 수 있다.1

원형에 가까운 로봇은 robot_radius로 단순화해도 좋다.

local_costmap:
  local_costmap:
    ros__parameters:
      robot_radius: 0.22
      footprint_padding: 0.01

TurtleBot처럼 위에서 봤을 때 거의 원형인 로봇은 이 방식이 간단하고 이해하기 쉽다. 반지름에 약간의 여유를 더하면 작은 제어 오차도 흡수할 수 있다.

하지만 직사각형 로봇, 앞뒤로 긴 로봇, 센서나 짐받이가 한쪽으로 튀어나온 로봇은 footprint를 쓰는 편이 정확하다.

local_costmap:
  local_costmap:
    ros__parameters:
      footprint: "[[0.35, 0.25], [0.35, -0.25], [-0.30, -0.25], [-0.30, 0.25]]"
      footprint_padding: 0.02

이 좌표들은 보통 base_link 기준으로 로봇 외곽을 시계 방향 또는 반시계 방향으로 적은 것이다. 위 예시는 앞쪽으로 0.35m, 뒤쪽으로 0.30m, 좌우로 0.25m인 직사각형 로봇을 뜻한다.

여기서 재미있는 일이 벌어진다. 길고 얇은 로봇을 robot_radius로 표현하면 가장 긴 모서리를 감싸는 큰 원이 된다. 그러면 실제로는 통과할 수 있는 문도 costmap에서는 막힌 것처럼 보일 수 있다. 반대로 footprint를 정확히 넣으면 로봇의 방향에 따라 통과 가능한 공간을 더 정확히 판단할 수 있다.

물론 footprint가 만능은 아니다. 로봇 외형을 너무 타이트하게 잡으면 실제 주행 중 작은 흔들림에도 충돌 위험이 생긴다. footprint_padding은 이런 상황을 위해 외형에 살짝 여유를 더하는 값이다. 로봇이 비싼 장비를 싣고 있거나 사람이 많은 환경에서 움직인다면, 몇 cm의 여유가 큰 차이를 만든다.

Inflation layer: 벽 주변을 얼마나 무섭게 볼 것인가

RViz에서 벽 주변에 색이 번져 보이는 이유가 inflation_layer다. 이 레이어는 장애물 주변으로 비용을 퍼뜨려 로봇이 장애물에 너무 가까이 가지 않도록 만든다. Nav2 공식 문서는 inflation layer가 장애물 주변에 지수 감쇠 함수를 적용해 충돌에 가까운 영역의 이동 비용을 높이고, 로봇의 내접 반경 안쪽에는 치명적 비용을 둔다고 설명한다.3

여기서 핵심 파라미터는 두 개다.

inflation_layer:
  plugin: "nav2_costmap_2d::InflationLayer"
  inflation_radius: 0.55
  cost_scaling_factor: 3.0

inflation_radius는 장애물 주변으로 비용을 확장할 범위다. 값이 0.55라면 장애물에서 반경 0.55m 안쪽까지 비용이 퍼진다. 이 값이 너무 작으면 로봇이 벽에 바짝 붙은 경로를 선택할 수 있다. 이 값이 너무 크면 좁은 통로 전체가 높은 비용으로 뒤덮여 로봇이 들어가지 못한다.

cost_scaling_factor는 비용이 거리와 함께 얼마나 빠르게 낮아지는지를 정한다. 공식 문서에서는 inflation radius 범위에 걸친 지수 감쇠 계수라고 설명한다.3 여기서 자주 헷갈리는 점이 있다. 이 값은 "안전거리의 크기" 자체가 아니다. 안전거리의 최대 범위는 inflation_radius가 정하고, cost_scaling_factor는 그 안에서 비용 곡선의 기울기를 바꾼다.

일반적으로 cost_scaling_factor가 크면 장애물 근처에서 비용이 급격하게 떨어진다. 즉, 아주 가까운 곳은 위험하지만 조금만 멀어져도 비용이 빠르게 낮아진다. 반대로 값이 작으면 비용이 더 완만하게 감소해 장애물의 영향이 넓게 남는다. 그래서 로봇이 복도 중앙을 더 선호하게 만들고 싶다면 inflation_radius를 충분히 주고, 비용이 너무 빨리 사라지지 않도록 cost_scaling_factor를 조정해야 한다.

말로만 보면 감이 잘 오지 않으니 세 가지 상황을 떠올려 보자.

  • inflation_radius가 너무 작으면 로봇은 벽 옆도 별로 위험하지 않다고 느껴 벽에 붙어 갈 수 있다.

  • inflation_radius가 너무 크면 좁은 문이나 복도가 높은 비용으로 가득 차 지나갈 길이 사라질 수 있다.

  • cost_scaling_factor가 너무 커서 비용이 급격히 떨어지면 장애물에서 조금 떨어진 영역의 회피 압력이 약해질 수 있다.

  • cost_scaling_factor가 너무 작아 비용이 넓게 유지되면 넓은 복도에서는 중앙을 잘 잡지만 좁은 공간에서는 지나치게 겁먹을 수 있다.

좁은 문을 통과하지 못하는 문제를 만났다고 해서 무조건 inflation_radius만 줄이면 안 된다. 문을 통과하게는 만들 수 있지만, 로봇이 벽에 너무 가까워질 수도 있다. 먼저 footprint나 robot_radius가 실제보다 크게 잡혀 있지 않은지 확인하고, 그다음 inflation 값을 조정하는 순서가 안전하다.

좁은 문을 통과하지 못하는 이유를 해석하기

이제 처음 장면으로 돌아가 보자. 로봇이 문 앞에서 멈췄다. 사람 눈에는 충분히 넓다. 하지만 Nav2는 움직이지 않는다. 이때 원인은 대개 다음 중 하나다.

  • 로봇의 robot_radius 또는 footprint가 실제보다 크게 설정되어 있다.

  • inflation_radius가 커서 문 양쪽 벽의 inflated cost가 서로 겹쳐 있다.

  • cost_scaling_factor 설정 때문에 문 중앙의 비용도 너무 높게 남아 있다.

  • costmap resolution이 너무 거칠어 좁은 틈이 격자에서 막힌 공간처럼 표현된다.

  • LiDAR 노이즈나 반사 때문에 문 근처에 가짜 장애물이 찍히고 clearing이 제대로 되지 않는다.

이때 RViz에서 global costmap과 local costmap을 따로 확인해야 한다. global costmap에서 이미 문이 막혀 보인다면 Planner가 전역 경로를 만들 수 없다. local costmap에서는 열려 있는데 global costmap에서 막혀 있다면 static map, inflation, footprint 설정을 먼저 본다.

반대로 global path는 문을 통과하도록 잘 만들어졌는데 로봇이 문 앞에서 멈춘다면 local costmap이나 Controller 쪽 문제일 가능성이 크다. local costmap에 가짜 장애물이 남아 있거나, 로봇 주변 inflated cost가 너무 넓거나, Controller가 선택한 궤적들이 모두 충돌 후보로 판정되는 상황일 수 있다.

디버깅은 항상 "누가 막았는가?"를 찾는 일이다. Planner가 막았는가, Controller가 막았는가, 아니면 costmap이 세상을 너무 무섭게 그렸는가?

/scan을 observation source로 연결해 보기

이제 실제 설정 흐름을 하나로 묶어 보자. TurtleBot 계열 시뮬레이션이나 2D LiDAR가 달린 이동 로봇에서 가장 흔한 센서 토픽은 /scan이다. 이 토픽을 local costmap과 global costmap에 모두 넣을 수도 있고, local costmap에만 넣을 수도 있다.

먼저 local costmap에 /scan을 연결해 로봇 주변 장애물 회피에 사용해 보자.

local_costmap:
  local_costmap:
    ros__parameters:
      update_frequency: 5.0
      publish_frequency: 2.0
      global_frame: odom
      robot_base_frame: base_link
      rolling_window: true
      width: 3
      height: 3
      resolution: 0.05
      robot_radius: 0.22
      plugins: ["obstacle_layer", "inflation_layer"]

      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        enabled: true
        observation_sources: scan
        footprint_clearing_enabled: true
        scan:
          topic: /scan
          data_type: "LaserScan"
          marking: true
          clearing: true
          obstacle_max_range: 2.5
          obstacle_min_range: 0.0
          raytrace_max_range: 3.0
          raytrace_min_range: 0.0
          inf_is_valid: false

      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        enabled: true
        inflation_radius: 0.55
        cost_scaling_factor: 3.0

footprint_clearing_enabled는 로봇 자신의 footprint 아래에 있는 점유 셀을 정리하는 데 사용된다. 센서 노이즈나 이전 장애물 흔적 때문에 로봇 바로 아래가 막혀 버리면 로봇은 자기 자신에게 갇힌 것처럼 행동할 수 있다. 이 설정은 그런 상황을 줄이는 데 도움이 된다.

다음으로 global costmap에도 obstacle layer를 넣을 수 있다.

global_costmap:
  global_costmap:
    ros__parameters:
      update_frequency: 1.0
      publish_frequency: 1.0
      global_frame: map
      robot_base_frame: base_link
      resolution: 0.05
      track_unknown_space: true
      robot_radius: 0.22
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]

      static_layer:
        plugin: "nav2_costmap_2d::StaticLayer"
        map_subscribe_transient_local: true

      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        enabled: true
        observation_sources: scan
        scan:
          topic: /scan
          data_type: "LaserScan"
          marking: true
          clearing: true
          obstacle_max_range: 2.5
          raytrace_max_range: 3.0

      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        enabled: true
        inflation_radius: 0.55
        cost_scaling_factor: 3.0

global costmap에 실시간 장애물을 반영하면 Planner가 더 큰 우회 경로를 다시 계산할 수 있다. 예를 들어 복도 한쪽이 상자로 완전히 막혔다면 local controller가 그 순간만 피하는 것보다, global planner가 다른 복도를 선택하는 편이 낫다.

하지만 모든 동적 장애물을 global costmap에 강하게 남기는 것이 항상 좋은 것은 아니다. 사람이 잠깐 지나간 흔적이 오래 남으면 Planner가 불필요하게 멀리 돌아갈 수 있다. 그래서 observation_persistence, clearing, update frequency, obstacle range를 환경에 맞춰 조정해야 한다.

Marking과 Clearing을 눈으로 확인하기

설정이 끝났다면 RViz에서 costmap을 확인한다. 보통 Nav2 RViz 설정에는 Global Costmap, Local Costmap 표시가 들어 있다. 없다면 Map 디스플레이를 추가하고 다음 토픽을 선택해 볼 수 있다.

/global_costmap/costmap
/local_costmap/costmap
/global_costmap/costmap_raw
/local_costmap/costmap_raw

환경과 Nav2 버전에 따라 표시 토픽 이름은 조금 다를 수 있다. ros2 topic list | grep costmap으로 실제 토픽을 확인하는 습관을 들이자.

장애물 표시 실험은 간단하다. 시뮬레이션이라면 로봇 앞에 물체를 놓고, 실제 로봇이라면 LiDAR 앞에 박스를 둔다. local costmap에 장애물 표시가 생기면 marking이 동작하고 있는 것이다. 박스를 치웠을 때 표시가 사라지면 clearing도 동작하고 있다.

만약 장애물이 생기기만 하고 사라지지 않는다면 다음을 확인한다.

  • clearing: true인지 확인한다.

  • raytrace_max_range가 충분히 긴지 확인한다.

  • LiDAR가 빈 공간을 실제로 관측하고 있는지 확인한다.

  • /scan의 range_max, inf 값 처리와 inf_is_valid 설정을 확인한다.

  • 센서 프레임에서 costmap 프레임까지 TF가 이어지는지 확인한다.

특히 유리문, 검은 물체, 반사가 강한 금속 물체는 LiDAR에서 이상하게 보일 수 있다. costmap은 센서가 준 정보를 믿을 뿐이다. 센서가 거짓말을 하면 costmap도 그럴듯한 거짓말을 한다.

Resolution은 지도의 픽셀 크기다

resolution은 costmap 한 칸이 실제 세계에서 몇 미터를 의미하는지 정한다. resolution: 0.05라면 한 칸이 5cm다. 3m × 3m local costmap은 가로 60칸, 세로 60칸이 된다.

해상도가 작을수록 지도가 촘촘해진다. 좁은 문, 얇은 장애물, 복잡한 구조를 더 세밀하게 표현할 수 있다. 하지만 칸 수가 늘어나므로 계산량도 커진다. 반대로 해상도가 크면 계산은 가벼워지지만 좁은 공간이 뭉개져 보일 수 있다.

예를 들어 문 폭이 0.75m이고 로봇 폭이 0.5m라면 남는 여유는 양쪽 합쳐 0.25m다. 한쪽 여유는 대략 0.125m다. 이 환경에서 costmap 해상도가 0.1m라면 한두 칸 차이로 통과 가능성이 바뀔 수 있다. 여기에 inflation까지 겹치면 문이 막힌 것처럼 보일 수 있다.

그렇다고 무조건 0.01m로 낮추면 좋을까? 그렇지 않다. 작은 사무실 로봇에서 1cm 해상도는 계산량을 크게 늘릴 수 있다. 일반적인 실내 이동 로봇은 0.03m에서 0.05m 사이를 많이 시험해 볼 수 있고, 넓은 창고처럼 큰 환경에서는 더 거친 해상도를 고려할 수 있다. 중요한 것은 로봇 크기, 통로 폭, 센서 정확도, CPU 성능을 함께 보는 것이다.

튜닝은 한 번에 하나씩 바꾼다

Costmap 튜닝을 처음 하면 YAML 파일의 숫자를 한꺼번에 바꾸고 싶어진다. robot_radius도 줄이고, inflation_radius도 줄이고, cost_scaling_factor도 바꾸고, resolution도 낮춘다. 그러면 로봇이 움직이기 시작할 수도 있다. 문제는 왜 움직이기 시작했는지 모른다는 것이다. 다음 환경에서 다시 막히면 어디를 고쳐야 할지 알 수 없다.

좋은 튜닝은 한 번에 하나씩 바꾸고 관찰하는 것이다.

  • 먼저 실제 로봇 크기를 재고 robot_radius 또는 footprint를 맞춘다.

  • 그다음 RViz에서 static map과 costmap이 같은 위치에 겹치는지 확인한다.

  • /scan 장애물이 marking과 clearing 되는지 확인한다.

  • 마지막으로 inflation_radius와 cost_scaling_factor를 조정한다.

  • 좁은 통로 문제가 남으면 resolution과 Controller 설정까지 함께 본다.

튜닝의 기준은 "로봇이 지나가느냐" 하나만이 아니다. 로봇이 지나가더라도 벽에 너무 가까우면 좋은 설정이 아니다. 반대로 너무 안전하게만 잡으면 실제 업무를 하지 못한다. 좋은 costmap은 겁이 많지만 과하게 겁먹지는 않는 지도다.

흔한 증상과 원인

Costmap 문제는 증상이 비슷해 보여도 원인이 다를 수 있다. 다음 표처럼 먼저 증상을 나누어 보면 디버깅이 쉬워진다.

증상가능성이 큰 원인먼저 볼 것
로봇이 좁은 문을 못 지나감footprint 과대 설정, inflation 과대, resolution 거침robot_radius, footprint, inflation_radius, RViz costmap
벽에 너무 붙어 감inflation 영향이 약함inflation_radius, cost_scaling_factor
장애물이 사라졌는데 costmap에 남음clearing 실패clearing, raytrace_max_range, /scan, TF
local costmap에는 장애물이 보이는데 global path는 그대로 감global costmap에 obstacle layer가 없거나 업데이트가 느림global_costmap plugins, update frequency
센서는 보이는데 costmap에는 안 찍힘토픽 이름, data_type, frame_id, TF 문제/scan, observation_sources, tf2_echo
로봇 주변이 항상 막혀 있음footprint 아래 장애물 잔상, 센서 노이즈footprint_clearing_enabled, 센서 위치, clearing

문제 해결의 핵심은 RViz를 믿되, RViz만 믿지는 않는 것이다. RViz에 보이는 색은 결과다. 결과가 이상하면 토픽, TF, 파라미터를 거슬러 올라가야 한다. /scan은 들어오는가? frame_id는 맞는가? costmap frame으로 변환되는가? obstacle layer가 해당 토픽을 보고 있는가? inflation이 너무 넓지는 않은가? 이렇게 한 단계씩 좁혀 가면 대부분의 costmap 문제는 설명된다.

Costmap을 이해하면 로봇의 고집이 보인다

Nav2를 처음 다룰 때 로봇은 가끔 고집쟁이처럼 보인다. 충분히 지나갈 수 있는 곳을 거부하고, 멀쩡한 복도에서 멈추고, 목표를 찍어도 빙빙 돈다. 하지만 costmap을 이해하면 그 고집에는 나름의 이유가 있다는 것을 알게 된다.

로봇은 사람처럼 "대충 지나갈 수 있겠는데?"라고 생각하지 않는다. 로봇은 footprint가 차지할 공간, 장애물 주변의 inflated cost, 센서가 방금 본 장애물, clearing되지 않은 흔적, 미지 영역의 비용을 모두 숫자로 본다. 그 숫자의 합이 위험하다고 말하면 로봇은 멈춘다.

이 장에서 본 핵심은 다음과 같다. Nav2 costmap은 여러 레이어를 겹쳐 하나의 비용 지도를 만든다. global costmap은 Planner가 전역 경로를 찾는 데 쓰이고, local costmap은 Controller가 현재 속도와 궤적을 고르는 데 쓰인다. robot_radius와 footprint는 로봇의 몸집을 costmap에 알려 주는 설정이다. inflation_radius와 cost_scaling_factor는 장애물 주변을 얼마나 넓고 어떤 기울기로 위험하게 볼지 정한다. 그리고 /scan 같은 센서 토픽은 observation_sources를 통해 costmap에 들어와 marking과 clearing을 수행한다.

이제 우리는 로봇이 왜 특정 길을 피하는지 설명할 수 있다. 다음 장에서는 이 costmap 위에서 실제로 목표 지점을 지정하고, Nav2가 전역 경로를 만들고, 지역 제어를 통해 로봇을 목표까지 움직이는 과정을 따라가 볼 것이다. costmap이 로봇의 눈이라면, 다음 장의 목표 이동은 그 눈으로 세상을 보며 첫 걸음을 내딛는 순간이다.

참고

1Costmap 2D — Nav2 documentation

2Obstacle Layer Parameters — Nav2 documentation

3Inflation Layer Parameters — Nav2 documentation

책 목록

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

전체 목차 보기

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

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

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

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

참여하기