Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/spectral-indices.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ By calculating normalized differences, empirical scaling offsets, and non-linear
┌────────────────────────────────┐
Linear Quantization Scaling │ ──► $I_{\text{norm}} \in [0.0, 1.0]$
Radiometric Scaling (optional) │ ──► $\rho = \text{DN} \times s + o$
└───────────────┬────────────────┘
┌──────────────────────────┴──────────────────────────┐
Expand All @@ -34,6 +34,48 @@ By calculating normalized differences, empirical scaling offsets, and non-linear
└────────────────────────────────┘
```

## Radiometric Input Requirements

**Indices are computed on the band values as read.** No rescaling is applied unless you ask for it.

> **Changed in 1.4.0.** Earlier releases routed every index through a per-band min–max rescale, $(x - x_{\min}) / (x_{\max} - x_{\min})$, applied *independently to each band*. Because each band received a different affine transform, this altered the relationships **between** bands — and those relationships are the entire physical content of a spectral index. Three consequences: published thresholds did not apply, values were not comparable across scenes or dates, and **a pixel's value depended on how much of the image you loaded**, since the rescale used the loaded extent's own extrema. Index values from earlier versions are not comparable with current output.
>
> Measured on the bundled example, the rescale inverted inter-band relationships outright: AFRI's correlation with NDVI ran **+0.71 on the values as stored and −0.69 after rescaling**, against a source paper that reports the two as nearly identical. Min–max normalization remains in use for the enhancement, HSV, PCA and SVM modules, where rescaling is appropriate — and for SVM it is necessary, since an RBF kernel needs comparable feature scales.

### Which indices need reflectance, and which do not

| Index | Constant in reflectance units? | Safe on raw DN? |
|---|---|---|
| NDVI, NDWI, UI, BSI | none | **Yes** — a normalized difference is invariant to a gain applied to all bands equally |
| SAVI | soil adjustment $L = 0.5$ | **No** |
| AFRI | coefficients $0.66$ / $0.50$ on SWIR | **No** |

Adding $L = 0.5$ to a digital number in the thousands contributes nothing, so SAVI on unscaled input is silently not SAVI. The same applies to AFRI's coefficients. Both emit a `UserWarning` when handed values far outside the reflectance range.

### Supplying the scaling

Every index accepts `scale_factor` and `offset`, applied as $\rho = \text{DN} \times s + o$. Published values for the common analysis-ready products ship as `RADIOMETRIC_PRESETS`:

```Python
from fezrs import SAVICalculator
from fezrs.utils.radiometry_handler import RADIOMETRIC_PRESETS

SAVICalculator(
nir_path="LC09_..._SR_B5.TIF",
red_path="LC09_..._SR_B4.TIF",
**RADIOMETRIC_PRESETS["landsat-c2-l2"], # scale 2.75e-5, offset -0.2
).execute(output_path="./exports/")
```

| Preset | Scale | Offset |
|---|---|---|
| `landsat-c2-l2` | $2.75 \times 10^{-5}$ | $-0.2$ |
| `sentinel2-l2a` | $10^{-4}$ | $0.0$ |
| `sentinel2-l2a-baseline4` | $10^{-4}$ | $-0.1$ |
| `reflectance` | $1.0$ | $0.0$ |

Sentinel-2 processing baseline 04.00 and later carries a `BOA_ADD_OFFSET` of $-1000$, hence the separate preset. If your product is already reflectance, the defaults are correct and nothing needs passing.

## Mathematical & Scientific Formulations

### `NDVICalculator` (Normalized Difference Vegetation Index)
Expand Down
14 changes: 11 additions & 3 deletions fezrs/tools/spectral_indices/afri_calculator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling, warn_if_not_reflectance
from fezrs.utils.type_handler import AFRIVariantType, BandPathType


Expand Down Expand Up @@ -38,13 +39,17 @@ def __init__(
swir1_path: BandPathType | None = None,
swir2_path: BandPathType | None = None,
variant: AFRIVariantType = "1.6",
scale_factor: float = 1.0,
offset: float = 0.0,
):
"""
Args:
nir_path: Near-infrared band.
swir1_path: SWIR ~1.6 um band, required for the ``"1.6"`` variant.
swir2_path: SWIR ~2.1 um band, required for the ``"2.1"`` variant.
variant: Which AFRI formulation to compute.
scale_factor: Multiplicative radiometric scale, see RADIOMETRIC_PRESETS.
offset: Additive radiometric offset.
"""
if variant not in AFRI_COEFFICIENTS:
raise ValueError(
Expand All @@ -71,16 +76,19 @@ def __init__(

super().__init__(**band_paths)

self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=["nir", self.swir_band]
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=["nir", self.swir_band]),
scale_factor=scale_factor,
offset=offset,
)
warn_if_not_reflectance(self.source_bands, "AFRI")

def _validate(self):
pass

def process(self):
nir, swir = (
self.normalized_bands[band] for band in ("nir", self.swir_band)
self.source_bands[band] for band in ("nir", self.swir_band)
)

self._output = divide_with_nan(
Expand Down
15 changes: 11 additions & 4 deletions fezrs/tools/spectral_indices/bi_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling
from fezrs.utils.type_handler import BandPathType, BIFormulationType


Expand Down Expand Up @@ -40,6 +41,8 @@ def __init__(
swir1_path: BandPathType | None = None,
blue_path: BandPathType | None = None,
formulation: BIFormulationType | None = None,
scale_factor: float = 1.0,
offset: float = 0.0,
):
"""
Args:
Expand All @@ -50,6 +53,8 @@ def __init__(
blue_path: Blue band, required by ``"bsi"``.
formulation: ``"bsi"`` or ``"legacy"``. Inferred from the supplied
bands when omitted.
scale_factor: Multiplicative radiometric scale, see RADIOMETRIC_PRESETS.
offset: Additive radiometric offset.
"""
if formulation is None:
formulation = (
Expand Down Expand Up @@ -95,8 +100,10 @@ def __init__(

super().__init__(**band_paths)

self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=list(self._required_bands)
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=list(self._required_bands)),
scale_factor=scale_factor,
offset=offset,
)

def _validate(self):
Expand All @@ -105,7 +112,7 @@ def _validate(self):
def process(self):
if self.formulation == "bsi":
swir1, red, nir, blue = (
self.normalized_bands[band]
self.source_bands[band]
for band in ("swir1", "red", "nir", "blue")
)
self._output = divide_with_nan(
Expand All @@ -114,7 +121,7 @@ def process(self):
)
else:
nir, red, green = (
self.normalized_bands[band] for band in ("nir", "red", "green")
self.source_bands[band] for band in ("nir", "red", "green")
)
self._output = divide_with_nan(
(nir - green) - red,
Expand Down
12 changes: 9 additions & 3 deletions fezrs/tools/spectral_indices/ndvi_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling, warn_if_not_reflectance
from fezrs.utils.type_handler import BandPathType


Expand All @@ -13,17 +14,22 @@ def __init__(
self,
nir_path: BandPathType,
red_path: BandPathType,
scale_factor: float = 1.0,
offset: float = 0.0,
):
super().__init__(nir_path=nir_path, red_path=red_path)
self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=["nir", "red"]
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=["nir", "red"]),
scale_factor=scale_factor,
offset=offset,
)
warn_if_not_reflectance(self.source_bands, "NDVI")

def _validate(self):
pass

def process(self):
nir, red = (self.normalized_bands[band] for band in ("nir", "red"))
nir, red = (self.source_bands[band] for band in ("nir", "red"))

self._output = divide_with_nan(nir - red, nir + red)
return self._output
Expand Down
12 changes: 9 additions & 3 deletions fezrs/tools/spectral_indices/ndwi_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling, warn_if_not_reflectance
from fezrs.utils.type_handler import BandPathType


Expand All @@ -13,17 +14,22 @@ def __init__(
self,
nir_path: BandPathType,
green_path: BandPathType,
scale_factor: float = 1.0,
offset: float = 0.0,
):
super().__init__(nir_path=nir_path, green_path=green_path)
self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=["nir", "green"]
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=["nir", "green"]),
scale_factor=scale_factor,
offset=offset,
)
warn_if_not_reflectance(self.source_bands, "NDWI")

def _validate(self):
pass

def process(self):
nir, green = (self.normalized_bands[band] for band in ("nir", "green"))
nir, green = (self.source_bands[band] for band in ("nir", "green"))

self._output = divide_with_nan(green - nir, nir + green)
return self._output
Expand Down
12 changes: 9 additions & 3 deletions fezrs/tools/spectral_indices/savi_calculator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling, warn_if_not_reflectance
from fezrs.utils.type_handler import BandPathType


Expand All @@ -10,17 +11,22 @@ def __init__(
self,
nir_path: BandPathType,
red_path: BandPathType,
scale_factor: float = 1.0,
offset: float = 0.0,
):
super().__init__(nir_path=nir_path, red_path=red_path)
self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=["nir", "red"]
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=["nir", "red"]),
scale_factor=scale_factor,
offset=offset,
)
warn_if_not_reflectance(self.source_bands, "SAVI")

def _validate(self):
pass

def process(self):
nir, red = (self.normalized_bands[band] for band in ("nir", "red"))
nir, red = (self.source_bands[band] for band in ("nir", "red"))

self._output = divide_with_nan(nir - red, nir + red + 0.5) * 1.5
return self._output
Expand Down
12 changes: 9 additions & 3 deletions fezrs/tools/spectral_indices/ui_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Import module and files
from fezrs.base import BaseTool
from fezrs.tools.spectral_indices._division import divide_with_nan
from fezrs.utils.radiometry_handler import apply_scaling, warn_if_not_reflectance
from fezrs.utils.type_handler import BandPathType


Expand All @@ -13,17 +14,22 @@ def __init__(
self,
nir_path: BandPathType,
swir2_path: BandPathType,
scale_factor: float = 1.0,
offset: float = 0.0,
):
super().__init__(nir_path=nir_path, swir2_path=swir2_path)
self.normalized_bands = self.files_handler.get_normalized_bands(
requested_bands=["nir", "swir2"]
self.source_bands = apply_scaling(
self.files_handler.get_bands(requested_bands=["nir", "swir2"]),
scale_factor=scale_factor,
offset=offset,
)
warn_if_not_reflectance(self.source_bands, "UI")

def _validate(self):
pass

def process(self):
nir, swir2 = (self.normalized_bands[band] for band in ("nir", "swir2"))
nir, swir2 = (self.source_bands[band] for band in ("nir", "swir2"))

self._output = divide_with_nan(swir2 - nir, nir + swir2)
return self._output
Expand Down
1 change: 1 addition & 0 deletions fezrs/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .file_handler import *
from .radiometry_handler import *
from .type_handler import *
from .histogram_handler import *
27 changes: 27 additions & 0 deletions fezrs/utils/file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@ def get_normalized_bands(
if self.bands.get(band) is not None
}

def get_bands(self, requested_bands: Optional[List[BandNameType]] = None):
"""
Retrieve the requested image bands with their values as read.

Unlike :meth:`get_normalized_bands`, no rescaling is applied. Spectral
indices must use this accessor: a per-band min-max rescale gives each
band a different affine transform, which alters the relationships
*between* bands, and those relationships are the entire physical content
of a band ratio.

Args:
requested_bands (Optional[List[BandNameType]]): A list of band names
to return. If None, all available bands are returned.

Returns:
Dict[str, Optional[np.ndarray]]: A dictionary mapping band names to
their image data as read. Bands with no data are excluded.
"""
if requested_bands is None:
requested_bands = list(self.bands.keys())

return {
band: self.bands[band]
for band in requested_bands
if self.bands.get(band) is not None
}

def get_metadata_bands(
self, requested_bands: Optional[list[BandNameType]] = None
) -> Dict[str, Dict]:
Expand Down
Loading