Ejercicio: OBIA en GRASS GIS

Autor/a

Verónica Andreo

Fecha de publicación

13 de septiembre de 2023

En este ejercicio vamos a ejemplificar el flujo de trabajo para realizar una clasificación supervisada basada en objetos con datos SPOT.

Antes de empezar y para ganar tiempo, conectamos nuestro drive e instalamos GRASS en Google Colab.

# import drive from google colab
from google.colab import drive
# mount drive
drive.mount("/content/drive")
%%bash
DEBIAN_FRONTEND=noninteractive 
sudo add-apt-repository ppa:ubuntugis/ubuntugis-unstable 
apt update 
apt install grass subversion grass-dev
apt remove libproj22

Chequeamos el path de instalación de GRASS

!grass --config path

e instalamos otras librerías de python que nos serán útiles.

!pip install pygdal

La función que usaremos en esta sesión para realizar clasificación supervisada de los objetos llama internamente a ciertas librerías de R. Para poder tenerlas de antemano en nuestro entorno, primero instalamos y cargamos en la notebook el paquete de python que nos permite hacer interfaz con R.

!pip install rpy2==3.5.1
%reload_ext rpy2.ipython

Chequeamos nuestra sesión de R.

%%R 
sessionInfo()

Instalamos todos los paquetes necesarios para este ejercicio. This might take a while…

%%R
install.packages("caret")
install.packages("kernlab")
install.packages("e1071")
install.packages("randomForest")
install.packages("rpart")
install.packages("ggplot")
install.packages("lattice")
install.packages("doParallel")

Datos para esta sesión

  • SPOT 6
  • Canales VIS - NIR (6 m)
  • Canal PAN (1.5 m)
  • Datos ya corregidos y fusionados; resolución 1.5 m

Manos a la obra

Iniciamos GRASS GIS

Iniciamos GRASS GIS en el proyecto posgar2007_4_cba y mapset PERMANENT.

import os

# data directory
homedir = "/content/drive/MyDrive/curso_grass_2023"

# change to homedir so output files will be saved there
os.chdir(homedir)
# GRASS GIS database variables
grassdata = os.path.join(homedir, "grassdata")
project = "posgar2007_4_cba"
mapset = "PERMANENT"
# import standard Python packages we need
import subprocess
import sys

# ask GRASS GIS where its Python packages are to be able to start it from the notebook
sys.path.append(
    subprocess.check_output(["grass", "--config", "python_path"], text=True).strip()
)
# import the GRASS GIS packages we need
import grass.script as gs
import grass.jupyter as gj

# start the GRASS GIS Session
session = gj.init(grassdata, project, mapset)
Tarea
  • Crear un mapset llamado obia_spot e importar allí las bandas VIS-NIR y PAN de la imagen SPOT con reproyección al vuelo y resolución espacial de 1.5 m.
  • Alinear la región computacional a la extensión y resolución de alguna de las bandas importadas.
  • Hacer una ecualización de histograma para mejorar el contraste de visualización.
  • Mostrar la combinación RGB color natural (1: azul, 2: verde, 3: rojo, 4: NIR).

Importar datos y visualizar

Creamos el mapset obia_spot.

# Create a new mapset
gs.run_command("g.mapset",
               mapset="obia_spot",
               flags="c")

Una vez creado el mapset, procedemos con la importación de datos. Empezamos por las bandas multi-espectrales.

spot_data = os.path.join(homedir, "gisdata", "spot_data")
# import pansharpened SPOT data
gs.run_command("r.import",
               input=os.path.join(spot_data, "SPOT_20180621_PANSHARP_p.tif"),
               output="SPOT_20180621_PANSHARP",
               resolution="value",
               resolution_value=1.5)

Importamos también la banda pancromática.

# import SPOT PAN band
gs.run_command("r.import",
               input=os.path.join(spot_data, "SPOT_20180621_PAN.tif"),
               output="SPOT_20180621_PAN",
               resolution="value",
               resolution_value=1.5)

Chequeamos la lista de mapas raster en el mapset.

pansharp = gs.list_grouped(type="raster", pattern="*PANSHARP*")["obia_spot"]
pansharp

Establecemos la región computacional a una de las bandas importadas, de modo que límites y resolución de la región queden alineados a los datos importados.

# align region to one of the raster bands
print(gs.read_command("g.region",
                      raster="SPOT_20180621_PANSHARP.1",
                      flags="p"))

Establecemos grey como paleta de colores para las bandas RGB, para poder realizar la composición color natural posteriormente.

# apply grey color to RGB bands
gs.run_command("r.colors", 
               map=pansharp,
               color="grey")

Primero, hacemos la ecualización de colores para mejorar la visualización.

# perform color auto-balancing for RGB bands
gs.run_command("i.colors.enhance", 
               red="SPOT_20180621_PANSHARP.3",
               green="SPOT_20180621_PANSHARP.2", 
               blue="SPOT_20180621_PANSHARP.1",
               strength=95)

Visualizamos la composición RGB 321 color natural.

# display the enhanced RGB combination
cba_rgb = gj.Map(use_region=False)
cba_rgb.d_rgb(red="SPOT_20180621_PANSHARP.3",
              green="SPOT_20180621_PANSHARP.2",
              blue="SPOT_20180621_PANSHARP.1")
cba_rgb.d_grid(size=2000, flags="b")
cba_rgb.show()

Hay valores nulos?

En la cadena de procesamiento que vamos a realizar, especialmente en los pasos de segmentación, no se admiten valores nulos en los mapas de entrada. Así que, como primer paso antes de proceder, chequeamos la estadística univariada para saber si hay valores nuelos en nuestros mapas.

# one band
print(gs.read_command("r.univar",
                      map="SPOT_20180621_PANSHARP.2"))
# joint stats for all the bands
print(gs.read_command("r.univar",
                      map=pansharp))
Importante

Si hubiera valores nulos, se deben rellenar antes de comenzar! Qué herramientas podríamos utilizar?

Índices espectrales y texturas GLCM

Como ya hicimos en los ejercicios anteriores, estimamos índices espectrales de vegetación y agua.

# estimate vegetation index
gs.run_command("i.vi", 
               red="SPOT_20180621_PANSHARP.3", 
               nir="SPOT_20180621_PANSHARP.4", 
               output="SPOT_20180621_NDVI", 
               viname="ndvi")
# install i.wi
gs.run_command("g.extension", 
               extension="i.wi")
               
# estimate water index
gs.run_command("i.wi", 
               green="SPOT_20180621_PANSHARP.2", 
               nir="SPOT_20180621_PANSHARP.4", 
               output="SPOT_20180621_NDWI", 
               winame="ndwi_mf")

# set ndwi color palette
gs.run_command("r.colors", 
               map="SPOT_20180621_NDWI", 
               color="ndwi")

Por otra parte, vamos a estimar medidas de textura con r.texture. En este caso, elegimos Inverse Difference Moment (IDM) y Angular Second Moment (ASM), pero hay muchas otras opciones. Este módulo puede ser muy lento si la región computacional es muy grande y si el tamaño de la ventana es grande también. Para agilizar los cálculos en estos casos, se puede usar la extensión r.texture.tiled.

# estimate textures measures
gs.run_command("r.texture",
               input="SPOT_20180621_PAN",
               output="SPOT_20180621",
               size=7,
               distance=3,
               method="idm,asm")
# set color table to grey for texture bands
gs.run_command("r.colors", 
               map="SPOT_20180621_IDM", 
               color="grey",
               flags="e")
gs.run_command("r.colors", 
               map="SPOT_20180621_ASM", 
               color="grey",
               flags="e")

Visualizamos las bandas creadas. Esto puede no funcionar dentro de Colab, en tal caso probar de a un mapa o usar la clase Map.

idx_map = gj.InteractiveMap(width = 400, use_region=False, tiles="OpenStreetMap")
idx_map.add_raster("SPOT_20180621_NDVI", opacity=0.7)
idx_map.add_raster("SPOT_20180621_NDWI", opacity=0.7)
idx_map.add_raster("SPOT_20180621_IDM", opacity=0.7)
idx_map.add_raster("SPOT_20180621_ASM", opacity=0.7)
idx_map.add_layer_control(position = "bottomright")
idx_map.show()
Nota

Sobre qué banda calculamos las texturas? Si no contamos con una banda pancromática, podemos crearla promediando las bandas visibles

# create pan-vis from RGB (if no pan available)
R = "SPOT_20180621_PANSHARP.3"
G = "SPOT_20180621_PANSHARP.2"
B = "SPOT_20180621_PANSHARP.1"

gs.mapcalc(exp=f"PANVIS = round(({R} + {G} + {B}) / 3)"

Segmentación

Búsqueda de umbrales de sub y sobre-segmentación

Como vimos en el ejercicio anterior, usualmente, los módulos i.* toman un grupo de mapas como entrada. Antes de comenzar con la segmentación, entonces, creamos un grupo con las bandas multiespectrales únicamente.

# create imagery group (only ms bands)
gs.run_command("i.group",
               group="spot_bands",
               input=pansharp)

Ahora vamos a aprovechar una de las grandes ventajas de la región computacional, i.e., definir una región más pequeña para realizar unas pruebas :)

# set smaller region
gs.run_command("g.region",
               n=6525171,
               s=6523179,
               w=4390557,
               e=4393257)
gs.run_command("g.region",
               save="obia_subset")
gs.region()

Ahora sí, ejecutamos un par de segmentaciones para determinar niveles de sub- y sobre-segmentación. Empezamos con un umbral pequeño.

# run segmentation - small threshold
gs.run_command("i.segment",
               group="spot_bands",
               output="segment_001",
               threshold=0.01,
               memory=2000)
# convert output to vector
gs.run_command("r.to.vect",
               input="segment_001",
               output="segment_001",
               type="area",
               flags="tv")
# display results
segs = gj.Map(use_region=True)
segs.d_rgb(red="SPOT_20180621_PANSHARP.3",
              green="SPOT_20180621_PANSHARP.2",
              blue="SPOT_20180621_PANSHARP.1")
segs.d_vect(map="segment_001", type="boundary", color="yellow")
segs.show()

Probamos con un umbral más grande.

# run segmentation - larger threshold
gs.run_command("i.segment",
               group="spot_bands",
               output="segment_005",
               threshold=0.05,
               memory=2000)
# convert output to vector
gs.run_command("r.to.vect",
               input="segment_005",
               output="segment_005",
               type="area",
               flags="tv")
# display results
segs = gj.Map(use_region=True)
segs.d_rgb(red="SPOT_20180621_PANSHARP.3",
              green="SPOT_20180621_PANSHARP.2",
              blue="SPOT_20180621_PANSHARP.1")
segs.d_vect(map="segment_005", type="boundary", color="red")
segs.show()

Hagamos zoom sobre los resultados para cada umbral.

Sobre-segmentado

Sub-segmentado

Tarea

Se animan a probar con otros valores y en otras regiones?

Búsqueda automática de umbrales por optimización

Teniendo valores de umbrales de sub- y sobre-segmentación, podemos proceder a realizar la segmentación de manera autómatica, buscando el mejor umbral en una especie de grilla definida por umbral mínimo, umbral máximo y paso. Para ello, utilizamos la extensión: i.segment.uspo.

Este procedimiento es computacionalmente intensivo para un área grande y muchas combinaciones de parámetros, pero siempre podemos:

  • Limitar el tamaño de la región computacional o generar tiles con i.cutlines y paralelizar la USPO.
  • Limitar el rango de los parámetros, o usar un paso relativamente grande.
  • Crear superpixels con i.superpixels.slic para usarlos como semillas.

Generación de semillas

La extensión i.superpixels.slic

  • También puede utilizarse para la segmentación per se, como vimos en el ejercicio anterior.
  • Es muy rápida para reagrupar pequeñas cantidades de píxeles similares.
  • Se puede usar para reducir el número de píxeles en un factor de 4-5 y acelerar i.segment.uspo, que ya no empieza por pixeles individuales, sino por pequeños grupos de pixeles similares.
  • Se usa baja compactación para mantener la separación espectral (Ver el manual: A larger compactness value will cause spatially more compact, but spectrally more heterogeneous superpixels).

USPO con superpixels como semillas

Vamos a ejecutar entonces i.superpixels.slic con bajo valor de compactación para generar el semillero para i.segment.uspo.

# install extension
gs.run_command("g.extension",
               extension="i.superpixels.slic")
               
# run superpixel segmentation to use as seeds
gs.run_command("i.superpixels.slic",
               input="spot_bands",
               output="superpixels",
               step=2,
               compactness=0.7,
               memory=2000)

Acá podemos ver un recorte de la composición RGB y el resultado de la ejecución de i.superpixels.slic.

Tarea

Cuántas semillas se generaron? Qué factor de reducción se consigue en comparación a usar todos los pixeles?

Para responder pueden dar una mirada a los manuales de r.info y g.region.

Instalamos las extensiones y ejecutamos la segmentación con optimización.

# install extensions
gs.run_command("g.extension", 
               extension="r.neighborhoodmatrix")
gs.run_command("g.extension", 
               extension="i.segment.uspo")
# run segmentation with uspo
gs.run_command("i.segment.uspo",
               group="spot_bands",
               output="uspo_parameters.csv",
               region="obia_subset",
               seeds="superpixels",
               segment_map="segs",
               threshold_start=0.005,
               threshold_stop=0.05,
               threshold_step=0.005,
               minsizes=3, 
               number_best=5,
               memory=8000,
               processes=6)
# explore results
gs.list_grouped(type="raster", 
                pattern="segs_*")["obia_spot"]
!cat uspo_parameters.csv

Convertimos el mejor resultado, i.e., **rank1* a vector para visualizar el resultado.

# convert to vector the rank1
gs.run_command("r.to.vect",
               input="segs_obia_subset_rank1",
               output="segs",
               type="area",
               flags="tv")

Zoom al resultado de ejecutar la segmentación con USPO

Tarea

Cuántos segmentos obtuvieron?

Dar una mirada a v.info para responder.

Estadísticas de segmentos: i.segment.stats

Instalamos la extensión que nos permite extraer estadísticas para los segmentos obtenidos en el paso anterior.

# install extensions
gs.run_command("g.extension", 
               extension="i.segment.stats")
# extract stats for all segments 
# Note: *vectormap* output does not work for this dataset, 
# see workaround with csv output in further steps
gs.run_command("i.segment.stats",
               map="segs_obia_subset_rank1",
               csvfile="segs_stats.csv",
               rasters="SPOT_20180621_ASM,SPOT_20180621_IDM,SPOT_20180621_NDVI,SPOT_20180621_NDWI,SPOT_20180621_PAN",
               raster_statistics="mean,stddev",
               area_measures="area,perimeter,compact_circle,compact_square",
               processes=4)

Visualizamos el csv resultante con la estadística por segmentos.

!cat segs_stats.csv | head
Tarea

Qué otras estadísticas se podrían obtener? Qué otro(s) módulo(s) podría(n) sustituir a i.segment.stats?

Datos de entrenamiento

Tenemos nuestros segmentos caracterizados, pero nos falta un dato fundamental para poder realizar una clasificación supervisada… la verdad de terreno, i.e., a qué clase pertenece una muestra de todos los segmentos del área de estudio. Como etiquetar segmentos o puntos es una tarea tediosa, se provee dentro del mapset PERMANENT un conjunto de puntos con etiqueta para realizar el entrenamiento. Veamos cuántos puntos tenemos.

# get info of labeled points
gs.vector_info_topo("labeled_points")

Como el acceso a tablas de otros mapsets no está permitido, nos copiamos el vector con los puntos etiquetados al mapset obia_spot.

# copy vector to current mapset (access to tables from different mapsets is not allowed)
gs.run_command("g.copy",
               vector="labeled_points@PERMANENT,labeled_points")

Cuántos puntos de cada clase tenemos?

# get number of points per class
print(gs.read_command("db.select",
                      sql="SELECT train_class,COUNT(cat) as count_class FROM labeled_points GROUP BY train_class"))

Seleccionar segmentos sobre los cuales tenemos puntos de entrenamiento

# select segments that are below labeled points
gs.run_command("v.select",
               ainput="segs",
               binput="labeled_points",
               output="train_segments",
               operator="overlap")

Cuántos segmentos contienen puntos de entrenamiento?

# get info of segments
gs.vector_info_topo(map="train_segments")

Veamos un zoom a la selección de segmentos con puntos de entrenamiento.

Antes de asignar el atributo de los puntos a los segmentos, necesitamos agregar una tabla y una columna al vector con los segmentos.

# add attr table to train segments
gs.run_command("v.db.addtable",
               map="train_segments")
# add column to train segments
gs.run_command("v.db.addcolumn",
               map="train_segments",
               column="class int")

Ahora sí, usamos el módulo v.distance para transferir la etiqueta (clase de cobertura) desde los puntos a los segmentos

# assign label from points to segments
gs.run_command("v.distance",
               from_="train_segments", 
               to="labeled_points",
               upload="to_attr",
               column="class",
               to_column="train_class")

Cuántos segmentos de cada clase tenemos?

# group training segments per class
!db.select sql="SELECT class,COUNT(cat) as count_class FROM train_segments GROUP BY class"

Como, por alguna razón, para estos datos, no pudimos obtener un vector a partir de i.segment.stats, necesitamos hacer un “workaround” para obtener, en cambio, un csv con los segmentos de entrenamiento, sus estadísticas y sus etiquetas. Para eso, haremos lo siguiente:

  • convertir a raster el vector train_segments con las etiquetas,
  • obtener las estadísticas para los segmentos de entrenamiento con i.segment.stats,
  • importar a GRASS la tabla csv resultante,
  • unir el vector de segmentos de entrenamiento que tiene las etiquetas con la tabla con las estadísticas,
  • eliminar la columna cat_ resultante de la unión, ya que no es un atributo para la clasificación, y
  • exportar la nueva tabla de atributos como csv.

Empecemos el workaround…

# convert train_segments vector to raster
gs.run_command("v.to.rast",
               input="train_segments",
               output="train_segments",
               use="cat")
# extract stats for training segments
gs.run_command("i.segment.stats",
               map="train_segments",
               csvfile="train_segs_stats.csv",
               separator="comma",
               rasters="SPOT_20180621_ASM,SPOT_20180621_IDM,SPOT_20180621_NDVI,SPOT_20180621_NDWI,SPOT_20180621_PAN",
               raster_statistics="mean,stddev",
               area_measures="area,perimeter,compact_circle,compact_square",
               processes=4)
!cat train_segs_stats.csv | head
# import csv table
gs.run_command("db.in.ogr",
               input="train_segs_stats.csv",
               output="train_segs_stats") # name of the table within GRASS
# check column names
print(gs.read_command("db.describe",
                      table="train_segs_stats",
                      flags="c"))
# merge vector train_segments with attr from train_segs_stats table
gs.run_command("v.db.join",
               map="train_segments",
               column="cat",
               other_table="train_segs_stats",
               other_column="cat_")
# drop cat_ column
gs.run_command("v.db.dropcolumn",
               map="train_segments",
               columns="cat_")
# save patched attr table as csv 
gs.run_command("v.db.select",
               map="train_segments",
               file="train_segs_stats_class.csv")
# check csv
!cat train_segs_stats_class.csv | head

Si de i.segment.stats ustedes pueden obtener un vector, la tarea es mucho más sencilla:

# select segments that are below labeled points
v.select \
  ainput=segs_stats \ # vector obtenido con i.segment.stats
  binput=labeled_points \
  output=train_segments \
  operator=overlap

# add column to train segments
v.db.addcolumn train_segments column="class int"

# assign label from points to segments
v.distance from=train_segments \
  to=labeled_points \
  upload=to_attr \
  column=class \
  to_column=train_class
Una forma de seleccionar y etiquetar datos de entrenamiento
  • Ejecutar una clasificación no supervisada con 10 clases
  • Extraer una x cantidad de puntos por clase (r.sample.category)
  • Etiquetar los puntos manualmente
  • Usar puntos para transferir las etiquetas a los segmentos como ya vimos
# Unsupervised classification
i.group group=spot_all input=SPOT_20180621_ASM,SPOT_20180621_IDM,SPOT_20180621_NDVI,SPOT_20180621_NDWI,SPOT_20180621_PAN,SPOT_20180621_PANSHARP.1,SPOT_20180621_PANSHARP.2,SPOT_20180621_PANSHARP.3,SPOT_20180621_PANSHARP.4
i.cluster group=spot_all signaturefile=sig classes=10
i.maxlik group=spot_all signaturefile=sig output=uns_clas

# install extension
g.extension r.sample.category

# get n points per class
r.sample.category input=uns_clas output=uns_clas_points npoints=150

# then, manually label points

Clasificación con Machine learning

Ahora sí, finalmente llegamos a la clasificación de los segmentos. Vamos a instalar la extensión v.class.mlR. Esta extensión usa paquetes de R para realizar la clasificación, por lo tanto necesitamos tener R y ciertos paquetes previamente instalados.

# install extension
gs.run_command("g.extension", 
               extension="v.class.mlR")
# run classification
gs.run_command("v.class.mlR",
               segments_file="segs_stats.csv", # stats of all segments
               training_file="train_segs_stats_class.csv", # stats of training segs
               train_class_column="class",
               classified_map="classification",
               raster_segments_map="segs_obia_subset_rank1",
               classifier="rf",
               folds=5,
               partitions=10,
               tunelength=10,
               weighting_modes="smv",
               weighting_metric="accuracy",
               output_model_file="model",
               variable_importance_file="var_imp.txt",
               accuracy_file="accuracy.csv",
               model_details="classifier_runs.txt",
               r_script_file="Rscript_mlR.R",
               processes=2, # use more if available
               flags="n")
Tarea

Crear paleta de colores para las clases de cobertura urbana y guardarla como obia_urban en el directorio de trabajo. Ver el manual para obtener ejemplos sobre la creación de reglas. En la imagen de abajo, se sugieren algunos colores.

Aplicamos la paleta de colores que creamos.

# set color table that we created interactively
gs.run_command("r.colors",
               map="classification_rf",
               rules="obia_urban")

Visualizamos el resultado de la clasificación supervisada con Machine Learning basada en objetos

# display results
obia = gj.Map(use_region=True)
obia.d_rast(map="classification_rf")
obia.show()

Revisemos los archivos auxiliares que obtuvimos de la ejecución de la clasificación por random forest.

!cat var_imp.txt
!cat accuracy.csv
!cat classifier_runs.txt
Importante

El proceso de clasificación usualmente conlleva una serie de iteraciones que implican selección de variables más importantes, búsqueda de más/mejores datos de entrenamiento y validación

Validación

  • Se usan datos independientes para validar las clasificaciones
  • Se construye una matriz de confusión que permite visualizar los errores por clase en los elementos que están fuera de la diagonal
  • Se estiman varias medidas relacionadas a la precisión, ej.: overall accuracy y kappa

Distintas opciones: 1. Generar un nuevo set de puntos y etiquetarlos 2. Separar el set de puntos etiquetados en train y test de antemano

Opción 1

Tarea

Generar un set de validación de al menos 50 segmentos. Una vez creado el vector de segmentos con etiquetas, testing, convertirlo a formato raster y ejecutar r.kappa.

r.kappa necesita mapas raster como input, por lo tanto necesitamos transformar los segmentos de validación a formato raster usando la columna class como fuente de valores para los pixeles.

# convert labeled test segments to raster
gs.run_command("v.to.rast",
               map="testing",
               use="attr",
               attribute_column="class",
               output="testing")
# create confusion matrix and estimate precision measures
print(gs.read_command("r.kappa",
                      classification="classification_rf",
                      reference="testing"))

Opción 2

Alternativamente, podemos separar el set de puntos etiquetados en train y test. Usemos la extensión v.divide.training_validation creada por mundialis.

# install the extension
gs.run_command("g.extension",
               extension="v.divide.training_validation",
               url="https://github.com/mundialis/v.divide.training_validation")
# divide our labeled segments into train and test
gs.run_command("v.divide.training_validation",
               input="train_segments", # vector de seg de entr con clases
               column="class", 
               training="training", 
               validation="testing", 
               training_percent=70)
Tarea

Ejecutar nuevamente la clasificación usando sólo el vector training. Recordar convertir a csv.

Finalmente, convertimos a raster el vector testing utilizando la columna class como valor para los pixeles y ejecutamos r.kappa.

# convert labeled test segments to raster
gs.run_command("v.to.rast",
               map="testing",
               use="attr",
               attribute_column="class",
               output="testing")
# create confusion matrix and estimate precision measures
print(gs.read_command("r.kappa",
                      classification="classification_rf",
                      reference="testing"))