Topic, Service, Action, Gazebo 예시들을 진행하면서 다양한 센서 데이터들을 다루어보았습니다. 이번 시간에는 예시들과 함께 해당 센서 데이터들의 ROS 2 연동 프로그램을 작성해 보겠습니다.
다양한 컴퓨터 비전 알고리즘을 담고 있는 오픈소스 비전 라이브러리인 OpenCV를 ROS 2와 연동하여 사용해 봅시다.
첫번째 시간으로 ROS 2의 이미지 데이터를 OpenCV Python, cv2와 연동하는 방법에 대해 알아보겠습니다.
# Import the necessary libraries
import rclpy # Python Client Library for ROS 2
from rclpy.node import Node # Handles the creation of nodes
from sensor_msgs.msg import Image # Image is the message type
from cv_bridge import CvBridge # Package to convert between ROS and OpenCV Images
import cv2 # OpenCV library
VIDEO_FILE_ROOT = "/home/kimsooyoung/ros2_ws/rosbag2/img_pub_test.mp4"
# Terminal 1 - pkg build and run
cbp py_cv_tutorial && source install/local_setup.bash
ros2 run py_cv_tutorial img_pub
# Terminal 2 - rqt_image_view
ros2 run rqt_image_view rqt_image_view
⇒ rqt image view는 이미지 topic만을 위한 rqt tool로 이미지 데이터를 시각화해줍니다.
from cv_bridge import CvBridge # Package to convert between ROS and OpenCV Images
import cv2 # OpenCV library
class ImagePublisher(Node):
"""
Create an ImagePublisher class, which is a subclass of the Node class.
"""
def __init__(self):
...
# Used to convert between ROS and OpenCV images
self.br = CvBridge()
def timer_callback(self):
...
if ret == True:
# Publish the image.
# The 'cv2_to_imgmsg' method converts an OpenCV
# image to a ROS 2 image message
self.publisher_.publish(self.br.cv2_to_imgmsg(frame, encoding="bgr8"))
⇒ CvBridge 객체의 imgmsg_to_cv2를 사용하면 손쉽게 Image data로 변환이 가능합니다.
일전 학습하였던 rosbag2을 사용하여 예시를 실행해보고자 합니다. 제가 미리 준비한 데이터셋을 제공드리니, 기억하기 쉬운 위치에 다운받아 주세요.
Windows + WSL2 유저의 경우 explorer.exe
명령어를 통해 파일 탐색기를 열 수 있습니다.
# 예제 실행 준비
colcon build --packages-select py_cv_tutorial
source install/local_setup.bash
# Terminal 1 – 데이터 위치로 이동하여 ros2 bag 실행
cd <데이터를 저장한 위치>
ros2 bag play quadrupped_train.bag_0.db3
# Terminal 2 – image_view 실행
ros2 run rqt_image_view rqt_image_view
사진과 같이 기차 선로 모습이 보인다면 성공입니다.
해당 데이터셋은 기차 선로에서 주행하는 4족 보행 로봇으로부터 취득한 것입니다. 기차 선로는 주기적으로 관리가 필요하며, 길고 긴 선로를 로봇이 자동으로 검사해준다면 아주 유용할 것입니다. 이러한 상상을 해보면서 실습에 임해봅시다.
# Terminal 1
$ cd <bag 파일 위치>
$ ros2 bag play quadruped_train/quadruped_train.bag_0.db3
[INFO] [1667077909.362223100] [rosbag2_storage]: Opened database 'quadrupped_train/quadrupped_train.bag_0.db3' for READ_ONLY.
...
# Terminal 2
$ ros2 run py_cv_tutorial img_sub
[INFO] [1667077909.613586300] [image_subscriber]: Receiving video frame
[INFO] [1667077910.934147000] [image_subscriber]: Receiving video frame
...
from cv_bridge import CvBridge # Package to convert between ROS and OpenCV Images
import cv2 # OpenCV library
class ImageSubscriber(Node):
def __init__(self):
# Used to convert between ROS and OpenCV images
self.br = CvBridge()
...
def listener_callback(self, data):
...
# Convert ROS Image message to OpenCV image
current_frame = self.br.imgmsg_to_cv2(data, "bgr8")
edge_frame = self.hough_transform(current_frame)
직선 검출을 위해 사용된 OpenCV 기능들은 아래와 같습니다. 로직을 업그레이드하여 여러분만의 로직을 만들어 보세요!
컴퓨터 비전만으로 완벽한 선로 인식을 구현하기는 너무나 힘듭니다. 딥러닝을 사용해서 이를 극복할 수 있을 것이며, 데이터셋의 제작을 위해 rosbag2 데이터에서 이미지를 추출하는 예시를 준비하였습니다.
$ ros2 run py_cv_tutorial rosbag2_to_timedimg
/home/kimsooyoung/ros2_ws/rosbag2/quadrupped_train/quadrupped_train.bag_0.db3
saved: color_1666796292992515592.png
saved: color_1666796293035428479.png
saved: color_1666796293079139778.png
saved: color_1666796293120768031.png
saved: color_1666796293161406687.png
...
def main(args=None):
# Change below roots to your ros2 bag locations
ROOT_DIR = "/home/kimsooyoung/ros2_ws/rosbag2/quadrupped_train"
FILENAME = "/quadrupped_train.bag_0.db3"
DESCRIPTION = "color_"
파이썬을 통해 ROS 2와 OpenCV를 연동하는 기본적인 맥락은 이해하셨으리라 생각합니다. 동일한작업을 C++에서 작업하면서 몇가지 유의점을 살펴보겠습니다.
$ pkg-config --modversion opencv4
4.2.0
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/header.hpp"
#include "sensor_msgs/msg/image.hpp"
// cv_bridge converts between ROS 2 image messages and OpenCV image representations.
#include <cv_bridge/cv_bridge.h>
// Using image_transport allows us to publish and subscribe to compressed image streams in ROS2
#include <image_transport/image_transport.hpp>
// We include everything about OpenCV as we don't care much about compilation time at the moment.
#include <opencv2/opencv.hpp>
const std::string VIDEO_FILE_ROOT = "/home/kimsooyoung/ros2_ws/go1_lidar.mp4";
# Terminal 1 - run demo
ros2 run cpp_cv_tutorial img_pub
# Terminal 2 - rqt_image_view
ros2 run rqt_image_view rqt_image_view
#include <cv_bridge/cv_bridge.h>
#include <image_transport/image_transport.hpp>
#include <opencv2/opencv.hpp>
...
void timer_callback() {
// // Create a new 640x480 image
// cv::Mat my_image(cv::Size(640, 480), CV_8UC3);
//--- GRAB AND WRITE LOOP
cap.read(my_img);
if (my_img.empty()){
std::cerr << "ERROR! blank frame grabbed\n";
rclcpp::shutdown();
}
// Write message to be sent. Member function toImageMsg() converts a CvImage
// into a ROS image message
img_msg = cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", my_img).toImageMsg();
// Publish the image to the topic defined in the publisher
image_pub->publish(*img_msg.get());
}
ros2 launch chess_world chess_world.launch.py
class ImageSubscriber : public rclcpp::Node{
private:
rclcpp::Subscription<Image>::SharedPtr m_img_sub;
rclcpp::Publisher<Image>::SharedPtr m_img_pub;
public:
ImageSubscriber(): Node("image_subscriber"){
std::string topic_name = "video_frames";
m_img_sub = this->create_subscription<Image>(
topic_name, 10, std::bind(&ImageSubscriber::image_cb, this, std::placeholders::_1)
);
ros2 run cpp_cv_tutorial img_sub
cv::Mat image_processing(const cv::Mat img_in){
// Create output image
cv::Mat img_out;
cv::Mat img_small;
...
return img_out;
}
void image_cb(const Image::SharedPtr msg) const{
// Convert ROS Image to CV Image
cv::Mat frame = cv_bridge::toCvCopy(
msg, sensor_msgs::image_encodings::BGR8
)->image;
// Image processing
cv::Mat proced_img = image_processing(frame);
}
find_package(OpenCV REQUIRED)
add_executable(img_pub src/img_pub.cpp)
ament_target_dependencies(img_pub rclcpp std_msgs sensor_msgs cv_bridge image_transport OpenCV)
add_executable(img_sub src/img_sub.cpp)
ament_target_dependencies(img_sub rclcpp std_msgs sensor_msgs cv_bridge image_transport OpenCV)
이번 시간부터 3D PointCloud Data 처리를 위한 다양한 기능을 제공하는 PCL(Point Cloud Library)을 실습하고 ROS 2와 연동해보고자 합니다.
사실 기본적인 PCL에 대한 이해가 있다면 일전 cv2 예시처럼 ROS 2 ⇒ PCL, PCL ⇒ ROS 2의 Mapping 작업만 잘 수행하면 되는데요. 따라서 python, c++에서 pcl을 다루는 방법에 대해 먼저 익숙해진 뒤 ROS 2에서 이를 구현하는 방식으로 진행하겠습니다.
⇒ 하지만 x,y,z를 비롯해서 Point Cloud의 추가 데이터 항목들이 있습니다. 자주 사용되는 point cloud 포멧들을 살펴봅시다.
가장 많이 사용하는 기본 데이터 타입으로 xyz 위치 데이터만 담고 있습니다.
intensity라는 필드가 추가된 형태로 이는 물체로부터 되돌아온 빛의 양을 뜻합니다. 바닥에 고인 물이나 투명한 물체의 경우 이 I 필드를 통해 detection 가능하여 최근 활발히 연구되고 있습니다.
RGB 3색이 포함된 데이터 타입으로 rgbd camera에서 주로 사용됩니다.
Point Cloud는 여러 데이터 포맷으로 사용 가능하지만 가장 자주 사용되는 것은 pcd 포맷입니다. 이는 크게 header/data로 구분되어 있으며 예시를 통해 함께 익숙해져보겠습니다.
Description | |
---|---|
Header | 전체 포인트 수, 데이터 타입, 크기 등의 정보 |
Data | x,y,z 또는 x,y,z + 추가정보 |
# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z rgb
SIZE 4 4 4 4
TYPE F F F U
COUNT 1 1 1 1
WIDTH 4421
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 4421
DATA ascii
-0.8739934 -1.342802 0.73930842 1577997
-0.86556709 -1.342802 0.73930842 1577997
-0.85433203 -1.3428019 0.73930848 1577997
-0.84450132 -1.3428019 0.73930854 1577997
std_msgs/Header header
geometry_msgs/Point32[] points
ChannelFloat32[] channels
std_msgs/Header header
uint32 height
uint32 width
PointField[] fields
bool is_bigendian # Is this data bigendian?
uint32 point_step # Length of a point in bytes
uint32 row_step # Length of a row in bytes
uint8[] data # Actual point data, size is (row_step*height)
bool is_dense # True if there are no invalid points
PointCloud2가 더욱 풍부한 데이터를 담고 있는데요. ROS 2 Foxy 이상부터 PointCloud2를 지원하며 그 이후 버전부터 PointCloud는 거의 사용하지 않습니다.
PCL c++은 ROS 2 설치 시 자동으로 설치되므로 별도 설치는 필요 없습니다. (오히려 잘못 건들면 복잡해지므로 가만히 냅둡시다.) PCL python은 ubuntu 버전에 따라 설치 방법이 다릅니다.
$ sudo pip install cython
$ sudo apt-get install pcl-tools
$ sudo apt install python3-pcl
$ sudo add-apt-repository ppa:sweptlaser/python3-pcl
$ sudo apt update
$ sudo apt install python3-pcl
$ sudo apt install python3-pcl
$ python3
Python 3.8.10
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pcl
>>>
아래 표와 같이 pcl은 다양한 알고리즘을 API 형태로 제공하고 있습니다. 이들에 익숙해지기 위해 python pcl을 사용하여 실습을 해본 뒤, 실제 ROS 2 topic을 처리해보고, c++로도 사용해보겠습니다.
Fuction | Description |
---|---|
pcl_visualization | PointCloud 시각화 도구 |
pcl_surface | 3차원 곡면 복원 |
pcl_segmentation | PointCloud의 Clustering과 Classification 기능 |
pcl_sample_consensus | 선, 평면, 실린더 등 모델 추정을 위한 알고리즘 |
pcl_kdtree | 빠른 처리, 탐색을 위한 자료 구조 |
pcl_features | PointCloud로부터 특징 추출을 위한 자료 구조와 방법들 |
pcl_registration | ICP와 같이 서로 다른 PointCloud를 Align하고 정합하기 위한 방법들 |
pcl_keypoints | Key point 검출 알고리즘 (BRISK, Harris Corner, NARF, SIFT, SUSAN 등) |
pcl_io 3D | 센서로부터 PointCloud 데이터를 직접 읽고 쓰기 위한 인터페이스 |
cd py_pcl_tutorial/exercise
python3 example1_load_and_view.py
import pcl
import pcl.pcl_visualization
# Load point cloud file
cloud = pcl.load_XYZRGB('./data/pcl_sub_node.pcd')
# Create pcl built-in viewer
visual = pcl.pcl_visualization.CloudViewing()
##### Visualize APIs #####
# 'ShowColorACloud',
# 'ShowColorCloud',
# 'ShowGrayCloud',
# 'ShowMonochromeCloud',
# 'WasStopped'
filename = 'example_save.pcd'
pcl.save(cloud, filename)
visual.ShowColorCloud(cloud, b'cloud')
v = True
while v:
v = not(visual.WasStopped())
pcl_sub_node.pcd는 lidar world에서 제가 미리 추출해 둔 pointcloud 입니다.
Description | |
---|---|
pcl.load_XYZRGB() | pcd data를 load합니다. 단, 저장된 유형에 따라 load_XYZ, load_XYZI 등 사용하는 함수가 다릅니다. |
pcl.pcl_visualization.CloudViewing() | 시각화를 위한 뷰어를 생성합니다. |
pcl.save(, ) | 처리한 cloud를 저장합니다. |
수많은 point cloud들을 모두 각각 처리하는 것은 매우 많은 연산을 요합니다. 따라서 보통 이를 3차원 격자의 Voxel 형태로 Sampling 합니다. Voxel이란, 마치 게임 그래픽처럼 해상도를 낮춘 큐브이며 Voxel 내 점 군 유/무에 따라 중심점을 샘플링합니다.
# Voxel Grid filter
# Create a VoxelGrid filter object for our input point cloud
vox = cloud.make_voxel_grid_filter()
# Choose a voxel (also known as leaf) size
# Note: this (1) is a poor choice of leaf size
# Experiment and find the appropriate size!
LEAF_SIZE = 0.01
# Set the voxel (or leaf) size
vox.set_leaf_size(LEAF_SIZE, LEAF_SIZE, LEAF_SIZE)
# Call the filter function to obtain the resultant downsampled point cloud
cloud_voxed = vox.filter()
LEAF_SIZE = 0.01
LEAF_SIZE = 0.1
ROI(Region of Interest)를 설정하고 원하는 위치의 point cloud들만 추려내보겠습니다. 주로 바닥 지점이나, 테이블 위 물체에서 물체 부분만 추출하는 경우 사용됩니다.
# Pass Through filter
passthrough = cloud_voxed.make_passthrough_filter()
# Assign axis and range to the passthrough filter object.
filter_axis = 'z'
passthrough.set_filter_field_name(filter_axis)
axis_min = -1.1
axis_max = 1.1
passthrough.set_filter_limits(axis_min, axis_max)
cloud_filtered = passthrough.filter()
⇒ 현재 z축을 기준으로 -1.1 ~ 1.1 영역을 필터링하였습니다. x y 축에 대해서도 동일 작업이 가능합니다.
직선은 2개의 매개변수로, 평면은 3개의 매개변수로 모델링할 수 있습니다. PCL에서는 이런 모델 기반 Consensus를 사용하는 가장 대표적인 예시인 RANSAC을 제공하고 있습니다.
# RANSAC plane segmentation
# Create the segmentation object
seg = cloud_filtered.make_segmenter()
# Set the model you wish to fit
seg.set_model_type(pcl.SACMODEL_PLANE)
seg.set_method_type(pcl.SAC_RANSAC)
# Max distance for a point to be considered fitting the model
# Experiment with different values for max_distance
# for segmenting the table
max_distance = 0.01
seg.set_distance_threshold(max_distance)
# Call the segment function to obtain set of inlier indices and model coefficients
inliers, coefficients = seg.segment()
# Extract inliers
extracted_inliers = cloud_filtered.extract(inliers, negative=True)
목적 자체는 바닥을 제거한다는 점에서 Pass Through Filter와 동일하지만, 사용되는 알고리즘에서 차이를 갖습니다.
실제 센서를 사용하다보면 사진과 같이 노이즈도 심하고, 의도치 않는 비정상적인 이상치가 발생합니다. PCL에서는 일종의 평균 필터를 통해 이를 제거하는 함수를 갖고 있습니다.
# Much like the previous filters, we start by creating a filter object:
outlier_filter = cloud_filtered.make_statistical_outlier_filter()
# Set the number of neighboring points to analyze for any given point
outlier_filter.set_mean_k(50)
# Set threshold scale factor
x = 1.0
# Any point with a mean distance larger than global (mean distance+x*std_dev) will be considered outlier
outlier_filter.set_std_dev_mul_thresh(x)
# Finally call the filter function for magic
cloud_filtered = outlier_filter.filter()
⇒ 현재 우리 예시는 Gazebo에서 추출한지라 이 필터의 영향이 거의 없습니다. 따라서 예제 코드는 생략하겠습니다.
KD-Tree는 Binary Tree를 응용한 K 차원의 자료 구조입니다. x,y,z 3차원에 대한 KD-Tree를 구축하고, 이 안에 모든 point들을 가지런히 추가해두면, 이후 거리 계산 등 연산 시 매우 빠른 탐색이 가능합니다.
# create kd tree for the search method of extraction
tree = extracted_inliers.make_kdtree()
⇒ XYZRGB 데이터는 make_kdtree_flann()이라는 함수를 사용합니다.
Point Cloud들을 분류하여 군집화를 해야 물체 인식과 추정이 가능합니다. 군집과 알고리즘에는 여러 종류가 있지만 이번 실습에서는 밀집도를 기준으로 분류하는 Euclidean Clustering을 사용하겠습니다.
이 방식은 클러스터 수를 지정하지 않아도 알아서 클러스터 수도 결정하고, 불특정한 형상의 클러스터도 분류할 수 있습니다. 뿐만 아니라 노이즈에도 강인한 알고리즘입니다.
# Load point cloud file
cloud = pcl.load_XYZRGB('./data/pcl_sub_node.pcd')
# eliminate color from cloud for clustering
while_cloud = XYZRGB_to_XYZ(cloud)
...
# Create a cluster extraction object
ec = extracted_inliers.make_EuclideanClusterExtraction()
# Set tolerances for distance threshold
# as well as minimum and maximum cluster size (in points)
ec.set_ClusterTolerance(0.07)
ec.set_MinClusterSize(10)
ec.set_MaxClusterSize(25000)
# Search the k-d tree for clusters
ec.set_SearchMethod(tree)
# Extract indices for each of the discovered clusters
cluster_indices = ec.Extract()
# Create Cluster-Mask Point Cloud to visualize each cluster separately
get_color_list.color_list =[]
cluster_color = get_color_list(len(cluster_indices))
# Assign a color corresponding to each segmented object in scene
color_cluster_point_list = []
for j, indices in enumerate(cluster_indices):
for i, indice in enumerate(indices):
color_cluster_point_list.append([extracted_inliers[indice][0],
extracted_inliers[indice][1],
extracted_inliers[indice][2],
rgb_to_float(cluster_color[j])])
#Create new cloud containing all clusters, each with unique color
cluster_cloud = pcl.PointCloud_PointXYZRGB()
cluster_cloud.from_list(color_cluster_point_list)
# Load point cloud file
cloud = pcl.load_XYZRGB('./data/pcl_sub_node.pcd')
# eliminate color from cloud for clustering
while_cloud = XYZRGB_to_XYZ(cloud)
# Create a cluster extraction object
ec = extracted_inliers.make_EuclideanClusterExtraction()
# Set tolerances for distance threshold
# as well as minimum and maximum cluster size (in points)
ec.set_ClusterTolerance(0.07)
ec.set_MinClusterSize(10)
ec.set_MaxClusterSize(25000)
Description | |
---|---|
set_ClusterTolerance | point clout 처리 시 cluster로 판단하는 기준 거리 |
set_MinClusterSize | cluster가 되기 위한 최소 point cloud 개수 |
set_MaxClusterSize | cluster가 되기 위한 최대 point cloud 개수 |
ec.set_SearchMethod(tree)
# Extract indices for each of the discovered clusters
cluster_indices = ec.Extract()
# Create Cluster-Mask Point Cloud to visualize each cluster separately
get_color_list.color_list =[]
cluster_color = get_color_list(len(cluster_indices))
# Assign a color corresponding to each segmented object in scene
color_cluster_point_list = []
for j, indices in enumerate(cluster_indices):
for i, indice in enumerate(indices):
color_cluster_point_list.append([extracted_inliers[indice][0],
extracted_inliers[indice][1],
extracted_inliers[indice][2],
rgb_to_float(cluster_color[j])])
지금까지 배운 내용들을 총동원해서 PCL를 ROS 2와 연동해보겠습니다.
# Terminal 1 - gazebo world launch
ros2 launch lidar_world lidar_world.launch.py
# Terminal 2 - python node run
ros2 run py_pcl_tutorial pcl_cluster_pub
[INFO] [1682602819.908146533] [pcl_cluster_node]: PCL Cluster Node has been started
rviz2를 통해 /cluster topic을 subscribe 하면, 사진과 같이 물체별로 다른 색상이 적용된 새로운 pointcloud topic을 확인할 수 있습니다.
코드를 분석하기 전, 전체 시스템을 요약해보았습니다.
def ros2_to_pcl(ros_cloud):
""" Converts a ROS PointCloud2 message to a pcl PointXYZRGB
Args:
ros_cloud (PointCloud2): ROS PointCloud2 message
Returns:
pcl.PointCloud_PointXYZRGB: PCL XYZRGB point cloud
"""
...
def pcl_to_ros2(pcl_array, now=None):
""" Converts a pcl PointXYZRGB to a ROS PointCloud2 message
Args:
pcl_array (PointCloud_PointXYZRGB): A PCL XYZRGB point cloud
now (Time): A ROS 2 time object like self.get_clock().now()
Returns:
PointCloud2: A ROS point cloud
"""
주의해야 할 점으로, PCL ⇒ ROS 2 변환 시에는 시간에 대한 데이터가 추가되니 Node 내부의 clock에서 now 객체를 전달해야 합니다.
class PCLClusterNode(Node):
def __init__(self):
super().__init__('pcl_cluster_node')
self.pc_subscription = self.create_subscription(
PointCloud2, 'pointcloud',
self.pcl_callback, 10
)
self.cluster_publisher = self.create_publisher(
PointCloud2, 'cluster', 10
)
def pcl_callback(self, pcl_msg):
# TODO: Convert ROS msg to PCL data
pcl_data = ros2_to_pcl(pcl_msg)
...
#Create new cloud containing all clusters, each with unique color
cluster_cloud = pcl.PointCloud_PointXYZRGB()
cluster_cloud.from_list(color_cluster_point_list)
...
# Convert PCL data to ROS 2 messages
cluster_cloud_msg = pcl_to_ros2(cluster_cloud, now=self.get_clock().now())
self.cluster_publisher.publish(cluster_cloud_msg)