Multispectral Analysis

Multispectral analysis is using multiple wavelength bands to extract information invisible to the human eye. Your eye sees three bands (red, green, blue). Sentinel-2 sees 13. Those extra bands — near-infrared, red edge, shortwave infrared — reveal vegetation health, water content, mineral composition, and camouflage. This is where remote sensing becomes intelligence.


The Electromagnetic Spectrum for GEOINT

Each part of the spectrum interacts differently with surface materials. Understanding these interactions lets you extract specific information from imagery.

Visible (400-700nm)

BandWavelengthS2 BandWhat It Reveals
Blue~490nmB02Water penetration, atmospheric scatter, dust/smoke
Green~560nmB03Peak vegetation reflectance, water turbidity
Red~665nmB04Chlorophyll absorption (low reflectance in healthy plants)

Intelligence note: The visible bands give you what a photograph gives — color, texture, context. But the real analytical power comes from bands below.

Red Edge (700-783nm)

Three Sentinel-2 bands (B05, B06, B07) capture the “red edge” — the steep rise in reflectance between red absorption and NIR plateau in vegetation.

Why this matters:

  • Vegetation stress shifts the red edge to shorter wavelengths BEFORE visible symptoms appear
  • Detects camouflage: artificial green materials don’t replicate the red edge
  • Crop health monitoring: stressed crops show red edge shift weeks before yield drops
import numpy as np
import matplotlib.pyplot as plt
 
# Red edge detection of vegetation stress
wavelengths = [665, 705, 740, 783, 842]
band_names = ["B04\nRed", "B05\nRE1", "B06\nRE2", "B07\nRE3", "B08\nNIR"]
 
healthy = [0.04, 0.12, 0.35, 0.48, 0.50]
stressed = [0.06, 0.08, 0.20, 0.30, 0.32]
artificial_green = [0.04, 0.05, 0.06, 0.07, 0.08]  # camouflage paint/netting
 
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(wavelengths, healthy, "go-", linewidth=2, markersize=10, label="Healthy vegetation")
ax.plot(wavelengths, stressed, "yo-", linewidth=2, markersize=10, label="Stressed vegetation")
ax.plot(wavelengths, artificial_green, "ko--", linewidth=2, markersize=10,
        label="Artificial green (camo)")
 
ax.axvspan(700, 783, alpha=0.1, color="red", label="Red edge region")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance")
ax.set_title("Red Edge — Detecting Vegetation Stress and Camouflage")
ax.set_xticks(wavelengths)
ax.set_xticklabels(band_names)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("red_edge_detection.png", dpi=150)
plt.show()

Near-Infrared (NIR, 842nm)

B08 is the most important band for GEOINT after visible RGB.

  • Vegetation reflects NIR strongly (~50%) — healthy plants have high NIR
  • Water absorbs NIR completely — water boundaries are razor-sharp in NIR
  • Urban surfaces have moderate NIR reflectance
  • Snow has very high NIR initially but drops rapidly with age

Shortwave Infrared (SWIR, 1610nm and 2190nm)

B11 and B12 are sensitive to moisture content and certain minerals.

  • Moisture detection: wet soil is darker in SWIR than dry soil. Fresh vehicle tracks on dry dirt roads show darker SWIR.
  • Mineral identification: different minerals have diagnostic absorption features in SWIR
  • Fire detection: active fires are very bright in SWIR (thermal emission)
  • Snow vs cloud discrimination: snow has very low SWIR reflectance; clouds have high SWIR. This solves one of the hardest problems in optical remote sensing.

Vegetation Indices

Indices combine bands mathematically to isolate specific properties. They normalize for illumination variations, making them comparable across time.

NDVI — Normalized Difference Vegetation Index

NDVI = (NIR - Red) / (NIR + Red)
NDVI ValueInterpretation
-1 to 0Water, snow, clouds
0 to 0.1Bare soil, rock, sand
0.1 to 0.3Sparse vegetation, grassland
0.3 to 0.6Moderate vegetation, crops
0.6 to 0.9Dense healthy vegetation, forest

Intelligence application: NDVI time series reveals activity. Sudden NDVI drops indicate vegetation removal (construction, deforestation, military earthworks). Abnormal NDVI patterns may indicate irrigation changes or agricultural disruption.

EVI — Enhanced Vegetation Index

Corrects for atmospheric and soil background effects that NDVI doesn’t handle well.

def compute_evi(nir, red, blue, G=2.5, C1=6.0, C2=7.5, L=1.0):
    """
    Enhanced Vegetation Index.
    More sensitive than NDVI in high-biomass areas (dense forests).
    Less sensitive to atmospheric effects.
    """
    nir, red, blue = [b.astype(np.float32) for b in [nir, red, blue]]
    evi = G * (nir - red) / (nir + C1 * red - C2 * blue + L)
    return np.clip(evi, -1, 1)

SAVI — Soil-Adjusted Vegetation Index

For areas with significant soil background (sparse vegetation, agriculture).

def compute_savi(nir, red, L=0.5):
    """
    Soil-Adjusted Vegetation Index.
    L=0.5 is standard. L=0 for very dense vegetation (= NDVI). L=1 for sparse.
    """
    nir, red = nir.astype(np.float32), red.astype(np.float32)
    return ((nir - red) * (1 + L)) / (nir + red + L)

Water Indices

NDWI — Normalized Difference Water Index

def compute_ndwi(green, nir):
    """
    NDWI (McFeeters, 1996). Green=B03, NIR=B08.
    Positive values indicate water. Useful for water body delineation.
    """
    g, n = green.astype(np.float32), nir.astype(np.float32)
    return (g - n) / (g + n + 1e-10)
 
def compute_mndwi(green, swir):
    """
    Modified NDWI (Xu, 2006). Green=B03, SWIR=B11.
    Better than NDWI for separating water from built-up areas.
    """
    g, s = green.astype(np.float32), swir.astype(np.float32)
    return (g - s) / (g + s + 1e-10)

Intelligence application: Water indices detect flooding, map coastlines, identify dam construction, monitor reservoir levels. MNDWI is better than NDWI in urban areas because buildings have high NIR (confusing NDWI) but moderate SWIR.


Built-Up and Urban Indices

NDBI — Normalized Difference Built-up Index

def compute_ndbi(swir, nir):
    """
    NDBI (Zha, 2003). SWIR=B11, NIR=B08.
    Positive values indicate built-up/urban areas.
    """
    s, n = swir.astype(np.float32), nir.astype(np.float32)
    return (s - n) / (s + n + 1e-10)

Urban index strategy:

Combine NDBI with NDVI to separate urban from bare soil (both have low NDVI, but urban has higher NDBI):

urban_mask = (ndbi > 0) & (ndvi < 0.2)

Burn / Fire Indices

NBR — Normalized Burn Ratio

def compute_nbr(nir, swir2):
    """
    NBR (Key & Benson, 2006). NIR=B08, SWIR2=B12.
    Used for fire scar mapping. Burned areas have low NIR and high SWIR2.
    """
    n, s = nir.astype(np.float32), swir2.astype(np.float32)
    return (n - s) / (n + s + 1e-10)
 
def compute_dnbr(nbr_pre, nbr_post):
    """
    Differenced NBR = pre-fire NBR - post-fire NBR.
    dNBR > 0.1: low severity burn
    dNBR > 0.27: moderate severity
    dNBR > 0.66: high severity
    """
    return nbr_pre - nbr_post

Band Combination Table

Quick reference for what band combination to use for each intelligence question:

QuestionBands (R,G,B display)IndexWhy
General visual assessmentB04,B03,B02Natural color
Vegetation healthB08,B04,B03NDVINIR highlights vegetation
Water bodiesB08,B11,B04NDWI/MNDWIWater dark in NIR/SWIR
Urban areasB12,B11,B04NDBIUrban bright in SWIR
Fire scarsB12,B08,B04NBR/dNBRBurn scar high SWIR, low NIR
Vegetation vs camoB07,B05,B04Red edge slopeFake green lacks red edge
Soil moistureB11,B08,B02SWIR ratioWet soil dark in SWIR
Snow/iceB04,B11,B12NDSISnow dark in SWIR
Geology/mineralsB12,B11,B08Various ratiosMineral absorption features

Complete Multispectral Analysis Pipeline

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
import matplotlib.patches as mpatches
 
def multispectral_pipeline(bands):
    """
    Complete multispectral analysis pipeline.
    bands: dict with keys B02, B03, B04, B08, B11, B12 (float32 reflectance)
    Returns: classification map + index layers
    """
    # Compute indices
    ndvi = (bands["B08"] - bands["B04"]) / (bands["B08"] + bands["B04"] + 1e-10)
    ndwi = (bands["B03"] - bands["B08"]) / (bands["B03"] + bands["B08"] + 1e-10)
    mndwi = (bands["B03"] - bands["B11"]) / (bands["B03"] + bands["B11"] + 1e-10)
    ndbi = (bands["B11"] - bands["B08"]) / (bands["B11"] + bands["B08"] + 1e-10)
 
    # Threshold-based classification
    # Order matters: more specific rules first
    classification = np.zeros(ndvi.shape, dtype=np.uint8)
 
    # 1 = Water (MNDWI > 0.1 and NDVI < 0.1)
    classification[(mndwi > 0.1) & (ndvi < 0.1)] = 1
 
    # 2 = Dense vegetation (NDVI > 0.5)
    classification[(ndvi > 0.5) & (classification == 0)] = 2
 
    # 3 = Sparse vegetation (NDVI 0.2-0.5)
    classification[(ndvi > 0.2) & (ndvi <= 0.5) & (classification == 0)] = 3
 
    # 4 = Urban/built-up (NDBI > 0, NDVI < 0.2)
    classification[(ndbi > 0) & (ndvi < 0.2) & (classification == 0)] = 4
 
    # 5 = Bare soil (NDVI < 0.2, NDBI <= 0, not water)
    classification[(ndvi < 0.2) & (classification == 0)] = 5
 
    # 0 = Unclassified (remaining)
 
    return {
        "ndvi": ndvi,
        "ndwi": ndwi,
        "mndwi": mndwi,
        "ndbi": ndbi,
        "classification": classification,
    }
 
 
def plot_analysis_results(bands, results, save_path=None):
    """Visualize multispectral analysis results."""
    fig, axes = plt.subplots(2, 3, figsize=(18, 12))
 
    # True color
    rgb = np.stack([bands["B04"], bands["B03"], bands["B02"]], axis=-1)
    rgb = np.clip(rgb * 3.5, 0, 1)
    axes[0, 0].imshow(rgb)
    axes[0, 0].set_title("True Color (B4-B3-B2)")
 
    # Color infrared
    cir = np.stack([bands["B08"], bands["B04"], bands["B03"]], axis=-1)
    cir = np.clip(cir * 3, 0, 1)
    axes[0, 1].imshow(cir)
    axes[0, 1].set_title("Color Infrared (B8-B4-B3)")
 
    # NDVI
    im = axes[0, 2].imshow(results["ndvi"], cmap="RdYlGn", vmin=-0.2, vmax=0.8)
    axes[0, 2].set_title("NDVI")
    plt.colorbar(im, ax=axes[0, 2], fraction=0.046)
 
    # MNDWI
    im = axes[1, 0].imshow(results["mndwi"], cmap="RdBu", vmin=-0.5, vmax=0.5)
    axes[1, 0].set_title("MNDWI (water positive)")
    plt.colorbar(im, ax=axes[1, 0], fraction=0.046)
 
    # NDBI
    im = axes[1, 1].imshow(results["ndbi"], cmap="RdYlBu_r", vmin=-0.5, vmax=0.5)
    axes[1, 1].set_title("NDBI (urban positive)")
    plt.colorbar(im, ax=axes[1, 1], fraction=0.046)
 
    # Classification
    class_names = ["Unclassified", "Water", "Dense veg", "Sparse veg", "Urban", "Bare soil"]
    class_colors = ["white", "#1f77b4", "#2ca02c", "#98df8a", "#7f7f7f", "#d2b48c"]
    cmap = ListedColormap(class_colors)
    im = axes[1, 2].imshow(results["classification"], cmap=cmap, vmin=0, vmax=5)
    axes[1, 2].set_title("Land Cover Classification")
    legend = [mpatches.Patch(color=c, label=n) for c, n in zip(class_colors, class_names)]
    axes[1, 2].legend(handles=legend, loc="lower right", fontsize=8)
 
    for ax in axes.flat:
        ax.axis("off")
 
    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=150, bbox_inches="tight")
    plt.show()
 
    # Print statistics
    total_pixels = results["classification"].size
    for i, name in enumerate(class_names):
        count = np.sum(results["classification"] == i)
        pct = count / total_pixels * 100
        print(f"  {name}: {count:,} pixels ({pct:.1f}%)")

Synthetic Demo (Runs Without Data)

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
import matplotlib.patches as mpatches
 
np.random.seed(42)
size = 400
 
# Create a realistic scene
scene = np.zeros((size, size), dtype=int)
# Water: left half, bottom quarter
scene[300:, :200] = 1
# Dense forest: upper-left
scene[:150, :180] = 2
# Agriculture: upper-right
scene[:150, 220:] = 3
# Urban: center
scene[150:300, 150:300] = 4
# Bare soil: scattered
scene[150:200, 300:] = 5
scene[200:250, :100] = 5
 
# Spectral profiles: (B02, B03, B04, B08, B11, B12)
profiles = {
    0: (0.10, 0.12, 0.15, 0.22, 0.28, 0.25),  # mixed/unclassified
    1: (0.05, 0.04, 0.03, 0.01, 0.00, 0.00),  # water
    2: (0.02, 0.05, 0.03, 0.48, 0.12, 0.06),  # dense forest
    3: (0.03, 0.07, 0.04, 0.42, 0.18, 0.10),  # agriculture
    4: (0.08, 0.09, 0.12, 0.20, 0.26, 0.23),  # urban
    5: (0.10, 0.14, 0.18, 0.24, 0.30, 0.27),  # bare soil
}
 
band_names = ["B02", "B03", "B04", "B08", "B11", "B12"]
bands = {}
for i, name in enumerate(band_names):
    band = np.zeros((size, size), dtype=np.float32)
    for lc, profile in profiles.items():
        mask = scene == lc
        band[mask] = profile[i] + np.random.normal(0, 0.008, np.sum(mask))
    bands[name] = np.clip(band, 0, 1)
 
# Run pipeline
results = multispectral_pipeline(bands)
plot_analysis_results(bands, results, save_path="multispectral_analysis.png")

Connection to AI/ML Computer Vision

Spectral indices make excellent features for machine learning classification. Instead of raw band values (which vary with illumination), indices like NDVI, NDWI, NDBI are normalized and more robust.

For your AI/ML CV course:

  • Use spectral indices as input features alongside raw bands
  • Random Forest on [NDVI, NDWI, NDBI, SAVI, red edge bands] often outperforms deep learning on raw bands for land cover classification
  • CNNs can learn spatial patterns (texture, shape) that spectral indices miss
  • Best approach: combine spectral indices + spatial features

Exercises

Exercise 1: Classify Land Cover Using Indices

  1. Load a Sentinel-2 scene with all bands (B02, B03, B04, B08, B11, B12)
  2. Apply multispectral_pipeline() to classify into 5 classes
  3. Adjust thresholds until the classification looks reasonable
  4. Compute class areas in km
  5. How would you validate this classification? What ground truth would you need?

Exercise 2: Detect Water Bodies

  1. Load Sentinel-2 for a coastal area with lakes, rivers, and sea
  2. Compute both NDWI and MNDWI
  3. Compare: which gives cleaner water/land separation?
  4. Threshold to create binary water mask
  5. Compute total water surface area

Exercise 3: Map Urban Expansion

  1. Find two Sentinel-2 scenes of the same area, 3-5 years apart
  2. Compute NDBI and NDVI for both dates
  3. Identify areas where NDBI increased and NDVI decreased (= new construction)
  4. This is a simplified change detection approach

Self-Test Questions

  1. Why is NDVI more useful than simply looking at the NIR band alone?
  2. A target appears green in visible light but has very low NIR reflectance. What is it likely? (Hint: not vegetation)
  3. What does the red edge tell you that NDVI does not?
  4. Why is MNDWI (using SWIR) better than NDWI (using NIR) for water detection in urban areas?
  5. You see high NDBI but also high NDVI. What land cover type might this be?

See also: Tutorial - Working with Raster Data | Sensor Types and Imagery | Change Detection Next: Change Detection