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
beratools/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __author__ = """AppliedGRG"""
2
+ __email__ = 'appliedgrg@gmail.com'
3
+ __version__ = '0.1.0'
File without changes
@@ -0,0 +1,476 @@
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
8
+
9
+ Description:
10
+ This script is part of the BERA Tools.
11
+ Webpage: https://github.com/appliedgrg/beratools
12
+
13
+ This file is intended to be hosting algorithms and utility functions/classes
14
+ for centerline tool.
15
+ """
16
+
17
+ import enum
18
+ from itertools import compress
19
+
20
+ import geopandas as gpd
21
+ import numpy as np
22
+ import pandas as pd
23
+ import rasterio
24
+ import shapely
25
+ import shapely.geometry as sh_geom
26
+ import shapely.ops as sh_ops
27
+ from label_centerlines import get_centerline
28
+
29
+ import beratools.core.algo_common as algo_common
30
+ import beratools.core.algo_cost as algo_cost
31
+ import beratools.core.algo_dijkstra as bt_dijkstra
32
+ import beratools.core.constants as bt_const
33
+ import beratools.core.tool_base as bt_base
34
+ import beratools.tools.common as bt_common
35
+
36
+
37
+ class CenterlineParams(float, enum.Enum):
38
+ """
39
+ Parameters for centerline generation.
40
+
41
+ These parameters are used to control the behavior of centerline generation
42
+ and should be adjusted based on the specific requirements of the application.
43
+ """
44
+
45
+ BUFFER_CLIP = 5.0
46
+ SEGMENTIZE_LENGTH = 1.0
47
+ SIMPLIFY_LENGTH = 0.5
48
+ SMOOTH_SIGMA = 0.8
49
+ CLEANUP_POLYGON_BY_AREA = 1.0
50
+
51
+
52
+ @enum.unique
53
+ class CenterlineStatus(enum.IntEnum):
54
+ """
55
+ Status of centerline generation.
56
+
57
+ This enum is used to indicate the status of centerline generation.
58
+ It can be used to track the success or failure of the centerline generation process.
59
+
60
+ """
61
+
62
+ SUCCESS = 1
63
+ FAILED = 2
64
+ REGENERATE_SUCCESS = 3
65
+ REGENERATE_FAILED = 4
66
+
67
+
68
+ def centerline_is_valid(centerline, input_line):
69
+ """
70
+ Check if centerline is valid.
71
+
72
+ Args:
73
+ centerline (_type_): _description_
74
+ input_line (sh_geom.LineString): Seed line or least cost path.
75
+ Only two end points are used.
76
+
77
+ Returns:
78
+ bool: True if line is valid
79
+
80
+ """
81
+ if not centerline:
82
+ return False
83
+
84
+ # centerline length less the half of least cost path
85
+ if (
86
+ centerline.length < input_line.length / 2
87
+ or centerline.distance(sh_geom.Point(input_line.coords[0])) > bt_const.BT_EPSILON
88
+ or centerline.distance(sh_geom.Point(input_line.coords[-1])) > bt_const.BT_EPSILON
89
+ ):
90
+ return False
91
+
92
+ return True
93
+
94
+
95
+ def snap_end_to_end(in_line, line_reference):
96
+ if type(in_line) is sh_geom.MultiLineString:
97
+ in_line = sh_ops.linemerge(in_line)
98
+ if type(in_line) is sh_geom.MultiLineString:
99
+ print(f"algo_centerline: MultiLineString found {in_line.centroid}, pass.")
100
+ return None
101
+
102
+ pts = list(in_line.coords)
103
+ if len(pts) < 2:
104
+ print("snap_end_to_end: input line invalid.")
105
+ return in_line
106
+
107
+ line_start = sh_geom.Point(pts[0])
108
+ line_end = sh_geom.Point(pts[-1])
109
+ ref_ends = sh_geom.MultiPoint([line_reference.coords[0], line_reference.coords[-1]])
110
+
111
+ _, snap_start = sh_ops.nearest_points(line_start, ref_ends)
112
+ _, snap_end = sh_ops.nearest_points(line_end, ref_ends)
113
+
114
+ if in_line.has_z:
115
+ snap_start = shapely.force_3d(snap_start)
116
+ snap_end = shapely.force_3d(snap_end)
117
+ else:
118
+ snap_start = shapely.force_2d(snap_start)
119
+ snap_end = shapely.force_2d(snap_end)
120
+
121
+ pts[0] = snap_start.coords[0]
122
+ pts[-1] = snap_end.coords[0]
123
+
124
+ return sh_geom.LineString(pts)
125
+
126
+
127
+ def find_centerline(poly, input_line):
128
+ """
129
+ Find centerline from polygon and input line.
130
+
131
+ Args:
132
+ poly : sh_geom.Polygon
133
+ input_line ( sh_geom.LineString): Least cost path or seed line
134
+
135
+ Returns:
136
+ centerline (sh_geom.LineString): Centerline
137
+ status (CenterlineStatus): Status of centerline generation
138
+
139
+ """
140
+ default_return = input_line, CenterlineStatus.FAILED
141
+ if not poly:
142
+ print("find_centerline: No polygon found")
143
+ return default_return
144
+
145
+ poly = shapely.segmentize(poly, max_segment_length=CenterlineParams.SEGMENTIZE_LENGTH)
146
+
147
+ # buffer to reduce MultiPolygons
148
+ poly = poly.buffer(bt_const.SMALL_BUFFER)
149
+ if type(poly) is sh_geom.MultiPolygon:
150
+ print("sh_geom.MultiPolygon encountered, skip.")
151
+ return default_return
152
+
153
+ exterior_pts = list(poly.exterior.coords)
154
+
155
+ if bt_const.CenterlineFlags.DELETE_HOLES:
156
+ poly = sh_geom.Polygon(exterior_pts)
157
+ if bt_const.CenterlineFlags.SIMPLIFY_POLYGON:
158
+ poly = poly.simplify(CenterlineParams.SIMPLIFY_LENGTH)
159
+
160
+ line_coords = list(input_line.coords)
161
+
162
+ # TODO add more code to filter Voronoi vertices
163
+ src_geom = sh_geom.Point(line_coords[0]).buffer(CenterlineParams.BUFFER_CLIP * 3).intersection(poly)
164
+ dst_geom = sh_geom.Point(line_coords[-1]).buffer(CenterlineParams.BUFFER_CLIP * 3).intersection(poly)
165
+ src_geom = None
166
+ dst_geom = None
167
+
168
+ try:
169
+ centerline = get_centerline(
170
+ poly,
171
+ segmentize_maxlen=1,
172
+ max_points=3000,
173
+ simplification=0.05,
174
+ smooth_sigma=CenterlineParams.SMOOTH_SIGMA,
175
+ max_paths=1,
176
+ src_geom=src_geom,
177
+ dst_geom=dst_geom,
178
+ )
179
+ except Exception as e:
180
+ print(f"find_centerline: {e}")
181
+ return default_return
182
+
183
+ if not centerline:
184
+ return default_return
185
+
186
+ if type(centerline) is sh_geom.MultiLineString:
187
+ if len(centerline.geoms) > 1:
188
+ print(" Multiple centerline segments detected, no further processing.")
189
+ return centerline, CenterlineStatus.SUCCESS # TODO: inspect
190
+ elif len(centerline.geoms) == 1:
191
+ centerline = centerline.geoms[0]
192
+ else:
193
+ return default_return
194
+
195
+ cl_coords = list(centerline.coords)
196
+
197
+ # trim centerline at two ends
198
+ head_buffer = sh_geom.Point(cl_coords[0]).buffer(CenterlineParams.BUFFER_CLIP)
199
+ centerline = centerline.difference(head_buffer)
200
+
201
+ end_buffer = sh_geom.Point(cl_coords[-1]).buffer(CenterlineParams.BUFFER_CLIP)
202
+ centerline = centerline.difference(end_buffer)
203
+
204
+ # No centerline detected, use input line instead.
205
+ if not centerline:
206
+ return default_return
207
+ try:
208
+ # Empty centerline detected, use input line instead.
209
+ if centerline.is_empty:
210
+ return default_return
211
+ except Exception as e:
212
+ print(f"find_centerline: {e}")
213
+
214
+ centerline = snap_end_to_end(centerline, input_line)
215
+
216
+ # Check centerline. If valid, regenerate by splitting polygon into two halves.
217
+ if not centerline_is_valid(centerline, input_line):
218
+ try:
219
+ print("Regenerating line ...")
220
+ centerline = regenerate_centerline(poly, input_line)
221
+ return centerline, CenterlineStatus.REGENERATE_SUCCESS
222
+ except Exception as e:
223
+ print(f"find_centerline: {e}")
224
+ return input_line, CenterlineStatus.REGENERATE_FAILED
225
+
226
+ return centerline, CenterlineStatus.SUCCESS
227
+
228
+
229
+ def find_corridor_polygon(corridor_thresh, in_transform, line_gpd):
230
+ # Threshold corridor raster used for generating centerline
231
+ corridor_thresh_cl = np.ma.where(corridor_thresh == 0.0, 1, 0).data
232
+ if corridor_thresh_cl.dtype == np.int64:
233
+ corridor_thresh_cl = corridor_thresh_cl.astype(np.int32)
234
+
235
+ corridor_mask = np.where(1 == corridor_thresh_cl, True, False)
236
+ poly_generator = rasterio.features.shapes(corridor_thresh_cl, mask=corridor_mask, transform=in_transform)
237
+ corridor_polygon = []
238
+
239
+ try:
240
+ for poly, value in poly_generator:
241
+ if sh_geom.shape(poly).area > 1:
242
+ corridor_polygon.append(sh_geom.shape(poly))
243
+ except Exception as e:
244
+ print(f"find_corridor_polygon: {e}")
245
+
246
+ if corridor_polygon:
247
+ corridor_polygon = sh_ops.unary_union(corridor_polygon)
248
+ if type(corridor_polygon) is sh_geom.MultiPolygon:
249
+ poly_list = shapely.get_parts(corridor_polygon)
250
+ merge_poly = poly_list[0]
251
+ for i in range(1, len(poly_list)):
252
+ if shapely.intersects(merge_poly, poly_list[i]):
253
+ merge_poly = shapely.union(merge_poly, poly_list[i])
254
+ else:
255
+ buffer_dist = poly_list[i].distance(merge_poly) + 0.1
256
+ buffer_poly = poly_list[i].buffer(buffer_dist)
257
+ merge_poly = shapely.union(merge_poly, buffer_poly)
258
+ corridor_polygon = merge_poly
259
+ else:
260
+ corridor_polygon = None
261
+
262
+ # create GeoDataFrame for centerline
263
+ corridor_poly_gpd = gpd.GeoDataFrame.copy(line_gpd)
264
+ corridor_poly_gpd.geometry = [corridor_polygon]
265
+
266
+ return corridor_poly_gpd
267
+
268
+
269
+ def process_single_centerline(row_and_path):
270
+ """
271
+ Find centerline.
272
+
273
+ Args:
274
+ row_and_path (list of row (gdf and lc_path)): and least cost path
275
+ first is GeoPandas row, second is input line, (least cost path)
276
+
277
+ Returns:
278
+ row: GeoPandas row with centerline
279
+
280
+ """
281
+ row = row_and_path[0]
282
+ lc_path = row_and_path[1]
283
+
284
+ poly = row.geometry.iloc[0]
285
+ centerline, status = find_centerline(poly, lc_path)
286
+ row["centerline"] = centerline
287
+
288
+ return row
289
+
290
+
291
+ def find_centerlines(poly_gpd, line_seg, processes):
292
+ centerline_gpd = []
293
+ rows_and_paths = []
294
+
295
+ try:
296
+ for i in poly_gpd.index:
297
+ row = poly_gpd.loc[[i]]
298
+ if "OLnSEG" in line_seg.columns:
299
+ line_id, Seg_id = row["OLnFID"].iloc[0], row["OLnSEG"].iloc[0]
300
+ lc_path = line_seg.loc[(line_seg.OLnFID == line_id) & (line_seg.OLnSEG == Seg_id)][
301
+ "geometry"
302
+ ].iloc[0]
303
+ else:
304
+ line_id = row["OLnFID"].iloc[0]
305
+ lc_path = line_seg.loc[(line_seg.OLnFID == line_id)]["geometry"].iloc[0]
306
+
307
+ rows_and_paths.append((row, lc_path))
308
+ except Exception as e:
309
+ print(f"find_centerlines: {e}")
310
+
311
+ centerline_gpd = bt_base.execute_multiprocessing(
312
+ process_single_centerline, rows_and_paths, "find_centerlines", processes, 1
313
+ )
314
+ return pd.concat(centerline_gpd)
315
+
316
+
317
+ def regenerate_centerline(poly, input_line):
318
+ """
319
+ Regenerates centerline when initial poly is not valid.
320
+
321
+ Args:
322
+ input_line (sh_geom.LineString): Seed line or least cost path.
323
+ Only two end points will be used
324
+
325
+ Returns:
326
+ sh_geom.MultiLineString
327
+
328
+ """
329
+ line_1 = sh_ops.substring(input_line, start_dist=0.0, end_dist=input_line.length / 2)
330
+ line_2 = sh_ops.substring(input_line, start_dist=input_line.length / 2, end_dist=input_line.length)
331
+
332
+ pts = shapely.force_2d(
333
+ [
334
+ sh_geom.Point(list(input_line.coords)[0]),
335
+ sh_geom.Point(list(line_1.coords)[-1]),
336
+ sh_geom.Point(list(input_line.coords)[-1]),
337
+ ]
338
+ )
339
+ perp = algo_common.generate_perpendicular_line_precise(pts)
340
+
341
+ # sh_geom.MultiPolygon is rare, but need to be dealt with
342
+ # remove polygon of area less than CenterlineParams.CLEANUP_POLYGON_BY_AREA
343
+ poly = poly.buffer(bt_const.SMALL_BUFFER)
344
+ if type(poly) is sh_geom.MultiPolygon:
345
+ poly_geoms = list(poly.geoms)
346
+ poly_valid = [True] * len(poly_geoms)
347
+ for i, item in enumerate(poly_geoms):
348
+ if item.area < CenterlineParams.CLEANUP_POLYGON_BY_AREA:
349
+ poly_valid[i] = False
350
+
351
+ poly_geoms = list(compress(poly_geoms, poly_valid))
352
+ if len(poly_geoms) != 1: # still multi polygon
353
+ print("regenerate_centerline: Multi or none polygon found, pass.")
354
+
355
+ poly = sh_geom.Polygon(poly_geoms[0])
356
+
357
+ poly_exterior = sh_geom.Polygon(poly.buffer(bt_const.SMALL_BUFFER).exterior)
358
+ poly_split = sh_ops.split(poly_exterior, perp)
359
+
360
+ if len(poly_split.geoms) < 2:
361
+ print("regenerate_centerline: polygon sh_ops.split failed, pass.")
362
+ return None
363
+
364
+ poly_1 = poly_split.geoms[0]
365
+ poly_2 = poly_split.geoms[1]
366
+
367
+ # find polygon and line pairs
368
+ pair_line_1 = line_1
369
+ pair_line_2 = line_2
370
+ if not poly_1.intersects(line_1):
371
+ pair_line_1 = line_2
372
+ pair_line_2 = line_1
373
+ elif poly_1.intersection(line_1).length < line_1.length / 3:
374
+ pair_line_1 = line_2
375
+ pair_line_2 = line_1
376
+
377
+ center_line_1 = find_centerline(poly_1, pair_line_1)
378
+ center_line_2 = find_centerline(poly_2, pair_line_2)
379
+
380
+ center_line_1 = center_line_1[0]
381
+ center_line_2 = center_line_2[0]
382
+
383
+ if not center_line_1 or not center_line_2:
384
+ print("Regenerate line: centerline is None")
385
+ return None
386
+
387
+ try:
388
+ if center_line_1.is_empty or center_line_2.is_empty:
389
+ print("Regenerate line: centerline is empty")
390
+ return None
391
+ except Exception as e:
392
+ print(f"regenerate_centerline: {e}")
393
+
394
+ print("Centerline is regenerated.")
395
+ return sh_ops.linemerge(sh_geom.MultiLineString([center_line_1, center_line_2]))
396
+
397
+
398
+ class SeedLine:
399
+ """Class to store seed line and least cost path."""
400
+
401
+ def __init__(self, line_gdf, ras_file, proc_segments, line_radius):
402
+ self.line = line_gdf
403
+ self.raster = ras_file
404
+ self.line_radius = line_radius
405
+ self.lc_path = None
406
+ self.centerline = None
407
+ self.corridor_poly_gpd = None
408
+
409
+ def compute(self):
410
+ line = self.line.geometry[0]
411
+ line_radius = self.line_radius
412
+ in_raster = self.raster
413
+ seed_line = line # LineString
414
+ default_return = (seed_line, seed_line, None)
415
+
416
+ ras_clip, out_meta = bt_common.clip_raster(in_raster, seed_line, line_radius)
417
+ cost_clip, _ = algo_cost.cost_raster(ras_clip, out_meta)
418
+
419
+ lc_path = line
420
+ try:
421
+ if bt_const.CenterlineFlags.USE_SKIMAGE_GRAPH:
422
+ lc_path = bt_dijkstra.find_least_cost_path_skimage(cost_clip, out_meta, seed_line)
423
+ else:
424
+ lc_path = bt_dijkstra.find_least_cost_path(cost_clip, out_meta, seed_line)
425
+ except Exception as e:
426
+ print(e)
427
+ return default_return
428
+
429
+ if lc_path:
430
+ lc_path_coords = lc_path.coords
431
+ else:
432
+ lc_path_coords = []
433
+
434
+ self.lc_path = lc_path
435
+
436
+ # search for centerline
437
+ if len(lc_path_coords) < 2:
438
+ print("No least cost path detected, use input line.")
439
+ self.line["cl_status"] = CenterlineStatus.FAILED.value
440
+ return default_return
441
+
442
+ # get corridor raster
443
+ lc_path = sh_geom.LineString(lc_path_coords)
444
+ ras_clip, out_meta = bt_common.clip_raster(in_raster, lc_path, line_radius * 0.9)
445
+ cost_clip, _ = algo_cost.cost_raster(ras_clip, out_meta)
446
+
447
+ out_transform = out_meta["transform"]
448
+ transformer = rasterio.transform.AffineTransformer(out_transform)
449
+ cell_size = (out_transform[0], -out_transform[4])
450
+
451
+ x1, y1 = lc_path_coords[0]
452
+ x2, y2 = lc_path_coords[-1]
453
+ source = [transformer.rowcol(x1, y1)]
454
+ destination = [transformer.rowcol(x2, y2)]
455
+ corridor_thresh_cl = algo_common.corridor_raster(
456
+ cost_clip,
457
+ out_meta,
458
+ source,
459
+ destination,
460
+ cell_size,
461
+ bt_const.FP_CORRIDOR_THRESHOLD,
462
+ )
463
+
464
+ # find contiguous corridor polygon and extract centerline
465
+ df = gpd.GeoDataFrame(geometry=[seed_line], crs=out_meta["crs"])
466
+ corridor_poly_gpd = find_corridor_polygon(corridor_thresh_cl, out_transform, df)
467
+ center_line, status = find_centerline(corridor_poly_gpd.geometry.iloc[0], lc_path)
468
+ self.line["cl_status"] = status.value
469
+
470
+ self.lc_path = self.line.copy()
471
+ self.lc_path.geometry = [lc_path]
472
+
473
+ self.centerline = self.line.copy()
474
+ self.centerline.geometry = [center_line]
475
+
476
+ self.corridor_poly_gpd = corridor_poly_gpd