Source code for voxcity.geoprocessor.draw.edit_landcover

"""
Interactive land-cover editor for ipyleaflet maps.

Provides:
- edit_landcover: Full interactive editor (paint cells by click or area polygon)
"""

from __future__ import annotations

import math
import time

import numpy as np
import shapely.geometry as geom
from ipyleaflet import (
    Map,
    Polygon as LeafletPolygon,
    Polyline,
    WidgetControl,
    Circle,
    GeoJSON,
)
from ipywidgets import (
    VBox,
    HBox,
    Button,
    HTML,
    Checkbox,
    ToggleButton,
    Layout,
    Dropdown,
)

from ._common import (
    LC_COLORS_BY_NAME,
    create_basemap_tiles,
    compute_grid_geometry,
    build_building_geojson,
    build_canopy_geojson,
    build_lc_geojson,
    build_lc_legend_html,
    get_lc_source_colors,
    lc_style_callback,
    generate_editor_css,
    geo_to_cell,
    is_near_first,
    clear_area_draw,
    refresh_area_markers,
    handle_area_mousemove,
    make_layer_toggle,
    make_status_setter,
)

# Import VoxCity for type checking
try:
    from typing import TYPE_CHECKING

    if TYPE_CHECKING:
        from ...models import VoxCity
except ImportError:
    pass


[docs] def edit_landcover(voxcity=None, initial_center=None, zoom=17): """ Interactive map editor for land-cover classes. Users can select a land-cover class from the palette and paint individual cells by clicking or in bulk by drawing an area polygon. Args: voxcity (VoxCity, optional): VoxCity object for data extraction. initial_center (tuple, optional): (lon, lat) for initial map centre. zoom (int): Initial zoom level. Default=17. Returns: tuple: (map_object, land_cover_classes np.ndarray) """ # --- Data extraction --- land_cover = None rectangle_vertices = None building_gdf = None land_cover_source = None canopy_top_lc = None if voxcity is not None: if voxcity.land_cover is not None and voxcity.land_cover.classes is not None: land_cover = voxcity.land_cover.classes.copy() rectangle_vertices = voxcity.extras.get("rectangle_vertices", None) building_gdf = voxcity.extras.get("building_gdf", None) land_cover_source = voxcity.extras.get("land_cover_source", None) if land_cover_source is None: selected = voxcity.extras.get("selected_sources", {}) land_cover_source = selected.get("land_cover_source", "OpenStreetMap") if voxcity.tree_canopy is not None and voxcity.tree_canopy.top is not None: canopy_top_lc = voxcity.tree_canopy.top.copy() if land_cover is None: raise ValueError("VoxCity object must contain a land_cover grid.") # --- Class lookup --- from ...utils.lc import get_land_cover_classes _src_classes = get_land_cover_classes(land_cover_source) _class_names = list(dict.fromkeys(_src_classes.values())) _LC_NAMES = {i: name for i, name in enumerate(_class_names)} _name_to_hex = get_lc_source_colors(land_cover_source) _LC_COLORS = {i: _name_to_hex.get(name, "#808080") for i, name in enumerate(_class_names)} _num_classes = len(_class_names) _NON_EDITABLE_NAMES = {"Tree", "Tree Canopy", "Trees", "Building", "Built Area", "Built-up", "Built"} _EDITABLE_CLASSES = [i for i, name in enumerate(_class_names) if name not in _NON_EDITABLE_NAMES] # --- Grid geometry --- _lc_grid_geom = None if rectangle_vertices is not None and voxcity is not None: _lc_grid_geom = compute_grid_geometry(rectangle_vertices, voxcity.land_cover.meta.meshsize) # --- GeoJSON builders --- def _build_lc_geojson_local(): return build_lc_geojson(land_cover, _lc_grid_geom, land_cover_source) # --- Map centre --- if initial_center is not None: center_lon, center_lat = initial_center elif rectangle_vertices is not None: center_lon = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2 center_lat = (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2 else: center_lon, center_lat = -100.0, 40.0 # --- Create map --- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True) m.layout.height = "600px" _basemap_tiles = create_basemap_tiles() _current_basemap = [_basemap_tiles["Google Satellite"]] m.layers = tuple([_current_basemap[0]]) # Rectangle outline if rectangle_vertices is not None and len(rectangle_vertices) >= 4: try: lat_lon_coords = [(lat, lon) for lon, lat in rectangle_vertices] rect_outline = LeafletPolygon( locations=lat_lon_coords, color="#fed766", weight=2, fill_color="#fed766", fill_opacity=0.0, ) m.add_layer(rect_outline) except Exception: pass # --- Overlays --- _overlay_layers = {} buildings_overlay = GeoJSON( data=build_building_geojson(building_gdf), style={"color": "#1565c0", "fillColor": "#42a5f5", "fillOpacity": 0.35, "weight": 1}, ) _overlay_layers["Buildings"] = [buildings_overlay, False] # Tree / canopy overlay _canopy_grid_geom_lc = None if canopy_top_lc is not None and rectangle_vertices is not None and voxcity is not None: _canopy_grid_geom_lc = compute_grid_geometry(rectangle_vertices, voxcity.tree_canopy.meta.meshsize) if canopy_top_lc is not None and _canopy_grid_geom_lc is not None: _tree_overlay = GeoJSON( data=build_canopy_geojson(canopy_top_lc, _canopy_grid_geom_lc), style={"color": "#00ff7f", "fillColor": "#00ff7f", "fillOpacity": 0.35, "weight": 0.5}, ) _overlay_layers["Trees"] = [_tree_overlay, False] # Land-cover overlay (always visible) lc_overlay = GeoJSON(data=_build_lc_geojson_local(), style_callback=lc_style_callback) m.add_layer(lc_overlay) # --- UI --- # Generate per-class CSS for colored palette buttons # add_class() puts the class on the <button> element itself, so use # .jupyter-button.gm-lc-cN (same element) not > (child combinator). _palette_css_parts = [] for _cls in range(len(_class_names)): _c = _LC_COLORS.get(_cls, "#808080") _r, _g, _b = int(_c[1:3], 16), int(_c[3:5], 16), int(_c[5:7], 16) _lum = 0.299 * _r + 0.587 * _g + 0.114 * _b _tc = "#fff" if _lum < 140 else "#1f1f1f" _palette_css_parts.append( f" .gm-lc-root .jupyter-button.gm-lc-c{_cls}:not(#_) {{" f" background:{_c} !important;color:{_tc} !important;" f" border-color:rgba(0,0,0,0.18) !important;}}" ) # Selection ring for colored buttons _palette_css_parts.append( " .gm-lc-root .jupyter-button[class*='gm-lc-c'].mod-success:not(#_)" " { outline:2px solid #1a73e8;outline-offset:-2px; }" ) _palette_css_parts.append( " .gm-lc-root .jupyter-button[class*='gm-lc-c']:hover:not(#_)" " { filter:brightness(0.92) !important; }" ) _palette_extra_css = "\n".join(_palette_css_parts) style_html = HTML(generate_editor_css( "gm-lc-root", drawing_mode_class="lc-drawing-mode", delete_mode_class="lc-delete-mode", extra_css=_palette_extra_css, )) _selected_class = [_EDITABLE_CLASSES[0]] class_buttons = [] for cls_code in _EDITABLE_CLASSES: name = _LC_NAMES.get(cls_code, "?") btn = Button( description=name, layout=Layout(width="auto", height="20px", padding="0 4px"), button_style="success" if cls_code == _selected_class[0] else "", ) btn._lc_code = cls_code btn.add_class(f"gm-lc-c{cls_code}") class_buttons.append(btn) def _on_class_button(b): _selected_class[0] = b._lc_code for cb in class_buttons: cb.button_style = "success" if cb._lc_code == _selected_class[0] else "" _set_status(f"Selected: {_LC_NAMES.get(b._lc_code, '?')}", "info") for btn in class_buttons: btn.on_click(_on_class_button) _class_rows = [] for i in range(0, len(class_buttons), 3): row = class_buttons[i : i + 3] _class_rows.append(HBox(row, layout=Layout(gap="3px", margin="1px 0"))) class_palette = VBox(_class_rows, layout=Layout(margin="0", gap="0")) paint_click_button = Button(description="Click", button_style="success", layout=Layout(flex="1", height="26px")) paint_area_button = ToggleButton( value=False, description="Area", button_style="", layout=Layout(flex="1", height="26px"), tooltip="Draw polygon to paint area", ) status_bar = HTML( value="<div style='padding:3px 8px;border-radius:10px;font-size:10px;" "text-align:center;background:#f0f4ff;color:#1a73e8;'>Ready</div>" ) hover_info = HTML("") _set_status = make_status_setter(status_bar, use_gm_class=False) paint_row = HBox([paint_click_button, paint_area_button], layout=Layout(margin="0", gap="4px")) # Basemap switcher basemap_dropdown = Dropdown( options=list(_basemap_tiles.keys()), value="Google Satellite", layout=Layout(width="140px", height="26px"), ) def _on_basemap_change(change): name = change["new"] new_tile = _basemap_tiles[name] layers = list(m.layers) layers[0] = new_tile m.layers = tuple(layers) _current_basemap[0] = new_tile basemap_dropdown.observe(_on_basemap_change, names="value") # Layer toggles _layer_checkboxes = [] for _lyr_name, (_lyr_obj, _lyr_on) in _overlay_layers.items(): cb = Checkbox( value=_lyr_on, description=_lyr_name, indent=False, layout=Layout(width="auto", height="20px"), ) cb.observe(make_layer_toggle(m, _overlay_layers, _lyr_name, _lyr_obj, front_layers=[lc_overlay]), names="value") _layer_checkboxes.append(cb) _layer_widgets = [ HTML("<div class='gm-sep' style='margin:4px 0;'></div>"), HTML("<div class='gm-label' style='margin:0 0 2px 0;'>Basemap</div>"), basemap_dropdown, ] if _layer_checkboxes: _layer_widgets.append(HTML("<div class='gm-label' style='margin:4px 0 2px 0;'>Layers</div>")) _layer_widgets.extend(_layer_checkboxes) panel = VBox( [ style_html, HTML("<div class='gm-title' style='margin-bottom:4px;padding-bottom:4px;'>Land Cover Editor</div>"), HTML("<div class='gm-label' style='margin:0 0 2px 0;'>Class</div>"), class_palette, HTML("<div class='gm-sep' style='margin:4px 0;'></div>"), HTML("<div class='gm-label' style='margin:0 0 2px 0;'>Paint</div>"), paint_row, status_bar, hover_info, ] + _layer_widgets, layout=Layout(width="220px", padding="8px 10px"), ) panel.add_class("gm-lc-root") card = VBox( [panel], layout=Layout( background_color="white", border_radius="16px", box_shadow="0 1px 3px rgba(0,0,0,0.1), 0 4px 16px rgba(0,0,0,0.06)", overflow="hidden", ), ) m.add_control(WidgetControl(widget=card, position="topright")) # --- State --- mode = "paint_click" _area_state = {"clicks": [], "layers": [], "preview": None} _last_area_click = [0.0] _last_mousemove = [0.0] _toggling = [False] def _refresh_lc_overlay(): lc_overlay.data = _build_lc_geojson_local() def _paint_cell(lon, lat): ci, cj = geo_to_cell(lon, lat, _lc_grid_geom, land_cover.shape) if ci is None: _set_status("Outside grid", "warn") return cls_val = _selected_class[0] land_cover[ci, cj] = cls_val _refresh_lc_overlay() name = _LC_NAMES.get(cls_val, "?") _set_status(f"Painted ({ci},{cj}) \u2192 {name}", "success") def _execute_area_paint(polygon_coords): from matplotlib.path import Path as _MplPath cls_val = _selected_class[0] painted = 0 if _lc_grid_geom is not None: origin = _lc_grid_geom["origin"] u = _lc_grid_geom["u_vec"] v = _lc_grid_geom["v_vec"] dx = _lc_grid_geom["adj_mesh"][0] dy = _lc_grid_geom["adj_mesh"][1] nx, ny = land_cover.shape ii_all = np.arange(nx) jj_all = np.arange(ny) ii_grid, jj_grid = np.meshgrid(ii_all, jj_all, indexing="ij") ii_flat = ii_grid.ravel() jj_flat = jj_grid.ravel() centers = origin + np.outer((ii_flat + 0.5) * dx, u) + np.outer((jj_flat + 0.5) * dy, v) mpl_path = _MplPath(polygon_coords) inside = mpl_path.contains_points(centers) if np.any(inside): painted = int(inside.sum()) land_cover[ii_flat[inside], jj_flat[inside]] = cls_val _refresh_lc_overlay() clear_area_draw(m, _area_state) m.double_click_zoom = True name = _LC_NAMES.get(cls_val, "?") if painted > 0: _set_status(f"Painted {painted} cell(s) \u2192 {name}", "success") else: _set_status("No cells in drawn area", "warn") # --- Mode management --- def _update_button_styles(): paint_click_button.button_style = "success" if mode == "paint_click" else "" paint_area_button.button_style = "success" if mode == "paint_area" else "" paint_area_button.value = (mode == "paint_area") def set_mode(new_mode): nonlocal mode mode = new_mode clear_area_draw(m, _area_state) if new_mode == "paint_area": m.double_click_zoom = False m.default_style = {"cursor": "crosshair"} m.add_class("lc-drawing-mode") else: m.double_click_zoom = True m.default_style = {"cursor": "grab"} m.remove_class("lc-drawing-mode") _toggling[0] = True _update_button_styles() _toggling[0] = False if mode == "paint_click": _set_status("Click map to paint cell", "info") elif mode == "paint_area": _set_status("Draw polygon to paint area", "info") def on_click_paint_click(b): set_mode("paint_click") def on_paint_area_toggle(change): if _toggling[0] or change["name"] != "value": return _toggling[0] = True set_mode("paint_area" if change["new"] else "paint_click") _toggling[0] = False paint_click_button.on_click(on_click_paint_click) paint_area_button.observe(on_paint_area_toggle, names="value") # --- Map interaction --- def handle_map_click(**kwargs): # Area polygon drawing if mode == "paint_area": _area_color = "#1a73e8" if kwargs.get("type") == "dblclick": pts = _area_state["clicks"] if len(pts) >= 3: _execute_area_paint(pts) else: _set_status("Need at least 3 points", "warn") return elif kwargs.get("type") == "click": now = time.time() if now - _last_area_click[0] < 0.3: return _last_area_click[0] = now coords = kwargs.get("coordinates") if not coords: return lat, lon = coords if is_near_first(_area_state["clicks"], lon, lat): _execute_area_paint(_area_state["clicks"]) return _area_state["clicks"].append((lon, lat)) refresh_area_markers(m, _area_state, _area_color) n = len(_area_state["clicks"]) _set_status(f"{n} point(s) \u2014 click first point to close", "info") elif kwargs.get("type") == "mousemove": now = time.time() if now - _last_mousemove[0] < 0.05: return _last_mousemove[0] = now coords = kwargs.get("coordinates") if coords: handle_area_mousemove(m, _area_state, coords, _area_color) return # Click painting if kwargs.get("type") == "click": lat, lon = kwargs.get("coordinates", (None, None)) if lat is None or lon is None: return if mode == "paint_click": _paint_cell(lon, lat) elif kwargs.get("type") == "mousemove": lat, lon = kwargs.get("coordinates", (None, None)) if lat is None or lon is None: return ci, cj = geo_to_cell(lon, lat, _lc_grid_geom, land_cover.shape) if ci is not None: cls_val = int(land_cover[ci, cj]) name = _LC_NAMES.get(cls_val, f"Code {cls_val}") color = _LC_COLORS.get(cls_val, "#808080") hover_info.value = ( f"<div class='gm-hover'>" f"<span style='display:inline-block;width:10px;height:10px;" f"border-radius:2px;background:{color};margin-right:4px;'></span>" f"({ci},{cj}) {name}" f"</div>" ) else: hover_info.value = "" m.on_interaction(handle_map_click) return m, land_cover