diff --git a/geemap/ee_tile_layers.py b/geemap/ee_tile_layers.py index 2cf728ad02..395ca10488 100644 --- a/geemap/ee_tile_layers.py +++ b/geemap/ee_tile_layers.py @@ -9,6 +9,7 @@ import ee import folium import ipyleaflet +from functools import lru_cache from . import common @@ -139,6 +140,7 @@ def __init__( shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. opacity (float, optional): The layer's opacity represented as a number between 0 and 1. Defaults to 1. """ + self._ee_object = ee_object self.url_format = _get_tile_url_format( ee_object, _validate_vis_params(vis_params) ) @@ -151,3 +153,79 @@ def __init__( max_zoom=24, **kwargs, ) + + @lru_cache() + def _calculate_vis_stats(self, *, bounds, bands): + """Calculate stats used for visualization parameters. + + Stats are calculated consistently with the Code Editor visualization parameters, + and are cached to avoid recomputing for the same bounds and bands. + + Args: + bounds (ee.Geometry|ee.Feature|ee.FeatureCollection): The bounds to sample. + bands (tuple): The bands to sample. + + Returns: + tuple: The minimum, maximum, standard deviation, and mean values across the + specified bands. + """ + stat_reducer = (ee.Reducer.minMax() + .combine(ee.Reducer.mean().unweighted(), sharedInputs=True) + .combine(ee.Reducer.stdDev(), sharedInputs=True)) + + stats = self._ee_object.select(bands).reduceRegion( + reducer=stat_reducer, + geometry=bounds, + bestEffort=True, + maxPixels=10_000, + crs="SR-ORG:6627", + scale=1, + ).getInfo() + + mins, maxs, stds, means = [ + {v for k, v in stats.items() if k.endswith(stat) and v is not None} + for stat in ('_min', '_max', '_stdDev', '_mean') + ] + if any(len(vals) == 0 for vals in (mins, maxs, stds, means)): + raise ValueError('No unmasked pixels were sampled.') + + min_val = min(mins) + max_val = max(maxs) + std_dev = sum(stds) / len(stds) + mean = sum(means) / len(means) + + return (min_val, max_val, std_dev, mean) + + def calculate_vis_minmax(self, *, bounds, bands=None, percent=None, sigma=None): + """Calculate the min and max clip values for visualization. + + Args: + bounds (ee.Geometry|ee.Feature|ee.FeatureCollection): The bounds to sample. + bands (list, optional): The bands to sample. If None, all bands are used. + percent (float, optional): The percent to use when stretching. + sigma (float, optional): The number of standard deviations to use when + stretching. + + Returns: + tuple: The minimum and maximum values to clip to. + """ + bands = self._ee_object.bandNames() if bands is None else tuple(bands) + try: + min_val, max_val, std, mean = self._calculate_vis_stats( + bounds=bounds, bands=bands + ) + except ValueError: + return (0, 0) + + if sigma is not None: + stretch_min = mean - sigma * std + stretch_max = mean + sigma * std + elif percent is not None: + x = (max_val - min_val) * (1 - percent) + stretch_min = min_val + x + stretch_max = max_val - x + else: + stretch_min = min_val + stretch_max = max_val + + return (stretch_min, stretch_max) diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index 08a5944d35..fd073112af 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -1152,6 +1152,30 @@ def __init__(self, host_map, layer_dict): style={"description_width": "initial"}, ) + self._stretch_dropdown = ipywidgets.Dropdown( + options={ + "Custom": {}, + "1 σ": {"sigma": 1}, + "2 σ": {"sigma": 2}, + "3 σ": {"sigma": 3}, + "90%": {"percent": 0.90}, + "98%": {"percent": 0.98}, + "100%": {"percent": 1.0}, + }, + description="Stretch:", + layout=ipywidgets.Layout(width="260px"), + style={"description_width": "initial"}, + ) + + self._stretch_button = ipywidgets.Button( + disabled=True, + tooltip="Re-calculate stretch", + layout=ipywidgets.Layout(width="36px"), + icon="refresh", + ) + self._stretch_dropdown.observe(self._value_stretch_changed, names="value") + self._stretch_button.on_click(self._update_stretch) + self._value_range_slider = ipywidgets.FloatRangeSlider( value=[self._min_value, self._max_value], min=self._left_value, @@ -1230,6 +1254,9 @@ def __init__(self, host_map, layer_dict): style={"description_width": "initial"}, ) + self._stretch_hbox = ipywidgets.HBox( + [self._stretch_dropdown, self._stretch_button] + ) self._colormap_hbox = ipywidgets.HBox( [self._linear_checkbox, self._step_checkbox] ) @@ -1276,17 +1303,53 @@ def __init__(self, host_map, layer_dict): layout=ipywidgets.Layout( padding="5px 0px 5px 8px", # top, right, bottom, left # width="330px", - max_height="280px", + max_height="305px", overflow="auto", display="block", ), children=children, ) + def _value_stretch_changed(self, value): + """Apply the selected stretch option and update widget states.""" + stretch_option = value["new"] + + if stretch_option: + self._stretch_button.disabled = False + self._value_range_slider.disabled = True + self._update_stretch() + else: + self._stretch_button.disabled = True + self._value_range_slider.disabled = False + + def _update_stretch(self, *_): + """Calculate and set the range slider by applying stretch parameters.""" + stretch_params = self._stretch_dropdown.value + + (s, w), (n, e) = self._host_map.bounds + map_bbox = ee.Geometry.BBox(west=w, south=s, east=e, north=n) + vis_bands = set((b.value for b in self._bands_hbox.children)) + min_val, max_val = self._ee_layer.calculate_vis_minmax( + bounds=map_bbox, + bands=vis_bands, + **stretch_params + ) + + # Update in the correct order to avoid setting an invalid range + if min_val > self._value_range_slider.max: + self._value_range_slider.max = max_val + self._value_range_slider.min = min_val + else: + self._value_range_slider.min = min_val + self._value_range_slider.max = max_val + + self._value_range_slider.value = [min_val, max_val] + def _get_tool_layout(self, grayscale): return [ ipywidgets.HBox([self._greyscale_radio_button, self._rgb_radio_button]), self._bands_hbox, + self._stretch_hbox, self._value_range_slider, self._opacity_slider, self._gamma_slider, diff --git a/mkdocs.yml b/mkdocs.yml index 356efa743e..e7b0a9ecc7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,7 +51,8 @@ plugins: allow_errors: false ignore: ["conf.py"] execute: False - execute_ignore: ["notebooks/*.ipynb", "workshops/*.ipynb"] + execute_ignore: + ["notebooks/*.ipynb", "workshops/*.ipynb", "changelog_update.py"] markdown_extensions: - admonition