Source code for plotpy.mathutils.colormap

# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)

"""
Colormap functions
------------------

Overview
^^^^^^^^

The :py:mod:`plotpy.mathutils.colormap` module contains definition of common colormaps
and tools to manipulate and create them.

The following functions are available:

* :py:func:`.get_cmap`: get a colormap from its name
* :py:func:`.cmap_exists`: check if a colormap exists
* :py:func:`.add_cmap`: add a colormap to the list of available colormaps
* :py:func:`.build_icon_from_cmap`: build an icon representing the colormap
* :py:func:`.build_icon_from_cmap_name`: build an icon representing the colormap
  from its name

Reference
^^^^^^^^^

.. autofunction:: get_cmap
.. autofunction:: cmap_exists
.. autofunction:: add_cmap
.. autofunction:: build_icon_from_cmap
.. autofunction:: build_icon_from_cmap_name
"""

from __future__ import annotations

import json
import os
from typing import Dict, Literal, Sequence

import numpy as np
import qtpy.QtCore as QC
import qtpy.QtGui as QG
from guidata.configtools import get_module_data_path
from qwt import QwtInterval, toQImage

from plotpy.config import CONF
from plotpy.widgets.colormap.widget import EditableColormap

FULLRANGE = QwtInterval(0.0, 1.0)
SMALL_ICON_WIDTH, SMALL_ICON_HEIGHT, SMALL_ICON_ORIENTATION = 16, 7, "h"
LARGE_ICON_WIDTH, LARGE_ICON_HEIGHT, LARGE_ICON_ORIENTATION = 80, 16, "h"

CmapDictType = Dict[str, EditableColormap]


def load_raw_colormaps_from_json(
    json_path: str,
) -> dict[str, Sequence[tuple[float, str]]]:
    """Tries to load raw colormaps from a json file. A raw colormap is a list of tuple
    sequence of tuples that contains a position value and a hex color string.

    Args:
        json_path: absolute path to the json to open. If file is not found, returns an
         empty dictionary

    Returns:
        Dictionnary of colormaps names -> raw colormap sequences
    """
    if isinstance(json_path, str) and os.path.isfile(json_path):
        with open(json_path, encoding="utf-8") as f:
            try:
                return json.load(f)
            except json.JSONDecodeError as e:
                print(e)
                return {}
    return {}


def load_qwt_colormaps_from_json(json_path: str) -> CmapDictType:
    """Same as function load_raw_colormaps_from_json but transforms the raw colormaps
    into CustomQwtLinearColormap objects that are used by plotpy.

    Args:
        json_path: absolute path to the json to open. If file is not found, returns an
        empty dictionary

    Returns:
        Dictionnary of colormpas names -> CustomQwtLinearColormap
    """
    return {
        name.lower(): EditableColormap.from_iterable(iterable, name=name)
        for name, iterable in load_raw_colormaps_from_json(json_path).items()
    }


def get_cmap_path(config_path: str):
    """Takes a file path (i.e. from the CONF global variable) and tries to find it in
    this order:
        1. in Plotpy's data directory
        2. in user's plotpy configuration directory
        3. anywhere else using the path as an absolute path
    If the file is not found, the absolute filepath returned will point to user's plotpy
    configuration folder.

    Args:
        config_path: file path/name to check in the order listed above.
    """
    try:
        data_config_path = os.path.join(
            get_module_data_path("plotpy", "data"), config_path
        )
        if os.path.isfile(data_config_path):
            return data_config_path
    except (FileNotFoundError, PermissionError, OSError) as e:
        print(e)

    user_config_path = CONF.get_path(config_path)
    if os.path.isfile(user_config_path):
        return user_config_path

    if os.path.isfile(config_path):
        return config_path

    return user_config_path


# Load default colormaps path from the config file
DEFAULT_COLORMAPS_PATH = get_cmap_path(
    CONF.get(
        "colormaps",
        "colormaps/default",
        default="colormaps_default.json",  # type: ignore
    )
)
# Load custom colormaps path from the config file
CUSTOM_COLORMAPS_PATH = get_cmap_path(
    CONF.get(
        "colormaps",
        "colormaps/custom",
        default="colormaps_custom.json",  # type: ignore
    )
)

# Load default and custom colormaps from json files
DEFAULT_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(DEFAULT_COLORMAPS_PATH)
CUSTOM_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(CUSTOM_COLORMAPS_PATH)

# Merge default and custom colormaps into a single dictionnary to simplify access
ALL_COLORMAPS: CmapDictType = {**DEFAULT_COLORMAPS, **CUSTOM_COLORMAPS}

# Default colormap to use if a colormap is not found
DEFAULT = ALL_COLORMAPS["jet"]


def save_colormaps(json_filename: str, colormaps: CmapDictType):
    """Saves colormaps into the given json file. Refer ton function get_cmap_path to
    know what json_filename can be used.

    Args:
        json_filename: json file name/path in which to save the colormaps
        colormaps: Dictionnary of colormpas names -> CustomQwtLinearColormap
    """
    raw_colormaps = {cmap.name: cmap.to_tuples() for cmap in colormaps.values()}
    json_abs_path = CONF.get_path(json_filename)
    with open(json_abs_path, "w", encoding="utf-8") as f:
        json.dump(raw_colormaps, f, indent=4)


[docs] def build_icon_from_cmap( cmap: EditableColormap, width: int = SMALL_ICON_WIDTH, height: int = SMALL_ICON_HEIGHT, orientation: Literal["h", "v"] = SMALL_ICON_ORIENTATION, margin: int = 0, ) -> QG.QIcon: """Builds an icon representing the colormap Args: cmap: colormap width: icon width height: icon height orientation: orientation of the colormap in the icon. Can be "h" for horizontal or "v" for vertical margin: margin around the colormap in the icon. Beware that the margin is included in the given icon size. For example, if margin is 1 and width is 16, the actual colormap width will be 14 (16 - 2 * 1). This was done to prevent interpolation on display. """ padded_width = width - 2 * margin padded_height = height - 2 * margin data = np.zeros((padded_height, padded_width), np.uint8) if orientation == "h": line = np.linspace(0, 255, padded_width) data[:, :] = line[:] else: line = np.linspace(0, 255, padded_height) data[:, :] = line[:, np.newaxis] img = toQImage(data) img.setColorTable(cmap.colorTable(FULLRANGE)) cm_pxmap = QG.QPixmap.fromImage(img) if margin == 0: return QG.QIcon(cm_pxmap) padded_pixmap = QG.QPixmap(width, height) padded_pixmap.fill(QC.Qt.GlobalColor.transparent) # Create a painter to draw onto the new pixmap painter = QG.QPainter(padded_pixmap) painter.drawPixmap(margin, margin, cm_pxmap) painter.end() icon = QG.QIcon(padded_pixmap) return icon
[docs] def build_icon_from_cmap_name( cmap_name: str, width: int = SMALL_ICON_WIDTH, height: int = SMALL_ICON_HEIGHT, orientation: Literal["h", "v"] = SMALL_ICON_ORIENTATION, margin: int = 0, ) -> QG.QIcon: """Builds an QIcon representing the colormap from the colormap name found in ALL_COLORMAPS global variable. Args: cmap_name: colormap name to search in ALL_COLORMAPS width: icon width height: icon height orientation: orientation of the colormap in the icon. Can be "h" for horizontal or "v" for vertical margin: margin around the colormap in the icon. Beware that the margin is included in the given icon size. For example, if margin is 1 and width is 16, the actual colormap width will be 14 (16 - 2 * 1). This was done to prevent interpolation on display. Returns: QIcon representing the colormap """ return build_icon_from_cmap( get_cmap(cmap_name.lower()), width, height, orientation, margin )
[docs] def get_cmap(cmap_name: str) -> EditableColormap: """Returns the colormap with the given name from the ALL_COLORMAPS global variable. If the colormap is not found, returns the DEFAULT colormap. Args: cmap_name: colormap name to search in ALL_COLORMAPS. All keys in ALL_COLORMAPS are lower case, so the given name is also lowered. Returns: A CustomQwtLinearColormap instance corresponding to the given name, if no colormap is found, returns the DEFAULT colormap. """ global ALL_COLORMAPS return ALL_COLORMAPS.get(cmap_name.lower(), DEFAULT)
[docs] def cmap_exists(cmap_name: str, cmap_dict: CmapDictType | None = None) -> bool: """Returns True if the colormap with the given name exists in the given colormap dictionary, False otherwise. If no dictionary is given, the ALL_COLORMAPS global variable is used. Args: cmap_name: colormap name to search in given colormap dictionnary. All keys in the dictionary are lower case, so the given name is also lowered. cmap_dict: colormap dictionnary to search in. If None, ALL_COLORMAPS is used. Returns: True if the colormap exists, False otherwise. """ if cmap_dict is None: cmap_dict = ALL_COLORMAPS return cmap_name.lower() in cmap_dict
[docs] def add_cmap(cmap: EditableColormap) -> None: """Adds the given colormap to both ALL_COLORMAPS and CUSTOM_COLORMAPS global variables. Args: cmap: colormap to add """ global ALL_COLORMAPS, CUSTOM_COLORMAPS ALL_COLORMAPS[cmap.name.lower()] = cmap CUSTOM_COLORMAPS[cmap.name.lower()] = cmap save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS)
def delete_cmap(cmap: EditableColormap) -> None: """Deletes the given colormap from both ALL_COLORMAPS and CUSTOM_COLORMAPS global""" global ALL_COLORMAPS, CUSTOM_COLORMAPS if CUSTOM_COLORMAPS.pop(cmap.name.lower(), None) is not None: del ALL_COLORMAPS[cmap.name.lower()] save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS)