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.
- beratools/__init__.py +1 -7
- beratools/core/algo_centerline.py +491 -351
- beratools/core/algo_common.py +497 -0
- beratools/core/algo_cost.py +192 -0
- beratools/core/{dijkstra_algorithm.py → algo_dijkstra.py} +503 -460
- beratools/core/algo_footprint_rel.py +577 -0
- beratools/core/algo_line_grouping.py +944 -0
- beratools/core/algo_merge_lines.py +214 -0
- beratools/core/algo_split_with_lines.py +304 -0
- beratools/core/algo_tiler.py +428 -0
- beratools/core/algo_vertex_optimization.py +469 -0
- beratools/core/constants.py +52 -86
- beratools/core/logger.py +76 -85
- beratools/core/tool_base.py +196 -133
- beratools/gui/__init__.py +11 -15
- beratools/gui/{beratools.json → assets/beratools.json} +2185 -2300
- beratools/gui/batch_processing_dlg.py +513 -463
- beratools/gui/bt_data.py +481 -487
- beratools/gui/bt_gui_main.py +710 -691
- beratools/gui/main.py +26 -0
- beratools/gui/map_window.py +162 -146
- beratools/gui/tool_widgets.py +725 -493
- beratools/tools/Beratools_r_script.r +1120 -1120
- beratools/tools/Ht_metrics.py +116 -116
- beratools/tools/__init__.py +7 -7
- beratools/tools/batch_processing.py +136 -132
- beratools/tools/canopy_threshold_relative.py +672 -670
- beratools/tools/canopycostraster.py +222 -222
- beratools/tools/centerline.py +136 -176
- beratools/tools/common.py +857 -885
- beratools/tools/fl_regen_csf.py +428 -428
- beratools/tools/forest_line_attributes.py +408 -408
- beratools/tools/line_footprint_absolute.py +213 -363
- beratools/tools/line_footprint_fixed.py +436 -282
- beratools/tools/line_footprint_functions.py +733 -720
- beratools/tools/line_footprint_relative.py +73 -64
- beratools/tools/line_grouping.py +45 -0
- beratools/tools/ln_relative_metrics.py +615 -615
- beratools/tools/r_cal_lpi_elai.r +24 -24
- beratools/tools/r_generate_pd_focalraster.r +100 -100
- beratools/tools/r_interface.py +79 -79
- beratools/tools/r_point_density.r +8 -8
- beratools/tools/rpy_chm2trees.py +86 -86
- beratools/tools/rpy_dsm_chm_by.py +81 -81
- beratools/tools/rpy_dtm_by.py +63 -63
- beratools/tools/rpy_find_cellsize.py +43 -43
- beratools/tools/rpy_gnd_csf.py +74 -74
- beratools/tools/rpy_hummock_hollow.py +85 -85
- beratools/tools/rpy_hummock_hollow_raster.py +71 -71
- beratools/tools/rpy_las_info.py +51 -51
- beratools/tools/rpy_laz2las.py +40 -40
- beratools/tools/rpy_lpi_elai_lascat.py +466 -466
- beratools/tools/rpy_normalized_lidar_by.py +56 -56
- beratools/tools/rpy_percent_above_dbh.py +80 -80
- beratools/tools/rpy_points2trees.py +88 -88
- beratools/tools/rpy_vegcoverage.py +94 -94
- beratools/tools/tiler.py +48 -206
- beratools/tools/tool_template.py +69 -54
- beratools/tools/vertex_optimization.py +61 -620
- beratools/tools/zonal_threshold.py +144 -144
- beratools-0.2.2.dist-info/METADATA +108 -0
- beratools-0.2.2.dist-info/RECORD +74 -0
- {beratools-0.2.0.dist-info → beratools-0.2.2.dist-info}/WHEEL +1 -1
- {beratools-0.2.0.dist-info → beratools-0.2.2.dist-info}/licenses/LICENSE +22 -22
- beratools/gui/cli.py +0 -18
- beratools/gui/gui.json +0 -8
- beratools/gui_tk/ASCII Banners.txt +0 -248
- beratools/gui_tk/__init__.py +0 -20
- beratools/gui_tk/beratools_main.py +0 -515
- beratools/gui_tk/bt_widgets.py +0 -442
- beratools/gui_tk/cli.py +0 -18
- beratools/gui_tk/img/BERALogo.png +0 -0
- beratools/gui_tk/img/closed.gif +0 -0
- beratools/gui_tk/img/closed.png +0 -0
- beratools/gui_tk/img/open.gif +0 -0
- beratools/gui_tk/img/open.png +0 -0
- beratools/gui_tk/img/tool.gif +0 -0
- beratools/gui_tk/img/tool.png +0 -0
- beratools/gui_tk/main.py +0 -14
- beratools/gui_tk/map_window.py +0 -144
- beratools/gui_tk/runner.py +0 -1481
- beratools/gui_tk/tooltip.py +0 -55
- beratools/third_party/pyqtlet2/__init__.py +0 -9
- beratools/third_party/pyqtlet2/leaflet/__init__.py +0 -26
- beratools/third_party/pyqtlet2/leaflet/control/__init__.py +0 -6
- beratools/third_party/pyqtlet2/leaflet/control/control.py +0 -59
- beratools/third_party/pyqtlet2/leaflet/control/draw.py +0 -52
- beratools/third_party/pyqtlet2/leaflet/control/layers.py +0 -20
- beratools/third_party/pyqtlet2/leaflet/core/Parser.py +0 -24
- beratools/third_party/pyqtlet2/leaflet/core/__init__.py +0 -2
- beratools/third_party/pyqtlet2/leaflet/core/evented.py +0 -180
- beratools/third_party/pyqtlet2/leaflet/layer/__init__.py +0 -5
- beratools/third_party/pyqtlet2/leaflet/layer/featuregroup.py +0 -34
- beratools/third_party/pyqtlet2/leaflet/layer/icon/__init__.py +0 -1
- beratools/third_party/pyqtlet2/leaflet/layer/icon/icon.py +0 -30
- beratools/third_party/pyqtlet2/leaflet/layer/imageoverlay.py +0 -18
- beratools/third_party/pyqtlet2/leaflet/layer/layer.py +0 -105
- beratools/third_party/pyqtlet2/leaflet/layer/layergroup.py +0 -45
- beratools/third_party/pyqtlet2/leaflet/layer/marker/__init__.py +0 -1
- beratools/third_party/pyqtlet2/leaflet/layer/marker/marker.py +0 -91
- beratools/third_party/pyqtlet2/leaflet/layer/tile/__init__.py +0 -2
- beratools/third_party/pyqtlet2/leaflet/layer/tile/gridlayer.py +0 -4
- beratools/third_party/pyqtlet2/leaflet/layer/tile/tilelayer.py +0 -16
- beratools/third_party/pyqtlet2/leaflet/layer/vector/__init__.py +0 -5
- beratools/third_party/pyqtlet2/leaflet/layer/vector/circle.py +0 -15
- beratools/third_party/pyqtlet2/leaflet/layer/vector/circlemarker.py +0 -18
- beratools/third_party/pyqtlet2/leaflet/layer/vector/path.py +0 -5
- beratools/third_party/pyqtlet2/leaflet/layer/vector/polygon.py +0 -14
- beratools/third_party/pyqtlet2/leaflet/layer/vector/polyline.py +0 -18
- beratools/third_party/pyqtlet2/leaflet/layer/vector/rectangle.py +0 -14
- beratools/third_party/pyqtlet2/leaflet/map/__init__.py +0 -1
- beratools/third_party/pyqtlet2/leaflet/map/map.py +0 -220
- beratools/third_party/pyqtlet2/mapwidget.py +0 -45
- beratools/third_party/pyqtlet2/web/custom.js +0 -43
- beratools/third_party/pyqtlet2/web/map.html +0 -23
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/layers-2x.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/layers.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-icon-2x.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-icon.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/images/marker-shadow.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/leaflet.css +0 -656
- beratools/third_party/pyqtlet2/web/modules/leaflet_193/leaflet.js +0 -6
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.codeclimate.yml +0 -14
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.editorconfig +0 -4
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.gitattributes +0 -22
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/.travis.yml +0 -43
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/LICENSE +0 -20
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/layers-2x.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/layers.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-icon-2x.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-icon.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/marker-shadow.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet-2x.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet.png +0 -0
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/images/spritesheet.svg +0 -156
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/leaflet.draw.css +0 -10
- beratools/third_party/pyqtlet2/web/modules/leaflet_draw_414/leaflet.draw.js +0 -10
- beratools/third_party/pyqtlet2/web/modules/leaflet_rotatedMarker_020/LICENSE +0 -22
- beratools/third_party/pyqtlet2/web/modules/leaflet_rotatedMarker_020/leaflet.rotatedMarker.js +0 -57
- beratools/tools/forest_line_ecosite.py +0 -216
- beratools/tools/lapis_all.py +0 -103
- beratools/tools/least_cost_path_from_chm.py +0 -152
- beratools-0.2.0.dist-info/METADATA +0 -63
- beratools-0.2.0.dist-info/RECORD +0 -142
- /beratools/gui/{img → assets}/BERALogo.png +0 -0
- /beratools/gui/{img → assets}/closed.gif +0 -0
- /beratools/gui/{img → assets}/closed.png +0 -0
- /beratools/{gui_tk → gui/assets}/gui.json +0 -0
- /beratools/gui/{img → assets}/open.gif +0 -0
- /beratools/gui/{img → assets}/open.png +0 -0
- /beratools/gui/{img → assets}/tool.gif +0 -0
- /beratools/gui/{img → assets}/tool.png +0 -0
- {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)
|