Skip to content

Commit 3f820f4

Browse files
authored
Merge pull request #41 from iosefa/feat/add-thin-radius-to-process-tiles
process: add Poisson-based thinning and improve PAI tile handling
2 parents aa60351 + 9ed23a2 commit 3f820f4

File tree

2 files changed

+170
-16
lines changed

2 files changed

+170
-16
lines changed

pyforestscan/process.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from tqdm import tqdm
77

88
from 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
1010
from pyforestscan.handlers import create_geotiff
1111
from pyforestscan.pipeline import _hag_raster, _hag_delaunay
1212
from 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,

tests/test_process.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,127 @@ def test_process_with_tiles_no_data(mock_pipeline_cls, tmp_path):
7272

7373
created_tifs = list(out_dir.glob("*.tif"))
7474
assert len(created_tifs) == 0, "No data => should produce no TIF output."
75+
76+
77+
@patch("pyforestscan.process.pdal.Pipeline")
78+
def test_process_with_tiles_no_data_with_thinning(mock_pipeline_cls, tmp_path):
79+
"""
80+
If the pipeline returns no points and thinning is requested, ensure we still skip gracefully.
81+
"""
82+
mock_pipeline = MagicMock()
83+
mock_pipeline.execute.return_value = True
84+
mock_pipeline.arrays = [np.array([], dtype=[('X', 'f8'), ('Y', 'f8'), ('Z', 'f8')])]
85+
mock_pipeline_cls.return_value = mock_pipeline
86+
87+
out_dir = tmp_path / "test_fhd_thin"
88+
out_dir.mkdir()
89+
90+
process_with_tiles(
91+
ept_file="fake_ept_path",
92+
tile_size=(50, 50),
93+
output_path=str(out_dir),
94+
metric="fhd",
95+
voxel_size=(1, 1, 1),
96+
buffer_size=0.0,
97+
srs="EPSG:32610",
98+
hag=False,
99+
hag_dtm=False,
100+
dtm=None,
101+
bounds=([0, 50], [0, 50], [0, 50]),
102+
thin_radius=1.0
103+
)
104+
105+
created_tifs = list(out_dir.glob("*.tif"))
106+
assert len(created_tifs) == 0, "No data => should produce no TIF output (even with thinning)."
107+
108+
109+
@patch("pyforestscan.process.downsample_poisson")
110+
@patch("pyforestscan.process.pdal.Pipeline")
111+
def test_process_with_tiles_thin_radius_applied(mock_pipeline_cls, mock_downsample, tmp_path):
112+
"""
113+
When thin_radius is provided, ensure downsample_poisson is called and output is produced.
114+
"""
115+
# Mock PDAL pipeline to return a small synthetic point set with required fields
116+
dtype = [("X", "f8"), ("Y", "f8"), ("HeightAboveGround", "f8")]
117+
pts = np.zeros(100, dtype=dtype)
118+
pts["X"] = np.random.uniform(0, 10, size=100)
119+
pts["Y"] = np.random.uniform(0, 10, size=100)
120+
pts["HeightAboveGround"] = np.random.uniform(0, 5, size=100)
121+
122+
mock_pipeline = MagicMock()
123+
mock_pipeline.execute.return_value = True
124+
mock_pipeline.arrays = [pts]
125+
mock_pipeline_cls.return_value = mock_pipeline
126+
127+
# Mock downsample to return half the points, and track calls
128+
def _fake_downsample(arrays, thin_radius):
129+
arr = arrays[0]
130+
return [arr[::2]]
131+
132+
mock_downsample.side_effect = _fake_downsample
133+
134+
out_dir = tmp_path / "test_pai_thin"
135+
out_dir.mkdir()
136+
137+
process_with_tiles(
138+
ept_file="fake_ept_path",
139+
tile_size=(20, 20),
140+
output_path=str(out_dir),
141+
metric="pai",
142+
voxel_size=(2, 2, 1),
143+
voxel_height=1.0,
144+
buffer_size=0.0,
145+
srs="EPSG:32610",
146+
hag=False,
147+
hag_dtm=False,
148+
dtm=None,
149+
bounds=([0, 20], [0, 20], [0, 10]),
150+
thin_radius=1.0
151+
)
152+
153+
# Should have called thinning at least once
154+
assert mock_downsample.called, "downsample_poisson should be called when thin_radius is set"
155+
156+
# And produced at least one PAI output
157+
created_tifs = list(out_dir.glob("tile_*_pai.tif"))
158+
assert len(created_tifs) >= 1, "Expected at least one output tile for PAI."
159+
160+
161+
@patch("pyforestscan.process.pdal.Pipeline")
162+
def test_process_with_tiles_pai_handles_low_top_height(mock_pipeline_cls, tmp_path):
163+
"""
164+
When the available height is below the default PAI min_height (1 m),
165+
the process should not raise and should produce a tile (zeros allowed).
166+
"""
167+
dtype = [("X", "f8"), ("Y", "f8"), ("HeightAboveGround", "f8")]
168+
pts = np.zeros(50, dtype=dtype)
169+
pts["X"] = np.random.uniform(0, 10, size=50)
170+
pts["Y"] = np.random.uniform(0, 10, size=50)
171+
# HAG strictly below 1 m to force a single Z layer when dz=1
172+
pts["HeightAboveGround"] = np.random.uniform(0, 0.5, size=50)
173+
174+
mock_pipeline = MagicMock()
175+
mock_pipeline.execute.return_value = True
176+
mock_pipeline.arrays = [pts]
177+
mock_pipeline_cls.return_value = mock_pipeline
178+
179+
out_dir = tmp_path / "test_pai_lowtop"
180+
out_dir.mkdir()
181+
182+
process_with_tiles(
183+
ept_file="fake_ept_path",
184+
tile_size=(20, 20),
185+
output_path=str(out_dir),
186+
metric="pai",
187+
voxel_size=(2, 2, 1), # dz=1
188+
voxel_height=1.0,
189+
buffer_size=0.0,
190+
srs="EPSG:32610",
191+
hag=False,
192+
hag_dtm=False,
193+
dtm=None,
194+
bounds=([0, 20], [0, 20], [0, 10])
195+
)
196+
197+
created_tifs = list(out_dir.glob("tile_*_pai.tif"))
198+
assert len(created_tifs) >= 1, "Expected a PAI output tile even when top height < 1 m."

0 commit comments

Comments
 (0)