Skip to content

Commit 3ee785c

Browse files
wrignj08claude
andcommitted
perf: speed up area preservation with Newton/Steiner buffer search
Replace the bracketed Brent's-method search in _preserve_area_with_buffer with a Steiner-quadratic seed (A(d) ~= A0 + L*d + pi*d^2) refined by Newton's method, using the measured boundary length as the derivative (dA/dd equals the offset's perimeter), so no bracketing is needed. This cuts buffer operations per ring from ~5-6 to ~1-2. On examples/Water.gpkg the full single-core pipeline is ~1.3x faster at the default 5 iterations (5.1s -> 3.8s). Output is unchanged within the area tolerance (per-polygon symmetric difference <= 0.005%, area accuracy identical). Shapes where Newton stalls (pinch-offs, topology changes near the root) fall back to the original Brent's-method search, preserved verbatim as _preserve_area_brentq. Adds 7 tests covering the Newton path, the fallback, buffer-count regression, and Newton/brentq agreement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a092a4c commit 3ee785c

3 files changed

Lines changed: 181 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [0.3.2] - 2026-06-30
8+
9+
### Changed
10+
- Area preservation is faster with no change to output. The buffer-distance search now seeds from the Steiner area expansion `A(d) ≈ A₀ + L·d + π·d²` — solving that quadratic for the initial offset instead of using a linear estimate — and refines with Newton's method using the measured boundary length as the derivative (the rate of area change of an outward offset equals its perimeter), so it no longer brackets the root before solving. This cuts buffer operations per ring from roughly 5–6 to 1–2. On `examples/Water.gpkg` the full single-core pipeline is ~1.3x faster at the default 5 iterations (5.1s → 3.8s), with the speedup scaling up with iteration count and the polygon share of the workload (down to ~1.0x on hole-dominated inputs). Output is unchanged within the area tolerance (per-polygon symmetric difference ≤ 0.005%, area-preservation accuracy identical). Awkward shapes where Newton stalls (pinch-offs or topology changes near the root) fall back to the previous bracketed Brent's-method search, so robustness is unchanged.
11+
712
## [0.3.1] - 2026-06-15
813

914
### Fixed

smoothify/smoothify_core.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
from typing import cast
23

34
import numpy as np
@@ -193,16 +194,30 @@ def _generate_starting_point_variants(
193194
)
194195

195196

197+
# Most rings land within tolerance in one or two Newton steps; allow a few
198+
# more for awkward shapes before handing off to the bracketed fallback.
199+
_AREA_NEWTON_MAX_STEPS = 6
200+
201+
196202
def _preserve_area_with_buffer(
197203
polygon: Polygon,
198204
target_area: float,
199205
tolerance: float = 1e-6,
200206
) -> Polygon:
201-
"""Restore original polygon area after smoothing via iterative buffering.
207+
"""Restore original polygon area after smoothing via buffering.
202208
203-
Smoothing operations can slightly change polygon area. This function uses
204-
Brent's method (root-finding algorithm) to find the optimal buffer distance
205-
that restores the original area within the specified tolerance."""
209+
Smoothing slightly changes polygon area; this finds the buffer distance that
210+
restores the original area to within ``tolerance``.
211+
212+
The buffered area follows the Steiner expansion ``A(d) ~= A0 + L*d + pi*d^2``
213+
(``L`` = perimeter), so we seed the search by solving that quadratic instead
214+
of the cruder linear estimate, then refine with Newton's method. The area
215+
swept by an outward offset changes at a rate equal to the boundary length
216+
(the coarea identity), so each buffered candidate yields a near-exact
217+
derivative for free and no bracketing is needed -- typically one or two
218+
buffers per ring instead of the half-dozen a bracketed root-find spends.
219+
Awkward shapes (pinch-offs, topology changes near the root) fall back to the
220+
robust Brent's-method search."""
206221

207222
if polygon.is_empty:
208223
return polygon
@@ -211,8 +226,63 @@ def _preserve_area_with_buffer(
211226
if abs(current_area - target_area) <= tolerance:
212227
return polygon
213228

214-
# Approximate buffer distance needed (assuming circular shape)
215229
perimeter = polygon.length
230+
if perimeter <= 0:
231+
return polygon
232+
233+
# Seed from the Steiner quadratic pi*d^2 + L*d + (A0 - target) = 0, taking
234+
# the root nearest zero. When the parabola never reaches the target (a deep
235+
# shrink past its vertex) fall back to the first-order estimate and let
236+
# Newton walk in.
237+
area_gap = current_area - target_area
238+
discriminant = perimeter * perimeter - 4.0 * math.pi * area_gap
239+
if discriminant >= 0:
240+
distance = (-perimeter + math.sqrt(discriminant)) / (2.0 * math.pi)
241+
else:
242+
distance = -area_gap / perimeter
243+
244+
best_result: Polygon | None = None
245+
best_error = float("inf")
246+
for _ in range(_AREA_NEWTON_MAX_STEPS):
247+
candidate = polygon.buffer(distance)
248+
candidate_area = candidate.area
249+
error = abs(candidate_area - target_area)
250+
if error < best_error:
251+
best_result, best_error = candidate, error
252+
if error <= tolerance:
253+
return candidate
254+
# dA/dd == boundary length of the current candidate (coarea identity).
255+
slope = candidate.length
256+
if slope <= 0:
257+
break
258+
next_distance = distance - (candidate_area - target_area) / slope
259+
if not math.isfinite(next_distance):
260+
break
261+
distance = next_distance
262+
263+
# Newton stalled before reaching tolerance: defer to the bracketed search,
264+
# but keep whichever result is actually closer to the target.
265+
fallback = _preserve_area_brentq(
266+
polygon, target_area, tolerance, current_area, perimeter
267+
)
268+
if fallback is not None and abs(fallback.area - target_area) < best_error:
269+
return fallback
270+
return best_result if best_result is not None else polygon
271+
272+
273+
def _preserve_area_brentq(
274+
polygon: Polygon,
275+
target_area: float,
276+
tolerance: float,
277+
current_area: float,
278+
perimeter: float,
279+
) -> Polygon | None:
280+
"""Bracketed Brent's-method area restoration (robust fallback).
281+
282+
Slower than the Newton path (it brackets the root before solving) but does
283+
not rely on a good initial estimate, so it covers shapes where Newton
284+
stalls. Returns ``None`` only if no usable buffer could be produced."""
285+
216286
initial_guess = (target_area - current_area) / perimeter if perimeter > 0 else 0
217287

218288
# Cache evaluations: brentq re-evaluates the bracket endpoints, and

tests/test_smoothify_core.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
import pytest
44
from shapely.geometry import LineString, Polygon
5+
from shapely.geometry.base import BaseGeometry
56

7+
from smoothify import smoothify_core
68
from smoothify.smoothify_core import (
79
_generate_starting_point_variants,
810
_join_adjacent,
11+
_preserve_area_brentq,
912
_preserve_area_with_buffer,
1013
_rotate_polygon_start,
1114
_smoothify_geometry,
@@ -89,6 +92,104 @@ def test_preserve_area_larger_polygon(self):
8992
assert abs(preserved.area - target_area) < 1e-3
9093
assert preserved.area < large_polygon.area
9194

95+
def test_preserve_area_empty(self):
96+
"""Empty input is returned unchanged."""
97+
empty = Polygon()
98+
assert _preserve_area_with_buffer(empty, target_area=10.0).is_empty
99+
100+
def test_preserve_area_concave_shape(self):
101+
"""Newton path reaches tolerance on a concave (non-convex) polygon."""
102+
# L-shape: the pi*d^2 Steiner seed assumes total turning of 2*pi, which
103+
# a reflex corner violates, so this exercises the Newton correction.
104+
l_shape = Polygon([(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)])
105+
for target in (l_shape.area * 1.05, l_shape.area * 0.95):
106+
preserved = _preserve_area_with_buffer(
107+
l_shape, target_area=target, tolerance=1e-4
108+
)
109+
assert abs(preserved.area - target) < 1e-4
110+
111+
def test_preserve_area_uses_few_buffers(self):
112+
"""The Newton solve should reach tolerance in far fewer buffers than the
113+
bracketed fallback would (guards the optimisation against regressions)."""
114+
polygon = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
115+
calls = {"n": 0}
116+
original_buffer = BaseGeometry.buffer
117+
118+
def counting_buffer(self, *args, **kwargs):
119+
calls["n"] += 1
120+
return original_buffer(self, *args, **kwargs)
121+
122+
BaseGeometry.buffer = counting_buffer
123+
try:
124+
preserved = _preserve_area_with_buffer(
125+
polygon, target_area=polygon.area * 1.1, tolerance=1e-4
126+
)
127+
finally:
128+
BaseGeometry.buffer = original_buffer
129+
130+
assert abs(preserved.area - polygon.area * 1.1) < 1e-4
131+
assert calls["n"] <= 4
132+
133+
def test_preserve_area_falls_back_to_brentq(self, monkeypatch):
134+
"""When Newton is denied any steps the function must still reach
135+
tolerance via the bracketed Brent's-method fallback."""
136+
monkeypatch.setattr(smoothify_core, "_AREA_NEWTON_MAX_STEPS", 0)
137+
polygon = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
138+
for target in (polygon.area * 1.1, polygon.area * 0.9):
139+
preserved = _preserve_area_with_buffer(
140+
polygon, target_area=target, tolerance=1e-3
141+
)
142+
assert abs(preserved.area - target) < 1e-3
143+
144+
def test_newton_and_brentq_agree(self):
145+
"""The Newton path and the bracketed fallback land on the same area."""
146+
polygon = Polygon([(0, 0), (8, 0), (8, 8), (0, 8)])
147+
target = polygon.area * 1.07
148+
newton = _preserve_area_with_buffer(polygon, target_area=target, tolerance=1e-4)
149+
brentq = _preserve_area_brentq(
150+
polygon,
151+
target_area=target,
152+
tolerance=1e-4,
153+
current_area=polygon.area,
154+
perimeter=polygon.length,
155+
)
156+
assert brentq is not None
157+
assert abs(newton.area - target) < 1e-4
158+
assert abs(brentq.area - target) < 1e-4
159+
assert abs(newton.area - brentq.area) < 1e-3
160+
161+
162+
class TestPreserveAreaBrentq:
163+
"""Test suite for the bracketed Brent's-method fallback."""
164+
165+
def test_brentq_grows_polygon(self):
166+
"""Fallback expands a polygon to a larger target area."""
167+
polygon = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
168+
result = _preserve_area_brentq(
169+
polygon,
170+
target_area=100.0,
171+
tolerance=1e-3,
172+
current_area=polygon.area,
173+
perimeter=polygon.length,
174+
)
175+
assert result is not None
176+
assert abs(result.area - 100.0) < 1e-3
177+
assert result.area > polygon.area
178+
179+
def test_brentq_shrinks_polygon(self):
180+
"""Fallback shrinks a polygon to a smaller target area."""
181+
polygon = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
182+
result = _preserve_area_brentq(
183+
polygon,
184+
target_area=100.0,
185+
tolerance=1e-3,
186+
current_area=polygon.area,
187+
perimeter=polygon.length,
188+
)
189+
assert result is not None
190+
assert abs(result.area - 100.0) < 1e-3
191+
assert result.area < polygon.area
192+
92193

93194
class TestJoinAdjacent:
94195
"""Test suite for joining adjacent geometries."""

0 commit comments

Comments
 (0)