Skip to content

Commit

Permalink
Merge 4eaed97 into 38a9021
Browse files Browse the repository at this point in the history
  • Loading branch information
theroggy committed Jan 9, 2024
2 parents 38a9021 + 4eaed97 commit 09d578f
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGES.txt
Expand Up @@ -19,6 +19,7 @@ Improvements:
signature in the ``transformation`` function.
- The ``include_z`` in ``shapely.transform()`` now also allows ``None``, which
lets it automatically detect the dimensionality of each input geometry.
- Add parameters ``method`` and ``keep_collapsed`` to ``shapely.make_valid()`` (#1941)
- Upgraded the GEOS version in the binary wheel distributions to 3.12.1.

Breaking changes in GEOS 3.12:
Expand Down
Binary file added docs/_static/images/make_valid_methods.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions docs/code/make_valid_methods.py
@@ -0,0 +1,47 @@
from matplotlib import pyplot as plt
import shapely
from shapely.plotting import plot_points, plot_polygon, plot_line

from figures import BLUE, GRAY, RED

input = shapely.MultiPolygon(
[
shapely.Polygon(
[(2, 0), (2, 12), (7, 12), (7, 10), (7, 12), (10, 12), (8, 12), (8, 0), (2, 0)],
[[(3, 10), (5, 10), (5, 12), (3, 12), (3, 10)]]
),
shapely.Polygon(
[(4, 2), (4, 8), (12, 8), (12, 2), (4, 2)],
[[(6, 4), (10, 4), (10, 6), (6, 6), (6, 4)]],
)
]
)

fig, ax = plt.subplots(1, 3, sharex=True, sharey=True, figsize=(11, 4), dpi=90)
plot_polygon(input, ax=ax[0], add_points=False, color=BLUE)
plot_points(input, ax=ax[0], color=GRAY, alpha=0.7)
ax[0].set_title("invalid input")
ax[0].set_aspect('equal')

# Structure makevalid
valid_structure = shapely.make_valid(input, method="structure", keep_collapsed=True)
plot_polygon(valid_structure, ax=ax[1], add_points=False, color=BLUE)
plot_points(valid_structure, ax=ax[1], color=GRAY, alpha=0.7)

ax[1].set_title("make_valid - structure")
ax[1].set_aspect('equal')

# Linework makevalid
valid_linework = shapely.make_valid(input)
for geom in valid_linework.geoms:
if isinstance(geom, shapely.MultiPolygon):
plot_polygon(geom, ax=ax[2], add_points=False, color=BLUE)
plot_points(geom, ax=ax[2], color=GRAY, alpha=0.7)
else:
plot_line(geom, ax=ax[2], color=RED, linewidth=1)
plot_points(geom, ax=ax[2], color=GRAY, alpha=0.7)
ax[2].set_title("make_valid - linework")
ax[2].set_aspect('equal')

plt.show()
fig.savefig("./docs/_static/images/make_valid_methods.png", bbox_inches='tight')
76 changes: 70 additions & 6 deletions shapely/constructive.py
Expand Up @@ -91,7 +91,7 @@ def buffer(
join_style="round",
mitre_limit=5.0,
single_sided=False,
**kwargs
**kwargs,
):
"""
Computes the buffer of a geometry for positive and negative buffer distance.
Expand Down Expand Up @@ -185,7 +185,7 @@ def buffer(
np.intc(join_style),
mitre_limit,
np.bool_(single_sided),
**kwargs
**kwargs,
)


Expand Down Expand Up @@ -251,7 +251,7 @@ def offset_curve(
np.intc(quad_segs),
np.intc(join_style),
np.double(mitre_limit),
**kwargs
**kwargs,
)


Expand Down Expand Up @@ -330,7 +330,7 @@ def clip_by_rect(geometry, xmin, ymin, xmax, ymax, **kwargs):
np.double(ymin),
np.double(xmax),
np.double(ymax),
**kwargs
**kwargs,
)


Expand Down Expand Up @@ -508,12 +508,40 @@ def build_area(geometry, **kwargs):


@multithreading_enabled
def make_valid(geometry, **kwargs):
def make_valid(geometry, method="linework", keep_collapsed=True, **kwargs):
"""Repairs invalid geometries.
Two ``methods`` are available:
* the 'linework' algorithm tries to preserve every edge and vertex in the input. It
combines all rings into a set of noded lines and then extracts valid polygons from
that linework. An alternating even-odd strategy is used to assign areas as
interior or exterior. A disadvantage is that for some relatively simple invalid
geometries this produces rather complex results.
* the 'structure' algorithm tries to reason from the structure of the input to find
the 'correct' repair: exterior rings bound area, interior holes exclude area.
It first makes all rings valid, then shells are merged and holes are subtracted
from the shells to generate valid result. It assumes that holes and shells are
correctly categorized in the input geometry.
Example:
|make_valid_methods|
When using ``make_valid`` on a Polygon, the result can be a GeometryCollection. For
this example this is the case when the 'linework' ``method`` is used. LineStrings in
the result are drawn in red.
Parameters
----------
geometry : Geometry or array_like
method : {'linework', 'structure'}, default 'linework'
Algorithm to use when repairing geometry. 'structure'
requires GEOS >= 3.10.
keep_collapsed : bool, default True
For the 'structure' method, True will keep components that have collapsed into a
lower dimensionality. For example, a ring collapsing to a line, or a line
collapsing to a point. Must be True for the 'linework' method.
**kwargs
See :ref:`NumPy ufunc docs <ufuncs.kwargs>` for other keyword arguments.
Expand All @@ -525,8 +553,44 @@ def make_valid(geometry, **kwargs):
False
>>> make_valid(polygon)
<MULTILINESTRING ((0 0, 1 1), (1 1, 1 2))>
>>> make_valid(polygon, method="structure", keep_collapsed=True)
<LINESTRING (0 0, 1 1, 1 2, 1 1, 0 0)>
>>> make_valid(polygon, method="structure", keep_collapsed=False)
<POLYGON EMPTY>
.. |make_valid_methods| image:: ../_static/images/make_valid_methods.png
:alt: Example file for make_valid methods
"""
return lib.make_valid(geometry, **kwargs)
if not np.isscalar(method):
raise TypeError("method only accepts scalar values")
if not np.isscalar(keep_collapsed):
raise TypeError("keep_collapsed only accepts scalar values")

if method == "linework":
if keep_collapsed is False:
raise ValueError(
"The 'linework' method does not support 'keep_collapsed=False'"
)

# The make_valid code can be removed once support for GEOS < 3.10 is dropped.
# In GEOS >= 3.10, make_valid just calls make_valid_with_params with
# method="linework" and keep_collapsed=True, so there is no advantage to keep
# both code paths in shapely on long term.
return lib.make_valid(geometry, **kwargs)

elif method == "structure":
if lib.geos_version < (3, 10, 0):
raise ValueError(
"The 'structure' method is only available in GEOS >= 3.10.0"
)

return lib.make_valid_with_params(
geometry, np.intc(1), np.bool_(keep_collapsed), **kwargs
)

else:
raise ValueError(f"Unknown method: {method}")


@multithreading_enabled
Expand Down
100 changes: 100 additions & 0 deletions shapely/tests/test_constructive.py
Expand Up @@ -244,6 +244,106 @@ def test_make_valid_1d(geom, expected):
assert np.all(shapely.normalize(actual) == shapely.normalize(expected))


@pytest.mark.skipif(shapely.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"geom,expected",
[
(point, point), # a valid geometry stays the same (but is copied)
# an L shaped polygon without area is converted to a linestring
(
Polygon([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)]),
LineString([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)]),
),
# a polygon with self-intersection (bowtie) is converted into polygons
(
Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)]),
MultiPolygon(
[
Polygon([(1, 1), (2, 2), (2, 0), (1, 1)]),
Polygon([(0, 0), (0, 2), (1, 1), (0, 0)]),
]
),
),
(empty, empty),
([empty], [empty]),
],
)
def test_make_valid_structure(geom, expected):
actual = shapely.make_valid(geom, method="structure")
assert actual is not expected
# normalize needed to handle variation in output across GEOS versions
assert shapely.normalize(actual) == expected


@pytest.mark.skipif(shapely.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"geom,expected",
[
(point, point), # a valid geometry stays the same (but is copied)
# an L shaped polygon without area is converted to Empty Polygon
(
Polygon([(0, 0), (1, 1), (1, 2), (1, 1), (0, 0)]),
Polygon(),
),
# a polygon with self-intersection (bowtie) is converted into polygons
(
Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)]),
MultiPolygon(
[
Polygon([(1, 1), (2, 2), (2, 0), (1, 1)]),
Polygon([(0, 0), (0, 2), (1, 1), (0, 0)]),
]
),
),
(empty, empty),
([empty], [empty]),
],
)
def test_make_valid_structure_keep_collapsed_false(geom, expected):
actual = shapely.make_valid(geom, method="structure", keep_collapsed=False)
assert actual is not expected
# normalize needed to handle variation in output across GEOS versions
assert shapely.normalize(actual) == expected


@pytest.mark.skipif(shapely.geos_version >= (3, 10, 0), reason="GEOS >= 3.10")
def test_make_valid_structure_unsupported_geos():
with pytest.raises(
ValueError, match="The 'structure' method is only available in GEOS >= 3.10.0"
):
_ = shapely.make_valid(Point(), method="structure")


@pytest.mark.skipif(shapely.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"method, keep_collapsed, error_type, error",
[
(
np.array(["linework", "structure"]),
True,
TypeError,
"method only accepts scalar values",
),
(
"linework",
[True, False],
TypeError,
"keep_collapsed only accepts scalar values",
),
("unknown", True, ValueError, "Unknown method: unknown"),
(
"linework",
False,
ValueError,
"The 'linework' method does not support 'keep_collapsed=False'",
),
],
)
def test_make_valid_invalid_params(method, keep_collapsed, error_type, error):
with pytest.raises(error_type, match=error):
_ = shapely.make_valid(Point(), method=method, keep_collapsed=keep_collapsed)


@pytest.mark.parametrize(
"geom,expected",
[
Expand Down

0 comments on commit 09d578f

Please sign in to comment.