Skip to content

Commit bf15b74

Browse files
authored
Merge pull request #36 from iosefa/add-canopy-cover
Add canopy cover
2 parents 653e1e0 + 5be9fe9 commit bf15b74

9 files changed

Lines changed: 288 additions & 40 deletions

File tree

docs/examples/calculate-forest-metrics.ipynb

Lines changed: 59 additions & 23 deletions
Large diffs are not rendered by default.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Canopy Cover (GEDI-style)
2+
3+
## Definition
4+
5+
Canopy cover at height z is the fraction of ground area occluded by vegetation above z meters (Height Above Ground).
6+
Following GEDI, we model the gap probability using the Beer–Lambert law:
7+
8+
Cover(z) = 1 − exp(−k · PAI_above(z))
9+
10+
Where:
11+
- `k` is the extinction coefficient (default 0.5 for spherical leaf-angle assumption).
12+
- `PAI_above(z)` is the vertical integral of Plant Area Density (PAD) above height `z`.
13+
14+
Common choices:
15+
- Tree canopy cover (GEDI-like): z = 2.0 m.
16+
- Total vegetative cover: z = 0.0 m.
17+
18+
## Usage
19+
20+
```python
21+
from pyforestscan.handlers import read_lidar
22+
from pyforestscan.filters import filter_hag
23+
from pyforestscan.calculate import assign_voxels, calculate_pad, calculate_canopy_cover
24+
from pyforestscan.visualize import plot_metric
25+
26+
file_path = "../example_data/20191210_5QKB020880.laz"
27+
28+
# Ensure HAG is present for PAD/PAI calculations
29+
arrays = read_lidar(file_path, "EPSG:32605", hag=True)
30+
arrays = filter_hag(arrays)
31+
points = arrays[0]
32+
33+
voxel_resolution = (5, 5, 1) # dx, dy, dz in meters
34+
voxels, extent = assign_voxels(points, voxel_resolution)
35+
36+
pad = calculate_pad(voxels, voxel_resolution[-1])
37+
38+
# GEDI-like canopy cover at z=2 m
39+
cover = calculate_canopy_cover(pad, voxel_height=voxel_resolution[-1], min_height=2.0, k=0.5)
40+
41+
plot_metric('Canopy Cover (z=2 m)', cover, extent, metric_name='Cover', cmap='viridis')
42+
```
43+
44+
## Notes
45+
46+
- As z increases, canopy cover decreases because less foliage lies above higher thresholds.
47+
- `calculate_canopy_cover` returns NaN where PAD is entirely missing in the integration range.
48+
- When tiling large EPT datasets, use `process_with_tiles(..., metric="cover", voxel_height=..., cover_min_height=2.0, cover_k=0.5)` to write GeoTIFF tiles.
49+

docs/usage/forest-structure/intro.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Most calculations for the metrics follows the method outlined in Kamoske et al.
1313
* [Plant Area Density (PAD)](pad.md)
1414
* [Plant Area Index (PAI)](pai.md)
1515
* [Foliage Height Diversity (FHD)](fhd.md)
16+
* [Canopy Cover](canopy_cover.md)
1617

1718
## References
1819

pyforestscan/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .calculate import (
2+
assign_voxels,
3+
calculate_pad,
4+
calculate_pai,
5+
calculate_fhd,
6+
calculate_chm,
7+
generate_dtm,
8+
calculate_canopy_cover,
9+
)
10+
11+
__all__ = [
12+
"assign_voxels",
13+
"calculate_pad",
14+
"calculate_pai",
15+
"calculate_fhd",
16+
"calculate_chm",
17+
"generate_dtm",
18+
"calculate_canopy_cover",
19+
]

pyforestscan/calculate.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ def calculate_pad(voxel_returns,
130130
if drop_ground:
131131
pad[:, :, 0] = np.nan
132132

133-
pad[voxel_returns[:, :, 0] == 0, :] = np.nan
133+
# Mask only columns that have zero returns across all Z (true empty columns)
134+
empty_columns = (np.sum(voxel_returns, axis=2) == 0)
135+
pad[empty_columns, :] = np.nan
134136

135137
return pad
136138

@@ -168,6 +170,62 @@ def calculate_pai(pad,
168170
return pai
169171

170172

173+
def calculate_canopy_cover(pad: np.ndarray,
174+
voxel_height: float,
175+
min_height: float = 2.0,
176+
max_height: float | None = None,
177+
k: float = 0.5) -> np.ndarray:
178+
"""
179+
Calculate GEDI-style canopy cover at a height threshold using PAD.
180+
181+
Uses the Beer–Lambert relation: Cover(z) = 1 - exp(-k * PAI_above(z)), where
182+
PAI_above(z) is the integrated Plant Area Index above height z.
183+
184+
Args:
185+
pad (np.ndarray): 3D array of PAD values with shape (X, Y, Z).
186+
voxel_height (float): Height of each voxel in meters (> 0).
187+
min_height (float, optional): Height-above-ground threshold z (in meters) at which
188+
to compute canopy cover. Defaults to 2.0 m (GEDI convention).
189+
max_height (float or None, optional): Maximum height to integrate up to. If None,
190+
integrates to the top of the PAD volume. Defaults to None.
191+
k (float, optional): Extinction coefficient (Beer–Lambert constant). Defaults to 0.5.
192+
193+
Returns:
194+
np.ndarray: 2D array (X, Y) of canopy cover values in [0, 1], with NaN where
195+
PAD is entirely missing for the integration range.
196+
197+
Raises:
198+
ValueError: If parameters are invalid (e.g., non-positive voxel_height, k < 0,
199+
or min_height >= max_height).
200+
"""
201+
if voxel_height <= 0:
202+
raise ValueError(f"voxel_height must be > 0 metres (got {voxel_height})")
203+
if k < 0:
204+
raise ValueError(f"k must be >= 0 (got {k})")
205+
206+
# Compute PAI integrated from min_height up to max_height/top
207+
pai_above = calculate_pai(pad, voxel_height, min_height=min_height, max_height=max_height)
208+
209+
# Identify columns that are entirely NaN within the integration range
210+
if max_height is None:
211+
max_height = pad.shape[2] * voxel_height
212+
if min_height >= max_height:
213+
raise ValueError("Minimum height index must be less than maximum height index.")
214+
start_idx = int(np.ceil(min_height / voxel_height))
215+
end_idx = int(np.floor(max_height / voxel_height))
216+
range_slice = pad[:, :, start_idx:end_idx]
217+
all_nan_mask = np.all(np.isnan(range_slice), axis=2)
218+
219+
# Beer–Lambert canopy cover
220+
cover = 1.0 - np.exp(-k * pai_above)
221+
222+
# Clamp to [0,1] and set invalids
223+
cover = np.where(np.isfinite(cover), cover, np.nan)
224+
cover = np.clip(cover, 0.0, 1.0)
225+
cover[all_nan_mask] = np.nan
226+
return cover
227+
228+
171229
def calculate_fhd(voxel_returns) -> np.ndarray:
172230
"""
173231
Calculate the Foliage Height Diversity (FHD) for a given set of voxel returns.

pyforestscan/process.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from tqdm import tqdm
77

8-
from pyforestscan.calculate import calculate_fhd, calculate_pad, calculate_pai, assign_voxels, calculate_chm
8+
from pyforestscan.calculate import calculate_fhd, calculate_pad, calculate_pai, assign_voxels, calculate_chm, calculate_canopy_cover
99
from pyforestscan.filters import remove_outliers_and_clean
1010
from pyforestscan.handlers import create_geotiff
1111
from pyforestscan.pipeline import _hag_raster, _hag_delaunay
@@ -50,7 +50,8 @@ def _crop_dtm(dtm_path, tile_min_x, tile_min_y, tile_max_x, tile_max_y):
5050

5151
def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
5252
voxel_height=1, buffer_size=0.1, srs=None, hag=False,
53-
hag_dtm=False, dtm=None, bounds=None, interpolation=None, remove_outliers=False) -> None:
53+
hag_dtm=False, dtm=None, bounds=None, interpolation=None, remove_outliers=False,
54+
cover_min_height: float = 2.0, cover_k: float = 0.5) -> None:
5455
"""
5556
Process a large EPT point cloud by tiling, compute CHM or other metrics for each tile,
5657
and write the results to the specified output directory.
@@ -59,7 +60,7 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
5960
ept_file (str): Path to the EPT file containing the point cloud data.
6061
tile_size (tuple): Size of each tile as (tile_width, tile_height).
6162
output_path (str): Directory where the output files will be saved.
62-
metric (str): Metric to compute for each tile ("chm", "fhd", or "pai").
63+
metric (str): Metric to compute for each tile ("chm", "fhd", "pai", or "cover").
6364
voxel_size (tuple): Voxel resolution as (x_resolution, y_resolution, z_resolution).
6465
voxel_height (float, optional): Height of each voxel in meters. Required if metric is "pai".
6566
buffer_size (float, optional): Fractional buffer size relative to tile size (e.g., 0.1 for 10% buffer). Defaults to 0.1.
@@ -72,6 +73,8 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
7273
If None, tiling is done over the entire dataset.
7374
interpolation (str or None, optional): Interpolation method for CHM calculation ("linear", "cubic", "nearest", or None).
7475
remove_outliers (bool, optional): Whether to remove statistical outliers before calculating metrics. Defaults to False.
76+
cover_min_height (float, optional): Height threshold (in meters) for canopy cover (used when metric == "cover"). Defaults to 2.0.
77+
cover_k (float, optional): Beer–Lambert extinction coefficient for canopy cover. Defaults to 0.5.
7578
7679
Returns:
7780
None
@@ -80,7 +83,7 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
8083
ValueError: If an unsupported metric is requested, if buffer or voxel sizes are invalid, or required arguments are missing.
8184
FileNotFoundError: If the EPT or DTM file does not exist, or a required file for processing is missing.
8285
"""
83-
if metric not in ["chm", "fhd", "pai"]:
86+
if metric not in ["chm", "fhd", "pai", "cover"]:
8487
raise ValueError(f"Unsupported metric: {metric}")
8588

8689
(min_z, max_z) = (None, None)
@@ -188,7 +191,7 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
188191

189192
result_file = os.path.join(output_path, f"tile_{i}_{j}_chm.tif")
190193
create_geotiff(chm, result_file, srs, core_extent)
191-
elif metric in ["fhd", "pai"]:
194+
elif metric in ["fhd", "pai", "cover"]:
192195
voxels, spatial_extent = assign_voxels(tile_points, voxel_size)
193196

194197
if metric == "fhd":
@@ -204,6 +207,22 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
204207
else:
205208
result = calculate_pai(pad, voxel_height)
206209
result = np.where(np.isfinite(result), result, 0)
210+
elif metric == "cover":
211+
if not voxel_height:
212+
raise ValueError(f"voxel_height is required for metric {metric}")
213+
214+
pad = calculate_pad(voxels, voxel_size[-1])
215+
if np.all(pad == 0):
216+
result = np.zeros((pad.shape[0], pad.shape[1]))
217+
else:
218+
result = calculate_canopy_cover(
219+
pad,
220+
voxel_height=voxel_height,
221+
min_height=cover_min_height,
222+
max_height=None,
223+
k=cover_k,
224+
)
225+
result = np.where(np.isfinite(result), result, 0)
207226

208227
if current_buffer_size > 0:
209228
if buffer_pixels_x * 2 >= result.shape[1] or buffer_pixels_y * 2 >= result.shape[0]:

pyforestscan/utils.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,41 @@ def get_srs_from_ept(ept_file) -> str or None:
4848

4949
def get_bounds_from_ept(ept_file) -> tuple[float, float, float, float, float, float]:
5050
"""
51-
Extract the spatial bounds of a point cloud from an EPT (Entwine Point Tile) file using PDAL.
51+
Extract dataset bounds from an EPT (Entwine Point Tile) source.
5252
5353
Args:
54-
ept_file (str): Path to the EPT file containing the point cloud data.
54+
ept_file (str): Path or URL to the EPT JSON.
5555
5656
Returns:
57-
tuple: A tuple of bounds in the format (min_x, max_x, min_y, max_y, min_z, max_z).
57+
tuple: (min_x, max_x, min_y, max_y, min_z, max_z)
5858
5959
Raises:
6060
KeyError: If bounds information is not available in the EPT metadata.
61+
ValueError: If the EPT bounds are malformed.
6162
"""
6263
ept_json = _read_ept_json(ept_file)
6364

64-
try:
65-
bounds = ept_json["bounds"]
66-
return bounds
67-
except KeyError:
65+
# Prefer "bounds"; some datasets may also include "boundsConforming".
66+
raw_bounds = ept_json.get("bounds")
67+
if raw_bounds is None:
68+
raw_bounds = ept_json.get("boundsConforming")
69+
if raw_bounds is None:
6870
raise KeyError("Bounds information is not available in the ept metadata.")
6971

72+
# Handle common representations: list/tuple of 6 numbers or a dict with keys.
73+
if isinstance(raw_bounds, (list, tuple)) and len(raw_bounds) == 6:
74+
min_x, min_y, min_z, max_x, max_y, max_z = raw_bounds
75+
elif isinstance(raw_bounds, dict):
76+
try:
77+
min_x = raw_bounds["minx"]; min_y = raw_bounds["miny"]; min_z = raw_bounds["minz"]
78+
max_x = raw_bounds["maxx"]; max_y = raw_bounds["maxy"]; max_z = raw_bounds["maxz"]
79+
except Exception as e:
80+
raise ValueError(f"Malformed EPT bounds dictionary: {e}") from None
81+
else:
82+
raise ValueError(f"Unexpected EPT bounds format: {type(raw_bounds)}")
83+
84+
return (min_x, max_x, min_y, max_y, min_z, max_z)
85+
7086

7187
def tile_las_in_memory(
7288
las_file,

tests/test_calculate.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
calculate_pad,
88
calculate_pai,
99
calculate_fhd,
10-
calculate_chm
10+
calculate_chm,
11+
calculate_canopy_cover
1112
)
1213
import math
1314

@@ -407,8 +408,13 @@ def test_calculate_fhd_high_diversity():
407408

408409

409410
def test_calculate_fhd_partial_zero_distribution():
410-
voxel_returns = np.random.randint(0, 5, size=(5, 5, 5))
411+
# Construct deterministic columns to avoid flaky equality due to randomness.
412+
voxel_returns = np.zeros((5, 5, 5), dtype=int)
413+
# Column with all mass in a single bin -> entropy 0
411414
voxel_returns[0, 0, :] = [1, 0, 0, 0, 0]
415+
# Column with uniform distribution across bins -> maximal entropy
416+
voxel_returns[1, 1, :] = [1, 1, 1, 1, 1]
417+
412418
fhd = calculate_fhd(voxel_returns)
413419
assert isinstance(fhd, np.ndarray)
414420
assert fhd.shape == (5, 5)
@@ -601,3 +607,42 @@ def test_calculate_chm_large_heights():
601607
assert isinstance(chm, np.ndarray)
602608
assert chm.ndim == 2
603609
assert np.all(chm >= 1000)
610+
611+
612+
# ----------------------------
613+
# Tests for calculate_canopy_cover
614+
# ----------------------------
615+
616+
def test_calculate_canopy_cover_basic():
617+
pad = np.ones((4, 3, 10), dtype=float)
618+
voxel_height = 1.0
619+
z = 2.0
620+
k = 0.5
621+
# PAI above z=2 with dz=1 and 10 layers => sum from idx 2..9 = 8
622+
expected_cover = 1.0 - np.exp(-k * 8.0)
623+
cov = calculate_canopy_cover(pad, voxel_height, min_height=z, k=k)
624+
assert cov.shape == (4, 3)
625+
assert np.allclose(cov, expected_cover)
626+
627+
628+
def test_calculate_canopy_cover_zero_pad():
629+
pad = np.zeros((2, 2, 5), dtype=float)
630+
cov = calculate_canopy_cover(pad, voxel_height=1.0, min_height=2.0, k=0.5)
631+
assert np.all(cov == 0.0)
632+
633+
634+
def test_calculate_canopy_cover_nans_propagate():
635+
pad = np.ones((2, 2, 6), dtype=float)
636+
pad[:, :, 3:] = np.nan # everything above z=3 m is NaN
637+
cov = calculate_canopy_cover(pad, voxel_height=1.0, min_height=3.0, k=0.5)
638+
# All NaN in integration range -> NaN in cover
639+
assert np.all(np.isnan(cov))
640+
641+
642+
def test_calculate_canopy_cover_monotonic_with_height():
643+
pad = np.ones((3, 3, 10), dtype=float)
644+
cov0 = calculate_canopy_cover(pad, voxel_height=1.0, min_height=0.0, k=0.5)
645+
cov2 = calculate_canopy_cover(pad, voxel_height=1.0, min_height=2.0, k=0.5)
646+
cov5 = calculate_canopy_cover(pad, voxel_height=1.0, min_height=5.0, k=0.5)
647+
assert np.all(cov0 >= cov2)
648+
assert np.all(cov2 >= cov5)

tests/test_utils.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,16 @@ def test_get_srs_from_usgs_hawaii_ept():
2525
def test_get_bounds_from_usgs_hawaii_ept():
2626
"""
2727
Tests retrieving the EPT bounds from a real EPT JSON.
28+
get_bounds_from_ept returns (min_x, max_x, min_y, max_y, min_z, max_z).
2829
"""
2930
ept_url = "https://s3-us-west-2.amazonaws.com/usgs-lidar-public/HI_Hawaii_Island_2017/ept.json"
3031
bounds = get_bounds_from_ept(ept_url)
31-
assert len(bounds) == 6, "Bounds should be [minX, maxX, minY, maxY, minZ, maxZ]."
32-
assert bounds == [-17405012, 2136822, -5677, -17229390, 2312444, 169945]
32+
assert len(bounds) == 6, "Bounds should be (minX, maxX, minY, maxY, minZ, maxZ)."
33+
expected = (-17405012, -17229390, 2136822, 2312444, -5677, 169945)
34+
assert tuple(bounds) == expected
35+
36+
min_x, max_x, min_y, max_y, min_z, max_z = bounds
37+
assert min_x < max_x and min_y < max_y and min_z < max_z
3338

3439

3540
def test_tile_las_in_memory():

0 commit comments

Comments
 (0)