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,255 @@
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 merging lines.
15
+ """
16
+
17
+ from itertools import pairwise
18
+ from operator import itemgetter
19
+
20
+ import networkit as nk
21
+ import shapely.geometry as sh_geom
22
+ from shapely.geometry import GeometryCollection, LineString, MultiLineString
23
+ from shapely.ops import linemerge
24
+
25
+ import beratools.core.algo_common as algo_common
26
+ import beratools.core.constants as bt_const
27
+
28
+
29
+ def safe_linemerge(geom):
30
+ if isinstance(geom, (MultiLineString, GeometryCollection)):
31
+ return linemerge(geom)
32
+ elif isinstance(geom, LineString):
33
+ return geom
34
+ else:
35
+ return geom
36
+
37
+ def custom_line_merge(geom):
38
+ if geom.geom_type == "MultiLineString":
39
+ # First try shapely's linemerge (fast)
40
+ merged = linemerge(geom)
41
+ # If still MultiLineString, use MergeLines for complex cases
42
+ if isinstance(merged, sh_geom.MultiLineString):
43
+ worker = MergeLines(merged)
44
+ merged = worker.merge_all_lines()
45
+ return merged if merged else geom
46
+ else:
47
+ return merged
48
+ elif geom.geom_type == "LineString":
49
+ return geom
50
+ else:
51
+ return geom
52
+
53
+
54
+ def run_line_merge(in_line_gdf, merge_group):
55
+ out_line_gdf = in_line_gdf
56
+ if merge_group:
57
+ if bt_const.BT_GROUP not in in_line_gdf.columns:
58
+ in_line_gdf = in_line_gdf.copy()
59
+ in_line_gdf[bt_const.BT_GROUP] = range(1, len(in_line_gdf) + 1)
60
+ out_line_gdf = in_line_gdf.dissolve(by=bt_const.BT_GROUP, as_index=False)
61
+
62
+ import time
63
+ print(f"[{time.time()}] Starting line_merge on {len(out_line_gdf)} geometries")
64
+ out_line_gdf.geometry = out_line_gdf.geometry.apply(safe_linemerge)
65
+ print(f"[{time.time()}] Finished line_merge")
66
+
67
+ print(f"[{time.time()}] Starting loop over {len(out_line_gdf)} rows")
68
+ for i, row in enumerate(out_line_gdf.itertuples()):
69
+ if isinstance(row.geometry, sh_geom.MultiLineString):
70
+ print(f"[{time.time()}] Processing row {i}: MultiLineString with {len(row.geometry.geoms)} parts")
71
+ worker = MergeLines(row.geometry)
72
+ merged_line = worker.merge_all_lines()
73
+ if merged_line:
74
+ out_line_gdf.at[row.Index, "geometry"] = merged_line
75
+ if i % 100 == 0:
76
+ print(f"[{time.time()}] Processed {i} rows")
77
+ print(f"[{time.time()}] Finished loop")
78
+
79
+ out_line_gdf = algo_common.clean_line_geometries(out_line_gdf)
80
+ out_line_gdf.reset_index(inplace=True, drop=True)
81
+ out_line_gdf["length"] = out_line_gdf.geometry.length
82
+ return out_line_gdf
83
+
84
+
85
+ class MergeLines:
86
+ """Merge line segments in MultiLineString."""
87
+
88
+ def __init__(self, multi_line):
89
+ self.G = None
90
+ self.line_segs = None
91
+ self.multi_line = multi_line
92
+ self.node_poly = None
93
+ self.end = None
94
+
95
+ self.create_graph()
96
+
97
+ def create_graph(self):
98
+ self.line_segs = list(self.multi_line.geoms)
99
+
100
+ # TODO: check empty line and null geoms
101
+ self.line_segs = [line for line in self.line_segs if line.length > 1e-3]
102
+ self.multi_line = sh_geom.MultiLineString(self.line_segs)
103
+ m = sh_geom.mapping(self.multi_line)
104
+ self.end = [(i[0], i[-1]) for i in m["coordinates"]]
105
+
106
+ self.G = nk.Graph(edgesIndexed=True)
107
+ self.G.addNodes(2)
108
+ self.G.addEdge(0, 1)
109
+
110
+ self.node_poly = [
111
+ sh_geom.Point(self.end[0][0]).buffer(1),
112
+ sh_geom.Point(self.end[0][1]).buffer(1),
113
+ ]
114
+
115
+ for i, line in enumerate(self.end[1:]):
116
+ node_exists = False
117
+ pt = sh_geom.Point(line[0])
118
+ pt_buffer = pt.buffer(1)
119
+
120
+ for node in self.G.iterNodes():
121
+ if self.node_poly[node].contains(pt):
122
+ node_exists = True
123
+ node_start = node
124
+ if not node_exists:
125
+ node_start = self.G.addNode()
126
+ self.node_poly.append(pt_buffer)
127
+
128
+ node_exists = False
129
+ pt = sh_geom.Point(line[1])
130
+ pt_buffer = pt.buffer(1)
131
+ for node in self.G.iterNodes():
132
+ if self.node_poly[node].contains(pt):
133
+ node_exists = True
134
+ node_end = node
135
+ if not node_exists:
136
+ node_end = self.G.addNode()
137
+ self.node_poly.append(pt_buffer)
138
+
139
+ self.G.addEdge(node_start, node_end)
140
+
141
+ def get_components(self):
142
+ cc = nk.components.ConnectedComponents(self.G)
143
+ cc.run()
144
+ components = cc.getComponents()
145
+ return components
146
+
147
+ def is_single_path(self, component):
148
+ single_path = True
149
+ for node in component:
150
+ neighbors = list(self.G.iterNeighbors(node))
151
+ if len(neighbors) > 2:
152
+ single_path = False
153
+
154
+ return single_path
155
+
156
+ def get_merged_line_for_component(self, component):
157
+ sub = nk.graphtools.subgraphFromNodes(self.G, component)
158
+ lines = None
159
+ if nk.graphtools.maxDegree(sub) >= 3: # not simple path
160
+ edges = [self.G.edgeId(i[0], i[1]) for i in list(sub.iterEdges())]
161
+ lines = itemgetter(*edges)(self.line_segs)
162
+ elif nk.graphtools.maxDegree(sub) == 2:
163
+ lines = self.merge_single_line(component)
164
+
165
+ return lines
166
+
167
+ def find_path_for_component(self, component):
168
+ neighbors = list(self.G.iterNeighbors(component[0]))
169
+ path = [component[0]]
170
+ right = neighbors[0]
171
+ path.append(right)
172
+
173
+ left = None
174
+ if len(neighbors) == 2:
175
+ left = neighbors[1]
176
+ path.insert(0, left)
177
+
178
+ neighbors = list(self.G.iterNeighbors(right))
179
+ while len(neighbors) > 1:
180
+ if neighbors[0] not in path:
181
+ path.append(neighbors[0])
182
+ right = neighbors[0]
183
+ else:
184
+ path.append(neighbors[1])
185
+ right = neighbors[1]
186
+
187
+ neighbors = list(self.G.iterNeighbors(right))
188
+
189
+ # last node
190
+ if neighbors[0] not in path:
191
+ path.append(neighbors[0])
192
+
193
+ # process left side
194
+ if left:
195
+ neighbors = list(self.G.iterNeighbors(left))
196
+ while len(neighbors) > 1:
197
+ if neighbors[0] not in path:
198
+ path.insert(0, neighbors[0])
199
+ left = neighbors[0]
200
+ else:
201
+ path.insert(0, neighbors[1])
202
+ left = neighbors[1]
203
+
204
+ neighbors = list(self.G.iterNeighbors(left))
205
+
206
+ # last node
207
+ if neighbors[0] not in path:
208
+ path.insert(0, neighbors[0])
209
+
210
+ return path
211
+
212
+ def merge_single_line(self, component):
213
+ path = self.find_path_for_component(component)
214
+
215
+ pairs = list(pairwise(path))
216
+ line_list = [self.G.edgeId(i[0], i[1]) for i in pairs]
217
+
218
+ vertices = []
219
+
220
+ for i, id in enumerate(line_list):
221
+ pair = pairs[i]
222
+ poly_t = self.node_poly[pair[0]]
223
+ point_t = sh_geom.Point(self.end[id][0])
224
+ if poly_t.contains(point_t):
225
+ line = self.line_segs[id]
226
+ else:
227
+ # line = reverse(self.line_segs[id])
228
+ line = self.line_segs[id].reverse()
229
+
230
+ vertices.extend(list(line.coords))
231
+ last_vertex = vertices.pop()
232
+
233
+ vertices.append(last_vertex)
234
+ merged_line = sh_geom.LineString(vertices)
235
+
236
+ return [merged_line]
237
+
238
+ def merge_all_lines(self):
239
+ components = self.get_components()
240
+ lines = []
241
+ for c in components:
242
+ line = self.get_merged_line_for_component(c)
243
+ if line:
244
+ lines.extend(self.get_merged_line_for_component(c))
245
+ else: # TODO: check line
246
+ print(f"merge_all_lines: failed to merge: {self.multi_line.bounds}")
247
+
248
+ # print('Merge lines done.')
249
+
250
+ if len(lines) > 1:
251
+ return sh_geom.MultiLineString(lines)
252
+ elif len(lines) == 1:
253
+ return lines[0]
254
+ else:
255
+ return None
@@ -0,0 +1,296 @@
1
+ """Split lines at intersections using a class-based approach."""
2
+
3
+ from itertools import combinations
4
+
5
+ import geopandas as gpd
6
+ from shapely import STRtree, snap
7
+ from shapely.geometry import LineString, MultiPoint, Point
8
+
9
+ import beratools.core.algo_common as algo_common
10
+
11
+ EPSILON = 1e-5
12
+ INTER_STATUS_COL = "INTER_STATUS"
13
+
14
+
15
+ def min_distance_in_multipoint(multipoint):
16
+ points = list(multipoint.geoms)
17
+ min_dist = float("inf")
18
+ for p1, p2 in combinations(points, 2):
19
+ dist = p1.distance(p2)
20
+ if dist < min_dist:
21
+ min_dist = dist
22
+ return min_dist
23
+
24
+
25
+ class LineSplitter:
26
+ """Split lines at intersections."""
27
+
28
+ def __init__(self, line_gdf):
29
+ """
30
+ Initialize the LineSplitter with the input GeoPackage and layer name.
31
+
32
+ Args:
33
+ input_gpkg (str): Path to the input GeoPackage file.
34
+ layer_name (str): Name of the layer to read from the GeoPackage.
35
+
36
+ """
37
+ # Explode if needed for multi-part geometries
38
+ self.line_gdf = line_gdf.explode()
39
+ self.line_gdf[INTER_STATUS_COL] = 1 # record line intersection status
40
+ self.inter_status = {}
41
+ self.sindex = self.line_gdf.sindex # Spatial index for faster operations
42
+
43
+ self.intersection_gdf = []
44
+ self.split_lines_gdf = None
45
+
46
+ def cut_line_by_points(self, line, points):
47
+ """
48
+ Cuts a LineString into segments based on the given points.
49
+
50
+ Args:
51
+ line: A shapely LineString to be cut.
52
+ points: A list of Point objects where the LineString needs to be cut.
53
+
54
+ Return:
55
+ A list of LineString segments after the cuts.
56
+
57
+ """
58
+ # Create a spatial index for the coordinates of the LineString
59
+ line_coords = [Point(x, y) for x, y in line.coords]
60
+ sindex = STRtree(line_coords)
61
+
62
+ # Sort points based on their projected position along the line
63
+ sorted_points = sorted(points, key=lambda p: line.project(p))
64
+ segments = []
65
+
66
+ # Process each point, inserting it into the correct location
67
+ start_idx = 0
68
+ start_pt = None
69
+ end_pt = None
70
+
71
+ for point in sorted_points:
72
+ # Find the closest segment on the line using the spatial index
73
+ nearest_pt_idx = sindex.nearest(point)
74
+ end_idx = nearest_pt_idx
75
+ end_pt = point
76
+
77
+ dist1 = line.project(point)
78
+ dist2 = line.project(line_coords[nearest_pt_idx])
79
+
80
+ if dist1 > dist2:
81
+ end_idx = nearest_pt_idx + 1
82
+
83
+ # Create a new segment
84
+ new_coords = line_coords[start_idx:end_idx]
85
+ if start_pt: # Append start point
86
+ new_coords = [start_pt] + new_coords
87
+
88
+ if end_pt: # Append end point
89
+ new_coords = new_coords + [end_pt]
90
+
91
+ nearest_segment = LineString(new_coords)
92
+ start_idx = end_idx
93
+ start_pt = end_pt
94
+
95
+ segments.append(nearest_segment)
96
+
97
+ # Add remaining part of the line after the last point
98
+ if start_idx < len(line_coords):
99
+ # If last point is not close to end point of line
100
+ if start_pt.distance(line_coords[-1]) > EPSILON:
101
+ remaining_part = LineString([start_pt] + line_coords[end_idx:])
102
+ segments.append(remaining_part)
103
+
104
+ return segments
105
+
106
+ def find_intersections(self):
107
+ """
108
+ Find intersections between lines in the GeoDataFrame.
109
+
110
+ Return:
111
+ List of Point geometries where the lines intersect.
112
+
113
+ """
114
+ visited_pairs = set()
115
+ intersection_points = []
116
+
117
+ # Iterate through each line geometry to find intersections
118
+ for idx, line1 in enumerate(self.line_gdf.geometry):
119
+ # Use spatial index to find candidates for intersection
120
+ indices = list(self.sindex.intersection(line1.bounds))
121
+ indices.remove(idx) # Remove the current index from the list
122
+
123
+ for match_idx in indices:
124
+ line2 = self.line_gdf.iloc[match_idx].geometry
125
+
126
+ # Create an index pair where the smaller index comes first
127
+ pair = tuple(sorted([idx, match_idx]))
128
+
129
+ # Skip if this pair has already been visited
130
+ if pair in visited_pairs:
131
+ continue
132
+
133
+ # Mark the pair as visited
134
+ visited_pairs.add(pair)
135
+
136
+ # Only check lines that are different and intersect
137
+ line1 = snap(line1, line2, tolerance=EPSILON)
138
+ if line1.intersects(line2):
139
+ # Find intersection points (can be multiple)
140
+ intersections = line1.intersection(line2)
141
+
142
+ if intersections.is_empty:
143
+ continue
144
+
145
+ # Intersection can be Point, MultiPoint, LineString
146
+ # or GeometryCollection
147
+ if isinstance(intersections, Point):
148
+ intersection_points.append(intersections)
149
+ else:
150
+ # record for further inspection
151
+ # GeometryCollection, MultiLineString
152
+ if isinstance(intersections, MultiPoint):
153
+ intersection_points.extend(intersections.geoms)
154
+ elif isinstance(intersections, LineString):
155
+ intersection_points.append(intersections.interpolate(0.5, normalized=True))
156
+
157
+ # if minimum distance between points is greater than threshold
158
+ # mark line as valid
159
+ if isinstance(intersections, MultiPoint):
160
+ if min_distance_in_multipoint(intersections) > algo_common.DISTANCE_THRESHOLD:
161
+ continue
162
+ # if intersection is a line, mark line as valid
163
+ if isinstance(intersections, LineString):
164
+ continue
165
+
166
+ for item in pair:
167
+ self.inter_status[item] = 0
168
+
169
+ self.intersection_gdf = gpd.GeoDataFrame(geometry=intersection_points, crs=self.line_gdf.crs)
170
+
171
+ def split_lines_at_intersections(self):
172
+ """
173
+ Split lines at the given intersection points.
174
+
175
+ Args:
176
+ intersection_points: List of Point geometries where the lines should be split.
177
+
178
+ Returns:
179
+ A GeoDataFrame with the split lines.
180
+
181
+ """
182
+ # Create a spatial index for faster point-line intersection checks
183
+ sindex = self.intersection_gdf.sindex
184
+
185
+ # List to hold the new split line segments
186
+ new_rows = []
187
+
188
+ # Iterate through each intersection point to split lines at that point
189
+ for row in self.line_gdf.itertuples():
190
+ if not isinstance(row.geometry, LineString):
191
+ continue
192
+
193
+ # Use spatial index to find possible line candidates for intersection
194
+ possible_matches = sindex.query(row.geometry.buffer(EPSILON))
195
+ end_pts = MultiPoint([row.geometry.coords[0], row.geometry.coords[-1]])
196
+
197
+ pt_list = []
198
+ new_segments = [row.geometry]
199
+
200
+ for idx in possible_matches:
201
+ point = self.intersection_gdf.loc[idx].geometry
202
+ # Check if the point is on the line
203
+ if row.geometry.distance(point) < EPSILON:
204
+ if end_pts.distance(point) < EPSILON:
205
+ continue
206
+ else:
207
+ pt_list.append(point)
208
+
209
+ if len(pt_list) > 0:
210
+ # Split the line at the intersection
211
+ new_segments = self.cut_line_by_points(row.geometry, pt_list)
212
+
213
+ # If the line was split into multiple segments, create new rows
214
+ for segment in new_segments:
215
+ new_row = row._asdict() # Convert the original row into a dictionary
216
+ new_row["geometry"] = segment # Update the geometry with the split one
217
+ new_rows.append(new_row)
218
+
219
+ self.split_lines_gdf = gpd.GeoDataFrame(
220
+ new_rows, columns=self.line_gdf.columns, crs=self.line_gdf.crs
221
+ )
222
+
223
+ self.split_lines_gdf = algo_common.clean_line_geometries(self.split_lines_gdf)
224
+
225
+ # Debugging: print how many segments were created
226
+ print(f"Total new line segments created: {len(new_rows)}")
227
+
228
+ def save_to_geopackage(
229
+ self,
230
+ input_gpkg,
231
+ line_layer="split_lines",
232
+ intersection_layer=None,
233
+ invalid_layer=None,
234
+ ):
235
+ """
236
+ Save the split lines and intersection points to the GeoPackage.
237
+
238
+ Args:
239
+ line_layer: split lines layer name in the GeoPackage.
240
+ intersection_layer: layer name for intersection points in the GeoPackage.
241
+
242
+ """
243
+ # Save intersection points and split lines to the GeoPackage
244
+ if self.split_lines_gdf is not None and intersection_layer:
245
+ if len(self.intersection_gdf) > 0:
246
+ self.intersection_gdf.to_file(input_gpkg, layer=intersection_layer, driver="GPKG")
247
+
248
+ if self.split_lines_gdf is not None and line_layer:
249
+ if len(self.split_lines_gdf) > 0:
250
+ self.split_lines_gdf["length"] = self.split_lines_gdf.geometry.length
251
+ self.split_lines_gdf.to_file(input_gpkg, layer=line_layer, driver="GPKG")
252
+
253
+ # save invalid splits
254
+ invalid_splits = self.line_gdf.loc[self.line_gdf[INTER_STATUS_COL] == 0]
255
+ if not invalid_splits.empty and invalid_layer:
256
+ if len(invalid_splits) > 0:
257
+ invalid_splits.to_file(input_gpkg, layer=invalid_layer, driver="GPKG")
258
+
259
+ def process(self, intersection_gdf=None):
260
+ """
261
+ Find intersection points, split lines at intersections.
262
+
263
+ Args:
264
+ intersection_gdf: external GeoDataFrame with intersection points.
265
+
266
+ """
267
+ if intersection_gdf is not None:
268
+ self.intersection_gdf = intersection_gdf
269
+ else:
270
+ self.find_intersections()
271
+
272
+ if self.inter_status:
273
+ for idx in self.inter_status.keys():
274
+ self.line_gdf.loc[idx, INTER_STATUS_COL] = self.inter_status[idx]
275
+
276
+ if not self.intersection_gdf.empty:
277
+ # Split the lines at intersection points
278
+ self.split_lines_at_intersections()
279
+ else:
280
+ print("No intersection points found, no lines to split.")
281
+
282
+
283
+ def split_with_lines(input_gpkg, layer_name):
284
+ splitter = LineSplitter(input_gpkg, layer_name)
285
+ splitter.process()
286
+ splitter.save_to_geopackage()
287
+
288
+
289
+ if __name__ == "__main__":
290
+ input_gpkg = r"I:\Temp\footprint_final.gpkg"
291
+ layer_name = "merged_lines_original"
292
+
293
+ gdf = gpd.read_file(input_gpkg, layer=layer_name)
294
+ splitter = LineSplitter(gdf)
295
+ splitter.process()
296
+ splitter.save_to_geopackage(input_gpkg)