Skip to content

sylva.change: airborne surveys

The functions for airborne surveys are re-exported from sylva.change (change.align_surveys, change.chm_change and so on); they live in sylva.change.als. See Change between airborne surveys.

sylva.change.als

Change between two airborne lidar surveys of one area.

Every function works on catalogues of tiles (sylva.als.catalog) through the chunk engine of sylva.als: each chunk reads the same buffered box from both surveys, and the results do not depend on the chunk size or the number of workers. As elsewhere in sylva.change, every change carries its uncertainty or level of detection, and what the data cannot support is labelled (below detection, no data, uncertain, undetected, unobserved) rather than reported as change.

::

from sylva import als, change

a, b = als.catalog("2019/"), als.catalog("2024/")          # ground classified
al = change.align_surveys(a, b, stable_classes=(2, 6))      # ground and roofs
chm = change.chm_change(a, b, resolution=1.0, alignment=al, harmonise=True)
gaps = change.gap_change(chm, height=2.0, min_area=10.0, years=5)
trees = change.tree_change(als.find_trees(a), als.find_trees(b), chm, alignment=al)
metrics = change.metric_change(a, b, resolution=20.0, metrics=["zq95", "cover"], alignment=al)

ALSAlignment dataclass

Offsets of a second airborne survey relative to a first, from align_surveys.

A point p of the second survey belongs at p - offset in the frame of the first. The offsets are estimated per block of a grid and given as one constant, per block, or as a smooth field (model).

Attributes:

Name Type Description
xmin, ymin, block_size

South-west corner and side (m) of the block grid.

model {'constant', 'blocks', 'field'}

How the offset at a point is obtained.

smoothing float

Standard deviation (m) of the Gaussian weights of the field.

values, sigmas

(ny, nx, 3) offset (dx, dy, dz) of the model at each block centre (m) and its standard deviation.

offset, sigma

(3,) constant offset over all blocks and its standard deviation.

birge ndarray

(3,) Birge ratio of the blocks about the model: above 1 where the blocks disagree by more than their uncertainties, which have been widened by it.

noise_a, noise_b

Median residual RMS (m) of planes fitted to each survey's stable returns: return noise plus the roughness of the surface within the plane radius. An upper bound on the return noise.

blocks dict

Per block ((ny, nx) arrays, (ny, nx, 3) for vectors): fitted, n_samples, n_used, n_eff (independent samples), offset and sigma (the block's own estimate), median_before (median height of the second survey above the first before any offset), spread_before and spread_after (robust spread of those heights before and after), horizontal_determined, noise_a, noise_b and iterations.

residuals ndarray | None

(ny, nx, 3) block estimate minus the model at the block centre.

crs str | None

Coordinate system of the first survey.

settings dict

The settings of the call.

constant classmethod

constant(offset, sigma=(0.0, 0.0, 0.0)) -> ALSAlignment

A known constant offset.

Parameters:

Name Type Description Default
offset

(dx, dy, dz) of the second survey relative to the first (m).

required
sigma

Its standard deviation (m).

(0.0, 0.0, 0.0)

Returns:

Type Description
ALSAlignment

offset_at

offset_at(x, y) -> np.ndarray

Offset (dx, dy, dz) of the second survey at points.

Parameters:

Name Type Description Default
x

Coordinates (arrays of one length, or numbers).

required
y

Coordinates (arrays of one length, or numbers).

required

Returns:

Type Description
ndarray

(N, 3) offsets (m).

sigma_at

sigma_at(x, y) -> np.ndarray

Standard deviation of offset_at.

Parameters:

Name Type Description Default
x

Coordinates (arrays of one length, or numbers).

required
y

Coordinates (arrays of one length, or numbers).

required

Returns:

Type Description
ndarray

(N, 3) standard deviations (m).

apply

apply(cloud: PointCloud) -> PointCloud

Move a cloud of the second survey into the frame of the first.

Parameters:

Name Type Description Default
cloud PointCloud

Points of the second survey.

required

Returns:

Type Description
PointCloud

A copy with xyz - offset_at(x, y).

table

table() -> dict[str, np.ndarray]

One row per block with an estimate: centre x, y, the block's dx, dy, dz and their standard deviations sd_dx, sd_dy, sd_dz, the model's model_dx, model_dy, model_dz there, n_used, n_eff, horizontal_determined, spread_before and spread_after.

to_csv

to_csv(path) -> None

Write table as CSV.

Parameters:

Name Type Description Default
path

Output file.

required

report

report() -> str

A summary in words.

GapChange dataclass

Canopy gaps of two surveys and their dynamics, from gap_change.

Attributes:

Name Type Description
a, b

The gaps of each survey.

cells ndarray

(rows, cols) codes of GAP_CELLS: canopy (no gap in either), stable_gap, formed (gap only in the second survey, with a significant loss), closed (gap only in the first, with a significant gain), uncertain (crossed the threshold without a significant change) and no_data.

status_a, closed_area

Per gap of the first survey: closed (no part is gap any more), shrunk, stable or uncertain; and its area closed (m²).

status_b, formed_area

Per gap of the second survey: new, expanded, stable or uncertain; and its area formed (m²).

areas dict

Area (m²) per cell code.

years float | None

Time between the surveys, if given.

summary

summary() -> dict

Gap fractions, areas formed and closed (with annual rates as a share of the area compared when years is known), gap counts by status and the size-distribution exponents of both surveys.

report

report() -> str

A summary in words.

to_geojson

to_geojson(path, survey: str = 'b') -> None

Write one survey's gaps with their status and area formed or closed.

Parameters:

Name Type Description Default
path

Output file.

required
survey ('a', 'b')

Which gaps.

"a"

Gaps dataclass

Canopy gaps of one CHM, from canopy_gaps.

Attributes:

Name Type Description
labels ndarray

(rows, cols) gap id of each cell (0 for none).

id, area, n_cells, x, y, mean_height, max_height

Per gap: id (1, 2, ... in order of the first cell from the south-west), area (m²), cells, centre, and mean and largest CHM height of its cells (m).

polygons list

Per gap, its outline along cell edges: a list of parts, each (exterior, holes) with (k, 2) rings (exteriors counter-clockwise). Parts touch only at corners.

area_with_data float

Area (m²) of the cells with a CHM value.

xmin, ymin, resolution

The CHM's grid.

crs str | None

Its coordinate system.

settings dict

height, min_area, max_area, connectivity.

gap_fraction property

gap_fraction: float

Share of the area with data that is gap.

table

table() -> dict[str, np.ndarray]

id, area, n_cells, x, y, mean_height and max_height per gap.

size_distribution

size_distribution(bins=None) -> tuple[np.ndarray, np.ndarray]

Number of gaps per size class.

Parameters:

Name Type Description Default
bins

Bin edges (m²); by default powers of two from the smallest gap size allowed.

None

Returns:

Type Description
(edges, counts)

size_exponent

size_exponent(xmin: float | None = None) -> tuple[float, float, int]

Exponent of a power law p(a) ~ a^-alpha fitted to the gap sizes by maximum likelihood (Clauset et al. 2009).

Parameters:

Name Type Description Default
xmin float | None

Smallest size included (m²); the smallest allowed by default.

None

Returns:

Type Description
(alpha, standard error, number of gaps)

NaN with fewer than two gaps.

to_geojson

to_geojson(path, properties: dict | None = None) -> None

Write the gap outlines as GeoJSON polygons with table as properties.

Parameters:

Name Type Description Default
path

Output file.

required
properties dict | None

Extra per-gap columns (name to sequence).

None

MetricChange dataclass

Area-based metrics of two surveys compared, from metric_change.

Attributes:

Name Type Description
names list

The metrics.

a, b

Per metric (name to ~sylva.Raster): each survey's value.

difference dict

b - a.

bias, sigma

Mean and standard deviation of the change the cell would show from sampling alone (the permutation distribution).

lower, upper, lod

The interval a change must leave to be significant, and its half-width.

classes dict

Per metric, (rows, cols) codes of SURFACE_CLASSES (no_data also where the permutations gave no interval).

settings dict

The settings of the call.

report

report() -> str

Per metric: median difference and the shares of cells with a significant gain and loss.

PAIChange dataclass

Plant area index of two surveys compared, from pai_change.

Attributes:

Name Type Description
pai_a, pai_b, sigma_a, sigma_b

PAI of each survey per cell and its standard deviation.

difference, sigma, lod

pai_b - pai_a, its standard deviation and level of detection.

classes ndarray

(rows, cols) codes: 0 no_data, 1 below_detection, 2 gain, 3 loss, 4 saturated (no pulse crossed the canopy in one survey, so its PAI is only a lower bound).

profile dict

The pooled profile of the whole area: height, pad_a, pad_b, difference and sigma per layer.

mask

mask(name: str) -> np.ndarray

Cells of one class.

Parameters:

Name Type Description Default
name ('no_data', 'below_detection', 'gain', 'loss', 'saturated')

The class.

"no_data"

Returns:

Type Description
ndarray

(rows, cols) bool.

SurfaceChange dataclass

A surface of two airborne surveys compared, from surface_change.

Attributes:

Name Type Description
surface {'chm', 'dsm', 'dtm'}

What was compared.

a, b

The surfaces of each survey on the first survey's catalogue grid (for "chm" and "dsm" the highest return per cell; NaN without returns).

difference Raster

b - a (m) where the cell could be assessed, NaN elsewhere.

bias Raster

Change expected from the sampling of the surface alone (m): the mean of the permutation distribution. Negative where the second survey samples the cell more sparsely, since its highest return then falls shorter of the canopy top.

lower, upper

The interval (m) a change must leave to be significant: the permutation interval widened by the DTM and alignment errors.

lod Raster

Its half-width (m), the level of detection.

sigma Raster

Standard deviation of the change a cell would show without any (m).

classes ndarray

(rows, cols) codes of SURFACE_CLASSES: 0 no_data, 1 below_detection, 2 gain (above upper), 3 loss (below lower).

sigma_a, sigma_b

Standard deviation of each survey's value: the spread of its highest pulse under the permutation, and its DTM's error.

pulses_a, pulses_b

Pulses with a return in the cell, per m².

density_a, density_b

First returns per m² in each cell, after harmonisation.

dod DoD

The difference with volumes and areas over the significant cells (sylva.change.DoD; its significant marks the cells outside [lower, upper] and its lod is lod).

sensors dict

Per survey ("a", "b") and as compared after harmonisation ("a_compared", "b_compared"): returns, pulses, area, pulse density, returns per pulse, share of single-return pulses, mean absolute scan angle and most returns per pulse.

median_bias float

Median of bias over cells that are canopy (above 2 m) in both.

notes list

Differences between the sensors that bias the comparison, in words.

settings dict

The settings of the call.

mask

mask(name: str) -> np.ndarray

Cells of one class.

Parameters:

Name Type Description Default
name str

One of SURFACE_CLASSES.

required

Returns:

Type Description
ndarray

(rows, cols) bool.

areas

areas() -> dict[str, float]

Area (m²) of each class.

report

report() -> str

A summary in words, with the sensor notes.

write

write(directory, format: str = 'asc') -> list[str]

Write a, b, difference, lod and classes as rasters named <surface>_<layer>.

Parameters:

Name Type Description Default
directory

Output directory (created if needed).

required
format ('asc', 'tif')

ESRI ASCII grids (with a .prj), or GeoTIFF.

"asc"

Returns:

Type Description
list of str

The files written.

ALSTreeChange dataclass

Trees of two airborne surveys compared, from tree_change.

table has one row per tree of the first survey (with its partner in the second, if any) and one per tree found only in the second:

id_a, id_b Ids in each survey (0 for none). status survivor, damaged, dead, undetected or unobserved for a tree of the first survey; recruit, released, undetected or unobserved for one found only in the second. x, y Top in the first survey's frame. distance Between the partners' tops (m). height_a, height_b, dh, sigma, lod Heights (m), height change, its standard deviation and level of detection. dh_change growth, decrease, below_detection or unmeasured (empty without a partner). crown_area_a, crown_area_b Crown areas (m²). crown_loss, crown_gain Shares of the crown with a significant canopy loss or gain. observed Share of the crown with data in both surveys.

Attributes:

Name Type Description
table dict

The columns above, as NumPy arrays.

crs str | None

Coordinate system of the positions.

settings dict

The settings of the call.

counts

counts() -> dict[str, int]

Rows per status (undetected and unobserved split by the survey the tree was found in).

summary

summary(area: float | None = None, years: float | None = None, mask=None) -> dict

Totals: trees by fate, mean height growth of the survivors with its standard error (from their scatter, and from their measurement uncertainties alone), crown area lost and, with years, annual mortality and recruitment rates (Sheil et al. 1995). With area (m²), counts and crown areas are also given per hectare.

Parameters:

Name Type Description Default
area float | None

Area covered (m²).

None
years float | None

Time between the surveys.

None
mask

Rows to include (bool array), e.g. the trees in a plot.

None

Returns:

Type Description
dict

grid

grid(resolution: float, bounds=None) -> dict[str, Raster]

Totals per grid cell: survivors, damaged, dead, recruits, mean_growth, growth_se and crown_area_lost (m²).

Parameters:

Name Type Description Default
resolution float

Cell size (m).

required
bounds

(xmin, ymin, xmax, ymax); the trees' extent snapped to multiples of resolution by default.

None

Returns:

Type Description
dict of Raster

to_csv

to_csv(path) -> None

Write the table as CSV.

Parameters:

Name Type Description Default
path

Output file.

required

to_pandas

to_pandas()

The table as a pandas DataFrame.

report

report(years: float | None = None) -> str

A summary in words.

Parameters:

Name Type Description Default
years float | None

Time between the surveys, for annual rates.

None

align_surveys

align_surveys(catalog_a, catalog_b, block_size: float = 100.0, stable_classes=(2,), model: str = 'field', smoothing: float | None = None, horizontal: bool = True, horizontal_prior: float = 0.3, sample_spacing: float = 1.0, radius: float = 1.5, min_neighbours: int = 6, max_roughness: float = 0.15, max_slope: float = 1.0, max_offset: float = 2.0, correlation_length: float = 5.0, min_samples: int = 30, huber: float = 1.5, iterations: int = 20, chunk_size: float | None = None, workers: int | None = None) -> ALSAlignment

Offsets between two airborne surveys, on stable surfaces.

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) sits dz - gx dx - gy dy above a plane of gradient (gx, gy), so the vertical offset follows from every sample and the horizontal ones from the variety of slopes and aspects (Nuth and Kääb 2011). Each block is solved by iteratively reweighted least squares (Huber weights), refitting the planes at the moved positions until the offsets settle.

A horizontal offset along a slope of one aspect, or on flat ground, looks like a vertical one; a Gaussian prior of horizontal_prior keeps it near zero there, and its standard deviation stays near the prior. Surfaces of several aspects (terrain, roofs: stable_classes=(2, 6); roads are class 11) fix it. Samples within correlation_length of each other count as one independent sample.

Parameters:

Name Type Description Default
catalog_a

The reference and the later survey, with ground (and any other stable class) classified.

required
catalog_b

The reference and the later survey, with ground (and any other stable class) classified.

required
block_size float

Side of the blocks (m); use the tile size for one estimate per tile.

100.0
stable_classes

Classes of the returns taken as unchanged: 2 ground; 6 buildings; 11 roads.

(2,)
model ('field', 'blocks', 'constant')

A smooth field (the blocks pooled with Gaussian weights of smoothing m and interpolated between block centres), each block on its own, or one offset for the whole area.

"field"
smoothing float | None

Standard deviation (m) of the field's weights; block_size by default.

None
horizontal bool

Estimate horizontal offsets (else only the vertical one).

True
horizontal_prior float

Prior standard deviation (m) of each horizontal offset.

0.3
sample_spacing float

Sampling of the second survey (m), radius (m) and fewest returns of the reference planes.

1.0
radius float

Sampling of the second survey (m), radius (m) and fewest returns of the reference planes.

1.0
min_neighbours float

Sampling of the second survey (m), radius (m) and fewest returns of the reference planes.

1.0
max_roughness float

Planes rougher than this RMS (m) or steeper than this gradient are left out (edges, walls, vegetation misclassified as ground).

0.15
max_slope float

Planes rougher than this RMS (m) or steeper than this gradient are left out (edges, walls, vegetation misclassified as ground).

0.15
max_offset float

Largest offset searched (m).

2.0
correlation_length float

Side (m) of the squares within which samples are not independent.

5.0
min_samples int

Fewest usable samples in a block.

30
huber float

Huber constant (robust standard deviations) and largest number of refits.

1.5
iterations float

Huber constant (robust standard deviations) and largest number of refits.

1.5
chunk_size float | None

Side of the chunks (m), rounded up to whole blocks; by default the median tile size.

None
workers int | None

Chunks at once (one per core by default).

None

Returns:

Type Description
ALSAlignment

Raises:

Type Description
ValueError

For bad settings, tiles without a classification, or if no block has enough stable samples in both surveys.

canopy_gaps

canopy_gaps(chm: Raster, height: float = 2.0, min_area: float = 10.0, max_area: float | None = None, connectivity: int = 8) -> Gaps

Canopy gaps of a CHM: connected cells no higher than height.

As ForestGapR's getForestGaps (Silva et al. 2019): cells at or below the height threshold are joined across edges and corners (connectivity=8) or edges only (4), and regions whose area lies between min_area and max_area are gaps. A threshold of 2 m follows Brokaw's (1982) definition of a gap as an opening reaching down to within 2 m of the ground. NaN cells are never gap.

Parameters:

Name Type Description Default
chm Raster

Canopy height model.

required
height float

Gap height threshold (m).

2.0
min_area float

Size limits (m²); no upper limit by default.

10.0
max_area float

Size limits (m²); no upper limit by default.

10.0
connectivity (8, 4)

Cell neighbourhood.

8

Returns:

Type Description
Gaps

gap_change

gap_change(change, chm_b: Raster | None = None, height: float = 2.0, min_area: float = 10.0, max_area: float | None = None, connectivity: int = 8, years: float | None = None) -> GapChange

Gap formation and closure between two surveys.

Gaps are found in each CHM as canopy_gaps finds them. A cell forms a gap when it is in a gap of the second survey only and its CHM fell significantly, and closes one when it is in a gap of the first only and its CHM rose significantly (as ForestGapR's GapChangeDec, Silva et al. 2019, but with the level of detection of surface_change); a cell that crossed the threshold without a significant change is uncertain. Each gap of the second survey is new, expanded, stable or uncertain, each of the first closed, shrunk, stable or uncertain.

Parameters:

Name Type Description Default
change

A CHM SurfaceChange, or the first survey's CHM (a ~sylva.Raster) with chm_b; without a surface change every transition counts.

required
chm_b Raster | None

The second survey's CHM, on the grid of the first.

None
height float

As for canopy_gaps.

2.0
min_area float

As for canopy_gaps.

2.0
max_area float

As for canopy_gaps.

2.0
connectivity float

As for canopy_gaps.

2.0
years float | None

Time between the surveys, for annual rates.

None

Returns:

Type Description
GapChange

metric_change

metric_change(catalog_a, catalog_b, resolution: float = 20.0, metrics=None, alignment: ALSAlignment | None = None, harmonise: bool = False, density_cell: float = 10.0, seed: int = 0, permutations: int = 100, stratum: float = 2.0, threshold: float = 2.0, entropy_bin: float = 1.0, cover_break: float = 2.0, min_height: float | None = None, drop_noise: bool = True, dtm_method: str = 'plane', dtm_resolution: float = 1.0, confidence: float = 0.95, chunk_size: float | None = None, buffer: float = 20.0, workers: int | None = None) -> MetricChange

Differences of area-based metrics between two surveys, with their level of detection.

The metrics of sylva.als.grid_metrics are computed per cell for both surveys in one pass, heights above each survey's own DTM, the second survey moved by alignment and, with harmonise, the denser survey thinned as surface_change thins it.

Whether a metric changed is tested by a stratified permutation test (Pitman 1937): if nothing changed, the pulses of both surveys that fell in one stratum m square of the cell 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. bias and [lower, upper] are the mean and central confidence interval of metric(b) - metric(a) over permutations such reassignments, drawn from a stream seeded by the cell and seed so that the result does not depend on the chunks. Keeping pulses in their squares keeps the pattern in which each survey sampled the cell; resampling the cell's pulses as independent draws would ignore it and understate the uncertainty. The test covers sampling, not differences between the sensors (footprint, sensitivity), which bias metrics systematically: harmonisation removes the part due to pulse density only.

Parameters:

Name Type Description Default
catalog_a

The earlier and the later survey, with ground classified.

required
catalog_b

The earlier and the later survey, with ground classified.

required
resolution float

Cell size (m).

20.0
metrics

Names (see sylva.als.metric_names); all by default.

None
alignment ALSAlignment | None

As for surface_change (seed also seeds the permutations).

None
harmonise ALSAlignment | None

As for surface_change (seed also seeds the permutations).

None
density_cell ALSAlignment | None

As for surface_change (seed also seeds the permutations).

None
seed ALSAlignment | None

As for surface_change (seed also seeds the permutations).

None
permutations int

Reassignments per cell; 0 for none (no level of detection, every change no_data).

100
stratum float

Side (m) of the squares within which pulses are reassigned.

2.0
threshold float

As for sylva.als.grid_metrics.

2.0
entropy_bin float

As for sylva.als.grid_metrics.

2.0
cover_break float

As for sylva.als.grid_metrics.

2.0
min_height float

As for sylva.als.grid_metrics.

2.0
drop_noise float

As for sylva.als.grid_metrics.

2.0
dtm_method str

Each survey's normalising DTM, as for surface_change.

'plane'
dtm_resolution str

Each survey's normalising DTM, as for surface_change.

'plane'
confidence float

Of the interval.

0.95
chunk_size float | None

As for sylva.als.apply.

None
buffer float | None

As for sylva.als.apply.

None
workers float | None

As for sylva.als.apply.

None

Returns:

Type Description
MetricChange

pai_change

pai_change(profile_a, profile_b, confidence: float = 0.95) -> PAIChange

Plant area index change from two gap-fraction profiles.

Both sylva.als.ALSProfile must come from sylva.als.gap_profile on the same grid and layers (the same resolution, bin_size, min_height and max_height over the same area), each with its survey's trajectory. With W pulses in a cell, a share P reaching the ground and a mean extinction k, PAI = -ln P / k and, by the delta method on the binomial P, its standard deviation is sqrt((1 - P) / (W P)) / k. A cell no pulse crossed is saturated. The profiles are not moved by an alignment: compare cells much larger than the horizontal offset.

Parameters:

Name Type Description Default
profile_a

The two profiles.

required
profile_b

The two profiles.

required
confidence float

Of the level of detection.

0.95

Returns:

Type Description
PAIChange

profile_change

profile_change(profile_a, profile_b, mask=None) -> dict

Plant area density profile of an area in two surveys, from the pooled counts of its cells.

Parameters:

Name Type Description Default
profile_a

sylva.als.ALSProfile on one grid and layers.

required
profile_b

sylva.als.ALSProfile on one grid and layers.

required
mask

(ny, nx) bool, the cells of the area; all by default.

None

Returns:

Type Description
dict

height (layer centres, m), pad_a, pad_b, difference and sigma (m²/m³) per layer; sigma is NaN for a layer no pulse crossed in either survey.

chm_change

chm_change(catalog_a, catalog_b, resolution: float = 1.0, **kw) -> SurfaceChange

Canopy height model change: surface_change with surface="chm".

Parameters:

Name Type Description Default
catalog_a

The earlier and the later survey, with ground classified.

required
catalog_b

The earlier and the later survey, with ground classified.

required
resolution float

Cell size (m).

1.0
**kw

Any other argument of surface_change.

{}

Returns:

Type Description
SurfaceChange

dtm_change

dtm_change(catalog_a, catalog_b, resolution: float = 1.0, **kw) -> SurfaceChange

Terrain change: surface_change with surface="dtm".

Parameters:

Name Type Description Default
catalog_a

The earlier and the later survey, with ground classified.

required
catalog_b

The earlier and the later survey, with ground classified.

required
resolution float

Cell size (m).

1.0
**kw

Any other argument of surface_change.

{}

Returns:

Type Description
SurfaceChange

harmonise

harmonise(catalog_a, catalog_b, out_a, out_b, density_cell: float = 10.0, seed: int = 0, format: str | None = None, chunk_size: float | None = None, workers: int | None = None) -> tuple[Catalog, Catalog]

Thin two surveys to a common pulse density and write them as tiles.

In each density_cell square (on a grid through the origin), the survey with more first returns keeps each of its pulses with probability n_other / n_own, all returns of a pulse together, chosen by a hash of the pulse (its GPS time and flight line) and seed: the same pulses whatever the tiling, and the same that surface_change keeps with harmonise=True. Tree detection and metrics on the thinned tiles then compare like with like.

Parameters:

Name Type Description Default
catalog_a

The two surveys.

required
catalog_b

The two surveys.

required
out_a

Directories for the thinned tiles of each (one file per chunk).

required
out_b

Directories for the thinned tiles of each (one file per chunk).

required
density_cell float

Side (m) of the squares in which densities are compared.

10.0
seed int

Seed of the pulse selection.

0
format ('las', 'laz')

Output format (that of the input tiles by default).

"las"
chunk_size float | None

As for sylva.als.apply.

None
workers float | None

As for sylva.als.apply.

None

Returns:

Type Description
(Catalog, Catalog)

The thinned surveys.

surface_change

surface_change(catalog_a, catalog_b, surface: str = 'chm', resolution: float = 1.0, alignment: ALSAlignment | None = None, harmonise: bool = False, density_cell: float = 10.0, seed: int = 0, first_returns: bool = False, subcircle: float = 0.0, dtm_method: str = 'plane', dtm_resolution: float = 1.0, min_returns: int = 2, noise_a: float | None = None, noise_b: float | None = None, interpolation_error: float = 0.02, confidence: float = 0.95, horizontal_sigma: float = 0.0, vertical_sigma: float = 0.0, chunk_size: float | None = None, buffer: float = 20.0, workers: int | None = None) -> SurfaceChange

Difference of a CHM, DSM or DTM of two surveys with a level of detection per cell.

Both surveys are gridded in one pass with one algorithm (the highest return per cell), each normalised by a DTM of its own ground returns.

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 heights are spread in the cell, and at a crown's edge one survey may hit the crown where the other hits only ground. Within a cell each pulse contributes its highest return there; if nothing changed, the pulses of both surveys sample one surface and every split of the pooled values into the two surveys' numbers is equally likely. The distribution of max(b) - max(a) over the splits, exact from the order statistics of the pooled values (a permutation test on the maxima), is the change sampling alone would give: its mean is bias, and its central confidence interval, widened by the DTM errors (noise / sqrt(n_ground) plus interpolation_error times the distance to the nearest ground return) and the alignment's uncertainty (the horizontal part times the surface's gradient; for DSMs and DTMs also the vertical part), is [lower, upper]. A change above it is a gain, below it a loss; cells with fewer than min_returns pulses in either survey are no_data. A DTM is compared with the DTM and alignment errors alone.

The permutation assumes the two sensors sample the canopy alike except for their numbers of pulses. notes names the sensor differences (pulse density, returns per pulse, scan angle) that bias CHM change otherwise; harmonise=True thins the denser survey, pulse by pulse, to the other's pulse density in each density_cell square (as harmonise does to whole catalogues), and first_returns=True compares first returns only.

Parameters:

Name Type Description Default
catalog_a

The earlier and the later survey, with ground classified.

required
catalog_b

The earlier and the later survey, with ground classified.

required
surface ('chm', 'dsm', 'dtm')

Canopy heights, surface elevations, or terrain.

"chm"
resolution float

Cell size (m).

1.0
alignment ALSAlignment | None

From align_surveys: moves the second survey into the first's frame, and gives the alignment uncertainty (and, when noise_a and noise_b are not given, the noise of each survey).

None
harmonise bool

Thin the denser survey to the other's pulse density in squares of density_cell m, choosing pulses by a hash of the pulse and seed.

False
density_cell bool

Thin the denser survey to the other's pulse density in squares of density_cell m, choosing pulses by a hash of the pulse and seed.

False
seed bool

Thin the denser survey to the other's pulse density in squares of density_cell m, choosing pulses by a hash of the pulse and seed.

False
first_returns bool

Grid first returns only.

False
subcircle float

Replace each return by eight points on a circle of this radius (m); 0 for the returns themselves.

0.0
dtm_method ('plane', 'tin', 'lowest')

Each survey's DTM (for normalisation, and the surface of "dtm"): at each cell a plane fitted to the ground returns around it (the same whatever the chunks, with its standard error), a triangulation of the ground returns, or the lowest per cell.

"plane"
dtm_resolution float

Cell size of the normalising DTM (m).

1.0
min_returns int

Fewest pulses in a cell of each survey.

2
noise_a float | None

Return noise of each survey (m), for the DTM error; by default the alignment's plane residuals, else 0.05 m.

None
noise_b float | None

Return noise of each survey (m), for the DTM error; by default the alignment's plane residuals, else 0.05 m.

None
interpolation_error float

Growth of the DTM error with distance to the nearest ground return (m per m).

0.02
confidence float

Of the interval.

0.95
horizontal_sigma float

Alignment uncertainty (m) when no alignment is given.

0.0
vertical_sigma float

Alignment uncertainty (m) when no alignment is given.

0.0
chunk_size float | None

As for sylva.als.apply (the buffer is raised to two cells, and with harmonise to density_cell plus two cells).

None
buffer float | None

As for sylva.als.apply (the buffer is raised to two cells, and with harmonise to density_cell plus two cells).

None
workers float | None

As for sylva.als.apply (the buffer is raised to two cells, and with harmonise to density_cell plus two cells).

None

Returns:

Type Description
SurfaceChange

Raises:

Type Description
ValueError

For bad settings, or if no chunk has ground returns in both surveys.

tree_change

tree_change(trees_a, trees_b, change: SurfaceChange, alignment: ALSAlignment | None = None, max_distance: float = 1.5, max_growth: float = 0.3, max_drop: float = 0.2, height_weight: float = 1.0, dead_fraction: float = 0.5, damage_fraction: float = 0.3, min_observed: float = 0.5, confidence: float = 0.95) -> ALSTreeChange

Airborne trees of two surveys matched, with height growth, mortality, damage and recruitment.

The trees are matched by the optimal assignment of sylva.change.match_trees (Kuhn 1955; Munkres 1957) with the tree height in place of the diameter: a pair must lie within max_distance and its height may grow by at most max_growth or fall by at most max_drop of the larger height. The CHM change says what became of the others:

  • a matched tree is a survivor, or damaged when a significant loss covers damage_fraction of its crown or its height fell by more than its level of detection;
  • an unmatched tree of the first survey is damaged when a tree of the second stands within 2 * max_distance of its top on significantly lowered canopy (a broken or collapsed crown), dead when a significant loss covers dead_fraction of its crown, unobserved when less than min_observed of its crown has data, and otherwise undetected (its canopy is still there);
  • an unmatched tree of the second survey is a recruit when the canopy at its top rose significantly from below 1 - max_growth of its height (more than an existing crown can grow), released when it fell (an understorey tree exposed by the loss of a neighbour), unobserved without data, and otherwise undetected (no significant change, or a rise a growing crown explains: a top split off a neighbour's crown).

The standard deviation of a height is that of its top's CHM cell in change (noise, sampling of the apex, DTM); the level of detection of a height change is the normal quantile of confidence times the standard deviation of the difference.

Parameters:

Name Type Description Default
trees_a

The trees of each survey, from sylva.als.find_trees (or a dict with x, y, height and optionally id, crown_area and crowns), each in its own survey's frame.

required
trees_b

The trees of each survey, from sylva.als.find_trees (or a dict with x, y, height and optionally id, crown_area and crowns), each in its own survey's frame.

required
change SurfaceChange

The CHM change of the same surveys (chm_change), best at the resolution the trees were found at.

required
alignment ALSAlignment | None

Moves the second survey's trees into the first's frame (the one change was made with).

None
max_distance float

Matching: largest distance (m), largest relative height gain and loss, and the weight of the squared relative height difference next to the squared distance over max_distance.

1.5
max_growth float

Matching: largest distance (m), largest relative height gain and loss, and the weight of the squared relative height difference next to the squared distance over max_distance.

1.5
max_drop float

Matching: largest distance (m), largest relative height gain and loss, and the weight of the squared relative height difference next to the squared distance over max_distance.

1.5
height_weight float

Matching: largest distance (m), largest relative height gain and loss, and the weight of the squared relative height difference next to the squared distance over max_distance.

1.5
dead_fraction float

Shares of the crown, as above.

0.5
damage_fraction float

Shares of the crown, as above.

0.5
min_observed float

Shares of the crown, as above.

0.5
confidence float

Of the levels of detection.

0.95

Returns:

Type Description
ALSTreeChange