Source code for moniplot.lib.fig_draw_image

#
# https://github.com/rmvanhees/moniplot.git
#
# Copyright (c) 2022-2023 SRON - Netherlands Institute for Space Research
#
# License:  GPLv3
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
This module holds `fig_data_to_xarr` and `fig_qdata_to_xarr`.

These functions are used by resp. `draw_signal` and `draw_quality`.
"""
from __future__ import annotations

__all__ = ['adjust_img_ticks', 'fig_data_to_xarr',
           'fig_draw_panels', 'fig_qdata_to_xarr']

import warnings
from math import log10
from typing import TYPE_CHECKING

import matplotlib.colors as mcolors
import numpy as np
import xarray as xr
from matplotlib.ticker import AutoMinorLocator

from moniplot.image_to_xarray import data_to_xr
from moniplot.tol_colors import tol_cmap, tol_cset

if TYPE_CHECKING:
    from matplotlib import Axes

# - global parameters ------------------------------
CSET = tol_cset('bright')


# - local functions --------------------------------
def adjust_zunit(zunits: str,
                 vmin: float, vmax: float) -> tuple[int, str | None]:
    """Adjust data range and its units.

    Units which are renamed: electron to `e` and Volt to `V`
    Scale data-range between -1000 and 1000.

    Parameters
    ----------
    zunits :  str
       Units of the image data
    vmin, vmax : float
        image-data range

    Returns
    -------
    tuple
        dscale, zunits
    """
    if zunits is None or zunits == '1':
        return 1, zunits

    zunits = zunits.replace('electron', 'e')
    zunits = zunits.replace('Volt', 'V')
    zunits = zunits.replace('-1', '$^{-1}$')
    zunits = zunits.replace('-2', '$^{-2}$')
    zunits = zunits.replace('-3', '$^{-3}$')
    zunits = zunits.replace('um', '\u03bcm')
    # 'thin space', alternative is 'mid space': '\u2005'
    zunits = zunits.replace('.', '\u2009')
    zunits = zunits.replace(' ', '\u2009')

    if zunits[0] in ('e', 'V', 'A'):
        key_to_zunit = {-4: 'p', -3: 'n', -2: '\u03bc', -1: 'm',
                        0: '', 1: 'k', 2: 'M', 3: 'G', 4: 'T'}
        max_value = max(abs(vmin), abs(vmax))
        key = min(4, max(-4, int(log10(max_value)) // 3))

        return 1000 ** key, key_to_zunit[key] + zunits

    return 1, zunits


def set_norm(zscale: str, vmin: float, vmax: float) -> mcolors:
    """Set data-range normalization.

    Parameters
    ----------
    zscale : str
        Scaling of the data values. Recognized values are: 'linear', 'log',
        'diff' or 'ratio'.
    vmin : float
        Minimum of the data range
    vmax : float
        Minimum of the data range

    Returns
    -------
    matplotlib.colors.mcolors
    """
    if zscale == 'log':
        return mcolors.LogNorm(vmin=max(vmin, 1e-6), vmax=vmax)

    if vmin == vmax:
        scale = max(1, abs(round((vmin + vmax) / 2)))
        vmin -= 1e-3 * scale
        vmax += 1e-3 * scale

    if zscale == 'diff':
        if vmin < 0 < vmax:
            vcntr = 0.
            tmp1, tmp2 = (vmin, vmax)
            vmin = -max(-tmp1, tmp2)
            vmax = max(-tmp1, tmp2)
        else:
            vcntr = (vmin + vmax) / 2
        return mcolors.TwoSlopeNorm(vcntr, vmin=vmin, vmax=vmax)

    if zscale == 'ratio':
        if vmin < 1 < vmax:
            vcntr = 1.
            tmp1, tmp2 = (vmin, vmax)
            vmin = min(tmp1, 1 / tmp2)
            vmax = max(1 / tmp1, tmp2)
        else:
            vcntr = (vmin + vmax) / 2
        return mcolors.TwoSlopeNorm(vcntr, vmin=vmin, vmax=vmax)

    return mcolors.Normalize(vmin=vmin, vmax=vmax)


[docs] def adjust_img_ticks(axx: Axes, xarr: xr.DataArray, dims: str | None = None) -> None: """Adjust ticks of the image axis. Parameters ---------- axx : matplotlib.Axes Matplotlib Axes object for the central image panel xarr : xarray.DataArray Object holding measurement data and attributes. dims : str, optional Name of the plot dimension (`X` or `Y`). """ if dims is None or dims == 'X': if (xarr.shape[1] % 10) == 0: axx.set_xticks(np.linspace(0, xarr.shape[1], 6, dtype=int)) elif (xarr.shape[1] % 8) == 0: axx.set_xticks(np.linspace(0, xarr.shape[1], 5, dtype=int)) axx.xaxis.set_minor_locator(AutoMinorLocator()) if dims is None or dims == 'Y': if (xarr.shape[0] % 10) == 0: axx.set_yticks(np.linspace(0, xarr.shape[0], 6, dtype=int)) elif (xarr.shape[0] % 8) == 0: axx.set_yticks(np.linspace(0, xarr.shape[0], 5, dtype=int)) axx.yaxis.set_minor_locator(AutoMinorLocator())
[docs] def fig_draw_panels(axx_p: dict, xarr: xr.DataArray, side_panels: str) -> None: """Draw two side-panels, one left and one under the main image panel. Parameters ---------- axx_p : dict dictionary holding matplotlib Axes for panel `X` and `Y` xarr : xarray.DataArray Object holding measurement data and attributes side_panels : str Show row and column statistics in side plots. """ # get numpy function to apply on image rows and columns for side panels func_panels = { 'median': np.median, 'nanmedian': np.nanmedian, 'mean': np.mean, 'nanmean': np.nanmean, 'quality': 'quality', 'std': np.std, 'nanstd': np.nanstd}.get(side_panels) if func_panels is None: raise KeyError(f'unknown function for side_panels: {side_panels}') # draw panel below the image panel xdata = np.arange(xarr.shape[1]) if side_panels == 'quality': ydata = np.sum(((xarr.values == 1) | (xarr.values == 2)), axis=0) axx_p['X'].step(xdata, ydata, linewidth=0.75, color=CSET.yellow) ydata = np.sum((xarr.values == 1), axis=0) # worst axx_p['X'].step(xdata, ydata, linewidth=0.75, color=CSET.red) if len(xarr.attrs['flag_values']) == 6: ydata = np.sum((xarr.values == 4), axis=0) # to_good axx_p['X'].step(xdata, ydata, linewidth=0.75, color=CSET.green) else: with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered') axx_p['X'].plot(xdata, func_panels(xarr.values, axis=0), linewidth=0.75, color=CSET.blue) adjust_img_ticks(axx_p['X'], xarr, dims='X') axx_p['X'].grid() # draw panel left of the image panel ydata = np.arange(xarr.shape[0]) if side_panels == 'quality': xdata = np.sum(((xarr.values == 1) | (xarr.values == 2)), axis=1) axx_p['Y'].step(xdata, ydata, linewidth=0.75, color=CSET.yellow) xdata = np.sum(xarr.values == 1, axis=1) # worst axx_p['Y'].step(xdata, ydata, linewidth=0.75, color=CSET.red) if len(xarr.attrs['flag_values']) == 6: xdata = np.sum(xarr.values == 4, axis=1) # to_good axx_p['Y'].step(xdata, ydata, linewidth=0.75, color=CSET.green) else: with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered') axx_p['Y'].plot(func_panels(xarr.values, axis=1), ydata, linewidth=0.75, color=CSET.blue) # axx_p['Y'].xaxis.tick_top() axx_p['X'].tick_params(axis='y', labelrotation=45, labelsize='small') axx_p['Y'].tick_params(axis='x', bottom=False, top=True, labelbottom=False, labeltop=True, labelrotation=-45, labelsize='small') adjust_img_ticks(axx_p['Y'], xarr, dims='Y') axx_p['Y'].grid()
# - main functions ---------------------------------
[docs] def fig_data_to_xarr(data: np.ndarray, zscale: str = None, vperc: list[int, int] | None = None, vrange: list[float, float] | None = None) -> xr.DataArray: """Prepare image data for plotting. Parameters ---------- data : array-like or xarray.DataArray zscale : str, default='linear' Scaling of the data values. Recognized values are: 'linear', 'log', 'diff' or 'ratio'. vperc : list, default=[1, 99] Range to normalize luminance data between percentiles min and max of array data. vrange : list, default=None Range to normalize luminance data between vmin and vmax. Returns ------- xarray.DataArray image data and attributes ready for plotting Notes ----- The input data should have two dimensions. If the input data is array-like, it will be converted into a xarray DataArray using the function data_to_xr. The returned xarray DataArray has addition attributes to facilitate the plotting methods of MONplot with names '_cmap' or start with '_z'. The default values of vperc are used when both vrange and vperc are None. When vrange and vperc are provided, then vrange is used. """ # make sure that we are working with a xarray DataArray xarr = data.copy() if isinstance(data, xr.DataArray) else data_to_xr(data) if zscale is None: zscale = 'linear' if zscale not in ('diff', 'linear', 'log', 'ratio'): raise RuntimeError(f'unknown zscale: {zscale}') xarr.attrs['_zscale'] = zscale # obtain image-data range if vrange is None and vperc is None: vmin, vmax = np.nanpercentile(xarr.values, (1., 99.)) elif vrange is None: if len(vperc) != 2: raise TypeError('keyword vperc requires two values') vmin, vmax = np.nanpercentile(xarr.values, vperc) else: if len(vrange) != 2: raise TypeError('keyword vrange requires two values') vmin, vmax = vrange # set data units and scaling if 'units' in xarr.attrs: dscale, zunits = adjust_zunit(xarr.attrs['units'], vmin, vmax) xarr.values[np.isfinite(xarr.values)] /= dscale xarr.attrs['_zunits'] = zunits else: dscale = 1. xarr.attrs['_zunits'] = '1' # set data label if zscale == 'ratio' or xarr.attrs['_zunits'] == '1': xarr.attrs['_zlabel'] = { 'ratio': 'ratio', 'diff': 'difference'}.get(zscale, 'value') elif zscale == 'diff': xarr.attrs['_zlabel'] = f'difference [{xarr.attrs["_zunits"]}]' else: # zscale in ('linear', 'log') xarr.attrs['_zlabel'] = f'value [{xarr.attrs["_zunits"]}]' # set matplotlib colormap xarr.attrs['_cmap'] = {'linear': tol_cmap('rainbow_PuRd'), 'log': tol_cmap('rainbow_WhBr'), 'diff': tol_cmap('sunset'), 'ratio': tol_cmap('sunset')}.get(zscale) # set matplotlib data normalization xarr.attrs['_znorm'] = set_norm(zscale, vmin / dscale, vmax / dscale) return xarr
# pylint: disable=too-many-arguments
[docs] def fig_qdata_to_xarr(data: np.ndarray, ref_data: np.ndarray | None = None, data_sel: slice | None = None, thres_worst: float = 0.1, thres_bad: float = 0.8, qlabels: tuple[str] = None) -> xr.DataArray: r"""Prepare pixel-quality data for plotting. Parameters ---------- data : array-like or xarray.DataArray Object holding detector pixel-quality data and attributes. ref_data : numpy.ndarray, default=None Numpy array holding reference data, for example pixel quality reference map taken from the CKD. Shown are the changes with respect to the reference data. data_sel : slice, optional Select a region on the detector by fancy indexing (using a boolean/integer arrays), or using index tuples for arrays (generated with `numpy.s\_`). Outside this region the pixels will be labeled: 'unusable'. thres_worst : float, default=0.1 Threshold to reject only the worst of the bad pixels, intended for CKD derivation. thres_bad : float, default=0.8 Threshold for bad pixels. qlabels : tuple of strings, optional Labels for the pixel-quality classes, see below Returns ------- xarray.DataArray image data and attributes ready for plotting Notes ----- Without a reference dataset, the default quality ranking labels are:: 'unusable' : pixels outside the illuminated region 'worst' : 0 <= value < thres_worst 'bad' : 0 <= value < thres_bad 'good' : thres_bad <= value <= 1 Otherwise, the default quality ranking labels are:: 'unusable' : pixels outside the illuminated region 'to worst' : from good or bad to worst 'good to bad' : from good to bad 'to good' : from any rank to good 'unchanged' : no change in rank """ if data_sel is None: exclude_region = None else: if isinstance(data_sel, np.ndarray): # pylint: disable=invalid-unary-operand-type exclude_region = ~data_sel else: exclude_region = np.full(data.shape, True) exclude_region[data_sel] = False def float_to_quality(arr: np.ndarray) -> np.ndarray: """Convert float value [0, 1] to quality classes.""" res = np.empty(arr.shape, dtype='i1') buff = arr.values if isinstance(arr, xr.DataArray) else arr res[buff >= thres_bad] = 4 res[(buff > thres_worst) & (buff < thres_bad)] = 2 res[buff <= thres_worst] = 1 if exclude_region is not None: res[exclude_region] = 0 return res qval = float_to_quality(data) if ref_data is not None: qdiff = float_to_quality(ref_data) - qval qval = np.full_like(qdiff, 8) qval[(qdiff == -2) | (qdiff == -3)] = 4 qval[qdiff == 2] = 2 qval[(qdiff == 1) | (qdiff == 3)] = 1 if exclude_region is not None: qval[exclude_region] = 0 # make sure that we are working with a xarray DataArray xarr = data_to_xr(qval) xarr.attrs['long_name'] = 'Pixel Quality' xarr.attrs['thres_bad'] = thres_bad xarr.attrs['thres_worst'] = thres_worst xarr.attrs['_zscale'] = 'quality' # define colors, data-range if ref_data is None: if qlabels is None: xarr.attrs['flag_meanings'] = ('unusable', 'worst', 'bad', 'good') elif len(qlabels) != 4: raise TypeError('keyword qlabels requires four labels') else: xarr.attrs['flag_meanings'] = qlabels # define colors for resp. unusable, worst, bad and good ctuple = (CSET.grey, CSET.red, CSET.yellow, '#FFFFFF') xarr.attrs['valid_range'] = np.array([0, 8], dtype='i1') xarr.attrs['flag_values'] = np.array([0, 1, 2, 4, 8], dtype='i1') else: if qlabels is None: xarr.attrs['flag_meanings'] = ('unusable', 'to worst', 'good to bad ', 'to good', 'unchanged') elif len(qlabels) != 5: raise TypeError('keyword qlabels requires five labels') else: xarr.attrs['flag_meanings'] = qlabels # define colors for resp. unusable, worst, bad, good and unchanged ctuple = (CSET.grey, CSET.red, CSET.yellow, CSET.green, '#FFFFFF') xarr.attrs['valid_range'] = np.array([0, 16], dtype='i1') xarr.attrs['flag_values'] = np.array([0, 1, 2, 4, 8, 16], dtype='i1') xarr.attrs['_cmap'] = mcolors.ListedColormap(ctuple) xarr.attrs['_znorm'] = mcolors.BoundaryNorm(xarr.attrs['flag_values'], xarr.attrs['_cmap'].N) return xarr