33from scipy .interpolate import griddata
44from scipy .stats import entropy
55from scipy import ndimage
6- from typing import List , Tuple
6+ from typing import List , Tuple , Optional
77
88
99def 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+
276488def 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
0 commit comments