├── README.md ├── LICENSE └── OT_STAC_Simple.ipynb /README.md: -------------------------------------------------------------------------------- 1 | [![NSF-2410799](https://img.shields.io/badge/NSF-2410799-blue.svg)](https://nsf.gov/awardsearch/showAward?AWD_ID=2410799) 2 | [![NSF-2410800](https://img.shields.io/badge/NSF-2410800-blue.svg)](https://nsf.gov/awardsearch/showAward?AWD_ID=2410800) 3 | [![NSF-2410801](https://img.shields.io/badge/NSF-2410801-blue.svg)](https://nsf.gov/awardsearch/showAward?AWD_ID=2410801) 4 | 5 | # OpenTopography STAC Examples 6 | Example Notebooks for Accessing OpenTopography's SpatioTemporal Asset Catalog (STAC) 7 | 8 | [OT_STAC_Simple.ipynb](https://github.com/OpenTopography/STAC-Examples/blob/main/OT_STAC_Simple.ipynb) - This simple notebook demonstrates how to query the OpenTopography Raster STAC catalog for DEM datasets within a specified area, filter the results, and create hillshade overlays on an interactive map. It also manages geospatial metadata and projection details to ensure accurate terrain visualization. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, OpenTopography Facility 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /OT_STAC_Simple.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "nbformat": 4, 3 | "nbformat_minor": 0, 4 | "metadata": { 5 | "colab": { 6 | "provenance": [], 7 | "authorship_tag": "ABX9TyMEOuBnhM+sjRvxIQCJaLX2", 8 | "include_colab_link": true 9 | }, 10 | "kernelspec": { 11 | "name": "python3", 12 | "display_name": "Python 3" 13 | }, 14 | "language_info": { 15 | "name": "python" 16 | } 17 | }, 18 | "cells": [ 19 | { 20 | "cell_type": "markdown", 21 | "metadata": { 22 | "id": "view-in-github", 23 | "colab_type": "text" 24 | }, 25 | "source": [ 26 | "\"Open" 27 | ] 28 | }, 29 | { 30 | "cell_type": "markdown", 31 | "source": [ 32 | "# OT Raster STAC Simple Example\n", 33 | "This notebook demonstrates how to connect to the OpenTopography Raster STAC catalog to search for digital elevation model (DEM) datasets within a specified geographic bounding box. It retrieves available collections in the catalog, filters items whose bounding boxes intersect with the area of interest.\n", 34 | "\n", 35 | "The notebook includes a function to generate hillshade images from elevation data, which are then used to create semi-transparent shaded relief overlays on an interactive map centered on the query area. This enables visualization of terrain features from the selected raster DEM assets. The code also handles geospatial metadata and coordinate reference system details for accurate rendering and pixel scaling.\n", 36 | "\n", 37 | "Authors: Minh Phan, Viswanath Nandigam\n", 38 | "\n", 39 | "info@opentopography.org" 40 | ], 41 | "metadata": { 42 | "id": "dJrZDegfskLF" 43 | } 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "metadata": { 49 | "id": "ph_AX_qzVesV" 50 | }, 51 | "outputs": [], 52 | "source": [ 53 | "# Import core libraries: STAC Python Client, Array math (NumPy), Raster I/O (rasterio), Interactive web map display (ipyleaflet Map and ImageOverlay). in‑memory image creation and encoding (Pillow, io, base64).\n", 54 | "\n", 55 | "!pip install --quiet pystac-client rasterio\n", 56 | "\n", 57 | "import json\n", 58 | "from pystac_client import Client\n", 59 | "import numpy as np\n", 60 | "import rasterio\n", 61 | "from ipyleaflet import Map, ImageOverlay, basemaps\n", 62 | "from IPython.display import display\n", 63 | "from PIL import Image\n", 64 | "import io, base64" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "source": [ 70 | "# Connect to the OpenTopography Raster STAC catalog\n", 71 | "URL = 'https://twsa.ucsd.edu/stac/raster_catalog.json'\n", 72 | "client = Client.from_file(URL)\n", 73 | "\n", 74 | "# Define the spatial bounding box of interest as [min_lon, min_lat, max_lon, max_lat]\n", 75 | "bbox = [-119.6, 37.7, -119.5, 37.8]\n", 76 | "\n", 77 | "# Retrieve collections available in the STAC catalog\n", 78 | "collections = client.get_collections()\n", 79 | "\n", 80 | "# Start search and iterate through collections and items\n", 81 | "item_count = 0\n", 82 | "\n", 83 | "print(\"Searching for items...\")\n", 84 | "\n", 85 | "for collection in collections:\n", 86 | " # print(f\"\\nCollection: {collection.title}\")\n", 87 | "\n", 88 | " # Get items from this collection\n", 89 | " items = collection.get_items()\n", 90 | "\n", 91 | " for item in items:\n", 92 | " # Check if item intersects with bounding box\n", 93 | " if item.bbox:\n", 94 | " item_bbox = item.bbox\n", 95 | " # Intersection logic\n", 96 | " if not (item_bbox[2] < bbox[0] or # item east < search west\n", 97 | " item_bbox[0] > bbox[2] or # item west > search east\n", 98 | " item_bbox[3] < bbox[1] or # item north < search south\n", 99 | " item_bbox[1] > bbox[3]): # item south > search north\n", 100 | "\n", 101 | " print(f\"\\nCollection Title: {collection.title}\")\n", 102 | " print(f\"Collection ID: {collection.id}\")\n", 103 | " print(f\"Item ID: {item.id}\")\n", 104 | " item_count += 1\n", 105 | "\n", 106 | " # break after 5 intersecting items found\n", 107 | " if item_count >= 5:\n", 108 | " break\n", 109 | "\n", 110 | " if item_count >= 5:\n", 111 | " break\n" 112 | ], 113 | "metadata": { 114 | "id": "qqVO_xXrViBD" 115 | }, 116 | "execution_count": null, 117 | "outputs": [] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "source": [ 122 | "# Retrieve a metadata for the specific data Collection using the collection ID\n", 123 | "# e.g. ALOS World 3D - 30m\n", 124 | "\n", 125 | "collection = client.get_collection(\"OTALOS.112016.4326.2\")\n", 126 | "\n", 127 | "# Print all metadata in JSON\n", 128 | "collection_dict = collection.to_dict()\n", 129 | "print(json.dumps(collection_dict, indent=2))" 130 | ], 131 | "metadata": { 132 | "id": "Hd6MO0qccBV6" 133 | }, 134 | "execution_count": null, 135 | "outputs": [] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "source": [ 140 | "# Check if two bounding boxes intersect (minx, miny, maxx, maxy)\n", 141 | "def intersects(a, b):\n", 142 | " return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])\n", 143 | "\n", 144 | "\"\"\"\n", 145 | "Generate hillshade (shaded relief)\n", 146 | "Parameters:\n", 147 | " arr (np.ndarray): 2D array of elevation values.\n", 148 | " azimuth (float): Sun azimuth in degrees (default 315° = NW).\n", 149 | " altitude (float): Sun altitude in degrees above horizon (default 45°).\n", 150 | " dx (float): Spacing between cells in x-direction (default 1).\n", 151 | " dy (float): Spacing between cells in y-direction (default 1).\n", 152 | "\"\"\"\n", 153 | "\n", 154 | "def hillshade(arr, azimuth=315.0, altitude=45.0, dx=1.0, dy=1.0):\n", 155 | " # Handle NaN values, fill with mean or zero if mean is NaN\n", 156 | " if np.isnan(arr).any():\n", 157 | " fill = np.nanmean(arr)\n", 158 | " if np.isnan(fill):\n", 159 | " arr = np.where(np.isnan(arr), 0, arr)\n", 160 | " else:\n", 161 | " arr = np.where(np.isnan(arr), fill, arr)\n", 162 | "\n", 163 | " # Compute gradients in y and x directions\n", 164 | " gy, gx = np.gradient(arr, dy, dx)\n", 165 | "\n", 166 | " # Calculate slope and aspect\n", 167 | " slope = np.pi/2.0 - np.arctan(np.hypot(gx, gy))\n", 168 | " aspect = np.arctan2(-gx, gy)\n", 169 | "\n", 170 | " # Convert sun azimuth and altitude to radians\n", 171 | " az = np.deg2rad(azimuth)\n", 172 | " alt = np.deg2rad(altitude)\n", 173 | "\n", 174 | " # Calculate hillshade based on illumination model\n", 175 | " hs = np.sin(alt) * np.sin(slope) + np.cos(alt) * np.cos(slope) * np.cos(az - aspect)\n", 176 | "\n", 177 | " # Clamp values between 0 and 1 for valid shading\n", 178 | " np.clip(hs, 0, 1, out=hs)\n", 179 | "\n", 180 | " # Scale to 0-255 for image representation\n", 181 | " return (hs * 255).astype(np.uint8)\n", 182 | "\n", 183 | "\n", 184 | "# Find the first item in the collection whose bounding box intersects the query bounding box\n", 185 | "first_item = next((it for it in collection.get_items() if it.bbox and intersects(it.bbox, bbox)), None)\n", 186 | "\n", 187 | "if first_item is None:\n", 188 | " print(\"No item intersects the search bbox.\")\n", 189 | "else:\n", 190 | " # Collect GeoTIFF-like assets whose bbox intersects the query bbox\n", 191 | " chosen_assets = []\n", 192 | " for asset in first_item.assets.values():\n", 193 | " mt = (getattr(asset, \"media_type\", \"\") or getattr(asset, \"type\", \"\")).lower()\n", 194 | " if \"tiff\" in mt:\n", 195 | " asset_bbox = getattr(asset, \"extra_fields\", {}).get(\"bbox\") or first_item.bbox\n", 196 | " if asset_bbox and intersects(asset_bbox, bbox):\n", 197 | " chosen_assets.append((asset, asset_bbox))\n", 198 | "\n", 199 | " if not chosen_assets:\n", 200 | " print(\"No TIFF assets intersect the search bbox.\")\n", 201 | " else:\n", 202 | " # Extract query bbox coordinates\n", 203 | " west_q, south_q, east_q, north_q = bbox\n", 204 | "\n", 205 | " # Initialize interactive map centered on query bbox midpoint with zoom level 8\n", 206 | " m = Map(center=((south_q + north_q) / 2.0, (west_q + east_q) / 2.0), zoom=8, scroll_wheel_zoom=True)\n", 207 | " m.layout.height = \"600px\"\n", 208 | "\n", 209 | " # Loop over chosen GeoTIFF assets to create hillshade overlays on the map\n", 210 | " for idx, (asset, asset_bbox) in enumerate(chosen_assets, start=1):\n", 211 | " west, south, east, north = asset_bbox\n", 212 | "\n", 213 | " with rasterio.open(asset.href) as ds:\n", 214 | " band = ds.read(1).astype(\"float32\")\n", 215 | " if ds.nodata is not None:\n", 216 | " band = np.where(band == ds.nodata, np.nan, band)\n", 217 | "\n", 218 | " # Approximate pixel size in meters for gradient calculation\n", 219 | " if ds.crs and ds.crs.is_geographic:\n", 220 | " # Geographic CRS: convert degrees to meters based on latitude\n", 221 | " mid_lat = (south + north) / 2.0\n", 222 | " m_per_deg_lat = 111_132.0\n", 223 | " m_per_deg_lon = 111_320.0 * np.cos(np.deg2rad(mid_lat))\n", 224 | " dx = abs(ds.transform.a) * m_per_deg_lon\n", 225 | " dy = abs(ds.transform.e) * m_per_deg_lat\n", 226 | " else:\n", 227 | " # Projected CRS: pixel size is directly from transform\n", 228 | " dx = abs(ds.transform.a)\n", 229 | " dy = abs(ds.transform.e)\n", 230 | "\n", 231 | " # Generate hillshade image and alpha mask for valid data pixels\n", 232 | " hs = hillshade(band, dx=dx, dy=dy)\n", 233 | " alpha = (~np.isnan(band)).astype(np.uint8) * 255\n", 234 | " rgba = np.dstack([hs, hs, hs, alpha]).astype(np.uint8)\n", 235 | "\n", 236 | " # Encode RGBA image as PNG data URL for map overlay\n", 237 | " buf = io.BytesIO()\n", 238 | " Image.fromarray(rgba).save(buf, format=\"PNG\", optimize=True)\n", 239 | " data_url = \"data:image/png;base64,\" + base64.b64encode(buf.getvalue()).decode(\"ascii\")\n", 240 | "\n", 241 | " # Add image overlay to map with bounds and slight opacity\n", 242 | " m.add_layer(ImageOverlay(\n", 243 | " url=data_url,\n", 244 | " bounds=((south, west), (north, east)),\n", 245 | " opacity=0.95,\n", 246 | " name=f\"Hillshade {idx}\"\n", 247 | " ))\n", 248 | "\n", 249 | " display(m)" 250 | ], 251 | "metadata": { 252 | "id": "VoZTmj98cVVX" 253 | }, 254 | "execution_count": null, 255 | "outputs": [] 256 | }, 257 | { 258 | "cell_type": "code", 259 | "source": [], 260 | "metadata": { 261 | "id": "7b6_IxwGlrbN" 262 | }, 263 | "execution_count": null, 264 | "outputs": [] 265 | } 266 | ] 267 | } --------------------------------------------------------------------------------