Source code for cityImage.pedestrian

"""Pedestrian network filtering and construction.

This module preserves the cityImage-specific pedestrian filtering semantics
previously embedded in the old OSM loading route, while delegating raw OSM
feature retrieval to OSMnx and generic line-to-network construction to
``cityImage.network``.

The core entry point is ``pedestrian_network_from_osm_features``: pass a
GeoDataFrame of OSM highway features that you downloaded elsewhere, and this
module filters it into a cityImage-style pedestrian network.
"""

from __future__ import annotations

from collections.abc import Iterable
from typing import Any

import geopandas as gpd
import pandas as pd

from .network import network_from_lines

EXCLUDED_HIGHWAY_VALUES = {
    "motorway",
    "motorway_link",
    "trunk",
    "trunk_link",
    "busway",
    "construction",
    "proposed",
    "raceway",
}

EXCLUDED_ACCESS_VALUES = {
    "private",
    "no",
}

# OSM ``foot=*`` values that grant public pedestrian access. ``official`` is a
# signposted legal designation (a stronger ``designated``). Restricted values
# such as ``permit`` and ``customers`` are deliberately excluded: like
# ``private``, they do not grant general-public access. Note "promiscuous" is not
# an OSM value; the intended concept is ``permissive`` (access tolerated but
# revocable), which is already covered.
PEDESTRIAN_OK_FOOT_VALUES = {
    "yes",
    "designated",
    "permissive",
    "destination",
    "official",
}

# OSM ``foot=*`` values that mean pedestrians should not use this way. Like
# ``foot=no`` they take precedence over a permissive general ``access`` and drop
# the way. ``use_sidepath`` means "walk on the parallel path, not here";
# ``discouraged`` is legal but advised against; ``private`` restricts access.
FOOT_NO_VALUES = {
    "no",
    "use_sidepath",
    "discouraged",
    "private",
}

# OSM-style affirmative spellings. OSM prefers "yes", but sloppy data also uses
# these variants; treat them all as an affirmative value.
OSM_YES_VALUES = {
    "yes",
    "true",
    "1",
    "y",
}

# A sidewalk counts as present when it is tagged affirmatively or with a
# side/position ("separate" means it is mapped as its own way). This also covers
# sub-keys such as ``sidewalk:left=1`` because their tokens are collected too.
SIDEWALK_EVIDENCE_VALUES = OSM_YES_VALUES | {
    "both",
    "left",
    "right",
    "separate",
}

# Highways that are inherently walkable and kept regardless of any evidence.
INHERENTLY_PEDESTRIAN_HIGHWAYS = {
    "footway",
    "pedestrian",
    "path",
    "steps",
    "corridor",
    "track",
    "bridleway",
}

# Pedestrian-priority streets (woonerf-style), kept as walkable regardless of
# evidence.
KEEP_REGARDLESS = {
    "living_street",
}

# Highways kept unconditionally, but whose ``ped`` column records whether there
# is actual pedestrian evidence: "yes" with a usable foot tag or mapped sidewalk,
# otherwise "noEvidence". Covers residential streets and major through-roads,
# where walkability is assumed but uncertain without evidence.
KEEP_WITH_EVIDENCE_FLAG = {
    "residential",
    "primary",
    "secondary",
    "tertiary",
}

# Values written to the ``ped`` column of the resulting edges.
PED_YES = "yes"
PED_NO_EVIDENCE = "noEvidence"

# How to reconcile separately-mapped sidewalks (``highway=footway`` +
# ``footway=sidewalk``) with road centrelines, which can otherwise describe the
# same street twice as parallel edges:
#   - "keep_both":   keep both, no de-duplication (may double-count streets).
#   - "centrelines": one edge per street — drop separately-mapped
#                    ``footway=sidewalk`` ways and keep road centrelines. Suits
#                    Image-of-the-City / centrality analysis.
#   - "sidewalks":   walking-surface network — keep sidewalks/crossings and drop
#                    the centreline of any road that declares ``sidewalk=separate``
#                    (its sidewalk is mapped elsewhere). Suits fine pedestrian
#                    simulation/routing.
SIDEWALK_POLICIES = ("keep_both", "centrelines", "sidewalks")
DEFAULT_SIDEWALK_POLICY = "keep_both"


def _as_tokens(value: Any) -> list[str]:
    """Return a normalised list of lowercase OSM tag tokens."""
    if isinstance(value, str):
        return [value.strip().lower()] if value.strip() else []

    if isinstance(value, Iterable) and not isinstance(value, (bytes, str)):
        tokens: list[str] = []
        for item in value:
            tokens.extend(_as_tokens(item))
        return tokens

    if pd.isna(value):
        return []

    token = str(value).strip().lower()
    return [token] if token else []


def _first_token(value: Any) -> str | None:
    """Return the first normalised OSM tag token, if available."""
    tokens = _as_tokens(value)
    return tokens[0] if tokens else None


def _truthy_osm_yes(value: Any) -> bool:
    """Return True for OSM-style affirmative values (yes/true/1/y)."""
    return _first_token(value) in OSM_YES_VALUES


def _ensure_columns(gdf: gpd.GeoDataFrame, columns: Iterable[str]) -> gpd.GeoDataFrame:
    """Ensure all requested columns exist, filled with NA values when absent."""
    gdf = gdf.copy()
    for column in columns:
        if column not in gdf.columns:
            gdf[column] = pd.NA
    return gdf


def _line_geometries_only(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Keep only LineString and MultiLineString geometries."""
    return gdf[gdf.geometry.geom_type.isin({"LineString", "MultiLineString"})].copy()


def _pedestrian_status(
    row: pd.Series, sidewalk_policy: str = DEFAULT_SIDEWALK_POLICY
) -> str | None:
    """Classify an OSM highway row for the pedestrian network.

    Parameters
    ----------
    row
        A row of OSM highway features.
    sidewalk_policy
        How to reconcile separately-mapped sidewalks with road centrelines; one
        of :data:`SIDEWALK_POLICIES`.

    Returns
    -------
    str or None
        - ``"yes"``: confidently walkable — inherently pedestrian, a
          walkable-by-default street, or carrying explicit pedestrian evidence
          (a usable ``foot`` tag or a mapped sidewalk).
        - ``"noEvidence"``: a residential street or major through-road kept
          without pedestrian evidence; walkability is uncertain.
        - ``None``: the row should be dropped from the network.
    """
    highway_tokens = set(_as_tokens(row.get("highway")))
    highway = _first_token(row.get("highway"))

    if not highway_tokens:
        return None

    if highway_tokens & EXCLUDED_HIGHWAY_VALUES:
        return None

    if _truthy_osm_yes(row.get("area")):
        return None

    foot_tokens = set(_as_tokens(row.get("foot")))
    if foot_tokens & FOOT_NO_VALUES:
        return None

    has_explicit_foot_access = bool(foot_tokens & PEDESTRIAN_OK_FOOT_VALUES)

    # A generic access restriction excludes the way unless pedestrians are
    # explicitly allowed via the mode-specific ``foot`` tag, which takes
    # precedence over ``access`` in OSM.
    access_tokens = set(_as_tokens(row.get("access")))
    if access_tokens & EXCLUDED_ACCESS_VALUES and not has_explicit_foot_access:
        return None

    sidewalk_tokens = set()
    for column, value in row.items():
        if str(column).startswith("sidewalk"):
            sidewalk_tokens.update(_as_tokens(value))

    has_sidewalk_evidence = bool(sidewalk_tokens & SIDEWALK_EVIDENCE_VALUES)
    has_evidence = has_explicit_foot_access or has_sidewalk_evidence

    footway_tokens = set(_as_tokens(row.get("footway")))
    is_separately_mapped_sidewalk = "footway" in highway_tokens and "sidewalk" in footway_tokens
    is_inherently_pedestrian = bool(highway_tokens & INHERENTLY_PEDESTRIAN_HIGHWAYS)

    # Sidewalk-vs-centreline de-duplication.
    if sidewalk_policy == "centrelines" and is_separately_mapped_sidewalk:
        # The road centreline already represents this street; drop the parallel
        # sidewalk way.
        return None
    if (
        sidewalk_policy == "sidewalks"
        and "separate" in sidewalk_tokens
        and not is_inherently_pedestrian
        and highway != "cycleway"
    ):
        # The sidewalk is mapped as its own way; drop the redundant road centreline.
        return None

    if highway == "cycleway":
        # A cycleway joins the pedestrian network when pedestrians are allowed
        # (foot tag) or a sidewalk is mapped alongside it; otherwise it is dropped.
        return PED_YES if has_evidence else None

    if is_inherently_pedestrian:
        return PED_YES

    if highway_tokens & KEEP_REGARDLESS:
        return PED_YES

    if highway_tokens & KEEP_WITH_EVIDENCE_FLAG:
        return PED_YES if has_evidence else PED_NO_EVIDENCE

    # Ambiguous ways (service, unclassified, road) and any unrecognised highway
    # type are kept only with explicit pedestrian evidence, otherwise dropped.
    return PED_YES if has_evidence else None


def _is_pedestrian_row(row: pd.Series) -> bool:
    """Return True when an OSM highway row should remain in the pedestrian network."""
    return _pedestrian_status(row) is not None


[docs] def filter_pedestrian_osm_features( highways_gdf: gpd.GeoDataFrame, sidewalk_policy: str = DEFAULT_SIDEWALK_POLICY, ) -> gpd.GeoDataFrame: """Filter OSM highway features using cityImage pedestrian-network semantics. Parameters ---------- highways_gdf GeoDataFrame of OSM features, usually downloaded with an OSMnx ``features_from_*``/``geometries_from_*`` call using ``tags={"highway": True}``. sidewalk_policy How to reconcile separately-mapped sidewalks with road centrelines; one of :data:`SIDEWALK_POLICIES`. Defaults to ``"keep_both"`` (no de-duplication). Use ``"centrelines"`` for one edge per street or ``"sidewalks"`` for a walking-surface network. Returns ------- geopandas.GeoDataFrame Line/MultiLine features that remain after pedestrian filtering, with an added ``ped`` column: ``"yes"`` for confidently walkable ways and ``"noEvidence"`` for residential/major roads kept without pedestrian evidence. """ if not isinstance(highways_gdf, gpd.GeoDataFrame): raise TypeError("highways_gdf must be a GeoDataFrame") if sidewalk_policy not in SIDEWALK_POLICIES: raise ValueError( f"sidewalk_policy must be one of {SIDEWALK_POLICIES}, got {sidewalk_policy!r}" ) if highways_gdf.empty: return highways_gdf.copy() required_filter_columns = [ "highway", "area", "foot", "footway", "cycleway", "access", ] gdf = _ensure_columns(highways_gdf, required_filter_columns) gdf = gdf[gdf.geometry.notna()].copy() gdf = _line_geometries_only(gdf) if gdf.empty: return gdf gdf["ped"] = gdf.apply(_pedestrian_status, axis=1, sidewalk_policy=sidewalk_policy) return gdf[gdf["ped"].notna()].copy()
[docs] def pedestrian_network_from_osm_features( highways_gdf: gpd.GeoDataFrame, crs: Any, sidewalk_policy: str = DEFAULT_SIDEWALK_POLICY, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """Build a cityImage pedestrian network from already-downloaded OSM features. Raw OSM download is deliberately not owned by this function. Use OSMnx or another data source to retrieve highway features, then pass them here. ``sidewalk_policy`` selects how separately-mapped sidewalks and road centrelines are reconciled; see :func:`filter_pedestrian_osm_features`. """ filtered = filter_pedestrian_osm_features(highways_gdf, sidewalk_policy=sidewalk_policy) if filtered.empty: empty_edges = gpd.GeoDataFrame(columns=["edgeID", "u", "v", "length"], geometry=[], crs=crs) empty_nodes = gpd.GeoDataFrame(columns=["nodeID", "x", "y"], geometry=[], crs=crs) return empty_nodes, empty_edges metadata_columns = [ "name", "highway", "ped", "lit", "foot", "footway", "cycleway", "access", "surface", "width", ] metadata_columns.extend( column for column in filtered.columns if str(column).startswith("sidewalk") ) other_columns = [] seen = set() for column in metadata_columns: if column in seen or column not in filtered.columns: continue # Skip metadata columns that are entirely empty (e.g. tag columns # back-filled with NA when the source lacked them), so the edges do not # carry all-null passthrough columns. if filtered[column].isna().all(): continue other_columns.append(column) seen.add(column) return network_from_lines(filtered, crs, other_columns=other_columns)
def _call_osmnx_features(ox: Any, method_name: str, *args: Any, **kwargs: Any) -> gpd.GeoDataFrame: """Call modern OSMnx feature functions with fallback for older versions.""" modern = getattr(ox, f"features_from_{method_name}", None) if modern is not None: return modern(*args, **kwargs) legacy = getattr(ox, f"geometries_from_{method_name}", None) if legacy is not None: return legacy(*args, **kwargs) raise ImportError( "Installed OSMnx version does not expose features_from_* or geometries_from_* APIs" )
[docs] def pedestrian_network_from_osm( query: Any = None, *, crs: Any, download_method: str = "OSMplace", distance: float = 500, address: str | None = None, point: tuple[float, float] | None = None, polygon: Any = None, sidewalk_policy: str = DEFAULT_SIDEWALK_POLICY, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """Download OSM highway features with OSMnx and build a pedestrian network. This is a thin optional convenience wrapper. The cityImage-specific part is the pedestrian filtering; OSM acquisition remains delegated to OSMnx. Parameters ---------- query Place name for ``download_method="OSMplace"``. For address/point/polygon methods, prefer the explicit keyword arguments. crs Target projected CRS for the output network. download_method One of ``"OSMplace"``, ``"distance_from_address"``, ``"distance_from_point"``, or ``"polygon"``. distance Distance in metres for address/point downloads. address, point, polygon Explicit spatial inputs for the corresponding download methods. sidewalk_policy How to reconcile separately-mapped sidewalks with road centrelines; see :func:`filter_pedestrian_osm_features`. """ import osmnx as ox tags = {"highway": True} if download_method == "OSMplace": if query is None: raise ValueError("query must be provided when download_method='OSMplace'") features = _call_osmnx_features(ox, "place", query, tags=tags) elif download_method == "distance_from_address": address_query = address or query if address_query is None: raise ValueError( "address or query must be provided when download_method='distance_from_address'" ) features = _call_osmnx_features(ox, "address", address_query, tags=tags, dist=distance) elif download_method == "distance_from_point": point_query = point or query if point_query is None: raise ValueError( "point or query must be provided when download_method='distance_from_point'" ) features = _call_osmnx_features(ox, "point", point_query, tags=tags, dist=distance) elif download_method == "polygon": polygon_query = polygon or query if polygon_query is None: raise ValueError("polygon or query must be provided when download_method='polygon'") features = _call_osmnx_features(ox, "polygon", polygon_query, tags=tags) else: raise ValueError( "download_method must be one of: OSMplace, distance_from_address, " "distance_from_point, polygon" ) return pedestrian_network_from_osm_features(features, crs, sidewalk_policy=sidewalk_policy)