"""A class used for indexing datasets using a spatial bounding box."""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, overload
import geopandas as gpd
from rasterio.crs import CRS
from rasterio.warp import transform_bounds
from shapely.geometry import box
if TYPE_CHECKING:
from collections.abc import Iterator
[docs]
class BoundingBox:
"""a class used for indexing datasets using a spatial bounding box.
.. note::
This class is a modified version of the BoundingBox class from the
torchgeo package. The main modifications include:
- Removal of ``date bounds``
- Change of the bounding box to (left, bottom, right, top), which aligns
with the :class:`rasterio.coords.BoundingBox` class.
- Addition of the CRS attribute to automatically convert the bounding
box to the CRS of the dataset.
"""
[docs]
def __init__(
self,
left: float,
bottom: float,
right: float,
top: float,
crs: CRS | str | None = None,
) -> None:
"""Initialize a BoundingBox.
Parameters
----------
left : float
The western boundary.
bottom : float
The southern boundary.
right : float
The eastern boundary.
top : float
The northern boundary.
crs : CRS | str | None, optional
The coordinate reference system of the bounding box. Can be any object
that can be passed to :meth:`pyproj.crs.CRS.from_user_input`.
Default is None.
"""
if left > right:
msg = f"Bounding box is invalid: 'left={left}' > 'right={right}'"
raise ValueError(msg)
if bottom > top:
msg = f"Bounding box is invalid: 'bottom={bottom}' > 'top={top}'"
raise ValueError(msg)
self.left = left
self.right = right
self.bottom = bottom
self.top = top
self._crs = crs
def __str__(self) -> str:
"""Return a string representation of the bounding box."""
return (
f"BoundingBox(left={self.left}, bottom={self.bottom}, "
f"right={self.right}, top={self.top}, crs={self.crs})"
)
def __repr__(self) -> str:
"""Return a string representation of the bounding box."""
return self.__str__()
# https://github.com/PyCQA/pydocstyle/issues/525
@overload
def __getitem__(self, key: int) -> float:
pass
@overload
def __getitem__(self, key: slice) -> list[float]:
pass
def __getitem__(self, key: int | slice) -> float | list[float]:
"""Index the (left, bottom, right, top) tuple.
Args:
----
key: integer or slice object
Returns:
-------
the value(s) at that index
Raises:
------
IndexError: if key is out of bounds
"""
return [self.left, self.bottom, self.right, self.top][key]
def __iter__(self) -> Iterator[float]:
"""Container iterator.
Returns
-------
iterator object that iterates over all objects in the container
"""
yield from [self.left, self.bottom, self.right, self.top]
def __contains__(self, other: BoundingBox) -> bool:
"""Whether or not other is within the bounds of this bounding box.
Args:
----
other: another bounding box
Returns:
-------
True if other is within this bounding box, else False
"""
return (
(self.left <= other.left <= self.right)
and (self.left <= other.right <= self.right)
and (self.bottom <= other.bottom <= self.top)
and (self.bottom <= other.top <= self.top)
)
def __or__(self, other: BoundingBox) -> BoundingBox:
"""Union operator.
Parameters
----------
other: BoundingBox
another bounding box
Returns
-------
BoundingBox:
the minimum bounding box that contains both self and other
"""
other, crs_new = self._ensure_points_crs(other)
return BoundingBox(
min(self.left, other.left),
max(self.right, other.right),
min(self.bottom, other.bottom),
max(self.top, other.top),
crs=crs_new,
)
def __and__(self, other: BoundingBox) -> BoundingBox:
"""Intersection operator.
Parameters
----------
other: BoundingBox
another bounding box
Returns
-------
BoundingBox:
the intersection of self and other
Raises
------
ValueError:
if self and other do not intersect
"""
try:
other, crs_new = self._ensure_points_crs(other)
return BoundingBox(
max(self.left, other.left),
min(self.right, other.right),
max(self.bottom, other.bottom),
min(self.top, other.top),
crs=crs_new,
)
except ValueError as e:
msg = f"Bounding boxes {self} and {other} do not overlap"
raise ValueError(msg) from e
def _ensure_points_crs(self, other: BoundingBox) -> tuple[BoundingBox, CRS]:
"""Ensure the coordinate reference system of the bbox are the same."""
if self.crs != other.crs:
if self.crs is None or other.crs is None:
crs_new = self.crs or other.crs
warnings.warn(
"Cannot find the coordinate reference system of the bbox. "
"The crs of two bbox will assume to be the same. ",
stacklevel=2,
)
else:
other = other.to_crs(self.crs)
crs_new = self.crs
else:
crs_new = self.crs
return other, crs_new
@property
def area(self) -> float:
"""Area of bounding box.
Area is defined as spatial area.
Returns
-------
area
"""
return (self.right - self.left) * (self.top - self.bottom)
@property
def crs(self) -> CRS | None:
"""The coordinate reference system of the bounding box."""
return self._crs
[docs]
def to_crs(self, crs: CRS | str) -> BoundingBox:
"""Convert the bounding box to a new coordinate reference system.
Parameters
----------
crs : CRS | str
The new coordinate reference system. Can be any object that can be
passed to :meth:`pyproj.crs.CRS.from_user_input`.
"""
if self.crs is None:
msg = (
"The current coordinate reference system is None. "
"Please set the crs using set_crs() first."
)
raise ValueError(msg)
crs = CRS.from_user_input(crs)
if self.crs == crs:
return self
left, bottom, right, top = transform_bounds(
self.crs,
crs,
self.left,
self.bottom,
self.right,
self.top,
)
return BoundingBox(left, bottom, right, top, crs=crs)
[docs]
def set_crs(self, crs: CRS | str) -> None:
"""Set the coordinate reference system of the bounding box.
Parameters
----------
crs : CRS | str
The new coordinate reference system. Can be any object that can be
passed to :meth:`pyproj.crs.CRS.from_user_input`.
.. warning::
This method will only set the crs attribute without converting
the bounding box to a new coordinate reference system. If you
want to convert the bounding box values to a new coordinate,
please use :meth:`to_crs`
"""
self._crs = CRS.from_user_input(crs)
[docs]
def to_dict(self) -> dict[str, float]:
"""Convert the bounding box to a dictionary.
Returns
-------
dictionary with keys 'left', 'bottom', 'right', 'top'
"""
return {
"left": self.left,
"bottom": self.bottom,
"right": self.right,
"top": self.top,
}
[docs]
def to_GeoDataFrame(self) -> gpd.GeoDataFrame:
"""Convert the bounding box to a GeoDataFrame.
Returns
-------
GeoDataFrame with the bounding box as a polygon
"""
gdf = gpd.GeoDataFrame(
geometry=[box(self.left, self.bottom, self.right, self.top)],
)
gdf.crs = self.crs
return gdf
[docs]
def intersects(self, other: BoundingBox) -> bool:
"""Whether or not two bounding boxes intersect.
Parameters
----------
other: BoundingBox
another bounding box
Returns
-------
True if bounding boxes intersect, else False
"""
return (
self.left <= other.right
and self.right >= other.left
and self.bottom <= other.top
and self.top >= other.bottom
)
[docs]
def split(
self,
proportion: float,
horizontal: bool = True,
) -> tuple[BoundingBox, BoundingBox]:
"""Split BoundingBox in two.
Parameters
----------
proportion: float
split proportion in range (0,1)
horizontal: bool
whether the split is horizontal or vertical
Returns
-------
A tuple with the resulting BoundingBoxes
"""
if not (0.0 < proportion < 1.0):
msg = "Input proportion must be between 0 and 1."
raise ValueError(msg)
if horizontal:
w = self.right - self.left
splitx = self.left + w * proportion
bbox1 = BoundingBox(self.left, splitx, self.bottom, self.top)
bbox2 = BoundingBox(splitx, self.right, self.bottom, self.top)
else:
h = self.top - self.bottom
splity = self.bottom + h * proportion
bbox1 = BoundingBox(self.left, self.right, self.bottom, splity)
bbox2 = BoundingBox(self.left, self.right, splity, self.top)
return bbox1, bbox2
[docs]
def buffer(self, distance: float) -> BoundingBox:
"""Buffer the bounding box.
Parameters
----------
distance: float
the buffer distance in the units of the bounding box
Returns
-------
the buffered bounding box
"""
return BoundingBox(
self.left - distance,
self.bottom - distance,
self.right + distance,
self.top + distance,
crs=self.crs,
)