Skip to content

Commit 4125b34

Browse files
authored
Fix rasterize accuracy: GPU ceil, half-open fill, all_touched pairing (#995) (#996)
* Fix GPU ceil approximation, half-open fill convention, and all_touched edge pairing (#995) Three rasterize accuracy fixes: 1. GPU scanline used int(x + 0.999999) as a ceil approximation. This diverges from CPU's np.ceil for values within 1e-6 of an integer. Replaced with exact int-based ceil logic. 2. Scanline fill used closed interval [ceil(x_start), floor(x_end)] for column ranges. This double-counts boundary pixels when adjacent polygons share an edge, and includes pixels whose centers fall outside the polygon. Changed to half-open [ceil(x_start), ceil(x_end)-1], matching GDAL's convention for axis-aligned edges. 3. all_touched mode expanded edge y-ranges, which broke even-odd edge pairing on rows where the expansion created an odd active edge count. This caused all_touched=True to fill fewer pixels than all_touched=False. Fixed by keeping normal scanline fill and drawing polygon boundaries via Bresenham separately, guaranteeing the result is a superset. * Add rasterize accuracy test suite with invariant-based checks (#995) 41 tests exercising mathematical properties of correct rasterization: - Watertight tiling: non-overlapping polygons counted exactly once - Area conservation: rasterized area converges to analytic area - Point-in-polygon cross-check: shapely.contains vs rasterized grid - Annulus: polygon with hole matches pi*(R^2 - r^2) - Symmetry: axis-aligned shapes perfectly symmetric, others bounded - Merge mode identities: sum-with-ones equals count, etc. - all_touched monotonicity: always a superset of normal fill - rasterio reference: exact match for axis-aligned, <3% diff for slanted - Edge cases: thin polygons, concave shapes, nearly-degenerate triangles
1 parent 3bd55d3 commit 4125b34

File tree

2 files changed

+803
-7
lines changed

2 files changed

+803
-7
lines changed

xrspatial/rasterize.py

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,44 @@ def _extract_lines_loop(geometries, values, bounds, height, width):
615615
np.array(all_vals, np.float64))
616616

617617

618+
# ---------------------------------------------------------------------------
619+
# Polygon boundary segments (for all_touched mode)
620+
# ---------------------------------------------------------------------------
621+
622+
def _extract_polygon_boundary_segments(geometries, values, bounds,
623+
height, width):
624+
"""Extract polygon ring edges as line segments for Bresenham drawing.
625+
626+
Used by all_touched mode: drawing the boundary ensures every pixel
627+
the polygon touches is burned, without expanding scanline edge
628+
y-ranges (which breaks edge pairing).
629+
"""
630+
from shapely.geometry import LineString as _LS
631+
632+
boundary_lines = []
633+
boundary_vals = []
634+
for geom, val in zip(geometries, values):
635+
if geom is None or geom.is_empty:
636+
continue
637+
if geom.geom_type == 'Polygon':
638+
parts = [geom]
639+
elif geom.geom_type == 'MultiPolygon':
640+
parts = list(geom.geoms)
641+
else:
642+
continue
643+
for poly in parts:
644+
boundary_lines.append(_LS(poly.exterior.coords))
645+
boundary_vals.append(val)
646+
for interior in poly.interiors:
647+
boundary_lines.append(_LS(interior.coords))
648+
boundary_vals.append(val)
649+
650+
if not boundary_lines:
651+
return _EMPTY_LINES
652+
return _extract_line_segments(boundary_lines, boundary_vals,
653+
bounds, height, width)
654+
655+
618656
# ---------------------------------------------------------------------------
619657
# CPU burn kernels (numba)
620658
# ---------------------------------------------------------------------------
@@ -752,7 +790,7 @@ def _scanline_fill_cpu(out, written, edge_y_min, edge_y_max, edge_x_at_ymin,
752790
x_start = xs[k]
753791
x_end = xs[k + 1]
754792
col_start = max(int(np.ceil(x_start)), 0)
755-
col_end = min(int(np.floor(x_end)), width - 1)
793+
col_end = min(int(np.ceil(x_end)) - 1, width - 1)
756794
for c in range(col_start, col_end + 1):
757795
_merge_pixel(out, written, row, c, val, mode)
758796
k += 2
@@ -775,14 +813,27 @@ def _run_numpy(geometries, values, bounds, height, width, fill, dtype,
775813
(poly_geoms, poly_vals, poly_ids), (line_geoms, line_vals), \
776814
(point_geoms, point_vals) = _classify_geometries(geometries, values)
777815

778-
# 1. Polygons
816+
# 1. Polygons -- always use normal edge ranges for scanline fill
817+
# (all_touched y-expansion breaks edge pairing, so we handle
818+
# all_touched by drawing polygon boundaries separately below).
779819
edge_arrays = _extract_edges(
780-
poly_geoms, poly_vals, poly_ids, bounds, height, width, all_touched)
820+
poly_geoms, poly_vals, poly_ids, bounds, height, width,
821+
all_touched=False)
781822
edge_arrays = _sort_edges(edge_arrays)
782823
if len(edge_arrays[0]) > 0:
783824
_scanline_fill_cpu(out, written, *edge_arrays, height, width,
784825
merge_mode)
785826

827+
# 1b. all_touched: draw polygon boundaries via Bresenham so every
828+
# pixel the boundary passes through is burned. This guarantees
829+
# all_touched is a superset of the normal fill.
830+
if all_touched and poly_geoms:
831+
br0, bc0, br1, bc1, bvals = _extract_polygon_boundary_segments(
832+
poly_geoms, poly_vals, bounds, height, width)
833+
if len(br0) > 0:
834+
_burn_lines_cpu(out, written, br0, bc0, br1, bc1, bvals,
835+
height, width, merge_mode)
836+
786837
# 2. Lines
787838
r0, c0, r1, c1, lvals = _extract_line_segments(
788839
line_geoms, line_vals, bounds, height, width)
@@ -911,10 +962,17 @@ def _scanline_fill_gpu(out, written, edge_y_min, edge_x_at_ymin,
911962
while k + 1 < j:
912963
x_start = xs[k]
913964
x_end = xs[k + 1]
914-
col_start = int(x_start + 0.999999)
965+
# Proper ceil: int() truncates toward zero, so for
966+
# positive fractions we add 1; for exact ints and
967+
# negative fractions int() already rounds up.
968+
ix = int(x_start)
969+
col_start = ix + 1 if x_start > ix else ix
915970
if col_start < 0:
916971
col_start = 0
917-
col_end = int(x_end)
972+
# Half-open interval: ceil(x_end) - 1 excludes
973+
# boundary pixels whose centers are outside.
974+
ix_end = int(x_end)
975+
col_end = ix_end if x_end > ix_end else ix_end - 1
918976
if col_end >= width:
919977
col_end = width - 1
920978
for c in range(col_start, col_end + 1):
@@ -1038,9 +1096,10 @@ def _run_cupy(geometries, values, bounds, height, width, fill, dtype,
10381096
(poly_geoms, poly_vals, poly_ids), (line_geoms, line_vals), \
10391097
(point_geoms, point_vals) = _classify_geometries(geometries, values)
10401098

1041-
# 1. Polygons
1099+
# 1. Polygons -- always use normal edge ranges (see _run_numpy comment)
10421100
edge_arrays = _extract_edges(
1043-
poly_geoms, poly_vals, poly_ids, bounds, height, width, all_touched)
1101+
poly_geoms, poly_vals, poly_ids, bounds, height, width,
1102+
all_touched=False)
10441103
edge_arrays = _sort_edges(edge_arrays)
10451104
edge_y_min, edge_y_max, edge_x_at_ymin, edge_inv_slope, \
10461105
edge_value, edge_geom_id = edge_arrays
@@ -1064,6 +1123,19 @@ def _run_cupy(geometries, values, bounds, height, width, fill, dtype,
10641123
out, written, d_y_min, d_x_at_ymin, d_inv_slope,
10651124
d_value, d_geom_id, d_row_ptr, d_col_idx, width, merge_mode)
10661125

1126+
# 1b. all_touched: draw polygon boundaries via Bresenham (GPU)
1127+
if all_touched and poly_geoms:
1128+
br0, bc0, br1, bc1, bvals = _extract_polygon_boundary_segments(
1129+
poly_geoms, poly_vals, bounds, height, width)
1130+
if len(br0) > 0:
1131+
n_bsegs = len(br0)
1132+
tpb = 256
1133+
bpg = (n_bsegs + tpb - 1) // tpb
1134+
kernels['burn_lines'][bpg, tpb](
1135+
out, written, cupy.asarray(br0), cupy.asarray(bc0),
1136+
cupy.asarray(br1), cupy.asarray(bc1),
1137+
cupy.asarray(bvals), n_bsegs, height, width, merge_mode)
1138+
10671139
# 2. Lines
10681140
r0, c0, r1, c1, lvals = _extract_line_segments(
10691141
line_geoms, line_vals, bounds, height, width)

0 commit comments

Comments
 (0)