smoothify 0.2.0__tar.gz → 0.2.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.
- {smoothify-0.2.0/smoothify.egg-info → smoothify-0.2.2}/PKG-INFO +1 -1
- smoothify-0.2.2/smoothify/__version__.py +1 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify/geometry_ops.py +14 -8
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify/smoothify_core.py +20 -9
- {smoothify-0.2.0 → smoothify-0.2.2/smoothify.egg-info}/PKG-INFO +1 -1
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify.egg-info/SOURCES.txt +2 -0
- smoothify-0.2.2/tests/test_all_geometry_types.py +894 -0
- smoothify-0.2.2/tests/test_real_world_data.py +90 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_smoothify_core.py +20 -0
- smoothify-0.2.0/smoothify/__version__.py +0 -1
- {smoothify-0.2.0 → smoothify-0.2.2}/LICENSE +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/README.md +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/pyproject.toml +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/setup.cfg +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify/__init__.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify/coordinator.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify.egg-info/dependency_links.txt +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify.egg-info/requires.txt +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/smoothify.egg-info/top_level.txt +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_area_tolerance.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_auto_segment_length.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_chaikin.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_edge_cases_coverage.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_geometry_types.py +0 -0
- {smoothify-0.2.0 → smoothify-0.2.2}/tests/test_smoothify_api.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -49,7 +49,7 @@ def _smoothify_multipolygon(
|
|
|
49
49
|
for polygon in polygons:
|
|
50
50
|
assert isinstance(polygon, Polygon)
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
smoothed = [
|
|
53
53
|
_smoothify_polygon(
|
|
54
54
|
geom=polygon,
|
|
55
55
|
segment_length=segment_length,
|
|
@@ -59,7 +59,16 @@ def _smoothify_multipolygon(
|
|
|
59
59
|
)
|
|
60
60
|
for polygon in polygons
|
|
61
61
|
]
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
# Flatten any MultiPolygons from hole subtraction
|
|
64
|
+
flattened = []
|
|
65
|
+
for geom_result in smoothed:
|
|
66
|
+
if isinstance(geom_result, MultiPolygon):
|
|
67
|
+
flattened.extend(geom_result.geoms)
|
|
68
|
+
else:
|
|
69
|
+
flattened.append(geom_result)
|
|
70
|
+
|
|
71
|
+
return MultiPolygon(flattened)
|
|
63
72
|
|
|
64
73
|
|
|
65
74
|
def _smoothify_linearing(
|
|
@@ -105,12 +114,13 @@ def _smoothify_polygon(
|
|
|
105
114
|
smooth_iterations: int = 3,
|
|
106
115
|
preserve_area: bool = True,
|
|
107
116
|
area_tolerance: float = 0.01,
|
|
108
|
-
) -> Polygon:
|
|
117
|
+
) -> Polygon | MultiPolygon:
|
|
109
118
|
"""Smooth a Polygon while preserving interior holes.
|
|
110
119
|
|
|
111
120
|
Smooths the exterior shell and each interior hole independently, then
|
|
112
121
|
recombines them. This approach prevents artifacts at hole boundaries and
|
|
113
|
-
maintains proper polygon topology.
|
|
122
|
+
maintains proper polygon topology. May return a MultiPolygon if hole
|
|
123
|
+
subtraction splits the polygon."""
|
|
114
124
|
|
|
115
125
|
holes, filled_polygon = _extract_and_fill_holes(geom)
|
|
116
126
|
|
|
@@ -147,10 +157,6 @@ def _smoothify_polygon(
|
|
|
147
157
|
if not hole_inside.is_empty:
|
|
148
158
|
smooth_polygon = smooth_polygon.difference(hole_inside)
|
|
149
159
|
|
|
150
|
-
if not isinstance(smooth_polygon, Polygon):
|
|
151
|
-
raise ValueError(
|
|
152
|
-
f"Expected output of smoothify_geometry to be Polygon, got {type(smooth_polygon)}" # noqa: E501
|
|
153
|
-
)
|
|
154
160
|
return smooth_polygon
|
|
155
161
|
|
|
156
162
|
|
|
@@ -52,7 +52,7 @@ def _chaikin_corner_cutting(
|
|
|
52
52
|
# Vectorized smoothing at 1/4 and 3/4 positions
|
|
53
53
|
# Pre-allocate result array for better performance
|
|
54
54
|
n_new_points = len(p0) * 2
|
|
55
|
-
points = np.empty((n_new_points,
|
|
55
|
+
points = np.empty((n_new_points, points.shape[1]), dtype=np.float64)
|
|
56
56
|
points[0::2] = 0.75 * p0 + 0.25 * p1 # q points
|
|
57
57
|
points[1::2] = 0.25 * p0 + 0.75 * p1 # r points
|
|
58
58
|
|
|
@@ -306,15 +306,26 @@ def _smoothify_geometry(
|
|
|
306
306
|
)
|
|
307
307
|
geom_iterations.append(smoothed)
|
|
308
308
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
309
|
+
if isinstance(geom, Polygon):
|
|
310
|
+
geom_iterations = [make_valid(g) for g in geom_iterations]
|
|
311
|
+
|
|
312
|
+
dissolved_poly = make_valid(unary_union(geom_iterations)).simplify(
|
|
313
|
+
tolerance=segment_length / 5,
|
|
314
|
+
preserve_topology=True,
|
|
315
|
+
)
|
|
313
316
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
317
|
+
# If the union is a MultiPolygon, take the largest geometry
|
|
318
|
+
if isinstance(dissolved_poly, MultiPolygon):
|
|
319
|
+
largest_geom = max(dissolved_poly.geoms, key=lambda x: x.area)
|
|
320
|
+
dissolved_poly = largest_geom
|
|
321
|
+
else:
|
|
322
|
+
# LineStrings: skip make_valid/unary_union — self-intersecting lines
|
|
323
|
+
# are geometrically valid, and unary_union would split them at
|
|
324
|
+
# crossing points into a MultiLineString
|
|
325
|
+
dissolved_poly = geom_iterations[0].simplify(
|
|
326
|
+
tolerance=segment_length / 5,
|
|
327
|
+
preserve_topology=True,
|
|
328
|
+
)
|
|
318
329
|
|
|
319
330
|
assert isinstance(dissolved_poly, (Polygon, LineString)), (
|
|
320
331
|
f"Resulting geometry must be Polygon or LineString. Got {type(dissolved_poly)}."
|
|
@@ -11,10 +11,12 @@ smoothify.egg-info/SOURCES.txt
|
|
|
11
11
|
smoothify.egg-info/dependency_links.txt
|
|
12
12
|
smoothify.egg-info/requires.txt
|
|
13
13
|
smoothify.egg-info/top_level.txt
|
|
14
|
+
tests/test_all_geometry_types.py
|
|
14
15
|
tests/test_area_tolerance.py
|
|
15
16
|
tests/test_auto_segment_length.py
|
|
16
17
|
tests/test_chaikin.py
|
|
17
18
|
tests/test_edge_cases_coverage.py
|
|
18
19
|
tests/test_geometry_types.py
|
|
20
|
+
tests/test_real_world_data.py
|
|
19
21
|
tests/test_smoothify_api.py
|
|
20
22
|
tests/test_smoothify_core.py
|
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Tests for all geometry types with simple and complex shapes.
|
|
2
|
+
|
|
3
|
+
Ensures smoothify handles every supported geometry type correctly,
|
|
4
|
+
from minimal examples through to complex real-world-like shapes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
from shapely import make_valid, wkt
|
|
12
|
+
from shapely.geometry import (
|
|
13
|
+
GeometryCollection,
|
|
14
|
+
LinearRing,
|
|
15
|
+
LineString,
|
|
16
|
+
MultiLineString,
|
|
17
|
+
MultiPolygon,
|
|
18
|
+
Polygon,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from smoothify import smoothify
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Polygons
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
class TestPolygonSimple:
|
|
28
|
+
"""Simple polygon inputs."""
|
|
29
|
+
|
|
30
|
+
def test_triangle(self):
|
|
31
|
+
poly = Polygon([(0, 0), (10, 0), (5, 8)])
|
|
32
|
+
result = smoothify(poly, segment_length=1.0)
|
|
33
|
+
assert isinstance(result, Polygon)
|
|
34
|
+
assert result.is_valid
|
|
35
|
+
assert len(result.exterior.coords) > 3
|
|
36
|
+
|
|
37
|
+
def test_square(self):
|
|
38
|
+
poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
39
|
+
result = smoothify(poly, segment_length=1.0)
|
|
40
|
+
assert isinstance(result, Polygon)
|
|
41
|
+
assert result.is_valid
|
|
42
|
+
|
|
43
|
+
def test_rectangle_thin(self):
|
|
44
|
+
"""Thin rectangle — smoothing shouldn't collapse it."""
|
|
45
|
+
poly = Polygon([(0, 0), (100, 0), (100, 2), (0, 2)])
|
|
46
|
+
result = smoothify(poly, segment_length=1.0)
|
|
47
|
+
assert isinstance(result, Polygon)
|
|
48
|
+
assert result.is_valid
|
|
49
|
+
assert result.area > 0
|
|
50
|
+
|
|
51
|
+
def test_regular_hexagon(self):
|
|
52
|
+
coords = [
|
|
53
|
+
(10 * math.cos(math.radians(60 * i)), 10 * math.sin(math.radians(60 * i)))
|
|
54
|
+
for i in range(6)
|
|
55
|
+
]
|
|
56
|
+
poly = Polygon(coords)
|
|
57
|
+
result = smoothify(poly, segment_length=1.0)
|
|
58
|
+
assert isinstance(result, Polygon)
|
|
59
|
+
assert result.is_valid
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestPolygonComplex:
|
|
63
|
+
"""Complex polygon inputs."""
|
|
64
|
+
|
|
65
|
+
def test_star_shape(self):
|
|
66
|
+
"""Star polygon with concavities."""
|
|
67
|
+
coords = []
|
|
68
|
+
for i in range(10):
|
|
69
|
+
angle = math.radians(36 * i)
|
|
70
|
+
r = 10 if i % 2 == 0 else 4
|
|
71
|
+
coords.append((r * math.cos(angle), r * math.sin(angle)))
|
|
72
|
+
poly = Polygon(coords)
|
|
73
|
+
result = smoothify(poly, segment_length=0.5)
|
|
74
|
+
assert isinstance(result, Polygon)
|
|
75
|
+
assert result.is_valid
|
|
76
|
+
|
|
77
|
+
def test_polygon_with_one_hole(self):
|
|
78
|
+
exterior = [(0, 0), (50, 0), (50, 50), (0, 50)]
|
|
79
|
+
hole = [(15, 15), (35, 15), (35, 35), (15, 35)]
|
|
80
|
+
poly = Polygon(exterior, [hole])
|
|
81
|
+
result = smoothify(poly, segment_length=1.0)
|
|
82
|
+
assert isinstance(result, (Polygon, MultiPolygon))
|
|
83
|
+
assert result.is_valid
|
|
84
|
+
|
|
85
|
+
def test_polygon_with_many_holes(self):
|
|
86
|
+
exterior = [(0, 0), (100, 0), (100, 100), (0, 100)]
|
|
87
|
+
holes = [
|
|
88
|
+
[(10 + 25 * i, 10 + 25 * j),
|
|
89
|
+
(20 + 25 * i, 10 + 25 * j),
|
|
90
|
+
(20 + 25 * i, 20 + 25 * j),
|
|
91
|
+
(10 + 25 * i, 20 + 25 * j)]
|
|
92
|
+
for i in range(3) for j in range(3)
|
|
93
|
+
]
|
|
94
|
+
poly = Polygon(exterior, holes)
|
|
95
|
+
result = smoothify(poly, segment_length=1.0)
|
|
96
|
+
assert result.is_valid
|
|
97
|
+
|
|
98
|
+
def test_circle_approximation(self):
|
|
99
|
+
"""High-vertex polygon approximating a circle."""
|
|
100
|
+
n = 200
|
|
101
|
+
coords = [
|
|
102
|
+
(10 * math.cos(2 * math.pi * i / n),
|
|
103
|
+
10 * math.sin(2 * math.pi * i / n))
|
|
104
|
+
for i in range(n)
|
|
105
|
+
]
|
|
106
|
+
poly = Polygon(coords)
|
|
107
|
+
result = smoothify(poly, segment_length=0.5)
|
|
108
|
+
assert isinstance(result, Polygon)
|
|
109
|
+
assert result.is_valid
|
|
110
|
+
|
|
111
|
+
def test_L_shaped_polygon(self):
|
|
112
|
+
poly = Polygon([
|
|
113
|
+
(0, 0), (10, 0), (10, 5), (5, 5), (5, 10), (0, 10),
|
|
114
|
+
])
|
|
115
|
+
result = smoothify(poly, segment_length=0.5)
|
|
116
|
+
assert isinstance(result, Polygon)
|
|
117
|
+
assert result.is_valid
|
|
118
|
+
|
|
119
|
+
def test_pixelated_polygon(self):
|
|
120
|
+
"""Staircase polygon typical of raster-to-vector conversion."""
|
|
121
|
+
coords = []
|
|
122
|
+
for i in range(10):
|
|
123
|
+
coords.append((i, i))
|
|
124
|
+
coords.append((i + 1, i))
|
|
125
|
+
for i in range(10, 0, -1):
|
|
126
|
+
coords.append((i, i + 5))
|
|
127
|
+
coords.append((i - 1, i + 5))
|
|
128
|
+
poly = Polygon(coords)
|
|
129
|
+
result = smoothify(poly, segment_length=1.0)
|
|
130
|
+
assert isinstance(result, Polygon)
|
|
131
|
+
assert result.is_valid
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# LineStrings
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
class TestLineStringSimple:
|
|
138
|
+
"""Simple LineString inputs."""
|
|
139
|
+
|
|
140
|
+
def test_two_point_line(self):
|
|
141
|
+
line = LineString([(0, 0), (10, 10)])
|
|
142
|
+
result = smoothify(line, segment_length=1.0)
|
|
143
|
+
assert isinstance(result, LineString)
|
|
144
|
+
assert result.coords[0] == (0.0, 0.0)
|
|
145
|
+
assert result.coords[-1] == (10.0, 10.0)
|
|
146
|
+
|
|
147
|
+
def test_three_point_angle(self):
|
|
148
|
+
line = LineString([(0, 0), (5, 5), (10, 0)])
|
|
149
|
+
result = smoothify(line, segment_length=1.0)
|
|
150
|
+
assert isinstance(result, LineString)
|
|
151
|
+
|
|
152
|
+
def test_straight_line(self):
|
|
153
|
+
"""Perfectly straight line should remain straight-ish."""
|
|
154
|
+
line = LineString([(0, 0), (5, 0), (10, 0)])
|
|
155
|
+
result = smoothify(line, segment_length=1.0)
|
|
156
|
+
assert isinstance(result, LineString)
|
|
157
|
+
|
|
158
|
+
def test_right_angle(self):
|
|
159
|
+
line = LineString([(0, 0), (10, 0), (10, 10)])
|
|
160
|
+
result = smoothify(line, segment_length=1.0)
|
|
161
|
+
assert isinstance(result, LineString)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestLineStringComplex:
|
|
165
|
+
"""Complex LineString inputs."""
|
|
166
|
+
|
|
167
|
+
def test_zigzag(self):
|
|
168
|
+
coords = [(i, (i % 2) * 5) for i in range(20)]
|
|
169
|
+
line = LineString(coords)
|
|
170
|
+
result = smoothify(line, segment_length=0.5)
|
|
171
|
+
assert isinstance(result, LineString)
|
|
172
|
+
assert result.coords[0] == line.coords[0]
|
|
173
|
+
assert result.coords[-1] == line.coords[-1]
|
|
174
|
+
|
|
175
|
+
def test_spiral(self):
|
|
176
|
+
t = np.linspace(0, 4 * np.pi, 200)
|
|
177
|
+
x = t * np.cos(t)
|
|
178
|
+
y = t * np.sin(t)
|
|
179
|
+
line = LineString(np.column_stack([x, y]))
|
|
180
|
+
result = smoothify(line, segment_length=0.5)
|
|
181
|
+
assert isinstance(result, LineString)
|
|
182
|
+
|
|
183
|
+
def test_self_intersecting_line(self):
|
|
184
|
+
"""Self-intersecting line must stay a LineString, not be split."""
|
|
185
|
+
line = LineString([
|
|
186
|
+
(0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
|
|
187
|
+
(2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
|
|
188
|
+
])
|
|
189
|
+
assert not line.is_simple
|
|
190
|
+
result = smoothify(line, segment_length=0.5)
|
|
191
|
+
assert isinstance(result, LineString)
|
|
192
|
+
|
|
193
|
+
def test_random_walk(self):
|
|
194
|
+
"""Long random-walk line (river-like)."""
|
|
195
|
+
rng = np.random.default_rng(42)
|
|
196
|
+
n = 300
|
|
197
|
+
x = np.cumsum(rng.standard_normal(n) * 0.2)
|
|
198
|
+
y = np.cumsum(rng.standard_normal(n) * 0.2)
|
|
199
|
+
line = LineString(np.column_stack([x, y]))
|
|
200
|
+
result = smoothify(line, segment_length=0.5)
|
|
201
|
+
assert isinstance(result, LineString)
|
|
202
|
+
|
|
203
|
+
def test_many_vertices(self):
|
|
204
|
+
coords = [(i * 0.1, math.sin(i * 0.1) * 5) for i in range(500)]
|
|
205
|
+
line = LineString(coords)
|
|
206
|
+
result = smoothify(line, segment_length=0.5)
|
|
207
|
+
assert isinstance(result, LineString)
|
|
208
|
+
|
|
209
|
+
def test_hairpin_turn(self):
|
|
210
|
+
"""Tight U-turn."""
|
|
211
|
+
line = LineString([(0, 0), (10, 0), (10.5, 0.5), (10, 1), (0, 1)])
|
|
212
|
+
result = smoothify(line, segment_length=0.5)
|
|
213
|
+
assert isinstance(result, LineString)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# LinearRings
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
class TestLinearRing:
|
|
220
|
+
"""LinearRing inputs."""
|
|
221
|
+
|
|
222
|
+
def test_simple_ring(self):
|
|
223
|
+
ring = LinearRing([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
224
|
+
result = smoothify(ring, segment_length=1.0)
|
|
225
|
+
assert isinstance(result, (LinearRing, Polygon))
|
|
226
|
+
assert result.is_valid
|
|
227
|
+
|
|
228
|
+
def test_triangular_ring(self):
|
|
229
|
+
ring = LinearRing([(0, 0), (10, 0), (5, 8)])
|
|
230
|
+
result = smoothify(ring, segment_length=1.0)
|
|
231
|
+
assert isinstance(result, (LinearRing, Polygon))
|
|
232
|
+
assert result.is_valid
|
|
233
|
+
|
|
234
|
+
def test_complex_ring(self):
|
|
235
|
+
"""Ring with many vertices."""
|
|
236
|
+
n = 50
|
|
237
|
+
coords = [
|
|
238
|
+
(10 * math.cos(2 * math.pi * i / n),
|
|
239
|
+
10 * math.sin(2 * math.pi * i / n))
|
|
240
|
+
for i in range(n)
|
|
241
|
+
]
|
|
242
|
+
ring = LinearRing(coords)
|
|
243
|
+
result = smoothify(ring, segment_length=0.5)
|
|
244
|
+
assert isinstance(result, (LinearRing, Polygon))
|
|
245
|
+
assert result.is_valid
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# MultiPolygons
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
class TestMultiPolygonSimple:
|
|
252
|
+
"""Simple MultiPolygon inputs."""
|
|
253
|
+
|
|
254
|
+
def test_two_squares(self):
|
|
255
|
+
p1 = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
|
|
256
|
+
p2 = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
|
|
257
|
+
mp = MultiPolygon([p1, p2])
|
|
258
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
259
|
+
assert result.is_valid
|
|
260
|
+
|
|
261
|
+
def test_single_polygon_multi(self):
|
|
262
|
+
"""MultiPolygon with one polygon."""
|
|
263
|
+
p = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
264
|
+
mp = MultiPolygon([p])
|
|
265
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
266
|
+
assert result.is_valid
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestMultiPolygonComplex:
|
|
270
|
+
"""Complex MultiPolygon inputs."""
|
|
271
|
+
|
|
272
|
+
def test_adjacent_squares_merged(self):
|
|
273
|
+
"""Adjacent polygons that should merge."""
|
|
274
|
+
p1 = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
275
|
+
p2 = Polygon([(10, 0), (20, 0), (20, 10), (10, 10)])
|
|
276
|
+
mp = MultiPolygon([p1, p2])
|
|
277
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=True)
|
|
278
|
+
assert result.is_valid
|
|
279
|
+
|
|
280
|
+
def test_many_small_polygons(self):
|
|
281
|
+
"""Grid of small polygons."""
|
|
282
|
+
polys = [
|
|
283
|
+
Polygon([(i * 15, j * 15), (i * 15 + 5, j * 15),
|
|
284
|
+
(i * 15 + 5, j * 15 + 5), (i * 15, j * 15 + 5)])
|
|
285
|
+
for i in range(4) for j in range(4)
|
|
286
|
+
]
|
|
287
|
+
mp = MultiPolygon(polys)
|
|
288
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
289
|
+
assert result.is_valid
|
|
290
|
+
|
|
291
|
+
def test_mixed_size_polygons(self):
|
|
292
|
+
tiny = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
|
|
293
|
+
large = Polygon([(50, 50), (150, 50), (150, 150), (50, 150)])
|
|
294
|
+
mp = MultiPolygon([tiny, large])
|
|
295
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
296
|
+
assert result.is_valid
|
|
297
|
+
|
|
298
|
+
def test_polygon_with_holes_in_multi(self):
|
|
299
|
+
exterior1 = [(0, 0), (30, 0), (30, 30), (0, 30)]
|
|
300
|
+
hole1 = [(5, 5), (15, 5), (15, 15), (5, 15)]
|
|
301
|
+
p1 = Polygon(exterior1, [hole1])
|
|
302
|
+
p2 = Polygon([(50, 50), (60, 50), (60, 60), (50, 60)])
|
|
303
|
+
mp = MultiPolygon([p1, p2])
|
|
304
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
305
|
+
assert result.is_valid
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# MultiLineStrings
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
class TestMultiLineStringSimple:
|
|
312
|
+
"""Simple MultiLineString inputs."""
|
|
313
|
+
|
|
314
|
+
def test_two_lines(self):
|
|
315
|
+
ml = MultiLineString([
|
|
316
|
+
[(0, 0), (5, 5)],
|
|
317
|
+
[(10, 0), (15, 5)],
|
|
318
|
+
])
|
|
319
|
+
result = smoothify(ml, segment_length=1.0)
|
|
320
|
+
assert result.is_valid
|
|
321
|
+
|
|
322
|
+
def test_single_line_multi(self):
|
|
323
|
+
ml = MultiLineString([[(0, 0), (5, 5), (10, 0)]])
|
|
324
|
+
result = smoothify(ml, segment_length=1.0)
|
|
325
|
+
assert result.is_valid
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestMultiLineStringComplex:
|
|
329
|
+
"""Complex MultiLineString inputs."""
|
|
330
|
+
|
|
331
|
+
def test_many_lines(self):
|
|
332
|
+
lines = [
|
|
333
|
+
[(i * 10, 0), (i * 10 + 5, 5), (i * 10 + 10, 0)]
|
|
334
|
+
for i in range(10)
|
|
335
|
+
]
|
|
336
|
+
ml = MultiLineString(lines)
|
|
337
|
+
result = smoothify(ml, segment_length=1.0)
|
|
338
|
+
assert result.is_valid
|
|
339
|
+
|
|
340
|
+
def test_zigzag_lines(self):
|
|
341
|
+
lines = []
|
|
342
|
+
for j in range(5):
|
|
343
|
+
coords = [(i, (i % 2) * 3 + j * 10) for i in range(15)]
|
|
344
|
+
lines.append(coords)
|
|
345
|
+
ml = MultiLineString(lines)
|
|
346
|
+
result = smoothify(ml, segment_length=0.5)
|
|
347
|
+
assert result.is_valid
|
|
348
|
+
|
|
349
|
+
def test_long_and_short_lines(self):
|
|
350
|
+
short = LineString([(0, 0), (1, 1)])
|
|
351
|
+
long_line = LineString([(10, 10)] + [(10 + i, 10 + math.sin(i)) for i in range(100)])
|
|
352
|
+
ml = MultiLineString([short, long_line])
|
|
353
|
+
result = smoothify(ml, segment_length=1.0)
|
|
354
|
+
assert result.is_valid
|
|
355
|
+
|
|
356
|
+
def test_self_intersecting_multilinestring(self):
|
|
357
|
+
"""MultiLineString containing a self-intersecting line."""
|
|
358
|
+
si_line = LineString([
|
|
359
|
+
(0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
|
|
360
|
+
(2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
|
|
361
|
+
])
|
|
362
|
+
normal_line = LineString([(10, 0), (15, 5), (20, 0)])
|
|
363
|
+
ml = MultiLineString([si_line, normal_line])
|
|
364
|
+
result = smoothify(ml, segment_length=0.5)
|
|
365
|
+
assert result.is_valid
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# GeometryCollections
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
class TestGeometryCollectionSimple:
|
|
372
|
+
"""Simple GeometryCollection inputs."""
|
|
373
|
+
|
|
374
|
+
def test_polygon_and_line(self):
|
|
375
|
+
poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
376
|
+
line = LineString([(20, 0), (30, 10)])
|
|
377
|
+
gc = GeometryCollection([poly, line])
|
|
378
|
+
result = smoothify(gc, segment_length=1.0)
|
|
379
|
+
assert isinstance(result, GeometryCollection)
|
|
380
|
+
assert result.is_valid
|
|
381
|
+
|
|
382
|
+
def test_single_polygon_collection(self):
|
|
383
|
+
poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
384
|
+
gc = GeometryCollection([poly])
|
|
385
|
+
result = smoothify(gc, segment_length=1.0)
|
|
386
|
+
assert result.is_valid
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class TestGeometryCollectionComplex:
|
|
390
|
+
"""Complex GeometryCollection inputs."""
|
|
391
|
+
|
|
392
|
+
def test_all_supported_types(self):
|
|
393
|
+
"""Collection with every supported geometry type."""
|
|
394
|
+
poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
395
|
+
line = LineString([(20, 0), (25, 5), (30, 0)])
|
|
396
|
+
mp = MultiPolygon([
|
|
397
|
+
Polygon([(40, 0), (45, 0), (45, 5), (40, 5)]),
|
|
398
|
+
Polygon([(50, 0), (55, 0), (55, 5), (50, 5)]),
|
|
399
|
+
])
|
|
400
|
+
ml = MultiLineString([
|
|
401
|
+
[(60, 0), (65, 5)],
|
|
402
|
+
[(70, 0), (75, 5)],
|
|
403
|
+
])
|
|
404
|
+
gc = GeometryCollection([poly, line, mp, ml])
|
|
405
|
+
result = smoothify(gc, segment_length=1.0)
|
|
406
|
+
assert result.is_valid
|
|
407
|
+
|
|
408
|
+
def test_many_mixed_geometries(self):
|
|
409
|
+
geoms = []
|
|
410
|
+
for i in range(5):
|
|
411
|
+
geoms.append(Polygon([
|
|
412
|
+
(i * 20, 0), (i * 20 + 8, 0),
|
|
413
|
+
(i * 20 + 8, 8), (i * 20, 8),
|
|
414
|
+
]))
|
|
415
|
+
for i in range(5):
|
|
416
|
+
geoms.append(LineString([
|
|
417
|
+
(i * 20, 20), (i * 20 + 5, 25), (i * 20 + 10, 20),
|
|
418
|
+
]))
|
|
419
|
+
gc = GeometryCollection(geoms)
|
|
420
|
+
result = smoothify(gc, segment_length=1.0)
|
|
421
|
+
assert result.is_valid
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# List inputs
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
class TestListInput:
|
|
428
|
+
"""List of geometries inputs."""
|
|
429
|
+
|
|
430
|
+
def test_list_of_polygons(self):
|
|
431
|
+
polys = [
|
|
432
|
+
Polygon([(i * 20, 0), (i * 20 + 10, 0),
|
|
433
|
+
(i * 20 + 10, 10), (i * 20, 10)])
|
|
434
|
+
for i in range(3)
|
|
435
|
+
]
|
|
436
|
+
result = smoothify(polys, segment_length=1.0)
|
|
437
|
+
assert result.is_valid
|
|
438
|
+
|
|
439
|
+
def test_list_of_linestrings(self):
|
|
440
|
+
lines = [
|
|
441
|
+
LineString([(i * 20, 0), (i * 20 + 5, 5), (i * 20 + 10, 0)])
|
|
442
|
+
for i in range(3)
|
|
443
|
+
]
|
|
444
|
+
result = smoothify(lines, segment_length=1.0)
|
|
445
|
+
assert result.is_valid
|
|
446
|
+
|
|
447
|
+
def test_mixed_list(self):
|
|
448
|
+
geoms = [
|
|
449
|
+
Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
|
|
450
|
+
LineString([(20, 0), (30, 10)]),
|
|
451
|
+
]
|
|
452
|
+
result = smoothify(geoms, segment_length=1.0)
|
|
453
|
+
assert result.is_valid
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Smoothing parameters across types
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
class TestSmoothingParamsAllTypes:
|
|
460
|
+
"""Test different smooth_iterations and segment_length across types."""
|
|
461
|
+
|
|
462
|
+
@pytest.fixture(params=[1, 3, 5])
|
|
463
|
+
def iterations(self, request):
|
|
464
|
+
return request.param
|
|
465
|
+
|
|
466
|
+
@pytest.fixture(params=[0.5, 1.0, 5.0])
|
|
467
|
+
def seg_len(self, request):
|
|
468
|
+
return request.param
|
|
469
|
+
|
|
470
|
+
def test_polygon_iterations(self, iterations, seg_len):
|
|
471
|
+
poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
|
|
472
|
+
result = smoothify(poly, segment_length=seg_len, smooth_iterations=iterations)
|
|
473
|
+
assert isinstance(result, Polygon)
|
|
474
|
+
assert result.is_valid
|
|
475
|
+
|
|
476
|
+
def test_linestring_iterations(self, iterations, seg_len):
|
|
477
|
+
line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
|
|
478
|
+
result = smoothify(line, segment_length=seg_len, smooth_iterations=iterations)
|
|
479
|
+
assert isinstance(result, LineString)
|
|
480
|
+
|
|
481
|
+
def test_multipolygon_iterations(self, iterations, seg_len):
|
|
482
|
+
mp = MultiPolygon([
|
|
483
|
+
Polygon([(0, 0), (8, 0), (8, 8), (0, 8)]),
|
|
484
|
+
Polygon([(20, 0), (28, 0), (28, 8), (20, 8)]),
|
|
485
|
+
])
|
|
486
|
+
result = smoothify(mp, segment_length=seg_len,
|
|
487
|
+
smooth_iterations=iterations, merge_multipolygons=False)
|
|
488
|
+
assert result.is_valid
|
|
489
|
+
|
|
490
|
+
def test_multilinestring_iterations(self, iterations, seg_len):
|
|
491
|
+
ml = MultiLineString([
|
|
492
|
+
[(0, 0), (5, 5), (10, 0)],
|
|
493
|
+
[(20, 0), (25, 5), (30, 0)],
|
|
494
|
+
])
|
|
495
|
+
result = smoothify(ml, segment_length=seg_len, smooth_iterations=iterations)
|
|
496
|
+
assert result.is_valid
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# Area preservation across polygon types
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
class TestAreaPreservation:
|
|
503
|
+
"""Area preservation for different polygon shapes."""
|
|
504
|
+
|
|
505
|
+
@pytest.mark.parametrize("preserve", [True, False])
|
|
506
|
+
def test_square_area(self, preserve):
|
|
507
|
+
poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
|
|
508
|
+
result = smoothify(poly, segment_length=5.0, preserve_area=preserve)
|
|
509
|
+
assert isinstance(result, Polygon)
|
|
510
|
+
if preserve:
|
|
511
|
+
assert abs(result.area - poly.area) / poly.area < 0.05
|
|
512
|
+
|
|
513
|
+
@pytest.mark.parametrize("preserve", [True, False])
|
|
514
|
+
def test_star_area(self, preserve):
|
|
515
|
+
coords = []
|
|
516
|
+
for i in range(10):
|
|
517
|
+
angle = math.radians(36 * i)
|
|
518
|
+
r = 20 if i % 2 == 0 else 8
|
|
519
|
+
coords.append((r * math.cos(angle), r * math.sin(angle)))
|
|
520
|
+
poly = Polygon(coords)
|
|
521
|
+
result = smoothify(poly, segment_length=1.0, preserve_area=preserve)
|
|
522
|
+
assert isinstance(result, Polygon)
|
|
523
|
+
assert result.is_valid
|
|
524
|
+
|
|
525
|
+
@pytest.mark.parametrize("preserve", [True, False])
|
|
526
|
+
def test_polygon_with_hole_area(self, preserve):
|
|
527
|
+
exterior = [(0, 0), (50, 0), (50, 50), (0, 50)]
|
|
528
|
+
hole = [(15, 15), (35, 15), (35, 35), (15, 35)]
|
|
529
|
+
poly = Polygon(exterior, [hole])
|
|
530
|
+
result = smoothify(poly, segment_length=1.0, preserve_area=preserve)
|
|
531
|
+
assert result.is_valid
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
# Degenerate / tricky geometries
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
class TestDegenerateGeometries:
|
|
538
|
+
"""Degenerate, invalid, or tricky geometries that stress edge cases."""
|
|
539
|
+
|
|
540
|
+
def test_bowtie_polygon(self):
|
|
541
|
+
"""Self-intersecting (bowtie) polygon — must be made valid first."""
|
|
542
|
+
geom = wkt.loads("POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))")
|
|
543
|
+
assert not geom.is_valid
|
|
544
|
+
geom = make_valid(geom)
|
|
545
|
+
result = smoothify(geom, segment_length=0.5)
|
|
546
|
+
assert result.is_valid
|
|
547
|
+
|
|
548
|
+
def test_polygon_hole_touching_exterior(self):
|
|
549
|
+
"""Hole that shares a vertex with the exterior ring."""
|
|
550
|
+
geom = wkt.loads(
|
|
551
|
+
"POLYGON((0 0,10 0,10 10,0 10,0 0),(5 0,8 3,5 6,2 3,5 0))"
|
|
552
|
+
)
|
|
553
|
+
assert geom.is_valid
|
|
554
|
+
result = smoothify(geom, segment_length=1.0)
|
|
555
|
+
assert result.is_valid
|
|
556
|
+
|
|
557
|
+
def test_spike_polygon(self):
|
|
558
|
+
"""Polygon with a degenerate zero-width spike."""
|
|
559
|
+
geom = wkt.loads("POLYGON((0 0,10 0,10 10,5 10,5 0,0 0))")
|
|
560
|
+
# Shapely treats this as invalid; make_valid turns it into a collection
|
|
561
|
+
geom = make_valid(geom)
|
|
562
|
+
result = smoothify(geom, segment_length=1.0)
|
|
563
|
+
assert result.is_valid
|
|
564
|
+
|
|
565
|
+
def test_multipolygon_touching_at_edge(self):
|
|
566
|
+
"""Two polygons sharing an entire edge — invalid MultiPolygon."""
|
|
567
|
+
geom = wkt.loads(
|
|
568
|
+
"MULTIPOLYGON(((0 0,5 0,5 5,0 5,0 0)),((5 0,10 0,10 5,5 5,5 0)))"
|
|
569
|
+
)
|
|
570
|
+
geom = make_valid(geom)
|
|
571
|
+
result = smoothify(geom, segment_length=1.0)
|
|
572
|
+
assert result.is_valid
|
|
573
|
+
|
|
574
|
+
def test_empty_polygon(self):
|
|
575
|
+
geom = wkt.loads("POLYGON EMPTY")
|
|
576
|
+
result = smoothify(geom, segment_length=1.0)
|
|
577
|
+
assert result.is_empty
|
|
578
|
+
|
|
579
|
+
def test_empty_linestring(self):
|
|
580
|
+
geom = LineString()
|
|
581
|
+
result = smoothify(geom, segment_length=1.0)
|
|
582
|
+
assert result.is_empty
|
|
583
|
+
|
|
584
|
+
def test_empty_multipolygon(self):
|
|
585
|
+
geom = MultiPolygon()
|
|
586
|
+
result = smoothify(geom, segment_length=1.0)
|
|
587
|
+
assert result.is_valid
|
|
588
|
+
|
|
589
|
+
def test_empty_multilinestring(self):
|
|
590
|
+
geom = MultiLineString()
|
|
591
|
+
result = smoothify(geom, segment_length=1.0)
|
|
592
|
+
assert result.is_valid
|
|
593
|
+
|
|
594
|
+
def test_duplicate_points_linestring(self):
|
|
595
|
+
"""LineString with consecutive duplicate points."""
|
|
596
|
+
geom = wkt.loads("LINESTRING(0 0,0 0,1 1,2 2,2 2)")
|
|
597
|
+
result = smoothify(geom, segment_length=0.5)
|
|
598
|
+
assert isinstance(result, LineString)
|
|
599
|
+
assert result.is_valid
|
|
600
|
+
|
|
601
|
+
def test_near_zero_area_polygon(self):
|
|
602
|
+
"""Extremely thin polygon that is nearly degenerate."""
|
|
603
|
+
poly = Polygon([(0, 0), (100, 0), (100, 0.001), (0, 0.001)])
|
|
604
|
+
result = smoothify(poly, segment_length=0.01)
|
|
605
|
+
assert result.is_valid
|
|
606
|
+
|
|
607
|
+
def test_polygon_with_collinear_points(self):
|
|
608
|
+
"""Polygon where several consecutive vertices are collinear."""
|
|
609
|
+
poly = Polygon([
|
|
610
|
+
(0, 0), (2, 0), (5, 0), (8, 0), (10, 0),
|
|
611
|
+
(10, 10), (0, 10),
|
|
612
|
+
])
|
|
613
|
+
result = smoothify(poly, segment_length=1.0)
|
|
614
|
+
assert isinstance(result, Polygon)
|
|
615
|
+
assert result.is_valid
|
|
616
|
+
|
|
617
|
+
def test_linestring_with_collinear_points(self):
|
|
618
|
+
"""LineString where all points are collinear."""
|
|
619
|
+
line = LineString([(0, 0), (1, 0), (2, 0), (5, 0), (10, 0)])
|
|
620
|
+
result = smoothify(line, segment_length=1.0)
|
|
621
|
+
assert isinstance(result, LineString)
|
|
622
|
+
|
|
623
|
+
def test_almost_closed_linestring(self):
|
|
624
|
+
"""LineString whose start and end are nearly identical."""
|
|
625
|
+
line = LineString([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.001)])
|
|
626
|
+
result = smoothify(line, segment_length=1.0)
|
|
627
|
+
assert isinstance(result, LineString)
|
|
628
|
+
|
|
629
|
+
def test_polygon_hole_nearly_fills_exterior(self):
|
|
630
|
+
"""Hole that is almost as large as the exterior."""
|
|
631
|
+
exterior = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
|
632
|
+
hole = [(0.5, 0.5), (9.5, 0.5), (9.5, 9.5), (0.5, 9.5)]
|
|
633
|
+
poly = Polygon(exterior, [hole])
|
|
634
|
+
result = smoothify(poly, segment_length=0.5)
|
|
635
|
+
assert result.is_valid
|
|
636
|
+
|
|
637
|
+
def test_reversed_ring_orientation(self):
|
|
638
|
+
"""Polygon with clockwise exterior (non-standard orientation)."""
|
|
639
|
+
# Shapely normalises this, but real files may have it
|
|
640
|
+
poly = Polygon([(0, 0), (0, 10), (10, 10), (10, 0)]) # CW
|
|
641
|
+
result = smoothify(poly, segment_length=1.0)
|
|
642
|
+
assert isinstance(result, Polygon)
|
|
643
|
+
assert result.is_valid
|
|
644
|
+
|
|
645
|
+
def test_zero_area_ring(self):
|
|
646
|
+
"""Polygon that degenerates to a line (zero area)."""
|
|
647
|
+
poly = Polygon([(0, 0), (10, 0), (5, 0)])
|
|
648
|
+
# Shapely makes this empty/invalid
|
|
649
|
+
if poly.is_empty or not poly.is_valid:
|
|
650
|
+
poly = make_valid(poly)
|
|
651
|
+
result = smoothify(poly, segment_length=1.0)
|
|
652
|
+
assert result.is_valid
|
|
653
|
+
|
|
654
|
+
def test_nearly_coincident_vertices(self):
|
|
655
|
+
"""Polygon with vertices separated by < 1e-10."""
|
|
656
|
+
poly = Polygon([
|
|
657
|
+
(0, 0), (10, 0), (10, 1e-11), (10, 10), (0, 10),
|
|
658
|
+
])
|
|
659
|
+
result = smoothify(poly, segment_length=1.0)
|
|
660
|
+
assert result.is_valid
|
|
661
|
+
|
|
662
|
+
def test_overlapping_multipolygon(self):
|
|
663
|
+
"""MultiPolygon with overlapping members."""
|
|
664
|
+
p1 = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
|
665
|
+
p2 = Polygon([(5, 5), (15, 5), (15, 15), (5, 15)])
|
|
666
|
+
geom = make_valid(MultiPolygon([p1, p2]))
|
|
667
|
+
result = smoothify(geom, segment_length=1.0)
|
|
668
|
+
assert result.is_valid
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Z coordinates
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
class TestZCoordinates:
|
|
675
|
+
"""Geometries with Z dimension (common in real shapefiles)."""
|
|
676
|
+
|
|
677
|
+
def test_polygon_z(self):
|
|
678
|
+
poly = Polygon([(0, 0, 5), (10, 0, 5), (10, 10, 5), (0, 10, 5)])
|
|
679
|
+
result = smoothify(poly, segment_length=1.0)
|
|
680
|
+
assert isinstance(result, Polygon)
|
|
681
|
+
assert result.is_valid
|
|
682
|
+
|
|
683
|
+
def test_linestring_z(self):
|
|
684
|
+
line = LineString([(0, 0, 0), (5, 5, 10), (10, 0, 20)])
|
|
685
|
+
result = smoothify(line, segment_length=1.0)
|
|
686
|
+
assert isinstance(result, LineString)
|
|
687
|
+
|
|
688
|
+
def test_multipolygon_z(self):
|
|
689
|
+
p1 = Polygon([(0, 0, 1), (5, 0, 1), (5, 5, 1), (0, 5, 1)])
|
|
690
|
+
p2 = Polygon([(20, 20, 2), (25, 20, 2), (25, 25, 2), (20, 25, 2)])
|
|
691
|
+
mp = MultiPolygon([p1, p2])
|
|
692
|
+
result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
|
|
693
|
+
assert result.is_valid
|
|
694
|
+
|
|
695
|
+
def test_multilinestring_z(self):
|
|
696
|
+
ml = MultiLineString([
|
|
697
|
+
[(0, 0, 0), (5, 5, 10)],
|
|
698
|
+
[(10, 0, 0), (15, 5, 10)],
|
|
699
|
+
])
|
|
700
|
+
result = smoothify(ml, segment_length=1.0)
|
|
701
|
+
assert result.is_valid
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
# Numeric edge cases
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
class TestNumericEdgeCases:
|
|
708
|
+
"""Extreme coordinate values and numeric stability."""
|
|
709
|
+
|
|
710
|
+
def test_very_large_coordinates(self):
|
|
711
|
+
"""Coordinates typical of projected CRS (e.g. UTM easting/northing)."""
|
|
712
|
+
poly = Polygon([
|
|
713
|
+
(500000, 6000000), (500100, 6000000),
|
|
714
|
+
(500100, 6000100), (500000, 6000100),
|
|
715
|
+
])
|
|
716
|
+
result = smoothify(poly, segment_length=10.0)
|
|
717
|
+
assert isinstance(result, Polygon)
|
|
718
|
+
assert result.is_valid
|
|
719
|
+
|
|
720
|
+
def test_very_small_coordinates(self):
|
|
721
|
+
"""Tiny fractional coordinates."""
|
|
722
|
+
poly = Polygon([
|
|
723
|
+
(0, 0), (1e-6, 0), (1e-6, 1e-6), (0, 1e-6),
|
|
724
|
+
])
|
|
725
|
+
result = smoothify(poly, segment_length=1e-7)
|
|
726
|
+
assert isinstance(result, Polygon)
|
|
727
|
+
assert result.is_valid
|
|
728
|
+
|
|
729
|
+
def test_large_coordinate_linestring(self):
|
|
730
|
+
line = LineString([
|
|
731
|
+
(1e7, 1e7), (1e7 + 50, 1e7 + 50), (1e7 + 100, 1e7),
|
|
732
|
+
])
|
|
733
|
+
result = smoothify(line, segment_length=5.0)
|
|
734
|
+
assert isinstance(result, LineString)
|
|
735
|
+
|
|
736
|
+
def test_negative_coordinates(self):
|
|
737
|
+
poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
|
|
738
|
+
result = smoothify(poly, segment_length=1.0)
|
|
739
|
+
assert isinstance(result, Polygon)
|
|
740
|
+
assert result.is_valid
|
|
741
|
+
|
|
742
|
+
def test_mixed_sign_coordinates(self):
|
|
743
|
+
"""Geometry spanning the origin."""
|
|
744
|
+
line = LineString([(-10, -5), (0, 5), (10, -5)])
|
|
745
|
+
result = smoothify(line, segment_length=1.0)
|
|
746
|
+
assert isinstance(result, LineString)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# Collapse / degenerate output cases
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
class TestCollapseOutputs:
|
|
753
|
+
"""Inputs that may collapse or degenerate after smoothing."""
|
|
754
|
+
|
|
755
|
+
def test_tiny_polygon_survives(self):
|
|
756
|
+
"""Very small polygon should not vanish."""
|
|
757
|
+
poly = Polygon([(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)])
|
|
758
|
+
result = smoothify(poly, segment_length=0.01)
|
|
759
|
+
assert result.is_valid
|
|
760
|
+
# Should still have some area
|
|
761
|
+
if isinstance(result, Polygon):
|
|
762
|
+
assert result.area > 0
|
|
763
|
+
|
|
764
|
+
def test_tiny_triangle(self):
|
|
765
|
+
poly = Polygon([(0, 0), (0.01, 0), (0.005, 0.01)])
|
|
766
|
+
result = smoothify(poly, segment_length=0.001)
|
|
767
|
+
assert result.is_valid
|
|
768
|
+
|
|
769
|
+
def test_very_short_linestring(self):
|
|
770
|
+
"""Two-point line shorter than segment_length."""
|
|
771
|
+
line = LineString([(0, 0), (0.01, 0.01)])
|
|
772
|
+
result = smoothify(line, segment_length=1.0)
|
|
773
|
+
assert isinstance(result, LineString)
|
|
774
|
+
|
|
775
|
+
def test_large_segment_length_polygon(self):
|
|
776
|
+
"""segment_length much larger than the geometry — aggressive simplification."""
|
|
777
|
+
poly = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
|
|
778
|
+
result = smoothify(poly, segment_length=100.0)
|
|
779
|
+
assert result.is_valid
|
|
780
|
+
|
|
781
|
+
def test_large_segment_length_linestring(self):
|
|
782
|
+
line = LineString([(0, 0), (1, 1), (2, 0), (3, 1), (4, 0)])
|
|
783
|
+
result = smoothify(line, segment_length=100.0)
|
|
784
|
+
assert isinstance(result, LineString)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# ---------------------------------------------------------------------------
|
|
788
|
+
# Invariant / property tests
|
|
789
|
+
# ---------------------------------------------------------------------------
|
|
790
|
+
class TestInvariants:
|
|
791
|
+
"""Property-based invariants that should hold for all smoothing operations."""
|
|
792
|
+
|
|
793
|
+
def test_idempotence_polygon(self):
|
|
794
|
+
"""Smoothing twice should not drift much after the first pass."""
|
|
795
|
+
poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
|
|
796
|
+
once = smoothify(poly, segment_length=2.0)
|
|
797
|
+
twice = smoothify(once, segment_length=2.0)
|
|
798
|
+
assert isinstance(once, Polygon)
|
|
799
|
+
assert isinstance(twice, Polygon)
|
|
800
|
+
# Second pass should change area by < 5%
|
|
801
|
+
assert abs(twice.area - once.area) / once.area < 0.05
|
|
802
|
+
|
|
803
|
+
def test_idempotence_linestring(self):
|
|
804
|
+
line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
|
|
805
|
+
once = smoothify(line, segment_length=1.0)
|
|
806
|
+
twice = smoothify(once, segment_length=1.0)
|
|
807
|
+
assert isinstance(once, LineString)
|
|
808
|
+
assert isinstance(twice, LineString)
|
|
809
|
+
# Length should not drift wildly
|
|
810
|
+
assert abs(twice.length - once.length) / once.length < 0.15
|
|
811
|
+
|
|
812
|
+
def test_bounded_area_distortion_polygon(self):
|
|
813
|
+
"""Area change should be bounded (within 10% without preserve_area)."""
|
|
814
|
+
poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
|
|
815
|
+
result = smoothify(poly, segment_length=5.0, preserve_area=False)
|
|
816
|
+
assert isinstance(result, Polygon)
|
|
817
|
+
ratio = result.area / poly.area
|
|
818
|
+
assert 0.8 < ratio < 1.2, f"Area ratio {ratio:.3f} outside bounds"
|
|
819
|
+
|
|
820
|
+
def test_area_preservation_tight(self):
|
|
821
|
+
"""With preserve_area=True, area should be within tolerance."""
|
|
822
|
+
poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
|
|
823
|
+
result = smoothify(poly, segment_length=5.0, preserve_area=True,
|
|
824
|
+
area_tolerance=0.01)
|
|
825
|
+
assert isinstance(result, Polygon)
|
|
826
|
+
pct_error = abs(result.area - poly.area) / poly.area * 100
|
|
827
|
+
assert pct_error < 1.0, f"Area error {pct_error:.4f}% exceeds 1%"
|
|
828
|
+
|
|
829
|
+
def test_hausdorff_distance_bounded_polygon(self):
|
|
830
|
+
"""Smoothed polygon should stay close to original."""
|
|
831
|
+
poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
|
|
832
|
+
result = smoothify(poly, segment_length=2.0)
|
|
833
|
+
assert isinstance(result, Polygon)
|
|
834
|
+
dist = poly.hausdorff_distance(result)
|
|
835
|
+
# Should not drift more than a few segment_lengths
|
|
836
|
+
assert dist < 10.0, f"Hausdorff distance {dist:.2f} too large"
|
|
837
|
+
|
|
838
|
+
def test_hausdorff_distance_bounded_linestring(self):
|
|
839
|
+
"""Smoothed line should stay close to original."""
|
|
840
|
+
line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
|
|
841
|
+
result = smoothify(line, segment_length=1.0)
|
|
842
|
+
assert isinstance(result, LineString)
|
|
843
|
+
dist = line.hausdorff_distance(result)
|
|
844
|
+
assert dist < 5.0, f"Hausdorff distance {dist:.2f} too large"
|
|
845
|
+
|
|
846
|
+
def test_envelope_containment_polygon(self):
|
|
847
|
+
"""Smoothed polygon envelope should be similar to original."""
|
|
848
|
+
poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
|
|
849
|
+
result = smoothify(poly, segment_length=5.0)
|
|
850
|
+
assert isinstance(result, Polygon)
|
|
851
|
+
# Result bounding box should not be wildly different
|
|
852
|
+
orig_bounds = poly.bounds
|
|
853
|
+
res_bounds = result.bounds
|
|
854
|
+
for i in range(4):
|
|
855
|
+
assert abs(orig_bounds[i] - res_bounds[i]) < 10.0
|
|
856
|
+
|
|
857
|
+
def test_vertex_count_increases(self):
|
|
858
|
+
"""Smoothing should generally add vertices."""
|
|
859
|
+
poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
|
|
860
|
+
result = smoothify(poly, segment_length=2.0)
|
|
861
|
+
assert isinstance(result, Polygon)
|
|
862
|
+
assert len(result.exterior.coords) > len(poly.exterior.coords)
|
|
863
|
+
|
|
864
|
+
def test_output_always_valid(self):
|
|
865
|
+
"""Diverse set of inputs should always produce valid output."""
|
|
866
|
+
inputs = [
|
|
867
|
+
Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
|
|
868
|
+
LineString([(0, 0), (5, 5), (10, 0)]),
|
|
869
|
+
MultiPolygon([
|
|
870
|
+
Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]),
|
|
871
|
+
Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]),
|
|
872
|
+
]),
|
|
873
|
+
MultiLineString([[(0, 0), (5, 5)], [(10, 0), (15, 5)]]),
|
|
874
|
+
GeometryCollection([
|
|
875
|
+
Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
|
|
876
|
+
LineString([(20, 0), (30, 10)]),
|
|
877
|
+
]),
|
|
878
|
+
]
|
|
879
|
+
for geom in inputs:
|
|
880
|
+
result = smoothify(geom, segment_length=1.0)
|
|
881
|
+
assert result.is_valid, f"Invalid output for {geom.geom_type}"
|
|
882
|
+
|
|
883
|
+
def test_linestring_endpoints_preserved(self):
|
|
884
|
+
"""Endpoints must be preserved across various LineStrings."""
|
|
885
|
+
lines = [
|
|
886
|
+
LineString([(0, 0), (10, 10)]),
|
|
887
|
+
LineString([(0, 0), (5, 5), (10, 0)]),
|
|
888
|
+
LineString([(i, math.sin(i)) for i in range(20)]),
|
|
889
|
+
]
|
|
890
|
+
for line in lines:
|
|
891
|
+
result = smoothify(line, segment_length=1.0)
|
|
892
|
+
assert isinstance(result, LineString)
|
|
893
|
+
assert result.coords[0] == pytest.approx(line.coords[0], abs=1e-6)
|
|
894
|
+
assert result.coords[-1] == pytest.approx(line.coords[-1], abs=1e-6)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests using real-world water body data from NAIP OWM."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from smoothify import smoothify
|
|
9
|
+
|
|
10
|
+
TEST_DATA = Path(__file__).parent / "test_data" / "naip_owm_water_bodies.geojson"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def water_gdf():
|
|
15
|
+
return gpd.read_file(TEST_DATA)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestRealWorldWaterBodies:
|
|
19
|
+
"""Smoke tests to ensure smoothify works on real-world data with various settings."""
|
|
20
|
+
|
|
21
|
+
def test_default_settings(self, water_gdf):
|
|
22
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, num_cores=1)
|
|
23
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
24
|
+
assert len(result) == len(water_gdf)
|
|
25
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
26
|
+
|
|
27
|
+
def test_no_area_preservation(self, water_gdf):
|
|
28
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, preserve_area=False)
|
|
29
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
30
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
31
|
+
|
|
32
|
+
def test_high_smooth_iterations(self, water_gdf):
|
|
33
|
+
result = smoothify(geom=water_gdf, smooth_iterations=6)
|
|
34
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
35
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
36
|
+
|
|
37
|
+
def test_low_smooth_iterations(self, water_gdf):
|
|
38
|
+
result = smoothify(geom=water_gdf, smooth_iterations=1)
|
|
39
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
40
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
41
|
+
|
|
42
|
+
def test_zero_smooth_iterations(self, water_gdf):
|
|
43
|
+
result = smoothify(geom=water_gdf, smooth_iterations=0)
|
|
44
|
+
assert result is water_gdf
|
|
45
|
+
|
|
46
|
+
def test_explicit_segment_length(self, water_gdf):
|
|
47
|
+
result = smoothify(geom=water_gdf, segment_length=5.0, smooth_iterations=3)
|
|
48
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
49
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
50
|
+
|
|
51
|
+
def test_large_segment_length(self, water_gdf):
|
|
52
|
+
result = smoothify(geom=water_gdf, segment_length=20.0, smooth_iterations=3)
|
|
53
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
54
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
55
|
+
|
|
56
|
+
def test_strict_area_tolerance(self, water_gdf):
|
|
57
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, area_tolerance=0.001)
|
|
58
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
59
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
60
|
+
|
|
61
|
+
def test_relaxed_area_tolerance(self, water_gdf):
|
|
62
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, area_tolerance=1.0)
|
|
63
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
64
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
65
|
+
|
|
66
|
+
def test_merge_collection(self, water_gdf):
|
|
67
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, merge_collection=True)
|
|
68
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
69
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
70
|
+
|
|
71
|
+
def test_no_merge_collection(self, water_gdf):
|
|
72
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, merge_collection=False)
|
|
73
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
74
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
75
|
+
|
|
76
|
+
def test_parallel_processing(self, water_gdf):
|
|
77
|
+
result = smoothify(geom=water_gdf, smooth_iterations=3, num_cores=2)
|
|
78
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
79
|
+
assert len(result) == len(water_gdf)
|
|
80
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
81
|
+
|
|
82
|
+
def test_merge_field(self, water_gdf):
|
|
83
|
+
result = smoothify(
|
|
84
|
+
geom=water_gdf,
|
|
85
|
+
smooth_iterations=3,
|
|
86
|
+
merge_collection=True,
|
|
87
|
+
merge_field="class",
|
|
88
|
+
)
|
|
89
|
+
assert isinstance(result, gpd.GeoDataFrame)
|
|
90
|
+
assert all(geom.is_valid for geom in result.geometry)
|
|
@@ -143,6 +143,26 @@ class TestSmoothifyGeometry:
|
|
|
143
143
|
assert smoothed.coords[0] == line.coords[0]
|
|
144
144
|
assert smoothed.coords[-1] == line.coords[-1]
|
|
145
145
|
|
|
146
|
+
def test_smoothify_self_intersecting_linestring(self):
|
|
147
|
+
"""Test that a self-intersecting LineString is smoothed without error.
|
|
148
|
+
|
|
149
|
+
Lines that cross themselves are geometrically valid. The smoothing
|
|
150
|
+
pipeline must not split them into a MultiLineString via make_valid
|
|
151
|
+
or unary_union, which node lines at self-intersection points.
|
|
152
|
+
"""
|
|
153
|
+
# S-curve that crosses itself — triggers unary_union splitting
|
|
154
|
+
line = LineString([
|
|
155
|
+
(0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
|
|
156
|
+
(2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
|
|
157
|
+
])
|
|
158
|
+
assert not line.is_simple # confirm it self-intersects
|
|
159
|
+
|
|
160
|
+
smoothed = _smoothify_geometry(
|
|
161
|
+
line, segment_length=0.5, smooth_iterations=3, preserve_area=False
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert isinstance(smoothed, LineString)
|
|
165
|
+
|
|
146
166
|
def test_preserve_area_option(self):
|
|
147
167
|
"""Test that preserve_area option works."""
|
|
148
168
|
square = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|