RAVE SCOUT: análisis de aislamiento de España y visor de localizaciones

Barrido de los 498.528 km² de la península y Baleares sobre malla de 100 m
(EPSG:3035) para localizar sitios alejados de todo con acceso rodado.

- 12,5 M de edificios y 1,4 M de km de viales de OSM, incluidos los países
  vecinos para no inflar el aislamiento en la franja fronteriza
- Espacios protegidos oficiales (Red Natura 2000 + designación nacional) vía
  la Agencia Europea de Medio Ambiente: cubren el 29 % del país frente al 15 %
  que tenía OSM
- Modelo acústico con propagación real; es el filtro que de verdad corta, y
  deja 505 km² en toda España
- 355 localizaciones valoradas de 1 a 5 estrellas en seis criterios
- Visor autónomo con relieve, ortofoto PNOA por localización, relieve 3D y
  mapa deslizante con satélite, catastro y espacios protegidos
This commit is contained in:
sito 2026-08-08 20:29:27 +02:00
commit d4b3b803e0
394 changed files with 7393 additions and 0 deletions

133
src/render_map.py Normal file
View file

@ -0,0 +1,133 @@
"""Renderiza el mapa de aislamiento a PNG (versión clara y oscura).
Se dibuja solo España porque el análisis solo es válido dentro de España: los
datos de los vecinos entran para calcular distancias correctas en la frontera,
pero sus celdas no se puntúan ni se muestran.
Dos versiones de color porque un PNG no se adapta al tema de quien lo mira, y
el informe .
"""
import csv
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
F = 8 # factor de reducción -> ~1645 px de ancho
# Rampa secuencial azul (100 -> 700), magnitud continua, un solo tono.
RAMP = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7",
"#3987e5", "#2a78d6", "#256abf", "#1c5cab", "#184f95", "#104281",
"#0d366b"]
# El fondo de cada versión es el mismo que el de la página, para que el mapa no
# lleve marco y se funda con el papel.
#
# En claro la rampa va de claro a oscuro: más aislado = azul más denso, que
# destaca sobre papel claro. En oscuro se invierte. No es un volteo automático
# del gradiente sino la misma rampa recorrida al revés, porque lo que tiene que
# conservarse es el significado: lo aislado siempre es lo que más resalta. Con
# la rampa sin invertir, los vacíos —justo lo que buscamos— se disolverían en
# el fondo negro.
THEMES = {
"light": dict(surface="#e9edf2", ring="#ffffff", label="#0f1620",
halo="#ffffff", reverse=False),
"dark": dict(surface="#0b0f14", ring="#0b0f14", label="#e8edf3",
halo="#0b0f14", reverse=True),
}
MARKER = {"light": "#d4541f", "dark": "#f08a5a"}
def hex2rgb(h):
return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
def ramp_lut(reverse=False):
cols = np.array([hex2rgb(h) for h in (RAMP[::-1] if reverse else RAMP)],
dtype=np.float32)
xs = np.linspace(0, 255, len(cols))
lut = np.zeros((256, 3), dtype=np.uint8)
for ch in range(3):
lut[:, ch] = np.interp(np.arange(256), xs, cols[:, ch]).astype(np.uint8)
return lut
def block_reduce(a, f, how="mean"):
h = (a.shape[0] // f) * f
w = (a.shape[1] // f) * f
a = a[:h, :w].reshape(h // f, f, w // f, f)
return a.mean(axis=(1, 3)) if how == "mean" else a.max(axis=(1, 3))
def get_font(size):
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def main():
iso = np.load(C.INTERIM / "iso_pure.npy")
spain = np.load(C.INTERIM / "mask_spain.npy").astype(bool)
vals = iso[spain & np.isfinite(iso)]
lo, hi = np.percentile(vals, 2), np.percentile(vals, 99.5)
print(f"escala del mapa: {lo:.0f} .. {hi:.0f} m")
filled = np.where(np.isfinite(iso), iso, 0.0)
small = block_reduce(filled, F, "mean")
land = block_reduce(spain.astype(np.float32), F, "mean") > 0.35
norm = np.clip((small - lo) / (hi - lo), 0, 1)
idx = (norm * 255).astype(np.uint8)
with open(C.OUT / "candidatos_estrellas.csv") as fh:
cands = [c for c in csv.DictReader(fh) if int(c["estrellas"]) >= 3]
cands.sort(key=lambda c: -float(c["score"]))
for i, c in enumerate(cands, 1):
c["rank"] = i
for theme, col in THEMES.items():
rgb = ramp_lut(col["reverse"])[idx]
img_arr = np.empty(rgb.shape, dtype=np.uint8)
img_arr[:] = hex2rgb(col["surface"])
img_arr[land] = rgb[land]
img = Image.fromarray(img_arr, "RGB")
d = ImageDraw.Draw(img)
f_lbl = get_font(15)
for cd in cands:
x, y = C.to_grid(float(cd["lon"]), float(cd["lat"]))
px = (x - C.X_MIN) / C.RES / F
py = (C.Y_MAX - y) / C.RES / F
rank = int(cd["rank"])
r = 6 if rank <= 10 else 4
# anillo del color de la superficie: separa el punto del fondo
d.ellipse([px - r - 2, py - r - 2, px + r + 2, py + r + 2],
fill=hex2rgb(col["ring"]))
d.ellipse([px - r, py - r, px + r, py + r], fill=hex2rgb(MARKER[theme]))
if rank <= 10:
t = str(rank)
tx, ty = px + r + 5, py - 9
for ox, oy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
d.text((tx + ox, ty + oy), t, font=f_lbl,
fill=hex2rgb(col["halo"]))
d.text((tx, ty), t, font=f_lbl, fill=hex2rgb(col["label"]))
out = C.OUT / f"mapa_{theme}.png"
# Paleta de 200 colores: la rampa es continua pero el ojo no distingue
# más pasos, y el PNG baja de ~1,5 MB a ~400 KB, que importa porque va
# incrustado en el informe.
img.convert("P", palette=Image.ADAPTIVE, colors=200).save(out, optimize=True)
print(f" {out.name} {img.size[0]}x{img.size[1]} "
f"{out.stat().st_size/1024:.0f} KB")
if __name__ == "__main__":
main()