Patch classification datasets
This tutorial builds a scene classification dataset in the style of EuroSAT and BigEarthNet: Esri World Imagery near Amerongen in the Netherlands at about 1.5 m per pixel, cut into 128 × 128 patches (about 190 m wide), each labeled with the land use that covers most of it: built-up, farmland, forest or water. mapcv decides the label from the share of every patch that each OpenStreetMap polygon class covers, and writes the labels as a CSV and a JSON file next to the images.
Time: about 10 minutes. You need: pip install mapcv; torch + torchvision for the training step.
-
Get the labels
Section titled “Get the labels”The labels are the 643 land-use, forest and water polygons of the Sentinel-2 example, grouped into a
classproperty with four values:Terminal window mkdir landuse-scenes && cd landuse-scenescurl -LO https://raw.githubusercontent.com/tahamukhtar20/mapcv/main/examples/sentinel2-landcover/landuse.geojsonOSM data is © OpenStreetMap contributors under the ODbL, which asks you to credit it when you share the result.
-
Write the config
Section titled “Write the config”It is a segmentation config plus
task: classificationand an optionalclassificationblock.mapcv init --template classificationwrites a commented starting point.mapcv.yaml task: classificationregion:west: 5.400south: 51.975east: 5.500north: 52.035imagery:type: xyzzoom: 16 # ≈ 1.5 m/px at this latitudesource: esri_satellitemax_connections: 4labels:path: landuse.geojsonlabel_field: class # built_up, farmland, forest, waterclassification:mode: single # one label per patchmin_fraction: 0.5 # the class must cover at least half of the patchempty: skip # drop patches no class covers that muchsampler:patch_size: 128 # ≈ 190 m per patchedge_strategy: dropwriter:staging_dir: ./datasetimage_format: pngsplit:strategy: spatial # whole blocks per split: no leakage between splitstest_ratio: 0.20val_ratio: 0.10seed: 42How a patch gets its label:
- Coverage. mapcv burns the labels into the same class mask that
task: segmentationwould write, with the same rasterizer and pixel rule, and counts the pixels of each class. A class’s coverage is its pixels divided by the patch’s valid pixels: pixels that have imagery (not NoData, not a failed tile, not padding) and are notlabels.ignore_index. Where polygons overlap, the later one in the file wins, as in a segmentation mask. singlegives the patch the class with the largest coverage (a tie goes to the lowest class ID), as long as that class reachesmin_fraction.multigives it every class that reachesmin_fraction.min_fractionis inclusive, and0means “any labeled pixel”: a class qualifies when its coverage is at least this and above zero.emptysays what happens to a patch no class qualifies for:skipdrops it,backgroundkeeps it with the labelbackground.
All options: Configuration → classification.
- Coverage. mapcv burns the labels into the same class mask that
-
Terminal window $ mapcv plan mapcv.yaml╭─ Plan for mapcv.yaml ────────────────────────────────────────────────────────╮│ Task classification · single-label · min_fraction 0.5 · empty: skip ││ Region 5.4, 51.975 → 5.5, 52.035 (≈ 6.9 × 6.6 km) ││ Imagery esri_satellite · zoom 16 (≈ 1.47 m/px) ││ Raster 4,864 × 4,608 px · 342 tiles (≈ 8.6 MB to download) ││ Labels landuse.geojson · 643 polygons · classes: built_up → 1, farmland → ││ 2, forest → 3, water → 4 ││ Patches ≈ 1,368 × 128 px (grid, stride 128) ││ Output dataset · png (≈ 37.3 MB) ││ Split spatial · test 0.2 · val 0.1 ││ Memory ≈ 44.8 MB per chunk │╰──────────────────────────────────────────────────────────────────────────────╯The plan counts every grid position. Patches that no class covers by at least half are dropped when the dataset is built, so it will hold fewer.
-
Generate
Section titled “Generate”Terminal window $ mapcv generate mapcv.yaml╭─ Dataset ready ──────────────────────────────────────────────────────────────╮│ Patches 984 ││ Source xyz · esri_satellite ││ Shape 128×128×3 uint8 ││ Tiles 342 fetched · 0 failed ││ Splits train 700 (71%) · val 81 (8%) · test 203 (21%) ││ Time 51s ││ Files dataset/ (images/, manifest.json, splits/, patches.geojson, ││ labels.csv, labels_train.csv, labels_val.csv, labels_test.csv, ││ labels.json, classes.txt) │╰──────────────────────────────────────────────────────────────────────────────╯label id patches sharebuilt_up 1 106 10.8%farmland 2 228 23.2%forest 3 590 60.0%water 4 60 6.1%984 of the 1,368 grid positions had a class over half of them; the rest are mixed or unlabeled and were skipped.
mapcv info datasetshows the same table later.Directorydataset/
Directoryimages/ 984 × patch_0000000.png …
- …
- labels.csv image,labels,split for every patch
- labels_train.csv image,labels of the 700 train patches
- labels_val.csv 81 val patches
- labels_test.csv 203 test patches
- classes.txt built_up, farmland, forest, water: one per line
- labels.json the classes and every image’s labels
- manifest.json
Directorysplits/ the split as patch names, as for every task
- …
- patches.geojson footprints of the patches, to open in QGIS
labels.csvis one row per patch:dataset/labels.csv image,labels,splitpatch_0000000.png,forest,trainpatch_0000001.png,forest,trainpatch_0000002.png,forest,trainlabels.jsonholds the same labels as arrays, so a multi-label dataset needs no parsing:dataset/labels.json {"classes": ["built_up", "farmland", "forest", "water"],"images": {"patch_0000000.png": ["forest"],"patch_0000001.png": ["forest"],…manifest.jsonrecords every patch’s coverage per class and its labels, so you can pick a stricter threshold later without touching the imagery:{"row":0,"col":0,"padded":false,"chunk":0,"files":{"image":"images/patch_0000000.png"},"summary":{"class_coverage":{"3":0.912109375},"labels":[3],"empty_ratio":0.0}} -
Look at the patches
Section titled “Look at the patches”
Three patches per class, each covered by its class for at least half its area. Shown with Dutch open aerial imagery (Beeldmateriaal Nederland, CC BY 4.0) over the same area; with the tutorial’s Esri imagery your patches look alike.
look.py import csvimport randomimport matplotlib.pyplot as pltfrom PIL import Imagewith open("dataset/labels_train.csv", newline="", encoding="utf-8") as handle:rows = list(csv.DictReader(handle))random.seed(0)by_label = {}for row in rows:by_label.setdefault(row["labels"], []).append(row["image"])fig, axes = plt.subplots(len(by_label), 4, figsize=(8, 2 * len(by_label)))for line, (label, images) in zip(axes, sorted(by_label.items())):for ax, image in zip(line, random.sample(images, 4)):ax.imshow(Image.open(f"dataset/images/{image}"))ax.axis("off")line[0].set_title(f"{label} ({len(images)})", loc="left", fontsize=9)plt.tight_layout()plt.show()The rows are the four classes (59, 132, 462 and 47 train patches). A label is only as good as the polygons: OSM’s forest polygons contain clearings and a few houses, and a farmland patch can hold a corner of a pond. That is why
min_fraction: 0.5is a sensible start; raise it for cleaner, rarer examples. -
Train a classifier
Section titled “Train a classifier”labels_train.csvandlabels_val.csvare everything a loader needs. This dataset class reads a split and returns an image with its class index (the position of the name inclasses.txt):train_classifier.py import csvimport torchimport torchvisionfrom PIL import Imagefrom torchvision.transforms.functional import to_tensorROOT = "dataset"classes = open(f"{ROOT}/classes.txt", encoding="utf-8").read().splitlines()index = {name: i for i, name in enumerate(classes)}class Scenes(torch.utils.data.Dataset):def __init__(self, split):with open(f"{ROOT}/labels_{split}.csv", newline="", encoding="utf-8") as handle:self.rows = list(csv.DictReader(handle))def __len__(self):return len(self.rows)def __getitem__(self, i):row = self.rows[i]image = Image.open(f"{ROOT}/images/{row['image']}").convert("RGB")return to_tensor(image), index[row["labels"]]train = torch.utils.data.DataLoader(Scenes("train"), batch_size=32, shuffle=True)val = torch.utils.data.DataLoader(Scenes("val"), batch_size=64)model = torchvision.models.resnet18(weights="DEFAULT")model.fc = torch.nn.Linear(model.fc.in_features, len(classes))optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)loss_fn = torch.nn.CrossEntropyLoss()for epoch in range(10):model.train()for images, labels in train:loss = loss_fn(model(images), labels)optimizer.zero_grad()loss.backward()optimizer.step()model.eval()correct = 0with torch.no_grad():for images, labels in val:correct += (model(images).argmax(1) == labels).sum().item()print(epoch, loss.item(), correct / len(val.dataset))The classes are unbalanced (60 % forest), so look at per-class recall, not only accuracy, and consider a weighted loss. Evaluate once on
labels_test.csvat the end. -
Variations
Section titled “Variations”-
Several labels per patch. Set
mode: multi,min_fraction: 0.1andempty: background: a patch gets every class that covers at least 10 % of it, and a patch with no such class is kept asbackground. On the same area that makes 1,368 patches, among them 149background, 598forestonly, 105farmland forestand a few with three classes.labels.csvthen holds the names joined by a space, andclasses.txtstarts withbackground:dataset/labels.csv image,labels,splitpatch_0000000.png,forest,trainpatch_0000007.png,farmland forest,testpatch_0000010.png,background,trainpatch_0000423.png,built_up farmland,trainTurn
labels.jsoninto multi-hot targets withclassesfor the column order, and train withBCEWithLogitsLoss:import jsonimport torchdata = json.load(open("dataset/labels.json"))index = {name: i for i, name in enumerate(data["classes"])}def multi_hot(names):target = torch.zeros(len(index))for name in names:target[index[name]] = 1.0return targetIn
multimode class names cannot contain whitespace (it separates the labels in the CSV); mapcv stops with a message that names the class if one does. -
Any pixel counts. With the default
min_fraction: 0, a patch gets a label as soon as one valid pixel is labeled. Useful for rare classes such as a road or a pond that cover little of a patch; combine it withmulti. -
Label rasters.
labels.type: rasterworks too: the label raster is sampled onto each patch exactly as for segmentation, and the classes you declare inlabels.classesbecome the labels (usename:to choose them). Pixels outside the label raster, on its NoData or onignore_valuesare not valid and do not count. -
Re-split without regenerating.
mapcv split dataset --strategy spatial --test-ratio 0.3rewrites thesplitcolumn oflabels.csvand the threelabels_<split>.csvfiles from the manifest. With--strategy stratifiedthe strata come from each patch’s label (for several labels, the one with the largest coverage), so every class is spread over the splits in proportion.
-
Attribution
Section titled “Attribution”If you publish this dataset or a model trained on it, credit the sources: imagery by Esri World Imagery and labels © OpenStreetMap contributors (ODbL). See Providers & licensing.