# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file."""Plot handler============The :mod:`cdl.core.gui.plothandler` module provides plot handlers for signaland image panels, that is, classes handling `PlotPy` plot items for representingsignals and images.Signal plot handler-------------------.. autoclass:: SignalPlotHandler :members: :inherited-members:Image plot handler------------------.. autoclass:: ImagePlotHandler :members: :inherited-members:"""# pylint: disable=invalid-name # Allows short reference names like x, y, ...from__future__importannotationsimporthashlibfromcollections.abcimportIteratorfromtypingimportTYPE_CHECKING,Callable,Generic,TypeVarfromweakrefimportWeakKeyDictionaryimportnumpyasnpfromplotpy.constantsimportPlotTypefromplotpy.itemsimportCurveItem,GridItem,LegendBoxItem,MaskedImageItemfromplotpy.plotimportPlotOptionsfromqtpyimportQtWidgetsasQWfromcdl.configimportConf,_fromcdl.core.model.baseimportTypeObj,TypePlotItemfromcdl.core.model.imageimportImageObjfromcdl.core.model.signalimportSignalObjfromcdl.utils.qthelpersimportblock_signals,create_progress_barifTYPE_CHECKING:fromplotpy.itemsimportLabelItemfromplotpy.plotimportBasePlot,PlotWidgetfromcdl.core.gui.panel.baseimportBaseDataPaneldefcalc_data_hash(obj:SignalObj|ImageObj)->str:"""Calculate a hash for a SignalObj | ImageObj object's data"""returnhashlib.sha1(np.ascontiguousarray(obj.data)).hexdigest()TypePlotHandler=TypeVar("TypePlotHandler",bound="BasePlotHandler")classBasePlotHandler(Generic[TypeObj,TypePlotItem]):"""Object handling plot items associated to objects (signals/images)"""PLOT_TYPE:PlotType|None=None# Replaced in subclassesdef__init__(self,panel:BaseDataPanel,plotwidget:PlotWidget,)->None:self.panel=panelself.plotwidget=plotwidgetself.plot=plotwidget.get_plot()# Plot items: key = object uuid, value = plot itemself.__plotitems:dict[str,TypePlotItem]={}self.__shapeitems=[]self.__cached_hashes:WeakKeyDictionary[TypeObj,list[int]]=(WeakKeyDictionary())self.__auto_refresh=Falseself.__show_first_only=Falseself.__result_items_mapping:WeakKeyDictionary[LabelItem,Callable]=(WeakKeyDictionary())def__len__(self)->int:"""Return number of items"""returnlen(self.__plotitems)def__getitem__(self,oid:str)->TypePlotItem:"""Return item associated to object uuid"""try:returnself.__plotitems[oid]exceptKeyErrorasexc:# Item does not exist: this may happen when "auto refresh" is disabled# (object has been added to model but the corresponding plot item has not# been created yet)ifnotself.__auto_refresh:self.refresh_plot("selected",True,force=True)returnself.__plotitems[oid]# Item does not exist and auto refresh is enabled: this should not happenraiseexcdefget(self,key:str,default:TypePlotItem|None=None)->TypePlotItem|None:"""Return item associated to object uuid. If the key is not found, default is returned if given, otherwise None is returned."""returnself.__plotitems.get(key,default)defget_obj_from_item(self,item:TypePlotItem)->TypeObj|None:"""Return object associated to plot item Args: item: plot item Returns: Object associated to plot item """forobjinself.panel.objmodel:ifself.get(obj.uuid)isitem:returnobjreturnNonedef__setitem__(self,oid:str,item:TypePlotItem)->None:"""Set item associated to object uuid"""self.__plotitems[oid]=itemdef__iter__(self)->Iterator[TypePlotItem]:"""Return an iterator over plothandler values (plot items)"""returniter(self.__plotitems.values())defremove_item(self,oid:str)->None:"""Remove plot item associated to object uuid"""try:item=self.__plotitems.pop(oid)exceptKeyErrorasexc:# Item does not exist: this may happen when "auto refresh" is disabled# (object has been added to model but the corresponding plot item has not# been created yet)ifnotself.__auto_refresh:return# Item does not exist and auto refresh is enabled: this should not happenraiseexcself.plot.del_item(item)defclear(self)->None:"""Clear plot items"""self.__plotitems={}self.cleanup_dataview()defadd_shapes(self,oid:str,do_autoscale:bool=False)->None:"""Add geometric shape items associated to computed results and annotations, for the object with the given uuid"""obj=self.panel.objmodel[oid]ifobj.metadata:items=list(obj.iterate_shape_items(editable=False))results=list(obj.iterate_resultproperties())+list(obj.iterate_resultshapes())forresultinresults:item=result.get_label_item(obj)ifitemisnotNone:items.append(item)self.__result_items_mapping[item]=(lambdaitem,rprop=result:rprop.update_obj_metadata_from_item(obj,item))ifitems:ifdo_autoscale:self.plot.do_autoscale()# Performance optimization: block `plotpy.plot.BasePlot` signals, add# all items except the last one, unblock signals, then add the last one# (this avoids some unnecessary refresh process by PlotPy)withblock_signals(self.plot,True):withcreate_progress_bar(self.panel,_("Creating geometric shapes"),max_=len(items)-1)asprogress:fori_item,iteminenumerate(items[:-1]):progress.setValue(i_item+1)ifprogress.wasCanceled():breakself.plot.add_item(item)self.__shapeitems.append(item)QW.QApplication.processEvents()self.plot.add_item(items[-1])self.__shapeitems.append(items[-1])defupdate_resultproperty_from_plot_item(self,item:LabelItem)->None:"""Update result property from plot item"""callback=self.__result_items_mapping.get(item)ifcallbackisnotNone:callback(item)defremove_all_shape_items(self)->None:"""Remove all geometric shapes associated to result items"""ifset(self.__shapeitems).issubset(set(self.plot.items)):self.plot.del_items(self.__shapeitems)self.__shapeitems=[]def__add_item_to_plot(self,oid:str)->TypePlotItem:"""Make plot item and add it to plot. Args: oid: object uuid Returns: Plot item """obj=self.panel.objmodel[oid]self.__cached_hashes[obj]=calc_data_hash(obj)item:TypePlotItem=obj.make_item()item.set_readonly(True)self[oid]=itemself.plot.add_item(item)returnitemdef__update_item_on_plot(self,oid:str,ref_item:TypePlotItem,just_show:bool=False)->None:"""Update plot item. Args: oid: object uuid ref_item: reference item just_show: if True, only show the item (do not update it, except regarding the reference item). Defaults to False. """ifnotjust_show:obj=self.panel.objmodel[oid]cached_hash=self.__cached_hashes.get(obj)new_hash=calc_data_hash(obj)data_changed=cached_hashisNoneorcached_hash!=new_hashself.__cached_hashes[obj]=new_hashobj.update_item(self[oid],data_changed=data_changed)self.update_item_according_to_ref_item(self[oid],ref_item)@staticmethoddefupdate_item_according_to_ref_item(item:TypePlotItem,ref_item:TypePlotItem)->None:# pylint: disable=unused-argument"""Update plot item according to reference item"""# For now, nothing to do here: it's only used for images (contrast)defset_auto_refresh(self,auto_refresh:bool)->None:"""Set auto refresh mode. Args: auto_refresh: if True, refresh plot items automatically """self.__auto_refresh=auto_refreshifauto_refresh:self.refresh_plot("selected")defset_show_first_only(self,show_first_only:bool)->None:"""Set show first only mode. Args: show_first_only: if True, show only the first selected item """self.__show_first_only=show_first_onlyifself.__auto_refresh:self.refresh_plot("selected")defreduce_shown_oids(self,oids:list[str])->list[str]:"""Reduce the number of shown objects to visible items only. The base implementation is to show only the first selected item if the option "Show first only" is enabled. Args: oids: list of object uuids Returns: Reduced list of object uuids """ifself.__show_first_only:returnoids[:1]returnoidsdefrefresh_plot(self,what:str,update_items:bool=True,force:bool=False)->None:"""Refresh plot. Args: what: string describing the objects to refresh. Valid values are "selected" (refresh the selected objects), "all" (refresh all objects), "existing" (refresh existing plot items), or an object uuid. update_items: if True, update the items. If False, only show the items (do not update them, except if the option "Use reference item LUT range" is enabled and more than one item is selected). Defaults to True. force: if True, force refresh even if auto refresh is disabled. Raises: ValueError: if `what` is not a valid value """ifnotself.__auto_refreshandnotforce:returnifwhat=="selected":# Refresh selected objectsoids=self.panel.objview.get_sel_object_uuids(include_groups=True)iflen(oids)==1:self.cleanup_dataview()self.remove_all_shape_items()foriteminself:ifitemisnotNone:item.hide()elifwhat=="existing":# Refresh existing objectsoids=self.__plotitems.keys()elifwhat=="all":# Refresh all objectsoids=self.panel.objmodel.get_object_ids()else:# Refresh a single object defined by its uuidoids=[what]try:# Check if this is a valid object uuidself.panel.objmodel.get_objects(oids)exceptKeyErrorasexc:raiseValueError(f"Invalid value for `what`: {what}")fromexc# Initialize titles and scales dictionariestitle_keys=("title","xlabel","ylabel","zlabel","xunit","yunit","zunit")titles_dict={}autoscale=Falsescale_keys=("xscalelog","xscalemin","xscalemax","yscalelog","yscalemin","yscalemax",)scales_dict={}ifoids:oids=self.reduce_shown_oids(oids)ref_item=Nonewithcreate_progress_bar(self.panel,_("Creating plot items"),max_=len(oids))asprogress:# Iterate over objectsfori_obj,oidinenumerate(oids):progress.setValue(i_obj+1)ifprogress.wasCanceled():breakobj=self.panel.objmodel[oid]# Collecting titles informationforkeyintitle_keys:title=getattr(obj,key,"")value=titles_dict.get(key)ifvalueisNone:titles_dict[key]=titleelifvalue!=title:titles_dict[key]=""# Collecting scales informationautoscale=autoscaleorobj.autoscaleforkeyinscale_keys:scale=getattr(obj,key,None)ifscaleisnotNone:cmp=minif"min"inkeyelsemaxscales_dict[key]=cmp(scales_dict.get(key,scale),scale)# Update or add item to plotitem=self.get(oid)ifitemisNone:item=self.__add_item_to_plot(oid)else:self.__update_item_on_plot(oid,ref_item=ref_item,just_show=notupdate_items)ifref_itemisNone:ref_item=itemifwhat!="existing"oritem.isVisible():self.plot.set_item_visible(item,True,replot=False)self.plot.set_active_item(item)item.unselect()# Add geometric shapesself.add_shapes(oid,do_autoscale=autoscale)self.plot.replot()else:# No object to refresh: clean up titlesforkeyintitle_keys:titles_dict[key]=""# Set titlestdict=titles_dicttdict["ylabel"]=(tdict["ylabel"],tdict.pop("zlabel"))tdict["yunit"]=(tdict["yunit"],tdict.pop("zunit"))self.plot.set_titles(**titles_dict)# Set scalesreplot=Falseforaxis_name,axisin(("bottom","x"),("left","y")):axis_id=self.plot.get_axis_id(axis_name)scalelog=scales_dict.get(f"{axis}scalelog")ifscalelogisnotNone:new_scale="log"ifscalelogelse"lin"self.plot.set_axis_scale(axis_id,new_scale,autoscale=False)replot=Trueifautoscale:self.plot.do_autoscale()else:foraxis_name,axisin(("bottom","x"),("left","y")):axis_id=self.plot.get_axis_id(axis_name)new_vmin=scales_dict.get(f"{axis}scalemin")new_vmax=scales_dict.get(f"{axis}scalemax")ifnew_vminisnotNoneornew_vmaxisnotNone:self.plot.do_autoscale(replot=False,axis_id=axis_id)vmin,vmax=self.plot.get_axis_limits(axis_id)new_vmin=new_vminifnew_vminisnotNoneelsevminnew_vmax=new_vmaxifnew_vmaxisnotNoneelsevmaxself.plot.set_axis_limits(axis_id,new_vmin,new_vmax)replot=Trueifreplot:self.plot.replot()defcleanup_dataview(self)->None:"""Clean up data view"""# Performance optimization: using `baseplot.BasePlot.del_items` instead of# `baseplot.BasePlot.del_item` (avoid emitting unnecessary signals)self.plot.del_items([itemforiteminself.plot.items[:]ifitemnotinselfandnotisinstance(item,(LegendBoxItem,GridItem))])defget_current_plot_options(self)->PlotOptions:"""Return standard signal/image plot options"""returnPlotOptions(type=self.PLOT_TYPE,xlabel=self.plot.get_axis_title("bottom"),ylabel=self.plot.get_axis_title("left"),xunit=self.plot.get_axis_unit("bottom"),yunit=self.plot.get_axis_unit("left"),)
[docs]classSignalPlotHandler(BasePlotHandler[SignalObj,CurveItem]):"""Object handling signal plot items, plot dialogs, plot options"""PLOT_TYPE=PlotType.CURVE
[docs]deftoggle_anti_aliasing(self,state:bool)->None:"""Toggle anti-aliasing Args: state: if True, enable anti-aliasing """self.plot.set_antialiasing(state)self.plot.replot()
[docs]defget_current_plot_options(self)->PlotOptions:"""Return standard signal/image plot options"""options=super().get_current_plot_options()options.curve_antialiasing=self.plot.antialiasedreturnoptions
[docs]@staticmethoddefupdate_item_according_to_ref_item(item:MaskedImageItem,ref_item:MaskedImageItem)->None:"""Update plot item according to reference item"""ifref_itemisnotNoneandConf.view.ima_ref_lut_range.get():item.set_lut_range(ref_item.get_lut_range())plot:BasePlot=item.plot()plot.update_colormap_axis(item)
[docs]defreduce_shown_oids(self,oids:list[str])->list[str]:"""Reduce the number of shown objects to visible items only. The base implementation is to show only the first selected item if the option "Show first only" is enabled. Args: oids: list of object uuids Returns: Reduced list of object uuids """oids=super().reduce_shown_oids(oids)# For Image View, we show only the last image (which is the highest z-order# plot item) if more than one image is selected, if last image has no# transparency and if the other images are all completely covered by the last# image.# TODO: [P4] Enhance this algorithm to handle more complex cases# (not sure it's worth it)iflen(oids)>1:# Get objects associated to the oidsobjs=self.panel.objmodel.get_objects(oids)# First condition is about the image transparencylast_obj=objs[-1]alpha_cond=(last_obj.metadata.get("alpha",1.0)==1.0andlast_obj.metadata.get("alpha_function",0)==0)ifalpha_cond:# Second condition is about the image size and positiongeom_cond=Trueforobjinobjs[:-1]:geom_cond=(geom_condandlast_obj.x0<=obj.x0andlast_obj.y0<=obj.y0andlast_obj.x0+last_obj.width>=obj.x0+obj.widthandlast_obj.y0+last_obj.height>=obj.y0+obj.height)ifnotgeom_cond:breakifgeom_cond:oids=oids[-1:]returnoids
[docs]defrefresh_plot(self,what:str,update_items:bool=True,force:bool=False)->None:"""Refresh plot. Args: what: string describing the objects to refresh. Valid values are "selected" (refresh the selected objects), "all" (refresh all objects), "existing" (refresh existing plot items), or an object uuid. update_items: if True, update the items. If False, only show the items (do not update them, except if the option "Use reference item LUT range" is enabled and more than one item is selected). Defaults to True. force: if True, force refresh even if auto refresh is disabled. Raises: ValueError: if `what` is not a valid value """super().refresh_plot(what=what,update_items=update_items,force=force)self.plotwidget.contrast.setVisible(Conf.view.show_contrast.get(True))
[docs]defcleanup_dataview(self)->None:"""Clean up data view"""forwidgetin(self.plotwidget.xcsw,self.plotwidget.ycsw):widget.hide()super().cleanup_dataview()
[docs]defget_current_plot_options(self)->PlotOptions:"""Return standard signal/image plot options"""options=super().get_current_plot_options()options.zlabel=self.plot.get_axis_title("right")options.zunit=self.plot.get_axis_unit("right")returnoptions