Why Are My Land Cover Areas Wrong in Degrees?

On this page

Problem statement

ESA WorldCover is "10 m land cover", so the area of a class must be its pixel count times 100 m². For Oslo that gives the municipality an area of 1,045 km². Its boundary encloses 452 km². The map is not in metres: WorldCover pixels are 1/12,000 of a degree in each direction, which is about 9.3 m north–south everywhere, but east–west shrinks with the cosine of latitude. At Oslo's 60° N a pixel covers about 43 m², not 100.

The same trap applies to any land cover or raster product in a geographic CRS (EPSG:4326): Dynamic World exports, many global forest and water products, climate grids. This page measures the error for Oslo and shows two correct ways to compute class areas.

Quick answer

Give every pixel row its true area on the ellipsoid, then sum by class:

lat_top = TRANSFORM.f + np.arange(WC.shape[0]) * TRANSFORM.e          # latitude of each row's top edge
res = TRANSFORM.a
row_m2 = np.array([abs(GEOD.polygon_area_perimeter([0, res, res, 0], [la, la, la - res, la - res])[0]) for la in lat_top])
pix_m2 = np.broadcast_to(row_m2[:, None], WC.shape)
naive = (WC > 0).sum() * 100 / 1e6
true = pix_m2[WC > 0].sum() / 1e6
print(f"Oslo: {naive:,.0f} km² as count × 100 m², {true:,.0f} km² with true pixel areas ({naive / true:.2f}x too large)")
print(f"a pixel here covers {row_m2.min():.1f}–{row_m2.max():.1f} m²")
Oslo: 1,045 km² as count × 100 m², 451 km² with true pixel areas (2.32x too large)
a pixel here covers 43.0–43.4 m²
Line chart of how many times pixel count times 100 square metres overstates the true area of a WorldCover pixel, against latitude: 1.17 at the equator near Nairobi, 1.35 at Cairo, 1.77 at Paris, 2.31 at Oslo and 3.32 at Tromsø, rising steeply towards the poles. Line chart of how many times pixel count times 100 square metres overstates the true area of a WorldCover pixel, against latitude: 1.17 at the equator near Nairobi, 1.35 at Cairo, 1.77 at Paris, 2.31 at Oslo and 3.32 at Tromsø, rising steeply towards the poles.
The error is never zero and grows with latitude: at Oslo the naive area is more than twice the truth.

Check CRS and pixel size, see the latitude effect, reproject

1. Check the CRS and the pixel size

with rasterio.open("data/oslo_worldcover.tif") as src:
    print(src.crs, f"pixel {src.res[0]:.10f}° x {src.res[1]:.10f}°")
lat = 60.0
ns = GEOD.inv(10, lat, 10, lat + res)[2]; ew = GEOD.inv(10, lat, 10 + res, lat)[2]
print(f"at {lat}° N one pixel is {ns:.2f} m north–south and {ew:.2f} m east–west")
EPSG:4326 pixel 0.0000833333° x 0.0000833333°
at 60.0° N one pixel is 9.28 m north–south and 4.65 m east–west

2. See how the error grows with latitude

for la in (0, 30, 45, 60, 70):
    a = abs(GEOD.polygon_area_perimeter([0, res, res, 0], [la, la, la + res, la + res])[0])
    print(f"{la:2d}°: true pixel area {a:6.1f} m²; count × 100 m² overstates by {100 / a:.2f}x")
 0°: true pixel area   85.5 m²; count × 100 m² overstates by 1.17x
30°: true pixel area   74.3 m²; count × 100 m² overstates by 1.35x
45°: true pixel area   60.8 m²; count × 100 m² overstates by 1.64x
60°: true pixel area   43.2 m²; count × 100 m² overstates by 2.32x
70°: true pixel area   29.6 m²; count × 100 m² overstates by 3.38x

Even at the equator the pixel is about 85 m², not 100, because 1/12,000 of a degree is 9.3 m, not 10.

Four rectangles drawn to scale against a dashed 10 by 10 metre square: one WorldCover pixel is 9.28 by 9.21 metres at the equator, 85.5 square metres; 8.04 by 9.24 metres at 30 degrees; 6.57 by 9.26 metres at 45 degrees; and 4.65 by 9.28 metres at 60 degrees, 43.2 square metres. Four rectangles drawn to scale against a dashed 10 by 10 metre square: one WorldCover pixel is 9.28 by 9.21 metres at the equator, 85.5 square metres; 8.04 by 9.24 metres at 30 degrees; 6.57 by 9.26 metres at 45 degrees; and 4.65 by 9.28 metres at 60 degrees, 43.2 square metres.
A pixel of fixed angular size narrows east–west towards the poles.

3. Or reproject to an equal-area CRS, with nearest neighbour

Reprojecting to a metric CRS gives pixels of known size. It must use nearest-neighbour resampling, so no new class values appear:

dst_t, w, h = calculate_default_transform("EPSG:4326", "EPSG:25833", WC.shape[1], WC.shape[0], *array_bounds(*WC.shape, TRANSFORM), resolution=10)
utm = np.zeros((h, w), np.uint8)
reproject(WC, utm, src_transform=TRANSFORM, src_crs="EPSG:4326", dst_transform=dst_t, dst_crs="EPSG:25833", resampling=Resampling.nearest)
print(f"UTM 33N, 10 m pixels: {(utm > 0).sum() * 100 / 1e6:,.0f} km²; new values created: {sorted(set(np.unique(utm)) - set(np.unique(WC)))}")
UTM 33N, 10 m pixels: 452 km²; new values created: []

UTM zone 33 is not strictly equal-area, but within a zone its area distortion is a fraction of a per cent, well below the classification's own error.

Class areas, and why shares survive

The data used on this page

ESA WorldCover 2021 v200 (tiles N57E009 and N60E009 from the public esa-worldcover S3 bucket) and the municipality of Oslo from geoBoundaries Norway ADM2 (Geonorge, CC BY 4.0). Pixel areas are computed on the WGS 84 ellipsoid with pyproj.Geod.

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import rasterio.mask
import requests
from pyproj import Geod
from rasterio.merge import merge
from rasterio.transform import array_bounds
from rasterio.warp import Resampling, calculate_default_transform, reproject

Path("data").mkdir(exist_ok=True)
BUCKET = "https://esa-worldcover.s3.eu-central-1.amazonaws.com"
F = Path("data/nor_adm2.geojson")
if not F.exists():
    F.write_bytes(requests.get("https://github.com/wmgeolab/geoBoundaries/raw/9469f09/releaseData/gbOpen/NOR/ADM2/geoBoundaries-NOR-ADM2.geojson", timeout=300).content)
adm2 = gpd.read_file(F)
OSLO = adm2[adm2.shapeName.str.startswith("Oslo")]
if not Path("data/oslo_worldcover.tif").exists():
    srcs = [rasterio.open(f"/vsicurl/{BUCKET}/v200/2021/map/ESA_WorldCover_10m_2021_v200_{n}_Map.tif") for n in ("N57E009", "N60E009")]
    mosaic, tr = merge(srcs, bounds=tuple(OSLO.total_bounds))
    with rasterio.open("data/oslo_worldcover.tif", "w", driver="GTiff", height=mosaic.shape[1], width=mosaic.shape[2], count=1,
                       dtype="uint8", crs="EPSG:4326", transform=tr, nodata=0, compress="deflate") as dst:
        dst.write(mosaic)
with rasterio.open("data/oslo_worldcover.tif") as src:
    wc, TRANSFORM = rasterio.mask.mask(src, OSLO.geometry, crop=True, nodata=0)
WC = wc[0]
GEOD = Geod(ellps="WGS84")
WC_CLASSES = {10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland", 50: "Built-up", 60: "Bare/sparse vegetation",
              70: "Snow and ice", 80: "Permanent water bodies", 90: "Herbaceous wetland", 95: "Mangroves", 100: "Moss and lichen"}
print(f"Oslo boundary: {OSLO.to_crs(25833).area.sum() / 1e6:,.0f} km² in UTM 33N; WorldCover window {WC.shape[1]} x {WC.shape[0]} pixels")
Oslo boundary: 452 km² in UTM 33N; WorldCover window 5547 x 3910 pixels

Example 1: class areas, and why shares survive

classes = sorted(set(np.unique(WC)) - {0})
t = pd.DataFrame({"naive km²": [(WC == c).sum() * 100 / 1e6 for c in classes],
                  "true km²": [pix_m2[WC == c].sum() / 1e6 for c in classes]}, index=[WC_CLASSES[c] for c in classes])
t["share (count)"] = t["naive km²"] / t["naive km²"].sum() * 100
t["share (area)"] = t["true km²"] / t["true km²"].sum() * 100
print(t.round(1).sort_values("true km²", ascending=False).to_string())
                        naive km²  true km²  share (count)  share (area)
Tree cover                  781.3     337.4           74.8          74.8
Built-up                    144.0      62.3           13.8          13.8
Permanent water bodies       59.6      25.7            5.7           5.7
Grassland                    42.3      18.3            4.0           4.0
Cropland                     15.8       6.8            1.5           1.5
Bare/sparse vegetation        1.2       0.5            0.1           0.1
Herbaceous wetland            0.7       0.3            0.1           0.1

Within one city, pixel areas barely vary, so class shares come out the same either way. Areas in km² do not, and shares do not either over a region spanning many degrees of latitude.

Angular units, uneven distances

A geographic CRS has angular units. A pixel of a fixed angle covers a fixed distance north–south (a degree of latitude is about 111 km everywhere) but an east–west distance proportional to the cosine of latitude. "10 m" in a product's name describes its resolution near the equator, roughly; it is not a pixel area. Counting pixels and multiplying by a nominal area is only right in a projected, equal-area CRS.

The area of Oslo from ESA WorldCover pixels, computed three ways. Oslo from WorldCover pixels, three waysmethodareaverdictpixel count × 100 m²1,045 km²2.32x too large× true area per row (Geod)451 km²correctreproject nearest to UTM 33N452 km²correctboundary polygon, UTM 33N452 km²referenceAt 60° N a 1/12,000° pixel covers about 43 m² spatialworkflow.io
Three ways to compute an area from WorldCover pixels.

The correction is cheap because pixel area depends only on the row. One ellipsoidal area per row, broadcast across the columns, weights every pixel exactly; this is the approach used for global land cover statistics. Reprojecting is the alternative when the data are needed in metres anyway, provided the resampling is nearest neighbour and the CRS is suitable for the region.

Sphere versus ellipsoid and global sums

Land cover area in degrees: problems and fixes. area in degrees problemsSymptomFixarea 2.3 times too large at 60° Nweight each row by its true pixelarea17% too large at the equator1/12,000° is not 10 mnew classes after reprojectionnearest-neighbour resampling onlyregional shares skewedarea-weight shares over widelatitudesESA WorldCover 2021, Oslo spatialworkflow.io
Area in degrees problems and fixes.
  • Sphere vs ellipsoid: the spherical formula R² Δλ (sin φ₁ − sin φ₂) is a close approximation; pyproj.Geod gives the ellipsoidal area at little extra cost.
  • Equator: even there a pixel is about 9.2 m north–south by 9.3 m east–west, 85.5 m², so count × 100 m² overstates by 17%.
  • Other "10 m" products: Sentinel-2 bands in their UTM CRS really are 10 m pixels; only products delivered in EPSG:4326 need the correction.
  • Masks: the boundary mask keeps whole pixels by their centre; that error is tiny next to the latitude error.
  • Global sums: summing pixel counts across latitudes mixes pixel sizes from 85 m² to almost nothing near the poles.

FAQ

Why is the area of my WorldCover class too large?

WorldCover pixels are 1/12,000 of a degree, not 10 m squares. Away from the equator they cover much less than 100 m²: about 43 m² at 60° N.

How do I calculate land cover area from a raster in EPSG:4326?

Compute the true area of one pixel per row with pyproj.Geod, multiply each pixel by its row's area, and sum by class; or reproject to an equal-area CRS with nearest-neighbour resampling.

Are class percentages wrong too?

Within a small area, hardly: all pixels have nearly the same area. Over many degrees of latitude, yes.

What is the pixel size of ESA WorldCover?

1/12,000 of a degree (0.0000833°), about 9.3 m north–south and 9.3 m × cos(latitude) east–west.