|
| 1 | +"""Regression tests for issue #1598. |
| 2 | +
|
| 3 | +``read_vrt(path, band=N)`` used to always source the nodata sentinel |
| 4 | +from ``vrt.bands[0]`` rather than the requested band, so a multi-band |
| 5 | +VRT with per-band ``<NoDataValue>`` would mis-mask reads of any band |
| 6 | +other than band 0: |
| 7 | +
|
| 8 | +* ``attrs['nodata']`` advertised band 0's sentinel (wrong). |
| 9 | +* The integer-to-float64 promotion ran against band 0's sentinel, so |
| 10 | + band N's actual nodata pixels stayed as literal integers. |
| 11 | +* The returned dtype was integer when it should have been float64. |
| 12 | +
|
| 13 | +The fix uses ``vrt.bands[band].nodata`` when a band is selected. |
| 14 | +""" |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import numpy as np |
| 18 | +import pytest |
| 19 | + |
| 20 | +from xrspatial.geotiff import read_vrt |
| 21 | +from xrspatial.geotiff._writer import write |
| 22 | + |
| 23 | + |
| 24 | +def _write_two_band_per_band_nodata_vrt(tmp_path): |
| 25 | + """Two single-band uint16 sources, each with a distinct nodata |
| 26 | + sentinel, exposed as bands 1 and 2 of a hand-rolled VRT. |
| 27 | + """ |
| 28 | + band0 = np.array([[1, 2], [3, 65535]], dtype=np.uint16) |
| 29 | + band1 = np.array([[7, 8], [9, 65000]], dtype=np.uint16) |
| 30 | + p0 = str(tmp_path / 'vrt_band0_1598.tif') |
| 31 | + p1 = str(tmp_path / 'vrt_band1_1598.tif') |
| 32 | + write(band0, p0, nodata=65535, compression='none', tiled=False) |
| 33 | + write(band1, p1, nodata=65000, compression='none', tiled=False) |
| 34 | + |
| 35 | + vrt_path = str(tmp_path / 'two_band_per_band_nodata_1598.vrt') |
| 36 | + vrt_xml = f"""<VRTDataset rasterXSize="2" rasterYSize="2"> |
| 37 | + <GeoTransform>0.0, 1.0, 0.0, 0.0, 0.0, -1.0</GeoTransform> |
| 38 | + <VRTRasterBand dataType="UInt16" band="1"> |
| 39 | + <NoDataValue>65535</NoDataValue> |
| 40 | + <SimpleSource> |
| 41 | + <SourceFilename relativeToVRT="0">{p0}</SourceFilename> |
| 42 | + <SourceBand>1</SourceBand> |
| 43 | + <SrcRect xOff="0" yOff="0" xSize="2" ySize="2"/> |
| 44 | + <DstRect xOff="0" yOff="0" xSize="2" ySize="2"/> |
| 45 | + </SimpleSource> |
| 46 | + </VRTRasterBand> |
| 47 | + <VRTRasterBand dataType="UInt16" band="2"> |
| 48 | + <NoDataValue>65000</NoDataValue> |
| 49 | + <SimpleSource> |
| 50 | + <SourceFilename relativeToVRT="0">{p1}</SourceFilename> |
| 51 | + <SourceBand>1</SourceBand> |
| 52 | + <SrcRect xOff="0" yOff="0" xSize="2" ySize="2"/> |
| 53 | + <DstRect xOff="0" yOff="0" xSize="2" ySize="2"/> |
| 54 | + </SimpleSource> |
| 55 | + </VRTRasterBand> |
| 56 | +</VRTDataset>""" |
| 57 | + with open(vrt_path, 'w') as f: |
| 58 | + f.write(vrt_xml) |
| 59 | + return vrt_path |
| 60 | + |
| 61 | + |
| 62 | +def test_read_vrt_band0_uses_band0_nodata(tmp_path): |
| 63 | + """Sanity check the band-0 selection still works after the fix. |
| 64 | +
|
| 65 | + Confirms the refactor did not flip the index. |
| 66 | + """ |
| 67 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 68 | + r = read_vrt(vrt_path, band=0) |
| 69 | + assert r.dtype == np.float64 |
| 70 | + assert r.attrs.get('nodata') == 65535.0 |
| 71 | + assert np.isnan(r.values[1, 1]) |
| 72 | + assert r.values[0, 0] == 1 |
| 73 | + |
| 74 | + |
| 75 | +def test_read_vrt_band1_uses_band1_nodata(tmp_path): |
| 76 | + """The previously-broken case: band=1 must use band 1's sentinel. |
| 77 | +
|
| 78 | + Before the fix this returned dtype=uint16 with values=[[7,8], |
| 79 | + [9,65000]] and attrs['nodata']=65535. |
| 80 | + """ |
| 81 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 82 | + r = read_vrt(vrt_path, band=1) |
| 83 | + assert r.dtype == np.float64, ( |
| 84 | + "band=1 read kept uint16 dtype; per-band nodata regression." |
| 85 | + ) |
| 86 | + assert r.attrs.get('nodata') == 65000.0, ( |
| 87 | + f"attrs['nodata'] was {r.attrs.get('nodata')}, " |
| 88 | + f"expected 65000 from band 1's <NoDataValue>." |
| 89 | + ) |
| 90 | + assert np.isnan(r.values[1, 1]), ( |
| 91 | + "band 1's sentinel pixel was not NaN-masked; " |
| 92 | + "promotion ran against the wrong sentinel." |
| 93 | + ) |
| 94 | + assert r.values[0, 0] == 7 |
| 95 | + assert r.values[1, 0] == 9 |
| 96 | + |
| 97 | + |
| 98 | +def test_read_vrt_no_band_keeps_band0_nodata_attr(tmp_path): |
| 99 | + """Unselected reads still surface band 0's sentinel. |
| 100 | +
|
| 101 | + Multi-band VRTs with mixed sentinels return all bands stacked, and |
| 102 | + the canonical attr cannot encode per-band values; advertising |
| 103 | + band 0's sentinel matches the prior behavior and the documented |
| 104 | + "first band wins" contract for multi-band reads. |
| 105 | + """ |
| 106 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 107 | + r = read_vrt(vrt_path) |
| 108 | + assert r.attrs.get('nodata') == 65535.0 |
| 109 | + |
| 110 | + |
| 111 | +def test_read_vrt_negative_band_raises(tmp_path): |
| 112 | + """Negative band indices used to be silently accepted via Python |
| 113 | + list indexing (``vrt.bands[-1]`` returned the last band) while the |
| 114 | + public reader's nodata lookup rejected them, producing band-N data |
| 115 | + with no nodata sentinel. They are now a clear ValueError up front. |
| 116 | + """ |
| 117 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 118 | + with pytest.raises(ValueError, match="band"): |
| 119 | + read_vrt(vrt_path, band=-1) |
| 120 | + |
| 121 | + |
| 122 | +def test_read_vrt_out_of_range_band_raises(tmp_path): |
| 123 | + """Out-of-range band indices used to raise IndexError from deep in |
| 124 | + the read path. They are now a ValueError that names the available |
| 125 | + band count. |
| 126 | + """ |
| 127 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 128 | + with pytest.raises(ValueError, match="out of range"): |
| 129 | + read_vrt(vrt_path, band=5) |
| 130 | + |
| 131 | + |
| 132 | +def test_read_vrt_non_integer_band_raises(tmp_path): |
| 133 | + """A non-int ``band`` would previously have raised TypeError on the |
| 134 | + list index. ValueError here matches the rest of the input |
| 135 | + validation surface. |
| 136 | + """ |
| 137 | + vrt_path = _write_two_band_per_band_nodata_vrt(tmp_path) |
| 138 | + with pytest.raises(ValueError, match="band"): |
| 139 | + read_vrt(vrt_path, band="1") |
| 140 | + with pytest.raises(ValueError, match="band"): |
| 141 | + read_vrt(vrt_path, band=True) |
0 commit comments