TIKI-MINI로 시작하는 ROS2 실무
목차

2. 시뮬레이션

2.11지도 만들기

2.10장에서 slam_toolbox의 설정을 읽었지만 지도가 실제로 만들어지는 과정은 보지 못했습니다. 이 장에서는 그 일부를 직접 짭니다. 위치를 보정하는 부분은 빼고, /odom이 알려 주는 위치를 믿은 채 /scan을 격자 위에 쌓는 가장 단순한 맵핑입니다. 이 단순한 방식이 어디서 무너지는지 보면 SLAM이 하는 일이 분명해집니다.

점유 격자 지도

로봇이 쓰는 지도는 대개 점유 격자(occupancy grid)입니다. 평면을 같은 크기의 정사각형 칸으로 나누고, 칸마다 "무언가 있는가"를 하나의 숫자로 적습니다. ROS2에서는 nav_msgs/msg/OccupancyGrid로 주고받습니다.

필드뜻
info.resolution한 칸의 한 변 길이(m). 이 장의 예제는 0.1
info.width, info.height가로, 세로 칸 수. 이 장은 80 x 60
info.origin칸 (0, 0)의 지도 좌표상 위치. 왼쪽 아래 모서리입니다
data칸 값의 1차원 배열. 행 우선이라 칸 (cx, cy)는 data[cy * width + cx]
칸 좌표와 data 배열 위치01234567891011121314151617181920212223012345cx0123cyorigin: 칸 (0, 0)의 왼쪽 아래 모서리data (행 우선 1차원 배열)01234567891011…cy = 0 행cy = 1 행칸 (2, 1) → data[1 × 6 + 2]= data[8]예시 격자: width 6, height 4
칸 (cx, cy)가 1차원 배열 data의 몇 번째 값인지 보여 줍니다. 원점이 있는 아래 행부터 한 행씩 배열에 이어 붙이며, 그림은 width 6인 작은 예시입니다.

칸 값은 0에서 100까지가 점유 확률(%)이고 -1은 모른다는 뜻입니다. 이 장에서는 -1(모름), 0(비어 있음), 100(점유) 세 값만 씁니다. 해상도를 절반으로 줄이면 칸 수가 4배가 되므로 같은 8 m x 6 m라도 80 x 60 = 4800칸이 160 x 120 = 19200칸이 됩니다.

스캔 한 번이 지도를 바꾸는 법

로봇이 (x, y)에서 각도 a 방향으로 쏜 빔이 거리 r에서 반사됐다고 합시다. 이 사실은 두 가지를 알려 줍니다.

  • 로봇에서 반사점까지 빔이 지나간 칸에는 아무것도 없습니다.
  • 반사점이 들어 있는 칸에는 무언가 있습니다.

그래서 로봇이 있는 칸에서 반사점의 칸까지 직선을 그어, 반사점 칸을 뺀 나머지는 "비어 있음"으로, 반사점 칸은 "점유"로 표시합니다. 두 칸 사이의 직선이 지나는 칸을 구하는 데는 브레젠험(Bresenham) 알고리즘을 씁니다. 예제의 line_cells()가 이 함수입니다.

빔 하나가 격자를 바꾸는 모습로봇 칸: 빈 칸 쪽으로빔이 지나간 칸: L_FREE -0.4반사점 칸: L_OCC +0.85나머지 칸: 그대로지나는 칸 목록은 line_cells()가브레젠험 알고리즘으로 구합니다반사점 뒤 칸은 이 빔으로 바뀌지 않음
빔 하나가 바꾸는 칸입니다. 로봇 칸부터 반사점 앞 칸까지는 L_FREE를, 반사점이 든 칸에는 L_OCC를 더하고 그 뒤의 칸은 건드리지 않습니다.

한 번의 관측을 그대로 믿으면 노이즈 하나에 칸이 뒤집힙니다. 그래서 칸마다 값을 하나 두고 관측할 때마다 조금씩 더하고 뺍니다. 확률 p를 ln(p / (1 - p))로 바꾼 값을 로그 오즈라고 하는데, 이 값은 관측을 단순한 덧셈으로 합칠 수 있어서 맵핑에 자주 쓰입니다. 예제의 상수는 이렇습니다.

상수값확률로 읽으면
L_OCC+0.85점유로 관측 한 번에 약 70%
L_FREE-0.4빈 칸으로 관측 한 번에 약 40%
L_MIN, L_MAX-4, +4한 칸이 한없이 확신하지 않도록 자른 한계

로그 오즈가 0이면 아직 관측이 없는 칸이고 -1(모름)로 발행합니다. 0보다 크면 100, 작으면 0입니다. 같은 칸을 여러 번 보면 값이 한계까지 쌓이므로 한두 번 잘못 본 빔은 나중의 관측에 묻힙니다.

지도를 쌓는 노드

아래 코드는 노드 하나가 세 가지 일을 합니다. /odom으로 위치를 받고, /scan을 격자에 쌓고, 1초에 한 번 /map을 발행합니다. 로봇을 움직이는 drive()도 같은 노드에 들어 있어서 코스 안쪽을 한 바퀴 돕니다. 2.7장에서 /odom을 보고 정해진 경로를 달린 것과 같은 방식입니다.

LIVE

1) 오도메트리를 믿고 지도 쌓기

실행이 끝나면 결과 화면에 지도 (/map) 탭이 자동으로 열립니다. 재생 막대를 움직이거나 재생 버튼을 누르면 지도가 자라는 과정을 볼 수 있습니다. 파란 선은 로봇이 지나온 경로입니다. 지도 + 3D 탭에서는 지도 아래에 URDF 3D 화면이 같이 나옵니다. 3D 화면에서는 로봇이 쌓은 지도가 청록색 기둥과 바닥으로 겹쳐 보이므로 어두운 실제 벽과 비교할 수 있습니다.

직접 바꿔 보기

  • 그대로 실행하면 80초 시점에 알려진 칸이 76.9%(3692/4800), 점유 칸이 385개입니다. 로봇은 76.6초에 마지막 경유점에서 멈추고 충돌은 0회입니다. 결과 화면에 미션 결과: 도착이 나오지만 course 월드의 목표 원 옆을 지나갈 때 찍힌 것이고 이 장의 주제와는 상관없습니다.
  • 재생을 10초에 맞추면 알려진 칸이 57.6%입니다. 출발하자마자 정면 벽과 장애물이 한꺼번에 보이기 때문입니다. 20초에 71.1%, 40초에 76.6%이고 이후로는 거의 늘지 않습니다. 라이다가 닿지 않는 모서리 뒤와 장애물 뒤쪽은 끝까지 모르는 칸으로 남습니다.
  • 정답 지도 겹쳐 보기를 켜면 점선으로 진짜 벽과 장애물이 그려집니다. 검은 칸이 점선 위에 얹혀 있는지 확인하세요. 예제 결과에서 점유 칸 385개 전부가 실제 벽이나 장애물 칸에서 한 칸 이내에 있었습니다.
  • RES를 0.05로, W와 H를 160과 120으로 바꾸면 칸 수가 19200개가 되어 벽이 더 가늘게 그려지고 계산량이 늘어납니다.
  • range(0, len(scan.ranges), 3)의 3을 1로 바꾸면 빔을 모두 쓰므로 지도는 조금 더 촘촘하지만 실행이 느려집니다.

오도메트리가 틀리면

위 코드는 /odom의 위치를 정답으로 믿습니다. 현실의 바퀴 오도메트리는 미끄러짐과 바퀴 지름 오차 때문에 조금씩 어긋나고, 어긋남은 이동할수록 쌓입니다. 에뮬레이터에는 /odom의 증분(이동한 거리와 회전한 각도)에 노이즈를 섞는 odomNoise 옵션이 있습니다. 같은 코드를 노이즈 15%로 돌려 봅니다.

LIVE

2) 오도메트리 오차 15%로 같은 코드 돌리기

정답 지도 겹쳐 보기를 켜 보세요. 로봇은 자기 위치를 잘못 알고 있어서 같은 벽이 두 겹, 세 겹으로 그려집니다.

직접 바꿔 보기

  • 80초 시점의 알려진 칸은 80.0%(3841/4800), 점유 칸은 467개입니다. 노이즈가 없을 때(385개)보다 점유 칸이 82개 많은 이유는 같은 벽이 어긋난 자리에 여러 번 그려졌기 때문입니다. 점유 칸 중 실제 벽에서 한 칸 이내에 있는 비율은 약 65%로 1번 실행의 100%보다 크게 떨어졌습니다.
  • odomNoise를 0.05로 바꿔 보세요. 점유 칸 중 정답에 맞는 비율이 약 97%로 줄어듭니다. 5%에서는 지도가 아직 거의 맞지만 오차가 커질수록 빠르게 무너집니다. 이 숫자들은 고정 시드(12345)로 계산된 값이라 같은 코드는 매번 같은 결과가 나옵니다.
  • 로봇은 노이즈가 섞인 /odom 기준으로 경유점을 따라가므로 실제 경로가 계획과 달라집니다. 오차가 더 크면 벽에 부딪힐 수도 있습니다.

이 문제를 푸는 방법이 SLAM입니다. 2.10장에서 본 slam_toolbox는 새 스캔을 이전 스캔들과 겹쳐 보고(스캔 매칭) /odom이 틀렸을 때 그만큼을 고쳐서 지도에 붙입니다. 한 바퀴 돌아 처음 보던 곳을 다시 보면(루프 클로저) 그동안 쌓인 오차를 한꺼번에 줄입니다. 이 장의 맵핑은 그 보정 단계를 뺀 것이어서 위치를 잘못 알면 지도가 그대로 틀립니다.

실제 로봇에서는

실제 TIKI-MINI에서는 이 코드를 쓰지 않고 개발 PC에서 tiki_slam의 slam_toolbox를 띄웁니다. 입력은 같고(/scan, odom → base_footprint TF) 출력도 같습니다(/map과 map → odom TF). 지도를 파일로 저장하는 방법은 4.7장에서 다룹니다.

RViz2에서 /map을 볼 때는 Map 디스플레이의 Durability를 Transient Local로 맞추는 것이 일반적입니다. 지도 퍼블리셔는 보통 마지막 지도를 보관해 두는 transient local 방식으로 발행하는데, 구독하는 쪽이 Volatile이면 이미 발행된 지도를 받지 못해 아무것도 보이지 않을 수 있습니다. 에뮬레이터는 QoS를 구분하지 않으므로 이 차이는 실기기에서만 겪습니다.

한눈에 정리

  1. 점유 격자 지도는 resolution, width, height, origin과 행 우선 data(-1 모름, 0 빈 칸, 100 점유)로 이루어진다. 칸 (cx, cy)는 data[cy * width + cx]다.
  2. 스캔 빔 하나는 로봇 칸부터 반사점 앞까지를 빈 칸으로, 반사점 칸을 점유로 바꾼다. 직선이 지나는 칸은 브레젠험 알고리즘으로 구한다.
  3. 칸마다 로그 오즈를 두고 관측마다 L_OCC나 L_FREE를 더하면 한두 번의 잘못된 관측이 누적된 값에 묻힌다. 한계(L_MIN, L_MAX)로 한없이 쌓이는 것을 막는다.
  4. inf로 읽힌 빔은 "범위 안에 반사가 없음"일 뿐이므로 지도에 쓰지 않는다.
  5. 위치를 /odom에 맡긴 맵핑은 오도메트리 오차가 그대로 지도 오차가 된다. course 월드에서 오차 0%일 때 점유 칸의 100%가 정답 근처였지만 15%에서는 65%로 떨어졌다.
  6. SLAM은 스캔 매칭과 루프 클로저로 오도메트리 오차를 고쳐 지도에 붙인다.
  7. 실기기에서 RViz2에 /map이 보이지 않으면 Map 디스플레이의 Durability(Transient Local)부터 확인한다.

확인 문제

Q1. 해상도 0.1 m인 지도에서 가로 8 m, 세로 6 m를 덮으려면 width와 height는 얼마여야 하나요? 해상도를 0.05 m로 바꾸면 data 배열의 길이는 어떻게 되나요?

0.1 m에서는 width 80, height 60이고 data 길이는 4800입니다. 0.05 m로 바꾸면 width 160, height 120이 되어 길이가 19200으로 4배가 됩니다. 칸의 한 변이 절반이 되면 가로와 세로 칸 수가 모두 두 배가 되기 때문입니다.

Q2. 스캔에서 어떤 빔이 inf였습니다. 이 빔이 지나간 칸을 모두 비어 있음으로 표시하면 안 되는 이유는 무엇인가요?

inf는 "범위 안에 반사가 없다"는 뜻이지만 범위 밖(range_max 너머)이거나 센서 앞 range_min 안쪽 사각지대에서도 inf가 나옵니다. 벽 바로 앞에서 정면 빔이 inf로 읽힐 수 있으므로 이 빔을 빈 칸으로 쓰면 벽 앞의 점유 칸이 지워질 수 있습니다. 그래서 예제는 inf 빔을 건너뜁니다.

Q3. 오도메트리 오차 15%에서 정답 벽이 두 겹 세 겹으로 그려졌습니다. 이 현상의 원인과 SLAM이 해결하는 방식을 설명해 보세요.

로봇이 자기 위치를 잘못 알고 있어서, 같은 벽을 볼 때마다 다른 위치에서 본 것처럼 격자에 쌓았기 때문입니다. slam_toolbox는 새 스캔을 이전 스캔과 겹쳐 보고 가장 잘 맞는 이동량을 찾아(스캔 매칭) /odom의 오차를 고친 뒤에 지도에 붙입니다. 오래전에 본 곳을 다시 보면(루프 클로저) 쌓인 오차를 한꺼번에 줄입니다.

다음 장

2.12장에서는 만들어진 지도를 가지고 로봇이 목표까지 가는 Nav2의 구성 요소와 nav2_params.yaml을 읽습니다.