GIS & Geospatial Data (QGIS, GeoPandas, PostGIS) Module 6 – Raster data and remote sensing
Map algebra, NDVI and reclassification
Combine aligned bands cell by cell, compute NDVI and both NDWIs so nodata never leaks, avoid integer traps and reclassify with thresholds you record.
What you will learn
- Combine aligned bands cell by cell with a mask, so that nodata never reaches a result
- Compute NDVI and NDWI and explain what their values mean
- Avoid integer overflow, zero denominators and unscaled values in band maths
- Classify a continuous raster with thresholds you can defend and record
Before you start
What you’ll learn · Before you start
What you will learn
- Combine aligned bands cell by cell with a mask, so that nodata never reaches a result
- Compute NDVI and NDWI and explain what their values mean
- Avoid integer overflow, zero denominators and unscaled values in band maths
- Classify a continuous raster with thresholds you can defend and record
Before you start
On this page
A field reflects 0.0494 of red light and 0.4214 of near-infrared: (0.4214 − 0.0494) ÷ (0.4214 + 0.0494) = 0.790, the NDVI (normalised difference vegetation index): (NIR − red) ÷ (NIR + red), from −1 to 1: negative over water and rising with green leaves, which absorb red light and reflect near-infrared strongly. of dense, healthy crop. Map algebra repeats that formula for every cell of aligned rasters, 480,000 times for the lesson raster, and writes a new raster of the answers.
Here you combine bands cell by cell with a mask that keeps missing cells missing, compute NDVI and the two indices that share the name NDWI (normalised difference water index): A name shared by two indices: McFeeters' (green − NIR) ÷ (green + NIR), which outlines open water, and Gao's (NIR − SWIR) ÷ (NIR + SWIR), which senses water in leaves. A map should say which bands it used., and read what their values say about water, soil, towns and crops. Then you reproduce band maths that goes wrong without an error, and turn NDVI into classes with thresholds you can defend.
Map algebra applies one formula to every cell of aligned grids
map algebra: Computing a new raster from one or more aligned rasters with a formula applied cell by cell, such as a band ratio, a sum or a comparison with a threshold. Kinds that also read a cell's neighbours or a whole zone are moving-window and zonal operations. computes a raster from one or more rasters with a formula that works on each cell by itself: add two bands, divide one by another, compare a band with a threshold. The inputs must be aligned, with the same CRS, pixel size, origin and number of rows and columns, so that row 418, column 100 is the same patch of ground in each; the next lesson shows how to get there. Here is a corner of the lesson raster, three rows by three columns, through the NDVI formula:
Rows 418 to 420 and columns 98 to 100 of the lesson raster, through the NDVI formula
Text description of the diagram
Two grids of 3 rows and 3 columns sit side by side at the top. The left one holds the red band: 2217, 2215 and 494 in the first row; 2210, 2209 and 496 in the second; 2209, 2210 and 0 in the third. The right one holds the near-infrared band: 2817, 2815 and 4214; 2810, 2809 and 4216; 2809, 2810 and 0. In both, the bottom-right cell, which holds 0, is drawn grey with a dashed outline: it has no data.
Under them the formula NDVI = (NIR − red) / (NIR + red) points down to a third grid with the result of each cell: 0.119, 0.119 and 0.790 in the first row; 0.120, 0.120 and 0.789 in the second; 0.120, 0.120 and nodata in the third. The two cells above 0.5, 0.790 and 0.789, are shaded light blue: a field of crop. The six cells near 0.12 are bare soil. The bottom-right cell is grey with a dashed outline and reads nodata, because a cell missing in an input stays missing in the output.
Two cells are crop, 0.790 and 0.789, six are bare soil, about 0.12, and one lies in the hole without data. A cell missing in either input must stay missing in the output, whatever its nodata value: A value, such as -9999, that marks a raster cell with no measurement. It is a code, not data, and must be left out of sums, means and counts. More in “Vector and raster data models”. Follow both kinds of cell through the steps:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A cell with data. Row 418, column 100 holds red 494 and near-infrared 4214. Map algebra reads the same cell of both bands, which is why the two grids must line up pixel for pixel.
- Check the mask. Neither value is the nodata value, so the cell goes on to the formula.
- The formula. (4214 − 494) ÷ (4214 + 494) = 3720 ÷ 4708 = 0.790, a field of dense, healthy crop.
- The result. The output raster gets 0.790 in the same row and column. Every other cell goes through the same steps, on its own.
- A cell in the hole. Row 420, column 100 is in the hole of 2,400 pixels without data, so both bands hold 0.
- The mask decides. Red is the nodata value, so the formula is never reached. Computing it would divide 0 by 0, or worse, give a number that looks like data.
- Nodata goes through. The output cell becomes nodata too. A cell missing in any input is missing in the result, so no statistic of the output can count it.
The program applies those steps to all 480,000 pixels and averages the result for each kind of ground:
"""The lesson's raster: two bands, red and near-infrared, of 800 by 600 pixels of 10 m in UTM zone 43N.
Each value is a reflectance times 10,000, stored as an unsigned 16-bit integer, and 0 marks a pixel without data.
The scene is made by whole-number formulas, so every program that imports it, here or in a browser, gets the
same numbers: a river, fields of crops and bare soil, a town, and a hole of 2,400 pixels with no data.
"""
WIDTH, HEIGHT = 800, 600 # columns, rows
TRANSFORM = (10.0, 0.0, 370_000.0, 0.0, -10.0, 2_046_000.0) # a, b, c, d, e, f
CRS = "EPSG:32643" # WGS 84 / UTM zone 43N
NODATA = 0
def noise(row, col):
"""A fixed whole number from -40 to 40 for each row and column, the same on every computer."""
h = ((row * 73_856_093) ^ (col * 19_349_663)) & 0xFFFFFFFF
h = ((h ^ (h >> 13)) * 1_274_126_177) & 0xFFFFFFFF
return h % 81 - 40
def wobble(row, col):
"""Texture that changes smoothly over 16 pixels, as real ground does, plus a little noise of its own."""
r, c, fr, fc = row // 16, col // 16, row % 16, col % 16
top = noise(r, c) * (16 - fc) + noise(r, c + 1) * fc
bottom = noise(r + 1, c) * (16 - fc) + noise(r + 1, c + 1) * fc
return (top * (16 - fr) + bottom * fr) // 256 + noise(row, col) // 8
def cover(row, col):
"""What lies under a pixel: water, town, soil, crop, or None in the hole without data."""
if 420 <= row < 460 and 100 <= col < 160:
return None
if abs(row - (260 + col * col // 4000)) < 18:
return "water"
if 60 <= row < 200 and 520 <= col < 700:
return "town"
field_row, field_col = row // 80, col // 100
if (field_row * 3 + field_col) % 5 == 0:
return "soil"
return "crop"
def pixel(row, col):
"""(red, nir) of one pixel, reflectance times 10,000."""
kind = cover(row, col)
if kind is None:
return NODATA, NODATA
w = wobble(row, col)
if kind == "water":
return 350 + w, 200 + w // 2
if kind == "town":
return 1_700 + w, 2_200 + w
if kind == "soil":
return 2_200 + w, 2_800 + w
vigour = ((row // 80) * 7 + (col // 100) * 13) % 10 # 0 to 9, one value per field
return 800 - vigour * 40 + w, 3_000 + vigour * 150 + (col % 100) * 3 + w
def bands():
"""The two bands as lists of rows: red, then near-infrared."""
red, nir = [], []
for row in range(HEIGHT):
pairs = [pixel(row, col) for col in range(WIDTH)]
red.append([p[0] for p in pairs])
nir.append([p[1] for p in pairs])
return red, nir """NDVI cell by cell: a 3 x 3 corner of the lesson raster, then every pixel, with nodata kept out."""
from scene import HEIGHT, NODATA, WIDTH, cover, pixel
def ndvi(red, nir):
"""(NIR - red) / (NIR + red) in floating point, or None where either band has no data."""
if red == NODATA or nir == NODATA:
return None
return (nir - red) / (nir + red)
def show(value):
return "nodata" if value is None else f"{value:.3f}"
rows, cols = range(418, 421), range(98, 101)
print("row band" + "".join(f" col {c:>3}" for c in cols))
for r in rows:
print(f"{r} red " + "".join(f"{pixel(r, c)[0]:>9}" for c in cols))
print(f"{r} nir " + "".join(f"{pixel(r, c)[1]:>9}" for c in cols))
print(f"{r} ndvi" + "".join(f"{show(ndvi(*pixel(r, c))):>9}" for c in cols))
totals, counts, missing = {}, {}, 0
for r in range(HEIGHT):
for c in range(WIDTH):
value = ndvi(*pixel(r, c))
if value is None:
missing += 1
continue
kind = cover(r, c)
totals[kind] = totals.get(kind, 0.0) + value
counts[kind] = counts.get(kind, 0) + 1
valid = sum(counts.values())
print(f"pixels with data: {valid:,}; nodata: {missing:,}")
print(f"mean NDVI of the pixels with data: {sum(totals.values()) / valid:.4f}")
for kind in ("water", "town", "soil", "crop"):
print(f" {kind:>5}: {counts[kind]:>7,} pixels, mean NDVI {totals[kind] / counts[kind]:.3f}") Output
row band col 98 col 99 col 100 418 red 2217 2215 494 418 nir 2817 2815 4214 418 ndvi 0.119 0.119 0.790 419 red 2210 2209 496 419 nir 2810 2809 4216 419 ndvi 0.120 0.120 0.789 420 red 2209 2210 0 420 nir 2809 2810 0 420 ndvi 0.120 0.120 nodata pixels with data: 477,600; nodata: 2,400 mean NDVI of the pixels with data: 0.5046 water: 28,000 pixels, mean NDVI -0.273 town: 25,200 pixels, mean NDVI 0.128 soil: 94,900 pixels, mean NDVI 0.120 crop: 329,500 pixels, mean NDVI 0.710
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ndvi_grid.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
477,600 pixels have data, and their mean NDVI is 0.5046: water −0.273, town 0.128, soil 0.120 and crop 0.710. On
whole arrays, NumPy’s masked arrays do the same bookkeeping: a True in the mask marks a value as invalid, and
computations such as a mean leave it out
(NumPy, numpy.ma). rasterio returns such an array
from read(masked=True).
Checkpoint
Check: Map algebra, cell by cell, with masks
NDVI and NDWI measure the contrast between two bands
Each index here is a normalised difference of two bands, (a − b) ÷ (a + b), which lies between −1 and 1 whenever neither band is negative. NDVI compares near-infrared with red: the chlorophyll of green leaves takes in most of the visible light for photosynthesis, while the cells of the leaf send much of the near-infrared back (NASA Earth Observatory). It was described for monitoring vegetation on the Great Plains with ERTS images (Rouse and others). NASA reads 0.1 and below as rock, sand or snow, 0.2 to 0.3 as shrub and grassland, and 0.6 to 0.8 as temperate and tropical rainforest.
NDWI names two indices. McFeeters defined one from green and near-infrared to outline open water (McFeeters); Gao defined another from near-infrared and shortwave infrared to sense the liquid water in leaves (Gao). On Sentinel-2, green is B3, red B4, near-infrared B8 and shortwave infrared B11, at about 1,610 nm (SentiWiki). The program computes all three for four surfaces, with illustrative reflectances:
"""Three normalised differences for four surfaces: NDVI and the two indices called NDWI."""
# Reflectance, 0 to 1, in four bands: illustrative values for this lesson, shaped like real surfaces
# (green 560 nm, red 665 nm, near-infrared 833 nm, shortwave infrared 1610 nm, about Sentinel-2's B3, B4, B8, B11).
SURFACES = {
"water": {"green": 0.060, "red": 0.035, "nir": 0.020, "swir": 0.010},
"crop": {"green": 0.080, "red": 0.050, "nir": 0.400, "swir": 0.200},
"soil": {"green": 0.150, "red": 0.220, "nir": 0.280, "swir": 0.350},
"town": {"green": 0.140, "red": 0.170, "nir": 0.220, "swir": 0.250},
}
def normalised_difference(a, b):
"""(a - b) / (a + b): from -1 to 1, the contrast between two bands."""
return (a - b) / (a + b)
print(f"{'surface':>8} {'NDVI':>7} {'NDWI (green, NIR)':>18} {'NDWI (NIR, SWIR)':>17}")
for name, s in SURFACES.items():
ndvi = normalised_difference(s["nir"], s["red"])
ndwi_water = normalised_difference(s["green"], s["nir"]) # McFeeters (1996): open water
ndwi_leaves = normalised_difference(s["nir"], s["swir"]) # Gao (1996): water in leaves
print(f"{name:>8} {ndvi:>7.3f} {ndwi_water:>18.3f} {ndwi_leaves:>17.3f}") Output
surface NDVI NDWI (green, NIR) NDWI (NIR, SWIR)
water -0.273 0.500 0.333
crop 0.778 -0.667 0.333
soil 0.120 -0.302 -0.111
town 0.128 -0.222 -0.064
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 indices.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Only water has a positive green and near-infrared NDWI, 0.500, and only water a negative NDVI. The near-infrared and shortwave index gives water and crop the same 0.333: it was made for water in leaves, not for finding ponds. Name the two bands of every NDWI you publish.
reclassification: Turning a raster of continuous values into classes with a table of thresholds, such as NDVI below 0 as water and 0.3 or more as vegetation. The table and the image's date belong with the result. turns such an index into classes with a table of thresholds, such as NDVI below 0 as water and 0.3 or more as vegetation. Its thresholds are decisions about one place and one date, so a reclassification is only as good as the table kept with it.
Key idea
An index is a contrast between two named bands: NDVI is near-infrared against red, and an NDWI means nothing until you say which two bands it compares.
Checkpoint
Check: NDVI and the two NDWIs
Band maths that fails without an error, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
Three mistakes give wrong NDVI with no error message: integers that wrap round, cells that divide 0 by 0, and digital numbers used without their scale and offset. Each is reproduced here on the lesson raster, with its fix.
In this part:
- Integer sums that wrap round in 8 and 16 bits
- Zero denominators, and a hole that leaks into the mean
- Digital numbers need their scale and offset
- Three checks that catch all three failures
1 diagram · 2 runnable examples (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reclassification with thresholds you can defend and record Pro: opens with a Pro, Premium or Ultimate pass
A reclassification is a table of decisions written as numbers. Here you write one for the lesson raster, measure how far a single threshold moves an area, and run the same table on a second date.
In this part:
- A class table with half-open classes and its own nodata
- One threshold moves an area by a fifth, measured
- Choosing the threshold from field plots, with numbers
- The same table on a second date
- Reclassify NDVI with a table and measure the classes
- Results of the sample tests
3 runnable examples (Python) · 1 exercise with sample tests · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: NDVI with a mask
The exercise computes NDVI with a mask on the lesson’s corner and averages it, and the self-check covers the ideas of the first two sections, from cell-by-cell algebra to the two NDWIs:
Exercise · Easy · Python
Compute NDVI with a mask, and its mean
The red and near-infrared bands of a small raster are given as lists of rows of whole numbers, and nodata is the value that marks a pixel without data in either band.
ndvi_grid(red, nir, nodata)returns the NDVI of every pixel,(nir - red) / (nir + red), as a list of rows of the same shape, withNonewherever either band holdsnodataor wherenir + redis 0.mean_ndvi(grid)returns(mean, count): the mean of the values that are notNone, rounded to 4 decimals, and how many there were. A grid with no values returns(None, 0).
The sample tests use the 3 by 3 corner of the lesson raster, rows 418 to 420 and columns 98 to 100, and a tiny grid with a pixel whose bands are both 0.
Starter code · masked.py
def ndvi_grid(red, nir, nodata):
"""NDVI of every pixel, a list of rows, with None where either band has no data or the sum is 0."""
# Replace this line: go through the rows and columns of both bands together.
return []
def mean_ndvi(grid):
"""(mean rounded to 4 decimals, count) of the values that are not None; (None, 0) if there are none."""
# Replace this line: leave out every None before you sum.
return None, 0 The sample tests · test_masked.py
from masked import mean_ndvi, ndvi_grid
RED = [[2217, 2215, 494], [2210, 2209, 496], [2209, 2210, 0]]
NIR = [[2817, 2815, 4214], [2810, 2809, 4216], [2809, 2810, 0]]
def test_lesson_corner():
"""computes NDVI cell by cell and keeps the nodata cell as None"""
grid = ndvi_grid(RED, NIR, 0)
assert round(grid[0][2], 3) == 0.790
assert round(grid[1][0], 3) == 0.120
assert grid[2][2] is None
def test_shape_is_kept():
"""returns a grid of the same shape as the bands"""
grid = ndvi_grid(RED, NIR, 0)
assert len(grid) == 3 and all(len(row) == 3 for row in grid)
def test_mean_leaves_nodata_out():
"""averages the eight cells with data, not nine"""
assert mean_ndvi(ndvi_grid(RED, NIR, 0)) == (0.287, 8)
def test_zero_sum_and_other_nodata_value():
"""masks a pixel whose bands are both 0, and honours another nodata value"""
grid = ndvi_grid([[100, 0], [-9999, 300]], [[300, 0], [500, -9999]], -9999)
assert grid[0][0] == 0.5
assert grid[0][1] is None and grid[1][0] is None and grid[1][1] is None
assert mean_ndvi(grid) == (0.5, 1)
def test_empty():
"""returns (None, 0) when no cell has data"""
assert mean_ndvi([[None, None]]) == (None, 0)
def test_equal_bands():
"""gives 0, not nodata, to a pixel whose two bands are equal"""
assert ndvi_grid([[200, 0]], [[200, 0]], 0) == [[0.0, None]] A hint
Check the mask before you divide: if r == nodata or n == nodata or r + n == 0, put None in the output cell and go on to the next one. For the mean, collect the values that are not None first, then divide their sum by how many there are.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
6 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Three longer questions start from this lesson, from an index above 1 to a threshold that moves an answer; the bank gives what each tests and a first step:
-
Fresher to mid
A colleague's NDVI raster has values above 100 over every reservoir and river, while fields and towns look normal. What happened, how do you prove it, and how do you change the pipeline so that it cannot happen again?
-
Mid to senior
You are asked for the area of healthy crop in a district from one Sentinel-2 image, using NDVI. How do you choose the threshold, and what do you report with the area?
-
Mid to senior
The mean NDVI of a district fell from 0.75 to 0.52 between two Sentinel-2 images taken a year apart, in the same month, yet the fields look the same on both. How do you investigate before anyone reports a drought?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is map algebra, and what must its input rasters share?
A formula applied to each cell on its own, such as adding, dividing or comparing bands. The inputs must be aligned (the same CRS, pixel size, origin, rows and columns), so that a cell is the same patch of ground in every input.
-
What should happen to a cell that is missing in one input of a map algebra formula?
It stays missing in the output, whatever the nodata value. NumPy's masked arrays keep that bookkeeping, a True in the mask marking an invalid value that a mean leaves out, and rasterio returns one from read(masked=True).
-
What is NDVI, and why does it pick out green vegetation?
(NIR − red) ÷ (NIR + red), between −1 and 1. Chlorophyll absorbs most visible light while leaf cells send much near-infrared back: a field reflecting 0.4214 near-infrared and 0.0494 red gives 0.790, dense healthy crop.
-
What do NDVI values say about the ground?
Below 0 is typically water; 0.1 and below rock, sand or snow; 0.2 to 0.3 shrub and grassland; 0.6 to 0.8 temperate and tropical rainforest, by NASA's reading. The lesson's town averaged 0.128 and its crop 0.710.
-
Which two indices share the name NDWI, and what does each measure?
McFeeters' NDWI, from green and near-infrared, outlines open water; Gao's, from near-infrared and shortwave infrared, senses the water in leaves and gave water and crop the same 0.333. Name the two bands of every NDWI you publish.
-
Which Sentinel-2 bands are green, red, near-infrared and shortwave infrared?
B3 is green, B4 red, B8 near-infrared and B11 shortwave infrared, at about 1,610 nm.
-
What is reclassification, and what must be kept with its result?
Turning a continuous raster into classes with a table of thresholds, such as NDVI below 0 as water and 0.3 or more as vegetation. The thresholds are decisions about one place and one date, so the table travels with the result.
Key takeaways
- Map algebra applies one formula to the same cell of aligned rasters, and a cell missing in any input stays missing in the output.
- NDVI, (NIR − red) ÷ (NIR + red), runs from −1 to 1: negative for water, about 0.1 for bare ground and 0.6 to 0.8 for rainforest.
- Two indices are called NDWI: green with near-infrared outlines open water, near-infrared with shortwave infrared senses water in leaves.
- Reclassification turns an index into classes through a table of thresholds, which must be kept with the result.
Terms in this lesson
- map algebra
- Computing a new raster from one or more aligned rasters with a formula applied cell by cell, such as a band ratio, a sum or a comparison with a threshold. Kinds that also read a cell's neighbours or a whole zone are moving-window and zonal operations.
- NDVI (normalised difference vegetation index)
- (NIR − red) ÷ (NIR + red), from −1 to 1: negative over water and rising with green leaves, which absorb red light and reflect near-infrared strongly.
- NDWI (normalised difference water index)
- A name shared by two indices: McFeeters' (green − NIR) ÷ (green + NIR), which outlines open water, and Gao's (NIR − SWIR) ÷ (NIR + SWIR), which senses water in leaves. A map should say which bands it used.
- reclassification
- Turning a raster of continuous values into classes with a table of thresholds, such as NDVI below 0 as water and 0.3 or more as vegetation. The table and the image's date belong with the result.
References
- Measuring Vegetation (NDVI & EVI) (NASA Earth Observatory)
- Monitoring vegetation systems in the Great Plains with ERTS (Rouse, Haas, Schell and Deering) (NASA Technical Reports Server)
- The use of the Normalized Difference Water Index (NDWI) in the delineation of open water features (McFeeters) (International Journal of Remote Sensing)
- NDWI: a normalized difference water index for remote sensing of vegetation liquid water from space (Gao) (Remote Sensing of Environment)
- S2 Mission (SentiWiki) (European Space Agency (Copernicus))
- S2 Products (SentiWiki) (European Space Agency (Copernicus))
- S2 Processing (SentiWiki) (European Space Agency (Copernicus))
- Data types (NumPy user guide) (NumPy Developers)
- The numpy.ma module (NumPy reference) (NumPy Developers)
- Nodata masks (rasterio documentation) (rasterio developers)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress