자연어로 지시하면 SO-101 양팔 로봇이 책상 위 쓰레기를 쓸어 담는 Physical AI 데모. Vision(카메라·YOLO) · Language(LLM·의도 게이트) · Action(LeRobot ACT 정책)을 하나의 파이프라인으로 통합했다.
위 영상: PAI-Language UI 에
청소해줘입력 → 5557 트리거 발행 → ACT 정책이 양팔을 구동해 초록 조각 3개를 쓰레받기 안으로 수거. 원본 화질:docs/media/demo.mp4(Jetson AGX Thor + SO-101 양팔, 실기 촬영)
역할 — Language 파트(자연어 이해·의도 게이트), LeRobot 연동 어댑터(rollout_with_zmq_task),
3창 통합과 오케스트레이션을 맡았다. Vision 파트는 팀원 @yeounhyeok 의
원저작이며 git subtree 로 히스토리째 합쳤다.
PAI-Vision ──ZMQ:5555 raw 프레임(front/wrist)──▶ LeRobot (ACT 롤아웃, ZMQCamera)
└───────WS:8000 장면 JSON────────────────▶ PAI-Language (LLM)
PAI-Language ──ZMQ:5557 instruction──────────▶ LeRobot (rollout_with_zmq_task 어댑터)
| 채널 | 포트 | bind 주체 | 소비 주체 |
|---|---|---|---|
| raw 프레임 (ZMQ) | 5555 | Vision | LeRobot ZMQCamera (camera_name=front/wrist) |
| 장면 JSON (WS) | 8000 | Vision | Language |
| instruction (ZMQ) | 5557 | Language | rollout_with_zmq_task |
카메라는 Vision 이 단독 소유한다. 같은 USB 카메라를 두 프로세스가 동시에 열 수 없어서,
로봇 쪽은 /dev/video* 를 직접 열지 않고 Vision 이 중계하는 ZMQ 프레임을 관측으로 받는다.
프레임 발행기는 Vision 파트에 있다(팀원 원저작).
LeRobot 롤아웃은 시작하면 끝까지 계속 돈다. 이 데모는 "말했을 때 그 한 번만" 움직여야 하므로,
롤아웃을 고치는 대신 get_action 을 감싸는 어댑터를 만들었다
(rollout_with_zmq_task.py).
실행 창 밖에서는 정책 출력 대신 캡처해둔 초기 자세를 계속 명령해 팔을 그 포즈에 고정한다.
트리거 수신 → 2초 대기(즉시 움직이면 팔이 튄다) → N초 구동 → 복귀. "멈춰" 는 창을 즉시 닫는다.
여기서 스레드 경계가 걸린다. instruction 은 ZMQ SUB 스레드로 들어오는데 get_action 은
제어 루프 메인 스레드에서 불린다. 트리거마다 엔진을 reset 해야 하는데
(→ 겪은 문제와 원인) SUB 스레드에서 engine.reset() 을 직접 부르면
추론 중인 상태를 건드린다. 락 대신 reset_pending 플래그로 풀었다 — SUB 는 set 만,
메인이 실행 창 첫 스텝에서 consume. 쓰는 쪽과 지우는 쪽이 하나씩이라 경합이 성립하지 않는다.
정책 act-trash-gathering 은 ACT(언어 비조건부) 다. instruction 의 내용 으로 행동이
바뀌지 않고, 학습된 단일 태스크(쓰레기 모으기)만 수행한다. 그래서 "말대로 조종"이 성립하지 않는다.
대신 Language 가 의도를 판별해 그때만 로봇을 트리거한다.
| 입력 | 동작 |
|---|---|
"쓰레기 좀 모아줘" · "책상 정리해줘" · "clean up" |
트리거 발행 → EXEC_SECONDS 동안 구동 후 정지 |
"마우스 집어줘" · "안녕?" |
대화 응답만, 로봇 미실행 |
"멈춰" |
STOP sentinel 발행 → 즉시 정지 + ACT 큐 비우기 |
판별 키워드는 language/.env 의 TRASH_KEYWORDS 로 조정한다.
게이트 구현: language/language/main.py 의 _is_trash_intent.
실기로 돌리며 만난 것 중, 증상만 보고는 원인을 못 맞힌 것들.
| 증상 | 실제 원인 | 대응 |
|---|---|---|
| 같은 명령인데 1·2회차 동작이 다름 | ACT 내부 액션 청크 큐·temporal-ensemble 버퍼가 idle 동안 얼어 있다가 잔여분부터 재생 | 트리거마다 engine.reset(). SUB↔메인 스레드 레이스는 reset_pending 플래그로 회피 |
| 개선했더니 동작이 위험하게 빨라짐 | --fps 는 상한만 지정 — 이전 CPU 경로가 25Hz 를 못 채우고 있었을 뿐 |
--fps 인자 노출 + 기본 20Hz |
| 미리보기 창이 2개 뜸 | 카메라 워커마다 cv2.imshow |
show_window — 첫 카메라만 창, 나머지는 스트림만 |
첫 항목의 reset 은 소프트웨어 잔여만 없앤다. ACT 는 현재 팔 자세와 화면으로 궤적을
재현하므로, 트리거 사이에 팔이 초기 자세로 돌아가지 않거나 쓰레기 배치가 달라지면 동작은
여전히 달라진다. 이건 정책의 성질이라 코드로 지울 수 없다.
두 번째는 방향이 반대다 — 빨라진 것이 문제를 만들었다. 느린 CPU 경로가 우연히 속도 제한기 노릇을 하고 있었을 뿐이고, 목표 25Hz 를 꽉 채우자 오히려 학습 분포와 어긋났다.
이 외에 컨테이너 venv 가 uv 기반이라
pip이 없던 문제,docker commit이 entrypoint 를 삼켜 이미지가 깨진 사고, 매 실행 44MB 를 재다운로드하던 torch 캐시 등 8건의 기록:docs/history/
| 디렉터리 | 역할 |
|---|---|
vision/ |
카메라 단독 소유 → YOLO 장면(WS:8000) + raw 프레임(ZMQ:5555) 송출 |
language/ |
자연어 → OpenAI → 대화 응답 + 의도 게이트(ZMQ:5557). LeRobot 어댑터 scripts/rollout_with_zmq_task.py 포함 |
orchestration/ |
실행 스크립트, Docker(lerobot-workspace), 캘리브레이션, udev 포트 고정, 정책 설정 |
docs/ |
런북 · 트러블슈팅 기록 · 데모 미디어 |
# 1) venv 2개
cd vision && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && cd ..
cd language && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && cd ..
# 2) .env (키는 저장소에 없다)
cp vision/.env.example vision/.env # CAMERAS=front:2,wrist:0, ZMQ_PUBLISH_ENABLED=true
cp language/.env.example language/.env # ★ OPENAI_API_KEY, INSTRUCTION_PUB_ENABLED=1
# 3) 정책 가중치 (198MB — 저장소에 없다). 공개 저장소라 토큰·추가 패키지 불필요
bash orchestration/scripts/fetch-model.sh
# 4) 하드웨어 포트 고정 (팔 serial / 카메라 USB 포트 기준 심볼릭 링크)
sudo bash orchestration/scripts/install-udev.sh
# 5) Docker 이미지
sudo docker compose -f orchestration/docker/docker-compose.yml buildxhost +local:
orchestration/scripts/pai-run-all.sh 25 # 명령 1건당 구동 25초창1(Vision) → 창2(Language UI) → 창3(LeRobot) 순서로 뜬다. 창2에 "쓰레기 좀 모아줘" 를 입력하면 로봇이 움직인다.
수동 실행과 단계별 격리 검증은 런북 참고.
하드웨어 없이 파이프라인 뼈대를 확인할 수 있다. 로봇을 붙이기 전에 이걸로 채널부터 격리해 두면 실패가 소프트웨어인지 하드웨어인지 바로 갈린다.
language/.venv/bin/python -m pytest language/ # 유닛테스트
language/.venv/bin/python orchestration/scripts/verify-dryrun.py # 의도 게이트 + 5557 채널 + STOP| 항목 | 상태 |
|---|---|
| Language 유닛테스트 | 113 passed |
| 의도 게이트 (트리거 4종 / 차단 4종) | 통과 |
| Language → ZMQ:5557 → 롤아웃 어댑터 채널 | 통과 (dry-run) |
| STOP sentinel 분기 | 통과 |
| 하드웨어 E2E (docker · ZMQ:5555 프레임 · 실제 구동) | 위 데모 영상으로 입증 |
- 호스트: Jetson AGX Thor (aarch64, CUDA 13 / sm_110), Ubuntu 24.04, Python 3.12
- 로봇: SO-ARM101 양팔 (follower 2 + leader 2), CH340 USB-serial
- 카메라: 동일 모델 UVC 웹캠 2대 (front / wrist) — serial 이 같아 USB 포트 경로로 구분
- LeRobot: v0.5.2 고정 (
bd9619df)
⚠️ udev 규칙의 팔 serial 과 카메라 USB 포트 경로는 이 하드웨어 고유다. 다른 장비에서는orchestration/docker/udev/아래 규칙을 자기 값으로 교체해야 한다.
| 종류 | 링크 |
|---|---|
| ACT 정책 | danny1002/act-trash-gathering |
| 데이터셋 | danny1002/trash_gathering (Apache-2.0) |
| LeRobot | huggingface/lerobot v0.5.2 |
이 저장소는 세 갈래로 개발되던 작업을 git subtree 로 히스토리째 합친 것이다.
| 파트 | 원본 저장소 |
|---|---|
vision/ |
yeounhyeok/PAI-Vision — 원저작 |
language/ |
donghee-ai/PAI-Language |
orchestration/ |
hsu-pai/PAI-setup (+ so_arm101_setup 고유 자산) |
흡수된 히스토리를 조회하는 법
git subtree add 로 합친 커밋들은 흡수 당시의 원래 경로를 유지한다. 따라서
git log -- vision/… 로는 조회되지 않고, 원본 경로나 흡수 지점 커밋을 기준으로 봐야 한다.
git log --oneline --graph --all # 전체 그래프 (병합 지점이 보인다)
git log --full-history -- app/adapters/run_all.py # 원본 경로로 Vision 이력
git log --oneline f7f6275 # 흡수된 Vision tip 에서 거슬러 올라가기