@@ -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