Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@
_validate_dtype_cast,
_validate_tile_size_arg,
)
from ._writer import write
# ``_writer.write`` (alias for ``_writer._write``) is module-private;
# see ``_writer.py`` docstring and issue #2138. The public eager write
# surface is :func:`to_geotiff`; do not re-export the array-level
# entry point here. The dotted path ``xrspatial.geotiff._writer._write``
# still works for the handful of internal call sites that need it.
from ._writers.eager import to_geotiff
# Re-export only; called by xrspatial/geotiff/tests/test_nodata_no_extra_copy_1553.py.
from ._writers.eager import _write_single_tile # noqa: F401
Expand Down
29 changes: 26 additions & 3 deletions xrspatial/geotiff/_reader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
"""TIFF/COG reader: tile/strip assembly, windowed reads, HTTP range requests."""
"""TIFF/COG reader: tile/strip assembly, windowed reads, HTTP range requests.

This module is private to :mod:`xrspatial.geotiff`. The supported public
read entry points are :func:`xrspatial.geotiff.open_geotiff`,
:func:`xrspatial.geotiff.read_geotiff_gpu`,
:func:`xrspatial.geotiff.read_geotiff_dask`, and
:func:`xrspatial.geotiff.read_vrt`. Direct callers of the helpers
defined here bypass the DataArray-level work that the public wrappers
perform (ambiguous-metadata fail-closed, nodata-to-NaN promotion,
``masked_nodata`` attr, ``transform`` / ``crs`` attrs population) and
have to replicate those steps by hand. See issue #2138.

For source modules inside :mod:`xrspatial.geotiff`, the canonical
internal name for the array-level reader is :func:`_read_to_array`.
The non-underscored :func:`read_to_array` is kept as an alias for
internal call sites that pre-date the rename.
"""
from __future__ import annotations

import math
Expand Down Expand Up @@ -3185,13 +3201,13 @@ def _miniswhite_inverted_nodata(nodata, ifd: IFD, dtype: np.dtype):
return nodata


def read_to_array(source, *, window=None, overview_level: int | None = None,
def _read_to_array(source, *, window=None, overview_level: int | None = None,
band: int | None = None,
max_pixels: int = MAX_PIXELS_DEFAULT,
max_cloud_bytes=_MAX_CLOUD_BYTES_SENTINEL,
allow_rotated: bool = False,
) -> tuple[np.ndarray, GeoInfo]:
"""Read a GeoTIFF/COG to a numpy array.
"""Read a GeoTIFF/COG to a numpy array (module-private).

Parameters
----------
Expand Down Expand Up @@ -3428,3 +3444,10 @@ def read_to_array(source, *, window=None, overview_level: int | None = None,
close_sidecar(sidecar)

return arr, geo_info


# Backward-compatible alias for internal call sites that pre-date the
# rename to :func:`_read_to_array`. New code inside
# ``xrspatial.geotiff`` should import :func:`_read_to_array` directly.
# See issue #2138.
read_to_array = _read_to_array
272 changes: 261 additions & 11 deletions xrspatial/geotiff/_writer.py

Large diffs are not rendered by default.

38 changes: 32 additions & 6 deletions xrspatial/geotiff/_writers/eager.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,8 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
bigtiff=bigtiff,
max_z_error=max_z_error,
photometric=photometric,
allow_unparseable_crs=allow_unparseable_crs)
allow_unparseable_crs=allow_unparseable_crs,
allow_internal_only_jpeg=allow_internal_only_jpeg)
return path

# Dispatch to write_geotiff_gpu when GPU was selected (explicit
Expand Down Expand Up @@ -668,6 +669,13 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
max_z_error=max_z_error,
photometric=photometric,
restore_sentinel=restore_sentinel,
# ``to_geotiff`` ran the JPEG opt-in and the CRS
# fallback gates upstream; forwarding the kwargs lets
# ``_write_streaming``'s push-down check stay aligned
# rather than rejecting input the wrapper accepted.
# Issue #2138.
allow_internal_only_jpeg=allow_internal_only_jpeg,
allow_unparseable_crs=allow_unparseable_crs,
)
return path

Expand Down Expand Up @@ -756,6 +764,12 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
max_z_error=max_z_error,
photometric=photometric,
restore_sentinel=restore_sentinel,
# ``to_geotiff`` ran the JPEG opt-in and the CRS fallback
# gates upstream; forwarding the kwargs keeps ``_write``'s
# push-down check from rejecting input the wrapper accepted.
# Issue #2138.
allow_internal_only_jpeg=allow_internal_only_jpeg,
allow_unparseable_crs=allow_unparseable_crs,
)
return path

Expand All @@ -771,7 +785,9 @@ def _write_single_tile(chunk_data, path, geo_transform, epsg, wkt,
gdal_metadata_xml=None,
extra_tags=None,
photometric: str | int = 'auto',
restore_sentinel: bool = True):
restore_sentinel: bool = True,
allow_internal_only_jpeg: bool = False,
allow_unparseable_crs: bool = False):
"""Write a single tile GeoTIFF. Used by _write_vrt_tiled.

Forwards the same rich-tag set that ``to_geotiff`` passes through to
Expand Down Expand Up @@ -826,15 +842,21 @@ def _write_single_tile(chunk_data, path, geo_transform, epsg, wkt,
bigtiff=bigtiff,
max_z_error=max_z_error,
photometric=photometric,
restore_sentinel=restore_sentinel)
restore_sentinel=restore_sentinel,
# Forward the JPEG / CRS-fallback opt-ins so the per-tile
# write does not re-trip the push-down gate ``to_geotiff``
# / ``_write_vrt_tiled`` already cleared upstream (#2138).
allow_internal_only_jpeg=allow_internal_only_jpeg,
allow_unparseable_crs=allow_unparseable_crs)


def _write_vrt_tiled(data, vrt_path, *, crs=None, nodata=None,
compression='zstd', compression_level=None,
tile_size=256, predictor: bool | int = False,
bigtiff=None, max_z_error: float = 0.0,
photometric: str | int = 'auto',
allow_unparseable_crs: bool = False):
allow_unparseable_crs: bool = False,
allow_internal_only_jpeg: bool = False):
"""Write a DataArray as a directory of tiled GeoTIFFs with a VRT index.

This enables streaming dask arrays to disk without materializing the
Expand Down Expand Up @@ -1050,7 +1072,9 @@ def _write_vrt_tiled(data, vrt_path, *, crs=None, nodata=None,
gdal_metadata_xml=gdal_meta_xml,
extra_tags=extra_tags_list,
photometric=photometric,
restore_sentinel=restore_sentinel)
restore_sentinel=restore_sentinel,
allow_internal_only_jpeg=allow_internal_only_jpeg,
allow_unparseable_crs=allow_unparseable_crs)
delayed_tasks.append(task)
else:
# Numpy: slice and write directly
Expand All @@ -1067,7 +1091,9 @@ def _write_vrt_tiled(data, vrt_path, *, crs=None, nodata=None,
gdal_metadata_xml=gdal_meta_xml,
extra_tags=extra_tags_list,
photometric=photometric,
restore_sentinel=restore_sentinel)
restore_sentinel=restore_sentinel,
allow_internal_only_jpeg=allow_internal_only_jpeg,
allow_unparseable_crs=allow_unparseable_crs)

col_offset += chunk_w
row_offset += chunk_h
Expand Down
18 changes: 12 additions & 6 deletions xrspatial/geotiff/tests/test_jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ def test_grayscale_tiled(self, tmp_path):
rng = np.random.RandomState(1050)
expected = rng.randint(50, 200, (32, 32), dtype=np.uint8)
path = str(tmp_path / 'gray_1050_tiled.tif')
write(expected, path, compression='jpeg', tiled=True, tile_size=16)
write(expected, path, compression='jpeg', tiled=True, tile_size=16,
allow_internal_only_jpeg=True)

arr, geo = read_to_array(path)
assert arr.shape == expected.shape
Expand All @@ -89,7 +90,8 @@ def test_grayscale_stripped(self, tmp_path):
rng = np.random.RandomState(1050)
expected = rng.randint(50, 200, (32, 32), dtype=np.uint8)
path = str(tmp_path / 'gray_1050_stripped.tif')
write(expected, path, compression='jpeg', tiled=False)
write(expected, path, compression='jpeg', tiled=False,
allow_internal_only_jpeg=True)

arr, geo = read_to_array(path)
assert arr.shape == expected.shape
Expand All @@ -104,7 +106,8 @@ def test_rgb_tiled(self, tmp_path):
b = np.full((32, 32), 128, dtype=np.uint8)
expected = np.stack([r, g, b], axis=2)
path = str(tmp_path / 'rgb_1050_tiled.tif')
write(expected, path, compression='jpeg', tiled=True, tile_size=16)
write(expected, path, compression='jpeg', tiled=True, tile_size=16,
allow_internal_only_jpeg=True)

arr, geo = read_to_array(path)
assert arr.shape == expected.shape
Expand All @@ -118,19 +121,22 @@ def test_float_data_rejected(self, tmp_path):
arr = np.zeros((8, 8), dtype=np.float32)
path = str(tmp_path / 'bad_1050.tif')
with pytest.raises(ValueError, match="uint8"):
write(arr, path, compression='jpeg')
write(arr, path, compression='jpeg',
allow_internal_only_jpeg=True)

def test_uint16_data_rejected(self, tmp_path):
arr = np.zeros((8, 8), dtype=np.uint16)
path = str(tmp_path / 'bad16_1050.tif')
with pytest.raises(ValueError, match="uint8"):
write(arr, path, compression='jpeg')
write(arr, path, compression='jpeg',
allow_internal_only_jpeg=True)

def test_4band_rejected(self, tmp_path):
arr = np.zeros((8, 8, 4), dtype=np.uint8)
path = str(tmp_path / 'bad4b_1050.tif')
with pytest.raises(ValueError, match="1 or 3 bands"):
write(arr, path, compression='jpeg')
write(arr, path, compression='jpeg',
allow_internal_only_jpeg=True)


class TestWriteGeotiffJpeg:
Expand Down
Loading
Loading