Source code for pymodaq_gui.plotting.items.roi

from abc import abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Tuple, Union, Callable, Iterable as IterableType

import numpy as np
import pyqtgraph as pg

from pymodaq_data import DataDim
from pymodaq_data.post_treatment.process_to_scalar import DataProcessorFactory
from pymodaq_gui.plotting.utils.plot_utils import Point
from pymodaq_utils.logger import get_module_name, set_logger
from pymodaq_utils.enums import StrEnum
from pymodaq_utils.math_utils import rotate2D

from pymodaq_data.plotting.utils import PlotColors

from pyqtgraph import ROI as pgROI, ROI, LinearRegionItem, mkColor
from pyqtgraph import LinearRegionItem as pgLinearROI
from pyqtgraph import functions as fn
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSignalBlocker, Signal, Slot

from pymodaq_gui.config import get_set_roi_path
from pymodaq_gui.parameter import (Parameter, ParameterTree,
                                   )
from pymodaq_gui.plotting.utils import plot_utils
data_processors = DataProcessorFactory()

roi_path = get_set_roi_path()
logger = set_logger(get_module_name(__file__))
translate = QtCore.QCoreApplication.translate
plot_colors = PlotColors()


ROI_NAME_PREFIX = 'ROI_'
[docs] def roi_format(index): return f'{ROI_NAME_PREFIX}{index:02d}'
[docs] class ROIDim(StrEnum): ROI1D = 'data_1D' ROI2D = 'data_2D'
[docs] def map_to_datadim(self) -> DataDim: if self == ROIDim.ROI1D: return DataDim.Data1D else: return DataDim.Data2D
[docs] class ROIBase: """ Base class to be inherited for ROI to be created by the factory""" DIMENSIONALITY: ROIDim = NotImplemented DESCRIPTOR: str = NotImplemented # the identifier of the ROI, its name! index_signal = Signal(int) def __init__(self, index=0, name='roi', compute=True): self.name = name self.index = index self._compute = compute self.menu = None self.signalBlocker = None self._clipboard = None
[docs] def init_qt(self): self.signalBlocker = QSignalBlocker(self) self.signalBlocker.unblock() self._clipboard = QtGui.QGuiApplication.clipboard()
[docs] def emit_index_signal(self): self.index_signal.emit(self.index)
[docs] @abstractmethod def mouseClickEvent(self, ev): ...
[docs] @abstractmethod def getMenu(self): ...
def _emitCopyRequest(self): self.sigCopyRequested.emit(self)
[docs] def mouseDoubleClickEvent(self, ev): if ev.button() == QtCore.Qt.MouseButton.LeftButton: ev.accept() self.sigDoubleClicked.emit(self, ev)
[docs] @abstractmethod def to_info(self) -> 'RoiInfo': """ Return the info about the ROI To be Reimplemented for different ROI types""" ...
[docs] def copy_clipboard(self): info = self.to_info() self._clipboard.setText(str(info.to_slices()))
[docs] def color(self): raise NotImplementedError
[docs] def set_color(self, value): raise NotImplementedError
[docs] def center(self) -> IterableType[float]: raise NotImplementedError
[docs] def set_center(self, value: IterableType[float]): raise NotImplementedError
[docs] def width(self) -> float: raise NotImplementedError
[docs] def set_width(self, value: float): raise NotImplementedError
[docs] def height(self) -> float: raise NotImplementedError
[docs] def set_height(self, value: float): raise NotImplementedError
[docs] def x(self) -> float: raise NotImplementedError
[docs] def set_x(self, value: float): raise NotImplementedError
[docs] def y(self) -> float: raise NotImplementedError
[docs] def set_y(self, value: float): raise NotImplementedError
[docs] def key(self) -> str: return roi_format(self.index)
[docs] def type(self) -> str: return type(self).__name__
[docs] def doShow(self, status: bool = True): if status: self.show() else: self.hide()
@property def compute(self): return self._compute @compute.setter def compute(self, compute: bool = True): self._compute = compute
[docs] class ROIFactory(): """The factory class for creating ROI""" registry = {}
[docs] @classmethod def register(cls) -> Callable: """Class decorator method to register ROI class to the internal registry. Must be used as decorator above the definition of a ROI class. """ def inner_wrapper(wrapped_class: ROIBase) -> ROIBase: if wrapped_class.DIMENSIONALITY is NotImplemented or \ wrapped_class.DESCRIPTOR is NotImplemented: raise NotImplementedError(f'{wrapped_class} does not properly provide a valid value for ' f'`DIMENSIONALITY` ({wrapped_class.DIMENSIONALITY}) or for ' f'`ROI_DESC` ({wrapped_class.DESCRIPTOR})') if wrapped_class.DIMENSIONALITY not in cls.registry: cls.registry[wrapped_class.DIMENSIONALITY] = {} if wrapped_class.DESCRIPTOR not in cls.registry[wrapped_class.DIMENSIONALITY]: cls.registry[wrapped_class.DIMENSIONALITY][wrapped_class.DESCRIPTOR] = wrapped_class return wrapped_class return inner_wrapper
[docs] @classmethod def create(cls, dimensionality: ROIDim, descriptor: str, *args, **kwargs) -> ROIBase: """Factory command to create the ROI object. This method gets the appropriate ROI class from the registry and instantiates it. Parameters ---------- dimensionality: ROIDim the dimensionality of the ROI descriptor: str the roi descriptor string Returns ------- an instance of the ROI created """ if dimensionality not in cls.registry: raise ValueError(f".{dimensionality} is not a supported ROI dimensionality") elif descriptor not in cls.registry[dimensionality]: raise ValueError(f".{descriptor} is not a supported file description.") return cls.registry[dimensionality][descriptor](*args, **kwargs)
[docs] @classmethod def get_dimensionality(cls): """Returns a list of registered dimensionality""" return list(cls.registry.keys()).sort()
[docs] @classmethod def get_descriptors_from_dimensionality(cls, dim: ROIDim): """Returns a list of ROi descriptors for a given dimensionality""" descriptors = list(cls.registry[dim].keys()) descriptors.sort() return descriptors
[docs] class ROI(pgROI, ROIBase): """ Base class for all 2D ROI""" sigCopyRequested = Signal(object) sigDoubleClicked = Signal(object, object) sigRemoveRequested = Signal(object) def __init__(self, *args, index=0, name='roi', compute=True, **kwargs): color = kwargs.pop('color', None) angle = kwargs.pop('angle', None) pgROI.__init__(self, *args, **kwargs) ROIBase.__init__(self, index=index, name=name, compute=compute) self.init_qt() if color is not None: self.set_color(color) if angle is not None: self.setAngle(angle)
[docs] def getMenu(self): if self.menu is None: self.menu = QtWidgets.QMenu() self.menu.setTitle(translate("ROI", "ROI")) self.menu.addAction('Copy ROI slice to clipboard', self.copy_clipboard) self.menu.addAction("Copy ROI", self._emitCopyRequest) self.menu.addAction("Remove ROI", self._emitRemoveRequest) return self.menu
[docs] def contextMenuEnabled(self): return True
[docs] def raiseContextMenu(self, ev): menu = self.getMenu() menu = self.scene().addParentContextMenus(self, menu, ev) pos = ev.screenPos() menu.popup(QtCore.QPoint(int(pos.x()), int(pos.y())))
[docs] def contextMenuEvent(self, event): if self.menu is not None: self.menu.exec(event.screenPos())
[docs] def mouseClickEvent(self, ev): super().mouseClickEvent(ev) if ev.button() == QtCore.Qt.MouseButton.RightButton and self.contextMenuEnabled(): self.raiseContextMenu(ev) ev.accept() elif self.acceptedMouseButtons() & ev.button(): ev.accept() self.sigClicked.emit(self, ev) elif ev.button() == QtCore.Qt.MouseButton.MiddleButton: ev.accept() self._emitRemoveRequest() else: ev.ignore()
[docs] def color(self): return self.pen.color()
[docs] def set_color(self, value): self.pen.setColor(value)
[docs] def center(self) -> pg.Point: """ Get the center position of the ROI """ return pg.Point(self.pos() + rotate2D(point=(self.width()/2,self.height()/2), angle=np.deg2rad(self.angle())))
[docs] def set_center(self, center: Union[pg.Point, Tuple[float, float]]): """ Set the center position of the ROI """ if isinstance(center, pg.Point): center = (center.x(), center.y()) pos = tuple(np.array(center) - np.array(rotate2D(point=(self.width()/2, self.height()/2), angle=np.deg2rad(self.angle())))) self.setPos(pos)
[docs] def to_info(self) -> 'RoiInfo': """ Return the info about the ROI """ return RoiInfo.info_from_rect_roi(self)
[docs] def width(self) -> float: return self.size().x()
[docs] def set_width(self, value: float): self.setSize((value, self.height()), center=(0.5, 0.5))
[docs] def height(self) -> float: return self.size().y()
[docs] def set_height(self, value: float): self.setSize((self.width(), value), center=(0.5, 0.5))
[docs] def x(self) -> float: return self.pos().x()
[docs] def set_x(self, value: float): self.setPos(value, self.y())
[docs] def y(self) -> float: return self.pos().y()
[docs] def set_y(self, value: float): self.setPos(self.x(), value)
[docs] class ROIBrushable(ROI): def __init__(self, brush=None, *args, **kwargs): super().__init__(*args, **kwargs) if brush is None: brush = QtGui.QBrush(QtGui.QColor(0, 0, 255, 50)) self.setBrush(brush)
[docs] def setBrush(self, *br, **kargs): """Set the brush that fills the region. Can have any arguments that are valid for :func:`mkBrush <pyqtgraph.mkBrush>`. """ self.brush = fn.mkBrush(*br, **kargs) self.currentBrush = self.brush
[docs] def paint(self, p, opt, widget): # p.save() # Note: don't use self.boundingRect here, because subclasses may need to redefine it. r = QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) p.setBrush(self.currentBrush) p.translate(r.left(), r.top()) p.scale(r.width(), r.height()) p.drawRect(0, 0, 1, 1)
# p.restore()
[docs] @ROIFactory.register() class LinearROI(pgLinearROI, ROIBase): sigCopyRequested = Signal(object) sigDoubleClicked = Signal(object,object) sigRemoveRequested = Signal(object) DIMENSIONALITY = ROIDim.ROI1D DESCRIPTOR = 'LinearROI' def __init__(self, index=0, pos=[0, 10], name='roi', compute=True, **kwargs): color = kwargs.pop('color', None) pgLinearROI.__init__(self, values=pos, **kwargs) ROIBase.__init__(self, index=index, name=name, compute=compute) self.init_qt() if color is not None: self.set_color(color)
[docs] def getMenu(self): if self.menu is None: self.menu = QtWidgets.QMenu() self.menu.setTitle(translate("ROI", "ROI")) self.menu.addAction('Copy ROI to clipboard', self.copy_clipboard) self.menu.addAction("Copy ROI", self._emitCopyRequest) self.menu.addAction("Remove ROI", self._emitRemoveRequest) return self.menu
[docs] def contextMenuEnabled(self): return True
[docs] def raiseContextMenu(self, ev): menu = self.getMenu() menu = self.scene().addParentContextMenus(self, menu, ev) pos = ev.screenPos() menu.popup(QtCore.QPoint(int(pos.x()), int(pos.y())))
[docs] def contextMenuEvent(self, event): if self.menu is not None: self.menu.exec(event.screenPos())
[docs] def mouseClickEvent(self, ev): super().mouseClickEvent(ev) if ev.button() == QtCore.Qt.MouseButton.RightButton and self.contextMenuEnabled(): self.raiseContextMenu(ev) ev.accept() elif self.acceptedMouseButtons() & ev.button(): ev.accept() self.sigClicked.emit(self, ev) elif ev.button() == QtCore.Qt.MouseButton.MiddleButton: ev.accept() self._emitRemoveRequest() else: ev.ignore()
[docs] def to_info(self) -> 'RoiInfo': """ Return the info about the ROI """ return RoiInfo.info_from_linear_roi(self)
[docs] def pos(self) -> Tuple[float, float]: return self.getRegion()
[docs] def setPos(self, pos: Tuple[float, float]): self.setRegion(pos)
[docs] def setPen(self, color): self.setBrush(color)
[docs] def color(self): return self.brush.color()
[docs] def set_color(self, value): self.brush.setColor(value) line_color = mkColor(value) line_color.setAlpha(255) for line in self.lines: line.setPen(value)
[docs] def center(self) -> float: """ Get the center position of the ROI """ pos = self.pos() return (pos[0] + pos[1]) / 2
[docs] def x(self) -> float: return self.pos()[0]
[docs] def set_x(self, value: float): self.setPos((value, self.y()))
[docs] def y(self) -> float: return self.pos()[1]
[docs] def set_y(self, value: float): self.setPos((self.x(), value))
[docs] @ROIFactory.register() class EllipseROI(ROI): """ Elliptical ROI subclass with one scale handle and one rotation handle. ============== ============================================================= **Arguments** pos (length-2 sequence) The position of the ROI's origin. size (length-2 sequence) The size of the ROI's bounding rectangle. **args All extra keyword arguments are passed to ROI() ============== ============================================================= """ DIMENSIONALITY = ROIDim.ROI2D DESCRIPTOR = 'EllipseROI' def __init__(self, index=0, pos=[0, 0], size=[10, 10], **kwargs): # QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) super().__init__(pos=pos, size=size, index=index, **kwargs) self.addRotateHandle([1.0, 0.5], [0.5, 0.5]) self.addScaleHandle([0.5 * 2. ** -0.5 + 0.5, 0.5 * 2. ** -0.5 + 0.5], [0.5, 0.5])
[docs] def getArrayRegion(self, arr, img=None, axes=(0, 1), **kwds): """ Return the result of ROI.getArrayRegion() masked by the elliptical shape of the ROI. Regions outside the ellipse are set to 0. """ # Note: we could use the same method as used by PolyLineROI, but this # implementation produces a nicer mask. if kwds.get("returnMappedCoords", False): arr, coords = pgROI.getArrayRegion(self, arr, img, axes, **kwds) else: arr = pgROI.getArrayRegion(self, arr, img, axes, **kwds) if arr is None or arr.shape[axes[0]] == 0 or arr.shape[axes[1]] == 0: return arr w = arr.shape[axes[0]] h = arr.shape[axes[1]] # generate an ellipsoidal mask mask = np.fromfunction( lambda x, y: (((x + 0.5) / (w / 2.) - 1) ** 2 + ((y + 0.5) / (h / 2.) - 1) ** 2) ** 0.5 < 1, (w, h)) # reshape to match array axes if axes[0] > axes[1]: mask = mask.T shape = [(n if i in axes else 1) for i, n in enumerate(arr.shape)] mask = mask.reshape(shape) if kwds.get("returnMappedCoords", False): return arr * mask, coords else: return arr * mask
[docs] def paint(self, p, opt, widget): r = self.boundingRect() p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) p.scale(r.width(), r.height()) # workaround for GL bug r = QtCore.QRectF(r.x() / r.width(), r.y() / r.height(), 1, 1) p.drawEllipse(r)
[docs] def shape(self): self.path = QtGui.QPainterPath() self.path.addEllipse(self.boundingRect()) return self.path
[docs] @ROIFactory.register() class CircularROI(EllipseROI): DIMENSIONALITY = ROIDim.ROI2D DESCRIPTOR = 'CircularROI' def __init__(self, index=0, pos=[0, 0], size=[10, 10], **kwargs): ROI.__init__(self, pos=pos, size=size, index=index, **kwargs) self.addScaleHandle([0.5 * 2. ** -0.5 + 0.5, 0.5 * 2. ** -0.5 + 0.5], [0.5, 0.5], lockAspect=True)
[docs] class SimpleRectROI(ROI): r""" Rectangular ROI subclass with a single scale handle at the top-right corner. """ def __init__(self, pos=[0, 0], size=[10, 10], centered=False, sideScalers=False, **args): super().__init__(pos, size, **args) if centered: center = [0.5, 0.5] else: center = [0, 0] self.addScaleHandle([1, 1], center) if sideScalers: self.addScaleHandle([1, 0.5], [center[0], 0.5]) self.addScaleHandle([0.5, 1], [0.5, center[1]])
[docs] @ROIFactory.register() class RectROI(ROI): DIMENSIONALITY = ROIDim.ROI2D DESCRIPTOR = 'RectROI' def __init__(self, index=0, pos=[0, 0], size=[10, 10], **kwargs): super().__init__(pos=pos, size=size, index=index, **kwargs) # , scaleSnap=True, translateSnap=True) self.addScaleHandle([1, 1], [0, 0]) self.addRotateHandle([0, 0], [0.5, 0.5])
[docs] class ROIPositionMapper(QtWidgets.QWidget): """ Widget presenting a Tree structure representing a ROI positions. """ def __init__(self, roi_pos, roi_size): super().__init__() self.roi_pos = roi_pos self.roi_size = roi_size
[docs] def show_dialog(self): self.params = [ {'name': 'position', 'type': 'group', 'children': [ {'name': 'x0', 'type': 'float', 'value': self.roi_pos[0] + self.roi_size[0] / 2, 'step': 1}, {'name': 'y0', 'type': 'float', 'value': self.roi_pos[1] + self.roi_size[1] / 2, 'step': 1}, ]}, {'name': 'size', 'type': 'group', 'children': [ {'name': 'width', 'type': 'float', 'value': self.roi_size[0], 'step': 1}, {'name': 'height', 'type': 'float', 'value': self.roi_size[1], 'step': 1}], }] dialog = QtWidgets.QDialog(self) vlayout = QtWidgets.QVBoxLayout() self.settings_tree = ParameterTree() vlayout.addWidget(self.settings_tree, 10) self.settings_tree.setMinimumWidth(300) self.settings = Parameter.create(name='settings', type='group', children=self.params) self.settings_tree.setParameters(self.settings, showTop=False) dialog.setLayout(vlayout) buttonBox = QtWidgets.QDialogButtonBox(parent=self) buttonBox.addButton('Apply', buttonBox.AcceptRole) buttonBox.accepted.connect(dialog.accept) buttonBox.addButton('Cancel', buttonBox.RejectRole) buttonBox.rejected.connect(dialog.reject) vlayout.addWidget(buttonBox) self.setWindowTitle('Set Precise positions for the ROI') res = dialog.exec() if res == dialog.Accepted: return self.settings else: return None
[docs] @dataclass class RoiInfo: """ DataClass holding info about a given ROI Parameters ---------- origin size angle centered color roi_class index """ origin: Union[Point, IterableType[float]] size: Union[Point, IterableType[float]] angle: float = None centered: bool = False color: Tuple[int, int, int] = (255, 0, 0) roi_class: type = None index: int = 0
[docs] @classmethod def info_from_linear_roi(cls, roi: LinearROI): pos = roi.pos() return cls(Point((pos[0],)), size=Point((pos[1] - pos[0],)), color=roi.color, roi_class=type(roi), index=roi.index)
[docs] @classmethod def info_from_rect_roi(cls, roi: RectROI): return cls(Point(list(roi.pos())[::-1]), size=Point((roi.height(), roi.width())), color=roi.color, roi_class=type(roi), index=roi.index)
[docs] def center_origin(self): if not self.centered: self.origin += Point((self.size[0] / 2, self.size[1] / 2)) self.centered = True
[docs] def uncenter_origin(self): if self.centered: self.origin -= Point((self.size[0] / 2, self.size[1] / 2)) self.centered = False
def __repr__(self): return f'Origin: {self.origin}, Size: {self.size}, Centered: {self.centered}'
[docs] @classmethod def from_slices(cls, slices: IterableType[slice]) -> 'RoiInfo': """ Return a ROIInfo instance from a list of slices """ if isinstance(slices, slice): slices = [slices] return cls(Point(*[_slice.start for _slice in slices]), size=Point(*[_slice.stop - _slice.start for _slice in slices]), roi_class=RectROI if len(slices) == 2 else LinearROI)
[docs] def to_slices(self, as_integer=True) -> IterableType[slice]: """Get slices to be used directly to slice DataWithAxes""" if not as_integer: cast = float else: cast = int if issubclass(self.roi_class, pgROI): if self.centered: return (slice(cast(self.origin[0] - self.size[0] / 2), cast(self.origin[0] + self.size[0] / 2)), slice(cast(self.origin[1] - self.size[1] / 2), cast(self.origin[1] + self.size[1] / 2)), ) else: return (slice(cast(self.origin[0]), cast(self.origin[0] + self.size[0])), slice(cast(self.origin[1]), cast(self.origin[1] + self.size[1])), ) elif issubclass(self.roi_class, pgLinearROI): if self.centered: return (slice(cast(self.origin[0] - self.size[0] / 2), cast(self.origin[0] + self.size[0] / 2)),) else: return (slice(cast(self.origin[0]), cast(self.origin[0] + self.size[0])),)