Source code for cityImage.landuse.sparse

"""Sparse/non-OSM land-use helpers.

This module is intentionally separate from the OSM land-use pipeline.

Use this route when land-use evidence comes from:
- a sparse attribute already attached to building footprints;
- a municipal / survey / classified land-use layer;
- any non-OSM source with labels that need to be mapped to the same
  macro-groups consumed downstream by landmark/pragmatic scoring.

The expected downstream schema is:

    land_uses          list[str]
    land_uses_overlap  list[float]

`land_uses_overlap` is optional for some consumers. When it is present, it
should align 1:1 with `land_uses` and sum to 1 per row.
"""

from __future__ import annotations

from collections import defaultdict
from typing import Any

import geopandas as gpd
import numpy as np
import pandas as pd

from .utils import _to_list

NULL_STRINGS = {"", "none", "nan", "null", "na", "n/a"}


def _clean_label(value: Any) -> str | None:
    """Normalize one sparse land-use label to a comparable string."""
    if value is None:
        return None

    try:
        if pd.isna(value):
            return None
    except Exception:
        pass

    label = str(value).strip()
    if label.lower() in NULL_STRINGS:
        return None

    return label


def _mapping_lookup(mapping: dict[Any, Any] | None) -> dict[str, Any]:
    """Build a case-insensitive mapping lookup."""
    if mapping is None:
        return {}

    lookup: dict[str, Any] = {}
    for raw, target in mapping.items():
        cleaned = _clean_label(raw)
        if cleaned is not None:
            lookup[cleaned] = target
            lookup[cleaned.lower()] = target
    return lookup


def _mapped_groups(
    value: Any,
    lookup: dict[str, Any],
    *,
    default_land_use: str,
    preserve_unmapped: bool,
) -> list[str]:
    """Map one raw value to one or more target macro-groups."""
    cleaned = _clean_label(value)
    if cleaned is None:
        return []

    mapped = lookup.get(cleaned, lookup.get(cleaned.lower()))

    if mapped is None:
        mapped = cleaned if preserve_unmapped else default_land_use

    groups: list[str] = []
    seen: set[str] = set()

    for item in _to_list(mapped):
        label = _clean_label(item)
        if label is None or label in seen:
            continue
        seen.add(label)
        groups.append(label)

    return groups


[docs] def classify_sparse_land_uses( buildings_gdf: gpd.GeoDataFrame, source_column: str, target_column: str = "land_uses", mapping: dict[Any, Any] | None = None, default_land_use: str = "unknown", preserve_unmapped: bool = True, ) -> gpd.GeoDataFrame: """Classify sparse/non-OSM land-use attributes attached to buildings. The output column is always a list[str] column so it can feed directly into `pragmatic_score`. """ if source_column not in buildings_gdf.columns: raise ValueError(f"GeoDataFrame must contain '{source_column}'") gdf = buildings_gdf.copy() lookup = _mapping_lookup(mapping) def _classify_cell(cell: Any) -> list[str]: groups: list[str] = [] seen: set[str] = set() for raw_value in _to_list(cell): for group in _mapped_groups( raw_value, lookup, default_land_use=default_land_use, preserve_unmapped=preserve_unmapped, ): if group not in seen: seen.add(group) groups.append(group) return groups or [default_land_use] gdf[target_column] = gdf[source_column].apply(_classify_cell) return gdf
def _normalise_weight_dict( weights: dict[str, float], default_land_use: str, ) -> tuple[list[str], list[float]]: """Return stable labels and normalised weights from a group -> mass dict.""" clean_weights = { group: float(weight) for group, weight in weights.items() if group and np.isfinite(float(weight)) and float(weight) > 0 } if not clean_weights: return [default_land_use], [1.0] total = sum(clean_weights.values()) labels = list(clean_weights.keys()) overlaps = [clean_weights[label] / total for label in labels] return labels, overlaps
[docs] def attach_sparse_land_uses( buildings_gdf: gpd.GeoDataFrame, land_uses_gdf: gpd.GeoDataFrame, source_column: str, target_column: str = "land_uses", overlaps_column: str = "land_uses_overlap", mapping: dict[Any, Any] | None = None, default_land_use: str = "unknown", preserve_unmapped: bool = True, min_overlap_area: float = 0.0, ) -> gpd.GeoDataFrame: """Spatially attach sparse/non-OSM land-use labels to buildings. For polygon land-use layers, overlap weights are based on intersection area. For point/line layers, weights are based on intersecting feature counts. """ if source_column not in land_uses_gdf.columns: raise ValueError(f"land_uses_gdf must contain '{source_column}'") if ( buildings_gdf.crs is not None and land_uses_gdf.crs is not None and buildings_gdf.crs != land_uses_gdf.crs ): raise ValueError("CRS mismatch: buildings_gdf and land_uses_gdf must have the same CRS") buildings = buildings_gdf.copy() buildings["_ci_sparse_row"] = range(len(buildings)) # Force object dtype so nested lists are stored as scalar cell values. buildings[target_column] = pd.Series( [[default_land_use] for _ in range(len(buildings))], index=buildings.index, dtype="object", ) buildings[overlaps_column] = pd.Series( [[1.0] for _ in range(len(buildings))], index=buildings.index, dtype="object", ) if buildings.empty or land_uses_gdf.empty: return buildings.drop(columns=["_ci_sparse_row"], errors="ignore") land_uses = land_uses_gdf[[source_column, "geometry"]].copy() land_uses = land_uses[land_uses.geometry.notna()].copy() if land_uses.empty: return buildings.drop(columns=["_ci_sparse_row"], errors="ignore") lookup = _mapping_lookup(mapping) geom_types = set(land_uses.geometry.geom_type.dropna().unique()) area_like = bool(geom_types) and all("Polygon" in geom_type for geom_type in geom_types) if area_like: intersections = gpd.overlay( buildings[["_ci_sparse_row", "geometry"]], land_uses[[source_column, "geometry"]], how="intersection", keep_geom_type=False, ) if intersections.empty: return buildings.drop(columns=["_ci_sparse_row"], errors="ignore") intersections["_ci_weight"] = intersections.geometry.area.astype(float) intersections = intersections[intersections["_ci_weight"] > min_overlap_area] else: try: intersections = gpd.sjoin( buildings[["_ci_sparse_row", "geometry"]], land_uses[[source_column, "geometry"]], how="inner", predicate="intersects", ) except TypeError: intersections = gpd.sjoin( buildings[["_ci_sparse_row", "geometry"]], land_uses[[source_column, "geometry"]], how="inner", op="intersects", ) if intersections.empty: return buildings.drop(columns=["_ci_sparse_row"], errors="ignore") intersections["_ci_weight"] = 1.0 grouped_weights: dict[int, dict[str, float]] = defaultdict(lambda: defaultdict(float)) for _, row in intersections.iterrows(): row_id = int(row["_ci_sparse_row"]) raw_weight = float(row["_ci_weight"]) groups = _mapped_groups( row[source_column], lookup, default_land_use=default_land_use, preserve_unmapped=preserve_unmapped, ) if not groups: continue split_weight = raw_weight / len(groups) for group in groups: grouped_weights[row_id][group] += split_weight for row_id, weights in grouped_weights.items(): labels, overlaps = _normalise_weight_dict(weights, default_land_use) matched_index = buildings.index[buildings["_ci_sparse_row"] == row_id] if len(matched_index) == 0: continue # Use scalar .at assignment. Do not use: # buildings.loc[mask, column] = [labels] # because pandas may treat the nested list as a broadcastable ndarray. idx = matched_index[0] buildings.at[idx, target_column] = labels buildings.at[idx, overlaps_column] = overlaps return buildings.drop(columns=["_ci_sparse_row"], errors="ignore")