EarthShift is an open benchmarking testbed for evaluating geospatial foundation models under distribution shift. Contributions that add new models or new datasets are especially welcome.
Models live in models/ as task-specific wrapper classes around a pretrained backbone. Each model file defines at minimum a *Classifier class, and optionally a *Segmentation class.
Create models/mymodel.py. The wrapper must subclass torch.nn.Module and follow this pattern:
import torch
import torch.nn as nn
from utils import channel_adapter, SimpleDecoder # re-use existing utilities
class MyModelClassifier(nn.Module):
def __init__(self, backbone, img_channels, num_classes, **kwargs):
super().__init__()
self.backbone = backbone
self.channel_adapt = channel_adapter(img_channels, BACKBONE_IN_CHANNELS)
embed_dim = 768 # set to your backbone's output dimension
self.classifier = nn.Linear(embed_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.channel_adapt(x)
feats = self.backbone(x) # shape: [B, embed_dim]
return self.classifier(feats)
class MyModelSegmentation(nn.Module):
def __init__(self, backbone, img_channels, num_classes, **kwargs):
super().__init__()
self.backbone = backbone
self.channel_adapt = channel_adapter(img_channels, BACKBONE_IN_CHANNELS)
embed_dim = 768
self.decoder = SimpleDecoder(embed_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.channel_adapt(x)
feats = self.backbone.forward_features(x) # shape: [B, embed_dim, H', W']
return self.decoder(feats)Backbone weights should be loaded from a public source (HuggingFace Hub, timm, or torchgeo) so that anyone can reproduce results without private data. Document the source in a comment at the top of the file.
ModelManager.get_model() uses a match/case block to instantiate backbones. Add a new case:
# model_manager.py – inside ModelManager.get_model()
case "mymodel":
from models.mymodel import MyModelClassifier, MyModelSegmentation
backbone = ... # load pretrained backbone here
return backboneThen in ModelManager.modify_model(), add the wrapper assignment:
case "mymodel":
if self.task == "class":
return MyModelClassifier(model, self.img_channels, self.num_classes)
elif self.task == "semseg":
return MyModelSegmentation(model, self.img_channels, self.num_classes)If your model requires wavelength/band information, handle it in ModelManager.get_waves() following the pattern used by DOFA, Clay, or CROMA.
DataManager.generate_transform() applies model-specific resizing and normalization. Add a case for your model, specifying its expected input resolution and normalization statistics:
# data_manager.py – inside generate_transform()
case "mymodel":
resize = (224, 224) # adjust to your backbone's expected size
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]Use ImageNet statistics for RGB-pretrained models, or compute and provide dataset-specific statistics for multispectral models.
Add "mymodel" to the "models" list for each relevant experiment config (see Updating the Config).
Datasets are accessed through DataManager.get_dataset(). For datasets available in torchgeo, no new dataset class is needed — torchgeo handles downloading and loading.
Check whether the dataset exists in torchgeo:
from torchgeo.datasets import MyNewDataset # confirm this import worksIf it does not exist in torchgeo, you will need to write a custom torch.utils.data.Dataset subclass in data_manager.py, following the pattern of GeospatialDataset (line 24) or MEuroSAT (line 446).
get_dataset() uses a match/case on self.dataset_pair. Add a case that returns the torchgeo dataset instance for each split:
# data_manager.py – inside DataManager.get_dataset()
case "SourceDataset-TargetDataset":
from torchgeo.datasets import SourceDataset, TargetDataset
if split == "train" or split == "val":
return SourceDataset(root=self.root_dir, split=split, download=False)
else:
return TargetDataset(root=self.root_dir, split=split, download=False)Torchgeo datasets accept a transforms argument; pass self.transform so model-specific preprocessing is applied automatically.
Pick a descriptive hyphenated name that encodes the shift being tested, e.g. "EuroSAT-PatternNet" for a data-domain shift or "BigEarthNet-S2-S1" for a sensor shift. This name is used as the key in the config and as the --dataset_pair CLI argument.
If your dataset uses a sensor or spectral configuration not already covered, add mean/std constants near the top of data_manager.py and reference them in generate_transform().
Add the dataset pair to the appropriate config file (see below).
Config files in configs/ define which dataset pairs and models are active for each shift experiment. Each file corresponds to one shift type:
| File | Shift type |
|---|---|
configs/data-shift-exp.json |
Dataset / domain shift |
configs/sensor-shift-exp.json |
Sensor modality shift |
configs/temporal-shift-exp.json |
Temporal / location shift |
configs/scale-shift-exp.json |
Spatial scale shift |
configs/geo-shift-exp.json |
Geographic shift |
{
"task": {
"class": {
"data-pairs": {
"SourceDataset-TargetDataset": {
"finetune": "SourceDataset",
"finetune_classes": ["class_a", "class_b"],
"test": "TargetDataset",
"test_classes": ["class_a", "class_b"],
"img_channels": 3,
"finetune_wavelength": "rgb",
"test_wavelength": "rgb",
"filter": true
}
},
"models": ["resnet18", "DOFA", "CROMA", "mymodel"]
}
}
}Field reference:
| Field | Description |
|---|---|
finetune |
Dataset name used for fine-tuning (must match a case in get_dataset()) |
finetune_classes |
Class labels available in the training split |
test |
Dataset name used for out-of-distribution evaluation |
test_classes |
Class labels in the test split (may differ from finetune_classes) |
img_channels |
Number of input channels (e.g. 3 for RGB, 12 for Sentinel-2 all-bands) |
finetune_wavelength |
Modality key for training data; passed to get_waves() (e.g. "rgb", "s2", "s1") |
test_wavelength |
Modality key for test data; set differently from finetune_wavelength for sensor-shift experiments |
filter |
Whether to filter samples to only the specified classes |
Before opening a PR, confirm the following:
For new models:
- Model file added to
models/with*Classifier(and*Segmentationif applicable) - Backbone source documented (URL / HuggingFace repo ID) in the model file
- Case added to
ModelManager.get_model()andModelManager.modify_model()inmodel_manager.py - Transform case added to
DataManager.generate_transform()indata_manager.py - Model name added to
"models"list in at least one config file - Smoke-tested locally:
python run_pipeline.py --model mymodel --task class --shift data --dataset_pair <pair> --finetune_type head ...
For new datasets:
- Dataset pair case added to
DataManager.get_dataset()indata_manager.py - Dataset pair name and metadata added to the appropriate
configs/*.jsonfile - Normalization statistics added or referenced in
generate_transform() - Data source and download instructions documented here or in
README.md - Smoke-tested locally with at least one existing model
General:
- No hard-coded absolute paths (use
root_dir/save_dirarguments) - Reproducibility: any new random operations use the seeded generator from
set_seed() - PR description includes: what shift scenario is addressed, where weights/data can be obtained, and any known limitations