"""Implement BoundingBox class to carry CRS and location information."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Dict, List, Union
from geopandas import GeoDataFrame
from pydantic import BaseModel, Field, field_validator
from pyproj import CRS, Transformer
from shapely.geometry import Polygon, box
from shapely.ops import unary_union
from typeguard import typechecked
from xarray import DataArray, Dataset
from spectral_indices.roi.utils import read_raster_file, read_shape_file
[docs]
class BoundingBox:
"""Initialize the bounding box with coordinates and a CRS.
Args:
bbox (Union[List[float], Tuple[float, ...]]): Xmin, Ymin, Xmax, Ymax.
crs (Union[str, CRS]): Coordinate reference system.
"""
def __init__(self, bbox, crs):
self.bbox = bbox
self.crs = crs if isinstance(crs, CRS) else CRS.from_user_input(crs)
@field_validator("crs")
def validate_crs(cls, value):
val = value if isinstance(value, CRS) else CRS.from_user_input(value)
return val
@property
def polygon(self) -> Polygon:
return box(*self.bbox)
@property
def __geo_interface__(self) -> Dict[str, Union[str, list]]:
"""Return the coordinates of the bbox for the GeoJSON interface.
The coordinates will be returned in the current CRS.
Returns:
dict: GeoJSON representation of the bbox.
"""
return {
"type": "Polygon",
"coordinates": [
[
[self.bbox[0], self.bbox[1]], # Southwest corner
[self.bbox[2], self.bbox[1]], # Southeast corner
[self.bbox[2], self.bbox[3]], # Northeast corner
[self.bbox[0], self.bbox[3]], # Northwest corner
[
self.bbox[0],
self.bbox[1],
], # Back to southwest corner
]
],
}
[docs]
def to_geodataframe(self) -> GeoDataFrame:
"""Convert the bbox to a GeoDataFrame with CRS.
Returns:
gpd.GeoDataFrame: GeoDataFrame containing the bbox.
"""
return GeoDataFrame({"geometry": [self.polygon]}, crs=self.crs.to_string())
@classmethod
@typechecked
def union(cls, boxes: List[BoundingBox]) -> BoundingBox:
crs = set([b.crs for b in boxes])
if len(crs) > 1:
raise ValueError(f"All bounding boxes should have the same CRS, got {crs}")
crs = list(crs)[0]
polygons = [bbox.polygon for bbox in boxes]
total_bounds = unary_union(polygons).bounds
return BoundingBox(bbox=list(total_bounds), crs=crs)
[docs]
@classmethod
@typechecked
def from_geodataframe(
cls, shape_data: Union[GeoDataFrame, Union[str, Path]]
) -> BoundingBox:
"""Create BoundingBox from shape file or geoDataFrame.
Args:
shape_data (``Union[gpd.GeoDataFrame, Union[str, Path]]``): Path to shape file or geoDataFrame.
Raises:
ValueError: CRS is null.
Returns:
``BoundingBox``:
- Shape corresponding BoundingBox.
"""
if isinstance(shape_data, (str, Path)):
shape_data = read_shape_file(shape_data)
if shape_data.crs is None:
raise Exception("The GeoDataFrame must have a defined CRS.")
# Calculate the bounding box
bounds = shape_data.total_bounds # (xmin, ymin, xmax, ymax)
crs = shape_data.crs
bbox = (bounds[0], bounds[1], bounds[2], bounds[3])
return cls(bbox=bbox, crs=crs)
[docs]
@classmethod
@typechecked
def from_raster(
cls, raster_data: Union[Union[str, Path], Union[Dataset, DataArray]]
) -> BoundingBox:
"""Create an instance of BoundingBox from a raster file or rio xarray.
Args:
raster_data (``Union[Union[str, Path], Union[Dataset, DataArray]]``): Path to raster file or xarray with rio attr.
Returns:
``BoundingBox``:
- Raster corresponding BoundingBox
"""
if isinstance(raster_data, (str, Path)):
raster_data = read_raster_file(raster_data)
# Get the raster coordinates
bounds = raster_data.rio.bounds()
crs = raster_data.rio.crs.to_string() # Get the CRS of the raster
bbox = (bounds[0], bounds[1], bounds[2], bounds[3])
return cls(bbox, crs)
def __repr__(self) -> str:
return f"BoundingBox: {self.bbox}, CRS: {self.crs.to_string()}"