Multi-Scanner (Velodyne® VLP-16 / “Puck”)

Notebook: Lukas Winiwarter, 2022 & Hannah Weiser, 2026

In this demo, we demonstrate how a scanner with multiple scanlines, such as the Velodyne® VLP-16, can be configured in HELIOS++. The demo contains two surveys, one for a static simulation (scanner at a single location) and one for a dynamic simulation (scanner moving between waypoints).

[1]:
from IPython.display import Code
from pathlib import Path
import helios
from helios.utils import display_xml
import matplotlib.pyplot as plt

Scanner

As the scanner is the main component in this demo, we investigate the scanner XML file tls_scanners.xml. Here, we find a predefined Velodyne VLP-16. Other multi-channel/multi-beam scanners, such as the Velodyne Puck LITE, or the Ouster OS2 can be implemented in similar ways.

[2]:
scanner = helios.scanner_from_name("vlp16")
[3]:
Code(
    display_xml(
        Path(helios.utils.get_asset_directories()[-1]) / "scanners_tls.xml", "vlp16"
    )
)
[3]:
<scanner id="vlp16" accuracy_m="0.03" beamDivergence_rad="0.0007" name="Velodyne VLP-16" optics="rotating" pulseFreqs_Hz="18750" pulseLength_ns="5" rangeMin_m="0.09144" rangeMax_m="100" scanAngleMax_deg="1" scanAngleEffectiveMax_deg="1" scanFreqMin_Hz="0" scanFreqMax_Hz="0" wavelength_nm="905" maxNOR="2" headRotatePerSecMax_deg="7200">

    <headRotateAxis x="0" y="0" z="1" />
    <FWFSettings beamSampleQuality="3" />  <!-- set to one for fast simulations -->
    <channels>
      <channel id="0">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-15" />
        </beamOrigin>
      </channel>
      <channel id="1">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="1" />
        </beamOrigin>
      </channel>
      <channel id="2">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-13" />
        </beamOrigin>
      </channel>
      <channel id="3">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="3" />
        </beamOrigin>
      </channel>
      <channel id="4">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-11" />
        </beamOrigin>
      </channel>
      <channel id="5">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="5" />
        </beamOrigin>
      </channel>
      <channel id="6">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-9" />
        </beamOrigin>
      </channel>
      <channel id="7">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="7" />
        </beamOrigin>
      </channel>
      <channel id="8">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-7" />
        </beamOrigin>
      </channel>
      <channel id="9">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="9" />
        </beamOrigin>
      </channel>
      <channel id="10">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-5" />
        </beamOrigin>
      </channel>
      <channel id="11">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="11" />
        </beamOrigin>
      </channel>
      <channel id="12">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-3" />
        </beamOrigin>
      </channel>
      <channel id="13">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="13" />
        </beamOrigin>
      </channel>
      <channel id="14">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="-1" />
        </beamOrigin>
      </channel>
      <channel id="15">
        <beamOrigin x="0" y="0" z="0">
          <rot axis="x" angle_deg="15" />
        </beamOrigin>
      </channel>
    </channels>

  </scanner>
  

The scanner is defined with rotating optics, but the values for scanFreqMin_Hz and scanFreqMax_Hz (both “0”) show that this feature is not used in this implementation. Additionaly, the scanAngleMax_deg is set to a value of 1. Subsequently, 16 channels are defined, each with their individual rotations. As the scan plane is not used (default would be the y/z-Plane, see the wiki) and only a single direction per pulse is given, a simple rotation upwards or downwards about the x axis is sufficient. The values for the angles are taken from the user manual of the VLP-16.

The pulseFreq_hz in the <scannerSettings > is coming from the max. measurement rate of the VLP-16 (300,000 pts/sec). The headRotatePerSecMax_deg value corresponds to what Velodyne refers to as scan frequency - this is not to be confused with the within-scan line scanFreq_hz we use for e.g. RIEGL-type TLS sensors. The user manual gives a value of 1200 rpm as a maximum rotation speed of the motor, equivalent to 20 rotations per seconds (20 Hz scan frequency), or a rotation speed headRotatePerSecMax_deg of 7200 (20 * 360°).

Scene

Let us now use a simple scene (just containing a cube with 100 m side length, centered around the origin) to carry out two surveys:

[4]:
box = helios.ScenePart.from_obj("../data/sceneparts/basic/box/box100.obj")
scene = helios.StaticScene([box])

Static survey

[5]:
survey = helios.Survey(
    scanner=scanner, platform=helios.platform_from_name("tripod"), scene=scene
)
survey.add_leg(
    x=0,
    y=0,
    z=0,
    pulse_frequency=18750 * helios.units.Hz,
    scan_frequency=0 * helios.units.Hz,
    head_rotation=3600 * helios.units.deg / helios.units.s,
    rotation_start_angle=0 * helios.units.deg,
    rotation_stop_angle=3600 * helios.units.deg,
)

Here, we note two things:

  1. the value of head_rotation, which here corresponds to 10 rotations per second, or 600 rotations per minute. The user manual explains that values between 300 and 1200 rpm, in increments of 60 rpm, are permissible.

  2. the range between rotation_start_angle and rotation_stop_angle. Combined with the rotation speed, this gives the sensor’s integration time. In this example, 10 full rotations (10 * 360°) are carried out, which takes (at a rotation speed of 10 rotations per second or 3600° per second) one second.

We are now ready to run the simulation.

[6]:
pc, traj = survey.run()

Let’s display a 3D plot for all points recorded in the first 1/10th second (one rotation). You can see how the ‘zero’ location of the scanner is towards the positive y axis.

[7]:
fig = plt.figure(figsize=(15, 10))
ax = fig.add_subplot(projection="3d")

# select all points recorded in the first 1/10 second
sel = pc["gps_time"] <= min(pc["gps_time"]) + 0.1
pc_filtered = pc["position"][sel]
gps_time_filtered = pc["gps_time"][sel]

ax.scatter(
    pc_filtered[:, 0],
    pc_filtered[:, 1],
    pc_filtered[:, 2],
    s=0.1,
    c=gps_time_filtered,
)
ax.scatter([0], [0], [0], s=50, c="red")
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("Z")

ax.tick_params(labelsize=16)
plt.show()
_images/12-multi_scanner_puck_13_0.png

Dynamic survey

We now want to simulate a moving scanner (e.g. a driving car). We use the same scene as before, but move the scanner from (-40, 0, 0) to (40, 0, 0) over a course of 10 seconds (8 m/s). Note how we have to adapt the rotation_stop_angle to 36000 to allow for 10 seconds of operation (at a rotation of 10 Hz) before the survey exits.

[8]:
scanner = helios.scanner_from_name("vlp16")
platform = helios.platform_from_name("simple_linearpath")
[9]:
survey = helios.Survey(scanner=scanner, platform=platform, scene=scene)
scanner_settings = helios.ScannerSettings(
    pulse_frequency=18750 * helios.units.Hz,
    scan_frequency=0 * helios.units.Hz,
    head_rotation=3600 * helios.units.deg / helios.units.s,
    rotation_start_angle=0 * helios.units.deg,
    rotation_stop_angle=36000 * helios.units.deg,
    trajectory_time_interval=0.01,
)
survey.add_leg(x=-40, y=0, z=0, speed_m_s=8, scanner_settings=scanner_settings)
survey.add_leg(x=40, y=0, z=0, speed_m_s=8, scanner_settings=scanner_settings)
[10]:
pc, traj = survey.run()

Now let’s find the output files and display a 3D plot for all points recorded in the first 1/10th second (one rotation). In a second plot, we plot the points recorded in the last 1/10th second (last rotation).

[11]:
for fun in (min, max):

    fig = plt.figure(figsize=(15, 10))
    ax = fig.add_subplot(projection="3d")

    # select all points recorded in the first/last 1/10 second
    sel_idx = abs(pc["gps_time"] - fun(pc["gps_time"])) <= 0.1

    pc_filtered = pc["position"][sel_idx]
    gps_time_filtered = pc["gps_time"][sel_idx]

    ax.scatter(
        pc_filtered[:, 0],
        pc_filtered[:, 1],
        pc_filtered[:, 2],
        s=0.1,
        c=gps_time_filtered,
    )
    ax.scatter([-40 if fun is min else 40], [0], [0], s=50, c="red")

    ax.tick_params(labelsize=16)
    plt.title(
        "Points recorded in the "
        + ("first " if fun is min else "last ")
        + "full rotation"
    )

    ax.set_xlabel("X")
    ax.set_ylabel("Y")
    ax.set_zlabel("Z")
    plt.show()
_images/12-multi_scanner_puck_19_0.png
_images/12-multi_scanner_puck_19_1.png

In contrast to the static case, the ‘zero’ point now appears towards the positive x-Axis! What happened? The linearpath platform oriented the sensor such that the default axis (which is y for the scanners) is pointing forward in the direction of movement. As the platform (simple_linearpath) does not provide any additional rotations (e.g. in contrast to the sr22 airborne platform), the scanner faces towards the direction of movement at the beginning of the scan.

The gaps in the ‘far’ corners are due to the maximum range of the scanner, given as 100 m.