Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add parameters method and keep_collapsed to make_valid + improve doc #1941

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
57 changes: 57 additions & 0 deletions docs/code/make_valid_methods.py
@@ -0,0 +1,57 @@
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")

fig.tight_layout()
plt.show()
72 changes: 66 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:

.. plot:: code/make_valid_methods.py

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,40 @@ 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)
theroggy marked this conversation as resolved.
Show resolved Hide resolved
<POLYGON EMPTY>
"""
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":
theroggy marked this conversation as resolved.
Show resolved Hide resolved
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)
brendan-ward marked this conversation as resolved.
Show resolved Hide resolved

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