geo-adjacency 1.0.7__tar.gz → 1.1.2__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.
- {geo_adjacency-1.0.7 → geo_adjacency-1.1.2}/PKG-INFO +13 -3
- geo_adjacency-1.1.2/geo_adjacency/adjacency.py +544 -0
- {geo_adjacency-1.0.7 → geo_adjacency-1.1.2}/geo_adjacency/exception.py +2 -0
- geo_adjacency-1.1.2/geo_adjacency/utils.py +137 -0
- geo_adjacency-1.1.2/pyproject.toml +47 -0
- geo_adjacency-1.0.7/geo_adjacency/adjacency.py +0 -362
- geo_adjacency-1.0.7/geo_adjacency/file.log +0 -10
- geo_adjacency-1.0.7/geo_adjacency/utils.py +0 -49
- geo_adjacency-1.0.7/pyproject.toml +0 -25
- {geo_adjacency-1.0.7 → geo_adjacency-1.1.2}/LICENSE +0 -0
- {geo_adjacency-1.0.7 → geo_adjacency-1.1.2}/README.md +0 -0
- {geo_adjacency-1.0.7 → geo_adjacency-1.1.2}/geo_adjacency/__init__.py +0 -0
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: geo-adjacency
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.1.2
|
|
4
|
+
Summary: A package to determine which geometries are adjacent to each other, accounting for obstacles and gaps between features.
|
|
5
|
+
Home-page: https://asmyth01.github.io/geo-adjacency/
|
|
5
6
|
License: MIT
|
|
7
|
+
Keywords: voronoi,adjacency,geospatial,geometry
|
|
6
8
|
Author: Andrew Smyth
|
|
7
|
-
Author-email:
|
|
9
|
+
Author-email: andrew.j.smyth.89@gmail.com
|
|
8
10
|
Requires-Python: >=3.9,<3.13
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
10
16
|
Classifier: Programming Language :: Python :: 3
|
|
11
17
|
Classifier: Programming Language :: Python :: 3.9
|
|
12
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
15
23
|
Requires-Dist: matplotlib (>=3.8.1,<4.0.0)
|
|
16
24
|
Requires-Dist: numpy (>=1.26.2,<2.0.0)
|
|
17
25
|
Requires-Dist: scipy (>=1.11.3,<2.0.0)
|
|
18
26
|
Requires-Dist: setuptools (>=69.0.0,<70.0.0)
|
|
19
27
|
Requires-Dist: shapely (>=2.0.2,<3.0.0)
|
|
28
|
+
Project-URL: Documentation, https://asmyth01.github.io/geo-adjacency/
|
|
29
|
+
Project-URL: Repository, https://github.com/andrewsmyth/geo-adjacency
|
|
20
30
|
Description-Content-Type: text/markdown
|
|
21
31
|
|
|
22
32
|
# Geo-Adjacency
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `adjacency` module implements the AdjacencyEngine class,
|
|
3
|
+
which allows us to calculate adjacency relationships. Adjacency relationships are between a set of source geometries,
|
|
4
|
+
or between source geometries and a second set of target geometries. Obstacle geometries can be passed in to
|
|
5
|
+
stand between sources or sources and targets, but they are not included in the output.
|
|
6
|
+
|
|
7
|
+
For example, if we wanted to know what trees in a forest are adjacent to the shore of a lake, we could
|
|
8
|
+
pass in a set of Point geometries to the trees, a Polygon to represent the lake, and a LineString to represent
|
|
9
|
+
a road passing between some of the trees and the shore.
|
|
10
|
+
|
|
11
|
+
`AdjacencyEngine` utilizes a Voronoi diagram of all the vertices in all the geometries combined to determine
|
|
12
|
+
which geometries are adjacent to each other. See
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from typing import List, Union, Dict, Tuple, Generator
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from typing_extensions import Self # Python < 3.11
|
|
21
|
+
except ImportError:
|
|
22
|
+
from typing import Self # Python >= 3.11
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
import matplotlib.pyplot as plt
|
|
26
|
+
import numpy as np
|
|
27
|
+
import shapely.ops
|
|
28
|
+
from scipy.spatial import distance
|
|
29
|
+
from scipy.spatial import Voronoi
|
|
30
|
+
from shapely import LineString, Point, Polygon, MultiPolygon
|
|
31
|
+
from shapely.geometry.base import BaseGeometry
|
|
32
|
+
|
|
33
|
+
from geo_adjacency.exception import ImmutablePropertyError
|
|
34
|
+
from geo_adjacency.utils import (
|
|
35
|
+
add_geometry_to_plot,
|
|
36
|
+
coords_from_point,
|
|
37
|
+
coords_from_ring,
|
|
38
|
+
coords_from_polygon,
|
|
39
|
+
coords_from_multipolygon,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ToDo: Support geometries with Z-coordinates
|
|
43
|
+
# Create a custom logger
|
|
44
|
+
log: logging.Logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Create handlers
|
|
47
|
+
c_handler: logging.StreamHandler = logging.StreamHandler()
|
|
48
|
+
c_handler.setLevel(logging.WARNING)
|
|
49
|
+
|
|
50
|
+
# Create formatters and add it to handlers
|
|
51
|
+
c_format: logging.Formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
|
|
52
|
+
f_format: logging.Formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
53
|
+
c_handler.setFormatter(c_format)
|
|
54
|
+
|
|
55
|
+
# Add handlers to the logger
|
|
56
|
+
log.addHandler(c_handler)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Feature:
|
|
60
|
+
"""
|
|
61
|
+
A _Feature is a wrapper around a Shapely geometry that allows us to easily determine if two
|
|
62
|
+
geometries are adjacent.
|
|
63
|
+
"""
|
|
64
|
+
__slots__ = ("_geometry", "_coords", "voronoi_points")
|
|
65
|
+
|
|
66
|
+
def __init__(self, geometry: BaseGeometry):
|
|
67
|
+
"""
|
|
68
|
+
Create a _Feature from a Shapely geometry.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
geometry (BaseGeometry): A valid Shapely Geometry, either a Point, LineString, Polygon, or
|
|
72
|
+
MultiPolygon.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if not isinstance(geometry, (Point, Polygon, MultiPolygon, LineString)):
|
|
76
|
+
raise TypeError(
|
|
77
|
+
"Cannot create _Feature for geometry type '%s'." % type(geometry)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert geometry.is_valid, "Could not process invalid geometry: %s" % geometry.wkt
|
|
81
|
+
|
|
82
|
+
self._geometry: BaseGeometry = geometry
|
|
83
|
+
self._coords: Union[List[Tuple[float, float]], None] = None
|
|
84
|
+
self.voronoi_points: set = set()
|
|
85
|
+
|
|
86
|
+
def __str__(self):
|
|
87
|
+
return str(self.geometry)
|
|
88
|
+
|
|
89
|
+
def __repr__(self):
|
|
90
|
+
return f"<_Feature: {str(self.geometry)}>"
|
|
91
|
+
|
|
92
|
+
def _is_adjacent(
|
|
93
|
+
self, other: Self, min_overlapping_voronoi_vertices: int = 2
|
|
94
|
+
) -> bool:
|
|
95
|
+
"""
|
|
96
|
+
Determine if two features are adjacent based on how many Voronoi vertices they share. Note:
|
|
97
|
+
the Voronoi analysis must have been run, or this will always return False.
|
|
98
|
+
Args:
|
|
99
|
+
other (_Feature): Another _Feature to compare to.
|
|
100
|
+
min_overlapping_voronoi_vertices (int): The minimum number of Voronoi vertices that
|
|
101
|
+
must be shared to be considered adjacent.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
bool: True if the two features are adjacent.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
assert isinstance(other, type(self)), "Cannot compare '%s' with '%s'." % (
|
|
108
|
+
type(self),
|
|
109
|
+
type(other),
|
|
110
|
+
)
|
|
111
|
+
if len(self.voronoi_points) == 0 and len(other.voronoi_points) == 0:
|
|
112
|
+
log.warning(
|
|
113
|
+
"No Voronoi vertices found for either feature. Did you run the analysis yet?"
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
return (
|
|
117
|
+
len(self.voronoi_points & other.voronoi_points)
|
|
118
|
+
>= min_overlapping_voronoi_vertices
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def geometry(self):
|
|
123
|
+
"""
|
|
124
|
+
Access the Shapely geometry of the feature.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
BaseGeometry: The Shapely geometry of the feature.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
return self._geometry
|
|
131
|
+
|
|
132
|
+
@geometry.setter
|
|
133
|
+
def geometry(self, geometry):
|
|
134
|
+
self._geometry = geometry
|
|
135
|
+
self._coords = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def coords(self) -> List[Tuple[float, float]]:
|
|
139
|
+
"""
|
|
140
|
+
Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List[Tuple[float, float]]: A list of coordinate tuples.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if not self._coords:
|
|
148
|
+
if isinstance(self.geometry, Point):
|
|
149
|
+
self._coords = coords_from_point(self.geometry)
|
|
150
|
+
elif isinstance(self.geometry, LineString):
|
|
151
|
+
self._coords = coords_from_ring(self.geometry)
|
|
152
|
+
elif isinstance(self.geometry, Polygon):
|
|
153
|
+
self._coords = coords_from_polygon(self.geometry)
|
|
154
|
+
elif isinstance(self.geometry, MultiPolygon):
|
|
155
|
+
self._coords = coords_from_multipolygon(self.geometry)
|
|
156
|
+
else:
|
|
157
|
+
raise TypeError(f"Unknown geometry type '{type(self.geometry)}'")
|
|
158
|
+
return self._coords
|
|
159
|
+
|
|
160
|
+
@coords.setter
|
|
161
|
+
def coords(self, coords):
|
|
162
|
+
raise ImmutablePropertyError("Property coords is immutable.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AdjacencyEngine:
|
|
166
|
+
"""
|
|
167
|
+
A class for calculating the adjacency of a set of geometries to another geometry or set
|
|
168
|
+
of geometries, given a set of obstacles, within a given radius.
|
|
169
|
+
|
|
170
|
+
First, the Voronoi diagram is generated for each geometry and obstacle. Then, we check which
|
|
171
|
+
voronoi shapes intersect one another. If they do, then the two underlying geometries are
|
|
172
|
+
adjacent.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
__slots__ = (
|
|
176
|
+
"_source_features",
|
|
177
|
+
"_target_features",
|
|
178
|
+
"_obstacle_features",
|
|
179
|
+
"_adjacency_dict",
|
|
180
|
+
"_feature_indices",
|
|
181
|
+
"_vor",
|
|
182
|
+
"_all_features",
|
|
183
|
+
"_all_coordinates",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
source_geoms: List[BaseGeometry],
|
|
189
|
+
target_geoms: Union[List[BaseGeometry], None] = None,
|
|
190
|
+
obstacle_geoms: Union[List[BaseGeometry], None] = None,
|
|
191
|
+
densify_features: bool = False,
|
|
192
|
+
max_segment_length: Union[float, None] = None,
|
|
193
|
+
):
|
|
194
|
+
"""
|
|
195
|
+
Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
|
|
196
|
+
features are in the same projection.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
source_geoms (List[BaseGeometry]): List of Shapely geometries. We will which ones are adjacent to
|
|
200
|
+
which others, unless target_geoms is specified.
|
|
201
|
+
target_geoms (Union[List[BaseGeometry], None]), optional): list of Shapley geometries. if not None, We will
|
|
202
|
+
test if these features are adjacent to the source features.
|
|
203
|
+
obstacle_geoms (Union[List[BaseGeometry], None]), optional): List
|
|
204
|
+
of Shapely geometries. These features will not be tested for adjacency, but they can
|
|
205
|
+
prevent a source and target feature from being adjacent.
|
|
206
|
+
densify_features (bool, optional): If
|
|
207
|
+
True, we will add additional points to the features to improve accuracy of the voronoi
|
|
208
|
+
diagram. If densify_features is True and max_segment_length is false, then the max_segment_length
|
|
209
|
+
will be calculated based on the average segment length of all features, divided by 5.
|
|
210
|
+
max_segment_length (Union[float, None], optional): The maximum distance between vertices that we want.
|
|
211
|
+
In projection units. densify_features must be True, or an error will be thrown.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
if max_segment_length and not densify_features:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"interpolate_points must be True if interpolation_distance is not None"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._source_features: Tuple[_Feature] = tuple(
|
|
220
|
+
[_Feature(geom) for geom in source_geoms]
|
|
221
|
+
)
|
|
222
|
+
self._target_features: Tuple[_Feature] = (
|
|
223
|
+
tuple([_Feature(geom) for geom in target_geoms])
|
|
224
|
+
if target_geoms
|
|
225
|
+
else tuple()
|
|
226
|
+
)
|
|
227
|
+
self._obstacle_features: Union[Tuple[_Feature], None] = (
|
|
228
|
+
tuple([_Feature(geom) for geom in obstacle_geoms])
|
|
229
|
+
if obstacle_geoms
|
|
230
|
+
else tuple()
|
|
231
|
+
)
|
|
232
|
+
self._adjacency_dict: Union[Dict[int, List[int]], None] = None
|
|
233
|
+
self._feature_indices: Union[Dict[int, int], None] = None
|
|
234
|
+
self._vor = None
|
|
235
|
+
self._all_coordinates = None
|
|
236
|
+
|
|
237
|
+
"""All source, target, and obstacle features in a single list. The order of this list must
|
|
238
|
+
not be changed."""
|
|
239
|
+
self._all_features: Tuple[_Feature, ...] = tuple(
|
|
240
|
+
[*self.source_features, *self.target_features, *self.obstacle_features]
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if densify_features:
|
|
244
|
+
if max_segment_length is None:
|
|
245
|
+
max_segment_length = self._calc_segmentation_dist()
|
|
246
|
+
log.info("Calculated max_segment_length of %s" % max_segment_length)
|
|
247
|
+
|
|
248
|
+
for feature in self.all_features:
|
|
249
|
+
if not isinstance(feature.geometry, Point):
|
|
250
|
+
feature.geometry = feature.geometry.segmentize(max_segment_length)
|
|
251
|
+
# Reset all coordinates
|
|
252
|
+
self._all_coordinates = None
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def all_features(self):
|
|
256
|
+
"""
|
|
257
|
+
All source, target, and obstacle features in a single list. The order of this list must
|
|
258
|
+
not be changed. This property cannot be set manually.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List[_Feature]: A list of _Features.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
return self._all_features
|
|
265
|
+
|
|
266
|
+
@all_features.setter
|
|
267
|
+
def all_features(self, value):
|
|
268
|
+
raise ImmutablePropertyError("Property all_features is immutable.")
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def all_coordinates(self):
|
|
272
|
+
"""
|
|
273
|
+
All source, target, and obstacle coordinates in a single list. The order of this list must
|
|
274
|
+
not be changed. This property cannot be set manually.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List[tuple[float, float]]: A list of coordinate tuples.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
if not self._all_coordinates:
|
|
281
|
+
self._all_coordinates = []
|
|
282
|
+
for feature in self.all_features:
|
|
283
|
+
self._all_coordinates.extend(feature.coords)
|
|
284
|
+
|
|
285
|
+
self._all_coordinates = tuple(self._all_coordinates)
|
|
286
|
+
return self._all_coordinates
|
|
287
|
+
|
|
288
|
+
@all_coordinates.setter
|
|
289
|
+
def all_coordinates(self, value):
|
|
290
|
+
raise ImmutablePropertyError("Property all_coordinates is immutable.")
|
|
291
|
+
|
|
292
|
+
def _calc_segmentation_dist(self, divisor=5):
|
|
293
|
+
"""
|
|
294
|
+
Try to create a well-fitting maximum length for all line segments in all features. Take
|
|
295
|
+
the average distance between all coordinate pairs and divide by 5. This means that the
|
|
296
|
+
average segment will be divided into five segments.
|
|
297
|
+
|
|
298
|
+
This won't work as well if the different geometry sets have significantly different
|
|
299
|
+
average segment lengths. In that case, it is advisable to prepare the data appropriately
|
|
300
|
+
beforehand.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
divisor (int, optional): Divide the average segment length by this number to get the new desired
|
|
304
|
+
segment length.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
float: Average segment length divided by divisor.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
return float(
|
|
311
|
+
(
|
|
312
|
+
sum(distance.pdist(self.all_coordinates, "euclidean"))
|
|
313
|
+
/ math.pow(len(self.all_coordinates), 2)
|
|
314
|
+
)
|
|
315
|
+
/ divisor
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def source_features(self) -> Tuple[_Feature]:
|
|
320
|
+
"""
|
|
321
|
+
Features which will be the keys in the adjacency_dict.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
List[_Feature]: A list of _Features.
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
return self._source_features
|
|
328
|
+
|
|
329
|
+
@source_features.setter
|
|
330
|
+
def source_features(self, features: Tuple[BaseGeometry]):
|
|
331
|
+
raise ImmutablePropertyError("Property source_features is immutable.")
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def target_features(self) -> Tuple[_Feature]:
|
|
335
|
+
"""
|
|
336
|
+
Features which will be the values in the adjacency_dict.
|
|
337
|
+
Returns:
|
|
338
|
+
List[_Feature]: A list of _Features.
|
|
339
|
+
"""
|
|
340
|
+
return self._target_features
|
|
341
|
+
|
|
342
|
+
@target_features.setter
|
|
343
|
+
def target_features(self, _):
|
|
344
|
+
raise ImmutablePropertyError("Property target_features is immutable.")
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def obstacle_features(self) -> Tuple[_Feature]:
|
|
348
|
+
"""
|
|
349
|
+
Features which can prevent source and target features from being adjacent. They
|
|
350
|
+
Do not participate in the adjacency_dict.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
List[_Feature]: A list of _Features.
|
|
354
|
+
"""
|
|
355
|
+
return self._obstacle_features
|
|
356
|
+
|
|
357
|
+
@obstacle_features.setter
|
|
358
|
+
def obstacle_features(self, _):
|
|
359
|
+
raise ImmutablePropertyError("Property obstacle_features is immutable.")
|
|
360
|
+
|
|
361
|
+
def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
|
|
362
|
+
"""
|
|
363
|
+
A list which is the length of self._all_coordinates. For each coordinate, we add the
|
|
364
|
+
index of the corresponding feature from the list self.all_features. This is used to
|
|
365
|
+
determine which coordinate belongs to which feature after we calculate the voronoi
|
|
366
|
+
diagram.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
coord_index (int): The index of the coordinate in self._all_coordinates
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
_Feature: A _Feature at the given index.
|
|
373
|
+
"""
|
|
374
|
+
if not self._feature_indices:
|
|
375
|
+
self._feature_indices = {}
|
|
376
|
+
c = -1
|
|
377
|
+
for f, feature in enumerate(self.all_features):
|
|
378
|
+
for _ in range(len(feature.coords)):
|
|
379
|
+
c += 1
|
|
380
|
+
self._feature_indices[c] = f
|
|
381
|
+
|
|
382
|
+
return self.all_features[self._feature_indices[coord_index]]
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def vor(self):
|
|
386
|
+
"""
|
|
387
|
+
The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
|
|
388
|
+
adjacency analysis.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
scipy.spatial.Voronoi: The Scipy Voronoi object.
|
|
392
|
+
"""
|
|
393
|
+
if not self._vor:
|
|
394
|
+
self._vor = Voronoi(np.array(self.all_coordinates))
|
|
395
|
+
return self._vor
|
|
396
|
+
|
|
397
|
+
@vor.setter
|
|
398
|
+
def vor(self, _):
|
|
399
|
+
raise ImmutablePropertyError("Property vor is immutable.")
|
|
400
|
+
|
|
401
|
+
def _get_voronoi_vertex_idx_for_coord_idx(self, feature_coord_index: int) -> Generator[int, None, None]:
|
|
402
|
+
"""
|
|
403
|
+
For a given feature coordinate index, return the indices of the voronoi vertices. Ignore
|
|
404
|
+
any "-1"s, which indicate vertices at infinity; these provide no adjacency information.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
feature_coord_index (int): The index of the coordinate in self.all_coordinates
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Generator[int, None, None]: A generator of the indices of the voronoi vertices.
|
|
411
|
+
"""
|
|
412
|
+
return (i for i in self.vor.regions[self.vor.point_region[feature_coord_index]] if i != -1)
|
|
413
|
+
|
|
414
|
+
def _tag_feature_with_voronoi_vertices(self):
|
|
415
|
+
"""
|
|
416
|
+
Tag each feature with the vertices of the voronoi region it belongs to. Runs the
|
|
417
|
+
voronoi analysis if it has not been done already. This is broken out mostly for testing.
|
|
418
|
+
Do not call this function directly.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
None
|
|
422
|
+
"""
|
|
423
|
+
# We don't need to tag obstacles with their voronoi vertices
|
|
424
|
+
obstacle_coord_len = sum(len(feat.coords) for feat in self.obstacle_features)
|
|
425
|
+
|
|
426
|
+
# Tag each feature with the vertices of the voronoi region it
|
|
427
|
+
# belongs to
|
|
428
|
+
for feature_coord_index in range(
|
|
429
|
+
len(self.all_coordinates) - obstacle_coord_len
|
|
430
|
+
):
|
|
431
|
+
feature = self.get_feature_from_coord_index(feature_coord_index)
|
|
432
|
+
for i in self._get_voronoi_vertex_idx_for_coord_idx(feature_coord_index):
|
|
433
|
+
feature.voronoi_points.add(i)
|
|
434
|
+
|
|
435
|
+
def _determine_adjacency(
|
|
436
|
+
self, source_set: Tuple[_Feature], target_set: Tuple[_Feature]
|
|
437
|
+
):
|
|
438
|
+
"""
|
|
439
|
+
Determines the adjacency relationship between two sets of features.
|
|
440
|
+
Args:
|
|
441
|
+
source_set (Tuple[_Feature]): The set of source features.
|
|
442
|
+
target_set (Tuple[_Feature]): The set of target features.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
None
|
|
446
|
+
"""
|
|
447
|
+
min_overlapping_voronoi_vertices = 2
|
|
448
|
+
for source_index, source_feature in enumerate(source_set):
|
|
449
|
+
for target_index, target_feature in enumerate(target_set):
|
|
450
|
+
if source_feature != target_feature and source_feature._is_adjacent(
|
|
451
|
+
target_feature, min_overlapping_voronoi_vertices
|
|
452
|
+
):
|
|
453
|
+
self._adjacency_dict[source_index].append(target_index)
|
|
454
|
+
|
|
455
|
+
def get_adjacency_dict(self) -> Dict[int, List[int]]:
|
|
456
|
+
"""
|
|
457
|
+
Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
|
|
458
|
+
are the indices of any target geometries which are adjacent to the feature_geoms.
|
|
459
|
+
|
|
460
|
+
If no targets were specified, then calculate adjacency between source features and other
|
|
461
|
+
source features.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
dict: A dictionary of indices. The keys are the indices of feature_geoms. The
|
|
465
|
+
values are the indices of any adjacent features.
|
|
466
|
+
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
"""Note: We want adjacent features to have at least two overlapping vertices, otherwise we
|
|
470
|
+
might call the features adjacent when their Voronoi regions don't share any edges."""
|
|
471
|
+
|
|
472
|
+
if self._adjacency_dict is None:
|
|
473
|
+
self._tag_feature_with_voronoi_vertices()
|
|
474
|
+
|
|
475
|
+
# If any two features have any voronoi indices in common, then their voronoi regions
|
|
476
|
+
# must intersect, therefore the input features are adjacent.
|
|
477
|
+
self._adjacency_dict = defaultdict(list)
|
|
478
|
+
|
|
479
|
+
# Get adjacency between source and target features
|
|
480
|
+
if len(self.target_features) > 0:
|
|
481
|
+
self._determine_adjacency(self.source_features, self.target_features)
|
|
482
|
+
# If no target specified, get adjacency between source and other source features.
|
|
483
|
+
else:
|
|
484
|
+
self._determine_adjacency(self.source_features, self.source_features)
|
|
485
|
+
|
|
486
|
+
return self._adjacency_dict
|
|
487
|
+
|
|
488
|
+
def plot_adjacency_dict(self) -> None:
|
|
489
|
+
"""
|
|
490
|
+
Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
|
|
491
|
+
it has not already been run.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
None
|
|
495
|
+
"""
|
|
496
|
+
# Plot the adjacency linkages between the source and target
|
|
497
|
+
if len(self.target_features) > 0:
|
|
498
|
+
for source_i, target_is in self.get_adjacency_dict().items():
|
|
499
|
+
source_poly = self.source_features[source_i].geometry
|
|
500
|
+
target_polys = [
|
|
501
|
+
self.target_features[target_i].geometry for target_i in target_is
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
# Plot the linestrings between the source and target polygons
|
|
505
|
+
links = []
|
|
506
|
+
for target_poly in target_polys:
|
|
507
|
+
if target_poly:
|
|
508
|
+
try:
|
|
509
|
+
links.append(
|
|
510
|
+
LineString(
|
|
511
|
+
shapely.ops.nearest_points(target_poly, source_poly)
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
except ValueError:
|
|
515
|
+
log.error(
|
|
516
|
+
f"Error creating link between '{target_poly}' and '{source_poly}'"
|
|
517
|
+
)
|
|
518
|
+
add_geometry_to_plot(links, "green")
|
|
519
|
+
|
|
520
|
+
else:
|
|
521
|
+
for source_i, source_2_is in self.get_adjacency_dict().items():
|
|
522
|
+
source_poly = self.source_features[source_i].geometry
|
|
523
|
+
target_polys = [
|
|
524
|
+
self.source_features[source_2_i].geometry
|
|
525
|
+
for source_2_i in source_2_is
|
|
526
|
+
if source_2_i > source_i
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
# Plot the linestrings between the source and target polygons
|
|
530
|
+
links = [
|
|
531
|
+
LineString([target_poly.centroid, source_poly.centroid])
|
|
532
|
+
for target_poly in target_polys
|
|
533
|
+
if target_poly is not None
|
|
534
|
+
]
|
|
535
|
+
add_geometry_to_plot(links, "green")
|
|
536
|
+
|
|
537
|
+
add_geometry_to_plot([t.geometry for t in self.target_features], "blue")
|
|
538
|
+
add_geometry_to_plot([t.geometry for t in self.source_features], "grey")
|
|
539
|
+
add_geometry_to_plot([t.geometry for t in self.obstacle_features], "red")
|
|
540
|
+
|
|
541
|
+
plt.title("Adjacency linkages between source and target")
|
|
542
|
+
plt.xlabel("Longitude")
|
|
543
|
+
plt.ylabel("Latitude")
|
|
544
|
+
plt.show()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions. These are designed for use in the AdjacencyEngine only, and should not be called
|
|
3
|
+
by end users.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
from matplotlib import pyplot as plt
|
|
9
|
+
from shapely import Point, LineString, Polygon, MultiPolygon
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def coords_from_point(point: Point) -> List[Tuple[float, float]]:
|
|
13
|
+
"""
|
|
14
|
+
Convert a Point into a tuple of (x, y). We put this inside a list for consistency with other
|
|
15
|
+
coordinate methods to allow us to seamlessly merge them later.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
point (Point): A Shapely Point.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
List[Tuple[float, float]]: A list of coordinate tuples.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
assert isinstance(point, Point), "Geometry must be a Point, not '%s'." % type(point)
|
|
25
|
+
return [(float(point.x), float(point.y))]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def coords_from_ring(ring: LineString) -> List[Tuple[float, float]]:
|
|
29
|
+
"""
|
|
30
|
+
Convert a LinearRing into a list of (x, y) tuples.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
ring (LineString): A Shapely LinearString.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List[Tuple[float, float]]: A list of coordinate tuples.
|
|
37
|
+
"""
|
|
38
|
+
assert isinstance(
|
|
39
|
+
ring, LineString
|
|
40
|
+
), "Geometry must be a LinearRing, not '%s'." % type(ring)
|
|
41
|
+
return [(float(coord[0]), float(coord[1])) for coord in ring.coords]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def coords_from_polygon(polygon: Polygon) -> List[Tuple[float, float]]:
|
|
45
|
+
"""
|
|
46
|
+
Convert a Polygon into a list of (x, y) tuples. Does not repeat the first coordinate to close
|
|
47
|
+
the ring.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
polygon (Polygon): A Shapely Polygon.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List[Tuple[float, float]]: A list of coordinate tuples.
|
|
54
|
+
"""
|
|
55
|
+
assert isinstance(polygon, Polygon), "Geometry must be a Polygon, not '%s'." % type(
|
|
56
|
+
polygon
|
|
57
|
+
)
|
|
58
|
+
coords = []
|
|
59
|
+
coords.extend(coords_from_ring(polygon.exterior)[:-1])
|
|
60
|
+
for ring in polygon.interiors:
|
|
61
|
+
coords.extend(coords_from_ring(ring)[:-1])
|
|
62
|
+
return coords
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def coords_from_multipolygon(multipolygon: MultiPolygon) -> List[Tuple[float, float]]:
|
|
66
|
+
"""
|
|
67
|
+
Convert a MultiPolygon into a list of (x, y) tuples. Does not repeat the first coordinate to
|
|
68
|
+
close the ring.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
multipolygon (MultiPolygon): A Shapely MultiPolygon.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List[Tuple[float, float]]: A list of coordinate tuples.
|
|
75
|
+
"""
|
|
76
|
+
assert isinstance(
|
|
77
|
+
multipolygon, MultiPolygon
|
|
78
|
+
), "Geometry must be a MultiPolygon, not '%s'." % type(multipolygon)
|
|
79
|
+
coords = []
|
|
80
|
+
for polygon in multipolygon.geoms:
|
|
81
|
+
coords.extend(coords_from_polygon(polygon))
|
|
82
|
+
return coords
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def flatten_list(nested_list) -> List:
|
|
86
|
+
"""
|
|
87
|
+
Flatten a list of lists.
|
|
88
|
+
Args:
|
|
89
|
+
nested_list (List): A list of lists.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
# check if list is empty
|
|
95
|
+
if not bool(nested_list):
|
|
96
|
+
return nested_list
|
|
97
|
+
|
|
98
|
+
# to check instance of list is empty or not
|
|
99
|
+
if isinstance(nested_list[0], list):
|
|
100
|
+
# call function with sublist as argument
|
|
101
|
+
return flatten_list(*nested_list[:1]) + flatten_list(nested_list[1:])
|
|
102
|
+
|
|
103
|
+
# Call function with sublist as argument
|
|
104
|
+
return nested_list[:1] + flatten_list(nested_list[1:])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_geometry_to_plot(geoms, color="black"):
|
|
108
|
+
"""
|
|
109
|
+
When updating the test data, it may be useful to visualize it. Add a geometry to the global
|
|
110
|
+
maplotlib plt object. The next time we call plt.show(), this geometry will be plotted.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
geoms (List[BaseGeometry]): A list of Shapely geometries.
|
|
114
|
+
color (str): The color we want the geometry to be in the plot.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
None
|
|
118
|
+
"""
|
|
119
|
+
for geom in geoms:
|
|
120
|
+
if isinstance(geom, Point):
|
|
121
|
+
plt.plot(
|
|
122
|
+
geom.x,
|
|
123
|
+
geom.y,
|
|
124
|
+
marker="o",
|
|
125
|
+
markersize=5,
|
|
126
|
+
markeredgecolor="black",
|
|
127
|
+
markerfacecolor=color,
|
|
128
|
+
)
|
|
129
|
+
elif isinstance(geom, LineString):
|
|
130
|
+
plt.plot(*geom.coords.xy, color=color)
|
|
131
|
+
elif isinstance(geom, Polygon):
|
|
132
|
+
plt.plot(*geom.exterior.xy, color=color, linestyle="-")
|
|
133
|
+
elif isinstance(geom, MultiPolygon):
|
|
134
|
+
for sub_poly in geom.geoms:
|
|
135
|
+
plt.plot(*sub_poly.exterior.xy, color=color, linewidth=3)
|
|
136
|
+
else:
|
|
137
|
+
raise TypeError("Unknown geometry type")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "geo-adjacency"
|
|
3
|
+
version = "1.1.2"
|
|
4
|
+
description = "A package to determine which geometries are adjacent to each other, accounting for obstacles and gaps between features."
|
|
5
|
+
authors = ["Andrew Smyth <andrew.j.smyth.89@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "geo_adjacency"}]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
repository = "https://github.com/andrewsmyth/geo-adjacency"
|
|
10
|
+
homepage = "https://asmyth01.github.io/geo-adjacency/"
|
|
11
|
+
documentation = "https://asmyth01.github.io/geo-adjacency/"
|
|
12
|
+
keywords = ["voronoi", "adjacency", "geospatial", "geometry"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering",
|
|
25
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.poetry.dependencies]
|
|
29
|
+
python = "<3.13,>=3.9"
|
|
30
|
+
scipy = "^1.11.3"
|
|
31
|
+
shapely = "^2.0.2"
|
|
32
|
+
numpy = "^1.26.2"
|
|
33
|
+
matplotlib = "^3.8.1"
|
|
34
|
+
setuptools = "^69.0.0"
|
|
35
|
+
|
|
36
|
+
[tool.poetry.dev-dependencies]
|
|
37
|
+
pytest = "^7.4.3"
|
|
38
|
+
pylint = "^3.0.2"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
[tool.poetry.group.dev.dependencies]
|
|
42
|
+
pytest = "^7.4.3"
|
|
43
|
+
pytest-cov = "^4.1.0"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["poetry-core"]
|
|
47
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
The adjacency module is a part of the geo-adjacency package, which provides functionality for
|
|
3
|
-
calculating and analyzing adjacency between geometries. It implements the AdjacencyEngine class,
|
|
4
|
-
which allows users to determine adjacency relationships between a set of source and target
|
|
5
|
-
geometries, taking into account obstacles and a specified radius. The module utilizes the Voronoi
|
|
6
|
-
diagram to generate adjacency linkages and provides methods for plotting the adjacency linkages on
|
|
7
|
-
a map. The module also handles cases where there are gaps between features, ensuring accurate
|
|
8
|
-
adjacency analysis. Overall, the adjacency module enables users to answer questions about spatial
|
|
9
|
-
adjacency, providing valuable insights for geospatial analysis and decision-making processes.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import math
|
|
13
|
-
import os
|
|
14
|
-
import time
|
|
15
|
-
from collections import defaultdict
|
|
16
|
-
from typing import List, Union, Dict, Tuple
|
|
17
|
-
import logging
|
|
18
|
-
|
|
19
|
-
from scipy.spatial import distance, voronoi_plot_2d
|
|
20
|
-
from scipy.spatial import Voronoi
|
|
21
|
-
from shapely import Polygon, MultiPolygon, LineString, Point
|
|
22
|
-
from shapely.geometry.base import BaseGeometry
|
|
23
|
-
from shapely.ops import nearest_points
|
|
24
|
-
from shapely.wkt import loads
|
|
25
|
-
from shapely.geometry import mapping
|
|
26
|
-
import numpy as np
|
|
27
|
-
import matplotlib.pyplot as plt
|
|
28
|
-
|
|
29
|
-
from geo_adjacency.exception import ImmutablePropertyError
|
|
30
|
-
from geo_adjacency.utils import flatten_list, add_geometry_to_plot
|
|
31
|
-
|
|
32
|
-
# Create a custom logger
|
|
33
|
-
logger = logging.getLogger(__name__)
|
|
34
|
-
|
|
35
|
-
# Create handlers
|
|
36
|
-
c_handler = logging.StreamHandler()
|
|
37
|
-
f_handler = logging.FileHandler("file.log")
|
|
38
|
-
c_handler.setLevel(logging.WARNING)
|
|
39
|
-
f_handler.setLevel(logging.ERROR)
|
|
40
|
-
|
|
41
|
-
# Create formatters and add it to handlers
|
|
42
|
-
c_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
|
|
43
|
-
f_format = logging.Formatter(
|
|
44
|
-
"%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
45
|
-
c_handler.setFormatter(c_format)
|
|
46
|
-
f_handler.setFormatter(f_format)
|
|
47
|
-
|
|
48
|
-
# Add handlers to the logger
|
|
49
|
-
logger.addHandler(c_handler)
|
|
50
|
-
logger.addHandler(f_handler)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class _Feature:
|
|
54
|
-
__slots__ = ("_geometry", "_coords", "voronoi_points")
|
|
55
|
-
|
|
56
|
-
def __init__(self, geometry: BaseGeometry):
|
|
57
|
-
self._geometry: BaseGeometry = geometry
|
|
58
|
-
self._coords = None
|
|
59
|
-
self.voronoi_points: set = set()
|
|
60
|
-
|
|
61
|
-
def __str__(self):
|
|
62
|
-
return str(self.geometry)
|
|
63
|
-
|
|
64
|
-
def __repr__(self):
|
|
65
|
-
return f"<_Feature: {str(self.geometry)}>"
|
|
66
|
-
|
|
67
|
-
@property
|
|
68
|
-
def geometry(self):
|
|
69
|
-
"""
|
|
70
|
-
Access the Shapely geometry of the feature.
|
|
71
|
-
:return: BaseGeometry
|
|
72
|
-
"""
|
|
73
|
-
return self._geometry
|
|
74
|
-
|
|
75
|
-
@geometry.setter
|
|
76
|
-
def geometry(self, geometry):
|
|
77
|
-
self._geometry = geometry
|
|
78
|
-
self._coords = None
|
|
79
|
-
|
|
80
|
-
@property
|
|
81
|
-
def coords(self) -> List[Tuple[float, float]]:
|
|
82
|
-
"""
|
|
83
|
-
Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
|
|
84
|
-
|
|
85
|
-
:return: List[Tuple[float, float]]: A list of coordinate tuples.
|
|
86
|
-
"""
|
|
87
|
-
if not self._coords:
|
|
88
|
-
if isinstance(self.geometry, Point):
|
|
89
|
-
self._coords = tuple([(self.geometry.x, self.geometry.y)])
|
|
90
|
-
elif isinstance(self.geometry, Polygon):
|
|
91
|
-
self._coords = tuple(mapping(self.geometry)["coordinates"][0])
|
|
92
|
-
elif isinstance(self.geometry, MultiPolygon):
|
|
93
|
-
self._coords = tuple(flatten_list(
|
|
94
|
-
mapping(self.geometry)["coordinates"][0]))
|
|
95
|
-
elif isinstance(self.geometry, LineString):
|
|
96
|
-
self._coords = tuple((x, y) for x, y in self.geometry.coords)
|
|
97
|
-
else:
|
|
98
|
-
raise TypeError(
|
|
99
|
-
f"Unknown geometry type '{type(self.geometry)}'")
|
|
100
|
-
return self._coords
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class AdjacencyEngine:
|
|
104
|
-
"""
|
|
105
|
-
A class for calculating the adjacency of a set of geometries to another geometry or set
|
|
106
|
-
of geometries, given a set of obstacles, within a given radius.
|
|
107
|
-
|
|
108
|
-
First, the Voronoi diagram is generated for each geometry and obstacle. Then, we check which
|
|
109
|
-
voronoi shapes intersect one another. If they do, then the two underlying geometries are
|
|
110
|
-
adjacent.
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
__slots__ = ("_source_features", "_target_features", "_obstacle_features", "_adjacency_dict",
|
|
114
|
-
"_feature_indices", "_vor", "all_features", "all_coordinates")
|
|
115
|
-
|
|
116
|
-
def __init__(
|
|
117
|
-
self,
|
|
118
|
-
source_geoms: List[BaseGeometry],
|
|
119
|
-
target_geoms: List[BaseGeometry],
|
|
120
|
-
obstacle_geoms: Union[List[BaseGeometry], None] = None,
|
|
121
|
-
densify_features: bool = False,
|
|
122
|
-
max_segment_length: Union[float, None] = None,
|
|
123
|
-
):
|
|
124
|
-
"""
|
|
125
|
-
Note: only Multipolygons, Polygons, and LineStrings are supported. It is assumed all
|
|
126
|
-
features are in the same projection.
|
|
127
|
-
|
|
128
|
-
:param source_geoms: List of Shapely geometries. We will test if these features are
|
|
129
|
-
adjacent to the target features.
|
|
130
|
-
:param target_geoms: List of Shapley geometries. We will
|
|
131
|
-
test if these features are adjacent to the source features.
|
|
132
|
-
:param obstacle_geoms: List
|
|
133
|
-
of Shapely geometries. These features will not be tested for adjacency, but they can
|
|
134
|
-
prevent a source and target feature from being adjacent.
|
|
135
|
-
:param densify_features: If
|
|
136
|
-
True, we will add additional points to the features to improve accuracy of the voronoi
|
|
137
|
-
diagram.
|
|
138
|
-
:param max_segment_length: The maximum distance between vertices that we want.
|
|
139
|
-
In projection units. densify_features must be True, or an error will be thrown. If
|
|
140
|
-
densify_features is True and max_segment_length is false, then the max_segment_length
|
|
141
|
-
will be calculated based on the average segment length of all features, divided by 5.
|
|
142
|
-
This often works well.
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
if max_segment_length and not densify_features:
|
|
146
|
-
raise ValueError(
|
|
147
|
-
"interpolate_points must be True if interpolation_distance is not None"
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
self._source_features: Tuple[_Feature] = tuple([
|
|
151
|
-
_Feature(geom) for geom in source_geoms
|
|
152
|
-
])
|
|
153
|
-
self._target_features: Tuple[_Feature] = tuple([
|
|
154
|
-
_Feature(geom) for geom in target_geoms
|
|
155
|
-
])
|
|
156
|
-
self._obstacle_features: Union[Tuple[_Feature], None] = tuple([
|
|
157
|
-
_Feature(geom) for geom in obstacle_geoms
|
|
158
|
-
])
|
|
159
|
-
self._adjacency_dict = None
|
|
160
|
-
self._feature_indices = None
|
|
161
|
-
self._vor = None
|
|
162
|
-
|
|
163
|
-
"""All source, target, and obstacle features in a single list. The order of this list must
|
|
164
|
-
not be changed."""
|
|
165
|
-
self.all_features: Tuple[_Feature, ...] = tuple([*self.source_features, *self.target_features,
|
|
166
|
-
*self.obstacle_features])
|
|
167
|
-
|
|
168
|
-
if densify_features:
|
|
169
|
-
if max_segment_length is None:
|
|
170
|
-
max_segment_length = self.calc_segmentation_dist()
|
|
171
|
-
logger.info("Calculated max_segment_length of %s" % max_segment_length)
|
|
172
|
-
|
|
173
|
-
for feature in self.all_features:
|
|
174
|
-
feature.geometry = feature.geometry.segmentize(
|
|
175
|
-
max_segment_length)
|
|
176
|
-
|
|
177
|
-
self.all_coordinates: Tuple[Tuple[float, float]] = tuple(flatten_list(
|
|
178
|
-
[feature.coords for feature in self.all_features]
|
|
179
|
-
))
|
|
180
|
-
|
|
181
|
-
def calc_segmentation_dist(self, divisor=5):
|
|
182
|
-
"""
|
|
183
|
-
Try to create a well-fitting maximum length for all line segments in all features. Take
|
|
184
|
-
the average distance between all coordinate pairs and divide by 5. This means that the
|
|
185
|
-
average segment will be divided into five segments.
|
|
186
|
-
|
|
187
|
-
This won't work as well if the different geometry sets have significantly different
|
|
188
|
-
average segment lengths. In that case, it is advisable to prepare the data appropriately
|
|
189
|
-
beforehand.
|
|
190
|
-
|
|
191
|
-
:param divisor: Divide the average segment length by this number to get the new desired
|
|
192
|
-
segment length.
|
|
193
|
-
|
|
194
|
-
:return:
|
|
195
|
-
"""
|
|
196
|
-
|
|
197
|
-
all_coordinates = flatten_list(
|
|
198
|
-
[feature.coords for feature in self.all_features]
|
|
199
|
-
)
|
|
200
|
-
return float(
|
|
201
|
-
(
|
|
202
|
-
sum(distance.pdist(all_coordinates, "euclidean"))
|
|
203
|
-
/ math.pow(len(all_coordinates), 2)
|
|
204
|
-
)
|
|
205
|
-
/ divisor
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
@property
|
|
209
|
-
def source_features(self) -> Tuple[_Feature]:
|
|
210
|
-
"""
|
|
211
|
-
Features which will be the keys in the adjacency_dict.
|
|
212
|
-
:return: List of source features.
|
|
213
|
-
"""
|
|
214
|
-
return self._source_features
|
|
215
|
-
|
|
216
|
-
@source_features.setter
|
|
217
|
-
def source_features(self, features: Tuple[BaseGeometry]):
|
|
218
|
-
raise ImmutablePropertyError("Property source_features is immutable.")
|
|
219
|
-
|
|
220
|
-
@property
|
|
221
|
-
def target_features(self) -> Tuple[_Feature]:
|
|
222
|
-
"""
|
|
223
|
-
Features which will be the values in the adjacency_dict.
|
|
224
|
-
:return: List of target features.
|
|
225
|
-
"""
|
|
226
|
-
return self._target_features
|
|
227
|
-
|
|
228
|
-
@target_features.setter
|
|
229
|
-
def target_features(self, _):
|
|
230
|
-
raise ImmutablePropertyError("Property target_features is immutable.")
|
|
231
|
-
|
|
232
|
-
@property
|
|
233
|
-
def obstacle_features(self) -> Tuple[_Feature]:
|
|
234
|
-
"""
|
|
235
|
-
Features which can prevent source and target features from being adjacent. They
|
|
236
|
-
Do not participate in the adjacency_dict.
|
|
237
|
-
:return: List of obstacle features.
|
|
238
|
-
"""
|
|
239
|
-
return self._obstacle_features
|
|
240
|
-
|
|
241
|
-
@obstacle_features.setter
|
|
242
|
-
def obstacle_features(self, _):
|
|
243
|
-
raise ImmutablePropertyError(
|
|
244
|
-
"Property obstacle_features is immutable.")
|
|
245
|
-
|
|
246
|
-
def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
|
|
247
|
-
"""
|
|
248
|
-
A list which is the length of self._all_coordinates. For each coordinate, we add the
|
|
249
|
-
index of the corresponding feature from the list self.all_features. This is used to
|
|
250
|
-
determine which coordinate belongs to which feature after we calculate the voronoi
|
|
251
|
-
diagram.
|
|
252
|
-
|
|
253
|
-
:return: A _Feature.
|
|
254
|
-
|
|
255
|
-
"""
|
|
256
|
-
if not self._feature_indices:
|
|
257
|
-
self._feature_indices = {}
|
|
258
|
-
c = -1
|
|
259
|
-
for f, feature in enumerate(self.all_features):
|
|
260
|
-
for _ in range(len(feature.coords)):
|
|
261
|
-
c += 1
|
|
262
|
-
self._feature_indices[c] = f
|
|
263
|
-
|
|
264
|
-
return self.all_features[self._feature_indices[coord_index]]
|
|
265
|
-
|
|
266
|
-
@property
|
|
267
|
-
def vor(self):
|
|
268
|
-
"""
|
|
269
|
-
The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
|
|
270
|
-
adjacency analysis.
|
|
271
|
-
:return: Voronoi object.
|
|
272
|
-
"""
|
|
273
|
-
if not self._vor:
|
|
274
|
-
self._vor = Voronoi(np.array(self.all_coordinates))
|
|
275
|
-
return self._vor
|
|
276
|
-
|
|
277
|
-
@vor.setter
|
|
278
|
-
def vor(self, _):
|
|
279
|
-
raise ImmutablePropertyError("Property vor is immutable.")
|
|
280
|
-
|
|
281
|
-
def get_adjacency_dict(self) -> Dict[int, List[int]]:
|
|
282
|
-
"""
|
|
283
|
-
Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
|
|
284
|
-
are the indices of any target geometries which are adjacent to the feature_geoms.
|
|
285
|
-
|
|
286
|
-
:return: dict A dictionary of indices. The keys are the indices of feature_geoms. The
|
|
287
|
-
values are the indices of any
|
|
288
|
-
"""
|
|
289
|
-
|
|
290
|
-
if self._adjacency_dict is None:
|
|
291
|
-
# We don't need to tag obstacles with their voronoi vertices
|
|
292
|
-
obstacle_coord_len = sum(
|
|
293
|
-
len(feat.coords) for feat in self.obstacle_features
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
# Tag each feature with the vertices of the voronoi region it
|
|
297
|
-
# belongs to
|
|
298
|
-
for coord_index in range(
|
|
299
|
-
len(self.all_coordinates) - obstacle_coord_len):
|
|
300
|
-
feature = self.get_feature_from_coord_index(coord_index)
|
|
301
|
-
for vor_vertex_index in self.vor.regions[
|
|
302
|
-
self.vor.point_region[coord_index]
|
|
303
|
-
]:
|
|
304
|
-
# "-1" indices indicate the vertex goes to infinity. These don't provide us
|
|
305
|
-
# with adjacency information, so we ignore them.
|
|
306
|
-
if vor_vertex_index != -1:
|
|
307
|
-
feature.voronoi_points.add(vor_vertex_index)
|
|
308
|
-
|
|
309
|
-
# If any two features have any voronoi indices in common, then their voronoi regions
|
|
310
|
-
# must intersect, therefore the input features are adjacent.
|
|
311
|
-
self._adjacency_dict = defaultdict(list)
|
|
312
|
-
|
|
313
|
-
for coord_index, source_feature in enumerate(self.source_features):
|
|
314
|
-
for vor_region_index, target_feature in enumerate(
|
|
315
|
-
self.target_features):
|
|
316
|
-
if (
|
|
317
|
-
len(
|
|
318
|
-
source_feature.voronoi_points
|
|
319
|
-
& target_feature.voronoi_points
|
|
320
|
-
)
|
|
321
|
-
> 1
|
|
322
|
-
):
|
|
323
|
-
self._adjacency_dict[coord_index].append(
|
|
324
|
-
vor_region_index)
|
|
325
|
-
|
|
326
|
-
return self._adjacency_dict
|
|
327
|
-
|
|
328
|
-
def plot_adjacency_dict(self) -> None:
|
|
329
|
-
"""
|
|
330
|
-
Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
|
|
331
|
-
it has not already been run.
|
|
332
|
-
:return: None
|
|
333
|
-
"""
|
|
334
|
-
# Plot the adjacency linkages between the source and target
|
|
335
|
-
for source_i, target_is in self.get_adjacency_dict().items():
|
|
336
|
-
source_poly = self.source_features[source_i].geometry
|
|
337
|
-
target_polys = [
|
|
338
|
-
self.target_features[target_i].geometry for target_i in
|
|
339
|
-
target_is
|
|
340
|
-
]
|
|
341
|
-
|
|
342
|
-
# Plot the linestrings between the source and target polygons
|
|
343
|
-
links = [
|
|
344
|
-
LineString(
|
|
345
|
-
[nearest_points(source_poly, target_poly)
|
|
346
|
-
[1], source_poly.centroid]
|
|
347
|
-
)
|
|
348
|
-
for target_poly in target_polys
|
|
349
|
-
]
|
|
350
|
-
add_geometry_to_plot(links, "green")
|
|
351
|
-
|
|
352
|
-
add_geometry_to_plot(
|
|
353
|
-
[t.geometry for t in self.target_features], "blue")
|
|
354
|
-
add_geometry_to_plot(
|
|
355
|
-
[t.geometry for t in self.source_features], "grey")
|
|
356
|
-
add_geometry_to_plot(
|
|
357
|
-
[t.geometry for t in self.obstacle_features], "red")
|
|
358
|
-
|
|
359
|
-
plt.title("Adjacency linkages between source and target")
|
|
360
|
-
plt.xlabel("Longitude")
|
|
361
|
-
plt.ylabel("Latitude")
|
|
362
|
-
plt.show()
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
2023-11-20 19:19:58,755 - __main__ - ERROR - This is an error
|
|
2
|
-
2023-11-20 19:21:23,871 - __main__ - ERROR - This is an error
|
|
3
|
-
2023-11-20 19:22:38,493 - __main__ - ERROR - This is an error
|
|
4
|
-
2023-11-20 19:22:48,026 - __main__ - ERROR - This is an error
|
|
5
|
-
2023-11-20 19:23:34,369 - __main__ - ERROR - This is an error
|
|
6
|
-
2023-11-20 19:23:44,297 - __main__ - ERROR - This is an error
|
|
7
|
-
2023-11-20 19:26:28,000 - __main__ - ERROR - This is an error
|
|
8
|
-
2023-11-20 19:26:58,737 - __main__ - ERROR - This is an error
|
|
9
|
-
2023-11-20 19:28:41,686 - __main__ - ERROR - This is an error
|
|
10
|
-
2023-11-20 19:29:03,485 - __main__ - ERROR - This is an error
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Convenience functions.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from matplotlib import pyplot as plt
|
|
6
|
-
from shapely import Point, LineString, Polygon, MultiPolygon
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def flatten_list(list_of_lists):
|
|
10
|
-
"""
|
|
11
|
-
Flattens a list of lists.
|
|
12
|
-
:param list_of_lists: The list of lists.
|
|
13
|
-
:return: A flattened list.
|
|
14
|
-
"""
|
|
15
|
-
flattened_list = []
|
|
16
|
-
|
|
17
|
-
for sublist in list_of_lists:
|
|
18
|
-
for item in sublist:
|
|
19
|
-
flattened_list.append(item)
|
|
20
|
-
|
|
21
|
-
return flattened_list
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def add_geometry_to_plot(geoms, color="black"):
|
|
25
|
-
"""
|
|
26
|
-
When updating the test data, it may be useful to visualize it.
|
|
27
|
-
:param geoms:
|
|
28
|
-
:param color:
|
|
29
|
-
:return:
|
|
30
|
-
"""
|
|
31
|
-
for geom in geoms:
|
|
32
|
-
if isinstance(geom, Point):
|
|
33
|
-
plt.plot(
|
|
34
|
-
geom.x,
|
|
35
|
-
geom.y,
|
|
36
|
-
marker="o",
|
|
37
|
-
markersize=5,
|
|
38
|
-
markeredgecolor="black",
|
|
39
|
-
markerfacecolor=color,
|
|
40
|
-
)
|
|
41
|
-
elif isinstance(geom, LineString):
|
|
42
|
-
plt.plot(*geom.coords.xy, color=color)
|
|
43
|
-
elif isinstance(geom, Polygon):
|
|
44
|
-
plt.plot(*geom.exterior.xy, color=color, linestyle="-")
|
|
45
|
-
elif isinstance(geom, MultiPolygon):
|
|
46
|
-
for sub_poly in geom.geoms:
|
|
47
|
-
plt.plot(*sub_poly.exterior.xy, color=color, linewidth=3)
|
|
48
|
-
else:
|
|
49
|
-
raise TypeError("Unknown geometry type")
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "geo-adjacency"
|
|
3
|
-
version = "1.0.7"
|
|
4
|
-
description = ""
|
|
5
|
-
authors = ["Andrew Smyth <andrews@zillowgroup.com>"]
|
|
6
|
-
readme = "README.md"
|
|
7
|
-
packages = [{include = "geo_adjacency"}]
|
|
8
|
-
license = "MIT"
|
|
9
|
-
|
|
10
|
-
[tool.poetry.dependencies]
|
|
11
|
-
python = "<3.13,>=3.9"
|
|
12
|
-
scipy = "^1.11.3"
|
|
13
|
-
shapely = "^2.0.2"
|
|
14
|
-
numpy = "^1.26.2"
|
|
15
|
-
matplotlib = "^3.8.1"
|
|
16
|
-
setuptools = "^69.0.0"
|
|
17
|
-
|
|
18
|
-
[tool.poetry.dev-dependencies]
|
|
19
|
-
pytest = "^7.4.3"
|
|
20
|
-
pylint = "^3.0.2"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
[build-system]
|
|
24
|
-
requires = ["poetry-core"]
|
|
25
|
-
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|