Skip to content

Commit 9e4f01c

Browse files
committed
Revert single-pass _follow, keep JIT merge helpers and batch shapely (#1008)
Benchmarks showed the single-pass _follow was 15-30% slower than the original two-pass version. The buffer growth check in the inner loop adds overhead that numba doesn't optimize away, and the two-pass approach benefits from the data being in cache on the second pass. Reverted _follow to the original two-pass implementation. The JIT merge helpers (2.3-2.6x dask speedup) and batch shapely construction (1.3-1.6x geopandas speedup) are kept.
1 parent c770b6e commit 9e4f01c

File tree

2 files changed

+91
-128
lines changed

2 files changed

+91
-128
lines changed

xrspatial/polygonize.py

Lines changed: 91 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ def _min_and_max(value0, value1):
107107
# facing W, and if hole is False the start is on the S edge of the pixel
108108
# facing E.
109109
#
110-
# Single pass with a dynamically-grown buffer (doubling strategy). Buffer
111-
# starts at 64 points and grows as needed. Amortized cost is O(1) per point
112-
# from O(log n) reallocations, which is cheaper than retracing the boundary
113-
# a second time.
110+
# There are two passes. First pass determines the number of points and
111+
# allocates a numpy array big enough to take the points, the second pass saves
112+
# the points. This is better than a single pass that repeatedly reallocates
113+
# space for the increasing number of points.
114114
#
115115
# Returns the region ID and the 2D array of boundary points. The last
116116
# boundary point is the same as the first.
@@ -125,104 +125,98 @@ def _follow(
125125
) -> Tuple[int, np.ndarray]:
126126
region = regions[ij]
127127
n = nx*ny
128+
points = None # Declare before loop for numba
128129

129-
# Dynamic buffer for point coordinates (x, y pairs stored flat).
130-
buf_cap = 64
131-
points = np.empty(2 * buf_cap)
132-
npoints = 0
133-
134-
prev_forward = 0 # Start with an invalid direction.
135-
if hole:
136-
forward = -1 # Facing W along N edge.
137-
left = -nx
138-
else:
139-
forward = 1 # Facing E along S edge.
140-
left = nx
141-
142-
start_forward = forward
143-
start_ij = ij
144-
145-
while True:
146-
if forward == 1 and not hole:
147-
# Mark pixel so that it will not be considered a future
148-
# non-hole starting pixel.
149-
visited[ij] |= 1
150-
elif forward == -1 and ij+nx < n:
151-
# Mark pixel so that it will not be considered a future
152-
# hole starting pixel.
153-
visited[ij+nx] |= 2
154-
155-
if prev_forward != forward:
156-
# Grow buffer if needed.
157-
if npoints >= buf_cap:
158-
old_points = points
159-
buf_cap *= 2
160-
points = np.empty(2 * buf_cap)
161-
points[:2*npoints] = old_points[:2*npoints]
162-
163-
# Add point.
164-
i = ij % nx
165-
j = ij // nx
166-
if forward == -1:
167-
i += 1
168-
j += 1
169-
elif forward == nx:
170-
i += 1
171-
elif forward == -nx:
172-
j += 1
173-
points[2*npoints] = i
174-
points[2*npoints+1] = j
175-
npoints += 1
176-
177-
prev_forward = forward
178-
ijnext = ij + forward
179-
ijnext_right = ijnext - left
180-
181-
# Determine direction of turn.
182-
if abs(forward) == 1: # Facing E (forward == 1) or W (forward -1)
183-
if _diff_row(ij, ijnext, nx):
184-
turn = Turn.Left
185-
elif (not _outside_domain(ijnext_right, n) and
186-
regions[ijnext_right] == region):
187-
turn = Turn.Right
188-
elif regions[ijnext] == region:
189-
turn = Turn.Straight
190-
else:
191-
turn = Turn.Left
192-
else: # Facing N (forward == nx) or S (forward == -nx)
193-
if _outside_domain(ijnext, n):
194-
turn = Turn.Left
195-
elif (not _diff_row(ijnext, ijnext_right, nx) and
196-
regions[ijnext_right] == region):
197-
turn = Turn.Right
198-
elif regions[ijnext] == region:
199-
turn = Turn.Straight
200-
else:
201-
turn = Turn.Left
130+
for pass_ in range(2):
131+
prev_forward = 0 # Start with an invalid direction.
132+
if hole:
133+
forward = -1 # Facing W along N edge.
134+
left = -nx
135+
else:
136+
forward = 1 # Facing E along S edge.
137+
left = nx
138+
139+
start_forward = forward
140+
start_ij = ij
141+
npoints = 0
142+
143+
while True:
144+
if pass_ == 1:
145+
if forward == 1 and not hole:
146+
# Mark pixel so that it will not be considered a future
147+
# non-hole starting pixel.
148+
visited[ij] |= 1
149+
elif forward == -1 and ij+nx < n:
150+
# Mark pixel so that is will not be considered a future
151+
# hole starting pixel.
152+
visited[ij+nx] |= 2
153+
154+
if prev_forward != forward:
155+
if pass_ == 1:
156+
# Add point.
157+
i = ij % nx
158+
j = ij // nx
159+
if forward == -1:
160+
i += 1
161+
j += 1
162+
elif forward == nx:
163+
i += 1
164+
elif forward == -nx:
165+
j += 1
166+
points[2*npoints] = i
167+
points[2*npoints+1] = j
168+
npoints += 1
202169

203-
# Apply turn.
204-
if turn == Turn.Straight:
205-
ij = ijnext
206-
elif turn == Turn.Left:
207-
prev_forward = forward
208-
forward = left
209-
left = -prev_forward
210-
else: # Turn.Right
211170
prev_forward = forward
212-
forward = -left
213-
left = prev_forward
214-
ij = ijnext_right
171+
ijnext = ij + forward
172+
ijnext_right = ijnext - left
173+
174+
# Determine direction of turn.
175+
if abs(forward) == 1: # Facing E (forward == 1) or W (forward -1)
176+
if _diff_row(ij, ijnext, nx):
177+
turn = Turn.Left
178+
elif (not _outside_domain(ijnext_right, n) and
179+
regions[ijnext_right] == region):
180+
turn = Turn.Right
181+
elif regions[ijnext] == region:
182+
turn = Turn.Straight
183+
else:
184+
turn = Turn.Left
185+
else: # Facing N (forward == nx) or S (forward == -nx)
186+
if _outside_domain(ijnext, n):
187+
turn = Turn.Left
188+
elif (not _diff_row(ijnext, ijnext_right, nx) and
189+
regions[ijnext_right] == region):
190+
turn = Turn.Right
191+
elif regions[ijnext] == region:
192+
turn = Turn.Straight
193+
else:
194+
turn = Turn.Left
195+
196+
# Apply turn.
197+
if turn == Turn.Straight:
198+
ij = ijnext
199+
elif turn == Turn.Left:
200+
prev_forward = forward
201+
forward = left
202+
left = -prev_forward
203+
else: # Turn.Right
204+
prev_forward = forward
205+
forward = -left
206+
left = prev_forward
207+
ij = ijnext_right
208+
209+
# Finished boundary when returned to start.
210+
if ij == start_ij and forward == start_forward:
211+
break
215212

216-
# Finished boundary when returned to start.
217-
if ij == start_ij and forward == start_forward:
218-
break
213+
if pass_ == 0:
214+
# End of first pass.
215+
points = np.empty(2*(npoints+1)) # Note extra point at end.
219216

220-
# Trim buffer to actual size and add closing point.
221-
result = np.empty(2*(npoints+1))
222-
result[:2*npoints] = points[:2*npoints]
223-
result = result.reshape((-1, 2))
224-
result[-1] = result[0] # End point the same as start point.
225-
return region, result
217+
points = points.reshape((-1, 2))
218+
points[-1] = points[0] # End point the same as start point.
219+
return region, points
226220

227221

228222
# Generator of numba-compatible comparison functions for values.

xrspatial/tests/test_polygonize.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -461,37 +461,6 @@ def test_polygonize_dask_cupy_matches_numpy(chunks):
461461

462462
# --- Performance-related regression tests (#1008) ---
463463

464-
def test_polygonize_1008_large_boundary_buffer_growth():
465-
"""Single-pass _follow buffer growth: polygon with > 64 boundary points.
466-
467-
A thin snake-like region forces the boundary tracer to produce many
468-
vertices, exercising the dynamic buffer doubling in _follow.
469-
"""
470-
# Horizontal snake: 1-pixel-wide path zigzagging across a 60-column raster.
471-
data = np.zeros((6, 60), dtype=np.int32)
472-
data[0, :] = 1 # row 0: left to right
473-
data[1, 59] = 1 # turn down
474-
data[2, :] = 1 # row 2: right to left (fills whole row)
475-
data[3, 0] = 1 # turn down
476-
data[4, :] = 1 # row 4: left to right
477-
478-
raster = xr.DataArray(data)
479-
values, polygons = polygonize(raster, connectivity=4)
480-
481-
# The value-1 polygon should have many boundary points (> 64).
482-
val1_idx = [i for i, v in enumerate(values) if v == 1]
483-
assert len(val1_idx) >= 1
484-
for idx in val1_idx:
485-
for ring in polygons[idx]:
486-
assert ring.shape[1] == 2
487-
assert np.array_equal(ring[0], ring[-1])
488-
489-
# Total area must equal raster size.
490-
total_area = sum(
491-
assert_polygon_valid_and_get_area(p) for p in polygons)
492-
assert_allclose(total_area, data.size)
493-
494-
495464
def test_polygonize_1008_jit_merge_helpers():
496465
"""JIT-compiled _simplify_ring, _signed_ring_area, _point_in_ring."""
497466
from ..polygonize import _point_in_ring, _signed_ring_area, _simplify_ring

0 commit comments

Comments
 (0)