Source code for plotpy.widgets.colormap.widget
# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)
"""
Colormap widget
---------------
This module provides a basic widget to edit a colormap that contains a multi-slider
and a colorap representation.
Reference
~~~~~~~~~
.. autoclass:: EditableColormap
:members:
.. autoclass:: ColorMapWidget
:members:
"""
from __future__ import annotations
from typing import Sequence, Tuple, TypeVar, Union
import numpy as np
import qtpy.QtCore as QC
import qtpy.QtGui as QG
import qtpy.QtWidgets as QW
from guidata.qthelpers import add_actions, create_action
from qwt import QwtInterval, QwtLinearColorMap, toQImage
from qwt.color_map import ColorStop
from plotpy.config import _
from plotpy.widgets.colormap._slider import QColorMapSlider
#: Type variable for color types
ColorTypeT = TypeVar("ColorTypeT", bound=Union[QG.QColor, int])
#: Type for color initialization types
QColorInitTypes = Union[
QG.QColor, int, Tuple[int, int, int, int], str, QC.Qt.GlobalColor, None
]
[docs]
class EditableColormap(QwtLinearColorMap):
"""Overload of the QwtLinearColorMap class to add some features. This class is
temporary and should be removed when its features are added to QwtPython.
Args:
*args: QwtLinearColorMap arguments
name: Optional str name given to the colormap. Useful for the interactions
with the rest of PlotPy, notably with the gobal colormaps dictionaries
such as colormaps.ALL_COLORMAPS. If None, the colormap name will be set to
"temporary". Defaults to None.
"""
def __init__(self, *args, name: str | None = None) -> None:
super().__init__(*args)
self.name = name or "temporary"
self.invert = False
def __deepcopy__(self, memo: dict[int, object]) -> EditableColormap:
"""Deepcopy method to copy the colormap object.
Args:
memo: dict to store the copied objects
Returns:
New EditableColormap instance.
"""
new_instance = EditableColormap(self.color1(), self.color2(), name=self.name)
for stop in self.colorStops():
stop: ColorStop
new_instance.addColorStop(stop.pos, stop.rgb)
return new_instance
[docs]
def rgb(self, interval: QwtInterval, value: float) -> int:
"""Returns the color of the colormap at the given value.
This overriden method is used to add the possibility to invert the colormap
colors.
Args:
interval: QwtInterval of the colormap
value: value to get the color from
Returns:
Color of the colormap at the given value.
"""
if self.invert:
return super().rgb(
interval, interval.maxValue() - value + interval.minValue()
)
return super().rgb(interval, value)
@property
def color_stop_values(self) -> list[float]:
"""Returns the position values of the color stops.
Returns:
List of color stop positions.
"""
return [stop.pos for stop in self.colorStops()]
[docs]
def is_boundary_stop_index(self, stop_index: int) -> bool:
"""Checks if the given index is a boundary index (first or last).
Args:
stop_index: stop color index to check
Returns:
True if the index is a boundary index, False otherwise.
"""
return not (
self._can_update_next_stop(stop_index)
and self._can_update_previous_stop(stop_index)
)
def _can_update_next_stop(self, stop_index: int) -> bool:
"""Checks if the next color stop can be updated.
Args:
stop_index: current color stop index
Returns:
True if the next color stop can be updated, False otherwise.
"""
return stop_index < (len(self.colorStops()) - 1)
def _can_update_previous_stop(self, stop_index: int) -> bool:
"""Checks if the previous color stop can be updated.
Args:
stop_index: current color stop index
Returns:
True if the previous color stop can be updated, False otherwise.
"""
return stop_index > 0
[docs]
def update_stops_steps(self, stop_index: int) -> None:
"""Updates the steps of the previous and next color stops of the given index.
The steps are the distance between the current stop and the next color stop.
This method can update the given color stop index, the previous one and the next
stop if necessary.
Args:
stop_index: current color stop index to update.
"""
current_stop = self.colorStops()[stop_index]
if self._can_update_previous_stop(stop_index):
prev_stop: ColorStop = self.colorStops()[stop_index - 1]
prev_stop.updateSteps(current_stop)
if self._can_update_next_stop(stop_index):
next_stop: ColorStop = self.colorStops()[stop_index + 1]
current_stop.updateSteps(next_stop)
[docs]
@classmethod
def from_iterable(
cls, iterable: Sequence[tuple[float, str]], name=None
) -> "EditableColormap":
"""Converts the given iterable of tuples to a colormap (instance of Self).
The iterable must be at least of length 2. If the iterable is of length 1,
the colormap will be composed of two identical colors. If the iterable is
empty, the colormap will be composed of two default colors (blue and yellow).
Args:
iterable: Iterable of tuples composed of a float position and a hex color
string.
name: Name of the new colormap instance. Defaults to None.
Returns:
New CustomQwtLinearColormap instance.
"""
if len(iterable) == 1:
iterable = (iterable[0],) * 2
elif len(iterable) == 0:
iterable = (
(0.0, QG.QColor(QC.Qt.GlobalColor.blue)),
(1.0, QG.QColor(QC.Qt.GlobalColor.yellow)),
)
color_min, color_max = iterable[0][1], iterable[-1][1]
colormap = cls(color_min, color_max, name=name)
pos_min, pos_max = iterable[0][0], iterable[-1][0]
for position, color in iterable[1:-1]:
scaled_position = (position - pos_min) / pos_max
colormap.addColorStop(scaled_position, color)
return colormap
[docs]
def to_tuples(self) -> tuple[tuple[float, str], ...]:
"""Converts a colormap to a tuple of tuples composed of a float position and a
hex color string.
Returns:
Tuple of tuples composed of a float position and a hex color string.
"""
return tuple(
(stop.pos, QG.QColor(stop.rgb).name()) for stop in self.colorStops()
)
[docs]
def move_color_stop(
self, stop_index: int, new_pos: float, new_color: QG.QColor | None = None
) -> None:
"""Moves a color stop to a new position and updates the steps of the previous
and next color stops if necessary. Mutates the colormap object!
Args:
stop_index: color stop index to move
new_pos: new color stop position
new_color: new color stop color. If None, will use the current stop color.
Defaults to None.
"""
try:
stop: ColorStop | None = self.colorStops()[stop_index]
except IndexError as e:
print(e)
return
if stop is None:
print("No Colorstops set, aborting edition.")
return
new_color = new_color or QG.QColor(stop.rgb)
new_stop = ColorStop(new_pos, new_color)
self.colorStops()[stop_index] = new_stop
self.update_stops_steps(stop_index)
[docs]
def delete_stop(self, stop_index: int) -> None:
"""Deletes the color stop at given index and updates the previous color stop
steps if necessary. Mutates the colormap object!
Args:
stop_index: color stop index to delete
"""
if not self.is_boundary_stop_index(stop_index):
cstops: list[ColorStop] = self.colorStops()
cstops.pop(stop_index)
cstops[stop_index - 1].updateSteps(cstops[stop_index])
[docs]
def get_stop_color(self, index: int) -> QG.QColor:
"""Returns the color of the given color stop index.
Args:
index: color stop index
Returns:
Color of the given color stop index.
"""
index = min(index, len(self.colorStops()))
cstop: ColorStop = self.colorStops()[index]
return QG.QColor(cstop.rgb)
[docs]
class ColorMapWidget(QW.QWidget):
"""Simple colormap widget containing a horizontal slider and a colorbar image.
The slider is used to edit the colormap that changes in real time. It is
possible to add/remove handles by right-clicking on the colorbar image. This
widget does not provide a way to change a handle color. For this, use the
ColorMapEditor widget or the ColorMapManager widget.
Args:
parent: parent widget
cmap_width: minimum width of the widget. Defaults to 400.
cmap_height: minimum height of the colorbar. Defaults to 50.
color1: first color. Ignored if the 'colormap' argument is used. If None,
default color is blue. Defaults to None.
color2: second color. Ignored if the 'colormap' argument is used. If None,
default color is yellow. Defaults to None.
colormap: colormap instance. If None, color1 and color2 will be used
to create a new colormap. Defaults to None.
"""
COLORMAP_CHANGED = QC.Signal() # type: ignore
HANDLE_SELECTED = QC.Signal(int) # type: ignore
HANDLE_ADDED = QC.Signal(int, float) # type: ignore
HANDLE_DELETED = QC.Signal(int) # type: ignore
def __init__(
self,
parent: QW.QWidget | None,
cmap_width: int = 400,
cmap_height: int = 50,
color1: QG.QColor | None = None,
color2: QG.QColor | None = None,
colormap: EditableColormap | None = None,
) -> None:
super().__init__(parent)
self.cmap_width = cmap_width
self.cmap_height = cmap_height
self.min, self.max = 0.0, 1.0
self._tolerance = 0.05
self._px_cursor_offset = 0 # This is a patch because the cursor position is
# not exactly the handle position...
self._handle_selected = None
self.slider_menu = self.setup_menu()
multi_range_hslider = QColorMapSlider(QC.Qt.Orientation.Horizontal)
multi_range_hslider.setTickPosition(QW.QSlider.TickPosition.TicksAbove)
multi_range_hslider.setBarVisible(False)
multi_range_hslider.setMinimum(self.min)
multi_range_hslider.setMaximum(self.max)
multi_range_hslider.setMaximumHeight(20)
self.multi_range_hslider = multi_range_hslider
self.set_handles_values((self.min, self.max))
self.setContextMenuPolicy(QC.Qt.ContextMenuPolicy.CustomContextMenu)
self._last_selection: tuple[int, int] = 0, 0
self.qwt_color_interval = QwtInterval(self.min, self.max)
if colormap is None:
color1 = QG.QColor(QC.Qt.GlobalColor.blue) if color1 is None else color1
color2 = QG.QColor(QC.Qt.GlobalColor.yellow) if color2 is None else color2
self._colormap = EditableColormap(color1, color2)
else:
self._colormap = colormap
self.set_handles_values(colormap.color_stop_values)
self.colortable = self._colormap.colorTable(self.qwt_color_interval)
colormap_label = QW.QLabel("Stylish colormap widget")
colormap_label.setScaledContents(True)
self._colormap_label = colormap_label
self.draw_colormap_image()
layout = QW.QVBoxLayout(self)
layout.addWidget(colormap_label)
layout.addWidget(multi_range_hslider)
self.range_layout = layout
self.setLayout(layout)
self.setToolTip(_("Right click to add/remove a color"))
self.COLORMAP_CHANGED.connect(self.draw_colormap_image)
self.multi_range_hslider.valueChanged.connect(
self._edit_color_map_on_slider_change
)
self.multi_range_hslider.sliderPressed.connect(self.emit_handle_selected)
self.multi_range_hslider.sliderReleased.connect(self.release_handle)
self.customContextMenuRequested.connect(self.open_slider_menu)
[docs]
def set_colormap(self, colormap: EditableColormap) -> None:
"""Replaces the current colormap.
Args:
colormap: replacement colormap
"""
self._colormap = colormap
self.set_handles_values(colormap.color_stop_values)
self.COLORMAP_CHANGED.emit()
[docs]
def get_colormap(self) -> EditableColormap:
"""Get the current colormap being edited.
Returns:
current colormap
"""
return self._colormap
[docs]
def update_color_table(self) -> None:
"""Updates and caches the current color table."""
self.colortable[:] = self._colormap.colorTable(self.qwt_color_interval)
[docs]
def emit_handle_selected(self) -> None:
"""When called, computes a slider position from the current position of the
mouse and maps it to slider range. Then finds which handles was selected and
emits the custom signal HANDLE_SELECTED(handle_index) and caches the index."""
self._handle_selected = self.multi_range_hslider.pressed_index
self.HANDLE_SELECTED.emit(self._handle_selected)
[docs]
def release_handle(self) -> None:
"""When called, unset the cached current handle index."""
self._handle_selected = None
[docs]
def draw_colormap_image(self) -> None:
"""Recomputes and redraws the colorbar image."""
# TODO: check how to update the image without having to recreate one,
# just by updating the colortable
self.update_color_table()
self._colormap_label.setPixmap(self.cmap_to_qimage())
[docs]
def get_color_from_value(
self, value: float, as_type: type[ColorTypeT] = QG.QColor
) -> ColorTypeT:
"""Returns the color assigned to a given value using the current colormap.
Args:
value: value to assign a color to
as_type: Color type to be returned (int or QG.QColor). Defaults to
QG.QColor.
Returns:
The assigned color for the input value using the current colormap.
"""
int_color = self._colormap.rgb(self.qwt_color_interval, value)
return as_type(int_color)
[docs]
def get_handle_color(
self, handle_index: int, as_type: type[ColorTypeT] = QG.QColor
) -> ColorTypeT:
"""Returns the color of a handled using its index. Directly return the color of
the ColorStop object of the given index.
Args:
handle_index: index of the handled to assign a color too.
as_type: Color type to be returned (int or QG.QColor). Defaults to
QG.QColor.
Returns:
The assigned color for the input handle index using the current colormap.
"""
int_color = self._colormap.get_stop_color(handle_index)
return as_type(int_color)
[docs]
def get_hex_color(self, handle_index: int) -> str:
"""Same as self.get_handle_color but returns the color as a hex color string.
Args:
handle_index: index of the handled to assign a color too.
Returns:
Handle's hex color string
"""
return self.get_handle_color(handle_index, QG.QColor).name()
[docs]
def edit_color_stop(
self,
index: int,
new_pos: float | None = None,
new_color: QG.QColor | int | None = None,
) -> None:
"""Edit an existing color stop in the current colormap. Mutates the colormap
object. Also edits the slider handle position.
Args:
index: color stop index to mutate
new_pos: new color stop position. If not set will remain the same.
Defaults to None.
new_color: new color. If not set will remain the same. Defaults to None.
"""
if new_pos is None:
new_pos = self.get_handles_tuple()[index]
if isinstance(new_color, int):
new_color = QG.QColor(self.colortable[new_color])
self._colormap.move_color_stop(index, new_pos, new_color)
self.set_handles_values(self._colormap.color_stop_values)
self.COLORMAP_CHANGED.emit()
def _edit_color_map_on_slider_change(self, raw_values: tuple[float, ...]) -> None:
"""Modifies the colormap depending on the currently selected handle. Tiggered on
hanle movement.
Args:
raw_values: tuple of handles position
"""
handle_index = self._handle_selected
values = self.multi_range_hslider.filter_first_last_positions(raw_values)
if handle_index is not None:
handle_pos = values[handle_index]
if handle_index == 0 or handle_index == len(values):
return
self._colormap.move_color_stop(handle_index, handle_pos)
self.COLORMAP_CHANGED.emit()
def _get_closest_handle_index(self, pos: float) -> tuple[int, float]:
"""Return the index of the closest handle from the given position
Args:
pos: position along the slider axis and coordinate space
Returns:
index and distance of the closest point (distance can be negative)
"""
values = self.get_handles_tuple()
closest_handle = min(range(len(values)), key=lambda i: abs(values[i] - pos))
return closest_handle, abs(pos - values[closest_handle])
def _get_neighbour_positions(self, pos: float) -> tuple[float, float]:
"""Return the positions of the previous and next handles from the given
position. If no handle is found, the method can return self.min and/or self.max
Args:
pos: position from which to look for the previous and next handles
Returns:
previous and next handle positions
"""
values = self.get_handles_tuple()
previous_pos = max(
filter(lambda val: val <= pos, values[::-1]), default=self.min
)
next_pos = min(filter(lambda val: val > pos, values), default=self.max)
return previous_pos, next_pos
def _new_available_pos(self, pos: float) -> float:
"""Computes the most position to insert a new handle. This method takes into
account the input position, the prefered offset if handles are too close,
previous and next handles from the current position to output the best position
possible.
Args:
pos: requested position
Returns:
Best position for insertion according to parameters cited above.
"""
previous_pos, next_pos = self._get_neighbour_positions(pos)
if (neighbour_diff := next_pos - previous_pos) < self._tolerance * 2:
return previous_pos + (neighbour_diff / 2)
if (pos - previous_pos) < self._tolerance:
return previous_pos + self._tolerance
if (next_pos - pos) < self._tolerance:
return next_pos - self._tolerance
return pos
def _add_handle_at_click(self) -> None:
"""Adds a new handle at last right-clicked position thanks to the attribute
self._last_selection. Mutates the colormap object.
"""
x, _ = self._last_selection
new_handle_value = self.multi_range_hslider.widget_pos_to_value(x)
values = self.get_handles_list()
new_handle_value = self._new_available_pos(new_handle_value)
new_color = self.get_color_from_value(new_handle_value)
self._colormap.addColorStop(new_handle_value, new_color)
values.append(new_handle_value)
values.sort()
self.set_handles_values(values)
self.HANDLE_ADDED.emit(values.index(new_handle_value), new_handle_value)
def _delete_handle_at_click(self) -> None:
"""Tries to delete an existing handle at the right-clicked position thanks to
the attribute self._last_selection. Does nothing if the click is too far from
a handle. Mutates the colormap object.
"""
x, _ = self._last_selection
values: list[float] = self.get_handles_list()
x_handle_coor_space = self.multi_range_hslider.widget_pos_to_value(x)
closest_handle_index, click_distance = self._get_closest_handle_index(
x_handle_coor_space
)
if (
abs(click_distance) > self._tolerance
or len(values) <= 2
or closest_handle_index in (0, len(values) - 1)
):
return
values.pop(closest_handle_index)
self.set_handles_values(tuple(values))
self._colormap.delete_stop(closest_handle_index)
self.HANDLE_DELETED.emit(closest_handle_index)
self.COLORMAP_CHANGED.emit()
[docs]
def add_handle_at_relative_pos(
self, relative_pos: float, new_color: QG.QColor | int | None = None
) -> None:
"""insert a handle in the widget at the relative position (between 0. and 1.).
Mutates the colormap object. If the relative position is already occupied by a
handle, the new handle will be inserted at the closest available position then
will be moved back to the requested position.
Args:
relative_pos: insertion position
"""
if new_color is None:
new_color = self._colormap.color(self.qwt_color_interval, relative_pos)
new_relative_pos = self._new_available_pos(relative_pos)
self._colormap.addColorStop(new_relative_pos, new_color)
values = self.get_handles_list()
values.append(new_relative_pos)
values.sort()
new_value_index = values.index(new_relative_pos)
self.edit_color_stop(new_value_index, relative_pos, None)
self.HANDLE_ADDED.emit(new_value_index, relative_pos)
[docs]
def get_handles_count(self) -> int:
"""Number of slider handles.
Returns:
handles count
"""
return len(self.multi_range_hslider.value())
[docs]
def get_handles_list(self) -> list[float]:
"""Return the current handles as a mutable list. If the data doesn't need to be
mutated, prefer the method self.get_handles_tuple() that directly returns the
handles tuple without making a conversion to a list.
Returns:
Mutable list of handles position
"""
return list(self.multi_range_hslider.value())
[docs]
def get_handles_tuple(self) -> tuple[float, ...]:
"""Passthrough to get the tuple of handles position.
Returns:
Immutable tuple of handles position
"""
return self.multi_range_hslider.value()
[docs]
def set_handles_values(self, values: Sequence[float]) -> None:
"""Passthrough to set handles position.
Args:
values: sequence of values with a minimum length of 2 (min and max).
"""
self.multi_range_hslider.setValue(values)
[docs]
def cmap_to_qimage(self) -> QG.QPixmap:
"""Build the horizontal colorab pixmap from the current colormap."""
data = np.zeros((self.cmap_height, self.cmap_width), np.uint8)
line = np.linspace(0, 255, self.cmap_width)
data[:, :] = line[np.newaxis, :]
img = toQImage(data)
img.setColorTable(self.colortable)
return QG.QPixmap.fromImage(img)