From dedc6da36302939e4acaf569519d124c23593210 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 15 May 2025 13:49:46 +0200 Subject: [PATCH 01/73] Use input container for lh geo estimator --- .../lighthouse_geo_bs_estimation_wizard.py | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 63ef456d..edefcd21 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -41,7 +41,10 @@ from cflib.localization.lighthouse_system_aligner import LighthouseSystemAligner from cflib.localization.lighthouse_geometry_solver import LighthouseGeometrySolver from cflib.localization.lighthouse_system_scaler import LighthouseSystemScaler -from cflib.localization.lighthouse_types import Pose, LhDeck4SensorPositions, LhMeasurement, LhCfPoseSample +from cflib.localization.lighthouse_types import Pose, LhDeck4SensorPositions, LhMeasurement +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample +from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer + from PyQt6 import QtCore, QtWidgets, QtGui import time @@ -173,7 +176,6 @@ def _timeout_cb(self): self.timeout_timer.stop() def _ready_cb(self, averages): - print(self.show_add_measurements) recorded_angles = averages angles_calibrated = {} for bs_id, data in recorded_angles.items(): @@ -327,18 +329,15 @@ class EstimateGeometryThread(QtCore.QObject): finished = QtCore.pyqtSignal() failed = QtCore.pyqtSignal() - def __init__(self, origin, x_axis, xy_plane, samples): + def __init__(self, container: LhGeoInputContainer): super(EstimateGeometryThread, self).__init__() - self.origin = origin - self.x_axis = x_axis - self.xy_plane = xy_plane - self.samples = samples + self.container = container self.bs_poses = {} def run(self): try: - self.bs_poses = self._estimate_geometry(self.origin, self.x_axis, self.xy_plane, self.samples) + self.bs_poses = self._estimate_geometry(self.container) self.finished.emit() except Exception as ex: print(ex) @@ -347,27 +346,21 @@ def run(self): def get_poses(self): return self.bs_poses - def _estimate_geometry(self, origin: LhCfPoseSample, - x_axis: list[LhCfPoseSample], - xy_plane: list[LhCfPoseSample], - samples: list[LhCfPoseSample]) -> dict[int, Pose]: + def _estimate_geometry(self, container: LhGeoInputContainer) -> dict[int, Pose]: """Estimate the geometry of the system based on samples recorded by a Crazyflie""" - matched_samples = [origin] + x_axis + xy_plane + LighthouseSampleMatcher.match(samples, min_nr_of_bs_in_match=2) - initial_guess, cleaned_matched_samples = LighthouseInitialEstimator.estimate(matched_samples, - LhDeck4SensorPositions.positions) + matched_samples = container.get_matched_samples() + initial_guess, cleaned_matched_samples = LighthouseInitialEstimator.estimate(matched_samples) - solution = LighthouseGeometrySolver.solve(initial_guess, - cleaned_matched_samples, - LhDeck4SensorPositions.positions) + solution = LighthouseGeometrySolver.solve(initial_guess, cleaned_matched_samples, container.sensor_positions) if not solution.success: raise Exception("No lighthouse base station geometry solution could be found!") start_x_axis = 1 - start_xy_plane = 1 + len(x_axis) + start_xy_plane = 1 + len(container.x_axis) origin_pos = solution.cf_poses[0].translation - x_axis_poses = solution.cf_poses[start_x_axis:start_x_axis + len(x_axis)] + x_axis_poses = solution.cf_poses[start_x_axis:start_x_axis + len(container.x_axis)] x_axis_pos = list(map(lambda x: x.translation, x_axis_poses)) - xy_plane_poses = solution.cf_poses[start_xy_plane:start_xy_plane + len(xy_plane)] + xy_plane_poses = solution.cf_poses[start_xy_plane:start_xy_plane + len(container.xy_plane)] xy_plane_pos = list(map(lambda x: x.translation, xy_plane_poses)) # Align the solution @@ -407,12 +400,15 @@ def __init__(self, cf: Crazyflie, origin_page: RecordOriginSamplePage, xaxis_pag def _action_btn_clicked(self): self.start_action_button.setDisabled(True) self.status_text.setText(self.str_pad('Estimating geometry...')) - origin = self.origin_page.get_sample() - x_axis = [self.xaxis_page.get_sample()] - xy_plane = self.xyplane_page.get_samples() - samples = self.xyzspace_page.get_samples() + + container = LhGeoInputContainer(LhDeck4SensorPositions.positions) + container.set_origin_sample(self.origin_page.get_sample()) + container.set_x_axis_sample(self.xaxis_page.get_sample()) + container.set_xy_plane_samples(self.xyplane_page.get_samples()) + container.set_xyz_space_samples(self.xyzspace_page.get_samples()) + self.thread_estimator = QtCore.QThread() - self.worker = EstimateGeometryThread(origin, x_axis, xy_plane, samples) + self.worker = EstimateGeometryThread(container) self.worker.moveToThread(self.thread_estimator) self.thread_estimator.started.connect(self.worker.run) self.worker.finished.connect(self.thread_estimator.quit) From 03a0b19dd711b22d352d791e073f3ebec2c0bbfe Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 15 May 2025 14:12:40 +0200 Subject: [PATCH 02/73] Added debug support --- .../ui/wizards/lighthouse_geo_bs_estimation_wizard.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index edefcd21..f4e352f9 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -180,7 +180,7 @@ def _ready_cb(self, averages): angles_calibrated = {} for bs_id, data in recorded_angles.items(): angles_calibrated[bs_id] = data[1] - self.recorded_angle_result = LhCfPoseSample(angles_calibrated=angles_calibrated) + self.recorded_angle_result = LhCfPoseSample(angles_calibrated) self.visible_basestations = ', '.join(map(lambda x: str(x + 1), recorded_angles.keys())) amount_of_basestations = len(recorded_angles.keys()) @@ -276,7 +276,6 @@ def __init__(self, cf: Crazyflie, parent=None): self.record_time_total = DEFAULT_RECORD_TIME self.record_time_current = 0 self.reader = LighthouseSweepAngleReader(self.cf, self._ready_single_sample_cb) - self.bs_seen = set() def extra_layout_field(self): h_box = QtWidgets.QHBoxLayout() @@ -319,7 +318,6 @@ def _ready_single_sample_cb(self, bs_id: int, angles: LighthouseBsVectors): now = time.time() measurement = LhMeasurement(timestamp=now, base_station_id=bs_id, angles=angles) self.recorded_angles_result.append(measurement) - self.bs_seen.add(str(bs_id + 1)) def get_samples(self): return self.recorded_angles_result @@ -407,6 +405,12 @@ def _action_btn_clicked(self): container.set_xy_plane_samples(self.xyplane_page.get_samples()) container.set_xyz_space_samples(self.xyzspace_page.get_samples()) + # Enable to write to file. This can be used for debugging in examples/lighthouse/multi_bs_geometry_estimation.py + # found in the python lib + # import pickle + # with open('lh_geo_input_dump.pickle', 'wb') as handle: + # pickle.dump(container, handle, protocol=pickle.HIGHEST_PROTOCOL) + self.thread_estimator = QtCore.QThread() self.worker = EstimateGeometryThread(container) self.worker.moveToThread(self.thread_estimator) From bb6cea9ea10a4a9811b9ba6ea92aa508bfe92a62 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 15 May 2025 14:35:12 +0200 Subject: [PATCH 03/73] Added logging --- .../ui/wizards/lighthouse_geo_bs_estimation_wizard.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index f4e352f9..42b64b22 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -30,6 +30,8 @@ from __future__ import annotations import cfclient +import logging +import time from cflib.crazyflie import Crazyflie from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry @@ -45,11 +47,11 @@ from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer - from PyQt6 import QtCore, QtWidgets, QtGui -import time +logger = logging.getLogger(__name__) + REFERENCE_DIST = 1.0 ITERATION_MAX_NR = 2 DEFAULT_RECORD_TIME = 20 @@ -335,10 +337,12 @@ def __init__(self, container: LhGeoInputContainer): def run(self): try: + logger.debug("Start estimation") self.bs_poses = self._estimate_geometry(self.container) + logger.debug("Estimation done") self.finished.emit() except Exception as ex: - print(ex) + logger.error("Estimation exception: " + str(ex)) self.failed.emit() def get_poses(self): From ecd86e84a19e345ef2a0fe615361e2da44fe14c5 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 16 May 2025 10:44:50 +0200 Subject: [PATCH 04/73] LH geo estimation scaling in cflib updated --- .../lighthouse_geo_bs_estimation_wizard.py | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 42b64b22..16aab1eb 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -39,19 +39,16 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors from cflib.localization.lighthouse_initial_estimator import LighthouseInitialEstimator -from cflib.localization.lighthouse_sample_matcher import LighthouseSampleMatcher -from cflib.localization.lighthouse_system_aligner import LighthouseSystemAligner from cflib.localization.lighthouse_geometry_solver import LighthouseGeometrySolver -from cflib.localization.lighthouse_system_scaler import LighthouseSystemScaler from cflib.localization.lighthouse_types import Pose, LhDeck4SensorPositions, LhMeasurement from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample -from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer +from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from PyQt6 import QtCore, QtWidgets, QtGui - logger = logging.getLogger(__name__) + REFERENCE_DIST = 1.0 ITERATION_MAX_NR = 2 DEFAULT_RECORD_TIME = 20 @@ -73,6 +70,8 @@ def __init__(self, cf, ready_cb, parent=None, *args): self.button(QtWidgets.QWizard.WizardButton.FinishButton).clicked.connect(self._finish_button_clicked_callback) + logger.info("Wizard started") + def _finish_button_clicked_callback(self): self.ready_cb(self.get_geometry_page.get_geometry()) @@ -337,9 +336,9 @@ def __init__(self, container: LhGeoInputContainer): def run(self): try: - logger.debug("Start estimation") + logger.info("Start estimation") self.bs_poses = self._estimate_geometry(self.container) - logger.debug("Estimation done") + logger.info("Estimation done") self.finished.emit() except Exception as ex: logger.error("Estimation exception: " + str(ex)) @@ -350,34 +349,28 @@ def get_poses(self): def _estimate_geometry(self, container: LhGeoInputContainer) -> dict[int, Pose]: """Estimate the geometry of the system based on samples recorded by a Crazyflie""" + matched_samples = container.get_matched_samples() + logger.info("start initial guess") initial_guess, cleaned_matched_samples = LighthouseInitialEstimator.estimate(matched_samples) + logger.info("initial guess done") + + scaled_initial_guess = LhGeoEstimationManager.align_and_scale_solution(container, initial_guess, REFERENCE_DIST) + for bs_id, pose in sorted(scaled_initial_guess.bs_poses.items()): + pos = pose.translation + logger.info(f' {bs_id + 1}: ({pos[0]}, {pos[1]}, {pos[2]})') + logger.info(f"Len cleaned samples: {len(cleaned_matched_samples)}") + + logger.info("Start solver") solution = LighthouseGeometrySolver.solve(initial_guess, cleaned_matched_samples, container.sensor_positions) + logger.info("Solver done") + logger.info(f"Success: {solution.success}") if not solution.success: raise Exception("No lighthouse base station geometry solution could be found!") - start_x_axis = 1 - start_xy_plane = 1 + len(container.x_axis) - origin_pos = solution.cf_poses[0].translation - x_axis_poses = solution.cf_poses[start_x_axis:start_x_axis + len(container.x_axis)] - x_axis_pos = list(map(lambda x: x.translation, x_axis_poses)) - xy_plane_poses = solution.cf_poses[start_xy_plane:start_xy_plane + len(container.xy_plane)] - xy_plane_pos = list(map(lambda x: x.translation, xy_plane_poses)) - - # Align the solution - bs_aligned_poses, transformation = LighthouseSystemAligner.align( - origin_pos, x_axis_pos, xy_plane_pos, solution.bs_poses) - - cf_aligned_poses = list(map(transformation.rotate_translate_pose, solution.cf_poses)) - - # Scale the solution - bs_scaled_poses, cf_scaled_poses, scale = LighthouseSystemScaler.scale_fixed_point(bs_aligned_poses, - cf_aligned_poses, - [REFERENCE_DIST, 0, 0], - cf_aligned_poses[1]) - - return bs_scaled_poses + scaled_solution = LhGeoEstimationManager.align_and_scale_solution(container, solution.poses, REFERENCE_DIST) + return scaled_solution.bs_poses class EstimateBSGeometryPage(LighthouseBasestationGeometryWizardBasePage): @@ -414,6 +407,9 @@ def _action_btn_clicked(self): # import pickle # with open('lh_geo_input_dump.pickle', 'wb') as handle: # pickle.dump(container, handle, protocol=pickle.HIGHEST_PROTOCOL) + # with open('lh_geo_input_dump.pickle', 'rb') as handle: + # container = pickle.load(handle) + self.thread_estimator = QtCore.QThread() self.worker = EstimateGeometryThread(container) From 16a89f9636cefbd2e8baebe3d806f14a37bd2718 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 12 Jun 2025 15:35:25 +0200 Subject: [PATCH 05/73] Basic continuous lh geo estimation --- .../dialogs/lighthouse_bs_geometry_dialog.py | 2 +- src/cfclient/ui/tabs/lighthouse_tab.py | 6 +- .../lighthouse_geo_bs_estimation_wizard.py | 233 +++++------------- 3 files changed, 64 insertions(+), 177 deletions(-) diff --git a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py index f8658bdc..bc6c84a6 100644 --- a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py +++ b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py @@ -156,7 +156,7 @@ def __init__(self, lighthouse_tab, *args): self._lighthouse_tab._helper.cf, self._sweep_angles_received_and_averaged_signal.emit) self._base_station_geometry_wizard = LighthouseBasestationGeometryWizard( - self._lighthouse_tab._helper.cf, self._base_station_geometery_received_signal.emit) + self._lighthouse_tab, self._base_station_geometery_received_signal.emit) self._lh_geos = None self._newly_estimated_geometry = {} diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 8d296994..4407dc4a 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -46,6 +46,8 @@ from cflib.localization import LighthouseConfigWriter from cflib.localization import LighthouseConfigFileManager +from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry + from cfclient.ui.dialogs.lighthouse_bs_geometry_dialog import LighthouseBsGeometryDialog from cfclient.ui.dialogs.basestation_mode_dialog import LighthouseBsModeDialog from cfclient.ui.dialogs.lighthouse_system_type_dialog import LighthouseSystemTypeDialog @@ -358,7 +360,9 @@ def __init__(self, helper): self._is_connected = False self._update_ui() - def write_and_store_geometry(self, geometries): + def write_and_store_geometry(self, geometries: dict[int, LighthouseBsGeometry]): + # TODO krri Hanlde repeated quick writes. This is called from the geo wizard and write_and_store_config() will + # throw if there is an ongoing write if self._lh_config_writer: self._lh_config_writer.write_and_store_config(self._new_system_config_written_to_cf_signal.emit, geos=geometries) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 16aab1eb..658e2b7e 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -38,12 +38,11 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors -from cflib.localization.lighthouse_initial_estimator import LighthouseInitialEstimator -from cflib.localization.lighthouse_geometry_solver import LighthouseGeometrySolver -from cflib.localization.lighthouse_types import Pose, LhDeck4SensorPositions, LhMeasurement +from cflib.localization.lighthouse_types import LhDeck4SensorPositions, LhMeasurement, LhBsCfPoses from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager + from PyQt6 import QtCore, QtWidgets, QtGui logger = logging.getLogger(__name__) @@ -61,20 +60,19 @@ class LighthouseBasestationGeometryWizard(QtWidgets.QWizard): - def __init__(self, cf, ready_cb, parent=None, *args): + def __init__(self, lighthouse_tab, ready_cb, parent=None, *args): super(LighthouseBasestationGeometryWizard, self).__init__(parent) - self.cf = cf + self.lighthouse_tab = lighthouse_tab + self.cf = lighthouse_tab._helper.cf + self.container = LhGeoInputContainer(LhDeck4SensorPositions.positions) + self.solver_thread = LhGeoEstimationManager.SolverThread(self.container, is_done_cb=self.solution_handler) + self.solver_thread.start() self.ready_cb = ready_cb self.wizard_opened_first_time = True self.reset() - self.button(QtWidgets.QWizard.WizardButton.FinishButton).clicked.connect(self._finish_button_clicked_callback) - logger.info("Wizard started") - def _finish_button_clicked_callback(self): - self.ready_cb(self.get_geometry_page.get_geometry()) - def reset(self): self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowType.WindowCloseButtonHint) @@ -84,35 +82,44 @@ def reset(self): self.removePage(1) self.removePage(2) self.removePage(3) - self.removePage(4) - del self.get_origin_page, self.get_xaxis_page, self.get_xyplane_page - del self.get_xyzspace_page, self.get_geometry_page + del self._origin_page, self._xaxis_page, self._xyplane_page + del self._xyzspace_page else: self.wizard_opened_first_time = False - self.get_origin_page = RecordOriginSamplePage(self.cf, self) - self.get_xaxis_page = RecordXAxisSamplePage(self.cf, self) - self.get_xyplane_page = RecordXYPlaneSamplesPage(self.cf, self) - self.get_xyzspace_page = RecordXYZSpaceSamplesPage(self.cf, self) - self.get_geometry_page = EstimateBSGeometryPage( - self.cf, self.get_origin_page, self.get_xaxis_page, self.get_xyplane_page, self.get_xyzspace_page, self) + self._origin_page = RecordOriginSamplePage(self.cf, self.container, self) + self._xaxis_page = RecordXAxisSamplePage(self.cf, self.container, self) + self._xyplane_page = RecordXYPlaneSamplesPage(self.cf, self.container, self) + self._xyzspace_page = RecordXYZSpaceSamplesPage(self.cf, self.container, self) - self.addPage(self.get_origin_page) - self.addPage(self.get_xaxis_page) - self.addPage(self.get_xyplane_page) - self.addPage(self.get_xyzspace_page) - self.addPage(self.get_geometry_page) + self.addPage(self._origin_page) + self.addPage(self._xaxis_page) + self.addPage(self._xyplane_page) + self.addPage(self._xyzspace_page) self.setWindowTitle("Lighthouse Base Station Geometry Wizard") self.resize(WINDOW_STARTING_WIDTH, WINDOW_STARTING_HEIGHT) + def solution_handler(self, scaled_solution: LhBsCfPoses): + """Upload the geometry to the Crazyflie""" + geo_dict = {} + for bs_id, pose in scaled_solution.bs_poses.items(): + geo = LighthouseBsGeometry() + geo.origin = pose.translation.tolist() + geo.rotation_matrix = pose.rot_matrix.tolist() + geo.valid = True + geo_dict[bs_id] = geo + + self.lighthouse_tab.write_and_store_geometry(geo_dict) + class LighthouseBasestationGeometryWizardBasePage(QtWidgets.QWizardPage): - def __init__(self, cf: Crazyflie, show_add_measurements=False, parent=None): + def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, show_add_measurements=False, parent=None): super(LighthouseBasestationGeometryWizardBasePage, self).__init__(parent) self.show_add_measurements = show_add_measurements self.cf = cf + self.container = container self.layout = QtWidgets.QVBoxLayout() self.explanation_picture = QtWidgets.QLabel() @@ -181,7 +188,6 @@ def _ready_cb(self, averages): angles_calibrated = {} for bs_id, data in recorded_angles.items(): angles_calibrated[bs_id] = data[1] - self.recorded_angle_result = LhCfPoseSample(angles_calibrated) self.visible_basestations = ', '.join(map(lambda x: str(x + 1), recorded_angles.keys())) amount_of_basestations = len(recorded_angles.keys()) @@ -198,6 +204,7 @@ def _ready_cb(self, averages): self.start_action_button.setText("Restart Measurement") self.start_action_button.setDisabled(False) else: + self.store_sample(angles_calibrated) self.too_few_bs = False status_text_string = f'Recording Done! Visible Base stations: {self.visible_basestations}\n' if self.show_add_measurements: @@ -214,6 +221,9 @@ def _ready_cb(self, averages): self.start_action_button.setText("Restart Measurement") self.start_action_button.setDisabled(False) + def store_sample(self, angles: LhCfPoseSample) -> None: + self.recorded_angle_result = angles + def get_sample(self): return self.recorded_angle_result @@ -228,18 +238,22 @@ def str_pad(self, string_msg): class RecordOriginSamplePage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, parent=None): - super(RecordOriginSamplePage, self).__init__(cf) + def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): + super(RecordOriginSamplePage, self).__init__(cf, container) self.explanation_text.setText( 'Step 1. Put the Crazyflie where you want the origin of your coordinate system.\n') pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_1.png") pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) self.explanation_picture.setPixmap(pixmap) + def store_sample(self, angles: LhCfPoseSample) -> None: + self.container.set_origin_sample(LhCfPoseSample(angles)) + super().store_sample(angles) + class RecordXAxisSamplePage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, parent=None): - super(RecordXAxisSamplePage, self).__init__(cf) + def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): + super(RecordXAxisSamplePage, self).__init__(cf, container) self.explanation_text.setText('Step 2. Put the Crazyflie on the positive X-axis,' + f' exactly {REFERENCE_DIST} meters from the origin.\n' + 'This will be used to define the X-axis as well as scaling of the system.') @@ -247,10 +261,14 @@ def __init__(self, cf: Crazyflie, parent=None): pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) self.explanation_picture.setPixmap(pixmap) + def store_sample(self, angles: LhCfPoseSample) -> None: + self.container.set_x_axis_sample(LhCfPoseSample(angles)) + super().store_sample(angles) + class RecordXYPlaneSamplesPage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, parent=None): - super(RecordXYPlaneSamplesPage, self).__init__(cf, show_add_measurements=True) + def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): + super(RecordXYPlaneSamplesPage, self).__init__(cf, container, show_add_measurements=True) self.explanation_text.setText('Step 3. Put the Crazyflie somewhere in the XY-plane, but not on the X-axis.\n' + 'This position is used to map the the XY-plane to the floor.\n' + 'You can sample multiple positions to get a more precise definition.') @@ -258,13 +276,18 @@ def __init__(self, cf: Crazyflie, parent=None): pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) self.explanation_picture.setPixmap(pixmap) + def store_sample(self, angles: LighthouseBsVectors) -> None: + # measurement = LhMeasurement(timestamp=now, base_station_id=bs_id, angles=angles) + self.container.append_xy_plane_sample(LhCfPoseSample(angles)) + super().store_sample(angles) + def get_samples(self): return self.recorded_angles_result class RecordXYZSpaceSamplesPage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, parent=None): - super(RecordXYZSpaceSamplesPage, self).__init__(cf) + def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): + super(RecordXYZSpaceSamplesPage, self).__init__(cf, container) self.explanation_text.setText('Step 4. Move the Crazyflie around, try to cover all of the flying space,\n' + 'make sure all the base stations are received.\n' + 'Avoid moving too fast, you can increase the record time if needed.\n') @@ -319,147 +342,7 @@ def _ready_single_sample_cb(self, bs_id: int, angles: LighthouseBsVectors): now = time.time() measurement = LhMeasurement(timestamp=now, base_station_id=bs_id, angles=angles) self.recorded_angles_result.append(measurement) + self.container.append_xyz_space_samples([measurement]) def get_samples(self): return self.recorded_angles_result - - -class EstimateGeometryThread(QtCore.QObject): - finished = QtCore.pyqtSignal() - failed = QtCore.pyqtSignal() - - def __init__(self, container: LhGeoInputContainer): - super(EstimateGeometryThread, self).__init__() - - self.container = container - self.bs_poses = {} - - def run(self): - try: - logger.info("Start estimation") - self.bs_poses = self._estimate_geometry(self.container) - logger.info("Estimation done") - self.finished.emit() - except Exception as ex: - logger.error("Estimation exception: " + str(ex)) - self.failed.emit() - - def get_poses(self): - return self.bs_poses - - def _estimate_geometry(self, container: LhGeoInputContainer) -> dict[int, Pose]: - """Estimate the geometry of the system based on samples recorded by a Crazyflie""" - - matched_samples = container.get_matched_samples() - logger.info("start initial guess") - initial_guess, cleaned_matched_samples = LighthouseInitialEstimator.estimate(matched_samples) - logger.info("initial guess done") - - scaled_initial_guess = LhGeoEstimationManager.align_and_scale_solution(container, initial_guess, REFERENCE_DIST) - for bs_id, pose in sorted(scaled_initial_guess.bs_poses.items()): - pos = pose.translation - logger.info(f' {bs_id + 1}: ({pos[0]}, {pos[1]}, {pos[2]})') - - logger.info(f"Len cleaned samples: {len(cleaned_matched_samples)}") - - logger.info("Start solver") - solution = LighthouseGeometrySolver.solve(initial_guess, cleaned_matched_samples, container.sensor_positions) - logger.info("Solver done") - logger.info(f"Success: {solution.success}") - if not solution.success: - raise Exception("No lighthouse base station geometry solution could be found!") - - scaled_solution = LhGeoEstimationManager.align_and_scale_solution(container, solution.poses, REFERENCE_DIST) - return scaled_solution.bs_poses - - -class EstimateBSGeometryPage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, origin_page: RecordOriginSamplePage, xaxis_page: RecordXAxisSamplePage, - xyplane_page: RecordXYPlaneSamplesPage, xyzspace_page: RecordXYZSpaceSamplesPage, parent=None): - - super(EstimateBSGeometryPage, self).__init__(cf) - self.explanation_text.setText('Step 5. Press the button to estimate the geometry and check the result.\n' + - 'If the positions of the base stations look reasonable, press finish to close ' + - 'the wizard,\n' + - 'if not restart the wizard.') - pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_5.png") - pixmap = pixmap.scaledToWidth(640) - self.explanation_picture.setPixmap(pixmap) - self.start_action_button.setText('Estimate Geometry') - self.origin_page = origin_page - self.xaxis_page = xaxis_page - self.xyplane_page = xyplane_page - self.xyzspace_page = xyzspace_page - self.bs_poses = {} - - def _action_btn_clicked(self): - self.start_action_button.setDisabled(True) - self.status_text.setText(self.str_pad('Estimating geometry...')) - - container = LhGeoInputContainer(LhDeck4SensorPositions.positions) - container.set_origin_sample(self.origin_page.get_sample()) - container.set_x_axis_sample(self.xaxis_page.get_sample()) - container.set_xy_plane_samples(self.xyplane_page.get_samples()) - container.set_xyz_space_samples(self.xyzspace_page.get_samples()) - - # Enable to write to file. This can be used for debugging in examples/lighthouse/multi_bs_geometry_estimation.py - # found in the python lib - # import pickle - # with open('lh_geo_input_dump.pickle', 'wb') as handle: - # pickle.dump(container, handle, protocol=pickle.HIGHEST_PROTOCOL) - # with open('lh_geo_input_dump.pickle', 'rb') as handle: - # container = pickle.load(handle) - - - self.thread_estimator = QtCore.QThread() - self.worker = EstimateGeometryThread(container) - self.worker.moveToThread(self.thread_estimator) - self.thread_estimator.started.connect(self.worker.run) - self.worker.finished.connect(self.thread_estimator.quit) - self.worker.finished.connect(self._geometry_estimated_finished) - self.worker.failed.connect(self._geometry_estimated_failed) - self.worker.finished.connect(self.worker.deleteLater) - self.thread_estimator.finished.connect(self.thread_estimator.deleteLater) - self.thread_estimator.start() - - def _geometry_estimated_finished(self): - self.bs_poses = self.worker.get_poses() - self.start_action_button.setDisabled(False) - self.status_text.setText(self.str_pad('Geometry estimated! (X,Y,Z) in meters \n' + - self._print_base_stations_poses(self.bs_poses))) - self.is_done = True - self.completeChanged.emit() - - def _geometry_estimated_failed(self): - self.bs_poses = self.worker.get_poses() - self.status_text.setText(self.str_pad('Geometry estimate failed! \n' + - 'Hit Cancel to close the wizard and start again')) - - def _print_base_stations_poses(self, base_stations: dict[int, Pose]): - """Pretty print of base stations pose""" - bs_string = '' - for bs_id, pose in sorted(base_stations.items()): - pos = pose.translation - temp_string = f' {bs_id + 1}: ({pos[0]}, {pos[1]}, {pos[2]})' - bs_string += '\n' + temp_string - - return bs_string - - def get_geometry(self): - geo_dict = {} - for bs_id, pose in self.bs_poses.items(): - geo = LighthouseBsGeometry() - geo.origin = pose.translation.tolist() - geo.rotation_matrix = pose.rot_matrix.tolist() - geo.valid = True - geo_dict[bs_id] = geo - - return geo_dict - - -if __name__ == '__main__': - import sys - app = QtWidgets.QApplication(sys.argv) - wizard = LighthouseBasestationGeometryWizard() - wizard.show() - sys.exit(app.exec()) From 2d900b01377fa07431d02fa8af8dcf5b1baf7b65 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 13 Jun 2025 15:58:01 +0200 Subject: [PATCH 06/73] Adapted to modifications in the lib --- .../ui/wizards/lighthouse_geo_bs_estimation_wizard.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 658e2b7e..8f3024ef 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -38,9 +38,10 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors -from cflib.localization.lighthouse_types import LhDeck4SensorPositions, LhMeasurement, LhBsCfPoses +from cflib.localization.lighthouse_types import LhDeck4SensorPositions, LhMeasurement from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager +from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution from PyQt6 import QtCore, QtWidgets, QtGui @@ -100,10 +101,10 @@ def reset(self): self.setWindowTitle("Lighthouse Base Station Geometry Wizard") self.resize(WINDOW_STARTING_WIDTH, WINDOW_STARTING_HEIGHT) - def solution_handler(self, scaled_solution: LhBsCfPoses): + def solution_handler(self, solution: LighthouseGeometrySolution): """Upload the geometry to the Crazyflie""" geo_dict = {} - for bs_id, pose in scaled_solution.bs_poses.items(): + for bs_id, pose in solution.poses.bs_poses.items(): geo = LighthouseBsGeometry() geo.origin = pose.translation.tolist() geo.rotation_matrix = pose.rot_matrix.tolist() From e9c567763732177761d61f073da8264e373648be Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 17 Jun 2025 13:14:01 +0200 Subject: [PATCH 07/73] Stop solver thread --- .../ui/wizards/lighthouse_geo_bs_estimation_wizard.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 8f3024ef..335f4ed4 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -67,7 +67,6 @@ def __init__(self, lighthouse_tab, ready_cb, parent=None, *args): self.cf = lighthouse_tab._helper.cf self.container = LhGeoInputContainer(LhDeck4SensorPositions.positions) self.solver_thread = LhGeoEstimationManager.SolverThread(self.container, is_done_cb=self.solution_handler) - self.solver_thread.start() self.ready_cb = ready_cb self.wizard_opened_first_time = True self.reset() @@ -113,6 +112,12 @@ def solution_handler(self, solution: LighthouseGeometrySolution): self.lighthouse_tab.write_and_store_geometry(geo_dict) + def showEvent(self, event): + self.solver_thread.start() + + def closeEvent(self, event): + self.solver_thread.stop() + class LighthouseBasestationGeometryWizardBasePage(QtWidgets.QWizardPage): From 77cdeca6611d7c8dd448bf6d8146f3e821e195d5 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 23 Jun 2025 11:59:12 +0200 Subject: [PATCH 08/73] Adaptations for new sampling method --- .../lighthouse_geo_bs_estimation_wizard.py | 88 ++++++++----------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index 335f4ed4..c99543f4 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -31,17 +31,17 @@ import cfclient import logging -import time from cflib.crazyflie import Crazyflie from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader -from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleReader +from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors -from cflib.localization.lighthouse_types import LhDeck4SensorPositions, LhMeasurement +from cflib.localization.lighthouse_types import LhDeck4SensorPositions from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution +from cflib.localization.user_action_detector import UserActionDetector from PyQt6 import QtCore, QtWidgets, QtGui @@ -101,7 +101,17 @@ def reset(self): self.resize(WINDOW_STARTING_WIDTH, WINDOW_STARTING_HEIGHT) def solution_handler(self, solution: LighthouseGeometrySolution): - """Upload the geometry to the Crazyflie""" + logger.info('Solution ready --------------------------------------') + logger.info(f'Converged: {solution.has_converged}') + logger.info(f'Progress info: {solution.progress_info}') + logger.info(f'Progress is ok: {solution.progress_is_ok}') + logger.info(f'Origin: {solution.is_origin_sample_valid}, {solution.origin_sample_info}') + logger.info(f'X-axis: {solution.is_x_axis_samples_valid}, {solution.x_axis_samples_info}') + logger.info(f'XY-plane: {solution.is_xy_plane_samples_valid}, {solution.xy_plane_samples_info}') + logger.info(f'XYZ space: {solution.xyz_space_samples_info}') + logger.info(f'General info: {solution.general_failure_info}') + + # Upload the geometry to the Crazyflie geo_dict = {} for bs_id, pose in solution.poses.bs_poses.items(): geo = LighthouseBsGeometry() @@ -110,6 +120,7 @@ def solution_handler(self, solution: LighthouseGeometrySolution): geo.valid = True geo_dict[bs_id] = geo + logger.info('Uploading geometry to Crazyflie') self.lighthouse_tab.write_and_store_geometry(geo_dict) def showEvent(self, event): @@ -294,61 +305,36 @@ def get_samples(self): class RecordXYZSpaceSamplesPage(LighthouseBasestationGeometryWizardBasePage): def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): super(RecordXYZSpaceSamplesPage, self).__init__(cf, container) - self.explanation_text.setText('Step 4. Move the Crazyflie around, try to cover all of the flying space,\n' + - 'make sure all the base stations are received.\n' + - 'Avoid moving too fast, you can increase the record time if needed.\n') + self.explanation_text.setText('Step 4. Sample points in the space that will be used.\n' + + 'Make sure all the base stations are received, you need at least two base \n' + + 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + + 'left-right around the Z-axis and then holding it still for a second.\n') pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_4.png") pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) self.explanation_picture.setPixmap(pixmap) - self.record_timer = QtCore.QTimer() - self.record_timer.timeout.connect(self._record_timer_cb) - self.record_time_total = DEFAULT_RECORD_TIME - self.record_time_current = 0 - self.reader = LighthouseSweepAngleReader(self.cf, self._ready_single_sample_cb) - - def extra_layout_field(self): - h_box = QtWidgets.QHBoxLayout() - self.seconds_explanation_text = QtWidgets.QLabel() - self.fill_record_times_line_edit = QtWidgets.QLineEdit(str(DEFAULT_RECORD_TIME)) - self.seconds_explanation_text.setText('Enter the number of seconds you want to record:') - h_box.addStretch() - h_box.addWidget(self.seconds_explanation_text) - h_box.addWidget(self.fill_record_times_line_edit) - h_box.addStretch() - self.layout.addLayout(h_box) - - def _record_timer_cb(self): - self.record_time_current += 1 - self.status_text.setText(self.str_pad('Collecting sweep angles...' + - f' seconds remaining: {self.record_time_total-self.record_time_current}')) - - if self.record_time_current == self.record_time_total: - self.reader.stop() - self.status_text.setText(self.str_pad( - 'Recording Done!'+f' Got {len(self.recorded_angles_result)} samples!')) - self.start_action_button.setText("Restart measurements") - self.start_action_button.setDisabled(False) - self.is_done = True - self.completeChanged.emit() - self.record_timer.stop() + self.reader = LighthouseMatchedSweepAngleReader(self.cf, self._ready_single_sample_cb) + self.detector = UserActionDetector(self.cf, cb=self.user_action_cb) def _action_btn_clicked(self): - self.is_done = False - self.reader.start() - self.record_time_current = 0 - self.record_time_total = int(self.fill_record_times_line_edit.text()) - self.record_timer.start(1000) - self.status_text.setText(self.str_pad('Collecting sweep angles...' + - f' seconds remaining: {self.record_time_total}')) - + self.is_done = True self.start_action_button.setDisabled(True) + self.detector.start() + + def user_action_cb(self): + self.reader.start() - def _ready_single_sample_cb(self, bs_id: int, angles: LighthouseBsVectors): - now = time.time() - measurement = LhMeasurement(timestamp=now, base_station_id=bs_id, angles=angles) - self.recorded_angles_result.append(measurement) - self.container.append_xyz_space_samples([measurement]) + def _ready_single_sample_cb(self, sample: LhCfPoseSample): + self.container.append_xyz_space_samples([sample]) def get_samples(self): return self.recorded_angles_result + + def _stop_all(self): + self.reader.stop() + if self.detector is not None: + self.detector.stop() + + def cleanupPage(self): + self._stop_all() + super().cleanupPage() From 5cd63e21d3e6621dcbaddc41d371bedc433c3fdd Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 23 Jun 2025 16:40:03 +0200 Subject: [PATCH 09/73] First step of integrating geo wizard in lighthouse tab --- src/cfclient/ui/tabs/lighthouse_tab.py | 22 + src/cfclient/ui/tabs/lighthouse_tab.ui | 776 +++++++++++++------------ 2 files changed, 430 insertions(+), 368 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 4407dc4a..c2f0e582 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -31,6 +31,7 @@ """ import logging +from enum import Enum from PyQt6 import uic from PyQt6.QtCore import Qt, pyqtSignal, QTimer @@ -262,6 +263,10 @@ def _mix(self, col1, col2, mix): return col1 * mix + col2 * (1.0 - mix) +class UiMode(Enum): + flying = 1 + geo_estimation = 2 + class LighthouseTab(TabToolbox, lighthouse_tab_class): """Tab for plotting Lighthouse data""" @@ -357,6 +362,9 @@ def __init__(self, helper): self._load_sys_config_button.clicked.connect(self._load_sys_config_button_clicked) self._save_sys_config_button.clicked.connect(self._save_sys_config_button_clicked) + self._ui_mode = UiMode.flying + self._geo_mode_button.toggled.connect(lambda enabled: self._change_ui_mode(enabled)) + self._is_connected = False self._update_ui() @@ -390,6 +398,7 @@ def _connected(self, link_uri): logger.debug("Crazyflie connected to {}".format(link_uri)) self._basestation_geometry_dialog.reset() + self._flying_mode_button.setChecked(True) self._is_connected = True if self._helper.cf.param.get_value('deck.bcLighthouse4') == '1': @@ -485,6 +494,7 @@ def _disconnected(self, link_uri): self._update_graphics() self._plot_3d.clear() self._basestation_geometry_dialog.close() + self._flying_mode_button.setChecked(True) self.is_lighthouse_deck_active = False self._is_connected = False self._update_ui() @@ -519,6 +529,14 @@ def _logging_error(self, log_conf, msg): "Error when using log config", " [{0}]: {1}".format(log_conf.name, msg)) + def _change_ui_mode(self, is_geo_mode: bool): + if is_geo_mode: + self._ui_mode = UiMode.geo_estimation + else: + self._ui_mode = UiMode.flying + + self._update_ui() + def _update_graphics(self): if self.is_visible() and self.is_lighthouse_deck_active: self._plot_3d.update_cf_pose(self._helper.pose_logger.position, @@ -536,6 +554,10 @@ def _update_ui(self): self._load_sys_config_button.setEnabled(enabled) self._save_sys_config_button.setEnabled(enabled) + self._mode_group.setEnabled(enabled) + self._geometry_area.setEnabled(enabled) + self._geometry_area.setVisible(self._ui_mode == UiMode.geo_estimation) + def _update_position_label(self, position): if len(position) == 3: coordinate = "({:0.2f}, {:0.2f}, {:0.2f})".format( diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index 71f5cce5..6346f80e 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -6,399 +6,411 @@ 0 0 - 1753 - 763 + 1302 + 742 Plot - + - - - 0 + + + QLayout::SetDefaultConstraint - - - - 6 + + + + + 0 + 0 + - - QLayout::SetDefaultConstraint + + + 0 + 0 + - - - - QLayout::SetDefaultConstraint - - - - - - 0 - 0 - - - - - 0 - 0 - - - - Crazyflie status - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - + + Crazyflie status + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + - - - - - - - Status: - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - - - - true - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Position: - - - - - - - - 150 - 0 - - - - QFrame::NoFrame - - - (0.0 , 0.0 , 0.0) - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - + + + Status: + + - + + + + 0 + 0 + + + + + 200 + 0 + + + + - + + + true + + + + + - Qt::Vertical + Qt::Horizontal - 0 + 40 20 - - - - - - true - - - - 0 - 0 - - - - - 0 - 0 - - - - Basestation Status - - + + + - - - - - - - QLayout::SetMinimumSize - - - 2 - - - - - background-color: lightpink; - - - QFrame::Box - - - - - - - - - - - 30 - 0 - - - - 1 - - - - - - - - 100 - 0 - - - - Geometry - - - - - - - Receiving - - - - - - - background-color: lightpink; - - - QFrame::Box - - - - - - - - - - background-color: lightpink; - - - QFrame::Box - - - - - - - - - - - 100 - 0 - - - - Estimator - - - - - - - Calibration - - - - - - - background-color: lightpink; - - - QFrame::Box - - - - - - - - - - - - - - - - - - + + + Position: + + + + + + + + 150 + 0 + + + + QFrame::NoFrame + + + (0.0 , 0.0 , 0.0) + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - System Management - - - - QLayout::SetDefaultConstraint + + + + + + + + + + true + + + + 0 + 0 + + + + + 0 + 0 + + + + Basestation Status + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + QLayout::SetMinimumSize + + + 2 + + + + + background-color: lightpink; + + + QFrame::Box + + + + + + + + + + + 30 + 0 + + + + 1 + + + + + + + + 100 + 0 + + + + Geometry + + + + + + + Receiving + + + + + + + background-color: lightpink; + + + QFrame::Box + + + + + + + + + + background-color: lightpink; + + + QFrame::Box + + + + + + + + + + + 100 + 0 + + + Estimator + + + + + + + Calibration + + + + + + + background-color: lightpink; + + + QFrame::Box + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + System Management + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + QLayout::SetDefaultConstraint + + + + + - - - - - - - Manage geometry - - - - - - - Change system type - - - - - - - Set BS channel - - - - - - - - - - - Save system config - - - - - - - Load system config - - - - - - - - - Qt::Vertical - - - - 20 - 5 - - - - - + + + Manage geometry + + + + + + + Change system type + + + + + + + Set BS channel + + - - + + + + + + + Save system config + + + + + + + Load system config + + + + + + + + + + + + + + + 0 + 0 + + + + Mode + + + + + + Flying + + + true + + + + + + + Geometry + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + - + Qt::Horizontal @@ -414,12 +426,40 @@ - - - - QLayout::SetMaximumSize + + + + QFrame::StyledPanel - + + QFrame::Raised + + + + + + + + + TextLabel + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + From 96ddf1cc923bc15f452c3b2f86f235abdad1c665 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 24 Jun 2025 16:37:22 +0200 Subject: [PATCH 10/73] Basic functionality in geo wizard widget --- src/cfclient/ui/tabs/lighthouse_tab.py | 9 +- src/cfclient/ui/tabs/lighthouse_tab.ui | 25 +- src/cfclient/ui/widgets/geo_estimator.ui | 133 +++++++ .../geo_estimator_resources/bslh_1.png | Bin 0 -> 6677 bytes .../geo_estimator_resources/bslh_2.png | Bin 0 -> 9105 bytes .../geo_estimator_resources/bslh_3.png | Bin 0 -> 8838 bytes .../geo_estimator_resources/bslh_4.png | Bin 0 -> 13664 bytes .../geo_estimator_resources/bslh_5.png | Bin 0 -> 7743 bytes .../ui/widgets/geo_estimator_widget.py | 340 ++++++++++++++++++ 9 files changed, 483 insertions(+), 24 deletions(-) create mode 100644 src/cfclient/ui/widgets/geo_estimator.ui create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_1.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_2.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_3.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_4.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_5.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_widget.py diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index c2f0e582..6af2430c 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -42,6 +42,7 @@ import cfclient from cfclient.ui.tab_toolbox import TabToolbox +from cfclient.ui.widgets.geo_estimator_widget import GeoEstimatorWidget from cflib.crazyflie.log import LogConfig from cflib.crazyflie.mem import LighthouseMemHelper from cflib.localization import LighthouseConfigWriter @@ -302,6 +303,10 @@ def __init__(self, helper): super(LighthouseTab, self).__init__(helper, 'Lighthouse Positioning') self.setupUi(self) + self._geo_estimator_widget = GeoEstimatorWidget(self) + self._geometry_area.addWidget(self._geo_estimator_widget) + + # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it self._connected_signal.connect(self._connected) @@ -555,8 +560,8 @@ def _update_ui(self): self._save_sys_config_button.setEnabled(enabled) self._mode_group.setEnabled(enabled) - self._geometry_area.setEnabled(enabled) - self._geometry_area.setVisible(self._ui_mode == UiMode.geo_estimation) + + self._geo_estimator_widget.setVisible(self._ui_mode == UiMode.geo_estimation and enabled) def _update_position_label(self, position): if len(position) == 3: diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index 6346f80e..fa9a0687 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -404,9 +404,9 @@ - + - + @@ -427,26 +427,7 @@ - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - - TextLabel - - - - - + diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui new file mode 100644 index 00000000..003bdc5b --- /dev/null +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -0,0 +1,133 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + + true + + + + Geometry estimator + + + + + + + Sample collection + + + + + + TextLabel + + + + + + + Image + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + + 0 + 0 + + + + Start measurement + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Previous + + + + + + + + 0 + 0 + + + + Next + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_1.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_1.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1d1f75443558966c82c9b5ae35857148bf29b2 GIT binary patch literal 6677 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 literal 0 HcmV?d00001 diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_2.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_2.png new file mode 100644 index 0000000000000000000000000000000000000000..56dfb5fb66c7038b8af662773b4e0121e44694d5 GIT binary patch literal 9105 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 literal 0 HcmV?d00001 diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_3.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_3.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f385e4e31dff99e52ba15c2aa5fc9b5aca94cb GIT binary patch literal 8838 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 z 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 0: + return self.order[i - 1] + else: + return self + + def has_previous(self): + """Check if there is a previous step in the collection process""" + return self.previous() != self + + +class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): + """Widget for the geometry estimator UI""" + + _timeout_reader_signal = QtCore.pyqtSignal(object) + _solution_ready_signal = QtCore.pyqtSignal(object) + + def __init__(self, lighthouse_tab): + super(GeoEstimatorWidget, self).__init__() + self.setupUi(self) + + self._lighthouse_tab = lighthouse_tab + self._helper = lighthouse_tab._helper + + self._step_next_button.clicked.connect(lambda: self._update_step(self._current_step.next())) + self._step_previous_button.clicked.connect(lambda: self._update_step(self._current_step.previous())) + self._step_measure.clicked.connect(self._measure) + + self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) + self._timeout_reader_signal.connect(self._average_available_cb) + self._timeout_reader_result_setter = None + + self._current_step = _CollectionStep.ORIGIN + self._populate_step() + self._update_ui_reading(False) + + self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) + self._solution_ready_signal.connect(self._solution_ready_cb) + self._solver_thread = None + + # TODO krri handĺe disconnects + + def setVisible(self, visible: bool): + super(GeoEstimatorWidget, self).setVisible(visible) + if visible: + if self._solver_thread is None: + logger.info("Starting solver thread") + self._solver_thread = LhGeoEstimationManager.SolverThread(self._container, + is_done_cb=self._solution_ready_signal.emit) + self._solver_thread.start() + else: + if self._solver_thread is not None: + logger.info("Stopping solver thread") + self._solver_thread.stop(do_join=False) + self._solver_thread = None + + def _update_step(self, step): + """Update the widget to display the new step""" + if step != self._current_step: + self._current_step = step + self._populate_step() + + def _populate_step(self): + """Populate the widget with the current step's information""" + step = self._current_step + + self._step_title.setText(step.title) + self._step_image.setPixmap(QtGui.QPixmap( + cfclient.module_path + '/ui/widgets/geo_estimator_resources/' + step.image)) + self._step_instructions.setText(step.instructions) + self._step_info.setText('') + + self._step_measure.setVisible(step != _CollectionStep.XYZ_SPACE) + + self._step_previous_button.setEnabled(step.has_previous()) + self._step_next_button.setEnabled(step.has_next()) + + def _update_ui_reading(self, is_reading: bool): + """Update the UI to reflect whether a reading is in progress""" + is_enabled = not is_reading + + self._step_measure.setEnabled(is_enabled) + self._step_next_button.setEnabled(is_enabled) + self._step_previous_button.setEnabled(is_enabled) + + def _measure(self): + """Trigger the measurement for the current step""" + match self._current_step: + case _CollectionStep.ORIGIN: + self._measure_origin() + case _CollectionStep.X_AXIS: + self._measure_x_axis() + case _CollectionStep.XY_PLANE: + self._measure_xy_plane() + case _CollectionStep.XYZ_SPACE: + pass + + def _measure_origin(self): + """Measure the origin position""" + # Placeholder for actual measurement logic + logger.info("Measuring origin position...") + self._start_timeout_average_read(self._container.set_origin_sample) + + def _measure_x_axis(self): + """Measure the X-axis position""" + # Placeholder for actual measurement logic + logger.info("Measuring X-axis position...") + self._start_timeout_average_read(self._container.set_x_axis_sample) + + def _measure_xy_plane(self): + """Measure the XY-plane position""" + # Placeholder for actual measurement logic + logger.info("Measuring XY-plane position...") + self._start_timeout_average_read(self._container.append_xy_plane_sample) + + def _start_timeout_average_read(self, setter: Callable[[LhCfPoseSample], None]): + """Start the timeout average angle reader""" + self._timeout_reader.start() + self._timeout_reader_result_setter = setter + self._step_info.setText("Collecting angles...") + self._update_ui_reading(True) + + def _average_available_cb(self, sample: LhCfPoseSample): + """Callback for when the average angles are available from the reader or after""" + + bs_ids = list(sample.angles_calibrated.keys()) + bs_ids.sort() + bs_seen = ', '.join(map(lambda x: str(x + 1), bs_ids)) + bs_count = len(bs_ids) + + logger.info("Average angles received: %s", bs_seen) + + self._update_ui_reading(False) + + if bs_count == 0: + self._step_info.setText("No base stations seen, please try again.") + elif bs_count < 2: + self._step_info.setText(f"Only one base station (nr {bs_seen}) was seen, we need at least two. Please try again.") + else: + if self._timeout_reader_result_setter is not None: + self._timeout_reader_result_setter(sample) + self._step_info.setText(f"Base stations {bs_seen} were seen. Sample stored.") + + self._timeout_reader_result_setter = None + + def _solution_ready_cb(self, solution: LighthouseGeometrySolution): + logger.info('Solution ready --------------------------------------') + logger.info(f'Converged: {solution.has_converged}') + logger.info(f'Progress info: {solution.progress_info}') + logger.info(f'Progress is ok: {solution.progress_is_ok}') + logger.info(f'Origin: {solution.is_origin_sample_valid}, {solution.origin_sample_info}') + logger.info(f'X-axis: {solution.is_x_axis_samples_valid}, {solution.x_axis_samples_info}') + logger.info(f'XY-plane: {solution.is_xy_plane_samples_valid}, {solution.xy_plane_samples_info}') + logger.info(f'XYZ space: {solution.xyz_space_samples_info}') + logger.info(f'General info: {solution.general_failure_info}') + + if solution.progress_is_ok: + self._upload_geometry(solution.poses.bs_poses) + + def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): + geo_dict = {} + for bs_id, pose in bs_poses.items(): + geo = LighthouseBsGeometry() + geo.origin = pose.translation.tolist() + geo.rotation_matrix = pose.rot_matrix.tolist() + geo.valid = True + geo_dict[bs_id] = geo + + logger.info('Uploading geometry to Crazyflie') + self._lighthouse_tab.write_and_store_geometry(geo_dict) + + +class TimeoutAngleReader: + def __init__(self, cf: Crazyflie, ready_cb: Callable[[LhCfPoseSample], None]): + self._ready_cb = ready_cb + + self.timeout_timer = QtCore.QTimer() + self.timeout_timer.timeout.connect(self._timeout_cb) + self.timeout_timer.setSingleShot(True) + + self.reader = LighthouseSweepAngleAverageReader(cf, self._reader_ready_cb) + + self.lock = threading.Lock() + self.is_collecting = False + + def start(self, timeout=2000): + with self.lock: + if self.is_collecting: + raise RuntimeError("Measurement already in progress!") + self.is_collecting = True + + self.reader.start_angle_collection() + self.timeout_timer.start(timeout) + logger.info("Starting angle collection with timeout of %d ms", timeout) + + def _timeout_cb(self): + logger.info("Timeout reached, stopping angle collection") + with self.lock: + if not self.is_collecting: + return + self.is_collecting = False + + self.reader.stop_angle_collection() + + result = LhCfPoseSample({}) + self._ready_cb(result) + + def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVectors]]): + logger.info("Reader ready with %d base stations", len(recorded_angles)) + with self.lock: + if not self.is_collecting: + return + self.is_collecting = False + + # TODO krri Can not stop the timer from this thread + # self.timeout_timer.stop() + + angles_calibrated: dict[int, LighthouseBsVectors] = {} + for bs_id, data in recorded_angles.items(): + angles_calibrated[bs_id] = data[1] + + result = LhCfPoseSample(angles_calibrated) + self._ready_cb(result) From 00909f35bbf9356fa60b2bb1681f1913467e43e0 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 25 Jun 2025 11:35:37 +0200 Subject: [PATCH 11/73] Improved used feedback --- src/cfclient/ui/widgets/geo_estimator.ui | 110 +++++++++++++++++- .../ui/widgets/geo_estimator_widget.py | 97 ++++++++++++--- 2 files changed, 191 insertions(+), 16 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 003bdc5b..f7c5d765 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -7,7 +7,7 @@ 0 0 400 - 300 + 753 @@ -54,7 +54,7 @@ - + TextLabel @@ -73,6 +73,16 @@ + + + + QFrame::Panel + + + TextLabel + + + @@ -113,6 +123,102 @@ + + + + + + + 0 + 0 + + + + Data status + + + + + + Origin + + + + + + + X-axis + + + + + + + XY-plane + + + + + + + + 0 + 0 + + + + XYZ-space + + + + + + + + + + + 0 + 0 + + + + Solution status + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + + + + + + Clear all + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 093a2d4d..dbaa0bb8 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -31,6 +31,8 @@ from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui +from PyQt6.QtWidgets import QMessageBox + import logging from enum import Enum @@ -126,6 +128,12 @@ def has_previous(self): return self.previous() != self +STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" +STYLE_RED_BACKGROUND = "background-color: lightpink;" + + +# TODO krri Sample XYZ-space + class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" @@ -143,20 +151,23 @@ def __init__(self, lighthouse_tab): self._step_previous_button.clicked.connect(lambda: self._update_step(self._current_step.previous())) self._step_measure.clicked.connect(self._measure) + self._clear_all_button.clicked.connect(self._clear_all) + self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) self._timeout_reader_signal.connect(self._average_available_cb) self._timeout_reader_result_setter = None + self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) + + self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution() + self._current_step = _CollectionStep.ORIGIN self._populate_step() self._update_ui_reading(False) - self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) self._solution_ready_signal.connect(self._solution_ready_cb) self._solver_thread = None - # TODO krri handĺe disconnects - def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -171,6 +182,19 @@ def setVisible(self, visible: bool): self._solver_thread.stop(do_join=False) self._solver_thread = None + def clear_state(self): + self._container.clear_all_samples() + + def _clear_all(self): + dlg = QMessageBox(self) + dlg.setWindowTitle("Clear samples Confirmation") + dlg.setText("Are you sure you want to clear all samples and start over?") + dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + button = dlg.exec() + + if button == QMessageBox.StandardButton.Yes: + self.clear_state() + def _update_step(self, step): """Update the widget to display the new step""" if step != self._current_step: @@ -188,18 +212,60 @@ def _populate_step(self): self._step_info.setText('') self._step_measure.setVisible(step != _CollectionStep.XYZ_SPACE) + self._update_solution_info() self._step_previous_button.setEnabled(step.has_previous()) self._step_next_button.setEnabled(step.has_next()) def _update_ui_reading(self, is_reading: bool): - """Update the UI to reflect whether a reading is in progress""" + """Update the UI to reflect whether a reading is in progress, that is enable/disable buttons""" is_enabled = not is_reading self._step_measure.setEnabled(is_enabled) self._step_next_button.setEnabled(is_enabled) self._step_previous_button.setEnabled(is_enabled) + def _update_solution_info(self): + solution = self._latest_solution + + match self._current_step: + case _CollectionStep.ORIGIN: + self._step_solution_info.setText( + 'OK' if solution.is_origin_sample_valid else solution.origin_sample_info) + case _CollectionStep.X_AXIS: + self._step_solution_info.setText( + 'OK' if solution.is_x_axis_samples_valid else solution.x_axis_samples_info) + case _CollectionStep.XY_PLANE: + self._step_solution_info.setText( + 'OK' if solution.is_xy_plane_samples_valid else solution.xy_plane_samples_info) + case _CollectionStep.XYZ_SPACE: + self._step_solution_info.setText(solution.xyz_space_samples_info) + + self._set_background_color(self._data_status_origin, solution.is_origin_sample_valid) + self._set_background_color(self._data_status_x_axis, solution.is_x_axis_samples_valid) + self._set_background_color(self._data_status_xy_plane, solution.is_xy_plane_samples_valid) + # TODO krri XYZ-space + + if solution.progress_is_ok: + self._solution_status_is_ok.setText('Solution is OK') + self._solution_status_uploaded.setText('Uploaded') + else: + self._solution_status_is_ok.setText('No solution') + self._solution_status_uploaded.setText('Not uploaded') + self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) + + self._solution_status_info.setText(solution.general_failure_info) + + def _set_background_color(self, widget: QtWidgets.QWidget, is_valid: bool): + """Set the background color of a widget based on validity""" + if is_valid: + widget.setStyleSheet(STYLE_GREEN_BACKGROUND) + else: + widget.setStyleSheet(STYLE_RED_BACKGROUND) + + # Force a repaint to ensure the style is applied immediately + widget.repaint() + def _measure(self): """Trigger the measurement for the current step""" match self._current_step: @@ -261,15 +327,18 @@ def _average_available_cb(self, sample: LhCfPoseSample): self._timeout_reader_result_setter = None def _solution_ready_cb(self, solution: LighthouseGeometrySolution): - logger.info('Solution ready --------------------------------------') - logger.info(f'Converged: {solution.has_converged}') - logger.info(f'Progress info: {solution.progress_info}') - logger.info(f'Progress is ok: {solution.progress_is_ok}') - logger.info(f'Origin: {solution.is_origin_sample_valid}, {solution.origin_sample_info}') - logger.info(f'X-axis: {solution.is_x_axis_samples_valid}, {solution.x_axis_samples_info}') - logger.info(f'XY-plane: {solution.is_xy_plane_samples_valid}, {solution.xy_plane_samples_info}') - logger.info(f'XYZ space: {solution.xyz_space_samples_info}') - logger.info(f'General info: {solution.general_failure_info}') + self._latest_solution = solution + self._update_solution_info() + + logger.debug('Solution ready --------------------------------------') + logger.debug(f'Converged: {solution.has_converged}') + logger.debug(f'Progress info: {solution.progress_info}') + logger.debug(f'Progress is ok: {solution.progress_is_ok}') + logger.debug(f'Origin: {solution.is_origin_sample_valid}, {solution.origin_sample_info}') + logger.debug(f'X-axis: {solution.is_x_axis_samples_valid}, {solution.x_axis_samples_info}') + logger.debug(f'XY-plane: {solution.is_xy_plane_samples_valid}, {solution.xy_plane_samples_info}') + logger.debug(f'XYZ space: {solution.xyz_space_samples_info}') + logger.debug(f'General info: {solution.general_failure_info}') if solution.progress_is_ok: self._upload_geometry(solution.poses.bs_poses) @@ -329,7 +398,7 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec return self.is_collecting = False - # TODO krri Can not stop the timer from this thread + # Can not stop the timer from this thread, let it run. # self.timeout_timer.stop() angles_calibrated: dict[int, LighthouseBsVectors] = {} From 0366878b1aa4cafe27ac5836c7601aaca777ceff Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 25 Jun 2025 12:11:56 +0200 Subject: [PATCH 12/73] Added samplinf of xyz-space --- src/cfclient/ui/tabs/lighthouse_tab.py | 4 +- .../ui/widgets/geo_estimator_widget.py | 49 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 6af2430c..17068a11 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -268,6 +268,7 @@ class UiMode(Enum): flying = 1 geo_estimation = 2 + class LighthouseTab(TabToolbox, lighthouse_tab_class): """Tab for plotting Lighthouse data""" @@ -306,7 +307,6 @@ def __init__(self, helper): self._geo_estimator_widget = GeoEstimatorWidget(self) self._geometry_area.addWidget(self._geo_estimator_widget) - # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it self._connected_signal.connect(self._connected) @@ -374,7 +374,7 @@ def __init__(self, helper): self._update_ui() def write_and_store_geometry(self, geometries: dict[int, LighthouseBsGeometry]): - # TODO krri Hanlde repeated quick writes. This is called from the geo wizard and write_and_store_config() will + # TODO krri Handle repeated quick writes. This is called from the geo wizard and write_and_store_config() will # throw if there is an ongoing write if self._lh_config_writer: self._lh_config_writer.write_and_store_config(self._new_system_config_written_to_cf_signal.emit, diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index dbaa0bb8..7208c5fb 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -132,8 +132,6 @@ def has_previous(self): STYLE_RED_BACKGROUND = "background-color: lightpink;" -# TODO krri Sample XYZ-space - class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" @@ -147,8 +145,8 @@ def __init__(self, lighthouse_tab): self._lighthouse_tab = lighthouse_tab self._helper = lighthouse_tab._helper - self._step_next_button.clicked.connect(lambda: self._update_step(self._current_step.next())) - self._step_previous_button.clicked.connect(lambda: self._update_step(self._current_step.previous())) + self._step_next_button.clicked.connect(lambda: self._change_step(self._current_step.next())) + self._step_previous_button.clicked.connect(lambda: self._change_step(self._current_step.previous())) self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) @@ -157,13 +155,17 @@ def __init__(self, lighthouse_tab): self._timeout_reader_signal.connect(self._average_available_cb) self._timeout_reader_result_setter = None + self._action_detector = UserActionDetector(self._helper.cf, cb=self._user_action_detected_cb) + self._matched_reader = LighthouseMatchedSweepAngleReader(self._helper.cf, self._single_sample_ready_cb) + self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution() self._current_step = _CollectionStep.ORIGIN - self._populate_step() + self._update_step_ui() self._update_ui_reading(False) + self._update_solution_info() self._solution_ready_signal.connect(self._solution_ready_cb) self._solver_thread = None @@ -177,6 +179,7 @@ def setVisible(self, visible: bool): is_done_cb=self._solution_ready_signal.emit) self._solver_thread.start() else: + self._action_detector.stop() if self._solver_thread is not None: logger.info("Stopping solver thread") self._solver_thread.stop(do_join=False) @@ -195,13 +198,17 @@ def _clear_all(self): if button == QMessageBox.StandardButton.Yes: self.clear_state() - def _update_step(self, step): + def _change_step(self, step): """Update the widget to display the new step""" if step != self._current_step: self._current_step = step - self._populate_step() + self._update_step_ui() + if step == _CollectionStep.XYZ_SPACE: + self._action_detector.start() + else: + self._action_detector.stop() - def _populate_step(self): + def _update_step_ui(self): """Populate the widget with the current step's information""" step = self._current_step @@ -212,11 +219,12 @@ def _populate_step(self): self._step_info.setText('') self._step_measure.setVisible(step != _CollectionStep.XYZ_SPACE) - self._update_solution_info() self._step_previous_button.setEnabled(step.has_previous()) self._step_next_button.setEnabled(step.has_next()) + self._update_solution_info() + def _update_ui_reading(self, is_reading: bool): """Update the UI to reflect whether a reading is in progress, that is enable/disable buttons""" is_enabled = not is_reading @@ -236,15 +244,20 @@ def _update_solution_info(self): self._step_solution_info.setText( 'OK' if solution.is_x_axis_samples_valid else solution.x_axis_samples_info) case _CollectionStep.XY_PLANE: - self._step_solution_info.setText( - 'OK' if solution.is_xy_plane_samples_valid else solution.xy_plane_samples_info) + if solution.xy_plane_samples_info: + text = f'OK, {self._container.xy_plane_sample_count()} sample(s)' + else: + text = solution.xy_plane_samples_info + self._step_solution_info.setText(text) case _CollectionStep.XYZ_SPACE: - self._step_solution_info.setText(solution.xyz_space_samples_info) + text = f'OK, {self._container.xyz_space_sample_count()} sample(s)' + if solution.xyz_space_samples_info: + text += f', {solution.xyz_space_samples_info}' + self._step_solution_info.setText(text) self._set_background_color(self._data_status_origin, solution.is_origin_sample_valid) self._set_background_color(self._data_status_x_axis, solution.is_x_axis_samples_valid) self._set_background_color(self._data_status_xy_plane, solution.is_xy_plane_samples_valid) - # TODO krri XYZ-space if solution.progress_is_ok: self._solution_status_is_ok.setText('Solution is OK') @@ -318,7 +331,8 @@ def _average_available_cb(self, sample: LhCfPoseSample): if bs_count == 0: self._step_info.setText("No base stations seen, please try again.") elif bs_count < 2: - self._step_info.setText(f"Only one base station (nr {bs_seen}) was seen, we need at least two. Please try again.") + self._step_info.setText(f"Only one base station (nr {bs_seen}) was seen, " + + "we need at least two. Please try again.") else: if self._timeout_reader_result_setter is not None: self._timeout_reader_result_setter(sample) @@ -355,6 +369,13 @@ def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): logger.info('Uploading geometry to Crazyflie') self._lighthouse_tab.write_and_store_geometry(geo_dict) + def _user_action_detected_cb(self): + self._matched_reader.start() + + def _single_sample_ready_cb(self, sample: LhCfPoseSample): + self._container.append_xyz_space_samples([sample]) + self._update_solution_info() + class TimeoutAngleReader: def __init__(self, cf: Crazyflie, ready_cb: Callable[[LhCfPoseSample], None]): From 94db0bdd962a1dbcc951d4520171a6587503a382 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 27 Jun 2025 09:07:59 +0200 Subject: [PATCH 13/73] Use signal for callback from cflib --- src/cfclient/ui/widgets/geo_estimator_widget.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 7208c5fb..8c442c16 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -78,7 +78,7 @@ class _CollectionStep(Enum): 'This position is used to map the the XY-plane to the floor.\n' + 'You can sample multiple positions to get a more precise definition.') XYZ_SPACE = ('bslh_4.png', - 'Step 4. Flight space', + 'Step 4. XYZ-space', 'Sample points in the space that will be used.\n' + 'Make sure all the base stations are received, you need at least two base \n' + 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + @@ -136,6 +136,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" _timeout_reader_signal = QtCore.pyqtSignal(object) + _container_updated_signal = QtCore.pyqtSignal() _solution_ready_signal = QtCore.pyqtSignal(object) def __init__(self, lighthouse_tab): @@ -155,6 +156,8 @@ def __init__(self, lighthouse_tab): self._timeout_reader_signal.connect(self._average_available_cb) self._timeout_reader_result_setter = None + self._container_updated_signal.connect(self._update_solution_info) + self._action_detector = UserActionDetector(self._helper.cf, cb=self._user_action_detected_cb) self._matched_reader = LighthouseMatchedSweepAngleReader(self._helper.cf, self._single_sample_ready_cb) @@ -374,7 +377,8 @@ def _user_action_detected_cb(self): def _single_sample_ready_cb(self, sample: LhCfPoseSample): self._container.append_xyz_space_samples([sample]) - self._update_solution_info() + self._container_updated_signal.emit() + # self._update_solution_info() class TimeoutAngleReader: From f7860e4c1b45008e44ba642e7882945db1d2447c Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 27 Jun 2025 09:51:03 +0200 Subject: [PATCH 14/73] Added basic link stats --- src/cfclient/ui/widgets/geo_estimator.ui | 12 ++++++++++++ src/cfclient/ui/widgets/geo_estimator_widget.py | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index f7c5d765..b1dece7d 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -212,6 +212,18 @@ + + + + Base station links + + + + + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 8c442c16..c601f4fc 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -272,6 +272,16 @@ def _update_solution_info(self): self._solution_status_info.setText(solution.general_failure_info) + link_info = '' + for bs, link_map in solution.link_count.items(): + count = len(link_map) + if count > 0: + seen_bs = ', '.join(map(lambda x: str(x + 1), link_map.keys())) + link_info += f'Base station {bs + 1}: {count} link(s), id {seen_bs}\n' + else: + link_info += f'Base station {bs + 1}: No link\n' + self._bs_link_text.setPlainText(link_info) + def _set_background_color(self, widget: QtWidgets.QWidget, is_valid: bool): """Set the background color of a widget based on validity""" if is_valid: From e78d7eb11f72feebeced5d10a817e7d698321b7a Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 3 Jul 2025 10:28:56 +0200 Subject: [PATCH 15/73] Add user feedback when sampling --- src/cfclient/ui/widgets/geo_estimator.ui | 53 ++++++++++++++----- .../ui/widgets/geo_estimator_widget.py | 52 ++++++++++++++++-- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index b1dece7d..42254895 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -27,7 +27,10 @@ - + + + + Sample collection @@ -41,6 +44,9 @@ + + + Image @@ -138,37 +144,55 @@ - + + + + 0 + 0 + + + + + Origin + + false + - + + + + X-axis + + false + - + XY-plane + + false + - - - - 0 - 0 - - + XYZ-space + + false + @@ -219,7 +243,12 @@ - + + + qwe + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index c601f4fc..264ce7c1 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -32,6 +32,7 @@ from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtCore import QTimer import logging @@ -128,8 +129,15 @@ def has_previous(self): return self.previous() != self +class _UserNotificationType(Enum): + SUCCESS = "success" + FAILURE = "failure" + PENDING = "pending" + + STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" STYLE_RED_BACKGROUND = "background-color: lightpink;" +STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): @@ -137,6 +145,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _timeout_reader_signal = QtCore.pyqtSignal(object) _container_updated_signal = QtCore.pyqtSignal() + _user_notification_signal = QtCore.pyqtSignal(object) _solution_ready_signal = QtCore.pyqtSignal(object) def __init__(self, lighthouse_tab): @@ -158,8 +167,14 @@ def __init__(self, lighthouse_tab): self._container_updated_signal.connect(self._update_solution_info) + self._user_notification_signal.connect(self._notify_user) + self._user_notification_clear_timer = QTimer() + self._user_notification_clear_timer.setSingleShot(True) + self._user_notification_clear_timer.timeout.connect(self._user_notification_clear) + self._action_detector = UserActionDetector(self._helper.cf, cb=self._user_action_detected_cb) - self._matched_reader = LighthouseMatchedSweepAngleReader(self._helper.cf, self._single_sample_ready_cb) + self._matched_reader = LighthouseMatchedSweepAngleReader(self._helper.cf, self._single_sample_ready_cb, + timeout_cb=self._single_sample_timeout_cb) self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) @@ -173,6 +188,11 @@ def __init__(self, lighthouse_tab): self._solution_ready_signal.connect(self._solution_ready_cb) self._solver_thread = None + self._data_status_origin.clicked.connect(lambda: self._change_step(_CollectionStep.ORIGIN)) + self._data_status_x_axis.clicked.connect(lambda: self._change_step(_CollectionStep.X_AXIS)) + self._data_status_xy_plane.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) + self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -282,6 +302,23 @@ def _update_solution_info(self): link_info += f'Base station {bs + 1}: No link\n' self._bs_link_text.setPlainText(link_info) + def _notify_user(self, notification_type: _UserNotificationType): + match notification_type: + case _UserNotificationType.SUCCESS: + self._helper.cf.platform.send_user_notification(True) + self._sample_collection_box.setStyleSheet(STYLE_GREEN_BACKGROUND) + case _UserNotificationType.FAILURE: + self._helper.cf.platform.send_user_notification(False) + self._sample_collection_box.setStyleSheet(STYLE_RED_BACKGROUND) + case _UserNotificationType.PENDING: + self._sample_collection_box.setStyleSheet(STYLE_YELLOW_BACKGROUND) + + self._user_notification_clear_timer.stop() + self._user_notification_clear_timer.start(1000) + + def _user_notification_clear(self): + self._sample_collection_box.setStyleSheet('') + def _set_background_color(self, widget: QtWidgets.QWidget, is_valid: bool): """Set the background color of a widget based on validity""" if is_valid: @@ -343,13 +380,16 @@ def _average_available_cb(self, sample: LhCfPoseSample): if bs_count == 0: self._step_info.setText("No base stations seen, please try again.") + self._user_notification_signal.emit(_UserNotificationType.FAILURE) elif bs_count < 2: self._step_info.setText(f"Only one base station (nr {bs_seen}) was seen, " + "we need at least two. Please try again.") + self._user_notification_signal.emit(_UserNotificationType.FAILURE) else: if self._timeout_reader_result_setter is not None: self._timeout_reader_result_setter(sample) self._step_info.setText(f"Base stations {bs_seen} were seen. Sample stored.") + self._user_notification_signal.emit(_UserNotificationType.SUCCESS) self._timeout_reader_result_setter = None @@ -383,12 +423,16 @@ def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): self._lighthouse_tab.write_and_store_geometry(geo_dict) def _user_action_detected_cb(self): - self._matched_reader.start() + self._user_notification_signal.emit(_UserNotificationType.PENDING) + self._matched_reader.start(timeout=1.0) def _single_sample_ready_cb(self, sample: LhCfPoseSample): - self._container.append_xyz_space_samples([sample]) + self._user_notification_signal.emit(_UserNotificationType.SUCCESS) self._container_updated_signal.emit() - # self._update_solution_info() + self._container.append_xyz_space_samples([sample]) + + def _single_sample_timeout_cb(self): + self._user_notification_signal.emit(_UserNotificationType.FAILURE) class TimeoutAngleReader: From 8dbe0994bfaf8459fc1ad6cd1e538e5e021a34c6 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 3 Jul 2025 10:40:30 +0200 Subject: [PATCH 16/73] Added button to sample XYZ-space --- .../ui/widgets/geo_estimator_widget.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 264ce7c1..888c4ce7 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -241,7 +241,10 @@ def _update_step_ui(self): self._step_instructions.setText(step.instructions) self._step_info.setText('') - self._step_measure.setVisible(step != _CollectionStep.XYZ_SPACE) + if step == _CollectionStep.XYZ_SPACE: + self._step_measure.setText('Sample position') + else: + self._step_measure.setText('Start measurement') self._step_previous_button.setEnabled(step.has_previous()) self._step_next_button.setEnabled(step.has_next()) @@ -339,26 +342,29 @@ def _measure(self): case _CollectionStep.XY_PLANE: self._measure_xy_plane() case _CollectionStep.XYZ_SPACE: - pass + self._measure_xyz_space() def _measure_origin(self): """Measure the origin position""" - # Placeholder for actual measurement logic - logger.info("Measuring origin position...") + logger.debug("Measuring origin position...") self._start_timeout_average_read(self._container.set_origin_sample) def _measure_x_axis(self): """Measure the X-axis position""" - # Placeholder for actual measurement logic - logger.info("Measuring X-axis position...") + logger.debug("Measuring X-axis position...") self._start_timeout_average_read(self._container.set_x_axis_sample) def _measure_xy_plane(self): """Measure the XY-plane position""" - # Placeholder for actual measurement logic - logger.info("Measuring XY-plane position...") + logger.debug("Measuring XY-plane position...") self._start_timeout_average_read(self._container.append_xy_plane_sample) + def _measure_xyz_space(self): + """Measure the XYZ-space position""" + logger.debug("Measuring XYZ-space position...") + self._user_notification_signal.emit(_UserNotificationType.PENDING) + self._matched_reader.start(timeout=1.0) + def _start_timeout_average_read(self, setter: Callable[[LhCfPoseSample], None]): """Start the timeout average angle reader""" self._timeout_reader.start() @@ -423,8 +429,7 @@ def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): self._lighthouse_tab.write_and_store_geometry(geo_dict) def _user_action_detected_cb(self): - self._user_notification_signal.emit(_UserNotificationType.PENDING) - self._matched_reader.start(timeout=1.0) + self._measure_xyz_space() def _single_sample_ready_cb(self, sample: LhCfPoseSample): self._user_notification_signal.emit(_UserNotificationType.SUCCESS) From a58093a0a7d4ad99a1cde469511ad39ba60dffa2 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 3 Jul 2025 15:53:51 +0200 Subject: [PATCH 17/73] Show solution error --- src/cfclient/ui/widgets/geo_estimator.ui | 7 +++++++ src/cfclient/ui/widgets/geo_estimator_widget.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 42254895..ad3aa880 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -224,6 +224,13 @@ + + + + TextLabel + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 888c4ce7..b0bb5865 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -288,9 +288,11 @@ def _update_solution_info(self): if solution.progress_is_ok: self._solution_status_is_ok.setText('Solution is OK') self._solution_status_uploaded.setText('Uploaded') + self._solution_status_max_error.setText(f'Error: {solution.error_stats.max * 1000:.1f} mm') else: self._solution_status_is_ok.setText('No solution') self._solution_status_uploaded.setText('Not uploaded') + self._solution_status_max_error.setText('Error: --') self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) self._solution_status_info.setText(solution.general_failure_info) From 29bef96e24c9d764dfc4e897356bffb3cb5e13aa Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 4 Jul 2025 18:17:42 +0200 Subject: [PATCH 18/73] Added file and session management --- src/cfclient/ui/widgets/geo_estimator.ui | 29 +++++++++- .../ui/widgets/geo_estimator_widget.py | 55 +++++++++++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index ad3aa880..5c8ebdfe 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -261,10 +261,33 @@ - - - Clear all + + + Session management + + + + + Load + + + + + + + Save copy as... + + + + + + + New session + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index b0bb5865..bc798cc8 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -29,8 +29,10 @@ Container for the geometry estimation functionality in the lighthouse tab. """ +import os from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui +from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QMessageBox from PyQt6.QtCore import QTimer @@ -148,6 +150,8 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _user_notification_signal = QtCore.pyqtSignal(object) _solution_ready_signal = QtCore.pyqtSignal(object) + FILE_REGEX_YAML = "Config *.yaml;;All *.*" + def __init__(self, lighthouse_tab): super(GeoEstimatorWidget, self).__init__() self.setupUi(self) @@ -160,6 +164,8 @@ def __init__(self, lighthouse_tab): self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) + self._load_button.clicked.connect(self._load_from_file) + self._save_button.clicked.connect(self._save_to_file) self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) self._timeout_reader_signal.connect(self._average_available_cb) @@ -177,6 +183,8 @@ def __init__(self, lighthouse_tab): timeout_cb=self._single_sample_timeout_cb) self._container = LhGeoInputContainer(LhDeck4SensorPositions.positions) + self._session_path = os.path.join(cfclient.config_path, 'lh_geo_sessions') + self._container.enable_auto_save(self._session_path) self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution() @@ -208,7 +216,7 @@ def setVisible(self, visible: bool): self._solver_thread.stop(do_join=False) self._solver_thread = None - def clear_state(self): + def new_session(self): self._container.clear_all_samples() def _clear_all(self): @@ -219,7 +227,34 @@ def _clear_all(self): button = dlg.exec() if button == QMessageBox.StandardButton.Yes: - self.clear_state() + self.new_session() + + def _load_from_file(self): + names = QFileDialog.getOpenFileName(self, 'Load session', self._session_path, self.FILE_REGEX_YAML) + + if names[0] == '': + return + + file_name = names[0] + with open(file_name, 'r', encoding='UTF8') as handle: + self._container.populate_from_file_yaml(handle) + + def _save_to_file(self): + """Save the current geometry samples to a file""" + names = QFileDialog.getSaveFileName(self, 'Save session', self._helper.current_folder, self.FILE_REGEX_YAML) + + if names[0] == '': + return + + self._helper.current_folder = os.path.dirname(names[0]) + + if not names[0].endswith(".yaml") and names[0].find(".") < 0: + file_name = names[0] + ".yaml" + else: + file_name = names[0] + + with open(file_name, 'w', encoding='UTF8') as handle: + self._container.save_as_yaml_file(handle) def _change_step(self, step): """Update the widget to display the new step""" @@ -256,8 +291,17 @@ def _update_ui_reading(self, is_reading: bool): is_enabled = not is_reading self._step_measure.setEnabled(is_enabled) - self._step_next_button.setEnabled(is_enabled) - self._step_previous_button.setEnabled(is_enabled) + self._step_next_button.setEnabled(is_enabled and self._current_step.has_next()) + self._step_previous_button.setEnabled(is_enabled and self._current_step.has_previous()) + + self._data_status_origin.setEnabled(is_enabled) + self._data_status_x_axis.setEnabled(is_enabled) + self._data_status_xy_plane.setEnabled(is_enabled) + self._data_status_xyz_space.setEnabled(is_enabled) + + self._load_button.setEnabled(is_enabled) + self._save_button.setEnabled(is_enabled) + self._clear_all_button.setEnabled(is_enabled) def _update_solution_info(self): solution = self._latest_solution @@ -312,11 +356,14 @@ def _notify_user(self, notification_type: _UserNotificationType): case _UserNotificationType.SUCCESS: self._helper.cf.platform.send_user_notification(True) self._sample_collection_box.setStyleSheet(STYLE_GREEN_BACKGROUND) + self._update_ui_reading(False) case _UserNotificationType.FAILURE: self._helper.cf.platform.send_user_notification(False) self._sample_collection_box.setStyleSheet(STYLE_RED_BACKGROUND) + self._update_ui_reading(False) case _UserNotificationType.PENDING: self._sample_collection_box.setStyleSheet(STYLE_YELLOW_BACKGROUND) + self._update_ui_reading(True) self._user_notification_clear_timer.stop() self._user_notification_clear_timer.start(1000) From 8138b26c997278e7005200735b7df465cbb2269a Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 8 Jul 2025 12:33:01 +0200 Subject: [PATCH 19/73] Display samples --- src/cfclient/ui/tabs/lighthouse_tab.py | 69 ++++++++++++++++--- .../ui/widgets/geo_estimator_widget.py | 8 +-- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 17068a11..3741d69e 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -47,6 +47,7 @@ from cflib.crazyflie.mem import LighthouseMemHelper from cflib.localization import LighthouseConfigWriter from cflib.localization import LighthouseConfigFileManager +from cflib.localization import LighthouseGeometrySolution from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry @@ -88,6 +89,7 @@ def __init__(self, the_scene, color, text=None): self._scene = the_scene self._color = color self._text = text + self._position = [0.0, 0, 0] self._marker = scene.visuals.Markers( pos=np.array([[0, 0, 0]]), @@ -118,6 +120,10 @@ def __init__(self, the_scene, color, text=None): parent=self._scene) def set_pose(self, position, rot): + if np.array_equal(position, self._position): + return + self._position = position + self._marker.set_data(pos=np.array([position]), face_color=self._color) if self._label: @@ -145,11 +151,35 @@ def set_color(self, color): self._marker.set_data(face_color=self._color) -class Plot3dLighthouse(scene.SceneCanvas): +class CfMarkerPose(MarkerPose): POSITION_BRUSH = np.array((0, 0, 1.0)) + + def __init__(self, the_scene): + super().__init__(the_scene, self.POSITION_BRUSH, None) + + +class BsMarkerPose(MarkerPose): BS_BRUSH_VISIBLE = np.array((0.2, 0.5, 0.2)) BS_BRUSH_NOT_VISIBLE = np.array((0.8, 0.5, 0.5)) + def __init__(self, the_scene, text=None): + super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text) + + def set_visible(self, visible: bool): + if visible: + self.set_color(self.BS_BRUSH_VISIBLE) + else: + self.set_color(self.BS_BRUSH_NOT_VISIBLE) + + +class SampleMarkerPose(MarkerPose): + POSITION_BRUSH = np.array((1.0, 0, 0)) + + def __init__(self, the_scene): + super().__init__(the_scene, self.POSITION_BRUSH, None) + + +class Plot3dLighthouse(scene.SceneCanvas): VICINITY_DISTANCE = 2.5 HIGHLIGHT_DISTANCE = 0.5 @@ -173,6 +203,7 @@ def __init__(self): self._cf = None self._base_stations = {} + self._samples = [] self.freeze() @@ -230,13 +261,13 @@ def _addArrows(self, length, width, head_length, head_width, parent): def update_cf_pose(self, position, rot): if not self._cf: - self._cf = MarkerPose(self._view.scene, self.POSITION_BRUSH) + self._cf = CfMarkerPose(self._view.scene) self._cf.set_pose(position, rot) def update_base_station_geos(self, geos): for id, geo in geos.items(): if (geo is not None) and (id not in self._base_stations): - self._base_stations[id] = MarkerPose(self._view.scene, self.BS_BRUSH_NOT_VISIBLE, text=f"{id + 1}") + self._base_stations[id] = BsMarkerPose(self._view.scene, text=f"{id + 1}") self._base_stations[id].set_pose(geo.origin, geo.rotation_matrix) geos_to_remove = self._base_stations.keys() - geos.keys() @@ -246,10 +277,7 @@ def update_base_station_geos(self, geos): def update_base_station_visibility(self, visibility): for id, bs in self._base_stations.items(): - if id in visibility: - bs.set_color(self.BS_BRUSH_VISIBLE) - else: - bs.set_color(self.BS_BRUSH_NOT_VISIBLE) + bs.set_visible(id in visibility) def clear(self): if self._cf: @@ -259,9 +287,21 @@ def clear(self): for bs in self._base_stations.values(): bs.remove() self._base_stations = {} + self.clear_samples() + + def update_samples(self, solution: LighthouseGeometrySolution): + for i, pose in enumerate(solution.poses.cf_poses): + if i >= len(self._samples): + self._samples.append(SampleMarkerPose(self._view.scene)) + self._samples[i].set_pose(pose.translation, pose.rot_matrix) - def _mix(self, col1, col2, mix): - return col1 * mix + col2 * (1.0 - mix) + for sample in self._samples[len(solution.poses.cf_poses):]: + sample.remove() + + def clear_samples(self): + for sample in self._samples: + sample.remove() + self._samples = [] class UiMode(Enum): @@ -282,7 +322,6 @@ class LighthouseTab(TabToolbox, lighthouse_tab_class): STATUS_MISSING_DATA = 1 STATUS_TO_ESTIMATOR = 2 - # TODO change these names to something more logical LOG_STATUS = "lighthouse.status" LOG_RECEIVE = "lighthouse.bsReceive" LOG_CALIBRATION_EXISTS = "lighthouse.bsCalVal" @@ -306,6 +345,7 @@ def __init__(self, helper): self._geo_estimator_widget = GeoEstimatorWidget(self) self._geometry_area.addWidget(self._geo_estimator_widget) + self._geo_estimator_widget.solution_ready_signal.connect(self._solution_updated_cb) # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it @@ -447,6 +487,9 @@ def _geometry_read_cb(self, geometries): self._basestation_geometry_dialog.geometry_updated(self._lh_geos) self._is_geometry_read_ongoing = False + def _solution_updated_cb(self, solution: LighthouseGeometrySolution): + self._latest_solution = solution + def _is_matching_current_geo_data(self, geometries): return geometries == self._lh_geos.keys() @@ -548,6 +591,12 @@ def _update_graphics(self): self._rpy_to_rot(self._helper.pose_logger.rpy_rad)) self._plot_3d.update_base_station_geos(self._lh_geos) self._plot_3d.update_base_station_visibility(self._bs_data_to_estimator) + + if self._ui_mode == UiMode.geo_estimation: + self._plot_3d.update_samples(self._latest_solution) + else: + self._plot_3d.clear_samples() + self._update_position_label(self._helper.pose_logger.position) self._update_status_label(self._lh_status) self._mask_status_matrix(self._bs_available) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index bc798cc8..e074205d 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -48,7 +48,7 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors -from cflib.localization.lighthouse_types import LhDeck4SensorPositions +from cflib.localization.lighthouse_types import LhDeck4SensorPositions, Pose from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution @@ -148,7 +148,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _timeout_reader_signal = QtCore.pyqtSignal(object) _container_updated_signal = QtCore.pyqtSignal() _user_notification_signal = QtCore.pyqtSignal(object) - _solution_ready_signal = QtCore.pyqtSignal(object) + solution_ready_signal = QtCore.pyqtSignal(object) FILE_REGEX_YAML = "Config *.yaml;;All *.*" @@ -193,7 +193,7 @@ def __init__(self, lighthouse_tab): self._update_ui_reading(False) self._update_solution_info() - self._solution_ready_signal.connect(self._solution_ready_cb) + self.solution_ready_signal.connect(self._solution_ready_cb) self._solver_thread = None self._data_status_origin.clicked.connect(lambda: self._change_step(_CollectionStep.ORIGIN)) @@ -207,7 +207,7 @@ def setVisible(self, visible: bool): if self._solver_thread is None: logger.info("Starting solver thread") self._solver_thread = LhGeoEstimationManager.SolverThread(self._container, - is_done_cb=self._solution_ready_signal.emit) + is_done_cb=self.solution_ready_signal.emit) self._solver_thread.start() else: self._action_detector.stop() From 77e833fa54da7f681a2d8be5737292b2ddb5ad18 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 9 Jul 2025 17:35:52 +0200 Subject: [PATCH 20/73] Make marker axis optional --- src/cfclient/ui/tabs/lighthouse_tab.py | 48 +++++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 3741d69e..91b45719 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -85,7 +85,7 @@ class MarkerPose(): LABEL_SIZE = 100 LABEL_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self, the_scene, color, text=None): + def __init__(self, the_scene, color, text=None, axis_visible=False): self._scene = the_scene self._color = color self._text = text @@ -96,20 +96,25 @@ def __init__(self, the_scene, color, text=None): parent=self._scene, face_color=self._color) - self._x_axis = scene.visuals.Line( - pos=np.array([[0, 0, 0], [0, 0, 0]]), - color=self.COL_X_AXIS, - parent=self._scene) + if axis_visible: + self._x_axis = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.COL_X_AXIS, + parent=self._scene) - self._y_axis = scene.visuals.Line(pos=np.array( - [[0, 0, 0], [0, 0, 0]]), - color=self.COL_Y_AXIS, - parent=self._scene) + self._y_axis = scene.visuals.Line(pos=np.array( + [[0, 0, 0], [0, 0, 0]]), + color=self.COL_Y_AXIS, + parent=self._scene) - self._z_axis = scene.visuals.Line( - pos=np.array([[0, 0, 0], [0, 0, 0]]), - color=self.COL_Z_AXIS, - parent=self._scene) + self._z_axis = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.COL_Z_AXIS, + parent=self._scene) + else: + self._x_axis = None + self._y_axis = None + self._z_axis = None self._label = None if self._text: @@ -129,14 +134,15 @@ def set_pose(self, position, rot): if self._label: self._label.pos = self.LABEL_OFFSET + position - x_tip = np.dot(np.array(rot), np.array([self.AXIS_LEN, 0, 0])) - self._x_axis.set_data(np.array([position, x_tip + position]), color=self.COL_X_AXIS) + if self._x_axis: + x_tip = np.dot(np.array(rot), np.array([self.AXIS_LEN, 0, 0])) + self._x_axis.set_data(np.array([position, x_tip + position]), color=self.COL_X_AXIS) - y_tip = np.dot(np.array(rot), np.array([0, self.AXIS_LEN, 0])) - self._y_axis.set_data(np.array([position, y_tip + position]), color=self.COL_Y_AXIS) + y_tip = np.dot(np.array(rot), np.array([0, self.AXIS_LEN, 0])) + self._y_axis.set_data(np.array([position, y_tip + position]), color=self.COL_Y_AXIS) - z_tip = np.dot(np.array(rot), np.array([0, 0, self.AXIS_LEN])) - self._z_axis.set_data(np.array([position, z_tip + position]), color=self.COL_Z_AXIS) + z_tip = np.dot(np.array(rot), np.array([0, 0, self.AXIS_LEN])) + self._z_axis.set_data(np.array([position, z_tip + position]), color=self.COL_Z_AXIS) def remove(self): self._marker.parent = None @@ -155,7 +161,7 @@ class CfMarkerPose(MarkerPose): POSITION_BRUSH = np.array((0, 0, 1.0)) def __init__(self, the_scene): - super().__init__(the_scene, self.POSITION_BRUSH, None) + super().__init__(the_scene, self.POSITION_BRUSH, None, axis_visible=True) class BsMarkerPose(MarkerPose): @@ -163,7 +169,7 @@ class BsMarkerPose(MarkerPose): BS_BRUSH_NOT_VISIBLE = np.array((0.8, 0.5, 0.5)) def __init__(self, the_scene, text=None): - super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text) + super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text, axis_visible=True) def set_visible(self, visible: bool): if visible: From 9dfd90d57eff00b184c8578a68f5dddac1d293c4 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 9 Jul 2025 17:44:00 +0200 Subject: [PATCH 21/73] Added button for loading geo as well as restore session --- src/cfclient/ui/widgets/geo_estimator.ui | 51 ++++++++++++------- .../ui/widgets/geo_estimator_widget.py | 12 +++-- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 5c8ebdfe..57326c9e 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -265,27 +265,42 @@ Session management - + - - - Load - - - - - - - Save copy as... - - + + + + + Load + + + + + + + Save as... + + + + - - - New session - - + + + + + Restore sesssion + + + + + + + New session + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index e074205d..3c781924 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -164,7 +164,8 @@ def __init__(self, lighthouse_tab): self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) - self._load_button.clicked.connect(self._load_from_file) + self._load_button.clicked.connect(lambda: self._load_from_file(use_session_path=False)) + self._restore_button.clicked.connect(lambda: self._load_from_file(use_session_path=True)) self._save_button.clicked.connect(self._save_to_file) self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) @@ -229,12 +230,17 @@ def _clear_all(self): if button == QMessageBox.StandardButton.Yes: self.new_session() - def _load_from_file(self): - names = QFileDialog.getOpenFileName(self, 'Load session', self._session_path, self.FILE_REGEX_YAML) + def _load_from_file(self, use_session_path=False): + path = self._session_path if use_session_path else self._helper.current_folder + names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) if names[0] == '': return + if not use_session_path: + # If not using the session path, update the current folder + self._helper.current_folder = os.path.dirname(names[0]) + file_name = names[0] with open(file_name, 'r', encoding='UTF8') as handle: self._container.populate_from_file_yaml(handle) From 26863ad42dbe353dac01de74e7072cb4027fc3df Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 9 Jul 2025 17:59:45 +0200 Subject: [PATCH 22/73] Clarified text --- src/cfclient/ui/widgets/geo_estimator_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 3c781924..500a284d 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -85,7 +85,8 @@ class _CollectionStep(Enum): 'Sample points in the space that will be used.\n' + 'Make sure all the base stations are received, you need at least two base \n' + 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + - 'left-right around the Z-axis and then holding it still for a second.\n') + 'left-right around the Z-axis and then holding it still for a second, or \n' + + 'optionally by clicking the sample button below.\n') def __init__(self, image, title, instructions): self.image = image From ed2efd1facb13cbb97cd33631dae5f7a0e34641e Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 14 Jul 2025 16:43:57 +0200 Subject: [PATCH 23/73] Visualize sample positions and add list for samples --- src/cfclient/ui/tabs/lighthouse_tab.py | 93 ++- src/cfclient/ui/widgets/geo_estimator.ui | 543 +++++++++--------- .../ui/widgets/geo_estimator_widget.py | 95 ++- .../lighthouse_geo_bs_estimation_wizard.py | 2 +- 4 files changed, 438 insertions(+), 295 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 91b45719..17df1fec 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -144,17 +144,23 @@ def set_pose(self, position, rot): z_tip = np.dot(np.array(rot), np.array([0, 0, self.AXIS_LEN])) self._z_axis.set_data(np.array([position, z_tip + position]), color=self.COL_Z_AXIS) + def get_position(self): + return self._position + def remove(self): self._marker.parent = None - self._x_axis.parent = None - self._y_axis.parent = None - self._z_axis.parent = None + if self._x_axis is not None: + self._x_axis.parent = None + if self._y_axis is not None: + self._y_axis.parent = None + if self._z_axis is not None: + self._z_axis.parent = None if self._label: self._label.parent = None def set_color(self, color): self._color = color - self._marker.set_data(face_color=self._color) + self._marker.set_data(pos=np.array([self._position]), face_color=self._color) class CfMarkerPose(MarkerPose): @@ -171,7 +177,7 @@ class BsMarkerPose(MarkerPose): def __init__(self, the_scene, text=None): super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text, axis_visible=True) - def set_visible(self, visible: bool): + def set_receiving_status(self, visible: bool): if visible: self.set_color(self.BS_BRUSH_VISIBLE) else: @@ -179,11 +185,49 @@ def set_visible(self, visible: bool): class SampleMarkerPose(MarkerPose): - POSITION_BRUSH = np.array((1.0, 0, 0)) + NORMAL_BRUSH = np.array((0.8, 0.8, 0.8)) + HIGHLIGHT_BRUSH = np.array((0.2, 0.2, 0.2)) + BS_LINE_COL = np.array((0.0, 0.0, 0.0)) def __init__(self, the_scene): - super().__init__(the_scene, self.POSITION_BRUSH, None) + super().__init__(the_scene, self.NORMAL_BRUSH, None) + self._is_highlighted = False + self._bs_lines = [] + + def set_highlighted(self, highlighted: bool, bs_positions=[]): + if highlighted: + # always update lines when highlighted as base station positions may have changed + self.set_color(self.HIGHLIGHT_BRUSH) + for i, pos in enumerate(bs_positions): + if i >= len(self._bs_lines): + line = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.BS_LINE_COL, + parent=self._scene) + self._bs_lines.append(line) + else: + line = self._bs_lines[i] + + line.set_data(np.array([self._position, pos]), color=self.BS_LINE_COL) + + for _ in range(len(self._bs_lines) - len(bs_positions)): + line = self._bs_lines.pop() + line.parent = None + else: + if highlighted != self._is_highlighted: + self.set_color(self.NORMAL_BRUSH) + self._clear_lines() + self._is_highlighted = highlighted + + def remove(self): + super().remove() + self._clear_lines() + + def _clear_lines(self): + for line in self._bs_lines: + line.parent = None + self._bs_lines = [] class Plot3dLighthouse(scene.SceneCanvas): VICINITY_DISTANCE = 2.5 @@ -210,6 +254,7 @@ def __init__(self): self._cf = None self._base_stations = {} self._samples = [] + self.selected_sample_index = -1 self.freeze() @@ -283,7 +328,7 @@ def update_base_station_geos(self, geos): def update_base_station_visibility(self, visibility): for id, bs in self._base_stations.items(): - bs.set_visible(id in visibility) + bs.set_receiving_status(id in visibility) def clear(self): if self._cf: @@ -296,12 +341,27 @@ def clear(self): self.clear_samples() def update_samples(self, solution: LighthouseGeometrySolution): - for i, pose in enumerate(solution.poses.cf_poses): - if i >= len(self._samples): - self._samples.append(SampleMarkerPose(self._view.scene)) - self._samples[i].set_pose(pose.translation, pose.rot_matrix) - - for sample in self._samples[len(solution.poses.cf_poses):]: + marker_idx = 0 + for smpl_idx, pose_smpl in enumerate(solution.samples): + if pose_smpl.is_valid: + pose = pose_smpl.pose + if marker_idx >= len(self._samples): + self._samples.append(SampleMarkerPose(self._view.scene)) + + self._samples[marker_idx].set_pose(pose.translation, pose.rot_matrix) + + if smpl_idx == self.selected_sample_index: + bs_positions = [] + for id in pose_smpl.base_station_ids: + if id in self._base_stations: + bs_positions.append(self._base_stations[id].get_position()) + self._samples[marker_idx].set_highlighted(True, bs_positions=bs_positions) + else: + self._samples[marker_idx].set_highlighted(False) + + marker_idx += 1 + + for sample in self._samples[marker_idx:]: sample.remove() def clear_samples(self): @@ -352,6 +412,7 @@ def __init__(self, helper): self._geo_estimator_widget = GeoEstimatorWidget(self) self._geometry_area.addWidget(self._geo_estimator_widget) self._geo_estimator_widget.solution_ready_signal.connect(self._solution_updated_cb) + self._geo_estimator_widget.sample_selection_changed_signal.connect(self._sample_selection_changed_cb) # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it @@ -496,6 +557,10 @@ def _geometry_read_cb(self, geometries): def _solution_updated_cb(self, solution: LighthouseGeometrySolution): self._latest_solution = solution + def _sample_selection_changed_cb(self, sample_index: int): + """Callback when the selected sample in the geo estimator widget changes""" + self._plot_3d.selected_sample_index = sample_index + def _is_matching_current_geo_data(self, geometries): return geometries == self._lh_geos.keys() diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 57326c9e..349c121f 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -6,10 +6,16 @@ 0 0 - 400 + 702 753 + + + 0 + 0 + + Form @@ -27,284 +33,291 @@ - - - - - - Sample collection - - - - - - TextLabel - - - - - - - - - - Image - - - - - - - TextLabel - - - - - - - TextLabel - - - - - - - - 0 - 0 - - - - Start measurement - - - - - - - QFrame::Panel - - - TextLabel - - - - - - - Qt::Horizontal - - - - - - - - - - 0 - 0 - - - - Previous - - - - - - - - 0 - 0 - - - - Next - - - - - - - + - + - + - + 0 0 - - Data status - - - - - - - 0 - 0 - - - - - - - Origin - - - false - - - - - - - - - - X-axis - - - false - - - - - - - XY-plane - - - false - - - - - - - XYZ-space - - - false - - - - - - - - 0 - 0 - - - - Solution status - - - - - - TextLabel - - - - - - - TextLabel - - - - - - - TextLabel - - - - - - - TextLabel - - - - - + + + + + + + + Sample collection + + + + + + TextLabel + + + + + + + + + + Image + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + + 0 + 0 + + + + Start measurement + + + + + + + QFrame::Panel + + + TextLabel + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Previous + + + + + + + + 0 + 0 + + + + Next + + + + + + + + + + + + + 0 + 0 + + + + Data status + + + + + + + 0 + 0 + + + + + + + Origin + + + false + + + + + + + + + + X-axis + + + false + + + + + + + XY-plane + + + false + + + + + + + XYZ-space + + + false + + + + + + + + + + + 0 + 0 + + + + Solution status + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + + + + Show sample details + + + + + + + Session management + + + + + + + + Load + + + + + + + Save as... + + + + + + + + + + + Restore sesssion + + + + + + + New session + + + + + + + + + - - - - Base station links - - - - - - qwe - - - - - - - - - - - Session management - - - - - - - - Load - - - - - - - Save as... - - - - - - - - - - - Restore sesssion - - - - - - - New session - - - - - - - - diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 500a284d..b582036e 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -34,7 +34,7 @@ from PyQt6 import QtCore, QtWidgets, uic, QtGui from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QMessageBox -from PyQt6.QtCore import QTimer +from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex import logging @@ -48,8 +48,8 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors -from cflib.localization.lighthouse_types import LhDeck4SensorPositions, Pose -from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample +from cflib.localization.lighthouse_types import LhDeck4SensorPositions +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample, LhCfPoseSampleStatus from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution from cflib.localization.user_action_detector import UserActionDetector @@ -150,6 +150,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _container_updated_signal = QtCore.pyqtSignal() _user_notification_signal = QtCore.pyqtSignal(object) solution_ready_signal = QtCore.pyqtSignal(object) + sample_selection_changed_signal = QtCore.pyqtSignal(int) FILE_REGEX_YAML = "Config *.yaml;;All *.*" @@ -188,7 +189,7 @@ def __init__(self, lighthouse_tab): self._session_path = os.path.join(cfclient.config_path, 'lh_geo_sessions') self._container.enable_auto_save(self._session_path) - self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution() + self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution([]) self._current_step = _CollectionStep.ORIGIN self._update_step_ui() @@ -203,6 +204,23 @@ def __init__(self, lighthouse_tab): self._data_status_xy_plane.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) + self._samples_details_model = SampleTableModel(self) + self._samples_table_view.setModel(self._samples_details_model) + self._samples_table_view.selectionModel().currentRowChanged.connect(self._selection_changed) + + header = self._samples_table_view.horizontalHeader() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + + self._sample_details_checkbox.setChecked(False) + self._samples_table_view.setVisible(False) + self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) + + def _selection_changed(self, current: QModelIndex, previous: QModelIndex): + self.sample_selection_changed_signal.emit(current.row()) + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -263,6 +281,10 @@ def _save_to_file(self): with open(file_name, 'w', encoding='UTF8') as handle: self._container.save_as_yaml_file(handle) + def _sample_details_checkbox_state_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._samples_table_view.setVisible(enabled) + def _change_step(self, step): """Update the widget to display the new step""" if step != self._current_step: @@ -348,16 +370,6 @@ def _update_solution_info(self): self._solution_status_info.setText(solution.general_failure_info) - link_info = '' - for bs, link_map in solution.link_count.items(): - count = len(link_map) - if count > 0: - seen_bs = ', '.join(map(lambda x: str(x + 1), link_map.keys())) - link_info += f'Base station {bs + 1}: {count} link(s), id {seen_bs}\n' - else: - link_info += f'Base station {bs + 1}: No link\n' - self._bs_link_text.setPlainText(link_info) - def _notify_user(self, notification_type: _UserNotificationType): match notification_type: case _UserNotificationType.SUCCESS: @@ -469,8 +481,10 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): logger.debug(f'XYZ space: {solution.xyz_space_samples_info}') logger.debug(f'General info: {solution.general_failure_info}') + self._samples_details_model.setSolution(self._latest_solution) + if solution.progress_is_ok: - self._upload_geometry(solution.poses.bs_poses) + self._upload_geometry(solution.bs_poses) def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): geo_dict = {} @@ -547,3 +561,54 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec result = LhCfPoseSample(angles_calibrated) self._ready_cb(result) + +class SampleTableModel(QAbstractTableModel): + def __init__(self, parent=None, *args): + QAbstractTableModel.__init__(self, parent) + self._headers = ['Type', 'X', 'Y', 'Z', 'Error'] + self._table_values = [] + + def rowCount(self, parent=None, *args, **kwargs): + return len(self._table_values) + + def columnCount(self, parent=None, *args, **kwargs): + return len(self._headers) + + def data(self, index, role=None): + if index.isValid(): + value = self._table_values[index.row()][index.column()] + if role == Qt.ItemDataRole.DisplayRole: + return QVariant(value) + + return QVariant() + + def headerData(self, col, orientation, role=None): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return QVariant(self._headers[col]) + return QVariant() + + def setSolution(self, solution: LighthouseGeometrySolution): + """Set the solution and update the table values""" + self.beginResetModel() + self._table_values = [] + + for sample in solution.samples: + if sample.status == LhCfPoseSampleStatus.OK: + error = f'{sample.error_distance * 1000:.1f} mm' + pose = sample.pose + x = f'{pose.translation[0]:.2f}' + y = f'{pose.translation[1]:.2f}' + z = f'{pose.translation[2]:.2f}' + else: + error = f'{sample.status}' + x = y = z = '--' + + self._table_values.append([ + f'{sample.sample_type}', + x, + y, + z, + error, + ]) + + self.endResetModel() diff --git a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py index c99543f4..1cb4d63c 100644 --- a/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +++ b/src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py @@ -113,7 +113,7 @@ def solution_handler(self, solution: LighthouseGeometrySolution): # Upload the geometry to the Crazyflie geo_dict = {} - for bs_id, pose in solution.poses.bs_poses.items(): + for bs_id, pose in solution.bs_poses.items(): geo = LighthouseBsGeometry() geo.origin = pose.translation.tolist() geo.rotation_matrix = pose.rot_matrix.tolist() From ade17975386e1813ae749e485f160089eb1318b9 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 13:34:03 +0200 Subject: [PATCH 24/73] Added sample delete buttons --- src/cfclient/ui/tabs/lighthouse_tab.py | 3 ++- .../ui/widgets/geo_estimator_widget.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 17df1fec..1c3c7bc2 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -343,7 +343,7 @@ def clear(self): def update_samples(self, solution: LighthouseGeometrySolution): marker_idx = 0 for smpl_idx, pose_smpl in enumerate(solution.samples): - if pose_smpl.is_valid: + if pose_smpl.has_pose: pose = pose_smpl.pose if marker_idx >= len(self._samples): self._samples.append(SampleMarkerPose(self._view.scene)) @@ -363,6 +363,7 @@ def update_samples(self, solution: LighthouseGeometrySolution): for sample in self._samples[marker_idx:]: sample.remove() + del self._samples[marker_idx:] def clear_samples(self): for sample in self._samples: diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index b582036e..c67f5ee3 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -33,7 +33,7 @@ from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui from PyQt6.QtWidgets import QFileDialog -from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QMessageBox, QPushButton from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex @@ -483,6 +483,12 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): self._samples_details_model.setSolution(self._latest_solution) + # Add delete buttons + for row in range(len(solution.samples)): + button = QPushButton('Delete') + button.clicked.connect(lambda _, r=row: self._container.remove_sample(r)) + self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, 5), button) + if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -565,7 +571,7 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) - self._headers = ['Type', 'X', 'Y', 'Z', 'Error'] + self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Action'] self._table_values = [] def rowCount(self, parent=None, *args, **kwargs): @@ -574,11 +580,12 @@ def rowCount(self, parent=None, *args, **kwargs): def columnCount(self, parent=None, *args, **kwargs): return len(self._headers) - def data(self, index, role=None): + def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: if index.isValid(): - value = self._table_values[index.row()][index.column()] if role == Qt.ItemDataRole.DisplayRole: - return QVariant(value) + if index.column() < len(self._table_values[index.row()]): + value = self._table_values[index.row()][index.column()] + return QVariant(value) return QVariant() @@ -593,7 +600,7 @@ def setSolution(self, solution: LighthouseGeometrySolution): self._table_values = [] for sample in solution.samples: - if sample.status == LhCfPoseSampleStatus.OK: + if sample.is_valid and sample.has_pose: error = f'{sample.error_distance * 1000:.1f} mm' pose = sample.pose x = f'{pose.translation[0]:.2f}' From 3816a28b95273d69307419d688414d52a21f6917 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 14:15:09 +0200 Subject: [PATCH 25/73] Added color to table --- .../ui/widgets/geo_estimator_widget.py | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index c67f5ee3..152e826b 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -568,11 +568,18 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec result = LhCfPoseSample(angles_calibrated) self._ready_cb(result) + +class _TableRowStatus(Enum): + OK = 0 + INVALID = 1 + LARGE_ERROR = 2 + class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Action'] self._table_values = [] + self._table_highlights: list[_TableRowStatus] = [] def rowCount(self, parent=None, *args, **kwargs): return len(self._table_values) @@ -587,6 +594,13 @@ def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: value = self._table_values[index.row()][index.column()] return QVariant(value) + if role == Qt.ItemDataRole.BackgroundRole: + if self._table_highlights[index.row()] == _TableRowStatus.INVALID: + return QVariant(QtGui.QBrush(Qt.GlobalColor.gray)) + elif self._table_highlights[index.row()] == _TableRowStatus.LARGE_ERROR: + if index.column() == 4: + return QVariant(QtGui.QBrush(Qt.GlobalColor.red)) + return QVariant() def headerData(self, col, orientation, role=None): @@ -598,17 +612,26 @@ def setSolution(self, solution: LighthouseGeometrySolution): """Set the solution and update the table values""" self.beginResetModel() self._table_values = [] + self._table_highlights = [] for sample in solution.samples: - if sample.is_valid and sample.has_pose: - error = f'{sample.error_distance * 1000:.1f} mm' - pose = sample.pose - x = f'{pose.translation[0]:.2f}' - y = f'{pose.translation[1]:.2f}' - z = f'{pose.translation[2]:.2f}' + status = _TableRowStatus.OK + x = y = z = '--' + error = '--' + + if sample.is_valid: + if sample.has_pose: + error = f'{sample.error_distance * 1000:.1f} mm' + pose = sample.pose + x = f'{pose.translation[0]:.2f}' + y = f'{pose.translation[1]:.2f}' + z = f'{pose.translation[2]:.2f}' + + if sample.is_error_large: + status = _TableRowStatus.LARGE_ERROR else: error = f'{sample.status}' - x = y = z = '--' + status = _TableRowStatus.INVALID self._table_values.append([ f'{sample.sample_type}', @@ -617,5 +640,6 @@ def setSolution(self, solution: LighthouseGeometrySolution): z, error, ]) + self._table_highlights.append(status) self.endResetModel() From 129c048397d8fa97ca70141aa03a923f57f22d25 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 14:28:42 +0200 Subject: [PATCH 26/73] Styling table --- src/cfclient/ui/widgets/geo_estimator.ui | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 349c121f..55de4e8c 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -40,11 +40,26 @@ - + 0 0 + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + false + From 0d1e73282736b24fe6216b1e4a5fae15ef0ca679 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 14:51:32 +0200 Subject: [PATCH 27/73] Added feedback that the solver is running --- .../ui/widgets/geo_estimator_widget.py | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 152e826b..4b326212 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -141,6 +141,7 @@ class _UserNotificationType(Enum): STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" STYLE_RED_BACKGROUND = "background-color: lightpink;" STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" +STYLE_NO_BACKGROUND = "background-color: ;" class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): @@ -149,6 +150,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _timeout_reader_signal = QtCore.pyqtSignal(object) _container_updated_signal = QtCore.pyqtSignal() _user_notification_signal = QtCore.pyqtSignal(object) + start_solving_signal = QtCore.pyqtSignal() solution_ready_signal = QtCore.pyqtSignal(object) sample_selection_changed_signal = QtCore.pyqtSignal(int) @@ -190,15 +192,17 @@ def __init__(self, lighthouse_tab): self._container.enable_auto_save(self._session_path) self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution([]) - self._current_step = _CollectionStep.ORIGIN - self._update_step_ui() - self._update_ui_reading(False) - self._update_solution_info() + self.start_solving_signal.connect(self._start_solving_cb) self.solution_ready_signal.connect(self._solution_ready_cb) + self._is_solving = False self._solver_thread = None + self._update_step_ui() + self._update_ui_reading(False) + self._update_solution_info() + self._data_status_origin.clicked.connect(lambda: self._change_step(_CollectionStep.ORIGIN)) self._data_status_x_axis.clicked.connect(lambda: self._change_step(_CollectionStep.X_AXIS)) self._data_status_xy_plane.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) @@ -227,7 +231,9 @@ def setVisible(self, visible: bool): if self._solver_thread is None: logger.info("Starting solver thread") self._solver_thread = LhGeoEstimationManager.SolverThread(self._container, - is_done_cb=self.solution_ready_signal.emit) + is_done_cb=self.solution_ready_signal.emit, + is_starting_estimation_cb=( + self.start_solving_signal.emit)) self._solver_thread.start() else: self._action_detector.stop() @@ -358,15 +364,19 @@ def _update_solution_info(self): self._set_background_color(self._data_status_x_axis, solution.is_x_axis_samples_valid) self._set_background_color(self._data_status_xy_plane, solution.is_xy_plane_samples_valid) - if solution.progress_is_ok: - self._solution_status_is_ok.setText('Solution is OK') - self._solution_status_uploaded.setText('Uploaded') - self._solution_status_max_error.setText(f'Error: {solution.error_stats.max * 1000:.1f} mm') + if self._is_solving: + self._solution_status_is_ok.setText('Solving... please wait') + self._set_background_none(self._solution_status_is_ok) else: - self._solution_status_is_ok.setText('No solution') - self._solution_status_uploaded.setText('Not uploaded') - self._solution_status_max_error.setText('Error: --') - self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) + if solution.progress_is_ok: + self._solution_status_is_ok.setText('Solution is OK') + self._solution_status_uploaded.setText('Uploaded') + self._solution_status_max_error.setText(f'Error: {solution.error_stats.max * 1000:.1f} mm') + else: + self._solution_status_is_ok.setText('No solution') + self._solution_status_uploaded.setText('Not uploaded') + self._solution_status_max_error.setText('Error: --') + self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) self._solution_status_info.setText(solution.general_failure_info) @@ -390,6 +400,9 @@ def _notify_user(self, notification_type: _UserNotificationType): def _user_notification_clear(self): self._sample_collection_box.setStyleSheet('') + def _set_background_none(self, widget: QtWidgets.QWidget): + widget.setStyleSheet(STYLE_NO_BACKGROUND) + def _set_background_color(self, widget: QtWidgets.QWidget, is_valid: bool): """Set the background color of a widget based on validity""" if is_valid: @@ -467,7 +480,12 @@ def _average_available_cb(self, sample: LhCfPoseSample): self._timeout_reader_result_setter = None + def _start_solving_cb(self): + self._is_solving = True + self._update_solution_info() + def _solution_ready_cb(self, solution: LighthouseGeometrySolution): + self._is_solving = False self._latest_solution = solution self._update_solution_info() From 122fe2a8e3fe61ee032cb31f7fc62e12fab0d049 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 15:07:22 +0200 Subject: [PATCH 28/73] Support frequent geo uploads --- src/cfclient/ui/tabs/lighthouse_tab.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 1c3c7bc2..12a9cca3 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -481,18 +481,26 @@ def __init__(self, helper): self._is_connected = False self._update_ui() + self._pending_geo_update = None + def write_and_store_geometry(self, geometries: dict[int, LighthouseBsGeometry]): - # TODO krri Handle repeated quick writes. This is called from the geo wizard and write_and_store_config() will - # throw if there is an ongoing write if self._lh_config_writer: - self._lh_config_writer.write_and_store_config(self._new_system_config_written_to_cf_signal.emit, - geos=geometries) + if self._lh_config_writer.is_write_ongoing: + self._pending_geo_update = geometries + else: + self._lh_config_writer.write_and_store_config(self._new_system_config_written_to_cf_signal.emit, + geos=geometries) def _new_system_config_written_to_cf(self, success): - # Reset the bit fields for calibration data status to get a fresh view - self._helper.cf.param.set_value("lighthouse.bsCalibReset", '1') - # New geo data has been written and stored in the CF, read it back to update the UI - self._start_read_of_geo_data() + if self._pending_geo_update: + # If there is a pending update, write it now + self.write_and_store_geometry(self._pending_geo_update) + self._pending_geo_update = None + else: + # Reset the bit fields for calibration data status to get a fresh view + self._helper.cf.param.set_value("lighthouse.bsCalibReset", '1') + # New geo data has been written and stored in the CF, read it back to update the UI + self._start_read_of_geo_data() def _show_basestation_geometry_dialog(self): self._basestation_geometry_dialog.reset() From 99f94c28c8f150ea628651456218f0af3e689f4e Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 15:24:33 +0200 Subject: [PATCH 29/73] Removed old geo estimation wizard --- .../dialogs/lighthouse_bs_geometry_dialog.py | 17 - .../dialogs/lighthouse_bs_geometry_dialog.ui | 7 - src/cfclient/ui/wizards/__init__.py | 0 src/cfclient/ui/wizards/bslh_1.png | Bin 6677 -> 0 bytes src/cfclient/ui/wizards/bslh_2.png | Bin 9105 -> 0 bytes src/cfclient/ui/wizards/bslh_3.png | Bin 8838 -> 0 bytes src/cfclient/ui/wizards/bslh_4.png | Bin 13664 -> 0 bytes src/cfclient/ui/wizards/bslh_5.png | Bin 7743 -> 0 bytes .../lighthouse_geo_bs_estimation_wizard.py | 340 ------------------ 9 files changed, 364 deletions(-) delete mode 100644 src/cfclient/ui/wizards/__init__.py delete mode 100644 src/cfclient/ui/wizards/bslh_1.png delete mode 100644 src/cfclient/ui/wizards/bslh_2.png delete mode 100644 src/cfclient/ui/wizards/bslh_3.png delete mode 100644 src/cfclient/ui/wizards/bslh_4.png delete mode 100644 src/cfclient/ui/wizards/bslh_5.png delete mode 100644 src/cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py diff --git a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py index bc6c84a6..5206d109 100644 --- a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py +++ b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py @@ -34,7 +34,6 @@ from cflib.localization import LighthouseBsGeoEstimator from cflib.localization import LighthouseSweepAngleAverageReader from cflib.crazyflie.mem import LighthouseBsGeometry -from cfclient.ui.wizards.lighthouse_geo_bs_estimation_wizard import LighthouseBasestationGeometryWizard __author__ = 'Bitcraze AB' __all__ = ['LighthouseBsGeometryDialog'] @@ -129,7 +128,6 @@ def set_current_geos(self, geos): class LighthouseBsGeometryDialog(QtWidgets.QWidget, basestation_geometry_widget_class): _sweep_angles_received_and_averaged_signal = pyqtSignal(object) - _base_station_geometery_received_signal = pyqtSignal(object) def __init__(self, lighthouse_tab, *args): super(LighthouseBsGeometryDialog, self).__init__(*args) @@ -137,7 +135,6 @@ def __init__(self, lighthouse_tab, *args): self._lighthouse_tab = lighthouse_tab - self._estimate_geometry_button.clicked.connect(self._estimate_geometry_button_clicked) self._simple_estimator = LighthouseBsGeoEstimator() self._estimate_geometry_simple_button.clicked.connect(self._estimate_geometry_simple_button_clicked) try: @@ -149,15 +146,11 @@ def __init__(self, lighthouse_tab, *args): self._write_to_cf_button.clicked.connect(self._write_to_cf_button_clicked) self._sweep_angles_received_and_averaged_signal.connect(self._sweep_angles_received_and_averaged_cb) - self._base_station_geometery_received_signal.connect(self._basestation_geometry_received_signal_cb) self._close_button.clicked.connect(self.close) self._sweep_angle_reader = LighthouseSweepAngleAverageReader( self._lighthouse_tab._helper.cf, self._sweep_angles_received_and_averaged_signal.emit) - self._base_station_geometry_wizard = LighthouseBasestationGeometryWizard( - self._lighthouse_tab, self._base_station_geometery_received_signal.emit) - self._lh_geos = None self._newly_estimated_geometry = {} @@ -180,11 +173,6 @@ def reset(self): self._newly_estimated_geometry = {} self._update_ui() - def _basestation_geometry_received_signal_cb(self, basestation_geometries): - self._newly_estimated_geometry = basestation_geometries - self.show() - self._update_ui() - def _sweep_angles_received_and_averaged_cb(self, averaged_angles): self._averaged_angles = averaged_angles self._newly_estimated_geometry = {} @@ -200,11 +188,6 @@ def _sweep_angles_received_and_averaged_cb(self, averaged_angles): self._update_ui() - def _estimate_geometry_button_clicked(self): - self._base_station_geometry_wizard.reset() - self._base_station_geometry_wizard.show() - self.hide() - def _estimate_geometry_simple_button_clicked(self): self._sweep_angle_reader.start_angle_collection() self._update_ui() diff --git a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui index 86fb1e6d..ffc97391 100644 --- a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui +++ b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui @@ -21,13 +21,6 @@ - - - - Estimate Geometry - - - diff --git a/src/cfclient/ui/wizards/__init__.py b/src/cfclient/ui/wizards/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/cfclient/ui/wizards/bslh_1.png b/src/cfclient/ui/wizards/bslh_1.png deleted file mode 100644 index 0b1d1f75443558966c82c9b5ae35857148bf29b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6677 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 diff --git a/src/cfclient/ui/wizards/bslh_2.png b/src/cfclient/ui/wizards/bslh_2.png deleted file mode 100644 index 56dfb5fb66c7038b8af662773b4e0121e44694d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9105 zcmd6NXH-*7)b0s26a_^DktQfbx`Kd6Q;LKp9qB~@=~a3X1f_$%fOJq0RJyd#0s$!^ zy>~(p0t5(134{c0-h2Puwch*huJ!$xb=K^eIcxUbXZCsaGduQ~p5`Tb4tf9pE@?eg zGXww%H~@fDz%*ou=)l0g19aX`pZfs-!_|KqXmkNe1OQ$@OHI`{C}(FuKjsT2muC+% zlQAGJ|MZ^x?PxWz;7WUo`diWOzfqflwH|o#i2jpeLG|bTz{>Hh=_u+t&j(cV4q7OO z$PjbiUmPpfBRH!CwzPN~r@zrMHg+27`y!m|dOJG)MmZBhdvN2%0cK`_CP>^_n9{RD zfBk?XOD}Q{Pg1<_6}|%khLda$EGU6mb$|+RNdUmWFgu7D_#{Qa3#d3!sQ~b9Fj=L< zD*y=K;0GuGX^sEE6PK^bV~`;=$w0CjCx&F8Ib#SsZ3Mu~ESj+U26g2;T-|(-iGOQr`S(JdZ8ufp>tb^5e zF!cBeV8r*I-YcX2*A}e*4`-WIU6%xm{xAP$3c~u19joG zjtUApdQoaxMR^PdG06xrG%zE9OEbK@BLQ^l0a46_;mS=8FmMErkOzQ2KR75(wXt%5 zwS19hhfCcDNA;G56MP4IKx60;Ji)MXHjuf! z^1(r#jcIQDoyFJdQ60hfg;+^*lBw_-Q;QLhL9dHSfG}=+YxnZ9VZpuR;}j1?$9vX# zN#CSIy#T`#9+!=TGMhBAgzp1(0a@@HwP2{`k3`J?CFM$XU_ODKH}dug2t@$H86k)J z3P~d^Z+L*R2;diQ=eM~Ob7%asJxchI6ceu{>%j?E&&GUBMzQ({C0xc3^unzmAMHcc zwvZ7*4Vd2ty-)0u$7$@%#hNG#w$KAr@>%eiZ#jNkGiN^Kgf3r?NG6z+BsC!Lg!+AA zOo{Cj0+;sE3BE7x{$!P%D}WZtIDK6@oV|S9jm7-lkKjgwH9Aqv!<3&pzWpB#LsZfj@<55Sl% zVCN~SamrnsdKn`8^;igC%m#k_4k3}Y)Otf{LI9K;7+!X}!&VmMXqF-YBt$F0tt`v7 z3`)Z>QeE+Tv;aRJ;460J+;t82CDnJhW3lG{eD>9~?3iSLL-5?m+)I3Io}37z2&Q62#1Pvht6C7q^y~fZ|>YRR0|!2<87B_{;dO zKo|HUOx~_eKJbf${H#?4{`@dsUX@!vWjP1zI;d0#ea)GFZU7v-zyom%F)XT$f7wgP z4MZs^Lt*zbWEoeR>N`(32jHe)xJM~#TXa!s5V?2?Ug10dBJJLzSNjBTXTVF5xghCBBS${yqDl0p>*E!3HHTdk1)M z8)KzOv|&F40Ur118?MM>3R4Zs0q*)%3E)!z0eHYGa8D^g26~A={ylsL^sElg6zaMq zfZ-8Z@0G(dO2FwAMP60T!DlE;)+~FNd_Emv+~l*h7~8v~aAf{N!4DDN5WwxK1%Jg1 z2;N;kJA*Y|XdG!!JxnpMCBlci zNQgk12!Nsk>Mnc4#4Xy)Z&5Ghf?rw7DeO|rc|_HM)4${KuI6Df7^2Ym;&Z%-P}DMc zb8j4S(M|o?0Z^|90lRlX;)ln4K|AV26+BJ#fAbP~o8}naRcbIRVm&Etx#t6NQa&b7 z4$!cn7eWPiA;fpM)3Z~0rECjB^8Yn)E+-ywAPwSxCYwU0AICcX0RE2Fx{B{*`3RiWcIjpX^3J(s<^K9+Ov z%1iw5QWkk;1viyru)qMnB!$n&Yfk%hxsvCP(K2c#dsVV3>)+mW8n{1Vi~{5wkn$I= zb5!i6Lq4zNrjCYQu?X|@5+cxWA8Rl(j8{?VqE=t8+^&XZWj1`z=)pZIfl73#(B{us z4!dzH#gIZj_$kGqE{XC2Nyd!CAeRnj_6m;21=$)%^U}{=LrZUuz7-ZZULFruWv&na zlAdu6GZNvT_!8Sop-hF@-QTC&NFl1;vKp5d-bh9Ra(bF`R@+?WWoAdE6Q`t%K7Bvj zy9tOJW73?-l_+OAI6n(Nxa~WE_9qKTy;)$UV-Af2}WC!`YlynU`@wck4Av>S{Hp zy{(e^eW-t4VS9-g5ezgtxtW}yRQwL#gO%v{&8JX~W1GDNB&p@Nye@Qye(ZE zIP%r~3q*L~VL#iC&YTE7X;qfJr=3}!oqeh(>V+_2S1IXTQ*ez_4jjIN3+l&7Yik(~ zLEcn{q)7Xi-_NGZ+=nneSq_(bR=?46)1T^m5LogV#xvE6mOPOG=V-!|^Lt|Bw3-9? zw_#<^GlZ`Kw0h{a9de{2$31qr*xt994IF!geFNGf^Dyo-)eI_-_lfa^UA#ln`x+Pe zaax2FGWHAB4dzfQX><#?oUz5@ZWh(H3Wm{8NsHQC})5@pnuLRm`S(ftzU`_ak>(eQT!~OY)V`fKw;If(G zr<{`Wb|*TRXrb)WKWXNcCy!_t+J!P`z2Oc`5kp%W_qDa#*TJx|yCraPuwIlgkC9>q zXoW7{4#<--x{P`UhRqur1I_0Jw2PXInF34p56MP&pn0vWPd;5{8=fnGk8Mll; zoEdUgxVlxT%g#yI{zyb>* zC97!UF{>vzHxhd%)l>FVt%&*EXDkmi0M2($u;(3wPJfA`dYoVPdo|^83($Q0QB+`y ziu;>ab~L=0dEILvlZ00I++_Cd8mk{k%6NorPwwvc*&uCMHvQ7afNE+`in(&r8 zM5z~kSy72soyv^?geYb{0D^G3JzqWh<$59Z62VToz?F0||7nf_TR_neex`BJv4ed$ z!2nAb=ohkQ(X6>nv2?xbuL_flZu`y_6QRoOMxjlLx2#Vs!wt_gfhDoHb+jMLXvfZ` z*g-MBV#;u+zNL1CB|qS4FxUu68PC4E z&QGFfqyu?}CY*Cc&nh2CsK1Pjh?`*1b9fw}>!@Q<#{CzfsHAS|+wcJ7%~0?JcaQ~( z7OSYhb!tEls=u@Q4k{1HUl`!d@(v?f&pN3o82SY;0VB=zIu3?CL(P82wepTR44%4dIH%N1o zy6WfDzpJUEY%9kh#1qmj4+Z_~F^S=@2+;wdC9z-f`Ra9#H=&;#ZWITuyDv;#H_7xJ zM3>)^>_B6E9=}X1I|x-oe)y7}#8=L`ojU4VlsaWbG;%k$0&{gqOAU%yX#w+ZW;_2e z3~9I0`+1Gjt7Ki}!vqmlI5_9W5FbM}*@TPqA;PvU?g$Tc(Icnd>35FA-FL+;Zru@o zu~?CYGW_lQB#gVCjqs24qFE%3k}M@)XMn zL;btD72~G%v|=j5FAO08!#(LnkOi&7zM@opj`>8lubX%RA9KshSyyOLs^BfjyVJ_C z@fkROWjr@`ex_C-{u*b7CJ>dE7JW=OZg+%=_1yFDA4 zADJnKY;ygEoJ(eOw8LyJmnRO0l5@}z6FpIGV)|o?!jj)xyb_?2#(+%pSzdLrF7%MMouKEf1d-|U3tmaiGueF|fN z28EBb6ZgSC)dvLRF`7Om;kCP^Yc79hldDPFWVVpWF6Q&!{O(j9>yuJ@oXtFU*HB*kSy)JK9JB1gM`T9wuI4^seLy=< zP%kb(LGQZQ-eneVPJR>X4U|}1qYgHBTU5N(<>>({3aoc@ccXN_P}8SFK2ve|a6K0L z(6o(3H|h*w@;JG;!s{67uUuaV3pfr#%?O;j*I-{@St$xJ8`+c$yh^ zBo3CRyZ6BPCf&AetgbNrOH-?lLo z{yZz9-Iz`y1lq>#;jL2t%Fx~q+0Sh9zW3%R{y_IqM9;|8u1*_vp(8D?O^QH31NxWOw4%NAFH5o(0XG08OS%!DiCMx#Z99IK&SO$dtyE=w|G*5WeU zdiY`H)b>q&A2&bO*g19or!WX{cfa4UqDgE>XVXyRmeg;&y?f73^+Kqd^Wug=2H#T0 zhMGE2R|JZB-5FfY5P@l8)ip{1NtQS+jNcuyT2>oA6LRXujh|h(sM@x9){`(5mcj9D z6S@k1_YPnS2|J|kubK?n;t7O+Ifo9b?h4cYO(}JglXXZn-It-|~SiAp==P zxvgOEImlsMs$%YNcH!bYhh@>eA1^zUOKs9B4zzAi_Dy$EkGGQXhApG?BW8-P-zR`+ zlOFE=^B$c5?+?McNc;+RkhQJ(SIbC`naO_M=|!fJ5dn1XLC*+l$EdL+Gm<%|0(RZDYUA+tTeC|c^FnWeUsevEWx&HchtXW_#IIZ<1fos6+0rcpf3(E-;llQ@q zW~SvT9raw_&)dDpTt*@G1G5~coCYO$OQPhmzM67NC94!Y#Le5~-f?Wcw zi0IGBx3w68y)?!Ofen;6MLvZ5^&cE+K97yB=XtmB?1-t`UVtjg$Ki`b9&J@=9#Y=f z828gWakbohcjsrui_^dr@xUhL>FgR&v2VYu?@~CjY49Y6CG6o{OGynv78z1Dl8|^W#~O$EC-v2M?As4PS&m=A6iXo)}6s>?ZwWU#PVG{ zAJIg!l%^u6zoPf`#9rXIWs12G2jIqrsSNoXR(4fRTuc@e=r!NGzmxsr&%nJjRLB|; z7K*-Dxt?Wp;fO=S@(c+`x2QG(0v+o`rjm3D7*HCAlT6nsG{0;D8BiI70UL#zI&Rh6 z4WHSYhaKW`56?0rxFIhHL|rGXWTyV+eUD2p*sY?&dwze}_^JI3w zl#eP&za|`TR$o%sEN=`=(;V?3E$if?in2Y%h)Qqk=uViN*M(Ym4j5h=_4~7|A0v)D z58Hqjx4^}xSbu3RyyA#!_`6X_r_gLI9^DjuRK|Af5wJZdWOyby@Z}tVR*~CpR-EX(QD9 z?6WLVmW}hL?n#4cUS&l_Yf*b&x^7H?))yvapSY3P4c%vSoB|+k-SsGyWhS@)UBu5Z zqS`R_n%5p|>Wl?uk?uw&JzNrd3!>EltHPkji?K!96LRVsU0T6T0XR-FMDQFwS*phF z`-M`WWHRqBtp={313jzV=YhN_ecwUY_}RHgol)@MsrKB$J)oQS@si~g3cSOWce2Q! z>>{Qm4y$|gCl}?_4!=-D$)dxBoCAiU)FDHNt-f%$A>?%PR~{{3If@mu#Wa$=?Ram+g9I&sn$#o#Zin;Od@4 zza(bil-GtWW51_XxAiZLh(h=c=6^}G$p`|7p_J3_Zk?0|p(*B{UQ=_9IB zU&QNBDQYsBR3dU4iZVL?@=cn7L^5t=JWkb#>I>fX?1Y7wzkw!9x~~f1HD0S zi#~kd4)NE4j;ReNqYDG;Tja5Jd@j@fY@8w3FpZVnrY$cL&jy%*CsrRgyLTEzc5Tv= zA8svvDXrM^(XwqvHqcu*S&xNEH52aj`7dPU91IyjB)+CD3KbE(Q(&PfR4O2ER|_!Q zCyek)crEedTyVtfG;zm^>p+dY4AaTrN9FkrI4Ol#?!*ura6L=~u#Vu$t8zDgEhd&M z{qAcXpo_S;UvxMW5*U1KZnBMUHddq<`YargTK!{`>?JZ)OeS$#tG>52YV9{|_q4XM zs5ZCK2#QhDXt(R?^Mk4eA?c>UC8UCIXoa&cv;V%(|{nUARFu z(@sVtR=9$^!6Il#bp;$$SIZ|g363cx_%s{e6Ke~=}IeH(vgrW0>h>< zVJ>ZK=Oshv7bhW6anK9`v0CKa`0#VcO#JXa1y>uIx9UYd+o;359uOxy2IQxfPB;Gv zXQM4*v%*c18oxEM;XWWML`VVbR=yFaw(&Zma z3z{Y7aF>B+ylkA$zkg%SJ3N+W)T_LA@!X1iS6vy?#)-r0ud>TF6$!I(trl$JX-5qA z#~SQNx1rrCU8l#7K3*?J#LJr$*y7BY5H;|8v+YB`62D7({ zZl2WG*-oWwY6jffz9PPfxE2odh+wQFL@&%9MZj43r=2kzJ2d|&w&-d9V(xT1v0?m} z{RdOYy5Migkhcq=6m7I9Erd5K`Qq6Qj~*g#6G<~UvX0>gP*@ifzEGcY3}Y{hY@05X z94rYZ4X)?3=&eT94&DhkP6xw;^XAyIGNAmI@9A$^gSY!{+FRqp6P6Ye&)VM;qfK;DnDAiN*N?Hy;bhdZn<(v7 zk{*0OIwFM-p~_VO$u*GsE_lScb@0WJ2o&$Fw`U&ORnG0=R&#RI1oczO*sOpD-BLX7 zdEP^q-JVpLR+fJ-D!p$JmOJZ$30%eA{`xoe_aa(Hm_(-ga8r{}AwTXZlpD9;FSH<= zw#BJX2_;%b%eYeRz&4(tH59$z>Xn{hFYzDkmEB#jQ^(iq=4mCx~c- znxHc97YnOp0k=KNb2R+$ZU-gK{C?cwa{2keNti3gJmM~IrnTERZamUDYV7hb9FVBf zQuZ{;?c0|m$X?)s@)whtPrk%)T~vs%d8HYi2Ura!HtJ*JrSvf~#La9@;rQZ+QOo zJ{ot!>d?3UNpv0RL^Jqwm7yJsDkhzmf& zV&-bsF;#}tP`~$_3)XXSFEH~0Io_z1=$@P%_?xW~4A08NM+a!DRQWo)frVbvu zgOi!wshI6m6nMU4iVAYlYrn&@%J*gtY0}r>DdoDYb?`U?Y|Fr{)!A-va38nNm9lyw&r2;wk3PrZw4orVe z;m(!(Aul&0WCH5!;12+lvj5fscx=MpS(Z!Z_3B42+5)S?*aiE?=#n{K@~w0nR!s?> zh@KHB;oGl(4m^E{d;X;eUrO zP+J%X2ozcNa~Yn?*( zLVSE5i|)5!Zcx=%*`mJoH*^`z2A_MfT|#2oZ=D(JW6jQXHP!cw_vVsv4*K?^ZIdKV zat(juE#{bjgcKks791rLrYKzp9Q?`oHybKT{oBOVnX#7BM!tZ?VPqk~$~tYt874BK zyjv7D6VEWl2Xw@d={H?rq;>irS>~jC@3oJrQS<$l571MRgdqfNwD4~8Re+dX!-oyp z6Ee)fcJQ$LC0~Nk%466JAxDZfZRf)bjAP^ygQ;tRJr=^JuXfI+NUw$0SkC^DipRe$ zCxjO=z?)=I(q!uA2+0+xytRWxqFY@fLfC691_tHZ(4on&pCUA4UnsyR7GyhbfcF?? z=SD`c?Lia8uyE0M#ie|8n2KEixyT{!mpn}2+~bdgPKFX3cy#^X9LePe;#j!MA^=bf zpNbYgApB6I7;|CrKy_0%dJ+r9Z;KtpU( diff --git a/src/cfclient/ui/wizards/bslh_3.png b/src/cfclient/ui/wizards/bslh_3.png deleted file mode 100644 index e5f385e4e31dff99e52ba15c2aa5fc9b5aca94cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8838 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 z 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 0: - self.too_few_bs = False - self.completeChanged.emit() - self.start_action_button.setText("Restart Measurement") - self.start_action_button.setDisabled(False) - else: - self.store_sample(angles_calibrated) - self.too_few_bs = False - status_text_string = f'Recording Done! Visible Base stations: {self.visible_basestations}\n' - if self.show_add_measurements: - self.recorded_angles_result.append(self.get_sample()) - status_text_string += f'Total measurements added: {len(self.recorded_angles_result)}\n' - self.status_text.setText(self.str_pad(status_text_string)) - self.is_done = True - self.completeChanged.emit() - - if self.show_add_measurements: - self.start_action_button.setText("Add more measurements") - self.start_action_button.setDisabled(False) - else: - self.start_action_button.setText("Restart Measurement") - self.start_action_button.setDisabled(False) - - def store_sample(self, angles: LhCfPoseSample) -> None: - self.recorded_angle_result = angles - - def get_sample(self): - return self.recorded_angle_result - - def str_pad(self, string_msg): - new_string_msg = string_msg - - if string_msg.count('\n') < STRING_PAD_TOTAL: - for i in range(STRING_PAD_TOTAL-string_msg.count('\n')): - new_string_msg += '\n' - - return new_string_msg - - -class RecordOriginSamplePage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): - super(RecordOriginSamplePage, self).__init__(cf, container) - self.explanation_text.setText( - 'Step 1. Put the Crazyflie where you want the origin of your coordinate system.\n') - pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_1.png") - pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) - self.explanation_picture.setPixmap(pixmap) - - def store_sample(self, angles: LhCfPoseSample) -> None: - self.container.set_origin_sample(LhCfPoseSample(angles)) - super().store_sample(angles) - - -class RecordXAxisSamplePage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): - super(RecordXAxisSamplePage, self).__init__(cf, container) - self.explanation_text.setText('Step 2. Put the Crazyflie on the positive X-axis,' + - f' exactly {REFERENCE_DIST} meters from the origin.\n' + - 'This will be used to define the X-axis as well as scaling of the system.') - pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_2.png") - pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) - self.explanation_picture.setPixmap(pixmap) - - def store_sample(self, angles: LhCfPoseSample) -> None: - self.container.set_x_axis_sample(LhCfPoseSample(angles)) - super().store_sample(angles) - - -class RecordXYPlaneSamplesPage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): - super(RecordXYPlaneSamplesPage, self).__init__(cf, container, show_add_measurements=True) - self.explanation_text.setText('Step 3. Put the Crazyflie somewhere in the XY-plane, but not on the X-axis.\n' + - 'This position is used to map the the XY-plane to the floor.\n' + - 'You can sample multiple positions to get a more precise definition.') - pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_3.png") - pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) - self.explanation_picture.setPixmap(pixmap) - - def store_sample(self, angles: LighthouseBsVectors) -> None: - # measurement = LhMeasurement(timestamp=now, base_station_id=bs_id, angles=angles) - self.container.append_xy_plane_sample(LhCfPoseSample(angles)) - super().store_sample(angles) - - def get_samples(self): - return self.recorded_angles_result - - -class RecordXYZSpaceSamplesPage(LighthouseBasestationGeometryWizardBasePage): - def __init__(self, cf: Crazyflie, container: LhGeoInputContainer, parent=None): - super(RecordXYZSpaceSamplesPage, self).__init__(cf, container) - self.explanation_text.setText('Step 4. Sample points in the space that will be used.\n' + - 'Make sure all the base stations are received, you need at least two base \n' + - 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + - 'left-right around the Z-axis and then holding it still for a second.\n') - pixmap = QtGui.QPixmap(cfclient.module_path + "/ui/wizards/bslh_4.png") - pixmap = pixmap.scaledToWidth(PICTURE_WIDTH) - self.explanation_picture.setPixmap(pixmap) - - self.reader = LighthouseMatchedSweepAngleReader(self.cf, self._ready_single_sample_cb) - self.detector = UserActionDetector(self.cf, cb=self.user_action_cb) - - def _action_btn_clicked(self): - self.is_done = True - self.start_action_button.setDisabled(True) - self.detector.start() - - def user_action_cb(self): - self.reader.start() - - def _ready_single_sample_cb(self, sample: LhCfPoseSample): - self.container.append_xyz_space_samples([sample]) - - def get_samples(self): - return self.recorded_angles_result - - def _stop_all(self): - self.reader.stop() - if self.detector is not None: - self.detector.stop() - - def cleanupPage(self): - self._stop_all() - super().cleanupPage() From 01fa284cb919b5b62140b4e17f4e441587bc21df Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 15:26:55 +0200 Subject: [PATCH 30/73] styling --- src/cfclient/ui/tabs/lighthouse_tab.py | 1 + src/cfclient/ui/widgets/geo_estimator_widget.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 12a9cca3..285751ea 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -229,6 +229,7 @@ def _clear_lines(self): line.parent = None self._bs_lines = [] + class Plot3dLighthouse(scene.SceneCanvas): VICINITY_DISTANCE = 2.5 HIGHLIGHT_DISTANCE = 0.5 diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 4b326212..00db7b57 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -49,7 +49,7 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors from cflib.localization.lighthouse_types import LhDeck4SensorPositions -from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample, LhCfPoseSampleStatus +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution from cflib.localization.user_action_detector import UserActionDetector @@ -592,6 +592,7 @@ class _TableRowStatus(Enum): INVALID = 1 LARGE_ERROR = 2 + class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) From 7ad80d0de997450c4be1c23416558510e6fff7c6 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 15 Jul 2025 17:15:04 +0200 Subject: [PATCH 31/73] Fixed table width --- src/cfclient/ui/widgets/geo_estimator.ui | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 55de4e8c..7de064f1 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -45,6 +45,9 @@ 0 + + QAbstractScrollArea::AdjustToContents + QAbstractItemView::SelectRows From c0886cc001b4172577cf0cb6dc20018199f55054 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 17 Jul 2025 16:06:29 +0200 Subject: [PATCH 32/73] Use UID to delete samples instead of index --- src/cfclient/ui/widgets/geo_estimator_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 00db7b57..2c4152fe 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -502,9 +502,9 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): self._samples_details_model.setSolution(self._latest_solution) # Add delete buttons - for row in range(len(solution.samples)): + for row, sample in enumerate(solution.samples): button = QPushButton('Delete') - button.clicked.connect(lambda _, r=row: self._container.remove_sample(r)) + button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, 5), button) if solution.progress_is_ok: From a05a6015e7c7eaaaadee35e8220885828128e262 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 18 Jul 2025 11:40:19 +0200 Subject: [PATCH 33/73] Added basic support for verification samples --- src/cfclient/ui/widgets/geo_estimator.ui | 7 ++++ .../ui/widgets/geo_estimator_widget.py | 32 +++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 7de064f1..cbc4c67b 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -270,6 +270,13 @@ + + + + TextLabel + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 2c4152fe..2cdcd879 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -49,7 +49,7 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors from cflib.localization.lighthouse_types import LhDeck4SensorPositions -from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample, LhCfPoseSampleType, LhCfPoseSampleStatus from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution from cflib.localization.user_action_detector import UserActionDetector @@ -143,7 +143,8 @@ class _UserNotificationType(Enum): STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" STYLE_NO_BACKGROUND = "background-color: ;" - +MOVE_COLUMN = 5 +DEL_COLUMN = 6 class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" @@ -372,10 +373,16 @@ def _update_solution_info(self): self._solution_status_is_ok.setText('Solution is OK') self._solution_status_uploaded.setText('Uploaded') self._solution_status_max_error.setText(f'Error: {solution.error_stats.max * 1000:.1f} mm') + + verification_error = 'No data' + if solution.verification_stats: + verification_error = f'{solution.verification_stats.max * 1000:.1f} mm' + self._solution_status_verification_error.setText(f'Verification err: {verification_error}') else: self._solution_status_is_ok.setText('No solution') self._solution_status_uploaded.setText('Not uploaded') self._solution_status_max_error.setText('Error: --') + self._solution_status_verification_error.setText('Verification err: --') self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) self._solution_status_info.setText(solution.general_failure_info) @@ -501,11 +508,22 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): self._samples_details_model.setSolution(self._latest_solution) - # Add delete buttons + # Add action buttons to table for row, sample in enumerate(solution.samples): - button = QPushButton('Delete') - button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) - self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, 5), button) + if sample.status != LhCfPoseSampleStatus.NO_DATA: + # Move button + if sample.sample_type == LhCfPoseSampleType.VERIFICATION: + button = QPushButton('To xyz') + button.clicked.connect(lambda _, uid=sample.uid : self._container.convert_to_xyz_space_sample(uid)) + else: + button = QPushButton('To verif.') + button.clicked.connect(lambda _, uid=sample.uid : self._container.convert_to_verification_sample(uid)) + self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, MOVE_COLUMN), button) + + # Delete button + button = QPushButton('Del') + button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) + self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, DEL_COLUMN), button) if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -596,7 +614,7 @@ class _TableRowStatus(Enum): class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) - self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Action'] + self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Move', 'Del'] self._table_values = [] self._table_highlights: list[_TableRowStatus] = [] From eaaf3ca90bf0ec271fd4f8c2e3b29767be42f4a6 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 18 Jul 2025 15:33:26 +0200 Subject: [PATCH 34/73] Added sampling of verification points --- .../ui/widgets/geo_estimator_widget.py | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 2cdcd879..373fbb58 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -82,12 +82,19 @@ class _CollectionStep(Enum): 'You can sample multiple positions to get a more precise definition.') XYZ_SPACE = ('bslh_4.png', 'Step 4. XYZ-space', - 'Sample points in the space that will be used.\n' + + 'Sample points in the space that you will use.\n' + 'Make sure all the base stations are received, you need at least two base \n' + 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + 'left-right around the Z-axis and then holding it still for a second, or \n' + 'optionally by clicking the sample button below.\n') + VERIFICATION = ('bslh_4.png', + 'Step 5. Verification', + 'Sample points to be used for verification of the geometry.\n' + + 'Sample by rotating the Crazyflie quickly \n' + + 'left-right around the Z-axis and then holding it still for a second, or \n' + + 'optionally by clicking the sample button below.\n') + def __init__(self, image, title, instructions): self.image = image self.title = title @@ -102,7 +109,8 @@ def order(self): self._order = [self.ORIGIN, self.X_AXIS, self.XY_PLANE, - self.XYZ_SPACE] + self.XYZ_SPACE, + self.VERIFICATION] return self._order def next(self): @@ -208,6 +216,7 @@ def __init__(self, lighthouse_tab): self._data_status_x_axis.clicked.connect(lambda: self._change_step(_CollectionStep.X_AXIS)) self._data_status_xy_plane.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) + self._data_status_verification.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) self._samples_details_model = SampleTableModel(self) self._samples_table_view.setModel(self._samples_details_model) @@ -297,7 +306,7 @@ def _change_step(self, step): if step != self._current_step: self._current_step = step self._update_step_ui() - if step == _CollectionStep.XYZ_SPACE: + if step in [_CollectionStep.XYZ_SPACE, _CollectionStep.VERIFICATION]: self._action_detector.start() else: self._action_detector.stop() @@ -334,6 +343,7 @@ def _update_ui_reading(self, is_reading: bool): self._data_status_x_axis.setEnabled(is_enabled) self._data_status_xy_plane.setEnabled(is_enabled) self._data_status_xyz_space.setEnabled(is_enabled) + self._data_status_verification.setEnabled(is_enabled) self._load_button.setEnabled(is_enabled) self._save_button.setEnabled(is_enabled) @@ -360,6 +370,9 @@ def _update_solution_info(self): if solution.xyz_space_samples_info: text += f', {solution.xyz_space_samples_info}' self._step_solution_info.setText(text) + case _CollectionStep.VERIFICATION: + text = f'OK, {self._container.verification_sample_count()} sample(s)' + self._step_solution_info.setText(text) self._set_background_color(self._data_status_origin, solution.is_origin_sample_valid) self._set_background_color(self._data_status_x_axis, solution.is_x_axis_samples_valid) @@ -430,7 +443,9 @@ def _measure(self): case _CollectionStep.XY_PLANE: self._measure_xy_plane() case _CollectionStep.XYZ_SPACE: - self._measure_xyz_space() + self._measure_single_sample() + case _CollectionStep.VERIFICATION: + self._measure_single_sample() def _measure_origin(self): """Measure the origin position""" @@ -447,9 +462,9 @@ def _measure_xy_plane(self): logger.debug("Measuring XY-plane position...") self._start_timeout_average_read(self._container.append_xy_plane_sample) - def _measure_xyz_space(self): - """Measure the XYZ-space position""" - logger.debug("Measuring XYZ-space position...") + def _measure_single_sample(self): + """Measure a single sample. Used both for xyz-space and verification""" + logger.debug("Measuring single sample...") self._user_notification_signal.emit(_UserNotificationType.PENDING) self._matched_reader.start(timeout=1.0) @@ -541,12 +556,16 @@ def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): self._lighthouse_tab.write_and_store_geometry(geo_dict) def _user_action_detected_cb(self): - self._measure_xyz_space() + self._measure_single_sample() def _single_sample_ready_cb(self, sample: LhCfPoseSample): self._user_notification_signal.emit(_UserNotificationType.SUCCESS) self._container_updated_signal.emit() - self._container.append_xyz_space_samples([sample]) + match self._current_step: + case _CollectionStep.XYZ_SPACE: + self._container.append_xyz_space_samples([sample]) + case _CollectionStep.VERIFICATION: + self._container.append_verification_samples([sample]) def _single_sample_timeout_cb(self): self._user_notification_signal.emit(_UserNotificationType.FAILURE) From 4edf9016c6e406b908828149bcf51d8e2b2c2aa2 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 18 Jul 2025 16:32:23 +0200 Subject: [PATCH 35/73] Colors for verification samples --- src/cfclient/ui/tabs/lighthouse_tab.py | 12 ++++++- src/cfclient/ui/widgets/geo_estimator.ui | 7 ++++ .../ui/widgets/geo_estimator_widget.py | 34 +++++++++++++------ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 285751ea..78a8ecd9 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -48,6 +48,7 @@ from cflib.localization import LighthouseConfigWriter from cflib.localization import LighthouseConfigFileManager from cflib.localization import LighthouseGeometrySolution +from cflib.localization import LhCfPoseSampleType from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry @@ -186,12 +187,14 @@ def set_receiving_status(self, visible: bool): class SampleMarkerPose(MarkerPose): NORMAL_BRUSH = np.array((0.8, 0.8, 0.8)) + VERIFICATION_BRUSH = np.array((1.0, 1.0, 0.9)) HIGHLIGHT_BRUSH = np.array((0.2, 0.2, 0.2)) BS_LINE_COL = np.array((0.0, 0.0, 0.0)) def __init__(self, the_scene): super().__init__(the_scene, self.NORMAL_BRUSH, None) self._is_highlighted = False + self._is_verification = False self._bs_lines = [] def set_highlighted(self, highlighted: bool, bs_positions=[]): @@ -215,11 +218,16 @@ def set_highlighted(self, highlighted: bool, bs_positions=[]): line.parent = None else: if highlighted != self._is_highlighted: - self.set_color(self.NORMAL_BRUSH) + self.set_color(self.VERIFICATION_BRUSH) if self._is_verification else self.set_color(self.NORMAL_BRUSH) self._clear_lines() self._is_highlighted = highlighted + def set_verification_type(self, is_verification: bool): + self._is_verification = is_verification + if not self._is_highlighted: + self.set_color(self.VERIFICATION_BRUSH) if self._is_verification else self.set_color(self.NORMAL_BRUSH) + def remove(self): super().remove() self._clear_lines() @@ -350,6 +358,8 @@ def update_samples(self, solution: LighthouseGeometrySolution): self._samples.append(SampleMarkerPose(self._view.scene)) self._samples[marker_idx].set_pose(pose.translation, pose.rot_matrix) + self._samples[marker_idx].set_verification_type( + pose_smpl.sample_type == LhCfPoseSampleType.VERIFICATION) if smpl_idx == self.selected_sample_index: bs_positions = [] diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index cbc4c67b..68a62529 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -234,6 +234,13 @@ + + + + Verification + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 373fbb58..fc156a5d 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -153,6 +153,8 @@ class _UserNotificationType(Enum): MOVE_COLUMN = 5 DEL_COLUMN = 6 + + class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" @@ -529,10 +531,11 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): # Move button if sample.sample_type == LhCfPoseSampleType.VERIFICATION: button = QPushButton('To xyz') - button.clicked.connect(lambda _, uid=sample.uid : self._container.convert_to_xyz_space_sample(uid)) + button.clicked.connect(lambda _, uid=sample.uid: self._container.convert_to_xyz_space_sample(uid)) else: button = QPushButton('To verif.') - button.clicked.connect(lambda _, uid=sample.uid : self._container.convert_to_verification_sample(uid)) + button.clicked.connect(lambda _, uid=sample.uid: self._container.convert_to_verification_sample( + uid)) self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, MOVE_COLUMN), button) # Delete button @@ -625,9 +628,9 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec class _TableRowStatus(Enum): - OK = 0 INVALID = 1 LARGE_ERROR = 2 + VERIFICATION = 3 class SampleTableModel(QAbstractTableModel): @@ -635,7 +638,7 @@ def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Move', 'Del'] self._table_values = [] - self._table_highlights: list[_TableRowStatus] = [] + self._table_highlights: list[set[_TableRowStatus]] = [] def rowCount(self, parent=None, *args, **kwargs): return len(self._table_values) @@ -651,11 +654,17 @@ def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: return QVariant(value) if role == Qt.ItemDataRole.BackgroundRole: - if self._table_highlights[index.row()] == _TableRowStatus.INVALID: - return QVariant(QtGui.QBrush(Qt.GlobalColor.gray)) - elif self._table_highlights[index.row()] == _TableRowStatus.LARGE_ERROR: + color = None + if _TableRowStatus.VERIFICATION in self._table_highlights[index.row()]: + color = QtGui.QColor(255, 255, 230) + if _TableRowStatus.INVALID in self._table_highlights[index.row()]: + color = Qt.GlobalColor.gray + if _TableRowStatus.LARGE_ERROR in self._table_highlights[index.row()]: if index.column() == 4: - return QVariant(QtGui.QBrush(Qt.GlobalColor.red)) + color = QtGui.QColor(255, 182, 193) + + if color: + return QVariant(QtGui.QBrush(color)) return QVariant() @@ -671,10 +680,13 @@ def setSolution(self, solution: LighthouseGeometrySolution): self._table_highlights = [] for sample in solution.samples: - status = _TableRowStatus.OK + status: set[_TableRowStatus] = set() x = y = z = '--' error = '--' + if sample.sample_type == LhCfPoseSampleType.VERIFICATION: + status.add(_TableRowStatus.VERIFICATION) + if sample.is_valid: if sample.has_pose: error = f'{sample.error_distance * 1000:.1f} mm' @@ -684,10 +696,10 @@ def setSolution(self, solution: LighthouseGeometrySolution): z = f'{pose.translation[2]:.2f}' if sample.is_error_large: - status = _TableRowStatus.LARGE_ERROR + status.add(_TableRowStatus.LARGE_ERROR) else: error = f'{sample.status}' - status = _TableRowStatus.INVALID + status.add(_TableRowStatus.INVALID) self._table_values.append([ f'{sample.sample_type}', From 0b794db695520e8e1e026fecbfb1807094447018 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 18 Jul 2025 18:15:45 +0200 Subject: [PATCH 36/73] Added bs link stats --- src/cfclient/ui/widgets/geo_estimator.ui | 152 +++++++++++------- .../ui/widgets/geo_estimator_widget.py | 62 ++++++- 2 files changed, 155 insertions(+), 59 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 68a62529..67f1db79 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -181,65 +181,73 @@ Data status - + - - - - 0 - 0 - - - - - - - Origin - - - false - - - - - - - - - - X-axis - - - false - - - - - - - XY-plane - - - false - - - - - - - XYZ-space - - - false - - + + + + + + 0 + 0 + + + + + + + Origin + + + false + + + + + + + + + + X-axis + + + false + + + + + + + XY-plane + + + false + + + + - - - Verification - - + + + + + XYZ-space + + + false + + + + + + + Verification + + + + @@ -301,6 +309,38 @@ + + + + Base station links + + + + + + QLayout::SetDefaultConstraint + + + 2 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index fc156a5d..68b83fd8 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -32,8 +32,8 @@ import os from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui -from PyQt6.QtWidgets import QFileDialog -from PyQt6.QtWidgets import QMessageBox, QPushButton +from PyQt6.QtWidgets import QFileDialog, QGridLayout +from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex @@ -149,7 +149,7 @@ class _UserNotificationType(Enum): STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" STYLE_RED_BACKGROUND = "background-color: lightpink;" STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" -STYLE_NO_BACKGROUND = "background-color: ;" +STYLE_NO_BACKGROUND = "background-color: none;" MOVE_COLUMN = 5 DEL_COLUMN = 6 @@ -234,6 +234,8 @@ def __init__(self, lighthouse_tab): self._samples_table_view.setVisible(False) self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) + self._bs_linkage_handler = BsLinkageHandler(self._bs_linkage_grid) + def _selection_changed(self, current: QModelIndex, previous: QModelIndex): self.sample_selection_changed_signal.emit(current.row()) @@ -543,6 +545,8 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, DEL_COLUMN), button) + self._bs_linkage_handler.update(solution) + if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -711,3 +715,55 @@ def setSolution(self, solution: LighthouseGeometrySolution): self._table_highlights.append(status) self.endResetModel() + + +class BsLinkageHandler: + STYLE_RED_BACKGROUND = "background-color: lightpink;" + STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" + + def __init__(self, container: QGridLayout): + self._container = container + + for bs in range(0, 16): + container.addWidget(self._create_label(str(bs + 1)), 0, bs) + container.addWidget(self._create_label(), 1, bs) + + def update(self, solution: LighthouseGeometrySolution): + container = self._container + link_count = solution.link_count + threshold = solution.link_count_ok_threshold + + for bs in range(0, 16): + exists = bs in link_count + count = 0 + text = '' + if exists: + count = len(link_count[bs]) + text = f'{count}' + is_ok = count >= threshold + + label_1 = container.itemAtPosition(0, bs).widget() + label_1.setVisible(exists) + + label_2 = container.itemAtPosition(1, bs).widget() + label_2.setVisible(exists) + label_2.setText(text) + if is_ok: + label_2.setStyleSheet(self.STYLE_GREEN_BACKGROUND) + else: + label_2.setStyleSheet(self.STYLE_RED_BACKGROUND) + + + def _create_label(self, text=None): + label = QLabel() + label.setMinimumSize(30, 0) + label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + if text: + label.setText(str(text)) + else: + label.setProperty('frameShape', 'QFrame::Box') + label.setStyleSheet(STYLE_NO_BACKGROUND) + + return label From cc30d40c8615496be0ed196ae44a2d1b0fb87cdd Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Sat, 19 Jul 2025 13:21:18 +0200 Subject: [PATCH 37/73] styling --- src/cfclient/ui/widgets/geo_estimator_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 68b83fd8..44605b91 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -753,7 +753,6 @@ def update(self, solution: LighthouseGeometrySolution): else: label_2.setStyleSheet(self.STYLE_RED_BACKGROUND) - def _create_label(self, text=None): label = QLabel() label.setMinimumSize(30, 0) From 7287a2c3f424f9b8f97a3a62ccb0b68f7f6a42bb Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 24 Jul 2025 07:31:04 +0200 Subject: [PATCH 38/73] Samples in 3D-view can be clicked --- src/cfclient/ui/tabs/lighthouse_tab.py | 26 ++++++++++++++++--- .../ui/widgets/geo_estimator_widget.py | 3 +++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 78a8ecd9..894b3ecd 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -86,7 +86,7 @@ class MarkerPose(): LABEL_SIZE = 100 LABEL_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self, the_scene, color, text=None, axis_visible=False): + def __init__(self, the_scene, color, text=None, axis_visible=False, interactive=False): self._scene = the_scene self._color = color self._text = text @@ -97,6 +97,9 @@ def __init__(self, the_scene, color, text=None, axis_visible=False): parent=self._scene, face_color=self._color) + if interactive: + self._marker.interactive = True + if axis_visible: self._x_axis = scene.visuals.Line( pos=np.array([[0, 0, 0], [0, 0, 0]]), @@ -163,6 +166,9 @@ def set_color(self, color): self._color = color self._marker.set_data(pos=np.array([self._position]), face_color=self._color) + def is_same_visual(self, visual): + return visual == self._marker + class CfMarkerPose(MarkerPose): POSITION_BRUSH = np.array((0, 0, 1.0)) @@ -192,7 +198,7 @@ class SampleMarkerPose(MarkerPose): BS_LINE_COL = np.array((0.0, 0.0, 0.0)) def __init__(self, the_scene): - super().__init__(the_scene, self.NORMAL_BRUSH, None) + super().__init__(the_scene, self.NORMAL_BRUSH, None, interactive=True) self._is_highlighted = False self._is_verification = False self._bs_lines = [] @@ -249,7 +255,7 @@ class Plot3dLighthouse(scene.SceneCanvas): TEXT_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self): + def __init__(self, sample_clicked_signal: pyqtSignal(int)): scene.SceneCanvas.__init__(self, keys=None) self.unfreeze() @@ -265,6 +271,8 @@ def __init__(self): self._samples = [] self.selected_sample_index = -1 + self.events.mouse_press.connect(self.on_mouse_press) + self._sample_clicked_signal = sample_clicked_signal self.freeze() plane_size = 10 @@ -279,6 +287,14 @@ def __init__(self): self._addArrows(1, 0.02, 0.1, 0.1, self._view.scene) + def on_mouse_press(self, event): + visual = self.visual_at(event.pos) + for index, sample in enumerate(self._samples): + if sample.is_same_visual(visual): + clicked_index = index + self._sample_clicked_signal.emit(clicked_index) + break + def _addArrows(self, length, width, head_length, head_width, parent): # The Arrow visual in vispy does not seem to work very good, # draw arrows using lines instead. @@ -416,6 +432,7 @@ class LighthouseTab(TabToolbox, lighthouse_tab_class): _new_system_config_written_to_cf_signal = pyqtSignal(bool) _geometry_read_signal = pyqtSignal(object) _calibration_read_signal = pyqtSignal(object) + _sample_clicked_signal = pyqtSignal(int) def __init__(self, helper): super(LighthouseTab, self).__init__(helper, 'Lighthouse Positioning') @@ -435,6 +452,7 @@ def __init__(self, helper): self._new_system_config_written_to_cf_signal.connect(self._new_system_config_written_to_cf) self._geometry_read_signal.connect(self._geometry_read_cb) self._calibration_read_signal.connect(self._calibration_read_cb) + self._sample_clicked_signal.connect(self._geo_estimator_widget.set_selected_sample) # Connect the Crazyflie API callbacks to the signals self._helper.cf.connected.add_callback(self._connected_signal.emit) @@ -522,7 +540,7 @@ def _show_basestation_mode_dialog(self): self._basestation_mode_dialog.show() def _set_up_plots(self): - self._plot_3d = Plot3dLighthouse() + self._plot_3d = Plot3dLighthouse(self._sample_clicked_signal) self._plot_layout.addWidget(self._plot_3d.native) def _connected(self, link_uri): diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 44605b91..6b2c82b8 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -239,6 +239,9 @@ def __init__(self, lighthouse_tab): def _selection_changed(self, current: QModelIndex, previous: QModelIndex): self.sample_selection_changed_signal.emit(current.row()) + def set_selected_sample(self, index: int): + self._samples_table_view.selectRow(index) + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: From 85a71755f518f5f29cdda3d95c31e889bcc407f1 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 24 Jul 2025 07:42:57 +0200 Subject: [PATCH 39/73] Use squares for samples --- src/cfclient/ui/tabs/lighthouse_tab.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 894b3ecd..2672219b 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -86,16 +86,18 @@ class MarkerPose(): LABEL_SIZE = 100 LABEL_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self, the_scene, color, text=None, axis_visible=False, interactive=False): + def __init__(self, the_scene, color, text=None, axis_visible=False, interactive=False, symbol: str = 'disc'): self._scene = the_scene self._color = color self._text = text self._position = [0.0, 0, 0] + self._symbol = symbol self._marker = scene.visuals.Markers( pos=np.array([[0, 0, 0]]), parent=self._scene, - face_color=self._color) + face_color=self._color, + symbol=self._symbol) if interactive: self._marker.interactive = True @@ -133,7 +135,7 @@ def set_pose(self, position, rot): return self._position = position - self._marker.set_data(pos=np.array([position]), face_color=self._color) + self._marker.set_data(pos=np.array([position]), face_color=self._color, symbol=self._symbol) if self._label: self._label.pos = self.LABEL_OFFSET + position @@ -164,7 +166,7 @@ def remove(self): def set_color(self, color): self._color = color - self._marker.set_data(pos=np.array([self._position]), face_color=self._color) + self._marker.set_data(pos=np.array([self._position]), face_color=self._color, symbol=self._symbol) def is_same_visual(self, visual): return visual == self._marker @@ -198,7 +200,7 @@ class SampleMarkerPose(MarkerPose): BS_LINE_COL = np.array((0.0, 0.0, 0.0)) def __init__(self, the_scene): - super().__init__(the_scene, self.NORMAL_BRUSH, None, interactive=True) + super().__init__(the_scene, self.NORMAL_BRUSH, None, interactive=True, symbol='square') self._is_highlighted = False self._is_verification = False self._bs_lines = [] From efe109ac87795a42d742e1c92706ccc0969caed0 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 13:29:29 +0100 Subject: [PATCH 40/73] Dynamic marker axis visibility --- src/cfclient/ui/tabs/lighthouse_tab.py | 95 +++++++++++++++++--------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 2672219b..9944c588 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -86,12 +86,17 @@ class MarkerPose(): LABEL_SIZE = 100 LABEL_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self, the_scene, color, text=None, axis_visible=False, interactive=False, symbol: str = 'disc'): + def __init__(self, the_scene, color, text=None, axis_visible: bool = False, interactive=False, symbol: str = 'disc'): self._scene = the_scene self._color = color self._text = text self._position = [0.0, 0, 0] + self._rot = np.identity(3) self._symbol = symbol + self._axis_visible = False + self._x_axis = None + self._y_axis = None + self._z_axis = None self._marker = scene.visuals.Markers( pos=np.array([[0, 0, 0]]), @@ -102,26 +107,6 @@ def __init__(self, the_scene, color, text=None, axis_visible=False, interactive= if interactive: self._marker.interactive = True - if axis_visible: - self._x_axis = scene.visuals.Line( - pos=np.array([[0, 0, 0], [0, 0, 0]]), - color=self.COL_X_AXIS, - parent=self._scene) - - self._y_axis = scene.visuals.Line(pos=np.array( - [[0, 0, 0], [0, 0, 0]]), - color=self.COL_Y_AXIS, - parent=self._scene) - - self._z_axis = scene.visuals.Line( - pos=np.array([[0, 0, 0], [0, 0, 0]]), - color=self.COL_Z_AXIS, - parent=self._scene) - else: - self._x_axis = None - self._y_axis = None - self._z_axis = None - self._label = None if self._text: self._label = scene.visuals.Text( @@ -130,25 +115,67 @@ def __init__(self, the_scene, color, text=None, axis_visible=False, interactive= pos=self.LABEL_OFFSET, parent=self._scene) - def set_pose(self, position, rot): - if np.array_equal(position, self._position): + self.set_axis_visible(axis_visible) + + def set_axis_visible(self, visible: bool): + if visible == self._axis_visible: return - self._position = position - self._marker.set_data(pos=np.array([position]), face_color=self._color, symbol=self._symbol) + if visible: + if self._x_axis is None: + self._x_axis = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.COL_X_AXIS, + parent=self._scene) + + if self._y_axis is None: + self._y_axis = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.COL_Y_AXIS, + parent=self._scene) + + if self._z_axis is None: + self._z_axis = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.COL_Z_AXIS, + parent=self._scene) + else: + if self._x_axis is not None: + self._x_axis.parent = None + self._x_axis = None + if self._y_axis is not None: + self._y_axis.parent = None + self._y_axis = None + if self._z_axis is not None: + self._z_axis.parent = None + self._z_axis = None - if self._label: - self._label.pos = self.LABEL_OFFSET + position + self._axis_visible = visible + + self._update_visuals() + + def set_pose(self, position, rot): + if np.array_equal(position, self._position) and np.array_equal(rot, self._rot): + return - if self._x_axis: - x_tip = np.dot(np.array(rot), np.array([self.AXIS_LEN, 0, 0])) - self._x_axis.set_data(np.array([position, x_tip + position]), color=self.COL_X_AXIS) + self._position = position + self._rot = rot - y_tip = np.dot(np.array(rot), np.array([0, self.AXIS_LEN, 0])) - self._y_axis.set_data(np.array([position, y_tip + position]), color=self.COL_Y_AXIS) + self._update_visuals() - z_tip = np.dot(np.array(rot), np.array([0, 0, self.AXIS_LEN])) - self._z_axis.set_data(np.array([position, z_tip + position]), color=self.COL_Z_AXIS) + def _update_visuals(self): + self._marker.set_data(pos=np.array([self._position]), face_color=self._color, symbol=self._symbol) + + if self._label: + self._label.pos = self.LABEL_OFFSET + self._position + + if self._axis_visible: + x_tip = np.dot(np.array(self._rot), np.array([self.AXIS_LEN, 0, 0])) + self._x_axis.set_data(np.array([self._position, x_tip + self._position]), color=self.COL_X_AXIS) + y_tip = np.dot(np.array(self._rot), np.array([0, self.AXIS_LEN, 0])) + self._y_axis.set_data(np.array([self._position, y_tip + self._position]), color=self.COL_Y_AXIS) + z_tip = np.dot(np.array(self._rot), np.array([0, 0, self.AXIS_LEN])) + self._z_axis.set_data(np.array([self._position, z_tip + self._position]), color=self.COL_Z_AXIS) def get_position(self): return self._position From 0e8504e2e5313368051bf4cd4fa02c1266079fd3 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 13:30:04 +0100 Subject: [PATCH 41/73] Show sample orientation when highlighted --- src/cfclient/ui/tabs/lighthouse_tab.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 9944c588..e4aac05b 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -256,6 +256,8 @@ def set_highlighted(self, highlighted: bool, bs_positions=[]): self.set_color(self.VERIFICATION_BRUSH) if self._is_verification else self.set_color(self.NORMAL_BRUSH) self._clear_lines() + self.set_axis_visible(highlighted) + self._is_highlighted = highlighted def set_verification_type(self, is_verification: bool): From 0e27cb316d0aedba239de28b2e1636b50d4b0085 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 14:33:08 +0100 Subject: [PATCH 42/73] Improved selection behavior for samples --- src/cfclient/ui/tabs/lighthouse_tab.py | 7 +++++ .../ui/widgets/geo_estimator_widget.py | 27 ++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index e4aac05b..20484549 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -320,12 +320,19 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): def on_mouse_press(self, event): visual = self.visual_at(event.pos) + + is_sample_hit = False + for index, sample in enumerate(self._samples): if sample.is_same_visual(visual): clicked_index = index self._sample_clicked_signal.emit(clicked_index) + is_sample_hit = True break + if not is_sample_hit: + self._sample_clicked_signal.emit(-1) + def _addArrows(self, length, width, head_length, head_width, parent): # The Arrow visual in vispy does not seem to work very good, # draw arrows using lines instead. diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 6b2c82b8..be9d0aa2 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -33,8 +33,8 @@ from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui from PyQt6.QtWidgets import QFileDialog, QGridLayout -from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel -from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex +from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel, QAbstractItemView +from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex, QItemSelection import logging @@ -222,7 +222,9 @@ def __init__(self, lighthouse_tab): self._samples_details_model = SampleTableModel(self) self._samples_table_view.setModel(self._samples_details_model) - self._samples_table_view.selectionModel().currentRowChanged.connect(self._selection_changed) + self._samples_table_view.selectionModel().selectionChanged.connect(self._selection_changed) + self._samples_table_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self._samples_table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) header = self._samples_table_view.horizontalHeader() header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) @@ -236,11 +238,20 @@ def __init__(self, lighthouse_tab): self._bs_linkage_handler = BsLinkageHandler(self._bs_linkage_grid) - def _selection_changed(self, current: QModelIndex, previous: QModelIndex): - self.sample_selection_changed_signal.emit(current.row()) + def _selection_changed(self, current: QItemSelection, previous: QItemSelection): + model_indexes = current.indexes() + + if len(model_indexes) > 0: + self.sample_selection_changed_signal.emit(model_indexes[0].row()) + else: + self.sample_selection_changed_signal.emit(-1) def set_selected_sample(self, index: int): - self._samples_table_view.selectRow(index) + if index >= 0: + self._samples_table_view.selectRow(index) + else: + self._samples_table_view.clearSelection() + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) @@ -274,7 +285,9 @@ def _clear_all(self): def _load_from_file(self, use_session_path=False): path = self._session_path if use_session_path else self._helper.current_folder - names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) + # names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) + names = ['/home/kristoffer/code/tmp/cf-configs/4bs-samples.yaml'] # TODO krri remove + if names[0] == '': return From 1dbd60b83be7386974c650701b542e51120660e0 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 15:39:53 +0100 Subject: [PATCH 43/73] Clear 3D-view selection when new solution is available --- src/cfclient/ui/widgets/geo_estimator_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index be9d0aa2..35ba1944 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -252,7 +252,6 @@ def set_selected_sample(self, index: int): else: self._samples_table_view.clearSelection() - def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -543,6 +542,9 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): self._samples_details_model.setSolution(self._latest_solution) + # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. + self.sample_selection_changed_signal.emit(-1) + # Add action buttons to table for row, sample in enumerate(solution.samples): if sample.status != LhCfPoseSampleStatus.NO_DATA: From 831a1017410d988618a54811646f6dc350a04bfd Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 16:08:38 +0100 Subject: [PATCH 44/73] Clear samples when loading a LH system config --- src/cfclient/ui/tabs/lighthouse_tab.py | 15 +++++++++++++-- src/cfclient/ui/widgets/geo_estimator_widget.py | 8 +++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 20484549..6a547082 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -904,6 +904,16 @@ def _update_basestation_status_indicators(self): label.setToolTip('') def _load_sys_config_button_clicked(self): + if not self._geo_estimator_widget.is_container_empty(): + dlg = QMessageBox(self) + dlg.setWindowTitle("Clear samples Confirmation") + dlg.setText("Loading a new system configuration will clear all samples. Are you sure?") + dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + button = dlg.exec() + + if button != QMessageBox.StandardButton.Yes: + return + names = QFileDialog.getOpenFileName(self, 'Open file', self._helper.current_folder, FILE_REGEX_YAML) if names[0] == '': @@ -912,8 +922,9 @@ def _load_sys_config_button_clicked(self): self._helper.current_folder = os.path.dirname(names[0]) if self._lh_config_writer is not None: - self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, - names[0]) + self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, names[0]) + # Clear all samples as the new configuration is based on some other (unknown) set of samples + self._geo_estimator_widget.new_session() def _save_sys_config_button_clicked(self): # Get calibration data from the Crazyflie to complete the system config data set diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 35ba1944..2247d870 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -282,11 +282,13 @@ def _clear_all(self): if button == QMessageBox.StandardButton.Yes: self.new_session() + def is_container_empty(self) -> bool: + """Check if the container has any samples""" + return self._container.is_empty() + def _load_from_file(self, use_session_path=False): path = self._session_path if use_session_path else self._helper.current_folder - # names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) - names = ['/home/kristoffer/code/tmp/cf-configs/4bs-samples.yaml'] # TODO krri remove - + names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) if names[0] == '': return From 8eee1d36987c421179d1df9401ed9ea5156959d8 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 16:43:34 +0100 Subject: [PATCH 45/73] Added simplistic home button to LH 3D view --- src/cfclient/ui/tabs/lighthouse_tab.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 6a547082..4ecb557d 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -35,9 +35,7 @@ from PyQt6 import uic from PyQt6.QtCore import Qt, pyqtSignal, QTimer -from PyQt6.QtWidgets import QMessageBox -from PyQt6.QtWidgets import QFileDialog -from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QMessageBox, QFileDialog, QLabel, QPushButton import cfclient from cfclient.ui.tab_toolbox import TabToolbox @@ -286,6 +284,8 @@ class Plot3dLighthouse(scene.SceneCanvas): TEXT_OFFSET = np.array((0.0, 0, 0.25)) + DEFAULT_CAMERA_DISTANCE = 10.0 + def __init__(self, sample_clicked_signal: pyqtSignal(int)): scene.SceneCanvas.__init__(self, keys=None) self.unfreeze() @@ -293,9 +293,10 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): self._view = self.central_widget.add_view() self._view.bgcolor = '#ffffff' self._view.camera = scene.TurntableCamera( - distance=10.0, + distance=self.DEFAULT_CAMERA_DISTANCE, up='+z', center=(0.0, 0.0, 1.0)) + self._view.camera.set_default_state() self._cf = None self._base_stations = {} @@ -318,6 +319,10 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): self._addArrows(1, 0.02, 0.1, 0.1, self._view.scene) + def move_camera_home(self): + self._view.camera.reset() + self._view.camera.distance = self.DEFAULT_CAMERA_DISTANCE + def on_mouse_press(self, event): visual = self.visual_at(event.pos) @@ -581,6 +586,10 @@ def _set_up_plots(self): self._plot_3d = Plot3dLighthouse(self._sample_clicked_signal) self._plot_layout.addWidget(self._plot_3d.native) + home_button = QPushButton('Home', self._plot_3d.native) + home_button.move(5, 5) + home_button.clicked.connect(self._plot_3d.move_camera_home) + def _connected(self, link_uri): """Callback when the Crazyflie has been connected""" logger.debug("Crazyflie connected to {}".format(link_uri)) From 5fda8d089b9608e8c4ec2db2f3a82924cc7f4dc9 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 22 Jan 2026 17:04:59 +0100 Subject: [PATCH 46/73] Don't reset selection if camera is moved by clicking the plane --- src/cfclient/ui/tabs/lighthouse_tab.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 4ecb557d..81b1b92d 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -298,17 +298,16 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): center=(0.0, 0.0, 1.0)) self._view.camera.set_default_state() - self._cf = None - self._base_stations = {} - self._samples = [] - self.selected_sample_index = -1 + self._cf: CfMarkerPose | None = None + self._base_stations: dict[int, BsMarkerPose] = {} + self._samples: list[SampleMarkerPose] = [] + self.selected_sample_index: int = -1 self.events.mouse_press.connect(self.on_mouse_press) self._sample_clicked_signal = sample_clicked_signal - self.freeze() plane_size = 10 - scene.visuals.Plane( + self._plane = scene.visuals.Plane( width=plane_size, height=plane_size, width_segments=plane_size, @@ -316,8 +315,10 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): color=(0.5, 0.5, 0.5, 0.5), edge_color="gray", parent=self._view.scene) + self._plane.interactive = True self._addArrows(1, 0.02, 0.1, 0.1, self._view.scene) + self.freeze() def move_camera_home(self): self._view.camera.reset() @@ -326,16 +327,19 @@ def move_camera_home(self): def on_mouse_press(self, event): visual = self.visual_at(event.pos) - is_sample_hit = False + is_object_hit = False + + if visual == self._plane: + is_object_hit = True for index, sample in enumerate(self._samples): if sample.is_same_visual(visual): clicked_index = index self._sample_clicked_signal.emit(clicked_index) - is_sample_hit = True + is_object_hit = True break - if not is_sample_hit: + if not is_object_hit: self._sample_clicked_signal.emit(-1) def _addArrows(self, length, width, head_length, head_width, parent): From 2ae4e79ea2618e19b8a249500f56b19d45fa5a9f Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 23 Jan 2026 12:37:45 +0100 Subject: [PATCH 47/73] Added table for base station positions in geo estimation --- src/cfclient/ui/tabs/lighthouse_tab.py | 139 +++++++++++-- src/cfclient/ui/widgets/geo_estimator.ui | 144 +++++++------ .../ui/widgets/geo_estimator_widget.py | 196 ++++++++++++------ 3 files changed, 340 insertions(+), 139 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 81b1b92d..c274bf3f 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -194,6 +194,9 @@ def set_color(self, color): self._marker.set_data(pos=np.array([self._position]), face_color=self._color, symbol=self._symbol) def is_same_visual(self, visual): + if not self._marker.interactive: + raise RuntimeError("is_same_visual can only be used for interactive markers") + return visual == self._marker @@ -205,17 +208,61 @@ def __init__(self, the_scene): class BsMarkerPose(MarkerPose): + HIGHLIGHT_BRUSH = np.array((0.2, 0.2, 0.5)) BS_BRUSH_VISIBLE = np.array((0.2, 0.5, 0.2)) BS_BRUSH_NOT_VISIBLE = np.array((0.8, 0.5, 0.5)) + LINE_COL = np.array((0.0, 0.0, 0.0)) def __init__(self, the_scene, text=None): - super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text, axis_visible=True) + super().__init__(the_scene, self.BS_BRUSH_NOT_VISIBLE, text, axis_visible=True, interactive=True) + + self._is_visible = False + self._is_highlighted = False + self._bs_lines = [] def set_receiving_status(self, visible: bool): - if visible: - self.set_color(self.BS_BRUSH_VISIBLE) + self._is_visible = visible + self.set_color(self._get_brush()) + + def set_highlighted(self, highlighted: bool, other_positions=[]): + if highlighted: + for i, pos in enumerate(other_positions): + if i >= len(self._bs_lines): + line = scene.visuals.Line( + pos=np.array([[0, 0, 0], [0, 0, 0]]), + color=self.LINE_COL, + parent=self._scene) + self._bs_lines.append(line) + else: + line = self._bs_lines[i] + + line.set_data(np.array([self._position, pos]), color=self.LINE_COL) + + for _ in range(len(self._bs_lines) - len(other_positions)): + line = self._bs_lines.pop() + line.parent = None else: - self.set_color(self.BS_BRUSH_NOT_VISIBLE) + self._clear_lines() + + self._is_highlighted = highlighted + self.set_color(self._get_brush()) + + def remove(self): + super().remove() + self._clear_lines() + + def _clear_lines(self): + for line in self._bs_lines: + line.parent = None + self._bs_lines = [] + + def _get_brush(self) -> np.ndarray: + if self._is_highlighted: + return self.HIGHLIGHT_BRUSH + elif self._is_visible: + return self.BS_BRUSH_VISIBLE + else: + return self.BS_BRUSH_NOT_VISIBLE class SampleMarkerPose(MarkerPose): @@ -232,8 +279,9 @@ def __init__(self, the_scene): def set_highlighted(self, highlighted: bool, bs_positions=[]): if highlighted: - # always update lines when highlighted as base station positions may have changed self.set_color(self.HIGHLIGHT_BRUSH) + + # always update lines when highlighted as base station positions may have changed for i, pos in enumerate(bs_positions): if i >= len(self._bs_lines): line = scene.visuals.Line( @@ -286,7 +334,7 @@ class Plot3dLighthouse(scene.SceneCanvas): DEFAULT_CAMERA_DISTANCE = 10.0 - def __init__(self, sample_clicked_signal: pyqtSignal(int)): + def __init__(self, sample_clicked_signal: pyqtSignal(int), base_station_clicked_signal: pyqtSignal(int)): scene.SceneCanvas.__init__(self, keys=None) self.unfreeze() @@ -302,9 +350,11 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int)): self._base_stations: dict[int, BsMarkerPose] = {} self._samples: list[SampleMarkerPose] = [] self.selected_sample_index: int = -1 + self.selected_base_station_id: int = -1 self.events.mouse_press.connect(self.on_mouse_press) self._sample_clicked_signal = sample_clicked_signal + self._base_station_clicked_signal = base_station_clicked_signal plane_size = 10 self._plane = scene.visuals.Plane( @@ -329,18 +379,29 @@ def on_mouse_press(self, event): is_object_hit = False + # Check if the plane was hit. This will prevent deselecting samples and base stations when rotating the camera + # with the mouse over the plane. if visual == self._plane: is_object_hit = True - for index, sample in enumerate(self._samples): - if sample.is_same_visual(visual): - clicked_index = index - self._sample_clicked_signal.emit(clicked_index) - is_object_hit = True - break + if not is_object_hit: + for index, sample in enumerate(self._samples): + if sample.is_same_visual(visual): + clicked_index = index + self._sample_clicked_signal.emit(clicked_index) + is_object_hit = True + break + + if not is_object_hit: + for id, bs in self._base_stations.items(): + if bs.is_same_visual(visual): + is_object_hit = True + self._base_station_clicked_signal.emit(id) + break if not is_object_hit: self._sample_clicked_signal.emit(-1) + self._base_station_clicked_signal.emit(-1) def _addArrows(self, length, width, head_length, head_width, parent): # The Arrow visual in vispy does not seem to work very good, @@ -387,17 +448,54 @@ def update_cf_pose(self, position, rot): self._cf = CfMarkerPose(self._view.scene) self._cf.set_pose(position, rot) - def update_base_station_geos(self, geos): + def update_base_station_geos(self, geos, solution: LighthouseGeometrySolution): + # Geos are read from the CF (not the solution) to reflect the actual stored data + # The solution is only used to get link information (if available) for highlighting + for id, geo in geos.items(): + # Add a new base station if it does not exist if (geo is not None) and (id not in self._base_stations): self._base_stations[id] = BsMarkerPose(self._view.scene, text=f"{id + 1}") + self._base_stations[id].set_pose(geo.origin, geo.rotation_matrix) + # Highlight if selected + if id == self.selected_base_station_id: + # We have two options of what to show, either the positions of other base stations or + # the positions of samples that have measurements from this base station. + other_positions = self._get_positions_of_linked_base_stations(id, solution, geos) + # other_positions = self._get_positions_of_samples_with_bs(id, solution) + + self._base_stations[id].set_highlighted(True, other_positions=other_positions) + else: + self._base_stations[id].set_highlighted(False) + + # Remove any base stations that are no longer present geos_to_remove = self._base_stations.keys() - geos.keys() for id in geos_to_remove: existing = self._base_stations.pop(id) existing.remove() + def _get_positions_of_linked_base_stations(self, bs_id: int, solution: LighthouseGeometrySolution, + geos: dict[int, LighthouseBsGeometry]) -> list[float]: + linked_base_stations = solution.link_count[bs_id].keys() + positions = [] + for other_id in linked_base_stations: + if other_id in geos: + geo = geos[other_id] + if geo is not None: + positions.append(geo.origin) + return positions + + def _get_positions_of_samples_with_bs(self, bs_id: int, solution: LighthouseGeometrySolution) -> list[float]: + positions = [] + for sample in solution.samples: + if sample.sample_type != LhCfPoseSampleType.VERIFICATION: + if bs_id in sample.base_station_ids: + pose = sample.pose + positions.append(pose.translation) + return positions + def update_base_station_visibility(self, visibility): for id, bs in self._base_stations.items(): bs.set_receiving_status(id in visibility) @@ -480,6 +578,7 @@ class LighthouseTab(TabToolbox, lighthouse_tab_class): _geometry_read_signal = pyqtSignal(object) _calibration_read_signal = pyqtSignal(object) _sample_clicked_signal = pyqtSignal(int) + _base_station_clicked_signal = pyqtSignal(int) def __init__(self, helper): super(LighthouseTab, self).__init__(helper, 'Lighthouse Positioning') @@ -489,6 +588,7 @@ def __init__(self, helper): self._geometry_area.addWidget(self._geo_estimator_widget) self._geo_estimator_widget.solution_ready_signal.connect(self._solution_updated_cb) self._geo_estimator_widget.sample_selection_changed_signal.connect(self._sample_selection_changed_cb) + self._geo_estimator_widget.base_station_selection_changed_signal.connect(self._base_station_selection_changed_cb) # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it @@ -500,6 +600,7 @@ def __init__(self, helper): self._geometry_read_signal.connect(self._geometry_read_cb) self._calibration_read_signal.connect(self._calibration_read_cb) self._sample_clicked_signal.connect(self._geo_estimator_widget.set_selected_sample) + self._base_station_clicked_signal.connect(self._geo_estimator_widget.set_selected_base_station) # Connect the Crazyflie API callbacks to the signals self._helper.cf.connected.add_callback(self._connected_signal.emit) @@ -554,6 +655,8 @@ def __init__(self, helper): self._ui_mode = UiMode.flying self._geo_mode_button.toggled.connect(lambda enabled: self._change_ui_mode(enabled)) + self._latest_solution = LighthouseGeometrySolution([]) + self._is_connected = False self._update_ui() @@ -587,7 +690,7 @@ def _show_basestation_mode_dialog(self): self._basestation_mode_dialog.show() def _set_up_plots(self): - self._plot_3d = Plot3dLighthouse(self._sample_clicked_signal) + self._plot_3d = Plot3dLighthouse(self._sample_clicked_signal, self._base_station_clicked_signal) self._plot_layout.addWidget(self._plot_3d.native) home_button = QPushButton('Home', self._plot_3d.native) @@ -647,9 +750,13 @@ def _solution_updated_cb(self, solution: LighthouseGeometrySolution): self._latest_solution = solution def _sample_selection_changed_cb(self, sample_index: int): - """Callback when the selected sample in the geo estimator widget changes""" + """Callback when the sample selection in the geo estimator widget changes""" self._plot_3d.selected_sample_index = sample_index + def _base_station_selection_changed_cb(self, bs_id: int): + """Callback when the base station selection in the geo estimator widget changes""" + self._plot_3d.selected_base_station_id = bs_id + def _is_matching_current_geo_data(self, geometries): return geometries == self._lh_geos.keys() @@ -749,7 +856,7 @@ def _update_graphics(self): if self.is_visible() and self.is_lighthouse_deck_active: self._plot_3d.update_cf_pose(self._helper.pose_logger.position, self._rpy_to_rot(self._helper.pose_logger.rpy_rad)) - self._plot_3d.update_base_station_geos(self._lh_geos) + self._plot_3d.update_base_station_geos(self._lh_geos, self._latest_solution) self._plot_3d.update_base_station_visibility(self._bs_data_to_estimator) if self._ui_mode == UiMode.geo_estimation: diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 67f1db79..2f999f81 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -7,7 +7,7 @@ 0 0 702 - 753 + 793 @@ -32,37 +32,85 @@ - - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContents - - - QAbstractItemView::SelectRows - - - false - - - true - - - false - - - false - + + + + + + Samples + + + + + + + + 0 + 0 + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + true + + + false + + + false + + + + + + + Base stations + + + + + + + + 0 + 0 + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + true + + + false + + + + @@ -123,7 +171,7 @@ - QFrame::Panel + QFrame::Shape::Panel TextLabel @@ -133,7 +181,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -309,38 +357,6 @@ - - - - Base station links - - - - - - QLayout::SetDefaultConstraint - - - 2 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - @@ -393,7 +409,7 @@ - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 2247d870..e6885ae7 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -29,11 +29,12 @@ Container for the geometry estimation functionality in the lighthouse tab. """ +from itertools import count import os from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui from PyQt6.QtWidgets import QFileDialog, QGridLayout -from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel, QAbstractItemView +from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex, QItemSelection @@ -164,6 +165,7 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): start_solving_signal = QtCore.pyqtSignal() solution_ready_signal = QtCore.pyqtSignal(object) sample_selection_changed_signal = QtCore.pyqtSignal(int) + base_station_selection_changed_signal = QtCore.pyqtSignal(int) FILE_REGEX_YAML = "Config *.yaml;;All *.*" @@ -220,38 +222,79 @@ def __init__(self, lighthouse_tab): self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) self._data_status_verification.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) + # Create sample details table self._samples_details_model = SampleTableModel(self) self._samples_table_view.setModel(self._samples_details_model) - self._samples_table_view.selectionModel().selectionChanged.connect(self._selection_changed) - self._samples_table_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self._samples_table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - - header = self._samples_table_view.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + self._samples_table_view.selectionModel().selectionChanged.connect(self._sample_selection_changed) + + header_samples = self._samples_table_view.horizontalHeader() + header_samples.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + + # Create base station details table + self._base_stations_details_model = BaseStationTableModel(self) + self._base_stations_table_view.setModel(self._base_stations_details_model) + self._base_stations_table_view.selectionModel().selectionChanged.connect(self._base_station_selection_changed) + + header_base_stations = self._base_stations_table_view.horizontalHeader() + header_base_stations.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) self._sample_details_checkbox.setChecked(False) - self._samples_table_view.setVisible(False) self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) + self._details_group_box.setVisible(False) - self._bs_linkage_handler = BsLinkageHandler(self._bs_linkage_grid) - - def _selection_changed(self, current: QItemSelection, previous: QItemSelection): + def _sample_selection_changed(self, current: QItemSelection, previous: QItemSelection): + # Called from the sample details table when the selection changes model_indexes = current.indexes() if len(model_indexes) > 0: - self.sample_selection_changed_signal.emit(model_indexes[0].row()) + # model_indexes contains one index per column, just take the first one + row = model_indexes[0].row() + self.sample_selection_changed_signal.emit(row) + + self._base_stations_table_view.clearSelection() else: self.sample_selection_changed_signal.emit(-1) def set_selected_sample(self, index: int): + # Called from the 3D-graph when the selected sample changes + self._base_stations_table_view.clearSelection() + if index >= 0: self._samples_table_view.selectRow(index) else: self._samples_table_view.clearSelection() + def _base_station_selection_changed(self, current: QItemSelection, previous: QItemSelection): + # Called from the base station details table when the selection changes + model_indexes = current.indexes() + + if len(model_indexes) > 0: + # model_indexes contains one index per column, just take the first one + row = model_indexes[0].row() + bs_id = self._base_stations_details_model.get_bs_id_of_row(row) + self.base_station_selection_changed_signal.emit(bs_id) + + self._samples_table_view.clearSelection() + else: + self.base_station_selection_changed_signal.emit(-1) + + def set_selected_base_station(self, bs_id: int): + # Called from the 3D-graph when the selected base station changes + self._samples_table_view.clearSelection() + + if bs_id >= 0: + row = self._base_stations_details_model.get_row_of_bs_id(bs_id) + self._base_stations_table_view.selectRow(row) + else: + self._base_stations_table_view.clearSelection() + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -320,7 +363,7 @@ def _save_to_file(self): def _sample_details_checkbox_state_changed(self, state: int): enabled = state == Qt.CheckState.Checked.value - self._samples_table_view.setVisible(enabled) + self._details_group_box.setVisible(enabled) def _change_step(self, step): """Update the widget to display the new step""" @@ -543,11 +586,13 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): logger.debug(f'General info: {solution.general_failure_info}') self._samples_details_model.setSolution(self._latest_solution) + self._base_stations_details_model.setSolution(self._latest_solution) # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. self.sample_selection_changed_signal.emit(-1) + self.base_station_selection_changed_signal.emit(-1) - # Add action buttons to table + # Add action buttons to sample table for row, sample in enumerate(solution.samples): if sample.status != LhCfPoseSampleStatus.NO_DATA: # Move button @@ -565,8 +610,6 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, DEL_COLUMN), button) - self._bs_linkage_handler.update(solution) - if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -655,6 +698,7 @@ class _TableRowStatus(Enum): INVALID = 1 LARGE_ERROR = 2 VERIFICATION = 3 + TOO_FEW_LINKS = 4 class SampleTableModel(QAbstractTableModel): @@ -737,52 +781,86 @@ def setSolution(self, solution: LighthouseGeometrySolution): self.endResetModel() -class BsLinkageHandler: - STYLE_RED_BACKGROUND = "background-color: lightpink;" - STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" +class BaseStationTableModel(QAbstractTableModel): + def __init__(self, parent=None, *args): + QAbstractTableModel.__init__(self, parent) + self._headers = ['Id', 'X', 'Y', 'Z', 'Samples', 'Links'] + self._table_values = [] + self._table_highlights: list[set[_TableRowStatus]] = [] - def __init__(self, container: QGridLayout): - self._container = container + def rowCount(self, parent=None, *args, **kwargs): + return len(self._table_values) - for bs in range(0, 16): - container.addWidget(self._create_label(str(bs + 1)), 0, bs) - container.addWidget(self._create_label(), 1, bs) + def columnCount(self, parent=None, *args, **kwargs): + return len(self._headers) - def update(self, solution: LighthouseGeometrySolution): - container = self._container - link_count = solution.link_count - threshold = solution.link_count_ok_threshold + def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: + if index.isValid(): + if role == Qt.ItemDataRole.DisplayRole: + if index.column() < len(self._table_values[index.row()]): + value = self._table_values[index.row()][index.column()] + return QVariant(value) - for bs in range(0, 16): - exists = bs in link_count - count = 0 - text = '' - if exists: - count = len(link_count[bs]) - text = f'{count}' - is_ok = count >= threshold + if role == Qt.ItemDataRole.BackgroundRole: + color = None + if _TableRowStatus.TOO_FEW_LINKS in self._table_highlights[index.row()]: + if index.column() == 5: + color = QtGui.QColor(255, 182, 193) - label_1 = container.itemAtPosition(0, bs).widget() - label_1.setVisible(exists) + if color: + return QVariant(QtGui.QBrush(color)) - label_2 = container.itemAtPosition(1, bs).widget() - label_2.setVisible(exists) - label_2.setText(text) - if is_ok: - label_2.setStyleSheet(self.STYLE_GREEN_BACKGROUND) - else: - label_2.setStyleSheet(self.STYLE_RED_BACKGROUND) + return QVariant() + + def headerData(self, col, orientation, role=None): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return QVariant(self._headers[col]) + return QVariant() - def _create_label(self, text=None): - label = QLabel() - label.setMinimumSize(30, 0) - label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) + def setSolution(self, solution: LighthouseGeometrySolution): + """Set the solution and update the table values""" + self.beginResetModel() + self._table_values = [] + self._table_highlights = [] - if text: - label.setText(str(text)) - else: - label.setProperty('frameShape', 'QFrame::Box') - label.setStyleSheet(STYLE_NO_BACKGROUND) + # Dictionary keys may not be ordered, sort by base station ID + for bs_id, pose in sorted(solution.bs_poses.items()): + status: set[_TableRowStatus] = set() + + x = f'{pose.translation[0]:.2f}' + y = f'{pose.translation[1]:.2f}' + z = f'{pose.translation[2]:.2f}' + + link_count = len(solution.link_count[bs_id]) + if link_count < solution.link_count_ok_threshold: + status.add(_TableRowStatus.TOO_FEW_LINKS) + + samples_containing_bs = solution.bs_sample_count[bs_id] + + self._table_values.append([ + bs_id + 1, + x, + y, + z, + samples_containing_bs, + link_count, + ]) + self._table_highlights.append(status) + + self.endResetModel() + + def get_bs_id_of_row(self, row: int) -> int: + """Get the base station ID for a given row""" + if 0 <= row < len(self._table_values): + bs_id = self._table_values[row][0] - 1 # IDs are 1-based in the table + return bs_id + + return -1 + + def get_row_of_bs_id(self, bs_id: int) -> int: + """Get the row index for a given base station ID""" + for row, values in enumerate(self._table_values): + if values[0] - 1 == bs_id: # IDs are 1-based in the table + return row - return label + return -1 From 01fc504bfa5117db1a8f7f989d0533515f173424 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 14:59:09 +0100 Subject: [PATCH 48/73] Converted delete button for samples to context menu --- .../ui/widgets/geo_estimator_widget.py | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index e6885ae7..81206291 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -153,7 +153,6 @@ class _UserNotificationType(Enum): STYLE_NO_BACKGROUND = "background-color: none;" MOVE_COLUMN = 5 -DEL_COLUMN = 6 class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): @@ -233,6 +232,9 @@ def __init__(self, lighthouse_tab): header_samples.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) header_samples.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + self._samples_table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._samples_table_view.customContextMenuRequested.connect(self._create_sample_table_context_menu) + # Create base station details table self._base_stations_details_model = BaseStationTableModel(self) self._base_stations_table_view.setModel(self._base_stations_details_model) @@ -249,6 +251,22 @@ def __init__(self, lighthouse_tab): self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) self._details_group_box.setVisible(False) + def _create_sample_table_context_menu(self, point): + menu = QtWidgets.QMenu() + + delete_action = None + + item = self._samples_table_view.indexAt(point) + row = item.row() + if row >= 0: + delete_action = menu.addAction('Delete sample') + + action = menu.exec(self._samples_table_view.mapToGlobal(point)) + + if action == delete_action: + uid = self._samples_details_model.get_uid_of_row(item.row()) + self._container.remove_sample(uid) + def _sample_selection_changed(self, current: QItemSelection, previous: QItemSelection): # Called from the sample details table when the selection changes model_indexes = current.indexes() @@ -605,11 +623,6 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): uid)) self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, MOVE_COLUMN), button) - # Delete button - button = QPushButton('Del') - button.clicked.connect(lambda _, uid=sample.uid: self._container.remove_sample(uid)) - self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, DEL_COLUMN), button) - if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -704,8 +717,9 @@ class _TableRowStatus(Enum): class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) - self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Move', 'Del'] + self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Move'] self._table_values = [] + self._uids: list [int] = [] self._table_highlights: list[set[_TableRowStatus]] = [] def rowCount(self, parent=None, *args, **kwargs): @@ -745,6 +759,7 @@ def setSolution(self, solution: LighthouseGeometrySolution): """Set the solution and update the table values""" self.beginResetModel() self._table_values = [] + self._uids = [] self._table_highlights = [] for sample in solution.samples: @@ -776,10 +791,18 @@ def setSolution(self, solution: LighthouseGeometrySolution): z, error, ]) + self._uids.append(sample.uid) self._table_highlights.append(status) self.endResetModel() + def get_uid_of_row(self, row: int) -> int: + """Get the sample UID for a given row""" + if 0 <= row < len(self._uids): + return self._uids[row] + + return -1 + class BaseStationTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): From 7d683f76b457e00cbc655bed3504073f3a2b7abc Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 15:15:50 +0100 Subject: [PATCH 49/73] Converted sample type change button to context menu --- .../ui/widgets/geo_estimator_widget.py | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 81206291..c0bcb0b9 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -152,8 +152,6 @@ class _UserNotificationType(Enum): STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" STYLE_NO_BACKGROUND = "background-color: none;" -MOVE_COLUMN = 5 - class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): """Widget for the geometry estimator UI""" @@ -259,13 +257,21 @@ def _create_sample_table_context_menu(self, point): item = self._samples_table_view.indexAt(point) row = item.row() if row >= 0: + uid = self._samples_details_model.get_uid_of_row(item.row()) + sample_type = self._samples_details_model.get_sample_type_of_row(row) + delete_action = menu.addAction('Delete sample') + change_action = menu.addAction('Change type') action = menu.exec(self._samples_table_view.mapToGlobal(point)) if action == delete_action: - uid = self._samples_details_model.get_uid_of_row(item.row()) self._container.remove_sample(uid) + elif action == change_action: + if sample_type == LhCfPoseSampleType.VERIFICATION: + self._container.convert_to_xyz_space_sample(uid) + else: + self._container.convert_to_verification_sample(uid) def _sample_selection_changed(self, current: QItemSelection, previous: QItemSelection): # Called from the sample details table when the selection changes @@ -610,19 +616,6 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): self.sample_selection_changed_signal.emit(-1) self.base_station_selection_changed_signal.emit(-1) - # Add action buttons to sample table - for row, sample in enumerate(solution.samples): - if sample.status != LhCfPoseSampleStatus.NO_DATA: - # Move button - if sample.sample_type == LhCfPoseSampleType.VERIFICATION: - button = QPushButton('To xyz') - button.clicked.connect(lambda _, uid=sample.uid: self._container.convert_to_xyz_space_sample(uid)) - else: - button = QPushButton('To verif.') - button.clicked.connect(lambda _, uid=sample.uid: self._container.convert_to_verification_sample( - uid)) - self._samples_table_view.setIndexWidget(self._samples_details_model.index(row, MOVE_COLUMN), button) - if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -717,9 +710,10 @@ class _TableRowStatus(Enum): class SampleTableModel(QAbstractTableModel): def __init__(self, parent=None, *args): QAbstractTableModel.__init__(self, parent) - self._headers = ['Type', 'X', 'Y', 'Z', 'Err', 'Move'] + self._headers = ['Type', 'X', 'Y', 'Z', 'Err'] self._table_values = [] - self._uids: list [int] = [] + self._uids: list[int] = [] + self._sample_types: list[LhCfPoseSampleType] = [] self._table_highlights: list[set[_TableRowStatus]] = [] def rowCount(self, parent=None, *args, **kwargs): @@ -760,6 +754,7 @@ def setSolution(self, solution: LighthouseGeometrySolution): self.beginResetModel() self._table_values = [] self._uids = [] + self._sample_types = [] self._table_highlights = [] for sample in solution.samples: @@ -792,6 +787,7 @@ def setSolution(self, solution: LighthouseGeometrySolution): error, ]) self._uids.append(sample.uid) + self._sample_types.append(sample.sample_type) self._table_highlights.append(status) self.endResetModel() @@ -801,7 +797,14 @@ def get_uid_of_row(self, row: int) -> int: if 0 <= row < len(self._uids): return self._uids[row] - return -1 + raise IndexError("Row index out of range") + + def get_sample_type_of_row(self, row: int) -> LhCfPoseSampleType: + """Get the sample type for a given row""" + if 0 <= row < len(self._sample_types): + return self._sample_types[row] + + raise IndexError("Row index out of range") class BaseStationTableModel(QAbstractTableModel): From 3c5909796c9318bb1fa590828aa5df54d84ee581 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 15:18:00 +0100 Subject: [PATCH 50/73] refactoring --- src/cfclient/ui/widgets/geo_estimator_widget.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index c0bcb0b9..978c9a61 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -609,8 +609,8 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): logger.debug(f'XYZ space: {solution.xyz_space_samples_info}') logger.debug(f'General info: {solution.general_failure_info}') - self._samples_details_model.setSolution(self._latest_solution) - self._base_stations_details_model.setSolution(self._latest_solution) + self._samples_details_model.set_solution(self._latest_solution) + self._base_stations_details_model.set_solution(self._latest_solution) # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. self.sample_selection_changed_signal.emit(-1) @@ -749,7 +749,7 @@ def headerData(self, col, orientation, role=None): return QVariant(self._headers[col]) return QVariant() - def setSolution(self, solution: LighthouseGeometrySolution): + def set_solution(self, solution: LighthouseGeometrySolution): """Set the solution and update the table values""" self.beginResetModel() self._table_values = [] @@ -843,7 +843,7 @@ def headerData(self, col, orientation, role=None): return QVariant(self._headers[col]) return QVariant() - def setSolution(self, solution: LighthouseGeometrySolution): + def set_solution(self, solution: LighthouseGeometrySolution): """Set the solution and update the table values""" self.beginResetModel() self._table_values = [] From 47f302fc41e8765d11c0a4c66df71bacf0a6ba3d Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 15:28:06 +0100 Subject: [PATCH 51/73] Added checkbox for toggling base station details --- src/cfclient/ui/widgets/geo_estimator.ui | 7 +++++++ src/cfclient/ui/widgets/geo_estimator_widget.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 2f999f81..e901d23a 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -357,6 +357,13 @@ + + + + Show base station details + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 978c9a61..64d088d7 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -247,7 +247,11 @@ def __init__(self, lighthouse_tab): self._sample_details_checkbox.setChecked(False) self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) - self._details_group_box.setVisible(False) + self._samples_table_view.setVisible(False) + + self._base_station_details_checkbox.setChecked(False) + self._base_station_details_checkbox.stateChanged.connect(self._base_station_details_checkbox_state_changed) + self._base_stations_table_view.setVisible(False) def _create_sample_table_context_menu(self, point): menu = QtWidgets.QMenu() @@ -387,7 +391,11 @@ def _save_to_file(self): def _sample_details_checkbox_state_changed(self, state: int): enabled = state == Qt.CheckState.Checked.value - self._details_group_box.setVisible(enabled) + self._samples_table_view.setVisible(enabled) + + def _base_station_details_checkbox_state_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._base_stations_table_view.setVisible(enabled) def _change_step(self, step): """Update the widget to display the new step""" From 8647a1e0aa3373db4e64103d7fea0249469748c4 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 15:55:42 +0100 Subject: [PATCH 52/73] Moved import/export buttons and removed restore session button --- src/cfclient/ui/tabs/lighthouse_tab.py | 9 +--- src/cfclient/ui/tabs/lighthouse_tab.ui | 52 ++++++------------- src/cfclient/ui/widgets/geo_estimator.ui | 21 +++++--- .../ui/widgets/geo_estimator_widget.py | 11 ++-- 4 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index c274bf3f..47e54446 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -649,9 +649,6 @@ def __init__(self, helper): self._change_system_type_button.clicked.connect(lambda: self._system_type_dialog.show()) self._manage_basestation_mode_button.clicked.connect(self._show_basestation_mode_dialog) - self._load_sys_config_button.clicked.connect(self._load_sys_config_button_clicked) - self._save_sys_config_button.clicked.connect(self._save_sys_config_button_clicked) - self._ui_mode = UiMode.flying self._geo_mode_button.toggled.connect(lambda enabled: self._change_ui_mode(enabled)) @@ -872,8 +869,6 @@ def _update_ui(self): enabled = self._is_connected and self.is_lighthouse_deck_active self._manage_estimate_geometry_button.setEnabled(enabled) self._change_system_type_button.setEnabled(enabled) - self._load_sys_config_button.setEnabled(enabled) - self._save_sys_config_button.setEnabled(enabled) self._mode_group.setEnabled(enabled) @@ -1023,7 +1018,7 @@ def _update_basestation_status_indicators(self): label.setStyleSheet(STYLE_RED_BACKGROUND) label.setToolTip('') - def _load_sys_config_button_clicked(self): + def _load_sys_config_user_action(self): if not self._geo_estimator_widget.is_container_empty(): dlg = QMessageBox(self) dlg.setWindowTitle("Clear samples Confirmation") @@ -1046,7 +1041,7 @@ def _load_sys_config_button_clicked(self): # Clear all samples as the new configuration is based on some other (unknown) set of samples self._geo_estimator_widget.new_session() - def _save_sys_config_button_clicked(self): + def _save_sys_config_user_action(self): # Get calibration data from the Crazyflie to complete the system config data set # When the data is ready we get a callback on _calibration_read self._lh_memory_helper.read_all_calibs(self._calibration_read_signal.emit) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index fa9a0687..aa592f08 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -17,7 +17,7 @@ - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -37,7 +37,7 @@ Crazyflie status - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop @@ -76,7 +76,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -106,7 +106,7 @@ - QFrame::NoFrame + QFrame::Shape::NoFrame (0.0 , 0.0 , 0.0) @@ -116,7 +116,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -154,13 +154,13 @@ Basestation Status - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - QLayout::SetMinimumSize + QLayout::SizeConstraint::SetMinimumSize 2 @@ -171,7 +171,7 @@ background-color: lightpink; - QFrame::Box + QFrame::Shape::Box @@ -217,7 +217,7 @@ background-color: lightpink; - QFrame::Box + QFrame::Shape::Box @@ -230,7 +230,7 @@ background-color: lightpink; - QFrame::Box + QFrame::Shape::Box @@ -263,7 +263,7 @@ background-color: lightpink; - QFrame::Box + QFrame::Shape::Box @@ -300,14 +300,14 @@ System Management - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint - + @@ -333,24 +333,6 @@ - - - - - - Save system config - - - - - - - Load system config - - - - - @@ -391,7 +373,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -412,7 +394,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -432,7 +414,7 @@ - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index e901d23a..191b5331 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -373,16 +373,16 @@ - + - Load + Import samples - + - Save as... + Export samples @@ -391,16 +391,23 @@ - + - Restore sesssion + Import solution + + + + + + + Export solution - New session + Clear all samples diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 64d088d7..b0149a83 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -178,9 +178,10 @@ def __init__(self, lighthouse_tab): self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) - self._load_button.clicked.connect(lambda: self._load_from_file(use_session_path=False)) - self._restore_button.clicked.connect(lambda: self._load_from_file(use_session_path=True)) - self._save_button.clicked.connect(self._save_to_file) + self._import_samples_button.clicked.connect(lambda: self._load_from_file(use_session_path=False)) + self._export_samples_button.clicked.connect(self._save_to_file) + self._import_solution_button.clicked.connect(self._lighthouse_tab._load_sys_config_user_action) + self._export_solution_button.clicked.connect(self._lighthouse_tab._save_sys_config_user_action) self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) self._timeout_reader_signal.connect(self._average_available_cb) @@ -441,8 +442,8 @@ def _update_ui_reading(self, is_reading: bool): self._data_status_xyz_space.setEnabled(is_enabled) self._data_status_verification.setEnabled(is_enabled) - self._load_button.setEnabled(is_enabled) - self._save_button.setEnabled(is_enabled) + self._import_samples_button.setEnabled(is_enabled) + self._export_samples_button.setEnabled(is_enabled) self._clear_all_button.setEnabled(is_enabled) def _update_solution_info(self): From ca0088f060f955ba496ff71b4b59932cb0dbbf20 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 16:04:17 +0100 Subject: [PATCH 53/73] Removed old geometry dialog --- .../dialogs/lighthouse_bs_geometry_dialog.py | 215 ------------------ .../dialogs/lighthouse_bs_geometry_dialog.ui | 110 --------- src/cfclient/ui/tabs/lighthouse_tab.py | 11 - src/cfclient/ui/tabs/lighthouse_tab.ui | 7 - 4 files changed, 343 deletions(-) delete mode 100644 src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py delete mode 100644 src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui diff --git a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py deleted file mode 100644 index 5206d109..00000000 --- a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py +++ /dev/null @@ -1,215 +0,0 @@ -# -*- coding: utf-8 -*- -# -# || ____ _ __ -# +------+ / __ )(_) /_______________ _____ ___ -# | 0xBC | / __ / / __/ ___/ ___/ __ `/_ / / _ \ -# +------+ / /_/ / / /_/ /__/ / / /_/ / / /_/ __/ -# || || /_____/_/\__/\___/_/ \__,_/ /___/\___/ -# -# Copyright (C) 2021-2023 Bitcraze AB -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, -# MA 02110-1301, USA. - -""" -Dialog box used to configure base station geometry. Used from the lighthouse tab. -""" -import logging - -import cfclient -from PyQt6 import QtWidgets -from PyQt6 import uic -from PyQt6.QtCore import QVariant, Qt, QAbstractTableModel, pyqtSignal -from cflib.localization import LighthouseBsGeoEstimator -from cflib.localization import LighthouseSweepAngleAverageReader -from cflib.crazyflie.mem import LighthouseBsGeometry - -__author__ = 'Bitcraze AB' -__all__ = ['LighthouseBsGeometryDialog'] - -logger = logging.getLogger(__name__) - -(basestation_geometry_widget_class, connect_widget_base_class) = ( - uic.loadUiType( - cfclient.module_path + '/ui/dialogs/lighthouse_bs_geometry_dialog.ui') -) - - -class LighthouseBsGeometryTableModel(QAbstractTableModel): - def __init__(self, headers, parent=None, *args): - QAbstractTableModel.__init__(self, parent) - self._headers = headers - self._table_values = [] - self._current_geos = {} - self._estimated_geos = {} - - def rowCount(self, parent=None, *args, **kwargs): - return len(self._table_values) - - def columnCount(self, parent=None, *args, **kwargs): - return len(self._headers) - - def data(self, index, role=None): - if index.isValid(): - value = self._table_values[index.row()][index.column()] - if role == Qt.ItemDataRole.DisplayRole: - return QVariant(value) - - return QVariant() - - def headerData(self, col, orientation, role=None): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return QVariant(self._headers[col]) - return QVariant() - - def _compile_entry(self, current_geo, estimated_geo, index): - result = 'N/A' - if current_geo is not None: - result = '%.2f' % current_geo.origin[index] - if estimated_geo is not None: - result += ' -> %.2f' % estimated_geo.origin[index] - - return result - - def _add_table_value(self, current_geo, estimated_geo, id, table_values): - x = self._compile_entry(current_geo, estimated_geo, 0) - y = self._compile_entry(current_geo, estimated_geo, 1) - z = self._compile_entry(current_geo, estimated_geo, 2) - - table_values.append([id + 1, x, y, z]) - - def _add_table_value_for_id(self, current_geos, estimated_geos, table_values, id): - current_geo = None - if id in current_geos: - current_geo = current_geos[id] - - estimated_geo = None - if id in estimated_geos: - estimated_geo = estimated_geos[id] - - if current_geo is not None or estimated_geo is not None: - self._add_table_value(current_geo, estimated_geo, id, table_values) - - def _add_table_values(self, current_geos, estimated_geos, table_values): - current_ids = set(current_geos.keys()) - estimated_ids = set(estimated_geos.keys()) - all_ids = current_ids.union(estimated_ids) - - for id in all_ids: - self._add_table_value_for_id(current_geos, estimated_geos, table_values, id) - - def _update_table_data(self): - self.layoutAboutToBeChanged.emit() - self._table_values = [] - self._add_table_values(self._current_geos, self._estimated_geos, self._table_values) - self._table_values.sort(key=lambda row: row[0]) - self.layoutChanged.emit() - - def set_estimated_geos(self, geos): - self._estimated_geos = geos - self._update_table_data() - - def set_current_geos(self, geos): - self._current_geos = geos - self._update_table_data() - - -class LighthouseBsGeometryDialog(QtWidgets.QWidget, basestation_geometry_widget_class): - - _sweep_angles_received_and_averaged_signal = pyqtSignal(object) - - def __init__(self, lighthouse_tab, *args): - super(LighthouseBsGeometryDialog, self).__init__(*args) - self.setupUi(self) - - self._lighthouse_tab = lighthouse_tab - - self._simple_estimator = LighthouseBsGeoEstimator() - self._estimate_geometry_simple_button.clicked.connect(self._estimate_geometry_simple_button_clicked) - try: - if not self._simple_estimator.is_available(): - self._estimate_geometry_simple_button.setEnabled(False) - except Exception as e: - print(e) - - self._write_to_cf_button.clicked.connect(self._write_to_cf_button_clicked) - - self._sweep_angles_received_and_averaged_signal.connect(self._sweep_angles_received_and_averaged_cb) - self._close_button.clicked.connect(self.close) - - self._sweep_angle_reader = LighthouseSweepAngleAverageReader( - self._lighthouse_tab._helper.cf, self._sweep_angles_received_and_averaged_signal.emit) - - self._lh_geos = None - self._newly_estimated_geometry = {} - - # Table handlers - self._headers = ['id', 'x', 'y', 'z'] - self._data_model = LighthouseBsGeometryTableModel(self._headers, self) - self._table_view.setModel(self._data_model) - - self._table_view.verticalHeader().setVisible(False) - - header = self._table_view.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Stretch) - - self._update_ui() - - def reset(self): - self._newly_estimated_geometry = {} - self._update_ui() - - def _sweep_angles_received_and_averaged_cb(self, averaged_angles): - self._averaged_angles = averaged_angles - self._newly_estimated_geometry = {} - - for id, average_data in averaged_angles.items(): - sensor_data = average_data[1] - rotation_bs_matrix, position_bs_vector = self._simple_estimator.estimate_geometry(sensor_data) - geo = LighthouseBsGeometry() - geo.rotation_matrix = rotation_bs_matrix - geo.origin = position_bs_vector - geo.valid = True - self._newly_estimated_geometry[id] = geo - - self._update_ui() - - def _estimate_geometry_simple_button_clicked(self): - self._sweep_angle_reader.start_angle_collection() - self._update_ui() - - def _write_to_cf_button_clicked(self): - if len(self._newly_estimated_geometry) > 0: - self._lighthouse_tab.write_and_store_geometry(self._newly_estimated_geometry) - self._newly_estimated_geometry = {} - - self._update_ui() - - def _update_ui(self): - self._write_to_cf_button.setEnabled(len(self._newly_estimated_geometry) > 0) - self._data_model.set_estimated_geos(self._newly_estimated_geometry) - - def closeEvent(self, event): - self._stop_collection() - - def _stop_collection(self): - self._sweep_angle_reader.stop_angle_collection() - - def geometry_updated(self, geometry): - self._data_model.set_current_geos(geometry) - - self._update_ui() diff --git a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui b/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui deleted file mode 100644 index ffc97391..00000000 --- a/src/cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui +++ /dev/null @@ -1,110 +0,0 @@ - - - Form - - - Qt::ApplicationModal - - - - 0 - 0 - 443 - 555 - - - - Basestation Geometry Managment - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Estimate Geometry Simple - - - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Horizontal - - - - - - - - - Write to Crazyflie - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Close - - - - - - - - - - - - diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 47e54446..87f54b57 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -50,7 +50,6 @@ from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry -from cfclient.ui.dialogs.lighthouse_bs_geometry_dialog import LighthouseBsGeometryDialog from cfclient.ui.dialogs.basestation_mode_dialog import LighthouseBsModeDialog from cfclient.ui.dialogs.lighthouse_system_type_dialog import LighthouseSystemTypeDialog from cfclient.utils.logconfigreader import FILE_REGEX_YAML @@ -641,11 +640,9 @@ def __init__(self, helper): self._graph_timer.timeout.connect(self._update_graphics) self._graph_timer.start() - self._basestation_geometry_dialog = LighthouseBsGeometryDialog(self) self._basestation_mode_dialog = LighthouseBsModeDialog(self) self._system_type_dialog = LighthouseSystemTypeDialog(helper) - self._manage_estimate_geometry_button.clicked.connect(self._show_basestation_geometry_dialog) self._change_system_type_button.clicked.connect(lambda: self._system_type_dialog.show()) self._manage_basestation_mode_button.clicked.connect(self._show_basestation_mode_dialog) @@ -678,10 +675,6 @@ def _new_system_config_written_to_cf(self, success): # New geo data has been written and stored in the CF, read it back to update the UI self._start_read_of_geo_data() - def _show_basestation_geometry_dialog(self): - self._basestation_geometry_dialog.reset() - self._basestation_geometry_dialog.show() - def _show_basestation_mode_dialog(self): self._basestation_mode_dialog.reset() self._basestation_mode_dialog.show() @@ -698,7 +691,6 @@ def _connected(self, link_uri): """Callback when the Crazyflie has been connected""" logger.debug("Crazyflie connected to {}".format(link_uri)) - self._basestation_geometry_dialog.reset() self._flying_mode_button.setChecked(True) self._is_connected = True @@ -740,7 +732,6 @@ def _start_read_of_geo_data(self): def _geometry_read_cb(self, geometries): # Remove any geo data where the valid flag is False self._lh_geos = dict(filter(lambda key_value: key_value[1].valid, geometries.items())) - self._basestation_geometry_dialog.geometry_updated(self._lh_geos) self._is_geometry_read_ongoing = False def _solution_updated_cb(self, solution: LighthouseGeometrySolution): @@ -805,7 +796,6 @@ def _disconnected(self, link_uri): self._clear_state() self._update_graphics() self._plot_3d.clear() - self._basestation_geometry_dialog.close() self._flying_mode_button.setChecked(True) self.is_lighthouse_deck_active = False self._is_connected = False @@ -867,7 +857,6 @@ def _update_graphics(self): def _update_ui(self): enabled = self._is_connected and self.is_lighthouse_deck_active - self._manage_estimate_geometry_button.setEnabled(enabled) self._change_system_type_button.setEnabled(enabled) self._mode_group.setEnabled(enabled) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index aa592f08..e3373244 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -310,13 +310,6 @@ - - - - Manage geometry - - - From f23e9f91eb337ff85a554ef35e936802199dedd3 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 16:33:22 +0100 Subject: [PATCH 54/73] Reorganized the System Management UI --- src/cfclient/ui/tabs/lighthouse_tab.py | 3 +- src/cfclient/ui/tabs/lighthouse_tab.ui | 376 +++++++++++++------------ 2 files changed, 201 insertions(+), 178 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 87f54b57..372bc01c 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -859,7 +859,8 @@ def _update_ui(self): enabled = self._is_connected and self.is_lighthouse_deck_active self._change_system_type_button.setEnabled(enabled) - self._mode_group.setEnabled(enabled) + self._flying_mode_button.setEnabled(enabled) + self._geo_mode_button.setEnabled(enabled) self._geo_estimator_widget.setVisible(self._ui_mode == UiMode.geo_estimation and enabled) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index e3373244..66a368e9 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -10,6 +10,12 @@ 742 + + + 0 + 0 + + Plot @@ -20,118 +26,215 @@ QLayout::SizeConstraint::SetDefaultConstraint - - - - 0 - 0 - - - - - 0 - 0 - - - - Crazyflie status - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - - + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Crazyflie status + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + - + - - - Status: - - + + + + + Status: + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + - + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + - - - - 0 - 0 - - - - - 200 - 0 - - - - - - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - + + + + + Position: + + + + + + + + 150 + 0 + + + + QFrame::Shape::NoFrame + + + (0.0 , 0.0 , 0.0) + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + System Management + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + + QLayout::SizeConstraint::SetDefaultConstraint + - - - - - Position: - - - + - - - - 150 - 0 - - - - QFrame::Shape::NoFrame - - - (0.0 , 0.0 , 0.0) - - + + + + + Set BS channel + + + + + + + Switch BS version + + + + - - - Qt::Orientation::Horizontal + + + QLayout::SizeConstraint::SetMaximumSize - - - 40 - 20 - - - + + + + Geometry mode + + + + + + + On + + + + + + + Off + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + - - - + + + @@ -282,87 +385,6 @@ - - - - - 0 - 0 - - - - - 0 - 0 - - - - System Management - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - - QLayout::SizeConstraint::SetDefaultConstraint - - - - - - - - - Change system type - - - - - - - Set BS channel - - - - - - - - - - - - - - - 0 - 0 - - - - Mode - - - - - - Flying - - - true - - - - - - - Geometry - - - - - - From 606fbb6732077fcba8093664060908bbc6b982f1 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Mon, 2 Feb 2026 21:23:41 +0100 Subject: [PATCH 55/73] Break out samples and bs from GeoEstimatorWidget into separate widget --- src/cfclient/ui/tabs/lighthouse_tab.py | 26 +- src/cfclient/ui/tabs/lighthouse_tab.ui | 16 +- src/cfclient/ui/widgets/geo_estimator.ui | 171 +++----- .../ui/widgets/geo_estimator_details.ui | 112 ++++++ .../widgets/geo_estimator_details_widget.py | 369 ++++++++++++++++++ .../ui/widgets/geo_estimator_widget.py | 328 +--------------- 6 files changed, 573 insertions(+), 449 deletions(-) create mode 100644 src/cfclient/ui/widgets/geo_estimator_details.ui create mode 100644 src/cfclient/ui/widgets/geo_estimator_details_widget.py diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 372bc01c..ccbb7a32 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -41,6 +41,7 @@ from cfclient.ui.tab_toolbox import TabToolbox from cfclient.ui.widgets.geo_estimator_widget import GeoEstimatorWidget +from cfclient.ui.widgets.geo_estimator_details_widget import GeoEstimatorDetailsWidget from cflib.crazyflie.log import LogConfig from cflib.crazyflie.mem import LighthouseMemHelper from cflib.localization import LighthouseConfigWriter @@ -583,11 +584,24 @@ def __init__(self, helper): super(LighthouseTab, self).__init__(helper, 'Lighthouse Positioning') self.setupUi(self) + # Add the geometry estimator widget self._geo_estimator_widget = GeoEstimatorWidget(self) self._geometry_area.addWidget(self._geo_estimator_widget) self._geo_estimator_widget.solution_ready_signal.connect(self._solution_updated_cb) - self._geo_estimator_widget.sample_selection_changed_signal.connect(self._sample_selection_changed_cb) - self._geo_estimator_widget.base_station_selection_changed_signal.connect(self._base_station_selection_changed_cb) + + # Add the geometry estimator details widget + self._geo_estimator_details_widget = GeoEstimatorDetailsWidget() + self._details_area.addWidget(self._geo_estimator_details_widget) + self._geo_estimator_details_widget.sample_selection_changed_signal.connect(self._sample_selection_changed_cb) + self._geo_estimator_details_widget.base_station_selection_changed_signal.connect(self._base_station_selection_changed_cb) + + # Connect signals between the geo estimator widget and the details widget + self._geo_estimator_widget.solution_ready_signal.connect(self._geo_estimator_details_widget.solution_ready_cb) + self._geo_estimator_widget._base_station_details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.base_station_details_checkbox_state_changed) + self._geo_estimator_widget._sample_details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.sample_details_checkbox_state_changed) + self._geo_estimator_details_widget.do_remove_sample_signal.connect(self._geo_estimator_widget.remove_sample) + self._geo_estimator_details_widget.do_convert_to_xyz_space_sample_signal.connect(self._geo_estimator_widget.convert_to_xyz_space_sample) + self._geo_estimator_details_widget.do_convert_to_verification_sample_signal.connect(self._geo_estimator_widget.convert_to_verification_sample) # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it @@ -598,8 +612,8 @@ def __init__(self, helper): self._new_system_config_written_to_cf_signal.connect(self._new_system_config_written_to_cf) self._geometry_read_signal.connect(self._geometry_read_cb) self._calibration_read_signal.connect(self._calibration_read_cb) - self._sample_clicked_signal.connect(self._geo_estimator_widget.set_selected_sample) - self._base_station_clicked_signal.connect(self._geo_estimator_widget.set_selected_base_station) + self._sample_clicked_signal.connect(self._geo_estimator_details_widget.set_selected_sample) + self._base_station_clicked_signal.connect(self._geo_estimator_details_widget.set_selected_base_station) # Connect the Crazyflie API callbacks to the signals self._helper.cf.connected.add_callback(self._connected_signal.emit) @@ -862,7 +876,9 @@ def _update_ui(self): self._flying_mode_button.setEnabled(enabled) self._geo_mode_button.setEnabled(enabled) - self._geo_estimator_widget.setVisible(self._ui_mode == UiMode.geo_estimation and enabled) + is_geo_visible = self._ui_mode == UiMode.geo_estimation and enabled + self._geo_estimator_widget.setVisible(is_geo_visible) + self._geo_estimator_details_widget.setVisible(is_geo_visible) def _update_position_label(self, position): if len(position) == 3: diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index 66a368e9..96b97b7c 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -403,9 +403,15 @@ - + + + + + + QLayout::SizeConstraint::SetMinimumSize + @@ -421,13 +427,13 @@ + + + - - - - + Qt::Orientation::Vertical diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 191b5331..f4e451da 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -20,99 +20,8 @@ Form - - - - - true - - - - Geometry estimator - - - - - - - - - - Samples - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::SelectionMode::SingleSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - false - - - true - - - false - - - false - - - - - - - Base stations - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::SelectionMode::SingleSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - false - - - true - - - false - - - - - - @@ -369,49 +278,59 @@ Session management - + - + - - - Import samples - - + + + + + Import samples + + + + + + + Export samples + + + + - - - Export samples - - + + + + + Import solution + + + + + + + Export solution + + + + - - - - - Import solution - - - - - - - Export solution - - - - - - - Clear all samples - - - - + + + + 0 + 0 + + + + Clear all samples + + diff --git a/src/cfclient/ui/widgets/geo_estimator_details.ui b/src/cfclient/ui/widgets/geo_estimator_details.ui new file mode 100644 index 00000000..a185bccd --- /dev/null +++ b/src/cfclient/ui/widgets/geo_estimator_details.ui @@ -0,0 +1,112 @@ + + + Form + + + + 0 + 0 + 702 + 793 + + + + + 0 + 0 + + + + Form + + + + + + + + + Samples + + + + + + + + 0 + 0 + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + true + + + false + + + false + + + + + + + + + + + + + Base stations + + + + + + + + 0 + 0 + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + true + + + false + + + + + + + + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_details_widget.py b/src/cfclient/ui/widgets/geo_estimator_details_widget.py new file mode 100644 index 00000000..f2a973db --- /dev/null +++ b/src/cfclient/ui/widgets/geo_estimator_details_widget.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# || ____ _ __ +# +------+ / __ )(_) /_______________ _____ ___ +# | 0xBC | / __ / / __/ ___/ ___/ __ `/_ / / _ \ +# +------+ / /_/ / / /_/ /__/ / / /_/ / / /_/ __/ +# || || /_____/_/\__/\___/_/ \__,_/ /___/\___/ +# +# Copyright (C) 2026 Bitcraze AB +# +# Crazyflie Nano Quadcopter Client +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +""" +Container for the geometry estimation functionality in the lighthouse tab. +""" + +import logging +from enum import Enum + +from PyQt6 import QtCore, QtWidgets, uic, QtGui +from PyQt6.QtCore import QAbstractTableModel, QVariant, Qt, QModelIndex, QItemSelection + +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSampleType +from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution +import cfclient + +__author__ = 'Bitcraze AB' +__all__ = ['GeoEstimatorDetailsWidget'] + +logger = logging.getLogger(__name__) + +(geo_estimator_details_widget_class, connect_widget_base_class) = ( + uic.loadUiType(cfclient.module_path + '/ui/widgets/geo_estimator_details.ui')) + + +class GeoEstimatorDetailsWidget(QtWidgets.QWidget, geo_estimator_details_widget_class): + """Widget for the samples and base stations details of the geometry estimator UI""" + + sample_selection_changed_signal = QtCore.pyqtSignal(int) + base_station_selection_changed_signal = QtCore.pyqtSignal(int) + do_remove_sample_signal = QtCore.pyqtSignal(int) + do_convert_to_xyz_space_sample_signal = QtCore.pyqtSignal(int) + do_convert_to_verification_sample_signal = QtCore.pyqtSignal(int) + + def __init__(self): + super(GeoEstimatorDetailsWidget, self).__init__() + self.setupUi(self) + + # Create sample details table + self._samples_details_model = SampleTableModel(self) + self._samples_table_view.setModel(self._samples_details_model) + self._samples_table_view.selectionModel().selectionChanged.connect(self._sample_selection_changed) + + header_samples = self._samples_table_view.horizontalHeader() + header_samples.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_samples.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + + self._samples_table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._samples_table_view.customContextMenuRequested.connect(self._create_sample_table_context_menu) + + # Create base station details table + self._base_stations_details_model = BaseStationTableModel(self) + self._base_stations_table_view.setModel(self._base_stations_details_model) + self._base_stations_table_view.selectionModel().selectionChanged.connect(self._base_station_selection_changed) + + header_base_stations = self._base_stations_table_view.horizontalHeader() + header_base_stations.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header_base_stations.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + + self._samples_widget.setVisible(False) + self._base_stations_widget.setVisible(False) + + def _create_sample_table_context_menu(self, point): + menu = QtWidgets.QMenu() + + delete_action = None + + item = self._samples_table_view.indexAt(point) + row = item.row() + if row >= 0: + uid = self._samples_details_model.get_uid_of_row(item.row()) + sample_type = self._samples_details_model.get_sample_type_of_row(row) + + delete_action = menu.addAction('Delete sample') + change_action = menu.addAction('Change type') + + action = menu.exec(self._samples_table_view.mapToGlobal(point)) + + if action == delete_action: + self.do_remove_sample_signal.emit(uid) + elif action == change_action: + if sample_type == LhCfPoseSampleType.VERIFICATION: + self.do_convert_to_xyz_space_sample_signal.emit(uid) + else: + self.do_convert_to_verification_sample_signal.emit(uid) + + def _sample_selection_changed(self, current: QItemSelection, previous: QItemSelection): + # Called from the sample details table when the selection changes + model_indexes = current.indexes() + + if len(model_indexes) > 0: + # model_indexes contains one index per column, just take the first one + row = model_indexes[0].row() + self.sample_selection_changed_signal.emit(row) + + self._base_stations_table_view.clearSelection() + else: + self.sample_selection_changed_signal.emit(-1) + + def _base_station_selection_changed(self, current: QItemSelection, previous: QItemSelection): + # Called from the base station details table when the selection changes + model_indexes = current.indexes() + + if len(model_indexes) > 0: + # model_indexes contains one index per column, just take the first one + row = model_indexes[0].row() + bs_id = self._base_stations_details_model.get_bs_id_of_row(row) + self.base_station_selection_changed_signal.emit(bs_id) + + self._samples_table_view.clearSelection() + else: + self.base_station_selection_changed_signal.emit(-1) + + def solution_ready_cb(self, solution: LighthouseGeometrySolution): + self._samples_details_model.set_solution(solution) + self._base_stations_details_model.set_solution(solution) + + # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. + self.sample_selection_changed_signal.emit(-1) + self.base_station_selection_changed_signal.emit(-1) + + def set_selected_sample(self, index: int): + # Called from the 3D-graph when the selected sample changes + self._base_stations_table_view.clearSelection() + + if index >= 0: + self._samples_table_view.selectRow(index) + else: + self._samples_table_view.clearSelection() + + def set_selected_base_station(self, bs_id: int): + # Called from the 3D-graph when the selected base station changes + self._samples_table_view.clearSelection() + + if bs_id >= 0: + row = self._base_stations_details_model.get_row_of_bs_id(bs_id) + self._base_stations_table_view.selectRow(row) + else: + self._base_stations_table_view.clearSelection() + + def sample_details_checkbox_state_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._samples_widget.setVisible(enabled) + + def base_station_details_checkbox_state_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._base_stations_widget.setVisible(enabled) + + +class _TableRowStatus(Enum): + INVALID = 1 + LARGE_ERROR = 2 + VERIFICATION = 3 + TOO_FEW_LINKS = 4 + + +class SampleTableModel(QAbstractTableModel): + def __init__(self, parent=None, *args): + QAbstractTableModel.__init__(self, parent) + self._headers = ['Type', 'X', 'Y', 'Z', 'Err'] + self._table_values = [] + self._uids: list[int] = [] + self._sample_types: list[LhCfPoseSampleType] = [] + self._table_highlights: list[set[_TableRowStatus]] = [] + + def rowCount(self, parent=None, *args, **kwargs): + return len(self._table_values) + + def columnCount(self, parent=None, *args, **kwargs): + return len(self._headers) + + def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: + if index.isValid(): + if role == Qt.ItemDataRole.DisplayRole: + if index.column() < len(self._table_values[index.row()]): + value = self._table_values[index.row()][index.column()] + return QVariant(value) + + if role == Qt.ItemDataRole.BackgroundRole: + color = None + if _TableRowStatus.VERIFICATION in self._table_highlights[index.row()]: + color = QtGui.QColor(255, 255, 230) + if _TableRowStatus.INVALID in self._table_highlights[index.row()]: + color = Qt.GlobalColor.gray + if _TableRowStatus.LARGE_ERROR in self._table_highlights[index.row()]: + if index.column() == 4: + color = QtGui.QColor(255, 182, 193) + + if color: + return QVariant(QtGui.QBrush(color)) + + return QVariant() + + def headerData(self, col, orientation, role=None): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return QVariant(self._headers[col]) + return QVariant() + + def set_solution(self, solution: LighthouseGeometrySolution): + """Set the solution and update the table values""" + self.beginResetModel() + self._table_values = [] + self._uids = [] + self._sample_types = [] + self._table_highlights = [] + + for sample in solution.samples: + status: set[_TableRowStatus] = set() + x = y = z = '--' + error = '--' + + if sample.sample_type == LhCfPoseSampleType.VERIFICATION: + status.add(_TableRowStatus.VERIFICATION) + + if sample.is_valid: + if sample.has_pose: + error = f'{sample.error_distance * 1000:.1f} mm' + pose = sample.pose + x = f'{pose.translation[0]:.2f}' + y = f'{pose.translation[1]:.2f}' + z = f'{pose.translation[2]:.2f}' + + if sample.is_error_large: + status.add(_TableRowStatus.LARGE_ERROR) + else: + error = f'{sample.status}' + status.add(_TableRowStatus.INVALID) + + self._table_values.append([ + f'{sample.sample_type}', + x, + y, + z, + error, + ]) + self._uids.append(sample.uid) + self._sample_types.append(sample.sample_type) + self._table_highlights.append(status) + + self.endResetModel() + + def get_uid_of_row(self, row: int) -> int: + """Get the sample UID for a given row""" + if 0 <= row < len(self._uids): + return self._uids[row] + + raise IndexError("Row index out of range") + + def get_sample_type_of_row(self, row: int) -> LhCfPoseSampleType: + """Get the sample type for a given row""" + if 0 <= row < len(self._sample_types): + return self._sample_types[row] + + raise IndexError("Row index out of range") + + +class BaseStationTableModel(QAbstractTableModel): + def __init__(self, parent=None, *args): + QAbstractTableModel.__init__(self, parent) + self._headers = ['Id', 'X', 'Y', 'Z', 'Samples', 'Links'] + self._table_values = [] + self._table_highlights: list[set[_TableRowStatus]] = [] + + def rowCount(self, parent=None, *args, **kwargs): + return len(self._table_values) + + def columnCount(self, parent=None, *args, **kwargs): + return len(self._headers) + + def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: + if index.isValid(): + if role == Qt.ItemDataRole.DisplayRole: + if index.column() < len(self._table_values[index.row()]): + value = self._table_values[index.row()][index.column()] + return QVariant(value) + + if role == Qt.ItemDataRole.BackgroundRole: + color = None + if _TableRowStatus.TOO_FEW_LINKS in self._table_highlights[index.row()]: + if index.column() == 5: + color = QtGui.QColor(255, 182, 193) + + if color: + return QVariant(QtGui.QBrush(color)) + + return QVariant() + + def headerData(self, col, orientation, role=None): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return QVariant(self._headers[col]) + return QVariant() + + def set_solution(self, solution: LighthouseGeometrySolution): + """Set the solution and update the table values""" + self.beginResetModel() + self._table_values = [] + self._table_highlights = [] + + # Dictionary keys may not be ordered, sort by base station ID + for bs_id, pose in sorted(solution.bs_poses.items()): + status: set[_TableRowStatus] = set() + + x = f'{pose.translation[0]:.2f}' + y = f'{pose.translation[1]:.2f}' + z = f'{pose.translation[2]:.2f}' + + link_count = len(solution.link_count[bs_id]) + if link_count < solution.link_count_ok_threshold: + status.add(_TableRowStatus.TOO_FEW_LINKS) + + samples_containing_bs = solution.bs_sample_count[bs_id] + + self._table_values.append([ + bs_id + 1, + x, + y, + z, + samples_containing_bs, + link_count, + ]) + self._table_highlights.append(status) + + self.endResetModel() + + def get_bs_id_of_row(self, row: int) -> int: + """Get the base station ID for a given row""" + if 0 <= row < len(self._table_values): + bs_id = self._table_values[row][0] - 1 # IDs are 1-based in the table + return bs_id + + return -1 + + def get_row_of_bs_id(self, bs_id: int) -> int: + """Get the row index for a given base station ID""" + for row, values in enumerate(self._table_values): + if values[0] - 1 == bs_id: # IDs are 1-based in the table + return row + + return -1 diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index b0149a83..89b10597 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -159,10 +159,8 @@ class GeoEstimatorWidget(QtWidgets.QWidget, geo_estimator_widget_class): _timeout_reader_signal = QtCore.pyqtSignal(object) _container_updated_signal = QtCore.pyqtSignal() _user_notification_signal = QtCore.pyqtSignal(object) - start_solving_signal = QtCore.pyqtSignal() + _start_solving_signal = QtCore.pyqtSignal() solution_ready_signal = QtCore.pyqtSignal(object) - sample_selection_changed_signal = QtCore.pyqtSignal(int) - base_station_selection_changed_signal = QtCore.pyqtSignal(int) FILE_REGEX_YAML = "Config *.yaml;;All *.*" @@ -205,7 +203,7 @@ def __init__(self, lighthouse_tab): self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution([]) self._current_step = _CollectionStep.ORIGIN - self.start_solving_signal.connect(self._start_solving_cb) + self._start_solving_signal.connect(self._start_solving_cb) self.solution_ready_signal.connect(self._solution_ready_cb) self._is_solving = False self._solver_thread = None @@ -220,109 +218,8 @@ def __init__(self, lighthouse_tab): self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) self._data_status_verification.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) - # Create sample details table - self._samples_details_model = SampleTableModel(self) - self._samples_table_view.setModel(self._samples_details_model) - self._samples_table_view.selectionModel().selectionChanged.connect(self._sample_selection_changed) - - header_samples = self._samples_table_view.horizontalHeader() - header_samples.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_samples.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_samples.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_samples.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - - self._samples_table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self._samples_table_view.customContextMenuRequested.connect(self._create_sample_table_context_menu) - - # Create base station details table - self._base_stations_details_model = BaseStationTableModel(self) - self._base_stations_table_view.setModel(self._base_stations_details_model) - self._base_stations_table_view.selectionModel().selectionChanged.connect(self._base_station_selection_changed) - - header_base_stations = self._base_stations_table_view.horizontalHeader() - header_base_stations.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_base_stations.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_base_stations.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_base_stations.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header_base_stations.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - self._sample_details_checkbox.setChecked(False) - self._sample_details_checkbox.stateChanged.connect(self._sample_details_checkbox_state_changed) - self._samples_table_view.setVisible(False) - self._base_station_details_checkbox.setChecked(False) - self._base_station_details_checkbox.stateChanged.connect(self._base_station_details_checkbox_state_changed) - self._base_stations_table_view.setVisible(False) - - def _create_sample_table_context_menu(self, point): - menu = QtWidgets.QMenu() - - delete_action = None - - item = self._samples_table_view.indexAt(point) - row = item.row() - if row >= 0: - uid = self._samples_details_model.get_uid_of_row(item.row()) - sample_type = self._samples_details_model.get_sample_type_of_row(row) - - delete_action = menu.addAction('Delete sample') - change_action = menu.addAction('Change type') - - action = menu.exec(self._samples_table_view.mapToGlobal(point)) - - if action == delete_action: - self._container.remove_sample(uid) - elif action == change_action: - if sample_type == LhCfPoseSampleType.VERIFICATION: - self._container.convert_to_xyz_space_sample(uid) - else: - self._container.convert_to_verification_sample(uid) - - def _sample_selection_changed(self, current: QItemSelection, previous: QItemSelection): - # Called from the sample details table when the selection changes - model_indexes = current.indexes() - - if len(model_indexes) > 0: - # model_indexes contains one index per column, just take the first one - row = model_indexes[0].row() - self.sample_selection_changed_signal.emit(row) - - self._base_stations_table_view.clearSelection() - else: - self.sample_selection_changed_signal.emit(-1) - - def set_selected_sample(self, index: int): - # Called from the 3D-graph when the selected sample changes - self._base_stations_table_view.clearSelection() - - if index >= 0: - self._samples_table_view.selectRow(index) - else: - self._samples_table_view.clearSelection() - - def _base_station_selection_changed(self, current: QItemSelection, previous: QItemSelection): - # Called from the base station details table when the selection changes - model_indexes = current.indexes() - - if len(model_indexes) > 0: - # model_indexes contains one index per column, just take the first one - row = model_indexes[0].row() - bs_id = self._base_stations_details_model.get_bs_id_of_row(row) - self.base_station_selection_changed_signal.emit(bs_id) - - self._samples_table_view.clearSelection() - else: - self.base_station_selection_changed_signal.emit(-1) - - def set_selected_base_station(self, bs_id: int): - # Called from the 3D-graph when the selected base station changes - self._samples_table_view.clearSelection() - - if bs_id >= 0: - row = self._base_stations_details_model.get_row_of_bs_id(bs_id) - self._base_stations_table_view.selectRow(row) - else: - self._base_stations_table_view.clearSelection() def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) @@ -332,7 +229,7 @@ def setVisible(self, visible: bool): self._solver_thread = LhGeoEstimationManager.SolverThread(self._container, is_done_cb=self.solution_ready_signal.emit, is_starting_estimation_cb=( - self.start_solving_signal.emit)) + self._start_solving_signal.emit)) self._solver_thread.start() else: self._action_detector.stop() @@ -358,6 +255,18 @@ def is_container_empty(self) -> bool: """Check if the container has any samples""" return self._container.is_empty() + def remove_sample(self, uid: int) -> None: + """Delete a sample by its unique ID""" + self._container.remove_sample(uid) + + def convert_to_xyz_space_sample(self, uid: int) -> None: + """Convert a sample to an xyz-space sample by its unique ID""" + self._container.convert_to_xyz_space_sample(uid) + + def convert_to_verification_sample(self, uid: int) -> None: + """Convert a sample to a verification sample by its unique ID""" + self._container.convert_to_verification_sample(uid) + def _load_from_file(self, use_session_path=False): path = self._session_path if use_session_path else self._helper.current_folder names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) @@ -390,14 +299,6 @@ def _save_to_file(self): with open(file_name, 'w', encoding='UTF8') as handle: self._container.save_as_yaml_file(handle) - def _sample_details_checkbox_state_changed(self, state: int): - enabled = state == Qt.CheckState.Checked.value - self._samples_table_view.setVisible(enabled) - - def _base_station_details_checkbox_state_changed(self, state: int): - enabled = state == Qt.CheckState.Checked.value - self._base_stations_table_view.setVisible(enabled) - def _change_step(self, step): """Update the widget to display the new step""" if step != self._current_step: @@ -618,13 +519,6 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): logger.debug(f'XYZ space: {solution.xyz_space_samples_info}') logger.debug(f'General info: {solution.general_failure_info}') - self._samples_details_model.set_solution(self._latest_solution) - self._base_stations_details_model.set_solution(self._latest_solution) - - # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. - self.sample_selection_changed_signal.emit(-1) - self.base_station_selection_changed_signal.emit(-1) - if solution.progress_is_ok: self._upload_geometry(solution.bs_poses) @@ -707,195 +601,3 @@ def _reader_ready_cb(self, recorded_angles: dict[int, tuple[int, LighthouseBsVec result = LhCfPoseSample(angles_calibrated) self._ready_cb(result) - - -class _TableRowStatus(Enum): - INVALID = 1 - LARGE_ERROR = 2 - VERIFICATION = 3 - TOO_FEW_LINKS = 4 - - -class SampleTableModel(QAbstractTableModel): - def __init__(self, parent=None, *args): - QAbstractTableModel.__init__(self, parent) - self._headers = ['Type', 'X', 'Y', 'Z', 'Err'] - self._table_values = [] - self._uids: list[int] = [] - self._sample_types: list[LhCfPoseSampleType] = [] - self._table_highlights: list[set[_TableRowStatus]] = [] - - def rowCount(self, parent=None, *args, **kwargs): - return len(self._table_values) - - def columnCount(self, parent=None, *args, **kwargs): - return len(self._headers) - - def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: - if index.isValid(): - if role == Qt.ItemDataRole.DisplayRole: - if index.column() < len(self._table_values[index.row()]): - value = self._table_values[index.row()][index.column()] - return QVariant(value) - - if role == Qt.ItemDataRole.BackgroundRole: - color = None - if _TableRowStatus.VERIFICATION in self._table_highlights[index.row()]: - color = QtGui.QColor(255, 255, 230) - if _TableRowStatus.INVALID in self._table_highlights[index.row()]: - color = Qt.GlobalColor.gray - if _TableRowStatus.LARGE_ERROR in self._table_highlights[index.row()]: - if index.column() == 4: - color = QtGui.QColor(255, 182, 193) - - if color: - return QVariant(QtGui.QBrush(color)) - - return QVariant() - - def headerData(self, col, orientation, role=None): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return QVariant(self._headers[col]) - return QVariant() - - def set_solution(self, solution: LighthouseGeometrySolution): - """Set the solution and update the table values""" - self.beginResetModel() - self._table_values = [] - self._uids = [] - self._sample_types = [] - self._table_highlights = [] - - for sample in solution.samples: - status: set[_TableRowStatus] = set() - x = y = z = '--' - error = '--' - - if sample.sample_type == LhCfPoseSampleType.VERIFICATION: - status.add(_TableRowStatus.VERIFICATION) - - if sample.is_valid: - if sample.has_pose: - error = f'{sample.error_distance * 1000:.1f} mm' - pose = sample.pose - x = f'{pose.translation[0]:.2f}' - y = f'{pose.translation[1]:.2f}' - z = f'{pose.translation[2]:.2f}' - - if sample.is_error_large: - status.add(_TableRowStatus.LARGE_ERROR) - else: - error = f'{sample.status}' - status.add(_TableRowStatus.INVALID) - - self._table_values.append([ - f'{sample.sample_type}', - x, - y, - z, - error, - ]) - self._uids.append(sample.uid) - self._sample_types.append(sample.sample_type) - self._table_highlights.append(status) - - self.endResetModel() - - def get_uid_of_row(self, row: int) -> int: - """Get the sample UID for a given row""" - if 0 <= row < len(self._uids): - return self._uids[row] - - raise IndexError("Row index out of range") - - def get_sample_type_of_row(self, row: int) -> LhCfPoseSampleType: - """Get the sample type for a given row""" - if 0 <= row < len(self._sample_types): - return self._sample_types[row] - - raise IndexError("Row index out of range") - - -class BaseStationTableModel(QAbstractTableModel): - def __init__(self, parent=None, *args): - QAbstractTableModel.__init__(self, parent) - self._headers = ['Id', 'X', 'Y', 'Z', 'Samples', 'Links'] - self._table_values = [] - self._table_highlights: list[set[_TableRowStatus]] = [] - - def rowCount(self, parent=None, *args, **kwargs): - return len(self._table_values) - - def columnCount(self, parent=None, *args, **kwargs): - return len(self._headers) - - def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: - if index.isValid(): - if role == Qt.ItemDataRole.DisplayRole: - if index.column() < len(self._table_values[index.row()]): - value = self._table_values[index.row()][index.column()] - return QVariant(value) - - if role == Qt.ItemDataRole.BackgroundRole: - color = None - if _TableRowStatus.TOO_FEW_LINKS in self._table_highlights[index.row()]: - if index.column() == 5: - color = QtGui.QColor(255, 182, 193) - - if color: - return QVariant(QtGui.QBrush(color)) - - return QVariant() - - def headerData(self, col, orientation, role=None): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return QVariant(self._headers[col]) - return QVariant() - - def set_solution(self, solution: LighthouseGeometrySolution): - """Set the solution and update the table values""" - self.beginResetModel() - self._table_values = [] - self._table_highlights = [] - - # Dictionary keys may not be ordered, sort by base station ID - for bs_id, pose in sorted(solution.bs_poses.items()): - status: set[_TableRowStatus] = set() - - x = f'{pose.translation[0]:.2f}' - y = f'{pose.translation[1]:.2f}' - z = f'{pose.translation[2]:.2f}' - - link_count = len(solution.link_count[bs_id]) - if link_count < solution.link_count_ok_threshold: - status.add(_TableRowStatus.TOO_FEW_LINKS) - - samples_containing_bs = solution.bs_sample_count[bs_id] - - self._table_values.append([ - bs_id + 1, - x, - y, - z, - samples_containing_bs, - link_count, - ]) - self._table_highlights.append(status) - - self.endResetModel() - - def get_bs_id_of_row(self, row: int) -> int: - """Get the base station ID for a given row""" - if 0 <= row < len(self._table_values): - bs_id = self._table_values[row][0] - 1 # IDs are 1-based in the table - return bs_id - - return -1 - - def get_row_of_bs_id(self, bs_id: int) -> int: - """Get the row index for a given base station ID""" - for row, values in enumerate(self._table_values): - if values[0] - 1 == bs_id: # IDs are 1-based in the table - return row - - return -1 From bd5721151313cf90ed58209377ec58b2d52296f6 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 3 Feb 2026 09:46:35 +0100 Subject: [PATCH 56/73] Updated solution status box --- src/cfclient/ui/widgets/geo_estimator.ui | 55 ++++++++++--------- .../ui/widgets/geo_estimator_widget.py | 28 +++++----- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index f4e451da..a71eb956 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -221,13 +221,6 @@ Solution status - - - - TextLabel - - - @@ -249,29 +242,39 @@ - - - - TextLabel - - - - - - Show sample details - - - - - - - Show base station details - - + + + + + Show sample details + + + + + + + Show base station details + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 89b10597..c10929e7 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -377,26 +377,26 @@ def _update_solution_info(self): self._set_background_color(self._data_status_xy_plane, solution.is_xy_plane_samples_valid) if self._is_solving: - self._solution_status_is_ok.setText('Solving... please wait') - self._set_background_none(self._solution_status_is_ok) + self._solution_status_info.setText('Updating...') + self._set_background_none(self._solution_status_info) else: + solution_error_label = 'Solution sample error:' + solution_error = '--' + verification_error_label = 'Validation sample error:' + verification_error = '--' + if solution.progress_is_ok: - self._solution_status_is_ok.setText('Solution is OK') - self._solution_status_uploaded.setText('Uploaded') - self._solution_status_max_error.setText(f'Error: {solution.error_stats.max * 1000:.1f} mm') + self._solution_status_info.setText('Uploaded') + solution_error = f'{solution.error_stats.max * 1000:.1f} mm (max)' - verification_error = 'No data' if solution.verification_stats: - verification_error = f'{solution.verification_stats.max * 1000:.1f} mm' - self._solution_status_verification_error.setText(f'Verification err: {verification_error}') + verification_error = f'{solution.verification_stats.max * 1000:.1f} mm (max)' else: - self._solution_status_is_ok.setText('No solution') - self._solution_status_uploaded.setText('Not uploaded') - self._solution_status_max_error.setText('Error: --') - self._solution_status_verification_error.setText('Verification err: --') - self._set_background_color(self._solution_status_is_ok, solution.progress_is_ok) + self._solution_status_info.setText('Not enough samples') - self._solution_status_info.setText(solution.general_failure_info) + self._solution_status_max_error.setText(f'{solution_error_label} {solution_error}') + self._solution_status_verification_error.setText(f'{verification_error_label} {verification_error}') + self._set_background_color(self._solution_status_info, solution.progress_is_ok) def _notify_user(self, notification_type: _UserNotificationType): match notification_type: From 40038f9155f5cd1d4c6d2bce382f1d05a6617b40 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 3 Feb 2026 11:55:53 +0100 Subject: [PATCH 57/73] Show special upload message when geometry is loaded from file (complex and probably error prone solution) --- src/cfclient/ui/tabs/lighthouse_tab.py | 20 ++-- .../ui/widgets/geo_estimator_widget.py | 91 +++++++++++++++---- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index ccbb7a32..df521845 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -588,6 +588,8 @@ def __init__(self, helper): self._geo_estimator_widget = GeoEstimatorWidget(self) self._geometry_area.addWidget(self._geo_estimator_widget) self._geo_estimator_widget.solution_ready_signal.connect(self._solution_updated_cb) + self._connected_signal.connect(self._geo_estimator_widget.cf_connected_cb) + self._geometry_read_signal.connect(self._geo_estimator_widget.geometry_has_been_read_back_cb) # Add the geometry estimator details widget self._geo_estimator_details_widget = GeoEstimatorDetailsWidget() @@ -1024,7 +1026,7 @@ def _update_basestation_status_indicators(self): label.setStyleSheet(STYLE_RED_BACKGROUND) label.setToolTip('') - def _load_sys_config_user_action(self): + def load_sys_config_user_action(self) -> bool: if not self._geo_estimator_widget.is_container_empty(): dlg = QMessageBox(self) dlg.setWindowTitle("Clear samples Confirmation") @@ -1033,21 +1035,23 @@ def _load_sys_config_user_action(self): button = dlg.exec() if button != QMessageBox.StandardButton.Yes: - return + return False names = QFileDialog.getOpenFileName(self, 'Open file', self._helper.current_folder, FILE_REGEX_YAML) if names[0] == '': - return + return False self._helper.current_folder = os.path.dirname(names[0]) - if self._lh_config_writer is not None: - self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, names[0]) - # Clear all samples as the new configuration is based on some other (unknown) set of samples - self._geo_estimator_widget.new_session() + if self._lh_config_writer is None: + return False + + self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, names[0]) + + return True - def _save_sys_config_user_action(self): + def save_sys_config_user_action(self): # Get calibration data from the Crazyflie to complete the system config data set # When the data is ready we get a callback on _calibration_read self._lh_memory_helper.read_all_calibs(self._calibration_read_signal.emit) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index c10929e7..c23d6c1e 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -147,6 +147,14 @@ class _UserNotificationType(Enum): PENDING = "pending" +class _UploadStatus(Enum): + NOT_STARTED = "not_started" + UPLOADING_SAMPLE_SOLUTION = "uploading_sample_solution" + UPLOADING_FILE_CONFIG = "uploading_file_config" + SAMPLE_SOLUTION_DONE = "sample_solution_done" + FILE_CONFIG_DONE = "file_config_done" + + STYLE_GREEN_BACKGROUND = "background-color: lightgreen;" STYLE_RED_BACKGROUND = "background-color: lightpink;" STYLE_YELLOW_BACKGROUND = "background-color: lightyellow;" @@ -176,10 +184,13 @@ def __init__(self, lighthouse_tab): self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) - self._import_samples_button.clicked.connect(lambda: self._load_from_file(use_session_path=False)) - self._export_samples_button.clicked.connect(self._save_to_file) - self._import_solution_button.clicked.connect(self._lighthouse_tab._load_sys_config_user_action) - self._export_solution_button.clicked.connect(self._lighthouse_tab._save_sys_config_user_action) + self._import_samples_button.clicked.connect(lambda: self._load_samples_from_file(use_session_path=False)) + self._export_samples_button.clicked.connect(self._save_samples_to_file) + + self._upload_status = _UploadStatus.NOT_STARTED + + self._import_solution_button.clicked.connect(self._start_geo_file_upload) + self._export_solution_button.clicked.connect(self._lighthouse_tab.save_sys_config_user_action) self._timeout_reader = TimeoutAngleReader(self._helper.cf, self._timeout_reader_signal.emit) self._timeout_reader_signal.connect(self._average_available_cb) @@ -221,6 +232,14 @@ def __init__(self, lighthouse_tab): self._sample_details_checkbox.setChecked(False) self._base_station_details_checkbox.setChecked(False) + def _start_geo_file_upload(self): + if self._lighthouse_tab.load_sys_config_user_action(): + self._upload_status = _UploadStatus.UPLOADING_FILE_CONFIG + + # Clear all samples as the new configuration is based on some other (unknown) set of samples + self.new_session() + + def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -242,14 +261,15 @@ def new_session(self): self._container.clear_all_samples() def _clear_all(self): - dlg = QMessageBox(self) - dlg.setWindowTitle("Clear samples Confirmation") - dlg.setText("Are you sure you want to clear all samples and start over?") - dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - button = dlg.exec() + if not self.is_container_empty(): + dlg = QMessageBox(self) + dlg.setWindowTitle("Clear samples Confirmation") + dlg.setText("Are you sure you want to clear all samples and start over?") + dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + button = dlg.exec() - if button == QMessageBox.StandardButton.Yes: - self.new_session() + if button == QMessageBox.StandardButton.Yes: + self.new_session() def is_container_empty(self) -> bool: """Check if the container has any samples""" @@ -267,7 +287,22 @@ def convert_to_verification_sample(self, uid: int) -> None: """Convert a sample to a verification sample by its unique ID""" self._container.convert_to_verification_sample(uid) - def _load_from_file(self, use_session_path=False): + def cf_connected_cb(self, link_uri: str): + """Callback when the Crazyflie has connected""" + self._upload_status = _UploadStatus.NOT_STARTED + self._update_solution_info() + + def geometry_has_been_read_back_cb(self): + """Callback when the geometry has been read back from the Crazyflie after an upload, that is the full + round-trip is done. Called from the tab.""" + if self._upload_status == _UploadStatus.UPLOADING_SAMPLE_SOLUTION: + self._upload_status = _UploadStatus.SAMPLE_SOLUTION_DONE + elif self._upload_status == _UploadStatus.UPLOADING_FILE_CONFIG: + self._upload_status = _UploadStatus.FILE_CONFIG_DONE + + self._update_solution_info() + + def _load_samples_from_file(self, use_session_path=False): path = self._session_path if use_session_path else self._helper.current_folder names = QFileDialog.getOpenFileName(self, 'Load session', path, self.FILE_REGEX_YAML) @@ -282,7 +317,7 @@ def _load_from_file(self, use_session_path=False): with open(file_name, 'r', encoding='UTF8') as handle: self._container.populate_from_file_yaml(handle) - def _save_to_file(self): + def _save_samples_to_file(self): """Save the current geometry samples to a file""" names = QFileDialog.getSaveFileName(self, 'Save session', self._helper.current_folder, self.FILE_REGEX_YAML) @@ -380,23 +415,34 @@ def _update_solution_info(self): self._solution_status_info.setText('Updating...') self._set_background_none(self._solution_status_info) else: + background_color_is_ok = solution.progress_is_ok + solution_error_label = 'Solution sample error:' solution_error = '--' verification_error_label = 'Validation sample error:' verification_error = '--' if solution.progress_is_ok: - self._solution_status_info.setText('Uploaded') + if self._upload_status == _UploadStatus.UPLOADING_SAMPLE_SOLUTION: + self._solution_status_info.setText('Uploading...') + elif self._upload_status == _UploadStatus.SAMPLE_SOLUTION_DONE: + self._solution_status_info.setText('Uploaded') + solution_error = f'{solution.error_stats.max * 1000:.1f} mm (max)' if solution.verification_stats: verification_error = f'{solution.verification_stats.max * 1000:.1f} mm (max)' else: - self._solution_status_info.setText('Not enough samples') + no_samples = not solution.contains_samples + if no_samples and self._upload_status == _UploadStatus.FILE_CONFIG_DONE: + self._solution_status_info.setText('Uploaded (imported config)') + background_color_is_ok = True + else: + self._solution_status_info.setText('Not enough samples') self._solution_status_max_error.setText(f'{solution_error_label} {solution_error}') self._solution_status_verification_error.setText(f'{verification_error_label} {verification_error}') - self._set_background_color(self._solution_status_info, solution.progress_is_ok) + self._set_background_color(self._solution_status_info, background_color_is_ok) def _notify_user(self, notification_type: _UserNotificationType): match notification_type: @@ -505,9 +551,9 @@ def _start_solving_cb(self): self._update_solution_info() def _solution_ready_cb(self, solution: LighthouseGeometrySolution): + # Called when the solver thread has a new solution ready self._is_solving = False self._latest_solution = solution - self._update_solution_info() logger.debug('Solution ready --------------------------------------') logger.debug(f'Converged: {solution.has_converged}') @@ -520,8 +566,19 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): logger.debug(f'General info: {solution.general_failure_info}') if solution.progress_is_ok: + no_samples = not solution.contains_samples + if (self._upload_status == _UploadStatus.UPLOADING_FILE_CONFIG or self._upload_status == _UploadStatus.FILE_CONFIG_DONE) and no_samples: + # If we imported a config file and started an upload all samples are cleared and we will end up here + # when the solver is done. We don't want to change the upload status in this case as the status will + # show that there are no samples. + pass + else: + self._upload_status = _UploadStatus.UPLOADING_SAMPLE_SOLUTION + self._upload_geometry(solution.bs_poses) + self._update_solution_info() + def _upload_geometry(self, bs_poses: dict[int, LighthouseBsGeometry]): geo_dict = {} for bs_id, pose in bs_poses.items(): From 4b693a70e4b7839dc13a712d6d08fa9cdb6fc8d3 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 3 Feb 2026 15:14:12 +0100 Subject: [PATCH 58/73] Updated the steps box --- src/cfclient/ui/widgets/geo_estimator.ui | 514 +++++++++++++----- .../ui/widgets/geo_estimator_widget.py | 98 ++-- 2 files changed, 418 insertions(+), 194 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index a71eb956..0fb437f0 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -6,12 +6,12 @@ 0 0 - 702 + 424 793 - + 0 0 @@ -34,173 +34,401 @@ - - - TextLabel - - - - - - - - - - Image - - - - - - - TextLabel - - - - - - - TextLabel - - - - - - - - 0 - 0 - - - - Start measurement - - - - - - - QFrame::Shape::Panel - - - TextLabel - - - - - - - Qt::Orientation::Horizontal - - - - - - - - - - 0 - 0 - + + + + + <html><head/><body><p align="center">X-axis<br/>sample</p></body></html> + + + + - Previous + Refined - - - - - 0 - 0 - + + + + Qt::Orientation::Vertical + + + + - Next + Icon + + + Qt::AlignmentFlag::AlignCenter - - - - - - - - - - 0 - 0 - - - - Data status - - - - - - - - - 0 - 0 - + + + + Basic - - + + + + + + Icon + + Qt::AlignmentFlag::AlignCenter + + + + + - Origin + Icon - - false + + Qt::AlignmentFlag::AlignCenter - - - - + + + + <html><head/><body><p align="center">Verification<br/>samples</p></body></html> + + + + - X-axis + Icon - - false + + Qt::AlignmentFlag::AlignCenter - - + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + - XY-plane + <html><head/><body><p align="center">Origin<br/>sample</p></body></html> - - false + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter - - - - - - + + + + <html><head/><body><p align="center">XY-plane<br/>samples</p></body></html> + + + + + + + <html><head/><body><p align="center">XYZ-space<br/>samples</p></body></html> + + + + + - XYZ-space + Icon - - false + + Qt::AlignmentFlag::AlignCenter + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Image + + + + + - + + + + + + 0 + 0 + + + + TextLabel + + + true + + + + + + + Qt::Orientation::Horizontal + + + + + + + + 0 + 0 + + + + QFrame::Shape::Panel + + + 0 + + + TextLabel + + + + + + + + + + 0 + 0 + + - Verification + Take sample @@ -223,6 +451,12 @@ + + + 0 + 0 + + TextLabel @@ -230,6 +464,12 @@ + + + 0 + 0 + + TextLabel @@ -237,6 +477,12 @@ + + + 0 + 0 + + TextLabel diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index c23d6c1e..5277566b 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -70,31 +70,37 @@ class _CollectionStep(Enum): ORIGIN = ('bslh_1.png', 'Step 1. Origin', - 'Put the Crazyflie where you want the origin of your coordinate system.\n') + 'Put the Crazyflie where you want the ' + + 'origin of your coordinate system.') X_AXIS = ('bslh_2.png', 'Step 2. X-axis', - 'Put the Crazyflie on the positive X-axis,' + - f' exactly {REFERENCE_DIST} meters from the origin.\n' + - 'This will be used to define the X-axis as well as scaling of the system.') + 'Put the Crazyflie on the positive X-axis, ' + + f'exactly {REFERENCE_DIST} meters from the ' + + 'origin. This will be used to define the X-axis ' + + 'as well as scaling of the system.') XY_PLANE = ('bslh_3.png', 'Step 3. XY-plane', - 'Put the Crazyflie somewhere in the XY-plane, but not on the X-axis.\n' + - 'This position is used to map the the XY-plane to the floor.\n' + - 'You can sample multiple positions to get a more precise definition.') + 'Put the Crazyflie somewhere in the XY-plane, ' + + 'but not on the X-axis. This position is used ' + + 'to map the the XY-plane to the floor. You can ' + + 'sample multiple positions to get a more ' + + 'precise definition.') XYZ_SPACE = ('bslh_4.png', 'Step 4. XYZ-space', - 'Sample points in the space that you will use.\n' + - 'Make sure all the base stations are received, you need at least two base \n' + - 'stations in each sample. Sample by rotating the Crazyflie quickly \n' + - 'left-right around the Z-axis and then holding it still for a second, or \n' + - 'optionally by clicking the sample button below.\n') + 'Sample points in the space that you will use. ' + + 'Make sure all the base stations are received, ' + + 'you need at least two base stations in each ' + + 'sample. Sample by rotating the Crazyflie quickly ' + + 'left-right around the Z-axis and then holding it ' + + 'still for a second, or optionally by clicking ' + + 'the sample button.') VERIFICATION = ('bslh_4.png', 'Step 5. Verification', - 'Sample points to be used for verification of the geometry.\n' + - 'Sample by rotating the Crazyflie quickly \n' + - 'left-right around the Z-axis and then holding it still for a second, or \n' + - 'optionally by clicking the sample button below.\n') + 'Sample points to be used for verification of the geometry. ' + + 'Sample by rotating the Crazyflie quickly ' + + 'left-right around the Z-axis and then holding it still for a second, or ' + + 'optionally by clicking the sample button below.') def __init__(self, image, title, instructions): self.image = image @@ -179,8 +185,6 @@ def __init__(self, lighthouse_tab): self._lighthouse_tab = lighthouse_tab self._helper = lighthouse_tab._helper - self._step_next_button.clicked.connect(lambda: self._change_step(self._current_step.next())) - self._step_previous_button.clicked.connect(lambda: self._change_step(self._current_step.previous())) self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) @@ -213,6 +217,7 @@ def __init__(self, lighthouse_tab): self._latest_solution: LighthouseGeometrySolution = LighthouseGeometrySolution([]) self._current_step = _CollectionStep.ORIGIN + self._origin_radio.setChecked(True) self._start_solving_signal.connect(self._start_solving_cb) self.solution_ready_signal.connect(self._solution_ready_cb) @@ -223,12 +228,11 @@ def __init__(self, lighthouse_tab): self._update_ui_reading(False) self._update_solution_info() - self._data_status_origin.clicked.connect(lambda: self._change_step(_CollectionStep.ORIGIN)) - self._data_status_x_axis.clicked.connect(lambda: self._change_step(_CollectionStep.X_AXIS)) - self._data_status_xy_plane.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) - self._data_status_xyz_space.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) - self._data_status_verification.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) - + self._origin_radio.clicked.connect(lambda: self._change_step(_CollectionStep.ORIGIN)) + self._x_axis_radio.clicked.connect(lambda: self._change_step(_CollectionStep.X_AXIS)) + self._xy_plane_radio.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) + self._xyz_space_radio.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) + self._verification_radio.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) self._sample_details_checkbox.setChecked(False) self._base_station_details_checkbox.setChecked(False) @@ -348,7 +352,6 @@ def _update_step_ui(self): """Populate the widget with the current step's information""" step = self._current_step - self._step_title.setText(step.title) self._step_image.setPixmap(QtGui.QPixmap( cfclient.module_path + '/ui/widgets/geo_estimator_resources/' + step.image)) self._step_instructions.setText(step.instructions) @@ -359,9 +362,6 @@ def _update_step_ui(self): else: self._step_measure.setText('Start measurement') - self._step_previous_button.setEnabled(step.has_previous()) - self._step_next_button.setEnabled(step.has_next()) - self._update_solution_info() def _update_ui_reading(self, is_reading: bool): @@ -369,47 +369,25 @@ def _update_ui_reading(self, is_reading: bool): is_enabled = not is_reading self._step_measure.setEnabled(is_enabled) - self._step_next_button.setEnabled(is_enabled and self._current_step.has_next()) - self._step_previous_button.setEnabled(is_enabled and self._current_step.has_previous()) - self._data_status_origin.setEnabled(is_enabled) - self._data_status_x_axis.setEnabled(is_enabled) - self._data_status_xy_plane.setEnabled(is_enabled) - self._data_status_xyz_space.setEnabled(is_enabled) - self._data_status_verification.setEnabled(is_enabled) + self._origin_radio.setEnabled(is_enabled) + self._x_axis_radio.setEnabled(is_enabled) + self._xy_plane_radio.setEnabled(is_enabled) + self._xyz_space_radio.setEnabled(is_enabled) + self._verification_radio.setEnabled(is_enabled) self._import_samples_button.setEnabled(is_enabled) self._export_samples_button.setEnabled(is_enabled) + self._import_solution_button.setEnabled(is_enabled) + self._export_solution_button.setEnabled(is_enabled) self._clear_all_button.setEnabled(is_enabled) def _update_solution_info(self): solution = self._latest_solution - match self._current_step: - case _CollectionStep.ORIGIN: - self._step_solution_info.setText( - 'OK' if solution.is_origin_sample_valid else solution.origin_sample_info) - case _CollectionStep.X_AXIS: - self._step_solution_info.setText( - 'OK' if solution.is_x_axis_samples_valid else solution.x_axis_samples_info) - case _CollectionStep.XY_PLANE: - if solution.xy_plane_samples_info: - text = f'OK, {self._container.xy_plane_sample_count()} sample(s)' - else: - text = solution.xy_plane_samples_info - self._step_solution_info.setText(text) - case _CollectionStep.XYZ_SPACE: - text = f'OK, {self._container.xyz_space_sample_count()} sample(s)' - if solution.xyz_space_samples_info: - text += f', {solution.xyz_space_samples_info}' - self._step_solution_info.setText(text) - case _CollectionStep.VERIFICATION: - text = f'OK, {self._container.verification_sample_count()} sample(s)' - self._step_solution_info.setText(text) - - self._set_background_color(self._data_status_origin, solution.is_origin_sample_valid) - self._set_background_color(self._data_status_x_axis, solution.is_x_axis_samples_valid) - self._set_background_color(self._data_status_xy_plane, solution.is_xy_plane_samples_valid) + self._set_background_color(self._origin_icon, solution.is_origin_sample_valid) + self._set_background_color(self._x_axis_icon, solution.is_x_axis_samples_valid) + self._set_background_color(self._xy_plane_icon, solution.is_xy_plane_samples_valid) if self._is_solving: self._solution_status_info.setText('Updating...') From 6decc7bfd9c7e765d3ccb817a8847eb2b82b4f71 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 3 Feb 2026 15:44:26 +0100 Subject: [PATCH 59/73] Cleaned up imports --- src/cfclient/ui/widgets/geo_estimator_widget.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 5277566b..0c069981 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -29,14 +29,12 @@ Container for the geometry estimation functionality in the lighthouse tab. """ -from itertools import count import os from typing import Callable from PyQt6 import QtCore, QtWidgets, uic, QtGui -from PyQt6.QtWidgets import QFileDialog, QGridLayout -from PyQt6.QtWidgets import QMessageBox, QPushButton, QLabel -from PyQt6.QtCore import QTimer, QAbstractTableModel, QVariant, Qt, QModelIndex, QItemSelection - +from PyQt6.QtWidgets import QFileDialog +from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtCore import QTimer import logging from enum import Enum From b7c8a80774a0c71699286c9a44072e046fd2aea1 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Tue, 3 Feb 2026 16:14:52 +0100 Subject: [PATCH 60/73] Added dummy icons to steps --- src/cfclient/ui/widgets/geo_estimator.ui | 76 ++++++++---------- .../geo_estimator_resources/checkmark.png | Bin 0 -> 2502 bytes .../geo_estimator_resources/crossmark.png | Bin 0 -> 2936 bytes .../ui/widgets/geo_estimator_widget.py | 21 +++-- 4 files changed, 48 insertions(+), 49 deletions(-) create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/checkmark.png create mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/crossmark.png diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 0fb437f0..23c2f914 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -6,7 +6,7 @@ 0 0 - 424 + 420 793 @@ -422,7 +422,7 @@ - + 0 0 @@ -527,51 +527,39 @@ Session management - - - - - - - - - Import samples - - - - - - - Export samples - - - - - - - - - - - Import solution - - - - - - - Export solution - - - - - - + + + + + Export solution + + - + + + + Export samples + + + + + + + Import solution + + + + + + + Import samples + + + + - + 0 0 diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/checkmark.png b/src/cfclient/ui/widgets/geo_estimator_resources/checkmark.png new file mode 100644 index 0000000000000000000000000000000000000000..afff994ff93e2ef9a0f1aef6ab0d7d2003a638fb GIT binary patch literal 2502 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 iuc!1W=+Go2 str: + """Get the icon file name based on validity""" + if is_valid: + return 'checkmark.png' + else: + return 'crossmark.png' + def _update_solution_info(self): solution = self._latest_solution - self._set_background_color(self._origin_icon, solution.is_origin_sample_valid) - self._set_background_color(self._x_axis_icon, solution.is_x_axis_samples_valid) - self._set_background_color(self._xy_plane_icon, solution.is_xy_plane_samples_valid) + self._set_label_icon(self._origin_icon, self._get_step_icon(solution.is_origin_sample_valid)) + self._set_label_icon(self._x_axis_icon, self._get_step_icon(solution.is_x_axis_samples_valid)) + self._set_label_icon(self._xy_plane_icon, self._get_step_icon(solution.is_xy_plane_samples_valid)) if self._is_solving: self._solution_status_info.setText('Updating...') From 8f36e48017284a1e16af193070fe5065aaea8873 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 4 Feb 2026 01:19:09 +0100 Subject: [PATCH 61/73] Added info boxes --- src/cfclient/ui/tabs/lighthouse_tab.py | 4 + src/cfclient/ui/tabs/lighthouse_tab.ui | 6 +- src/cfclient/ui/widgets/geo_estimator.ui | 8 +- .../ui/widgets/geo_estimator_widget.py | 6 ++ src/cfclient/ui/widgets/info_label.py | 90 +++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 src/cfclient/ui/widgets/info_label.py diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index df521845..802befb5 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -42,6 +42,7 @@ from cfclient.ui.widgets.geo_estimator_widget import GeoEstimatorWidget from cfclient.ui.widgets.geo_estimator_details_widget import GeoEstimatorDetailsWidget +from cfclient.ui.widgets.info_label import InfoLabel from cflib.crazyflie.log import LogConfig from cflib.crazyflie.mem import LighthouseMemHelper from cflib.localization import LighthouseConfigWriter @@ -672,6 +673,9 @@ def __init__(self, helper): self._pending_geo_update = None + self._base_stations_info_label = InfoLabel("This is information about base station status. TODO update", self._base_station_group_box) + self._sys_management_info_label = InfoLabel("This is information about system management. TODO update", self._sys_management_group_box) + def write_and_store_geometry(self, geometries: dict[int, LighthouseBsGeometry]): if self._lh_config_writer: if self._lh_config_writer.is_write_ongoing: diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index 96b97b7c..b3a8d059 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -142,7 +142,7 @@ - + 0 @@ -193,7 +193,7 @@ - Geometry mode + Geometry mode: @@ -237,7 +237,7 @@ - + true diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 23c2f914..22c5e784 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -43,7 +43,7 @@ - + Refined @@ -67,7 +67,7 @@ - + Basic @@ -438,7 +438,7 @@ - + 0 @@ -523,7 +523,7 @@ - + Session management diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 434c6b24..24f24b7f 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -42,6 +42,7 @@ import cfclient +from cfclient.ui.widgets.info_label import InfoLabel from cflib.crazyflie import Crazyflie from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry from cflib.localization.lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader @@ -234,6 +235,11 @@ def __init__(self, lighthouse_tab): self._sample_details_checkbox.setChecked(False) self._base_station_details_checkbox.setChecked(False) + self._basic_steps_info_label = InfoLabel("This is information about basic steps. TODO update", self._basic_steps_section_label) + self._refined_steps_info_label = InfoLabel("This is information about refined steps. TODO update", self._refined_steps_section_label) + self._solution_status_info_label = InfoLabel("This is information about the solution status. TODO update", self._solution_status_group_box) + self._session_management_info_label = InfoLabel("This is information about session management. TODO update", self._session_management_group_box) + def _start_geo_file_upload(self): if self._lighthouse_tab.load_sys_config_user_action(): self._upload_status = _UploadStatus.UPLOADING_FILE_CONFIG diff --git a/src/cfclient/ui/widgets/info_label.py b/src/cfclient/ui/widgets/info_label.py new file mode 100644 index 00000000..0006412c --- /dev/null +++ b/src/cfclient/ui/widgets/info_label.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# || ____ _ __ +# +------+ / __ )(_) /_______________ _____ ___ +# | 0xBC | / __ / / __/ ___/ ___/ __ `/_ / / _ \ +# +------+ / /_/ / / /_/ /__/ / / /_/ / / /_/ __/ +# || || /_____/_/\__/\___/_/ \__,_/ /___/\___/ +# +# Copyright (C) 2026 Bitcraze AB +# +# Crazyflie Nano Quadcopter Client +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from enum import Enum +from PyQt6.QtWidgets import QLabel, QWidget, QToolTip +from PyQt6.QtCore import QObject, QEvent + + +class InfoLabel(QLabel): + """A label with an information icon and a tooltip.""" + + class Position(Enum): + TOP_LEFT = 1 + TOP_RIGHT = 2 + BOTTOM_LEFT = 3 + BOTTOM_RIGHT = 4 + + ICON_WIDTH = 16 + ICON_HEIGHT = 16 + MARGIN = 0 + + def __init__(self, tooltip: str, parent: QWidget, position: Position = Position.TOP_RIGHT, v_margin: int = MARGIN, h_margin: int = MARGIN): + super().__init__(parent) + + self._v_margin = v_margin + self._h_margin = h_margin + + self._event_filter = _EventFilter(self, position) + parent.installEventFilter(self._event_filter) + + self.setToolTip(tooltip) + self.setPixmap(self.style().standardIcon(self.style().StandardPixmap.SP_MessageBoxInformation).pixmap(self.ICON_WIDTH, self.ICON_HEIGHT)) + + def mousePressEvent(self, event): + """Override mouse press event to show tooltip on click.""" + QToolTip.showText(event.globalPosition().toPoint(), self.toolTip(), self) + + +class _EventFilter(QObject): + def __init__(self, info_label: 'InfoLabel', position: InfoLabel.Position): + super().__init__() + self._info_label = info_label + self._position = position + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Resize: + self._update_position() + return super().eventFilter(obj, event) + + def _update_position(self): + parent = self._info_label.parent() + if parent is None: + return + x, y = 0, 0 + if self._position == InfoLabel.Position.TOP_LEFT: + x, y = self._info_label._h_margin, self._info_label._v_margin + elif self._position == InfoLabel.Position.TOP_RIGHT: + x = parent.width() - self._info_label.ICON_WIDTH - self._info_label._h_margin + y = self._info_label._v_margin + elif self._position == InfoLabel.Position.BOTTOM_LEFT: + x = self._info_label._h_margin + y = parent.height() - self._info_label.ICON_HEIGHT - self._info_label._v_margin + elif self._position == InfoLabel.Position.BOTTOM_RIGHT: + x = parent.width() - self._info_label.ICON_WIDTH - self._info_label._h_margin + y = parent.height() - self._info_label.ICON_HEIGHT - self._info_label._v_margin + self._info_label.move(x, y) From 0271033d21d5e8ebe9294cbe4265f57ab7276a42 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Wed, 4 Feb 2026 01:46:59 +0100 Subject: [PATCH 62/73] Moved home button into 3d plot class --- src/cfclient/ui/tabs/lighthouse_tab.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 802befb5..205c3d37 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -36,6 +36,7 @@ from PyQt6 import uic from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtWidgets import QMessageBox, QFileDialog, QLabel, QPushButton +from vispy.util.event import Event import cfclient from cfclient.ui.tab_toolbox import TabToolbox @@ -369,12 +370,23 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int), base_station_clicked_ self._plane.interactive = True self._addArrows(1, 0.02, 0.1, 0.1, self._view.scene) + + self._home_button = QPushButton('Home', self.native) + self._home_button.clicked.connect(self.move_camera_home) + + self._drone_view_button = QPushButton('Drone view', self.native) + self._drone_view_button.clicked.connect(self.camera_follow_drone) + self.freeze() def move_camera_home(self): self._view.camera.reset() self._view.camera.distance = self.DEFAULT_CAMERA_DISTANCE + def camera_follow_drone(self): + # TODO + pass + def on_mouse_press(self, event): visual = self.visual_at(event.pos) @@ -404,6 +416,15 @@ def on_mouse_press(self, event): self._sample_clicked_signal.emit(-1) self._base_station_clicked_signal.emit(-1) + def on_resize(self, event: Event): + x = self.native.width() - self._drone_view_button.width() - 5 + y = 5 + self._drone_view_button.move(x, y) + x -= self._home_button.width() + 5 + self._home_button.move(x, y) + + return super().on_resize(event) + def _addArrows(self, length, width, head_length, head_width, parent): # The Arrow visual in vispy does not seem to work very good, # draw arrows using lines instead. @@ -703,10 +724,6 @@ def _set_up_plots(self): self._plot_3d = Plot3dLighthouse(self._sample_clicked_signal, self._base_station_clicked_signal) self._plot_layout.addWidget(self._plot_3d.native) - home_button = QPushButton('Home', self._plot_3d.native) - home_button.move(5, 5) - home_button.clicked.connect(self._plot_3d.move_camera_home) - def _connected(self, link_uri): """Callback when the Crazyflie has been connected""" logger.debug("Crazyflie connected to {}".format(link_uri)) From b9b71aaa030849d765c01c9934db7b470859a0d7 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 5 Feb 2026 13:13:08 +0100 Subject: [PATCH 63/73] Removed the drone view button after a failed attempt to implement it. --- src/cfclient/ui/pose_logger.py | 6 +++ src/cfclient/ui/tabs/lighthouse_tab.py | 74 +++++++++++++------------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/cfclient/ui/pose_logger.py b/src/cfclient/ui/pose_logger.py index 2f345503..5105890d 100644 --- a/src/cfclient/ui/pose_logger.py +++ b/src/cfclient/ui/pose_logger.py @@ -34,6 +34,7 @@ from cflib.crazyflie import Crazyflie from cflib.crazyflie.log import LogConfig from cflib.utils.callbacks import Caller +from cflib.localization import Pose __author__ = 'Bitcraze AB' __all__ = ['PoseLogger'] @@ -78,6 +79,11 @@ def rpy_rad(self): """Get the roll, pitch and yaw of the full pose in radians""" return [math.radians(self.pose[3]), math.radians(self.pose[4]), math.radians(self.pose[5])] + @property + def full_pose(self) -> Pose: + """Get the full pose as a Pose object""" + return Pose.from_cf_rpy(roll=self.pose[3], pitch=self.pose[4], yaw=self.pose[5], t_vec=self.position) + def _connected(self, link_uri) -> None: logConf = LogConfig("Pose", 40) logConf.add_variable(self.LOG_NAME_ESTIMATE_X, "float") diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 205c3d37..5b996422 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -50,6 +50,7 @@ from cflib.localization import LighthouseConfigFileManager from cflib.localization import LighthouseGeometrySolution from cflib.localization import LhCfPoseSampleType +from cflib.localization import Pose from cflib.crazyflie.mem.lighthouse_memory import LighthouseBsGeometry @@ -374,19 +375,12 @@ def __init__(self, sample_clicked_signal: pyqtSignal(int), base_station_clicked_ self._home_button = QPushButton('Home', self.native) self._home_button.clicked.connect(self.move_camera_home) - self._drone_view_button = QPushButton('Drone view', self.native) - self._drone_view_button.clicked.connect(self.camera_follow_drone) - self.freeze() def move_camera_home(self): self._view.camera.reset() self._view.camera.distance = self.DEFAULT_CAMERA_DISTANCE - def camera_follow_drone(self): - # TODO - pass - def on_mouse_press(self, event): visual = self.visual_at(event.pos) @@ -417,10 +411,8 @@ def on_mouse_press(self, event): self._base_station_clicked_signal.emit(-1) def on_resize(self, event: Event): - x = self.native.width() - self._drone_view_button.width() - 5 + x = self.native.width() - self._home_button.width() - 5 y = 5 - self._drone_view_button.move(x, y) - x -= self._home_button.width() + 5 self._home_button.move(x, y) return super().on_resize(event) @@ -465,10 +457,43 @@ def _addArrows(self, length, width, head_length, head_width, parent): [0, -w, 0]], width=1.0, color='blue', parent=parent, marker_size=0.0) - def update_cf_pose(self, position, rot): + def update_cf_pose(self, pose: Pose): if not self._cf: self._cf = CfMarkerPose(self._view.scene) - self._cf.set_pose(position, rot) + self._cf.set_pose(pose.translation, pose.rot_matrix) + + # if self._follow_drone: + # self._update_cam_to_follow_drone_view(pose) + + def _update_cam_to_follow_drone_view(self, pose: Pose): + # Unfortunately this does not fully work as intended yet, not sure why. + self._view.camera.center = pose.translation + + elevation, azimuth, roll = self._rotation_to_camera_parameters(pose) + self._view.camera.elevation = elevation + self._view.camera.azimuth = azimuth + self._view.camera.roll = roll + + def _rotation_to_camera_parameters(self, pose: Pose): + # This conversion is the inverse of _get_rotation_tr() in turntable.py (in vispy). It has been verified using + # _get_rotation_tr() and seems to work correctly. + angles = pose.rot_euler(seq='XZY', degrees=True) + if abs(angles[0]) < 90: + elevation = angles[0] + azimuth = -angles[1] + roll = -angles[2] + else: + elevation = 180 + angles[0] + if elevation > 180: + elevation -= 360 + azimuth = 180 + angles[1] + if azimuth > 180: + azimuth -= 360 + roll = 180 - angles[2] + if roll > 180: + roll -= 360 + + return elevation, azimuth, roll def update_base_station_geos(self, geos, solution: LighthouseGeometrySolution): # Geos are read from the CF (not the solution) to reflect the actual stored data @@ -878,8 +903,7 @@ def _change_ui_mode(self, is_geo_mode: bool): def _update_graphics(self): if self.is_visible() and self.is_lighthouse_deck_active: - self._plot_3d.update_cf_pose(self._helper.pose_logger.position, - self._rpy_to_rot(self._helper.pose_logger.rpy_rad)) + self._plot_3d.update_cf_pose(self._helper.pose_logger.full_pose) self._plot_3d.update_base_station_geos(self._lh_geos, self._latest_solution) self._plot_3d.update_base_station_visibility(self._bs_data_to_estimator) @@ -946,28 +970,6 @@ def _clear_state_indicator(self): if item is not None: item.widget().deleteLater() - def _rpy_to_rot(self, rpy): - # http://planning.cs.uiuc.edu/node102.html - # Pitch reversed compared to page above - roll = rpy[0] - pitch = rpy[1] - yaw = rpy[2] - - cg = math.cos(roll) - cb = math.cos(-pitch) - ca = math.cos(yaw) - sg = math.sin(roll) - sb = math.sin(-pitch) - sa = math.sin(yaw) - - r = [ - [ca * cb, ca * sb * sg - sa * cg, ca * sb * cg + sa * sg], - [sa * cb, sa * sb * sg + ca * cg, sa * sb * cg - ca * sg], - [-sb, cb * sg, cb * cg], - ] - - return np.array(r) - def _populate_status_matrix(self): container = self._basestation_stats_container From e42d91450b9dbd73e896add80690ef353c150453 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 5 Feb 2026 14:56:47 +0100 Subject: [PATCH 64/73] Reworked sample/bs details --- src/cfclient/ui/tabs/lighthouse_tab.py | 3 +- src/cfclient/ui/tabs/lighthouse_tab.ui | 15 +------ src/cfclient/ui/widgets/geo_estimator.ui | 11 +---- .../ui/widgets/geo_estimator_details.ui | 45 ++++++++++--------- .../widgets/geo_estimator_details_widget.py | 5 +-- .../ui/widgets/geo_estimator_widget.py | 7 +-- 6 files changed, 34 insertions(+), 52 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 5b996422..5dd844e9 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -646,8 +646,7 @@ def __init__(self, helper): # Connect signals between the geo estimator widget and the details widget self._geo_estimator_widget.solution_ready_signal.connect(self._geo_estimator_details_widget.solution_ready_cb) - self._geo_estimator_widget._base_station_details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.base_station_details_checkbox_state_changed) - self._geo_estimator_widget._sample_details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.sample_details_checkbox_state_changed) + self._geo_estimator_widget._details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.details_checkbox_state_changed) self._geo_estimator_details_widget.do_remove_sample_signal.connect(self._geo_estimator_widget.remove_sample) self._geo_estimator_details_widget.do_convert_to_xyz_space_sample_signal.connect(self._geo_estimator_widget.convert_to_xyz_space_sample) self._geo_estimator_details_widget.do_convert_to_verification_sample_signal.connect(self._geo_estimator_widget.convert_to_verification_sample) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index b3a8d059..fa0eee9e 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -193,7 +193,7 @@ - Geometry mode: + Geometry est mode: @@ -432,19 +432,6 @@ - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 22c5e784..3d717aac 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -494,16 +494,9 @@ - + - Show sample details - - - - - - - Show base station details + Show sample/basestation details diff --git a/src/cfclient/ui/widgets/geo_estimator_details.ui b/src/cfclient/ui/widgets/geo_estimator_details.ui index a185bccd..a1dcb714 100644 --- a/src/cfclient/ui/widgets/geo_estimator_details.ui +++ b/src/cfclient/ui/widgets/geo_estimator_details.ui @@ -7,7 +7,7 @@ 0 0 702 - 793 + 319 @@ -19,21 +19,17 @@ Form - + - + + + Samples + - - - - Samples - - - - + 0 0 @@ -65,19 +61,15 @@ - + + + Base stations + - - - - Base stations - - - - + 0 0 @@ -105,6 +97,19 @@ + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + diff --git a/src/cfclient/ui/widgets/geo_estimator_details_widget.py b/src/cfclient/ui/widgets/geo_estimator_details_widget.py index f2a973db..e20c3a54 100644 --- a/src/cfclient/ui/widgets/geo_estimator_details_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_details_widget.py @@ -168,12 +168,9 @@ def set_selected_base_station(self, bs_id: int): else: self._base_stations_table_view.clearSelection() - def sample_details_checkbox_state_changed(self, state: int): + def details_checkbox_state_changed(self, state: int): enabled = state == Qt.CheckState.Checked.value self._samples_widget.setVisible(enabled) - - def base_station_details_checkbox_state_changed(self, state: int): - enabled = state == Qt.CheckState.Checked.value self._base_stations_widget.setVisible(enabled) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 24f24b7f..4c84a3cb 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -187,7 +187,7 @@ def __init__(self, lighthouse_tab): self._step_measure.clicked.connect(self._measure) self._clear_all_button.clicked.connect(self._clear_all) - self._import_samples_button.clicked.connect(lambda: self._load_samples_from_file(use_session_path=False)) + self._import_samples_button.clicked.connect(lambda: self._load_samples_from_file(use_session_path=True)) self._export_samples_button.clicked.connect(self._save_samples_to_file) self._upload_status = _UploadStatus.NOT_STARTED @@ -232,8 +232,7 @@ def __init__(self, lighthouse_tab): self._xy_plane_radio.clicked.connect(lambda: self._change_step(_CollectionStep.XY_PLANE)) self._xyz_space_radio.clicked.connect(lambda: self._change_step(_CollectionStep.XYZ_SPACE)) self._verification_radio.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) - self._sample_details_checkbox.setChecked(False) - self._base_station_details_checkbox.setChecked(False) + self._details_checkbox.setChecked(False) self._basic_steps_info_label = InfoLabel("This is information about basic steps. TODO update", self._basic_steps_section_label) self._refined_steps_info_label = InfoLabel("This is information about refined steps. TODO update", self._refined_steps_section_label) @@ -403,6 +402,8 @@ def _update_solution_info(self): self._set_label_icon(self._origin_icon, self._get_step_icon(solution.is_origin_sample_valid)) self._set_label_icon(self._x_axis_icon, self._get_step_icon(solution.is_x_axis_samples_valid)) self._set_label_icon(self._xy_plane_icon, self._get_step_icon(solution.is_xy_plane_samples_valid)) + self._xyz_space_icon.setText(f'({self._container.xyz_space_sample_count()})') + self._verification_icon.setText(f'({self._container.verification_sample_count()})') if self._is_solving: self._solution_status_info.setText('Updating...') From acca017c77a8d845c248972dc0f09d552c8dd658 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 5 Feb 2026 16:18:23 +0100 Subject: [PATCH 65/73] Moved system management back to the right of bs status --- src/cfclient/ui/tabs/lighthouse_tab.ui | 383 +++++++++++-------------- 1 file changed, 160 insertions(+), 223 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index fa0eee9e..4ebd0c58 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -22,219 +22,75 @@ - - QLayout::SizeConstraint::SetDefaultConstraint - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - Crazyflie status - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - - - - - - - - Status: - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Position: - - - - - - - - 150 - 0 - - - - QFrame::Shape::NoFrame - - - (0.0 , 0.0 , 0.0) - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - System Management - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - - QLayout::SizeConstraint::SetDefaultConstraint + + + + 0 + 0 + + + + + 0 + 0 + + + + Crazyflie status + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + + + + + 150 + 0 + - - - - - - - - Set BS channel - - - - - - - Switch BS version - - - - - - - - - QLayout::SizeConstraint::SetMaximumSize - - - - - Geometry est mode: - - - - - - - On - - - - - - - Off - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - - - + + QFrame::Shape::NoFrame + + + (0.0 , 0.0 , 0.0) + + + + + + + Position: + + + + + + + Status: + + + + + + + + 0 + 0 + + + + - + + + true + + + + + @@ -296,12 +152,6 @@ - - - 100 - 0 - - Geometry @@ -342,12 +192,6 @@ - - - 100 - 0 - - Estimator @@ -385,6 +229,99 @@ + + + + + 0 + 0 + + + + + 0 + 0 + + + + System Management + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + + + + + + + Set BS channel + + + + + + + Switch BS version + + + + + + + + + QLayout::SizeConstraint::SetMaximumSize + + + + + Geometry mode: + + + + + + + On + + + + + + + Off + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + From bfce8781c454346253154356d426faa2119d693c Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 5 Feb 2026 16:35:02 +0100 Subject: [PATCH 66/73] Moved details checkbox into solution status box --- src/cfclient/ui/widgets/geo_estimator.ui | 30 +++++++----------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index 3d717aac..b471aea8 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -488,32 +488,18 @@ + + + + Show sample/basestation details + + + - - - - - Show sample/basestation details - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - + From f20cdc52919b53af0351f37d0cf7e28b588a3736 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Thu, 5 Feb 2026 16:40:55 +0100 Subject: [PATCH 67/73] Moved geometry more radio buttons to under CF status --- src/cfclient/ui/tabs/lighthouse_tab.ui | 217 ++++++++++++------------- 1 file changed, 104 insertions(+), 113 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.ui b/src/cfclient/ui/tabs/lighthouse_tab.ui index 4ebd0c58..ab7d3cf8 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.ui +++ b/src/cfclient/ui/tabs/lighthouse_tab.ui @@ -23,74 +23,109 @@ - - - - 0 - 0 - - - - - 0 - 0 - - - - Crazyflie status - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - - - - - 150 - 0 - - - - QFrame::Shape::NoFrame - - - (0.0 , 0.0 , 0.0) - - - - - - - Position: - - - - - - - Status: - - - - - - - - 0 - 0 - - - - - - - - true - - - - - + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Crazyflie status + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + + + + + 150 + 0 + + + + QFrame::Shape::NoFrame + + + (0.0 , 0.0 , 0.0) + + + + + + + Position: + + + + + + + Status: + + + + + + + + 0 + 0 + + + + - + + + true + + + + + + + + + + QLayout::SizeConstraint::SetMaximumSize + + + + + Geometry mode: + + + + + + + On + + + + + + + Off + + + true + + + + + + @@ -254,7 +289,7 @@ QLayout::SizeConstraint::SetDefaultConstraint - + @@ -273,50 +308,6 @@ - - - - QLayout::SizeConstraint::SetMaximumSize - - - - - Geometry mode: - - - - - - - On - - - - - - - Off - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - From cb733d53784ef586a657b6e087e59e40ab0d0191 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 6 Feb 2026 09:51:19 +0100 Subject: [PATCH 68/73] Updated images --- .../geo_estimator_resources/bslh_1.png | Bin 6677 -> 6104 bytes .../geo_estimator_resources/bslh_2.png | Bin 9105 -> 8771 bytes .../geo_estimator_resources/bslh_3.png | Bin 8838 -> 8609 bytes .../geo_estimator_resources/bslh_4.png | Bin 13664 -> 11083 bytes .../geo_estimator_resources/bslh_5.png | Bin 7743 -> 0 bytes .../geo_estimator_resources/checkmark.png | Bin 2502 -> 2527 bytes .../geo_estimator_resources/crossmark.png | Bin 2936 -> 2537 bytes 7 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/cfclient/ui/widgets/geo_estimator_resources/bslh_5.png diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_1.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_1.png index 0b1d1f75443558966c82c9b5ae35857148bf29b2..2a7eb79e85e9c311d5074f040b475693fd5c1592 100644 GIT binary patch literal 6104 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 vhFB}Anrl#yT?$ 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 diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_2.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_2.png index 56dfb5fb66c7038b8af662773b4e0121e44694d5..3ce0c4a02bfa4482b28df62983adf60cab2f8fbf 100644 GIT binary patch literal 8771 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 diff --git a/src/cfclient/ui/widgets/geo_estimator_resources/bslh_3.png b/src/cfclient/ui/widgets/geo_estimator_resources/bslh_3.png index e5f385e4e31dff99e52ba15c2aa5fc9b5aca94cb..640765c6aa905d007443402d977975d149ed3c39 100644 GIT binary patch literal 8609 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 u!2c0w*#Lbc2G@1Vp5R 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%0AQA$y(h#eJ5t(H<-OHrk^3W6A+ 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 K?J7;j$Nvu*PB6>> literal 13664 zcmd6O=R*@;u=XZ^U}&NDCJIPL>Aj;ModAX|C{2on-djML*pS|Ps3KKb1O*FKAXKSJ z=*7?p;pTVW_m8+A_QUQuXU^=NIWy0kc{b6+NRJxA4gmmw`u;s_GXMZb0RTvy;u`UZ z=+MxA4cC3|JwyNiCH;RF=-2NEJOJ2%$fW=lR?J+ z*=2?`>{X`dN5{QKhOQr3T^d@e2Yc&i12QWKI)R7g#@!ekvvHW(t3w;uk@>@{5nL#N z?tpeH_bySvZv*xFABhToqXa>Ts@)}32Y{$SC;$S0J6ZrKv4QIUSOe}>#L_z0a|g|O zceLnBkJ^Ord0R7LH;G3S0RGk>URGR7ZNTKSImd@k6+7^-I@g8!jz6>2A$AlxZ$n(3 zPq2PWa+*%8nu`2#E?;ogO?qp}gXqf0wNJ&pKHTk7!P~e%?XCD-~#@aob z7@YEn$8Df6XBr}oP;ImJWbt^vogh0f+)lT=r_i(h6|OXM@=SI83_GYGXQDG|bxn@Y zKH19#0E)3d=54Z`te65hzu{E(7hwhze&o23BHA&?!~#PMFHmSe5yjSsF=E2&qD~Dn zSFKd8a?i0CR$O*>8*L1NnuP=yWd}Rz;x}QQJ8&FcfPJ%UrFHK11$|F3l$4yEJ)i5~ z764Q-f+QIroCVdXKcfi#2?t71^4M~wa4{azfui=NW(@h~15g13P%pD4n@4OuWMTGu zF#W4Wsj1DSj!Upean@X(mvdic;;%-E;$t&B_(%YC|0y=GqgrL&H_dc4 z%eR)5seqgPt_0_~H2o$BNGA>^Z~8i|*%1OIglIQ?ROHcyJ|I1k_OfdOB7;i&85#Zjq;o^sfXJiBC=Q-55t%iYOo|mJSH*ucz`KHk`N_ zQpo!ak0#)juar*&L4YqS*wOf`-O&_!h{3?GGEJD2ji#_ySs7wZRBAQ=nvWt%g@x>M zG;`wVWbsH%vjI{=IQH@x9a#hbwA=umXlnIQVsmLJ>fOJMlJ^OssLsP5>$R&H1Hhjs zAd?S9h8NXe{RXYTI5s|*B{HxE3l84#Po}tWL~{l$Gb}F(~o>O z!gf{fUo=1|?~#F^ddWsfMUxLsFS|4)4|}{fD_K3dS6~d2b=m>ogORIW3IoO5OWKO{ zU-)^KbD4YX!o-uY4-%LkWQ-XeBzY)*N&#N{V2oMLGJ568t}6>K3t{@LP;X5Aj2(4c zAopxoYzu!|fF7urg?Rsp?gPCk8kIBUKN69FIdCbVOy#erOK^+&X}3yluG52g+3E5) z$wP8=G^A)=2tZ!&-OO&}V;%~|^^NgdAVvMDKi{MWF3Fx0iGe`dks}Y9fMJ{UOLKSi z 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 literal 2502 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 literal 2936 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 iuc!1W=+Go2 Date: Fri, 6 Feb 2026 10:10:36 +0100 Subject: [PATCH 69/73] Updated texts when taking samples --- .../ui/widgets/geo_estimator_widget.py | 56 +++++++------------ 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index 4c84a3cb..d0e54cea 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -68,43 +68,31 @@ class _CollectionStep(Enum): ORIGIN = ('bslh_1.png', - 'Step 1. Origin', - 'Put the Crazyflie where you want the ' + - 'origin of your coordinate system.') + 'Put the Crazyflie where you want the origin of your coordinate system.', + 'Start measurement') X_AXIS = ('bslh_2.png', - 'Step 2. X-axis', - 'Put the Crazyflie on the positive X-axis, ' + - f'exactly {REFERENCE_DIST} meters from the ' + - 'origin. This will be used to define the X-axis ' + - 'as well as scaling of the system.') + f'Put the Crazyflie on the positive X-axis, exactly {REFERENCE_DIST} meters from the ' + + 'origin. This sample will be used to define the X-axis as well as scaling of the system.', + 'Start measurement') XY_PLANE = ('bslh_3.png', - 'Step 3. XY-plane', - 'Put the Crazyflie somewhere in the XY-plane, ' + - 'but not on the X-axis. This position is used ' + - 'to map the the XY-plane to the floor. You can ' + - 'sample multiple positions to get a more ' + - 'precise definition.') - XYZ_SPACE = ('bslh_4.png', - 'Step 4. XYZ-space', - 'Sample points in the space that you will use. ' + - 'Make sure all the base stations are received, ' + - 'you need at least two base stations in each ' + - 'sample. Sample by rotating the Crazyflie quickly ' + - 'left-right around the Z-axis and then holding it ' + - 'still for a second, or optionally by clicking ' + - 'the sample button.') - + 'Put the Crazyflie somewhere in the XY-plane, but not on the X-axis. This position is used to map ' + + 'the XY-plane to the floor. You can sample multiple positions to get a more precise definition.', + 'Start measurement') VERIFICATION = ('bslh_4.png', - 'Step 5. Verification', - 'Sample points to be used for verification of the geometry. ' + - 'Sample by rotating the Crazyflie quickly ' + - 'left-right around the Z-axis and then holding it still for a second, or ' + - 'optionally by clicking the sample button below.') + 'Sample points to be used for verification of the geometry. Sample by rotating the Crazyflie ' + + 'quickly left-right around the Z-axis and then holding it still for a second, or ' + + 'optionally by clicking the button.', + 'Sample position') + XYZ_SPACE = ('bslh_4.png', + 'Sample points in the space to be used for refining the geometry. You need at least two base ' + + 'stations visible in each sample. Sample by rotating the Crazyflie quickly left-right around the ' + + 'Z-axis and then holding it still for a second, or optionally by clicking the button.', + 'Sample position') - def __init__(self, image, title, instructions): + def __init__(self, image, instructions, button_text): self.image = image - self.title = title self.instructions = instructions + self.button_text = button_text self._order = None @@ -358,11 +346,7 @@ def _update_step_ui(self): self._set_label_icon(self._step_image, step.image) self._step_instructions.setText(step.instructions) self._step_info.setText('') - - if step == _CollectionStep.XYZ_SPACE: - self._step_measure.setText('Sample position') - else: - self._step_measure.setText('Start measurement') + self._step_measure.setText(step.button_text) self._update_solution_info() From a2b296f986f5d60507449073fe119788b1824245 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 6 Feb 2026 10:21:40 +0100 Subject: [PATCH 70/73] Cleaned up solution status box --- src/cfclient/ui/widgets/geo_estimator.ui | 80 ++++++++++++++----- .../ui/widgets/geo_estimator_widget.py | 6 +- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/cfclient/ui/widgets/geo_estimator.ui b/src/cfclient/ui/widgets/geo_estimator.ui index b471aea8..bcd821a1 100644 --- a/src/cfclient/ui/widgets/geo_estimator.ui +++ b/src/cfclient/ui/widgets/geo_estimator.ui @@ -463,28 +463,72 @@ - - - - 0 - 0 - - - - TextLabel + + + Qt::Orientation::Horizontal - - - - 0 - 0 - - - - TextLabel + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + Solution sample error: + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + Validation sample error: + + + + + + + + + Qt::Orientation::Horizontal diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index d0e54cea..d5d7d1d9 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -395,9 +395,7 @@ def _update_solution_info(self): else: background_color_is_ok = solution.progress_is_ok - solution_error_label = 'Solution sample error:' solution_error = '--' - verification_error_label = 'Validation sample error:' verification_error = '--' if solution.progress_is_ok: @@ -418,8 +416,8 @@ def _update_solution_info(self): else: self._solution_status_info.setText('Not enough samples') - self._solution_status_max_error.setText(f'{solution_error_label} {solution_error}') - self._solution_status_verification_error.setText(f'{verification_error_label} {verification_error}') + self._solution_status_max_error.setText(f'{solution_error}') + self._solution_status_verification_error.setText(f'{verification_error}') self._set_background_color(self._solution_status_info, background_color_is_ok) def _notify_user(self, notification_type: _UserNotificationType): From 25b60a5dddf998c2ebab6c5d218adc4f66948607 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 6 Feb 2026 10:32:10 +0100 Subject: [PATCH 71/73] Better pop up menu text for changing sample type --- src/cfclient/ui/widgets/geo_estimator_details_widget.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_details_widget.py b/src/cfclient/ui/widgets/geo_estimator_details_widget.py index e20c3a54..92c2d86d 100644 --- a/src/cfclient/ui/widgets/geo_estimator_details_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_details_widget.py @@ -102,7 +102,10 @@ def _create_sample_table_context_menu(self, point): sample_type = self._samples_details_model.get_sample_type_of_row(row) delete_action = menu.addAction('Delete sample') - change_action = menu.addAction('Change type') + if sample_type == LhCfPoseSampleType.VERIFICATION: + change_action = menu.addAction('Change to XYZ-space sample') + else: + change_action = menu.addAction('Change to verification sample') action = menu.exec(self._samples_table_view.mapToGlobal(point)) From 644a41d22f5565f67d674a778ab4c62e5348f7ab Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 6 Feb 2026 10:59:39 +0100 Subject: [PATCH 72/73] Show pending color when sampling on the floor --- src/cfclient/ui/widgets/geo_estimator_widget.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index d5d7d1d9..babedb23 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -421,6 +421,7 @@ def _update_solution_info(self): self._set_background_color(self._solution_status_info, background_color_is_ok) def _notify_user(self, notification_type: _UserNotificationType): + timeout = 1000 match notification_type: case _UserNotificationType.SUCCESS: self._helper.cf.platform.send_user_notification(True) @@ -433,9 +434,10 @@ def _notify_user(self, notification_type: _UserNotificationType): case _UserNotificationType.PENDING: self._sample_collection_box.setStyleSheet(STYLE_YELLOW_BACKGROUND) self._update_ui_reading(True) + timeout = 3000 self._user_notification_clear_timer.stop() - self._user_notification_clear_timer.start(1000) + self._user_notification_clear_timer.start(timeout) def _user_notification_clear(self): self._sample_collection_box.setStyleSheet('') @@ -494,6 +496,7 @@ def _start_timeout_average_read(self, setter: Callable[[LhCfPoseSample], None]): self._timeout_reader_result_setter = setter self._step_info.setText("Collecting angles...") self._update_ui_reading(True) + self._user_notification_signal.emit(_UserNotificationType.PENDING) def _average_available_cb(self, sample: LhCfPoseSample): """Callback for when the average angles are available from the reader or after""" From b164653b327c9ebed3aee97a264d16b5b7d55557 Mon Sep 17 00:00:00 2001 From: Kristoffer Richardsson Date: Fri, 6 Feb 2026 11:16:44 +0100 Subject: [PATCH 73/73] Flake 8 --- src/cfclient/ui/tabs/lighthouse_tab.py | 25 ++++++++++++------- .../widgets/geo_estimator_details_widget.py | 3 ++- .../ui/widgets/geo_estimator_widget.py | 18 +++++++------ src/cfclient/ui/widgets/info_label.py | 8 ++++-- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/cfclient/ui/tabs/lighthouse_tab.py b/src/cfclient/ui/tabs/lighthouse_tab.py index 5dd844e9..5bac8a50 100644 --- a/src/cfclient/ui/tabs/lighthouse_tab.py +++ b/src/cfclient/ui/tabs/lighthouse_tab.py @@ -60,7 +60,6 @@ from vispy import scene import numpy as np -import math import os __author__ = 'Bitcraze AB' @@ -87,7 +86,8 @@ class MarkerPose(): LABEL_SIZE = 100 LABEL_OFFSET = np.array((0.0, 0, 0.25)) - def __init__(self, the_scene, color, text=None, axis_visible: bool = False, interactive=False, symbol: str = 'disc'): + def __init__(self, the_scene, color, text=None, axis_visible: bool = False, interactive=False, + symbol: str = 'disc'): self._scene = the_scene self._color = color self._text = text @@ -642,14 +642,18 @@ def __init__(self, helper): self._geo_estimator_details_widget = GeoEstimatorDetailsWidget() self._details_area.addWidget(self._geo_estimator_details_widget) self._geo_estimator_details_widget.sample_selection_changed_signal.connect(self._sample_selection_changed_cb) - self._geo_estimator_details_widget.base_station_selection_changed_signal.connect(self._base_station_selection_changed_cb) + self._geo_estimator_details_widget.base_station_selection_changed_signal.connect( + self._base_station_selection_changed_cb) # Connect signals between the geo estimator widget and the details widget self._geo_estimator_widget.solution_ready_signal.connect(self._geo_estimator_details_widget.solution_ready_cb) - self._geo_estimator_widget._details_checkbox.stateChanged.connect(self._geo_estimator_details_widget.details_checkbox_state_changed) + self._geo_estimator_widget._details_checkbox.stateChanged.connect( + self._geo_estimator_details_widget.details_checkbox_state_changed) self._geo_estimator_details_widget.do_remove_sample_signal.connect(self._geo_estimator_widget.remove_sample) - self._geo_estimator_details_widget.do_convert_to_xyz_space_sample_signal.connect(self._geo_estimator_widget.convert_to_xyz_space_sample) - self._geo_estimator_details_widget.do_convert_to_verification_sample_signal.connect(self._geo_estimator_widget.convert_to_verification_sample) + self._geo_estimator_details_widget.do_convert_to_xyz_space_sample_signal.connect( + self._geo_estimator_widget.convert_to_xyz_space_sample) + self._geo_estimator_details_widget.do_convert_to_verification_sample_signal.connect( + self._geo_estimator_widget.convert_to_verification_sample) # Always wrap callbacks from Crazyflie API though QT Signal/Slots # to avoid manipulating the UI when rendering it @@ -718,8 +722,10 @@ def __init__(self, helper): self._pending_geo_update = None - self._base_stations_info_label = InfoLabel("This is information about base station status. TODO update", self._base_station_group_box) - self._sys_management_info_label = InfoLabel("This is information about system management. TODO update", self._sys_management_group_box) + self._base_stations_info_label = InfoLabel("This is information about base station status. TODO update", + self._base_station_group_box) + self._sys_management_info_label = InfoLabel("This is information about system management. TODO update", + self._sys_management_group_box) def write_and_store_geometry(self, geometries: dict[int, LighthouseBsGeometry]): if self._lh_config_writer: @@ -1069,7 +1075,8 @@ def load_sys_config_user_action(self) -> bool: if self._lh_config_writer is None: return False - self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, names[0]) + self._lh_config_writer.write_and_store_config_from_file(self._new_system_config_written_to_cf_signal.emit, + names[0]) return True diff --git a/src/cfclient/ui/widgets/geo_estimator_details_widget.py b/src/cfclient/ui/widgets/geo_estimator_details_widget.py index 92c2d86d..ff78c930 100644 --- a/src/cfclient/ui/widgets/geo_estimator_details_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_details_widget.py @@ -148,7 +148,8 @@ def solution_ready_cb(self, solution: LighthouseGeometrySolution): self._samples_details_model.set_solution(solution) self._base_stations_details_model.set_solution(solution) - # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to avoid problems. + # There seems to be some issues with the selection when updating the model. Reset the 3D-graph selection to + # avoid problems. self.sample_selection_changed_signal.emit(-1) self.base_station_selection_changed_signal.emit(-1) diff --git a/src/cfclient/ui/widgets/geo_estimator_widget.py b/src/cfclient/ui/widgets/geo_estimator_widget.py index babedb23..42feda16 100644 --- a/src/cfclient/ui/widgets/geo_estimator_widget.py +++ b/src/cfclient/ui/widgets/geo_estimator_widget.py @@ -49,7 +49,7 @@ from cflib.localization.lighthouse_sweep_angle_reader import LighthouseMatchedSweepAngleReader from cflib.localization.lighthouse_bs_vector import LighthouseBsVectors from cflib.localization.lighthouse_types import LhDeck4SensorPositions -from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample, LhCfPoseSampleType, LhCfPoseSampleStatus +from cflib.localization.lighthouse_cf_pose_sample import LhCfPoseSample from cflib.localization.lighthouse_geo_estimation_manager import LhGeoInputContainer, LhGeoEstimationManager from cflib.localization.lighthouse_geometry_solution import LighthouseGeometrySolution from cflib.localization.user_action_detector import UserActionDetector @@ -222,10 +222,14 @@ def __init__(self, lighthouse_tab): self._verification_radio.clicked.connect(lambda: self._change_step(_CollectionStep.VERIFICATION)) self._details_checkbox.setChecked(False) - self._basic_steps_info_label = InfoLabel("This is information about basic steps. TODO update", self._basic_steps_section_label) - self._refined_steps_info_label = InfoLabel("This is information about refined steps. TODO update", self._refined_steps_section_label) - self._solution_status_info_label = InfoLabel("This is information about the solution status. TODO update", self._solution_status_group_box) - self._session_management_info_label = InfoLabel("This is information about session management. TODO update", self._session_management_group_box) + self._basic_steps_info_label = InfoLabel("This is information about basic steps. TODO update", + self._basic_steps_section_label) + self._refined_steps_info_label = InfoLabel("This is information about refined steps. TODO update", + self._refined_steps_section_label) + self._solution_status_info_label = InfoLabel("This is information about the solution status. TODO update", + self._solution_status_group_box) + self._session_management_info_label = InfoLabel("This is information about session management. TODO update", + self._session_management_group_box) def _start_geo_file_upload(self): if self._lighthouse_tab.load_sys_config_user_action(): @@ -234,7 +238,6 @@ def _start_geo_file_upload(self): # Clear all samples as the new configuration is based on some other (unknown) set of samples self.new_session() - def setVisible(self, visible: bool): super(GeoEstimatorWidget, self).setVisible(visible) if visible: @@ -546,7 +549,8 @@ def _solution_ready_cb(self, solution: LighthouseGeometrySolution): if solution.progress_is_ok: no_samples = not solution.contains_samples - if (self._upload_status == _UploadStatus.UPLOADING_FILE_CONFIG or self._upload_status == _UploadStatus.FILE_CONFIG_DONE) and no_samples: + if (self._upload_status == _UploadStatus.UPLOADING_FILE_CONFIG or + self._upload_status == _UploadStatus.FILE_CONFIG_DONE) and no_samples: # If we imported a config file and started an upload all samples are cleared and we will end up here # when the solver is done. We don't want to change the upload status in this case as the status will # show that there are no samples. diff --git a/src/cfclient/ui/widgets/info_label.py b/src/cfclient/ui/widgets/info_label.py index 0006412c..a197e3b5 100644 --- a/src/cfclient/ui/widgets/info_label.py +++ b/src/cfclient/ui/widgets/info_label.py @@ -43,7 +43,8 @@ class Position(Enum): ICON_HEIGHT = 16 MARGIN = 0 - def __init__(self, tooltip: str, parent: QWidget, position: Position = Position.TOP_RIGHT, v_margin: int = MARGIN, h_margin: int = MARGIN): + def __init__(self, tooltip: str, parent: QWidget, position: Position = Position.TOP_RIGHT, + v_margin: int = MARGIN, h_margin: int = MARGIN): super().__init__(parent) self._v_margin = v_margin @@ -53,7 +54,10 @@ def __init__(self, tooltip: str, parent: QWidget, position: Position = Position. parent.installEventFilter(self._event_filter) self.setToolTip(tooltip) - self.setPixmap(self.style().standardIcon(self.style().StandardPixmap.SP_MessageBoxInformation).pixmap(self.ICON_WIDTH, self.ICON_HEIGHT)) + + info_pixmap = self.style().StandardPixmap.SP_MessageBoxInformation + info_icon = self.style().standardIcon(info_pixmap).pixmap(self.ICON_WIDTH, self.ICON_HEIGHT) + self.setPixmap(info_icon) def mousePressEvent(self, event): """Override mouse press event to show tooltip on click."""