Fusing LiDAR and Photogrammetry Point Clouds
This page combines a LiDAR survey and a photogrammetric reconstruction of the same site into one cloud that is better than either — aligning them on surfaces both measured reliably, quantifying the systematic bias between them, deciding which source is authoritative for which surface type, transferring colour to the LiDAR points, and tagging every point with its provenance, all in EPSG:25832+7837.
Why you hit this
The two sources fail in opposite places. LiDAR penetrates vegetation, gives trustworthy bare earth and has good absolute accuracy, and carries no colour and little facade detail from the air. Photogrammetry gives dense colour and facade geometry and cannot see the ground under a tree. A twin that has both and uses them separately ends up with two versions of every roof, differing by a few centimetres, and a mesh pipeline that reconstructs a doubled surface. Fusing them properly is mostly bookkeeping — which source owns which surface, and what the offset between them is — and the payoff is one cloud with no duplicated geometry. The pipeline context is in photogrammetry processing pipelines.
Prerequisites
- Python 3.10+ with
laspy[lazrs]>=2.5,numpy>=1.24,scipy>=1.11,open3d>=0.18,pdal>=3.4. - Both clouds in the same compound CRS — reproject first, and check the vertical datum on both, since photogrammetric output is frequently ellipsoidal.
- Classification on the LiDAR (ground, building, vegetation) and colour on the photogrammetric cloud.
- Overlapping coverage of at least a few hard, planar surfaces: roads, car parks, flat roofs.
Step-by-Step
1. Load both and confirm the frames match
import laspy
import numpy as np
def load(path):
las = laspy.read(path)
crs = las.header.parse_crs()
xyz = np.column_stack([las.x, las.y, las.z])
rgb = (np.column_stack([las.red, las.green, las.blue]) / 65535.0
if hasattr(las, "red") else None)
cls = np.asarray(las.classification)
return {"xyz": xyz, "rgb": rgb, "cls": cls, "crs": crs.to_epsg() if crs else None,
"n": len(xyz), "path": path}
lidar = load("survey_2026_lidar.laz")
photo = load("flight_2026_photo_georef.laz")
for c in (lidar, photo):
print(f"{c['path']}: {c['n']:,} points, EPSG {c['crs']}, "
f"z {c['xyz'][:, 2].min():.1f}–{c['xyz'][:, 2].max():.1f} m, colour: {c['rgb'] is not None}")
assert lidar["crs"] == photo["crs"], "reproject to a common CRS before fusing"
The z ranges are the quick test for a vertical datum mismatch: two clouds of the same site whose ranges differ by a constant 40–50 m are in different height systems, and no amount of alignment will fix that — it has to be transformed properly.
2. Align on surfaces both sources measured well
import open3d as o3d
OFFSET = np.floor(lidar["xyz"].min(axis=0))
def stable_subset(cloud, classes=(2, 6), z_band=None):
m = np.isin(cloud["cls"], classes) if cloud["cls"] is not None else np.ones(cloud["n"], bool)
xyz = cloud["xyz"][m]
if z_band is not None:
xyz = xyz[(xyz[:, 2] > z_band[0]) & (xyz[:, 2] < z_band[1])]
pc = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(xyz - OFFSET))
return pc.voxel_down_sample(0.25)
ref = stable_subset(lidar) # ground and buildings from LiDAR
src = stable_subset(photo, classes=(1, 2, 6)) # photogrammetric classes are often unset
for p in (ref, src):
p.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=1.0, max_nn=30))
reg = o3d.pipelines.registration.registration_icp(
src, ref, max_correspondence_distance=0.6, init=np.eye(4),
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPlane(),
criteria=o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=80))
print(f"fitness {reg.fitness:.3f}, inlier RMSE {reg.inlier_rmse * 100:.1f} cm")
print("translation (cm):", (reg.transformation[:3, 3] * 100).round(1))
photo["xyz"] = (reg.transformation @ np.column_stack(
[photo["xyz"] - OFFSET, np.ones(photo["n"])]).T).T[:, :3] + OFFSET
Aligning on ground and roofs excludes exactly the surfaces where the two sources legitimately disagree — vegetation, where one sees canopy and the other sees through it, and water, where photogrammetry sees nothing stable. Point-to-plane ICP on those surfaces converges quickly because both clouds are locally planar there.
Treat the resulting translation as information, not just as a correction. A vertical shift of a few centimetres is normal survey-to-survey disagreement. A vertical shift of 10–30 cm with a small horizontal component usually means the photogrammetric heights carry a scale or calibration bias, and the fix belongs in the reconstruction, not here.
3. Measure the residual bias per surface type
from scipy.spatial import cKDTree
tree = cKDTree(lidar["xyz"][np.isin(lidar["cls"], (2,))]) # LiDAR ground
def signed_dz(points, k=8):
d, idx = tree.query(points[:, :2], k=k)
z_ref = lidar["xyz"][np.isin(lidar["cls"], (2,))][idx][:, :, 2].mean(axis=1)
return points[:, 2] - z_ref
sample = photo["xyz"][np.random.default_rng(3).choice(photo["n"], 200_000, replace=False)]
dz = signed_dz(sample)
print(f"photogrammetry minus LiDAR ground: median {np.median(dz) * 100:+.1f} cm, "
f"p5 {np.percentile(dz, 5) * 100:+.1f}, p95 {np.percentile(dz, 95) * 100:+.1f}")
Comparing each photogrammetric point with the mean height of its nearest LiDAR ground neighbours gives a signed bias distribution rather than a single number. On open ground the median should be within a couple of centimetres of zero after alignment. The p95 tail is mostly vegetation and cars — points where the two sources measured different things, which is the next step’s problem.
4. Split authority by surface type
AUTHORITY = {
"ground_open": "lidar", # both see it; LiDAR has better absolute accuracy
"ground_vegetated": "lidar", # only LiDAR sees it at all
"roofs": "photo", # denser, and carries colour
"facades": "photo", # airborne LiDAR barely sees them
"vegetation": "lidar", # structure through the canopy
"water": "lidar", # photogrammetry has no stable features
}
veg_tree = cKDTree(lidar["xyz"][np.isin(lidar["cls"], (3, 4, 5))][:, :2]) if (
np.isin(lidar["cls"], (3, 4, 5)).any()) else None
def photo_keep_mask(photo_xyz, lidar_ground_z, veg_radius=2.0):
keep = np.ones(len(photo_xyz), bool)
above_ground = photo_xyz[:, 2] - lidar_ground_z
keep &= above_ground > 1.5 # below that, LiDAR owns the ground
if veg_tree is not None:
near_veg = veg_tree.query_ball_point(photo_xyz[:, :2], veg_radius)
keep &= np.array([len(n) == 0 for n in near_veg]) # LiDAR owns vegetated areas
return keep
ground_z = np.empty(photo["n"])
step = 1_000_000
for i in range(0, photo["n"], step):
ground_z[i:i + step] = photo["xyz"][i:i + step, 2] - signed_dz(photo["xyz"][i:i + step])
keep = photo_keep_mask(photo["xyz"], ground_z)
print(f"keeping {keep.sum():,} of {photo['n']:,} photogrammetric points "
f"({100 * keep.mean():.1f}%)")
The authority table is the substance of the fusion, and writing it down explicitly is what makes the result reviewable. The height threshold keeps photogrammetric points that are on structures and drops the ones that duplicate LiDAR ground; the vegetation test drops the canopy surface that would otherwise sit above LiDAR points that describe the same trees properly.
5. Transfer colour to the LiDAR points
photo_kd = cKDTree(photo["xyz"])
d, idx = photo_kd.query(lidar["xyz"], k=4, distance_upper_bound=0.5, workers=-1)
valid = np.isfinite(d).all(axis=1)
lidar_rgb = np.zeros((lidar["n"], 3))
w = 1.0 / np.maximum(d[valid], 1e-3)
lidar_rgb[valid] = (photo["rgb"][idx[valid]] * w[:, :, None]).sum(axis=1) / w.sum(axis=1)[:, None]
print(f"{valid.mean() * 100:.1f}% of LiDAR points coloured from photogrammetry")
Inverse-distance weighting over four neighbours gives a smoother result than nearest-neighbour colour and avoids the speckle that a single nearest point produces where the two clouds have different densities. Points with no photogrammetric neighbour within half a metre — under canopy, inside courtyards the flight never saw — stay uncoloured, which is honest and better than stretching a colour from two metres away.
6. Merge with provenance and write
from pyproj import CRS
merged_xyz = np.vstack([lidar["xyz"], photo["xyz"][keep]])
merged_rgb = np.vstack([lidar_rgb, photo["rgb"][keep]])
merged_cls = np.concatenate([lidar["cls"], np.full(keep.sum(), 6, dtype=np.uint8)])
source = np.concatenate([np.zeros(lidar["n"], np.uint8), np.ones(keep.sum(), np.uint8)])
header = laspy.LasHeader(point_format=7, version="1.4")
header.offsets = np.floor(merged_xyz.min(axis=0))
header.scales = [0.001, 0.001, 0.001]
header.add_crs(CRS.from_user_input("EPSG:25832+7837"))
header.add_extra_dim(laspy.ExtraBytesParams(name="source", type=np.uint8,
description="0=lidar 1=photogrammetry"))
out = laspy.LasData(header)
out.x, out.y, out.z = merged_xyz.T
out.red, out.green, out.blue = (merged_rgb * 65535).astype(np.uint16).T
out.classification = merged_cls
out.source = source
out.write("site_fused.laz")
print(f"written {len(merged_xyz):,} points; {100 * source.mean():.1f}% from photogrammetry")
The source dimension is what makes the fused cloud defensible a year later. Every measurement taken from it can be traced to the sensor that produced it, a disputed height can be checked against the source with the better vertical accuracy, and a reprocessed photogrammetric flight can replace its own points without touching the LiDAR.
Expected Output & Verification
survey_2026_lidar.laz: 42,118,004 points, EPSG 25832, z 498.1–556.8 m, colour: False
flight_2026_photo_georef.laz: 78,412,006 points, EPSG 25832, z 498.4–557.2 m, colour: True
fitness 0.914, inlier RMSE 4.8 cm
translation (cm): [ 1.4 -2.2 3.9]
photogrammetry minus LiDAR ground: median +1.2 cm, p5 -4.8, p95 +182.4
keeping 26,884,551 of 78,412,006 photogrammetric points (34.3%)
92.4% of LiDAR points coloured from photogrammetry
written 69,002,555 points; 39.0% from photogrammetry
Verify that the fusion removed the double surface rather than hiding it. Compute the local thickness of the merged cloud on flat roofs:
roof = merged_xyz[(merged_cls == 6) & (merged_xyz[:, 2] > np.percentile(merged_xyz[:, 2], 80))]
cell = 0.5
keys = np.floor(roof[:, :2] / cell).astype(np.int64)
order = np.lexsort((keys[:, 1], keys[:, 0]))
roof, keys = roof[order], keys[order]
bounds = np.flatnonzero(np.any(np.diff(keys, axis=0) != 0, axis=1)) + 1
thick = [np.ptp(r[:, 2]) for r in np.split(roof, bounds) if len(r) > 6]
print(f"roof cell thickness: median {np.median(thick) * 100:.1f} cm, p95 {np.percentile(thick, 95) * 100:.1f} cm")
assert np.median(thick) < 0.15, "double surface remains: authority split is not removing duplicates"
A median thickness of a few centimetres on flat roofs is single-surface noise. A median near the alignment offset — or a bimodal distribution — means both sources still contribute to the same roof, which is the failure this whole procedure exists to prevent.
Performance Notes
- KD-tree queries dominate. Build trees on the smaller cloud and query with
workers=-1; a 40-million-point tree takes a minute and a few gigabytes. - Chunk the queries, as the ground-height loop does, so peak memory stays bounded — a
(80e6, 8)index array is 5 GB on its own. - Voxel-downsample before ICP. Alignment on 0.25 m samples of the stable classes converges to the same transformation as the full clouds and takes seconds.
- Tile large sites. Fusion is local, so process 500 m tiles with a 50 m overlap and keep each tile’s interior; the alignment transformation should be computed once for the whole site, not per tile.
- Keep both sources. The fused cloud is a derived product; regenerating it after a reprocessing run is cheap only if the inputs are still there.
Common Errors
The fused cloud has two roofs everywhere. The authority split was not applied, or the height threshold was too low so photogrammetric ground points survived. Check the roof-thickness metric above.
ICP converges to a translation of several metres. The stable subsets do not overlap — often because one cloud is in a different CRS or the classes selected were empty. Print the extents of both subsets before registering.
Colour transfer leaves grey patches across whole streets. The photogrammetric cloud has no points there: water, glass, or a gap in the flight. Leave them uncoloured and record it; interpolating colour across a gap invents appearance.
Vegetation looks worse after fusion. Photogrammetric canopy points were kept alongside LiDAR vegetation, producing a shell over a structure. Widen the vegetation exclusion radius.
Frequently Asked Questions
Should fusion happen before or after classification?
Classify the LiDAR first — the authority rules depend on it — and classify the fused cloud afterwards if downstream tools need consistent classes, using the source dimension to keep the two sets distinguishable.
Can I fuse clouds from different years?
Only if nothing changed, which is rarely true. Between epochs, the right operation is change detection rather than fusion; running both is how a twin gets an updated surface and a record of what moved, as in change detection between LiDAR scan epochs.
Is it better to fuse the clouds or mesh them separately and combine meshes?
Fuse the clouds. Meshes combine badly — two surfaces of the same roof produce self-intersections that no repair fixes cleanly — while clouds combine by selection, which is exactly what the authority split does.
Related Guides
- Georeferencing Photogrammetric Point Clouds — getting both sources into one frame
- Registering Multi-Epoch Scans with ICP — the alignment step in depth
- Colorizing LiDAR from Orthophotos with PDAL — the raster alternative to colour transfer
Back to Photogrammetry Processing Pipelines.