3  Feature Engineering

The pipeline assembles 591 features per segment. Features fall into two categories: pixel features (computed at the raster level, then summarised per segment as mean + std) and segment-only features (computed directly from segment geometry or label raster).

3.1 Feature summary

Group Features Type
Spectral bands (8 bands × 10 epochs) 80 Pixel
Legacy indices (NDVI/NDWI/NDBI/EVI × 10 epochs) 40 Pixel
Temporal summaries (max/min/std/amp × 4 indices) 16 Pixel
Tree height ETH (mean + std within 30 m) 2 Pixel
New per-epoch indices (10 indices × 10 epochs) 100 Pixel
Temporal percentiles (p10/p50/p90 × 4 indices) 12 Pixel
Harmonic fit (amp/phase/offset × 4 indices) 12 Pixel
Year-over-year delta (March 2024 → March 2026 × 4 indices) 4 Pixel
Texture (entropy, range, CV, MAD of NDVI/NIR) 6 Segment
Shape (area, perimeter, compactness, elongation, fill) 5 Segment
Sentinel-1 SAR (19 bands × mean + std) 38 Pixel
PALSAR-2 (5 bands × mean + std) 10 Pixel
Meta v2 canopy (4 statistics × mean + std) 8 Pixel
Total 591

3.2 Legacy pixel cache (138 features)

Built by planetscope_10epoch_local.py. For each of the 10 epochs:

  • 8 raw spectral bands ({epoch}_b1 … {epoch}_b8)
  • 4 spectral indices: NDVI, NDWI, NDBI, EVI

Plus temporal summary statistics across all 10 epochs (4 indices × 4 stats = 16 features):

max{index}, min{index}, std{index}, amp{index}

And 2 ETH canopy height features: tree_height_mean, tree_height_std.

Band mapping (legacy): the legacy pixel script treats b2=Blue, b3=Green_I, b4=Red, b6=Red, b8=NIR. The v3 extension uses the full SuperDove mapping for new indices only; legacy features are unchanged to preserve reproducibility.

3.3 New per-epoch indices (100 features)

Computed by planetscope_10epoch_obia_v3.py using the full SuperDove band set. 10 new indices per epoch × 10 epochs = 100 features:

Index Formula Sensitivity
NDRE (NIR − RedEdge) / (NIR + RedEdge) Canopy chlorophyll
CIre NIR / RedEdge − 1 Chlorophyll index
RENDVI (RedEdge − Red) / (RedEdge + Red) Early stress detection
SAVI (NIR − Red)(1 + L) / (NIR + Red + L), L=0.5 Bare-soil adjusted
OSAVI (NIR − Red) / (NIR + Red + 0.16) Open canopy
MSAVI2 0.5 × (2NIR + 1 − √((2NIR+1)² − 8(NIR−Red))) Minimised soil effect
GNDVI (NIR − Green) / (NIR + Green) Canopy greenness
ARVI (NIR − (2Red − Blue)) / (NIR + (2Red − Blue)) Atmospheric resistant
VARI (Green − Red) / (Green + Red − Blue) Visible atmosphere resistant
BSI ((Yellow + Red) − (NIR + Blue)) / ((Yellow + Red) + (NIR + Blue)) Bare soil (no SWIR; Yellow substitutes)

Results are cached as single-band GeoTIFFs in outputs_10epoch_obia_v3/feature_cache_v3/. First run: ~45 sec/epoch. Subsequent runs: instant cache hit.

3.4 Temporal features (28 features)

Derived across the 10-epoch time series for four curated indices: NDVI, EVI, NDRE, GNDVI.

3.4.1 Percentiles (12 features)

Per-pixel p10, p50, p90 across the 10 epochs. Robust to outliers from cloud shadow or missing acquisitions:

p10{index}, p50{index}, p90{index}

3.4.2 Harmonic fit (12 features)

Fits a one-term Fourier model per pixel:

y = a0 + a1·cos(2π·DOY/365) + b1·sin(2π·DOY/365)

Three derived features per index: amplitude (√(a1² + b1²)), phase (arctan2(b1, a1)), and offset (a0). The amplitude captures the magnitude of seasonal greenness variation; the offset is the mean greenness level.

harmAmp{index}, harmPhase{index}, harmOffset{index}

harmOffsetNDVI ranked #10 and ampNDVI ranked #4 in the top-15 feature importance list — showing that seasonality structure is more informative than any single-date observation.

3.4.3 Year-over-year delta (4 features)

March 2024 → March 2026 pixel-wise difference for each curated index. Captures land-cover change and phenological shift over the two-year observation window:

yoy{index}_mar26_mar24

3.5 Texture features (6 segment-level)

Computed on the temporal-median NDVI and NIR images (not per-epoch, to avoid dominance by any single date):

Feature Description
entropyNDVI__seg Shannon entropy of the NDVI histogram within the segment
entropyNIR__seg Shannon entropy of the NIR histogram within the segment
rangeNDVI__seg p90 − p10 range of NDVI pixels within the segment
rangeNIR__seg p90 − p10 range of NIR pixels within the segment
cvNDVI__seg Coefficient of variation of NDVI within the segment
madNDVI__seg Mean absolute deviation of NDVI within the segment

High entropy and range indicate heterogeneous vegetation (e.g. agroforest edges), while low values indicate uniform canopy (e.g. paddy, water).

3.5.1 Note on Haralick GLCM textures

Haralick GLCM textures (OTB otbcli_HaralickTextureExtraction) were tested on 2026-05-03 and disabled. The hypothesis was that planting-row periodicity in production forest would appear as high ASM/energy at a 1-pixel (~3 m) offset. In practice, the temporal-median composite smoothed away the spatial pattern, and the 32 extra features dropped L2 CV OA from 0.65 to 0.61 (curse of dimensionality at 97 training rows). Re-enabling is gated by cfg.haralick_enable = True.

3.6 Shape features (5 segment-level)

Derived from the segment label raster:

Feature Description
area_m2__seg Segment area in square metres
perimeter_m__seg Perimeter length (4-connected edge counting)
compactness__seg 4π × area / perimeter²
elongation__seg Bounding-box major / minor axis ratio
fill_ratio__seg Area / bounding-box area

3.7 Zonal statistics

All pixel features are summarised per segment via zonal mean and standard deviation — two segment features per pixel feature. The standard deviation captures within-segment variability (e.g. a heterogeneous segment with mixed vegetation will have high std across spectral bands).

Segments with fewer than 4 in-AOI pixels are flagged as boundary slivers and dropped from training.

3.7.1 Median imputation

Some PlanetScope epochs have a 1–2 pixel ragged edge in certain bands (observed: sep25_b1, nov25_b1). Rather than dropping all segments that touch this edge, v3 median-imputes partial-NaN features:

non_sliver = ~seg_df[feature_cols].isna().all(axis=1)
medians = seg_df.loc[non_sliver, feature_cols].median()
seg_df.loc[non_sliver, feature_cols] = (
    seg_df.loc[non_sliver, feature_cols].fillna(medians)
)

This recovered 13 labeled segments in the 2026-05-03 run, including 6 of 6 newly-added Production Forest samples that sat on the edge.