diff --git a/leafmap/common.py b/leafmap/common.py index 84d4e49d16..3617c3f347 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -996,11 +996,15 @@ def bbox_to_geojson(bounds): """Convert coordinates of a bounding box to a geojson. Args: - bounds (list): A list of coordinates representing [left, bottom, right, top]. + bounds (list | tuple): A list of coordinates representing [left, bottom, right, top] or m.bounds. Returns: dict: A geojson feature. """ + + if isinstance(bounds, tuple) and len(bounds) == 2: + bounds = [bounds[0][1], bounds[0][0], bounds[1][1], bounds[1][0]] + return { "geometry": { "type": "Polygon", diff --git a/leafmap/foliumap.py b/leafmap/foliumap.py index fc2ee0397c..bc653b0081 100644 --- a/leafmap/foliumap.py +++ b/leafmap/foliumap.py @@ -912,6 +912,7 @@ def add_stac_layer( attribution=".", opacity=1.0, shown=True, + fit_bounds=True, **kwargs, ): """Adds a STAC TileLayer to the map. @@ -927,6 +928,7 @@ def add_stac_layer( attribution (str, optional): The attribution to use. Defaults to ''. opacity (float, optional): The opacity of the layer. Defaults to 1. shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. + fit_bounds (bool, optional): A flag indicating whether the map should be zoomed to the layer extent. Defaults to True. """ tile_url = stac_tile( url, collection, item, assets, bands, titiler_endpoint, **kwargs @@ -939,8 +941,10 @@ def add_stac_layer( opacity=opacity, shown=shown, ) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3]) + + if fit_bounds: + self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) + arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3]) def add_mosaic_layer( self, diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index f3faa45917..0840b353e2 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -837,6 +837,7 @@ def add_stac_layer( attribution="", opacity=1.0, shown=True, + fit_bounds=True, **kwargs, ): """Adds a STAC TileLayer to the map. @@ -852,14 +853,16 @@ def add_stac_layer( attribution (str, optional): The attribution to use. Defaults to ''. opacity (float, optional): The opacity of the layer. Defaults to 1. shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. + fit_bounds (bool, optional): A flag indicating whether the map should be zoomed to the layer extent. Defaults to True. """ tile_url = stac_tile( url, collection, item, assets, bands, titiler_endpoint, **kwargs ) bounds = stac_bounds(url, collection, item, titiler_endpoint) self.add_tile_layer(tile_url, name, attribution, opacity, shown) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3]) + if fit_bounds: + self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) + arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3]) if not hasattr(self, "cog_layer_dict"): self.cog_layer_dict = {} diff --git a/leafmap/stac.py b/leafmap/stac.py index d0cf2861d6..2f98daf43f 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -985,6 +985,9 @@ def stac_root_link(url, return_col_id=False): collection_id = obj.id href = obj.get_root_link().get_href() + if not url.startswith(href): + href = obj.get_self_href() + if return_col_id: return href, collection_id else: @@ -992,11 +995,19 @@ def stac_root_link(url, return_col_id=False): except Exception as e: print(e) - return None + if return_col_id: + return None, None + else: + return None def stac_client( - url, headers=None, parameters=None, ignore_conformance=False, modifier=None, return_col_id=False + url, + headers=None, + parameters=None, + ignore_conformance=False, + modifier=None, + return_col_id=False, ): """Get the STAC client. It wraps the pystac.Client.open() method. See https://pystac-client.readthedocs.io/en/stable/api.html#pystac_client.Client.open @@ -1026,11 +1037,15 @@ def stac_client( root = stac_root_link(url, return_col_id=return_col_id) if return_col_id: - client = Client.open(root[0], headers, parameters, ignore_conformance, modifier) + client = Client.open( + root[0], headers, parameters, ignore_conformance, modifier + ) collection_id = root[1] return client, collection_id else: - client = Client.open(root, headers, parameters, ignore_conformance, modifier) + client = Client.open( + root, headers, parameters, ignore_conformance, modifier + ) return client except Exception as e: @@ -1190,12 +1205,85 @@ def stac_search( items = list(search.item_collection()) info = {} for item in items: - info[item.id] = {'id': item.id, 'href': item.get_self_href(), 'bands': list(item.get_assets().keys()), 'assets': item.get_assets()} + info[item.id] = { + "id": item.id, + "href": item.get_self_href(), + "bands": list(item.get_assets().keys()), + "assets": item.get_assets(), + } return info else: return search +def stac_search_to_gdf(search, **kwargs): + """Convert STAC search result to a GeoDataFrame. + + Args: + search (pystac_client.ItemSearch): The search result returned by leafmap.stac_search(). + **kwargs: Additional keyword arguments to pass to the GeoDataFrame.from_features() function. + + Returns: + GeoDataFrame: A GeoPandas GeoDataFrame object. + """ + import geopandas as gpd + + gdf = gpd.GeoDataFrame.from_features( + search.item_collection().to_dict(), crs="EPSG:4326", **kwargs + ) + return gdf + + +def stac_search_to_df(search, **kwargs): + """Convert STAC search result to a DataFrame. + + Args: + search (pystac_client.ItemSearch): The search result returned by leafmap.stac_search(). + **kwargs: Additional keyword arguments to pass to the DataFrame.drop() function. + + Returns: + DataFrame: A Pandas DataFrame object. + """ + gdf = stac_search_to_gdf(search) + return gdf.drop(columns=["geometry"], **kwargs) + + +def stac_search_to_dict(search, **kwargs): + """Convert STAC search result to a dictionary. + + Args: + search (pystac_client.ItemSearch): The search result returned by leafmap.stac_search(). + + Returns: + dict: A dictionary of STAC items, with the stac item id as the key, and the stac item as the value. + """ + + items = list(search.item_collection()) + info = {} + for item in items: + info[item.id] = { + "id": item.id, + "href": item.get_self_href(), + "bands": list(item.get_assets().keys()), + "assets": item.get_assets(), + } + return info + + +def stac_search_to_list(search, **kwargs): + + """Convert STAC search result to a list. + + Args: + search (pystac_client.ItemSearch): The search result returned by leafmap.stac_search(). + + Returns: + list: A list of STAC items. + """ + + return search.item_collections() + + def download_data_catalogs(out_dir=None, quiet=True, overwrite=False): """Download geospatial data catalogs from https://github.com/giswqs/geospatial-data-catalogs. @@ -1224,15 +1312,35 @@ def download_data_catalogs(out_dir=None, quiet=True, overwrite=False): if os.path.exists(work_dir) and not overwrite: return work_dir else: - + gdown.download(url, out_file, quiet=quiet) with zipfile.ZipFile(out_file, "r") as zip_ref: zip_ref.extractall(out_dir) return work_dir - + def set_default_bands(bands): + excluded = [ + "index", + "metadata", + "mtl.json", + "mtl.txt", + "mtl.xml", + "qa", + "qa-browse", + "QA", + "rendered_preview", + "tilejson", + "tir-browse", + "vnir-browse", + "xml", + ] + + for band in excluded: + if band in bands: + bands.remove(band) + if len(bands) == 0: return [None, None, None] @@ -1242,14 +1350,23 @@ def set_default_bands(bands): if not isinstance(bands, list): raise ValueError("bands must be a list or a string.") - if (set(['nir', 'red', 'green']) <= set(bands)): - return ['nir', 'red', 'green'] - elif (set(['red', 'green', 'blue']) <= set(bands)): - return ['red', 'green', 'blue'] - elif (set(["B3", "B2", "B1"]) <= set(bands)): + if set(["nir", "red", "green"]) <= set(bands): + return ["nir", "red", "green"] + elif set(["nir08", "red", "green"]) <= set(bands): + return ["nir08", "red", "green"] + elif set(["red", "green", "blue"]) <= set(bands): + return ["red", "green", "blue"] + elif set(["B8", "B4", "B3"]) <= set(bands): + return ["B8", "B4", "B3"] + elif set(["B4", "B3", "B2"]) <= set(bands): + return ["B4", "B3", "B2"] + elif set(["B3", "B2", "B1"]) <= set(bands): return ["B3", "B2", "B1"] + elif set(["B08", "B04", "B03"]) <= set(bands): + return ["B08", "B04", "B03"] + elif set(["B04", "B03", "B02"]) <= set(bands): + return ["B04", "B03", "B02"] elif len(bands) < 3: - return bands[0] * 3 + return [bands[0]] * 3 else: return bands[:3] - \ No newline at end of file diff --git a/leafmap/toolbar.py b/leafmap/toolbar.py index e50266dd2a..5c80e91d7d 100644 --- a/leafmap/toolbar.py +++ b/leafmap/toolbar.py @@ -4438,7 +4438,6 @@ def stac_gui(m=None): Returns: ipywidgets: The tool GUI widget. """ - from .pc import get_pc_collection_list import pandas as pd widget_width = "450px" @@ -4487,6 +4486,12 @@ def stac_gui(m=None): "url": "url", "description": "summary", }, + "Custom STAC Endpoint": { + "filename": "", + "name": "", + "url": "", + "description": "", + }, } connections = list(stac_info.keys()) @@ -4540,6 +4545,15 @@ def stac_gui(m=None): value="https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a", description="URL:", tooltip="STAC Catalog URL", + placeholder="Enter a STAC URL, e.g., https://earth-search.aws.element84.com/v1", + style=style, + layout=widgets.Layout(width="454px", padding=padding), + ) + + custom_dataset = widgets.Dropdown( + options=[], + value=None, + description="Dataset:", style=style, layout=widgets.Layout(width="454px", padding=padding), ) @@ -4558,8 +4572,8 @@ def stac_gui(m=None): ) collection = widgets.Dropdown( - options=get_pc_collection_list(), - value="landsat-8-c2-l2 - Landsat 8 Collection 2 Level-2", + options=[], + value=None, description="Collection:", style=style, layout=widgets.Layout(width="454px", padding=padding), @@ -4610,12 +4624,21 @@ def stac_gui(m=None): layout=widgets.Layout(width=band_width, padding=padding), ) + max_items = widgets.Text( + value="20", + description="Max items:", + placeholder="Maximum number of items to return from the STAC API", + tooltip="Maximum number of items to return from the STAC API", + style=style, + layout=widgets.Layout(width="130px", padding=padding), + ) + vmin = widgets.Text( value=None, description="vmin:", tooltip="Minimum value of the raster to visualize", style=style, - layout=widgets.Layout(width="148px", padding=padding), + layout=widgets.Layout(width="100px", padding=padding), ) vmax = widgets.Text( @@ -4623,7 +4646,7 @@ def stac_gui(m=None): description="vmax:", tooltip="Maximum value of the raster to visualize", style=style, - layout=widgets.Layout(width="148px", padding=padding), + layout=widgets.Layout(width="100px", padding=padding), ) nodata = widgets.Text( @@ -4631,7 +4654,7 @@ def stac_gui(m=None): description="Nodata:", tooltip="Nodata the raster to visualize", style=style, - layout=widgets.Layout(width="150px", padding=padding), + layout=widgets.Layout(width="113px", padding=padding), ) palette_options = list_palettes(lowercase=True) @@ -4639,7 +4662,15 @@ def stac_gui(m=None): options=palette_options, value=None, description="palette:", - layout=widgets.Layout(width="300px", padding=padding), + layout=widgets.Layout(width="180px", padding=padding), + style=style, + ) + + add_footprints = widgets.Checkbox( + value=True, + description="Add footprints", + indent=False, + layout=widgets.Layout(width="120px", padding=padding), style=style, ) @@ -4651,7 +4682,15 @@ def stac_gui(m=None): style=style, ) - add_params_text = "Additional parameters in the format of a dictionary, for example, \n {'palette': ['#006633', '#E5FFCC', '#662A00', '#D8D8D8', '#F5F5F5'], 'expression': '(SR_B5-SR_B4)/(SR_B5+SR_B4)'}" + query_params_text = "Additional parameters to query the STAC API, for example: {'query': {'eo:cloud_cover':{'lt':10}}}" + query_params = widgets.Textarea( + value="", + placeholder=query_params_text, + layout=widgets.Layout(width="454px", padding=padding), + style=style, + ) + + add_params_text = "Additional parameters to visualize imagery, for example: {'palette': ['#006633', '#E5FFCC', '#662A00', '#D8D8D8', '#F5F5F5'], 'expression': '(SR_B5-SR_B4)/(SR_B5+SR_B4)'}" add_params = widgets.Textarea( value="", placeholder=add_params_text, @@ -4678,6 +4717,7 @@ def reset_options(reset_url=True): red.value = "red" green.value = "green" blue.value = "blue" + max_items.value = "20" vmin.value = "" vmax.value = "" nodata.value = "" @@ -4690,7 +4730,7 @@ def reset_options(reset_url=True): raster_options = widgets.VBox() raster_options.children = [ widgets.HBox([red, green, blue]), - widgets.HBox([palette, checkbox]), + widgets.HBox([palette, add_footprints, checkbox]), params_widget, ] @@ -4702,6 +4742,13 @@ def reset_options(reset_url=True): ) buttons.style.button_width = "65px" + dataset_widget = widgets.VBox() + dataset_widget.children = [ + dataset, + description, + http_url, + ] + toolbar_widget = widgets.VBox() toolbar_widget.children = [toolbar_button] toolbar_header = widgets.HBox() @@ -4709,9 +4756,7 @@ def reset_options(reset_url=True): toolbar_footer = widgets.VBox() toolbar_footer.children = [ connection, - dataset, - description, - http_url, + dataset_widget, widgets.HBox([start_date, end_date]), item, raster_options, @@ -4747,27 +4792,66 @@ def update_bands(): def connection_changed(change): if change["new"]: - df = pd.read_csv(stac_info[connection.value]["filename"], sep="\t") - datasets = df[stac_info[connection.value]["name"]].tolist() - dataset.options = datasets - dataset.value = datasets[0] + if connection.value != "Custom STAC Endpoint": + df = pd.read_csv(stac_info[connection.value]["filename"], sep="\t") + datasets = df[stac_info[connection.value]["name"]].tolist() + dataset.options = datasets + dataset.value = datasets[0] + dataset_widget.children = [dataset, description, http_url] + else: + http_url.value = "https://earth-search.aws.element84.com/v1" + dataset_widget.children = [http_url, custom_dataset] connection.observe(connection_changed, names="value") def dataset_changed(change): if change["new"]: - df = pd.read_csv(stac_info[connection.value]["filename"], sep="\t") - df = df[df[stac_info[connection.value]["name"]] == dataset.value] - description.value = df[stac_info[connection.value]["description"]].tolist()[ - 0 - ] - http_url.value = df[stac_info[connection.value]["url"]].tolist()[0] - item.options = [] - stac_data.clear() - update_bands() + if connection.value != "Custom STAC Endpoint": + df = pd.read_csv(stac_info[connection.value]["filename"], sep="\t") + df = df[df[stac_info[connection.value]["name"]] == dataset.value] + description.value = df[ + stac_info[connection.value]["description"] + ].tolist()[0] + http_url.value = df[stac_info[connection.value]["url"]].tolist()[0] + item.options = [] + custom_dataset.options = [] + stac_data.clear() + update_bands() dataset.observe(dataset_changed, names="value") + def http_url_changed(change): + + with output: + print("Searching...") + try: + + if connection.value == "Custom STAC Endpoint": + custom_collections = stac_collections( + http_url.value, return_ids=True + ) + if custom_collections: + custom_dataset.options = custom_collections + + collection_id = http_url.value.split("/")[-1] + if collection_id in custom_collections: + custom_dataset.value = collection_id + + else: + custom_dataset.options = [] + + else: + + custom_cols = stac_collections(http_url.value, return_ids=True) + item.options = custom_cols + stac_data.clear() + update_bands() + output.clear_output() + except Exception as e: + print(e) + + http_url.on_submit(http_url_changed) + def item_changed(change): if change["new"]: layer_name.value = item.value @@ -4787,7 +4871,8 @@ def checkbox_changed(change): if change["new"]: params_widget.children = [ layer_name, - widgets.HBox([vmin, vmax, nodata]), + widgets.HBox([max_items, vmin, vmax, nodata]), + query_params, add_params, ] else: @@ -4838,30 +4923,84 @@ def button_clicked(change): if start_date.value is not None and end_date.value is not None: datetime = str(start_date.value) + "/" + str(end_date.value) elif start_date.value is not None: - datetime = str(start_date.value) + datetime = str(start_date.value) + "/.." elif end_date.value is not None: - datetime = str(end_date.value) + datetime = "../" + str(end_date.value) else: datetime = None - if m is not None and m.user_roi is not None: - intersects = m.user_roi["geometry"] + if m is not None: + if m.user_roi is not None: + intersects = m.user_roi["geometry"] + else: + intersects = bbox_to_geojson(m.bounds) + else: intersects = None + if ( + checkbox.value + and query_params.value.strip().startswith("{") + and query_params.value.strip().endswith("}") + ): + query = eval(query_params.value) + elif query_params.value.strip() == "": + query = {} + else: + print( + "Invalid query parameters. It must be a dictionary with keys such as 'query', 'sortby', 'filter', 'fields'" + ) + query = {} + print("Retrieving items...") try: - search = stac_search( - url=http_url.value, - max_items=MAX_ITEMS, - intersects=intersects, - datetime=datetime, - get_info=True, - ) - item.options = list(search.keys()) + + if connection.value == "Custom STAC Endpoint": + search = stac_search( + url=http_url.value, + max_items=int(max_items.value), + intersects=intersects, + datetime=datetime, + collections=custom_dataset.value, + **query, + ) + else: + search = stac_search( + url=http_url.value, + max_items=int(max_items.value), + intersects=intersects, + datetime=datetime, + **query, + ) + search_dict = stac_search_to_dict(search) + item.options = list(search_dict.keys()) + setattr(m, "stac_search", search) + setattr(m, "stac_dict", search_dict) + setattr(m, "stac_items", stac_search_to_list(search)) + + if add_footprints.value and m is not None: + gdf = stac_search_to_gdf(search) + style = { + "stroke": True, + "color": "#000000", + "weight": 2, + "opacity": 1, + "fill": True, + "fillColor": "#000000", + "fillOpacity": 0, + } + hover_style = {"fillOpacity": 0.3} + m.add_gdf( + gdf, + style=style, + hover_style=hover_style, + layer_name="Footprints", + zoom_to_layer=False, + ) + setattr(m, "stac_gdf", gdf) stac_data.clear() - stac_data.append(search) + stac_data.append(search_dict) update_bands() output.clear_output() @@ -4905,7 +5044,6 @@ def button_clicked(change): if nodata.value: vis_params["nodata"] = nodata.value - col = collection.value.split(" - ")[0] if len(set([red.value, green.value, blue.value])) == 1: assets = red.value else: @@ -4913,14 +5051,36 @@ def button_clicked(change): try: - m.add_stac_layer( - url=stac_data[0][item.value]["href"], - item=item.value, - assets=assets, - name=layer_name.value, - **vis_params, - ) - m.stac_data = stac_data[0][item.value] + if connection.value == "Microsoft Planetary Computer": + m.add_stac_layer( + collection=http_url.value.split("/")[-1], + item=item.value, + assets=assets, + name=layer_name.value, + fit_bounds=False, + **vis_params, + ) + setattr( + m, + "stac_item", + { + "collection": http_url.value.split("/")[-1], + "item": item.value, + "assets": assets, + }, + ) + + else: + + m.add_stac_layer( + url=stac_data[0][item.value]["href"], + item=item.value, + assets=assets, + name=layer_name.value, + fit_bounds=False, + **vis_params, + ) + setattr(m, "stac_item", stac_data[0][item.value]) output.clear_output() except Exception as e: print(e) diff --git a/mkdocs.yml b/mkdocs.yml index c8eaf79ae9..4be650640c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,7 @@ nav: - pc module: pc.md - plotlymap module: plotlymap.md - pydeck module: deck.md + - stac module: stac.md - toolbar module: toolbar.md - Workshops: - workshops/FOSS4G_2021.ipynb diff --git a/requirements.txt b/requirements.txt index 20ac6988e7..0c78f4cc3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ geojson ipyevents ipyfilechooser>=0.6.0 ipyleaflet>=0.17.0 +ipywidgets<8.0.0 matplotlib numpy pandas