
가정용 CPU로 뼈와 근육만 있는 인체를 세우고, 잡고, 물을 마시고, 춤추게 만든 전 과정 기록 —— 700개의 근육, 마스코트 “evis”
요약
가정용 CPU 환경에서 700개의 근육으로 구성된 인체 모델(MS-Human-700)을 MuJoCo로 구현하고, 진화 알고리즘을 통해 다양한 동작을 수행하는 과정을 기록한 연구 사례입니다. 서기, 잡기, 물 마시기 등 각 임무의 성공과 실패 원인을 데이터 기반으로 분석하며 물리 시뮬레이션의 디버깅 과정을 상세히 다룹니다.
핵심 포인트
- MS-Human-700 모델을 활용한 근육 기반 인체 시뮬레이션 구현
- 진화 알고리즘을 통한 근육 구동 및 동작 제어 실험
- 물리 엔진(MuJoCo) 내 버그 추적 및 측정 기반의 문제 해결 과정
- 운동학적 재생(Kinematic Playback)과 실제 근육 구동의 결합
이 글을 읽기에 적합한 사람: 로봇 공학, 생체 역학 (Biomechanics), 진화 계산 (Evolutionary Computation), MuJoCo, 모션 캡처 (Mocap)에 관심이 있는 사람 / "근육으로 구동되는 인체 모델" 또는 "집에서 해부학적 의미를 가진 인체를 움직이게 하는 것"에 관심이 있는 사람 / 예쁜 결과물보다는 솔직한 내부 해체 과정, 그리고 막혔던 부분부터 해결하기까지의 전 과정을 읽고 싶은 사람.
전제 지식: Python을 읽을 수 있다면 충분합니다. 제어 (Control), 생체 역학 (Biomechanics), Mocap 용어는 등장할 때마다 하나씩 설명하겠습니다.
모든 수치는 노트북 CPU에서 실제로 측정되었습니다. 이 글은 성공학이 아니라, "한쪽은 솔직하게 패배하고, 다른 한쪽은 승리한" 두 가지 결과를 동일한 잣대로 측정하며, 막히면 추측하지 않고 측정하고 수정해 나가는 일련의 기록입니다.
이 글은 이전에 나누어 썼던 세 편의 연작 —— 「서기·잡기·운반하기」 「물 마시기 (버그 추적)」 「evis 마스코트화 (mocap)」 —— 를 시간 순서와 인과 관계에 따라 하나로 합친 결정판입니다. 분량이 매우 길기 때문에 관심 있는 장부터 읽으셔도 괜찮습니다.
뼈, 건(Tendon), 그리고 700개의 근육으로 구성된 전신 인체 모델(중국 LNS Group의 MS-Human-700 (ICRA 2024, Apache-2.0))을 가정용 CPU 환경의 MuJoCo로 가져와, 진화 (Evolution) 알고리즘을 통해 근육을 하나씩 구동하여 「서기」 「잡기」 「운반하기」 「물 마시기」를 수행하게 하고, 마지막으로 이를 **마스코트 “evis”**로 만들어 mocap을 이용해 춤을 추게 하고 젓가락으로 음식을 먹게 만들었습니다. 핵심은 구동 장치가 토크 모터가 아니라, 수축력만을 생성할 수 있는 근육이라는 점입니다.
서기는 솔직하게 패배했고, 잡기는 승리했으며, 운반은 부분적인 승리(1/3)였습니다. 아무것도 하지 않으면 약 1초 내에 무너질 골격이 폐루프 제어 (Closed-loop Control)를 통해 붕괴를 전형적인 2.5초까지 지연시켰지만, 6초를 버티지는 못했습니다 (패배). 반면, 어깨를 고정한 팔 모델의 경우, 81개의 근육으로 구성된 그 손은 탁자 위의 물병을 성공적으로 집어 0.82m 높이로 들어 올려 유지했습니다 (승리). 여기에 「지정된 위치로 운반하기」 임무를 추가하자 부분적인 성공이 되었습니다 (세 가지 명령 중 엄밀한 의미에서 성공한 것은 1개뿐 = 1/3). 왜 임무에 따라 승패가 달라지는가, 이것이 바로 전반부의 주인공입니다.
후반부는 "측정 후 수정"의 연속입니다. "물을 마셨다"고 생각했던 동작이 "입에 들어가지 않았다"는 한마디에 부정당했고, 끝까지 추적한 결과 물병이 바닥을 뚫고 지하로 가라앉는 버그(keyframe 번호 불일치)를 발견했습니다. 단순히 올바른 위치에 0 하나를 삽입했을 뿐인데 해결되었습니다. 이어 골격을 evis라고 명명하고 mocap을 이식했는데, 이번에는 「허리가 노인처럼 굽어 있는」 거북목 버그가 나타났습니다. 추측을 멈추고 관절각 (Joint Angle)을 측정한 결과, 범인은 「마커 (Marker)의 높이를 잘못 설정한 것」이었습니다. 추측하기 전에 먼저 측정하라 —— 이것이 글 전체를 관통하는 기저음입니다.
먼저 솔직한 주석을 달아둡니다: 서기, 잡기, 운반하기, 물 마시기(팔)는 모두 근육으로 구동되는 실제 과정이지만, evis의 춤, 젓가락질, 그리고 물 마시는 자세의 일부에는 운동학적 재생 (Kinematic Playback, 관절각을 직접 지정하는 연출 레이어)이 섞여 있습니다. 어디가 실제이고 어디가 연출인지는 곳곳과 최종장에서 명확히 표시하겠습니다.
↑ 이 글의 종착점: 걷기 → 달리기 → 점프 → 살사 댄스 → 발차기 → 젓가락으로 음식 먹기. 모두 동일한 골격(evis)에 무료 mocap을 이식하여 구동한 것입니다. 이 단계에 이르기까지 겪었던 「패배」와 「버그」는 아래 글에서 측정된 모든 수치와 함께 그대로 남겨두겠습니다.
글이 매우 길기 때문에, 먼저 이 단어들을 지도로 만들어 두겠습니다. 이미 익숙한 내용은 바로 건너뛰셔도 됩니다.
| 용어 | 쉬운 설명 |
|---|---|
| 근골격 모델 (musculoskeletal model) | 뼈와 관절뿐만 아니라 근육과 건(tendon)까지 포함된 인체 시뮬레이션. 이번에는 700개의 근육이 포함된 전신 모델임 |
| ... | 신체의 상태(기울기, 속도, 지면 접촉)를 관찰하면서 근육 제어를 조정함. 관찰 없이 결정하는 것은 개루프 (open-loop) 제어임 |
| 역진자 (inverted pendulum) | 거꾸로 세워진 막대기. 가만히 두면 쓰러짐. 두 발로 서 있는 것의 본질은 끊임없이 이를 다시 바로 세우는 것임 |
| CMA-ES (진화 전략, evolution strategy) | 해(solution)의 집단에 변이와 선택을 가하여 더 나은 방향으로 만드는 최적화 방법. 이번에는 제어 전략 내 수백~수천 개의 수치를 진화시킴 |
| 베이스라인 (baseline) | 학습 전, 제어가 없는 상태의 대조군. 「학습이 실제로 얼마나 개선을 가져왔는가」를 측정하기 위한 출발점 |
| RTF (실시간 계수 / real-time factor) | 시뮬레이션이 실시간보다 얼마나 빠른지 나타냄. 13×는 1초 분량의 물리 연산을 약 0.08초 만에 완료함을 의미 (CPU와 병렬성에 크게 의존) |
qpos | 모델의 모든 관절 위치를 일렬로 나열한 배열. 후반부의 핵심임. 관절이 추가되면 이 배열의 순서가 어긋나게 됨 |
| 키프레임 (keyframe) | 「실험 시작 시의 자세」를 기록한 초기화 데이터. qpos의 나열 순서가 위치(번호)에 의해 결정된다는 점이 핵심임 |
| 웜 스타트 (warm-start) | 학습을 「제로 베이스」에서 시작하는 것이 아니라, 「이미 알고 있는 전략」에서 시작하는 것 |
| 모션 캡처 (mocap) | 사람의 동작을 기록한 관절 각도의 시계열 데이터. 여기서는 무료 BVH 파일을 사용함 |
| ... |
새로운 일을 시작하기 전에는 먼저 조사를 합니다. 이것이 저의 습관입니다. 이번 주제는 MS-Human-700입니다. 중국 LNS Group에서 제작하여 ICRA 2024에서 발표한 전신 근골격 모델(Zuo, He, Shao, Sui, Self model for embodied intelligence)입니다. 이 모델은 Apache-2.0 라이선스로 공개되었으며, MuJoCo의 공식 모델 세트인 Menagerie에 포함되어 있습니다. 이는 뼈, 건, 그리고 700개의 근육을 포함한, 그야말로
액추에이터(Actuator)는 전부 "근육"입니다. 로봇의 모터처럼 "+3 N·m에서 -3 N·m"까지 양방향으로 회전하는 것이 아니라, 근육은 수축(당기는) 방향으로만 힘을 낼 수 있습니다. 활성값(Activation value)은 0~1 사이이며, 힘-길이-속도에 의존하는 비선형 Hill 모델에 의해 출력되는 힘이 결정됩니다. 따라서 "관절을 오른쪽으로 돌리려면 오른쪽 근육을 사용하고, 왼쪽으로 돌리려면 왼쪽 근육을 사용해야" 하므로, 반드시 쌍(pair)으로 준비되어야 하며 그 수는 자유도(DoF)보다 많습니다(=중복성, Redundancy).
무릎은 생체역학적으로 매우 정밀합니다. 단순한 경첩(Hinge) 구조가 아니라, 여러 보조 관절과 제약 조건을 사용하여 대퇴골과 경골 사이의 구름(Rolling) 및 미끄러짐(Sliding) 운동을 재현합니다.
골반은 "부유 기저부(Floating base)"입니다. 지면에 고정되지 않고 3축 평행 이동과 3축 회전을 통해 자유롭게 움직일 수 있습니다. 초기 자세에서 골반은 0.95m 높이로 직립해 있습니다.
속도: 이 모델은 노트북(Intel Core, Ice Lake 세대 모바일 CPU)의 싱글 스레드, 유휴(Idle) 상태에서 RTF 약 13배에 달합니다(6초의 물리 시간이 약 0.46초, ~6500 steps/s, dt=0.002s). 하지만 이는 독점 사용 시의 수치이며, 진화를 위해 7개의 코어를 병렬로 구동할 경우, 각 프로세스는 35배로 떨어집니다(코어 간 자원 경쟁 때문). RTF는 CPU 모델, 병렬도, 적분기(Integrator) 설정에 따라 이처럼 큰 차이가 발생하므로, 수치를 읽을 때는 반드시 조건을 함께 확인해야 합니다. 그럼에도 불구하고, 이 속도는 "수만 번의 롤아웃(Rollout)"을 통한 진화를 실제로 실행할 수 있는 수준에 도달했습니다.
새로운 제어를 시도하기 전에, 반드시 **학습이 없는 베이스라인(Baseline)**을 먼저 측정해야 합니다. 그래야 나중에 비정상적으로 좋은 수치가 나왔을 때, 출발점을 명확히 한 상태에서 그 수치를 의심해 볼 수 있기 때문입니다. 지면이 포함된 보행 모델에서 6초간의 롤아웃을 수행하며, "골반 높이가 0.6m 미만으로 떨어지면 넘어짐으로 간주"하도록 규정했습니다.
| 제어 방식 | 넘어지기 전 시간 | 결과 |
|---|---|---|
| 제어 없음 (모든 근육 이완) | 0.50초 | 붕괴 |
| 모든 근육 최대 수축 | 0.99초 | 붕괴 |
| 매 스텝 랜덤 값 적용 | 1.00초 | 붕괴 |
아무것도 하지 않으면 인체 골격은 약 1초 만에 쓰러집니다. 모든 근육을 최대한 팽팽하게 조여 강성(Stiffness)을 높이면 아주 조금 더 버틸 수는 있지만, 여전히 서 있을 수는 없습니다. 이것이 직립 과제의 시작점입니다.
그리고 바로 이 지점에서, 저는 기존 도구가 "무너지는" 것을 실측했습니다 —— 이는 이번 작업에서 기술적으로 가장 흥미로운 발견 중 하나였습니다. onocollo에는 원래 "Menagerie 내 로봇의 제어 진화"를 돕는 컨트롤러가 있었습니다. 하지만 그 설계는 각 액추에이터가 자신이 담당하는 관절의 각도를 읽어 동작하도록 되어 있었습니다. 그러나 근육은 "자신이 담당하는 단 하나의 관절"을 갖지 않습니다(건(Tendon)을 통해 여러 관절을 가로지르기 때문입니다). 그 결과, 100개의 근육 모두가 동일한(0인) 입력을 받게 되었고, 가중치를 공유하기 때문에 100개의 근육이 내는 활성값도 완전히 똑같아졌습니다.
100개의 근육이 매 순간 완전히 동일한 강도로 수축한다면, 이족 보행은 절대 불가능합니다. 저는 수치적으로 이러한 퇴화(Degeneracy, 고유한 활성값의 종류 = 1)를 확인했습니다. 따라서 근육 모델에는 새로운 표현 방식이 필요합니다: "전신의 상태를 보고, 각 근육에 서로 다른 명령을 내리는 것". 이는 논문에서 언급한 "계층적 저차원 표현(Hierarchical low-dimensional representation)"의 필요성과 정확히 일치합니다.
제가 구현한 제어 방식은 다음과 같습니다.
- 전신의 고유 수용 감각(Proprioception) 읽기: 골반 높이 오차, 기울기(전후/좌우/회전) 및 각속도, 골반의 평행 이동 속도, 좌우 발의 지면 접촉 여부 등 균형에 작용하는 총 14개의 변수를 읽습니다.
- 근육 협응(Muscle Synergy)으로 연결: 이 상태를
tanh를 통해 **K개의 "함께 움직이는 패턴"**으로 선형 매핑한 후,K×100행렬을 사용하여 100개 근육의 활성값으로 펼칩니다(sigmoid를 사용하여 0~1 사이로 압축). K=6일 때 전략의 수치는 약 790개입니다. 이것이 논문에서 말하는 "저차원 표현"에 대한 저만의 구현입니다. - CMA-ES를 이용한 진화: 진화 전략(Evolution Strategy)을 사용하여 이 수치들을 최적화합니다. 보상(Reward)은 "서 있는 동안 높이가 목표에 가까울수록, 상체가 곧을수록, 골반 속도가 작을수록 높은 점수를 부여하며, 넘어지면 종료"하는 방식입니다.
한 가지 솔직한 설계 수정 사항을 말씀드리자면: 초기 보상은 단순히 "목표 높이에 가까워지면 점수를 추가"하는 방식이었습니다. 그러자 진화 과정에서 편법이 발생했습니다. 바로 『천천히, 우아하게 쓰러지는』 전략으로 점수를 챙기는 것이었습니다(쓰러지는 도중에는 높이가 여전히 높기 때문입니다). 겉보기에는 전혀 서 있는 것이 아니었습니다. 그래서 저는 골반 속도에 페널티를 추가하여, "천천히 쓰러지는 것"보다 "조용히 멈춰 서 있는 것"이 더 높은 점수를 받도록 수정했습니다. 이는 이러한 편법을 막기 위해 척도(Scale)를 조정한 것입니다.
계산 자원을 낭비하지 않기 위해, 모델을 한 번만 로드하고 7개의 코어로 병렬 평가하는 메커니즘도 작성했습니다(모델 로드에는 약 2초가 소요되지만, 한 번의 롤아웃은 수백 밀리초밖에 걸리지 않으므로, 매번 다시 로드하면 로딩 시간이 주된 오버헤드가 됩니다). 각 워커(Worker)가 모델을 한 번만 로드하고 반복해서 재사용하게 함으로써 병렬도에 상응하는 가속을 얻을 수 있었습니다.
결과는 —— 솔직히 말씀드리면, 목표에 도달하지 못했습니다. 최적값(Best)만 나열하면 "가장 높은 봉우리 하나를 대표값으로 삼는" 오류를 범하게 되므로, 저는 전형적인 값(진화 후반부 각 세대 Best의 중앙값)도 함께 나열했습니다. 먼저 **단일 랜덤 시드(Random seed)**에서의 결과를 살펴보겠습니다.
| 제어 (단일 시드) | 전략의 수치 | 전형적 (중앙값) | 최적 (best) | 결과 |
|---|---|---|---|---|
| 베이스라인 (제어 없음) | 0 | — | 0.5~1.0 초 | 붕괴 |
| ... | ||||
| 이 표를 보며 저는 순간 이런 생각이 들었습니다. "기억(적분항)을 추가한 후, 3.71초 → 4.53초로 길어졌다. 가설대로, 균형을 잡으려면 실제로 오차에 대한 적분이 필요하다". 이론적으로 균형 제어는 원래 그래야 하므로 (적분 제어, Integral control), 이 설명은 매우 그럴듯해 보였습니다. |
하지만 CMA-ES는 확률적입니다. 단일 랜덤 시드에서의 차이는 어쩌면 시드의 운이 좋았을 뿐일지도 모릅니다. 그래서 저는 "기억 있음/없음"을 각각 3개의 시드로 실행한 뒤 중앙값을 취하여 (공정한 비교를 위해) 다시 확인했습니다.
| 제어 | best의 중앙값 (3개 시드) | best의 분산 범위 | 전형적 (median-late) 중앙값 |
|---|---|---|---|
| 기억 없음 (plain) | 3.55 초 | 3.16~3.80 초 | 2.71 초 |
| 기억 있음 (적분항) | 3.56 초 | 3.34~4.17 초 | 2.39 초 |
결론이 뒤집혔습니다. best의 중앙값은 3.55 ≒ 3.56으로 거의 동일했으며, 전형적인 값에서는 오히려 기억이 있는 쪽이 더 낮았습니다 (2.39 < 2.71). 즉, 단일 시드에서 보았던 4.53초는 동일한 분포 내에서 우연히 높게 나타난 피크(peak)일 뿐, 기억항이 효과를 발휘했다는 증거가 될 수 없습니다. 저의 "기억이 유용하다"는 멋진 가설은 n=3 환경에서 지지받지 못했습니다.
- 폐루프 제어(Closed-loop control)는 붕괴 시점을 약 1초에서 전형적 2.5초, 최적 3.5초 내외로 늦추었습니다. 이는 베이스라인을 명확히 넘어섰으며, 폐루프 제어는 정적 근긴장(static muscle tone)보다 성능이 좋았습니다. 배율의 분모는 보수적으로 "제어 없음 ~ 1.0초"로 잡았습니다 (만약 모든 근육이 이완된 0.50초를 분모로 잡는다면 수치가 두 배로 뛰겠지만, 그것은 과장입니다). 이 범위 내에서는 기억의 유무가 유의미한 차이를 만들지 않았습니다.
- 하지만 6초 동안 지속해서 서 있을 수는 없었습니다. 최적의 전략조차 마지막 프레임에서는 바닥에 누워 있었습니다.
- 교훈: 랜덤 최적화에서 n=1일 때의 "효과가 있다!"는 종종 가짜입니다. 가설이 아름다울수록, 여러 시드의 중앙값을 얻기 전까지는 그것을 믿지 말아야 합니다. 이 경험은 제가 콘텐츠를 게시하기 전, **독립적인 프로세스를 통한 팩트 체크(fact-check)**를 거치게 하는 방식이 실제로 효과를 발휘한 사례입니다 (팩트 체크 결과 "단일 시드에 기반한 인과적 결론을 너무 무겁게 내렸다"라고 지적 → 3개의 시드로 검증 → 결론 반전).
왜 도달하지 못했을까요? 솔직히 말해서, CMA-ES를 사용하여 약 800차원에 달하는 선형 협응 전략(linear synergy strategy)을 최적화하는 것은, 거꾸로 선 진자(inverted pendulum)를 지속적으로 안정화하기에는 힘이 부족합니다 (위에서 보았듯 간단한 적분식 기억을 추가해도 살려낼 수 없었습니다). 이는 논문에서 계층적 강화학습(Hierarchical Reinforcement Learning)이 필요한 이유와 모순되지 않습니다. 여기서 이를 "일어섰다!"라고 좋게 포장하기는 쉽지만, 실제로 지속해서 서 있지 못했으므로 그렇게 쓰지 않겠습니다.
서 있는 것이 어려운 이유는 본질적으로 그것이 "거꾸로 선 진자(inverted pendulum)\
마찰 계수 $\mu=1.0$ (고무에 가까운 다소 높은 마찰)의 접촉 조건 하에서 성립합니다. 마찰이 높을수록 잡는 것(grasping)이 쉬워집니다. 만약 $\mu$를 낮춘다면 동일한 전략으로도 완전히 미끄러질 가능성이 있습니다. 따라서 "잡았다"라는 표현은 이 마찰 조건 하에서만 성립한다는 점을 명시해야 합니다.
이는 또한 제가 이전 글에서 "AI 의수로 젓가락질 시키기"를 다룰 때 남겨두었던 미결 과제, 즉 "손이 없는 상태(젓가락을 직접 조종하는 상태)"라는 문제에 대해 진정한 해부학적 의미의 손을 통해 내놓는 답변이기도 합니다. 엄지손가락, 벌레근(wormian muscle), 골간근(interosseous muscle)까지 모두 갖춘 손이 오직 근육의 힘만으로 물체를 잡은 것입니다.
"잡고 들어 올리기"는 사실 "위로 들어 올리기, 어디든 상관없음"이라는 단방향 작업입니다. 진정한 조작(manipulation)이란 잡은 물체를 우리가 명령한 지정된 위치로 옮기는 것입니다. 이에 따라 저는 전략에 "목표 - 물병"이라는 벡터를 보여주었습니다(관측값에 3차원을 추가). 그리고 동일한 제어로 3가지 서로 다른 목표(정수직 위 / 몸 측면의 물 마시는 자세 / 전방 약간 위쪽)를 따를 수 있는지를 테스트했습니다. 만약 하나의 전략이 명령에 따라 운반 목적지를 바꿀 수 있다면, 그것은 "제어 가능하다(controllable)"라고 말할 수 있습니다.
하지만 막상 실행해 보니 또다시 보상 해킹(reward hacking)에 빠졌습니다. 만약 "물병이 목표에 가까워질수록 점수가 높아진다"라고만 설정하면, 잡기(grasping)의 그래디언트(gradient)가 사라져 버립니다. 물병의 초기 위치가 이미 목표점에 가깝기 때문에, 가장 가까운 거리에서 얻을 수 있는 기본 점수를 챙길 수 있기 때문입니다. 결과적으로 최적화 과정은 "근육의 힘을 빼고 물병을 탁자 위에 그대로 두어" 움직이지 않고 점수를 따는 방식을 택합니다. 200 세대(generation)를 돌려도 목표와의 최단 거리는 전혀 개선되지 않고 계속 동결되어 있었습니다. 즉, 점수는 오르고 있지만 잡지도, 옮기지도 않은 상태였습니다. 이는 §5에서 다룬 "천천히 쓰러지는" 기만 방식의 형제 격입니다.
수정 방법은 두 가지가 있습니다:
- 웜 스타트(warm-start): §6에서 검증된 잡기 전략을 초기값으로 사용합니다. 관측 차원이 다르기 때문에(잡기 19 $\rightarrow$ 운반 22), 새로 추가된 명령 벡터의 입력 가중치를 모두 0으로 채워 초기 상태를 잡기 전략과 완전히 일치시켰습니다. 이렇게 하면 진화가 해야 할 일은 "움직일 수 있는 잡기 능력 위에 명령 추종 기능만 추가하는 것"이 되어, 처음부터 "그대로 두기"라는 함정을 건너뛸 수 있습니다.
- 잡기 유지 가산점 + 터미널 게이팅(terminal gating): 손과 물병이 가까워지는 정도에 가산점을 주되, 최종 보상은 지속적으로 잡고 있을 때만 유효하도록 설정했습니다(떨어뜨리거나 던지면 0점).
그 결과, 점수는 "그대로 두기"로 속였던 기본 점수(약 18.9)에서 약 1021로 급등했으며, 내용 또한 실질적인 성과로 바뀌었습니다:
| 명령 목표 | 최단 거리 | 지속적으로 잡았는가? | 목표에 도달했는가? (12cm 이내) |
|---|---|---|---|
| 정수직 위 +0.30m | 0.103m | ✓ | ✓ (최종 0.108m) |
| 몸 측면 · 물 마시는 자세 | 0.265m | ✓ | ✗ |
| 전방 약간 위쪽 | 0.120m | ✓ | 임계(critical) |
3가지 명령 하에서 모두 물병을 지속적으로 잡았으며(손바닥물병 0.0430.046m), 명령 방향으로 운반했습니다. 정수직 위는 내려놓을 때까지 계속 성공했고, 전방 약간 위쪽은 임계치에 도달했습니다. 정직한 한계는 "몸 측면의 물 마시는 자세"에서 나타났는데, 0.27m가 모자라 닿지 못했습니다. 이는 정수직 위 잡기에서 웜 스타트(warm-start)로 이어진 **단일 선형 협응(single linear coordination)**이 팔을 몸 측면으로 끌어당기는 자세를 수행하기에는 용량이 부족했기 때문입니다. 잡기는 완벽했지만, 명령 추종은 3분의 1 정도만 엄격하게 성공했습니다. 과장하지 않고, 이를 부분적인 의미에서의 "제어 가능한 조작"으로 여기에 기록합니다.
이러한 운반의 가장 흥미로운 응용은 바로 "입가로 가져가기"입니다. 즉, 식사나 물을 마시는 동작입니다. 명령 목표를 "몸 측면 · 입가"로 전환하면, 근육과 골격으로 이루어진 손은 잡은 물병을 몸 방향으로 끌어당기려 시도합니다(아래 GIF 참조). 하지만—이것이 바로 위 표에서 0.27m 차이로 멈춰 섰던 가장 어려운 목표였으며, 이 시점에서는 아직 입가에 도달하지 못했습니다.
당시 저는 거의 결론을 내릴 뻔했습니다. "닿지 못하는 이유는 단일 선형 협응의 용량이 부족하기 때문이다"라고 말이죠. — 하지만 그것은 틀렸습니다. 이어지는 제 II부에서는 이 오류를 측정하고 파헤치는 이야기를 다룹니다.
분명히 동일한 모델, 동일한 제어 표현(control representation), 동일한 최적화 세트를 사용했음에도 불구하고, 서기(standing)는 실패했고 잡기(grasping)는 성공했습니다. 결정적인 차이는 작업의 물리적 안정성(physical stability)에 있다고 생각합니다.
- 서기는 불안정한 평형(unstable equilibrium)입니다. 거꾸로 선 진자(inverted pendulum)는 아주 미세한 기울어짐만으로도 오차가 지수적으로 증가합니다. 제어는 "끊임없이, 신속하게, 정확하게" 계속해서 중심을 잡아야 하며, 조금이라도 실수하면 발산하여 쓰러지게 됩니다. 기억(memory)이 없는 선형 전략에게 이 짐은 너무 무겁습니다.
- 잡기(이 설정 하에서)는 안정적입니다. 어깨가 고정되어 있어, 목표(들어 올리기)를 일단 달성하면 유지하기 쉽습니다. 제어상의 일부 거친 움직임은 치명적이지 않습니다.
물론, 안정성이 유일한 차이점이라고 단언한다면 그것은 정직하지 못한 태도일 것입니다. 최소한 세 가지 혼합 요인이 존재합니다: ① 도달(reach) 문제는 이미 해결됨(잡을 때 손이 이미 물병에서 0.1m 떨어진 곳에 있음), ② 보상의 직관성(잡기는 "높을수록 좋다"는 단조롭고 조밀한 보상인 반면, 서기는 생존 시간에 기반하므로 "천천히 쓰러지는" 기만이 발생함), ③ 자유도(DOF)의 차이(잡기는 어깨가 고정되어 있어, 서기를 망치는 불안정한 부동 기저(floating base)의 6자유도가 없음). 주된 원인은 안정성이지만, 이 요소들도 작용하고 있습니다. 너무 매끄러운 이야기는 단일 원인을 과장하기 마련입니다.
반면 §7의 운반(place)은 이 판단의 세 번째 데이터 포인트입니다. 무대(stage)와 잡기(grasp)는 동일한 안정적인 조작 변형(fall 하지 않음)이지만, 여기에 "지정된 장소로 옮기라는" 제어 요구 사항을 추가하는 순간, 완전한 승리(잡기)에서 **부분적인 승리(3분의 1)**로 추락했습니다. 난이도는 작업의 안정성뿐만 아니라, **요구되는 제어의 "지능 수준(smartness)"**에 따라서도 결정됩니다.
수확은 다음과 같이 요약할 수 있습니다: "AI가 무엇을 할 수 있는가/할 수 없는가"는 전략의 지능 수준뿐만 아니라, 작업 자체의 안정성(그리고 지지점, 보상, 자유도, 요구되는 제어성)에 의해 결정됩니다. 동일한 도구라도 올바른 경기장을 선택하면 이길 수 있지만, 잘못 선택하면 정직하게 넘어집니다. 또한 이 세 가지 작업에서, 저는 공개하기 전에 한 번 더 확인하여 "점수는 오르지만 내용은 따라오지 못하는" 기만(천천히 쓰러지거나 물병을 방치하는 것)을 잡아냈습니다 —— 이것이 이 글을 관통하는 방식입니다.
제 I부 §7에서 저는 물을 마시는 동작이 "목표물에서 0.27m 떨어진 곳에서 멈췄으며, 이는 용량(capacity)의 한계"라고 썼습니다. 여기서부터 제가 측정을 통해 그 결론이 틀렸음을 깨닫고 수정해 나가는 과정이 시작됩니다. 이 글의 주인공은 "물을 마셨다"가 아니라, 그 이전에 있었던, 소박하지만 MuJoCo를 다루는 사람이라면 누구나 한 번쯤 겪게 되는 버그(bug)입니다.
제 I부 이후, 저는 목표 지점을 "진짜 입"의 위치로 다시 설정했고, 그 결과 동일한 제어와 동일한 용량 하에서 손이 입가에 닿았다고 판정했습니다. 수치상으로는 확실히 닿았습니다(병의 중심과 목표 지점 사이의 거리가 불과 몇 cm였습니다). 저는 심지어 "물을 마시는 동작을 성공했다"라고 글을 쓰기 시작했습니다.
하지만 렌더링된 영상을 본 사람(저에게 방향을 지시해 준 분)은 저에게 세 가지 거부 의견을 보냈습니다.
- "입에 전혀 들어가지 않았다" —— 병이 목이나 쇄골 부근에서 멈췄을 뿐, 실제 하악(턱)에는 전혀 닿지 않았습니다.
- "입도 벌리지 않았다" —— 입은 처음부터 끝까지 닫혀 있었습니다. 전혀 마시는 것처럼 보이지 않았습니다.
- "무엇을 하는지 알 수 없다" —— 정면 카메라 한 대뿐이라 팔이 어떻게 움직이는지 읽어낼 수 없습니다.
이것이 바로 이 시리즈가 일관되게 고수해 온 방식입니다 —— "한번 아주 좋은 결과가 나타나면, 스스로 이겼다고 생각하기 전에 먼저 내부를 의심하라" —— 타인의 눈을 통해 작동하기 시작한 순간입니다. 저는 "거리가 불과 몇 cm"라는 숫자는 믿었지만, 화면은 의심하지 않았습니다. 하지만 사람은 화면을 보고 숫자의 거짓말을 단번에 꿰뚫어 보았습니다. 정직하게, 주장을 철회하는 것부터 시작합니다.
먼저 (1)을 봅시다. 제가 "입"으로 설정했던 목표 지점은 (0.07, 0, 1.46)이었습니다. 하지만 실제 하악의 기하학적 구조를 측정해 보니, 하악 바닥의 높이는 약 1.50m였고, 입술 부근은 1.55m 근처였습니다. 저의 목표치인 1.46은 하악 바닥보다도 낮았으며, 즉 목이나 쇄골의 높이였습니다.
병이 옮겨진 곳은 "입가"가 아니라 "목구멍"이었고, 그곳에서 멈춘 것입니다. 거리 수치가 작았던 이유는 그것이 잘못된 목표에는 정확히 도달했기 때문입니다 —— 목표를 잘못된 곳에 두면, 달성률의 품질이 높을수록 오히려 결과는 더 나빠집니다. 저는 목표를 실제 입술 높이인 (0.05, 0, 1.55)로 수정했습니다.
이는 동시에 제 I부 §7에서 썼던 "단일 선형 협응(single linear synergy)은 팔을 몸 옆으로 끌어당길 용량이 부족하다(그래서 닿지 않는다)"라는 문장에 대한 자기 수정이기도 합니다. 닿지 않았던 진짜 이유는 용량이 아니라 목표를 잘못 설정했기 때문입니다. 책임을 용량으로 돌리는 것은 언제나 가장 편한 도피처입니다. 다만 오해를 피하기 위해 덧붙이자면, 여기서 뒤집힌 것은 "입에 닿지 않는 이유가 용량 때문"이라는 잘못된 귀인(attribution)일 뿐이며, §7에서 확인했던 "팔을 임의의 지점에 정밀하게 배치하는 정밀도가 3분의 1에 불과하다"는 단일 선형 협응의 용량 한계 자체는 여전히 유효합니다.
교훈을 하나 미리 얻자면: "이것이 알고리즘의 한계다"라고 결론을 내리기 전에,
먼저 채점표(목표, 보상)가 올바른지 의심하십시오. 정체를 능력의 한계로 돌리는 것은 대개 너무 이른 판단입니다.
다음은 (2) "입이 벌어지지 않았다"입니다. 이것은 저의 설정 실수가 아니라, 모델 자체의 한계였습니다. 팔과 손을 극도로 정밀하게 조작하기 위한 변형의 대가로, **머리를 단일 강체(rigid body)**로 처리했습니다. 턱관절도 없고 얼굴 근육도 없습니다. 81개의 근육은 모두 팔, 손, 몸통에 속해 있으며, 입을 벌릴 수 있는 근육은 단 하나도 없습니다.
지시는 명확했습니다 —— "하악을 벌릴 수 있는 모델로 개조하라." 그래서 저는 기존 모델(251MB의 에셋 그룹)을 통째로 복사하여 그대로 둔 채, 조작용 신체 정의 파일만 편집하여 머리의 하악 기하 구조를 독립적인 서브 강체인 mandible(하악) + 힌지 관절(hinge joint)인 jaw_open으로 분리했습니다. 이 관절에 각도를 부여하면 하악이 내려가고 입이 벌어지게 됩니다.
하지만 여기에는 처음부터 정직한 경계 설정이 있었습니다. 이 하악에는 근육이 부착되지 않았습니다. 따라서 하악의 개폐는 근육에 의해 풀려나오는 동작이 아니라, 직접 위치를 입력하는 스크립트(kinematic) 방식입니다. 팔의 잡기와 운반은 81개의 근육이 진정으로 풀어낸 결과이지만, 입의 벌어짐은 연출(performance)입니다 —— 이 차이를 저는 끝까지 유지할 것입니다.
개조된 모델은 성공적으로 로드되었습니다. 관절이 하나 늘어나 자유도가 48에서 49로 증가했습니다. 근육 수(81개)는 변하지 않았으므로, 학습된 제어는 당연히 그대로 로드되어야 했습니다. 저는 하악이 닫힌 화면과 벌어진 화면을 각각 확인했고, 개폐 자체는 작동했습니다. —— 하지만, 학습된 물 마시기 제어를 적용하자 상황이 이상해졌습니다.
개조된 하악 모델 위에서, 이미 학습된 잡기와 운반 제어(§7의 champion)를 재생했습니다. 기대한 것은 "원래처럼 잡아서 입가로 옮기는 것"이었습니다. 실제로 일어난 일은 다음과 같습니다.
근육과 골격이 마치 물을 마시려는 듯 팔을 입가로 들어 올렸습니다. 하지만 손은 비어 있었고, 테이블 위의 병도 사라진 상태였습니다. 제어(control)는 병이 원래 있어야 할 위치를 잡으려 시도했지만, 그곳에는 아무것도 없었기에 팔은 허공을 휘저으며 점차 힘이 빠졌습니다. 단지 턱(jaw) 하나를 추가했을 뿐인데, 움켜쥐는 동작(grasping)이 완전히 망가져 버린 것입니다. 근육의 수나 팔의 관절(joint)은 전혀 건드리지 않았는데도 말입니다.
이런 상황에 직면했을 때, 추측에 의존해 원인을 단정 짓고 임시방편(patch)을 만드는 것은 가장 위험합니다. 처음에는 저도 이렇게 짐작했습니다. “턱 관절을 하나 추가했으니, 팔 관절의 초기 각도가 틀어졌을 거야. 이상한 자세에서 시작되었겠지.” 듣기에는 꽤 일리가 있어 보였습니다. 하지만 이 시리즈의 원칙은 “‘아마 X 때문일 것이다’라고 말하기보다, ‘X를 확인하겠다, 확인 방법은 Y이다’라고 말하는 것”입니다.
그래서 저는 진단 코드를 작성하여, **원래 모델과 턱이 추가된 모델의 시작 순간 상태를 나란히 측정(measure)**했습니다. 두 모델에서 팔의 주요 관절 7개의 각도, 손바닥의 세계 좌표(world coordinate), 병의 세계 좌표를 비교했습니다. 결과는 다음과 같습니다.
| 측정 항목 | 원래 모델 | 턱 추가 모델 | 차이 |
|---|---|---|---|
| 팔 reach 관절 7개의 각도 | (기준) | (동일) | 최대 0.0 |
| 손바닥의 세계 좌표 | (기준) | (동일) | 0.0 |
| 병의 높이 z | 0.94 m | −0.63 m | −1.57 m |
| 손~병 사이 거리 | 0.108 m | 1.667 m | — |
제 추측은 빗나갔습니다. 팔의 관절과 손의 위치는 원래 모델과 완전히 일치했습니다(초기 자세는 망가지지 않았습니다). 망가진 것은 오직 병뿐이었습니다. 원래 z=0.94m인 테이블 위에 있어야 할 병이, 뜻밖에도 z=−0.63m, 즉 바닥을 뚫고 들어간 지하에서 시작되고 있었습니다. 손은 올바른 초기 자세를 유지하고 있었습니다. 오직 잡아야 할 대상(병)만이 바닥 아래로 떨어진 것입니다. 그래서 팔은 “테이블 위 병이 있어야 할 위치”를 향해 올바르게 움직였지만, 그곳에는 아무것도 없었습니다. 허공을 휘저은 진실은 바로 이것이었습니다.
만약 여기서 “분명 팔의 위치가 틀어졌을 거야”라고 단정 짓고 코드를 수정했다면, 고쳐지기는커녕 멀쩡했던 팔의 제어(control)까지 망가뜨렸을 것입니다.
측정(measurement)은 잘못된 곳을 고치는 실수를 방지해 줍니다.
왜 하필 병만 떨어졌으며, 그것도 무려 1.57m나 떨어졌을까요? 여기에는 MuJoCo(혹은 그 어떤 물리 엔진이라도)를 사용하는 모든 사람에게 유용한 교훈이 숨어 있습니다.
MuJoCo는 모델의 모든 관절 위치를 하나의 1차원 배열인 qpos에 집어넣습니다. 각 관절은 신체의 트리 구조(tree structure) 순서에 따라 qpos 내의 특정 **위치(슬롯, slot)**에 “몇 번째부터 몇 번째까지” 할당됩니다. 그리고 “실험 시작 시의 자세”를 정의하는 keyframe은, 단지 그 qpos의 값들을 있는 그대로 나열하여 기록한 숫자들의 나열일 뿐입니다. 마치 qpos="-0.07 0.03 ... 0.94 ..."와 같은 식입니다. 즉, i번째 값은 i번째 슬롯에 들어갑니다. 위치에 의해 결정되는 것입니다.
그런 다음, 저는 머리에 턱 힌지(jaw hinge)인 jaw_open을 추가했습니다. MuJoCo는 여기에 42번째 슬롯을 할당했습니다. 그 결과—42번째 이후의 모든 관절이 각각 뒤로 한 칸씩 밀려나게 되었습니다. 병의 위치를 저장하는 슬롯 또한 42번에서 43번으로 밀려났습니다. 하지만 keyframe의 숫자 배열은 여전히 48개였고, 원래 모델의 배열을 그대로 유지하고 있었습니다(턱을 추가했지만 시작 자세의 수열을 업데이트하지 않은 상태). 모델의 qpos는 이미 49개로 늘어났는데, keyframe은 48개뿐인 상황입니다. MuJoCo는 부족한 1개를 끝부분에 0으로 채워 넣습니다(padding). 결과적으로, 42번째부터 그 이후의 값들이 모두 한 칸씩 밀려서 읽히게 되었습니다.
그림으로 나타내면 다음과 같습니다.
슬롯 번호: ... 41 42(신규) 43 44 45 46 47 48
원래 내용: ... 팔 끝 [턱=0] 병x 병y 병z 회전… 회전… 회전…
keyframe 실제: ... 팔 끝 0 0 0 -1.57 0.32 0 0(padding)
팔(41번째 슬롯 및 그 이전)은 아무런 피해가 없었습니다—오차가 42번째부터 시작되었기 때문입니다. 하지만 병은 달랐습니다. 여기서 중요한 전제 조건을 하나 짚고 넘어가야 합니다: 이 병은 “테이블 높이(0.94m)를 원점으로 하는 상하 슬라이딩 관절(sliding joint)”에 달려 있으며, keyframe에 들어가는 값은 이 원점에 대한 상대적 변위(m)를 나타냅니다. 원래 병의 상하 위치는 0(=테이블 위에 놓임)이어야 합니다.
그런데 한 칸이 밀리면서, 그 높이 칸에는 원래 인접한 관절의 회전각 값이어야 할 -1.57이 흘러 들어왔습니다. -1.57은 원래...
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기