Skip to content

Commit 726577b

Browse files
authored
Merge pull request #49 from iosefa/feature/metrics-per-flightline
process: add `by_flightline` option for per-flightline processing
2 parents ccaab21 + bf82b04 commit 726577b

13 files changed

Lines changed: 477 additions & 73 deletions

docs/examples/calculate-forest-metrics.ipynb

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

docs/examples/getting-started-importing-preprocessing-dtm-chm.ipynb

Lines changed: 50 additions & 7 deletions
Large diffs are not rendered by default.

docs/examples/working-with-large-point-clouds.ipynb

Lines changed: 42 additions & 10 deletions
Large diffs are not rendered by default.

docs/usage/forest-structure/fhd.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,19 @@ points = arrays[0]
3131
voxel_resolution = (5, 5, 1)
3232
voxels, extent = assign_voxels(points, voxel_resolution)
3333

34-
fhd = calculate_fhd(voxels)
34+
fhd = calculate_fhd(
35+
voxels,
36+
voxel_height=voxel_resolution[-1],
37+
# Optional: ignore returns below 2 m to drop ground/understory if desired.
38+
min_height=2.0,
39+
)
3540
plot_metric('Foliage Height Diversity', fhd, extent, metric_name='FHD', cmap='viridis', fig_size=None)
3641
```
3742

43+
**Notes**
44+
- `min_height` defaults to 0 (all returns). Raise it (e.g., 2 m) to exclude near-ground returns from the entropy calculation.
45+
- `max_height` can limit the top of the integration range if needed.
46+
3847
![fhd.png](../../images/fhd.png)
3948

4049
## References

docs/usage/forest-structure/pai.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,17 @@ voxel_resolution = (5, 5, 1)
3333
voxels, extent = assign_voxels(points, voxel_resolution)
3434

3535
pad = calculate_pad(voxels, voxel_resolution[-1])
36-
pai = calculate_pai(pad)
36+
pai = calculate_pai(
37+
pad,
38+
voxel_height=voxel_resolution[-1],
39+
# Defaults to min_height=1.0; raise if you want to exclude very low vegetation.
40+
min_height=1.0,
41+
)
3742
plot_metric('Plant Area Index', pai, extent, metric_name='PAI', cmap='viridis', fig_size=None)
3843
```
3944

40-
![pai.png](../../images/pai.png)
45+
![pai.png](../../images/pai.png)
46+
47+
**Notes**
48+
- `min_height` defaults to 1 m to mirror common PAI conventions. Increase it to ignore very near-ground returns or set lower (>=0) if you need the full profile.
49+
- `max_height` optionally caps the integration height.

pyforestscan/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
calculate_pai,
55
calculate_fhd,
66
calculate_chm,
7+
calculate_point_density,
8+
calculate_voxel_stat,
79
generate_dtm,
810
calculate_canopy_cover,
911
)
@@ -14,6 +16,8 @@
1416
"calculate_pai",
1517
"calculate_fhd",
1618
"calculate_chm",
19+
"calculate_point_density",
20+
"calculate_voxel_stat",
1721
"generate_dtm",
1822
"calculate_canopy_cover",
1923
]

pyforestscan/calculate.py

Lines changed: 230 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from scipy.interpolate import griddata
44
from scipy.stats import entropy
55
from scipy import ndimage
6-
from typing import List, Tuple
6+
from typing import List, Tuple, Optional
77

88

99
def generate_dtm(ground_points, resolution=2.0) -> Tuple[np.ndarray, List]:
@@ -243,7 +243,10 @@ def calculate_canopy_cover(pad: np.ndarray,
243243
return cover
244244

245245

246-
def calculate_fhd(voxel_returns) -> np.ndarray:
246+
def calculate_fhd(voxel_returns,
247+
voxel_height: float = 1.0,
248+
min_height: float = 0.0,
249+
max_height: float | None = None) -> np.ndarray:
247250
"""
248251
Calculate the Foliage Height Diversity (FHD) for a given set of voxel returns.
249252
@@ -253,18 +256,36 @@ def calculate_fhd(voxel_returns) -> np.ndarray:
253256
Args:
254257
voxel_returns (np.ndarray): 3D numpy array of shape (X, Y, Z) representing voxel returns,
255258
where X and Y are spatial dimensions and Z represents height bins (vertical layers).
259+
voxel_height (float, optional): Height of each voxel in meters (> 0). Defaults to 1.0.
260+
min_height (float, optional): Minimum height (in meters) to include in the entropy calculation.
261+
Defaults to 0.0 (use all heights by default).
262+
max_height (float or None, optional): Maximum height (in meters) to include. If None, uses the full
263+
height of the voxel grid. Defaults to None.
256264
257265
Returns:
258266
np.ndarray: 2D numpy array of shape (X, Y) with FHD values for each (X, Y) location.
259-
Areas with no voxel returns will have NaN values.
267+
Areas with no voxel returns in the requested height range will have NaN values.
260268
"""
261-
sum_counts = np.sum(voxel_returns, axis=2)
269+
if voxel_height <= 0:
270+
raise ValueError(f"voxel_height must be > 0 metres (got {voxel_height})")
271+
272+
effective_max_height = max_height if max_height is not None else voxel_returns.shape[2] * voxel_height
273+
if min_height >= effective_max_height:
274+
return np.full(voxel_returns.shape[:2], np.nan, dtype=float)
275+
276+
start_idx = int(np.ceil(min_height / voxel_height))
277+
end_idx = int(np.floor(effective_max_height / voxel_height))
278+
if start_idx >= end_idx:
279+
return np.full(voxel_returns.shape[:2], np.nan, dtype=float)
280+
281+
core_returns = voxel_returns[:, :, start_idx:end_idx]
282+
sum_counts = np.sum(core_returns, axis=2)
262283

263284
with np.errstate(divide='ignore', invalid='ignore'):
264285
proportions = np.divide(
265-
voxel_returns,
286+
core_returns,
266287
sum_counts[..., None],
267-
out=np.zeros_like(voxel_returns, dtype=float),
288+
out=np.zeros_like(core_returns, dtype=float),
268289
where=sum_counts[..., None] != 0
269290
)
270291

@@ -273,6 +294,197 @@ def calculate_fhd(voxel_returns) -> np.ndarray:
273294
return fhd
274295

275296

297+
def calculate_point_density(voxel_returns: np.ndarray,
298+
per_area: bool = False,
299+
cell_area: float | None = None) -> np.ndarray:
300+
"""
301+
Calculate point density (or count) per (X, Y) voxel column by summing returns across Z.
302+
303+
Args:
304+
voxel_returns (np.ndarray): 3D numpy array of shape (X, Y, Z) representing voxel returns (counts).
305+
per_area (bool, optional): If True, divide counts by ``cell_area`` to yield points per unit area. Defaults to False.
306+
cell_area (float or None, optional): Area of a single (X, Y) cell in the same units as the coordinates (e.g., m^2).
307+
Required when ``per_area=True``.
308+
309+
Returns:
310+
np.ndarray: 2D array (X, Y) of point counts (or density if per_area=True).
311+
312+
Notes:
313+
- For columns with no returns, the count is 0. This differs from metrics like FHD where no-data is NaN.
314+
- If you want density per m^2, set ``per_area=True`` and pass ``cell_area = dx * dy``.
315+
"""
316+
counts = np.sum(voxel_returns, axis=2, dtype=float)
317+
if per_area:
318+
if cell_area is None or cell_area <= 0:
319+
raise ValueError("cell_area must be > 0 when per_area=True")
320+
return counts / float(cell_area)
321+
return counts
322+
323+
324+
def calculate_voxel_stat(
325+
arr,
326+
voxel_resolution: Tuple[float, float, float],
327+
dimension: str,
328+
stat: str,
329+
z_index_range: Optional[Tuple[int, Optional[int]]] = None,
330+
):
331+
"""
332+
Compute a column-wise statistic for a given dimension over a 3-D voxel grid.
333+
334+
The function bins points into voxels with the same XY/Z sizing used by ``assign_voxels``.
335+
For each (X, Y) column it filters points to the requested Z-index range, then evaluates a
336+
simple statistic (mean, min, max, etc.) on the provided dimension.
337+
338+
Args:
339+
arr (np.ndarray): Structured array containing at least 'X', 'Y', and 'HeightAboveGround'
340+
fields, plus the provided ``dimension``.
341+
voxel_resolution (tuple[float, float, float]): (dx, dy, dz) sizes in the same units
342+
as the coordinates and height-above-ground values. All components must be > 0.
343+
dimension (str): Dimension/field name to evaluate (e.g. 'Z', 'Intensity',
344+
'HeightAboveGround'). The field must exist on ``arr``.
345+
stat (str): Statistic to compute. Supported values (case-insensitive) are:
346+
{'mean', 'sum', 'count', 'min', 'max', 'median', 'std'}.
347+
z_index_range (Tuple[int, Optional[int]] | None): Inclusive-exclusive Z index bounds
348+
expressed in voxel indices `(start, stop)`. Defaults to the full column when None.
349+
``stop`` may be None to include the topmost voxels. For example, `(0, 3)` covers
350+
the first three voxels (indices 0, 1, 2).
351+
352+
Returns:
353+
tuple[np.ndarray, list]: (stat_array, extent)
354+
- stat_array: 2-D array shaped (nx, ny) with the requested statistic per column.
355+
Cells without points are NaN (except for 'count', which yields 0).
356+
- extent: [x_min, x_max, y_min, y_max] covering the raster footprint.
357+
"""
358+
if dimension not in arr.dtype.names:
359+
raise KeyError(f"Dimension '{dimension}' not found in array fields")
360+
if 'HeightAboveGround' not in arr.dtype.names:
361+
raise KeyError("Input array must include a 'HeightAboveGround' field")
362+
363+
dx, dy, dz = voxel_resolution
364+
if dx <= 0 or dy <= 0 or dz <= 0:
365+
raise ValueError("voxel_resolution components must be > 0")
366+
367+
supported_stats = {'mean', 'sum', 'count', 'min', 'max', 'median', 'std'}
368+
key = stat.lower()
369+
if key not in supported_stats:
370+
raise ValueError(f"Unsupported statistic '{stat}'. "
371+
f"Choose from {sorted(supported_stats)}")
372+
373+
pts = arr[arr['HeightAboveGround'] >= 0]
374+
if pts.size == 0:
375+
raise ValueError("No points available (all HeightAboveGround < 0)")
376+
377+
x_vals = pts['X']
378+
y_vals = pts['Y']
379+
hag_vals = pts['HeightAboveGround']
380+
381+
x0 = np.floor(x_vals.min() / dx) * dx
382+
y0 = np.ceil(y_vals.max() / dy) * dy
383+
384+
x_bins = np.arange(x0, x_vals.max() + dx, dx)
385+
if x_bins.size < 2:
386+
x_bins = np.array([x0, x0 + dx])
387+
388+
y_bins_desc = np.arange(y0, y_vals.min() - dy, -dy)
389+
if y_bins_desc.size < 2:
390+
y_bins_desc = np.array([y0, y0 - dy])
391+
y_bins = y_bins_desc[::-1]
392+
393+
z_max = hag_vals.max()
394+
z_bins = np.arange(0.0, z_max + dz, dz)
395+
if z_bins.size < 2:
396+
z_bins = np.array([0.0, dz])
397+
398+
nx = len(x_bins) - 1
399+
ny = len(y_bins) - 1
400+
nz = len(z_bins) - 1
401+
402+
x_idx = np.digitize(x_vals, x_bins) - 1
403+
y_idx = np.digitize(y_vals, y_bins) - 1
404+
z_idx = np.digitize(hag_vals, z_bins) - 1
405+
406+
np.clip(x_idx, 0, nx - 1, out=x_idx)
407+
np.clip(y_idx, 0, ny - 1, out=y_idx)
408+
np.clip(z_idx, 0, nz - 1, out=z_idx)
409+
410+
if z_index_range is None:
411+
z_start, z_stop = 0, nz
412+
else:
413+
if len(z_index_range) != 2:
414+
raise ValueError("z_index_range must be a (start, stop) tuple")
415+
z_start = max(0, int(z_index_range[0]))
416+
z_stop = z_index_range[1]
417+
z_stop = nz if z_stop is None else min(int(z_stop), nz)
418+
if z_start >= z_stop:
419+
raise ValueError("z_index_range start must be < stop")
420+
421+
mask = (z_idx >= z_start) & (z_idx < z_stop)
422+
if not np.any(mask):
423+
result = np.full((nx, ny), np.nan, dtype=float)
424+
if key == 'count':
425+
result.fill(0.0)
426+
extent = [x_bins[0], x_bins[-1], y_bins_desc[-1], y_bins_desc[0]]
427+
return result, extent
428+
429+
x_idx = x_idx[mask]
430+
y_idx = y_idx[mask]
431+
values = np.asarray(pts[dimension][mask], dtype=float)
432+
433+
# Flip Y axis to match assign_voxels orientation
434+
y_idx = (ny - 1) - y_idx
435+
436+
flat_idx = x_idx * ny + y_idx
437+
flat_size = nx * ny
438+
439+
counts = np.bincount(flat_idx, minlength=flat_size).astype(float)
440+
441+
if key == 'count':
442+
data = counts
443+
elif key == 'sum':
444+
data = np.bincount(flat_idx, weights=values, minlength=flat_size)
445+
elif key == 'mean':
446+
sums = np.bincount(flat_idx, weights=values, minlength=flat_size)
447+
with np.errstate(invalid='ignore', divide='ignore'):
448+
data = sums / counts
449+
data[counts == 0] = np.nan
450+
elif key == 'std':
451+
sums = np.bincount(flat_idx, weights=values, minlength=flat_size)
452+
sumsq = np.bincount(flat_idx, weights=values * values, minlength=flat_size)
453+
with np.errstate(invalid='ignore', divide='ignore'):
454+
mean = sums / counts
455+
var = (sumsq / counts) - (mean ** 2)
456+
var[counts <= 0] = np.nan
457+
var[var < 0] = 0.0 # numerical safety
458+
data = np.sqrt(var)
459+
elif key == 'min':
460+
data = np.full(flat_size, np.inf, dtype=float)
461+
np.minimum.at(data, flat_idx, values)
462+
data[data == np.inf] = np.nan
463+
elif key == 'max':
464+
data = np.full(flat_size, -np.inf, dtype=float)
465+
np.maximum.at(data, flat_idx, values)
466+
data[data == -np.inf] = np.nan
467+
elif key == 'median':
468+
data = np.full(flat_size, np.nan, dtype=float)
469+
order = np.argsort(flat_idx, kind='mergesort')
470+
sorted_idx = flat_idx[order]
471+
sorted_vals = values[order]
472+
unique, first = np.unique(sorted_idx, return_index=True)
473+
counts_unique = np.diff(np.append(first, sorted_vals.size))
474+
for u, start, count in zip(unique, first, counts_unique):
475+
chunk = sorted_vals[start:start + count]
476+
data[u] = np.median(chunk)
477+
else:
478+
raise AssertionError("Unhandled statistic path")
479+
480+
grid = data.reshape(nx, ny)
481+
if key not in ('count', 'sum'):
482+
grid[counts.reshape(nx, ny) == 0] = np.nan
483+
484+
extent = [x_bins[0], x_bins[-1], y_bins_desc[-1], y_bins_desc[0]]
485+
return grid, extent
486+
487+
276488
def calculate_chm(arr, voxel_resolution, interpolation="linear",
277489
interp_valid_region=False, interp_clean_edges=False) -> Tuple[np.ndarray, List]:
278490
"""
@@ -314,13 +526,19 @@ def calculate_chm(arr, voxel_resolution, interpolation="linear",
314526
x_min, x_max = x.min(), x.max()
315527
y_min, y_max = y.min(), y.max()
316528

317-
x_bins = np.arange(x_min, x_max + x_resolution, x_resolution)
318-
y_bins = np.arange(y_min, y_max + y_resolution, y_resolution)
529+
nx = int(np.ceil((x_max - x_min) / x_resolution))
530+
ny = int(np.ceil((y_max - y_min) / y_resolution))
319531

320-
x_indices = np.digitize(x, x_bins) - 1
321-
y_indices = np.digitize(y, y_bins) - 1
532+
chm = np.full((nx, ny), np.nan)
533+
534+
x_bins = x_min + np.arange(nx + 1) * x_resolution
535+
y_bins = y_min + np.arange(ny + 1) * y_resolution
322536

323-
chm = np.full((len(x_bins) - 1, len(y_bins) - 1), np.nan)
537+
x_indices = np.floor((x - x_min) / x_resolution).astype(int)
538+
y_indices = np.floor((y - y_min) / y_resolution).astype(int)
539+
540+
np.minimum(x_indices, nx - 1, out=x_indices)
541+
np.minimum(y_indices, ny - 1, out=y_indices)
324542

325543
for xi, yi, zi in zip(x_indices, y_indices, z):
326544
if 0 <= xi < chm.shape[0] and 0 <= yi < chm.shape[1]:
@@ -361,7 +579,7 @@ def calculate_chm(arr, voxel_resolution, interpolation="linear",
361579
chm = _clean_edges(chm)
362580

363581
chm = np.flip(chm, axis=1)
364-
extent = [x_min, x_max, y_min, y_max]
582+
extent = [x_min, x_min + nx * x_resolution, y_min, y_min + ny * y_resolution]
365583

366584
return chm, extent
367585

pyforestscan/filters.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from pyforestscan.handlers import _build_pdal_pipeline
55
from pyforestscan.pipeline import _filter_hag, _filter_ground, _filter_statistical_outlier, _filter_smrf, \
6-
_filter_radius, _select_ground, _filter_voxeldownsize
6+
_filter_radius, _select_ground, _filter_voxeldownsize, _filter_pointsourceid
77

88

99
def filter_hag(arrays, lower_limit=0, upper_limit=None) -> List:
@@ -58,6 +58,21 @@ def filter_select_ground(arrays) -> List:
5858
return pipeline.arrays
5959

6060

61+
def filter_pointsourceid(arrays, pointsource_ids) -> List:
62+
"""
63+
Filter point cloud arrays to include only the requested PointSourceId values.
64+
65+
Args:
66+
arrays (list): Point cloud arrays to be processed.
67+
pointsource_ids (int or iterable of int): PointSourceId identifiers to keep.
68+
69+
Returns:
70+
list: Point cloud arrays containing only points with the specified PointSourceId values.
71+
"""
72+
pipeline = _build_pdal_pipeline(arrays, [_filter_pointsourceid(pointsource_ids)])
73+
return pipeline.arrays
74+
75+
6176
def remove_outliers_and_clean(arrays, mean_k=8, multiplier=3.0, remove=False) -> List:
6277
"""
6378
Processes input arrays by removing statistical outliers and optionally cleans
@@ -230,4 +245,3 @@ def downsample_voxel(arrays, cell, mode) -> List:
230245

231246
pipeline = _build_pdal_pipeline(arrays, [_filter_voxeldownsize(float(cell), mode_lc)])
232247
return pipeline.arrays
233-

0 commit comments

Comments
 (0)