
26/05/13
캔과 손가락 사이의 접촉, 캔의 밀림이 더 잘 보이는 데모영상입니다.
26/05/11
데모영상 초안입니다.
| 영역 | 구현 내용 |
|---|---|
| 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 클래스 |
본 프로젝트의 목표는 외부 라이브러리 의존성을 최소화한 커스텀 로봇 시뮬레이션을 만드는 것입니다. 26/05/12 기준 MJCF 파싱, 렌더링 부분을 제외한 나머지 모듈을 모두 직접 구현하였습니다. 추후 자체 파서를 추가해 의존성을 더욱 낮추는 것을 목표로 하고 있습니다. 또한 기존 로직을 보완하고 모던 로보틱스 동역학(8장) 파트를 하나식 추가해 나갈 계획입니다.
구현한 핵심 모듈을 관련 개념과 함께 설명했습니다.
본 로봇 시뮬레이터는 무조코 등 기존 라이브러리로부터 의존성을 최대한 줄이기 위해 MJCF xml 파일을 파싱하여 새로운 인스턴스로 만들었습니다. 로봇의 정적, 동적 정보를 나타내는 클래스를 각각 RobotModel, RobotState로 구현했습니다. MJCF에서 body tag가 링크들은 BodyNode 클래스로 나타내고 링크의 부모/자식 노드, 관절 정보, transform matrix 등을 포함했습니다.
한편 MJCF의 mesh type은 Open3D mesh로 표현되었고 기타 geom 객체 메타데이터를 아우르기 위해 GeomRecord 클래스를 정의하였습니다.
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는 엔드이펙터의 자세를 입력으로 받아 그 자세를 취하기 위한 관절각을 푸는 과정입니다.
\[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
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
]
갑작스러운 패널 입력 변화에 관절각이 튀지 않도록 시간에 따라 위치, 속도, 가속도 정보를 제공하는 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 클래스를 정의했습니다. 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
충돌 감지 모듈의 파이프라인은 다음과 같습니다.
현재 자가 충돌을 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 조건으로 힘을 기반으로 두 물체가 평형 상태를 유지할 수 있는지 판단하였습니다. 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 패턴은 렌더링부와 로직 처리부를 철저히 분리하는 패턴입니다. target position 변화 등이 감지될 때 View(GUI) 모듈에서 이벤트 핸들러를 수행하게 했고, 이 이벤트 핸들러도 로직의 일부이기에 controller에서 정의했습니다. controller의 메인 함수는 현재 상태를 나타내는 각종 변수들을 nonlocal로 선언하여 이벤트 핸들러가 상태를 변화시킬 수 있게 하였습니다.
그러나 이벤트 핸들러만으로는 시뮬레이션 구색을 갖추기 어려웠습니다. FK나 IK를 적용하고 렌더링하는 역할을 이벤트 핸들러에게 맡기면 FK 적용 주기가 상당히 길어져 끊김 현상이 많이 발생하였습니다. 이에 레퍼런스를 탐색하던 중 game loop에서 자주 쓰이는 tick loop을 참고하였고, Open3D의 tick callback API 활용했습니다.
tick 핸들러는 다음과 같이 앞서 언급된 기능들을 복습하듯이 한 번 이상 수행합니다.
이 과정에서 handle_tick() 함수가 매우 길어져 코드 블록의 역할을 알기 힘들었을 뿐만 아니라 대상 물체가 캔이 아닐 경우 더욱 까다로운 리펙토링이 예상되었습니다. 이에 무조코 등 기존 시뮬레이터가 전체 파이프라인을 어떻게 쪼개는지 조사한 후, 각 컴포넌트에 제 handle_tick() 코드 블록들을 할당하였습니다.
제가 알아본 시뮬레이터의 기본 구조는
각 역할마다 기존 코드 블록은
에 매칭될 수 있습니다.