RViz에 로봇팔을 띄우고 관절을 움직이는 흔한 방법은 joint_state_publisher를 쓰는 거예요. 이번에는 그 자리를 직접 만든 파이썬 UI로 대체해야 했어요.
만드는 방법은 두 가지가 있어요. 관절 이름과 가동범위를 코드에 적어 두고 슬라이더를 그리거나, URDF에서 읽어 화면을 구성하거나. 앞쪽이 훨씬 짧아요. 뒤쪽을 골랐고, 화면은 그 값을 했는데 기구학에서 대가를 치렀어요.
/robot_description 구독
기구학정기구학 + 역기구학(공식 / CCD)
검산RViz TF 대조 · 무작위 목표 12회
관절 정보를 코드에 적지 않기
robot_state_publisher는 URDF를 /robot_description 토픽으로 내보내는데, 이걸 구독해서 XML을 파싱하면 관절 이름·타입·회전축·가동범위·링크 길이를 전부 얻을 수 있어요.
qos = QoSProfile(depth=1)
qos.durability = QoSDurabilityPolicy.TRANSIENT_LOCAL # 늦게 떠도 마지막 값을 받는다
self.create_subscription(String, 'robot_description', self._on_desc, qos)TRANSIENT_LOCAL이 중요해요. URDF는 시작할 때 한 번만 발행되기 때문에, 일반 구독으로는 UI가 조금 늦게 뜨면 영영 못 받아요. 발행자와 구독자의 내구성(durability) 설정이 맞아야 마지막 값이 전달돼요.
이렇게 하면 UI가 로봇을 모르는 채로 만들어져요. 슬라이더 개수도, 각 슬라이더의 범위도 실행 시점에 결정돼요.
처음 보는 모델을 만났을 때
이번에 써야 했던 URDF는 제가 만든 것이 아니라 공개된 4축 로봇암이었어요. 관절 이름은 base_shoulder, shoulder_arm1, arm1_arm2, arn2_end_arm이에요. 마지막 것은 오타가 그대로 남아 있는 이름이라, 코드에 적어 뒀다면 반드시 한 번은 틀렸을 거예요.
띄워 보니 UI가 코드 수정 없이 그대로 동작했어요.
URDF 수신 — 관절 4개: base_shoulder, shoulder_arm1, arm1_arm2, arn2_end_arm
여기까지는 설계가 통한 부분이에요. 문제는 그다음이었어요.
파일에 적힌 순서와 실제로 연결된 순서
끝단 좌표를 보여주려고 정기구학(forward kinematics)을 계산하고 있었어요. 관절각을 받아 링크를 차례로 이어 붙이면 끝점 위치가 나와요. 처음에는 URDF에 적힌 순서대로 변환을 누적했는데, 이 URDF에서는 그게 틀렸어요.
children = {j['child'] for j in self.joints}
roots = [j['parent'] for j in self.joints if j['parent'] not in children]정렬을 고치고 나서 RViz가 계산한 TF와 대조했어요. 관절이 전부 0일 때 뿌리에서 끝단까지가 [0.000, 0.004, 0.219]로 소수점 세 자리까지 같았어요. 같은 URDF를 읽는 두 구현이 같은 값을 내는지 보는 것이 이 계산의 자체 검산이 돼요.
구조를 안다는 가정을 버리기
역기구학(inverse kinematics)에서는 더 크게 걸렸어요. 처음에는 공식으로 풀고 있었어요. 요(yaw) 축 하나가 방향을 정하고 나머지 세 축이 한 평면 안에서 움직이는 구조라고 가정하면, 2링크 역기구학 공식으로 한 번에 나와요. 그 대신 그 구조에서만 성립해요. 지정 URDF는 축 배치가 달라서 해가 나오지 않았어요.
CCD(Cyclic Coordinate Descent)는 끝단에서 뿌리 쪽으로 관절을 하나씩 보면서, 그 관절의 회전축 둘레로 “지금 끝단”이 “목표”에 가장 가까워지는 각도만큼 돌리는 일을 반복해요.
v1 = tip - origin # 관절 → 현재 끝단
v2 = tgt - origin # 관절 → 목표
v1 = v1 - np.dot(v1, axis) * axis # 축에 수직인 성분만 남긴다
v2 = v2 - np.dot(v2, axis) * axis
ang = math.atan2(float(np.dot(np.cross(v1, v2), axis)), float(np.dot(v1, v2)))
q[i] = max(j['lower'], min(j['upper'], q[i] + ang)) # 가동범위로 자른다축에 수직인 성분만 남기는 게 핵심이에요. 회전으로 줄일 수 있는 오차는 그 성분뿐이고, 축 방향 성분은 이 관절로는 어떻게 해도 줄지 않아요. 각도를 구할 때 외적을 축에 투영해 부호를 얻으면 어느 쪽으로 돌아야 하는지가 나와요.
매 단계에서 가동범위로 자르기 때문에 한계를 넘는 해가 나오지 않고, 못 닿는 목표를 줘도 발산하지 않고 가장 가까운 자세로 수렴해요.
도달 범위도 모델에서 재요
마지막으로 걸린 건 사용성이었어요. 좌표 입력칸의 기본값을 제가 만든 로봇 기준으로 넣어 뒀는데, 지정 URDF는 팔이 더 짧아서 그 좌표가 닿지 않았어요. 버튼을 누르면 “못 닿아요”만 나왔어요.
기본값도 모델에서 재게 했어요. 관절 한계 안에서 자세를 격자로 훑어 끝점을 모으면 그 로봇이 실제로 닿는 범위가 나와요.
grids = [np.linspace(lo, hi, 5) for lo, hi in lims]
pts = [self.chain.fk(list(combo))[:3, 3] for combo in itertools.product(*grids)]이 로봇은 반경 15센티미터, 높이 −8에서 22센티미터였어요. 그 안쪽 지점을 기본값으로 넣고 안내 문구도 실측값으로 바꾸니 버튼을 누르면 팔이 그 자리로 가요. 웹캠으로 사람 손목을 따라가게 붙였을 때도 화면 49개 지점이 전부 도달 범위 안에 들어왔어요.
일반화의 값
| 방식 | 만드는 시간 | 새 모델을 만났을 때 |
|---|---|---|
| 관절 이름을 코드에 적기 | 30분 | 이름·범위·순서를 전부 고쳐야 함 |
| URDF를 읽기 | 훨씬 오래 | 화면은 고칠 게 없음 · 기구학은 고쳐야 했음 |
파일 순서를 연결 순서로 착각한 것과 축 배치를 가정한 것은 둘 다 “내가 만든 로봇에서는 우연히 맞던” 코드였어요. 일반화는 인터페이스에서 시작하지만, 실제로 검증되는 건 다른 입력이 들어왔을 때예요.
같은 날 2륜 로봇 쪽에서 겪은 문제는 가상 시리얼로 검증한 코드가 실물에서 무너진 네 지점에 적었어요. 앞서 서보암에 텔레옵을 붙이며 만난 함정은 부분 수신과 개루프 서보 부팅 점프 — 시리얼 텔레옵의 함정에 있어요.