Source code for cdl.core.gui.docks
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
Docks
=====
The :mod:`cdl.core.gui.docks` module provides the dockable widgets for the
DataLab main window.
Plot widget
-----------
.. autoclass:: DataLabPlotWidget
Dockable plot widget
--------------------
.. autoclass:: DockablePlotWidget
"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
import scipy.integrate as spt
from guidata.configtools import get_icon, get_image_file_path
from guidata.qthelpers import create_action, is_dark_theme
from guidata.widgets.dockable import DockableWidget
from plotpy.constants import PlotType
from plotpy.items import CurveItem
from plotpy.panels import XCrossSection, YCrossSection
from plotpy.plot import PlotOptions, PlotWidget
from plotpy.tools import (
BasePlotMenuTool,
CurveStatsTool,
DeleteItemTool,
DisplayCoordsTool,
DoAutoscaleTool,
EditItemDataTool,
ExportItemDataTool,
ImageStatsTool,
ItemCenterTool,
RectangularSelectionTool,
RectZoomTool,
SelectTool,
)
from plotpy.tools.image import get_stats as get_image_stats
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
from qtpy.QtWidgets import QApplication, QMainWindow
from cdl.algorithms.image import get_centroid_fourier
from cdl.algorithms.signal import fwhm
from cdl.config import APP_NAME, Conf, _
from cdl.core.model.signal import create_signal
if TYPE_CHECKING:
from plotpy.items.image.base import BaseImageItem
from plotpy.plot import BasePlot
from plotpy.styles import BaseImageParam
def fwhm_info(x, y):
"""Return FWHM information string"""
with warnings.catch_warnings(record=True) as w:
x0, _y0, x1, _y1 = fwhm((x, y), "zero-crossing")
wstr = " ⚠️" if w else ""
return f"{x1 - x0:g}{wstr}"
CURVESTATSTOOL_LABELFUNCS = (
("%g < x < %g", lambda *args: (args[0].min(), args[0].max())),
("%g < y < %g", lambda *args: (args[1].min(), args[1].max())),
("<y>=%g", lambda *args: args[1].mean()),
("σ(y)=%g", lambda *args: args[1].std()),
("∑(y)=%g", lambda *args: spt.trapezoid(args[1])),
("∫ydx=%g<br>", lambda *args: spt.trapezoid(args[1], args[0])),
("FWHM = %s", fwhm_info),
)
def get_more_image_stats(
item: BaseImageItem,
x0: float,
y0: float,
x1: float,
y1: float,
) -> str:
"""Return formatted string with stats on image rectangular area
(output should be compatible with AnnotatedShape.get_infos)
Args:
item: image item
x0: X0
y0: Y0
x1: X1
y1: Y1
"""
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
infos = get_image_stats(item, x0, y0, x1, y1)
ix0, iy0, ix1, iy1 = item.get_closest_index_rect(x0, y0, x1, y1)
data = item.data[iy0:iy1, ix0:ix1]
p: BaseImageParam = item.param
xunit, yunit, zunit = p.get_units()
integral = data.sum()
integral_fmt = r"%.3e " + zunit
infos += f"<br>∑ = {integral_fmt % integral}"
if xunit == yunit:
surfacefmt = p.xformat.split()[0] + " " + xunit
if xunit != "":
surfacefmt = surfacefmt + "²"
surface = abs((x1 - x0) * (y1 - y0))
infos += f"<br>A = {surfacefmt % surface}"
if xunit is not None and zunit is not None:
if surface != 0:
density = integral / surface
densityfmt = r"%.3e"
if xunit and zunit:
densityfmt += " " + zunit + "/" + xunit + "²"
infos = infos + f"<br>ρ = {densityfmt % density}"
c_i, c_j = get_centroid_fourier(data)
c_x, c_y = item.get_plot_coordinates(c_j + ix0, c_i + iy0)
infos += "<br>" + "<br>".join(
[
"C|x = " + p.xformat % c_x,
"C|y = " + p.yformat % c_y,
]
)
return infos
def profile_to_signal(plot: BasePlot, panel: XCrossSection | YCrossSection) -> None:
"""Send cross section curve to DataLab's signal list
Args:
panel: Cross section panel
"""
win = None
for win in QApplication.topLevelWidgets():
if isinstance(win, QMainWindow):
break
if win is None or win.objectName() != APP_NAME:
# pylint: disable=import-outside-toplevel
# pylint: disable=cyclic-import
from cdl.core.gui import main
# Note : this is the only way to retrieve the DataLab main window instance
# when the CrossSectionItem object is embedded into an image widget
# parented to another main window.
win = main.CDLMainWindow.get_instance()
assert win is not None # Should never happen
for item in panel.cs_plot.get_items():
if not isinstance(item, CurveItem):
continue
x, y, _dx, _dy = item.get_data()
if x is None or y is None or x.size == 0 or y.size == 0:
continue
signal = create_signal(item.param.label)
if isinstance(panel, YCrossSection):
signal.set_xydata(y, x)
xaxis_name = "left"
xunit = plot.get_axis_unit("bottom")
if xunit:
signal.title += " " + xunit
else:
signal.set_xydata(x, y)
xaxis_name = "bottom"
yunit = plot.get_axis_unit("left")
if yunit:
signal.title += " " + yunit
signal.ylabel = plot.get_axis_title("right")
signal.yunit = plot.get_axis_unit("right")
signal.xlabel = plot.get_axis_title(xaxis_name)
signal.xunit = plot.get_axis_unit(xaxis_name)
win.signalpanel.add_object(signal)
# Show DataLab main window on top, if not already visible
win.show()
win.raise_()
[docs]
class DataLabPlotWidget(PlotWidget):
"""DataLab PlotWidget
This class is a subclass of `plotpy.plot.PlotWidget` that provides a
customized widget for DataLab, with a specific set of tools and a
customized appearance.
Args:
plot_type: Plot type
"""
def __init__(self, plot_type: PlotType) -> None:
super().__init__(options=PlotOptions(type=plot_type), toolbar=True)
def __register_standard_tools(self) -> None:
"""Register standard tools
The only differences with the `manager.register_standard_tools` method are
the following:
1. We don't register the `BasePlotMenuTool, "axes"` tool, because it is not
compatible with DataLab's approach to axes management.
2. We don't register the `ItemListPanelTool` tool (this intends to prevent
the user from accessing the item list panel, and thus, the parameters of all
the items - some of them are read-only and should not be modified, like the
annotations for example).
"""
mgr = self.manager
select_tool = mgr.add_tool(SelectTool)
mgr.set_default_tool(select_tool)
mgr.add_tool(RectangularSelectionTool, intersect=False)
mgr.add_tool(RectZoomTool)
mgr.add_tool(DoAutoscaleTool)
mgr.add_tool(BasePlotMenuTool, "item")
mgr.add_tool(ExportItemDataTool)
mgr.add_tool(EditItemDataTool)
mgr.add_tool(ItemCenterTool)
mgr.add_tool(DeleteItemTool)
mgr.add_separator_tool()
mgr.add_tool(BasePlotMenuTool, "grid")
mgr.add_tool(DisplayCoordsTool)
def __register_other_tools(self) -> None:
"""Register other tools"""
mgr = self.manager
mgr.add_separator_tool()
if self.options.type == PlotType.CURVE:
mgr.register_curve_tools()
statstool = mgr.get_tool(CurveStatsTool)
statstool.set_labelfuncs(CURVESTATSTOOL_LABELFUNCS)
else:
mgr.register_image_tools()
# Customizing the ImageStatsTool
statstool = mgr.get_tool(ImageStatsTool)
statstool.set_stats_func(get_more_image_stats, replace=True)
# Customizing the X and Y cross section panels
plot = mgr.get_plot()
for panel in (mgr.get_xcs_panel(), mgr.get_ycs_panel()):
to_signal_action = create_action(
panel,
_("Process signal"),
icon=get_icon("to_signal.svg"),
triggered=lambda panel=panel: profile_to_signal(plot, panel),
)
tb = panel.toolbar
tb.insertSeparator(tb.actions()[0])
tb.insertAction(tb.actions()[0], to_signal_action)
mgr.add_separator_tool()
mgr.register_other_tools()
mgr.add_separator_tool()
mgr.update_tools_status()
mgr.get_default_tool().activate()
[docs]
def register_tools(self) -> None:
"""Register the plotting tools according to the plot type"""
self.__register_standard_tools()
self.__register_other_tools()
[docs]
class DockablePlotWidget(DockableWidget):
"""Docked plotting widget
Args:
parent: Parent widget
plot_type: Plot type
"""
LOCATION = QC.Qt.RightDockWidgetArea
def __init__(
self,
parent: QW.QWidget,
plot_type: PlotType,
) -> None:
super().__init__(parent)
self.plotwidget = DataLabPlotWidget(plot_type)
self.toolbar = self.plotwidget.get_toolbar()
self.watermark = QW.QLabel()
original_image = QG.QPixmap(get_image_file_path("DataLab-watermark.png"))
self.watermark.setPixmap(original_image)
self.setup_layout()
self.setup_plotwidget()
def __get_toolbar_row_col(self) -> tuple[int, int]:
"""Return toolbar row and column"""
tb_pos = Conf.view.plot_toolbar_position.get()
tb_col, tb_row = 1, 1
if tb_pos in ("left", "right"):
self.toolbar.setOrientation(QC.Qt.Vertical)
tb_col = 0 if tb_pos == "left" else 2
else:
self.toolbar.setOrientation(QC.Qt.Horizontal)
tb_row = 0 if tb_pos == "top" else 2
return tb_row, tb_col
[docs]
def setup_layout(self) -> None:
"""Setup layout"""
tb_row, tb_col = self.__get_toolbar_row_col()
layout = QW.QGridLayout()
layout.addWidget(self.toolbar, tb_row, tb_col)
layout.addWidget(self.plotwidget, 1, 1)
layout.addWidget(self.watermark, 1, 1, QC.Qt.AlignCenter)
self.setLayout(layout)
[docs]
def update_toolbar_position(self) -> None:
"""Update toolbar position"""
tb_row, tb_col = self.__get_toolbar_row_col()
layout = self.layout()
layout.removeWidget(self.toolbar)
layout.addWidget(self.toolbar, tb_row, tb_col)
[docs]
def setup_plotwidget(self) -> None:
"""Setup plotting widget"""
title = self.toolbar.windowTitle()
self.plotwidget.get_manager().add_toolbar(self.toolbar, title)
# Customizing widget appearances
self.update_color_mode()
plot = self.plotwidget.get_plot()
canvas = plot.canvas()
canvas.setFrameStyle(canvas.Plain | canvas.NoFrame)
plot.SIG_ITEMS_CHANGED.connect(self.update_watermark)
[docs]
def update_color_mode(self) -> None:
"""Update plot widget styles according to application color mode"""
if is_dark_theme():
palette = QApplication.instance().palette()
else:
palette = QG.QPalette(QC.Qt.white)
for widget in (self.plotwidget, self.plotwidget.get_plot(), self):
widget.setBackgroundRole(QG.QPalette.Window)
widget.setAutoFillBackground(True)
widget.setPalette(palette)
[docs]
def update_watermark(self, plot: BasePlot) -> None:
"""Update watermark visibility"""
items = plot.get_items()
if self.plotwidget.options.type == PlotType.IMAGE:
enabled = len(items) <= 1
else:
enabled = len(items) <= 2
self.watermark.setVisible(enabled)
# ------DockableWidget API
[docs]
def visibility_changed(self, enable: bool) -> None:
"""DockWidget visibility has changed"""
DockableWidget.visibility_changed(self, enable)
self.toolbar.setVisible(enable)