[자율주행] 모션 디스큐(deskew) — 한 스윕은 한 순간이 아니다
자율주행/센서
· 2026-09-13
본문의 점선 밑줄 친 전문용어를 누르면 설명이 열립니다.
센서는 어떻게 세상을 재는가에서 한 줄로 지나간 사실이 있다. 한 안의 점들은 서로 다른 순간에 찍혔다. 회전형 라이다는 한 바퀴에 시간이 걸리고, 그동안 차는 계속 움직인다.
이 한 줄이 실무에서는 계속 되돌아온다. 을 다루다 보면 이런 질문들이 남는다.
- 정지한 벽인데 왜 휘어 보이는가. 왜 같은 기둥이 두 겹으로 보이는가
- deskew는 정확히 무슨 연산인가. 무엇을 고치고, 무엇은 끝까지 못 고치는가
- 내가 받은 데이터는 이미 보정된 것인가. 어떻게 확인하는가
- 점별 타임스탬프를 안 남기면 왜 영영 못 하는가
- 파이프라인 어디에 넣고, 무엇을 메타데이터로 남겨야 하는가
이 글은 그 다섯 개의 답이다.
왜 필요한가 — 한 바퀴 도는 동안 차가 간다
10Hz 회전 라이다는 한 바퀴에 100ms를 쓴다. 그동안 차는 멈춰 있지 않다.
속도 100ms(10Hz) 동안 50ms(20Hz) 동안
30 km/h 83 cm 42 cm
60 km/h 167 cm 83 cm
100 km/h 278 cm 139 cm
의 첫 점과 마지막 점은 서로 다른 원점에서 본 값이다. 그런데 파일에는 한 덩어리로, 마치 한 순간의 사진처럼 들어 있다. 이대로 쓰면 정지한 세상이 움직인 것처럼 기록된다.
차가 → 방향으로 달리는 중, 왼쪽의 직선 벽을 스캔
보정 전 보정 후
┌ 스윕 시작에 찍힌 점 ┌ 같은 벽
│···· │········
│ ···· │········
│ ···· ← 뒤로 갈수록 │········
│ ·· 밀린다 │········
└ 스윕 끝 └
직선이 기울고, 스윕이 한 바퀴를 닫는 지점(seam)에서는 같은 물체가 두 겹으로 어긋난다. 100ms 차이가 나는 두 관측이 바로 옆에 붙기 때문이다.
그리고 회전이 병진보다 더 무섭다. 교차로에서 도는 중이라 yaw rate가 20°/s라면 100ms에 2°가 돌아간다. 각도 오차는 거리에 비례해 벌어진다.
2° 회전 오차가 만드는 위치 오차
10 m 거리 → 35 cm
50 m 거리 → 175 cm
100 m 거리 → 349 cm
직진 고속 주행보다 저속 회전 구간에서 더 크게 망가진다는 게 직관과 어긋나는 부분이다. "고속도로 데이터만 조심하면 되겠지"가 틀린 이유다.
무엇이 실제로 망가지나
왜곡 자체보다 중요한 건 이게 하류에서 무엇을 오염시키는가다.
- 지도·정합 — 연속 을 ICP로 붙일 때 잔차가 남는다. 왜곡된 스캔끼리 맞추면 포즈 추정에도 오차가 들어가고, 측위가 조용히 나빠진다.
- 지면 — 평평한 노면이 기울거나 두께가 생긴다. 지면 제거 임계값을 아무리 손봐도 안 맞는다.
- 박스 라벨 — 늘어난 에 맞춰 라벨러가 박스를 크게 그린다. 왜곡이 정답에 박제된다. 나중에 보정된 점군으로 다시 학습하면 박스가 일관되게 큰 편향이 남는다.
- 오진 — 라이다와 카메라가 안 맞는 걸 보고 를 다시 잡는다. 원인이 시간인데 공간을 고치는 것이다. 캘리브레이션에서 시간이 세 번째 함정인 이유가 이것이다.
- 누적 점군 — 여러 스윕을 겹치면 왜곡이 겹쳐 윤곽이 뭉개진다. 점유 예측의 정답처럼 누적 점군에서 자동 생성하는 라벨은 그대로 품질이 내려간다.
deskew는 무슨 연산인가
정의는 한 줄이다. 각 점을 그 점이 찍힌 시각의 센서 자세로 해석해, 스윕 기준 시각의 좌표계로 옮긴다.
점 p_i가 시각 t_i에 센서 좌표계에서 측정됐다면,
p_ref = T_world←sensor(t_ref)⁻¹ · T_world←sensor(t_i) · p_i
T_world←sensor(t)는 그 순간의 센서 자세( × )다. 포즈는 보통 라이다보다 훨씬 높은 주파수로 들어오므로, 점의 시각에 맞춰 보간해서 쓴다.
1import numpy as np 2from scipy.spatial.transform import Rotation, Slerp 3 4def deskew(points, t_point, pose_t, pose_xyz, pose_quat, t_ref): 5 """points: (N,3) 센서 좌표계, t_point: (N,) 점별 절대 시각(초) 6 pose_*: 고주파 에고 포즈 샘플, t_ref: 스윕 기준 시각""" 7 slerp = Slerp(pose_t, Rotation.from_quat(pose_quat)) 8 9 # 각 점 시각의 자세 (회전은 slerp, 이동은 선형 보간) 10 R_i = slerp(t_point).as_matrix() # (N,3,3) 11 t_i = np.stack([np.interp(t_point, pose_t, pose_xyz[:, k]) for k in range(3)], -1) 12 13 R_ref = slerp([t_ref]).as_matrix()[0] 14 t_ref_xyz = np.array([np.interp(t_ref, pose_t, pose_xyz[:, k]) for k in range(3)]) 15 16 # 월드로 올렸다가 기준 시각 좌표계로 내린다 17 p_world = np.einsum("nij,nj->ni", R_i, points) + t_i 18 return (p_world - t_ref_xyz) @ R_ref
회전은 선형 보간하면 안 된다. 쿼터니언 slerp를 쓴다 — 이유는 3D 기하 기초에서 다뤘다. 회전 표현을 데이터셋 간에 옮길 때 어디서 어긋나는지는 좌표계 변환기에서 직접 돌려볼 수 있다.
기준 시각을 무엇으로 잡는가
여기가 관례가 갈리는 자리다. 스윕 시작, 중간, 끝 중 무엇을 t_ref로 쓰느냐에 따라 결과가 통째로 평행이동한다. 100ms 스윕에서 시작과 중간은 50ms 차이고, 60km/h면 83cm다.
이건 정답이 없는 대신 일관성과 명시가 전부인 결정이다. 로그의 timestamp가 어느 순간인지(스윕 시작인지 중간인지), deskew의 t_ref가 그것과 같은지, 카메라 트리거는 어디에 맞춰져 있는지 — 셋이 어긋나면 전 프레임에 일정한 오프셋이 생긴다. 그리고 일정한 오프셋은 오차와 구별되지 않는다.
포즈가 충분히 촘촘해야 한다
10Hz 에고 포즈로 100ms를 보간하면, 보간 구간이 곧 스윕 전체다. 직진이면 그럭저럭 맞지만 회전 구간에서는 앞의 2° 오차가 그대로 남는다. 그래서 실무에서는 /INS 급(100~400Hz) 포즈를 쓴다. 포즈 소스가 무엇이고 몇 Hz였는지는 보정 결과와 함께 남겨야 하는 정보다.
deskew가 고치지 못하는 것 — 움직이는 물체
여기가 가장 자주 오해되는 지점이다. deskew는 자차 운동만 되돌린다. 보정 후에 정렬되는 것은 정지한 세상이고, 움직이는 물체는 여전히 왜곡된 채 남는다.
다만 크기를 정확히 보자. 한 물체가 노출되는 시간은 스윕 전체가 아니라 그 물체가 차지하는 각도만큼이다.
10Hz 라이다(100ms/회전), 옆 차선 차량이 시야에서 30°를 차지
노출 시간 = 100ms × 30/360 = 8.3ms
상대속도 20 m/s → 형상이 약 17cm 늘어난다
한 스윕 안에서는 17cm지만, 여러 스윕을 누적하면 프레임 간격(100ms)만큼 번진다. 20m/s로 지나가는 차는 10장만 쌓아도 20m 꼬리를 남긴다. 그래서 누적 은 배경에는 좋고 움직이는 물체에는 독이다. 검사·판정에 쓸 점군과 보기 좋으라고 쌓는 점군을 같은 것으로 두면 안 되는 이유가 여기 있다.
물체 단위 보정(object-level motion compensation)은 추적과 속도가 있어야 가능하다. 즉 인지 결과에 의존하므로, 인지 입력을 만드는 단계에서는 쓸 수 없다. 자동 라벨이나 오프라인 재처리에서만 부분적으로 쓴다.
내 데이터는 이미 보정돼 있는가
데이터셋과 드라이버마다 다르고, 문서에 안 적혀 있는 경우가 흔하다.
| 출처 | 점별 시각 | 스윕 내 보정 |
|---|---|---|
| Waymo Open Dataset | 있음 (top lidar의 per-pixel pose) | 직접 가능, 공식 예제가 방식을 보여준다 |
| Argoverse 2 | 있음 (offset_ns) | 직접 가능 |
| nuScenes | 없음 (x, y, z, intensity, ring) | 스윕 내부는 불가 |
| ROS 드라이버 | 드라이버별 (time, t …) | 이름·단위가 제각각 |
| 일부 센서 펌웨어 | 내부 로 출력 전 보정 | 이미 되어 있을 수 있음 |
nuScenes가 좋은 예다. 점군 파일은 점당 5개 값뿐이라 스윕 안에서 어느 점이 언제 찍혔는지 알 수 없다. 그래서 스윕 내부 왜곡은 되돌릴 수도, 심지어 "되어 있는지" 판정할 수도 없다. 흔히 보는 sweep 누적은 다른 이야기다 — 스윕 간 로 정합하는 것이라 정지물은 맞고 움직이는 물체는 번진다.
ROS 쪽은 필드 이름부터 확인해야 한다. Velodyne 계열은 time(스윕 시작 기준 초), Ouster는 t(ns) 식으로 다르고, 단위와 기준점(스윕 시작인지 절대 시각인지)이 제각각이다. PointField 정의를 직접 읽는 게 빠르다.
되어 있는지 확인하는 네 가지 방법
메타데이터가 말해주지 않으면 데이터에 물어본다.
1. 정지 구조물 테스트. 회전 구간의 스캔에서 직선 벽·기둥·가드레일을 본다. 휘어 있거나, 스윕 seam에서 단차가 보이면 보정 전이다. 가장 빠르고 가장 확실하다.
2. 잔차-운동 상관. 지면이나 벽에 평면을 피팅하고 잔차를 속도·yaw rate와 상관 낸다. 보정 전이면 상관이 뜨고, 보정 후면 사라진다. 단, 대조군이 필요하다 — 고속도로에서는 평면이 잘 맞고 주차장에서는 안 맞는 식으로 장면 종류가 만드는 상관이 섞인다. 직교 축(예: 좌우 기울기)을 대조군으로 같이 재서, 두 상관이 구별되지 않으면 결론을 내리지 않는다.
3. 연속 스윕 ICP 잔차. 인접한 두 스윕을 정합했을 때의 잔차는 보정 후 줄어든다. 보정 전후를 같은 구간에서 비교하면 효과가 숫자로 나온다.
4. 같은 장소 재방문. 루프를 도는 구간에서 두 번 지난 같은 벽이 지도에서 몇 cm 두께로 나오는지 본다.
그리고 가장 중요한 제약. 점별 시각이 없으면 보정도 검증도 불가능하다. 이때는 "보정돼 있다"도 "안 돼 있다"도 말할 수 없다. 이런 경우 결론을 지어내지 말고 미상으로 남기는 것이 맞다. 애매한 판정을 근거로 파이프라인을 고치면 원인이 아닌 곳을 고치게 된다.
실무에서 무너지는 자리
기준 시각 불일치. 앞에서 말한 그것. 라이다 t_ref는 스윕 시작인데 라벨과 카메라는 스윕 중간에 맞춰져 있으면, 전 프레임에 수십 cm 상수 오프셋이 생긴다. 증상이 오차와 똑같아서 몇 주를 태울 수 있다.
이중 보정. 센서 펌웨어가 이미 보정해서 내보내는데 파이프라인이 또 한다. 오차가 두 배로 들어가고, 직진에서는 티가 안 나다가 회전에서만 나타난다.
저주파 포즈. 10Hz 포즈로 보간하면 회전 구간에서 남는다. "보정했는데도 벽이 조금 휜다"의 단골 원인이다.
시계 동기화. 포즈와 라이다가 서로 다른 시계를 쓰면(· 동기화가 안 맞으면) 보정이 오히려 악화시킨다. 20ms 어긋난 포즈로 보정하면 20ms만큼 반대로 밀어버린다. 는 deskew의 전제 조건이다.
좌표계 미기록. 보정 결과를 센서 좌표계에 되돌려 놨는지, 에고 좌표계로 옮겨 놨는지 적어두지 않으면 다음 사람이 한 번 더 변환한다.
라벨과 학습 입력의 불일치. 라벨러는 보정된 점군을 보고 그렸는데 학습 로더는 원본을 읽는다. 조용히 어긋나고, 모델은 에러 없이 조금 나빠진다.
파이프라인 어디에 넣을 것인가
데이터 엔지니어 입장에서 deskew는 "한 번 돌리고 끝"인 전처리가 아니라 버전이 붙는 파생 데이터다.
- 원본은 보정 전 + 점별 시각 그대로 보존한다. 포즈가 나중에 좋아지면(오프라인 으로 다시 풀면) 재보정할 수 있어야 한다. 보정본만 남기면 그 기회가 사라진다.
- 보정본은 파생 레이어로 굽는다. MCAP이라면 을 나눈다 —
/lidar/points_raw와/lidar/points_deskewed. 같은 토픽에 섞으면 둘을 구별할 방법이 사라진다. - 무엇으로 보정했는지 메타데이터에 남긴다.
1deskew: 2 applied: true 3 ref_time: sweep_start # sweep_start | sweep_mid | sweep_end 4 pose_source: ins_ekf # ins_ekf | wheel_odom | offline_slam 5 pose_rate_hz: 200 6 algo_version: deskew-v2 7 clock_sync: ptp
이 여섯 줄이 있으면 나중에 "이 구간은 왜 이상하지"에 답할 수 있다. 없으면 데이터만 보고 역추적해야 하는데, 앞에서 봤듯 점별 시각이 없으면 그마저도 불가능하다.
- 비용은 생각보다 작다. 점당 행렬 곱 한 번이라 100만 점이어도 수십 ms다. 병목은 연산이 아니라 포즈를 가져오는 I/O인 경우가 많다. 스윕마다 포즈 테이블을 다시 읽지 말고 구간 단위로 올려두면 된다.
- 학습 로더에서 즉석 보정 vs 미리 굽기. 실험마다 기준 시각이나 포즈 소스를 바꿔볼 거면 즉석이 낫고, 고정됐으면 미리 구워 두는 쪽이 GPU를 덜 굶긴다.
데이터 엔지니어의 시선
- 점별 시각 필드는 기록 시점의 결정이다. 나중에 복원할 방법이 없다. 저장 비용 몇 %를 아끼려다 그 기간 데이터의 재처리 가능성을 통째로 버린다.
- 보정 여부를 파일 이름이나 구전으로 관리하지 않는다. 메타데이터에 넣고, 읽는 쪽이 그걸 신뢰하게 만든다.
- "라이다-카메라가 안 맞는다"는 리포트를 받으면 를 만지기 전에 시간을 먼저 본다. 기준 시각·동기화·보정 여부 순서로.
- 왜곡은 회전 구간에서 가장 크다. 검증 데이터셋을 고를 때 직진 구간만 넣으면 통과하고 실차에서 터진다.
- 같은 문제가 카메라에도 있다. 는 한 장이 한 순간이 아니다 — 카메라 이미지의 실제에서 다뤘다. 라이다만 고치고 카메라를 잊는 경우가 많다.
정리
- 회전 라이다의 한 스윕은 한 순간이 아니다. 10Hz면 100ms, 60km/h면 그 사이 1.67m를 간다.
- 회전이 병진보다 위험하다. yaw rate 20°/s면 100ms에 2°, 50m 거리에서 1.75m 오차다.
- deskew는 각 점을 그 점의 시각에 해당하는 자세로 해석해 기준 시각 좌표계로 옮기는 것이다. 재료는 점별 시각 + 고주파 포즈 두 가지뿐이다.
- 자차 운동만 되돌린다. 움직이는 물체는 한 스윕 안에서는 조금(각폭만큼), 누적하면 크게 남는다.
- 데이터셋마다 상태가 다르다. 점별 시각이 없으면 보정도 검증도 불가능하고, 그때는 판정하지 말고 미상으로 남긴다.
- 검증은 정지 구조물 → 잔차-운동 상관 → ICP 잔차 → 루프 재방문 순으로 싸고 확실하다.
- 실무 함정은 대부분 시간에 있다 — 기준 시각 불일치, 이중 보정, 저주파 포즈, 시계 동기화.
- 보정본은 버전이 붙는 파생 데이터로 다루고, 원본과 메타데이터를 함께 남긴다.
이어서 볼 글: 이 왜곡이 왜 생기는지의 물리는 센서는 어떻게 세상을 재는가, 시간과 공간을 맞추는 일 전체는 센서 캘리브레이션, 보정에 쓰는 포즈가 어디서 오는지는 측위와 SLAM, 보정본을 어떤 으로 남길지는 MCAP 완전 정복에서 다룬다.