import typing
import warnings
from functools import partial
import qtawesome as qta
from napari.utils import Colormap
from qtpy.QtCore import QEvent, QMargins, QModelIndex, QPoint, QPointF, QRect, QSize, Qt, Signal
from qtpy.QtGui import QColor, QIcon, QMouseEvent, QPainter, QPaintEvent, QPen, QPolygonF, QShowEvent
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QListView,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QWidget,
)
from superqt import QEnumComboBox
from PartSeg.common_backend.base_settings import ViewSettings
from PartSeg.common_gui.collapse_checkbox import CollapseCheckbox
from PartSeg.common_gui.numpy_qimage import create_colormap_image
from PartSeg.common_gui.qt_util import get_mouse_x
from PartSeg.common_gui.universal_gui_part import CustomSpinBox
from PartSegCore.image_operations import NoiseFilterType
image_dict = {} # dict to store QImages generated from colormap
ColorMapDict = typing.MutableMapping[str, typing.Tuple[Colormap, bool]]
try:
from qtpy import PYQT6
except ImportError: # pragma: no cover
PYQT6 = False
if PYQT6:
def _has_focus_state(state: QStyle.StateFlag) -> bool:
return QStyle.StateFlag.State_HasFocus in state
else:
def _has_focus_state(state: QStyle.StateFlag) -> bool:
return bool(state & QStyle.State(QStyle.StateFlag.State_HasFocus))
[docs]class ColorStyledDelegate(QStyledItemDelegate):
"""
Class for paint :py:class:`~.ColorComboBox` elements when list trigger
:param base_height: height of single list element
:param color_dict: Dict mapping name to colors
"""
def __init__(self, base_height: int, color_dict: ColorMapDict, **kwargs):
super().__init__(**kwargs)
self.base_height = base_height
self.color_dict = color_dict
[docs] def paint(self, painter: QPainter, style: QStyleOptionViewItem, model: QModelIndex):
if model.data() not in image_dict:
image_dict[model.data()] = create_colormap_image(model.data(), self.color_dict)
rect = QRect(style.rect.x(), style.rect.y() + 2, style.rect.width() - 150, style.rect.height() - 4)
painter.drawImage(rect, image_dict[model.data()])
rect2 = QRect(style.rect.x() + style.rect.width() - 140, style.rect.y() + 2, 140, style.rect.height() - 4)
painter.drawText(rect2, Qt.AlignmentFlag.AlignLeft, model.data())
if _has_focus_state(style.state):
painter.save()
pen = QPen()
pen.setWidth(5)
painter.setPen(pen)
painter.drawRect(rect)
painter.restore()
[docs] def sizeHint(self, style: QStyleOptionViewItem, model: QModelIndex):
res = super().sizeHint(style, model)
res.setHeight(self.base_height)
res.setWidth(max(500, res.width()))
return res
[docs]class ColorComboBox(QComboBox):
"""
Combobox showing colormap instead of text
:param id_num: id which be emmit in signals. Designed to inform which channel information is changed
:param colors: list of colors which should be able to choose. All needs to be keys in `color_dict`
:param color_dict: dict from name to colormap definition
:param colormap: initial colormap
:param base_height: initial height of widget
:param lock: show lock padlock to inform that fixed range is used
:param blur: show info about blur selected
"""
triangle_width = 20
clicked = Signal(int)
"""Information about mouse click event on widget"""
channel_visible_changed = Signal(int, bool)
"""Signal with information about change of channel visibility (ch_num, visible)"""
channel_colormap_changed = Signal(int, str)
"""Signal with information about colormap change. (ch_num, name_of_colormap)"""
def __init__(
self,
id_num: int,
colors: typing.List[str],
color_dict: ColorMapDict,
colormap: str = "",
base_height=50,
lock=False,
blur=NoiseFilterType.No,
gamma=1,
):
super().__init__()
self.id = id_num
self.check_box = QCheckBox() # ColorCheckBox(parent=self)
self.check_box.setChecked(True)
self.lock = LockedInfoWidget(base_height - 10)
self.lock.setVisible(lock)
self.blur = BlurInfoWidget(base_height - 10)
self.blur.setVisible(blur != NoiseFilterType.No)
self.gamma = GammaInfoWidget()
self.gamma.setVisible(gamma != 1)
self.color_dict = color_dict
self.colors = colors
self.addItems(self.colors)
self.color = colormap or self.itemText(0)
self.setCurrentText(self.color)
self.currentTextChanged.connect(self._update_image)
self.base_height = base_height
self.show_arrow = False
self.show_frame = False
view = QListView()
view.setMinimumWidth(300)
view.setItemDelegate(ColorStyledDelegate(self.base_height, color_dict))
self.setView(view)
self.image = None # only for moment, to reduce code repetition
layout = QHBoxLayout()
layout.setContentsMargins(7, 0, 0, 0)
layout.addWidget(self.check_box)
layout.addWidget(self.lock)
layout.addWidget(self.blur)
layout.addWidget(self.gamma)
layout.addStretch(1)
self.setLayout(layout)
self.check_box.stateChanged.connect(partial(self.channel_visible_changed.emit, self.id))
self.currentTextChanged.connect(partial(self.channel_colormap_changed.emit, self.id))
self._update_image()
[docs] def change_colors(self, colors: typing.List[str]):
"""change list of colormaps to choose"""
self.colors = colors
current_color = self.currentText()
prev = self.blockSignals(True)
try:
index = colors.index(current_color)
except ValueError:
index = -1
self.clear()
self.addItems(colors)
if index != -1:
self.setCurrentIndex(index)
self.blockSignals(prev)
else:
self.blockSignals(prev)
self._update_image()
self.repaint()
def _update_image(self):
self.color = self.currentText()
if self.color not in image_dict:
image_dict[self.color] = create_colormap_image(self.color, self.color_dict)
self.image = image_dict[self.color]
self.show_arrow = False
[docs] def enterEvent(self, event: QEvent):
self.show_arrow = True
self.repaint()
[docs] def mouseMoveEvent(self, _):
self.show_arrow = True
[docs] def leaveEvent(self, event: QEvent):
self.show_arrow = False
self.repaint()
[docs] def showEvent(self, _event: QShowEvent):
self.show_arrow = False
[docs] def paintEvent(self, event: QPaintEvent):
painter = QPainter(self)
painter.drawImage(self.rect(), self.image)
if self.show_frame:
painter.save()
pen = QPen()
pen.setWidth(2)
pen.setColor(QColor("black"))
painter.setPen(pen)
rect = QRect(1, 1, self.width() - 2, self.height() - 2)
painter.drawRect(rect)
pen.setColor(QColor("white"))
painter.setPen(pen)
rect = QRect(3, 3, self.width() - 6, self.height() - 6)
painter.drawRect(rect)
painter.restore()
if self.show_arrow:
painter.save()
triangle = QPolygonF()
dist = 4
point1 = QPoint(self.width() - self.triangle_width, 0)
size = QSize(20, self.height() // 2)
rect = QRect(point1, size)
painter.fillRect(rect, QColor("white"))
triangle.append(QPointF(point1) + QPointF(dist, dist))
triangle.append(QPointF(point1) + QPointF(size.width() - dist, dist))
triangle.append(QPointF(point1) + QPointF(size.width() // 2, size.height() - dist))
painter.setBrush(Qt.GlobalColor.black)
painter.drawPolygon(triangle, Qt.FillRule.WindingFill)
painter.restore()
[docs] def mousePressEvent(self, event: QMouseEvent):
if get_mouse_x(event) > self.width() - self.triangle_width:
super().mousePressEvent(event)
self.clicked.emit(self.id)
[docs] def minimumSizeHint(self):
size: QSize = super().minimumSizeHint()
return QSize(size.width(), max(size.height(), self.base_height))
[docs] def is_checked(self):
"""check if checkbox on widget is checked"""
return self.check_box.isChecked()
@property
def is_lock(self):
"""check if lock property is set"""
return self.lock.isVisible
@property
def is_blur(self):
"""check if blur property is set"""
return self.blur.isVisible
@property
def set_lock(self) -> typing.Callable[[bool], None]:
"""set lock property"""
return self.lock.setVisible
@property
def set_gamma(self) -> typing.Callable[[bool], None]:
"""set lock property"""
return self.gamma.setVisible
@property
def set_blur(self) -> typing.Callable[[bool], None]:
"""set blur property"""
return self.blur.setVisible
@property
def colormap_changed(self):
"""alias for signal, return color name"""
return self.currentTextChanged
[docs] def set_selection(self, val: bool):
"""set element selected (add frame)"""
self.show_frame = val
self.repaint()
[docs] def set_color(self, val: str):
"""set current colormap"""
self.setCurrentText(val)
[docs]class ChannelProperty(QWidget):
"""
For manipulate chanel properties.
1. Apply gaussian blur to channel
2. Fixed range for coloring
In future should be extended
:param settings: for storing internal state. allow keep state between sessions
:param start_name: name used to select proper information from settings object.
Introduced for case with multiple image view.
"""
def __init__(self, settings: ViewSettings, start_name: str):
if not start_name:
raise ValueError("ChannelProperty should have non empty start_name")
super().__init__()
self.current_name = start_name
self.current_channel = 0
self._settings = settings
self.widget_dict: typing.Dict[str, ColorComboBoxGroup] = {}
self.minimum_value = CustomSpinBox(self)
self.minimum_value.setRange(-(10**6), 10**6)
self.minimum_value.valueChanged.connect(self.range_changed)
self.maximum_value = CustomSpinBox(self)
self.maximum_value.setRange(-(10**6), 10**6)
self.maximum_value.valueChanged.connect(self.range_changed)
self.fixed = QCheckBox("Fix range")
self.fixed.stateChanged.connect(self.lock_channel)
self.use_filter = QEnumComboBox(enum_class=NoiseFilterType)
self.use_filter.setToolTip("Only current channel")
self.filter_radius = QDoubleSpinBox()
self.filter_radius.setSingleStep(0.1)
self.filter_radius.valueChanged.connect(self.gauss_radius_changed)
self.use_filter.currentIndexChanged.connect(self.gauss_use_changed)
self.gamma_value = QDoubleSpinBox()
self.gamma_value.setRange(0.01, 100)
self.gamma_value.setSingleStep(0.1)
self.gamma_value.valueChanged.connect(self.gamma_value_changed)
self.collapse_widget = CollapseCheckbox("Channel property")
self.collapse_widget.add_hide_element(self.minimum_value)
self.collapse_widget.add_hide_element(self.maximum_value)
self.collapse_widget.add_hide_element(self.fixed)
self.collapse_widget.add_hide_element(self.use_filter)
self.collapse_widget.add_hide_element(self.filter_radius)
self.collapse_widget.add_hide_element(self.gamma_value)
self.setup_ui()
def setup_ui(self):
layout = QGridLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.collapse_widget, 0, 0, 1, 4)
label1 = QLabel("Min bright:")
layout.addWidget(label1, 1, 0)
layout.addWidget(self.minimum_value, 1, 1)
label2 = QLabel("Max bright:")
layout.addWidget(label2, 2, 0)
layout.addWidget(self.maximum_value, 2, 1)
layout.addWidget(self.fixed, 1, 2, 1, 2)
label3 = QLabel("Filter:")
layout.addWidget(label3, 3, 0, 1, 1)
layout.addWidget(self.use_filter, 3, 1, 1, 1)
layout.addWidget(self.filter_radius, 3, 2, 1, 1)
label4 = QLabel("Gamma:")
layout.addWidget(label4, 4, 0, 1, 1)
layout.addWidget(self.gamma_value, 4, 1, 1, 1)
self.setLayout(layout)
self.collapse_widget.add_hide_element(label1)
self.collapse_widget.add_hide_element(label2)
self.collapse_widget.add_hide_element(label3)
self.collapse_widget.add_hide_element(label4)
[docs] def send_info(self):
"""send info to"""
widget = self.widget_dict[self.current_name]
widget.parameters_changed(self.current_channel)
def refresh_values(self, path: typing.Optional[str]):
if path is None or path.startswith(self.current_name):
self.change_current(self.current_name, self.current_channel)
[docs] def change_current(self, name: str, channel: int) -> None:
"""
Change to show values connected with channel `channel` from viewer `viewer`
:param str name: name of viewer
:param int channel: channel to which data should be presented
:rtype: None
"""
if name not in self.widget_dict:
raise ValueError(f"name {name} not in register")
self.current_name = name
self.current_channel = channel
block = self.blockSignals(True)
self.minimum_value.blockSignals(True)
self.minimum_value.setValue(
self._settings.get_from_profile(f"{self.current_name}.range_{self.current_channel}", (0, 65000))[0]
)
self.minimum_value.blockSignals(False)
self.maximum_value.setValue(
self._settings.get_from_profile(f"{self.current_name}.range_{self.current_channel}", (0, 65000))[1]
)
self.use_filter.setCurrentEnum(
self._settings.get_from_profile(
f"{self.current_name}.use_filter_{self.current_channel}", NoiseFilterType.No
)
)
self.filter_radius.setValue(
self._settings.get_from_profile(f"{self.current_name}.filter_radius_{self.current_channel}", 1)
)
self.fixed.setChecked(
self._settings.get_from_profile(f"{self.current_name}.lock_{self.current_channel}", False)
)
self.gamma_value.setValue(
self._settings.get_from_profile(f"{self.current_name}.gamma_value_{self.current_channel}", 1)
)
self.blockSignals(block)
def gamma_value_changed(self):
self._settings.set_in_profile(
f"{self.current_name}.gamma_value_{self.current_channel}", self.gamma_value.value()
)
self.send_info()
def gauss_radius_changed(self):
self._settings.set_in_profile(
f"{self.current_name}.filter_radius_{self.current_channel}", self.filter_radius.value()
)
if self.use_filter.currentEnum() != NoiseFilterType.No:
self.send_info()
def gauss_use_changed(self):
self._settings.set_in_profile(
f"{self.current_name}.use_filter_{self.current_channel}", self.use_filter.currentEnum()
)
if self.use_filter.currentEnum() == NoiseFilterType.Median:
self.filter_radius.setDecimals(0)
self.filter_radius.setSingleStep(1)
else:
self.filter_radius.setDecimals(2)
self.filter_radius.setSingleStep(0.1)
self.send_info()
def lock_channel(self, value):
self._settings.set_in_profile(f"{self.current_name}.lock_{self.current_channel}", value)
self.send_info()
def range_changed(self):
self._settings.set_in_profile(
f"{self.current_name}.range_{self.current_channel}",
(self.minimum_value.value(), self.maximum_value.value()),
)
if self.fixed.isChecked():
self.send_info()
[docs]class ColorComboBoxGroup(QWidget):
"""
Group of :class:`.ColorComboBox` for control visibility and chose colormap for channels.
"""
coloring_update = Signal()
"""information about global change of coloring"""
change_channel = Signal(str, int)
"""information which channel change"""
def __init__(
self,
settings: ViewSettings,
viewer_name: str,
channel_property: typing.Optional[ChannelProperty] = None,
height: int = 40,
):
super().__init__()
self.viewer_name = viewer_name
self.height = height
self.settings = settings
self.settings.colormap_changes.connect(self.update_color_list)
self.active_box = 0
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
if channel_property is not None:
channel_property.register_widget(self)
self.change_channel.connect(channel_property.change_current)
settings.connect_channel_colormap_name(viewer_name, self.update_colors)
settings.connect_to_profile(self.viewer_name, self._settings_updated)
def _settings_updated(self, path: str = ""):
if "." in path:
potential_name = path.rsplit(".", maxsplit=1)[-1]
if "_" not in potential_name:
return
name, potential_num = potential_name.rsplit("_", maxsplit=1)
if potential_num.isnumeric() and name in {"use_filter", "lock", "gamma_value"}:
self.parameters_changed(int(potential_num))
else:
for i in range(self.layout().count()):
self.parameters_changed(i)
@property
def name(self): # pragma: no cover
warnings.warn("name is deprecated", DeprecationWarning, stacklevel=2)
return self.viewer_name
[docs] def update_colors(self):
"""For each channel update colormap to reflect settings"""
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
el.setCurrentText(self.settings.get_channel_colormap_name(self.viewer_name, i))
[docs] def update_color_list(self, colors: typing.Optional[typing.List[str]] = None):
"""Update list of available colormaps in each selector"""
if colors is None:
colors = self.settings.chosen_colormap
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
el.change_colors(colors)
[docs] def update_channels(self):
"""update number of channels base on settings"""
self.set_channels(self.settings.channels)
@property
def channels_count(self):
return self.layout().count()
@property
def selected_colormaps(self) -> typing.List[Colormap]:
"""For each channel give information about selected colormap by name"""
resp = []
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
resp.append(self.settings.colormap_dict[el.currentText()][0])
return resp
@property
def channel_visibility(self) -> typing.List[bool]:
resp = []
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
resp.append(el.is_checked())
return resp
@property
def current_colors(self) -> typing.List[typing.Optional[str]]:
"""List of current colors. None if channel is not selected."""
resp = []
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
if el.is_checked():
resp.append(el.currentText())
else:
resp.append(None)
return resp
@property
def current_colormaps(self) -> typing.List[typing.Optional[Colormap]]:
"""List of current colormaps. None if channel is not selected"""
resp = []
for i in range(self.layout().count()):
el: ColorComboBox = self.layout().itemAt(i).widget()
if el.is_checked():
resp.append(self.settings.colormap_dict[el.currentText()][0])
else:
resp.append(None)
return resp
def visibility_changed(self, index):
self.coloring_update.emit()
self.change_channel.emit(self.viewer_name, index)
def change_selected_color(self, index, color):
self.settings.set_channel_colormap_name(self.viewer_name, index, str(color))
self.coloring_update.emit()
self.change_channel.emit(self.viewer_name, index)
def active_channel(self, channel: int):
return self.layout().itemAt(channel).widget().is_checked()
[docs] def set_channels(self, num: int):
"""Set number of channels to display"""
self.settings.set_in_profile(f"{self.viewer_name}.channels_count", num)
if num >= self.layout().count():
for i in range(self.layout().count(), num):
el = ColorComboBox(
id_num=i,
colors=self.settings.chosen_colormap,
color_dict=self.settings.colormap_dict,
colormap=self.settings.get_channel_colormap_name(self.viewer_name, i),
base_height=self.height,
lock=self.settings.get_from_profile(f"{self.viewer_name}.lock_{i}", False),
blur=self.settings.get_from_profile(f"{self.viewer_name}.use_filter_{i}", NoiseFilterType.No),
gamma=self.settings.get_from_profile(f"{self.viewer_name}.gamma_value_{i}", 1),
)
el.clicked.connect(self.set_active)
el.channel_visible_changed.connect(self.visibility_changed)
el.channel_colormap_changed.connect(self.change_selected_color)
self.layout().addWidget(el)
else:
for _ in range(self.layout().count() - num):
el = self.layout().takeAt(num).widget()
el.colormap_changed.disconnect()
el.channel_colormap_changed.disconnect()
el.clicked.disconnect()
el.channel_visible_changed.disconnect()
el.deleteLater()
if num <= self.active_box:
self.set_active(num - 1)
self.change_channel.emit(self.viewer_name, self.active_box)
def set_active(self, pos: int):
self.active_box = pos
for i in range(self.layout().count()):
el = self.layout().itemAt(i).widget()
el.show_frame = i == self.active_box
self.change_channel.emit(self.viewer_name, pos)
self.repaint()
def get_filter(self) -> typing.List[typing.Tuple[NoiseFilterType, float]]:
return [
(
self.settings.get_from_profile(f"{self.viewer_name}.use_filter_{i}", NoiseFilterType.No),
self.settings.get_from_profile(f"{self.viewer_name}.filter_radius_{i}", 1),
)
for i in range(self.layout().count())
]
def get_limits(self) -> typing.List[typing.Union[typing.Tuple[int, int], None]]:
resp: typing.List[typing.Union[typing.Tuple[int, int], None]] = [(0, 0)] * self.layout().count() #
for i in range(self.layout().count()):
resp[i] = (
self.settings.get_from_profile(f"{self.viewer_name}.range_{i}", (0, 65000))
if self.settings.get_from_profile(f"{self.viewer_name}.lock_{i}", False)
else None
)
return resp
def get_gamma(self) -> typing.List[float]:
return [
self.settings.get_from_profile(f"{self.viewer_name}.gamma_value_{i}", 1)
for i in range(self.layout().count())
]
[docs] def parameters_changed(self, channel):
"""for ChannelProperty to inform about change of parameters"""
if self.layout().itemAt(channel) is None:
return
widget: ColorComboBox = self.layout().itemAt(channel).widget()
widget.set_blur(
self.settings.get_from_profile(f"{self.viewer_name}.use_filter_{channel}", NoiseFilterType.No)
!= NoiseFilterType.No
)
widget.set_lock(self.settings.get_from_profile(f"{self.viewer_name}.lock_{channel}", False))
widget.set_gamma(self.settings.get_from_profile(f"{self.viewer_name}.gamma_value_{channel}", 1) != 1)
if self.active_channel(channel):
self.coloring_update.emit()
if self.active_box == channel:
self.change_channel.emit(self.viewer_name, channel)
[docs]class GammaInfoWidget(QWidget):
def __init__(self, size=25, margin=2):
super().__init__()
self.margin = QMargins(margin, margin, margin, margin)
self.setFixedWidth(size)
self.setFixedHeight(size)
self.setToolTip("Gamma translated")
[docs] def paintEvent(self, a0: QPaintEvent) -> None:
super().paintEvent(a0)
painter = QPainter(self)
painter.save()
painter.setPen(QColor("white"))
painter.setBrush(QColor("white"))
painter.drawRect(self.rect() - self.margin)
icon: QIcon = qta.icon("mdi.chart-bell-curve-cumulative")
icon.paint(painter, self.rect())
painter.restore()