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 ADDED
@@ -0,0 +1,5 @@
1
+ """Hardware-agnostic processing and calibration of taxel array touch sensors."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("nb-touch")
@@ -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