Skip to content

Commit c770b6e

Browse files
committed
Add performance regression tests for polygonize (#1008)
- Buffer growth: snake-shaped polygon with >64 boundary points - JIT merge helpers: direct tests of _simplify_ring, _signed_ring_area, _point_in_ring - Dask merge: checkerboard pattern forcing many boundary merges - Geopandas batch: mixed hole-free and holed polygons through the shapely.polygons() batch path
1 parent a8833e3 commit c770b6e

File tree

1 file changed

+112
-0
lines changed

1 file changed

+112
-0
lines changed

xrspatial/tests/test_polygonize.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,115 @@ def test_polygonize_dask_cupy_matches_numpy(chunks):
457457
assert val in areas_dcp, f"Value {val} missing from dask+cupy result"
458458
assert_allclose(areas_dcp[val], areas_np[val],
459459
err_msg=f"Area mismatch for value {val}")
460+
461+
462+
# --- Performance-related regression tests (#1008) ---
463+
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+
495+
def test_polygonize_1008_jit_merge_helpers():
496+
"""JIT-compiled _simplify_ring, _signed_ring_area, _point_in_ring."""
497+
from ..polygonize import _point_in_ring, _signed_ring_area, _simplify_ring
498+
499+
# Unit square: CCW exterior.
500+
square = np.array([
501+
[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=np.float64)
502+
503+
assert_allclose(_signed_ring_area(square), 1.0)
504+
505+
# Point inside.
506+
assert _point_in_ring(0.5, 0.5, square) is True
507+
# Point outside.
508+
assert _point_in_ring(2.0, 2.0, square) is False
509+
510+
# Square with collinear midpoints on each edge.
511+
with_collinear = np.array([
512+
[0, 0], [0.5, 0], [1, 0], [1, 0.5], [1, 1],
513+
[0.5, 1], [0, 1], [0, 0.5], [0, 0]], dtype=np.float64)
514+
simplified = _simplify_ring(with_collinear)
515+
# Should remove the midpoints, leaving 4 corners + closing point.
516+
assert simplified.shape == (5, 2)
517+
assert_allclose(_signed_ring_area(simplified), 1.0)
518+
519+
# Ring with no collinear points should be returned unchanged.
520+
triangle = np.array([
521+
[0, 0], [2, 0], [1, 2], [0, 0]], dtype=np.float64)
522+
assert _simplify_ring(triangle) is triangle
523+
524+
525+
@dask_array_available
526+
def test_polygonize_1008_dask_merge_many_boundary_polygons():
527+
"""Dask merge with many boundary-crossing polygons of the same value.
528+
529+
Checkerboard pattern in small chunks forces many boundary polygons
530+
through the merge path, exercising the JIT-compiled helpers.
531+
"""
532+
# 8x8 checkerboard, chunks of 4x4.
533+
data = np.zeros((8, 8), dtype=np.int32)
534+
data[::2, ::2] = 1
535+
data[1::2, 1::2] = 1
536+
537+
raster_np = xr.DataArray(data)
538+
vals_np, polys_np = polygonize(raster_np, connectivity=4)
539+
areas_np = _area_by_value(vals_np, polys_np)
540+
541+
raster_da = xr.DataArray(da.from_array(data, chunks=(4, 4)))
542+
vals_da, polys_da = polygonize(raster_da, connectivity=4)
543+
areas_da = _area_by_value(vals_da, polys_da)
544+
545+
for val in areas_np:
546+
assert val in areas_da
547+
assert_allclose(areas_da[val], areas_np[val],
548+
err_msg=f"Area mismatch for value {val}")
549+
550+
551+
@pytest.mark.skipif(gpd is None, reason="geopandas not installed")
552+
def test_polygonize_1008_geopandas_batch_with_holes():
553+
"""Batch shapely construction: mix of hole-free and holed polygons."""
554+
# Outer ring of 0s with inner block of 1s containing a 2 (hole in 1).
555+
data = np.zeros((6, 6), dtype=np.int32)
556+
data[1:5, 1:5] = 1
557+
data[2:4, 2:4] = 2
558+
559+
raster = xr.DataArray(data)
560+
df = polygonize(raster, return_type="geopandas", connectivity=4)
561+
562+
assert isinstance(df, gpd.GeoDataFrame)
563+
assert len(df) == 3 # values 0, 1, 2
564+
565+
# Value 1 polygon should have a hole (the 2-region).
566+
row_1 = df[df.DN == 1].iloc[0]
567+
geom = row_1.geometry
568+
assert len(list(geom.interiors)) == 1
569+
570+
# Total area should equal raster size.
571+
assert_allclose(df.geometry.area.sum(), 36.0)

0 commit comments

Comments
 (0)