Satellite Imagery Analysis — Deep Dive

Moving from notebook-level NDVI maps to production satellite pipelines requires mastering data cube construction, atmospheric correction, multi-temporal compositing, machine-learning classification, and cloud-native I/O. This deep dive covers the patterns that scale.

Building analysis-ready data cubes

Raw satellite scenes arrive as individual granules with varying footprints, CRS, and cloud cover. The first step is assembling them into a regularly gridded data cube.

stackstac: STAC items → xarray

import stackstac
from pystac_client import Client

catalog = Client.open("https://earth-search.aws.element84.com/v1")
search = catalog.search(
    collections=["sentinel-2-l2a"],
    bbox=[-74.05, 40.65, -73.85, 40.85],
    datetime="2025-06-01/2025-08-31",
    query={"eo:cloud_cover": {"lt": 20}},
)
items = list(search.items())

stack = stackstac.stack(
    items,
    assets=["B04", "B08", "SCL"],  # Red, NIR, Scene Classification
    resolution=10,
    epsg=32618,
    bounds_latlon=[-74.05, 40.65, -73.85, 40.85],
)
print(stack)  # <xarray.DataArray (time, band, y, x)>

stackstac lazily constructs a Dask-backed xarray DataArray. No data is downloaded until you call .compute() on a subset. This lets you define processing on a cube spanning hundreds of scenes without running out of memory.

Atmospheric correction

Level-1 (L1C) data measures top-of-atmosphere reflectance — what the satellite sensor recorded. Level-2 (L2A) applies atmospheric correction to estimate bottom-of-atmosphere reflectance — what the ground actually reflects.

For Sentinel-2, the ESA provides L2A products processed with Sen2Cor. For Landsat, USGS provides Collection 2 Level-2 products. When L2A is unavailable, Python options include:

# py6s wraps the 6S radiative transfer model
from Py6S import SixS, AtmosProfile, Wavelength

s = SixS()
s.atmos_profile = AtmosProfile.PredefinedType(AtmosProfile.MidlatitudeSummer)
s.wavelength = Wavelength(0.640, 0.670)  # Red band
s.run()
correction_factor = s.outputs.atmos_corrected_reflectance

For most use cases, start with pre-corrected L2A products and only calibrate manually when working with sensors that lack standard processing.

Cloud masking: beyond SCL

The default Scene Classification Layer misses thin cirrus and cloud edges. Production pipelines use multiple strategies:

S2Cloudless (ML-based)

from s2cloudless import S2PixelCloudDetector

detector = S2PixelCloudDetector(threshold=0.4, all_bands=True)
cloud_probs = detector.get_cloud_probability_maps(band_stack)
cloud_mask = cloud_probs > 0.5

Temporal compositing (median pixel)

Instead of masking individual scenes, compute the median across a time window. Clouds are transient, so the median eliminates them:

# stack shape: (time, band, y, x) — Dask array
ndvi_stack = (stack.sel(band="B08") - stack.sel(band="B04")) / \
             (stack.sel(band="B08") + stack.sel(band="B04") + 1e-10)

# Mask cloudy pixels with NaN, then take temporal median
scl = stack.sel(band="SCL")
cloud_free = ndvi_stack.where(~scl.isin([3, 8, 9]))
median_ndvi = cloud_free.median(dim="time").compute()

Median composites are the workhorse of large-area land-cover mapping.

Time-series analysis

Satellite time-series reveal seasonal patterns and anomalies:

import xarray as xr

# Monthly NDVI means
monthly = ndvi_stack.resample(time="1ME").mean()

# Detect anomalies (deviation from long-term monthly mean)
climatology = monthly.groupby("time.month").mean(dim="time")
anomalies = monthly.groupby("time.month") - climatology

Agricultural monitoring uses these anomalies to detect drought stress weeks before it becomes visible at ground level.

Phenology extraction

from scipy.signal import savgol_filter

# Smooth the NDVI time-series for a single pixel
pixel_ts = ndvi_stack.sel(x=-73.95, y=40.72, method="nearest").values
smoothed = savgol_filter(pixel_ts, window_length=7, polyorder=2)

# Green-up date: first day NDVI exceeds 0.4
green_up_idx = np.argmax(smoothed > 0.4)

Machine learning classification

Pixel-based classification

from sklearn.ensemble import RandomForestClassifier
import numpy as np

# Training data: labeled pixels with band values
# X shape: (n_samples, n_bands), y shape: (n_samples,)
X_train = np.column_stack([red_train, green_train, nir_train, swir_train])
y_train = labels  # 0=water, 1=forest, 2=urban, 3=agriculture

clf = RandomForestClassifier(n_estimators=200, n_jobs=-1)
clf.fit(X_train, y_train)

# Predict full image
bands = np.stack([red, green, nir, swir], axis=-1)
flat = bands.reshape(-1, 4)
prediction = clf.predict(flat).reshape(red.shape)

Deep learning with torchgeo

For state-of-the-art accuracy, use pretrained models:

from torchgeo.models import ResNet18_Weights

weights = ResNet18_Weights.SENTINEL2_ALL_MOCO
model = timm.create_model("resnet18", in_chans=13, num_classes=10)
model.load_state_dict(weights.get_state_dict())

torchgeo provides pretrained weights for Sentinel-2, Landsat, and other sensors, significantly reducing training data requirements.

Zonal statistics

Aggregate raster values within vector boundaries — for example, average NDVI per farm field:

from rasterstats import zonal_stats

stats = zonal_stats(
    "fields.geojson",
    "ndvi.tif",
    stats=["mean", "std", "min", "max", "count"],
)
# [{'mean': 0.72, 'std': 0.08, 'min': 0.3, 'max': 0.88, 'count': 4521}, ...]

This bridges raster analysis and vector reporting.

Scalable processing patterns

Tiled processing with Dask

import dask.array as da

# Process 1TB of imagery in 256×256 tiles
result = da.map_blocks(
    compute_ndvi,
    stack.sel(band="B04").data,
    stack.sel(band="B08").data,
    dtype=np.float32,
)
result.to_zarr("ndvi_cube.zarr")

Cloud-native pipeline architecture

STAC Search → stackstac (lazy cube) → Dask processing → COG/Zarr output → STAC catalog
     ↓                                      ↓
  pystac-client                       Coiled/Dask Gateway cluster

This architecture processes continental-scale datasets by distributing tile reads and computations across cloud workers. Data never leaves the cloud — reads go from S3 COGs to compute nodes, and outputs write back to S3.

Common pitfalls

PitfallImpactSolution
Mixing L1C and L2A dataInconsistent reflectance valuesUse L2A exclusively or apply consistent correction
Ignoring nodata valuesCorrupted statisticsAlways mask with src.read(masked=True) or explicit nodata checks
Wrong band orderingNDVI inverted or nonsensicalVerify band assignments against product documentation
Computing area in degreesMeaningless pixel areasReproject to UTM before area calculations
Not clipping to AOIProcessing unnecessary dataUse windowed reads or bbox filtering
Single-date analysisClouds, shadows, seasonalityUse multi-temporal composites

Real-world applications

  • Precision agriculture: NDVI time-series per field → variable-rate fertilizer maps
  • Deforestation monitoring: Monthly change detection on Sentinel-2 → alert system (Global Forest Watch uses this approach)
  • Urban heat islands: Landsat thermal band → surface temperature maps → city planning
  • Flood mapping: SAR (Sentinel-1) backscatter change → water extent during events
  • Carbon estimation: Biomass indices + LiDAR calibration → forest carbon stock maps

The one thing to remember: Production satellite analysis is not about individual scenes — it is about building lazy, cloud-native data cubes from STAC catalogs, applying temporal composites to eliminate noise, and scaling computation with Dask so that the same code works for one field or one continent.

pythonsatellite-imageryremote-sensinggeospatial

See Also

  • Python Adaptive Learning Systems How Python builds learning apps that adjust to each student like a personal tutor who knows exactly what you need next.
  • Python Airflow Learn Airflow as a timetable manager that makes sure data tasks run in the right order every day.
  • Python Altair Learn Altair through the idea of drawing charts by describing rules, not by hand-placing every visual element.
  • Python Automated Grading How Python grades homework and exams automatically, from simple answer keys to understanding written essays.
  • Python Batch Vs Stream Processing Batch processing is like doing laundry once a week; stream processing is like a self-cleaning shirt that cleans itself constantly.