From 9a0a58b18229d1bffce18a25419ddb8842d2c297 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 13 May 2026 05:28:19 -0700 Subject: [PATCH 1/2] geotiff: guard int(nodata) on NaN/Inf GDAL_NODATA strings (#1774) open_geotiff / read_geotiff_dask / _apply_nodata_mask_gpu used to crash with ValueError: cannot convert float NaN to integer when reading an integer TIFF whose GDAL_NODATA tag was the string "nan" / "inf" / "-inf". _geotags.py:extract_geo_info parses the tag through float(nodata_str) so a "nan" tag surfaces as Python NaN; the integer mask code then called int(nodata) without checking finiteness. Three sites in xrspatial/geotiff/__init__.py needed the gate (eager numpy, _apply_nodata_mask_gpu, _delayed_read_window) plus the read_geotiff_dask effective_dtype branch. Sibling helpers _resolve_masked_fill and _sparse_fill_value in _reader.py already guard with not math.isnan(v) and not math.isinf(v), so this is the unfinished pass of #1581. A non-finite sentinel on an integer file cannot match any pixel value, so the mask is a no-op and the file dtype is preserved; attrs['nodata'] still carries the raw NaN/Inf sentinel so a write round-trip keeps the original GDAL_NODATA tag. 15 regression tests in test_nodata_nan_int_1774.py cover the eager numpy path (3 NaN string variants + 6 Inf string variants), the dask path (NaN + Inf), the GPU helper (NaN + Inf + finite regression guard), and the in-range finite sentinel regression guard on the eager path. All 2023 existing geotiff tests still pass. --- .claude/sweep-accuracy-state.csv | 1 + xrspatial/geotiff/__init__.py | 60 +++-- .../geotiff/tests/test_nodata_nan_int_1774.py | 225 ++++++++++++++++++ 3 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 xrspatial/geotiff/tests/test_nodata_nan_int_1774.py diff --git a/.claude/sweep-accuracy-state.csv b/.claude/sweep-accuracy-state.csv index 25661b36..6b41bbd2 100644 --- a/.claude/sweep-accuracy-state.csv +++ b/.claude/sweep-accuracy-state.csv @@ -13,6 +13,7 @@ fire,2026-04-30,,,,All ops per-pixel (no accumulation/stencil/projected distance flood,2026-04-30,,MEDIUM,2;5,"MEDIUM (not fixed): dask backend preserves float32 input dtype while numpy promotes to float64 in flood_depth and curve_number_runoff; DataArray inputs for curve_number, mannings_n bypass scalar > 0 (and CN <= 100) range validation, silently producing NaN/garbage." focal,2026-03-30T13:00:00Z,1092,,, geotiff,2026-05-12,1753,HIGH,3;5,"Pass 21 (2026-05-12): HIGH fixed -- issue #1753. open_geotiff(path, gpu=True, window=...) on a stripped TIFF with no GeoTIFF tags (has_georef=False) returned float64 coords synthesised from the default unit GeoTransform (e.g. y=[-0.5,-1.5,-2.5,-3.5]) instead of integer pixel coords (np.arange(c0,c1,dtype=int64)) that the eager numpy, dask, and tiled-GPU paths returned. Regression of the #1710 fix: PR #1738 (issue #1732) added the GPU stripped fallback at __init__.py lines 2724-2747 to forward max_pixels/window/band, but the windowed coord branch only checked 'if t is None' (never true -- _extract_transform always returns a default GeoTransform()), missing the has_georef=False shortcut the eager numpy path used. The default transform has pixel_width=1.0, pixel_height=-1.0, so the PixelIsArea branch synthesised the half-pixel-shifted negative-y output. The dask+cupy path shared the same code so the bug fired on every gpu=True windowed read of a non-georef stripped TIFF. Fix: route 'not getattr(geo_info, has_georef, True)' through the integer-coord branch alongside the existing 't is None' check. Also fixed the latent off-by-one in the t-is-None branch (used arange(r1-r0) instead of arange(r0,r1) so offset windows would have returned [0,1,2,3] -- but the path was unreachable so the bug was latent). 14 regression tests in test_gpu_stripped_no_georef_window_1753.py covering origin window, offset window, dask+cupy variants, uint16 dtype, no transform attr, dtype-parity x3, value-parity x3, and georef-still-works PixelIsArea origin + offset. All 2005 non-stale geotiff tests still pass; 10 pre-existing failures in test_predictor2_big_endian_gpu_1517.py are stale test-infrastructure issues from PR #1708 (which hid read_to_array from public namespace) and are unrelated to accuracy. Categories: Cat 3 (off-by-one boundary handling on the latent unreachable branch) + Cat 5 (backend inconsistency: CPU eager and dask returned int64 integer coords while stripped GPU returned float64 half-pixel-shifted coords). | Pass 20 (2026-05-12): HIGH fixed -- PR #1691 (no issue created; agent harness blocked gh issue create). Integer COG overview pyramid mixed sentinel into reduced pixels. _block_reduce_2d (_writer.py:258-264) and _block_reduce_2d_gpu (_gpu_decode.py:3027-3028) promoted integer blocks to float64 but never masked the sentinel to NaN before nanmean / nanmin / nanmax / nanmedian. The reduction averaged the sentinel into surrounding valid cells (e.g. (-9999 + 100 + 100 + 100)/4 = -2425 cast back to int16), producing overview pixels that the read-side int-to-NaN mask in open_geotiff couldn't recover because they didn't equal the sentinel. Silent garbage at every zoom above level 0 for to_geotiff(int_data, cog=True, nodata=N). Methods affected: mean, min, max, median; nearest/mode safe (no averaging). Fix: gate the sentinel-to-NaN mask on representability in the source integer dtype (mirrors _int_nodata_in_range in _reader.py) so uint16+GDAL_NODATA=""-9999"" stays a no-op; rewrite all-sentinel-block NaN back to sentinel before the integer dtype cast so the cast is well-defined (the caller's post-overview loop in write() only runs for floats). GPU mirror gets the same path with cupy.where + cupy.isnan for byte parity with CPU. 38 regression tests in test_cog_int_overview_nodata_2026_05_12.py: _block_reduce_2d per-dtype/per-method matrix (uint8/uint16/int16/int32 x mean/min/max/median), all-sentinel-block, no-nodata regression, out-of-range sentinel no-op, end-to-end uint16 + int16 round-trip, 3-band integer COG, GPU per-dtype/per-method matrix, CPU/GPU byte-match parity. All 1606 existing geotiff tests still pass. Categories: Cat 1 (precision/representation loss in nan-aware reduction) + Cat 2 (silent NaN-equivalent corruption from sentinel poisoning) + Cat 5 (backend parity between float and integer code paths within the same writer). Deferred LOW: HTTP COG path (_read_cog_http at _reader.py:1638) skips the band-range validation that local/dask/GPU added in #1673; band=-1 silently selects the last channel on HTTP while local raises IndexError. Cat 5, MEDIUM-leaning but separate concern from the overview fix; one-finding-per-PR per project policy. | Pass 19 (2026-05-12): MEDIUM fixed -- issue #1655. read_vrt silently dropped 0 on a SimpleSource because of src.nodata or nodata at _vrt.py:370. Python treats 0.0 as falsy, so the per-source sentinel fell through to the band-level (or None when missing) and pixels equal to 0.0 in the source file survived as valid data. The in-code comment acknowledged the quirk as backward compat, but the resulting behaviour silently biased every NaN-aware aggregation on VRT mosaics whose sources used 0 as a sentinel (a common convention for unsigned remote-sensing imagery). Fix: src_nodata = src.nodata if src.nodata is not None else nodata. Five regression tests in test_vrt_source_nodata_zero_1655.py covering source NODATA=0, integer XML literal, non-zero unchanged, band-level NoDataValue=0 still honoured, and source-overrides-band precedence. All 100 vrt-related geotiff tests still pass; 3 pre-existing test_features.py matplotlib palette failures unrelated. Categories: Cat 2 (NaN propagation) + Cat 5 (backend inconsistency: read_geotiff masks 0 correctly when GDAL_NODATA tag is set; only VRT path was broken). | Pass 18 (2026-05-11): MEDIUM fixed -- issue #1642. PR #1641 (issue #1640) inherited level-0 georef on overview reads but kept the level-0 origin_x/origin_y unchanged. That is correct for PixelIsArea (origin = upper-left corner of pixel (0,0)) but wrong for PixelIsPoint (origin = center of pixel (0,0), GeoKey 1025 = 2). For a 1024x1024 PixelIsPoint COG with 10 m pixels and origin (0, 0), open_geotiff(overview_level=1) returned x[:3]=[0,20,40] instead of [5,25,45] (level-1 pixel 0 covers level-0 pixels 0-1 whose centers are 0 and 10, centroid 5); same for y. Downstream sel/interp/reproject silently snaps to the wrong pixel for any DEM-style PixelIsPoint COG (USGS, OpenTopography, Copernicus DEM). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (raster_type-dependent backend convention). Fix: in extract_geo_info_with_overview_inheritance (_geotags.py), pick the effective raster_type first (overview-declared if non-default, otherwise inherited from parent), then when it is PixelIsPoint apply origin_shift = (scale - 1) * 0.5 * pixel_size_lvl0 along each axis before building the new GeoTransform. PixelIsArea path is byte-equivalent. 13 regression tests in test_overview_pixel_is_point_1642.py: centroid identity across all 4 backends, transform tuple across all 4 backends, uniform grid step, unit-level helper tests for both raster_types via stubbed extract_geo_info, own-geokeys-not-clobbered path on PixelIsPoint, and a PixelIsArea regression check. All 1397 existing non-network geotiff tests still pass (3 pre-existing matplotlib palette failures unrelated). Deferred LOW: non-power-of-two overview dimensions cause scale = base_w/ov_w to diverge from the true 2^level reduction (writer drops the right/bottom strip via h2=(h//2)*2; for h=1023 a level-1 overview has 511 rows so scale=2.0019 not 2.0). Fix would need to either (a) emit explicit geo tags on overview IFDs from the writer or (b) pass the level number into the inheritance helper; neither is a one-line change and the resulting coord error is sub-pixel of level 0. | Pass 17 (2026-05-11): MEDIUM fixed -- issue #1634. open_geotiff eager path windowed read produced confusing CoordinateValidationError when window extended past source extent. read_to_array clamped the window internally and returned a smaller array, but the eager code path used unclamped window indices for y/x coord generation (xrspatial/geotiff/__init__.py lines 562-572), so the coord array length differed from the data and xarray refused to construct the DataArray. Same bug affected the windowed transform shift in _populate_attrs_from_geo_info. The dask path (read_geotiff_dask) already validated up front since #1561, raising a clear ValueError with the format 'window=... is outside the source extent (HxW) or has non-positive size.' so the two backends diverged on the contract. Fix: validate the window up front in open_geotiff's eager branch via _read_geo_info (metadata-only read, no extra pixel cost) using the exact same condition the dask path uses, raising the same ValueError message format. Reproduction: 10x10 raster + window=(5,5,15,15) on eager raised CoordinateValidationError('conflicting sizes ... length 5 ... length 10'); now raises ValueError('window=(5, 5, 15, 15) is outside the source extent (10x10) or has non-positive size.'). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (backend inconsistency). 12 regression tests in test_window_out_of_bounds_1634.py: negative start, past-right-edge, past-bottom-edge, past-both-edges, zero-size, inverted window, full-extent ok, interior subset, edge-aligned, eager-vs-dask parity, message-format parity, issue reproducer. All 1286 existing non-network geotiff tests still pass. | Pass 16 (2026-05-11): HIGH fixed -- issue #1623. to_geotiff(cog=True, overview_resampling='cubic', nodata=) on a float raster with NaN regions produced overview pixels with severe ringing artefacts near nodata borders. Same class of bug as #1613 but for the cubic branch: writer rewrites NaN to the sentinel upstream, then _block_reduce_2d(method=cubic) handed the sentinel-poisoned array straight to scipy.ndimage.zoom(order=3). The cubic spline blended the sentinel (e.g. -9999) into neighbouring cells, producing values like 1133.44, -10290.08 where the data was a constant 100. Repro on 16x16 float32 with a 4x4 NaN corner showed 18 polluted pixels in the 8x8 overview. Fix: when nodata is supplied on a float dtype and the sentinel is found, mask sentinel to NaN, run cubic with prefilter=False so a single NaN cannot poison the entire row/column (default B-spline prefilter is global), then rewrite any NaN in the result back to the sentinel. prefilter=False only fires when a sentinel is present so the non-nodata cubic semantics are unchanged. GPU side: _block_reduce_2d_gpu previously raised on method='cubic'; added a CPU fallback (same pattern as 'mode') so GPU writer produces byte-equivalent overviews. GPU_OVERVIEW_METHODS now includes 'cubic'. 12 regression tests in test_cog_cubic_overview_nodata_1623.py (helper no-ringing, poisoning repro, no-nodata unchanged, end-to-end round-trip, GPU fallback, CPU/GPU byte-match, +/-inf nodata mask, NaN-sentinel no-op, GPU_OVERVIEW_METHODS contract). All 1256 existing geotiff tests still pass (3 pre-existing matplotlib failures unrelated). | Pass 15 (2026-05-11): HIGH fixed -- issue #1613. to_geotiff(cog=True, nodata=) on a float raster with NaN produced a corrupted overview pyramid. The NaN-to-sentinel rewrite in __init__.py:1202 (CPU) and :2852 (GPU write_geotiff_gpu) ran BEFORE _make_overview / make_overview_gpu, so the nan-aware aggregations (np.nanmean/min/max/median, cupy.nanmean/min/max/median) saw the sentinel as a real number and biased every overview pixel. Reproduction with -9999 sentinel produced [[-4998.75,-4997.75],..] where np.nanmean gives [[1.5,3.5],..]. Both CPU and GPU paths affected; backend results matched each other but were both wrong (CAT 2 NaN propagation + CAT 5 documents the parity). Fix: _block_reduce_2d / _block_reduce_2d_gpu accept a nodata kwarg that masks the sentinel back to NaN for float dtypes before the reduction; the writer's overview loop passes nodata in, then rewrites all-sentinel reductions (which surface as NaN from the reducer) back to the sentinel for the on-disk pyramid. 11 regression tests in test_cog_overview_nodata_1613.py (CPU mean / partial-block / min/max/median / no-nodata passthrough / helper kwarg / all-sentinel block / GPU mean / GPU helper / CPU-GPU agreement). All 235 nodata/overview/cog tests still pass. | Pass 14 (2026-05-11): HIGH fixed -- issue #1611. read_vrt(band=None) on a multi-band integer VRT with per-band tags only masks band 0's sentinel. __init__.py lines 2795-2809 in read_vrt apply vrt.bands[0].nodata to the full ndim==3 array; bands 1+ keep their integer sentinels as literal finite values (e.g. 65000 surfaces as 65000.0 after the dtype=float64 cast, not NaN). Float-VRT path masks per-band correctly in _vrt._read_data lines 296-297 + 347-351. PR #1602 fixed the single-band band=N case for issue #1598; the band=None multi-band case is the same class of bug. Repro: 2-band uint16 VRT with NoDataValue 65535 / 65000 returns r.values[1,1,1] == 65000.0 instead of NaN; r.values[1,1,0] is NaN (band 0 sentinel masked). Fix scope: in read_vrt, when band is None, iterate over vrt.bands and mask each arr[..., i] slice against its own (gated by the same _int_nodata_in_range guard PR #1583 introduced). Severity HIGH (Cat 2 NaN propagation + Cat 5 backend inconsistency: identical input semantics produce different masking outcomes based on dtype, with finite garbage values where NaN expected). Fix in PR #1612: walks vrt.bands when band is None and ndim==3, masks each arr[..., i] slice against its own via the refactored _sentinel_for_dtype helper (reuses PR #1583's range guard so out-of-range/non-finite/fractional sentinels are a no-op). attrs['nodata'] still carries band 0's sentinel for band=None reads (documented contract). 7 regression tests in test_vrt_multiband_int_nodata_1611.py: uint16 per-band, int32 negative, mixed presence, dtype preservation when no sentinel hit, out-of-range gating, band=N non-regression, attrs contract. 135 existing vrt/nodata geotiff tests still pass. | Pass 13 (2026-05-11): HIGH fixed -- issue #1599. write_geotiff_gpu (and to_geotiff gpu=True) emitted raw NaN bytes for missing pixels even when nodata= was supplied, while the CPU writer substituted NaN with the sentinel before encoding. xrspatial-only round-trips were unaffected (the reader masks both NaN and the sentinel), but external readers (rasterio/GDAL/QGIS) that mask only on the GDAL_NODATA tag saw NaN pixels as valid data -- rasterio reported 100% valid pixels on a 25-NaN file vs CPU's 25-invalid report. Root cause: __init__.py lines 2579-2587 jumped from shape/dtype resolution straight to compression, missing the equivalent of the CPU writer's NaN-to-sentinel rewrite at to_geotiff line ~1156. Fix: cupy.isnan + masked write on a defensive copy of arr, gated on np_dtype.kind=='f' and not np.isnan(float(nodata)). Caller's CuPy buffer preserved (copy before mutate). 7 regression tests in test_gpu_writer_nan_sentinel_1599.py: substitution lands as sentinel, CPU/GPU byte-equivalent, caller buffer not mutated, no-NaN no-op, NaN sentinel skips substitution, rasterio sees identical invalid count on CPU/GPU, multiband 3D path. All other GPU writer tests still pass (50 passed across band-first, attrs, nodata, dask+cupy, writer, nodata aliases). | Pass 12 (2026-05-11): HIGH fixed -- issue #1581. Reading a uint TIFF with a negative GDAL_NODATA sentinel (e.g. uint16 + -9999) raised OverflowError on every backend because the nodata-mask code did arr.dtype.type(int(nodata)) with no range check. Three identical cast sites in __init__.py (numpy eager, _apply_nodata_mask_gpu, _delayed_read_window) plus _resolve_masked_fill and _sparse_fill_value in _reader.py. Fix: _int_nodata_in_range helper gates the cast; out-of-range sentinels are a no-op for value matching (the file can never contain that value), file dtype is preserved, attrs['nodata'] still surfaces the original sentinel so write round-trips keep the GDAL_NODATA tag intact. Matches rasterio behavior. 8 regression tests in test_nodata_out_of_range_1581.py cover the helper, both eager and dask read paths, in-range sentinel non-regression, and GPU helper (cupy-gated). | Pass 11 (2026-05-10): CLEAN. Audited the one additional commit since pass 10 -- #1559 (PR 1548, Centralise GeoTIFF attrs population across all read backends). Refactor extracts _populate_attrs_from_geo_info helper and routes eager numpy, dask, GPU stripped, GPU tiled read paths through it; before the fix dask only emitted crs/transform/raster_type/nodata while numpy emitted the full attrs set including x/y_resolution, resolution_unit, image_description, extra_samples, GDAL metadata, and the CRS-description fields. No data-path arithmetic touched; only attrs dict population. Windowed origin math (origin_x + c0*pixel_width, origin_y + r0*pixel_height) verified to produce -98.0 / 48.75 origin for window=(10,20,50,70) on a (0.1,-0.125) pixel-size raster, with PixelIsArea half-pixel offset preserved on coord lookups (-97.95, 48.6875). Cross-backend attrs parity re-verified: numpy/dask/cupy all emit identical key set on deflate+predictor3+nodata round-trip (crs, crs_wkt, nodata, transform, x_resolution, y_resolution). Data bit-parity re-verified across numpy/dask/cupy on same payload (np.array_equal with equal_nan=True). test_attrs_parity_1548.py (5 tests), test_reader.py/test_writer.py/test_dask_cupy_combined.py (25 tests), GPU orientation/predictor2-BE/LERC-mask/nodata/byteswap suites (65 tests) all green. No accuracy or backend-divergence findings. | Pass 10 (2026-05-10): CLEAN. Audited 5 recent commits: #1558 drop-defensive-copies (frombuffer path still .copy()s before in-place predictor decode at _reader.py:778), #1556 fp-predictor ngjit (writer pre-ravels so 1-D slice arg is correct, float32/64 LE+BE bit-exact), #1552 batched D2H (OOM guard fires before cupy.concatenate, host_buf offsets correct), #1551 parallel-decode gate (>= vs > sends 256x256 default to parallel path, no value diff confirmed via partial-tile parity), #1549 nvjpeg constants (gray + RGB GPU JPEG decode pixel-identical to Pillow CPU, max diff = 0). Cross-backend parity re-verified clean: numpy/dask+numpy/cupy/dask+cupy equal .data/.dtype/.coords/nodata/NaN-mask on deflate+predictor3+nodata; orientations 1-8 numpy==GPU; partial edge tiles 100x150, 257x383, 512x257 numpy==GPU==dask; predictor2 LE/BE round-trip uint8/int16/uint16/int32/uint32 pass; predictor3 LE/BE float32/64 pass. Deferred LOW (pre-existing, not opened): float16 (bps=16, SampleFormat=3) absent from tiff_dtype_to_numpy map - writer never emits, asymmetric but unreachable. | Pass 9 (2026-05-09): TWO HIGH fixed -- (a) PR #1539 closes #1537: TIFF Orientation tag 2/3/4 (mirror flips) on georeferenced files left y/x coords computed from the un-flipped transform, so xarray label lookups returned the wrong pixel even though _apply_orientation flipped the buffer. PR #1521 only updated the transform for the 5-8 axis-swap branch. Fix updates origin and pixel-scale signs along whichever axes were flipped, for both PixelIsArea (origin shifts by N*step) and PixelIsPoint (shifts by (N-1)*step). 10 new tests in test_orientation.py. (b) PR #1546 closes #1540: read_geotiff_gpu ignored Orientation tag completely; CPU correctly applied 2-8 (PR #1521) but GPU returned the raw stored buffer. Cross-backend disagreement on every non-default orientation. Fix adds _apply_orientation_gpu (cupy slicing mirror of the CPU helper) and _apply_orientation_geo_info, threads them into the tiled GPU pipeline, reuses CPU-fallback geo_info for the stripped path to avoid double-applying. 28 new tests in test_orientation_gpu.py (every orientation, single-band tiled, single-band stripped, 3-band tiled, mirror-flip sel-fidelity, default no-tag passthrough). Re-confirmed clean: HTTP coalesce_ranges with overlapping ranges and zero-length ranges, parallel streaming write thread-safety (each tile gets independent buffer via copy or padded zeros), planar=2 + chunky GPU LERC mask propagation matches CPU, IFD chain cap MAX_IFDS=256, max_z_error round-trip on tiled write, _resolve_masked_fill float vs integer dtype semantics. Deferred LOW: per-sample LERC mask (3D mask (h,w,samples)) collapsed to per-pixel ""any sample invalid"" on GPU while CPU honours per-sample; LERC implementations rarely emit 3D masks (verified: lerc.encode with 2D mask on 3-band returns 2D mask). Documented planar=2 + LERC + GPU silently drops mask (rare in practice, source comment acknowledges). | Pass 8 (2026-05-07): HIGH fixed in fix-jpeg-tiff-disable -- to_geotiff(compression='jpeg') wrote files that no external reader can decode. The writer tags compression=7 (new-style JPEG) but emits a self-contained JFIF stream per tile/strip and never writes the JPEGTables tag (347) that the TIFF spec requires for that codec. libtiff/GDAL/rasterio all reject the file with TIFFReadEncodedStrip() failed; our reader round-trips because Pillow decodes the standalone JFIF, hiding the break. Pass-4 notes flagged the read side of the same JPEGTables gap and deferred it; pass-8 covers the write side. Fix: reject compression='jpeg' at the to_geotiff entry with a clear ValueError pointing at deflate/zstd/lzw. The internal _writer.write is untouched so the existing self-decoding tests still cover the codec; re-enabling the public path needs a JPEGTables-aware encoder. PR diffs reviewed but not merged: #1512 (BytesIO source) and #1513 (LERC max_z_error) -- both look correct; #1512 file-like read path goes through read_all() once so the per-call BytesIOSource lock is theoretical, and #1513 forwards max_z_error through every overview/tile/strip/streaming path including _write_vrt_tiled and _compress_block. No regressions found in either open PR. Other surfaces audited clean: predictor=3 with float16 (writer auto-promotes to float32 on both eager and streaming paths, value-exact round-trip); planar=2 multi-tile read uses band_idx*tiles_per_band offset so no cross-contamination between planes; _header.py multi-byte tag parsing uses bo (byte_order) consistently; Pillow YCbCr-vs-tagged-RGB photometric mismatch becomes moot once JPEG is disabled. Deferred (LOW/MEDIUM, not filed): JPEG2000 writer accepts arbitrary dtype with no validation (rare codec, narrow risk); float16 dtype not in tiff_dtype_to_numpy decode map (writer never emits it - asymmetric but unreachable); Orientation tag (274) still ignored on read (pass-4 deferral). | Pass 7 (2026-05-07): HIGH fixed in fix-mmap-cache-refcount-after-replace -- _MmapCache.release() looked up the cache entry by realpath, so a holder that acquired the OLD mmap before an os.replace and released it AFTER another caller had acquired the post-replace entry would decrement the new holder's refcount. Subsequent eviction (cache full, or another acquire) closed the still-in-use mmap, breaking reads with 'mmap closed or invalid'. Real exposure: any concurrent reader/writer pattern where to_geotiff replaces a file that another reader had just opened via open_geotiff with chunks= or via _FileSource. PR #1506 added stale-replacement detection but did not fix the refcount confusion across the pop. Fix: acquire returns an opaque entry token; release takes the token and decrements that exact entry, regardless of cache state. Orphaned (popped) entries close their fh+mmap when their own refcount hits zero. _FileSource updated to pass the token. Regression test test_release_after_path_replacement_does_not_clobber_new_holder added. All 665 geotiff tests pass; GPU path verified. | Pass 6 (2026-05-07) PR #1507: BE pred2 numba TypingError. | Pass 5 (2026-05-06) PR #1506: mmap cache stale after file replace. | Pass 4 (2026-05-06) PR #1501: sparse COG tiles. | Pass 3 (2026-05-06) PR #1500: predictor=3 byte order. | Pass 2 (2026-05-05) PR #1498: predictor=2 sample-wise. | Pass 1 (2026-04-23) PR #1247. Re-confirmed clean over passes 2-7: items 2 (writer always emits LE TIFFs - hardcoded b'II'), 3 (RowsPerStrip default = height when missing), 4 (StripByteCounts missing raises clear ValueError), 5 (TileWidth without TileLength caught by 'tw <= 0 or th <= 0' check at _reader.py:688), 9 (read determinism on compressed+tiled+multiband), 11 (predictor=2 with awkward sample stride round-trips), 18 (compression_level=99 raises ValueError 'out of range for deflate (valid: 1-9)'), 21 (concurrent writes serialize correctly via mkstemp+os.replace), 24 (uint16 dtype preserved on numpy backend, dask honors chunks param), 26 (chunks rounds correctly with remainder chunk for non-tile-aligned). Deferred: item 8 (BytesIO/file-like sources are not supported, source.lower() error) - documented as 'str' parameter, not a bug; item 19 (LERC max_z_error not user-exposed by to_geotiff) - missing feature, not a bug." +geotiff,2026-05-13,1774,MEDIUM,2;5,"Pass 21 (2026-05-13): MEDIUM fixed -- issue #1774. open_geotiff / read_geotiff_dask / _apply_nodata_mask_gpu crashed with ValueError: cannot convert float NaN to integer when reading an integer TIFF whose GDAL_NODATA tag was the string ""nan"" / ""inf"" / ""-inf"". Three sites in xrspatial/geotiff/__init__.py called int(nodata) on the integer-dtype branch without first checking np.isfinite. _geotags.py:extract_geo_info parses the GDAL_NODATA tag through float(nodata_str) so a ""nan"" tag surfaces as Python NaN; the integer mask code then explodes. Sibling helpers _resolve_masked_fill and _sparse_fill_value in _reader.py already gate on not math.isnan(v) and not math.isinf(v) (the unfinished pass of #1581). Fix: gate each int(nodata) cast on np.isfinite(nodata). A non-finite sentinel on an integer file cannot match any pixel, so the mask is a no-op and the file dtype is preserved; attrs['nodata'] still carries the raw NaN/Inf sentinel so a write round-trip keeps the original GDAL_NODATA tag. The read_geotiff_dask effective_dtype branch already used try/except and was safe in practice, but tightened with the same isfinite gate for readability. 15 regression tests in test_nodata_nan_int_1774.py covering eager numpy (3 NaN variants + 6 Inf variants), in-range finite still masks regression guard, dask (NaN + Inf), and GPU (NaN + Inf + finite). All pass; 2023 existing geotiff tests still pass (7 pre-existing test_predictor2_big_endian_gpu failures unrelated: they reference xrspatial.geotiff.read_to_array which was hidden from the public namespace in #1708, 3 pre-existing matplotlib palette failures in test_features.py unrelated). Categories: Cat 2 (NaN propagation: NaN nodata produced a crash instead of being treated as missing) + Cat 5 (backend inconsistency: _resolve_masked_fill / _sparse_fill_value already guarded; the three __init__.py sites did not). | Pass 20 (2026-05-12): HIGH fixed -- PR #1691 (no issue created; agent harness blocked gh issue create). Integer COG overview pyramid mixed sentinel into reduced pixels. _block_reduce_2d (_writer.py:258-264) and _block_reduce_2d_gpu (_gpu_decode.py:3027-3028) promoted integer blocks to float64 but never masked the sentinel to NaN before nanmean / nanmin / nanmax / nanmedian. The reduction averaged the sentinel into surrounding valid cells (e.g. (-9999 + 100 + 100 + 100)/4 = -2425 cast back to int16), producing overview pixels that the read-side int-to-NaN mask in open_geotiff couldn't recover because they didn't equal the sentinel. Silent garbage at every zoom above level 0 for to_geotiff(int_data, cog=True, nodata=N). Methods affected: mean, min, max, median; nearest/mode safe (no averaging). Fix: gate the sentinel-to-NaN mask on representability in the source integer dtype (mirrors _int_nodata_in_range in _reader.py) so uint16+GDAL_NODATA=""-9999"" stays a no-op; rewrite all-sentinel-block NaN back to sentinel before the integer dtype cast so the cast is well-defined (the caller's post-overview loop in write() only runs for floats). GPU mirror gets the same path with cupy.where + cupy.isnan for byte parity with CPU. 38 regression tests in test_cog_int_overview_nodata_2026_05_12.py: _block_reduce_2d per-dtype/per-method matrix (uint8/uint16/int16/int32 x mean/min/max/median), all-sentinel-block, no-nodata regression, out-of-range sentinel no-op, end-to-end uint16 + int16 round-trip, 3-band integer COG, GPU per-dtype/per-method matrix, CPU/GPU byte-match parity. All 1606 existing geotiff tests still pass. Categories: Cat 1 (precision/representation loss in nan-aware reduction) + Cat 2 (silent NaN-equivalent corruption from sentinel poisoning) + Cat 5 (backend parity between float and integer code paths within the same writer). Deferred LOW: HTTP COG path (_read_cog_http at _reader.py:1638) skips the band-range validation that local/dask/GPU added in #1673; band=-1 silently selects the last channel on HTTP while local raises IndexError. Cat 5, MEDIUM-leaning but separate concern from the overview fix; one-finding-per-PR per project policy. | Pass 19 (2026-05-12): MEDIUM fixed -- issue #1655. read_vrt silently dropped 0 on a SimpleSource because of src.nodata or nodata at _vrt.py:370. Python treats 0.0 as falsy, so the per-source sentinel fell through to the band-level (or None when missing) and pixels equal to 0.0 in the source file survived as valid data. The in-code comment acknowledged the quirk as backward compat, but the resulting behaviour silently biased every NaN-aware aggregation on VRT mosaics whose sources used 0 as a sentinel (a common convention for unsigned remote-sensing imagery). Fix: src_nodata = src.nodata if src.nodata is not None else nodata. Five regression tests in test_vrt_source_nodata_zero_1655.py covering source NODATA=0, integer XML literal, non-zero unchanged, band-level NoDataValue=0 still honoured, and source-overrides-band precedence. All 100 vrt-related geotiff tests still pass; 3 pre-existing test_features.py matplotlib palette failures unrelated. Categories: Cat 2 (NaN propagation) + Cat 5 (backend inconsistency: read_geotiff masks 0 correctly when GDAL_NODATA tag is set; only VRT path was broken). | Pass 18 (2026-05-11): MEDIUM fixed -- issue #1642. PR #1641 (issue #1640) inherited level-0 georef on overview reads but kept the level-0 origin_x/origin_y unchanged. That is correct for PixelIsArea (origin = upper-left corner of pixel (0,0)) but wrong for PixelIsPoint (origin = center of pixel (0,0), GeoKey 1025 = 2). For a 1024x1024 PixelIsPoint COG with 10 m pixels and origin (0, 0), open_geotiff(overview_level=1) returned x[:3]=[0,20,40] instead of [5,25,45] (level-1 pixel 0 covers level-0 pixels 0-1 whose centers are 0 and 10, centroid 5); same for y. Downstream sel/interp/reproject silently snaps to the wrong pixel for any DEM-style PixelIsPoint COG (USGS, OpenTopography, Copernicus DEM). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (raster_type-dependent backend convention). Fix: in extract_geo_info_with_overview_inheritance (_geotags.py), pick the effective raster_type first (overview-declared if non-default, otherwise inherited from parent), then when it is PixelIsPoint apply origin_shift = (scale - 1) * 0.5 * pixel_size_lvl0 along each axis before building the new GeoTransform. PixelIsArea path is byte-equivalent. 13 regression tests in test_overview_pixel_is_point_1642.py: centroid identity across all 4 backends, transform tuple across all 4 backends, uniform grid step, unit-level helper tests for both raster_types via stubbed extract_geo_info, own-geokeys-not-clobbered path on PixelIsPoint, and a PixelIsArea regression check. All 1397 existing non-network geotiff tests still pass (3 pre-existing matplotlib palette failures unrelated). Deferred LOW: non-power-of-two overview dimensions cause scale = base_w/ov_w to diverge from the true 2^level reduction (writer drops the right/bottom strip via h2=(h//2)*2; for h=1023 a level-1 overview has 511 rows so scale=2.0019 not 2.0). Fix would need to either (a) emit explicit geo tags on overview IFDs from the writer or (b) pass the level number into the inheritance helper; neither is a one-line change and the resulting coord error is sub-pixel of level 0. | Pass 17 (2026-05-11): MEDIUM fixed -- issue #1634. open_geotiff eager path windowed read produced confusing CoordinateValidationError when window extended past source extent. read_to_array clamped the window internally and returned a smaller array, but the eager code path used unclamped window indices for y/x coord generation (xrspatial/geotiff/__init__.py lines 562-572), so the coord array length differed from the data and xarray refused to construct the DataArray. Same bug affected the windowed transform shift in _populate_attrs_from_geo_info. The dask path (read_geotiff_dask) already validated up front since #1561, raising a clear ValueError with the format 'window=... is outside the source extent (HxW) or has non-positive size.' so the two backends diverged on the contract. Fix: validate the window up front in open_geotiff's eager branch via _read_geo_info (metadata-only read, no extra pixel cost) using the exact same condition the dask path uses, raising the same ValueError message format. Reproduction: 10x10 raster + window=(5,5,15,15) on eager raised CoordinateValidationError('conflicting sizes ... length 5 ... length 10'); now raises ValueError('window=(5, 5, 15, 15) is outside the source extent (10x10) or has non-positive size.'). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (backend inconsistency). 12 regression tests in test_window_out_of_bounds_1634.py: negative start, past-right-edge, past-bottom-edge, past-both-edges, zero-size, inverted window, full-extent ok, interior subset, edge-aligned, eager-vs-dask parity, message-format parity, issue reproducer. All 1286 existing non-network geotiff tests still pass. | Pass 16 (2026-05-11): HIGH fixed -- issue #1623. to_geotiff(cog=True, overview_resampling='cubic', nodata=) on a float raster with NaN regions produced overview pixels with severe ringing artefacts near nodata borders. Same class of bug as #1613 but for the cubic branch: writer rewrites NaN to the sentinel upstream, then _block_reduce_2d(method=cubic) handed the sentinel-poisoned array straight to scipy.ndimage.zoom(order=3). The cubic spline blended the sentinel (e.g. -9999) into neighbouring cells, producing values like 1133.44, -10290.08 where the data was a constant 100. Repro on 16x16 float32 with a 4x4 NaN corner showed 18 polluted pixels in the 8x8 overview. Fix: when nodata is supplied on a float dtype and the sentinel is found, mask sentinel to NaN, run cubic with prefilter=False so a single NaN cannot poison the entire row/column (default B-spline prefilter is global), then rewrite any NaN in the result back to the sentinel. prefilter=False only fires when a sentinel is present so the non-nodata cubic semantics are unchanged. GPU side: _block_reduce_2d_gpu previously raised on method='cubic'; added a CPU fallback (same pattern as 'mode') so GPU writer produces byte-equivalent overviews. GPU_OVERVIEW_METHODS now includes 'cubic'. 12 regression tests in test_cog_cubic_overview_nodata_1623.py (helper no-ringing, poisoning repro, no-nodata unchanged, end-to-end round-trip, GPU fallback, CPU/GPU byte-match, +/-inf nodata mask, NaN-sentinel no-op, GPU_OVERVIEW_METHODS contract). All 1256 existing geotiff tests still pass (3 pre-existing matplotlib failures unrelated). | Pass 15 (2026-05-11): HIGH fixed -- issue #1613. to_geotiff(cog=True, nodata=) on a float raster with NaN produced a corrupted overview pyramid. The NaN-to-sentinel rewrite in __init__.py:1202 (CPU) and :2852 (GPU write_geotiff_gpu) ran BEFORE _make_overview / make_overview_gpu, so the nan-aware aggregations (np.nanmean/min/max/median, cupy.nanmean/min/max/median) saw the sentinel as a real number and biased every overview pixel. Reproduction with -9999 sentinel produced [[-4998.75,-4997.75],..] where np.nanmean gives [[1.5,3.5],..]. Both CPU and GPU paths affected; backend results matched each other but were both wrong (CAT 2 NaN propagation + CAT 5 documents the parity). Fix: _block_reduce_2d / _block_reduce_2d_gpu accept a nodata kwarg that masks the sentinel back to NaN for float dtypes before the reduction; the writer's overview loop passes nodata in, then rewrites all-sentinel reductions (which surface as NaN from the reducer) back to the sentinel for the on-disk pyramid. 11 regression tests in test_cog_overview_nodata_1613.py (CPU mean / partial-block / min/max/median / no-nodata passthrough / helper kwarg / all-sentinel block / GPU mean / GPU helper / CPU-GPU agreement). All 235 nodata/overview/cog tests still pass. | Pass 14 (2026-05-11): HIGH fixed -- issue #1611. read_vrt(band=None) on a multi-band integer VRT with per-band tags only masks band 0's sentinel. __init__.py lines 2795-2809 in read_vrt apply vrt.bands[0].nodata to the full ndim==3 array; bands 1+ keep their integer sentinels as literal finite values (e.g. 65000 surfaces as 65000.0 after the dtype=float64 cast, not NaN). Float-VRT path masks per-band correctly in _vrt._read_data lines 296-297 + 347-351. PR #1602 fixed the single-band band=N case for issue #1598; the band=None multi-band case is the same class of bug. Repro: 2-band uint16 VRT with NoDataValue 65535 / 65000 returns r.values[1,1,1] == 65000.0 instead of NaN; r.values[1,1,0] is NaN (band 0 sentinel masked). Fix scope: in read_vrt, when band is None, iterate over vrt.bands and mask each arr[..., i] slice against its own (gated by the same _int_nodata_in_range guard PR #1583 introduced). Severity HIGH (Cat 2 NaN propagation + Cat 5 backend inconsistency: identical input semantics produce different masking outcomes based on dtype, with finite garbage values where NaN expected). Fix in PR #1612: walks vrt.bands when band is None and ndim==3, masks each arr[..., i] slice against its own via the refactored _sentinel_for_dtype helper (reuses PR #1583's range guard so out-of-range/non-finite/fractional sentinels are a no-op). attrs['nodata'] still carries band 0's sentinel for band=None reads (documented contract). 7 regression tests in test_vrt_multiband_int_nodata_1611.py: uint16 per-band, int32 negative, mixed presence, dtype preservation when no sentinel hit, out-of-range gating, band=N non-regression, attrs contract. 135 existing vrt/nodata geotiff tests still pass. | Pass 13 (2026-05-11): HIGH fixed -- issue #1599. write_geotiff_gpu (and to_geotiff gpu=True) emitted raw NaN bytes for missing pixels even when nodata= was supplied, while the CPU writer substituted NaN with the sentinel before encoding. xrspatial-only round-trips were unaffected (the reader masks both NaN and the sentinel), but external readers (rasterio/GDAL/QGIS) that mask only on the GDAL_NODATA tag saw NaN pixels as valid data -- rasterio reported 100% valid pixels on a 25-NaN file vs CPU's 25-invalid report. Root cause: __init__.py lines 2579-2587 jumped from shape/dtype resolution straight to compression, missing the equivalent of the CPU writer's NaN-to-sentinel rewrite at to_geotiff line ~1156. Fix: cupy.isnan + masked write on a defensive copy of arr, gated on np_dtype.kind=='f' and not np.isnan(float(nodata)). Caller's CuPy buffer preserved (copy before mutate). 7 regression tests in test_gpu_writer_nan_sentinel_1599.py: substitution lands as sentinel, CPU/GPU byte-equivalent, caller buffer not mutated, no-NaN no-op, NaN sentinel skips substitution, rasterio sees identical invalid count on CPU/GPU, multiband 3D path. All other GPU writer tests still pass (50 passed across band-first, attrs, nodata, dask+cupy, writer, nodata aliases). | Pass 12 (2026-05-11): HIGH fixed -- issue #1581. Reading a uint TIFF with a negative GDAL_NODATA sentinel (e.g. uint16 + -9999) raised OverflowError on every backend because the nodata-mask code did arr.dtype.type(int(nodata)) with no range check. Three identical cast sites in __init__.py (numpy eager, _apply_nodata_mask_gpu, _delayed_read_window) plus _resolve_masked_fill and _sparse_fill_value in _reader.py. Fix: _int_nodata_in_range helper gates the cast; out-of-range sentinels are a no-op for value matching (the file can never contain that value), file dtype is preserved, attrs['nodata'] still surfaces the original sentinel so write round-trips keep the GDAL_NODATA tag intact. Matches rasterio behavior. 8 regression tests in test_nodata_out_of_range_1581.py cover the helper, both eager and dask read paths, in-range sentinel non-regression, and GPU helper (cupy-gated). | Pass 11 (2026-05-10): CLEAN. Audited the one additional commit since pass 10 -- #1559 (PR 1548, Centralise GeoTIFF attrs population across all read backends). Refactor extracts _populate_attrs_from_geo_info helper and routes eager numpy, dask, GPU stripped, GPU tiled read paths through it; before the fix dask only emitted crs/transform/raster_type/nodata while numpy emitted the full attrs set including x/y_resolution, resolution_unit, image_description, extra_samples, GDAL metadata, and the CRS-description fields. No data-path arithmetic touched; only attrs dict population. Windowed origin math (origin_x + c0*pixel_width, origin_y + r0*pixel_height) verified to produce -98.0 / 48.75 origin for window=(10,20,50,70) on a (0.1,-0.125) pixel-size raster, with PixelIsArea half-pixel offset preserved on coord lookups (-97.95, 48.6875). Cross-backend attrs parity re-verified: numpy/dask/cupy all emit identical key set on deflate+predictor3+nodata round-trip (crs, crs_wkt, nodata, transform, x_resolution, y_resolution). Data bit-parity re-verified across numpy/dask/cupy on same payload (np.array_equal with equal_nan=True). test_attrs_parity_1548.py (5 tests), test_reader.py/test_writer.py/test_dask_cupy_combined.py (25 tests), GPU orientation/predictor2-BE/LERC-mask/nodata/byteswap suites (65 tests) all green. No accuracy or backend-divergence findings. | Pass 10 (2026-05-10): CLEAN. Audited 5 recent commits: #1558 drop-defensive-copies (frombuffer path still .copy()s before in-place predictor decode at _reader.py:778), #1556 fp-predictor ngjit (writer pre-ravels so 1-D slice arg is correct, float32/64 LE+BE bit-exact), #1552 batched D2H (OOM guard fires before cupy.concatenate, host_buf offsets correct), #1551 parallel-decode gate (>= vs > sends 256x256 default to parallel path, no value diff confirmed via partial-tile parity), #1549 nvjpeg constants (gray + RGB GPU JPEG decode pixel-identical to Pillow CPU, max diff = 0). Cross-backend parity re-verified clean: numpy/dask+numpy/cupy/dask+cupy equal .data/.dtype/.coords/nodata/NaN-mask on deflate+predictor3+nodata; orientations 1-8 numpy==GPU; partial edge tiles 100x150, 257x383, 512x257 numpy==GPU==dask; predictor2 LE/BE round-trip uint8/int16/uint16/int32/uint32 pass; predictor3 LE/BE float32/64 pass. Deferred LOW (pre-existing, not opened): float16 (bps=16, SampleFormat=3) absent from tiff_dtype_to_numpy map - writer never emits, asymmetric but unreachable. | Pass 9 (2026-05-09): TWO HIGH fixed -- (a) PR #1539 closes #1537: TIFF Orientation tag 2/3/4 (mirror flips) on georeferenced files left y/x coords computed from the un-flipped transform, so xarray label lookups returned the wrong pixel even though _apply_orientation flipped the buffer. PR #1521 only updated the transform for the 5-8 axis-swap branch. Fix updates origin and pixel-scale signs along whichever axes were flipped, for both PixelIsArea (origin shifts by N*step) and PixelIsPoint (shifts by (N-1)*step). 10 new tests in test_orientation.py. (b) PR #1546 closes #1540: read_geotiff_gpu ignored Orientation tag completely; CPU correctly applied 2-8 (PR #1521) but GPU returned the raw stored buffer. Cross-backend disagreement on every non-default orientation. Fix adds _apply_orientation_gpu (cupy slicing mirror of the CPU helper) and _apply_orientation_geo_info, threads them into the tiled GPU pipeline, reuses CPU-fallback geo_info for the stripped path to avoid double-applying. 28 new tests in test_orientation_gpu.py (every orientation, single-band tiled, single-band stripped, 3-band tiled, mirror-flip sel-fidelity, default no-tag passthrough). Re-confirmed clean: HTTP coalesce_ranges with overlapping ranges and zero-length ranges, parallel streaming write thread-safety (each tile gets independent buffer via copy or padded zeros), planar=2 + chunky GPU LERC mask propagation matches CPU, IFD chain cap MAX_IFDS=256, max_z_error round-trip on tiled write, _resolve_masked_fill float vs integer dtype semantics. Deferred LOW: per-sample LERC mask (3D mask (h,w,samples)) collapsed to per-pixel ""any sample invalid"" on GPU while CPU honours per-sample; LERC implementations rarely emit 3D masks (verified: lerc.encode with 2D mask on 3-band returns 2D mask). Documented planar=2 + LERC + GPU silently drops mask (rare in practice, source comment acknowledges). | Pass 8 (2026-05-07): HIGH fixed in fix-jpeg-tiff-disable -- to_geotiff(compression='jpeg') wrote files that no external reader can decode. The writer tags compression=7 (new-style JPEG) but emits a self-contained JFIF stream per tile/strip and never writes the JPEGTables tag (347) that the TIFF spec requires for that codec. libtiff/GDAL/rasterio all reject the file with TIFFReadEncodedStrip() failed; our reader round-trips because Pillow decodes the standalone JFIF, hiding the break. Pass-4 notes flagged the read side of the same JPEGTables gap and deferred it; pass-8 covers the write side. Fix: reject compression='jpeg' at the to_geotiff entry with a clear ValueError pointing at deflate/zstd/lzw. The internal _writer.write is untouched so the existing self-decoding tests still cover the codec; re-enabling the public path needs a JPEGTables-aware encoder. PR diffs reviewed but not merged: #1512 (BytesIO source) and #1513 (LERC max_z_error) -- both look correct; #1512 file-like read path goes through read_all() once so the per-call BytesIOSource lock is theoretical, and #1513 forwards max_z_error through every overview/tile/strip/streaming path including _write_vrt_tiled and _compress_block. No regressions found in either open PR. Other surfaces audited clean: predictor=3 with float16 (writer auto-promotes to float32 on both eager and streaming paths, value-exact round-trip); planar=2 multi-tile read uses band_idx*tiles_per_band offset so no cross-contamination between planes; _header.py multi-byte tag parsing uses bo (byte_order) consistently; Pillow YCbCr-vs-tagged-RGB photometric mismatch becomes moot once JPEG is disabled. Deferred (LOW/MEDIUM, not filed): JPEG2000 writer accepts arbitrary dtype with no validation (rare codec, narrow risk); float16 dtype not in tiff_dtype_to_numpy decode map (writer never emits it - asymmetric but unreachable); Orientation tag (274) still ignored on read (pass-4 deferral). | Pass 7 (2026-05-07): HIGH fixed in fix-mmap-cache-refcount-after-replace -- _MmapCache.release() looked up the cache entry by realpath, so a holder that acquired the OLD mmap before an os.replace and released it AFTER another caller had acquired the post-replace entry would decrement the new holder's refcount. Subsequent eviction (cache full, or another acquire) closed the still-in-use mmap, breaking reads with 'mmap closed or invalid'. Real exposure: any concurrent reader/writer pattern where to_geotiff replaces a file that another reader had just opened via open_geotiff with chunks= or via _FileSource. PR #1506 added stale-replacement detection but did not fix the refcount confusion across the pop. Fix: acquire returns an opaque entry token; release takes the token and decrements that exact entry, regardless of cache state. Orphaned (popped) entries close their fh+mmap when their own refcount hits zero. _FileSource updated to pass the token. Regression test test_release_after_path_replacement_does_not_clobber_new_holder added. All 665 geotiff tests pass; GPU path verified. | Pass 6 (2026-05-07) PR #1507: BE pred2 numba TypingError. | Pass 5 (2026-05-06) PR #1506: mmap cache stale after file replace. | Pass 4 (2026-05-06) PR #1501: sparse COG tiles. | Pass 3 (2026-05-06) PR #1500: predictor=3 byte order. | Pass 2 (2026-05-05) PR #1498: predictor=2 sample-wise. | Pass 1 (2026-04-23) PR #1247. Re-confirmed clean over passes 2-7: items 2 (writer always emits LE TIFFs - hardcoded b'II'), 3 (RowsPerStrip default = height when missing), 4 (StripByteCounts missing raises clear ValueError), 5 (TileWidth without TileLength caught by 'tw <= 0 or th <= 0' check at _reader.py:688), 9 (read determinism on compressed+tiled+multiband), 11 (predictor=2 with awkward sample stride round-trips), 18 (compression_level=99 raises ValueError 'out of range for deflate (valid: 1-9)'), 21 (concurrent writes serialize correctly via mkstemp+os.replace), 24 (uint16 dtype preserved on numpy backend, dask honors chunks param), 26 (chunks rounds correctly with remainder chunk for non-tile-aligned). Deferred: item 8 (BytesIO/file-like sources are not supported, source.lower() error) - documented as 'str' parameter, not a bug; item 19 (LERC max_z_error not user-exposed by to_geotiff) - missing feature, not a bug." glcm,2026-05-01,1408,HIGH,2,"angle=None averaged NaN as 0, masking no-valid-pairs as zero texture; fixed via nanmean-style averaging" hillshade,2026-04-10T12:00:00Z,,,,"Horn's method correct. All backends consistent. NaN propagation correct. float32 adequate for [0,1] output." hydro,2026-04-30,,LOW,1,Only LOW: twi log(0)=-inf if fa=0 (out-of-contract); MFD weighted sum no Kahan (negligible). No CRIT/HIGH issues. diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index fac3fda9..99c522a2 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -897,14 +897,21 @@ def open_geotiff(source: str | BinaryIO, *, # An out-of-range sentinel (e.g. uint16 file with # GDAL_NODATA="-9999") cannot match any decoded pixel, so the # mask would be all-False -- skip the cast that would otherwise - # raise OverflowError and leave the array unchanged. - nodata_int = int(nodata) - info = np.iinfo(arr.dtype) - if info.min <= nodata_int <= info.max: - mask = arr == arr.dtype.type(nodata_int) - if mask.any(): - arr = arr.astype(np.float64) - arr[mask] = np.nan + # raise OverflowError and leave the array unchanged. A + # non-finite sentinel ("NaN" / "Inf" GDAL_NODATA strings) also + # cannot match an integer pixel, so the ``int(nodata)`` cast + # below would raise ValueError; gate on ``np.isfinite`` first + # to mirror ``_resolve_masked_fill`` / ``_sparse_fill_value`` + # in ``_reader.py`` (#1774). attrs['nodata'] still carries the + # raw sentinel so a write round-trip preserves the tag. + if np.isfinite(nodata): + nodata_int = int(nodata) + info = np.iinfo(arr.dtype) + if info.min <= nodata_int <= info.max: + mask = arr == arr.dtype.type(nodata_int) + if mask.any(): + arr = arr.astype(np.float64) + arr[mask] = np.nan if dtype is not None: target = np.dtype(dtype) @@ -969,12 +976,18 @@ def _apply_nodata_mask_gpu(arr_gpu, nodata): arr_dtype.type('nan'), arr_gpu) return arr_gpu if arr_dtype.kind in ('u', 'i'): - nodata_int = int(nodata) - info = np.iinfo(arr_dtype) # Out-of-range sentinels (e.g. uint16 + GDAL_NODATA="-9999") cannot # match any decoded pixel; skip the cast that would otherwise raise - # OverflowError. attrs['nodata'] is still set by the caller so the - # original sentinel survives a write round-trip. + # OverflowError. A non-finite sentinel ("NaN" / "Inf" GDAL_NODATA + # strings) also cannot match an integer pixel and would raise + # ValueError on ``int(nodata)``; gate on ``np.isfinite`` first to + # mirror ``_resolve_masked_fill`` in ``_reader.py`` (#1774). + # attrs['nodata'] is still set by the caller so the original + # sentinel survives a write round-trip. + if not np.isfinite(nodata): + return arr_gpu + nodata_int = int(nodata) + info = np.iinfo(arr_dtype) if not (info.min <= nodata_int <= info.max): return arr_gpu sentinel = arr_dtype.type(nodata_int) @@ -2046,9 +2059,17 @@ def read_geotiff_dask(source: str, *, # Nodata masking promotes integer arrays to float64 (for NaN). # Validate against the effective dtype, not the raw file dtype. # An out-of-range sentinel (e.g. uint16 file + nodata=-9999) is a - # no-op for masking and leaves the file dtype unchanged. + # no-op for masking and leaves the file dtype unchanged. A + # non-finite sentinel ("NaN" / "Inf" GDAL_NODATA strings) cannot + # match an integer pixel either and is short-circuited via the + # ``np.isfinite`` gate so the ``int(...)`` cast never sees NaN + # (#1774). The try/except keeps callers that pass an exotic + # ``nodata`` type (e.g. complex) on the no-op path rather than + # surfacing an opaque error here. effective_dtype = file_dtype - if nodata is not None and file_dtype.kind in ('u', 'i'): + if (nodata is not None + and file_dtype.kind in ('u', 'i') + and np.isfinite(nodata)): try: _nd_int = int(nodata) _info = np.iinfo(file_dtype) @@ -2290,12 +2311,17 @@ def _read(http_meta): # avoid a peak-memory doubler on every dask chunk. if arr.dtype.kind == 'f' and not np.isnan(nodata): arr[arr == arr.dtype.type(nodata)] = np.nan - elif arr.dtype.kind in ('u', 'i'): - nodata_int = int(nodata) - info = np.iinfo(arr.dtype) + elif arr.dtype.kind in ('u', 'i') and np.isfinite(nodata): # Out-of-range sentinels (e.g. uint16 + nodata=-9999) # cannot match any pixel; skip the cast that would # otherwise raise OverflowError and leave arr unchanged. + # Non-finite sentinels ("NaN" / "Inf" GDAL_NODATA strings) + # also cannot match an integer pixel and would raise + # ValueError on ``int(nodata)``; the ``np.isfinite`` gate + # mirrors ``_resolve_masked_fill`` in ``_reader.py`` + # (#1774). + nodata_int = int(nodata) + info = np.iinfo(arr.dtype) if info.min <= nodata_int <= info.max: mask = arr == arr.dtype.type(nodata_int) if mask.any(): diff --git a/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py b/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py new file mode 100644 index 00000000..3dbc1ade --- /dev/null +++ b/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py @@ -0,0 +1,225 @@ +"""Regression tests for issue #1774. + +Reading an integer GeoTIFF whose ``GDAL_NODATA`` tag holds a non-finite +string (``"NaN"`` / ``"nan"`` / ``"Inf"`` / ``"-Inf"``) used to crash with +``ValueError: cannot convert float NaN to integer`` at three call sites in +``xrspatial/geotiff/__init__.py``: + +* ``open_geotiff`` eager numpy path +* ``_apply_nodata_mask_gpu`` (GPU) +* ``_delayed_read_window`` (dask) + +The fix gates each ``int(nodata)`` cast on ``np.isfinite(nodata)``, mirroring +the ``_resolve_masked_fill`` / ``_sparse_fill_value`` helpers in +``_reader.py``. A non-finite sentinel on an integer file cannot match any +pixel value, so the mask is a no-op and the file dtype is preserved. +``attrs['nodata']`` still carries the raw sentinel so a write round-trip +keeps the original GDAL_NODATA tag. +""" +from __future__ import annotations + +import importlib.util +import struct + +import numpy as np +import pytest + + +from xrspatial.geotiff import open_geotiff, read_geotiff_dask + + +def _gpu_available() -> bool: + if importlib.util.find_spec("cupy") is None: + return False + try: + import cupy + return bool(cupy.cuda.is_available()) + except Exception: + return False + + +_HAS_GPU = _gpu_available() +_gpu_only = pytest.mark.skipif( + not _HAS_GPU, + reason="cupy + CUDA required", +) + + +def _build_uint16_tiff(nodata_str: str, tmp_path) -> str: + """Write a minimal 2x2 uint16 TIFF with GDAL_NODATA=. + + Hand-rolled rather than going through ``to_geotiff`` so the GDAL_NODATA + tag carries arbitrary string content (``"nan"``, ``"Inf"``, etc.). The + writer would refuse those at the resolve-nodata step before the file + ever lands on disk. + """ + bo = '<' + width, height = 2, 2 + pixels = np.array([[10, 20], [30, 40]], dtype=np.uint16) + + nodata_bytes = nodata_str.encode('ascii') + b'\x00' + + tag_list: list[tuple[int, int, int, bytes]] = [] + + def add_short(tag: int, val: int) -> None: + tag_list.append((tag, 3, 1, struct.pack(f'{bo}H', val))) + + def add_long(tag: int, val: int) -> None: + tag_list.append((tag, 4, 1, struct.pack(f'{bo}I', val))) + + def add_ascii(tag: int, data: bytes) -> None: + tag_list.append((tag, 2, len(data), data)) + + add_short(256, width) + add_short(257, height) + add_short(258, 16) # BitsPerSample + add_short(259, 1) # Compression = none + add_short(262, 1) # Photometric = MinIsBlack + add_short(277, 1) # SamplesPerPixel + add_short(278, height) # RowsPerStrip + add_long(273, 0) # StripOffsets (patched after layout) + add_long(279, len(pixels.tobytes())) # StripByteCounts + add_short(339, 1) # SampleFormat = uint + add_ascii(42113, nodata_bytes) # GDAL_NODATA + + tag_list.sort(key=lambda t: t[0]) + num_entries = len(tag_list) + ifd_start = 8 + ifd_size = 2 + 12 * num_entries + 4 + overflow_base = ifd_start + ifd_size + overflow_buf = bytearray() + + processed: list[tuple[int, int, int, bytes]] = [] + for tag, typ, count, raw in tag_list: + if len(raw) > 4: + ovf_pos = overflow_base + len(overflow_buf) + overflow_buf.extend(raw) + if len(overflow_buf) % 2: + overflow_buf.append(0) + new_raw = struct.pack(f'{bo}I', ovf_pos) + else: + new_raw = raw + processed.append((tag, typ, count, new_raw)) + + pixel_start = overflow_base + len(overflow_buf) + for i, (tag, typ, count, raw) in enumerate(processed): + if tag == 273: + processed[i] = (tag, typ, count, + struct.pack(f'{bo}I', pixel_start)) + + out = bytearray() + out.extend(b'II') + out.extend(struct.pack(f'{bo}H', 42)) + out.extend(struct.pack(f'{bo}I', ifd_start)) + out.extend(struct.pack(f'{bo}H', num_entries)) + for tag, typ, count, raw in processed: + out.extend(struct.pack(f'{bo}HHI', tag, typ, count)) + out.extend(raw.ljust(4, b'\x00')) + out.extend(struct.pack(f'{bo}I', 0)) # next IFD = 0 + out.extend(overflow_buf) + out.extend(pixels.tobytes()) + + path = str(tmp_path / f'uint16_nodata_{nodata_str.replace("-", "neg")}.tif') + with open(path, 'wb') as f: + f.write(bytes(out)) + return path + + +@pytest.mark.parametrize('nodata_str', ['nan', 'NaN', 'NAN']) +def test_open_geotiff_eager_int_nodata_nan(tmp_path, nodata_str): + """Eager numpy path: NaN nodata on uint16 file is a no-op (#1774).""" + path = _build_uint16_tiff(nodata_str, tmp_path) + da = open_geotiff(path) + # No pixel can match NaN, so the dtype stays uint16 + assert da.dtype == np.uint16 + np.testing.assert_array_equal(da.values, [[10, 20], [30, 40]]) + # The raw sentinel survives on attrs so write round-trips keep the tag + assert 'nodata' in da.attrs + assert np.isnan(da.attrs['nodata']) + + +@pytest.mark.parametrize('nodata_str', ['inf', 'Inf', 'INF', + '-inf', '-Inf', '-INF']) +def test_open_geotiff_eager_int_nodata_inf(tmp_path, nodata_str): + """Eager numpy path: +/-Inf nodata on uint16 file is a no-op (#1774).""" + path = _build_uint16_tiff(nodata_str, tmp_path) + da = open_geotiff(path) + assert da.dtype == np.uint16 + np.testing.assert_array_equal(da.values, [[10, 20], [30, 40]]) + assert 'nodata' in da.attrs + assert np.isinf(da.attrs['nodata']) + + +def test_open_geotiff_eager_int_nodata_finite_still_masks(tmp_path): + """Regression guard: in-range finite sentinel still masks correctly.""" + # 30 is one of the pixel values; using it as a sentinel masks one pixel. + path = _build_uint16_tiff('30', tmp_path) + da = open_geotiff(path) + # uint16 + in-range sentinel hit promotes to float64 with NaN + assert da.dtype == np.float64 + assert np.isnan(da.values[1, 0]) + assert da.values[0, 0] == 10 + assert da.attrs['nodata'] == 30 + + +def test_read_geotiff_dask_int_nodata_nan(tmp_path): + """Dask path: NaN nodata on uint16 file is a no-op (#1774).""" + path = _build_uint16_tiff('nan', tmp_path) + da = read_geotiff_dask(path, chunks=2) + # effective_dtype stays uint16 because the sentinel is non-finite + assert da.dtype == np.uint16 + np.testing.assert_array_equal(da.compute().values, [[10, 20], [30, 40]]) + assert 'nodata' in da.attrs + assert np.isnan(da.attrs['nodata']) + + +def test_read_geotiff_dask_int_nodata_inf(tmp_path): + """Dask path: Inf nodata on uint16 file is a no-op (#1774).""" + path = _build_uint16_tiff('inf', tmp_path) + da = read_geotiff_dask(path, chunks=2) + assert da.dtype == np.uint16 + np.testing.assert_array_equal(da.compute().values, [[10, 20], [30, 40]]) + assert np.isinf(da.attrs['nodata']) + + +@_gpu_only +def test_apply_nodata_mask_gpu_int_nan_noop(): + """GPU helper: NaN nodata on uint16 array is a no-op (#1774).""" + import cupy + + from xrspatial.geotiff import _apply_nodata_mask_gpu + + arr_gpu = cupy.asarray(np.array([[1, 2], [3, 4]], dtype=np.uint16)) + out = _apply_nodata_mask_gpu(arr_gpu, float('nan')) + # No promotion, same buffer back + assert out.dtype == cupy.uint16 + np.testing.assert_array_equal(out.get(), [[1, 2], [3, 4]]) + + +@_gpu_only +def test_apply_nodata_mask_gpu_int_inf_noop(): + """GPU helper: Inf nodata on uint16 array is a no-op (#1774).""" + import cupy + + from xrspatial.geotiff import _apply_nodata_mask_gpu + + arr_gpu = cupy.asarray(np.array([[1, 2], [3, 4]], dtype=np.uint16)) + out = _apply_nodata_mask_gpu(arr_gpu, float('inf')) + assert out.dtype == cupy.uint16 + np.testing.assert_array_equal(out.get(), [[1, 2], [3, 4]]) + + +@_gpu_only +def test_apply_nodata_mask_gpu_int_finite_still_masks(): + """GPU helper regression guard: in-range finite sentinel still masks.""" + import cupy + + from xrspatial.geotiff import _apply_nodata_mask_gpu + + arr_gpu = cupy.asarray(np.array([[1, 2], [3, 4]], dtype=np.uint16)) + out = _apply_nodata_mask_gpu(arr_gpu, 3) + # 3 is in range and hits a pixel; promotes to float64 with NaN + assert out.dtype == cupy.float64 + arr = out.get() + assert np.isnan(arr[1, 0]) + assert arr[0, 0] == 1.0 From 1dd393fb1d2b733d45f596c8b8fab77972497e20 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 13 May 2026 06:02:50 -0700 Subject: [PATCH 2/2] geotiff: also gate fractional GDAL_NODATA on integer files Copilot review on #1778 flagged that the np.isfinite(nodata) guard added for NaN/Inf sentinels still lets a fractional sentinel through to int(nodata). A "3.5" GDAL_NODATA on a uint16 file would truncate to 3 and silently mask real pixel value 3. Pair the np.isfinite check with float(nodata).is_integer() at all four sites (open_geotiff eager path, _apply_nodata_mask_gpu, read_geotiff_dask effective_dtype, _delayed_read_window). Matches the existing _writer.py / _vrt.py pattern used for #1564 and #1616 (VRT fractional NoDataValue on integer bands stays a no-op). Add 5 regression tests: fractional NaN-like parametrize (3 variants), truncation-aliasing guard ("30.5" must not mask pixel value 30), dask path no-op, and a GPU helper no-op. --- xrspatial/geotiff/__init__.py | 44 +++++++++--- .../geotiff/tests/test_nodata_nan_int_1774.py | 67 +++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index 99c522a2..e4e2fcd3 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -902,9 +902,15 @@ def open_geotiff(source: str | BinaryIO, *, # cannot match an integer pixel, so the ``int(nodata)`` cast # below would raise ValueError; gate on ``np.isfinite`` first # to mirror ``_resolve_masked_fill`` / ``_sparse_fill_value`` - # in ``_reader.py`` (#1774). attrs['nodata'] still carries the - # raw sentinel so a write round-trip preserves the tag. - if np.isfinite(nodata): + # in ``_reader.py`` (#1774). A fractional sentinel (e.g. + # ``GDAL_NODATA="3.5"`` on a ``uint16`` file) also cannot match + # an integer pixel; ``int(3.5)`` would truncate to 3 and + # silently mask a real pixel value, so gate on + # ``float(nodata).is_integer()`` as well (mirrors the + # ``_writer.py`` / ``_vrt.py`` pattern used for #1564 / #1616). + # attrs['nodata'] still carries the raw sentinel so a write + # round-trip preserves the tag. + if np.isfinite(nodata) and float(nodata).is_integer(): nodata_int = int(nodata) info = np.iinfo(arr.dtype) if info.min <= nodata_int <= info.max: @@ -981,10 +987,15 @@ def _apply_nodata_mask_gpu(arr_gpu, nodata): # OverflowError. A non-finite sentinel ("NaN" / "Inf" GDAL_NODATA # strings) also cannot match an integer pixel and would raise # ValueError on ``int(nodata)``; gate on ``np.isfinite`` first to - # mirror ``_resolve_masked_fill`` in ``_reader.py`` (#1774). + # mirror ``_resolve_masked_fill`` in ``_reader.py`` (#1774). A + # fractional sentinel (e.g. ``"3.5"`` on a ``uint16`` file) also + # cannot match an integer pixel and ``int(3.5)`` would truncate + # to 3, silently masking a real pixel value; gate on + # ``float(nodata).is_integer()`` as well (mirrors the + # ``_writer.py`` / ``_vrt.py`` pattern used for #1564 / #1616). # attrs['nodata'] is still set by the caller so the original # sentinel survives a write round-trip. - if not np.isfinite(nodata): + if not (np.isfinite(nodata) and float(nodata).is_integer()): return arr_gpu nodata_int = int(nodata) info = np.iinfo(arr_dtype) @@ -2063,13 +2074,18 @@ def read_geotiff_dask(source: str, *, # non-finite sentinel ("NaN" / "Inf" GDAL_NODATA strings) cannot # match an integer pixel either and is short-circuited via the # ``np.isfinite`` gate so the ``int(...)`` cast never sees NaN - # (#1774). The try/except keeps callers that pass an exotic - # ``nodata`` type (e.g. complex) on the no-op path rather than - # surfacing an opaque error here. + # (#1774). A fractional sentinel (e.g. ``"3.5"`` on a ``uint16`` + # file) also cannot match an integer pixel and ``int(3.5)`` would + # truncate to 3, silently flagging a real pixel value as nodata; + # gate on ``float(nodata).is_integer()`` as well so fractional + # tags stay on the no-op path. The try/except keeps callers that + # pass an exotic ``nodata`` type (e.g. complex) on the no-op path + # rather than surfacing an opaque error here. effective_dtype = file_dtype if (nodata is not None and file_dtype.kind in ('u', 'i') - and np.isfinite(nodata)): + and np.isfinite(nodata) + and float(nodata).is_integer()): try: _nd_int = int(nodata) _info = np.iinfo(file_dtype) @@ -2311,7 +2327,9 @@ def _read(http_meta): # avoid a peak-memory doubler on every dask chunk. if arr.dtype.kind == 'f' and not np.isnan(nodata): arr[arr == arr.dtype.type(nodata)] = np.nan - elif arr.dtype.kind in ('u', 'i') and np.isfinite(nodata): + elif (arr.dtype.kind in ('u', 'i') + and np.isfinite(nodata) + and float(nodata).is_integer()): # Out-of-range sentinels (e.g. uint16 + nodata=-9999) # cannot match any pixel; skip the cast that would # otherwise raise OverflowError and leave arr unchanged. @@ -2319,7 +2337,11 @@ def _read(http_meta): # also cannot match an integer pixel and would raise # ValueError on ``int(nodata)``; the ``np.isfinite`` gate # mirrors ``_resolve_masked_fill`` in ``_reader.py`` - # (#1774). + # (#1774). Fractional sentinels (e.g. ``"3.5"`` on a + # ``uint16`` file) also cannot match an integer pixel and + # ``int(3.5)`` would truncate to 3 and silently mask + # pixel value 3; the ``float(nodata).is_integer()`` gate + # short-circuits them too. nodata_int = int(nodata) info = np.iinfo(arr.dtype) if info.min <= nodata_int <= info.max: diff --git a/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py b/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py index 3dbc1ade..d31b22ba 100644 --- a/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py +++ b/xrspatial/geotiff/tests/test_nodata_nan_int_1774.py @@ -15,6 +15,12 @@ pixel value, so the mask is a no-op and the file dtype is preserved. ``attrs['nodata']`` still carries the raw sentinel so a write round-trip keeps the original GDAL_NODATA tag. + +The same gate is paired with ``float(nodata).is_integer()`` so that a +fractional ``GDAL_NODATA`` string (e.g. ``"3.5"`` on a ``uint16`` file) +also stays a no-op rather than truncating to ``int(3.5) == 3`` and +silently masking real pixel value 3. This mirrors the +``_writer.py`` / ``_vrt.py`` pattern used for #1564 / #1616. """ from __future__ import annotations @@ -223,3 +229,64 @@ def test_apply_nodata_mask_gpu_int_finite_still_masks(): arr = out.get() assert np.isnan(arr[1, 0]) assert arr[0, 0] == 1.0 + + +# ---------------------------------------------------------------------- +# Fractional GDAL_NODATA on integer files (Copilot follow-up review) +# ---------------------------------------------------------------------- +# A fractional sentinel like ``"3.5"`` on a ``uint16`` file is similarly +# nonsensical: ``int(3.5) == 3`` would silently flag a real pixel value +# as nodata. The four masking sites must treat fractional sentinels the +# same as non-finite ones (no-op, preserve dtype, preserve raw attr). + + +@pytest.mark.parametrize('nodata_str', ['3.5', '29.5', '0.5']) +def test_open_geotiff_eager_int_nodata_fractional_noop(tmp_path, nodata_str): + """Eager numpy path: fractional nodata on uint16 is a no-op.""" + path = _build_uint16_tiff(nodata_str, tmp_path) + da = open_geotiff(path) + assert da.dtype == np.uint16 + np.testing.assert_array_equal(da.values, [[10, 20], [30, 40]]) + assert da.attrs['nodata'] == float(nodata_str) + + +def test_open_geotiff_eager_int_nodata_fractional_does_not_alias_truncation( + tmp_path, +): + """A ``"30.5"`` sentinel must not mask the real pixel value 30 + (which is in the test image). ``int(30.5)`` would truncate to 30 + without the integerness gate. + """ + path = _build_uint16_tiff('30.5', tmp_path) + da = open_geotiff(path) + assert da.dtype == np.uint16 + # pixel @[1,0] is 30; the fractional sentinel must NOT have masked it + assert da.values[1, 0] == 30 + np.testing.assert_array_equal(da.values, [[10, 20], [30, 40]]) + + +def test_read_geotiff_dask_int_nodata_fractional_noop(tmp_path): + """Dask path: fractional nodata on uint16 is a no-op.""" + path = _build_uint16_tiff('30.5', tmp_path) + da = read_geotiff_dask(path, chunks=2) + # effective_dtype stays uint16 because the sentinel is fractional + assert da.dtype == np.uint16 + computed = da.compute().values + assert computed[1, 0] == 30 + np.testing.assert_array_equal(computed, [[10, 20], [30, 40]]) + assert da.attrs['nodata'] == 30.5 + + +@_gpu_only +def test_apply_nodata_mask_gpu_int_fractional_noop(): + """GPU helper: fractional nodata on uint16 is a no-op.""" + import cupy + + from xrspatial.geotiff import _apply_nodata_mask_gpu + + arr_gpu = cupy.asarray(np.array([[1, 2], [3, 4]], dtype=np.uint16)) + out = _apply_nodata_mask_gpu(arr_gpu, 3.5) + # 3.5 cannot match any uint16 pixel; ``int(3.5) == 3`` would have + # truncated and masked the real pixel value 3. + assert out.dtype == cupy.uint16 + np.testing.assert_array_equal(out.get(), [[1, 2], [3, 4]])