Pingul commited on
Commit
ab75598
·
verified ·
1 Parent(s): 088545b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +186 -0
app.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py (versión con logging, manejo de TileOutsideBounds y endpoint debug hillshade)
2
+ import io
3
+ import os
4
+ import logging
5
+ import numpy as np
6
+ from fastapi import FastAPI, Response
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from rio_tiler.io import COGReader
9
+ from rio_tiler.errors import TileOutsideBounds
10
+ from PIL import Image
11
+
12
+ COG_URL = os.environ.get("COG_URL", "https://huggingface.co/datasets/Pingul/mexico-dem/resolve/main/Mexico_DEM_COG.tif")
13
+ TILE_SIZE = 256
14
+
15
+ # Logging simple
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger("dem-tiler")
18
+
19
+ app = FastAPI(title="Mexico DEM TerrainRGB API (debuggable)")
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=True,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ def encode_terrainrgb(arr: np.ndarray) -> np.ndarray:
30
+ """
31
+ Convierte elevación en metros a Terrain-RGB (Mapbox spec).
32
+ """
33
+ arr = np.array(arr, dtype=np.float32)
34
+ arr = np.clip(arr, -10000, 9000)
35
+ arr = (arr + 10000.0) * 10.0 # mapbox scale
36
+
37
+ R = np.floor(arr / (256.0 * 256.0))
38
+ G = np.floor((arr - R * 256.0 * 256.0) / 256.0)
39
+ B = np.floor(arr - R * 256.0 * 256.0 - G * 256.0)
40
+
41
+ rgb = np.stack([R, G, B], axis=-1).astype(np.uint8)
42
+ return rgb
43
+
44
+ def elev_to_grayscale(arr: np.ndarray) -> np.ndarray:
45
+ """
46
+ Convierte elevación a imagen grayscale para debug: mapea min->0 max->255.
47
+ NaNs se convierten a 0 (transparente later).
48
+ """
49
+ a = np.array(arr, dtype=np.float32)
50
+ a = np.where(np.isfinite(a), a, np.nan)
51
+ # clamp to sensible range (optional)
52
+ vmin = np.nanpercentile(a, 2) if np.isfinite(np.nanpercentile(a, 2)) else np.nanmin(a)
53
+ vmax = np.nanpercentile(a, 98) if np.isfinite(np.nanpercentile(a, 98)) else np.nanmax(a)
54
+ if np.isnan(vmin) or np.isnan(vmax) or vmin == vmax:
55
+ vmin, vmax = np.nanmin(a), np.nanmax(a)
56
+ # handle edge cases
57
+ if np.isnan(vmin): vmin = 0.0
58
+ if np.isnan(vmax): vmax = vmin + 1.0
59
+
60
+ norm = (a - vmin) / (vmax - vmin)
61
+ norm = np.clip(norm, 0.0, 1.0)
62
+ img = (norm * 255).astype(np.uint8)
63
+ # convert to RGB
64
+ rgb = np.stack([img, img, img], axis=-1)
65
+ return rgb
66
+
67
+ @app.get("/")
68
+ def root():
69
+ return {"message": "Mexico DEM TerrainRGB API running", "cog_url": COG_URL}
70
+
71
+ @app.get("/health")
72
+ def health():
73
+ return {"status": "ok"}
74
+
75
+ @app.get("/info")
76
+ def get_info():
77
+ try:
78
+ with COGReader(COG_URL) as cog:
79
+ meta = cog.info()
80
+ # return a small, JSON-serializable subset
81
+ out = {
82
+ "bounds": meta.bounds, # [left, bottom, right, top] in CRS of dataset (EPSG:3857)
83
+ "minzoom": meta.minzoom,
84
+ "maxzoom": meta.maxzoom,
85
+ "band_descriptions": meta.band_descriptions,
86
+ "statistics": {
87
+ "min": getattr(meta, "min", None),
88
+ "max": getattr(meta, "max", None)
89
+ },
90
+ "crs": str(meta.crs)
91
+ }
92
+ return out
93
+ except Exception as e:
94
+ logger.exception("Error reading COG info")
95
+ return {"error": str(e)}
96
+
97
+ @app.get("/tiles/{z}/{x}/{y}.png")
98
+ def get_tile(z: int, x: int, y: int):
99
+ """
100
+ Tile endpoint: devuelve Terrain-RGB PNG (Mapbox spec).
101
+ Si el tile está fuera de bounds, devuelve PNG transparente.
102
+ """
103
+ try:
104
+ with COGReader(COG_URL) as cog:
105
+ data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
106
+ elev = data[0].astype(np.float32)
107
+
108
+ # Replace nodata values with NaN if mask indicates no-data
109
+ if mask is not None:
110
+ # rio-tiler mask: 255 valid, 0 invalid (varies), handle robustly
111
+ invalid = (mask == 0)
112
+ elev = elev.astype(np.float32)
113
+ elev[invalid] = np.nan
114
+
115
+ # Handle all-nodata -> return transparent
116
+ if np.all(np.isnan(elev)):
117
+ raise TileOutsideBounds(f"All NaN for tile {z}/{x}/{y}")
118
+
119
+ rgb = encode_terrainrgb(np.nan_to_num(elev, nan=-10000.0))
120
+ img = Image.fromarray(rgb, "RGB")
121
+ buf = io.BytesIO()
122
+ img.save(buf, format="PNG")
123
+ buf.seek(0)
124
+ return Response(buf.getvalue(), media_type="image/png")
125
+
126
+ except TileOutsideBounds:
127
+ # tile fuera de bounds: PNG transparente
128
+ blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
129
+ buf = io.BytesIO()
130
+ blank.save(buf, format="PNG")
131
+ buf.seek(0)
132
+ return Response(buf.getvalue(), media_type="image/png")
133
+
134
+ except Exception as e:
135
+ logger.exception(f"Unexpected error generating tile {z}/{x}/{y}")
136
+ # Devuelve 500 con mensaje corto (no stacktrace)
137
+ return Response(status_code=500, content=f"Server error generating tile: {str(e)}")
138
+
139
+ @app.get("/debug/{z}/{x}/{y}.png")
140
+ def debug_tile(z: int, x: int, y: int):
141
+ """
142
+ Endpoint de debug: devuelve una imagen en escala de grises (hillshade-like) para ver visualmente
143
+ si la tile solicitada coincide con tu DEM.
144
+ """
145
+ try:
146
+ with COGReader(COG_URL) as cog:
147
+ data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
148
+ elev = data[0].astype(np.float32)
149
+ # mask nodata
150
+ if mask is not None:
151
+ elev[mask == 0] = np.nan
152
+
153
+ if np.all(np.isnan(elev)):
154
+ raise TileOutsideBounds()
155
+
156
+ # convert elevation to grayscale RGB for debug
157
+ rgb = elev_to_grayscale(elev)
158
+ # if any nodata, set those pixels transparent by returning RGBA
159
+ alpha = np.where(np.isfinite(elev), 255, 0).astype(np.uint8)
160
+ rgba = np.dstack([rgb, alpha])
161
+ img = Image.fromarray(rgba, "RGBA")
162
+ buf = io.BytesIO()
163
+ img.save(buf, format="PNG")
164
+ buf.seek(0)
165
+ return Response(buf.getvalue(), media_type="image/png")
166
+
167
+ except TileOutsideBounds:
168
+ blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
169
+ buf = io.BytesIO()
170
+ blank.save(buf, format="PNG")
171
+ buf.seek(0)
172
+ return Response(buf.getvalue(), media_type="image/png")
173
+ except Exception as e:
174
+ logger.exception("Error in debug_tile")
175
+ return Response(status_code=500, content=f"Error: {e}")
176
+
177
+ @app.get("/point")
178
+ def get_point(lon: float, lat: float):
179
+ try:
180
+ with COGReader(COG_URL) as cog:
181
+ val = cog.point(lon, lat)
182
+ elevation = float(val.data[0])
183
+ return {"lon": lon, "lat": lat, "elevation_m": elevation}
184
+ except Exception as e:
185
+ logger.exception("Error sampling point")
186
+ return Response(status_code=500, content=f"Error sampling point: {e}")