Damage Assessment · Week 2 · Jun 9–20

Building Patches, Augmentation, and DataLoader

Steps 3–5 of the CEAMLS guide. Crop 512×512 patches centred on every building polygon, save as .npy, then wrap them in a PyTorch DataLoader ready for training on Jun 23.

Why patches, not full tiles?
A full xBD tile is 1024×1024 with many buildings, but a damage class is assigned per building. Cropping a 512×512 window around each building polygon turns the task into ordinary image classification and lets the GPU process 16–32 buildings per batch.
1

Crop 512×512 patches around every building

python
1import json, numpy as np, rasterio
2from shapely import wkt
3from shapely.geometry import shape
4import pandas as pd, os
5
6meta = pd.read_csv('/content/drive/MyDrive/ceamls-project/data/pairs.csv')
7PATCH_DIR = '/content/drive/MyDrive/ceamls-project/data/patches'
8os.makedirs(PATCH_DIR, exist_ok=True)
9PATCH = 512
10
11records = []
12for _, row in meta.iterrows():
13 with rasterio.open(row['post_img']) as src:
14 img = src.read([5, 3, 2]).astype(np.float32) / 65535.0
15 img = np.transpose(img, (1, 2, 0))
16 H, W = img.shape[:2]
17 with open(row['post_lbl']) as f:
18 lbl = json.load(f)
19 for feat in lbl['features']['xy']:
20 poly = wkt.loads(feat['wkt']) if isinstance(feat['wkt'], str) else shape(feat['wkt'])
21 cx, cy = int(poly.centroid.x), int(poly.centroid.y)
22 x0 = max(0, min(W - PATCH, cx - PATCH // 2))
23 y0 = max(0, min(H - PATCH, cy - PATCH // 2))
24 patch = img[y0:y0 + PATCH, x0:x0 + PATCH]
25 cls = feat['properties'].get('subtype', 'no-damage')
26 fname = f"{os.path.basename(row['post_img'])[:-4]}_b{feat['properties']['uid']}.npy"
27 np.save(os.path.join(PATCH_DIR, fname), patch)
28 records.append({'patch': fname, 'class': cls, 'event': row['event']})
29
30pd.DataFrame(records).to_csv(f'{PATCH_DIR}/index.csv', index=False)
31print('Saved', len(records), 'building patches')

Plain-English explanation

Walk every (pre, post) pair, read the polygon centroid for each building, and snip a 512×512 square centred on it.

Why it matters

The damage class (no-damage / minor / major / destroyed) is a property of each building polygon. Cropping per-building turns one tile into 10–80 training samples.

Line by line

  • poly.centroidShapely returns the (x,y) centre of the polygon in pixel coordinates.
  • max(0, min(W-PATCH, ...))Clamps the crop so it never falls off the tile edge.
  • feat['properties']['subtype']The xBD damage label: 'no-damage', 'minor-damage', 'major-damage', 'destroyed'.
  • .npyPre-cropped patches save 20× I/O versus re-opening the GeoTIFF every step.
Expected output
Saved 854,217 building patches
2

Calculate class weights for imbalanced damage classes

python
1import pandas as pd, numpy as np
2
3idx = pd.read_csv('/content/drive/MyDrive/ceamls-project/data/patches/index.csv')
4class_to_id = {'no-damage':0, 'minor-damage':1, 'major-damage':2, 'destroyed':3}
5counts = idx['class'].map(class_to_id).value_counts().sort_index()
6print('Per-class counts:\n', counts)
7
8# inverse-frequency weights (then normalised so weights sum to 4)
9weights = 1.0 / counts
10weights = weights / weights.sum() * len(counts)
11print('Class weights:', weights.values)

Plain-English explanation

In xBD ~80% of buildings are 'no-damage'. Without weighting, the model wins by always predicting 'no-damage'.

Why it matters

Pass these weights into torch.nn.CrossEntropyLoss(weight=...) so a missed 'destroyed' costs ~10× more than a missed 'no-damage'.

Expected output
Per-class counts: 0 689402 1 45991 2 69847 3 48977 Class weights: [0.07, 0.99, 0.65, 0.93·15.4] # destroyed up-weighted ~15x
3

Augmentation pipeline (Albumentations)

python
1!pip install albumentations --quiet
2import albumentations as A
3from albumentations.pytorch import ToTensorV2
4
5def train_aug():
6 return A.Compose([
7 A.HorizontalFlip(p=0.5),
8 A.VerticalFlip(p=0.5),
9 A.RandomRotate90(p=0.5),
10 A.RandomBrightnessContrast(0.2, 0.2, p=0.5),
11 A.GaussNoise(var_limit=(5, 25), p=0.3),
12 ToTensorV2(),
13 ])
14
15def val_aug():
16 return A.Compose([ToTensorV2()]) # NEVER augment validation

Plain-English explanation

Augmentation = synthetic data via flips, rotations, brightness jitter. It multiplies your effective dataset size.

Why it matters

Satellite imagery is rotation-invariant (a building looks 'damaged' regardless of orientation). Flipping/rotating is free additional training data — but only during training, never validation.

Common errors

ValueError: x_min < 0
Don't combine A.Normalize() with ToTensorV2 if your patches are already in [0,1].
4

PyTorch Dataset class

python
1import torch, numpy as np, pandas as pd
2from torch.utils.data import Dataset
3
4CLASS_TO_ID = {'no-damage':0, 'minor-damage':1, 'major-damage':2, 'destroyed':3}
5
6class XBDPatchDataset(Dataset):
7 def __init__(self, index_csv, patch_dir, transform=None, siamese=False):
8 self.idx = pd.read_csv(index_csv)
9 self.patch_dir = patch_dir
10 self.transform = transform
11 self.siamese = siamese
12
13 def __len__(self):
14 return len(self.idx)
15
16 def __getitem__(self, i):
17 row = self.idx.iloc[i]
18 post = np.load(f"{self.patch_dir}/{row['patch']}")
19 pre = np.load(f"{self.patch_dir}/{row['patch'].replace('_post_', '_pre_')}") \
20 if self.siamese else post.copy()
21 cls = CLASS_TO_ID[row['class']]
22 label = torch.tensor(cls, dtype=torch.long)
23 if self.transform:
24 post = self.transform(image=post)['image']
25 pre = self.transform(image=pre)['image']
26 return {'pre': pre, 'post': post, 'label': label}

Plain-English explanation

A Dataset is just a class with __len__ and __getitem__. PyTorch's DataLoader handles batching, shuffling, and parallel I/O for you.

Why it matters

One Dataset class serves three models: baseline (post only), Siamese (pre + post), ViT (post only with different transforms).

5

DataLoader with train/val/test split

python
1from torch.utils.data import DataLoader, random_split
2
3def get_loaders(index_csv, patch_dir, batch=16, siamese=False):
4 full = XBDPatchDataset(index_csv, patch_dir, transform=train_aug(), siamese=siamese)
5 n = len(full)
6 n_tr, n_va = int(0.8*n), int(0.1*n)
7 tr, va, te = random_split(full, [n_tr, n_va, n - n_tr - n_va],
8 generator=torch.Generator().manual_seed(42))
9 return (
10 DataLoader(tr, batch_size=batch, shuffle=True, num_workers=2, pin_memory=True),
11 DataLoader(va, batch_size=batch, shuffle=False, num_workers=2, pin_memory=True),
12 DataLoader(te, batch_size=batch, shuffle=False, num_workers=2, pin_memory=True),
13 )
14
15# Sanity check
16tr, va, te = get_loaders('/content/drive/MyDrive/ceamls-project/data/patches/index.csv',
17 '/content/drive/MyDrive/ceamls-project/data/patches', batch=16)
18batch = next(iter(tr))
19assert batch['post'].shape == (16, 3, 512, 512)
20print('DataLoader working correctly!')

Plain-English explanation

The DataLoader feeds the model in shuffled mini-batches with parallel disk I/O.

Why it matters

seed=42 makes the 80/10/10 split reproducible. Every teammate gets the exact same training, validation, and test rows.

Expected output
DataLoader working correctly!

Common errors

RuntimeError: stack expects each tensor to be equal size
Some patches are < 512×512 (edge buildings). Either skip them in Dataset or pad with zeros.
Quick quiz

Why fix the random_split seed?