한컴인스페이스

드론 객체탐지 시스템 - ROS1 → ROS2 마이그레이션

2026.07
ROS2 마이그레이션 설계 및 구현
ROS2rclpyPythonDockerPyTorchpytest

아키텍처 - ROS1 Noetic → ROS2 Humble

RealSense 카메라
RGB + Depth 토픽
RGB/Depth 시간 동기화
ApproximateTimeSynchronizer
객체 탐지 (팀원 작성)
Faster R-CNN, GPU
3D 좌표 변환 (팀원 작성)
Pinhole 역투영
GCS / 드론
MultiTarget 퍼블리시 · UDP 시각화 전송
ROS_MASTER_URI(ROS1) → ROS_DOMAIN_ID(ROS2) 기반 프로세스당 드론 격리

드론에 탑재된 RealSense 카메라로 폭발물·화재·부상자 등을 탐지해 3D 좌표로 변환, GCS(지상 통제소)로 전송하는 시스템입니다. 핵심 탐지 모델(Faster R-CNN)과 좌표 변환 로직은 팀원이 작성했고, 저는 시스템 전체 아키텍처를 역분석해 문서화하고 테스트를 추가한 뒤, ROS1 Noetic(catkin)에서 ROS2 Humble(colcon/ament)로의 마이그레이션을 설계·구현했습니다.

ROS1에서는 UDP로 받은 드론 IP마다 ROS_MASTER_URI를 새로 설정해 별도 프로세스를 띄우는 방식으로 여러 드론을 동시에 서빙했습니다. ROS2에는 이 Master 개념 자체가 없어(DDS 기반 자동 discovery), 이 프로세스당 격리 구조를 유지할 방법부터 다시 설계해야 했습니다.

핵심 기능

멀티 드론 격리 재설계 - ROS_MASTER_URI에서 ROS_DOMAIN_ID로

Master 개념이 없는 ROS2에서 프로세스당 드론 격리 구조를 그대로 재사용하는 매핑 모듈 설계

detector

ROS1에서는 multiprocessing.Process마다 ROS_MASTER_URI를 드론 IP로 설정해 프로세스 단위로 통신을 격리했습니다. ROS2는 DDS 기반이라 Master가 없고, 대신 ROS_DOMAIN_ID가 다르면 노드끼리 서로 보이지 않는 방식으로 격리됩니다. 기존 "프로세스당 드론 하나" 아키텍처를 최소 재설계로 재사용하려면, 드론 IP를 도메인 ID로 바꿔주는 계층이 필요했습니다.

domain_map.py를 rospy/소켓/환경변수 접근이 전혀 없는 순수 함수 모듈로 작성했습니다. config.yaml의 명시적 IP→도메인 매핑을 우선 사용하고, 매핑이 없으면 IP 마지막 옥텟에서 결정론적으로 도메인 ID를 파생하되 유효 범위(1~101)로 클램프합니다. ROS 환경 없이도 pytest로 이 로직만 독립적으로 검증할 수 있습니다.

def ip_to_domain_id(ip, explicit_map=None):
    if explicit_map and ip in explicit_map:
        return _validated(explicit_map[ip])
    octets = ip.split(".")
    last = int(octets[-1])
    span = DOMAIN_ID_MAX - DOMAIN_ID_MIN + 1
    return DOMAIN_ID_MIN + (last % span)  # 1~101 범위로 결정론적 파생
ROS1ROS2
격리 단위프로세스당 ROS_MASTER_URI프로세스당 ROS_DOMAIN_ID
드론 식별IP를 마스터 주소로 직접 사용IP → domain_map.py로 도메인 ID 변환
아키텍처 변경 범위-프로세스당 격리 구조는 그대로, 격리 메커니즘만 교체
패키지 전체 이식 - catkin → colcon, rospy → rclpy

explore_msgs·detector 두 패키지를 ROS2 빌드 시스템과 노드 API로 전환

explore_msgs · detector

메시지 패키지 explore_msgspackage.xml format2→3, 빌드를 ament_cmake + rosidl_generate_interfaces로 전환했습니다. detectorament_python (setup.py/setup.cfg/resource) 구조로 바꾸고, 메인 노드를 rclpy.node.Node 상속 구조로 재작성했습니다.

항목ROS1ROS2
초기화rospy.init_node()rclpy.init() + Node(name)
spinrospy.spin()rclpy.spin(node)
시간rospy.Time.now()node.get_clock().now().to_msg()
종료 확인rospy.is_shutdown()not rclpy.ok()

하드코딩돼 있던 /catkin_ws/src/detector/... 절대경로도 DETECTOR_ROOT 환경변수(미설정 시 파일 위치 기준 자동 해석)로 파라미터화해, 도커 이미지·설치 경로가 바뀌어도 코드 수정 없이 대응하게 했습니다. 호출부가 확인되지 않던 master_check.py는 ROS2에 대응하는 등가물이 없어 마이그레이션 대상에서 제외했습니다.

Docker GPU 파이프라인 디버깅 - UCX·OpenCV 런타임 오류 해결

Ubuntu 22.04+Humble 풀빌드 후 실제 GPU 결합 테스트에서 발견한 런타임 오류 2건

Docker

Ubuntu 22.04 기반 ROS2 Humble 이미지로 전환하고 colcon build가 성공한 뒤, 실제 GPU 추론까지 엮은 결합 테스트에서 두 가지 런타임 오류를 만났습니다.

1. torch import 시 UCX 심볼 오류

undefined symbol: ucm_set_global_opts로 torch import가 실패했습니다. 원인은 HPC-X UCX 라이브러리가 부분적으로만 로드된 것이었습니다. libucm/uct/ucs/ucp 4종을 의존성 순서대로 LD_PRELOAD하고, 충돌하던 기존 hpcx/ompi LD_LIBRARY_PATH는 제거해 해결했습니다.

2. OpenCV 타입 스텁 버그

nvcr 베이스 이미지의 opencv 4.7 typing 스텁에 있는 cv2.dnn.DictValue 버그로 cv2 import 자체가 실패했습니다. pip으로 opencv를 덧설치하던 것을 제거해 베이스 이미지의 4.7을 그대로 쓰도록 하고, 문제가 되는 스텁 한 줄을 무력화해 우회했습니다.

두 문제 모두 colcon build 성공만으로는 드러나지 않고 실제 GPU 추론 파이프라인을 끝까지 돌려봐야 나오는 종류라, 빌드 통과와 런타임 동작 검증을 분리해서 봐야 한다는 걸 확인한 과정이었습니다.

안전한 종료 처리 - ExternalShutdownException과 무효 컨텍스트 publish 방지

STOP 명령 시 나던 트레이스백 2종을 정상 종료 경로 처리 + publish 직전 가드로 제거

detector

UDP로 받은 STOP 명령으로 DepthPointor 프로세스를 종료할 때 트레이스백 2종이 발생했습니다. 하나는 rclpy.spin()이 던지는 ExternalShutdownException을 오류로 취급해 로그를 더럽히는 것이었고, 다른 하나는 무거운 GPU 추론이 도는 도중 종료 신호가 오면 추론이 끝난 뒤 이미 내려간 rclpy 컨텍스트에 publish를 시도하다 죽는 경쟁 상태였습니다.

ExternalShutdownException KeyboardInterrupt와 함께 정상 종료 경로로 분류하고, 모든 publish 호출 직전에 shutdown_flagrclpy.ok()를 다시 확인하는 _safe_publish() 가드를 추가했습니다. 함수 진입 시점 검사만으로는 추론이 오래 걸리는 동안 상태가 바뀔 수 있어, publish 직전에 한 번 더 확인하는 것이 핵심이었습니다.

def _safe_publish(self, publisher, msg):
    # 무거운 GPU 추론 도중 STOP 이 오면 함수 진입 시점 검사만으론 부족 -
    # publish 직전에 다시 확인한다.
    if self.shutdown_flag or not rclpy.ok():
        return
    publisher.publish(msg)

실 하드웨어 없이 전체 흐름(UDP 명령 수신 → 프로세스 spawn → 합성 카메라 → GPU 추론 → MultiTarget 퍼블리시 → STOP)을 재현해 MultiTarget 27건 퍼블리시, 종료 트레이스백 0건을 확인했습니다.

실장비 없는 회귀 검증 - synthetic 메시지 기반 통합 테스트

ROS1→ROS2 API 전면 교체인데 자동화 테스트가 전무 → pytest 단위 + rostest 통합 테스트 신설

tests

저장소에 자동화 테스트가 전혀 없어 지금까지는 실 드론+RealSense 장비에서 수동 확인만으로 검증해온 시스템이었습니다. ROS1→ROS2는 API·빌드 시스템이 전면 교체되는 변경이라, 수동 확인만으로는 회귀를 놓치기 쉽다고 판단해 마이그레이션에 앞서 최소한의 자동 테스트부터 추가했습니다.

3D 좌표 변환(point_distance/drone_points)과 UDP 명령 파싱(handle_command)에서 rospy/OpenCV에 의존하지 않는 순수 로직geometry.py/command_parser.py로 분리하고, gcs_ros_main.py가 이를 호출하도록 리팩터링(동작은 동일)했습니다. ROS가 설치되지 않은 환경에서도 pytest만으로 이 로직을 검증할 수 있습니다.

여기에 더해 synthetic CameraInfo/CompressedImage/depth Image를 퍼블리시하고 inference.model_checkpoint/inference.main만 모킹(GPU·가중치 불필요)한 rostest 통합 테스트를 추가해, 실제 cv_bridge/message_filters 동기화 경로를 태우는 end-to-end 검증까지 확보했습니다.

테스트 레이어대상결과
pytest 단위 테스트좌표 변환·명령 파싱 순수 로직10 passed - ROS 미설치 환경에서도 실행 가능
rostest 통합 테스트RGB+Depth 동기화 → 추론 → MultiTarget 퍼블리시GPU·가중치 없이 전체 경로 검증
전체colcon build + pytest17 passed