Skip to main content
Summary

숫자 하나가 로봇의 움직임이 되기까지

로봇을 움직인다는 말은 조금 멋지게 들리지만, 컴퓨터 안에서는 결국 숫자를 바꾸는 일이다. 로봇의 위치가 (2, 3)에서 (2.1, 3.0)으로 바뀌고, 방향이 30도에서 31도로 바뀌고, 센서값이 1.2m에서 1.18m로 바뀐다. 겉으로 보면 바퀴가 구르고 팔이 움직이고 카메라가 세상을 보는 것 같지만, 프로그램 입장에서는 수많은 숫자가 계속 갱신될 뿐이다.

문제는 이 숫자들이 혼자 다니지 않는다는 점이다. 위치는 보통 x, y처럼 여러 값이 묶여 있고, 속도도 x 방향 속도와 y 방향 속도가 함께 움직인다. 카메라가 본 점들은 수백 개, 수천 개일 수 있고, 라이다 센서는 한 번에 수많은 거리값을 반환한다. 로봇공학에서 NumPy가 중요한 이유가 여기에 있다. NumPy는 많은 숫자를 하나의 덩어리로 다루게 해 주고, 그 덩어리 전체에 빠르게 계산을 적용할 수 있게 해 준다.

이번 장에서는 로봇 수학의 가장 기본 재료인 배열, 벡터, 행렬을 NumPy로 다루어 본다. 아직 어려운 공식은 필요 없다. 대신 다음 감각을 익히는 것이 중요하다.

  • 로봇의 위치와 속도는 벡터로 표현할 수 있다.

  • 여러 개의 벡터를 모으면 행렬처럼 다룰 수 있다.

  • 행렬 곱은 좌표를 변환하는 강력한 도구다.

  • 브로드캐스팅을 사용하면 많은 점을 한 번에 움직일 수 있다.

이 네 가지 감각은 앞으로 좌표 변환, 회전, 센서 데이터 처리, 위치 추정, 경로 계획으로 계속 이어진다.

NumPy로 벡터와 행렬 다루기

Python 리스트로 시작해 보기

먼저 NumPy 없이 Python 리스트만 사용해 보자. 로봇의 위치를 x, y 좌표로 표현하면 다음과 같다.

robot_position = [2.0, 3.0]
robot_velocity = [0.5, 0.0]

여기서 robot_position은 로봇이 월드 좌표계에서 x 방향으로 2.0, y 방향으로 3.0 위치에 있다는 뜻이다. robot_velocity는 로봇이 x 방향으로 초당 0.5만큼 움직이고, y 방향으로는 움직이지 않는다는 뜻이다.

그렇다면 1초 뒤 위치는 어떻게 계산할 수 있을까? 머릿속으로는 간단하다.

새 위치 = 현재 위치 + 속도 × 시간

리스트로 직접 계산하면 다음처럼 각 원소를 하나씩 꺼내야 한다.

robot_position = [2.0, 3.0]
robot_velocity = [0.5, 0.0]
dt = 1.0

new_position = [
    robot_position[0] + robot_velocity[0] * dt,
    robot_position[1] + robot_velocity[1] * dt
]

print(new_position)

실행 결과는 다음과 같다.

[2.5, 3.0]

계산 자체는 어렵지 않다. 하지만 좌표가 2개가 아니라 3개라면 어떨까? 로봇이 하나가 아니라 100대라면? 센서값이 360개라면? 리스트로 하나씩 꺼내 계산하는 방식은 금방 지저분해진다. 로봇을 다루다 보면 숫자가 많아지는 것은 피할 수 없다. 그래서 우리는 리스트보다 수치 계산에 더 잘 맞는 도구가 필요하다.

그 도구가 NumPy 배열이다.

NumPy 배열 만들기

NumPy는 보통 np라는 이름으로 불러온다.

import numpy as np

이제 리스트를 NumPy 배열로 바꿔 보자.

import numpy as np

robot_position = np.array([2.0, 3.0])
robot_velocity = np.array([0.5, 0.0])
dt = 1.0

new_position = robot_position + robot_velocity * dt

print(new_position)

결과는 다음과 같다.

[2.5 3. ]

계산식이 훨씬 자연스러워졌다. robot_velocity * dt는 속도 벡터 전체에 시간 간격을 곱한다. 그리고 그 결과를 위치 벡터 전체에 더한다. 우리가 수학에서 쓰던 식과 거의 같은 모양이다.

new_position = robot_position + robot_velocity * dt

이 한 줄은 앞으로 계속 등장할 로봇 시뮬레이션의 핵심 문장이다. 로봇이 조금 움직일 때마다 현재 위치에 작은 이동량을 더한다. 이 작은 이동량은 보통 속도와 시간 간격으로 계산한다.

NumPy 배열이 리스트와 다른 점을 더 분명히 보기 위해 간단한 예를 비교해 보자.

python_list = [1, 2, 3]
numpy_array = np.array([1, 2, 3])

print(python_list * 2)
print(numpy_array * 2)

결과는 다음과 같다.

[1, 2, 3, 1, 2, 3]
[2 4 6]

Python 리스트에 * 2를 하면 리스트가 두 번 반복된다. 반면 NumPy 배열에 * 2를 하면 각 숫자에 2가 곱해진다. 로봇 수학에서 우리가 원하는 것은 대부분 두 번째 동작이다. 위치를 두 배로 늘리고, 속도에 시간을 곱하고, 센서값에서 오차를 빼는 계산을 해야 하기 때문이다.

배열의 모양 이해하기

NumPy 배열을 다룰 때는 값만큼이나 모양이 중요하다. 배열의 모양은 shape로 확인할 수 있다.

position = np.array([2.0, 3.0])

print(position)
print(position.shape)

결과는 다음과 같다.

[2. 3.]
(2,)

(2,)는 원소가 2개인 1차원 배열이라는 뜻이다. 로봇의 2D 위치 벡터를 표현하기에 알맞다.

이번에는 여러 점을 한 번에 표현해 보자. 예를 들어 로봇 주변에 감지된 장애물 세 개의 위치가 다음과 같다고 하자.

obstacles = np.array([
    [1.0, 2.0],
    [3.0, 1.0],
    [4.0, 5.0]
])

print(obstacles)
print(obstacles.shape)

결과는 다음과 같다.

[[1. 2.]
 [3. 1.]
 [4. 5.]]
(3, 2)

(3, 2)는 행이 3개, 열이 2개라는 뜻이다. 여기서는 점이 3개 있고, 각 점은 x와 y라는 두 값을 가진다. 로봇공학에서는 이런 형태가 자주 나온다. 여러 개의 위치, 여러 개의 센서값, 여러 개의 경로점을 하나의 배열에 담아 처리한다.

배열의 차원 수는 ndim으로 확인할 수 있다.

print(position.ndim)
print(obstacles.ndim)

결과는 다음과 같다.

1
2

position은 1차원 배열이고, obstacles는 2차원 배열이다. 지금 당장은 shape와 ndim이 조금 낯설 수 있다. 하지만 배열 계산이 예상대로 되지 않을 때 가장 먼저 확인해야 할 것이 바로 이 두 가지다. 로봇이 이상한 방향으로 날아가거나 그래프가 엉뚱하게 그려질 때, 원인은 생각보다 자주 배열의 모양에 있다.

위치 벡터 표현하기

벡터는 여러 숫자를 순서대로 묶은 것이다. 로봇공학에서는 벡터를 주로 위치, 방향, 속도, 힘 같은 양을 표현하는 데 사용한다.

2차원 평면 위 로봇의 위치는 다음처럼 표현할 수 있다.

position = np.array([2.0, 3.0])

이 배열은 단순한 숫자 묶음이지만, 의미를 붙이면 로봇의 위치가 된다.

position[0] = x 위치
position[1] = y 위치

코드로 확인해 보자.

position = np.array([2.0, 3.0])

x = position[0]
y = position[1]

print('x:', x)
print('y:', y)

결과는 다음과 같다.

x: 2.0
y: 3.0

이번에는 목표 지점도 벡터로 표현해 보자.

robot_position = np.array([2.0, 3.0])
goal_position = np.array([7.0, 6.0])

로봇에서 목표까지 향하는 벡터는 목표 위치에서 현재 위치를 빼면 된다.

to_goal = goal_position - robot_position

print(to_goal)

결과는 다음과 같다.

[5. 3.]

이 벡터는 로봇이 목표에 도착하려면 x 방향으로 5, y 방향으로 3만큼 이동해야 한다는 뜻이다. 여기서 중요한 점은 뺄셈 하나로 방향과 남은 거리를 동시에 얻었다는 것이다. 좌표를 벡터로 표현하면 이런 계산이 아주 자연스러워진다.

목표까지의 직선거리는 벡터의 길이로 구할 수 있다. NumPy에서는 np.linalg.norm을 사용한다. 여기서 linalg는 linear algebra, 즉 선형대수를 뜻한다. 지금은 벡터와 행렬 계산 도구가 들어 있는 상자라고 생각하면 충분하다.

distance = np.linalg.norm(to_goal)

print(distance)

결과는 대략 다음과 같다.

5.830951894845301

로봇은 목표까지 약 5.83만큼 떨어져 있다. 앞으로 목표 추종 로봇을 만들 때 이 거리를 이용해 도착 여부를 판단할 것이다.

if distance < 0.1:
    print('목표에 도착했습니다.')
else:
    print('아직 이동해야 합니다.')

수학 공식으로만 보면 거리 계산은 딱딱하게 느껴질 수 있다. 하지만 로봇의 입장에서 보면 이것은 아주 현실적인 질문이다. 나는 목표에서 얼마나 떨어져 있는가? 아직 움직여야 하는가, 아니면 멈춰도 되는가? 벡터는 이런 질문에 답하기 위한 가장 기본적인 언어다.

속도 벡터로 위치 업데이트하기

로봇이 움직인다는 것은 위치가 시간에 따라 바뀐다는 뜻이다. 가장 간단한 이동 모델은 속도가 일정하다고 가정하고 위치를 업데이트하는 것이다.

position = np.array([0.0, 0.0])
velocity = np.array([1.0, 0.5])
dt = 0.1

position = position + velocity * dt

print(position)

결과는 다음과 같다.

[0.1  0.05]

dt는 시간 간격이다. 여기서는 0.1초 동안 움직였다고 가정했다. x 방향 속도가 초당 1.0이므로 0.1초 동안 x 위치는 0.1 증가한다. y 방향 속도는 초당 0.5이므로 0.1초 동안 y 위치는 0.05 증가한다.

이 과정을 여러 번 반복하면 로봇의 움직임이 된다.

position = np.array([0.0, 0.0])
velocity = np.array([1.0, 0.5])
dt = 0.1

for step in range(10):
    position = position + velocity * dt
    print(step, position)

결과는 다음처럼 나온다.

0 [0.1  0.05]
1 [0.2 0.1]
2 [0.3  0.15]
3 [0.4 0.2]
4 [0.5  0.25]
5 [0.6 0.3]
6 [0.7  0.35]
7 [0.8 0.4]
8 [0.9  0.45]
9 [1.  0.5]

이 코드는 작지만 로봇 시뮬레이터의 씨앗이다. 위치가 있고, 속도가 있고, 시간이 흐른다. 매 순간 위치를 조금씩 갱신하면 궤적이 만들어진다. 나중에 Matplotlib으로 이 위치들을 그리면 로봇이 선을 따라 움직이는 모습을 볼 수 있다.

궤적을 저장하려면 위치를 리스트에 모아 두면 된다.

position = np.array([0.0, 0.0])
velocity = np.array([1.0, 0.5])
dt = 0.1

trajectory = []

for step in range(10):
    position = position + velocity * dt
    trajectory.append(position.copy())

trajectory = np.array(trajectory)

print(trajectory)
print(trajectory.shape)

여기서 position.copy()가 중요하다. NumPy 배열은 객체이므로 그냥 position을 저장하면 나중에 값이 바뀔 때 예상치 못한 결과가 생길 수 있다. 현재 순간의 위치를 기록하고 싶다면 복사본을 저장하는 습관을 들이는 것이 좋다.

trajectory의 모양은 (10, 2)가 된다. 위치가 10개 있고, 각 위치는 x, y 두 값을 가진다는 뜻이다. 즉, 궤적도 결국 여러 위치 벡터를 쌓아 만든 배열이다.

센서값 배열 다루기

로봇은 바퀴만 굴리는 기계가 아니다. 주변을 감지해야 한다. 초음파 센서, 적외선 센서, 카메라, LiDAR, IMU 같은 장치들은 모두 숫자를 내보낸다. 센서 데이터도 NumPy 배열로 표현하면 편리하다.

예를 들어 로봇 전방에 5개의 거리 센서가 있다고 하자. 각 센서는 장애물까지의 거리를 미터 단위로 측정한다.

sensor_distances = np.array([1.2, 0.8, 2.5, 1.7, 0.4])

print(sensor_distances)

가장 가까운 장애물 거리는 np.min으로 구할 수 있다.

nearest = np.min(sensor_distances)

print(nearest)

결과는 다음과 같다.

0.4

평균 거리는 np.mean으로 구한다.

average_distance = np.mean(sensor_distances)

print(average_distance)

센서값 중에서 1.0m보다 가까운 값만 찾고 싶다면 조건식을 사용할 수 있다.

close_mask = sensor_distances < 1.0

print(close_mask)
print(sensor_distances[close_mask])

결과는 다음과 같다.

[False  True False False  True]
[0.8 0.4]

close_mask는 각 센서값이 조건을 만족하는지 나타내는 배열이다. 이런 배열을 불리언 마스크라고 한다. 불리언은 True 또는 False 값을 뜻한다. 마스크라는 말은 필요한 값만 통과시키고 나머지는 가리는 필터처럼 작동한다는 뜻이다.

이 방식은 로봇의 안전 판단에 바로 사용할 수 있다.

safe_distance = 0.5

if np.any(sensor_distances < safe_distance):
    print('너무 가까운 장애물이 있습니다. 정지!')
else:
    print('계속 이동할 수 있습니다.')

np.any는 조건을 만족하는 값이 하나라도 있는지 확인한다. 로봇 앞 센서 중 하나라도 0.5m보다 가까운 장애물을 감지하면 멈추게 할 수 있다.

반대로 모든 값이 조건을 만족하는지 확인하려면 np.all을 사용한다.

if np.all(sensor_distances > safe_distance):
    print('모든 방향이 안전합니다.')

이처럼 센서 데이터를 배열로 다루면 판단 로직이 짧고 분명해진다. 하나씩 반복문으로 검사할 수도 있지만, NumPy는 많은 값을 한 번에 비교하고 요약하는 데 매우 강하다.

행렬은 숫자의 표가 아니다

행렬은 숫자를 행과 열로 배치한 것이다. 하지만 로봇공학에서 행렬은 단순한 표가 아니다. 행렬은 벡터를 다른 벡터로 바꾸는 도구다. 특히 위치와 방향을 변환할 때 행렬이 자주 등장한다.

먼저 간단한 행렬을 만들어 보자.

A = np.array([
    [1.0, 2.0],
    [3.0, 4.0]
])

print(A)
print(A.shape)

결과는 다음과 같다.

[[1. 2.]
 [3. 4.]]
(2, 2)

이 행렬은 2행 2열이다. 2D 벡터에 곱하면 또 다른 2D 벡터를 만든다.

v = np.array([1.0, 0.0])

result = A @ v

print(result)

여기서 @는 행렬 곱 연산자다. 결과는 다음과 같다.

[1. 3.]

행렬 곱은 처음 보면 조금 이상하다. 왜 [1, 0]에 A를 곱했더니 [1, 3]이 될까? 계산 과정을 풀어 쓰면 다음과 같다.

첫 번째 결과 = 1.0 × 1.0 + 2.0 × 0.0 = 1.0
두 번째 결과 = 3.0 × 1.0 + 4.0 × 0.0 = 3.0

행렬 곱은 각 행과 벡터를 짝지어 곱하고 더하는 계산이다. 이 계산은 좌표 변환에서 강력한 의미를 가진다. 행렬이 벡터의 방향과 크기를 바꿀 수 있기 때문이다.

원소별 곱과 행렬 곱 구분하기

NumPy에서 *와 @는 완전히 다르다. 이 차이를 반드시 구분해야 한다.

*는 같은 위치에 있는 원소끼리 곱한다.

A = np.array([
    [1.0, 2.0],
    [3.0, 4.0]
])

B = np.array([
    [10.0, 20.0],
    [30.0, 40.0]
])

print(A * B)

결과는 다음과 같다.

[[ 10.  40.]
 [ 90. 160.]]

반면 @는 행렬 곱이다.

print(A @ B)

결과는 다음과 같다.

[[ 70. 100.]
 [150. 220.]]

두 결과는 전혀 다르다. 원소별 곱은 각 칸끼리 곱하는 계산이고, 행렬 곱은 한 행과 한 열을 묶어 계산하는 변환이다.

로봇 좌표 변환에서는 대부분 @를 사용한다. 예를 들어 로봇 앞 방향을 나타내는 벡터가 있다고 하자.

forward = np.array([1.0, 0.0])

이 벡터는 x축 방향을 바라본다. 이제 이 벡터를 90도 회전시키고 싶다. 2D에서 90도 회전 행렬은 다음과 같다.

R = np.array([
    [0.0, -1.0],
    [1.0,  0.0]
])

회전은 원소별 곱이 아니라 행렬 곱으로 해야 한다.

rotated = R @ forward

print(rotated)

결과는 다음과 같다.

[0. 1.]

x축을 향하던 벡터 [1, 0]이 y축을 향하는 [0, 1]로 바뀌었다. 이것이 행렬을 변환 도구라고 부르는 이유다.

만약 실수로 R * forward를 사용하면 어떻게 될까?

wrong = R * forward

print(wrong)

결과는 다음과 같다.

[[ 0. -0.]
 [ 1.  0.]]

결과가 벡터가 아니라 2×2 배열이 되어 버렸다. 로봇의 방향 벡터 하나를 얻고 싶었는데 엉뚱한 모양의 값이 나온 것이다. NumPy 코드에서 *와 @를 혼동하면 이런 일이 자주 생긴다.

간단히 정리하면 다음과 같다.

  • 센서값을 보정하기 위해 각 값에 계수를 곱할 때는 *를 사용한다.

  • 회전 행렬로 방향 벡터를 돌릴 때는 @를 사용한다.

  • 좌표 변환 행렬을 여러 번 연결할 때도 @를 사용한다.

이 구분은 앞으로 회전 행렬과 동차 좌표계를 배울 때 계속 중요해진다.

작은 회전 변환 만들어 보기

이번에는 각도를 사용해서 회전 행렬을 직접 만들어 보자. NumPy의 삼각함수는 라디안을 사용한다. 라디안은 각도를 표현하는 또 다른 단위다. 180도는 π 라디안이고, 90도는 π/2 라디안이다.

도 단위를 라디안으로 바꿀 때는 np.deg2rad를 사용하면 편하다.

angle_deg = 30.0
angle_rad = np.deg2rad(angle_deg)

print(angle_rad)

2D 회전 행렬은 다음과 같이 만들 수 있다.

c = np.cos(angle_rad)
s = np.sin(angle_rad)

R = np.array([
    [c, -s],
    [s,  c]
])

print(R)

이제 로봇의 전방 벡터를 30도 회전해 보자.

forward = np.array([1.0, 0.0])
rotated_forward = R @ forward

print(rotated_forward)

대략 다음과 같은 결과가 나온다.

[0.8660254 0.5      ]

이는 길이가 1인 벡터가 30도 방향을 바라보게 되었다는 뜻이다. x 성분은 약 0.866, y 성분은 0.5다. 숫자만 보면 낯설지만, 그림으로 그리면 원 위의 한 방향을 가리키는 화살표다.

로봇이 현재 바라보는 방향을 벡터로 표현하면 여러 가지를 쉽게 계산할 수 있다. 예를 들어 로봇이 전방으로 2m 이동한다면 다음 위치 변화량은 이렇게 구한다.

move_distance = 2.0
movement = rotated_forward * move_distance

print(movement)

결과는 다음과 같다.

[1.73205081 1.        ]

30도 방향으로 2m 움직이면 x는 약 1.73, y는 1.0 증가한다. 여기서는 회전할 때 @를 사용했고, 이동 거리만큼 늘릴 때는 *를 사용했다. 두 연산의 역할이 다르다는 점을 다시 확인할 수 있다.

여러 점을 한 번에 표현하기

로봇이 보는 세상에는 점이 하나만 있지 않다. 장애물의 꼭짓점, 라이다 점들, 카메라 특징점, 경로점 등 많은 점이 동시에 등장한다. 이런 점들을 하나의 배열로 묶으면 계산이 쉬워진다.

예를 들어 로봇 주변에 네 개의 점이 있다고 하자.

points = np.array([
    [1.0, 0.0],
    [0.0, 1.0],
    [-1.0, 0.0],
    [0.0, -1.0]
])

print(points.shape)

결과는 다음과 같다.

(4, 2)

네 개의 점이 있고 각 점은 x, y 두 좌표를 가진다. 이 점들을 모두 같은 방향으로 이동시키고 싶다면 어떻게 해야 할까? 예를 들어 모든 점을 x 방향으로 3, y 방향으로 2만큼 옮기고 싶다.

리스트 방식이라면 반복문을 떠올릴 수 있다. 하지만 NumPy에서는 다음처럼 바로 더할 수 있다.

translation = np.array([3.0, 2.0])

moved_points = points + translation

print(moved_points)

결과는 다음과 같다.

[[4. 2.]
 [3. 3.]
 [2. 2.]
 [3. 1.]]

points의 모양은 (4, 2)이고, translation의 모양은 (2,)다. 모양이 다른데도 덧셈이 되었다. NumPy가 translation을 네 점 각각에 자동으로 더해 주었기 때문이다. 이것을 브로드캐스팅이라고 한다.

브로드캐스팅의 감각

브로드캐스팅은 작은 배열을 큰 배열의 모양에 맞춰 자동으로 확장해 계산하는 규칙이다. 이름은 조금 어렵지만 감각은 단순하다. 여러 점에 같은 이동량을 더하고 싶다면, 이동 벡터 하나만 준비하면 된다.

points = np.array([
    [1.0, 0.0],
    [0.0, 1.0],
    [-1.0, 0.0],
    [0.0, -1.0]
])

translation = np.array([3.0, 2.0])

moved_points = points + translation

이 계산은 내부적으로 다음과 같은 느낌으로 작동한다.

[1, 0]  + [3, 2] = [4, 2]
[0, 1]  + [3, 2] = [3, 3]
[-1, 0] + [3, 2] = [2, 2]
[0, -1] + [3, 2] = [3, 1]

브로드캐스팅을 사용하면 반복문 없이도 여러 점을 한 번에 이동시킬 수 있다. 로봇 시뮬레이션에서 이것은 매우 유용하다. 장애물 전체를 옮기거나, 로봇 몸체를 구성하는 점들을 현재 위치로 이동시키거나, 센서 점군 전체에 같은 보정값을 더할 수 있다.

센서값에도 브로드캐스팅을 사용할 수 있다. 예를 들어 거리 센서에 일정한 편향이 있다고 하자. 모든 센서가 실제보다 0.05m 크게 측정한다면, 이 값을 빼서 보정할 수 있다.

raw_distances = np.array([1.25, 0.85, 2.55, 1.75, 0.45])
bias = 0.05

corrected_distances = raw_distances - bias

print(corrected_distances)

결과는 다음과 같다.

[1.2 0.8 2.5 1.7 0.4]

여기서는 배열에서 숫자 하나를 뺐다. NumPy는 그 숫자를 배열의 모든 원소에 적용한다. 이것도 브로드캐스팅이다.

여러 점 회전시키기

이제 조금 더 로봇다운 예제로 들어가 보자. 로봇 몸체를 삼각형으로 표현한다고 하자. 로봇이 정면을 향할 때 몸체의 세 꼭짓점은 로봇 기준 좌표계에서 다음과 같다고 하자.

robot_shape = np.array([
    [0.5, 0.0],
    [-0.3, 0.25],
    [-0.3, -0.25]
])

첫 번째 점 [0.5, 0.0]은 로봇의 앞쪽 코다. 나머지 두 점은 뒤쪽 왼쪽과 뒤쪽 오른쪽이다. 이 세 점을 선으로 이으면 화살표처럼 생긴 로봇 모양이 된다.

로봇이 45도 회전했다면, 이 몸체 점들도 함께 회전해야 한다.

angle = np.deg2rad(45.0)
c = np.cos(angle)
s = np.sin(angle)

R = np.array([
    [c, -s],
    [s,  c]
])

점 하나를 회전시킬 때는 R @ point를 사용했다. 그런데 지금은 점이 여러 개다. robot_shape의 모양은 (3, 2)이고, R의 모양은 (2, 2)다.

여러 점을 회전시킬 때는 다음처럼 쓸 수 있다.

rotated_shape = robot_shape @ R.T

print(rotated_shape)

여기서 R.T는 행렬 R의 전치다. 전치는 행과 열을 바꾸는 연산이다. 왜 R이 아니라 R.T를 곱할까? 우리가 점들을 각 행에 저장했기 때문이다.

점 하나를 열벡터처럼 생각하면 보통 다음처럼 쓴다.

rotated = R @ point

하지만 robot_shape에서는 각 점이 행으로 들어 있다.

[x, y]
[x, y]
[x, y]

이 형태에서는 오른쪽에서 곱해야 하므로 robot_shape @ R.T를 사용한다. 처음에는 조금 헷갈릴 수 있지만, 실전에서는 배열의 모양을 확인하면 된다.

print(robot_shape.shape)
print(R.shape)
print(R.T.shape)
print(rotated_shape.shape)

모양은 다음과 같다.

(3, 2)
(2, 2)
(2, 2)
(3, 2)

결과적으로 점 3개가 회전된 점 3개로 바뀐다. 입력도 (3, 2), 출력도 (3, 2)다. 로봇 모양은 유지되지만 방향만 바뀐다.

회전한 뒤 이동하기

로봇 몸체를 월드 좌표계에 배치하려면 회전만으로는 부족하다. 로봇의 현재 위치만큼 이동도 해야 한다. 예를 들어 로봇의 중심이 (2, 1)에 있다고 하자.

robot_position = np.array([2.0, 1.0])

회전된 몸체 점들에 이 위치를 더하면 월드 좌표계에서의 몸체 점을 얻을 수 있다.

world_shape = rotated_shape + robot_position

print(world_shape)

여기서도 브로드캐스팅이 사용된다. rotated_shape는 (3, 2)이고, robot_position은 (2,)다. NumPy는 로봇 위치를 세 꼭짓점 각각에 더한다.

전체 코드를 한 번에 보면 다음과 같다.

import numpy as np

robot_shape = np.array([
    [0.5, 0.0],
    [-0.3, 0.25],
    [-0.3, -0.25]
])

robot_position = np.array([2.0, 1.0])
robot_angle = np.deg2rad(45.0)

c = np.cos(robot_angle)
s = np.sin(robot_angle)

R = np.array([
    [c, -s],
    [s,  c]
])

rotated_shape = robot_shape @ R.T
world_shape = rotated_shape + robot_position

print(world_shape)

이 코드는 앞으로 로봇을 화면에 그릴 때 그대로 활용할 수 있다. 로봇의 기본 모양은 로봇 기준 좌표계에 두고, 현재 각도만큼 회전한 뒤, 현재 위치만큼 이동한다. 말로 하면 길지만 계산은 두 줄이다.

rotated_shape = robot_shape @ R.T
world_shape = rotated_shape + robot_position

이 두 줄에는 로봇 좌표계와 월드 좌표계의 기본 감각이 들어 있다. 로봇 기준으로 만든 점을 세상 기준의 점으로 바꾸는 것이다.

배열 인덱싱과 슬라이싱

NumPy 배열에서 원하는 부분만 꺼내는 방법도 자주 사용한다. 여러 점이 들어 있는 배열을 다시 보자.

points = np.array([
    [1.0, 2.0],
    [3.0, 1.0],
    [4.0, 5.0],
    [0.0, 3.0]
])

첫 번째 점을 꺼내려면 다음처럼 쓴다.

first_point = points[0]

print(first_point)

결과는 다음과 같다.

[1. 2.]

첫 번째 점의 x 좌표만 꺼내려면 행과 열 인덱스를 함께 지정한다.

x = points[0, 0]
y = points[0, 1]

print(x, y)

모든 점의 x 좌표만 꺼낼 수도 있다.

xs = points[:, 0]
ys = points[:, 1]

print(xs)
print(ys)

결과는 다음과 같다.

[1. 3. 4. 0.]
[2. 1. 5. 3.]

:는 해당 방향의 모든 값을 뜻한다. points[:, 0]은 모든 행에서 0번 열만 가져오라는 뜻이다. 이것은 Matplotlib으로 점들을 그릴 때 특히 자주 사용한다.

# 다음 장에서 이런 식으로 사용한다.
# plt.scatter(points[:, 0], points[:, 1])

아직 그림을 그리지는 않지만, 점 배열에서 x 좌표들과 y 좌표들을 분리하는 감각을 익혀 두면 다음 장이 훨씬 쉬워진다.

축을 따라 계산하기

여러 점이 있을 때 각 점의 평균 위치를 구하고 싶을 수 있다. 예를 들어 여러 센서가 감지한 물체의 위치가 조금씩 다르다면, 평균을 내서 대표 위치를 잡을 수 있다.

measurements = np.array([
    [2.0, 3.1],
    [2.2, 2.9],
    [1.9, 3.0],
    [2.1, 3.2]
])

mean_position = np.mean(measurements, axis=0)

print(mean_position)

결과는 다음과 같다.

[2.05 3.05]

axis=0은 행 방향으로 내려가며 평균을 내라는 뜻이다. 말이 조금 헷갈릴 수 있으니 결과의 의미로 기억하자. 여러 점이 (4, 2) 모양으로 있을 때 axis=0으로 평균을 내면 x 평균과 y 평균이 나온다.

이번에는 각 측정값이 평균 위치에서 얼마나 떨어져 있는지 계산해 보자.

differences = measurements - mean_position

distances = np.linalg.norm(differences, axis=1)

print(distances)

여기서도 브로드캐스팅이 사용된다. measurements는 (4, 2)이고, mean_position은 (2,)다. 평균 위치가 각 측정값에서 자동으로 빼진다.

np.linalg.norm(differences, axis=1)은 각 행마다 벡터의 길이를 구한다. 즉, 각 측정점이 평균 위치에서 얼마나 떨어져 있는지 계산한다.

이런 계산은 센서 데이터에서 이상한 값을 찾을 때 사용할 수 있다. 평균에서 지나치게 멀리 떨어진 측정값은 노이즈일 가능성이 있기 때문이다. 나중에 확률과 센서 오차를 배울 때 이 감각이 다시 등장한다.

데이터 타입도 중요하다

NumPy 배열에는 데이터 타입이 있다. 정수 배열인지, 실수 배열인지에 따라 계산 결과와 동작이 달라질 수 있다.

int_position = np.array([1, 2])
float_position = np.array([1.0, 2.0])

print(int_position.dtype)
print(float_position.dtype)

결과는 환경에 따라 조금 다를 수 있지만 대략 다음과 같다.

int64
float64

로봇 위치, 속도, 각도, 센서값은 대부분 실수로 다룬다. 그래서 처음부터 1보다 1.0처럼 쓰는 습관이 좋다.

position = np.array([0.0, 0.0])
velocity = np.array([0.1, 0.2])

정수 배열을 사용해도 많은 경우 NumPy가 자동으로 실수 결과를 만들어 주지만, 로봇 시뮬레이션에서는 실수 배열을 기본으로 생각하는 편이 안전하다. 로봇은 격자 칸만 움직이는 것이 아니라 0.1m, 0.01rad처럼 작은 양으로 계속 움직이기 때문이다.

필요하다면 배열을 만들 때 타입을 직접 지정할 수도 있다.

position = np.array([0, 0], dtype=float)

print(position)
print(position.dtype)

결과는 다음과 같다.

[0. 0.]
float64

실습: 로봇과 장애물 한 번에 이동시키기

이제 지금까지 배운 내용을 묶어 작은 실습을 해 보자. 로봇의 위치와 장애물 점들이 있을 때, 로봇이 이동한 만큼 모든 상대 위치를 다시 계산해 보겠다.

상황은 이렇다. 월드 좌표계에 장애물 세 개가 있다. 로봇은 처음에 (1, 1)에 있다가 (2, 1.5)로 이동했다.

import numpy as np

obstacles_world = np.array([
    [3.0, 2.0],
    [4.0, 4.0],
    [0.5, 3.0]
])

robot_position_old = np.array([1.0, 1.0])
robot_position_new = np.array([2.0, 1.5])

먼저 예전 로봇 위치에서 본 장애물의 상대 위치를 구해 보자. 상대 위치는 장애물 위치에서 로봇 위치를 빼면 된다.

relative_old = obstacles_world - robot_position_old

print(relative_old)

결과는 다음과 같다.

[[ 2.   1. ]
 [ 3.   3. ]
 [-0.5  2. ]]

로봇이 새 위치로 이동한 뒤의 상대 위치도 같은 방식으로 계산한다.

relative_new = obstacles_world - robot_position_new

print(relative_new)

결과는 다음과 같다.

[[ 1.   0.5]
 [ 2.   2.5]
 [-1.5  1.5]]

여기서도 브로드캐스팅이 핵심이다. 장애물은 3개이므로 obstacles_world는 (3, 2) 배열이다. 로봇 위치는 (2,) 배열이다. NumPy는 로봇 위치를 각 장애물 위치에서 자동으로 빼 준다.

이번에는 각 장애물까지의 거리를 구해 보자.

distances = np.linalg.norm(relative_new, axis=1)

print(distances)

결과는 대략 다음과 같다.

[1.11803399 3.20156212 2.12132034]

가장 가까운 장애물의 인덱스는 np.argmin으로 찾을 수 있다.

nearest_index = np.argmin(distances)
nearest_obstacle = obstacles_world[nearest_index]

print('nearest index:', nearest_index)
print('nearest obstacle:', nearest_obstacle)
print('distance:', distances[nearest_index])

이 코드는 로봇이 주변 장애물 중 가장 가까운 것을 찾는 아주 단순한 예다. 실제 로봇은 더 많은 센서와 복잡한 알고리즘을 사용하지만, 출발점은 이와 크게 다르지 않다. 점들의 배열을 만들고, 로봇 위치를 빼고, 거리 배열을 계산한 다음, 가장 작은 값을 찾는다.

실습: 간단한 로봇 자세 업데이트

이번에는 로봇의 위치와 방향을 함께 업데이트해 보자. 2D 로봇의 자세는 보통 (x, y, theta)로 표현한다. 여기서 theta는 로봇이 바라보는 각도다.

pose = np.array([0.0, 0.0, 0.0])

이 배열은 로봇이 원점에 있고, x축 방향을 바라본다는 뜻이다. 로봇이 전방으로 초당 1.0m 이동하고, 각속도 없이 똑바로 간다고 하자.

linear_speed = 1.0
angular_speed = 0.0
dt = 0.1

현재 각도에서 전방 방향 벡터를 만들 수 있다.

theta = pose[2]
forward = np.array([np.cos(theta), np.sin(theta)])

이제 위치와 각도를 업데이트한다.

pose[:2] = pose[:2] + forward * linear_speed * dt
pose[2] = pose[2] + angular_speed * dt

print(pose)

결과는 다음과 같다.

[0.1 0.  0. ]

pose[:2]는 pose 배열의 앞 두 원소, 즉 x와 y만 뜻한다. pose[2]는 각도다. 이처럼 하나의 배열 안에 위치와 방향을 함께 담을 수도 있다.

이번에는 로봇이 조금씩 회전하면서 움직이게 해 보자.

pose = np.array([0.0, 0.0, 0.0])

linear_speed = 1.0
angular_speed = 0.5
dt = 0.1

trajectory = []

for step in range(20):
    theta = pose[2]
    forward = np.array([np.cos(theta), np.sin(theta)])

    pose[:2] = pose[:2] + forward * linear_speed * dt
    pose[2] = pose[2] + angular_speed * dt

    trajectory.append(pose.copy())

trajectory = np.array(trajectory)

print(trajectory[:5])

이 코드는 로봇이 조금씩 방향을 틀며 전진하는 궤적을 만든다. 아직 그림으로 보지는 않았지만, 다음 장에서 이 궤적의 x, y 값을 꺼내 선으로 그리면 곡선처럼 보일 것이다.

xs = trajectory[:, 0]
ys = trajectory[:, 1]
thetas = trajectory[:, 2]

여기서도 배열의 모양과 슬라이싱이 중요하다. trajectory는 여러 개의 자세를 담은 배열이고, 각 자세는 x, y, theta 세 값을 가진다. 따라서 trajectory[:, 0]은 모든 시간의 x 위치, trajectory[:, 1]은 모든 시간의 y 위치, trajectory[:, 2]는 모든 시간의 방향이다.

자주 만나는 실수들

NumPy를 처음 사용할 때는 문법보다 모양 때문에 자주 막힌다. 오류 메시지가 길게 나오더라도 겁먹을 필요는 없다. 대부분은 배열의 shape를 확인하면 해결의 실마리가 보인다.

첫 번째 실수는 Python 리스트와 NumPy 배열을 같은 것으로 생각하는 것이다.

values = [1, 2, 3]
print(values * 2)

이 코드는 각 값을 두 배로 만드는 것이 아니라 리스트를 반복한다. 수치 계산을 하려면 배열로 바꾸자.

values = np.array([1, 2, 3])
print(values * 2)

두 번째 실수는 원소별 곱과 행렬 곱을 혼동하는 것이다.

rotated = R * point   # 대부분의 회전 계산에서는 잘못된 코드
rotated = R @ point   # 회전 행렬을 벡터에 적용하는 코드

세 번째 실수는 배열의 모양을 확인하지 않고 계산하는 것이다.

print(points.shape)
print(translation.shape)

이 두 줄은 단순하지만 강력하다. 계산이 되지 않거나 결과가 이상하면 먼저 모양을 출력해 보자.

마지막으로, 위치를 저장할 때 복사하지 않는 실수도 자주 나온다.

trajectory.append(position)        # 의도와 다를 수 있음
trajectory.append(position.copy()) # 현재 위치를 안전하게 저장

로봇 시뮬레이션은 시간에 따라 값이 계속 바뀐다. 어느 순간의 값을 기록하고 싶다면 복사본을 저장하는 습관이 중요하다.

작은 연습 문제

직접 손을 움직여 보는 것이 가장 빠른 학습법이다. 아래 문제를 Python 파일이나 Jupyter Notebook에서 실행해 보자.

  • 로봇 위치 position = np.array([1.0, 2.0])와 목표 위치 goal = np.array([4.0, 6.0])를 만들고, 목표까지의 벡터와 거리를 계산해 보자.

  • 2D 점 5개를 (5, 2) 배열로 만들고, 모든 점을 [2.0, -1.0]만큼 이동시켜 보자.

  • 90도 회전 행렬을 만든 뒤, 벡터 [1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]을 회전시켜 결과를 확인해 보자.

  • 로봇 자세 (x, y, theta)를 배열로 만들고, 50번 반복하면서 전방으로 조금씩 이동하는 궤적을 저장해 보자.

연습 문제를 풀 때 정답을 바로 맞히는 것보다 중요한 것은 배열의 모양을 계속 확인하는 것이다.

print(array.shape)

이 한 줄은 NumPy 세계의 나침반이다.

이번 장에서 얻은 것

이번 장에서는 로봇 수학의 기본 재료인 NumPy 배열, 벡터, 행렬을 다뤘다. 리스트와 NumPy 배열의 차이를 비교했고, 로봇의 위치와 속도, 센서값을 배열로 표현했다. 또한 *와 @의 차이를 통해 원소별 곱과 행렬 곱을 구분했다. 마지막으로 브로드캐스팅을 사용해 여러 점을 한 번에 이동시키고, 로봇 몸체의 점들을 회전한 뒤 월드 좌표계에 배치했다.

아직 화면에 아무것도 그리지 않았지만, 이미 로봇 시뮬레이션의 절반은 시작된 셈이다. 로봇의 위치는 배열이고, 움직임은 벡터의 덧셈이며, 방향 변화는 행렬 곱이다. 다음 장에서는 이 숫자들을 그림으로 바꿔 볼 것이다. 점이 화면에 찍히고, 선이 궤적이 되고, 작은 삼각형이 로봇처럼 움직이기 시작한다.

책 목록

로봇을 위한 수학과 Python: 좌표에서 시뮬레이터까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기