{ "cells": [ { "cell_type": "code", "execution_count": null, "id": "0", "metadata": {}, "outputs": [], "source": [ "# Copyright (c) TorchGeo Contributors. All rights reserved.\n", "# Licensed under the MIT License." ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": "# Change Detection\n_Written by: Harald Kristen_\n\nIn this tutorial, we demonstrate how to train a change detection model using TorchGeo.\n\n**What is Change Detection**:\nChange 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:\n\n- **Urban Monitoring**: Track city growth, new construction, and infrastructure development\n- **Disaster Response**: Assess damage from floods, earthquakes, wildfires, and tropical cyclones\n- **Deforestation Tracking**: Monitor illegal logging and forest loss\n- **Agriculture**: Detect crop changes, irrigation patterns, and land use shifts\n- **Environmental Monitoring**: Track glacial retreat, coastal erosion, and wetland changes\n\nIn this tutorial, we will train a binary change detection model on the [OSCD100](https://torchgeo.readthedocs.io/en/stable/api/datasets/oscd.html#torchgeo.datasets.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.\n\nIt 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." }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "## Setup\n", "\n", "First, we install TorchGeo and TensorBoard." ] }, { "cell_type": "code", "execution_count": null, "id": "3", "metadata": {}, "outputs": [], "source": "!uv pip install torchgeo tensorboard" }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## Imports\n", "\n", "Next, we import TorchGeo and any other libraries we need." ] }, { "cell_type": "code", "execution_count": null, "id": "5", "metadata": {}, "outputs": [], "source": "%matplotlib inline\n%load_ext tensorboard\n\nimport os\nimport tempfile\n\nimport lightning as L\nimport matplotlib.pyplot as plt\nimport torch\nfrom lightning.pytorch import Trainer\nfrom lightning.pytorch.callbacks import EarlyStopping, ModelCheckpoint\nfrom lightning.pytorch.loggers import TensorBoardLogger\n\nfrom torchgeo.datamodules import OSCD100DataModule\nfrom torchgeo.datasets import OSCD100\nfrom torchgeo.trainers import ChangeDetectionTask\n\ntorch.set_float32_matmul_precision('medium')\nL.seed_everything(0, workers=True)" }, { "cell_type": "markdown", "id": "6", "metadata": {}, "source": [ "## Visualize the Dataset\n", "\n", "Let's download the dataset and look at some examples from OSCD100.\n", "\n", "**About OSCD100**: This is a smaller, downsampled version of the full [OSCD](https://torchgeo.readthedocs.io/en/stable/api/datasets/oscd.html#torchgeo.datasets.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.\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "7", "metadata": {}, "outputs": [], "source": [ "root = os.path.join(tempfile.gettempdir(), 'oscd100')\n", "dataset = OSCD100(root=root, split='train', bands=OSCD100.rgb_bands, download=True)\n", "\n", "sample = dataset[2]\n", "\n", "fig = dataset.plot(sample, suptitle='OSCD100 Sample')\n", "plt.show()\n", "\n", "print(f'Dataset size: {len(dataset)} image pairs in the {dataset.split} split')\n", "print(\n", " f'Image shape: {sample[\"image\"].shape} (T, C, H, W) where T=2 timesteps, C=3 RGB bands'\n", ")\n", "print(f'Mask shape: {sample[\"mask\"].shape}')" ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": "## Lightning Modules\n\nTorchGeo uses [PyTorch Lightning](https://lightning.ai/docs/pytorch/stable/) to organize the training code and set up the data loader. The `OSCD100DataModule`:\n1. Downloads the data\n2. Sets up train, validation, and test data loaders\n3. Applies preprocessing and augmentation" }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "The following variables can be modified to control training." ] }, { "cell_type": "code", "execution_count": null, "id": "10", "metadata": { "id": "8e100f8b", "nbmake": { "mock": { "batch_size": 2, "fast_dev_run": true, "max_epochs": 1, "num_workers": 0 } } }, "outputs": [], "source": [ "batch_size = 8\n", "num_workers = 4\n", "max_epochs = 50\n", "fast_dev_run = False" ] }, { "cell_type": "code", "execution_count": null, "id": "12", "metadata": {}, "outputs": [], "source": [ "root = os.path.join(tempfile.gettempdir(), 'oscd100')\n", "datamodule = OSCD100DataModule(\n", " root=root,\n", " bands=OSCD100.rgb_bands, # RGB: B04 (Red), B03 (Green), B02 (Blue)\n", " batch_size=batch_size,\n", " num_workers=num_workers,\n", " download=True,\n", ")" ] }, { "cell_type": "markdown", "id": "13", "metadata": {}, "source": [ "For training a *deep learning change detection model*, we use the `ChangeDetectionTask` class from `torchgeo.trainers`, which handles:\n", "- Preprocessing and forwarding the bi-temporal image pair through the model\n", "- Training with binary cross-entropy loss\n", "- Computing metrics like accuracy, F1-score, and Jaccard Index\n" ] }, { "cell_type": "markdown", "id": "14", "metadata": {}, "source": "We use [**BTC** (Be The Change)](https://torchgeo.readthedocs.io/en/stable/api/models/btc.html), a change-detection-specific architecture that uses a [Swin Transformer](https://arxiv.org/abs/2103.14030) backbone pretrained on [Cityscapes](https://www.cityscapes-dataset.com/) followed by a [UPerNet](https://arxiv.org/abs/1807.10221) 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.\n\nKey configuration choices:\n- RGB bands (`in_channels=3`): Red, Green, Blue for a fast tutorial training experiment\n- `weights=True`: Load Cityscapes-pretrained Swin-Tiny encoder\n- `freeze_backbone=True`: Freeze the pretrained encoder and only fine-tune the decoder head — faster convergence and less overfitting on this small dataset\n- `pos_weight=10.0`: Upweights the change class to counter severe class imbalance — most pixels show no change\n- `lr=0.0001`: Use a small learning rate since we already have a pre-trained model\n- `loss='bce'`: Binary cross-entropy loss\n\nFor other supported models and backbones, check the [trainers documentation](https://torchgeo.readthedocs.io/en/stable/api/trainers.html#torchgeo.trainers.ChangeDetectionTask)." }, { "cell_type": "code", "execution_count": null, "id": "15", "metadata": {}, "outputs": [], "source": "task = ChangeDetectionTask(\n model='btc',\n backbone='swin_tiny',\n weights=True, # Cityscapes-pretrained Swin-Tiny encoder\n freeze_backbone=True, # only fine-tune the decoder head\n loss='bce',\n pos_weight=torch.tensor([10.0]), # upweight the rare change class\n in_channels=3, # RGB bands\n lr=0.0001,\n)" }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "## Training\n", "\n", "Now we can train the model using Lightning's [Trainer](https://lightning.ai/docs/pytorch/stable/common/trainer.html), allowing us to easily incorporate:\n", "\n", "- Model checkpointing, that saves the best model based on a metric we define\n", "- Early stopping to prevent overfitting\n", "- TensorBoard logs metrics for visualization" ] }, { "cell_type": "code", "execution_count": null, "id": "17", "metadata": {}, "outputs": [], "source": [ "default_root_dir = os.path.join(tempfile.gettempdir(), 'experiments')\n", "checkpoint_callback = ModelCheckpoint(\n", " monitor='val_AverageF1Score',\n", " mode='max',\n", " dirpath=default_root_dir,\n", " save_top_k=1,\n", " save_last=True,\n", ")\n", "early_stopping_callback = EarlyStopping(\n", " monitor='val_AverageF1Score', mode='max', min_delta=0.0, patience=10\n", ")\n", "logger = TensorBoardLogger(save_dir=default_root_dir, name='change_detection_logs')" ] }, { "cell_type": "code", "execution_count": null, "id": "18", "metadata": {}, "outputs": [], "source": [ "trainer = Trainer(\n", " callbacks=[checkpoint_callback, early_stopping_callback],\n", " fast_dev_run=fast_dev_run,\n", " log_every_n_steps=1,\n", " logger=logger,\n", " min_epochs=1,\n", " max_epochs=max_epochs,\n", ")" ] }, { "cell_type": "markdown", "id": "19", "metadata": {}, "source": "**Note**: With a frozen backbone, training completes in ~50 seconds using ~2GB VRAM of your GPU." }, { "cell_type": "code", "execution_count": null, "id": "20", "metadata": {}, "outputs": [], "source": [ "trainer.fit(model=task, datamodule=datamodule)" ] }, { "cell_type": "markdown", "id": "21", "metadata": {}, "source": [ "### Visualize Training with TensorBoard\n", "\n", "Use TensorBoard to visualize metrics across epochs.\n", "\n", "**In Google Colab**, run the cell below to launch TensorBoard inline.\n", "\n", "**Locally**, run from your terminal `tensorboard --logdir /tmp/experiments` and navigate to http://localhost:6006" ] }, { "cell_type": "code", "execution_count": null, "id": "22", "metadata": {}, "outputs": [], "source": [ "%tensorboard --logdir \"$default_root_dir\"" ] }, { "cell_type": "markdown", "id": "23", "metadata": {}, "source": [ "## Evaluation\n", "\n", "Now we evaluate the model on the test set.\n", "\n", "### Understanding the Metrics\n", "\n", "- **Accuracy**: Percentage of correctly classified pixels\n", "- **F1 Score**: Harmonic mean of precision and recall\n", "- **Jaccard Index (IoU)**: Intersection over Union between prediction and ground truth" ] }, { "cell_type": "code", "execution_count": null, "id": "24", "metadata": {}, "outputs": [], "source": [ "ckpt_path = None if fast_dev_run else 'best'\n", "trainer.test(model=task, datamodule=datamodule, ckpt_path=ckpt_path)" ] }, { "cell_type": "markdown", "id": "25", "metadata": {}, "source": "## Visualize Predictions\n\nLet'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." }, { "cell_type": "code", "execution_count": null, "id": "26", "metadata": {}, "outputs": [], "source": "viz_dataset = OSCD100(root=root, split='test', bands=OSCD100.rgb_bands)\n\ntask.eval()\n\nfor idx in [1, 7, 13]:\n sample = viz_dataset[idx]\n normed = datamodule.aug({'image': sample['image'].float().unsqueeze(0)})['image']\n with torch.no_grad():\n prob = task.predict_step({'image': normed.to(task.device)}, 0).squeeze().cpu()\n sample['prediction'] = (prob > 0.5).float().unsqueeze(0)\n fig = viz_dataset.plot(sample, suptitle=f'Test Sample {idx}')\n plt.show()" }, { "cell_type": "markdown", "id": "27", "metadata": {}, "source": "## Interpreting the Results\n\nWith BTC (swin_tiny) and a frozen backbone you should expect:\n\n- **Accuracy**: ~93–95%\n- **F1 Score**: ~0.55–0.65\n- **IoU (Jaccard Index)**: ~0.40–0.50\n\nChange 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.\n\n## To Go Further\n\n- Train on the full [OSCD](https://torchgeo.readthedocs.io/en/stable/api/datasets.html#oscd) dataset (24 city pairs) with full resolution\n- Train for 100+ epochs with a learning rate scheduler\n- Try a larger backbone (`swin_small`, `swin_base`) for higher capacity\n- 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`" } ], "metadata": { "kernelspec": { "display_name": "torchgeo-dev-py313", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.11" } }, "nbformat": 4, "nbformat_minor": 5 }