Source code for hypergas.quality_mask

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2023-2024 HyperGas developers
#
# This file is part of hypergas.
#
# hypergas is a library to retrieve trace gases from hyperspectral satellite data
"""Create a 2D quality mask for hyperspectral satellite data."""

import logging
import numpy as np
import xarray as xr

from .unit_spectrum import Unit_spec

LOG = logging.getLogger(__name__)


[docs] class QualityMask(): """Quality mask for hyperspectral satellite scenes. Computes per-pixel boolean flags for water, cloud, and cirrus contamination based on top-of-atmosphere (TOA) reflectance thresholds, and combines them into a single ``qmask`` DataArray. Parameters ---------- scn : :class:`~satpy.Scene` Satpy Scene that must contain the following datasets: * ``'radiance'`` – spectral radiance in W m-2 sr-1 um-1, with dimensions ``(bands, y, x)``. * ``'sza'`` – solar zenith angle in degrees, shape ``(y, x)``. Attributes ---------- scn : :class:`~satpy.Scene` The input scene (stored for reference). rad : :class:`~xarray.DataArray` Radiance converted to uW cm-2 sr-1 nm-1 (divided by 10). sza : float Scene-mean solar zenith angle in radians. rho : :class:`~xarray.DataArray` TOA apparent reflectance, shape ``(bands, y, x)``. water : :class:`~xarray.DataArray` Boolean water mask, shape ``(y, x)``. cloud : :class:`~xarray.DataArray` Boolean cloud mask, shape ``(y, x)``. cirrus : :class:`~xarray.DataArray` Boolean cirrus mask, shape ``(y, x)``. qmask : :class:`~xarray.DataArray` Combined quality mask with a ``quality_flag`` dimension whose labels are ``['water', 'cloud', 'cirrus', 'invalid']``. """ def __init__(self, scn): # Store scene and convert radiance units: # W m-2 sr-1 um-1 -> uW cm-2 sr-1 nm-1 (factor = 1/10) self.scn = scn self.rad = scn['radiance'] / 10 # Scene-mean solar zenith angle (degrees -> radians) self.sza = np.deg2rad(float(scn['sza'].mean())) # Pre-compute TOA reflectance used by all mask methods self._toa() def _toa(self): """Compute top-of-atmosphere (apparent) reflectance. The TOA reflectance is defined as: .. math:: \\rho(\\lambda) = \\frac{\\pi \\cdot L(\\lambda)}{E_0(\\lambda) \\cdot \\cos(\\theta_s)} where :math:`L` is the at-sensor radiance (uW cm-2 sr-1 nm-1), :math:`E_0` is the extraterrestrial solar irradiance convolved to the sensor's spectral response function (uW cm-2 nm-1), and :math:`\\theta_s` is the solar zenith angle. The result is stored in ``self.rho`` with shape ``(bands, y, x)``. """ # Build a Unit_spec object to access the solar irradiance spectrum unit = Unit_spec( self.scn['radiance'], self.scn['radiance'].coords['bands'], self.scn['radiance'].coords['bands'].min(), self.scn['radiance'].coords['bands'].max(), ) irr = unit.solar_irradiance # convert to uW cm-2 nm-1 irr /= 10 irr.attrs['units'] = 'uW cm-2 nm-1' # Convolve the high-resolution solar spectrum with the sensor SRF irr_resampled = unit._convolve( unit.wvl_sensor, unit.fwhm_sensor, irr.coords['wavelength'].values, irr.values, ) # Compute TOA reflectance: ρ = (π · L) / (E₀ · cos θ_s) # Transpose operations keep xarray dimension alignment correct # Keep rho as a lazy dask array — don't load the full cube here irr_resampled = xr.DataArray( irr_resampled, dims=["bands"], coords={"bands": self.rad["bands"]} ) self.rho = (np.pi * self.rad) / (irr_resampled * np.cos(self.sza))
[docs] def mask(self): """Compute all individual masks and combine into ``self.qmask``. Calls :meth:`water_mask`, :meth:`cloud_mask`, and :meth:`cirrus_mask` in sequence, then concatenates the results along a new ``quality_flag`` dimension. An additional ``'invalid'`` flag is appended that is ``True`` wherever *any* of the three masks is ``True``. After calling this method the combined mask is available as ``self.qmask`` with shape ``(quality_flag, y, x)`` and ``quality_flag`` labels ``['water', 'cloud', 'cirrus', 'invalid']``. """ LOG.info('Generating quality masks using TOA ...') # Load all required bands in ONE dask compute instead of 5 separate ones bands_needed = [450, 1000, 1250, 1380, 1650] rho_subset = self.rho.sel(bands=bands_needed, method='nearest').load() self.water_mask(rho_subset) self.cloud_mask(rho_subset) self.cirrus_mask(rho_subset) # Drop any scalar/spectral coordinates inherited from .sel(bands=...) # (e.g. 'bands', 'fwhm', 'wavelength') so that xr.concat finds a # consistent set of coordinates across all three masks. spatial_masks = [ m.drop_vars([c for c in m.coords if c not in m.dims]) for m in (self.water, self.cloud, self.cirrus) ] # Stack the three boolean masks along a new 'quality_flag' dimension qmask = xr.concat( spatial_masks, dim=xr.DataArray( ['water', 'cloud', 'cirrus'], dims='quality_flag', name='quality_flag', ), ) # Derive a single 'invalid' flag: True if any individual flag is True any_mask = qmask.any(dim='quality_flag') any_mask = any_mask.expand_dims(quality_flag=['invalid']) # Append 'invalid' to produce the final 4-flag mask qmask = xr.concat([qmask, any_mask], dim='quality_flag').astype(float) self.qmask = qmask.rename('quality_mask')
[docs] def water_mask(self, rho=None): """Identify water pixels using TOA reflectance at 1000 nm. A pixel is flagged as water when its near-infrared reflectance falls below 0.05, exploiting the strong absorption of liquid water beyond 900 nm. Sets ``self.water`` to a boolean :class:`~xarray.DataArray` of shape ``(y, x)``. """ rho = rho if rho is not None else self.rho.load() rho_1000 = rho.sel(bands=1000, method='nearest') self.water = rho_1000 < 0.05
[docs] def cloud_mask(self, rho=None): """Identify cloud pixels using multi-band TOA reflectance thresholds. Three independent reflectance tests are applied: * 450 nm > 0.28 (high visible reflectance) * 1250 nm > 0.46 (high short-wave infrared reflectance) * 1650 nm > 0.22 (high short-wave infrared reflectance) A pixel is flagged as cloudy only when **all three** conditions are satisfied (majority vote ≥ 3 out of 3), reducing false positives over bright land surfaces. Sets ``self.cloud`` to a boolean :class:`~xarray.DataArray` of shape ``(y, x)``. References ---------- Sandford et al., *AMT*, 13, 7047–7057, 2020. https://doi.org/10.5194/amt-13-7047-2020 """ rho = rho if rho is not None else self.rho.load() rho_450 = rho.sel(bands=450, method='nearest') rho_1250 = rho.sel(bands=1250, method='nearest') rho_1650 = rho.sel(bands=1650, method='nearest') # Cast each threshold test to int so they can be summed self.cloud = ( (rho_450 > 0.28).astype(int) + (rho_1250 > 0.46).astype(int) + (rho_1650 > 0.22).astype(int) ) >= 3
[docs] def cirrus_mask(self, rho=None): """Identify cirrus cloud pixels using TOA reflectance at 1380 nm. The 1380 nm water-vapour absorption band is used as a cirrus proxy: surface-leaving radiance is almost entirely absorbed by atmospheric water vapour at this wavelength, so any residual reflectance above the threshold is attributed to high-altitude cirrus ice clouds. Sets ``self.cirrus`` to a boolean :class:`~xarray.DataArray` of shape ``(y, x)``. References ---------- Gao & Goetz, *GRL*, 20(4), 301–304, 1993. https://doi.org/10.1029/93GL00106 """ rho = rho if rho is not None else self.rho.load() rho_1380 = rho.sel(bands=1380, method='nearest') self.cirrus = rho_1380 > 0.1