Finding Stable Pixels and Choosing PIAs#
This notebook measures how much the reflectance of every pixel has moved across a whole Sentinel-2 archive, and uses that map to choose pseudo-invariant areas (PIAs, also called PIFs): the polygons used in relative radiometric normalization, where a scene is adjusted to match a reference by fitting a per-band regression on surfaces assumed not to have changed.
The measurement is the short part — three lines of code. Most of the notebook is about reading it correctly, because the obvious statistics fail in instructive ways.
Why the bands, and not an index#
Vegetation indices are ratios, so they cancel out multiplicative changes in brightness.
A pixel can hold exactly the same NDVI for eight years while its reflectance drifts by a
third. That drift is precisely what a PIA must not have, so the analysis runs on the
bands themselves, which ndvi2gif exposes as ordinary index names:
index='blue' | 'green' | 'red' | 'nir' | 'swir1' | 'swir2'
index='red_edge1' | 'red_edge2' | 'red_edge3' # Sentinel-2 only
They come back as surface reflectance in 0–1 on every sensor, so a threshold picked on Sentinel-2 keeps its meaning on Landsat.
What gets measured#
Everything here is a property of the pixel with respect to itself. Nothing is a percentile, a rank or a comparison against the rest of the scene, which means the numbers survive a change of extent, of date, or of sensor:
what it answers |
|
|---|---|
|
how much the reflectance moved, in reflectance units |
|
the same, relative to the pixel’s own level |
|
the largest excursion it ever had |
drift |
how much of that movement went in one direction |
|
how many observations back the answer |
1. Setup#
import warnings
warnings.filterwarnings('ignore', category=UserWarning, module='geemap.conversion')
import ee
import geemap
from ndvi2gif import NdviSeasonality
# ee.Authenticate() # only the first time
ee.Initialize(project='your-project-id')
print('✓ Earth Engine initialised')
2. Region of interest#
The example runs over the working extent of the Doñana flood-mapping protocol (SW Spain): roughly 114 × 103 km covering the marshes, the Atlantic coast, the Guadalquivir estuary, several reservoirs and the city of Seville.
Any ROI accepted by NdviSeasonality works: a shapefile, a GeoJSON, a drawn geometry, a
DEIMS site id or a Sentinel-2 tile code.
UTM = 'EPSG:32629'
START, END = 2018, 2025
# Doñana protocol extent, in its native projected CRS
roi = ee.Geometry.Rectangle(
[676440, 4059960, 790380, 4163340], proj=UTM, geodesic=False
)
Map = geemap.Map()
Map.centerObject(roi, zoom=8)
Map.addLayer(roi, {'color': 'red'}, 'Study extent')
Map
3. The dispersion map#
NdviSeasonality composites inside a year: periods splits the year into seasons,
months or a single 1 January–31 December window, but never spans several years. To get
one raster for the whole archive you reduce the underlying collection directly. The
object exposes it as ndvi_col — already filtered to the ROI and cloud-masked — and the
index function as d[index]:
def series(index, start=START, end=END, max_cloud_cover=60):
"""Every valid observation of `index` over the full period, as one collection."""
ns = NdviSeasonality(
roi=roi, sat='S2', start_year=start, end_year=end, index=index,
cloud_filter=True, max_cloud_cover=max_cloud_cover, scl_mask=True,
)
return ns.ndvi_col.filterDate(f'{start}-01-01', f'{end + 1}-01-01').map(ns.d[index])
col = series('swir1')
std = col.reduce(ee.Reducer.stdDev()).rename('std')
mean = col.mean().rename('mean')
cv = std.divide(mean).rename('cv')
rng = col.max().subtract(col.min()).rename('range')
n_obs = col.reduce(ee.Reducer.count()).rename('n_obs')
That is the whole measurement. std is in reflectance units, so 0.02 means the pixel has
held its SWIR1 reflectance to within a couple of percentage points across eight years of
acquisitions, in every season.
Note
scl_mask=True applies per-pixel cloud and shadow masking from the Scene Classification
Layer. Residual cloud is the easiest way to fake instability, so it is not optional here.
max_cloud_cover=60 is deliberately loose in return: the scene-level filter discards
whole images, and with per-pixel masking in place a partly cloudy scene still contributes
its clear half. Dispersion statistics want observations.
Map = geemap.Map()
Map.centerObject(roi, zoom=8)
Map.addLayer(std, {'min': 0.01, 'max': 0.12,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'std (swir1)')
Map.addLayer(cv, {'min': 0, 'max': 1.5,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'cv (swir1)', False)
Map.addLayer(rng, {'min': 0, 'max': 0.5,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'range (swir1)', False)
Map.addLayer(n_obs, {'min': 100, 'max': 900,
'palette': ['440154', '21918c', 'fde725']}, 'valid observations', False)
Map
3.1 Which statistic to trust#
They are not interchangeable, and each fails in its own way:
stdis the workhorse. It averages over hundreds of observations, so no single bad one moves it much.rangeismax - min, so it is decided by one observation. A cloud edge the SCL mask did not catch blows up the range of an otherwise perfect pixel. Read it as “something happened to this pixel once”, not as a measure of stability.cvisstddivided by the pixel’s own level, which is what makes brightness comparable — and what makes it explode on water, where the denominator goes to zero.countis the quality layer for all of them. A standard deviation from three images means nothing. It also reveals the Sentinel-2 tiling: pixels in the overlap between two MGRS tiles are seen about twice as often.
Use std and the drift of section 5 as the two numbers you decide with; keep cv and
range as diagnostics for when something does not add up. Two interpretable numbers beat
one composite index that fires without telling you why.
3.2 Choosing the time window#
Worth a minute of measurement rather than a guess. key='count' with periods=1 gives
the valid observations per pixel of each year, and over this extent it says:
year |
scenes |
distinct dates |
valid obs/pixel (median) |
|---|---|---|---|
2015 |
28 |
9 |
— |
2016 |
70 |
28 |
— |
2017 |
308 |
89 |
75 |
2018 |
510 |
142 |
91 |
2019 |
525 |
145 |
105 |
2020–2024 |
~520 |
~145 |
~100 |
The archive reaches back to 2015, so the limit is not the catalogue. It is Sentinel-2B, launched in March 2017 and operational around the middle of that year: before it, revisit was 10 days instead of 5. That is why 2017 has 89 distinct dates against the ~145 of a normal year, and why they concentrate in the second half.
So 2017 is usable — 75 observations per pixel is plenty for an annual mean — but it is unevenly sampled within the year, which biases the within-year term of section 4 towards whichever seasons were better covered. A reasonable compromise is to include 2017 in the between-year and drift analysis, where each year contributes a single mean, and leave it out of the within-year term. 2016, with 28 dates, is not usable either way.
3.3 Export it and go look at it#
Over an extent this size a direct download will time out, so the raster goes out as an
Earth Engine task. Reflectance-like bands are scaled by 10000 and stored as int16,
which keeps the file a quarter of the size of float32 and loses nothing you can see.
dispersion = ee.Image.cat([
std.multiply(10000),
cv.multiply(10000),
rng.multiply(10000),
mean.multiply(10000),
n_obs,
]).toInt16().rename(['std', 'cv', 'range', 'mean', 'n_obs']).clip(roi)
task = ee.batch.Export.image.toDrive(
image=dispersion, description='swir1_dispersion',
folder='ndvi2gif', region=roi, scale=10, crs=UTM, maxPixels=1e13,
)
# task.start()
In QGIS, load std with a green-to-red stretch over roughly 200–1200 (remember the
×10000) and the quiet surfaces come straight out: runways, quarry floors, dune fields,
industrial roofs, open water.
4. Two levels: within the year and between years#
The single std pools two very different kinds of movement, and separating them costs
almost nothing:
how it is computed |
what it captures |
|
|---|---|---|
within-year |
|
scatter inside each year: phenology, tides, illumination, residual cloud |
between-year |
|
movement between years: land-cover change, a reservoir filling and emptying |
periods=1 is what makes this work: each composite spans the whole year, so the statistic
covers every valid observation of that year, winter and summer together. Seasonality is
not noise to be averaged away here — a surface that changes with the season is not
invariant, and a PIA has to hold regardless of when the scene being normalized was
acquired. Keeping the whole year inside one period puts the seasonal cycle into the
within-year term, where it belongs.
def annual(index, key):
"""One composite per year, each covering the full year."""
ns = NdviSeasonality(
roi=roi, sat='S2', periods=1, start_year=START, end_year=END,
index=index, key=key, cloud_filter=True, max_cloud_cover=60, scl_mask=True,
)
return ns.get_year_composite().select('p1')
band = 'swir1'
# Variances add, so the split is done on variances and square-rooted at the end
within_var = annual(band, 'variance').mean() # scatter inside a year
annual_level = annual(band, 'mean') # one value per year
between_var = annual_level.reduce(ee.Reducer.variance()) # movement across years
total = within_var.add(between_var).sqrt().rename('sigma')
frac_between = between_var.divide(within_var.add(between_var)).rename('frac_between')
# The same idea in more readable units: brightest year minus darkest
between_range = annual_level.max().subtract(annual_level.min())
Map = geemap.Map()
Map.centerObject(roi, zoom=8)
Map.addLayer(within_var.sqrt(), {'min': 0.01, 'max': 0.12,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'within-year std')
Map.addLayer(between_var.sqrt(), {'min': 0.005, 'max': 0.06,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'between-year std')
Map.addLayer(frac_between, {'min': 0, 'max': 0.5,
'palette': ['ffffff', '4575b4', '313695']},
'fraction of variance that is interannual', False)
Map
The two terms reconstruct the total dispersion of section 3 almost exactly: over a test
window inside this extent, total and the direct std correlate at 0.9998, and
their 5th, 50th and 95th percentiles agree to four decimal places (0.0181 / 0.0239 /
0.0679).
So the split buys no precision — it buys diagnosis. Green in the first layer means “this pixel held its reflectance all year”. Blue in the last one, wherever the others are not green, says the instability is movement between years rather than a wobble inside them. Reservoirs and burnt areas light up; irrigated crops do not, because their variance is seasonal and repeats.
5. Drift: is the pixel going somewhere?#
Dispersion says how much a pixel moves. It does not say whether the movement goes anywhere, and only one of those is fatal:
A pixel that oscillates and returns has a high
std, but it averages out. Its value in a scene from 2019 and one from 2025 has the same expectation.A pixel that drifts has a value that depends on the date. Since the reference scene has one fixed date, that pixel biases the regression by an amount that grows with how far the scene being normalized sits from it. This one does not average away.
SpatialTrendAnalyzer answers the second question, and periods=1, key='mean' already
produced the annual series it needs.
from ndvi2gif.timeseries import SpatialTrendAnalyzer
ns_level = NdviSeasonality(
roi=roi, sat='S2', periods=1, start_year=START, end_year=END,
index=band, key='mean', cloud_filter=True, max_cloud_cover=60, scl_mask=True,
)
# 'mann_kendall' returns Sen's slope and intercept, the accumulated magnitude,
# and Kendall's tau: the rank correlation with time, scale-free, from -1 to +1
trend = SpatialTrendAnalyzer(ns_level).calculate_pixel_trends(
method='mann_kendall', min_observations=5,
)
drift = trend.select('magnitude').abs().rename('drift') # reflectance over the series
tau = trend.select('tau') # how monotonic the movement is
Map = geemap.Map()
Map.centerObject(roi, zoom=8)
Map.addLayer(trend.select('slope'), {'min': -0.01, 'max': 0.01,
'palette': ['313695', 'ffffff', 'a50026']},
"Sen's slope (reflectance / year)")
Map.addLayer(drift, {'min': 0, 'max': 0.06,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'accumulated drift')
Map.addLayer(tau, {'min': -1, 'max': 1,
'palette': ['313695', 'ffffff', 'a50026']}, "Kendall's tau", False)
Map
Warning
ee.Reducer.kendallsCorrelation() also advertises a p-value band, and ndvi2gif does
not return it: it comes back fully masked at every series length tested, including trends
scipy.stats.kendalltau scores below 1e-20, and a fully masked band silently masks
whatever it is combined with. Judge significance from tau and the number of years.
6. Reading the map: what the reference classes say#
The Doñana protocol has a hand-drawn set of pseudo-invariant areas, built and revised over years of operational use. Measuring the statistics inside them shows what the map means in practice — and why no single number settles the question.
class |
brightness |
σ (red) |
σ (nir) |
σ (swir1) |
CV worst band |
% var. interannual |
drift (swir1) |
|---|---|---|---|---|---|---|---|
Arena (dunes) |
0.457 |
0.044 |
0.047 |
0.059 |
0.12 |
0.06 |
0.006 |
Fosfoyesos (gypsum stacks) |
0.474 |
0.082 |
0.081 |
0.082 |
0.21 |
0.21 |
0.050 |
Urbano-2 |
0.263 |
0.045 |
0.050 |
0.057 |
0.23 |
0.04 |
0.004 |
Aeropuertos (runways) |
0.269 |
0.046 |
0.046 |
0.053 |
0.24 |
0.09 |
0.004 |
Urbano-1 |
0.260 |
0.047 |
0.052 |
0.061 |
0.24 |
0.04 |
0.006 |
Minería (quarries) |
0.257 |
0.057 |
0.058 |
0.067 |
0.29 |
0.13 |
0.034 |
Marisma mareal (tidal marsh) |
0.178 |
0.039 |
0.040 |
0.040 |
0.39 |
0.07 |
0.003 |
Pastizales (turf) |
0.295 |
0.047 |
0.080 |
0.052 |
0.58 |
0.06 |
0.015 |
Pinar (pine forest) |
0.137 |
0.032 |
0.036 |
0.044 |
0.65 |
0.17 |
0.033 |
Mar (open sea) |
0.018 |
0.021 |
0.021 |
0.022 |
1.39 |
0.02 |
0.000 |
Embalses (reservoirs) |
0.034 |
0.039 |
0.046 |
0.044 |
1.84 |
0.08 |
0.001 |
Sentinel-2 2018–2025, averaged inside each class of a reference layer in its state of 2026-08-24 (118 polygons, 11 classes), drawn by hand and independently of this method.
Four things in that table are worth internalising before drawing any polygon.
A std threshold favours dark surfaces. Dark pixels have little signal, so they have
little variance — not because they are invariant but because there is nothing there to
vary. At σ ≤ 0.05, reservoirs (0.039–0.046) pass and dunes (0.044–0.059) do not, which is
exactly backwards.
A cv threshold favours bright ones. By CV, Mar and Embalses are the two worst
classes in the list, yet open sea is one of the most reliable normalization targets there
is. Its CV is an artefact of dividing by 0.018.
Neither of them sees drift. Embalses and Fosfoyesos sit at comparable σ, but the
reservoirs have essentially zero drift — they filled, emptied and filled again, and came
back — while the gypsum stacks climb 0.008 reflectance per year, 0.050 across the series.
Those are the phosphogypsum ponds at Huelva, under active capping and revegetation. Same
dispersion, opposite verdicts: a reservoir may be salvageable by picking better polygons,
a surface under construction is not.
Look at the bands separately. Pastizales here is irrigated turf — golf greens and
football pitches — and it is the only class whose bands disagree: 0.080 in the near
infrared against 0.047 in red and 0.052 in SWIR1. Red saturates against chlorophyll and
sits still, while the near infrared follows leaf area, which moves with every mowing and
with the autumn overseeding. If your normalization is fitted per band, that class is
usable in red and SWIR and not in NIR.
Note
Pinar drifting 0.033 with tau 0.39 deserves a look rather than a shrug. The 2017 Las
Peñuelas fire burned pine forest at the edge of this extent, and post-fire recovery is
exactly the monotonic SWIR1 signal this test is built to find.
7. Drawing the PIAs#
The regression that normalizes a scene needs targets spread across the brightness range: a set clustered at one end constrains slope and offset very poorly. Nothing in the map guarantees that spread, and — as section 6 shows — no single statistic can, because every threshold leans one way or the other.
The class structure is what provides it. Dark sea, dark-to-mid pine and marsh, mid urban fabric and runways, bright dunes: the classes already stratify the brightness range by construction, which is the reason they exist. So the workflow is not “threshold the map and see what comes out”, it is:
Threshold the map with something simple and absolute —
stdbelow a value you choose, drift below another.Go class by class, and inside each one draw polygons where the map is quiet.
Check every class still has enough pixels to satisfy whatever minimum your normalization enforces.
Step 1 in code, with thresholds as plain numbers rather than percentiles, so the same criterion means the same thing on another extent, another date or another sensor:
MAX_STD = 0.06 # reflectance units
MAX_DRIFT = 0.02 # reflectance accumulated over the whole series
MIN_OBS = 100 # observations behind the statistics
MIN_PATCH = 25 # pixels; at 10 m that is 2500 m², a 5x5 Sentinel-2 block
quiet = (std.lte(MAX_STD)
.And(drift.lte(MAX_DRIFT))
.And(n_obs.gte(MIN_OBS)))
# Isolated pixels are no use as ground targets: they have to survive geolocation
# error and the point spread function of whatever sensor is being normalized
patch_size = quiet.selfMask().connectedPixelCount(maxSize=128, eightConnected=True)
stable = quiet.selfMask().updateMask(patch_size.gte(MIN_PATCH)).rename('stable')
Map = geemap.Map()
Map.centerObject(roi, zoom=8)
Map.addLayer(std, {'min': 0.01, 'max': 0.12,
'palette': ['1a9850', 'ffffbf', 'd73027']}, 'std (swir1)')
Map.addLayer(stable, {'palette': ['00d4ff']}, 'quiet and not drifting')
Map
Warning
connectedPixelCount is expensive, and so is anything that forces the whole stack to be
evaluated at once. Adding stable to a map is fine — tiles render lazily, and only what
you look at gets computed — but a reduceRegion over the full extent on a stack built
from eight years of Sentinel-2 will return Computation timed out or User memory limit exceeded, even when every band computes happily on its own. Summarise one layer at a
time, or send the job out as an export task.
Both thresholds are yours to set, and the table in section 6 is the calibration: a class
you already trust tells you what value your surfaces actually reach. MAX_STD = 0.06
admits everything from open sea to dunes while excluding the gypsum stacks;
MAX_DRIFT = 0.02 removes quarries and the stacks while leaving the sea untouched.
7.1 Export the candidates#
Vectorizing an extent this size in the notebook will time out, so it goes out as a task.
polys = stable.reduceToVectors(
geometry=roi, scale=20, crs=UTM, geometryType='polygon',
eightConnected=True, labelProperty='stable', maxPixels=1e10,
)
task_vec = ee.batch.Export.table.toDrive(
collection=polys, description='pia_candidates', folder='ndvi2gif',
fileFormat='GeoJSON',
)
# task_vec.start()
8. What to do with the output#
The polygons are candidates, not PIAs:
Give them a class. The method finds surfaces that do not change; it has no idea whether a given one is a runway, a dune or a factory roof. Normalization schemes that enforce a minimum number of pixels per class need that label.
Check the brightness spread survives. Especially after clipping to a sub-area, where a whole class can disappear.
Watch for stable-but-wrong surfaces. Deep shadow in relief, permanent cloud over a mountain range and water bodies at a fixed level are all radiometrically quiet and all poor targets, for different reasons.
Re-run as the archive grows. Stability is a statement about a time span. A target that was invariant 2018–2025 is not promised to stay that way.
Appendix: selecting by percentile instead#
An alternative to absolute thresholds is to cut the extent into brightness strata and keep the most stable fraction inside each one. It has a real attraction — every stratum contributes candidates, so the brightness spread is guaranteed without any class structure — and a real cost, spelled out at the end.
import numpy as np
BANDS = ['red', 'nir', 'swir1']
N_STRATA, KEEP_PCT, N_SAMPLE = 10, 10, 4000
sigma_b, level_b = {}, {}
for b in BANDS:
c = series(b)
sigma_b[b] = c.reduce(ee.Reducer.stdDev())
level_b[b] = c.mean()
# A PIA has to hold in every band, so the criterion is the worst one
sigma_worst = ee.Image(sigma_b['red']).max(sigma_b['nir']).max(sigma_b['swir1']).rename('sigma')
brightness = level_b['red'].add(level_b['nir']).add(level_b['swir1']).divide(3).rename('brightness')
# A random sample is enough to place the cuts; getInfo() stops at 5000 features
sample = sigma_worst.addBands(brightness).sample(
region=roi, scale=100, numPixels=N_SAMPLE, seed=42, tileScale=4, dropNulls=True,
).getInfo()
pts = [f['properties'] for f in sample['features']]
sg = np.array([p['sigma'] for p in pts])
br = np.array([p['brightness'] for p in pts])
edges = list(np.percentile(br, np.arange(100 / N_STRATA, 100, 100 / N_STRATA)))
strat = np.digitize(br, edges)
thr = [float(np.percentile(sg[strat == k], KEEP_PCT)) for k in range(N_STRATA)]
stratum = ee.Image(0)
for e in edges:
stratum = stratum.add(brightness.gt(float(e)))
candidates = sigma_worst.lte(
stratum.rename('stratum').toInt().remap(list(range(N_STRATA)), thr)
).And(n_obs.gte(MIN_OBS)).rename('candidate')
print('brightness deciles: ', [round(e, 3) for e in edges])
print('sigma threshold each:', [round(t, 4) for t in thr])
Scored against the same reference classes, with 10 % of the extent selected by construction, and again after also requiring drift below 0.02:
class |
% selected |
% selected, not drifting |
|---|---|---|
Arena |
81 % |
62 % |
Aeropuertos |
76 % |
52 % |
Urbano-2 |
66 % |
53 % |
Urbano-1 |
58 % |
49 % |
Marisma mareal |
57 % |
52 % |
Minería |
55 % |
18 % |
Pinar |
31 % |
23 % |
Pastizales |
27 % |
18 % |
Mar |
26 % |
26 % |
Fosfoyesos |
19 % |
2 % |
Embalses |
0 % |
0 % |
Read against the 10 % baseline this is encouraging: runways, urban fabric and dunes come out seven to eight times enriched, and reservoirs are rejected outright — the textbook pseudo-invariant surfaces recovered without being told what they are. The drift column does its job too, collapsing quarries and gypsum stacks while leaving the sea untouched.
And it is the wrong tool for judging a pixel. Every number above is a rank against
the rest of this extent. A fifth of it is sea, so the first two brightness deciles are
essentially all water and the method ends up sorting calm sea from choppy sea — which is
why Mar, the quietest class in the archive, scores a middling 26 %. Clip to a sub-area
where the ocean is gone and the cuts move, and the same pixels change verdict without
having changed at all.
Percentiles answer “which are the most stable pixels here”. Absolute thresholds answer “has this pixel changed”, which is the question a PIA actually poses, and the only one whose answer travels between extents, dates and sensors. Use the appendix when you want a fixed budget of candidates spread across brightness; use section 7 otherwise.
See also#
Indices & Variables Reference — the raw reflectance bands and everything else
index=acceptsTime Series Analysis — for pixels that do change, and by how much
Hydroperiod Analysis — the flood-mapping side of the same Doñana protocol