Skip to content

Commit 0a18fc5

Browse files
wrignj08claude
andcommitted
perf: start variants at arc-length positions instead of densifying
Start-point variants now begin at evenly spaced arc-length positions, computed by interpolating a single new start vertex on the ring, rather than rotating to a vertex index on a fully densified ring. The old path segmentized the whole ring only so index-based rotation would land on evenly spaced points, but every vertex it added was collinear and immediately stripped by the following Douglas-Peucker. Feeding DP just the original vertices plus one cuts its input ~3.4x and the simplify step ~3.5x on examples/Water.gpkg (total single-core CPU work ~4.4s -> ~2.7s), for ~1.5x faster end-to-end than 0.3.2 with output unchanged within tolerance (per-polygon area drift <= 0.01%, worst concave turn unchanged). Also replace numpy.roll with a slice-based cyclic shift (_roll0) in the Chaikin and phase-alignment hot loops (bit-identical, fewer allocations), and remove the now-dead pre-simplify segmentize scaffolding. - Add tests: arc-length rotation preserves geometry and places the start at the requested fraction; _roll0 matches np.roll bit-for-bit. - Refresh stale comments/docstrings (union -> median, segmentize removed). - Update README "How It Works" to align 1:1 with the pipeline figure, and regenerate images/pipeline_steps.png plus its generator. - CHANGELOG: document the median-consensus merge and the arc-length speedup. - Re-run example notebooks and regenerate smoothed water output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95e70f3 commit 0a18fc5

11 files changed

Lines changed: 261 additions & 168 deletions

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Changed
8+
- Start-point variants are now merged by a per-point median consensus instead of a geometric union. Douglas-Peucker is start-vertex dependent, so the four rotated variants disagree slightly about where a feature's vertices land; the union superimposed those disagreements, whereas the median resamples the variants to a common point count, brings them into phase via an FFT cross-correlation, and takes the coordinate-wise median — a start-invariant consensus that outvotes a single bad variant instead of averaging toward it. This is also faster than the union it replaces (~1.3x end-to-end at the default 3 iterations, ~1.4x at 5, on `examples/Water.gpkg`). The median sits at the consensus interior rather than the outer envelope, so the merged ring is marginally smaller before area preservation restores it (with `preserve_area=True`); with `preserve_area=False` the small reduction is left as-is.
9+
- Faster smoothing with no meaningful change to output. Start-point variants now begin at evenly spaced arc-length positions, computed by interpolating a single new start vertex, rather than rotating to a vertex index on a fully densified ring. The old path segmentized the whole ring only so that index-based rotation would land on evenly spaced points — but every vertex it added was collinear and immediately stripped by the following Douglas-Peucker. Feeding DP just the original vertices plus one cuts its input roughly 3.4x and the simplify step ~3.5x on `examples/Water.gpkg` (total single-core CPU work ~4.4s → ~2.7s, about 1.25x faster end-to-end at the default 3 iterations). The per-point median phase-alignment and Chaikin corner cutting also replace `numpy.roll` with a slice-based cyclic shift (bit-identical, fewer allocations). Output is unchanged within tolerance (per-polygon area drift ≤ 0.01%, worst concave turn unchanged).
10+
11+
### Fixed
12+
- Eliminated the residual "dimple" fold that appeared where the start-point variants disagreed about a shallow feature near the simplify tolerance. Their union superimposed the disagreement as a double peak with a valley that the area-preservation buffer then sharpened into a fold slipping under the concave-turn seal threshold. The per-point median consensus (see Changed) resolves the variants to one boundary instead of overlaying them, removing the artifact (raster-noise fuzz harness: 4 → 0 residual folds; `examples/Water.gpkg`: 8 → 0).
13+
714
## [0.3.2] - 2026-06-30
815

916
### Changed

README.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,22 +196,21 @@ smoothed = smoothify(
196196

197197
## How It Works
198198

199-
Smoothify uses an advanced multi-step smoothing pipeline:
199+
Smoothify uses an advanced multi-step smoothing pipeline. The numbered steps below correspond to the panels in the figure:
200200

201201
<p align="left">
202202
<img src="https://raw.githubusercontent.com/DPIRD-DMA/Smoothify/main/images/pipeline_steps.png" alt="Smoothify pipeline steps" width="800">
203203
</p>
204204

205205

206-
1. Joins touching holes (for Polygons, when `merge_holes=True`) so they smooth as one opening
207-
2. Adds intermediate vertices along line segments (segmentize)
208-
3. Generates multiple rotated variants (for Polygons) to avoid artifacts
209-
4. Simplifies each variant to remove noise
210-
5. Applies Chaikin corner cutting to smooth
211-
6. Merges all variants via union to eliminate start-point artifacts
212-
7. Applies final smoothing pass
213-
8. Optionally restores original area via buffering (for Polygons)
214-
9. Detects and repairs any sharp folds left by features near the smoothing scale (e.g. one-pixel-wide arms), using a small morphological opening/closing bounded at `segment_length / 4`
206+
1. **Pixelated input** — a polygon straight from raster-to-vector conversion, with a stair-stepped boundary
207+
2. **Multiple variants** (for Polygons) that start at evenly spaced arc-length positions, each simplified to strip staircase noise, so no artifact is tied to a fixed start vertex
208+
3. **Chaikin corner cutting** applied to each variant
209+
4. **Per-point median merge** — a start-invariant consensus that resolves the variants' disagreements
210+
5. **Final smoothing pass** on the merged result
211+
6. **Restore original area** via buffering (for Polygons, when `preserve_area=True`)
212+
213+
Two steps are not shown in the figure: before step 2, touching holes are joined (for Polygons, when `merge_holes=True`) so they smooth as one opening; and after step 6, the result is checked for sharp concave folds left by features near the smoothing scale (e.g. one-pixel-wide arms) and repaired with a small morphological opening/closing bounded at `segment_length / 4`.
215214

216215
## Invalid Geometries
217216

examples/Water_Smoothed.gpkg

0 Bytes
Binary file not shown.

examples/merge_holes_examples.ipynb

Lines changed: 5 additions & 5 deletions
Large diffs are not rendered by default.

examples/real_world_water_example.ipynb

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

examples/smoothify_vs_shapely_comparison.ipynb

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

examples/usage_examples.ipynb

Lines changed: 36 additions & 34 deletions
Large diffs are not rendered by default.

images/generate_pipeline_graphic.py

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
from smoothify.smoothify_core import (
2121
_CHAIKIN_SEGMENT_FACTOR,
2222
_chaikin_corner_cutting,
23+
_combine_variants,
2324
_generate_starting_point_variants,
25+
_polygonal_only,
2426
_preserve_area_with_buffer,
2527
)
2628

@@ -51,7 +53,12 @@ def pixel_blob() -> Polygon:
5153
]
5254
merged = unary_union(squares)
5355
assert isinstance(merged, Polygon)
54-
return merged
56+
# unary_union leaves a vertex at every pixel boundary along straight runs;
57+
# drop the collinear ones (tolerance 0) so only the true staircase corners
58+
# remain, matching what a polygonized raster's meaningful vertices are.
59+
corners = merged.simplify(0)
60+
assert isinstance(corners, Polygon)
61+
return corners
5562

5663

5764
def draw(ax, geom, color="black", lw=1.8, dots=False, alpha=1.0, style="-"):
@@ -78,34 +85,35 @@ def main() -> None:
7885
original = pixel_blob()
7986

8087
# --- the simplified pipeline, capturing intermediates -------------------
81-
# 1. densify so the start-point rotation has vertices to rotate to
82-
densified = original.segmentize(SEGMENT_LENGTH / 2)
83-
84-
# 2. rotated start-point variants, each simplified (noise removal) and
85-
# re-segmentized so corner rounding stays capped at segment_length
88+
# 1. variants that start at evenly spaced arc-length positions, each
89+
# simplified (noise removal) and re-segmentized so corner rounding stays
90+
# capped at segment_length. No up-front densify is needed: the start
91+
# points are interpolated directly at their arc-length positions.
8692
variants = []
87-
for variant in _generate_starting_point_variants(densified, n_starting_points=4):
93+
for variant in _generate_starting_point_variants(original, n_starting_points=4):
8894
variant = variant.simplify(
8995
tolerance=SEGMENT_LENGTH, preserve_topology=True
9096
).segmentize(SEGMENT_LENGTH * _CHAIKIN_SEGMENT_FACTOR)
9197
variants.append(variant)
9298

93-
# 3. Chaikin corner cutting per variant
99+
# 2. Chaikin corner cutting per variant
94100
smoothed_variants = [
95-
make_valid(_chaikin_corner_cutting(v, num_iterations=2)) for v in variants
101+
_polygonal_only(make_valid(_chaikin_corner_cutting(v, num_iterations=2)))
102+
for v in variants
96103
]
97104

98-
# 4. union of the variants removes each one's start-point artifact
99-
merged = make_valid(unary_union(smoothed_variants)).simplify(
105+
# 3. per-point median merge: a start-invariant consensus of the variants
106+
merged = _combine_variants(smoothed_variants).simplify(
100107
tolerance=SEGMENT_LENGTH / 5, preserve_topology=True
101108
)
109+
assert isinstance(merged, Polygon)
102110

103-
# 5. final smoothing pass
111+
# 4. final smoothing pass
104112
final_smooth = _chaikin_corner_cutting(
105113
merged.segmentize(SEGMENT_LENGTH * _CHAIKIN_SEGMENT_FACTOR), num_iterations=3
106114
)
107115

108-
# 6. restore the original area by buffering
116+
# 5. restore the original area by buffering
109117
final = _preserve_area_with_buffer(
110118
final_smooth, target_area=original.area, tolerance=original.area * 1e-4
111119
)
@@ -121,32 +129,34 @@ def main() -> None:
121129

122130
ax = panels[1]
123131
reference(ax, original)
124-
draw(ax, densified, color="black", lw=1.0, dots=True)
125-
ax.set_title("2. Densify\n(segmentize at segment_length / 2)")
132+
for v, c, s in zip(variants, VARIANT_COLORS, VARIANT_STYLES, strict=True):
133+
draw(ax, v, color=c, lw=1.6, alpha=0.9, style=s)
134+
ax.set_title("2. Start at 4 arc-length\npositions, simplify each")
126135

127136
ax = panels[2]
128137
reference(ax, original)
129-
for v, c, s in zip(variants, VARIANT_COLORS, VARIANT_STYLES, strict=True):
138+
for v, c, s in zip(smoothed_variants, VARIANT_COLORS, VARIANT_STYLES, strict=True):
130139
draw(ax, v, color=c, lw=1.6, alpha=0.9, style=s)
131-
ax.set_title("3. Rotate start point 4 ways,\nsimplify each variant")
140+
ax.set_title("3. Chaikin corner cutting\nper variant")
132141

133142
ax = panels[3]
134143
reference(ax, original)
135144
for v, c, s in zip(smoothed_variants, VARIANT_COLORS, VARIANT_STYLES, strict=True):
136-
draw(ax, v, color=c, lw=1.6, alpha=0.9, style=s)
137-
ax.set_title("4. Chaikin corner cutting\nper variant")
145+
draw(ax, v, color=c, lw=1.0, alpha=0.35, style=s)
146+
draw(ax, merged, color="black", lw=2.0)
147+
ax.set_title("4. Per-point median merge\n(start-invariant consensus)")
138148

139149
ax = panels[4]
140150
reference(ax, original)
141-
draw(ax, merged, color="black", lw=1.8)
142-
ax.set_title("5. Union of variants\n(removes start-point artifacts)")
151+
draw(ax, final_smooth, color="black", lw=1.8)
152+
ax.set_title("5. Final smoothing pass")
143153

144154
ax = panels[5]
145155
reference(ax, original)
146156
gpd.GeoSeries([final]).plot(ax=ax, color="#d3eed3", alpha=0.8)
147157
draw(ax, final, color="#1a7a1a", lw=2.0)
148158
err = abs(final.area - original.area) / original.area
149-
ax.set_title(f"6. Final smooth + restore area\n(area error {err:.4%})")
159+
ax.set_title(f"6. Restore original area\n(area error {err:.4%})")
150160

151161
minx, miny, maxx, maxy = original.buffer(1.4).bounds
152162
for ax in panels:

images/pipeline_steps.png

22.2 KB
Loading

0 commit comments

Comments
 (0)