Skip to content
← Back to Python for GIS: scripting, GDAL and spatial statistics
Coding for GIS · Part 21 of 22Intermediate· 23 min readSources

Land Use and Land Cover Classification with Python: K-means and Random Forest on Sentinel-2

Turn a six-band Sentinel-2 image of Bengaluru into a land cover map with Python. Group pixels with K-means, train a Random Forest on labelled samples, check the result against independent reference points, and measure the area of each class.

A land use and land cover map answers a simple question: what covers the ground at each place? This part builds one for a 20 km by 20 km piece of Bengaluru, using six bands of a Sentinel-2 image and about 440 labelled samples. It uses the two methods that every beginner should know: K-means, which needs no labels, and Random Forest, which learns from labelled samples. The same task is done in QGIS, by hand, in the QGIS tutorial on land cover classification with Landsat.

The theory behind both methods is in the study note Image Classification Methods. Read its sections on K-means, Random Forest and accuracy assessment first if the words are new. This part assumes Part 16 and Part 19, so you should know how to open a raster with rasterio, and a little pandas.

What you need

  • Python with rasterio, GeoPandas, pandas, NumPy, Matplotlib and scikit-learn. Install scikit-learn with pip install scikit-learn if you do not have it. A Jupyter notebook is the easiest place to work.
  • The data: bengaluru_lulc_s2.zip (36 MB). Unzip it next to your notebook, so that you have a folder called bengaluru_lulc_s2.
FileWhat it is
s2_bengaluru_2026.tifSix bands of Sentinel-2 surface reflectance, from a median of the dry season (January to March 2026) scenes, 10 m cells, 2000 by 2000 cells
training_samples.csv439 labelled samples: the six band values and the class, from points across the Bengaluru region
validation_points.gpkg87 reference points inside the image, a fresh random sample labelled by the author without seeing any map, never used for training

The bands are blue, green, red, near infrared (NIR), shortwave infrared 1 and shortwave infrared 2, which are Sentinel-2 bands B2, B3, B4, B8, B11 and B12. The values are reflectance multiplied by 10,000. The image is a median of the cloud-free scenes in the dry season, so it has no cloud, and it also shows what a dry season does to the land: fields are bare and brown, and many are hard to tell from built-up land.

The samples were labelled on a high resolution basemap, one place at a time, in four classes. Every point was then checked by eye against this 10 m image. Points whose spot was mixed (for example trees between houses) were removed, and points that clearly showed another class were relabelled.

ClassCodeMeaning
Water1Lakes and tanks
Built-up2Buildings, roads and paved ground
Vegetation3Trees, parks and green crops
Open land4Bare soil, dry fields and cleared plots

The training samples and the reference points were labelled at different times and never mixed. The reference points are a fresh random sample inside the image, drawn after the training samples were fixed, and the labeller saw no classification while choosing classes. Points that could not be assigned to one class were marked unclear and dropped. Keeping the two sets apart is deliberate, and the last sections come back to why.

1. Open the image

import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
import matplotlib.pyplot as plt

# Step 1: open the six-band image and read the facts about it
with rasterio.open("bengaluru_lulc_s2/s2_bengaluru_2026.tif") as src:
    print("bands:", src.count, "| size:", src.width, "x", src.height, "cells")
    print("cell size:", src.res[0], "m | CRS: EPSG", src.crs.to_epsg(), "| data type:", src.dtypes[0])

    # Step 2: read all bands and turn the numbers into reflectance between 0 and 1
    image = src.read().astype("float32") / 10000
    transform = src.transform
    left, bottom, right, top = src.bounds

# Step 3: give each band its own name, so that the code below is easy to read
blue, green, red, nir, swir1, swir2 = image
print("image shape:", image.shape)
print("mean red:", round(float(red.mean()), 3), "| mean NIR:", round(float(nir.mean()), 3))
bands: 6 | size: 2000 x 2000 cells
cell size: 10.0 m | CRS: EPSG 32643 | data type: uint16
image shape: (6, 2000, 2000)
mean red: 0.133 | mean NIR: 0.227

image is a three-dimensional array with the shape (bands, rows, columns). Dividing by 10,000 turns the stored whole numbers into reflectance. float32 matters: integer arithmetic on uint16 bands gives wrong index values, as Part 19 showed.

2. Look at the image

Two colour composites help. In the true colour image you see the scene as an eye would. In the false colour image, shortwave infrared 1, near infrared and red go into the red, green and blue channels, which separates the surfaces much better.

# Step 1: a helper that stretches reflectance to 0 to 1 so that a picture has good contrast
def stretch(band, low, high):
    return np.clip((band - low) / (high - low), 0, 1) ** 0.8

# Step 2: build the two composites by stacking three bands on the last axis
true_colour = np.dstack([stretch(red, 0.02, 0.30), stretch(green, 0.02, 0.30), stretch(blue, 0.02, 0.30)])
false_colour = np.dstack([stretch(swir1, 0.05, 0.45), stretch(nir, 0.05, 0.45), stretch(red, 0.02, 0.25)])

# Step 3: draw them side by side with real coordinates (kilometres)
extent = (left / 1000, right / 1000, bottom / 1000, top / 1000)
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
axes[0].imshow(true_colour, extent=extent)
axes[0].set_title("True colour: red, green, blue")
axes[1].imshow(false_colour, extent=extent)
axes[1].set_title("False colour: SWIR1, NIR, red")
for ax in axes:
    ax.set_xlabel("Easting (km)")
axes[0].set_ylabel("Northing (km)")
fig.savefig("lulc_s2_colour.png", dpi=130, bbox_inches="tight")
print("saved")
saved
Two images of the same 20 km square of eastern Bengaluru. On the left a hazy true colour image in browns and greys. On the right a false colour image where built-up land is purple-grey, bare fields are pink, vegetation is green and lakes are black
Figure 21.1. True colour (left) and false colour (right) for the study window.

Read the false colour image like a legend. Lakes are black, because water absorbs near and shortwave infrared. Vegetation is green, because it reflects near infrared strongly. Built-up land is purple-grey, and bare dry fields are pink. In the true colour image all of this is a similar brown, which is why the extra bands are worth having.

3. Add spectral indices

An index combines bands into one number that is high for one kind of surface. Three are used here, from the spectral indices note: NDVI for vegetation, MNDWI for water and NDBI for built-up and bare surfaces. A classifier can learn a ratio from the raw bands alone, but giving it the ratio makes the job easier, especially for a small training set.

# Step 1: a function that returns the six bands plus three indices (nine features in all)
def make_features(blue, green, red, nir, swir1, swir2):
    eps = 1e-6                                   # avoids dividing by zero over very dark cells
    ndvi = (nir - red) / (nir + red + eps)       # high over green vegetation
    mndwi = (green - swir1) / (green + swir1 + eps)   # above 0 over open water
    ndbi = (swir1 - nir) / (swir1 + nir + eps)   # high over built-up land and dry bare soil
    return [blue, green, red, nir, swir1, swir2, ndvi, mndwi, ndbi]

feature_names = ["blue", "green", "red", "nir", "swir1", "swir2", "ndvi", "mndwi", "ndbi"]

# Step 2: apply it to the whole image
features = make_features(blue, green, red, nir, swir1, swir2)
ndvi, mndwi, ndbi = features[6:]
print("NDVI range:", round(float(ndvi.min()), 2), "to", round(float(ndvi.max()), 2))
print("MNDWI range:", round(float(mndwi.min()), 2), "to", round(float(mndwi.max()), 2))
print("NDBI range:", round(float(ndbi.min()), 2), "to", round(float(ndbi.max()), 2))

# Step 3: draw the three indices
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for ax, layer, title, cmap, vmin, vmax in zip(
    axes, [ndvi, mndwi, ndbi], ["NDVI", "MNDWI", "NDBI"], ["YlGn", "Blues", "Reds"], [-0.1, -0.6, -0.3], [0.8, 0.5, 0.4]
):
    im = ax.imshow(layer, cmap=cmap, vmin=vmin, vmax=vmax, extent=extent)
    ax.set_title(title)
    fig.colorbar(im, ax=ax, shrink=0.7)
fig.savefig("lulc_s2_indices.png", dpi=120, bbox_inches="tight")
print("saved")
NDVI range: -0.58 to 0.94
MNDWI range: -1.0 to 0.88
NDBI range: -0.82 to 0.74
saved
Three maps of the study window: NDVI in yellow to dark green, MNDWI in blues with lakes dark, and NDBI in reds with built-up land darker
Figure 21.2. NDVI, MNDWI and NDBI for the study window.

4. From an image to a table

A classifier does not know about rows and columns of an image. It reads a table in which each pixel is one row and each feature is one column. NumPy turns the image into that table with one line.

# Step 1: flatten every feature layer into one long column, then put the columns side by side
pixels = np.stack([layer.ravel() for layer in features], axis=1)
print("table shape (pixels, features):", pixels.shape)

# Step 2: look at the first rows as a pandas table
print(pd.DataFrame(pixels[:5], columns=feature_names).round(3))
table shape (pixels, features): (4000000, 9)
    blue  green    red    nir  swir1  swir2   ndvi  mndwi   ndbi
0  0.070  0.099  0.136  0.223  0.323  0.253  0.244 -0.529  0.182
1  0.081  0.120  0.168  0.254  0.311  0.246  0.204 -0.442  0.101
2  0.080  0.110  0.145  0.242  0.291  0.227  0.252 -0.453  0.092
3  0.074  0.098  0.126  0.236  0.287  0.220  0.304 -0.489  0.097
4  0.058  0.079  0.106  0.216  0.298  0.224  0.345 -0.581  0.159

The table has four million rows, one for each 10 m cell, and nine columns. Whatever the classifier decides for a row, we can put back in the image later, because reshape reverses ravel.

5. Unsupervised: K-means

K-means needs no labels. It places k centres in the nine-dimensional space, gives every pixel to its nearest centre, moves each centre to the mean of its pixels, and repeats. We ask for 10 clusters, more than the 4 classes we want, and merge them afterwards. A cluster is a group of similar pixels and nothing more.

K-means measures straight line distance, so a feature with large numbers would dominate. StandardScaler rescales each feature to a mean of 0 and a spread of 1 first. To save time, the model is fitted on a random sample of 50,000 pixels, and then it labels all four million.

from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler

# Step 1: learn the scaling from all pixels
scaler = StandardScaler().fit(pixels)

# Step 2: pick 50,000 random pixels and fit K-means on them
rng = np.random.default_rng(42)                  # fixed seed, so the result can be repeated
sample = rng.choice(len(pixels), size=50_000, replace=False)
kmeans = KMeans(n_clusters=10, n_init=5, random_state=42)
kmeans.fit(scaler.transform(pixels[sample]))

# Step 3: give every pixel in the image its cluster number, and put it back in the image shape
cluster_map = kmeans.predict(scaler.transform(pixels)).reshape(red.shape)
print("clusters found:", np.unique(cluster_map))

# Step 4: describe each cluster by the mean of three features
summary = pd.DataFrame({
    "share_%": np.bincount(cluster_map.ravel()) / cluster_map.size * 100,
    "mean_ndvi": [ndvi[cluster_map == k].mean() for k in range(10)],
    "mean_mndwi": [mndwi[cluster_map == k].mean() for k in range(10)],
    "mean_ndbi": [ndbi[cluster_map == k].mean() for k in range(10)],
}).round(2)
print(summary)
clusters found: [0 1 2 3 4 5 6 7 8 9]
   share_%  mean_ndvi  mean_mndwi  mean_ndbi
0    12.92       0.57       -0.54      -0.05
1     7.80       0.15       -0.39       0.12
2    11.84       0.10       -0.17       0.02
3    11.56       0.30       -0.51       0.17
4     1.50       0.02        0.56      -0.36
5     3.58       0.73       -0.49      -0.28
6    15.69       0.25       -0.49       0.13
7     2.59       0.05       -0.13       0.03
8    18.05       0.14       -0.27       0.08
9    14.47       0.42       -0.48       0.01

The table is what you would use to name the clusters by hand. A cluster with a high MNDWI is water, and one with a high NDVI is vegetation. The middle clusters, with low NDVI and a positive NDBI, are the problem: built-up land and dry bare fields look alike in the dry season, and K-means cannot tell them apart without help.

The help here comes from the labelled samples. Each sample is given the cluster it falls in, and each cluster takes the class that most of its samples have. This is a quick way to name clusters, and the samples are used only for the names.

# Step 1: read the labelled samples and build the same nine features for them
train = pd.read_csv("bengaluru_lulc_s2/training_samples.csv")
band_columns = ["blue", "green", "red", "nir", "swir1", "swir2"]
train_features = np.stack(make_features(*[train[c].to_numpy("float32") for c in band_columns]), axis=1)
train_class = train["class_id"].to_numpy()
print(train["class_name"].value_counts())

# Step 2: find the cluster of every sample, and count samples of each class in each cluster
train_clusters = kmeans.predict(scaler.transform(train_features))
counts = pd.crosstab(train_clusters, train["class_name"])
print(counts)

# Step 3: each cluster takes the class that is most common in it
class_names = {1: "Water", 2: "Built-up", 3: "Vegetation", 4: "Open land"}
name_to_id = {name: code for code, name in class_names.items()}
lookup = np.zeros(10, dtype="uint8")             # 0 means no class, for a cluster without samples
for cluster, class_name in counts.idxmax(axis=1).items():
    lookup[cluster] = name_to_id[class_name]
print("cluster to class:", lookup.tolist())

# Step 4: replace each cluster number in the image by its class code
kmeans_map = lookup[cluster_map]
class_name
Built-up      136
Open land     132
Vegetation    108
Water          63
Name: count, dtype: int64
class_name  Built-up  Open land  Vegetation  Water
row_0                                             
0                  3          3          19      0
1                 14         22           1      0
2                 21          7           1      0
3                 11          4          39      1
4                  0          0           0     62
5                  0          7           7      0
6                 22         63          15      0
7                  8          1           1      0
8                 48          5           1      0
9                  9         20          24      0
cluster to class: [3, 4, 2, 3, 1, 4, 4, 2, 2, 3]

train_features has the same nine columns in the same order as pixels, and it is also float32. The model was fitted on float32 numbers, and giving it float64 would raise an error about a buffer type, a common stumbling block.

6. Supervised: Random Forest

Random Forest learns directly from the labelled samples. Many decision trees are grown, each on a random part of the samples, and every tree votes for a class. The majority wins.

Before the final model, a quick test shows how it behaves. Setting aside 30 percent of the samples and scoring the model on them is the usual first check.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

# Step 1: keep 30 percent of the samples aside, with the same class shares in both parts
x_train, x_test, y_train, y_test = train_test_split(
    train_features, train_class, test_size=0.3, random_state=42, stratify=train_class
)

# Step 2: train on 70 percent and score on the 30 percent the model has never seen
forest_test = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1)
forest_test.fit(x_train, y_train)
print("accuracy on the held-out samples:", round(forest_test.score(x_test, y_test), 3))

# Step 3: train the final model on all the samples, and classify every pixel in the image
forest = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1)
forest.fit(train_features, train_class)
forest_map = forest.predict(pixels).reshape(red.shape).astype("uint8")
print("classified cells:", forest_map.size, "| classes found:", np.unique(forest_map))
accuracy on the held-out samples: 0.742
classified cells: 4000000 | classes found: [1 2 3 4]

n_estimators is the number of trees, and n_jobs=-1 uses every processor core. random_state makes the run repeatable. The score on the held-out samples is a first check. It is not the accuracy of the map, and the next section shows why.

7. Draw the maps

from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch

# Step 1: one colour per class, in the order of the codes 0 (none), 1, 2, 3, 4 (blue, purple, teal-green and sand stay apart for colour-blind readers)
colours = ["#ffffff", "#2a78d6", "#7b3294", "#1baf7a", "#e6c35c"]
cmap = ListedColormap(colours)

# Step 2: draw K-means and Random Forest side by side
fig, axes = plt.subplots(1, 2, figsize=(13, 6.5))
for ax, result, title in zip(axes, [kmeans_map, forest_map], ["K-means, clusters named from samples", "Random Forest"]):
    ax.imshow(result, cmap=cmap, vmin=0, vmax=4, extent=extent, interpolation="nearest")
    ax.set_title(title)
    ax.set_xlabel("Easting (km)")
axes[0].set_ylabel("Northing (km)")

# Step 3: a legend with the class names
handles = [Patch(color=colours[code], label=name) for code, name in class_names.items()]
axes[1].legend(handles=handles, loc="lower right", frameon=True)
fig.savefig("lulc_s2_maps.png", dpi=120, bbox_inches="tight")
print("saved")
saved
Drag to compare the satellite image with the map made from it
A 20 km square in eastern Bengaluru, dry season 2026, 10 m cells.
Satellite image of the square
Land cover map of the square
FALSE COLOUR IMAGERANDOM FOREST MAP
False colour shows shortwave infrared, near infrared and red as red, green and blue: water is black, vegetation green, built-up land purple-grey, dry fields pink. Contains modified Copernicus Sentinel data 2026.

Drag the white line to compare the satellite image with the map made from it, and switch between K-means and Random Forest. The two maps share the same broad pattern: a built-up core in purple, lakes in blue, green patches that follow parks and valleys, and open land to the east and south. They disagree in the middle ground, where built-up land, dry fields and sparse trees mix.

8. Check the maps against independent points

A plausible-looking map can still be wrong. The reference points tell us how often it is right. They are 87 places inside the image whose class was decided from a high resolution basemap and which were never used for training or for naming clusters.

# Step 1: read the reference points and find the row and column of the image cell under each point
points = gpd.read_file("bengaluru_lulc_s2/validation_points.gpkg")
rows, cols = rasterio.transform.rowcol(transform, points.geometry.x.to_numpy(), points.geometry.y.to_numpy())
reference = points["class_id"].to_numpy()
print("reference points:", len(points))
print(points["class_name"].value_counts())

# Step 2: a function that builds the confusion matrix and the three accuracy measures
def accuracy_report(reference, mapped):
    matrix = pd.crosstab(pd.Series(mapped, name="mapped"), pd.Series(reference, name="reference"))
    matrix = matrix.reindex(index=[1, 2, 3, 4], columns=[1, 2, 3, 4], fill_value=0)
    correct = np.diag(matrix.to_numpy())
    overall = correct.sum() / matrix.to_numpy().sum()
    users = correct / matrix.sum(axis=1).clip(lower=1).to_numpy()        # correct divided by the row total
    producers = correct / matrix.sum(axis=0).clip(lower=1).to_numpy()    # correct divided by the column total
    table = pd.DataFrame({"users": users, "producers": producers}, index=list(class_names.values())).round(2)
    matrix.index = matrix.columns = list(class_names.values())
    return matrix, overall, table

# Step 3: apply it to both maps
for label, result in [("K-means", kmeans_map), ("Random Forest", forest_map)]:
    matrix, overall, table = accuracy_report(reference, result[rows, cols])
    print(f"--- {label}: overall accuracy {overall:.2f}")
    print(matrix)
    print(table)
reference points: 87
class_name
Vegetation    33
Built-up      24
Open land     20
Water         10
Name: count, dtype: int64
--- K-means: overall accuracy 0.69
            Water  Built-up  Vegetation  Open land
Water           8         0           1          0
Built-up        0        15           2          2
Vegetation      2         4          24          5
Open land       0         5           6         13
            users  producers
Water        0.89       0.80
Built-up     0.79       0.62
Vegetation   0.69       0.73
Open land    0.54       0.65
--- Random Forest: overall accuracy 0.75
            Water  Built-up  Vegetation  Open land
Water           9         0           1          0
Built-up        1        22           5          4
Vegetation      0         1          21          3
Open land       0         1           6         13
            users  producers
Water        0.90       0.90
Built-up     0.69       0.92
Vegetation   0.84       0.64
Open land    0.65       0.65

Read each matrix with the mapped class in the rows and the reference class in the columns. The numbers on the diagonal are the points where the map and the reference agree. User's accuracy is the diagonal cell divided by the row total, and it says how often a mapped class is right. Producer's accuracy is the diagonal cell divided by the column total, and it says how many of the real points of a class the map found.

The same numbers are below as a chart. Switch between the two methods and hover a cell to see how many points it holds. The bars show user's and producer's accuracy.

Random Forest: 65 of 87 reference points are right (75%)
Independent reference points. Rows: what the map says. Columns: what the point really is. Hover a cell.
What the point really isWaterBuilt-upVegetationOpen landWater901090%Built-up1225469%Vegetation0121384%Open land0161365%User's accuracy90%92%64%65%Producer's
Darker cells hold more points. The bars on the right are user's accuracy: how often the map is right when it says this class. The bars below are producer's accuracy: how often a point of this class was found. The weakest user's accuracy is open land (65%). 87 points is a small sample, so the numbers are indicative.

Three things help when you read these numbers.

  • The overall accuracy on the reference points and the score on the held-out training samples are close here (about 75 and 74 percent). They need not be. Held-out samples come from the same places as the training samples, so they often flatter the model, and fresh points are the fair test. When the two agree, the training samples are likely to cover what the map has to handle. When they differ by a lot, trust the reference points.
  • Eighty-seven points are better than a few dozen and still small. One point moves the overall accuracy by about 1 percentage point, and the uncertainty is about plus or minus 10 points. The numbers per class are rougher: vegetation has 33 points, built-up 24, open land 20 and water 10. Use 50 or more points per class for a map you want to publish. This part is a lesson, not a product.
  • The reference points were drawn by class of ground, with extra points on water, so the overall accuracy is indicative and not an estimate for the whole window.

The difference between the two methods (about 6 points) is smaller than the uncertainty, so it would be wrong to say that one is better from this test. What you can say is where the maps fail. Open land is confused with built-up land and with vegetation in both, and the Random Forest finds more of the built-up points than K-means does.

9. Which features mattered?

A Random Forest reports how much each feature helped to split the samples. It helps to see what the model leaned on.

# Step 1: pair every feature with its importance and sort
importance = pd.Series(forest.feature_importances_, index=feature_names).sort_values()
print(importance.round(3))

# Step 2: draw a horizontal bar chart
fig, ax = plt.subplots(figsize=(6, 4))
importance.plot.barh(ax=ax, color=["#1baf7a" if name in ("ndvi", "mndwi", "ndbi") else "#2a78d6" for name in importance.index])
ax.set_xlabel("Importance")
ax.set_title("What the Random Forest used")
fig.savefig("lulc_s2_importance.png", dpi=120, bbox_inches="tight")
print("saved")
ndbi     0.050
ndvi     0.083
red      0.090
green    0.098
blue     0.104
swir2    0.118
nir      0.128
swir1    0.150
mndwi    0.179
dtype: float64
saved
The water index and the shortwave infrared carried the most weight
Share of the forest's splitting work by feature. Blue: bands. Teal-green: indices made from bands.
mndwi17.9%swir115.0%nir12.8%swir211.8%blue10.4%green9.8%red9.0%ndvi8.3%ndbi5.0%
band index
Importance says what the model used, not what is true of the ground. Features that are correlated share the credit.

The water index and shortwave infrared 1 come out highest, which fits the false colour picture, where those bands separate water, vegetation and built-up land. The importances are spread fairly evenly, and none of the nine features does the job alone. The importance shows how much the model used a feature, so treat it as a clue and not as proof that the feature is the right one.

10. Areas of each class

# Step 1: count the cells of each class. One cell is 10 m by 10 m, which is 100 square metres
cell_area_km2 = 10 * 10 / 1_000_000
areas = pd.DataFrame({
    "kmeans_km2": np.bincount(kmeans_map.ravel(), minlength=5)[1:] * cell_area_km2,
    "forest_km2": np.bincount(forest_map.ravel(), minlength=5)[1:] * cell_area_km2,
}, index=list(class_names.values())).round(1)

# Step 2: add the shares of the whole 400 square kilometre window
areas["forest_share_%"] = (areas["forest_km2"] / areas["forest_km2"].sum() * 100).round(1)
print(areas)
print("window area:", round(areas["forest_km2"].sum(), 1), "km2")
            kmeans_km2  forest_km2  forest_share_%
Water              6.0         6.9             1.7
Built-up         129.9       181.4            45.4
Vegetation       155.8       116.2            29.0
Open land        108.3        95.5            23.9
window area: 400.0 km2

Class areas from a map depend on its errors. With producer's and user's accuracies like these, the real area of built-up land or open land could differ from the map by many square kilometres. Area-weighted estimators, described in the study note and in the Olofsson reference, give the corrected area and its confidence interval. Quote the areas here as map areas.

The same areas are below as a chart.

The two methods differ by 52 km² on built-up land
Class area in the 400 km² window, one cell is 100 m².
Water6.0 km²6.9 km²Built-up129.9 km²181.4 km²Vegetation155.8 km²116.2 km²Open land108.3 km²95.5 km²
K-means Random Forest
Areas read from a map carry the map's errors, so read them together with the accuracy chart.

11. Save the map

# Step 1: write the Random Forest map as a GeoTIFF that keeps the CRS and position of the image
with rasterio.open("bengaluru_lulc_s2/s2_bengaluru_2026.tif") as src:
    profile = src.profile
profile.update(count=1, dtype="uint8", nodata=0, compress="deflate")
with rasterio.open("lulc_random_forest_2026.tif", "w", **profile) as dst:
    dst.write(forest_map, 1)
    dst.write_colormap(1, {0: (255, 255, 255, 0), 1: (42, 120, 214, 255), 2: (123, 50, 148, 255), 3: (27, 175, 122, 255), 4: (230, 195, 92, 255)})

# Step 2: read it back to check that it survived
with rasterio.open("lulc_random_forest_2026.tif") as check:
    print("saved:", check.width, "x", check.height, "|", check.dtypes[0], "| CRS EPSG", check.crs.to_epsg(), "| NoData", check.nodata)
saved: 2000 x 2000 | uint8 | CRS EPSG 32643 | NoData 0.0

The colour table stored in the file means QGIS opens the map with the same colours. Drag lulc_random_forest_2026.tif into QGIS to put it over a basemap and check places you know.

What limits the accuracy, and what would help?

About three in four reference points are right, which is reasonable for four classes from a single dry season image. Dry season images of Indian cities are hard to classify, and the main errors here are typical.

  • Dry fields, cleared plots and built-up land are all pale and have similar spectra. A second image from the wet season, when crops are green, would separate them better than any algorithm. You can stack two dates as extra features with the same code.
  • A cell of 10 m can still hold a roof, a tree and a road. The reference points are single places, and the cell around them is a mixture.
  • Texture helps. The local standard deviation of NIR in a 5 by 5 window is high over built-up land and low over a bare field, and scipy.ndimage.uniform_filter makes it in a few lines. It needs training samples taken from the same image, such as polygons drawn on it, because the table of samples used here holds only band values.
  • More samples for the weak classes help most. The training set has few labelled water points, and an extra 60 clean ones were added from a global land cover product to make up the number.
  • Judge each change on the reference points, and keep those points out of every decision, or the accuracy figure stops being independent.

Run this part as a notebook

The code on this page is also a Jupyter notebook, with the theory in text cells, the code, guided practice, an independent assignment and hidden solutions. Download the Part 21 notebook, put it next to the unzipped bengaluru_lulc_s2 folder, and run the cells from top to bottom.

Practice

  1. Change the number of K-means clusters from 10 to 6 and to 16, name the clusters again and check the overall accuracy each time. Does more clusters help?
  2. Train the Random Forest on the six bands only, without the three indices. How do the overall accuracy and the importance table change?
  3. Change n_estimators from 100 to 20 and to 300. How do the result and the time change?
  4. Replace the Random Forest by a decision tree, DecisionTreeClassifier(max_depth=6), and by KNeighborsClassifier(n_neighbors=7) after scaling. Which gives the best overall accuracy on the reference points, and which gives the best held-out score?

Common mistakes

  • Reading the score on held-out training samples as the accuracy of the map.
  • Using the same reference points to tune the model and to report its accuracy.
  • Giving the model float64 samples after fitting it on float32 pixels, or the reverse.
  • Building the features for the samples in a different order from the features of the image.
  • Forgetting to scale the features before K-means.
  • Quoting class areas without the accuracy next to them.
  • Naming clusters by looking at one colour on a map instead of at their values.

Key takeaways