"""Renderiza el mapa de aislamiento a PNG (versión clara y oscura). Se dibuja solo el país activo porque el análisis solo es válido dentro de él: 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 sí. """ 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()