Applying a Terrain Illumination Correction
Compute how directly each pixel faces the sun, then normalise reflectance against it:
import numpy as np
cos_i = (np.cos(sun_zen) * np.cos(slope)
+ np.sin(sun_zen) * np.sin(slope) * np.cos(sun_az - aspect)) # local illumination
# SCS+C: bounded correction that accounts for diffuse skylight
corrected = refl * (np.cos(slope) * np.cos(sun_zen) + c) / (cos_i + c)
The same formula as a hillshade, but with the real sun of the acquisition rather than a conventional light. This page belongs to terrain analysis and DEM-derived products in Satellite Processing Workflows & Index Pipelines.
The Problem, Quantified
For a single cover type, reflectance should not depend on how the slope faces the sun, yet uncorrected pixels line up along a clear trend. That trend is both the problem and the tool: its intercept and slope give the empirical C parameter that the correction uses to account for diffuse light.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
The illumination geometry and correction |
rioxarray |
>=0.15 |
Aligning DEM derivatives with the imagery |
scikit-learn |
>=1.3 |
Robust regression for the C parameter |
pip install "numpy>=1.23" "rioxarray>=0.15" "scikit-learn>=1.3"
Complete Working Example
import numpy as np
from sklearn.linear_model import HuberRegressor
def illumination(slope_deg: np.ndarray, aspect_deg: np.ndarray,
sun_zenith_deg: float, sun_azimuth_deg: float) -> np.ndarray:
s, a = np.radians(slope_deg), np.radians(aspect_deg)
z, phi = np.radians(sun_zenith_deg), np.radians(sun_azimuth_deg)
return (np.cos(z) * np.cos(s) + np.sin(z) * np.sin(s) * np.cos(phi - a)).astype("float32")
def fit_c(band: np.ndarray, cos_i: np.ndarray, cover_mask: np.ndarray,
min_pixels: int = 2_000) -> float:
"""C parameter from reflectance ~ cos_i over one cover type."""
ok = cover_mask & np.isfinite(band) & np.isfinite(cos_i) & (cos_i > 0)
if ok.sum() < min_pixels:
raise ValueError("too few pixels of the reference cover to fit C")
m = HuberRegressor().fit(cos_i[ok, None], band[ok])
slope, intercept = float(m.coef_[0]), float(m.intercept_)
return intercept / slope if slope > 0 else np.inf
def scs_c(band: np.ndarray, slope_deg: np.ndarray, cos_i: np.ndarray,
sun_zenith_deg: float, c: float) -> np.ndarray:
s = np.radians(slope_deg)
z = np.radians(sun_zenith_deg)
out = band * (np.cos(s) * np.cos(z) + c) / (cos_i + c)
out[cos_i <= 0] = np.nan # self-shadowed: no direct light at all
return out.astype("float32")
Fitting C over a single cover type — mature forest is the usual choice in mountains — is essential. The regression assumes the only reason reflectance varies is illumination; fitting over mixed cover folds real land-cover differences into C and produces a correction that is subtly wrong everywhere. The sun angles come from the scene’s catalog properties, found with the tools in querying STAC catalogs programmatically.
Three Corrections Compared
The cosine correction is theoretically clean and practically dangerous: it divides by the illumination term, which approaches zero on slopes facing away from the sun, so shaded slopes are brightened far beyond their true reflectance. Adding the C term to both numerator and denominator bounds the correction, and the SCS variant additionally models how forest canopies are lit, which is why SCS+C has become the default for vegetated terrain.
Self-shadowed pixels — those facing so far away from the sun that cos(i) is negative — receive no direct light at all, and no illumination correction can recover them. Masking them, as the example does, is the honest treatment; they are not observable in that acquisition.
When to Correct, and When Not To
Illumination correction matters most for single-band analysis, absolute reflectance, and classification in steep terrain, where illumination can exceed the spectral difference between classes. It matters least for normalised ratios such as NDVI, because illumination multiplies both bands roughly equally and largely cancels in the ratio — though diffuse light and adjacency break that cancellation on strongly shaded slopes.
It also matters for change detection between dates with different sun elevation: a lower winter sun lengthens shadows and changes the illumination on every slope, which a differencing product will report as change. Correcting both dates, or comparing only anniversary dates, removes that false signal, as discussed in separating real change from illumination differences.
Verification
import numpy as np
def trend(band, cos_i, mask):
ok = mask & np.isfinite(band) & np.isfinite(cos_i)
return float(np.polyfit(cos_i[ok], band[ok], 1)[0])
before = trend(nir, cos_i, forest)
after = trend(nir_corrected, cos_i, forest)
print(f"slope before {before:.3f}, after {after:.3f}")
assert abs(after) < 0.25 * abs(before), "illumination dependence remains"
The regression check is the definitive test because it measures exactly the effect the correction is meant to remove. A substantial remaining slope means C was fitted over mixed cover, the aspect convention is inverted, or the solar geometry came from the wrong scene.
Common Errors
Shaded slopes become extremely bright
The plain cosine correction was used. Switch to C or SCS+C.
The correction makes things worse
Aspect is measured in the wrong convention, so illumination is computed for mirrored slopes. Verify aspect on a known slope.
C comes out negative or infinite
The regression slope is non-positive, usually because C was fitted over too little terrain or mixed cover. Fit over a larger area of one cover type.
Correction is inconsistent between scenes
Each scene needs its own solar angles and its own C. Never reuse either across acquisitions.
Frequently Asked Questions
Q: Why not use the simple cosine correction? Because it assumes all light is direct. Shaded slopes also receive diffuse skylight, so dividing by a small illumination value massively over-brightens them. The C-correction adds an empirical term for diffuse light that keeps the correction bounded.
Q: Where do the solar angles come from? From the scene metadata: the mean solar zenith and azimuth at acquisition time, usually present in the catalog item as view and sun angle properties. For large scenes, per-pixel angle grids exist in some products and are slightly more accurate.
Q: Should I correct before computing indices? For indices that are ratios of bands, much of the illumination effect already cancels, so correction matters less. For absolute reflectance, single bands and classification in steep terrain, correct before anything else.
Q: Does the DEM resolution matter for the correction? Yes. The DEM must resolve the slopes that the imagery resolves; a 90 metre DEM under 10 metre imagery smooths away small ridges, leaving their illumination uncorrected. Use a DEM at or near the imagery resolution, derived at native resolution and resampled onto the image grid.
Q: Can one C parameter serve a whole region? Only if the region shares one dominant cover and one acquisition. C depends on the band, the sun geometry and the surface, so fit it per scene and per band, and refit wherever the reference cover changes substantially across a large area.
Related
- Terrain Analysis and DEM-Derived Products — the parent topic.
- Generating Hillshade Rasters in Python — the same geometry with a conventional light.
- Atmospheric Correction and Surface Reflectance — the correction that should come first.
- Computing Slope and Aspect from a DEM — the inputs, and the conventions that must match.