25 - LiDAR point clouds and waveforms¶
This chapter simulates airborne (UAV) and terrestrial LiDAR and produces discrete point clouds and full waveforms.
What is simulated¶
- The emission position and direction of every laser pulse, from the scanner parameters and the platform trajectory;
- intersections with triangle meshes and the path of the beam through statistical vegetation;
- pulse width, beam divergence, volume scattering, attenuation along the path and multiple returns;
- the point cloud as output, and optionally the full waveform of every pulse.
LiDAR uses the geometry and optical properties already in the scene. Returns from a statistical crown come from the backscatter of leaves distributed continuously along the beam path. In a monostatic LiDAR the emitted and received paths coincide, and the return calculation takes this into account, so no extra hotspot parameter is needed. Triangle meshes and statistical vegetation can be in the same scene.
Parts of a LiDAR survey¶
A LiDAR survey consists of a scanner, a platform and flight lines (or scan positions):
import less
scanner = less.Scanner(
beam_divergence=0.5, # beam divergence (mrad)
pulse_rate=20_000, # pulse rate (Hz)
scan_freq=80.0, # scan frequency (Hz)
scan_angle=27.0, # half scan angle (degrees)
wavelengths=[1064], # laser wavelength (nm)
beam_samples=4, # samples per beam, representing the footprint size
)
| Parameter | Meaning | Typical airborne | Typical terrestrial |
|---|---|---|---|
beam_divergence |
Beam divergence (mrad) | 0.3–0.5 | 0.1–0.3 |
pulse_rate |
Pulse rate (Hz) | 10⁵–4×10⁵ | — |
scan_freq |
Scan frequency (Hz) | 50–200 | — |
scan_angle |
Half scan angle (degrees) | 15–30 | — |
wavelengths |
Wavelengths (nm) | 1064 | 1550 |
beam_samples |
Samples per beam | 4–16 | 1–4 |
angular_resolution |
Angular resolution of terrestrial scans (degrees) | — | 0.05–0.2 |
max_returns |
Maximum number of returns per pulse | — | — |
Platforms:
uav = less.UAV(altitude=50.0, speed=5.0)
aircraft = less.Aircraft(altitude=1000.0, speed=60.0)
tripod = less.Tripod(height=1.5)
vehicle = less.Vehicle(height=2.5, speed=5.0)
Airborne surveys use waypoints to define flight lines; terrestrial surveys use scan positions to define the scan range:
flight_line = [less.Waypoint(-10, 25, active=True), less.Waypoint(60, 25, active=True)]
station = [less.ScanPosition(x=25, y=25, azimuth_range=(0, 360), zenith_range=(5, 80))]
UAV LiDAR¶
scene = less.Scene()
scene.size = 50.0
scene.terrain = less.Terrain(property=less.Lambertian(reflectance=0.15))
scene.illumination = less.Illumination(
source=less.Sun(zenith=30, azimuth=150), atmosphere=less.NoAtmosphere())
tree = less.Object("ash", mesh=less.asset_path("FREX.obj"))
tree.set_property("leaves", less.Prospect(cab=40, car=10, cw=0.012, cm=0.008, N=1.5))
tree.set_property("stem_branch", less.Lambertian(reflectance=0.08))
positions = less.place.random(scene, n=60, min_dist=3.0, seed=42)
n = len(positions)
scene.add(tree, positions=positions,
scales=less.place.uniform_scale(n, 0.6, 1.5, seed=42),
rotations=less.place.random_rotation(n, seed=42))
als = less.LiDARSurvey(
scanner=scanner,
platform=uav,
legs=flight_line,
range_resolution=0.15, # range resolution (m)
name="UAV LiDAR",
)
cloud = scene.simulate(als)
cloud.save("uav_pointcloud.ply")
print(cloud.num_points, "points")
print("height range:", cloud.points[:, 2].min(), "to", cloud.points[:, 2].max(), "m")
Terrestrial LiDAR¶
Terrestrial LiDAR scans a hemisphere from a fixed position, with very high point density close by:
tls = less.LiDARSurvey(
scanner=less.Scanner(beam_divergence=0.2, wavelengths=[1550], beam_samples=1,
angular_resolution=0.5), # 0.5° angular resolution
platform=tripod,
legs=station,
range_resolution=0.02,
name="TLS",
)
tls_cloud = scene.simulate(tls)
tls_cloud.save("tls_pointcloud.ply")
| Airborne | Terrestrial | |
|---|---|---|
| View | From above | From below and sideways |
| Coverage | Large areas along flight lines | Around the scan position |
| Point density | 1–20 points per m² | Over 100 points per m² |
| Strengths | Canopy height, mapping large areas | Stems, inner canopy structure |
Point cloud contents¶
print(cloud.points.shape) # (points, 3) coordinates
print(cloud.intensity.shape) # intensity
print(cloud.return_number[:10]) # return number
print(cloud.num_returns[:10]) # number of returns of the pulse
Discrete returns are extracted from each pulse's waveform: the strongest peaks are kept (at most Scanner.max_returns) and numbered from near to far; intensity is summed over the wavelength channels. cloud.return_timeline gives the time span of pulse emission and return arrival.
Full waveforms¶
waveform_product = scene.simulate(als, product="waveform")
waveform = waveform_product.waveform["data"]
print(waveform.shape) # (pulses, range bins, wavelengths)
print(waveform_product.waveform["min_range"]) # start of the first range bin (m)
print(waveform_product.waveform["range_resolution"]) # length of a range bin (m)
Point clouds and waveforms come from the same propagation, so canopy, understorey and ground returns in the point cloud agree with the waveform.
Several flight lines¶
strips = []
for y in (15, 25, 35):
strips += [less.Waypoint(-10, y, active=True), less.Waypoint(60, y, active=True)]
multi_strip = less.LiDARSurvey(
scanner=less.Scanner(beam_divergence=0.5, pulse_rate=20_000, scan_freq=80.0,
scan_angle=20.0, wavelengths=[1064], beam_samples=4),
platform=uav, legs=strips, range_resolution=0.15, name="Multi-strip")
Common applications¶
| Application | Data | Method |
|---|---|---|
| Canopy height model | Airborne point cloud | Highest point minus ground elevation |
| Leaf area index | Airborne point cloud | Gap fraction |
| Individual tree segmentation | Airborne or terrestrial | Watershed, deep learning |
| Diameter at breast height | Terrestrial | Circle fitting |
Reconstructing scenes from point clouds is covered in chapter 17.
Companion script: scripts/25_lidar.py
See A5 - LiDAR ranging and waveforms for independent zero-divergence and finite-beam validation, with rotatable point clouds, waveform comparisons and sampling-convergence results.