AEGLOSLAB ROS 2 Jazzy 대전

제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}$$

즉 각 점이 서로 다른 시각에 측정됐다는 것을 메시지가 명시한다.

t=0.000s: ranges[0] 측정 t=0.000139s: ranges[1] ... t=0.0999s: ranges[719] header.stamp = 0.000s (첫 점 기준)

정직: header.stamp가 스캔 시작인지 끝인지 드라이버마다 다르다(확인 필요). REP·메시지 정의는 시작을 의도하지만, 실제로는 끝 시각이나 중간 시각을 넣는 드라이버가 있다. 이것이 틀리면 디스큐와 tf 조회가 체계적으로 어긋난다 — 새 라이다를 장착할 때 확인해야 할 항목이다.

함정: time_increment가 0인 드라이버가 흔하다. "모든 점이 동시에 측정됐다"는 뜻이 되어, 디스큐가 불가능해진다. 물리적으로 사실이 아니므로, 정확한 디스큐가 필요하면 드라이버를 고치거나 직접 계산해야 한다.

$$\text{time\_increment} \approx \frac{\text{scan\_time}}{N_{\text{points}}}$$

7.5.4 운동 왜곡

로봇이 움직이는 동안 스캔이 진행되면 점들이 서로 다른 위치에서 측정된다.

t=0: 로봇 위치 P0 스캔 시작 t=50ms: 로봇 위치 P1 스캔 중간 t=100ms: 로봇 위치 P2 스캔 끝 모든 점을 P0 기준으로 해석 → 왜곡

왜곡 크기 추정:

운동왜곡
병진 속도 $v$$\Delta x \approx v \cdot T_{\text{scan}}$
회전 속도 $\omega$$\Delta\theta \approx \omega \cdot T_{\text{scan}}$

구체적으로:

상황스캔 시간왜곡
1 m/s 직진, 10 Hz100 ms10 cm
0.5 rad/s 회전, 10 Hz100 ms0.05 rad(2.9도)
2 rad/s 회전, 10 Hz100 ms0.2 rad(11.5도)
1 m/s, 40 Hz25 ms2.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.0range_min 미만이므로 버려야 할 값 — LaserScan.msg가 "values < range_min or > range_max should be discarded"라고 명시(실행 확인)그대로 쓰지 말 것

inf와 NaN의 구분이 중요하다.

아니오 예 예 아니오 ranges[i] 값 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 라이다
메시지LaserScanPointCloud2(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 함정 카탈로그

#함정증상대처
1angle_min < angle_max 가정시계 방향 센서에서 깨짐부호 무관 계산
2inf를 버림유령 장애물 잔존자유 공간으로 해석
3NaN을 inf처럼없는 자유 공간 생성구분 처리
4드라이버의 무효 값 규약 미확인오해석실측 확인
5header.stamp 기준 미확인디스큐·tf 체계적 오차드라이버 확인
6time_increment가 0디스큐 불가계산·드라이버 수정
7운동 왜곡 무시회전 시 지도 흐려짐디스큐(7.5.6)
8디스큐 시 tf 대기 없음외삽 오류(5.2)타임아웃·지연 처리
9오도메트리 주기 부족디스큐 보간 오차100 Hz 이상
10자기 구조물 미제거상시 장애물·로봇 갇힘각도 필터
11필터 영역을 inf로가짜 자유 공간NaN 검토
12pointcloud_to_laserscan 높이 부적절장애물 누락·지면 오검출높이 구간 조정
133D time 필드 기준 오해디스큐가 왜곡을 키움벤더 문서 확인
14SLAM의 디스큐 여부 미확인중복 보정·미보정패키지 확인

7.5.11 체크포인트

  • LaserScan의 각도 필드에서 인덱스↔각도 변환식을 쓸 수 있는가. angle_increment가 음수일 수 있다는 사실의 함의는?
  • 시간 필드 세 가지의 의미와 점별 시각 계산식을 말할 수 있는가.
  • header.stamp의 기준이 드라이버마다 다를 수 있다는 사실이 왜 중요한가.
  • 운동 왜곡의 발생 원리와, 병진·회전 각각의 크기 추정식을 말할 수 있는가. 왜 회전이 더 심각한가.
  • 운동 왜곡의 전형적 증상 다섯 가지와, "정지하면 정상이다"가 무엇의 신호인가.
  • inf와 NaN의 의미 차이와, 각각을 잘못 처리했을 때의 결과를 말할 수 있는가.
  • transformLaserScanToPointCloud가 디스큐를 하는 원리와, 필요한 세 가지 조건은?
  • pointcloud_to_laserscan의 용도와, 높이 구간 설정이 왜 중요한가.
  • 각도 필터가 실무에서 자주 필요한 이유는? 필터로 자른 영역을 어떻게 채워야 하는가.
  • 3D 라이다가 2D와 다른 다섯 가지를 말할 수 있는가. 점별 디스큐를 그대로 구현하면 왜 느린가.
  • 3D 디스큐에 IMU를 쓰는 것이 표준적 방법인 이유는?
  • 운동 왜곡을 검증하는 표준 방법과, 그 결과를 무엇에 활용하는가.

다음 장 7.6는 이 스캔과 짝을 이루는 관성·오도메트리 융합을 다룬다. IMU의 특성, Odometry 메시지의 두 부분, 그리고 robot_localization 구성.