|
| 1 | +""" |
| 2 | +GUI application to help manually tune LOAM feature extraction parameters |
| 3 | +
|
| 4 | +Author: Dan McGann |
| 5 | +Date: May 2025 |
| 6 | +""" |
| 7 | + |
| 8 | +import os |
| 9 | +import sys |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import numpy as np |
| 13 | +import open3d as o3d |
| 14 | +import open3d.visualization.gui as gui |
| 15 | +import open3d.visualization.rendering as rendering |
| 16 | + |
| 17 | +# Manually access the loam package in case it is not installed |
| 18 | +SCRIPT_DIR = Path(os.path.dirname(os.path.abspath(__file__))) |
| 19 | +sys.path.append(str(SCRIPT_DIR / ".." / "build" / "python")) |
| 20 | +import loam |
| 21 | + |
| 22 | + |
| 23 | +class FeatureViewer: |
| 24 | + """ |
| 25 | + The visualizer for LOAM Features |
| 26 | +
|
| 27 | + Based on: https://github.com/isl-org/Open3D/blob/main/examples/python/visualization/all_widgets.py |
| 28 | + """ |
| 29 | + |
| 30 | + def __init__(self): |
| 31 | + # Define the Window |
| 32 | + self.window = gui.Application.instance.create_window( |
| 33 | + "Feature Viewer", 1280, 720 |
| 34 | + ) |
| 35 | + |
| 36 | + # The feature extraction parameters that available for edit |
| 37 | + self.fe_params = { |
| 38 | + "neighbor_points": 3, |
| 39 | + "number_sectors": 6, |
| 40 | + "max_edge_feats_per_sector": 10, |
| 41 | + "max_planar_feats_per_sector": 50, |
| 42 | + "edge_feat_threshold": 100.0, |
| 43 | + "planar_feat_threshold": 1.0, |
| 44 | + "occlusion_thresh": 0.5, |
| 45 | + "parallel_thresh": 1.0, |
| 46 | + } |
| 47 | + |
| 48 | + # The Lidar Parameters for the PCD |
| 49 | + self.lidar_params = { |
| 50 | + "rows": 64, |
| 51 | + "cols": 1024, |
| 52 | + "min_range": 1.0, |
| 53 | + "max_range": 100.0, |
| 54 | + } |
| 55 | + |
| 56 | + # The pointcloud to Evaluate |
| 57 | + self.pcd = None |
| 58 | + |
| 59 | + # The Materials for rendering |
| 60 | + self.pcd_mat = rendering.MaterialRecord() |
| 61 | + self.pcd_mat.base_color = [0, 0, 0, 1] |
| 62 | + self.pcd_mat.point_size = 2 |
| 63 | + |
| 64 | + self.plane_mat = rendering.MaterialRecord() |
| 65 | + self.plane_mat.base_color = [0, 0, 1, 1] |
| 66 | + self.plane_mat.point_size = 5 |
| 67 | + |
| 68 | + self.edge_mat = rendering.MaterialRecord() |
| 69 | + self.edge_mat.base_color = [1, 0.8, 0, 1] |
| 70 | + self.edge_mat.point_size = 5 |
| 71 | + |
| 72 | + # Define the Geometry Scene |
| 73 | + self._define_scene() |
| 74 | + |
| 75 | + # Define the Parameter selection Panel |
| 76 | + self._define_parameter_panel() |
| 77 | + |
| 78 | + # Add the Settings to the window |
| 79 | + self.window.set_on_layout(self._on_layout) |
| 80 | + self.window.add_child(self._scene) |
| 81 | + self.window.add_child(self._param_panel) |
| 82 | + |
| 83 | + def _define_scene(self): |
| 84 | + self._scene = gui.SceneWidget() |
| 85 | + self._scene.scene = rendering.Open3DScene(self.window.renderer) |
| 86 | + self._scene.scene.set_background([1, 1, 1, 1]) # White Background |
| 87 | + self._scene.scene.scene.set_sun_light( |
| 88 | + [-1, -1, -1], [1, 1, 1], 100000 |
| 89 | + ) # direction, color, intensity |
| 90 | + self._scene.scene.scene.enable_sun_light(True) |
| 91 | + bbox = o3d.geometry.AxisAlignedBoundingBox([-10, -10, -10], [10, 10, 10]) |
| 92 | + self._scene.setup_camera(60, bbox, [0, 0, 0]) |
| 93 | + |
| 94 | + def _add_lidar_param_setter(self, grid, param_name, data_type): |
| 95 | + layout = gui.Horiz(0.25 * self.window.theme.font_size) |
| 96 | + layout.add_child(gui.Label(f"{param_name}:")) |
| 97 | + editor = gui.NumberEdit( |
| 98 | + gui.NumberEdit.INT if data_type == "int" else gui.NumberEdit.DOUBLE |
| 99 | + ) |
| 100 | + editor.double_value = self.lidar_params[param_name] |
| 101 | + editor.set_on_value_changed(lambda v: self.lidar_params.update({param_name: v})) |
| 102 | + layout.add_child(editor) |
| 103 | + grid.add_child(layout) |
| 104 | + |
| 105 | + def _add_parameter_tuner(self, param_name, data_type, limits): |
| 106 | + tuner = gui.VGrid(3, 0.25 * self.window.theme.font_size) |
| 107 | + tuner.add_child(gui.Label(f"{param_name}:")) |
| 108 | + # The slider for this parameter |
| 109 | + slider = gui.Slider(gui.Slider.INT if data_type == "int" else gui.Slider.DOUBLE) |
| 110 | + slider.set_limits(*limits) |
| 111 | + slider.double_value = self.fe_params[param_name] |
| 112 | + tuner.add_child(slider) |
| 113 | + # The number editor for this parameter |
| 114 | + editor = gui.NumberEdit( |
| 115 | + gui.NumberEdit.INT if data_type == "int" else gui.NumberEdit.DOUBLE |
| 116 | + ) |
| 117 | + editor.set_limits(*limits) |
| 118 | + editor.double_value = self.fe_params[param_name] |
| 119 | + tuner.add_child(editor) |
| 120 | + |
| 121 | + # Setup the callbacks for this parameter |
| 122 | + slider.set_on_value_changed( |
| 123 | + lambda v: self._on_param_update(v, param_name, slider, editor) |
| 124 | + ) |
| 125 | + editor.set_on_value_changed( |
| 126 | + lambda v: self._on_param_update(v, param_name, slider, editor) |
| 127 | + ) |
| 128 | + |
| 129 | + # Add the parameter row to the layout |
| 130 | + self._param_panel.add_child(tuner) |
| 131 | + |
| 132 | + def _define_parameter_panel(self): |
| 133 | + em = self.window.theme.font_size |
| 134 | + # Widgets are laid out in layouts: gui.Horiz, gui.Vert, |
| 135 | + self._param_panel = gui.Vert( |
| 136 | + 0.25 * em, gui.Margins(0.5 * em, 0.5 * em, 0.5 * em, 0.5 * em) |
| 137 | + ) |
| 138 | + |
| 139 | + # Create a file-chooser widget for selecting the PCD file to run feature extraction on |
| 140 | + self._param_panel.add_child(gui.Label("Select PCD File to Evaluate:")) |
| 141 | + self._fileedit = gui.TextEdit() |
| 142 | + filedlgbutton = gui.Button("...") |
| 143 | + filedlgbutton.horizontal_padding_em = 0.5 |
| 144 | + filedlgbutton.vertical_padding_em = 0 |
| 145 | + filedlgbutton.set_on_clicked(self._on_filedlg_button) |
| 146 | + |
| 147 | + # Create the horizontal widget for the row. |
| 148 | + fileedit_layout = gui.Horiz(0.5 * em) |
| 149 | + fileedit_layout.add_child(gui.Label("PCD File:")) |
| 150 | + fileedit_layout.add_child(self._fileedit) |
| 151 | + fileedit_layout.add_fixed(0.25 * em) |
| 152 | + fileedit_layout.add_child(filedlgbutton) |
| 153 | + # add to the top-level (vertical) layout |
| 154 | + self._param_panel.add_child(fileedit_layout) |
| 155 | + |
| 156 | + self._param_panel.add_child(gui.Label("Specify Lidar Parameters:")) |
| 157 | + lidar_param_grid = gui.VGrid(2, 0.5 * em) |
| 158 | + self._add_lidar_param_setter(lidar_param_grid, "rows", "int") |
| 159 | + self._add_lidar_param_setter(lidar_param_grid, "min_range", "double") |
| 160 | + self._add_lidar_param_setter(lidar_param_grid, "cols", "int") |
| 161 | + self._add_lidar_param_setter(lidar_param_grid, "max_range", "double") |
| 162 | + self._param_panel.add_child(lidar_param_grid) |
| 163 | + |
| 164 | + # Add Tuners for all of the parameters |
| 165 | + self._param_panel.add_child(gui.Label("Adjust to Tune Features:")) |
| 166 | + self._add_parameter_tuner("neighbor_points", "int", (1, 20)) |
| 167 | + self._add_parameter_tuner("number_sectors", "int", (1, 20)) |
| 168 | + self._add_parameter_tuner("max_edge_feats_per_sector", "int", (1, 10000)) |
| 169 | + self._add_parameter_tuner("max_planar_feats_per_sector", "int", (1, 10000)) |
| 170 | + |
| 171 | + self._add_parameter_tuner("edge_feat_threshold", "double", (0.0, 1000.0)) |
| 172 | + self._add_parameter_tuner("planar_feat_threshold", "double", (0.0, 1000.0)) |
| 173 | + self._add_parameter_tuner("occlusion_thresh", "double", (0.0, 10.0)) |
| 174 | + self._add_parameter_tuner("parallel_thresh", "double", (0.0, 10.0)) |
| 175 | + |
| 176 | + def _on_filedlg_button(self): |
| 177 | + filedlg = gui.FileDialog(gui.FileDialog.OPEN, "Select file", self.window.theme) |
| 178 | + filedlg.add_filter(".pcd", "Pointcloud (.pcd)") |
| 179 | + filedlg.add_filter("", "All files") |
| 180 | + filedlg.set_on_cancel(self._on_filedlg_cancel) |
| 181 | + filedlg.set_on_done(self._on_filedlg_done) |
| 182 | + self.window.show_dialog(filedlg) |
| 183 | + |
| 184 | + def _on_filedlg_cancel(self): |
| 185 | + self.window.close_dialog() |
| 186 | + |
| 187 | + def _on_filedlg_done(self, path): |
| 188 | + self._fileedit.text_value = path |
| 189 | + self.pcd = o3d.io.read_point_cloud(path) |
| 190 | + self._scene.scene.remove_geometry("pcd") |
| 191 | + self._scene.scene.add_geometry("pcd", self.pcd, self.pcd_mat) |
| 192 | + self._update_features() |
| 193 | + self.window.close_dialog() |
| 194 | + |
| 195 | + def _on_param_update(self, new_val, param_name, slider, editor): |
| 196 | + self.fe_params[param_name] = new_val |
| 197 | + slider.double_value = new_val |
| 198 | + editor.double_value = slider.double_value # slider val to clamp |
| 199 | + self._update_features() |
| 200 | + |
| 201 | + def _update_features(self): |
| 202 | + if self.pcd: |
| 203 | + fe_params = loam.FeatureExtractionParams() |
| 204 | + fe_params.neighbor_points = int(self.fe_params["neighbor_points"]) |
| 205 | + fe_params.number_sectors = int(self.fe_params["number_sectors"]) |
| 206 | + fe_params.max_edge_feats_per_sector = int( |
| 207 | + self.fe_params["max_edge_feats_per_sector"] |
| 208 | + ) |
| 209 | + fe_params.max_planar_feats_per_sector = int( |
| 210 | + self.fe_params["max_planar_feats_per_sector"] |
| 211 | + ) |
| 212 | + fe_params.edge_feat_threshold = self.fe_params["edge_feat_threshold"] |
| 213 | + fe_params.planar_feat_threshold = self.fe_params["planar_feat_threshold"] |
| 214 | + fe_params.occlusion_thresh = self.fe_params["occlusion_thresh"] |
| 215 | + fe_params.parallel_thresh = self.fe_params["parallel_thresh"] |
| 216 | + |
| 217 | + try: |
| 218 | + lidar_params = loam.LidarParams( |
| 219 | + int(self.lidar_params["rows"]), |
| 220 | + int(self.lidar_params["cols"]), |
| 221 | + self.lidar_params["min_range"], |
| 222 | + self.lidar_params["max_range"], |
| 223 | + ) |
| 224 | + |
| 225 | + features = loam.extractFeatures( |
| 226 | + np.asarray(self.pcd.points), lidar_params, fe_params |
| 227 | + ) |
| 228 | + |
| 229 | + planar_features = o3d.geometry.PointCloud() |
| 230 | + planar_features.points = o3d.utility.Vector3dVector( |
| 231 | + features.planar_points |
| 232 | + ) |
| 233 | + edge_features = o3d.geometry.PointCloud() |
| 234 | + edge_features.points = o3d.utility.Vector3dVector(features.edge_points) |
| 235 | + |
| 236 | + self._scene.scene.remove_geometry("planar_features") |
| 237 | + self._scene.scene.add_geometry( |
| 238 | + "planar_features", planar_features, self.plane_mat |
| 239 | + ) |
| 240 | + self._scene.scene.remove_geometry("edge_features") |
| 241 | + self._scene.scene.add_geometry( |
| 242 | + "edge_features", edge_features, self.edge_mat |
| 243 | + ) |
| 244 | + except Exception: |
| 245 | + dlg = gui.Dialog("ERROR") |
| 246 | + em = self.window.theme.font_size |
| 247 | + dlg_layout = gui.Vert(em, gui.Margins(em, em, em, em)) |
| 248 | + dlg_layout.add_child( |
| 249 | + gui.Label( |
| 250 | + "Could not compute features on provided pointcloud. Check that the lidar parameters are correct." |
| 251 | + ) |
| 252 | + ) |
| 253 | + ok_button = gui.Button("Ok") |
| 254 | + ok_button.set_on_clicked(self.window.close_dialog) |
| 255 | + dlg_layout.add_child(ok_button) |
| 256 | + dlg.add_child(dlg_layout) |
| 257 | + self.window.show_dialog(dlg) |
| 258 | + |
| 259 | + def _on_layout(self, layout_context): |
| 260 | + # The on_layout callback should set the frame (position + size) of every |
| 261 | + # child correctly. After the callback is done the window will layout |
| 262 | + # the grandchildren. |
| 263 | + r = self.window.content_rect |
| 264 | + self._scene.frame = r |
| 265 | + |
| 266 | + param_sizes = self._param_panel.calc_preferred_size( |
| 267 | + layout_context, gui.Widget.Constraints() |
| 268 | + ) |
| 269 | + width = max(r.width * 0.35, 350) |
| 270 | + height = min(r.height, param_sizes.height) |
| 271 | + self._param_panel.frame = gui.Rect(r.get_right() - width, r.y, width, height) |
| 272 | + |
| 273 | + |
| 274 | +def main(): |
| 275 | + # We need to initialize the application, which finds the necessary shaders for |
| 276 | + # rendering and prepares the cross-platform window abstraction. |
| 277 | + gui.Application.instance.initialize() |
| 278 | + # Initialize the feature viewer |
| 279 | + w = FeatureViewer() |
| 280 | + # Run the event loop. This will not return until the last window is closed. |
| 281 | + gui.Application.instance.run() |
| 282 | + |
| 283 | + |
| 284 | +if __name__ == "__main__": |
| 285 | + main() |
0 commit comments