LiDAR Point Clouds Explained: Returns, Classes and Intensity

On this page

Problem statement

A lidar point cloud is not a 3D picture. It is a table of pulse measurements, and almost every question people ask of it β€” how tall is that tree, where is the ground, how dense is the survey β€” depends on columns other than X, Y and Z.

Loaded from a real 3DEP survey over Brooks Camp, Alaska:

12,968,770 points over 841 x 841 m
18.3 points per square metre
59.5% first returns, 36.1% second, 4.4% third, 0.1% fourth
76.5% of points came from a pulse that produced more than one return
22.9% classified as ground; 77.1% left unclassified

That last line is the one that ruins most first attempts. This survey has no vegetation classes at all β€” only ground and unclassified. Any script assuming class 5 means "high vegetation" produces an empty array here.

Quick answer

import laspy
import numpy as np

las = laspy.read("tile.laz")
print(f"{len(las.points):,} points, LAS {las.header.version}, "
      f"point format {las.header.point_format.id}")
print("dimensions:", [d.name for d in las.point_format.dimensions])

classes, counts = np.unique(las.classification, return_counts=True)
for code, count in zip(classes, counts):
    print(f"  class {code}: {count:,} ({count / len(las.points):.1%})")
12,968,770 points, LAS 1.2, point format 1
dimensions: ['X', 'Y', 'Z', 'intensity', 'return_number', 'number_of_returns', 'scan_direction_flag', 'edge_of_flight_line', 'classification', 'synthetic', 'key_point', 'withheld', 'scan_angle_rank', 'user_data', 'point_source_id', 'gps_time', 'OriginId']
  class 1: 10,003,338 (77.1%)
  class 2: 2,965,430 (22.9%)
  class 7: 2 (0.0%)

Always print the class histogram before writing any code that depends on a class.

One laser pulse producing several returns as it passes through canopy, with the first return at the treetop and the last on the ground. one pulse, several rows in the fileopen groundsingle = first = lastone returnthrough canopy1st 2nd 3rd(last)three returns76.5% of points came from a pulse that produced more than one return spatialworkflow.io
One pulse, several returns. Which return you keep decides whether you are mapping the canopy or the ground.

What a row is, returns, classifications

1. Understand what one row is

A row is one return: a detectable echo from one outgoing laser pulse. A pulse over bare ground gives one return. A pulse through a canopy can give several, as energy reflects off leaves, branches and finally the ground.

That is why return_number and number_of_returns are two separate columns. return_number = 2, number_of_returns = 3 means "the second of three echoes from this pulse".

Measured here, 76.5% of points came from multi-return pulses β€” an unusually high figure that says this is a well-vegetated landscape scanned by a sensor that resolves closely spaced echoes.

2. Use returns to reason about structure

  • First returns are the top of whatever the pulse hit β€” canopy, roof, ground in the open.
  • Last returns are the deepest thing the pulse reached. Over vegetation that is often, but not always, the ground.
  • Intermediate returns are canopy interior, which is what makes lidar useful for forest structure.
  • Single returns (number_of_returns == 1) came from a surface that stopped the pulse outright: bare ground, water, a roof.

Measured relationship between last returns and classified ground:

returns = np.asarray(las.return_number)
last = returns == np.asarray(las.number_of_returns)
ground = np.asarray(las.classification) == 2
print(f"ground points that are last returns:     {last[ground].mean():6.1%}")
print(f"last returns that are classified ground: {ground[last].mean():6.1%}")
ground points that are last returns:     100.0%
last returns that are classified ground:  38.4%

Every ground point is a last return, and only two in five last returns are ground. "Last return" is a necessary condition for ground, not a sufficient one β€” which is exactly why classification is a separate, harder step.

3. Check the classification before trusting it

The LAS standard defines class codes: 2 is ground, 3–5 low/medium/high vegetation, 6 building, 9 water, 7 and 18 noise.

Defining them does not mean a survey populated them. This one has 22.9% ground and 77.1% class 1, "unclassified" β€” meaning the vendor ran a ground filter and stopped.

if 5 not in set(np.unique(las.classification)):
    print("no vegetation class β€” derive height from returns, not from classes")
no vegetation class β€” derive height from returns, not from classes

4. Treat intensity as relative, not physical

intensity is the amplitude of the returned echo. It correlates with surface reflectance at the laser's wavelength, which makes it tempting to use like a band of imagery.

It is not calibrated. It varies with range, incidence angle, atmospheric conditions and the sensor's automatic gain β€” so intensity is not comparable between flight lines, let alone between surveys. Use it within one flight line, for relative contrast, and check point_source_id to see how many flight lines you are mixing.

5. Look at point_source_id and gps_time

point_source_id identifies the flight line. Where two lines overlap, density doubles and intensity jumps, which produces stripes in any density or intensity raster.

gps_time gives the acquisition time, which is how you separate passes and how you detect that a "single survey" was actually flown across three weeks with a metre of leaf-on change in between.

All ground points being last returns while only 38 percent of last returns are ground, shown as nested sets. last return is necessary for ground and nowhere near sufficientmeasuredwhyground β†’ last returnβœ“100.0%ground is opaque; nothing returnsbeyond itlast return β†’ groundβœ•38.4%roofs, branches and walls alsostop a pulseFiltering by last return gives a set two and a half times too large spatialworkflow.io
Last return is necessary for ground and nowhere near sufficient. That gap is what a ground classifier exists to close.

A survey report before any analysis

The data used on this page

The USGS 3DEP lidar survey of Brooks Camp in Katmai National Park, Alaska (2012), from the USGS Entwine Point Tile archive on AWS, AK_BrooksCamp_2012. EPT stores a survey as an octree of LAZ files; the setup downloads every node under node 4-3-13-7 β€” 287 files, 82.8 MB β€” and joins them into one file, tile.laz. The archive is in Web Mercator (EPSG:3857), so its "metres" are not metres at 58.6Β° N; see the coordinates guide.

import json
import os
import urllib.request

import laspy

EPT = "https://s3-us-west-2.amazonaws.com/usgs-lidar-public/AK_BrooksCamp_2012"
ROOT = (4, 3, 13, 7)                                     # depth, x, y, z of the octree node


def under(key):
    d, x, y, z = map(int, key.split("-"))
    step = 2 ** (d - ROOT[0])
    return d >= ROOT[0] and (x // step, y // step, z // step) == ROOT[1:]


hierarchy = json.load(urllib.request.urlopen(f"{EPT}/ept-hierarchy/0-0-0-0.json"))
nodes = sorted(k for k in hierarchy if under(k))
os.makedirs("nodes", exist_ok=True)
for key in nodes:
    path = f"nodes/{key}.laz"
    if not os.path.exists(path):
        urllib.request.urlretrieve(f"{EPT}/ept-data/{key}.laz", path + ".part")
        os.replace(path + ".part", path)

if not os.path.exists("tile.laz"):                       # the whole subtree as one file
    with laspy.open(f"nodes/{nodes[0]}.laz") as first:
        header = first.header
    with laspy.open("tile.laz", mode="w", header=header) as writer:
        for key in nodes:
            writer.write_points(laspy.read(f"nodes/{key}.laz").points)
with laspy.open("tile.laz") as reader:
    print(len(nodes), "nodes,", f"{reader.header.point_count:,} points,",
          f"{sum(os.path.getsize(f'nodes/{k}.laz') for k in nodes) / 1e6:.1f} MB of nodes")
287 nodes, 12,968,770 points, 82.8 MB of nodes

Example 1 β€” a survey report before any analysis

import laspy
import numpy as np

CLASSES = {0: "never classified", 1: "unclassified", 2: "ground",
           3: "low vegetation", 4: "medium vegetation", 5: "high vegetation",
           6: "building", 7: "low noise", 9: "water", 11: "road surface",
           17: "bridge deck", 18: "high noise"}


def survey_report(path):
    """Everything you need to know before writing analysis code."""
    las = laspy.read(path)
    n = len(las.points)
    x, y, z = np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)
    area = (x.max() - x.min()) * (y.max() - y.min())

    print(f"  {n:,} points, LAS {las.header.version}, "
          f"point format {las.header.point_format.id}")
    print(f"  extent {x.max() - x.min():.0f} x {y.max() - y.min():.0f} "
          f"(units of the file CRS)")
    print(f"  z {z.min():.1f} to {z.max():.1f}")
    print(f"  nominal density {n / area:.1f} per square unit "
          f"β€” meaningless unless the CRS is metric and undistorted")

    codes, counts = np.unique(las.classification, return_counts=True)
    for code, count in sorted(zip(codes.tolist(), counts.tolist()),
                              key=lambda t: -t[1]):
        print(f"    {code:2d} {CLASSES.get(code, 'reserved'):18} "
              f"{count:11,}  {count / n:6.2%}")

    returns, rcounts = np.unique(las.return_number, return_counts=True)
    print("  returns: " + ", ".join(f"{r}:{c / n:.1%}"
                                    for r, c in zip(returns, rcounts)))
    multi = np.asarray(las.number_of_returns) > 1
    print(f"  multi-return pulses: {multi.mean():.1%} of points")

    sources = np.unique(las.point_source_id)
    print(f"  {len(sources)} flight line(s)")
    return {"n": n, "classes": dict(zip(codes.tolist(), counts.tolist()))}


report = survey_report("tile.laz")

# Web Mercator stretches both axes by 1/cos(latitude): measure the area on the ground
from pyproj import Transformer

x, y = np.asarray(las.x), np.asarray(las.y)
lon, lat = Transformer.from_crs(3857, 4326, always_xy=True).transform(x.mean(), y.mean())
scale = np.cos(np.radians(lat))
ground_area = (x.max() - x.min()) * scale * (y.max() - y.min()) * scale
print(f"  centre {lat:.3f}Β°N {lon:.3f}Β°; {(x.max() - x.min()) * scale:.0f} x "
      f"{(y.max() - y.min()) * scale:.0f} m on the ground, "
      f"{len(x) / ground_area:.1f} points per square metre")
  12,968,770 points, LAS 1.2, point format 1
  extent 1613 x 1613 (units of the file CRS)
  z 15.9 to 203.1
  nominal density 5.0 per square unit β€” meaningless unless the CRS is metric and undistorted
     1 unclassified        10,003,338  77.13%
     2 ground               2,965,430  22.87%
     7 low noise                    2   0.00%
  returns: 1:59.5%, 2:36.1%, 3:4.4%, 4:0.1%, 5:0.0%
  multi-return pulses: 76.5% of points
  9 flight line(s)
  centre 58.566Β°N -155.783Β°; 841 x 841 m on the ground, 18.3 points per square metre

Example 2 β€” deriving height above ground without vegetation classes

import numpy as np


def height_above_ground(x, y, z, classification, cell=1.0):
    """Normalise heights against a per-cell ground minimum.

    Works when the survey classifies ground and nothing else, which is
    the common case.
    """
    ground = classification == 2
    if not ground.any():
        raise ValueError("no ground points β€” run a ground filter first")

    left, top = x.min(), y.max()
    width = int(np.ceil((x.max() - left) / cell))
    height = int(np.ceil((top - y.min()) / cell))

    col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
    row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
    flat = row * width + col

    dtm = np.full(width * height, np.inf)
    np.minimum.at(dtm, flat[ground], z[ground])
    dtm[np.isinf(dtm)] = np.nan

    normalised = z - dtm[flat]
    covered = np.isfinite(normalised)
    print(f"  {covered.mean():.1%} of points have a ground cell beneath them")
    print(f"  height above ground: {np.nanmin(normalised):.1f} to "
          f"{np.nanmax(normalised):.1f} m")
    return normalised


normalised = height_above_ground(x, y, np.asarray(las.z), np.asarray(las.classification))
  75.8% of points have a ground cell beneath them
  height above ground: -1.2 to 22.1 m

The 24.2% without a ground cell beneath them are the honest output β€” and the cells here are one Web Mercator unit, 0.52 m on the ground, so many are simply smaller than the spacing of ground returns. The small negative heights are unclassified points below the lowest ground point in their cell. Filling them by interpolating the DTM is reasonable and should be recorded, because those heights rest on an interpolated ground rather than an observed one.

Example 3 β€” separating flight lines

import numpy as np


def flight_line_report(point_source_id, gps_time, intensity):
    """Overlapping lines double the density and shift the intensity."""
    lines = np.unique(point_source_id)
    print(f"  {len(lines)} flight lines")
    for line in lines[:10]:
        m = point_source_id == line
        span = (gps_time[m].max() - gps_time[m].min()) / 60
        print(f"    line {line:5d}: {m.sum():10,} points, "
              f"{span:6.1f} min, median intensity {np.median(intensity[m]):6.0f}")

    if len(lines) > 1:
        medians = [np.median(intensity[point_source_id == l]) for l in lines]
        spread = (max(medians) - min(medians)) / np.mean(medians)
        print(f"  intensity varies {spread:.0%} between lines "
              f"β€” do not compare intensity across them")
    return lines


lines = flight_line_report(np.asarray(las.point_source_id), np.asarray(las.gps_time),
                           np.asarray(las.intensity))
  9 flight lines
    line     5:    946,235 points,    0.2 min, median intensity    127
    line     7:  3,003,561 points,    0.3 min, median intensity    108
    line     8:    844,661 points,    0.2 min, median intensity    118
    line    10:  2,918,818 points,    0.3 min, median intensity    108
    line    14:    150,097 points,    0.1 min, median intensity    130
    line    15:    671,206 points,    0.2 min, median intensity    139
    line    18:    222,052 points,    0.1 min, median intensity    127
    line    25:  2,320,279 points,    0.3 min, median intensity    113
    line    26:  1,891,861 points,    0.2 min, median intensity    119
  intensity varies 26% between lines β€” do not compare intensity across them

An intensity raster built across several flight lines shows the flight pattern rather than the ground. Rasterise intensity per line, or normalise per line, or do not use intensity.

Points and median intensity for each of the nine flight lines in the Brooks Camp lidar patch. nine flight lines in one 841 m patch, Brooks Camp 2012point_source_idpointsmedian intensity73,003,561108102,918,818108252,320,279113261,891,8611195946,2351278844,66111815671,20613918222,05212714150,097130Median intensity ran from 108 to 139 across the lines of one patch β€” 26% of their mean spatialworkflow.io
Example 3's report, largest line first. Every line crossed the same patch, and the intensities still differ by a quarter.

Density in the wrong CRS, and classification as a product

Why point density is the wrong number in the wrong CRS

The naive density of this survey is 5.0 points per square metre β€” and the true density is 18.3.

The file is stored in EPSG:3857, Web Mercator, where distances are inflated by 1/cos(latitude). At 58.57Β° north that is a factor of 1.92, so areas are inflated by 3.68, and any density computed in those units is understated by the same factor.

The measured extent makes it concrete: 1,613 Γ— 1,613 Web Mercator "metres" for a patch that is 841 Γ— 841 m on the ground. Reprojecting helps only if you then measure the area properly: in UTM zone 5N the same points have a bounding box of 877 Γ— 876 m, because the UTM grid is turned 2.4Β° against Web Mercator's there, and dividing by that box gives 16.9 β€” the convex hull gives 18.3.

Every metric quantity β€” density, slope, canopy area, distance between points β€” inherits that error. Reproject to a projected CRS suited to the area before computing anything metric. See laspy returns the wrong coordinates.

Why classification is a separate product

The ground/not-ground decision is a filtering problem, not a property of the measurement. Algorithms such as progressive TIN densification or cloth simulation start from the lowest returns and iteratively decide which points are consistent with a plausible ground surface.

They can fail: on steep slopes they clip ridges, under dense canopy they have little to work with, and near buildings they can accept a flat roof as ground. Vendors deliver ground classification because it is hard and worth paying for β€” and they often deliver only that, because the other classes are harder still.

The 100% / 38.4% relationship measured above is the reason. Filtering by last return gets you a superset of the ground that is nearly three times too large.

Why intensity looks like a band and is not one

Returned amplitude depends on the target's reflectance at the laser wavelength β€” usually 1,064 nm, in the near-infrared, so vegetation is bright and water is nearly black.

It also depends on the range to target (inverse square), the incidence angle, the atmospheric transmission on the day, and any automatic gain the sensor applied. None of that is recorded in a way that lets you invert it.

The practical rule: intensity is a texture, useful for eyeballing and for local contrast within one flight line. It is not a reflectance measurement and it is not comparable across lines, dates or sensors.

Why the file is a table and behaves like one

A LAS file is a header plus fixed-width records. The coordinates are stored as 32-bit integers with a scale and offset in the header β€” hence X and x being different attributes in laspy, one raw and one scaled.

That layout is why point clouds are fast to read sequentially and slow to query spatially: there is no index. Formats that add one β€” COPC, EPT β€” exist precisely to make "give me this bounding box at this resolution" a cheap operation. See LAS, LAZ and COPC explained.

Classification breakdown of a real 3DEP survey: 77 percent unclassified, 23 percent ground and two noise points. what this survey actually classified1 unclassified77.13%2 ground22.87%7 low noise2 points01No vegetation or building classes exist β€” filtering on class 5 returns nothing spatialworkflow.io
The LAS standard defines twelve classes. A survey contains the ones it was paid to produce.

Unclassified points and raw integers

  • Class 1 means "unclassified", not "unknown surface". A cloud that is 77% class 1 has only had a ground filter run.
  • las.X is the raw integer, las.x is the scaled coordinate. Mixing them is off by a factor of 100 or 1,000.
  • Every ground point is a last return; only 38.4% of last returns are ground in this survey.
  • Intensity is uncalibrated. Do not compare it across flight lines.
  • Check the CRS before computing density. Web Mercator understated it by a factor of 3.68 here, and a bounding box in a rotated grid by another 8%.
  • number_of_returns can be 0 in malformed files; guard division by it.
  • GPS time can be either "week seconds" or "adjusted standard time" β€” the header's global encoding bit says which.
  • Withheld and synthetic flags mark points the vendor wants excluded; check them before analysis.

FAQ

What is a lidar return?

One detectable echo from one outgoing laser pulse. A pulse through vegetation can produce several returns, which is why return_number and number_of_returns are separate columns.

What do the classification codes mean?

The LAS standard defines 2 as ground, 3–5 as vegetation by height, 6 as building, 9 as water and 7/18 as noise. A survey only contains the classes it was paid to produce β€” this one had ground and nothing else.

Can I use last returns instead of ground classification?

Only as a rough filter. Every ground point here was a last return, but only 38.4% of last returns were ground.

Is lidar intensity like a satellite band?

No. It is uncalibrated and varies with range, incidence angle, atmosphere and sensor gain, so it is not comparable between flight lines.

How dense is my point cloud?

Points divided by ground area β€” but only after reprojecting to a suitable projected CRS. In Web Mercator this survey appeared to be 5.0 points/mΒ² when it is really 18.3.

Why does my cloud have no vegetation points?

Because the survey did not classify vegetation. Derive height above ground from the returns and the ground surface instead of filtering by class.

What is point_source_id for?

It identifies the flight line. Overlapping lines double the local density and shift the intensity, which shows up as stripes in derived rasters.