Source code for sepal_ui.mapping.inspector_control

"""Customized ``Control`` to display the value of all available layers on a specific pixel."""

import json
from pathlib import Path
from typing import Optional, Sequence, Union

import ee
import geopandas as gpd
import ipyvuetify as v
import rasterio as rio
import rioxarray
from deprecated.sphinx import deprecated
from ipyleaflet import GeoJSON, Map, Marker
from rasterio.crs import CRS
from shapely import geometry as sg
from traitlets import Bool

from sepal_ui import color
from sepal_ui import sepalwidgets as sw
from sepal_ui.frontend import styles as ss
from sepal_ui.mapping.layer import EELayer
from sepal_ui.mapping.menu_control import MenuControl
from sepal_ui.message import ms
from sepal_ui.scripts import decorator as sd


[docs] class InspectorControl(MenuControl): m: Optional[Map] = None "the map on which he vinspector is displayed to interact with it's layers" w_loading: Optional[v.ProgressLinear] = None "The progress bar on top of the Card" menu: Optional[v.Menu] = None "The menu displayed when the map btn is clicked" text: Optional[v.CardText] = None "The text element from the card that is edited when the user click on the map" open_tree: Bool = Bool(True).tag(sync=True) "Either or not the tree should be opened automatically" marker: Optional[Marker] = None "The marker of the last visited point"
[docs] def __init__(self, m: Map, open_tree: bool = True, **kwargs) -> None: """Widget control displaying a btn on the map. When clicked the menu expand to show the values of each layer available on the map. The menu values will be change when the user click on a location on the map. It can digest any Layer added on a SepalMap. Args: m: the map on which he vinspector is displayed to interact with it's layers """ # set traits self.open_tree = open_tree # set some default parameters kwargs.setdefault("position", "topleft") kwargs["m"] = m # create a loading to place it on top of the card. It will always be visible # even when the card is scrolled p_style = json.loads((ss.JSON_DIR / "progress_bar.json").read_text()) self.w_loading = sw.ProgressLinear( indeterminate=False, background_color=color.menu, color=p_style["color"][v.theme.dark], ) # set up the content title = sw.CardTitle(children=[ms.inspector_control.title]) self.text = sw.CardText(children=[ms.inspector_control.landing]) # create the menu widget super().__init__("fa-solid fa-crosshairs", self.text, title, **kwargs) # avoid closing the inspector when clicking on the map self.menu.close_on_click = False # create a marker outside of the map [91, 181] and hide it self.marker = Marker(location=[91, 181], draggable=False, visible=False) self.m.add_layer(self.marker) # adapt the size self.set_size(min_height=0) # add js behaviour self.menu.observe(self.toggle_cursor, "v_model") self.m.on_interaction(self.read_data)
[docs] def toggle_cursor(self, *args) -> None: """Toggle the cursor and marker display. Toggle the cursor on the map to notify to the user that the inspector mode is activated. also activate previous marker if the inspector already include data. """ cursors = [{"cursor": "grab"}, {"cursor": "crosshair"}] self.m.default_style = cursors[self.menu.v_model] self.marker.visible = self.menu.v_model return
[docs] def read_data(self, **kwargs) -> None: """Read the data when the map is clicked with the vinspector activated. Args: kwargs: any arguments from the map interaction """ # check if the v_inspector is active is_click = kwargs.get("type") == "click" is_active = self.menu.v_model is True if not (is_click and is_active): return # set the loading mode. Cannot be done as a decorator to avoid # flickering while moving the cursor on the map self.w_loading.indeterminate = True self.m.default_style = {"cursor": "wait"} # init the text children children = [] # get the coordinates as (x, y) lng, lat = coords = [c for c in reversed(kwargs.get("coordinates"))] # write the coordinates and the scale txt = ms.inspector_control.coords.format(round(self.m.get_scale())) children.append(sw.Html(tag="h4", children=[txt])) children.append(sw.Html(tag="p", children=[f"[{lng:.3f}, {lat:.3f}]"])) # wrap layer data in a treeview widget tree_view = sw.Treeview(hoverable=True, dense=True, open_on_click=True) children.append(sw.Html(tag="h4", children=[ms.inspector_control.layers])) children.append(tree_view) # write the layers data items, layers = [], [lyr for lyr in self.m.layers if not lyr.base] for i, lyr in enumerate(layers): if isinstance(lyr, EELayer): data = self._from_eelayer(lyr.ee_object, coords) elif isinstance(lyr, GeoJSON): data = self._from_geojson(lyr.data, coords) elif type(lyr).__name__ == "BoundTileLayer": data = self._from_raster(lyr.raster, coords) elif isinstance(lyr, Marker): continue else: data = { ms.inspector_control.info.header: ms.inspector_control.info.text } items.append( { "id": str(i), "name": lyr.name, "children": [{"name": f"{k}: {v}"} for k, v in data.items()], } ) tree_view.items = items tree_view.open_ = "0" if self.open_tree else "" # set them in the card self.text.children = children # place a marker on the right coordinates self.marker.location = [lat, lng] # set back the cursor to crosshair self.w_loading.indeterminate = False self.m.default_style = {"cursor": "crosshair"} # one last flicker to replace the menu next to the btn # if not it goes below the map # I've try playing with the styles but it didn't worked out well # lost hours on this issue : 2h self.menu.v_model = False self.menu.v_model = True return
@sd.need_ee def _from_eelayer(self, ee_obj: ee.ComputedObject, coords: Sequence[float]) -> dict: """Extract the values of the ee_object for the considered point. Args: ee_obj: the ee object to reduce to a single point coords: the coordinates of the point (lng, lat). Returns: tke value associated to the image/feature names """ # create a gee point ee_point = ee.Geometry.Point(*coords) if isinstance(ee_obj, ee.FeatureCollection): # filter all the value to the point features = ee_obj.filterBounds(ee_point) # if there is none, print non for every property if features.size().getInfo() == 0: cols = ee_obj.first().propertyNames().getInfo() pixel_values = {c: None for c in cols if c not in ["system:index"]} # else simply return all the values of the first element else: pixel_values = features.first().toDictionary().getInfo() elif isinstance(ee_obj, ee.Image): # reduce the layer region using mean pixel_values = ee_obj.reduceRegion( geometry=ee_point, scale=self.m.get_scale(), reducer=ee.Reducer.mean(), ).getInfo() else: raise ValueError( f'the layer object is a "{type(ee_obj)}" which is not accepted.' ) return pixel_values def _from_geojson(self, data: dict, coords: Sequence[float]) -> dict: """Extract the values of the data for the considered point. Args: data: the shape to reduce to a single point coords: the coordinates of the point (lng, lat). Returns: The value associated to the feature names """ # extract the coordinates as a poin point = sg.Point(*coords) # filter the data to 1 point gdf = gpd.GeoDataFrame.from_features(data) gdf_filtered = gdf[gdf.contains(point)] skip_cols = ["geometry", "style"] # only display the columns name if empty if len(gdf_filtered) == 0: cols = gdf.columns.to_list() return {c: None for c in cols if c not in skip_cols} # else print the values of the first element else: return gdf_filtered.iloc[0, ~gdf.columns.isin(skip_cols)].to_dict() def _from_raster(self, raster: Union[str, Path], coords: Sequence[float]) -> dict: """Extract the values of the data-array for the considered point. Args: raster: the path to the image to reduce to a single point coords: the coordinates of the point (lng, lat). Returns: The value associated to the feature names """ # extract the coordinates as a point point = sg.Point(*coords) # extract the pixel size in degrees (equatorial approximation) scale = self.m.get_scale() * 0.00001 # open the image and unproject it da = rioxarray.open_rasterio(raster, masked=True) da = da.chunk((1000, 1000)) if da.rio.crs != CRS.from_string("EPSG:4326"): da = da.rio.reproject("EPSG:4326") # sample is not available for da so I do as in GEE a mean reducer around 1px # is it an overkill ? yes if sg.box(*da.rio.bounds()).contains(point): bounds = point.buffer(scale).bounds window = rio.windows.from_bounds(*bounds, transform=da.rio.transform()) da_filtered = da.rio.isel_window(window) means = da_filtered.mean(axis=(1, 2)).to_numpy() pixel_values = { ms.inspector_control.band.format(i + 1): v for i, v in enumerate(means) } # if the point is out of the image display None else: pixel_values = { ms.inspector_control.band.format(i + 1): None for i in range(da.rio.count) } return pixel_values
[docs] @deprecated( version="2.15.1", reason="ValueInspector class is now renamed InspectorControl" ) class ValueInspector(InspectorControl): pass