Devin.KR

센서 기초 - 엔코더·IMU·거리센서

개발자KR 조회 6

이 장에서 배우는 것

앞 장에서는 두 바퀴의 각속도로부터 로봇 전체의 선속도와 각속도를 구하는 식을 다뤘다. 그 식은 바퀴 속도를 "정확히 안다"고 가정한 위에 서 있다. 그런데 실제 로봇에서 바퀴 속도는 엔코더가 알려주는 값이고, 로봇이 얼마나 돌았는지는 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반사 표면, 측정 한계이상치 제거 후 필터링

연습 문제

  1. CPR이 200인 엔코더에서 실제 회전각이 1.5라디안일 때, 엔코더가 실제로 내보내는 각도(라디안)와 오차를 구하라.
  2. 정지한 로봇의 자이로를 50회 측정했더니 평균이 0.03rad/s로 나왔다. 이 값이 편향인지 잡음인지 설명하고, 이후 측정값을 어떻게 보정할지 서술하라.
  3. 완성 코드의 range_reading에서 dropout_prob을 0.3으로 바꾸면 어떤 변화가 생기는지 설명하고, 이런 값을 실제 로봇 코드에서 어떻게 걸러내야 하는지 서술하라.
  4. 엔코더 샘플링 주기를 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초일 때 훨씬 작아진다. 즉 샘플링 주기를 늘리면 같은 엔코더로도 상대적인 양자화 오차의 영향이 줄어들지만, 그만큼 로봇의 움직임을 더 늦게 알게 된다는 대가가 따른다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.