|
| 1 | +--- |
| 2 | +jupytext: |
| 3 | + text_representation: |
| 4 | + extension: .md |
| 5 | + format_name: markdown |
| 6 | + format_version: "1.3" |
| 7 | + jupytext_version: 1.16.2 |
| 8 | +kernelspec: |
| 9 | + display_name: Python 3 (ipykernel) |
| 10 | + language: python |
| 11 | + name: python3 |
| 12 | +--- |
| 13 | + |
| 14 | +# La Gran Muralla Verde en Senegal |
| 15 | + |
| 16 | +[La Gran Muralla Verde](https://es.wikipedia.org/wiki/Gran_muralla_verde_\(%C3%81frica\)) es un esfuerzo para combatir la expansión del desierto del Sahara mediante el crecimiento de vegetación de forma sistemática y científica. El [producto OPERA DIST-ALERT](https://lpdaac.usgs.gov/documents/1766/OPERA_DIST_HLS_Product_Specification_V1.pdf) también puede utilizarse para identificar cambios en el crecimiento de la vegetación (que impliquen causas naturales o, en este caso, antropogénicas). |
| 17 | + |
| 18 | +<center> |
| 19 | + <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Sahara_satellite_hires.jpg/800px-Sahara_satellite_hires.jpg"> |
| 20 | +</center> |
| 21 | + |
| 22 | +*** |
| 23 | + |
| 24 | +## Esquema de las etapas del análisis |
| 25 | + |
| 26 | +- Identificación de los parámetros de búsqueda (AOI, ventana de tiempo, _endpoint_, etc.) |
| 27 | +- Obtención de los resultados de la búsqueda en un `DataFrame` |
| 28 | +- Exploración y refinamiento de los resultados de la búsqueda |
| 29 | +- Procesamiento de los datos para obtener resultados relevantes |
| 30 | + |
| 31 | +En este caso, crearemos un DataFrame para resumir los resultados de la búsqueda, los reduciremos a un tamaño manejable y crearemos un control deslizante para analizar los datos recuperados. |
| 32 | + |
| 33 | +*** |
| 34 | + |
| 35 | +### Importación preliminar |
| 36 | + |
| 37 | +```{code-cell} python |
| 38 | +from warnings import filterwarnings |
| 39 | +filterwarnings('ignore') |
| 40 | +import numpy as np, pandas as pd, xarray as xr |
| 41 | +import rioxarray as rio |
| 42 | +import rasterio |
| 43 | +``` |
| 44 | + |
| 45 | +```{code-cell} python |
| 46 | +import hvplot.pandas, hvplot.xarray |
| 47 | +import geoviews as gv |
| 48 | +from geoviews import opts |
| 49 | +gv.extension('bokeh') |
| 50 | +``` |
| 51 | + |
| 52 | +```{code-cell} python |
| 53 | +from pystac_client import Client |
| 54 | +from osgeo import gdal |
| 55 | +# GDAL setup for accessing cloud data |
| 56 | +gdal.SetConfigOption('GDAL_HTTP_COOKIEFILE','~/.cookies.txt') |
| 57 | +gdal.SetConfigOption('GDAL_HTTP_COOKIEJAR', '~/.cookies.txt') |
| 58 | +gdal.SetConfigOption('GDAL_DISABLE_READDIR_ON_OPEN','EMPTY_DIR') |
| 59 | +gdal.SetConfigOption('CPL_VSIL_CURL_ALLOWED_EXTENSIONS','TIF, TIFF') |
| 60 | +``` |
| 61 | + |
| 62 | +### Funciones de utilidad |
| 63 | + |
| 64 | +```{code-cell} python |
| 65 | +# simple utility to make a rectangle with given center of width dx & height dy |
| 66 | +def make_bbox(pt,dx,dy): |
| 67 | + '''Returns bounding-box represented as tuple (x_lo, y_lo, x_hi, y_hi) |
| 68 | + given inputs pt=(x, y), width & height dx & dy respectively, |
| 69 | + where x_lo = x-dx/2, x_hi=x+dx/2, y_lo = y-dy/2, y_hi = y+dy/2. |
| 70 | + ''' |
| 71 | + return tuple(coord+sgn*delta for sgn in (-1,+1) for coord,delta in zip(pt, (dx/2,dy/2))) |
| 72 | +``` |
| 73 | + |
| 74 | +```{code-cell} python |
| 75 | +# simple utility to plot an AOI or bounding-box |
| 76 | +def plot_bbox(bbox): |
| 77 | + '''Given bounding-box, returns GeoViews plot of Rectangle & Point at center |
| 78 | + + bbox: bounding-box specified as (lon_min, lat_min, lon_max, lat_max) |
| 79 | + Assume longitude-latitude coordinates. |
| 80 | + ''' |
| 81 | + # These plot options are fixed but can be over-ridden |
| 82 | + point_opts = opts.Points(size=12, alpha=0.25, color='blue') |
| 83 | + rect_opts = opts.Rectangles(line_width=0, alpha=0.1, color='red') |
| 84 | + lon_lat = (0.5*sum(bbox[::2]), 0.5*sum(bbox[1::2])) |
| 85 | + return (gv.Points([lon_lat]) * gv.Rectangles([bbox])).opts(point_opts, rect_opts) |
| 86 | +``` |
| 87 | + |
| 88 | +```{code-cell} python |
| 89 | +# utility to extract search results into a Pandas DataFrame |
| 90 | +def search_to_dataframe(search): |
| 91 | + '''Constructs Pandas DataFrame from PySTAC Earthdata search results. |
| 92 | + DataFrame columns are determined from search item properties and assets. |
| 93 | + 'asset': string identifying an Asset type associated with a granule |
| 94 | + 'href': data URL for file associated with the Asset in a given row.''' |
| 95 | + granules = list(search.items()) |
| 96 | + assert granules, "Error: empty list of search results" |
| 97 | + props = list({prop for g in granules for prop in g.properties.keys()}) |
| 98 | + tile_ids = map(lambda granule: granule.id.split('_')[3], granules) |
| 99 | + rows = (([g.properties.get(k, None) for k in props] + [a, g.assets[a].href, t]) |
| 100 | + for g, t in zip(granules,tile_ids) for a in g.assets ) |
| 101 | + df = pd.concat(map(lambda x: pd.DataFrame(x, index=props+['asset','href', 'tile_id']).T, rows), |
| 102 | + axis=0, ignore_index=True) |
| 103 | + assert len(df), "Empty DataFrame" |
| 104 | + return df |
| 105 | +``` |
| 106 | + |
| 107 | +Estas funciones podrían incluirse en archivos modulares para proyectos de investigación más desarrollados. Para fines didácticos, se incluyen en este cuaderno computacional. |
| 108 | + |
| 109 | +*** |
| 110 | + |
| 111 | +## Obtención de los resultados de búsqueda |
| 112 | + |
| 113 | +La Gran Muralla Verde se extiende por el continente africano. Elegiremos un área de interés centrada en las coordenadas geográficas $(-16.0913^{\circ}, 16.528^{\circ})$ en Senegal. Analizaremos todos los datos disponibles desde enero de 2022 hasta finales de marzo de 2024. Usaremos los identificadores `AOI` y `DATE_RANGE` para utilizarlos eventualmente en una consulta de búsqueda PySTAC. |
| 114 | + |
| 115 | +```{code-cell} python |
| 116 | +AOI = make_bbox((-16.0913, 16.528), 0.1, 0.1) |
| 117 | +DATE_RANGE = "2022-01-01/2024-03-31" |
| 118 | +``` |
| 119 | + |
| 120 | +El gráfico que se genera a continuación ilustra el AOI. Las herramientas Bokeh Zoom son útiles para analizar la caja en varias escalas de longitud. |
| 121 | + |
| 122 | +```{code-cell} python |
| 123 | +# Optionally plot the AOI |
| 124 | +basemap = gv.tile_sources.OSM(padding=0.1, alpha=0.25) |
| 125 | +plot_bbox(AOI) * basemap |
| 126 | +``` |
| 127 | + |
| 128 | +```{code-cell} python |
| 129 | +search_params = dict(bbox=AOI, datetime=DATE_RANGE) |
| 130 | +print(search_params) |
| 131 | +``` |
| 132 | + |
| 133 | +Para ejecutar la búsqueda, definimos la URI del punto final e instanciamos el objeto `Client`. |
| 134 | + |
| 135 | +```{code-cell} python |
| 136 | +ENDPOINT = 'https://cmr.earthdata.nasa.gov/stac' |
| 137 | +PROVIDER = 'LPCLOUD' |
| 138 | +COLLECTIONS = ["OPERA_L3_DIST-ALERT-HLS_V1_1"] |
| 139 | +search_params.update(collections=COLLECTIONS) |
| 140 | +print(search_params) |
| 141 | +
|
| 142 | +catalog = Client.open(f'{ENDPOINT}/{PROVIDER}/') |
| 143 | +search_results = catalog.search(**search_params) |
| 144 | +``` |
| 145 | + |
| 146 | +La búsqueda en sí es bastante rápida y arroja algunos miles de resultados que pueden analizarse más fácilmente en un DataFrame de Pandas. |
| 147 | + |
| 148 | +```{code-cell} python |
| 149 | +%%time |
| 150 | +df = search_to_dataframe(search_results) |
| 151 | +df.info() |
| 152 | +df.head() |
| 153 | +``` |
| 154 | + |
| 155 | +Limpiaremos el `DataFrame` `df` como lo venimos haciendo de manera habitual: |
| 156 | + |
| 157 | +- renombramos la columna `eo:cloud_cover` como `cloud_cover`, |
| 158 | +- eliminamos las columnas `datetime` extrañas, |
| 159 | +- convertimos las columnas en tipos de datos sensibles, |
| 160 | +- convertimos la columna `datetime` en `DatetimeIndex`, y |
| 161 | +- establecemos la columna `datetime` como `Index`. |
| 162 | + |
| 163 | +```{code-cell} python |
| 164 | +df = df.rename(columns={'eo:cloud_cover':'cloud_cover'}) |
| 165 | +df.cloud_cover = df.cloud_cover.astype(np.float16) |
| 166 | +df = df.drop(['start_datetime', 'end_datetime'], axis=1) |
| 167 | +df = df.convert_dtypes() |
| 168 | +df.datetime = pd.DatetimeIndex(df.datetime) |
| 169 | +df = df.set_index('datetime').sort_index() |
| 170 | +``` |
| 171 | + |
| 172 | +```{code-cell} python |
| 173 | +df.info() |
| 174 | +``` |
| 175 | + |
| 176 | +El siguiente paso es identificar un conjunto con una cantidad menor de filas a partir de los resultados de la búsqueda con los que podamos trabajar más fácilmente. |
| 177 | + |
| 178 | +*** |
| 179 | + |
| 180 | +## Exploración y refinamiento de los resultados de la búsqueda |
| 181 | + |
| 182 | +Lo que queremos es la banda `VEG-DIST-STATUS` de los datos DIST-ALERT, así que debemos extraer solamente las filas del `df` asociadas a esa banda. Para ello, podemos construir una serie booleana `c1` que sea `True` siempre que la cadena de la columna `asset` incluya `VEG-DIST-STATUS` como subcadena. También podemos construir una serie booleana `c2` para filtrar las filas cuya `cloud_cover` exceda el 20%. |
| 183 | + |
| 184 | +```{code-cell} python |
| 185 | +c1 = df.asset.str.contains('VEG-DIST-STATUS') |
| 186 | +``` |
| 187 | + |
| 188 | +```{code-cell} python |
| 189 | +c2 = df.cloud_cover<20 |
| 190 | +``` |
| 191 | + |
| 192 | +Si analizamos la columna `tile_id`, podemos ver que un único mosaico MGRS contiene el AOI que especificamos. Como tal, todos los datos indexados en `df` corresponden a mediciones distintas tomadas de un mosaico geográfico fijo en diferentes momentos. |
| 193 | + |
| 194 | +```{code-cell} python |
| 195 | +df.tile_id.value_counts() |
| 196 | +``` |
| 197 | + |
| 198 | +Podemos combinar la información anterior para reducir el `DataFrame` a una secuencia de filas mucho más pequeña. También podemos eliminar las columnas `asset` y `tile_id` porque serán las mismas en todas las filas después del filtrado. También podemos eliminar la columna `cloud_cover`, ya que en lo sucesivo solo necesitaremos la columna `href`. |
| 199 | + |
| 200 | +```{code-cell} python |
| 201 | +df = df.loc[c1 & c2].drop(['asset', 'tile_id', 'cloud_cover'], axis=1) |
| 202 | +df.info() |
| 203 | +``` |
| 204 | + |
| 205 | +*** |
| 206 | + |
| 207 | +## Procesamiento de los datos para obtener resultados relevantes |
| 208 | + |
| 209 | +Podemos analizar el `DataFrame` para ver cuál es la información resultante. |
| 210 | + |
| 211 | +```{code-cell} python |
| 212 | +df |
| 213 | +``` |
| 214 | + |
| 215 | +Hay casi ochenta filas, cada una de las cuales está asociada a un gránulo distinto (en este contexto, un archivo GeoTIFF producido a partir de una observación efectuada en una fecha y hora determinadas). Utilizaremos un bucle para crear un `DataArray` apilado a partir de los archivos remotos utilizando `xarray.concat`. Dado que se deben recuperar algunas docenas de archivos de una fuente remota, esto puede tardar unos minutos y el resultado requerirá algo de memoria (unos 12 MiB por cada fila, ya que cada GeoTIFF corresponde a una matriz de $3,660\times3,660$ de enteros de 8 bits sin signo). |
| 216 | + |
| 217 | +```{code-cell} python |
| 218 | +%%time |
| 219 | +stack = [] |
| 220 | +for timestamp, row in df.iterrows(): |
| 221 | + data = rio.open_rasterio(row.href).squeeze() |
| 222 | + data = data.rename(dict(x='longitude', y='latitude')) |
| 223 | + del data.coords['band'] |
| 224 | + data.coords.update({'time':timestamp}) |
| 225 | + data.attrs = dict(description=f"OPERA DIST: VEG-DIST-STATUS", units=None) |
| 226 | + stack.append(data) |
| 227 | +stack = xr.concat(stack, dim='time') |
| 228 | +stack |
| 229 | +``` |
| 230 | + |
| 231 | +A modo de recordatorio, para la banda `VEG-DIST-STATUS`, interpretaremos los valores del ráster de la siguiente manera: |
| 232 | + |
| 233 | +- **0:** Sin alteración |
| 234 | +- **1:** Primera detección de alteraciones con cambios en la cobertura vegetal <50% |
| 235 | +- **2:** Detección provisional de alteraciones con cambios en la cobertura vegetal <50% |
| 236 | +- **3:** Detección confirmada de alteraciones con cambios en la cobertura vegetal < 50% |
| 237 | +- **4:** Primera detección de alteraciones con cambios en la cobertura vegetal ≥50% |
| 238 | +- **5:** Detección provisional de alteraciones con cambios en la cobertura vegetal ≥50% |
| 239 | +- **6:** Detección confirmada de alteraciones con cambios en la cobertura vegetal ≥50% |
| 240 | +- **7:** Detección finalizada de alteraciones con cambios en la cobertura vegetal <50% |
| 241 | +- **8:** Detección finalizada de alteraciones con cambios en la cobertura vegetal ≥50% |
| 242 | +- **255** Datos faltantes |
| 243 | + |
| 244 | +Al aplicar `np.unique` a la pila de rásters, vemos que estos 10 valores distintos aparecen en algún lugar de los datos. |
| 245 | + |
| 246 | +```{code-cell} python |
| 247 | +np.unique(stack) |
| 248 | +``` |
| 249 | + |
| 250 | +Trataremos los píxeles con valores faltantes (por ejemplo, el `255`) igual que los píxeles sin alteraciones (por ejemplo, el valor `0`). Podríamos reasignar el valor `nan` a esos píxeles, pero eso convierte todos los datos a `float32` o `float64` y, por lo tanto, aumenta la cantidad de memoria requerida. Es decir, reasignar `255->0` nos permite ignorar los valores que faltan sin utilizar más memoria. |
| 251 | + |
| 252 | +```{code-cell} python |
| 253 | +stack = stack.where(stack!=255, other=0) |
| 254 | +
|
| 255 | +np.unique(stack) |
| 256 | +``` |
| 257 | + |
| 258 | +Definiremos un mapa de colores para identificar los píxeles que muestran signos de alteraciones. En vez de asignar colores diferentes a cada una de las 8 categorías de alteraciones, utilizaremos [valores RGBA](https://es.wikipedia.org/wiki/Espacio_de_color_RGBA) para asignar colores con un valor de transparencia. Con el mapa de colores definido en la siguiente celda, la mayoría de los píxeles serán totalmente transparentes. Los píxeles restantes son de color rojo con valores `alpha` estrictamente positivos. Los valores que realmente queremos ver son `3`, `6`, `7` y `8` (que indican una alteración confirmada en curso o una alteración confirmada que finalizó). |
| 259 | + |
| 260 | +```{code-cell} python |
| 261 | +# Define a colormap using RGBA values; these need to be written manually here... |
| 262 | +COLORS = [ |
| 263 | + (255, 255, 255, 0.0), # No disturbance |
| 264 | + (255, 0, 0, 0.25), # <50% disturbance, first detection |
| 265 | + (255, 0, 0, 0.25), # <50% disturbance, provisional |
| 266 | + (255, 0, 0, 0.50), # <50% disturbance, confirmed, ongoing |
| 267 | + (255, 0, 0, 0.50), # ≥50% disturbance, first detection |
| 268 | + (255, 0, 0, 0.50), # ≥50% disturbance, provisional |
| 269 | + (255, 0, 0, 1.00), # ≥50% disturbance, confirmed, ongoing |
| 270 | + (255, 0, 0, 0.75), # <50% disturbance, finished |
| 271 | + (255, 0, 0, 1.00), # ≥50% disturbance, finished |
| 272 | + ] |
| 273 | +``` |
| 274 | + |
| 275 | +Ya podemos, entonces, producir visualizaciones utilizando la matriz `stack`. |
| 276 | + |
| 277 | +- Definimos `view` como un subconjunto de `stack` que se salta `steps` de píxeles en cada dirección para acelerar el renderizado (cambiar a `steps=1` o `steps=None` cuando querramos graficar a resolución completa). |
| 278 | +- Definimos los diccionarios `image_opts` y `layout_opts` para controlar los argumentos que pasaremos a `hvplot.image`. |
| 279 | +- El resultado, cuando se visualiza, es un gráfico interactivo con un selector que nos permite ver cortes temporales específicos de los datos. |
| 280 | + |
| 281 | +```{code-cell} python |
| 282 | +steps = 100 |
| 283 | +subset=slice(0,None,steps) |
| 284 | +view = stack.isel(longitude=subset, latitude=subset) |
| 285 | +
|
| 286 | +image_opts = dict( |
| 287 | + x='longitude', |
| 288 | + y='latitude', |
| 289 | + cmap=COLORS, |
| 290 | + colorbar=False, |
| 291 | + clim=(-0.5,8.5), |
| 292 | + crs = stack.rio.crs, |
| 293 | + tiles=gv.tile_sources.ESRI, |
| 294 | + tiles_opts=dict(alpha=0.1, padding=0.1), |
| 295 | + project=True, |
| 296 | + rasterize=True, |
| 297 | + widget_location='bottom', |
| 298 | + ) |
| 299 | +
|
| 300 | +layout_opts = dict( |
| 301 | + title = 'Great Green Wall, Sahel Region, Africa\nDisturbance Alerts', |
| 302 | + xlabel='Longitude (°)',ylabel='Latitude (°)', |
| 303 | + fontscale=1.25, |
| 304 | + frame_width=500, |
| 305 | + frame_height=500, |
| 306 | + ) |
| 307 | +``` |
| 308 | + |
| 309 | +```{code-cell} python |
| 310 | +view.hvplot.image(**image_opts, **layout_opts) |
| 311 | +``` |
| 312 | + |
| 313 | +Puede ser difícil ver los píxeles de color rojo con toda la región a la vista. Las herramientas zoom de caja y zoom de rueda son útiles aquí. También hay cierta latencia cuando se utiliza el selector, ya que se tarda un tiempo en renderizar. |
| 314 | + |
| 315 | +*** |
0 commit comments