제7부 센서·인지 파이프라인
7.5 LiDAR·LaserScan 처리
7.5.1 이 장에서 배우는 것 / 선수 지식
라이다는 이동 로봇에서 가장 널리 쓰이는 센서다. SLAM(8.2)·측위(8.3)·코스트맵(8.4)이 전부 여기 의존한다. 그런데 라이다에는 다른 센서에 없는 고유한 문제가 있다 — 스캔이 순간에 일어나지 않는다.
10 Hz 라이다는 한 바퀴에 100 ms가 걸린다. 그동안 로봇이 1 m/s로 움직이면 스캔 시작점과 끝점 사이에 10 cm의 위치 차이가 있다. 이것을 무시하면 벽이 휘어 보이고, SLAM이 어긋나고, 빠른 회전에서 지도가 흐려진다.
이 장은 그 문제와 대처를 중심으로 라이다 데이터를 다룬다.
배우는 것:
LaserScan구조 — 각도·거리·시간 필드의 정확한 의미.- 시간 규약 —
scan_time·time_increment가 왜 있는가. - 운동 왜곡 — 발생 원리와 크기 추정.
- 디스큐(deskew) — 보정 방법과 도구.
- 무효 값 처리 —
inf·NaN·범위 밖. - 변환 도구 —
laser_geometry·pointcloud_to_laserscan. - 필터 —
laser_filters와 흔한 구성. - 3D 라이다 — 2D와 다른 점.
선수 지식: 7.1(센서 규약), 7.4(PointCloud2), 5.1·5.2(tf·시간), 1.10(시계).
세 패키지 모두 Jazzy apt에 실재한다(실행 확인) —
ros-jazzy-laser-filters·ros-jazzy-pointcloud-to-laserscan·ros-jazzy-laser-geometry. 파라미터 이름·형식은 버전 의존이므로 각 절의(확인 필요)는 그대로 둔다.
7.5.2 LaserScan 구조
header:
stamp: {...} # ★ 스캔 시작 시각 (7.5.3)
frame_id: "laser_link"
angle_min: -3.14159 # rad
angle_max: 3.14159 # rad
angle_increment: 0.0087 # rad (약 0.5도)
time_increment: 0.000139 # 측정 간 시간(초)
scan_time: 0.1 # 스캔 한 바퀴 시간(초)
range_min: 0.15 # m — 이보다 가까우면 무효
range_max: 12.0 # m — 이보다 멀면 무효
ranges: [1.2, 1.3, inf, ...] # m, 개수 = (angle_max-angle_min)/angle_increment + 1
intensities: [...] # 선택
각도 → 인덱스 관계:
$$\theta_i = \text{angle\_min} + i \cdot \text{angle\_increment}$$
$$i = \frac{\theta - \text{angle\_min}}{\text{angle\_increment}}$$
직교 좌표 변환:
$$x_i = r_i \cos\theta_i, \qquad y_i = r_i \sin\theta_i, \qquad z_i = 0$$
이 좌표는 frame_id(센서 프레임) 기준이며 REP 103을 따른다(7.1.2) — x가 앞, y가 왼쪽, 반시계가 양의 각도.
for (size_t i = 0; i < scan->ranges.size(); ++i) {
const float r = scan->ranges[i];
if (!std::isfinite(r) || r < scan->range_min || r > scan->range_max) {
continue; // 무효(7.5.5)
}
const float theta = scan->angle_min + i * scan->angle_increment;
const float x = r * std::cos(theta);
const float y = r * std::sin(theta);
// ...
}
함정:
angle_increment가 음수일 수 있다. 센서가 시계 방향으로 스캔하면angle_min > angle_max이고 증분이 음수다. 인덱스 계산이 그대로 동작하지만,angle_min < angle_max를 가정한 코드는 깨진다.
함정:
ranges의 길이를 각도 범위로 계산해 검증하라. 드라이버가 잘못 채우는 경우가 있고, 그러면 마지막 몇 점이 잘못된 각도로 해석된다.
7.5.3 시간 규약 — 왜 두 개인가
LaserScan에는 시간 필드가 셋이다.
| 필드 | 의미 |
|---|---|
header.stamp | 첫 번째 측정의 시각 |
time_increment | 연속한 두 측정 사이의 시간 |
scan_time | 스캔 한 바퀴에 걸린 시간(또는 스캔 간 주기) |
$$t_i = \text{stamp} + i \cdot \text{time\_increment}$$
즉 각 점이 서로 다른 시각에 측정됐다는 것을 메시지가 명시한다.
정직:
header.stamp가 스캔 시작인지 끝인지 드라이버마다 다르다(확인 필요). REP·메시지 정의는 시작을 의도하지만, 실제로는 끝 시각이나 중간 시각을 넣는 드라이버가 있다. 이것이 틀리면 디스큐와 tf 조회가 체계적으로 어긋난다 — 새 라이다를 장착할 때 확인해야 할 항목이다.
함정:
time_increment가 0인 드라이버가 흔하다. "모든 점이 동시에 측정됐다"는 뜻이 되어, 디스큐가 불가능해진다. 물리적으로 사실이 아니므로, 정확한 디스큐가 필요하면 드라이버를 고치거나 직접 계산해야 한다.
$$\text{time\_increment} \approx \frac{\text{scan\_time}}{N_{\text{points}}}$$
7.5.4 운동 왜곡
로봇이 움직이는 동안 스캔이 진행되면 점들이 서로 다른 위치에서 측정된다.
왜곡 크기 추정:
| 운동 | 왜곡 |
|---|---|
| 병진 속도 $v$ | $\Delta x \approx v \cdot T_{\text{scan}}$ |
| 회전 속도 $\omega$ | $\Delta\theta \approx \omega \cdot T_{\text{scan}}$ |
구체적으로:
| 상황 | 스캔 시간 | 왜곡 |
|---|---|---|
| 1 m/s 직진, 10 Hz | 100 ms | 10 cm |
| 0.5 rad/s 회전, 10 Hz | 100 ms | 0.05 rad(2.9도) |
| 2 rad/s 회전, 10 Hz | 100 ms | 0.2 rad(11.5도) |
| 1 m/s, 40 Hz | 25 ms | 2.5 cm |
회전이 병진보다 훨씬 심각하다. 10 m 거리의 점이 2.9도 회전하면 50 cm 어긋난다.
$$\text{끝점 오차} \approx r \cdot \omega \cdot T_{\text{scan}}$$
증상:
| 증상 | 상황 |
|---|---|
| 벽이 휘어 보임 | 직진 중 |
| 벽이 두 겹으로 | 회전 중, 스캔 경계에서 |
| SLAM 지도가 흐려짐 | 빠른 회전 |
| 스캔 매칭 실패 | 왜곡이 큰 경우 |
| 정지 시에는 정상 | 왜곡은 운동 중에만 |
견해: "정지하면 정상인데 움직이면 지도가 이상하다"는 운동 왜곡의 전형적 신호다. 이 증상이 보이면 디스큐를 먼저 검토하라. SLAM 파라미터를 아무리 튜닝해도 입력이 왜곡되어 있으면 한계가 있다.
7.5.5 무효 값 처리
모든 측정이 유효한 것은 아니다.
| 값 | 의미 | 처리 |
|---|---|---|
inf(또는 range_max 초과) | 아무것도 없음(측정 실패·너무 멂) | "빈 공간"으로 해석 |
NaN | 알 수 없음 | "정보 없음"으로 해석 |
< range_min | 너무 가까움 | 무효 |
0.0 | range_min 미만이므로 버려야 할 값 — LaserScan.msg가 "values < range_min or > range_max should be discarded"라고 명시(실행 확인) | 그대로 쓰지 말 것 |
inf와 NaN의 구분이 중요하다.
함정:
inf를NaN처럼 버리면 자유 공간 정보를 잃는다. 코스트맵(8.4)은 "레이가 통과했다"는 정보로 장애물을 지우는데,inf를 무시하면 지워지지 않아 유령 장애물이 남는다.
함정: 반대로
NaN을inf처럼 처리하면 없는 자유 공간을 만든다. 센서가 못 본 곳을 "비어 있다"고 하면 위험하다.
// 올바른 구분
if (std::isinf(r)) {
mark_free_up_to(theta, scan->range_max); // 최대 거리까지 비어 있음
} else if (std::isnan(r)) {
// 아무것도 하지 않음 — 정보 없음
} else if (r >= scan->range_min && r <= scan->range_max) {
mark_obstacle(theta, r);
mark_free_up_to(theta, r); // 그 앞은 비어 있음
}
정직: 드라이버가 규약을 안 지키는 경우가 흔하다. 측정 실패를 0으로, 또는
range_max로, 또는range_max + 1로 채우는 드라이버가 있다. 새 라이다를 장착하면 빈 공간을 향하게 하고 값을 확인하라.
# 무효 값 확인
ros2 topic echo /scan --field ranges --once | head -c 500
7.5.6 laser_geometry — 스캔을 구름으로
디스큐를 포함한 변환을 제공한다.
#include "laser_geometry/laser_geometry.hpp"
laser_geometry::LaserProjection projector_;
// ① 단순 변환 — 디스큐 없음
sensor_msgs::msg::PointCloud2 cloud;
projector_.projectLaser(*scan, cloud);
// ② tf를 이용한 고정밀 변환 — 각 점의 시각을 반영(디스큐)
projector_.transformLaserScanToPointCloud(
"odom", *scan, cloud, *tf_buffer_);
②가 디스큐를 수행한다. 각 점의 시각 $t_i$에서의 변환을 tf에서 조회해 적용한다 — 그래서 tf 버퍼에 스캔 기간 전체의 변환이 있어야 한다.
| 요구 | 이유 |
|---|---|
| tf 버퍼에 스캔 시작~끝 변환 | 각 점 시각의 자세가 필요 |
time_increment가 정확 | 점별 시각 계산 |
| 충분한 tf 발행 주기 | 보간 정확도(5.1.5) |
함정:
transformLaserScanToPointCloud는 스캔 끝 시각의 tf가 있어야 하므로 대기가 필요하다. 스캔을 받자마자 호출하면 아직 그 시각의 오도메트리가 안 왔을 수 있고,into the future오류가 난다(5.2.2.1). 타임아웃을 주거나 한 스캔 늦게 처리한다.
견해: 디스큐를 하려면 오도메트리 발행 주기가 충분해야 한다. 스캔 기간 동안 tf 샘플이 2개뿐이면 선형 보간의 오차가 크다. 100 Hz 이상 오도메트리가 있으면 10 Hz 스캔의 디스큐가 정확해진다(5.5.6의 논리와 같다).
pointcloud_to_laserscan — 반대 방향:
Node(
package='pointcloud_to_laserscan',
executable='pointcloud_to_laserscan_node',
parameters=[{
'target_frame': 'base_link',
'min_height': -0.1,
'max_height': 0.5, # 이 높이 구간만 2D로 투영
'angle_min': -3.14,
'angle_max': 3.14,
'angle_increment': 0.0087,
'range_min': 0.2,
'range_max': 20.0,
'use_inf': True, # 빈 방향을 inf로(7.5.5)
}],
remappings=[('cloud_in', '/points'), ('scan', '/scan')],
)
용도: 3D 라이다·RGB-D를 2D SLAM·Nav2에 쓰기 위해. slam_toolbox(8.2)와 AMCL(8.3)은 LaserScan을 기대한다.
함정:
min_height·max_height구간 선택이 결과를 좌우한다. 너무 좁으면 장애물을 놓치고, 너무 넓으면 지면이나 천장이 장애물로 인식된다. 로봇 높이와 관심 장애물에 맞춰 정하라.
함정:
use_inf를 끄면 빈 방향이range_max로 채워진다. 그러면 코스트맵이 최대 거리에 유령 벽을 만들 수 있다(확인 필요: 소비자 해석에 따라 다름).
7.5.7 laser_filters
스캔 전처리 필터 체인이다.
# config/laser_filters.yaml
scan_filter_chain:
ros__parameters:
filter1:
name: range_filter
type: laser_filters/LaserScanRangeFilter
params:
lower_threshold: 0.2
upper_threshold: 15.0
filter2:
name: angle_filter
type: laser_filters/LaserScanAngularBoundsFilterInPlace
params:
lower_angle: -2.5 # 로봇 뒤쪽 제외
upper_angle: 2.5
filter3:
name: shadow_filter
type: laser_filters/ScanShadowsFilter
params:
min_angle: 10
max_angle: 170
window: 1
neighbors: 0
정직: 필터 이름·파라미터 형식은 버전에 따라 다르다
(확인 필요). 개념은 안정적이다.
| 필터 | 목적 |
|---|---|
| Range | 범위 밖 제거 |
| AngularBounds | 특정 각도 제외(로봇 자체 구조물) |
| ShadowsFilter | 물체 가장자리의 유령 점 제거 |
| SpeckleFilter | 고립된 노이즈 점 |
| Footprint | 로봇 자기 몸체 제거 |
| Intensity | 강도 기준 필터 |
견해: 각도 필터가 실무에서 가장 자주 필요하다. 라이다가 로봇 몸체·케이블·마스트를 보는 경우가 흔하고, 그것이 코스트맵에 상시 장애물로 인식되면 로봇이 갇힌다. 설치 후 RViz2에서 자기 구조물이 보이는지 확인하고 각도로 잘라내라.
함정: 필터로 자른 영역은 "정보 없음"이지 "비어 있음"이 아니다. 잘린 각도를
inf로 채우면 그 방향이 자유 공간이 되어 위험하다.NaN이 안전하지만 필터 구현마다 다르다(확인 필요).
7.5.8 3D 라이다
2D와 다른 점들이다.
| 축 | 2D 라이다 | 3D 라이다 |
|---|---|---|
| 메시지 | LaserScan | PointCloud2(7.4) |
| 점 수 | 수백~수천 | 수만~수십만 |
| 시간 필드 | time_increment | 점별 time 필드(있으면) |
| 디스큐 | laser_geometry | 직접 구현·SLAM이 담당 |
| 링 정보 | 없음 | ring 필드 |
점별 시각:
많은 3D 라이다가 time 필드를 제공한다 — 스캔 시작 기준 상대 시각(초).
sensor_msgs::PointCloud2ConstIterator<float> it_t(cloud, "time");
// t_point = header.stamp + *it_t
함정:
time필드의 기준과 단위가 벤더마다 다르다(확인 필요). 절대 시각인지 상대인지, 초인지 나노초인지 확인해야 한다. 틀리면 디스큐가 오히려 왜곡을 키운다.
디스큐 구현 개념:
// 각 점을 그 시각의 자세로 보정 (개념)
for (each point) {
const rclcpp::Time t_point = stamp + rclcpp::Duration::from_seconds(*it_time);
// 스캔 끝 시각 기준으로 통일
auto tf = tf_buffer_->lookupTransform(
target_frame, t_ref, // 기준 시각
cloud.header.frame_id, t_point, // 이 점의 시각
"odom"); // 고정 프레임(5.1.8)
transform_point(*it_x, *it_y, *it_z, tf);
}
5.1.8의 "서로 다른 시각 조회" API가 여기서 쓰인다.
정직: 점마다 tf를 조회하면 매우 느리다. 실무 구현은 몇 개 구간으로 나눠 보간하거나, 각속도·선속도로 근사한다. 대부분의 3D SLAM 패키지(예: LIO 계열)가 이를 내장하므로, 직접 구현하기 전에 사용하는 SLAM이 디스큐를 하는지 확인하라.
견해: 3D 라이다 디스큐는 IMU와 함께 하는 것이 표준적 방법이다. 오도메트리보다 IMU가 고빈도(수백 Hz)이므로 스캔 기간의 자세 변화를 정확히 알 수 있다. 이것이 LIO(LiDAR-Inertial Odometry) 계열이 성능이 좋은 이유 중 하나다.
7.5.9 진단·검증
# 기본 확인
ros2 topic hz /scan
ros2 topic echo /scan --no-arr --once # 구조·파라미터(7.4.10)
# 시간 필드 확인 — 디스큐 가능 여부
ros2 topic echo /scan --field time_increment --once
ros2 topic echo /scan --field scan_time --once
# 무효 값 규약 확인
ros2 topic echo /scan --field ranges --once | head -c 300
검증 항목:
| 항목 | 방법 |
|---|---|
| 방향 | 앞에 벽 → +x에 점(7.1.9) |
| 각도 범위 | angle_min·max가 사양과 맞는가 |
| 거리 정확도 | 줄자로 측정한 거리와 비교 |
| 무효 값 | 빈 공간을 향했을 때 inf인가 |
| 시간 필드 | time_increment가 0이 아닌가 |
| 자기 구조물 | RViz2에서 로봇 몸체가 보이는가 |
| 운동 왜곡 | 회전하며 벽이 두 겹으로 보이는가 |
견해: "제자리 회전하며 RViz2 관찰"이 운동 왜곡 검증의 표준 방법이다. 정지 시 선명한 벽이 회전 중 흐려지거나 두 겹으로 보이면 왜곡이 있는 것이다. 회전 속도를 높여 가며 어느 지점부터 문제가 되는지 확인하면, 운영 속도 제한의 근거가 된다.
진단 노출(6.9):
| 항목 | 이유 |
|---|---|
| 스캔 주파수 | 센서 정상 여부 |
| 유효 점 비율 | 오염·고장 조기 탐지 |
| 최대·최소 거리 분포 | 환경 변화 |
time_increment 유효성 | 디스큐 가능 여부 |
견해: "유효 점 비율"이 라이다 진단에서 유용하다. 렌즈가 더러워지거나 창이 긁히면 유효 측정이 줄어드는데, 완전히 고장 나기 전에 감지할 수 있다.
7.5.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | angle_min < angle_max 가정 | 시계 방향 센서에서 깨짐 | 부호 무관 계산 |
| 2 | inf를 버림 | 유령 장애물 잔존 | 자유 공간으로 해석 |
| 3 | NaN을 inf처럼 | 없는 자유 공간 생성 | 구분 처리 |
| 4 | 드라이버의 무효 값 규약 미확인 | 오해석 | 실측 확인 |
| 5 | header.stamp 기준 미확인 | 디스큐·tf 체계적 오차 | 드라이버 확인 |
| 6 | time_increment가 0 | 디스큐 불가 | 계산·드라이버 수정 |
| 7 | 운동 왜곡 무시 | 회전 시 지도 흐려짐 | 디스큐(7.5.6) |
| 8 | 디스큐 시 tf 대기 없음 | 외삽 오류(5.2) | 타임아웃·지연 처리 |
| 9 | 오도메트리 주기 부족 | 디스큐 보간 오차 | 100 Hz 이상 |
| 10 | 자기 구조물 미제거 | 상시 장애물·로봇 갇힘 | 각도 필터 |
| 11 | 필터 영역을 inf로 | 가짜 자유 공간 | NaN 검토 |
| 12 | pointcloud_to_laserscan 높이 부적절 | 장애물 누락·지면 오검출 | 높이 구간 조정 |
| 13 | 3D time 필드 기준 오해 | 디스큐가 왜곡을 키움 | 벤더 문서 확인 |
| 14 | SLAM의 디스큐 여부 미확인 | 중복 보정·미보정 | 패키지 확인 |
7.5.11 체크포인트
LaserScan의 각도 필드에서 인덱스↔각도 변환식을 쓸 수 있는가.angle_increment가 음수일 수 있다는 사실의 함의는?- 시간 필드 세 가지의 의미와 점별 시각 계산식을 말할 수 있는가.
header.stamp의 기준이 드라이버마다 다를 수 있다는 사실이 왜 중요한가.- 운동 왜곡의 발생 원리와, 병진·회전 각각의 크기 추정식을 말할 수 있는가. 왜 회전이 더 심각한가.
- 운동 왜곡의 전형적 증상 다섯 가지와, "정지하면 정상이다"가 무엇의 신호인가.
inf와NaN의 의미 차이와, 각각을 잘못 처리했을 때의 결과를 말할 수 있는가.transformLaserScanToPointCloud가 디스큐를 하는 원리와, 필요한 세 가지 조건은?pointcloud_to_laserscan의 용도와, 높이 구간 설정이 왜 중요한가.- 각도 필터가 실무에서 자주 필요한 이유는? 필터로 자른 영역을 어떻게 채워야 하는가.
- 3D 라이다가 2D와 다른 다섯 가지를 말할 수 있는가. 점별 디스큐를 그대로 구현하면 왜 느린가.
- 3D 디스큐에 IMU를 쓰는 것이 표준적 방법인 이유는?
- 운동 왜곡을 검증하는 표준 방법과, 그 결과를 무엇에 활용하는가.
다음 장 7.6는 이 스캔과 짝을 이루는 관성·오도메트리 융합을 다룬다. IMU의 특성, Odometry 메시지의 두 부분, 그리고 robot_localization 구성.