# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file."""DataLab Datasets"""# pylint: disable=invalid-name # Allows short reference names like x, y, ...from__future__importannotationsimportabcimportenumimportjsonimportsysfromcollections.abcimportCallable,Generator,IterablefromcopyimportdeepcopyfromtypingimportTYPE_CHECKING,Any,Generic,Iterator,Literal,Type,TypeVarimportguidata.datasetasgdsimportnumpyasnpimportpandasaspdfromguidata.configtoolsimportget_fontfromguidata.datasetimportupdate_datasetfromguidata.ioimportJSONReader,JSONWriterfromnumpyimportmafromplotpy.builderimportmakefromplotpy.ioimportload_items,save_itemsfromplotpy.itemsimport(AbstractLabelItem,AnnotatedPoint,AnnotatedSegment,AnnotatedShape,LabelItem,PolygonShape,)fromcdl.algorithmsimportcoordinatesfromcdl.algorithms.datatypesimportis_integer_dtypefromcdl.configimportPLOTPY_CONF,Conf,_ifTYPE_CHECKING:fromplotpy.itemsimport(AbstractShape,AnnotatedCircle,AnnotatedEllipse,AnnotatedPolygon,AnnotatedRectangle,CurveItem,Marker,MaskedImageItem,XRangeSelection,)fromplotpy.stylesimportAnnotationParam,ShapeParamROI_KEY="_roi_"ANN_KEY="_ann_"defdeepcopy_metadata(metadata:dict[str,Any])->dict[str,Any]:"""Deepcopy metadata, except keys starting with "_" (private keys) with the exception of "_roi_" and "_ann_" keys."""mdcopy=deepcopy(metadata)forkey,valueinmetadata.items():rshape=ResultShape.from_metadata_entry(key,value)ifrshapeisNoneandkey.startswith("_")andkeynotin(ROI_KEY,ANN_KEY):mdcopy.pop(key)returnmdcopy@enum.uniqueclassChoices(enum.Enum):"""Object associating an enum to guidata.dataset.ChoiceItem choices"""# Reimplement enum.Enum method as suggested by Python documentation:# https://docs.python.org/3/library/enum.html#enum.Enum._generate_next_value_# Here, it is only needed for ImageDatatypes (see core/model/image.py).# pylint: disable=unused-argument,no-self-argument,no-memberdef_generate_next_value_(name,start,count,last_values):returnname.lower()@classmethoddefget_choices(cls):"""Return tuple of (key, value) choices to be used as parameter of guidata.dataset.ChoiceItem"""returntuple((member,member.value)formemberincls)
[docs]classBaseProcParam(gds.DataSet):"""Base class for processing parameters"""def__init__(self,title=None,comment=None,icon=""):super().__init__(title,comment,icon)self.set_global_prop("data",min=None,max=None)
[docs]defapply_integer_range(self,vmin,vmax):# pylint: disable=unused-argument"""Do something in case of integer min-max range"""
[docs]defapply_float_range(self,vmin,vmax):# pylint: disable=unused-argument"""Do something in case of float min-max range"""
[docs]defset_from_datatype(self,dtype):"""Set min/max range from NumPy datatype"""ifis_integer_dtype(dtype):info=np.iinfo(dtype)self.apply_integer_range(info.min,info.max)else:info=np.finfo(dtype)self.apply_float_range(info.min,info.max)self.set_global_prop("data",min=info.min,max=info.max)
[docs]classUniformRandomParam(BaseRandomParam):"""Uniform-law random signal/image parameters"""
[docs]defapply_integer_range(self,vmin,vmax):"""Do something in case of integer min-max range"""self.vmin,self.vmax=vmin,vmax
vmin=gds.FloatItem("V<sub>min</sub>",default=-0.5,help=_("Uniform distribution lower bound"))vmax=gds.FloatItem("V<sub>max</sub>",default=0.5,help=_("Uniform distribution higher bound")).set_pos(col=1)
[docs]classNormalRandomParam(BaseRandomParam):"""Normal-law random signal/image parameters"""DEFAULT_RELATIVE_MU=0.1DEFAULT_RELATIVE_SIGMA=0.02
[docs]defapply_integer_range(self,vmin,vmax):"""Do something in case of integer min-max range"""delta=vmax-vminself.mu=int(self.DEFAULT_RELATIVE_MU*delta+vmin)self.sigma=int(self.DEFAULT_RELATIVE_SIGMA*delta)
mu=gds.FloatItem("μ",default=DEFAULT_RELATIVE_MU,help=_("Normal distribution mean"))sigma=gds.FloatItem("σ",default=DEFAULT_RELATIVE_SIGMA,help=_("Normal distribution standard deviation"),).set_pos(col=1)
[docs]@enum.uniqueclassShapeTypes(enum.Enum):"""Shape types for image metadata"""# Reimplement enum.Enum method as suggested by Python documentation:# https://docs.python.org/3/library/enum.html#enum.Enum._generate_next_value_# pylint: disable=unused-argument,no-self-argument,no-memberdef_generate_next_value_(name,start,count,last_values):returnf"_{name.lower()[:3]}_"#: Rectangle shapeRECTANGLE=enum.auto()#: Circle shapeCIRCLE=enum.auto()#: Ellipse shapeELLIPSE=enum.auto()#: Segment shapeSEGMENT=enum.auto()#: Marker shapeMARKER=enum.auto()#: Point shapePOINT=enum.auto()#: Polygon shapePOLYGON=enum.auto()
defconfig_annotated_shape(item:AnnotatedShape,fmt:str,lbl:bool,section:str|None=None,option:str|None=None,show_computations:bool|None=None,):"""Configurate annotated shape. Args: item: Annotated shape item fmt: Format string lbl: Show label section: Shape style section (e.g. "plot") option: Shape style option (e.g. "shape/drag") show_computations: Show computations """param:AnnotationParam=item.annotationparamparam.format=fmtparam.show_label=lblifshow_computationsisnotNone:param.show_computations=show_computationsifisinstance(item,AnnotatedSegment):item.label.labelparam.anchor="T"item.label.labelparam.update_item(item.label)param.update_item(item)ifsectionisnotNoneandoptionisnotNone:item.set_style(section,option)# TODO: [P3] Move this function as a method of plot items in PlotPydefset_plot_item_editable(item:AbstractShape|AbstractLabelItem|AnnotatedShape,state):"""Set plot item editable state. Args: item: Plot item state: State """item.set_movable(state)item.set_resizable(stateandnotisinstance(item,AbstractLabelItem))item.set_rotatable(stateandnotisinstance(item,AbstractLabelItem))item.set_readonly(notstate)item.set_selectable(state)classBaseResult(abc.ABC):"""Base class for results, i.e. objects returned by computation functions used by :py:class`cdl.core.gui.processor.base.BaseProcessor.compute_10` method. Args: title: result title category: result category array: result array (one row per ROI, first column is ROI index) labels: result labels (one label per column of result array) """PREFIX=""# To be overriden in children classesMETADATA_ATTRS=()# To be overriden in children classesdef__init__(self,title:str,array:np.ndarray,labels:list[str]|None=None,)->None:assertisinstance(title,str)self.title=titleself.array=arrayself.xunit:str=""self.yunit:str=""self.__labels=labelsself.check_array()@property@abc.abstractmethoddefcategory(self)->str:"""Return result category"""defcheck_array(self)->None:"""Check if array attribute is valid Raises: AssertionError: invalid array """# Allow to pass a list of lists or a NumPy array.# For instance, the following are equivalent:# array = [[1, 2], [3, 4]]# array = np.array([[1, 2], [3, 4]])# Or, for only one line (one single result), the following are equivalent:# array = [1, 2]# array = [[1, 2]]# array = np.array([[1, 2]])ifisinstance(self.array,(list,tuple)):ifisinstance(self.array[0],(list,tuple)):self.array=np.array(self.array)else:self.array=np.array([self.array])assertisinstance(self.array,np.ndarray)assertlen(self.array.shape)==2@propertydeflabels(self)->list[str]|None:"""Return result labels (one label per column of result array)"""returnself.__labels@propertydefheaders(self)->list[str]|None:"""Return result headers (one header per column of result array)"""# Default implementation: return labelsreturnself.__labelsdefto_dataframe(self)->pd.DataFrame:"""Return DataFrame from properties array"""returnpd.DataFrame(self.shown_array,columns=list(self.headers))@property@abc.abstractmethoddefshown_array(self)->np.ndarray:"""Return array of shown results, i.e. including complementary array (if any) Returns: Array of shown results """@propertydefraw_data(self):"""Return raw data (array without ROI informations)"""returnself.array[:,1:]@propertydefkey(self)->str:"""Return metadata key associated to result"""returnself.PREFIX+self.title@classmethoddeffrom_metadata_entry(cls,key:str,value:dict[str,Any])->BaseResult|None:"""Create metadata shape object from (key, value) metadata entry"""if(isinstance(key,str)andkey.startswith(cls.PREFIX)andisinstance(value,dict)):try:title=key[len(cls.PREFIX):]instance=cls(title,**value)returninstanceexcept(ValueError,TypeError):passreturnNone@classmethoddefmatch(cls,key,value)->bool:"""Return True if metadata dict entry (key, value) is a metadata result"""returncls.from_metadata_entry(key,value)isnotNonedefadd_to(self,obj:BaseObj)->None:"""Add result to object metadata Args: obj: object (signal/image) """self.set_obj_metadata(obj)defset_obj_metadata(self,obj:BaseObj)->None:"""Set object metadata with properties Args: obj: object """obj.metadata[self.key]={key:getattr(self,key)forkeyinself.METADATA_ATTRS}
[docs]classResultProperties(BaseResult):"""Object representing properties serializable in signal/image metadata. Result `array` is a NumPy 2-D array: each row is a list of properties, optionnally associated to a ROI (first column value). ROI index is starting at 0 (or is simply 0 if there is no ROI). Args: title: properties title array: properties array labels: properties labels (one label per column of result array) item_json: JSON string of label item associated to this obj .. note:: The `array` argument can be a list of lists or a NumPy array. For instance, the following are equivalent: - ``array = [[1, 2], [3, 4]]`` - ``array = np.array([[1, 2], [3, 4]])`` Or for only one line (one single result), the following are equivalent: - ``array = [1, 2]`` - ``array = [[1, 2]]`` - ``array = np.array([[1, 2]])`` """PREFIX="_properties_"METADATA_ATTRS=("array","labels","item_json")def__init__(self,title:str,array:np.ndarray,labels:list[str]|None,item_json:str="",)->None:super().__init__(title,array,labels)iflabelsisnotNone:assertlen(labels)==self.array.shape[1]-1self.item_json=item_json# JSON string of label item associated to this obj@propertydefcategory(self)->str:"""Return result category"""return_("Properties")+f" | {self.title}"@propertydefheaders(self)->list[str]|None:"""Return result headers (one header per column of result array)"""# ResultProperties implementation: return labels without units or "=" signreturn[label.split("=")[0].strip()forlabelinself.labels]@propertydefshown_array(self)->np.ndarray:"""Return array of shown results, i.e. including complementary array (if any) Returns: Array of shown results """returnself.raw_data
@propertydeflabel_contents(self)->tuple[tuple[int,str],...]:"""Return label contents, i.e. a tuple of couples of (index, text) where index is the column of raw_data and text is the associated label format string"""returntuple(enumerate(self.labels))
[docs]defcreate_label_item(self,obj:BaseObj)->LabelItem|None:"""Create label item Args: obj: object (signal/image) Returns: Label item .. note:: The signal or image object is required as argument to create the label item because the label text may contain format strings that need to be filled with the object properties. For instance, the label text may contain the signal or image units. """text=""fori_rowinrange(self.array.shape[0]):suffix=f"|ROI{i_row}"ifi_row>0else""text+=f"<u>{self.title}{suffix}</u>:"fori_col,labelinself.label_contents:# "label" may contains "<" and ">" characters which are interpreted# as HTML tags by the LabelItem. We must escape them.label=label.replace("<","<").replace(">",">")if"%"notinlabel:label+=" = %g"text+=("<br>"+label.strip().format(obj)%self.shown_array[i_row,i_col])ifi_row<self.shown_array.shape[0]-1:text+="<br><br>"item=make.label(text,"TL",(0,0),"TL",title=self.title)font=get_font(PLOTPY_CONF,"properties","label/font")item.set_style("properties","label")item.labelparam.font.update_param(font)item.labelparam.update_item(item)returnitem
[docs]defget_label_item(self,obj:BaseObj)->LabelItem|None:"""Return label item associated to this result Args: obj: object (signal/image) Returns: Label item .. note:: The signal or image object is required as argument to eventually create the label item if it has not been created yet. See :py:meth:`create_label_item`. """ifnotself.item_json:# Label item has not been created yetitem=self.create_label_item(obj)ifitemisnotNone:self.update_obj_metadata_from_item(obj,item)ifself.item_json:item=json_to_items(self.item_json)[0]assertisinstance(item,LabelItem)returnitemreturnNone
[docs]classResultShape(ResultProperties):"""Object representing a geometrical shape serializable in signal/image metadata. Result `array` is a NumPy 2-D array: each row is a result, optionnally associated to a ROI (first column value). ROI index is starting at 0 (or is simply 0 if there is no ROI). Args: title: result shape title array: shape coordinates (multiple shapes: one shape per row), first column is ROI index (0 if there is no ROI) shape: shape kind item_json: JSON string of label item associated to this obj add_label: if True, add a label item (and the geometrical shape) to plot (default to False) Raises: AssertionError: invalid argument .. note:: The `array` argument can be a list of lists or a NumPy array. For instance, the following are equivalent: - ``array = [[1, 2], [3, 4]]`` - ``array = np.array([[1, 2], [3, 4]])`` Or for only one line (one single result), the following are equivalent: - ``array = [1, 2]`` - ``array = [[1, 2]]`` - ``array = np.array([[1, 2]])`` """PREFIX="_shapes_"METADATA_ATTRS=("array","shape","item_json","add_label")def__init__(self,title:str,array:np.ndarray,shape:Literal["rectangle","circle","ellipse","segment","marker","point","polygon"],item_json:str="",add_label:bool=False,)->None:self.shape=shapetry:self.shapetype=ShapeTypes[shape.upper()]exceptKeyErrorasexc:raiseValueError(f"Invalid shapetype {shape}")fromexcself.add_label=add_labelsuper().__init__(title,array,labels=None,item_json=item_json)@propertydefcategory(self)->str:"""Return result category"""returnself.shape.upper()
[docs]defcheck_array(self)->None:"""Check if array attribute is valid Raises: AssertionError: invalid array """super().check_array()ifself.shapetypeisShapeTypes.POLYGON:# Polygon is a special case: the number of data columns is variable# (2 columns per point). So we only check if the number of columns# is odd, which means that the first column is the ROI index, followed# by an even number of data columns (flattened x, y coordinates).assertself.array.shape[1]%2==1else:data_colnb=len(self.__get_coords_labels())# `data_colnb` is the number of data columns depends on the shape type,# not counting the ROI index, hence the +1 in the following assertionassertself.array.shape[1]==data_colnb+1
def__get_coords_labels(self)->tuple[str]:"""Return shape coordinates labels Returns: Shape coordinates labels """ifself.shapetypeisShapeTypes.POLYGON:labels=[]foriinrange(0,self.array.shape[1]-1,2):labels+=[f"x{i//2}",f"y{i//2}"]returntuple(labels)try:return{ShapeTypes.MARKER:("x","y"),ShapeTypes.POINT:("x","y"),ShapeTypes.RECTANGLE:("x0","y0","x1","y1"),ShapeTypes.CIRCLE:("x","y","r"),ShapeTypes.SEGMENT:("x0","y0","x1","y1"),ShapeTypes.ELLIPSE:("x","y","a","b","θ"),}[self.shapetype]exceptKeyErrorasexc:raiseNotImplementedError(f"Unsupported shapetype {self.shapetype}")fromexcdef__get_complementary_xlabels(self)->tuple[str]|None:"""Return complementary labels for result array columns Returns: Complementary labels for result array columns, or None if there is no complementary labels """ifself.shapetypeisShapeTypes.SEGMENT:return("L","Xc","Yc")ifself.shapetypein(ShapeTypes.CIRCLE,ShapeTypes.ELLIPSE):return("A",)returnNonedef__get_complementary_array(self)->np.ndarray|None:"""Return the complementary array of results, e.g. the array of lengths for a segment result shape, or the array of areas for a circle result shape Returns: Complementary array of results, or None if there is no complementary array """array=self.arrayifself.shapetypeisShapeTypes.SEGMENT:dx1,dy1=array[:,3]-array[:,1],array[:,4]-array[:,2]length=np.linalg.norm(np.vstack([dx1,dy1]).T,axis=1)xc=(array[:,1]+array[:,3])/2yc=(array[:,2]+array[:,4])/2returnnp.vstack([length,xc,yc]).Tifself.shapetypeisShapeTypes.CIRCLE:area=np.pi*array[:,3]**2returnarea.reshape(-1,1)ifself.shapetypeisShapeTypes.ELLIPSE:area=np.pi*array[:,3]*array[:,4]returnarea.reshape(-1,1)returnNone@propertydefheaders(self)->list[str]|None:"""Return result headers (one header per column of result array)"""labels=self.__get_coords_labels()+(self.__get_complementary_xlabels()or())returnlabels[-self.shown_array.shape[1]:]@propertydefshown_array(self)->np.ndarray:"""Return array of shown results, i.e. including complementary array (if any) Returns: Array of shown results """comp_array=self.__get_complementary_array()ifcomp_arrayisNone:returnself.raw_datareturnnp.hstack([self.raw_data,comp_array])@propertydeflabel_contents(self)->tuple[tuple[int,str],...]:"""Return label contents, i.e. a tuple of couples of (index, text) where index is the column of raw_data and text is the associated label format string"""contents=[]foridx,lblinenumerate(self.__get_complementary_xlabels()):contents.append((idx+self.raw_data.shape[1],lbl))returntuple(contents)
[docs]defmerge_with(self,obj:BaseObj,other_obj:BaseObj|None=None):"""Merge object resultshape with another's: obj <-- other_obj or simply merge this resultshape with obj if other_obj is None"""ifother_objisNone:other_obj=objother_value=other_obj.metadata.get(self.key)ifother_valueisnotNone:other=ResultShape.from_metadata_entry(self.key,other_value)assertotherisnotNoneother_array=np.array(other.array,copy=True)other_array[:,0]+=self.array[-1,0]+1# Adding ROI index offsetifother_array.shape[1]!=self.array.shape[1]:# This can only happen if the shape is a polygonassertself.shapetypeisShapeTypes.POLYGON# We must padd the array with NaNsmax_colnb=max(self.array.shape[1],other_array.shape[1])new_array=np.full((self.array.shape[0]+other_array.shape[0],max_colnb),np.nan)new_array[:self.array.shape[0],:self.array.shape[1]]=self.arraynew_array[self.array.shape[0]:,:other_array.shape[1]]=other_arrayself.array=new_arrayelse:self.array=np.vstack([self.array,other_array])self.add_to(obj)
[docs]deftransform_coordinates(self,func:Callable[[np.ndarray],None])->None:"""Transform shape coordinates. Args: func: function to transform coordinates """ifself.shapetypein(ShapeTypes.MARKER,ShapeTypes.POINT,ShapeTypes.POLYGON,ShapeTypes.RECTANGLE,ShapeTypes.SEGMENT,):func(self.raw_data)elifself.shapetypeisShapeTypes.CIRCLE:coords=coordinates.array_circle_to_diameter(self.raw_data)func(coords)self.raw_data[:]=coordinates.array_circle_to_center_radius(coords)elifself.shapetypeisShapeTypes.ELLIPSE:coords=coordinates.array_ellipse_to_diameters(self.raw_data)func(coords)self.raw_data[:]=coordinates.array_ellipse_to_center_axes_angle(coords)else:raiseNotImplementedError(f"Unsupported shapetype {self.shapetype}")
[docs]defiterate_plot_items(self,fmt:str,lbl:bool,option:Literal["s","i"])->Iterable:"""Iterate over metadata shape plot items. Args: fmt: numeric format (e.g. "%.3f") lbl: if True, show shape labels option: shape style option ("s" for signal, "i" for image) Yields: Plot item """forcoordsinself.raw_data:yieldself.create_shape_item(coords,fmt,lbl,option)
[docs]defcreate_shape_item(self,coords:np.ndarray,fmt:str,lbl:bool,option:Literal["s","i"])->(AnnotatedPoint|Marker|AnnotatedRectangle|AnnotatedCircle|AnnotatedSegment|AnnotatedEllipse|PolygonShape|None):"""Make geometrical shape plot item adapted to the shape type. Args: coords: shape data fmt: numeric format (e.g. "%.3f") lbl: if True, show shape labels option: shape style option ("s" for signal, "i" for image) Returns: Plot item """ifself.shapetypeisShapeTypes.MARKER:x0,y0=coordsitem=self.__make_marker_item(x0,y0,fmt)elifself.shapetypeisShapeTypes.POINT:x0,y0=coordsitem=AnnotatedPoint(x0,y0)sparam:ShapeParam=item.shape.shapeparamsparam.symbol.marker="Ellipse"sparam.symbol.size=6sparam.sel_symbol.marker="Ellipse"sparam.sel_symbol.size=6aparam=item.annotationparamaparam.title=self.titlesparam.update_item(item.shape)aparam.update_item(item)elifself.shapetypeisShapeTypes.RECTANGLE:x0,y0,x1,y1=coordsitem=make.annotated_rectangle(x0,y0,x1,y1,title=self.title)elifself.shapetypeisShapeTypes.CIRCLE:xc,yc,r=coordsx0,y0,x1,y1=coordinates.circle_to_diameter(xc,yc,r)item=make.annotated_circle(x0,y0,x1,y1,title=self.title)elifself.shapetypeisShapeTypes.SEGMENT:x0,y0,x1,y1=coordsitem=make.annotated_segment(x0,y0,x1,y1,title=self.title)elifself.shapetypeisShapeTypes.ELLIPSE:xc,yc,a,b,t=coordscoords=coordinates.ellipse_to_diameters(xc,yc,a,b,t)x0,y0,x1,y1,x2,y2,x3,y3=coordsitem=make.annotated_ellipse(x0,y0,x1,y1,x2,y2,x3,y3,title=self.title)elifself.shapetypeisShapeTypes.POLYGON:x,y=coords[::2],coords[1::2]item=make.polygon(x,y,title=self.title,closed=False)else:print(f"Warning: unsupported item {self.shapetype}",file=sys.stderr)returnNoneifisinstance(item,AnnotatedShape):config_annotated_shape(item,fmt,lbl,"results",option)set_plot_item_editable(item,False)returnitem
def__make_marker_item(self,x0:float,y0:float,fmt:str)->Marker:"""Make marker item Args: x0: x coordinate y0: y coordinate fmt: numeric format (e.g. '%.3f') """ifnp.isnan(x0):mstyle="-"deflabel(x,y):# pylint: disable=unused-argumentreturn(self.title+": "+fmt)%yelifnp.isnan(y0):mstyle="|"deflabel(x,y):# pylint: disable=unused-argumentreturn(self.title+": "+fmt)%xelse:mstyle="+"txt=self.title+": ("+fmt+", "+fmt+")"deflabel(x,y):returntxt%(x,y)returnmake.marker(position=(x0,y0),markerstyle=mstyle,label_cb=label,linestyle="DashLine",color="yellow",)
defconfigure_roi_item(item,fmt:str,lbl:bool,editable:bool,option:Literal["s","i"],):"""Configure ROI plot item. Args: item: plot item fmt: numeric format (e.g. "%.3f") lbl: if True, show shape labels editable: if True, make shape editable option: shape style option ("s" for signal, "i" for image) Returns: Plot item """option+="/"+("editable"ifeditableelse"readonly")ifnoteditable:ifisinstance(item,AnnotatedShape):config_annotated_shape(item,fmt,lbl,"roi",option,show_computations=editable)item.set_movable(False)item.set_resizable(False)item.set_readonly(True)item.set_style("roi",option)returnitemdefitems_to_json(items:list)->str|None:"""Convert plot items to JSON string. Args: items: list of plot items Returns: JSON string or None if items is empty """ifitems:writer=JSONWriter(None)save_items(writer,items)returnwriter.get_json(indent=4)returnNonedefjson_to_items(json_str:str|None)->list:"""Convert JSON string to plot items. Args: json_str: JSON string or None Returns: List of plot items """items=[]ifjson_str:try:foriteminload_items(JSONReader(json_str)):items.append(item)exceptjson.decoder.JSONDecodeError:passreturnitemsTypeSingleROI=TypeVar("TypeSingleROI",bound="BaseSingleROI")TypeROI=TypeVar("TypeROI",bound="BaseROI")TypeROIParam=TypeVar("TypeROIParam",bound="BaseROIParam")TypeObj=TypeVar("TypeObj",bound="BaseObj")TypePlotItem=TypeVar("TypePlotItem",bound="CurveItem | MaskedImageItem")TypeROIItem=TypeVar("TypeROIItem",bound="XRangeSelection | AnnotatedPolygon | AnnotatedRectangle | AnnotatedCircle",)classBaseROIParamMeta(abc.ABCMeta,gds.DataSetMeta):"""Mixed metaclass to avoid conflicts"""classBaseROIParam(gds.DataSet,Generic[TypeObj,TypeSingleROI],metaclass=BaseROIParamMeta):"""Base class for ROI parameters"""@abc.abstractmethoddefto_single_roi(self,obj:TypeObj,title:str="")->TypeSingleROI:"""Convert parameters to single ROI Args: obj: object (signal/image) title: ROI title Returns: Single ROI """classBaseSingleROI(Generic[TypeObj,TypeROIParam,TypeROIItem],abc.ABC):"""Base class for single ROI Args: coords: ROI edge (physical coordinates for signal) indices: if True, coords are indices (pixels) instead of physical coordinates title: ROI title """def__init__(self,coords:np.ndarray,indices:bool,title:str="ROI")->None:self.coords=np.array(coords,intifindiceselsefloat)self.indices=indicesself.title=titleself.check_coords()def__eq__(self,other:BaseSingleROI)->bool:"""Test equality with another single ROI"""return(np.array_equal(self.coords,other.coords)andself.indices==other.indices)defget_physical_coords(self,obj:TypeObj)->np.ndarray:"""Return physical coords Args: obj: object (signal/image) Returns: Physical coords """ifself.indices:returnobj.indices_to_physical(self.coords)returnself.coordsdefget_indices_coords(self,obj:TypeObj)->np.ndarray:"""Return indices coords Args: obj: object (signal/image) Returns: Indices coords """ifself.indices:returnself.coordsreturnobj.physical_to_indices(self.coords)defset_indices_coords(self,obj:TypeObj,coords:np.ndarray)->None:"""Set indices coords Args: obj: object (signal/image) coords: indices coords """ifself.indices:self.coords=coordselse:self.coords=obj.indices_to_physical(coords)@abc.abstractmethoddefcheck_coords(self)->None:"""Check if coords are valid Raises: ValueError: invalid coords """@abc.abstractmethoddefto_mask(self,obj:TypeObj)->np.ndarray:"""Create mask from ROI Args: obj: signal or image object Returns: Mask (boolean array where True values are inside the ROI) """@abc.abstractmethoddefto_param(self,obj:TypeObj,title:str|None=None)->TypeROIParam:"""Convert ROI to parameters Args: obj: object (signal/image), for physical-indices coordinates conversion title: ROI title """@abc.abstractmethoddefto_plot_item(self,obj:TypeObj,title:str|None=None)->TypeROIItem:"""Make ROI plot item from ROI. Args: obj: object (signal/image), for physical-indices coordinates conversion title: ROI title Returns: Plot item """@classmethod@abc.abstractmethoddeffrom_plot_item(cls:Type[TypeSingleROI],item:AbstractShape)->TypeSingleROI:"""Create single ROI from plot item Args: item: plot item Returns: Single ROI """defto_dict(self)->dict:"""Convert ROI to dictionary Returns: Dictionary """return{"coords":self.coords,"indices":self.indices,"title":self.title,"type":type(self).__name__,}@classmethoddeffrom_dict(cls:Type[TypeSingleROI],dictdata:dict)->TypeSingleROI:"""Convert dictionary to ROI Args: dictdata: dictionary Returns: ROI """returncls(dictdata["coords"],dictdata["indices"],dictdata["title"])classBaseROI(Generic[TypeObj,TypeSingleROI,TypeROIParam,TypeROIItem],abc.ABC):"""Abstract base class for ROIs (Regions of Interest) Args: singleobj: if True, when extracting data defined by ROIs, only one object is created (default to True). If False, one object is created per single ROI. If None, the value is get from the user configuration inverse: if True, ROI is outside the region of interest """PREFIX=""# This is overriden in children classesdef__init__(self,singleobj:bool|None=None,inverse:bool=False)->None:self.single_rois:list[TypeSingleROI]=[]ifsingleobjisNone:singleobj=Conf.proc.extract_roi_singleobj.get()self.singleobj=singleobjself.inverse=inverse@staticmethod@abc.abstractmethoddefget_compatible_single_roi_classes()->list[Type[BaseSingleROI]]:"""Return compatible single ROI classes"""def__len__(self)->int:"""Return number of ROIs"""returnlen(self.single_rois)def__iter__(self)->Iterator[TypeSingleROI]:"""Iterate over single ROIs"""returniter(self.single_rois)defget_single_roi(self,index:int)->TypeSingleROI:"""Return single ROI at index Args: index: ROI index """returnself.single_rois[index]defis_empty(self)->bool:"""Return True if no ROI is defined"""returnlen(self)==0@classmethoddefcreate(cls:Type[BaseROI],single_roi:TypeSingleROI)->TypeROI:"""Create Regions of Interest object from a single ROI. Args: single_roi: single ROI Returns: Regions of Interest object """roi=cls()roi.add_roi(single_roi)returnroidefcopy(self)->TypeROI:"""Return a copy of ROIs"""returndeepcopy(self)defempty(self)->None:"""Empty ROIs"""self.single_rois.clear()defadd_roi(self,roi:TypeSingleROI|TypeROI)->None:"""Add ROI. Args: roi: ROI Raises: TypeError: if roi type is not supported (not a single ROI or a ROI) ValueError: if `singleobj` or `inverse` values are incompatible """ifisinstance(roi,BaseSingleROI):self.single_rois.append(roi)elifisinstance(roi,BaseROI):self.single_rois.extend(roi.single_rois)ifroi.singleobj!=self.singleobj:raiseValueError("Incompatible `singleobj` values")ifroi.inverse!=self.inverse:raiseValueError("Incompatible `inverse` values")else:raiseTypeError(f"Unsupported ROI type: {type(roi)}")@abc.abstractmethoddefto_mask(self,obj:TypeObj)->np.ndarray[bool]:"""Create mask from ROI Args: obj: signal or image object Returns: Mask (boolean array where True values are inside the ROI) """defto_params(self,obj:TypeObj,title:str|None=None)->TypeROIParam|gds.DataSetGroup:"""Convert ROIs to group of parameters Args: obj: object (signal/image), for physical to pixel conversion title: group title """returngds.DataSetGroup([iroi.to_param(obj,f"ROI{idx:02d}")foridx,iroiinenumerate(self)],title=_("Regions of interest")iftitleisNoneelsetitle,)@classmethoddeffrom_params(cls:Type[BaseROI],obj:TypeObj,params:TypeROIParam|gds.DataSetGroup)->TypeROI:"""Create ROIs from parameters Args: obj: object (signal/image) params: ROI parameters Returns: ROIs """roi=cls()ifisinstance(params,gds.DataSetGroup):forparaminparams.datasets:param:TypeROIParamroi.add_roi(param.to_single_roi(obj))else:roi.add_roi(params.to_single_roi(obj))returnroidefiterate_roi_items(self,obj:TypeObj,fmt:str,lbl:bool,editable:bool=True)->Iterator[TypeROIItem]:"""Iterate over ROI plot items associated to each single ROI composing the object. Args: obj: object (signal/image), for physical-indices coordinates conversion fmt: format string lbl: if True, add label editable: if True, ROI is editable Yields: Plot item """forindex,single_roiinenumerate(self):title="ROI"ifindexisNoneelsef"ROI{index:02d}"roi_item=single_roi.to_plot_item(obj,title)yieldconfigure_roi_item(roi_item,fmt,lbl,editable,option=self.PREFIX)defto_dict(self)->dict:"""Convert ROIs to dictionary Returns: Dictionary """return{"singleobj":self.singleobj,"inverse":self.inverse,"single_rois":[roi.to_dict()forroiinself.single_rois],}@classmethoddeffrom_dict(cls:Type[TypeROI],dictdata:dict)->TypeROI:"""Convert dictionary to ROIs Args: dictdata: dictionary Returns: ROIs """instance=cls()instance.singleobj=dictdata["singleobj"]instance.inverse=dictdata["inverse"]instance.single_rois=[]forsingle_roiindictdata["single_rois"]:forsingle_roi_classininstance.get_compatible_single_roi_classes():ifsingle_roi["type"]==single_roi_class.__name__:instance.single_rois.append(single_roi_class.from_dict(single_roi))breakelse:raiseValueError(f"Unsupported single ROI type: {single_roi['type']}")returninstanceclassBaseObjMeta(abc.ABCMeta,gds.DataSetMeta):"""Mixed metaclass to avoid conflicts"""classBaseObj(Generic[TypeROI,TypePlotItem],metaclass=BaseObjMeta):"""Object (signal/image) interface"""PREFIX=""# This is overriden in children classesDEFAULT_FMT="s"# This is overriden in children classesCONF_FMT=Conf.view.sig_format# This is overriden in children classes# This is overriden in children classes with a gds.DictItem instance:metadata:dict[str,Any]={}VALID_DTYPES=()def__init__(self):self.__onb=0self.__roi_changed:bool|None=Noneself.__metadata_options:dict[str,Any]|None=Noneself._maskdata_cache:np.ndarray|None=Noneself.reset_metadata_to_defaults()@staticmethod@abc.abstractmethoddefget_roi_class()->Type[TypeROI]:"""Return ROI class"""@propertydefnumber(self)->int:"""Return object number (used for short ID)"""returnself.__onb@number.setterdefnumber(self,onb:int)->None:"""Set object number (used for short ID). Args: onb: object number """self.__onb=onb@propertydefshort_id(self)->str:"""Short object ID"""returnf"{self.PREFIX}{self.__onb:03d}"@property@abc.abstractmethoddefdata(self):"""Data"""@classmethoddefget_valid_dtypenames(cls)->list[str]:"""Get valid data type names Returns: Valid data type names supported by this class """return[dtnamefordtnameinnp.sctypeDictifdtnamein(dtype.__name__fordtypeincls.VALID_DTYPES)]defcheck_data(self):"""Check if data is valid, raise an exception if that's not the case Raises: TypeError: if data type is not supported """ifself.dataisnotNone:ifself.data.dtypenotinself.VALID_DTYPES:raiseTypeError(f"Unsupported data type: {self.data.dtype}")defiterate_roi_indices(self)->Generator[int|None,None,None]:"""Iterate over object ROI indices (if there is no ROI, yield None)"""ifself.roiisNone:yieldNoneelse:yield fromrange(len(self.roi))@abc.abstractmethoddefget_data(self,roi_index:int|None=None)->np.ndarray:""" Return original data (if ROI is not defined or `roi_index` is None), or ROI data (if both ROI and `roi_index` are defined). Args: roi_index: ROI index Returns: Data """@abc.abstractmethoddefcopy(self,title:str|None=None,dtype:np.dtype|None=None)->TypeObj:"""Copy object. Args: title: title dtype: data type Returns: Copied object """@abc.abstractmethoddefset_data_type(self,dtype):"""Change data type. Args: dtype: data type """@abc.abstractmethoddefmake_item(self,update_from:TypePlotItem|None=None)->TypePlotItem:"""Make plot item from data. Args: update_from: update Returns: Plot item """@abc.abstractmethoddefupdate_item(self,item:TypePlotItem,data_changed:bool=True)->None:"""Update plot item from data. Args: item: plot item data_changed: if True, data has changed """@abc.abstractmethoddefphysical_to_indices(self,coords:list)->np.ndarray:"""Convert coordinates from physical (real world) to (array) indices Args: coords: coordinates Returns: Indices """@abc.abstractmethoddefindices_to_physical(self,indices:np.ndarray)->list:"""Convert coordinates from (array) indices to physical (real world) Args: indices: indices Returns: Coordinates """defroi_has_changed(self)->bool:"""Return True if ROI has changed since last call to this method. The first call to this method will return True if ROI has not yet been set, or if ROI has been set and has changed since the last call to this method. The next call to this method will always return False if ROI has not changed in the meantime. Returns: True if ROI has changed """ifself.__roi_changedisNone:self.__roi_changed=Truereturned_value=self.__roi_changedself.__roi_changed=Falsereturnreturned_value@propertydefroi(self)->TypeROI|None:"""Return object regions of interest object. Returns: Regions of interest object """roidata=self.metadata.get(ROI_KEY)ifroidataisNone:returnNoneifnotisinstance(roidata,dict):# Old or unsupported format: remove itself.metadata.pop(ROI_KEY)returnNonereturnself.get_roi_class().from_dict(roidata)@roi.setterdefroi(self,roi:TypeROI|None)->None:"""Set object regions of interest. Args: roi: regions of interest object """ifroiisNone:ifROI_KEYinself.metadata:self.metadata.pop(ROI_KEY)else:self.metadata[ROI_KEY]=roi.to_dict()self.__roi_changed=True@propertydefmaskdata(self)->np.ndarray:"""Return masked data (areas outside defined regions of interest) Returns: Masked data """roi_changed=self.roi_has_changed()ifself.roiisNone:ifroi_changed:self._maskdata_cache=Noneelifroi_changedorself._maskdata_cacheisNone:self._maskdata_cache=self.roi.to_mask(self)returnself._maskdata_cachedefget_masked_view(self)->ma.MaskedArray:"""Return masked view for data Returns: Masked view """self.data:np.ndarrayview=self.data.view(ma.MaskedArray)view.mask=self.maskdatareturnviewdefinvalidate_maskdata_cache(self)->None:"""Invalidate mask data cache: force to rebuild it"""self._maskdata_cache=Nonedefiterate_resultshapes(self)->Iterable[ResultShape]:"""Iterate over object result shapes. Yields: Result shape """forkey,valueinself.metadata.items():ifResultShape.match(key,value):yieldResultShape.from_metadata_entry(key,value)defiterate_resultproperties(self)->Iterable[ResultProperties]:"""Iterate over object result properties. Yields: Result properties """forkey,valueinself.metadata.items():ifResultProperties.match(key,value):yieldResultProperties.from_metadata_entry(key,value)defdelete_results(self)->None:"""Delete all object results (shapes and properties)"""forkeyinlist(self.metadata.keys()):ifResultShape.match(key,self.metadata[key])orResultProperties.match(key,self.metadata[key]):self.metadata.pop(key)defupdate_resultshapes_from(self,other:TypeObj)->None:"""Update geometric shape from another object (merge metadata). Args: other: other object, from which to update this object """# The following code is merging the result shapes of the `other` object# with the result shapes of this object, but it is merging only the result# shapes of the same type (`mshape.key`). Thus, if the `other` object has# a result shape that is not present in this object, it will not be merged,# and we will have to add it to this object manually.formshapeinself.iterate_resultshapes():assertmshapeisnotNonemshape.merge_with(self,other)# Iterating on `other` object result shapes to find result shapes that are# not present in this object, and add them to this object.formshapeinother.iterate_resultshapes():assertmshapeisnotNoneifmshape.keynotinself.metadata:mshape.add_to(self)deftransform_shapes(self,orig,func,param=None):"""Apply transform function to result shape / annotations coordinates. Args: orig: original object func: transform function param: transform function parameter """deftransform(coords:np.ndarray):"""Transform coordinates"""ifparamisNone:func(self,orig,coords)else:func(self,orig,coords,param)formshapeinself.iterate_resultshapes():assertmshapeisnotNonemshape.transform_coordinates(transform)items=[]foriteminjson_to_items(self.annotations):ifisinstance(item,AnnotatedShape):transform(item.shape.points)item.set_label_position()elifisinstance(item,LabelItem):x,y=item.Gpoints=np.array([[x,y]],float)transform(points)x,y=points[0]item.set_pos(x,y)items.append(item)ifitems:self.annotations=items_to_json(items)def__set_annotations(self,annotations:str|None)->None:"""Set object annotations (JSON string describing annotation plot items) Args: annotations: JSON string describing annotation plot items, or None to remove annotations """ifannotationsisNone:ifANN_KEYinself.metadata:self.metadata.pop(ANN_KEY)else:self.metadata[ANN_KEY]=annotationsdef__get_annotations(self)->str:"""Get object annotations (JSON string describing annotation plot items)"""returnself.metadata.get(ANN_KEY,"")annotations=property(__get_annotations,__set_annotations)defadd_annotations_from_items(self,items:list)->None:"""Add object annotations (annotation plot items). Args: items: annotation plot items """ann_items=json_to_items(self.annotations)ann_items.extend(items)ifann_items:self.annotations=items_to_json(ann_items)defadd_annotations_from_file(self,filename:str)->None:"""Add object annotations from file (JSON). Args: filename: filename """withopen(filename,"r",encoding="utf-8")asfile:json_str=file.read()ifself.annotations:json_str=self.annotations[:-1]+","+json_str[1:]self.annotations=json_str@abc.abstractmethoddefadd_label_with_title(self,title:str|None=None)->None:"""Add label with title annotation Args: title: title (if None, use object title) """defiterate_shape_items(self,editable:bool=False):"""Iterate over shape items encoded in metadata (if any). Args: editable: if True, annotations are editable Yields: Plot item """fmt=self.get_metadata_option("format")lbl=self.get_metadata_option("showlabel")forkey,valueinself.metadata.items():ifkey==ROI_KEY:roi=self.roiifroiisnotNone:yield fromroi.iterate_roi_items(self,fmt=fmt,lbl=lbl,editable=False)elifResultShape.match(key,value):mshape:ResultShape=ResultShape.from_metadata_entry(key,value)yield frommshape.iterate_plot_items(fmt,lbl,self.PREFIX)ifself.annotations:try:foriteminjson_to_items(self.annotations):ifisinstance(item,AnnotatedShape):config_annotated_shape(item,fmt,lbl)set_plot_item_editable(item,editable)yielditemexceptjson.decoder.JSONDecodeError:passdefremove_all_shapes(self)->None:"""Remove metadata shapes and ROIs"""forkey,valueinlist(self.metadata.items()):resultshape=ResultShape.from_metadata_entry(key,value)ifresultshapeisnotNoneorkey==ROI_KEY:# Metadata entry is a metadata shape or a ROIself.metadata.pop(key)self.annotations=Nonedefget_metadata_option(self,name:str)->Any:"""Return metadata option value A metadata option is a metadata entry starting with an underscore. It is a way to store application-specific options in object metadata. Args: name: option name Returns: Option value Valid option names: 'format': format string 'showlabel': show label """ifnamenotinself.__metadata_options:raiseValueError(f"Invalid metadata option name `{name}`")default=self.__metadata_options[name]returnself.metadata.get(f"__{name}",default)defset_metadata_option(self,name:str,value:Any)->None:"""Set metadata option value A metadata option is a metadata entry starting with an underscore. It is a way to store application-specific options in object metadata. Args: name: option name value: option value Valid option names: 'format': format string 'showlabel': show label """ifnamenotinself.__metadata_options:raiseValueError(f"Invalid metadata option name `{name}`")self.metadata[f"__{name}"]=valuedefsave_attr_to_metadata(self,attrname:str,new_value:Any)->None:"""Save attribute to metadata Args: attrname: attribute name new_value: new value """value=getattr(self,attrname)ifvalue:self.metadata[f"orig_{attrname}"]=valuesetattr(self,attrname,new_value)defrestore_attr_from_metadata(self,attrname:str,default:Any)->None:"""Restore attribute from metadata Args: attrname: attribute name default: default value """value=self.metadata.pop(f"orig_{attrname}",default)setattr(self,attrname,value)defreset_metadata_to_defaults(self)->None:"""Reset metadata to default values"""self.__metadata_options={"format":"%"+self.CONF_FMT.get(self.DEFAULT_FMT),"showlabel":Conf.view.show_label.get(False),}self.metadata={}forname,valueinself.__metadata_options.items():self.set_metadata_option(name,value)self.update_metadata_view_settings()def__get_def_dict(self)->dict[str,Any]:"""Return default visualization settings dictionary"""returnConf.view.get_def_dict(self.__class__.__name__[:3].lower())defupdate_metadata_view_settings(self)->None:"""Update metadata view settings from Conf.view"""self.metadata.update(self.__get_def_dict())defupdate_plot_item_parameters(self,item:TypePlotItem)->None:"""Update plot item parameters from object data/metadata Takes into account a subset of plot item parameters. Those parameters may have been overriden by object metadata entries or other object data. The goal is to update the plot item accordingly. This is *almost* the inverse operation of `update_metadata_from_plot_item`. Args: item: plot item """# Subclasses have to override this method to update plot item parameters,# then call this implementation of the method to update plot item.update_dataset(item.param,self.metadata)item.param.update_item(item)ifitem.selected:item.select()defupdate_metadata_from_plot_item(self,item:TypePlotItem)->None:"""Update metadata from plot item. Takes into account a subset of plot item parameters. Those parameters may have been modified by the user through the plot item GUI. The goal is to update the metadata accordingly. This is *almost* the inverse operation of `update_plot_item_parameters`. Args: item: plot item """forkeyinself.__get_def_dict():ifhasattr(item.param,key):# In case the PlotPy version is not up-to-dateself.metadata[key]=getattr(item.param,key)# Subclasses may override this method to update metadata from plot item,# then call this implementation of the method to update metadata standard# entries.