From Satellites to Forest Maps
What this pipeline actually does — and what happens the moment you run it
A satellite image goes in. A forest map comes out.
The Cisokan watershed in West Java is a mosaic of paddy fields, plantations, forest, and buildings — and from space, it's hard to tell them apart. This pipeline reads satellite images and automatically labels every patch of ground with one of nine land-cover classes.
The result is a raster map where every pixel answers: what is this piece of land?
Input
10 PlanetScope satellite images taken over 2 years, plus radar and canopy-height data
Pipeline
12 Python scripts that segment, measure, and classify — running for 45–90 minutes on a cold start
Output
A 9-class land-cover map at 3 m resolution covering the entire watershed
Trace what happens when you run it
Imagine you open a terminal and type python planetscope_10epoch_obia_v3.py. Here is the exact journey your data takes — from raw satellite pixels to a coloured forest map on screen.
Nine answers the map can give
The pipeline uses a two-level hierarchy. Level 1 sorts land into 7 broad categories. Level 2 zooms into "Dense Vegetation" and splits it into three forest subtypes.
Lakes, rivers, the Cisokan reservoir — any open water surface
Irrigated rice fields — strong NDVI swing between planting and harvest
Roads, rooftops, settlements — high brightness across all seasons
Clouds, shadows, mixed pixels — anything that defies the other labels
Old-growth and secondary forest — tallest canopy, highest biomass
Timber plantations — dense but younger, more uniform canopy
Mixed gardens (coffee, rubber, fruit trees) — heterogeneous and hard to classify
Grassland, shrubs, degraded land — covers 46% of the watershed
Upland agriculture — corn, cassava, mixed annual crops
Cisokan has very little permanently bare soil — what looked like "Bareland" was mostly transient states (harvested paddy, fallow, cloud shadows). Forcing the model to learn an incoherent class made everything worse. Removing it lifted overall accuracy from 87% to 89.5%.
How well does it work?
Before diving into the machinery, here's the headline: the pipeline achieves 89.5% overall accuracy for the 7-class L1 map — verified on 228 held-out segments the model never saw during training.
L1 Accuracy
89.5% overall accuracy, 92% in 5-fold cross-validation. Every class reaches F1 ≥ 0.82.
L2 Accuracy
57.5% for forest subtypes — harder problem, only 137 training samples. Production forest is the toughest class.
Key driver
Canopy height data (#1 and #3 most important features) — tree height separates forest from farmland better than any spectral band.
Module 2 introduces the 12 scripts. Module 3 unpacks how raw pixels become 591 measurements. Module 4 explains segmentation and the Random Forest. Module 5 shows how to read accuracy numbers and where to improve the model.
Meet the Cast
12 Python scripts, each with a single job — and the three satellite data sources they feed on
Think of each script like a specialist on a film crew
No single person on a film set does everything — the director doesn't also operate the camera. Same idea here. Each Python script has one focused responsibility, and they hand work to each other in a clear order.
The scripts break into four groups: data downloaders, feature builders, classifiers, and analysis tools.
Three eyes in the sky
The classifier doesn't rely on a single satellite. It fuses three completely different data types — each one sees something the others can't.
Ten images taken from March 2024 to March 2026. The backbone of the pipeline — tells you how green, wet, or bright each patch is, and how it changes across seasons.
Radar pulses bounce back differently from rough bark versus smooth water versus metal roofs. Works at night and in rain. 19 Sentinel-1 + 5 PALSAR-2 features.
How tall is the vegetation? A 40 m Natural Forest tree looks nothing like a 3 m shrub. Canopy height is the single most important feature in the whole model.
SAR sees what optical sensors can't: forest structure. L-band radar (PALSAR-2) actually penetrates the canopy and bounces off trunks, while C-band (Sentinel-1) scatters from leaves and branches. Together they encode biomass information that pure colour cameras can't provide.
The scripts on game day
On a fresh setup, the scripts run in this order, handing work to each other. Here's how they'd describe it to each other:
Put your knowledge to the test
Before moving on, check your understanding of which scripts do what — and when to run them.
You have new PlanetScope images from a fresh download. Which script must you run before the main classifier?
Why does the pipeline bother downloading SAR data when PlanetScope already has 8 spectral bands?
591 Ways to See a Tree
How raw satellite pixels become a 591-column table — one row per forest patch
Why does the model need 591 numbers per patch?
A single pixel on a single date tells you very little. A paddy field and a wet dirt road look nearly identical in March. But watch them both across 10 dates, measure their radar response, check how tall the vegetation is — now they diverge completely.
The art of this pipeline is turning each 93-pixel segment into a rich "fingerprint" — 591 numbers that describe everything observable about that patch from space.
138 Legacy Pixel Features
8 bands × 10 epochs = 80 raw band means, plus NDVI, NDWI, NDBI, EVI per epoch and temporal summaries. Built by planetscope_10epoch_local.py.
100 New Epoch Indices
10 spectral indices (NDRE, EVI2, BSI, OSAVI, VARI, MNDWI, CIG, GNDVI, ARI, SIPI) across all 10 dates. More discriminative than raw bands.
48 SAR Features
19 Sentinel-1 + 5 PALSAR-2 bands, each with mean and std across the segment = 48 numbers. Radar backscatter encodes canopy roughness and soil moisture.
12 Canopy Height Features
Meta v2 (avg, stdev, p95, cover) + ETH (mean, std) — 6 raw × mean + std = 12. The #1 and #3 most important features in the entire model.
11 Texture + Shape
6 texture stats from NDVI/NIR (variance, contrast...) + 5 geometric properties (area, compactness, elongation...) per segment.
The zonal stats trick — going from pixels to patches
Think of each segment as a territory on a map. You want to describe that territory — not pixel by pixel, but as a whole. The pipeline uses a clever counting technique called zonal statistics.
For each of the 316,000 segments and each of ~266 pixel features, it computes two numbers: the mean (typical value inside this territory) and the standard deviation (how varied the values are). That gives 266 × 2 = 532 zonal features.
count = np.bincount(lbl, minlength=n_seg+1)
s = np.bincount(lbl, weights=val, minlength=n_seg+1)
sq = np.bincount(lbl, weights=val*val, minlength=n_seg+1)
mean = s / count
std = np.sqrt(sq/count - mean**2)
Count how many pixels live in each segment (like a census)
Sum up all the pixel values inside each segment
Sum up all the squared values (needed to compute spread)
Divide total by count to get the average value per segment
Compute spread: how far individual pixels deviate from the average
A loop across 316k segments × 266 features × 93 pixels would take hours. numpy.bincount does the same work by accumulating histogram buckets in a single pass over the pixel array. Wall-clock time: ~4 minutes vs multiple hours.
Why compute indices instead of just using the raw bands?
Raw reflectance values are sensitive to lighting, atmosphere, and sensor quirks. Spectral indices are ratios that cancel out much of that noise and amplify the signal you care about.
NDVI
Normalised Difference Vegetation Index — how green and photosynthetically active. The classic.
NDRE
Red-Edge version of NDVI — more sensitive to canopy chlorophyll, less saturated over dense forest.
NDWI / MNDWI
Water index — high over water bodies, low over dry soil. Key for separating Paddy from Crops.
BSI
Bare Soil Index — high over exposed dirt, low over vegetation. Flips with NDVI.
VARI
Visible Atmospherically Resistant Index — works with RGB only, great for Crops in the March image.
OSAVI / EVI2
Soil-adjusted vegetation indices — better than NDVI where bare soil shows through sparse canopy.
Seasonality is the secret weapon
A single date can be ambiguous — a harvested paddy field and a barren slope look identical in the dry season. But across 10 dates, paddy has a very specific signature: it goes green fast (planting), peaks, then goes brown fast (harvest). Forest stays green year-round.
The pipeline computes temporal summary features that capture this rhythm:
The 50th percentile (median) NDVI captures the "typical" greenness ignoring extreme dates. The spread between p10 and p90 captures seasonal swing.
Fit a sinusoidal wave to NDVI across time. Amplitude tells you how much it varies seasonally. Phase tells you when it peaks. Together they fingerprint crop calendars.
Compare the same month across two years. Stable forest shows near-zero change. Cleared land shows a sudden NDVI drop. This detects disturbance.
The feature importance rankings show that September indices (NDBI, NDWI, NDRE, OSAVI, BSI) dominate the top-15 list. September is the dry season — maximum contrast between bare soil, senescent crops, and evergreen forest. Seasonal timing matters as much as spectral range.
Match the feature to what it detects
Drag each feature group to the land-cover property it is best at detecting.
Separating tall Natural Forest from short shrubs and crops
Penetrating the forest canopy to measure wood volume and biomass
Distinguishing open water bodies and flooded paddy fields from dry land
Detecting seasonal crop calendars — how much NDVI oscillates across the year
The Thinking Machine
How LSMS segmentation draws the borders — and how Random Forest learns to label what's inside them
Why not just classify pixel by pixel?
The naïve approach would be: for each of the 300 million pixels in the Cisokan image, decide its class. The problem? A single pixel is 3 m × 3 m — about the size of a car. A single pixel of "forest" could easily be a sunlit gap, a shadow, or a branch tip. Noise overwhelms signal.
OBIA solves this by first grouping nearby similar pixels into segments — patches of 50–500 pixels that share similar colour, texture, and brightness — then classifying each patch as a unit.
Pixel-by-pixel (old way)
300M decisions. Each pixel sees only one 3 m square. Noise in a single dark pixel wrongly labels "shadow" as "water." Salt-and-pepper errors everywhere.
Segment-by-segment (OBIA)
316k decisions. Each segment sees 93 pixels averaged together. Noise cancels out. You can also compute shape (round vs. elongated) — water is round, roads are thin lines.
LSMS: the pixel sorter
LSMS works like a crowd-sorting algorithm at a festival. Each pixel "walks" toward the nearest cluster of similar-coloured neighbours. After all pixels settle, the groups become segments.
Three parameters control how fine-grained the result is:
spatialr = 3
Search radius in pixels — how far a pixel looks for similar neighbours. Smaller = finer segments.
ranger = 12.0
Spectral tolerance — how different two pixels can be and still merge. Lower = more segments.
minsize = 50
Minimum segment size in pixels. Tiny slivers below this are merged into neighbours.
Early runs used coarse parameters (spatialr=15, ranger=30) producing 60k segments of ~485 pixels each. Tightening to the current fine settings tripled the segment count to 316k, halved pixel-per-segment to 93, doubled the usable training set, and boosted cross-validation accuracy from 81% to 88.7% — with the standard deviation shrinking from ±4.9pp to ±1.7pp (far more stable).
The Random Forest — a forest that classifies forests
A Random Forest is not one model but an ensemble of 100 decision trees, each independently trained on a random subset of the data. The final prediction is the majority vote across all 100 trees.
Here is the exact configuration used in this pipeline:
RandomForestClassifier(
n_estimators=100,
min_samples_leaf=1,
max_samples=0.5,
bootstrap=True,
max_features="sqrt",
random_state=42,
n_jobs=-1,
)
Create a committee of 100 decision trees
Each leaf node can represent a single training example (no smoothing)
Each tree only sees 50% of the training data — forces diversity
Sample with replacement (like drawing cards, then putting them back)
Each tree sees a random square-root-sized subset of the 591 features
Fixed randomness — run it twice, get the same result
Use all CPU cores in parallel — faster training
Two classifiers, two questions
The pipeline runs two Random Forests back to back. Think of it as a two-round interview: the first screener narrows the field, then a specialist makes the final call.
7 broad classes — Waterbody, Paddy, Built-up, Others, Dense Vegetation, Sparse Vegetation, Crops
All "Dense Vegetation" segments get handed to L2
3 forest subtypes — Natural Forest, Production Forest, Agroforest
Final map merges L1 + L2 into a 9-class hierarchical raster
An earlier version of the pipeline (v2) expanded each training point into a weighted row and assigned fractional labels per segment. This created cross-split contamination — the same segment appeared in both training and validation splits with different labels. The v1 strict majority-vote scheme (used in v3) avoids this entirely. Lesson: always check your train/validation split for data leakage.
Debugging the classifier
You've deployed the model. A colleague reports that Natural Forest segments are frequently mislabelled as Production Forest. Apply what you've learned to diagnose the issue.
Natural Forest and Production Forest are both "Dense Vegetation" in the L1 model. Where does the confusion actually happen?
What is the single highest-value action to improve Production Forest classification?
Reading the Numbers
What accuracy metrics actually tell you — and how to use feature importance to improve the model
Overall Accuracy and Kappa — what the headline numbers mean
After training, the pipeline withholds 30% of labeled segments for a final exam — segments the model never saw during training. It predicts the class for each, then compares to the ground truth labels.
Overall Accuracy (OA)
Percentage of validation segments classified correctly. 89.5% means 9 out of 10 unseen patches got the right label. Intuitive, but misleading if classes are unbalanced.
Cohen's Kappa
Kappa adjusts OA for chance agreement. If a model just predicted "Sparse Vegetation" for everything, it would get 46% OA for free. Kappa penalises that. 0.875 kappa is excellent.
5-Fold Cross-Validation
Repeat the train/test split 5 times with different partitions. The mean (92.0%) and std deviation (±1.6%) together tell you: "this model is both accurate and stable."
The coarse-segmentation model had CV OA of 81% ± 4.9pp. The fine model: 88.7% ± 1.7pp. The mean improved, but the standard deviation shrinking by 3x matters more for production — it means the model is consistent across different data splits, not just lucky on one.
F1 Score — the per-class honest grade
Overall accuracy hides class-level failures. F1 score catches them: a class with F1 = 0.50 is getting half its predictions wrong, even if OA looks fine.
Others: 0.97
The catch-all class — very consistent because anything ambiguous gets sent here
Built-up: 0.90
Roofs and roads have a unique spectral + radar signature — high confidence
Paddy: 0.89
Strong seasonal NDWI cycle makes paddy distinctive
Waterbody: 0.93
Water is spectrally unique — very low reflectance across all bands
Crops: 0.91
+0.24 vs the coarse model — finer segments broke apart crop/sparse confusion
Sparse Vegetation: 0.82
+0.25 vs coarse — biggest single improvement from fine segmentation
Dense Vegetation: 0.83
L1 correct — subtypes then handled at L2 with a separate RF
What the model actually learned to look at
Feature importance is like a spotlight on the model's attention. After training, you can ask: "Out of 591 features, which ones did the trees lean on most?" The answers are surprising — and actionable.
How tall is the canopy on average across this segment? Nothing separates 40 m natural forest from 1 m crops faster than this single number.
How much does the bare soil signal swing across the year? Forest stays stable. Crops go from bare to vegetated and back. This rhythm is the key to separating them.
How variable is the canopy height within this segment? A uniform plantation has low std. Mixed natural forest with gaps has high std. This discriminates forest subtypes.
L-band radar penetrates the canopy and bounces off woody trunks. High HV = dense woody biomass. This is structural information no optical band can provide.
The Top-20 model achieves identical accuracy to the full-feature model. This is a sign of feature saturation — once segments are homogeneous enough (fine LSMS), the top 20 features capture everything the model needs. You could deploy a 20-feature model 30x faster with no accuracy cost.
The L2 problem — and the path to fixing it
The L2 forest-subtype classifier achieves 57.5% accuracy on 40 validation segments. The dominant failure mode: 7 of 14 Production Forest segments get labelled as Natural Forest.
Here's the full confusion matrix:
| Pred. Natural | Pred. Production | Pred. Agroforest | |
|---|---|---|---|
| True Natural (n=18) | 11 | 5 | 2 |
| True Production (n=14) | 7 | 7 | 0 |
| True Agroforest (n=8) | 1 | 2 | 5 |
Production Forest and Natural Forest both have tall, dense canopy. The features that should separate them — tree_height_std, S1_VV_stdDev — are present, but the model has only 48 Production training examples to learn from. The fix is straightforward: collect 30–50 more Production Forest training points from areas where planting rows are visible in the satellite basemap.
Final challenge — reading a real scenario
The L2 model reports CV OA of 64.9% ± 7.6%. A colleague says "64.9% is decent." What does the ±7.6% tell you that they're missing?
You want to improve L2 accuracy without collecting more training points. Which action would have the largest impact?
1) Add 30–50 Production Forest training samples. 2) Investigate why 46% of the AOI is Sparse Vegetation — some may be misclassified Agroforest fringe. 3) Test the YRF (Young Regenerated Forest) rule with dedicated samples. 4) Consider fine-scale SAR at 10 m for the L2 model once the training set is larger.