rigidax 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rigidax/__init__.py +64 -0
- rigidax/dynamics.py +212 -0
- rigidax/ik/__init__.py +48 -0
- rigidax/ik/bvh.py +99 -0
- rigidax/ik/collision.py +235 -0
- rigidax/ik/qp.py +177 -0
- rigidax/ik/solver.py +405 -0
- rigidax/ik/tasks.py +195 -0
- rigidax/ik/trajectory.py +57 -0
- rigidax/kinematics.py +181 -0
- rigidax/model.py +340 -0
- rigidax/spatial.py +107 -0
- rigidax-1.0.0.dist-info/METADATA +257 -0
- rigidax-1.0.0.dist-info/RECORD +17 -0
- rigidax-1.0.0.dist-info/WHEEL +5 -0
- rigidax-1.0.0.dist-info/licenses/LICENSE +21 -0
- rigidax-1.0.0.dist-info/top_level.txt +1 -0
rigidax/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""rigidax: differentiable rigid-body kinematics and dynamics in JAX"""
|
|
2
|
+
|
|
3
|
+
from rigidax.dynamics import (
|
|
4
|
+
bias_forces,
|
|
5
|
+
body_spatial_inertias,
|
|
6
|
+
coriolis_forces,
|
|
7
|
+
difference,
|
|
8
|
+
dof_inertias,
|
|
9
|
+
dynamics_terms,
|
|
10
|
+
forward_dynamics,
|
|
11
|
+
gravity_forces,
|
|
12
|
+
integrate,
|
|
13
|
+
mass_matrix,
|
|
14
|
+
mass_matrix_inverse,
|
|
15
|
+
rnea,
|
|
16
|
+
)
|
|
17
|
+
from rigidax.kinematics import (
|
|
18
|
+
Kinematics,
|
|
19
|
+
body_coms,
|
|
20
|
+
body_jacobian,
|
|
21
|
+
body_pose,
|
|
22
|
+
center_of_mass,
|
|
23
|
+
center_of_mass_jacobian,
|
|
24
|
+
forward_kinematics,
|
|
25
|
+
point_jacobian,
|
|
26
|
+
points_jacobians,
|
|
27
|
+
site_jacobian,
|
|
28
|
+
site_pose,
|
|
29
|
+
)
|
|
30
|
+
from rigidax.model import Model, Params, Topology, from_mujoco, from_xml_string, load
|
|
31
|
+
|
|
32
|
+
__version__ = "1.0.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Kinematics",
|
|
36
|
+
"Model",
|
|
37
|
+
"Params",
|
|
38
|
+
"Topology",
|
|
39
|
+
"bias_forces",
|
|
40
|
+
"body_coms",
|
|
41
|
+
"body_jacobian",
|
|
42
|
+
"body_pose",
|
|
43
|
+
"body_spatial_inertias",
|
|
44
|
+
"center_of_mass",
|
|
45
|
+
"center_of_mass_jacobian",
|
|
46
|
+
"coriolis_forces",
|
|
47
|
+
"difference",
|
|
48
|
+
"dof_inertias",
|
|
49
|
+
"dynamics_terms",
|
|
50
|
+
"forward_dynamics",
|
|
51
|
+
"forward_kinematics",
|
|
52
|
+
"from_mujoco",
|
|
53
|
+
"from_xml_string",
|
|
54
|
+
"gravity_forces",
|
|
55
|
+
"integrate",
|
|
56
|
+
"load",
|
|
57
|
+
"mass_matrix",
|
|
58
|
+
"mass_matrix_inverse",
|
|
59
|
+
"point_jacobian",
|
|
60
|
+
"points_jacobians",
|
|
61
|
+
"rnea",
|
|
62
|
+
"site_jacobian",
|
|
63
|
+
"site_pose",
|
|
64
|
+
]
|
rigidax/dynamics.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Inverse/forward dynamics and configuration-space integration.
|
|
2
|
+
|
|
3
|
+
All algorithms are written as dense, vectorized passes over the DOF ancestor mask,
|
|
4
|
+
so there are no sequential loops over joints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import jax
|
|
8
|
+
import jax.numpy as jnp
|
|
9
|
+
import jax.scipy.linalg as jsl
|
|
10
|
+
import numpy as np
|
|
11
|
+
from jax import Array
|
|
12
|
+
|
|
13
|
+
from rigidax.kinematics import Kinematics, _kin, body_coms
|
|
14
|
+
from rigidax.model import Model, derived
|
|
15
|
+
from rigidax.spatial import (
|
|
16
|
+
cross_force,
|
|
17
|
+
cross_motion,
|
|
18
|
+
einsum,
|
|
19
|
+
mm,
|
|
20
|
+
quat_conj,
|
|
21
|
+
quat_integrate,
|
|
22
|
+
quat_log,
|
|
23
|
+
quat_mul,
|
|
24
|
+
quat_normalize,
|
|
25
|
+
quat_to_mat,
|
|
26
|
+
skew,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def body_spatial_inertias(model: Model, kin: Kinematics, bodies=None) -> Array:
|
|
31
|
+
"""(nbody, 6, 6) spatial inertias in the world frame, about the origin"""
|
|
32
|
+
P = model.params
|
|
33
|
+
b = slice(None) if bodies is None else bodies
|
|
34
|
+
with jax.ensure_compile_time_eval(): # parameter-only: constant if not traced
|
|
35
|
+
Ri = quat_to_mat(jnp.asarray(P.body_iquat)[b])
|
|
36
|
+
I_local = einsum("bij,bj,bkj->bik", Ri, jnp.asarray(P.body_inertia)[b], Ri)
|
|
37
|
+
ipos = jnp.asarray(P.body_ipos)[b]
|
|
38
|
+
m = jnp.asarray(P.body_mass)[b][:, None, None]
|
|
39
|
+
m_eye = m * jnp.eye(3)
|
|
40
|
+
R, p = kin.body_R[b], kin.body_p[b]
|
|
41
|
+
Ic = mm(mm(R, I_local), jnp.swapaxes(R, -1, -2))
|
|
42
|
+
C = skew(p + einsum("bij,bj->bi", R, ipos))
|
|
43
|
+
mC = m * C
|
|
44
|
+
top = jnp.concatenate([Ic - mm(mC, C), mC], -1)
|
|
45
|
+
bot = jnp.concatenate([-mC, jnp.broadcast_to(m_eye, mC.shape)], -1)
|
|
46
|
+
return jnp.concatenate([top, bot], -2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _moving_inertias(model, kin):
|
|
50
|
+
"""Spatial inertias of the bodies that some DOF moves (static subset)"""
|
|
51
|
+
return body_spatial_inertias(model, kin, derived(model.topo).owned)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dof_inertias(model: Model, kin: Kinematics) -> Array:
|
|
55
|
+
"""(nv, 6, 6) inertia carried by each DOF (welded bodies fused, virtual DOFs zero)"""
|
|
56
|
+
D = derived(model.topo)
|
|
57
|
+
own = np.zeros((model.nv, len(D.owned)))
|
|
58
|
+
own[D.owned_dof, np.arange(len(D.owned))] = 1.0
|
|
59
|
+
return einsum("ki,iab->kab", own, _moving_inertias(model, kin))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ext_wrenches(model: Model, kin: Kinematics, xfrc: Array) -> Array:
|
|
63
|
+
"""Per-body [force, torque] at the COM (world axes, MuJoCo's xfrc_applied layout)
|
|
64
|
+
-> spatial forces about the origin, for each moving body"""
|
|
65
|
+
D = derived(model.topo)
|
|
66
|
+
c = body_coms(model, None, kin)[D.owned]
|
|
67
|
+
f, tq = xfrc[D.owned, :3], xfrc[D.owned, 3:]
|
|
68
|
+
return jnp.concatenate([tq + jnp.cross(c, f), f], -1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rnea(model, kin, Ib, v, a, gravity, F_ext):
|
|
72
|
+
D = derived(model.topo)
|
|
73
|
+
A, W = jnp.asarray(D.A), jnp.asarray(D.W)
|
|
74
|
+
S = kin.S
|
|
75
|
+
acc = None
|
|
76
|
+
if v is not None:
|
|
77
|
+
Sv = S * v[:, None]
|
|
78
|
+
V = mm(A, Sv)
|
|
79
|
+
if np.array_equal(D.cross, np.arange(model.nv)):
|
|
80
|
+
Vc = V # hinge/slide only: each axis moves with its own frame
|
|
81
|
+
else:
|
|
82
|
+
Vc = jnp.concatenate([V, jnp.zeros((1, 6), V.dtype)])[D.cross]
|
|
83
|
+
acc = cross_motion(Vc, Sv)
|
|
84
|
+
if a is not None:
|
|
85
|
+
Sa = S * a[:, None]
|
|
86
|
+
acc = Sa if acc is None else acc + Sa
|
|
87
|
+
Acc = jnp.zeros((len(D.owned), 6), S.dtype) if acc is None else mm(A, acc)[D.owned_dof]
|
|
88
|
+
if gravity:
|
|
89
|
+
g = jnp.asarray(model.params.gravity, S.dtype)
|
|
90
|
+
Acc = Acc + jnp.concatenate([jnp.zeros(3, S.dtype), -g])
|
|
91
|
+
F = einsum("iab,ib->ia", Ib, Acc)
|
|
92
|
+
if v is not None:
|
|
93
|
+
Vb = V[D.owned_dof]
|
|
94
|
+
F = F + cross_force(Vb, einsum("iab,ib->ia", Ib, Vb))
|
|
95
|
+
if F_ext is not None:
|
|
96
|
+
F = F - F_ext
|
|
97
|
+
tau = jnp.sum(S * mm(W, F), -1)
|
|
98
|
+
if a is not None:
|
|
99
|
+
tau = tau + jnp.asarray(model.params.dof_armature) * a
|
|
100
|
+
return tau
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def rnea(model: Model, q: Array, v: Array | None, a: Array | None, *,
|
|
104
|
+
xfrc: Array | None = None, gravity: bool = True,
|
|
105
|
+
kin: Kinematics | None = None) -> Array:
|
|
106
|
+
"""Inverse dynamics: tau = M(q) a + C(q, v) v + g(q) - J^T f_ext.
|
|
107
|
+
|
|
108
|
+
v / a may be None (treated as zero). `xfrc` is (nbody, 6) [force, torque] applied
|
|
109
|
+
at each body's COM in world axes, like MuJoCo's xfrc_applied.
|
|
110
|
+
Armature is included in the M(q) a term.
|
|
111
|
+
"""
|
|
112
|
+
kin = _kin(model, q, kin)
|
|
113
|
+
F_ext = None if xfrc is None else _ext_wrenches(model, kin, xfrc)
|
|
114
|
+
return _rnea(model, kin, _moving_inertias(model, kin), v, a, gravity, F_ext)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _crba(model, kin, Ib):
|
|
118
|
+
D = derived(model.topo)
|
|
119
|
+
A = jnp.asarray(D.A)
|
|
120
|
+
Ic = einsum("ki,iab->kab", D.W, Ib) # composite inertia of each DOF's subtree
|
|
121
|
+
F = einsum("kab,kb->ka", Ic, kin.S)
|
|
122
|
+
M_all = mm(F, kin.S.T) # M_all[k, j] = s_j . Ic_k s_k
|
|
123
|
+
L = A * M_all # keep (k, ancestor j) pairs
|
|
124
|
+
return L + jnp.tril(L, -1).T + jnp.diag(jnp.asarray(model.params.dof_armature))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mass_matrix(model: Model, q: Array, kin: Kinematics | None = None) -> Array:
|
|
128
|
+
"""Joint-space inertia matrix (CRBA), including armature, (nv, nv)"""
|
|
129
|
+
kin = _kin(model, q, kin)
|
|
130
|
+
return _crba(model, kin, _moving_inertias(model, kin))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def bias_forces(model: Model, q: Array, v: Array, kin: Kinematics | None = None) -> Array:
|
|
134
|
+
"""C(q, v) v + g(q), (nv,)"""
|
|
135
|
+
return rnea(model, q, v, None, kin=kin)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def gravity_forces(model: Model, q: Array, kin: Kinematics | None = None) -> Array:
|
|
139
|
+
return rnea(model, q, None, None, kin=kin)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def coriolis_forces(model: Model, q: Array, v: Array, kin: Kinematics | None = None) -> Array:
|
|
143
|
+
return rnea(model, q, v, None, gravity=False, kin=kin)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def dynamics_terms(model: Model, q: Array, v: Array, kin: Kinematics | None = None):
|
|
147
|
+
"""(M, bias) from a single kinematics and inertia pass"""
|
|
148
|
+
kin = _kin(model, q, kin)
|
|
149
|
+
Ib = _moving_inertias(model, kin)
|
|
150
|
+
return _crba(model, kin, Ib), _rnea(model, kin, Ib, v, None, True, None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def forward_dynamics(model: Model, q: Array, v: Array, tau: Array, *,
|
|
154
|
+
xfrc: Array | None = None, kin: Kinematics | None = None) -> Array:
|
|
155
|
+
"""qacc = M^-1 (tau + J^T f_ext - bias), via one Cholesky factorization"""
|
|
156
|
+
kin = _kin(model, q, kin)
|
|
157
|
+
Ib = _moving_inertias(model, kin)
|
|
158
|
+
M = _crba(model, kin, Ib)
|
|
159
|
+
F_ext = None if xfrc is None else _ext_wrenches(model, kin, xfrc)
|
|
160
|
+
bias = _rnea(model, kin, Ib, v, None, True, F_ext)
|
|
161
|
+
return jsl.cho_solve(jsl.cho_factor(M, lower=True), tau - bias)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def mass_matrix_inverse(M: Array) -> Array:
|
|
165
|
+
c = jsl.cho_factor(M, lower=True)
|
|
166
|
+
return jsl.cho_solve(c, jnp.eye(M.shape[0], dtype=M.dtype))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ----- configuration space -----
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def integrate(model: Model, q: Array, v: Array, dt: float) -> Array:
|
|
173
|
+
"""q (+) v*dt, respecting quaternions (MuJoCo's mj_integratePos)"""
|
|
174
|
+
D = derived(model.topo)
|
|
175
|
+
q = jnp.asarray(q)
|
|
176
|
+
if len(D.scalar_j):
|
|
177
|
+
j = D.scalar_j
|
|
178
|
+
q = q.at[D.qadr[j]].add(v[D.dadr[j]] * dt)
|
|
179
|
+
if len(D.free_j):
|
|
180
|
+
qa, da = D.qadr[D.free_j][:, None], D.dadr[D.free_j][:, None]
|
|
181
|
+
q = q.at[qa + jnp.arange(3)].add(v[da + jnp.arange(3)] * dt)
|
|
182
|
+
quat = quat_normalize(q[qa + 3 + jnp.arange(4)])
|
|
183
|
+
q = q.at[qa + 3 + jnp.arange(4)].set(quat_integrate(quat, v[da + 3 + jnp.arange(3)], dt))
|
|
184
|
+
if len(D.ball_j):
|
|
185
|
+
qa, da = D.qadr[D.ball_j][:, None], D.dadr[D.ball_j][:, None]
|
|
186
|
+
quat = quat_normalize(q[qa + jnp.arange(4)])
|
|
187
|
+
q = q.at[qa + jnp.arange(4)].set(quat_integrate(quat, v[da + jnp.arange(3)], dt))
|
|
188
|
+
return q
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def difference(model: Model, q0: Array, q1: Array) -> Array:
|
|
192
|
+
"""v such that integrate(q0, v, 1) == q1 (MuJoCo's mj_differentiatePos, dt=1)"""
|
|
193
|
+
D = derived(model.topo)
|
|
194
|
+
q0, q1 = jnp.asarray(q0), jnp.asarray(q1)
|
|
195
|
+
v = jnp.zeros(model.nv, jnp.result_type(q0, q1))
|
|
196
|
+
if len(D.scalar_j):
|
|
197
|
+
j = D.scalar_j
|
|
198
|
+
v = v.at[D.dadr[j]].set(q1[D.qadr[j]] - q0[D.qadr[j]])
|
|
199
|
+
|
|
200
|
+
def rot(qa):
|
|
201
|
+
a = quat_normalize(q0[qa + jnp.arange(4)])
|
|
202
|
+
b = quat_normalize(q1[qa + jnp.arange(4)])
|
|
203
|
+
return quat_log(quat_mul(quat_conj(a), b))
|
|
204
|
+
|
|
205
|
+
if len(D.free_j):
|
|
206
|
+
qa, da = D.qadr[D.free_j][:, None], D.dadr[D.free_j][:, None]
|
|
207
|
+
v = v.at[da + jnp.arange(3)].set(q1[qa + jnp.arange(3)] - q0[qa + jnp.arange(3)])
|
|
208
|
+
v = v.at[da + 3 + jnp.arange(3)].set(rot(qa + 3))
|
|
209
|
+
if len(D.ball_j):
|
|
210
|
+
qa, da = D.qadr[D.ball_j][:, None], D.dadr[D.ball_j][:, None]
|
|
211
|
+
v = v.at[da + jnp.arange(3)].set(rot(qa))
|
|
212
|
+
return v
|
rigidax/ik/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""rigidax.ik: differential IK, batched retargeting and self-collision avoidance"""
|
|
2
|
+
|
|
3
|
+
from rigidax.ik.collision import SphereModel
|
|
4
|
+
from rigidax.ik.qp import INF, QPResult, QPState, solve_qp, spd_inverse
|
|
5
|
+
from rigidax.ik.solver import FloorPoints, IKSolver
|
|
6
|
+
from rigidax.ik.tasks import (
|
|
7
|
+
DirectionTask,
|
|
8
|
+
HeightTask,
|
|
9
|
+
JointTask,
|
|
10
|
+
OrientationTask,
|
|
11
|
+
PositionTask,
|
|
12
|
+
PostureTask,
|
|
13
|
+
TwistTask,
|
|
14
|
+
direction,
|
|
15
|
+
height,
|
|
16
|
+
joints,
|
|
17
|
+
orientation,
|
|
18
|
+
position,
|
|
19
|
+
rotation_error,
|
|
20
|
+
twist,
|
|
21
|
+
)
|
|
22
|
+
from rigidax.ik.trajectory import smooth_trajectory
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DirectionTask",
|
|
26
|
+
"FloorPoints",
|
|
27
|
+
"HeightTask",
|
|
28
|
+
"IKSolver",
|
|
29
|
+
"INF",
|
|
30
|
+
"JointTask",
|
|
31
|
+
"OrientationTask",
|
|
32
|
+
"PositionTask",
|
|
33
|
+
"PostureTask",
|
|
34
|
+
"QPResult",
|
|
35
|
+
"QPState",
|
|
36
|
+
"SphereModel",
|
|
37
|
+
"TwistTask",
|
|
38
|
+
"direction",
|
|
39
|
+
"height",
|
|
40
|
+
"joints",
|
|
41
|
+
"orientation",
|
|
42
|
+
"position",
|
|
43
|
+
"rotation_error",
|
|
44
|
+
"smooth_trajectory",
|
|
45
|
+
"solve_qp",
|
|
46
|
+
"spd_inverse",
|
|
47
|
+
"twist",
|
|
48
|
+
]
|
rigidax/ik/bvh.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Minimal BVH motion-capture reader (Y-up files converted to Z-up)"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_bvh(path):
|
|
9
|
+
"""-> joint names, parents, offsets (J, 3), channels per joint, motion (T, C)"""
|
|
10
|
+
names, parents, offsets, channels, stack = [], [], [], [], []
|
|
11
|
+
lines = iter(Path(path).read_text().split("\n"))
|
|
12
|
+
for line in lines:
|
|
13
|
+
tok = line.split()
|
|
14
|
+
if not tok:
|
|
15
|
+
continue
|
|
16
|
+
if tok[0] in ("ROOT", "JOINT"):
|
|
17
|
+
names.append(tok[1])
|
|
18
|
+
parents.append(stack[-1] if stack else -1)
|
|
19
|
+
offsets.append(None)
|
|
20
|
+
channels.append([])
|
|
21
|
+
elif tok[0] == "End":
|
|
22
|
+
names.append(None) # end site: parsed for nesting, dropped later
|
|
23
|
+
parents.append(stack[-1])
|
|
24
|
+
offsets.append(None)
|
|
25
|
+
channels.append([])
|
|
26
|
+
elif tok[0] == "{":
|
|
27
|
+
stack.append(len(names) - 1)
|
|
28
|
+
elif tok[0] == "}":
|
|
29
|
+
stack.pop()
|
|
30
|
+
elif tok[0] == "OFFSET":
|
|
31
|
+
offsets[stack[-1]] = [float(x) for x in tok[1:4]]
|
|
32
|
+
elif tok[0] == "CHANNELS":
|
|
33
|
+
channels[stack[-1]] = tok[2:]
|
|
34
|
+
elif tok[0] == "MOTION":
|
|
35
|
+
break
|
|
36
|
+
next(lines) # Frames:
|
|
37
|
+
next(lines) # Frame Time:
|
|
38
|
+
motion = np.array([[float(x) for x in ln.split()] for ln in lines if ln.strip()])
|
|
39
|
+
keep = [i for i, n in enumerate(names) if n is not None]
|
|
40
|
+
remap = {old: new for new, old in enumerate(keep)}
|
|
41
|
+
return ([names[i] for i in keep], [remap.get(parents[i], -1) for i in keep],
|
|
42
|
+
np.array([offsets[i] for i in keep]), [channels[i] for i in keep], motion)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _axis_rot(axis, deg):
|
|
46
|
+
a = np.radians(deg)
|
|
47
|
+
c, s = np.cos(a), np.sin(a)
|
|
48
|
+
o, z = np.ones_like(a), np.zeros_like(a)
|
|
49
|
+
if axis == "X":
|
|
50
|
+
m = [[o, z, z], [z, c, -s], [z, s, c]]
|
|
51
|
+
elif axis == "Y":
|
|
52
|
+
m = [[c, z, s], [z, o, z], [-s, z, c]]
|
|
53
|
+
else:
|
|
54
|
+
m = [[c, -s, z], [s, c, z], [z, z, o]]
|
|
55
|
+
return np.moveaxis(np.array(m), [0, 1], [-2, -1])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bvh_rotations(path):
|
|
59
|
+
"""Global joint orientations {name: (T, 3, 3)}, Z-up (same frame as bvh_positions)"""
|
|
60
|
+
names, parents, offsets, channels, motion = load_bvh(path)
|
|
61
|
+
T, col = len(motion), 0
|
|
62
|
+
R = [None] * len(names)
|
|
63
|
+
for j, ch in enumerate(channels):
|
|
64
|
+
Rl = np.broadcast_to(np.eye(3), (T, 3, 3))
|
|
65
|
+
for c in ch:
|
|
66
|
+
if not c.endswith("position"):
|
|
67
|
+
Rl = Rl @ _axis_rot(c[0], motion[:, col])
|
|
68
|
+
col += 1
|
|
69
|
+
R[j] = Rl if parents[j] < 0 else R[parents[j]] @ Rl
|
|
70
|
+
C = np.array([[1.0, 0, 0], [0, 0, -1], [0, 1, 0]]) # Y-up -> Z-up
|
|
71
|
+
return {n: C @ R[i] for i, n in enumerate(names)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bvh_positions(path, unit: float = 0.01):
|
|
75
|
+
"""Global joint positions {name: (T, 3)}, Z-up, in meters (unit: BVH units -> m)"""
|
|
76
|
+
names, parents, offsets, channels, motion = load_bvh(path)
|
|
77
|
+
T, col = len(motion), 0
|
|
78
|
+
R = [None] * len(names)
|
|
79
|
+
P = [None] * len(names)
|
|
80
|
+
for j, ch in enumerate(channels):
|
|
81
|
+
Rl = np.broadcast_to(np.eye(3), (T, 3, 3))
|
|
82
|
+
pos = np.broadcast_to(offsets[j], (T, 3))
|
|
83
|
+
for c in ch:
|
|
84
|
+
if c.endswith("position"):
|
|
85
|
+
pos = pos.copy() if pos.base is not None else pos
|
|
86
|
+
pos = np.array(pos)
|
|
87
|
+
pos[:, "XYZ".index(c[0])] = motion[:, col]
|
|
88
|
+
else:
|
|
89
|
+
Rl = Rl @ _axis_rot(c[0], motion[:, col])
|
|
90
|
+
col += 1
|
|
91
|
+
p = parents[j]
|
|
92
|
+
if p < 0:
|
|
93
|
+
R[j], P[j] = Rl, pos
|
|
94
|
+
else:
|
|
95
|
+
R[j] = R[p] @ Rl
|
|
96
|
+
P[j] = P[p] + np.einsum("tij,j->ti", R[p], offsets[j])
|
|
97
|
+
pos = np.stack(P, 1) * unit
|
|
98
|
+
C = np.array([[1.0, 0, 0], [0, 0, -1], [0, 1, 0]]) # Y-up -> Z-up
|
|
99
|
+
return {n: pos[:, i] @ C.T for i, n in enumerate(names)}
|
rigidax/ik/collision.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Automatic sphere collision models for any MuJoCo model.
|
|
2
|
+
|
|
3
|
+
Each body's collision geometry (meshes, boxes, capsules, cylinders, spheres, ellipsoids)
|
|
4
|
+
is sampled into points. The points are sliced along their principal axis, and each
|
|
5
|
+
slice gets a sphere at its centroid covering most of its cross-section. Pairs are
|
|
6
|
+
generated automatically: different bodies, not parent/child, optionally filtered.
|
|
7
|
+
Pairs that already overlap in the reference pose get a relaxed minimum distance, so
|
|
8
|
+
the robot never fights its own default posture.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import jax.numpy as jnp
|
|
15
|
+
import numpy as np
|
|
16
|
+
from jax import Array
|
|
17
|
+
|
|
18
|
+
from rigidax.kinematics import Kinematics, forward_kinematics
|
|
19
|
+
from rigidax.model import Model, derived
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SphereModel:
|
|
24
|
+
body: np.ndarray # (S,) body index of each sphere
|
|
25
|
+
center: np.ndarray # (S, 3) center in the body frame
|
|
26
|
+
radius: np.ndarray # (S,)
|
|
27
|
+
pairs: np.ndarray # (P, 2) sphere indices
|
|
28
|
+
min_dist: np.ndarray # (P,) required clearance between sphere surfaces
|
|
29
|
+
# Row structure for the solver: each pair is owned by one "row" sphere, and each
|
|
30
|
+
# row constrains its closest partner. Fixed shapes, no sorting, stable meanings.
|
|
31
|
+
rows: np.ndarray = None # (R,) sphere indices that own pairs
|
|
32
|
+
partner: np.ndarray = None # (R, S) bool: partner spheres of each row
|
|
33
|
+
row_min_dist: np.ndarray = None # (R, S) clearance per (row, partner)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def num_spheres(self) -> int:
|
|
37
|
+
return len(self.radius)
|
|
38
|
+
|
|
39
|
+
def centers(self, kin: Kinematics) -> Array:
|
|
40
|
+
b = self.body
|
|
41
|
+
return kin.body_p[b] + jnp.einsum("sij,sj->si", kin.body_R[b], self.center)
|
|
42
|
+
|
|
43
|
+
def gaps(self, kin: Kinematics) -> Array:
|
|
44
|
+
"""Surface distance minus required clearance per pair (< 0: violated)"""
|
|
45
|
+
C = self.centers(kin)
|
|
46
|
+
i, j = self.pairs[:, 0], self.pairs[:, 1]
|
|
47
|
+
return (jnp.linalg.norm(C[i] - C[j], axis=1) - self.radius[i] - self.radius[j]
|
|
48
|
+
- self.min_dist)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def fit(m, model: Model, bodies: Callable[[str], bool] | None = None,
|
|
52
|
+
pair_filter: Callable[[str, str], bool] | None = None,
|
|
53
|
+
spacing: float = 0.05, cover: float = 95.0, margin: float = 0.01,
|
|
54
|
+
geom_group: int | None = 3, q_ref=None, extra=None) -> "SphereModel":
|
|
55
|
+
"""Fit spheres to a compiled mujoco.MjModel `m` (the same model as `model`).
|
|
56
|
+
|
|
57
|
+
bodies: which bodies to include (by name); default: all with geometry.
|
|
58
|
+
pair_filter: which body pairs to check (by names); default: all eligible.
|
|
59
|
+
geom_group: geom group holding collision geometry (None: all geoms).
|
|
60
|
+
q_ref: reference pose for relaxing pre-existing overlaps (default qpos0).
|
|
61
|
+
extra: optional [(body name, centers (n, 3) in that body's frame, radii (n,))] of
|
|
62
|
+
spheres to attach rigidly, e.g. the envelope of parts not in the model
|
|
63
|
+
(fingers whose joints are not solved).
|
|
64
|
+
"""
|
|
65
|
+
import mujoco
|
|
66
|
+
|
|
67
|
+
names = [mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, b) or "" for b in range(m.nbody)]
|
|
68
|
+
sb, sc, sr = [], [], []
|
|
69
|
+
for b in range(1, m.nbody):
|
|
70
|
+
if bodies is not None and not bodies(names[b]):
|
|
71
|
+
continue
|
|
72
|
+
V = _body_points(m, b, geom_group)
|
|
73
|
+
if len(V) < 4:
|
|
74
|
+
continue
|
|
75
|
+
c, r = _fit_spheres(V, spacing, cover)
|
|
76
|
+
sb += [b] * len(r)
|
|
77
|
+
sc.append(c)
|
|
78
|
+
sr.append(r)
|
|
79
|
+
for name, c, r in (extra or ()):
|
|
80
|
+
b = m.body(name).id
|
|
81
|
+
if bodies is not None and not bodies(names[b]):
|
|
82
|
+
continue
|
|
83
|
+
sb += [b] * len(r)
|
|
84
|
+
sc.append(np.asarray(c, float).reshape(-1, 3))
|
|
85
|
+
sr.append(np.asarray(r, float).reshape(-1))
|
|
86
|
+
sb, sc, sr = np.array(sb, int), np.concatenate(sc), np.concatenate(sr)
|
|
87
|
+
parent = m.body_parentid
|
|
88
|
+
pairs = [(i, j) for i in range(len(sb)) for j in range(i + 1, len(sb))
|
|
89
|
+
if sb[i] != sb[j] and parent[sb[i]] != sb[j] and parent[sb[j]] != sb[i]
|
|
90
|
+
and (pair_filter is None or pair_filter(names[sb[i]], names[sb[j]]))]
|
|
91
|
+
pairs = np.array(pairs, int).reshape(-1, 2)
|
|
92
|
+
model_ = SphereModel(sb, sc, sr, pairs, np.zeros(len(pairs)))
|
|
93
|
+
kin = forward_kinematics(model, m.qpos0 if q_ref is None else q_ref)
|
|
94
|
+
rest = np.asarray(model_.gaps(kin))
|
|
95
|
+
min_dist = np.minimum(margin, rest - 0.005)
|
|
96
|
+
return SphereModel.with_rows(sb, sc, sr, pairs, min_dist)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def with_rows(body, center, radius, pairs, min_dist) -> "SphereModel":
|
|
100
|
+
"""Assign each pair to the endpoint with more partners (fewest rows overall)"""
|
|
101
|
+
S = len(radius)
|
|
102
|
+
degree = np.bincount(pairs.ravel(), minlength=S)
|
|
103
|
+
owner = np.where(degree[pairs[:, 0]] >= degree[pairs[:, 1]], pairs[:, 0], pairs[:, 1])
|
|
104
|
+
other = np.where(owner == pairs[:, 0], pairs[:, 1], pairs[:, 0])
|
|
105
|
+
rows = np.unique(owner)
|
|
106
|
+
r_of = {r: k for k, r in enumerate(rows)}
|
|
107
|
+
partner = np.zeros((len(rows), S), bool)
|
|
108
|
+
row_min = np.zeros((len(rows), S))
|
|
109
|
+
for o, t, md in zip(owner, other, min_dist):
|
|
110
|
+
partner[r_of[o], t] = True
|
|
111
|
+
row_min[r_of[o], t] = md
|
|
112
|
+
return SphereModel(body, center, radius, pairs, min_dist, rows, partner, row_min)
|
|
113
|
+
|
|
114
|
+
def num_rows(self, k: int = 1) -> int:
|
|
115
|
+
return len(self.rows) * min(k, self.partner.shape[1])
|
|
116
|
+
|
|
117
|
+
def constraint_rows(self, model: Model, kin: Kinematics, k: int = 1):
|
|
118
|
+
"""Linearized constraints for each row sphere against its k closest partners:
|
|
119
|
+
rows a (R*k, nv) and bounds b (R*k,) such that a @ dq >= b (b = -gap)."""
|
|
120
|
+
import jax
|
|
121
|
+
|
|
122
|
+
C = self.centers(kin)
|
|
123
|
+
Cr = C[self.rows]
|
|
124
|
+
D = (jnp.linalg.norm(Cr[:, None] - C[None], axis=-1)
|
|
125
|
+
- self.radius[self.rows][:, None] - self.radius[None] - self.row_min_dist)
|
|
126
|
+
D = jnp.where(self.partner, D, jnp.inf)
|
|
127
|
+
k = min(k, D.shape[1])
|
|
128
|
+
if k == 1:
|
|
129
|
+
j = jnp.argmin(D, axis=1)[:, None]
|
|
130
|
+
else:
|
|
131
|
+
j = jax.lax.top_k(-D, k)[1] # (R, k) closest partners
|
|
132
|
+
gap = jnp.take_along_axis(D, j, axis=1) # (R, k)
|
|
133
|
+
gap = jnp.where(jnp.isfinite(gap), gap, 1.0) # rows with < k partners: inactive
|
|
134
|
+
Cj = C[j] # (R, k, 3)
|
|
135
|
+
delta = Cr[:, None] - Cj
|
|
136
|
+
n = delta / jnp.maximum(jnp.linalg.norm(delta, axis=-1, keepdims=True), 1e-9)
|
|
137
|
+
# a = n^T (J(c_i) - J(c_j)), with J(c) = (u + w x c) masked by the body's DOFs.
|
|
138
|
+
# Per DOF: n.(u + w x c) = u.n + w.(c x n): two small mat-vecs, no (S, 3, nv)
|
|
139
|
+
# Jacobian tensor.
|
|
140
|
+
w, u = kin.S[:, :3], kin.S[:, 3:]
|
|
141
|
+
mask = jnp.asarray(derived(model.topo).body_mask) # (nbody, nv)
|
|
142
|
+
un = jnp.einsum("vk,rqk->rqv", u, n)
|
|
143
|
+
ti = jnp.einsum("vk,rqk->rqv", w, jnp.cross(Cr[:, None], n))
|
|
144
|
+
tj = jnp.einsum("vk,rqk->rqv", w, jnp.cross(Cj, n))
|
|
145
|
+
mi = mask[self.body[self.rows]][:, None] # (R, 1, nv)
|
|
146
|
+
mj = mask[jnp.asarray(self.body)[j]] # (R, k, nv)
|
|
147
|
+
a = mi * (un + ti) - mj * (un + tj)
|
|
148
|
+
return a.reshape(-1, a.shape[-1]), -gap.reshape(-1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _quat_mat(q):
|
|
152
|
+
import mujoco
|
|
153
|
+
|
|
154
|
+
R = np.zeros(9)
|
|
155
|
+
mujoco.mju_quat2Mat(R, q)
|
|
156
|
+
return R.reshape(3, 3)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _primitive_points(gtype, size, n=400):
|
|
160
|
+
import mujoco
|
|
161
|
+
|
|
162
|
+
rng = np.random.default_rng(0)
|
|
163
|
+
G = mujoco.mjtGeom
|
|
164
|
+
u = rng.normal(size=(n, 3))
|
|
165
|
+
u /= np.linalg.norm(u, axis=1, keepdims=True)
|
|
166
|
+
if gtype == G.mjGEOM_SPHERE:
|
|
167
|
+
return u * size[0]
|
|
168
|
+
if gtype == G.mjGEOM_ELLIPSOID:
|
|
169
|
+
return u * size[:3]
|
|
170
|
+
if gtype == G.mjGEOM_BOX:
|
|
171
|
+
return rng.uniform(-1, 1, (n, 3)) * size[:3]
|
|
172
|
+
if gtype in (G.mjGEOM_CAPSULE, G.mjGEOM_CYLINDER):
|
|
173
|
+
z = rng.uniform(-size[1], size[1], n)
|
|
174
|
+
ring = u[:, :2] / np.linalg.norm(u[:, :2], axis=1, keepdims=True) * size[0]
|
|
175
|
+
pts = np.c_[ring, z]
|
|
176
|
+
if gtype == G.mjGEOM_CAPSULE:
|
|
177
|
+
pts = np.r_[pts, u * size[0] + [0, 0, size[1]], u * size[0] - [0, 0, size[1]]]
|
|
178
|
+
return pts
|
|
179
|
+
return np.zeros((0, 3))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _body_points(m, b, group):
|
|
183
|
+
import mujoco
|
|
184
|
+
|
|
185
|
+
out = []
|
|
186
|
+
for g in np.where(m.geom_bodyid == b)[0]:
|
|
187
|
+
if group is not None and m.geom_group[g] != group:
|
|
188
|
+
continue
|
|
189
|
+
if m.geom_type[g] == mujoco.mjtGeom.mjGEOM_MESH:
|
|
190
|
+
mid = m.geom_dataid[g]
|
|
191
|
+
v = m.mesh_vert[m.mesh_vertadr[mid]: m.mesh_vertadr[mid] + m.mesh_vertnum[mid]]
|
|
192
|
+
else:
|
|
193
|
+
v = _primitive_points(m.geom_type[g], m.geom_size[g])
|
|
194
|
+
if len(v):
|
|
195
|
+
out.append(v @ _quat_mat(m.geom_quat[g]).T + m.geom_pos[g])
|
|
196
|
+
return np.concatenate(out) if out else np.zeros((0, 3))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fit_spheres(V, spacing, cover):
|
|
200
|
+
"""Slice along the principal axis; split wide (anisotropic) slices along their
|
|
201
|
+
second axis so flat bodies like a torso get several tight spheres, not one fat one"""
|
|
202
|
+
mu = V.mean(0)
|
|
203
|
+
_, _, Vt = np.linalg.svd(V - mu, full_matrices=False)
|
|
204
|
+
axis = Vt[0]
|
|
205
|
+
t = (V - mu) @ axis
|
|
206
|
+
k = max(1, int(np.ceil((t.max() - t.min()) / spacing)))
|
|
207
|
+
edges = np.linspace(t.min(), t.max(), k + 1)
|
|
208
|
+
centers, radii = [], []
|
|
209
|
+
for a, b in zip(edges[:-1], edges[1:]):
|
|
210
|
+
sel = (t >= a) & (t <= b)
|
|
211
|
+
if sel.sum() < 4:
|
|
212
|
+
continue
|
|
213
|
+
P = V[sel]
|
|
214
|
+
c0 = P.mean(0)
|
|
215
|
+
flat = P - c0
|
|
216
|
+
flat -= np.outer(flat @ axis, axis) # cross-section points
|
|
217
|
+
_, sv, Wt = np.linalg.svd(flat, full_matrices=False)
|
|
218
|
+
w_axis = Wt[0]
|
|
219
|
+
s_w = flat @ w_axis
|
|
220
|
+
ext_w = s_w.max() - s_w.min()
|
|
221
|
+
ext_d = np.ptp(flat @ Wt[1]) if len(Wt) > 1 else ext_w
|
|
222
|
+
# number of sub-spheres across the wide direction
|
|
223
|
+
n_sub = int(np.clip(np.round(ext_w / max(ext_d, 1e-6)), 1, 4))
|
|
224
|
+
sub_edges = np.linspace(s_w.min(), s_w.max(), n_sub + 1)
|
|
225
|
+
for sa, sb in zip(sub_edges[:-1], sub_edges[1:]):
|
|
226
|
+
ss = (s_w >= sa) & (s_w <= sb)
|
|
227
|
+
if ss.sum() < 4:
|
|
228
|
+
continue
|
|
229
|
+
c = P[ss].mean(0)
|
|
230
|
+
radial = P[ss] - c
|
|
231
|
+
radial -= np.outer(radial @ axis, axis)
|
|
232
|
+
r = max(np.percentile(np.linalg.norm(radial, axis=1), cover), 0.5 * (b - a))
|
|
233
|
+
centers.append(c)
|
|
234
|
+
radii.append(r)
|
|
235
|
+
return np.array(centers), np.array(radii)
|