Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: add FaceArtifacts #510

Merged
merged 6 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ci/envs/310-latest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies:
- packaging
- pandas!=1.5.0
- shapely>=2
- esda
- tqdm
# testing
- codecov
Expand Down
1 change: 1 addition & 0 deletions ci/envs/311-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ dependencies:
- git+https://github.com/networkx/networkx.git
- git+https://github.com/shapely/shapely.git
- git+https://github.com/pysal/mapclassify.git
- git+https://github.com/pysal/esda.git
1 change: 1 addition & 0 deletions ci/envs/311-latest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies:
- packaging
- pandas!=1.5.0
- shapely>=2
- esda
- tqdm
# testing
- codecov
Expand Down
1 change: 1 addition & 0 deletions ci/envs/39-latest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies:
- pandas!=1.5.0
- shapely>=2
- pygeos
- esda
- tqdm
# testing
- codecov
Expand Down
14 changes: 10 additions & 4 deletions docs/_static/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ @article{fleischmann2020
journal = {Computers, Environment and Urban Systems},
keywords = {\#nosource}
}
@article{fleischmann2023,
title = {A shape-based heuristic for the detection of urban block artifacts},
author = {Fleischmann, Martin and Vybornova, Anastassia},
year = {under review},
journal = {Journal of Spatial Information Science}
}

@article{gil2012,
title = {On the {{Discovery}} of {{Urban Typologies}}: {{Data Mining}} the {{Multi}}-Dimensional {{Character}} of {{Neighbourhoods}}},
Expand Down Expand Up @@ -206,8 +212,8 @@ @article{tripathy2020open
pages = {2399808320967680},
year = {2020},
publisher = {SAGE Publications Sage UK: London, England},
volume = {48},
pages = {2188--2205},
number = {8},
doi = {10.1177/2399808320967680}
volume = {48},
pages = {2188--2205},
number = {8},
doi = {10.1177/2399808320967680}
}
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ preprocessing
close_gaps
CheckTessellationInput
extend_lines
FaceArtifacts
remove_false_nodes
preprocess
roundabout_simplification
Expand Down
190 changes: 190 additions & 0 deletions momepy/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import pandas as pd
import shapely
from packaging.version import Version
from scipy.signal import find_peaks
from scipy.stats import gaussian_kde
from shapely.geometry import LineString, Point
from shapely.ops import linemerge, polygonize
from tqdm.auto import tqdm
Expand All @@ -25,6 +27,7 @@
"close_gaps",
"extend_lines",
"roundabout_simplification",
"FaceArtifacts",
]

GPD_10 = Version(gpd.__version__) >= Version("0.10")
Expand Down Expand Up @@ -1060,3 +1063,190 @@ def roundabout_simplification(
output = _ext_lines_to_center(edges, incoming_all, idx_drop)

return output


class FaceArtifacts:
"""Identify face artifacts in street networks

For a given street network composed of transportation-oriented geometry containing
features representing things like roundabouts, dual carriegaways and complex
intersections, identify areas enclosed by geometry that is considered a `face
artifact` as per :cite:`fleischmann2023`. Face artifacts highlight areas with a high
likelihood of being of non-morphological (e.g. transporation) origin and may require
simplification prior morphological analysis. See :cite:`fleischmann2023` for more
details.

Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing street network represented as (Multi)LineString geometry
index : str, optional
A type of the shape compacntess index to be used. Available are
['circlular_compactness', 'isoperimetric_quotient', 'diameter_ratio'], by
default "circular_compactness"
height_mins : float, optional
Required depth of valleys, by default np.NINF
height_maxs : float, optional
Required height of peaks, by default 0.008
prominence : float, optional
Required prominence of peaks, by default 0.00075

Attributes
----------
threshold : float
Identified threshold between face polygons and face artifacts
face_artifacts : GeoDataFrame
A GeoDataFrame of geometries identified as face artifacts
polygons : GeoDataFrame
All polygons resulting from polygonization of the input gdf with the
face_artifact_index
kde : scipy.stats._kde.gaussian_kde
Representation of a kernel-density estimate using Gaussian kernels.
pdf : numpy.ndarray
Probability density function
peaks : numpy.ndarray
locations of peaks in pdf
valleys : numpy.ndarray
locations of valleys in pdf

Examples
--------
>>> fa = momepy.FaceArtifacts(street_network_prague)
>>> fa.threshold
6.9634555986177045
>>> fa.face_artifacts.head()
geometry face_artifact_index
6 POLYGON ((-744164.625 -1043922.362, -744167.39... 5.112844
9 POLYGON ((-744154.119 -1043804.734, -744152.07... 6.295660
10 POLYGON ((-744101.275 -1043738.053, -744103.80... 2.862871
12 POLYGON ((-744095.511 -1043623.478, -744095.35... 3.712403
17 POLYGON ((-744488.466 -1044533.317, -744489.33... 5.158554
"""

def __init__(
self,
gdf,
index="circular_compactness",
height_mins=np.NINF,
height_maxs=0.008,
prominence=0.00075,
):
try:
from esda import shape
except (ImportError, ModuleNotFoundError) as err:
raise ImportError(
"The `esda` package is required. You can install it using "
"`pip install esda` or `conda install esda -c conda-forge`."
) from err

# Polygonize street network
polygons = gpd.GeoSeries(
shapely.polygonize( # polygonize
[gdf.unary_union]
).geoms, # get parts of the collection from polygonize
crs=gdf.crs,
).explode(
ignore_index=True
) # shoudln't be needed but doesn't hurt to ensure

# Store geometries as a GeoDataFrame
self.polygons = gpd.GeoDataFrame(geometry=polygons)
if index == "circular_compactness":
self.polygons["face_artifact_index"] = np.log(
shape.minimum_bounding_circle_ratio(polygons) * polygons.area
)
elif index == "isoperimetric_quotient":
self.polygons["face_artifact_index"] = np.log(
shape.isoperimetric_quotient(polygons) * polygons.area
)
elif index == "diameter_ratio":
self.polygons["face_artifact_index"] = np.log(
shape.diameter_ratio(polygons) * polygons.area
)
else:
raise ValueError(
f"'{index}' is not supported. Use one of ['circlular_compactness', "
"'isoperimetric_quotient', 'diameter_ratio']"
)

# parameters for peak/valley finding
peak_parameters = {
"height_mins": height_mins,
"height_maxs": height_maxs,
"prominence": prominence,
}
mylinspace = np.linspace(
self.polygons["face_artifact_index"].min(),
self.polygons["face_artifact_index"].max(),
1000,
)

self.kde = gaussian_kde(
self.polygons["face_artifact_index"], bw_method="silverman"
)
self.pdf = self.kde.pdf(mylinspace)

# find peaks
self.peaks, self.d_peaks = find_peaks(
x=self.pdf,
height=peak_parameters["height_maxs"],
threshold=None,
distance=None,
prominence=peak_parameters["prominence"],
width=1,
plateau_size=None,
)

# find valleys
self.valleys, self.d_valleys = find_peaks(
x=-self.pdf + 1,
height=peak_parameters["height_mins"],
threshold=None,
distance=None,
prominence=peak_parameters["prominence"],
width=1,
plateau_size=None,
)

# check if we have at least 2 peaks
condition_2peaks = len(self.peaks) > 1

# check if we have at least 1 valley
condition_1valley = len(self.valleys) > 0

conditions = [condition_2peaks, condition_1valley]

# if both these conditions are true, we find the artifact index
if all(conditions):
# find list order of highest peak
highest_peak_listindex = np.argmax(self.d_peaks["peak_heights"])
# find index (in linspace) of highest peak
highest_peak_index = self.peaks[highest_peak_listindex]
# define all possible peak ranges fitting our definition
peak_bounds = list(zip(self.peaks, self.peaks[1:]))
peak_bounds_accepted = [b for b in peak_bounds if highest_peak_index in b]
# find all valleys that lie between two peaks
valleys_accepted = [
v_index
for v_index in self.valleys
if any(v_index in range(b[0], b[1]) for b in peak_bounds_accepted)
]
# the value of the first of those valleys is our artifact index
# get the order of the valley
valley_index = valleys_accepted[0]

# derive threshold value for given option from index/linspace
self.threshold = float(mylinspace[valley_index])
self.face_artifacts = self.polygons[
self.polygons.face_artifact_index < self.threshold
]
else:
warnings.warn(
"No threshold found. Either your dataset it too small or the "
"distribution of the face artifact index does not follow the "
"expected shape.",
UserWarning,
stacklevel=2,
)
self.threshold = None
self.face_artifacts = None
48 changes: 48 additions & 0 deletions momepy/tests/test_preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,51 @@ def test_roundabout_simplification_center_type_mean(self):
)
assert len(check) == 65
assert len(self.df_streets_rabs) == 88


def test_FaceArtifacts():
pytest.importorskip("esda")
osmnx = pytest.importorskip("osmnx")
type_filter = (
'["highway"~"living_street|motorway|motorway_link|pedestrian|primary'
"|primary_link|residential|secondary|secondary_link|service|tertiary"
'|tertiary_link|trunk|trunk_link|unclassified|service"]'
)
streets_graph = osmnx.graph_from_point(
(35.7798, -78.6421),
dist=1000,
network_type="all_private",
custom_filter=type_filter,
retain_all=True,
simplify=False,
)
streets_graph = osmnx.projection.project_graph(streets_graph)
gdf = osmnx.graph_to_gdfs(
osmnx.get_undirected(streets_graph),
nodes=False,
edges=True,
node_geometry=False,
fill_edge_geometry=True,
)
fa = mm.FaceArtifacts(gdf)
assert 6 < fa.threshold < 9
assert isinstance(fa.face_artifacts, gpd.GeoDataFrame)
assert fa.face_artifacts.shape[0] > 200
assert fa.face_artifacts.shape[1] == 2

with pytest.warns(UserWarning, match="No threshold found"):
mm.FaceArtifacts(gdf.cx[712104:713000, 3961073:3961500])

fa_ipq = mm.FaceArtifacts(gdf, index="isoperimetric_quotient")
assert 6 < fa_ipq.threshold < 9
assert fa_ipq.threshold != fa.threshold

fa_dia = mm.FaceArtifacts(gdf, index="diameter_ratio")
assert 6 < fa_dia.threshold < 9
assert fa_dia.threshold != fa.threshold

fa = mm.FaceArtifacts(gdf, index="isoperimetric_quotient")
assert 6 < fa.threshold < 9

with pytest.raises(ValueError, match="'banana' is not supported"):
mm.FaceArtifacts(gdf, index="banana")