우회로를 눈앞에 두고, 나는 외쳤다 — nonononononono yolo!
0. 서론: 백기를 들기 직전
지난 7편의 끝은 처참했습니다. 로봇의 눈(usb_cam)은 켜졌고, 번역가(cv_bridge)는 자리를 잡았고, 비전 전용 부대(vision_control)까지 창설을 마쳤는데 — 정작 시각 피질(YOLOv8)이 들어오질 못했습니다. 530MB짜리 PyTorch가 해시 검문소에서 번번이 산화했고, 캐시 소각도, 공식 서버 직결도, wget 수동 주입도, 무한 재시도 루프도, 무균실(yolo_venv)까지도 전부 부서졌습니다. 새벽의 제 입에서는 “설치가 아예 안 되니, 욜로는 망했구만?“이라는 항복 선언이 새어 나오고 있었지요.
이번 글은 그 바닥에서 시작합니다. 그리고 세 번 뒤집힙니다.
한 번은 우회로의 유혹 — 딥러닝 없이도 목표를 이룰 수 있는, 멀쩡하고 우아한 탈출구가 눈앞에 깔립니다. 또 한 번은 한 줄의 의문 — “어차피 내장 그래픽인데, 왜 CUDA를 받고 있지?“가 지옥의 정체를 폭로합니다. 그리고 마지막 한 번은, 설치가 모두 끝나고 승리를 확신한 순간에 터지는 Segmentation fault — 이 시리즈에서 제가 만난 가장 깊숙한 곳의 배신입니다.
먼저 결론부터: 이날 밤 제 문제는 끝까지 ‘코드’가 아니었습니다. 전반전의 적이 화물의 크기(패킷 손상)였다면, 후반전의 적은 버전의 약속(ABI)이었습니다. 코드가 문법적으로 완벽해도, 라이브러리 두 개가 “같은 NumPy를 쓴다"는 약속을 어기면 시스템은 죽습니다.
1. 우회로의 유혹: 순수 픽셀의 미학
바닥에 주저앉아 곰곰이 생각해 보니, 중요한 사실 하나가 보였습니다. 제 진짜 목표는 “무거운 딥러닝 모델을 설치하는 것"이 아니라, “카메라로 무언가를 찾아 그 위치 좌표를 로봇에게 넘기는 것” 이라는 사실을요. 비주얼 서보잉의 데이터 흐름을 검증하는 데 YOLO가 반드시 필요한 건 아니었습니다.
게다가 저는 이미 무기를 손에 쥐고 있었습니다. 7편에서 sudo apt install ros-humble-cv-bridge를 실행했을 때, 우분투 시스템 깊숙한 곳에 세계 최고 수준의 비전 라이브러리 OpenCV(C++ 기반 파이썬 바인딩)가 이미 통째로 깔려 들어와 있었던 것입니다. pip 다운로드는 단 한 줄도 필요 없었습니다.
그래서 우회로를 닦기 시작했습니다. 무거운 AI 대신, 순수한 수학적 픽셀 연산인 HSV 색상 추적(Color Tracking) 입니다. 카메라 앞에서 파란색 물건을 흔들면 OpenCV가 그 색만 귀신같이 추출해, YOLO와 똑같은 형태의 바운딩 박스와 좌표를 뿜어내는 방식이지요.
왜 RGB가 아니라 HSV일까요? RGB(빛의 삼원색)에서는 같은 파란 물체라도 그림자가 지면 세 값이 한꺼번에 출렁여서 “파랑이다"를 판정하기 어렵습니다. 반면 HSV는 색을 색상(Hue), 채도(Saturation), 명도(Value) 세 축으로 분해합니다. 빛이 어두워지면 명도 $V$만 떨어질 뿐, 색상 $H$는 그대로 유지됩니다. 그래서 조명 변화에 훨씬 강합니다.
추적의 수학은 두 단계입니다. 먼저 각 픽셀이 목표 색 범위에 드는지 판정하는 마스크를 만듭니다. 픽셀 $(u,v)$의 HSV 값을 $(H, S, V)$라 하면,
$$ \text{mask}(u,v) = \big[\, H_{lo} \le H \le H_{hi} \,\big] \;\wedge\; \big[\, S_{lo} \le S \le S_{hi} \,\big] \;\wedge\; \big[\, V_{lo} \le V \le V_{hi} \,\big] $$이것이 OpenCV의 cv2.inRange가 하는 일 전부입니다. 그다음, 마스크에서 가장 큰 덩어리를 찾아 그 중심점(centroid) 을 계산합니다. 마스크가 켜진 픽셀들의 좌표를 $\{(x_i, y_i)\}_{i=1}^{N}$이라 하면,
이 $c_x$가 바로, 7편에서 세운 서보잉 작전의 그 운명의 숫자입니다. 저는 이 우회로의 코드를 끝까지 짰습니다. pip가 단 1바이트도 필요 없는, 시스템 내장 OpenCV와 NumPy만으로 즉시 도는 노드입니다.
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
import numpy as np
class ColorTracker(Node):
def __init__(self):
super().__init__('color_tracker')
self.br = CvBridge()
self.subscription = self.create_subscription(
Image, '/image_raw', self.image_callback, 10)
def image_callback(self, data):
frame = self.br.imgmsg_to_cv2(data, "bgr8")
# BGR -> HSV (조명/그림자에 강함)
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
# 파란색 범위 마스크 (빨강은 H가 0/180 양끝으로 갈라져 코드가 길어짐)
lower_blue = np.array([100, 150, 0])
upper_blue = np.array([140, 255, 255])
mask = cv2.inRange(hsv, lower_blue, upper_blue)
# 윤곽선 -> 가장 큰 덩어리
contours, _ = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) > 500: # 노이즈 무시
x, y, w, h = cv2.boundingRect(largest)
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cx = x + w // 2 # 중심 X
cy = y + h // 2
cv2.circle(frame, (cx, cy), 5, (0, 0, 255), -1)
cv2.putText(frame, f"Target X: {cx}", (x, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
cv2.imshow("OpenCV Pure Color Tracker", frame)
cv2.waitKey(1)
이 알고리즘이 실제로 통하는 길인지, 제 PC에는 OpenCV가 없으니 순수 NumPy와 matplotlib만으로 똑같은 알고리즘을 재현해 그려봤습니다. 합성 이미지를 만들고, matplotlib.colors.rgb_to_hsv로 HSV 변환을 한 뒤, 직접 inRange 마스크와 중심점을 계산합니다.
🔬 파이썬 코드 보기 (펼치기 / 접기)
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import rgb_to_hsv
from matplotlib.patches import Rectangle
rng = np.random.default_rng(7)
H, W = 240, 320
# 1) 합성 이미지: 회색 배경 + 약간의 노이즈
img = np.full((H, W, 3), 0.45, dtype=float)
img += rng.normal(0, 0.03, img.shape)
# 빨강 더미 물체 두 개(방해물)
img[40:80, 30:70] = [0.85, 0.15, 0.15]
img[170:200, 250:290] = [0.80, 0.20, 0.18]
# 진짜 타겟: 파란 사각형(오른쪽-중앙)
img[100:165, 195:255] = [0.12, 0.25, 0.85]
img = np.clip(img, 0, 1)
# 2) RGB -> HSV (H,S,V 모두 0~1 정규화)
hsv = rgb_to_hsv(img)
Hc, Sc, Vc = hsv[..., 0], hsv[..., 1], hsv[..., 2]
# 3) 파란색 inRange 마스크: H in [0.55,0.72], S>0.4, V>0.3
mask = (Hc >= 0.55) & (Hc <= 0.72) & (Sc > 0.4) & (Vc > 0.3)
# 4) 중심점 c_x, c_y = 마스크 픽셀 좌표 평균
ys, xs = np.nonzero(mask)
cx, cy = xs.mean(), ys.mean()
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
fig, axes = plt.subplots(1, 3, figsize=(14, 4.4))
axes[0].imshow(img); axes[0].set_title("Synthetic camera frame (RGB)")
axes[1].imshow(mask, cmap='gray'); axes[1].set_title("inRange mask (blue only)")
axes[2].imshow(img)
axes[2].add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0,
edgecolor='lime', facecolor='none', linewidth=2.5))
axes[2].plot(cx, cy, 'rx', markersize=14, markeredgewidth=3)
axes[2].set_title(f"Detection: centroid c_x = {cx:.0f}")
for a in axes:
a.set_xlabel("u (pixel x)"); a.set_ylabel("v (pixel y)")
plt.tight_layout()
plt.show()
print(f"detected centroid: c_x = {cx:.1f}, c_y = {cy:.1f} (image width W = {W})")

detected centroid: c_x = 224.5, c_y = 132.0 (image width W = 320)
맨 왼쪽 원본에는 빨간 방해물 둘과 파란 타겟 하나가 섞여 있습니다. 가운데 마스크에서는 오직 파란 영역만 하얗게 살아남았고(빨강은 깔끔히 지워졌지요), 오른쪽에서는 그 덩어리에 초록 박스가 쳐지고 빨간 X로 중심점이 찍혔습니다. 딥러닝 한 톨 없이, 평균 한 번으로 타겟의 $c_x$를 뽑아내는 길. 멀쩡히 통하는, 우아하고 가벼운 우회로였습니다.
코드를 저장했습니다. setup.py 등록까지 마쳤습니다. 이제 colcon build 한 번, 실행 한 번이면 이 길고 긴 밤이 ‘일단은’ 끝나는 상황. 그런데 엔터 키 위에서 손가락이 멈췄습니다.
사실 이 우회로를 닦는 내내, 머릿속 한구석에는 다른 의문 하나가 이미 박혀 있었습니다. “아니, 어차피 나는 내장 그래픽으로 돌리고 있는데 — 왜 CUDA를 받다가 죽어야 하지?” 우회로가 부족해서 손이 멈춘 게 아니었습니다. 첫째, 이 길로 가면 로봇은 파란 펜은 쫓아도 사람을 알아보지는 못합니다. 색이 아니라 의미를 보는 눈 — 제가 원래 원했던 건 그것이었습니다. 둘째, 저 의문이 맞다면 애초에 우회할 필요조차 없었던 겁니다. 그날 밤 기록에 비명처럼 남아 있는 한 줄은 이것입니다.
nonononononono yolo!
우회로를 덮었습니다. 욜로로 갑니다. 단, 이번엔 똑같은 방식으로 박치기하는 게 아니라 — 먼저 지옥의 정체부터 해부하기로 했습니다.
2. 범인은 기본값이었다: 왜 CUDA가 끌려왔는가
우회로 위에서 제 손가락을 멈춰 세웠던 그 의문을, 이제 정면으로 마주할 차례였습니다.
아니, 어차피 나는 지금 내장 그래픽으로 돌리고 있는데 — 딱 욜로랑 OpenCV만 설치하면 되는 거 아닌가? 왜 자꾸 딴것들을 설치하면서 이 시간을 다 잡아먹는 거지?
에러 로그를 다시 읽어보니 답이 거기 있었습니다. 밤새 저를 죽인 파일들의 이름을 보십시오. torch(CUDA 포함 빌드), nvidia-cusparselt, nvidia-nvjitlink. 전부 NVIDIA GPU 전용 부품입니다. pip install ultralytics라고 치면, pip는 사용자가 내장 그래픽을 쓰든 말든 리눅스에서는 일단 가장 무거운 ‘NVIDIA GPU 풀패키지(CUDA 런타임 포함)‘를 기본값으로 멱살 잡고 끌어옵니다. 1GB가 넘는 엔비디아 부품들을 받느라 네트워크가 터지고 해시가 깨지며 금쪽같은 시간이 증발한 것입니다.
그런데 제 용도는 “물체를 인식해 좌표만 넘겨주는” 데이터 흐름 테스트입니다. 이건 CPU 연산만으로도 차고 넘칩니다. 즉 — 저는 받을 필요가 없는 화물 때문에 침몰하고 있었던 겁니다.
7편에서 유도한 생존 확률 공식을 다시 꺼내 봅시다. 파일이 $N$개의 패킷으로 쪼개져 날아올 때 무사 도착할 확률은 $P(\text{intact}) = (1-p)^N$이었습니다. 화물의 크기를 줄이면 $N$이 줄고, 생존 확률은 지수적으로 살아납니다. CUDA를 뺀 CPU 전용 PyTorch는 본체가 약 150MB — CUDA 직결판(780.4MB)의 5분의 1 수준입니다. 그 차이가 얼마나 결정적인지 그날 밤의 실제 화물 목록으로 계산해 보겠습니다.
🔬 파이썬 코드 보기 (펼치기 / 접기)
import numpy as np
import matplotlib.pyplot as plt
packet_kb = 1.5 # 패킷 크기 ~1.5KB (MTU), 7편과 동일한 가정
p = 1e-5 # 패킷 하나가 깨질 확률 (십만 분의 일)
# 그날 밤 로그에 찍힌 실제 화물들 (마지막만 CPU 전용 대안)
wheels = [
("torch 2.11 (PyPI, CUDA)", 530.6, "#cf222e"),
("torch 2.5.1+cu121", 780.4, "#cf222e"),
("nvidia-cusparselt", 169.9, "#f0883e"),
("nvidia-nvjitlink", 39.7, "#f0883e"),
("torch (CPU-only) ~150MB", 150.0, "#2ea043"),
]
def survival(mb):
n_packets = mb * 1024 / packet_kb
return (1.0 - p) ** n_packets # P(intact) = (1-p)^N
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))
names = [w[0] for w in wheels]
sizes = [w[1] for w in wheels]
colors = [w[2] for w in wheels]
bars = ax1.barh(names, sizes, color=colors)
for b, s in zip(bars, sizes):
ax1.text(s + 12, b.get_y() + b.get_height() / 2, f"{s:.0f} MB",
va='center', fontsize=9.5, fontweight='bold')
ax1.invert_yaxis()
ax1.set_xlim(0, 920)
ax1.set_xlabel("wheel size [MB]", fontsize=11)
ax1.set_title("That night's cargo: CUDA stack vs CPU-only", fontsize=12, fontweight='bold')
ax1.grid(True, axis='x', alpha=0.3)
mb_axis = np.linspace(1, 900, 400)
ax2.plot(mb_axis, [survival(m) * 100 for m in mb_axis], lw=2.4, color='#1f6feb')
for name, s, c in wheels:
ax2.scatter([s], [survival(s) * 100], color=c, zorder=5, s=60)
ax2.annotate(f"CPU-only ~150MB -> {survival(150)*100:.0f}%",
(150, survival(150) * 100), xytext=(220, 55),
fontsize=10, color='#2ea043', fontweight='bold',
arrowprops=dict(arrowstyle='->', color='#2ea043'))
ax2.annotate(f"cu121 780MB -> {survival(780.4)*100:.1f}%",
(780.4, survival(780.4) * 100), xytext=(480, 18),
fontsize=10, color='#cf222e', fontweight='bold',
arrowprops=dict(arrowstyle='->', color='#cf222e'))
ax2.set_xlabel("file size [MB]", fontsize=11)
ax2.set_ylabel("P(intact) [%] (p = 1e-5 per packet)", fontsize=11)
ax2.set_title("Shrink the cargo, survive the trip", fontsize=12, fontweight='bold')
ax2.grid(True, alpha=0.3)
ax2.set_ylim(0, 105)
plt.tight_layout()
plt.show()
for name, s, c in wheels:
sv = survival(s)
print(f"{name:28s} P(intact) = {sv*100:5.1f}% E[tries] = {1/sv:6.1f}")

torch 2.11 (PyPI, CUDA) P(intact) = 2.7% E[tries] = 37.4
torch 2.5.1+cu121 P(intact) = 0.5% E[tries] = 205.9
nvidia-cusparselt P(intact) = 31.4% E[tries] = 3.2
nvidia-nvjitlink P(intact) = 76.3% E[tries] = 1.3
torch (CPU-only) ~150MB P(intact) = 35.9% E[tries] = 2.8
숫자가 모든 것을 말해줍니다. 같은 회선, 같은 패킷 손실률($p=10^{-5}$)에서 780MB짜리 CUDA 직결판은 무사 도착 확률이 약 0.5% — 기댓값으로 이백 번 넘게 던져야 한 번 성공합니다. 반면 CPU 전용 torch(약 150MB)는 약 36% — 평균 세 번이면 들어옵니다. 밤새 저를 괴롭힌 의존성 지옥은 네트워크의 저주도, 운명의 장난도 아니었습니다. 그저 제 하드웨어를 모르는 기본값이 보낸, 너무 큰 화물이었던 겁니다.
기본값(default)은 당신을 위한 것이 아닙니다. 평균적인 사용자를 위한 것입니다. 내 상황이 평균에서 벗어나 있다면 — 내장 그래픽으로 딥러닝을 돌리겠다는 사람처럼 — 기본값은 가장 친절한 얼굴을 한 함정이 됩니다.
3. 세 줄의 정공법: CPU 전용 설치
진단이 끝났으니 처치는 간단합니다. 지긋지긋한 엔비디아 CUDA 부품들을 전부 건너뛰고, CPU만 사용하는 가벼운 코어를 다이렉트로 꽂아 넣는 것. PyTorch는 공식적으로 CPU 전용 인덱스(/whl/cpu)를 제공합니다. 7편에서 지어 둔 무균실(yolo_venv) 안에서, 딱 세 줄입니다.
# 무균실 진입 + 찌꺼기 캐시 완벽 소각
source ~/yolo_venv/bin/activate
pip cache purge
# 1. CPU 전용 PyTorch — CUDA 부품을 통째로 배제 (본체 약 150MB)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
# 2. 껍데기(YOLO 및 영상 처리) 장착
pip install ultralytics opencv-python
다운로드 바가 막힘없이 끝까지 갔습니다. 밤새 단 한 번도 통과하지 못했던 그 검문소를, 가벼워진 화물은 무사통과했습니다. THESE PACKAGES DO NOT MATCH THE HASHES라는 빨간 글자는 한 번도 나타나지 않았습니다. 가장 무거운 뼈대(torch)가 CPU판으로 이미 박혀 있으니, 이어지는 ultralytics 설치도 더는 엉뚱한 CUDA 파일을 끌어오지 않았지요.
이제 7편에서 짜 두었던 노드의 뼈대에 진짜 시각 피질을 채워 넣을 차례입니다. 7편의 yolo_detector는 설치 실패로 cv_bridge 번역과 화면 출력까지만 하던 껍데기였지요. 거기에 YOLO 추론을 얹되, 핵심은 device='cpu' — 추론 장치를 명시적으로 못 박는 한 줄입니다.
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
from ultralytics import YOLO
class YoloDetector(Node):
def __init__(self):
super().__init__('yolo_detector')
self.get_logger().info('>>> YOLOv8 비전 시스템 초기화 중...')
self.br = CvBridge()
# YOLOv8 나노 모델 (가장 가볍고 빠름). 첫 실행 시 자동 다운로드
self.model = YOLO('yolov8n.pt')
self.subscription = self.create_subscription(
Image, '/image_raw', self.image_callback, 10)
self.get_logger().info('>>> 눈(usb_cam) 연결 대기 중...')
def image_callback(self, data):
# 1. ROS2 이미지 패킷 -> OpenCV 배열로 번역
current_frame = self.br.imgmsg_to_cv2(data, "bgr8")
# 2. YOLO 추론 강행 (사람[class 0]만 감지, CPU 연산 강제 지정)
results = self.model(current_frame, classes=[0], device='cpu', verbose=False)
# 3. 바운딩 박스가 그려진 결과 이미지 렌더링
annotated_frame = results[0].plot()
cv2.imshow("YOLOv8 ROS2 Detector (CPU Mode)", annotated_frame)
cv2.waitKey(1)
가제보도 로봇 런치도 전부 끈 채, 터미널 두 개로 카메라와 YOLO만 1:1 테스트입니다.
# 터미널 1: 눈 뜨기
source /opt/ros/humble/setup.bash
ros2 run usb_cam usb_cam_node_exe
# 터미널 2: (yolo_venv 켠 상태로) 시각 피질 가동
cd ~/ros2_ws
colcon build --packages-select vision_control
source install/setup.bash
ros2 run vision_control yolo_detector
터미널 1은 완벽했습니다. 640x480 at 30 FPS, 타이머 33ms — 7편에서 본 그 로그 그대로, 눈은 정상적으로 떠졌습니다. 그리고 터미널 2에서 엔터를 치는 순간, 화면이 열리는 대신 — 노트북이 비명을 질렀습니다.
4. 마지막 배신: NumPy 1.x vs 2.x, C-API의 균열
A module that was compiled using NumPy 1.x cannot be run in
NumPy 2.2.6 as it may crash. ...
File ".../cv_bridge/__init__.py", line 6, in <module>
from cv_bridge.boost.cv_bridge_boost import cvtColorForDisplay, getCvType
AttributeError: _ARRAY_API not found
...
[ros2run]: Segmentation fault
Segmentation fault. 메모리를 잘못 건드려 OS가 프로세스를 강제로 찢어 죽였다는 신호. 코딩이 처음인 제게 이건 거의 사망 선고처럼 보였습니다. 그런데 범인은 다름 아닌, 7편 내내 다리 한가운데 서 있던 그 번역가 — cv_bridge였습니다.
원리는 이렇습니다. ROS2 Humble의 cv_bridge는 NumPy 1.x를 기준으로 C++ 컴파일되어 우분투에 깔렸습니다. 그런데 방금 가상 환경에 YOLO를 설치하면서 pip가 최신 NumPy 2.2.6을 끌고 들어왔습니다. NumPy 2.0부터는 내부 C-API(_ARRAY_API)의 메모리 구조가 완전히 갈아엎어졌습니다. 그래서 1.x 시절의 약속을 믿고 컴파일된 cv_bridge가 2.x의 메모리를 옛날 주소로 더듬는 순간, OS가 위험을 감지하고 프로세스를 즉사시킨 것입니다.
이게 바로 ABI(Application Binary Interface) 불일치입니다. 소스 코드 레벨의 호환성(API)이 아니라, 이미 컴파일된 바이너리끼리 메모리 배치를 약속하는 규약이 어긋난 것이지요. 같은 함수 이름이라도, 컴파일 시점에 가정한 구조체의 메모리 오프셋이 다르면 런타임에 폭발합니다.
이 충돌의 본질을 메모리 오프셋 그림으로 그려봤습니다. 컴파일된 cv_bridge가 기대하는 1.x 레이아웃과, 런타임에 실제로 깔린 2.x 레이아웃을 나란히 놓으면 왜 엉뚱한 곳을 읽게 되는지가 한눈에 보입니다.
🔬 파이썬 코드 보기 (펼치기 / 접기)
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, FancyArrowPatch
fig, ax = plt.subplots(figsize=(12, 5.6))
fig.patch.set_facecolor('#0d1117')
ax.set_facecolor('#0d1117')
# NumPy 1.x ABI (cv_bridge가 컴파일 시점에 가정한 레이아웃)
layout_1x = [("data*", 0, 1.4), ("ndim", 1.4, 0.8), ("shape*", 2.2, 1.2),
("strides*", 3.4, 1.2), ("descr*", 4.6, 1.4)]
# NumPy 2.x ABI (런타임에 실제로 메모리에 깔린 새 레이아웃)
layout_2x = [("flags", 0, 0.9), ("data*", 0.9, 1.4), ("ndim", 2.3, 0.8),
("shape*", 3.1, 1.2), ("descr*", 4.3, 1.7)]
def draw_row(layout, y, title, color):
ax.text(-0.2, y + 0.55, title, color='white', fontsize=11,
fontweight='bold', ha='right', va='center')
for name, x0, w in layout:
ax.add_patch(Rectangle((x0, y), w, 0.9, edgecolor=color,
facecolor='#161b22', linewidth=2))
ax.text(x0 + w/2, y + 0.45, name, color='white',
fontsize=9, ha='center', va='center')
draw_row(layout_1x, 2.4, "cv_bridge expects\n(compiled vs NumPy 1.x)", '#2ea043')
draw_row(layout_2x, 0.6, "actual array in memory\n(NumPy 2.2.6 runtime)", '#cf222e')
# cv_bridge가 'descr*'를 1.x 오프셋(4.6)에서 읽으려 함 -> 2.x에선 그 자리에 다른 게 있음
ax.add_patch(FancyArrowPatch((5.3, 2.4), (5.15, 1.5), arrowstyle='-|>',
mutation_scale=20, color='#f0883e', linewidth=2.4))
ax.text(5.4, 1.95, "reads wrong\noffset ->\n_ARRAY_API\nnot found\n=> Segfault",
color='#f0883e', fontsize=9.5, ha='left', va='center', fontweight='bold')
ax.text(3.0, 3.85, "NumPy C-API / ABI mismatch", color='white',
fontsize=14, fontweight='bold', ha='center')
ax.set_xlim(-2.2, 7.6)
ax.set_ylim(0, 4.2)
ax.axis('off')
plt.tight_layout()
plt.show()

위쪽 초록 줄은 cv_bridge가 “배열은 이렇게 생겼을 것"이라고 컴파일 시점에 못 박아둔 1.x 구조입니다. 아래쪽 빨간 줄은 NumPy 2.x가 런타임에 실제로 메모리에 깔아둔 새 구조입니다. 같은 descr* 필드라도 오프셋이 달라졌기 때문에, cv_bridge가 옛 주소로 손을 뻗는 순간 엉뚱한 메모리를 읽고 _ARRAY_API not found를 거쳐 Segfault로 직행합니다. 두 우주를 잇던 다리가, 미세하게 어긋난 설계도 한 장 때문에 무너진 것입니다.
해결책은 에러 로그가 친절히 알려준 그대로였습니다. 다리를 다시 짓는 대신, 강 쪽을 옛 높이로 되돌리는 것. 즉 NumPy를 1.x 시절로 다운그레이드하는 것입니다.
# (yolo_venv) 안에서
pip install "numpy<2.0.0"
이 한 줄이 NumPy 2.2.6을 지우고 1.26.4로 롤백했습니다. 그리고 다시 노드를 가동하자 —
5. 박스가 떴다, 그런데 사람만 잡는다: classes=[0] 봉인 해제
마침내 화면이 열렸습니다. YOLOv8 ROS2 Detector (CPU Mode) — 창 제목부터가 그날 밤의 훈장이었습니다. 사람을 향해 또박또박 따라붙는 초록 바운딩 박스가, 새벽의 제 얼굴 위에 떴습니다. 그래픽카드 없이, 순수 CPU 연산만으로, ROS2 네트워크를 타고 온 영상 위에서, 딥러닝이 돌고 있었습니다.
성공. 그런데 곧바로 이상한 점을 발견했습니다. 욜로가 사람만 잡는 겁니다. 책상 위의 마우스를 렌즈 앞에 들이밀어도, 스마트폰을 흔들어도, 화면은 본 체도 하지 않았습니다. 80가지 사물을 인식한다던 그 유명한 YOLO가?
모델이 고장 난 게 아니었습니다. 코드를 다시 읽다가, 제가 박아 둔 한 줄에서 범인을 찾았습니다.
results = self.model(current_frame, classes=[0], device='cpu', verbose=False)
classes=[0] — “세상 모든 물건을 무시하고 오직 사람(COCO 데이터셋 기준 0번 클래스)만 찾아라"라는 강력한 제약입니다. 로봇이 잡동사니에 한눈팔지 않고 사람만 추적하게 하려고 걸어 둔 안대였는데, 정작 그 안대를 씌운 사실을 까맣게 잊고 있었던 거지요.
YOLOv8이 기본으로 학습한 COCO 데이터셋은 80가지 사물 클래스를 담고 있습니다. 사람(0)부터 시작해 노트북(63), 마우스(64), 키보드(66), 스마트폰(67)까지. 안대를 어떻게 조절하느냐는 코드 한 줄 차이입니다.
# 봉인 완전 해제: 보이는 80종을 전부 잡아라
results = self.model(current_frame, device='cpu', verbose=False)
# 정밀 타격: 사람(0), 마우스(64), 스마트폰(67)만 추적
results = self.model(current_frame, classes=[0, 64, 67], device='cpu', verbose=False)
이 필터가 검출 결과를 어떻게 바꾸는지, 합성 장면 하나에 똑같은 필터링 로직을 적용해 비교해 봤습니다. 검출 목록에서 허용 클래스만 남기는 것 — classes= 옵션이 하는 일의 전부입니다.
🔬 파이썬 코드 보기 (펼치기 / 접기)
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
# 합성 검출 결과: (class_id, name, x, y, w, h, confidence) — COCO 인덱스 기준
detections = [
(0, "person", 60, 30, 150, 200, 0.91),
(67, "cell phone", 250, 120, 60, 100, 0.84),
(64, "mouse", 340, 195, 70, 45, 0.78),
(39, "bottle", 18, 150, 45, 95, 0.66),
]
def draw_filtered(ax, allowed, title):
ax.set_facecolor('#0d1117')
ax.add_patch(Rectangle((0, 0), 440, 260, facecolor='#161b22',
edgecolor='#30363d', linewidth=1.5))
# classes= 필터의 본질: 허용 리스트에 든 클래스만 남긴다
kept = [d for d in detections if d[0] in allowed]
for cid, name, x, y, w, h, conf in detections:
on = cid in allowed
color = '#2ea043' if on else '#444c56'
ax.add_patch(Rectangle((x, y), w, h, fill=False, edgecolor=color,
linewidth=2.4 if on else 1.2,
linestyle='-' if on else '--'))
label = f"{name} {conf:.2f}" if on else f"{name} (filtered)"
ax.text(x, y - 9, label, color=color, fontsize=9,
fontweight='bold' if on else 'normal')
ax.set_title(f"{title} -> {len(kept)} box(es)", color='white',
fontsize=11, fontweight='bold')
ax.set_xlim(-12, 452)
ax.set_ylim(272, -32) # 이미지 좌표계 (y축 아래로)
ax.axis('off')
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6))
fig.patch.set_facecolor('#0d1117')
draw_filtered(axes[0], {0}, "classes=[0] (person only)")
draw_filtered(axes[1], {0, 64, 67}, "classes=[0, 64, 67]")
plt.show()
n_left = sum(1 for d in detections if d[0] in {0})
n_right = sum(1 for d in detections if d[0] in {0, 64, 67})
print(f"same scene, same model: {n_left} box -> {n_right} boxes (only the filter changed)")

same scene, same model: 1 box -> 3 boxes (only the filter changed)
왼쪽이 그날 밤 처음 본 화면입니다. 모델은 네 가지 물체를 전부 ‘보고’ 있었지만, 필터가 사람 하나만 통과시켜 박스 1개. 오른쪽은 허용 리스트에 마우스와 스마트폰을 추가한 결과로 박스 3개. 모델도, 장면도, 가중치도 똑같습니다 — 달라진 건 한 줄짜리 필터뿐입니다. (참고로 물병(39번)은 여전히 잘립니다. 안대는 벗기는 게 아니라, 어디까지 보여줄지 고르는 도구인 셈이지요.)
코드를 고치고 다시 돌리자, 스마트폰과 마우스 위에도 박스가 또박또박 붙기 시작했습니다. 로봇의 눈이 — 드디어, 완전히 — 떠진 것입니다.
6. 마무리: 정공법의 결산
그 밤의 후반전을 정리하면 이렇습니다.
- 우회로(HSV 색상 추적) 를 끝까지 닦아 보고도 덮었습니다.
inRange마스크와 중심점 $c_x = \frac{1}{N}\sum x_i$라는 우아한 수학이었지만, 색이 아니라 의미를 보는 눈이 목표였으니까요. - 지옥의 정체를 해부했습니다. 범인은 네트워크가 아니라 하드웨어를 모르는 기본값 — 내장 그래픽 사용자에게 CUDA 풀패키지를 보내는 pip의 관성이었습니다. 화물을 5분의 1로 줄이자($N$이 줄자), 생존 확률 $(1-p)^N$은 0.5%에서 36%로 살아났고, 검문소는 단번에 뚫렸습니다.
- 그리고 마지막 관문, NumPy 1.x vs 2.x ABI 충돌이라는 보이지 않는 균열을
numpy<2.0.0다운그레이드로 봉합했습니다. 코드가 완벽해도 버전의 약속이 어긋나면 시스템은 죽는다 — 이날 밤 가장 비싼 수업료를 낸 교훈입니다. - 마지막으로
classes=[0]이라는 제 손으로 씌운 안대를 발견하고, COCO 80종의 세계로 봉인을 풀었습니다.
이제 제 로봇은 ROS2 네트워크를 타고 온 영상에서, 그래픽카드 한 톨 없이, 사람과 사물을 실시간으로 알아봅니다. 7편에서 세워 둔 서보잉 작전 — 화면이 곧 명령이 되는 그 회로 — 에 마침내 진짜 신호를 공급할 눈이 생긴 겁니다. (그 신호가 결국 단순한 좌표가 아니라 훨씬 풍부한 것으로 진화하리란 건, 이때는 아직 몰랐지만요.)
하지만 여기서 한 가지 미해결 과제가 남았습니다. 색상 추적도, YOLO도 결국 물체의 ‘껍데기’만 봅니다. 주먹을 쥐든 손가락을 펴든 그저 “손이네” 하고 박스만 칠 뿐, 손가락이 몇 개 펴졌는지는 알지 못합니다. 제가 정말로 원했던 것 — 손가락 제스처로 로봇을 부리는 것 — 을 이루려면, 손의 관절 하나하나를 좌표로 읽어내는 더 정교한 눈이 필요합니다.
다음 9편에서는 구글이 만든 관절 추적 AI MediaPipe Hands를 시스템에 이식합니다. 손가락의 펴짐과 접힘을 Y좌표 비교라는 단순한 기하학으로 판정하는 알고리즘을 파헤치고, 그 과정에서 또 한 번 펼쳐질 의존성 전쟁을 정면 돌파해보겠습니다. 로봇이 드디어, 제 손가락의 숫자를 세기 시작할 겁니다.