Skip to main content
Summary

숫자가 그림이 되는 순간

앞 장에서 우리는 NumPy 배열로 위치와 방향을 표현했다. 예를 들어 [2.0, 1.0]은 2D 평면 위의 한 점이 될 수 있고, [1.0, 0.0]은 오른쪽을 향하는 방향 벡터가 될 수 있다. 하지만 숫자만 바라보고 있으면 로봇이 어디에 있는지, 어느 방향을 보는지, 제대로 움직이고 있는지 감각적으로 알기 어렵다.

로봇 수학에서 시각화는 단순한 장식이 아니다. 시각화는 계산이 맞는지 확인하는 가장 빠른 방법이다. 로봇이 목표점을 향해 간다고 생각했는데 그림으로 보니 엉뚱한 방향으로 달려가고 있다면, 우리는 즉시 좌표축, 각도, 부호, 단위 중 하나가 잘못되었음을 알아차릴 수 있다.

이번 장에서는 Matplotlib으로 작은 2D 세계를 만든다. 그 위에 로봇의 위치를 점으로 찍고, 방향을 화살표로 표시하고, 이동 경로를 선으로 남겨 볼 것이다. 마지막에는 로봇이 한 프레임씩 움직이는 애니메이션도 만들어 본다.

이 장에서 중요한 목표는 멋진 그래프를 그리는 것이 아니다. 숫자 배열을 로봇의 움직임으로 보는 눈을 만드는 것이다.

Matplotlib으로 로봇 위치 그리기

2D 평면 만들기

먼저 Matplotlib을 불러오자. 앞으로 대부분의 예제에서는 NumPy와 Matplotlib을 함께 사용한다. NumPy는 좌표를 계산하고, Matplotlib은 계산 결과를 화면에 그린다.

import numpy as np
import matplotlib.pyplot as plt

가장 단순한 2D 평면부터 만들어 보자.

import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

plt.show()

여기서 fig는 전체 그림판이고, ax는 실제 좌표축이 그려지는 영역이다. 처음에는 plt.plot()만 사용해도 충분해 보이지만, 로봇 시뮬레이션에서는 ax를 직접 다루는 방식이 훨씬 편하다. 축 범위, 격자, 점, 선, 화살표, 애니메이션을 한곳에 차곡차곡 쌓을 수 있기 때문이다.

set_xlim(-5, 5)는 x축을 -5부터 5까지 보이게 한다. set_ylim(-5, 5)는 y축을 -5부터 5까지 보이게 한다. 이 평면을 로봇이 돌아다니는 작은 실험실이라고 생각하자.

그런데 여기서 아주 중요한 설정이 하나 빠져 있다. 바로 축 비율이다.

축 비율을 고정해야 거리 감각이 살아난다

로봇에게 1m는 어느 방향으로 가든 1m여야 한다. x방향 1m와 y방향 1m가 화면에서 다르게 보이면, 우리가 보는 그림은 로봇의 실제 움직임을 왜곡할 수 있다.

다음 코드를 실행해 보자.

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-2, 2)
ax.grid(True)

circle = plt.Circle((0, 0), 1.0, fill=False)
ax.add_patch(circle)

plt.show()

분명 반지름이 1인 원을 그렸는데, 화면에서는 원이 타원처럼 보일 수 있다. 축 범위나 창 크기에 따라 x축과 y축의 화면 비율이 달라졌기 때문이다.

로봇 위치를 그릴 때는 보통 다음 설정을 추가한다.

ax.set_aspect("equal", adjustable="box")

전체 코드는 다음과 같다.

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

circle = plt.Circle((0, 0), 1.0, fill=False)
ax.add_patch(circle)

plt.show()

set_aspect("equal")은 x축에서 1칸과 y축에서 1칸을 같은 길이로 그리라는 뜻이다. 이 설정은 로봇 시각화에서 매우 중요하다. 앞으로 2D 로봇의 위치, 방향, 경로를 그릴 때는 거의 습관처럼 넣어 주자.

로봇 수학을 공부할 때 그림이 이상해 보이면 코드가 틀렸다고 의심하기 전에 축 비율부터 확인하자. 생각보다 많은 혼란이 여기서 시작된다.

점으로 로봇의 위치 표현하기

이제 로봇의 위치를 점으로 찍어 보자. 2D 평면에서 로봇의 위치는 보통 다음처럼 표현한다.

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

첫 번째 값은 x좌표, 두 번째 값은 y좌표다. 이 점을 Matplotlib으로 그리면 된다.

import numpy as np
import matplotlib.pyplot as plt

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

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.scatter(robot_pos[0], robot_pos[1], s=100, color="tab:blue")

plt.show()

scatter()는 점을 찍는 함수다. robot_pos[0]은 x좌표, robot_pos[1]은 y좌표다. s=100은 점의 크기이고, color="tab:blue"는 점 색상이다.

이제 [2.0, 1.0]이라는 숫자가 화면 위의 한 위치가 되었다. 별것 아닌 것 같지만, 이것이 모든 로봇 시뮬레이션의 출발점이다. 로봇 팔의 끝점, 이동 로봇의 중심, 카메라가 발견한 물체, 목표 지점 모두 결국 좌표 평면 위의 점으로 표현할 수 있다.

목표점도 함께 찍어 보자.

robot_pos = np.array([2.0, 1.0])
goal_pos = np.array([-3.0, 3.0])

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.scatter(robot_pos[0], robot_pos[1], s=100, color="tab:blue", label="robot")
ax.scatter(goal_pos[0], goal_pos[1], s=120, color="tab:red", marker="*", label="goal")

ax.legend()
plt.show()

label을 지정하고 legend()를 호출하면 범례가 표시된다. 파란 점은 로봇, 빨간 별은 목표점이다. 이제 우리는 로봇이 목표에서 얼마나 떨어져 있는지 눈으로 볼 수 있다.

선으로 관계 표현하기

점은 위치를 보여 준다. 선은 두 위치 사이의 관계를 보여 준다. 로봇과 목표점을 선으로 연결하면 "로봇이 어디로 가야 하는가"가 훨씬 분명해진다.

robot_pos = np.array([2.0, 1.0])
goal_pos = np.array([-3.0, 3.0])

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.scatter(robot_pos[0], robot_pos[1], s=100, color="tab:blue", label="robot")
ax.scatter(goal_pos[0], goal_pos[1], s=120, color="tab:red", marker="*", label="goal")

ax.plot(
    [robot_pos[0], goal_pos[0]],
    [robot_pos[1], goal_pos[1]],
    linestyle="--",
    color="gray",
    label="to goal"
)

ax.legend()
plt.show()

plot()은 선을 그릴 때 사용한다. 여기서는 x좌표 두 개 [robot_x, goal_x]와 y좌표 두 개 [robot_y, goal_y]를 전달했다. Matplotlib은 첫 번째 점과 두 번째 점을 선으로 연결한다.

이 선은 단순한 장식이 아니다. 다음 장들에서 우리는 이 선을 방향 벡터로 바꾸고, 거리를 계산하고, 로봇이 목표를 향해 움직이게 만들 것이다. 지금은 그 계산을 하기 전에 "아, 저 방향으로 가야 하는구나"라고 눈으로 확인하는 단계다.

화살표로 방향 표현하기

로봇은 위치만으로 충분하지 않다. 로봇은 보통 어떤 방향을 바라보고 있다. 같은 위치에 있어도 동쪽을 보는 로봇과 북쪽을 보는 로봇은 전혀 다른 상태다.

2D 이동 로봇의 자세를 표현할 때는 보통 다음 세 값을 사용한다.

x, y, theta

여기서 x와 y는 위치이고, theta는 방향 각도다. theta는 보통 라디안 단위를 사용한다. 라디안은 각도를 나타내는 또 다른 단위다. 180도는 π 라디안이고, 90도는 π/2 라디안이다.

예를 들어 로봇이 (1, 2) 위치에 있고, 45도 방향을 바라본다고 해 보자.

robot_pos = np.array([1.0, 2.0])
theta = np.deg2rad(45)

np.deg2rad(45)는 45도를 라디안으로 바꿔 준다. 수학 함수 sin, cos는 보통 라디안을 사용하므로, 각도 계산에서는 라디안에 익숙해지는 것이 좋다.

방향 각도 theta를 방향 벡터로 바꾸려면 다음처럼 계산한다.

direction = np.array([np.cos(theta), np.sin(theta)])

이 식은 매우 중요하다. 각도 theta가 주어졌을 때, x방향 성분은 cos(theta), y방향 성분은 sin(theta)가 된다. 즉, 로봇이 바라보는 방향을 길이 1짜리 벡터로 만든 것이다.

이제 화살표로 그려 보자.

import numpy as np
import matplotlib.pyplot as plt

robot_pos = np.array([1.0, 2.0])
theta = np.deg2rad(45)
direction = np.array([np.cos(theta), np.sin(theta)])

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.scatter(robot_pos[0], robot_pos[1], s=100, color="tab:blue")

ax.arrow(
    robot_pos[0], robot_pos[1],
    direction[0], direction[1],
    head_width=0.2,
    head_length=0.3,
    length_includes_head=True,
    color="tab:orange"
)

plt.show()

arrow()의 앞 두 값은 화살표가 시작되는 위치다. 그다음 두 값은 화살표가 얼마나 이동하며 그려질지를 뜻한다. 즉, robot_pos에서 시작해서 direction만큼 뻗는 화살표를 그린다.

여기서 방향 벡터의 길이는 1이다. 만약 화살표를 더 길게 그리고 싶다면 방향 벡터에 숫자를 곱하면 된다.

arrow_length = 1.5
arrow_vec = arrow_length * direction

그런 다음 arrow_vec[0], arrow_vec[1]을 사용하면 된다.

ax.arrow(
    robot_pos[0], robot_pos[1],
    arrow_vec[0], arrow_vec[1],
    head_width=0.2,
    head_length=0.3,
    length_includes_head=True,
    color="tab:orange"
)

이제 로봇은 화면 위에서 단순한 점이 아니라, "어디에 있고 어디를 바라보는지"를 가진 존재가 되었다.

로봇을 작은 삼각형으로 그리기

점과 화살표만으로도 충분히 많은 것을 표현할 수 있다. 하지만 로봇을 작은 삼각형으로 그리면 방향이 더 직관적으로 보인다. 삼각형의 뾰족한 부분이 로봇의 앞쪽이라고 생각하면 된다.

먼저 로봇의 모양을 로봇 기준 좌표계에서 정의해 보자. 로봇 기준 좌표계란 로봇 자신을 중심으로 보는 좌표계다. 로봇의 앞쪽을 x축 양의 방향이라고 정하자.

robot_shape = np.array([
    [0.5, 0.0],    # 앞쪽 꼭짓점
    [-0.3, 0.25],  # 왼쪽 뒤
    [-0.3, -0.25], # 오른쪽 뒤
    [0.5, 0.0]     # 다시 앞쪽으로 닫기
])

이 좌표들은 아직 월드 좌표계의 위치가 아니다. 로봇 중심을 원점으로 놓았을 때의 모양이다. 이제 이 모양을 회전시키고, 원하는 위치로 이동시켜야 한다.

회전 행렬을 사용하면 된다. 회전 행렬은 뒤에서 더 자세히 배우겠지만, 지금은 다음 함수를 그대로 사용해도 좋다.

def rotation_matrix(theta):
    return np.array([
        [np.cos(theta), -np.sin(theta)],
        [np.sin(theta),  np.cos(theta)]
    ])

로봇 모양을 회전하고 이동시키는 함수도 만들어 보자.

def transform_robot_shape(robot_shape, position, theta):
    R = rotation_matrix(theta)
    rotated_shape = robot_shape @ R.T
    moved_shape = rotated_shape + position
    return moved_shape

여기서 robot_shape @ R.T는 로봇 모양의 모든 점을 회전시킨다. + position은 회전된 점들을 로봇의 실제 위치로 이동시킨다. 3장에서 배운 브로드캐스팅 덕분에, position 하나를 더해도 모든 꼭짓점에 같은 이동이 적용된다.

전체 코드를 실행해 보자.

import numpy as np
import matplotlib.pyplot as plt


def rotation_matrix(theta):
    return np.array([
        [np.cos(theta), -np.sin(theta)],
        [np.sin(theta),  np.cos(theta)]
    ])


def transform_robot_shape(robot_shape, position, theta):
    R = rotation_matrix(theta)
    rotated_shape = robot_shape @ R.T
    moved_shape = rotated_shape + position
    return moved_shape


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

robot_pos = np.array([1.0, 2.0])
theta = np.deg2rad(30)

world_shape = transform_robot_shape(robot_shape, robot_pos, theta)

fig, ax = plt.subplots()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.plot(world_shape[:, 0], world_shape[:, 1], color="tab:blue")
ax.scatter(robot_pos[0], robot_pos[1], color="tab:red", s=50)

plt.show()

world_shape[:, 0]은 모든 꼭짓점의 x좌표이고, world_shape[:, 1]은 모든 꼭짓점의 y좌표다. 이 둘을 plot()에 넣으면 삼각형 윤곽선이 그려진다.

이 예제에는 앞으로 계속 등장할 중요한 구조가 숨어 있다.

  • 로봇의 모양은 로봇 기준 좌표계에서 정의한다.

  • 회전 행렬로 로봇의 방향을 반영한다.

  • 위치 벡터를 더해 월드 좌표계 위에 놓는다.

지금은 그림을 그리기 위해 이 구조를 사용했지만, 실제 로봇공학에서도 같은 생각을 쓴다. 센서 위치, 바퀴 위치, 로봇 팔 끝점, 카메라가 본 물체 위치를 모두 좌표 변환으로 연결한다.

이동 궤적 그리기

로봇은 한곳에 가만히 있는 물체가 아니다. 시간에 따라 위치가 바뀐다. 그래서 로봇 시각화에서는 현재 위치만큼이나 지나온 길, 즉 궤적이 중요하다.

먼저 여러 시점의 위치를 배열로 만들어 보자.

trajectory = np.array([
    [0.0, 0.0],
    [0.5, 0.2],
    [1.0, 0.5],
    [1.5, 0.9],
    [2.0, 1.4],
    [2.5, 2.0]
])

각 행은 한 시점의 로봇 위치다. 첫 번째 열은 x좌표, 두 번째 열은 y좌표다.

이제 이 궤적을 선으로 그려 보자.

import numpy as np
import matplotlib.pyplot as plt

trajectory = np.array([
    [0.0, 0.0],
    [0.5, 0.2],
    [1.0, 0.5],
    [1.5, 0.9],
    [2.0, 1.4],
    [2.5, 2.0]
])

fig, ax = plt.subplots()

ax.set_xlim(-1, 4)
ax.set_ylim(-1, 4)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.plot(trajectory[:, 0], trajectory[:, 1], marker="o", color="tab:blue", label="trajectory")

start = trajectory[0]
end = trajectory[-1]

ax.scatter(start[0], start[1], color="tab:green", s=100, label="start")
ax.scatter(end[0], end[1], color="tab:red", s=100, label="current")

ax.legend()
plt.show()

trajectory[:, 0]은 모든 x좌표를 가져오고, trajectory[:, 1]은 모든 y좌표를 가져온다. marker="o"를 넣으면 각 위치가 작은 원으로 표시된다.

이 그림을 보면 로봇이 원점 근처에서 출발해 오른쪽 위로 이동했다는 것을 바로 알 수 있다. 숫자 배열만 볼 때보다 훨씬 빠르다.

로봇 시뮬레이션을 만들 때는 매 시간마다 새 위치를 계산하고, 그 위치를 궤적 목록에 추가한다. 그리고 궤적을 그리면 로봇이 어떤 경로로 움직였는지 확인할 수 있다.

간단한 이동 데이터 만들기

이번에는 손으로 좌표를 하나씩 쓰지 말고, 코드로 이동 궤적을 만들어 보자. 로봇이 일정한 속도로 움직인다고 가정한다.

위치는 다음처럼 업데이트할 수 있다.

new_position = old_position + velocity * dt

여기서 velocity는 속도 벡터이고, dt는 시간 간격이다. 예를 들어 속도가 [0.5, 0.2]이고 dt가 1초라면, 매초 x방향으로 0.5, y방향으로 0.2만큼 이동한다.

import numpy as np

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

trajectory = []

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

trajectory = np.array(trajectory)
print(trajectory)

position.copy()를 사용한 점에 주목하자. NumPy 배열을 리스트에 넣을 때 현재 값을 안전하게 저장하려면 복사본을 넣는 습관이 좋다. 나중에 같은 배열을 계속 수정하는 코드에서는 복사하지 않았을 때 예상과 다른 결과가 생길 수 있다.

이제 이 궤적을 그려 보자.

import numpy as np
import matplotlib.pyplot as plt

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

trajectory = []

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

trajectory = np.array(trajectory)

fig, ax = plt.subplots()

ax.set_xlim(-1, 6)
ax.set_ylim(-1, 4)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.plot(trajectory[:, 0], trajectory[:, 1], marker="o", color="tab:blue")
ax.scatter(trajectory[0, 0], trajectory[0, 1], color="tab:green", s=100, label="start")
ax.scatter(trajectory[-1, 0], trajectory[-1, 1], color="tab:red", s=100, label="end")

ax.legend()
plt.show()

이 코드는 아직 단순하다. 하지만 이미 로봇 시뮬레이션의 핵심 형태를 가지고 있다. 현재 위치가 있고, 속도가 있고, 시간이 흐를 때 위치가 업데이트된다. 그리고 그 결과를 그림으로 확인한다.

다음 장에서는 이 구조를 조금 더 발전시켜 목표 지점까지 이동하는 첫 번째 로봇 시뮬레이터를 만들 것이다.

방향이 함께 변하는 로봇 그리기

이번에는 로봇이 움직이면서 바라보는 방향도 함께 표시해 보자. 로봇이 속도 방향을 바라본다고 가정하면, 방향 각도 theta는 속도 벡터에서 계산할 수 있다.

2D 벡터의 방향 각도는 np.arctan2(y, x)로 구한다.

theta = np.arctan2(velocity[1], velocity[0])

arctan2는 뒤에서 더 자세히 다룰 것이다. 지금은 "x, y 성분을 가진 벡터가 어느 각도를 향하는지 계산하는 함수"라고 이해하면 충분하다.

아래 코드는 이동 궤적과 현재 로봇의 방향을 함께 그린다.

import numpy as np
import matplotlib.pyplot as plt

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

trajectory = []

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

trajectory = np.array(trajectory)

current_pos = trajectory[-1]
theta = np.arctan2(velocity[1], velocity[0])
direction = np.array([np.cos(theta), np.sin(theta)])

fig, ax = plt.subplots()

ax.set_xlim(-1, 6)
ax.set_ylim(-1, 4)
ax.set_aspect("equal", adjustable="box")
ax.grid(True)
ax.set_xlabel("x")
ax.set_ylabel("y")

ax.plot(trajectory[:, 0], trajectory[:, 1], marker="o", color="tab:blue", label="trajectory")
ax.scatter(current_pos[0], current_pos[1], color="tab:red", s=100, label="current")

ax.arrow(
    current_pos[0], current_pos[1],
    direction[0], direction[1],
    head_width=0.2,
    head_length=0.3,
    length_includes_head=True,
    color="tab:orange"
)

ax.legend()
plt.show()

이제 그림에는 세 가지 정보가 담긴다. 로봇이 지나온 길, 현재 위치, 그리고 현재 바라보는 방향이다. 이 세 가지는 앞으로 거의 모든 2D 이동 로봇 예제에서 반복해서 등장한다.

그리기 코드를 함수로 정리하기

같은 설정을 매번 반복해서 쓰면 코드가 길어진다. 축 범위 설정, 격자 표시, 축 비율 고정 같은 코드는 함수로 묶어 두면 편하다.

def setup_2d_axes(ax, xlim=(-5, 5), ylim=(-5, 5)):
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")

로봇을 그리는 함수도 만들 수 있다.

def draw_robot(ax, position, theta, color="tab:blue"):
    direction = np.array([np.cos(theta), np.sin(theta)])

    ax.scatter(position[0], position[1], s=80, color=color)
    ax.arrow(
        position[0], position[1],
        direction[0], direction[1],
        head_width=0.2,
        head_length=0.3,
        length_includes_head=True,
        color=color
    )

이제 전체 코드는 더 읽기 쉬워진다.

import numpy as np
import matplotlib.pyplot as plt


def setup_2d_axes(ax, xlim=(-5, 5), ylim=(-5, 5)):
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")


def draw_robot(ax, position, theta, color="tab:blue"):
    direction = np.array([np.cos(theta), np.sin(theta)])

    ax.scatter(position[0], position[1], s=80, color=color)
    ax.arrow(
        position[0], position[1],
        direction[0], direction[1],
        head_width=0.2,
        head_length=0.3,
        length_includes_head=True,
        color=color
    )


trajectory = np.array([
    [0.0, 0.0],
    [0.5, 0.2],
    [1.0, 0.5],
    [1.5, 0.9],
    [2.0, 1.4]
])

current_pos = trajectory[-1]
theta = np.deg2rad(35)

fig, ax = plt.subplots()
setup_2d_axes(ax, xlim=(-1, 4), ylim=(-1, 3))

ax.plot(trajectory[:, 0], trajectory[:, 1], marker="o", color="gray", label="trajectory")
draw_robot(ax, current_pos, theta)

ax.legend()
plt.show()

함수를 만드는 이유는 단순히 코드를 짧게 하려는 것이 아니다. 생각을 분리하기 위해서다. setup_2d_axes()는 2D 세계를 준비한다. draw_robot()은 로봇을 그린다. 이동 계산은 또 다른 함수가 맡을 수 있다. 이렇게 역할을 나누면 나중에 시뮬레이터가 커져도 코드를 이해하기 쉬워진다.

한 프레임씩 움직이는 로봇

지금까지는 이동이 끝난 뒤의 결과를 한 장의 그림으로 보았다. 하지만 로봇은 실제로 한순간에 목적지로 순간이동하지 않는다. 시간에 따라 조금씩 움직인다. 이 과정을 보려면 애니메이션을 사용하면 된다.

Matplotlib에서는 FuncAnimation을 사용해 프레임마다 그림을 갱신할 수 있다.

먼저 필요한 모듈을 불러온다.

from matplotlib.animation import FuncAnimation

애니메이션의 기본 생각은 간단하다. 프레임 번호가 0, 1, 2, 3처럼 증가할 때마다 그 번호에 해당하는 로봇 위치를 그리고, 이전 그림을 지운 뒤 새 그림을 보여 주는 것이다.

아래 예제를 실행해 보자.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation


def setup_2d_axes(ax, xlim=(-5, 5), ylim=(-5, 5)):
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")


# 이동 궤적 만들기
position = np.array([0.0, 0.0])
velocity = np.array([0.08, 0.04])
dt = 1.0

trajectory = []

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

trajectory = np.array(trajectory)

theta = np.arctan2(velocity[1], velocity[0])
direction = np.array([np.cos(theta), np.sin(theta)])

fig, ax = plt.subplots()
setup_2d_axes(ax, xlim=(-1, 8), ylim=(-1, 5))

trajectory_line, = ax.plot([], [], color="tab:blue", label="trajectory")
robot_point, = ax.plot([], [], "o", color="tab:red", markersize=8, label="robot")
direction_arrow = None

ax.legend()


def update(frame):
    global direction_arrow

    current = trajectory[frame]
    past = trajectory[:frame + 1]

    trajectory_line.set_data(past[:, 0], past[:, 1])
    robot_point.set_data([current[0]], [current[1]])

    if direction_arrow is not None:
        direction_arrow.remove()

    direction_arrow = ax.arrow(
        current[0], current[1],
        direction[0] * 0.6, direction[1] * 0.6,
        head_width=0.15,
        head_length=0.2,
        length_includes_head=True,
        color="tab:orange"
    )

    return trajectory_line, robot_point, direction_arrow


ani = FuncAnimation(fig, update, frames=len(trajectory), interval=50, blit=False)

plt.show()

코드가 조금 길어졌지만 핵심은 어렵지 않다. trajectory_line은 지나온 경로를 나타내고, robot_point는 현재 로봇 위치를 나타낸다. update(frame) 함수는 프레임 번호를 받아서 그 시점의 로봇 위치를 화면에 반영한다.

interval=50은 프레임 사이의 시간 간격을 밀리초 단위로 지정한다. 50이면 대략 0.05초마다 화면이 갱신된다. 값이 작을수록 더 빠르게 움직이는 것처럼 보이고, 값이 클수록 더 느리게 움직이는 것처럼 보인다.

blit=False는 전체 그림 갱신 방식을 단순하게 유지하기 위한 설정이다. 애니메이션 성능을 높일 때는 blit=True를 쓰기도 하지만, 처음에는 False가 이해하기 쉽고 오류도 적다.

Jupyter Notebook에서 애니메이션이 바로 보이지 않는 경우도 있다. 그럴 때는 실행 환경에 따라 표시 방식 설정이 필요할 수 있다. 하지만 지금 중요한 것은 특정 환경의 출력 설정이 아니라, 애니메이션의 구조다. 프레임 번호가 바뀌고, 그 번호에 맞는 위치를 그리고, 화면을 갱신한다. 이 흐름만 이해하면 된다.

삼각형 로봇 애니메이션 만들기

점과 화살표도 좋지만, 이번에는 앞에서 만든 삼각형 로봇을 움직여 보자. 로봇이 이동 방향을 바라보며 앞으로 나아가는 장면을 만들 것이다.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation


def setup_2d_axes(ax, xlim=(-5, 5), ylim=(-5, 5)):
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")


def rotation_matrix(theta):
    return np.array([
        [np.cos(theta), -np.sin(theta)],
        [np.sin(theta),  np.cos(theta)]
    ])


def transform_robot_shape(robot_shape, position, theta):
    R = rotation_matrix(theta)
    return robot_shape @ R.T + position


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

# 곡선처럼 보이는 궤적 만들기
t_values = np.linspace(0, 8, 120)
x_values = t_values * 0.6
y_values = np.sin(t_values) * 1.0
trajectory = np.column_stack([x_values, y_values])

fig, ax = plt.subplots()
setup_2d_axes(ax, xlim=(-1, 6), ylim=(-2, 2))

trajectory_line, = ax.plot([], [], color="tab:gray", linestyle="--", label="trajectory")
robot_line, = ax.plot([], [], color="tab:blue", linewidth=2, label="robot")

ax.legend()


def update(frame):
    current = trajectory[frame]
    past = trajectory[:frame + 1]

    if frame < len(trajectory) - 1:
        next_pos = trajectory[frame + 1]
    else:
        next_pos = trajectory[frame]

    velocity_direction = next_pos - current
    theta = np.arctan2(velocity_direction[1], velocity_direction[0])

    world_shape = transform_robot_shape(robot_shape, current, theta)

    trajectory_line.set_data(past[:, 0], past[:, 1])
    robot_line.set_data(world_shape[:, 0], world_shape[:, 1])

    return trajectory_line, robot_line


ani = FuncAnimation(fig, update, frames=len(trajectory), interval=50, blit=False)

plt.show()

이 예제에서 궤적은 sin 함수를 사용해 만들었다. 로봇이 물결 모양의 길을 따라 움직이는 것처럼 보인다. 현재 위치와 다음 위치의 차이를 이용해 로봇이 바라볼 방향을 계산했다.

velocity_direction = next_pos - current
theta = np.arctan2(velocity_direction[1], velocity_direction[0])

이 두 줄은 로봇 시뮬레이션에서 자주 등장한다. 현재 위치에서 다음 위치를 빼면 이동 방향 벡터가 된다. 그 벡터의 각도를 구하면 로봇이 어느 방향을 바라봐야 하는지 알 수 있다.

여기서 수학은 종이 위에 갇혀 있지 않다. 뺄셈 하나가 방향이 되고, arctan2 하나가 회전 각도가 되고, 회전 행렬 하나가 로봇의 몸체를 화면 위에서 돌린다.

시각화할 때 자주 생기는 실수

Matplotlib으로 로봇을 그리다 보면 코드가 맞아 보이는데 그림이 이상할 때가 있다. 그럴 때는 대부분 아주 기본적인 부분에서 문제가 생긴다.

  • 축 비율을 고정하지 않아 원이 타원처럼 보이거나 이동 방향이 왜곡된다.

  • x좌표와 y좌표의 순서를 바꾸어 로봇이 대각선 반대편에 그려진다.

  • 각도를 도 단위로 생각하면서 sin, cos에 그대로 넣어 방향이 엉뚱해진다.

  • 이전 프레임의 화살표나 도형을 지우지 않아 화면에 잔상이 계속 쌓인다.

특히 각도 단위는 자주 실수하는 부분이다. np.sin(90)은 우리가 기대하는 1이 아니다. NumPy의 삼각함수는 90도를 90라디안으로 해석한다. 90도를 넣고 싶다면 반드시 np.deg2rad(90)처럼 변환해야 한다.

np.sin(np.deg2rad(90))  # 1.0에 가까운 값

또 하나의 습관은 좌표를 출력해 보는 것이다. 그림이 이상하면 먼저 숫자를 확인하자.

print("position:", position)
print("theta:", theta)
print("direction:", direction)

로봇 시각화는 눈으로 보는 작업이지만, 결국 그림은 숫자에서 나온다. 숫자와 그림을 함께 확인하는 습관이 디버깅 시간을 크게 줄여 준다.

작은 실험: 목표점까지 이어지는 그림 만들기

마지막으로 이번 장에서 배운 내용을 하나로 묶어 보자. 로봇 위치, 목표점, 목표 방향, 예상 이동 궤적을 모두 한 그림에 표시한다.

import numpy as np
import matplotlib.pyplot as plt


def setup_2d_axes(ax, xlim=(-5, 5), ylim=(-5, 5)):
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")


robot_pos = np.array([-3.0, -2.0])
goal_pos = np.array([3.0, 2.0])

# 로봇에서 목표로 향하는 벡터
to_goal = goal_pos - robot_pos

distance = np.linalg.norm(to_goal)
direction = to_goal / distance

theta = np.arctan2(direction[1], direction[0])

# 목표 방향으로 조금씩 이동한다고 가정한 예상 궤적
step_size = 0.5
num_steps = int(distance / step_size) + 1

trajectory = []
position = robot_pos.copy()

for step in range(num_steps):
    trajectory.append(position.copy())
    position = position + direction * step_size

trajectory = np.array(trajectory)

fig, ax = plt.subplots()
setup_2d_axes(ax, xlim=(-4, 4), ylim=(-3, 3))

ax.scatter(robot_pos[0], robot_pos[1], color="tab:blue", s=100, label="robot")
ax.scatter(goal_pos[0], goal_pos[1], color="tab:red", marker="*", s=150, label="goal")

ax.plot(trajectory[:, 0], trajectory[:, 1], color="tab:gray", linestyle="--", marker="o", label="expected path")

ax.arrow(
    robot_pos[0], robot_pos[1],
    direction[0], direction[1],
    head_width=0.2,
    head_length=0.3,
    length_includes_head=True,
    color="tab:orange"
)

ax.set_title(f"distance = {distance:.2f}, theta = {np.rad2deg(theta):.1f} deg")
ax.legend()
plt.show()

이 예제에는 앞으로 배울 내용이 미리 들어 있다. 목표까지의 벡터를 구하고, 거리를 계산하고, 단위 방향 벡터를 만들고, 그 방향으로 조금씩 움직이는 궤적을 만든다. 아직 완전한 로봇 제어기는 아니지만, 이미 "목표를 향해 이동한다"는 생각의 뼈대가 보인다.

코드를 실행하면 로봇에서 목표로 이어지는 점선 경로가 나타난다. 주황색 화살표는 로봇이 처음 바라봐야 할 방향을 보여 준다. 제목에는 목표까지의 거리와 방향 각도가 표시된다.

이 한 장의 그림은 많은 이야기를 한다. 로봇은 어디에 있는가? 목표는 어디에 있는가? 얼마나 떨어져 있는가? 어느 방향으로 가야 하는가? 예상 경로는 축 비율상 자연스러운가? 이 질문들에 그림이 즉시 답해 준다.

이번 장에서 얻은 감각

이번 장에서는 Matplotlib으로 2D 로봇 세계를 그리는 방법을 배웠다. 좌표 평면을 만들고, 축 비율을 고정하고, 점과 선과 화살표로 위치와 방향을 표현했다. 또 시간에 따라 변하는 위치를 궤적으로 누적하고, 애니메이션으로 한 프레임씩 움직이는 모습을 확인했다.

가장 중요한 감각은 이것이다. 로봇의 수학은 그려 보면 빨리 이해된다.

좌표가 이상하면 점이 엉뚱한 곳에 찍힌다. 방향 계산이 틀리면 화살표가 목표가 아닌 곳을 가리킨다. 속도 업데이트가 잘못되면 궤적이 예상과 다르게 휘어진다. 그림은 계산의 결과를 숨기지 않는다.

앞으로 우리는 거리, 각도, 회전 행렬, 좌표 변환, 센서 오차, 위치 추정을 계속 배울 것이다. 그때마다 Matplotlib은 작은 실험실이 되어 줄 것이다. 수식으로 이해하고, NumPy로 계산하고, Matplotlib으로 확인한다. 이 흐름이 이 책의 기본 리듬이다.

다음 장에서는 이 리듬을 이용해 첫 번째 로봇 시뮬레이터를 만든다. 로봇의 위치를 업데이트하고, 목표 지점을 향해 움직이게 하고, 그 궤적을 화면에 남겨 볼 것이다.

책 목록

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

전체 목차 보기

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

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

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

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

참여하기