Open in Studio Open in Colab
[ ]:
# Copyright (c) TorchGeo Contributors. All rights reserved.
# Licensed under the MIT License.

Change Detection#

Written by: Harald Kristen

In this tutorial, we demonstrate how to train a change detection model using TorchGeo.

What is Change Detection: Change detection is the process of identifying differences between images of the same location captured at different times. It is a fundamental task in remote sensing with numerous real-world applications:

  • Urban Monitoring: Track city growth, new construction, and infrastructure development

  • Disaster Response: Assess damage from floods, earthquakes, wildfires, and tropical cyclones

  • Deforestation Tracking: Monitor illegal logging and forest loss

  • Agriculture: Detect crop changes, irrigation patterns, and land use shifts

  • Environmental Monitoring: Track glacial retreat, coastal erosion, and wetland changes

In this tutorial, we will train a binary change detection model on the OSCD100 dataset to detect urban changes in bi-temporal Sentinel-2 imagery. Our model will learn to identify where new buildings have been constructed or existing ones removed.

It is recommended to run this notebook on Google Colab if you do not have your own GPU. Click the “Open in Colab” button above to get started.

Setup#

First, we install TorchGeo and TensorBoard.

[ ]:
!uv pip install torchgeo tensorboard

Imports#

Next, we import TorchGeo and any other libraries we need.

[ ]:
%matplotlib inline
%load_ext tensorboard

import os
import tempfile

import lightning as L
import matplotlib.pyplot as plt
import torch
from lightning.pytorch import Trainer
from lightning.pytorch.callbacks import EarlyStopping, ModelCheckpoint
from lightning.pytorch.loggers import TensorBoardLogger

from torchgeo.datamodules import OSCD100DataModule
from torchgeo.datasets import OSCD100
from torchgeo.trainers import ChangeDetectionTask

torch.set_float32_matmul_precision('medium')
L.seed_everything(0, workers=True)

Visualize the Dataset#

Let’s download the dataset and look at some examples from OSCD100.

About OSCD100: This is a smaller, downsampled version of the full OSCD (Onera Satellite Change Detection) dataset. It is designed for tutorials and quick experimentation. It contains 100 image pairs at 256×256 resolution with 60 training, 20 validation, and 20 test samples.

Each sample contains all 13 Sentinel-2 spectral bands, though we only use the RGB bands for simplicity in this notebook. For research and benchmarking, use the full OSCD dataset.

[ ]:
root = os.path.join(tempfile.gettempdir(), 'oscd100')
dataset = OSCD100(root=root, split='train', bands=OSCD100.rgb_bands, download=True)

sample = dataset[2]

fig = dataset.plot(sample, suptitle='OSCD100 Sample')
plt.show()

print(f'Dataset size: {len(dataset)} image pairs in the {dataset.split} split')
print(
    f'Image shape: {sample["image"].shape} (T, C, H, W) where T=2 timesteps, C=3 RGB bands'
)
print(f'Mask shape: {sample["mask"].shape}')

Lightning Modules#

TorchGeo uses PyTorch Lightning to organize the training code and set up the data loader. The OSCD100DataModule:

  1. Downloads the data

  2. Sets up train, validation, and test data loaders

  3. Applies preprocessing and augmentation

The following variables can be modified to control training.

[ ]:
batch_size = 8
num_workers = 4
max_epochs = 50
fast_dev_run = False
[ ]:
root = os.path.join(tempfile.gettempdir(), 'oscd100')
datamodule = OSCD100DataModule(
    root=root,
    bands=OSCD100.rgb_bands,  # RGB: B04 (Red), B03 (Green), B02 (Blue)
    batch_size=batch_size,
    num_workers=num_workers,
    download=True,
)

For training a deep learning change detection model, we use the ChangeDetectionTask class from torchgeo.trainers, which handles:

  • Preprocessing and forwarding the bi-temporal image pair through the model

  • Training with binary cross-entropy loss

  • Computing metrics like accuracy, F1-score, and Jaccard Index

We use BTC (Be The Change), a change-detection-specific architecture that uses a Swin Transformer backbone pretrained on Cityscapes followed by a UPerNet decoder. Unlike general segmentation models that simply concatenate both images into a single tensor, BTC processes them through separate encoder paths and fuses temporal features in a change-aware way.

Key configuration choices:

  • RGB bands (in_channels=3): Red, Green, Blue for a fast tutorial training experiment

  • weights=True: Load Cityscapes-pretrained Swin-Tiny encoder

  • freeze_backbone=True: Freeze the pretrained encoder and only fine-tune the decoder head — faster convergence and less overfitting on this small dataset

  • pos_weight=10.0: Upweights the change class to counter severe class imbalance — most pixels show no change

  • lr=0.0001: Use a small learning rate since we already have a pre-trained model

  • loss='bce': Binary cross-entropy loss

For other supported models and backbones, check the trainers documentation.

[ ]:
task = ChangeDetectionTask(
    model='btc',
    backbone='swin_tiny',
    weights=True,  # Cityscapes-pretrained Swin-Tiny encoder
    freeze_backbone=True,  # only fine-tune the decoder head
    loss='bce',
    pos_weight=torch.tensor([10.0]),  # upweight the rare change class
    in_channels=3,  # RGB bands
    lr=0.0001,
)

Training#

Now we can train the model using Lightning’s Trainer, allowing us to easily incorporate:

  • Model checkpointing, that saves the best model based on a metric we define

  • Early stopping to prevent overfitting

  • TensorBoard logs metrics for visualization

[ ]:
default_root_dir = os.path.join(tempfile.gettempdir(), 'experiments')
checkpoint_callback = ModelCheckpoint(
    monitor='val_AverageF1Score',
    mode='max',
    dirpath=default_root_dir,
    save_top_k=1,
    save_last=True,
)
early_stopping_callback = EarlyStopping(
    monitor='val_AverageF1Score', mode='max', min_delta=0.0, patience=10
)
logger = TensorBoardLogger(save_dir=default_root_dir, name='change_detection_logs')
[ ]:
trainer = Trainer(
    callbacks=[checkpoint_callback, early_stopping_callback],
    fast_dev_run=fast_dev_run,
    log_every_n_steps=1,
    logger=logger,
    min_epochs=1,
    max_epochs=max_epochs,
)

Note: With a frozen backbone, training completes in ~50 seconds using ~2GB VRAM of your GPU.

[ ]:
trainer.fit(model=task, datamodule=datamodule)

Visualize Training with TensorBoard#

Use TensorBoard to visualize metrics across epochs.

In Google Colab, run the cell below to launch TensorBoard inline.

Locally, run from your terminal tensorboard --logdir /tmp/experiments and navigate to http://localhost:6006

[ ]:
%tensorboard --logdir "$default_root_dir"

Evaluation#

Now we evaluate the model on the test set.

Understanding the Metrics#

  • Accuracy: Percentage of correctly classified pixels

  • F1 Score: Harmonic mean of precision and recall

  • Jaccard Index (IoU): Intersection over Union between prediction and ground truth

[ ]:
ckpt_path = None if fast_dev_run else 'best'
trainer.test(model=task, datamodule=datamodule, ckpt_path=ckpt_path)

Visualize Predictions#

Let’s visualize predictions on the test set. We create a fresh dataset (without the random crop augmentation the datamodule applies) so predictions align with the full 256×256 ground truth masks. We add the predicted mask to each sample and pass it to the dataset’s built-in plot() method.

[ ]:
viz_dataset = OSCD100(root=root, split='test', bands=OSCD100.rgb_bands)

task.eval()

for idx in [1, 7, 13]:
    sample = viz_dataset[idx]
    normed = datamodule.aug({'image': sample['image'].float().unsqueeze(0)})['image']
    with torch.no_grad():
        prob = task.predict_step({'image': normed.to(task.device)}, 0).squeeze().cpu()
    sample['prediction'] = (prob > 0.5).float().unsqueeze(0)
    fig = viz_dataset.plot(sample, suptitle=f'Test Sample {idx}')
    plt.show()

Interpreting the Results#

With BTC (swin_tiny) and a frozen backbone you should expect:

  • Accuracy: ~93–95%

  • F1 Score: ~0.55–0.65

  • IoU (Jaccard Index): ~0.40–0.50

Change detection is hard due to class imbalance as most pixels show no change, especially on such a small toy dataset. We set a random seed to improve reproducibility, but results may vary slightly between runs, as Swin Transformer’s attention uses CUDA kernels that have no deterministic implementation.

To Go Further#

  • Train on the full OSCD dataset (24 city pairs) with full resolution

  • Train for 100+ epochs with a learning rate scheduler

  • Try a larger backbone (swin_small, swin_base) for higher capacity

  • Use all 13 Sentinel-2 bands with S2 pretrained weights — requires a swin_v2_b backbone with Swin_V2_B_Weights.SENTINEL2_MI_MS_SATLAS