TLS Arbaro trees
Notebook: Hannah Weiser, 2026
This demo showcases two highly detailed tree models scanned from two terrestrial laser scanning (TLS) scanning positions.
Imports
[1]:
import helios
import numpy as np
Creating the virtual scene
Our virtual scene consists of three scene parts - a ground plane and two trees - all loaded as 3D models in .OBJ format. The ground plane is additionally scaled by a factor of 100.
The tree models use the y-axis as “up” axis. Hence, we set up_axis="y" (default is “z”). We furthermore scale the tree models down to half their size and translate them to position them in the scene. Finally, we create a static scene from the loaded scene parts.
[2]:
# load objs and create transformations
groundplane = helios.ScenePart.from_obj(
"../data/sceneparts/basic/groundplane/groundplane.obj"
).scale(
100
) # scale groundplane by 100
tree1 = helios.ScenePart.from_obj(
"../data/sceneparts/arbaro/black_tupelo_low.obj", up_axis="y"
) # 3D models exported from Blender often have the Y-axis as "up-axis"
tree2 = helios.ScenePart.from_obj(
"../data/sceneparts/arbaro/sassafras_low.obj", up_axis="y"
)
# Trees should both be scaled down by half and translated
tree1 = tree1.scale(0.5).translate([0.0, 15.0, 0.0])
tree2 = tree2.scale(0.5).translate([-10.0, 15.0, 0.0])
# create scene
scene = helios.StaticScene(scene_parts=[groundplane, tree1, tree2])
Platform and scanner
HELIOS++ comes with a wide range of pre-defined scanners and platforms. We can check which scanners and platforms are available using helios.list_scanners() and helios.list_platforms().
For this demo, we use the “RIEGL VZ-400” scanner mounted on a “tripod” platform.
[3]:
scanner = helios.scanner_from_name("riegl_vz_400")
platform = helios.platform_from_name("tripod")
Predefined scanners and platforms are defined in the platforms.xml, scanners_als.xml, and scanners_tls.xml files in the python/helios/data directory. You can also define your own scanners and platforms by creating your own XML files following the same format and making sure the file is in one of the helios search directories. By default, these are the current working directory and the helios and
helios/data directories of the helios package.
You can add custom folders to the asset search path using helios.add_asset_directory().
[4]:
helios.add_asset_directory("<path_to_your_custom_assets>")
You can look at the XML definition of our chosen scanner here: https://github.com/3dgeo-heidelberg/helios/blob/main/python/helios/data/scanners_tls.xml
There are a lot of scanner-specific settings, including the accuracy (accuracy_m), the beam divergence (beamDivergence_rad), and a list of possible pulse frequencies (pulseFreq_Hz). The scanner uses a rotating prism as the beam deflector, most likely a triangular prism. The actual output window mentioned in the data sheet (+60° to -40° = 100°; see the image below) is smaller than the maximum output
window (2 x 120° = 240°) in order to prevent the beam from hitting another face of the rotating deflector, which would result in scattering in not-well defined directions. In HELIOS, we define the actual field of view using the scanAngleEffectiveMax_deg and the maximum field of view using the scanAngle_deg. Because the maximum field of view is not usually provided in the datasheet, we must look at the ratio of the effective measurement rate to the pulse repetition rate, which is the same
as the ratio between scanAngleEffectiveMax_deg and scanAngle_deg. For the RIEGL VZ-400, this ratio is 2.4, as shown below in an excerpt from the data sheet. Therefore, we can compute the maximum field of view as 2.4 times the actual field of view, i.e., 100° x 2.4 = 240°. Because the scan angles in HELIOS are defined as half-angles, we divide by two to get the maximum scanAngle_deg of 120°.

Source: RIEGL Laser Measurement Systems (2017): RIEGL VZ-400 data sheet
Scanner settings
Below, we define two sets of scanner settings, which will be used for the two legs. In the first version (scanner_settings1), we define the scan frequency and the head rotation speed for the scanner.
However, for TLS, users typically do not specify these settings, but instead provide a vertical and horizontal angular resolution. This is demonstrated in the scanner_settings2. HELIOS++ then internally computed the needed scan frequency and head rotation speed to achieve these resolutions.
Note that we can set units in two different ways, either expressed as strings like "10 deg/s" or by multiplying with units from the helios.units object. This is implemented with the Python package pint. Note that these are the default units that HELIOS++ uses:
Angle: rad
Frequency: Hz
Length: m
TimeInterval: s
So especially for angles, users are advised to take care of the radians to degree conversion where needed.
[5]:
scanner_settings1 = helios.ScannerSettings(
pulse_frequency=100_000, # Hz
scan_frequency=120, # Hz
min_vertical_angle="-40 deg",
max_vertical_angle="60 deg",
head_rotation="10 deg/s",
)
# for rotating head and rotating mirror terrestrial laser scanners, we can also provide
# the vertical and horizontal angular resolution instead of scan frequency and head rotation speed
scanner_settings2 = helios.ScannerSettings(
pulse_frequency=100_000, # Hz
vertical_resolution=0.09 * helios.units.deg,
horizontal_resolution=0.09 * helios.units.deg,
min_vertical_angle=-40 * helios.units.deg,
max_vertical_angle=60 * helios.units.deg,
)
# Full waveform settings
fullwave_settings = helios.FullWaveformSettings(
bin_size=0.2 * helios.units.ns, beam_sample_quality=3 # default
)
Scan positions
Next, we define two scan positions for our TLS survey. In HELIOS++, scan positions (or waypoints for mobile surveys) are called “legs”. Below, we first create the survey with our defined scanner, platform, scene, and full waveform settings, and then add the two legs, specifying the platform positions and the horizontal field of view of the sensor.
[6]:
survey = helios.Survey(
scanner=scanner,
platform=platform,
scene=scene,
full_waveform_settings=fullwave_settings,
)
# first scan position
survey.add_leg(
scanner_settings=scanner_settings1,
x=1.0,
y=25.5,
z=1.5,
force_on_ground=True, # this works because of the specification in the groundplane.mtl file
rotation_start_angle=100 * helios.units.deg,
rotation_stop_angle=225 * helios.units.deg,
)
# second scan position, here we use the other scan setting definition
survey.add_leg(
scanner_settings=scanner_settings2,
x=-4.0,
y=-2.5,
z=1.5,
force_on_ground=True,
rotation_start_angle=-45 * helios.units.deg,
rotation_stop_angle=45 * helios.units.deg,
)
Running the survey
Per defult, the survey.run() method returns two NumPy arrays, points measurements and the recorded trajectory.
[7]:
points, trajectories = survey.run(
verbosity=helios.LogVerbosity.VERBOSE, format=helios.OutputFormat.NPY
)
CRS bounding box (by vertices): Min: dvec3(-100.000000, -100.000000, 0.000000), Max: dvec3(100.000000, 100.000000, 13.945395)
Shift: dvec3(0.000000, 0.000000, 6.972697)
# vertices to translate: 680580
Actual bounding box (by vertices): Min: dvec3(-100.000000, -100.000000, -6.972697), Max: dvec3(100.000000, 100.000000, 6.972697)
Building KD-Grove...
KDTree (num. primitives 226860) :
Max. # primitives in leaf: 77
Min. # primitives in leaf: 1
Max. depth reached: 39
KDTree axis-aligned surface area: 91156.3
Interior nodes: 413672
Leaf nodes: 344113
Total tree cost: 6.16344
KDGrove stats:
Number of trees: 1
Number of static trees: 1
Number of dynamic trees: 0
Statistics (min, max, total, mean, stdev):
Building time: (0.6270, 0.6270, 0.6270, 0.6270, 0.0000)
Tree primitives: (226860, 226860, 226860, 226860.0000, 0.0000)
Max primitives in leaf: (77, 77, 77, 77.0000, 0.0000)
Min primitives in leaf: (1, 1, 1, 1.0000, 0.0000)
Maximum depth: (39, 39, 39, 39.0000, 0.0000)
Axis-aligned surface area: (91156.3160, 91156.3160, 91156.3160, 91156.3160, 0.0000)
Number of interior nodes: (413672, 413672, 413672, 413672.0000, 0.0000)
Number of leaf nodes: (344113, 344113, 344113, 344113.0000, 0.0000)
Tree cost: (6.1634, 6.1634, 6.1634, 6.1634, 0.0000)
KDG built in 0.628s
Reading Spectral Library...
10 materials found
Warning: material None of primitive 8Triangle (/home/runner/work/helios/helios/example_notebooks/../data/sceneparts/basic/groundplane/groundplane.mtl) has no spectral definition
Scanner settings of leg 1 have been updated to consider the ratio between max effective scan angle and max scan angle.
Consequently, the old scanFreq_Hz = 200 and headRotatePerSec_rad = 0 have been updated.
The new values are scanFreq_Hz = 37.5 and headRotatePerSec_rad = 0.0589049.
Number of subsampling rays (riegl_vz400): 19
Simulation: Scanner changed!
Pulse frequency set to 100000
Scan angle set to 120
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max -40/60 degrees
SOURCE Leg with serial ID:0 waypoints:
Origin: (1, 25.5, -6.9727)
Target: (-4, -2.5, -6.9727)
Next: (-4, -2.5, -6.9727)
Starting simulation loop 1 ...
Pulse frequency set to 100000
Scan angle set to 120
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max -40/60 degrees
Finishing simulation loop 1 ...
Finished simulation loop 1.
Elapsed simulation steps = 3916670
Elapsed virtual time = 39.1667 sec.
Main thread simulation loop finished in 8.80631 sec.
Waiting for completion of pulse computation tasks...
Pulse computation tasks finished in 8.80633 sec.
points and trajectories are NumPy arrays of structured data type:
[8]:
points[:3]
[8]:
array([(0, 0, [-1.81273254e+00, 2.50003586e+01, 2.42807424e-03], [-0.84642353, -0.15035493, -0.51084303], [ 1. , 25.5 , -5.2726975], 3.32307934, 43465730.0059277 , 0., 1, 1, 241, 0, 426984.00726, 0),
(0, 0, [-3.30645770e+00, 2.47347640e+01, 2.06343858e-03], [-0.91784469, -0.16309641, -0.36188491], [ 1. , 25.5 , -5.2726975], 4.69192419, 17671439.26310572, 0., 1, 1, 274, 0, 426984.00759, 0),
(0, 0, [-3.36147868e+00, 2.47249792e+01, 5.94182791e-03], [-0.91962378, -0.1634142 , -0.3571945 ], [ 1. , 25.5 , -5.2726975], 4.74267716, 14406766.23522477, 0., 1, 1, 275, 0, 426984.0076 , 0)],
dtype=[('channel_id', '<u8'), ('hit_object_id', '<i4'), ('position', '<f8', (3,)), ('beam_direction', '<f8', (3,)), ('beam_origin', '<f8', (3,)), ('distance', '<f8'), ('intensity', '<f8'), ('echo_width', '<f8'), ('return_number', '<i4'), ('number_of_returns', '<i4'), ('fullwave_index', '<i4'), ('classification', '<i4'), ('gps_time', '<f8'), ('point_source_id', '<u2')])
[9]:
trajectories[:3]
[9]:
array([(426984. , [ 1. , 25.5, 0. ], -0., 0., -0.),
(426984.01, [ 1. , 25.5, 0. ], -0., 0., -0.),
(426984.02, [ 1. , 25.5, 0. ], -0., 0., -0.)],
dtype=[('gps_time', '<f8'), ('position', '<f8', (3,)), ('roll', '<f8'), ('pitch', '<f8'), ('yaw', '<f8')])
If we want a different output format, we need to change the format parameter of the method. Available options are:
NPY (default)
LAS
LAZ
XYZ
LASPY
Not that the LASPY format also returns two Python objects, but the first is a laspy.LasData instance instead of a NumPy array.
If LAS, LAZ, or XYZ are provided as format, then the output is written to file and the .run() method returns only a single value, the path to the created output directory.
We can also set different log verbosity levels.
[10]:
survey.run(verbosity=helios.LogVerbosity.QUIET, format=helios.OutputFormat.LAZ)
[10]:
PosixPath('/home/runner/work/helios/helios/example_notebooks/output/2026-05-21_22-36-45')
Visualizing the result
Finally, let’s visualize the simulated points using matplotlib.
[11]:
import matplotlib.pyplot as plt
pos = points["position"]
# discard points outside of [-20, -20] to [20, 20] (x, y)
points_sub = points[
(pos[:, 0] > -20) & (pos[:, 1] > -20) & (pos[:, 0] < 20) & (pos[:, 1] < 20)
]
pos = points_sub["position"]
sp_1 = points_sub["point_source_id"] == 0
sp_2 = points_sub["point_source_id"] == 1
fig, ax = plt.subplots(figsize=(15, 10))
ax.scatter(pos[sp_1, 0], pos[sp_1, 2], s=0.1, alpha=0.5, label="Points from SP1")
ax.scatter(pos[sp_2, 0], pos[sp_2, 2], s=0.1, alpha=0.5, label="Points from SP2")
plt.axis("equal")
ax.tick_params(labelsize=16)
plt.xlabel("X", fontsize=18)
plt.ylabel("Z", fontsize=18, rotation=0)
plt.legend(fontsize=18, markerscale=20)
plt.show()
[ ]: