El Día 07 del #30DayMapChallenge pedía un mapa de luces nocturnas hecho a toda prisa, y yo quería que Venezuela se viera desde el espacio, de noche. Lo armé con tres piezas: un script corto de Google Earth Engine que baja la radiancia VIIRS, ArcGIS Pro para pintarla y componer el layout, y una IA conectada a Pro por MCP que ejecutó el trabajo repetitivo. Al final hice una animación en la que las luces se encienden de la más intensa a la más tenue. Te cuento cada paso y dejo el código.
- A descargar la radiancia nocturna VIIRS de un país con Earth Engine, ya reescalada para mapas.
- A darle una paleta propia al raster en ArcGIS Pro y a armar un layout vertical con arcpy.
- A convertir el mapa en una animación con música original, sin derechos de autor.
El resultado
Un mapa vertical 4:5, de fondo casi negro, donde cada píxel de unos 464 m de lado es una luz medida por el satélite. La escala de color va de negro a azul navy, azul cielo, dorado y blanco. Tiene título, ciudades, leyenda, fuentes y sistema de referencia (EPSG:4326, WGS 84).
Puedes ver el video final en YouTube: Venezuela de noche, animación.
Quién hizo qué
Un MCP (Model Context Protocol) es el puente que deja a una IA usar las herramientas de un programa. Con el MCP de ArcGIS Pro, la IA puede leer mi proyecto abierto, ejecutar geoprocesos y scripts de arcpy y exportar layouts. Así quedó el reparto:
| Tarea | La IA por MCP | Yo |
|---|---|---|
| Elegir tema y dato | Propuso opciones | Elegí las luces nocturnas y el país |
| Script de Earth Engine | Lo escribió | Lo corrí en el Code Editor y exporté el raster |
| Mapa y paleta en Pro | Aplicó la paleta y creó el mapa | Revisé el color |
| Layout | Lo construyó con arcpy y exportó vistas previas | Moví algunas etiquetas a mano |
| Animación y música | Las generó con código | Revisé el resultado |
Paso a paso
1. Descarga la luz nocturna con Earth Engine
Uso la colección mensual NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG, que corrige la luz parásita, y los límites de países de FAO GAUL 2015. Saco la mediana de los doce meses para quitar destellos puntuales, y la paso a escala logarítmica de 0 a 255. Con el logaritmo se ven a la vez una ciudad grande y un pueblo pequeño.
var year = 2023; // año a resumir
var pais = ee.FeatureCollection('FAO/GAUL/2015/level0')
.filter(ee.Filter.eq('ADM0_NAME', 'Venezuela'));
var aoi = pais.geometry().buffer(30000, 1000).bounds(1000); // país + 30 km de margen
var viirs = ee.ImageCollection('NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG')
.filterDate(year + '-01-01', (year + 1) + '-01-01')
.filterBounds(aoi)
.select('avg_rad');
print('Imágenes mensuales usadas:', viirs.size()); // si sale 0, baja el año
var radiancia = viirs.median().clip(aoi); // mediana anual
// log10 reescalado: 0,1 nW/cm²/sr o menos -> 0 ; 100 o más -> 255
var luz8 = radiancia.max(0.1).log10()
.add(1).divide(3).multiply(255)
.clamp(0, 255).round().uint8().rename('LUZ_' + year);
Export.image.toDrive({
image: luz8, description: 'D07_LUZ_VEN_' + year,
folder: 'D07_LUCES_VENEZUELA', fileNamePrefix: 'D07_LUZ_VEN_' + year,
region: aoi, crs: 'EPSG:4326', scale: 463.83, maxPixels: 1e9
});
En el Code Editor abre la pestaña Tasks y pulsa RUN. El GeoTIFF llega a tu Google Drive.
viirs.size() imprime 0, ese año aún no está en la colección. Baja el valor de year. Yo usé 2023.2. Crea la paleta de marca
ArcGIS Pro acepta un archivo .clr con 256 líneas (índice, rojo, verde, azul). Lo genero interpolando entre cinco colores:
# Genera d07_marca.clr: 256 colores interpolados con la paleta DALPGIS
stops = {0: (0, 0, 0), 64: (7, 34, 95), 128: (91, 143, 214),
191: (229, 192, 11), 255: (255, 255, 255)}
claves = sorted(stops)
with open("d07_marca.clr", "w") as f:
for i in range(256):
a = max(k for k in claves if k <= i)
b = min(k for k in claves if k >= i)
t = 0 if a == b else (i - a) / (b - a)
rgb = [round(stops[a][c] + (stops[b][c] - stops[a][c]) * t) for c in range(3)]
f.write("%d %d %d %d\n" % (i, *rgb))
3. Prepara el mapa en ArcGIS Pro
Aquí entra el MCP. Mi proyecto tiene una caja de herramientas Python mínima (un .pyt) con una herramienta que ejecuta un script .py dentro del proyecto abierto, con la variable aprx ya disponible. La IA escribe el script, lo guardo en mi carpeta y lo lanza con run_gp_tool. El script copia el raster, le pone la paleta y crea el mapa:
import arcpy, os
base = r"D:\PROY_RETO_MAPAS\DIA_07"
arcpy.env.overwriteOutput = True
# copia del raster con la paleta (el original no se toca)
src = os.path.join(base, "D07_LUZ_VEN_2023.tif")
dst = os.path.join(base, "D07_LUZ_VEN_2023_marca.tif")
arcpy.management.CopyRaster(src, dst, pixel_type="8_BIT_UNSIGNED")
arcpy.management.AddColormap(dst, "#", os.path.join(base, "d07_marca.clr"))
# mapa nuevo en coordenadas geográficas
m = aprx.createMap("D07_Noche", "MAP")
m.spatialReference = arcpy.SpatialReference(4326)
r = m.addDataFromPath(dst)
r.name = "Luz_nocturna_2023"
# contorno de estados: sin relleno y dorado casi transparente
e = m.addDataFromPath(r"RUTA\A\TU\CAPA_DE_ESTADOS")
e.name = "Estados"
s = e.symbology
s.updateRenderer("SimpleRenderer")
s.renderer.symbol.color = {"RGB": [0, 0, 0, 0]}
s.renderer.symbol.outlineColor = {"RGB": [229, 192, 11, 40]}
s.renderer.symbol.outlineWidth = 0.4
e.symbology = s
aprx.save()
d07_layout_v1.py, v2) y revisa el registro antes de exportar.4. Arma el layout con arcpy
La clave es que el marco del mapa tenga la misma proporción que el raster. Así puedo convertir longitud y latitud a posición de página con una regla de tres y colocar cada etiqueta con precisión. Este es el esqueleto:
import arcpy
lyt = aprx.createLayout(12.0, 15.0, "INCH", "D07_Noche_Venezuela") # 4:5
def clr(rgb, t):
return '<CLR red="%d" green="%d" blue="%d">%s</CLR>' % (rgb[0], rgb[1], rgb[2], t)
def poly(x0, y0, x1, y1):
return arcpy.Polygon(arcpy.Array([arcpy.Point(x0, y0), arcpy.Point(x0, y1),
arcpy.Point(x1, y1), arcpy.Point(x1, y0), arcpy.Point(x0, y0)]))
def text(name, x0, ytop, w, h, txt, size, rgb, style="Regular"):
aprx.createTextElement(lyt, poly(x0, ytop - h, x0 + w, ytop), "POLYGON",
clr(rgb, txt), size, "Montserrat", style, name=name)
# marco del mapa con la MISMA proporción del raster (así las coordenadas calzan)
RW, RE, RS, RN = -73.6498076, -59.5290112, 0.3791657, 12.7624667
MX0, MX1, MY1 = 0.5, 11.5, 12.85
MY0 = MY1 - (MX1 - MX0) * (RN - RS) / (RE - RW)
mf = lyt.createMapFrame(arcpy.Extent(MX0, MY0, MX1, MY1), m, "D07_Mapa")
mf.camera.setExtent(arcpy.Extent(RW, RS, RE, RN))
def pg(lon, lat): # lon/lat -> posición en la página (pulgadas)
return (MX0 + (lon - RW) / (RE - RW) * (MX1 - MX0),
MY0 + (lat - RS) / (RN - RS) * (MY1 - MY0))
text("D07_Titulo", 0.5, 14.3, 11.2, 0.9, "VENEZUELA DE NOCHE", 52, (255, 255, 255), "Bold")
# una ciudad = un anillo + una etiqueta, posicionados con pg()
x, y = pg(-71.62, 10.64) # Maracaibo
aprx.createPredefinedGraphicElement(lyt, poly(x - .045, y - .045, x + .045, y + .045),
"ELLIPSE", name="D07_Pt_Maracaibo")
text("D07_Lb_Maracaibo", x - 2.3, y + 0.30, 2.2, 0.25, "Maracaibo", 10, (255, 255, 255), "Bold")
Repetí ese patrón (anillo y etiqueta) para Maracaibo, Caracas, Valencia–Maracay, Barquisimeto, Puerto La Cruz, Ciudad Guayana y Puerto Ayacucho. También puse una llamada dorada en el norte de Monagas, la leyenda como una barra de 64 rectángulos con la paleta y el bloque de fuentes y CRS.
5. Revisa, ajusta y exporta
La IA exportó una vista previa pequeña con export_layout, yo la miré y corregimos lo que chocaba: un texto sobre otro, una etiqueta tapada por el brillo. Luego moví unas etiquetas a mano en Pro y exporté el PNG a 200 ppp y el PDF.
6. Convierte el mapa en una animación
ArcPy no anima, así que renderizo los fotogramas fuera de Pro con el mismo raster y la misma paleta. En cada fotograma muestro solo los píxeles por encima de un umbral que baja con el tiempo: primero se encienden los más brillantes y al final los tenues. Después aparece el layout completo.
import numpy as np
from PIL import Image
mapa = Image.open("D07_Venezuela_de_noche.png").convert("RGB") # export de ArcGIS Pro
W, H = mapa.size
s = W / 12.0 # píxeles por pulgada
x0, x1, y0, y1 = int(0.5 * s), int(11.5 * s), int(2.15 * s), int(11.80 * s)
paleta = np.array([[int(v) for v in l.split()[1:4]]
for l in open("d07_marca.clr")], dtype=np.uint8)
raster = np.array(Image.open("D07_LUZ_VEN_2023.tif")) # uint8 0-255
v = np.array(Image.fromarray(raster).resize((x1 - x0, y1 - y0), Image.BILINEAR))
fondo = np.array((3, 9, 26), dtype=np.uint8)
base = np.array(mapa).copy()
base[y0:y1, x0:x1] = fondo # sin mapa: título, leyenda, pie
fps, n = 30, 0
def guardar(a):
global n
Image.fromarray(a).resize((1080, 1350), Image.LANCZOS).save("fr/f%04d.png" % n)
n += 1
for i in range(4 * fps): # 4 s: se encienden las luces
t = (i + 1) / (4 * fps)
t = t * t * (3 - 2 * t) # entra y sale suave
umbral = 255 - (255 - 45) * t # de las más intensas a las tenues
fr = base.copy()
fr[y0:y1, x0:x1] = np.where((v >= umbral)[..., None], paleta[v], fondo)
guardar(fr)
ultimo, completo = fr.copy(), np.array(mapa)
for i in range(fps // 2):
guardar(ultimo) # pausa
for i in range(fps): # 1 s: aparece el layout final
a = (i + 1) / fps
guardar((ultimo * (1 - a) + completo * a).astype(np.uint8))
for i in range(3 * fps):
guardar(completo)
# ffmpeg -framerate 30 -i fr/f%04d.png -c:v libx264 -pix_fmt yuv420p -crf 18 animacion.mp4
7. Música original sin derechos de autor
Para no depender de pistas de terceros, generé la música con código: un colchón de acordes suaves y una campanita por cada luz que se enciende. Como la compuse yo, no tiene derechos de autor.
import numpy as np, wave
sr, D = 44100, 8.5
t = np.arange(int(sr * D)) / sr
L, R = np.zeros_like(t), np.zeros_like(t)
rng = np.random.default_rng(7)
midi = lambda m: 440 * 2 ** ((m - 69) / 12)
def sumar(sig, pan):
global L, R
L += sig * (1 - pan); R += sig * pan
# colchón: acordes de Am9 y Fmaj7 con dos voces ligeramente desafinadas
def colchon(notas, a, b):
for m in notas:
for det, pan in ((-0.07, 0.3), (0.07, 0.7)):
f = midi(m) * 2 ** (det / 12)
s = sum(np.sin(2 * np.pi * f * k * t + k) / k ** 1.6 for k in range(1, 7))
sumar(s * np.clip((t - a) / 1.6, 0, 1) * np.clip((b - t) / 1.6, 0, 1) * 0.05, pan)
colchon([45, 52, 57, 60, 64], 0, 4.8)
colchon([41, 48, 53, 57, 64], 3.2, D)
# campanitas: una por "luz" que se enciende, cada vez más seguidas
escala = [69, 72, 74, 76, 79, 81, 84, 88]
e = 0.3
while e < 4.6:
f = midi(escala[min(7, int(rng.random() * 3 + e / 4.6 * 5))])
d = t - e
env = np.where(d > 0, np.exp(-d * 3.5), 0) * np.clip(d * 200, 0, 1)
sumar((np.sin(2 * np.pi * f * t) + 0.4 * np.sin(2 * np.pi * f * 2.01 * t)) * env * 0.05, rng.random())
e += max(0.12, 0.7 * (1 - e / 4.6) ** 1.5 + 0.1)
for ch in (L, R): # eco difuso + fade in/out
for dl, g in ((0.073, .45), (0.131, .35), (0.197, .28), (0.283, .2)):
k = int(dl * sr); ch[k:] += ch[:-k].copy() * g
fade = np.clip(t / 0.8, 0, 1) * np.clip((D - t) / 1.5, 0, 1)
st = np.stack([L * fade, R * fade], 1)
st = (st / abs(st).max() * 0.8 * 32767).astype(np.int16)
with wave.open("musica.wav", "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(sr); w.writeframes(st.tobytes())
Para unirla al video: ffmpeg -i animacion.mp4 -i musica.wav -map 0:v -map 1:a -c:v copy -c:a aac -shortest salida.mp4
Qué se ve en el mapa
- Un corredor de luz en el norte. Desde Maracaibo hasta Puerto La Cruz, la costa y el eje de Valencia, Maracay y Caracas concentran la mayor parte del brillo.
- El sur, casi a oscuras. Amazonas y el sur de Bolívar casi no emiten luz; apenas se distingue Puerto Ayacucho.
- Un resplandor intenso en el norte de Monagas. Es el punto más brillante del mapa y coincide con una zona petrolera. No lo atribuyo a una sola causa sin verificarla; podría incluir instalaciones con quema de gas.
- Vecinos visibles. Cúcuta, Boa Vista y las luces de Trinidad y Tobago aparecen en el borde del mapa.
Límites y precauciones
- La escala es logarítmica. Sirve para ver dónde hay luz, no para comparar cuánta. El brillo se satura en 100 nW/cm²/sr.
- Luz no es población ni economía. VIIRS no capta bien algunas luces (por ejemplo, ciertos LED de luz azul) y sí registra quemas, llamas de gas e incluso barcos. Una zona oscura no está necesariamente deshabitada.
- Es una mediana de 2023. Esconde apagones o cambios dentro del año.
- Proyección. El mapa está en EPSG:4326 (grados). No lo uses para medir áreas ni distancias; reproyecta antes.
- Límites estatales. Vienen de mi capa de división estadal y se muestran con transparencia solo como referencia.
- Resolución. Cada píxel mide unos 464 m; no se ven calles ni edificios.
Dónde usar este método
El mismo flujo (descargar, dar paleta, componer layout y animar) sirve para series de mapas con un estilo común, como este reto. Con luces nocturnas puedes comparar años, estudiar la expansión urbana o preparar un mapa de portada. Si quieres ver cómo usé el mismo MCP en otros días, lee el Día 03 y el Día 05. La documentación del conjunto de datos está en el catálogo de Earth Engine, y la de arcpy.mp en la ayuda de ArcGIS Pro.
El lunes 9/11/2026 llega el Atlas Petrolero
Suscríbete a GeoHacks Insight en LinkedIn y recibe cada mapa del reto con su proceso.
Suscribirme en LinkedIn Entrar a la comunidadPreguntas frecuentes
¿Cómo hago un mapa de luces nocturnas en ArcGIS Pro?
Descarga la radiancia VIIRS (por ejemplo desde Earth Engine), ábrela en Pro, aplica una paleta con un archivo .clr y arma el layout. La escala logarítmica ayuda a ver a la vez ciudades grandes y pueblos pequeños.
¿Qué es el MCP de ArcGIS Pro?
Es un puente que deja a una IA usar ArcGIS Pro: leer el proyecto, ejecutar geoprocesos y scripts de arcpy y exportar layouts. Tú decides qué se hace y revisas cada resultado.
¿Por qué usar escala logarítmica?
Porque la radiancia varía en varios órdenes de magnitud. Sin logaritmo, solo se verían las ciudades más grandes y todo lo demás quedaría negro.
¿Puedo animar un mapa en ArcGIS Pro?
ArcPy no ofrece animación de mapas ráster como esta. Yo renderizo los fotogramas con Python, con el mismo raster y paleta, y los uno con ffmpeg.
¿Los puntos brillantes son siempre ciudades?
No. VIIRS también capta carreteras, instalaciones industriales, quema de gas, incendios y barcos. Interpreta cada zona con otra fuente antes de concluir algo.



