Skip to content

Commit

Permalink
Add stretch options for visualizing images (#1803)
Browse files Browse the repository at this point in the history
* Implement the range stretch widget

* Refactor to move vis param calculation into EELeafletTileLayer

* 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.

* Replace Stamen.Terrain

* Increase vis widget default height

* Ignore changelog_update.py in mkdocs

* Change docs build to python 3.11

---------

Co-authored-by: Qiusheng Wu <[email protected]>
  • Loading branch information
aazuspan and giswqs authored Oct 31, 2023
1 parent ddc512c commit bc0f09b
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 2 deletions.
78 changes: 78 additions & 0 deletions geemap/ee_tile_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import ee
import folium
import ipyleaflet
from functools import lru_cache

from . import common

Expand Down Expand Up @@ -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)
)
Expand All @@ -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)
65 changes: 64 additions & 1 deletion geemap/map_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit bc0f09b

Please sign in to comment.