Laboratório 5#

Avaliação de classificação supervisionada de imagens no GEE

Objetivos:

  1. Divisão de amostras para avaliação de classificação

  2. Classificação supervisionada

  3. Seleção de modelo

[ ]:
import random

import ee


import geemap
[ ]:
# Ref: https://developers.google.com/earth-engine/apidocs/ee-authenticate
# Para inicializar a sessão para execução insira o id do projeto em ee.Initialize().
ee.Authenticate()
ee.Initialize(project='id_projeto')

Preparação dos dados#

Primeiro definimos uma região de interesse, que pode convenientemente ser a mesma área do laboratório anterior, o que facilitará bastante o desenvolvimento deste laboratório.

[ ]:
# roi: região de interesse
roi = ee.Geometry.BBox(
    west=-46.88655, south=-23.6906, east=-46.611642, north=-23.590958
)
roi

Vamos realizar algumas operações idênticas ao laboratório anterior, para carregar a imagem do Sentinel-2 e preparar os dados para o laboratório.

Para começar, vamos definir algumas variáveis globais que serão utilizadas ao longo do laboratório.

[ ]:
escala = 10  # resolução espacial, em metros
limite_nuvens = 10  # limite de cobertura de nuvens, em porcentagem
dataset = ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")  # dataset original
sao_paulo = ee.Geometry.Point([-46.6333, -23.5500])  # um ponto qualquer em São Paulo
data_inicio = "2019-01-01"
data_fim = "2023-01-01"
banda_infra_vermelho = "B8"  # banda do infravermelho próximo (NIR)
banda_vermelho = "B4"  # banda do vermelho

Agora definimos algumas funções relevantes para o pré-processamento dos dados.

[ ]:
def recorta(img: ee.Image) -> ee.Image:
    """Função para recortar parte da img na região desejada. Assume que já
    existe uma variável roi definida."""
    return img.clip(roi)


def remove_nuvens(img: ee.Image) -> ee.Image:
    """Função para remover nuvens de uma img. Só funciona para o Sentinel-2."""
    return (
        img.updateMask(img.select("MSK_CLDPRB").lt(limite_nuvens))
        .updateMask(img.select("SCL").neq(3))
        .updateMask(img.select("SCL").neq(7))
        .updateMask(img.select("SCL").neq(8))
        .updateMask(img.select("SCL").neq(9))
        .updateMask(img.select("SCL").neq(10))
    )


def remove_valores_invalidos(img: ee.Image) -> ee.Image:
    """Função para remoção de valores inválidos. Funciona somente para o
    Sentinel-2."""
    return (
        img.updateMask(img.select("B2").lt(10000))
        .updateMask(img.select("B3").lt(10000))
        .updateMask(img.select("B4").lt(10000))
        .updateMask(img.select("B8").lt(10000))
    )

Agora executamos o pré-processamento dos dados, igual fizemos no laboratório anterior.

[ ]:
# Filtra o dataset para a região, datas e bandas de interesse.
dataset = (
    dataset.filterBounds(sao_paulo)
    .filterMetadata("CLOUDY_PIXEL_PERCENTAGE", "less_than", limite_nuvens)
    .filterDate(data_inicio, data_fim)
    .select(["B2", "B3", "B4", "B8", "MSK_CLDPRB", "SCL"])
)

# Recorta o dataset para a região de interesse.
dataset_clipped = dataset.map(recorta)

# Remove as nuvens do dataset.
dataset_sem_nuvens = dataset_clipped.map(remove_nuvens)

# Remove valores inválidos na coleção.
dataset_sem_nuvens = dataset_sem_nuvens.map(remove_valores_invalidos)
dataset_sem_nuvens

Até aqui apenas editamos o dataset, vamos então criar uma ee.Image a partir dele, tal qual fizemos no laboratório anterior.

[ ]:
# Cria o mosaico a partir da coleção.
imagem = dataset_sem_nuvens.mosaic()

# Cria a composição a partir da coleção.
imagem = dataset_sem_nuvens.mean()

# Ajuste nos valores de reflectância das imagens.
imagem = imagem.multiply(0.0001)

# Extrai bandas do vermelho e do infravermelho próximo
imagem_banda_nir = imagem.select(banda_infra_vermelho)
imagem_banda_vermelho = imagem.select(banda_vermelho)

# Calcula o NDVI
imagem = imagem.addBands(
    imagem_banda_nir.subtract(imagem_banda_vermelho)
    .divide(imagem_banda_nir.add(imagem_banda_vermelho))
    .rename("NDVI"),
    ["NDVI"],
)

# Ajuste nos valores de reflectância das imagens.
# imagem = imagem.multiply(0.0001)  # TODO: por que tem que multiplicar 2 vezes?

# Seleciona as bandas de interesse.
imagem = imagem.select(ee.List(["B2", "B3", "B4", "B8", "NDVI"]))
imagem

Vamos utilizar os resultados do laboratório anterior para treinar e avaliar um modelo de classificação supervisionada. Para isso, vamos precisar do arquivo shapefile coletado ao final do laboratório anterior. Se você não tiver o arquivo samples.shp no seu drive, copie-o do diretório data do repositório do curso no GitHub.

O módulo geopandas é necessário para ler o arquivo shapefile. Execute a célula abaixo para instalar o módulo, caso necessário.

[ ]:
# %pip install geopandas
[ ]:
# TODO: usar um link pro github, colocar caminho absoluto.
amostras_completas = geemap.shp_to_ee("../data/samples_lab4/samples_lab4.shp")
amostras_completas
[ ]:
amostras_agua = amostras_completas.filterMetadata("classe", "equals", 0)
amostras_veget_baixa = amostras_completas.filterMetadata("classe", "equals", 1)
amostras_veget_alta = amostras_completas.filterMetadata("classe", "equals", 2)
amostras_construcoes = amostras_completas.filterMetadata("classe", "equals", 3)
amostras_solo = amostras_completas.filterMetadata("classe", "equals", 4)

Utilizamos o método sampleRegions para extrair amostras de pixels da imagem para cada polígono do shapefile.

Aqui é importante que as amostras estejam contidas no mesmo lugar geométrico da imagem.

[ ]:
# amostras_selecionadas = imagem.sampleRegions(
#     collection=amostras_completas, properties=["classe"], scale=escala
# )
[ ]:
# amostras_selecionadas

Classificação supervisionada#

A partir do passo da coleta das amostras vamos prosseguir com algumas opções mais avançadas. Veremos alguns métodos alternativos de seleção de pixels para treinamento e teste.

Amostras com tamanho pré-definido#

Começaremos com a segunda opção de amostragem, pegando um número limitado de pixels por classe.

Para prevenir sobrecarga no GEE, vamos limitar o tamanho das amostras por classe usando a função sample() em cada ee.FeatureCollection do dicionário amostras_dict.

A sample() é uma função que automatiza a amostragem de pixels numa região, iniciando com uma semente aleatória.

[ ]:
def coleta_pixels_amostra(regiao_amostra: ee.Geometry) -> ee.FeatureCollection:
    """Realiza uma amostragem limitada de pixels dentro das regiões de uma
    FeatureCollection."""
    # Gera uma semente aleatória para a amostragem
    semente = ee.Number(random.random()).multiply(10000).toLong()

    # Amostra a imagem dentro da região dada
    sample = imagem.sample(
        region=ee.FeatureCollection(regiao_amostra),
        dropNulls=True,  # Descarta quaisquer amostras com valores nulos
        seed=semente,  # Usa a semente gerada para amostragem aleatória
        scale=escala,  # A escala na qual a amostragem deve ser feita
        numPixels=200,  # O número de pixels para coletar
        geometries=False,  # Falso para economizar tempo de processamento
    )

    # Recupera o número da classe da primeira feição na coleção
    numero_classe = ee.FeatureCollection(regiao_amostra).first().get("classe")

    # Mapeia sobre a amostra, definindo o número da classe para cada feição
    return sample.map(lambda feature: feature.set({"classe": numero_classe}))

Em seguida vamos criar uma lista com as feature collections das regiões amostradas para cada classe.

[ ]:
lista_amostra_classes = ee.List(
    [
        amostras_agua,
        amostras_veget_baixa,
        amostras_veget_alta,
        amostras_construcoes,
        amostras_solo,
    ]
)
lista_amostra_classes

Agora precisamos executar a função para colher amostras nas feature collections de cada classe a partir da lista de amostras. O resultado será uma lista com uma feature collection para cada cada classe com a respectiva amostra.

[ ]:
lista_amostra_pixels = lista_amostra_classes.map(coleta_pixels_amostra)
lista_amostra_pixels

Finalmente, as Feature Collections são combinadas e achatadas usando flatten() para formar uma única Feature Collection com todas as Features.

[ ]:
amostras_pixels = ee.FeatureCollection(lista_amostra_pixels).flatten()
amostras_pixels

Divisão das amostras em subconjuntos#

Vamos dividir as amostras em 3 conjuntos: treinamento, validação e testes.

Uma semente aleatória gera números entre 0 e 1 para cada Feature de pixel em random. As amostras são divididas usando filtros em random, mantendo proporções uniformes. Por exemplo, random < 0.7 aloca 70% para treino, e 15% para teste e validação cada.

[ ]:
semente = ee.Number(random.random()).multiply(10000).toLong()

Adiciona uma propriedade a cada pixel da amostra com um número real entre 0 e 1 aleatório.

[ ]:
amostras_pixels = amostras_pixels.randomColumn("random", semente)
amostras_pixels

Vamos separar 70% dos dados para treinamento, 15% para validação e 15% para teste final.

[ ]:
treinamento = amostras_pixels.filter(ee.Filter.lt("random", 0.7))
validacao = amostras_pixels.filter(ee.Filter.gte("random", 0.7)).filter(
    ee.Filter.lt("random", 0.85)
)
teste = amostras_pixels.filter(ee.Filter.gte("random", 0.85))
[ ]:
treinamento  # para visualizar a amostra de treinamento
[ ]:
validacao  # para visualizar a amostra de validação
[ ]:
teste  # para visualizar a amostra de teste

Treinamento e avaliação dos modelos#

O passo inicial do processo é treinar os modelos desejados e avaliá-los na amostra de validação, para enfim tomar uma decisão sobre a escolha do modelo, ou reparametrização.

Treinamento#

Para treinar o modelo, é necessário criar uma instância do classificador selecionado, configurando-o com os parâmetros apropriados, e depois aplicar o método train().

No nosso caso, usaremos o classificador smileRandomForest(), configurado inicialmente para usar 50 árvores de decisão. Este número de árvores é ajustável e deve ser refinado baseado no desempenho observado no conjunto de validação. Informações adicionais sobre outros parâmetros configuráveis podem ser consultadas na documentação do Google Earth Engine (GEE), e suas aplicações práticas estão detalhadas na literatura especializada.

[ ]:
# Instancia um classificador na memória com os parâmetros dados e treinando no conjunto de treinamento.
classificador_treinado = ee.Classifier.smileRandomForest(50).train(
    features=treinamento,  # amostra a ser usada para treinamento
    classProperty="classe",  # propriedade que contém o número que identifica a classe
    inputProperties=ee.List(["B2", "B3", "B4", "B8", "NDVI"]),  # lista de bandas
)
classificador_treinado

Validação#

O processo de validação tenta compensar os efeitos do conjunto de treinamento sobre a matriz de confusão. Aqui, o classificador treinado é usado para classificar a amostra de validação e uma matriz de erros é gerada.

A tendência é que a acurácia seja menor, caso a acurácia reduza excessivamente, pode ser efeito de um overfitting, onde o modelo tem um desempenho excelente no conjunto de treinamento, mas no de validação tem um desempenho ruim. Nesse caso, ajustes devem ser feitos nos parâmetros do modelo e conjunto de treinamento.

/vamos iniciar aplicando o classificador que foi treinado no conjunto de treinamento sobre o conjunto de validação.

[ ]:
validacao_classificada = validacao.classify(classificador_treinado)
validacao_classificada

Calcula a matriz de erros do classificador aplicado ao conjunto de validação

[ ]:
matriz_validacao = validacao_classificada.errorMatrix("classe", "classification")
matriz_validacao

Calcula a acurácia do classificador aplicado ao conjunto de validação

[ ]:
matriz_validacao.accuracy()

Teste final e classificação da imagem#

Vamos classificar o conjunto de testes usando o modelo validado, aplicando o método classify() do GEE ao objeto da amostra de testes com o classificador treinado.

[ ]:
validacao_classificada = validacao.classify(classificador_treinado)
validacao_classificada

A matriz de erro é então calculada e podemos visualizar a acurácia dos testes.

[ ]:
matriz_validacao = validacao_classificada.errorMatrix("classe", "classification")
matriz_validacao

Aqui é feita a avaliação do modelo escolhido, estimando seu erro de predição em novos dados através da acurácia.

[ ]:
matriz_validacao.accuracy()

O resultado do método classify(), diferentemente dos conjuntos de validação e teste, é um objeto do tipo ee.Image com uma única banda em que os pixels armazenam o valor relativo às classes que foram atribuídas no momento do desenho das regiões das amostras em tela.

Abaixo vamos aplicar o classificador que foi treinado no conjunto de treinamento sobre o conjunto de teste.

[ ]:
teste_classificada = teste.classify(classificador_treinado)
teste_classificada

Calcula a matriz de erros da amostra de testes.

[ ]:
matriz_teste = teste_classificada.errorMatrix("classe", "classification")
matriz_teste

Calcula a acurácia da matriz de erros da amostra de testes.

[ ]:
matriz_teste.accuracy()

Classifica a imagem com o classificador treinado e com os parâmetros definidos.

[ ]:
imagem_classificada = imagem.classify(classificador_treinado)
imagem_classificada

Visualiza o mapa final com as imagens geradas

[ ]:
paleta_cores = [
    "#1f77b4",  # Água - um azul mais suave e claro.
    "#98df8a",  # Vegetação rasteira - um verde claro para diferenciar da vegetação alta.
    "#2ca02c",  # Vegetação alta - um verde mais vibrante e menos saturado.
    "#7f7f7f",  # Construção - um cinza médio que representa áreas construídas.
    "#ff7f0e",  # Solo exposto - um laranja mais vibrante e atraente.
]
[ ]:
my_map = geemap.Map()
my_map.centerObject(roi, 12)
my_map.addLayer(
    imagem_classificada, {"min": 0, "max": 4, "palette": paleta_cores}, "Classificação"
)
my_map