TLS and ALS together¶
A terrestrial scan measures a plot from below: every stem, its diameter,
the understorey and the lower crowns, in millimetre detail but over a
hectare at most, and with the tops of tall trees often hidden. An airborne
survey measures the same forest from above: the upper canopy and the
terrain over the whole landscape, but hardly a stem. sylva.fusion puts
the two in one frame and combines them:
| Step | Function | What it gives |
|---|---|---|
| Registration | fusion.register |
the TLS plot placed on the ALS survey, with its residuals and uncertainty |
| Tree linking | fusion.link_trees |
TLS stems linked to ALS trees, the trees under each crown, and one table of TLS diameters and ALS heights |
| Merged clouds | fusion.merge_clouds |
one point cloud, TLS below and ALS above, with the source of every point |
| Merged profiles | fusion.fuse_profiles |
one plant area density profile, each height taken from the instrument that sampled it better |
| Upscaling | fusion.plot_values, fit_model, upscale |
plot values from the TLS regressed on ALS metrics and predicted over the survey, with leave-one-out checks and prediction intervals |
import numpy as np
from sylva import als, fusion, ground, read, registration, trees, voxels
tls = ground.classify_ground_csf(read("plot.laz")) # the plot, in its scanner frame
cat = als.catalog("tiles/") # the survey, ground classified
gnss = registration.translation(512_340.0, 6_912_870.0, 0.0) # the plot centre, to a few metres
reg = fusion.register(tls, cat, gnss, search_radius=10.0) # heading searched over 360 degrees
print(reg.report())
tls_map = reg.apply(tls) # the plot in the survey's frame
links = fusion.link_trees(stems, als_trees, reg, volumes=qsms)
links.to_csv("trees.csv") # TLS DBH, ALS height, flags
The computations are in the Rust core (sylva_rs::fusion); results do not
depend on the number of threads.
Registration¶
A TLS plot arrives in one of two states: in a local frame (a scanner or
project frame, placed roughly by a hand-held GNSS position and perhaps a
compass heading), or already georeferenced, with the metre-level error of
the GNSS antenna on the scanner. fusion.register handles both; they
differ only in how wide a search they need (search_radius,
heading_range).
Search. Each cloud gets a canopy height model (CHM, the highest point
of each cell above a DTM made from its own ground points) and a terrain
model (DTM, the lowest ground point of each cell). The cells of the TLS
plot form a template that is turned by a heading and shifted over the ALS
rasters, and each pose is scored by Pearson's correlation of the two CHMs,
plus dtm_weight (0.5) times that of the two DTMs, over the cells both
measured. A correlation is blind to a vertical offset between the terrains
and to a TLS canopy that reads low where the scanner saw less of the
crowns. The whole window is scored at coarse_resolution (2 m): every
heading within heading_range of the initial one, heading_step (3°)
apart, and every shift within search_radius. The n_candidates (3) best
separated peaks are searched again at resolution (0.5 m), with bilinear
sampling and a parabola through the best score in heading, x and y. For a
sparse survey the fine cells are widened to hold about returns_per_cell
(10) returns each: 1 m at 10 returns per m². The height comes from the
median difference of the two DTMs.
Refinement. From each peak, a robust point-to-plane ICP (the ICP of
sylva.coreg: Huber weights,
a trimmed tail, a voxel pyramid of 1, 0.5 and 0.25 m) moves the TLS points
onto the ALS points: ground, stems and crowns (refine="all"), the ground
alone ("ground"), or nothing ("none"). A run counts only if it stays
within max_refine_shift (3 m) and max_refine_turn (8°) of its peak;
of those, the one that fits best is kept (the most TLS points within the
last cut-off of an ALS point, the higher peak when two fit within half a
percent). The 3-D fit of ground and crowns separates poses the canopy
models cannot: a TLS canopy model reads the crowns from below and to the
side, so with one or two scan positions its best correlation can lie a few
degrees or metres from the truth (the validation below has cases of 2 m
and 7°), and the ICP from that peak recovers it.
Checks and uncertainty. reg.report() lists what can be checked
without a reference:
TLS to ALS registration
pose heading +35.00 deg, shift x +1.146 y -3.525 z +1.904 m about (48.86, 53.76, 0.60)
search score 0.988 (CHM r 0.983, DTM r 1.000), 11444 cells of 0.5 m, ambiguity 0.07
icp accepted: fitness 0.939, rmse 0.268 m, 187886 pairs, moved 0.034 m and 0.03 deg from peak 1
ground TLS minus ALS DTM: median +0.040 m, NMAD 0.008 m (204735 points)
canopy to the nearest ALS return: median 0.201 m, 90 % 0.353 m (195001 points)
std error x 0.008 m, y 0.010 m, z 0.001 m, heading 0.017 deg
- ambiguity is the misfit (1 minus the score) of the best peak over that of the second: 0 for a clear answer, near 1 when another pose fits about as well (a warning is printed above 0.8). It is high in uniform stands, where the canopy repeats itself.
- ground compares the TLS ground points with the ALS DTM: the median is the vertical offset left (here the 4 cm by which a DTM of the lowest return per cell sits below the surface), the NMAD its spread.
- canopy is the distance of TLS canopy points to the nearest ALS return.
- std error is the larger, for each of x, y, z and the heading, of two
estimates. The formal one,
sigma² H⁻¹from the ICP's point-to-plane information (reg.covariance, 6 x 6 about the pivot), treats every residual as independent and is a lower bound. The jackknife (reg.jackknife) runs the ICP four more times, each with one quadrant of the plot left out, and shows how much the answer depends on which part of the plot is used.
Everything is computed about a pivot near the plot (the centre of its ground), so map coordinates of any size lose no precision; the shifts and uncertainties refer to that pivot. The TLS is thinned to a tenth of a fine cell first, so plots of tens of millions of points take seconds: a plot of 20.6 million points was registered onto 9.6 million ALS returns in 8 s on eight cores.
Two settings matter most in practice:
- The search window. For a plot in a scanner frame, give its centre to
within
search_radiusininitialand leaveheading_rangeat 180 (a full turn). For a georeferenced plot,search_radiusa few metres andheading_rangea few degrees (withheading_stepof 1) are faster and leave less room for a wrong peak. - The plot's extent. Crop the TLS to the plot (the stand the scanners
saw well) rather than every distant return; at least half of its canopy
cells must fall on ALS data (
min_overlap).
Tree linking¶
fusion.link_trees links the TLS trees (stem positions, DBH, heights,
volumes) to the ALS trees (tops, heights, crown outlines, from
als.segment_trees or als.find_trees). A stem and an
ALS tree can be one tree when the stem lies inside the crown (or within
crown_buffer, 0.5 m, of its outline) or within max_distance (3 m) of
the top. Among these pairs an optimal assignment (Kuhn 1955; Munkres 1957)
minimises the total cost, a stem left without an ALS tree costing
max_cost (2). A pair costs
(d / D)² + height_weight * dh² + dbh_weight * (1 - dbh / dbh_max)²
with d the stem-to-top distance, D the larger of max_distance and
the crown's equivalent radius, dh = (h_als - h_tls) / h_als (0.3 for a
stem without a TLS height), and dbh_max the largest DBH among the stems
that could be that tree (both weights 2). The stem that is tallest by the
TLS and thickest under a crown is therefore its tree, as the dominant tree
of a crown usually is both; the diameter term matters because TLS heights
are unreliable exactly where it matters, in the upper crowns. Each ALS
tree gets at most one stem.
The stems under a crown that are not its tree are reported explicitly:
| Status | Meaning |
|---|---|
matched |
the stem is this ALS tree |
suppressed |
under the crown of a taller ALS tree (by more than top_tolerance, 1 m, and 10 %): an understorey or overtopped tree the ALS cannot see |
codominant |
under an ALS crown whose tree is another stem of about the same height: the ALS saw two canopy trees as one |
unlinked |
under no ALS crown and near no top |
links.one_to_many() gives each ALS crown that holds two stems or more,
with the TLS ids under it (its own stem first), and links.als_table()
every ALS tree with its stems.
The combined table. links.table() has one row per TLS tree: its DBH,
TLS height and volume, its status and ALS tree, and a combined height
with its height_source and a flag:
- a matched tree takes the ALS height, which sees the top from above; unless the TLS is known to have seen the top too and measured it taller (both are lower bounds, but a TLS height can also be too tall where the segmentation gave a tree part of a neighbour's crown);
- other trees keep the TLS height, flagged
top_not_seenwhen the TLS did not see the top, so that the height is a lower bound.
Whether the TLS saw a top comes from top_seen, or from sampling, the
output of voxels.tree_sampling: a top
counts as seen when above_observed_fraction, the share of the space just
above the tree's highest point that the scan observed, is at least
min_above_observed (0.5). Without either, a matched tree's top counts as
seen when its TLS height reaches the ALS height within top_tolerance.
g = voxels.ray_voxelize(shots, 0.25, dtm=dtm, occlusion=True)
sampling = voxels.tree_sampling(g, tls, labels)
links = fusion.link_trees(stems, als_trees, reg, volumes=qsms, sampling=sampling)
links.one_to_many() # {als id: [tls ids]}
One cloud, one profile¶
fusion.merge_clouds(tls, als, dtm, split) keeps the TLS points below a
split height above ground and the ALS returns above it, and marks each
point's source (1 TLS, 2 ALS) and height. The split is a height, a
raster of heights (one per column), or a fused profile, whose
split_height is the lowest height above which the ALS carries at least
half the weight.
fusion.fuse_profiles(tls_grid, als_grid, dtm, area) combines the
ray-traced voxels of both instruments (a TLS
RayVoxelGrid and an ALS
ALSVoxels) into one plant area density
(PAD) profile over the same area (a box, a circle or a polygon). Each grid
is summarised per bin of height above the DTM: the PAD of the bin, the mean
number of pulses entering its voxels (voxels no pulse reached count 0, so
occlusion lowers it) and the share of its voxels crossed by at least
min_beams (5) pulses. Each bin then takes its PAD from both instruments,
weighted by
"beams"(the default): the pulses, since the variance of a gap-fraction estimate falls in proportion to the pulses that sampled it;"observed": the share of the bin each instrument saw;"best": the one with more pulses alone.
The weights are part of the result (weight_tls, weight_als, and each
instrument's own profile in tls and als). The TLS pulses must first be
moved into the survey's frame, with the registration, and both grids should
share the voxel size:
g_tls = voxels.ray_voxelize(shots.transform(reg.transform), 1.0, box, ground_class=2)
g_als = als.ray_voxelize(cat, traj, voxel_size=1.0, z_range=(zmin, zmax))
prof = fusion.fuse_profiles(g_tls, g_als, dtm, area=(x0, y0, 15.0))
print(prof.pai("tls"), prof.pai("als"), prof.pai())
prof.table() # height, pad, weights, and each instrument's profile
The estimator. By default a bin's PAD is the mean of the voxels' own
estimates (estimator="mean", the field pad_fpl), over the voxels
observed, so a crown and the gap beside it count by their volume. The
alternative, "pooled", divides the bin's weighted hits by its free path
length; that ratio of sums is not biased upwards by poorly sampled voxels,
but it weights each voxel by the pulses that reached it, and in a clumped
canopy the shadowed crowns receive fewer pulses than the gaps between them.
On an open stand of 90 trees like those of the validation below, the
pooled estimate read the plant area 36 % low from the TLS and 25 % low
from the ALS, the mean 4 % low and within 1 %; use "pooled" only for
horizontally uniform layers.
Upscaling¶
The area-based approach (Næsset 2002) relates plot values measured on the ground to ALS metrics of the same plots, and predicts across the survey from a raster of the same metrics:
values = [fusion.plot_values(plot_trees, area=706.9, wood_density=600.0) for plot_trees in plots]
agb = np.array([v["agb"] for v in values]) # Mg/ha
pm = als.plot_metrics(cat, centres, radius=15.0, metrics=["zq95", "cover"], min_height=0.0)
grid = als.grid_metrics(cat, 25.0, ["zq95", "cover"], min_height=0.0)
up = fusion.upscale(agb, pm, grid, ["zq95", "cover"], model="loglog")
print(up.model.summary())
up.mean.to_geotiff("agb.tif"); up.se.to_geotiff("agb_se.tif")
fusion.plot_valuessums a plot's trees per hectare: above-ground biomass (wood volume, e.g. from QSMs, times the wood density; there is no default density), volume, basal area and stem density, above an inventorymin_dbh.fusion.fit_modelfits ordinary least squares,"loglog"(`ln y = b0- Σ bk ln xk
, a power law, the usual form for biomass) or"linear". Log-log predictions are taken back asexp(ŷ + σ²/2)` (Baskerville 1972), which estimates the mean rather than the median. - Leave-one-out predictions come from the hat matrix in closed form
(equal to refitting without each plot, which the tests check), and give
the RMSE, bias and R² a new plot can expect (
model.loo_rmse,loo_bias,loo_r2,loo_rrmse). Prefer them to the in-sample R² when choosing predictors. fusion.upscalepredicts every cell: the mean, the standard error of a new observation,σ √(1 + x₀ᵀ (XᵀX)⁻¹ x₀), and the Student t interval atlevel(for log-log, the interval ofln ytaken back and the standard error of the log-normal distribution it implies). Cells whose metrics lie outside the range of the plots are marked inup.extrapolated.
Keep the model small: one or two metrics for ten or twenty plots, since
each costs a degree of freedom and correlated metrics (zq95 and zmax)
add little; and compute the grid at cells of the plots' area, since height
percentiles depend on the area they summarise.
Synthetic scenes seen by both¶
fusion.synthetic_scan is a terrestrial scanner for the synthetic scenes
that sees them as synthetic.als_flight
does: every scene point is a sphere of target_radius and the ground is
the analytic terrain, and each pulse is a thin ray that stops at the first
sphere or the terrain. A layer of spheres of n per m³ is then a turbid
medium of plant area density 2 π r² n for both scanners, so the TLS and
ALS estimates can be checked against the same known foliage.
(synthetic.scan instead lets any point in a
pulse's angular cell be hit, which suits geometry but not densities.)
A worked example¶
One forest, flown over at about 95 returns per m² and scanned from five positions; the TLS is given in a frame turned by 35° whose centre is known to within 4 m.
import numpy as np
from sylva import PointCloud, Shots, als, filters, fusion, ground, registration, synthetic, trees
stand = synthetic.stand(90, size=100.0, min_spacing=3.0, heights=(8, 25), seed=3)
scene = synthetic.crown_forest(stand, size=100.0, ground_points=100, margin=0.0, seed=3)
flight = synthetic.als_flight(scene, pulse_rate=20_000, line_spacing=30.0, bounds=(0, 0, 100, 100), seed=3)
origins = [(x, y, float(synthetic.terrain_height(x, y)) + 1.5)
for x, y in [(50, 50), (38, 38), (62, 38), (38, 62), (62, 62)]]
shots = fusion.synthetic_scan(scene, origins, resolution_deg=0.2)
truth = registration.rotation_z(35.0)
truth[:3, 3] = [53.1, 47.8, 1.9]
local = Shots.concatenate(shots).transform(np.linalg.inv(truth)) # what the scanners recorded
tls = local.to_pointcloud()
c = np.linalg.inv(truth) @ [50, 50, 0, 1]
tls = tls[np.hypot(tls.x - c[0], tls.y - c[1]) < 30] # the plot
reg = fusion.register(tls, flight.points, registration.translation(50, 50, 0), search_radius=8.0)
err = reg.transform @ np.linalg.inv(truth)
print((err @ [50, 50, 0, 1])[:3] - [50, 50, 0]) # [-0.002 0.008 0.004]
tl = filters.voxel_downsample(tls, 0.02)
tl = ground.normalize_height(tl, ground.make_dtm(tl, 0.5))
stems = trees.detect_stems(tl)
stems, _ = trees.merge_branches(tl, stems)
labels = trees.segment_trees(tl, stems)
trees.tree_heights(tl, labels, stems)
stems, labels = trees.prune_trees(stems, labels, min_height=3.0)
pts = flight.points
dtm = ground.make_dtm(pts, 1.0)
tops = als.segment_trees(pts, heights=ground.normalize_height(pts, dtm).attrs["height"],
window=als.LinearWindow(0.0, 0.2, 2.0, 20.0), max_cr=20)
links = fusion.link_trees(stems, tops, reg)
print(links) # TreeLinks(32 TLS trees: 20 matched, 10 suppressed, 2 codominant, 0 unlinked)
merged = fusion.merge_clouds(reg.apply(tls), pts, dtm, split=10.0)
The report of this registration is the one shown above; the whole example runs in about 15 s.
Validation¶
All of it on synthetic.crown_forest stands (ellipsoidal crowns of radius
a quarter of the tree height, filled with leaf points; heights 8 to 25 m)
flown by synthetic.als_flight and scanned by fusion.synthetic_scan, so
that the trees, the pose and the plant area are known.
Registration. One hectare with 90 trees, scanned from five positions
at 0.2° (with 1 cm noise added to the TLS points, cropped to 30 m around
the plot centre) and flown at three pulse rates. The local frame was
turned by a random heading (full circle searched) and the plot centre given
to within 4 m; the georeferenced plot was off by up to 3 m and 3° (searched
within 5 m and 5°). Three stands per density; the errors are at the plot
centre after the search alone and after the ICP, and the standard errors
are those reg.std reports.
| ALS returns/m² | Case | Search error: horizontal (m), heading (°) | Final error: horizontal (m), vertical (m), heading (°) | Reported std error x, y (m) |
|---|---|---|---|---|
| 95 | local | 0.01 to 0.06, 0.05 to 0.15 | 0.005 to 0.012, 0.003 to 0.004, 0.001 to 0.015 | 0.003 to 0.009 |
| 95 | georeferenced | 0.04 to 0.06, 0.02 to 0.18 | 0.005 to 0.009, 0.002 to 0.006, 0.004 to 0.015 | 0.004 to 0.009 |
| 10 | local | 0.02 to 0.13, 0.29 to 0.44 | 0.004 to 0.042, 0.000 to 0.003, 0.03 to 0.14 | 0.011 to 0.034 |
| 10 | georeferenced | 0.10 to 0.16, 0.04 to 0.62 | 0.019 to 0.031, 0.000 to 0.003, 0.003 to 0.14 | 0.009 to 0.041 |
| 2 | local | 0.25 to 0.92, 0.28 to 1.19 | 0.052 to 0.067, 0.001 to 0.002, 0.01 to 0.05 | 0.034 to 0.073 |
| 2 | georeferenced | 0.15 to 1.13, 0.10 to 2.72 | 0.069 to 0.145, 0.002 to 0.005, 0.03 to 0.25 | 0.030 to 0.110 |
The ICP brings every case to within 15 cm, and to a centimetre at dense
point spacing; the reported standard errors are of the size of the actual
errors, within a factor of about three either way. On smaller stands (0.36
ha, 30 trees, two scan positions at 0.4°, three stands, the TLS cropped to
18 m and to 25 m), the search alone was 1.5 to 2.7 m and 2 to 7.6° off in
all six runs, the TLS canopy model being read from so few positions; the
ICP from the peaks still ended within 1.5 cm in all six. The ground-only
ICP (refine="ground") fixes the height and tilt but can drift sideways on
gentle terrain (6 cm and 29 cm in two runs on the first stand above), so it
is best kept for bare plots.
Tree linking. Three such stands, registered as above, with the TLS
trees from trees.detect_stems, merge_branches, segment_trees,
tree_heights and prune_trees, and the ALS trees from
als.segment_trees (Dalponte 2016, a window of a fifth of the height,
max_cr=20). Of the 44 TLS trees within 25 m of the plot centres, 30 had
their own ALS tree (the others are overtopped and form no local maximum in
the canopy surface):
| Count | Right | |
|---|---|---|
| TLS trees linked to their own ALS tree | 30 of 30 | 30 |
| Links to another tree's ALS crown | 0 | |
Reported suppressed or codominant |
13 | 13 (none had its own ALS tree) |
unlinked |
1 |
The heights of the linked trees against the truth: the TLS alone had a mean error of -1.19 m and an RMSE of 3.46 m (its segmentation gives crowns wrongly in these closed canopies, and misses tops); the combined height a mean error of +0.03 m and an RMSE of 0.07 m. The TLS DBH was within 1.5 cm (RMSE).
Profiles. The same kind of stands, of leaves alone (the stems of the scene are points on a surface, not a turbid medium, and are left out of the truth), their true PAD from the number of spheres per height bin; voxels of 1 m, PAD by height within 15 m of the plot centre. PAI and the RMSE of the PAD bins against the truth:
| Stand | True PAI | TLS (five scans) | ALS (7 sub-beams) | Fused, beams | Fused, observed | Fused, best |
|---|---|---|---|---|---|---|
| open, 90 trees, seed 1 | 0.42 | 0.42 (0 %, 0.001) | 0.47 (+12 %, 0.004) | 0.42 (0 %, 0.001) | 0.44 (+6 %, 0.002) | 0.42 (0 %, 0.001) |
| open, 90 trees, seed 2 | 0.77 | 0.76 (-1 %, 0.001) | 0.79 (+3 %, 0.003) | 0.76 (-1 %, 0.001) | 0.77 (+1 %, 0.002) | 0.76 (-1 %, 0.001) |
| dense, 200 trees, seed 1 | 5.80 | 5.57 (-4 %, 0.017) | 4.70 (-19 %, 0.091) | 5.52 (-5 %, 0.018) | 5.16 (-11 %, 0.049) | 5.57 (-4 %, 0.017) |
| dense, 200 trees, seed 2 | 5.67 | 5.40 (-5 %, 0.022) | 4.42 (-22 %, 0.092) | 5.37 (-5 %, 0.023) | 4.93 (-13 %, 0.053) | 5.40 (-5 %, 0.022) |
| dense, with stems | 6.01 | 5.66 (-6 %, 0.020) | 4.73 (-21 %, 0.083) | 5.61 (-7 %, 0.021) | 5.22 (-13 %, 0.046) | 5.66 (-6 %, 0.020) |
| dense, one TLS scan, 20 m | 6.01 | 5.06 (-16 %, 0.061) | 4.97 (-17 %, 0.073) | 5.16 (-14 %, 0.052) | 5.06 (-16 %, 0.057) | 5.10 (-15 %, 0.060) |
| the same, ALS one ray per pulse | 6.01 | 5.06 (-16 %, 0.061) | 4.90 (-18 %, 0.105) | 5.21 (-13 %, 0.049) | 5.09 (-15 %, 0.061) | 5.12 (-15 %, 0.061) |
With five scan positions the TLS samples every bin with hundreds to thousands of pulses per voxel, the pulse weighting follows it, and the fused profile is as good as the better instrument. From a single scan position the TLS reads the upper crowns low (by 30 % at 18 m and by more than half above 21 m: it sees them through the lower ones) while the ALS reads them 13 to 16 % low; there the ALS takes 37 to 51 % of the weight above 18 m, and the fused profile has the lowest RMSE of the three. The ALS reads the lower half of dense crowns 30 to 55 % low, because the voxels deep in a crown are reached by few pulses from above. None of the weightings can remove a bias both instruments share.
Upscaling. A 4 ha landscape of 16 stands of 50 m with 8 to 50 trees
and height ranges between 6 and 26 m, flown at about 20 returns per m²;
15 plots of 625 m² at random, their biomass from the true trees (a stem
volume of half the cylinder of DBH and height, times 600 kg/m³), fitted on
the metrics of als.plot_metrics and predicted on a 25 m grid of
als.grid_metrics; then compared with the true biomass of every cell.
| Stand | Model | LOO RMSE (plots) | Cells: RMSE, bias (Mg/ha) | Cells: R² | 95 % interval covers |
|---|---|---|---|---|---|
| 1 | loglog, zq95 | 42 % | 29.5 (69 %), +9.9 | 0.24 | 82 % |
| 1 | loglog, zq95 + cover | 12 % | 9.4 (22 %), +3.1 | 0.92 | 77 % |
| 1 | linear, zmean | 25 % | 17.1 (40 %), +3.5 | 0.74 | 97 % |
| 2 | loglog, zq95 | 72 % | 23.4 (54 %), +6.1 | 0.25 | 100 % |
| 2 | loglog, zq95 + cover | 32 % | 8.2 (19 %), +0.1 | 0.91 | 95 % |
| 2 | linear, zmean | 43 % | 12.8 (29 %), 0.0 | 0.78 | 100 % |
| 3 | loglog, zq95 | 46 % | 15.3 (40 %), -4.0 | 0.61 | 91 % |
| 3 | loglog, zq95 + cover | 30 % | 7.0 (17 %), -0.8 | 0.91 | 98 % |
| 3 | linear, zmean | 25 % | 7.7 (20 %), -2.4 | 0.90 | 99 % |
Height alone cannot tell a sparse stand of tall trees from a dense one; height and cover together recover the landscape's biomass cell by cell (R² 0.91 to 0.92), and the leave-one-out RMSE of the plots ranks the models as the cells do. With 15 plots the intervals cover 77 to 100 % of the cells. On an exact power law the fit recovers the coefficients to 1e-9, and with noise its leave-one-out predictions, coefficients and standard errors equal refitting and NumPy's least squares (tests).
Command line¶
sylva fusion-register plot.laz tiles/ reg.json --initial gnss.txt --search-radius 10 \
--transformed plot_map.laz
sylva fusion-trees tls_trees.csv als_trees.csv linked.csv --crowns crowns.geojson \
--registration reg.json --als-output als_linked.csv
sylva fusion-upscale plots.csv metrics/ agb/ --response agb --predictors zq95,cover
fusion-register reads the TLS cloud (ground classified) and a directory
of ALS tiles or one file, writes the registration as JSON (and with
--transformed the moved cloud) and prints the report; --initial is a
text file of the 4 x 4 matrix. fusion-trees links a CSV of TLS trees
(x, y and optionally tree_id, dbh, height, volume) to the CSV
and GeoJSON written by als-trees. fusion-upscale reads a CSV of plots
(the value and the metrics, e.g. the output of als-plot-metrics with a
column added) and the rasters of als-metrics, and writes the prediction
rasters. See Command line.
Limitations¶
- The search needs canopy or terrain structure: in a uniform plantation
or on flat ground under a closed, even canopy, poses a row apart score
alike (
ambiguitynear 1); give a tightersearch_radiusthere. - Only a rigid transform is estimated; a TLS project that is itself deformed (a poorly registered multi-scan plot) will not fit everywhere.
- The formal covariance is a lower bound, and the jackknife over four quadrants is a coarse estimate; neither includes an error the ALS itself carries (its own georeferencing).
- Tree linking can only be as good as the two segmentations: ALS crowns
that merge trees make
codominantstems, and a TLS tree whose height is badly wrong can be leftsuppressedunder its own crown. - The profiles weight by sampling, not by bias: where both instruments underestimate a layer (the lower half of dense crowns from the air, the upper crowns from a single scan position) the fused profile does too.
- The upscaling models are deliberately simple (ordinary least squares, no variable selection, no spatial correlation); the prediction interval is that of a single cell, not of a total over many cells.