Skip to content

Commit 89f6f8b

Browse files
authored
chip away some refactors in geometry.py (#46)
* chip away some refactors in geometry.py * martin comments
1 parent 32d1f0c commit 89f6f8b

File tree

2 files changed

+121
-25
lines changed

2 files changed

+121
-25
lines changed

sgeop/geometry.py

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import geopandas as gpd
88
import numpy as np
9+
import pandas as pd
910
import shapely
1011
from libpysal import graph
1112
from scipy import spatial
@@ -122,7 +123,7 @@ def voronoi_skeleton(
122123
buffer : None | float | int = None
123124
Optional custom buffer distance for dealing with Voronoi infinity issues.
124125
secondary_snap_to : None | gpd.GeoSeries = None
125-
...
126+
Fall-back series of geometries that shall be connected to the skeleton.
126127
limit_distance : None | int = 2
127128
...
128129
consolidation_tolerance : None | float = None
@@ -236,7 +237,7 @@ def voronoi_skeleton(
236237
if edgelines.shape[0] > 0:
237238
# if there is no explicit snapping target, snap to the boundary of the polygon
238239
# via the shortest line. That is by definition always within the polygon
239-
# (Martin thinks)
240+
# (Martin thinks) (James concurs)
240241
if snap_to is not False:
241242
if snap_to is None:
242243
sl = shapely.shortest_line(
@@ -296,24 +297,45 @@ def _consolidate(
296297
return edgelines
297298

298299

299-
def snap_to_targets(edgelines, poly, snap_to, secondary_snap_to=None):
300+
def snap_to_targets(
301+
edgelines: np.ndarray,
302+
poly: shapely.Polygon,
303+
snap_to: gpd.GeoSeries,
304+
secondary_snap_to: None | gpd.GeoSeries = None,
305+
) -> tuple[list[shapely.LineString], list[shapely.Point]]:
306+
"""Snap edgelines to vertices.
307+
308+
Parameters
309+
----------
310+
edgelines : numpy.ndarray
311+
Voronoi skeleton edges.
312+
poly : None | shapely.Polygon = None
313+
Polygon enclosed by ``lines``.
314+
snap_to : None | gpd.GeoSeries = None
315+
Series of geometries that shall be connected to the skeleton.
316+
secondary_snap_to : None | gpd.GeoSeries = None
317+
Fall-back series of geometries that shall be connected to the skeleton.
318+
319+
Returns
320+
-------
321+
to_add, to_split : tuple[list[shapely.LineString], list[shapely.Point]]
322+
Lines to add and points where to split.
323+
"""
324+
300325
to_add = []
301326
to_split = []
302-
# cast edgelines to gdf
303-
edgelines_df = gpd.GeoDataFrame(geometry=edgelines)
304-
# build queen contiguity on edgelines and extract component labels
305-
comp_labels = graph.Graph.build_contiguity(
306-
edgelines_df[~(edgelines_df.is_empty | edgelines_df.geometry.isna())],
307-
rook=False,
308-
).component_labels
309-
# compute size of each component
310-
comp_counts = comp_labels.value_counts()
311-
# get MultiLineString geometry per connected component
312-
components = edgelines_df.dissolve(comp_labels)
327+
328+
# generate graph from lines
329+
comp_labels, comp_counts, components = _prep_components(edgelines)
330+
331+
primary_union = shapely.union_all(snap_to)
332+
secondary_union = shapely.union_all(secondary_snap_to)
313333

314334
# if there are muliple components, loop over all and treat each
315335
if len(components) > 1:
316336
for comp_label, comp in components.geometry.items():
337+
cbound = comp.boundary
338+
317339
# if component does not interest the boundary, it needs to be snapped
318340
# if it does but has only one part, this part interesect only on one
319341
# side (the node remaining from the removed edge) and needs to be
@@ -322,30 +344,25 @@ def snap_to_targets(edgelines, poly, snap_to, secondary_snap_to=None):
322344
(not comp.intersects(poly.boundary))
323345
or comp_counts[comp_label] == 1
324346
or (
325-
not comp.intersects(shapely.union_all(snap_to))
347+
not comp.intersects(primary_union)
326348
) # ! this fixes one thing but may break others
327349
):
328350
# add segment composed of the shortest line to the nearest snapping
329351
# target. We use boundary to snap to endpoints of edgelines only
330-
sl = shapely.shortest_line(comp.boundary, shapely.union_all(snap_to))
352+
sl = shapely.shortest_line(cbound, primary_union)
331353
if _is_within(sl, poly):
332-
to_add.append(sl)
333-
to_split.append(shapely.get_point(sl, -1))
354+
to_split, to_add = _split_add(sl, to_split, to_add)
334355
else:
335356
if secondary_snap_to is not None:
336-
sl = shapely.shortest_line(
337-
comp.boundary, shapely.union_all(secondary_snap_to)
338-
)
339-
to_split.append(shapely.get_point(sl, -1))
340-
to_add.append(sl)
357+
sl = shapely.shortest_line(cbound, secondary_union)
358+
to_split, to_add = _split_add(sl, to_split, to_add)
341359
else:
342360
# if there is a single component, ensure it gets a shortest line to an
343361
# endpoint from each snapping target
344362
for target in snap_to:
345363
sl = shapely.shortest_line(components.boundary.item(), target)
346364
if _is_within(sl, poly):
347-
to_split.append(shapely.get_point(sl, -1))
348-
to_add.append(sl)
365+
to_split, to_add = _split_add(sl, to_split, to_add)
349366
else:
350367
warnings.warn(
351368
"Could not create a connection as it would lead outside "
@@ -354,3 +371,31 @@ def snap_to_targets(edgelines, poly, snap_to, secondary_snap_to=None):
354371
stacklevel=2,
355372
)
356373
return to_add, to_split
374+
375+
376+
def _prep_components(lines: np.ndarray) -> tuple[pd.Series, pd.Series, gpd.GeoSeries]:
377+
"""Helper for preparing graph components & labels in PySAL."""
378+
379+
# cast edgelines to gdf
380+
lines = gpd.GeoDataFrame(geometry=lines)
381+
382+
# build queen contiguity on edgelines and extract component labels
383+
not_empty = ~lines.is_empty
384+
not_nan = ~lines.geometry.isna()
385+
lines = lines[not_empty | not_nan]
386+
comp_labels = graph.Graph.build_contiguity(lines, rook=False).component_labels
387+
388+
# compute size of each component
389+
comp_counts = comp_labels.value_counts()
390+
391+
# get MultiLineString geometry per connected component
392+
components = lines.dissolve(comp_labels)
393+
394+
return comp_labels, comp_counts, components
395+
396+
397+
def _split_add(line: shapely.LineString, splits: list, adds: list) -> tuple[list]:
398+
"""Helper for preparing splitter points & added lines."""
399+
splits.append(shapely.get_point(line, -1))
400+
adds.append(line)
401+
return splits, adds

sgeop/tests/test_geometry.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,54 @@ def test_consolidate(tolerance):
225225
numpy.array([line_100_900, line_100_120, line_110_900]), tolerance
226226
)
227227
numpy.testing.assert_array_equal(observed, known)
228+
229+
230+
def test_prep_components():
231+
line1 = shapely.LineString(((1, 1), (1, 2)))
232+
line2 = shapely.LineString(((1, 2), (2, 2)))
233+
line3 = shapely.LineString(((3, 0), (3, 3)))
234+
235+
known_labels = pandas.Series(
236+
[0, 0, 1],
237+
index=pandas.Index([0, 1, 2], name="focal"),
238+
name="component labels",
239+
dtype=numpy.int32,
240+
)
241+
known_counts = pandas.Series(
242+
[2, 1],
243+
index=pandas.Index([0, 1], name="component labels", dtype=numpy.int32),
244+
name="count",
245+
dtype=numpy.int64,
246+
)
247+
known_comps = geopandas.GeoDataFrame(
248+
geometry=[
249+
shapely.MultiLineString(
250+
(
251+
shapely.LineString(((1, 1), (1, 2))),
252+
shapely.LineString(((1, 2), (2, 2))),
253+
)
254+
),
255+
shapely.LineString(((3, 0), (3, 3))),
256+
],
257+
index=pandas.Index([0, 1], name="component labels", dtype=numpy.int32),
258+
)
259+
260+
observed_labels, observed_counts, observed_comps = sgeop.geometry._prep_components(
261+
[line1, line2, line3]
262+
)
263+
264+
pandas.testing.assert_series_equal(observed_labels, known_labels)
265+
pandas.testing.assert_series_equal(observed_counts, known_counts)
266+
geopandas.testing.assert_geodataframe_equal(observed_comps, known_comps)
267+
268+
269+
def test_split_add():
270+
_x = 1100
271+
x1, y1 = _x, 0
272+
x2, y2 = _x, 1000
273+
sl = shapely.LineString(((x1, y1), (x2, y2)))
274+
known_splits = [shapely.Point((x2, y2))]
275+
known_adds = [sl]
276+
observed_splits, observed_adds = sgeop.geometry._split_add(sl, [], [])
277+
assert observed_splits == known_splits
278+
assert observed_adds == known_adds

0 commit comments

Comments
 (0)