Survey

The survey is where everything comes together. It takes a scene, a scanner and a platform and the so-called “legs” that define the positions/trajectories of the platform during the survey and the speicifc settings of the scanner.

[1]:
import copy
import pandas as pd
import helios

Let’s demonstrate this for a basic ALS survey and prepare the platform, scanner and scene.

[2]:
platform = helios.platform_from_name("sr22")
scanner = helios.scanner_from_name("leica_als50")
sceneparts = helios.ScenePart.from_objs("../data/sceneparts/toyblocks/*.obj")
scene = helios.StaticScene(sceneparts)

From this, we can create an “empty” survey, which we can then populate with legs and settings:

[3]:
survey = helios.Survey(scene=scene, scanner=scanner, platform=platform)
[4]:
# no legs defined yet
survey.legs
[4]:
()

To use surveys created for ealier versions of helios (< v3.0.0), we can also load surveys from XML files. To make sure the survey can be loaded correctly, add the directory where the survey XML file is located as an asset directory (here: the root directory of the cloned helios repository):

[5]:
helios.add_asset_directory("D:/Software/_helios_versions/helios/")
tls_toyblocks_survey = helios.Survey.from_xml(
    "data/surveys/toyblocks/tls_toyblocks.xml"
)

Scanner settings

Scanner settings are defined per leg. Often, many or all legs of a survey will share the same scanner settings. We can define them once and then pass them to the legs. Depending on the optics of the respective scanner, different scan settings are relevant.

Note that we always specify the unit of the settings, resolving any ambiguity and ensuring that the settings are correctly interpreted. This is done by the help of the package pint. We can either specify the units by multiplying with the unit from helios.units or by using a string with the unit.

[6]:
scan_settings = helios.ScannerSettings(
    pulse_frequency=100_000 * helios.units.Hz,
    scan_angle=20 * helios.units.deg,
    scan_frequency="70 Hz",
    trajectory_time_interval=0.01 * helios.units.s,
)

Platform settings

Likewise, we also have platform settings. We can use PlatformSettings for static platforms and DynamicPlatformSettings for moving platforms. The latter allows us to specify the speed of the platform, or - if using interpolated trajectory, the trajectory settings. However, since platform settings are usually not shared between legs, we will often just specify the individual parameters directly when constructing or adding legs.

Legs

Legs are the positions or waypoints of a survey. In trajectory mode, they can also be portions of a given trajectory.

Waypoint mode

In the first example, we use the waypoint mode and define a list of waypoints that the airplane should fly through during the survey. Additionally, we set the speed of the platform.

[7]:
waypoints = [[-50, -25, 250], [50, -25, 250], [50, 25, 250], [-50, 25, 250]]
speed = 30  # m/s

Using a simple loop, we can now add the legs at the respective positions and apply the scan settings.

[8]:
for x, y, z in waypoints:
    survey.add_leg(x=x, y=y, z=z, speed_m_s=speed, scanner_settings=scan_settings)

Trajectory mode

Instead of defining waypoints, we can also define a trajectory that the platform should follow during the survey. This has the advantage, that the platform’s attitude can be considered and does not stay constant as in the waypoint mode. The input trajectory can come from real-world flights, which allows to closely replicate them, or be generated synthetically, e.g., by a flight simulator.

We can load such a trajectory from a CSV file, or directly create it in Python as a structured NumPy array (see ALS over a DTM with constant height above ground). The trajectory must contain the columns “t” (time), “roll”, “pitch”, “yaw”, “x”, “y”, “z”. t is the time, roll, pitch and yaw are the orientation, x, y and z are the position of the platform at a given time.

[9]:
trajectory = helios.load_traj_csv(
    csv="../data/trajectories/flyandrotate.trj",
    tIndex=0,
    xIndex=4,
    yIndex=5,
    zIndex=6,
    rollIndex=1,
    pitchIndex=2,
    yawIndex=3,
)

The platform is then loaded as “interpolate platform”, which means that HELIOS++ will interpolate the platform position and orientation at the time of each scan based on the trajectory. Two interpolation_method values can be chosen:

  • ARINC 705: This definition is according to the aviation norm ARINC 705 (Bäumker & Heimes, 2001) and interprets the rotations as intrinsic, i.e., body-fixed rotations. This is the default interpolation method.

  • CANONICAL: This definition interprets the rotations as extrinsic, i.e., world-fixed rotations.

In addition, there are two boolean parameters that can be set when loading the platform:

  • sync_gps_time: If set to True, the starting time of the simulation will be synchronized with the ninimum time value from the input trajectory.

  • is_roll_pitch_yaw_in_radians: If set to True, the roll, pitch and yaw values in the trajectory will be interpreted as radians. If set to False, they will be interpreted as degrees. Default is False.

[10]:
scanner2 = helios.scanner_from_name("riegl_lms_q560")
platform_interp = helios.Platform.load_interpolate_platform(
    trajectory,
    "data/platforms.xml",
    "sr22",
    sync_gps_time=True,
    interpolation_method="ARINC 705",  # default interpolation_mode
    is_roll_pitch_yaw_in_radians=False,  # default is False
)
survey2 = helios.Survey(scene=scene, scanner=scanner2, platform=platform_interp)

We can now add the entire trajectory as a single leg to the survey. Alternatively, we can load only a subset of the trajectory, or split the trajectory into multiple legs by defining a start_time and end_time for each leg. We can also repeat the same trajectory in multiple legs.

[11]:
trajectory_settings1 = helios.TrajectorySettings(
    start_time=5, end_time=10, teleport_to_start=True
)

survey2.add_leg(scanner_settings=scan_settings)
survey2.add_leg(
    scanner_settings=scan_settings, trajectory_settings=trajectory_settings1
)

Running a survey

When running a survey, we can provide ExecutionSettings and OutputSettings to control the execution of the survey and the output format.

Execution settings

For example, we can explicitly change the number of threads, the logging verbosity or the parallelization strategy.

[12]:
execution_settings = helios.ExecutionSettings(
    num_threads=4,
    verbosity=helios.LogVerbosity.VERY_VERBOSE,
    parallelization=helios.ParallelizationStrategy.WAREHOUSE,
)
[13]:
pc, traj = survey.run(execution_settings=execution_settings)

Output settings

The output settings allow to change the format in which the output is returned (incl. requesting waveform and pulse output), the directory where the output is written (if written to file), and more. We show a few options below.

[14]:
# default
output_settings = helios.OutputSettings(format=helios.OutputFormat.NPY)
# return a laspy object
output_settings = helios.OutputSettings(format=helios.OutputFormat.LASPY)
# write to LAZ and change the las scale
output_settings = helios.OutputSettings(
    format=helios.OutputFormat.LAZ, las_scale=0.00025
)
# write to LAS and also write waveform and pulse output, use a custom output directory
output_settings = helios.OutputSettings(
    format=helios.OutputFormat.LAS,
    output_dir="detailed_output",
    write_waveform=True,
    write_pulse=True,
)
# write to XYZ and split by channel (relevant for multi-channel devices)
output_settings = helios.OutputSettings(
    format=helios.OutputFormat.XYZ, split_by_channel=True
)

Live view

When running a survey, we can also specify callbacks that are called during the survey execution. We use such callbacks for helios-live, which can be enables with live=True. This will show the virtual scene and the point collection in real time during the survey execution. The live view can be customized by providing a custom LiveViewer instance with specific settings.

pc, traj = survey.run(live=True, num_threads=1)

Output formats

Note that depending on the chosen output format, the survey.run() method has either one of two returns:

  • OutputFormat.NPY / OutputFormat.LASPY: two returns, a points array (or LASPY object) and a trajectory array

  • OutputFormat.LAS / OutputFormat.LAZ / OutputFormat.XYZ: one return, the path to the output folder

Below is a demonstration of the different output formats. Here you can also see that the individual parameters of ExecutionSettings and OutputSettings can be passed directly to the survey.run() method, without explicitly creating the settings objects.

[15]:
# output to arrays: returns two arrays
# numpy
pc, traj = survey.run(format=helios.OutputFormat.NPY)
# laspy
las, traj2 = survey.run(
    format=helios.OutputFormat.LASPY,
    parallelization=helios.ParallelizationStrategy.WAREHOUSE,
)

# output to file: returns one Path object (path to the output directory)
outpath_laz = survey.run(
    format=helios.OutputFormat.LAZ, verbosity=helios.LogVerbosity.VERY_VERBOSE
)

This is what the output looks like in each case:

  1. OutputFormat.NPY

[16]:
# all simulated points and their attributes
pc[:10]
[16]:
array([(0, 1, [-24.2681    ,  17.56074892,   9.88167972], [-4.49147234e-13,  1.74036743e-01, -9.84739159e-01], [-61.073084 , -27.807459 , 230.4609125], 244.55036441, 6.62253898, 0., 1, 1, 919110, 0, 248574.85774, 0),
       (0, 1, [-24.2678    ,  17.51525395,   8.65332633], [-4.46472962e-13,  1.73000509e-01, -9.84921735e-01], [-61.072784 , -27.807459 , 230.4609125], 245.75219034, 2.51876412, 0., 1, 1, 919111, 0, 248574.85775, 0),
       (0, 1, [-24.2675    ,  17.49789678,   7.2490756 ], [-4.43798194e-13,  1.71964083e-01, -9.85103220e-01], [-61.072484 , -27.807459 , 230.4609125], 247.1324014 , 1.38474619, 0., 1, 1, 919112, 0, 248574.85776, 0),
       (0, 1, [-24.2672    ,  17.49784541,   5.72807408], [-4.41122936e-13,  1.70927467e-01, -9.85283615e-01], [-61.072184 , -27.807459 , 230.4609125], 248.63087369, 1.37794712, 0., 1, 1, 919113, 0, 248574.85777, 0),
       (0, 1, [-24.2669    ,  17.51477964,   4.08998356], [-4.38447189e-13,  1.69890661e-01, -9.85462918e-01], [-61.071884 , -27.807459 , 230.4609125], 250.24789049, 1.36743566, 0., 1, 1, 919114, 0, 248574.85778, 0),
       (0, 1, [-24.2666    ,  17.5028899 ,   2.5999914 ], [-4.35770957e-13,  1.68853668e-01, -9.85641131e-01], [-61.071584 , -27.807459 , 230.4609125], 251.7143419 , 1.3572132 , 0., 1, 1, 919115, 0, 248574.85779, 0),
       (0, 1, [-24.2663    ,  17.48059594,   1.15272689], [-4.33094242e-13,  1.67816488e-01, -9.85818252e-01], [-61.071284 , -27.807459 , 230.4609125], 253.13720103, 1.34727307, 0., 1, 1, 919116, 0, 248574.8578 , 0),
       (0, 1, [-23.9555    ,  17.20066531,   5.91202615], [-4.38447189e-13,  1.69890661e-01, -9.85462918e-01], [-60.760484 , -27.807459 , 230.4609125], 248.39896999, 3.7578878 , 0., 1, 1, 920152, 0, 248574.86816, 0),
       (0, 1, [-23.9552    ,  17.21803296,   7.3410075 ], [-4.41122936e-13,  1.70927467e-01, -9.85283615e-01], [-60.760184 , -27.807459 , 230.4609125], 246.99384919, 3.81641334, 0., 1, 1, 920153, 0, 248574.86817, 0),
       (0, 1, [-23.9549    ,  17.20393523,   8.93304624], [-4.43798194e-13,  1.71964083e-01, -9.85103220e-01], [-60.759884 , -27.807459 , 230.4609125], 245.42296567, 3.84969181, 0., 1, 1, 920154, 0, 248574.86818, 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')])
[17]:
# only xyz position of simulated points
pc["position"][:10]
[17]:
array([[-24.2681    ,  17.56074892,   9.88167972],
       [-24.2678    ,  17.51525395,   8.65332633],
       [-24.2675    ,  17.49789678,   7.2490756 ],
       [-24.2672    ,  17.49784541,   5.72807408],
       [-24.2669    ,  17.51477964,   4.08998356],
       [-24.2666    ,  17.5028899 ,   2.5999914 ],
       [-24.2663    ,  17.48059594,   1.15272689],
       [-23.9555    ,  17.20066531,   5.91202615],
       [-23.9552    ,  17.21803296,   7.3410075 ],
       [-23.9549    ,  17.20393523,   8.93304624]])
[18]:
# trajectory
traj[:10]
[18]:
array([(248574.  , [-50.0003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.01, [-49.7003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.02, [-49.4003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.03, [-49.1003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.04, [-48.8003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.05, [-48.5003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.06, [-48.2003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.07, [-47.9003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.08, [-47.6003, -25.    , 250.    ], -0., 0., 4.71238898),
       (248574.09, [-47.3003, -25.    , 250.    ], -0., 0., 4.71238898)],
      dtype=[('gps_time', '<f8'), ('position', '<f8', (3,)), ('roll', '<f8'), ('pitch', '<f8'), ('yaw', '<f8')])
  1. OutputFormat.LASPY

[19]:
print(las)
print(las.x)
print(las.gps_time)
<LasData(1.4, point fmt: <PointFormat(6, 12 bytes of extra dims)>, 43847 points, 1 vlrs)>
<ScaledArrayView([-24.2  -24.2  -24.2  ...   1.85   1.85   1.85])>
[248574.86001 248574.86002 248574.86003 ... 248580.60511 248580.60512
 248580.60513]
[20]:
# the trajectory output is the same in both NPY and LASPY cases
traj2[:10]
[20]:
array([(248574.  , [-50.0003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.01, [-49.7003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.02, [-49.4003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.03, [-49.1003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.04, [-48.8003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.05, [-48.5003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.06, [-48.2003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.07, [-47.9003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.08, [-47.6003, -25.    , 250.    ], 0., 0., 4.71238898),
       (248574.09, [-47.3003, -25.    , 250.    ], 0., 0., 4.71238898)],
      dtype=[('gps_time', '<f8'), ('position', '<f8', (3,)), ('roll', '<f8'), ('pitch', '<f8'), ('yaw', '<f8')])
  1. OutputFormat.LAZ (output to file)

[21]:
outpath_laz
[21]:
WindowsPath('d:/Software/_helios_versions/helios/doc/output/2026-06-02_23-03-09')

Running multiple surveys

In practice, we often need to run multiple surveys to create different dataset variations, e.g., using different scanner settings, trajectories, or scenes. Rather than loading these components again for each survey, we can reuse them. Currently, when reusing the same scanner, platform, or scene objects across multiple surveys, they need to be deep-copied as demonstrated below to avoid a ValueError. The reason is that with the current design of helios, reusing them without copying would couple their internal states in unforeseen and unintended ways.

Example 1: Modifications of the scene

[22]:
scanner = helios.scanner_from_name("riegl_vz_600i")
platform = helios.platform_from_name("tripod")
plane = helios.ScenePart.from_obj("../data/sceneparts/basic/plane/plane.obj").rotate(
    axis=(1, 0, 0), angle=90 * helios.units.deg
)
x, y, z = [0, -10, 0]
scanner_settings = helios.ScannerSettings(
    pulse_frequency=1_200_000 * helios.units.Hz,
    horizontal_resolution=0.03 * helios.units.deg,
    vertical_resolution=0.03 * helios.units.deg,
    rotation_start_angle=-20 * helios.units.deg,
    rotation_stop_angle=20 * helios.units.deg,
    trajectory_time_interval=0.01 * helios.units.s,
)

results = {}
for angle in range(0, 90, 15):
    print(f"Running survey with plane rotated by {angle} degrees around the z-axis...")
    survey = helios.Survey(
        scene=helios.StaticScene(
            [
                copy.deepcopy(plane).rotate(
                    axis=(0, 0, 1), angle=angle * helios.units.deg
                )
            ]
        ),
        scanner=copy.deepcopy(scanner),
        platform=copy.deepcopy(platform),
    )
    survey.add_leg(x=x, y=y, z=z, scanner_settings=scanner_settings)
    pc, _ = survey.run(format=helios.OutputFormat.NPY)
    results[angle] = pc["position"]
Running survey with plane rotated by 0 degrees around the z-axis...
Running survey with plane rotated by 15 degrees around the z-axis...
Running survey with plane rotated by 30 degrees around the z-axis...
Running survey with plane rotated by 45 degrees around the z-axis...
Running survey with plane rotated by 60 degrees around the z-axis...
Running survey with plane rotated by 75 degrees around the z-axis...
[23]:
# Display the results with matplotlib
import matplotlib.pyplot as plt

# 3d figure
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection="3d")
for i, (angle, pc) in enumerate(results.items()):
    ax.scatter(pc[:, 0], pc[:, 1], pc[:, 2], s=1, label=f"Angle: {angle}°")
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("Z")
plt.legend()
plt.show()
_images/survey_41_0.png

Example 2: Modifications of full waveform settings

To test different full waveform settings for the same scene, we use the utility function combine_parameters. To create the parameter combinations, we change the bin_size, win_size, and beam_sample_quality variables. The combine_parameters function is documented in detail in the Utility functions section, and more information on the full waveform is provided in the Full waveform and intensity modelling section.

[24]:
# full waveform parameters to be tested
bin_sizes = [0.25, 0.1, 0.05]  # discretization of the waveform in time (ns)
win_sizes = [2, 1, 0.5]  # pulse length (ns)
beam_sample_qualities = [
    3,
    5,
]  # number of concentric circles of subrays for discretization in space

scanner = helios.scanner_from_name("riegl_vux_1uav")
platform = helios.platform_from_name("copter_linearpath")
scanner_settings = helios.ScannerSettings(
    pulse_frequency=300_000 * helios.units.Hz,
    scan_frequency=60 * helios.units.Hz,
    trajectory_time_interval=0.01 * helios.units.s,
)

tree1 = helios.ScenePart.from_obj(
    "../data/sceneparts/arbaro/sassafras_low.obj", up_axis="y"
)
groundplane = helios.ScenePart.from_obj(
    "../data/sceneparts/basic/plane/plane.obj"
).scale(20)
scene = helios.StaticScene([groundplane, tree1])

return_metric_results = {}
# combine parameters for fwfsettings
for params in helios.combine_parameters(
    bin_size=bin_sizes, win_size=win_sizes, beam_sample_quality=beam_sample_qualities
):
    fwf_settings = helios.FullWaveformSettings(
        bin_size=params["bin_size"] * helios.units.ns,
        win_size=params["win_size"] * helios.units.ns,
        beam_sample_quality=params["beam_sample_quality"],
    )
    # make sure to use deepcopy on all the assets that are used in multiple runs
    survey = helios.Survey(
        scene=copy.deepcopy(scene),
        scanner=copy.deepcopy(scanner),
        platform=copy.deepcopy(platform),
        full_waveform_settings=fwf_settings,
    )
    survey.add_leg(x=-10, y=0, z=50, speed_m_s=6, scanner_settings=scanner_settings)
    survey.add_leg(
        x=20, y=0, z=50, speed_m_s=6, scanner_settings=scanner_settings, is_active=False
    )
    pc, traj = survey.run(format=helios.OutputFormat.NPY)

    # compute return metrics and store in dict. This will allow the comparison of the returns from different settings.
    num_points = pc.shape[0]
    num_first_returns = (pc["return_number"] == 1).sum()
    num_multiple_returns = (pc["return_number"] > 1).sum()
    num_intermediate_returns = (
        (pc["return_number"] > 1) & (pc["return_number"] < pc["number_of_returns"])
    ).sum()
    ratio_intermediate = num_intermediate_returns / num_points
    ratio_multiple = num_multiple_returns / num_points
    return_metric_results[
        params["bin_size"], params["win_size"], params["beam_sample_quality"]
    ] = {
        "num_points": num_points,
        "num_first_returns": num_first_returns,
        "num_multiple_returns": num_multiple_returns,
        "ratio_multiple": ratio_multiple,
        "num_intermediate_returns": num_intermediate_returns,
        "ratio_intermediate": ratio_intermediate,
    }

Now we create a table to compare the return metrics for the different settings.

[25]:
return_df = pd.DataFrame.from_dict(
    return_metric_results,
    orient="index",
    columns=[
        "num_points",
        "num_first_returns",
        "num_multiple_returns",
        "ratio_multiple",
        "num_intermediate_returns",
        "ratio_intermediate",
    ],
)
return_df.index.names = ["bin_size", "win_size", "beam_sample_quality"]
# formatting
for k in return_df.keys():
    if "ratio" in k:
        return_df[k] = return_df[k].map(lambda x: f"{x:.3%}")
    elif "number" in k:
        return_df[k] = return_df[k].map(lambda x: f"{int(x):,}")

display(return_df)
num_points num_first_returns num_multiple_returns ratio_multiple num_intermediate_returns ratio_intermediate
bin_size win_size beam_sample_quality
0.25 2.0 3 180758 180001 757 0.419% 8 0.004%
5 180975 180001 974 0.538% 22 0.012%
1.0 3 186568 180001 6567 3.520% 663 0.355%
5 187995 180001 7994 4.252% 983 0.523%
0.5 3 187299 180001 7298 3.896% 905 0.483%
5 188885 180001 8884 4.703% 1299 0.688%
0.10 2.0 3 180820 180001 819 0.453% 8 0.004%
5 181028 180001 1027 0.567% 20 0.011%
1.0 3 186686 180001 6685 3.581% 678 0.363%
5 188101 180001 8100 4.306% 988 0.525%
0.5 3 187371 180001 7370 3.933% 880 0.470%
5 188946 180001 8945 4.734% 1262 0.668%
0.05 2.0 3 180755 180001 754 0.417% 8 0.004%
5 180953 180001 952 0.526% 19 0.010%
1.0 3 186544 180001 6543 3.507% 655 0.351%
5 187939 180001 7938 4.224% 949 0.505%
0.5 3 187211 180001 7210 3.851% 816 0.436%
5 188746 180001 8745 4.633% 1180 0.625%

We can observe the following trends in the results:

  • We usually get more points and multiple returns with higher beam sample quality since sampling more subrays within the footprint increases the chances of registering multiple returns.

  • The number of multiple returns also increases with smaller window sizes. The window size is used in the maximum detection, so if the window size is smaller than the spacing between two returns, they can be registered as separate returns, leading to more multiple returns.

By comparing these numbers with a real-world reference, we may choose the full waveform settings that are most suitable for our simulation scenario.