From cccdb9b7e1232667148f7d6eb157fac33ddd4cdf Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 16 May 2026 06:01:12 -0700 Subject: [PATCH 1/2] geotiff: golden corpus phase 2.5 -- compression (#1930) Add six fixtures covering the five major TIFF compression codecs: none, deflate (predictor 2 and 3), lzw (predictor 2), lerc (lossless), and jpeg in YCbCr. The jpeg entry sets tolerance.lossy: true so the oracle skips bit-exact pixel comparison for that cell. --- .../compression_deflate_predictor2_uint16.tif | Bin 0 -> 465 bytes ...compression_deflate_predictor3_float32.tif | Bin 0 -> 537 bytes .../fixtures/compression_jpeg_uint8_ycbcr.tif | Bin 0 -> 1188 bytes .../fixtures/compression_lerc_float32.tif | Bin 0 -> 687 bytes .../compression_lzw_predictor2_int16.tif | Bin 0 -> 1029 bytes .../fixtures/compression_none_uint8.tif | Bin 0 -> 4462 bytes .../geotiff/tests/golden_corpus/manifest.yaml | 123 +++++++++ .../test_golden_corpus_compression_1930.py | 248 ++++++++++++++++++ 8 files changed, 371 insertions(+) create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor2_uint16.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor3_float32.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_jpeg_uint8_ycbcr.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_lerc_float32.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_lzw_predictor2_int16.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/compression_none_uint8.tif create mode 100644 xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor2_uint16.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor2_uint16.tif new file mode 100644 index 0000000000000000000000000000000000000000..a47b3b9418d71f353e54b76977210e556ea9b4d9 GIT binary patch literal 465 zcmebD)MDUZU|-o&0L7W1Y>*B%C>x|lkdXzfw+hG+MG_Z- zvK@eG#Gz`!fovHhHMUST6Hq)DNt~~lhk+R=b_LFZm_~*u!XuC?PWL09ZwXn*&TGBiLu`3=Ay5Z~@u| z^ahXyS^^9NMuuksj2s)=fwBy2V1A!6C&z|%kUTe-W?<;ZEtw{}qzGi5!?KVF$8h&x z1q+iJhKf0FuPE{zaNuD+`0aoHzM{`xlqwG`Vm$rl$lj0hjbH!z_3Qhu`tKIct8Lcj z{{QlI_V3%@JB#g}SLXg+zW)B5`}_Roe6~EFecQhF@3Vj3Y9Gxx|C2ps_x=C?;{aXo literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor3_float32.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_deflate_predictor3_float32.tif new file mode 100644 index 0000000000000000000000000000000000000000..68f768e11123d5ddd54dc3c5e6b93df7807d1208 GIT binary patch literal 537 zcmebD)MDUZU|-pD0L7W1Y>*B%C>x|lkdXzfw+hG+MG_Z- zvK@eG#Gz{D1KBc2YHXowW}tX55}U7?hXEvZ3y7Oqco^7#^d}%*-_FdS0Hl?Gc5P_q zVPFEXlYs1v?Mz^w6am>F@Mq=6OTI|~_AnYEN{Ea*09Mi9<^WU42=*B}0|N^%T!6L# zy#b_wmH-2Rk>QyDBge*epezF$nBS+&$+4jwB+m_|85lZpOQy*#DFT`2uq-6PG2A^^ z!NR15p<>S43x!2D^s)kN@#iTmPJB|Nrqh``2%u z9C!R5YJOtA{HN#jcYdbto%_1(0{>6N`hUmg)?d4QvcLJ?>bwc|_D|0L-|_i;mG$+% z3*IBpf0T3KPPaXkubvWkX{uEKIxz z$znrc;U^#_RKPc&uqUAcZEUc&voNgB%q`(xp>`I|o5}p<*=OFFeP`z8ZbAVF0U;qs z4R&#Y9kQf$$S!l;BfB}fdg4Hw8waytj`M0i-G zzCgXpk#NWkBXl}o_N-99xTLNBEBWoRw)zk9Mk*20Gss1oskUpMlebdG{_QK1j7`~i zhjz56`rQ6K`!ya(*f2h(M3#@O-^{bU>Dv;wX6G5Snln+ zUha*ecwo2|SB9!lG&p)==+;P*Bz?6z<74%^!?%;VNg|Z*>!R){m3r#^QGflfTekmm z{rm2NXY&(BBM<)v?di5PwPqSeW>X7*_ipRrfEU=V4j+%ew@#t9Qu`|Ge6{MU&D5+Z z+7Vb;eEFfg_QK?Ae0=zL^3iNF9!p+>)!Ig&cw4a)6*z&Afyh4L3g4jF@wf>n>S5<; jmEVne#+$3bdGBXvDY%UcFoB%|Oxt*q?f*Z>KU-pD0L7W1Z1)ZZ1~w=gq(_jE1+15WiGe{B zNn8xdb^xjohpJf*WXm9_35K$nf#Q74JPaT@0cdDb3l9SukX->}uWx5&Pyn)50NESb zc|g`Mya2K{wljfU{{zTwSi;0m1QdS<#Gj6`fYm$$vO$0aLNVC5yEyvzrj{h8B$g!F zaVaR+c$TE*D&!^RrrIhw`}+n2xdsP&`uoKPxrVt01$#Pq`gn#!Dk-Go=O$+6*(!PZ zI=Z{Y2ZscOI){V?xhmQD_y-630QK1DgAB6cve8Gif#J`}kC%Lt0_uFg^b|vWe0nO5$HEyU;;&e-Udb;f`v&9gHLKvvXKHPe5xff`N7gaHqcxK1_7|)450V|#RGQm m!5$&rV9&tR&dAlzC^Vf>YCdB#BU?8k-(*Iy*^F}35(fZjqi1RW literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_lzw_predictor2_int16.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_lzw_predictor2_int16.tif new file mode 100644 index 0000000000000000000000000000000000000000..7d5aa89508d4b5ba38a8d9431bbced9965b1939a GIT binary patch literal 1029 zcmebD)MDUZU|-o&0L7W1Y*rwf4ax@T5oBZm>#YKEM3KbB zplk=A8gZzaZYBl>86-8fP&QDQAsC6x*UZDf3>3Qs#7!+c3~WI96A-U&XJ$|U(n>(P zHnj6Ffb=B+*&ExLz&A(MpbGICR7;s~XoU&-sQDp&-J$bBh4DQK-EADh@ ztMqs-3hG$1N|m|adtuRuDW}x=W}ILU_NcifYB1^C@`+p`kD3)09$J2(ie*{1!qQ_I zS6;dLMO*cs%i79Sn`P_S9j3Xp>TZ>JLQ8nYCvKT*S3}lcU3)D$X4-B3Esj|R-e-#L zr|h_*_t|>NxBtZlZZ4^aXyeO|*%hPn<5ZYjbzk%S6w53%cgD#F(>F@-gv{4WyqNmc z@{aFy@d?*X6$_lTT^>I5_A4`^yK}u0mt4=>{_A%-f9ukzZ}TJ19v7cj@iczBb?&`B z^Sv)i3+~#*7jKq(llJ?b-nGq*lU{$^X}7L@Gv~Y3ANj@m`X`^C|EM5ozh2twcdsqp zUyA!-tiSr%S%(isB@BBEoPWKte|P)TQpXL!$5{^aE_UYJfBy7EhQsfplDQ7+exCRJ z$eS8>?t|~%SaLX@eG(~o^ka$Av11c=&z5!G$dW3x`(qEIphKYZL80{%wlw_q2$<3@ za$uA5OHId2I%QCvdp2R>xb&rq%_B V-`!uC6teVX$0IQ}YdfVC8~{+yXBq$i literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_none_uint8.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/compression_none_uint8.tif new file mode 100644 index 0000000000000000000000000000000000000000..c6c6ee2d32b207fe51e07246cffd9be20dafaa3e GIT binary patch literal 4462 zcmebD)MDUZU|-qGR53%@Aa!g=Y(YjAu--hNgea1@7?ce% zQyi)WWR(n(nqVY0Uo#H_Gf?e0AZ}{mVPFH&&wzM+J2Qg0)H0i_v%<8S#uOp927+fh(#2 literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml index ad345ff09..63c7d7f65 100644 --- a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml +++ b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml @@ -141,3 +141,126 @@ fixtures: atol: 0.0 rtol: 0.0 lossy: false + + # --------------------------------------------------------------------- + # Phase 2 PR 5 -- compression coverage. + # + # Five major codecs across six entries: uncompressed (none), deflate + # (with predictor 2 over int and predictor 3 over float), lzw (with + # predictor 2 over int), lerc on float, and jpeg in YCbCr on uint8. + # The jpeg entry is intrinsically lossy; the oracle is called with + # lossy=True for that one. + # + # Source data uses the "checker" pattern so compression has structure + # to exploit; arrays are kept tiny (under 8 KB target file size). + # LERC and JPEG availability depends on the GDAL build; the smoke + # test skips those cells when the codec is unavailable. + # --------------------------------------------------------------------- + + - id: compression_none_uint8 + description: >- + Baseline uncompressed uint8. Establishes the size floor and exercises + the no-codec write path for parity with the compressed cells. + width: 64 + height: 64 + dtype: uint8 + compression: none + predictor: 1 + pixel_pattern: checker + tags: [compression, none] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: compression_deflate_predictor2_uint16 + description: >- + Deflate with predictor 2 (horizontal differencing) on uint16. + Predictor 2 is the standard integer predictor; this is the most + common deflate configuration in the wild. + width: 64 + height: 64 + dtype: uint16 + compression: deflate + predictor: 2 + compression_level: 6 + pixel_pattern: checker + tags: [compression, deflate, predictor2] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: compression_deflate_predictor3_float32 + description: >- + Deflate with predictor 3 (floating-point predictor) on float32. + Predictor 3 is float-only and exercises the byte-shuffled write + path that hit issue #1313. + width: 64 + height: 64 + dtype: float32 + compression: deflate + predictor: 3 + compression_level: 6 + pixel_pattern: checker + tags: [compression, deflate, predictor3, float] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: compression_lzw_predictor2_int16 + description: >- + LZW with predictor 2 on signed int16. Covers the LZW codec branch + and the signed-integer predictor combination. + width: 64 + height: 64 + dtype: int16 + compression: lzw + predictor: 2 + pixel_pattern: checker + tags: [compression, lzw, predictor2] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: compression_lerc_float32 + description: >- + LERC on float32 with a small max_z_error. LERC is bit-exact only + when max_z_error is 0; this fixture asserts the lossless setting + (max_z_error 0) so it is comparable to the other codecs. + width: 64 + height: 64 + dtype: float32 + compression: lerc + predictor: 1 + max_z_error: 0.0 + pixel_pattern: checker + tags: [compression, lerc, float] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: compression_jpeg_uint8_ycbcr + description: >- + JPEG in YCbCr photometric on 3-band uint8 tiled raster. JPEG is + intrinsically lossy; the oracle is invoked with lossy=True for + this cell. Strict bit-exact comparison is expected to fail. + width: 64 + height: 64 + bands: 3 + dtype: uint8 + layout: tiled + tile_size: 32 + compression: jpeg + predictor: 1 + compression_level: 75 + photometric: ycbcr + pixel_pattern: checker + tags: [compression, jpeg, lossy, ycbcr] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: true diff --git a/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py b/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py new file mode 100644 index 000000000..2872e7f8c --- /dev/null +++ b/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py @@ -0,0 +1,248 @@ +"""Smoke tests for the Phase 2 PR 5 compression fixtures (issue #1930). + +Six fixtures land in this PR, one per major codec / predictor variant: + +* ``compression_none_uint8`` -- baseline uncompressed uint8. +* ``compression_deflate_predictor2_uint16`` -- deflate + predictor 2 (int). +* ``compression_deflate_predictor3_float32`` -- deflate + predictor 3 (float). +* ``compression_lzw_predictor2_int16`` -- LZW + predictor 2 on signed int. +* ``compression_lerc_float32`` -- LERC on float32 with max_z_error 0 + (lossless setting, so it is comparable to the other codecs). +* ``compression_jpeg_uint8_ycbcr`` -- JPEG in YCbCr on uint8 tiled raster. + Intrinsically lossy; the oracle is called with ``lossy=True``. + +The test: + +* loads each fixture with rasterio, +* builds an xrspatial-shaped DataArray from the rasterio read, +* calls ``compare_to_oracle`` and asserts it accepts the matching case + for lossless codecs, with ``lossy=True`` for jpeg, +* pins the JPEG cell's expected behaviour: strict mode must reject and + lossy mode must accept. + +LERC and JPEG codec availability depends on the GDAL build. When a +codec is unavailable the corresponding fixture is missing from disk and +the relevant test is skipped via ``pytest.skip``. The pre-PR generator +run did write them; this guard is defensive for environments where GDAL +was rebuilt without those drivers. +""" +from __future__ import annotations + +import pathlib + +import numpy as np +import pytest + +# Both pyyaml and rasterio are runtime deps of this test. importorskip +# keeps minimal environments green; once Phase 2 fully lands these are +# planned to move into the test extras (see README). +pytest.importorskip("yaml") +rasterio = pytest.importorskip("rasterio") + +import xarray as xr # noqa: E402 + +from xrspatial.geotiff.tests.golden_corpus._oracle import ( # noqa: E402 + compare_to_oracle, +) + + +FIXTURES_DIR = ( + pathlib.Path(__file__).resolve().parent + / "golden_corpus" + / "fixtures" +) + + +# Fixture id, lossy flag for the oracle call. +COMPRESSION_FIXTURES: tuple[tuple[str, bool], ...] = ( + ("compression_none_uint8", False), + ("compression_deflate_predictor2_uint16", False), + ("compression_deflate_predictor3_float32", False), + ("compression_lzw_predictor2_int16", False), + ("compression_lerc_float32", False), + ("compression_jpeg_uint8_ycbcr", True), +) + + +def _fixture_path(fid: str) -> pathlib.Path: + """Return the on-disk path for a fixture id, skipping if absent. + + A missing file usually means the GDAL build lacks the codec (LERC or + JPEG most commonly). The generator silently skips writing in that + case, so we treat absence as a clean skip rather than a hard fail. + """ + p = FIXTURES_DIR / f"{fid}.tif" + if not p.exists(): + pytest.skip(f"fixture {fid} not present (codec unavailable?)") + return p + + +def _candidate_from_rasterio(path: pathlib.Path) -> xr.DataArray: + """Read ``path`` with rasterio and return an xrspatial-shaped DataArray. + + Mirrors the shape that ``xrspatial.geotiff.open_geotiff`` produces: + a 2-D DataArray for single-band, 3-D ``(band, y, x)`` otherwise, + pixel-centre coords, and the canonical ``transform`` 6-tuple in attrs. + """ + with rasterio.open(path) as src: + data = src.read() # shape (bands, H, W) + transform = src.transform + crs = src.crs + nodata = src.nodata + dtype = src.dtypes[0] + + height, width = data.shape[-2], data.shape[-1] + pw = float(transform.a) + ph = float(transform.e) + ox = float(transform.c) + oy = float(transform.f) + x = ox + (np.arange(width) + 0.5) * pw + y = oy + (np.arange(height) + 0.5) * ph + + attrs: dict = { + "transform": (pw, 0.0, ox, 0.0, ph, oy), + } + if crs is not None: + epsg = crs.to_epsg() + if epsg is not None: + attrs["crs"] = epsg + else: + attrs["crs_wkt"] = crs.to_wkt() + if nodata is not None: + attrs["nodata"] = nodata + + if data.shape[0] == 1: + arr = data[0].astype(dtype) + return xr.DataArray( + arr, + dims=("y", "x"), + coords={"y": y, "x": x}, + attrs=attrs, + ) + return xr.DataArray( + data.astype(dtype), + dims=("band", "y", "x"), + coords={ + "band": np.arange(1, data.shape[0] + 1), + "y": y, + "x": x, + }, + attrs=attrs, + ) + + +# --------------------------------------------------------------------------- +# Per-fixture parametrised smoke +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("fid, lossy", COMPRESSION_FIXTURES) +def test_compression_fixture_is_valid_tiff(fid: str, lossy: bool) -> None: + """Each fixture opens with rasterio and reports the expected codec.""" + path = _fixture_path(fid) + with rasterio.open(path) as src: + # Sanity: shape and dtype come out matching the manifest. + assert src.width > 0 and src.height > 0 + assert src.count >= 1 + # CRS round-trips to EPSG:4326 (the default in the manifest). + assert src.crs is not None + assert src.crs.to_epsg() == 4326 + + +@pytest.mark.parametrize("fid, lossy", COMPRESSION_FIXTURES) +def test_compression_fixture_oracle_accepts(fid: str, lossy: bool) -> None: + """rasterio-built candidate must satisfy the oracle. + + For lossy fixtures (jpeg) the oracle is called with ``lossy=True``; + for everything else it is called in strict bit-exact mode. + """ + path = _fixture_path(fid) + cand = _candidate_from_rasterio(path) + compare_to_oracle(path, cand, lossy=lossy) + + +# --------------------------------------------------------------------------- +# JPEG: pin lossy semantics explicitly +# --------------------------------------------------------------------------- + +def test_jpeg_lossy_mode_required() -> None: + """The jpeg fixture passes only when the oracle is told it is lossy. + + Two halves: strict mode (default) must reject because JPEG quantises + pixel values; lossy mode must accept. This pins the contract added + in PR #1991 for Phase 2's jpeg cell. + """ + path = _fixture_path("compression_jpeg_uint8_ycbcr") + cand = _candidate_from_rasterio(path) + # Strict mode: the rasterio-decoded pixels match themselves trivially. + # To prove the lossy contract we need to perturb the candidate so + # strict comparison fails while shape/dtype/transform/CRS all match. + perturbed_data = cand.data.copy() + # Bump every pixel by 1 -- still in uint8 range for the checker + # pattern (values are 0 or 255 in YCbCr-encoded form, but the rasterio + # decoded pixels are in [0, 255]; +1 wraps 255 to 0 for one block + # which is fine for the assertion). + perturbed_data = ( + perturbed_data.astype(np.int32) + 1 + ).clip(0, 255).astype(np.uint8) + perturbed = xr.DataArray( + perturbed_data, + dims=cand.dims, + coords=cand.coords, + attrs=dict(cand.attrs), + ) + with pytest.raises(AssertionError, match="pixel arrays differ"): + compare_to_oracle(path, perturbed) + # Lossy mode: same perturbed candidate is accepted. + compare_to_oracle(path, perturbed, lossy=True) + + +# --------------------------------------------------------------------------- +# Manifest cross-check +# --------------------------------------------------------------------------- + +def test_compression_fixtures_declared_in_manifest() -> None: + """Every fixture id this test parametrises must exist in the manifest. + + Catches drift between this file and ``manifest.yaml``. + """ + import importlib + + generate = importlib.import_module( + "xrspatial.geotiff.tests.golden_corpus.generate" + ) + manifest = generate.load_manifest() + declared = {f["id"] for f in manifest["fixtures"]} + for fid, _ in COMPRESSION_FIXTURES: + assert fid in declared, f"{fid} missing from manifest.yaml" + + +def test_jpeg_fixture_marked_lossy_in_manifest() -> None: + """The jpeg manifest entry must carry ``tolerance.lossy: true``. + + The oracle's lossy contract is opt-in per-fixture and the manifest + is the source of truth; mis-marking would silently downgrade strict + comparison for everyone. + """ + import importlib + + generate = importlib.import_module( + "xrspatial.geotiff.tests.golden_corpus.generate" + ) + manifest = generate.load_manifest() + by_id = {f["id"]: f for f in manifest["fixtures"]} + jpeg = by_id["compression_jpeg_uint8_ycbcr"] + tol = jpeg.get("tolerance") or {} + assert tol.get("lossy") is True, ( + "compression_jpeg_uint8_ycbcr must declare tolerance.lossy: true" + ) + # And conversely the lossless cells must not. + for fid, lossy in COMPRESSION_FIXTURES: + if lossy: + continue + entry = by_id.get(fid) + if entry is None: + continue + tol = entry.get("tolerance") or {} + assert tol.get("lossy") in (False, None), ( + f"{fid} unexpectedly marked lossy" + ) From 23de1a455e9bc8445ce8ce8b3c11d131602979fa Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 16 May 2026 06:03:06 -0700 Subject: [PATCH 2/2] geotiff: golden corpus compression -- review-pass comment fixes Self-review on #1995. Two comments were misleading: * `_fixture_path` claimed the generator silently skips writes when a codec is unavailable. It does not -- it raises. Reword to describe the actual reason a file might be missing (a contributor rebuilt the corpus locally without the codec). * The JPEG perturbation comment claimed values were "0 or 255 in YCbCr form" with wrap-around. The decoded YCbCr pixels are well below 255, the perturbation uses .clip, and there is no wrap. Reword. No behaviour change. --- .../test_golden_corpus_compression_1930.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py b/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py index 2872e7f8c..edfa3b1b6 100644 --- a/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py +++ b/xrspatial/geotiff/tests/test_golden_corpus_compression_1930.py @@ -67,9 +67,12 @@ def _fixture_path(fid: str) -> pathlib.Path: """Return the on-disk path for a fixture id, skipping if absent. - A missing file usually means the GDAL build lacks the codec (LERC or - JPEG most commonly). The generator silently skips writing in that - case, so we treat absence as a clean skip rather than a hard fail. + A missing file usually means the maintainer who regenerated the + corpus had a GDAL build without the relevant codec (LERC or JPEG + are the common offenders). The committed fixtures in this PR were + built with both codecs available; this guard exists so a contributor + rebuilding locally without those drivers does not see a hard fail + for a file they could not produce. """ p = FIXTURES_DIR / f"{fid}.tif" if not p.exists(): @@ -176,13 +179,12 @@ def test_jpeg_lossy_mode_required() -> None: # Strict mode: the rasterio-decoded pixels match themselves trivially. # To prove the lossy contract we need to perturb the candidate so # strict comparison fails while shape/dtype/transform/CRS all match. - perturbed_data = cand.data.copy() - # Bump every pixel by 1 -- still in uint8 range for the checker - # pattern (values are 0 or 255 in YCbCr-encoded form, but the rasterio - # decoded pixels are in [0, 255]; +1 wraps 255 to 0 for one block - # which is fine for the assertion). + # Bump every pixel by 1 (clipped to uint8 range). The decoded YCbCr + # checker pattern lands well below 255 so clipping is a no-op in + # practice; the assertion is that the perturbed array is no longer + # bit-equal to the rasterio read. perturbed_data = ( - perturbed_data.astype(np.int32) + 1 + cand.data.astype(np.int32) + 1 ).clip(0, 255).astype(np.uint8) perturbed = xr.DataArray( perturbed_data,