Filtering and registration¶
Thinning¶
TLS density falls with the square of the range, so near-scanner areas dominate anything that counts points. Thin before processing:
| Function | Keeps | Use for |
|---|---|---|
filters.voxel_downsample(c, 0.01) |
first point per voxel, attributes kept | general thinning, fastest |
filters.voxel_downsample(c, 0.01, "centroid") |
voxel centroids, no attributes | smoothing noise |
filters.min_distance_subsample(c, 0.01) |
points at least d apart, no grid pattern |
normals, segmentation |
filters.random_subsample(c, fraction=0.1, seed=0) |
random share, reproducible | quick looks, tests |
Typical spacings: 1 cm for stems and QSMs, 2–5 cm for ground filtering and ICP, and 5–10 cm for plot overviews.
Cropping and noise¶
from sylva import filters
plot = filters.crop_cylinder(cloud, center_xy=(0, 0), radius=30)
box = filters.crop_box(cloud, (None, None, 0.5), (None, None, 40)) # None = open side
near = filters.range_filter(scan, origin=(0, 0, 0), max_range=60)
clean = filters.statistical_outlier_removal(cloud, k=8, std_ratio=2.0)
clean = filters.radius_outlier_removal(cloud, radius=0.05, min_neighbors=4)
keep = filters.statistical_outlier_removal(cloud, return_mask=True) # boolean mask instead
The statistical filter (Rusu et al. 2008) compares every point's mean distance to its neighbours against global statistics. Where density varies a lot, run it per scan or after thinning, or it removes distant, sparse points that are real.
Local geometry¶
normals = filters.estimate_normals(cloud, k=12) # sign arbitrary
planarity, linearity = filters.planarity_linearity(cloud, k=20)
labels = filters.euclidean_clusters(cloud.xyz, radius=0.1, min_points=50) # 0 = largest, -1 = too small
dist, idx = filters.knn(cloud.xyz, queries, k=8)
Planarity and linearity are the eigenvalue features of Weinmann et al.
(2015). Stems and branches are linear, while leaves and
ground are planar. These features drive the leaf/wood filters in sylva.qsm
and sylva.leaves.
Registration¶
Scans from a RiSCAN project already carry their SOPs (see Point clouds and files). To register scans yourself, or to refine a registration:
from sylva import registration as reg, filters
# 1. Coarse: from matched targets (reflectors, tie points)
T0 = reg.kabsch(source_targets, target_targets)
# 2. Fine: ICP on thinned clouds, starting from T0
src = filters.voxel_downsample(scan_b, 0.05)
dst = filters.voxel_downsample(scan_a, 0.05)
T, info = reg.icp(src, dst, init=T0, max_correspondence_distance=0.3,
method="plane", trim=0.8)
info # {'rmse': ..., 'iterations': ..., 'n_correspondences': ...}
merged = reg.merge_scans([scan_a, scan_b], [np.eye(4), T]) # adds scan_id
kabsch is the closed-form least-squares rotation of Kabsch (1976) with
Umeyama's (1991) guard against reflections. icp is point-to-point ICP
(Besl & McKay 1992) or, with method="plane", point-to-plane ICP (Chen &
Medioni 1992) solved by Low's (2004) linearisation; see the
references.
- Starting point. ICP only converges from a start within about
max_correspondence_distance, so give it the SOP or a Kabsch estimate.reg.rotation_zandreg.translationbuild simple starting guesses. - Partial overlap.
trimbelow 1 drops the worst pairs each iteration (trimmed ICP, Chetverikov et al. 2002), which is what two scan positions of a forest plot need. - Checking the result. ICP's RMSE mixes noise with misregistration.
quality.stem_noiseseparates the two, giving the horizontal offset of every scan measured on the stems (see Scan quality).
Coregistering a survey¶
sylva.coreg registers every scan position of a survey at once, with no
targets and no starting alignment: it matches the stems each scan sees. Where scans share RiSCAN targets, it uses them too.
from sylva import coreg, read_riscan_project, riscan
project = read_riscan_project("survey.PROJ")
scans = project.with_scans(require_sop=False)
config = coreg.CoregConfig(
riscan_filter="current", # or "legacy", "none"
riegl_options=coreg.reading_options("export.settings", min_reflectance=-20),
)
result = coreg.coregister(
[p.rxp for p in scans], config,
names=[p.name for p in scans],
levelling=[p.levelling for p in scans], # tilted scans levelled first
reflectors=[p.reflectors() for p in scans], # RiSCAN targets, if any
approximate_positions=riscan.gnss_to_local([p.gnss for p in scans]),
)
print(result.report())
result.save("transforms.json")
merged = coreg.merge_clouds([p.rxp for p in scans], result, voxel=0.02)
It works in three stages:
- Each scan gets a terrain model and a stem map. Only the locally
planar points (stems, ground, logs) are kept for ICP. A scanner tilted
on its side cannot see the ground in the directions along its tilt axis,
where the lowest returns are foliage; the terrain there is filled from
the directions it could see (
ground_min_coverage). - Each pair is matched on shared targets where both scans saw three or
more. Otherwise the two stem maps are matched in plan, and the height
comes from the ground both scans saw rather than from the stems: a stem's
height rests on its own scan's terrain model, which is least certain
under understory, and vertical error is the weak point of stem-based
registration (Tremblay & Béland 2018; Wang et al. 2023). A robust
point-to-plane ICP (Chen & Medioni 1992, with Huber weights and a trimmed
tail as in Chetverikov et al. 2002) then refines the match. A pair is
accepted only if it fits both overall and above the ground, so a flat
ground can't confirm a wrong match on its own, and if the two terrain
models then agree within
max_ground_disagreement(25 cm). - The whole survey is solved as a pose graph (Lu & Milios 1997) by Levenberg–Marquardt with a Huber (1964) kernel, with outlier edges rejected. Each edge is weighted by the directions its ICP surfaces constrain: a pair that overlaps mostly on flat ground holds the height firmly and the horizontal position loosely. Scans that are left over are placed against the combined survey, then registered pairwise to their new neighbours.
Options:
- Trusted targets. A pair that shares at least
trusted_reflector_matches(5) targets, with an RMSE undertrusted_reflector_rmse(3 cm), keeps the target solution instead of ICP. - Fixed poses and priors.
fixed={index: pose}holds scans at trusted poses, so new positions can join an existing project.priors(RiSCAN SOPs, GNSS and compass) place scans that see too few stems to match (seecoreg.place_from_prior). - RiSCAN filters.
riegl_optionsapplies RiSCAN export bounds on range, deviation, reflectance and amplitude, read from an export settings file (explicit bounds override the file).riscan_filterdrops what RiSCAN drops:"current"removes echoes closer than 0.5 m, and"legacy"also removes isolated weak echoes, as older RiSCAN versions did. - Checking the result.
result.report()lists every pair. For each one it gives how far apart the same trees land from the two scans (horizontally) and how far apart their terrain models are (dz), checks that need no ground truth.
Positions far apart share little surface, so ICP's fitness falls with the
distance between them; the gates (min_icp_fitness 0.04,
min_icp_fitness_above_ground 0.03) were set on a 14-scan VZ-400 survey of
upright and tilted scans 30-35 m apart, scored against its reflector-based
registration. From stems alone all 14 scans registered, with a median
error of 2.9 cm and at most 4.4 cm 15 m from the scanner. A survey
whose positions share few trees may still register only in part. The same
pipeline is the sylva coreg command (see Command line). The
methods it builds on are cited on the references page.