Skip to content

Commit 2e2e08f

Browse files
authored
Feature Visualization + Param Tuning (#4)
* Improved Default Params After testing these parameters seem to work better than the previous defaults for modern lidars (e.g. OS1). * Add Feature Visualizer Script Add script useful for vizualizing LOAM Features and tuning parameters. * Add scripts documentation
1 parent 08df054 commit 2e2e08f

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ Of course simple Scan-to-Scan matching does drift. To get better performance you
7373
* `registration*` - Defines functionally to register two feature sets.
7474
* `loam.h` - Convenience header for including entire library.
7575
* `python/` - Contains definition of python bindings.
76+
* `scripts/` - Contains helper scripts.
77+
* `tune_feature_extraction.py` - An `open3d` based visualizer to help users tune feature extraction parameters.
7678
* `tests/` - Contains unit tests for the library.
7779

7880
All code is currently documented inline (doxygen coming soon!). For example usage see tests in `tests/` and an example ros wrapper is coming soon.
@@ -141,6 +143,11 @@ To run the unit tests:
141143
* Run the Tests
142144
* `make loam-test` or `make loam-check`
143145

146+
### Scripts
147+
To run the feature extraction tuning script users will need to build the python bindings, but do not necessarily need to install them as the script manually accesses the package if built. Users will additionally need `open3d` and `numpy` installed in their python environment.
148+
149+
Running this script will open an interactive GUI. Users may use the menu select a LiDAR scan (in PCD format, and visualized in black) from which to compute visualize the extracted feature points (orange=edge, blue=planar). The GUI also allows users to dynamically adjust the feature extraction parameters to visualize their effects and tune the hyper parameters. Note: users must ensure that the LiDAR configuration is correct for the selected LiDAR Scan.
150+
144151
## Quirks
145152
* We need ceres 2.2.0 to make use of manifolds so we access it via fetch content. This causes a cmake name collision of `uninstall` with nanoflann. Since neither prefix their target names. Thankfully ceres provides an option `PROVIDE_UNINSTALL_TARGET` (also not prefixed :/) to resolve this collision. Additionally, since ceres is built locally, we will never need to install OR uninstall it, so loosing this target is a-okay.
146153

scripts/tune_feature_extraction.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

Comments
 (0)