Change between airborne surveys¶
sylva.change compares two airborne lidar surveys of one area as it
compares two terrestrial epochs (Change detection): every
change carries its level of detection, and what the data cannot support
is labelled (below detection, no data, uncertain, undetected, unobserved)
rather than reported. The surveys are catalogues of tiles
(Airborne lidar tiles); every function runs through the chunk
engine, reading the same buffered box from both surveys, so any area can be
processed and the results do not depend on how it is divided.
| Function | Compares | Result |
|---|---|---|
align_surveys(a, b) |
stable surfaces (ground, roofs, roads) | offsets (dx, dy, dz) per block, as one offset or a smooth field, with their uncertainty |
chm_change, dtm_change, surface_change |
the highest return per cell, or the terrain | difference, level of detection per cell, gain / loss / below detection / no data, sensor notes |
harmonise(a, b, out_a, out_b) |
pulse densities | both surveys thinned to a common pulse density, as tiles |
canopy_gaps, gap_change |
CHMs | gaps with outlines; formation and closure; size distributions |
tree_change(trees_a, trees_b, chm) |
trees of als.find_trees |
per-tree height growth with its level of detection; survivors, damaged, dead, recruits |
metric_change(a, b) |
als.grid_metrics |
metric differences with a level of detection per cell |
pai_change(profile_a, profile_b) |
als.gap_profile |
PAI and PAD change with their uncertainty |
A worked example¶
synthetic.als_epochs flies a synthetic forest
twice with known changes: trees felled singly, eight felled together to
open a gap, the trees of the west half grown by 1 m, a second sensor flown
higher and sparser, and the second survey delivered displaced by
(0.3, -0.2, 0.15) m. Two gabled buildings east of the forest give roofs
of four aspects.
from sylva import als, change, synthetic
ep = synthetic.als_epochs(n_trees=230, gap=(70.0, 70.0, 8), seed=1)
a, b = ep.write_tiles("2019", "2024", size=50.0, epsg=32755) # 370,785 and 204,161 returns
al = change.align_surveys(a, b, block_size=60.0, stable_classes=(2, 6))
chm = change.chm_change(a, b, resolution=2.0, alignment=al)
gaps = change.gap_change(chm, height=2.0, min_area=10.0, years=5)
ha, hb = change.harmonise(a, b, "2019_h", "2024_h") # a common pulse density
kw = dict(resolution=1.0, window=als.LinearWindow(0.0, 0.2, 3.0, 20.0), max_cr=20, hmin=5.0)
trees = change.tree_change(als.find_trees(ha, **kw), als.find_trees(hb, **kw),
change.chm_change(a, b, 1.0, alignment=al, harmonise=True),
alignment=al)
metrics = change.metric_change(a, b, 20.0, metrics=["zq95", "cover"], alignment=al,
harmonise=True)
print(al.report(), chm.report(), gaps.report(), trees.report(years=5), sep="\n")
ALS alignment (field): constant offset dx +0.296 ± 0.023, dy -0.198 ± 0.015, dz +0.150 ± 0.001 m
blocks 4 of 6 of 60 m with an estimate; dx fixed by the data in 1, dy in 4
spread 0.023 m before, 0.020 m after (median over blocks, robust)
Birge 1.00, 1.00, 1.00 (x, y, z)
noise 0.020 m (a), 0.020 m (b)
CHM change at 2 m: 14,400 m² assessed, 240 m² no data
gain 3,808 m² (26.4 %)
loss 400 m² (2.8 %)
below_detection 10,192 m² (70.8 %)
level of detection median 0.57 m (5 to 95 %: 0.02 to 2.05)
volume +4,338 / -4,297 m³ over the significant cells
survey a 12.6 pulses/m², 2.06 returns per pulse, mean |scan angle| 14.3 deg
survey b 5.6 pulses/m², 2.56 returns per pulse, mean |scan angle| 13.8 deg
note: pulse density differs: 12.6 pulses/m² in survey a and 5.6 in survey b ...
note: returns per pulse differ (2.06 in survey a, 2.56 in survey b; ...
Gaps (at most 2 m high, at least 10 m²): 10 in survey a (27.6 %), 9 in survey b (29.0 %)
formed 200 m² (gaps new: 1, expanded: 1)
...
The offset is recovered to 4, 2 and 0.1 mm, each within its standard
deviation; the ground alone (a slope of one aspect in x) would have fixed
dy but left dx to its prior, and dz with it (see below). The gap formed
covers 200 m², as the true canopy does; the cells that crossed the 2 m
threshold without a significant change (120 m²) are uncertain. The
survivors grew by +0.62 ± 0.06 m on average (true +0.56 m), though only 12
of 86 individually beyond their level of detection of about 1 m. The
example runs in about 10 s.
Aligning the surveys¶
Two surveys are never quite in one frame: GNSS/INS trajectories drift by
centimetres to decimetres between flights, and a vertical offset of 10 cm
over a hectare is 1,000 m³ of spurious change in a DTM difference.
align_surveys estimates the offsets of the second survey on surfaces that
do not change: ground (class 2) and, with stable_classes=(2, 6, 11),
roofs and roads.
Every stable return of the second survey (one per sample_spacing square)
is compared with a plane fitted to the first survey's stable returns within
radius of it. A survey displaced by (dx, dy, dz) stands
dz - gx dx - gy dy above a plane of gradient (gx, gy), so the vertical
offset comes from every sample and the horizontal ones from the variety of
slopes and aspects, as in the co-registration of elevation models of Nuth
and Kääb (2011). Each block of block_size m is solved by iteratively
reweighted least squares (Huber 1964 weights), refitting the planes at the
moved positions until the offsets settle; samples within
correlation_length of each other count as one independent sample, since
neighbouring planes share returns and surveys err in patches.
Two things keep the uncertainty honest:
- Noisy gradients. A plane fitted to a few returns has a noisy gradient, and noise in a regressor both pretends to fix a horizontal offset and biases it towards zero. The moment matrix is corrected by the gradients' own covariance (the correction for errors in variables; Fuller 1987), and a direction whose remaining information is within the sampling fluctuation of that correction is given none.
- Undetermined directions. On flat ground, or on a slope of one
aspect, a horizontal shift along the slope looks exactly like a vertical
one. A Gaussian prior of
horizontal_prior(0.3 m) keeps such a horizontal offset near zero, its standard deviation stays near the prior, and the vertical offset absorbs the part the data fix (dz - gx dx), with an uncertainty that says so. The report counts the blocks whose data fixed each horizontal offset.
The blocks are then combined (model): "constant" pools the information
of all blocks into one offset, "blocks" keeps each block's own (blocks
without enough samples take the constant one), and "field" (the default)
pools the blocks at each block centre with Gaussian weights of smoothing
m and interpolates bilinearly between centres, for surveys whose offset
drifts along a flight. Either way the uncertainty is widened by the Birge
ratio of the blocks about the model, so that blocks disagreeing more than
their uncertainties allow widen the uncertainty rather than being averaged
away. al.table() and al.to_csv() give each block's estimate, its
standard deviation and its residual from the model; al.offset_at(x, y),
al.sigma_at(x, y) and al.apply(cloud) use the model, and
ALSAlignment.constant(offset, sigma) states a known one. Blocks are
chunked whole, so each estimate uses exactly the returns in and around its
block whatever chunk_size and workers are.
Surface change¶
chm_change(a, b, resolution) (and dtm_change, or surface_change with
surface="dsm") grids both surveys in one pass with one algorithm, the
highest return per cell, each normalised by a DTM of its own ground returns
(dtm_method="plane": at each DTM cell a plane fitted to the ground
returns around it, with its standard error). subcircle replaces each
return by eight points on a circle, and
first_returns=True grids first returns only.
Sampling. The highest of a cell's returns is a sample of its canopy: it
falls short of the top by an amount that depends on the number of pulses
and on how the canopy's heights are spread in the cell, and at a crown's
edge one survey may hit the crown where the other hits only ground. Both
effects are read from the data. Within a cell each pulse contributes its
highest return there. If the canopy did not change, the pulses of the two
surveys are samples of one surface, and every split of the pooled values
into the two surveys' numbers of pulses is equally likely. The distribution
of max(b) - max(a) over those splits follows exactly from the order
statistics of the pooled values (if the highest value falls in the first
survey, with probability n_a / N, the second survey's highest is v_j
with probability C(j - 1, n_b - 1) / C(N - 1, n_b), and the other way
round): a permutation test on the maxima (Pitman 1937). Its mean is the
change sampling alone gives (bias: negative where the second survey is
sparser), and its central confidence interval bounds the change expected
without any.
Other errors. Each survey's DTM under the cell errs by the standard
error of its ground plane plus interpolation_error times the distance to
the nearest ground return; the alignment errs horizontally (its standard
deviation times the surface's gradient) and, for DSMs and DTMs, vertically
(a CHM is normalised by each survey's own ground, which cancels a vertical
offset). The permutation interval is widened by these, taken as normal and
independent of the sampling, to a half-width lod = sqrt(h² + (z s)²),
with h its sampling half-width and s their combined standard deviation.
A change above the interval [lower, upper] is a gain, below it a
loss, within it below_detection; a cell with fewer than min_returns
pulses in either survey is no_data. The DoD of the significant cells
(chm.dod: volumes and areas, as change.dod
gives them) and the rasters (chm.write("out/")) follow.
Choosing the resolution. The test needs pulses: a cell with 3 pulses of the sparser survey cannot tell whether it missed a sparse crown by chance. On the synthetic surveys (12.5 and 5.5 pulses/m²), 87 % of the cells whose canopy fell by more than 3 m were a significant loss at 2 m, and 64 % at 1 m. A cell should hold about 20 pulses of the sparser survey.
Scan patterns. The permutation assumes that a survey's pulses fall at
random places in a cell. Surveys are planned with pulses about as far
apart along the scan as along the track, and the test then holds: with the
same sensor flown twice over an unchanged canopy, 2.6 to 4 % of the cells
were called changed at 95 % confidence. A pattern with pulses 15 times
farther apart across the track than along it (the default scan_rate of
synthetic.als_flight at 4,000 pulses/s) puts a cell's pulses in one or two
lines, and 11 % of the cells were then called changed: the pulses are not
independent samples of the cell.
Sensor differences and harmonisation¶
A difference is change only if both surveys measured the canopy the same
way (Næsset 2009; Disney et al. 2010). The permutation covers the pulse
density, which is the largest effect on a CHM: the highest of fewer returns
falls shorter of the top, so a sparser second survey reads as canopy loss.
chm.bias is that expected change per cell and chm.median_bias its
median over the canopy. Other differences are not sampling and are not
covered. A wider footprint or a more sensitive receiver triggers first
returns higher in a crown and records more returns per pulse; a different
scan angle sees crown sides and gaps differently. chm.sensors compares
the surveys (pulse density, returns per pulse, share of single-return
pulses, mean absolute scan angle, most returns per pulse) and chm.notes
names every difference large enough to bias the comparison.
harmonise=True thins the denser survey, pulse by pulse, to the other's
pulse density in each density_cell square: in a square where it has more
first returns, each of its pulses is kept with probability
n_other / n_own, chosen by a hash of the pulse (GPS time and flight line)
and seed, so every return of a pulse shares its fate and the choice does
not depend on the chunks. change.harmonise(a, b, out_a, out_b) writes the
same thinning as tiles, for tree detection or anything else run on both
surveys. On the synthetic surveys the median CHM difference over unchanged
canopy was -0.07 m without harmonisation (the density effect) and +0.04 m
with it (what remains is the second sensor's wider footprint); harmonising
costs detection power, since the denser survey loses pulses.
Gaps¶
canopy_gaps(chm, height, min_area) finds the gaps of one CHM as
ForestGapR's getForestGaps does (Silva et al. 2019): connected cells
(across edges and corners, connectivity=8) no higher than height, with
an area between min_area and max_area. The default 2 m follows Brokaw's
(1982) definition of a gap as an opening reaching down to within 2 m of
the ground. Each gap has its area, centre, heights and outline along cell
edges (a polygon per part, with holes; to_geojson), and
size_exponent() fits a power law to the sizes by maximum likelihood
(Clauset et al. 2009), as gap sizes often follow one (Fisher et al. 2008;
Asner et al. 2013).
gap_change(chm_change) finds the gaps of both surveys and classes every
cell: formed (in a gap of the second survey only, with a significant
loss), closed (in a gap of the first only, with a significant gain),
stable_gap, canopy, no_data, and uncertain where a cell crossed the
threshold without a significant change. This is ForestGapR's
GapChangeDec with a level of detection: without one, every cell whose
canopy moved across the threshold by noise alone would count. Each gap of
the second survey is new, expanded, stable or uncertain, each of the
first closed, shrunk, stable or uncertain, with its area formed or
closed; summary() gives gap fractions, areas, annual formation and
closure rates and the size exponents.
Trees¶
tree_change(trees_a, trees_b, chm) takes the trees of
als.find_trees in each survey and the CHM change of the
same surveys (at the resolution the trees were found at). The trees are
matched by the optimal assignment of
change.match_trees (Kuhn 1955; Munkres 1957),
the tree height standing in for the diameter: a pair must lie within
max_distance (1.5 m) and its height may grow by at most max_growth
(30 %) or fall by at most max_drop (20 %) of the larger height. The CHM
change says what became of the rest:
| Status | Survey | When |
|---|---|---|
survivor |
both | matched |
damaged |
both | matched, with a significant loss over damage_fraction (30 %) of the crown or a height fall beyond its level of detection; or unmatched, with a tree of the second survey standing on significantly lowered canopy within 2 max_distance of its top (a broken top) |
dead |
first | unmatched, with a significant loss over dead_fraction (50 %) of its crown |
recruit |
second | unmatched, its top on a significant gain from below 1 - max_growth of its height |
released |
second | unmatched, its top on a significant loss: an understorey tree exposed by a neighbour's fall |
undetected |
either | unmatched, without such a change: the canopy is still there, but no tree was found on it in the other survey |
unobserved |
either | less than min_observed (50 %) of the crown has data in both surveys |
A height is the value of the top's CHM cell, and its standard deviation
that cell's in the CHM change (sigma_a, sigma_b: the spread of its
highest pulse under the permutation, and the DTM's error); a height change
beyond 1.96 times their combination is growth or decrease, otherwise
below_detection. summary(area, years) gives the counts, the mean height
growth of the survivors with its standard error (from their scatter, and
from their measurement uncertainty alone), the crown area of the dead and
lost from the damaged, and annual mortality and recruitment rates (Sheil
et al. 1995); grid(resolution) the same per cell, to_csv and
to_pandas the table.
Which trees can be compared depends on tree detection, which misses
overtopped trees and splits crowns at low pulse densities (see
Airborne trees). A top found in one survey only
is undetected, not a death or a recruit; expect many of them where
detection is poor, and compare the counts with that in mind. Detect the
trees of both surveys at a common pulse density (change.harmonise) so
that they are found alike.
Area-based metrics and plant area index¶
metric_change(a, b, resolution, metrics) computes the metrics of
als.grid_metrics for both surveys in one pass (heights
above each survey's own DTM, the second moved by alignment, optionally
harmonised) and tests each difference as the CHM is tested: if nothing
changed, the pulses of both surveys that fell in one stratum m square (2
m) sample the same canopy, and any reassignment of them between the
surveys that keeps each survey's number of pulses in each square is as
likely as the observed one. The mean and central interval of
metric(b) - metric(a) over permutations (100) reassignments, drawn from
a stream seeded by the cell and seed, give bias, [lower, upper] and
the class. Keeping pulses in their squares keeps the pattern in which each
survey sampled the cell, which a resampling of the cell's pulses as
independent draws would ignore.
The test covers sampling only. With thousands of returns per cell, the sensor differences above are significant: on the synthetic surveys the second sensor's wider footprint raised the mean height of unchanged cells by 0.7 m and their cover by 3 %, and harmonising the pulse density does not remove it. Metrics compared across sensors measure the sensors as well as the forest.
pai_change(profile_a, profile_b) compares two
als.gap_profile results on one grid and set of layers
(give both the same bounds, resolution, bin_size, min_height and
max_height), each made with its own survey's trajectory. With W pulses
in a cell, a share P reaching the ground and a mean extinction k,
PAI = -ln P / k (MacArthur and Horn 1969), and its standard deviation
from the binomial variance of P is sqrt((1 - P) / (W P)) / k. A cell no
pulse crossed is saturated: its PAI is only a lower bound. profile and
profile_change(profile_a, profile_b, mask) compare the pooled plant area
density profile of an area layer by layer in the same way. The profiles are
not moved by an alignment: use cells much larger than the horizontal
offset.
Chunks and workers¶
Every result is computed cell by cell from the returns near the cell, in a
fixed order, with interpolation weights taken from the position on the
whole catalogue's grid, so the number of workers never changes it, and
neither, in practice, does the chunk size: across tile-sized chunks and 17,
23, 30 and 45 m chunks on one to four workers, the alignment blocks, the
classes and nearly every value were identical, and the few values that
differed (a handful of cells) did so in the last bits. The buffer is raised
automatically to hold whole cells and the ground planes of the DTM under
them (up to 16 m); with harmonise, also a density_cell.
On 36 ha (the synthetic pair copied 5 by 5: 9.3 and 5.1 million returns in 36 and 48 tiles of 100 m), on eight threads, the alignment took 7.5 s, the CHM change 8 s (harmonised, 8 s), the metric change of three metrics with 100 permutations per cell 41 s, and writing the harmonised tiles 14 s.
Command line¶
sylva als-change 2019/ 2024/ change/ --align --stable-classes 2,6 --harmonise \
--resolution 2 --gaps --gap-min-area 10 --years 5
sylva als-tree-change 2019/ 2024/ trees.csv --align --harmonise --resolution 1 \
--window-linear 0 0.2 3 20 --hmin 5 --grid totals/ --grid-resolution 50 --years 5
als-change writes <surface>_a, _b, _difference, _lod and
_classes rasters (--format asc or tif), the alignment's blocks
(alignment.csv) and, with --gaps, the gaps of both surveys with their
status as GeoJSON, and prints the reports. als-tree-change finds the
trees of both surveys (on harmonised copies with --harmonise), compares
them and writes one CSV row per tree, and with --grid the per-cell totals
as rasters. Both take --pattern, --chunk-size, --buffer and
--workers.
Validation¶
synthetic.als_epochs (100 m of forest, 100 trees of 12 to 25 m with
crowns of closed outline, two buildings) was flown twice per seed, with the
changes of the worked example: 4 trees felled singly, 5 felled to open a
gap, the trees of the west half grown by 1.0 m, the second survey
displaced by (0.3, -0.2, 0.15) m. Three sets: the default sensors
(12.5 and 5.5 pulses/m², the second flown 50 % higher; 5 seeds), the same
sensor twice (5 seeds), and a closed canopy (230 trees, 8 felled for the
gap; 3 seeds). "Unchanged" cells lie more than 1.5 m beyond the crown of
every changed tree; the truth of a cell is the highest point of the scene
above it.
Alignment (model="constant", 60 m blocks):
| Stable surfaces | Error dx, dy, dz (mean abs.) | Standard deviation | Largest error / sd |
|---|---|---|---|
| ground | 0.29, 0.003, 0.014 m | 0.30, 0.019, 0.015 m | 1.0 |
| ground and roofs | 0.005, 0.002, 0.000 m | 0.022, 0.015, 0.001 m | 0.7 |
The terrain slopes uniformly in x, so ground alone leaves dx to the prior
and says so; its dz absorbs the shift along the slope, within its
standard deviation. The roofs fix all three.
CHM change (95 % confidence, aligned unless stated):
| Sensors | Cell | Harmonised | False change, unchanged canopy | Unchanged ground | Canopy fallen > 3 m: loss | Grown 1 m: gain | Median difference, unchanged canopy |
|---|---|---|---|---|---|---|---|
| different | 2 m | no | 3.1 % | 0.4 % | 87 % | 65 % | -0.07 m |
| different | 2 m | yes | 3.0 % | 0.3 % | 85 % | 52 % | +0.04 m |
| different | 2 m, not aligned | no | 6.8 % | 0.3 % | 87 % | 67 % | -0.08 m |
| different | 1 m | no | 1.9 % | 0.9 % | 64 % | 25 % | -0.09 m |
| same | 2 m | no | 4.0 % | 0.7 % | 90 % | 68 % | 0.00 m |
| same | 1 m | no | 2.6 % | 1.2 % | 84 % | 26 % | 0.00 m |
| closed canopy | 2 m | no | 3.8 % | 0.4 % | 96 % | 74 % | -0.07 m |
Without the alignment, the 0.3 m horizontal offset doubles the false changes at crown edges; the DTM difference is then a significant rise over 99 % of the area, and aligned 0.05 % (median |difference| 1.6 mm).
Gaps (closed canopy, 2 m cells): the gap cells formed covered 200,
364 and 204 m² in the true canopy; 90, 91 and 84 % of them were formed,
with 16 to 20 m² formed elsewhere, and 88 to 176 m² uncertain. The gap
at the felled trees was new or expanded in every seed, and the gap
fractions (27 to 29 %) were within 2 points of the true ones.
Trees (dalponte2016 at 1 m, window a fifth of the height; a
detected top is a true tree's when within 1.5 m of its highest point):
| Sensors | Trees found in the first survey | Felled to open the gap: dead |
Felled singly: dead |
Height change error, grown (mean, RMSE) | Unchanged (mean, RMSE) | 1 m growth beyond its LoD | Unchanged below detection | False dead, false recruit |
|---|---|---|---|---|---|---|---|---|
| different | 492 | 14 of 16 | 7 of 14 | -0.07, 0.17 m | -0.07, 0.15 m | 20 % (LoD 1.04 m) | 99 % | 0, 0 |
| different, harmonised | 776 | 10 of 14 | 4 of 14 | +0.04, 0.19 m | +0.02, 0.17 m | 26 % (LoD 1.18 m) | 99 % | 0, 2 |
| same | 492 | 16 of 16 | 11 of 14 | +0.00, 0.11 m | +0.01, 0.10 m | 65 % (LoD 0.72 m) | 100 % | 0, 0 |
| closed canopy, different | 390 | 8 of 9 | 0 of 4 | -0.06, 0.16 m | -0.07, 0.13 m | 17 % (LoD 1.13 m) | 100 % | 0, 0 |
In every set at least 98 % of the height changes lay within their level
of detection of the truth. Harmonising removes the density
bias of the height changes (-0.07 m, the sparser survey's tops falling
shorter) at the cost of more false tops in the thinned survey and so fewer
trees assessed; the felled trees not called dead were undetected
(their crowns overlapped standing neighbours, so less than half of the
crown lost canopy), never survivor. Of the 1,300 or so tops found in
the second survey only (over all 26 comparisons, mostly splits of large
crowns), 3 were called recruit and the rest undetected; no tree grew
in.
Metrics (20 m cells, 100 permutations, 2 m strata): with the same sensor, 12 % (zq95), 3 % (zmean) and 6 % (cover) of the unchanged cells were called changed. With the different sensors, 45 to 70 %: the second sensor's footprint raised zmean by 0.7 m and cover by 3 %, a real difference in what the sensors measure, which harmonising the density does not remove.
tests/test_change_als.py repeats these checks on a smaller pair and
covers the rest: harmonisation in the pass equals harmonised tiles, results
across chunks and workers, analytic gap outlines with holes, PAI change
from counts and from two flights, and invalid input.
Limitations¶
- Offsets only: rotations between surveys are modelled as a smoothly varying offset field, which suits trajectory drift but not a rotated delivery frame.
- The levels of detection cover sampling, DTMs and alignment, not sensor differences; the notes name them, and harmonisation removes only the density effect.
- A cell needs pulses: resolve at about 20 pulses of the sparser survey per cell. Strongly anisotropic scan patterns make the tests anti-conservative.
- Tree-level results inherit the detection of
als.find_trees; unmatched tops are labelledundetectedrather than counted as deaths or recruits. - Gaps, trees and PAI work on the rasters and profiles of the whole area in
memory (as
als.chmreturns them); the point passes are chunked. - PAI profiles are not aligned; they must share their grid and layers.
References¶
Asner, G. P., Kellner, J. R., Kennedy-Bowdoin, T., Knapp, D. E., Anderson, C., & Martin, R. E. (2013). Forest canopy gap distributions in the southern Peruvian Amazon. PLoS ONE, 8(4), e60875. https://doi.org/10.1371/journal.pone.0060875
Brokaw, N. V. L. (1982). The definition of treefall gap and its effect on measures of forest dynamics. Biotropica, 14(2), 158–160. https://doi.org/10.2307/2387750
Clauset, A., Shalizi, C. R., & Newman, M. E. J. (2009). Power-law distributions in empirical data. SIAM Review, 51(4), 661–703. https://doi.org/10.1137/070710111
Disney, M. I., Kalogirou, V., Lewis, P., Prieto-Blanco, A., Hancock, S., & Pfeifer, M. (2010). Simulating the impact of discrete-return lidar system and survey characteristics over young conifer and broadleaf forests. Remote Sensing of Environment, 114(7), 1546–1560. https://doi.org/10.1016/j.rse.2010.02.009
Fisher, J. I., Hurtt, G. C., Thomas, R. Q., & Chambers, J. Q. (2008). Clustered disturbances lead to bias in large-scale estimates based on forest sample plots. Ecology Letters, 11(6), 554–563. https://doi.org/10.1111/j.1461-0248.2008.01169.x
Fuller, W. A. (1987). Measurement error models. Wiley. https://doi.org/10.1002/9780470316665
Huber, P. J. (1964). Robust estimation of a location parameter. The Annals of Mathematical Statistics, 35(1), 73–101. https://doi.org/10.1214/aoms/1177703732
Kuhn, H. W. (1955). The Hungarian method for the assignment problem. Naval Research Logistics Quarterly, 2(1–2), 83–97. https://doi.org/10.1002/nav.3800020109
MacArthur, R. H., & Horn, H. S. (1969). Foliage profile by vertical measurements. Ecology, 50(5), 802–804. https://doi.org/10.2307/1933693
Munkres, J. (1957). Algorithms for the assignment and transportation problems. Journal of the Society for Industrial and Applied Mathematics, 5(1), 32–38. https://doi.org/10.1137/0105003
Næsset, E. (2009). Effects of different sensors, flying altitudes, and pulse repetition frequencies on forest canopy metrics and biophysical stand properties derived from small-footprint airborne laser data. Remote Sensing of Environment, 113(1), 148–159. https://doi.org/10.1016/j.rse.2008.09.001
Nuth, C., & Kääb, A. (2011). Co-registration and bias corrections of satellite elevation data sets for quantifying glacier thickness change. The Cryosphere, 5(1), 271–290. https://doi.org/10.5194/tc-5-271-2011
Pitman, E. J. G. (1937). Significance tests which may be applied to samples from any populations. Supplement to the Journal of the Royal Statistical Society, 4(1), 119–130. https://doi.org/10.2307/2984124
Sheil, D., Burslem, D. F. R. P., & Alder, D. (1995). The interpretation and misinterpretation of mortality rate measures. Journal of Ecology, 83(2), 331–333. https://doi.org/10.2307/2261571
Silva, C. A., Valbuena, R., Pinagé, E. R., Mohan, M., de Almeida, D. R. A., North Broadbent, E., Jaafar, W. S. W. M., de Almeida Papa, D., Cardil, A., & Klauberg, C. (2019). ForestGapR: An R package for forest gap analysis from canopy height models. Methods in Ecology and Evolution, 10(8), 1347–1356. https://doi.org/10.1111/2041-210X.13211