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,941 @@
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
+ This file hosts code to deal with line grouping and merging, cleanups.
14
+ """
15
+
16
+ import enum
17
+ from collections import defaultdict
18
+ from dataclasses import dataclass, field
19
+ from itertools import chain
20
+ from typing import Optional, Union
21
+
22
+ import networkit as nk
23
+ import numpy as np
24
+ import shapely
25
+ import shapely.geometry as sh_geom
26
+
27
+ import beratools.core.algo_common as algo_common
28
+ import beratools.core.algo_merge_lines as algo_merge_lines
29
+ import beratools.core.constants as bt_const
30
+
31
+ TRIMMING_DISTANCE = 75 # meters
32
+ SMALL_BUFFER = 1
33
+
34
+
35
+ @enum.unique
36
+ class VertexClass(enum.IntEnum):
37
+ """Enum class for vertex class."""
38
+
39
+ TWO_WAY_ZERO_PRIMARY_LINE = 1
40
+ THREE_WAY_ZERO_PRIMARY_LINE = 2
41
+ THREE_WAY_ONE_PRIMARY_LINE = 3
42
+ FOUR_WAY_ZERO_PRIMARY_LINE = 4
43
+ FOUR_WAY_ONE_PRIMARY_LINE = 5
44
+ FOUR_WAY_TWO_PRIMARY_LINE = 6
45
+ FIVE_WAY_ZERO_PRIMARY_LINE = 7
46
+ FIVE_WAY_ONE_PRIMARY_LINE = 8
47
+ FIVE_WAY_TWO_PRIMARY_LINE = 9
48
+ SINGLE_WAY = 10
49
+
50
+
51
+ CONCERN_CLASSES = (
52
+ VertexClass.FIVE_WAY_ZERO_PRIMARY_LINE,
53
+ VertexClass.FIVE_WAY_ONE_PRIMARY_LINE,
54
+ VertexClass.FIVE_WAY_TWO_PRIMARY_LINE,
55
+ VertexClass.FOUR_WAY_ZERO_PRIMARY_LINE,
56
+ VertexClass.FOUR_WAY_ONE_PRIMARY_LINE,
57
+ VertexClass.FOUR_WAY_TWO_PRIMARY_LINE,
58
+ VertexClass.THREE_WAY_ZERO_PRIMARY_LINE,
59
+ VertexClass.THREE_WAY_ONE_PRIMARY_LINE,
60
+ VertexClass.TWO_WAY_ZERO_PRIMARY_LINE,
61
+ VertexClass.SINGLE_WAY,
62
+ )
63
+
64
+ ANGLE_TOLERANCE = np.pi / 10
65
+ TURN_ANGLE_TOLERANCE = np.pi * 0.5 # (little bigger than right angle)
66
+ TRIM_THRESHOLD = 0.15
67
+ TRANSECT_LENGTH = 40
68
+
69
+
70
+ def points_in_line(line):
71
+ """Get point list of line."""
72
+ point_list = []
73
+ try:
74
+ for point in list(line.coords): # loops through every point in a line
75
+ # loops through every vertex of every segment
76
+ if point: # adds all the vertices to segment_list, which creates an array
77
+ point_list.append(sh_geom.Point(point[0], point[1]))
78
+ except Exception as e:
79
+ print(e)
80
+
81
+ return point_list
82
+
83
+
84
+ def get_angle(line, end_index):
85
+ """
86
+ Calculate the angle of the first or last segment.
87
+
88
+ Args:
89
+ line: sh_geom.LineString
90
+ end_index: 0 or -1 of the line vertices. Consider the multipart.
91
+
92
+ """
93
+ pts = points_in_line(line)
94
+
95
+ if end_index == 0:
96
+ pt_1 = pts[0]
97
+ pt_2 = pts[1]
98
+ elif end_index == -1:
99
+ pt_1 = pts[-1]
100
+ pt_2 = pts[-2]
101
+
102
+ delta_x = pt_2.x - pt_1.x
103
+ delta_y = pt_2.y - pt_1.y
104
+ angle = np.arctan2(delta_y, delta_x)
105
+
106
+ return angle
107
+
108
+
109
+ @dataclass
110
+ class SingleLine:
111
+ """Class to store line and its simplified line."""
112
+
113
+ line_id: int = field(default=0)
114
+ line: Union[sh_geom.LineString, sh_geom.MultiLineString] = field(default=None)
115
+ sim_line: Union[sh_geom.LineString, sh_geom.MultiLineString] = field(default=None)
116
+ vertex_index: int = field(default=0)
117
+ group: int = field(default=0)
118
+
119
+ def get_angle_for_line(self):
120
+ return get_angle(self.sim_line, self.vertex_index)
121
+
122
+ def end_transect(self):
123
+ coords = self.sim_line.coords
124
+ end_seg = None
125
+ if self.vertex_index == 0:
126
+ end_seg = sh_geom.LineString([coords[0], coords[1]])
127
+ elif self.vertex_index == -1:
128
+ end_seg = sh_geom.LineString([coords[-1], coords[-2]])
129
+
130
+ l_left = end_seg.offset_curve(TRANSECT_LENGTH)
131
+ l_right = end_seg.offset_curve(-TRANSECT_LENGTH)
132
+
133
+ return sh_geom.LineString([l_left.coords[0], l_right.coords[0]])
134
+
135
+ def midpoint(self):
136
+ return shapely.force_2d(self.line.interpolate(0.5, normalized=True))
137
+
138
+ def update_line(self, line):
139
+ self.line = line
140
+
141
+
142
+ class VertexNode:
143
+ """Class to store vertex and lines connected to it."""
144
+
145
+ def __init__(self, line_id, line, sim_line, vertex_index, group=None) -> None:
146
+ self.vertex = None
147
+ self.line_list = []
148
+ self.line_connected = [] # pairs of lines connected
149
+ self.line_not_connected = []
150
+ self.vertex_class = None
151
+
152
+ if line:
153
+ self.add_line(SingleLine(line_id, line, sim_line, vertex_index, group))
154
+
155
+ def set_vertex(self, line, vertex_index):
156
+ """Set vertex coordinates."""
157
+ self.vertex = shapely.force_2d(shapely.get_point(line, vertex_index))
158
+
159
+ def add_line(self, line_class):
160
+ """Add line when creating or merging other VertexNode."""
161
+ self.line_list.append(line_class)
162
+ self.set_vertex(line_class.line, line_class.vertex_index)
163
+
164
+ def get_line(self, line_id):
165
+ for line in self.line_list:
166
+ if line.line_id == line_id:
167
+ return line.line
168
+
169
+ def get_line_obj(self, line_id):
170
+ for line in self.line_list:
171
+ if line.line_id == line_id:
172
+ return line
173
+
174
+ def get_line_geom(self, line_id):
175
+ return self.get_line_obj(line_id).line
176
+
177
+ def get_all_line_ids(self):
178
+ all_line_ids = {i.line_id for i in self.line_list}
179
+ return all_line_ids
180
+
181
+ def update_line(self, line_id, line):
182
+ for i in self.line_list:
183
+ if i.line_id == line_id:
184
+ i.update_line(line)
185
+
186
+ def merge(self, vertex):
187
+ """Merge other VertexNode if they have same vertex coords."""
188
+ self.add_line(vertex.line_list[0])
189
+
190
+ def get_trim_transect(self, poly, line_indices):
191
+ if not poly:
192
+ return None
193
+
194
+ internal_line = None
195
+ for line_idx in line_indices:
196
+ line = self.get_line_obj(line_idx)
197
+ if poly.contains(line.midpoint()):
198
+ internal_line = line
199
+
200
+ if not internal_line:
201
+ # print("No line is retrieved")
202
+ return
203
+ return internal_line.end_transect()
204
+
205
+ def _trim_polygon(self, poly, trim_transect):
206
+ if not poly or not trim_transect:
207
+ return
208
+
209
+ split_poly = shapely.ops.split(poly, trim_transect)
210
+
211
+ if len(split_poly.geoms) != 2:
212
+ return
213
+
214
+ # check geom_type
215
+ none_poly = False
216
+ for geom in split_poly.geoms:
217
+ if geom.geom_type != "Polygon":
218
+ none_poly = True
219
+
220
+ if none_poly:
221
+ return
222
+
223
+ # only two polygons in split_poly
224
+ if split_poly.geoms[0].area > split_poly.geoms[1].area:
225
+ poly = split_poly.geoms[0]
226
+ else:
227
+ poly = split_poly.geoms[1]
228
+
229
+ return poly
230
+
231
+ def trim_end_all(self, polys):
232
+ """
233
+ Trim all unconnected lines in the vertex.
234
+
235
+ Args:
236
+ polys: list of polygons returned by sindex.query
237
+
238
+ """
239
+ polys = polys.geometry
240
+ new_polys = []
241
+ for idx, poly in polys.items():
242
+ out_poly = self.trim_end(poly)
243
+ if out_poly:
244
+ new_polys.append((idx, out_poly))
245
+
246
+ return new_polys
247
+
248
+ def trim_end(self, poly):
249
+ transect = self.get_trim_transect(poly, self.line_not_connected)
250
+ if not transect:
251
+ return
252
+
253
+ poly = self._trim_polygon(poly, transect)
254
+ return poly
255
+ # Helper to get the neighbor coordinate based on vertex_index.
256
+
257
+ @staticmethod
258
+ def get_vertex(line_obj, index):
259
+ coords = list(line_obj.sim_line.coords)
260
+ # Normalize negative indices.
261
+ if index < 0:
262
+ index += len(coords)
263
+ if 0 <= index < len(coords):
264
+ return sh_geom.Point(coords[index])
265
+
266
+ @staticmethod
267
+ def get_neighbor(line_obj):
268
+ index = 0
269
+
270
+ if line_obj.vertex_index == 0:
271
+ index = 1
272
+ elif line_obj.vertex_index == -1:
273
+ index = -2
274
+
275
+ return VertexNode.get_vertex(line_obj, index)
276
+
277
+ @staticmethod
278
+ def parallel_line_centered(p1, p2, center, length):
279
+ """Generate a parallel line."""
280
+ # Compute the direction vector.
281
+ dx = p2.x - p1.x
282
+ dy = p2.y - p1.y
283
+
284
+ # Normalize the direction vector.
285
+ magnitude = (dx**2 + dy**2) ** 0.5
286
+ if magnitude == 0:
287
+ return None
288
+ dx /= magnitude
289
+ dy /= magnitude
290
+
291
+ # Compute half-length shifts.
292
+ half_dx = (dx * length) / 2
293
+ half_dy = (dy * length) / 2
294
+
295
+ # Compute the endpoints of the new parallel line.
296
+ new_p1 = sh_geom.Point(center.x - half_dx, center.y - half_dy)
297
+ new_p2 = sh_geom.Point(center.x + half_dx, center.y + half_dy)
298
+
299
+ return sh_geom.LineString([new_p1, new_p2])
300
+
301
+ def get_transect_for_primary(self):
302
+ """
303
+ Get a transect line from two primary connected lines.
304
+
305
+ This method calculates a transect line that is perpendicular to the line segment
306
+ formed by the next vertex neighbors of these two lines and the current vertex.
307
+
308
+ Return:
309
+ A transect line object if the conditions are met, otherwise None.
310
+
311
+ """
312
+ if not self.line_connected or len(self.line_connected[0]) != 2:
313
+ return None
314
+
315
+ # Retrieve the two connected line objects from the first connectivity group.
316
+ line_ids = self.line_connected[0]
317
+ pt1 = None
318
+ pt1 = None
319
+ if line_ids[0] == line_ids[1]: # line ring
320
+ # TODO: check line ring when merging vertex nodes.
321
+ # TODO: change one end index to -1
322
+ line_id = line_ids[0]
323
+ pt1 = self.get_vertex(self.get_line_obj(line_id), 1)
324
+ pt2 = self.get_vertex(self.get_line_obj(line_id), -2)
325
+ else: # two different lines
326
+ line_obj1 = self.get_line_obj(line_ids[0])
327
+ line_obj2 = self.get_line_obj(line_ids[1])
328
+
329
+ pt1 = self.get_neighbor(line_obj1)
330
+ pt2 = self.get_neighbor(line_obj2)
331
+
332
+ if pt1 is None or pt2 is None:
333
+ return None
334
+
335
+ transect = algo_common.generate_perpendicular_line_precise([pt1, self.vertex, pt2], offset=40)
336
+ return transect
337
+
338
+ def get_transect_for_primary_second(self):
339
+ """
340
+ Get a transect line from the second primary connected line.
341
+
342
+ For the second primary line, this method retrieves the neighbor point from
343
+ two lines in the second connectivity group, creates a reference line through the
344
+ vertex by mirroring the neighbor point about the vertex, and then generates a
345
+ parallel line centered at the vertex.
346
+
347
+ Returns:
348
+ A LineString representing the transect if available, otherwise None.
349
+
350
+ """
351
+ # Ensure there is a second connectivity group.
352
+ if not self.line_connected or len(self.line_connected) < 2:
353
+ return None
354
+
355
+ # Use the first line of the second connectivity group.
356
+ second_primary = self.line_connected[1]
357
+ line_obj1 = self.get_line_obj(second_primary[0])
358
+ line_obj2 = self.get_line_obj(second_primary[1])
359
+ if not line_obj1 or not line_obj2:
360
+ return None
361
+
362
+ pt1 = self.get_neighbor(line_obj1)
363
+ pt2 = self.get_neighbor(line_obj2)
364
+
365
+ if pt1 is None or pt2 is None:
366
+ return None
367
+
368
+ center = self.vertex
369
+ transect = self.parallel_line_centered(pt1, pt2, center, TRANSECT_LENGTH)
370
+ return transect
371
+
372
+ def trim_primary_end(self, polys):
373
+ """
374
+ Trim first primary line in the vertex.
375
+
376
+ Args:
377
+ polys: list of polygons returned by sindex.query
378
+
379
+ """
380
+ if len(self.line_connected) == 0:
381
+ return
382
+
383
+ new_polys = []
384
+ line = self.line_connected[0]
385
+
386
+ # use the first line to get transect
387
+ # transect = self.get_line_obj(line[0]).end_transect()
388
+ # if len(self.line_connected) == 1:
389
+ transect = self.get_transect_for_primary()
390
+ # elif len(self.line_connected) > 1:
391
+ # transect = self.get_transect_for_primary_second()
392
+
393
+ idx_1 = line[0]
394
+ poly_1 = None
395
+ idx_1 = line[1]
396
+ poly_2 = None
397
+
398
+ for idx, poly in polys.items():
399
+ # TODO: no polygons
400
+ if not poly:
401
+ continue
402
+
403
+ if poly.buffer(SMALL_BUFFER).contains(self.get_line_geom(line[0])):
404
+ poly_1 = poly
405
+ idx_1 = idx
406
+ elif poly.buffer(SMALL_BUFFER).contains(self.get_line_geom(line[1])):
407
+ poly_2 = poly
408
+ idx_2 = idx
409
+
410
+ if poly_1:
411
+ poly_1 = self._trim_polygon(poly_1, transect)
412
+ new_polys.append([idx_1, poly_1])
413
+ if poly_2:
414
+ poly_2 = self._trim_polygon(poly_2, transect)
415
+ new_polys.append([idx_2, poly_2])
416
+
417
+ return new_polys
418
+
419
+ def trim_intersection(self, polys, merge_group=True):
420
+ """
421
+ Trim intersection of lines and polygons.
422
+
423
+ TODO: there are polygons of 0 zero.
424
+
425
+ """
426
+
427
+ def get_poly_with_info(line, polys):
428
+ if polys.empty:
429
+ return None, None, None
430
+
431
+ for idx, row in polys.iterrows():
432
+ poly = row.geometry
433
+ if not poly: # TODO: no polygon
434
+ continue
435
+
436
+ if poly.buffer(SMALL_BUFFER).contains(line):
437
+ return idx, poly, row["max_width"]
438
+
439
+ return None, None, None
440
+
441
+ poly_trim_list = []
442
+ primary_lines = []
443
+ p_primary_list = []
444
+
445
+ # retrieve primary lines
446
+ if len(self.line_connected) > 0:
447
+ for idx in self.line_connected[0]: # only one connected line is used
448
+ primary_lines.append(self.get_line(idx))
449
+ _, poly, _ = get_poly_with_info(self.get_line(idx), polys)
450
+
451
+ if poly:
452
+ p_primary_list.append(poly.buffer(bt_const.SMALL_BUFFER))
453
+ else:
454
+ print("trim_intersection: No primary polygon found.")
455
+
456
+ line_idx_to_trim = self.line_not_connected
457
+ poly_list = []
458
+ if not merge_group: # add all remaining primary lines for trimming
459
+ if len(self.line_connected) > 1:
460
+ for line in self.line_connected[1:]:
461
+ line_idx_to_trim.extend(line)
462
+
463
+ # sort line index to by footprint area
464
+ for line_idx in line_idx_to_trim:
465
+ line = self.get_line_geom(line_idx)
466
+ poly_idx, poly, max_width = get_poly_with_info(line, polys)
467
+ if poly_idx:
468
+ poly_list.append((line_idx, poly_idx, max_width))
469
+
470
+ poly_list = sorted(poly_list, key=lambda x: x[2])
471
+
472
+ # create PolygonTrimming object and trim all by primary line
473
+ for i, indices in enumerate(poly_list):
474
+ line_idx = indices[0]
475
+ poly_idx = indices[1]
476
+ line_cleanup = self.get_line(line_idx)
477
+ poly_cleanup = polys.loc[poly_idx].geometry
478
+ poly_trim = PolygonTrimming(
479
+ line_index=line_idx,
480
+ line_cleanup=line_cleanup,
481
+ poly_index=poly_idx,
482
+ poly_cleanup=poly_cleanup,
483
+ )
484
+
485
+ poly_trim_list.append(poly_trim)
486
+ if p_primary_list:
487
+ poly_trim.process(p_primary_list, self.vertex)
488
+
489
+ # use poly_trim.poly_cleanup to update polys gdf's geometry
490
+ polys.at[poly_trim.poly_index, "geometry"] = poly_trim.poly_cleanup
491
+
492
+ # further trimming overlaps by non-primary lines
493
+ # poly_list and poly_trim_list have same index
494
+ for i, indices in enumerate(poly_list):
495
+ p_list = []
496
+ for p in poly_list[i + 1 :]:
497
+ p_list.append(polys.loc[p[1]].geometry)
498
+
499
+ poly_trim = poly_trim_list[i]
500
+ poly_trim.process(p_list, self.vertex)
501
+
502
+ return poly_trim_list
503
+
504
+ def assign_vertex_class(self):
505
+ if len(self.line_list) == 5:
506
+ if len(self.line_connected) == 0:
507
+ self.vertex_class = VertexClass.FIVE_WAY_ZERO_PRIMARY_LINE
508
+ if len(self.line_connected) == 1:
509
+ self.vertex_class = VertexClass.FIVE_WAY_ONE_PRIMARY_LINE
510
+ if len(self.line_connected) == 2:
511
+ self.vertex_class = VertexClass.FIVE_WAY_TWO_PRIMARY_LINE
512
+ elif len(self.line_list) == 4:
513
+ if len(self.line_connected) == 0:
514
+ self.vertex_class = VertexClass.FOUR_WAY_ZERO_PRIMARY_LINE
515
+ if len(self.line_connected) == 1:
516
+ self.vertex_class = VertexClass.FOUR_WAY_ONE_PRIMARY_LINE
517
+ if len(self.line_connected) == 2:
518
+ self.vertex_class = VertexClass.FOUR_WAY_TWO_PRIMARY_LINE
519
+ elif len(self.line_list) == 3:
520
+ if len(self.line_connected) == 0:
521
+ self.vertex_class = VertexClass.THREE_WAY_ZERO_PRIMARY_LINE
522
+ if len(self.line_connected) == 1:
523
+ self.vertex_class = VertexClass.THREE_WAY_ONE_PRIMARY_LINE
524
+ elif len(self.line_list) == 2:
525
+ if len(self.line_connected) == 0:
526
+ self.vertex_class = VertexClass.TWO_WAY_ZERO_PRIMARY_LINE
527
+ elif len(self.line_list) == 1:
528
+ self.vertex_class = VertexClass.SINGLE_WAY
529
+
530
+ def all_has_valid_group_attr(self):
531
+ """If all values in group list are valid value, return True."""
532
+ # TODO: if some line has no group, give advice
533
+ for i in self.line_list:
534
+ if i.group is None:
535
+ return False
536
+
537
+ return True
538
+
539
+ def need_regrouping(self):
540
+ pass
541
+
542
+ def check_connectivity(self, use_angle_grouping=True):
543
+ # Fill missing group with -1
544
+ for line in self.line_list:
545
+ if line.group is None:
546
+ line.group = -1
547
+
548
+ if self.need_regrouping():
549
+ self.group_regroup()
550
+
551
+ if use_angle_grouping:
552
+ self.group_line_by_angle()
553
+ else:
554
+ self.update_connectivity_by_group()
555
+
556
+ # record line not connected
557
+ all_line_ids = self.get_all_line_ids()
558
+ self.line_not_connected = list(all_line_ids - set(chain(*self.line_connected)))
559
+
560
+ self.assign_vertex_class()
561
+
562
+ def group_regroup(self):
563
+ pass
564
+
565
+ def update_connectivity_by_group(self):
566
+ group_line = defaultdict(list)
567
+ for i in self.line_list:
568
+ group_line[i.group].append(i.line_id)
569
+
570
+ for value in group_line.values():
571
+ if len(value) > 1:
572
+ self.line_connected.append(value)
573
+
574
+ def group_line_by_angle(self):
575
+ """Generate connectivity of all lines."""
576
+ if len(self.line_list) == 1:
577
+ return
578
+
579
+ # if there are 2 and more lines
580
+ new_angles = [i.get_angle_for_line() for i in self.line_list]
581
+ angle_visited = [False] * len(new_angles)
582
+
583
+ if len(self.line_list) == 2:
584
+ angle_diff = abs(new_angles[0] - new_angles[1])
585
+ angle_diff = angle_diff if angle_diff <= np.pi else angle_diff - np.pi
586
+
587
+ # if angle_diff >= TURN_ANGLE_TOLERANCE:
588
+ self.line_connected.append(
589
+ (
590
+ self.line_list[0].line_id,
591
+ self.line_list[1].line_id,
592
+ )
593
+ )
594
+ return
595
+
596
+ # three and more lines
597
+ for i, angle_1 in enumerate(new_angles):
598
+ for j, angle_2 in enumerate(new_angles[i + 1 :]):
599
+ if not angle_visited[i + j + 1]:
600
+ angle_diff = abs(angle_1 - angle_2)
601
+ angle_diff = angle_diff if angle_diff <= np.pi else angle_diff - np.pi
602
+ if (
603
+ angle_diff < ANGLE_TOLERANCE
604
+ or np.pi - ANGLE_TOLERANCE < abs(angle_1 - angle_2) < np.pi + ANGLE_TOLERANCE
605
+ ):
606
+ angle_visited[j + i + 1] = True # tenth of PI
607
+ self.line_connected.append(
608
+ (
609
+ self.line_list[i].line_id,
610
+ self.line_list[i + j + 1].line_id,
611
+ )
612
+ )
613
+
614
+
615
+ class LineGrouping:
616
+ """Class to group lines and merge them."""
617
+
618
+ def __init__(self, in_line_gdf, merge_group=True, use_angle_grouping=True) -> None:
619
+ if in_line_gdf is None:
620
+ raise ValueError("Line GeoDataFrame cannot be None")
621
+ self.use_angle_grouping = use_angle_grouping
622
+
623
+ if in_line_gdf.empty:
624
+ raise ValueError("Line GeoDataFrame cannot be empty")
625
+
626
+ self.lines = algo_common.clean_line_geometries(in_line_gdf)
627
+ self.lines.reset_index(inplace=True, drop=True)
628
+ self.merge_group = merge_group
629
+
630
+ self.sim_geom = self.lines.simplify(1)
631
+
632
+ self.G = nk.Graph(len(self.lines))
633
+ self.merged_vertex_list = []
634
+ self.has_group_attr = False
635
+ self.need_regrouping = False
636
+ self.groups = [None] * len(self.lines)
637
+ self.merged_lines_trimmed = None # merged trimmed lines
638
+
639
+ self.vertex_list = []
640
+ self.vertex_of_concern = []
641
+ self.v_index = None # sindex of all vertices for vertex_list
642
+
643
+ self.polys = None
644
+
645
+ # invalid geoms in final geom list
646
+ self.valid_lines = None
647
+ self.valid_polys = None
648
+ self.invalid_lines = None
649
+ self.invalid_polys = None
650
+
651
+ def create_vertex_list(self):
652
+ # check if data has group column
653
+ if bt_const.BT_GROUP in self.lines.keys():
654
+ self.groups = self.lines[bt_const.BT_GROUP]
655
+ self.has_group_attr = True
656
+ if self.groups.hasnans: # Todo: check for other invalid values
657
+ self.need_regrouping = True
658
+
659
+ for idx, s_geom, geom, group in zip(*zip(*self.sim_geom.items()), self.lines.geometry, self.groups):
660
+ self.vertex_list.append(VertexNode(idx, geom, s_geom, 0, group))
661
+ self.vertex_list.append(VertexNode(idx, geom, s_geom, -1, group))
662
+
663
+ v_points = []
664
+ for i in self.vertex_list:
665
+ if i.vertex is None:
666
+ print("Vertex is None, skipping.")
667
+ continue
668
+
669
+ v_points.append(i.vertex.buffer(SMALL_BUFFER)) # small polygon
670
+
671
+ # Spatial index of all vertices
672
+ self.v_index = shapely.STRtree(v_points)
673
+
674
+ vertex_visited = [False] * len(self.vertex_list)
675
+ for i, pt in enumerate(v_points):
676
+ if vertex_visited[i]:
677
+ continue
678
+
679
+ s_list = self.v_index.query(pt)
680
+ vertex = self.vertex_list[i]
681
+ if len(s_list) > 1:
682
+ for j in s_list:
683
+ if j != i:
684
+ # some short line will be very close to each other
685
+ if vertex.vertex.distance(self.vertex_list[j].vertex) > bt_const.SMALL_BUFFER:
686
+ continue
687
+
688
+ vertex.merge(self.vertex_list[j])
689
+ vertex_visited[j] = True
690
+
691
+ self.merged_vertex_list.append(vertex)
692
+ vertex_visited[i] = True
693
+
694
+ for i in self.merged_vertex_list:
695
+ i.check_connectivity(self.use_angle_grouping)
696
+
697
+ for i in self.merged_vertex_list:
698
+ if i.line_connected:
699
+ for edge in i.line_connected:
700
+ self.G.addEdge(edge[0], edge[1])
701
+
702
+ def group_lines(self):
703
+ cc = nk.components.ConnectedComponents(self.G)
704
+ cc.run()
705
+ # print("number of components ", cc.numberOfComponents())
706
+
707
+ group = 0
708
+ for i in range(cc.numberOfComponents()):
709
+ component = cc.getComponents()[i]
710
+ for id in component:
711
+ self.groups[id] = group
712
+
713
+ group += 1
714
+
715
+ def update_line_in_vertex_node(self, line_id, line):
716
+ """Update line in VertexNode after trimming."""
717
+ idx = self.v_index.query(line)
718
+ for i in idx:
719
+ v = self.vertex_list[i]
720
+ v.update_line(line_id, line)
721
+
722
+ def run_line_merge(self):
723
+ return algo_merge_lines.run_line_merge(self.lines, self.merge_group)
724
+
725
+ def find_vertex_for_poly_trimming(self):
726
+ self.vertex_of_concern = [i for i in self.merged_vertex_list if i.vertex_class in CONCERN_CLASSES]
727
+
728
+ def line_and_poly_cleanup(self):
729
+ sindex_poly = self.polys.sindex
730
+
731
+ for vertex in self.vertex_of_concern:
732
+ s_idx = sindex_poly.query(vertex.vertex, predicate="within")
733
+ if len(s_idx) == 0:
734
+ continue
735
+
736
+ # Trim intersections of primary lines
737
+ polys = self.polys.loc[s_idx].geometry
738
+ if not self.merge_group:
739
+ if (
740
+ vertex.vertex_class == VertexClass.FIVE_WAY_TWO_PRIMARY_LINE
741
+ or vertex.vertex_class == VertexClass.FIVE_WAY_ONE_PRIMARY_LINE
742
+ or vertex.vertex_class == VertexClass.FOUR_WAY_ONE_PRIMARY_LINE
743
+ or vertex.vertex_class == VertexClass.FOUR_WAY_TWO_PRIMARY_LINE
744
+ or vertex.vertex_class == VertexClass.THREE_WAY_ONE_PRIMARY_LINE
745
+ ):
746
+ out_polys = vertex.trim_primary_end(polys)
747
+ if len(out_polys) == 0:
748
+ continue
749
+
750
+ # update polygon DataFrame
751
+ for idx, out_poly in out_polys:
752
+ if out_poly:
753
+ self.polys.at[idx, "geometry"] = out_poly
754
+
755
+ # retrieve polygons again. Some polygons may be updated
756
+ polys = self.polys.loc[s_idx]
757
+ if (
758
+ vertex.vertex_class == VertexClass.SINGLE_WAY
759
+ or vertex.vertex_class == VertexClass.TWO_WAY_ZERO_PRIMARY_LINE
760
+ or vertex.vertex_class == VertexClass.THREE_WAY_ZERO_PRIMARY_LINE
761
+ or vertex.vertex_class == VertexClass.FOUR_WAY_ZERO_PRIMARY_LINE
762
+ or vertex.vertex_class == VertexClass.FIVE_WAY_ZERO_PRIMARY_LINE
763
+ ):
764
+ if vertex.vertex_class == VertexClass.THREE_WAY_ZERO_PRIMARY_LINE:
765
+ pass
766
+
767
+ out_polys = vertex.trim_end_all(polys)
768
+ if len(out_polys) == 0:
769
+ continue
770
+
771
+ # update polygon DataFrame
772
+ for idx, out_poly in out_polys:
773
+ self.polys.at[idx, "geometry"] = out_poly
774
+
775
+ polys = self.polys.loc[s_idx]
776
+ if vertex.vertex_class != VertexClass.SINGLE_WAY:
777
+ poly_trim_list = vertex.trim_intersection(polys, self.merge_group)
778
+ for p_trim in poly_trim_list:
779
+ # update main line and polygon DataFrame
780
+ self.polys.at[p_trim.poly_index, "geometry"] = p_trim.poly_cleanup
781
+ self.lines.at[p_trim.line_index, "geometry"] = p_trim.line_cleanup
782
+
783
+ # update VertexNode's line
784
+ self.update_line_in_vertex_node(p_trim.line_index, p_trim.line_cleanup)
785
+
786
+ def get_merged_lines_original(self):
787
+ return self.lines.dissolve(by=bt_const.BT_GROUP)
788
+
789
+ def run_grouping(self):
790
+ self.create_vertex_list()
791
+ if not self.has_group_attr:
792
+ self.group_lines()
793
+
794
+ self.find_vertex_for_poly_trimming()
795
+ self.lines[bt_const.BT_GROUP] = self.groups # assign group attribute
796
+
797
+ def run_regrouping(self):
798
+ """
799
+ Run this when new lines are added to grouped file.
800
+
801
+ Some new lines has empty group attributes
802
+ """
803
+ pass
804
+
805
+ def run_cleanup(self, in_polys):
806
+ self.polys = in_polys.copy()
807
+ self.line_and_poly_cleanup()
808
+ self.run_line_merge_trimmed()
809
+ self.check_geom_validity()
810
+
811
+ def run_line_merge_trimmed(self):
812
+ self.merged_lines_trimmed = self.run_line_merge()
813
+
814
+ def check_geom_validity(self):
815
+ """
816
+ Check MultiLineString and MultiPolygon in line and polygon dataframe.
817
+
818
+ Save to separate layers for user to double check
819
+ """
820
+ # remove null geometry
821
+ # TODO make sure lines and polygons match in pairs
822
+ # they should have same amount and spatial coverage
823
+ self.valid_polys = self.polys[~self.polys.geometry.isna() & ~self.polys.geometry.is_empty]
824
+
825
+ # save sh_geom.MultiLineString and sh_geom.MultiPolygon
826
+ self.invalid_polys = self.polys[(self.polys.geometry.geom_type == "MultiPolygon")]
827
+
828
+ # check lines
829
+ self.valid_lines = self.merged_lines_trimmed[
830
+ ~self.merged_lines_trimmed.geometry.isna() & ~self.merged_lines_trimmed.geometry.is_empty
831
+ ]
832
+ self.valid_lines.reset_index(inplace=True, drop=True)
833
+
834
+ self.invalid_lines = self.merged_lines_trimmed[
835
+ (self.merged_lines_trimmed.geometry.geom_type == "MultiLineString")
836
+ ]
837
+ self.invalid_lines.reset_index(inplace=True, drop=True)
838
+
839
+ def save_file(self, out_file):
840
+ if not self.valid_lines.empty:
841
+ self.valid_lines["length"] = self.valid_lines.length
842
+ self.valid_lines.to_file(out_file, layer="merged_lines")
843
+
844
+ if not self.valid_polys.empty:
845
+ if "length" in self.valid_polys.columns:
846
+ self.valid_polys.drop(columns=["length"], inplace=True)
847
+
848
+ self.valid_polys["area"] = self.valid_polys.area
849
+ self.valid_polys.to_file(out_file, layer="clean_footprint")
850
+
851
+ if not self.invalid_lines.empty:
852
+ self.invalid_lines.to_file(out_file, layer="invalid_lines")
853
+
854
+ if not self.invalid_polys.empty:
855
+ self.invalid_polys.to_file(out_file, layer="invalid_polygons")
856
+
857
+
858
+ @dataclass
859
+ class PolygonTrimming:
860
+ """Store polygon and line to trim. Primary polygon is used to trim both."""
861
+
862
+ poly_primary: Optional[sh_geom.MultiPolygon] = None
863
+ poly_index: int = field(default=-1)
864
+ poly_cleanup: Optional[sh_geom.Polygon] = None
865
+ line_index: int = field(default=-1)
866
+ line_cleanup: Optional[sh_geom.LineString] = None
867
+
868
+ def process(self, primary_poly_list=None, vertex=None):
869
+ # prepare primary polygon
870
+ poly_primary = shapely.union_all(primary_poly_list)
871
+ trim_distance = TRIMMING_DISTANCE
872
+
873
+ if self.line_cleanup.length < 100.0:
874
+ trim_distance = 50.0
875
+
876
+ poly_primary = poly_primary.intersection(vertex.buffer(trim_distance))
877
+
878
+ self.poly_primary = poly_primary
879
+
880
+ # TODO: check why there is such cases
881
+ if self.poly_cleanup is None:
882
+ print("No polygon to trim.")
883
+ return
884
+
885
+ midpoint = self.line_cleanup.interpolate(0.5, normalized=True)
886
+ if self.poly_primary is None or self.poly_primary.is_empty:
887
+ # print("Warning: No valid primary polygon for trimming; skipping difference operation.")
888
+ # TODO: handle this case
889
+ return
890
+ diff = self.poly_cleanup.difference(self.poly_primary)
891
+ if diff.geom_type == "Polygon":
892
+ self.poly_cleanup = diff
893
+ elif diff.geom_type == "MultiPolygon":
894
+ # area = self.poly_cleanup.area
895
+ reserved = []
896
+ for i in diff.geoms:
897
+ # if i.area > TRIM_THRESHOLD * area: # small part
898
+ # reserved.append(i)
899
+ if i.contains(midpoint):
900
+ reserved.append(i)
901
+
902
+ if len(reserved) == 0:
903
+ pass
904
+ elif len(reserved) == 1:
905
+ self.poly_cleanup = sh_geom.Polygon(*reserved)
906
+ else:
907
+ # TODO output all MultiPolygons which should be dealt with
908
+ # self.poly_cleanup = sh_geom.MultiPolygon(reserved)
909
+ print("trim: MultiPolygon detected, please check")
910
+
911
+ diff = self.line_cleanup.intersection(self.poly_cleanup)
912
+ if diff.geom_type == "GeometryCollection":
913
+ geoms = []
914
+ for item in diff.geoms:
915
+ if item.geom_type == "LineString":
916
+ geoms.append(item)
917
+ elif item.geom_type == "MultiLineString":
918
+ print("trim: sh_geom.MultiLineString detected, please check")
919
+ if len(geoms) == 0:
920
+ return
921
+ elif len(geoms) == 1:
922
+ diff = geoms[0]
923
+ else:
924
+ diff = sh_geom.MultiLineString(geoms)
925
+
926
+ if diff.geom_type == "LineString":
927
+ self.line_cleanup = diff
928
+ elif diff.geom_type == "MultiLineString":
929
+ length = self.line_cleanup.length
930
+ reserved = []
931
+ for i in diff.geoms:
932
+ if i.length > TRIM_THRESHOLD * length: # small part
933
+ reserved.append(i)
934
+
935
+ if len(reserved) == 0:
936
+ pass
937
+ elif len(reserved) == 1:
938
+ self.line_cleanup = sh_geom.LineString(*reserved)
939
+ else:
940
+ # TODO output all MultiPolygons which should be dealt with
941
+ self.poly_cleanup = sh_geom.MultiLineString(reserved)