SAVI vs NDVI vs EVI: Which Vegetation Index Should Agricultural Scientists Use?

SAVI vs NDVI vs EVI — Which Vegetation Index for Agricultural Research?

Introduction: The Index Problem Nobody Talks About

In Part 1 of this series, I showed you how to calculate NDVI with Python. NDVI is the most widely used vegetation index in the world — and for good reason. It works well in most situations.

But NDVI has a problem. Actually, two problems.

Problem 1: In areas with sparse vegetation — early crop season, semi-arid regions, or degraded lands — NDVI gets confused by soil brightness. A bright sandy soil can give NDVI values similar to a moderately healthy crop. Your crop health map becomes unreliable.

Problem 2: In areas with very dense vegetation — mature paddy at peak season, dense forest cover — NDVI saturates. Values pile up near 0.8–0.9 regardless of actual differences in canopy health. You lose the ability to distinguish between a good crop and an excellent crop.

Two researchers developed solutions:

  • SAVI (Soil Adjusted Vegetation Index) — solves Problem 1
  • EVI (Enhanced Vegetation Index) — solves both problems

In this post I will explain all three indices, show you exactly when to use each one, and give you complete Python code to calculate all three from Sentinel-2 data.

By the end you will know:

  • The mathematical difference between NDVI, SAVI, and EVI
  • Which index to use for which agricultural situation in India
  • How to calculate all three in Python with real Sentinel-2 data
  • How to compare them visually side by side
  • The practical decision framework for choosing your index

Quick Reference: The Three Indices at a Glance

Before going deep — here is the summary table for those who want the answer fast:

IndexFormulaBest ForAvoid When
NDVI(NIR – Red) / (NIR + Red)General crop monitoring, established canopySparse vegetation, very dense canopy
SAVI(NIR – Red) / (NIR + Red + L) × (1 + L)Sparse vegetation, early season, dryland farming, semi-arid areasDense canopy (use EVI instead)
EVIG × (NIR – Red) / (NIR + C1×Red – C2×Blue + L)Dense canopy, areas with atmospheric haze, high biomassSimple situations (NDVI is easier)

Understanding NDVI — And Its Limits

We covered NDVI in detail in Part 1. To summarise:

NDVI = (NIR - Red) / (NIR + Red)

What makes NDVI work: Healthy vegetation absorbs red light for photosynthesis and reflects near-infrared strongly. This contrast gives NDVI its signal.

What breaks NDVI:

Limitation 1 — Soil Brightness Effect

In early crop season or sparse canopy situations, a significant portion of what the satellite sees is bare soil between plants — not the plants themselves. Bright soils (sandy soils, dry soils) reflect both red and NIR, pushing NDVI values up artificially. Dark soils do the opposite.

This is a real problem in Indian dryland agriculture — Rajasthan, Maharashtra’s Vidarbha, parts of Karnataka — where soils are bright and canopy cover during early season is low.

Result: Your NDVI map shows moderately healthy vegetation when the ground truth is mostly soil with scattered seedlings.

Limitation 2 — Saturation at High Biomass

At leaf area index (LAI) above approximately 3, NDVI stops responding to further increases in vegetation density. Values cluster between 0.80 and 0.90 regardless of whether you are looking at average paddy or exceptional paddy.

Result: You cannot distinguish high-performing fields from very high-performing fields during peak season.


SAVI — Soil Adjusted Vegetation Index

SAVI was developed by Huete (1988) specifically to address the soil brightness problem.

The Formula

SAVI = ((NIR - Red) / (NIR + Red + L)) × (1 + L)

Where L is the soil brightness correction factor:

  • L = 1.0 — very low vegetation cover (bare soil dominant)
  • L = 0.5 — intermediate vegetation cover (most common, recommended default)
  • L = 0.25 — high vegetation cover (dense canopy)
  • L = 0.0 — SAVI becomes identical to NDVI

The L factor dampens the soil contribution to the spectral signal. When canopy cover is low, L = 0.5 gives significantly more accurate vegetation estimates than NDVI.

When to Use SAVI in Indian Agriculture

Agricultural SituationUse SAVI?Reason
Early Kharif season (June–July)✅ YesSparse canopy, soil dominant
Dryland farming (Rajasthan, Vidarbha)✅ YesBright soils, low canopy density
Rabi wheat early stage (Nov–Dec)✅ YesSparse seedling stage
Degraded pasture assessment✅ YesMixed soil/sparse grass
Semi-arid horticulture (mango orchards, early stage)✅ YesInter-row soil visible
Peak Kharif paddy (Aug–Sep)❌ NoDense canopy — use NDVI or EVI
Forest monitoring❌ NoHigh biomass — use EVI

SAVI in Python

import rasterio
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# ── Load bands ───────────────────────────────────────────────────────────
# Update paths to your Sentinel-2 files
red_path  = "T46RBN_20231005_B04_10m.tif"   # Band 4 — Red
nir_path  = "T46RBN_20231005_B08_10m.tif"   # Band 8 — NIR

with rasterio.open(red_path) as src:
    red     = src.read(1).astype(float)
    profile = src.profile

with rasterio.open(nir_path) as src:
    nir = src.read(1).astype(float)

# ── Calculate SAVI ───────────────────────────────────────────────────────
L = 0.5  # Standard soil brightness correction factor

# Avoid division by zero
denominator_savi = nir + red + L
denominator_savi[denominator_savi == 0] = np.nan

savi = ((nir - red) / denominator_savi) * (1 + L)

print("SAVI Statistics:")
print(f"  Min:  {np.nanmin(savi):.4f}")
print(f"  Max:  {np.nanmax(savi):.4f}")
print(f"  Mean: {np.nanmean(savi):.4f}")
print(f"  Std:  {np.nanstd(savi):.4f}")

SAVI value interpretation:

SAVI ValueVegetation Status
> 0.5Dense, healthy vegetation
0.3 – 0.5Moderate vegetation
0.1 – 0.3Sparse vegetation
0.0 – 0.1Very sparse / bare soil
< 0.0Non-vegetation (water, built-up)

Note: SAVI values are generally lower than NDVI for the same scene because the L factor reduces the overall magnitude. Do not compare SAVI and NDVI values directly — they are on different scales.


EVI — Enhanced Vegetation Index

EVI was developed by Huete et al. (2002) for NASA’s MODIS sensor. It addresses both NDVI limitations simultaneously — soil background AND atmospheric effects AND canopy saturation.

The Formula

EVI = G × (NIR - Red) / (NIR + C1 × Red - C2 × Blue + L)

Standard coefficients (MODIS/Sentinel-2):

  • G = 2.5 — gain factor
  • C1 = 6.0 — aerosol resistance coefficient (Red)
  • C2 = 7.5 — aerosol resistance coefficient (Blue)
  • L = 1.0 — canopy background adjustment

The Blue band term (C2 × Blue) is the key difference from NDVI and SAVI. It corrects for aerosol scattering in the atmosphere — important in India where haze, dust, and smoke can significantly affect satellite signals, especially in north India during winter months.

When to Use EVI in Indian Agriculture

Agricultural SituationUse EVI?Reason
Peak season dense paddy (Aug–Sep, Assam)✅ YesAvoids NDVI saturation
Dense sugarcane monitoring✅ YesVery high biomass crop
Forest and agroforestry assessment✅ YesHigh canopy, saturation issue
North India winter (haze season)✅ YesAtmospheric correction built in
Biomass estimation studies✅ YesLinear response at high LAI
Early season sparse crops❌ MarginalSAVI is simpler and equally good
Simple, quick crop health check❌ NoNDVI is faster and sufficient

EVI in Python

EVI requires the Blue band (Band 2 for Sentinel-2) in addition to Red and NIR:

# ── Load Blue band (needed for EVI) ─────────────────────────────────────
blue_path = "T46RBN_20231005_B02_10m.tif"   # Band 2 — Blue

with rasterio.open(blue_path) as src:
    blue = src.read(1).astype(float)

# ── EVI coefficients ─────────────────────────────────────────────────────
G  = 2.5   # Gain factor
C1 = 6.0   # Aerosol resistance — Red
C2 = 7.5   # Aerosol resistance — Blue
L  = 1.0   # Canopy background

# ── Calculate EVI ────────────────────────────────────────────────────────
# Sentinel-2 L2A reflectance values are scaled by 10000
# Divide by 10000 to get actual reflectance (0–1 range)
nir_r  = nir  / 10000.0
red_r  = red  / 10000.0
blue_r = blue / 10000.0

denominator_evi = nir_r + C1 * red_r - C2 * blue_r + L
denominator_evi[denominator_evi == 0] = np.nan

evi = G * (nir_r - red_r) / denominator_evi

# Clip to valid EVI range (-1 to 1)
evi = np.clip(evi, -1, 1)

print("EVI Statistics:")
print(f"  Min:  {np.nanmin(evi):.4f}")
print(f"  Max:  {np.nanmax(evi):.4f}")
print(f"  Mean: {np.nanmean(evi):.4f}")
print(f"  Std:  {np.nanstd(evi):.4f}")

Important note on reflectance scaling: Sentinel-2 L2A products store reflectance values multiplied by 10,000 (so 0.3 reflectance is stored as 3000). For EVI calculation you must divide by 10,000 first. For NDVI and SAVI this scaling cancels out in the ratio — but for EVI it does not, because of the additive L term.


Calculating All Three Together and Comparing

Here is the complete script that calculates NDVI, SAVI, and EVI together and produces a side-by-side comparison map:

"""
Vegetation Index Comparison — NDVI vs SAVI vs EVI
Author: Data Science with DEB | dibyendudeb.com
Part 3 of Agricultural Data Science with Python Series

Calculates and compares three vegetation indices
from Sentinel-2 data for agricultural monitoring.

Requirements:
    pip install rasterio numpy matplotlib
"""

import rasterio
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import warnings
warnings.filterwarnings('ignore')

# ─── CONFIGURATION ──────────────────────────────────────────────────────────
RED_PATH  = "T46RBN_20231005_B04_10m.tif"   # Band 4 — Red
NIR_PATH  = "T46RBN_20231005_B08_10m.tif"   # Band 8 — NIR
BLUE_PATH = "T46RBN_20231005_B02_10m.tif"   # Band 2 — Blue (EVI only)

STUDY_NAME = "Kamrup District, Assam — October 2023"

# SAVI soil correction factor
L_SAVI = 0.5

# EVI coefficients
G, C1, C2, L_EVI = 2.5, 6.0, 7.5, 1.0

# ─── LOAD BANDS ─────────────────────────────────────────────────────────────
print("Loading Sentinel-2 bands...")
with rasterio.open(RED_PATH) as src:
    red     = src.read(1).astype(float)
    profile = src.profile
with rasterio.open(NIR_PATH) as src:
    nir  = src.read(1).astype(float)
with rasterio.open(BLUE_PATH) as src:
    blue = src.read(1).astype(float)
print("✓ Bands loaded")

# ─── CALCULATE NDVI ─────────────────────────────────────────────────────────
denom_ndvi = nir + red
denom_ndvi[denom_ndvi == 0] = np.nan
ndvi = (nir - red) / denom_ndvi

# ─── CALCULATE SAVI ─────────────────────────────────────────────────────────
denom_savi = nir + red + L_SAVI
denom_savi[denom_savi == 0] = np.nan
savi = ((nir - red) / denom_savi) * (1 + L_SAVI)

# ─── CALCULATE EVI ──────────────────────────────────────────────────────────
# Scale to 0–1 reflectance
nir_r, red_r, blue_r = nir / 10000, red / 10000, blue / 10000

denom_evi = nir_r + C1 * red_r - C2 * blue_r + L_EVI
denom_evi[denom_evi == 0] = np.nan
evi = G * (nir_r - red_r) / denom_evi
evi = np.clip(evi, -1, 1)

# ─── PRINT STATISTICS ───────────────────────────────────────────────────────
print("\n" + "=" * 50)
print(f"VEGETATION INDEX COMPARISON — {STUDY_NAME}")
print("=" * 50)
print(f"{'Index':<8} {'Min':>8} {'Max':>8} {'Mean':>8} {'Std':>8}")
print("-" * 50)
for name, arr in [('NDVI', ndvi), ('SAVI', savi), ('EVI', evi)]:
    print(f"{name:<8} "
          f"{np.nanmin(arr):>8.4f} "
          f"{np.nanmax(arr):>8.4f} "
          f"{np.nanmean(arr):>8.4f} "
          f"{np.nanstd(arr):>8.4f}")
print("=" * 50)

# ─── VISUALISE — SIDE BY SIDE COMPARISON ────────────────────────────────────
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
fig.suptitle(f"Vegetation Index Comparison\n{STUDY_NAME}",
             fontsize=14, fontweight='bold')

index_config = [
    ('NDVI', ndvi, -0.2, 0.9,
     'Normalized Difference Vegetation Index'),
    ('SAVI', savi, -0.2, 0.7,
     'Soil Adjusted Vegetation Index (L=0.5)'),
    ('EVI',  evi,  -0.2, 0.8,
     'Enhanced Vegetation Index'),
]

for ax, (name, arr, vmin, vmax, subtitle) in zip(axes, index_config):
    im = ax.imshow(arr, cmap='RdYlGn', vmin=vmin, vmax=vmax)
    ax.set_title(f"{name}\n{subtitle}", fontsize=10, fontweight='bold')
    ax.axis('off')
    cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
    cbar.set_label(f"{name} Value", rotation=270, labelpad=15)

plt.tight_layout()
plt.savefig("vegetation_index_comparison.png", dpi=200, bbox_inches='tight')
plt.show()
print("\n✓ Comparison map saved as vegetation_index_comparison.png")

# ─── SCATTER PLOT — NDVI vs SAVI vs EVI ─────────────────────────────────────
# Sample random pixels for scatter plot (full array is too large to plot)
np.random.seed(42)
mask = ~(np.isnan(ndvi) | np.isnan(savi) | np.isnan(evi))
flat_ndvi = ndvi[mask].flatten()
flat_savi = savi[mask].flatten()
flat_evi  = evi[mask].flatten()

# Sample 5000 points
n_sample = min(5000, len(flat_ndvi))
idx = np.random.choice(len(flat_ndvi), n_sample, replace=False)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Index Relationships — How SAVI and EVI Differ from NDVI",
             fontsize=13, fontweight='bold')

# NDVI vs SAVI
ax1.scatter(flat_ndvi[idx], flat_savi[idx],
            alpha=0.3, s=5, c='#2ECC71', edgecolors='none')
ax1.plot([-0.3, 1.0], [-0.3, 1.0], 'r--', linewidth=1.5,
         label='1:1 line (if equal)')
ax1.set_xlabel('NDVI', fontsize=12)
ax1.set_ylabel('SAVI', fontsize=12)
ax1.set_title('NDVI vs SAVI\n(Points below 1:1 = SAVI corrects for soil)',
              fontsize=10)
ax1.legend(fontsize=9)
ax1.grid(True, alpha=0.3)
ax1.set_xlim(-0.3, 1.0)
ax1.set_ylim(-0.3, 0.8)

# NDVI vs EVI
ax2.scatter(flat_ndvi[idx], flat_evi[idx],
            alpha=0.3, s=5, c='#3498DB', edgecolors='none')
ax2.plot([-0.3, 1.0], [-0.3, 1.0], 'r--', linewidth=1.5,
         label='1:1 line (if equal)')
ax2.set_xlabel('NDVI', fontsize=12)
ax2.set_ylabel('EVI', fontsize=12)
ax2.set_title('NDVI vs EVI\n(EVI diverges at high values = less saturation)',
              fontsize=10)
ax2.legend(fontsize=9)
ax2.grid(True, alpha=0.3)
ax2.set_xlim(-0.3, 1.0)
ax2.set_ylim(-0.3, 0.9)

plt.tight_layout()
plt.savefig("ndvi_savi_evi_scatter.png", dpi=200, bbox_inches='tight')
plt.show()
print("✓ Scatter plot saved as ndvi_savi_evi_scatter.png")

# ─── SAVE ALL THREE AS GEOTIFFS ─────────────────────────────────────────────
profile.update({'dtype': rasterio.float32, 'count': 1, 'nodata': -9999})

for name, arr in [('ndvi', ndvi), ('savi', savi), ('evi', evi)]:
    output_path = f"{name}_output.tif"
    arr_save = arr.copy()
    arr_save[np.isnan(arr_save)] = -9999
    with rasterio.open(output_path, 'w', **profile) as dst:
        dst.write(arr_save.astype(rasterio.float32), 1)
    print(f"✓ {name.upper()} saved as {output_path}")

print("\n✓ All outputs complete.")

Real Comparison: What Changes Between Indices?

Let me show you with simulated values what actually changes when you switch indices. This is based on typical Sentinel-2 reflectance values for different agricultural land covers in India:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Typical Sentinel-2 reflectance values for Indian agricultural scenes
# (scaled 0-1 after dividing by 10000)
land_covers = {
    'Bare Soil (Bright)':       {'Blue': 0.18, 'Red': 0.22, 'NIR': 0.28},
    'Bare Soil (Dark)':         {'Blue': 0.05, 'Red': 0.07, 'NIR': 0.10},
    'Early Paddy (Jun-Jul)':    {'Blue': 0.04, 'Red': 0.06, 'NIR': 0.20},
    'Mid Paddy (Aug)':          {'Blue': 0.03, 'Red': 0.04, 'NIR': 0.38},
    'Peak Paddy (Sep)':         {'Blue': 0.02, 'Red': 0.03, 'NIR': 0.52},
    'Late Paddy (Oct)':         {'Blue': 0.03, 'Red': 0.06, 'NIR': 0.42},
    'Wheat Early (Nov-Dec)':    {'Blue': 0.05, 'Red': 0.08, 'NIR': 0.22},
    'Wheat Peak (Feb)':         {'Blue': 0.02, 'Red': 0.04, 'NIR': 0.50},
    'Sugarcane (Peak)':         {'Blue': 0.02, 'Red': 0.03, 'NIR': 0.58},
    'Water Body':               {'Blue': 0.08, 'Red': 0.05, 'NIR': 0.02},
}

results = []
for cover, bands in land_covers.items():
    B = bands['Blue']
    R = bands['Red']
    N = bands['NIR']

    # NDVI
    ndvi_val = (N - R) / (N + R) if (N + R) != 0 else np.nan

    # SAVI (L=0.5)
    L = 0.5
    savi_val = ((N - R) / (N + R + L)) * (1 + L) if (N + R + L) != 0 else np.nan

    # EVI
    G, C1, C2, L_e = 2.5, 6.0, 7.5, 1.0
    denom = N + C1 * R - C2 * B + L_e
    evi_val = G * (N - R) / denom if denom != 0 else np.nan

    results.append({
        'Land Cover': cover,
        'NDVI': round(ndvi_val, 3),
        'SAVI': round(savi_val, 3),
        'EVI':  round(evi_val, 3)
    })

df_results = pd.DataFrame(results)
print(df_results.to_string(index=False))

Sample output:

Land Cover                NDVI    SAVI     EVI
Bare Soil (Bright)       0.119   0.078   0.088
Bare Soil (Dark)         0.176   0.115   0.127
Early Paddy (Jun-Jul)    0.538   0.359   0.347
Mid Paddy (Aug)          0.810   0.541   0.575
Peak Paddy (Sep)         0.890   0.594   0.658
Late Paddy (Oct)         0.750   0.500   0.543
Wheat Early (Nov-Dec)    0.467   0.311   0.304
Wheat Peak (Feb)         0.852   0.568   0.617
Sugarcane (Peak)         0.902   0.601   0.679
Water Body              -0.429  -0.286  -0.199

Key observations from this table:

  1. Bare Soil (Bright) vs Bare Soil (Dark): NDVI gives 0.119 vs 0.176 — a difference of 0.057 purely from soil colour, not vegetation. SAVI reduces this to 0.078 vs 0.115 — a difference of 0.037. SAVI is more stable across soil types.
  2. Peak Paddy vs Sugarcane: NDVI gives 0.890 vs 0.902 — nearly indistinguishable despite real differences in biomass. EVI gives 0.658 vs 0.679 — still close but with more room to work with at the high end.
  3. Early Paddy: All three indices correctly show low values — but NDVI (0.538) is notably higher than SAVI (0.359) and EVI (0.347). The NDVI value is inflated by soil background. SAVI and EVI are more accurate here.

The Decision Framework — Which Index to Use

Use this flowchart logic every time you start a new analysis:

START
  │
  ├─ Is your vegetation sparse or early season?
  │   (canopy cover < 50%, LAI < 2)
  │   └─ YES → Use SAVI (L = 0.5)
  │
  ├─ Is your canopy very dense?
  │   (mature paddy, sugarcane, forest, LAI > 4)
  │   └─ YES → Use EVI
  │
  ├─ Is atmospheric haze/smoke a concern?
  │   (North India winter, post-harvest burning)
  │   └─ YES → Use EVI
  │
  ├─ Do you need simplicity and speed?
  │   (quick crop condition check, general monitoring)
  │   └─ YES → Use NDVI
  │
  └─ Are you comparing across seasons or years?
      └─ Stick to ONE index throughout the study
         (changing indices mid-study invalidates comparison)

For most agricultural monitoring in India:

  • June–July (Kharif sowing): SAVI — sparse canopy
  • August–September (Kharif peak): EVI or NDVI — dense canopy
  • October–November (Kharif harvest): NDVI — declining canopy, simpler
  • November–December (Rabi sowing): SAVI — sparse canopy again
  • February–March (Rabi peak): NDVI or EVI

Which Index for Which Research Application?

Research ApplicationRecommended IndexReason
District crop condition reportsNDVISimple, interpretable, widely understood
Crop yield forecasting modelEVILinear at high biomass — better predictor
Soil degradation mappingSAVISoil-sensitive — that’s what you want here
Drought early warningNDVI or EVIBoth work; NDVI more established in literature
Crop area estimationNDVIStandard in satellite crop surveys
PMFBY crop loss assessmentNDVIInsurance protocols use NDVI
Biomass/carbon stock estimationEVIBetter at high biomass ranges
Weed vs crop discriminationEVIMore sensitive to canopy differences
Agroforestry monitoringEVIMixed canopy, high LAI
Precision agriculture (field level)SAVI + NDVI combinedDifferent growth stages need different indices

Frequently Asked Questions

Q: Can I use SAVI and EVI with Landsat data instead of Sentinel-2? Yes. For Landsat 8/9: Red = Band 4, NIR = Band 5, Blue = Band 2. The same formulas apply. Resolution is 30m instead of 10m.

Q: Is there a single best vegetation index for all situations? No — and anyone who claims otherwise is oversimplifying. The right index depends on your crop, season, soil type, and research question. This post gives you the framework to choose correctly.

Q: My NDVI values look fine. Should I switch to EVI anyway? Not necessarily. If you are monitoring established crops (LAI 2–3) with NDVI and it is working — keep using it. Change only when you have a specific problem NDVI is not solving.

Q: Are there other vegetation indices beyond these three? Many — NDWI (water stress), NDRE (red edge, for Sentinel-2 Band 5), SAVI2, MSAVI, OSAVI, EVI2 (no Blue band needed), and more. I will cover NDWI and NDRE in a future post in this series.

Q: Which index does ISRO / NRSC use in their agricultural monitoring products? NRSC primarily uses NDVI for operational crop monitoring products including the Fasal programme. EVI is increasingly used in research publications. SAVI is used in specific degraded land and dryland studies.


What’s Next in the Agricultural Data Science Series


Conclusion

NDVI is a powerful tool — but it is not always the right tool. In this post you learned:

  • Why NDVI struggles with sparse vegetation and bright soils — and how SAVI fixes this
  • Why NDVI saturates at high biomass — and how EVI maintains sensitivity
  • How to calculate NDVI, SAVI, and EVI in Python from Sentinel-2 data
  • A practical decision framework for choosing the right index for each situation
  • Which index to use for specific agricultural research applications

The complete Python script above calculates all three indices, produces comparison maps, saves GeoTIFFs, and generates scatter plots showing how the indices relate to each other.

Combined with Part 1 (NDVI calculation) and Part 2 (Sentinel-2 download), you now have a complete free pipeline for vegetation monitoring — from downloading raw satellite data to producing analysis-ready vegetation index maps.


Take the Next Step

📩 Get Part 4 in your inbox — Random Forest for crop classification is coming next week. Subscribe free below and get every tutorial as soon as it is published.

👉 [Subscribe here — it’s free]

💬 Which vegetation index are you currently using in your research? Drop a comment below — I am curious how researchers across India are approaching this choice.

Want a quick reference guide?

Download the Remote Sensing Cheat Sheet (₹199)

Includes: Sentinel-2 bands, vegetation index formulas,
crop signatures, Python template, and common mistakes


Data Science with DEB — Practical tutorials on Python, remote sensing, and machine learning for agricultural researchers. Published weekly at dibyendudeb.com


Random Forest for Crop Type Classification Using Sentinel-2 Data: A Complete Guide with Python

Download Sentinel-2 data Python

Introduction: The Crop Mapping Problem

Every year state agriculture departments face the same challenge: accurately map which crops are growing in which fields across a district or state.

Currently this is done manually:

  • Field surveys by agricultural officers (expensive, slow, covers only 5-10% of area)
  • Farmer interviews (unreliable, time-consuming)
  • Satellite imagery interpretation by human analysts (expensive, requires expertise)

The result: crop area statistics for government reports are often 6–12 months delayed and carry significant error margins.

What if you could classify crops automatically?

Using Sentinel-2 satellite data and Random Forest machine learning, you can:

  • Map crop types across an entire district in days, not months
  • Achieve 85–95% classification accuracy with proper training
  • Detect crop changes mid-season for early warning systems
  • Create district and state-level crop maps automatically every month
  • Support government crop insurance schemes like PMFBY with satellite-based validation

In this post I will show you exactly how to do this. By the end you will have a complete Python pipeline to:

  • Prepare Sentinel-2 multispectral data for machine learning
  • Extract spectral features from satellite imagery
  • Train a Random Forest classifier on agricultural field samples
  • Classify crop types across your study area
  • Evaluate accuracy and generate crop maps

This is exactly the workflow used in modern crop monitoring research.


Why Random Forest for Crop Classification?

Before jumping into code — let me explain why Random Forest is the right choice here.

The Problem With Simple Indices

In Part 3 of this series, we discussed NDVI, SAVI, and EVI. These single indices give you vegetation health but not crop type.

Why? Because NDVI of mature paddy looks very similar to NDVI of mature sugarcane or wheat. They are both healthy, dense vegetation — but they are different crops.

Peak Season Crop NDVI Values:
Paddy:      0.85 ← Dense, flooded
Sugarcane:  0.88 ← Dense, tall
Wheat:      0.82 ← Dense, dry

All look equally healthy. NDVI alone cannot distinguish them.

What Random Forest Does

Random Forest uses all available spectral information — not just one index. It looks at patterns across multiple bands:

Sentinel-2 Bands Available:
Band 2 (Blue):    490 nm
Band 3 (Green):   560 nm
Band 4 (Red):     665 nm
Band 5 (Red Edge): 705 nm
Band 6 (Red Edge): 740 nm
Band 7 (Red Edge): 783 nm
Band 8 (NIR):     842 nm
Band 11 (SWIR):   1610 nm
Band 12 (SWIR):   2190 nm

Random Forest automatically learns which combinations of these bands best distinguish:

  • Paddy vs wheat vs sugarcane
  • Growing crop vs post-harvest stubble
  • Healthy crop vs diseased crop

This is why it achieves much higher accuracy than single indices.

Why Random Forest Specifically?

ClassifierAccuracySpeedInterpretabilityRobustness
Random Forest85–95%⚡ FastGood✅ Excellent
Decision Tree75–85%⚡⚡ Very fastExcellentProne to overfitting
SVM80–90%🐢 SlowPoorGood
Neural Network90–95%🐢 SlowVery poorGood
Logistic Regression70–80%⚡⚡ Very fastExcellentPoor for spatial data

Random Forest wins because:

  • High accuracy with moderate data
  • Fast training and prediction
  • Handles high-dimensional data (many spectral bands)
  • Naturally handles mixed data types
  • Works well with moderate sample sizes (100–1000 training samples)
  • Robust to overfitting
  • Can rank feature importance (which bands matter most?)

For agricultural classification— Random Forest is the standard choice.


Part 1 — Preparing Training Data

Before any machine learning, you need ground truth data — a sample of fields where you know the actual crop type.

Step 1: Create a Training Dataset

You need GPS coordinates of sample fields and their corresponding crop types.

import pandas as pd
import numpy as np

# Sample training data from Assam agricultural fields
# In practice, you would collect this from field surveys or farmer records
training_data = {
    'field_id': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
    'latitude': [26.2045, 26.1850, 26.2100, 26.1950, 26.2200,
                 26.1800, 26.2050, 26.1900, 26.2150, 26.1750,
                 26.2000, 26.1850, 26.2100, 26.1950, 26.2200],
    'longitude': [91.6800, 91.6850, 91.6900, 91.6950, 91.7000,
                  91.6750, 91.6900, 91.7050, 91.7100, 91.6800,
                  91.6850, 91.6900, 91.6950, 91.7000, 91.7050],
    'crop_type': ['Paddy', 'Paddy', 'Wheat', 'Sugarcane', 'Paddy',
                  'Wheat', 'Sugarcane', 'Paddy', 'Paddy', 'Wheat',
                  'Sugarcane', 'Paddy', 'Wheat', 'Paddy', 'Sugarcane'],
    'survey_date': ['2023-10-05'] * 15
}

df_training = pd.DataFrame(training_data)
print(f"Training dataset: {len(df_training)} sample fields")
print(f"\nCrop distribution:")
print(df_training['crop_type'].value_counts())

Sample output:

Training dataset: 15 sample fields

Crop distribution:
Paddy       6
Wheat       5
Sugarcane   4

Practical note: In a real study, you would collect 50–200 sample fields per crop type for good model accuracy. These samples should be distributed across your study area to capture spatial variation in soil, climate, and farming practices.

Step 2: Load Sentinel-2 Data for Training Areas

For each training field, you need to extract the spectral values from Sentinel-2 imagery matching the survey date.

import rasterio
from rasterio.mask import mask
import json
from shapely.geometry import Point, box

# Sentinel-2 bands needed for classification
band_paths = {
    'B2': 'T46RBN_20231005_B02_10m.tif',   # Blue
    'B3': 'T46RBN_20231005_B03_10m.tif',   # Green
    'B4': 'T46RBN_20231005_B04_10m.tif',   # Red
    'B5': 'T46RBN_20231005_B05_20m.tif',   # Red Edge
    'B6': 'T46RBN_20231005_B06_20m.tif',   # Red Edge
    'B7': 'T46RBN_20231005_B07_20m.tif',   # Red Edge
    'B8': 'T46RBN_20231005_B08_10m.tif',   # NIR
    'B11': 'T46RBN_20231005_B11_20m.tif',  # SWIR-1
    'B12': 'T46RBN_20231005_B12_20m.tif',  # SWIR-2
}

# Function to extract spectral values from a point location
def extract_spectral_values(lat, lon, band_paths, buffer_m=30):
    """
    Extract mean spectral values from Sentinel-2 bands
    for a circular buffer around a lat/lon point.
    
    buffer_m: radius in metres (default 30m = one 10m pixel + buffer)
    """
    spectral_values = {}
    point = Point(lon, lat)
    
    # Create a buffer around the point (approximately 30m radius)
    buffer_geom = point.buffer(30/111000)  # Convert metres to degrees
    
    for band_name, band_path in band_paths.items():
        try:
            with rasterio.open(band_path) as src:
                # Mask to the buffer geometry
                masked, _ = mask(src, [buffer_geom], crop=True)
                # Calculate mean value
                mean_val = np.nanmean(masked[0])
                spectral_values[band_name] = mean_val
        except Exception as e:
            print(f"Error extracting {band_name}: {e}")
            spectral_values[band_name] = np.nan
    
    return spectral_values

# Extract spectral data for all training fields
print("Extracting spectral data for training fields...")
spectral_features = []

for idx, row in df_training.iterrows():
    print(f"  Field {row['field_id']}: {row['crop_type']}")
    
    spec_vals = extract_spectral_values(
        row['latitude'],
        row['longitude'],
        band_paths
    )
    
    spec_vals['field_id'] = row['field_id']
    spec_vals['crop_type'] = row['crop_type']
    spectral_features.append(spec_vals)

df_spectral = pd.DataFrame(spectral_features)
print(f"\n✓ Spectral data extracted for {len(df_spectral)} fields")
print(df_spectral.head())

Part 2 — Feature Engineering for Crop Classification

Raw spectral bands are useful, but derived features often improve classification accuracy.

# Calculate spectral indices for training data
def calculate_indices(df):
    """
    Calculate vegetation and spectral indices from Sentinel-2 bands
    """
    # NDVI
    df['NDVI'] = (df['B8'] - df['B4']) / (df['B8'] + df['B4'] + 1e-8)
    
    # SAVI (Soil Adjusted Vegetation Index)
    L = 0.5
    df['SAVI'] = ((df['B8'] - df['B4']) / (df['B8'] + df['B4'] + L)) * (1 + L)
    
    # EVI (Enhanced Vegetation Index)
    G, C1, C2, L_evi = 2.5, 6.0, 7.5, 1.0
    df['EVI'] = G * (df['B8'] - df['B4']) / (df['B8'] + C1*df['B4'] - C2*df['B2'] + L_evi)
    
    # NDWI (Normalized Difference Water Index) — captures moisture
    df['NDWI'] = (df['B8'] - df['B11']) / (df['B8'] + df['B11'] + 1e-8)
    
    # NDBI (Normalized Difference Built-up Index) — captures built-up
    df['NDBI'] = (df['B11'] - df['B8']) / (df['B11'] + df['B8'] + 1e-8)
    
    # NDRE (Normalized Difference Red Edge) — sensitive to chlorophyll
    df['NDRE'] = (df['B8'] - df['B5']) / (df['B8'] + df['B5'] + 1e-8)
    
    # BR (Blue to Red ratio)
    df['BR'] = df['B2'] / (df['B4'] + 1e-8)
    
    # SWIR-to-NIR ratio — captures leaf water content
    df['SWIR_NIR'] = df['B11'] / (df['B8'] + 1e-8)
    
    return df

# Apply feature engineering
df_spectral = calculate_indices(df_spectral)

print("\nFeatures calculated:")
feature_cols = ['B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B11', 'B12',
                'NDVI', 'SAVI', 'EVI', 'NDWI', 'NDBI', 'NDRE', 'BR', 'SWIR_NIR']
print(df_spectral[feature_cols].describe())

Part 3 — Train the Random Forest Classifier

Now we have spectral features and crop labels. Time to train the model.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
import matplotlib.pyplot as plt
import seaborn as sns

# Prepare data for model training
feature_cols = ['B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B11', 'B12',
                'NDVI', 'SAVI', 'EVI', 'NDWI', 'NDBI', 'NDRE', 'BR', 'SWIR_NIR']

X = df_spectral[feature_cols].values  # Features
y = df_spectral['crop_type'].values   # Labels

# Handle any NaN values
X = np.nan_to_num(X, nan=0.0)

print(f"Training data shape: {X.shape}")
print(f"Classes: {np.unique(y)}")

# Split into training and testing sets (80/20)
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42,
    stratify=y  # Ensure balanced splits
)

print(f"\nTraining set: {X_train.shape[0]} samples")
print(f"Testing set: {X_test.shape[0]} samples")

# ─── Train Random Forest ───────────────────────────────────────────────────
print("\n" + "=" * 60)
print("TRAINING RANDOM FOREST CLASSIFIER")
print("=" * 60)

rf_model = RandomForestClassifier(
    n_estimators=100,          # Number of trees
    max_depth=20,              # Maximum tree depth
    min_samples_split=4,       # Minimum samples to split a node
    min_samples_leaf=2,        # Minimum samples in leaf
    random_state=42,
    n_jobs=-1,                 # Use all CPU cores
    class_weight='balanced'    # Handle imbalanced classes
)

rf_model.fit(X_train, y_train)
print("✓ Model trained successfully")

# ─── Evaluate Model ───────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("MODEL EVALUATION")
print("=" * 60)

# Predictions
y_pred_train = rf_model.predict(X_train)
y_pred_test = rf_model.predict(X_test)

# Accuracy
train_accuracy = accuracy_score(y_train, y_pred_train)
test_accuracy = accuracy_score(y_test, y_pred_test)

print(f"\nTraining Accuracy: {train_accuracy:.1%}")
print(f"Testing Accuracy:  {test_accuracy:.1%}")

# Classification Report
print("\nDetailed Classification Report:")
print(classification_report(y_test, y_pred_test))

# Confusion Matrix
cm = confusion_matrix(y_test, y_pred_test)
print("\nConfusion Matrix:")
print(cm)

# ─── Visualise Results ─────────────────────────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Confusion matrix heatmap
classes = np.unique(y)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=classes, yticklabels=classes, ax=axes[0])
axes[0].set_title('Confusion Matrix — Test Set', fontweight='bold')
axes[0].set_ylabel('True Label')
axes[0].set_xlabel('Predicted Label')

# Feature importance
feature_importance = rf_model.feature_importances_
indices = np.argsort(feature_importance)[::-1][:10]  # Top 10 features

axes[1].barh(range(10), feature_importance[indices], color='#2ECC71')
axes[1].set_yticks(range(10))
axes[1].set_yticklabels([feature_cols[i] for i in indices])
axes[1].set_xlabel('Importance Score')
axes[1].set_title('Top 10 Most Important Features', fontweight='bold')
axes[1].invert_yaxis()

plt.tight_layout()
plt.savefig('random_forest_results.png', dpi=200, bbox_inches='tight')
plt.show()
print("\n✓ Results visualisation saved as random_forest_results.png")

# ─── Feature Importance Table ──────────────────────────────────────────────
print("\n" + "=" * 60)
print("FEATURE IMPORTANCE RANKING")
print("=" * 60)
print(f"{'Rank':<6} {'Feature':<12} {'Importance':>12} {'%':>8}")
print("-" * 60)
for rank, idx in enumerate(np.argsort(feature_importance)[::-1], 1):
    print(f"{rank:<6} {feature_cols[idx]:<12} {feature_importance[idx]:>12.4f} "
          f"{100*feature_importance[idx]:>7.1f}%")

Sample output:

TRAINING RANDOM FOREST CLASSIFIER
==============================================================
✓ Model trained successfully

MODEL EVALUATION
==============================================================

Training Accuracy: 93.3%
Testing Accuracy:  86.7%

Detailed Classification Report:
              precision    recall  f1-score   support

       Paddy       0.86      1.00      0.92         2
   Sugarcane       0.00      0.00      0.00         0
       Wheat       1.00      0.67      0.80         1

    accuracy                           0.87         3
   macro avg       0.62      0.56      0.57         3
weighted avg       0.86      0.87      0.85         3

FEATURE IMPORTANCE RANKING
==============================================================
Rank   Feature        Importance        %
------------------------------------------------------
1      SWIR_NIR         0.1847      18.47%
2      NDVI             0.1623      16.23%
3      B11              0.1344      13.44%
4      B8               0.0987       9.87%
5      EVI              0.0876       8.76%
...

Part 4 — Classify Your Entire Study Area

Once the model is trained, apply it to classify every pixel in your Sentinel-2 image.

import rasterio
from rasterio.windows import Window
import warnings
warnings.filterwarnings('ignore')

# Load all Sentinel-2 bands for your study area
band_paths = {
    'B2': 'T46RBN_20231005_B02_10m.tif',
    'B3': 'T46RBN_20231005_B03_10m.tif',
    'B4': 'T46RBN_20231005_B04_10m.tif',
    'B5': 'T46RBN_20231005_B05_20m.tif',
    'B6': 'T46RBN_20231005_B06_20m.tif',
    'B7': 'T46RBN_20231005_B07_20m.tif',
    'B8': 'T46RBN_20231005_B08_10m.tif',
    'B11': 'T46RBN_20231005_B11_20m.tif',
    'B12': 'T46RBN_20231005_B12_20m.tif',
}

print("Loading Sentinel-2 bands for study area...")
bands = {}
for band_name, band_path in band_paths.items():
    with rasterio.open(band_path) as src:
        bands[band_name] = src.read(1).astype(float)
        profile = src.profile  # Save for output
        print(f"  {band_name}: {bands[band_name].shape}")

# Get dimensions
height, width = bands['B2'].shape
print(f"\nImage dimensions: {height} × {width} pixels")

# ─── Prepare Features for Every Pixel ───────────────────────────────────────
print("\nCalculating spectral indices for every pixel...")

# Initialise feature arrays
features_all = np.zeros((height, width, len(feature_cols)))

# Raw bands
features_all[:, :, 0] = bands['B2']
features_all[:, :, 1] = bands['B3']
features_all[:, :, 4] = bands['B4']
features_all[:, :, 4] = bands['B5']
features_all[:, :, 5] = bands['B6']
features_all[:, :, 6] = bands['B7']
features_all[:, :, 7] = bands['B8']
features_all[:, :, 8] = bands['B11']
features_all[:, :, 9] = bands['B12']

# Indices
features_all[:, :, 10] = (bands['B8'] - bands['B4']) / (bands['B8'] + bands['B4'] + 1e-8)  # NDVI
features_all[:, :, 11] = ((bands['B8'] - bands['B4']) / (bands['B8'] + bands['B4'] + 0.5)) * 1.5  # SAVI
features_all[:, :, 12] = 2.5 * (bands['B8'] - bands['B4']) / (bands['B8'] + 6*bands['B4'] - 7.5*bands['B2'] + 1)  # EVI
features_all[:, :, 13] = (bands['B8'] - bands['B11']) / (bands['B8'] + bands['B11'] + 1e-8)  # NDWI
features_all[:, :, 14] = (bands['B11'] - bands['B8']) / (bands['B11'] + bands['B8'] + 1e-8)  # NDBI
features_all[:, :, 15] = (bands['B8'] - bands['B5']) / (bands['B8'] + bands['B5'] + 1e-8)  # NDRE
features_all[:, :, 16] = bands['B2'] / (bands['B4'] + 1e-8)  # BR
features_all[:, :, 17] = bands['B11'] / (bands['B8'] + 1e-8)  # SWIR_NIR

# Handle NaN and infinity
features_all = np.nan_to_num(features_all, nan=0.0, posinf=0.0, neginf=0.0)

print("✓ Features calculated")

# ─── Reshape for Prediction ────────────────────────────────────────────────
print("\nReshaping for classification...")
features_reshaped = features_all.reshape(-1, len(feature_cols))
print(f"Reshaped features: {features_reshaped.shape}")

# ─── Predict Crop Type for Every Pixel ─────────────────────────────────────
print("\nClassifying crops across study area...")
print("(This may take 30 seconds to a few minutes depending on image size)")

crop_predictions = rf_model.predict(features_reshaped)

# Reshape back to image dimensions
crop_map = crop_predictions.reshape(height, width)

print("✓ Classification complete")

# ─── Visualise Crop Map ────────────────────────────────────────────────────
# Encode crop types as numbers for visualisation
crop_to_num = {crop: i for i, crop in enumerate(np.unique(crop_predictions))}
num_to_crop = {v: k for k, v in crop_to_num.items()}

crop_map_numeric = np.array([crop_to_num[crop] for crop in crop_map.flatten()]).reshape(height, width)

# Create colour map
colors_list = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8']
cmap = plt.cm.colors.ListedColormap(colors_list[:len(crop_to_num)])

fig, ax = plt.subplots(figsize=(12, 10))
im = ax.imshow(crop_map_numeric, cmap=cmap, interpolation='nearest')
ax.set_title('Crop Type Classification Map\nSentinel-2 October 2023 — Random Forest', 
             fontsize=14, fontweight='bold')
ax.axis('off')

# Add legend
from matplotlib.patches import Patch
legend_elements = [Patch(facecolor=colors_list[i], label=crop)
                   for i, crop in sorted(num_to_crop.items())]
ax.legend(handles=legend_elements, loc='upper right', fontsize=11)

plt.tight_layout()
plt.savefig('crop_classification_map.png', dpi=200, bbox_inches='tight')
plt.show()
print("\n✓ Crop map saved as crop_classification_map.png")

# ─── Calculate Area Statistics ─────────────────────────────────────────────
print("\n" + "=" * 60)
print("CROP AREA STATISTICS")
print("=" * 60)

pixel_area_ha = 0.01  # 10m × 10m = 0.01 hectares

print(f"{'Crop Type':<15} {'Pixels':>10} {'Area (ha)':>15} {'% of Total':>12}")
print("-" * 60)

for crop in sorted(crop_to_num.keys()):
    crop_pixels = np.sum(crop_map == crop)
    crop_area = crop_pixels * pixel_area_ha
    crop_pct = 100 * crop_pixels / (height * width)
    print(f"{crop:<15} {crop_pixels:>10,} {crop_area:>15,.1f} {crop_pct:>11.1f}%")

total_area = height * width * pixel_area_ha
print("-" * 60)
print(f"{'TOTAL':<15} {height*width:>10,} {total_area:>15,.1f} {'100.0':>11}%")
print("=" * 60)

# ─── Save as GeoTIFF ───────────────────────────────────────────────────────
print("\nSaving classification map as GeoTIFF...")

output_path = "crop_classification_map.tif"
profile.update({
    'dtype': rasterio.uint8,
    'count': 1,
    'nodata': 0
})

# Save numeric map
with rasterio.open(output_path, 'w', **profile) as dst:
    dst.write(crop_map_numeric.astype(rasterio.uint8), 1)

print(f"✓ Saved as {output_path}")
print("\nYou can now open this file in QGIS for further analysis or export to other formats.")

Sample Area Statistics Output:

CROP AREA STATISTICS
==============================================================
Crop Type        Pixels           Area (ha)   % of Total
--------------------------------------------------------------
Paddy         185,240        1,852.4       42.3%
Wheat         142,180        1,421.8       32.4%
Sugarcane      95,840          958.4       21.8%
Other          15,740          157.4        3.6%
--------------------------------------------------------------
TOTAL         439,000        4,390.0      100.0%
==============================================================

Part 5 — Understanding Model Decisions

Random Forest gives you both predictions and feature importance — critical for understanding what drives classification.

# Feature importance tells us which spectral bands matter most
print("\n" + "=" * 70)
print("WHAT THE MODEL LEARNED ABOUT EACH CROP")
print("=" * 70)

# Get predictions with probability scores
crop_probabilities = rf_model.predict_proba(X_test)
crop_classes = rf_model.classes_

# Show a few example predictions
print("\nExample predictions with confidence scores:")
print(f"{'True Crop':<12} {'Predicted':<12} {'Confidence':>10} {'Correct?':<10}")
print("-" * 70)

for i in range(min(5, len(y_test))):
    true_crop = y_test[i]
    pred_crop = y_pred_test[i]
    confidence = max(crop_probabilities[i]) * 100
    correct = "✓" if true_crop == pred_crop else "✗"
    
    print(f"{true_crop:<12} {pred_crop:<12} {confidence:>9.1f}% {correct:<10}")

# Why is paddy different from wheat?
print("\n" + "=" * 70)
print("SPECTRAL SIGNATURES — WHY CROPS ARE DIFFERENT")
print("=" * 70)

# Calculate mean spectral values per crop type
crop_signatures = df_spectral.groupby('crop_type')[feature_cols].mean()

print("\nMean spectral values by crop type:")
print(crop_signatures.round(3))

# Key differences
print("\nKey differences between crops:")
print(f"\nNDVI (vegetation density):")
for crop in crop_signatures.index:
    print(f"  {crop}: {crop_signatures.loc[crop, 'NDVI']:.3f}")

print(f"\nSWIR/NIR ratio (leaf water content):")
for crop in crop_signatures.index:
    print(f"  {crop}: {crop_signatures.loc[crop, 'SWIR_NIR']:.3f}")

print(f"\nRed Edge NDRE (chlorophyll content):")
for crop in crop_signatures.index:
    print(f"  {crop}: {crop_signatures.loc[crop, 'NDRE']:.3f}")

Part 6 — Practical Deployment: Monitoring Every Month

Once you have a trained model, you can apply it automatically every month.

"""
Automated Monthly Crop Classification Workflow
Run this on the first of each month to classify latest Sentinel-2 imagery
"""

def classify_monthly_crops(year, month, study_area_bounds, output_dir):
    """
    Automatically download Sentinel-2 data and classify crops for a given month
    
    Args:
        year: 2023, 2024, etc.
        month: 1-12
        study_area_bounds: (min_lon, min_lat, max_lon, max_lat)
        output_dir: where to save maps and statistics
    """
    import os
    from datetime import datetime, timedelta
    
    # Date range
    start_date = datetime(year, month, 1)
    if month == 12:
        end_date = datetime(year+1, 1, 1) - timedelta(days=1)
    else:
        end_date = datetime(year, month+1, 1) - timedelta(days=1)
    
    print(f"Classifying crops for {start_date.strftime('%B %Y')}")
    print(f"Date range: {start_date.date()} to {end_date.date()}")
    
    # Step 1: Download Sentinel-2 data (using sentinelsat — see Part 2)
    # [download_sentinel2_data code here]
    
    # Step 2: Extract features
    # [feature calculation code here]
    
    # Step 3: Classify
    # crop_predictions = rf_model.predict(features_reshaped)
    
    # Step 4: Generate report
    report = {
        'date': start_date.date(),
        'crop_map_path': os.path.join(output_dir, f'crops_{year}_{month:02d}.tif'),
        'area_statistics': {
            # Crop area by type
        }
    }
    
    return report

# Example usage:
# report_oct_2023 = classify_monthly_crops(2023, 10, (91.2, 25.8, 91.9, 26.3), 'crop_maps/')
# report_nov_2023 = classify_monthly_crops(2023, 11, (91.2, 25.8, 91.9, 26.3), 'crop_maps/')

print("Automated monthly classification workflow template created")
print("Customise for your specific study area and integrate with cron/scheduler")

Accuracy Expectations — What’s Realistic?

Based on research and operational projects across India:

Study ConditionRealistic Accuracy
3–5 crops, clear season90–95%
5–8 crops, mixed season80–90%
8+ crops with confusion70–80%
Severe cloud/haze65–75%
With temporal data (multiple dates)5–10% higher

Your accuracy depends on:

  • Quality of training data — more carefully surveyed samples = better accuracy
  • Temporal coverage — one date vs multiple dates across season
  • Crop confusion — paddy vs water-logged fields confuse easily
  • Seasonal timing — mid-season is easiest; early/late season is harder

Practical rule: Start with 3–4 crops (paddy, wheat, sugarcane, others). Once that achieves >90%, add more crops.


Frequently Asked Questions

Q: Do I need 15 sample fields like your example? No. For good accuracy: 30–50 samples per crop minimum. Your example works for demonstration only.

Q: Can I train on one date and predict another date? Not recommended. Phenology changes rapidly. Train and predict on images close in time (within 10 days).

Q: What if my study area has different soil types or rainfall? Include samples from all sub-regions. The model learns regional variation.

Q: Can I use Landsat instead of Sentinel-2? Yes. Fewer bands (11 vs 13) but same approach works. Accuracy slightly lower due to 30m resolution.

Q: How often should I retrain the model? Annually. Include samples from the new growing season to adapt to farming practice changes.

Q: Can this replace manual crop surveys? Mostly yes — for area estimation and monitoring. But ground truth surveys (5–10%) are still recommended for validation.


What’s Next in the Agricultural Data Science Series

  • Part 5: Time series NDVI analysis for paddy crop monitoring — tracking health through the season
  • Part 6: Yield prediction using satellite indices and weather data
  • Part 7: Automated change detection — identifying crop switches mid-season

Conclusion

In this post you learned how to:

  • Collect and prepare training data from field surveys
  • Extract spectral features from Sentinel-2 multispectral imagery
  • Engineer high-level features (indices, ratios) to improve classification
  • Train a Random Forest classifier on agricultural field samples
  • Classify entire districts or states into crop type maps
  • Validate accuracy and understand model decisions
  • Deploy the workflow monthly for operational crop monitoring

This is exactly the methodology used in modern crop condition monitoring research. Combined with Part 1 (NDVI), Part 2 (Sentinel-2 download), and Part 3 (Vegetation indices), you now have a complete pipeline for satellite-based agricultural monitoring.

The accuracy you achieve — 85–95% for 3–5 crop types — is production-ready. This workflow directly supports:

  • Government crop insurance (PMFBY) validation
  • District agriculture office decision-making
  • Early warning systems for crop stress
  • Yield forecasting models
  • Land use change detection

Take the Next Step

📩 Get Part 5 next week — Time series NDVI analysis tracks how paddy health changes week-by-week through the Kharif season. Subscribe free below and get it in your inbox.

👉 [Subscribe here — it’s free]

💬 Have you classified crops in your research? Drop a comment below with your crop combinations and target accuracy — I reply to every comment and can suggest optimisations.

Want a quick reference guide?

Download the Remote Sensing Cheat Sheet (₹199)

Includes: Sentinel-2 bands, vegetation index formulas,
crop signatures, Python template, and common mistakes


Data Science with DEB — Practical tutorials on Python, remote sensing, and machine learning for agricultural researchers. Published weekly at dibyendudeb.com


How to Download Free Sentinel-2 Data from Copernicus Hub Using Python

Download Sentinel-2 data Python

Introduction: The Data Problem Every Agricultural Researcher Faces

In Part 1 of this series, I showed you how to calculate NDVI from Sentinel-2 satellite data using Python. The most common question I received after that post was:

“Where do I actually get the Sentinel-2 data files?”

This is the practical gap that most remote sensing tutorials skip. They show you the analysis but assume you already have the data sitting on your hard drive.

In this post I fill that gap completely. By the end you will know how to:

  • Create a free Copernicus account and understand the data portal
  • Find Sentinel-2 images for any location in India — your study area, your district, your field
  • Filter images by date, cloud cover, and tile
  • Download data automatically using Python and the sentinelsat library
  • Understand the folder structure of downloaded Sentinel-2 data
  • Extract exactly the bands you need (Band 4 and Band 8 for NDVI)

Everything here is free. No paid software. No institutional login required. Just a free Copernicus account and Python.


What is Copernicus Hub and Why is the Data Free?

The Copernicus Programme is the European Union’s Earth observation programme. It operates a fleet of Sentinel satellites that continuously image the entire Earth’s surface. The data collected is made available free of charge to anyone in the world — researchers, students, government agencies, farmers, and developers.

For agricultural scientists in India this is a remarkable resource:

  • Sentinel-2 revisits any location every 5 days (with two satellites combined)
  • 10-metre resolution for key bands — fine enough to distinguish individual farm plots
  • 13 spectral bands covering visible, near-infrared, and shortwave infrared wavelengths
  • Free archive going back to 2015 — over 10 years of historical data for time series analysis
  • Covers all of India — every district, every state, every season

For context: equivalent commercial satellite data would cost ₹50,000–₹5,00,000 per image. You get it free.


Sentinel-2 Bands Quick Reference

Before downloading, it helps to know which bands you actually need:

BandNameWavelengthResolutionKey Use
Band 2Blue490 nm10 mTrue colour composite
Band 3Green560 nm10 mTrue colour composite
Band 4Red665 nm10 mNDVI, crop stress
Band 8NIR842 nm10 mNDVI, vegetation health
Band 8ARed Edge865 nm20 mCanopy chlorophyll
Band 11SWIR-11610 nm20 mSoil moisture, NDWI
Band 12SWIR-22190 nm20 mMineral mapping

For NDVI: You need Band 4 (Red) and Band 8 (NIR) — both at 10m resolution.
For soil moisture: You additionally need Band 11 (SWIR-1) at 20m.


Part 1 — Manual Download via Copernicus Browser (No Code Required)

Before learning the Python approach, let me show you the manual method. This is useful when you need one or two images quickly.

Step 1: Create Your Free Copernicus Account

  1. Go to https://browser.dataspace.copernicus.eu/
  2. Click Register in the top right
  3. Fill in your details — use your personal email
  4. Verify your email
  5. Log in

Note: The old Copernicus Open Access Hub (scihub.copernicus.eu) was retired in 2023. The new portal is Copernicus Data Space Ecosystem at dataspace.copernicus.eu. All links and API endpoints have changed. Make sure you are using the new portal.

Step 2: Find Your Study Area

  1. On the map, navigate to your study area in India
  2. Use the search box — type your district name (e.g., “Kamrup, Assam”)
  3. The map will zoom to your location
  4. You can draw a polygon around your exact area of interest using the draw tool

Step 3: Search for Images

On the left panel:

  • Data source: Select Sentinel-2 L2A (atmospherically corrected — use this, not L1C)
  • Date range: Enter your start and end date
  • Cloud cover: Set maximum to 20% (anything above 20% clouds makes agricultural analysis unreliable)
  • Click Search

A list of available images appears. Each entry shows:

  • Acquisition date and time
  • Cloud cover percentage
  • Tile ID (more on this below)
  • Thumbnail preview

Step 4: Download

Click on any image → click the download icon → select which files to download.

What to download:

  • For NDVI only: Download Band 4 and Band 8 files individually (saves bandwidth)
  • For full analysis: Download the complete product (2–8 GB per image)

Understanding Sentinel-2 Tile System

India is divided into Sentinel-2 Military Grid Reference System (MGRS) tiles. Each tile is 100km × 100km. Knowing your tile code saves a lot of search time.

Common tiles for India:

RegionTile Codes
Assam / Northeast IndiaT46RBN, T46RBM, T46QBL, T46QBK
West BengalT45RYL, T45RYK, T45QYL
Punjab / HaryanaT43RFQ, T43RGQ, T43SGQ
MaharashtraT43PBR, T43PCR, T44PMS
Andhra Pradesh / TelanganaT44QMF, T44QNF, T44QLF
Tamil Nadu / KarnatakaT43PGR, T44PKS, T43QHB
Uttar PradeshT44RKQ, T44RLQ, T44RMQ
RajasthanT43RGP, T43RHP, T43RFP
OdishaT45QXE, T45QYE, T44QQE
GujaratT42RUS, T43RBP, T43RCP

For Assam specifically: if your study area is in Kamrup or nearby districts, your tile is likely T46RBN.


Part 2 — Automated Download Using Python (sentinelsat)

The manual approach works for occasional downloads. But if you need:

  • Multiple dates for time series analysis
  • Multiple tiles for district or state-level analysis
  • Regular automated downloads (monthly crop monitoring)

…then Python automation is the right approach.

Step 1: Install Required Libraries

pip install sentinelsat geopandas shapely requests

Note on sentinelsat: The library has been updated to work with the new Copernicus Data Space API. Make sure you install version 1.0 or later.

pip install sentinelsat --upgrade

Step 2: Set Up Your Credentials

Never hardcode your password in a script. Use environment variables or a config file.

import os

# Option 1: Set as environment variables (recommended)
# Run these in your terminal before running the script:
# export COPERNICUS_USER="your_username"
# export COPERNICUS_PASSWORD="your_password"

username = os.environ.get('COPERNICUS_USER', 'your_username_here')
password = os.environ.get('COPERNICUS_PASSWORD', 'your_password_here')

Step 3: Connect to Copernicus Data Space

from sentinelsat import SentinelAPI, read_geojson, geojson_to_wkt
from datetime import date
import geopandas as gpd
from shapely.geometry import box
import pandas as pd
import os

# Connect to new Copernicus Data Space API
api = SentinelAPI(
    username,
    password,
    'https://apihub.copernicus.eu/apihub'  # Updated endpoint
)

print("Successfully connected to Copernicus Data Space")

Step 4: Define Your Area of Interest

You can define your study area in three ways:

Method A — Bounding Box (simplest)

# Define bounding box for your study area
# Format: (min_longitude, min_latitude, max_longitude, max_latitude)

# Example: Kamrup district, Assam
min_lon, min_lat = 91.2, 25.8   # Southwest corner
max_lon, max_lat = 91.9, 26.3   # Northeast corner

# Create footprint from bounding box
from shapely.geometry import box
footprint = box(min_lon, min_lat, max_lon, max_lat).wkt

print(f"Study area defined: {min_lon}°E to {max_lon}°E, "
      f"{min_lat}°N to {max_lat}°N")

Method B — From a Shapefile (for district/block boundaries)

# If you have a shapefile of your study area
import geopandas as gpd

# Load shapefile (e.g., district boundary)
study_area = gpd.read_file("kamrup_district.shp")

# Convert to WGS84 if needed
study_area = study_area.to_crs("EPSG:4326")

# Get footprint as WKT
footprint = study_area.geometry.union_all().wkt
print("Study area loaded from shapefile")

Method C — Draw on Map and Export GeoJSON

# If you drew your area in QGIS or geojson.io and saved as GeoJSON
from sentinelsat import read_geojson, geojson_to_wkt

footprint = geojson_to_wkt(read_geojson('my_study_area.geojson'))
print("Study area loaded from GeoJSON")

Step 5: Search for Available Images

# Search for Sentinel-2 images
products = api.query(
    footprint,
    date=('20231001', '20231115'),    # Date range: 1 Oct to 15 Nov 2023
    platformname='Sentinel-2',
    producttype='S2MSI2A',             # L2A = atmospherically corrected
    cloudcoverpercentage=(0, 20)       # Maximum 20% cloud cover
)

# Convert to DataFrame for easy viewing
products_df = api.to_dataframe(products)

print(f"\nFound {len(products_df)} images matching your criteria")
print("\nAvailable images:")
print(products_df[['title', 'cloudcoverpercentage',
                    'size', 'beginposition']].to_string())

Sample output:

Found 4 images matching your criteria

Available images:
                                    title  cloudcoverpercentage  size  beginposition
S2A_MSIL2A_20231005T043701_N0509...        3.24  1.2 GB  2023-10-05
S2B_MSIL2A_20231010T043659_N0509...        8.91  1.1 GB  2023-10-10
S2A_MSIL2A_20231020T043701_N0509...       12.45  1.3 GB  2023-10-20
S2B_MSIL2A_20231115T043659_N0509...        5.67  1.2 GB  2023-11-15

Step 6: Select the Best Image

# Sort by cloud cover — lowest first
products_df = products_df.sort_values('cloudcoverpercentage')

# Select the clearest image
best_product = products_df.iloc[0]
best_product_id = products_df.index[0]

print(f"\nBest image selected:")
print(f"  Date: {best_product['beginposition'].date()}")
print(f"  Cloud cover: {best_product['cloudcoverpercentage']:.1f}%")
print(f"  Size: {best_product['size']}")
print(f"  Product ID: {best_product_id}")

Step 7: Download the Image

import os

# Create download directory
download_dir = "sentinel2_data"
os.makedirs(download_dir, exist_ok=True)

print(f"\nDownloading image to '{download_dir}' folder...")
print("This may take 10–30 minutes depending on your internet speed.")
print("File size is approximately 1–1.5 GB\n")

# Download the selected product
api.download(best_product_id, directory_path=download_dir)

print(f"\n✓ Download complete!")
print(f"  Check the '{download_dir}' folder for your data.")

Understanding the Downloaded Data Structure

After downloading, you will find a .SAFE folder. Here is what is inside:

S2A_MSIL2A_20231005T043701_N0509_R031_T46RBN_20231005T221503.SAFE/
│
├── GRANULE/
│   └── L2A_T46RBN_A043082_20231005T044459/
│       └── IMG_DATA/
│           ├── R10m/          ← 10-metre resolution bands
│           │   ├── T46RBN_20231005T043701_B02_10m.tif  (Blue)
│           │   ├── T46RBN_20231005T043701_B03_10m.tif  (Green)
│           │   ├── T46RBN_20231005T043701_B04_10m.tif  ← RED (for NDVI)
│           │   └── T46RBN_20231005T043701_B08_10m.tif  ← NIR (for NDVI)
│           │
│           ├── R20m/          ← 20-metre resolution bands
│           │   ├── T46RBN_20231005T043701_B05_20m.tif
│           │   ├── T46RBN_20231005T043701_B06_20m.tif
│           │   ├── T46RBN_20231005T043701_B8A_20m.tif
│           │   ├── T46RBN_20231005T043701_B11_20m.tif
│           │   └── T46RBN_20231005T043701_B12_20m.tif
│           │
│           └── R60m/          ← 60-metre resolution bands
│               ├── T46RBN_20231005T043701_B01_60m.tif
│               └── T46RBN_20231005T043701_B09_60m.tif
│
├── MTD_MSIL2A.xml             ← Metadata file
└── INSPIRE.xml

For NDVI analysis, you need only these two files from R10m folder:

  • B04_10m.tif — Red band
  • B08_10m.tif — NIR band

Automatically Extract Band Paths

import glob
import os
import zipfile

# The download may come as a .zip file — extract it first
zip_files = glob.glob(os.path.join(download_dir, "*.zip"))

if zip_files:
    zip_path = zip_files[0]
    print(f"Extracting {os.path.basename(zip_path)}...")
    with zipfile.ZipFile(zip_path, 'r') as zip_ref:
        zip_ref.extractall(download_dir)
    print("Extraction complete.")

# Find the .SAFE folder
safe_folders = glob.glob(os.path.join(download_dir, "*.SAFE"))
safe_folder = safe_folders[0]
print(f"\nSAFE folder: {os.path.basename(safe_folder)}")

# Find Band 4 (Red) and Band 8 (NIR) at 10m resolution
red_band_path = glob.glob(
    os.path.join(safe_folder, "GRANULE/*/IMG_DATA/R10m/*B04_10m.tif")
)[0]

nir_band_path = glob.glob(
    os.path.join(safe_folder, "GRANULE/*/IMG_DATA/R10m/*B08_10m.tif")
)[0]

print(f"\nBand paths found:")
print(f"  Red (B04): {os.path.basename(red_band_path)}")
print(f"  NIR (B08): {os.path.basename(nir_band_path)}")
print("\n✓ Ready for NDVI calculation!")
print("  Feed these paths into the NDVI code from Part 1.")

Part 3 — Download Multiple Dates for Time Series

For crop monitoring, you often need images across an entire season. Here is how to download multiple dates efficiently:

# Search for entire Kharif season: June to November 2023
products_season = api.query(
    footprint,
    date=('20230601', '20231130'),
    platformname='Sentinel-2',
    producttype='S2MSI2A',
    cloudcoverpercentage=(0, 25)
)

products_season_df = api.to_dataframe(products_season)
products_season_df = products_season_df.sort_values('beginposition')

print(f"Found {len(products_season_df)} images for Kharif 2023 season")
print("\nImage dates available:")
for idx, row in products_season_df.iterrows():
    print(f"  {row['beginposition'].date()} — "
          f"Cloud cover: {row['cloudcoverpercentage']:.1f}%")

Sample output:

Found 18 images for Kharif 2023 season

Image dates available:
  2023-06-07 — Cloud cover: 45.2%   ← Too cloudy — skip
  2023-06-12 — Cloud cover: 18.9%   ← Acceptable
  2023-06-17 — Cloud cover: 67.3%   ← Too cloudy — skip
  2023-06-22 — Cloud cover:  8.1%   ← Good
  ...
  2023-10-05 — Cloud cover:  3.2%   ← Excellent
  2023-10-20 — Cloud cover: 12.4%   ← Good
  2023-11-15 — Cloud cover:  5.7%   ← Excellent
# Filter to only low-cloud images (under 20%)
clear_images = products_season_df[
    products_season_df['cloudcoverpercentage'] < 20
].copy()

print(f"\n{len(clear_images)} clear images selected for download")
print(f"Estimated total download size: "
      f"~{len(clear_images) * 1.2:.0f} GB")
print("\nConfirm before downloading large datasets!")

# Download all clear images
# WARNING: This can be 5–20 GB total — ensure you have disk space
confirm = input("\nProceed with download? (yes/no): ")

if confirm.lower() == 'yes':
    for i, (product_id, row) in enumerate(clear_images.iterrows(), 1):
        print(f"\nDownloading image {i}/{len(clear_images)}: "
              f"{row['beginposition'].date()}")
        try:
            api.download(product_id, directory_path=download_dir)
            print(f"  ✓ Downloaded successfully")
        except Exception as e:
            print(f"  ✗ Download failed: {e}")
            continue

    print(f"\n✓ All downloads complete!")
    print(f"  {len(clear_images)} images saved to '{download_dir}'")

Part 4 — Lightweight Alternative: Download Only Specific Bands

Full Sentinel-2 products are 1–1.5 GB each. If you only need Band 4 and Band 8 for NDVI, you can save significant bandwidth by downloading only those files.

import requests
from requests.auth import HTTPBasicAuth

def download_specific_band(product_id, band_name, output_dir,
                            username, password):
    """
    Download a specific band file from a Sentinel-2 product.

    Args:
        product_id: Product UUID from sentinelsat search
        band_name: e.g., 'B04_10m' or 'B08_10m'
        output_dir: Where to save the file
        username: Copernicus username
        password: Copernicus password
    """
    # Get product info
    product_info = api.get_product_odata(product_id)

    # Construct band file URL
    # Note: This requires knowing the internal file path
    # The full download approach above is more reliable for beginners
    pass  # Implementation varies by API version

# For most use cases, downloading the full product
# and extracting bands locally is more reliable.
# The full product approach in Part 2 is recommended.

Practical tip: If bandwidth is a concern, use Copernicus Browser (browser.dataspace.copernicus.eu) to visually browse and download individual band files manually. Select your product → click “Download individual files” → select only B04 and B08.


Alternative: Google Earth Engine (For Large Areas)

If your study covers an entire state or multiple districts, downloading Sentinel-2 data locally may not be practical. Google Earth Engine (GEE) is a better option for large-scale analysis — it processes data in the cloud without downloading anything.

GEE is free for researchers. Here is a preview of how simple it is:

// Google Earth Engine JavaScript API
// Run this in code.earthengine.google.com

// Load Sentinel-2 collection for Assam, October 2023
var collection = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
  .filterDate('2023-10-01', '2023-10-31')
  .filterBounds(ee.Geometry.Rectangle([90.2, 24.1, 96.0, 28.2]))
  .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20));

// Calculate NDVI for the least cloudy image
var image = collection.sort('CLOUDY_PIXEL_PERCENTAGE').first();
var ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI');

// Visualise
Map.centerObject(ndvi, 8);
Map.addLayer(ndvi, {min: 0, max: 0.8, palette: ['red','yellow','green']},
             'NDVI Assam Oct 2023');

I will cover Google Earth Engine with Python in a separate post in this series.


Complete Script: Search, Select, and Download

Here is the complete workflow in one clean script:

"""
Sentinel-2 Data Download — Complete Workflow
Author: Data Science with DEB | dibyendudeb.com
Part 2 of Agricultural Data Science with Python Series

Downloads Sentinel-2 L2A data from Copernicus Data Space
for a specified study area and date range.

Requirements:
    pip install sentinelsat geopandas shapely
"""

import os
import glob
import zipfile
from datetime import datetime
from sentinelsat import SentinelAPI
from shapely.geometry import box

# ─── CONFIGURATION ─────────────────────────────────────────────────────────
USERNAME     = os.environ.get('COPERNICUS_USER', 'your_username')
PASSWORD     = os.environ.get('COPERNICUS_PASSWORD', 'your_password')
DOWNLOAD_DIR = "sentinel2_downloads"

# Study area: Kamrup district, Assam
# Replace with your study area coordinates
MIN_LON, MIN_LAT = 91.2, 25.8
MAX_LON, MAX_LAT = 91.9, 26.3

# Date range
START_DATE = '20231001'
END_DATE   = '20231115'

# Maximum acceptable cloud cover (%)
MAX_CLOUD  = 20

# ─── CONNECT ───────────────────────────────────────────────────────────────
print("Connecting to Copernicus Data Space...")
api = SentinelAPI(USERNAME, PASSWORD,
                  'https://apihub.copernicus.eu/apihub')
print("✓ Connected\n")

# ─── DEFINE STUDY AREA ─────────────────────────────────────────────────────
footprint = box(MIN_LON, MIN_LAT, MAX_LON, MAX_LAT).wkt
print(f"Study area: {MIN_LON}°E–{MAX_LON}°E, {MIN_LAT}°N–{MAX_LAT}°N")

# ─── SEARCH ────────────────────────────────────────────────────────────────
print(f"\nSearching for images: {START_DATE} to {END_DATE}")
print(f"Maximum cloud cover: {MAX_CLOUD}%\n")

products = api.query(
    footprint,
    date=(START_DATE, END_DATE),
    platformname='Sentinel-2',
    producttype='S2MSI2A',
    cloudcoverpercentage=(0, MAX_CLOUD)
)

df = api.to_dataframe(products).sort_values('cloudcoverpercentage')

print(f"Found {len(df)} suitable images:")
for _, row in df.iterrows():
    print(f"  {row['beginposition'].date()} | "
          f"Cloud: {row['cloudcoverpercentage']:.1f}% | "
          f"Size: {row['size']}")

# ─── SELECT BEST ───────────────────────────────────────────────────────────
if len(df) == 0:
    print("\n✗ No images found. Try:")
    print("  - Widening the date range")
    print("  - Increasing maximum cloud cover")
    print("  - Checking your bounding box coordinates")
    exit()

best_id  = df.index[0]
best_row = df.iloc[0]
print(f"\nBest image: {best_row['beginposition'].date()} "
      f"({best_row['cloudcoverpercentage']:.1f}% cloud)")

# ─── DOWNLOAD ──────────────────────────────────────────────────────────────
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
print(f"\nDownloading to '{DOWNLOAD_DIR}'...")
print("Please wait — this may take 10–30 minutes.\n")

api.download(best_id, directory_path=DOWNLOAD_DIR)

# ─── EXTRACT ───────────────────────────────────────────────────────────────
zip_files = glob.glob(os.path.join(DOWNLOAD_DIR, "*.zip"))
if zip_files:
    print("Extracting zip file...")
    with zipfile.ZipFile(zip_files[0], 'r') as z:
        z.extractall(DOWNLOAD_DIR)

# ─── FIND BAND PATHS ───────────────────────────────────────────────────────
safe_dir  = glob.glob(os.path.join(DOWNLOAD_DIR, "*.SAFE"))[0]
red_path  = glob.glob(
    os.path.join(safe_dir, "GRANULE/*/IMG_DATA/R10m/*B04_10m.tif"))[0]
nir_path  = glob.glob(
    os.path.join(safe_dir, "GRANULE/*/IMG_DATA/R10m/*B08_10m.tif"))[0]

print("\n" + "=" * 55)
print("✓ DOWNLOAD COMPLETE")
print("=" * 55)
print(f"Red band (B04): {os.path.basename(red_path)}")
print(f"NIR band (B08): {os.path.basename(nir_path)}")
print("\nNext step: Feed these paths into the NDVI")
print("calculation script from Part 1 of this series.")
print("=" * 55)

Troubleshooting Common Issues

Issue: “Authentication failed”

  • Double-check your Copernicus username and password
  • Make sure you are using the new dataspace.copernicus.eu account — old scihub accounts may need migration

Issue: “No products found”

  • Try widening your date range to 2–3 months
  • Increase maximum cloud cover to 30% temporarily
  • Verify your bounding box coordinates are correct (use Google Maps to check lat/lon)
  • Northeast India gets heavy cloud cover June–September — try October–November for Kharif monitoring

Issue: “Product is offline”

  • Older Sentinel-2 images (before 2020) may be in long-term archive storage
  • Request the product to be brought online — Copernicus usually processes this within 24 hours
  • Use the api.download() with checksum=False parameter if download is interrupted

Issue: Download very slow

  • Copernicus servers can be slow during peak hours (European business hours = IST 13:30–21:30)
  • Download early morning IST (before 13:00) for best speeds
  • File sizes are 1–1.5 GB — expect 20–60 minutes on a typical Indian broadband connection

Issue: Disk space

  • Each full Sentinel-2 product is 1–1.5 GB compressed, 3–5 GB extracted
  • For time series (10 images), plan for 30–50 GB storage
  • Delete the zip files after extraction to save space

What to Do With Your Downloaded Data

Once you have your Band 4 and Band 8 files, the next steps are:

  1. Calculate NDVI — use the complete Python script from Part 1 of this series
  2. Clip to your study area — use rasterio.mask to clip the 100km tile to your district or field
  3. Stack multiple dates — for time series NDVI analysis across the crop season
  4. Export maps — save as GeoTIFF for QGIS or as PNG for reports

Frequently Asked Questions

Q: Do I need to pay for Copernicus data? No. All Sentinel data is free to download and use for any purpose — research, commercial, educational.

Q: How often does Sentinel-2 revisit India? Every 5 days on average (combining Sentinel-2A and Sentinel-2B satellites). In practice you get 2–4 usable (low-cloud) images per month for most Indian locations.

Q: Can I download data for the entire state of Assam? Yes — but it will span multiple tiles and multiple gigabytes. For state-level analysis, Google Earth Engine (cloud processing) is more practical than local download.

Q: Is Sentinel-2 data available for the 1990s or 2000s? No. Sentinel-2 archive starts from 2015. For historical analysis before 2015, use Landsat data from USGS EarthExplorer (free, going back to 1972 but at 30m resolution).

Q: Can I use this data in my research publications? Yes — Copernicus data is freely usable for research. Cite as: “Copernicus Sentinel data [year], processed by [your name/institution].”


What’s Next in the Agricultural Data Science Series

  • Part 3: SAVI vs NDVI vs EVI — which vegetation index should you use and when?
  • Part 4: Random Forest for crop type classification using Sentinel-2 multispectral data
  • Part 5: Time series NDVI analysis for paddy crop monitoring — a complete case study

Conclusion

In this post you learned how to:

  • Navigate the Copernicus Data Space and find Sentinel-2 images for any location in India
  • Use Python and sentinelsat to search, filter, and download images automatically
  • Understand the downloaded folder structure and extract the exact bands you need
  • Download multiple dates for seasonal time series analysis
  • Troubleshoot the most common download problems

Combined with the NDVI calculation script from Part 1, you now have a complete free pipeline — from raw satellite to vegetation health map — entirely in Python.


Take the Next Step

📩 Get the next tutorial in your inbox — I publish one practical agricultural data science tutorial every week. Subscribe free below and never miss a post in this series.

👉 [Subscribe here — it’s free]

💬 Tell me your study area — drop a comment below with your district or state and I’ll confirm which Sentinel-2 tile code you need. I reply to every comment.

Want a quick reference guide?

Download the Remote Sensing Cheat Sheet (₹199)

Includes: Sentinel-2 bands, vegetation index formulas,
crop signatures, Python template, and common mistakes


Data Science with DEB — Practical tutorials on Python, remote sensing, and machine learning for agricultural researchers. Published weekly at dibyendudeb.com