BERATools 0.3.10__tar.gz → 0.4.0__tar.gz
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-0.3.10 → beratools-0.4.0}/PKG-INFO +1 -1
- {beratools-0.3.10 → beratools-0.4.0}/beratools/__init__.py +1 -1
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_centerline.py +20 -8
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_check_seed_line.py +5 -39
- beratools-0.4.0/beratools/core/algo_check_seed_line_validate.py +388 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_common.py +24 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/logger.py +1 -0
- beratools-0.4.0/beratools/core/tool_geo_simplify.py +89 -0
- beratools-0.4.0/beratools/external/geo_simplify/geo-simplify-linux-x64 +0 -0
- beratools-0.4.0/beratools/external/geo_simplify/geo-simplify-win-x64.exe +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/beratools.json +31 -4
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/bt_data.py +13 -48
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/bt_gui_main.py +42 -7
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/tool_widgets.py +32 -14
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/canopy_footprint_absolute.py +22 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/centerline.py +69 -7
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/check_seed_line.py +95 -32
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/ground_footprint.py +22 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/spatial_common.py +161 -10
- beratools-0.4.0/beratools/utility/unit_conversion.py +119 -0
- {beratools-0.3.10 → beratools-0.4.0}/.gitignore +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/LICENSE +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/README.md +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/cli/__init__.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/cli/entry.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/__init__.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_canopy_footprint_absolute.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_cost.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_dijkstra.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_line_grouping.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_merge_lines.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_seed_line_correction.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_split_with_lines.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_vertex_optimization.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_vertex_preclean.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/canopy_threshold_relative.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/constants.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/line_footprint_functions.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/core/tool_base.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/__init__.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/LICENSE.md +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/UPSTREAM.md +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/__init__.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/_src.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/exceptions.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERALogo.ico +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERALogo.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_GUI_Main.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_Splash.gif +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_WizardImage.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/closed.gif +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/closed.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/gui.json +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/open.gif +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/open.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/tool.gif +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/tool.png +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/geometry_types.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/main.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/__init__.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/common.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/line_footprint_relative.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/tool_template.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/vertex_optimization.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/env_checks.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/tool_args.py +0 -0
- {beratools-0.3.10 → beratools-0.4.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: BERATools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: An advanced forest line feature analysis platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/appliedgrg/beratools
|
|
6
6
|
Author-email: AppliedGRG <appliedgrg@gmail.com>, Richard Zeng <richardqzeng@gmail.com>
|
|
@@ -99,7 +99,10 @@ def snap_end_to_end(in_line, line_reference, max_snap_dist=None):
|
|
|
99
99
|
if type(in_line) is sh_geom.MultiLineString:
|
|
100
100
|
in_line = sh_ops.linemerge(in_line)
|
|
101
101
|
if type(in_line) is sh_geom.MultiLineString:
|
|
102
|
-
|
|
102
|
+
algo_common.log_file_only(
|
|
103
|
+
f"algo_centerline: MultiLineString found {in_line.centroid}, pass.",
|
|
104
|
+
logger_name=__name__,
|
|
105
|
+
)
|
|
103
106
|
return None
|
|
104
107
|
|
|
105
108
|
pts = list(in_line.coords)
|
|
@@ -218,7 +221,7 @@ def find_centerline(poly, input_line, guided_strategy="main_route"):
|
|
|
218
221
|
effective_strategy = guided_strategy
|
|
219
222
|
|
|
220
223
|
if not poly:
|
|
221
|
-
|
|
224
|
+
algo_common.log_file_only("find_centerline: No polygon found")
|
|
222
225
|
return default_return
|
|
223
226
|
|
|
224
227
|
poly = shapely.segmentize(poly, max_segment_length=CenterlineParams.SEGMENTIZE_LENGTH)
|
|
@@ -252,11 +255,18 @@ def find_centerline(poly, input_line, guided_strategy="main_route"):
|
|
|
252
255
|
guided_strategy,
|
|
253
256
|
)
|
|
254
257
|
except Exception as e:
|
|
255
|
-
|
|
258
|
+
error_msg = str(e)
|
|
259
|
+
if error_msg == "endpoint-guided extraction failed for provided endpoints":
|
|
260
|
+
algo_common.log_file_only(f"find_centerline: {error_msg}")
|
|
261
|
+
else:
|
|
262
|
+
print(f"find_centerline: {e}")
|
|
256
263
|
centerline = None
|
|
257
264
|
|
|
258
265
|
if not centerline and guided_strategy in {"pairwise", "virtual_nodes", "direct_insert"}:
|
|
259
|
-
|
|
266
|
+
if guided_strategy == "pairwise":
|
|
267
|
+
algo_common.log_file_only("find_centerline: pairwise guidance failed, retrying main_route")
|
|
268
|
+
else:
|
|
269
|
+
print(f"find_centerline: {guided_strategy} guidance failed, retrying main_route")
|
|
260
270
|
try:
|
|
261
271
|
centerline = _extract_centerline_from_polygon(
|
|
262
272
|
poly,
|
|
@@ -305,7 +315,7 @@ def find_centerline(poly, input_line, guided_strategy="main_route"):
|
|
|
305
315
|
# Check centerline. If valid, regenerate by splitting polygon into two halves.
|
|
306
316
|
if not centerline_is_valid(centerline, input_line):
|
|
307
317
|
try:
|
|
308
|
-
|
|
318
|
+
algo_common.log_file_only("Regenerating line ...", logger_name=__name__)
|
|
309
319
|
centerline = regenerate_centerline(poly, input_line)
|
|
310
320
|
return centerline, CenterlineStatus.REGENERATE_SUCCESS
|
|
311
321
|
except Exception as e:
|
|
@@ -447,7 +457,9 @@ def regenerate_centerline(poly, input_line):
|
|
|
447
457
|
poly_split = sh_ops.split(poly_exterior, perp)
|
|
448
458
|
|
|
449
459
|
if len(poly_split.geoms) < 2:
|
|
450
|
-
|
|
460
|
+
algo_common.log_file_only(
|
|
461
|
+
"regenerate_centerline: polygon sh_ops.split failed, pass.", logger_name=__name__
|
|
462
|
+
)
|
|
451
463
|
return None
|
|
452
464
|
|
|
453
465
|
poly_1 = poly_split.geoms[0]
|
|
@@ -480,7 +492,7 @@ def regenerate_centerline(poly, input_line):
|
|
|
480
492
|
except Exception as e:
|
|
481
493
|
print(f"regenerate_centerline: {e}")
|
|
482
494
|
|
|
483
|
-
|
|
495
|
+
algo_common.log_file_only("Centerline is regenerated.", logger_name=__name__)
|
|
484
496
|
return sh_ops.linemerge(sh_geom.MultiLineString([center_line_1, center_line_2]))
|
|
485
497
|
|
|
486
498
|
|
|
@@ -531,7 +543,7 @@ class SeedLine:
|
|
|
531
543
|
|
|
532
544
|
# search for centerline
|
|
533
545
|
if len(lc_path_coords) < 2:
|
|
534
|
-
|
|
546
|
+
algo_common.log_file_only("No least cost path detected, use input line.", logger_name=__name__)
|
|
535
547
|
self.line["cl_status"] = CenterlineStatus.FAILED.value
|
|
536
548
|
return
|
|
537
549
|
|
|
@@ -11,6 +11,7 @@ import shapely.geometry as sh_geom
|
|
|
11
11
|
|
|
12
12
|
import beratools.core.constants as bt_const
|
|
13
13
|
import beratools.core.algo_common as algo_common
|
|
14
|
+
import beratools.utility.unit_conversion as unit_conversion
|
|
14
15
|
|
|
15
16
|
try:
|
|
16
17
|
import rasterio
|
|
@@ -227,53 +228,19 @@ def _normalize_to_lines(gdf):
|
|
|
227
228
|
|
|
228
229
|
|
|
229
230
|
def _require_crs(gdf, parameter_label):
|
|
230
|
-
|
|
231
|
-
if crs is None:
|
|
232
|
-
raise ValueError(f"Input line CRS is missing; cannot apply '{parameter_label}'.")
|
|
233
|
-
return crs
|
|
231
|
+
return unit_conversion.require_crs(gdf, parameter_label)
|
|
234
232
|
|
|
235
233
|
|
|
236
234
|
def _build_linear_unit_context(crs, reference_geom):
|
|
237
|
-
|
|
238
|
-
if reference_geom is None or reference_geom.is_empty:
|
|
239
|
-
raise ValueError("Unable to determine reference geometry for geographic CRS unit conversion.")
|
|
240
|
-
ref_point = reference_geom.representative_point()
|
|
241
|
-
metric_crs = pyproj.CRS.from_proj4(
|
|
242
|
-
f"+proj=aeqd +lat_0={ref_point.y} +lon_0={ref_point.x} +datum=WGS84 +units=m +no_defs"
|
|
243
|
-
)
|
|
244
|
-
to_metric = pyproj.Transformer.from_crs(crs, metric_crs, always_xy=True)
|
|
245
|
-
to_source = pyproj.Transformer.from_crs(metric_crs, crs, always_xy=True)
|
|
246
|
-
return {
|
|
247
|
-
"is_geographic": True,
|
|
248
|
-
"to_metric": to_metric,
|
|
249
|
-
"to_source": to_source,
|
|
250
|
-
"unit_factor": None,
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
unit_factor = crs.axis_info[0].unit_conversion_factor if crs.axis_info else None
|
|
254
|
-
if unit_factor is None or unit_factor <= 0:
|
|
255
|
-
raise ValueError("Unable to determine projected CRS linear units.")
|
|
256
|
-
return {
|
|
257
|
-
"is_geographic": False,
|
|
258
|
-
"to_metric": None,
|
|
259
|
-
"to_source": None,
|
|
260
|
-
"unit_factor": unit_factor,
|
|
261
|
-
}
|
|
235
|
+
return unit_conversion.build_linear_unit_context(crs, reference_geom)
|
|
262
236
|
|
|
263
237
|
|
|
264
238
|
def _meters_to_native_units(distance_m, unit_ctx):
|
|
265
|
-
|
|
266
|
-
return float(distance_m)
|
|
267
|
-
return float(distance_m) / float(unit_ctx["unit_factor"])
|
|
239
|
+
return unit_conversion.meters_to_native_units(distance_m, unit_ctx)
|
|
268
240
|
|
|
269
241
|
|
|
270
242
|
def _geometry_length_meters(geom, unit_ctx):
|
|
271
|
-
|
|
272
|
-
return 0.0
|
|
273
|
-
if unit_ctx["is_geographic"]:
|
|
274
|
-
geom_metric = sh_ops.transform(unit_ctx["to_metric"].transform, geom)
|
|
275
|
-
return float(geom_metric.length)
|
|
276
|
-
return float(geom.length) * float(unit_ctx["unit_factor"])
|
|
243
|
+
return unit_conversion.geometry_length_meters(geom, unit_ctx)
|
|
277
244
|
|
|
278
245
|
|
|
279
246
|
def _geographic_raster_cell_size_m(src, crs, x_res, y_res):
|
|
@@ -844,4 +811,3 @@ def qc_split_lines_at_intersections(gdf):
|
|
|
844
811
|
return splitter.split_lines_gdf.reset_index(drop=True)
|
|
845
812
|
return splitter.split_lines_gdf
|
|
846
813
|
return gdf.reset_index(drop=True)
|
|
847
|
-
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Final validation pass on check_seed_line outputs to detect remaining issues."""
|
|
2
|
+
|
|
3
|
+
import geopandas as gpd
|
|
4
|
+
from shapely.geometry import GeometryCollection, LineString, MultiLineString, MultiPoint, Point
|
|
5
|
+
|
|
6
|
+
import beratools.core.constants as bt_const
|
|
7
|
+
import beratools.utility.unit_conversion as unit_conversion
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ISSUE_COLUMNS = [
|
|
11
|
+
"issue_type",
|
|
12
|
+
"description",
|
|
13
|
+
"line_id",
|
|
14
|
+
"line_id_2",
|
|
15
|
+
"value",
|
|
16
|
+
"threshold",
|
|
17
|
+
"geometry",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_line_parts(geom):
|
|
22
|
+
if geom is None or geom.is_empty:
|
|
23
|
+
return []
|
|
24
|
+
if geom.geom_type == "LineString":
|
|
25
|
+
return [geom]
|
|
26
|
+
if geom.geom_type == "MultiLineString":
|
|
27
|
+
return [part for part in geom.geoms if part is not None and not part.is_empty]
|
|
28
|
+
if geom.geom_type == "GeometryCollection":
|
|
29
|
+
parts = []
|
|
30
|
+
for item in geom.geoms:
|
|
31
|
+
parts.extend(_iter_line_parts(item))
|
|
32
|
+
return parts
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_unit_ctx(gdf):
|
|
37
|
+
crs = unit_conversion.require_crs(gdf, "QC report checks")
|
|
38
|
+
reference_geom = None if gdf.empty else gdf.unary_union.envelope
|
|
39
|
+
return unit_conversion.build_linear_unit_context(crs, reference_geom)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _point_distance_m(point_a, point_b, unit_ctx):
|
|
43
|
+
if unit_ctx["is_geographic"]:
|
|
44
|
+
point_a_m = Point(unit_ctx["to_metric"].transform(point_a.x, point_a.y))
|
|
45
|
+
point_b_m = Point(unit_ctx["to_metric"].transform(point_b.x, point_b.y))
|
|
46
|
+
return float(point_a_m.distance(point_b_m))
|
|
47
|
+
return float(point_a.distance(point_b)) * float(unit_ctx["unit_factor"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _segment_length_m(coord_a, coord_b, unit_ctx):
|
|
51
|
+
seg = LineString([coord_a, coord_b])
|
|
52
|
+
return unit_conversion.geometry_length_meters(seg, unit_ctx)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _intersection_points(geom):
|
|
56
|
+
if geom is None or geom.is_empty:
|
|
57
|
+
return []
|
|
58
|
+
if isinstance(geom, Point):
|
|
59
|
+
return [geom]
|
|
60
|
+
if isinstance(geom, MultiPoint):
|
|
61
|
+
return [pt for pt in geom.geoms if pt is not None and not pt.is_empty]
|
|
62
|
+
if isinstance(geom, LineString):
|
|
63
|
+
if geom.length <= bt_const.SMALL_BUFFER:
|
|
64
|
+
return []
|
|
65
|
+
return [geom.interpolate(0.5, normalized=True)]
|
|
66
|
+
if isinstance(geom, MultiLineString):
|
|
67
|
+
points = []
|
|
68
|
+
for part in geom.geoms:
|
|
69
|
+
points.extend(_intersection_points(part))
|
|
70
|
+
return points
|
|
71
|
+
if isinstance(geom, GeometryCollection):
|
|
72
|
+
points = []
|
|
73
|
+
for part in geom.geoms:
|
|
74
|
+
points.extend(_intersection_points(part))
|
|
75
|
+
return points
|
|
76
|
+
if hasattr(geom, "representative_point"):
|
|
77
|
+
return [geom.representative_point()]
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _overlap_midpoints_and_lengths(geom, unit_ctx):
|
|
82
|
+
if geom is None or geom.is_empty:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
items = []
|
|
86
|
+
if isinstance(geom, LineString):
|
|
87
|
+
length_m = unit_conversion.geometry_length_meters(geom, unit_ctx)
|
|
88
|
+
if length_m > bt_const.SMALL_BUFFER:
|
|
89
|
+
items.append((geom.interpolate(0.5, normalized=True), float(length_m)))
|
|
90
|
+
return items
|
|
91
|
+
|
|
92
|
+
if isinstance(geom, MultiLineString):
|
|
93
|
+
for part in geom.geoms:
|
|
94
|
+
items.extend(_overlap_midpoints_and_lengths(part, unit_ctx))
|
|
95
|
+
return items
|
|
96
|
+
|
|
97
|
+
if isinstance(geom, GeometryCollection):
|
|
98
|
+
for part in geom.geoms:
|
|
99
|
+
items.extend(_overlap_midpoints_and_lengths(part, unit_ctx))
|
|
100
|
+
return items
|
|
101
|
+
|
|
102
|
+
return items
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dedupe_records(records, precision=9):
|
|
106
|
+
deduped = []
|
|
107
|
+
seen = set()
|
|
108
|
+
for rec in records:
|
|
109
|
+
pt = rec["geometry"]
|
|
110
|
+
key = (
|
|
111
|
+
rec["issue_type"],
|
|
112
|
+
int(rec["line_id"]),
|
|
113
|
+
-1 if rec["line_id_2"] is None else int(rec["line_id_2"]),
|
|
114
|
+
round(float(pt.x), precision),
|
|
115
|
+
round(float(pt.y), precision),
|
|
116
|
+
round(float(rec["value"]), 6),
|
|
117
|
+
round(float(rec["threshold"]), 6),
|
|
118
|
+
)
|
|
119
|
+
if key in seen:
|
|
120
|
+
continue
|
|
121
|
+
seen.add(key)
|
|
122
|
+
deduped.append(rec)
|
|
123
|
+
return deduped
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_short_lines(gdf, min_length_m, unit_ctx):
|
|
127
|
+
threshold = float(min_length_m)
|
|
128
|
+
records = []
|
|
129
|
+
for line_id, geom in enumerate(gdf.geometry):
|
|
130
|
+
for part in _iter_line_parts(geom):
|
|
131
|
+
length_m = unit_conversion.geometry_length_meters(part, unit_ctx)
|
|
132
|
+
if length_m < threshold:
|
|
133
|
+
records.append(
|
|
134
|
+
{
|
|
135
|
+
"issue_type": "short_line",
|
|
136
|
+
"description": f"Line length {length_m:.3f}m is below minimum {threshold:.3f}m",
|
|
137
|
+
"line_id": int(line_id),
|
|
138
|
+
"line_id_2": None,
|
|
139
|
+
"value": float(length_m),
|
|
140
|
+
"threshold": threshold,
|
|
141
|
+
"geometry": part.interpolate(0.5, normalized=True),
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
return _dedupe_records(records)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_close_vertices(gdf, close_distance_m, unit_ctx):
|
|
148
|
+
threshold = float(close_distance_m)
|
|
149
|
+
records = []
|
|
150
|
+
for line_id, geom in enumerate(gdf.geometry):
|
|
151
|
+
for part in _iter_line_parts(geom):
|
|
152
|
+
coords = list(part.coords)
|
|
153
|
+
if len(coords) < 2:
|
|
154
|
+
continue
|
|
155
|
+
for i in range(1, len(coords)):
|
|
156
|
+
dist_m = _segment_length_m(coords[i - 1], coords[i], unit_ctx)
|
|
157
|
+
if bt_const.SMALL_BUFFER < dist_m <= threshold:
|
|
158
|
+
records.append(
|
|
159
|
+
{
|
|
160
|
+
"issue_type": "close_vertices",
|
|
161
|
+
"description": (
|
|
162
|
+
f"Consecutive vertices are {dist_m:.3f}m apart; threshold {threshold:.3f}m"
|
|
163
|
+
),
|
|
164
|
+
"line_id": int(line_id),
|
|
165
|
+
"line_id_2": None,
|
|
166
|
+
"value": float(dist_m),
|
|
167
|
+
"threshold": threshold,
|
|
168
|
+
"geometry": Point(coords[i]),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return _dedupe_records(records)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def check_unsnapped_endpoints(gdf, snap_tolerance_m, unit_ctx):
|
|
175
|
+
threshold = float(snap_tolerance_m)
|
|
176
|
+
records = []
|
|
177
|
+
|
|
178
|
+
endpoints = []
|
|
179
|
+
for line_id, geom in enumerate(gdf.geometry):
|
|
180
|
+
for part in _iter_line_parts(geom):
|
|
181
|
+
coords = list(part.coords)
|
|
182
|
+
if len(coords) < 2:
|
|
183
|
+
continue
|
|
184
|
+
endpoints.append({"line_id": int(line_id), "point": Point(coords[0])})
|
|
185
|
+
endpoints.append({"line_id": int(line_id), "point": Point(coords[-1])})
|
|
186
|
+
|
|
187
|
+
if len(endpoints) < 2:
|
|
188
|
+
return records
|
|
189
|
+
|
|
190
|
+
if unit_ctx["is_geographic"]:
|
|
191
|
+
candidates = [
|
|
192
|
+
(i, j)
|
|
193
|
+
for i in range(len(endpoints))
|
|
194
|
+
for j in range(i + 1, len(endpoints))
|
|
195
|
+
if endpoints[i]["line_id"] != endpoints[j]["line_id"]
|
|
196
|
+
]
|
|
197
|
+
else:
|
|
198
|
+
threshold_native = unit_conversion.meters_to_native_units(threshold, unit_ctx)
|
|
199
|
+
points = gpd.GeoSeries([ep["point"] for ep in endpoints], crs=gdf.crs)
|
|
200
|
+
sindex = points.sindex
|
|
201
|
+
candidates = []
|
|
202
|
+
for i, ep in enumerate(endpoints):
|
|
203
|
+
bounds = ep["point"].buffer(threshold_native).bounds
|
|
204
|
+
for j in sindex.intersection(bounds):
|
|
205
|
+
if j <= i:
|
|
206
|
+
continue
|
|
207
|
+
if ep["line_id"] == endpoints[j]["line_id"]:
|
|
208
|
+
continue
|
|
209
|
+
candidates.append((i, int(j)))
|
|
210
|
+
|
|
211
|
+
for i, j in candidates:
|
|
212
|
+
ep_a = endpoints[i]
|
|
213
|
+
ep_b = endpoints[j]
|
|
214
|
+
dist_m = _point_distance_m(ep_a["point"], ep_b["point"], unit_ctx)
|
|
215
|
+
if dist_m <= bt_const.SMALL_BUFFER or dist_m > threshold:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
line_id_a = min(ep_a["line_id"], ep_b["line_id"])
|
|
219
|
+
line_id_b = max(ep_a["line_id"], ep_b["line_id"])
|
|
220
|
+
records.append(
|
|
221
|
+
{
|
|
222
|
+
"issue_type": "unsnapped_endpoint",
|
|
223
|
+
"description": (
|
|
224
|
+
f"Endpoints are close ({dist_m:.3f}m) but not snapped (threshold {threshold:.3f}m)"
|
|
225
|
+
),
|
|
226
|
+
"line_id": int(line_id_a),
|
|
227
|
+
"line_id_2": int(line_id_b),
|
|
228
|
+
"value": float(dist_m),
|
|
229
|
+
"threshold": threshold,
|
|
230
|
+
"geometry": ep_a["point"],
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return _dedupe_records(records)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_self_crossing(gdf):
|
|
238
|
+
records = []
|
|
239
|
+
threshold = 0.0
|
|
240
|
+
for line_id, geom in enumerate(gdf.geometry):
|
|
241
|
+
for part in _iter_line_parts(geom):
|
|
242
|
+
if part.is_simple:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
coords = list(part.coords)
|
|
246
|
+
if len(coords) < 4:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
segments = []
|
|
250
|
+
for i in range(len(coords) - 1):
|
|
251
|
+
seg = LineString([coords[i], coords[i + 1]])
|
|
252
|
+
if seg.length > bt_const.SMALL_BUFFER:
|
|
253
|
+
segments.append((i, seg))
|
|
254
|
+
|
|
255
|
+
seg_count = len(segments)
|
|
256
|
+
for i in range(seg_count):
|
|
257
|
+
idx_a, seg_a = segments[i]
|
|
258
|
+
for j in range(i + 1, seg_count):
|
|
259
|
+
idx_b, seg_b = segments[j]
|
|
260
|
+
|
|
261
|
+
if abs(idx_a - idx_b) <= 1:
|
|
262
|
+
continue
|
|
263
|
+
if idx_a == 0 and idx_b == len(coords) - 2 and Point(coords[0]).equals(Point(coords[-1])):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
inter = seg_a.intersection(seg_b)
|
|
267
|
+
if inter is None or inter.is_empty:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
for pt in _intersection_points(inter):
|
|
271
|
+
records.append(
|
|
272
|
+
{
|
|
273
|
+
"issue_type": "self_crossing",
|
|
274
|
+
"description": "Line has self-crossing or self-touching segments",
|
|
275
|
+
"line_id": int(line_id),
|
|
276
|
+
"line_id_2": None,
|
|
277
|
+
"value": 0.0,
|
|
278
|
+
"threshold": threshold,
|
|
279
|
+
"geometry": pt,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return _dedupe_records(records)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_overlapping_lines(gdf):
|
|
287
|
+
records = []
|
|
288
|
+
threshold = 0.0
|
|
289
|
+
|
|
290
|
+
lines = []
|
|
291
|
+
for line_id, geom in enumerate(gdf.geometry):
|
|
292
|
+
for part in _iter_line_parts(geom):
|
|
293
|
+
lines.append({"line_id": int(line_id), "geometry": part})
|
|
294
|
+
|
|
295
|
+
if len(lines) < 2:
|
|
296
|
+
return records
|
|
297
|
+
|
|
298
|
+
unit_ctx = _build_unit_ctx(gdf)
|
|
299
|
+
|
|
300
|
+
line_gdf = gpd.GeoDataFrame(lines, geometry="geometry", crs=gdf.crs)
|
|
301
|
+
sindex = line_gdf.sindex
|
|
302
|
+
|
|
303
|
+
for i, row in line_gdf.iterrows():
|
|
304
|
+
geom_i = row.geometry
|
|
305
|
+
for j in sindex.intersection(geom_i.bounds):
|
|
306
|
+
j = int(j)
|
|
307
|
+
if j <= i:
|
|
308
|
+
continue
|
|
309
|
+
other = line_gdf.iloc[j]
|
|
310
|
+
if int(other["line_id"]) == int(row["line_id"]):
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
inter = geom_i.intersection(other.geometry)
|
|
314
|
+
if inter is None or inter.is_empty:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
for pt, overlap_len_m in _overlap_midpoints_and_lengths(inter, unit_ctx):
|
|
318
|
+
line_id_a = min(int(row["line_id"]), int(other["line_id"]))
|
|
319
|
+
line_id_b = max(int(row["line_id"]), int(other["line_id"]))
|
|
320
|
+
records.append(
|
|
321
|
+
{
|
|
322
|
+
"issue_type": "overlap",
|
|
323
|
+
"description": f"Lines share an overlapping segment ({overlap_len_m:.3f}m)",
|
|
324
|
+
"line_id": line_id_a,
|
|
325
|
+
"line_id_2": line_id_b,
|
|
326
|
+
"value": float(overlap_len_m),
|
|
327
|
+
"threshold": threshold,
|
|
328
|
+
"geometry": pt,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return _dedupe_records(records)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def generate_qc_report(gdf, min_length_m, close_distance_m, snap_tolerance_m):
|
|
336
|
+
if gdf is None:
|
|
337
|
+
gdf = gpd.GeoDataFrame(geometry=[])
|
|
338
|
+
|
|
339
|
+
gdf = gdf.reset_index(drop=True)
|
|
340
|
+
if gdf.empty:
|
|
341
|
+
issues_gdf = gpd.GeoDataFrame(
|
|
342
|
+
{col: [] for col in ISSUE_COLUMNS if col != "geometry"},
|
|
343
|
+
geometry=gpd.GeoSeries([], crs=gdf.crs),
|
|
344
|
+
crs=gdf.crs,
|
|
345
|
+
)
|
|
346
|
+
summary = {
|
|
347
|
+
"short_line": 0,
|
|
348
|
+
"close_vertices": 0,
|
|
349
|
+
"unsnapped_endpoint": 0,
|
|
350
|
+
"self_crossing": 0,
|
|
351
|
+
"overlap": 0,
|
|
352
|
+
"total": 0,
|
|
353
|
+
}
|
|
354
|
+
return issues_gdf, summary
|
|
355
|
+
|
|
356
|
+
unit_ctx = _build_unit_ctx(gdf)
|
|
357
|
+
|
|
358
|
+
short_line = check_short_lines(gdf, min_length_m=min_length_m, unit_ctx=unit_ctx)
|
|
359
|
+
close_vertices = check_close_vertices(gdf, close_distance_m=close_distance_m, unit_ctx=unit_ctx)
|
|
360
|
+
unsnapped_endpoint = check_unsnapped_endpoints(
|
|
361
|
+
gdf,
|
|
362
|
+
snap_tolerance_m=snap_tolerance_m,
|
|
363
|
+
unit_ctx=unit_ctx,
|
|
364
|
+
)
|
|
365
|
+
self_crossing = check_self_crossing(gdf)
|
|
366
|
+
overlap = check_overlapping_lines(gdf)
|
|
367
|
+
|
|
368
|
+
all_issues = short_line + close_vertices + unsnapped_endpoint + self_crossing + overlap
|
|
369
|
+
|
|
370
|
+
if all_issues:
|
|
371
|
+
issues_gdf = gpd.GeoDataFrame(all_issues, columns=ISSUE_COLUMNS, geometry="geometry", crs=gdf.crs)
|
|
372
|
+
else:
|
|
373
|
+
issues_gdf = gpd.GeoDataFrame(
|
|
374
|
+
{col: [] for col in ISSUE_COLUMNS if col != "geometry"},
|
|
375
|
+
geometry=gpd.GeoSeries([], crs=gdf.crs),
|
|
376
|
+
crs=gdf.crs,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
summary = {
|
|
380
|
+
"short_line": len(short_line),
|
|
381
|
+
"close_vertices": len(close_vertices),
|
|
382
|
+
"unsnapped_endpoint": len(unsnapped_endpoint),
|
|
383
|
+
"self_crossing": len(self_crossing),
|
|
384
|
+
"overlap": len(overlap),
|
|
385
|
+
}
|
|
386
|
+
summary["total"] = int(sum(summary.values()))
|
|
387
|
+
|
|
388
|
+
return issues_gdf, summary
|
|
@@ -39,6 +39,30 @@ DISTANCE_THRESHOLD = 2 # 1 meter for intersection neighborhood
|
|
|
39
39
|
logger = logging.getLogger(__name__)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def log_file_only(message, level=logging.INFO, logger_name=None):
|
|
43
|
+
"""Log a message to file handlers only, skipping console/gui handlers."""
|
|
44
|
+
target_logger = logging.getLogger(logger_name) if logger_name else logging.getLogger(__name__)
|
|
45
|
+
record = target_logger.makeRecord(
|
|
46
|
+
name=target_logger.name,
|
|
47
|
+
level=level,
|
|
48
|
+
fn="",
|
|
49
|
+
lno=0,
|
|
50
|
+
msg=message,
|
|
51
|
+
args=(),
|
|
52
|
+
exc_info=None,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
root_logger = logging.getLogger()
|
|
56
|
+
wrote_to_file = False
|
|
57
|
+
for handler in root_logger.handlers:
|
|
58
|
+
if isinstance(handler, logging.FileHandler):
|
|
59
|
+
handler.handle(record)
|
|
60
|
+
wrote_to_file = True
|
|
61
|
+
|
|
62
|
+
if not wrote_to_file:
|
|
63
|
+
target_logger.log(level, message)
|
|
64
|
+
|
|
65
|
+
|
|
42
66
|
def process_single_item(cls_obj):
|
|
43
67
|
"""
|
|
44
68
|
Process a class object for universal multiprocessing.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tooling helpers for invoking geo-simplify binaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_diameter(value):
|
|
14
|
+
try:
|
|
15
|
+
diameter = float(value)
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
raise ValueError("simplify_diameter must be a number >= 0.")
|
|
18
|
+
|
|
19
|
+
if diameter < 0:
|
|
20
|
+
raise ValueError("simplify_diameter must be >= 0.")
|
|
21
|
+
|
|
22
|
+
return diameter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_temp_output_same_folder(out_file, suffix=".gpkg", prefix="centerline_tmp_"):
|
|
26
|
+
out_path = Path(out_file)
|
|
27
|
+
out_dir = out_path.parent if out_path.parent else Path(".")
|
|
28
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
fd, temp_path = tempfile.mkstemp(prefix=prefix, suffix=suffix, dir=out_dir)
|
|
30
|
+
os.close(fd)
|
|
31
|
+
return Path(temp_path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_reduce_bend(input_file, in_layer, output_file, out_layer, diameter, smooth_line=True):
|
|
35
|
+
binary_path = _geo_simplify_resolve_binary_path()
|
|
36
|
+
command = [
|
|
37
|
+
str(binary_path),
|
|
38
|
+
"reduce-bend",
|
|
39
|
+
"--input",
|
|
40
|
+
str(input_file),
|
|
41
|
+
"--in-layer",
|
|
42
|
+
in_layer,
|
|
43
|
+
"--output",
|
|
44
|
+
output_file,
|
|
45
|
+
"--diameter",
|
|
46
|
+
str(diameter),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
if smooth_line:
|
|
50
|
+
command.append("--smooth-line")
|
|
51
|
+
|
|
52
|
+
if out_layer:
|
|
53
|
+
command.extend(["--out-layer", out_layer])
|
|
54
|
+
|
|
55
|
+
result = subprocess.run(command, capture_output=True, text=True)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"geo-simplify reduce-bend failed "
|
|
59
|
+
f"(exit code {result.returncode})\n"
|
|
60
|
+
f"Command: {' '.join(command)}\n"
|
|
61
|
+
f"stdout:\n{result.stdout}\n"
|
|
62
|
+
f"stderr:\n{result.stderr}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _geo_simplify_resolve_binary_path():
|
|
67
|
+
external_dir = Path(__file__).resolve().parents[1] / "external" / "geo_simplify"
|
|
68
|
+
system_name = platform.system()
|
|
69
|
+
|
|
70
|
+
if system_name == "Windows":
|
|
71
|
+
binary_path = external_dir / "geo-simplify-win-x64.exe"
|
|
72
|
+
elif system_name == "Linux":
|
|
73
|
+
binary_path = external_dir / "geo-simplify-linux-x64"
|
|
74
|
+
else:
|
|
75
|
+
raise RuntimeError(f"Centerline simplify is not supported on OS: {system_name}")
|
|
76
|
+
|
|
77
|
+
if not binary_path.exists():
|
|
78
|
+
raise FileNotFoundError(f"Missing geo-simplify binary: {binary_path}")
|
|
79
|
+
|
|
80
|
+
if system_name == "Linux":
|
|
81
|
+
_geo_simplify_ensure_executable(binary_path)
|
|
82
|
+
|
|
83
|
+
return binary_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _geo_simplify_ensure_executable(binary_path):
|
|
87
|
+
mode = binary_path.stat().st_mode
|
|
88
|
+
if not (mode & stat.S_IXUSR):
|
|
89
|
+
binary_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
Binary file
|
|
Binary file
|