Module sertit.vectors

Vectors tools

You can use this only if you have installed sertit[full] or sertit[vectors]

Expand source code
# -*- coding: utf-8 -*-
# Copyright 2021, SERTIT-ICube - France, https://sertit.unistra.fr/
# This file is part of sertit-utils project
#     https://github.com/sertit/sertit-utils
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Vectors tools

You can use this only if you have installed sertit[full] or sertit[vectors]
"""
import logging
import os
from typing import Any, Generator, Union

import numpy as np
import pandas as pd

try:
    import geopandas as gpd
    from shapely import wkt
    from shapely.geometry import MultiPolygon, Polygon, box
except ModuleNotFoundError as ex:
    raise ModuleNotFoundError(
        "Please install 'geopandas' to use the rasters package."
    ) from ex

from sertit.logs import SU_NAME

LOGGER = logging.getLogger(SU_NAME)

WGS84 = "EPSG:4326"


def corresponding_utm_projection(lon: float, lat: float) -> str:
    """
    Find the EPSG code of the UTM projection from a lon/lat in WGS84.

    ```python
    >>> corresponding_utm_projection(lon=7.8, lat=48.6)  # Strasbourg
    'EPSG:32632'
    ```

    Args:
        lon (float): Longitude (WGS84)
        lat (float): Latitude (WGS84)

    Returns:
        str: EPSG string

    """
    # EPSG code begins with 32
    # Then 6 if north, 7 if south -> (np.sign(lat) + 1) / 2 * 100 == 1 if lat > 0 (north), 0 if lat < 0 (south)
    # Then EPSG code with usual formula np.floor((180 + lon) / 6) + 1)
    epsg = int(32700 - (np.sign(lat) + 1) / 2 * 100 + np.floor((180 + lon) / 6) + 1)
    return f"EPSG:{epsg}"


def from_polygon_to_bounds(
    polygon: Union[Polygon, MultiPolygon]
) -> (float, float, float, float):
    """
    Convert a `shapely.polygon` to its bounds, sorted as `left, bottom, right, top`.

    ```python
    >>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
    >>> from_polygon_to_bounds(poly)
    (0.0, 0.0, 1.0, 1.0)
    ```

    Args:
        polygon (MultiPolygon): polygon to convert

    Returns:
        (float, float, float, float): left, bottom, right, top
    """
    left = polygon.bounds[0]  # xmin
    bottom = polygon.bounds[1]  # ymin
    right = polygon.bounds[2]  # xmax
    top = polygon.bounds[3]  # ymax

    assert left < right
    assert bottom < top

    return left, bottom, right, top


def from_bounds_to_polygon(
    left: float, bottom: float, right: float, top: float
) -> Polygon:
    """
    Convert the bounds to a `shapely.polygon`.

    ```python
    >>> poly = from_bounds_to_polygon(0.0, 0.0, 1.0, 1.0)
    >>> print(poly)
    'POLYGON ((1 0, 1 1, 0 1, 0 0, 1 0))'
    ```

    Args:
        left (float): Left coordinates
        bottom (float): Bottom coordinates
        right (float): Right coordinates
        top (float): Top coordinates

    Returns:
        Polygon: Polygon corresponding to the bounds

    """
    return box(min(left, right), min(top, bottom), max(left, right), max(top, bottom))


def get_geodf(
    geometry: Union[Polygon, list, gpd.GeoSeries], crs: str
) -> gpd.GeoDataFrame:
    """
    Get a GeoDataFrame from a geometry and a crs

    ```python
    >>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
    >>> geodf = get_geodf(poly, crs=WGS84)
    >>> print(geodf)
                                                geometry
    0  POLYGON ((0.00000 0.00000, 0.00000 1.00000, 1....
    ```

    Args:
        geometry (Union[Polygon, list]): List of Polygons, or Polygon or bounds
        crs (str): CRS of the polygon

    Returns:
        gpd.GeoDataFrame: Geometry as a geodataframe
    """
    if isinstance(geometry, list):
        if isinstance(geometry[0], Polygon):
            pass
        else:
            try:
                geometry = [from_bounds_to_polygon(*geometry)]
            except TypeError as ex:
                raise TypeError(
                    "Give the extent as 'left', 'bottom', 'right', and 'top'"
                ) from ex
    elif isinstance(geometry, Polygon):
        geometry = [geometry]
    elif isinstance(geometry, gpd.GeoSeries):
        geometry = geometry.geometry
    else:
        raise TypeError("geometry should be a list or a Polygon.")

    return gpd.GeoDataFrame(geometry=geometry, crs=crs)


def set_kml_driver() -> None:
    """
    Set KML driver for Fiona data (use it at your own risks !)

    ```python
    >>> path = "path\\to\\kml.kml"
    >>> gpd.read_file(path)
    fiona.errors.DriverError: unsupported driver: 'LIBKML'

    >>> set_kml_driver()
    >>> gpd.read_file(path)
                   Name  ...                                           geometry
    0  CC679_new_AOI2_3  ...  POLYGON Z ((45.03532 32.49765 0.00000, 46.1947...
    [1 rows x 12 columns]
    ```

    """
    drivers = gpd.io.file.fiona.drvsupport.supported_drivers
    if "LIBKML" not in drivers:
        drivers["LIBKML"] = "rw"
    if "KML" not in drivers:  # Just in case
        drivers["KML"] = "rw"


def get_aoi_wkt(aoi_path: str, as_str: bool = True) -> Union[str, Polygon]:
    """
    Get AOI formatted as a WKT from files that can be read by Fiona (like shapefiles, ...)
    or directly from a WKT file. The use of KML has been forced (use it at your own risks !).

    See: https://fiona.readthedocs.io/en/latest/fiona.html#fiona.open

    It is assessed that:

    - only **one** polygon composes the AOI (as only the first one is read)
    - it should be specified in lat/lon (WGS84) if a WKT file is provided
    ```python
    >>> path = "path\\to\\vec.geojson"  # OK with ESRI Shapefile, geojson, WKT, KML...
    >>> get_aoi_wkt(path)
    'POLYGON Z ((46.1947755465253067 32.4973553439109324 0.0000000000000000, 45.0353174370802520 32.4976496856158974
    0.0000000000000000, 45.0355748149750283 34.1139970085580018 0.0000000000000000, 46.1956059695554089
    34.1144793800670882 0.0000000000000000, 46.1947755465253067 32.4973553439109324 0.0000000000000000))'
    ```

    Args:
        aoi_path (str): Absolute or relative path to an AOI.
            Its format should be WKT or any format read by Fiona, like shapefiles.
        as_str (bool): If True, return WKT as a str, otherwise as a shapely geometry

    Returns:
        Union[str, Polygon]: AOI formatted as a WKT stored in lat/lon
    """
    if not os.path.isfile(aoi_path):
        raise FileNotFoundError(f"AOI file {aoi_path} does not exist.")

    if aoi_path.endswith(".wkt"):
        try:
            with open(aoi_path, "r") as aoi_f:
                aoi = wkt.load(aoi_f)
        except Exception as ex:
            raise ValueError("AOI WKT cannot be read") from ex
    else:
        try:
            if aoi_path.endswith(".kml"):
                set_kml_driver()

            # Open file
            aoi_file = gpd.read_file(aoi_path)

            # Check if a conversion to lon/lat is needed
            if aoi_file.crs.srs != WGS84:
                aoi_file = aoi_file.to_crs(WGS84)

            # Get envelope polygon
            geom = aoi_file["geometry"]
            if len(geom) > 1:
                LOGGER.warning(
                    "Your AOI contains several polygons. Only the first will be treated !"
                )
            polygon = geom[0].convex_hull

            # Convert to WKT
            aoi = wkt.loads(str(polygon))

        except Exception as ex:
            raise ValueError("AOI cannot be read by Fiona") from ex

    # Convert to string if needed
    if as_str:
        aoi = wkt.dumps(aoi)

    LOGGER.debug("Specified AOI in WKT: %s", aoi)
    return aoi


def get_wider_exterior(vector: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """TODO"""

    # Get the footprint max (discard small holes stored in other polygons)
    wider = vector[vector.area == np.max(vector.area)]

    # Only select the exterior of this footprint(sometimes some holes persist)
    if not wider.empty:
        poly = Polygon(list(wider.exterior.iat[0].coords))
        wider = gpd.GeoDataFrame(geometry=[poly], crs=wider.crs)

        # Resets index as we only got one polygon left which should have index 0
        wider.reset_index(inplace=True)

    return wider


def _to_polygons(val: Any) -> Polygon:
    """
    Convert to polygon (to be used in pandas) -> convert the geometry column

    Args:
        val (Any): Pandas value that has a "coordinates" field

    Returns:
        Polygon: Pandas value as a Polygon
    """
    # Donut cases
    if len(val["coordinates"]) > 1:
        poly = Polygon(val["coordinates"][0], val["coordinates"][1:])
    else:
        poly = Polygon(val["coordinates"][0])

    # Note: it doesn't check if polygons are valid or not !
    # If needed, do:
    # if not poly.is_valid:
    #   poly = poly.buffer(1.0E-9)
    return poly


def shapes_to_gdf(shapes: Generator, crs: str):
    """TODO"""
    # Convert results to pandas (because of invalid geometries) and save it
    pd_results = pd.DataFrame(shapes, columns=["geometry", "raster_val"])

    if not pd_results.empty:
        # Convert to proper polygons(correct geometries)
        pd_results.geometry = pd_results.geometry.apply(_to_polygons)

    # Convert to geodataframe with correct geometry
    return gpd.GeoDataFrame(pd_results, geometry=pd_results.geometry, crs=crs)

Functions

def corresponding_utm_projection(

lon,
lat)

Find the EPSG code of the UTM projection from a lon/lat in WGS84.

>>> corresponding_utm_projection(lon=7.8, lat=48.6)  # Strasbourg
'EPSG:32632'

Args

lon : float
Longitude (WGS84)
lat : float
Latitude (WGS84)

Returns

str
EPSG string
Expand source code
def corresponding_utm_projection(lon: float, lat: float) -> str:
    """
    Find the EPSG code of the UTM projection from a lon/lat in WGS84.

    ```python
    >>> corresponding_utm_projection(lon=7.8, lat=48.6)  # Strasbourg
    'EPSG:32632'
    ```

    Args:
        lon (float): Longitude (WGS84)
        lat (float): Latitude (WGS84)

    Returns:
        str: EPSG string

    """
    # EPSG code begins with 32
    # Then 6 if north, 7 if south -> (np.sign(lat) + 1) / 2 * 100 == 1 if lat > 0 (north), 0 if lat < 0 (south)
    # Then EPSG code with usual formula np.floor((180 + lon) / 6) + 1)
    epsg = int(32700 - (np.sign(lat) + 1) / 2 * 100 + np.floor((180 + lon) / 6) + 1)
    return f"EPSG:{epsg}"

def from_polygon_to_bounds(

polygon)

Convert a shapely.polygon to its bounds, sorted as left, bottom, right, top.

>>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
>>> from_polygon_to_bounds(poly)
(0.0, 0.0, 1.0, 1.0)

Args

polygon : MultiPolygon
polygon to convert

Returns

(float, float, float, float): left, bottom, right, top

Expand source code
def from_polygon_to_bounds(
    polygon: Union[Polygon, MultiPolygon]
) -> (float, float, float, float):
    """
    Convert a `shapely.polygon` to its bounds, sorted as `left, bottom, right, top`.

    ```python
    >>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
    >>> from_polygon_to_bounds(poly)
    (0.0, 0.0, 1.0, 1.0)
    ```

    Args:
        polygon (MultiPolygon): polygon to convert

    Returns:
        (float, float, float, float): left, bottom, right, top
    """
    left = polygon.bounds[0]  # xmin
    bottom = polygon.bounds[1]  # ymin
    right = polygon.bounds[2]  # xmax
    top = polygon.bounds[3]  # ymax

    assert left < right
    assert bottom < top

    return left, bottom, right, top

def from_bounds_to_polygon(

left,
bottom,
right,
top)

Convert the bounds to a shapely.polygon.

>>> poly = from_bounds_to_polygon(0.0, 0.0, 1.0, 1.0)
>>> print(poly)
'POLYGON ((1 0, 1 1, 0 1, 0 0, 1 0))'

Args

left : float
Left coordinates
bottom : float
Bottom coordinates
right : float
Right coordinates
top : float
Top coordinates

Returns

Polygon
Polygon corresponding to the bounds
Expand source code
def from_bounds_to_polygon(
    left: float, bottom: float, right: float, top: float
) -> Polygon:
    """
    Convert the bounds to a `shapely.polygon`.

    ```python
    >>> poly = from_bounds_to_polygon(0.0, 0.0, 1.0, 1.0)
    >>> print(poly)
    'POLYGON ((1 0, 1 1, 0 1, 0 0, 1 0))'
    ```

    Args:
        left (float): Left coordinates
        bottom (float): Bottom coordinates
        right (float): Right coordinates
        top (float): Top coordinates

    Returns:
        Polygon: Polygon corresponding to the bounds

    """
    return box(min(left, right), min(top, bottom), max(left, right), max(top, bottom))

def get_geodf(

geometry,
crs)

Get a GeoDataFrame from a geometry and a crs

>>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
>>> geodf = get_geodf(poly, crs=WGS84)
>>> print(geodf)
                                            geometry
0  POLYGON ((0.00000 0.00000, 0.00000 1.00000, 1....

Args

geometry : Union[Polygon, list]
List of Polygons, or Polygon or bounds
crs : str
CRS of the polygon

Returns

gpd.GeoDataFrame
Geometry as a geodataframe
Expand source code
def get_geodf(
    geometry: Union[Polygon, list, gpd.GeoSeries], crs: str
) -> gpd.GeoDataFrame:
    """
    Get a GeoDataFrame from a geometry and a crs

    ```python
    >>> poly = Polygon(((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)))
    >>> geodf = get_geodf(poly, crs=WGS84)
    >>> print(geodf)
                                                geometry
    0  POLYGON ((0.00000 0.00000, 0.00000 1.00000, 1....
    ```

    Args:
        geometry (Union[Polygon, list]): List of Polygons, or Polygon or bounds
        crs (str): CRS of the polygon

    Returns:
        gpd.GeoDataFrame: Geometry as a geodataframe
    """
    if isinstance(geometry, list):
        if isinstance(geometry[0], Polygon):
            pass
        else:
            try:
                geometry = [from_bounds_to_polygon(*geometry)]
            except TypeError as ex:
                raise TypeError(
                    "Give the extent as 'left', 'bottom', 'right', and 'top'"
                ) from ex
    elif isinstance(geometry, Polygon):
        geometry = [geometry]
    elif isinstance(geometry, gpd.GeoSeries):
        geometry = geometry.geometry
    else:
        raise TypeError("geometry should be a list or a Polygon.")

    return gpd.GeoDataFrame(geometry=geometry, crs=crs)

def set_kml_driver(

)

Set KML driver for Fiona data (use it at your own risks !)

>>> path = "path\to\kml.kml"
>>> gpd.read_file(path)
fiona.errors.DriverError: unsupported driver: 'LIBKML'

>>> set_kml_driver()
>>> gpd.read_file(path)
               Name  ...                                           geometry
0  CC679_new_AOI2_3  ...  POLYGON Z ((45.03532 32.49765 0.00000, 46.1947...
[1 rows x 12 columns]
Expand source code
def set_kml_driver() -> None:
    """
    Set KML driver for Fiona data (use it at your own risks !)

    ```python
    >>> path = "path\\to\\kml.kml"
    >>> gpd.read_file(path)
    fiona.errors.DriverError: unsupported driver: 'LIBKML'

    >>> set_kml_driver()
    >>> gpd.read_file(path)
                   Name  ...                                           geometry
    0  CC679_new_AOI2_3  ...  POLYGON Z ((45.03532 32.49765 0.00000, 46.1947...
    [1 rows x 12 columns]
    ```

    """
    drivers = gpd.io.file.fiona.drvsupport.supported_drivers
    if "LIBKML" not in drivers:
        drivers["LIBKML"] = "rw"
    if "KML" not in drivers:  # Just in case
        drivers["KML"] = "rw"

def get_aoi_wkt(

aoi_path,
as_str=True)

Get AOI formatted as a WKT from files that can be read by Fiona (like shapefiles, …) or directly from a WKT file. The use of KML has been forced (use it at your own risks !).

See: https://fiona.readthedocs.io/en/latest/fiona.html#fiona.open

It is assessed that:

  • only one polygon composes the AOI (as only the first one is read)
  • it should be specified in lat/lon (WGS84) if a WKT file is provided
>>> path = "path\to\vec.geojson"  # OK with ESRI Shapefile, geojson, WKT, KML...
>>> get_aoi_wkt(path)
'POLYGON Z ((46.1947755465253067 32.4973553439109324 0.0000000000000000, 45.0353174370802520 32.4976496856158974
0.0000000000000000, 45.0355748149750283 34.1139970085580018 0.0000000000000000, 46.1956059695554089
34.1144793800670882 0.0000000000000000, 46.1947755465253067 32.4973553439109324 0.0000000000000000))'

Args

aoi_path : str
Absolute or relative path to an AOI. Its format should be WKT or any format read by Fiona, like shapefiles.
as_str : bool
If True, return WKT as a str, otherwise as a shapely geometry

Returns

Union[str, Polygon]
AOI formatted as a WKT stored in lat/lon
Expand source code
def get_aoi_wkt(aoi_path: str, as_str: bool = True) -> Union[str, Polygon]:
    """
    Get AOI formatted as a WKT from files that can be read by Fiona (like shapefiles, ...)
    or directly from a WKT file. The use of KML has been forced (use it at your own risks !).

    See: https://fiona.readthedocs.io/en/latest/fiona.html#fiona.open

    It is assessed that:

    - only **one** polygon composes the AOI (as only the first one is read)
    - it should be specified in lat/lon (WGS84) if a WKT file is provided
    ```python
    >>> path = "path\\to\\vec.geojson"  # OK with ESRI Shapefile, geojson, WKT, KML...
    >>> get_aoi_wkt(path)
    'POLYGON Z ((46.1947755465253067 32.4973553439109324 0.0000000000000000, 45.0353174370802520 32.4976496856158974
    0.0000000000000000, 45.0355748149750283 34.1139970085580018 0.0000000000000000, 46.1956059695554089
    34.1144793800670882 0.0000000000000000, 46.1947755465253067 32.4973553439109324 0.0000000000000000))'
    ```

    Args:
        aoi_path (str): Absolute or relative path to an AOI.
            Its format should be WKT or any format read by Fiona, like shapefiles.
        as_str (bool): If True, return WKT as a str, otherwise as a shapely geometry

    Returns:
        Union[str, Polygon]: AOI formatted as a WKT stored in lat/lon
    """
    if not os.path.isfile(aoi_path):
        raise FileNotFoundError(f"AOI file {aoi_path} does not exist.")

    if aoi_path.endswith(".wkt"):
        try:
            with open(aoi_path, "r") as aoi_f:
                aoi = wkt.load(aoi_f)
        except Exception as ex:
            raise ValueError("AOI WKT cannot be read") from ex
    else:
        try:
            if aoi_path.endswith(".kml"):
                set_kml_driver()

            # Open file
            aoi_file = gpd.read_file(aoi_path)

            # Check if a conversion to lon/lat is needed
            if aoi_file.crs.srs != WGS84:
                aoi_file = aoi_file.to_crs(WGS84)

            # Get envelope polygon
            geom = aoi_file["geometry"]
            if len(geom) > 1:
                LOGGER.warning(
                    "Your AOI contains several polygons. Only the first will be treated !"
                )
            polygon = geom[0].convex_hull

            # Convert to WKT
            aoi = wkt.loads(str(polygon))

        except Exception as ex:
            raise ValueError("AOI cannot be read by Fiona") from ex

    # Convert to string if needed
    if as_str:
        aoi = wkt.dumps(aoi)

    LOGGER.debug("Specified AOI in WKT: %s", aoi)
    return aoi

def get_wider_exterior(

vector)

TODO

Expand source code
def get_wider_exterior(vector: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """TODO"""

    # Get the footprint max (discard small holes stored in other polygons)
    wider = vector[vector.area == np.max(vector.area)]

    # Only select the exterior of this footprint(sometimes some holes persist)
    if not wider.empty:
        poly = Polygon(list(wider.exterior.iat[0].coords))
        wider = gpd.GeoDataFrame(geometry=[poly], crs=wider.crs)

        # Resets index as we only got one polygon left which should have index 0
        wider.reset_index(inplace=True)

    return wider

def shapes_to_gdf(

shapes,
crs)

TODO

Expand source code
def shapes_to_gdf(shapes: Generator, crs: str):
    """TODO"""
    # Convert results to pandas (because of invalid geometries) and save it
    pd_results = pd.DataFrame(shapes, columns=["geometry", "raster_val"])

    if not pd_results.empty:
        # Convert to proper polygons(correct geometries)
        pd_results.geometry = pd_results.geometry.apply(_to_polygons)

    # Convert to geodataframe with correct geometry
    return gpd.GeoDataFrame(pd_results, geometry=pd_results.geometry, crs=crs)