Devin.KR

좌표계와 벡터 - 로봇이 세상을 보는 법

개발자KR 조회 2

이 장에서 배우는 것

로봇이 스스로 판단을 내리려면 먼저 "나는 어디에 있고, 목표는 어느 쪽에 있는가"를 숫자로 표현할 수 있어야 한다. 이 장에서는 그 숫자를 담는 그릇인 좌표계와, 좌표 사이의 관계를 계산하는 도구인 벡터를 다룬다. 로봇 시스템의 구성을 훑어본 앞 장에 이어, 이번에는 "감지"와 "판단" 단계에서 공통으로 쓰이는 수학 언어를 세운다.

  • 전역 좌표계와 로봇 좌표계가 왜 따로 필요한지 설명할 수 있다.
  • 두 점 사이의 상대 위치를 벡터 뺄셈으로 구할 수 있다.
  • 벡터의 크기(노름), 내적, 2차원 외적의 의미와 계산법을 안다.
  • numpy 배열로 좌표를 표현할 때 흔히 생기는 실수를 피할 수 있다.

문제 상황

창고에서 일하는 차동 구동 로봇을 생각해 보자. 천장 카메라나 지도 데이터가 로봇에게 "다음 목표점은 (5.2, 3.1)이다"라고 알려준다. 이 좌표는 창고 바닥에 고정된 전역 좌표계 기준이다. 그런데 로봇의 주행 로직은 "이 목표가 내 앞에 있는지, 뒤에 있는지, 왼쪽인지 오른쪽인지"를 알아야 바퀴 속도를 정할 수 있다.

처음 이런 코드를 짜는 사람은 전역 좌표값을 그대로 조향 계산에 넣어버리는 실수를 자주 한다. 그 결과 로봇이 뒤에 있는 목표를 향해 계속 전진하거나, 왼쪽에 있는 목표인데 오른쪽으로 도는 문제가 생긴다. 이 장은 이런 혼동을 좌표계 구분과 벡터 연산으로 정리하는 데서 시작한다. 다만 로봇 자신의 회전을 일반적인 행렬로 표현하는 방법은 다음 장에서 다루므로, 여기서는 벡터의 뺄셈·내적·2차원 외적만으로 "앞/뒤/좌/우"를 판별하는 수준까지만 다룬다.

전역 좌표계와 로봇 좌표계

전역 좌표계(global frame)는 바닥이나 지도에 고정된 좌표계다. 로봇이 움직여도 원점과 축의 방향은 변하지 않는다. 지도, 목표 지점, 장애물 위치처럼 "세상에 고정된" 정보는 보통 이 좌표계로 주어진다.

로봇 좌표계(robot frame)는 로봇 본체에 붙어서 로봇과 함께 이동하고 회전하는 좌표계다. 원점은 흔히 로봇의 중심이나 두 바퀴 축의 중점으로 잡고, x축은 로봇의 정면, y축은 로봇의 왼쪽을 가리키도록 정의한다. 이 규칙은 책이나 프로젝트마다 다를 수 있으므로, 코드에 어떤 규칙을 썼는지 반드시 문서화해야 한다.

전역 좌표계 안에서 로봇 좌표계가 어디에, 어떤 방향으로 놓여 있는지를 세 숫자 (x, y, θ)로 나타낸 것을 로봇의 자세(pose)라 부른다. θ는 로봇이 향한 방향, 즉 전역 x축과 로봇 x축 사이의 각도다. 같은 점이라도 어느 좌표계에서 보느냐에 따라 숫자가 완전히 달라진다는 점이 이번 장의 핵심이다.

전역 좌표계와 로봇 좌표계 비교
구분원점축 방향대표 용도
전역 좌표계지도/바닥에 고정처음 정한 대로 고정지도, 목표 지점, 장애물 위치
로봇 좌표계로봇 중심(또는 바퀴 축 중점)정면=x, 왼쪽=y, 로봇과 함께 회전조향 판단, 장애물 회피
로봇 좌표계의 축은 로봇의 위치와 방향에 따라 전역 좌표계 안에서 함께 움직인다

이 장에서는 좌표계 사이의 완전한 변환(회전행렬)은 다루지 않는다. 대신 "로봇 위치에서 목표까지의 상대 벡터"를 구하고, 그 벡터가 로봇이 향한 방향을 기준으로 앞인지 뒤인지, 왼쪽인지 오른쪽인지를 벡터 연산만으로 판별하는 방법을 다룬다.

벡터로 위치와 방향 표현하기

벡터는 크기와 방향을 가진 양이다. 평면에서는 두 실수의 순서쌍 (x, y)로 나타낸다. 점 하나도 원점에서 그 점까지의 위치벡터로 볼 수 있다.

두 점 사이의 상대 위치는 벡터 뺄셈으로 구한다. target - robot_pos는 로봇에서 목표까지 가리키는 벡터다. 이 벡터의 크기(노름, norm)는 sqrt(dx*dx + dy*dy)로, 두 점 사이의 유클리드 거리와 같다.

벡터 덧셈에는 유용한 성질이 있다. 여러 구간을 연달아 이동한 벡터를 모두 더하면, 중간 경유점은 상쇄되고 출발점에서 도착점까지의 벡터만 남는다. 로봇이 여러 목표점을 순서대로 거쳐갈 때 전체 이동량을 확인하는 데 이 성질을 쓸 수 있다.

내적(dot product) a·b = ax*bx + ay*by은 한 벡터를 다른 벡터 방향으로 투영한 성분의 길이와 관련된 값이다. 로봇의 진행 방향을 가리키는 단위벡터와 상대 벡터의 내적을 구하면, 그 부호로 목표가 "앞"인지 "뒤"인지 판별할 수 있다(양수면 앞, 음수면 뒤).

2차원 외적(2D cross product) a×b = ax*by - ay*bx는 3차원 외적의 z성분에 해당하는 스칼라 값이다. 평면 위 로봇에서는 이 부호만으로 "왼쪽"인지 "오른쪽"인지 판별할 수 있다. 두 연산 모두 다음 장에서 다룰 회전행렬을 이루는 cosθ, sinθ와 맞닿아 있다.

벡터의 내적은 전진 성분을, 2차원 외적은 좌우 성분을 알려준다

numpy로 좌표와 벡터 다루기

numpy의 np.array는 좌표를 담기에 알맞다. 두 배열을 더하거나 빼면 원소별로 계산되므로, 벡터 뺄셈과 덧셈을 그대로 코드로 옮길 수 있다. 거리 계산에는 np.linalg.norm을, 내적 계산에는 np.dot을 쓴다.

주의할 점은 dtype이다. np.array([0, 0])처럼 정수만 넣어 배열을 만들면 dtype이 정수형으로 고정되고, 이후 실수를 대입해도 소수점 아래가 잘려나간다. 로봇 좌표처럼 실수를 다루는 배열은 처음부터 np.array([0.0, 0.0])처럼 실수로 선언해야 한다. 이 실수는 뒤의 "실무에서 자주 틀리는 것"에서 다시 다룬다.

완성 코드

다음 코드는 로봇 위치와 진행 방향, 그리고 전역 좌표계로 주어진 목표점 네 개를 입력받아, 각 목표점까지의 거리와 전/후·좌/우 관계를 벡터 연산만으로 판별한다. numpy만 있으면 실행된다.

"""좌표계와 벡터 - 차동 구동 로봇의 목표점 판별 예제"""
import numpy as np


def forward_vector(heading_rad):
    return np.array([np.cos(heading_rad), np.sin(heading_rad)])


def cross2d(a, b):
    return a[0] * b[1] - a[1] * b[0]


def format_vec(v):
    return f"({v[0]:.2f}, {v[1]:.2f})"


def classify_target(robot_pos, heading_rad, target):
    rel = target - robot_pos
    distance = np.linalg.norm(rel)
    heading = forward_vector(heading_rad)
    forward_component = np.dot(rel, heading)
    side_component = cross2d(heading, rel)
    front_back = "전방" if forward_component > 0 else "후방"
    left_right = "왼쪽" if side_component > 0 else "오른쪽"
    return distance, front_back, left_right


def main():
    robot_pos = np.array([2.0, 1.0])
    heading_deg = 30.0
    heading_rad = np.radians(heading_deg)

    waypoints = [
        np.array([4.0, 1.5]),
        np.array([1.0, 3.0]),
        np.array([2.0, -1.0]),
        np.array([5.0, 4.0]),
    ]

    print(f"로봇 위치: {format_vec(robot_pos)} / 진행 방향: {heading_deg:.1f}도")
    print("-" * 50)
    for i, wp in enumerate(waypoints):
        distance, front_back, left_right = classify_target(robot_pos, heading_rad, wp)
        print(f"목표점 {i} {format_vec(wp)} → 거리 {distance:.2f} m, {front_back}, {left_right}")

    print("-" * 50)
    segments = [waypoints[0] - robot_pos]
    for i in range(1, len(waypoints)):
        segments.append(waypoints[i] - waypoints[i - 1])
    total_by_sum = np.sum(segments, axis=0)
    total_direct = waypoints[-1] - robot_pos
    print(f"구간 벡터 합: {format_vec(total_by_sum)}")
    print(f"시작→끝 직선 벡터: {format_vec(total_direct)}")
    print(f"두 벡터 일치 여부: {np.allclose(total_by_sum, total_direct)}")


if __name__ == "__main__":
    main()

줄별 해설

  • forward_vector: 각도(라디안)를 받아 로봇이 향한 방향의 단위벡터 (cosθ, sinθ)를 반환한다. 다음 장에서 회전행렬로 확장될 값이지만, 여기서는 방향을 나타내는 벡터 하나로 충분하다.
  • cross2d: 2차원 외적의 스칼라 값을 계산한다. 인자 순서를 바꾸면 부호가 반대가 된다는 점을 뒤에서 다시 짚는다.
  • format_vec: 벡터를 소수점 둘째 자리까지 고정된 형식으로 출력하기 위한 보조 함수다. numpy 배열을 그대로 출력하면 값마다 자릿수가 달라져 보기 어렵다.
  • classify_target: rel = target - robot_pos로 상대 벡터를 구하고, np.linalg.norm으로 거리를, np.dot으로 전/후 성분을, cross2d로 좌/우 성분을 계산한다.
  • main: 로봇 위치 (2.0, 1.0)과 진행 방향 30도, 목표점 네 개를 정의하고 각각의 판정 결과를 출력한다.
  • segments 리스트와 total_by_sum: 로봇 위치에서 시작해 목표점을 순서대로 거쳐가는 구간 벡터들을 모두 더한 값이, 시작점에서 마지막 목표점까지의 직선 벡터와 같은지 np.allclose로 확인한다. 벡터 덧셈의 성질을 코드로 확인하는 부분이다.

실행 결과

$ python3 robot_frame_vectors.py
로봇 위치: (2.00, 1.00) / 진행 방향: 30.0도
--------------------------------------------------
목표점 0 (4.00, 1.50) → 거리 2.06 m, 전방, 오른쪽
목표점 1 (1.00, 3.00) → 거리 2.24 m, 전방, 왼쪽
목표점 2 (2.00, -1.00) → 거리 2.00 m, 후방, 오른쪽
목표점 3 (5.00, 4.00) → 거리 4.24 m, 전방, 왼쪽
--------------------------------------------------
구간 벡터 합: (3.00, 3.00)
시작→끝 직선 벡터: (3.00, 3.00)
두 벡터 일치 여부: True

실무에서 자주 틀리는 것

전역 좌표와 상대 좌표를 같은 이름으로 섞어 쓰는 실수

변수 이름을 pos, target처럼 두루뭉술하게 지으면, 코드만 보고는 어디가 전역 좌표이고 어디가 상대 좌표인지 알 수 없다. 함수가 늘어날수록 전역 좌표를 그대로 조향 계산에 넘기는 실수가 쌓인다.

# 틀린 코드
pos = np.array([2.0, 1.0])
target = np.array([4.0, 1.5])
diff = target - pos
steer(diff)  # diff가 전역 기준인지 로봇 기준인지 이름만으로는 알 수 없다
# 고친 코드
robot_pos_global = np.array([2.0, 1.0])
target_global = np.array([4.0, 1.5])
rel_global = target_global - robot_pos_global  # 아직 전역 좌표축 기준 벡터임을 이름에 남긴다
steer(rel_global)

좌표를 파이썬 list로 관리하다 벡터 연산에서 오류가 나는 실수

파이썬 리스트는 뺄셈 연산자를 지원하지 않는다. numpy 배열로 바꾸지 않은 채 벡터처럼 다루면 실행 중 오류가 난다.

# 틀린 코드
robot_pos = [2.0, 1.0]
target = [4.0, 1.5]
rel = target - robot_pos  # TypeError 발생
# 고친 코드
robot_pos = np.array([2.0, 1.0])
target = np.array([4.0, 1.5])
rel = target - robot_pos  # 원소별 뺄셈이 정상 동작한다

2차원 외적의 인자 순서를 바꿔서 좌우 판정이 뒤집히는 실수

cross2d(a, b)는 a와 b의 순서를 바꾸면 부호가 반대가 된다. 진행 방향 벡터와 상대 벡터의 순서를 실수로 바꾸면 왼쪽과 오른쪽 판정이 통째로 뒤집힌다.

# 틀린 코드
side_component = cross2d(rel, heading)  # 순서가 반대다
# 고친 코드
side_component = cross2d(heading, rel)  # 기준이 되는 진행 방향을 먼저 넣는다

numpy 배열을 정수형으로 만들어 놓고 소수 좌표를 대입하는 실수

정수 값만으로 배열을 만들면 dtype이 정수형으로 고정된다. 이후 실수를 대입해도 소수점 아래는 잘려나간다.

# 틀린 코드
robot_pos = np.array([0, 0])       # dtype이 int64로 정해진다
robot_pos[0] = 1.5                 # 1로 잘려서 저장된다
# 고친 코드
robot_pos = np.array([0.0, 0.0])   # 처음부터 float로 선언한다
robot_pos[0] = 1.5                 # 1.5가 그대로 저장된다

한눈에 보기

이 장에서 쓴 벡터 연산 정리
연산수식numpy 표현의미
뺄셈target - robot_pos배열끼리 -상대 위치 벡터
덧셈v1 + v2 + ...배열끼리 + 또는 np.sum구간 이동 벡터의 합
노름sqrt(dx² + dy²)np.linalg.norm두 점 사이 거리
내적a·b = ax·bx + ay·bynp.dot전/후 성분(부호로 판별)
2D 외적a×b = ax·by - ay·bx직접 구현좌/우 성분(부호로 판별)

연습 문제

  1. 로봇이 전역 좌표 (1.0, 1.0)에 있고 진행 방향이 0도(전역 x축과 같은 방향)다. 목표점이 (1.0, 4.0)에 있을 때, 상대 벡터와 거리를 구하고, 이 장의 classify_target 기준으로는 전/후가 어떻게 판정되는지 설명하라.
  2. 로봇이 A(0,0)에서 출발해 B(2,1), C(5,1)을 거쳐 D(5,4)까지 이동했다. 구간 벡터 A→B, B→C, C→D를 모두 더한 값과 A→D 직선 벡터를 각각 구하고, 두 값이 같은 이유를 벡터 덧셈의 성질로 설명하라.
  3. cross2d(a, b) 대신 cross2d(b, a)를 쓰면 결과가 어떻게 달라지는지, 그 이유를 벡터의 성질로 설명하라.
  4. np.array([0, 0])로 배열을 만든 뒤 배열[1] = 2.7을 대입하면 실제로 저장되는 값은 무엇이며, 그 이유는 무엇인가.

정답과 해설

  1. 상대 벡터는 (1.0, 4.0) - (1.0, 1.0) = (0.0, 3.0)이고 거리는 3.0이다. 진행 방향 단위벡터는 (1.0, 0.0)이므로 내적은 0·1 + 3·0 = 0이다. classify_target은 내적이 0보다 클 때만 "전방"으로 판정하므로, 정확히 0인 이 경우는 "후방"으로 분류된다. 목표가 로봇 바로 옆(90도 방향)에 있을 때 이 단순한 경계값 판정이 의도와 다르게 동작할 수 있다는 한계를 보여주는 예다.
  2. 구간 벡터의 합은 (2,1) + (3,0) + (0,3) = (5,4)이고, A→D 직선 벡터는 (5,4) - (0,0) = (5,4)로 같다. 벡터 덧셈에서는 중간 경유점의 좌표가 서로 상쇄되고 시작점과 끝점만 남기 때문에, 경유지가 몇 개든 구간 벡터의 합은 항상 시작점에서 끝점까지의 직선 벡터와 같다.
  3. 부호만 반대로 바뀐다. 외적은 반교환(anticommutative) 성질을 가지므로 a×b = -(b×a)이며, 크기는 그대로지만 부호가 뒤집혀 왼쪽/오른쪽 판정이 서로 바뀐다.
  4. 저장되는 값은 2다. 배열을 정수 값 0, 0으로만 만들었기 때문에 dtype이 정수형(int64 등)으로 고정되고, 이후 대입되는 실수는 이 dtype에 맞춰 소수점 아래가 버려진 채 저장된다. 실수 좌표를 다루려면 배열을 만들 때부터 실수 값을 넣어야 한다.

댓글 0

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

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