MyJoCo: Custom 3D Robot Simulator from Scratch

alt text

깃허브 및 데모영상

GitHub

26/05/13

YouTube

캔과 손가락 사이의 접촉, 캔의 밀림이 더 잘 보이는 데모영상입니다.

26/05/11

YouTube

데모영상 초안입니다.

Agenda

  • Current progress (26/05/12 기준)
  • Project Overview
  • Core Implementation
  • Limitation (future implementation plan)

Current progress (26/05/12 기준)

영역 구현 내용
Robot model BodyNode, GeomRecord, RobotModel, RobotState 클래스
Math utils rotation/transform/skew/jacobian matrix, hat 연산 등
FK PoE 기반 recursive FK
IK position IK, pose IK
Trajectory 0.1초 cubic/quantic time scaling
Collision 손가락-캔/테이블 중심 필터링, proxy 클래스
Contact ContactPoint 클래스, normal/tangent 벡터 계산
Grasp alpha interpolation, form/force closure 판단
Object update 캔 가속도, 위치 업데이트
GUI target pose panel, grasp panel 클래스

Project Overview

본 프로젝트의 목표는 외부 라이브러리 의존성을 최소화한 커스텀 로봇 시뮬레이션을 만드는 것입니다. 26/05/12 기준 MJCF 파싱, 렌더링 부분을 제외한 나머지 모듈을 모두 직접 구현하였습니다. 추후 자체 파서를 추가해 의존성을 더욱 낮추는 것을 목표로 하고 있습니다. 또한 기존 로직을 보완하고 모던 로보틱스 동역학(8장) 파트를 하나식 추가해 나갈 계획입니다.

기타 사항

  • 기초를 익히는데 중점을 두어 관련 개념 및 솔루션은 Modern Robotics (Kevin M. Lynch , Frank C. Park, 2017)를 참고했습니다.
  • 소프트웨어 아키텍처(controller, collision-check, solver, integrator, renderer 구조)와 추상화된 충돌 감지 파이프라인(collision-pair filtering, collection 등)은 MuJoCo 공식 문서를 참고했습니다.

Core Implementation

구현한 핵심 모듈을 관련 개념과 함께 설명했습니다.

MJCF parser

본 로봇 시뮬레이터는 무조코 등 기존 라이브러리로부터 의존성을 최대한 줄이기 위해 MJCF xml 파일을 파싱하여 새로운 인스턴스로 만들었습니다. 로봇의 정적, 동적 정보를 나타내는 클래스를 각각 RobotModel, RobotState로 구현했습니다. MJCF에서 body tag가 링크들은 BodyNode 클래스로 나타내고 링크의 부모/자식 노드, 관절 정보, transform matrix 등을 포함했습니다.

한편 MJCF의 mesh type은 Open3D mesh로 표현되었고 기타 geom 객체 메타데이터를 아우르기 위해 GeomRecord 클래스를 정의하였습니다.

Forward Kinematics

forward kinematics는 관절각을 입력으로 노드(링크)의 위치, 즉 transform matrix를 구하는 과정입니다.

\[T(\theta) = e^{[S_1]\theta_1} e^{[S_2]\theta_2} \cdots e^{[S_n]\theta_n} M\]

위치 p에서 속도 p_dot에 대한 선형 미분 방정식의 해를 구하면 $p(t) = e^{[\omega]t}p(0)$ 형태의 식이 도출됩니다. 이를 변환 T에 대해 일반화하면 screw matrix 지수식으로 표현가능하며, 특정 노드의 위치는 변환 행렬 $e^{[\mathcal{S}]\theta}$ 의 누적 곱으로 표현됩니다.

아래 코드는 한 노드에서 변환 행렬 $e^{[\mathcal{S}]\theta}$ 를 구하고, 재귀적으로 자식노드의 위치를 계산합니다.

# sim/model/kinematics/ik.py
def compute_fk_all_links_recursive(
    node: BodyNode,
    state: RobotState,
    home_pose,
    cum_exp,  # 누적 exponential product
    all_link_poses,
    include_node_joints=True,
):
    if include_node_joints:  # 현재 노드에 달린 joint 변환을 FK에 반영할지 정하는 flag
        for joint in node.joints:
            joint_transform = home_pose[node.name]
            q = joint_transform[:3, :3] @ joint["pos"] + joint_transform[:3, 3]
            w = joint_transform[:3, :3] @ joint["axis"]
            S = unit_screw_axis(q, w, 0, joint["type"])
            theta = state.get(
                joint["name"]
            )  # PoE에서 세타는 기준 자세로부터 joint displacement

            # M(home configuration)이 0이 아니면 목표 관절값에서 M만큼 빼줌
            if "_qpos" in home_pose:
                theta -= home_pose["_qpos"][joint["qpos_addr"]]

            S_exp = screw_hat(S) * theta
            cum_exp = cum_exp @ expm(S_exp)

    all_link_poses[node.name] = cum_exp @ home_pose[node.name]

    for child in node.children:
        compute_fk_all_links_recursive(child, state, home_pose, cum_exp, all_link_poses)

    return

Inverse Kinematics

Inverse Kinematics는 엔드이펙터의 자세를 입력으로 받아 그 자세를 취하기 위한 관절각을 푸는 과정입니다.

\[x_d = f(\theta_d)\] \[x_d - f(\theta_i) \approx \frac{\partial f}{\partial \theta}(\theta_i) (\theta_d-\theta_i)\]

IK에서는 위치 오차를 관절각 변화율에 대한 위치 변화율을 기울기로 하는 일차함수로 표현한 뒤, newton-raphson 방법론으로 해를 구합니다. 이때 관절각 변화율에 대한 위치 변화율은 자코비안 행렬이므로 세타를 업데이트하는 과정에서 자코비안 의사역행렬을 사용합니다.

\[\theta_{i+1} = \theta_i + J_s^{\dagger}(\theta_i)\,V_s\]

주목할 점은 자코비안 행렬이 열, 행 개수가 다를 때입니다. 열이 행보다 많으면 6차원 공간을 모두 커버할 수 있으므로(특수한 자세 제외) 입력 자세를 만족시키는 관절각 조합이 여러개 나올 수 있고, 이때 의사역행렬은 가장 작은 해를 반환합니다. 반면 행이 열보다 많은 경우에는 해가 없을 수도 있으며 의사역행렬은 목표 자세와 가장 가깝게 만드는 해를 반환합니다.

IK는 또한 position IK와 pose IK로 구분되어 전자는 오차로 위치를, 후자는 트위스트 에러를 사용합니다. 본 시뮬레이터에서는 캔 잡는 모션을 용이하게 하기 위해 캔을 잡을 오른손에 pose IK를 적용했습니다. 반면 왼손은 유연성을 고려하여 position IK를 적용했습니다.

아래 geometric IK solver 코드는 roll/pitch/yaw 입력을 반영하여 목표 pose를 만들고 newton-raphson 최적화를 수행합니다. 또한 매 스탭마다 세타는 xml에 명시된 관절각 범위를 고려하여 클리핑됩니다.

# sim/model/kinematics/ik.py
def solve_newton_raphson_geometric(
    robot: RobotModel,
    state: RobotState,
    target_pos,
    rot,
    home_pose,
    target_body="arm_r_link7",
):
    T_sb = robot.body_node_for(target_body).world_transform
    T_sd = T_sb.copy()
    T_sd[:3, 3] = target_pos
    T_sd[:3, :3] = rot  # 기준 회전에 RPY offset을 적용한 절대 목표 회전

    theta_prev = state.qpos.copy()
    theta = state.qpos.copy()
    iter = 0

    while True:
        state.qpos = theta.copy()
        link_poses = compute_fk(robot, state, home_pose)
        T_sb = link_poses[target_body]
        twist_error_mat, twist_error = calculate_twist_error(T_sb, T_sd)

        # space frame 기준 자코비안 행렬 J
        J, joints = compute_geometric_jacobian(robot, link_poses, target_body)

        J_b = (
            Adjoint(np.linalg.inv(T_sb)) @ J
        )  # body frame로의 좌표계 변환을 위해 big adjoint 연산 수행
        rows, cols = J_b.shape

        if rows == cols:
            dq = np.linalg.pinv(J_b) @ twist_error
        elif rows > cols:
            dq = np.linalg.pinv(J_b.T @ J_b) @ J_b.T @ twist_error
        else:
            dq = J_b.T @ np.linalg.pinv(J_b @ J_b.T) @ twist_error

        for joint, dq_i in zip(joints, dq):
            theta[joint["qpos_addr"]] += dq_i

        theta = clamp_joint_ranges(theta, joints)

        if np.linalg.norm(twist_error) <= 1e-4:
            break
        if iter > 20:
            break

        iter += 1

    return state

Grasping

for index, i in enumerate(range(1, 5)):  # 엄지 순회
    finger_node = robot.body_node_for(f"finger_r_link{i}")
    joint = finger_node.joints[0]
    qpos_index = joint["qpos_addr"]
    state.qpos[qpos_index] = (1 - alpha) * q_open_list[index] + alpha * q_closed_list[
        index
    ]

Trajectory Generation

갑작스러운 패널 입력 변화에 관절각이 튀지 않도록 시간에 따라 위치, 속도, 가속도 정보를 제공하는 time-scaling 함수를 만듭니다. 모던 로보틱스 9장에서 소개된 cubic_time_scaling, 가속도 안정성을 위해 quintic_time_scaling으로 위치를 보간하다가, 지연이 덜한 cubic_time_scaling를 주로 사용하게 되었습니다.

# sim/model/motion/trajectory.py
# 제약 조건 4개: s(0) = 0, s(Tf) = 1, s_dot(0) = 0, s_dot(T) = 0
def cubic_time_scaling(T, t):
    scaled_t = (
        t / T
    )  # 서로 다른 입력 T에 대해 동일한 time-scaling 계수를 사용하기 위해 정규화된 시간을 사용함. 다시 말해, t로 표현된 s(t)가 0과 1사이 값을 반환하게 하기 위해 t를 정규화함
    s_t = 3 * scaled_t**2 - 2 * scaled_t**3
    s_dot_t = (6 * scaled_t - 6 * scaled_t**2) / T
    s_ddot_t = (6 - 12 * scaled_t) / T**2

    return s_t, s_dot_t, s_ddot_t

ContactPoint modeling

손가락-물체 접촉점 상태를 표현하기 위해 ContactPoint 클래스를 정의했습니다. ContactPoint 클래스는 접촉점 위치, normal/tangent 단위 벡터, 접촉점에서의 상대 속도와 상대 트위스트, 점 간 거리 등을 포함합니다. 그리고 생성자 호출부에서 넘겨진 거리를 바탕으로 관통 거리가 계산됩니다. 후처리 생성자로 필드가 다 채워지면 contact type을 결정합니다.

# sim/model/solver/contact.py
def __post_init__(self):
    self.V_a = np.asarray(self.V_a, dtype=float).reshape(6)
    self.V_b = np.asarray(self.V_b, dtype=float).reshape(6)
    self.V_rel = self.V_a - self.V_b
    self.depth = max(0, -self.distance)
    # ...
    self.contact_type = contact_type(self)

# sim/model/solver/contact.py
def contact_type(contactpoint: ContactPoint):
    # ...
    if np.allclose( # 접촉점 p에서 두 강체의 속도가 같으면 붙어있는 rolling 모드로 봄
        v_a + create_skew(w_a) @ contactpoint.p_a,
        v_b + create_skew(w_b) @ contactpoint.p_b,
    ):

충돌 현상은 접촉, 관통 그리고 관통하지 말아야 할 금지된 충돌 세 가지로 분류했습니다. 캔과 로봇손 사이의 관통은 접촉 범주에 포함되며, 많이 파고든 만큼 강한 힘이 작용했다는 의미이므로 물체는 더 강한 힘을 받게 됩니다. 관통 상태에서 법선 벡터는 python fcl 라이브러리에서 계산되었으며 접촉 상태에서 힘은 아래 코드와 같이 법선 방향 힘과 마찰력을 동시에 계산했습니다.

# sim/model/grasping/contact_constraint.py
v_rel = contact.v_rel  # 물체 기준에서 본 손 속도

normal = np.asarray(contact.normal, dtype=float)
norm = np.linalg.norm(normal)
if norm < 1e-8:
    continue

normal = normal / norm
normal_amount = np.dot(v_rel, normal)

if normal_amount < 0:
    contact.force = np.zeros(3)
    continue

friction_force = np.zeros(3)
if contact.tangent is not None:
    tangent_direction = contact.tangent
    friction_amount = np.linalg.norm(friction_coefficient * normal_force)
    friction_force = friction_amount * tangent_direction  # 접선 방향의 마찰력 계산

# 접촉힘 = 법선힘 + 접선힘
contact.force = normal_force * normal + friction_force

Collision Detection

충돌 감지 모듈의 파이프라인은 다음과 같습니다.

  1. 주어진 state로 FK 적용
  2. 충돌 가능성이 있는 노드 쌍들을 선별 (손가락-손가락, 손가락-캔, 손가락-테이블)
  3. 각 노드를 capsule proxy로 근사하고 python fcl 라이브러리로 프록시 간 거리를 계산
  4. self collision, 손가락-테이블 충돌은 뚫을 수 없는 충돌이라 판단하고 자세를 rollback
  5. 손가락-캔 충돌은 ContactPoint 인스턴스를 생성
  6. form, force closure 판단 여부는 다른 모듈에서 다루었습니다.

현재 자가 충돌을 hard collision으로 취급하는데 자가 충돌 처리 로직이 아직 빈약한 상태입니다. 또한 관통 상태에서 단순히 위치 벡터 차이로 normal 벡터를 구하고 있어 추가적인 보완이 필요합니다.

아래 코드는 프록시 간 거리를 계산하고 거리가 임계값 이하일 경우 접촉으로 간주해 ContactPoint 인스턴스를 생성하는 부분입니다.

# sim/model/collision/collision_check.py
# collision pair 필터링
if not should_check_collision_pair(r_a, r_b):
    continue

# 계산 효율을 위해 각 노드를 proxy로 근사하고 proxy 간 거리를 계산
distance_result = proxy_distance(r_a, r_b, proxy_cache)  # python fcl 기반 거리 측정
if len(distance_result) == 4:
    p_a, p_b, d, normal = distance_result
else:
    p_a, p_b, d = distance_result
    normal = contact_normal(p_a, p_b)
body_names = {r_a.body_name, r_b.body_name}

# 손이 물체를 관통하는 상황은 충돌보다 접촉에 가까운 것으로 봄
if "pr_cokeCan" in body_names and d <= 0.002:
    is_contact = True

    if return_contacts:  # 접촉점 배열 형성
        contact_candidates.append(
            ContactPoint(
                record_a=r_a,
                record_b=r_b,
                point=0.5 * (p_a + p_b),
                p_a=p_a,
                p_b=p_b,
                normal=normal,
                distance=d,
                depth=max(0.0, -d),  # penetration depth
                V_a=state.body_twists.get(r_a.body_name, np.zeros(6)),
                V_b=state.body_twists.get(r_b.body_name, np.zeros(6)),
            )
        )

Form/Force Closure Check

form/force closure 조건으로 힘을 기반으로 두 물체가 평형 상태를 유지할 수 있는지 판단하였습니다. grasp 평가 함수는 form/force closure 차례로 평가하고 force closure를 만족할 경우 linprog()로 각 접촉력의 가중치를 계산합니다.

# sim/model/solver/form_closure.py
def check_form_closure(contact_points):
    # 조건0: positive span이 공간 전체를 덮어야 해 최소 7개의 접촉점을 가져야함 (공간 기준)
    if len(contact_points) < 7:
        return False

    wrenches = [
        force_to_wrench(contact.point, contact.normal)
        for contact in contact_points
        if contact.normal is not None
    ]

    if not wrenches:
        return False

    G = np.column_stack(wrenches)

    # 조건1. G의 rank가 공간 차원 전체
    rank = np.linalg.matrix_rank(G)

    if rank != 6:  # 평면인 경우 공간인 경우 분기처리하기
        return False

    # 조건2. Gk = 0, k > 0를 만족하는 k가 존재
    if not has_positive_k(G):
        return False

    return True

위 코드와 같이 form closure 조건은 접촉점에서 법선 방향의 힘만 고려하며 이는 마찰력이 없다고 가정해도 만족해야 합니다. 이 때문에 form closure는 force closure보다 더 까다로운 조건으로 여겨집니다. 접촉점의 모든 wrench로 wrench matrix를 만들고, wrench 벡터들의 선형 조합으로 어느 방향의 wrench든 만들 수 있을 때 이 조건이 성립합니다.

이와 달리 force closure는 접촉점에서의 마찰력도 고려하며, 단일 접촉점에서 여러 wrench 벡터가 나오기 때문에 접촉점당 하나의 wrench matrix를 갖게 됩니다. 모던 로보틱스 12장에서는 법선 힘과 마찰력 간 관계를 쿨롱 마찰 모델로 표현합니다. 쿨롱 마찰 모델은 법선 힘의 크기가 크면 마찰력도 이에 비례해 커진다는 현상을 수식으로 표현합니다.

\[\|f_t\| \le \mu f_n\]

부등식은 고정된 법선 벡터 fn에 대해서 half-space를 형성하고 fn의 연속성을 고려하면 원뿔 형태가 됩니다. 이때 연속적인 원뿔 안의 공간을 벡터로 표현하기 어려운 점이 있습니다. 이때 원뿔 위의 한 점을 기준으로 두 개의 기저 벡터를 생성한 뒤, 기저 벡터를 바탕으로 네 개의 근사 edge force의 선형 결합으로 원뿔내에 속하는 벡터를 표현할 수 있습니다. 근사된 마찰 원뿔은 wrench 원뿔로 변환되고 wrench 원뿔 역시 접촉점당 네 개의 wrench로 표현이 가능합니다. 따라서 j개의 접촉점이 있을 경우 4j개의 열로 구성된 wrench matrix G를 만들 수 있습니다.

# sim/model/solver/force_closure.py
def solve_contact_forces(contact_points, external_wrench, friction_coefficient):
    # external_wrench: 중력항
    external_wrench = np.asarray(external_wrench, dtype=float).reshape(6)
    wrenches = []

    # 각 접촉점마다 friction cone edge force 생성
    for contact in contact_points:
        if contact.normal is None or contact.point is None:
            continue

        normal = np.asarray(contact.normal, dtype=float)
        normal_norm = np.linalg.norm(normal)
        if normal_norm < 1e-8:
            continue

        normal = normal / normal_norm

        if abs(normal[0]) < 0.9:
            a = np.array([1.0, 0.0, 0.0])
        else:
            a = np.array([0.0, 1.0, 0.0])

        basis1 = np.cross(normal, a)
        basis1 = basis1 / np.linalg.norm(basis1)

        basis2 = np.cross(normal, basis1)
        basis2 = basis2 / np.linalg.norm(basis2)

        edge_forces = [  # 각 edge force를 wrench로 변환
            normal + friction_coefficient * basis1,
            normal - friction_coefficient * basis1,
            normal + friction_coefficient * basis2,
            normal - friction_coefficient * basis2,
        ]

        for edge_force in edge_forces:
            wrenches.append(force_to_wrench(contact.point, edge_force))

    if not wrenches:
        return False, None

    G = np.column_stack(wrenches)  # wrench edge를 합쳐 G 생성
    m = G.shape[1]  # wrench edge 계수

    # 정적 평형(Static Equilibrium) 상태를 위한 접촉력 가중치 계산...
    result = linprog(
        c=np.ones(m),  # minimize sum(f)
        A_eq=G,
        b_eq=-external_wrench,
        bounds=[(0.0, None)] * m,  # f >= 0 제약
        method="highs",
    )

네 개의 기저 벡터, 즉 edge force들의 양의 선형 조합의 조합을 푸는 문제를 linprog() 함수로 계산합니다.

MVC 패턴과 Tick loop

시뮬레이션 프로그램에서 역할 분담의 첫 레퍼런스가 되었던 MVC 패턴은 렌더링부와 로직 처리부를 철저히 분리하는 패턴입니다. target position 변화 등이 감지될 때 View(GUI) 모듈에서 이벤트 핸들러를 수행하게 했고, 이 이벤트 핸들러도 로직의 일부이기에 controller에서 정의했습니다. controller의 메인 함수는 현재 상태를 나타내는 각종 변수들을 nonlocal로 선언하여 이벤트 핸들러가 상태를 변화시킬 수 있게 하였습니다.

그러나 이벤트 핸들러만으로는 시뮬레이션 구색을 갖추기 어려웠습니다. FK나 IK를 적용하고 렌더링하는 역할을 이벤트 핸들러에게 맡기면 FK 적용 주기가 상당히 길어져 끊김 현상이 많이 발생하였습니다. 이에 레퍼런스를 탐색하던 중 game loop에서 자주 쓰이는 tick loop을 참고하였고, Open3D의 tick callback API 활용했습니다.

tick 핸들러는 다음과 같이 앞서 언급된 기능들을 복습하듯이 한 번 이상 수행합니다.

  1. 이전 qpos와 hand pose를 저장
  2. time.perf_counter() 기반으로 dt를 계산하고, 물체 속도 적분을 위한 dt는 최대 1/30으로 제한
  3. GUI target이 있으면 trajectory interpolation으로 현재 목표 위치를 계산
  4. roll/pitch/yaw offset을 rotation matrix로 변환
  5. right hand에 대해 pose IK를 적용
  6. left hand에 대해 position IK를 적용하여 왼손 position를 유지
  7. grasp slider 값이 있으면 finger joint qpos를 갱신
  8. candidate state에 대해 collision/contact를 검사
  9. 위험한 collision이면 이전 상태로 rollback
  10. 가능하면 FK를 적용하고 qvel, body twist를 계산
  11. can과 접촉 중이면 grasp closure 또는 contact force를 평가
  12. grasp 성공 시 hand와 object 사이 offset을 유지하면서 캔을 같이 이동
  13. grasp가 아닌 단순 contact면 contact force 기반으로 캔을 밀어냄

이 과정에서 handle_tick() 함수가 매우 길어져 코드 블록의 역할을 알기 힘들었을 뿐만 아니라 대상 물체가 캔이 아닐 경우 더욱 까다로운 리펙토링이 예상되었습니다. 이에 무조코 등 기존 시뮬레이터가 전체 파이프라인을 어떻게 쪼개는지 조사한 후, 각 컴포넌트에 제 handle_tick() 코드 블록들을 할당하였습니다.

제가 알아본 시뮬레이터의 기본 구조는

  • Controller: user intent and tick orchestration
  • Collision: contact candidate generation
  • Solver: contact, closure, and grasp-state evaluation
  • Integrator: qpos/qvel/body transform updates
  • Renderer: Open3D visualization and GUI callbacks

각 역할마다 기존 코드 블록은

  • Controller: handle_tick()
  • Collision: collision_check()
  • Solver: object_body_contacts(), evaluate_grasp_hold(), update_grasp_state()
  • Integrator: apply_translation(), update_qvel(), integrate_force()
  • Renderer: renderer.py

에 매칭될 수 있습니다.

Limitation (future implementation plan)

  • 현재 접촉점에서 물체의 회전을 고려하지 않고 있습니다.
  • 엑추에이터 힘을 별도로 계산하지 않고 고정된 힘 크기를 사용하고 있습니다.
  • force closure를 판단하는 과정에서 원뿔을 네 개의 기저벡터로 근사합니다. 이때 각 기저벡터 앞에 붙는 가중치만 반환하고 실제 힘으로 복원하는 과정이 추가되어야 합니다.
  • Manipulability을 평가하는 로직을 추가해야 합니다.
  • 엔드이펙터를 뜻하는 문자열이 하드코딩되어 있습니다.
  • 캔을 xy 평면에 수평한 방향으로 밀 때, z축 힘을 자른다는 아쉬움이 있습니다.
  • 중력 및 기타 물리 법칙을 환경에 적용해야 합니다.
  • tick loop 및 궤적 형성 과정을 최적화하여 더 부드러운 움직임을 도모해야 합니다.
  • 오른손, 왼손에 연속적으로 IK를 적용하는 게 옳은지 검증해봐야 합니다. 7장 Kinematics of Closed Chains을 참고해야 합니다.
  • capsule proxy 외에 cylinder, sphere 등 다양한 primitive type을 고려해야 합니다.
  • 한 방향으로 힘이 더해질 때 누적되는 힘이 커지는 상황에서 에너지 보존 법칙 활용을 고려하고 있습니다. (레퍼런스: 무조코)