TIKI-MINI로 시작하는 ROS2 실무
목차

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 토픽으로 한 번 보내면 나중에 켠 노드도 받습니다. 움직이는 변환은 시간이 지나면 낡은 값이 되므로 주기적으로 갱신해야 합니다.

발행하고 조회하기

변환의 발행과 조회TransformBroadcasterodom → base_link10 Hz로 계속StaticTransformBroadcasterbase_link → laser한 번만/tf/tf_staticBufferTransformListener가트리를 채움내 코드lookup_transform(odom, laser)조회는 리스너가 변환을 받은 뒤부터 성공합니다. 그 전에는 LookupException 입니다
발행하는 쪽은 /tf와 /tf_static으로 보내고, 조회하는 쪽은 Buffer에 쌓인 트리에서 두 프레임 사이의 변환을 찾습니다.

아래 코드는 노드 둘로 이루어져 있습니다.

  • 발행 노드는 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()은 "가장 최근 값"을 뜻합니다.

LIVE

변환 발행과 조회

실행하면 맨 처음(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)
라이다가 본 점을 odom 좌표로 옮기기xyodom 원점base_link (2, 1)로봇의 앞laser의 앞 1 m물체 (2.0, 0.1)1laser 프레임의 점 (1, 0)2라이다는 뒤를 보고 있음(base_link에서 yaw 180도)3로봇은 odom (2, 1)에서+y 쪽으로 yaw 90도4결과: odom 기준 (2.0, 0.1)
아래 실습의 상황을 위에서 내려다본 그림입니다. 라이다가 자기 앞 1 m에서 본 점이 odom 좌표로는 (2.0, 0.1)입니다.

아래 코드에서는 상황을 일부러 헷갈리게 만들었습니다. 라이다를 base_link에서 뒤쪽으로 돌려(yaw 180도) 달았고, 로봇은 odom의 (2, 1)에서 +y 방향(yaw 90도)을 봅니다. 라이다는 자기 앞(laser 프레임의 +x) 1 m에서 물체를 봤습니다. 물체는 odom 기준으로 어디일까요?

LIVE

센서 점을 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를 채웠는지

한눈에 정리

  1. 변환은 TransformStamped이고 header.frame_id가 부모, child_frame_id가 자식, 값은 부모에서 본 자식의 자세이다.
  2. 움직이는 변환은 TransformBroadcaster로 주기적으로, 고정된 변환은 StaticTransformBroadcaster로 한 번 발행한다.
  3. 조회는 Buffer와 TransformListener를 만들고 lookup_transform(목표, 원본, 시각)을 부른다. 결과는 목표 프레임에서 본 원본 프레임의 자세이다.
  4. 조회 직후의 LookupException은 정상이므로 try/except로 받아 다음 주기에 다시 시도한다.
  5. 센서의 점은 조회한 변환의 이동과 회전으로 다른 프레임 좌표로 옮긴다.

확인 문제

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를 읽으며 이 장에서 만든 프레임 트리가 실제 로봇에서는 어떤 모양인지 확인합니다.