BERATools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beratools/__init__.py +3 -0
- beratools/core/__init__.py +0 -0
- beratools/core/algo_centerline.py +476 -0
- beratools/core/algo_common.py +489 -0
- beratools/core/algo_cost.py +185 -0
- beratools/core/algo_dijkstra.py +492 -0
- beratools/core/algo_footprint_rel.py +693 -0
- beratools/core/algo_line_grouping.py +941 -0
- beratools/core/algo_merge_lines.py +255 -0
- beratools/core/algo_split_with_lines.py +296 -0
- beratools/core/algo_vertex_optimization.py +451 -0
- beratools/core/constants.py +56 -0
- beratools/core/logger.py +92 -0
- beratools/core/tool_base.py +126 -0
- beratools/gui/__init__.py +11 -0
- beratools/gui/assets/BERALogo.png +0 -0
- beratools/gui/assets/beratools.json +471 -0
- beratools/gui/assets/closed.gif +0 -0
- beratools/gui/assets/closed.png +0 -0
- beratools/gui/assets/gui.json +8 -0
- beratools/gui/assets/open.gif +0 -0
- beratools/gui/assets/open.png +0 -0
- beratools/gui/assets/tool.gif +0 -0
- beratools/gui/assets/tool.png +0 -0
- beratools/gui/bt_data.py +485 -0
- beratools/gui/bt_gui_main.py +700 -0
- beratools/gui/main.py +27 -0
- beratools/gui/tool_widgets.py +730 -0
- beratools/tools/__init__.py +7 -0
- beratools/tools/canopy_threshold_relative.py +769 -0
- beratools/tools/centerline.py +127 -0
- beratools/tools/check_seed_line.py +48 -0
- beratools/tools/common.py +622 -0
- beratools/tools/line_footprint_absolute.py +203 -0
- beratools/tools/line_footprint_fixed.py +480 -0
- beratools/tools/line_footprint_functions.py +884 -0
- beratools/tools/line_footprint_relative.py +75 -0
- beratools/tools/tool_template.py +72 -0
- beratools/tools/vertex_optimization.py +57 -0
- beratools-0.1.0.dist-info/METADATA +134 -0
- beratools-0.1.0.dist-info/RECORD +44 -0
- beratools-0.1.0.dist-info/WHEEL +4 -0
- beratools-0.1.0.dist-info/entry_points.txt +2 -0
- beratools-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (C) 2025 Applied Geospatial Research Group.
|
|
3
|
+
|
|
4
|
+
This script is licensed under the GNU General Public License v3.0.
|
|
5
|
+
See <https://gnu.org/licenses/gpl-3.0> for full license details.
|
|
6
|
+
|
|
7
|
+
Author: Richard Zeng, Maverick Fong
|
|
8
|
+
|
|
9
|
+
Description:
|
|
10
|
+
This script is part of the BERA Tools.
|
|
11
|
+
Webpage: https://github.com/appliedgrg/beratools
|
|
12
|
+
|
|
13
|
+
The purpose of this script is to provide main interface for canopy footprint tool.
|
|
14
|
+
The tool is used to generate the footprint of a line based on relative threshold.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import time
|
|
19
|
+
from enum import Enum
|
|
20
|
+
|
|
21
|
+
import geopandas as gpd
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import rasterio.features as ras_feat
|
|
25
|
+
import shapely
|
|
26
|
+
import shapely.geometry as sh_geom
|
|
27
|
+
import shapely.geometry.base as sh_base
|
|
28
|
+
import shapely.ops as sh_ops
|
|
29
|
+
from skimage.graph import MCP_Flexible
|
|
30
|
+
|
|
31
|
+
import beratools.core.algo_common as algo_common
|
|
32
|
+
import beratools.core.algo_cost as algo_cost
|
|
33
|
+
import beratools.core.constants as bt_const
|
|
34
|
+
import beratools.core.tool_base as bt_base
|
|
35
|
+
import beratools.tools.common as bt_common
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Side(Enum):
|
|
39
|
+
"""Constants for left and right side."""
|
|
40
|
+
|
|
41
|
+
left = "left"
|
|
42
|
+
right = "right"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FootprintCanopy:
|
|
46
|
+
"""Relative canopy footprint class."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, in_geom, in_chm, in_layer=None):
|
|
49
|
+
data = gpd.read_file(in_geom, layer=in_layer)
|
|
50
|
+
self.lines = []
|
|
51
|
+
|
|
52
|
+
for idx in data.index:
|
|
53
|
+
line = LineInfo(data.iloc[[idx]], in_chm)
|
|
54
|
+
self.lines.append(line)
|
|
55
|
+
|
|
56
|
+
def compute(self, processes, parallel_mode=bt_const.ParallelMode.MULTIPROCESSING):
|
|
57
|
+
result = bt_base.execute_multiprocessing(
|
|
58
|
+
algo_common.process_single_item,
|
|
59
|
+
self.lines,
|
|
60
|
+
"Canopy Footprint",
|
|
61
|
+
processes,
|
|
62
|
+
parallel_mode,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
fp = []
|
|
66
|
+
percentile = []
|
|
67
|
+
try:
|
|
68
|
+
for item in result:
|
|
69
|
+
if item.footprint is not None:
|
|
70
|
+
fp.append(item.footprint)
|
|
71
|
+
else:
|
|
72
|
+
print("Footprint is None for one of the lines.")
|
|
73
|
+
continue # Skip failed line
|
|
74
|
+
|
|
75
|
+
if fp:
|
|
76
|
+
self.footprints = pd.concat(fp)
|
|
77
|
+
else:
|
|
78
|
+
print("No valid footprints to save.")
|
|
79
|
+
self.footprints = None
|
|
80
|
+
|
|
81
|
+
for item in result:
|
|
82
|
+
if item.lines_percentile is not None:
|
|
83
|
+
percentile.append(item.lines_percentile)
|
|
84
|
+
else:
|
|
85
|
+
print("lines_percentile is None for one of the lines.")
|
|
86
|
+
continue # Skip failed line
|
|
87
|
+
|
|
88
|
+
if percentile:
|
|
89
|
+
self.lines_percentile = pd.concat(percentile)
|
|
90
|
+
else:
|
|
91
|
+
print("No valid lines_percentile to save.")
|
|
92
|
+
self.lines_percentile = None
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print(f"Error during processing: {e}")
|
|
95
|
+
|
|
96
|
+
def save_footprint(self, out_footprint, layer=None):
|
|
97
|
+
if self.footprints is not None and isinstance(self.footprints, gpd.GeoDataFrame):
|
|
98
|
+
self.footprints.to_file(out_footprint, layer=layer)
|
|
99
|
+
else:
|
|
100
|
+
print("No footprints to save (None or not a GeoDataFrame).")
|
|
101
|
+
|
|
102
|
+
def save_line_percentile(self, out_percentile):
|
|
103
|
+
if self.lines_percentile is not None and isinstance(self.lines_percentile, gpd.GeoDataFrame):
|
|
104
|
+
self.lines_percentile.to_file(out_percentile)
|
|
105
|
+
else:
|
|
106
|
+
print("No lines_percentile to save (None or not a GeoDataFrame).")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class BufferRing:
|
|
110
|
+
"""Buffer ring class."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, ring_poly, side):
|
|
113
|
+
self.geometry = ring_poly
|
|
114
|
+
self.side = side
|
|
115
|
+
self.percentile = 0.5
|
|
116
|
+
self.Dyn_Canopy_Threshold = 0.05
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class LineInfo:
|
|
120
|
+
"""Class to store line information."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
line_gdf,
|
|
125
|
+
in_chm,
|
|
126
|
+
max_ln_width=32,
|
|
127
|
+
tree_radius=1.5,
|
|
128
|
+
max_line_dist=1.5,
|
|
129
|
+
canopy_avoidance=0.0,
|
|
130
|
+
exponent=1.0,
|
|
131
|
+
canopy_thresh_percentage=50,
|
|
132
|
+
):
|
|
133
|
+
self.line = line_gdf
|
|
134
|
+
self.in_chm = in_chm
|
|
135
|
+
self.line_simp = self.line.geometry.simplify(tolerance=0.5, preserve_topology=True)
|
|
136
|
+
|
|
137
|
+
self.canopy_percentile = 50
|
|
138
|
+
self.DynCanTh = np.nan
|
|
139
|
+
# chk_df_multipart
|
|
140
|
+
# if proc_segments:
|
|
141
|
+
# line_seg = split_into_segments(line_seg)
|
|
142
|
+
|
|
143
|
+
self.buffer_rings = []
|
|
144
|
+
|
|
145
|
+
self.CL_CutHt = np.nan
|
|
146
|
+
self.CR_CutHt = np.nan
|
|
147
|
+
self.RDist_Cut = np.nan
|
|
148
|
+
self.LDist_Cut = np.nan
|
|
149
|
+
|
|
150
|
+
self.canopy_thresh_percentage = canopy_thresh_percentage
|
|
151
|
+
self.canopy_avoidance = canopy_avoidance
|
|
152
|
+
self.exponent = exponent
|
|
153
|
+
self.max_ln_width = max_ln_width
|
|
154
|
+
self.max_line_dist = max_line_dist
|
|
155
|
+
self.tree_radius = tree_radius
|
|
156
|
+
|
|
157
|
+
self.nodata = -9999
|
|
158
|
+
self.dyn_canopy_ndarray = None
|
|
159
|
+
self.negative_cost_clip = None
|
|
160
|
+
self.out_meta = None
|
|
161
|
+
|
|
162
|
+
self.buffer_left = None
|
|
163
|
+
self.buffer_right = None
|
|
164
|
+
self.footprint = None
|
|
165
|
+
|
|
166
|
+
self.lines_percentile = None
|
|
167
|
+
|
|
168
|
+
def compute(self):
|
|
169
|
+
self.prepare_ring_buffer()
|
|
170
|
+
|
|
171
|
+
ring_list = []
|
|
172
|
+
for item in self.buffer_rings:
|
|
173
|
+
ring = self.cal_percentileRing(item)
|
|
174
|
+
if ring is not None:
|
|
175
|
+
ring_list.append(ring)
|
|
176
|
+
else:
|
|
177
|
+
# print("Skipping invalid ring.")
|
|
178
|
+
# TODO: handle None rings appropriately
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
self.buffer_rings = ring_list
|
|
182
|
+
|
|
183
|
+
# Aggregate percentiles and geometries for lines_percentile
|
|
184
|
+
percentile_records = []
|
|
185
|
+
for ring in self.buffer_rings:
|
|
186
|
+
if ring is not None and hasattr(ring, "geometry") and hasattr(ring, "percentile"):
|
|
187
|
+
percentile_records.append(
|
|
188
|
+
{"geometry": ring.geometry, "percentile": ring.percentile, "side": ring.side.value}
|
|
189
|
+
)
|
|
190
|
+
if percentile_records:
|
|
191
|
+
self.lines_percentile = gpd.GeoDataFrame(percentile_records)
|
|
192
|
+
self.lines_percentile.set_geometry("geometry", inplace=True)
|
|
193
|
+
if self.line.crs:
|
|
194
|
+
self.lines_percentile = self.lines_percentile.set_crs(self.line.crs, allow_override=True)
|
|
195
|
+
else:
|
|
196
|
+
self.lines_percentile = None
|
|
197
|
+
|
|
198
|
+
self.rate_of_change(self.get_percentile_array(Side.left), Side.left)
|
|
199
|
+
self.rate_of_change(self.get_percentile_array(Side.right), Side.right)
|
|
200
|
+
|
|
201
|
+
self.line["CL_CutHt"] = self.CL_CutHt
|
|
202
|
+
self.line["CR_CutHt"] = self.CR_CutHt
|
|
203
|
+
self.line["RDist_Cut"] = self.RDist_Cut
|
|
204
|
+
self.line["LDist_Cut"] = self.LDist_Cut
|
|
205
|
+
|
|
206
|
+
self.DynCanTh = (self.CL_CutHt + self.CR_CutHt) / 2
|
|
207
|
+
self.line["DynCanTh"] = self.DynCanTh
|
|
208
|
+
|
|
209
|
+
self.prepare_line_buffer()
|
|
210
|
+
|
|
211
|
+
fp_left = self.process_single_footprint(Side.left)
|
|
212
|
+
fp_right = self.process_single_footprint(Side.right)
|
|
213
|
+
|
|
214
|
+
# Check if footprints are valid
|
|
215
|
+
if fp_left is None or fp_right is None:
|
|
216
|
+
print("One or both footprints are None in LineInfo.")
|
|
217
|
+
self.footprint = None
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Buffer cleanup for validity
|
|
222
|
+
fp_left.geometry = fp_left.geometry.buffer(0)
|
|
223
|
+
fp_right.geometry = fp_right.geometry.buffer(0)
|
|
224
|
+
|
|
225
|
+
fp_combined = pd.concat([fp_left, fp_right])
|
|
226
|
+
|
|
227
|
+
if fp_combined.empty or not isinstance(fp_combined, gpd.GeoDataFrame):
|
|
228
|
+
print("Combined footprint is invalid or empty.")
|
|
229
|
+
self.footprint = None
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
fp_combined = fp_combined.dissolve()
|
|
233
|
+
fp_combined.geometry = fp_combined.geometry.buffer(-0.005)
|
|
234
|
+
|
|
235
|
+
self.footprint = fp_combined
|
|
236
|
+
except Exception as e:
|
|
237
|
+
print(f"Error combining footprints: {e}")
|
|
238
|
+
self.footprint = None
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Transfer group value to footprint if present
|
|
242
|
+
if bt_const.BT_GROUP in self.line.columns:
|
|
243
|
+
self.footprint[bt_const.BT_GROUP] = self.line[bt_const.BT_GROUP].iloc[0]
|
|
244
|
+
|
|
245
|
+
def prepare_ring_buffer(self):
|
|
246
|
+
nrings = 1
|
|
247
|
+
ringdist = 15
|
|
248
|
+
ring_list = self.multi_ring_buffer(self.line_simp, nrings, ringdist)
|
|
249
|
+
for i in ring_list:
|
|
250
|
+
if BufferRing(i, Side.left):
|
|
251
|
+
self.buffer_rings.append(BufferRing(i, Side.left))
|
|
252
|
+
else:
|
|
253
|
+
print("Empty buffer ring")
|
|
254
|
+
|
|
255
|
+
nrings = -1
|
|
256
|
+
ringdist = -15
|
|
257
|
+
ring_list = self.multi_ring_buffer(self.line_simp, nrings, ringdist)
|
|
258
|
+
for i in ring_list:
|
|
259
|
+
if BufferRing(i, Side.right):
|
|
260
|
+
self.buffer_rings.append(BufferRing(i, Side.right))
|
|
261
|
+
else:
|
|
262
|
+
print("Empty buffer ring")
|
|
263
|
+
|
|
264
|
+
def cal_percentileRing(self, ring):
|
|
265
|
+
line_buffer = None
|
|
266
|
+
try:
|
|
267
|
+
line_buffer = ring.geometry
|
|
268
|
+
if line_buffer.is_empty or shapely.is_missing(line_buffer):
|
|
269
|
+
return None
|
|
270
|
+
if line_buffer.has_z:
|
|
271
|
+
line_buffer = sh_ops.transform(lambda x, y, z=None: (x, y), line_buffer)
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
print(f"cal_percentileRing: {e}")
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
# TODO: temporary workaround for exception causing not percentile defined
|
|
278
|
+
try:
|
|
279
|
+
clipped_raster, _ = bt_common.clip_raster(self.in_chm, line_buffer, 0)
|
|
280
|
+
clipped_raster = np.squeeze(clipped_raster, axis=0)
|
|
281
|
+
|
|
282
|
+
# mask all -9999 (nodata) value cells
|
|
283
|
+
masked_raster = np.ma.masked_where(clipped_raster == bt_const.BT_NODATA, clipped_raster)
|
|
284
|
+
filled_raster = np.ma.filled(masked_raster, np.nan)
|
|
285
|
+
|
|
286
|
+
# Calculate the percentile
|
|
287
|
+
percentile = np.nanpercentile(filled_raster, 50)
|
|
288
|
+
|
|
289
|
+
if percentile > 1:
|
|
290
|
+
ring.Dyn_Canopy_Threshold = percentile * (0.3)
|
|
291
|
+
else:
|
|
292
|
+
ring.Dyn_Canopy_Threshold = 1
|
|
293
|
+
|
|
294
|
+
ring.percentile = percentile
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(e)
|
|
297
|
+
print("Default values are used.")
|
|
298
|
+
|
|
299
|
+
return ring
|
|
300
|
+
|
|
301
|
+
def get_percentile_array(self, side):
|
|
302
|
+
per_array = []
|
|
303
|
+
for item in self.buffer_rings:
|
|
304
|
+
try:
|
|
305
|
+
if item.side == side:
|
|
306
|
+
per_array.append(item.percentile)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
print(e)
|
|
309
|
+
|
|
310
|
+
return per_array
|
|
311
|
+
|
|
312
|
+
def rate_of_change(self, percentile_array, side):
|
|
313
|
+
# Since the x interval is 1 unit, the array 'diff' is the rate of change (slope)
|
|
314
|
+
diff = np.ediff1d(percentile_array)
|
|
315
|
+
cut_dist = len(percentile_array) / 5
|
|
316
|
+
|
|
317
|
+
median_percentile = np.nanmedian(percentile_array)
|
|
318
|
+
if not np.isnan(median_percentile):
|
|
319
|
+
cut_percentile = float(math.floor(median_percentile))
|
|
320
|
+
else:
|
|
321
|
+
cut_percentile = 0.5
|
|
322
|
+
|
|
323
|
+
found = False
|
|
324
|
+
changes = 1.50
|
|
325
|
+
Change = np.insert(diff, 0, 0)
|
|
326
|
+
scale_down = 1.0
|
|
327
|
+
|
|
328
|
+
# test the rate of change is > than 150% (1.5), if it is
|
|
329
|
+
# no result found then lower to 140% (1.4) until 110% (1.1)
|
|
330
|
+
try:
|
|
331
|
+
while not found and changes >= 1.1:
|
|
332
|
+
for ii in range(0, len(Change) - 1):
|
|
333
|
+
if percentile_array[ii] >= 0.5:
|
|
334
|
+
if (Change[ii]) >= changes:
|
|
335
|
+
cut_dist = (ii + 1) * scale_down
|
|
336
|
+
cut_percentile = math.floor(percentile_array[ii])
|
|
337
|
+
|
|
338
|
+
if 0.5 >= cut_percentile:
|
|
339
|
+
if cut_dist > 5:
|
|
340
|
+
cut_percentile = 2
|
|
341
|
+
cut_dist = cut_dist * scale_down**3
|
|
342
|
+
# @<0.5 found and modified
|
|
343
|
+
elif 0.5 < cut_percentile <= 5.0:
|
|
344
|
+
if cut_dist > 6:
|
|
345
|
+
cut_dist = cut_dist * scale_down**3 # 4.0
|
|
346
|
+
# @0.5-5.0 found and modified
|
|
347
|
+
elif 5.0 < cut_percentile <= 10.0:
|
|
348
|
+
if cut_dist > 8: # 5
|
|
349
|
+
cut_dist = cut_dist * scale_down**3
|
|
350
|
+
# @5-10 found and modified
|
|
351
|
+
elif 10.0 < cut_percentile <= 15:
|
|
352
|
+
if cut_dist > 5:
|
|
353
|
+
cut_dist = cut_dist * scale_down**3 # 5.5
|
|
354
|
+
# @10-15 found and modified
|
|
355
|
+
elif 15 < cut_percentile:
|
|
356
|
+
if cut_dist > 4:
|
|
357
|
+
cut_dist = cut_dist * scale_down**2
|
|
358
|
+
cut_percentile = 15.5
|
|
359
|
+
# @>15 found and modified
|
|
360
|
+
found = True
|
|
361
|
+
# rate of change found
|
|
362
|
+
break
|
|
363
|
+
changes = changes - 0.1
|
|
364
|
+
|
|
365
|
+
except IndexError:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
# if still no result found, lower to 10% (1.1),
|
|
369
|
+
# if no result found then default is used
|
|
370
|
+
if not found:
|
|
371
|
+
if 0.5 >= median_percentile:
|
|
372
|
+
cut_dist = 4 * scale_down # 3
|
|
373
|
+
cut_percentile = 0.5
|
|
374
|
+
elif 0.5 < median_percentile <= 5.0:
|
|
375
|
+
cut_dist = 4.5 * scale_down # 4.0
|
|
376
|
+
cut_percentile = math.floor(median_percentile)
|
|
377
|
+
elif 5.0 < median_percentile <= 10.0:
|
|
378
|
+
cut_dist = 5.5 * scale_down # 5
|
|
379
|
+
cut_percentile = math.floor(median_percentile)
|
|
380
|
+
elif 10.0 < median_percentile <= 15:
|
|
381
|
+
cut_dist = 6 * scale_down # 5.5
|
|
382
|
+
cut_percentile = math.floor(median_percentile)
|
|
383
|
+
elif 15 < median_percentile:
|
|
384
|
+
cut_dist = 5 * scale_down # 5
|
|
385
|
+
cut_percentile = 15.5
|
|
386
|
+
|
|
387
|
+
if side == Side.right:
|
|
388
|
+
self.RDist_Cut = cut_dist
|
|
389
|
+
self.CR_CutHt = float(cut_percentile)
|
|
390
|
+
elif side == Side.left:
|
|
391
|
+
self.LDist_Cut = cut_dist
|
|
392
|
+
self.CL_CutHt = float(cut_percentile)
|
|
393
|
+
|
|
394
|
+
def multi_ring_buffer(self, df, nrings, ringdist):
|
|
395
|
+
"""
|
|
396
|
+
Buffers an input DataFrames geometry nring (number of rings) times.
|
|
397
|
+
|
|
398
|
+
Compute with a distance between rings of ringdist and returns
|
|
399
|
+
a list of non overlapping buffers
|
|
400
|
+
"""
|
|
401
|
+
rings = [] # A list to hold the individual buffers
|
|
402
|
+
line = df.geometry.iloc[0]
|
|
403
|
+
# For each ring (1, 2, 3, ..., nrings)
|
|
404
|
+
for ring in np.arange(0, ringdist, nrings):
|
|
405
|
+
big_ring = line.buffer(
|
|
406
|
+
nrings + ring, single_sided=True, cap_style="flat"
|
|
407
|
+
) # Create one big buffer
|
|
408
|
+
small_ring = line.buffer(ring, single_sided=True, cap_style="flat") # Create one smaller one
|
|
409
|
+
the_ring = big_ring.difference(small_ring) # Difference the big with the small to create a ring
|
|
410
|
+
if (
|
|
411
|
+
~shapely.is_empty(the_ring)
|
|
412
|
+
or ~shapely.is_missing(the_ring)
|
|
413
|
+
or not None
|
|
414
|
+
or ~the_ring.area == 0
|
|
415
|
+
):
|
|
416
|
+
if isinstance(the_ring, sh_geom.MultiPolygon) or isinstance(the_ring, shapely.Polygon):
|
|
417
|
+
rings.append(the_ring) # Append the ring to the rings list
|
|
418
|
+
else:
|
|
419
|
+
if isinstance(the_ring, shapely.GeometryCollection):
|
|
420
|
+
for i in range(0, len(the_ring.geoms)):
|
|
421
|
+
if not isinstance(the_ring.geoms[i], shapely.LineString):
|
|
422
|
+
rings.append(the_ring.geoms[i])
|
|
423
|
+
|
|
424
|
+
return rings # return the list
|
|
425
|
+
|
|
426
|
+
def prepare_line_buffer(self):
|
|
427
|
+
line = self.line.geometry.iloc[0]
|
|
428
|
+
buffer_left_1 = line.buffer(
|
|
429
|
+
distance=self.max_ln_width + 1,
|
|
430
|
+
cap_style=3,
|
|
431
|
+
single_sided=True,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
buffer_left_2 = line.buffer(
|
|
435
|
+
distance=-1,
|
|
436
|
+
cap_style=3,
|
|
437
|
+
single_sided=True,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
self.buffer_left = sh_ops.unary_union([buffer_left_1, buffer_left_2])
|
|
441
|
+
|
|
442
|
+
buffer_right_1 = line.buffer(
|
|
443
|
+
distance=-self.max_ln_width - 1,
|
|
444
|
+
cap_style=3,
|
|
445
|
+
single_sided=True,
|
|
446
|
+
)
|
|
447
|
+
buffer_right_2 = line.buffer(distance=1, cap_style=3, single_sided=True)
|
|
448
|
+
|
|
449
|
+
self.buffer_right = sh_ops.unary_union([buffer_right_1, buffer_right_2])
|
|
450
|
+
|
|
451
|
+
def dyn_canopy_cost_raster(self, side):
|
|
452
|
+
in_chm_raster = self.in_chm
|
|
453
|
+
# tree_radius = self.tree_radius
|
|
454
|
+
# max_line_dist = self.max_line_dist
|
|
455
|
+
# canopy_avoid = self.canopy_avoidance
|
|
456
|
+
# exponent = self.exponent
|
|
457
|
+
line_df = self.line
|
|
458
|
+
out_meta = self.out_meta
|
|
459
|
+
|
|
460
|
+
canopy_thresh_percentage = self.canopy_thresh_percentage / 100
|
|
461
|
+
|
|
462
|
+
Cut_Dist = None
|
|
463
|
+
line_buffer = None
|
|
464
|
+
if side == Side.left:
|
|
465
|
+
canopy_ht_threshold = line_df.CL_CutHt * canopy_thresh_percentage
|
|
466
|
+
Cut_Dist = self.LDist_Cut
|
|
467
|
+
line_buffer = self.buffer_left
|
|
468
|
+
elif side == Side.right:
|
|
469
|
+
canopy_ht_threshold = line_df.CR_CutHt * canopy_thresh_percentage
|
|
470
|
+
Cut_Dist = self.RDist_Cut
|
|
471
|
+
line_buffer = self.buffer_right
|
|
472
|
+
else:
|
|
473
|
+
canopy_ht_threshold = 0.5
|
|
474
|
+
Cut_Dist = 1.0
|
|
475
|
+
line_buffer = None
|
|
476
|
+
|
|
477
|
+
canopy_ht_threshold = float(canopy_ht_threshold)
|
|
478
|
+
if canopy_ht_threshold <= 0:
|
|
479
|
+
canopy_ht_threshold = 0.5
|
|
480
|
+
|
|
481
|
+
# get the round up integer number for tree search radius
|
|
482
|
+
# tree_radius = float(tree_radius)
|
|
483
|
+
# max_line_dist = float(max_line_dist)
|
|
484
|
+
# canopy_avoid = float(canopy_avoid)
|
|
485
|
+
# cost_raster_exponent = float(exponent)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
clipped_rasterC, out_meta = bt_common.clip_raster(in_chm_raster, line_buffer, 0)
|
|
489
|
+
negative_cost_clip, dyn_canopy_ndarray = algo_cost.cost_raster(
|
|
490
|
+
clipped_rasterC,
|
|
491
|
+
out_meta,
|
|
492
|
+
self.tree_radius,
|
|
493
|
+
canopy_ht_threshold,
|
|
494
|
+
self.max_line_dist,
|
|
495
|
+
self.canopy_avoidance,
|
|
496
|
+
self.exponent,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return dyn_canopy_ndarray, negative_cost_clip, out_meta, Cut_Dist
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
print(f"dyn_canopy_cost_raster: {e}")
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
def process_single_footprint(self, side):
|
|
506
|
+
# this will change segment content, and parameters will be changed
|
|
507
|
+
in_canopy_r, in_cost_r, in_meta, Cut_Dist = self.dyn_canopy_cost_raster(side)
|
|
508
|
+
|
|
509
|
+
if np.isnan(in_canopy_r).all():
|
|
510
|
+
print("Canopy raster empty")
|
|
511
|
+
|
|
512
|
+
if np.isnan(in_cost_r).all():
|
|
513
|
+
print("Cost raster empty")
|
|
514
|
+
|
|
515
|
+
exp_shk_cell = self.exponent # TODO: duplicate vars
|
|
516
|
+
no_data = self.nodata
|
|
517
|
+
|
|
518
|
+
shapefile_proj = self.line.crs
|
|
519
|
+
in_transform = in_meta["transform"]
|
|
520
|
+
|
|
521
|
+
segment_list = []
|
|
522
|
+
|
|
523
|
+
feat = self.line.geometry.iloc[0]
|
|
524
|
+
if hasattr(feat, "geoms"):
|
|
525
|
+
for geom in feat.geoms:
|
|
526
|
+
for coord in geom.coords:
|
|
527
|
+
segment_list.append(coord)
|
|
528
|
+
else:
|
|
529
|
+
for coord in feat.coords:
|
|
530
|
+
segment_list.append(coord)
|
|
531
|
+
|
|
532
|
+
cell_size_x = in_transform[0]
|
|
533
|
+
cell_size_y = -in_transform[4]
|
|
534
|
+
|
|
535
|
+
# Work out the corridor from both end of the centerline
|
|
536
|
+
try:
|
|
537
|
+
if len(in_cost_r.shape) > 2:
|
|
538
|
+
in_cost_r = np.squeeze(in_cost_r, axis=0)
|
|
539
|
+
|
|
540
|
+
algo_cost.remove_nan_from_array_refactor(in_cost_r)
|
|
541
|
+
in_cost_r[in_cost_r == no_data] = np.inf
|
|
542
|
+
|
|
543
|
+
# generate 1m interval points along line
|
|
544
|
+
distance_delta = 1
|
|
545
|
+
distances = np.arange(0, feat.length, distance_delta)
|
|
546
|
+
multipoint_along_line = [feat.interpolate(distance) for distance in distances]
|
|
547
|
+
multipoint_along_line.append(sh_geom.Point(segment_list[-1]))
|
|
548
|
+
# Rasterize points along line
|
|
549
|
+
rasterized_points_Alongln = ras_feat.rasterize(
|
|
550
|
+
multipoint_along_line,
|
|
551
|
+
out_shape=in_cost_r.shape,
|
|
552
|
+
transform=in_transform,
|
|
553
|
+
fill=0,
|
|
554
|
+
all_touched=True,
|
|
555
|
+
default_value=1,
|
|
556
|
+
)
|
|
557
|
+
points_Alongln = np.transpose(np.nonzero(rasterized_points_Alongln))
|
|
558
|
+
|
|
559
|
+
# Find minimum cost paths through an N-d costs array.
|
|
560
|
+
mcp_flexible1 = MCP_Flexible(in_cost_r, sampling=(cell_size_x, cell_size_y), fully_connected=True)
|
|
561
|
+
flex_cost_alongLn, flex_back_alongLn = mcp_flexible1.find_costs(starts=points_Alongln)
|
|
562
|
+
|
|
563
|
+
# Generate corridor
|
|
564
|
+
corridor = flex_cost_alongLn
|
|
565
|
+
corridor = np.ma.masked_invalid(corridor)
|
|
566
|
+
|
|
567
|
+
# Calculate minimum value of corridor raster
|
|
568
|
+
if np.ma.min(corridor) is not None:
|
|
569
|
+
corr_min = float(np.ma.min(corridor))
|
|
570
|
+
else:
|
|
571
|
+
corr_min = 0.5
|
|
572
|
+
|
|
573
|
+
# normalize corridor raster by deducting corr_min
|
|
574
|
+
corridor_norm = corridor - corr_min
|
|
575
|
+
|
|
576
|
+
# Set minimum as zero and save minimum file
|
|
577
|
+
corridor_th_value = Cut_Dist / cell_size_x
|
|
578
|
+
if corridor_th_value < 0: # if no threshold found, use default value
|
|
579
|
+
corridor_th_value = bt_const.FP_CORRIDOR_THRESHOLD / cell_size_x
|
|
580
|
+
|
|
581
|
+
corridor_thresh = np.ma.where(corridor_norm >= corridor_th_value, 1.0, 0.0)
|
|
582
|
+
clean_raster = algo_common.morph_raster(corridor_thresh, in_canopy_r, exp_shk_cell, cell_size_x)
|
|
583
|
+
|
|
584
|
+
# create mask for non-polygon area
|
|
585
|
+
mask = np.where(clean_raster == 1, True, False)
|
|
586
|
+
if clean_raster.dtype == np.int64:
|
|
587
|
+
clean_raster = clean_raster.astype(np.int32)
|
|
588
|
+
|
|
589
|
+
# Process: ndarray to shapely Polygon
|
|
590
|
+
out_polygon = ras_feat.shapes(clean_raster, mask=mask, transform=in_transform)
|
|
591
|
+
|
|
592
|
+
# create a shapely MultiPolygon
|
|
593
|
+
multi_polygon = []
|
|
594
|
+
if out_polygon is not None:
|
|
595
|
+
try:
|
|
596
|
+
for poly, value in out_polygon:
|
|
597
|
+
multi_polygon.append(sh_geom.shape(poly))
|
|
598
|
+
except TypeError:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
if not multi_polygon:
|
|
602
|
+
print("No polygons generated from raster. Returning None.")
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
poly = sh_geom.MultiPolygon(multi_polygon) if multi_polygon else None
|
|
606
|
+
|
|
607
|
+
# create GeoDataFrame directly from dictionary
|
|
608
|
+
out_gdata = gpd.GeoDataFrame({"CorriThresh": [corridor_th_value], "geometry": [poly]})
|
|
609
|
+
out_gdata.set_geometry("geometry", inplace=True)
|
|
610
|
+
if shapefile_proj:
|
|
611
|
+
out_gdata = out_gdata.set_crs(shapefile_proj, allow_override=True)
|
|
612
|
+
|
|
613
|
+
if out_gdata is None or out_gdata.empty or out_gdata.geometry.isnull().all():
|
|
614
|
+
print("Empty GeoDataFrame from process_single_footprint.")
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
return out_gdata
|
|
618
|
+
|
|
619
|
+
except Exception as e:
|
|
620
|
+
print("Exception: {}".format(e))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def line_footprint_rel(
|
|
624
|
+
in_line,
|
|
625
|
+
in_chm,
|
|
626
|
+
out_footprint,
|
|
627
|
+
processes,
|
|
628
|
+
verbose=True,
|
|
629
|
+
in_layer=None,
|
|
630
|
+
out_layer=None,
|
|
631
|
+
max_ln_width=32,
|
|
632
|
+
tree_radius=1.5,
|
|
633
|
+
max_line_dist=1.5,
|
|
634
|
+
canopy_avoidance=0.0,
|
|
635
|
+
exponent=1.0,
|
|
636
|
+
canopy_thresh_percentage=50,
|
|
637
|
+
parallel_mode=bt_const.ParallelMode.MULTIPROCESSING,
|
|
638
|
+
):
|
|
639
|
+
"""Safe version of relative canopy footprint tool."""
|
|
640
|
+
try:
|
|
641
|
+
footprint = FootprintCanopy(in_line, in_chm, in_layer=in_layer)
|
|
642
|
+
except Exception as e:
|
|
643
|
+
print(f"Failed to initialize FootprintCanopy: {e}")
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
footprint.compute(processes, parallel_mode)
|
|
648
|
+
except Exception as e:
|
|
649
|
+
print(f"Error in compute(): {e}")
|
|
650
|
+
import traceback
|
|
651
|
+
|
|
652
|
+
traceback.print_exc()
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
# Save only if footprints were actually generated
|
|
656
|
+
if (
|
|
657
|
+
hasattr(footprint, "footprints")
|
|
658
|
+
and footprint.footprints is not None
|
|
659
|
+
and hasattr(footprint.footprints, "empty")
|
|
660
|
+
and not footprint.footprints.empty
|
|
661
|
+
):
|
|
662
|
+
try:
|
|
663
|
+
footprint.save_footprint(out_footprint, out_layer)
|
|
664
|
+
if verbose:
|
|
665
|
+
print(f"Footprint saved to {out_footprint} layer={out_layer}")
|
|
666
|
+
except Exception as e:
|
|
667
|
+
print(f"Failed to save footprint: {e}")
|
|
668
|
+
else:
|
|
669
|
+
print("No valid footprints to save.")
|
|
670
|
+
|
|
671
|
+
# Optionally save percentile lines (if needed)
|
|
672
|
+
if (
|
|
673
|
+
hasattr(footprint, "lines_percentile")
|
|
674
|
+
and footprint.lines_percentile is not None
|
|
675
|
+
and hasattr(footprint.lines_percentile, "empty")
|
|
676
|
+
and not footprint.lines_percentile.empty
|
|
677
|
+
):
|
|
678
|
+
out_percentile = out_footprint.replace("footprint", "line_percentile")
|
|
679
|
+
try:
|
|
680
|
+
footprint.save_line_percentile(out_percentile)
|
|
681
|
+
if verbose:
|
|
682
|
+
print(f"Line percentile saved to {out_percentile}")
|
|
683
|
+
except Exception as e:
|
|
684
|
+
print(f"Failed to save line percentile: {e}")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
if __name__ == "__main__":
|
|
688
|
+
"""This part is to be another version of relative canopy footprint tool."""
|
|
689
|
+
in_args, in_verbose = bt_common.check_arguments()
|
|
690
|
+
start_time = time.time()
|
|
691
|
+
line_footprint_rel(**in_args.input, processes=int(in_args.processes), verbose=in_verbose)
|
|
692
|
+
|
|
693
|
+
print("Elapsed time: {}".format(time.time() - start_time))
|