Convert EuroSAT to an rslearn Dataset

July 28, 2026 ยท View on GitHub

This tutorial converts the EuroSAT dataset into an rslearn dataset and fine-tunes OlmoEarth for land-cover classification. EuroSAT is a Sentinel-2 image dataset with one class label per image.

The tutorial is intended to provide examples of programmatically creating windows, and then writing raster and vector data to each window.

Download EuroSAT

Download and extract the multispectral dataset. The resulting EuroSAT_MS directory contains one subdirectory per category and one GeoTIFF per example.

wget https://zenodo.org/records/7711810/files/EuroSAT_MS.zip
unzip EuroSAT_MS.zip

Configure the Dataset

Create dataset/config.json:

{
  "layers": {
    "label": {
      "type": "vector"
    },
    "sentinel2": {
      "type": "raster",
      "band_sets": [
        {
          "bands": ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B09", "B10", "B11", "B12", "B8A"],
          "dtype": "uint16"
        }
      ]
    }
  }
}

We do not specify a data source for either layer because we will programmatically write both of them.

Convert the Data

The script below creates one window per EuroSAT GeoTIFF. The source raster determines the window projection and bounds. A stable hash assigns each example to the train or validation split, and the source category becomes a vector property.

import hashlib
from datetime import UTC, datetime

import rasterio
import tqdm
from upath import UPath

from rslearn.dataset import Dataset, Window
from rslearn.utils.feature import Feature
from rslearn.utils.raster_array import RasterArray
from rslearn.utils.raster_format import (
  GeotiffRasterFormat,
  get_raster_projection_and_bounds,
)
from rslearn.utils.vector_format import GeojsonVectorFormat

source_path = UPath("./EuroSAT_MS")
dataset = Dataset(UPath("./dataset"))
examples = [
  (tif_path, category_path.name)
  for category_path in source_path.iterdir()
  for tif_path in category_path.iterdir()
  if tif_path.suffix.lower() in {".tif", ".tiff"}
]

sentinel2_bands = [
  "B01", "B02", "B03", "B04", "B05", "B06", "B07",
  "B08", "B09", "B10", "B11", "B12", "B8A",
]
for tif_path, category in tqdm.tqdm(examples):
  with rasterio.open(tif_path) as source:
    projection, bounds = get_raster_projection_and_bounds(source)
    array = source.read()

  window_name = tif_path.stem
  digest = hashlib.sha256(window_name.encode()).hexdigest()
  split = "val" if digest[0] in {"0", "1", "2"} else "train"
  window = Window(
    storage=dataset.storage,
    group="default",
    name=window_name,
    projection=projection,
    bounds=bounds,
    time_range=(
      datetime(2018, 1, 1, tzinfo=UTC),
      datetime(2019, 1, 1, tzinfo=UTC),
    ),
    options={"split": split},
    data_factory=dataset.window_data_storage_factory,
  )
  window.save()

  with window.data.open_layer_writer("sentinel2") as writer:
    writer.write_raster(
      sentinel2_bands,
      GeotiffRasterFormat(),
      projection,
      bounds,
      RasterArray(chw_array=array),
    )
  window.mark_layer_completed("sentinel2")

  feature = Feature(window.get_geometry(), {"category": category})
  with window.data.open_layer_writer("label") as writer:
    writer.write_vector(GeojsonVectorFormat(), [feature])
  window.mark_layer_completed("label")

Fine-tune OlmoEarth

Save the model configuration below as model.yaml. It adds a decoder after the OlmoEarth-v1.2-Base model, and configures it to input the Sentinel-2 image and train against the classification labels.

model:
  class_path: rslearn.train.lightning_module.RslearnLightningModule
  init_args:
    model:
      class_path: rslearn.models.singletask.SingleTaskModel
      init_args:
        # This section specifies the model architecture. We pair the
        # OlmoEarth-v1.2-Base encoder with a decoder that applies max pooling
        # to get down to a feature vector, and then applies two fully connected
        # layers to obtain classification logits.
        encoder:
          - class_path: rslearn.models.olmoearth_pretrain.model.OlmoEarth
            init_args:
              model_id: OLMOEARTH_V1_2_BASE
              patch_size: 8
        decoder:
          - class_path: rslearn.models.pooling_decoder.PoolingDecoder
            init_args:
              in_channels: 768
              num_fc_layers: 1
              fc_channels: 128
              out_channels: 10
          # The ClassificationHead will apply a softmax and compute cross
          # entropy loss.
          - class_path: rslearn.train.tasks.classification.ClassificationHead
    optimizer:
      class_path: rslearn.models.olmoearth_pretrain.optimizer.LayerDecayAdamW
      init_args:
        lr: 0.0001
data:
  class_path: rslearn.train.data_module.RslearnDataModule
  init_args:
    path: ./dataset
    inputs:
      # We input both the Sentinel-2 images and the vector class IDs from the
      # dataset. The "sentinel2_l2a" name and band order matches what is
      # expected by the OlmoEarthNormalize transform and OlmoEarth model.
      sentinel2_l2a:
        data_type: raster
        layers: [sentinel2]
        bands: [B02, B03, B04, B08, B05, B06, B07, B8A, B11, B12, B01, B09]
        passthrough: true
      targets:
        data_type: vector
        layers: [label]
        is_target: true
    task:
      # We train with ClassificationTask since it is window level
      # classification.
      class_path: rslearn.train.tasks.classification.ClassificationTask
      init_args:
        property_name: category
        classes: [AnnualCrop, Forest, HerbaceousVegetation, Highway, Industrial, Pasture, PermanentCrop, Residential, River, SeaLake]
        metric_kwargs:
          # Use micro accuracy so that the computed accuracy is just the
          # fraction of correctly classified windows.
          average: micro
    batch_size: 16
    num_workers: 32
    default_config:
      transforms:
        - class_path: rslearn.models.olmoearth_pretrain.norm.OlmoEarthNormalize
          init_args:
            band_names:
              sentinel2_l2a: [B02, B03, B04, B08, B05, B06, B07, B8A, B11, B12, B01, B09]
    train_config:
      tags:
        split: train
    val_config:
      tags:
        split: val
trainer:
  max_epochs: 100
  callbacks:
    - class_path: rslearn.train.callbacks.checkpointing.ManagedBestLastCheckpoint
      init_args:
        monitor: val_accuracy
        mode: max
project_name: ${PROJECT_NAME}
run_name: ${RUN_NAME}
management_dir: ${MANAGEMENT_DIR}

Run training:

export PROJECT_NAME=eurosat
export RUN_NAME=eurosat_00
export MANAGEMENT_DIR=./project_data
rslearn model fit --config model.yaml

Add a Sentinel-2 Time Series

EuroSAT provides one image per example. To compare it with a multi-image input, add a retrieved layer to dataset/config.json:

{
  "layers": {
    "sentinel2_ts": {
      "type": "raster",
      "band_sets": [
        {
          "bands": ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B09", "B11", "B12", "B8A"],
          "dtype": "uint16"
        }
      ],
      "data_source": {
        "class_path": "rslearn.data_sources.planetary_computer.Sentinel2",
        "init_args": {
          "harmonize": true,
          "sort_by": "eo:cloud_cover"
        },
        "ingest": false,
        "query_config": {
          "max_matches": 4,
          "space_mode": "MOSAIC"
        }
      }
    }
  }
}

Run prepare and materialize for the new layer:

rslearn dataset prepare --root ./dataset --enabled-layers sentinel2_ts
rslearn dataset materialize --root ./dataset --enabled-layers sentinel2_ts

Then replace the sentinel2_l2a input in model.yaml with:

sentinel2_l2a:
  data_type: raster
  layers: [sentinel2_ts, sentinel2_ts.1, sentinel2_ts.2, sentinel2_ts.3]
  bands: [B02, B03, B04, B08, B05, B06, B07, B8A, B11, B12, B01, B09]
  passthrough: true
  load_all_layers: true

Train the time-series variant under a new run name:

export RUN_NAME=eurosat_ts_00
rslearn model fit --config model.yaml