Chapter 39 — GIS 2: Raster Data: Aerial Photos You Can Calculate With

Series I5 — A First Application: Web, Maps, and Dashboards

NoteIn this chapter, you will:
  • Understand rasters: pixels, resolution, and bands
  • Read an image with Rasterio and move between pixels and coordinates
  • Calculate NDVI, the plant greenness index
  • Clip an image with a land boundary, then change its CRS and resolution
  • Calculate zonal statistics per plot and pond, then save a GeoTIFF

All scripts for this chapter are in the kode/ch39/ folder. On the web version, you can also download them all at once (zip).

Chapter 38 drew land with lines. But a satellite photo or drone photo contains no lines: it holds a grid of tiny squares, each with one number. That is a raster. Imagine a chequered cloth laid over your garden; each square is painted the average colour of the ground beneath it. A square is called a pixel.

A raster = a grid of pixels. Each band has its own numbers; NDVI is calculated from two bands.

Three key terms:

NoteREMEMBER

Healthy plants reflect a lot of near infrared and absorb a lot of red light. Water hardly reflects any infrared. This difference is used to measure “greenness”.

Downloading big images for this book is not allowed, so we make a made-up satellite image with a script: a grid of 100 columns by 80 rows, 10 m pixels, covering the same 1 km × 800 m area as Chapter 38, with two bands (red and NIR) and a little random noise. Each plot is deliberately given a certain greenness.

01_make_synthetic_image.py
# Chapter 39 - Making a made-up satellite image (2 bands: red and near infrared)
from pathlib import Path

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import rasterize
from rasterio.transform import from_origin

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

# A grid of 100 columns x 80 rows, 10 m pixels, top-left corner at (700000, 9226800) UTM 48S
transform = from_origin(700000, 9226800, 10, 10)
shape = (80, 100)

plots = gpd.read_file("data/plot.geojson").to_crs(32748)
ponds = gpd.read_file("data/pond.geojson").to_crs(32748)
rivers = gpd.read_file("data/river.geojson").to_crs(32748)

# A "land cover map": each pixel gets the NDVI value we want it to have
target_ndvi = np.full(shape, 0.15)                  # bare soil
plot_value = {"H1": 0.80, "H2": 0.78, "K1": 0.60, "K2": 0.55, "S1": 0.45, "S2": 0.70}
for _, row in plots.iterrows():
    mask = rasterize([(row.geometry, 1)], out_shape=shape, transform=transform, dtype="uint8")
    target_ndvi[mask == 1] = plot_value[row["code"]]
water = list(ponds.geometry) + list(rivers.buffer(8).geometry)      # ponds and the river body
target_ndvi[rasterize(water, out_shape=shape, transform=transform, dtype="uint8") == 1] = -0.20

# From the target NDVI, build the red and NIR bands (plus a little random noise)
rng = np.random.default_rng(42)
target_ndvi = target_ndvi + rng.normal(0, 0.03, shape)
red = rng.uniform(0.05, 0.09, shape)
nir = red * (1 + target_ndvi) / (1 - target_ndvi)
image = np.stack([red, nir]).astype("float32")

profile = dict(driver="GTiff", height=80, width=100, count=2, dtype="float32",
               crs="EPSG:32748", transform=transform)
with rasterio.open(tmp / "synthetic_image.tif", "w", **profile) as target:
    target.write(image)
    target.set_band_description(1, "red")
    target.set_band_description(2, "nir")
print("saved:", (tmp / "synthetic_image.tif").name, "| shape:", image.shape)

# Proof: identical to the file provided in data/
with rasterio.open("data/synthetic_image.tif") as existing:
    print("identical to data/synthetic_image.tif:", np.array_equal(existing.read(), image))
saved: synthetic_image.tif | shape: (2, 80, 100)
identical to data/synthetic_image.tif: True

This script makes the image in _tmp/ and proves it is identical to the provided file data/synthetic_image.tif. The following scripts read the provided file. Two new tools appear here: rasterize turns polygons into pixels, and from_origin(x, y, width, height) sets the grid’s position on earth (top-left corner and pixel size).

Reading rasters with Rasterio

Rasterio reads and writes rasters (using GDAL behind the scenes). Each band is read as a NumPy array (Chapter 23).

02_read_raster.py
# Chapter 39 - Reading a raster: pixels, resolution, bands, and coordinates
import rasterio

with rasterio.open("data/synthetic_image.tif") as image:
    print("size (width x height):", image.width, "x", image.height, "pixels")
    print("number of bands:", image.count, "| descriptions:", image.descriptions)
    print("data type:", image.dtypes[0])
    print("CRS:", image.crs)
    print("resolution (m):", image.res)
    print("bounds (left, bottom, right, top):", tuple(image.bounds))

    red = image.read(1)          # band 1 -> a two-dimensional NumPy array
    nir = image.read(2)
    print("\narray shape:", red.shape, "(rows, columns)")
    print("red: min %.3f max %.3f" % (red.min(), red.max()))
    print("nir: min %.3f max %.3f" % (nir.min(), nir.max()))

    # Pixel (row 10, column 20) -> the map coordinates of its centre, and back
    x, y = image.xy(10, 20)
    print("\ncentre of pixel (10, 20):", x, y)
    print("that coordinate maps back to (row, column):", image.index(x, y))

    # The value at one point, e.g. inside pond A (easting 700122, northing 9226285)
    print("[red, nir] at pond A:", [round(float(v), 3) for v in next(image.sample([(700122, 9226285)]))])
size (width x height): 100 x 80 pixels
number of bands: 2 | descriptions: ('merah', 'nir')
data type: float32
CRS: EPSG:32748
resolution (m): (10.0, 10.0)
bounds (left, bottom, right, top): (700000.0, 9226000.0, 701000.0, 9226800.0)

array shape: (80, 100) (rows, columns)
red: min 0.050 max 0.090
nir: min 0.030 max 1.314

centre of pixel (10, 20): 700205.0 9226695.0
that coordinate maps back to (row, column): (10, 20)
[red, nir] at pond A: [0.057, 0.038]
  • The image is 100 × 80 pixels, with two bands named red and nir, type float32, CRS EPSG:32748 (metres), resolution 10 m.
  • bounds shows where it lies: from easting 700000 to 701000 and northing 9226000 to 9226800.
  • The array has shape (80, 100): rows first, then columns. That is NumPy’s order, the opposite of the (x, y) we usually think of on a map.
  • xy(row, column) turns a pixel number into the coordinates of its centre; index(x, y) reverses it. sample reads the value at one point: inside pond A, red is 0.057 and NIR 0.038. NIR is lower than red, a typical sign of water.

NDVI: measuring greenness

NDVI (Normalized Difference Vegetation Index) = (NIR − Red) ÷ (NIR + Red). The result is between −1 and 1: water is negative, bare soil is around 0.1 to 0.2, and dense plants approach 0.8 and above.

03_ndvi.py
# Chapter 39 - NDVI: the plant greenness index
from pathlib import Path

import numpy as np
import rasterio

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

with rasterio.open("data/synthetic_image.tif") as image:
    red, nir = image.read(1), image.read(2)
    profile = image.profile

# NDVI = (NIR - Red) / (NIR + Red), a value between -1 and 1
ndvi = (nir - red) / (nir + red)
print("NDVI: min %.2f | mean %.2f | max %.2f" % (ndvi.min(), ndvi.mean(), ndvi.max()))

# Simple classes (the thresholds are only learning examples)
classes = np.digitize(ndvi, [0.0, 0.3, 0.6])          # 0=water, 1=bare, 2=medium, 3=dense
names = ["water", "bare soil", "medium vegetation", "dense vegetation"]
for i, n in enumerate(names):
    count = int((classes == i).sum())
    print(f"{n:18s}: {count:5d} pixels = {count * 100 / 10_000:5.1f} ha")

# Save the NDVI as a one-band GeoTIFF
profile.update(count=1, dtype="float32", nodata=-9999.0)
with rasterio.open(tmp / "ndvi.tif", "w", **profile) as target:
    target.write(ndvi.astype("float32"), 1)
print("saved:", "ndvi.tif")
NDVI: min -0.29 | mean 0.33 | max 0.89
water             :   216 pixels =   2.2 ha
bare soil         :  4964 pixels =  49.6 ha
medium vegetation :   777 pixels =   7.8 ha
dense vegetation  :  2043 pixels =  20.4 ha
saved: ndvi.tif

The mean NDVI of the whole image is 0.33 and the smallest is −0.29 (water). The classes are made with np.digitize; the thresholds 0, 0.3, and 0.6 are only learning examples (real values depend on the sensor and crop). For instance, dense vegetation is 2043 pixels × 100 m² = 20.4 ha. The operation (nir - red) / (nir + red) works on the whole grid at once, with no loop: the power of NumPy. The result is saved as a GeoTIFF (nodata marked −9999).

NDVI of the made-up image with plot and pond boundaries. Dark green = dense, orange = water.

Clipping an image with a land boundary

Usually we only need the image inside a certain piece of land. rasterio.mask.mask clips it with a polygon.

04_clip_to_boundary.py
# Chapter 39 - Clipping the image with a land boundary
from pathlib import Path

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.mask import mask

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

plots = gpd.read_file("data/plot.geojson")
with rasterio.open("data/synthetic_image.tif") as image:
    s1 = plots[plots["code"] == "S1"].to_crs(image.crs)        # vector CRS = raster CRS
    clipped, transform = mask(image, s1.geometry, crop=True, nodata=np.nan)
    profile = image.profile

red, nir = clipped
ndvi = (nir - red) / (nir + red)
print("clip shape (band, rows, columns):", clipped.shape)
print("pixels inside S1:", int(np.isfinite(ndvi).sum()))
print("mean NDVI of S1: %.2f" % np.nanmean(ndvi))

profile.update(height=clipped.shape[1], width=clipped.shape[2], transform=transform, nodata=np.nan)
with rasterio.open(tmp / "clip_S1.tif", "w", **profile) as target:
    target.write(clipped)
print("saved:", "clip_S1.tif")
clip shape (band, rows, columns): (2, 17, 21)
pixels inside S1: 300
mean NDVI of S1: 0.45
saved: clip_S1.tif

The clip is 17 rows by 21 columns: a box containing S1 (originally 15 rows × 20 columns of pixels), a bit bigger because the box edges are rounded outward (I did not check the exact cause). What really lies inside the plot: 300 pixels = 3 hectares, equal to the area of S1 in Chapter 38. Its mean NDVI is 0.45. Notice .to_crs(image.crs): the polygon must have the same CRS as the raster. If you forget:

08_wrong_crs_mask.py
# Chapter 39 - Trap: the land boundary has a different CRS from the raster
import geopandas as gpd
import rasterio
from rasterio.mask import mask

plots = gpd.read_file("data/plot.geojson")        # EPSG:4326 (degrees), forgot to_crs!
with rasterio.open("data/synthetic_image.tif") as image:    # raster EPSG:32748 (metres)
    print("raster CRS:", image.crs, "| vector CRS:", plots.crs)
    mask(image, plots.geometry, crop=True)
raster CRS: EPSG:32748 | vector CRS: EPSG:4326
Traceback (most recent call last):
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\mask.py", line 80, in raster_geometry_mask
    window = geometry_window(dataset, shapes, pad_x=pad_x, pad_y=pad_y)
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\features.py", line 522, in geometry_window
    bounding_window = bounding_window.intersection(raster_window)
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\windows.py", line 787, in intersection
    return intersection([self, other])
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\windows.py", line 125, in wrapper
    return function(*args[0])
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\windows.py", line 239, in intersection
    return functools.reduce(_intersection, windows)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\windows.py", line 257, in _intersection
    raise WindowError(f"Intersection is empty {w1} {w2}")
rasterio.errors.WindowError: Intersection is empty Window(col_off=-69990, row_off=922680, width=1, height=1) Window(col_off=0, row_off=0, width=100, height=80)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "08_wrong_crs_mask.py", line 9, in <module>
    mask(image, plots.geometry, crop=True)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\mask.py", line 180, in mask
    shape_mask, transform, window = raster_geometry_mask(
                                    ~~~~~~~~~~~~~~~~~~~~^
        dataset, shapes, all_touched=all_touched, invert=invert, crop=crop,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        pad=pad, pad_width=pad_width)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<folder-proyek>\.venv\Lib\site-packages\rasterio\mask.py", line 86, in raster_geometry_mask
    raise ValueError('Input shapes do not overlap raster.')
ValueError: Input shapes do not overlap raster.

Rasterio refuses with Input shapes do not overlap raster: degree coordinates (around 106) do not overlap metre coordinates (around 700000). Luckily there is an error here. Even without an error, a result like this should always be suspected.

Reproject and resample

Reproject changes a raster’s CRS; resample changes pixel size. Both force the computer to recalculate pixel values, and there is a choice of method: bilinear (a weighted average of neighbours, good for continuous data like NDVI), average, or nearest (copy the nearest neighbour, a must for class data such as a land-cover map, so no “class 1.5” appears).

05_reproject_resample.py
# Chapter 39 - Reproject (change the CRS) and resample (change the resolution)
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import calculate_default_transform, reproject

with rasterio.open("data/synthetic_image.tif") as image:
    nir = image.read(2)

    # Reproject: UTM (metres) -> WGS 84 (degrees)
    transform, width, height = calculate_default_transform(
        image.crs, "EPSG:4326", image.width, image.height, *image.bounds)
    result = np.zeros((height, width), dtype="float32")
    reproject(source=nir, destination=result, src_transform=image.transform, src_crs=image.crs,
              dst_transform=transform, dst_crs="EPSG:4326", resampling=Resampling.bilinear)
    print("before:", image.width, "x", image.height, "pixels, unit metres")
    print("after :", width, "x", height, "pixels, pixel size (degrees): %.6f" % transform.a)

    # Resample: read directly at 3x coarser resolution (30 m), values averaged
    coarse = image.read(2, out_shape=(image.height // 3, image.width // 3), resampling=Resampling.average)
    print("\nresolution 10 m :", nir.shape, "pixels")
    print("resolution 30 m :", coarse.shape, "pixels")
    print("mean NIR 10 m vs 30 m: %.4f vs %.4f" % (nir.mean(), coarse.mean()))
before: 100 x 80 pixels, unit metres
after : 100 x 80 pixels, pixel size (degrees): 0.000090

resolution 10 m : (80, 100) pixels
resolution 30 m : (26, 33) pixels
mean NIR 10 m vs 30 m: 0.2249 vs 0.2249

After reprojecting to EPSG:4326, the pixel size becomes about 0.00009 degrees (roughly 10 m). Going from 10 m to 30 m resolution gives a grid of 26 × 33 pixels (a third of the size, rounded down). The mean NIR before and after is the same (0.2249): Resampling.average preserves the mean.

Zonal statistics

Zonal statistics summarise raster values inside each zone (polygon): mean, smallest, largest. We make a mask for each polygon with geometry_mask, then calculate with NumPy.

06_zonal_statistics.py
# Chapter 39 - Zonal statistics: summarising raster values inside each polygon
import geopandas as gpd
import rasterio
from rasterio.features import geometry_mask

with rasterio.open("data/synthetic_image.tif") as image:
    red, nir = image.read(1), image.read(2)
    ndvi = (nir - red) / (nir + red)
    plots = gpd.read_file("data/plot.geojson").to_crs(image.crs)
    ponds = gpd.read_file("data/pond.geojson").to_crs(image.crs)

    def zonal(zones):
        result = []
        for _, z in zones.iterrows():
            # invert=True: True INSIDE the polygon, False outside
            inside = geometry_mask([z.geometry], out_shape=ndvi.shape, transform=image.transform, invert=True)
            values = ndvi[inside]
            result.append((z["code"], int(inside.sum()), values.mean(), values.min(), values.max()))
        return result

    print("zone     pixels  NDVI_mean  NDVI_min  NDVI_max")
    for code, n, mean, low, high in zonal(plots):
        print(f"plot {code:>2s}  {n:4d}  {mean:9.2f}  {low:8.2f}  {high:9.2f}")
    for code, n, mean, low, high in zonal(ponds):
        print(f"pond {code:>2s}  {n:4d}  {mean:9.2f}  {low:8.2f}  {high:9.2f}")
zone     pixels  NDVI_mean  NDVI_min  NDVI_max
plot H1   900       0.80      0.71       0.89
plot H2   695       0.78      0.67       0.88
plot K1   400       0.60      0.51       0.68
plot K2   300       0.55      0.45       0.63
plot S1   300       0.45      0.36       0.55
plot S2   225       0.70      0.62       0.76
pond  A    12      -0.20     -0.24      -0.15
pond  B    12      -0.21     -0.25      -0.15
pond  C    12      -0.20     -0.25      -0.14
pond  D    16      -0.19     -0.26      -0.14

Forest H1 and H2 are densely green (0.80 and 0.78), ricefield S1 is lowest (0.45; in this made-up data the value was deliberately set low, as if the rice were young), and all four ponds have negative NDVI (around −0.2): water. The pixel counts also match the areas: H1 900 pixels × 100 m² = 9 ha.

Running Project I5: NDVI per plot

The zonal result goes back into the plot table and gets flagged.

07_project_ndvi_plots.py
# Chapter 39 - Running project I5: NDVI per plot, saved to GeoPackage
from pathlib import Path

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import geometry_mask

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

with rasterio.open("data/synthetic_image.tif") as image:
    red, nir = image.read(1), image.read(2)
    ndvi = (nir - red) / (nir + red)
    plots = gpd.read_file("data/plot.geojson").to_crs(image.crs)
    mean = []
    for geom in plots.geometry:
        # invert=True: True INSIDE the polygon
        inside = geometry_mask([geom], out_shape=ndvi.shape, transform=image.transform, invert=True)
        mean.append(round(float(ndvi[inside].mean()), 2))

plots["ndvi_mean"] = mean
plots["status"] = np.where(plots["ndvi_mean"] < 0.5, "check it", "good")      # threshold 0.5 = example
plots["area_ha"] = (plots.area / 10_000).round(2)
print(plots[["code", "sector", "commodity", "area_ha", "ndvi_mean", "status"]].to_string(index=False))

plots.to_file(tmp / "plots_ndvi.gpkg", layer="plots_ndvi", driver="GPKG")
print("\nsaved: plots_ndvi.gpkg | columns:", list(gpd.read_file(tmp / "plots_ndvi.gpkg").columns))
code    sector commodity  area_ha  ndvi_mean   status
  H1    forest      Teak     9.00       0.80     good
  H2    forest   Meranti     6.93       0.78     good
  K1   orchard    coffee     4.00       0.60     good
  K2   orchard     cocoa     3.00       0.55     good
  S1 ricefield      rice     3.00       0.45 check it
  S2 ricefield      rice     2.25       0.70     good

saved: plots_ndvi.gpkg | columns: ['code', 'sector', 'commodity', 'ndvi_mean', 'status', 'area_ha', 'geometry']

Only S1 (0.45) has the status “check it” because its NDVI is below the 0.5 threshold, which here is just an example number. The table is saved to a GeoPackage: vector data and raster results finally come together. Remember that this image is made up; with a real image, NDVI should be compared with the planting season and field observation.

Exercises

  1. In 03_ndvi.py, change the class thresholds to [0.0, 0.2, 0.5] and watch the area of each class change.
  2. In 04_clip_to_boundary.py, clip plot K1 (the coffee orchard) and print its mean NDVI.
  3. In 06_zonal_statistics.py, add a standard deviation column (values.std()). Which plot is the most uniform?
  4. Challenge: make a column pixels_below_0_5 for each plot and show its percentage. Compare it with the “check it” status.

Chapter Summary

  • A raster = a grid of pixels; resolution = pixel size on the ground; a band = a layer of values.
  • Rasterio reads bands as NumPy arrays; xy and index move between pixels and coordinates.
  • NDVI = (NIR − red) / (NIR + red): water is negative, bare soil low, dense plants high.
  • mask clips with a polygon of the same CRS; reproject and resampling recalculate pixels (choose the method to suit the data type).
  • Zonal statistics summarise a raster per polygon; the result can be joined to a vector table.

Next, in Chapter 40: static and interactive maps, PostGIS, and driving QGIS from Python.