Applying Dark Object Subtraction in Python
Mask clouds and shadows, take a low percentile of each band as its haze, subtract it:
import numpy as np
def dos(band: np.ndarray, clear: np.ndarray, pct: float = 0.1) -> np.ndarray:
dark = np.float32(np.percentile(band[clear & np.isfinite(band)], pct))
return np.clip(band - dark, 0, None) # remove additive path radiance
Crude, fast, needing nothing beyond the scene itself — and good enough to remove most visible haze. This page belongs to atmospheric correction and surface reflectance in Satellite Processing Workflows & Index Pipelines.
What Is Being Subtracted
The size of the shift per band is itself a sanity check. Rayleigh and aerosol scattering fall off steeply with wavelength, so the dark value should be largest in the blue, smaller in the green and red, and close to zero in the near and shortwave infrared. A dark value that is higher in the near infrared than in the blue means the “dark object” was not dark in the near infrared — often vegetation rather than water.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Percentiles and subtraction |
rasterio |
>=1.3.0 |
Reading bands and writing the result |
scipy |
>=1.11 |
Optional dilation of the cloud mask |
pip install "numpy>=1.23" "rasterio>=1.3.0" "scipy>=1.11"
Complete Working Example
import numpy as np
from scipy import ndimage
def clear_mask(cloud: np.ndarray, shadow: np.ndarray, *, buffer_px: int = 3) -> np.ndarray:
"""Pixels safely away from clouds and cloud shadows."""
bad = ndimage.binary_dilation(cloud | shadow, iterations=buffer_px)
return ~bad
def dark_values(bands: dict[str, np.ndarray], clear: np.ndarray,
pct: float = 0.1, min_pixels: int = 5_000) -> dict[str, float]:
out = {}
for name, arr in bands.items():
sel = clear & np.isfinite(arr)
if sel.sum() < min_pixels:
raise ValueError(f"too few clear pixels ({sel.sum()}) to estimate haze in {name}")
out[name] = float(np.percentile(arr[sel], pct))
return out
def apply_dos(bands: dict[str, np.ndarray], dark: dict[str, float]) -> dict[str, np.ndarray]:
return {name: np.clip(arr - np.float32(dark[name]), 0, None).astype("float32")
for name, arr in bands.items()}
if __name__ == "__main__":
# bands: TOA reflectance arrays keyed by name; cloud/shadow: boolean masks
clear = clear_mask(cloud, shadow)
dark = dark_values(bands, clear)
print({k: round(v, 4) for k, v in dark.items()}) # should fall with wavelength
corrected = apply_dos(bands, dark)
Requiring a minimum number of clear pixels is the guard against the method’s worst failure: a mostly cloudy scene where the “darkest” clear pixels are just the edges of shadows the mask missed. Refusing to correct such a scene is better than silently over-darkening it, and the refusal points at a masking problem worth fixing — covered in dilating cloud masks to catch thin cirrus.
Choosing the Percentile
The absolute minimum is a poor choice because a few defective or saturated-low pixels set it. A percentile in the plateau — 0.1 is a good default — reflects genuinely dark surfaces while ignoring outliers. Scenes with no dark surfaces at all, such as a dry landscape without water or deep shadow, have no plateau; there the method has nothing to anchor on and should not be applied.
Where the Method Breaks Down
Dark object subtraction removes an additive constant per band and nothing else. It does not correct attenuation, which scales the whole signal down in hazy conditions. It does not handle aerosol variation across the scene, which matters for large scenes near pollution sources or dust plumes. It does not handle adjacency effects near bright boundaries. And it cannot handle terrain: shadowed slopes are dark because of geometry, not atmosphere.
For those reasons its output is best treated as “haze-reduced reflectance” rather than surface reflectance. It is excellent for visual products and good for single-scene classification; for quantitative time series it narrows the gap to a physical correction without closing it, and a provider’s surface reflectance product should be preferred whenever one exists, as the comparison in comparing L1C and L2A Sentinel-2 products shows.
Verification
dark = dark_values(bands, clear)
order = ["blue", "green", "red", "nir", "swir1"]
vals = [dark[b] for b in order if b in dark]
assert all(a >= b - 0.005 for a, b in zip(vals, vals[1:])), \
f"dark values do not fall with wavelength: {dict(zip(order, vals))}"
assert dark["blue"] < 0.2, "blue dark value implausibly high — cloud leaking into the sample"
The monotonic check encodes the physics directly, and it costs nothing to run on every scene. It fails for exactly the scenes where the method is unreliable — no true dark objects, or cloud contamination in the sample — and turns a silent over-correction into an explicit error.
Common Errors
The corrected scene is too dark everywhere
A cloud shadow set the dark value. Mask and dilate clouds and shadows before estimating.
Near-infrared dark value is higher than blue
The darkest pixels are vegetation, not water or shadow. The scene lacks true dark objects; do not apply the method.
Many pixels clip to exactly zero
The percentile is too high and has reached into ordinary surfaces. Lower it into the plateau.
The correction is applied twice in a pipeline
A surface reflectance product was fed through the same step as top-of-atmosphere data. Check the processing-level tag before applying, and skip anything already corrected.
Results vary a lot between adjacent scenes
Each scene’s dark value is estimated independently, and aerosol load genuinely differs. That variation is the point, but it limits cross-scene consistency.
Frequently Asked Questions
Q: What does dark object subtraction assume? That somewhere in the scene there are pixels whose true reflectance is essentially zero in each band — deep clear water, dense shadow — so whatever they record is atmospheric path radiance. It also assumes that path radiance is the same across the scene.
Q: Why must clouds be masked first? Cloud shadows are among the darkest pixels in a scene, and they are dark for a reason unrelated to the clear-sky atmosphere. If a shadow sets the dark value, the whole scene is over-corrected and everything becomes too dark.
Q: Is it good enough for time series? It is much better than nothing and worse than a physical correction. It removes most day-to-day haze variation but not attenuation or spatial variation in aerosols, so residual differences between dates remain.
Q: Should it be applied to surface reflectance products? No. They are already corrected, and subtracting a dark value again removes real signal from dark surfaces. Apply it only to top-of-atmosphere data.
Q: Can the dark values be reused for the next scene? Only for scenes acquired on the same pass under the same conditions. Aerosol load changes from day to day, and sometimes within hours, so reusing another date’s dark values defeats the purpose of estimating them. Estimate per scene and record the values in the output’s tags for later comparison.
Related
- Atmospheric Correction and Surface Reflectance — the parent topic.
- Converting Landsat DN to TOA Reflectance — the input this correction expects.
- Building True Colour Composites with Contrast Stretch — the display-only cousin of this correction.
- Masking Clouds with the Sentinel-2 SCL Band — producing the clear mask it needs.