좌표계는 머릿속에만 있으면 금방 헷갈린다. base_link에서 LiDAR가 앞으로 18cm, 위로 22cm 떨어져 있다는 사실을 노트에 적어 두는 것만으로는 부족하다. 로봇 소프트웨어의 다른 노드들이 그 사실을 읽을 수 있어야 한다. 카메라 노드는 카메라의 위치를 알아야 하고, 장애물 회피 노드는 LiDAR 점들이 로봇 기준으로 어디에 있는지 알아야 하며, 내비게이션 노드는 로봇이 지도 위에서 어느 방향을 보고 있는지 알아야 한다.
TF는 바로 이 정보를 ROS2 시스템 전체에 흘려보내는 장치다. 이전 장에서 우리는 map, odom, base_link, camera_link, lidar_link 같은 프레임 이름과 부모-자식 관계를 배웠다. 이번 장에서는 그 관계를 실제 코드로 발행하고, 다시 읽어 본다. 그림 속 화살표가 토픽 위를 흐르는 데이터가 되는 순간이다.
ROS2의 tf2 튜토리얼은 정적 브로드캐스터, 동적 브로드캐스터, 리스너, 디버깅 도구를 단계적으로 다룬다. 핵심은 단순하다. 어떤 노드는 좌표 변환을 발행하고, 어떤 노드는 그 변환을 버퍼에 모아 두었다가 필요한 순간에 조회한다.1

TF를 발행한다는 것
TF에서 좌표 변환을 발행하는 노드를 보통 브로드캐스터라고 부른다. 브로드캐스터는 '부모 프레임에서 자식 프레임이 어디에 있는가'를 계속 알려 주는 노드다.
예를 들어 로봇 본체 중심이 base_link이고, LiDAR가 로봇 앞쪽에 고정되어 있다면 다음과 같은 관계를 만들 수 있다.
base_link ──> lidar_link이 화살표는 lidar_link가 base_link 기준으로 어디에 있는지를 뜻한다. LiDAR가 로봇 앞쪽 0.18m, 위쪽 0.22m에 달려 있고 회전 없이 정면을 바라본다면 변환 값은 대략 다음과 같다.
parent frame : base_link
child frame : lidar_link
translation : x=0.18, y=0.0, z=0.22
rotation : roll=0.0, pitch=0.0, yaw=0.0여기서 translation은 위치 이동, rotation은 회전을 뜻한다. ROS2에서 회전은 보통 쿼터니언으로 표현된다. 쿼터니언은 3차원 회전을 안정적으로 표현하기 위한 네 개의 값 x, y, z, w다. 처음에는 낯설지만, 당장 모든 수학을 외울 필요는 없다. 코드를 작성할 때는 roll, pitch, yaw를 쿼터니언으로 바꾸는 함수를 만들어 쓰면 된다.
TF 브로드캐스트에는 크게 두 종류가 있다.
정적 변환은 로봇이 움직여도 변하지 않는 관계다.
base_link -> lidar_link,base_link -> camera_link처럼 센서가 로봇에 고정 장착된 경우가 여기에 해당한다.동적 변환은 시간에 따라 변하는 관계다.
odom -> base_link처럼 로봇이 움직이면서 계속 바뀌는 위치와 자세가 대표적이다.두 변환은 모두
geometry_msgs/msg/TransformStamped형식을 기반으로 다루지만, 발행 방식과 의미가 다르다.
정적 변환은 보통 /tf_static으로 한 번 또는 드물게 발행되고, 동적 변환은 /tf로 계속 발행된다. ROS2의 tf2는 이런 변환을 받아 프레임 트리를 만들고, listener가 원하는 두 프레임 사이의 관계를 계산할 수 있게 해 준다.2
명령어로 정적 센서 프레임 붙이기
가장 빠르게 TF를 만들어 보는 방법은 static_transform_publisher를 사용하는 것이다. 이 도구는 별도 코드를 작성하지 않아도 고정된 부모-자식 프레임 관계를 발행한다.
다음 명령은 base_link 기준으로 lidar_link를 앞쪽 0.18m, 위쪽 0.22m 위치에 둔다.
ros2 run tf2_ros static_transform_publisher \
--x 0.18 --y 0.0 --z 0.22 \
--roll 0.0 --pitch 0.0 --yaw 0.0 \
--frame-id base_link \
--child-frame-id lidar_link카메라를 추가한다면 같은 방식으로 camera_link를 붙일 수 있다. 예를 들어 카메라가 로봇 앞쪽 0.12m, 위쪽 0.32m에 있고 정면을 바라본다고 하자.
ros2 run tf2_ros static_transform_publisher \
--x 0.12 --y 0.0 --z 0.32 \
--roll 0.0 --pitch 0.0 --yaw 0.0 \
--frame-id base_link \
--child-frame-id camera_link이 명령에서 가장 중요한 값은 여섯 개의 숫자보다 --frame-id와 --child-frame-id다. 숫자가 조금 틀리면 센서 위치가 조금 어긋난다. 하지만 부모와 자식이 뒤집히면 로봇 전체 좌표 해석이 반대로 흐를 수 있다.
base_link -> lidar_link는 'LiDAR가 로봇 본체 기준 어디에 있는가'다. 반대로 lidar_link -> base_link를 발행하면 '로봇 본체가 LiDAR 기준 어디에 있는가'가 된다. 수학적으로는 역변환을 구할 수 있지만, TF 트리의 구조가 의도와 달라지고 이후 URDF, RViz, Nav2 설정을 맞출 때 혼란이 커진다.
실제 이동 로봇에서는 보통 센서 프레임을 base_link나 base_footprint 아래에 둔다.
base_link
├── lidar_link
├── camera_link
└── imu_link이 구조가 안정적으로 만들어져야 센서 데이터가 로봇 기준으로 올바르게 변환된다. LiDAR가 감지한 점 하나가 lidar_link 기준으로는 x=1.0일 수 있지만, 로봇 중심 기준으로는 LiDAR 장착 위치만큼 더 앞에 있을 수 있다. TF는 이런 보정을 자동으로 추적할 수 있게 해 준다.
코드로 정적 변환 발행하기
명령어는 빠르지만, 프로젝트가 커지면 정적 변환도 패키지와 launch 파일 안에 넣어 관리하는 편이 좋다. 이제 Python 노드로 base_link -> lidar_link 변환을 발행해 보자.
예제 패키지를 하나 만든다. 이미 사용하는 워크스페이스가 있다면 그 안에서 진행하면 된다.
cd ~/ros2_ws/src
ros2 pkg create robot_tf_examples \
--build-type ament_python \
--dependencies rclpy tf2_ros geometry_msgs다음 파일을 만든다.
cd ~/ros2_ws/src/robot_tf_examples/robot_tf_examples
touch static_sensor_tf_broadcaster.py파일 내용은 다음과 같다.
import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from tf2_ros.static_transform_broadcaster import StaticTransformBroadcaster
def quaternion_from_euler(roll, pitch, yaw):
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
qx = sr * cp * cy - cr * sp * sy
qy = cr * sp * cy + sr * cp * sy
qz = cr * cp * sy - sr * sp * cy
qw = cr * cp * cy + sr * sp * sy
return qx, qy, qz, qw
class StaticSensorTfBroadcaster(Node):
def __init__(self):
super().__init__('static_sensor_tf_broadcaster')
self.broadcaster = StaticTransformBroadcaster(self)
self.publish_lidar_transform()
def publish_lidar_transform(self):
transform = TransformStamped()
transform.header.stamp = self.get_clock().now().to_msg()
transform.header.frame_id = 'base_link'
transform.child_frame_id = 'lidar_link'
transform.transform.translation.x = 0.18
transform.transform.translation.y = 0.0
transform.transform.translation.z = 0.22
qx, qy, qz, qw = quaternion_from_euler(0.0, 0.0, 0.0)
transform.transform.rotation.x = qx
transform.transform.rotation.y = qy
transform.transform.rotation.z = qz
transform.transform.rotation.w = qw
self.broadcaster.sendTransform(transform)
self.get_logger().info('Published static transform: base_link -> lidar_link')
def main(args=None):
rclpy.init(args=args)
node = StaticSensorTfBroadcaster()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()TransformStamped에서 눈여겨볼 필드는 세 가지다.
header.stamp는 이 변환이 어느 시각의 정보인지 나타낸다.header.frame_id는 부모 프레임이다.child_frame_id는 자식 프레임이다.
정적 변환에서는 시간이 크게 바뀌지 않지만, 메시지 형식 자체가 stamped, 즉 시간표가 붙은 형태이므로 stamp를 채워 넣는다. 동적 변환에서는 이 stamp가 훨씬 중요해진다.
이제 setup.py의 entry_points에 실행 항목을 추가한다.
entry_points={
'console_scripts': [
'static_sensor_tf_broadcaster = robot_tf_examples.static_sensor_tf_broadcaster:main',
],
},빌드하고 실행한다.
cd ~/ros2_ws
colcon build --packages-select robot_tf_examples
source install/setup.bash
ros2 run robot_tf_examples static_sensor_tf_broadcaster다른 터미널에서 확인한다.
source ~/ros2_ws/install/setup.bash
ros2 run tf2_ros tf2_echo base_link lidar_link정상이라면 base_link 기준에서 lidar_link가 어느 위치와 회전에 있는지 계속 출력된다. 정적 변환이라 값은 바뀌지 않는다.
동적 변환: odom에서 base_link로 흐르는 시간
센서 프레임은 로봇에 붙어 있으므로 정적 변환으로 충분하다. 하지만 로봇 자체는 움직인다. 로봇이 앞으로 가고, 회전하고, 다시 멈추는 동안 odom 기준의 base_link 위치는 계속 달라진다.
odom ──> base_link ──> lidar_link이 구조에서 odom -> base_link는 동적 변환이다. 실제 로봇에서는 바퀴 엔코더, IMU, 시뮬레이터, 위치 추정 노드가 이 변환을 만든다. 여기서는 개념을 확인하기 위해 가짜 오도메트리 브로드캐스터를 작성한다. 로봇이 천천히 앞으로 가면서 조금씩 회전한다고 가정한다.
새 파일을 만든다.
cd ~/ros2_ws/src/robot_tf_examples/robot_tf_examples
touch fake_odom_tf_broadcaster.py내용은 다음과 같다.
import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from tf2_ros import TransformBroadcaster
def quaternion_from_yaw(yaw):
return 0.0, 0.0, math.sin(yaw * 0.5), math.cos(yaw * 0.5)
class FakeOdomTfBroadcaster(Node):
def __init__(self):
super().__init__('fake_odom_tf_broadcaster')
self.broadcaster = TransformBroadcaster(self)
self.x = 0.0
self.y = 0.0
self.yaw = 0.0
self.timer = self.create_timer(0.05, self.on_timer)
self.get_logger().info('Broadcasting dynamic transform: odom -> base_link')
def on_timer(self):
dt = 0.05
linear_speed = 0.10
angular_speed = 0.15
self.x += linear_speed * math.cos(self.yaw) * dt
self.y += linear_speed * math.sin(self.yaw) * dt
self.yaw += angular_speed * dt
transform = TransformStamped()
transform.header.stamp = self.get_clock().now().to_msg()
transform.header.frame_id = 'odom'
transform.child_frame_id = 'base_link'
transform.transform.translation.x = self.x
transform.transform.translation.y = self.y
transform.transform.translation.z = 0.0
qx, qy, qz, qw = quaternion_from_yaw(self.yaw)
transform.transform.rotation.x = qx
transform.transform.rotation.y = qy
transform.transform.rotation.z = qz
transform.transform.rotation.w = qw
self.broadcaster.sendTransform(transform)
def main(args=None):
rclpy.init(args=args)
node = FakeOdomTfBroadcaster()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()이 코드는 0.05초마다 새 변환을 만든다. 약 20Hz로 odom -> base_link 관계가 갱신되는 셈이다. TransformBroadcaster는 sendTransform()으로 변환을 발행한다. ROS2 공식 예제에서도 동적 브로드캐스터는 TransformStamped에 현재 시각, 부모 프레임, 자식 프레임, 위치, 회전을 채운 뒤 전송하는 방식으로 구성된다.3
여기서 가장 중요한 줄은 다음 세 줄이다.
transform.header.stamp = self.get_clock().now().to_msg()
transform.header.frame_id = 'odom'
transform.child_frame_id = 'base_link'header.stamp는 이 변환이 언제의 로봇 자세인지 말한다. listener가 특정 시각의 센서 데이터를 처리할 때 이 시간이 맞지 않으면 '그 시각의 변환을 찾을 수 없다'는 오류가 발생할 수 있다.
header.frame_id는 기준 좌표계다. odom이라고 적으면 '오도메트리 기준 세계에서'라는 뜻이 된다.
child_frame_id는 움직이는 대상 프레임이다. 여기서는 로봇 본체인 base_link다. 따라서 이 변환은 'odom 기준으로 base_link가 어디에 있는가'를 표현한다.
setup.py에 실행 항목을 하나 더 추가한다.
entry_points={
'console_scripts': [
'static_sensor_tf_broadcaster = robot_tf_examples.static_sensor_tf_broadcaster:main',
'fake_odom_tf_broadcaster = robot_tf_examples.fake_odom_tf_broadcaster:main',
],
},다시 빌드한다.
cd ~/ros2_ws
colcon build --packages-select robot_tf_examples
source install/setup.bash두 브로드캐스터를 각각 실행한다.
ros2 run robot_tf_examples static_sensor_tf_broadcaster다른 터미널에서 다음을 실행한다.
ros2 run robot_tf_examples fake_odom_tf_broadcaster이제 TF 트리는 다음처럼 연결된다.
odom
└── base_link
└── lidar_link여기서 작은 마법이 일어난다. 우리는 odom -> lidar_link를 직접 발행하지 않았다. 그런데 TF는 odom -> base_link와 base_link -> lidar_link를 이어 붙여 odom 기준의 lidar_link 위치를 계산할 수 있다. 이것이 TF 트리의 힘이다.
TransformListener와 Buffer 만들기
브로드캐스터가 좌표 변환을 '말하는' 노드라면, listener는 그 말을 '듣고 기억하는' 노드다. ROS2에서 listener는 보통 Buffer와 함께 만든다. TransformListener가 /tf와 /tf_static을 구독해 들어오는 변환을 받고, Buffer가 그 변환들을 일정 시간 동안 저장한다. ROS2 Python 튜토리얼은 listener가 생성되면 들어오는 TF 변환을 받아 버퍼에 보관하고, lookup_transform()으로 필요한 프레임 관계를 조회한다고 설명한다.4
이제 odom 기준의 base_link 위치를 주기적으로 출력하는 노드를 작성해 보자.
cd ~/ros2_ws/src/robot_tf_examples/robot_tf_examples
touch tf_pose_listener.py코드는 다음과 같다.
import math
import rclpy
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.time import Time
from tf2_ros import TransformException
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
def yaw_from_quaternion(q):
siny_cosp = 2.0 * (q.w * q.z + q.x * q.y)
cosy_cosp = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
return math.atan2(siny_cosp, cosy_cosp)
class TfPoseListener(Node):
def __init__(self):
super().__init__('tf_pose_listener')
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.timer = self.create_timer(0.5, self.on_timer)
self.get_logger().info('Listening transform: odom -> base_link')
def on_timer(self):
target_frame = 'odom'
source_frame = 'base_link'
try:
transform = self.tf_buffer.lookup_transform(
target_frame,
source_frame,
Time(),
timeout=Duration(seconds=0.2),
)
except TransformException as ex:
self.get_logger().warn(
f'Could not transform {source_frame} to {target_frame}: {ex}'
)
return
x = transform.transform.translation.x
y = transform.transform.translation.y
yaw = yaw_from_quaternion(transform.transform.rotation)
self.get_logger().info(
f'{source_frame} in {target_frame}: x={x:.3f}, y={y:.3f}, yaw={yaw:.3f} rad'
)
def main(args=None):
rclpy.init(args=args)
node = TfPoseListener()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()lookup_transform()의 인자 순서는 특히 조심해야 한다.
lookup_transform(target_frame, source_frame, time)이 호출은 'source frame이 target frame 기준으로 어디에 있는가'를 묻는다. 따라서 다음 코드는 base_link가 odom 기준으로 어디 있는지 조회한다.
self.tf_buffer.lookup_transform('odom', 'base_link', Time())Time()은 최신으로 사용 가능한 변환을 달라는 뜻으로 자주 사용된다. 로봇 제어 루프에서 '지금 로봇이 어디 있는가'를 빠르게 알고 싶을 때 유용하다. 단, 센서 메시지의 정확한 시각과 맞추어 처리해야 하는 경우에는 최신 변환이 아니라 메시지의 stamp에 해당하는 변환을 조회해야 한다.
setup.py에 listener도 추가한다.
entry_points={
'console_scripts': [
'static_sensor_tf_broadcaster = robot_tf_examples.static_sensor_tf_broadcaster:main',
'fake_odom_tf_broadcaster = robot_tf_examples.fake_odom_tf_broadcaster:main',
'tf_pose_listener = robot_tf_examples.tf_pose_listener:main',
],
},빌드 후 세 노드를 실행한다.
cd ~/ros2_ws
colcon build --packages-select robot_tf_examples
source install/setup.bash터미널 1에서는 정적 센서 TF를 실행한다.
ros2 run robot_tf_examples static_sensor_tf_broadcaster터미널 2에서는 가짜 오도메트리 TF를 실행한다.
ros2 run robot_tf_examples fake_odom_tf_broadcaster터미널 3에서는 listener를 실행한다.
ros2 run robot_tf_examples tf_pose_listener출력은 다음처럼 조금씩 바뀐다.
base_link in odom: x=0.145, y=0.002, yaw=0.217 rad
base_link in odom: x=0.195, y=0.005, yaw=0.292 rad
base_link in odom: x=0.244, y=0.009, yaw=0.367 rad이제 노드는 TF 트리에서 로봇의 위치와 방향을 읽고 있다. Nav2가 로봇 위치를 알고 경로를 따라가거나, 장애물 회피 노드가 센서 데이터를 로봇 기준으로 바꾸는 일도 같은 원리 위에 있다.
최신 시점 조회와 특정 시각 조회
TF를 처음 사용할 때는 Time()으로 최신 변환을 가져오는 것만으로도 충분해 보인다. 하지만 센서 데이터가 들어오면 이야기가 달라진다.
LiDAR 메시지가 다음과 같은 시각에 찍혔다고 하자.
scan.header.stamp = 12.300초그런데 listener가 12.450초에 최신 TF를 조회해 이 LiDAR 데이터를 변환하면, 로봇은 그 사이에 조금 움직였을 수 있다. 느린 로봇에서는 차이가 작지만, 빠르게 움직이거나 회전하는 로봇에서는 점들이 벽을 뚫고 들어가거나 장애물이 흔들리는 것처럼 보일 수 있다.
그래서 센서 데이터를 처리할 때는 보통 메시지의 stamp에 맞추어 변환을 조회한다.
stamp = msg.header.stamp
lookup_time = Time.from_msg(stamp)
transform = self.tf_buffer.lookup_transform(
'base_link',
msg.header.frame_id,
lookup_time,
timeout=Duration(seconds=0.1),
)이 코드는 '센서 메시지가 찍힌 바로 그 시각에, 센서 프레임이 base_link 기준 어디에 있었는가'를 묻는다. 이런 시간 정합이 잘 맞아야 SLAM과 내비게이션에서 지도가 흔들리지 않는다.
특정 시각 조회를 실험하려면 listener에서 현재보다 0.5초 전의 변환을 요청해 볼 수 있다.
past_time = self.get_clock().now() - Duration(seconds=0.5)
transform = self.tf_buffer.lookup_transform(
'odom',
'base_link',
past_time,
timeout=Duration(seconds=0.2),
)버퍼 안에 0.5초 전 변환이 남아 있으면 조회가 성공한다. 하지만 너무 오래전 시각을 요청하거나, 노드를 켠 지 얼마 되지 않아 버퍼에 데이터가 충분히 쌓이지 않았다면 실패할 수 있다.
timeout은 변환이 아직 도착하지 않았을 때 얼마나 기다릴지 정하는 값이다. timeout이 없거나 너무 짧으면 listener가 TF를 받기 전에 조회가 먼저 실행되어 실패할 수 있다. 반대로 timeout을 너무 길게 잡으면 제어 루프가 TF를 기다리느라 느려질 수 있다. 자율주행 로봇에서는 보통 짧게 기다리고, 실패하면 이번 주기의 처리를 건너뛰는 방식이 안전하다.
좌표 하나를 실제로 변환해 보기
TF 조회 결과는 단순한 숫자 출력으로 끝나지 않는다. 좌표를 다른 프레임으로 바꾸는 데 사용된다.
예를 들어 LiDAR가 lidar_link 기준으로 1m 앞에 장애물을 보았다고 하자.
point in lidar_link: x=1.0, y=0.0LiDAR 자체가 base_link보다 0.18m 앞에 달려 있다면, 회전이 없는 경우 이 점은 base_link 기준으로 대략 다음 위치가 된다.
point in base_link: x=1.18, y=0.0하지만 센서가 회전되어 장착되었거나 로봇이 움직이는 중이라면 직접 더하기만으로는 부족하다. TF 변환의 translation과 rotation을 함께 적용해야 한다.
다음은 2D 평면에서 점 하나를 단순 변환하는 예다. 실제 3D 센서 데이터에는 tf2_geometry_msgs와 stamped message를 함께 사용하는 방식이 더 적합하지만, 원리를 이해하기에는 이 코드가 가장 직관적이다.
def transform_point_2d(point_x, point_y, transform):
tx = transform.transform.translation.x
ty = transform.transform.translation.y
yaw = yaw_from_quaternion(transform.transform.rotation)
cos_yaw = math.cos(yaw)
sin_yaw = math.sin(yaw)
result_x = tx + cos_yaw * point_x - sin_yaw * point_y
result_y = ty + sin_yaw * point_x + cos_yaw * point_y
return result_x, result_ybase_link 기준에서 lidar_link의 변환을 얻고 싶다면 이렇게 조회한다.
transform = self.tf_buffer.lookup_transform(
'base_link',
'lidar_link',
Time(),
timeout=Duration(seconds=0.2),
)
x, y = transform_point_2d(1.0, 0.0, transform)이제 x, y는 LiDAR 앞 1m에 있던 점이 로봇 본체 기준으로 어디에 있는지 나타낸다. 장애물 회피에서는 이런 변환이 매 순간 반복된다. 센서는 자기 기준으로 세상을 보고, 로봇은 base_link 기준으로 판단하며, 내비게이션은 map이나 odom 기준으로 경로를 계산한다. TF는 이 세 세계를 연결한다.
RViz, view_frames, rqt_tf_tree로 검증하기
TF 코드는 눈으로 확인해야 한다. 숫자만 보고 있으면 부모-자식이 뒤집혔는지, 트리가 끊겼는지, 프레임 이름에 오타가 있는지 놓치기 쉽다.
가장 먼저 사용할 도구는 tf2_echo다.
ros2 run tf2_ros tf2_echo odom base_link이 명령은 odom과 base_link 사이의 변환을 터미널에 출력한다. 동적 브로드캐스터가 실행 중이라면 translation과 rotation 값이 계속 바뀌어야 한다. 정적 센서 프레임은 다음처럼 확인한다.
ros2 run tf2_ros tf2_echo base_link lidar_link값이 고정되어 있다면 정상이다.
다음은 view_frames다.
ros2 run tf2_tools view_frames이 도구는 잠시 동안 TF 데이터를 듣고 프레임 트리를 PDF로 그린다. ROS2 문서에서도 view_frames는 현재 브로드캐스트되는 프레임 관계와 발행 주기 같은 진단 정보를 확인하는 도구로 소개된다.5
생성된 frames.pdf에서 다음 구조가 보이면 이번 예제는 잘 연결된 것이다.
odom
└── base_link
└── lidar_link만약 odom과 base_link가 따로 떨어져 있거나, lidar_link가 엉뚱한 부모 밑에 있다면 브로드캐스터의 header.frame_id와 child_frame_id를 다시 확인해야 한다.
GUI로 보고 싶다면 rqt_tf_tree도 유용하다.
ros2 run rqt_tf_tree rqt_tf_treeRViz에서는 더 직관적으로 확인할 수 있다.
rviz2RViz가 열리면 왼쪽 패널에서 Fixed Frame을 odom으로 설정하고, Add 버튼을 눌러 TF display를 추가한다. 그러면 프레임 축들이 화면에 나타난다. base_link는 천천히 움직이고, lidar_link는 base_link에 붙은 채 함께 움직여야 한다.
나중에 map 프레임까지 추가되면 전체 구조는 다음처럼 커진다.
map
└── odom
└── base_link
├── lidar_link
├── camera_link
└── imu_linkSLAM 단계에서는 map -> odom 관계가 생기고, Nav2 단계에서는 이 트리를 기반으로 로봇의 현재 위치, 장애물 위치, 목표 경로가 함께 계산된다. 그래서 지금 작은 TF 트리를 정확히 만드는 습관이 중요하다.
launch 파일로 함께 실행하기
TF 노드는 혼자 있을 때보다 함께 실행될 때 의미가 있다. 정적 센서 프레임, 오도메트리 프레임, listener를 매번 터미널 세 개에서 실행하는 것은 번거롭다. launch 파일로 묶어 보자.
패키지 루트에 launch 디렉터리를 만든다.
cd ~/ros2_ws/src/robot_tf_examples
mkdir -p launch
touch launch/tf_demo.launch.py파일 내용은 다음과 같다.
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='robot_tf_examples',
executable='static_sensor_tf_broadcaster',
name='static_sensor_tf_broadcaster',
output='screen',
),
Node(
package='robot_tf_examples',
executable='fake_odom_tf_broadcaster',
name='fake_odom_tf_broadcaster',
output='screen',
),
Node(
package='robot_tf_examples',
executable='tf_pose_listener',
name='tf_pose_listener',
output='screen',
),
])setup.py에서 launch 파일이 설치되도록 설정한다. 파일 위쪽에 필요한 import를 추가한다.
import os
from glob import glob그리고 data_files에 launch 디렉터리를 추가한다.
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*')),
],다시 빌드하고 실행한다.
cd ~/ros2_ws
colcon build --packages-select robot_tf_examples
source install/setup.bash
ros2 launch robot_tf_examples tf_demo.launch.py이제 TF 트리 하나가 자동으로 만들어진다. 이 흐름은 실제 로봇 bringup과 닮아 있다. 로봇을 켜면 센서 TF, robot_state_publisher, 오도메트리, 위치 추정, 내비게이션 노드가 함께 올라와야 한다. TF는 그 노드들이 같은 공간을 바라보게 만드는 공통 언어다.
자주 발생하는 TF 오류와 디버깅 절차
TF 오류는 처음 보면 무섭다. LookupException, ConnectivityException, ExtrapolationException 같은 이름이 나오면 마치 수학이 틀린 것처럼 느껴진다. 하지만 대부분은 몇 가지 원인으로 좁혀진다.
가장 흔한 문제는 프레임 이름 오타다. base_link를 한쪽에서는 base link로 쓰거나, lidar_link를 laser_link로 다르게 쓰면 TF는 둘을 완전히 다른 프레임으로 본다. ROS2에서는 프레임 이름을 프로젝트 전체에서 일관되게 관리해야 한다. URDF, launch 파일, Nav2 파라미터, 센서 드라이버 설정에 같은 이름이 반복해서 등장하기 때문이다.
두 번째는 부모-자식 방향 혼동이다. odom -> base_link를 발행해야 하는데 base_link -> odom을 발행하면 트리 모양이 반대로 생긴다. 이 경우 tf2_echo를 실행했을 때 값은 나오더라도 RViz에서 로봇이 이상하게 움직이거나, 다른 프레임과 연결할 때 구조가 어긋난다.
세 번째는 timestamp 불일치다. 센서 메시지는 과거 시각의 데이터인데 TF는 최신 값만 있거나, 반대로 TF stamp가 현재보다 미래로 찍히면 extrapolation 오류가 날 수 있다. 특히 시뮬레이션에서는 모든 노드가 use_sim_time을 같이 사용해야 한다. 어떤 노드는 시뮬레이션 시간을 쓰고, 어떤 노드는 컴퓨터 벽시계를 쓰면 같은 10초라도 서로 다른 세계의 시간이 된다.
네 번째는 listener 초기 조회 실패다. 노드를 켜자마자 lookup_transform()을 호출하면 아직 Buffer에 변환이 들어오지 않았을 수 있다. 이때는 timeout을 주거나, 예외를 잡고 다음 주기에 다시 시도하면 된다. 초기 몇 번의 실패는 반드시 치명적인 오류가 아니다.
다섯 번째는 같은 변환을 여러 노드가 동시에 발행하는 경우다. 예를 들어 시뮬레이터가 이미 odom -> base_link를 발행하는데, 사용자가 직접 만든 오도메트리 노드도 같은 변환을 발행하면 로봇 위치가 튀거나 TF 트리가 불안정해질 수 있다. 하나의 parent-child 쌍은 원칙적으로 한 노드가 책임지는 편이 좋다.
문제가 생기면 다음 순서로 확인하자.
ros2 run tf2_tools view_frames로 트리가 연결되어 있는지 본다.ros2 run tf2_ros tf2_echo 부모프레임 자식프레임으로 원하는 변환이 실제 출력되는지 확인한다.브로드캐스터 코드의
header.frame_id,child_frame_id,header.stamp를 확인한다.RViz의 Fixed Frame을
odom이나map으로 바꾸어 프레임 축이 의도대로 움직이는지 본다.시뮬레이션 중이라면 모든 관련 노드의
use_sim_time설정이 일치하는지 확인한다.
TF 디버깅은 범인을 찾는 추리와 비슷하다. 로봇이 이상하게 움직인다고 바로 제어기를 의심하지 말고, 먼저 좌표계가 제대로 연결되어 있는지 보자. 많은 경우 문제는 제어 알고리즘이 아니라 '로봇이 자기 위치를 잘못 알고 있는 것'에서 시작된다.
TF를 다룰 수 있다는 것의 의미
이번 장에서 우리는 세 가지 일을 했다. 고정 장착된 센서 프레임을 정적 변환으로 발행했고, 움직이는 로봇 본체를 동적 변환으로 발행했으며, listener로 필요한 프레임 사이의 변환을 조회했다.
이제 lidar_link의 점을 base_link로 바꾸는 일이 낯설지 않다. base_link가 odom에서 어디 있는지 읽는 것도 가능하다. 앞으로 SLAM을 실행하면 map -> odom 관계가 등장하고, Nav2를 실행하면 이 TF 트리를 바탕으로 경로 계획과 장애물 회피가 이어진다.
TF는 로봇 소프트웨어의 배관이다. 잘 보이지 않지만, 막히면 모든 것이 멈춘다. 반대로 TF가 올바르게 흐르면 센서, 위치 추정, 지도, 경로 계획, 제어 노드가 같은 좌표계를 공유한다. 그때부터 로봇은 단순히 바퀴를 굴리는 기계가 아니라, 자신이 어디에 있고 무엇을 보고 있으며 어디로 가야 하는지 이해하는 시스템이 된다.
다음 장에서는 이 좌표계 위에 로봇의 몸체를 올린다. URDF를 이용해 링크와 조인트를 정의하고, 바퀴와 센서가 달린 로봇 모델을 만들어 RViz에서 실제 형태로 확인할 것이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.