From 2f7ee1e1727bd1de1c5ca9f7a3a57a9b3de5ad42 Mon Sep 17 00:00:00 2001 From: Aaron Zuspan Date: Mon, 30 Oct 2023 18:00:19 -0700 Subject: [PATCH 1/7] Implement the range stretch widget --- geemap/map_widgets.py | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index 08a5944d35..b4b683d3f0 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="264px"), + 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] ) @@ -1283,10 +1310,77 @@ def __init__(self, host_map, layer_dict): 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, *args): + """Calculate and set the range slider by applying stretch parameters.""" + stretch_params = self._stretch_dropdown.value + + min_val, max_val = self._calculate_stretch(**stretch_params) + self._value_range_slider.min = min_val + self._value_range_slider.max = max_val + self._value_range_slider.value = [min_val, max_val] + + def _calculate_stretch(self, percent=None, sigma=None): + """Calculate min and max stretch values for the raster image.""" + (s, w), (n, e) = self._host_map.bounds + map_bbox = ee.Geometry.BBox(west=w, south=s, east=e, north=n) + vis_bands = list(set((b.value for b in self._bands_hbox.children))) + + stat_reducer = (ee.Reducer.minMax() + .combine(ee.Reducer.mean().unweighted(), sharedInputs=True) + .combine(ee.Reducer.stdDev(), sharedInputs=True)) + + stats = self._ee_object.select(vis_bands).reduceRegion( + reducer=stat_reducer, + geometry=map_bbox, + 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)): + # No unmasked pixels were sampled + return (0, 0) + + min_val = min(mins) + max_val = max(maxs) + std_dev = sum(stds) / len(stds) + mean = sum(means) / len(means) + + if sigma is not None: + stretch_min = mean - sigma * std_dev + stretch_max = mean + sigma * std_dev + 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) + 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, From f2b3a6c8c270cbd0657936b7ebdba8ece1f4245e Mon Sep 17 00:00:00 2001 From: Aaron Zuspan Date: Mon, 30 Oct 2023 22:24:37 -0700 Subject: [PATCH 2/7] Refactor to move vis param calculation into EELeafletTileLayer --- geemap/ee_tile_layers.py | 78 ++++++++++++++++++++++++++++++++++++++++ geemap/map_widgets.py | 57 ++++++----------------------- 2 files changed, 88 insertions(+), 47 deletions(-) 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 b4b683d3f0..390f7bcdc3 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -1322,59 +1322,22 @@ def _value_stretch_changed(self, value): self._stretch_button.disabled = True self._value_range_slider.disabled = False - def _update_stretch(self, *args): + def _update_stretch(self, *_): """Calculate and set the range slider by applying stretch parameters.""" stretch_params = self._stretch_dropdown.value - min_val, max_val = self._calculate_stretch(**stretch_params) - self._value_range_slider.min = min_val - self._value_range_slider.max = max_val - self._value_range_slider.value = [min_val, max_val] - - def _calculate_stretch(self, percent=None, sigma=None): - """Calculate min and max stretch values for the raster image.""" (s, w), (n, e) = self._host_map.bounds map_bbox = ee.Geometry.BBox(west=w, south=s, east=e, north=n) - vis_bands = list(set((b.value for b in self._bands_hbox.children))) - - stat_reducer = (ee.Reducer.minMax() - .combine(ee.Reducer.mean().unweighted(), sharedInputs=True) - .combine(ee.Reducer.stdDev(), sharedInputs=True)) - - stats = self._ee_object.select(vis_bands).reduceRegion( - reducer=stat_reducer, - geometry=map_bbox, - 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)): - # No unmasked pixels were sampled - return (0, 0) - - min_val = min(mins) - max_val = max(maxs) - std_dev = sum(stds) / len(stds) - mean = sum(means) / len(means) - - if sigma is not None: - stretch_min = mean - sigma * std_dev - stretch_max = mean + sigma * std_dev - 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 + 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 + ) - return (stretch_min, stretch_max) + 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 [ From fffd06cc8beb0996570e0c68fb53e6403ede52c9 Mon Sep 17 00:00:00 2001 From: Aaron Zuspan Date: Mon, 30 Oct 2023 23:08:58 -0700 Subject: [PATCH 3/7] Prevent TraitError when new min > old max Setting the new min value before the new max value can lead to a TraitError in the case where the new min is greater than the old max. To prevent that, this checks for that case and reverses the order that attrs are set in if needed. --- geemap/map_widgets.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index 390f7bcdc3..10bdcc07dc 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -1335,8 +1335,14 @@ def _update_stretch(self, *_): **stretch_params ) - self._value_range_slider.min = min_val - self._value_range_slider.max = max_val + # 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): From 89aaf466f74891ee2a9d5aac7d6f03cec50893c6 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Tue, 31 Oct 2023 08:54:34 -0400 Subject: [PATCH 4/7] Replace Stamen.Terrain --- geemap/plotlymap.py | 2 +- geemap/toolbar.py | 2 +- tests/test_basemaps.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/geemap/plotlymap.py b/geemap/plotlymap.py index dde35ec2d0..dcbd31aaaa 100644 --- a/geemap/plotlymap.py +++ b/geemap/plotlymap.py @@ -717,7 +717,7 @@ def add_heatmap_demo(self, **kwargs): **kwargs, ) - self.add_basemap("Stamen.Terrain") + self.add_basemap("Esri.WorldTopoMap") self.add_trace(heatmap) def add_gdf( diff --git a/geemap/toolbar.py b/geemap/toolbar.py index 21cf8297d5..888489237b 100644 --- a/geemap/toolbar.py +++ b/geemap/toolbar.py @@ -4928,7 +4928,7 @@ def plotly_basemap_gui(canvas, map_min_width="78%", map_max_width="98%"): map_widget.layout.width = map_min_width - value = "Stamen.Terrain" + value = "Esri.WorldTopoMap" m.add_basemap(value) dropdown = widgets.Dropdown( diff --git a/tests/test_basemaps.py b/tests/test_basemaps.py index 16b7a5a3ad..14c8ed2a87 100644 --- a/tests/test_basemaps.py +++ b/tests/test_basemaps.py @@ -51,7 +51,7 @@ def test_xyz_to_leaflet_sources(self): expected_keys = { "custom_xyz": "OpenStreetMap", "custom_wms": "USGS NAIP Imagery", - "xyzservices_xyz": "Stamen.Terrain", + "xyzservices_xyz": "Esri.WorldTopoMap", } for _, expected_name in expected_keys.items(): self.assertIn(expected_name, self.tiles) From e68a8efcf239ac4ec039acfcfc8a7e1f4edf5345 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Tue, 31 Oct 2023 09:25:46 -0400 Subject: [PATCH 5/7] Increase vis widget default height --- geemap/map_widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index 10bdcc07dc..fd073112af 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -1163,7 +1163,7 @@ def __init__(self, host_map, layer_dict): "100%": {"percent": 1.0}, }, description="Stretch:", - layout=ipywidgets.Layout(width="264px"), + layout=ipywidgets.Layout(width="260px"), style={"description_width": "initial"}, ) @@ -1303,7 +1303,7 @@ 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", ), From 623f6ed51ea57b67fe6512f483f19c2017226057 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Tue, 31 Oct 2023 09:29:33 -0400 Subject: [PATCH 6/7] Ignore changelog_update.py in mkdocs --- mkdocs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 00bf7e69dfd03c6aa8c9c91ac5f5cc0854838394 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Tue, 31 Oct 2023 09:39:42 -0400 Subject: [PATCH 7/7] Change docs build to python 3.11 --- .github/workflows/docs-build.yml | 2 +- .github/workflows/docs.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 76d5f5647c..405a72f9e0 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -12,7 +12,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.11" - name: Install GDAL run: | python -m pip install --upgrade pip diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d5752e37ee..e38046181f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.11" - name: Install GDAL run: | python -m pip install --upgrade pip