Skip to content

Commit 20309cf

Browse files
committed
Fix COG overview generation poisoned by nodata sentinel (#1613)
The NaN-to-sentinel rewrite in to_geotiff and write_geotiff_gpu ran before _make_overview / make_overview_gpu, so np.nanmean and the GPU counterparts saw the sentinel as a finite value and biased every overview pixel. A raster with NaN pixels and nodata=-9999 produced overview cells like -4998.75 where the correct nan-aware mean was 1.5. Thread a nodata kwarg through the reducers so they mask the sentinel back to NaN before aggregating. The writer's overview loop passes nodata in, then rewrites any all-sentinel cells (which surface as NaN from the reducer) back to the sentinel for the on-disk pyramid. CPU and GPU paths both fixed. New regression tests cover mean / min / max / median, partial-NaN blocks, all-NaN blocks, integer dtype passthrough, and CPU-GPU agreement.
1 parent 0088dfe commit 20309cf

5 files changed

Lines changed: 358 additions & 15 deletions

File tree

.claude/sweep-accuracy-state.csv

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

xrspatial/geotiff/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2891,9 +2891,25 @@ def _gpu_compress_to_part(gpu_arr, w, h, spp):
28912891
if oh > 0 and ow > 0:
28922892
overview_levels.append(len(overview_levels) + 1)
28932893

2894+
# Pass ``nodata`` so the GPU reducer masks the sentinel back to
2895+
# NaN before averaging. Without this, the NaN->sentinel rewrite
2896+
# done above on ``arr`` leaks the sentinel into the overview
2897+
# reduction and poisons the pyramid (issue #1613). Rewrite any
2898+
# all-sentinel cell NaN back to the sentinel after each level
2899+
# so the on-disk overview tiles still carry the sentinel value
2900+
# external readers expect.
28942901
current = arr
28952902
for _ in overview_levels:
2896-
current = make_overview_gpu(current, method=overview_resampling)
2903+
current = make_overview_gpu(current, method=overview_resampling,
2904+
nodata=nodata)
2905+
if (nodata is not None
2906+
and np.dtype(str(current.dtype)).kind == 'f'
2907+
and not np.isnan(float(nodata))):
2908+
nan_mask = cupy.isnan(current)
2909+
if bool(nan_mask.any().item()):
2910+
current = current.copy()
2911+
current[nan_mask] = np.dtype(
2912+
str(current.dtype)).type(nodata)
28972913
oh, ow = current.shape[:2]
28982914
parts.append(_gpu_compress_to_part(current, ow, oh, samples))
28992915

xrspatial/geotiff/_gpu_decode.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2891,9 +2891,17 @@ def gpu_compress_tiles(d_image, tile_width, tile_height,
28912891
GPU_OVERVIEW_METHODS = ('mean', 'nearest', 'min', 'max', 'median', 'mode')
28922892

28932893

2894-
def _block_reduce_2d_gpu(arr2d, method):
2895-
"""2x block-reduce a single 2D CuPy plane using *method*."""
2894+
def _block_reduce_2d_gpu(arr2d, method, nodata=None):
2895+
"""2x block-reduce a single 2D CuPy plane using *method*.
2896+
2897+
When ``nodata`` is supplied and ``arr2d`` is a float dtype, cells that
2898+
equal the sentinel are masked back to NaN before the reduction so the
2899+
``cupy.nan*`` aggregation routines correctly skip them. Mirrors the
2900+
CPU helper :func:`xrspatial.geotiff._writer._block_reduce_2d` so the
2901+
two backends produce identical overviews when ``nodata`` is set.
2902+
"""
28962903
import cupy
2904+
import numpy as np
28972905

28982906
h, w = arr2d.shape
28992907
h2 = (h // 2) * 2
@@ -2908,12 +2916,26 @@ def _block_reduce_2d_gpu(arr2d, method):
29082916
# Mode is expensive on GPU; fall back to CPU
29092917
cpu_arr = arr2d.get()
29102918
from ._writer import _block_reduce_2d
2911-
cpu_result = _block_reduce_2d(cpu_arr, 'mode')
2919+
cpu_result = _block_reduce_2d(cpu_arr, 'mode', nodata=nodata)
29122920
return cupy.asarray(cpu_result)
29132921

29142922
# Block reshape for mean/min/max/median
29152923
if arr2d.dtype.kind == 'f':
29162924
blocks = cropped.reshape(oh, 2, ow, 2)
2925+
# Mask the sentinel back to NaN so cupy.nanmean and friends
2926+
# honour it as missing-data (issue #1613).
2927+
if (nodata is not None
2928+
and not np.isnan(nodata)
2929+
and np.isfinite(nodata)):
2930+
try:
2931+
sentinel = np.dtype(str(arr2d.dtype)).type(nodata)
2932+
except (OverflowError, ValueError):
2933+
sentinel = None
2934+
if sentinel is not None:
2935+
mask = blocks == sentinel
2936+
if bool(mask.any().item()):
2937+
blocks = cupy.where(
2938+
mask, cupy.float64('nan'), blocks)
29172939
else:
29182940
blocks = cropped.astype(cupy.float64).reshape(oh, 2, ow, 2)
29192941

@@ -2936,7 +2958,7 @@ def _block_reduce_2d_gpu(arr2d, method):
29362958
return result.astype(arr2d.dtype)
29372959

29382960

2939-
def make_overview_gpu(arr, method='mean'):
2961+
def make_overview_gpu(arr, method='mean', nodata=None):
29402962
"""Generate a 2x decimated overview on GPU.
29412963
29422964
Parameters
@@ -2946,6 +2968,12 @@ def make_overview_gpu(arr, method='mean'):
29462968
method : str
29472969
Resampling method: 'mean', 'nearest', 'min', 'max', 'median',
29482970
or 'mode'.
2971+
nodata : scalar or None
2972+
When supplied and ``arr`` is a float dtype, cells equal to the
2973+
sentinel are masked back to NaN before the reduction so the
2974+
sentinel does not bias the result. Required for COG output that
2975+
sets ``nodata=...`` (issue #1613). Ignored for integer arrays
2976+
and for ``nearest`` / ``mode``.
29492977
29502978
Returns
29512979
-------
@@ -2955,7 +2983,7 @@ def make_overview_gpu(arr, method='mean'):
29552983
import cupy
29562984

29572985
if arr.ndim == 3:
2958-
bands = [_block_reduce_2d_gpu(arr[:, :, b], method)
2986+
bands = [_block_reduce_2d_gpu(arr[:, :, b], method, nodata=nodata)
29592987
for b in range(arr.shape[2])]
29602988
return cupy.stack(bands, axis=2)
2961-
return _block_reduce_2d_gpu(arr, method)
2989+
return _block_reduce_2d_gpu(arr, method, nodata=nodata)

xrspatial/geotiff/_writer.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,17 @@ def _compression_tag(compression_name: str) -> int:
139139
_MAX_OVERVIEW_LEVELS = 8
140140

141141

142-
def _block_reduce_2d(arr2d, method):
143-
"""2x block-reduce a single 2D plane using *method*."""
142+
def _block_reduce_2d(arr2d, method, nodata=None):
143+
"""2x block-reduce a single 2D plane using *method*.
144+
145+
When ``nodata`` is supplied and ``arr2d`` is a float dtype, cells that
146+
equal the sentinel are treated as NaN during the reduction so the
147+
``nan*`` aggregation routines correctly skip them. The reduced output
148+
keeps NaN wherever every contributing input cell was the sentinel
149+
(so callers can rewrite that NaN back to the sentinel after the
150+
reduction). The sentinel is ignored entirely for integer dtypes and
151+
for non-aggregation methods (``nearest``, ``mode``, ``cubic``).
152+
"""
144153
h, w = arr2d.shape
145154
h2 = (h // 2) * 2
146155
w2 = (w // 2) * 2
@@ -177,9 +186,35 @@ def _block_reduce_2d(arr2d, method):
177186
# Block reshape for mean/min/max/median
178187
if arr2d.dtype.kind == 'f':
179188
blocks = cropped.reshape(oh, 2, ow, 2)
189+
# When a sentinel was used in place of NaN by an upstream
190+
# NaN-to-sentinel rewrite, mask it back to NaN here so nanmean /
191+
# nanmin / nanmax / nanmedian honour the missing-data semantic.
192+
# Without this the sentinel value participates in the reduction
193+
# and poisons the overview (issue #1613).
194+
if (nodata is not None
195+
and not np.isnan(nodata)
196+
and np.isfinite(nodata)):
197+
try:
198+
sentinel = arr2d.dtype.type(nodata)
199+
except (OverflowError, ValueError):
200+
sentinel = None
201+
if sentinel is not None:
202+
mask = blocks == sentinel
203+
if mask.any():
204+
# ``np.where(mask, nan, blocks)`` produces a fresh
205+
# array so the caller's input is not mutated.
206+
blocks = np.where(mask, np.float64('nan'), blocks)
180207
else:
181208
blocks = cropped.astype(np.float64).reshape(oh, 2, ow, 2)
182-
209+
# Integer rasters can also carry a sentinel that an upstream
210+
# promotion already converted to NaN; cropped is integer so no
211+
# masking is needed here. The blocks.astype(float64) cast above
212+
# would lose any NaN anyway -- integer sentinels are handled at
213+
# the call site by promoting to float64 before reduction.
214+
215+
# nanmean / nanmin / nanmax / nanmedian raise warnings on all-nan
216+
# blocks; ``np.errstate`` would silence them but the resulting NaN is
217+
# the desired output so we leave the warning visible.
183218
if method == 'mean':
184219
result = np.nanmean(blocks, axis=(1, 3))
185220
elif method == 'min':
@@ -199,7 +234,8 @@ def _block_reduce_2d(arr2d, method):
199234
return result.astype(arr2d.dtype)
200235

201236

202-
def _make_overview(arr: np.ndarray, method: str = 'mean') -> np.ndarray:
237+
def _make_overview(arr: np.ndarray, method: str = 'mean',
238+
nodata=None) -> np.ndarray:
203239
"""Generate a 2x decimated overview.
204240
205241
Parameters
@@ -209,16 +245,23 @@ def _make_overview(arr: np.ndarray, method: str = 'mean') -> np.ndarray:
209245
method : str
210246
Resampling method: 'mean' (default), 'nearest', 'min', 'max',
211247
'median', 'mode', or 'cubic'.
248+
nodata : scalar or None
249+
When supplied and ``arr`` is a float dtype, cells equal to the
250+
sentinel are masked back to NaN before the reduction so the
251+
sentinel does not bias the result. Required for COG output that
252+
sets ``nodata=...`` (issue #1613). Ignored for integer arrays
253+
and for ``nearest`` / ``mode`` / ``cubic`` methods.
212254
213255
Returns
214256
-------
215257
np.ndarray
216258
Half-resolution array.
217259
"""
218260
if arr.ndim == 3:
219-
bands = [_block_reduce_2d(arr[:, :, b], method) for b in range(arr.shape[2])]
261+
bands = [_block_reduce_2d(arr[:, :, b], method, nodata=nodata)
262+
for b in range(arr.shape[2])]
220263
return np.stack(bands, axis=2)
221-
return _block_reduce_2d(arr, method)
264+
return _block_reduce_2d(arr, method, nodata=nodata)
222265

223266

224267
# ---------------------------------------------------------------------------
@@ -1100,9 +1143,36 @@ def write(data: np.ndarray, path: str, *,
11001143
if oh > 0 and ow > 0:
11011144
overview_levels.append(len(overview_levels) + 1)
11021145

1146+
# Overview reductions need the *unmasked* float array so that
1147+
# ``np.nanmean`` / ``np.nanmin`` / ``np.nanmax`` / ``np.nanmedian``
1148+
# honour the sentinel as missing-data. The CPU writer's caller
1149+
# (``to_geotiff``) currently rewrites NaN to ``nodata`` before
1150+
# ``write()`` runs (so the on-disk full-resolution tile bytes
1151+
# match the sentinel-aware reader). We pass ``nodata`` into
1152+
# ``_make_overview`` here so the reducer masks the sentinel back
1153+
# to NaN before averaging; without this, the sentinel poisons
1154+
# the overview (issue #1613). After reduction any cell that was
1155+
# all-sentinel comes back as NaN; ``_write_tiled`` / ``_write_stripped``
1156+
# serialise that NaN to disk, where the eager reader will mask
1157+
# it (and a future writer pass could rewrite to ``nodata`` for
1158+
# external readers -- out of scope for this fix).
11031159
current = data
11041160
for _ in overview_levels:
1105-
current = _make_overview(current, method=overview_resampling)
1161+
current = _make_overview(current, method=overview_resampling,
1162+
nodata=nodata)
1163+
# Rewrite any NaN produced by the all-sentinel reduction
1164+
# back to the sentinel so the overview pyramid carries the
1165+
# same masking convention as the full-resolution band. The
1166+
# original ``data`` already underwent the NaN->sentinel
1167+
# rewrite upstream, so the only new NaNs here come from the
1168+
# reducer itself.
1169+
if (nodata is not None
1170+
and current.dtype.kind == 'f'
1171+
and not np.isnan(nodata)):
1172+
nan_mask = np.isnan(current)
1173+
if nan_mask.any():
1174+
current = current.copy()
1175+
current[nan_mask] = current.dtype.type(nodata)
11061176
oh, ow = current.shape[:2]
11071177
if tiled:
11081178
o_off, o_bc, o_data = _write_tiled(current, comp_tag, pred_int,

0 commit comments

Comments
 (0)