PinkRab - 전체 목차1장

PinkRab - 벽에 붙은 6자유도 로봇과 브라우저 스튜디오

들어가며

어느날 유튜브를 보다가 IKEA 페그보드를 사용한 벽고정 so-arm 을 구현한 사람을 보게되었다. 저거 괜찮은데? 라고 생각을 했고, 마침 UMI 그리퍼를 구현해서 IL 을 해보고 싶었던 나는 벽걸이형 양팔 로봇을 만들어보기로 했다.

이것을 구현하기 위해서 나는 기존 so-arm 에서 몇 가지 수정을 해야했다.


1. 여섯 번째 축을 어디에 붙일까

UMI 그리퍼로 IL 을 하려면 팔이 적어도 6자유도를 가져야 한다. 사람 손끝을 보면 위치 세 개(x, y, z)에 방향 세 개(roll, pitch, yaw)를 더해 여섯이다.

그런데 SO-ARM 101 은 관절이 다섯이다. 숫자만 하나 모자란 게 아니라 배치가 더 문제였다. shoulder_lift·elbow_flex·wrist_flex 세 축이 서로 평행하다. 축이 평행하면 팔은 shoulder_pan 이 고른 평면 안에서만 굽는다. 집게가 물체에 다가갈 수 있는 방향이 방향 구(sphere) 전체가 아니라 그 위에 그어진 곡선 하나로 줄어든다는 뜻이다.

축을 하나 더 붙이는 건 정해졌고, 어디에 붙이냐가 남았다. 상완을 잘라 roll 을 하나 넣는 방법과 손목에 yaw 를 하나 얹는 방법을 같은 방식으로 재봤더니 도달 가능한 접근 방향이 각각 방향 구의 10.8% 와 25.6% 로 두 배 넘게 갈렸다. 손목 쪽이 이기는 데다 구조 링크를 자르지 않아도 된다.

그렇게 설계를 시작하려던 참에 이미 같은 문제를 푼 사람을 만났다. ahb 가 MakerWorld 에 올린 SO-ARM100 additional wrist joint 다. 프린트한 클레비스 하나로 손목에 축을 하나 더 만든다.

구조가 영리하다. 클레비스가 stock wrist_flex 서보를 통째로 감싸면서 한쪽 팔은 서보 혼에 볼트로 물리고 다른 쪽은 뒤쪽 보스를 탄다. 이 브래킷 자체가 wrist_flex 의 회전자가 되는 것이라, flex 관절은 원점도 축도 서보도 그대로다. 클레비스 아래 주머니에는 STS3215 를 꼬리부터 눕혀 끼우고 그 서보의 축이 새 yaw 가 된다. 원래 손목은 flex 혼에 물리던 그 인터페이스 그대로 새 서보 혼에 물린다.

체인은 링크 하나와 관절 하나를 얻고 잃는 것이 없다.

wrist_flex → [클레비스 + yaw 서보] → wrist_yaw → 손목 → wrist_roll → 집게

이제 팔 하나에 관절 여섯 개 + 그리퍼 하나 = 서보 일곱 개다. 여섯은 딱 맞는 숫자다.

  • pose 를 통째로 명령할 수 있다.
  • 여유 자유도(redundancy)가 없다. 같은 pose 를 만드는 자세가 여러 개로 갈리지 않아서 남는 자유도를 뭘로 쓸지 고민할 일 자체가 없다.

7자유도로 갔으면 팔꿈치를 어디로 돌릴지 같은 걸 매번 정해 줘야 했을 텐데, 여기선 그 고민이 통째로 사라진다. 초보 개인 프로젝트에서 이건 꽤 큰 절약이다.

2. 길어진 팔이 치르는 값

공짜는 아니었다. 손목 체인이 곧게 펴지면서 roll 관절이 flex 축 아래 61mm 에 매달려 있던 자리에서 앞으로 121mm 나간 자리로 옮겨 간다. 팔이 그만큼 길어지고, 늘어난 리치를 버티는 건 새로 넣은 yaw 가 아니라 그 앞의 flex 서보다. 여섯 번째 축의 값은 flex 가 치른다.

여기서 힘이 딸릴 게 걱정됐다. 그래서 클레비스를 하나만 쓰지 않고 같은 브래킷을 두 개 겹쳐 flex 서보를 양쪽에서 잡게 했다. 관절이 늘어난 건 아니다. 관절은 여전히 하나고 서보도 팔당 일곱 개 그대로다. 늘어난 건 그 축을 받치는 벽의 수다.

반대쪽으로도 한 번 더 손을 댔다. 버틸 힘을 키웠으면 버텨야 할 무게를 줄이는 게 같은 문제의 나머지 절반이다. 상완 링크는 양 끝에 관절이 붙은 폭 24mm 짜리 판인데, 그 사이 구간을 폭 방향으로 관통해 트러스를 냈다. 리브가 45도로 교차하니 남는 구멍이 마름모가 된다. stock 부품이 이미 쓰던 모양인데, 뚫어서 만든 게 아니라 남겨서 만든 셈이다. 아래팔은 roll 브래킷이 그 자리를 대신해 아예 프린트하지 않으므로 손대지 않았다.

트러스를 깎고 나면 매번 부품이 아직 하나의 덩어리인지 검사한다. 격자가 웹을 먹고 들어가면 링크가 두 조각으로 갈라지는데, 갈라진 링크도 STL 로는 멀쩡하게 저장된다.

3. 베이스는 페그보드가 정한다

벽에 붙이기로 한 이상 베이스는 IKEA 페그보드의 구멍 격자를 따라야 한다. stock 베이스로는 안 됐다. 볼트 자리가 사다리꼴로 놓여 있어서(x 로 69.8mm, y 로 63.5mm 와 55.6mm) 아무리 늘려도 직사각형 격자가 되지 않고, 날개 폭이 87mm 인데 새 볼트 패턴은 98.5mm 를 요구한다. 늘려 붙여 봤더니 예전 테이퍼 윤곽이 새 살 위로 대각선을 그으며 그대로 드러났다.

그래서 서보를 물고 있는 아치만 남기고 바닥을 통째로 갈았다. 둥근 윤곽의 얇은 스커트에 팔 링크와 같은 트러스를 넣고, 거기서 아치까지 받침을 경사로 올렸다. 120 × 80 볼트 패턴에 M4 접시머리다. 덤으로 볼트 머리가 17mm 짜리 구멍 바닥이 아니라 6mm 면에 앉고, 판에 단차가 없어졌다.

한 가지는 stock 날개가 하던 일을 그대로 물려받아야 했다. 어깨 서보 밑의 나사 두 개는 아래에서 위로 박는 것이라 드라이버가 지나갈 길이 필요하다. 구멍 없는 판을 깔면 드라이버 자리에 13mm 두께의 재료가 들어선다.

아치는 건드리지 않았으니 서보와 만나는 자리도, 벽 위로 서는 높이도 그대로다.

4. 그리고 집게는 게가 되었다

여기까지 오니 집게만 stock 이었다. SO-101 의 고정 턱은 곧게 테이퍼진 칼날인데, 벽에 붙은 팔 끝에 달린 걸 보니 영 아쉬웠다. 게 집게는 같은 칼날에 배가 있다. 바깥 모서리가 불룩하게 나갔다가 둥근 끝으로 돌아온다.

바꾼 건 윤곽뿐이다.

  • 무는 면은 있던 자리에 그대로 둔다. 움직이는 턱이 여전히 같은 선에서 만난다.
  • 두께도 station 마다 stock 그대로다. 칼날은 칼날로 두고 외곽선만 자란다.
  • 바깥 모서리에 최대 10mm 의 배를 준다. sine 을 타고 뿌리에서 0 으로 시작해 40% 지점에서 가장 깊고 끝에서 다시 0 으로 돌아온다.
  • 끝은 같은 선을 따라 6mm 더 나가고 둥글게 마감한다. 무는 모서리를 끝쪽으로 3.5mm 들어 올려서 닫힘 한계(-10도)에서 두 턱이 끝끼리 만나게 했다.

로봇 이름은 여기서 나왔다. PinkRab.

5. LeRobot 이 모르는 팔

부품을 다 깎고 나니 소프트웨어 쪽에서 청구서가 왔다. LeRobot 이 아는 SO follower 보다 관절이 하나 많다. 그래서 팔을 아예 별도 로봇 타입으로 등록했다. 한 팔은 pinkrab_follower, 한 쌍은 bi_pinkrab_follower. import 하는 순간 둘 다 등록되니 어떤 LeRobot 명령이든 --robot.type=pinkrab_follower 를 받는다.

캘리브레이션에서는 모든 관절을 끝까지 스윕한다. 스톡 SO follower 는 wrist_roll 을 예외로 두고 "한 바퀴 다 돈다"고 선언해 버리는데, 여기선 그리퍼에 프린트한 러그가 손목 러그와 ±175° 에서 만나므로 실제 끝단이 존재한다. 이게 생각보다 중요하다. use_degrees 를 쓰면 LeRobot 은 쓰기 경로에 아무 클램프도 걸지 않는다. 각 서보 EEPROM 에 적힌 한계값이 명령과 벽 사이를 막는 유일한 장치다.

6. 벽에 붙는 순간 전부 달라진다

책상 튜토리얼을 그대로 가져다 쓸 수 없는 이유가 여기 있다. 벽에 붙이면 이것들이 전부 바뀐다.

항목책상
중력 방향베이스 축과 나란함베이스 축과 직각
기저 회전전부 0설치마다 다른 실측값
작업영역팔 앞쪽 반구벽 바깥쪽으로만
충돌 외피책상 상판벽면 + 반대편 팔

그리고 여기부터가 진짜 문제인데, 이 로봇은 양팔이다. 서보 보드 두 장이 완전히 똑같이 생겼고 어느 쪽도 자기가 왼팔인지 오른팔인지 말해 주지 않는다.

둘 다 조용히 실패하는 종류다. 기저 회전이 틀리면 시뮬레이션은 멀쩡해 보이는데 실물과 안 맞는다. 보드가 뒤바뀌면 에러조차 안 난다. 그냥 두 팔이 좌우로 미러될 뿐인데, 벽에서는 그게 집게가 벽 바깥이 아니라 벽 쪽으로 휘둘리는 것을 뜻한다. 그래서 pinkrab CLI 가 존재한다. 보드는 한 번만 pinkrab ports detect 로 못 박아 두고(재접속해도 살아남는 장치 이름으로 저장한다. macOS 는 USB 시리얼 번호, 리눅스는 /dev/serial/by-id 심링크), 혹시 뒤집혔으면 pinkrab ports swap 으로 되돌린다.

두 팔은 미러 쌍이다

오른팔은 왼팔의 거울상이다. 그리퍼까지 통째로. 그래서 description 도 팔을 한 번만 쓰고 mirror 인자가 모든 y 오프셋과 roll·yaw 를 뒤집게 했다. 반사로 유도되지 않는 값은 딱 다섯 개인데, wrist_flex·shoulder_lift·shoulder_pan 의 원점과 wrist_roll·shoulder_pan 의 축이다. 이건 매크로 맨 위에 손으로 적어 둔다.

미러의 대가는 부호 하나다. 거울에 비친 프레임에서는 턱이 반대로 열리므로 왼쪽 그리퍼는 +100 쪽으로, 오른쪽은 -100 쪽으로 열린다. 명령을 만드는 자리마다 부호를 뒤집어 줘야 한다. 잊어버리면? 한쪽 손만 안 열린다. 아주 명확하게 안 열려서 다행이다.

7. 리더암이 없다는 것의 진짜 의미

리더암 방식은 리더를 어딘가에 단단히 고정해야 성립한다. 그런데 여기선 벽을 일하는 팔 두 대가 이미 다 차지했다. 자리가 없다.

그래서 이 로봇에는 관절을 베껴 올 데가 없다. 두 팔 다 팔로워다. 이게 벽걸이 결정에서 나온 단 하나의 구조적 결과인데, 파급이 꽤 크다.

팔을 움직이는 모든 경로가 역기구학(IK)을 거친다. IK 는 URDF 를 읽는다. 그러므로 description 은 나중에 붙이는 장식이 아니라 움직임의 관문이다.

동작은 관절 각도로 시작하지 않는다. "손을 여기에 놓아라"라는 pose 로 시작해서 pinkrab_core.kinematics 를 거쳐 관절 각도가 된다. 그 밑은 LeRobot 과 placo 이고 읽는 description 은 시뮬레이션이 읽는 그것과 같은 파일이다. 시뮬에서 겨눈 pose 와 실물에서 겨눈 pose 가 같은 pose 라는 뜻이다. check_description.py 가 두 모델을 같은 무작위 pose 로 걸어 보고 TCP 위치를 비교하는 것도 이걸 지키려는 장치다.

한 가지 함정. 여기서 나오는 각도는 description 각도지 서보 눈금이 아니다. 서보의 0 은 캘리브레이션이 물어봤을 때 팔이 잡고 있던 자세일 뿐이다. 그 둘을 잇는 다리가 angles.ticks_to_degrees 다.

8. 스튜디오, 브라우저 한 장

리더암이 없으면 팔을 움직여 볼 손잡이도 없다. 그래서 만든 게 pinkrab-studio 다. 브라우저 페이지 한 장에 three.js 로 로봇을 그리고, 그 뒤에서 MuJoCo 를 헤드리스로 돌린다.

서버는 파이썬 표준 라이브러리만으로 뜨고 three.js 도 벤더링해 두었다. 인터넷 없는 장비에서도 그냥 올라오고 로봇이 붙은 기계에는 디스플레이조차 필요 없다. --bind 0.0.0.0 하고 아무 노트북에서 페이지를 열면 된다.

PinkRab 스튜디오 — home pose 의 두 팔

슬라이더는 '요청'이지 명령이 아니다

여기가 이 스튜디오에서 제일 마음에 드는 부분이다. 슬라이더를 밀거나 손잡이를 끄는 건 팔을 그 자리에 놓는 게 아니라 서보에게 그 자세를 요청한다. 그리고 패널은 둘 다 보여 준다. 요청한 각도, 그리고 그게 다르면 그 옆에 빨간색으로 실제로 도달한 각도.

shoulder_lift 를 183도까지 밀어 보면(슬라이더는 기꺼이 그 값을 받는다) 팔은 99도에서 멈춘다. 상태줄이 어디에 닿았는지 이름까지 말해 준다.

<video src="https://woolimi.cdn.prismic.io/woolimi/iMGZ8s0n2ow9jCCY_pinkrab-studio.mp4" autoPlay loop muted playsInline style={{ width: '100%', borderRadius: '8px' }} />

슬라이더를 끌면 팔이 그 자리로 순간이동하지 않는다. 초당 300도 한도로 걸어간다.

left jaw on the desk - 9 mm in

접촉 판정은 MuJoCo 가 실제 충돌 메시로 한다. 두 팔은 어깨에서 380mm 떨어져 있고 각각 385mm 를 뻗으니 보드 앞에서 서로 만날 수 있는데, 만나면 거기서 선다. 한쪽으로 다른 쪽을 밀어붙여도 반 밀리미터 앞에서 서로를 막는다.

left wrist against right wrist - touching

접촉은 팔을 멈추지만 명령을 취소하지는 않는다. 서보는 계속 그 자세를 요청하고 요청값은 도달값 옆에 그대로 남아 있다. 실물 벤치가 딱 이렇게 동작한다. 페이지가 조용히 명령을 삼켜 버렸다면 곧 실물 팔로 나갈 pose 를 숨기는 셈이 된다.

명령은 도착지다

명령을 그대로 서보에 던지지 않는 이유도 같은 맥락이다.

명령은 도착지이지 지금 있을 자리가 아니다.

목표와 현재 위치 사이는 램프(ramp)로 걷는다. 매 프레임 max_step_deg 만큼, teleop_fps 로 환산하면 초당 300도씩 목표를 향해 액추에이터를 걸어간다. 왜냐면 목표와 현재 사이의 간격은 관절 가동범위 거의 전부가 될 수 있기 때문이다. 자세를 세팅한 순간, 슬라이더를 범위 끝까지 던진 순간이 그렇다. 그 간격을 서보가 낼 수 있는 최대 속도로 닫는 팔은, 벽에 붙은 팔의 경우 자기가 볼트로 박힌 벽을 후려치는 팔이다.

예외가 하나 있다. 요청이 아니라 선언된 자세는 램프를 타지 않는다. 로봇이 이미 거기 있으니까.

take 녹화와 재생

패널에서 바로 녹화하고 바로 재생한다. 이름 붙이고, 녹화 누르고, 팔을 원하는 대로 움직이고, 정지. ~/pinkrab/trajectories/<이름>.json 에 떨어지고 목록에 뜬다.

프레임마다 요청 벡터와 도달 벡터를 둘 다 담는다. 서로 다른 두 값이고 이 데이터로 학습할 정책은 둘 다 원하기 때문이다.

재생할 때 명령하는 건 요청 쪽이다. 녹화된 프레임은 이미 램프를 통과해 나온 값이라 램프 제한 안에 들어 있다. 그래서 램프가 그대로 통과시킨다.

시작 지점까지 가는 것 자체가 하나의 동작이라 패널이 그동안 그렇다고 말해 준다. 이때는 approach_deg_s, 초당 45도로 간다. 프레임당 상한 300도보다 한참 느린데, 그 상한은 이미 사람 손을 따라가는 명령에 걸린 천장이고 이건 로봇이 혼자 어딘가로 이동하는 상황이라 성격이 다르다. take 의 시계는 팔이 도착할 때까지 돌지 않는다. 같이 시작해 버리면 모든 take 의 도입부가 팔이 이동하는 동안 흘러가 버린다.

마지막으로, 페이지는 하나의 시뮬레이션이지 브라우저마다 하나가 아니다. 노트북 두 대로 열면 같은 팔을 같이 움직인다.

정리

  • 6자유도는 타협이 아니라 최소치다. 5자유도로는 pose 여섯 차원 중 하나가 비고 wrist_yaw 하나로 정확히 채워진다. 여유 자유도가 없어서 IK 도 단순해진다.
  • 축 하나를 얻으면 그 앞 관절이 값을 치른다. 손목이 121mm 앞으로 나가면서 부담은 flex 로 갔다. 브래킷을 두 개 겹쳐 받치고 상완에 트러스를 내서 양쪽에서 갚았다.
  • 벽이 부품 치수를 정한다. 페그보드의 구멍 격자가 120 × 80 볼트 패턴을 요구했고, stock 베이스로는 폭도 볼트 배치도 맞지 않아 아치만 남기고 새로 깔았다.
  • 벽걸이는 중력·기저 회전·작업영역·충돌 외피를 전부 바꾼다. 그리고 두 팔은 미러 쌍이라 좌우가 뒤집혀도 에러 없이 잘못 움직인다. CLI 가 그걸 못 하게 막는다.
  • 리더암을 포기하면 description 이 관문이 된다. 모든 동작이 IK 를 거치므로 URDF 가 맞지 않으면 아무것도 못 움직인다.
  • 스튜디오는 요청과 도달을 나란히 보여 준다. 접촉은 팔을 멈추지만 명령을 지우지는 않는다. 실물이 그렇게 동작하니까.
  • 명령은 도착지다. 초당 300도 램프로 걸어가고 take 재생은 그 램프를 그대로 통과한다.

여기까지가 팔 쪽 이야기다. 다음 편에서는 이 팔에 데이터를 먹일 UMI 그리퍼 이야기를 시작한다. 카메라 무게를 재고, 마커 큐브를 프린트하고, 잉크젯 때문에 하루를 날린 이야기다.