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:
parent
db6ed2f550
commit
4a5035ca52
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user