BERATools 0.2.0__py3-none-any.whl → 0.2.2__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 (153) hide show
  1. beratools/__init__.py +1 -7
  2. beratools/core/algo_centerline.py +491 -351
  3. beratools/core/algo_common.py +497 -0
  4. beratools/core/algo_cost.py +192 -0
  5. beratools/core/{dijkstra_algorithm.py → algo_dijkstra.py} +503 -460
  6. beratools/core/algo_footprint_rel.py +577 -0
  7. beratools/core/algo_line_grouping.py +944 -0
  8. beratools/core/algo_merge_lines.py +214 -0
  9. beratools/core/algo_split_with_lines.py +304 -0
  10. beratools/core/algo_tiler.py +428 -0
  11. beratools/core/algo_vertex_optimization.py +469 -0
  12. beratools/core/constants.py +52 -86
  13. beratools/core/logger.py +76 -85
  14. beratools/core/tool_base.py +196 -133
  15. beratools/gui/__init__.py +11 -15
  16. beratools/gui/{beratools.json → assets/beratools.json} +2185 -2300
  17. beratools/gui/batch_processing_dlg.py +513 -463
  18. beratools/gui/bt_data.py +481 -487
  19. beratools/gui/bt_gui_main.py +710 -691
  20. beratools/gui/main.py +26 -0
  21. beratools/gui/map_window.py +162 -146
  22. beratools/gui/tool_widgets.py +725 -493
  23. beratools/tools/Beratools_r_script.r +1120 -1120
  24. beratools/tools/Ht_metrics.py +116 -116
  25. beratools/tools/__init__.py +7 -7
  26. beratools/tools/batch_processing.py +136 -132
  27. beratools/tools/canopy_threshold_relative.py +672 -670
  28. beratools/tools/canopycostraster.py +222 -222
  29. beratools/tools/centerline.py +136 -176
  30. beratools/tools/common.py +857 -885
  31. beratools/tools/fl_regen_csf.py +428 -428
  32. beratools/tools/forest_line_attributes.py +408 -408
  33. beratools/tools/line_footprint_absolute.py +213 -363
  34. beratools/tools/line_footprint_fixed.py +436 -282
  35. beratools/tools/line_footprint_functions.py +733 -720
  36. beratools/tools/line_footprint_relative.py +73 -64
  37. beratools/tools/line_grouping.py +45 -0
  38. beratools/tools/ln_relative_metrics.py +615 -615
  39. beratools/tools/r_cal_lpi_elai.r +24 -24
  40. beratools/tools/r_generate_pd_focalraster.r +100 -100
  41. beratools/tools/r_interface.py +79 -79
  42. beratools/tools/r_point_density.r +8 -8
  43. beratools/tools/rpy_chm2trees.py +86 -86
  44. beratools/tools/rpy_dsm_chm_by.py +81 -81
  45. beratools/tools/rpy_dtm_by.py +63 -63
  46. beratools/tools/rpy_find_cellsize.py +43 -43
  47. beratools/tools/rpy_gnd_csf.py +74 -74
  48. beratools/tools/rpy_hummock_hollow.py +85 -85
  49. beratools/tools/rpy_hummock_hollow_raster.py +71 -71
  50. beratools/tools/rpy_las_info.py +51 -51
  51. beratools/tools/rpy_laz2las.py +40 -40
  52. beratools/tools/rpy_lpi_elai_lascat.py +466 -466
  53. beratools/tools/rpy_normalized_lidar_by.py +56 -56
  54. beratools/tools/rpy_percent_above_dbh.py +80 -80
  55. beratools/tools/rpy_points2trees.py +88 -88
  56. beratools/tools/rpy_vegcoverage.py +94 -94
  57. beratools/tools/tiler.py +48 -206
  58. beratools/tools/tool_template.py +69 -54
  59. beratools/tools/vertex_optimization.py +61 -620
  60. beratools/tools/zonal_threshold.py +144 -144
  61. beratools-0.2.2.dist-info/METADATA +108 -0
  62. beratools-0.2.2.dist-info/RECORD +74 -0
  63. {beratools-0.2.0.dist-info → beratools-0.2.2.dist-info}/WHEEL +1 -1
  64. {beratools-0.2.0.dist-info → beratools-0.2.2.dist-info}/licenses/LICENSE +22 -22
  65. beratools/gui/cli.py +0 -18
  66. beratools/gui/gui.json +0 -8
  67. beratools/gui_tk/ASCII Banners.txt +0 -248
  68. beratools/gui_tk/__init__.py +0 -20
  69. beratools/gui_tk/beratools_main.py +0 -515
  70. beratools/gui_tk/bt_widgets.py +0 -442
  71. beratools/gui_tk/cli.py +0 -18
  72. beratools/gui_tk/img/BERALogo.png +0 -0
  73. beratools/gui_tk/img/closed.gif +0 -0
  74. beratools/gui_tk/img/closed.png +0 -0
  75. beratools/gui_tk/img/open.gif +0 -0
  76. beratools/gui_tk/img/open.png +0 -0
  77. beratools/gui_tk/img/tool.gif +0 -0
  78. beratools/gui_tk/img/tool.png +0 -0
  79. beratools/gui_tk/main.py +0 -14
  80. beratools/gui_tk/map_window.py +0 -144
  81. beratools/gui_tk/runner.py +0 -1481
  82. beratools/gui_tk/tooltip.py +0 -55
  83. beratools/third_party/pyqtlet2/__init__.py +0 -9
  84. beratools/third_party/pyqtlet2/leaflet/__init__.py +0 -26
  85. beratools/third_party/pyqtlet2/leaflet/control/__init__.py +0 -6
  86. beratools/third_party/pyqtlet2/leaflet/control/control.py +0 -59
  87. beratools/third_party/pyqtlet2/leaflet/control/draw.py +0 -52
  88. beratools/third_party/pyqtlet2/leaflet/control/layers.py +0 -20
  89. beratools/third_party/pyqtlet2/leaflet/core/Parser.py +0 -24
  90. beratools/third_party/pyqtlet2/leaflet/core/__init__.py +0 -2
  91. beratools/third_party/pyqtlet2/leaflet/core/evented.py +0 -180
  92. beratools/third_party/pyqtlet2/leaflet/layer/__init__.py +0 -5
  93. beratools/third_party/pyqtlet2/leaflet/layer/featuregroup.py +0 -34
  94. beratools/third_party/pyqtlet2/leaflet/layer/icon/__init__.py +0 -1
  95. beratools/third_party/pyqtlet2/leaflet/layer/icon/icon.py +0 -30
  96. beratools/third_party/pyqtlet2/leaflet/layer/imageoverlay.py +0 -18
  97. beratools/third_party/pyqtlet2/leaflet/layer/layer.py +0 -105
  98. beratools/third_party/pyqtlet2/leaflet/layer/layergroup.py +0 -45
  99. beratools/third_party/pyqtlet2/leaflet/layer/marker/__init__.py +0 -1
  100. beratools/third_party/pyqtlet2/leaflet/layer/marker/marker.py +0 -91
  101. beratools/third_party/pyqtlet2/leaflet/layer/tile/__init__.py +0 -2
  102. beratools/third_party/pyqtlet2/leaflet/layer/tile/gridlayer.py +0 -4
  103. beratools/third_party/pyqtlet2/leaflet/layer/tile/tilelayer.py +0 -16
  104. beratools/third_party/pyqtlet2/leaflet/layer/vector/__init__.py +0 -5
  105. beratools/third_party/pyqtlet2/leaflet/layer/vector/circle.py +0 -15
  106. beratools/third_party/pyqtlet2/leaflet/layer/vector/circlemarker.py +0 -18
  107. beratools/third_party/pyqtlet2/leaflet/layer/vector/path.py +0 -5
  108. beratools/third_party/pyqtlet2/leaflet/layer/vector/polygon.py +0 -14
  109. beratools/third_party/pyqtlet2/leaflet/layer/vector/polyline.py +0 -18
  110. beratools/third_party/pyqtlet2/leaflet/layer/vector/rectangle.py +0 -14
  111. beratools/third_party/pyqtlet2/leaflet/map/__init__.py +0 -1
  112. beratools/third_party/pyqtlet2/leaflet/map/map.py +0 -220
  113. beratools/third_party/pyqtlet2/mapwidget.py +0 -45
  114. beratools/third_party/pyqtlet2/web/custom.js +0 -43
  115. beratools/third_party/pyqtlet2/web/map.html +0 -23
  116. beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/layers-2x.png +0 -0
  117. beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/layers.png +0 -0
  118. beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-icon-2x.png +0 -0
  119. beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-icon.png +0 -0
  120. beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-shadow.png +0 -0
  121. beratools/third_party/pyqtlet2/web/modules/leaflet_193/leaflet.css +0 -656
  122. beratools/third_party/pyqtlet2/web/modules/leaflet_193/leaflet.js +0 -6
  123. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.codeclimate.yml +0 -14
  124. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.editorconfig +0 -4
  125. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.gitattributes +0 -22
  126. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.travis.yml +0 -43
  127. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/LICENSE +0 -20
  128. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/layers-2x.png +0 -0
  129. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/layers.png +0 -0
  130. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-icon-2x.png +0 -0
  131. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-icon.png +0 -0
  132. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-shadow.png +0 -0
  133. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet-2x.png +0 -0
  134. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet.png +0 -0
  135. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet.svg +0 -156
  136. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/leaflet.draw.css +0 -10
  137. beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/leaflet.draw.js +0 -10
  138. beratools/third_party/pyqtlet2/web/modules/leaflet_rotatedMarker_020/LICENSE +0 -22
  139. beratools/third_party/pyqtlet2/web/modules/leaflet_rotatedMarker_020/leaflet.rotatedMarker.js +0 -57
  140. beratools/tools/forest_line_ecosite.py +0 -216
  141. beratools/tools/lapis_all.py +0 -103
  142. beratools/tools/least_cost_path_from_chm.py +0 -152
  143. beratools-0.2.0.dist-info/METADATA +0 -63
  144. beratools-0.2.0.dist-info/RECORD +0 -142
  145. /beratools/gui/{img → assets}/BERALogo.png +0 -0
  146. /beratools/gui/{img → assets}/closed.gif +0 -0
  147. /beratools/gui/{img → assets}/closed.png +0 -0
  148. /beratools/{gui_tk → gui/assets}/gui.json +0 -0
  149. /beratools/gui/{img → assets}/open.gif +0 -0
  150. /beratools/gui/{img → assets}/open.png +0 -0
  151. /beratools/gui/{img → assets}/tool.gif +0 -0
  152. /beratools/gui/{img → assets}/tool.png +0 -0
  153. {beratools-0.2.0.dist-info → beratools-0.2.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,214 @@
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
+ from itertools import pairwise
17
+ from operator import itemgetter
18
+
19
+ import networkit as nk
20
+ import shapely.geometry as sh_geom
21
+
22
+ import beratools.core.algo_common as algo_common
23
+ import beratools.core.constants as bt_const
24
+
25
+
26
+ def run_line_merge(in_line_gdf, merge_group):
27
+ out_line_gdf = in_line_gdf
28
+ if merge_group:
29
+ out_line_gdf = in_line_gdf.dissolve(by=bt_const.BT_GROUP, as_index=False)
30
+
31
+ out_line_gdf.geometry = out_line_gdf.line_merge()
32
+
33
+ for row in out_line_gdf.itertuples():
34
+ if isinstance(row.geometry, sh_geom.MultiLineString):
35
+ worker = MergeLines(row.geometry)
36
+ merged_line = worker.merge_all_lines()
37
+ if merged_line:
38
+ out_line_gdf.at[row.Index, "geometry"] = merged_line
39
+
40
+ out_line_gdf = algo_common.clean_line_geometries(out_line_gdf)
41
+ out_line_gdf.reset_index(inplace=True, drop=True)
42
+ out_line_gdf['length'] = out_line_gdf.geometry.length
43
+ return out_line_gdf
44
+
45
+ class MergeLines:
46
+ """Merge line segments in MultiLineString."""
47
+
48
+ def __init__(self, multi_line):
49
+ self.G = None
50
+ self.line_segs = None
51
+ self.multi_line = multi_line
52
+ self.node_poly = None
53
+ self.end = None
54
+
55
+ self.create_graph()
56
+
57
+ def create_graph(self):
58
+ self.line_segs = list(self.multi_line.geoms)
59
+
60
+ # TODO: check empty line and null geoms
61
+ self.line_segs = [line for line in self.line_segs if line.length > 1e-3]
62
+ self.multi_line = sh_geom.MultiLineString(self.line_segs)
63
+ m = sh_geom.mapping(self.multi_line)
64
+ self.end = [(i[0], i[-1]) for i in m['coordinates']]
65
+
66
+ self.G = nk.Graph(edgesIndexed=True)
67
+ self.G.addNodes(2)
68
+ self.G.addEdge(0, 1)
69
+
70
+ self.node_poly = [
71
+ sh_geom.Point(self.end[0][0]).buffer(1),
72
+ sh_geom.Point(self.end[0][1]).buffer(1),
73
+ ]
74
+
75
+ for i, line in enumerate(self.end[1:]):
76
+ node_exists = False
77
+ pt = sh_geom.Point(line[0])
78
+ pt_buffer = pt.buffer(1)
79
+
80
+ for node in self.G.iterNodes():
81
+ if self.node_poly[node].contains(pt):
82
+ node_exists = True
83
+ node_start = node
84
+ if not node_exists:
85
+ node_start = self.G.addNode()
86
+ self.node_poly.append(pt_buffer)
87
+
88
+ node_exists = False
89
+ pt = sh_geom.Point(line[1])
90
+ pt_buffer = pt.buffer(1)
91
+ for node in self.G.iterNodes():
92
+ if self.node_poly[node].contains(pt):
93
+ node_exists = True
94
+ node_end = node
95
+ if not node_exists:
96
+ node_end = self.G.addNode()
97
+ self.node_poly.append(pt_buffer)
98
+
99
+ self.G.addEdge(node_start, node_end)
100
+
101
+ def get_components(self):
102
+ cc = nk.components.ConnectedComponents(self.G)
103
+ cc.run()
104
+ components = cc.getComponents()
105
+ return components
106
+
107
+ def is_single_path(self, component):
108
+ single_path = True
109
+ for node in component:
110
+ neighbors = list(self.G.iterNeighbors(node))
111
+ if len(neighbors) > 2:
112
+ single_path = False
113
+
114
+ return single_path
115
+ def get_merged_line_for_component(self, component):
116
+ sub = nk.graphtools.subgraphFromNodes(self.G, component)
117
+ lines = None
118
+ if nk.graphtools.maxDegree(sub) >= 3: # not simple path
119
+ edges = [self.G.edgeId(i[0], i[1]) for i in list(sub.iterEdges())]
120
+ lines = itemgetter(*edges)(self.line_segs)
121
+ elif nk.graphtools.maxDegree(sub) == 2:
122
+ lines = self.merge_single_line(component)
123
+
124
+ return lines
125
+
126
+ def find_path_for_component(self, component):
127
+ neighbors = list(self.G.iterNeighbors(component[0]))
128
+ path = [component[0]]
129
+ right = neighbors[0]
130
+ path.append(right)
131
+
132
+ left = None
133
+ if len(neighbors) == 2:
134
+ left = neighbors[1]
135
+ path.insert(0, left)
136
+
137
+ neighbors = list(self.G.iterNeighbors(right))
138
+ while len(neighbors) > 1:
139
+ if neighbors[0] not in path:
140
+ path.append(neighbors[0])
141
+ right = neighbors[0]
142
+ else:
143
+ path.append(neighbors[1])
144
+ right = neighbors[1]
145
+
146
+ neighbors = list(self.G.iterNeighbors(right))
147
+
148
+ # last node
149
+ if neighbors[0] not in path:
150
+ path.append(neighbors[0])
151
+
152
+ # process left side
153
+ if left:
154
+ neighbors = list(self.G.iterNeighbors(left))
155
+ while len(neighbors) > 1:
156
+ if neighbors[0] not in path:
157
+ path.insert(0, neighbors[0])
158
+ left = neighbors[0]
159
+ else:
160
+ path.insert(0, neighbors[1])
161
+ left = neighbors[1]
162
+
163
+ neighbors = list(self.G.iterNeighbors(left))
164
+
165
+ # last node
166
+ if neighbors[0] not in path:
167
+ path.insert(0, neighbors[0])
168
+
169
+ return path
170
+
171
+ def merge_single_line(self, component):
172
+ path = self.find_path_for_component(component)
173
+
174
+ pairs = list(pairwise(path))
175
+ line_list = [self.G.edgeId(i[0], i[1]) for i in pairs]
176
+
177
+ vertices = []
178
+
179
+ for i, id in enumerate(line_list):
180
+ pair = pairs[i]
181
+ poly_t = self.node_poly[pair[0]]
182
+ point_t = sh_geom.Point(self.end[id][0])
183
+ if poly_t.contains(point_t):
184
+ line = self.line_segs[id]
185
+ else:
186
+ # line = reverse(self.line_segs[id])
187
+ line = self.line_segs[id].reverse()
188
+
189
+ vertices.extend(list(line.coords))
190
+ last_vertex = vertices.pop()
191
+
192
+ vertices.append(last_vertex)
193
+ merged_line = sh_geom.LineString(vertices)
194
+
195
+ return [merged_line]
196
+
197
+ def merge_all_lines(self):
198
+ components = self.get_components()
199
+ lines = []
200
+ for c in components:
201
+ line = self.get_merged_line_for_component(c)
202
+ if line:
203
+ lines.extend(self.get_merged_line_for_component(c))
204
+ else: # TODO: check line
205
+ print(f"merge_all_lines: failed to merge: {self.multi_line.bounds}")
206
+
207
+ # print('Merge lines done.')
208
+
209
+ if len(lines) > 1:
210
+ return sh_geom.MultiLineString(lines)
211
+ elif len(lines) == 1:
212
+ return lines[0]
213
+ else:
214
+ return None
@@ -0,0 +1,304 @@
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
+ def min_distance_in_multipoint(multipoint):
15
+ points = list(multipoint.geoms)
16
+ min_dist = float("inf")
17
+ for p1, p2 in combinations(points, 2):
18
+ dist = p1.distance(p2)
19
+ if dist < min_dist:
20
+ min_dist = dist
21
+ return min_dist
22
+
23
+ class LineSplitter:
24
+ """Split lines at intersections."""
25
+
26
+ def __init__(self, line_gdf):
27
+ """
28
+ Initialize the LineSplitter with the input GeoPackage and layer name.
29
+
30
+ Args:
31
+ input_gpkg (str): Path to the input GeoPackage file.
32
+ layer_name (str): Name of the layer to read from the GeoPackage.
33
+
34
+ """
35
+ # Explode if needed for multi-part geometries
36
+ self.line_gdf = line_gdf.explode()
37
+ self.line_gdf[INTER_STATUS_COL] = 1 # record line intersection status
38
+ self.inter_status = {}
39
+ self.sindex = self.line_gdf.sindex # Spatial index for faster operations
40
+
41
+ self.intersection_gdf = []
42
+ self.split_lines_gdf = None
43
+
44
+ def cut_line_by_points(self, line, points):
45
+ """
46
+ Cuts a LineString into segments based on the given points.
47
+
48
+ Args:
49
+ line: A shapely LineString to be cut.
50
+ points: A list of Point objects where the LineString needs to be cut.
51
+
52
+ Return:
53
+ A list of LineString segments after the cuts.
54
+
55
+ """
56
+ # Create a spatial index for the coordinates of the LineString
57
+ line_coords = [Point(x, y) for x, y in line.coords]
58
+ sindex = STRtree(line_coords)
59
+
60
+ # Sort points based on their projected position along the line
61
+ sorted_points = sorted(points, key=lambda p: line.project(p))
62
+ segments = []
63
+
64
+ # Process each point, inserting it into the correct location
65
+ start_idx = 0
66
+ start_pt = None
67
+ end_pt = None
68
+
69
+ for point in sorted_points:
70
+ # Find the closest segment on the line using the spatial index
71
+ nearest_pt_idx = sindex.nearest(point)
72
+ end_idx = nearest_pt_idx
73
+ end_pt = point
74
+
75
+ dist1 = line.project(point)
76
+ dist2 = line.project(line_coords[nearest_pt_idx])
77
+
78
+ if dist1 > dist2:
79
+ end_idx = nearest_pt_idx + 1
80
+
81
+ # Create a new segment
82
+ new_coords = line_coords[start_idx:end_idx]
83
+ if start_pt: # Append start point
84
+ new_coords = [start_pt] + new_coords
85
+
86
+ if end_pt: # Append end point
87
+ new_coords = new_coords + [end_pt]
88
+
89
+ nearest_segment = LineString(new_coords)
90
+ start_idx = end_idx
91
+ start_pt = end_pt
92
+
93
+ segments.append(nearest_segment)
94
+
95
+ # Add remaining part of the line after the last point
96
+ if start_idx < len(line_coords):
97
+ # If last point is not close to end point of line
98
+ if start_pt.distance(line_coords[-1]) > EPSILON:
99
+ remaining_part = LineString([start_pt] + line_coords[end_idx:])
100
+ segments.append(remaining_part)
101
+
102
+ return segments
103
+
104
+ def find_intersections(self):
105
+ """
106
+ Find intersections between lines in the GeoDataFrame.
107
+
108
+ Return:
109
+ List of Point geometries where the lines intersect.
110
+
111
+ """
112
+ visited_pairs = set()
113
+ intersection_points = []
114
+
115
+ # Iterate through each line geometry to find intersections
116
+ for idx, line1 in enumerate(self.line_gdf.geometry):
117
+ # Use spatial index to find candidates for intersection
118
+ indices = list(self.sindex.intersection(line1.bounds))
119
+ indices.remove(idx) # Remove the current index from the list
120
+
121
+ for match_idx in indices:
122
+ line2 = self.line_gdf.iloc[match_idx].geometry
123
+
124
+ # Create an index pair where the smaller index comes first
125
+ pair = tuple(sorted([idx, match_idx]))
126
+
127
+ # Skip if this pair has already been visited
128
+ if pair in visited_pairs:
129
+ continue
130
+
131
+ # Mark the pair as visited
132
+ visited_pairs.add(pair)
133
+
134
+ # Only check lines that are different and intersect
135
+ line1 = snap(line1, line2, tolerance=EPSILON)
136
+ if line1.intersects(line2):
137
+ # Find intersection points (can be multiple)
138
+ intersections = line1.intersection(line2)
139
+
140
+ if intersections.is_empty:
141
+ continue
142
+
143
+ # Intersection can be Point, MultiPoint, LineString or GeometryCollection
144
+ if isinstance(intersections, Point):
145
+ intersection_points.append(intersections)
146
+ else:
147
+ # record for further inspection
148
+ # GeometryCollection, MultiLineString
149
+ if isinstance(intersections, MultiPoint):
150
+ intersection_points.extend(intersections.geoms)
151
+ elif isinstance(intersections, LineString):
152
+ intersection_points.append(
153
+ intersections.interpolate(0.5, normalized=True)
154
+ )
155
+
156
+ # if minimum distance between points is greater than threshold
157
+ # mark line as valid
158
+ if isinstance(intersections, MultiPoint):
159
+ if (
160
+ min_distance_in_multipoint(intersections)
161
+ > algo_common.DISTANCE_THRESHOLD
162
+ ):
163
+ continue
164
+ # if intersection is a line, mark line as valid
165
+ if isinstance(intersections, LineString):
166
+ continue
167
+
168
+ for item in pair:
169
+ self.inter_status[item] = 0
170
+
171
+ self.intersection_gdf = gpd.GeoDataFrame(
172
+ geometry=intersection_points, crs=self.line_gdf.crs
173
+ )
174
+
175
+ def split_lines_at_intersections(self):
176
+ """
177
+ Split lines at the given intersection points.
178
+
179
+ Args:
180
+ intersection_points: List of Point geometries where the lines should be split.
181
+
182
+ Returns:
183
+ A GeoDataFrame with the split lines.
184
+
185
+ """
186
+ # Create a spatial index for faster point-line intersection checks
187
+ sindex = self.intersection_gdf.sindex
188
+
189
+ # List to hold the new split line segments
190
+ new_rows = []
191
+
192
+ # Iterate through each intersection point to split lines at that point
193
+ for row in self.line_gdf.itertuples():
194
+ if not isinstance(row.geometry, LineString):
195
+ continue
196
+
197
+ # Use spatial index to find possible line candidates for intersection
198
+ possible_matches = sindex.query(row.geometry.buffer(EPSILON))
199
+ end_pts = MultiPoint([row.geometry.coords[0], row.geometry.coords[-1]])
200
+
201
+ pt_list = []
202
+ new_segments = [row.geometry]
203
+
204
+ for idx in possible_matches:
205
+ point = self.intersection_gdf.loc[idx].geometry
206
+ # Check if the point is on the line
207
+ if row.geometry.distance(point) < EPSILON:
208
+ if end_pts.distance(point) < EPSILON:
209
+ continue
210
+ else:
211
+ pt_list.append(point)
212
+
213
+ if len(pt_list) > 0:
214
+ # Split the line at the intersection
215
+ new_segments = self.cut_line_by_points(row.geometry, pt_list)
216
+
217
+ # If the line was split into multiple segments, create new rows
218
+ for segment in new_segments:
219
+ new_row = row._asdict() # Convert the original row into a dictionary
220
+ new_row['geometry'] = segment # Update the geometry with the split one
221
+ new_rows.append(new_row)
222
+
223
+ self.split_lines_gdf = gpd.GeoDataFrame(
224
+ new_rows, columns=self.line_gdf.columns, crs=self.line_gdf.crs
225
+ )
226
+
227
+ self.split_lines_gdf = algo_common.clean_line_geometries(self.split_lines_gdf)
228
+
229
+ # Debugging: print how many segments were created
230
+ print(f"Total new line segments created: {len(new_rows)}")
231
+
232
+ def save_to_geopackage(
233
+ self,
234
+ input_gpkg,
235
+ line_layer="split_lines",
236
+ intersection_layer=None,
237
+ invalid_layer=None,
238
+ ):
239
+ """
240
+ Save the split lines and intersection points to the GeoPackage.
241
+
242
+ Args:
243
+ line_layer: split lines layer name in the GeoPackage.
244
+ intersection_layer: layer name for intersection points in the GeoPackage.
245
+
246
+ """
247
+ # Save intersection points and split lines to the GeoPackage
248
+ if self.split_lines_gdf is not None and intersection_layer:
249
+ if len(self.intersection_gdf) > 0:
250
+ self.intersection_gdf.to_file(
251
+ input_gpkg, layer=intersection_layer, driver="GPKG"
252
+ )
253
+
254
+ if self.split_lines_gdf is not None and line_layer:
255
+ if len(self.split_lines_gdf) > 0:
256
+ self.split_lines_gdf['length'] = self.split_lines_gdf.geometry.length
257
+ self.split_lines_gdf.to_file(
258
+ input_gpkg, layer=line_layer, driver="GPKG"
259
+ )
260
+
261
+ # save invalid splits
262
+ invalid_splits = self.line_gdf.loc[self.line_gdf[INTER_STATUS_COL] == 0]
263
+ if not invalid_splits.empty and invalid_layer:
264
+ if len(invalid_splits) > 0:
265
+ invalid_splits.to_file(
266
+ input_gpkg, layer=invalid_layer, driver="GPKG"
267
+ )
268
+
269
+ def process(self, intersection_gdf=None):
270
+ """
271
+ Find intersection points, split lines at intersections.
272
+
273
+ Args:
274
+ intersection_gdf: external GeoDataFrame with intersection points.
275
+
276
+ """
277
+ if intersection_gdf is not None:
278
+ self.intersection_gdf = intersection_gdf
279
+ else:
280
+ self.find_intersections()
281
+
282
+ if self.inter_status:
283
+ for idx in self.inter_status.keys():
284
+ self.line_gdf.loc[idx, INTER_STATUS_COL] = self.inter_status[idx]
285
+
286
+ if not self.intersection_gdf.empty:
287
+ # Split the lines at intersection points
288
+ self.split_lines_at_intersections()
289
+ else:
290
+ print("No intersection points found, no lines to split.")
291
+
292
+ def split_with_lines(input_gpkg, layer_name):
293
+ splitter = LineSplitter(input_gpkg, layer_name)
294
+ splitter.process()
295
+ splitter.save_to_geopackage()
296
+
297
+ if __name__ == "__main__":
298
+ input_gpkg = r"I:\Temp\footprint_final.gpkg"
299
+ layer_name = "merged_lines_original"
300
+
301
+ gdf = gpd.read_file(input_gpkg, layer=layer_name)
302
+ splitter = LineSplitter(gdf)
303
+ splitter.process()
304
+ splitter.save_to_geopackage(input_gpkg)