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.
Files changed (44) hide show
  1. beratools/__init__.py +3 -0
  2. beratools/core/__init__.py +0 -0
  3. beratools/core/algo_centerline.py +476 -0
  4. beratools/core/algo_common.py +489 -0
  5. beratools/core/algo_cost.py +185 -0
  6. beratools/core/algo_dijkstra.py +492 -0
  7. beratools/core/algo_footprint_rel.py +693 -0
  8. beratools/core/algo_line_grouping.py +941 -0
  9. beratools/core/algo_merge_lines.py +255 -0
  10. beratools/core/algo_split_with_lines.py +296 -0
  11. beratools/core/algo_vertex_optimization.py +451 -0
  12. beratools/core/constants.py +56 -0
  13. beratools/core/logger.py +92 -0
  14. beratools/core/tool_base.py +126 -0
  15. beratools/gui/__init__.py +11 -0
  16. beratools/gui/assets/BERALogo.png +0 -0
  17. beratools/gui/assets/beratools.json +471 -0
  18. beratools/gui/assets/closed.gif +0 -0
  19. beratools/gui/assets/closed.png +0 -0
  20. beratools/gui/assets/gui.json +8 -0
  21. beratools/gui/assets/open.gif +0 -0
  22. beratools/gui/assets/open.png +0 -0
  23. beratools/gui/assets/tool.gif +0 -0
  24. beratools/gui/assets/tool.png +0 -0
  25. beratools/gui/bt_data.py +485 -0
  26. beratools/gui/bt_gui_main.py +700 -0
  27. beratools/gui/main.py +27 -0
  28. beratools/gui/tool_widgets.py +730 -0
  29. beratools/tools/__init__.py +7 -0
  30. beratools/tools/canopy_threshold_relative.py +769 -0
  31. beratools/tools/centerline.py +127 -0
  32. beratools/tools/check_seed_line.py +48 -0
  33. beratools/tools/common.py +622 -0
  34. beratools/tools/line_footprint_absolute.py +203 -0
  35. beratools/tools/line_footprint_fixed.py +480 -0
  36. beratools/tools/line_footprint_functions.py +884 -0
  37. beratools/tools/line_footprint_relative.py +75 -0
  38. beratools/tools/tool_template.py +72 -0
  39. beratools/tools/vertex_optimization.py +57 -0
  40. beratools-0.1.0.dist-info/METADATA +134 -0
  41. beratools-0.1.0.dist-info/RECORD +44 -0
  42. beratools-0.1.0.dist-info/WHEEL +4 -0
  43. beratools-0.1.0.dist-info/entry_points.txt +2 -0
  44. 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))