Pingul commited on
Commit
00d5206
·
verified ·
1 Parent(s): 4007d7d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +174 -117
app.py CHANGED
@@ -1,8 +1,9 @@
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
@@ -10,14 +11,13 @@ 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
- # COG_URL = os.environ.get("COG_URL", "https://huggingface.co/datasets/Pingul/mexico-dem/resolve/main/CEM_15m_COG.tif")
14
  TILE_SIZE = 256
 
15
 
16
- # Logging simple
17
  logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger("dem-tiler")
19
 
20
- app = FastAPI(title="Mexico DEM TerrainRGB API (debuggable)")
21
 
22
  app.add_middleware(
23
  CORSMiddleware,
@@ -27,70 +27,147 @@ app.add_middleware(
27
  allow_headers=["*"],
28
  )
29
 
30
- def encode_terrainrgb(arr: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  """
32
- Convierte elevación en metros a Terrain-RGB (Mapbox spec).
 
33
  """
34
- arr = np.array(arr, dtype=np.float32)
35
- arr = np.clip(arr, -10000, 9000)
36
- arr = (arr + 10000.0) * 10.0 # mapbox scale
37
-
38
- R = np.floor(arr / (256.0 * 256.0))
39
- G = np.floor((arr - R * 256.0 * 256.0) / 256.0)
40
- B = np.floor(arr - R * 256.0 * 256.0 - G * 256.0)
41
-
42
- rgb = np.stack([R, G, B], axis=-1).astype(np.uint8)
43
- return rgb
44
-
45
- def elev_to_grayscale(arr: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  """
47
- Convierte elevación a imagen grayscale para debug: mapea min->0 max->255.
48
- NaNs se convierten a 0 (transparente later).
49
  """
50
- a = np.array(arr, dtype=np.float32)
51
- a = np.where(np.isfinite(a), a, np.nan)
52
- # clamp to sensible range (optional)
53
- vmin = np.nanpercentile(a, 2) if np.isfinite(np.nanpercentile(a, 2)) else np.nanmin(a)
54
- vmax = np.nanpercentile(a, 98) if np.isfinite(np.nanpercentile(a, 98)) else np.nanmax(a)
55
- if np.isnan(vmin) or np.isnan(vmax) or vmin == vmax:
56
- vmin, vmax = np.nanmin(a), np.nanmax(a)
57
- # handle edge cases
58
- if np.isnan(vmin): vmin = 0.0
59
- if np.isnan(vmax): vmax = vmin + 1.0
60
-
61
- norm = (a - vmin) / (vmax - vmin)
62
- norm = np.clip(norm, 0.0, 1.0)
63
- img = (norm * 255).astype(np.uint8)
64
- # convert to RGB
65
- rgb = np.stack([img, img, img], axis=-1)
66
- return rgb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
 
 
 
68
  @app.get("/")
69
  def root():
70
- return {"message": "Mexico DEM TerrainRGB API running", "cog_url": COG_URL}
71
 
72
  @app.get("/health")
73
  def health():
74
  return {"status": "ok"}
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  @app.get("/info")
77
  def get_info():
78
  try:
79
- with COGReader(COG_URL) as cog:
80
- meta = cog.info()
81
- # return a small, JSON-serializable subset
82
- out = {
83
- "bounds": meta.bounds, # [left, bottom, right, top] in CRS of dataset (EPSG:3857)
84
- "minzoom": meta.minzoom,
85
- "maxzoom": meta.maxzoom,
86
- "band_descriptions": meta.band_descriptions,
87
- "statistics": {
88
- "min": getattr(meta, "min", None),
89
- "max": getattr(meta, "max", None)
90
- },
91
- "crs": str(meta.crs)
92
- }
93
- return out
94
  except Exception as e:
95
  logger.exception("Error reading COG info")
96
  return {"error": str(e)}
@@ -98,77 +175,49 @@ def get_info():
98
  @app.get("/tiles/{z}/{x}/{y}.png")
99
  def get_tile(z: int, x: int, y: int):
100
  """
101
- Tile endpoint: devuelve Terrain-RGB PNG (Mapbox spec).
102
- Si el tile está fuera de bounds, devuelve PNG transparente.
103
  """
104
  try:
105
- with COGReader(COG_URL) as cog:
106
- data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
107
- elev = data[0].astype(np.float32)
108
-
109
- # Replace nodata values with NaN if mask indicates no-data
110
- if mask is not None:
111
- # rio-tiler mask: 255 valid, 0 invalid (varies), handle robustly
112
- invalid = (mask == 0)
113
- elev = elev.astype(np.float32)
114
- elev[invalid] = np.nan
115
-
116
- # Handle all-nodata -> return transparent
117
- if np.all(np.isnan(elev)):
118
- raise TileOutsideBounds(f"All NaN for tile {z}/{x}/{y}")
119
-
120
- rgb = encode_terrainrgb(np.nan_to_num(elev, nan=-10000.0))
121
- img = Image.fromarray(rgb, "RGB")
122
- buf = io.BytesIO()
123
- img.save(buf, format="PNG")
124
- buf.seek(0)
125
- return Response(buf.getvalue(), media_type="image/png")
126
-
127
- except TileOutsideBounds:
128
- # tile fuera de bounds: PNG transparente
129
- blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
130
- buf = io.BytesIO()
131
- blank.save(buf, format="PNG")
132
- buf.seek(0)
133
- return Response(buf.getvalue(), media_type="image/png")
134
-
135
  except Exception as e:
136
  logger.exception(f"Unexpected error generating tile {z}/{x}/{y}")
137
- # Devuelve 500 con mensaje corto (no stacktrace)
138
- return Response(status_code=500, content=f"Server error generating tile: {str(e)}")
139
 
140
  @app.get("/debug/{z}/{x}/{y}.png")
141
  def debug_tile(z: int, x: int, y: int):
142
- """
143
- Endpoint de debug: devuelve una imagen en escala de grises (hillshade-like) para ver visualmente
144
- si la tile solicitada coincide con tu DEM.
145
- """
146
  try:
147
- with COGReader(COG_URL) as cog:
148
- data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
149
- elev = data[0].astype(np.float32)
150
- # mask nodata
151
- if mask is not None:
152
- elev[mask == 0] = np.nan
153
-
154
- if np.all(np.isnan(elev)):
155
- raise TileOutsideBounds()
156
-
157
- # convert elevation to grayscale RGB for debug
158
- rgb = elev_to_grayscale(elev)
159
- # if any nodata, set those pixels transparent by returning RGBA
160
- alpha = np.where(np.isfinite(elev), 255, 0).astype(np.uint8)
161
- rgba = np.dstack([rgb, alpha])
162
- img = Image.fromarray(rgba, "RGBA")
163
- buf = io.BytesIO()
164
- img.save(buf, format="PNG")
165
- buf.seek(0)
166
- return Response(buf.getvalue(), media_type="image/png")
 
 
 
 
 
167
 
168
  except TileOutsideBounds:
169
  blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
170
  buf = io.BytesIO()
171
- blank.save(buf, format="PNG")
172
  buf.seek(0)
173
  return Response(buf.getvalue(), media_type="image/png")
174
  except Exception as e:
@@ -178,10 +227,18 @@ def debug_tile(z: int, x: int, y: int):
178
  @app.get("/point")
179
  def get_point(lon: float, lat: float):
180
  try:
181
- with COGReader(COG_URL) as cog:
182
- val = cog.point(lon, lat)
183
- elevation = float(val.data[0])
184
- return {"lon": lon, "lat": lat, "elevation_m": elevation}
185
  except Exception as e:
186
  logger.exception("Error sampling point")
187
- return Response(status_code=500, content=f"Error sampling point: {e}")
 
 
 
 
 
 
 
 
 
1
+ # app.py (versión optimizada)
2
  import io
3
  import os
4
  import logging
5
  import numpy as np
6
+ from functools import lru_cache
7
  from fastapi import FastAPI, Response
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from rio_tiler.io import COGReader
 
11
  from PIL import Image
12
 
13
  COG_URL = os.environ.get("COG_URL", "https://huggingface.co/datasets/Pingul/mexico-dem/resolve/main/Mexico_DEM_COG.tif")
 
14
  TILE_SIZE = 256
15
+ CACHE_SIZE = 512 # Cachear últimas 512 tiles
16
 
 
17
  logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger("dem-tiler")
19
 
20
+ app = FastAPI(title="Mexico DEM TerrainRGB API (optimizado)")
21
 
22
  app.add_middleware(
23
  CORSMiddleware,
 
27
  allow_headers=["*"],
28
  )
29
 
30
+ # ============================================================================
31
+ # OPTIMIZACIÓN 1: COGReader global reutilizable
32
+ # ============================================================================
33
+ _cog_reader = None
34
+
35
+ def get_cog_reader():
36
+ """Reutiliza la misma conexión COG en lugar de abrir/cerrar cada vez"""
37
+ global _cog_reader
38
+ if _cog_reader is None:
39
+ _cog_reader = COGReader(COG_URL)
40
+ return _cog_reader
41
+
42
+ # ============================================================================
43
+ # OPTIMIZACIÓN 2: Encoding Terrain-RGB más rápido (vectorizado eficiente)
44
+ # ============================================================================
45
+ def encode_terrainrgb_fast(arr: np.ndarray) -> np.ndarray:
46
  """
47
+ Versión optimizada de Terrain-RGB encoding.
48
+ Usa operaciones in-place y evita copias innecesarias.
49
  """
50
+ # Clamp valores
51
+ arr = np.clip(arr, -10000, 9000, out=arr)
52
+
53
+ # Escala Mapbox
54
+ arr *= 10.0
55
+ arr += 100000.0 # (arr + 10000) * 10
56
+
57
+ # Descomposición RGB más eficiente
58
+ arr_int = arr.astype(np.uint32)
59
+
60
+ R = (arr_int >> 16) & 0xFF
61
+ G = (arr_int >> 8) & 0xFF
62
+ B = arr_int & 0xFF
63
+
64
+ # Stack directo sin copies intermedias
65
+ return np.stack([R, G, B], axis=-1).astype(np.uint8)
66
+
67
+ # Alternativa aún más rápida si numexpr está disponible:
68
+ try:
69
+ import numexpr as ne
70
+ def encode_terrainrgb_fast(arr: np.ndarray) -> np.ndarray:
71
+ arr = np.clip(arr, -10000, 9000)
72
+ scaled = ne.evaluate("(arr + 10000.0) * 10.0")
73
+
74
+ R = np.floor(scaled / 65536.0).astype(np.uint8)
75
+ G = np.floor((scaled % 65536.0) / 256.0).astype(np.uint8)
76
+ B = (scaled % 256.0).astype(np.uint8)
77
+
78
+ return np.stack([R, G, B], axis=-1)
79
+ except ImportError:
80
+ pass # Usa la versión sin numexpr
81
+
82
+ # ============================================================================
83
+ # OPTIMIZACIÓN 3: Cache LRU para tiles
84
+ # ============================================================================
85
+ @lru_cache(maxsize=CACHE_SIZE)
86
+ def get_tile_cached(z: int, x: int, y: int) -> bytes:
87
  """
88
+ Cachea tiles generadas. Retorna bytes directamente.
89
+ LRU cache guarda las tiles más solicitadas en memoria.
90
  """
91
+ try:
92
+ cog = get_cog_reader()
93
+ data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
94
+ elev = data[0].astype(np.float32)
95
+
96
+ # Aplicar mask si existe
97
+ if mask is not None:
98
+ invalid = (mask == 0)
99
+ elev[invalid] = np.nan
100
+
101
+ # Check si todo es nodata
102
+ if np.all(np.isnan(elev)):
103
+ raise TileOutsideBounds(f"All NaN for tile {z}/{x}/{y}")
104
+
105
+ # Reemplazar NaN con valor base
106
+ np.nan_to_num(elev, copy=False, nan=-10000.0)
107
+
108
+ # Encode a Terrain-RGB
109
+ rgb = encode_terrainrgb_fast(elev)
110
+
111
+ # Convertir a PNG con compresión rápida
112
+ img = Image.fromarray(rgb, "RGB")
113
+ buf = io.BytesIO()
114
+ img.save(buf, format="PNG", compress_level=1) # compress_level=1 es MUY importante
115
+
116
+ return buf.getvalue()
117
+
118
+ except TileOutsideBounds:
119
+ # Tile transparente (cacheable también)
120
+ blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
121
+ buf = io.BytesIO()
122
+ blank.save(buf, format="PNG", compress_level=1)
123
+ return buf.getvalue()
124
 
125
+ # ============================================================================
126
+ # ENDPOINTS
127
+ # ============================================================================
128
  @app.get("/")
129
  def root():
130
+ return {"message": "Mexico DEM TerrainRGB API (optimizado)", "cog_url": COG_URL}
131
 
132
  @app.get("/health")
133
  def health():
134
  return {"status": "ok"}
135
 
136
+ @app.get("/cache/stats")
137
+ def cache_stats():
138
+ """Endpoint para ver estadísticas del cache"""
139
+ info = get_tile_cached.cache_info()
140
+ return {
141
+ "hits": info.hits,
142
+ "misses": info.misses,
143
+ "size": info.currsize,
144
+ "maxsize": info.maxsize,
145
+ "hit_rate": f"{info.hits / (info.hits + info.misses) * 100:.1f}%" if (info.hits + info.misses) > 0 else "0%"
146
+ }
147
+
148
+ @app.post("/cache/clear")
149
+ def clear_cache():
150
+ """Limpia el cache de tiles"""
151
+ get_tile_cached.cache_clear()
152
+ return {"status": "cache cleared"}
153
+
154
  @app.get("/info")
155
  def get_info():
156
  try:
157
+ cog = get_cog_reader()
158
+ meta = cog.info()
159
+ out = {
160
+ "bounds": meta.bounds,
161
+ "minzoom": meta.minzoom,
162
+ "maxzoom": meta.maxzoom,
163
+ "band_descriptions": meta.band_descriptions,
164
+ "statistics": {
165
+ "min": getattr(meta, "min", None),
166
+ "max": getattr(meta, "max", None)
167
+ },
168
+ "crs": str(meta.crs)
169
+ }
170
+ return out
 
171
  except Exception as e:
172
  logger.exception("Error reading COG info")
173
  return {"error": str(e)}
 
175
  @app.get("/tiles/{z}/{x}/{y}.png")
176
  def get_tile(z: int, x: int, y: int):
177
  """
178
+ Tile endpoint optimizado con cache.
 
179
  """
180
  try:
181
+ tile_bytes = get_tile_cached(z, x, y)
182
+ return Response(tile_bytes, media_type="image/png")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  except Exception as e:
184
  logger.exception(f"Unexpected error generating tile {z}/{x}/{y}")
185
+ return Response(status_code=500, content=f"Server error: {str(e)}")
 
186
 
187
  @app.get("/debug/{z}/{x}/{y}.png")
188
  def debug_tile(z: int, x: int, y: int):
189
+ """Endpoint de debug (sin cache para ver cambios en tiempo real)"""
 
 
 
190
  try:
191
+ cog = get_cog_reader()
192
+ data, mask = cog.tile(x, y, z, tilesize=TILE_SIZE)
193
+ elev = data[0].astype(np.float32)
194
+
195
+ if mask is not None:
196
+ elev[mask == 0] = np.nan
197
+
198
+ if np.all(np.isnan(elev)):
199
+ raise TileOutsideBounds()
200
+
201
+ # Grayscale simple
202
+ a = np.where(np.isfinite(elev), elev, 0)
203
+ vmin, vmax = np.nanpercentile(a[a != 0], [2, 98]) if np.any(a != 0) else (0, 1)
204
+ norm = np.clip((a - vmin) / (vmax - vmin + 1e-8), 0, 1)
205
+ gray = (norm * 255).astype(np.uint8)
206
+
207
+ rgb = np.stack([gray, gray, gray], axis=-1)
208
+ alpha = np.where(np.isfinite(elev), 255, 0).astype(np.uint8)
209
+ rgba = np.dstack([rgb, alpha])
210
+
211
+ img = Image.fromarray(rgba, "RGBA")
212
+ buf = io.BytesIO()
213
+ img.save(buf, format="PNG", compress_level=1)
214
+ buf.seek(0)
215
+ return Response(buf.getvalue(), media_type="image/png")
216
 
217
  except TileOutsideBounds:
218
  blank = Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
219
  buf = io.BytesIO()
220
+ blank.save(buf, format="PNG", compress_level=1)
221
  buf.seek(0)
222
  return Response(buf.getvalue(), media_type="image/png")
223
  except Exception as e:
 
227
  @app.get("/point")
228
  def get_point(lon: float, lat: float):
229
  try:
230
+ cog = get_cog_reader()
231
+ val = cog.point(lon, lat)
232
+ elevation = float(val.data[0])
233
+ return {"lon": lon, "lat": lat, "elevation_m": elevation}
234
  except Exception as e:
235
  logger.exception("Error sampling point")
236
+ return Response(status_code=500, content=f"Error: {e}")
237
+
238
+ # Cleanup al cerrar
239
+ @app.on_event("shutdown")
240
+ def shutdown_event():
241
+ global _cog_reader
242
+ if _cog_reader is not None:
243
+ _cog_reader.close()
244
+ _cog_reader = None