# app.py (versión con logging, manejo de TileOutsideBounds y endpoint debug hillshade) import io import os import logging import numpy as np from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware from rio_tiler.io import COGReader from rio_tiler.errors import TileOutsideBounds from PIL import Image COG_URL = os.environ.get("COG_URL", "https://huggingface.co/datasets/Pingul/mexico-dem/resolve/main/Mexico_DEM_COG.tif") # COG_URL = os.environ.get("COG_URL", "https://huggingface.co/datasets/Pingul/mexico-dem/resolve/main/CEM_15m_COG.tif") TILE_SIZE = 256 # Logging simple logging.basicConfig(level=logging.INFO) logger = logging.getLogger("dem-tiler") app = FastAPI(title="Mexico DEM TerrainRGB API (debuggable)") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) def encode_terrainrgb(arr: np.ndarray) -> np.ndarray: """ Convierte elevación en metros a Terrain-RGB (Mapbox spec). """ arr = np.array(arr, dtype=np.float32) arr = np.clip(arr, -10000, 9000) arr = (arr + 10000.0) * 10.0 # mapbox scale R = np.floor(arr / (256.0 * 256.0)) G = np.floor((arr - R * 256.0 * 256.0) / 256.0) B = np.floor(arr - R * 256.0 * 256.0 - G * 256.0) rgb = np.stack([R, G, B], axis=-1).astype(np.uint8) return rgb def elev_to_grayscale(arr: np.ndarray) -> np.ndarray: """ Convierte elevación a imagen grayscale para debug: mapea min->0 max->255. NaNs se convierten a 0 (transparente later). """ a = np.array(arr, dtype=np.float32) a = np.where(np.isfinite(a), a, np.nan) # clamp to sensible range (optional) vmin = np.nanpercentile(a, 2) if np.isfinite(np.nanpercentile(a, 2)) else np.nanmin(a) vmax = np.nanpercentile(a, 98) if np.isfinite(np.nanpercentile(a, 98)) else np.nanmax(a) if np.isnan(vmin) or np.isnan(vmax) or vmin == vmax: vmin, vmax = np.nanmin(a), np.nanmax(a) # handle edge cases if np.isnan(vmin): vmin = 0.0 if np.isnan(vmax): vmax = vmin + 1.0 norm = (a - vmin) / (vmax - vmin) norm = np.clip(norm, 0.0, 1.0) img = (norm * 255).astype(np.uint8) # convert to RGB rgb = np.stack([img, img, img], axis=-1) return rgb @app.get("/") def root(): return {"message": "Mexico DEM TerrainRGB API running", "cog_url": COG_URL} @app.get("/health") def health(): return {"status": "ok"} @app.get("/info") def get_info(): try: with COGReader(COG_URL) as cog: meta = cog.info() # return a small, JSON-serializable subset out = { "bounds": meta.bounds, # [left, bottom, right, top] in CRS of dataset (EPSG:3857) "minzoom": meta.minzoom, "maxzoom": meta.maxzoom, "band_descriptions": meta.band_descriptions, "statistics": { "min": getattr(meta, "min", None), "max": getattr(meta, "max", None) }, "crs": str(meta.crs) } return out except Exception as e: logger.exception("Error reading COG info") return {"error": str(e)} @app.get("/tiles/{z}/{x}/{y}.png") def get_tile(z: int, x: int, y: int): """ Tile endpoint: devuelve Terrain-RGB PNG (Mapbox spec). Si el tile está fuera de bounds, devuelve PNG transparente. """ try: with COGReader(COG_URL) as cog: data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE) elev = data[0].astype(np.float32) # Replace nodata values with NaN if mask indicates no-data if mask is not None: # rio-tiler mask: 255 valid, 0 invalid (varies), handle robustly invalid = (mask == 0) elev = elev.astype(np.float32) elev[invalid] = np.nan # Handle all-nodata -> return transparent if np.all(np.isnan(elev)): raise TileOutsideBounds(f"All NaN for tile {z}/{x}/{y}") rgb = encode_terrainrgb(np.nan_to_num(elev, nan=-10000.0)) img = Image.fromarray(rgb, "RGB") buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return Response(buf.getvalue(), media_type="image/png") except TileOutsideBounds: # tile fuera de bounds: PNG transparente blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0)) buf = io.BytesIO() blank.save(buf, format="PNG") buf.seek(0) return Response(buf.getvalue(), media_type="image/png") except Exception as e: logger.exception(f"Unexpected error generating tile {z}/{x}/{y}") # Devuelve 500 con mensaje corto (no stacktrace) return Response(status_code=500, content=f"Server error generating tile: {str(e)}") @app.get("/debug/{z}/{x}/{y}.png") def debug_tile(z: int, x: int, y: int): """ Endpoint de debug: devuelve una imagen en escala de grises (hillshade-like) para ver visualmente si la tile solicitada coincide con tu DEM. """ try: with COGReader(COG_URL) as cog: data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE) elev = data[0].astype(np.float32) # mask nodata if mask is not None: elev[mask == 0] = np.nan if np.all(np.isnan(elev)): raise TileOutsideBounds() # convert elevation to grayscale RGB for debug rgb = elev_to_grayscale(elev) # if any nodata, set those pixels transparent by returning RGBA alpha = np.where(np.isfinite(elev), 255, 0).astype(np.uint8) rgba = np.dstack([rgb, alpha]) img = Image.fromarray(rgba, "RGBA") buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return Response(buf.getvalue(), media_type="image/png") except TileOutsideBounds: blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0)) buf = io.BytesIO() blank.save(buf, format="PNG") buf.seek(0) return Response(buf.getvalue(), media_type="image/png") except Exception as e: logger.exception("Error in debug_tile") return Response(status_code=500, content=f"Error: {e}") @app.get("/point") def get_point(lon: float, lat: float): try: with COGReader(COG_URL) as cog: val = cog.point(lon, lat) elevation = float(val.data[0]) return {"lon": lon, "lat": lat, "elevation_m": elevation} except Exception as e: logger.exception("Error sampling point") return Response(status_code=500, content=f"Error sampling point: {e}")