Fix legend images: rewrite GIBS SVG URLs to PNG for API compatibility

GIBS capabilities list .svg legend URLs, but the Anthropic API only
accepts raster image types. GIBS hosts PNG versions at the same path,
so we rewrite .svg → .png and validate content-type on both legend
fetch paths (direct URL and WMS GetLegendGraphic fallback).
This commit is contained in:
Ryan Malloy 2026-02-18 18:53:15 -07:00
parent db6ed2f550
commit 4a5035ca52
2 changed files with 86 additions and 5 deletions

View File

@ -38,6 +38,9 @@ _INIT_RETRY_DELAYS = [2.0, 4.0, 8.0]
# Maximum image dimensions to prevent OOM from LLM requests # Maximum image dimensions to prevent OOM from LLM requests
MAX_IMAGE_DIMENSION = 4096 MAX_IMAGE_DIMENSION = 4096
# Content types accepted for legend images (SVG is not API-compatible)
_RASTER_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
class _LRUCache(OrderedDict): class _LRUCache(OrderedDict):
"""Simple LRU cache backed by OrderedDict.""" """Simple LRU cache backed by OrderedDict."""
@ -421,12 +424,22 @@ class GIBSClient:
layer_id: str, layer_id: str,
orientation: str = "horizontal", orientation: str = "horizontal",
) -> bytes | None: ) -> bytes | None:
"""Fetch the pre-rendered legend image for a layer.""" """Fetch the pre-rendered legend image for a layer.
GIBS capabilities list SVG legend URLs, but PNG versions exist
at the same path. We prefer PNG for broad client compatibility
(the Anthropic API and most image viewers don't render SVG).
"""
layer = self.layer_index.get(layer_id) layer = self.layer_index.get(layer_id)
if layer and layer.legend_url: if layer and layer.legend_url:
url = layer.legend_url
if url.endswith(".svg"):
url = url[:-4] + ".png"
try: try:
resp = await self.http.get(layer.legend_url) resp = await self.http.get(url)
resp.raise_for_status() resp.raise_for_status()
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
if content_type in _RASTER_IMAGE_TYPES:
return resp.content return resp.content
except httpx.HTTPError: except httpx.HTTPError:
pass pass
@ -446,8 +459,8 @@ class GIBSClient:
try: try:
resp = await self.http.get(url, params=params) resp = await self.http.get(url, params=params)
resp.raise_for_status() resp.raise_for_status()
content_type = resp.headers.get("content-type", "") content_type = resp.headers.get("content-type", "").split(";")[0].strip()
if content_type.startswith("image/"): if content_type in _RASTER_IMAGE_TYPES:
return resp.content return resp.content
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
log.debug("Legend not available for %s: %s", layer_id, exc) log.debug("Legend not available for %s: %s", layer_id, exc)

View File

@ -407,6 +407,74 @@ async def test_client_caches_successful_geocode(capabilities_xml):
await client.close() await client.close()
# ---------------------------------------------------------------------------
# Legend SVG → PNG rewriting (live API compatibility fix)
# ---------------------------------------------------------------------------
@respx.mock
async def test_legend_rewrites_svg_to_png(capabilities_xml):
"""Legend URLs ending in .svg should be rewritten to .png."""
# Modify the capabilities XML so AMSR2's legend URL ends in .svg
patched_xml = capabilities_xml.replace(
"AMSR2_Sea_Ice_Concentration_12km.png",
"AMSR2_Sea_Ice_Concentration_12km.svg",
)
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
return_value=httpx.Response(200, text=patched_xml)
)
buf = BytesIO()
Image.new("RGB", (200, 20), "white").save(buf, format="PNG")
png_route = respx.get(url__regex=r".*legends/.*\.png").mock(
return_value=httpx.Response(
200, content=buf.getvalue(), headers={"content-type": "image/png"}
)
)
client = GIBSClient()
await client.initialize()
result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly")
assert result is not None
# Verify the .png URL was requested, not .svg
assert png_route.call_count == 1
assert ".png" in str(png_route.calls.last.request.url)
await client.close()
@respx.mock
async def test_legend_rejects_svg_content(capabilities_xml):
"""If the legend URL returns SVG content, it should be rejected."""
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
return_value=httpx.Response(200, text=capabilities_xml)
)
respx.get(url__regex=r".*legends/.*\.png").mock(
return_value=httpx.Response(
200,
content=b'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"...',
headers={"content-type": "image/svg+xml"},
)
)
# WMS fallback also returns non-image
respx.get(url__regex=r".*wms\.cgi.*").mock(
return_value=httpx.Response(
200,
text="<ServiceException>not supported</ServiceException>",
headers={"content-type": "application/xml"},
)
)
client = GIBSClient()
await client.initialize()
result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly")
assert result is None
await client.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Client.http property guard # Client.http property guard
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------