AEGLOSLAB ROS 2 Jazzy 대전

제8부 항법

8.4 코스트맵 2D — 레이어와 센서 통합

8.4.1 이 장에서 배우는 것 / 선수 지식

코스트맵은 "어디로 갈 수 있고 어디가 위험한가"를 격자로 표현한 것이다. 플래너(8.5)가 경로를 찾을 공간이고, 컨트롤러가 회피할 대상이며, 센서 데이터가 항법으로 들어가는 통로다.

그리고 Nav2에서 가장 많은 실무 문제가 발생하는 곳이기도 하다. 유령 장애물이 남아 로봇이 갇히고, 팽창이 과해 좁은 통로를 못 지나고, 센서 설정이 틀려 아무것도 안 보인다.

배우는 것:

  • 구조 — 셀 값과 특수 값의 의미.
  • 레이어 구조 — 계층화가 무엇을 가능하게 하는가.
  • 전역·지역 코스트맵 — 설정이 어떻게 다른가.
  • 센서 통합 — 관측 소스 설정.
  • 팽창 — 수식과 튜닝의 실제.
  • 지우기(clearing) — 유령 장애물이 생기는 이유.
  • 풋프린트 — 로봇 형상 표현.
  • 진단 — 무엇이 잘못됐는지 보는 법.

선수 지식: 8.2(지도), 8.3(측위), 7.5.5(inf/NaN 처리), 7.4(포인트클라우드), 5.1(tf), 8.1(Nav2 구조).

정직: 코스트맵 파라미터는 많고 배포판마다 추가·변경된다. 아래는 핵심 항목이며 정확한 목록·기본값은 Nav2 문서가 정본이다(확인 필요). 튜닝 수치는 로봇·환경 의존이므로 출발점으로만 제시한다. 이 환경에서 검증하지 않았다(목차 3).

검증 상태(추가): 이 장의 서술 가운데 1곳은 이후 회차에서 Jazzy 실물과 대조해 확인했다 — 본문의 (실행 확인) 표기가 그것이다. 표기가 없는 서술은 위 고지가 그대로 적용된다(목차 3의 검증 범위 표). 147차(126번째 회차)가 125차의 지도 인프라를 재사용해 nav2_costmap_2d를 실제로 기동하고, 단일 장애물 셀의 실측 코스트맵 값을 팽창 공식과 직접 대조했다 — 8.4.2·8.4.6·8.4.10을 보라. 148차(127번째 회차)는 합성 LaserScan으로 ObstacleLayer를 실제로 적용해 obstacle_max_range의 표시 게이팅은 확인했지만, 지우기(raytrace 클리어링) 재현은 네 가설을 배제하고도 원인을 못 찾아 미해결로 남겼다 — 8.4.5를 보라. 149차(128번째 회차)가 이 미해결을 navigation2·laser_geometry 소스 계측으로 이어받아, range_max 정확 일치 시 광선 자체가 없는 것과 정면 근처 빔의 브레젠험 이산화 결함이라는 서로 다른 두 원인으로 규명했고, 재사용 도구(dump_costmap_local.py)의 TRANSIENT_LOCAL 한 번 구독 결함도 별도로 찾았다 — 8.4.5를 보라. 150차(129번째 회차)가 0~45° 16개 각도 스윕으로 149차의 "정면 근처만 위험" 프레이밍을 정정했다 — 고착은 0°·17°·20°·30°·40°(31%)로 산발적이며 정면 여부와 무관하다 — 8.4.5·8.4.7을 보라.

8.4.2 셀 값과 의미

코스트맵의 각 셀은 0~255의 값을 갖는다.

값이름의미
0FREE_SPACE자유
1~252—팽창 비용(가까울수록 높음)
253INSCRIBED_INFLATED_OBSTACLE로봇 내접원 안 — 확실히 충돌
254LETHAL_OBSTACLE실제 장애물
255NO_INFORMATION미지

253과 254의 구분이 중요하다:

실제 장애물 셀 = 254 로봇 내접원 반경 안 = 253 (중심이 여기 오면 충돌 확정) 팽창 영역 = 1~252 (가까울수록 높은 비용) 자유 = 0

플래너는 253 이상을 통과 불가로 취급하고, 1~252는 "가능하지만 비용이 높다"로 다룬다. 이것이 로봇이 벽에서 떨어져 다니게 만드는 메커니즘이다.

함정: NO_INFORMATION(255)의 처리가 설정에 따라 다르다. track_unknown_space가 참이면 미지를 별도로 다루고, 거짓이면 자유 공간처럼 취급한다. 미지를 자유로 보면 로봇이 탐사한 적 없는 곳으로 계획하므로, 상황에 맞게 정해야 한다.

8.4.3 레이어 구조

코스트맵은 여러 레이어를 쌓아 만든다.

StaticLayer 정적 지도 결합된 코스트맵 ObstacleLayer 센서 관측 VoxelLayer 3D 센서 InflationLayer 팽창 커스텀 레이어 금지구역 · 속도제한
레이어역할입력
StaticLayer지도를 코스트맵에/map(8.2)
ObstacleLayer2D 센서 관측LaserScan·PointCloud2
VoxelLayer3D 관측(높이 고려)PointCloud2
InflationLayer장애물 주변 비용다른 레이어 결과
RangeSensorLayer초음파 등Range

실행 확인(2026-08-16): Nav2 1.3.12의 nav2_costmap_2d 플러그인 등록을 대조했다. 위 다섯 레이어는 전부 실재하며(nav2_costmap_2d::StaticLayer·ObstacleLayer·VoxelLayer·InflationLayer·RangeSensorLayer), 이 책이 RangeLayer로 적고 있던 것을 실물 이름 RangeSensorLayer로 정정했다. 아울러 이 책이 다루지 않는 레이어가 셋 더 있다 — DenoiseLayer(고립된 잡음 셀 제거), PluginContainerLayer(레이어 묶음을 하나로 구성), BinaryFilter(필터 마스크로 이진 신호 발행, 8.7.6의 필터 계열). 필요해지면 Nav2 문서를 보라.

| KeepoutFilter·SpeedFilter | 금지구역·속도제한 | 필터 마스크(8.7) |

순서가 중요하다 — 나중 레이어가 앞의 결과 위에 적용된다. InflationLayer는 항상 마지막이어야 한다.

local_costmap:
  local_costmap:
    ros__parameters:
      plugins: ["obstacle_layer", "inflation_layer"]     # 순서대로 적용
      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"

함정: plugins 목록의 이름과 하위 설정 키가 일치해야 한다(8.1.7). 불일치하면 레이어가 로드되지 않고, 오류가 명확하지 않다.

실행 확인(147차) — InflationLayer를 일부러 맨 앞에 두고 재현했다. plugins: ["inflation_layer", "static_layer"](순서를 뒤집음)로 기동하니, 장애물 셀 자체는 여전히 254(static_layer가 나중에 그 값을 그대로 씀)로 나왔지만 주변 셀은 전부 0 — 팽창이 전혀 적용되지 않았다(147차 지점별 실측에서 dx=1~7, 즉 0.05~0.35 m 전 구간 비용 0). InflationLayer가 "그 시점까지 결합된 코스트맵"을 기준으로 팽창을 계산하기 때문에, 자신보다 뒤에 오는 레이어가 나중에 추가하는 장애물은 존재 자체를 모른다는 것이 원시 데이터로 확인됐다. tools/gap_harness/gap147/data/costmap_dump_reversed.json. 목차 3.

견해: 레이어 구조가 코스트맵의 강점이다. 새 정보원(3D 센서·금지구역·동적 물체 예측)을 레이어로 추가하면 플래너·컨트롤러를 변경하지 않고 항법에 반영된다. 커스텀 레이어를 만드는 것이 Nav2를 확장하는 가장 자연스러운 방법인 경우가 많다(8.1.7).

8.4.4 전역·지역 코스트맵

같은 코드지만 설정이 완전히 다르다.

global_costmap:
  global_costmap:
    ros__parameters:
      update_frequency: 1.0
      publish_frequency: 1.0
      global_frame: map                # ★ map 기준
      robot_base_frame: base_link
      rolling_window: false            # ★ 지도 전체 고정
      track_unknown_space: true
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]

local_costmap:
  local_costmap:
    ros__parameters:
      update_frequency: 5.0            # ★ 빠름
      publish_frequency: 2.0
      global_frame: odom               # ★ odom 기준(5.1.7)
      robot_base_frame: base_link
      rolling_window: true             # ★ 로봇을 따라 이동
      width: 5
      height: 5
      resolution: 0.05
      plugins: ["obstacle_layer", "inflation_layer"]    # static 없음
축전역지역
global_framemapodom
rolling_windowfalse(고정)true(따라 이동)
크기지도 전체5×5 m 내외
갱신 주기1~5 Hz5~20 Hz
StaticLayer✅ 있음대개 없음
용도전역 경로(8.5)즉각 회피

지역 코스트맵이 odom 기준인 것이 핵심이다(5.1.7). map이 재측위로 점프해도(8.3.3) 지역 코스트맵과 제어는 영향받지 않는다. 5.1.11의 13번에서 경고한 것이 여기서 올바르게 구현되어 있다.

견해: 지역 코스트맵 크기는 "정지 거리 + 여유"로 정한다. 최고 속도 1 m/s에서 정지에 1초 걸리면 최소 1 m가 필요하고, 계획 여유를 더해 3~5 m가 일반적이다. 너무 크면 계산이 늘고, 너무 작으면 회피 여유가 없다.

8.4.5 센서 통합

ObstacleLayer의 관측 소스 설정이다.

obstacle_layer:
  plugin: "nav2_costmap_2d::ObstacleLayer"
  enabled: true
  observation_sources: scan pointcloud      # 공백 구분

  scan:
    topic: /scan
    data_type: "LaserScan"
    sensor_frame: ""                        # 비우면 메시지의 frame_id 사용
    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
    inf_is_valid: false                     # ★ 7.5.5

  pointcloud:
    topic: /camera/points
    data_type: "PointCloud2"
    marking: true
    clearing: true
    obstacle_max_range: 3.0
    raytrace_max_range: 3.5
    min_obstacle_height: 0.1                # 지면 제외
    max_obstacle_height: 1.8

핵심 파라미터:

파라미터의미
marking이 소스로 장애물을 표시할 것인가
clearing이 소스로 장애물을 지울 것인가
obstacle_max_range표시에 쓸 최대 거리
raytrace_max_range지우기에 쓸 최대 거리
min/max_obstacle_height높이 필터(3D 데이터)
inf_is_validinf를 유효 측정으로 볼 것인가(7.5.5)

함정: raytrace_max_range가 obstacle_max_range보다 커야 한다. 지우는 범위가 표시하는 범위보다 좁으면, 표시된 장애물을 지울 수 없어 유령 장애물이 남는다(8.4.7).

실행 확인(148차, Jazzy) — obstacle_max_range가 표시를 정확히 게이팅함을 확인했다. 125~127차의 코스트맵 인프라에 합성 LaserScan을 붙여 ObstacleLayer(obstacle_max_range: 3.0)를 실제로 적용했다. 원래는 "장애물이 없을 때"를 뜻하려던 스캔 값(모든 360빔이 2.0 m)이 실수로 obstacle_max_range(3.0 m)보다 짧게 설정됐는데, 그 결과 모든 방향에서 실제 장애물처럼 마킹돼 반경 약 2.0 m의 온전한 원형 벽이 코스트맵에 그려졌다(원시 셀 270개, 원점으로부터의 거리 실측 1.971~2.026 m, 평균 1.996 m — 설정값 2.0 m과 거의 정확히 일치). 의도한 실험은 아니었지만, obstacle_max_range 안의 값이면 방향과 무관하게 정확한 기하학적 위치에 표시된다는 것을 뜻밖에 정밀하게 확인한 셈이다. 원시 데이터는 tools/gap_harness/gap148/data/ring_test_dump.json.

실행 확인(148차) — 지우기(raytrace 기반 클리어링)는 이번 회차 방법으로는 재현하지 못했다(미해결로 명시). 이 절의 예시 값 그대로(obstacle_max_range: 2.5·raytrace_max_range: 3.0) 단일 빔이 1.0 m에서 장애물을 보고하다가 2.8 m(정확히 obstacle_max_range보다 멀고 raytrace_max_range 이내 — 문서화된 메커니즘대로면 표시는 안 되고 지우기는 돼야 하는 구간)로 바뀌는 합성 스캔을 15초간 입력했다. 표시는 정확했지만(장애물 존재 중 확인), 이후 13초 넘게 "지워졌다"는 스캔을 계속 받아도 그 셀은 지워지지 않았다. 다음 가설을 각각 시험해 배제했다 — ① 타이밍/경쟁 오류(장애물 구간·이후 구간 모두 스캔 노드 로그 타임스탬프로 명시적 확인), ② costmap_raw 토픽만의 발행 지연(비-raw OccupancyGrid 토픽에서도 동일한 잔존 셀 확인), ③ raytrace_max_range를 넘는 판독이라 광선 자체가 안 나감(2.8 m는 3.0 m 이내로 명확히 배제), ④ observation_persistence로 오래된 관측이 캐시돼 재마킹(기본값 0.0으로 확인, 비활성). 네 가설을 배제하고도 원인을 찾지 못했다 — 단일 고정 빔의 합성 스캔이라는 이번 회차 방법 자체의 한계일 수도 있고, nav2_costmap_2d 소스 수준에서 확인이 필요한 것일 수도 있다(확인 필요, 다음 회차 후보). 이 절의 "유령 장애물이 남는다"는 서술 자체를 반박하는 것은 아니다 — 오히려 더 넓은 조건에서도 유령 장애물이 재현된다는 것을 보여준다. 원시 로그·데이터는 tools/gap_harness/gap148/(logs/·data/analysis.txt). 목차 3.

실행 확인(149차) — 148차의 미해결을 소스 계측으로 이어받아, 서로 다른 세 가지 원인으로 완전히 규명했다(→ 148차 정정). navigation2(jazzy 브랜치)와 laser_geometry(apt 설치본과 일치하는 2.7.2 태그) 소스를 직접 읽고, RCLCPP_WARN 로그를 심어 재빌드한 nav2_costmap_2d를 오버레이로 적용해 148차와 같은 시나리오를 다시 실행했다.

① range_max와 정확히 같은 판독은 광선이 전혀 안 나간다(별개의, 더 명백한 메커니즘). laserScanCallback()이 쓰는 laser_geometry::LaserProjection::projectLaser_()는 range < range_cutoff(기본값 scan.range_max) && range >= range_min(강한 부등호)로 점을 걸러 포인트클라우드를 만든다 — 판독값이 range_max와 정확히 같으면 그 빔은 클라우드에서 완전히 빠진다(마킹·지우기 모두 대상 자체가 없음). raytraceFreespace()에 로그를 심어 확인하니, 모든 빔이 range_max를 보고하는 스캔에서는 클리어링 클라우드의 포인트 수가 매 주기 0개였다.

② 148차가 실제로 겪은 것은 ①과 다른 원인이었다 — 정면(0°) 근처 빔의 브레젠험 이산화 결함이다. 148차의 재현 스캔(정면 빔이 1.0 m→2.8 m, range_max보다 작은 유한값)에서는 클리어링 클라우드에 포인트가 있고, 광선도 매 주기 원점에서 올바른 방향으로 나가는 것을 계측으로 확인했다. 그런데도 ObstacleLayer 자신의 내부 코스트맵 버퍼(costmap_)에서 유령 셀 값이 254(LETHAL_OBSTACLE)로 고정된 채 수백 주기 동안 한 번도 0으로 안 바뀌었다 — 마스터 코스트맵으로의 합성(updateWithMax, combination_method 기본값)은 정상 작동함도 같은 계측으로 확인했다(합성 직전 마스터 값 자체가 이미 최신 레이어 값을 그대로 반영). 즉 문제는 합성이 아니라 raytraceLine()의 브레젠험 경로 자체가 그 셀을 지나지 않는 것이었다 — 표시(짧은 거리)는 점 좌표를 직접 반올림하고, 지우기(긴 거리)는 원점에서 광선 종점까지 브레젠험으로 이산화하는데, 거의 축에 평행한(정면, 각도 0에 가까운) 얕은 기울기에서는 두 이산화 경로가 정확히 같은 셀에서 만난다는 보장이 없다. 대조로 45°(축에서 충분히 벗어난 각도) 빔을 같은 방식(1.0 m→유한 지우기값)으로 시험하니, 레이어 버퍼·마스터 코스트맵 모두 정상적으로 0(FREE_SPACE)이 되는 것을 버퍼 값 직접 로그로 확인했다 — 지우기 메커니즘 자체는 정상이며, 정면 근처 빔 특유의 이산화 결함이다.

③ 별개로, 148차가 쓴 재사용 도구(dump_costmap_local.py)에 잠복 결함이 있었다. costmap_raw는 TRANSIENT_LOCAL 토픽(늦게 구독해도 마지막 발행분을 재전송)인데, 이 도구는 첫 메시지 한 개만 받고 즉시 종료한다 — 그 첫 메시지가 "지금 상태"가 아니라 구독 직후 재전송되는, 구독 이전 시점의 캐시된 메시지일 수 있음을 이번 회차에 직접 확인했다(45° 대조군에서 연속 구독 6~8개 샘플을 받으니 [254, 0, 0, 0, 0, 0] — 첫 샘플만 오래된 값, 이후는 모두 실시간 0). 이 결함은 ②(정면 빔)의 결론 자체를 바꾸지는 않는다 — 정면 빔은 다중 샘플(6~8개, monitor_costmap_cell.py)로도 전부 254였고, 레이어 버퍼 계측으로도 독립 확인했기 때문이다. 다만 과거 회차가 이 도구로 "한 번만 측정해" 내린 결론 가운데 다른 것이 있다면, 별도 재확인 전까지는 신뢰도를 낮춰 볼 필요가 있다(확인 필요).

정리: 148차의 관찰("지워지지 않는다")은 정확했다. 원인은 ①이 아니라 ②(정면 근처 빔의 브레젠험 이산화 결함)였고, 소스 계측(레이어 자신의 costmap_ 버퍼 값을 지우기 직후 직접 로그)으로 확정했다. 45° 등 축에서 벗어난 각도에서는 재현되지 않았다 — 단일 고정 빔을 매 주기 똑같은 각도·거리로 반복하는 합성 스캔이라는 이번 시험 방법 자체가 이 결함을 증폭시켰을 가능성이 크다: 실제 다중 빔 라이다는 스캔마다 각 빔의 각도가 근소하게 달라지고 로봇도 미세하게 움직이므로, 같은 셀이 매번 같은 이산화 오차를 겪을 가능성은 낮아 자기 교정될 여지가 있다(다만 이 완화 효과 자체는 다중 빔·연속 스캔으로 별도 검증하지 않았다(확인 필요, 다음 회차 후보)). 원시 로그·계측 데이터는 tools/gap_harness/gap149/(logs/·data/·README.md). 목차 3.

실행 확인(150차) — "정면 근처 빔 특유"라는 위 ②의 각도 프레이밍을 정정한다(→ 149차 정정). 149차는 0°(고착)와 45°(정상)라는 정확히 두 각도만 대조해 "정면에 가까울수록 위험, 축에서 벗어나면 안전"이라는 인상을 줬다. 이번 회차는 같은 설정(1.0 m→2.8 m, obstacle_max_range: 2.5·raytrace_max_range: 3.0)에서 0°~45° 사이 16개 각도를 촘촘히 스윕(3°·5°·8°·10°·15°·17°·18°·20°·22°·25°·28°·30°·35°·40°·45°, 각 각도마다 새 ROS_DOMAIN_ID로 격리한 코스트맵을 새로 기동)했다. 고착된 각도는 {0°, 17°, 20°, 30°, 40°} — 5/16(31%)이고, 나머지 11개(3°·5°·8°·10°·15°·18°·22°·25°·28°·35°·45°)는 전부 정상적으로 지워졌다. 20°·30°·40°는 결코 "정면에 가까운" 각도가 아닌데도 고착됐다 — 149차가 시사한 "0°에 가까울수록 위험한 연속 구간"이 아니라, 이 표시-거리·지우기-거리 조합(1.0 m/2.8 m)에 대해 특정 각도들에서 산발적으로 나타나는 이산화 "공진(resonance)"에 더 가깝다. 각 고착 각도의 다중 샘플(5개, dump_costmap_multi.py — costmap_raw의 전체 배열을 다중 구독으로 확인해 149차가 찾은 TRANSIENT_LOCAL 1회 구독 결함을 다시 밟지 않았다)은 모두 일관되게 254를 보였다. 표시·지우기 거리(1.0 m/2.8 m)를 바꾸면 고착되는 각도 집합 자체가 달라지는지는 이번 회차도 확인하지 않았다(확인 필요, 다음 회차 후보). 원시 데이터는 tools/gap_harness/gap150/data/angle_sweep_results.json. 목차 3.

함정: marking만 켜고 clearing을 끄면 장애물이 영원히 쌓인다. 지나간 사람이 벽처럼 남아 로봇이 갇힌다.

inf_is_valid의 의미(7.5.5):

설정inf 처리
false무시 — 그 방향을 못 지운다
trueraytrace_max_range까지 자유 공간으로

견해: 라이다가 inf를 올바르게 발행한다면 inf_is_valid: true가 유용하다. 빈 공간을 적극적으로 지워 유령 장애물을 줄인다. 다만 드라이버가 측정 실패를 inf로 표현한다면 위험하다 — 못 본 곳을 비었다고 하게 된다. 7.5.5에서 드라이버 규약을 확인하라고 한 이유가 이것이다.

높이 필터가 3D 센서에서 결정적이다:

설정효과
min_obstacle_height가 너무 낮음지면이 장애물로
너무 높음낮은 장애물 누락
max_obstacle_height가 너무 높음천장·조명이 장애물로

함정: 깊이 카메라를 쓸 때 지면이 장애물로 인식되는 것이 매우 흔하다. 로봇이 평지에서도 앞이 막혔다고 판단한다. min_obstacle_height를 지면 위 5~15 cm로 높이되, 경사면에서는 지면이 다시 인식될 수 있으므로 VoxelLayer나 지면 분할이 필요할 수 있다.

8.4.6 팽창

장애물 주변에 비용을 부여해 로봇이 여유를 두고 다니게 한다.

inflation_layer:
  plugin: "nav2_costmap_2d::InflationLayer"
  enabled: true
  inflation_radius: 0.55           # m
  cost_scaling_factor: 3.0
  inflate_unknown: false
  inflate_around_unknown: false

비용 함수:

$$c(d) = (253 - 1) \cdot e^{-f \cdot (d - r_i)}$$

여기서 $d$는 장애물까지 거리, $r_i$는 로봇 내접원 반경, $f$는 cost_scaling_factor.

거리비용
$d \leq r_i$253(확실 충돌)
$r_i < d < $ inflation_radius지수적 감소
$d \geq$ inflation_radius0

실행 확인(147차, Jazzy) — 팽창 공식을 실측 코스트맵 값과 직접 대조했다. 125차의 정적 지도 인프라를 재사용해, 5m×5m 자유공간 한가운데 점 장애물 하나만 있는 지도를 새로 만들고 nav2_costmap_2d(StaticLayer+InflationLayer, robot_radius: 0.05·inflation_radius: 0.55·cost_scaling_factor: 3.0)를 실제로 lifecycle 기동해, /global_costmap/costmap_raw(래스터 값 그대로, nav2_msgs/msg/Costmap)의 셀 값을 장애물로부터의 거리별로 읽었다.

거리 $d$(m)실측 비용공식 예측차이
0.00(장애물 자신)254254(LETHAL_OBSTACLE)0
0.05($=r_i$)253253(INSCRIBED_INFLATED_OBSTACLE)0
0.10222217+5
0.20164161+3
0.30122119+3
0.409088+2
0.506765+2
0.55(=inflation_radius)5756+1
0.60048(공식대로면 아직 0이 아님)−48

지수 공식 자체는 정확히 맞는다 — 0.05~0.55 m 구간 전체에서 실측값이 공식 예측과 1~5(252 중, 약 2% 이내)로 거의 일치했다. 다만 "비용이 정확히 0이 되는 거리"는 inflation_radius(0.55 m)가 아니라 그보다 격자 한 칸(이 해상도에서 0.05 m) 더 바깥인 0.60 m였다 — 0.55 m 지점은 공식(≈56)과 실측(57) 둘 다 아직 양수이고, 0.60 m에서야 정확히 0이 된다. 표의 "$d \geq$ inflation_radius → 0"은 근사적으로만 맞고, 실제 경계는 inflation_radius + 격자 해상도 한 칸만큼 바깥쪽이었다(내부에서 반경을 셀 개수로 이산화하는 과정의 반올림으로 추정 — 소스로 확정하지는 않았다). 원시 데이터·분석은 tools/gap_harness/gap147/data/. 목차 3.

두 파라미터의 역할:

파라미터효과
inflation_radius비용이 0이 되는 거리 — 얼마나 넓게 퍼지나
cost_scaling_factor감소 속도 — 클수록 급격히 떨어짐
inflation_radius 크게 넓은 여유 좁은 통로 통과 어려움 inflation_radius 작게 벽에 가까이 충돌 위험 증가 cost_scaling_factor 크게 장애물 근처만 높은 비용 경로가 벽에 붙음 cost_scaling_factor 작게 넓게 완만한 비용 경로가 중앙으로

실행 확인(147차) — cost_scaling_factor를 3.0→10.0으로 바꿔 직접 비교했다. 같은 지도·같은 장애물에서 cost_scaling_factor만 10.0으로 높여 다시 기동하니 감소가 뚜렷하게 급해졌다 — 0.10 m 지점의 비용이 222(sf=3.0) → 166(sf=10.0), 0.20 m 지점은 164 → 61로 떨어졌다. "클수록 급격히 떨어진다"는 서술이 정량적으로 확인됐다.

부수 발견(운영상 중요) — cost_scaling_factor는 실행 중 ros2 param set으로 바꿔도 발행되는 코스트맵에 반영되지 않았다. 활성 상태에서 ros2 param set으로 값을 3.0→10.0으로 바꾸고(파라미터 자체는 ros2 param get으로 변경 확인됨) /global_costmap/clear_entirely_costmap까지 호출했지만, 이후 costmap_raw의 실제 비용 분포는 바뀌기 전(sf=3.0)과 똑같았다. 반면 노드를 껐다가 파라미터 파일에 새 값을 넣고 재기동하니 정상적으로 반영됐다(위 표의 sf=10.0 값). 즉 InflationLayer의 팽창 관련 파라미터는 이 환경에서 사실상 런타임 재구성이 안 되는 것으로 관찰됐다 — 소스 수준의 콜백 등록 여부까지 확인하지는 않았지만, 튜닝 중 ros2 param set으로 값을 바꿨는데 코스트맵이 안 바뀌면 "설정이 적용되지 않는다"가 아니라 "재기동이 필요한 파라미터"일 가능성을 먼저 의심하라는 실무적 함의가 있다(확인 필요: 다른 InflationLayer 파라미터·다른 레이어에도 같은 제약이 있는지). 목차 3.

견해: inflation_radius는 "로봇 반경 + 안전 여유"에서 시작한다. 로봇 반경 0.3 m에 여유 0.2 m면 0.5 m. 그 다음 cost_scaling_factor로 경로 성향을 조정한다 — 통로 중앙으로 가게 하려면 작게, 벽 가까이 지나도 되면 크게.

함정: inflation_radius가 통로 폭의 절반보다 크면 그 통로를 못 지난다. 통로 폭 1.0 m, 팽창 0.6 m면 중앙조차 비용이 높아 계획이 실패하거나 우회한다. 좁은 통로가 있는 환경에서는 팽창을 신중히 설정해야 하며, 필요하면 그 구역만 다른 설정을 쓰는 방법을 검토한다.

함정: 전역과 지역의 팽창을 다르게 설정할 수 있고, 대개 그래야 한다. 전역은 넓게(부드러운 경로), 지역은 좁게(실제 통과 가능) 두는 구성이 흔하다(확인 필요: 최적 조합은 환경 의존).

8.4.7 지우기와 유령 장애물

Nav2에서 가장 흔한 실무 문제다.

예 아니오 사람이 지나감 코스트맵에 장애물 표시 사람이 사라짐 레이 트레이싱이 그 셀을 지나가는가? ✅ 지워짐 ❌ 유령 장애물 잔존

지우기 메커니즘: 센서 광선이 어떤 셀을 통과하면, 그 셀은 비어 있는 것이다. 광선 끝의 셀만 장애물로 표시한다.

유령 장애물이 남는 원인:

원인설명
raytrace_max_range 부족먼 곳을 못 지움
센서 사각지대광선이 안 가는 곳
clearing: false지우기 비활성
inf 미처리빈 방향을 못 지움(7.5.5)
높이 필터다른 높이의 관측이 안 지움
측위 오차로봇 위치가 틀려 잘못된 곳을 지움
동적 물체가 계속 있음실제로 있는 것
판독값이 range_max와 정확히 일치(149차 소스 확인)laser_geometry가 그 빔의 점 자체를 클라우드에서 뺀다 — 광선이 안 나간다
특정 각도에서 브레젠험 지우기 경로가 그 셀을 안 지남(149차 소스 확인, 150차 각도 스윕으로 프레이밍 정정)149차는 "정면 근처만 위험"으로 봤지만, 150차의 0~45° 스윕(16개)에서 0°·17°·20°·30°·40°(31%)가 고착돼 — 정면 여부가 아니라 표시·지우기 거리 조합에 따른 산발적 이산화 공진에 가깝다

실행 확인(149차) — 위 표의 마지막 두 행은 148차가 미해결로 남긴 "유령 장애물이 안 지워짐"을 소스 계측으로 규명한 결과다. 자세한 계측·대조 실험은 8.4.5를 보라. 원시 로그는 tools/gap_harness/gap149/. 목차 3.

대처:

방법설명
raytrace_max_range 증가표시 범위보다 크게
inf_is_valid: true빈 방향 적극 지움
센서 추가·배치사각지대 축소
주기적 초기화/clear_entirely_*_costmap 서비스
복구 동작에 포함막히면 코스트맵 초기화(8.7)
# 코스트맵 수동 초기화
ros2 service call /local_costmap/clear_entirely_local_costmap \
  nav2_msgs/srv/ClearEntireCostmap "{}"
ros2 service call /global_costmap/clear_entirely_global_costmap \
  nav2_msgs/srv/ClearEntireCostmap "{}"

견해: 코스트맵 초기화를 복구 동작에 넣는 것이 표준 관행이다(8.6·8.7). 로봇이 갇혔을 때 유령 장애물이 원인인 경우가 많고, 초기화 한 번으로 풀린다. 다만 이것은 증상 완화이지 근본 해결이 아니다 — 자주 필요하다면 센서 설정이나 배치를 봐야 한다.

함정: 측위 정확도가 낮으면 지우기가 잘못된 곳에서 일어난다. 로봇이 자기 위치를 1 m 틀리게 알면, 실제 벽을 지우고 빈 곳에 장애물을 표시한다. 코스트맵 문제로 보이는 것이 실은 측위 문제인 경우가 있으므로, 8.3의 검증을 먼저 하라.

8.4.8 풋프린트

로봇의 형상을 어떻게 표현할 것인가.

# 원형 로봇
robot_radius: 0.35

# 다각형 로봇 (base_link 기준, m)
footprint: "[[0.35, 0.25], [0.35, -0.25], [-0.35, -0.25], [-0.35, 0.25]]"
footprint_padding: 0.03
방식장점단점
robot_radius단순·빠름사각 로봇에서 과보수적
footprint정확계산 증가·회전 고려 필요

견해: 원형에 가까운 로봇은 robot_radius, 길쭉한 로봇은 footprint를 쓰라. 길이 1 m·폭 0.5 m 로봇을 반경 0.55 m 원으로 근사하면, 폭 방향으로 과도하게 보수적이 되어 좁은 통로를 못 지난다.

함정: footprint를 쓰면 회전 시 충돌 검사가 달라진다. 제자리 회전할 때 길쭉한 로봇은 더 넓은 공간이 필요한데, 이것을 플래너·컨트롤러가 고려해야 한다(8.5). 홀로노믹이 아닌 로봇에서 특히 중요하다.

동적 풋프린트:

팔을 펴거나 짐을 실으면 형상이 바뀐다. footprint 토픽으로 실행 중 갱신할 수 있다(확인 필요: 토픽 이름·지원 범위).

견해: 매니퓰레이터를 탑재한 모바일 로봇에서 동적 풋프린트가 필요할 수 있다. 팔을 접었을 때와 폈을 때 안전 여유가 달라지므로, 상태에 따라 풋프린트를 바꾸는 것이 정확하다. 다만 복잡도가 늘고 플래너가 이를 어떻게 다루는지 확인이 필요하다.

8.4.9 진단

코스트맵은 시각화가 진단의 핵심이다.

# RViz2에서
# - Map 디스플레이로 /global_costmap/costmap
# - Map 디스플레이로 /local_costmap/costmap
# - 색상: 검정=장애물, 파랑/보라=팽창, 회색=미지

확인 항목:

항목확인
정적 지도 반영벽이 코스트맵에 있는가
센서 반영앞에 물건을 두면 나타나는가
지우기물건을 치우면 사라지는가
팽창 범위시각적으로 적절한가
유령 장애물없는 곳에 표시가 있는가
지면 오검출3D 센서에서 바닥이 인식되는가
갱신 속도반응이 충분히 빠른가
# 코스트맵 발행 확인
ros2 topic hz /local_costmap/costmap
ros2 topic echo /local_costmap/costmap --no-arr --once     # 7.4.10

# 관측 소스가 실제로 들어오는가
ros2 topic hz /scan
ros2 run tf2_ros tf2_echo base_link laser_link             # tf 확인

견해: "앞에 물건을 두고 코스트맵에 나타나는지 → 치우고 사라지는지" 가 가장 빠르고 확실한 검증이다. 7.1.9의 "앞에 벽" 검증과 같은 발상이며, 표시·지우기 양쪽을 한 번에 확인한다.

진단 노출(6.9):

항목이유
코스트맵 갱신 주기반응성
관측 소스별 수신 주기센서 끊김
점유 셀 비율급증하면 이상
코스트맵 초기화 횟수유령 장애물 빈도

8.4.10 함정 카탈로그

#함정증상대처
1raytrace_max_range < obstacle_max_range유령 장애물지우기 범위를 크게
2clearing: false장애물 누적켜기
3inf 미처리빈 방향 못 지움inf_is_valid(7.5.5)
43D 센서 min_obstacle_height 낮음지면이 장애물5~15 cm로
5max_obstacle_height 높음천장·조명 장애물로봇 높이 기준
6inflation_radius > 통로 폭 절반좁은 통로 통과 불가통로 폭 확인
7전역·지역 팽창 동일최적 아님용도별 설정
8plugins 이름과 키 불일치레이어 미로드이름 대응
9InflationLayer가 마지막이 아님팽창 미적용순서 확인
10지역 코스트맵을 map 기준으로재측위 시 점프odom 기준
11원형 근사를 길쭉한 로봇에과보수적footprint
12측위 문제를 코스트맵 문제로 오해해결 안 됨8.3 먼저
13코스트맵 초기화로 근본 해결 시도반복 발생센서·설정 점검
14시각 검증 생략원인 파악 지연RViz2 관찰

8.4.11 체크포인트

  • 코스트맵 셀 값 다섯 종류와 253·254의 차이를 말할 수 있는가. 플래너는 어디부터 통과 불가로 보는가.
  • NO_INFORMATION의 처리가 설정에 따라 어떻게 달라지며, 그 함의는?
  • 레이어 여섯 종류와 각각의 역할을 말할 수 있는가. InflationLayer의 순서가 왜 중요한가.
  • 전역·지역 코스트맵의 설정 차이 여섯 가지를 말할 수 있는가. 지역이 odom 기준인 이유는?
  • 지역 코스트맵 크기를 정하는 기준은?
  • marking·clearing·obstacle_max_range·raytrace_max_range의 관계와, 잘못 설정했을 때의 증상은?
  • inf_is_valid의 두 설정과, 켜기 전에 확인해야 할 것은?
  • 3D 센서에서 지면이 장애물로 인식되는 문제와 대처는?
  • 팽창 비용 함수와 두 파라미터의 역할을 설명할 수 있는가. 경로를 통로 중앙으로 보내려면?
  • inflation_radius가 통로 폭보다 클 때의 문제는?
  • 유령 장애물의 원인 일곱 가지를 들 수 있는가. 코스트맵 초기화가 근본 해결이 아닌 이유는?
  • 측위 오차가 코스트맵에 만드는 문제는?
  • 풋프린트 두 방식의 선택 기준과, 다각형 사용 시 추가 고려사항은?
  • 코스트맵 검증의 가장 빠른 방법은?

다음 장 8.5는 이 코스트맵 위에서 경로를 만들고 따라가는 플래너·컨트롤러 플러그인을 다룬다. 로봇 기구학에 맞는 선택이 핵심이다.