# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file."""ROI editor==========The :mod:`cdl.core.gui.roieditor` module provides the ROI editor widgetsfor signals and images.Signal ROI editor-----------------.. autoclass:: SignalROIEditor :members:Image ROI editor----------------.. autoclass:: ImageROIEditor :members:"""# pylint: disable=invalid-name # Allows short reference names like x, y, ...from__future__importannotationsimportabcfromtypingimportTYPE_CHECKING,Generic,Literal,TypeVar,Unionfromguidata.configtoolsimportget_iconfromguidata.qthelpersimportadd_actions,create_actionfromplotpy.builderimportmakefromplotpy.interfacesimportIImageItemTypefromplotpy.itemsimport(AnnotatedCircle,AnnotatedPolygon,AnnotatedRectangle,CurveItem,MaskedImageItem,ObjectInfo,XRangeSelection,)fromplotpy.plotimportPlotDialog,PlotManagerfromplotpy.toolsimportCircleTool,HRangeTool,PolygonTool,RectangleToolfromqtpyimportQtCoreasQCfromqtpyimportQtWidgetsasQWimportcdl.objasdlofromcdl.configimportConf,_fromcdl.core.model.baseimport(TypeObj,TypePlotItem,TypeROI,TypeROIItem,configure_roi_item,)fromcdl.core.model.imageimportCircularROI,PolygonalROI,RectangularROIfromcdl.core.model.signalimportSegmentROIfromcdl.envimportexecenvfromcdl.objimportImageObj,ImageROI,SignalObj,SignalROIifTYPE_CHECKING:fromplotpy.plotimportBasePlotfromplotpy.tools.baseimportInteractiveTooldefplot_item_to_single_roi(item:XRangeSelection|AnnotatedRectangle|AnnotatedCircle|AnnotatedPolygon,)->SegmentROI|CircularROI|RectangularROI|PolygonalROI:"""Factory function to create a single ROI object from plot item Args: item: Plot item Returns: Single ROI object """ifisinstance(item,XRangeSelection):cls=SegmentROIelifisinstance(item,AnnotatedRectangle):cls=RectangularROIelifisinstance(item,AnnotatedCircle):cls=CircularROIelifisinstance(item,AnnotatedPolygon):cls=PolygonalROIelse:raiseTypeError(f"Unsupported ROI item type: {type(item)}")returncls.from_plot_item(item)defconfigure_roi_item_in_tool(shape,obj:dlo.SignalObj|dlo.ImageObj)->None:"""Configure ROI item in tool"""fmt=obj.get_metadata_option("format")configure_roi_item(shape,fmt,lbl=True,editable=True,option=obj.PREFIX)deftool_activate(tool:InteractiveTool)->None:"""Tool activate"""plot=tool.get_active_plot()plot.select_some_items([])# Deselect all itemstool.activate()deftool_setup_shape(shape:TypeROIItem,obj:dlo.SignalObj|dlo.ImageObj)->None:"""Tool setup shape"""shape.setTitle("ROI")configure_roi_item_in_tool(shape,obj)classROISegmentTool(HRangeTool):"""ROI segment tool"""TITLE=_("Range ROI")ICON="signal_roi.svg"def__init__(self,manager:PlotManager,obj:dlo.SignalObj)->None:super().__init__(manager,switch_to_default_tool=True,toolbar_id=None)self.roi=SegmentROI([0,1],False)self.obj=objself.activate=tool_activatedefcreate_shape(self)->XRangeSelection:"""Create shape"""shape=self.roi.to_plot_item(self.obj)configure_roi_item_in_tool(shape,self.obj)returnshapeclassROIRectangleTool(RectangleTool):"""ROI rectangle tool"""TITLE=_("Rectangular ROI")ICON="roi_new_rectangle.svg"def__init__(self,manager:PlotManager,obj:dlo.ImageObj)->None:super().__init__(manager,switch_to_default_tool=True,toolbar_id=None,setup_shape_cb=tool_setup_shape,)self.roi=RectangularROI([0,0,1,1],True)self.obj=objself.activate=tool_activatedefcreate_shape(self)->tuple[AnnotatedRectangle,int,int]:"""Create shape"""item=self.roi.to_plot_item(self.obj)returnitem,0,2defsetup_shape(self,shape:AnnotatedRectangle)->None:"""Setup shape"""tool_setup_shape(shape,self.obj)classROICircleTool(CircleTool):"""ROI circle tool"""TITLE=_("Circular ROI")ICON="roi_new_circle.svg"def__init__(self,manager:PlotManager,obj:dlo.ImageObj)->None:super().__init__(manager,switch_to_default_tool=True,toolbar_id=None,setup_shape_cb=tool_setup_shape,)self.roi=CircularROI([0,0,1],True)self.obj=objself.activate=tool_activatedefcreate_shape(self)->tuple[AnnotatedCircle,int,int]:"""Create shape"""item=self.roi.to_plot_item(self.obj)returnitem,0,1defsetup_shape(self,shape:AnnotatedCircle)->None:"""Setup shape"""tool_setup_shape(shape,self.obj)classROIPolygonTool(PolygonTool):"""ROI polygon tool"""TITLE=_("Polygonal ROI")ICON="roi_new_polygon.svg"def__init__(self,manager:PlotManager,obj:dlo.ImageObj)->None:super().__init__(manager,switch_to_default_tool=True,toolbar_id=None,setup_shape_cb=tool_setup_shape,)self.roi=PolygonalROI([[0,0],[1,0],[1,1],[0,1]],True)self.obj=objself.activate=tool_activatedefcreate_shape(self)->tuple[AnnotatedPolygon,int,int]:"""Create shape"""returnself.roi.to_plot_item(self.obj)defsetup_shape(self,shape:AnnotatedPolygon)->None:"""Setup shape"""tool_setup_shape(shape,self.obj)TypeROIEditor=TypeVar("TypeROIEditor",bound="BaseROIEditor")classBaseROIEditorMeta(type(QW.QWidget),abc.ABCMeta):"""Mixed metaclass to avoid conflicts"""classBaseROIEditor(QW.QWidget,Generic[TypeObj,TypeROI,TypePlotItem,TypeROIItem],metaclass=BaseROIEditorMeta,):"""ROI Editor"""ICON_NAME=NoneOBJ_NAME=NoneROI_ITEM_TYPES=()def__init__(self,parent:PlotDialog,obj:TypeObj,extract:bool,item:TypePlotItem|None=None,)->None:super().__init__(parent)self.plot_dialog=parentparent.accepted.connect(self.dialog_accepted)self.plot=parent.get_plot()self.toolbar=QW.QToolBar(self)self.obj=objself.extract=extractself.__modified:bool|None=Noneself._tools:list[InteractiveTool]=[]roi=obj.roiifroiisNone:roi=self.get_obj_roi_class()()self.__roi:TypeROI=roifmt=obj.get_metadata_option("format")self.roi_items:list[TypeROIItem]=list(self.__roi.iterate_roi_items(obj,fmt,True,True))self.add_tools_to_plot_dialog()item=obj.make_item()ifitemisNoneelseitemitem.set_selectable(False)item.set_readonly(True)self.plot.add_item(item)forroi_iteminself.roi_items:self.plot.add_item(roi_item)self.plot.set_active_item(roi_item)self.remove_all_action:QW.QAction|None=Noneself.singleobj_btn:QW.QToolButton|None=Noneself.setup_widget()# force update of ROI titles and remove_all_btn stateself.items_changed(self.plot)self.plot.SIG_ITEMS_CHANGED.connect(self.items_changed)self.plot.SIG_ITEM_REMOVED.connect(self.item_removed)self.plot.SIG_RANGE_CHANGED.connect(lambda_rng,_min,_max:self.item_moved())self.plot.SIG_ANNOTATION_CHANGED.connect(lambda_plt:self.item_moved())# In "extract mode", the dialog box OK button should always been enabled# when at least one ROI is defined,# whereas in non-extract mode (when editing ROIs) the OK button is by default# disabled (until ROI data is modified)ifextract:self.modified=len(self.roi_items)>0else:self.modified=False@abc.abstractmethoddefget_obj_roi_class(self)->type[TypeROI]:"""Get object ROI class"""@abc.abstractmethoddefadd_tools_to_plot_dialog(self)->None:"""Add tools to plot dialog"""@propertydefmodified(self)->bool:"""Return dialog modified state"""returnself.__modified@modified.setterdefmodified(self,value:bool)->None:"""Set dialog modified state"""self.__modified=valueifself.extract:# In "extract mode", OK button is enabled when at least one ROI is definedvalue=valueandlen(self.roi_items)>0self.plot_dialog.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(value)defdialog_accepted(self)->None:"""Parent dialog was accepted: updating ROI Editor data"""self.__roi.empty()forroi_iteminself.roi_items:self.__roi.add_roi(plot_item_to_single_roi(roi_item))ifself.singleobj_btnisnotNone:singleobj=self.singleobj_btn.isChecked()self.__roi.singleobj=singleobjConf.proc.extract_roi_singleobj.set(singleobj)defget_roieditor_results(self)->tuple[TypeROI,bool]:"""Get ROI editor results Returns: A tuple containing the ROI data parameters and ROI modified state. ROI modified state is True if the ROI data has been modified within the dialog box. """returnself.__roi,self.modifieddefcreate_coordinate_based_roi_actions(self)->list[QW.QAction]:"""Create coordinate-based ROI actions"""return[]def__new_action_menu(self,title:str,icon:str,actions:list[QW.QAction])->QW.QAction:"""Create a new action menu"""menu=QW.QMenu(title)foractioninactions:menu.addAction(action)action=QW.QAction(get_icon(icon),title,self)action.setMenu(menu)returnactiondefcreate_actions(self)->list[QW.QAction]:"""Create actions"""g_menu_act=self.__new_action_menu(_("Graphical ROI"),"roi_graphical.svg",[tool.actionfortoolinself._tools],)c_menu_act=self.__new_action_menu(_("Coordinate-based ROI"),"roi_coordinate.svg",self.create_coordinate_based_roi_actions(),)self.remove_all_action=create_action(self,_("Remove all"),icon=get_icon("roi_delete.svg"),triggered=self.remove_all_rois,)return[g_menu_act,c_menu_act,None,self.remove_all_action]defsetup_widget(self)->None:"""Setup ROI editor widget"""layout=QW.QHBoxLayout()self.toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextUnderIcon)add_actions(self.toolbar,self.create_actions())foractioninself.toolbar.actions():ifaction.menu()isnotNone:widget=self.toolbar.widgetForAction(action)widget.setPopupMode(QW.QToolButton.ToolButtonPopupMode.InstantPopup)layout.addWidget(self.toolbar)ifself.extract:self.singleobj_btn=QW.QCheckBox(_("Extract all ROIs into a single %s object")%self.OBJ_NAME,self,)layout.addWidget(self.singleobj_btn)self.singleobj_btn.setChecked(self.__roi.singleobj)layout.addStretch()self.setLayout(layout)defremove_all_rois(self)->None:"""Remove all ROIs"""if(execenv.unattendedorQW.QMessageBox.question(self,_("Remove all ROIs"),_("Are you sure you want to remove all ROIs?"),)==QW.QMessageBox.Yes):self.plot.del_items(self.roi_items)@abc.abstractmethoddefupdate_roi_titles(self)->None:"""Update ROI annotation titles"""defupdate_roi_items(self)->None:"""Update ROI items"""old_nb_items=len(self.roi_items)self.roi_items=[itemforiteminself.plot.get_items()ifisinstance(item,self.ROI_ITEM_TYPES)]self.plot.select_some_items([])self.update_roi_titles()ifold_nb_items!=len(self.roi_items):self.modified=Truedefitems_changed(self,_plot:BasePlot)->None:"""Items have changed"""self.update_roi_items()self.remove_all_action.setEnabled(len(self.roi_items)>0)defitem_removed(self,item)->None:"""Item was removed. Since all items are read-only except ROIs... this must be an ROI."""assertiteminself.roi_itemsself.update_roi_items()self.modified=Truedefitem_moved(self)->None:"""ROI plot item has just been moved"""self.modified=TrueclassROIRangeInfo(ObjectInfo):"""ObjectInfo for ROI selection"""def__init__(self,roi_items)->None:self.roi_items=roi_itemsdefget_text(self)->str:textlist=[]forindex,roi_iteminenumerate(self.roi_items):x0,x1=roi_item.get_range()textlist.append(f"ROI{index:02d}: {x0} ≤ x ≤ {x1}")return"<br>".join(textlist)
[docs]classSignalROIEditor(BaseROIEditor[SignalObj,SignalROI,CurveItem,XRangeSelection]):"""Signal ROI Editor"""ICON_NAME="signal_roi.svg"OBJ_NAME=_("signal")ROI_ITEM_TYPES=(XRangeSelection,)
[docs]defget_obj_roi_class(self)->type[SignalROI]:"""Get object ROI class"""returnSignalROI
[docs]defadd_tools_to_plot_dialog(self)->None:"""Add tools to plot dialog"""mgr=self.plot_dialog.get_manager()segm_tool=mgr.add_tool(ROISegmentTool,self.obj)self._tools.append(segm_tool)
[docs]defcreate_coordinate_based_roi_actions(self)->list[QW.QAction]:"""Create coordinate-based ROI actions"""segcoord_act=create_action(self,_("Range ROI"),icon=get_icon("signal_roi.svg"),triggered=self.manually_add_roi,)return[segcoord_act]
[docs]defsetup_widget(self)->None:"""Setup ROI editor widget"""super().setup_widget()info=ROIRangeInfo(self.roi_items)info_label=make.info_label("BL",info,title=_("Regions of interest"))self.plot.add_item(info_label)self.info_label=info_label
[docs]defupdate_roi_titles(self):"""Update ROI annotation titles"""super().update_roi_titles()self.info_label.update_text()
[docs]classImageROIEditor(BaseROIEditor[ImageObj,ImageROI,MaskedImageItem,# `Union` is mandatory here for Python 3.9-3.10 compatibility:Union[AnnotatedPolygon,AnnotatedRectangle,AnnotatedCircle],]):"""Image ROI Editor"""ICON_NAME="image_roi.svg"OBJ_NAME=_("image")ROI_ITEM_TYPES=(AnnotatedRectangle,AnnotatedCircle,AnnotatedPolygon)
[docs]defget_obj_roi_class(self)->type[ImageROI]:"""Get object ROI class"""returnImageROI
[docs]defadd_tools_to_plot_dialog(self)->None:"""Add tools to plot dialog"""mgr=self.plot_dialog.get_manager()rect_tool=mgr.add_tool(ROIRectangleTool,self.obj)circ_tool=mgr.add_tool(ROICircleTool,self.obj)poly_tool=mgr.add_tool(ROIPolygonTool,self.obj)self._tools.extend([rect_tool,circ_tool,poly_tool])
[docs]defmanually_add_roi(self,roi_type:Literal["rectangle","circle","polygon"])->None:"""Manually add image ROI"""assertroi_typein("rectangle","circle","polygon")ifroi_type=="polygon":raiseNotImplementedError("Manual polygonal ROI creation is not supported")param=dlo.ROI2DParam()param.geometry=roi_typeifparam.edit(parent=self):roi:RectangularROI|CircularROI=param.to_single_roi(self.obj)shape=roi.to_plot_item(self.obj)configure_roi_item_in_tool(shape,self.obj)self.plot.add_item(shape)self.plot.set_active_item(shape)
[docs]defcreate_coordinate_based_roi_actions(self)->list[QW.QAction]:"""Create coordinate-based ROI actions"""rectcoord_act=create_action(self,_("Rectangular ROI"),icon=get_icon("roi_new_rectangle.svg"),triggered=lambda:self.manually_add_roi("rectangle"),)circcoord_act=create_action(self,_("Circular ROI"),icon=get_icon("roi_new_circle.svg"),triggered=lambda:self.manually_add_roi("circle"),)return[rectcoord_act,circcoord_act]
[docs]defsetup_widget(self)->None:"""Setup ROI editor widget"""super().setup_widget()item:MaskedImageItem=self.plot.get_items(item_type=IImageItemType)[0]item.set_mask_visible(False)
[docs]defupdate_roi_titles(self)->None:"""Update ROI annotation titles"""super().update_roi_titles()forindex,roi_iteminenumerate(self.roi_items):roi_item:AnnotatedRectangle|AnnotatedCircle|AnnotatedPolygonroi_item.annotationparam.title=f"ROI{index:02d}"roi_item.annotationparam.update_item(roi_item)