Random Forest for Crop Type Classification Using Sentinel-2 Data: A Complete Guide with 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.


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


Leave a Comment