Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Understanding Agricultural Land Cover Changes

This tutorial helps you understand agricultural land cover data using two complementary datasets: CORINE land cover and satellite-based land cover. You’ll explore and compare these datasets to understand their characteristics, differences, and temporal dynamics.

We’ll work with:

  • CORINE Land Cover: European land cover maps (1990, 2000, 2006, 2012, 2018) with detailed classification

  • Satellite Land Cover: Global satellite-based land cover (1992-2022) at 300m resolution

For more detail on what these datasets are, why they differ, and where they come from, see the Agricultural Land reference page.

Prerequisites: We’ll use preprocessed data for the region “Central Greece” (NUTS2 region “EL64”). If you change the region, please download and process the agricultural land data for your region using the how-to guides:

What you’ll learn:

  • Load and visualize agricultural land timeseries from CSV files

  • Explore spatial agricultural land distributions from raster data

  • Compare different land cover datasets (CORINE vs. satellite)

  • Understand temporal patterns in agricultural land changes

  • Create interactive maps of land cover

Setup

User settings

admin_id = "EL64"  # Central Greece

Load libraries

import xarray as xr
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import geopandas as gpd
import regionmask
from pathlib import Path
import rioxarray as rxr
import os

# Set up data directories
data_dir = Path("../data")
corine_output_dir = data_dir / admin_id / "corine_land_cover"
satellite_output_dir = data_dir / admin_id / "satellite_land_cover"

print(f"\nData directories:")
print(f"  CORINE: {corine_output_dir}")
print(f"  Satellite: {satellite_output_dir}")

Data directories:
  CORINE: ../data/EL64/corine_land_cover
  Satellite: ../data/EL64/satellite_land_cover

Load region shapefile

# Read NUTS shapefiles
regions_dir = data_dir / 'regions'
nuts_shp = regions_dir / 'NUTS_RG_20M_2024_4326' / 'NUTS_RG_20M_2024_4326.shp'
nuts_gdf = gpd.read_file(nuts_shp)

# Select the region of interest
sel_gdf = nuts_gdf[nuts_gdf['NUTS_ID'] == admin_id]
print(f"Region: {sel_gdf['NUTS_NAME'].values[0]} ({admin_id})")
print(f"Country: {sel_gdf['CNTR_CODE'].values[0]}")
lon_min, lat_min, lon_max, lat_max = sel_gdf.geometry.total_bounds

# Create a regionmask from the admin region geometry
admin_mask = regionmask.from_geopandas(sel_gdf, names='NUTS_ID')
Region: Στερεά Ελλάδα (EL64)
Country: EL

Load Regional Agricultural Land Timeseries

First, let’s load the processed CSV files created by the how-to guides.

Load CORINE land cover data

Two CSV files are loaded: one with aggregated land cover percentages by main category (agricultural, forest, urban, water), and one with a finer breakdown of agricultural sub-types. Both are produced by the CORINE how-to guide.

# Load CORINE CSV files
csv_corine_categories = corine_output_dir / "land_cover_categories.csv"
csv_corine_agri = corine_output_dir / "agricultural_land_breakdown.csv"

# Read the data
df_corine_categories = pd.read_csv(csv_corine_categories)
df_corine_agri = pd.read_csv(csv_corine_agri)

print("CORINE land cover categories:")
print(f"  Years: {df_corine_categories['year'].min():.0f} - {df_corine_categories['year'].max():.0f}")
print(f"  Records: {len(df_corine_categories)}")
print(f"\nMain categories sample:")
print(df_corine_categories.head())
print(f"\nAgricultural breakdown sample:")
print(df_corine_agri.head())
CORINE land cover categories:
  Years: 1990 - 2018
  Records: 5

Main categories sample:
   year  agricultural     forest     urban     water
0  1990     33.279219  62.715590  1.252794  1.067083
1  2000     33.257153  62.504301  1.510422  1.055861
2  2006     32.446728  62.442879  2.181934  1.109056
3  2012     31.755714  62.452383  2.562456  1.105405
4  2018     31.745346  62.425693  2.620222  1.105405

Agricultural breakdown sample:
   year  Non-irrigated arable  Permanently irrigated  Rice fields  Vineyards  \
0  1990              9.675677               3.523386     0.103912   0.095955   
1  2000              9.678976               3.548181     0.072627   0.097757   
2  2006              5.558817               7.323455     0.230085   0.091398   
3  2012              5.307652               7.742775     0.228292   0.098860   
4  2018              5.304568               7.733826     0.228292   0.098860   

   Fruit trees  Olive groves  Pastures  Annual+permanent crops  \
0     0.163262      4.380540  0.291327                0.104831   
1     0.163391      4.374450  0.288768                0.104831   
2     0.144353      4.734827  0.349343                0.104831   
3     0.143172      5.271346  0.368523                0.100512   
4     0.144966      5.271346  0.369299                0.100512   

   Complex cultivation  Agriculture+natural  Agro-forestry  
0             6.282549             8.657781            0.0  
1             6.244557             8.683615            0.0  
2             5.813614             8.096005            0.0  
3             5.582367             6.912215            0.0  
4             5.582367             6.911311            0.0  

Load satellite land cover data

The satellite CSV contains an annual agricultural fraction (0–1) for the region. We convert it to a percentage here so it is directly comparable to the CORINE values. The file is produced by the satellite land cover how-to guide.

# Load satellite CSV file
csv_satellite = satellite_output_dir / f"agricultural_land_fraction_{admin_id}.csv"

# Read the data
df_satellite = pd.read_csv(csv_satellite)
# Convert fraction to percentage for easier comparison
df_satellite['agricultural'] = df_satellite['ag_fraction'] * 100

print("Satellite land cover data:")
print(f"  Years: {df_satellite['year'].min():.0f} - {df_satellite['year'].max():.0f}")
print(f"  Records: {len(df_satellite)}")
print(f"\nSample:")
print(df_satellite.head())
Satellite land cover data:
  Years: 1992 - 2022
  Records: 31

Sample:
   year  ag_fraction  agricultural
0  1992     0.304581     30.458114
1  1993     0.304572     30.457187
2  1994     0.304567     30.456723
3  1995     0.301157     30.115664
4  1996     0.298070     29.807042

Compare Agricultural Land Timeseries

Let’s visualize and compare the two datasets to understand their differences and similarities.

Visualize both datasets

The plot below overlays agricultural land coverage (as a percentage of the total region area) from CORINE and the satellite product. CORINE has five snapshots; the satellite dataset provides annual coverage from 1992 to 2022.

fig, ax = plt.subplots(figsize=(14, 6))

# Plot CORINE agricultural land
ax.plot(df_corine_categories['year'], df_corine_categories['agricultural'], 
        marker='o', linewidth=2.5, markersize=10, label='CORINE Land Cover', 
        color='#8B4513', linestyle='-', alpha=0.8)

# Plot satellite agricultural land
ax.plot(df_satellite['year'], df_satellite['agricultural'], 
        marker='s', linewidth=2, markersize=6, label='Satellite Land Cover', 
        color='#228B22', linestyle='--', alpha=0.8)

ax.set_xlabel('Year', fontsize=12)
ax.set_ylabel('Agricultural Land Coverage (%)', fontsize=12)
ax.set_title(f'Agricultural Land Change in {admin_id} - Dataset Comparison', fontsize=14, fontweight='bold')
ax.legend(fontsize=11, loc='upper left')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
<Figure size 1400x600 with 1 Axes>

Figure: Agricultural land coverage (% of region) in Central Greece (EL64) over time. Brown markers show the five CORINE snapshots; green markers show the annual satellite-derived estimate. Both datasets capture the overall trend, but may differ in absolute values due to different classification methodologies.

Quantify differences

For overlapping years, let’s calculate the differences between the two datasets.

# Find overlapping years (CORINE years within satellite range)
corine_years = df_corine_categories['year'].values
satellite_years = df_satellite['year'].values

# Get closest satellite values for each CORINE year
comparison_data = []
for corine_year in corine_years:
    # Find exact match or closest year in satellite data
    if corine_year in satellite_years:
        sat_val = df_satellite[df_satellite['year'] == corine_year]['agricultural'].values[0]
        cor_val = df_corine_categories[df_corine_categories['year'] == corine_year]['agricultural'].values[0]
        
        comparison_data.append({
            'year': corine_year,
            'corine': cor_val,
            'satellite': sat_val,
            'difference': sat_val - cor_val,
            'percent_diff': ((sat_val - cor_val) / cor_val) * 100
        })

df_comparison = pd.DataFrame(comparison_data)

print("Dataset Comparison (overlapping years):")
print("="*90)
print(f"{'Year':<8} {'CORINE (%)':>12} {'Satellite (%)':>14} {'Diff (pp)':>12} {'Diff (%)':>12}")
print("-"*90)

for _, row in df_comparison.iterrows():
    print(f"{row['year']:<8.0f} {row['corine']:>12.2f} {row['satellite']:>14.2f} "
          f"{row['difference']:>12.2f} {row['percent_diff']:>11.2f}%")

print("\nSummary Statistics:")
print(f"  Mean absolute difference: {df_comparison['difference'].abs().mean():.2f} percentage points")
print(f"  Mean relative difference: {df_comparison['percent_diff'].abs().mean():.2f}%")
print(f"  Max difference: {df_comparison['difference'].abs().max():.2f} pp in {df_comparison.loc[df_comparison['difference'].abs().idxmax(), 'year']:.0f}")
Dataset Comparison (overlapping years):
==========================================================================================
Year       CORINE (%)  Satellite (%)    Diff (pp)     Diff (%)
------------------------------------------------------------------------------------------
2000            33.26          28.46        -4.80      -14.43%
2006            32.45          28.14        -4.31      -13.28%
2012            31.76          27.85        -3.90      -12.28%
2018            31.75          27.76        -3.99      -12.57%

Summary Statistics:
  Mean absolute difference: 4.25 percentage points
  Mean relative difference: 13.14%
  Max difference: 4.80 pp in 2000

The table above quantifies the agreement between datasets for the years that both cover. Larger percentage differences in particular years can reflect genuine changes in the agricultural boundary definition or classification thresholds rather than real land-use change.

Explore CORINE Land Cover Categories

CORINE provides detailed information about different land cover types beyond just agricultural land.

Plot all land cover categories

Beyond agricultural land, CORINE tracks forests, urban areas, and water bodies. Plotting all categories together shows how land cover has shifted over the five CORINE epochs and puts agricultural changes in context.

fig, ax = plt.subplots(figsize=(14, 6))

ax.plot(df_corine_categories['year'], df_corine_categories['agricultural'], 
        marker='o', linewidth=2.5, markersize=8, label='Agricultural', color='#8B4513')
ax.plot(df_corine_categories['year'], df_corine_categories['forest'], 
        marker='s', linewidth=2.5, markersize=8, label='Forest', color='#228B22')
ax.plot(df_corine_categories['year'], df_corine_categories['urban'], 
        marker='^', linewidth=2.5, markersize=8, label='Urban', color='#808080')
ax.plot(df_corine_categories['year'], df_corine_categories['water'], 
        marker='d', linewidth=2.5, markersize=8, label='Water', color='#4169E1')

ax.set_xlabel('Year', fontsize=12)
ax.set_ylabel('Land Cover (%)', fontsize=12)
ax.set_title(f'Land Cover Change Over Time in {admin_id}', fontsize=14, fontweight='bold')
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Print trends
print("Land Cover Trends (1990 to 2018):")
for category in ['agricultural', 'forest', 'urban', 'water']:
    start_val = df_corine_categories[category].iloc[0]
    end_val = df_corine_categories[category].iloc[-1]
    change = end_val - start_val
    print(f"  {category.capitalize()}: {start_val:.2f}% → {end_val:.2f}% (change: {change:+.2f} pp)")
<Figure size 1400x600 with 1 Axes>
Land Cover Trends (1990 to 2018):
  Agricultural: 33.28% → 31.75% (change: -1.53 pp)
  Forest: 62.72% → 62.43% (change: -0.29 pp)
  Urban: 1.25% → 2.62% (change: +1.37 pp)
  Water: 1.07% → 1.11% (change: +0.04 pp)

Figure: CORINE land cover categories for Central Greece (EL64) across the five available years (1990–2018). The percentage change between the first and last year is printed below the figure.

Detailed agricultural land breakdown

CORINE distinguishes up to 11 agricultural sub-types (arable land, vineyards, olive groves, pastures, etc.). The stacked area chart shows the relative contribution of each sub-type and how the composition has changed over time.

# Stacked area plot of agricultural land types
fig, ax = plt.subplots(figsize=(14, 7))

# Prepare data for stacking
agri_cols = [col for col in df_corine_agri.columns if col != 'year']
years = df_corine_agri['year'].values

# Create stacked area plot
ax.stackplot(years, 
             *[df_corine_agri[col].values for col in agri_cols],
             labels=agri_cols,
             alpha=0.8)

ax.set_xlabel('Year', fontsize=12)
ax.set_ylabel('Agricultural Land Coverage (%)', fontsize=12)
ax.set_title(f'Agricultural Land Types Over Time in {admin_id}', fontsize=14, fontweight='bold')
ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=9)
ax.grid(True, alpha=0.3, axis='y')

plt.tight_layout()
plt.show()

# Print summary of agricultural types
print("\nAgricultural land types (2018):")
latest_agri = df_corine_agri.iloc[-1]
for col in agri_cols:
    val = latest_agri[col]
    if val > 0.5:  # Only show significant types
        print(f"  {col}: {val:.2f}%")
<Figure size 1400x700 with 1 Axes>

Agricultural land types (2018):
  Non-irrigated arable: 5.30%
  Permanently irrigated: 7.73%
  Olive groves: 5.27%
  Complex cultivation: 5.58%
  Agriculture+natural: 6.91%

Figure: Stacked area chart of CORINE agricultural sub-types for Central Greece (EL64), 1990–2018. Each coloured band represents one agricultural class; the total height is the overall agricultural fraction. The dominant sub-types for the latest year are printed below.

Interactive Map of CORINE Land Cover

To understand the spatial distribution of agricultural land, we load the CORINE raster for the latest year (2018) and render it as an interactive map. Agricultural pixels (CORINE classes 12–22) are highlighted with a heatmap overlay; the underlying basemap can be toggled between OpenStreetMap, satellite imagery, and topography.

# Install folium if not available
try:
    import folium
    from folium import plugins
except ImportError:
    !pip install folium
    import folium
    from folium import plugins
# Load CORINE land cover raster (latest year)
corine_tif = corine_output_dir / f"corine_landcover_{admin_id}.tif"
corine_raster = rxr.open_rasterio(corine_tif)
corine_raster = corine_raster.squeeze()

print(f"Loaded CORINE raster: {corine_tif.name}")
print(f"Shape: {corine_raster.shape}")
print(f"Coordinate ranges: lon=[{float(corine_raster.x.min()):.2f}, {float(corine_raster.x.max()):.2f}], lat=[{float(corine_raster.y.min()):.2f}, {float(corine_raster.y.max()):.2f}]")
print(f"Unique classes present: {np.unique(corine_raster.values[~np.isnan(corine_raster.values)])}")
Loaded CORINE raster: corine_landcover_EL64.tif
Shape: (1650, 3105)
Coordinate ranges: lon=[21.26, 24.79], lat=[37.93, 39.43]
Unique classes present: [ 1.  2.  3.  4.  5.  6.  7.  8.  9. 10. 11. 12. 13. 14. 15. 16. 17. 18.
 19. 20. 21. 23. 24. 25. 26. 27. 28. 29. 30. 31. 32. 35. 37. 40. 41. 42.
 43. 44.]
# Define CORINE class names and colors
corine_classes = {
    1: ('Continuous urban fabric', '#E6004D'),
    2: ('Discontinuous urban fabric', '#FF0000'),
    3: ('Industrial or commercial units', '#CC4DF2'),
    4: ('Road and rail networks', '#CC0000'),
    5: ('Port areas', '#E6CCCC'),
    6: ('Airports', '#E6CCE6'),
    7: ('Mineral extraction sites', '#A600CC'),
    8: ('Dump sites', '#A64DCC'),
    9: ('Construction sites', '#FF4DFF'),
    10: ('Green urban areas', '#FFA6FF'),
    11: ('Sport and leisure facilities', '#FFE6FF'),
    12: ('Non-irrigated arable land', '#FFFFA8'),
    13: ('Permanently irrigated land', '#FFFF00'),
    14: ('Rice fields', '#E6E600'),
    15: ('Vineyards', '#E68000'),
    16: ('Fruit trees and berry plantations', '#F2A64D'),
    17: ('Olive groves', '#E6A600'),
    18: ('Pastures', '#E6E64D'),
    19: ('Annual crops associated with permanent crops', '#FFE6A6'),
    20: ('Complex cultivation patterns', '#FFE6CC'),
    21: ('Land principally occupied by agriculture', '#F2CC80'),
    22: ('Agro-forestry areas', '#80FF00'),
    23: ('Broad-leaved forest', '#00A600'),
    24: ('Coniferous forest', '#4DFF00'),
    25: ('Mixed forest', '#CCF24D'),
    26: ('Natural grasslands', '#A6FF80'),
    27: ('Moors and heathland', '#A6E64D'),
    28: ('Sclerophyllous vegetation', '#A6F200'),
    29: ('Transitional woodland-shrub', '#00CCF2'),
    30: ('Beaches, dunes, sands', '#CCE6FF'),
    31: ('Bare rocks', '#4D4DFF'),
    32: ('Sparsely vegetated areas', '#CCCCCC'),
    33: ('Burnt areas', '#000000'),
    34: ('Glaciers and perpetual snow', '#4DFF4D'),
    35: ('Inland marshes', '#00CCF2'),
    36: ('Peat bogs', '#80F2E6'),
    37: ('Salt marshes', '#00FFA6'),
    38: ('Salines', '#A6FFE6'),
    39: ('Intertidal flats', '#E6F2FF'),
    40: ('Water courses', '#00CCF2'),
    41: ('Water bodies', '#80F2E6'),
    42: ('Coastal lagoons', '#00FFA6'),
    43: ('Estuaries', '#A6FFE6'),
    44: ('Sea and ocean', '#E6F2FF')
}

# Calculate center of region
center_lat = (lat_min + lat_max) / 2
center_lon = (lon_min + lon_max) / 2

# Create base map
m = folium.Map(
    location=[center_lat, center_lon],
    zoom_start=9,
    tiles=None
)

# Add basemap options
folium.TileLayer('OpenStreetMap', name='OpenStreetMap').add_to(m)
folium.TileLayer(
    tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
    attr='Esri World Imagery',
    name='Satellite',
    overlay=False,
    control=True
).add_to(m)
folium.TileLayer(
    tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
    attr='Esri World Topo',
    name='Topographic',
    overlay=False,
    control=True
).add_to(m)

# Add region boundary
folium.GeoJson(
    sel_gdf.geometry,
    name=f'Region {admin_id}',
    style_function=lambda x: {
        'fillColor': 'none',
        'color': 'red',
        'weight': 3,
        'fillOpacity': 0
    }
).add_to(m)

# Create a simplified overlay showing agricultural vs non-agricultural land
# Agricultural classes are 12-22
corine_array = corine_raster.values
y_coords = corine_raster.y.values
x_coords = corine_raster.x.values

# Create points for agricultural land (green) and other land (various colors)
agri_points = []
other_points = []

# Sample the data for performance (every 5th point)
step = 5
for i in range(0, len(y_coords), step):
    for j in range(0, len(x_coords), step):
        val = corine_array[i, j]
        if not np.isnan(val):
            val_int = int(val)
            if 12 <= val_int <= 22:  # Agricultural
                agri_points.append([y_coords[i], x_coords[j]])
            else:
                other_points.append([y_coords[i], x_coords[j]])

# Add heatmaps for agricultural land
if len(agri_points) > 0:
    plugins.HeatMap(
        agri_points,
        name='Agricultural Land',
        min_opacity=0.4,
        max_zoom=13,
        radius=12,
        blur=15,
        gradient={0.4: 'yellow', 0.6: 'orange', 0.8: '#8B4513', 1.0: '#654321'}
    ).add_to(m)

# Add layer control
folium.LayerControl().add_to(m)

# Add title
title_html = f'''
<div style="position: fixed; 
     top: 10px; left: 50px; width: 450px; height: 70px; 
     background-color: white; border:2px solid grey; z-index:9999; 
     font-size:16px; padding: 10px; box-shadow: 2px 2px 6px rgba(0,0,0,0.3);
     border-radius: 5px;">
     <b>CORINE Land Cover - Agricultural Land Distribution</b><br>
     <span style="font-size:14px;">{admin_id} - Latest available (2018)</span><br>
     <i style="font-size:12px; color:#666;">Toggle layers and zoom to explore</i>
</div>
'''
m.get_root().html.add_child(folium.Element(title_html))

# Display map
m
Loading...

Figure: Interactive heatmap of CORINE agricultural land for Central Greece (EL64) in 2018. The orange–brown heatmap highlights pixels classified as agricultural (CORINE classes 12–22). Use the layer control (top right) to switch basemaps and zoom in to explore local patterns.

Key Takeaways

What have we learned about agricultural land cover data?

  1. Temporal coverage differs: CORINE provides five high-quality snapshots (1990–2018); the satellite product gives annual values from 1992 to 2022.

  2. Classification detail differs: CORINE distinguishes up to 11 agricultural sub-types; the satellite product reports a single agricultural fraction.

  3. Both datasets agree on the trend, but absolute values can differ by several percentage points — neither is simply “more correct”; the choice depends on your use case.

  4. Spatial patterns matter: the interactive map shows that agricultural land is not uniformly distributed — drought exposure will vary across the region depending on where crops are located.

Next step: see this data in action! The Agricultural land exposed to prolonged droughts tutorial combines the agricultural land data explored here with drought hazard projections to estimate how much cropland is exposed to drought under different climate scenarios.