ROS 2 TurtleBot3 카메라 영상을 PyQt5 GUI로 표시하고 동영상으로 저장하기 #2

28) 동영상 저장 코드를 write_video_frame()으로 분리

동영상 저장 기능을 별도 메서드로 분리했습니다.

if (
    self.recording_enabled
    and self.video_writer is not None
):
    self.write_video_frame(
        frame
    )

새 메서드는 다음과 같습니다.

def write_video_frame(self, frame):

이렇게 분리한 이유는 image_callback()의 역할을 명확하게 만들기 위해서입니다.

image_callback()
 ├─ 메시지 데이터 변환
 ├─ OpenCV 디코딩
 ├─ 마지막 프레임 갱신
 ├─ 녹화 함수 호출
 └─ 화면 표시 함수 호출

동영상 크기 확인, 리사이즈, 파일 쓰기, 저장 오류 처리는 write_video_frame()이 담당합니다.

write_video_frame()
 ├─ VideoWriter 상태 확인
 ├─ 영상 크기 확인
 ├─ 필요 시 크기 변경
 ├─ 프레임 저장
 └─ OpenCV 오류 처리

기능을 메서드 단위로 분리하면 다음 장점이 있습니다.

  • 콜백 코드가 짧아짐
  • 동영상 저장 오류를 별도로 처리할 수 있음
  • 추후 녹화 관련 기능 확장이 쉬움
  • 단위 테스트가 쉬워짐
  • 화면 표시와 파일 저장 책임이 분리됨

29) 화면 표시 조건

if self.camera_display_enabled:
    self.show_frame(frame)

카메라 화면 표시 상태가 활성화된 경우에만 show_frame()을 호출합니다.

카메라 토픽 수신과 화면 표시를 분리했기 때문에 화면 표시를 중지해도 다음 작업은 계속됩니다.

  1. ROS 2 메시지 수신
  2. 압축 이미지 디코딩
  3. 최근 프레임 갱신
  4. 동영상 녹화

즉 Stop Camera는 카메라 구독 중지가 아니라 화면 갱신 중지입니다.

30) 동영상 저장 전에 객체 상태 재확인

write_video_frame() 시작 부분에서 다음을 확인합니다.

if (
    self.video_writer is None
    or self.video_size is None
):
    return

호출하는 쪽에서도 이미 검사합니다.

if (
    self.recording_enabled
    and self.video_writer is not None
):

그런데 저장 함수 내부에서도 다시 확인합니다.

이는 중복 코드라기보다 방어적 프로그래밍입니다.

향후 다른 메서드에서 write_video_frame()을 직접 호출할 수도 있고, 녹화 종료와 프레임 수신 타이밍이 겹칠 수도 있습니다.

다음 상태가 순간적으로 발생할 가능성을 고려한 것입니다.

recording_enabled = True
video_writer = None

또는:

video_writer는 존재
video_size = None

정상 흐름에서는 발생하지 않아야 하지만, 자원 초기화나 예외 처리 중에는 상태가 완전히 동시에 변경되지 않을 수 있습니다.

함수 내부에서 필요한 조건을 직접 검사하면 잘못된 상태에서도 안전하게 반환할 수 있습니다.

31) 영상 크기 순서에 주의해야 하는 이유

height, width = frame.shape[:2]

OpenCV 이미지의 shape 순서는 다음과 같습니다.

높이, 너비, 채널

하지만 VideoWritercv2.resize()에서 사용하는 크기 순서는 다음과 같습니다.

너비, 높이

따라서 비교할 때 다음과 같이 작성합니다.

(width, height)

잘못된 예:

(height, width)

640×480 영상에서 잘못된 순서를 사용하면 480×640 크기로 처리되어 영상이 회전한 것처럼 보이거나 저장이 실패할 수 있습니다.

32) 녹화 해상도 검사

if (width, height) == self.video_size:

VideoWriter는 생성 시 지정한 해상도와 같은 크기의 프레임을 받아야 합니다.

녹화를 시작할 때 다음 코드로 저장 크기를 정합니다.

height, width = self.last_frame.shape[:2]

self.video_size = (
    width,
    height
)

녹화 도중 카메라 해상도가 변경되거나 다른 크기의 프레임이 들어올 수 있으므로 현재 프레임 크기를 확인합니다.

크기가 다르면 다음 코드로 맞춥니다.

write_frame = cv2.resize(
    write_frame,
    self.video_size
)

이 처리는 동영상 파일 손상을 방지하기 위한 안전장치입니다.

일반적으로 camera.launch.py 실행 중 해상도를 변경하지 않는다면 자주 실행되지 않습니다.

33) 크기 변경 시 INTER_AREA 보간법 사용

아래와 같이 보간법을 명시했습니다.

write_frame = cv2.resize(
    frame,
    self.video_size,
    interpolation=cv2.INTER_AREA
)

OpenCV의 resize()는 영상을 확대하거나 축소할 때 픽셀 값을 어떻게 계산할지 결정하는 보간법을 사용합니다.

INTER_AREA는 일반적으로 영상을 축소할 때 품질이 좋은 방식입니다.

카메라 해상도가 녹화 시작 시점보다 커졌다면 저장 해상도로 축소해야 합니다.

예를 들어:

현재 프레임 : 1280 × 720
녹화 크기   : 640 × 480

이때 INTER_AREA는 여러 원본 픽셀 영역을 기반으로 새로운 픽셀을 계산하므로 단순 최근접 방식보다 계단 현상과 노이즈를 줄이는 데 유리합니다.

다만 현재 구조는 원본 비율이 달라져도 지정 크기로 강제 변경합니다.

16:9 영상 → 4:3 저장 크기

이 경우 영상이 가로 또는 세로 방향으로 찌그러질 수 있습니다.

실제 제품 수준에서는 다음 방식 중 하나를 추가로 고려해야 합니다.

  • 원본 비율을 유지하며 검은 여백 추가
  • 중앙 부분만 잘라내기
  • 해상도 변경 시 새로운 동영상 파일 시작
  • 카메라 해상도 변경을 허용하지 않기

34) 동영상 프레임 저장

self.video_writer.write(
    write_frame
)

현재 프레임 한 장을 동영상 파일에 기록합니다.

카메라 메시지가 도착할 때마다 이 코드가 반복 실행되면서 동영상이 만들어집니다.

예를 들어 카메라 메시지가 초당 30번 도착하면 초당 약 30장의 이미지가 저장됩니다.

다만 VideoWriter에 지정한 FPS와 실제 메시지 수신 FPS가 다르면 재생 속도가 달라질 수 있습니다.

예를 들어 실제 수신 속도가 15 FPS인데 저장 FPS를 30으로 지정하면 동영상이 실제보다 빠르게 재생될 수 있습니다.

35) 동영상 프레임 쓰기 실패 시 녹화 자동 종료

write_video_frame()에는 다음 예외 처리가 적용니다.

except cv2.error as exception:
    self.node.get_logger().error(
        f'동영상 프레임 저장 오류: {exception}'
    )

    self.labelStatus.setText(
        '동영상 프레임 저장 오류'
    )

    self.stop_recording()

VideoWriter.write()는 다음 문제로 실패할 수 있습니다.

  • 디스크 공간 부족
  • 프레임 크기 불일치
  • 코덱 상태 이상
  • 저장 장치 오류
  • 비정상적인 프레임 데이터
  • 내부 OpenCV 오류

기존 코드에서는 저장 중 오류가 발생하면 콜백 예외로 인해 GUI 전체가 종료될 가능성이 있었습니다.

수정 코드에서는 오류 로그를 출력하고 현재 녹화만 안전하게 종료합니다.

프레임 저장 오류
    ↓
오류 로그 출력
    ↓
GUI 상태 메시지 갱신
    ↓
VideoWriter release
    ↓
녹화 버튼 상태 복구
    ↓
GUI 프로그램은 계속 실행

카메라 화면 표시 기능은 계속 사용할 수 있으므로 하나의 기능 오류가 프로그램 전체 장애로 이어지지 않습니다.

36) show_frame 함수의 역할

def show_frame(self, frame):

OpenCV 프레임을 PyQt5의 QLabel에 표시 가능한 형식으로 변환합니다.

처리 과정은 다음과 같습니다.

OpenCV BGR 영상
        ↓
RGB 영상으로 변환
        ↓
QImage 생성
        ↓
QPixmap 변환
        ↓
QLabel 크기에 맞게 조정
        ↓
QLabel에 표시

37) show_frame() 입력 영상 유효성 검사

show_frame() 함수 시작 부분에 다음 검사가 수행됩니다.

if (
    frame is None
    or frame.size == 0
):
    return

호출하는 쪽에서 이미 디코딩 실패를 확인하지만, show_frame() 자체도 유효하지 않은 입력을 방어합니다.

if frame is None:
    return

frame.size == 0은 NumPy 배열 객체는 존재하지만 내부 픽셀 데이터가 없는 경우를 검사합니다.

함수 내부에서 입력 조건을 확인하는 이유는 향후 다음과 같이 다른 위치에서 호출될 수 있기 때문입니다.

self.show_frame(
    processed_frame
)

영상 처리 알고리즘이 비어 있는 배열을 반환하더라도 cvtColor() 호출 전에 안전하게 중단할 수 있습니다.

38) cvtColor() 예외 처리

기존 코드는 색상 변환 오류를 처리하지 않았습니다.

rgb_frame = cv2.cvtColor(
    frame,
    cv2.COLOR_BGR2RGB
)

수정 코드에서는 다음과 같이 변경했습니다.

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

cv2.cvtColor()는 입력 영상의 채널 구성이 예상과 다를 때 오류가 발생할 수 있습니다.

현재 imdecode(..., cv2.IMREAD_COLOR)는 정상적으로 디코딩되면 3채널 BGR 영상을 반환하므로 일반적으로 문제없습니다.

하지만 향후 다음 상황이 생길 수 있습니다.

  • 흑백 영상으로 처리 방식 변경
  • BGRA 4채널 영상 입력
  • 전처리 함수가 1채널 배열 반환
  • 잘못된 차원의 NumPy 배열 전달

이 경우 OpenCV 오류를 처리하지 않으면 Qt 이벤트 루프까지 예외가 전달될 수 있습니다.

39) BGR 영상을 RGB로 변환

rgb_frame = cv2.cvtColor(
    frame,
    cv2.COLOR_BGR2RGB
)

OpenCV는 기본적으로 BGR 채널 순서를 사용합니다.

PyQt5의 QImage.Format_RGB888은 RGB 순서를 사용합니다.

변환하지 않으면 빨간색과 파란색이 서로 바뀌어 표시됩니다.

예를 들어 빨간 물체가 파란색으로 보일 수 있습니다.

따라서 QLabel에 표시하기 전에 채널 순서를 바꿉니다.

40) QImage 생성에 필요한 정보

height, width, channels = rgb_frame.shape

RGB 영상은 채널이 3개입니다.

Red
Green
Blue

다음 코드로 한 줄이 차지하는 바이트 수를 계산합니다.

bytes_per_line = channels * width

RGB 영상은 픽셀 하나가 3바이트이므로 640픽셀 영상에서는 다음과 같습니다.

3 × 640 = 1920바이트

이 값은 QImage가 메모리에서 한 줄씩 이동할 때 필요합니다.

41) QImage 생성 시 copy를 사용하는 이유

q_image = QImage(
    rgb_frame.data,
    width,
    height,
    bytes_per_line,
    QImage.Format_RGB888
).copy()

QImage는 NumPy 배열의 메모리를 직접 참조할 수 있습니다.

하지만 rgb_frame은 함수가 종료되면 사라질 수 있는 지역 변수입니다.

QImage가 해당 메모리를 계속 참조하면 다음 문제가 발생할 수 있습니다.

  1. 화면이 깨집니다.
  2. 영상 일부가 검게 표시됩니다.
  3. 예상하지 못한 메모리 값이 표시됩니다.
  4. 간헐적으로 프로그램이 종료될 수 있습니다.

.copy()를 사용하면 QImage가 자체 메모리를 확보합니다.

.copy()

교육용 프로그램에서는 약간의 복사 비용보다 안정성이 더 중요하므로 사용하는 것이 좋습니다.

42) QImage를 QPixmap으로 변환하는 이유

pixmap = QPixmap.fromImage(
    q_image
)

QImage는 주로 이미지 데이터 처리와 픽셀 접근에 적합한 객체입니다.

반면 QLabel 같은 GUI 위젯에 이미지를 표시할 때는 QPixmap이 주로 사용됩니다.

따라서 처리 흐름은 다음과 같습니다.

ROS 2 CompressedImage
    ↓
NumPy 압축 데이터
    ↓
OpenCV BGR 이미지
    ↓
OpenCV RGB 이미지
    ↓
QImage
    ↓
QPixmap
    ↓
QLabel

마지막으로 다음 코드로 라벨에 영상을 출력합니다.

self.labelImage.setPixmap(
    pixmap
)

43) QLabel 크기에 맞게 영상 조절

pixmap = pixmap.scaled(
    self.labelImage.size(),
    Qt.KeepAspectRatio,
    Qt.SmoothTransformation
)

첫 번째 인수는 QLabel의 현재 크기입니다.

self.labelImage.size()

두 번째 인수는 원본 비율 유지 옵션입니다.

Qt.KeepAspectRatio

이 옵션을 사용하지 않으면 창 크기에 따라 영상이 가로 또는 세로로 늘어날 수 있습니다.

세 번째 인수는 부드러운 크기 변환 옵션입니다.

Qt.SmoothTransformation

단순한 빠른 확대·축소보다 품질이 좋지만 약간의 연산량이 추가됩니다.

최종 영상은 다음 코드로 표시합니다.

self.labelImage.setPixmap(
    pixmap
)

44) 카메라 시작 버튼은 Subscriber를 생성하지 않는다

def start_camera(self):
    self.camera_display_enabled = True

Start Camera 버튼을 누르면 카메라 Subscriber를 새로 생성할 것처럼 보일 수 있지만 실제로는 그렇지 않습니다.

Subscriber는 생성자에서 이미 만들어졌습니다.

self.subscription = self.node.create_subscription(...)

start_camera()가 하는 일은 화면 표시 플래그를 활성화하는 것입니다.

self.camera_display_enabled = True

이 방식의 장점은 버튼을 누를 때마다 Subscriber를 생성하거나 제거하지 않아도 된다는 점입니다.

Subscriber를 반복 생성할 경우 다음 문제를 관리해야 합니다.

  • 기존 Subscriber 제거
  • 중복 콜백 방지
  • ROS 2 객체 생명주기 관리
  • 토픽 재연결 지연
  • QoS 협상 재처리

현재 구조에서는 ROS 2 영상 수신을 계속 유지하고 GUI 표시만 제어하므로 구현이 단순하고 안정적입니다.

45) 화면 정지와 카메라 수신 정지의 차이

def stop_camera(self):
    self.camera_display_enabled = False

이 함수는 카메라 장치를 정지시키지 않습니다.

또한 ROS 2 토픽 구독도 해제하지 않습니다.

단지 다음 코드가 실행되지 않도록 만듭니다.

if self.camera_display_enabled:
    self.show_frame(frame)

따라서 함수 이름인 stop_camera()는 실제 동작보다 넓은 의미를 가질 수 있습니다.

실제로는 다음 이름이 더 정확합니다.

stop_camera_display()

또는 다음과 같이 표현할 수 있습니다.

disable_preview()

현재 코드도 동작에는 문제가 없지만, 프로젝트 규모가 커질수록 함수 이름과 실제 동작을 일치시키는 것이 유지보수에 유리합니다.

특히 카메라 장치 자체의 전원이나 스트리밍을 제어하는 서비스가 추가될 경우, stop_camera()라는 이름이 혼동을 만들 수 있습니다.

46) labelImage 중앙 정렬

출력 메세지를 레이블 가운데에 배치합니다.

self.labelImage.setAlignment(
    Qt.AlignCenter
)

카메라 화면을 시작하기 전이나 중지한 후 QLabel에는 안내 문구가 표시됩니다.

Start Camera 버튼을 누르세요.

또는:

카메라 화면 표시가 중지되었습니다.

정렬을 지정하지 않으면 Qt Designer에서 설정한 값이나 QLabel 기본 정렬에 따라 문구가 왼쪽 또는 상단에 붙어 보일 수 있습니다.

Qt.AlignCenter를 사용하면 영상이 없을 때 안내 메시지가 라벨 가운데에 표시됩니다.

카메라 중지 시에도 clear() 호출 후 정렬을 다시 설정했습니다.

self.labelImage.clear()

self.labelImage.setAlignment(
    Qt.AlignCenter
)

clear()는 기존 Pixmap이나 텍스트를 제거합니다. 정렬 속성은 일반적으로 유지되지만, 코드의 의도를 명확히 하고 UI 설정 변경의 영향을 줄이기 위해 다시 지정했습니다.

47) QLabel 크기가 0인지 확인

크기를 먼저 가져옵니다.

label_size = self.labelImage.size()

그다음 너비와 높이가 모두 0보다 큰 경우에만 크기를 조정합니다.

if (
    label_size.width() > 0
    and label_size.height() > 0
):
    pixmap = pixmap.scaled(
        label_size,
        Qt.KeepAspectRatio,
        Qt.SmoothTransformation
    )

GUI 초기화 직후, 레이아웃 계산 전, 창 최소화 과정 등에서는 QLabel 크기가 일시적으로 0이 될 수 있습니다.

0×0 크기로 Pixmap을 조정하면 빈 Pixmap이 생성되거나 Qt 경고가 나타날 수 있습니다.

라벨 크기가 유효하지 않으면 원본 크기의 Pixmap을 그대로 설정하도록 한 것입니다.

48) 녹화를 시작하기 전에 프레임 존재 여부 확인하기

if self.last_frame is None:
    self.labelStatus.setText(
        '저장할 카메라 프레임이 없습니다. '
        '카메라 토픽을 확인하세요.'
    )
    return

OpenCV의 VideoWriter는 생성할 때 영상 크기가 필요합니다.

self.video_writer = cv2.VideoWriter(
    str(self.video_path),
    fourcc,
    self.video_fps,
    self.video_size
)

하지만 프로그램을 실행한 직후에는 아직 카메라 영상의 크기를 알 수 없습니다.

카메라 해상도를 코드에 고정할 수도 있습니다.

video_size = (640, 480)

그러나 실제 토픽 영상이 1280×720일 수도 있고, 카메라 설정에 따라 크기가 바뀔 수도 있습니다.

따라서 첫 번째 프레임을 수신한 뒤 실제 해상도를 사용합니다.

height, width = self.last_frame.shape[:2]

self.video_size = (
    width,
    height
)

OpenCV 이미지 배열은 높이와 너비 순서입니다.

frame.shape
# height, width, channels

반면 VideoWriter에 전달하는 크기는 너비와 높이 순서입니다.

(width, height)

이 순서를 반대로 입력하면 동영상 생성이 실패하거나 저장 결과가 비정상적일 수 있습니다.

49) 저장할 영상 크기의 유효성 검사

녹화 시작 시 영상 크기 확인을 수행니다.

if width <= 0 or height <= 0:
    self.labelStatus.setText(
        '올바르지 않은 카메라 영상 크기입니다.'
    )
    return

일반적인 정상 OpenCV 영상에서는 너비와 높이가 0 이하가 될 수 없습니다.

그러나 이 검사는 다음 문제를 방지하는 최종 방어선 역할을 합니다.

  • 비정상적인 NumPy 배열
  • 손상된 디코딩 결과
  • 향후 영상 처리 과정에서 크기가 잘못 변경됨
  • 빈 영역을 잘라낸 결과
  • 테스트용 잘못된 프레임 입력

VideoWriter(0, 0) 같은 크기를 전달하면 생성이 실패하거나 OpenCV 내부 오류가 발생할 수 있습니다.

50) 동영상 저장 디렉터리 자동 생성

output_dir = (
    Path.home()
    / 'Videos'
)

사용자 홈 디렉터리 아래의 Videos 폴더에 영상을 저장합니다.

Linux에서는 일반적으로 다음 경로가 됩니다.

/home/사용자이름/Videos

51) 저장 디렉터리 생성 예외 처리

저장 부분에 try-except를 적했습니다.

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

디렉터리 생성은 다음 상황에서 실패할 수 있습니다.

  • 홈 디렉터리 쓰기 권한 없음
  • 파일 시스템이 읽기 전용
  • 디스크 오류
  • Videos라는 이름의 일반 파일이 이미 존재
  • 네트워크 홈 디렉터리 연결 문제
  • 저장 공간 또는 inode 부족

기존 코드에서는 mkdir() 예외가 그대로 전달되어 프로그램이 종료될 수 있었습니다.

수정 후에는 녹화 시작만 중단하고 GUI는 계속 실행됩니다.

52) 파일 이름에 시간을 포함하는 이유

timestamp = datetime.now().strftime(
    '%Y%m%d_%H%M%S'
)

생성되는 문자열은 다음 형태입니다.

20260723_153025

이를 파일 이름에 사용합니다.

self.video_path = (
    output_dir
    / f'tb3_camera_{timestamp}.avi'
)

최종 파일 경로는 다음과 비슷합니다.

/home/user/Videos/tb3_camera_20260723_153025.avi

고정된 파일 이름을 사용하면 녹화를 시작할 때마다 이전 파일을 덮어쓸 수 있습니다.

시간 정보를 포함하면 녹화할 때마다 새로운 파일이 생성됩니다.

다만 1초 안에 녹화를 두 번 시작하면 같은 파일 이름이 만들어질 가능성이 있습니다. 실제 GUI 조작에서는 가능성이 낮지만, 자동 녹화 기능을 추가한다면 마이크로초까지 포함하는 것이 안전합니다.

timestamp = datetime.now().strftime(
    '%Y%m%d_%H%M%S_%f'
)

53) MJPG 코덱으로 AVI 파일 생성하기

fourcc = cv2.VideoWriter_fourcc(
    *'MJPG'
)

fourcc는 동영상 파일에 사용할 비디오 코덱을 나타냅니다.

MJPG는 각 프레임을 JPEG 이미지처럼 압축하는 Motion JPEG 방식입니다.

이 코덱을 선택한 이유는 다음과 같습니다.

  • OpenCV에서 비교적 사용하기 쉽다.
  • 프레임 단위 접근이 단순하다.
  • AVI 컨테이너와 함께 사용하기 편하다.
  • 많은 Linux 환경에서 별도 복잡한 설정 없이 동작한다.
  • 프레임 손상이 다른 프레임으로 크게 전파되지 않는다.

단점도 있습니다.

  • H.264나 H.265보다 파일 크기가 큰 편이다.
  • 동일 화질 기준 압축 효율이 낮다.
  • 장시간 녹화 시 저장 공간을 많이 사용한다.

예제와 테스트 용도에서는 안정적인 선택이지만, 실제 로봇 운용에서 장시간 영상을 저장하려면 H.264 기반 GStreamer 파이프라인이나 하드웨어 인코더 사용을 검토하는 것이 좋습니다.

54) VideoWriter 생성 자체의 예외 처리

객체 생성 단계도 예외 처리합니다.

try:
    self.video_writer = cv2.VideoWriter(
        str(self.video_path),
        fourcc,
        self.video_fps,
        self.video_size
    )

except cv2.error as exception:
    ...

일반적으로 VideoWriter 생성 실패는 객체가 만들어지지만 isOpened()False를 반환하는 형태가 많습니다.

하지만 OpenCV 빌드, 코덱 백엔드, 전달된 인자 상태에 따라 생성자 단계에서 cv2.error가 발생할 수도 있습니다.

두 실패 형태를 모두 처리해야 합니다.

실패 유형 1
VideoWriter 생성 중 예외 발생
실패 유형 2
객체는 생성됐지만 isOpened() == False

55) VideoWriter 상태를 이중으로 확인

video_writer에 대해 다음 조건을 검사합니다.

if (
    self.video_writer is None
    or not self.video_writer.isOpened()
):

생성자 예외 처리나 향후 코드 변경으로 인해 video_writerNone일 수 있으므로 먼저 검사합니다.

Python의 or는 왼쪽 조건이 참이면 오른쪽을 평가하지 않는 단락 평가를 사용합니다.

self.video_writer is None

이 조건이 참이면 다음 호출은 실행되지 않습니다.

self.video_writer.isOpened()

따라서 NoneType 객체에 메서드를 호출하는 오류를 방지합니다.

56) 녹화 실패 시 관련 상태를 모두 초기화

VideoWriter 생성에 실패하면 다음 값을 모두 초기화합니다.

self.video_writer = None
self.video_size = None
self.video_path = None

video_writer만 초기화하고 나머지 값을 남겨두면 실제 녹화가 시작되지 않았는데도 이전 또는 실패한 녹화 정보가 객체에 남습니다.

예를 들어 다음과 같은 모순된 상태가 생길 수 있습니다.

video_writer = None
video_size   = (640, 480)
video_path   = /home/user/Videos/tb3_camera_xxx.avi

이런 부분 초기화는 후속 버튼 처리와 종료 처리에서 버그를 만들 수 있습니다.

수정된 코드는 녹화 시작에 실패하면 녹화 관련 상태 전체를 초기 상태로 되돌립니다.

57) ROS 2 Logger를 이용한 녹화 시작 및 완료 기록

녹화 시작 시에 로그를 기록합니다.

self.node.get_logger().info(
    f'동영상 녹화 시작: {self.video_path}'
)

녹화 완료 시에도 로그를 남깁니다.

self.node.get_logger().info(
    f'동영상 저장 완료: {saved_path}'
)

GUI 상태 라벨은 화면에서 확인하기 편하지만, 이후 다른 상태 메시지로 덮어써집니다.

ROS 2 로그는 터미널이나 로그 파일에 남으므로 다음 정보를 추적하기 쉽습니다.

  • 녹화가 실제로 시작됐는지
  • 녹화 파일 경로
  • 녹화 종료 시점
  • 오류 발생 전후 상태
  • 원격 실행 환경의 동작 기록

특히 로봇에서 GUI를 원격 실행하거나 자동 녹화를 추가할 경우 터미널 로그가 중요합니다.

58) stop_recording()에서 try-finally 사용

녹화 정지 시 예외가 발생할 경우 처리입니다.

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

release()에서 예외가 발생하더라도 video_writer는 반드시 None으로 초기화되어야 합니다.

finally 블록은 예외 발생 여부와 관계없이 항상 실행됩니다.

release 성공
    ↓
finally 실행
    ↓
video_writer = None
release 실패
    ↓
except에서 오류 로그
    ↓
finally 실행
    ↓
video_writer = None

이 구조가 없으면 release() 실패 후 객체가 남아 있어 프로그램이 여전히 녹화 중인 것처럼 판단할 수 있습니다.

동영상 저장 중에는 파일이 완전히 마무리되지 않은 상태일 수 있습니다.

release()를 호출하면 다음 처리가 수행됩니다.

  • 남아 있는 영상 데이터 기록
  • 코덱 버퍼 정리
  • AVI 인덱스 정보 기록
  • 파일 핸들 닫기
  • 인코더 자원 해제

프로그램을 강제로 종료하거나 release()를 호출하지 않으면 파일이 생성되어 있더라도 재생할 수 없는 경우가 있습니다.

특히 AVI 파일은 종료 시점에 필요한 메타데이터가 기록될 수 있으므로 정상적인 종료 처리가 중요합니다.

녹화가 끝난 뒤 상태 변수도 초기화합니다.

self.recording_enabled = False
self.video_size = None
self.video_path = None

이렇게 해야 다음 녹화를 시작할 때 이전 녹화 정보가 남지 않습니다.

59) 저장 경로를 별도 변수로 보존하는 이유

saved_path = self.video_path

그다음 내부 상태를 초기화합니다.

self.video_path = None

하지만 상태 메시지에는 방금 저장한 파일 경로가 필요합니다.

self.labelStatus.setText(
    f'동영상 저장 완료 | {saved_path}'
)

self.video_path를 먼저 None으로 초기화하면 저장 완료 메시지에 경로를 표시할 수 없습니다.

따라서 초기화 전에 지역 변수 saved_path에 값을 복사합니다.

이 패턴은 자원 해제 함수에서 자주 사용됩니다.

기존 상태 보존
    ↓
객체 상태 초기화
    ↓
보존한 값으로 결과 처리

60) GUI 크기 변경 시 영상 다시 그리기

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
        )

QMainWindow의 크기가 변경되면 Qt가 resizeEvent()를 호출합니다.

영상은 show_frame()이 호출되는 시점의 라벨 크기를 기준으로 조정됩니다.

pixmap.scaled(
    self.labelImage.size(),
    ...
)

그런데 창 크기를 변경한 직후 새로운 카메라 프레임이 들어오지 않으면 이전 크기로 만들어진 Pixmap이 그대로 남을 수 있습니다.

이를 해결하기 위해 마지막 프레임을 다시 출력합니다.

self.show_frame(self.last_frame)

따라서 사용자가 창을 늘리거나 줄이면 영상도 즉시 새로운 크기에 맞게 조정됩니다.

super().resizeEvent(event)도 호출하고 있습니다.

이는 부모 클래스인 QMainWindow가 기본적으로 수행해야 하는 크기 변경 처리를 유지하기 위해 필요합니다.

부모 이벤트 함수를 호출하지 않으면 레이아웃 갱신이나 자식 위젯 배치가 예상과 다르게 동작할 수 있습니다.

61) closeEvent()의 종료

먼저 종료 상태를 설정하고 QTimer를 정지합니다.

self.window_closing = True

if hasattr(self, 'ros_timer'):
    self.ros_timer.stop()

if self.recording_enabled:
    self.stop_recording()

수정된 종료 순서는 다음과 같습니다.

1. 종료 진행 상태 설정
2. ROS 2 spin 타이머 중지
3. 동영상 녹화 종료
4. ROS 2 노드 삭제
5. ROS 2 Context 종료
6. Qt 종료 승인

가장 먼저 window_closingTrue로 설정하는 이유는 이미 예약된 콜백이 실행되더라도 즉시 반환하게 만들기 위해서입니다.

그다음 QTimer를 정지하여 새로운 spin_ros_once() 호출을 차단합니다.

이후 동영상 파일을 안전하게 닫고 ROS 2 노드를 제거합니다.

62) hasattr()를 사용한 부분 초기화 대응

종료 코드에서는 다음 조건을 확인합니다.

if hasattr(self, 'ros_timer'):
    self.ros_timer.stop()

노드 제거 시에도 사용합니다.

if hasattr(self, 'node'):
    self.node.destroy_node()

일반적인 정상 실행에서는 ros_timernode가 모두 존재합니다.

하지만 생성자 실행 중간에 예외가 발생할 수 있습니다.

예를 들어 UI 파일은 로드됐지만 ROS 2 노드 생성 후 타이머 생성 전에 오류가 발생했다고 가정해보겠습니다.

그 상태에서 창 종료 처리가 실행되면 아직 존재하지 않는 self.ros_timer에 접근할 수 있습니다.

AttributeError:
'CameraGui' object has no attribute 'ros_timer'

hasattr()는 해당 속성이 실제로 생성됐는지 확인합니다.

부분적으로 초기화된 객체도 안전하게 종료할 수 있도록 한 것입니다.

63) main() 함수의 try-except-finally

기존 main() 함수는 정상 실행 흐름만 처리했습니다.

rclpy.init(args=args)

app = QApplication([sys.argv[0]])

window = CameraGui()
window.show()

exit_code = app.exec_()

수정된 코드는 전체 초기화와 실행을 try 블록 안에 넣습니다.

try:
    rclpy.init(
        args=args
    )

    app = QApplication(
        [sys.argv[0]]
    )

    window = CameraGui()
    window.show()

    exit_code = app.exec_()

초기화 또는 실행 중 예외가 발생하면 다음 부분에서 처리합니다.

except Exception as exception:
    print(
        f'카메라 GUI 실행 오류: {exception}',
        file=sys.stderr
    )

오류는 표준 출력이 아니라 표준 오류 스트림으로 보냅니다.

file=sys.stderr

터미널 리다이렉션이나 로그 수집 환경에서 정상 출력과 오류 출력을 분리할 수 있습니다.

64) exit_code 초기값 지정

수정 코드에서는 함수 시작 시 다음 값을 지정합니다.

exit_code = 1

일반적으로 프로세스 종료 코드는 다음 의미를 가집니다.

0 : 정상 종료
0 이외 : 오류 종료

QApplication.exec_()가 정상적으로 반환되면 실제 Qt 종료 코드를 저장합니다.

exit_code = app.exec_()

그러나 QApplication 생성이나 CameraGui 생성 중 예외가 발생하면 app.exec_()까지 도달하지 못합니다.

이때 기본값 1을 유지하여 운영체제와 ros2 run에 비정상 종료임을 전달합니다.

25) 이 프로그램의 전체 실행 흐름

전체 실행 순서를 정리하면 다음과 같습니다.

프로그램 시작
    ↓
rclpy 초기화
    ↓
QApplication 생성
    ↓
CameraGui 생성
    ↓
UI 파일 로드
    ↓
ROS 2 노드 생성
    ↓
파라미터 읽기
    ↓
CompressedImage Subscriber 생성
    ↓
QTimer 시작
    ↓
Qt 이벤트 루프 실행

실행 중에는 다음 과정이 반복됩니다.

QTimer 발생
    ↓
rclpy.spin_once()
    ↓
카메라 메시지 수신 여부 확인
    ↓
CompressedImage 디코딩
    ↓
last_frame 갱신
    ↓
녹화 중이면 VideoWriter에 저장
    ↓
화면 표시 중이면 QLabel에 출력

종료 시에는 다음 순서로 처리됩니다.

녹화 종료
    ↓
QTimer 종료
    ↓
ROS 2 노드 제거
    ↓
rclpy 종료
    ↓
Qt 프로그램 종료

66) 화면 표시와 녹화 동작 조합

현재 구조에서는 화면 표시와 녹화 상태를 독립적으로 선택할 수 있습니다.

화면 표시녹화동작
중지중지프레임만 내부적으로 수신
시작중지GUI 화면에만 출력
중지시작화면 없이 파일 저장
시작시작화면 출력과 파일 저장 동시 수행

특히 화면 표시가 꺼진 상태에서도 Subscriber와 디코딩은 계속 실행됩니다.

따라서 CPU 사용량을 완전히 줄이고 싶은 경우에는 단순히 camera_display_enabled만 끄는 것으로 충분하지 않습니다.

현재 구조에서 화면 표시 중지 시 절약되는 작업은 다음과 같습니다.

  • BGR에서 RGB로 변환
  • QImage 생성
  • 이미지 메모리 복사
  • QPixmap 생성
  • 영상 크기 조정
  • QLabel 화면 갱신

하지만 다음 작업은 계속 수행됩니다.

  • ROS 2 메시지 수신
  • NumPy 배열 변환
  • JPEG 또는 PNG 디코딩
  • last_frame 저장
  • 녹화 중이면 파일 쓰기

카메라를 완전히 사용하지 않을 때 디코딩 부하까지 제거하려면 Subscriber를 제거하거나 콜백 초기에 처리 여부를 검사하는 구조가 필요합니다.

16. package.xml 작성

다음 내용을 package.xml에 저장합니다.

<?xml version="1.0"?>
<package format="3">
  <name>tb3_camera_gui</name>
  <version>0.1.0</version>

  <description>
    PyQt5 GUI for TurtleBot3 compressed camera images
  </description>

  <maintainer email="student@example.com">
    student
  </maintainer>

  <license>Apache-2.0</license>

  <buildtool_depend>
    ament_python
  </buildtool_depend>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>sensor_msgs</exec_depend>
  <exec_depend>ament_index_python</exec_depend>
  <exec_depend>python3-numpy</exec_depend>
  <exec_depend>python3-opencv</exec_depend>
  <exec_depend>python3-pyqt5</exec_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

package.xml의 export 영역에는 다음 내용이 반드시 포함되어야 합니다.

<export>
  <build_type>ament_python</build_type>
</export>

이 설정이 없으면 ROS 2가 패키지 빌드 형식을 올바르게 판단하지 못할 수 있습니다.

17. setup.py 작성

다음 내용을 setup.py에 저장합니다.

from glob import glob
import os

from setuptools import find_packages
from setuptools import setup


package_name = 'tb3_camera_gui'


setup(
    name=package_name,
    version='0.1.0',

    packages=find_packages(
        exclude=['test']
    ),

    data_files=[
        (
            'share/ament_index/resource_index/packages',
            ['resource/' + package_name]
        ),
        (
            'share/' + package_name,
            ['package.xml']
        ),
        (
            os.path.join(
                'share',
                package_name,
                'ui'
            ),
            glob(
                os.path.join(
                    package_name,
                    'ui',
                    '*.ui'
                )
            )
        ),
    ],

    install_requires=[
        'setuptools'
    ],

    zip_safe=True,

    maintainer='student',
    maintainer_email='student@example.com',

    description=(
        'PyQt5 GUI for TurtleBot3 '
        'compressed camera images'
    ),

    license='Apache-2.0',

    entry_points={
        'console_scripts': [
            (
                'camera_gui = '
                'tb3_camera_gui.camera_gui:main'
            ),
        ],
    },
)

data_files 설정에는 UI 파일 설치 경로가 포함되어 있습니다.

(
    os.path.join(
        'share',
        package_name,
        'ui'
    ),
    glob(
        os.path.join(
            package_name,
            'ui',
            '*.ui'
        )
    )
),

이 설정이 없으면 빌드 후 camera_gui.ui가 install 폴더에 복사되지 않습니다.

그 결과 프로그램 실행 시 UI 파일을 찾지 못할 수 있습니다.

18. 패키지 빌드

다음 명령으로 패키지를 빌드합니다.

cd ~/pyqt_ws

colcon build --packages-select tb3_camera_gui

빌드가 완료되면 환경을 적용합니다.

source ~/pyqt_ws/install/setup.bash

실행 파일이 등록되었는지 확인합니다.

ros2 pkg executables tb3_camera_gui

정상적인 경우 다음과 같이 출력됩니다.

tb3_camera_gui camera_gui

19. 전체 실행 순서

먼저 TurtleBot3에서 카메라 노드를 실행합니다.

source /opt/ros/$ROS_DISTRO/setup.bash
source ~/turtlebot3_ws/install/setup.bash

export ROS_DOMAIN_ID=30
export ROS_LOCALHOST_ONLY=0

ros2 launch turtlebot3_bringup camera.launch.py format:=BGR888 width:=320 height:=240

원격 PC에서 카메라 토픽을 확인합니다.

source /opt/ros/$ROS_DISTRO/setup.bash
export ROS_DOMAIN_ID=30

ros2 topic list | grep camera

다음 압축 토픽이 보이는지 확인합니다.

/camera/image_raw/compressed

GUI를 실행합니다.

source /opt/ros/$ROS_DISTRO/setup.bash
source ~/ros2_ws/install/setup.bash

export ROS_DOMAIN_ID=200
export ROS_LOCALHOST_ONLY=0

ros2 run tb3_camera_gui camera_gui

20. GUI 사용 순서

GUI 실행 후 다음 순서로 테스트합니다.

  1. Start Camera 버튼을 누릅니다.
  2. 카메라 영상이 화면에 표시되는지 확인합니다.
  3. Start Mov 버튼을 누릅니다.
  4. 상태 표시줄에서 저장 경로를 확인합니다.
  5. 카메라 또는 로봇을 움직입니다.
  6. Stop Mov 버튼을 누릅니다.
  7. ~/Videos 폴더에서 AVI 파일을 확인합니다.
  8. Stop Camera 버튼을 누릅니다.
  9. 카메라 화면 표시가 중지되는지 확인합니다.

저장된 동영상 파일을 확인합니다.

ls -lh ~/Videos

파일 탐색기로 폴더를 엽니다.

xdg-open ~/Videos

21. 다른 카메라 토픽 사용

카메라 토픽 이름이 다른 경우 Python 소스를 수정할 필요가 없습니다.

실행할 때 ROS 2 파라미터로 변경합니다.

ros2 run tb3_camera_gui camera_gui \
  --ros-args \
  -p image_topic:=/camera/image_raw/compressed

다중 로봇 환경에서 토픽이 다음과 같다고 가정합니다.

/robot1/camera/image_raw/compressed

다음과 같이 실행합니다.

ros2 run tb3_camera_gui camera_gui \
  --ros-args \
  -p image_topic:=/robot1/camera/image_raw/compressed

22. 동영상 FPS 변경

기본 동영상 FPS는 30입니다.

실제 카메라 토픽이 약 20Hz로 발행된다면 다음과 같이 실행할 수 있습니다.

ros2 run tb3_camera_gui camera_gui --ros-args -p video_fps:=20.0

실제 카메라 프레임 주기는 다음 명령으로 확인합니다.

ros2 topic hz /camera/image_raw/compressed

실제 수신 주기와 저장 FPS가 크게 다르면 저장된 동영상의 재생 속도가 실제 움직임과 다르게 보일 수 있습니다.

Leave a Comment