PyQt를 이용한 ROS2 GUI 프로그래밍 기초

위젯 직접 코딩 방식과 .ui 파일 방식 비교, ROS2 토픽 송수신 예제까지

ROS2로 로봇을 개발하다 보면 터미널 명령어만으로는 부족한 순간이 옵니다.
예를 들어 다음과 같은 기능은 GUI가 있으면 훨씬 편합니다.

  • 로봇 현재 상태 표시
  • /cmd_vel 토픽 발행
  • 센서 토픽 수신
  • 버튼으로 로봇 시작/정지
  • AMCL 위치 표시
  • 배터리, 모터, 라이다, 카메라 상태 모니터링
  • 작업 모드 선택
  • 웨이포인트 실행

ROS2 GUI를 만드는 방법은 여러 가지가 있지만, Python 환경에서는 PyQt + rclpy 조합이 실전성이 좋습니다.
이번 글에서는 PyQt로 ROS2 GUI를 만드는 두 가지 방식을 정리합니다.

  1. 위젯을 코드로 직접 작성하는 방식
  2. Qt Designer에서 만든 .ui 파일을 불러오는 방식

그리고 마지막에는 GUI에서 ROS2 토픽을 받고 발행하는 예제까지 작성합니다.

1. PyQt + ROS2 GUI의 기본 구조

ROS2 노드는 rclpy로 실행하고, GUI는 PyQt 이벤트 루프로 실행합니다.

문제는 둘 다 자기만의 반복 루프를 가진다는 점입니다.

PyQt는 다음 루프를 사용합니다.

app.exec_()

ROS2는 다음 루프를 사용합니다.

rclpy.spin(node)

따라서 GUI와 ROS2를 같이 쓰려면 보통 다음 구조를 사용합니다.

PyQt Main Thread
 └── GUI 표시, 버튼 처리, 화면 업데이트

ROS2 Thread
 └── rclpy executor 실행, 토픽 subscribe, publish 처리

중요한 원칙이 있습니다.

ROS2 콜백에서 PyQt 위젯을 직접 수정하지 말고, PyQt signal을 이용해 GUI 스레드로 전달해야 합니다.

즉, ROS2 subscriber 콜백 안에서 바로 label.setText()를 호출하는 것은 피하는 것이 좋습니다.
대신 pyqtSignal을 사용합니다.

2. 개발 환경 준비

Ubuntu + ROS2 환경을 기준으로 합니다.

sudo apt update
sudo apt install python3-pyqt5 pyqt5-dev-tools qttools5-dev-tools -y

ROS2 패키지를 하나 만듭니다.

cd ~/ros2_lab_ws/src
ros2 pkg create pyqt_ros2_gui --build-type ament_python --dependencies rclpy std_msgs geometry_msgs

패키지 구조는 다음과 같이 구성할 수 있습니다.

pyqt_ros2_gui/
├── package.xml
├── setup.py
├── resource/
│   └── pyqt_ros2_gui
├── pyqt_ros2_gui/
│   ├── __init__.py
│   ├── direct_widget_gui.py
│   ├── ui_file_gui.py
│   └── simple_gui.ui
└── launch/

3. 방법 1: 위젯을 코드로 직접 작성하는 방식

첫 번째 방법은 Qt Designer를 쓰지 않고 Python 코드에서 버튼, 라벨, 레이아웃을 직접 만드는 방식입니다.

작은 테스트 GUI, 디버깅용 GUI, 간단한 로봇 조작 패널은 이 방식이 빠릅니다.

장점

  • 파일 하나로 끝낼 수 있습니다.
  • 구조를 이해하기 쉽습니다.
  • 작은 예제나 테스트 GUI에 적합합니다.
  • Git diff 확인이 쉽습니다.

단점

  • 화면이 복잡해지면 코드가 지저분해집니다.
  • 버튼, 라벨, 레이아웃이 많아지면 유지보수가 힘듭니다.
  • 디자이너와 개발자가 분업하기 어렵습니다.

1) 직접 위젯 코딩 예제

아래 예제는 다음 기능을 합니다.

  • GUI 버튼 클릭 시 /cmd_vel 토픽 발행
  • /robot_status 토픽 수신
  • 수신한 문자열을 QLabel에 표시

파일명:

cd ~/ros2_lab_ws/src/pyqt_ros2_gui
touch pyqt_ros2_gui/direct_widget_gui.py

import sys
import threading

from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout
from PyQt5.QtCore import pyqtSignal, QObject

import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from geometry_msgs.msg import Twist


class RosSignals(QObject):
    status_received = pyqtSignal(str)


class GuiRosNode(Node):
    def __init__(self, signals):
        super().__init__('pyqt_direct_widget_gui_node')

        self.signals = signals

        self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)

        self.status_sub = self.create_subscription(
            String,
            '/robot_status',
            self.status_callback,
            10
        )

    def status_callback(self, msg):
        self.signals.status_received.emit(msg.data)

    def publish_cmd_vel(self, linear_x, angular_z):
        msg = Twist()
        msg.linear.x = linear_x
        msg.angular.z = angular_z
        self.cmd_vel_pub.publish(msg)


class MainWindow(QWidget):
    def __init__(self, ros_node, signals):
        super().__init__()

        self.ros_node = ros_node
        self.signals = signals

        self.setWindowTitle('ROS2 PyQt Direct Widget GUI')
        self.resize(400, 200)

        self.status_label = QLabel('Robot Status: waiting...')
        self.forward_button = QPushButton('Forward')
        self.stop_button = QPushButton('Stop')
        self.left_button = QPushButton('Left')
        self.right_button = QPushButton('Right')

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.left_button)
        button_layout.addWidget(self.forward_button)
        button_layout.addWidget(self.right_button)
        button_layout.addWidget(self.stop_button)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.status_label)
        main_layout.addLayout(button_layout)

        self.setLayout(main_layout)

        self.forward_button.clicked.connect(self.on_forward)
        self.stop_button.clicked.connect(self.on_stop)
        self.left_button.clicked.connect(self.on_left)
        self.right_button.clicked.connect(self.on_right)

        self.signals.status_received.connect(self.update_status)

    def update_status(self, text):
        self.status_label.setText(f'Robot Status: {text}')

    def on_forward(self):
        self.ros_node.publish_cmd_vel(0.2, 0.0)

    def on_stop(self):
        self.ros_node.publish_cmd_vel(0.0, 0.0)

    def on_left(self):
        self.ros_node.publish_cmd_vel(0.0, 0.5)

    def on_right(self):
        self.ros_node.publish_cmd_vel(0.0, -0.5)


def main():
    rclpy.init()

    app = QApplication(sys.argv)

    signals = RosSignals()
    ros_node = GuiRosNode(signals)

    ros_thread = threading.Thread(
        target=rclpy.spin,
        args=(ros_node,),
        daemon=True
    )
    ros_thread.start()

    window = MainWindow(ros_node, signals)
    window.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == '__main__':
    main()

2) 예제 전체 동작 구조

이 프로그램은 크게 세 부분으로 나눌 수 있습니다.

첫 번째는 PyQt5 GUI입니다. 사용자가 보는 창, 버튼, 상태 표시 라벨을 담당합니다.

두 번째는 ROS 2 노드입니다. /cmd_vel 토픽으로 속도 명령을 보내고, /robot_status 토픽에서 로봇 상태 메시지를 받습니다.

세 번째는 ROS 2 spin을 실행하는 별도 스레드입니다. PyQt5 이벤트 루프와 ROS 2 이벤트 루프가 서로 막히지 않도록 분리해서 실행합니다.

일반적으로 PyQt5 프로그램은 app.exec_()에서 GUI 이벤트 루프를 실행합니다. ROS 2는 rclpy.spin()을 통해 subscriber callback 등을 처리합니다. 둘 다 계속 실행되는 구조이기 때문에 하나의 메인 스레드에서 동시에 돌리면 문제가 생깁니다.

그래서 이 예제에서는 GUI는 메인 스레드에서 실행하고, ROS 2 spin은 별도 스레드에서 실행합니다.

3) 필요한 라이브러리 import 부분

import sys
import threading

sys는 프로그램 종료 시 sys.exit()를 사용하기 위해 가져옵니다.

threading은 ROS 2의 rclpy.spin()을 별도 스레드에서 실행하기 위해 사용합니다.

from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout
from PyQt5.QtCore import pyqtSignal, QObject

이 부분은 PyQt5 GUI를 만들기 위한 모듈입니다.

QApplication은 PyQt5 애플리케이션 전체를 관리합니다.

QWidget은 기본 창 역할을 합니다.

QPushButton은 버튼을 만들 때 사용합니다.

QLabel은 텍스트를 표시하는 라벨입니다.

QVBoxLayout은 위에서 아래 방향으로 위젯을 배치하는 레이아웃입니다.

QHBoxLayout은 왼쪽에서 오른쪽 방향으로 위젯을 배치하는 레이아웃입니다.

pyqtSignalQObject는 ROS 2 콜백에서 받은 데이터를 PyQt5 GUI로 안전하게 전달하기 위해 사용합니다.

import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from geometry_msgs.msg import Twist

이 부분은 ROS 2 Python 클라이언트 라이브러리와 메시지 타입을 가져오는 부분입니다.

rclpy는 ROS 2 Python 프로그램을 작성할 때 사용하는 기본 라이브러리입니다.

Node는 ROS 2 노드를 만들기 위한 기본 클래스입니다.

String은 문자열 메시지 타입입니다. 이 예제에서는 /robot_status 토픽에서 로봇 상태 메시지를 받을 때 사용합니다.

Twist는 선속도와 각속도를 표현하는 메시지 타입입니다. 이 예제에서는 /cmd_vel 토픽으로 로봇 이동 명령을 보낼 때 사용합니다.

4) RosSignals 클래스 설명

class RosSignals(QObject):
    status_received = pyqtSignal(str)

RosSignals 클래스는 ROS 2 콜백과 PyQt5 GUI 사이에서 데이터를 안전하게 전달하기 위한 클래스입니다.

여기서 중요한 부분은 다음 코드입니다.

status_received = pyqtSignal(str)

이 코드는 문자열을 전달할 수 있는 Qt 시그널을 정의합니다.

ROS 2 subscriber callback은 GUI 메인 스레드가 아닌 ROS 2 spin 스레드에서 실행될 수 있습니다. 그런데 PyQt5에서는 GUI 위젯을 다른 스레드에서 직접 수정하면 문제가 생길 수 있습니다.

예를 들어 ROS 2 callback 안에서 바로 다음과 같이 GUI 라벨을 수정하면 위험합니다.

self.status_label.setText("Robot Status: moving")

이런 방식은 스레드 안정성이 떨어집니다.

그래서 이 예제에서는 ROS 2 callback에서 직접 GUI를 수정하지 않고, Qt 시그널을 발생시킵니다.

self.signals.status_received.emit(msg.data)

그러면 PyQt5가 해당 시그널을 GUI 스레드에서 안전하게 처리합니다.

5) GuiRosNode 클래스 설명

class GuiRosNode(Node):
    def __init__(self, signals):
        super().__init__('pyqt_direct_widget_gui_node')

GuiRosNode 클래스는 ROS 2 노드 역할을 합니다.

Node 클래스를 상속받고 있으며, 노드 이름은 다음과 같이 지정되어 있습니다.

'pyqt_direct_widget_gui_node'

ROS 2에서 노드는 publisher, subscriber, service, timer 등을 포함하는 기본 실행 단위입니다.

이 노드는 두 가지 역할을 합니다.

첫 번째는 /cmd_vel 토픽으로 로봇 제어 명령을 발행하는 것입니다.

두 번째는 /robot_status 토픽을 구독해서 로봇 상태를 GUI로 전달하는 것입니다.

6) ROS 2 Publisher 생성 부분

self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)

이 코드는 /cmd_vel 토픽으로 Twist 메시지를 발행하는 publisher를 생성합니다.

/cmd_vel은 ROS에서 이동 로봇 제어에 자주 사용되는 표준적인 토픽 이름입니다.

보통 /cmd_vel 토픽에는 다음과 같은 값이 들어갑니다.

msg.linear.x
msg.angular.z

linear.x는 전진 또는 후진 속도입니다.

angular.z는 좌회전 또는 우회전 속도입니다.

마지막 인자인 10은 QoS queue size입니다. 즉, 메시지를 임시로 저장할 큐의 크기입니다.

7) ROS 2 Subscriber 생성 부분

self.status_sub = self.create_subscription(
    String,
    '/robot_status',
    self.status_callback,
    10
)

이 코드는 /robot_status 토픽을 구독하는 subscriber를 생성합니다.

메시지 타입은 String입니다.

/robot_status 토픽으로 문자열 메시지가 들어오면 self.status_callback 함수가 실행됩니다.

예를 들어 다른 ROS 2 노드에서 다음과 같은 메시지를 발행한다고 가정할 수 있습니다.

moving
stopped
error
battery low

그러면 이 GUI 프로그램은 해당 메시지를 받아서 화면의 상태 라벨에 표시합니다.

8) status_callback 함수 설명

def status_callback(self, msg):
    self.signals.status_received.emit(msg.data)

이 함수는 /robot_status 토픽에서 메시지를 받을 때마다 실행됩니다.

msg.data에는 실제 문자열 데이터가 들어 있습니다.

예를 들어 /robot_status"moving"이라는 메시지가 들어오면 msg.data 값은 "moving"이 됩니다.

이 함수에서는 GUI 라벨을 직접 수정하지 않습니다. 대신 Qt 시그널을 발생시킵니다.

self.signals.status_received.emit(msg.data)

이 방식이 중요한 이유는 ROS 2 callback과 PyQt5 GUI가 서로 다른 스레드에서 동작할 수 있기 때문입니다.

GUI 업데이트는 반드시 GUI 메인 스레드에서 처리하는 것이 안전합니다. Qt 시그널을 사용하면 이 작업을 안정적으로 처리할 수 있습니다.

9) publish_cmd_vel 함수 설명

def publish_cmd_vel(self, linear_x, angular_z):
    msg = Twist()
    msg.linear.x = linear_x
    msg.angular.z = angular_z
    self.cmd_vel_pub.publish(msg)

이 함수는 로봇 이동 명령을 /cmd_vel 토픽으로 발행합니다.

함수 인자는 두 개입니다.

linear_x는 전진 속도입니다.

angular_z는 회전 속도입니다.

예를 들어 다음 코드는 로봇을 앞으로 이동시키는 명령입니다.

self.ros_node.publish_cmd_vel(0.2, 0.0)

linear.x0.2이고 angular.z0.0이므로 로봇은 회전하지 않고 앞으로 이동합니다.

다음 코드는 로봇을 왼쪽으로 회전시키는 명령입니다.

self.ros_node.publish_cmd_vel(0.0, 0.5)

전진 속도는 없고 회전 속도만 있기 때문에 제자리에서 왼쪽으로 회전합니다.

10) MainWindow 클래스 설명

class MainWindow(QWidget):
    def __init__(self, ros_node, signals):
        super().__init__()

MainWindow 클래스는 실제 GUI 창을 담당합니다.

QWidget을 상속받아 기본 윈도우를 만들고, 그 안에 라벨과 버튼을 배치합니다.

생성자에서는 ROS 2 노드와 Qt 시그널 객체를 전달받습니다.

self.ros_node = ros_node
self.signals = signals

이렇게 저장해 두면 버튼을 클릭했을 때 ROS 2 노드의 publish_cmd_vel() 함수를 호출할 수 있습니다.

11) 창 제목과 크기 설정

self.setWindowTitle('ROS2 PyQt Direct Widget GUI')
self.resize(400, 200)

setWindowTitle()은 GUI 창의 제목을 설정합니다.

resize(400, 200)은 창의 초기 크기를 가로 400픽셀, 세로 200픽셀로 설정합니다.

실제 실행하면 상단 제목 표시줄에 ROS2 PyQt Direct Widget GUI라는 이름이 표시됩니다.

12) 상태 라벨과 버튼 생성

self.status_label = QLabel('Robot Status: waiting...')
self.forward_button = QPushButton('Forward')
self.stop_button = QPushButton('Stop')
self.left_button = QPushButton('Left')
self.right_button = QPushButton('Right')

이 부분에서는 GUI에 들어갈 위젯을 생성합니다.

status_label은 로봇 상태를 표시하는 라벨입니다.

초기 문구는 다음과 같습니다.

Robot Status: waiting...

아직 /robot_status 메시지를 받지 않은 상태라는 뜻입니다.

버튼은 총 네 개입니다.

Forward 버튼은 전진 명령을 보냅니다.

Stop 버튼은 정지 명령을 보냅니다.

Left 버튼은 좌회전 명령을 보냅니다.

Right 버튼은 우회전 명령을 보냅니다.

13) 버튼 레이아웃 구성

button_layout = QHBoxLayout()
button_layout.addWidget(self.left_button)
button_layout.addWidget(self.forward_button)
button_layout.addWidget(self.right_button)
button_layout.addWidget(self.stop_button)

이 코드는 버튼을 가로 방향으로 배치합니다.

QHBoxLayout은 위젯을 왼쪽에서 오른쪽으로 배치하는 레이아웃입니다.

따라서 버튼은 다음 순서로 배치됩니다.

Left | Forward | Right | Stop

이 방식은 간단한 테스트용 GUI에 적합합니다.

다만 실제 로봇 조종기 형태로 만들려면 버튼 배치를 조금 바꾸는 것이 더 직관적입니다. 예를 들어 Forward를 위쪽에 두고, Left, Stop, Right를 가운데에 두는 방식도 사용할 수 있습니다.

14) 전체 레이아웃 구성

main_layout = QVBoxLayout()
main_layout.addWidget(self.status_label)
main_layout.addLayout(button_layout)

self.setLayout(main_layout)

QVBoxLayout은 위에서 아래로 위젯을 배치합니다.

이 예제에서는 가장 위에 상태 라벨을 배치하고, 그 아래에 버튼 레이아웃을 배치합니다.

결과적으로 GUI 구조는 다음과 같습니다.

Robot Status: waiting...

Left | Forward | Right | Stop

self.setLayout(main_layout)을 호출하면 이 레이아웃이 현재 창에 적용됩니다.

15) 버튼 클릭 이벤트 연결

self.forward_button.clicked.connect(self.on_forward)
self.stop_button.clicked.connect(self.on_stop)
self.left_button.clicked.connect(self.on_left)
self.right_button.clicked.connect(self.on_right)

이 부분은 버튼 클릭 이벤트와 실행할 함수를 연결합니다.

PyQt5에서는 버튼이 클릭되면 clicked 시그널이 발생합니다.

이 시그널을 특정 함수와 연결하면 버튼 클릭 시 해당 함수가 자동으로 실행됩니다.

예를 들어 Forward 버튼을 누르면 self.on_forward() 함수가 실행됩니다.

self.forward_button.clicked.connect(self.on_forward)

이 구조는 Qt 프로그래밍에서 매우 자주 사용하는 signal-slot 방식입니다.

16) ROS 상태 수신 시그널 연결

self.signals.status_received.connect(self.update_status)

이 코드는 RosSignals 클래스에서 정의한 status_received 시그널을 update_status() 함수와 연결합니다.

즉, ROS 2 subscriber callback에서 다음 코드가 실행되면,

self.signals.status_received.emit(msg.data)

GUI 쪽에서는 자동으로 다음 함수가 호출됩니다.

self.update_status(text)

이 구조 덕분에 ROS 2에서 받은 데이터를 GUI 화면에 안전하게 반영할 수 있습니다.

17) update_status 함수 설명

def update_status(self, text):
    self.status_label.setText(f'Robot Status: {text}')

이 함수는 상태 라벨의 텍스트를 변경합니다.

예를 들어 /robot_status 토픽으로 다음 문자열이 들어왔다고 가정합니다.

moving

그러면 GUI 라벨은 다음과 같이 바뀝니다.

Robot Status: moving

이 함수는 Qt 시그널을 통해 호출되므로 GUI 스레드에서 안전하게 실행됩니다.

18) 전진 버튼 동작

def on_forward(self):
    self.ros_node.publish_cmd_vel(0.2, 0.0)

Forward 버튼을 누르면 이 함수가 실행됩니다.

linear_x 값이 0.2이고 angular_z 값이 0.0입니다.

즉, 로봇에게 전진 명령을 보냅니다.

전진 속도: 0.2
회전 속도: 0.0

실제 로봇에서는 이 값이 너무 빠르거나 느릴 수 있으므로 로봇 크기, 모터 성능, 바퀴 구조에 맞게 조정해야 합니다.

19) 정지 버튼 동작

def on_stop(self):
    self.ros_node.publish_cmd_vel(0.0, 0.0)

Stop 버튼을 누르면 전진 속도와 회전 속도를 모두 0으로 설정합니다.

전진 속도: 0.0
회전 속도: 0.0

이 명령을 받은 로봇은 정지합니다.

실제 로봇 제어에서는 정지 명령이 매우 중요합니다. GUI를 이용해 로봇을 제어할 때는 반드시 정지 버튼을 명확하게 배치하는 것이 좋습니다.

20) 좌회전 버튼 동작

def on_left(self):
    self.ros_node.publish_cmd_vel(0.0, 0.5)

Left 버튼을 누르면 제자리 좌회전 명령을 보냅니다.

linear_x0.0이므로 전진하지 않습니다.

angular_z0.5이므로 z축 기준으로 회전합니다.

일반적으로 ROS 2 이동 로봇에서는 angular.z가 양수이면 왼쪽 회전, 음수이면 오른쪽 회전으로 해석합니

다.

21) 우회전 버튼 동작

def on_right(self):
    self.ros_node.publish_cmd_vel(0.0, -0.5)

Right 버튼을 누르면 제자리 우회전 명령을 보냅니다.

angular_z 값이 -0.5이기 때문에 오른쪽 방향으로 회전합니다.

좌회전과 우회전은 부호만 다릅니다.

좌회전: angular.z = 0.5
우회전: angular.z = -0.5

22) main 함수의 역할

def main():
    rclpy.init()

main() 함수는 프로그램 실행의 시작점입니다.

가장 먼저 rclpy.init()을 호출해서 ROS 2 Python 클라이언트를 초기화합니다.

ROS 2 노드를 생성하거나 publisher, subscriber를 사용하기 전에 반드시 호출해야 합니다.

23) QApplication 생성

app = QApplication(sys.argv)

PyQt5 애플리케이션 객체를 생성합니다.

GUI 프로그램에서는 QApplication 객체가 반드시 필요합니다.

이 객체는 마우스 클릭, 키보드 입력, 창 갱신 등 GUI 이벤트 전체를 관리합니다.

24) 시그널 객체와 ROS 노드 생성

signals = RosSignals()
ros_node = GuiRosNode(signals)

먼저 RosSignals 객체를 생성합니다.

이 객체는 ROS 2 callback에서 GUI로 데이터를 넘길 때 사용합니다.

그 다음 GuiRosNode 객체를 생성합니다.

이때 signals 객체를 ROS 노드에 전달합니다.

이 구조 덕분에 ROS 노드는 GUI 객체를 직접 건드리지 않고도 GUI에 상태 데이터를 전달할 수 있습니다.

25) ROS spin을 별도 스레드에서 실행

ros_thread = threading.Thread(
    target=rclpy.spin,
    args=(ros_node,),
    daemon=True
)
ros_thread.start()

이 부분이 이 예제에서 가장 중요한 부분 중 하나입니다.

rclpy.spin(ros_node)는 ROS 2 노드가 subscriber callback 등을 계속 처리할 수 있게 해줍니다.

문제는 rclpy.spin()이 계속 실행되는 함수라는 점입니다.

만약 메인 스레드에서 rclpy.spin()을 실행하면 그 아래에 있는 PyQt5 GUI 실행 코드가 제대로 동작하지 않을 수 있습니다.

반대로 PyQt5의 app.exec_()도 계속 실행되는 이벤트 루프입니다.

그래서 이 예제에서는 ROS 2 spin을 별도 스레드에서 실행합니다.

daemon=True

daemon=True는 메인 프로그램이 종료될 때 이 스레드도 같이 종료될 수 있도록 설정하는 옵션입니다.

26) MainWindow 생성과 표시

window = MainWindow(ros_node, signals)
window.show()

MainWindow 객체를 생성합니다.

이때 ROS 2 노드와 시그널 객체를 함께 전달합니다.

GUI 내부 버튼들은 이 ROS 2 노드를 이용해서 /cmd_vel 명령을 발행합니다.

window.show()를 호출하면 실제 화면에 GUI 창이 표시됩니다.

27) PyQt5 이벤트 루프 실행

exit_code = app.exec_()

이 코드는 PyQt5 GUI 이벤트 루프를 실행합니다.

사용자가 창을 닫기 전까지 이 함수는 계속 실행됩니다.

버튼 클릭, 창 이동, 라벨 갱신 같은 GUI 이벤트는 모두 이 이벤트 루프에서 처리됩니다.

프로그램 창이 닫히면 app.exec_()가 종료되고, 종료 코드가 exit_code에 저장됩니다.

28) 프로그램 종료 처리

ros_node.destroy_node()
rclpy.shutdown()

sys.exit(exit_code)

GUI 창이 닫히면 ROS 2 노드를 제거합니다.

ros_node.destroy_node()

그 다음 ROS 2 시스템을 종료합니다.

rclpy.shutdown()

마지막으로 PyQt5 애플리케이션의 종료 코드를 사용해서 프로그램을 종료합니다.

sys.exit(exit_code)

이런 종료 처리는 중요합니다. ROS 2 노드를 제대로 정리하지 않으면 프로그램 종료 시 경고가 발생하거나, 백그라운드에 프로세스가 남는 문제가 생길 수 있습니다.

29) 실행 등록

setup.py에 실행 파일을 등록합니다.

entry_points={
    'console_scripts': [
        'direct_widget_gui = pyqt_ros2_gui.direct_widget_gui:main',
    ],
},

빌드합니다.

cd ~/ros2_ws
colcon build --packages-select pyqt_ros2_gui
source install/setup.bash

실행합니다.

ros2 run pyqt_ros2_gui direct_widget_gui

테스트용으로 상태 토픽을 발행합니다.

ros2 topic pub /robot_status std_msgs/msg/String "{data: 'ready'}"

GUI의 Forward, Stop, Left, Right 버튼을 누르면 /cmd_vel 토픽이 발행됩니다.

확인은 다음 명령으로 할 수 있습니다.

ros2 topic echo /cmd_vel

4. 방법 2: .ui 파일을 이용하는 방식

두 번째 방법은 Qt Designer로 화면을 먼저 만들고, Python 코드에서 .ui 파일을 불러오는 방식입니다.

실제 로봇 GUI는 버튼, 라벨, 콤보박스, 테이블, 그래픽뷰, 프로그레스바 등이 많아지기 때문에 이 방식이 더 실전적입니다.

장점

  • 화면 설계를 Qt Designer로 할 수 있습니다.
  • 복잡한 GUI를 관리하기 좋습니다.
  • 위젯 이름만 잘 정하면 Python 코드가 깔끔해집니다.
  • 유지보수에 유리합니다.

단점

  • .ui 파일과 Python 코드가 함께 관리되어야 합니다.
  • 위젯 이름을 잘못 바꾸면 코드에서 에러가 납니다.
  • 단순 예제에는 오히려 번거로울 수 있습니다.

1) Qt Designer 실행

designer

또는 다음 명령을 사용할 수 있습니다.

qtchooser -run-tool=designer -qt=5

간단한 GUI를 만듭니다.

필요한 위젯은 다음과 같습니다.

위젯 종류objectName
QLabelstatus_label
QPushButtonforward_button
QPushButtonstop_button
QPushButtonleft_button
QPushButtonright_button

파일명은 다음과 같이 저장합니다.

pyqt_ros2_gui/simple_gui.ui

designer 실행창입니다.

Main Window를 선택하고 “Create” 버튼을 클릭합니다.

윈도우의 제목을 수정합니다.

레이블을 추가합니다. 이름을 status_label로 수정합니다.

레이블의 프레임 모양을 정의합니다.

버튼들도 유사하게 추가합니다. 폰트의 크기와 각종 효과를 설정할 수 있습니다.

버튼의 레이블을 정의합니다.

레이블과 버튼을 모두 추가한 결과입니다.

패키지 작업 장소에 ui 파일로 저장합니다.

2) .ui 파일을 Python에서 직접 불러오기

파일명:

cd ~/ros2_lab_ws/src/pyqt_ros2_gui
touch pyqt_ros2_gui/ui_file_gui.py

import sys
import os
import threading

from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5 import uic
from PyQt5.QtCore import pyqtSignal, QObject

import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from geometry_msgs.msg import Twist


class RosSignals(QObject):
    status_received = pyqtSignal(str)


class GuiRosNode(Node):
    def __init__(self, signals):
        super().__init__('pyqt_ui_file_gui_node')

        self.signals = signals

        self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)

        self.status_sub = self.create_subscription(
            String,
            '/robot_status',
            self.status_callback,
            10
        )

    def status_callback(self, msg):
        self.signals.status_received.emit(msg.data)

    def publish_cmd_vel(self, linear_x, angular_z):
        msg = Twist()
        msg.linear.x = linear_x
        msg.angular.z = angular_z
        self.cmd_vel_pub.publish(msg)


class MainWindow(QMainWindow):
    def __init__(self, ros_node, signals):
        super().__init__()

        self.ros_node = ros_node
        self.signals = signals

        ui_path = os.path.join(
            os.path.dirname(__file__),
            'simple_gui.ui'
        )

        uic.loadUi(ui_path, self)

        self.forward_button.clicked.connect(self.on_forward)
        self.stop_button.clicked.connect(self.on_stop)
        self.left_button.clicked.connect(self.on_left)
        self.right_button.clicked.connect(self.on_right)

        self.signals.status_received.connect(self.update_status)

    def update_status(self, text):
        self.status_label.setText(f'Robot Status: {text}')

    def on_forward(self):
        self.ros_node.publish_cmd_vel(0.2, 0.0)

    def on_stop(self):
        self.ros_node.publish_cmd_vel(0.0, 0.0)

    def on_left(self):
        self.ros_node.publish_cmd_vel(0.0, 0.5)

    def on_right(self):
        self.ros_node.publish_cmd_vel(0.0, -0.5)


def main():
    rclpy.init()

    app = QApplication(sys.argv)

    signals = RosSignals()
    ros_node = GuiRosNode(signals)

    ros_thread = threading.Thread(
        target=rclpy.spin,
        args=(ros_node,),
        daemon=True
    )
    ros_thread.start()

    window = MainWindow(ros_node, signals)
    window.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == '__main__':
    main()

1) RosSignals 클래스 설명

class RosSignals(QObject):
    status_received = pyqtSignal(str)

RosSignals 클래스는 ROS 2와 PyQt5 사이에서 데이터를 안전하게 전달하기 위한 클래스입니다.

ROS 2 subscriber callback은 ROS 2 spin 스레드에서 실행될 수 있습니다. 그런데 PyQt5 GUI 위젯은 GUI 메인 스레드에서 수정하는 것이 안전합니다.

즉, ROS 2 callback 안에서 직접 QLabel의 텍스트를 바꾸는 방식은 좋지 않습니다.

그래서 이 코드에서는 Qt 시그널을 사용합니다.

status_received = pyqtSignal(str)

이 코드는 문자열 하나를 전달할 수 있는 시그널을 정의합니다.

나중에 /robot_status 메시지를 받으면 이 시그널을 통해 GUI 쪽으로 문자열을 넘깁니다.

2) GuiRosNode 클래스 설명

class GuiRosNode(Node):
    def __init__(self, signals):
        super().__init__('pyqt_ui_file_gui_node')

GuiRosNode 클래스는 ROS 2 노드 역할을 합니다.

Node 클래스를 상속받고 있으며, 노드 이름은 다음과 같습니다.

'pyqt_ui_file_gui_node'

이 노드는 두 가지 일을 합니다.

첫 번째는 /cmd_vel 토픽으로 로봇 제어 명령을 발행하는 것입니다.

두 번째는 /robot_status 토픽을 구독해서 로봇 상태 메시지를 받는 것입니다.

생성자에서 signals 객체를 전달받는 이유는 ROS 2 callback에서 받은 데이터를 PyQt5 GUI로 전달하기 위해서입니다.

self.signals = signals

이렇게 저장해 두면 subscriber callback 안에서 다음과 같이 시그널을 발생시킬 수 있습니다.

self.signals.status_received.emit(msg.data)

3) /cmd_vel Publisher 생성

self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)

이 코드는 /cmd_vel 토픽으로 Twist 메시지를 발행하는 publisher를 생성합니다.

/cmd_vel은 ROS 이동 로봇에서 자주 사용하는 속도 명령 토픽입니다.

Twist 메시지는 크게 두 가지 속도 정보를 가집니다.

linear
angular

linear.x는 전진 또는 후진 속도입니다.

angular.z는 좌회전 또는 우회전 속도입니다.

예를 들어 linear.x = 0.2이고 angular.z = 0.0이면 로봇은 앞으로 이동합니다.

반대로 linear.x = 0.0이고 angular.z = 0.5이면 로봇은 제자리에서 왼쪽으로 회전합니다.

마지막 인자인 10은 QoS queue size입니다. 메시지를 임시로 저장할 큐 크기라고 보면 됩니다.

4) /robot_status Subscriber 생성

self.status_sub = self.create_subscription(
    String,
    '/robot_status',
    self.status_callback,
    10
)

이 코드는 /robot_status 토픽을 구독하는 subscriber를 생성합니다.

메시지 타입은 String입니다.

즉, 다른 ROS 2 노드가 /robot_status 토픽으로 문자열 메시지를 보내면 이 노드가 그 메시지를 받을 수 있습니다.

예를 들어 다음과 같은 상태 메시지를 받을 수 있습니다.

waiting
moving
stopped
error
battery low

메시지가 들어오면 self.status_callback 함수가 자동으로 호출됩니다.

5) status_callback 함수 설명

def status_callback(self, msg):
    self.signals.status_received.emit(msg.data)

이 함수는 /robot_status 토픽에서 메시지를 받을 때 실행됩니다.

msg.data에는 실제 문자열 데이터가 들어 있습니다.

예를 들어 /robot_status로 다음 메시지가 들어왔다고 가정하겠습니다.

moving

그러면 msg.data 값은 "moving"이 됩니다.

이 함수는 GUI 라벨을 직접 수정하지 않고, Qt 시그널을 발생시킵니다.

self.signals.status_received.emit(msg.data)

이 방식이 중요한 이유는 스레드 안전성 때문입니다.

ROS 2 callback은 별도 스레드에서 실행될 수 있고, PyQt5 GUI는 메인 스레드에서 동작합니다. 따라서 GUI 위젯을 직접 수정하지 않고 시그널을 통해 전달하는 구조가 안정적입니다.

6) publish_cmd_vel 함수 설명

def publish_cmd_vel(self, linear_x, angular_z):
    msg = Twist()
    msg.linear.x = linear_x
    msg.angular.z = angular_z
    self.cmd_vel_pub.publish(msg)

이 함수는 로봇 이동 명령을 /cmd_vel 토픽으로 발행합니다.

함수 인자는 두 개입니다.

linear_x는 전진 또는 후진 속도입니다.

angular_z는 회전 속도입니다.

먼저 Twist 메시지 객체를 생성합니다.

msg = Twist()

그 다음 선속도와 각속도를 설정합니다.

msg.linear.x = linear_x
msg.angular.z = angular_z

마지막으로 publisher를 통해 메시지를 발행합니다.

self.cmd_vel_pub.publish(msg)

이 함수 덕분에 GUI 버튼에서는 복잡한 ROS 2 메시지 생성 과정을 몰라도 됩니다. 버튼 함수에서는 단순히 publish_cmd_vel()만 호출하면 됩니다.

7) MainWindow 클래스 설명

class MainWindow(QMainWindow):
    def __init__(self, ros_node, signals):
        super().__init__()

MainWindow 클래스는 PyQt5 GUI의 메인 창입니다.

이전 방식처럼 Python 코드 안에서 QPushButton, QLabel, QVBoxLayout 등을 직접 생성하지 않습니다.

대신 Qt Designer에서 만든 simple_gui.ui 파일을 불러옵니다.

이 방식의 장점은 GUI 디자인과 동작 코드를 분리할 수 있다는 점입니다.

디자인 변경이 필요할 때 Python 코드를 많이 수정하지 않아도 됩니다. Qt Designer에서 버튼 위치나 라벨 배치만 수정하면 됩니다.

8) ros_node와 signals 저장

self.ros_node = ros_node
self.signals = signals

MainWindow는 ROS 2 노드와 Qt 시그널 객체를 전달받아 내부 변수로 저장합니다.

self.ros_node는 버튼 클릭 시 /cmd_vel 명령을 발행하는 데 사용됩니다.

self.signals/robot_status 메시지를 받아 GUI 라벨을 업데이트하는 데 사용됩니다.

즉, MainWindow는 화면을 담당하지만, ROS 2 통신 기능은 GuiRosNode 객체를 통해 처리합니다.

이렇게 역할을 나누는 것이 좋습니다.

9) UI 파일 경로 생성

ui_path = os.path.join(
    os.path.dirname(__file__),
    'simple_gui.ui'
)

이 코드는 simple_gui.ui 파일의 경로를 생성합니다.

os.path.dirname(__file__)는 현재 Python 파일이 있는 폴더 경로를 의미합니다.

여기에 'simple_gui.ui'를 붙여서 UI 파일의 전체 경로를 만듭니다.

예를 들어 Python 파일과 UI 파일이 다음처럼 같은 폴더에 있다고 가정하겠습니다.

ros2_pyqt_gui/
├── pyqt_ui_gui.py
└── simple_gui.ui

그러면 ui_path는 현재 Python 파일과 같은 위치에 있는 simple_gui.ui 파일을 가리키게 됩니다.

이 방식을 사용하면 프로그램을 다른 위치에서 실행해도 .ui 파일을 안정적으로 찾을 수 있습니다.

단순히 다음과 같이 작성하면 실행 위치에 따라 파일을 못 찾을 수 있습니다.

uic.loadUi('simple_gui.ui', self)

그래서 현재 파일 기준 경로를 사용하는 것이 더 안전합니다.

10) uic.loadUi로 UI 파일 불러오기

uic.loadUi(ui_path, self)

이 코드는 Qt Designer에서 만든 simple_gui.ui 파일을 현재 MainWindow 객체에 로드합니다.

즉, .ui 파일 안에 정의된 버튼, 라벨, 창 구조가 Python 객체로 연결됩니다.

이 코드가 실행된 후에는 .ui 파일 안에 있는 위젯 이름을 Python 코드에서 바로 사용할 수 있습니다.

예를 들어 .ui 파일에 다음 objectName을 가진 버튼들이 있어야 합니다.

forward_button
stop_button
left_button
right_button

그리고 상태 표시용 라벨은 다음 objectName을 가져야 합니다.

status_label

이 이름들이 코드와 정확히 일치해야 합니다.

만약 Qt Designer에서 버튼 이름을 pushButton으로 해놓고 Python 코드에서 self.forward_button을 사용하면 오류가 발생합니다.

따라서 .ui 파일을 만들 때 objectName 설정이 매우 중요합니다.

11) simple_gui.ui 파일에 필요한 위젯 이름

이 코드가 정상 동작하려면 simple_gui.ui 파일 안에 최소한 다음 위젯들이 있어야 합니다.

status_label
forward_button
stop_button
left_button
right_button

각 위젯의 타입은 일반적으로 다음과 같이 구성하면 됩니다.

status_label: QLabel
forward_button: QPushButton
stop_button: QPushButton
left_button: QPushButton
right_button: QPushButton

Qt Designer에서 버튼을 만든 뒤 오른쪽 속성 창에서 objectName을 정확히 설정해야 합니다.

예를 들어 전진 버튼은 표시 텍스트가 Forward일 수 있지만, objectName은 반드시 forward_button이어야 합니다.

표시 텍스트와 objectName은 다릅니다.

표시 텍스트는 사용자가 화면에서 보는 이름입니다.

objectName은 Python 코드에서 해당 위젯에 접근할 때 사용하는 이름입니다.

12) 버튼 클릭 이벤트 연결

self.forward_button.clicked.connect(self.on_forward)
self.stop_button.clicked.connect(self.on_stop)
self.left_button.clicked.connect(self.on_left)
self.right_button.clicked.connect(self.on_right)

이 부분은 UI 파일에서 불러온 버튼과 Python 함수를 연결하는 코드입니다.

forward_button을 클릭하면 on_forward() 함수가 실행됩니다.

stop_button을 클릭하면 on_stop() 함수가 실행됩니다.

left_button을 클릭하면 on_left() 함수가 실행됩니다.

right_button을 클릭하면 on_right() 함수가 실행됩니다.

이 방식은 PyQt5의 signal-slot 구조입니다.

버튼은 클릭될 때 clicked 시그널을 발생시키고, connect()로 연결된 함수가 실행됩니다.

13) 상태 수신 시그널 연결

self.signals.status_received.connect(self.update_status)

이 코드는 ROS 2에서 받은 상태 메시지를 GUI 라벨 업데이트 함수와 연결합니다.

ROS 2 subscriber callback에서 다음 코드가 실행되면,

self.signals.status_received.emit(msg.data)

PyQt5 쪽에서는 update_status() 함수가 실행됩니다.

이 구조 덕분에 ROS 2 callback에서 GUI 위젯을 직접 수정하지 않아도 됩니다.

스레드가 분리된 구조에서는 이런 방식이 안전합니다.

14) update_status 함수 설명

def update_status(self, text):
    self.status_label.setText(f'Robot Status: {text}')

update_status() 함수는 GUI의 상태 라벨을 업데이트합니다.

인자로 받은 text를 이용해서 다음과 같은 형식으로 화면에 표시합니다.

Robot Status: moving

예를 들어 /robot_status 토픽으로 "stopped"라는 메시지가 들어오면 라벨은 다음과 같이 표시됩니다.

Robot Status: stopped

이 함수는 Qt 시그널에 의해 호출되므로 GUI 메인 스레드에서 안전하게 실행됩니다.

15) Forward 버튼 동작

def on_forward(self):
    self.ros_node.publish_cmd_vel(0.2, 0.0)

Forward 버튼을 누르면 이 함수가 실행됩니다.

이 함수는 ROS 2 노드의 publish_cmd_vel() 함수를 호출합니다.

전달되는 값은 다음과 같습니다.

linear_x = 0.2
angular_z = 0.0

즉, 로봇에게 앞으로 이동하라는 명령을 보냅니다.

linear.x가 양수이고 angular.z가 0이므로 직진 명령입니다.

16) Stop 버튼 동작

def on_stop(self):
    self.ros_node.publish_cmd_vel(0.0, 0.0)

Stop 버튼을 누르면 정지 명령을 발행합니다.

전진 속도와 회전 속도를 모두 0으로 설정합니다.

linear_x = 0.0
angular_z = 0.0

실제 로봇 GUI에서는 정지 버튼이 매우 중요합니다.

로봇이 움직이는 장비라면 정지 버튼은 항상 눈에 잘 보이고 누르기 쉬운 위치에 배치하는 것이 좋습니다.

17) Left 버튼 동작

def on_left(self):
    self.ros_node.publish_cmd_vel(0.0, 0.5)

Left 버튼을 누르면 좌회전 명령을 발행합니다.

전진 속도는 0이고, 회전 속도는 양수입니다.

linear_x = 0.0
angular_z = 0.5

일반적인 ROS 이동 로봇 좌표계에서는 angular.z가 양수이면 왼쪽 회전을 의미합니다.

따라서 이 명령은 로봇을 제자리에서 왼쪽으로 돌리는 명령입니다.

18) Right 버튼 동작

def on_right(self):
    self.ros_node.publish_cmd_vel(0.0, -0.5)

Right 버튼을 누르면 우회전 명령을 발행합니다.

전진 속도는 0이고, 회전 속도는 음수입니다.

linear_x = 0.0
angular_z = -0.5

angular.z가 음수이므로 로봇은 오른쪽 방향으로 회전합니다.

좌회전과 우회전은 회전 속도 값의 부호만 다릅니다.

19) main 함수 시작

def main():
    rclpy.init()

main() 함수는 프로그램의 시작점입니다.

가장 먼저 rclpy.init()을 호출해서 ROS 2 Python 클라이언트 라이브러리를 초기화합니다.

ROS 2 노드를 만들기 전에 반드시 이 초기화 과정이 필요합니다.

20) QApplication 생성

app = QApplication(sys.argv)

이 코드는 PyQt5 애플리케이션 객체를 생성합니다.

PyQt5 GUI 프로그램에서는 QApplication 객체가 반드시 필요합니다.

이 객체는 GUI 이벤트 루프를 관리합니다.

버튼 클릭, 창 닫기, 화면 갱신 같은 이벤트가 모두 이 객체를 통해 처리됩니다.

21) RosSignals와 GuiRosNode 생성

signals = RosSignals()
ros_node = GuiRosNode(signals)

먼저 RosSignals 객체를 생성합니다.

이 객체는 ROS 2 callback과 GUI 사이의 연결 통로 역할을 합니다.

그 다음 GuiRosNode 객체를 생성합니다.

이때 signals 객체를 전달합니다.

이렇게 하면 ROS 2 노드가 /robot_status 메시지를 받았을 때 GUI로 안전하게 전달할 수 있습니다.

22) ROS 2 spin을 별도 스레드에서 실행

ros_thread = threading.Thread(
    target=rclpy.spin,
    args=(ros_node,),
    daemon=True
)
ros_thread.start()

이 부분은 ROS 2와 PyQt5를 함께 사용할 때 핵심입니다.

ROS 2는 subscriber callback을 처리하기 위해 rclpy.spin()을 실행해야 합니다.

하지만 PyQt5도 app.exec_()라는 GUI 이벤트 루프를 실행해야 합니다.

두 함수는 모두 계속 실행되는 구조입니다.

그래서 하나의 스레드에서 둘을 동시에 실행하기 어렵습니다.

이 예제에서는 ROS 2 spin을 별도 스레드에서 실행합니다.

target=rclpy.spin

실행할 함수는 rclpy.spin입니다.

args=(ros_node,)

spin할 대상 노드는 ros_node입니다.

daemon=True

daemon 스레드로 설정했기 때문에 메인 프로그램이 종료될 때 같이 종료될 수 있습니다.

ros_thread.start()

이 코드가 실행되면 ROS 2 노드는 백그라운드 스레드에서 subscriber callback을 처리할 수 있게 됩니다.

23) MainWindow 생성과 표시

window = MainWindow(ros_node, signals)
window.show()

MainWindow 객체를 생성합니다.

여기에도 ros_nodesignals를 전달합니다.

MainWindow는 이 객체들을 이용해서 버튼 클릭 시 ROS 2 명령을 보내고, 상태 메시지를 GUI에 표시합니다.

window.show()는 실제 GUI 창을 화면에 표시합니다.

24) PyQt5 이벤트 루프 실행

exit_code = app.exec_()

이 코드는 PyQt5 GUI 이벤트 루프를 실행합니다.

사용자가 창을 닫기 전까지 프로그램은 이 상태로 계속 실행됩니다.

버튼 클릭이나 창 갱신 같은 GUI 이벤트는 이 이벤트 루프에서 처리됩니다.

창을 닫으면 app.exec_()가 종료되고, 종료 코드가 exit_code 변수에 저장됩니다.

25) 종료 처리

ros_node.destroy_node()
rclpy.shutdown()

sys.exit(exit_code)

GUI 창이 닫히면 ROS 2 노드를 정리합니다.

ros_node.destroy_node()

그 다음 ROS 2 시스템을 종료합니다.

rclpy.shutdown()

마지막으로 PyQt5 애플리케이션 종료 코드를 사용해 프로그램을 종료합니다.

sys.exit(exit_code)

이 종료 처리는 중요합니다.

ROS 2 노드를 제대로 정리하지 않으면 프로그램 종료 시 경고가 발생하거나 리소스가 남을 수 있습니다.

26) .ui 파일 설치 설정

setup.py에서 .ui 파일이 install 폴더에 포함되도록 설정합니다.

from setuptools import setup
import os
from glob import glob

package_name = 'pyqt_ros2_gui'

setup(
    name=package_name,
    version='0.0.0',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        (os.path.join('lib/python3.10/site-packages', package_name),
            glob(package_name + '/*.ui')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='your_name',
    maintainer_email='your_email@example.com',
    description='PyQt ROS2 GUI example',
    license='MIT',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'direct_widget_gui = pyqt_ros2_gui.direct_widget_gui:main',
            'ui_file_gui = pyqt_ros2_gui.ui_file_gui:main',
        ],
    },
)

ROS2 배포판이나 Python 버전에 따라 python3.10 부분은 달라질 수 있습니다.
Ubuntu 22.04 + ROS2 Humble이면 보통 Python 3.10입니다.

실행합니다.

cd ~/ros2_ws
colcon build --packages-select pyqt_ros2_gui
source install/setup.bash
ros2 run pyqt_ros2_gui ui_file_gui

5. .ui 파일을 Python 코드로 변환하는 방법

.ui 파일을 실행 중에 불러오는 방법도 있지만, Python 코드로 변환해서 사용할 수도 있습니다.

pyuic5 simple_gui.ui -o simple_gui_ui.py

그러면 다음처럼 사용할 수 있습니다.

from PyQt5.QtWidgets import QMainWindow
from .simple_gui_ui import Ui_MainWindow


class MainWindow(QMainWindow, Ui_MainWindow):
    def __init__(self):
        super().__init__()
        self.setupUi(self)

두 방식 비교

방식특징
uic.loadUi()실행 시 .ui 파일을 직접 읽음
pyuic5 변환.ui 파일을 .py 파일로 변환 후 import
uic.loadUiType().ui에서 form class를 얻어 상속 구조로 사용

실무에서는 uic.loadUi() 또는 uic.loadUiType() 방식이 편합니다.
화면 수정 후 Python 코드를 다시 생성하지 않아도 되기 때문입니다.

6. ROS2 콜백에서 GUI를 직접 수정하면 안 되는 이유

다음 코드는 피하는 것이 좋습니다.

def status_callback(self, msg):
    self.status_label.setText(msg.data)

이유는 ROS2 콜백이 PyQt 메인 스레드가 아닌 다른 스레드에서 실행될 수 있기 때문입니다.
Qt 위젯은 기본적으로 GUI 메인 스레드에서만 안전하게 수정해야 합니다.

올바른 구조는 다음과 같습니다.

def status_callback(self, msg):
    self.signals.status_received.emit(msg.data)

그리고 PyQt 쪽에서 signal을 받아 위젯을 수정합니다.

self.signals.status_received.connect(self.update_status)

def update_status(self, text):
    self.status_label.setText(text)

이 구조가 안정적입니다.

Leave a Comment