Modelado Geoespacial Avanzado
Capítulo 8 — Algoritmos y productos de información del mundo real
El modelado geoespacial avanzado nos permite transformar datos crudos en productos de información que responden preguntas específicas del mundo real. En este capítulo construiremos sobre los conceptos de procesamiento de datos aprendidos para crear sistemas completos utilizados en agricultura, gestión de emergencias, logística y otras industrias.
Un modelo geoespacial es una representación simplificada de algún aspecto del mundo real que nos ayuda a responder una o más preguntas sobre un proyecto o problema. Aprenderemos a combinar múltiples conjuntos de datos procesados para construir estos modelos.
Productos de Información
Los ejemplos en este capítulo son procesos reales que utilizarías como analista geoespacial profesional. Cada algoritmo, aunque diseñado para una tarea específica, tiene aplicaciones amplias en el análisis geoespacial.
Índice de Vegetación NDVI
El Normalized Difference Vegetative Index (NDVI) muestra la salud relativa de las plantas en un área de interés. Utiliza imágenes satelitales o aéreas para destacar la densidad de clorofila, empleando únicamente las bandas roja e infrarroja cercana.
Configuración del Framework
from osgeo import gdal, gdal_array, ogr from PIL import Image, ImageDraw import numpy as np def imageToArray(i): """Convierte imagen PIL a array NumPy""" a = gdal_array.numpy.fromstring(i.tobytes(), 'b') a.shape = i.im.size[1], i.im.size[0] return a def world2Pixel(geoMatrix, x, y): """Convierte coordenadas geoespaciales a píxeles""" ulX = geoMatrix[0] ulY = geoMatrix[3] xDist = geoMatrix[1] yDist = geoMatrix[5] pixel = int((x - ulX) / xDist) line = int((ulY - y) / abs(yDist)) return (pixel, line) # Cargar imagen multiespectral source = "farm.tif" target = "ndvi.tif" srcArray = gdal_array.LoadFile(source) srcImage = gdal.Open(source) geoTrans = srcImage.GetGeoTransform() # Extraer bandas roja e infrarroja r = srcArray[1] # Banda roja ir = srcArray[2] # Banda infrarroja
Rasterizar el Shapefile de Recorte
# Abrir shapefile del campo field = ogr.Open("field.shp") lyr = field.GetLayer("field") poly = lyr.GetNextFeature() # Convertir extensión a coordenadas de píxel minX, maxX, minY, maxY = lyr.GetExtent() ulX, ulY = world2Pixel(geoTrans, minX, maxY) lrX, lrY = world2Pixel(geoTrans, maxX, minY) # Calcular tamaño de la nueva imagen pxWidth = int(lrX - ulX) pxHeight = int(lrY - ulY) # Recortar bandas al bounding box rClip = r[ulY:lrY, ulX:lrX] irClip = ir[ulY:lrY, ulX:lrX] # Crear máscara como imagen PIL rasterPoly = Image.new("L", (pxWidth, pxHeight), 1) rasterize = ImageDraw.Draw(rasterPoly) rasterize.polygon(pixels, 0) # Convertir máscara a array NumPy mask = imageToArray(rasterPoly)
Aplicar Fórmula NDVI
# Recortar bandas usando la máscara rClip = gdal_array.numpy.choose(mask, (rClip, 0)).astype(gdal_array.numpy.uint8) irClip = gdal_array.numpy.choose(mask, (irClip, 0)).astype(gdal_array.numpy.uint8) # Ignorar warnings de NaN gdal_array.numpy.seterr(all="ignore") # Fórmula NDVI: (infrarrojo - rojo) / (infrarrojo + rojo) ndvi = 1.0 * ((irClip - rClip) / (irClip + rClip + 1.0)) # Convertir NaN a 0 ndvi = gdal_array.numpy.nan_to_num(ndvi) # Guardar como GeoTIFF gtiff = gdal.GetDriverByName('GTiff') gtiff.CreateCopy(target, copy_geo(ndvi, prototype=source))
Clasificar y Colorizar NDVI
import gdal_array as gd import operator from functools import reduce # Cargar NDVI y crear imagen RGB de salida ndvi = gd.LoadFile("ndvi.tif").astype(gd.numpy.uint8) rgb = gd.numpy.zeros((3, len(ndvi), len(ndvi[0])), gd.numpy.uint8) # Definir 7 clases con rangos de valores classes = [58, 73, 110, 147, 184, 220, 255] # Look-up table: marrón oscuro → verde oscuro lut = [ [120, 69, 25], # Suelo desnudo [255, 178, 74], # Vegetación baja [255, 237, 166], # Vegetación media-baja [173, 232, 94], # Vegetación media [135, 181, 64], # Vegetación media-alta [3, 156, 0], # Vegetación alta [1, 100, 0] # Vegetación muy saludable ] # Clasificar cada rango start = 1 for i in range(len(classes)): mask = gd.numpy.logical_and(start <= ndvi, ndvi <= classes[i]) for j in range(len(lut[i])): rgb[j] = gd.numpy.choose(mask, (rgb[j], lut[i][j])) start = classes[i] + 1 # Guardar imagen clasificada gd.SaveArray(rgb, "ndvi_color.tif", format="GTiff")
eficiente y localizada. Las clases pueden convertirse en shapefiles vectoriales para sistemas GPS de aplicación variable.
Modelo de Inundación
Un modelo de inundación nos ayuda a entender el impacto de una crecida. Utiliza el algoritmo Flood Fill (conocido del juego Buscaminas y herramientas de relleno en editores gráficos) para simular la extensión máxima de agua que puede contener una cuenca a cierta elevación.
Función Flood Fill
import numpy as np from linecache import getline def floodFill(x, y, arr): """ Flood fill basado en cola - no recursivo. Procesa celdas únicas usando sets de Python. """ # Crear array de salida lleno de ceros fld = np.zeros_like(arr) # Sets para tracking: fill (por procesar), filled (procesadas) fill = set() filled = set() # Agregar punto inicial fill.add((x, y)) # Dimensiones del array height, width = arr.shape while fill: # Obtener siguiente celda x, y = fill.pop() # Saltar si ya procesada o fuera de límites if (x, y) in filled: continue if x < 0 or x >= width: continue if y < 0 or y >= height: continue # Verificar si la celda está bajo el umbral if arr[y][x] == 1: # Marcar como inundada fld[y][x] = 1 filled.add((x, y)) # Agregar vecinos a la cola (4 direcciones) fill.add((x-1, y)) fill.add((x+1, y)) fill.add((x, y-1)) fill.add((x, y+1)) else: filled.add((x, y)) return fld
Predicción de Inundación
# Archivos de entrada/salida source = "dem.asc" target = "flood.asc" # Cargar DEM print("Loading DEM...") img = np.loadtxt(source, skiprows=6) # Filtrar: elevaciones < 70 metros = inundables wet = np.where(img < 70, 1, 0) print("Image masked") # Parsear header del ASCII Grid hdr = [getline(source, i) for i in range(1, 7)] values = [float(h.split(" ")[-1].strip()) for h in hdr] cols, rows, lx, ly, cell, nd = values # Punto inicial en el lecho del río (coordenadas de píxel) sx = 2582 sy = 2057 # Ejecutar flood fill print("Beginning flood fill...") fld = floodFill(sx, sy, wet) print("Finished flood fill") # Guardar resultado como ASCII Grid header = "" for i in range(6): header += hdr[i] with open(target, "wb") as f: f.write(bytes(header, 'UTF-8')) np.savetxt(f, fld, fmt="%1i") print("Done!")
GDAL.polygonize() para crear polígonos vectoriales.
Hillshade Colorizado
Combinamos la técnica de shaded relief con clasificación por colores para crear visualizaciones de terreno que sirven como fondos para mapas GIS. Este proceso mezcla un DEM colorizado con un hillshade utilizando PIL.
Crear Hillshade Colorizado
import gdal_array as gd from PIL import Image # Archivos de entrada relief = "relief.asc" # Shaded relief pre-calculado dem = "dem.asc" # Digital Elevation Model target = "hillshade.tif" # Cargar relief como fondo bg = gd.numpy.loadtxt(relief, skiprows=6) # Cargar DEM como foreground fg = gd.numpy.loadtxt(dem, skiprows=6)[:-2, :-2] # Crear imagen RGB vacía rgb = gd.numpy.zeros((3, len(fg), len(fg[0])), gd.numpy.uint8) # Clases por rangos de elevación (metros) classes = [356, 649, 942, 1235, 1528, 1821, 2114, 2300, 2700] # Paleta de colores: verde → marrón → gris (nieve) lut = [ [63, 159, 152], # Verde agua (bajo) [96, 235, 155], # Verde claro [100, 246, 174], # Verde brillante [248, 251, 155], # Amarillo [246, 190, 39], # Naranja [242, 155, 39], # Naranja oscuro [165, 84, 26], # Marrón [236, 119, 83], # Terracota [203, 203, 203] # Gris (cumbres) ] # Clasificar start = 1 for i in range(len(classes)): mask = gd.numpy.logical_and(start <= fg, fg <= classes[i]) for j in range(len(lut[i])): rgb[j] = gd.numpy.choose(mask, (rgb[j], lut[i][j])) start = classes[i] + 1 # Convertir arrays a imágenes PIL im1 = Image.fromarray(bg).convert('RGB') im2 = Image.fromarray(rgb.transpose(1, 2, 0)).convert('RGB') # Mezclar con 40% alpha hillshade = Image.blend(im1, im2, .4) # Guardar resultado hillshade.save(target)
Análisis de Ruta de Mínimo Costo
El algoritmo A* (A-star) busca la ruta óptima considerando tanto la distancia como el costo del terreno. A diferencia del algoritmo Dijkstra tradicional, A* es ideal para navegar sobre grids como un DEM, balanceando múltiples factores.
Función de Puntuación
import math def e_dist(p1, p2): """Distancia Euclidiana entre dos puntos""" y1, x1 = p1 y2, x2 = p2 return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) def score(cur, node, h, start, end): """ Calcula puntuación para elegir el siguiente nodo. Mayor peso al terreno, luego progreso hacia el objetivo. """ score = 0 # Elevación y distancias del nodo actual cur_h = h[cur] cur_g = e_dist(cur, end) # Distancia al destino cur_d = e_dist(cur, start) # Distancia desde inicio # Elevación y distancias del nodo vecino node_h = h[node] node_g = e_dist(node, end) node_d = e_dist(node, start) # Comparar valores con pesos if node_h < cur_h: score += cur_h - node_h # Bonus por bajar elevación if node_g < cur_g: score += 10 # Bonus por acercarse al destino if node_d > cur_d: score += 10 # Bonus por alejarse del inicio return score
Algoritmo A*
def astar(start, end, h): """ Búsqueda A* sobre grid de terreno. Retorna set de celdas que forman la ruta. """ closed_set = set() # Nodos ya evaluados open_set = set() # Nodos por evaluar path = set() # Ruta de salida open_set.add(start) while open_set: cur = open_set.pop() if cur == end: return path closed_set.add(cur) path.add(cur) # Obtener vecinos (8 direcciones) y, x = cur neighbors = [] for dy in [-1, 0, 1]: for dx in [-1, 0, 1]: if dy == 0 and dx == 0: continue ny, nx = y + dy, x + dx if 0 <= ny < h.shape[0] and 0 <= nx < h.shape[1]: neighbors.append((ny, nx)) # Encontrar mejor vecino best = None best_score = -1 for node in neighbors: if node in closed_set: continue s = score(cur, node, h, start, end) if s > best_score: best_score = s best = node if best: open_set.add(best) return path
Convertir Ruta a Shapefile
import pickle import shapefile def pix2coord(gt, x, y): """Convierte píxel a coordenadas geoespaciales""" c = gt[0] + (x * gt[1]) r = gt[3] + (y * gt[5]) return (c, r) # Cargar ruta guardada con pickle with open("path.p", "rb") as f: path = pickle.load(f) # Convertir a coordenadas geoespaciales coords = [] for y, x in path: coords.append(pix2coord(gt, x, y)) # Crear shapefile de línea with shapefile.Writer("path", shapeType=shapefile.POLYLINE) as w: w.field("NAME") w.record("LeastCostPath") w.line([coords])
Routing en Redes de Calles
El routing en calles utiliza un grafo conectado de líneas donde cada línea puede tener valores de impedancia (tráfico, velocidad, distancia). Utilizamos NetworkX para calcular la ruta más corta entre dos puntos.
Implementación con NetworkX
import networkx as nx import shapefile import math def haversine(p1, p2): """Distancia en metros entre dos puntos lat/lon""" lat1, lon1 = map(math.radians, p1) lat2, lon2 = map(math.radians, p2) dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat/2)**2 + \ math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 c = 2 * math.asin(math.sqrt(a)) return 6371000 * c # Radio de la Tierra en metros # Cargar red de calles r = shapefile.Reader("roads.shp") shapes = r.shapes() # Crear grafo sg = nx.Graph() # Agregar edges del shapefile for shape in shapes: points = shape.points for i in range(len(points) - 1): p1 = tuple(points[i]) p2 = tuple(points[i + 1]) sg.add_edge(p1, p2) # Calcular distancias para cada edge for n0, n1 in sg.edges(): dist = haversine(n0, n1) sg[n0][n1]["dist"] = dist # Puntos de inicio y fin start = (-89.32, 30.31) end = (-89.28, 30.35) # Encontrar nodos más cercanos nn_start = min(sg.nodes(), key=lambda n: haversine(start, n)) nn_end = min(sg.nodes(), key=lambda n: haversine(end, n)) # Calcular ruta más corta path = nx.shortest_path(sg, source=nn_start, target=nn_end, weight="dist") # Guardar como shapefile with shapefile.Writer("route", shapefile.POLYLINE) as w: w.field("NAME", "C", 40) w.line(parts=[[list(p) for p in path]]) w.record("route")
Geolocalización de Fotos
Las fotos tomadas con cámaras GPS (incluyendo smartphones) almacenan coordenadas en el header del archivo usando EXIF tags. Podemos extraer esta información para crear shapefiles con las ubicaciones de las fotos.
import glob import os from PIL import Image from PIL.ExifTags import TAGS import shapefile def exif(img): """Extrae datos EXIF de una imagen""" exif_data = {} try: i = Image.open(img) tags = i._getexif() for tag, value in tags.items(): decoded = TAGS.get(tag, tag) exif_data[decoded] = value except: pass return exif_data def dms2dd(d, m, s, direction): """Convierte Grados/Minutos/Segundos a Decimal""" sec = float((m * 60) + s) dec = float(sec / 3600) deg = float(d + dec) if direction.upper() in ['W', 'S']: deg = deg * -1 return deg def gps(exif_data): """Extrae coordenadas GPS de datos EXIF""" lat, lon = None, None if 'GPSInfo' in exif_data: coords = exif_data['GPSInfo'] # Latitud i = coords[1] d, m, s = coords[2][0][0], coords[2][1][0], coords[2][2][0] lat = dms2dd(d, m, s, i) # Longitud i = coords[3] d, m, s = coords[4][0][0], coords[4][1][0], coords[4][2][0] lon = dms2dd(d, m, s, i) return lat, lon # Procesar directorio de fotos photos = {} photo_dir = "./photos" files = glob.glob(os.path.join(photo_dir, "*.jpg")) for f in files: e = exif(f) lat, lon = gps(e) if lat and lon: photos[f] = [lon, lat] # Crear shapefile de puntos with shapefile.Writer("photos", shapefile.POINT) as w: w.field("NAME", "C", 80) for f, coords in photos.items(): w.point(*coords) w.record(f)
Máscara de Nubes Satelital
Las imágenes satelitales frecuentemente contienen nubes que interfieren con el análisis. Usando datos de quality-assurance de Landsat 8, podemos generar máscaras para filtrar automáticamente las áreas nubladas.
import glob import os import rasterio from l8qa.qa import write_cloud_mask # Directorio con datos Landsat landsat_dir = "l8" # Localizar archivo de quality-assurance src_qa = glob.glob(os.path.join(landsat_dir, '*QA*'))[0] # Generar máscara de nubes with rasterio.open(src_qa) as qa_raster: profile = qa_raster.profile profile.update(nodata=0) write_cloud_mask( qa_raster.read(1), profile, 'cloudmask.tif' )
pip install rio-l8qa
Resumen de Algoritmos
01 // NDVI
Índice de vegetación usando bandas roja e infrarroja para evaluar salud de cultivos.
02 // Flood Fill
Algoritmo basado en cola para modelar inundaciones desde un punto de origen.
03 // Color Hillshade
Combinación de shaded relief con clasificación por elevación usando PIL blend.
04 // A* Search
Búsqueda heurística para encontrar rutas de mínimo costo sobre terreno.
05 // Graph Routing
NetworkX para calcular caminos más cortos en redes de calles.
06 // EXIF Extraction
Geolocalización de fotos extrayendo coordenadas GPS de metadatos.