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.
Files changed (67) hide show
  1. {beratools-0.3.10 → beratools-0.4.0}/PKG-INFO +1 -1
  2. {beratools-0.3.10 → beratools-0.4.0}/beratools/__init__.py +1 -1
  3. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_centerline.py +20 -8
  4. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_check_seed_line.py +5 -39
  5. beratools-0.4.0/beratools/core/algo_check_seed_line_validate.py +388 -0
  6. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_common.py +24 -0
  7. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/logger.py +1 -0
  8. beratools-0.4.0/beratools/core/tool_geo_simplify.py +89 -0
  9. beratools-0.4.0/beratools/external/geo_simplify/geo-simplify-linux-x64 +0 -0
  10. beratools-0.4.0/beratools/external/geo_simplify/geo-simplify-win-x64.exe +0 -0
  11. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/beratools.json +31 -4
  12. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/bt_data.py +13 -48
  13. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/bt_gui_main.py +42 -7
  14. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/tool_widgets.py +32 -14
  15. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/canopy_footprint_absolute.py +22 -0
  16. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/centerline.py +69 -7
  17. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/check_seed_line.py +95 -32
  18. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/ground_footprint.py +22 -0
  19. {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/spatial_common.py +161 -10
  20. beratools-0.4.0/beratools/utility/unit_conversion.py +119 -0
  21. {beratools-0.3.10 → beratools-0.4.0}/.gitignore +0 -0
  22. {beratools-0.3.10 → beratools-0.4.0}/LICENSE +0 -0
  23. {beratools-0.3.10 → beratools-0.4.0}/README.md +0 -0
  24. {beratools-0.3.10 → beratools-0.4.0}/beratools/cli/__init__.py +0 -0
  25. {beratools-0.3.10 → beratools-0.4.0}/beratools/cli/entry.py +0 -0
  26. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/__init__.py +0 -0
  27. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_canopy_footprint_absolute.py +0 -0
  28. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_cost.py +0 -0
  29. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_dijkstra.py +0 -0
  30. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_line_grouping.py +0 -0
  31. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_merge_lines.py +0 -0
  32. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_seed_line_correction.py +0 -0
  33. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_split_with_lines.py +0 -0
  34. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_vertex_optimization.py +0 -0
  35. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/algo_vertex_preclean.py +0 -0
  36. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/canopy_threshold_relative.py +0 -0
  37. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/constants.py +0 -0
  38. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/line_footprint_functions.py +0 -0
  39. {beratools-0.3.10 → beratools-0.4.0}/beratools/core/tool_base.py +0 -0
  40. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/__init__.py +0 -0
  41. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/LICENSE.md +0 -0
  42. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/UPSTREAM.md +0 -0
  43. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/__init__.py +0 -0
  44. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/_src.py +0 -0
  45. {beratools-0.3.10 → beratools-0.4.0}/beratools/external/polygon_centerline/exceptions.py +0 -0
  46. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERALogo.ico +0 -0
  47. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERALogo.png +0 -0
  48. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_GUI_Main.png +0 -0
  49. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_Splash.gif +0 -0
  50. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/BERA_WizardImage.png +0 -0
  51. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/closed.gif +0 -0
  52. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/closed.png +0 -0
  53. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/gui.json +0 -0
  54. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/open.gif +0 -0
  55. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/open.png +0 -0
  56. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/tool.gif +0 -0
  57. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/assets/tool.png +0 -0
  58. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/geometry_types.py +0 -0
  59. {beratools-0.3.10 → beratools-0.4.0}/beratools/gui/main.py +0 -0
  60. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/__init__.py +0 -0
  61. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/common.py +0 -0
  62. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/line_footprint_relative.py +0 -0
  63. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/tool_template.py +0 -0
  64. {beratools-0.3.10 → beratools-0.4.0}/beratools/tools/vertex_optimization.py +0 -0
  65. {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/env_checks.py +0 -0
  66. {beratools-0.3.10 → beratools-0.4.0}/beratools/utility/tool_args.py +0 -0
  67. {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.10
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>
@@ -1,6 +1,6 @@
1
1
  __author__ = """Applied Geospatial Research Group"""
2
2
  __email__ = 'appliedgrg@gmail.com'
3
- __version__ = '0.3.10'
3
+ __version__ = '0.4.0'
4
4
  __license__ = "GPL-3.0-or-later"
5
5
  __copyright__ = "Copyright (c) AppliedGRG"
6
6
  __status__ = "Pre-Alpha"
@@ -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
- print(f"algo_centerline: MultiLineString found {in_line.centroid}, pass.")
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
- print("find_centerline: No polygon found")
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
- print(f"find_centerline: {e}")
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
- print(f"find_centerline: {guided_strategy} guidance failed, retrying main_route")
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
- print("Regenerating line ...")
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
- print("regenerate_centerline: polygon sh_ops.split failed, pass.")
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
- print("Centerline is regenerated.")
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
- print("No least cost path detected, use input line.")
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
- crs = pyproj.CRS.from_user_input(gdf.crs) if gdf.crs else None
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
- if crs.is_geographic:
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
- if unit_ctx["is_geographic"]:
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
- if geom is None or geom.is_empty:
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.
@@ -66,6 +66,7 @@ class Logger(object):
66
66
  if flush:
67
67
  for handler in self.logger.handlers:
68
68
  handler.flush()
69
+ sys.stdout.flush()
69
70
 
70
71
  def setup_logger(self):
71
72
  # Change root logger level from WARNING (default) to NOTSET
@@ -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)