Skip to content

Commit

Permalink
Tweak geobox extraction/persistence logic
Browse files Browse the repository at this point in the history
- Improve precision of GeoTransform attribute
- Change the way scale and translation is extracted
  - don't rely on resolution to compute offset
  - compute resolution from first two values
- Return original transform when array wasn't cropped
  - Geobox should round-trip without loss now
  • Loading branch information
Kirill888 committed May 9, 2024
1 parent 740a4cf commit 2803126
Show file tree
Hide file tree
Showing 3 changed files with 47 additions and 17 deletions.
29 changes: 24 additions & 5 deletions odc/geo/_xr_interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"""
Add ``.odc.`` extension to :py:class:`xarray.Dataset` and :class:`xarray.DataArray`.
"""
from __future__ import annotations

import functools
import warnings
from dataclasses import dataclass
Expand Down Expand Up @@ -33,7 +35,12 @@
from .gcp import GCPGeoBox, GCPMapping
from .geobox import Coordinate, GeoBox, GeoboxAnchor
from .geom import Geometry
from .math import affine_from_axis, maybe_int, resolution_from_affine
from .math import (
affine_from_axis,
approx_equal_affine,
maybe_int,
resolution_from_affine,
)
from .overlap import compute_output_geobox
from .roi import roi_is_empty
from .types import Resolution, SomeResolution, SomeShape, xy_
Expand Down Expand Up @@ -174,7 +181,7 @@ def _mk_crs_coord(
cf["gcps"] = _gcps_to_json(gcps)

if transform is not None:
cf["GeoTransform"] = " ".join(map(str, transform.to_gdal()))
cf["GeoTransform"] = _render_geo_transform(transform, precision=24)

return xarray.DataArray(
numpy.asarray(epsg, "int32"),
Expand Down Expand Up @@ -483,6 +490,12 @@ def _extract_geo_transform(crs_coord: xarray.DataArray) -> Optional[Affine]:
return Affine.from_gdal(c, a, b, f, d, e)


def _render_geo_transform(transform: Affine, precision: int = 24) -> str:
return " ".join(
map(lambda x: f"{x:.{precision}f}".rstrip("0").rstrip("."), transform.to_gdal())
)


def _extract_transform(
src: XarrayObject,
sdims: Tuple[str, str],
Expand All @@ -497,16 +510,17 @@ def _extract_transform(
return _extract_geo_transform(crs_coord)

_yy, _xx = (src[dim] for dim in sdims)
original_transform: Affine | None = None
if crs_coord is not None:
original_transform = _extract_geo_transform(crs_coord)

# First try to compute from 1-D X/Y coords
try:
transform = affine_from_axis(_xx.values, _yy.values)
except ValueError:
# This can fail when any dimension is shorter than 2 elements
# Figure out fallback resolution if possible and try again
if crs_coord is None:
return None
if (original_transform := _extract_geo_transform(crs_coord)) is None:
if crs_coord is None or original_transform is None:
return None
try:
transform = affine_from_axis(
Expand All @@ -517,6 +531,11 @@ def _extract_transform(
except ValueError:
return None

if original_transform is not None and approx_equal_affine(
transform, original_transform
):
transform = original_transform

if not gcp and (_pix2world := _xx.encoding.get("_transform", None)) is not None:
# non-axis aligned geobox detected
# adjust transform
Expand Down
28 changes: 22 additions & 6 deletions odc/geo/math.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,18 +240,26 @@ def data_resolution_and_offset(
:returns: ``(resolution, offset)``
"""
if not isinstance(data, np.ndarray):
data = data.values

if data.size < 2:
if data.size < 1:
raise ValueError("Can't calculate resolution for empty data")
if fallback_resolution is None:
raise ValueError("Can't calculate resolution with data size < 2")
res = fallback_resolution
else:
_res = (data[data.size - 1] - data[0]) / (data.size - 1.0)
res = _res.item()
off = data[0] - 0.5 * res
return res, float(off)

x0, x1 = map(float, data[:2])

off = data[0] - 0.5 * res
return res, off.item()
# x0 = 0*s + t + s/2
# x1 = 1*s + t + s/2
####################
# s = x1 - x0
# 2*t = 3*x0 - x1
return x1 - x0, (3 * x0 - x1) / 2


def affine_from_axis(
Expand Down Expand Up @@ -301,7 +309,7 @@ def affine_from_axis(
xres, xoff = data_resolution_and_offset(xx, frx)
yres, yoff = data_resolution_and_offset(yy, fry)

return Affine.translation(xoff, yoff) * Affine.scale(xres, yres)
return Affine(xres, 0.0, xoff, 0.0, yres, yoff)


def apply_affine(
Expand Down Expand Up @@ -361,6 +369,14 @@ def is_affine_st(A: Affine, tol: float = 1e-10) -> bool:
return abs(wx) < tol and abs(wy) < tol


def approx_equal_affine(a: Affine, b: Affine, tol: float = 1e-6) -> bool:
"""
Check if two affine matrices are approximately equal.
"""
sx, z1, tx, z2, sy, ty = map(lambda v: maybe_int(v, tol), (~a * b)[:6])
return (sx, z1, tx, z2, sy, ty) == (1, 0, 0, 0, 1, 0)


def snap_affine(
A: Affine, ttol: float = 1e-3, stol: float = 1e-6, tol: float = 1e-8
) -> Affine:
Expand Down
7 changes: 1 addition & 6 deletions odc/geo/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from . import CRS
from .geobox import GeoBox
from .gridspec import GridSpec
from .math import apply_affine, maybe_int
from .math import apply_affine, approx_equal_affine

# pylint: disable=invalid-name,

Expand Down Expand Up @@ -229,11 +229,6 @@ def daskify(xx, **kw):
)


def approx_equal_affine(a: Affine, b: Affine, tol: float = 1e-6) -> bool:
sx, z1, tx, z2, sy, ty = map(lambda v: maybe_int(v, tol), (~a * b)[:6])
return (sx, z1, tx, z2, sy, ty) == (1, 0, 0, 0, 1, 0)


def approx_equal_geobox(a: GeoBox, b: GeoBox, tol: float = 1e-6) -> bool:
if a.shape != b.shape or a.crs != b.crs:
return False
Expand Down

0 comments on commit 2803126

Please sign in to comment.