File size: 6,810 Bytes
0e9faee
ab75598
 
 
 
 
 
 
 
 
 
6eaf92d
 
ab75598
 
55b311e
ab75598
 
 
0e9faee
ab75598
 
 
 
 
 
 
 
 
a668f53
ab75598
a668f53
ab75598
a668f53
 
 
 
 
 
 
 
 
 
00d5206
55b311e
 
 
 
 
 
 
0e9faee
55b311e
 
 
 
0e9faee
55b311e
 
 
 
 
 
0e9faee
55b311e
 
 
ab75598
 
0e9faee
ab75598
 
 
 
 
 
 
 
55b311e
 
0e9faee
55b311e
0e9faee
55b311e
 
 
 
 
 
 
 
 
 
ab75598
 
 
 
 
 
 
0e9faee
 
ab75598
 
0e9faee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab75598
 
0e9faee
55b311e
ab75598
 
 
55b311e
0e9faee
 
55b311e
ab75598
55b311e
 
 
0e9faee
55b311e
 
 
 
 
 
0e9faee
55b311e
0e9faee
55b311e
 
 
 
 
 
 
ab75598
 
 
 
55b311e
ab75598
 
 
 
 
 
 
0b75be1
ab75598
0b75be1
55b311e
 
0b75be1
ab75598
0b75be1
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# 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}")