FlutIQ / app /tools /mapbox.py
kredd25's picture
v0.10: 3-image multimodal β€” satellite + wider-area + street view in one Gemma 4 call
abae764
"""
Mapbox Static Images API β€” satellite + topographic map fetchers.
The risk analyst (v0.10+) reasons about three visual perspectives in
a single Gemma 4 inference call:
- Street view (Google) β€” eye-level: lot grade, basement entries, downspouts
- Satellite (Mapbox) β€” bird's-eye: impervious surface %, drainage proximity
- Topographic (Mapbox) β€” contour lines: micro-depressions, terrain slope
Each capture is fetched server-side, base64-encoded, and inlined as a
data: URL into the risk analyst's prompt. No upstream re-fetch.
Free tier: Mapbox gives 50K static-image loads/month per account, so
2 maps Γ— 25K assessments/month is well within budget.
If MAPBOX_ACCESS_TOKEN is not set the helpers return None and the
risk analyst gracefully falls back to whatever images it does have
(Street View only, or none).
"""
import base64
from typing import Optional
import httpx
from app.config import MAPBOX_ACCESS_TOKEN
# Both endpoints follow the Static Images API contract:
# /styles/v1/{user}/{style_id}/static/{lon},{lat},{zoom},{bearing}/{w}x{h}{@2x}
# We use the public 'mapbox' user's stock styles.
_BASE = "https://api.mapbox.com/styles/v1/mapbox"
# Satellite zoom 17 = tight neighborhood (~150m across @2x). Enough to
# distinguish individual buildings and impervious surfaces.
_SAT_STYLE = "satellite-v9"
_SAT_ZOOM = 17
# Topo / outdoor zoom 15 = wider area (~600m across @2x). Wide enough to
# show terrain context, drainage features, and named waterways.
_TOPO_STYLE = "outdoors-v12"
_TOPO_ZOOM = 15
# 600x600 @2x β†’ 1200x1200 actual pixels. Good detail for vision tokens
# without blowing the context budget.
_SIZE = "600x600@2x"
def _is_configured() -> bool:
return bool(MAPBOX_ACCESS_TOKEN)
async def _fetch_static(style: str, zoom: int, lat: float, lon: float) -> Optional[dict]:
if not _is_configured():
return None
url = (
f"{_BASE}/{style}/static/{lon},{lat},{zoom},0/{_SIZE}"
f"?access_token={MAPBOX_ACCESS_TOKEN}"
)
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(url)
if resp.status_code != 200:
return None
ct = resp.headers.get("content-type", "image/png").split(";")[0].strip()
b64 = base64.b64encode(resp.content).decode("ascii")
return {
"data_url": f"data:{ct};base64,{b64}",
"bytes": len(resp.content),
"style": style,
"zoom": zoom,
"lat": lat,
"lon": lon,
}
async def get_satellite_image(lat: float, lon: float) -> Optional[dict]:
return await _fetch_static(_SAT_STYLE, _SAT_ZOOM, lat, lon)
async def get_topo_image(lat: float, lon: float) -> Optional[dict]:
return await _fetch_static(_TOPO_STYLE, _TOPO_ZOOM, lat, lon)