Skip to content

Commit a71b946

Browse files
committed
BoundingBox: add cyclic_antimeridian_split
and improve west-east longitude normalization related to Open-EO/openeo-geopyspark-driver#1568
1 parent 74feaac commit a71b946

3 files changed

Lines changed: 110 additions & 17 deletions

File tree

openeo_driver/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.139.0a2"
1+
__version__ = "0.139.0a3"

openeo_driver/util/geometry.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,17 @@ class CrsRequired(BoundingBoxException):
427427
pass
428428

429429

430+
def normalize_west_east_longitude(west: float, east: float) -> Tuple[float, float]:
431+
"""
432+
Assuming longitude in degrees (e.g. EPSG:4326):
433+
normalize west to range [-180, 180) and east to range (-180, 180]
434+
which is useful in bounding box contexts.
435+
"""
436+
west = ((west + 180) % 360) - 180
437+
east = 180 - ((180 - east) % 360)
438+
return west, east
439+
440+
430441
@dataclasses.dataclass(frozen=True)
431442
class BoundingBox:
432443
"""
@@ -452,6 +463,8 @@ class BoundingBox:
452463
# TODO: using frozen dataclasss, but with custom __init__ (for CRS normalization) makes things a bit messy.
453464
# It might be just easier to implement the "frozen dataclass" behavior manually
454465

466+
# TODO: do longitude normalization (for EPSG:4326) in constructor instead of on the fly in various places?
467+
455468
west: float
456469
south: float
457470
east: float
@@ -548,20 +561,15 @@ def _crs_with_cyclic_x(crs: Union[None, str]) -> bool:
548561
"""
549562
return crs == "EPSG:4326"
550563

551-
@staticmethod
552-
def _normalize_longitude(x: float) -> float:
553-
"""Normalize an EPSG:4326 longitude coordinate to the range [-180, 180)"""
554-
# TODO: do this normalization in constructor, instead of each time on the fly?
555-
return (x + 180) % 360 - 180
556-
557564
def cyclic_antimeridian_crossing(self) -> bool:
558565
"""
559566
Whether this bounding box uses cyclic longitude coordinates
560567
and crosses the antimeridian (so that west > east).
561568
"""
562-
return self._crs_with_cyclic_x(self.crs) and (
563-
self._normalize_longitude(self.west) > self._normalize_longitude(self.east)
564-
)
569+
if self._crs_with_cyclic_x(self.crs):
570+
west, east = normalize_west_east_longitude(west=self.west, east=self.east)
571+
return west > east
572+
return False
565573

566574
def as_dict(self) -> dict:
567575
return {
@@ -586,9 +594,9 @@ def as_polygon(self) -> shapely.geometry.Polygon:
586594
"""
587595
west, east = self.west, self.east
588596
if self._crs_with_cyclic_x(self.crs):
589-
west = self._normalize_longitude(west)
590-
east = self._normalize_longitude(east)
597+
west, east = normalize_west_east_longitude(west=west, east=east)
591598
if east < west:
599+
# TODO: this assumes "cyclic" implies longitude in degrees (EPSG:4326)
592600
east += 360
593601

594602
return shapely.geometry.box(minx=west, miny=self.south, maxx=east, maxy=self.north)
@@ -597,9 +605,9 @@ def as_geometry(self) -> Union[shapely.geometry.Polygon, shapely.geometry.MultiP
597605
"""Get bounding box as a shapely geometry (Polygon or MultiPolygon when crossing antimeridian)"""
598606
west, east = self.west, self.east
599607
if self._crs_with_cyclic_x(self.crs):
600-
east = self._normalize_longitude(east)
601-
west = self._normalize_longitude(west)
608+
west, east = normalize_west_east_longitude(west=west, east=east)
602609
if east < west:
610+
# TODO: this assumes "cyclic" implies longitude in degrees (EPSG:4326), and split is at +/-180
603611
return shapely.geometry.MultiPolygon(
604612
[
605613
shapely.geometry.box(west, self.south, 180, self.north),
@@ -625,12 +633,11 @@ def as_geojson(self) -> dict:
625633
def centroid(self) -> Tuple[float, float]:
626634
if self._crs_with_cyclic_x(self.crs):
627635
# Properly handle cyclic longitude coordinates, and antimeridian crossing
628-
west = self._normalize_longitude(self.west)
629-
east = self._normalize_longitude(self.east)
636+
west, east = normalize_west_east_longitude(west=self.west, east=self.east)
630637
if west <= east:
631638
x = 0.5 * (west + east)
632639
else:
633-
x = self._normalize_longitude(0.5 * (west + east + 360))
640+
x, _ = normalize_west_east_longitude(west=0.5 * (west + east + 360), east=0)
634641
else:
635642
x = 0.5 * (self.west + self.east)
636643

@@ -792,3 +799,18 @@ def intersection(self, other: "BoundingBox") -> Union["BoundingBox", None]:
792799
if west >= east or south >= north:
793800
return None
794801
return BoundingBox(west=west, south=south, east=east, north=north, crs=self.crs)
802+
803+
def cyclic_antimeridian_split(self) -> List["BoundingBox"]:
804+
"""
805+
Split this bounding box into two bounding boxes
806+
when it uses cyclic longitude coordinates and crosses the antimeridian.
807+
Otherwise, just return this bounding box as is.
808+
"""
809+
if self.cyclic_antimeridian_crossing():
810+
# TODO: this assumes "cyclic" implies longitude in degrees (EPSG:4326), and split is at +/-180
811+
return [
812+
BoundingBox(west=self.west, south=self.south, east=180, north=self.north, crs=self.crs),
813+
BoundingBox(west=-180, south=self.south, east=self.east, north=self.north, crs=self.crs),
814+
]
815+
else:
816+
return [self]

tests/util/test_geometry.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
spatial_extent_union,
2525
validate_geojson_basic,
2626
epsg_code_or_none,
27+
normalize_west_east_longitude,
2728
)
2829

2930
from ..data import get_path
@@ -865,6 +866,10 @@ def test_centroid(self):
865866
(170, -160, -175),
866867
(170 + 360, -160 + 5 * 360, -175),
867868
(170, -170, -180),
869+
(-180, -170, -175),
870+
(-190, -180, 175),
871+
(170, 180, 175),
872+
(180, 190, -175),
868873
],
869874
)
870875
def test_centroid_epsg4326_wrap_around(self, west, east, expected):
@@ -911,6 +916,18 @@ def test_contains_generic(self):
911916
[(-167.1, 51.5), (-167.1 - 360, 51.5), (0, 51), (360, 51.5), (169.9, 52), (169.9 + 360, 52)],
912917
[(-189, 51.5), (-171, 51.5), (171, 51.5), (189, 51.5), (0, 50.9), (0, 52.1)],
913918
),
919+
(
920+
# Bounding box with west bound at -180
921+
BoundingBox(-180, 51, -170, 52, crs=4326),
922+
[(-180, 51.5), (-175, 51.5), (180, 51.5), (185, 51.5)],
923+
[(-180.5, 51.5), (179.5, 51.5)],
924+
),
925+
(
926+
# Bounding box with east bound at 180
927+
BoundingBox(170, 51, 180, 52, crs=4326),
928+
[(180, 51.5), (175, 51.5), (-180, 51.5), (-185, 51.5)],
929+
[(180.5, 51.5), (-179.5, 51.5)],
930+
),
914931
],
915932
)
916933
def test_contains_epsg4326_wrap_around(self, bbox, inside, outside):
@@ -1115,6 +1132,60 @@ def test_intersection(self, bbox1, bbox2, expected):
11151132
if bbox1.crs == bbox2.crs:
11161133
assert bbox2.intersection(bbox1) == expected
11171134

1135+
@pytest.mark.parametrize(
1136+
["bbox", "expected"],
1137+
[
1138+
(
1139+
BoundingBox(1, 2, 3, 4),
1140+
[BoundingBox(1, 2, 3, 4)],
1141+
),
1142+
(
1143+
BoundingBox(-170, 2, 170, 4, crs=4326),
1144+
[BoundingBox(-170, 2, 170, 4, crs=4326)],
1145+
),
1146+
(
1147+
BoundingBox(175, 2, -170, 4, crs=4326),
1148+
[
1149+
BoundingBox(175, 2, 180, 4, crs=4326),
1150+
BoundingBox(-180, 2, -170, 4, crs=4326),
1151+
],
1152+
),
1153+
(
1154+
BoundingBox(175, 2, 180, 4, crs=4326),
1155+
[BoundingBox(175, 2, 180, 4, crs=4326)],
1156+
),
1157+
(
1158+
BoundingBox(-180, 2, -170, 4, crs=4326),
1159+
[BoundingBox(-180, 2, -170, 4, crs=4326)],
1160+
),
1161+
],
1162+
)
1163+
def test_cyclic_antimeridian_split(self, bbox, expected):
1164+
assert bbox.cyclic_antimeridian_split() == expected
1165+
1166+
1167+
def test_normalize_west_east_longitude():
1168+
assert normalize_west_east_longitude(0, 0) == (0, 0)
1169+
assert normalize_west_east_longitude(10, 20) == (10, 20)
1170+
assert normalize_west_east_longitude(10.5, 20.5) == (10.5, 20.5)
1171+
assert normalize_west_east_longitude(190, 200) == (-170, -160)
1172+
assert normalize_west_east_longitude(190.5, 200.5) == (-169.5, -159.5)
1173+
1174+
assert normalize_west_east_longitude(-10, -20) == (-10, -20)
1175+
assert normalize_west_east_longitude(-10.5, -20.5) == (-10.5, -20.5)
1176+
assert normalize_west_east_longitude(-200, -190) == (160, 170)
1177+
assert normalize_west_east_longitude(-200.5, -190.5) == (159.5, 169.5)
1178+
1179+
assert normalize_west_east_longitude(-180, -170) == (-180, -170)
1180+
assert normalize_west_east_longitude(-190, -180) == (170, 180)
1181+
assert normalize_west_east_longitude(-180.5, -170.5) == (179.5, -170.5)
1182+
assert normalize_west_east_longitude(-190.5, -180.5) == (169.5, 179.5)
1183+
1184+
assert normalize_west_east_longitude(170, 180) == (170, 180)
1185+
assert normalize_west_east_longitude(180, 190) == (-180, -170)
1186+
assert normalize_west_east_longitude(170.5, 180.5) == (170.5, -179.5)
1187+
assert normalize_west_east_longitude(180.5, 190.5) == (-179.5, -169.5)
1188+
11181189

11191190
class TestValidateGeoJSON:
11201191
@staticmethod

0 commit comments

Comments
 (0)