1. 실습 개요
이번 실습에서는 TurtleBot3에 연결된 Raspberry Pi Camera 영상을 ROS 2 토픽으로 발행하고, 원격 Ubuntu PC에서 PyQt5 GUI를 사용해 영상을 표시합니다.
카메라 영상은 압축되지 않은 raw 영상이 아니라 다음 압축 이미지 토픽을 사용합니다.
/camera/image_raw/compressed
GUI에는 다음 4개 버튼을 배치합니다.
- Start Camera 버튼을 누르면 카메라 영상을 GUI 화면에 표시합니다.
- Stop Camera 버튼을 누르면 영상 표시를 중지합니다.
- Start Mov 버튼을 누르면 수신 중인 영상을 동영상 파일로 저장합니다.
- Stop Mov 버튼을 누르면 동영상 저장을 종료합니다.
카메라 영상을 발행하기 위한 별도의 Python 카메라 노드는 작성하지 않습니다.
TurtleBot3에서 제공하는 다음 launch 파일을 사용합니다.
ros2 launch turtlebot3_bringup camera.launch.py format:=BGR888 width:=320 height:=240
GUI 프로그램은 카메라 토픽 구독, 화면 표시, 동영상 저장 기능만 담당합니다.
2. 전체 시스템 구성
전체 시스템은 다음과 같이 구성합니다.
Raspberry Pi Camera
↓
TurtleBot3 Raspberry Pi
↓
camera.launch.py 실행
↓
/camera/image_raw/compressed 발행
↓
Wi-Fi 네트워크
↓
원격 Ubuntu PC
↓
ROS 2 PyQt5 GUI
↓
영상 표시 및 AVI 파일 저장
로봇과 GUI 프로그램의 역할을 분리하면 프로그램 구조가 단순해집니다.
TurtleBot3에서는 카메라 영상을 계속 발행합니다.
원격 PC에서는 필요한 시점에 영상을 화면에 표시하거나 동영상으로 저장합니다.
3. 사용할 ROS 2 카메라 토픽
이번 예제에서 사용할 토픽은 다음과 같습니다.
/camera/image_raw/compressed
메시지 형식은 다음과 같습니다.
sensor_msgs/msg/CompressedImage
camera_ros는 다음과 같은 토픽을 제공합니다.
/camera/image_raw
/camera/image_raw/compressed
/camera/camera_info
각 토픽의 의미는 다음과 같습니다.
/camera/image_raw
메시지 형식:
sensor_msgs/msg/Image
설명:
압축되지 않은 원본 영상
/camera/image_raw/compressed
메시지 형식:
sensor_msgs/msg/CompressedImage
설명:
JPEG 또는 PNG 등으로 압축된 영상
/camera/camera_info
메시지 형식:
sensor_msgs/msg/CameraInfo
설명:
카메라 보정 정보와 내부 파라미터
CompressedImage 메시지에는 압축 형식을 나타내는 format 필드와 실제 압축 이미지 데이터가 들어 있는 data 필드가 포함됩니다.
무선 네트워크로 카메라 영상을 전송할 때는 일반적으로 raw 영상보다 compressed 영상이 네트워크 대역폭을 적게 사용합니다.
4. Start Camera 버튼의 정확한 의미
이번 프로그램에서 Start Camera 버튼은 로봇의 카메라 하드웨어를 직접 켜는 버튼이 아닙니다.
로봇에서는 이미 다음 명령으로 카메라 토픽을 발행하고 있어야 합니다.
ros2 launch turtlebot3_bringup camera.launch.py format:=BGR888 width:=320 height:=240
GUI의 Start Camera 버튼은 수신 중인 카메라 프레임을 QLabel에 표시하기 시작합니다.
Stop Camera 버튼은 QLabel의 화면 표시만 중지합니다.
따라서 Stop Camera 버튼을 눌러도 다음 동작은 유지됩니다.
- TurtleBot3 카메라 노드는 계속 실행됩니다.
/camera/image_raw/compressed토픽은 계속 발행됩니다.- GUI의 ROS 2 구독자는 계속 메시지를 받습니다.
- 동영상 녹화가 진행 중이면 녹화는 계속됩니다.
로봇의 카메라 노드 자체를 종료하려면 camera.launch.py를 실행한 로봇 터미널에서 Ctrl+C를 눌러야 합니다.
5. 개발 환경
예제는 다음 환경을 기준으로 작성합니다.
로봇 환경
TurtleBot3
Raspberry Pi
Raspberry Pi Camera
ROS 2 Humble 이상
camera_ros
원격 PC 환경
Ubuntu
ROS 2
Python 3
PyQt5
OpenCV
NumPy
ROS 2 배포판은 로봇과 원격 PC에서 가능하면 동일하게 맞추는 것이 좋습니다.
6. 로봇과 원격 PC의 네트워크 설정
로봇과 PC는 같은 공유기 또는 같은 네트워크에 연결합니다.
로봇과 PC에서 동일한 ROS_DOMAIN_ID를 설정합니다.
이번 예제에서는 200을 사용합니다.
export ROS_DOMAIN_ID=200
export ROS_LOCALHOST_ONLY=0
매번 입력하지 않으려면 로봇과 원격 PC의 ~/.bashrc에 추가합니다.
echo 'export ROS_DOMAIN_ID=200' >> ~/.bashrc
echo 'export ROS_LOCALHOST_ONLY=0' >> ~/.bashrc
source ~/.bashrc
설정값을 확인합니다.
echo $ROS_DOMAIN_ID
echo $ROS_LOCALHOST_ONLY
로봇과 PC의 ROS_DOMAIN_ID가 다르면 서로의 ROS 2 토픽을 찾지 못합니다.
7. TurtleBot3에 카메라 패키지 설치
TurtleBot3의 Raspberry Pi 터미널에서 실행합니다. 필요한 패키지가 이미 설치되어 있으면 아래 과정을 생략합니다.
source /opt/ros/$ROS_DISTRO/setup.bash
sudo apt update
sudo apt install ros-$ROS_DISTRO-camera-ros
sudo apt install ros-$ROS_DISTRO-image-transport-plugins
camera_ros 공식 문서에서도 ROS 패키지 저장소를 통한 다음 설치 방법을 제공합니다.
sudo apt install ros-$ROS_DISTRO-camera-ros
패키지 설치 여부를 확인합니다.
ros2 pkg executables camera_ros
정상적으로 설치되면 다음과 비슷한 결과가 나타납니다.
camera_ros camera_node
카메라가 libcamera에서 인식되는지 확인합니다.
cam -l
Raspberry Pi OS 환경에서는 다음 명령을 사용할 수도 있습니다.
rpicam-hello --list-cameras
구형 Raspberry Pi 환경에서는 다음 명령이 사용될 수 있습니다.
libcamera-hello --list-cameras
8. TurtleBot3에서 카메라 토픽 발행
TurtleBot3 Raspberry Pi에서 다음 명령을 실행합니다.
source /opt/ros/$ROS_DISTRO/setup.bash
source ~/turtlebot3_ws/install/setup.bash
export ROS_DOMAIN_ID=200
export ROS_LOCALHOST_ONLY=0
ros2 launch turtlebot3_bringup camera.launch.py format:=BGR888 width:=320 height:=240
카메라 번호를 지정하려면 다음과 같이 실행합니다.
ros2 launch turtlebot3_bringup camera.launch.py camera:=0 \
format:=BGR888 width:=320 height:=240
9. 카메라 토픽 확인
다른 터미널에서 ROS 2 환경을 다시 적용합니다.
source /opt/ros/$ROS_DISTRO/setup.bash
source ~/turtlebot3_ws/install/setup.bash
export ROS_DOMAIN_ID=200
카메라 관련 토픽을 확인합니다.
ros2 topic list | grep camera
정상적인 경우 다음과 비슷한 토픽이 출력됩니다.
/camera/camera_info
/camera/image_raw
/camera/image_raw/compressed
압축 토픽의 메시지 형식을 확인합니다.
ros2 topic info /camera/image_raw/compressed
다음 메시지 형식이 표시되어야 합니다.
Type: sensor_msgs/msg/CompressedImage
발행 주기를 확인합니다.
ros2 topic hz /camera/image_raw/compressed
발행자와 QoS 정보를 자세히 확인하려면 다음 명령을 사용합니다.
ros2 topic info \
/camera/image_raw/compressed \
--verbose
10. 원격 PC에 필요한 패키지 설치
원격 Ubuntu PC에서 다음 명령을 실행합니다. 관련 패키지를 설치하였으면 아래의 과정을 생략하세요.
sudo apt update
sudo apt install python3-pyqt5
sudo apt install python3-opencv
sudo apt install python3-numpy
sudo apt install qttools5-dev-tools
ROS 2 관련 패키지도 확인합니다.
sudo apt install ros-$ROS_DISTRO-rclpy
sudo apt install ros-$ROS_DISTRO-sensor-msgs
qttools5-dev-tools는 Qt Designer를 사용할 때 필요합니다.
제공된 .ui 파일을 수정하지 않고 그대로 사용한다면 Designer를 반드시 실행할 필요는 없습니다.
11. ROS 2 패키지 생성
Python 기반 ROS 2 패키지를 생성합니다.
cd ~/pyqt_ws/src
ros2 pkg create tb3_camera_gui \
--build-type ament_python \
--dependencies rclpy sensor_msgs ament_index_python

UI 파일을 저장할 폴더를 생성합니다.
mkdir -p ~/pyqt_ws/src/tb3_camera_gui/tb3_camera_gui/ui

최종 패키지 구조는 다음과 같습니다.
tb3_camera_gui
├── package.xml
├── resource
│ └── tb3_camera_gui
├── setup.cfg
├── setup.py
└── tb3_camera_gui
├── __init__.py
├── camera_gui.py
└── ui
└── camera_gui.ui
12. Qt Designer로 UI 구성
Qt Designer를 실행합니다.
designer
실행 후 다음 순서로 화면을 만듭니다.
- Main Window를 선택합니다.
- 중앙 위젯에 Vertical Layout을 배치합니다.
- 제목용 QLabel을 추가합니다.
- 카메라 화면용 QLabel을 추가합니다.
- Horizontal Layout을 추가합니다.
- QPushButton 4개를 추가합니다.
- 상태 표시용 QLabel을 추가합니다.
중요한 객체 이름은 다음과 같이 설정합니다.
카메라 화면 QLabel
labelImage
상태 표시 QLabel
labelStatus
Start Camera 버튼
btnStartCamera
Stop Camera 버튼
btnStopCamera
Start Mov 버튼
btnStartMov
Stop Mov 버튼
btnStopMov
UI 파일은 다음 경로에 저장합니다.
~/ros2_ws/src/tb3_camera_gui/tb3_camera_gui/ui/camera_gui.ui
13. camera_gui.ui 전체 소스
다음 내용을 camera_gui.ui 파일에 저장합니다.
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>CameraMainWindow</class>
<widget class="QMainWindow" name="CameraMainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>900</width>
<height>650</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>700</width>
<height>520</height>
</size>
</property>
<property name="windowTitle">
<string>TurtleBot3 Camera GUI</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="labelTitle">
<property name="font">
<font>
<pointsize>16</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>TurtleBot3 Compressed Camera Viewer</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelImage">
<property name="minimumSize">
<size>
<width>640</width>
<height>420</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QLabel {
background-color: rgb(30, 30, 30);
color: rgb(230, 230, 230);
border: 1px solid rgb(90, 90, 90);
}</string>
</property>
<property name="text">
<string>Camera Image</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="buttonLayout">
<item>
<widget class="QPushButton" name="btnStartCamera">
<property name="minimumSize">
<size>
<width>140</width>
<height>42</height>
</size>
</property>
<property name="text">
<string>Start Camera</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnStopCamera">
<property name="minimumSize">
<size>
<width>140</width>
<height>42</height>
</size>
</property>
<property name="text">
<string>Stop Camera</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnStartMov">
<property name="minimumSize">
<size>
<width>140</width>
<height>42</height>
</size>
</property>
<property name="text">
<string>Start Mov</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnStopMov">
<property name="minimumSize">
<size>
<width>140</width>
<height>42</height>
</size>
</property>
<property name="text">
<string>Stop Mov</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="labelStatus">
<property name="minimumSize">
<size>
<width>0</width>
<height>34</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QLabel {
background-color: rgb(240, 240, 240);
padding-left: 8px;
border: 1px solid rgb(190, 190, 190);
}</string>
</property>
<property name="text">
<string>대기 중</string>
</property>
<property name="alignment">
<set>Qt::AlignVCenter|Qt::AlignLeft</set>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>


14. camera_gui.py 전체 소스
다음 내용을 camera_gui.py에 저장합니다.
#!/usr/bin/env python3
import os
import sys
from datetime import datetime
from pathlib import Path
# Ubuntu GNOME Wayland 환경에서도 XWayland의 xcb 백엔드를 사용합니다.
# 사용자가 이미 QT_QPA_PLATFORM을 지정했다면 기존 값을 유지합니다.
os.environ.setdefault(
'QT_QPA_PLATFORM',
'xcb'
)
# PyQt5를 OpenCV보다 먼저 import합니다.
# 서로 다른 Qt 라이브러리가 로딩되는 문제를 줄이기 위한 순서입니다.
from PyQt5 import uic
from PyQt5.QtCore import QLibraryInfo, QTimer, Qt
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import QApplication, QMainWindow
# OpenCV를 import하면 opencv-python 패키지가
# QT_QPA_PLATFORM_PLUGIN_PATH를 cv2/qt/plugins로 변경할 수 있습니다.
import cv2
import numpy as np
import rclpy
from ament_index_python.packages import get_package_share_directory
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CompressedImage
def configure_qt_environment():
"""
OpenCV가 등록한 Qt 플러그인 경로를 제거하고
PyQt5가 사용하는 정상적인 Qt 플러그인 경로로 변경합니다.
"""
qt_environment_names = (
'QT_QPA_PLATFORM_PLUGIN_PATH',
'QT_QPA_FONTDIR',
'QT_PLUGIN_PATH',
)
for environment_name in qt_environment_names:
environment_value = os.environ.get(
environment_name,
''
)
normalized_value = (
environment_value
.replace('\\', '/')
.lower()
)
# OpenCV 패키지 내부의 Qt 경로만 제거합니다.
if (
'/cv2/' in normalized_value
or normalized_value.endswith('/cv2')
):
os.environ.pop(
environment_name,
None
)
# PyQt5가 실제로 사용하는 Qt 플러그인 디렉터리를 가져옵니다.
qt_plugin_path = QLibraryInfo.location(
QLibraryInfo.PluginsPath
)
if qt_plugin_path:
os.environ[
'QT_QPA_PLATFORM_PLUGIN_PATH'
] = qt_plugin_path
# QApplication을 생성하기 전에 반드시 실행해야 합니다.
configure_qt_environment()
class CameraGui(QMainWindow):
def __init__(self):
super().__init__()
ui_path = (
Path(
get_package_share_directory(
'tb3_camera_gui'
)
)
/ 'ui'
/ 'camera_gui.ui'
)
if not ui_path.exists():
raise FileNotFoundError(
f'UI 파일을 찾을 수 없습니다: {ui_path}'
)
uic.loadUi(
str(ui_path),
self
)
self.node = rclpy.create_node(
'tb3_camera_gui_node'
)
self.node.declare_parameter(
'image_topic',
'/camera/image_raw/compressed'
)
self.node.declare_parameter(
'video_fps',
30.0
)
self.image_topic = (
self.node
.get_parameter('image_topic')
.get_parameter_value()
.string_value
)
self.video_fps = (
self.node
.get_parameter('video_fps')
.get_parameter_value()
.double_value
)
if self.video_fps <= 0.0:
self.node.get_logger().warning(
'video_fps가 0 이하이므로 30.0 FPS를 사용합니다.'
)
self.video_fps = 30.0
self.camera_display_enabled = False
self.recording_enabled = False
self.window_closing = False
self.last_frame = None
self.video_writer = None
self.video_size = None
self.video_path = None
self.subscription = (
self.node.create_subscription(
CompressedImage,
self.image_topic,
self.image_callback,
qos_profile_sensor_data
)
)
self.btnStartCamera.clicked.connect(
self.start_camera
)
self.btnStopCamera.clicked.connect(
self.stop_camera
)
self.btnStartMov.clicked.connect(
self.start_recording
)
self.btnStopMov.clicked.connect(
self.stop_recording
)
self.btnStartCamera.setEnabled(True)
self.btnStopCamera.setEnabled(False)
self.btnStartMov.setEnabled(True)
self.btnStopMov.setEnabled(False)
self.labelImage.setAlignment(
Qt.AlignCenter
)
self.labelImage.setText(
'Start Camera 버튼을 누르세요.'
)
self.labelStatus.setText(
f'대기 중 | 구독 토픽: {self.image_topic}'
)
self.ros_timer = QTimer(self)
self.ros_timer.timeout.connect(
self.spin_ros_once
)
self.ros_timer.start(10)
self.node.get_logger().info(
f'카메라 GUI 시작: {self.image_topic}'
)
def spin_ros_once(self):
if (
self.window_closing
or not rclpy.ok()
):
return
try:
rclpy.spin_once(
self.node,
timeout_sec=0.0
)
except Exception as exception:
self.node.get_logger().error(
f'ROS 2 콜백 처리 오류: {exception}'
)
self.labelStatus.setText(
f'ROS 2 처리 오류 | {exception}'
)
def image_callback(self, msg):
if self.window_closing:
return
try:
encoded_data = np.frombuffer(
msg.data,
dtype=np.uint8
)
if encoded_data.size == 0:
self.labelStatus.setText(
'빈 이미지 데이터가 수신되었습니다.'
)
return
frame = cv2.imdecode(
encoded_data,
cv2.IMREAD_COLOR
)
frame = cv2.flip(frame, -1)
except cv2.error as exception:
self.node.get_logger().error(
f'OpenCV 이미지 디코딩 오류: {exception}'
)
self.labelStatus.setText(
'OpenCV 이미지 디코딩 오류'
)
return
except Exception as exception:
self.node.get_logger().error(
f'이미지 처리 오류: {exception}'
)
self.labelStatus.setText(
f'이미지 처리 오류 | {exception}'
)
return
if frame is None:
self.labelStatus.setText(
'이미지 디코딩 실패'
)
return
self.last_frame = frame
if (
self.recording_enabled
and self.video_writer is not None
):
self.write_video_frame(
frame
)
if self.camera_display_enabled:
self.show_frame(
frame
)
def write_video_frame(self, frame):
if (
self.video_writer is None
or self.video_size is None
):
return
try:
height, width = frame.shape[:2]
if (width, height) == self.video_size:
write_frame = frame
else:
write_frame = cv2.resize(
frame,
self.video_size,
interpolation=cv2.INTER_AREA
)
self.video_writer.write(
write_frame
)
except cv2.error as exception:
self.node.get_logger().error(
f'동영상 프레임 저장 오류: {exception}'
)
self.labelStatus.setText(
'동영상 프레임 저장 오류'
)
self.stop_recording()
def show_frame(self, frame):
if (
frame is None
or frame.size == 0
):
return
try:
rgb_frame = cv2.cvtColor(
frame,
cv2.COLOR_BGR2RGB
)
except cv2.error as exception:
self.node.get_logger().error(
f'색상 변환 오류: {exception}'
)
self.labelStatus.setText(
'영상 색상 변환 오류'
)
return
height, width, channels = rgb_frame.shape
bytes_per_line = (
channels * width
)
q_image = QImage(
rgb_frame.data,
width,
height,
bytes_per_line,
QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(
q_image
)
label_size = self.labelImage.size()
if (
label_size.width() > 0
and label_size.height() > 0
):
pixmap = pixmap.scaled(
label_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.labelImage.setPixmap(
pixmap
)
def start_camera(self):
self.camera_display_enabled = True
self.btnStartCamera.setEnabled(False)
self.btnStopCamera.setEnabled(True)
if self.last_frame is not None:
self.show_frame(
self.last_frame
)
self.labelStatus.setText(
f'카메라 화면 표시 중 | {self.image_topic}'
)
def stop_camera(self):
self.camera_display_enabled = False
self.btnStartCamera.setEnabled(True)
self.btnStopCamera.setEnabled(False)
self.labelImage.clear()
self.labelImage.setAlignment(
Qt.AlignCenter
)
self.labelImage.setText(
'카메라 화면 표시가 중지되었습니다.'
)
if self.recording_enabled:
self.labelStatus.setText(
'화면 표시 중지 | '
'동영상 녹화는 계속 진행 중'
)
else:
self.labelStatus.setText(
'카메라 화면 표시 중지'
)
def start_recording(self):
if self.recording_enabled:
return
if self.last_frame is None:
self.labelStatus.setText(
'저장할 카메라 프레임이 없습니다. '
'카메라 토픽을 확인하세요.'
)
return
height, width = (
self.last_frame.shape[:2]
)
if width <= 0 or height <= 0:
self.labelStatus.setText(
'올바르지 않은 카메라 영상 크기입니다.'
)
return
self.video_size = (
width,
height
)
output_dir = (
Path.home()
/ 'Videos'
)
try:
output_dir.mkdir(
parents=True,
exist_ok=True
)
except OSError as exception:
self.node.get_logger().error(
f'저장 디렉터리 생성 오류: {exception}'
)
self.labelStatus.setText(
f'저장 디렉터리 생성 실패 | {exception}'
)
return
timestamp = datetime.now().strftime(
'%Y%m%d_%H%M%S'
)
self.video_path = (
output_dir
/ f'tb3_camera_{timestamp}.avi'
)
fourcc = cv2.VideoWriter_fourcc(
*'MJPG'
)
try:
self.video_writer = cv2.VideoWriter(
str(self.video_path),
fourcc,
self.video_fps,
self.video_size
)
except cv2.error as exception:
self.node.get_logger().error(
f'VideoWriter 생성 오류: {exception}'
)
self.video_writer = None
self.video_size = None
self.video_path = None
self.labelStatus.setText(
'동영상 저장 객체 생성 실패'
)
return
if (
self.video_writer is None
or not self.video_writer.isOpened()
):
if self.video_writer is not None:
self.video_writer.release()
self.video_writer = None
self.video_size = None
self.video_path = None
self.labelStatus.setText(
'동영상 파일 생성 실패'
)
return
self.recording_enabled = True
self.btnStartMov.setEnabled(False)
self.btnStopMov.setEnabled(True)
self.labelStatus.setText(
f'동영상 녹화 중 | {self.video_path}'
)
self.node.get_logger().info(
f'동영상 녹화 시작: {self.video_path}'
)
def stop_recording(self):
was_recording = (
self.recording_enabled
)
saved_path = (
self.video_path
)
if self.video_writer is not None:
try:
self.video_writer.release()
except cv2.error as exception:
self.node.get_logger().error(
f'VideoWriter 종료 오류: {exception}'
)
finally:
self.video_writer = None
self.recording_enabled = False
self.video_size = None
self.video_path = None
self.btnStartMov.setEnabled(True)
self.btnStopMov.setEnabled(False)
if (
was_recording
and saved_path is not None
):
self.labelStatus.setText(
f'동영상 저장 완료 | {saved_path}'
)
self.node.get_logger().info(
f'동영상 저장 완료: {saved_path}'
)
else:
self.labelStatus.setText(
'녹화 중인 동영상이 없습니다.'
)
def resizeEvent(self, event):
super().resizeEvent(
event
)
if (
self.camera_display_enabled
and self.last_frame is not None
):
self.show_frame(
self.last_frame
)
def closeEvent(self, event):
self.window_closing = True
if hasattr(self, 'ros_timer'):
self.ros_timer.stop()
if self.recording_enabled:
self.stop_recording()
if hasattr(self, 'node'):
self.node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
event.accept()
def main(args=None):
exit_code = 1
app = None
window = None
try:
rclpy.init(
args=args
)
# configure_qt_environment()가 호출된 이후에
# QApplication을 생성해야 합니다.
app = QApplication(
[sys.argv[0]]
)
window = CameraGui()
window.show()
exit_code = app.exec_()
except Exception as exception:
print(
f'카메라 GUI 실행 오류: {exception}',
file=sys.stderr
)
finally:
if window is not None:
if (
window.recording_enabled
and window.video_writer is not None
):
window.video_writer.release()
if rclpy.ok():
rclpy.shutdown()
sys.exit(
exit_code
)
if __name__ == '__main__':
main()

15. 소스 설명
1) PyQt5와 OpenCV가 서로 다른 Qt를 사용한다
PyQt5는 GUI를 출력하기 위해 Qt 라이브러리를 사용합니다.
그런데 pip로 설치한 일반 opencv-python 패키지에도 OpenCV의 GUI 기능을 지원하기 위한 Qt 라이브러리와 플러그인이 포함될 수 있습니다.
구조를 단순화하면 다음과 같습니다.
PyQt5
├─ 자체 Qt 라이브러리
└─ 자체 platform plugin
opencv-python
├─ OpenCV 영상 처리 기능
├─ 자체 Qt 라이브러리
└─ cv2/qt/plugins/platforms/libqxcb.so
현재 프로그램은 화면 출력을 PyQt5가 담당합니다.
self.labelImage.setPixmap(pixmap)
OpenCV는 영상 디코딩, 색상 변환, 크기 변경, 동영상 저장에만 사용합니다.
cv2.imdecode()
cv2.cvtColor()
cv2.resize()
cv2.VideoWriter()
따라서 OpenCV의 Qt GUI 플러그인은 필요하지 않습니다.
그런데 opencv-python을 import하면 OpenCV가 자신의 Qt 플러그인 경로를 환경변수에 등록하는 경우가 있습니다.
/home/user/.local/lib/python3.10/site-packages/cv2/qt/plugins
이후 PyQt5가 QApplication을 생성할 때 PyQt5의 플러그인이 아니라 OpenCV 패키지 안의 libqxcb.so를 불러오려고 합니다.
PyQt5가 사용하는 Qt 라이브러리 버전과 OpenCV가 포함한 Qt 플러그인 버전이 다르면 다음과 같은 충돌이 발생합니다.
PyQt5 Qt 라이브러리
+
OpenCV의 xcb 플러그인
↓
Qt ABI 또는 스레드 객체 충돌
↓
프로그램 실행 실패
따라서 QApplication이 생성되기 전에 OpenCV가 설정한 Qt 플러그인 경로를 제거하고, PyQt5의 정상적인 플러그인 경로를 다시 지정해야 합니다.
2) QT_QPA_PLATFORM을 xcb로 지정
import보다 먼저 다음 설정을 먼저 추가합니다.
os.environ.setdefault(
'QT_QPA_PLATFORM',
'xcb'
)
QT_QPA_PLATFORM은 Qt가 어떤 화면 출력 백엔드를 사용할지 지정하는 환경변수입니다.
Linux 데스크톱에서는 대표적으로 다음 두 플랫폼이 사용됩니다.
xcb : X11 또는 XWayland 기반 출력
wayland : Wayland 네이티브 출력
Ubuntu GNOME이 Wayland 세션으로 실행 중이면 다음 경고가 나타날 수 있습니다.
Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
이 문장은 프로그램 종료의 직접적인 원인이 아니라 Qt가 Wayland 세션에서 xcb를 이용해 XWayland로 실행하겠다는 경고입니다.
따라서 무조건 다음처럼 설정하면 안 됩니다.
export QT_QPA_PLATFORM=wayland
Wayland 플러그인이 설치되지 않은 환경에서 이를 강제로 지정하면 새로운 오류가 발생합니다.
Could not find the Qt platform plugin "wayland"
시스템에 이미 QT_QPA_PLATFORM 값이 설정되어 있지 않을 때만 xcb를 기본값으로 사용합니다.
os.environ.setdefault(
'QT_QPA_PLATFORM',
'xcb'
)
setdefault()는 기존 값을 무조건 덮어쓰지 않습니다.
예를 들어 사용자가 실행 전에 다음과 같이 지정했다면:
export QT_QPA_PLATFORM=offscreen
코드가 이를 xcb로 변경하지 않습니다.
일반적인 대입문과의 차이는 다음과 같습니다.
# 기존 설정을 무조건 덮어씀
os.environ['QT_QPA_PLATFORM'] = 'xcb'
# 설정이 없을 때만 xcb 지정
os.environ.setdefault(
'QT_QPA_PLATFORM',
'xcb'
)
사용자 설정을 존중하면서 기본 실행 환경만 지정하는 방식입니다.
3) PyQt5를 OpenCV보다 먼저 import하도록 변경
PyQt5를 opencv 보다 먼저 import합니다.
from PyQt5 import uic
from PyQt5.QtCore import QLibraryInfo, QTimer, Qt
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import QApplication, QMainWindow
import cv2
import numpy as np
이 순서는 단순한 코드 정리가 아닙니다.
PyQt5를 먼저 import하면 현재 프로세스에서 PyQt5가 사용하는 Qt 라이브러리가 먼저 로딩됩니다.
PyQt5 import
↓
PyQt5의 Qt 라이브러리 로딩
↓
OpenCV import
↓
OpenCV가 Qt 환경변수를 수정할 가능성
↓
환경변수 재정리
반대로 OpenCV를 먼저 import하면 OpenCV가 포함한 Qt 관련 라이브러리와 환경변수가 먼저 활성화될 수 있습니다.
OpenCV import
↓
cv2/qt/plugins 등록
↓
PyQt5 import
↓
서로 다른 Qt 구성 혼합
다만 import 순서만 변경하는 것으로 모든 문제가 해결되는 것은 아닙니다.
OpenCV import 과정에서 QT_QPA_PLATFORM_PLUGIN_PATH가 다시 OpenCV 경로로 변경될 수 있기 때문입니다.
4) QLibraryInfo
다음 항목이 import 됩니다.
from PyQt5.QtCore import QLibraryInfo
QLibraryInfo는 현재 PyQt5가 사용 중인 Qt 라이브러리의 설치 경로 정보를 제공합니다.
이 함수는 PyQt5의 플러그인 디렉터리를 찾는 데 사용합니다.
qt_plugin_path = QLibraryInfo.location(
QLibraryInfo.PluginsPath
)
실제 반환 경로는 설치 방식에 따라 달라질 수 있습니다.
Ubuntu 패키지로 설치한 PyQt5라면 다음과 비슷할 수 있습니다.
/usr/lib/x86_64-linux-gnu/qt5/plugins
pip로 설치한 PyQt5라면 다음과 비슷한 경로가 반환될 수 있습니다.
/home/user/.local/lib/python3.10/site-packages/PyQt5/Qt5/plugins
경로를 코드에 직접 고정하지 않고 QLibraryInfo를 사용하는 이유는 시스템마다 PyQt5 설치 위치가 다르기 때문입니다.
다음과 같이 절대 경로를 직접 지정하면 다른 컴퓨터에서 동작하지 않을 수 있습니다.
os.environ[
'QT_QPA_PLATFORM_PLUGIN_PATH'
] = '/usr/lib/x86_64-linux-gnu/qt5/plugins'
QLibraryInfo를 사용하면 현재 실행 중인 PyQt5가 사용하는 경로를 기준으로 자동 설정할 수 있습니다.
5) configure_qt_environment() 함수
아래는 Qt 경로를 지정하는 새 함수입니다.
def configure_qt_environment():
"""
OpenCV가 등록한 Qt 플러그인 경로를 제거하고
PyQt5가 사용하는 정상적인 Qt 플러그인 경로로 변경합니다.
"""
이 함수는 크게 두 단계로 동작합니다.
1. OpenCV가 등록한 잘못된 Qt 경로 제거
2. PyQt5의 Qt 플러그인 경로 지정
6) 검사할 Qt 환경변수 목록
함수 내부에서 다음 세 환경변수를 검사합니다.
qt_environment_names = (
'QT_QPA_PLATFORM_PLUGIN_PATH',
'QT_QPA_FONTDIR',
'QT_PLUGIN_PATH',
)
각 환경변수의 역할은 다음과 같습니다.
QT_QPA_PLATFORM_PLUGIN_PATH
Qt 플랫폼 플러그인의 위치를 지정합니다.
대표적인 플랫폼 플러그인은 다음과 같습니다.
libqxcb.so
libqwayland-egl.so
libqoffscreen.so
OpenCV가 다음 경로를 지정하면:
.../site-packages/cv2/qt/plugins
PyQt5도 이 경로에서 xcb 플러그인을 찾으려고 합니다.
QT_PLUGIN_PATH
Qt가 전체 플러그인을 검색할 추가 경로입니다.
플랫폼 플러그인뿐 아니라 이미지 포맷, 아이콘 엔진, 스타일 등의 플러그인 검색에도 영향을 줄 수 있습니다.
이 값이 OpenCV 경로로 설정되어 있으면 QT_QPA_PLATFORM_PLUGIN_PATH를 수정해도 다른 Qt 플러그인이 혼합될 가능성이 있습니다.
QT_QPA_FONTDIR
Qt가 글꼴을 찾는 경로입니다.
OpenCV의 Qt 설정이 이 환경변수까지 변경하는 경우가 있습니다.
플랫폼 플러그인 오류의 직접적인 원인은 아니더라도, PyQt5에서 한글 글꼴이나 기본 UI 글꼴이 제대로 표시되지 않는 문제를 만들 수 있습니다.
7) 환경변수 경로 정규화
각 환경변수 값을 가져옵니다.
environment_value = os.environ.get(
environment_name,
''
)
환경변수가 존재하지 않으면 빈 문자열을 사용합니다.
그다음 다음과 같이 경로를 정규화합니다.
normalized_value = (
environment_value
.replace('\\', '/')
.lower()
)
두 가지 처리를 수행합니다.
역슬래시를 슬래시로 변환
.replace('\\', '/')
Linux 경로는 일반적으로 /를 사용하지만, 경로 형식이 다르거나 외부 환경 설정을 통해 \가 포함될 가능성도 있습니다.
비교 전에 경로 구분자를 통일하면 검사 조건이 단순해집니다.
소문자로 변환
.lower()
cv2, CV2, Cv2처럼 대소문자 차이가 있는 경우에도 동일하게 비교하기 위한 처리입니다.
Linux 파일 시스템은 대소문자를 구분하지만, 여기서는 실제 파일을 여는 것이 아니라 환경변수가 OpenCV 관련 경로인지 판단하는 것이 목적입니다.
8) OpenCV의 Qt 경로만 선택적으로 제거
환경변수에 OpenCV 경로가 포함됐는지 검사합니다.
if (
'/cv2/' in normalized_value
or normalized_value.endswith('/cv2')
):
os.environ.pop(
environment_name,
None
)
모든 Qt 환경변수를 무조건 삭제하지 않고, 값에 cv2 경로가 포함된 경우만 제거합니다.
예를 들어 다음 값은 제거 대상입니다.
/home/user/.local/lib/python3.10/site-packages/cv2/qt/plugins
다음처럼 사용자가 정상적으로 지정한 PyQt5 경로는 제거하지 않습니다.
/home/user/.local/lib/python3.10/site-packages/PyQt5/Qt5/plugins
환경변수를 삭제할 때 다음 코드를 사용합니다.
os.environ.pop(
environment_name,
None
)
두 번째 인자인 None은 해당 환경변수가 존재하지 않아도 예외를 발생시키지 않도록 합니다.
다음 코드와 차이가 있습니다.
del os.environ[environment_name]
del을 사용하면 키가 없을 때 KeyError가 발생할 수 있습니다.
pop(..., None)은 환경변수가 있으면 제거하고, 없으면 아무 작업도 하지 않습니다.
9) PyQt5의 플러그인 경로 재설정
OpenCV 경로를 제거한 다음 PyQt5의 플러그인 경로를 가져옵니다.
qt_plugin_path = QLibraryInfo.location(
QLibraryInfo.PluginsPath
)
유효한 경로가 반환되면 다음 환경변수에 저장합니다.
if qt_plugin_path:
os.environ[
'QT_QPA_PLATFORM_PLUGIN_PATH'
] = qt_plugin_path
최종적으로 Qt 플랫폼 플러그인 검색 경로는 다음과 같이 변경됩니다.
/home/user/.local/lib/python3.10/site-packages/PyQt5/Qt5/plugins
또는 시스템 PyQt5를 사용한다면 다음과 같이 설정될 수 있습니다.
/usr/lib/x86_64-linux-gnu/qt5/plugins
이제 QApplication은 OpenCV의 libqxcb.so가 아니라 PyQt5와 호환되는 libqxcb.so를 불러오게 됩니다.
10) 환경 설정 함수의 호출 위치가 중요한 이유
다음 코드는 클래스 정의 전에 실행됩니다.
configure_qt_environment()
그리고 QApplication은 이후 main()에서 생성됩니다.
app = QApplication(
[sys.argv[0]]
)
Qt 플랫폼 플러그인은 QApplication이 생성되는 시점에 로딩됩니다.
따라서 환경변수는 반드시 QApplication 생성 전에 수정해야 합니다.
잘못된 순서는 다음과 같습니다.
app = QApplication([sys.argv[0]])
configure_qt_environment()
이 경우 QApplication이 이미 OpenCV의 잘못된 플러그인을 로딩하려고 시도한 뒤이기 때문에 환경변수 변경이 늦습니다.
올바른 순서는 다음과 같습니다.
PyQt5 import
↓
OpenCV import
↓
OpenCV가 변경한 환경변수 정리
↓
PyQt5 플러그인 경로 지정
↓
QApplication 생성
11) UI 파일 존재 여부 검사
먼저 실제 파일 존재 여부를 검사합니다.
if not ui_path.exists():
raise FileNotFoundError(
f'UI 파일을 찾을 수 없습니다: {ui_path}'
)
ROS 2 Python 패키지에서 .ui 파일이 소스 디렉터리에 존재하더라도 setup.py의 data_files 설정이 잘못되면 install 디렉터리로 복사되지 않을 수 있습니다.
이 경우 uic.loadUi() 내부에서 비교적 복잡한 예외가 발생할 수 있습니다.
파일 존재를 먼저 확인함으로써 문제의 원인을 명확하게 출력합니다.
UI 파일을 찾을 수 없습니다:
/home/user/ros2_ws/install/tb3_camera_gui/share/tb3_camera_gui/ui/camera_gui.ui
이를 통해 다음 두 항목을 빠르게 점검할 수 있습니다.
1. camera_gui.ui 파일이 실제로 존재하는가?
2. setup.py의 data_files에 ui 파일이 등록되어 있는가?
에러가 발생하지 않으면 UI 파일 경로를 생성한 뒤 바로 uic.loadUi()를 호출했습니다.
uic.loadUi(
str(ui_path),
self
)
Qt Designer에서 만든 .ui 파일을 Python 코드로 미리 변환하지 않고 프로그램 실행 시 직접 읽습니다.
일반적으로 UI 파일을 Python 코드로 변환할 때는 다음 명령을 사용할 수 있습니다.
pyuic5 camera_gui.ui -o camera_gui_ui.py
하지만 이 프로그램에서는 변환 과정을 생략했습니다.
uic.loadUi()를 사용하면 다음 장점이 있습니다.
- UI를 변경해도 Python UI 변환 파일을 다시 만들 필요가 없습니다.
- Qt Designer에서 화면만 수정할 수 있습니다.
- 화면 코드와 동작 코드를 분리할 수 있습니다.
- 수강생이 UI 구조를 쉽게 확인할 수 있습니다.
두 번째 인수로 self를 전달했기 때문에 UI 파일에 정의된 객체가 현재 CameraGui 객체에 직접 연결됩니다.
예를 들어 UI 파일에 다음 객체가 있다면
btnStartCamera
labelImage
labelStatus
Python 코드에서 다음처럼 바로 사용할 수 있습니다.
self.btnStartCamera
self.labelImage
self.labelStatus
별도의 UI 클래스 객체를 생성하거나 멤버로 보관할 필요가 없습니다.
12) ROS 2 파라미터를 사용하는 이유
카메라 토픽 이름과 동영상 FPS는 코드에 고정하지 않고 ROS 2 파라미터로 선언했습니다.
self.node.declare_parameter(
'image_topic',
'/camera/image_raw/compressed'
)
self.node.declare_parameter(
'video_fps',
30.0
)
첫 번째 값은 파라미터 이름이고 두 번째 값은 기본값입니다.
따라서 별도 옵션 없이 실행하면 다음 값이 사용됩니다.
image_topic = /camera/image_raw/compressed
video_fps = 30.0
파라미터를 사용하면 소스 코드를 수정하지 않고 실행할 때 값을 변경할 수 있습니다.
예를 들어 카메라 토픽이 다음과 같다고 가정합니다.
/robot1/camera/image_raw/compressed
다음과 같이 실행할 수 있습니다.
ros2 run tb3_camera_gui camera_gui \
--ros-args \
-p image_topic:=/robot1/camera/image_raw/compressed
동영상 FPS를 20으로 변경하려면 다음과 같이 실행합니다.
ros2 run tb3_camera_gui camera_gui \
--ros-args \
-p video_fps:=20.0
ROS 2 파라미터는 동일한 프로그램을 여러 환경에서 재사용할 때 유용합니다.
13) 파라미터 값을 읽는 방식
선언한 파라미터 값은 다음 코드로 읽습니다.
self.image_topic = (
self.node
.get_parameter('image_topic')
.get_parameter_value()
.string_value
)
처리 순서는 다음과 같습니다.
get_parameter()
↓
파라미터 객체 가져오기
↓
get_parameter_value()
↓
내부 값 객체 가져오기
↓
string_value
↓
문자열 값 가져오기
문자열 파라미터이므로 string_value를 사용합니다.
.string_value
FPS는 실수형 파라미터이므로 double_value를 사용합니다.
self.video_fps = (
self.node
.get_parameter('video_fps')
.get_parameter_value()
.double_value
)
파라미터 타입에 따라 사용하는 속성이 달라집니다.
문자열
string_value
실수
double_value
정수
integer_value
논리값
bool_value
14) video_fps 파라미터 유효성 검사
video_fps 파라미터 값을 읽습니다.
self.video_fps = (
self.node
.get_parameter('video_fps')
.get_parameter_value()
.double_value
)
읽어들인 파라미터 값이 0 이하인지 검사합니다.
if self.video_fps <= 0.0:
self.node.get_logger().warning(
'video_fps가 0 이하이므로 30.0 FPS를 사용합니다.'
)
self.video_fps = 30.0
cv2.VideoWriter의 FPS에는 정상적인 양수 값이 필요합니다.
실행 명령에서 실수로 다음과 같이 지정할 수 있습니다.
ros2 run tb3_camera_gui camera_gui \
--ros-args \
-p video_fps:=0.0
또는 음수 값이 입력될 수도 있습니다.
-p video_fps:=-30.0
이 값을 그대로 VideoWriter에 전달하면 파일 생성에 실패하거나 비정상적인 동영상 파일이 만들어질 수 있습니다.
만약 잘못된 입력을 발견하면 ROS 2 경고 로그를 남기고 기본값인 30FPS를 사용합니다.
잘못된 FPS 입력
↓
ROS 2 warning 출력
↓
30.0으로 보정
↓
프로그램 계속 실행
단순히 프로그램을 종료시키기보다 안전한 기본값으로 복구하는 방식입니다.
15) window_closing 상태 변수
윈도우 종료 관련 상태 변수를 선언니다.
self.window_closing = False
프로그램 종료가 시작되면 이 값을 True로 변경합니다.
self.window_closing = True
이 변수는 창을 닫는 과정에서 ROS 2 콜백이나 영상 처리가 다시 실행되는 것을 방지합니다.
PyQt5의 QTimer는 일정 주기로 다음 함수를 호출합니다.
self.spin_ros_once
사용자가 창 닫기 버튼을 누르는 순간과 QTimer의 timeout 이벤트가 거의 동시에 발생할 수 있습니다.
이때 다음 상황이 생길 수 있습니다.
closeEvent() 시작
↓
ROS 2 노드 삭제 준비
↓
QTimer timeout 이벤트 실행
↓
spin_once()가 삭제 중인 노드에 접근
이를 방지하기 위해 spin_ros_once() 시작 부분에서 종료 상태를 검사합니다.
if (
self.window_closing
or not rclpy.ok()
):
return
이미 종료가 시작되었거나 ROS 2 Context가 유효하지 않으면 콜백 처리를 수행하지 않습니다.
image_callback()에서도 동일하게 검사합니다.
if self.window_closing:
return
종료 도중 이미 큐에 들어와 있던 카메라 메시지가 처리되는 것도 방지합니다.
16) 카메라 프레임과 동영상 객체를 보관하는 변수
다음 변수들은 카메라 프레임과 녹화 상태를 관리합니다.
self.last_frame = None
self.video_writer = None
self.video_size = None
self.video_path = None
각 변수의 역할은 다음과 같습니다.
self.last_frame
self.last_frame = None
가장 최근에 수신한 카메라 프레임을 저장합니다.
프로그램 시작 직후에는 아직 영상을 받지 않았으므로 None입니다.
영상이 수신되면 다음과 같이 갱신됩니다.
self.last_frame = frame
이 변수는 다음 기능에서 사용됩니다.
- Start Camera 버튼을 누른 즉시 최근 영상 표시
- Start Mov 버튼을 눌렀을 때 영상 존재 여부 확인
- 녹화할 영상 크기 확인
- GUI 창 크기 변경 후 영상 다시 표시
self.video_writer
self.video_writer = None
OpenCV의 VideoWriter 객체를 저장합니다.
녹화를 시작하면 실제 객체가 생성됩니다.
self.video_writer = cv2.VideoWriter(...)
녹화를 중지하면 객체를 해제한 뒤 다시 None으로 설정합니다.
self.video_writer.release()
self.video_writer = None
self.video_size
self.video_size = None
동영상 저장 크기를 보관합니다.
OpenCV의 영상 크기는 다음 순서로 저장합니다.
너비, 높이
예를 들어 640×480 영상이라면 다음과 같습니다.
self.video_size = (640, 480)
self.video_path
self.video_path = None
현재 녹화 중인 동영상의 저장 경로를 보관합니다.
예시는 다음과 같습니다.
/home/user/Videos/tb3_camera_20260723_142530.avi
녹화를 중지한 뒤 사용자에게 저장 완료 경로를 표시하기 위해 사용합니다.
17) 초기 버튼 상태 설정
프로그램 시작 시 카메라 화면은 정지 상태이고 녹화도 정지 상태입니다.
따라서 버튼 상태를 다음과 같이 설정합니다.
self.btnStartCamera.setEnabled(True)
self.btnStopCamera.setEnabled(False)
Start Camera 버튼은 사용할 수 있고 Stop Camera 버튼은 사용할 수 없습니다.
녹화 버튼도 같은 방식입니다.
self.btnStartMov.setEnabled(True)
self.btnStopMov.setEnabled(False)
버튼 상태를 제어하면 잘못된 순서의 입력을 줄일 수 있습니다.
예를 들어 녹화를 시작하지 않은 상태에서 Stop Mov 버튼을 누를 필요가 없으므로 비활성화합니다.
18) QTimer를 사용한 ROS 2 콜백 처리
다음 코드가 이 프로그램에서 가장 중요한 부분 중 하나입니다.
self.ros_timer = QTimer(self)
PyQt5의 타이머 객체를 생성합니다.
self를 부모 객체로 지정했기 때문에 메인 윈도우가 제거될 때 타이머도 함께 정리됩니다.
타이머의 timeout 신호를 함수에 연결합니다.
self.ros_timer.timeout.connect(
self.spin_ros_once
)
타이머를 10ms 간격으로 실행합니다.
self.ros_timer.start(10)
10ms는 0.01초입니다.
이론적으로 초당 약 100번 spin_ros_once()가 호출됩니다.
하지만 이것이 카메라를 100 FPS로 처리한다는 뜻은 아닙니다.
타이머는 ROS 2 메시지가 도착했는지 자주 확인하는 역할만 합니다.
카메라 토픽이 30 FPS로 발행된다면 실제 이미지 콜백은 초당 약 30번 실행됩니다.
19) spin_ros_once() 예외 처리
종료 상태를 검사하고 예외를 처리합니다.
def spin_ros_once(self):
if (
self.window_closing
or not rclpy.ok()
):
return
try:
rclpy.spin_once(
self.node,
timeout_sec=0.0
)
except Exception as exception:
self.node.get_logger().error(
f'ROS 2 콜백 처리 오류: {exception}'
)
self.labelStatus.setText(
f'ROS 2 처리 오류 | {exception}'
)
ROS 2 콜백 처리 중 발생한 예외가 Qt 이벤트 루프 밖으로 전달되면 GUI 프로그램 전체가 종료될 수 있습니다.
예외 발생 가능 상황은 다음과 같습니다.
- 노드가 종료되는 순간
spin_once()호출 - Subscription 콜백 내부의 처리 오류
- ROS 2 Context 상태 이상
- Executor 또는 WaitSet 관련 예외
- 잘못된 메시지 데이터 처리
예외는 두 곳에서 처리합니다.
터미널의 ROS 2 로그
self.node.get_logger().error(...)
GUI 상태 라벨
self.labelStatus.setText(...)
개발자는 터미널에서 자세한 오류를 확인할 수 있고, GUI 사용자도 현재 문제가 발생했다는 사실을 알 수 있습니다.
20) 압축 데이터를 NumPy 배열로 변환
encoded_data = np.frombuffer(
msg.data,
dtype=np.uint8
)
msg.data에는 JPEG 또는 PNG로 압축된 바이트 데이터가 들어 있습니다.
OpenCV의 imdecode()는 NumPy 배열 형태의 데이터를 입력으로 받기 때문에 np.frombuffer()를 사용합니다.
np.frombuffer()는 기존 메모리를 복사하지 않고 NumPy 배열 형태로 해석합니다.
dtype=np.uint8
이미지 파일 데이터는 1바이트 단위이므로 8비트 부호 없는 정수형을 사용합니다.
이 단계의 데이터는 아직 픽셀 배열이 아닙니다.
JPEG 또는 PNG 파일의 압축된 바이트 배열입니다.
21) cv2.imdecode로 영상 복원
frame = cv2.imdecode(
encoded_data,
cv2.IMREAD_COLOR
)
cv2.imdecode()는 메모리 안에 있는 압축 이미지 데이터를 OpenCV 영상으로 복원합니다.
일반적인 이미지 파일은 다음과 같이 읽습니다.
frame = cv2.imread('image.jpg')
하지만 ROS 2 메시지는 파일 경로가 아니라 메모리에 들어 있는 데이터를 전달합니다.
따라서 imread()가 아니라 imdecode()를 사용합니다.
cv2.IMREAD_COLOR를 지정했기 때문에 결과는 3채널 BGR 컬러 영상이 됩니다.
640×480 영상이라면 frame.shape은 다음과 같습니다.
(480, 640, 3)
순서는 다음과 같습니다.
높이, 너비, 채널 수
22) 빈 압축 이미지 데이터 검사
image_callback()에 다음 판단문을 수행합니다.
if encoded_data.size == 0:
self.labelStatus.setText(
'빈 이미지 데이터가 수신되었습니다.'
)
return
msg.data가 비어 있으면 다음 변환 결과도 빈 배열이 됩니다.
encoded_data = np.frombuffer(
msg.data,
dtype=np.uint8
)
빈 배열을 cv2.imdecode()에 전달하면 OpenCV assertion 오류가 발생할 수 있습니다.
예를 들면 다음과 같은 오류입니다.
OpenCV error:
(-215:Assertion failed) !buf.empty() in function 'imdecode_'
기존 코드의 frame is None 검사는 imdecode()가 정상적으로 호출된 뒤 결과가 None인 경우만 처리합니다.
하지만 빈 데이터는 None을 반환하기 전에 cv2.error 예외를 발생시킬 수 있습니다.
따라서 다음 두 검사는 서로 역할이 다릅니다.
# 입력 데이터 자체가 비어 있는지 검사
if encoded_data.size == 0:
return
# 데이터는 있지만 디코딩에 실패했는지 검사
if frame is None:
return
23) OpenCV 전용 예외인 cv2.error 처리
수정된 코드에서는 OpenCV 호출을 try-except로 감쌌습니다.
try:
frame = cv2.imdecode(
encoded_data,
cv2.IMREAD_COLOR
)
except cv2.error as exception:
...
OpenCV Python 함수는 입력값이나 내부 영상 처리에 문제가 생기면 일반 Exception이 아니라 cv2.error를 발생시키는 경우가 많습니다.
예를 들어 다음 상황에서 발생할 수 있습니다.
- 빈 압축 데이터
- 손상된 JPEG 데이터
- 지원되지 않는 데이터 형식
- 잘못된 영상 크기
- 내부 코덱 초기화 실패
cv2.error를 별도로 처리하면 OpenCV 관련 오류와 일반 Python 오류를 구분할 수 있습니다.
except cv2.error as exception:
self.node.get_logger().error(
f'OpenCV 이미지 디코딩 오류: {exception}'
)
그다음 일반 예외도 처리합니다.
except Exception as exception:
self.node.get_logger().error(
f'이미지 처리 오류: {exception}'
)
처리 순서가 중요합니다.
except cv2.error:
...
except Exception:
...
cv2.error도 넓게 보면 Exception 계열이므로 일반 Exception을 먼저 작성하면 OpenCV 전용 처리 구간에 도달하지 못할 수 있습니다.
24) 디코딩 실패 처리
if frame is None:
압축 데이터를 정상적으로 복원하지 못하면 cv2.imdecode()는 None을 반환합니다.
이 경우 화면 표시나 동영상 저장을 계속하면 오류가 발생하므로 즉시 처리합니다.
self.labelStatus.setText(
'이미지 디코딩 실패'
)
사용자에게 현재 상태를 알립니다.
return
현재 콜백을 종료합니다.
이 검사는 다음과 같은 문제를 방지합니다.
frame.shape접근 오류cv2.cvtColor()오류VideoWriter.write()오류- 잘못된 이미지 표시
- 프로그램 강제 종료
25) 최근 프레임 저장
self.last_frame = frame
정상적으로 복원된 영상을 최근 프레임 변수에 저장합니다.
Start Camera 버튼을 누르기 전에 카메라 토픽이 이미 수신되고 있었다면 last_frame에는 최신 영상이 들어 있습니다.
따라서 Start Camera 버튼을 누르는 순간 다음 코드로 바로 화면을 표시할 수 있습니다.
if self.last_frame is not None:
self.show_frame(
self.last_frame
)
다음 카메라 메시지가 도착할 때까지 기다리지 않아도 됩니다.
26) 화면 표시용 프레임과 저장용 프레임 분리
write_frame = frame
현재 프레임을 저장용 변수에 대입합니다.
화면에는 원본 프레임을 표시하고 동영상에는 크기가 조정된 프레임을 저장할 수 있도록 변수를 분리한 것입니다.
만약 다음처럼 원본 변수 자체를 변경하면
frame = cv2.resize(
frame,
self.video_size
)
GUI 화면에 표시되는 영상도 변경된 크기를 사용하게 됩니다.
저장용 프레임을 별도로 사용하면 화면 표시와 동영상 저장 처리를 독립적으로 유지할 수 있습니다.
27) 녹화 조건을 2개 확인하는 이유
if (
self.recording_enabled
and self.video_writer is not None
):
녹화 상태 플래그와 VideoWriter 객체를 모두 확인합니다.
첫 번째 조건:
self.recording_enabled
사용자가 Start Mov 버튼을 눌러 녹화를 시작한 상태인지 확인합니다.
두 번째 조건:
self.video_writer is not None
동영상 파일 객체가 실제로 생성되어 있는지 확인합니다.
두 조건을 함께 확인하면 다음과 같은 예외 상황을 막을 수 있습니다.
recording_enabled는 True
VideoWriter 생성은 실패
이 상태에서 write()를 호출하면 오류가 발생합니다.
따라서 실제 객체가 존재할 때만 프레임을 저장합니다.