How to Classify Land Cover from Satellite Imagery in Python

On this page

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 Snowdon β€” 22 September 2025, a 10 km window round the summit, classes taken from the scene's own classification layer β€” the median reflectances a classifier has to separate:

                 blue   green    red     nir
vegetation      0.039   0.068  0.057   0.362
water           0.015   0.013  0.008   0.007
bare soil       0.106   0.123  0.126   0.285
cloud           0.336   0.330  0.327   0.454
cloud shadow    0.029   0.037  0.031   0.133

Water and cloud shadow are both dark in the visible bands and separate cleanly in the near-infrared β€” 0.007 against 0.133, a factor of nineteen. That is the kind of contrast a classifier exploits, and it only exists if you include the right bands. (The setup below prints this table.)

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. Example 2 runs this on the Snowdon scene, with and without the groups.

Training polygons sampled to pixels, spectral and index features built, a classifier fitted with grouped validation, and the scene predicted. four steps, and the third decides whether the score is real1mask cloud, or label it as a class57% of one date2build bands + indices + texturenot bands alone3sample by polygon, keep the groupStratifiedGroupKFold4predict, with a confidence bandand per-class recallWithout grouping, the score measures memorisation of a training patch spatialworkflow.io
Grouping by training polygon at the split is what separates a real score from memorisation.

What you get β€” from 2. Build features beyond the raw bands

NDWI       3.02 sd
NDVI       2.76 sd
MNDWI      1.92 sd
NIR alone  1.43 sd

Mask first, richer features, polygon-based sampling

1. Mask before you sample

Cloud, cirrus and shadow are not land cover. On the single Sentinel-2 date over Snowdon used here, 57% 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 vs shadow
ndbi = (swir - nir) / (swir + nir)        # built-up, but reads high over water

How far apart water and cloud shadow sit on each, in pooled standard deviations:

def nd(a, b):
    with np.errstate(divide="ignore", invalid="ignore"):
        return (a - b) / (a + b)

water, shadow = scl == 6, scl == 3
for name, index in (("NDWI", nd(bands["green"], bands["nir"])), ("NDVI", nd(bands["nir"], bands["red"])),
                    ("MNDWI", nd(bands["green"], bands["swir16"])), ("NIR alone", bands["nir"])):
    a, b = index[water], index[shadow]
    pooled = np.sqrt((np.nanvar(a) + np.nanvar(b)) / 2)
    print(f"{name:9} {abs(np.nanmean(a) - np.nanmean(b)) / pooled:5.2f} sd")
NDWI       3.02 sd
NDVI       2.76 sd
MNDWI      1.92 sd
NIR alone  1.43 sd

Adding the right index 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.007 against 0.133 reflectance. two dark classes, one band that separates thembluegreenrednear-infraredwater0.0150.0130.0080.007cloud shadow0.0290.0370.031!0.133ratio1.9Γ—2.8Γ—3.9Γ—βœ“19Γ—A classifier without the near-infrared cannot tell these apart spatialworkflow.io
The near-infrared is what separates water from shadow. Without it the two classes are inseparable.

Features, training samples and a classifier

The data used on this page

One Sentinel-2 L2A scene, S2A_30UVD_20250922_0_L2A (22 September 2025), from Element 84's Earth Search, cut to the 10 Γ— 10 km window round the summit of Snowdon (Yr Wyddfa) that the NDVI and radiometric levels guides use: six bands at 10 m (the shortwave-infrared bilinearly resampled from 20 m) and the scene classification (SCL). The training labels combine two sources, and say so: land classes from ESA WorldCover 2021 where the SCL calls the pixel clear, and cloud and cloud shadow from the SCL itself. Up to 40 connected patches of at least 30 pixels per class become the training polygons. WorldCover and the SCL are models' outputs, so the scores below measure how the method behaves, not the true accuracy of a Snowdonia land cover map.

import geopandas as gpd
import numpy as np
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.features import shapes
from rasterio.transform import from_origin
from rasterio.warp import reproject
from rasterio.windows import from_bounds
from scipy import ndimage
from shapely.geometry import shape

BOUNDS = (422_880, 5_875_440, 432_880, 5_885_440)
TRANSFORM = from_origin(422_880, 5_885_440, 10, 10)
item = next(pystac_client.Client.open("https://earth-search.aws.element84.com/v1")
            .search(ids=["S2A_30UVD_20250922_0_L2A"], collections=["sentinel-2-l2a"]).items())
bands = {}
for name in ("blue", "green", "red", "nir", "swir16", "swir22"):
    with rasterio.open(item.assets[name].href) as src:
        bands[name] = src.read(1, window=from_bounds(*BOUNDS, transform=src.transform), out_shape=(1000, 1000),
                               resampling=Resampling.bilinear if name.startswith("swir") else Resampling.nearest
                               ).astype("float32") / 10_000
with rasterio.open(item.assets["scl"].href) as src:
    scl = src.read(1, window=from_bounds(*BOUNDS, transform=src.transform), out_shape=(1000, 1000),
                   resampling=Resampling.nearest)

print(f"cloud, cirrus or shadow: {np.isin(scl, (3, 8, 9, 10)).mean():.1%} of the window")
print("                 blue   green    red     nir")
for label, codes in (("vegetation", [4]), ("water", [6]), ("bare soil", [5]), ("cloud", [8, 9]), ("cloud shadow", [3])):
    m = np.isin(scl, codes)
    print(f"{label:14} " + " ".join(f"{np.median(bands[b][m]):7.3f}" for b in ("blue", "green", "red", "nir")))

worldcover = np.zeros((1000, 1000), "uint8")
with rasterio.open("https://esa-worldcover.s3.eu-central-1.amazonaws.com/v200/2021/map/"
                   "ESA_WorldCover_10m_2021_v200_N51W006_Map.tif") as src:
    reproject(rasterio.band(src, 1), worldcover, dst_transform=TRANSFORM, dst_crs="EPSG:32630",
              resampling=Resampling.mode)

CLASSES = {1: "tree cover", 2: "grassland", 3: "built-up", 4: "bare", 5: "water", 6: "cloud", 7: "cloud shadow"}
clear = np.isin(scl, (4, 5, 6, 7, 11))
label = np.zeros((1000, 1000), "uint8")
for code, wc in ((1, 10), (2, 30), (3, 50), (4, 60), (5, 80)):
    label[clear & (worldcover == wc)] = code
label[np.isin(scl, (8, 9, 10))] = 6
label[scl == 3] = 7

rng, rows = np.random.default_rng(0), []
for code, name in CLASSES.items():
    patches, n = ndimage.label(label == code)
    sizes = ndimage.sum_labels(np.ones_like(patches), patches, index=np.arange(1, n + 1))
    keep = np.nonzero(sizes >= 30)[0] + 1
    keep = rng.choice(keep, min(40, len(keep)), replace=False)
    for geom, _ in shapes(patches.astype("int32"), mask=np.isin(patches, keep), transform=TRANSFORM):
        rows.append({"class": name, "geometry": shape(geom)})
training_polygons = gpd.GeoDataFrame(rows, crs="EPSG:32630")
print(training_polygons["class"].value_counts().to_dict())
cloud, cirrus or shadow: 57.3% of the window
                 blue   green    red     nir
vegetation       0.039   0.068   0.057   0.362
water            0.015   0.013   0.008   0.007
bare soil        0.106   0.123   0.126   0.285
cloud            0.336   0.330   0.327   0.454
cloud shadow     0.029   0.037   0.031   0.133
{'tree cover': 40, 'grassland': 40, 'cloud shadow': 40, 'cloud': 31, 'built-up': 16, 'water': 11, 'bare': 5}

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
stack, feature_names = classification_features(bands)
  11 features: ['blue', 'green', 'red', 'nir', 'swir16', 'swir22', 'ndvi', 'ndwi', 'mndwi', 'ndbi', 'nir_texture_5']
  100.0% of cells complete

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
X, y, groups = sample_training(training_polygons, stack, TRANSFORM)
  8,705 pixels from 183 polygons
    bare                 5 polygons,    233 pixels
    built-up            16 polygons,    695 pixels
    cloud               31 polygons,  1,464 pixels
    cloud shadow        40 polygons,  2,000 pixels
    grassland           40 polygons,  1,894 pixels
    tree cover          40 polygons,  1,869 pixels
    water               11 polygons,    550 pixels

The quick answer's model, run on these samples β€” and again with the groups taken away:

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import balanced_accuracy_score, classification_report
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold

model = RandomForestClassifier(n_estimators=200, min_samples_leaf=2,
                               class_weight="balanced_subsample", n_jobs=4, random_state=0)
for label, splitter, split_groups in (("grouped by polygon", StratifiedGroupKFold(5, shuffle=True, random_state=0), groups),
                                      ("random pixels", StratifiedKFold(5, shuffle=True, random_state=0), None)):
    predicted = np.empty(len(y), dtype=y.dtype)
    for train, test in splitter.split(X, y, split_groups):
        predicted[test] = model.fit(X[train], y[train]).predict(X[test])
    print(f"--- {label}: balanced accuracy {balanced_accuracy_score(y, predicted):.3f}")
    if split_groups is not None:
        print(classification_report(y, predicted, digits=3))
--- grouped by polygon: balanced accuracy 0.484
              precision    recall  f1-score   support

        bare      0.305     0.288     0.296       233
    built-up      0.355     0.430     0.389       695
       cloud      0.694     0.647     0.669      1464
cloud shadow      0.506     0.549     0.527      2000
   grassland      0.360     0.287     0.319      1894
  tree cover      0.542     0.601     0.570      1869
       water      0.621     0.589     0.604       550

    accuracy                          0.506      8705
   macro avg      0.483     0.484     0.482      8705
weighted avg      0.503     0.506     0.503      8705

--- random pixels: balanced accuracy 0.719

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.

Balanced accuracy of the Snowdon land cover forest with random pixel folds and with folds grouped by training polygon. same samples, same model, two ways to splitrandom pixel folds0.719grouped by polygon0.48400.518,705 pixels from 183 training polygons; the gap is memorised patches spatialworkflow.io
The run above; only the grouped number is an estimate for unseen ground.

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
profile = {"driver": "GTiff", "width": 1000, "height": 1000, "crs": "EPSG:32630", "transform": TRANSFORM}
codes = {str(name): i + 1 for i, name in enumerate(np.unique(y))}
numeric = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, class_weight="balanced_subsample",
                                 n_jobs=4, random_state=0).fit(X, np.array([codes[v] for v in y]))
classify_scene(numeric, stack, profile, "landcover.tif", {v: k for k, v in codes.items()})
with rasterio.open("landcover.tif") as src:
    classes = src.read(1)
print({name: f"{(classes == code).mean():.1%}" for name, code in codes.items()})
  100.0% of the scene classified
  29.0% of classified pixels have a winning probability below 0.5
{'bare': '0.4%', 'built-up': '1.9%', 'cloud': '32.1%', 'cloud shadow': '28.2%', 'grassland': '15.6%', 'tree cover': '19.9%', 'water': '2.0%'}

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.

Why near-infrared does the work

Why the near-infrared does the work

The class table at the top shows why. Water and cloud shadow differ by a factor of two to four in the visible bands and nineteen in the near-infrared. Vegetation differs from bare soil by a factor of 2.2 in red, and its near-infrared to red ratio is 2.8 times bare soil's.

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: on the Snowdon samples, balanced accuracy was 0.719 with random pixel folds and 0.484 with each polygon kept whole.

Why to include or exclude clouds deliberately

Cloud and shadow are 57% of this Sentinel-2 date over Snowdon. 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. What it chooses is easy to check β€” train on the land classes only, then predict the pixels the SCL calls cloud and shadow:

land = ~np.isin(y, ["cloud", "cloud shadow"])
land_only = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, class_weight="balanced_subsample",
                                   n_jobs=4, random_state=0).fit(X[land], y[land])
flat = stack.reshape(stack.shape[0], -1).T
for name, codes_ in (("cloud", (8, 9)), ("cloud shadow", (3,))):
    guess = land_only.predict(flat[np.isin(scl.ravel(), codes_)])
    kinds, counts = np.unique(guess, return_counts=True)
    print(f"{name:13} ->", ", ".join(f"{k} {c / counts.sum():.0%}" for k, c in sorted(zip(kinds, counts), key=lambda t: -t[1])[:3]))
cloud         -> water 47%, grassland 44%, tree cover 4%
cloud shadow  -> grassland 37%, water 33%, tree cover 26%

Not the nearest-looking class you might guess. Cloud, far brighter than anything in the training data, came out as water or grassland β€” a random forest cannot extrapolate beyond the reflectances it was trained on, so an unfamiliar pixel lands wherever the trees' last splits happen to send it. Shadow was spread across grassland, water and tree cover. Either way a masked-out class does not disappear; it turns up in the map as something else.

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 putting near-duplicate pixels from one polygon in both folds, against a grouped split keeping each polygon whole. fifty pixels from one polygon are fifty views of one placerandom pixel splitβœ•near-duplicates in both foldsβœ•model memorises the patchβœ•accuracy looks excellentβœ•fails on a new patchgrouped by polygonβœ“each polygon wholeβœ“must generalise between patchesβœ“score always dropsβœ“the drop is the illusionThree or more polygons per class, or the class cannot be validated at all spatialworkflow.io
The score always falls when you group. The size of the fall is the size of the illusion.

Masking, extra bands and indices

  • 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.02 standard deviations, against 1.43 for the near-infrared alone.
  • 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 nineteenfold 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.