Skip to content

Commit 59decc8

Browse files
moved antimeridian crossing logic to BBoxDict #883
1 parent 5472b2a commit 59decc8

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

openeo/util.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -534,10 +534,6 @@ def __init__(self, *, west: float, south: float, east: float, north: float, crs:
534534

535535
# TODO: provide west, south, east, north, crs as @properties? Read-only or read-write?
536536

537-
def as_polygon(self) -> shapely.geometry.Polygon:
538-
"""Convert to a :class:`shapely.geometry.Polygon` representing the bounding box."""
539-
return shapely.geometry.box(minx=self["west"], miny=self["south"], maxx=self["east"], maxy=self["north"])
540-
541537
@classmethod
542538
def from_any(cls, x: Any, *, crs: Optional[str] = None) -> BBoxDict:
543539
if isinstance(x, dict):
@@ -573,6 +569,56 @@ def from_sequence(cls, seq: Union[list, tuple], crs: Optional[str] = None) -> BB
573569
raise InvalidBBoxException(f"Expected sequence with 4 items, but got {len(seq)}.")
574570
return cls(west=seq[0], south=seq[1], east=seq[2], north=seq[3], crs=crs)
575571

572+
@staticmethod
573+
def normalize_west_east_longitude(west: float, east: float) -> Tuple[float, float]:
574+
"""
575+
Assuming longitude in degrees (e.g. EPSG:4326):
576+
normalize west to range [-180, 180) and east to range (-180, 180]
577+
which is useful in bounding box contexts.
578+
"""
579+
west = ((west + 180) % 360) - 180
580+
east = 180 - ((180 - east) % 360)
581+
return west, east
582+
583+
@staticmethod
584+
def _crs_with_cyclic_x(crs: Union[None, str]) -> bool:
585+
"""
586+
Whether the x coordinate is cyclic (e.g. longitude in EPSG:4326)
587+
which requires some special handling like coordinate normalization and antimeridian crossing handling.
588+
"""
589+
return normalize_crs(crs) == 4326
590+
591+
def as_polygon(self) -> shapely.geometry.Polygon:
592+
"""
593+
Get bounding box as a shapely Polygon.
594+
Simple single polygon, but not ideal for proper handling of antimeridian crossing in EPSG:4326,
595+
which require to split the geometry in two parts: use `as_geometry` instead for that.
596+
"""
597+
west, east = self["west"], self["east"]
598+
if self._crs_with_cyclic_x(self.get("crs")):
599+
west, east = self.normalize_west_east_longitude(west=west, east=east)
600+
if east < west:
601+
# TODO: this assumes "cyclic" implies longitude in degrees (EPSG:4326)
602+
east += 360
603+
604+
return shapely.geometry.box(minx=west, miny=self["south"], maxx=east, maxy=self["north"])
605+
606+
def as_geometry(self) -> Union[shapely.geometry.Polygon, shapely.geometry.MultiPolygon]:
607+
"""Get bounding box as a shapely geometry (Polygon or MultiPolygon when crossing antimeridian)"""
608+
west, east = self["west"], self["east"]
609+
if self._crs_with_cyclic_x(self.get("crs")):
610+
west, east = self.normalize_west_east_longitude(west=west, east=east)
611+
if east < west:
612+
# TODO: this assumes "cyclic" implies longitude in degrees (EPSG:4326), and split is at +/-180
613+
return shapely.geometry.MultiPolygon(
614+
[
615+
shapely.geometry.box(west, self["south"], 180, self["north"]),
616+
shapely.geometry.box(-180, self["south"], east, self["north"]),
617+
]
618+
)
619+
620+
return shapely.geometry.box(minx=west, miny=self["south"], maxx=east, maxy=self["north"])
621+
576622

577623
def to_bbox_dict(x: Any, *, crs: Optional[Union[str, int]] = None) -> BBoxDict:
578624
"""

tests/test_util.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,45 @@ def test_to_bbox_dict_from_geometry(self):
798798
geometry = shapely.geometry.Polygon([(4, 2), (7, 4), (5, 8), (3, 3), (4, 2)])
799799
assert to_bbox_dict(geometry) == {"west": 3, "south": 2, "east": 7, "north": 8}
800800

801+
def test_as_polygon(self):
802+
bbox = BBoxDict(west=1, south=2, east=3, north=4)
803+
polygon = bbox.as_polygon()
804+
assert isinstance(polygon, shapely.geometry.Polygon)
805+
assert shapely.geometry.mapping(polygon) == {
806+
"type": "Polygon",
807+
"coordinates": (((3, 2), (3, 4), (1, 4), (1, 2), (3, 2)),),
808+
}
809+
810+
def test_as_polygon_across_anti_meridian(self):
811+
bbox = BBoxDict(west=170, south=50, east=-170, north=51, crs=4326)
812+
polygon = bbox.as_polygon()
813+
assert isinstance(polygon, shapely.geometry.Polygon)
814+
assert shapely.geometry.mapping(polygon) == {
815+
"type": "Polygon",
816+
"coordinates": (((190, 50), (190, 51), (170, 51), (170, 50), (190, 50)),),
817+
}
818+
819+
def test_as_geometry_basic(self):
820+
bbox = BBoxDict(west=1, south=2, east=3, north=4)
821+
polygon = bbox.as_geometry()
822+
assert isinstance(polygon, shapely.geometry.Polygon)
823+
assert shapely.geometry.mapping(polygon) == {
824+
"type": "Polygon",
825+
"coordinates": (((3, 2), (3, 4), (1, 4), (1, 2), (3, 2)),),
826+
}
827+
828+
def test_as_geometry_across_anti_meridian(self):
829+
bbox = BBoxDict(west=170, south=50, east=-170, north=51, crs=4326)
830+
polygon = bbox.as_geometry()
831+
assert isinstance(polygon, shapely.geometry.MultiPolygon)
832+
assert shapely.geometry.mapping(polygon) == {
833+
"type": "MultiPolygon",
834+
"coordinates": [
835+
(((180, 50), (180, 51), (170, 51), (170, 50), (180, 50)),),
836+
(((-170, 50), (-170, 51), (-180, 51), (-180, 50), (-170, 50)),),
837+
],
838+
}
839+
801840

802841
def test_url_join():
803842
assert url_join("http://d.test", "foo/bar") == "http://d.test/foo/bar"

0 commit comments

Comments
 (0)