Skip to content

Commit 17f0fe2

Browse files
authored
Merge pull request #536 from krichardsson/lh_initial_estimator_refactor
LH initial estimator refactor
2 parents 9f76d16 + 696ac5f commit 17f0fe2

6 files changed

+77
-53
lines changed

cflib/localization/lighthouse_geometry_solver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def __init__(self) -> None:
7878
# Information about errors in the solution
7979
self.error_info = {}
8080

81-
# Indicates if the solution coverged (True).
81+
# Indicates if the solution converged (True).
8282
# If it did not converge, the solution is probably not good enough to use
8383
self.success = False
8484

cflib/localization/lighthouse_initial_estimator.py

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,33 @@
2121
# along with this program. If not, see <http://www.gnu.org/licenses/>.
2222
from __future__ import annotations
2323

24+
from typing import NamedTuple
25+
2426
import numpy as np
2527
import numpy.typing as npt
2628

2729
from .ippe_cf import IppeCf
2830
from cflib.localization.lighthouse_types import LhBsCfPoses
2931
from cflib.localization.lighthouse_types import LhCfPoseSample
32+
from cflib.localization.lighthouse_types import LhException
3033
from cflib.localization.lighthouse_types import Pose
3134

3235

36+
ArrayFloat = npt.NDArray[np.float_]
37+
38+
39+
class BsPairIds(NamedTuple):
40+
"""A type representing the ids of a pair of base stations"""
41+
bs1: int
42+
bs2: int
43+
44+
45+
class BsPairPoses(NamedTuple):
46+
"""A type representing the poses of a pair of base stations"""
47+
bs1: Pose
48+
bs2: Pose
49+
50+
3351
class LighthouseInitialEstimator:
3452
"""
3553
Make initial estimates of base station and CF poses using IPPE (analytical solution).
@@ -40,8 +58,8 @@ class LighthouseInitialEstimator:
4058
OUTLIER_DETECTION_ERROR = 0.5
4159

4260
@classmethod
43-
def estimate(cls, matched_samples: list[LhCfPoseSample], sensor_positions: npt.ArrayLike) -> tuple(
44-
LhBsCfPoses, list[LhCfPoseSample]):
61+
def estimate(cls, matched_samples: list[LhCfPoseSample], sensor_positions: ArrayFloat) -> tuple[
62+
LhBsCfPoses, list[LhCfPoseSample]]:
4563
"""
4664
Make a rough estimate of the poses of all base stations and CF poses found in the samples.
4765
@@ -70,7 +88,7 @@ def estimate(cls, matched_samples: list[LhCfPoseSample], sensor_positions: npt.A
7088
break
7189

7290
if reference_bs_pose is None:
73-
raise Exception('Too little data, no reference')
91+
raise LhException('Too little data, no reference')
7492
bs_poses: dict[int, Pose] = {bs_id: reference_bs_pose}
7593

7694
# Calculate the pose of the remaining base stations, based on the pose of the first CF
@@ -82,8 +100,8 @@ def estimate(cls, matched_samples: list[LhCfPoseSample], sensor_positions: npt.A
82100
return LhBsCfPoses(bs_poses, cf_poses), cleaned_matched_samples
83101

84102
@classmethod
85-
def _find_solutions(cls, matched_samples: list[LhCfPoseSample], sensor_positions: npt.ArrayLike
86-
) -> dict[tuple(int, int), npt.NDArray]:
103+
def _find_solutions(cls, matched_samples: list[LhCfPoseSample], sensor_positions: ArrayFloat
104+
) -> dict[BsPairIds, ArrayFloat]:
87105
"""
88106
Find the pose of all base stations, in the reference frame of other base stations.
89107
@@ -101,9 +119,9 @@ def _find_solutions(cls, matched_samples: list[LhCfPoseSample], sensor_positions
101119
(2, 1) contains the position of base station 1, in the base station 2 reference frame.
102120
"""
103121

104-
position_permutations: dict[tuple(int, int), list[list[npt.ArrayLike]]] = {}
122+
position_permutations: dict[BsPairIds, list[list[ArrayFloat]]] = {}
105123
for sample in matched_samples:
106-
solutions: dict[int, tuple[Pose, Pose]] = {}
124+
solutions: dict[int, BsPairPoses] = {}
107125
for bs, angles in sample.angles_calibrated.items():
108126
projections = angles.projection_pair_list()
109127
estimates_ref_bs = IppeCf.solve(sensor_positions, projections)
@@ -115,18 +133,18 @@ def _find_solutions(cls, matched_samples: list[LhCfPoseSample], sensor_positions
115133
return cls._find_most_likely_positions(position_permutations)
116134

117135
@classmethod
118-
def _add_solution_permutations(cls, solutions: dict[int, tuple[Pose, Pose]],
119-
position_permutations: dict[tuple[int, int], list[list[npt.ArrayLike]]]):
136+
def _add_solution_permutations(cls, solutions: dict[int, BsPairPoses],
137+
position_permutations: dict[BsPairIds, list[list[ArrayFloat]]]):
120138
"""
121139
Add the possible permutations of base station positions for a sample to a collection of aggregated positions.
122140
The aggregated collection contains base station positions in the reference frame of other base stations.
123141
124142
:param solutions: All possible positions of the base stations, in the reference frame of the Crazyflie in one
125143
sample
126-
:param position_permutations: Aggregated possible solutions. A dictionary with base staion pairs as keys, mapped
127-
to lists of lists of possible positions. For instance, the entry for (2, 1) would
128-
contain a list of lists with 4 positions each, for where base station 1 might be
129-
located in the base station 2 reference frame.
144+
:param position_permutations: Aggregated possible solutions. A dictionary with base station pairs as keys,
145+
mapped to lists of lists of possible positions. For instance, the entry for (2, 1)
146+
would contain a list of lists with 4 positions each, for where base station 1
147+
might be located in the base station 2 reference frame.
130148
"""
131149
ids = sorted(solutions.keys())
132150

@@ -143,16 +161,16 @@ def _add_solution_permutations(cls, solutions: dict[int, tuple[Pose, Pose]],
143161
pose3 = solution_i[1].inv_rotate_translate_pose(solution_j[0])
144162
pose4 = solution_i[1].inv_rotate_translate_pose(solution_j[1])
145163

146-
pair = (id_i, id_j)
164+
pair = BsPairIds(id_i, id_j)
147165
if pair not in position_permutations:
148166
position_permutations[pair] = []
149167
position_permutations[pair].append([pose1.translation, pose2.translation,
150168
pose3.translation, pose4.translation])
151169

152170
@classmethod
153-
def _angles_to_poses(cls, matched_samples: list[LhCfPoseSample], sensor_positions: npt.ArrayLike,
154-
bs_positions: dict[tuple(int, int), npt.NDArray]) -> tuple(list[dict[int, Pose]],
155-
list[LhCfPoseSample]):
171+
def _angles_to_poses(cls, matched_samples: list[LhCfPoseSample], sensor_positions: ArrayFloat,
172+
bs_positions: dict[BsPairIds, ArrayFloat]) -> tuple[list[dict[int, Pose]],
173+
list[LhCfPoseSample]]:
156174
"""
157175
Estimate the base station poses in the Crazyflie reference frames, for each sample.
158176
@@ -170,7 +188,7 @@ def _angles_to_poses(cls, matched_samples: list[LhCfPoseSample], sensor_position
170188
cleaned_matched_samples: list[LhCfPoseSample] = []
171189

172190
for sample in matched_samples:
173-
solutions: dict[int, tuple[Pose, Pose]] = {}
191+
solutions: dict[int, BsPairPoses] = {}
174192
for bs, angles in sample.angles_calibrated.items():
175193
projections = angles.projection_pair_list()
176194
estimates_ref_bs = IppeCf.solve(sensor_positions, projections)
@@ -180,63 +198,63 @@ def _angles_to_poses(cls, matched_samples: list[LhCfPoseSample], sensor_position
180198
poses: dict[int, Pose] = {}
181199
ids = sorted(solutions.keys())
182200
first = ids[0]
201+
is_sample_valid = True
183202

184203
for other in ids[1:]:
185-
pair = (first, other)
186-
expected = bs_positions[pair]
204+
pair_ids = BsPairIds(first, other)
205+
expected = bs_positions[pair_ids]
187206

188-
firstPose, otherPose = cls._choose_solutions(solutions[first], solutions[other], expected)
189-
if firstPose is not None:
190-
poses[first] = firstPose
191-
poses[other] = otherPose
207+
success, pair_poses = cls._choose_solutions(solutions[first], solutions[other], expected)
208+
if success:
209+
poses[pair_ids.bs1] = pair_poses.bs1
210+
poses[pair_ids.bs2] = pair_poses.bs2
192211
else:
193-
poses = None
212+
is_sample_valid = False
194213
break
195214

196-
if poses is not None:
215+
if is_sample_valid:
197216
result.append(poses)
198217
cleaned_matched_samples.append(sample)
199218

200219
return result, cleaned_matched_samples
201220

202221
@classmethod
203-
def _choose_solutions(cls, solutions_1: tuple[Pose, Pose], solutions_2: tuple[Pose, Pose],
204-
expected: npt.ArrayLike) -> tuple[Pose, Pose]:
222+
def _choose_solutions(cls, solutions_1: BsPairPoses, solutions_2: BsPairPoses,
223+
expected: ArrayFloat) -> tuple[bool, BsPairPoses]:
205224
"""Pick the base pose solutions for a pair of base stations, based on the position in expected"""
206225

207226
min_dist = 100000.0
208-
best1 = None
209-
best2 = None
227+
best = BsPairPoses(Pose(), Pose())
228+
success = True
210229

211230
for solution_1 in solutions_1:
212231
for solution_2 in solutions_2:
213232
pose_second_bs_ref_fr_first = solution_1.inv_rotate_translate_pose(solution_2)
214233
dist = np.linalg.norm(expected - pose_second_bs_ref_fr_first.translation)
215234
if dist < min_dist:
216235
min_dist = dist
217-
best1 = solution_1
218-
best2 = solution_2
236+
best = BsPairPoses(solution_1, solution_2)
219237

220238
if min_dist > cls.OUTLIER_DETECTION_ERROR:
221-
return None, None
239+
success = False
222240

223-
return best1, best2
241+
return success, best
224242

225243
@classmethod
226-
def _find_most_likely_positions(cls, position_permutations: dict[tuple(int, int),
227-
list[list[npt.ArrayLike]]]) -> dict[tuple(int, int), npt.NDArray]:
244+
def _find_most_likely_positions(cls, position_permutations: dict[BsPairIds,
245+
list[list[ArrayFloat]]]) -> dict[BsPairIds, ArrayFloat]:
228246
"""
229247
Find the most likely base station positions from all the possible permutations.
230248
231249
Sort the permutations into buckets based on how close they are to the solutions in the first sample. Solutions
232-
that are "too" far away and distcarded. The bucket with the most samples in, is considerred the best.
250+
that are "too" far away and discarded. The bucket with the most samples in, is considered the best.
233251
"""
234-
result: dict[tuple(int, int), npt.NDArray] = {}
252+
result: dict[BsPairIds, ArrayFloat] = {}
235253

236254
for pair, position_lists in position_permutations.items():
237255
# Use first as reference to map the others to
238256
bucket_ref_positions = position_lists[0]
239-
buckets: list[list[npt.NDArray]] = [[], [], [], []]
257+
buckets: list[list[ArrayFloat]] = [[], [], [], []]
240258

241259
cls._map_positions_to_ref(bucket_ref_positions, position_lists, buckets)
242260
best_pos = cls._find_best_position_bucket(buckets)
@@ -245,8 +263,9 @@ def _find_most_likely_positions(cls, position_permutations: dict[tuple(int, int)
245263
return result
246264

247265
@classmethod
248-
def _map_positions_to_ref(cls, bucket_ref_positions: list[npt.ArrayLike], position_lists: list[list[npt.ArrayLike]],
249-
buckets: list[list[npt.ArrayLike]]) -> None:
266+
def _map_positions_to_ref(cls, bucket_ref_positions: list[ArrayFloat],
267+
position_lists: list[list[ArrayFloat]],
268+
buckets: list[list[ArrayFloat]]) -> None:
250269
"""
251270
Sort solution into buckets based on the distance to the reference position. If no bucket is close enough,
252271
the solution is discarded.
@@ -262,7 +281,7 @@ def _map_positions_to_ref(cls, bucket_ref_positions: list[npt.ArrayLike], positi
262281
break
263282

264283
@classmethod
265-
def _find_best_position_bucket(cls, buckets: list[list[npt.ArrayLike]]) -> npt.NDArray:
284+
def _find_best_position_bucket(cls, buckets: list[list[ArrayFloat]]) -> ArrayFloat:
266285
"""
267286
Find the bucket with the most solutions in, this is considered to be the correct solution.
268287
The final result is the mean of the solution in the bucket.
@@ -278,7 +297,7 @@ def _find_best_position_bucket(cls, buckets: list[list[npt.ArrayLike]]) -> npt.N
278297
return pos
279298

280299
@classmethod
281-
def _convert_estimates_to_cf_reference_frame(cls, estimates_ref_bs: list[IppeCf.Solution]) -> tuple[Pose, Pose]:
300+
def _convert_estimates_to_cf_reference_frame(cls, estimates_ref_bs: list[IppeCf.Solution]) -> BsPairPoses:
282301
"""
283302
Convert the two ippe solutions from the base station reference frame to the CF reference frame
284303
"""
@@ -288,12 +307,12 @@ def _convert_estimates_to_cf_reference_frame(cls, estimates_ref_bs: list[IppeCf.
288307
rot_2 = estimates_ref_bs[1].R.transpose()
289308
t_2 = np.dot(rot_2, -estimates_ref_bs[1].t)
290309

291-
return Pose(rot_1, t_1), Pose(rot_2, t_2)
310+
return BsPairPoses(Pose(rot_1, t_1), Pose(rot_2, t_2))
292311

293312
@classmethod
294313
def _estimate_remaining_bs_poses(cls, bs_poses_ref_cfs: list[dict[int, Pose]], bs_poses: dict[int, Pose]) -> None:
295314
"""
296-
Based on one base station pose, estimate the other base staion poses.
315+
Based on one base station pose, estimate the other base station poses.
297316
298317
The process is iterative and runs until all poses are found. Assume we know the pose of base station 0, and we
299318
have information of base station pairs (0, 2) and (2, 3), from this we can first derive the pose of 2 and after
@@ -342,7 +361,7 @@ def _estimate_remaining_bs_poses(cls, bs_poses_ref_cfs: list[dict[int, Pose]], b
342361
break
343362

344363
if len(to_find) == remaining:
345-
raise RuntimeError('Can not link positions between all base stations')
364+
raise LhException('Can not link positions between all base stations')
346365

347366
remaining = len(to_find)
348367

@@ -367,7 +386,7 @@ def q_average(Q, W=None):
367386
return Pose.from_quat(R_quat=average_quaternion, t_vec=average_pos)
368387

369388
@classmethod
370-
def _estimate_cf_poses(cls, bs_poses_ref_cfs: list[dict[int, Pose]], bs_poses: list[Pose]) -> list[Pose]:
389+
def _estimate_cf_poses(cls, bs_poses_ref_cfs: list[dict[int, Pose]], bs_poses: dict[int, Pose]) -> list[Pose]:
371390
"""
372391
Find the pose of the Crazyflie in all samples, based on the base station poses.
373392
"""

cflib/localization/lighthouse_sample_matcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class LighthouseSampleMatcher:
2929
"""Utility class to match samples of measurements from multiple lighthouse base stations.
3030
3131
Assuming that the Crazyflie was moving when the measurements were recorded,
32-
samples that were meassured aproximately at the same position are aggregated into
32+
samples that were measured approximately at the same position are aggregated into
3333
a list of LhCfPoseSample. Matching is done using the timestamp and a maximum time span.
3434
"""
3535

cflib/localization/lighthouse_types.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,14 @@ class LhDeck4SensorPositions:
175175
_sensor_distance_length = 0.03
176176

177177
# Sensor positions in the Crazyflie reference frame
178-
positions = np.float32([
178+
positions = np.array([
179179
(-_sensor_distance_length / 2, _sensor_distance_width / 2, 0.0),
180180
(-_sensor_distance_length / 2, -_sensor_distance_width / 2, 0.0),
181181
(_sensor_distance_length / 2, _sensor_distance_width / 2, 0.0),
182182
(_sensor_distance_length / 2, -_sensor_distance_width / 2, 0.0)])
183183

184184
diagonal_distance = np.sqrt(_sensor_distance_length ** 2 + _sensor_distance_length ** 2)
185+
186+
187+
class LhException(RuntimeError):
188+
"""Base exception for lighthouse exceptions"""

test/localization/lighthouse_test_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import numpy as np
2525
import numpy.typing as npt
26-
from scipy.spatial.transform.rotation import Rotation
26+
from scipy.spatial.transform import Rotation
2727

2828
from cflib.localization.lighthouse_types import Pose
2929

test/localization/test_lighthouse_initial_estimator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from cflib.localization.lighthouse_initial_estimator import LighthouseInitialEstimator
2828
from cflib.localization.lighthouse_types import LhCfPoseSample
2929
from cflib.localization.lighthouse_types import LhDeck4SensorPositions
30+
from cflib.localization.lighthouse_types import LhException
3031
from cflib.localization.lighthouse_types import Pose
3132

3233

@@ -44,7 +45,7 @@ def test_that_one_bs_pose_raises_exception(self):
4445

4546
# Test
4647
# Assert
47-
with self.assertRaises(Exception):
48+
with self.assertRaises(LhException):
4849
LighthouseInitialEstimator.estimate(samples, LhDeck4SensorPositions.positions)
4950

5051
def test_that_two_bs_poses_in_same_sample_are_found(self):
@@ -174,5 +175,5 @@ def test_that_raises_for_isolated_bs(self):
174175

175176
# Test
176177
# Assert
177-
with self.assertRaises(Exception):
178+
with self.assertRaises(LhException):
178179
LighthouseInitialEstimator.estimate(samples, LhDeck4SensorPositions.positions)

0 commit comments

Comments
 (0)