Skip to content

Commit

Permalink
Merge pull request #46 from Geoalert/new_features
Browse files Browse the repository at this point in the history
Add new features
  • Loading branch information
SakharovGeoalert committed Mar 19, 2024
2 parents 31bb68e + 0b05e7a commit 4a48e19
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 21 deletions.
8 changes: 4 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,26 @@ the remote sensing data, Aeronet_raster provides an interface to handle geotiff
1. python 3
2. rasterio >= 1.0.0
3. shapely >= 1.7.1
4. rtree>=0.8.3,<1.0.0
4. rtree>=0.8.3
5. opencv-python>=4.0.0
6. tqdm >=4.36.1

Pypi package:
.. code:: bash
$ pip install aeronet [all]
$ pip install aeronet[all]
for partial install:

Raster-only
.. code:: bash
$ pip install aeronet [raster]
$ pip install aeronet[raster]
Vector-only
.. code:: bash
$ pip install aeronet [vector]
$ pip install aeronet[vector]
Source code:
.. code:: bash
Expand Down
64 changes: 59 additions & 5 deletions aeronet_vector/aeronet_vector/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .utils import utm_zone, CRS_LATLON
import shapely
import numpy as np
from typing import Callable


class Feature:
Expand All @@ -32,12 +33,22 @@ def __getstate__(self):
return self.__dict__

def _valid(self, shape):
# TODO: make it static?
if not shape.is_valid:
shape = shape.buffer(0)
return shape

def apply(self, func):
return Feature(func(self._geometry), properties=self.properties, crs=self.crs)
def apply(self, func: Callable, inplace: bool = False):
"""Applies function geometry
Args:
func (Callable): function to apply
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
new Feature if inplace, else None"""
if inplace:
self._geometry = func(self._geometry)
else:
return Feature(func(self._geometry), properties=self.properties, crs=self.crs)

@property
def shape(self):
Expand Down Expand Up @@ -65,7 +76,7 @@ def IoU(self, other):
return self._geometry.intersection(other._geometry).area / self._geometry.union(other._geometry).area

def as_geojson(self, hold_crs=False):
""" Return Feature as GeoJSON formatted dict
""" Returns Feature as GeoJSON formatted dict
Args:
hold_crs (bool): serialize with current projection, that could be not ESPG:4326 (which is standards violation)
Returns:
Expand Down Expand Up @@ -106,16 +117,59 @@ def as_geojson(self, hold_crs=False):
def geojson(self):
return self.as_geojson()

def reproject(self, dst_crs):
def reproject(self, dst_crs, inplace: bool = False):
"""Reprojects Feature to dst_crs
Args:
dst_crs (str or CRS): crs to reproject to
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
new Feature if inplace, else None"""
new_geometry = transform_geom(
src_crs=self.crs,
dst_crs=dst_crs,
geom=self.geometry,
)
return Feature(new_geometry, properties=self.properties, crs=dst_crs)
if inplace:
self._geometry = new_geometry
else:
return Feature(new_geometry, properties=self.properties, crs=dst_crs)

def reproject_to_utm(self):
"""Alias of `reproject` method with automatic Band utm zone determining
The utm zone is determined according to the center of the bounding box of the collection.
Does not suit to large area geometry, that would not fit into one zone (about 6 dergees in longitude)
Returns:
new Feature"""
lon1, lat1, lon2, lat2 = self.shape.bounds
# todo: BUG?? handle non-latlon CRS!
dst_crs = utm_zone((lat1 + lat2)/2, (lon1 + lon2)/2)
return self.reproject(dst_crs)

def copy(self):
"""Returns a copy of feature"""
return Feature(shape(self.geometry), {k: v for k, v in self.properties.items()}, self.crs)

def simplify(self, tolerance: float, inplace: bool = True):
"""Simplifies geometry with Douglas-Pecker
Args:
tolerance (float): simplification tolerance
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
new Feature if inplace, else None"""
if inplace:
self._geometry = self._geometry.simplify(tolerance)
else:
return self.copy().simplify(tolerance, inplace=True)

def cast_property_to(self, key: str, new_type: type, inplace: bool = True):
"""Casts property to new type (e.g. str to int)
Args:
key (str): key of modified property
new_type (bool): type to cast to
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
new Feature if inplace, else None"""
if inplace:
self.properties[key] = new_type(self.properties.get(key))
else:
return self.copy().cast_property_to(key, new_type, inplace=True)
123 changes: 113 additions & 10 deletions aeronet_vector/aeronet_vector/featurecollection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,117 @@
from rasterio.errors import CRSError
from .feature import Feature
from .utils import utm_zone, CRS_LATLON
from typing import Callable


class FeatureCollection:
"""A set of Features with the same CRS"""

def __init__(self, features, crs=CRS.from_epsg(4326)):
self.crs = crs
self._crs = crs
self.features = self._valid(features)

# create indexed set for faster processing
self.index = rtree.index.Index()
for i, f in enumerate(self.features):
self.index.add(i, f.bounds, f.shape)

@property
def crs(self):
return self._crs

@crs.setter
def crs(self, value):
# Not reprojecting, just setting new value
self._crs = value
for f in self.features:
f.crs = value

def __getitem__(self, item):
return self.features[item]

def __len__(self):
return len(self.features)

def _valid(self, features):
@staticmethod
def _valid(features):
valid_features = []
for f in features:
if not f.geometry.get('coordinates'): # remove possible empty shapes
warnings.warn('Empty geometry detected. This geometry have been removed from collection.',
warnings.warn('Empty geometry detected. This geometry has been removed from collection.',
RuntimeWarning)
else:
valid_features.append(f)
return valid_features

def apply(self, func):
return FeatureCollection([f.apply(func) for f in self.features], crs=self.crs)
def apply(self, func: Callable, inplace: bool = True):
"""Applies function to collection geometries
Args:
func (Callable): function to apply
inplace (bool): if True modifies collection inplace, else returns new collection
Returns:
new FeatureCollection if inplace, else None
"""
if inplace:
for f in self.features:
f.apply(func, True)
else:
return FeatureCollection([f.apply(func) for f in self.features], crs=self.crs)

def filter(self, func):
return FeatureCollection(filter(func, self.features), crs=self.crs)
def filter(self, func: Callable, inplace: bool = True):
"""Filters collection according to func
Args:
func (Callable): filtering function
inplace (bool): if True modifies collection inplace, else returns new collection
Returns:
new FeatureCollection if inplace, else None
"""
if inplace:
self.features = list(filter(func, self.features))
else:
return FeatureCollection(filter(func, self.features), crs=self.crs)

def sort(self, key, reverse=False):
def sort(self, key: Callable, reverse: bool = False):
"""Sorts collection inplace
Args:
key (Callable): sorting function
reverse (bool): if True, ascending sorting order, else descending"""
self.features.sort(key=key, reverse=reverse)
self.index = rtree.index.Index()
for i, f in enumerate(self.features):
self.index.add(i, f.bounds, f.shape)

def extend(self, fc):
"""Extends collection with another collection (inplace)
Args:
fc (FeatureCollection): collection to extend with"""
for i, f in enumerate(fc):
self.index.add(i + len(self), f.bounds)
self.features.extend(fc.features)

def append(self, feature):
"""Appends feature to the collection (inplace)
Args:
feature (Feature): Feature to append"""
self.index.add(len(self), feature.bounds)
self.features.append(feature)

def bounds_intersection(self, feature):
"""Returns subset of collection features, which bounding boxes intersects with given feature bbox
Args:
feature (Feature): Feature to check intersection with
Returns:
FeatureCollection"""
idx = self.index.intersection(feature.bounds)
features = [self.features[i] for i in idx]
return FeatureCollection(features, self.crs)

def intersection(self, feature):
"""Returns subset of collection features, which intersects with given feature
Args:
feature (Feature): Feature to check intersection with
Returns:
FeatureCollection"""
proposed_features = self.bounds_intersection(feature)
features = []
for pf in proposed_features:
Expand Down Expand Up @@ -143,7 +200,7 @@ def read(cls, fp):
)
features.append(feature_)
except (KeyError, IndexError, AttributeError) as e:
message = 'Feature #{} have been removed from collection. Error: {}'.format(i, str(e))
message = 'Feature #{} has been removed from collection. Error: {}'.format(i, str(e))
warnings.warn(message, RuntimeWarning)

return cls(features, crs=crs)
Expand Down Expand Up @@ -214,5 +271,51 @@ def reproject_to_utm(self):
Alias of `reproject` method with automatic Band utm zone determining
The utm zone is determined according to the center of the bounding box of the collection.
Does not suit to large area geometry, that would not fit into one zone (about 6 dergees in longitude)
Returns:
new reprojected FeatureCollection
"""
return self.reproject(dst_crs='utm')
return self.reproject(dst_crs='utm')

def copy(self):
"""Returns a copy of collection"""
return FeatureCollection((f.copy() for f in self.features), crs=self.crs)

def simplify(self, tolerance: float, inplace: bool = True):
"""Simplifies geometries with Douglas-Pecker
Args:
tolerance (float): simplification tolerance
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
FeatureCollection if inplace, else None"""
if inplace:
for f in self.features:
f.simplify(tolerance, inplace=True)
else:
return self.copy().simplify(tolerance, inplace=True)

def cast_property_to(self, key: str, new_type: type, inplace: bool = True):
"""Casts property to new type (e.g. str to int)
Args:
key (str): key of modified property
new_type (bool): type to cast to
inplace (bool): if True modifies Feature inplace, else returns new Feature
Returns:
FeatureCollection if inplace, else None"""
if inplace:
for f in self.features:
f.cast_property_to(key, new_type, inplace=True)
else:
return self.copy().cast_property_to(key, new_type, inplace=True)

def index_of(self, condition):
"""Returns indexes of features where condition == True
Args:
condition (Callable): if condition(feature)==True, index of that feature will be returned
Raises:
ValueError when no features found
Returns:
(int) index of first occurrence"""
for i, f in enumerate(self.features):
if condition(f):
return i
raise ValueError('No features found')
4 changes: 2 additions & 2 deletions aeronet_vector/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
rasterio>=1.0.0
rtree>=0.8.3,<1.0.0
shapely>=1.7.1,<2.0.0
rtree>=0.8.3
shapely>=1.7.1
tqdm>=4.36.1,<4.65

0 comments on commit 4a48e19

Please sign in to comment.