Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add stretch options for visualizing images #1803

Merged
merged 7 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe EPSG:3857 is more common? @jdbcode

scale=1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the scale be the native resolution of the image insteaded of being hardcoded as 1?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! I went with scale=1 and the weird CRS because they're used by the Code Editor for calculating vis params (screenshot of the relevant obfuscated code from playground.js below). I'm guessing a fixed scale was used to avoid tricky corner cases like images with different projections per band or images with zero bands, which would break if you tried to use img.projection().nominalScale() and img.select(0).projection().nominalScale(), respectively. I'm open to other ideas though!

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Thanks for looking into this. I did not know that the Code Editor actually use these parameters.

).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)
63 changes: 63 additions & 0 deletions 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="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,
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 @@ -1283,10 +1310,46 @@ 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, *_):
"""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
2 changes: 1 addition & 1 deletion geemap/plotlymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion geemap/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_basemaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading