[Raspbot] 왜 YOLOv8n인가? 객체 인식과 FSM 제어
Yolov8n 객체 인식
수업 시간 예제로 받은 코드는 Haar Cascade 분류기를 사용하여 장애물이나 신호등을 인식하도록 되어 있다. Haar Cascade는 가볍고 빠르다는 장점이 있지만, 조명 변화에 취약하고 오검출이 많으며, 객체가 회전하거나 가려졌을 때 인식률이 현저히 떨어지는 단점이 있다.
나는 이번 프로젝트의 목표가 단순한 ‘기능 구현’이 아니라 ‘실제 주행 환경에서의 강건함(Robustness)’을 확보하는 것이기 때문에, 최신 객체 인식 모델인 YOLOv8n (Nano)을 도입하기로 결정했다.
관련 라이브러리 설치를 위해서는 인터넷 연결이 필수적이지만, 나는 미리 AP 모드에서 STA 모드로 전환하였기 때문에 아무 문제 없이 개발을 진행할 수 있었다.
기술 비교 (Haar Cascade vs YOLOv8n)

- Har Cascade (수업 예제)
- 2001년에 제안된 머신러닝 기반의 객체 검출 알고리즘. 영상의 명암 차이를 이용해 물체를 찾는다.
특징
- 가볍다 : CPU 연산만으로 라즈베리 파이에서 충분히 돌아간다.
- 단순하다 : OpenCV에 기본 내장되어 있어 코드 수정이 크게 필요없다.
단점
- Feature Engineering의 한계 : 사람의 정의한 명암비에 의존하므로, 그림자나 역광 등 조명 노이즈에 매우 취약하다.
- 회전 불변성 부족 : 대상이 조금만 기울어져도 인식하지 못한다.
- 낮은 정확도 : 오검출이 잦아 주행중 급정거를 유발할 수 있다.
- YOLOv8n (내가 선택한 방법)
- Ultralytics에서 2023년 출시한 최신 객체 인식 모델. v8n은 그중 가장 가벼운(Nano) 모델로 엣지 디바이스용이다.
특징
- 높은 정확도 : 딥러닝 기반으로 특징을 스스로 학습하므로 복잡한 환경에서도 강건하다.
- 속도 : 다른 객체 인식 알고리즘보다 빠른편이다. (물론 Haar 보다는 무겁지만 Pi5에서는 충분히 구동 가능)
- 다중 객체 인식 : 겹쳐있는 물체나 작은 물체도 잘 잡아낸다.
선정 이유
- 라즈베리 파이 카메라의 노출이 수시로 바뀌는 상황에서도 안정적인 검출이 필요했다.
- 추후에도 사람, 자동차, 꼬깔콘 등 클래스를 쉽게 추가하여 학습시킬 수 있다는 확장성이 좋았다.
YOLOv8n 도입 이유
“단순함보다는 신뢰성을 선택”
자율주행에서 객체 인식의 핵심은 보지 말아야 할 것을 보지 않는 능력과 봐야 할 것을 놓치지 않는 능력이라고 생각했다.
수업 코드인 Haar Cascade를 테스트해본 결과, 학습 시킨 객체가 아닌 바닥의 패턴이나 창문 등을 오인하는 경우가 빈번했다. 라즈베리 파이 카메라의 노출이 수시로 바뀌는 상황에서도 안정적인 검출이 필요했다.
추후에 사람, 자동차, 꼬깔콘 등 클래스를 쉽게 추가하여 학습시킬 수 있다는 확장성이 좋았다.
따라서 나는 다음과 같은 전략으로 YOLOv8n을 프로젝트에 적용했다.
- 경량화 모델 사용 : 라즈베리 파이의 자원을 고려하여 가장 가벼운 nano 모델을 사용한다.
- 커스텀 데이터셋 학습 : 기본 상용 데이터셋이 아닌, 프로젝트 환경에 맞는 데이터를 직접 수집하여 Fine-tuning 한다.
YOLOv8n 학습 과정
-
라즈봇 v2의 카메라를 사용해서 주행상황에서 발생할 수 있는 장면을 순차적으로 찍는다.


- 학습 데이터는 총 616장의 사진 데이터셋을 확보했다.
-
Roboflow 플랫폼에서 학습 데이터 라벨링을 한다.

- 라벨링 할 때 우리가 인식에 필요한 5가지 클래스로 분류하였다.
- car : 차량 장애물
- red : 신호등 빨간불
- green : 신호등 초록불
- oo : 주차 장소 O 마커
- xx : 주차 장소 X 마커
- 라벨링 할 때 우리가 인식에 필요한 5가지 클래스로 분류하였다.
-
Colab에서 학습을 진행한다. 로컬 pc에 GPU가 없는 맥 환경이라 학습 시간 단축을 위함이다.

-
best.pt학습 파일을 얻어서 코드에 적용한다.
YOLO를 적용한 FSM 구조
이제 실제 코드에 객체 인식 결과를 적용하는 단계이다.
1차 평가 때는 길을 따라 주행만 하는 것이 목표였기 때문에, 좌/우/직진을 고르는 단발성 판단 중심이였다.
최종 평가는 미션 상황(신호등/위험물/주차)에 따라 모드를 바꾸는 FSM 구조로 구현하는 것이 적합하겠다고 생각했다.
FSM Layer 구조
- Layer A (기본 주행 판단) : “차선 따라가자” → 기본적인 주행 DRIVE 모드를 총괄
- Layer B (미션 판단) : “지금은 신호등/위험물/주차 상황이다” → DRIVE 모드의 제어에 덮어쓰기
FSM(Finite State Machine)의 장점
여러 상황별로 작동해야 하는 복잡한 로직을 단순히 if-else나 switch문으로 나열하는 것 대신 FSM을 사용하게 되면
- 코드의 복잡도를 제어하고, 유지보수를 쉽게 만들 수 있다.
- 코드를 읽을 때 흐름을 파악하기 어려워지는 것을 방지하고
- 코드가 상태(State)별로 격리 되므로 로직이 깔끔하고 직관적이다.
- 현재 상태가 어떤 모드인지에 따라 제어가 명확하게 구분된다.
- 디버깅 할 때, 현재 상태가 무엇인지만 판단하고 그 상태에 대한 로직만 수정할 수 있어서 편리하다.
내가 정의한 5가지 상태
우리는 아래와 같은 5가지 모드를 정의했다.
- DRIVE : 기본 차선 추종 주행
- red_trigger가 뜨면 WAIT 상태로 넘어가고, 그 상태에서 계속 정지 유지(force_stop)
- WAIT_TRAFFIC_LIGHT : 신호등 대기 + 비프음 3회 (빨간불 정지, 초록불 출발)
- 초록불이 확실하고 + 빨간불이 없어졌을 때 다시 DRIVE 상태로 복귀
- HAZARD_ACTION : 위험물(자동차) 인식 시 정지 + 비프음 3회
- 지정 위험 물체 인지가 되면 HAZARD 상태로 가고, 정지 + 비프음 3회
- 비프음 3회 실행은 tick 기반(논블로킹)으로 계속 루프를 살려둔 구조
- time.sleep(1)을 사용해서 1초 동안 카메라도 꺼지고 판단도 멈추는 상황 방지
- 지정 위험 물체 인지가 되면 HAZARD 상태로 가고, 정지 + 비프음 3회
- PARKING_APPROACH : 바닥의 O 주차 마커로 차량 방향을 맞추며 접근 상태
- O 마커를 인지하면 접근 상태가 되어 기본 조향이 꺼지고 O 마커 중심을 향하는 조향만 작동
- 조향 로직은 O 마커 중심과 내 시야 중심선을 일치시키는 기본 조향 로직과 동일
- O 마커를 인지하면 접근 상태가 되어 기본 조향이 꺼지고 O 마커 중심을 향하는 조향만 작동
- PARKING_HOLD : O 마커 위에 도달했다고 판단되면 정지 유지 (주차 완료 상태)
- O 마커가 일정 프레임 이상 시야에서 안 보이면(차량 발 밑에 도달해서) 파킹 상태가 되어 완전 정지
트리거 기준
각 상태는 미리 정해놓은 크기 파라미터가 있고, 카메라에서 임계 크기 이상으로 화면에서 인식되면 해당 상태로 전환된다.
- n 프레임 연속 확인
- 오인식에 갑자기 반응하는 것을 방지하기 위해
confirm_frames파라미터로 n회 이상 감지되는 경우에만 상태 이전
- 오인식에 갑자기 반응하는 것을 방지하기 위해
- 한 번 발동되면 쿨다운 시간동안 재발동 금지
- 인식 후 바로 재인식 되어서 무한히 상태 이전 되는 것 방지
min_area_ratio:
red: 0.001
green: 0.001
oo: 0.020
xx: 0.015
car: 0.030
각 주행 모드에서 내보내는 제어 명령은 MissionCommand 라는 공용 명령 묶음으로 전달한다.
여기 안에, base_speed / steering / force_stop / beep_override
제어 안정화
길을 인식하지 못해 LOST 상태가 되면, 일단 3초 정지해서 기다려 본후 후진을 2초 동안 수행해서 길을 다시 찾을 수 있게 하는 로직을 추가했다.
코너를 너무 크게 돌아서 라인을 놓쳤을 때, 뒤로 물러나서 주행을 재개할 수 있을 것으로 기대된다.
전체 코드 다이어그램
<엔트리/런타임>
scripts/run_phase1.py
↓
raspbot/runtime/phase1_baseline.py:main() → run(cfg, args)
- motors_enabled: 's' 키로 토글(초기 False). False면 실제 모터 출력은 하지 않고(direction=PAUSE) 인지/제어/미션 계산만 계속 수행
- show_windows/enable_sliders: 설정 + --headless 옵션으로 OpenCV 윈도우/트랙바 분기
- enable_sliders=True면 매 프레임 ROI/PID/턴/헤딩/카메라/서보/YOLO 임계/추론주기 값을 트랙바에서 갱신
<인지 영역>
원본 프레임 (Camera.read())
↓
calculate_roi_points() → ROI 좌표 (pts_src)
↓
apply_roi_overlay() → ROI 박스 오버레이(디버그용)
↓
warp_perspective() → 버드아이뷰 변환
↓
(모드 선택) detect_road_lines()
├─ mode = lab: LAB 임계 기반 이진화
└─ mode = hsv: HSV/밝기 임계 기반 이진화
↓
binary (이진 이미지)
- 255: 검출된 차선/라인, 0: 도로(배경)
↓
compute_lane_error(binary) → (error_norm, centroid_x)
- 현재 run()에서는 centroid_x만 디버그에 사용, error_norm은 미사용
↓
estimate_heading(binary)
├─ road_mask = bitwise_not(binary) # 255=도로 영역(전경)
├─ connect_close_px: MORPH_CLOSE로 도로 덩어리의 작은 끊김을 연결
├─ [NEW] Connected Components 라벨링 후, P3(하단 밴드) 중심이 잡히면:
│ 1) 'P3가 속한 덩어리(도로)'만 남김 → target_mask
│ 2) P2 라벨이 다르더라도 merge_gap_px 내에서 맞닿으면 P2+P3 병합
│ 3) P3가 안 잡히거나 CC 실패 시: 전체 road_mask 사용(fallback)
├─ p1_margin_px: target_mask 전체를 dilation(상단 중심(P1) 안정화 목적)
├─ target_mask 위에서 밴드별 중심(P1/P2/P3) 계산 → centers
├─ slope_norm: 기울기 (-1~1) # 하단 중심 대비 상단 중심 이동량
└─ heading_offset: 상단 오프셋 (-1~1) # PID 입력(EMA 적용)
(참고: 중심이 2개 미만이면 slope_norm=None, heading_offset도 None/1점 기준으로만 계산)
↓
<제어 영역>
(Loop 초반) [NEW] beep_seq.tick(now) # 논블로킹 비프 시퀀스 진행
↓
Heading 스무딩 (EMA)
if heading_offset != None:
heading_prev = α * heading_offset + (1-α) * heading_prev
heading_used = heading_prev
else:
heading_used = None
↓
PID 제어기
입력: heading_used
출력: steering_output (heading_used==None이면 0, 아니면 PID 계산 + 제한 적용)
↓
상태 판별 (차선 기반)
if heading_used == None → LOST
elif (|slope_norm| > turn_slope_thresh OR |heading_used| > turn_offset_thresh)
→ TURN_LEFT / TURN_RIGHT # 방향은 heading_used 부호로 결정
else → STRAIGHT
↓
상태별 파라미터 조정 (차선 기반)
if TURN:
effective_speed = base_speed * turn_speed_scale
effective_steer_scale = steer_scale * turn_steer_scale
else:
effective_speed = base_speed
effective_steer_scale = steer_scale
↓
controller.base_speed = effective_speed
controller.steer_scale = effective_steer_scale
(참고) VehicleController:
- steer = steering * steer_scale (±deadband 이하면 0으로 처리)
- left = base_speed + steer, right = base_speed - steer (±speed_limit로 clamp)
↓
<YOLO 인지 + 미션 판단 영역> ★여기서부터가 Phase3 추가 핵심
↓
[NEW] YOLO 추론 + 이벤트 안정화 (비동기: YoloAsyncRunner 사용)
yolo_runner.update_settings(infer_interval_s, min_area_ratio)
yolo_runner.submit_frame(frame) # 최신 프레임만 유지(드랍 가능)
detections, triggers, raw_boxes, ts = yolo_runner.poll()
- detections: min_area_ratio 통과한 클래스별 "가장 큰 박스"(area_ratio 최대)
- triggers : Confirm Frames / Cooldown / release_frames 조건까지 만족한 '원샷 발생 신호'
(참고: detector.update_thresholds()/detector.step()는 워커 스레드에서 실행)
↓
[NEW] MissionFSM.update(lane_steering, lane_speed, detections, triggers, now)
├─ 상태 전이:
│ DRIVE
│ ├─ red 트리거 → WAIT_TRAFFIC_LIGHT (비프 3회 패턴 enqueue)
│ ├─ car 트리거 → HAZARD_ACTION (hazard_beep_delay 후 비프 패턴, 완료되면 DRIVE 복귀)
│ └─ oo 트리거 → PARKING_APPROACH
│
│ WAIT_TRAFFIC_LIGHT:
│ └─ green 트리거 AND red 미검출일 때만 DRIVE 복귀(깜빡임 안정화)
│
│ PARKING_APPROACH:
│ └─ oo 미검출 누적 >= missing_frames → PARKING_HOLD(정지)
│
├─ 출력(오버라이드) 생성:
│ force_stop = (WAIT_TRAFFIC_LIGHT, HAZARD_ACTION, PARKING_HOLD)
│ base_speed = lane_speed (기본은 차선 속도)
│ steering = lane_steering (기본은 차선 조향)
│
│ PARKING_APPROACH 일 때:
│ base_speed = min(base_speed, parking_approach_speed) # 속도 감속 (고정값)
│ steering = parking_steer_kp * (O 중심 x 정규화) * steer_limit
│
│ force_stop 이면:
│ base_speed = 0, steering = 0
↓
mission_cmd = { base_speed, steering, force_stop, state }
controller.base_speed = mission_cmd.base_speed # 최종 속도 확정
<최종 구동/안전 로직>
↓
if motors_enabled:
├─ if mission_cmd.force_stop:
│ controller.stop()
│ direction = mission_cmd.state
│
├─ elif (state == LOST) AND (mission_cmd.state == DRIVE):
│ [NEW] LOST 복구 시퀀스
│ - 일정 대기(lost_wait_s) 후
│ - 일정 시간 후진(lost_reverse_duration_s, speed = -lost_reverse_speed)
│ - 그 외에는 정지 + (옵션) fail_safe_beep
│
└─ else:
applied_left_speed, applied_right_speed, drive_dir
= controller.drive(mission_cmd.steering)
else:
controller.stop() # PAUSE
<하드웨어 출력>
↓
RaspbotHardware.drive(left_speed, right_speed)
↓
set_motor_speeds(left, left, right, right)
Ctrl_Muto(0, left),
Ctrl_Muto(1, left),
Ctrl_Muto(2, right),
Ctrl_Muto(3, right)
↓
모터 출력 (4채널)
<디버그 시각화(윈도우 켰을 때만)>
- visualize_binary_debug( bitwise_not(target_mask) if target_mask else binary, direction, centroid_x, mission_cmd.steering, fps, ... )
- YOLO 박스는 raw_boxes(last_results 기반)로 전부 그리되, 면적비 임계 이상이면 노란색으로 강조 표시