[ ]:
# 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:
Downloads the data
Sets up train, validation, and test data loaders
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 experimentweights=True: Load Cityscapes-pretrained Swin-Tiny encoderfreeze_backbone=True: Freeze the pretrained encoder and only fine-tune the decoder head — faster convergence and less overfitting on this small datasetpos_weight=10.0: Upweights the change class to counter severe class imbalance — most pixels show no changelr=0.0001: Use a small learning rate since we already have a pre-trained modelloss='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 capacityUse all 13 Sentinel-2 bands with S2 pretrained weights — requires a
swin_v2_bbackbone withSwin_V2_B_Weights.SENTINEL2_MI_MS_SATLAS