DJI Zenmuse L2 (Risley Prism Scanner)
Notebook: Thomas Isensee & Hannah Weiser, 2026
NOTE: This notebook is a work in progress and is still missing functionality that has to be ported from main to alpha-dev. The respective notebook cells are thus disabled.
This demo notebook uses the DJI Zenmuse L2 laser scanner, which is based on a risley beam deflector with three prisms. It has two scan modes, one that results in a repetitive scan pattern and one that results in a non-repetitive scan pattern.
[1]:
import helios
import matplotlib.pyplot as plt
Scene
We use a simple scene, consisting of a cubix box (box100.obj) of 100 x 100 x 100 units, which is centered at the origin O(0,0,0). We like to use this scene for debugging purposes, because no shots get lost when scanning from within the box and the planar surfaces allow to assess the scan pattern.
[2]:
box = helios.ScenePart.from_obj("../data/sceneparts/basic/box/box100.obj")
scene = helios.StaticScene(scene_parts=[box])
Survey 1: Repetitive scan pattern
Here, we simulate a simple linear path survey with a single leg, which moves at 50 m/s for 5 m along the x-axis.
[3]:
scanner = helios.scanner_from_name("dji_zenmuse_l2_repetitive")
platform = helios.platform_from_name("sr22")
survey = helios.Survey(scanner=scanner, platform=platform, scene=scene)
survey.add_leg(x=0.0, y=-15.0, z=0.0, speed_m_s=50)
survey.add_leg(x=5.0, y=-15.0, z=0.0, speed_m_s=50)
We use the DJI Zenmuse L2, defined in python/helios/data/scanners_als.xml, with the repetitive scan pattern (dji-zenmuse-l2-repetitive). For this deflector type, the scan pattern is controlled by the rotation speeds (rotorFreq1_Hz and rotorFreq2_Hz, rotorFreq3_Hz) of three rotating risley prisms. This design is described in detail in Qin et al. (2024).
Executing the survey
[4]:
points, trajectories = survey.run(
verbosity=helios.LogVerbosity.VERBOSE, format=helios.OutputFormat.NPY
)
Scene::doForceGround could not compute nothing because there was no ground scene part available
CRS bounding box (by vertices): Min: dvec3(-50.000000, -50.000000, -50.000000), Max: dvec3(50.000000, 50.000000, 50.000000)
Shift: dvec3(0.000000, 0.000000, 0.000000)
# vertices to translate: 36
Actual bounding box (by vertices): Min: dvec3(-50.000000, -50.000000, -50.000000), Max: dvec3(50.000000, 50.000000, 50.000000)
Building KD-Grove...
KDTree (num. primitives 12) :
Max. # primitives in leaf: 6
Min. # primitives in leaf: 4
Max. depth reached: 13
KDTree axis-aligned surface area: 60000
Interior nodes: 76
Leaf nodes: 77
Total tree cost: 9.03226
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.0010, 0.0010, 0.0010, 0.0010, 0.0000)
Tree primitives: (12, 12, 12, 12.0000, 0.0000)
Max primitives in leaf: (6, 6, 6, 6.0000, 0.0000)
Min primitives in leaf: (4, 4, 4, 4.0000, 0.0000)
Maximum depth: (13, 13, 13, 13.0000, 0.0000)
Axis-aligned surface area: (60000.0000, 60000.0000, 60000.0000, 60000.0000, 0.0000)
Number of interior nodes: (76, 76, 76, 76.0000, 0.0000)
Number of leaf nodes: (77, 77, 77, 77.0000, 0.0000)
Tree cost: (9.0323, 9.0323, 9.0323, 9.0323, 0.0000)
KDG built in 0.001s
Reading Spectral Library...
10 materials found
Warning: material Material of primitive 8Triangle (/home/runner/work/helios/helios/example_notebooks/../data/sceneparts/basic/box/box100.mtl) has no spectral definition
Number of subsampling rays (0): 19
Simulation: Scanner changed!
WARNING: Specified pulse frequency is not supported by this device. We'll set it nevertheless.
Pulse frequency set to 300000
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
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
SOURCE Leg with serial ID:0 waypoints:
Origin: (0, -15, 0)
Target: (5, -15, 0)
Next: (5, -15, 0)
Starting simulation loop 1 ...
Waypoint reached!
WARNING: Specified pulse frequency is not supported by this device. We'll set it nevertheless.
Pulse frequency set to 300000
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Scan angle set to 0
Waypoint reached!
Finishing simulation loop 1 ...
Finished simulation loop 1.
Elapsed simulation steps = 30002
Elapsed virtual time = 0.100007 sec.
Main thread simulation loop finished in 0.837164 sec.
Waiting for completion of pulse computation tasks...
Pulse computation tasks finished in 0.837175 sec.
Visualizing the results
Now we can display the point cloud.
[5]:
fig, ax = plt.subplots(1, 1, figsize=(6, 14))
pos = points["position"]
ax.scatter(pos[:, 0], pos[:, 1], s=0.1, c=points["gps_time"])
ax.set_ylabel("Y")
ax.set_xlabel("X")
ax.set_title("Repetitive pattern for the DJI Zenmuse L2 scanner")
ax.set_aspect("equal", "box")
plt.show()
Survey 2: Non-repetitive scan pattern
The same scanner can be used in a second mode, which results in a non-repetitive scan pattern. The difference in the pattern is the result of different rotor frequencies for the three prisms.
[6]:
scanner = helios.scanner_from_name("dji_zenmuse_l2_non_repetitive")
platform = helios.platform_from_name("tripod")
survey = helios.Survey(scanner=scanner, platform=platform, scene=scene)
xmlDocFilename: scanners_als.xml
xmlDocFilePath: /home/runner/work/helios/helios/python/helios/data
No scanner orientation defined.
EXCEPTION: No node with attribute [xyz]
Using default value for attribute 'averagePower_w' : 4
Using default value for attribute 'beamQualityFactor' : 1
Using default value for attribute 'opticalEfficiency' : 0.99
Using default value for attribute 'receiverDiameter_m' : 0.15
Using default value for attribute 'atmosphericVisibility_km' : 23
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Using default value for attribute 'binSize_ns' : 0.25
Using default value for attribute 'winSize_ns' : 1
Using default value for attribute 'maxFullwaveRange_ns' : 0
Using default value for attribute 'apertureDiameter_m' : 0.15
XML Assets Loader: Failed to read child element <headRotateAxis> of <scanner> element at line 508. Using default.
EXCEPTION: No node with attribute [xyz]
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanAngleMax_deg' : 0
Using default value for attribute 'maxNOR' : 0
Number of subsampling rays (0): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Number of subsampling rays (1): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Number of subsampling rays (2): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Number of subsampling rays (3): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Number of subsampling rays (4): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
Number of subsampling rays (5): 19
Using default value for attribute 'scanFreqMin_Hz' : 0
Using default value for attribute 'scanFreqMax_Hz' : 0
Using default value for attribute 'accuracy_m' : 0.02
Using default value for attribute 'rangeMin_m' : 2
Using default value for attribute 'rangeMax_m' : 1.79769e+308
Using default value for attribute 'headRotatePerSecMax_deg' : 0
Using default value for attribute 'beamDivergence_rad' : 0.0027
Using default value for attribute 'pulseLength_ns' : 4
Using default value for attribute 'maxNOR' : 0
Using default value for attribute 'receivedEnergyMin_W' : 0.0001
xmlDocFilename: platforms.xml
xmlDocFilePath: /home/runner/work/helios/helios/python/helios/data
No platform type specified. Using static platform.
For the non-repetitive scan pattern, we will use a static survey and different integration times to demonstrate the characteristics of the pattern, similar to the 9-tls_livox_demo example.
[7]:
# TODO: Add this example once max_duration has been merged from main
There are three legs corresponding to three scan positions (SPs) at the origin O(0, 0, 0). Using the max_duration parameter, we can control how long the integration time (scanning time) is at each SP. With increasing integration time, we get a higher point density and the density is highest in the center of the retina-like scan pattern.
Executing the survey
points, trajectories = survey.run(verbosity=helios.LogVerbosity.VERBOSE, format=helios.OutputFormat.NPY)Visualizing the results
Now we can display the point cloud.
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 8)) points_1 = points[points["point_source_id"] == 0] points_2 = points[points["point_source_id"] == 1] points_3 = points[points["point_source_id"] == 2] sp_1 = points_1["position"] sp_2 = points_2["position"] sp_3 = points_3["position"] ax1.scatter(sp_1[:, 0], sp_1[:, 2], s=0.1, c=points_1["gps_time"]) ax1.set_xlabel("X") ax1.set_ylabel("Z") ax1.set_title("Integration time: 0.2 s") ax1.set_aspect("equal", "box") ax2.scatter(sp_2[:, 0], sp_2[:, 2], s=0.1, c=points_2["gps_time"]) ax2.set_xlabel("X") ax2.set_ylabel("Z") ax2.set_title("Integration time: 1 s") ax2.set_aspect("equal", "box") ax3.scatter(sp_3[:, 0], sp_3[:, 2], s=0.1, c=points_3["gps_time"]) ax3.set_xlabel("X") ax3.set_ylabel("Z") ax3.set_title("Integration time: 2 s") ax3.set_aspect("equal", "box") plt.show()