Estimating Point Spacing for Mobile Mapping Scans
This page measures point spacing on a mobile mapping scan properly — per surface and per range, using nearest-neighbour distances rather than a planimetric density — and explains why the points-per-square-metre figure that works for airborne LiDAR is actively misleading here. A mobile scan’s density varies by an order of magnitude between a kerb two metres from the vehicle and a facade thirty metres away, and a single ppsm figure averages that away.
Why you hit this
Mobile mapping specifications are frequently written in the units airborne surveys use, because that is what procurement knows. The result is a contract that is either trivially satisfied or impossible, depending on where you measure: a scanner producing 3,000 points per square metre on the road surface beside the vehicle might produce 40 on a building facade at the far end of the street, and the “average” that gets reported describes neither. Every clearance, gauge and asset-extraction requirement lives on one of those surfaces.
The planimetric approach this replaces is covered in point cloud density standards; what follows is what changes when the sensor moves along a trajectory rather than flying over.
Prerequisites
- Python 3.10+ with
laspy>=2.5,numpy>=1.24,scipy>=1.11andopen3d>=0.18. - A scan retaining
GpsTimeand, ideally, the trajectory as a separate file. Range is recoverable from the trajectory and cannot be reconstructed without it. - A metric projected CRS, and outliers already removed — a single stray point ruins a nearest-neighbour statistic.
Step-by-Step
1. Understand why planimetric density fails here
Airborne density works because the sensor looks roughly straight down from a roughly constant height, so a square metre of ground receives a roughly constant number of returns. A mobile scanner rotates about a moving axis, so spacing along the scan line is range times the angular step, and spacing across it is vehicle speed divided by rotation rate.
import numpy as np
def mobile_spacing(range_m, angular_step_deg, speed_mps, rotations_per_s):
along = range_m * np.radians(angular_step_deg) # within one rotation
across = speed_mps / rotations_per_s # between rotations
return along, across
for r in (2.0, 8.0, 20.0, 40.0):
a, c = mobile_spacing(r, 0.03, 8.3, 200.0)
print(f"range {r:>5.1f} m → along {a*1000:6.1f} mm, across {c*1000:6.1f} mm, "
f"anisotropy {max(a, c)/min(a, c):.1f}:1")
Two facts fall out of that. Spacing along the scan line grows linearly with range, so density falls with the square of it. And the two spacings are independent, so the point pattern is anisotropic — dense in one direction and sparse in the other — which is exactly what a planimetric density cannot express.
2. Measure nearest-neighbour spacing instead
The robust statistic is the distance from each point to its nearest neighbour, which describes the pattern wherever it is measured.
import laspy
import numpy as np
from scipy.spatial import cKDTree
las = laspy.read("mobile_scan_utm33n.laz")
xyz = np.column_stack([np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)])
rng = np.random.default_rng(0)
sample = xyz[rng.choice(len(xyz), size=min(500_000, len(xyz)), replace=False)]
tree = cKDTree(xyz)
d, _ = tree.query(sample, k=2) # k=2: self plus nearest neighbour
nn = d[:, 1]
print(f"nearest-neighbour spacing: median {np.median(nn)*1000:.1f} mm, "
f"p10 {np.percentile(nn, 10)*1000:.1f}, p90 {np.percentile(nn, 90)*1000:.1f}")
The p10-to-p90 span is the number worth quoting alongside the median. On an airborne scan it is typically within a factor of two; on a mobile scan a factor of fifteen is normal, and that ratio is the honest description of what the survey delivered.
3. Stratify by surface, because that is what the requirement is about
A specification says “resolve a 25 mm conductor” or “measure kerb height to 10 mm”, and those live on specific surfaces.
import numpy as np
def spacing_by_class(las, xyz, tree, codes=(2, 6, 11)):
cls = np.asarray(las.classification)
out = {}
for c in codes:
m = cls == c
if m.sum() < 1000:
continue
sub = xyz[m]
d, _ = tree.query(sub[np.random.default_rng(1).choice(len(sub), 50_000)], k=2)
out[c] = np.median(d[:, 1])
return out
names = {2: "ground", 6: "building facade", 11: "road surface"}
for c, s in spacing_by_class(las, xyz, tree).items():
print(f"{names.get(c, c):<18} median spacing {s*1000:6.1f} mm")
4. Recover range from the trajectory
Range is what explains the variation, and it is recoverable if the trajectory was delivered.
import numpy as np
from scipy.interpolate import interp1d
traj = np.loadtxt("trajectory.txt") # gps_time, x, y, z
fx = interp1d(traj[:, 0], traj[:, 1], bounds_error=False, fill_value="extrapolate")
fy = interp1d(traj[:, 0], traj[:, 2], bounds_error=False, fill_value="extrapolate")
fz = interp1d(traj[:, 0], traj[:, 3], bounds_error=False, fill_value="extrapolate")
t = np.asarray(las.gps_time)
sensor = np.column_stack([fx(t), fy(t), fz(t)])
rng_m = np.linalg.norm(xyz - sensor, axis=1)
for lo, hi in ((0, 5), (5, 15), (15, 30), (30, 60)):
m = (rng_m >= lo) & (rng_m < hi)
if m.sum() < 1000:
continue
d, _ = tree.query(xyz[m][:20_000], k=2)
print(f"range {lo:>2}–{hi:<2} m: {m.sum():>10,} points, "
f"median spacing {np.median(d[:, 1])*1000:6.1f} mm")
5. Write the acceptance criterion in the units the work needs
The useful specification names a surface, a distance and a spacing.
REQUIREMENTS = [
{"surface": "road", "class": 11, "max_range_m": 8.0, "max_spacing_mm": 10.0},
{"surface": "kerb", "class": 2, "max_range_m": 12.0, "max_spacing_mm": 15.0},
{"surface": "conductor", "class": 14, "max_range_m": 40.0, "max_spacing_mm": 8.0},
]
def check(las, xyz, tree, rng_m, reqs):
cls = np.asarray(las.classification)
failures = []
for r in reqs:
m = (cls == r["class"]) & (rng_m <= r["max_range_m"])
if m.sum() < 500:
failures.append(f"{r['surface']}: only {m.sum()} points in range")
continue
d, _ = tree.query(xyz[m][:50_000], k=2)
got = float(np.median(d[:, 1])) * 1000
if got > r["max_spacing_mm"]:
failures.append(f"{r['surface']}: {got:.1f} mm > {r['max_spacing_mm']} mm")
return failures
Expected Output & Verification
A representative mobile scan of an urban street:
range 2.0 m → along 1.0 mm, across 41.5 mm, anisotropy 39.6:1
range 40.0 m → along 20.9 mm, across 41.5 mm, anisotropy 2.0:1
nearest-neighbour spacing: median 12.4 mm, p10 2.1, p90 68.9
road surface median spacing 4.1 mm
building facade median spacing 31.4 mm
range 0–5 m: 84,220,118 points, median spacing 3.8 mm
range 30–60 m: 4,102,884 points, median spacing 79.2 mm
The p10-to-p90 ratio of 33 is the headline finding, and it is what makes a single figure meaningless. Verify by checking that the measured spacing at a given range agrees with the geometric prediction from step 1 to within about 20% — a large disagreement means the trajectory interpolation is wrong, usually a GPS time offset between the scan and the trajectory file.
Common Errors
Range comes out implausibly large for every point. The trajectory and the scan use different GPS time conventions — adjusted standard GPS time versus week seconds. Check that the two time ranges overlap before interpolating.
Nearest-neighbour spacing is near zero everywhere. Duplicate points, usually from merging overlapping passes without deduplication. Deduplicate on exact coordinates first, or the statistic measures the duplication rather than the scan.
Spacing on facades looks better than the geometry predicts. Multiple passes covered the same facade from different positions, so the effective spacing is better than any single pass achieved. That is legitimate coverage, and it is worth reporting per pass as well as combined.
Frequently Asked Questions
Should I ever quote points per square metre for a mobile scan?
Only alongside the surface and the range it was measured on. Quoted alone it is a number that no downstream requirement can use.
How many points do I need to sample?
Fifty thousand per stratum is ample for a median and a robust p10/p90. The cKDTree query is the cost, and it scales with the sample rather than with the cloud.
Does this apply to terrestrial static scans too?
Partly. A static scan has the same range-dependent along-scan spacing, but no vehicle motion, so the across-scan spacing is also range-dependent and the pattern stays isotropic. The nearest-neighbour approach is still the right measurement.
A last note on procurement. The most useful thing this measurement produces is not a pass or a fail but a specification that can be met: naming the surface, the maximum range at which it must be met, and the spacing in millimetres turns an argument about averages into a check either party can run. A contract written in points per square metre over a mobile scan is, in practice, unenforceable in both directions — the surveyor cannot demonstrate compliance and the client cannot demonstrate breach, because neither can say where the number was measured.
The second use is planning. Because spacing along the scan line is range times the angular step, and across it is speed over rotation rate, the specification determines the survey: a conductor at 34 m needing 8 mm spacing implies either a finer angular step or a second pass from closer. That calculation is worth doing before the vehicle is booked rather than after the data is rejected.
What about multiple passes over the same street?
Report both. The combined cloud is what downstream work consumes, so its spacing is the operative number, but the per-pass figure is what tells you whether a single pass would have sufficed — which is the question that decides the next survey’s cost.
Related Guides
- Point Cloud Density Standards — the planimetric approach and where it does apply
- Best Practices for LiDAR Point Density in Infrastructure — per-asset density targets
- Removing Noise from Terrestrial LiDAR Scans — the cleanup that must precede any spacing statistic
Back to Point Cloud Density Standards.