From ebcb497425d86040ffef55bb46c077f145d24a15 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sun, 25 Jan 2026 00:54:22 -0500 Subject: [PATCH 1/5] Fix: Prevent titiler_endpoint from being overwritten in stac_tile When stac_stats fails for Planetary Computer items, it was overwriting the titiler_endpoint variable with a fallback string endpoint. This caused the subsequent tile request to use the wrong endpoint path (/stac/ instead of /item/), which expects different parameters and returns errors. Changes: 1. Use a separate fallback_endpoint variable for stac_stats fallback 2. Add error handling to check for 'tiles' key before accessing it 3. Provide informative error messages based on API response type Fixes opengeos/geoai#468 --- leafmap/stac.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/leafmap/stac.py b/leafmap/stac.py index 910cf9b84c..80f3e8da8e 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -706,12 +706,12 @@ def stac_tile( titiler_endpoint=titiler_endpoint, ) except Exception as e: - titiler_endpoint = "https://giswqs-titiler-endpoint.hf.space" + fallback_endpoint = "https://giswqs-titiler-endpoint.hf.space" stats = stac_stats( collection=collection, item=item, assets=assets, - titiler_endpoint=titiler_endpoint, + titiler_endpoint=fallback_endpoint, ) if "detail" not in stats: @@ -850,6 +850,26 @@ def stac_tile( titiler_endpoint.url_for_stac_item(), params=kwargs, timeout=10 ).json() + # Check if the response contains tiles + if "tiles" not in r: + # Try to provide helpful error message from the API response + if "detail" in r: + error_msg = f"TiTiler endpoint error: {r['detail']}" + elif isinstance(r, list) and len(r) > 0: + # Handle validation errors (list of error dicts) + if isinstance(r[0], dict) and "msg" in r[0]: + errors = [] + for e in r: + loc = "->".join(str(x) for x in e.get("loc", [])) + msg = e.get("msg", "") + errors.append(f"{loc}: {msg}") + error_msg = f"TiTiler endpoint validation failed: {'; '.join(errors)}" + else: + error_msg = f"TiTiler endpoint returned error list: {r}" + else: + error_msg = f"TiTiler endpoint returned unexpected response (missing 'tiles' key): {r}" + raise ValueError(error_msg) + tiles = r["tiles"][0] # Convert 127.0.0.1 to localhost for Google Colab browser access From e854aabddc015f8ee3cf5b1f8ea27feaff3936d9 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sun, 25 Jan 2026 01:29:34 -0500 Subject: [PATCH 2/5] Fix stac_bounds to handle GeoJSON Feature format from Planetary Computer The Planetary Computer /item/info.geojson endpoint returns a GeoJSON Feature with bounds nested in properties.{asset}.bounds, not a top-level bbox field. This fix: - Checks for bounds in properties.{asset}.bounds format - Falls back to calculating bbox from geometry coordinates - Ensures map zooms to correct location when visualizing NAIP items Fixes opengeos/geoai#468 --- leafmap/stac.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/leafmap/stac.py b/leafmap/stac.py index 80f3e8da8e..3daa89bb44 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -935,7 +935,29 @@ def stac_bounds( titiler_endpoint.url_for_stac_bounds(), params=kwargs ).json() - bounds = r["bbox"] + # Try to get bounds from different response formats + if "bbox" in r: + bounds = r["bbox"] + elif "properties" in r and isinstance(r["properties"], dict): + # Handle GeoJSON Feature format (Planetary Computer) + # Try to get bounds from properties.{asset}.bounds + for key, value in r["properties"].items(): + if isinstance(value, dict) and "bounds" in value: + bounds = value["bounds"] + break + else: + # Fallback to geometry bbox if available + if "geometry" in r and "coordinates" in r["geometry"]: + # Calculate bbox from geometry coordinates + coords = r["geometry"]["coordinates"][0] # Assuming Polygon + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + bounds = [min(lons), min(lats), max(lons), max(lats)] + else: + return None + else: + return None + return bounds except Exception as e: print(e) From c727884c0a497f3bf5c9cf20949ff5b6f1293242 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 06:30:06 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- leafmap/stac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/leafmap/stac.py b/leafmap/stac.py index 3daa89bb44..ae5208ffcc 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -957,7 +957,7 @@ def stac_bounds( return None else: return None - + return bounds except Exception as e: print(e) From 146a7a4b1dd5bea48b5a624bcb19b5d9040bb974 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sun, 25 Jan 2026 01:33:45 -0500 Subject: [PATCH 4/5] Silently handle stac_stats failures instead of printing errors When stac_stats fails (e.g., for Planetary Computer items that don't support the statistics endpoint), the errors were being printed to stdout, cluttering the output. This fix: - Wraps both primary and fallback stac_stats calls in try-except - Silently continues without rescaling if stats are unavailable - Removes the print(stats['detail']) statement - Sets stats=None if both attempts fail The tile visualization works fine without stats - they're only used for automatic rescaling, which isn't critical for display. Fixes opengeos/geoai#468 --- leafmap/stac.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/leafmap/stac.py b/leafmap/stac.py index ae5208ffcc..90cc6047bb 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -698,6 +698,7 @@ def stac_tile( and ("expression" not in kwargs) and ("rescale" not in kwargs) ): + stats = None try: stats = stac_stats( collection=collection, @@ -705,16 +706,21 @@ def stac_tile( assets=assets, titiler_endpoint=titiler_endpoint, ) - except Exception as e: - fallback_endpoint = "https://giswqs-titiler-endpoint.hf.space" - stats = stac_stats( - collection=collection, - item=item, - assets=assets, - titiler_endpoint=fallback_endpoint, - ) + except Exception: + # Try fallback endpoint if primary fails + try: + fallback_endpoint = "https://giswqs-titiler-endpoint.hf.space" + stats = stac_stats( + collection=collection, + item=item, + assets=assets, + titiler_endpoint=fallback_endpoint, + ) + except Exception: + # Stats not available, continue without rescaling + stats = None - if "detail" not in stats: + if stats and "detail" not in stats: try: percentile_2 = min([stats[s]["percentile_2"] for s in stats]) percentile_98 = max([stats[s]["percentile_98"] for s in stats]) @@ -732,8 +738,7 @@ def stac_tile( ] ) kwargs["rescale"] = f"{percentile_2},{percentile_98}" - else: - print(stats["detail"]) # When operation times out. + # Silently continue without stats if they're not available else: data = requests.get(url).json() From 66a1dcd579df49a74e92cb66cccf1c8b5798f64d Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sun, 25 Jan 2026 01:38:32 -0500 Subject: [PATCH 5/5] Fix check_titiler_endpoint to handle endpoint objects properly The real bug: check_titiler_endpoint was calling .lower() on the endpoint parameter without checking if it was a string first. When stac_stats was called with a PlanetaryComputerEndpoint object, it would pass it through check_titiler_endpoint again, which would fail trying to call .lower() on the object. This caused stac_stats to fail and fall back to the giswqs endpoint, which then printed error messages. The fix: - Check isinstance(titiler_endpoint, str) before calling .lower() - Allow endpoint objects to pass through unchanged - Now stac_stats works correctly with Planetary Computer from the start - No more fallback needed, no more error messages - Stats are properly retrieved and used for automatic rescaling This is the proper fix instead of just silencing errors. Fixes opengeos/geoai#468 --- leafmap/stac.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/leafmap/stac.py b/leafmap/stac.py index 90cc6047bb..1187c9156f 100644 --- a/leafmap/stac.py +++ b/leafmap/stac.py @@ -119,7 +119,11 @@ def check_titiler_endpoint(titiler_endpoint: Optional[str] = None) -> Any: Returns: The titiler endpoint. """ - if titiler_endpoint is not None and titiler_endpoint.lower() == "local": + if ( + titiler_endpoint is not None + and isinstance(titiler_endpoint, str) + and titiler_endpoint.lower() == "local" + ): titiler_endpoint = run_titiler(show_logs=False) elif titiler_endpoint is None: if os.environ.get("TITILER_ENDPOINT") is not None: @@ -129,8 +133,12 @@ def check_titiler_endpoint(titiler_endpoint: Optional[str] = None) -> Any: titiler_endpoint = PlanetaryComputerEndpoint() else: titiler_endpoint = "https://giswqs-titiler-endpoint.hf.space" - elif titiler_endpoint in ["planetary-computer", "pc"]: + elif isinstance(titiler_endpoint, str) and titiler_endpoint in [ + "planetary-computer", + "pc", + ]: titiler_endpoint = PlanetaryComputerEndpoint() + # If it's already an endpoint object, just return it as-is return titiler_endpoint @@ -698,7 +706,6 @@ def stac_tile( and ("expression" not in kwargs) and ("rescale" not in kwargs) ): - stats = None try: stats = stac_stats( collection=collection, @@ -706,21 +713,16 @@ def stac_tile( assets=assets, titiler_endpoint=titiler_endpoint, ) - except Exception: - # Try fallback endpoint if primary fails - try: - fallback_endpoint = "https://giswqs-titiler-endpoint.hf.space" - stats = stac_stats( - collection=collection, - item=item, - assets=assets, - titiler_endpoint=fallback_endpoint, - ) - except Exception: - # Stats not available, continue without rescaling - stats = None + except Exception as e: + fallback_endpoint = "https://giswqs-titiler-endpoint.hf.space" + stats = stac_stats( + collection=collection, + item=item, + assets=assets, + titiler_endpoint=fallback_endpoint, + ) - if stats and "detail" not in stats: + if "detail" not in stats: try: percentile_2 = min([stats[s]["percentile_2"] for s in stats]) percentile_98 = max([stats[s]["percentile_98"] for s in stats]) @@ -738,7 +740,6 @@ def stac_tile( ] ) kwargs["rescale"] = f"{percentile_2},{percentile_98}" - # Silently continue without stats if they're not available else: data = requests.get(url).json()