라인트레이서 로봇까지 만들었다면 이제 로봇은 바닥의 선을 읽고, 거리 센서로 앞을 감지하며, 엔코더로 자신의 움직임을 조금씩 이해할 수 있게 되었다. 이번 장에서는 로봇에게 조금 더 넓은 시야를 달아준다. 바로 카메라다.
카메라가 달린 로봇은 단순히 영상을 보는 장난감이 아니다. 화면 속 특정 색상의 공을 찾고, 그 공이 왼쪽에 있으면 왼쪽으로 돌고, 오른쪽에 있으면 오른쪽으로 돌며, 중앙에 있으면 앞으로 움직일 수 있다. 초음파 센서 하나가 정면의 거리만 알려주었다면, 카메라는 화면 전체를 한 번에 보여준다. 로봇 입장에서는 작은 창문이 생기는 셈이다.
이번 프로젝트의 핵심은 라즈베리파이를 영상 처리 담당으로 사용하고, ESP32나 아두이노를 모터 제어 담당으로 사용하는 것이다. 라즈베리파이는 카메라 프레임을 읽고, OpenCV로 색상 물체를 찾고, 웹 브라우저로 영상을 스트리밍한다. 마이크로컨트롤러는 라즈베리파이에서 받은 짧은 명령을 바탕으로 DC 모터를 안정적으로 움직인다. 즉, 라즈베리파이는 눈과 판단, ESP32나 아두이노는 근육과 반사 신경을 맡는다.

이번에 만들 로봇의 구조
이번 장에서 만들 로봇은 다음 흐름으로 동작한다.
[카메라]
↓ 영상 프레임
[라즈베리파이]
├─ 실시간 영상 스트리밍
├─ OpenCV 색상 추적
└─ 이동 명령 생성: F, L, R, S
↓ USB 시리얼
[ESP32 또는 아두이노]
↓ PWM과 방향 신호
[모터 드라이버]
↓
[좌우 DC 모터]여기서 F는 전진, L은 좌회전, R은 우회전, S는 정지 명령이다. 명령을 길게 보내지 않는 이유는 간단하다. 로봇에서 명령은 짧고 명확할수록 디버깅하기 쉽다. 라즈베리파이가 복잡한 영상 처리를 하더라도, 마이크로컨트롤러는 Fn 같은 한 줄 명령만 받으면 된다.
구조를 이렇게 나누면 장점이 크다. 라즈베리파이는 리눅스가 올라간 작은 컴퓨터라서 카메라, 웹 서버, OpenCV 같은 작업을 다루기 좋다. 반면 모터 PWM을 일정하게 내보내고, 통신이 잠시 끊기면 즉시 멈추는 일은 ESP32나 아두이노 같은 마이크로컨트롤러가 더 단순하고 안정적으로 처리한다.
준비물
카메라 로봇은 앞 장의 바퀴 로봇 위에 라즈베리파이와 카메라를 얹는 방식으로 만들 수 있다. 이미 DC 모터, 모터 드라이버, 배터리, 바퀴 프레임을 가지고 있다면 많은 부품을 재사용할 수 있다.
라즈베리파이 4, 5, Zero 2 W 등 Raspberry Pi OS를 실행할 수 있는 보드
Raspberry Pi Camera Module 또는 USB 웹캠
ESP32 개발 보드 또는 아두이노 보드
2륜 또는 4륜 DC 모터 로봇 프레임
L298N, TB6612FNG 같은 DC 모터 드라이버
모터용 배터리와 라즈베리파이용 안정적인 5V 전원
USB 케이블, 점퍼선, 고정용 브래킷 또는 케이블 타이
전원은 특히 중요하다. 라즈베리파이는 카메라와 Wi-Fi를 함께 사용할 때 순간 전류가 꽤 필요하다. 모터가 출발할 때 전압이 출렁이면 라즈베리파이가 재부팅되거나 카메라가 끊길 수 있다. 따라서 모터 전원과 라즈베리파이 전원은 분리해서 공급하고, 모터 드라이버와 마이크로컨트롤러의 GND는 반드시 공통으로 묶는다. 라즈베리파이와 아두이노를 USB로 연결하면 두 보드 사이의 GND는 USB를 통해 이미 연결된다. 다만 모터 드라이버 쪽 GND까지 빠뜨리지 말아야 한다.
카메라부터 인식시키기
처음부터 로봇을 움직이면 문제가 생겼을 때 원인을 찾기 어렵다. 카메라가 안 되는지, OpenCV가 안 되는지, 시리얼이 안 되는지, 모터 배선이 틀렸는지 한꺼번에 의심해야 하기 때문이다. 그래서 첫 단계는 매우 단순하다. 라즈베리파이가 카메라를 제대로 인식하는지 확인한다.
USB 웹캠을 사용할 때는 먼저 웹캠을 꽂고 다음 명령을 실행한다. 리눅스에서는 USB 웹캠이 보통 /dev/video0 같은 장치 파일로 나타나며, lsusb, v4l2-ctl, OpenCV의 VideoCapture로 확인할 수 있다.1
sudo apt update
sudo apt install -y v4l-utils ffmpeg python3-opencv python3-flask python3-serial
lsusb
ls /dev/video*
v4l2-ctl --list-devices
v4l2-ctl -d /dev/video0 --list-formats-ext/dev/video0가 보이면 일단 첫 관문은 통과다. 다음으로 라즈베리파이에 모니터가 연결되어 있다면 웹캠 영상을 바로 띄워본다.
ffplay -f video4linux2 -framerate 15 -video_size 320x240 /dev/video0이 명령은 /dev/video0에서 320×240 해상도, 초당 15프레임으로 영상을 읽어 화면에 보여준다. 비슷한 방식으로 USB 웹캠을 확인하고, 같은 네트워크의 다른 장치에서 영상을 보는 실습은 라즈베리파이 웹캠 로봇 프로젝트에서 자주 사용되는 기본 절차다.2
Raspberry Pi Camera Module을 쓴다면 리본 케이블 방향을 먼저 확인한다. 최신 Raspberry Pi OS에서는 다음 명령으로 카메라 미리보기를 확인할 수 있다.
rpicam-hello --timeout 0일부 환경에서는 예전 이름인 libcamera-hello를 사용한다.
libcamera-hello --timeout 0카메라 모듈이 보이지 않는다면 리본 케이블 방향, 커넥터 잠금, Raspberry Pi OS 버전, 카메라 인터페이스 설정을 차례대로 확인한다. 카메라 문제는 코드보다 케이블에서 더 자주 발생한다.
같은 네트워크에서 영상 보기
카메라가 로컬 화면에 보이면 이제 스마트폰이나 PC 브라우저에서 로봇의 시야를 볼 차례다. 이번 장에서는 Python과 Flask를 사용해 간단한 MJPEG 스트리밍 서버를 만든다. MJPEG은 여러 장의 JPEG 이미지를 빠르게 이어 보내는 방식이다. 압축 효율은 H.264보다 떨어질 수 있지만 구조가 단순하고 브라우저에서 확인하기 쉬워 실습에 적합하다. 스트리밍 방식에는 MJPEG, H.264, RTSP 등 여러 선택지가 있고, 해상도와 프레임률 설정에 따라 지연 시간과 네트워크 사용량이 달라진다.3
라즈베리파이의 IP 주소는 다음 명령으로 확인한다.
hostname -I예를 들어 IP가 192.168.0.37이라면, 나중에 PC나 스마트폰에서 다음 주소로 접속한다.
http://192.168.0.37:5000단, 라즈베리파이와 접속하는 PC 또는 스마트폰은 같은 Wi-Fi 공유기 안에 있어야 한다. 로봇을 야외에서 사용할 때는 라즈베리파이를 Wi-Fi 핫스팟으로 만들 수도 있지만, 처음 실습에서는 집이나 작업실의 같은 공유기에 연결하는 편이 훨씬 쉽다.
지연 시간을 줄이는 감각 익히기
카메라 로봇에서 영상 지연은 생각보다 큰 문제다. 화면에서는 공이 왼쪽에 있는데 실제 로봇은 이미 지나쳐버렸다면, 로봇은 계속 뒤늦은 판단을 하게 된다. 사람으로 치면 눈앞 상황이 1초 늦게 보이는 셈이다.
가장 쉬운 해결책은 해상도와 프레임률을 낮추는 것이다. 1280×720 영상은 보기에는 좋지만 라즈베리파이가 처리해야 할 픽셀 수가 많고, 네트워크로 보내야 할 데이터도 많다. 색상 추적 로봇에서는 고해상도보다 빠른 반응이 더 중요하다.
처음에는 다음 설정을 권장한다.
| 항목 | 권장 시작값 | 이유 |
|---|---|---|
| 해상도 | 320×240 | 처리량과 전송량이 작다 |
| 프레임률 | 10~15fps | 색상 추적 실습에는 충분하다 |
| JPEG 품질 | 60~75 | 화면 확인과 전송량 사이의 균형이 좋다 |
| 영상 처리 영역 | 전체 화면 또는 중앙 일부 | 필요 없는 픽셀 처리를 줄일 수 있다 |
실습할 때는 640×480으로 실행해본 뒤 320×240으로 낮춰보자. 브라우저 화면의 반응, 라즈베리파이 CPU 사용량, 모터 반응이 어떻게 달라지는지 직접 느껴보면 좋다. 카메라 로봇을 잘 만드는 사람은 코드를 많이 아는 사람이라기보다, 어느 정도 화질이면 충분하고 어느 정도 지연이면 위험한지 감각적으로 아는 사람이다.
OpenCV로 색상 물체 찾기
이제 진짜 재미있는 부분이다. 카메라 화면에서 특정 색상의 물체를 찾고, 그 중심 좌표를 계산한다. 여기서는 초록색 공이나 초록색 종이를 추적한다고 가정하자.
일반적인 카메라 이미지는 BGR 또는 RGB 형식으로 들어온다. 하지만 색상을 기준으로 물체를 찾을 때는 HSV 색공간이 더 편하다. HSV는 색을 색상, 채도, 밝기로 나누어 표현한다. 예를 들어 조명이 조금 어두워져도 색상 값은 비교적 유지되는 경우가 많아, 단순 RGB 범위보다 추적이 안정적인 편이다.
처리 순서는 다음과 같다.
카메라에서 프레임을 읽는다.
프레임을 HSV 색공간으로 변환한다.
원하는 색상 범위만 흰색으로 남기는 마스크를 만든다.
작은 점 노이즈를 제거한다.
가장 큰 윤곽선을 찾는다.
윤곽선의 중심 좌표를 계산한다.
화면 중심과 물체 중심의 x좌표 차이를 오차로 사용한다.
여기서 마스크는 매우 중요한 디버깅 도구다. 원본 화면에서 물체가 보인다고 해서 OpenCV가 물체를 보고 있는 것은 아니다. 마스크 화면에서 추적할 물체만 하얗게 보이고 나머지 배경이 검게 사라져야 한다. 마스크가 지저분하면 로봇도 지저분하게 움직인다.
라즈베리파이 영상 처리와 스트리밍 코드
다음 코드는 USB 웹캠을 기준으로 작성한 전체 예제다. 카메라 영상을 읽고, 초록색 물체를 찾고, 화면에 중심점과 명령을 표시하며, 동시에 브라우저로 영상을 스트리밍한다. 또한 시리얼 포트가 열려 있으면 아두이노나 ESP32로 이동 명령을 보낸다.
파일 이름은 camera_color_robot.py로 저장한다.
import cv2
import time
import threading
from flask import Flask, Response
try:
import serial
except ImportError:
serial = None
CAMERA_INDEX = 0
SERIAL_PORT = '/dev/ttyACM0'
BAUD_RATE = 115200
FRAME_WIDTH = 320
FRAME_HEIGHT = 240
FPS = 15
JPEG_QUALITY = 70
LOWER_HSV = (35, 60, 60)
UPPER_HSV = (85, 255, 255)
MIN_AREA = 500
STOP_AREA = 22000
DEAD_ZONE = 45
COMMAND_INTERVAL = 0.12
app = Flask(__name__)
lock = threading.Lock()
latest_jpeg = None
ser = None
last_cmd = None
last_sent = 0
def open_serial():
global ser
if serial is None or SERIAL_PORT is None:
print('Serial disabled')
return
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=0.1)
time.sleep(2)
print('Serial connected:', SERIAL_PORT)
except Exception as e:
ser = None
print('Serial open failed:', e)
def send_command(cmd):
global last_cmd, last_sent
now = time.time()
if cmd != last_cmd or now - last_sent > COMMAND_INTERVAL:
print('CMD', cmd)
if ser is not None:
ser.write((cmd + 'n').encode())
last_cmd = cmd
last_sent = now
def decide_command(cx, area, width):
if cx is None:
return 'S'
error_x = cx - width // 2
if abs(error_x) > DEAD_ZONE:
if error_x < 0:
return 'L'
return 'R'
if area > STOP_AREA:
return 'S'
return 'F'
def process_loop():
global latest_jpeg
open_serial()
cap = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_V4L2)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
cap.set(cv2.CAP_PROP_FPS, FPS)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
while True:
ok, frame = cap.read()
if not ok:
send_command('S')
time.sleep(0.1)
continue
frame = cv2.resize(frame, (FRAME_WIDTH, FRAME_HEIGHT))
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, LOWER_HSV, UPPER_HSV)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cx = None
cy = None
area = 0
if contours:
c = max(contours, key=cv2.contourArea)
area = cv2.contourArea(c)
if area > MIN_AREA:
m = cv2.moments(c)
if m['m00'] != 0:
cx = int(m['m10'] / m['m00'])
cy = int(m['m01'] / m['m00'])
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.circle(frame, (cx, cy), 5, (0, 0, 255), -1)
cmd = decide_command(cx, area, FRAME_WIDTH)
send_command(cmd)
cv2.line(frame, (FRAME_WIDTH // 2, 0), (FRAME_WIDTH // 2, FRAME_HEIGHT), (255, 255, 255), 1)
cv2.putText(frame, f'cmd:{cmd} area:{int(area)}', (10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
mask_view = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
mask_view = cv2.resize(mask_view, (160, 120))
frame[0:120, 160:320] = mask_view
ok, jpeg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
if ok:
with lock:
latest_jpeg = jpeg.tobytes()
time.sleep(0.005)
def generate():
while True:
with lock:
frame = latest_jpeg
if frame is None:
time.sleep(0.05)
continue
yield b'--framernContent-Type: image/jpegrnrn' + frame + b'rn'
@app.route('/')
def index():
return '''
<html>
<head><title>Camera Robot</title></head>
<body style='background:#111;color:white;text-align:center;font-family:sans-serif;'>
<h2>Camera Color Tracking Robot</h2>
<img src='/video_feed' style='width:640px;max-width:95%;border:2px solid #555;'>
<p>Left inset shows the color mask. Commands are printed on the video.</p>
</body>
</html>
'''
@app.route('/video_feed')
def video_feed():
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
if __name__ == '__main__':
threading.Thread(target=process_loop, daemon=True).start()
app.run(host='0.0.0.0', port=5000, threaded=True)실행은 다음처럼 한다.
python3 camera_color_robot.py라즈베리파이 화면에 CMD F, CMD L 같은 로그가 출력되면 영상 처리 루프가 동작하고 있는 것이다. 같은 네트워크의 PC나 스마트폰 브라우저에서 http://라즈베리파이_IP:5000으로 접속하면 스트리밍 화면을 볼 수 있다.
화면 오른쪽 위에는 마스크 영상이 작게 표시된다. 추적할 초록색 물체만 하얗게 보이고 배경이 검게 보이면 좋다. 만약 배경까지 하얗게 보이면 HSV 범위가 너무 넓은 것이다. 반대로 물체가 중간중간 사라지면 HSV 범위가 너무 좁거나 조명이 불안정한 것이다.
Camera Module을 쓸 때의 코드 변경
위 코드는 USB 웹캠을 기준으로 cv2.VideoCapture(0)를 사용한다. Raspberry Pi Camera Module을 쓰는 환경에서는 picamera2를 사용하는 편이 더 안정적일 수 있다. 이 경우 다음 패키지를 설치한다.
sudo apt install -y python3-picamera2그다음 카메라를 여는 부분을 다음 형태로 바꿀 수 있다.
from picamera2 import Picamera2
picam2 = Picamera2()
config = picam2.create_video_configuration(main={'size': (320, 240), 'format': 'RGB888'})
picam2.configure(config)
picam2.start()
while True:
rgb = picam2.capture_array()
frame = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)중요한 것은 카메라 종류가 아니라 이후 흐름이다. 최종적으로 OpenCV가 처리할 frame만 얻을 수 있으면, HSV 변환, 마스크, 윤곽선, 중심 좌표 계산은 동일하게 사용할 수 있다.
중심 좌표를 이동 명령으로 바꾸기
영상 처리 코드의 핵심은 decide_command() 함수다.
def decide_command(cx, area, width):
if cx is None:
return 'S'
error_x = cx - width // 2
if abs(error_x) > DEAD_ZONE:
if error_x < 0:
return 'L'
return 'R'
if area > STOP_AREA:
return 'S'
return 'F'여기서 cx는 추적한 물체의 중심 x좌표다. 화면 중심은 width // 2다. 따라서 error_x는 물체가 화면 중심에서 얼마나 벗어났는지를 나타낸다.
물체가 화면 왼쪽에 있으면 error_x가 음수이므로 로봇은 왼쪽으로 돈다. 물체가 오른쪽에 있으면 양수이므로 오른쪽으로 돈다. 물체가 화면 중앙 근처에 있으면 앞으로 간다. 단, 물체의 면적이 너무 커지면 너무 가까이 다가간 것으로 보고 정지한다.
DEAD_ZONE은 화면 중심 근처에서 로봇이 덜덜 떨리지 않게 만드는 완충 구역이다. 예를 들어 중심에서 10픽셀만 벗어나도 좌우로 돌게 하면 로봇이 계속 흔들린다. 그래서 중심 근처의 작은 오차는 무시한다. 이것은 제어 장에서 배운 오차와 보정의 개념이 영상으로 확장된 예다.
마이크로컨트롤러에서 모터 제어하기
이제 라즈베리파이가 보낸 F, L, R, S 명령을 모터 동작으로 바꿔보자. 아래 코드는 아두이노 UNO와 L298N 모터 드라이버를 기준으로 작성했다. ESP32를 사용할 때도 명령 구조는 같고, PWM 출력 핀과 전압 레벨만 보드에 맞게 조정하면 된다.
const int ENA = 5;
const int IN1 = 7;
const int IN2 = 8;
const int ENB = 6;
const int IN3 = 9;
const int IN4 = 10;
const int BASE_SPEED = 150;
const int TURN_SPEED = 140;
unsigned long lastCommandMs = 0;
const unsigned long FAILSAFE_MS = 500;
void setup() {
Serial.begin(115200);
pinMode(ENA, OUTPUT);
pinMode(IN1, OUTPUT);
pinMode(IN2, OUTPUT);
pinMode(ENB, OUTPUT);
pinMode(IN3, OUTPUT);
pinMode(IN4, OUTPUT);
stopMotors();
}
void loop() {
if (Serial.available()) {
char c = Serial.read();
if (c == 10 || c == 13) {
return;
}
lastCommandMs = millis();
if (c == 'F') {
moveForward();
} else if (c == 'L') {
turnLeft();
} else if (c == 'R') {
turnRight();
} else {
stopMotors();
}
}
if (millis() - lastCommandMs > FAILSAFE_MS) {
stopMotors();
}
}
void setLeftMotor(int speedValue) {
if (speedValue > 0) {
digitalWrite(IN1, HIGH);
digitalWrite(IN2, LOW);
analogWrite(ENA, speedValue);
} else if (speedValue < 0) {
digitalWrite(IN1, LOW);
digitalWrite(IN2, HIGH);
analogWrite(ENA, -speedValue);
} else {
digitalWrite(IN1, LOW);
digitalWrite(IN2, LOW);
analogWrite(ENA, 0);
}
}
void setRightMotor(int speedValue) {
if (speedValue > 0) {
digitalWrite(IN3, HIGH);
digitalWrite(IN4, LOW);
analogWrite(ENB, speedValue);
} else if (speedValue < 0) {
digitalWrite(IN3, LOW);
digitalWrite(IN4, HIGH);
analogWrite(ENB, -speedValue);
} else {
digitalWrite(IN3, LOW);
digitalWrite(IN4, LOW);
analogWrite(ENB, 0);
}
}
void moveForward() {
setLeftMotor(BASE_SPEED);
setRightMotor(BASE_SPEED);
}
void turnLeft() {
setLeftMotor(-TURN_SPEED);
setRightMotor(TURN_SPEED);
}
void turnRight() {
setLeftMotor(TURN_SPEED);
setRightMotor(-TURN_SPEED);
}
void stopMotors() {
setLeftMotor(0);
setRightMotor(0);
}여기서 가장 중요한 부분은 FAILSAFE_MS다. 라즈베리파이가 멈추거나 USB 케이블이 빠지거나 영상 처리 프로그램이 종료되면, 마이크로컨트롤러는 더 이상 명령을 받지 못한다. 이때 마지막 명령이 F였다고 계속 전진하면 위험하다. 그래서 0.5초 동안 새 명령이 들어오지 않으면 자동으로 정지하게 만든다.
로봇을 처음 테스트할 때는 바퀴를 바닥에 내려놓지 말고 손으로 들어 올린 상태에서 실행하자. CMD L이 출력될 때 로봇이 왼쪽으로 도는지, CMD R이 출력될 때 오른쪽으로 도는지 확인한다. 방향이 반대로 움직이면 좌우 모터 배선을 바꾸거나 코드에서 모터 방향을 반대로 조정하면 된다.
라즈베리파이와 보드 연결하기
가장 쉬운 연결 방법은 USB 케이블이다. 아두이노나 ESP32를 라즈베리파이에 USB로 연결하면 시리얼 포트가 보통 /dev/ttyACM0 또는 /dev/ttyUSB0로 나타난다.
ls /dev/ttyACM* /dev/ttyUSB*코드의 SERIAL_PORT 값을 실제 포트에 맞춘다.
SERIAL_PORT = '/dev/ttyACM0'시리얼 연결이 안 되면 영상 처리는 계속되지만 명령은 모터로 가지 않는다. 이 경우 라즈베리파이 터미널에 Serial open failed가 출력된다. 포트 이름, 권한, USB 케이블, 보드 전원을 차례대로 확인한다.
라즈베리파이 GPIO의 UART 핀으로 직접 연결할 수도 있지만, 처음에는 권장하지 않는다. 특히 아두이노 UNO의 TX는 5V 신호라서 라즈베리파이 RX 핀에 직접 넣으면 위험하다. 직접 UART를 연결하려면 레벨 시프터나 저항 분압을 사용해야 한다. 실습 단계에서는 USB 시리얼이 가장 안전하고 편하다.
HSV 값을 조정하는 방법
예제 코드는 초록색을 기준으로 한다.
LOWER_HSV = (35, 60, 60)
UPPER_HSV = (85, 255, 255)하지만 초록색이라고 해서 모두 같은 초록색은 아니다. 형광 초록 공, 진한 녹색 테이프, 연두색 종이는 카메라에서 다른 값으로 보인다. 조명도 큰 영향을 준다. 낮에는 잘 되던 값이 밤에는 흔들릴 수 있다.
처음에는 다음 방법으로 조정한다. 먼저 추적할 물체를 카메라 앞에 놓고 브라우저 화면의 마스크를 본다. 물체가 하얗게 꽉 차 보이면 좋다. 물체 안에 검은 구멍이 많으면 채도나 밝기 범위가 너무 좁을 수 있다. 배경까지 하얗게 보이면 색상 범위가 너무 넓다.
대표적인 색상의 Hue 범위는 대략 다음처럼 시작해볼 수 있다. 실제 값은 카메라와 조명에 따라 반드시 조정해야 한다.
| 색상 | Hue 시작 범위 예시 |
|---|---|
| 노랑 | 20~35 |
| 초록 | 35~85 |
| 파랑 | 90~130 |
| 빨강 | 0~10 및 170~180 |
빨간색은 HSV의 Hue 값이 원형으로 이어지기 때문에 범위를 두 개로 나누어 처리하는 경우가 많다. 예를 들어 낮은 빨강 범위와 높은 빨강 범위의 마스크를 각각 만든 뒤 더한다.
mask1 = cv2.inRange(hsv, (0, 80, 80), (10, 255, 255))
mask2 = cv2.inRange(hsv, (170, 80, 80), (180, 255, 255))
mask = mask1 | mask2색상 추적 로봇은 인공지능처럼 모든 물체를 이해하지 않는다. 대신 아주 단순한 규칙 하나를 빠르게 실행한다. '이 색이 어디에 있는가?' 이 단순함 덕분에 작은 라즈베리파이에서도 실시간으로 움직일 수 있다.
움직임을 더 부드럽게 만들기
현재 코드는 왼쪽, 오른쪽, 전진, 정지 중 하나를 선택하는 규칙 기반 제어다. 동작은 이해하기 쉽지만 조금 투박할 수 있다. 물체가 화면 중심에서 조금만 벗어나도 제자리 회전을 하기 때문이다.
더 부드럽게 만들려면 화면 중심 오차를 회전 속도에 반영하면 된다. 예를 들어 error_x가 작으면 좌우 모터 속도 차이를 조금만 주고, error_x가 크면 크게 준다. 이것은 라인트레이서에서 했던 P 제어와 같은 생각이다.
개념적으로는 다음과 같다.
turn = Kp × error_x
left_speed = base_speed + turn
right_speed = base_speed - turn이 방식으로 바꾸려면 라즈베리파이가 단순히 L, R만 보내는 대신 M 130 170처럼 좌우 모터 속도를 직접 보내도록 확장할 수 있다. 다만 처음부터 속도 명령까지 구현하면 디버깅 포인트가 늘어난다. 그래서 이번 장에서는 짧은 문자 명령으로 시작하고, 로봇이 안정적으로 동작한 뒤 속도 명령으로 확장하는 편이 좋다.
카메라 로봇 디버깅 순서
카메라 기반 로봇은 멋있지만, 문제가 생기면 원인이 여러 층에 숨어 있다. 이럴 때는 한 번에 전체를 고치려 하지 말고 아래 순서대로 확인한다.
| 증상 | 먼저 확인할 것 | 해결 방향 |
|---|---|---|
| 브라우저 영상이 안 보인다 | 라즈베리파이 IP, 같은 네트워크, Flask 실행 로그 | hostname -I, 방화벽, 포트 5000 확인 |
| 영상은 보이는데 마스크가 이상하다 | HSV 범위, 조명, 배경색 | 마스크 화면을 보며 범위 조정 |
| 중심점이 튄다 | 작은 노이즈, 반사광, 배경 물체 | MIN_AREA 증가, 노이즈 제거 강화 |
| 명령 로그는 나오는데 모터가 안 돈다 | 시리얼 포트, 보드 업로드, 모터 전원 | /dev/ttyACM0, 배터리, GND 확인 |
| 로봇이 반대로 돈다 | 좌우 모터 배선 또는 방향 함수 | 모터 선 교체 또는 코드 부호 반전 |
| 로봇이 늦게 반응한다 | 해상도, 프레임률, Wi-Fi 상태 | 320×240, 10~15fps로 낮추기 |
| 가끔 멈췄다 움직인다 | 전원 부족, USB 케이블 불량 | 라즈베리파이 전원과 모터 전원 분리 |
디버깅할 때 가장 좋은 친구는 로그다. 라즈베리파이 터미널에는 CMD L, CMD F 같은 명령이 출력된다. 브라우저 화면에는 마스크와 현재 명령이 표시된다. 마이크로컨트롤러 쪽도 필요하면 받은 문자를 시리얼 모니터에 출력해볼 수 있다. 단, 라즈베리파이가 이미 시리얼을 사용 중일 때는 PC 시리얼 모니터와 동시에 연결할 수 없으므로 테스트 단계를 나누어 진행한다.
카메라를 어디에 달아야 할까
카메라 위치는 생각보다 중요하다. 너무 낮게 달면 바닥만 보이고, 너무 높게 달면 가까운 물체의 위치 변화가 작게 느껴진다. 처음에는 로봇 전면 중앙, 바닥에서 8~15cm 정도 높이에 카메라를 달아보자. 카메라가 로봇의 정면을 바라보도록 단단히 고정한다.
USB 웹캠은 케이블이 흔들리면서 카메라 방향을 바꿀 수 있다. 케이블 타이로 케이블을 프레임에 묶어주면 영상 흔들림이 줄어든다. 카메라 모듈은 리본 케이블이 약하므로 꺾이지 않게 고정해야 한다. 로봇이 움직일 때 카메라가 덜컹거리면 화면 속 물체도 덜컹거리고, 중심 좌표도 덜컹거린다. 결국 모터 명령도 덜컹거린다.
좋은 영상 처리는 좋은 기계 구조에서 시작된다. 코드를 고치기 전에 카메라를 단단히 고정하는 것만으로도 추적이 훨씬 안정될 때가 많다.
카메라 기반 이동 로봇의 한계
이번 프로젝트는 영상 인식 로봇으로 넘어가는 훌륭한 다리이지만, 만능은 아니다. 색상 추적은 특히 환경 영향을 많이 받는다.
조명이 바뀌면 같은 물체도 다른 색으로 보인다. 햇빛이 들어오는 창가, 형광등 아래, 어두운 방에서 HSV 범위가 모두 달라질 수 있다. 배경에 비슷한 색이 있으면 로봇은 목표물을 헷갈린다. 초록색 공을 찾는데 뒤에 초록색 가방이 있으면 더 큰 가방을 목표로 삼을 수 있다.
카메라 흔들림도 문제다. 바퀴가 울퉁불퉁한 바닥을 지나가면 프레임 전체가 흔들리고, 물체 중심도 순간적으로 튄다. 영상 지연도 중요하다. 로봇은 현재를 보고 움직이는 것이 아니라, 아주 짧게나마 과거의 화면을 보고 움직인다. 속도가 빠를수록 이 지연은 더 크게 느껴진다.
그래서 카메라 로봇에서는 다음 습관이 중요하다.
원본 화면과 마스크 화면을 함께 확인한다.
중심 좌표, 면적, 명령을 화면이나 터미널에 출력한다.
로봇 속도를 처음에는 낮게 설정한다.
해상도와 프레임률을 욕심내지 않는다.
비슷한 배경색을 치우고 조명을 일정하게 만든다.
이것은 실패를 줄이는 요령이 아니라, 카메라 로봇을 실제로 작동시키는 핵심 기술이다.
확장 아이디어
이번 장의 로봇은 특정 색상 물체를 따라가는 가장 단순한 비전 로봇이다. 하지만 구조를 잘 잡아두었기 때문에 확장하기 쉽다.
먼저 서보 모터 팬틸트 장치 위에 카메라를 올릴 수 있다. 로봇 몸체를 돌리기 전에 카메라만 좌우로 움직여 목표물을 찾으면 더 자연스럽다. 다음으로 물체 면적을 거리의 힌트로 사용할 수 있다. 물체가 작게 보이면 멀리 있다고 보고 전진하고, 크게 보이면 가까이 있다고 보고 정지하는 방식이다.
더 나아가 색상 추적 대신 얼굴 검출, QR 코드 인식, ArUco 마커 추적, 간단한 객체 탐지로 확장할 수 있다. 라즈베리파이가 영상 처리와 판단을 맡고, ESP32나 아두이노가 모터를 제어하는 구조는 그대로 유지된다. 바뀌는 것은 '무엇을 볼 것인가'와 '어떤 명령을 만들 것인가'뿐이다.
이제 로봇은 단순히 센서값 하나에 반응하는 장치를 넘어섰다. 화면을 보고, 목표를 찾고, 자신의 몸을 움직여 다가간다. 다음 장에서는 이런 실전 제작 과정에서 반드시 마주치는 문제들을 모아 하드웨어 디버깅 체크리스트로 정리한다. 전원이 안 들어올 때, 모터가 안 돌 때, 센서값이 이상할 때, 로봇이 엉뚱하게 움직일 때 무엇부터 확인해야 하는지 차근차근 살펴보자.
참고
1Working with USB webcams on your Raspberry Pi
2Build a Raspberry Pi Webcam Robot – Step-by-Step Video Streaming Guide
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.