BERATools 0.4.3__tar.gz → 0.5.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 (70) hide show
  1. {beratools-0.4.3 → beratools-0.5.0}/PKG-INFO +9 -15
  2. {beratools-0.4.3 → beratools-0.5.0}/README.md +2 -3
  3. beratools-0.5.0/beratools/core/algo_astar.py +397 -0
  4. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_canopy_footprint_absolute.py +114 -5
  5. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_centerline.py +304 -37
  6. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_check_seed_line_validate.py +220 -2
  7. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_common.py +41 -23
  8. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_cost.py +48 -0
  9. beratools-0.5.0/beratools/core/algo_geometry.py +83 -0
  10. beratools-0.5.0/beratools/core/alt_spatial_common.py +108 -0
  11. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/constants.py +23 -1
  12. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/line_footprint_functions.py +1 -1
  13. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/tool_geo_simplify.py +36 -0
  14. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/polygon_centerline/_src.py +131 -3
  15. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/beratools.json +279 -0
  16. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/bt_gui_main.py +12 -8
  17. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/canopy_footprint_absolute.py +22 -0
  18. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/centerline.py +67 -0
  19. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/check_seed_line.py +17 -0
  20. {beratools-0.4.3 → beratools-0.5.0}/pyproject.toml +10 -14
  21. {beratools-0.4.3 → beratools-0.5.0}/.gitignore +0 -0
  22. {beratools-0.4.3 → beratools-0.5.0}/LICENSE +0 -0
  23. {beratools-0.4.3 → beratools-0.5.0}/beratools/__init__.py +0 -0
  24. {beratools-0.4.3 → beratools-0.5.0}/beratools/cli/__init__.py +0 -0
  25. {beratools-0.4.3 → beratools-0.5.0}/beratools/cli/entry.py +0 -0
  26. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/__init__.py +0 -0
  27. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_check_seed_line.py +0 -0
  28. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_dijkstra.py +0 -0
  29. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_line_grouping.py +0 -0
  30. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_merge_lines.py +0 -0
  31. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_seed_line_correction.py +0 -0
  32. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_split_with_lines.py +0 -0
  33. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_vertex_optimization.py +0 -0
  34. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/algo_vertex_preclean.py +0 -0
  35. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/canopy_threshold_relative.py +0 -0
  36. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/logger.py +0 -0
  37. {beratools-0.4.3 → beratools-0.5.0}/beratools/core/tool_base.py +0 -0
  38. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/__init__.py +0 -0
  39. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/geo_simplify/geo-simplify-linux-x64 +0 -0
  40. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/geo_simplify/geo-simplify-win-x64.exe +0 -0
  41. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/polygon_centerline/LICENSE.md +0 -0
  42. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/polygon_centerline/UPSTREAM.md +0 -0
  43. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/polygon_centerline/__init__.py +0 -0
  44. {beratools-0.4.3 → beratools-0.5.0}/beratools/external/polygon_centerline/exceptions.py +0 -0
  45. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/BERALogo.ico +0 -0
  46. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/BERALogo.png +0 -0
  47. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/BERA_GUI_Main.png +0 -0
  48. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/BERA_Splash.gif +0 -0
  49. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/BERA_WizardImage.png +0 -0
  50. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/closed.gif +0 -0
  51. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/closed.png +0 -0
  52. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/gui.json +0 -0
  53. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/open.gif +0 -0
  54. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/open.png +0 -0
  55. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/tool.gif +0 -0
  56. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/assets/tool.png +0 -0
  57. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/bt_data.py +0 -0
  58. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/geometry_types.py +0 -0
  59. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/main.py +0 -0
  60. {beratools-0.4.3 → beratools-0.5.0}/beratools/gui/tool_widgets.py +0 -0
  61. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/__init__.py +0 -0
  62. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/common.py +0 -0
  63. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/ground_footprint.py +0 -0
  64. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/line_footprint_relative.py +0 -0
  65. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/tool_template.py +0 -0
  66. {beratools-0.4.3 → beratools-0.5.0}/beratools/tools/vertex_optimization.py +0 -0
  67. {beratools-0.4.3 → beratools-0.5.0}/beratools/utility/env_checks.py +0 -0
  68. {beratools-0.4.3 → beratools-0.5.0}/beratools/utility/spatial_common.py +0 -0
  69. {beratools-0.4.3 → beratools-0.5.0}/beratools/utility/tool_args.py +0 -0
  70. {beratools-0.4.3 → beratools-0.5.0}/beratools/utility/unit_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BERATools
3
- Version: 0.4.3
3
+ Version: 0.5.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>
@@ -11,19 +11,18 @@ Classifier: Development Status :: 2 - Pre-Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
13
  Classifier: Natural Language :: English
14
- Classifier: Programming Language :: Python :: 3.10
15
- Classifier: Programming Language :: Python :: 3.11
16
14
  Classifier: Programming Language :: Python :: 3.12
17
15
  Classifier: Programming Language :: Python :: 3.13
18
16
  Classifier: Programming Language :: Python :: 3.14
19
- Requires-Python: >=3.10
20
- Requires-Dist: gdal; platform_system != 'Windows'
17
+ Requires-Python: <3.15,>=3.12
18
+ Requires-Dist: gdal<4,>=3.12; platform_system != 'Windows'
21
19
  Requires-Dist: geopandas
22
20
  Requires-Dist: networkit
21
+ Requires-Dist: numpy<2.4,>=1.26
23
22
  Requires-Dist: pandarallel
24
- Requires-Dist: pyogrio>=0.9.0
23
+ Requires-Dist: pyogrio>=0.12.0
25
24
  Requires-Dist: pyqt5
26
- Requires-Dist: rasterio
25
+ Requires-Dist: rasterio>=1.5.0
27
26
  Requires-Dist: scikit-image>=0.24.0
28
27
  Requires-Dist: tabulate
29
28
  Requires-Dist: tqdm
@@ -40,15 +39,11 @@ Requires-Dist: ruff; extra == 'dev'
40
39
  Requires-Dist: tox; extra == 'dev'
41
40
  Requires-Dist: twine; extra == 'dev'
42
41
  Provides-Extra: doc
43
- Requires-Dist: click==8.2.1; extra == 'doc'
44
- Requires-Dist: mkdocs; extra == 'doc'
45
- Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == 'doc'
46
- Requires-Dist: mkdocs-material; extra == 'doc'
47
42
  Requires-Dist: mkdocstrings; extra == 'doc'
48
43
  Requires-Dist: mkdocstrings-python; extra == 'doc'
49
44
  Requires-Dist: pymdown-extensions; extra == 'doc'
45
+ Requires-Dist: zensical==0.0.53; extra == 'doc'
50
46
  Provides-Extra: test
51
- Requires-Dist: gdal==3.8.4; extra == 'test'
52
47
  Requires-Dist: pytest; extra == 'test'
53
48
  Requires-Dist: pytest-qt; extra == 'test'
54
49
  Requires-Dist: pywinauto; (platform_system == 'Windows') and extra == 'test'
@@ -60,12 +55,11 @@ BERA Tools is successor of [Forest Line Mapper](https://github.com/appliedgrg/fl
60
55
 
61
56
  <div align="center">
62
57
 
63
- [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/appliedgrg/beratools/python-tests.yml?branch=main)](https://github.com/appliedgrg/beratools/actions/workflows/python-tests.yml)
64
- [![Codecov](https://img.shields.io/codecov/c/github/appliedgrg/beratools/main)](https://codecov.io/gh/appliedgrg/beratools)
58
+ [![Python Integration Tests](https://img.shields.io/github/actions/workflow/status/appliedgrg/beratools/python-integration-tests.yml?branch=main)](https://github.com/appliedgrg/beratools/actions/workflows/python-integration-tests.yml)
65
59
  [![GitHub Pages](https://img.shields.io/github/deployments/appliedgrg/beratools/github-pages?label=docs)](https://appliedgrg.github.io/beratools/)
66
60
  [![Conda Version](https://img.shields.io/conda/v/AppliedGRG/beratools)](https://anaconda.org/AppliedGRG/beratools)
67
61
  [![PyPI](https://img.shields.io/pypi/v/BERATools)](https://pypi.org/project/BERATools/)
68
- [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/release/python-3100/)
62
+ [![Python Version](https://img.shields.io/badge/python-3.12--3.14-blue)](https://www.python.org/downloads/)
69
63
  [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
70
64
 
71
65
  </div>
@@ -4,12 +4,11 @@ BERA Tools is successor of [Forest Line Mapper](https://github.com/appliedgrg/fl
4
4
 
5
5
  <div align="center">
6
6
 
7
- [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/appliedgrg/beratools/python-tests.yml?branch=main)](https://github.com/appliedgrg/beratools/actions/workflows/python-tests.yml)
8
- [![Codecov](https://img.shields.io/codecov/c/github/appliedgrg/beratools/main)](https://codecov.io/gh/appliedgrg/beratools)
7
+ [![Python Integration Tests](https://img.shields.io/github/actions/workflow/status/appliedgrg/beratools/python-integration-tests.yml?branch=main)](https://github.com/appliedgrg/beratools/actions/workflows/python-integration-tests.yml)
9
8
  [![GitHub Pages](https://img.shields.io/github/deployments/appliedgrg/beratools/github-pages?label=docs)](https://appliedgrg.github.io/beratools/)
10
9
  [![Conda Version](https://img.shields.io/conda/v/AppliedGRG/beratools)](https://anaconda.org/AppliedGRG/beratools)
11
10
  [![PyPI](https://img.shields.io/pypi/v/BERATools)](https://pypi.org/project/BERATools/)
12
- [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/release/python-3100/)
11
+ [![Python Version](https://img.shields.io/badge/python-3.12--3.14-blue)](https://www.python.org/downloads/)
13
12
  [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
14
13
 
15
14
  </div>
@@ -0,0 +1,397 @@
1
+ """Reusable A* path and corridor algorithms for centerline workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import math
7
+ from dataclasses import dataclass
8
+ from itertools import count
9
+ from typing import Iterable
10
+
11
+ import numpy as np
12
+ import rasterio
13
+ from rasterio.features import geometry_mask
14
+ from scipy import ndimage
15
+ from shapely.geometry import LineString
16
+
17
+
18
+ SQRT2 = math.sqrt(2.0)
19
+ ASTAR_LINE_BIAS = 0.001
20
+ DEFAULT_CORRIDOR_LINE_BIAS_WEIGHT = 0.1
21
+ DEFAULT_CORRIDOR_DISTANCE_PENALTY_WEIGHT = 0.2
22
+
23
+ NEIGHBORS: tuple[tuple[int, int], ...] = (
24
+ (1, 0),
25
+ (0, -1),
26
+ (-1, 0),
27
+ (0, 1),
28
+ (1, -1),
29
+ (1, 1),
30
+ (-1, -1),
31
+ (-1, 1),
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AStarAccumulation:
37
+ path: list[tuple[int, int]]
38
+ best_cost: float
39
+ g_scores: np.ndarray
40
+ closed: np.ndarray
41
+
42
+
43
+ def find_least_cost_path_astar_closest_line(cost_arr, meta: dict, input_line: LineString) -> LineString | None:
44
+ """Find an 8-neighbor A* LCP, tie-broken toward the seed-line direction."""
45
+
46
+ if input_line is None or input_line.is_empty or len(input_line.coords) < 2:
47
+ return None
48
+
49
+ costs, walkable = _prepare_lcp_costs(cost_arr, meta.get("nodata"))
50
+ rows, cols = costs.shape
51
+ transformer = rasterio.transform.AffineTransformer(meta["transform"])
52
+ start_xy = input_line.coords[0]
53
+ end_xy = input_line.coords[-1]
54
+ start = _clamp_row_col(transformer.rowcol(start_xy[0], start_xy[1]), rows, cols)
55
+ end = _clamp_row_col(transformer.rowcol(end_xy[0], end_xy[1]), rows, cols)
56
+ if not walkable[start] or not walkable[end]:
57
+ return None
58
+
59
+ min_cost = float(costs[walkable].min()) if np.any(walkable) else 0.0
60
+ tie_scores = np.full((rows, cols), math.inf, dtype="float64")
61
+ g_scores = np.full((rows, cols), math.inf, dtype="float64")
62
+ came_from: dict[tuple[int, int], tuple[int, int] | None] = {start: None}
63
+ closed: set[tuple[int, int]] = set()
64
+ sequence = count()
65
+ heap: list[tuple] = []
66
+
67
+ g_scores[start] = 0.0
68
+ tie_scores[start] = 0.0
69
+ heapq.heappush(heap, _lcp_queue_entry(start, end, start, 0.0, 0.0, min_cost, next(sequence)))
70
+
71
+ while heap:
72
+ *_, current = heapq.heappop(heap)
73
+ if current in closed:
74
+ continue
75
+ if current == end:
76
+ return _path_to_linestring(_reconstruct_path(came_from, end), transformer, start_xy, end_xy)
77
+ closed.add(current)
78
+
79
+ for neighbor in _neighbors(current, rows, cols, walkable):
80
+ if neighbor in closed:
81
+ continue
82
+ new_g = g_scores[current] + _lcp_edge_cost(costs, current, neighbor)
83
+ new_tie = _line_bias_score(neighbor, start, end)
84
+ current_g = g_scores[neighbor]
85
+ current_tie = tie_scores[neighbor]
86
+ improved = new_g < current_g - 1e-6
87
+ tied_better = abs(new_g - current_g) <= 1e-6 and new_tie < current_tie
88
+ if not improved and not tied_better:
89
+ continue
90
+ g_scores[neighbor] = new_g
91
+ tie_scores[neighbor] = new_tie
92
+ came_from[neighbor] = current
93
+ heapq.heappush(
94
+ heap,
95
+ _lcp_queue_entry(neighbor, end, start, new_g, new_tie, min_cost, next(sequence)),
96
+ )
97
+
98
+ return None
99
+
100
+
101
+ def astar_accumulation_corridor_raster(
102
+ cost_arr,
103
+ meta: dict,
104
+ lc_path: LineString,
105
+ *,
106
+ corridor_threshold: float,
107
+ line_bias_weight: float = DEFAULT_CORRIDOR_LINE_BIAS_WEIGHT,
108
+ distance_penalty_weight: float = DEFAULT_CORRIDOR_DISTANCE_PENALTY_WEIGHT,
109
+ ) -> tuple[np.ma.MaskedArray, dict[str, object]]:
110
+ """Build an A* accumulation corridor raster using BERA's 0-inside/1-outside convention."""
111
+
112
+ if lc_path is None or lc_path.is_empty or len(lc_path.coords) < 2:
113
+ raise RuntimeError("A* corridor requires a valid least-cost path")
114
+
115
+ cost = _prepare_corridor_cost_surface(cost_arr, meta)
116
+ rows, cols = cost.shape
117
+ transform = meta["transform"]
118
+ transformer = rasterio.transform.AffineTransformer(transform)
119
+ start_xy = lc_path.coords[0]
120
+ end_xy = lc_path.coords[-1]
121
+ source = _clamp_row_col(transformer.rowcol(*start_xy), rows, cols)
122
+ destination = _clamp_row_col(transformer.rowcol(*end_xy), rows, cols)
123
+ sampling = _raster_sampling(transform)
124
+
125
+ forward = _astar_mcp_geometric_accumulation(
126
+ cost,
127
+ source,
128
+ destination,
129
+ sampling=sampling,
130
+ line_bias_weight=line_bias_weight,
131
+ )
132
+ reverse = _astar_mcp_geometric_accumulation(
133
+ cost,
134
+ destination,
135
+ source,
136
+ sampling=sampling,
137
+ line_bias_weight=line_bias_weight,
138
+ )
139
+
140
+ valid = forward.closed & reverse.closed
141
+ score_data = forward.g_scores + reverse.g_scores - forward.best_cost
142
+ if distance_penalty_weight > 0.0:
143
+ astar_path = _path_to_linestring(forward.path, transformer, start_xy, end_xy)
144
+ distance_to_path = _distance_raster_to_line(astar_path, meta, cost.shape)
145
+ score_data = score_data + distance_to_path * distance_penalty_weight
146
+
147
+ score = np.ma.masked_invalid(score_data)
148
+ score = np.ma.array(score, mask=np.ma.getmaskarray(score) | ~valid)
149
+ inside = (~np.ma.getmaskarray(score)) & np.asarray(score.filled(np.inf) < corridor_threshold, dtype=bool)
150
+ corridor = np.ma.where(inside, 0.0, 1.0)
151
+ corridor = np.ma.array(corridor, mask=np.ma.getmaskarray(score))
152
+
153
+ details = {
154
+ "corridor_threshold": float(corridor_threshold),
155
+ "astar_line_bias_weight": float(line_bias_weight),
156
+ "astar_distance_penalty_weight": float(distance_penalty_weight),
157
+ "astar_best_cost": float(forward.best_cost),
158
+ "inside_cells": int(np.count_nonzero(inside)),
159
+ "inside_area": float(np.count_nonzero(inside) * _cell_area(transform)),
160
+ "forward_closed_cells": int(np.count_nonzero(forward.closed)),
161
+ "reverse_closed_cells": int(np.count_nonzero(reverse.closed)),
162
+ "both_closed_cells": int(np.count_nonzero(valid)),
163
+ "astar_path_vertices": int(len(forward.path)),
164
+ }
165
+ return corridor, details
166
+
167
+
168
+ def _prepare_lcp_costs(cost_arr, nodata) -> tuple[np.ndarray, np.ndarray]:
169
+ array = np.ma.asarray(cost_arr)
170
+ if array.ndim > 2:
171
+ array = np.ma.squeeze(array, axis=0)
172
+ costs = np.asarray(array.filled(np.nan), dtype="float64")
173
+ walkable = np.isfinite(costs) & (costs > 0.0)
174
+ if nodata is not None:
175
+ walkable &= costs != float(nodata)
176
+ return costs, walkable
177
+
178
+
179
+ def _prepare_corridor_cost_surface(cost_arr, meta: dict) -> np.ndarray:
180
+ arr = np.ma.asarray(cost_arr)
181
+ if arr.ndim > 2:
182
+ arr = np.ma.squeeze(arr, axis=0)
183
+ cost = np.asarray(arr.filled(np.inf), dtype="float64")
184
+ nodata = meta.get("nodata")
185
+ if nodata is not None:
186
+ cost[cost == float(nodata)] = np.inf
187
+ cost[~np.isfinite(cost)] = np.inf
188
+ cost[cost <= 0.0] = np.inf
189
+ return cost
190
+
191
+
192
+ def _astar_mcp_geometric_accumulation(
193
+ cost: np.ndarray,
194
+ source: tuple[int, int],
195
+ destination: tuple[int, int],
196
+ *,
197
+ sampling: tuple[float, float],
198
+ line_bias_weight: float,
199
+ ) -> AStarAccumulation:
200
+ if not np.isfinite(cost[source]) or not np.isfinite(cost[destination]):
201
+ raise RuntimeError("A* source or destination is not traversable")
202
+
203
+ rows, cols = cost.shape
204
+ walkable = np.isfinite(cost) & (cost > 0.0)
205
+ min_cost = float(cost[walkable].min()) if np.any(walkable) else 0.0
206
+ g_scores = np.full(cost.shape, math.inf, dtype="float64")
207
+ tie_scores = np.full(cost.shape, math.inf, dtype="float64")
208
+ came_from: dict[tuple[int, int], tuple[int, int] | None] = {source: None}
209
+ closed_nodes: set[tuple[int, int]] = set()
210
+ closed = np.zeros(cost.shape, dtype=bool)
211
+ sequence = count()
212
+ heap: list[tuple[float, float, float, int, tuple[int, int]]] = []
213
+
214
+ g_scores[source] = 0.0
215
+ tie_scores[source] = 0.0
216
+ destination_path = None
217
+ destination_cost = None
218
+ heapq.heappush(
219
+ heap,
220
+ _corridor_queue_entry(
221
+ source,
222
+ destination,
223
+ source,
224
+ 0.0,
225
+ 0.0,
226
+ min_cost,
227
+ sampling,
228
+ line_bias_weight,
229
+ next(sequence),
230
+ ),
231
+ )
232
+
233
+ while heap:
234
+ *_, current = heapq.heappop(heap)
235
+ if current in closed_nodes:
236
+ continue
237
+ if current == destination and destination_path is None:
238
+ destination_path = _reconstruct_path(came_from, destination)
239
+ destination_cost = float(g_scores[destination])
240
+ closed_nodes.add(current)
241
+ closed[current] = True
242
+
243
+ for neighbor in _neighbors(current, rows, cols, walkable):
244
+ if neighbor in closed_nodes:
245
+ continue
246
+ new_g = g_scores[current] + _geometric_edge_cost(cost, current, neighbor, sampling)
247
+ new_tie = _line_bias_score(neighbor, source, destination)
248
+ improved = new_g < g_scores[neighbor] - 1e-9
249
+ tied_better = abs(new_g - g_scores[neighbor]) <= 1e-9 and new_tie < tie_scores[neighbor]
250
+ if not improved and not tied_better:
251
+ continue
252
+ g_scores[neighbor] = new_g
253
+ tie_scores[neighbor] = new_tie
254
+ came_from[neighbor] = current
255
+ heapq.heappush(
256
+ heap,
257
+ _corridor_queue_entry(
258
+ neighbor,
259
+ destination,
260
+ source,
261
+ new_g,
262
+ new_tie,
263
+ min_cost,
264
+ sampling,
265
+ line_bias_weight,
266
+ next(sequence),
267
+ ),
268
+ )
269
+
270
+ if destination_path is None or destination_cost is None:
271
+ raise RuntimeError("A* path did not reach destination")
272
+
273
+ return AStarAccumulation(
274
+ path=destination_path,
275
+ best_cost=destination_cost,
276
+ g_scores=g_scores,
277
+ closed=closed,
278
+ )
279
+
280
+
281
+ def _neighbors(node: tuple[int, int], rows: int, cols: int, walkable: np.ndarray) -> Iterable[tuple[int, int]]:
282
+ row, col = node
283
+ for d_row, d_col in NEIGHBORS:
284
+ neighbor = row + d_row, col + d_col
285
+ if 0 <= neighbor[0] < rows and 0 <= neighbor[1] < cols and walkable[neighbor]:
286
+ yield neighbor
287
+
288
+
289
+ def _lcp_edge_cost(costs: np.ndarray, current: tuple[int, int], neighbor: tuple[int, int]) -> float:
290
+ base = (float(costs[current]) + float(costs[neighbor])) / 2.0
291
+ return base * (SQRT2 if current[0] != neighbor[0] and current[1] != neighbor[1] else 1.0)
292
+
293
+
294
+ def _geometric_edge_cost(
295
+ cost: np.ndarray,
296
+ current: tuple[int, int],
297
+ neighbor: tuple[int, int],
298
+ sampling: tuple[float, float],
299
+ ) -> float:
300
+ d_row = neighbor[0] - current[0]
301
+ d_col = neighbor[1] - current[1]
302
+ offset_length = math.hypot(d_row * sampling[0], d_col * sampling[1])
303
+ return offset_length * 0.5 * (float(cost[current]) + float(cost[neighbor]))
304
+
305
+
306
+ def _lcp_queue_entry(
307
+ node: tuple[int, int],
308
+ end: tuple[int, int],
309
+ start: tuple[int, int],
310
+ g_score: float,
311
+ tie_score: float,
312
+ min_cost: float,
313
+ sequence: int,
314
+ ) -> tuple:
315
+ heuristic = _octile(node, end) * min_cost
316
+ priority = g_score + heuristic + tie_score * ASTAR_LINE_BIAS
317
+ return priority, g_score + heuristic, tie_score, heuristic, sequence, node
318
+
319
+
320
+ def _corridor_queue_entry(
321
+ node: tuple[int, int],
322
+ destination: tuple[int, int],
323
+ source: tuple[int, int],
324
+ g_score: float,
325
+ tie_score: float,
326
+ min_cost: float,
327
+ sampling: tuple[float, float],
328
+ line_bias_weight: float,
329
+ sequence: int,
330
+ ) -> tuple[float, float, float, int, tuple[int, int]]:
331
+ heuristic = _euclidean_grid_distance(node, destination, sampling) * min_cost
332
+ priority = g_score + heuristic + tie_score * line_bias_weight
333
+ return priority, heuristic, _line_bias_score(node, source, destination), sequence, node
334
+
335
+
336
+ def _octile(a: tuple[int, int], b: tuple[int, int]) -> float:
337
+ d_row = abs(a[0] - b[0])
338
+ d_col = abs(a[1] - b[1])
339
+ return d_row + d_col + (SQRT2 - 2.0) * min(d_row, d_col)
340
+
341
+
342
+ def _euclidean_grid_distance(a: tuple[int, int], b: tuple[int, int], sampling: tuple[float, float]) -> float:
343
+ return math.hypot((a[0] - b[0]) * sampling[0], (a[1] - b[1]) * sampling[1])
344
+
345
+
346
+ def _line_bias_score(node: tuple[int, int], start: tuple[int, int], end: tuple[int, int]) -> float:
347
+ dx1 = node[1] - end[1]
348
+ dy1 = node[0] - end[0]
349
+ dx2 = start[1] - end[1]
350
+ dy2 = start[0] - end[0]
351
+ return abs(dx1 * dy2 - dx2 * dy1) / max(1.0, math.hypot(dx2, dy2))
352
+
353
+
354
+ def _reconstruct_path(
355
+ came_from: dict[tuple[int, int], tuple[int, int] | None], end: tuple[int, int]
356
+ ) -> list[tuple[int, int]]:
357
+ path = [end]
358
+ current = end
359
+ while came_from[current] is not None:
360
+ current = came_from[current]
361
+ path.append(current)
362
+ path.reverse()
363
+ return path
364
+
365
+
366
+ def _path_to_linestring(path: list[tuple[int, int]], transformer, start_xy, end_xy) -> LineString | None:
367
+ if len(path) < 2:
368
+ return None
369
+ points = [transformer.xy(row, col) for row, col in path]
370
+ points[0] = (start_xy[0], start_xy[1])
371
+ points[-1] = (end_xy[0], end_xy[1])
372
+ return LineString(points)
373
+
374
+
375
+ def _distance_raster_to_line(line: LineString, meta: dict, shape: tuple[int, int]) -> np.ndarray:
376
+ transform = meta["transform"]
377
+ cell_size = max(abs(float(transform.a)), abs(float(transform.e)))
378
+ path_mask = geometry_mask(
379
+ [line.buffer(cell_size * 0.5)],
380
+ out_shape=shape,
381
+ transform=transform,
382
+ invert=True,
383
+ all_touched=True,
384
+ )
385
+ return ndimage.distance_transform_edt(~path_mask, sampling=_raster_sampling(transform))
386
+
387
+
388
+ def _raster_sampling(transform) -> tuple[float, float]:
389
+ return (abs(float(transform.e)), abs(float(transform.a)))
390
+
391
+
392
+ def _cell_area(transform) -> float:
393
+ return abs(float(transform.a) * float(transform.e))
394
+
395
+
396
+ def _clamp_row_col(row_col: tuple[int, int], rows: int, cols: int) -> tuple[int, int]:
397
+ return max(0, min(int(row_col[0]), rows - 1)), max(0, min(int(row_col[1]), cols - 1))