From 43cd657a7513bc2d9149f329d0da46fb930f0f26 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Fri, 6 Dec 2024 23:20:18 -0500 Subject: [PATCH] Add support for visualizing Mapillary images (#1018) --- docs/maplibre/mapillary.ipynb | 58 ++++++++++ leafmap/common.py | 207 ++++++++++++++++++++++++++++++++++ leafmap/maplibregl.py | 121 ++++++++++++++++++++ 3 files changed, 386 insertions(+) diff --git a/docs/maplibre/mapillary.ipynb b/docs/maplibre/mapillary.ipynb index 485df2b655..e67988b665 100644 --- a/docs/maplibre/mapillary.ipynb +++ b/docs/maplibre/mapillary.ipynb @@ -30,6 +30,7 @@ "metadata": {}, "outputs": [], "source": [ + "import leafmap.common as common\n", "import leafmap.maplibregl as leafmap" ] }, @@ -68,6 +69,63 @@ "source": [ "![image](https://github.com/user-attachments/assets/db9fac4f-4d67-4ccb-8f2d-06d665bdd521)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "image_ids = common.search_mapillary_images(\n", + " lon=-73.99941, lat=40.71194, radius=0.0005, limit=5\n", + ")\n", + "image_ids" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "common.get_mapillary_image_url(image_ids[0], resolution=\"original\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "common.download_mapillary_images(image_ids, resolution=\"original\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "common.get_mapillary_image_widget(image_ids[0], style=\"classic\", width=1000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "common.get_mapillary_image_widget(image_ids[0], style=\"split\", width=1000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "common.get_mapillary_image_widget(image_ids[0], style=\"photo\", width=1000)" + ] } ], "metadata": { diff --git a/leafmap/common.py b/leafmap/common.py index f8ce25a1e2..bdc8a805fc 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -15355,3 +15355,210 @@ def plot_actual_vs_predicted( return fig else: fig.show() + + +def search_mapillary_images( + lon: Optional[float] = None, + lat: Optional[float] = None, + radius: float = 0.00005, + bbox: Optional[Union[str, List[float]]] = None, + limit: int = 2000, + access_token: Optional[str] = None, +) -> List[str]: + """ + Retrieves Mapillary image IDs near the specified test point within a bounding box. + + Args: + lon (float, optional): Longitude of the test point. Defaults to None. + lat (float, optional): Latitude of the test point. Defaults to None. + radius (float, optional): Radius to create the bounding box. Defaults to 0.00005. + bbox (Union[str, List[float]], optional): Bounding box coordinates. Defaults to None. + limit (int, optional): Maximum number of image IDs to retrieve. Defaults to 2000. + access_token (str, optional): Mapillary API access token. Defaults to None. + + Returns: + List[str]: JSON response from the Mapillary API containing image IDs. + """ + + if access_token is None: + access_token = get_api_key("MAPILLARY_API_KEY") + + if access_token is None: + raise ValueError( + "Mapillary API access token is required. Set it using the 'access_token' parameter." + ) + + metadata_endpoint = "https://graph.mapillary.com" + headers = {"Authorization": f"OAuth {access_token}"} + + if bbox is None: + if lon is None or lat is None: + raise ValueError("Longitude and latitude are required.") + bbox = f"{lon - radius},{lat - radius},{lon + radius},{lat + radius}" + else: + if isinstance(bbox, list): + bbox = ",".join(str(x) for x in bbox) + + # Construct the bounding box for the API call + url_imagesearch = f"{metadata_endpoint}/images?fields=id&bbox={bbox}&limit={limit}" + + try: + response = requests.get(url_imagesearch, headers=headers) + response.raise_for_status() # Raise an HTTPError for bad responses + return [image["id"] for image in response.json()["data"]] + except requests.exceptions.RequestException as e: + print(f"An error occurred: {e}") + return [] + + +def get_mapillary_image_widget( + image_id: str, + style: str = "photo", + width: int = 800, + height: int = 600, + frame_border: int = 0, + **kwargs: Any, +) -> widgets.HTML: + """ + Creates an iframe widget to display a Mapillary image. + + Args: + image_id (str): The ID of the Mapillary image. + style (str): The style of the image. Can be "photo", "classic", "split". Defaults to "photo". + width (int): The width of the iframe. Defaults to 800. + height (int): The height of the iframe. Defaults to 600. + frame_border (int): The frame border of the iframe. Defaults to 0. + **kwargs: Additional keyword arguments for the widget. + + Returns: + widgets.HTML: An iframe widget displaying the Mapillary image. + """ + + content = f""" + + """ + + # Create an iframe widget + iframe = widgets.HTML(value=content, placeholder="Mapillary Image", **kwargs) + + return iframe + + +def get_mapillary_image_url( + image_id: str, + resolution: str = "original", + access_token: Optional[str] = None, + **kwargs: Any, +) -> Optional[str]: + """ + Retrieves the URL of a Mapillary image. + + Args: + image_id (str): The ID of the Mapillary image. + resolution (str): The resolution of the image. Can be 256, 1024, 2048, or original. + Defaults to "original". + access_token (str, optional): The access token for the Mapillary API. Defaults to None. + **kwargs: Additional keyword arguments for the request. + + Raises: + ValueError: If no access token is provided. + + Returns: + Optional[str]: The URL of the Mapillary image, or None if an error occurs. + """ + if access_token is None: + access_token = get_api_key("MAPILLARY_API_KEY") + + if access_token is None: + raise ValueError( + "Mapillary API access token is required. Set it using the 'access_token' parameter." + ) + + # API URL + url = f"https://graph.mapillary.com/{image_id}" + + # Fields to retrieve + fields = f"thumb_{resolution}_url" + + # Request parameters + params = {"fields": fields, "access_token": access_token} + + # Fetch the data + response = requests.get(url, params=params, **kwargs) + + # Check the response + if response.status_code == 200: + data = response.json() + image_url = data.get(fields) + return image_url + else: + print(f"Error {response.status_code}: {response.text}") + return None + + +def download_mapillary_image( + image_id: str, + output: Optional[str] = None, + resolution: str = "original", + access_token: Optional[str] = None, + quiet: bool = True, + **kwargs: Any, +) -> None: + """ + Downloads a Mapillary image. + + Args: + image_id (str): The ID of the Mapillary image. + output (str, optional): The output file path. Defaults to None. + resolution (str): The resolution of the image. Can be 256, 1024, 2048, or original. + Defaults to "original". + access_token (str, optional): The access token for the Mapillary API. Defaults to None. + quiet (bool): Whether to suppress output. Defaults to True. + **kwargs: Additional keyword arguments for the download. + + Returns: + None + """ + + image_url = get_mapillary_image_url( + image_id, resolution=resolution, access_token=access_token + ) + if output is None: + + output = f"{image_id}.jpg" + download_file(image_url, output, quiet=quiet, **kwargs) + + +def download_mapillary_images( + image_ids: List[str], + output_dir: Optional[str] = None, + resolution: str = "original", + **kwargs: Any, +) -> None: + """ + Downloads multiple Mapillary images. + + Args: + image_ids (List[str]): A list of Mapillary image IDs. + output_dir (str, optional): The directory to save the images. Defaults + to the current working directory. + resolution (str): The resolution of the images. Defaults to "original". + **kwargs: Additional keyword arguments for the download. + + Returns: + None + """ + if output_dir is None: + output_dir = os.getcwd() + + for index, image_id in enumerate(image_ids): + output = os.path.join(output_dir, f"{image_id}.jpg") + print(f"Downloading {index + 1}/{len(image_ids)}: {image_id}.jpg ...") + download_mapillary_image( + image_id=image_id, output=output, resolution=resolution, **kwargs + ) diff --git a/leafmap/maplibregl.py b/leafmap/maplibregl.py index 848d536a16..cb09b1472b 100644 --- a/leafmap/maplibregl.py +++ b/leafmap/maplibregl.py @@ -4018,6 +4018,103 @@ def add_mapillary( self.add_popup(sequence_lyr_name) self.add_popup(image_lyr_name) + def create_mapillary_widget( + self, + lon: Optional[float] = None, + lat: Optional[float] = None, + radius: float = 0.00005, + bbox: Optional[Union[str, List[float]]] = None, + image_id: Optional[str] = None, + style: str = "classic", + width: int = 560, + height: int = 600, + frame_border: int = 0, + link: bool = True, + container: bool = True, + column_widths: List[int] = [8, 1], + **kwargs: Any, + ) -> Union[widgets.HTML, v.Row]: + """ + Creates a Mapillary widget. + + Args: + lon (Optional[float]): Longitude of the location. Defaults to None. + lat (Optional[float]): Latitude of the location. Defaults to None. + radius (float): Search radius for Mapillary images. Defaults to 0.00005. + bbox (Optional[Union[str, List[float]]]): Bounding box for the search. Defaults to None. + image_id (Optional[str]): ID of the Mapillary image. Defaults to None. + style (str): Style of the Mapillary image. Defaults to "classic". + width (int): Width of the iframe. Defaults to 560. + height (int): Height of the iframe. Defaults to 600. + frame_border (int): Frame border of the iframe. Defaults to 0. + link (bool): Whether to link the widget to map clicks. Defaults to True. + container (bool): Whether to return the widget in a container. Defaults to True. + column_widths (List[int]): Widths of the columns in the container. Defaults to [8, 1]. + **kwargs: Additional keyword arguments for the widget. + + Returns: + Union[widgets.HTML, v.Row]: The Mapillary widget or a container with the widget. + """ + + if image_id is None: + if lon is None or lat is None: + if len(self.center) > 0: + lon = self.center["lng"] + lat = self.center["lat"] + else: + lon = 0 + lat = 0 + image_ids = common.search_mapillary_images(lon, lat, radius, bbox, limit=1) + if len(image_ids) > 0: + image_id = image_ids[0] + + if image_id is None: + widget = widgets.HTML() + else: + widget = common.get_mapillary_image_widget( + image_id, style, width, height, frame_border, **kwargs + ) + + if link: + + def log_lng_lat(lng_lat): + lon = lng_lat.new["lng"] + lat = lng_lat.new["lat"] + image_id = common.search_mapillary_images( + lon, lat, radius=radius, limit=1 + ) + if len(image_id) > 0: + content = f""" + + """ + widget.value = content + + self.observe(log_lng_lat, names="clicked") + + if container: + left_col_layout = v.Col( + cols=column_widths[0], + children=[self], + class_="pa-1", # padding for consistent spacing + ) + right_col_layout = v.Col( + cols=column_widths[1], + children=[widget], + class_="pa-1", # padding for consistent spacing + ) + row = v.Row( + children=[left_col_layout, right_col_layout], + ) + return row + else: + + return widget + class Container(v.Container): @@ -5101,3 +5198,27 @@ def on_change(change): filepath_widget.value = filepaths[0] return main_widget + + +class MapWidget(v.Row): + + def __init__(self, left_obj, right_obj, column_widths=(5, 1), **kwargs): + + self.left_obj = left_obj + self.right_obj = right_obj + + left_col_layout = v.Col( + cols=column_widths[0], + children=[left_obj], + class_="pa-1", # padding for consistent spacing + ) + right_col_layout = v.Col( + cols=column_widths[1], + children=[right_obj], + class_="pa-1", # padding for consistent spacing + ) + + # if "class_" not in kwargs: + # kwargs["class_"] = "d-flex flex-wrap" + + super().__init__(children=[left_col_layout, right_col_layout], **kwargs)