이고 카메라 영상에서 라인을 검출한 뒤, 라인의 중심 위치를 계산할 수 있습니다.
기본 흐름은 다음과 같습니다.
- 카메라 영상 수신
- 이미지 정상 방향 보정
- 관심 영역 ROI 설정
- 흑백 변환
- 이진화
- 윤곽선 검출
- 가장 큰 윤곽선 선택
- 중심점 계산
- 결과 이미지 발행
라인 검출 노드를 작성합니다.
1 노드 파일 생성
cd ~/turtlebot3_ws/src/tb3_opencv_tutorial/tb3_opencv_tutorial
touch line_detect_node.py

2. 노드 작성
노드를 작성합니다.
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
class LineDetectNode(Node):
def __init__(self):
super().__init__('line_detect_node')
self.bridge = CvBridge()
self.image_sub = self.create_subscription(
Image,
'/camera/image_raw',
self.image_callback,
10
)
self.image_pub = self.create_publisher(
Image,
'/camera/line_detected',
10
)
self.get_logger().info('Line Detect Node started.')
self.get_logger().info('Subscribe: /camera/image_raw')
self.get_logger().info('Publish : /camera/line_detected')
def image_callback(self, msg):
try:
frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
except Exception as e:
self.get_logger().error(f'cv_bridge error: {e}')
return
frame = cv2.flip(frame, -1)
height, width, _ = frame.shape
roi = frame[int(height * 0.6):height, 0:width]
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
contours, _ = cv2.findContours(
binary,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
if len(contours) > 0:
largest_contour = max(contours, key=cv2.contourArea)
area = cv2.contourArea(largest_contour)
if area > 500:
M = cv2.moments(largest_contour)
if M['m00'] != 0:
cx = int(M['m10'] / M['m00'])
cy = int(M['m01'] / M['m00'])
cv2.drawContours(roi, [largest_contour], -1, (0, 255, 0), 2)
cv2.circle(roi, (cx, cy), 8, (0, 0, 255), -1)
error = cx - int(width / 2)
self.get_logger().info(f'Line center: {cx}, Error: {error}')
frame[int(height * 0.6):height, 0:width] = roi
out_msg = self.bridge.cv2_to_imgmsg(frame, encoding='bgr8')
out_msg.header = msg.header
self.image_pub.publish(out_msg)
def main(args=None):
rclpy.init(args=args)
node = LineDetectNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

3. 소스 설명
1) 클래스 선언부
class LineDetectNode(Node):
LineDetectNode 클래스는 ROS 2 노드입니다.
ROS 2 Python에서는 rclpy.node.Node 클래스를 상속받아 사용자 노드를 만듭니다. 이 예제에서는 카메라 영상을 구독하고, OpenCV로 라인을 검출한 뒤, 결과 이미지를 다시 발행하는 기능을 하나의 노드 안에 구성합니다.
즉, 이 클래스의 역할은 다음과 같습니다.
/camera/image_raw 구독
↓
OpenCV 이미지 변환
↓
이미지 방향 보정
↓
ROI 영역 추출
↓
흑백 변환
↓
이진화
↓
윤곽선 검출
↓
라인 중심 계산
↓
/camera/line_detected 발행
2) 생성자 함수
def __init__(self):
super().__init__('line_detect_node')
__init__() 함수는 노드가 생성될 때 한 번 실행됩니다.
super().__init__('line_detect_node')
이 코드는 ROS 2 노드 이름을 line_detect_node로 설정합니다.
노드를 실행하면 ROS 2 시스템 안에서 이 노드는 line_detect_node라는 이름으로 등록됩니다.
노드 목록은 다음 명령으로 확인할 수 있습니다.
ros2 node list
정상적으로 실행되면 다음과 같은 노드 이름을 볼 수 있습니다.
/line_detect_node
3) CvBridge 객체 생성
self.bridge = CvBridge()
ROS 2의 카메라 영상 메시지는 sensor_msgs/msg/Image 타입입니다. 하지만 OpenCV는 이 메시지를 직접 처리하지 못합니다.
OpenCV는 이미지를 numpy 배열 형태로 다룹니다. 따라서 ROS 2 이미지 메시지와 OpenCV 이미지 사이를 변환해 주는 도구가 필요합니다.
그 역할을 하는 것이 CvBridge입니다.
CvBridge는 다음 두 가지 변환에 사용됩니다.
ROS 2 Image 메시지 → OpenCV 이미지
OpenCV 이미지 → ROS 2 Image 메시지
이 예제에서는 카메라에서 받은 이미지를 OpenCV로 처리해야 하므로 먼저 CvBridge 객체를 생성합니다.
4) 카메라 이미지 토픽 구독
self.image_sub = self.create_subscription(
Image,
'/camera/image_raw',
self.image_callback,
10
)
이 코드는 /camera/image_raw 토픽을 구독하는 부분입니다.
각 인자의 의미는 다음과 같습니다.
Image 구독할 메시지 타입
/camera/image_raw 구독할 토픽 이름
self.image_callback 이미지가 들어올 때 실행할 콜백 함수
10 QoS 큐 크기
즉, 카메라에서 새로운 이미지가 발행될 때마다 self.image_callback() 함수가 자동으로 호출됩니다.
/camera/image_raw 토픽은 TurtleBot3의 카메라 launch 파일을 실행했을 때 발행되는 원본 이미지 토픽입니다.
ros2 launch turtlebot3_bringup camera.launch.py
토픽이 정상적으로 발행되는지는 다음 명령으로 확인할 수 있습니다.
ros2 topic list
또는 다음 명령으로 메시지 타입을 확인할 수 있습니다.
ros2 topic info /camera/image_raw
출력 예시는 다음과 같습니다.
Type: sensor_msgs/msg/Image
Publisher count: 1
Subscription count: 1
5) 라인 검출 결과 이미지 발행자 생성
self.image_pub = self.create_publisher(
Image,
'/camera/line_detected',
10
)
이 코드는 OpenCV로 처리한 결과 이미지를 /camera/line_detected 토픽으로 발행하기 위한 publisher를 생성합니다.
카메라 원본 영상은 /camera/image_raw로 들어오고, 라인 검출 결과는 /camera/line_detected로 나갑니다.
구조는 다음과 같습니다.
입력 토픽: /camera/image_raw
출력 토픽: /camera/line_detected
원격 PC에서 rqt_image_view를 실행한 뒤 /camera/line_detected 토픽을 선택하면 라인 검출 결과를 확인할 수 있습니다.
rqt_image_view
6) 로그 출력
self.get_logger().info('Line Detect Node started.')
self.get_logger().info('Subscribe: /camera/image_raw')
self.get_logger().info('Publish : /camera/line_detected')
이 부분은 노드가 정상적으로 시작되었는지 확인하기 위한 로그입니다.
노드를 실행하면 터미널에 다음과 같은 메시지가 출력됩니다.
Line Detect Node started.
Subscribe: /camera/image_raw
Publish : /camera/line_detected
강의에서는 이런 로그를 넣어두는 것이 좋습니다. 학생들이 노드가 정상적으로 실행되었는지 바로 확인할 수 있기 때문입니다.
7) 이미지 콜백 함수
def image_callback(self, msg):
image_callback() 함수는 /camera/image_raw 토픽으로 이미지가 들어올 때마다 실행됩니다.
즉, 카메라가 초당 30프레임을 발행한다면 이 함수도 초당 약 30번 호출됩니다.
이 함수 안에서 실제 OpenCV 영상 처리가 수행됩니다.
8) ROS 2 이미지 메시지를 OpenCV 이미지로 변환
try:
frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
except Exception as e:
self.get_logger().error(f'cv_bridge error: {e}')
return
카메라에서 들어온 msg는 ROS 2의 sensor_msgs/msg/Image 타입입니다. 이 메시지를 OpenCV에서 사용할 수 있도록 변환해야 합니다.
frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
이 코드는 ROS 2 이미지 메시지를 OpenCV 이미지로 변환합니다.
desired_encoding='bgr8'은 이미지를 OpenCV에서 일반적으로 사용하는 BGR 컬러 형식으로 변환하겠다는 의미입니다.
OpenCV에서는 색상 순서가 일반적인 RGB가 아니라 BGR입니다.
일반 이미지 표현: RGB
OpenCV 기본 표현: BGR
따라서 OpenCV에서 컬러 이미지를 처리할 때는 보통 bgr8 형식을 사용합니다.
try-except 문을 사용하는 이유는 이미지 변환 중 오류가 발생할 수 있기 때문입니다. 예를 들어 토픽의 이미지 인코딩이 맞지 않거나, 메시지 변환에 실패하면 예외가 발생합니다.
오류가 발생하면 로그를 출력하고 함수 실행을 중단합니다.
return
9) 카메라 이미지 방향 보정
frame = cv2.flip(frame, -1)
이 코드는 이미지를 상하좌우 모두 뒤집습니다.
Pi Camera 2가 TurtleBot3 Burger에 거꾸로 장착되어 있으면 영상도 거꾸로 출력됩니다. 이 상태에서 라인 검출을 하면 화면 아래쪽에 있어야 할 바닥 라인이 위쪽에 나타나거나, 좌우 방향이 실제 주행 방향과 맞지 않게 됩니다.
따라서 영상 처리를 하기 전에 먼저 카메라 이미지를 정상 방향으로 보정합니다.
cv2.flip()의 옵션은 다음과 같습니다.
cv2.flip(frame, 0) 상하 반전
cv2.flip(frame, 1) 좌우 반전
cv2.flip(frame, -1) 상하 + 좌우 반전
-1은 상하와 좌우를 모두 반전하므로 결과적으로 180도 회전한 것과 비슷한 효과를 냅니다.
카메라가 완전히 거꾸로 설치된 경우에는 다음 코드가 가장 간단합니다.
frame = cv2.flip(frame, -1)
또는 다음과 같이 작성해도 됩니다.
frame = cv2.rotate(frame, cv2.ROTATE_180)
이 예제에서는 cv2.flip(frame, -1)을 사용합니다.
10) 이미지 크기 정보 가져오기
height, width, _ = frame.shape
OpenCV 이미지의 크기 정보를 가져오는 코드입니다.
frame.shape는 이미지의 높이, 너비, 채널 수를 반환합니다.
컬러 이미지의 경우 일반적으로 다음과 같은 구조입니다.
frame.shape = (height, width, channel)
예를 들어 이미지 크기가 640×480이면 다음과 비슷합니다.
height = 480
width = 640
channel = 3
여기서 _는 채널 값을 사용하지 않겠다는 의미입니다.
height, width, _ = frame.shape
이 예제에서는 ROI 영역 설정과 화면 중심 계산에 height와 width를 사용합니다.
11) ROI 영역 설정
roi = frame[int(height * 0.6):height, 0:width]
ROI는 Region Of Interest의 약자입니다. 즉, 관심 영역입니다.
라인 트레이싱에서는 전체 이미지를 모두 처리할 필요가 없습니다. 바닥의 라인은 보통 카메라 화면의 아래쪽에 나타납니다. 그래서 이 예제에서는 이미지의 아래쪽 40%만 잘라서 처리합니다.
int(height * 0.6):height
이 부분은 세로 방향에서 60% 지점부터 맨 아래까지를 의미합니다.
0:width
이 부분은 가로 방향 전체를 의미합니다.
즉, 전체 이미지 중 아래쪽 영역만 사용합니다.
전체 이미지 높이: height
ROI 시작 위치: height * 0.6
ROI 끝 위치: height
사용 영역: 화면 아래쪽 40%
예를 들어 이미지 높이가 480픽셀이라면,
height * 0.6 = 288
따라서 ROI는 다음 영역이 됩니다.
y = 288 ~ 480
x = 0 ~ 640
이렇게 ROI를 제한하면 다음 장점이 있습니다.
처리 속도가 빨라진다.
불필요한 배경 영향을 줄일 수 있다.
라인 트레이싱에 필요한 바닥 영역만 집중적으로 처리할 수 있다.
화면 위쪽의 물체나 벽, 사람 등의 영향을 줄일 수 있다.
12) 흑백 이미지 변환
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
ROI 영역을 컬러 이미지에서 흑백 이미지로 변환합니다.
라인 검출에서는 색상 정보보다 밝기 정보가 더 중요합니다. 특히 흰색 바닥 위의 검은색 라인을 찾는 경우에는 흑백 이미지로 변환한 뒤 밝기값 기준으로 검출하는 것이 단순하고 안정적입니다.
컬러 이미지는 B, G, R 세 개의 채널을 갖습니다.
BGR 이미지: 3채널
GRAY 이미지: 1채널
흑백 이미지로 바꾸면 각 픽셀은 밝기값 하나만 가집니다.
0 검은색
255 흰색
검은색 라인은 낮은 밝기값을 가지고, 흰색 바닥은 높은 밝기값을 가집니다. 이 차이를 이용해 라인을 분리합니다.
13) 이진화 처리
_, binary = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
이 코드는 흑백 이미지를 이진 이미지로 변환합니다.
이진 이미지는 픽셀 값이 0 또는 255만 가지는 이미지입니다.
0 검은색
255 흰색
cv2.threshold() 함수의 인자는 다음과 같습니다.
gray 입력 흑백 이미지
80 임계값
255 임계값 조건을 만족할 때 적용할 최대값
cv2.THRESH_BINARY_INV 반전 이진화 방식
cv2.THRESH_BINARY_INV는 일반 이진화와 반대로 동작합니다.
픽셀값이 80보다 작으면 255
픽셀값이 80보다 크면 0
검은색 라인은 밝기값이 낮습니다. 따라서 검은 라인은 이진화 후 흰색 영역으로 바뀝니다.
원본 이미지:
검은 라인 = 어두운 값
흰 바닥 = 밝은 값
이진화 후:
검은 라인 = 흰색 영역 255
흰 바닥 = 검은색 영역 0
이렇게 하는 이유는 OpenCV의 윤곽선 검출 함수가 흰색 영역을 객체로 인식하기 때문입니다.
즉, 검은색 라인을 검출하기 위해 일부러 반전 이진화를 사용합니다.
14) 윤곽선 검출
contours, _ = cv2.findContours(
binary,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
이 코드는 이진 이미지에서 윤곽선을 찾습니다.
윤곽선은 흰색 영역의 외곽선입니다. 앞에서 검은 라인을 흰색으로 바꾸었기 때문에, 여기서 검출되는 윤곽선은 라인의 후보 영역입니다.
각 인자의 의미는 다음과 같습니다.
binary 입력 이진 이미지
cv2.RETR_EXTERNAL 가장 바깥쪽 윤곽선만 검출
cv2.CHAIN_APPROX_SIMPLE 윤곽선 좌표를 간단히 압축해서 저장
cv2.RETR_EXTERNAL을 사용하는 이유는 가장 바깥 윤곽선만 필요하기 때문입니다. 라인 검출에서는 내부 윤곽선까지 자세히 찾을 필요가 없습니다.
cv2.CHAIN_APPROX_SIMPLE은 윤곽선을 표현하는 점의 개수를 줄여줍니다. 불필요한 중복 좌표를 줄이기 때문에 메모리와 처리 속도 면에서 유리합니다.
15) 윤곽선 존재 여부 확인
if len(contours) > 0:
검출된 윤곽선이 하나 이상 있는지 확인합니다.
라인이 화면에 없거나, 조명이 너무 어둡거나, 임계값이 맞지 않으면 윤곽선이 검출되지 않을 수 있습니다.
윤곽선이 없을 때 바로 max() 함수를 사용하면 오류가 발생합니다. 따라서 먼저 윤곽선 개수를 확인합니다.
16. 가장 큰 윤곽선 선택
largest_contour = max(contours, key=cv2.contourArea)
검출된 윤곽선들 중에서 면적이 가장 큰 윤곽선을 선택합니다.
라인 검출에서는 화면 아래쪽 ROI 안에서 가장 큰 검은색 영역을 실제 라인이라고 가정합니다.
예를 들어 바닥에 작은 먼지나 그림자가 있을 경우 작은 윤곽선들이 생길 수 있습니다. 이런 작은 후보들은 무시하고, 가장 큰 영역을 라인으로 판단합니다.
작은 점, 노이즈 → 작은 윤곽선
실제 라인 → 큰 윤곽선
따라서 가장 큰 윤곽선을 선택하는 방식은 간단하지만 실습용으로 매우 효과적입니다.
17) 윤곽선 면적 계산
area = cv2.contourArea(largest_contour)
선택된 윤곽선의 면적을 계산합니다.
면적은 픽셀 단위입니다. 면적이 너무 작으면 실제 라인이 아니라 노이즈일 가능성이 높습니다.
18) 작은 노이즈 제거
if area > 500:
윤곽선 면적이 500보다 큰 경우에만 실제 라인으로 인정합니다.
이 조건이 없으면 작은 먼지, 그림자, 카메라 노이즈, 바닥 무늬까지 라인으로 잘못 인식할 수 있습니다.
area <= 500 노이즈로 판단
area > 500 라인 후보로 판단
이 값은 환경에 따라 조정해야 합니다.
예를 들어 카메라 해상도가 낮거나 라인이 얇으면 500이 너무 클 수 있습니다. 반대로 해상도가 높고 라인이 굵으면 500보다 더 큰 값을 사용해도 됩니다.
강의에서는 다음처럼 설명하면 좋습니다.
area > 500은 고정된 정답이 아니라 실습 환경에 맞게 조정하는 튜닝 값입니다.
19) 모멘트 계산
M = cv2.moments(largest_contour)
모멘트는 윤곽선의 중심점, 면적, 분포 등을 계산할 때 사용하는 값입니다.
여기서는 라인의 중심점을 계산하기 위해 사용합니다.
OpenCV에서 윤곽선 중심점은 다음 공식으로 계산합니다.
M[‘m00’] : 윤곽선의 면적
M[‘m10’] : x 방향 1차 모멘트
M[‘m01’] : y 방향 1차 모멘트
cx = M['m10'] / M['m00']
cy = M['m01'] / M['m00']
여기서 M['m00']은 윤곽선의 면적과 관련된 값입니다. 만약 M['m00']이 0이면 나눗셈 오류가 발생하므로 반드시 확인해야 합니다.
20) 0으로 나누는 오류 방지
if M['m00'] != 0:
중심점을 계산할 때 M['m00']으로 나누기 때문에, 이 값이 0인지 먼저 확인합니다.
만약 이 조건이 없으면 다음과 같은 오류가 발생할 수 있습니다.
ZeroDivisionError: float division by zero
실제 로봇 수업에서는 카메라 영상 상태가 계속 변하기 때문에 이런 예외 상황을 방지하는 코드가 중요합니다.
21) 라인 중심점 계산
cx = int(M['m10'] / M['m00'])
cy = int(M['m01'] / M['m00'])
이 코드는 검출된 라인 영역의 중심점을 계산합니다.
cx 라인 중심의 x좌표
cy 라인 중심의 y좌표
주의할 점은 이 좌표가 전체 이미지 기준이 아니라 ROI 내부 기준이라는 것입니다.
왜냐하면 윤곽선 검출을 roi 이미지에서 수행했기 때문입니다.
즉, cx는 ROI 안에서의 가로 중심 위치입니다. 하지만 ROI는 원본 이미지와 같은 가로 폭을 사용하므로, cx를 화면 중심과 비교하는 데는 문제가 없습니다.
cy는 ROI 내부의 y좌표이므로, 원본 이미지 전체 기준 y좌표와는 다릅니다. 원본 이미지 기준 좌표가 필요하다면 다음처럼 ROI 시작 y값을 더해야 합니다.
roi_y_start = int(height * 0.6)
cy_global = cy + roi_y_start
이 예제에서는 화면에 표시할 때 ROI 내부에 직접 원을 그리기 때문에 cy를 그대로 사용합니다.
22) 검출된 라인 윤곽선 그리기
cv2.drawContours(roi, [largest_contour], -1, (0, 255, 0), 2)
이 코드는 검출된 라인의 윤곽선을 초록색으로 그립니다.
각 인자의 의미는 다음과 같습니다.
roi 그림을 그릴 이미지
[largest_contour] 그릴 윤곽선
-1 모든 윤곽선 그리기
(0, 255, 0) 초록색
2 선 두께
OpenCV 색상은 RGB가 아니라 BGR 순서입니다.
(0, 255, 0) = 초록색
이 코드를 통해 rqt_image_view에서 어떤 영역이 라인으로 검출되었는지 눈으로 확인할 수 있습니다.
23) 라인 중심점 표시
cv2.circle(roi, (cx, cy), 8, (0, 0, 255), -1)
이 코드는 검출된 라인의 중심점에 빨간색 원을 그립니다.
각 인자의 의미는 다음과 같습니다.
roi 그림을 그릴 이미지
(cx, cy) 원의 중심 좌표
8 원의 반지름
(0, 0, 255) 빨간색
-1 원 내부를 채움
OpenCV에서 빨간색은 다음과 같이 표현합니다.
(0, 0, 255)
중심점을 표시하면 라인이 화면 기준으로 왼쪽에 있는지, 오른쪽에 있는지 쉽게 확인할 수 있습니다.
24) 화면 중심과 라인 중심의 오차 계산
error = cx - int(width / 2)
이 코드는 라인의 중심과 화면 중심 사이의 차이를 계산합니다.
화면 중심 x좌표 = width / 2
라인 중심 x좌표 = cx
오차 = 라인 중심 - 화면 중심
예를 들어 카메라 영상의 가로 크기가 640픽셀이라면 화면 중심은 320입니다.
width = 640
width / 2 = 320
라인 중심이 왼쪽에 있으면 다음과 같습니다.
cx = 250
error = 250 - 320 = -70
라인 중심이 오른쪽에 있으면 다음과 같습니다.
cx = 390
error = 390 - 320 = 70
라인이 정확히 가운데 있으면 다음과 같습니다.
cx = 320
error = 0
따라서 error 값의 의미는 다음과 같습니다.
error < 0 라인이 화면 왼쪽에 있음
error = 0 라인이 화면 중앙에 있음
error > 0 라인이 화면 오른쪽에 있음
라인 트레이싱에서는 이 값을 이용해 로봇의 회전 방향을 결정합니다.
예를 들면 다음과 같은 제어가 가능합니다.
error < 0 로봇을 왼쪽으로 회전
error > 0 로봇을 오른쪽으로 회전
error = 0 직진
실제 /cmd_vel 제어에서는 보통 다음과 같이 각속도를 계산합니다.
angular_z = -Kp * error
여기서 Kp는 비례 제어 상수입니다.
단, 카메라 설치 방향, 좌표계, 로봇 진행 방향에 따라 부호는 바뀔 수 있습니다. 실제 로봇에서는 반드시 천천히 테스트하면서 부호를 확인해야 합니다.
25) 로그로 중심점과 오차 출력
self.get_logger().info(f'Line center: {cx}, Error: {error}')
이 코드는 검출된 라인의 중심점과 화면 중심 기준 오차를 터미널에 출력합니다.
출력 예시는 다음과 같습니다.
Line center: 285, Error: -35
Line center: 322, Error: 2
Line center: 370, Error: 50
이 로그를 보면 라인이 화면의 어느 쪽에 있는지 확인할 수 있습니다.
다만 카메라 프레임마다 로그가 출력되기 때문에 출력량이 많을 수 있습니다. 실제 주행 코드에서는 매 프레임마다 로그를 출력하면 터미널이 너무 복잡해지고 성능에도 좋지 않을 수 있습니다.
수업 초반에는 이해를 위해 로그를 출력하고, 실제 주행 코드로 확장할 때는 주기를 줄이거나 삭제하는 것이 좋습니다.
26) 처리된 ROI를 원본 이미지에 다시 반영
frame[int(height * 0.6):height, 0:width] = roi
앞에서 roi 영역에 윤곽선과 중심점을 그렸습니다.
하지만 최종적으로 발행할 이미지는 전체 frame입니다. 따라서 수정된 roi를 다시 원본 이미지의 해당 영역에 넣어야 합니다.
이 코드는 원본 이미지의 아래쪽 40% 영역을 처리된 roi 이미지로 교체합니다.
결과적으로 /camera/line_detected 토픽에서는 전체 카메라 영상이 보이고, 아래쪽 ROI 영역에는 검출된 라인 윤곽선과 중심점이 표시됩니다.
27) OpenCV 이미지를 ROS 2 이미지 메시지로 변환
out_msg = self.bridge.cv2_to_imgmsg(frame, encoding='bgr8')
OpenCV로 처리한 이미지를 다시 ROS 2 이미지 메시지로 변환합니다.
입력은 OpenCV 이미지인 frame이고, 출력은 sensor_msgs/msg/Image 타입의 메시지입니다.
encoding='bgr8'은 결과 이미지가 BGR 컬러 이미지라는 의미입니다.
28) 원본 메시지 헤더 복사
out_msg.header = msg.header
원본 카메라 메시지의 헤더 정보를 결과 메시지에 복사합니다.
헤더에는 일반적으로 다음 정보가 들어 있습니다.
timestamp
frame_id
timestamp는 이미지가 촬영된 시간이고, frame_id는 카메라 좌표계 이름입니다.
결과 이미지에 원본 헤더를 복사하면, 나중에 다른 ROS 2 노드와 연동할 때 시간 정보와 좌표계 정보를 유지할 수 있습니다.
특히 RViz, TF, image pipeline, sensor fusion을 사용할 때는 헤더 정보가 중요합니다.
29) 결과 이미지 발행
self.image_pub.publish(out_msg)
이 코드는 처리된 이미지를 /camera/line_detected 토픽으로 발행합니다.
원격 PC에서 다음 명령을 실행하면 결과를 확인할 수 있습니다.
rqt_image_view
토픽 선택 메뉴에서 다음 토픽을 선택합니다.
/camera/line_detected
그러면 원본 영상 위에 라인 윤곽선과 중심점이 표시된 결과 영상을 볼 수 있습니다.
30) main 함수
def main(args=None):
rclpy.init(args=args)
main() 함수는 노드를 실행할 때 시작되는 함수입니다.
rclpy.init(args=args)
이 코드는 ROS 2 Python 클라이언트 라이브러리를 초기화합니다. ROS 2 노드를 만들기 전에 반드시 호출해야 합니다.
node = LineDetectNode()
앞에서 정의한 LineDetectNode 객체를 생성합니다.
이 시점에서 생성자 __init__() 함수가 실행되고, 구독자와 발행자가 만들어집니다.
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
rclpy.spin(node)는 노드가 계속 실행되도록 유지하는 함수입니다.
ROS 2에서는 토픽 메시지가 들어오면 콜백 함수가 실행됩니다. 그런데 프로그램이 바로 종료되면 콜백을 받을 수 없습니다.
따라서 spin()을 사용하여 노드가 계속 살아 있게 만듭니다.
사용자가 Ctrl + C를 누르면 KeyboardInterrupt가 발생합니다. 이 예제에서는 예외를 잡고 자연스럽게 종료되도록 했습니다.
node.destroy_node()
rclpy.shutdown()
노드 실행이 끝나면 사용한 ROS 2 자원을 정리합니다.
node.destroy_node()
이 코드는 노드를 제거합니다.
rclpy.shutdown()
이 코드는 ROS 2 Python 시스템을 종료합니다.
정리 코드를 넣어두면 프로그램 종료 시 리소스를 깔끔하게 반환할 수 있습니다.
4. setup.py에 예제 노드 추가하기
지금까지 만든 노드를 모두 등록하면 setup.py의 entry_points는 다음과 같이 구성할 수 있습니다.
entry_points={
'console_scripts': [
'image_flip_node = tb3_opencv_tutorial.image_flip_node:main',
'corrected_gray_node = tb3_opencv_tutorial.corrected_gray_node:main',
'red_detect_node = tb3_opencv_tutorial.red_detect_node:main',
'line_detect_node = tb3_opencv_tutorial.line_detect_node:main',
],
},

다시 빌드합니다.
cd ~/turtlebot3_ws
colcon build --packages-select tb3_opencv_tutorial
source install/setup.bash

실행 파일 목록을 확인합니다.
ros2 pkg executables tb3_opencv_tutorial
정상이라면 다음과 같은 노드들이 출력됩니다.
tb3_opencv_tutorial image_flip_node
tb3_opencv_tutorial corrected_gray_node
tb3_opencv_tutorial red_detect_node
tb3_opencv_tutorial line_detect_node

먼저 로봇에서 카메라 노드를 실행하고 원격 PC에서 색상 검출 노드를 실행합니다.
ros2 run tb3_opencv_tutorial line_detect_node
원격 PC에서 /camera/line_detected 토픽을 확인합니다.
rqt_image_view

