"""Polygons class for storing desired and undesired regions."""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Literal, Sequence
import geopandas as gpd
import pandas as pd
from pyproj.crs import CRS
from rasterio.errors import CRSError
if TYPE_CHECKING:
from pathlib import Path
from matplotlib.axes import Axes
from faninsar.typing import CrsLike
from .bbox import BoundingBox
[docs]
class Polygons:
"""Polygons object is used to store the desired and undesired regions.
desired regions will be retrieved from the dataset, while undesired regions
will be removed from the dataset.
.. tip::
When a mixed-types Polygons, where both "desired" and "undesired"
polygons, are provided:
* the "undesired" polygons will only be useful when there are overlapping
regions with the "desired" polygons. Otherwise, the "desired" polygons
are enough.
* you can use :meth:`to_desired` to get the desired polygons from a
mixed-types Polygons object.
"""
[docs]
def __init__(
self,
gdf: gpd.GeoDataFrame | gpd.GeoSeries,
types: (
Literal["desired", "undesired"] | Sequence[Literal["desired", "undesired"]]
) = "desired",
crs: CrsLike = None,
all_touched: bool = True,
pad: bool = False,
) -> None:
"""Initialize a Polygons object.
Parameters
----------
gdf : GeoDataFrame | GeoSeries
The GeoDataFrame or GeoSeries containing the "desired" or "undesired"
regions/polygons.
types : 'desired' | 'undesired' | Sequence['desired', 'undesired']
The types of polygons. If 'desired', the polygons are the desired
regions. If 'undesired', the polygons are the regions to be removed.
Default is 'desired'.
crs : Any, optional
The CRS of the polygons. Can be any object that can be passed to
:meth:`pyproj.crs.CRS.from_user_input` .
If None, the CRS of the input geometry will be used. Default is None.
all_touched : bool, optional
Whether to include all pixels touched by the polygon. Default is True.
pad : bool, optional
If True, the features will be padded in each direction by
one half of a pixel prior to cropping raster. Defaults to False.
"""
self._gdf = self._format_geometry(gdf, types, crs).sort_values(
by="types",
ascending=True,
)
self.pad = pad
self.all_touched = all_touched
def __str__(self) -> str:
"""Return a string representation of the Polygons object."""
return f"Polygons(count={len(self)}, crs='{self.crs}')"
def __repr__(self) -> str:
"""Return a string representation of the Polygons object."""
prefix = "Polygons:\n"
middle = self._gdf.__repr__()
suffix = f"\n[count={len(self)}, crs='{self.crs}']"
return f"{prefix}{middle}{suffix}"
def __len__(self) -> int:
"""Return the number of polygons."""
return len(self._gdf)
def __add__(self, other: Polygons) -> Polygons:
"""Add two Polygons objects."""
if not isinstance(other, Polygons):
msg = f"other must be an instance of Polygon. Got {type(other)}"
raise TypeError(msg)
if self.crs != other.crs:
if self.crs is None or other.crs is None:
warnings.warn(
"CRS is found lacking, adding polygons without CRS.",
stacklevel=2,
)
else:
other = other.to_crs(self.crs)
gdf = pd.concat([self.frame, other.frame], ignore_index=True)
return Polygons(gdf, types=gdf.types)
def _format_geometry(
self,
gdf: gpd.GeoDataFrame | gpd.GeoSeries | Polygons,
types: (
Literal["desired", "undesired"] | Sequence[Literal["desired", "undesired"]]
),
crs: CrsLike,
) -> gpd.GeoDataFrame:
"""Format the geometry column of the GeoDataFrame."""
if isinstance(gdf, gpd.GeoDataFrame):
_df = gpd.GeoDataFrame(gdf.geometry)
_df["types"] = types
elif isinstance(gdf, gpd.GeoSeries):
_df = gpd.GeoDataFrame(gdf)
_df["types"] = types
elif isinstance(gdf, Polygons):
_df = gdf.frame
else:
msg = f"gdf must be an instance of GeoDataFrame, GeoSeries. Got {type(gdf)}"
raise TypeError(msg)
return self._ensure_gdf_crs(_df, crs)
def _ensure_gdf_crs(self, gdf: gpd.GeoDataFrame, crs: CrsLike) -> CRS:
"""Ensure the CRS of the GeoDataFrame."""
if crs is None and gdf.crs is None:
warnings.warn(
"CRS is not found both in input geometries and parameters."
" Set to None.",
stacklevel=2,
)
else:
if crs is None:
return gdf
if not isinstance(crs, CRS):
crs = CRS.from_user_input(crs)
if gdf.crs is None:
gdf = gdf.set_crs(crs)
elif gdf.crs != crs:
gdf = gdf.to_crs(crs)
return gdf
@property
def all_touched(self) -> bool:
"""Whether to include all pixels touched by the polygon."""
return self._all_touched
@all_touched.setter
def all_touched(self, value: bool) -> None:
if not isinstance(value, bool):
msg = f"all_touched must be a bool. Got {type(value)}"
raise TypeError(msg)
self._all_touched = value
@property
def pad(self) -> bool:
"""Whether to pad the features in each direction by one half of a pixel.
This is used prior to cropping raster. Defaults to False.
"""
return self._pad
@pad.setter
def pad(self, value: bool) -> None:
if not isinstance(value, bool):
msg = f"pad must be a bool. Got {type(value)}"
raise TypeError(msg)
self._pad = value
@property
def geometry(self) -> gpd.GeoSeries:
"""The geometry column of the polygons."""
return self._gdf.geometry
@property
def types(self) -> pd.Series:
"""The types of polygons."""
return self._gdf["types"]
@property
def frame(self) -> gpd.GeoDataFrame:
"""GeoDataFrame format of polygons."""
return self._gdf
@property
def desired(self) -> Polygons:
"""Desired part of polygons."""
return Polygons(self._gdf[self.types == "desired"], types="desired")
@property
def undesired(self) -> Polygons:
"""Undesired part of polygons."""
return Polygons(self._gdf[self.types == "undesired"], types="undesired")
@property
def is_mixed(self) -> bool:
"""Whether the polygons contain both desired and undesired polygons."""
return len(self.desired) > 0 and len(self.undesired) > 0
[docs]
def to_desired(self) -> Polygons:
"""Return a desired polygons, with regions of undesired polygons being removed.
.. Warning::
This method should only be used when the Polygons object contains both
"desired" and "undesired" polygons. If the Polygons object only contains
"undesired" polygons, the returned Polygons object will be empty.
"""
_df = gpd.overlay(self.desired.frame, self.undesired.frame, how="difference")
return Polygons(_df, types="desired")
[docs]
def to_bbox(self) -> list[BoundingBox]:
"""Return a list of BoundingBox representing bounding boxes of polygons.
.. Warning::
This method will only return the bounding boxes of the desired polygons.
If the Polygons object only contains "undesired" polygons, the returned
list will be empty.
"""
return self.to_desired().frame
[docs]
def to_GeoDataFrame(self) -> gpd.GeoDataFrame:
"""Return a GeoDataFrame of the polygons.
This method is an alias of :attr:`frame` for API consistency with
:class:`~faninsar.query.Points` and :class:`BoundingBox`.
"""
return self.frame
@property
def crs(self) -> CRS:
"""The CRS of the polygons."""
return self._gdf.crs
[docs]
def to_crs(self, crs: CrsLike) -> Polygons:
"""Return a new Polygons object with new CRS.
Parameters
----------
crs : Any
The new CRS. Can be any object that can be passed to
:meth:`pyproj.crs.CRS.from_user_input`.
Returns
-------
Polygons
The new Polygons object.
"""
if not isinstance(crs, CRS):
crs = CRS.from_user_input(crs)
if self.crs == crs:
return self
gdf = self._gdf.to_crs(crs)
return Polygons(gdf, types=self.types)
[docs]
def set_crs(
self,
crs: CrsLike,
allow_override: bool = False,
) -> None:
"""Set the CRS of polygons.
.. warning::
This method will only set the crs attribute without converting the
geometries to a new coordinate reference system. If you want to convert
the geometries to a new coordinate, please use :meth:`to_crs`
Parameters
----------
crs : Any
The new CRS. Can be any object that can be passed to
:meth:`pyproj.crs.CRS.from_user_input`.
allow_override : bool, optional
Whether to allow overriding the existing CRS. If False, a CRSError
will be raised if the CRS has already been set. Default is False.
Raises
------
CRSError
If the CRS has already been set and allow_override is False.
"""
if not isinstance(crs, CRS):
crs = CRS.from_user_input(crs)
if self.crs != crs:
if self.crs is None or allow_override:
self._gdf.set_crs(crs, allow_override=True)
else:
msg = (
"The CRS has already been set. Set allow_override=True to override."
)
raise CRSError(msg)
[docs]
@classmethod
def from_file(
cls,
filename: str | Path,
types: (
Literal["desired", "undesired"] | Sequence[Literal["desired", "undesired"]]
) = "desired",
crs: CrsLike = None,
**kwargs,
) -> Polygons:
"""Initialize a Polygon object from a shapefile.
Parameters
----------
filename : str | Path
The path to the shapefile. file type can be any type that can be
passed to :func:`geopandas.read_file`.
types : 'desired' | 'undesired' | Sequence['desired', 'undesired'], optional
The types of polygons. If 'desired', the polygons are the desired
polygons. If 'undesired', the polygons are the polygons to be removed.
Default is 'desired'.
crs : Any, optional
The CRS of the polygons. Can be any object that can be passed to
:meth:`pyproj.crs.CRS.from_user_input`.
If None, the CRS of the input geometries will be used. Default is None.
**kwargs : dict
Other parameters passed to :func:`geopandas.read_file`.
Returns
-------
Polygons
The Polygons object.
"""
kwargs.update({"ignore_geometry": False})
gdf = gpd.read_file(filename, **kwargs)
return cls(gdf, types=types, crs=crs)
[docs]
def copy(self) -> Polygons:
"""Return a copy of the Polygons object."""
return Polygons(self._gdf.copy(), types=self.types)
[docs]
def plot(self, **kwargs) -> Axes:
"""Plot the polygons on a map.
Parameters
----------
**kwargs : dict
Other parameters passed to :meth:`geopandas.GeoDataFrame.plot`.
Returns
-------
Axes
The matplotlib axes.
"""
kwargs.update({"column": "types", "kind": "geo"})
kwargs.setdefault("legend", True)
cmap = "RdYlGn" if not self.is_mixed and len(self.undesired) > 0 else "RdYlGn_r"
kwargs.update(
{
"legend_kwds": {
"loc": "center left",
"bbox_to_anchor": (1.01, 0.5),
},
"cmap": cmap,
},
)
return self.frame.plot(**kwargs)