Skip to content

Commit 815b6c7

Browse files
authored
Rename GeoTIFF API to xarray conventions (#1047) (#1061)
open_geotiff replaces read_geotiff, to_geotiff replaces write_geotiff. Adds .xrs.to_geotiff() accessor on DataArray and Dataset, and .xrs.open_geotiff() on Dataset for spatially-windowed reads. Includes accessor tests and user guide notebook.
1 parent 8486517 commit 815b6c7

File tree

15 files changed

+1456
-180
lines changed

15 files changed

+1456
-180
lines changed

README.md

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -141,25 +141,29 @@ Native GeoTIFF and Cloud Optimized GeoTIFF reader/writer. No GDAL required.
141141

142142
| Name | Description | NumPy | Dask | CuPy GPU | Dask+CuPy GPU | Cloud |
143143
|:-----|:------------|:-----:|:----:|:--------:|:-------------:|:-----:|
144-
| [read_geotiff](xrspatial/geotiff/__init__.py) | Read GeoTIFF / COG / VRT | ✅️ | ✅️ | ✅️ | ✅️ | ✅️ |
145-
| [write_geotiff](xrspatial/geotiff/__init__.py) | Write DataArray as GeoTIFF / COG | ✅️ | ✅️ | ✅️ | ✅️ | ✅️ |
144+
| [open_geotiff](xrspatial/geotiff/__init__.py) | Read GeoTIFF / COG / VRT | ✅️ | ✅️ | ✅️ | ✅️ | ✅️ |
145+
| [to_geotiff](xrspatial/geotiff/__init__.py) | Write DataArray as GeoTIFF / COG | ✅️ | ✅️ | ✅️ | ✅️ | ✅️ |
146146
| [write_vrt](xrspatial/geotiff/__init__.py) | Generate VRT mosaic from GeoTIFFs | ✅️ | | | | |
147147

148-
`read_geotiff` and `write_geotiff` auto-dispatch to the correct backend:
148+
`open_geotiff` and `to_geotiff` auto-dispatch to the correct backend:
149149

150150
```python
151-
read_geotiff('dem.tif') # NumPy
152-
read_geotiff('dem.tif', chunks=512) # Dask
153-
read_geotiff('dem.tif', gpu=True) # CuPy (nvCOMP + GDS)
154-
read_geotiff('dem.tif', gpu=True, chunks=512) # Dask + CuPy
155-
read_geotiff('https://example.com/cog.tif') # HTTP COG
156-
read_geotiff('s3://bucket/dem.tif') # Cloud (S3/GCS/Azure)
157-
read_geotiff('mosaic.vrt') # VRT mosaic (auto-detected)
158-
159-
write_geotiff(cupy_array, 'out.tif') # auto-detects GPU
160-
write_geotiff(data, 'out.tif', gpu=True) # force GPU compress
161-
write_geotiff(data, 'ortho.tif', compression='jpeg') # JPEG for orthophotos
151+
open_geotiff('dem.tif') # NumPy
152+
open_geotiff('dem.tif', chunks=512) # Dask
153+
open_geotiff('dem.tif', gpu=True) # CuPy (nvCOMP + GDS)
154+
open_geotiff('dem.tif', gpu=True, chunks=512) # Dask + CuPy
155+
open_geotiff('https://example.com/cog.tif') # HTTP COG
156+
open_geotiff('s3://bucket/dem.tif') # Cloud (S3/GCS/Azure)
157+
open_geotiff('mosaic.vrt') # VRT mosaic (auto-detected)
158+
159+
to_geotiff(cupy_array, 'out.tif') # auto-detects GPU
160+
to_geotiff(data, 'out.tif', gpu=True) # force GPU compress
161+
to_geotiff(data, 'ortho.tif', compression='jpeg') # JPEG for orthophotos
162162
write_vrt('mosaic.vrt', ['tile1.tif', 'tile2.tif']) # generate VRT
163+
164+
# Accessor methods
165+
da.xrs.to_geotiff('out.tif', compression='lzw') # write from DataArray
166+
ds.xrs.open_geotiff('large_dem.tif') # read windowed to Dataset extent
163167
```
164168

165169
**Compression codecs:** Deflate, LZW (Numba JIT), ZSTD, PackBits, JPEG (Pillow), uncompressed
@@ -493,10 +497,10 @@ Importing `xrspatial` registers an `.xrs` accessor on DataArrays and Datasets, g
493497

494498
```python
495499
import xrspatial
496-
from xrspatial.geotiff import read_geotiff
500+
from xrspatial.geotiff import open_geotiff
497501

498502
# Read a GeoTIFF (no GDAL required)
499-
elevation = read_geotiff('dem.tif')
503+
elevation = open_geotiff('dem.tif')
500504

501505
# Surface analysis — call operations directly on the DataArray
502506
slope = elevation.xrs.slope()

docs/source/user_guide/multispectral.ipynb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
},
4242
"outputs": [],
4343
"source": [
44-
"import datashader as ds\nfrom datashader.colors import Elevation\nimport datashader.transfer_functions as tf\nfrom datashader.transfer_functions import shade\nfrom datashader.transfer_functions import stack\nfrom datashader.transfer_functions import dynspread\nfrom datashader.transfer_functions import set_background\nfrom datashader.transfer_functions import Images, Image\nfrom datashader.utils import orient_array\nimport numpy as np\nimport xarray as xr\nfrom xrspatial.geotiff import read_geotiff"
44+
"import datashader as ds\nfrom datashader.colors import Elevation\nimport datashader.transfer_functions as tf\nfrom datashader.transfer_functions import shade\nfrom datashader.transfer_functions import stack\nfrom datashader.transfer_functions import dynspread\nfrom datashader.transfer_functions import set_background\nfrom datashader.transfer_functions import Images, Image\nfrom datashader.utils import orient_array\nimport numpy as np\nimport xarray as xr\nfrom xrspatial.geotiff import open_geotiff"
4545
]
4646
},
4747
{
@@ -132,7 +132,7 @@
132132
}
133133
],
134134
"source": [
135-
"SCENE_ID = \"LC80030172015001LGN00\"\nEXTS = {\n \"blue\": \"B2\",\n \"green\": \"B3\",\n \"red\": \"B4\",\n \"nir\": \"B5\",\n}\n\ncvs = ds.Canvas(plot_width=1024, plot_height=1024)\nlayers = {}\nfor name, ext in EXTS.items():\n layer = read_geotiff(f\"../../../xrspatial-examples/data/{SCENE_ID}_{ext}.tiff\", band=0)\n layer.name = name\n layer = cvs.raster(layer, agg=\"mean\")\n layer.data = orient_array(layer)\n layers[name] = layer\nlayers"
135+
"SCENE_ID = \"LC80030172015001LGN00\"\nEXTS = {\n \"blue\": \"B2\",\n \"green\": \"B3\",\n \"red\": \"B4\",\n \"nir\": \"B5\",\n}\n\ncvs = ds.Canvas(plot_width=1024, plot_height=1024)\nlayers = {}\nfor name, ext in EXTS.items():\n layer = open_geotiff(f\"../../../xrspatial-examples/data/{SCENE_ID}_{ext}.tiff\", band=0)\n layer.name = name\n layer = cvs.raster(layer, agg=\"mean\")\n layer.data = orient_array(layer)\n layers[name] = layer\nlayers"
136136
]
137137
},
138138
{

examples/user_guide/25_GLCM_Texture.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@
282282
"metadata": {},
283283
"outputs": [],
284284
"source": [
285-
"import os\nfrom xrspatial.geotiff import read_geotiff\n\n\nCOG_URL = (\n 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/'\n 'sentinel-s2-l2a-cogs/10/S/EG/2023/9/'\n 'S2B_10SEG_20230921_0_L2A/B08.tif'\n)\n\ntry:\n nir_da = read_geotiff(COG_URL, band=0, window=(2100, 5300, 2600, 5800))\n nir = nir_da.values.astype(np.float64)\n print(f'Downloaded NIR band: {nir.shape}, range {nir.min():.0f} to {nir.max():.0f}')\nexcept Exception as exc:\n print(f'Remote read failed ({exc}), using synthetic fallback')\n rng_sat = np.random.default_rng(99)\n nir = np.zeros((500, 500), dtype=np.float64)\n nir[:, 250:] = rng_sat.normal(80, 10, (500, 250)).clip(20, 200)\n nir[:, :250] = rng_sat.normal(1800, 400, (500, 250)).clip(300, 4000)\n\nsatellite = xr.DataArray(nir, dims=['y', 'x'],\n coords={'y': np.arange(nir.shape[0], dtype=float),\n 'x': np.arange(nir.shape[1], dtype=float)})\n\nfig, ax = plt.subplots(figsize=(7, 7))\nsatellite.plot.imshow(ax=ax, cmap='gray', vmax=float(np.percentile(nir, 98)),\n add_colorbar=False)\nax.set_title('Sentinel-2 NIR band')\nax.set_axis_off()\nplt.tight_layout()"
285+
"import os\nfrom xrspatial.geotiff import open_geotiff\n\n\nCOG_URL = (\n 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/'\n 'sentinel-s2-l2a-cogs/10/S/EG/2023/9/'\n 'S2B_10SEG_20230921_0_L2A/B08.tif'\n)\n\ntry:\n nir_da = open_geotiff(COG_URL, band=0, window=(2100, 5300, 2600, 5800))\n nir = nir_da.values.astype(np.float64)\n print(f'Downloaded NIR band: {nir.shape}, range {nir.min():.0f} to {nir.max():.0f}')\nexcept Exception as exc:\n print(f'Remote read failed ({exc}), using synthetic fallback')\n rng_sat = np.random.default_rng(99)\n nir = np.zeros((500, 500), dtype=np.float64)\n nir[:, 250:] = rng_sat.normal(80, 10, (500, 250)).clip(20, 200)\n nir[:, :250] = rng_sat.normal(1800, 400, (500, 250)).clip(300, 4000)\n\nsatellite = xr.DataArray(nir, dims=['y', 'x'],\n coords={'y': np.arange(nir.shape[0], dtype=float),\n 'x': np.arange(nir.shape[1], dtype=float)})\n\nfig, ax = plt.subplots(figsize=(7, 7))\nsatellite.plot.imshow(ax=ax, cmap='gray', vmax=float(np.percentile(nir, 98)),\n add_colorbar=False)\nax.set_title('Sentinel-2 NIR band')\nax.set_axis_off()\nplt.tight_layout()"
286286
]
287287
},
288288
{

examples/user_guide/35_GeoTIFF_IO.ipynb

Lines changed: 1024 additions & 0 deletions
Large diffs are not rendered by default.
270 KB
Loading

examples/viewshed_gpu.ipynb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
},
3636
"outputs": [],
3737
"source": [
38-
"import pandas\nimport matplotlib.pyplot as plt\nimport geopandas as gpd\n\nimport xarray as xr\nimport numpy as np\nimport cupy\nfrom xrspatial.geotiff import read_geotiff\n\nimport xrspatial"
38+
"import pandas\nimport matplotlib.pyplot as plt\nimport geopandas as gpd\n\nimport xarray as xr\nimport numpy as np\nimport cupy\nfrom xrspatial.geotiff import open_geotiff\n\nimport xrspatial"
3939
]
4040
},
4141
{
@@ -66,7 +66,7 @@
6666
},
6767
"outputs": [],
6868
"source": [
69-
"file_name = '../xrspatial-examples/data/colorado_merge_3arc_resamp.tif'\n\nraster = read_geotiff(file_name, band=0)\nraster.name = 'Colorado Elevation Raster'\n\nxmin, xmax = raster.x.data.min(), raster.x.data.max()\nymin, ymax = raster.y.data.min(), raster.y.data.max()\n\nxmin, xmax, ymin, ymax"
69+
"file_name = '../xrspatial-examples/data/colorado_merge_3arc_resamp.tif'\n\nraster = open_geotiff(file_name, band=0)\nraster.name = 'Colorado Elevation Raster'\n\nxmin, xmax = raster.x.data.min(), raster.x.data.max()\nymin, ymax = raster.y.data.min(), raster.y.data.max()\n\nxmin, xmax, ymin, ymax"
7070
]
7171
},
7272
{

examples/xarray-spatial_classification-methods.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
},
4848
"outputs": [],
4949
"source": [
50-
"import xarray as xr\nfrom xrspatial.geotiff import read_geotiff\nimport xrspatial\n\nfile_name = '../xrspatial-examples/data/colorado_merge_3arc_resamp.tif'\nraster = read_geotiff(file_name, band=0)\nraster.name = 'Colorado Elevation Raster'\n\nxmin, xmax = raster.x.data.min(), raster.x.data.max()\nymin, ymax = raster.y.data.min(), raster.y.data.max()\n\nxmin, xmax, ymin, ymax"
50+
"import xarray as xr\nfrom xrspatial.geotiff import open_geotiff\nimport xrspatial\n\nfile_name = '../xrspatial-examples/data/colorado_merge_3arc_resamp.tif'\nraster = open_geotiff(file_name, band=0)\nraster.name = 'Colorado Elevation Raster'\n\nxmin, xmax = raster.x.data.min(), raster.x.data.max()\nymin, ymax = raster.y.data.min(), raster.y.data.max()\n\nxmin, xmax, ymin, ymax"
5151
]
5252
},
5353
{

xrspatial/accessor.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ def __init__(self, obj):
2626
def plot(self, **kwargs):
2727
"""Plot the DataArray, using an embedded TIFF colormap if present.
2828
29-
For palette/indexed-color GeoTIFFs (read via ``read_geotiff``),
29+
For palette/indexed-color GeoTIFFs (read via ``open_geotiff``),
3030
the TIFF's color table is applied automatically with correct
3131
normalization. For all other DataArrays, falls through to the
3232
standard ``da.plot()``.
3333
3434
Usage::
3535
36-
da = read_geotiff('landcover.tif')
36+
da = open_geotiff('landcover.tif')
3737
da.xrs.plot() # palette colors used automatically
3838
"""
3939
import numpy as np
@@ -482,6 +482,18 @@ def rasterize(self, geometries, **kwargs):
482482
from .rasterize import rasterize
483483
return rasterize(geometries, like=self._obj, **kwargs)
484484

485+
# ---- GeoTIFF I/O ----
486+
487+
def to_geotiff(self, path, **kwargs):
488+
"""Write this DataArray as a GeoTIFF.
489+
490+
Equivalent to ``to_geotiff(da, path, **kwargs)``.
491+
492+
See :func:`xrspatial.geotiff.to_geotiff` for full parameter docs.
493+
"""
494+
from .geotiff import to_geotiff
495+
return to_geotiff(self._obj, path, **kwargs)
496+
485497

486498
@xr.register_dataset_accessor("xrs")
487499
class XrsSpatialDatasetAccessor:
@@ -820,3 +832,75 @@ def rasterize(self, geometries, **kwargs):
820832
"Dataset has no 2D variable with 'y' and 'x' dimensions "
821833
"to use as rasterize template"
822834
)
835+
836+
# ---- GeoTIFF I/O ----
837+
838+
def to_geotiff(self, path, var=None, **kwargs):
839+
"""Write a Dataset variable as a GeoTIFF.
840+
841+
Parameters
842+
----------
843+
path : str
844+
Output file path.
845+
var : str or None
846+
Variable name to write. If None, uses the first 2D variable
847+
with y/x dimensions.
848+
**kwargs
849+
Passed to :func:`xrspatial.geotiff.to_geotiff`.
850+
"""
851+
from .geotiff import to_geotiff
852+
ds = self._obj
853+
if var is not None:
854+
return to_geotiff(ds[var], path, **kwargs)
855+
for v in ds.data_vars:
856+
da = ds[v]
857+
if da.ndim >= 2 and 'y' in da.dims and 'x' in da.dims:
858+
return to_geotiff(da, path, **kwargs)
859+
raise ValueError(
860+
"Dataset has no variable with 'y' and 'x' dimensions to write"
861+
)
862+
863+
def open_geotiff(self, source, **kwargs):
864+
"""Read a GeoTIFF windowed to this Dataset's spatial extent.
865+
866+
Uses the Dataset's y/x coordinates to compute a pixel window,
867+
then reads only that region from the file.
868+
869+
Parameters
870+
----------
871+
source : str
872+
File path to the GeoTIFF.
873+
**kwargs
874+
Passed to :func:`xrspatial.geotiff.open_geotiff` (except
875+
``window``, which is computed automatically).
876+
877+
Returns
878+
-------
879+
xr.DataArray
880+
The windowed portion of the GeoTIFF.
881+
"""
882+
from .geotiff import open_geotiff, _read_geo_info, _extent_to_window
883+
ds = self._obj
884+
if 'y' not in ds.coords or 'x' not in ds.coords:
885+
raise ValueError(
886+
"Dataset must have 'y' and 'x' coordinates to compute "
887+
"a spatial window"
888+
)
889+
y = ds.coords['y'].values
890+
x = ds.coords['x'].values
891+
y_min, y_max = float(y.min()), float(y.max())
892+
x_min, x_max = float(x.min()), float(x.max())
893+
894+
geo_info, file_h, file_w = _read_geo_info(source)
895+
t = geo_info.transform
896+
897+
# Expand extent by half a pixel so we capture edge pixels
898+
y_min -= abs(t.pixel_height) * 0.5
899+
y_max += abs(t.pixel_height) * 0.5
900+
x_min -= abs(t.pixel_width) * 0.5
901+
x_max += abs(t.pixel_width) * 0.5
902+
903+
window = _extent_to_window(t, file_h, file_w,
904+
y_min, y_max, x_min, x_max)
905+
kwargs.pop('window', None)
906+
return open_geotiff(source, window=window, **kwargs)

xrspatial/geotiff/__init__.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44
55
Public API
66
----------
7-
read_geotiff(source, ...)
7+
open_geotiff(source, ...)
88
Read a GeoTIFF file to an xarray.DataArray.
9-
write_geotiff(data, path, ...)
9+
to_geotiff(data, path, ...)
1010
Write an xarray.DataArray as a GeoTIFF or COG.
11-
open_cog(url, ...)
12-
Read a Cloud Optimized GeoTIFF from an HTTP URL.
1311
"""
1412
from __future__ import annotations
1513

@@ -20,7 +18,7 @@
2018
from ._reader import read_to_array
2119
from ._writer import write
2220

23-
__all__ = ['read_geotiff', 'write_geotiff', 'write_vrt']
21+
__all__ = ['open_geotiff', 'to_geotiff', 'write_vrt']
2422

2523

2624
def _wkt_to_epsg(wkt_or_proj: str) -> int | None:
@@ -98,7 +96,53 @@ def _coords_to_transform(da: xr.DataArray) -> GeoTransform | None:
9896
)
9997

10098

101-
def read_geotiff(source: str, *, window=None,
99+
def _read_geo_info(source: str):
100+
"""Read only the geographic metadata and image dimensions from a GeoTIFF.
101+
102+
Returns (geo_info, height, width) without reading pixel data.
103+
"""
104+
from ._geotags import extract_geo_info
105+
from ._header import parse_all_ifds, parse_header
106+
107+
with open(source, 'rb') as f:
108+
import mmap
109+
data = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
110+
try:
111+
header = parse_header(data)
112+
ifds = parse_all_ifds(data, header)
113+
ifd = ifds[0]
114+
geo_info = extract_geo_info(ifd, data, header.byte_order)
115+
return geo_info, ifd.height, ifd.width
116+
finally:
117+
data.close()
118+
119+
120+
def _extent_to_window(transform, file_height, file_width,
121+
y_min, y_max, x_min, x_max):
122+
"""Convert geographic extent to pixel window (row_start, col_start, row_stop, col_stop).
123+
124+
Clamps to file bounds.
125+
"""
126+
col_start = (x_min - transform.origin_x) / transform.pixel_width
127+
col_stop = (x_max - transform.origin_x) / transform.pixel_width
128+
129+
row_start = (y_max - transform.origin_y) / transform.pixel_height
130+
row_stop = (y_min - transform.origin_y) / transform.pixel_height
131+
132+
if row_start > row_stop:
133+
row_start, row_stop = row_stop, row_start
134+
if col_start > col_stop:
135+
col_start, col_stop = col_stop, col_start
136+
137+
row_start = max(0, int(np.floor(row_start)))
138+
col_start = max(0, int(np.floor(col_start)))
139+
row_stop = min(file_height, int(np.ceil(row_stop)))
140+
col_stop = min(file_width, int(np.ceil(col_stop)))
141+
142+
return (row_start, col_start, row_stop, col_stop)
143+
144+
145+
def open_geotiff(source: str, *, window=None,
102146
overview_level: int | None = None,
103147
band: int | None = None,
104148
name: str | None = None,
@@ -285,7 +329,7 @@ def _is_gpu_data(data) -> bool:
285329
return isinstance(data, _cupy_type)
286330

287331

288-
def write_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
332+
def to_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
289333
crs: int | str | None = None,
290334
nodata=None,
291335
compression: str = 'deflate',
@@ -445,14 +489,6 @@ def write_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
445489
)
446490

447491

448-
def open_cog(url: str, **kwargs) -> xr.DataArray:
449-
"""Deprecated: use ``read_geotiff(url, ...)`` instead.
450-
451-
read_geotiff handles HTTP URLs, cloud URIs, and local files.
452-
"""
453-
return read_geotiff(url, **kwargs)
454-
455-
456492
def read_geotiff_dask(source: str, *, chunks: int | tuple = 512,
457493
overview_level: int | None = None,
458494
name: str | None = None) -> xr.DataArray:

0 commit comments

Comments
 (0)