Source code for cityImage.landmarks

"""Core building landmark and imageability scoring functions.

This module contains the scientific cityImage building-level scoring logic.
It deliberately excludes data-loading helpers such as OSM download wrappers.
External libraries should prepare/download data; cityImage should score already-prepared GeoDataFrames.
"""

from __future__ import annotations

import concurrent.futures
import re
from typing import Any

import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.geometry import Point, Polygon, mapping

from .data_utils import scaling_columnDF

pd.set_option("display.precision", 3)


def _clean_height(x):
    if isinstance(x, (list, tuple, set)):
        x = next(iter(x), None)

    if pd.isna(x):
        return None

    if isinstance(x, (int, float)):
        return float(x)

    s = str(x).replace(",", ".")
    m = re.search(r"\d+(\.\d+)?", s)

    return float(m.group()) if m else None


[docs] def structural_score( buildings_gdf, obstructions_gdf, edges_gdf, advance_vis_expansion_distance=300, neighbours_radius=150, ): """ The function computes the "Structural Landmark Component" sub-scores of each building. It considers: - distance from the street network: - advance 2d visibility polygon; - number of neighbouring buildings in a given radius. Parameters ---------- buildings_gdf: Polygon GeoDataFrame Buildings GeoDataFrame - case study area. edges_gdf: LineString GeoDataFrame Street segmetns GeoDataFrame. obstructions_gdf: Polygon GeoDataFrame Obstructions GeoDataFrame. advance_vis_expansion_distance: float 2d advance visibility - it indicates up to which distance from the building boundaries the 2dvisibility polygon can expand. neighbours_radius: float Neighbours - search radius for other adjacent buildings. Returns ------- buildings_gdf: Polygon GeoDataFrame The updated buildings GeoDataFrame. """ from .visibility2d import visibility_polygon2d buildings_gdf = buildings_gdf.copy() assert_all_polygons(buildings_gdf) if buildings_gdf.empty: buildings_gdf["road"] = pd.Series(dtype=float, index=buildings_gdf.index) buildings_gdf["2dvis"] = pd.Series(dtype=float, index=buildings_gdf.index) buildings_gdf["neigh"] = pd.Series(dtype=int, index=buildings_gdf.index) return buildings_gdf # remove z coordinates if they are there already - issue with 2dvis if len(buildings_gdf.geometry.iloc[0].exterior.coords[0]) == 3: buildings_gdf["geometry"] = buildings_gdf["geometry"].apply( lambda g: type(g)([(x, y) for x, y, *_ in g.exterior.coords]) ) obstructions_gdf = buildings_gdf if obstructions_gdf is None else obstructions_gdf sindex = obstructions_gdf.sindex street_network = edges_gdf.geometry.union_all() buildings_gdf["road"] = buildings_gdf.geometry.distance(street_network) buildings_gdf["2dvis"] = buildings_gdf.geometry.apply( lambda row: visibility_polygon2d( row, obstructions_gdf, sindex, max_expansion_distance=advance_vis_expansion_distance ) ) buildings_gdf["neigh"] = buildings_gdf.geometry.apply( lambda row: _number_neighbours(row, obstructions_gdf, sindex, radius=neighbours_radius) ) return buildings_gdf
def _number_neighbours(geometry, obstructions_gdf, obstructions_sindex, radius): """ The function counts the number of neighbours, in a GeoDataFrame, around a given geometry, within a search radius. Parameters ---------- geometry: Shapely Geometry The geometry for which neighbors are counted. obstructions_gdf: GeoDataFrame The GeoDataFrame containing the obstructions. obstructions_sindex: Spatial Index The spatial index of the obstructions GeoDataFrame. radius: float The search radius for neighboring buildings. Returns ------- int The number of neighbors. """ buffer = geometry.buffer(radius) possible_neigh_index = list(obstructions_sindex.intersection(buffer.bounds)) possible_neigh = obstructions_gdf.iloc[possible_neigh_index] precise_neigh = possible_neigh[possible_neigh.intersects(buffer)] return len(precise_neigh)
[docs] def visibility_score(buildings_gdf, sight_lines=None, method="longest"): """Calculate visibility landmark sub-scores. Adds: - fac: approximate facade area, computed whenever height is available; - 3dvis: 3D visibility score, derived from sight-line lengths when provided. """ if sight_lines is None: sight_lines = pd.DataFrame() buildings_gdf = buildings_gdf.copy() buildings_gdf["fac"] = 0.0 has_height = "height" in buildings_gdf.columns if has_height: buildings_gdf["height"] = buildings_gdf["height"].apply(_clean_height) if has_height and not buildings_gdf.empty: buildings_gdf["fac"] = buildings_gdf.apply( lambda row: ( _facade_area(row["geometry"], row["height"]) if pd.notnull(row["height"]) else 0.0 ), axis=1, ) if not has_height or sight_lines.empty: buildings_gdf["3dvis"] = 0.0 return buildings_gdf sight_lines = sight_lines.copy() sight_lines["nodeID"] = sight_lines["nodeID"].astype(int) sight_lines["buildingID"] = sight_lines["buildingID"].astype(int) sight_lines["length"] = sight_lines.geometry.length stats = sight_lines.groupby("buildingID").agg({"length": ["mean", "max", "count"]}) stats.columns = stats.columns.droplevel(0) stats.rename(columns={"count": "nr_lines"}, inplace=True) for column in ["max", "mean", "nr_lines"]: stats[column] = stats[column].fillna(stats[column].min()) stats[column + "_sc"] = scaling_columnDF(stats[column]) if method == "longest": stats["3dvis"] = stats["max_sc"] elif method == "combined": stats["3dvis"] = ( stats["max_sc"] * 0.5 + stats["mean_sc"] * 0.25 + stats["nr_lines_sc"] * 0.25 ) else: raise ValueError("method must be either 'longest' or 'combined'") stats.reset_index(inplace=True) buildings_gdf = pd.merge( buildings_gdf, stats[["buildingID", "3dvis"]], on="buildingID", how="left", ) buildings_gdf["3dvis"] = buildings_gdf["3dvis"].where( pd.notnull(buildings_gdf["3dvis"]), 0.0, ) return buildings_gdf
def _facade_area(building_geometry, building_height): """ Compute the approximate facade area of a building given its geometry and height. Parameters ---------- building_geometry: Polygon The geometry of the building. building_height: float The height of the building. Returns ------- float The computed approximate facade area of the building. """ envelope = building_geometry.envelope coords = mapping(envelope)["coordinates"][0] d = [ (Point(coords[0])).distance(Point(coords[1])), (Point(coords[1])).distance(Point(coords[2])), ] width = min(d) return width * building_height def _is_historic(value: Any) -> bool: if value is None: return False try: if pd.isna(value): return False except Exception: pass return str(value).strip().lower() not in {"0", "no", "false", "", "none", "nan"}
[docs] def cultural_score( buildings_gdf, historic_elements_gdf=None, score_column: str | None = None, from_OSM: bool = False, ): """Compute a cultural landmark component per building.""" buildings_gdf = buildings_gdf.copy() buildings_gdf["cult"] = 0.0 if from_OSM: if "historic" not in buildings_gdf.columns: raise ValueError("from_OSM=True requires buildings_gdf to contain a 'historic' column") buildings_gdf["cult"] = ( buildings_gdf["historic"].apply(_is_historic).astype("int8").astype(float) ) return buildings_gdf if historic_elements_gdf is None or len(historic_elements_gdf) == 0: return buildings_gdf if buildings_gdf.crs != historic_elements_gdf.crs: raise ValueError( "CRS mismatch: buildings_gdf and historic_elements_gdf must have the same CRS" ) left = buildings_gdf[["geometry"]].copy() right_cols = ["geometry"] if score_column is not None: if score_column not in historic_elements_gdf.columns: raise ValueError(f"score_column '{score_column}' not found in historic_elements_gdf") right_cols.append(score_column) right = historic_elements_gdf[right_cols].copy() left = left[left.geometry.notna()].copy() right = right[right.geometry.notna()].copy() if left.empty or right.empty: return buildings_gdf try: joined = gpd.sjoin(left, right, how="inner", predicate="intersects") except TypeError: joined = gpd.sjoin(left, right, how="inner", op="intersects") if joined.empty: return buildings_gdf if score_column is None: counts = joined.groupby(joined.index).size().astype(float) buildings_gdf["cult"] = counts.reindex(buildings_gdf.index, fill_value=0.0) return buildings_gdf vals = pd.to_numeric(joined[score_column], errors="coerce").fillna(0.0) sums = vals.groupby(joined.index).sum().astype(float) buildings_gdf["cult"] = sums.reindex(buildings_gdf.index, fill_value=0.0) return buildings_gdf
[docs] def pragmatic_score( buildings_gdf, land_uses_column: str = "land_uses", overlaps_column: str = "land_uses_overlap", search_radius: float = 200, default_land_use: str = "unclassified", ): """Compute a pragmatic landmark component from semantic land-use labels. Missing or empty land-use labels are treated as ``default_land_use`` and aligned with a full overlap weight of ``[1.0]``. """ gdf = buildings_gdf.copy() def _as_list(v): if isinstance(v, list): return v if isinstance(v, tuple): return list(v) if isinstance(v, set): return list(v) if isinstance(v, np.ndarray): return v.tolist() if v is None: return [] try: if pd.isna(v): return [] except Exception: pass return [v] if land_uses_column not in gdf.columns: gdf[land_uses_column] = pd.Series( [[default_land_use] for _ in range(len(gdf))], index=gdf.index, dtype="object", ) if overlaps_column not in gdf.columns: gdf[overlaps_column] = pd.Series( [[] for _ in range(len(gdf))], index=gdf.index, dtype="object", ) gdf[land_uses_column] = gdf[land_uses_column].apply(lambda v: _as_list(v) or [default_land_use]) if gdf.empty: gdf["prag"] = pd.Series(dtype=float, index=gdf.index) return gdf def _weights_for_row(row): labels = row[land_uses_column] weights = _as_list(row[overlaps_column]) if len(labels) == 0: return [] if len(weights) != len(labels): return [1.0 / len(labels)] * len(labels) try: weights = [float(x) for x in weights] except Exception: return [1.0 / len(labels)] * len(labels) total = float(np.nansum(weights)) if not np.isfinite(total) or total <= 0: return [1.0 / len(labels)] * len(labels) return [x / total for x in weights] gdf["_ci_row_id"] = np.arange(len(gdf)) gdf["_w_list"] = gdf.apply(_weights_for_row, axis=1) gdf[overlaps_column] = pd.Series(gdf["_w_list"].tolist(), index=gdf.index, dtype="object") gdf["_lu_w"] = gdf.apply( lambda r: list(zip(r[land_uses_column], r["_w_list"], strict=False)), axis=1, ) gdf_exploded = gdf.explode("_lu_w", ignore_index=False) gdf_exploded[land_uses_column] = gdf_exploded["_lu_w"].apply( lambda x: x[0] if isinstance(x, tuple) else x ) gdf_exploded["_w"] = gdf_exploded["_lu_w"].apply( lambda x: float(x[1]) if isinstance(x, tuple) else 1.0 ) sindex = gdf_exploded.sindex def _unexpectedness(row_id, building_geometry, building_label): buf = building_geometry.buffer(search_radius) candidate_idx = list(sindex.intersection(buf.bounds)) if not candidate_idx: return 0.0 possible = gdf_exploded.iloc[candidate_idx] matches = possible[possible.intersects(buf)] matches = matches[matches["_ci_row_id"] != row_id] if matches.empty: return 0.0 total_w = float(matches["_w"].sum()) if total_w <= 0: return 0.0 Nj_w = float(matches.loc[matches[land_uses_column] == building_label, "_w"].sum()) return 1.0 - (Nj_w / total_w) gdf_exploded["prag_temp"] = gdf_exploded.apply( lambda row: _unexpectedness(row["_ci_row_id"], row.geometry, row[land_uses_column]), axis=1, ) scores = gdf_exploded.groupby("_ci_row_id")["prag_temp"].max() gdf["prag"] = gdf["_ci_row_id"].map(scores).fillna(0.0).astype(float) return gdf.drop(columns=["_ci_row_id", "_w_list", "_lu_w"], errors="ignore")
[docs] def compute_global_scores(buildings_gdf, global_indexes_weights, global_components_weights): """Compute component and global landmarkness scores.""" buildings_gdf = buildings_gdf.copy() cols = { "direct": ["3dvis", "fac", "height", "area", "2dvis", "cult", "prag"], "inverse": ["neigh", "road"], } if not (abs(sum(global_components_weights.values()) - 1.0) < 1e-6): raise ValueError("Global components weights must sum to 1.0") compute_vScore = ( "vScore" in global_components_weights and "height" in buildings_gdf.columns and buildings_gdf["height"].max() > 0.0 ) for col in cols["direct"] + cols["inverse"]: if col in buildings_gdf.columns: buildings_gdf[col + "_sc"] = scaling_columnDF( buildings_gdf[col], inverse=(col in cols["inverse"]), ) if compute_vScore: buildings_gdf["vScore"] = sum( buildings_gdf[f"{col}_sc"] * global_indexes_weights[col] for col in ["fac", "height", "3dvis"] if f"{col}_sc" in buildings_gdf ) buildings_gdf["vScore_sc"] = scaling_columnDF(buildings_gdf["vScore"]) buildings_gdf["sScore"] = sum( buildings_gdf[f"{col}_sc"] * global_indexes_weights[col] for col in ["area", "neigh", "2dvis", "road"] if f"{col}_sc" in buildings_gdf ) buildings_gdf["sScore_sc"] = scaling_columnDF(buildings_gdf["sScore"]) buildings_gdf["cScore"] = ( buildings_gdf["cult_sc"] if "cult_sc" in buildings_gdf.columns else 0.0 ) buildings_gdf["pScore"] = ( buildings_gdf["prag_sc"] if "prag_sc" in buildings_gdf.columns else 0.0 ) if "cult_sc" in buildings_gdf.columns: buildings_gdf["cScore_sc"] = buildings_gdf["cult_sc"] if "prag_sc" in buildings_gdf.columns: buildings_gdf["pScore_sc"] = buildings_gdf["prag_sc"] buildings_gdf["gScore"] = sum( buildings_gdf[f"{component}_sc"] * global_components_weights[component] for component in global_components_weights if f"{component}_sc" in buildings_gdf and (component != "vScore" or compute_vScore) ) buildings_gdf["gScore_sc"] = scaling_columnDF(buildings_gdf["gScore"]) return buildings_gdf
[docs] def compute_local_scores( buildings_gdf, local_indexes_weights, local_components_weights, rescaling_radius=1500 ): """ The function computes landmarkness at the local level. The components' weights may be different from the ones used to calculate the global score. The radius parameter indicates the extent of the area considered to rescale the landmarkness local score. - local_indexes_weights: keys are index names (string), items are weights. - local_components_weights: keys are component names (string), items are weights. Parameters ---------- buildings_gdf: Polygon GeoDataFrame The input GeoDataFrame containing buildings information. local_indexes_weights: dict Dictionary with index names (string) as keys and weights as values. local_components_weights: dict Dictionary with component names (string) as keys and weights as values. Returns ------- buildings_gdf: Polygon GeoDataFrame The updated buildings GeoDataFrame. Examples -------- >>> # local landmarkness indexes weights, cScore and pScore have only 1 index each >>> local_indexes_weights = { ... "3dvis": 0.50, ... "fac": 0.30, ... "height": 0.20, ... "area": 0.40, ... "2dvis": 0.00, ... "neigh": 0.30, ... "road": 0.30, ... } >>> # local landmarkness components weights >>> local_components_weights = {"vScore": 0.25, "sScore": 0.35, "cScore": 0.10, "pScore": 0.30} """ sindex = buildings_gdf.sindex # spatial index # Validate that local_components_weights sum to 1.0 if not (abs(sum(local_components_weights.values()) - 1.0) < 1e-6): raise ValueError("Local components weights must sum to 1.0") # Initialize scores conditionally compute_vScore = ( "vScore" in local_components_weights and "height" in buildings_gdf.columns and buildings_gdf["height"].max() > 0.0 ) if compute_vScore: buildings_gdf["vScore_l"] = 0.0 # Initialize only if valid height data exists buildings_gdf["sScore_l"] = 0.0 buildings_gdf["lScore"] = 0.0 with concurrent.futures.ThreadPoolExecutor() as executor: future_scores = { executor.submit( _building_local_score, row["geometry"], idx, buildings_gdf, sindex, local_components_weights, local_indexes_weights, rescaling_radius, ): idx for idx, row in buildings_gdf.iterrows() } for future in concurrent.futures.as_completed(future_scores): buildingID = future_scores[future] buildings_gdf.loc[buildingID, "lScore"] = future.result() buildings_gdf["lScore_sc"] = scaling_columnDF(buildings_gdf["lScore"]) return buildings_gdf
def _building_local_score( building_geometry, buildingID, buildings_gdf, buildings_gdf_sindex, local_components_weights, local_indexes_weights, radius, ): """ The function computes landmarkness at the local level for a single building. Parameters ---------- building_geometry Polygon The geometry of the building. buildingID: int The ID of the building. buildings_gdf: Polygon GeoDataFrame The GeoDataFrame containing the buildings. buildings_gdf_sindex: Spatial Index The spatial index of the buildings GeoDataFrame. local_components_weights: dictionary The weights assigned to local-level components. local_indexes_weights: dictionary The weights assigned to local-level indexes. radius: float The radius that regulates the area around the building within which the scores are recomputed. Returns ------- score : float The computed local-level landmarkness score for the building. """ cols = { "direct": ["3dvis", "fac", "height", "area", "2dvis", "cult", "prag"], "inverse": ["neigh", "road"], } buffer = building_geometry.buffer(radius) matches_index = list(buildings_gdf_sindex.intersection(buffer.bounds)) matches = buildings_gdf.iloc[matches_index].copy() matches = matches[matches.intersects(buffer)] # Rescale all values dynamically if the column exists in matches for column in cols["direct"] + cols["inverse"]: if column in matches.columns: matches[column + "_sc"] = scaling_columnDF( matches[column], inverse=(column in cols["inverse"]) ) # Compute structural score (sScore) if "sScore" in local_components_weights: matches["sScore_l"] = sum( matches[f"{col}_sc"] * local_indexes_weights[col] for col in ["area", "2dvis", "neigh", "road"] if f"{col}_sc" in matches ) # Recomputing visual scores only if "height" is valid # Determine if vScore should be computed compute_vScore = ( "vScore" in local_components_weights and "height" in matches.columns and matches["height"].max() > 0.0 ) if compute_vScore: matches["vScore_l"] = sum( matches[f"{col}_sc"] * local_indexes_weights[col] for col in ["fac", "height", "3dvis"] if f"{col}_sc" in matches ) # Compute cultural and pragmatic scores if defined if "cScore" in local_components_weights and "cult_sc" in matches.columns: matches["cScore_l"] = matches["cult_sc"] if "pScore" in local_components_weights and "prag_sc" in matches.columns: matches["pScore_l"] = matches["prag_sc"] # Rescale component scores dynamically for component in local_components_weights: if f"{component}_l" in matches and (component != "vScore" or compute_vScore): matches[f"{component}_l_sc"] = scaling_columnDF(matches[f"{component}_l"]) # Compute the final local score matches["lScore"] = sum( matches[f"{component}_l_sc"] * local_components_weights[component] for component in local_components_weights if f"{component}_l_sc" in matches and (component != "vScore" or compute_vScore) ) # Return the local score for the specified building return round(matches.loc[buildingID, "lScore"], 3) def assert_all_polygons(gdf: gpd.GeoDataFrame): """Raise TypeError if a GeoDataFrame contains non-Polygon geometries. Parameters ---------- gdf : geopandas.GeoDataFrame GeoDataFrame expected to contain only Shapely Polygon geometries. """ invalid = gdf[~gdf.geometry.apply(lambda g: isinstance(g, (Polygon)))] if not invalid.empty: raise TypeError( f"Found non-polygon geometries: {invalid.geometry.geom_type.unique().tolist()}" )