Skip to content

Commit bfe732a

Browse files
authored
Remove rioxarray dependency (#46)
1 parent c6bb13a commit bfe732a

File tree

14 files changed

+184
-41
lines changed

14 files changed

+184
-41
lines changed

docs/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@
155155
napoleon_type_aliases = {
156156
# general terms
157157
"sequence": ":term:`sequence`",
158-
"iterable": ":term:`iterable`",
158+
"Hashable": ":term:`sequence`",
159+
"iterable": "~collections.abc.Hashable",
159160
"callable": ":py:func:`callable`",
160161
"dict_like": ":term:`dict-like <mapping>`",
161162
"dict-like": ":term:`dict-like <mapping>`",

docs/raster_index/design_choices.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,16 @@ The downside here is that CRS information is duplicated in two places explicitly
4646
### Don't like it?
4747

4848
We chose this approach to enable experimentation. It is entirely possible to experiment with other approaches. Please reach out if you have opinions on this topic.
49+
50+
## Handling the `GeoTransform` attribute
51+
52+
GDAL _chooses_ to track the affine transform using a `GeoTransform` attribute on a `spatial_ref` variable. The `"spatial_ref"` is a
53+
"grid mapping" variable (as termed by the CF-conventions). It also records CRS information. Currently, our design is that
54+
{py:class}`xproj.CRSIndex` controls the CRS information and handles the creation of the `"spatial_ref"` variable, or more generally,
55+
the grid mapping variable. Thus, {py:class}`RasterIndex` _cannot_ keep the `"GeoTransform"` attribute on `"spatial_ref"` up-to-date
56+
because it does not control it.
57+
58+
Thus, {py:func}`assign_index` will delete the `"GeoTransform"` attribute on the grid mapping variable if it is detected, after using it
59+
to construct the affine matrix.
60+
61+
If you wish to extract the GeoTransform attribute to write it to a location of your choosing use {py:meth}`RasterIndex.as_geotransform`.

docs/rasterize/exactextract.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
"outputs": [],
2929
"source": [
3030
"import xarray as xr\n",
31+
"import xproj # noqa\n",
3132
"\n",
3233
"ds = xr.tutorial.open_dataset(\"eraint_uvz\")\n",
33-
"ds = ds.rio.write_crs(\"epsg:4326\")\n",
34+
"ds = ds.proj.assign_crs(spatial_ref=\"epsg:4326\")\n",
3435
"ds"
3536
]
3637
},

docs/rasterize/geometry_mask.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
"outputs": [],
2929
"source": [
3030
"import xarray as xr\n",
31+
"import xproj # noqa\n",
3132
"\n",
3233
"ds = xr.tutorial.open_dataset(\"eraint_uvz\")[[\"u\"]]\n",
33-
"ds = ds.rio.write_crs(\"epsg:4326\")\n",
34+
"ds = ds.proj.assign_crs(spatial_ref=\"epsg:4326\")\n",
3435
"ds"
3536
]
3637
},

docs/rasterize/rasterio.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
"outputs": [],
2929
"source": [
3030
"import xarray as xr\n",
31+
"import xproj # noqa\n",
3132
"\n",
3233
"ds = xr.tutorial.open_dataset(\"eraint_uvz\")\n",
33-
"ds = ds.rio.write_crs(\"epsg:4326\")\n",
34+
"ds = ds.proj.assign_crs(spatial_ref=\"epsg:4326\")\n",
3435
"ds"
3536
]
3637
},

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ test = [
4343
"pooch",
4444
"dask-geopandas",
4545
"rasterio",
46-
"rioxarray",
4746
"exactextract",
4847
"sparse",
4948
"netCDF4",

src/rasterix/raster_index.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union, maybe_int, snap_grid
2323
from rasterix.rioxarray_compat import guess_dims
24+
from rasterix.utils import get_affine
2425

2526
T_Xarray = TypeVar("T_Xarray", "DataArray", "Dataset")
2627

@@ -37,10 +38,14 @@ def assign_index(
3738
) -> T_Xarray:
3839
"""Assign a RasterIndex to an Xarray DataArray or Dataset.
3940
41+
By default, the affine transform is guessed by first looking for a ``GeoTransform`` attribute
42+
on a CF "grid mapping" variable (commonly ``"spatial_ref"``). If not present, then the affine is determined from 1D coordinate
43+
variables named ``x_dim`` and ``y_dim`` provided to this function.
44+
4045
Parameters
4146
----------
4247
obj : xarray.DataArray or xarray.Dataset
43-
The object to assign the index to. Must have a rio accessor with a transform.
48+
The object to assign the index to.
4449
x_dim : str, optional
4550
Name of the x dimension. If None, will be automatically detected.
4651
y_dim : str, optional
@@ -53,22 +58,37 @@ def assign_index(
5358
xarray.DataArray or xarray.Dataset
5459
The input object with RasterIndex coordinates assigned.
5560
61+
Notes
62+
-----
63+
The "grid mapping" variable is determined following the CF conventions:
64+
65+
- If a DataArray is provided, we look for an attribute named ``"grid_mapping"``.
66+
- For a Dataset, we pull the first detected ``"grid_mapping"`` attribute when iterating over data variables.
67+
68+
The value of this attribute is a variable name containing projection information (commonly ``"spatial_ref"``).
69+
We then look for a ``"GeoTransform"`` attribute on this variable (following GDAL convention).
70+
71+
References
72+
----------
73+
- `CF conventions document <http://cfconventions.org/Data/cf-conventions/cf-conventions-1.11/cf-conventions.html#grid-mappings-and-projections>`_.
74+
- `GDAL docs on GeoTransform <https://gdal.org/en/stable/tutorials/geotransforms_tut.html>`_.
75+
5676
Examples
5777
--------
5878
>>> import xarray as xr
59-
>>> import rioxarray # Required for rio accessor
79+
>>> import rioxarray # Required for reading TIFF
6080
>>> da = xr.open_dataset("path/to/raster.tif", engine="rasterio")
6181
>>> indexed_da = assign_index(da)
6282
"""
63-
import rioxarray # noqa
64-
6583
if x_dim is None or y_dim is None:
6684
guessed_x, guessed_y = guess_dims(obj)
6785
x_dim = x_dim or guessed_x
6886
y_dim = y_dim or guessed_y
6987

88+
affine = get_affine(obj, x_dim=x_dim, y_dim=y_dim, clear_transform=True)
89+
7090
index = RasterIndex.from_transform(
71-
obj.rio.transform(),
91+
affine,
7292
width=obj.sizes[x_dim],
7393
height=obj.sizes[y_dim],
7494
x_dim=x_dim,
@@ -626,6 +646,15 @@ def transform(self) -> Affine:
626646
"""Affine transform for top-left corners."""
627647
return self.center_transform() * Affine.translation(-0.5, -0.5)
628648

649+
def as_geotransform(self, *, decimals: int | None = None) -> str:
650+
"""Convert the affine transform to a string suitable for saving as the GeoTransform attribute."""
651+
gt = self.transform().to_gdal()
652+
if decimals is not None:
653+
fmt = f".{decimals}f"
654+
return " ".join(f"{num:{fmt}}" for num in gt)
655+
else:
656+
return " ".join(map(str, gt))
657+
629658
def center_transform(self) -> Affine:
630659
"""Affine transform for cell centers."""
631660
if not self._axis_independent:

src/rasterix/rasterize/rasterio.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
from rasterio.features import geometry_mask as geometry_mask_rio
1616
from rasterio.features import rasterize as rasterize_rio
1717

18-
from .utils import XAXIS, YAXIS, clip_to_bbox, get_affine, is_in_memory, prepare_for_dask
18+
from ..utils import get_affine
19+
from .utils import XAXIS, YAXIS, clip_to_bbox, is_in_memory, prepare_for_dask
1920

2021
F = TypeVar("F", bound=Callable[..., Any])
2122

@@ -161,7 +162,7 @@ def rasterize(
161162
obj = clip_to_bbox(obj, geometries, xdim=xdim, ydim=ydim)
162163

163164
rasterize_kwargs = dict(
164-
all_touched=all_touched, merge_alg=merge_alg, affine=get_affine(obj, xdim=xdim, ydim=ydim), env=env
165+
all_touched=all_touched, merge_alg=merge_alg, affine=get_affine(obj, x_dim=xdim, y_dim=ydim), env=env
165166
)
166167
# FIXME: box.crs == geometries.crs
167168

@@ -325,7 +326,7 @@ def geometry_mask(
325326
obj = clip_to_bbox(obj, geometries, xdim=xdim, ydim=ydim)
326327

327328
geometry_mask_kwargs = dict(
328-
all_touched=all_touched, affine=get_affine(obj, xdim=xdim, ydim=ydim), env=env
329+
all_touched=all_touched, affine=get_affine(obj, x_dim=xdim, y_dim=ydim), env=env
329330
)
330331

331332
if is_in_memory(obj=obj, geometries=geometries):

src/rasterix/utils.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import xarray as xr
2+
from affine import Affine
3+
4+
5+
def get_grid_mapping_var(obj: xr.Dataset | xr.DataArray) -> xr.DataArray | None:
6+
grid_mapping_var = None
7+
if isinstance(obj, xr.DataArray):
8+
if maybe := obj.attrs.get("grid_mapping", None):
9+
if maybe in obj.coords:
10+
grid_mapping_var = maybe
11+
else:
12+
# for datasets, grab the first one for simplicity
13+
for var in obj.data_vars.values():
14+
if maybe := var.attrs.get("grid_mapping"):
15+
if maybe in obj.coords:
16+
# make sure it exists and is not an out-of-date attribute
17+
grid_mapping_var = maybe
18+
break
19+
if grid_mapping_var is None and "spatial_ref" in obj.coords:
20+
# hardcode this
21+
grid_mapping_var = "spatial_ref"
22+
if grid_mapping_var is not None:
23+
return obj[grid_mapping_var]
24+
return None
25+
26+
27+
def get_affine(
28+
obj: xr.Dataset | xr.DataArray, *, x_dim="x", y_dim="y", clear_transform: bool = False
29+
) -> Affine:
30+
"""
31+
Grabs an affine transform from an Xarray object.
32+
33+
This method will first look for the ``"GeoTransform"`` attribute on a variable named
34+
``"spatial_ref"``. If not, it will auto-guess the transform from the provided ``x_dim``,
35+
and ``y_dim``.
36+
37+
Parameters
38+
----------
39+
obj: xr.DataArray or xr.Dataset
40+
x_dim: str, optional
41+
Name of the X dimension coordinate variable.
42+
y_dim: str, optional
43+
Name of the Y dimension coordinate variable.
44+
clear_transform: bool
45+
Whether to delete the ``GeoTransform`` attribute if detected.
46+
47+
Returns
48+
-------
49+
affine: Affine
50+
"""
51+
grid_mapping_var = get_grid_mapping_var(obj)
52+
if grid_mapping_var is not None and (transform := grid_mapping_var.attrs.get("GeoTransform")):
53+
if clear_transform:
54+
del grid_mapping_var.attrs["GeoTransform"]
55+
return Affine.from_gdal(*map(float, transform.split(" ")))
56+
else:
57+
x = obj.coords[x_dim]
58+
y = obj.coords[y_dim]
59+
if x.ndim != 1:
60+
raise ValueError(f"Coordinate variable {x_dim=!r} must be 1D.")
61+
if y.ndim != 1:
62+
raise ValueError(f"Coordinate variable {y_dim=!r} must be 1D.")
63+
dx = (x[1] - x[0]).item()
64+
dy = (y[1] - y[0]).item()
65+
return Affine.translation(
66+
x[0].item() - dx / 2, (y[0] if dy < 0 else y[-1]).item() - dy / 2
67+
) * Affine.scale(dx, dy)

tests/geometry_mask_snapshot.nc

2.41 KB
Binary file not shown.

0 commit comments

Comments
 (0)