대부분의 로보틱스 과정들에서 가장 먼저 다루는 것이 바로 좌표계 변환 - Transformation입니다. 로봇은 수많은 joint와 link로 이루어져 있으며, 센서와 움직임을 다루기 때문에 좌표계를 다루는 일이 매우 빈번합니다.
ROS 2에서는 TF2라는 특수한 형태로 이 좌표계와 시간을 함께 다루고 있습니다. 예시와 설명을 통해 ROS 2의 TF2에 대해 배워봅시다 😊
일전 husky 예시를 통해 tf2에 대한 개요을 파악해보고자 합니다.
# Terminal 1 - husky gazebo
ros2 launch husky_gazebo husky_playpen.launch.py
# Terminal 2 - rviz
rviz2
⇒ rviz에서 보이는 세가지 색상의 막대가 바로 tf2 입니다. x,y,z의 각 축을 각기 다른 색으로 표현하였으며, 연관된 좌표계끼리는 노란 선을 통해 연결한 모습이 보입니다.
⇒ 이렇게 좌표변환에 따른 데이터 시각화가 가능하려면 모든 좌표계들 사이의 회전, 평행 이동 변환 정보를 알고 있어야 할 것입니다. ROS 2에서 이를 어떻게 다룰 수 있는지 또 하나의 예시를 살펴보겠습니다.
# Terminal1 - husky gazebo
ros2 launch husky_gazebo husky_playpen.launch.py
# Terminal2 - tf2 view frame
cd ~/ros2_ws
ros2 run tf2_tools view_frames.py
tree를 확대해서 살펴봅시다.
만약 tf tree가 온전히 연결되어 있지 않다면 어떤 일이 발생할까요?
⇒ tf2간 연결이 끊어지게 되면, 끊어진 아래 시점에 대해서는 그 어떠한 정보도 알 수 없습니다. 따라서 rviz2에서의 시각화나, SLAM, 자율주행 시 이는 큰 문제가 됩니다.
image from : answers.ros.org
$ ros2 interface show sensor_msgs/msg/PointCloud2
**std_msgs/Header header**
uint32 height
uint32 width
sensor_msgs/PointField[] fields
bool is_bigendian
uint32 point_step
uint32 row_step
uint8[] data
bool is_dense
$ ros2 interface show std_msgs/msg/Header
uint32 seq
time stamp
string frame_id
⇒ frame_id와 stamp이 바로 tf2와 관련된 데이터로 이렇게 센서 데이터들은 대부분 좌표와 시간에 대한 정보를 포함하게 됩니다.
이번 시간에는 크게 3가지 예시를 통해 tf2에 대해 학습할 예정입니다.
topic publish를 하는데 왜 또 tf2 boradcast가 필요할까요?
⇒ 바로 위 예시처럼 topic에 담긴 정보는 실제 좌표변환이 아닌, frame id입니다. 이는 topic data를 받은 subscriber node 입장에서 센서 데이터를 다루는 상황을 생각해보면 이해하기 쉽습니다.
그럼, 예시와 코드를 통해 tf2를 마스터해봅시다!
tf2의 학습을 위해 gazebo 환경을 만들어 두었습니다. 함께 실행해봅시다.
cd ~/ros2_ws
cbp tf2_world && source install/local_setup.bash
ros2 launch tf2_world tf2_world.launch.py
마치 행성과 위성 같은 환경을 볼 수 있습니다.
ros2 run tf2_tools view_frames.py
tf2 데이터는 아무것도 없지요? 이번 시간의 목표는 우리가 직접 코딩과 launch file을 통해 tree를 구성해 보는 것입니다.
[gzserver-1] [INFO] [1681981785.437867364] [gazebo_ros_state]: Publishing states of gazebo models at [/model_states]
[gzserver-1] [INFO] [1681981785.438120429] [gazebo_ros_state]: Publishing states of gazebo links at [/link_states]
움직이지 않는 static object의 tf2 데이터는 static transform publisher가 담당합니다. 덕분에 우리는 굳이 코딩을 하지 않아도 미리 준비된 node를 실행하기만 하면 됩니다.
static_transform_publisher = Node(
package = "tf2_ros",
executable = "static_transform_publisher",
arguments = ["0", "0", "0.5", "0", "0", "0", "world", "unit_sphere"],
)
return LaunchDescription(
[
static_transform_publisher,
]
)
⇒ world frame 기준 unit_sphere frame를 z축으로 0.5만큼 떨어뜨려 tf2 data가 생성됩니다.
로봇 개발자들은 암묵적으로 모든 상황의 원점이 되는 절대 좌표계를 world frame이라고 정합니다.
ros2 run tf2_tools view_frames.py
⇒ world to unit_sphere tf가 성공적으로 생성되었습니다!
움직이지 않는 물체 뿐만 아니라, 로봇 내부에서 고정된 부분에서도 static_transform_publisher를 사용할 수 있습니다. 자주 탈부착되는 센서 frame이 대표적인 예시입니다.
지난 강의에서 고정된 물체에 대한 tf2 데이터를 다루는 방법을 알아보았습니다. 이번 시간부터 tf2를 다루는 코드를 작성해 보겠으며, 최종적으로 아래와 같은 시스템을 만들어보겠습니다.
topic에서는 publish/subscribe라는 용어를 사용하였지만 tf2에서는 broadcast/listen이라는 용어를 사용합니다. topic과 tf2는 완전 별개의 개념입니다! 혼란되지 않도록 주의하세요.
# Terminal1 - gazebo
ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
cbp py_tf2_tutorial && source install/local_setup.bash
ros2 run py_tf2_tutorial tf2_broadcaster
$ ros2 interface show gazebo_msgs/msg/ModelStates
# broadcast all model states in world frame
string[] name # model names
geometry_msgs/Pose[] pose # desired pose in world frame
geometry_msgs/Twist[] twist # desired twist in world frame
⇒ 이 모양을 기억하면서 코드로 진입해보겠습니다.
$ ros2 interface show geometry_msgs/msg/TransformStamped
# This expresses a transform from coordinate frame header.frame_id
# to the coordinate frame child_frame_id
#
# This message is mostly used by the
# tf package.
# See its documentation for more information.
Header header
string child_frame_id # the frame id of the child frame
Transform transform
import rclpy
from rclpy.node import Node
from tf2_ros import TransformBroadcaster
from gazebo_msgs.msg import ModelStates
from geometry_msgs.msg import TransformStamped
⇒ 따라서 topic sub과 tf2 broadcast가 필요한 상황입니다.
class OrbitBroadcaster(Node):
def __init__(self):
super().__init__('tf2_frame_broadcaster')
# Initialize the transform broadcaster
self.tf_broadcaster = TransformBroadcaster(self)
# Subscribe to a turtle{1}{2}/pose topic and call handle_turtle_pose
# callback function on each message
self.m_model_state_sub = self.create_subscription(
ModelStates, "model_states", self.handle_turtle_pose, 1
)
def handle_turtle_pose(self, msg):
index = [i for i in range(len(msg.name)) if msg.name[i] == "animated_sphere"][0]
t = TransformStamped()
# Read message content and assign it to
# corresponding tf variables
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'world'
t.child_frame_id = 'animated_sphere'
t.transform.translation.x = msg.pose[index].position.x
t.transform.translation.y = msg.pose[index].position.y
t.transform.translation.z = msg.pose[index].position.z
t.transform.rotation.x = msg.pose[index].orientation.x
t.transform.rotation.y = msg.pose[index].orientation.y
t.transform.rotation.z = msg.pose[index].orientation.z
t.transform.rotation.w = msg.pose[index].orientation.w
# Send the transformation
self.tf_broadcaster.sendTransform(t)
Description | |
---|---|
TransformBroadcaster() | broadcaster 생성 |
TransformStamped() | tf2 msg 생성 |
sendTransform(msg) | tf2 broadcast |
⇒ TF2 Listener를 사용하여 이를 구현해봅시다!
# Terminal1 - gazebo
$ ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
$ ros2 run py_tf2_tutorial tf2_broadcast
# Terminal3 - tf2 listener
$ ros2 run py_tf2_tutorial tf2_listener
[INFO] [1681998128.822169076] [tf2_frame_listener]: linear vel : 2.542703, angular vel : 1.810120
[INFO] [1681998128.922124745] [tf2_frame_listener]: linear vel : 2.501199, angular vel : 1.610775
...
import math
from geometry_msgs.msg import Twist
import rclpy
from rclpy.node import Node
from tf2_ros import TransformException
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
class TF2Listener(Node):
def __init__(self):
super().__init__('tf2_frame_listener')
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
# Create turtle2 velocity publisher
self.publisher = self.create_publisher(Twist, 'sphere_vel', 10)
# Call timer_cb function every 10 seconds
self.timer = self.create_timer(0.1, self.timer_cb)
⇒ TransformListener의 생성 이후, 모든 tf2의 listen을 시작하면 이를 Buffer에 담아두는 식입니다.
⇒ 주기적으로 listen, publish를 위해 timer를 사용합니다.
다음으로, timer_cb 함수를 뜯어봅시다.
from_frame_rel = 'animated_sphere'
to_frame_rel = 'world'
try:
t = self.tf_buffer.lookup_transform(
from_frame_rel, to_frame_rel, rclpy.time.Time())
except TransformException as ex:
self.get_logger().info(
f'Could not transform {to_frame_rel} to {from_frame_rel}: {ex}')
return
msg = Twist()
scale_rotation_rate = 1.0
msg.angular.z = scale_rotation_rate * math.atan2(
t.transform.translation.y,
t.transform.translation.x)
scale_forward_speed = 0.5
msg.linear.x = scale_forward_speed * math.sqrt(
t.transform.translation.x ** 2 +
t.transform.translation.y ** 2)
self.get_logger().info(f"linear_vel: {msg.linear.x}, angular_vel: {msg.angular.z}")
self.publisher.publish(msg)
⇒ 현재 예시이므로 정확한 3차원 속도를 계산하지는 않았지만, gazebo의 model_state 데이터에 기반하여 환경의 ground_truth로 사용할 수 있습니다.
# Terminal1 - gazebo
$ ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
$ ros2 run py_tf2_tutorial tf2_broadcast
# Terminal3 - tf2 add frame
$ ros2 run py_tf2_tutorial tf2_add_frame
⇒ 이번 예시는 새로운 dynamic frame을 추가하는 것입니다. 일전 정적 frame은 일전 launch file에서 static_transform_publisher를 사용하였지요? dynamics frame은 코드로 구현해보겠습니다.
import rclpy
import numpy as np
from rclpy.node import Node
from tf2_ros import TransformBroadcaster
from geometry_msgs.msg import TransformStamped
class InverOrbitFrame(Node):
def __init__(self):
super().__init__('inverse_orbit_frame_tf2_broadcaster')
self.count = 0
self.freq_factor = 2* np.pi / 20;
self.tf_broadcaster = TransformBroadcaster(self)
self.timer = self.create_timer(0.1, self.broadcast_timer_callback)
⇒ animated_sphere을 기준으로 원 운동을 하는 새로운 frame을 만드는 것이 목표입니다. 따라서 (cos(theta), sin(theta))의 좌표를 갖는 tf2 data를 구축하고 이를 시간에 따라 broadcast 하였습니다.
def broadcast_timer_callback(self):
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'animated_sphere'
t.child_frame_id = 'inverse_orbit_frame'
t.transform.translation.x = np.cos(-self.count * self.freq_factor)
t.transform.translation.y = np.sin(-self.count * self.freq_factor)
t.transform.translation.z = 0.5
t.transform.rotation.x = 0.0
t.transform.rotation.y = 0.0
t.transform.rotation.z = 0.0
t.transform.rotation.w = 1.0
self.tf_broadcaster.sendTransform(t)
self.count += 1
if self.count >= 20:
self.count = 0
실제 개발에서 tf2 데이터는 실시간성이 무척 중요한 좌표변환을 다루므로, c++ 코드를 사용하는 비중이 더 큽니다. 따라서, 우선 모든 예시를 살펴보고 센서 패키지에 기반하여 실 사용 사례도 확인해보겠습니다.
# Terminal1 - gazebo
ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
ros2 run cpp_tf2_tutorial tf2_broadcaster
#include "rclcpp/rclcpp.hpp"
#include "tf2/LinearMath/Quaternion.h"
#include "tf2_ros/transform_broadcaster.h"
#include "gazebo_msgs/msg/model_states.hpp"
#include "geometry_msgs/msg/transform_stamped.hpp"
class OrbitBroadcaster : public rclcpp::Node
{
private:
rclcpp::Subscription<ModelStates>::SharedPtr m_model_state_sub;
std::unique_ptr<tf2_ros::TransformBroadcaster> m_tf_broadcaster;
public:
OrbitBroadcaster() : Node("tf2_frame_broadcaster"){
m_model_state_sub = this->create_subscription<ModelStates>(
"model_states", 10,
std::bind(&OrbitBroadcaster::model_state_sub_cb, this, std::placeholders::_1)
);
// Initialize the transform broadcaster
m_tf_broadcaster =
std::make_unique<tf2_ros::TransformBroadcaster>(*this);
}
private:
void model_state_sub_cb(const ModelStates::ConstSharedPtr msg)
{
geometry_msgs::msg::TransformStamped t;
std::string model_name = "animated_sphere";
auto it = find(msg->name.begin(), msg->name.end(), model_name);
auto index = it - msg->name.begin();
#include "tf2/LinearMath/Quaternion.h"
// tf2::Quaternion q;
// q.setRPY(0, 0, msg->theta);
t.transform.rotation.x = msg->pose[index].orientation.x;
t.transform.rotation.y = msg->pose[index].orientation.y;
t.transform.rotation.z = msg->pose[index].orientation.z;
t.transform.rotation.w = msg->pose[index].orientation.w;
// Send the transformation
m_tf_broadcaster->sendTransform(t);
}
find_package(tf2 REQUIRED)
find_package(tf2_ros REQUIRED)
find_package(gazebo_msgs REQUIRED)
find_package(geometry_msgs REQUIRED)
add_executable(tf2_broadcast src/tf2_broadcast.cpp)
ament_target_dependencies(tf2_broadcast tf2 tf2_ros gazebo_msgs geometry_msgs)
Description | |
---|---|
std::make_unique<tf2_ros::TransformBroadcaster> | broadcaster 생성 |
geometry_msgs::msg::TransformStamped | tf2 msg 생성 |
tf_broadcaster->sendTransform(t) | tf2 broadcast |
# Terminal1 - gazebo
$ ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
$ ros2 run py_tf2_tutorial tf2_broadcast
# Terminal3 - tf2 listener
$ ros2 run py_tf2_tutorial tf2_listener
[INFO] [1681998128.822169076] [tf2_frame_listener]: linear vel : 2.542703, angular vel : 1.810120
[INFO] [1681998128.922124745] [tf2_frame_listener]: linear vel : 2.501199, angular vel : 1.610775
...
#include "rclcpp/rclcpp.hpp"
#include "tf2/exceptions.h"
#include "tf2_ros/transform_listener.h"
#include "tf2_ros/buffer.h"
#include "geometry_msgs/msg/twist.hpp"
#include "geometry_msgs/msg/transform_stamped.hpp"
using namespace std::chrono_literals;
using Twist = geometry_msgs::msg::Twist;
using TransformStamped = geometry_msgs::msg::TransformStamped;
public:
TF2Listener(): Node("tf2_frame_listener"){
m_tf_buffer = std::make_unique<tf2_ros::Buffer>(this->get_clock());
m_tf_listener = std::make_shared<tf2_ros::TransformListener>(*m_tf_buffer);
void timer_cb(){
// Store frame names in variables that will be used to
// compute transformations
TransformStamped t;
try {
t = m_tf_buffer->lookupTransform(
"animated_sphere", "world", tf2::TimePointZero
);
# Terminal1 - gazebo
$ ros2 launch tf2_world tf2_world.launch.py
# Terminal2 - tf2 broadcaster
$ ros2 run cpp_tf2_tutorial tf2_broadcast
# Terminal3 - tf2 add frame
$ ros2 run cpp_tf2_tutorial tf2_add_frame
⇒ tf2 broadcaster와 큰 차이가 없으므로, 구체적인 설명은 생략하겠습니다.
이번 시간에는 실제 센서 데이터 ROS 2 패키지에서 tf2가 사용된 모습을 확인해보겠습니다.
두 센서 패키지를 참고해보려 합니다.
// publish tf
if (publish_tf_) {
geometry_msgs::msg::TransformStamped tf;
tf.header.stamp = now;
tf.header.frame_id = parent_frame_id_;
tf.child_frame_id = frame_id_;
tf.transform.translation.x = 0.0;
tf.transform.translation.y = 0.0;
tf.transform.translation.z = 0.0;
tf.transform.rotation = imu_data_msg.orientation;
broadcaster_->sendTransform(tf);
}
⇒ link1
void BaseRealSenseNode::publish_static_tf(const rclcpp::Time& t,
const float3& trans,
const tf2::Quaternion& q,
const std::string& from,
const std::string& to)
{
geometry_msgs::msg::TransformStamped msg;
msg.header.stamp = t;
msg.header.frame_id = from;
msg.child_frame_id = to;
// Convert x,y,z (taken from camera extrinsics)
// from optical cooridnates to ros coordinates
msg.transform.translation.x = trans.z;
msg.transform.translation.y = -trans.x;
msg.transform.translation.z = -trans.y;
msg.transform.rotation.x = q.getX();
msg.transform.rotation.y = q.getY();
msg.transform.rotation.z = q.getZ();
msg.transform.rotation.w = q.getW();
_static_tf_msgs.push_back(msg);
}
void BaseRealSenseNode::publishStaticTransforms(std::vector<rs2::stream_profile> profiles)
{
// Publish static transforms
if (_publish_tf)
{
for (auto &profile : profiles)
{
calcAndPublishStaticTransform(profile, _base_profile);
}
if (_static_tf_broadcaster)
_static_tf_broadcaster->sendTransform(_static_tf_msgs);
}
}
...
void BaseRealSenseNode::pose_callback(rs2::frame frame)
{
...
geometry_msgs::msg::PoseStamped pose_msg;
pose_msg.pose.position.x = -pose.translation.z;
...
static tf2_ros::TransformBroadcaster br(_node);
geometry_msgs::msg::TransformStamped msg;
msg.header.stamp = t;
msg.header.frame_id = DEFAULT_ODOM_FRAME_ID;
msg.child_frame_id = FRAME_ID(POSE);
msg.transform.translation.x = pose_msg.pose.position.x;
msg.transform.translation.y = pose_msg.pose.position.y;
msg.transform.translation.z = pose_msg.pose.position.z;
msg.transform.rotation.x = pose_msg.pose.orientation.x;
msg.transform.rotation.y = pose_msg.pose.orientation.y;
msg.transform.rotation.z = pose_msg.pose.orientation.z;
msg.transform.rotation.w = pose_msg.pose.orientation.w;
if (_publish_odom_tf) br.sendTransform(msg);
⇒ 참고 링크 1
⇒ 참고 링크 2