Skip to content

Commit

Permalink
Merge pull request #2 from asfadmin/rew/pr-4721-add-ummg-helpers
Browse files Browse the repository at this point in the history
PR-4721 Add transformations for UMM-G
  • Loading branch information
reweeden authored Feb 23, 2024
2 parents 692204e + 70469b5 commit 1efd679
Show file tree
Hide file tree
Showing 12 changed files with 438 additions and 120 deletions.
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,61 @@
# Geo Extensions
This directory contains an installable python package with common functionality
that may be used across different workflows. The intent is for the API to be
designed in a generic and reusable way. Code that is very specific to its
context should not be copy/pasted into this library!

This library consists of some common functions needed to manipulate polygons
before posting them to CMR. This includes functions to split polygons along
the antimeridian and perform other 'clean up' operations.


## Example Usage

Polygons are manipulated using a composable pipeline of transformation
functions. A transformation function is any function with the following
signature:

```python
def transformation(polygon: Polygon) -> Generator[Polygon, None, None]:
...
```

A Transformer object is created with a list of transformations, and then can
be reused to perform the same manipulation on many polygons.

```python
from geo_extensions import Transformer
from geo_extensions.transformations import (
simplify_polygon,
split_polygon_on_antimeridian,
)


def my_custom_transformation(polygon):
"""Duplicate polygon"""

yield polygon
yield polygon


transformer = Transformer([
simplify_polygon,
my_custom_transformation,
split_polygon_on_antimeridian,
])

final_polygons = transformer.transform([
Polygon([
(150., 10.), (150., -10.),
(-150., -10.), (-150., 10.), (150., 10.),
])
])
```

The default transformer performs some standard transformations that are usually
needed. Check the definition for what those transformations are.

```python
from geo_extensions import default_transformer


WKT = "MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))"

polygons = default_transformer.from_wkt(WKT)
```
30 changes: 30 additions & 0 deletions geo_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from geo_extensions.checks import (
fixed_size_polygon_crosses_antimeridian,
polygon_crosses_antimeridian,
)
from geo_extensions.transformations import (
reverse_polygon,
simplify_polygon,
split_polygon_on_antimeridian,
)
from geo_extensions.transformer import Transformer, to_polygons
from geo_extensions.types import Transformation, TransformationResult

default_transformer = Transformer([
simplify_polygon(0.1),
split_polygon_on_antimeridian,
])


__all__ = (
"default_transformer",
"fixed_size_polygon_crosses_antimeridian",
"polygon_crosses_antimeridian",
"reverse_polygon",
"simplify_polygon",
"split_polygon_on_antimeridian",
"to_polygons",
"Transformation",
"TransformationResult",
"Transformer",
)
33 changes: 33 additions & 0 deletions geo_extensions/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from shapely.geometry import Polygon


def polygon_crosses_antimeridian(polygon: Polygon) -> bool:
"""Checks if the longitude coordinates 'wrap around' the 180/-180 line.
The polygon must be oriented in counter-clockwise order.
:param polygon: the polygon to check
"""

# Polygons crossing the antimeridian will appear to be mis-ordered
return not polygon.exterior.is_ccw


def fixed_size_polygon_crosses_antimeridian(
polygon: Polygon,
min_lon_extent: float,
) -> bool:
"""Checks if the longitude coordinates 'wrap around' the 180/-180 line
based on a heuristic that assumes the polygon is of a certain size.
:param polygon: the polygon check
:param min_lon_extent: the lower bound for the distance between the
longitude values of the bounding box enclosing the entire polygon.
Must be between (0, 180) exclusive.
"""
assert 0 < min_lon_extent < 180

min_lon, _, max_lon, _ = polygon.bounds
dist_from_180 = 180 - min_lon_extent

return max_lon > dist_from_180 or min_lon < -dist_from_180
64 changes: 27 additions & 37 deletions geo_extensions/geospatial.py → geo_extensions/transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,39 @@
ordered in the shapely flat space.
"""

from typing import List, Tuple
from typing import Generator, List, Tuple

from shapely.geometry import LineString, Polygon
from shapely.geometry.polygon import orient
from shapely.ops import linemerge, polygonize, unary_union

from geo_extensions.checks import polygon_crosses_antimeridian
from geo_extensions.types import Transformation, TransformationResult

Point = Tuple[float, float]
Bbox = List[Point]

ANTIMERIDIAN = LineString([(180, 90), (180, -90)])


def split_polygon_on_antimeridian(polygon: Polygon) -> List[Polygon]:
def simplify_polygon(tolerance: float, preserve_topology: bool = True) -> Transformation:
"""Create a transformation that calls polygon.simplify.
:returns: a callable transformation using the passed parameters
"""
def simplify(polygon: Polygon) -> TransformationResult:
"""Perform a shapely simplify operation on the polygon."""
yield polygon.simplify(tolerance, preserve_topology)

return simplify


def reverse_polygon(polygon: Polygon) -> TransformationResult:
"""Perform a shapely reverse operation on the polygon."""
yield polygon.reverse()


def split_polygon_on_antimeridian(polygon: Polygon) -> Generator[Polygon, None, None]:
"""Perform adjustment when the polygon crosses the antimeridian.
CMR requires the polygon to be split into two separate polygons to avoid it
Expand All @@ -60,48 +80,18 @@ def split_polygon_on_antimeridian(polygon: Polygon) -> List[Polygon]:
following conditions:
- Points must be in counter clockwise winding order
- Polygon must not cover more than half of the earth
:returns: a generator yielding the split polygons
"""

if not polygon_crosses_antimeridian(polygon):
return [polygon]
yield polygon
return

shifted_polygon = _shift_polygon(polygon)
new_polygons = _split_polygon(shifted_polygon, ANTIMERIDIAN)

return [
_shift_polygon_back(polygon)
for polygon in new_polygons
]


def polygon_crosses_antimeridian(polygon: Polygon) -> bool:
"""Checks if the longitude coordinates 'wrap around' the 180/-180 line.
The polygon must be oriented in counter-clockwise order.
"""

# Polygons crossing the antimeridian will appear to be mis-ordered
return not polygon.exterior.is_ccw


def fixed_size_polygon_crosses_antimeridian(
polygon: Polygon,
min_lon_extent: float,
) -> bool:
"""Checks if the longitude coordinates 'wrap around' the 180/-180 line
based on a heuristic that assumes the polygon is of a certain size.
:param polygon: the polygon check.
:param min_lon_extent: the lower bound for the distance between the
longitude values of the bounding box enclosing the entire polygon.
Must be between (0, 180) exclusive.
"""
assert 0 < min_lon_extent < 180

min_lon, _, max_lon, _ = polygon.bounds
dist_from_180 = 180 - min_lon_extent

return max_lon > dist_from_180 or min_lon < -dist_from_180
for polygon in new_polygons:
yield _shift_polygon_back(polygon)


def _shift_polygon(polygon: Polygon) -> Polygon:
Expand Down
72 changes: 72 additions & 0 deletions geo_extensions/transformer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from typing import Iterable, List, Sequence, Tuple

from shapely import Geometry, wkt
from shapely.geometry import MultiPolygon, Polygon

from geo_extensions.types import Transformation, TransformationResult


class Transformer:
"""Apply a sequence of transformations to a polygon list."""

def __init__(self, transformations: Sequence[Transformation]):
self.transformations = transformations

def from_wkt(self, wkt_str: str) -> List[Polygon]:
"""Load and transform an object from a WKT string.
:returns: a list of transformed polygons
:raises: ShapelyError, Exception
"""

obj = wkt.loads(wkt_str)
polygons = to_polygons(obj)

return self.transform(polygons)

def transform(self, polygons: Iterable[Polygon]) -> List[Polygon]:
"""Perform the transformation chain on a sequence of polygons.
:returns: a list of transformed polygons
"""

return list(
_apply_transformations(
polygons,
tuple(self.transformations),
)
)


def to_polygons(obj: Geometry) -> TransformationResult:
"""Convert a geometry to a sequence of polygons.
:returns: a generator yielding the polygon sequence.
:raises: Exception
"""
if isinstance(obj, MultiPolygon):
for poly in obj.geoms:
yield poly
return

if isinstance(obj, Polygon):
yield obj
return

raise Exception(f"WKT: '{obj}' is not a Polygon or MultiPolygon")


def _apply_transformations(
polygons: Iterable[Polygon],
transformations: Tuple[Transformation, ...],
) -> TransformationResult:
if not transformations:
yield from polygons
return

transformation, transformations = transformations[0], transformations[1:]
for polygon in polygons:
yield from _apply_transformations(
transformation(polygon),
transformations,
)
6 changes: 6 additions & 0 deletions geo_extensions/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from typing import Callable, Generator

from shapely.geometry import Polygon

TransformationResult = Generator[Polygon, None, None]
Transformation = Callable[[Polygon], TransformationResult]
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from pathlib import Path

import pytest
from shapely.geometry import Polygon


@pytest.fixture(scope="session")
def data_path():
return Path(__file__).parent / "data"


@pytest.fixture
def rectangle():
"""A rectanglular polygon"""
polygon = Polygon([
(160., 60.), (170., 60.),
(170., 70.), (160., 70.), (160., 60.),
])
assert polygon.exterior.is_ccw

return polygon


@pytest.fixture
def centered_rectangle():
"""A rectanglular polygon centered at 0, 0"""
polygon = Polygon([
(-30., 10.), (-30., -10.),
(30., 10.), (30., -10.), (-30., 10.),
])
assert polygon.exterior.is_ccw

return polygon


@pytest.fixture
def antimeridian_centered_rectangle():
"""A rectanglular polygon centered over the antimeridian"""
polygon = Polygon([
(150., 10.), (150., -10.),
(-150., -10.), (-150., 10.), (150., 10.),
])
assert not polygon.exterior.is_ccw

return polygon
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
POLYGON ((-64.18242781701073 80.92318071697005, -64.39970970298715 80.9454570771648, -64.61612029680748 80.96740982711674, -64.82622920447335 80.98850229027714, -65.00748150944156 81.00652638894496, -65.22501282807744 81.02794426023328, -65.44169904250701 81.04905266333425, -65.65757823927397 81.06986003091683, -65.87268623449013 81.09037439986633, -66.08705749062148 81.11060324832292, -66.30072405980573 81.13055382372104, -66.54829004319721 81.1534050786742, -66.74847878940515 81.17168274713765, -66.9682347986952 81.19153758767904, -67.1873024177517 81.21111583503362, -67.40424480895076 81.23029529806435, -67.6140176947673 81.24864531978461, -67.77904468607704 81.26295037283721, -67.98805577215917 81.28089510750608, -68.19658874371407 81.29861264412553, -68.40445755029818 81.31609026531473, -68.93161319601728 81.16999707795055, -68.75880659254568 81.15552921084696, -68.56143568631921 81.13885228644183, -68.34755266640347 81.12059367126959, -68.1330656904868 81.10208558733731, -67.9342625866557 81.08475101083606, -67.72625351642775 81.06642916394016, -67.52646242103165 81.04865047626856, -67.31399914334948 81.02955002151907, -67.10086164611913 81.01018509417794, -66.85147397179965 80.98726773213718, -66.64005009157752 80.96761326971924, -66.42790019583806 80.94768347454357, -66.21499149127725 80.92747133311453, -66.00129003603924 80.90696928004046, -65.78675973774801 80.88616939816542, -65.5713624245361 80.86506333039286, -65.35505768449538 80.84364224556352, -65.17458029235257 80.82559142573987, -64.96085433090136 80.80401042325435, -64.74688665108201 80.78217738556877, -64.18242781701073 80.92318071697005))
Loading

0 comments on commit 1efd679

Please sign in to comment.