Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/geotiff export #364

Merged
merged 10 commits into from
Feb 5, 2020
96 changes: 96 additions & 0 deletions podpac/core/coordinates/coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import xarray.core.coordinates
from six import string_types
import pyproj
import logging

import podpac
from podpac.core.settings import settings
Expand All @@ -32,6 +33,14 @@
from podpac.core.coordinates.dependent_coordinates import DependentCoordinates
from podpac.core.coordinates.rotated_coordinates import RotatedCoordinates

# Optional dependencies
from lazy_import import lazy_module, lazy_class

rasterio = lazy_module("rasterio")

# Set up logging
_logger = logging.getLogger(__name__)


class Coordinates(tl.HasTraits):
"""
Expand Down Expand Up @@ -442,6 +451,42 @@ def from_url(cls, url):

return cls.from_definition(coords)

@classmethod
def from_geotransform(cls, geotransform, shape, crs=None):
""" Creates Coordinates from GDAL Geotransform.

"""
# Handle the case of rotated coordinates
try:
rcoords = RotatedCoordinates.from_geotransform(geotransform)
coords = Coordinates([rcoords], dims=["lat,lon"], crs=crs)
except:
mpu-creare marked this conversation as resolved.
Show resolved Hide resolved
rcoords = None
_logger.debug("Rasterio source dataset does not have Rotated Coordinates")

if rcoords is not None and rcoords.theta != 0:
# These are Rotated coordinates and we can return
return coords

# Handle the case of uniform coordinates (not rotated, but N-S E-W aligned)
affine = rasterio.Affine.from_gdal(*geotransform)
if affine.e == affine.a == 0:
order = -1
step = np.array([affine.d, affine.b])
else:
order = 1
step = np.array([affine.e, affine.a])

origin = affine.f + step[0] / 2, affine.c + step[1] / 2
end = origin[0] + step[0] * (shape[::order][0] - 1), origin[1] + step[1] * (shape[::order][1] - 1)
coords = Coordinates(
[
podpac.clinspace(origin[0], end[0], shape[::order][0], "lat"),
podpac.clinspace(origin[1], end[1], shape[::order][1], "lon"),
][::order]
)
return coords

@classmethod
def from_definition(cls, d):
"""
Expand Down Expand Up @@ -692,6 +737,10 @@ def shape(self):

return tuple(size for c in self._coords.values() for size in c.shape)

@property
def ushape(self):
return tuple(self[dim].size for dim in self.udims)

@property
def ndim(self):
""":int: Number of dimensions. """
Expand Down Expand Up @@ -801,6 +850,53 @@ def hash(self):
json_d = json.dumps(self.full_definition, separators=(",", ":"), cls=podpac.core.utils.JSONEncoder)
return hash_alg(json_d.encode("utf-8")).hexdigest()

@property
def geotransform(self):
""" :tuple: GDAL geotransform. """
# Make sure we only have 1 time and alt dimension
if "time" in self.udims and self["time"].size > 1:
raise TypeError(
'Only 2-D coordinates have a GDAL transform. This array has a "time" dimension of {} > 1'.format(
self["time"].size
)
)
if "alt" in self.udims and self["alt"].size > 1:
raise TypeError(
'Only 2-D coordinates have a GDAL transform. This array has a "alt" dimension of {} > 1'.format(
self["alt"].size
)
)

# Do the uniform coordinates case
if (
"lat" in self.dims
and isinstance(self._coords["lat"], UniformCoordinates1d)
and "lon" in self.dims
and isinstance(self._coords["lon"], UniformCoordinates1d)
):
if self.dims.index("lon") < self.dims.index("lat"):
first, second = "lat", "lon"
else:
first, second = "lon", "lat" # This case will have the exact correct geotransform
transform = rasterio.transform.Affine.translation(
self[first].start - self[first].step / 2, self[second].start - self[second].step / 2
) * rasterio.transform.Affine.scale(self[first].step, self[second].step)
transform = transform.to_gdal()
elif "lat,lon" in self.dims and isinstance(self._coords["lat,lon"], RotatedCoordinates):
transform = self._coords["lat,lon"].geotransform
elif "lon,lat" in self.dims and isinstance(self._coords["lon,lat"], RotatedCoordinates):
transform = self._coords["lon,lat"].geotransform
else:
raise TypeError(
"Only 2-D coordinates that are uniform or rotated have a GDAL transform. These coordinates "
"{} do not.".format(self)
)
if self.udims.index("lon") < self.udims.index("lat"):
# transform = (transform[3], transform[5], transform[4], transform[0], transform[2], transform[1])
transform = transform[3:] + transform[:3]

return transform

# ------------------------------------------------------------------------------------------------------------------
# Methods
# ------------------------------------------------------------------------------------------------------------------
Expand Down
16 changes: 12 additions & 4 deletions podpac/core/coordinates/rotated_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,11 @@ def _validate_step(self, d):
@classmethod
def from_geotransform(cls, geotransform, shape, dims=None, ctypes=None, segment_lengths=None):
affine = rasterio.Affine.from_gdal(*geotransform)
origin = affine.c, affine.f
origin = affine.f, affine.c
deg = affine.rotation_angle
scale = ~affine.rotation(deg) * ~affine.translation(*origin) * affine
step = np.array([scale.a, scale.e])
step = np.array([scale.e, scale.a])
origin = affine.f + step[0] / 2, affine.c + step[1] / 2
return cls(shape, np.deg2rad(deg), origin, step, dims=dims, ctypes=ctypes, segment_lengths=segment_lengths)

@classmethod
Expand Down Expand Up @@ -237,8 +238,15 @@ def corner(self):

@property
def geotransform(self):
""" :tuple: GDAL geotransform. """
return self.affine.to_gdal()
""" :tuple: GDAL geotransform.
Note: This property may not provide the correct order of lat/lon in the geotransform as this class does not
always have knowledge of the dimension order of the specified dataset. As such it always supplies
geotransforms assuming that dims = ['lat', 'lon']
"""
t = rasterio.Affine.translation(self.origin[1] - self.step[1] / 2, self.origin[0] - self.step[0] / 2)
r = rasterio.Affine.rotation(self.deg)
s = rasterio.Affine.scale(*self.step[::-1])
return (t * r * s).to_gdal()

@property
def coordinates(self):
Expand Down
112 changes: 112 additions & 0 deletions podpac/core/coordinates/test/test_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -1618,3 +1618,115 @@ def test_concat_crs(self):

with pytest.raises(ValueError, match="Cannot concat Coordinates"):
concat([c1, c2])


class TestCoordinatesGeoTransform(object):
def uniform_working(self):
# order: -lat, lon
c = Coordinates([clinspace(1.5, 0.5, 5, "lat"), clinspace(1, 2, 9, "lon")])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf, np.array([[c["lon"].area_bounds[0], c["lon"].step, 0], [c["lat"].area_bounds[1], 0, c["lat"].step]])
)
# order: lon, lat
c = Coordinates([clinspace(0.5, 1.5, 5, "lon"), clinspace(1, 2, 9, "lat")])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf, np.array([[c["lon"].area_bounds[0], 0, c["lon"].step], [c["lat"].area_bounds[0], c["lat"].step, 0]])
)

# order: lon, -lat, time
c = Coordinates([clinspace(0.5, 1.5, 5, "lon"), clinspace(2, 1, 9, "lat"), crange(10, 11, 2, "time")])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf, np.array([[c["lon"].area_bounds[0], 0, c["lon"].step], [c["lat"].area_bounds[1], c["lat"].step, 0]])
)
# order: -lon, -lat, time, alt
c = Coordinates(
[
clinspace(1.5, 0.5, 5, "lon"),
clinspace(2, 1, 9, "lat"),
crange(10, 11, 2, "time"),
crange(10, 11, 2, "alt"),
]
)
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf, np.array([[c["lon"].area_bounds[1], 0, c["lon"].step], [c["lat"].area_bounds[1], c["lat"].step, 0]])
)

def error_time_alt_too_big(self):
# time
c = Coordinates(
[
clinspace(1.5, 0.5, 5, "lon"),
clinspace(2, 1, 9, "lat"),
crange(1, 11, 2, "time"),
crange(1, 11, 2, "alt"),
]
)
with pytest.raises(
TypeError, match='Only 2-D coordinates have a GDAL transform. This array has a "time" dimension of'
):
c.geotransform
# alt
c = Coordinates([clinspace(1.5, 0.5, 5, "lon"), clinspace(2, 1, 9, "lat"), crange(1, 11, 2, "alt")])
with pytest.raises(
TypeError, match='Only 2-D coordinates have a GDAL transform. This array has a "alt" dimension of'
):
c.geotransform

def rot_coords_working(self):
# order -lat, lon
rc = RotatedCoordinates(shape=(4, 3), theta=np.pi / 8, origin=[10, 20], step=[-2.0, 1.0], dims=["lat", "lon"])
c = Coordinates([rc], dims=["lat,lon"])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf,
np.array(
[
[rc.origin[1] - rc.step[1] / 2, rc.step[1] * np.cos(rc.theta), -rc.step[0] * np.sin(rc.theta)],
[rc.origin[0] - rc.step[0] / 2, rc.step[1] * np.sin(rc.theta), rc.step[0] * np.cos(rc.theta)],
]
),
)
# order lon, lat
rc = RotatedCoordinates(shape=(4, 3), theta=np.pi / 8, origin=[10, 20], step=[2.0, 1.0], dims=["lon", "lat"])
c = Coordinates([rc], dims=["lon,lat"])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf,
np.array(
[
[rc.origin[0] - rc.step[0] / 2, rc.step[1] * np.sin(rc.theta), rc.step[0] * np.cos(rc.theta)],
[rc.origin[1] - rc.step[1] / 2, rc.step[1] * np.cos(rc.theta), -rc.step[0] * np.sin(rc.theta)],
]
),
)

# order -lon, lat
rc = RotatedCoordinates(shape=(4, 3), theta=np.pi / 8, origin=[10, 20], step=[-2.0, 1.0], dims=["lon", "lat"])
c = Coordinates([rc], dims=["lon,lat"])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf,
np.array(
[
[rc.origin[0] - rc.step[0] / 2, rc.step[1] * np.sin(rc.theta), rc.step[0] * np.cos(rc.theta)],
[rc.origin[1] - rc.step[1] / 2, rc.step[1] * np.cos(rc.theta), -rc.step[0] * np.sin(rc.theta)],
]
),
)
# order -lat, -lon
rc = RotatedCoordinates(shape=(4, 3), theta=np.pi / 8, origin=[10, 20], step=[-2.0, -1.0], dims=["lat", "lon"])
c = Coordinates([rc], dims=["lat,lon"])
tf = np.array(c.geotransform).reshape(2, 3)
np.testing.assert_almost_equal(
tf,
np.array(
[
[rc.origin[1] - rc.step[1] / 2, rc.step[1] * np.cos(rc.theta), -rc.step[0] * np.sin(rc.theta)],
[rc.origin[0] - rc.step[0] / 2, rc.step[1] * np.sin(rc.theta), rc.step[0] * np.cos(rc.theta)],
]
),
)
8 changes: 7 additions & 1 deletion podpac/core/coordinates/test/test_rotated_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,17 @@ def test_copy(self):
class TestRotatedCoordinatesGeotransform(object):
def test_geotransform(self):
c = RotatedCoordinates(shape=(3, 4), theta=np.pi / 4, origin=[10, 20], step=[1.0, 2.0], dims=["lat", "lon"])
assert_allclose(c.geotransform, (10.0, 0.7071068, -1.4142136, 20.0, 0.7071068, 1.4142136))
assert_allclose(c.geotransform, (19.0, 1.4142136, -0.7071068, 9.5, 1.4142136, 0.7071068))

c2 = RotatedCoordinates.from_geotransform(c.geotransform, c.shape, dims=["lat", "lon"])
assert c == c2

c = RotatedCoordinates(shape=(3, 4), theta=np.pi / 4, origin=[10, 20], step=[1.0, 2.0], dims=["lon", "lat"])
assert_allclose(c.geotransform, (19.0, 1.4142136, -0.7071068, 9.5, 1.4142136, 0.7071068))

c2 = RotatedCoordinates.from_geotransform(c.geotransform, c.shape, dims=["lon", "lat"])
assert c == c2


class TestRotatedCoordinatesStandardMethods(object):
def test_eq_type(self):
Expand Down
50 changes: 36 additions & 14 deletions podpac/core/data/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import traitlets as tl
import pandas as pd
import xarray as xr
import pyproj
import logging

from podpac.core.settings import settings
from podpac.core.utils import common_doc, trait_is_defined
from podpac.core.data.datasource import COMMON_DATA_DOC, DataSource
from podpac.core.coordinates import Coordinates, UniformCoordinates1d, ArrayCoordinates1d, StackedCoordinates
from podpac.core.coordinates import RotatedCoordinates
from podpac.core.coordinates.utils import Dimension, VALID_DIMENSION_NAMES

# Optional dependencies
Expand All @@ -30,6 +33,9 @@
zarrGroup = lazy_class("zarr.Group")
s3fs = lazy_module("s3fs")

# Set up logging
_logger = logging.getLogger(__name__)


@common_doc(COMMON_DATA_DOC)
class DatasetSource(DataSource):
Expand Down Expand Up @@ -676,8 +682,22 @@ class Rasterio(DataSource):
source = tl.Union([tl.Unicode(), tl.Instance(BytesIO)]).tag(readonly=True)
dataset = tl.Any().tag(readonly=True)

@property
def nan_vals(self):
return list(self.dataset.nodatavals)

# node attrs
band = tl.CInt(1).tag(attr=True)
band = tl.CInt(allow_none=True).tag(attr=True)

@tl.default("band")
def _band_default(self):
if (self.outputs is not None) and (self.output is not None):
band = self.outputs.index(self.output)
elif self.outputs is None:
band = 1
else:
band = None # All bands
return band

@tl.default("dataset")
def _open_dataset(self):
Expand Down Expand Up @@ -719,21 +739,18 @@ def get_native_coordinates(self):

# check to see if the coordinates are rotated used affine
affine = self.dataset.transform
if affine[1] != 0.0 or affine[3] != 0.0:
raise NotImplementedError("Rotated coordinates are not yet supported")

try:
if isinstance(self.dataset.crs, rasterio.crs.CRS):
crs = self.dataset.crs.wkt
elif isinstance(self.dataset.crs, dict) and "init" in self.dataset.crs:
crs = self.dataset.crs["init"].upper()
except:
crs = None

# get bounds
left, bottom, right, top = self.dataset.bounds
else:
try:
crs = pyproj.CRS(self.dataset.crs).to_wkt()
except:
raise RuntimeError("Unexpected rasterio crs '%s'" % self.dataset.crs)

# rasterio reads data upside-down from coordinate conventions, so lat goes from top to bottom
lat = UniformCoordinates1d(top, bottom, size=self.dataset.height, name="lat")
lon = UniformCoordinates1d(left, right, size=self.dataset.width, name="lon")
return Coordinates([lat, lon], dims=["lat", "lon"], crs=crs)
return Coordinates.from_geotransform(affine.to_gdal(), self.dataset.shape, crs)

@common_doc(COMMON_DATA_DOC)
def get_data(self, coordinates, coordinates_index):
Expand All @@ -744,7 +761,12 @@ def get_data(self, coordinates, coordinates_index):

# read data within coordinates_index window
window = ((slc[0].start, slc[0].stop), (slc[1].start, slc[1].stop))
raster_data = self.dataset.read(self.band, out_shape=tuple(coordinates.shape), window=window)

if self.outputs is not None: # read all the bands
raster_data = self.dataset.read(out_shape=(len(self.outputs),) + tuple(coordinates.shape), window=window)
raster_data = np.moveaxis(raster_data, 0, 2)
else: # read the requested band
raster_data = self.dataset.read(self.band, out_shape=tuple(coordinates.shape), window=window)

# set raster data to output array
data.data.ravel()[:] = raster_data.ravel()
Expand Down
Loading