2. 시뮬레이션
2.4tf2 변환 노드 작성
1.9장에서 로봇의 부품마다 프레임(좌표계)이 있고, 프레임들이 부모와 자식으로 이어져 트리를 이룬다고 배웠습니다. 이 트리를 누가 만들까요. 노드들이 /tf 토픽으로 "자식 프레임은 부모 프레임에서 이만큼 떨어져 있다"를 발행하고, 그것을 모은 쪽이 트리를 짭니다. 이 장은 발행하는 노드와 조회하는 노드를 직접 씁니다.
변환 메시지 읽기
변환 하나는 geometry_msgs/TransformStamped입니다. 필드는 다섯 묶음입니다.
| 필드 | 뜻 |
|---|---|
header.stamp | 이 변환이 유효한 시각 |
header.frame_id | 부모 프레임 이름 |
child_frame_id | 자식 프레임 이름 |
transform.translation | 부모 프레임에서 본 자식 프레임 원점의 위치 (x, y, z, 단위 m) |
transform.rotation | 부모 프레임에서 본 자식 프레임의 방향 (쿼터니언 x, y, z, w) |
방향을 헷갈리기 쉽습니다. header.frame_id는 부모, child_frame_id는 자식이고 값은 "부모 좌표계에서 본 자식의 자세"입니다. 예를 들어 base_link에서 앞쪽 0.1 m, 위쪽 0.2 m에 라이다가 달려 있다면 부모는 base_link, 자식은 laser, 이동은 (0.1, 0, 0.2)입니다.
회전은 쿼터니언으로 적습니다. 바닥에서 도는 로봇은 z축 회전(yaw)만 쓰므로 (0, 0, sin(yaw/2), cos(yaw/2))로 만듭니다. 회전이 없는 상태는 w = 1, 나머지 0입니다. Humble에는 오일러 각을 쿼터니언으로 바꿔 주는 tf_transformations가 기본으로 들어 있지 않아서 아래 코드는 math로 직접 계산합니다.
고정된 변환과 움직이는 변환
변환에는 두 종류가 있습니다.
| 종류 | 예 | 발행 방법 |
|---|---|---|
| 움직이는 변환 | odom → base_link (로봇이 달린다) | TransformBroadcaster로 계속 발행(보통 10~50 Hz) |
| 고정된 변환 | base_link → laser (센서는 몸체에 붙어 있다) | StaticTransformBroadcaster로 한 번만 발행 |
고정된 변환은 /tf_static 토픽으로 한 번 보내면 나중에 켠 노드도 받습니다. 움직이는 변환은 시간이 지나면 낡은 값이 되므로 주기적으로 갱신해야 합니다.
발행하고 조회하기
아래 코드는 노드 둘로 이루어져 있습니다.
- 발행 노드는
base_link의 위치를odom기준으로 10 Hz로 갱신합니다. 앞으로 0.4 m/s로 가면서 초당 0.5 rad씩 회전합니다.laser는base_link앞 0.1 m, 위 0.2 m에 고정해 한 번만 발행합니다. - 조회 노드는 1초마다
odom기준으로 본laser의 위치와 방향을 조회해 출력합니다.
lookup_transform(목표 프레임, 원본 프레임, 시각)의 순서에 주의하세요. 결과는 "목표 프레임에서 본 원본 프레임의 자세"입니다. lookup_transform('odom', 'laser', Time())은 odom 좌표계에서 laser가 어디에 어느 방향으로 있는지 알려 줍니다. Time()은 "가장 최근 값"을 뜻합니다.
변환 발행과 조회
실행하면 맨 처음(0.00초)에 변환을 찾지 못했습니다 경고가 찍힙니다. 조회 노드가 만들어지자마자 조회했는데 버퍼가 아직 비어 있기 때문입니다. 메시지는 "odom" passed to lookupTransform argument target_frame does not exist.처럼 프레임이 없다는 내용입니다. 1초부터는 성공해서 laser 위치가 x=0.45, 0.82, 1.17, 1.52로 나아가고 방향이 26, 54, 83, 112도로 커집니다.
laser가 base_link보다 0.1 m 앞에 있으므로 로봇이 돌면 laser의 y 좌표가 조금 생기고, 로봇이 이동한 거리에 이 오프셋이 방향에 따라 더해지거나 빠집니다. 출력 창의 토픽 탭에서 /tf는 0.1초마다, /tf_static은 맨 처음 한 번만 발행된 것을 확인하세요.
직접 바꿔 보기
0.4 * self.t의 0.4를 0으로 바꾸면 제자리에서 도는 로봇이 됩니다.laser는odom원점 둘레의 반지름 0.1 m 원 위를 돌아 x, y가 0.09, 0.04에서 -0.04, 0.09로 바뀝니다.yaw = 0.5 * self.t의 0.5를 0으로 바꾸면 방향이 항상 0도이고 y는 0이며 x만 늘어납니다.self.create_timer(0.1, self.send_dynamic)의 주기를 1.0으로 바꾸면 첫 변환이 1초에야 발행되어 0초와 1초에 경고가 두 번 나옵니다. 이 코드는 시간이 아니라 호출 횟수로 위치를 계산하므로 로봇도 훨씬 느리게 움직입니다. 발행 주기가 느리면 조회 값이 낡거나 아예 없다는 점을 보여 줍니다.- 처음 경고를 만드는
self.look()줄을 지우면 0초 경고가 사라집니다. 실제 로봇 코드에서는 이런 경고를 예외로 받아 다음 주기에 다시 조회하는 방식이 표준이고, 위 코드의try/except TransformException이 그 모양입니다.
조회가 실패하는 경우
TransformException을 상속한 예외가 몇 가지 있습니다.
| 예외 | 언제 | 대처 |
|---|---|---|
LookupException | 그 프레임을 아직 아무도 발행하지 않았거나 이름이 틀렸다 | 프레임 이름 확인, 발행 노드가 도는지 확인, 다음 주기에 재시도 |
ConnectivityException | 두 프레임이 있지만 같은 트리로 이어지지 않는다 | 중간 변환을 발행하는 노드가 빠졌는지 확인 |
ExtrapolationException | 요청한 시각의 값이 없다(실제 ROS2에서 시각을 지정한 경우) | 더 최근 시각으로 조회하거나 발행 지연을 점검 |
rclpy에서는 조회 직후가 아니라 시작한 뒤 한동안 LookupException이 나는 것이 정상입니다. 예외를 잡아서 건너뛰고 다음 주기에 다시 시도하도록 짜세요. 에뮬레이터는 시각을 무시하므로 ExtrapolationException을 내지 않습니다.
센서의 점을 다른 좌표계로 옮기기
변환을 조회하는 가장 흔한 이유는 센서가 잰 점을 다른 프레임 좌표로 옮기기 위해서입니다. 라이다가 "내 앞 1 m에 물체가 있다"고 알려 주면, 그 물체가 odom(또는 map) 기준으로 어디인지 알아야 지도에 그리거나 피해 갈 수 있습니다.
점 (x, y)를 변환 t로 옮기는 식은 평면에서 다음과 같습니다. 회전각을 yaw라고 하면 새 좌표는 이동에 회전한 점을 더한 값입니다.
x' = tx + x*cos(yaw) - y*sin(yaw)
y' = ty + x*sin(yaw) + y*cos(yaw)
아래 코드에서는 상황을 일부러 헷갈리게 만들었습니다. 라이다를 base_link에서 뒤쪽으로 돌려(yaw 180도) 달았고, 로봇은 odom의 (2, 1)에서 +y 방향(yaw 90도)을 봅니다. 라이다는 자기 앞(laser 프레임의 +x) 1 m에서 물체를 봤습니다. 물체는 odom 기준으로 어디일까요?
센서 점을 odom 좌표로 옮기기
실행하면 물체 위치 (odom 기준): x=2.00, y=0.10이 나옵니다. 손으로 확인하면 이렇습니다. 라이다는 뒤를 보고 있으므로 라이다 앞 1 m는 base_link 기준으로 로봇 뒤쪽 0.9 m(라이다가 앞 0.1 m에 있어서 1.0에서 0.1을 뺍니다)입니다. 로봇이 +y를 보고 있으니 로봇 뒤쪽은 odom에서 -y 쪽이고, 로봇 위치 (2, 1)에서 0.9 m 내려가 (2, 0.1)이 됩니다.
마지막 줄은 odom과 이어지지 않은 camera 프레임을 조회해 LookupException을 확인하는 부분입니다. 아무도 camera를 발행하지 않았기 때문입니다.
직접 바꿔 보기
- 라이다의 회전(
s.transform.rotation.z = 1.0,w = 0.0)을z = 0.0,w = 1.0으로 바꾸면 라이다가 앞을 봅니다. 물체는 로봇 앞 1.1 m, 즉 odom의 (2.00, 2.10)이 됩니다. - 로봇의 방향 두 줄을
t.transform.rotation.z = 0.0,t.transform.rotation.w = 1.0으로 바꾸면 로봇이 +x를 봅니다. 라이다는 여전히 뒤를 보므로 물체는 로봇 뒤쪽 0.9 m이고 odom 기준 (1.10, 1.00)이 됩니다. 실행하기 전에 손으로 먼저 계산해 보세요.
이런 계산을 직접 하지 않아도 되도록 실제 ROS2에는 tf2_geometry_msgs 패키지가 PoseStamped, PointStamped 같은 메시지를 변환해 주는 함수를 제공합니다. 원리는 위의 식과 같으니, 라이브러리를 쓸 때도 어느 프레임에서 어느 프레임으로 가는 변환인지 방향을 먼저 따져야 합니다.
실제 환경에서 확인하기
노드 코드가 맞게 짜였는지는 터미널 도구로 확인합니다. 이 명령들은 3부에서 VM을 만든 뒤 실행하고, RViz에서 같은 트리를 보는 방법은 3.8장에서 다룹니다.
# 두 프레임 사이의 변환을 계속 출력 (목표 프레임, 원본 프레임 순서)
ros2 run tf2_ros tf2_echo odom laser
# 지금 발행되는 프레임 트리를 그림 파일로 저장
ros2 run tf2_tools view_frames
tf2_echo의 인자 순서도 lookup_transform과 같습니다. 목표 프레임이 먼저입니다. view_frames는 트리의 부모-자식 관계와 각 변환의 발행 주기를 PDF로 만들어 줍니다. 끊긴 곳, 중복 발행, 주기가 너무 느린 변환을 찾는 데 씁니다.
TIKI-MINI의 실제 프레임 트리는 다음 장 2.5장에서 URDF로 읽습니다. URDF에 적힌 고정 변환(base_link, laser 등)은 robot_state_publisher가 URDF를 읽어 발행합니다. 같은 변환을 StaticTransformBroadcaster로 또 발행하면 중복이 되므로 이미 URDF에 있는 센서 위치는 다시 발행하지 않습니다.
막혔을 때
| 증상 | 확인할 것 |
|---|---|
LookupException이 계속 난다 | 프레임 이름 철자, 발행 노드가 실행 중인지, 조회 노드가 spin되는지(변환은 토픽으로 도착하므로) |
| 값은 오는데 위치가 반대다 | lookup_transform의 목표/원본 순서, 발행할 때 부모와 자식을 바꿔 적지 않았는지 |
| 방향이 이상하게 돈다 | 쿼터니언을 정규화했는지, 각도 단위(도가 아닌 라디안)와 sin(yaw/2), cos(yaw/2)를 썼는지 |
트리가 이어지지 않는다(ConnectivityException) | 중간 프레임(예: odom → base_link)을 발행하는 노드가 빠지지 않았는지 |
| 값이 계단처럼 변한다 | 발행 주기가 너무 느리지 않은지, header.stamp를 채웠는지 |
한눈에 정리
- 변환은
TransformStamped이고header.frame_id가 부모,child_frame_id가 자식, 값은 부모에서 본 자식의 자세이다. - 움직이는 변환은
TransformBroadcaster로 주기적으로, 고정된 변환은StaticTransformBroadcaster로 한 번 발행한다. - 조회는
Buffer와TransformListener를 만들고lookup_transform(목표, 원본, 시각)을 부른다. 결과는 목표 프레임에서 본 원본 프레임의 자세이다. - 조회 직후의
LookupException은 정상이므로try/except로 받아 다음 주기에 다시 시도한다. - 센서의 점은 조회한 변환의 이동과 회전으로 다른 프레임 좌표로 옮긴다.
확인 문제
Q1. `lookup_transform('map', 'base_link', Time())`의 결과는 무엇을 뜻하나요?
map 좌표계에서 본 base_link의 위치와 방향, 즉 로봇이 지도 위 어디에 어느 쪽을 보고 있는지입니다. 첫 인자가 목표 프레임(결과를 표현할 좌표계), 둘째 인자가 원본 프레임(자세를 알고 싶은 대상)입니다.
Q2. 센서 위치처럼 변하지 않는 변환을 `TransformBroadcaster`로 매 주기 발행하는 것과 `StaticTransformBroadcaster`로 한 번만 발행하는 것 중 어느 쪽이 알맞나요?
StaticTransformBroadcaster입니다. 값이 변하지 않으므로 한 번만 /tf_static으로 보내면 되고, 나중에 켜는 노드도 그 값을 받습니다. 매 주기 발행하면 트래픽만 늘어납니다.
Q3. 리스너 노드를 시작하자마자 `lookup_transform`을 불렀더니 `LookupException`이 납니다. 코드가 틀린 것인가요?
꼭 그렇지는 않습니다. 변환은 /tf 토픽으로 도착하므로 리스너를 만든 직후에는 버퍼가 비어 있습니다. 예외를 잡아 다음 주기에 다시 시도하면 되고, 몇 번을 시도해도 계속 실패할 때만 프레임 이름과 발행 노드를 의심합니다.
다음 장
2.5장에서는 TIKI-MINI의 URDF를 읽으며 이 장에서 만든 프레임 트리가 실제 로봇에서는 어떤 모양인지 확인합니다.