nb-touch 0.1.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.
- nb_touch/__init__.py +5 -0
- nb_touch/calibration/__init__.py +9 -0
- nb_touch/calibration/orientation.py +173 -0
- nb_touch/calibration/samples.py +138 -0
- nb_touch/calibration/synthetic.py +158 -0
- nb_touch/contact/__init__.py +27 -0
- nb_touch/contact/active.py +25 -0
- nb_touch/contact/contacts.py +208 -0
- nb_touch/contact/cop_model.py +205 -0
- nb_touch/contact/detector.py +68 -0
- nb_touch/contact/filters.py +113 -0
- nb_touch/contact/zones.py +55 -0
- nb_touch/geometry/__init__.py +24 -0
- nb_touch/geometry/frames.py +73 -0
- nb_touch/geometry/neighbours.py +48 -0
- nb_touch/geometry/normals.py +127 -0
- nb_touch/geometry/so3.py +75 -0
- nb_touch/geometry/synthetic.py +130 -0
- nb_touch/geometry/transforms.py +73 -0
- nb_touch/io/__init__.py +17 -0
- nb_touch/io/recordings.py +149 -0
- nb_touch/io/sensor_geometry.py +209 -0
- nb_touch/py.typed +0 -0
- nb_touch/utils/__init__.py +1 -0
- nb_touch/utils/units.py +19 -0
- nb_touch/utils/yaml_io.py +46 -0
- nb_touch/viz/__init__.py +2 -0
- nb_touch/viz/arrows.py +139 -0
- nb_touch/viz/markers.py +41 -0
- nb_touch/viz/sensor.py +152 -0
- nb_touch/viz/sliders.py +56 -0
- nb_touch-0.1.0.dist-info/METADATA +208 -0
- nb_touch-0.1.0.dist-info/RECORD +35 -0
- nb_touch-0.1.0.dist-info/WHEEL +4 -0
- nb_touch-0.1.0.dist-info/licenses/LICENSE +21 -0
nb_touch/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Taxel orientation calibration from joint torques."""
|
|
2
|
+
|
|
3
|
+
from nb_touch.calibration.orientation import (
|
|
4
|
+
CalibrationResult,
|
|
5
|
+
CalibrationSettings,
|
|
6
|
+
calibrate_orientations,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ["CalibrationResult", "CalibrationSettings", "calibrate_orientations"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Taxel orientation calibration of Pan et al. 2026, Section 3.3: the taxel orientations
|
|
2
|
+
`R_i` that best explain the recorded joint torques. See
|
|
3
|
+
`docs/theory/taxel-orientation.md`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from nb_touch.calibration.samples import Jacobian, calibration_samples
|
|
12
|
+
from nb_touch.contact.contacts import ContactSettings
|
|
13
|
+
from nb_touch.geometry.frames import (
|
|
14
|
+
normals_from_orientations,
|
|
15
|
+
with_estimated_orientations,
|
|
16
|
+
)
|
|
17
|
+
from nb_touch.geometry.so3 import project_to_so3, project_to_so3_torch
|
|
18
|
+
from nb_touch.io.recordings import Recording
|
|
19
|
+
from nb_touch.io.sensor_geometry import SensorGeometry
|
|
20
|
+
|
|
21
|
+
LEARNING_RATE = 0.1
|
|
22
|
+
"""Adam learning rate, from the paper (Appendix A)."""
|
|
23
|
+
|
|
24
|
+
STEPS = 100
|
|
25
|
+
"""Adam steps, from the paper (Appendix A)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CalibrationSettings:
|
|
30
|
+
"""Settings of the taxel orientation calibration.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
contact: Active taxel threshold, fewest active taxels per sample, and CoP
|
|
34
|
+
model σ and λ.
|
|
35
|
+
learning_rate: Adam learning rate.
|
|
36
|
+
steps: Adam steps.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
contact: ContactSettings = field(default_factory=ContactSettings)
|
|
40
|
+
learning_rate: float = LEARNING_RATE
|
|
41
|
+
steps: int = STEPS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CalibrationResult:
|
|
46
|
+
"""Outcome of the calibration.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
geometry: The sensor geometry with the calibrated orientations.
|
|
50
|
+
losses: `(steps + 1,)` torque MSE, in (N·m)^2, before each step and at the
|
|
51
|
+
end.
|
|
52
|
+
num_samples: Number of samples with enough active taxels, used in the fit.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
geometry: SensorGeometry
|
|
56
|
+
losses: np.ndarray
|
|
57
|
+
num_samples: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def predict_torques(orientations: torch.Tensor, samples: dict) -> torch.Tensor:
|
|
61
|
+
"""Return the predicted joint torques `τ = -J^T f_cop` of every sample, `(S, J)`.
|
|
62
|
+
|
|
63
|
+
The paper's static equilibrium, gravity neglected: `f_cop` is the force acting
|
|
64
|
+
on the sensor, and `τ` the torques the joints apply to hold the finger still.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
orientations: Taxel orientations `(T, 3, 3)`, taxel frame to sensor frame.
|
|
68
|
+
samples: `CalibrationSamples` fields as tensors, from `samples_to_tensors`.
|
|
69
|
+
"""
|
|
70
|
+
rotated = torch.einsum(
|
|
71
|
+
"eij,ej->ei", orientations[samples["taxel_index"]], samples["forces"]
|
|
72
|
+
)
|
|
73
|
+
parts = torch.einsum("eij,ej->ei", samples["operators"], rotated)
|
|
74
|
+
num_samples = len(samples["torques"])
|
|
75
|
+
cop_forces = parts.new_zeros((num_samples, 3))
|
|
76
|
+
cop_forces = cop_forces.index_add(0, samples["sample_index"], parts)
|
|
77
|
+
return -torch.einsum("sij,si->sj", samples["jacobians"], cop_forces)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def torque_loss(orientations: torch.Tensor, samples: dict) -> torch.Tensor:
|
|
81
|
+
"""Return the mean squared error between predicted and recorded joint torques."""
|
|
82
|
+
return torch.mean(
|
|
83
|
+
(predict_torques(orientations, samples) - samples["torques"]) ** 2
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def samples_to_tensors(samples) -> dict:
|
|
88
|
+
"""Return the fields of `CalibrationSamples` as float64 or index tensors."""
|
|
89
|
+
return {
|
|
90
|
+
name: (
|
|
91
|
+
torch.as_tensor(value)
|
|
92
|
+
if name.endswith("_index")
|
|
93
|
+
else torch.as_tensor(value, dtype=torch.float64)
|
|
94
|
+
)
|
|
95
|
+
for name, value in vars(samples).items()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fit_orientations(
|
|
100
|
+
prior, samples: dict, settings: CalibrationSettings
|
|
101
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
102
|
+
"""Fit free 3×3 matrices, projected to rotations, with Adam.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
prior: Initial orientations `(T, 3, 3)`.
|
|
106
|
+
samples: From `samples_to_tensors`.
|
|
107
|
+
settings: `learning_rate` and `steps` are used.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
`(orientations, losses)`: orientations `(T, 3, 3)` and the loss before each step
|
|
111
|
+
and at the end, `(steps + 1,)`.
|
|
112
|
+
"""
|
|
113
|
+
matrices = torch.tensor(prior, dtype=torch.float64, requires_grad=True)
|
|
114
|
+
optimiser = torch.optim.Adam([matrices], lr=settings.learning_rate)
|
|
115
|
+
losses = []
|
|
116
|
+
for _ in range(settings.steps):
|
|
117
|
+
optimiser.zero_grad()
|
|
118
|
+
loss = torque_loss(project_to_so3_torch(matrices), samples)
|
|
119
|
+
loss.backward()
|
|
120
|
+
optimiser.step()
|
|
121
|
+
losses.append(loss.item())
|
|
122
|
+
orientations = project_to_so3(matrices.detach().numpy())
|
|
123
|
+
with torch.no_grad():
|
|
124
|
+
losses.append(torque_loss(torch.as_tensor(orientations), samples).item())
|
|
125
|
+
return orientations, np.array(losses)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def calibrate_orientations(
|
|
129
|
+
recording: Recording,
|
|
130
|
+
geometry: SensorGeometry,
|
|
131
|
+
jacobian: Jacobian,
|
|
132
|
+
link: str | None = None,
|
|
133
|
+
settings: CalibrationSettings = CalibrationSettings(),
|
|
134
|
+
) -> CalibrationResult:
|
|
135
|
+
"""Estimate the taxel orientations from a recording of random fingertip contacts.
|
|
136
|
+
|
|
137
|
+
The prior orientations (from the geometry, or estimated from the positions if
|
|
138
|
+
missing) are the starting point and give the normals of the CoP model. Taxels
|
|
139
|
+
never active keep their prior orientation.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
recording: Taxel forces in their taxel frames, joint angles and torques.
|
|
143
|
+
geometry: Sensor geometry, with or without orientations.
|
|
144
|
+
jacobian: `jacobian(q, link, point) -> (3, J)`, see `Jacobian`.
|
|
145
|
+
link: Link the sensor is mounted on, passed to `jacobian`.
|
|
146
|
+
settings: Calibration settings.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If the recording and geometry disagree on the taxel count, or
|
|
150
|
+
no sample has enough active taxels.
|
|
151
|
+
"""
|
|
152
|
+
if recording.num_taxels != geometry.num_taxels:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"recording has {recording.num_taxels} taxels, "
|
|
155
|
+
f"geometry {geometry.num_taxels}"
|
|
156
|
+
)
|
|
157
|
+
prior = with_estimated_orientations(geometry)
|
|
158
|
+
samples = calibration_samples(
|
|
159
|
+
recording,
|
|
160
|
+
prior.positions,
|
|
161
|
+
normals_from_orientations(prior.orientations),
|
|
162
|
+
jacobian,
|
|
163
|
+
link,
|
|
164
|
+
settings.contact,
|
|
165
|
+
)
|
|
166
|
+
orientations, losses = fit_orientations(
|
|
167
|
+
prior.orientations, samples_to_tensors(samples), settings
|
|
168
|
+
)
|
|
169
|
+
return CalibrationResult(
|
|
170
|
+
geometry=SensorGeometry(geometry.name, geometry.positions, orientations),
|
|
171
|
+
losses=losses,
|
|
172
|
+
num_samples=samples.num_samples,
|
|
173
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Everything of a recording the taxel orientation calibration needs, computed once:
|
|
2
|
+
the parts of the CoP model that do not depend on the taxel orientations.
|
|
3
|
+
|
|
4
|
+
The CoP position uses the force norms only and the normals come from the prior
|
|
5
|
+
orientations, so the CoP, the Jacobian at it and the ridge solve are fixed: only the
|
|
6
|
+
taxel forces in the sensor frame, `R_i f_i`, change with the orientations. See
|
|
7
|
+
`docs/theory/taxel-orientation.md`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from nb_touch.contact.active import active_taxels
|
|
16
|
+
from nb_touch.contact.contacts import ContactSettings
|
|
17
|
+
from nb_touch.contact.cop_model import (
|
|
18
|
+
cop_force_operator,
|
|
19
|
+
cop_normal,
|
|
20
|
+
cop_position,
|
|
21
|
+
taxel_force_matrices,
|
|
22
|
+
)
|
|
23
|
+
from nb_touch.io.recordings import Recording
|
|
24
|
+
|
|
25
|
+
Jacobian = Callable[[np.ndarray, str | None, np.ndarray], np.ndarray]
|
|
26
|
+
"""`jacobian(q, link, point) -> (3, J)`: position Jacobian of `point` (sensor frame,
|
|
27
|
+
in m) on `link` at joint angles `q` `(J,)`, expressed in the sensor frame."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CalibrationSamples:
|
|
32
|
+
"""The samples used by the calibration, E active taxels over S samples.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
sample_index: `(E,)` sample of each active taxel, in `0..S-1`.
|
|
36
|
+
taxel_index: `(E,)` taxel of each active taxel.
|
|
37
|
+
operators: `(E, 3, 3)` ridge solve `K_i`, so the CoP force of a sample is
|
|
38
|
+
the sum of `K_i R_i f_i` over its active taxels.
|
|
39
|
+
forces: `(E, 3)` taxel forces `f_i`, in their taxel frames, in N.
|
|
40
|
+
jacobians: `(S, 3, J)` Jacobians at the CoPs, in the sensor frame.
|
|
41
|
+
torques: `(S, J)` recorded joint torques, in N·m.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
sample_index: np.ndarray
|
|
45
|
+
taxel_index: np.ndarray
|
|
46
|
+
operators: np.ndarray
|
|
47
|
+
forces: np.ndarray
|
|
48
|
+
jacobians: np.ndarray
|
|
49
|
+
torques: np.ndarray
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def num_samples(self) -> int:
|
|
53
|
+
"""Number of samples S used."""
|
|
54
|
+
return len(self.torques)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def checked_jacobian(jacobian, num_joints: int) -> np.ndarray:
|
|
58
|
+
"""Return `jacobian` as an array, raising a `ValueError` unless it is `(3, J)`."""
|
|
59
|
+
jacobian = np.asarray(jacobian, dtype=float)
|
|
60
|
+
if jacobian.shape != (3, num_joints):
|
|
61
|
+
raise ValueError(f"expected a (3, {num_joints}) Jacobian, got {jacobian.shape}")
|
|
62
|
+
return jacobian
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sample_cop(positions, normals, forces, settings: ContactSettings):
|
|
66
|
+
"""Return the active taxels, CoP and ridge solve of one sample.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
positions: Taxel positions `(T, 3)`, in m.
|
|
70
|
+
normals: Unit outward prior normals `(T, 3)`.
|
|
71
|
+
forces: Taxel forces `(T, 3)`, in their taxel frames, in N.
|
|
72
|
+
settings: `threshold`, `min_taxels`, `sigma` and `regularisation` are used.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
`(taxels, cop, operators)`, or `None` if fewer than `min_taxels` taxels are
|
|
76
|
+
active or the normal at the CoP is undefined.
|
|
77
|
+
"""
|
|
78
|
+
settings = settings.from_geometry(positions)
|
|
79
|
+
taxels = np.flatnonzero(active_taxels(forces, settings.threshold))
|
|
80
|
+
if len(taxels) < max(settings.min_taxels, 1):
|
|
81
|
+
return None
|
|
82
|
+
points, zone_normals = positions[taxels], normals[taxels]
|
|
83
|
+
cop = cop_position(points, forces[taxels])
|
|
84
|
+
normal = cop_normal(points, zone_normals, cop)
|
|
85
|
+
if np.isnan(normal).any():
|
|
86
|
+
return None
|
|
87
|
+
matrices = taxel_force_matrices(points, zone_normals, cop, normal, settings.sigma)
|
|
88
|
+
return taxels, cop, cop_force_operator(matrices, settings.regularisation)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def calibration_samples(
|
|
92
|
+
recording: Recording,
|
|
93
|
+
positions,
|
|
94
|
+
normals,
|
|
95
|
+
jacobian: Jacobian,
|
|
96
|
+
link: str | None = None,
|
|
97
|
+
settings: ContactSettings = ContactSettings(),
|
|
98
|
+
) -> CalibrationSamples:
|
|
99
|
+
"""Prepare the samples of a recording for the calibration.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
recording: Taxel forces, joint angles and joint torques.
|
|
103
|
+
positions: Taxel positions `(T, 3)`, in m.
|
|
104
|
+
normals: Unit outward prior normals `(T, 3)`.
|
|
105
|
+
jacobian: See `Jacobian`.
|
|
106
|
+
link: Link the sensor is mounted on, passed to `jacobian`.
|
|
107
|
+
settings: Active taxels and CoP model settings.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ValueError: If no sample has enough active taxels.
|
|
111
|
+
"""
|
|
112
|
+
positions = np.asarray(positions, dtype=float)
|
|
113
|
+
normals = np.asarray(normals, dtype=float)
|
|
114
|
+
settings = settings.from_geometry(positions)
|
|
115
|
+
entries, jacobians, torques = [], [], []
|
|
116
|
+
for forces, q, tau in zip(
|
|
117
|
+
recording.taxel_forces_n,
|
|
118
|
+
recording.joint_pos_rad,
|
|
119
|
+
recording.joint_torque_nm,
|
|
120
|
+
strict=True,
|
|
121
|
+
):
|
|
122
|
+
found = sample_cop(positions, normals, forces, settings)
|
|
123
|
+
if found is None:
|
|
124
|
+
continue
|
|
125
|
+
taxels, cop, operators = found
|
|
126
|
+
entries.append((len(torques), taxels, operators, forces[taxels]))
|
|
127
|
+
jacobians.append(checked_jacobian(jacobian(q, link, cop), len(q)))
|
|
128
|
+
torques.append(tau)
|
|
129
|
+
if not torques:
|
|
130
|
+
raise ValueError("no sample has enough active taxels")
|
|
131
|
+
return CalibrationSamples(
|
|
132
|
+
sample_index=np.concatenate([np.full(len(e[1]), e[0]) for e in entries]),
|
|
133
|
+
taxel_index=np.concatenate([e[1] for e in entries]),
|
|
134
|
+
operators=np.concatenate([e[2] for e in entries]),
|
|
135
|
+
forces=np.concatenate([e[3] for e in entries]),
|
|
136
|
+
jacobians=np.array(jacobians),
|
|
137
|
+
torques=np.array(torques),
|
|
138
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Synthetic finger and recordings with known taxel orientations, for tests and demos.
|
|
2
|
+
|
|
3
|
+
The finger is held still, as in the paper's data collection (a stiff PD controller
|
|
4
|
+
holds the joints while an object is pressed on the fingertip), so its Jacobian does
|
|
5
|
+
not depend on the joint angles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from nb_touch.calibration.samples import sample_cop
|
|
11
|
+
from nb_touch.contact.contacts import ContactSettings
|
|
12
|
+
from nb_touch.contact.cop_model import (
|
|
13
|
+
cop_normal,
|
|
14
|
+
cop_to_taxel_forces,
|
|
15
|
+
taxel_force_matrices,
|
|
16
|
+
)
|
|
17
|
+
from nb_touch.geometry.frames import normals_from_orientations
|
|
18
|
+
from nb_touch.geometry.transforms import rotate_vectors, rotations_from_vectors
|
|
19
|
+
from nb_touch.io.recordings import Recording
|
|
20
|
+
from nb_touch.io.sensor_geometry import SensorGeometry
|
|
21
|
+
|
|
22
|
+
FINGER_AXES = np.array(
|
|
23
|
+
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]
|
|
24
|
+
)
|
|
25
|
+
"""Unit joint axes of the synthetic 4-joint finger, in the sensor frame: abduction,
|
|
26
|
+
then three flexion joints, like a human finger."""
|
|
27
|
+
|
|
28
|
+
FINGER_ORIGINS = np.array(
|
|
29
|
+
[
|
|
30
|
+
[0.0, -0.1, -0.005],
|
|
31
|
+
[0.0, -0.1, -0.005],
|
|
32
|
+
[0.0, -0.055, -0.005],
|
|
33
|
+
[0.0, -0.02, -0.005],
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
"""Points on the joint axes, in the sensor frame, in m: the finger runs along -y
|
|
37
|
+
from the sensor."""
|
|
38
|
+
|
|
39
|
+
RATE_HZ = 20.0
|
|
40
|
+
"""Sampling rate of the synthetic recordings, as in the paper (Appendix A)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def revolute_jacobian(axes, origins, point) -> np.ndarray:
|
|
44
|
+
"""Return the position Jacobian `(3, J)` of `point` for revolute joints: column
|
|
45
|
+
`j` is `a_j x (p - o_j)`.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
axes: Unit joint axes `(J, 3)`.
|
|
49
|
+
origins: A point on each axis `(J, 3)`, in m.
|
|
50
|
+
point: The point `(3,)`, in m, in the same frame.
|
|
51
|
+
"""
|
|
52
|
+
offsets = np.asarray(point, dtype=float) - np.asarray(origins, dtype=float)
|
|
53
|
+
return np.cross(np.asarray(axes, dtype=float), offsets).T
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def finger_jacobian(q, link, point) -> np.ndarray:
|
|
57
|
+
"""Jacobian `(3, 4)` of the synthetic finger at `point`, in the sensor frame.
|
|
58
|
+
|
|
59
|
+
`q` and `link` are ignored: the finger is held still.
|
|
60
|
+
"""
|
|
61
|
+
return revolute_jacobian(FINGER_AXES, FINGER_ORIGINS, point)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def perturbed_orientations(orientations, max_angle: float, rng) -> np.ndarray:
|
|
65
|
+
"""Return each orientation `(n, 3, 3)` turned about a random axis of its own frame,
|
|
66
|
+
by a random angle up to `max_angle`, in rad.
|
|
67
|
+
"""
|
|
68
|
+
count = len(orientations)
|
|
69
|
+
axes = rng.normal(size=(count, 3))
|
|
70
|
+
axes /= np.linalg.norm(axes, axis=1, keepdims=True)
|
|
71
|
+
angles = rng.uniform(0.0, max_angle, size=(count, 1))
|
|
72
|
+
return np.asarray(orientations) @ rotations_from_vectors(axes * angles)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def random_cop_force(normal, normal_range, max_slip: float, rng) -> np.ndarray:
|
|
76
|
+
"""Return a push against the outward `normal` of norm in `normal_range`, in N,
|
|
77
|
+
plus a shear across it in a random direction, up to `max_slip` times the push."""
|
|
78
|
+
push = rng.uniform(*normal_range)
|
|
79
|
+
shear = rng.normal(size=3)
|
|
80
|
+
shear -= (shear @ normal) * normal
|
|
81
|
+
shear *= rng.uniform(0.0, max_slip) * push / np.linalg.norm(shear)
|
|
82
|
+
return -push * normal + shear
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def model_torques(positions, normals, forces, settings: ContactSettings):
|
|
86
|
+
"""Return the joint torques `-J^T f_cop` `(4,)` of the synthetic finger, with the
|
|
87
|
+
CoP and CoP force estimated from the taxel forces, as the calibration does.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
positions: Taxel positions `(T, 3)`, in m.
|
|
91
|
+
normals: Unit outward taxel normals `(T, 3)`.
|
|
92
|
+
forces: Taxel forces `(T, 3)`, in the sensor frame, in N.
|
|
93
|
+
settings: Active taxels and CoP model settings.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The torques, zero if the sample has too few active taxels.
|
|
97
|
+
"""
|
|
98
|
+
found = sample_cop(positions, normals, forces, settings)
|
|
99
|
+
if found is None:
|
|
100
|
+
return np.zeros(len(FINGER_AXES))
|
|
101
|
+
taxels, cop, operators = found
|
|
102
|
+
cop_force = np.einsum("nij,nj->i", operators, forces[taxels])
|
|
103
|
+
return -finger_jacobian(None, None, cop).T @ cop_force
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def synthetic_recording(
|
|
107
|
+
geometry: SensorGeometry,
|
|
108
|
+
orientations,
|
|
109
|
+
num_samples: int,
|
|
110
|
+
rng,
|
|
111
|
+
normal_range=(0.5, 3.0),
|
|
112
|
+
max_slip: float = 0.5,
|
|
113
|
+
force_noise: float = 0.0,
|
|
114
|
+
torque_noise: float = 0.0,
|
|
115
|
+
settings: ContactSettings = ContactSettings(),
|
|
116
|
+
) -> Recording:
|
|
117
|
+
"""Simulate random contacts on the fingertip of the synthetic finger.
|
|
118
|
+
|
|
119
|
+
Each sample presses at a random taxel: the CoP model turns the CoP force into
|
|
120
|
+
taxel forces with the geometry's normals, and `orientations` take them to the
|
|
121
|
+
taxel frames. The joint torques, `-J^T f_cop`, come from the CoP and CoP force
|
|
122
|
+
estimated back from the taxel forces (`model_torques`), so with the true
|
|
123
|
+
orientations the calibration's model explains them exactly, without the bias of
|
|
124
|
+
the estimate (`docs/theory/cop.md`, Known bias).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
geometry: Sensor geometry with orientations, giving the normals.
|
|
128
|
+
orientations: True taxel orientations `(T, 3, 3)`, taxel frame to sensor frame.
|
|
129
|
+
num_samples: Number of samples N.
|
|
130
|
+
rng: A `numpy.random.Generator`.
|
|
131
|
+
normal_range: Smallest and largest push, in N.
|
|
132
|
+
max_slip: Largest shear over push.
|
|
133
|
+
force_noise: Standard deviation of the noise on each force component, N.
|
|
134
|
+
torque_noise: Standard deviation of the noise on each torque, in N·m.
|
|
135
|
+
settings: Active taxels and CoP model settings, the same as the
|
|
136
|
+
calibration's.
|
|
137
|
+
"""
|
|
138
|
+
positions = geometry.positions
|
|
139
|
+
normals = normals_from_orientations(geometry.orientations)
|
|
140
|
+
settings = settings.from_geometry(positions)
|
|
141
|
+
forces, torques = [], []
|
|
142
|
+
for taxel in rng.integers(len(positions), size=num_samples):
|
|
143
|
+
cop = positions[taxel]
|
|
144
|
+
normal = cop_normal(positions, normals, cop)
|
|
145
|
+
cop_force = random_cop_force(normal, normal_range, max_slip, rng)
|
|
146
|
+
matrices = taxel_force_matrices(positions, normals, cop, normal, settings.sigma)
|
|
147
|
+
sensor_forces = cop_to_taxel_forces(cop_force, matrices)
|
|
148
|
+
forces.append(
|
|
149
|
+
rotate_vectors(np.transpose(orientations, (0, 2, 1)), sensor_forces)
|
|
150
|
+
)
|
|
151
|
+
torques.append(model_torques(positions, normals, sensor_forces, settings))
|
|
152
|
+
forces, torques = np.array(forces), np.array(torques)
|
|
153
|
+
return Recording(
|
|
154
|
+
timestamps_s=np.arange(num_samples) / RATE_HZ,
|
|
155
|
+
joint_pos_rad=np.zeros_like(torques),
|
|
156
|
+
joint_torque_nm=torques + rng.normal(0.0, torque_noise, torques.shape),
|
|
157
|
+
taxel_forces_n=forces + rng.normal(0.0, force_noise, forces.shape),
|
|
158
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Taxel forces to contacts, each with its CoP, CoP force and stats."""
|
|
2
|
+
|
|
3
|
+
from nb_touch.contact.contacts import Contact, ContactSettings, find_contacts
|
|
4
|
+
from nb_touch.contact.cop_model import (
|
|
5
|
+
cop_normal,
|
|
6
|
+
cop_position,
|
|
7
|
+
cop_to_taxel_forces,
|
|
8
|
+
spacing_sigma,
|
|
9
|
+
taxel_force_matrices,
|
|
10
|
+
taxel_to_cop_force,
|
|
11
|
+
)
|
|
12
|
+
from nb_touch.contact.detector import ContactDetector
|
|
13
|
+
from nb_touch.contact.filters import make_filter
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Contact",
|
|
17
|
+
"ContactDetector",
|
|
18
|
+
"ContactSettings",
|
|
19
|
+
"cop_normal",
|
|
20
|
+
"cop_position",
|
|
21
|
+
"cop_to_taxel_forces",
|
|
22
|
+
"find_contacts",
|
|
23
|
+
"make_filter",
|
|
24
|
+
"spacing_sigma",
|
|
25
|
+
"taxel_force_matrices",
|
|
26
|
+
"taxel_to_cop_force",
|
|
27
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Which taxels are in contact in one frame.
|
|
2
|
+
|
|
3
|
+
Taken from `dextHand`, `src/dexthand/touch/contact.py`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
THRESHOLD = 0.2
|
|
9
|
+
"""Taxel force norm above which a taxel is in contact, in N. Value used on the ORCA
|
|
10
|
+
hand in `dextHand`."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def taxel_norms(forces) -> np.ndarray:
|
|
14
|
+
"""Return the force norm `(n,)` of every taxel, from forces `(n, 3)`."""
|
|
15
|
+
return np.linalg.norm(np.asarray(forces, dtype=float).reshape(-1, 3), axis=1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def active_taxels(forces, threshold: float = THRESHOLD) -> np.ndarray:
|
|
19
|
+
"""Return a mask `(n,)` of the taxels whose force norm is above `threshold`.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
forces: Taxel forces `(n, 3)`, in N.
|
|
23
|
+
threshold: Force norm, in N.
|
|
24
|
+
"""
|
|
25
|
+
return taxel_norms(forces) > threshold
|