Feature Engineering for Pixel-Based Models

A pixel-based model sees a vector of numbers and nothing else. Every bit of geography that matters — that this pixel sits on a north-facing slope, that its neighbours are all bright, that it was green in June and bare in October — has to be encoded into that vector or it is invisible to the model. Feature engineering for raster data is the work of doing that encoding deliberately, in an order that can be reproduced at inference time. This topic is part of Raster Machine Learning & Model Inference.

The organising principle is simple: a feature stack is a raster, so all the usual raster rules apply. Every feature band must be on the same grid, with the same nodata convention, in a fixed and documented order. The moment a feature is computed on a different grid — a DEM at 30 m joined to Sentinel-2 at 10 m without resampling, say — the whole matrix is quietly wrong.


Prerequisites

pip install "rasterio>=1.3.0" "xarray>=2023.12" "rioxarray>=0.15" "numpy>=1.23" "scipy>=1.11"
Package Minimum version Why required
rasterio 1.3.0 Reading bands and writing the stacked feature raster
xarray 2023.12 Named dimensions so band order is carried, not remembered
rioxarray 0.15 reproject_match for putting every input on one grid
numpy 1.23 The index arithmetic
scipy 1.11 ndimage.uniform_filter and friends for focal statistics

You will want the band-math grounding from band math operations with xarray, the alignment tooling from aligning two rasters with reproject_match, and a cloud mask from masking clouds with the Sentinel-2 SCL band.


Step-by-Step Workflow

Step 1 — Fix the base bands and the scaling

Everything downstream is derived from a small set of calibrated reflectance bands. Decide which ones, decide the scale factor, and write both into the manifest before computing anything.

import numpy as np
import rioxarray
import xarray as xr

BASE_BANDS = ["B02", "B03", "B04", "B08", "B11", "B12"]
REFLECTANCE_SCALE = 10_000.0


def load_base(paths: dict[str, str]) -> xr.Dataset:
    """Load the base reflectance bands onto one grid as float32."""
    ref = rioxarray.open_rasterio(paths[BASE_BANDS[0]], masked=True).squeeze("band")
    out = {}
    for name in BASE_BANDS:
        da = rioxarray.open_rasterio(paths[name], masked=True).squeeze("band")
        if da.shape != ref.shape:
            da = da.rio.reproject_match(ref)     # 20 m bands onto the 10 m grid
        out[name] = (da / REFLECTANCE_SCALE).astype("float32")
    return xr.Dataset(out)

The reproject_match call is doing quiet but critical work: Sentinel-2’s shortwave bands ship at 20 m and must land on the 10 m grid before they can share a feature vector with the visible bands. The resampling choice there is discussed in resampling Sentinel-2 20 m bands to 10 m.

Step 2 — Add spectral indices that separate your classes

Indices are ratios that cancel out illumination and emphasise a physical contrast. Adding every index in the literature is a waste; adding three that map onto the classes you care about is most of the signal.

Two indices separate four classes that raw bands do not Plotted against NDVI on the horizontal axis and NDWI on the vertical axis, water sits high and left, bare soil low and left, crops low and far right, and forest mid-right. The four clusters barely overlap, which is why two derived features can outperform six raw reflectance bands for a land cover model. Class separation in index space NDVI (B08 - B04) / (B08 + B04) NDWI -0.2 0.9 water bare soil crops forest two features A third index such as NDMI pulls stressed crops away from healthy ones along a new axis.
import xarray as xr


def add_indices(ds: xr.Dataset) -> xr.Dataset:
    """Append the normalised-difference features a land cover model needs."""
    def nd(a: str, b: str) -> xr.DataArray:
        return ((ds[a] - ds[b]) / (ds[a] + ds[b])).astype("float32")

    ds["NDVI"] = nd("B08", "B04")     # vegetation vigour
    ds["NDWI"] = nd("B03", "B08")     # open water
    ds["NDMI"] = nd("B08", "B11")     # canopy moisture
    ds["NDBI"] = nd("B11", "B08")     # built-up surfaces
    ds["BSI"] = (((ds.B11 + ds.B04) - (ds.B08 + ds.B02))
                 / ((ds.B11 + ds.B04) + (ds.B08 + ds.B02))).astype("float32")
    return ds

Note that NDBI is exactly -NDMI. Including both is harmless for a tree model and actively misleading for a linear one, and it is the kind of redundancy that creeps into a feature list over months. The index definitions themselves are covered in depth in spectral index calculation pipelines.

Step 3 — Encode local context with focal statistics

This is the step that closes most of the gap between a pixel model and a small convolutional one. A focal mean tells the model what the neighbourhood looks like; a focal standard deviation tells it how textured the neighbourhood is. Together they distinguish a smooth field from a broken-up settlement at the same average brightness.

import numpy as np
from scipy import ndimage


def focal_stats(arr: np.ndarray, size: int = 5) -> tuple[np.ndarray, np.ndarray]:
    """NaN-aware focal mean and standard deviation over a square window."""
    valid = np.isfinite(arr).astype("float32")
    filled = np.where(np.isfinite(arr), arr, 0.0).astype("float32")

    count = ndimage.uniform_filter(valid, size=size) * size * size
    total = ndimage.uniform_filter(filled, size=size) * size * size
    mean = np.divide(total, count, out=np.full_like(total, np.nan), where=count > 0)

    sq = ndimage.uniform_filter(filled ** 2, size=size) * size * size
    var = np.divide(sq, count, out=np.full_like(sq, np.nan), where=count > 0) - mean ** 2
    return mean, np.sqrt(np.clip(var, 0, None))

The NaN handling is not optional decoration. A plain uniform_filter over an array containing NaN propagates NaN across the whole window, so a single missing pixel erases a 5×5 block of features. The count-and-divide formulation above degrades gracefully instead, returning the mean of whatever valid pixels the window contains.

Two focal windows — a tight 3×3 and a broad 11×11 — usually beat one, because they encode texture at two scales. Three is rarely worth the read cost.

Step 4 — Join terrain and temporal features

Terrain features come from a DEM that almost never matches the imagery grid, so the join is a resampling operation. Temporal features come from collapsing a time series into statistics that survive missing observations.

Four feature families, one grid Reflectance bands, spectral indices, focal texture statistics and terrain derivatives each arrive from a different source and on a different native grid. Each is resampled onto the reference grid and appended to a single feature stack whose band order is written into the file, giving one array of shape features by rows by columns. Everything lands on the reference grid before it becomes a feature reflectance 6 bands, 10 m and 20 m native indices 5 ratios derived on the reference grid focal texture mean and sd at two window sizes terrain elevation, slope and aspect from a 30 m DEM reproject_match onto the reference grid — one CRS, one transform, one shape bilinear for continuous inputs, nearest for anything categorical feature stack: (28, rows, cols) float32, band descriptions written into the file the same 28 names, in the same order, are asserted at inference time
import rioxarray
import xarray as xr


def add_terrain(ds: xr.Dataset, dem_path: str) -> xr.Dataset:
    """Resample a DEM onto the reference grid and derive slope and aspect."""
    ref = ds[BASE_BANDS[0]]
    dem = rioxarray.open_rasterio(dem_path, masked=True).squeeze("band")
    dem = dem.rio.reproject_match(ref).astype("float32")

    gy, gx = np.gradient(dem.values, abs(ref.rio.resolution()[0]))
    ds["elevation"] = dem
    ds["slope"] = (("y", "x"), np.degrees(np.arctan(np.hypot(gx, gy))).astype("float32"))
    ds["aspect_sin"] = (("y", "x"), np.sin(np.arctan2(-gx, gy)).astype("float32"))
    ds["aspect_cos"] = (("y", "x"), np.cos(np.arctan2(-gx, gy)).astype("float32"))
    return ds

Aspect is encoded as a sine and cosine pair rather than as degrees, because raw aspect is circular: 359° and 1° are neighbours on the ground and maximally distant as numbers. Every tree that splits on raw aspect wastes a split recovering that. The same transformation applies to day-of-year in a temporal stack.

For the temporal half, collapse the series into percentiles rather than means. The median and the 10th/90th percentiles of NDVI across a season describe the phenology far better than an average, and they are robust to the handful of cloudy observations that always survive masking:

import xarray as xr


def add_temporal(ds: xr.Dataset, ndvi_series: xr.DataArray) -> xr.Dataset:
    """Summarise a masked NDVI time series into robust seasonal features."""
    ds["ndvi_p10"] = ndvi_series.quantile(0.10, dim="time").astype("float32")
    ds["ndvi_p50"] = ndvi_series.quantile(0.50, dim="time").astype("float32")
    ds["ndvi_p90"] = ndvi_series.quantile(0.90, dim="time").astype("float32")
    ds["ndvi_range"] = (ds.ndvi_p90 - ds.ndvi_p10).astype("float32")
    ds["ndvi_count"] = ndvi_series.notnull().sum(dim="time").astype("float32")
    return ds

ndvi_count is a feature in its own right, and an unusual one: it tells the model how much evidence each pixel’s summaries rest on. Pixels with three valid observations are systematically noisier than pixels with twenty, and a tree model will happily learn to discount them.

Step 5 — Write a named stack

The stack leaves this stage as a raster with band descriptions, because a feature order that lives only in a Python list is an outage waiting to happen.

import numpy as np
import rasterio
import xarray as xr


def write_feature_stack(ds: xr.Dataset, path: str, names: list[str]) -> None:
    ref = ds[names[0]]
    profile = {
        "driver": "GTiff", "height": ref.sizes["y"], "width": ref.sizes["x"],
        "count": len(names), "dtype": "float32", "crs": ds.rio.crs,
        "transform": ds.rio.transform(), "nodata": np.nan,
        "compress": "zstd", "zstd_level": 3, "tiled": True,
        "blockxsize": 512, "blockysize": 512,
    }
    with rasterio.open(path, "w", **profile) as dst:
        for i, name in enumerate(names, start=1):
            dst.write(ds[name].values.astype("float32"), i)
            dst.set_band_description(i, name)

Which Features Actually Earn Their Place

Feature lists grow monotonically unless someone prunes them, and every addition is paid for on every pixel of every scene forever. The useful discipline is to rank features by permutation importance on a held-out spatial fold — not by the model’s built-in impurity importance, which flatters high-cardinality continuous features — and then to delete from the bottom until accuracy moves.

Permutation importance for a twelve-feature land cover model Seasonal NDVI percentiles and the NDVI range dominate, followed by the shortwave infrared band and the moisture index. Focal texture contributes moderately. Raw blue and green reflectance and the observation count contribute almost nothing, and removing them costs no accuracy while making inference measurably faster. Drop from the bottom until accuracy moves ndvi_p90 ndvi_range ndvi_p10 B11 NDMI focal_sd_11 slope focal_mean_3 aspect_cos B03 ndvi_count the bottom three cost 11% of inference time and 0.002 of accuracy

Three patterns show up again and again in these rankings. Temporal summaries beat single-date reflectance for anything that grows, because phenology is the strongest signal in the data and a single date throws it away. Shortwave infrared punches above its weight for soil, moisture and burn discrimination, which is why a six-band base that omits B11 and B12 handicaps a model before it starts. And raw visible bands are usually the first things to go once indices are present, because the index has already extracted the contrast that mattered and discarded the illumination that did not.

The counter-intuitive entry is ndvi_count. It ranks low here, which is the healthy outcome; when it ranks high it is a warning, as the troubleshooting section below explains.

Pruning has a second benefit that only shows up at scale. A twelve-feature stack read from object storage is twelve byte-range requests per window; a twenty-eight-feature stack is twenty-eight. On a continental run that difference is hours, and it lands squarely on the egress bill described in reducing S3 egress costs in raster pipelines.


Parameter Reference

Parameter Type Default Usage note
REFLECTANCE_SCALE float 10000.0 Sentinel-2 L2A; Landsat Collection 2 uses a scale and an offset
size (focal window) int 5 Odd values only; 3 and 11 together beat any single window
resampling for DEM enum bilinear Bilinear for elevation, nearest for categorical terrain classes
quantile levels list[float] 0.1, 0.5, 0.9 Robust to residual cloud where mean and max are not
dtype str float32 float64 doubles the stack for no measurable accuracy gain
compress str zstd Feature stacks are large and transient; ZSTD level 3 is the sweet spot
nodata float NaN NaN propagates honestly; a sentinel like -9999 becomes a feature value

Verification & Testing

Check three properties before a feature stack is used for anything.

import numpy as np
import rasterio

with rasterio.open("features.tif") as src:
    names = list(src.descriptions)
    assert all(names), "every band must carry a description"
    assert len(set(names)) == len(names), "duplicate feature names"

    sample = src.read(window=((0, 512), (0, 512)))
    finite = np.isfinite(sample).reshape(len(names), -1).mean(axis=1)
    for name, frac in zip(names, finite):
        print(f"{name:<14} {frac:6.1%} finite")

A feature that is 5% finite is a bug, not a feature: it usually means a source raster covered only part of the scene. A feature that is 100% finite where every other band has gaps is equally suspicious — something filled the nodata silently.

The second test is a correlation sweep. Compute pairwise correlations on a sample of pixels and look for pairs above 0.98; those are duplicated information, like the NDBI/NDMI pair above, and dropping one costs nothing.

The third test is the one that catches drift: recompute a single feature for one window using the independent, naive implementation and compare. If the fast focal filter and a plain loop over a 5×5 neighbourhood disagree by more than floating-point noise, the fast path has a boundary bug.


Troubleshooting

Every focal feature is NaN near the scene edge

ndimage pads with zeros by default, and the count-based correction then divides by a partial count — correct, but only if mode="constant" with cval=0 is used consistently for both numerator and denominator. Mixing mode="reflect" for one and the default for the other produces exactly this artefact.

Feature values differ between training and inference

The band order changed. This is why descriptions are written into the file and asserted on load. If the assertion is missing, add it before debugging anything else.

MemoryError building the stack for a full tile

A 10,980 × 10,980 scene with 28 float32 features is 13 GB. Build the stack per window rather than per scene, or write bands incrementally as the code above does and never hold more than one feature at a time.

Terrain features look blocky compared to the imagery

A 30 m DEM resampled to 10 m with nearest-neighbour produces 3×3 plateaus. Use bilinear for continuous terrain, and consider computing slope at the DEM’s native resolution before resampling, which is more faithful than computing it from the upsampled surface.

The model ranks ndvi_count as the most important feature

That is usually real and usually a problem: the model has learned which regions are cloudy rather than what is on the ground. Check whether cloud frequency correlates with your classes geographically, and if it does, drop the count feature and rebalance the training sample.


Frequently Asked Questions

Q: Do tree-based models need features to be scaled? No. Decision trees split on thresholds, so any monotonic rescaling leaves the model unchanged. Scaling matters for linear models, neural networks and anything distance-based, and it matters for storage regardless, because a float32 stack is four times the size of a scaled int16 one.

Q: How many features is too many for a raster model? The practical ceiling is set by inference cost, not by the model. Every feature is a band that must be read, computed and held in memory for every pixel of every scene. Thirty well-chosen features usually beat a hundred and twenty, and they run four times faster.

Q: Should spectral indices be computed before or after cloud masking? After. A cloudy pixel has a physically meaningless index value, and if it survives into a temporal summary it will drag the median or the percentile with it. Mask first, then compute, then aggregate.

Q: Is it worth adding features from a second sensor? Often, but only after harmonisation. Landsat and Sentinel-2 reflectance differ enough that an unharmonised join teaches the model to detect which sensor a pixel came from. The correction is covered in harmonising Landsat and Sentinel-2 reflectance.


Deep-Dive Articles