최종 데모
📊 발표 자료: shoppinkki-presentation.netlify.app
Introduction
쑈삥끼(ShopPinkki)는 에드인에듀 실물 로봇 기반 VLA 모방학습 피지컬 AI 양성과정 의 2차 프로젝트로, Pinky Pro 로봇을 활용한 미니어처 마트용 자율주행 스마트 쇼핑 카트 입니다.
Shopping Pinky = Shoppinkki
고객을 따라다니고(Follow), 매장을 안내하며(Guide), 결제까지 처리하는(Pay) 스마트 카트.
Map

- 미니어처 매장 188 × 141 cm — 로봇 2대 동시 운용
- 상품 구역 8개 (가전, 과자, 해산물, 육류, 채소, 음료, 베이커리, 즉석식품)
- 특수 구역 5개 (화장실, 입구, 출구, 결제구역, 충전소 P1/P2)
Hardware
| 항목 | 사양 |
|---|---|
| 로봇 플랫폼 | Pinky Pro × 2대 (#54, #18) |
| 컴퓨팅 | Raspberry Pi 5 (8GB) on each |
| 센서 | RPLiDAR A1, Pi Camera, IMU |
| 구동 | Dynamixel 2륜 + LCD + LED + 부저 |
| 크기 | 110 × 150 × 142 mm |
Implementation
1. 시스템 아키텍처 + 통신 프레임워크 설계

프로젝트 리딩을 맡으면서 가장 먼저 한 일은 컴포넌트 경계와 통신 채널을 정하는 것이었다. 이 결정이 흔들리면 5명이 동시에 작업할 수 없기 때문에, 초반에 인터페이스 명세에 시간을 충분히 투자했다.
네트워크 구성
노트북 한 대로 로봇 2대를 제어해야 하고 SSH 접속을 원활하게 하기 위해 고정 IP 로 구성했다. 로봇은 각각 192.168.102.18 (Pinky #18), 192.168.102.54 (Pinky #54), 노트북은 192.168.102.100. 로봇 번호 18, 54 는 그대로 ROS namespace 로 활용해서 토픽 충돌을 방지했다.
2. Finite State Machine

사용 시나리오를 그대로 상태 다이어그램으로 옮겼다. CHARGING 에서 배터리 80% 이상이 되면 IDLE 로, 사용자가 등록되면 TRACKING 으로, 가이드 요청이 오면 GUIDING 으로 전이한다. 추종 중 사용자를 놓치면 LOST, 결제가 완료되면 TRACKING_CHECKOUT, 쇼핑 종료 후 도크로 돌아갈 때는 RETURNING 상태에 들어간다.
상태별 메모리 변수(is_locked_return, previous_tracking_state)로 결제 미완료 시 출구 차단, 가이드 도중 추종 모드로 복귀 시 직전 상태 복원 같은 미묘한 제약을 표현했다. 각 상태 안에서 BT 1~5 가 세부 동작(Nav2 호출, 키퍼아웃 필터 활성화, LCD/LED 피드백 등)을 분기한다.
주요 기능
1. 카트 시작 — QR 스캔에서 TRACKING 까지
쇼핑 시작은 4단계로 일관된 UX 를 따른다.
Step 1 — QR 스캔
로봇 LCD 의 QR 코드를 스마트폰으로 스캔하면 해당 로봇 전용 웹앱으로 이동한다. 로봇별 다른 URL 이라 두 대가 동시에 운용되어도 사용자는 자기 로봇만 바라본다.

Step 2 — 고객 웹앱 접속
웹앱에 로그인하면 카트 이용이 시작된다. 로봇 상태 (CHARGING / IDLE / TRACKING / WAITING / GUIDING / PAYMENT / RETURNING) 가 SocketIO 로 실시간 표시된다.
Step 3 — 인형 등록
로봇 카메라에 본인(데모에서는 인형) 모습을 등록한다. 이때 ReID 임베딩과 HSV 히스토그램을 추출해 "주인 시그니처" 를 만든다. 이후 추종 단계에서 이 시그니처와 매 프레임 비교한다.

Step 4 — TRACKING 전환
등록 완료 시 IDLE → TRACKING 으로 상태가 자동 전환되고 로봇이 주인을 따라가기 시작한다.
2. 인형 추종 파이프라인 — YOLOv8 + ByteTracker + ReID/HSV
여러 인형이 한 화면에 등장하는 환경에서 자기 주인만 따라가야 한다는 제약이 있다. 그래서 인식·식별·제어를 4단계 파이프라인으로 나눠 풀었다.
(1) 커스텀 YOLOv8 — 인형 인식
인형 클래스를 인식하는 공개 모델이 없어서 직접 학습시킬 수 밖에 없었다. Meta의 SAM3 (Segment Anything Model) 으로 라벨링을 보조해 red_clown 같은 키워드만 입력하면 마스크가 자동 생성되는 환경을 만들고, 이렇게 라벨링한 300장으로 인형 전용 YOLOv8 모델을 학습시켰다.
(2) ByteTracker — 프레임 간 동일 객체 이어붙이기
YOLO 는 프레임마다 독립적으로 검출만 하기 때문에, "같은 인형"인지를 모른다. ByteTracker 는 프레임 간 동일 객체에 track_id 를 부여하는 다중 객체 추적 알고리즘이고, Ultralytics 에서 model.track(img, persist=True) 한 줄로 활성화된다.
results = model.track(img, persist=True)
track_id = int(det.boxes.id[0])
다만 화면을 벗어났다 들어오면 새로운 track_id 가 발급되기 때문에, 변하는 track_id 사이에서 주인을 다시 찾는 추가 단계가 필요했다.
(3) ReID + HSV 히스토그램 상관계수 — 주인 식별
- ReID (Re-Identification): torchreid 의 OSNet x0.25 를 사용해 bbox 영역을 512차원 특징 벡터 로 변환하고, 등록된 주인 임베딩과 코사인 유사도로 비교한다. 모델이 가벼워 Pi 5 에서도 실시간으로 돌릴 수 있다.
- HSV 히스토그램 상관계수: ReID 만으로는 비슷하게 생긴 다른 인형에서 오인식이 날 수 있어서, HSV 색상 히스토그램 상관계수를 보조로 썼다. HSV 는 명도(V) 가 분리되어 조명 변화에 강하다. 임계값 ≥ 0.25 로 컷.
즉, "특징도 비슷하고 색도 비슷하면 같은 인형" 이라고 판정한다. 매 프레임마다 track_id 가 바뀌어도 주인을 잃지 않는다.
데모에서 표시되는 ID 는 ByteTracker 의
track_id이고, 화면을 들락날락하면 새 ID 로 갱신된다. ReID + HSV 가 그 변화를 흡수해 동일 인물(인형)을 추적한다.
(4) bbox 기반 PI/P 제어
주인을 찾았으면 따라가야 한다. bbox 의 크기와 위치만 보고 로봇 속도를 결정했다.
선속도 (linear_x) — PI 제어
linear_x = KP_DIST × error + KI_DIST × ∫error·dt
- bbox 크기가
TARGET_SIZE보다 작으면 → 멀다 → 전진 - bbox 크기가
TARGET_SIZE보다 크면 → 가깝다 → 감속·정지 - I 를 넣은 이유: P 만 쓰면 가까워질수록 오차가 작아져서 속도도 작아지고, 결국 바닥 마찰을 못 이기고 TARGET 직전에 멈춰버린다(정상상태 오차). I 가 작은 오차를 시간에 따라 누적해 마찰을 이긴다.
- D 를 뺀 이유: bbox 크기는 프레임마다 노이즈가 크다. D 가 노이즈에 반응해 속도가 진동하기 때문에 의도적으로 PI 만 썼다.
각속도 (angular_z) — P 제어 + deadzone
angular_z = KP_ANGLE × (화면중심X − bbox중심X)
- bbox 중심 X 가 화면 중앙에서 벗어난 만큼 회전
- deadzone ±45px: 중앙 근처에서 bbox 가 몇 픽셀씩 흔들려도 회전 명령을 보내지 않음 → 좌우 떨림 방지
- I 를 뺀 이유: 각도 오차가 누적되면 목표 방향을 지나쳐 오버슈팅하고, 반대 방향으로 또 오버슈팅하며 진동이 이어진다.
- Low-pass 스무딩: D 대신 출력값에
new × 0.3 + prev × 0.7로 저역 통과 필터를 걸어 부드럽게 변하도록 했다.
3. 가이드 — 자연어 검색 + Fleet 자율주행
가이드는 사용자가 "음료수 어디 있어?" 라고 입력하면 그 구역까지 자율주행으로 데려다주는 기능이다. 텍스트 → LLM/벡터 분석 → zone_id → Nav2 자율주행으로 동작한다.
검색 파이프라인 — 우선순위 분기
비싼 LLM 호출을 마지막 fallback 으로 두기 위해 4단계 우선순위로 입력을 처리했다.
| 우선순위 | 단계 | 처리 |
|---|---|---|
| 0 | 입력 방어 | 매장 안내 범위 밖 발화는 즉시 거절 응답 (LLM 비용·오답 차단) |
| 1 | 키워드 매핑 | "목말라" → 음료 구역 같은 직역 매핑이 있으면 즉시 hit |
| 2 | LLM + 벡터 | Ollama Qwen2.5 3B 로 키워드 추출 → pgvector 코사인 유사도 검색 |
| 3 | 구역 안내 | 결과 zone_id → DB 좌표 조회 → Nav2 목표로 전달 |
"매운 라면 어디 있어?" 처럼 키워드 매핑이 안 되는 자유 발화도 LLM + pgvector 로 의미적으로 가까운 구역(과자 옆 즉석식품 등)으로 안내된다.
Fleet 라우팅 — Open-RMF 도입에서 자체 구현으로 전환
가이드 자율주행에는 처음 Open-RMF 를 도입했다가, 결국 자체 Fleet Router 로 전환했다.
Open-RMF란? 제조사가 달라도 여러 로봇을 하나의 시스템으로 묶어 관리하는 오픈소스 플랫폼. 경로 충돌 방지, 작업 자동 할당, 이기종 로봇 통합, 인프라(엘리베이터·자동문) 연동을 지원한다.
처음 해보는 다중 로봇 경로 계획이라 "Open-RMF 쓰면 자동으로 쉽게 되겠지" 하고 시작했지만 막상 써보니 Ubuntu 에서만 동작, 노드 기동에만 수십 초, 고작 2대를 위한 런타임 오버헤드가 과하다는 문제가 있었다. 결국 의존성은 제거하되 Open-RMF 의 Nav Graph YAML 포맷은 그대로 재활용 했다.
Nav Graph — 점과 선으로 표현한 지도

- Vertex (점): 로봇이 도달 가능한 위치 단위. 모든 경로는 vertex 에서 시작·종료
- Lane (선): vertex 를 잇는 주행 경로. 단방향 lane 으로 일방통행 표현 가능
- 매장 맵은 39 vertices + lanes 로 정의해
shop_nav_graph.yaml에 저장, DBfleet_waypoint/fleet_lane테이블로 시딩
경로 조율 방식 — Open-RMF vs 자체 Fleet Router
| 항목 | Open-RMF (Traffic Schedule + Negotiation) | 자체 Fleet Router (Graph Routing + Lane Lock) |
|---|---|---|
| 예약 차원 | 시공간 4D — 궤적 + 시간축 | 공간 — 그래프 lane 선점 |
| 충돌 해결 | 로봇 간 협상 프로토콜로 궤적 수정 | Dijkstra 페널티 가중치 로 자동 우회 |
| 아키텍처 | 분산 — Fleet Adapter + Schedule 노드 | 중앙 집중 — FleetRouter 단일 노드 |
| 강점 | 대규모·이기종 fleet | 2대 규모에 단순·충분 |
자체 구현의 핵심은 Dijkstra 페널티 가중치 다. 다른 로봇이 이미 점유한 lane 에 큰 가중치를 더하면 Dijkstra 가 알아서 우회 경로를 찾아오기 때문에, 별도 협상 프로토콜이 필요 없다.
단, 시간축이 없기 때문에 다음과 같은 단점이 있다.
- 한 로봇이 lane 을 다 통과할 때까지 다른 로봇 대기
- 좁은 통로에서 같은 lane 을 동시에 노리면 데드락 가능
- 중앙집중이라 FleetRouter 가 죽으면 전체 정지·로봇 수가 늘면 병목.
본 프로젝트는 2대 규모라 이 단점을 감수할 만했다.
가이드 자율주행 데모
4. 장바구니 — QR 스캔 + WebSocket 실시간 동기화
장바구니는 사용자가 휴대폰으로 직접 관리한다. QR 스캔으로 추가, 목록에서 직접 삭제, WebSocket 실시간 동기화 세 가지 동작이 핵심이다.
- 상품 박스의 QR 을 스캔하면 즉시 장바구니에 담기고 web 앱에 반영
- 잘못 담은 항목은 목록에서 직접 삭제
- 한 사용자가 여러 디바이스를 써도 SocketIO 로 모든 디바이스가 동기화
5. 대기 — TRACKING ↔ WAITING 토글
추종 중에 사용자가 잠깐 따로 움직이고 싶을 때를 위한 기능이다. 웹앱의 [따라가기] / [대기하기] 탭으로 상태를 직접 토글할 수 있다.
- [대기하기] 탭 →
TRACKING → WAITING전이, 로봇은 그 자리에 정지 - [따라가기] 탭 →
WAITING → TRACKING복귀 - WAITING 상태에는 시간 제한 이 있고, 초과되면 자동으로 충전소로 복귀한다 (방치된 카트가 통로를 막는 것 방지)
6. 결제 — SM 차원 출구 차단
결제 구역에 진입하면 사용자 휴대폰에 결제 팝업이 자동으로 뜬다. 출구 차단은 State Machine 분기 로 구현된다 — 별도의 Nav2 keepout 필터 없이 상태값 자체로 결제구역 통과 가능 여부를 결정한다.
| 상태 | 결제구역 통과 | 진입 조건 |
|---|---|---|
TRACKING | ❌ | 쇼핑 중 기본 상태 |
TRACKING_CHECKOUT | ✅ | 결제 완료 (enter_tracking_checkout) |
즉, 결제하지 않으면 카트가 출구로 향하는 경로 자체를 따라가지 않는다. 결제 완료 시 TRACKING_CHECKOUT 으로 전이되어 결제구역 통과가 허용된다.
미결제 채로 사용자가 "보내주기" 를 누르면 → LOCKED 상태로 전이된다. LOCKED 는 BT 5 를 공유해 자동으로 충전 스테이션으로 귀환하며 (is_locked_return=True 플래그 유지), 도크 도착 후 LED 로 잠금 신호를 띄워 스태프가 직접 해제 해야 정상 운영으로 돌아온다.
7. 복귀 — 빈 충전 슬롯 자동 탐색
쇼핑이 끝나면 로봇은 빈 충전 슬롯을 조회해 도크로 자율 복귀한다.
쇼핑 종료 → 빈 충전 슬롯 조회 → RETURNING → 충전소 도착 → 세션 종료
- 쇼핑 도중이든 종료 후든 언제든 복귀 명령 가능
- 미결제 항목이 남은 채로 복귀하면 LOST 상태 로 전환된다. 충전소 도착 후 직원이 직접 해제해야 정상 운영으로 돌아오기 때문에, 도난 시도 같은 비정상 시나리오를 자연스럽게 차단한다.
Tech Stack
- OS / ROS: Ubuntu 24.04, ROS 2 Jazzy Jalisco
- 추종 비전: YOLOv8 (SAM3 라벨링), ByteTracker, torchreid (OSNet x0.25), HSV 히스토그램
- 제어: PI(선속도) + P(각속도, deadzone ±45px), Low-pass 스무딩
- Navigation: Nav2, SLAM Toolbox, 자체 Fleet Router (Dijkstra + lane lock)
- LLM: Ollama (Qwen 2.5 3B) + pgvector
- 통신: ROS 2 DDS (CycloneDDS), Flask, SocketIO, TCP/UDP
- DB: PostgreSQL 17 (Docker)
- UI: PyQt5 (Admin), Flask (Customer Web)
