Devin.KR

로봇 · 기본

좌표·센서·구동기의 기초

차동 구동 기구학 - 바퀴 속도와 로봇 속도

정기구학·역기구학, 오도메트리 적분, 오차 누적

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 이동과 회전을 하나의 동차 변환 행렬로 표현하는 법을 다뤘다. 이번 장은 그 변환이 실제로 어디서 나오는지, 즉 두 바퀴의 회전 속도가 로봇 전체의 이동 속도와 회전 속도로 어떻게 합쳐지는지를 다룬다. 2바퀴 차동 구동(differential drive) 로봇을 기준으로 정기구학과 역기구학을 세우고, 그 결과를 시간에 대해 적분해서 로봇의 위치를 추정하는 오도메트리(odometry)까지 구현한다.

  • 왼쪽·오른쪽 바퀴 속도로부터 로봇의 직진 속도와 회전 속도를 구하는 정기구학 공식을 세운다.
  • 원하는 로봇 속도를 바퀴 속도 명령으로 바꾸는 역기구학 공식을 세운다.
  • 바퀴 속도를 시간에 대해 적분해서 위치와 방향을 추정하는 오도메트리를 구현한다.
  • 바퀴 속도 측정에 작은 계통오차가 있을 때 오도메트리 결과가 어떻게 벌어지는지 확인한다.

문제 상황

차동 구동 로봇에 "앞으로 40cm 이동한 뒤 제자리에서 90도 돌고 다시 20cm 이동하라"는 명령을 내렸다. 로봇은 왼쪽·오른쪽 모터에 속도 명령만 받을 수 있고, 자기 위치를 직접 알려주는 센서는 없다. 개발자는 두 바퀴 속도를 어떻게 조합해야 "40cm 직진"이나 "90도 회전" 같은 명령이 되는지부터 정해야 하고, 명령을 실행한 뒤에는 로봇이 지금 어디에 있다고 판단할지도 계산해야 한다. 이 두 계산이 바로 정기구학과 오도메트리이며, 여기서 조금이라도 어긋나면 로봇이 실제로는 다른 위치에 가 있는데도 소프트웨어는 정확한 위치에 있다고 믿는 상황이 생긴다.

정기구학과 역기구학

왼쪽 바퀴 속도를 v_l, 오른쪽 바퀴 속도를 v_r(둘 다 m/s), 두 바퀴 중심 사이의 거리를 L이라 하자. 두 바퀴가 같은 속도면 로봇은 그대로 직진하고, 속도가 다르면 느린 쪽 바퀴를 중심으로 도는 원호를 그린다. 로봇 중심의 직진 속도 v는 두 바퀴 속도의 평균이고, 회전 속도 w는 두 바퀴 속도 차이를 L로 나눈 값이다.

바퀴 두 개의 속도 평균이 직진 속도를, 속도 차이가 회전 속도를 만든다는 구조를 보여준다

이 관계를 정리하면 다음과 같다. v_r이 v_l보다 크면 w가 양수가 되어 로봇이 왼쪽으로(반시계 방향으로) 돈다고 정한다. 이는 x축을 로봇의 초기 정면, y축을 왼쪽으로 두는 일반적인 평면 좌표계 관례와 맞는다.

정기구학과 역기구학이 계산하는 값
구분입력출력공식
정기구학바퀴 속도 v_l, v_r로봇 속도 v, wv=(v_l+v_r)/2, w=(v_r-v_l)/L
역기구학로봇 속도 v, w바퀴 속도 v_l, v_rv_l=v-wL/2, v_r=v+wL/2

역기구학은 정기구학을 뒤집어 풀기만 하면 나온다. 제어 루프가 "직진 0.2m/s, 회전 0.5rad/s로 움직여라"라고 결정하면, 그 값을 역기구학에 넣어 각 모터에 보낼 속도 명령을 만든다.

오도메트리 적분과 오차 누적

순간 속도를 위치로 적분하기

정기구학으로 구한 v, w는 그 순간의 속도일 뿐이다. 로봇의 위치 (x, y)와 방향 θ를 알려면 이 속도를 시간에 대해 적분해야 한다. θ(t) = θ0 + w·t이므로, dx/dt = v·cos θ(t)를 0부터 dt까지 적분하면 다음 식이 나온다.

w가 0이 아닐 때: x' = x0 + (v/w)·[sin(θ0+w·dt) - sin θ0], y' = y0 - (v/w)·[cos(θ0+w·dt) - cos θ0], θ' = θ0 + w·dt. w가 0이면 로봇은 직선으로만 움직이므로 x' = x0 + v·dt·cos θ0, y' = y0 + v·dt·sin θ0로 단순해진다. 이 두 식을 구간마다 반복 적용하는 것이 오도메트리다.

오차는 왜 누적되는가

오도메트리는 바퀴 속도 "측정값"을 적분한 결과이지, 로봇의 실제 위치를 직접 재는 것이 아니다. 만약 바퀴 지름을 실제보다 조금 크게 알고 있거나 트랙 폭 L을 잘못 재면, 매 구간마다 같은 방향으로 조금씩 틀린 값이 더해진다. 이런 계통오차(systematic error)는 이동 거리나 회전각에 비례해서 꾸준히 쌓이기 때문에, 직진 구간에서는 몇 밀리미터 차이였던 것이 회전을 몇 번 거치고 나면 방향 자체가 눈에 띄게 틀어진다. 반면 바퀴 미끄러짐처럼 방향이 매번 랜덤한 오차는 서로 상쇄되는 경향이 있어 상대적으로 천천히 쌓인다.

같은 명령을 실행해도 계통오차가 있으면 추정 경로가 참값 경로에서 점점 벌어진다

완성 코드

아래 프로그램은 정기구학·역기구학을 확인한 뒤, 직진-회전-직진으로 이루어진 세 구간을 참값(오차 없음)과 3% 계통오차가 섞인 추정값 두 가지로 오도메트리 적분해서 비교한다. 표준 라이브러리인 math와 dataclasses만 사용하며 난수를 쓰지 않으므로 실행할 때마다 같은 결과가 나온다. 파일명은 diff_drive_odometry.py로 저장한다.

"""차동 구동 로봇의 정기구학·역기구학과 오도메트리 적분 예제."""
import math
from dataclasses import dataclass

TRACK_WIDTH = 0.2  # m, 왼쪽·오른쪽 바퀴 중심 사이 거리


@dataclass
class Pose:
    x: float
    y: float
    theta: float  # rad


def forward_kinematics(v_l: float, v_r: float) -> tuple[float, float]:
    v = (v_l + v_r) / 2.0
    w = (v_r - v_l) / TRACK_WIDTH
    return v, w


def inverse_kinematics(v: float, w: float) -> tuple[float, float]:
    v_l = v - w * TRACK_WIDTH / 2.0
    v_r = v + w * TRACK_WIDTH / 2.0
    return v_l, v_r


def integrate_pose(pose: Pose, v: float, w: float, dt: float) -> Pose:
    if abs(w) < 1e-9:
        x = pose.x + v * dt * math.cos(pose.theta)
        y = pose.y + v * dt * math.sin(pose.theta)
        theta = pose.theta
    else:
        theta = pose.theta + w * dt
        x = pose.x + (v / w) * (math.sin(theta) - math.sin(pose.theta))
        y = pose.y - (v / w) * (math.cos(theta) - math.cos(pose.theta))
    return Pose(x, y, theta)


def run_phase(pose: Pose, v_l: float, v_r: float, duration: float, bias: float = 1.0) -> Pose:
    v, w = forward_kinematics(v_l * bias, v_r * bias)
    return integrate_pose(pose, v, w, duration)


def print_pose(label: str, pose: Pose) -> None:
    deg = math.degrees(pose.theta)
    print(f"{label}: x={pose.x:.4f} m, y={pose.y:.4f} m, theta={deg:.4f} deg")


def main() -> None:
    print("=== 기구학 변환 확인 ===")
    v, w = forward_kinematics(0.2, 0.3)
    print(f"정기구학: v_l=0.20, v_r=0.30 -> v={v:.4f} m/s, w={w:.4f} rad/s")
    v_l, v_r = inverse_kinematics(0.3, 0.4)
    print(f"역기구학: v=0.30, w=0.40 -> v_l={v_l:.4f} m/s, v_r={v_r:.4f} m/s")

    phases = [
        (0.2, 0.2, 2.0),
        (-0.1, 0.1, math.pi / 2),
        (0.2, 0.2, 1.0),
    ]

    print("\n=== 오도메트리 적분: 참값 vs 3% 계통오차 ===")
    true_pose = Pose(0.0, 0.0, 0.0)
    est_pose = Pose(0.0, 0.0, 0.0)
    for i, (v_l, v_r, dt) in enumerate(phases, start=1):
        true_pose = run_phase(true_pose, v_l, v_r, dt, bias=1.0)
        est_pose = run_phase(est_pose, v_l, v_r, dt, bias=1.03)
        print(f"-- 구간 {i} --")
        print_pose("참값", true_pose)
        print_pose("추정값", est_pose)

    dx = est_pose.x - true_pose.x
    dy = est_pose.y - true_pose.y
    dtheta = math.degrees(est_pose.theta) - math.degrees(true_pose.theta)
    error = math.hypot(dx, dy)
    print("\n=== 최종 오차 ===")
    print(f"위치 오차: {error:.4f} m")
    print(f"방향 오차: {dtheta:.4f} deg")


if __name__ == "__main__":
    main()

줄별 해설

TRACK_WIDTH는 두 바퀴 "중심" 사이의 거리다. 로봇 외곽 폭이 아니라 실제로 바닥에 닿는 바퀴 접지점 사이 거리를 재야 한다.

Pose는 x, y, theta 세 값을 묶은 자료형이다. theta는 항상 라디안 단위로 저장하고, 출력할 때만 math.degrees로 변환한다.

forward_kinematics와 inverse_kinematics는 앞서 표로 정리한 두 공식을 그대로 옮긴 것이다. 인자 순서(v_l이 먼저, v_r이 나중)를 지키는 것이 중요한데, 뒤에 "실무에서 자주 틀리는 것"에서 이 부분을 다시 짚는다.

integrate_pose는 w가 0에 가까운지 먼저 확인한다. w가 정확히 0이면 (v/w) 계산에서 0으로 나누게 되므로, 아주 작은 값(1e-9)을 기준으로 직진 공식과 회전 포함 공식을 나눈다. 회전이 섞인 경우에는 앞서 유도한 정확한 호 적분 공식을 그대로 쓴다.

run_phase는 명령한 바퀴 속도(v_l, v_r)에 bias를 곱한 뒤 정기구학과 오도메트리 적분을 한 번에 실행한다. bias=1.0이면 오차 없는 참값, bias=1.03이면 두 바퀴 속도가 실제보다 3% 높게 측정된 상황을 흉내 낸다. 두 바퀴에 똑같이 곱하므로 직진 구간에서는 속도만 틀리고, 회전 구간에서는 각속도만 틀린다.

main의 phases 리스트는 "직진 2초 → 제자리 회전(90도가 되는 시간만큼) → 직진 1초" 세 구간을 정의한다. 같은 phases를 bias만 다르게 주어 참값과 추정값을 나란히 적분하고, 마지막에 두 최종 위치의 차이를 위치 오차와 방향 오차로 출력한다.

실행 결과

터미널에서 다음과 같이 실행한다.

python3 diff_drive_odometry.py

출력은 다음과 같다.

=== 기구학 변환 확인 ===
정기구학: v_l=0.20, v_r=0.30 -> v=0.2500 m/s, w=0.5000 rad/s
역기구학: v=0.30, w=0.40 -> v_l=0.2600 m/s, v_r=0.3400 m/s

=== 오도메트리 적분: 참값 vs 3% 계통오차 ===
-- 구간 1 --
참값: x=0.4000 m, y=0.0000 m, theta=0.0000 deg
추정값: x=0.4120 m, y=0.0000 m, theta=0.0000 deg
-- 구간 2 --
참값: x=0.4000 m, y=0.0000 m, theta=90.0000 deg
추정값: x=0.4120 m, y=0.0000 m, theta=92.7000 deg
-- 구간 3 --
참값: x=0.4000 m, y=0.2000 m, theta=90.0000 deg
추정값: x=0.4023 m, y=0.2058 m, theta=92.7000 deg

=== 최종 오차 ===
위치 오차: 0.0062 m
방향 오차: 2.7000 deg

1구간(직진)이 끝난 시점에 이미 x가 0.4000과 0.4120으로 갈라지고, 2구간(회전)에서는 각도가 90도와 92.7도로 벌어진다. 3구간을 지나면 이 두 오차가 합쳐져 최종 위치가 6mm 넘게 어긋난다. 바퀴 속도 측정 오차 자체는 3%로 작았지만, 여러 구간을 거치며 위치 오차로 누적된 것을 볼 수 있다.

실무에서 자주 틀리는 것

트랙 폭을 로봇 외곽 폭으로 재는 실수

바퀴가 로봇 몸체보다 안쪽에 달려 있는데도 몸체 전체 폭을 L로 쓰면 w 계산이 항상 같은 비율로 틀어진다.

# 잘못된 예: 로봇 겉면 폭을 그대로 사용
TRACK_WIDTH = 0.24

# 고친 예: 왼쪽 바퀴 중심에서 오른쪽 바퀴 중심까지 실측
TRACK_WIDTH = 0.20

각속도 단위(rad/s와 deg/s) 혼동

w는 항상 rad/s로 다뤄야 하는데, 사람이 이해하기 쉬운 도(degree) 값을 변환 없이 그대로 적분식에 넣는 실수가 흔하다.

# 잘못된 예: 도(deg) 값을 라디안 연산에 그대로 사용
w_deg = 30.0
theta += w_deg * dt

# 고친 예: 라디안으로 변환한 뒤 사용
w_rad = math.radians(30.0)
theta += w_rad * dt

회전이 섞인 구간을 매 스텝 단순 직진 공식으로 적분

회전 중에는 x, y 변화가 cos θ, sin θ에 비선형으로 의존한다. theta를 갱신하기 전의 값으로 이동량을 계산하면 dt가 클수록 오차가 커진다.

# 잘못된 예: 갱신 전 theta로 이동량을 계산
x += v * math.cos(theta) * dt
y += v * math.sin(theta) * dt
theta += w * dt

# 고친 예: 정확한 호 적분 공식을 쓰는 integrate_pose를 호출
pose = integrate_pose(pose, v, w, dt)

forward_kinematics 인자 순서를 뒤바꿔 정의

함수를 정의할 때 v_r을 먼저 받도록 실수하면, 호출부는 그대로인데 로봇이 명령과 반대로 돈다.

# 잘못된 예: 인자 순서가 뒤바뀜
def forward_kinematics(v_r: float, v_l: float):
    ...

# 고친 예: v_l을 먼저, v_r을 나중에
def forward_kinematics(v_l: float, v_r: float):
    ...

한눈에 보기

오차의 성격과 오도메트리에 미치는 영향
오차 종류대표 원인시간에 따른 변화대응 방법
계통오차트랙 폭·바퀴 지름 보정값 오류이동·회전량에 비례해 꾸준히 누적실측으로 보정값 검증, 주기적 재보정
랜덤오차바퀴 미끄러짐, 노면 요철평균이 0에 가까워 상대적으로 느리게 누적필터링과 외부 센서로 보정

정기구학은 바퀴 속도를 로봇 속도로, 역기구학은 그 반대로 바꾼다는 점, 그리고 오도메트리는 그 속도를 적분한 "추정값"일 뿐 실제 위치를아니라는 점을 기억해야 한다는 것으로 이번 장을 마무리한다.

연습 문제

  1. TRACK_WIDTH를 실제보다 10% 작게 설정하면, 같은 v_l, v_r에 대해 계산된 w가 실제보다 크게 나오는지 작게 나오는지 이유와 함께 설명하라.
  2. v_l=0.15 m/s, v_r=0.25 m/s, TRACK_WIDTH=0.18 m일 때 v와 w를 구하라.
  3. 로봇이 v=0.3 m/s, w=-0.5 rad/s로 2초 동안 움직였다. θ0=0이라 할 때, 정확한 호 적분 공식으로 최종 pose (x, y, θ)를 구하라.
  4. 계통오차는 시간이 지날수록 위치 오차가 누적되지만, 랜덤오차는 상대적으로 덜 누적된다고 했다. 그 이유를 직관적으로 설명하라.

정답과 해설

1번. w = (v_r - v_l) / L 식에서 L이 분모다. 실제 L보다 10% 작은 값(0.9·L)을 넣으면 나누는 값이 작아지므로 계산된 w는 실제보다 약 11%(1/0.9배) 크게 나온다. 로봇은 실제로 그만큼 덜 돌았는데도 소프트웨어는 더 많이 돈 것으로 판단하게 된다.

2번. v = (0.15 + 0.25) / 2 = 0.2000 m/s, w = (0.25 - 0.15) / 0.18 = 0.5556 rad/s.

3번. θ' = 0 + (-0.5)·2 = -1.0000 rad. v/w = 0.3 / (-0.5) = -0.6. x' = -0.6·(sin(-1.0) - sin(0)) = -0.6·(-0.841471) = 0.5049 m. y' = -(-0.6)·(cos(-1.0) - cos(0)) = 0.6·(0.540302 - 1) = -0.2758 m. 정리하면 최종 pose는 x≈0.5049 m, y≈-0.2758 m, θ≈-1.0000 rad(약 -57.2958도)이다.

4번. 계통오차는 매 구간마다 같은 방향으로 값이 밀리기 때문에 구간을 거칠수록 오차가 한 방향으로 계속 더해진다. 반면 랜덤오차는 이번 구간에서 양(+)으로 틀렸다가 다음 구간에서 음(-)으로 틀리는 식으로 부호가 들쭉날쭉하므로, 여러 구간을 합산하면 서로 상쇄되는 부분이 생겨 계통오차보다 느리게 쌓인다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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