How to Classify Land Cover from Satellite Imagery in Python

Problem statement

Supervised land cover classification is four steps: sample training pixels, build features, fit a classifier, predict the scene. Each has a decision that matters more than the algorithm choice.

Measured on a real Sentinel-2 scene over Snowdonia, the class medians that a classifier has to separate:

                 blue   green    red     nir
vegetation      0.039   0.067  0.056   0.361
water           0.015   0.013  0.008   0.006
bare soil       0.090   0.106  0.110   0.235
cloud           0.527   0.507  0.504   0.599
cloud shadow    0.030   0.039  0.032   0.134

Water and cloud shadow are both dark in the visible bands and separate cleanly in the near-infrared β€” 0.006 against 0.134, a factor of twenty. That is the kind of contrast a classifier exploits, and it only exists if you include the right bands.

Quick answer

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import balanced_accuracy_score, classification_report
from sklearn.model_selection import StratifiedGroupKFold

model = RandomForestClassifier(n_estimators=200, min_samples_leaf=2,
                               class_weight="balanced_subsample",
                               n_jobs=-1, random_state=0)

predicted = np.empty(len(y), dtype=y.dtype)
for train, test in StratifiedGroupKFold(5, shuffle=True,
                                        random_state=0).split(X, y, groups):
    predicted[test] = model.fit(X[train], y[train]).predict(X[test])

print(classification_report(y, predicted, digits=3))
print(f"balanced accuracy {balanced_accuracy_score(y, predicted):.3f}")

The groups argument is what makes the score meaningful. Training polygons are clustered, and without grouping the model memorises polygons rather than classes.

Training polygons sampled to pixels, spectral and index features built, a classifier fitted with grouped validation, and the scene predicted.
Grouping by training polygon at the split is what separates a real score from memorisation.

Step-by-step solution

1. Mask before you sample

Cloud, cirrus and shadow are not land cover. Measured on a single Sentinel-2 date over Snowdonia, 51% of pixels were cloud, cirrus or cloud shadow β€” a classifier trained on those learns to classify weather.

usable = ~np.isin(scl, (0, 1, 3, 8, 9, 10))

Alternatively, include cloud and shadow as explicit classes so the model can label them. Either is defensible; leaving them unlabelled in the training data is not.

2. Build features beyond the raw bands

Raw reflectance separates the obvious classes. Indices separate the hard ones:

ndvi = (nir - red) / (nir + red)          # vegetation vs bare
ndwi = (green - nir) / (green + nir)      # water, 3.79 sd from shadow
ndbi = (swir - nir) / (swir + nir)        # built-up, but reads high over water

Measured separation between water and cloud shadow: NDWI 3.79 pooled standard deviations, NDVI 3.12, MNDWI 2.60. Adding NDWI is worth more than any hyperparameter.

Texture features β€” a local standard deviation over a window β€” separate classes with similar spectra and different structure, such as forest and grassland.

3. Sample training pixels from polygons, and keep the polygon id

samples = sample_in_polygons(training_polygons, per_polygon=50,
                             label_column="class")

Fifty pixels from one polygon are fifty views of one place. They must stay in the same cross-validation fold, which needs the polygon id carried through as a group.

Spread the sample across as many polygons per class as possible. A class drawn from one polygon teaches the model that class looks like that polygon.

4. Validate with grouped, stratified folds

StratifiedGroupKFold keeps each polygon whole and each fold's class balance similar. Without grouping, the reported accuracy is memorisation; without stratification, a rare class can be absent from a fold entirely.

5. Report per-class recall, not accuracy

A class covering 2% of the landscape can be ignored entirely while accuracy stays at 98%. Balanced accuracy β€” the mean per-class recall β€” does not hide it.

Water and cloud shadow overlapping in the visible bands and separating in the near-infrared at 0.006 against 0.134 reflectance.
The near-infrared is what separates water from shadow. Without it the two classes are inseparable.

Code examples

Example 1 β€” building the feature stack

import numpy as np


def classification_features(bands, include_indices=True, texture_window=5):
    """Reflectance, indices and texture, as an aligned stack."""
    features, names = [], []

    for name in ("blue", "green", "red", "nir", "swir16", "swir22"):
        if name in bands:
            features.append(bands[name])
            names.append(name)

    if include_indices:
        def nd(a, b):
            total = a + b
            with np.errstate(divide="ignore", invalid="ignore"):
                return np.where(np.abs(total) < 1e-6, np.nan, (a - b) / total)

        pairs = [("ndvi", "nir", "red"), ("ndwi", "green", "nir")]
        if "swir16" in bands:
            pairs += [("mndwi", "green", "swir16"), ("ndbi", "swir16", "nir")]
        for label, first, second in pairs:
            if first in bands and second in bands:
                features.append(nd(bands[first], bands[second]))
                names.append(label)

    if texture_window:
        from scipy.ndimage import uniform_filter
        base = np.nan_to_num(bands["nir"], nan=float(np.nanmean(bands["nir"])))
        mean = uniform_filter(base, size=texture_window)
        squared = uniform_filter(base ** 2, size=texture_window)
        features.append(np.sqrt(np.maximum(squared - mean ** 2, 0)))
        names.append(f"nir_texture_{texture_window}")

    stack = np.stack(features)
    print(f"  {len(names)} features: {names}")
    print(f"  {np.isfinite(stack).all(axis=0).mean():.1%} of cells complete")
    return stack, names

Texture is the feature most often missing from a land cover model. Forest and grassland can have very similar mean reflectance and very different local variance, and no amount of spectral tuning separates them.

Example 2 β€” sampling from training polygons with groups

import numpy as np
import geopandas as gpd
import rasterio


def sample_training(polygons, stack, transform, label_column="class",
                    per_polygon=50, min_polygons=3, seed=0):
    """Pixels inside training polygons, with the polygon id as the group."""
    from rasterio.features import geometry_mask
    rng = np.random.default_rng(seed)
    height, width = stack.shape[1:]

    rows, labels, groups = [], [], []
    per_class_polygons = polygons.groupby(label_column).size()

    for idx, polygon in polygons.iterrows():
        mask = ~geometry_mask([polygon.geometry], out_shape=(height, width),
                              transform=transform, invert=False)
        valid = mask & np.isfinite(stack).all(axis=0)
        candidates = np.nonzero(valid.ravel())[0]
        if candidates.size == 0:
            continue
        take = rng.choice(candidates, min(per_polygon, candidates.size),
                          replace=False)
        r, c = np.unravel_index(take, (height, width))
        rows.append(stack[:, r, c].T)
        labels.append(np.full(len(take), polygon[label_column]))
        groups.append(np.full(len(take), idx))

    X = np.vstack(rows)
    y = np.concatenate(labels)
    g = np.concatenate(groups)

    print(f"  {len(X):,} pixels from {len(np.unique(g))} polygons")
    for label, count in per_class_polygons.items():
        n_pixels = int((y == label).sum())
        flag = "  <- too few polygons to validate" if count < min_polygons else ""
        print(f"    {str(label):18} {count:3d} polygons, "
              f"{n_pixels:6,} pixels{flag}")
    return X, y, g

The polygon count per class is the number that decides whether the class can be validated. Two polygons means any split either trains on both or tests on ground the model has seen nothing like.

Example 3 β€” predicting the scene with a confidence band

import numpy as np
import rasterio


def classify_scene(model, stack, profile, out_path, class_names=None):
    """Class raster plus the winning probability, as two bands."""
    height, width = stack.shape[1:]
    valid = np.isfinite(stack).all(axis=0)
    X = stack[:, valid].T

    classes = model.predict(X)
    probabilities = model.predict_proba(X).max(axis=1)

    class_raster = np.full(height * width, 0, dtype="uint8")
    confidence = np.full(height * width, np.nan, dtype="float32")
    class_raster[valid.ravel()] = classes.astype("uint8")
    confidence[valid.ravel()] = probabilities

    profile = dict(profile) | {"count": 2, "dtype": "float32",
                               "nodata": np.nan, "compress": "deflate"}
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(class_raster.reshape(height, width).astype("float32"), 1)
        dst.write(confidence.reshape(height, width), 2)
        dst.set_band_description(1, "class")
        dst.set_band_description(2, "winning class probability")
        if class_names:
            dst.update_tags(classes=",".join(f"{k}={v}"
                                             for k, v in class_names.items()))

    low = confidence[valid.ravel()] < 0.5
    print(f"  {valid.mean():.1%} of the scene classified")
    print(f"  {low.mean():.1%} of classified pixels have a winning "
          "probability below 0.5")
    return out_path

The confidence band is the land cover equivalent of an applicability mask. Pixels where the winning probability is barely above the next one are the model guessing between two classes, and they cluster along class boundaries and in unfamiliar terrain.

Explanation

Why the near-infrared does the work

The class table at the top shows why. Water and cloud shadow differ by a factor of five in the visible bands and a factor of twenty in the near-infrared. Vegetation differs from bare soil by a factor of 1.5 in red and 6 in the ratio.

A classifier given only visible bands has to separate classes that genuinely overlap there. Adding the near-infrared and shortwave-infrared bands turns several hard separations into easy ones.

This is why band selection matters more than model selection: the model can only use contrasts that exist in its inputs.

Why grouping by polygon is not optional

Training polygons are drawn around homogeneous patches, so the pixels inside one are nearly identical.

Under a random split, a held-out pixel has fifty near-duplicates in the training set. The model predicts it perfectly, and the reported accuracy measures memorisation of that patch.

Grouping by polygon forces the model to generalise from one patch to another, which is what classifying a scene actually requires. The score always drops, and the drop is the size of the illusion.

Why to include or exclude clouds deliberately

Cloud and shadow are 51% of a single Sentinel-2 date over Snowdonia. They must be handled explicitly.

Masking them out means the classifier never sees them and will assign them a land cover class at prediction time β€” usually bare soil for cloud and water for shadow, since those are the nearest spectral matches.

Including them as classes means the model can label them, at the cost of two classes that are not land cover. For a single-date classification that is usually the better choice; for a composite, masking is right because the composite should not contain cloud at all.

Why per-pixel classification produces speckle

Each pixel is classified independently, so a boundary pixel that is a mixture of two classes is assigned to whichever it resembles more. The result is a scatter of isolated pixels along every boundary.

Options: include texture features so the model sees context, apply a majority filter afterwards, or segment the image and classify segments rather than pixels.

A majority filter is the cheapest and it erodes genuine small features. Segmentation is more principled and adds a parameter β€” the segment size β€” that becomes part of the analysis.

A random pixel split leaking near-duplicate pixels across folds against a grouped split keeping polygons whole.
The score always falls when you group. The size of the fall is the size of the illusion.

Edge cases or notes

  • Mask cloud and shadow before sampling, or include them as explicit classes.
  • Include the near-infrared and shortwave-infrared bands. Several separations exist only there.
  • Add indices; NDWI separated water from shadow by 3.79 standard deviations.
  • Group by training polygon at the split, always.
  • Three or more polygons per class, or the class cannot be validated.
  • Report per-class recall and balanced accuracy, not overall accuracy.
  • Write a confidence band alongside the class raster.
  • Expect speckle from per-pixel classification; texture features or segmentation reduce it.

FAQ

How do I classify land cover in Python?

Mask cloud, build a feature stack of bands plus indices and texture, sample pixels from training polygons keeping the polygon id, fit a classifier with grouped stratified folds, and predict the scene.

Which bands do I need?

At least the visible and near-infrared. Shortwave-infrared separates several classes that overlap elsewhere β€” water and shadow differ twentyfold in the near-infrared alone.

Why must I group by training polygon?

Because pixels inside one polygon are near-duplicates. Without grouping, the score measures memorisation of the patch rather than knowledge of the class.

Should clouds be a class or be masked?

Either, deliberately. Masking means the model assigns cloud a land cover class at prediction time; including it as a class means it can be labelled.

Why is my classified map speckled?

Per-pixel classification treats each pixel independently, so mixed boundary pixels scatter. Add texture features, apply a majority filter, or classify segments instead.

What accuracy should I report?

Per-class precision and recall plus balanced accuracy. Overall accuracy is dominated by the largest class.

How many training polygons per class?

At least three, and preferably many more. A class from one polygon teaches the model that polygon rather than the class.