66from tqdm import tqdm
77
88from pyforestscan .calculate import calculate_fhd , calculate_pad , calculate_pai , assign_voxels , calculate_chm , calculate_canopy_cover
9- from pyforestscan .filters import remove_outliers_and_clean
9+ from pyforestscan .filters import remove_outliers_and_clean , downsample_poisson
1010from pyforestscan .handlers import create_geotiff
1111from pyforestscan .pipeline import _hag_raster , _hag_delaunay
1212from pyforestscan .utils import get_bounds_from_ept , get_srs_from_ept
@@ -52,7 +52,8 @@ 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 ,
5353 hag_dtm = False , dtm = None , bounds = None , interpolation = None , remove_outliers = False ,
5454 cover_min_height : float = 2.0 , cover_k : float = 0.5 ,
55- skip_existing : bool = False , verbose : bool = False ) -> None :
55+ skip_existing : bool = False , verbose : bool = False ,
56+ thin_radius : float | None = None ) -> None :
5657 """
5758 Process a large EPT point cloud by tiling, compute CHM or other metrics for each tile,
5859 and write the results to the specified output directory.
@@ -78,6 +79,8 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
7879 cover_k (float, optional): Beer–Lambert extinction coefficient for canopy cover. Defaults to 0.5.
7980 skip_existing (bool, optional): If True, skip tiles whose output file already exists. Defaults to False.
8081 verbose (bool, optional): If True, print warnings for empty/invalid tiles and buffer adjustments. Defaults to False.
82+ thin_radius (float or None, optional): If provided (> 0), apply Poisson radius-based thinning per tile before metrics.
83+ Units are in the same CRS as the data (e.g., meters). Defaults to None.
8184
8285 Returns:
8386 None
@@ -111,7 +114,8 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
111114 with tqdm (total = total_tiles , desc = "Processing tiles" ) as pbar :
112115 for i in range (num_tiles_x ):
113116 for j in range (num_tiles_y ):
114- if metric == "chm" :
117+ # Apply buffer+crop for CHM and for PAI/COVER to avoid seam artifacts.
118+ if metric in ["chm" , "pai" , "cover" ]:
115119 current_buffer_size = buffer_size
116120 else :
117121 current_buffer_size = 0.0
@@ -171,17 +175,31 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
171175
172176 tile_pipeline = pdal .Pipeline (json .dumps (tile_pipeline_json ))
173177 tile_pipeline .execute ()
174- if remove_outliers :
175- tile_points = remove_outliers_and_clean (tile_pipeline .arrays )[0 ]
176- else :
177- tile_points = tile_pipeline .arrays [0 ]
178178
179- if tile_points .size == 0 :
179+ # Extract points from pipeline output safely
180+ arrays = tile_pipeline .arrays if hasattr (tile_pipeline , "arrays" ) else []
181+ if not arrays or arrays [0 ].size == 0 :
180182 if verbose :
181183 print (f"Warning: No data in tile ({ i } , { j } ). Skipping." )
182184 pbar .update (1 )
183185 continue
184186
187+ if remove_outliers :
188+ tile_points = remove_outliers_and_clean (arrays )[0 ]
189+ else :
190+ tile_points = arrays [0 ]
191+
192+ # Optional radius-based thinning before metrics
193+ if thin_radius is not None and thin_radius > 0 :
194+ thinned = downsample_poisson ([tile_points ], thin_radius = thin_radius )
195+ tile_points = thinned [0 ] if thinned else tile_points
196+
197+ if tile_points .size == 0 :
198+ if verbose :
199+ print (f"Warning: Tile ({ i } , { j } ) empty after thinning. Skipping." )
200+ pbar .update (1 )
201+ continue
202+
185203 buffer_pixels_x = int (np .ceil (buffer_x / voxel_size [0 ]))
186204 buffer_pixels_y = int (np .ceil (buffer_y / voxel_size [1 ]))
187205
@@ -235,8 +253,13 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
235253 if np .all (pad == 0 ):
236254 result = np .zeros ((pad .shape [0 ], pad .shape [1 ]))
237255 else :
238- result = calculate_pai (pad , voxel_height )
239- result = np .where (np .isfinite (result ), result , 0 )
256+ # Guard against empty integration range when top height < default min_height
257+ effective_max_height = pad .shape [2 ] * voxel_size [- 1 ]
258+ default_min_height = 1.0
259+ if default_min_height >= effective_max_height :
260+ result = np .zeros ((pad .shape [0 ], pad .shape [1 ]))
261+ else :
262+ result = calculate_pai (pad , voxel_height )
240263 elif metric == "cover" :
241264 if not voxel_height :
242265 raise ValueError (f"voxel_height is required for metric { metric } " )
@@ -252,18 +275,25 @@ def process_with_tiles(ept_file, tile_size, output_path, metric, voxel_size,
252275 max_height = None ,
253276 k = cover_k ,
254277 )
255- result = np .where (np .isfinite (result ), result , 0 )
256278
257279 if current_buffer_size > 0 :
258280 if buffer_pixels_x * 2 >= result .shape [1 ] or buffer_pixels_y * 2 >= result .shape [0 ]:
259- print (
260- f"Warning: Buffer size exceeds { metric .upper ()} dimensions for tile ({ i } , { j } ). "
261- f"Adjusting buffer size."
262- )
281+ if verbose :
282+ print (
283+ f"Warning: Buffer size exceeds { metric .upper ()} dimensions for tile ({ i } , { j } ). "
284+ f"Adjusting buffer size."
285+ )
263286 buffer_pixels_x = max (0 , result .shape [1 ] // 2 - 1 )
264287 buffer_pixels_y = max (0 , result .shape [0 ] // 2 - 1 )
265288
266- result = result [buffer_pixels_y :- buffer_pixels_y , buffer_pixels_x :- buffer_pixels_x ]
289+ # Safe crop (avoid -0 slicing)
290+ start_x = buffer_pixels_x
291+ end_x = result .shape [1 ] - buffer_pixels_x if buffer_pixels_x > 0 else result .shape [1 ]
292+ start_y = buffer_pixels_y
293+ end_y = result .shape [0 ] - buffer_pixels_y if buffer_pixels_y > 0 else result .shape [0 ]
294+
295+ if end_x > start_x and end_y > start_y :
296+ result = result [start_y :end_y , start_x :end_x ]
267297
268298 core_extent = (
269299 tile_min_x + buffer_x ,
0 commit comments