센서 기초 - 엔코더·IMU·거리센서
이 장에서 배우는 것
앞 장에서는 두 바퀴의 각속도로부터 로봇 전체의 선속도와 각속도를 구하는 식을 다뤘다. 그 식은 바퀴 속도를 "정확히 안다"고 가정한 위에 서 있다. 그런데 실제 로봇에서 바퀴 속도는 엔코더가 알려주는 값이고, 로봇이 얼마나 돌았는지는 IMU가 알려주는 값이고, 앞에 벽이 있는지는 거리센서가 알려주는 값이다. 이 값들은 참값 그대로가 아니라 분해능·편향·잡음이 섞인 근사값이다. 이 장에서는 엔코더·IMU·거리센서 세 가지를 예로 들어 측정값이 참값과 왜 달라지는지 정리하고, numpy로 그 차이를 재현 가능하게 흉내 낸다.
- 엔코더의 분해능이 만드는 양자화 오차를 계산으로 설명할 수 있다
- 편향(bias)과 잡음(noise)을 구분하고 각각이 평균에 미치는 영향을 안다
- 샘플링 주기가 센서값 해석에 미치는 영향을 설명할 수 있다
- 시드를 고정한 numpy 난수로 세 센서의 오차를 재현 가능하게 시뮬레이션할 수 있다
문제 상황
2바퀴 차동 구동 로봇에 엔코더로 이동 거리를 재고, IMU로 회전각을 적분하고, 거리센서로 앞 벽까지 거리를 재는 코드를 붙였다고 하자. 실제로 돌려보면 다음 세 가지가 관찰된다. 첫째, 바퀴가 정확히 한 바퀴 돈 것을 줄자로 확인했는데 엔코더로 계산한 이동 거리는 그보다 살짝 짧게 나온다. 둘째, 로봇을 책상 위에 가만히 올려두었는데 IMU 각속도를 적분한 값이 시간이 지날수록 계속 커진다. 셋째, 거리센서가 벽까지 80센티미터라고 읽다가 가끔 한 번씩 센서의 최대 측정 거리를 그대로 반환한다.
세 현상 모두 센서가 고장났다기보다는 분해능·편향·잡음이라는 정상적인 특성 때문에 생긴다. 원인을 구분하지 못하면 엉뚱한 보정을 시도하게 된다. 예를 들어 편향이 원인인 값을 여러 번 평균 내서 없애려고 하면, 평균을 아무리 내도 값이 줄지 않아 시간만 버리게 된다.
센서 오차의 세 갈래: 분해능·편향·잡음
센서가 내놓는 측정값을 참값과 비교하면 어긋나는 방식이 대략 세 가지로 나뉜다. 어떤 어긋남인지 구분해야 다음 장에서 다룰 필터를 어디에 어떻게 적용할지 정할 수 있다.
분해능과 양자화 오차
엔코더는 바퀴가 한 바퀴 도는 동안 정해진 개수의 펄스(pulse)만 낸다. 이 개수를 CPR(counts per revolution, 분해능)이라 부른다. CPR이 360이면 엔코더가 구분할 수 있는 최소 각도는 2π / 360 라디안이며, 실제 회전각이 이 값의 배수가 아니면 엔코더는 바로 아래 정수 카운트로 반내림해서 값을 낸다. 이렇게 연속값을 이산 단계로 잘라내는 과정을 양자화(quantization)라 하고, 그때 생기는 차이를 양자화 오차라 한다. 양자화 오차의 크기는 항상 분해능 한 칸보다 작지만, 절대 0이 되지는 않는다.
편향
편향(bias)은 참값에서 한쪽 방향으로 치우친 고정된 어긋남이다. 로봇을 정지시켜 놓아도 IMU 자이로가 0이 아닌 값을 계속 내놓는다면, 그 값이 편향이다. 편향의 특징은 여러 번 측정해서 평균을 내도 그대로 남는다는 점이다. 잡음처럼 무작위로 흔들리는 것이 아니라 매번 같은 방향으로 쏠려 있기 때문이다. 편향을 줄이려면 로봇이 확실히 정지해 있거나 참값을 아는 상태에서 따로 측정해 그 값을 추정한 뒤, 실제 사용할 때 측정값에서 그 추정값을 빼는 보정(calibration) 과정이 필요하다.
잡음
잡음(noise)은 평균이 0에 가까운 무작위 흔들림이다. 같은 조건에서 같은 값을 여러 번 측정해도 매번 조금씩 다르게 나오는 것이 잡음 때문이다. 편향과 달리 잡음은 표본을 많이 모아 평균을 낼수록 그 평균값이 참값에 가까워진다. 이 성질 덕분에 다음 장에서 다루는 이동평균이나 저역통과 필터가 효과를 낸다. 반대로 말하면, 편향이 섞인 값을 필터로 아무리 다듬어도 편향은 걸러지지 않는다.
| 오차 종류 | 정의 | 여러 번 평균 내면 | 대표 예시 |
|---|---|---|---|
| 편향(bias) | 참값에서 한쪽으로 치우친 고정 오차 | 줄지 않고 그대로 남는다 | 정지한 자이로가 내는 0이 아닌 값 |
| 잡음(noise) | 평균이 0인 무작위 흔들림 | 표본 수가 늘수록 줄어든다 | 거리센서 값의 미세한 흔들림 |
| 양자화 오차 | 분해능 단위로 반내림되며 생기는 오차 | 분해능을 바꾸지 않으면 줄지 않는다 | 엔코더 카운트 오차 |
샘플링 주기와 데이터 해석
센서는 정해진 주기(샘플링 주기)마다 값을 한 번씩 내놓는다. 이 주기를 어떻게 잡느냐에 따라 같은 센서라도 다른 결론이 나올 수 있다. 주기를 너무 길게 잡으면 그사이에 일어난 빠른 변화를 놓친다. 예를 들어 로봇이 짧은 순간 장애물을 스쳐 지나갔는데 거리센서 샘플링 주기가 그보다 길면 그 장애물은 기록에 남지 않는다.
반대로 주기를 너무 짧게 잡으면 엔코더처럼 분해능이 낮은 센서에서는 한 샘플 구간 동안 카운트가 거의 바뀌지 않아, 속도를 "이번 카운트 변화량 나누기 주기"로 계산했을 때 값이 계단처럼 0과 분해능 한 칸 사이를 오간다. 즉 샘플링 주기는 로봇이 실제로 움직이는 속도, 그리고 센서의 분해능과 함께 맞춰야 하는 값이다. 이 관계는 뒤에서 PID 제어의 제어 주기를 정할 때 다시 등장한다.
완성 코드
아래 코드는 엔코더의 양자화, IMU 자이로의 편향과 잡음, 거리센서의 잡음과 dropout(측정 실패로 최대 거리가 튀는 현상)을 numpy로 흉내 낸다. 시드를 고정했으므로 여러 번 실행해도 같은 결과가 나온다. 파일 이름은 sensor_basics.py로 한다.
import numpy as np
WHEEL_RADIUS = 0.03 # 바퀴 반지름 (m)
ENCODER_CPR = 360 # 엔코더 분해능: 1바퀴당 펄스 수
DT = 0.1 # 샘플링 주기 (s)
STEPS = 6
def true_wheel_ticks(wheel_speed_mps, dt, steps):
tick_rate = wheel_speed_mps / (2 * np.pi * WHEEL_RADIUS) * ENCODER_CPR
t = np.arange(1, steps + 1) * dt
return tick_rate * t
def encoder_reading(true_ticks):
return np.floor(true_ticks)
def gyro_reading(true_omega, bias, noise_std, rng):
noise = rng.normal(0.0, noise_std, size=true_omega.shape)
return true_omega + bias + noise
def range_reading(true_distance, noise_std, dropout_prob, max_range, rng):
noise = rng.normal(0.0, noise_std, size=true_distance.shape)
measured = true_distance + noise
dropout = rng.random(true_distance.shape) < dropout_prob
measured = np.where(dropout, max_range, measured)
return np.clip(measured, 0.0, max_range)
def main():
rng = np.random.default_rng(6)
true_ticks = true_wheel_ticks(0.3, DT, STEPS)
measured_ticks = encoder_reading(true_ticks)
print("[엔코더] 이상적 펄스 수 vs 실제 카운트")
for k in range(STEPS):
t = (k + 1) * DT
err = true_ticks[k] - measured_ticks[k]
print(f" t={t:.1f}s true={true_ticks[k]:.4f} count={int(measured_ticks[k])} 오차={err:.4f}")
n_gyro = 4000
true_omega = np.zeros(n_gyro)
measured_omega = gyro_reading(true_omega, bias=0.02, noise_std=0.01, rng=rng)
print(f"\n[자이로] 정지 상태에서 {n_gyro}회 측정")
print(f" 평균={measured_omega.mean():.3f} rad/s (참값 0과의 차이가 편향)")
print(f" 표준편차={measured_omega.std():.3f} rad/s (이것이 잡음 크기)")
n_range = 20000
true_distance = np.full(n_range, 0.8)
measured_distance = range_reading(
true_distance, noise_std=0.02, dropout_prob=0.0, max_range=2.0, rng=rng
)
error = measured_distance - true_distance
print(f"\n[거리센서] 참값 0.800m 고정, {n_range}회 측정")
print(f" 평균 오차={error.mean():.3f} m")
print(f" 표준편차={error.std():.3f} m")
forced = range_reading(
np.array([0.5, 1.2, 0.3]), noise_std=0.02, dropout_prob=1.0, max_range=2.0, rng=rng
)
print(f"\n[거리센서] dropout_prob=1.0일 때: {forced}")
if __name__ == "__main__":
main()
줄별 해설
WHEEL_RADIUS, ENCODER_CPR, DT, STEPS — 바퀴 반지름, 엔코더 분해능, 샘플링 주기, 시뮬레이션할 샘플 개수를 상수로 뽑아 두었다. 앞 장에서 다룬 바퀴 속도·반지름 관계를 그대로 쓴다.
true_wheel_ticks — 바퀴 표면 속도를 각속도로 바꾸고(반지름으로 나누기), 각속도를 CPR로 바꿔 "이상적으로 나와야 할 펄스 수"를 시간에 따라 계산한다. 여기까지는 잡음도 분해능 제한도 없는 참값이다.
encoder_reading — np.floor로 정수 아래를 버린다. 실제 엔코더가 정수 카운트만 셀 수 있다는 사실을 그대로 흉내 낸 것이다.
gyro_reading — 참값에 편향(bias)을 더하고, 그 위에 정규분포 잡음을 더한다. 편향은 고정된 상수, 잡음은 rng.normal로 매번 다르게 뽑히는 값이라는 차이를 코드로도 구분해서 표현했다.
range_reading — 잡음을 더한 뒤, 일정 확률(dropout_prob)로 그 값을 아예 버리고 max_range로 덮어쓴다. 초음파나 적외선 거리센서가 반사가 잡히지 않을 때 최대 거리를 반환하는 동작을 그대로 옮긴 것이다. 마지막의 np.clip은 잡음 때문에 값이 0보다 작아지거나 최대 거리를 넘는 경우를 물리적으로 말이 되는 범위로 자른다.
main의 엔코더 블록 — 참값과 측정값의 차이를 6개 샘플에 대해 출력해, 시간이 지날수록 오차가 0과 분해능 한 칸 사이를 오가는 것을 보여준다.
main의 자이로 블록 — 참값이 정확히 0인 상황(로봇 정지)을 4000번 측정해서, 측정값의 평균이 곧 편향의 크기이고 표준편차가 곧 잡음의 크기라는 것을 통계로 확인한다.
main의 거리센서 블록 — 참값을 0.8m로 고정하고 20000번 측정해 오차의 평균과 표준편차를 본다. 마지막 줄은 dropout_prob=1.0으로 두어, 어떤 값이 들어오든 무조건 max_range로 튀는 극단적인 경우를 확인한다.
실행 결과
$ python3 sensor_basics.py
[엔코더] 이상적 펄스 수 vs 실제 카운트
t=0.1s true=57.2958 count=57 오차=0.2958
t=0.2s true=114.5916 count=114 오차=0.5916
t=0.3s true=171.8873 count=171 오차=0.8873
t=0.4s true=229.1831 count=229 오차=0.1831
t=0.5s true=286.4789 count=286 오차=0.4789
t=0.6s true=343.7747 count=343 오차=0.7747
[자이로] 정지 상태에서 4000회 측정
평균=0.020 rad/s (참값 0과의 차이가 편향)
표준편차=0.010 rad/s (이것이 잡음 크기)
[거리센서] 참값 0.800m 고정, 20000회 측정
평균 오차=0.000 m
표준편차=0.020 m
[거리센서] dropout_prob=1.0일 때: [2. 2. 2.]
엔코더 오차가 0.18~0.89 사이에서 톱니처럼 오르내리는 것을 볼 수 있다. 자이로 평균은 우리가 넣은 편향값 0.02에, 표준편차는 잡음 크기 0.01에 그대로 맞아떨어진다. 거리센서도 마찬가지로 평균 오차는 0에 가깝고 표준편차는 잡음 크기 0.02와 같다.
실무에서 자주 틀리는 것
편향을 잡음처럼 평균으로 없애려 한다
readings = [gyro.read() for _ in range(100)]
omega_hat = sum(readings) / len(readings)
# omega_hat 에는 여전히 편향이 그대로 남아 있다
# 로봇을 움직이지 않은 상태에서 편향을 먼저 추정한다
calib = [gyro.read() for _ in range(100)]
bias_hat = sum(calib) / len(calib)
omega = gyro.read() - bias_hat
샘플링 주기를 분해능에 맞추지 않는다
prev_ticks = 0
for ticks in encoder_stream():
d_ticks = ticks - prev_ticks
speed = d_ticks * tick_to_rad / dt # dt 가 5ms 로 너무 짧다
prev_ticks = ticks
prev_ticks = 0
WINDOW = 10 # 10 샘플(50ms)마다 속도 계산
for i, ticks in enumerate(encoder_stream()):
if i % WINDOW == 0:
speed = (ticks - prev_ticks) * tick_to_rad / (WINDOW * dt)
prev_ticks = ticks
주기가 다른 센서 값을 시간 정렬 없이 합친다
for enc, imu in zip(encoder_log, imu_log):
fuse(enc, imu) # encoder 는 50Hz, imu 는 200Hz 라 같은 인덱스가 같은 시각이 아니다
for t in common_timestamps:
enc = interpolate(encoder_log, t)
imu = interpolate(imu_log, t)
fuse(enc, imu)
dropout으로 튄 값을 유효한 거리로 착각한다
closest = min(range_readings) # dropout 값(예: 2.0m)도 그냥 후보에 낀다
valid = [d for d in range_readings if d < max_range - 0.01]
closest = min(valid) if valid else None
한눈에 보기
| 센서 | 주요 오차 | 원인 | 다음 장에서 다룰 대응 |
|---|---|---|---|
| 엔코더 | 양자화 오차 | 분해능(CPR) 한계 | 여러 샘플을 모아 속도 추정 |
| IMU 자이로 | 편향 + 잡음 | 소자 특성, 온도 변화 | 이동평균·저역통과 필터 |
| 거리센서 | 잡음 + dropout | 반사 표면, 측정 한계 | 이상치 제거 후 필터링 |
연습 문제
- CPR이 200인 엔코더에서 실제 회전각이 1.5라디안일 때, 엔코더가 실제로 내보내는 각도(라디안)와 오차를 구하라.
- 정지한 로봇의 자이로를 50회 측정했더니 평균이 0.03rad/s로 나왔다. 이 값이 편향인지 잡음인지 설명하고, 이후 측정값을 어떻게 보정할지 서술하라.
- 완성 코드의
range_reading에서dropout_prob을 0.3으로 바꾸면 어떤 변화가 생기는지 설명하고, 이런 값을 실제 로봇 코드에서 어떻게 걸러내야 하는지 서술하라. - 엔코더 샘플링 주기를 0.1초에서 0.5초로 늘리면, 한 샘플 구간 동안의 참값 변화량과 분해능 오차의 비율이 어떻게 달라지는지 설명하라.
정답과 해설
1. 분해능은 2π / 200 ≈ 0.031416라디안이다. 1.5 / 0.031416 ≈ 47.746이므로 반내림하면 47카운트가 나오고, 엔코더가 내보내는 각도는 47 × 0.031416 ≈ 1.4765라디안이다. 오차는 1.5 − 1.4765 ≈ 0.0235라디안(약 1.3도)이다.
2. 참값이 0인 상황에서 측정 평균이 0으로 수렴하지 않고 0.03에서 안정적으로 유지된다면 이는 잡음이 아니라 편향이다. 잡음이라면 표본을 늘릴수록 평균이 0에 가까워져야 하는데 그렇지 않기 때문이다. 이후에는 이 0.03을 편향 추정값으로 저장해 두고, 실제 측정값에서 항상 이 값을 빼서 사용해야 한다.
3. dropout_prob을 0.3으로 올리면 20000번 측정 중 평균적으로 약 30%가 2.0m(최대 거리)로 튀게 된다. 이 값을 그대로 최솟값 계산이나 장애물 판단에 쓰면 실제로는 가까운 벽이 있는데도 "멀다"고 잘못 판단할 수 있다. 따라서 최대 거리에 매우 가까운 값은 유효하지 않은 측정으로 걸러내고, 직전 유효 값을 유지하거나 다른 센서 값으로 대체하는 처리가 필요하다.
4. 샘플링 주기가 0.1초일 때 한 구간의 참값 변화량은 앞선 예에서 약 57틱이었다. 주기를 0.5초로 늘리면 변화량은 5배인 약 287틱으로 커진다. 분해능 오차는 항상 1틱 미만으로 일정하므로, 변화량 대비 오차의 비율은 0.1초일 때보다 0.5초일 때 훨씬 작아진다. 즉 샘플링 주기를 늘리면 같은 엔코더로도 상대적인 양자화 오차의 영향이 줄어들지만, 그만큼 로봇의 움직임을 더 늦게 알게 된다는 대가가 따른다.