이번 글에서는 WEGO LIMO에서 카메라 영상을 이용해 실시간으로 객체를 검출하는 ROS 2 프로그램을 분석합니다.
사용되는 프로그램은 yolo_object_detect.py이며, Ultralytics의 YOLO 모델을 ROS 2 노드에서 실행하여 카메라 영상 속 객체를 검출하고 검출된 객체의 종류, 신뢰도, Bounding Box 좌표를 ROS 2 토픽으로 전달합니다.
또한 검출된 객체의 Bounding Box와 클래스 이름을 원본 영상에 표시한 뒤 별도의 영상 토픽으로 Publish하기 때문에 실제 LIMO에서 YOLO가 객체를 어떻게 인식하고 있는지 실시간으로 확인할 수 있습니다.
1. 프로그램 전체 구조
이 프로그램의 전체 동작 과정은 다음과 같습니다.
1. LIMO 카메라 영상 수신
/camera/color/image_raw/compressed
↓
2. CompressedImage를 OpenCV 이미지로 변환
↓
3. 최근 카메라 영상을 임시 저장
↓
4. 0.05초 주기의 Timer 실행
↓
5. Ultralytics YOLO 모델 추론
↓
6. 객체별 클래스, 신뢰도, Bounding Box 추출
↓
7. 신뢰도 0.5 이상 객체만 선택
↓
8. BoundingBoxes 메시지 생성
↓
9. yolo_bounding_boxes 토픽 Publish
↓
10. Bounding Box가 표시된 영상 생성
↓
11. yolo 토픽 Publish
↓
12. yolo/compressed 토픽 Publish
ROS 2의 관점에서 보면 이 노드는 하나의 Subscriber와 세 개의 Publisher로 구성되어 있습니다.
Camera
↓
YoloObjectDetectionNode
├─ yolo_bounding_boxes
├─ yolo
└─ yolo/compressed
딥러닝 모델 자체는 Ultralytics의 YOLO 클래스를 이용해 실행합니다.
2. 필요한 라이브러리
프로그램의 시작 부분은 다음과 같습니다.
import os
import sys
import numpy as np
np.bool_ = bool
import rclpy
import cv2
from rclpy.node import Node
from sensor_msgs.msg import CompressedImage, Image
from std_msgs.msg import Header
from cv_bridge import CvBridge
from ultralytics import YOLO
from dl_ros2_msgs.msg import BoundingBox, BoundingBoxes
각 라이브러리가 어떤 역할을 담당하는지 살펴보겠습니다.
1) NumPy 설정
import numpy as np
np.bool_ = bool
NumPy를 불러온 뒤 np.bool_을 Python의 기본 bool 타입으로 지정하고 있습니다.
이 코드는 해당 실행 환경에서 NumPy 타입과 외부 라이브러리 사이의 호환성을 맞추기 위한 코드로 볼 수 있습니다.
YOLO 객체 검출 자체의 핵심 로직은 아니지만, 프로그램 실행 환경과 관련된 설정입니다.
2) OpenCV
import cv2
OpenCV는 객체 검출 결과를 영상에 표시하는 데 사용합니다.
이 프로그램에서 OpenCV가 담당하는 대표적인 기능은 다음과 같습니다.
Bounding Box 사각형 그리기
Class 이름 출력
Confidence 출력
라벨 배경 생성
객체 검출 자체는 YOLO가 담당하고, 검출 결과를 사람이 볼 수 있도록 표현하는 부분은 OpenCV가 담당한다고 이해하면 됩니다.
3) ROS 2 Image Message
from sensor_msgs.msg import CompressedImage, Image
두 가지 영상 메시지를 사용합니다.
CompressedImage는 압축된 카메라 영상을 받을 때 사용합니다.
sensor_msgs/msg/CompressedImage
Image는 YOLO 검출 결과가 표시된 일반 ROS 2 영상을 Publish할 때 사용합니다.
sensor_msgs/msg/Image
4) CvBridge
from cv_bridge import CvBridge
ROS 2 이미지 메시지와 OpenCV 이미지 사이를 변환하는 역할을 담당합니다.
이번 프로그램에서는 양방향으로 사용됩니다.
CompressedImage
→
OpenCV Image
그리고
OpenCV Image
→
ROS 2 Image
또한 OpenCV 이미지를 다시 CompressedImage 형태로 변환할 때도 사용합니다.
5) Ultralytics YOLO
from ultralytics import YOLO
실제 객체 검출을 담당하는 핵심 라이브러리입니다.
모델 파일을 다음과 같이 불러옵니다.
self.model = YOLO(model_path)
그리고 카메라 이미지를 전달하여 추론합니다.
results = self.model(image, verbose=False)
즉 YOLO 추론과 관련된 복잡한 전처리와 후처리 과정 상당 부분은 Ultralytics 라이브러리 내부에서 처리됩니다.
6) 사용자 정의 Bounding Box Message
from dl_ros2_msgs.msg import BoundingBox, BoundingBoxes
객체 검출 결과를 ROS 2의 다른 Node로 전달하기 위한 사용자 정의 메시지입니다.
하나의 객체 정보는
BoundingBox
메시지로 표현합니다.
여러 객체를 하나의 메시지로 묶을 때는
BoundingBoxes
를 사용합니다.
구조적으로 보면 다음과 같습니다.
BoundingBoxes
├─ BoundingBox 1
├─ BoundingBox 2
├─ BoundingBox 3
└─ ...
3. LD_PRELOAD 환경 변수
다음 코드가 설정되어 있습니다.
os.environ['LD_PRELOAD'] = '/usr/lib/aarch64-linux-gnu/libgomp.so.1'
aarch64-linux-gnu 경로가 사용되고 있으므로 ARM64 Linux 환경을 고려한 코드라는 것을 확인할 수 있습니다.
libgomp.so.1은 GNU OpenMP Runtime Library입니다.
이 부분은 YOLO 객체 검출 알고리즘 자체와 관련된 코드라기보다는 프로그램이 실행되는 시스템 환경과 관련된 설정입니다.
4. YoloObjectDetectionNode 클래스
ROS 2 객체 검출 프로그램의 핵심 클래스는 다음과 같습니다.
class YoloObjectDetectionNode(Node):
Node 클래스를 상속하고 있기 때문에 하나의 ROS 2 Node가 됩니다.
생성자는 다음과 같이 시작합니다.
def __init__(self):
super().__init__('yolo_object_detection_node')
따라서 ROS 2 Node 이름은
yolo_object_detection_node
입니다.
5. CvBridge 객체 생성
self.bridge = CvBridge()
카메라 영상 변환을 위해 CvBridge 객체를 생성합니다.
이 객체는 이후 다음 세 곳에서 사용됩니다.
CompressedImage → OpenCV
OpenCV → Image
OpenCV → CompressedImage
6. YOLO 모델 경로 Parameter 선언
YOLO 모델 파일의 경로를 코드 안에 직접 작성하지 않고 ROS 2 Parameter로 전달받도록 구성되어 있습니다.
self.declare_parameter(
'model_weight_path',
''
)
Parameter 이름은
model_weight_path
입니다.
기본값은 빈 문자열입니다.
7. Parameter 값 가져오기
선언한 Parameter의 실제 값을 가져옵니다.
model_path = self.get_parameter(
'model_weight_path'
).get_parameter_value().string_value
결과적으로 model_path 변수에는 YOLO 모델 파일의 경로가 저장됩니다.
이 방식을 사용하면 Launch 파일에서 모델을 선택할 수 있습니다.
개념적으로 다음과 같은 구조입니다.
Launch File
↓
model_weight_path
↓
yolo_object_detection_node
↓
YOLO Model
따라서 Python 소스를 수정하지 않고도 다른 YOLO 모델 파일을 사용할 수 있습니다.
8. YOLO 모델 파일 검사
모델 파일을 불러오기 전에 경로가 정상적인지 검사합니다.
if not model_path or not os.path.exists(model_path):
다음 두 가지 경우에 오류가 발생합니다.
model_path가 비어 있는 경우
지정한 위치에 모델 파일이 없는 경우
문제가 발생하면 ROS 2 Logger를 통해 오류를 출력합니다.
self.get_logger().error(
f"Model path not provided or file not found: {model_path}"
)
그리고 프로그램을 종료합니다.
sys.exit(1)
객체 검출 Node에서는 모델이 반드시 필요하기 때문에 적절한 처리입니다.
9. YOLO 모델 로드
모델 파일이 정상적으로 존재하면 실제 YOLO 모델을 생성합니다.
try:
self.model = YOLO(model_path)
Ultralytics의 YOLO 클래스에 모델 경로를 전달합니다.
정상적으로 모델을 읽으면 다음 로그가 출력됩니다.
self.get_logger().info(
f"YOLO model loaded from {model_path}"
)
10. 모델 로딩 실패 처리
YOLO 모델을 읽는 과정에서 문제가 발생할 가능성이 있기 때문에 예외 처리를 하고 있습니다.
except Exception as e:
오류가 발생하면 다음 로그를 출력합니다.
self.get_logger().error(
f"Failed to load YOLO model: {str(e)}"
)
그리고 Node 실행을 종료합니다.
sys.exit(1)
따라서 잘못된 모델 파일이나 지원하지 않는 형식의 파일을 전달했을 경우 이후 영상 처리를 진행하지 않습니다.
11. 최근 카메라 영상을 저장하는 tmp_image
self.tmp_image = None
이번 프로그램을 이해할 때 상당히 중요한 변수입니다.
카메라 Subscriber Callback에서 YOLO 추론을 바로 실행하지 않습니다.
대신 수신한 영상을
self.tmp_image
에 저장합니다.
그리고 Timer Callback에서 해당 영상을 꺼내 YOLO 추론을 실행합니다.
즉 구조는 다음과 같습니다.
Camera Subscriber
↓
self.tmp_image
↓
Timer Callback
↓
YOLO
카메라 수신과 YOLO 추론을 하나의 Callback 안에서 직접 처리하지 않은 것이 이 프로그램의 특징입니다.
12. Bounding Box Publisher 생성
self.box_publisher = self.create_publisher(
BoundingBoxes,
'yolo_bounding_boxes',
10
)
객체 검출 정보를 다음 토픽으로 Publish합니다.
yolo_bounding_boxes
Message Type은
dl_ros2_msgs/msg/BoundingBoxes
입니다.
이 토픽에는 영상 자체가 아니라 객체 검출 결과가 숫자 데이터로 전달됩니다.
예를 들어 하나의 객체에 대해 다음 정보가 전달됩니다.
class_name
probability
xmin
ymin
xmax
ymax
13. 일반 YOLO 영상 Publisher
self.yolo_publisher = self.create_publisher(
Image,
'yolo',
10
)
YOLO 객체 검출 결과를 영상 위에 표시한 뒤 ROS 2 Image 메시지로 Publish합니다.
토픽 이름은
yolo
입니다.
14. 압축 YOLO 영상 Publisher
self.yolo_compressed_publisher = self.create_publisher(
CompressedImage,
'yolo/compressed',
10
)
동일한 검출 영상을 압축된 형태로도 Publish합니다.
토픽 이름은
yolo/compressed
입니다.
따라서 검출 결과 영상은 두 가지 형태로 제공됩니다.
yolo
→ sensor_msgs/Image
yolo/compressed
→ sensor_msgs/CompressedImage
15. 카메라 Subscriber
카메라 입력은 다음 코드에서 생성합니다.
self.image_sub = self.create_subscription(
CompressedImage,
'/camera/color/image_raw/compressed',
self.image_callback,
10
)
입력 토픽은
/camera/color/image_raw/compressed
입니다.
Message Type은
sensor_msgs/msg/CompressedImage
입니다.
카메라 영상이 들어올 때마다 다음 함수가 실행됩니다.
self.image_callback
16. Timer 생성
YOLO 추론은 Timer를 통해 실행됩니다.
self.timer_period = 0.05
Timer 주기는
0.05초
입니다.
이를 주파수로 계산하면
1 / 0.05
= 20 Hz
입니다.
그리고 Timer를 생성합니다.
self.timer = self.create_timer(
self.timer_period,
self.timer_callback
)
따라서 이론적인 Timer 호출 주기는 최대 초당 20회입니다.
다만 실제 YOLO 추론 시간이 0.05초보다 길다면 실제 객체 검출 처리 속도는 그보다 낮아질 수 있습니다.
17. image_callback 함수
카메라 메시지를 받을 때 실행되는 Callback입니다.
def image_callback(self, msg):
이 함수에서는 YOLO 추론을 하지 않습니다.
영상 변환과 저장만 수행합니다.
18. CompressedImage를 OpenCV 이미지로 변환
self.tmp_image = self.bridge.compressed_imgmsg_to_cv2(
msg,
'bgr8'
)
ROS 2의 CompressedImage 메시지를 OpenCV에서 사용할 수 있는 BGR 영상으로 변환합니다.
변환 결과는
self.tmp_image
에 저장됩니다.
즉 카메라가 새로운 영상을 보내면 tmp_image가 새로운 영상으로 갱신됩니다.
19. 카메라 영상 변환 실패 처리
영상 변환에서 오류가 발생하면 다음 코드가 실행됩니다.
except Exception as e:
ROS 2 Logger를 이용해 오류 내용을 출력합니다.
self.get_logger().error(
f"Failed to decode compressed image: {str(e)}"
)
20. Timer Callback
실제 YOLO 객체 검출은 다음 함수에서 이루어집니다.
def timer_callback(self):
프로그램의 핵심 처리 함수입니다.
전체 처리 흐름은 다음과 같습니다.
tmp_image 확인
↓
영상 복사
↓
YOLO 추론
↓
Bounding Box 추출
↓
Confidence 필터링
↓
BoundingBoxes 생성
↓
검출 영상 생성
↓
ROS 2 Publish
21. 처리할 영상이 있는지 확인
Timer가 실행되었다고 항상 카메라 영상이 준비되어 있는 것은 아닙니다.
따라서 먼저 다음 조건을 확인합니다.
if self.tmp_image is None:
return
카메라 영상이 아직 없거나 이전 영상의 처리가 끝난 상태라면 아무 작업도 수행하지 않고 Callback을 종료합니다.
22. 카메라 영상 복사
image = self.tmp_image.copy()
저장된 영상 자체를 직접 사용하는 대신 복사본을 만듭니다.
이후 객체 검출은 image를 기준으로 수행합니다.
23. BoundingBoxes 메시지 생성
b_boxes = BoundingBoxes()
현재 프레임에서 검출된 모든 객체 정보를 저장할 ROS 2 메시지를 생성합니다.
이후 검출된 각 객체마다 BoundingBox 객체를 만들어 b_boxes에 추가합니다.
구조적으로 다음과 같습니다.
b_boxes
├─ BoundingBox
├─ BoundingBox
├─ BoundingBox
└─ ...
24. YOLO 추론 실행
객체 검출의 핵심 코드입니다.
results = self.model(
image,
verbose=False
)
OpenCV 이미지를 YOLO 모델에 직접 전달합니다.
verbose=False로 설정되어 있기 때문에 Ultralytics가 매 추론 과정마다 출력하는 상세 로그를 최소화합니다.
ROS 2처럼 카메라 영상을 반복 처리하는 프로그램에서는 매 프레임마다 YOLO 로그가 출력되면 Terminal이 지나치게 복잡해질 수 있기 때문에 유용한 설정입니다.
25. 첫 번째 YOLO 결과 사용
YOLO 호출 결과는 results 형태로 반환됩니다.
프로그램에서는 첫 번째 결과만 사용합니다.
result = results[0]
현재 코드에서는 한 번에 하나의 이미지를 YOLO에 전달하기 때문에 해당 이미지에 대한 결과를 가져오는 구조입니다.
26. Annotation용 영상 복사
image_annotated = image.copy()
Bounding Box와 객체 이름을 표시하기 위한 영상을 별도로 생성합니다.
즉 영상의 역할을 분리하면 다음과 같습니다.
image
→ YOLO 입력
image_annotated
→ 검출 결과 표시
27. 검출된 Bounding Box 존재 여부 확인
if result.boxes is not None and result.boxes.xywh is not None:
YOLO 결과에 Bounding Box 정보가 있는지 확인합니다.
객체가 검출되어 Box 정보가 존재하는 경우에만 이후 처리를 수행합니다.
28. YOLO Bounding Box 좌표 추출
xywh = result.boxes.xywh.cpu().numpy()
YOLO의 Bounding Box 좌표를 가져옵니다.
xywh 형식은 다음 구조입니다.
x
y
width
height
여기서 일반적으로 x, y는 Bounding Box 중심 위치를 표현하고, w, h는 Bounding Box의 폭과 높이를 표현합니다.
즉 하나의 객체는 다음과 같이 표현됩니다.
w
<---------->
+----------+
| |
| ● | h
| x,y |
| |
+----------+
29. cpu 함수의 의미
코드에서는 다음과 같이 처리합니다.
result.boxes.xywh.cpu().numpy()
YOLO 결과 Tensor가 GPU 등에 존재할 수 있기 때문에
.cpu()
를 사용하여 CPU 영역으로 가져옵니다.
그리고
.numpy()
를 이용해 NumPy 배열로 변환합니다.
이후 Python과 ROS 2 메시지에서 좌표를 쉽게 사용할 수 있게 됩니다.
30. Confidence 값 추출
각 객체의 검출 신뢰도를 가져옵니다.
confs = result.boxes.conf.cpu().numpy()
예를 들어 결과가 다음과 같을 수 있습니다.
0.92
0.81
0.46
0.73
이 값은 이후 객체를 선택하는 기준으로 사용됩니다.
31. Class 번호 추출
classes = result.boxes.cls.cpu().numpy()
YOLO가 검출한 객체의 Class Index를 가져옵니다.
예를 들어 모델에 다음 클래스가 있다고 가정할 수 있습니다.
0 → class A
1 → class B
2 → class C
실제 클래스 이름은 모델에 저장된 names 정보를 이용해 가져옵니다.
32. 검출된 모든 객체 반복
for i in range(len(xywh)):
검출된 Bounding Box의 개수만큼 반복합니다.
객체가 3개 검출되었다면 i는 다음과 같이 진행됩니다.
i = 0
i = 1
i = 2
각 반복에서 하나의 객체 정보를 처리합니다.
33. Confidence Threshold
다음 코드가 중요한 필터 역할을 합니다.
if confs[i] < 0.5:
continue
신뢰도가
0.5 미만
인 검출 결과는 사용하지 않습니다.
예를 들어 다음과 같은 결과가 있다고 하겠습니다.
객체 A : 0.87
객체 B : 0.63
객체 C : 0.42
현재 코드에서는
객체 A → 사용
객체 B → 사용
객체 C → 제외
가 됩니다.
즉 이 프로그램에서 실제 ROS 2 메시지와 영상에 표시되는 객체는 Confidence가 0.5 이상인 객체입니다.
34. 객체 좌표 분리
x, y, w, h = xywh[i]
현재 객체의 Bounding Box 정보를 각각의 변수로 분리합니다.
x = 중심 X 좌표
y = 중심 Y 좌표
w = Bounding Box Width
h = Bounding Box Height
35. Class 이름 가져오기
YOLO가 제공하는 Class 번호를 사람이 읽을 수 있는 이름으로 변환합니다.
class_name = self.model.names[
int(classes[i])
]
예를 들어 현재 모델에서 Class 0의 이름이 특정 객체 이름으로 등록되어 있다면
self.model.names[0]
을 통해 해당 문자열을 가져옵니다.
따라서 후속 ROS 2 Node는 단순한 숫자 Class ID가 아니라 실제 Class 이름을 전달받게 됩니다.
36. BoundingBox 메시지 생성
객체 하나에 대한 메시지를 생성합니다.
bbox = BoundingBox()
그리고 YOLO 검출 결과를 메시지 필드에 저장합니다.
37. Class 이름 저장
bbox.class_name = class_name
검출된 객체의 클래스 이름을 저장합니다.
38. Probability 저장
bbox.probability = float(
confs[i]
)
YOLO Confidence 값을 저장합니다.
NumPy 타입을 ROS 2 메시지에서 안정적으로 사용하기 위해 Python float 타입으로 변환하고 있습니다.
39. XYWH를 XYXY로 변환
YOLO에서 가져온 좌표는 xywh 형식입니다.
하지만 사용자 정의 BoundingBox 메시지에는 다음 좌표가 필요합니다.
xmin
ymin
xmax
ymax
따라서 좌표를 변환합니다.
먼저 왼쪽 X 좌표입니다.
bbox.xmin = int(
x - w / 2
)
오른쪽 X 좌표입니다.
bbox.xmax = int(
x + w / 2
)
위쪽 Y 좌표입니다.
bbox.ymin = int(
y - h / 2
)
아래쪽 Y 좌표입니다.
bbox.ymax = int(
y + h / 2
)
40. XYWH와 XYXY의 차이
YOLO의 xywh를 그림으로 표현하면 다음과 같습니다.
Width
<---------------->
+----------------+
| |
| ● |
| x,y |
| |
+----------------+
Height
ROS 2 BoundingBox 메시지는 다음 형태를 사용합니다.
(xmin, ymin)
●-------------------+
| |
| |
| |
+-------------------●
(xmax, ymax)
두 좌표의 관계는 다음과 같습니다.
xmin = x - w / 2
ymin = y - h / 2
xmax = x + w / 2
ymax = y + h / 2
41. 숫자를 정수로 변환하는 이유
Bounding Box 좌표는 다음과 같이 처리됩니다.
int(...)
OpenCV 영상의 Pixel 위치로 사용하기 위해 정수 좌표로 변환하는 것입니다.
예를 들어
xmin = 142.7
이라는 값이 나오더라도 실제 영상에서 사용할 때는
xmin = 142
형태의 정수 Pixel 좌표로 사용합니다.
42. BoundingBoxes에 객체 추가
완성된 하나의 BoundingBox를 전체 객체 목록에 추가합니다.
b_boxes.bounding_boxes.append(
bbox
)
객체가 여러 개 검출되면 반복해서 추가됩니다.
예를 들어 객체가 3개 검출되었다면 다음 구조가 됩니다.
BoundingBoxes
bounding_boxes[0]
├─ class_name
├─ probability
├─ xmin
├─ ymin
├─ xmax
└─ ymax
bounding_boxes[1]
├─ class_name
├─ probability
├─ xmin
├─ ymin
├─ xmax
└─ ymax
bounding_boxes[2]
├─ class_name
├─ probability
├─ xmin
├─ ymin
├─ xmax
└─ ymax
43. 영상용 Label 생성
검출 영상에 표시할 문자열을 생성합니다.
label = f"{class_name}:{confs[i]:.2f}"
예를 들어 클래스 이름이 person이고 Confidence가 0.8734라고 가정하면 화면에는 다음과 같이 표시됩니다.
person:0.87
소수점 둘째 자리까지 표시합니다.
44. Bounding Box 그리기
self.plot_one_box(
[
bbox.xmin,
bbox.ymin,
bbox.xmax,
bbox.ymax
],
image_annotated,
label=label
)
직접 정의한 plot_one_box() 함수를 호출합니다.
전달되는 정보는 다음과 같습니다.
Bounding Box 좌표
표시할 영상
Class 이름과 Confidence
이 함수가 실제 OpenCV 사각형과 텍스트를 화면에 그립니다.
45. 객체가 없는 경우
YOLO 결과에 Bounding Box가 없는 경우에는 다음 코드가 실행됩니다.
else:
self.get_logger().info(
'No objects detected'
)
Terminal에는 다음 메시지가 출력됩니다.
No objects detected
46. BoundingBoxes Header 시간 기록
객체 처리가 끝나면 메시지의 Header에 시간을 기록합니다.
b_boxes.header.stamp = \
self.get_clock().now().to_msg()
ROS 2 Node의 현재 시간을 가져와 Timestamp로 저장합니다.
이 Timestamp는 다른 Node에서 객체 검출 데이터가 언제 생성되었는지 판단할 때 사용할 수 있습니다.
현재 소스에서는 header.stamp는 설정하지만 원본 카메라 메시지의 Header를 그대로 복사하지는 않습니다.
47. BoundingBoxes Publish
검출된 모든 객체 정보를 다음 코드로 Publish합니다.
self.box_publisher.publish(
b_boxes
)
토픽은
yolo_bounding_boxes
입니다.
따라서 다른 ROS 2 Node에서는 이 토픽을 Subscribe하여 객체 정보를 사용할 수 있습니다.
예를 들어 향후 다음과 같은 기능과 연결할 수 있는 데이터 구조입니다.
객체 존재 여부 판단
특정 Class 탐색
Bounding Box 중심 계산
객체 위치 판단
주행 상태 판단
이 프로그램 자체에서는 이러한 제어 판단까지 수행하지 않고 객체 검출 정보를 Publish하는 역할까지만 담당합니다.
48. OpenCV 영상을 ROS 2 Image로 변환
Bounding Box가 표시된 영상은 다음 코드로 ROS 2 Image 메시지로 변환합니다.
out_msg = self.bridge.cv2_to_imgmsg(
image_annotated,
encoding='bgr8'
)
OpenCV 영상은 BGR 형식을 사용하기 때문에
bgr8
Encoding을 지정합니다.
49. YOLO 영상 Publish
변환된 영상은 다음 코드로 Publish합니다.
self.yolo_publisher.publish(
out_msg
)
토픽은
yolo
입니다.
이 토픽을 이용하면 ROS 2에서 YOLO 검출 결과 영상을 확인할 수 있습니다.
50. CompressedImage 생성
동일한 검출 영상을 압축 형태로도 생성합니다.
yolo_compressed_msg = \
self.bridge.cv2_to_compressed_imgmsg(
image_annotated
)
51. 압축 YOLO 영상 Publish
self.yolo_compressed_publisher.publish(
yolo_compressed_msg
)
토픽은
yolo/compressed
입니다.
따라서 네트워크 상황이나 사용 프로그램에 따라 일반 Image와 CompressedImage 중 원하는 방식으로 검출 영상을 사용할 수 있습니다.
52. 객체 검출 처리 오류
YOLO 추론부터 ROS 2 Publish까지 전체 과정은 try 문으로 보호되어 있습니다.
try:
처리 과정에서 오류가 발생하면 다음 코드로 이동합니다.
except Exception as e:
그리고 ROS 2 Logger로 오류를 출력합니다.
self.get_logger().error(
f'Error in processing: {str(e)}'
)
53. finally의 역할
Timer Callback 마지막에는 다음 코드가 있습니다.
finally:
self.tmp_image = None
이 부분은 상당히 중요합니다.
객체 검출이 성공했든 실패했든 현재 처리했던 임시 영상을 제거합니다.
즉 하나의 카메라 영상에 대한 처리가 끝난 후
self.tmp_image = None
상태로 돌아갑니다.
이후 새로운 카메라 메시지가 들어오면 image_callback()에서 다시 tmp_image에 영상이 저장됩니다.
전체 흐름은 다음과 같습니다.
새 카메라 영상
↓
tmp_image 저장
↓
Timer 실행
↓
YOLO 추론
↓
Publish
↓
tmp_image = None
↓
새 영상 대기
54. Subscriber와 Timer를 분리한 구조
이 프로그램의 중요한 특징 중 하나입니다.
일반적으로 다음과 같이 작성할 수도 있습니다.
Camera Callback
↓
YOLO 실행
↓
Publish
하지만 이 프로그램은 다음 구조를 사용합니다.
Camera Callback
↓
tmp_image
↓
Timer Callback
↓
YOLO 실행
따라서 카메라 메시지를 수신하는 부분과 딥러닝 추론을 실행하는 부분이 코드상 분리되어 있습니다.
55. 카메라가 Timer보다 빠른 경우
예를 들어 카메라가 초당 30장의 영상을 전달하고 Timer가 이론적으로 초당 20회 실행된다면 모든 카메라 프레임을 반드시 하나씩 순서대로 처리하는 구조는 아닙니다.
tmp_image에는 가장 최근에 Callback에서 저장된 이미지가 들어가기 때문입니다.
즉 Queue에 모든 이미지를 별도로 쌓아 YOLO에 전달하는 구조가 아니라 하나의 임시 이미지 변수를 사용하는 구조입니다.
실시간 로봇 시스템에서는 오래된 프레임보다 최근 프레임이 중요한 경우가 있기 때문에 이러한 구조를 사용할 수 있습니다.
다만 실제 처리 속도는 YOLO 추론 시간과 ROS 2 Executor 동작 조건에 따라 달라집니다.
56. plot_one_box 함수
객체 검출 결과를 영상에 표시하는 함수입니다.
def plot_one_box(
self,
x,
img,
color=(255, 0, 0),
label=None,
line_thickness=None
):
각 매개변수의 역할은 다음과 같습니다.
x
→ Bounding Box 좌표
img
→ Bounding Box를 그릴 영상
color
→ Bounding Box 색상
label
→ 객체 이름과 Confidence
line_thickness
→ 선 두께
57. Bounding Box 선 두께 계산
tl = line_thickness or round(
0.002 *
(img.shape[0] + img.shape[1]) /
2
) + 1
사용자가 line_thickness를 지정하지 않으면 영상 크기에 따라 자동으로 선 두께를 계산합니다.
영상 크기가 커지면 Bounding Box 선도 어느 정도 두꺼워지고, 영상이 작으면 상대적으로 얇아집니다.
고정된 1 Pixel 선을 사용하는 것보다 다양한 해상도에서 보기 좋은 결과를 만들 수 있습니다.
58. Bounding Box 좌상단과 우하단 좌표
c1, c2 = (
int(x[0]),
int(x[1])
), (
int(x[2]),
int(x[3])
)
Bounding Box 좌표를 두 점으로 분리합니다.
c1 = xmin, ymin
c2 = xmax, ymax
그림으로 보면 다음과 같습니다.
c1
●---------------------+
| |
| |
| |
+---------------------●
c2
59. Bounding Box 사각형 출력
cv2.rectangle(
img,
c1,
c2,
color,
thickness=tl,
lineType=cv2.LINE_AA
)
OpenCV의 rectangle() 함수를 사용하여 객체 주변에 사각형을 그립니다.
cv2.LINE_AA를 사용하여 Anti-Aliasing이 적용된 선을 생성합니다.
기본 색상은 다음과 같습니다.
color=(255, 0, 0)
OpenCV는 BGR 순서를 사용하므로 기본 Bounding Box는 파란색입니다.
60. Label 존재 여부 확인
if label:
Class 이름과 Confidence 문자열이 전달된 경우에만 Label을 화면에 표시합니다.
61. Text 두께 계산
tf = max(
tl - 1,
1
)
Bounding Box 선 두께를 기준으로 Text 두께를 결정합니다.
최소값은 1입니다.
62. Text 크기 계산
t_size = cv2.getTextSize(
label,
0,
fontScale=tl / 3,
thickness=tf
)[0]
화면에 출력할 Label 문자열이 차지하는 크기를 계산합니다.
Label 길이에 따라 배경 사각형 크기를 결정하기 위해 필요한 과정입니다.
63. Label 배경 영역 계산
c2l = (
c1[0] + t_size[0],
c1[1] - t_size[1] - 3
)
Bounding Box의 좌측 상단 위치를 기준으로 Label이 들어갈 배경 사각형의 크기를 계산합니다.
64. Label 배경 사각형 생성
cv2.rectangle(
img,
c1,
c2l,
color,
-1,
cv2.LINE_AA
)
여기에서 Thickness에
-1
을 사용합니다.
OpenCV에서 -1은 사각형 내부를 채우라는 의미입니다.
따라서 객체 이름 뒤에 색상이 채워진 배경이 생성됩니다.

65. Class 이름과 Confidence 출력
cv2.putText(
img,
label,
(
c1[0],
c1[1] - 2
),
0,
tl / 3,
[225, 255, 255],
thickness=tf,
lineType=cv2.LINE_AA
)

최종적으로 Label 문자열을 영상에 표시합니다.
예를 들어 다음과 같은 형태입니다.
person:0.91
┌──────────────┐
│ │
│ Object │
│ │
└──────────────┘
실제 클래스 이름은 로드한 YOLO 모델에 저장되어 있는 클래스 정의에 따라 달라집니다.
66. main 함수
ROS 2 Python 프로그램은 다음 함수에서 시작됩니다.
def main(args=None):
먼저 ROS 2를 초기화합니다.
rclpy.init(args=args)
67. YOLO Node 생성
node = YoloObjectDetectionNode()
이 순간 생성자에서 다음 작업들이 모두 실행됩니다.
CvBridge 생성
Parameter 읽기
YOLO 모델 로드
Publisher 생성
Subscriber 생성
Timer 생성
모델 파일을 불러오지 못하면 이 단계에서 프로그램이 종료됩니다.
68. ROS 2 Spin
rclpy.spin(node)
Node가 계속 실행되도록 합니다.
Spin 상태에서는 ROS 2가 Subscriber Callback과 Timer Callback을 실행합니다.
전체적으로 다음 작업이 반복됩니다.
카메라 수신
↓
image_callback
Timer 발생
↓
timer_callback
YOLO 추론
↓
Publish
69. Ctrl+C 처리
사용자가 Terminal에서 Ctrl+C로 프로그램을 종료하면 KeyboardInterrupt가 발생합니다.
except KeyboardInterrupt:
pass
별도의 오류 메시지 없이 정상적인 종료 과정으로 넘어가도록 만들어져 있습니다.
70. 일반 예외 처리
다른 오류가 발생하면 다음 코드가 실행됩니다.
except Exception as e:
print(
f"Error running node: {e}"
)
71. ROS 2 Node 종료
마지막으로 Node를 제거합니다.
node.destroy_node()
그리고 ROS 2를 종료합니다.
rclpy.shutdown()
72. 프로그램의 핵심 ROS 2 Topic
이 프로그램을 실제 LIMO에서 실행할 때 가장 먼저 이해해야 하는 것은 Topic 관계입니다.
입력 영상은 다음과 같습니다.
/camera/color/image_raw/compressed
객체 검출 데이터는 다음 토픽으로 나갑니다.
yolo_bounding_boxes
일반 YOLO 결과 영상은
yolo
입니다.
압축 YOLO 결과 영상은
yolo/compressed
입니다.
따라서 전체 연결 구조는 다음과 같습니다.
/camera/color/image_raw/compressed
↓
yolo_object_detection_node
↓ ↓ ↓
yolo_bounding_boxes yolo yolo/compressed
73. BoundingBoxes 데이터의 중요성
실제 자율주행 프로그램에서 중요한 데이터는 단순히 YOLO 검출 화면만이 아닙니다.
다른 ROS 2 Node가 사용할 수 있는 숫자 형태의 객체 정보가 필요합니다.
이 프로그램은 이를
yolo_bounding_boxes
토픽으로 제공합니다.
객체마다 다음 정보를 사용할 수 있습니다.
class_name
probability
xmin
ymin
xmax
ymax
예를 들어 후속 Node에서는 Bounding Box 중심을 다음과 같이 계산할 수 있습니다.
center_x
= (xmin + xmax) / 2
center_y
= (ymin + ymax) / 2
이 계산은 현재 소스에는 포함되어 있지 않지만, 이 프로그램이 Publish하는 좌표를 이용하여 후속 제어 노드에서 계산할 수 있습니다.
74. 영상 결과와 숫자 결과를 동시에 Publish하는 이유
이 프로그램은 YOLO 결과를 두 종류로 전달합니다.
첫 번째는 기계가 사용할 데이터입니다.
BoundingBoxes
두 번째는 사람이 확인할 영상입니다.
Image
CompressedImage
즉 역할이 분리되어 있습니다.
YOLO
├─ BoundingBoxes
│ ↓
│ 다른 ROS 2 Node에서 사용
│
└─ Annotated Image
↓
개발자 확인
자율주행 시스템에서는 이러한 구조가 상당히 중요합니다.
제어 Node가 OpenCV 영상을 다시 분석할 필요 없이 이미 계산된 객체 좌표를 바로 사용할 수 있기 때문입니다.
75. Confidence 0.5의 의미
현재 프로그램에는 Confidence Threshold가 코드에 직접 설정되어 있습니다.
if confs[i] < 0.5:
continue
따라서 Threshold는
0.5
입니다.
이 값보다 낮은 객체는 ROS 2 BoundingBoxes에도 들어가지 않고 영상에도 표시되지 않습니다.
즉 실제 시스템에서 Threshold를 조정하려면 현재 코드에서는 이 숫자를 변경해야 합니다.
ROS 2 Parameter로 만들어 놓은 값은 아닙니다.
76. 객체 검출 프로그램의 역할
이 프로그램은 객체를 검출하지만 LIMO 차량을 직접 움직이지는 않습니다.
즉 역할은 다음 단계까지입니다.
카메라 영상
↓
객체 검출
↓
객체 정보 생성
↓
ROS 2 Publish
다음 단계인
객체 판단
↓
차량 행동 결정
↓
속도 또는 조향 제어
는 별도의 ROS 2 Node가 담당할 수 있도록 만들어진 구조입니다.