MLS wheat field

Notebook creator: Hannah Weiser, 2026

This demo uses a wheat field, built from 3D models of several wheat ears, which will be scanned by mobile laser scanning (MLS).

[1]:
import helios
import numpy as np

Creating the virtual scene

[2]:
# load objs and create transformations
groundplane = helios.ScenePart.from_obj(
    "../data/sceneparts/basic/groundplane/groundplane.obj"
).scale(
    100
)  # scale groundplane by 100
# positions for 15 wheat plants
wheat_positions = [
    [1.4, 2.8, 0.0],
    [1.4, 1.4, 0.0],
    [1.4, 0.0, 0.0],
    [1.4, -1.4, 0.0],
    [1.4, -2.8, 0.0],
    [0.0, 2.8, 0.0],
    [0.0, 1.4, 0.0],
    [0.0, 0.0, 0.0],
    [0.0, -1.4, 0.0],
    [0.0, -2.8, 0.0],
    [-1.4, 2.8, 0.0],
    [-1.4, 1.4, 0.0],
    [-1.4, 0.0, 0.0],
    [-1.4, -1.4, 0.0],
    [-1.4, -2.8, 0.0],
]
sceneparts = [groundplane]
for pos in wheat_positions:
    w = (
        helios.ScenePart.from_obj("../data/sceneparts/arbaro/wheat.obj", up_axis="y")
        .scale(2)
        .translate(pos)
    )
    sceneparts.append(w)
# create scene
scene = helios.StaticScene(scene_parts=sceneparts)

Platform and Scanner

We are using a “RIEGL VZ-400” mounted on a “tractor”. The tractor is a groundvehicle type platform (see platforms.xml), a mobile platform which moves on the ground between the consecutive legs with a constant speed provided by the user. The scannerMount parameter defines the exact position of the scanner and the angle of rotation around the Z- and Y-axis. For the tractor, the scanner is rotated 90° around the Z-axis and -30° around the Y-axis, so that the scene in the center can be capured the best way possible. The ground vehicle tries to mimic real vehicles. It performs “smooth turns” for wide-angle curves. For narrow-angle curves, it first turns, then backs up, then finishes the turn. This can also be seen in the plot later.

[3]:
scanner = helios.scanner_from_name("riegl_vz_400")
platform = helios.platform_from_name("tractor")

Scanner Settings

[4]:
# these scanner settings will be shared between all legs
scanner_settings = helios.ScannerSettings(
    pulse_frequency=100_000 * helios.units.Hz,
    scan_frequency=50 * helios.units.Hz,
    scan_angle=20 * helios.units.deg,
    head_rotation="0 deg/s",
    trajectory_time_interval=0.05 * helios.units.s,
)

Survey Route

[5]:
survey = helios.Survey(scanner=scanner, platform=platform, scene=scene)
[6]:
waypoints = [[-10, -10, 0], [10, -10, 0], [10, 10, 0], [-10, 10, 0], [-10, -10, 0]]
for x, y, z in waypoints:
    survey.add_leg(x=x, y=y, z=z, speed_m_s=3, scanner_settings=scanner_settings)

Running the survey

[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.001500), Max: dvec3(100.000000, 100.000000, 1.187800)
Shift: dvec3(0.000000, 0.000000, 0.593150)
# vertices to translate: 3402321
Actual bounding box (by vertices): Min: dvec3(-100.000000, -100.000000, -0.594650), Max: dvec3(100.000000, 100.000000, 0.594650)
Building KD-Grove...
KDTree (num. primitives 1134107) :
        Max. # primitives in leaf: 248
        Min. # primitives in leaf: 1
        Max. depth reached: 41
        KDTree axis-aligned surface area: 80951.4
        Interior nodes: 975880
        Leaf nodes: 747589
        Total tree cost: 6.13429
KDGrove stats:
        Number of trees: 1
        Number of static trees: 1
        Number of dynamic trees: 0
        Statistics (min, max, total, mean, stdev):
                Building time: (1.8560, 1.8560, 1.8560, 1.8560, 0.0000)
                Tree primitives: (1134107, 1134107, 1134107, 1134107.0000, 0.0000)
                Max primitives in leaf: (248, 248, 248, 248.0000, 0.0000)
                Min primitives in leaf: (1, 1, 1, 1.0000, 0.0000)
                Maximum depth: (41, 41, 41, 41.0000, 0.0000)
                Axis-aligned surface area: (80951.4400, 80951.4400, 80951.4400, 80951.4400, 0.0000)
                Number of interior nodes: (975880, 975880, 975880, 975880.0000, 0.0000)
                Number of leaf nodes: (747589, 747589, 747589, 747589.0000, 0.0000)
                Tree cost: (6.1343, 6.1343, 6.1343, 6.1343, 0.0000)

KDG built in 1.857s
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
Number of subsampling rays (riegl_vz400): 19
Simulation: Scanner changed!
Pulse frequency set to 100000
Scan angle set to 20
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max nan/nan degrees
 -- verticalAngleMin not set, using the value of -20 degrees
 -- verticalAngleMax not set, using the value of 20 degrees
SOURCE Leg with serial ID:0 waypoints:
        Origin: (-10, -10, -0.59315)
        Target: (10, -10, -0.59315)
        Next: (10, 10, -0.59315)

It was not possible to determine attitude with a single computation at MovingPlatform::initLegManual
        angle = 3.14159 but it should be below 0.025
        Using iterative computation instead
Iterative mode was used for manual leg initialization because default one failed for MovingPlatform
Starting simulation loop 1 ...
Waypoint reached!
Pulse frequency set to 100000
Scan angle set to 20
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max nan/nan degrees
 -- verticalAngleMin not set, using the value of -20 degrees
 -- verticalAngleMax not set, using the value of 20 degrees
SOURCE Leg with serial ID:1 waypoints:
        Origin: (10, -10, -0.59315)
        Target: (10, 10, -0.59315)
        Next: (-10, 10, -0.59315)

Turn mode 1
Turn mode 2
Waypoint reached!
Pulse frequency set to 100000
Scan angle set to 20
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max nan/nan degrees
 -- verticalAngleMin not set, using the value of -20 degrees
 -- verticalAngleMax not set, using the value of 20 degrees
SOURCE Leg with serial ID:2 waypoints:
        Origin: (10, 10, -0.59315)
        Target: (-10, 10, -0.59315)
        Next: (-10, -10, -0.59315)

Turn mode 1
Turn mode 2
Waypoint reached!
Pulse frequency set to 100000
Scan angle set to 20
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max nan/nan degrees
 -- verticalAngleMin not set, using the value of -20 degrees
 -- verticalAngleMax not set, using the value of 20 degrees
SOURCE Leg with serial ID:3 waypoints:
        Origin: (-10, 10, -0.59315)
        Target: (-10, -10, -0.59315)
        Next: (-10, -10, -0.59315)

Turn mode 1
Turn mode 2
Waypoint reached!
Pulse frequency set to 100000
Scan angle set to 20
Applying settings for PolygonMirrorBeamDeflector...
Vertical angle min/max nan/nan degrees
 -- verticalAngleMin not set, using the value of -20 degrees
 -- verticalAngleMax not set, using the value of 20 degrees
Waypoint reached!
Finishing simulation loop 1 ...
Finished simulation loop 1.
Elapsed simulation steps = 3404024
Elapsed virtual time = 34.0402 sec.
Main thread simulation loop finished in 2.67603 sec.
Waiting for completion of pulse computation tasks...
Pulse computation tasks finished in 2.67604 sec.

Visualizing the results

[8]:
import matplotlib.pyplot as plt
[9]:
def extract_by_bb(arr, b_box):
    assert len(b_box) == 6
    x_min, y_min, z_min, x_max, y_max, z_max = b_box
    pos = arr["position"]
    subset = arr[
        (pos[:, 0] > x_min)
        & (pos[:, 0] < x_max)
        & (pos[:, 1] > y_min)
        & (pos[:, 1] < y_max)
        & (pos[:, 2] > z_min)
        & (pos[:, 2] < z_max)
    ]

    return subset
[10]:
# create scene subset
bbox = [-5, -5, 0, 5, 5, 1.5]

points_sub = extract_by_bb(points, bbox)
[11]:
fig = plt.figure(figsize=(12, 8))
# 3d plot
ax = fig.add_subplot(projection="3d")

# scatter plot of points
pos = points_sub["position"]
sc = ax.scatter(
    pos[:, 0],
    pos[:, 1],
    pos[:, 2],
    c=pos[:, 2],
    cmap="RdYlBu_r",
    s=0.02,
    label="scene",
)

traj = trajectories["position"]
traj_time = trajectories["gps_time"]
# scatter plot of the trajectory
ax.plot(traj[:, 0], traj[:, 1], traj[:, 2], c="black", label="scanner trajectory")

cax = plt.axes([0.85, 0.2, 0.025, 0.55])
cbar = plt.colorbar(sc, cax=cax)

cbar.set_label("Height ($Z$)")

# Add axis labels.
ax.set_xlabel("$X$")
ax.set_ylabel("$Y$")
ax.set_zlabel("$Z$")

# set equal axes
box = (bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2])
ax.set_box_aspect(box)

# Set title.
ax.set_title(label="Point cloud and trajectory of scanner", fontsize=15)

# Display results
plt.show()
_images/02-mls_wheat_field_17_0.png
[ ]: