# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file."""Image object and related classes--------------------------------"""# pylint: disable=invalid-name # Allows short reference names like x, y, ...# pylint: disable=duplicate-codefrom__future__importannotationsimportabcimportenumimportrefromcollections.abcimportByteString,Mapping,SequencefromtypingimportTYPE_CHECKING,Any,Generic,Literal,Type,Unionfromuuidimportuuid4importguidata.datasetasgdsimportnumpyasnpfromguidata.configtoolsimportget_iconfromguidata.datasetimportupdate_datasetfromplotpy.builderimportmakefromplotpy.itemsimport(AnnotatedCircle,AnnotatedPolygon,AnnotatedRectangle,MaskedImageItem,)fromskimageimportdrawfromcdl.algorithms.datatypesimportclip_astypefromcdl.algorithms.imageimportscale_data_to_min_maxfromcdl.configimportConf,_fromcdl.core.modelimportbaseifTYPE_CHECKING:fromqtpyimportQtWidgetsasQWdefto_builtin(obj)->str|int|float|list|dict|np.ndarray|None:"""Convert an object implementing a numeric value or collection into the corresponding builtin/NumPy type. Return None if conversion fails."""try:returnint(obj)ifint(obj)==float(obj)elsefloat(obj)except(TypeError,ValueError):passifisinstance(obj,ByteString):returnstr(obj)ifisinstance(obj,Sequence):returnstr(obj)iflen(obj)==len(str(obj))elselist(obj)ifisinstance(obj,Mapping):returndict(obj)ifisinstance(obj,np.ndarray):returnobjreturnNone
[docs]classROI2DParam(base.BaseROIParam["ImageObj","BaseSingleImageROI"]):"""Image ROI parameters"""# Note: the ROI coordinates are expressed in pixel coordinates (integers)# => That is the only way to handle ROI parametrization for image objects.# Otherwise, we would have to ask the user to systematically provide the# physical coordinates: that would be cumbersome and error-prone._geometry_prop=gds.GetAttrProp("geometry")_rfp=gds.FuncProp(_geometry_prop,lambdax:x!="rectangle")_cfp=gds.FuncProp(_geometry_prop,lambdax:x!="circle")_pfp=gds.FuncProp(_geometry_prop,lambdax:x!="polygon")# Do not declare it as a static method: not supported by Python 3.9def_lbl(name:str,index:int):# pylint: disable=no-self-argument"""Returns name<sub>index</sub>"""returnf"{name}<sub>{index}</sub>"_ut="pixels"geometries=("rectangle","circle","polygon")geometry=gds.ChoiceItem(_("Geometry"),list(zip(geometries,geometries)),default="rectangle").set_prop("display",store=_geometry_prop,hide=True)# Parameters for rectangular ROI geometry:_tlcorner=gds.BeginGroup(_("Top left corner")).set_prop("display",hide=_rfp)x0=gds.IntItem(_lbl("X",0),unit=_ut).set_prop("display",hide=_rfp)y0=gds.IntItem(_lbl("Y",0),unit=_ut).set_pos(1).set_prop("display",hide=_rfp)_e_tlcorner=gds.EndGroup(_("Top left corner"))dx=gds.IntItem("ΔX",unit=_ut).set_prop("display",hide=_rfp)dy=gds.IntItem("ΔY",unit=_ut).set_pos(1).set_prop("display",hide=_rfp)# Parameters for circular ROI geometry:_cgroup=gds.BeginGroup(_("Center coordinates")).set_prop("display",hide=_cfp)xc=gds.IntItem(_lbl("X","C"),unit=_ut).set_prop("display",hide=_cfp)yc=gds.IntItem(_lbl("Y","C"),unit=_ut).set_pos(1).set_prop("display",hide=_cfp)_e_cgroup=gds.EndGroup(_("Center coordinates"))r=gds.IntItem(_("Radius"),unit=_ut).set_prop("display",hide=_cfp)# Parameters for polygonal ROI geometry:points=gds.FloatArrayItem(_("Coordinates")+f" ({_ut})").set_prop("display",hide=_pfp)
[docs]defto_single_roi(self,obj:ImageObj,title:str="")->PolygonalROI|RectangularROI|CircularROI:"""Convert parameters to single ROI Args: obj: image object (used for conversion of pixel to physical coordinates) title: ROI title Returns: Single ROI """ifself.geometry=="rectangle":returnRectangularROI.from_param(obj,self)ifself.geometry=="circle":returnCircularROI.from_param(obj,self)ifself.geometry=="polygon":returnPolygonalROI.from_param(obj,self)raiseValueError(f"Unknown ROI geometry type: {self.geometry}")
[docs]defget_suffix(self)->str:"""Get suffix text representation for ROI extraction"""ifself.geometry=="rectangle":returnf"x0={self.x0},y0={self.y0},dx={self.dx},dy={self.dy}"ifself.geometry=="circle":returnf"xc={self.xc},yc={self.yc},r={self.r}"ifself.geometry=="polygon":return"polygon"raiseValueError(f"Unknown ROI geometry type: {self.geometry}")
[docs]defget_extracted_roi(self,obj:ImageObj)->ImageROI|None:"""Get extracted ROI, i.e. the remaining ROI after extracting ROI from image. Args: obj: image object (used for conversion of pixel to physical coordinates) When extracting ROIs from an image to multiple images (i.e. one image per ROI), this method returns the ROI that has to be kept in the destination image. This is not necessary for a rectangular ROI: the destination image is simply a crop of the source image according to the ROI coordinates. But for a circular ROI or a polygonal ROI, the destination image is a crop of the source image according to the bounding box of the ROI. Thus, to avoid any loss of information, a ROI has to be defined for the destination image: this is the ROI returned by this method. It's simply the same as the source ROI, but with coordinates adjusted to the destination image. One may called this ROI the "extracted ROI". """ifself.geometry=="rectangle":returnNonesingle_roi=self.to_single_roi(obj)x0,y0,_x1,_y1=self.get_bounding_box_indices()single_roi.translate(obj,-x0,-y0)roi=ImageROI()roi.add_roi(single_roi)returnroi
[docs]defget_data(self,obj:ImageObj)->np.ndarray:"""Get data in ROI Args: obj: image object Returns: Data in ROI """x0,y0,x1,y1=self.get_bounding_box_indices()x0,y0=max(0,x0),max(0,y0)x1,y1=min(obj.data.shape[1],x1),min(obj.data.shape[0],y1)returnobj.data[y0:y1,x0:x1]
classBaseSingleImageROI(base.BaseSingleROI["ImageObj",ROI2DParam,base.TypeROIItem],Generic[base.TypeROIItem],abc.ABC,):"""Base class for single image ROI Args: coords: ROI edge coordinates (floats) title: ROI title .. note:: The image ROI coords are expressed in physical coordinates (floats). The conversion to pixel coordinates is done in :class:`cdl.obj.ImageObj` (see :meth:`cdl.obj.ImageObj.physical_to_indices`). Most of the time, the physical coordinates are the same as the pixel coordinates, but this is not always the case (e.g. after image binning), so it's better to keep the physical coordinates in the ROI object: this will help reusing the ROI with different images (e.g. with different pixel sizes). """@abc.abstractmethoddefget_bounding_box(self,obj:ImageObj)->tuple[float,float,float,float]:"""Get bounding box (physical coordinates) Args: obj: image object """@abc.abstractmethoddeftranslate(self,obj:ImageObj,dx:int,dy:int)->None:"""Translate ROI Args: obj: image object dx: translation along X-axis dy: translation along Y-axis """classPolygonalROI(BaseSingleImageROI[AnnotatedPolygon]):"""Polygonal ROI Args: coords: ROI edge coordinates title: title Raises: ValueError: if number of coordinates is odd .. note:: The image ROI coords are expressed in physical coordinates (floats) """defcheck_coords(self)->None:"""Check if coords are valid Raises: ValueError: invalid coords """iflen(self.coords)%2!=0:raiseValueError("Edge indices must be pairs of X, Y values")# pylint: disable=unused-argument@classmethoddeffrom_param(cls:PolygonalROI,obj:ImageObj,param:ROI2DParam)->PolygonalROI:"""Create ROI from parameters Args: obj: image object param: parameters """indices=True# ROI coordinates are in pixel coordinates in `ROI2DParam`returncls(param.points,indices=indices,title=param.get_title())defget_bounding_box(self,obj:ImageObj)->tuple[float,float,float,float]:"""Get bounding box (physical coordinates) Args: obj: image object """coords=self.get_physical_coords(obj)x_edges,y_edges=coords[::2],coords[1::2]returnmin(x_edges),min(y_edges),max(x_edges),max(y_edges)deftranslate(self,obj:ImageObj,dx:int,dy:int)->None:"""Translate ROI Args: obj: image object dx: translation along X-axis dy: translation along Y-axis """coords=self.get_indices_coords(obj)coords[::2]+=int(dx)coords[1::2]+=int(dy)self.set_indices_coords(obj,coords)defto_mask(self,obj:ImageObj)->np.ndarray:"""Create mask from ROI Args: obj: image object Returns: Mask (boolean array where True values are inside the ROI) """roi_mask=np.ones_like(obj.data,dtype=bool)indices=self.get_indices_coords(obj)rows,cols=indices[1::2],indices[::2]rr,cc=draw.polygon(rows,cols,shape=obj.data.shape)roi_mask[rr,cc]=Falsereturnroi_maskdefto_param(self,obj:ImageObj,title:str|None=None)->ROI2DParam:"""Convert ROI to parameters Args: obj: object (image), for physical-indices coordinates conversion title: ROI title """param=ROI2DParam(title=self.titleiftitleisNoneelsetitle)param.geometry="polygon"param.points=self.get_indices_coords(obj)returnparamdefto_plot_item(self,obj:ImageObj,title:str|None=None)->AnnotatedPolygon:"""Make and return the annnotated polygon associated to ROI Args: obj: object (image), for physical-indices coordinates conversion title: title """item=AnnotatedPolygon(self.get_physical_coords(obj).reshape(-1,2))item.annotationparam.title=self.titleiftitleisNoneelsetitleitem.annotationparam.update_item(item)item.set_style("plot","shape/drag")returnitem@classmethoddeffrom_plot_item(cls:PolygonalROI,item:AnnotatedPolygon)->PolygonalROI:"""Create ROI from plot item Args: item: plot item """returncls(item.get_points().flatten(),False,item.annotationparam.title)classRectangularROI(BaseSingleImageROI[AnnotatedRectangle]):"""Rectangular ROI Args: coords: ROI edge coordinates (x0, y0, dx, dy) title: title .. note:: The image ROI coords are expressed in physical coordinates (floats) """defcheck_coords(self)->None:"""Check if coords are valid Raises: ValueError: invalid coords """iflen(self.coords)!=4:raiseValueError("Rectangle ROI requires 4 coordinates")@classmethoddeffrom_param(cls:RectangularROI,obj:ImageObj,param:ROI2DParam)->RectangularROI:"""Create ROI from parameters Args: obj: image object param: parameters """ix0,iy0,ix1,iy1=param.get_bounding_box_indices()coords=[ix0,iy0,ix1-ix0,iy1-iy0]indices=True# ROI coordinates are in pixel coordinates in `ROI2DParam`returncls(coords,indices=indices,title=param.get_title())defget_bounding_box(self,obj:ImageObj)->tuple[float,float,float,float]:"""Get bounding box (physical coordinates) Args: obj: image object """x0,y0,dx,dy=self.get_physical_coords(obj)returnx0,y0,x0+dx,y0+dydeftranslate(self,obj:ImageObj,dx:int,dy:int)->None:"""Translate ROI Args: obj: image object dx: translation along X-axis dy: translation along Y-axis """coords=self.get_indices_coords(obj)coords[0]+=int(dx)coords[1]+=int(dy)self.set_indices_coords(obj,coords)defget_physical_coords(self,obj:ImageObj)->np.ndarray:"""Return physical coords Args: obj: image object Returns: Physical coords """ifself.indices:ix0,iy0,idx,idy=self.coordsx0,y0,x1,y1=obj.indices_to_physical([ix0,iy0,ix0+idx,iy0+idy])return[x0,y0,x1-x0,y1-y0]returnself.coordsdefget_indices_coords(self,obj:ImageObj)->np.ndarray:"""Return indices coords Args: obj: image object Returns: Indices coords """ifself.indices:returnself.coordsix0,iy0,ix1,iy1=obj.physical_to_indices(self.get_bounding_box(obj))return[ix0,iy0,ix1-ix0,iy1-iy0]defset_indices_coords(self,obj:ImageObj,coords:np.ndarray)->None:"""Set indices coords Args: obj: object (signal/image) coords: indices coords """ifself.indices:self.coords=coordselse:ix0,iy0,idx,idy=coordsx0,y0,x1,y1=obj.indices_to_physical([ix0,iy0,ix0+idx,iy0+idy])self.coords=[x0,y0,x1-x0,y1-y0]defto_mask(self,obj:ImageObj)->np.ndarray:"""Create mask from ROI Args: obj: image object Returns: Mask (boolean array where True values are inside the ROI) """roi_mask=np.ones_like(obj.data,dtype=bool)x0,y0,dx,dy=self.get_indices_coords(obj)roi_mask[max(y0,0):y0+dy,max(x0,0):x0+dx]=Falsereturnroi_maskdefto_param(self,obj:ImageObj,title:str|None=None)->ROI2DParam:"""Convert ROI to parameters Args: obj: object (image), for physical-indices coordinates conversion title: ROI title """param=ROI2DParam(title=self.titleiftitleisNoneelsetitle)param.geometry="rectangle"param.x0,param.y0,param.dx,param.dy=self.get_indices_coords(obj)returnparamdefto_plot_item(self,obj:ImageObj,title:str|None=None)->AnnotatedRectangle:"""Make and return the annnotated rectangle associated to ROI Args: obj: object (image), for physical-indices coordinates conversion title: title """definfo_callback(item:AnnotatedRectangle)->str:"""Return info string for rectangular ROI"""x0,y0,x1,y1=item.get_rect()ifself.indices:x0,y0,x1,y1=obj.physical_to_indices([x0,y0,x1,y1])x0,y0,dx,dy=self.rect_to_coords(x0,y0,x1,y1)return"<br>".join([f"X0, Y0 = {x0:g}, {y0:g}",f"ΔX x ΔY = {dx:g} x {dy:g}",])x0,y0,dx,dy=self.get_physical_coords(obj)x1,y1=x0+dx,y0+dytitle=self.titleiftitleisNoneelsetitleroi_item:AnnotatedRectangle=make.annotated_rectangle(x0,y0,x1,y1,title)roi_item.set_info_callback(info_callback)param=roi_item.label.labelparamparam.anchor="BL"param.xc,param.yc=5,-5param.update_item(roi_item.label)returnroi_item@staticmethoddefrect_to_coords(x0:int|float,y0:int|float,x1:int|float,y1:int|float)->np.ndarray:"""Convert rectangle to coordinates Args: x0: x0 (top-left corner) y0: y0 (top-left corner) x1: x1 (bottom-right corner) y1: y1 (bottom-right corner) Returns: Rectangle coordinates """returnnp.array([x0,y0,x1-x0,y1-y0],dtype=type(x0))@classmethoddeffrom_plot_item(cls:RectangularROI,item:AnnotatedRectangle)->RectangularROI:"""Create ROI from plot item Args: item: plot item """rect=item.get_rect()returncls(cls.rect_to_coords(*rect),False,item.annotationparam.title)classCircularROI(BaseSingleImageROI[AnnotatedCircle]):"""Circular ROI Args: coords: ROI edge coordinates (xc, yc, r) title: title .. note:: The image ROI coords are expressed in physical coordinates (floats) """# pylint: disable=unused-argument@classmethoddeffrom_param(cls:CircularROI,obj:ImageObj,param:ROI2DParam)->CircularROI:"""Create ROI from parameters Args: obj: image object param: parameters """ix0,iy0,ix1,iy1=param.get_bounding_box_indices()ixc,iyc=(ix0+ix1)*0.5,(iy0+iy1)*0.5ir=(ix1-ix0)*0.5indices=True# ROI coordinates are in pixel coordinates in `ROI2DParam`returncls([ixc,iyc,ir],indices=indices,title=param.get_title())defcheck_coords(self)->None:"""Check if coords are valid Raises: ValueError: invalid coords """iflen(self.coords)!=3:raiseValueError("Circle ROI requires 3 coordinates")defget_bounding_box(self,obj:ImageObj)->tuple[float,float,float,float]:"""Get bounding box (physical coordinates) Args: obj: image object """xc,yc,r=self.get_physical_coords(obj)returnxc-r,yc-r,xc+r,yc+rdeftranslate(self,obj:ImageObj,dx:int,dy:int)->None:"""Translate ROI Args: obj: image object dx: translation along X-axis dy: translation along Y-axis """coords=self.get_indices_coords(obj)coords[0]+=int(dx)coords[1]+=int(dy)self.set_indices_coords(obj,coords)defget_physical_coords(self,obj:ImageObj)->np.ndarray:"""Return physical coords Args: obj: image object Returns: Physical coords """ifself.indices:ixc,iyc,ir=self.coordsx0,y0,x1,y1=obj.indices_to_physical([ixc-ir,iyc-ir,ixc+ir,iyc+ir])return[0.5*(x0+x1),0.5*(y0+y1),0.5*(x1-x0)]returnself.coordsdefget_indices_coords(self,obj:ImageObj)->np.ndarray:"""Return indices coords Args: obj: image object Returns: Indices coords """ifself.indices:returnself.coordsix0,iy0,ix1,iy1=obj.physical_to_indices(self.get_bounding_box(obj))ixc,iyc=int((ix0+ix1)*0.5),int((iy0+iy1)*0.5)ir=int((ix1-ix0)*0.5)return[ixc,iyc,ir]defset_indices_coords(self,obj:ImageObj,coords:np.ndarray)->None:"""Set indices coords Args: obj: object (signal/image) coords: indices coords """ifself.indices:self.coords=coordselse:ixc,iyc,ir=coordsx0,y0,x1,y1=obj.indices_to_physical([ixc-ir,iyc-ir,ixc+ir,iyc+ir])self.coords=[0.5*(x0+x1),0.5*(y0+y1),0.5*(x1-x0)]defto_mask(self,obj:ImageObj)->np.ndarray:"""Create mask from ROI Args: obj: image object Returns: Mask (boolean array where True values are inside the ROI) """roi_mask=np.ones_like(obj.data,dtype=bool)ixc,iyc,ir=self.get_indices_coords(obj)yxratio=obj.dy/obj.dxrr,cc=draw.ellipse(iyc,ixc,ir/yxratio,ir,shape=obj.data.shape)roi_mask[rr,cc]=Falsereturnroi_maskdefto_param(self,obj:ImageObj,title:str|None=None)->ROI2DParam:"""Convert ROI to parameters Args: obj: object (image), for physical-indices coordinates conversion title: ROI title """param=ROI2DParam(title=self.titleiftitleisNoneelsetitle)param.geometry="circle"param.xc,param.yc,param.r=self.get_indices_coords(obj)returnparamdefto_plot_item(self,obj:ImageObj,title:str|None=None)->AnnotatedCircle:"""Make and return the annnotated circle associated to ROI Args: obj: object (image), for physical-indices coordinates conversion title: title """definfo_callback(item:AnnotatedCircle)->str:"""Return info string for circular ROI"""x0,y0,x1,y1=item.get_rect()ifself.indices:x0,y0,x1,y1=obj.physical_to_indices([x0,y0,x1,y1])xc,yc,r=self.rect_to_coords(x0,y0,x1,y1)return"<br>".join([f"Center = {xc:g}, {yc:g}",f"Radius = {r:g}",])xc,yc,r=self.get_physical_coords(obj)item=AnnotatedCircle(xc-r,yc,xc+r,yc)item.set_info_callback(info_callback)item.annotationparam.title=self.titleiftitleisNoneelsetitleitem.annotationparam.update_item(item)item.set_style("plot","shape/drag")returnitem@staticmethoddefrect_to_coords(x0:int|float,y0:int|float,x1:int|float,y1:int|float)->np.ndarray:"""Convert rectangle to circle coordinates Args: x0: x0 (top-left corner) y0: y0 (top-left corner) x1: x1 (bottom-right corner) y1: y1 (bottom-right corner) Returns: Circle coordinates """xc,yc,r=0.5*(x0+x1),0.5*(y0+y1),0.5*(x1-x0)returnnp.array([xc,yc,r],dtype=type(x0))@classmethoddeffrom_plot_item(cls:CircularROI,item:AnnotatedCircle)->CircularROI:"""Create ROI from plot item Args: item: plot item """rect=item.get_rect()returncls(cls.rect_to_coords(*rect),False,item.annotationparam.title)
[docs]classImageROI(base.BaseROI["ImageObj",BaseSingleImageROI,ROI2DParam,# `Union` is mandatory here for Python 3.9-3.10 compatibility:Union[AnnotatedPolygon,AnnotatedRectangle,AnnotatedCircle],]):"""Image 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 """PREFIX="i"
[docs]@staticmethoddefget_compatible_single_roi_classes()->list[Type[BaseSingleImageROI]]:"""Return compatible single ROI classes"""return[RectangularROI,CircularROI,PolygonalROI]
[docs]defto_mask(self,obj:ImageObj)->np.ndarray[bool]:"""Create mask from ROI Args: obj: image object Returns: Mask (boolean array where True values are inside the ROI) """mask=np.ones_like(obj.data,dtype=bool)forroiinself.single_rois:mask&=roi.to_mask(obj)returnmask
[docs]defcreate_image_roi(geometry:Literal["rectangle","circle","polygon"],coords:np.ndarray|list[float]|list[list[float]],indices:bool=True,singleobj:bool|None=None,inverse:bool=False,title:str="",)->ImageROI:"""Create Image Regions of Interest (ROI) object. More ROIs can be added to the object after creation, using the `add_roi` method. Args: geometry: ROI type ('rectangle', 'circle', 'polygon') coords: ROI coords (physical coordinates), `[x0, y0, dx, dy]` for a rectangle, `[xc, yc, r]` for a circle, or `[x0, y0, x1, y1, ...]` for a polygon (lists or NumPy arrays are accepted). For multiple ROIs, nested lists or NumPy arrays are accepted but with a common geometry type (e.g. `[[xc1, yc1, r1], [xc2, yc2, r2], ...]` for circles). indices: if True, coordinates are indices, if False, they are physical values (default to True for images) 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 title: title Returns: Regions of Interest (ROI) object Raises: ValueError: if ROI type is unknown or if the number of coordinates is invalid """coords=np.array(coords,float)ifcoords.ndim==1:coords=coords.reshape(1,-1)roi=ImageROI(singleobj,inverse)ifgeometry=="rectangle":ifcoords.shape[1]!=4:raiseValueError("Rectangle ROI requires 4 coordinates")forrowincoords:roi.add_roi(RectangularROI(row,indices,title))elifgeometry=="circle":ifcoords.shape[1]!=3:raiseValueError("Circle ROI requires 3 coordinates")forrowincoords:roi.add_roi(CircularROI(row,indices,title))elifgeometry=="polygon":ifcoords.shape[1]%2!=0:raiseValueError("Polygon ROI requires pairs of X, Y coordinates")forrowincoords:roi.add_roi(PolygonalROI(row,indices,title))else:raiseValueError(f"Unknown ROI type: {geometry}")returnroi
[docs]classImageObj(gds.DataSet,base.BaseObj[ImageROI,MaskedImageItem]):"""Image object"""PREFIX="i"CONF_FMT=Conf.view.ima_formatDEFAULT_FMT=".1f"VALID_DTYPES=(np.uint8,np.uint16,np.int16,np.int32,np.float32,np.float64,np.complex128,)def__init__(self,title=None,comment=None,icon=""):"""Constructor Args: title: title comment: comment icon: icon """gds.DataSet.__init__(self,title,comment,icon)base.BaseObj.__init__(self)self.regenerate_uuid()self._dicom_template=None
[docs]@staticmethoddefget_roi_class()->Type[ImageROI]:"""Return ROI class"""returnImageROI
[docs]defregenerate_uuid(self):"""Regenerate UUID This method is used to regenerate UUID after loading the object from a file. This is required to avoid UUID conflicts when loading objects from file without clearing the workspace first. """self.uuid=str(uuid4())
def__add_metadata(self,key:str,value:Any)->None:"""Add value to metadata if value can be converted into builtin/NumPy type Args: key: key value: value """stored_val=to_builtin(value)ifstored_valisnotNone:self.metadata[key]=stored_val
[docs]defset_metadata_from(self,obj:Mapping|dict)->None:"""Set metadata from object: dict-like (only string keys are considered) or any other object (iterating over supported attributes) Args: obj: object """self.reset_metadata_to_defaults()ptn=r"__[\S_]*__$"ifisinstance(obj,Mapping):forkey,valueinobj.items():ifisinstance(key,str)andnotre.match(ptn,key):self.__add_metadata(key,value)else:forattrnameindir(obj):ifattrname!="GroupLength"andnotre.match(ptn,attrname):try:attr=getattr(obj,attrname)ifnotcallable(attr)andattr:self.__add_metadata(attrname,attr)exceptAttributeError:pass
@propertydefdicom_template(self):"""Get DICOM template"""returnself._dicom_template@dicom_template.setterdefdicom_template(self,template):"""Set DICOM template"""iftemplateisnotNone:ipp=getattr(template,"ImagePositionPatient",None)ifippisnotNone:self.x0,self.y0=float(ipp[0]),float(ipp[1])pxs=getattr(template,"PixelSpacing",None)ifpxsisnotNone:self.dy,self.dx=float(pxs[0]),float(pxs[1])self.set_metadata_from(template)self._dicom_template=templateuuid=gds.StringItem("UUID").set_prop("display",hide=True)_tabs=gds.BeginTabGroup("all")_datag=gds.BeginGroup(_("Data"))data=gds.FloatArrayItem(_("Data"))metadata=gds.DictItem(_("Metadata"),default={})_e_datag=gds.EndGroup(_("Data"))_dxdyg=gds.BeginGroup(f'{_("Origin")} / {_("Pixel spacing")}')_origin=gds.BeginGroup(_("Origin"))x0=gds.FloatItem("X<sub>0</sub>",default=0.0)y0=gds.FloatItem("Y<sub>0</sub>",default=0.0).set_pos(col=1)_e_origin=gds.EndGroup(_("Origin"))_pixel_spacing=gds.BeginGroup(_("Pixel spacing"))dx=gds.FloatItem("Δx",default=1.0,nonzero=True)dy=gds.FloatItem("Δy",default=1.0,nonzero=True).set_pos(col=1)_e_pixel_spacing=gds.EndGroup(_("Pixel spacing"))_e_dxdyg=gds.EndGroup(f'{_("Origin")} / {_("Pixel spacing")}')_unitsg=gds.BeginGroup(f'{_("Titles")} / {_("Units")}')title=gds.StringItem(_("Image title"),default=_("Untitled"))_tabs_u=gds.BeginTabGroup("units")_unitsx=gds.BeginGroup(_("X-axis"))xlabel=gds.StringItem(_("Title"),default="")xunit=gds.StringItem(_("Unit"),default="")_e_unitsx=gds.EndGroup(_("X-axis"))_unitsy=gds.BeginGroup(_("Y-axis"))ylabel=gds.StringItem(_("Title"),default="")yunit=gds.StringItem(_("Unit"),default="")_e_unitsy=gds.EndGroup(_("Y-axis"))_unitsz=gds.BeginGroup(_("Z-axis"))zlabel=gds.StringItem(_("Title"),default="")zunit=gds.StringItem(_("Unit"),default="")_e_unitsz=gds.EndGroup(_("Z-axis"))_e_tabs_u=gds.EndTabGroup("units")_e_unitsg=gds.EndGroup(f'{_("Titles")} / {_("Units")}')_scalesg=gds.BeginGroup(_("Scales"))_prop_autoscale=gds.GetAttrProp("autoscale")autoscale=gds.BoolItem(_("Auto scale"),default=True).set_prop("display",store=_prop_autoscale)_tabs_b=gds.BeginTabGroup("bounds")_boundsx=gds.BeginGroup(_("X-axis"))xscalelog=gds.BoolItem(_("Logarithmic scale"),default=False)xscalemin=gds.FloatItem(_("Lower bound"),check=False).set_prop("display",active=gds.NotProp(_prop_autoscale))xscalemax=gds.FloatItem(_("Upper bound"),check=False).set_prop("display",active=gds.NotProp(_prop_autoscale))_e_boundsx=gds.EndGroup(_("X-axis"))_boundsy=gds.BeginGroup(_("Y-axis"))yscalelog=gds.BoolItem(_("Logarithmic scale"),default=False)yscalemin=gds.FloatItem(_("Lower bound"),check=False).set_prop("display",active=gds.NotProp(_prop_autoscale))yscalemax=gds.FloatItem(_("Upper bound"),check=False).set_prop("display",active=gds.NotProp(_prop_autoscale))_e_boundsy=gds.EndGroup(_("Y-axis"))_boundsz=gds.BeginGroup(_("LUT range"))zscalemin=gds.FloatItem(_("Lower bound"),check=False)zscalemax=gds.FloatItem(_("Upper bound"),check=False)_e_boundsz=gds.EndGroup(_("LUT range"))_e_tabs_b=gds.EndTabGroup("bounds")_e_scalesg=gds.EndGroup(_("Scales"))_e_tabs=gds.EndTabGroup("all")@propertydefwidth(self)->float:"""Return image width, i.e. number of columns multiplied by pixel size"""returnself.data.shape[1]*self.dx@propertydefheight(self)->float:"""Return image height, i.e. number of rows multiplied by pixel size"""returnself.data.shape[0]*self.dy@propertydefxc(self)->float:"""Return image center X-axis coordinate"""returnself.x0+0.5*self.width@propertydefyc(self)->float:"""Return image center Y-axis coordinate"""returnself.y0+0.5*self.height
[docs]defget_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: Masked data """ifself.roiisNoneorroi_indexisNone:returnself.datasingle_roi=self.roi.get_single_roi(roi_index)x0,y0,x1,y1=self.physical_to_indices(single_roi.get_bounding_box(self))returnself.get_masked_view()[y0:y1,x0:x1]
[docs]defcopy(self,title:str|None=None,dtype:np.dtype|None=None)->ImageObj:"""Copy object. Args: title: title dtype: data type Returns: Copied object """title=self.titleiftitleisNoneelsetitleobj=ImageObj(title=title)obj.title=titleobj.xlabel=self.xlabelobj.ylabel=self.ylabelobj.xunit=self.xunitobj.yunit=self.yunitobj.zunit=self.zunitobj.x0=self.x0obj.y0=self.y0obj.dx=self.dxobj.dy=self.dyobj.metadata=base.deepcopy_metadata(self.metadata)obj.data=np.array(self.data,copy=True,dtype=dtype)obj.dicom_template=self.dicom_templatereturnobj
[docs]defset_data_type(self,dtype:np.dtype)->None:"""Change data type. If data type is integer, clip values to the new data type's range, thus avoiding overflow or underflow. Args: Data type """self.data=clip_astype(self.data,dtype)
[docs]defupdate_plot_item_parameters(self,item:MaskedImageItem)->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 """foraxisin("x","y","z"):unit=getattr(self,axis+"unit")fmt=r"%.1f"ifunit:fmt=r"%.1f ("+unit+")"setattr(item.param,axis+"format",fmt)# Updating origin and pixel spacinghas_origin=self.x0isnotNoneandself.y0isnotNonehas_pixelspacing=self.dxisnotNoneandself.dyisnotNoneifhas_originorhas_pixelspacing:x0,y0,dx,dy=0.0,0.0,1.0,1.0ifhas_origin:x0,y0=self.x0,self.y0ifhas_pixelspacing:dx,dy=self.dx,self.dyshape=self.data.shapeitem.param.xmin,item.param.xmax=x0,x0+dx*shape[1]item.param.ymin,item.param.ymax=y0,y0+dy*shape[0]zmin,zmax=item.get_lut_range()ifself.zscaleminisnotNoneorself.zscalemaxisnotNone:zmin=zminifself.zscaleminisNoneelseself.zscaleminzmax=zmaxifself.zscalemaxisNoneelseself.zscalemaxitem.set_lut_range([zmin,zmax])super().update_plot_item_parameters(item)
[docs]defupdate_metadata_from_plot_item(self,item:MaskedImageItem)->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 """super().update_metadata_from_plot_item(item)# Updating the LUT range:self.zscalemin,self.zscalemax=item.get_lut_range()# Updating origin and pixel spacing:shape=self.data.shapeparam=item.paramxmin,xmax,ymin,ymax=param.xmin,param.xmax,param.ymin,param.ymaxifxmin==0andymin==0andxmax==shape[1]andymax==shape[0]:self.x0,self.y0,self.dx,self.dy=0.0,0.0,1.0,1.0else:self.x0,self.y0=xmin,yminself.dx,self.dy=(xmax-xmin)/shape[1],(ymax-ymin)/shape[0]
[docs]defmake_item(self,update_from:MaskedImageItem|None=None)->MaskedImageItem:"""Make plot item from data. Args: update_from: update from plot item Returns: Plot item """data=self.__viewable_data()item=make.maskedimage(data,self.maskdata,title=self.title,colormap="viridis",eliminate_outliers=Conf.view.ima_eliminate_outliers.get(),interpolation="nearest",show_mask=True,)ifupdate_fromisNone:self.update_plot_item_parameters(item)else:update_dataset(item.param,update_from.param)item.param.update_item(item)returnitem
[docs]defupdate_item(self,item:MaskedImageItem,data_changed:bool=True)->None:"""Update plot item from data. Args: item: plot item data_changed: if True, data has changed """ifdata_changed:item.set_data(self.__viewable_data(),lut_range=[item.min,item.max])item.set_mask(self.maskdata)item.param.label=self.titleself.update_plot_item_parameters(item)item.plot().update_colormap_axis(item)
[docs]defphysical_to_indices(self,coords:list[float])->np.ndarray:"""Convert coordinates from physical (real world) to (array) indices (pixel) Args: coords: coordinates Returns: Indices """indices=np.array(coords,float)ndim=indices.ndimifndim==1:indices=indices.reshape(1,-1)ifindices.size>0:indices[:,::2]-=self.x0+0.5*self.dxindices[:,::2]/=self.dxindices[:,1::2]-=self.y0+0.5*self.dyindices[:,1::2]/=self.dyifndim==1:indices=indices.flatten()returnnp.array(indices,int)
[docs]defindices_to_physical(self,indices:list[float|int]|np.ndarray)->np.ndarray:"""Convert coordinates from (array) indices to physical (real world) Args: indices: indices Returns: Coordinates """coords=np.array(indices,float)ndim=coords.ndimifndim==1:coords=coords.reshape(1,-1)ifcoords.size>0:coords[:,::2]*=self.dxcoords[:,::2]+=self.x0+0.5*self.dxcoords[:,1::2]*=self.dycoords[:,1::2]+=self.y0+0.5*self.dyifndim==1:coords=coords.flatten()returncoords
[docs]defadd_label_with_title(self,title:str|None=None)->None:"""Add label with title annotation Args: title: title (if None, use image title) """title=self.titleiftitleisNoneelsetitleiftitle:label=make.label(title,(self.x0,self.y0),(10,10),"TL")self.add_annotations_from_items([label])
[docs]defcreate_image(title:str,data:np.ndarray|None=None,metadata:dict|None=None,units:tuple|None=None,labels:tuple|None=None,)->ImageObj:"""Create a new Image object Args: title: image title data: image data metadata: image metadata units: X, Y, Z units (tuple of strings) labels: X, Y, Z labels (tuple of strings) Returns: Image object """assertisinstance(title,str)assertdataisNoneorisinstance(data,np.ndarray)image=ImageObj(title=title)image.title=titleimage.data=dataifunitsisnotNone:image.xunit,image.yunit,image.zunit=unitsiflabelsisnotNone:image.xlabel,image.ylabel,image.zlabel=labelsifmetadataisnotNone:image.metadata.update(metadata)returnimage
[docs]classImageDatatypes(base.Choices):"""Image data types"""
[docs]@classmethoddeffrom_dtype(cls,dtype):"""Return member from NumPy dtype"""returngetattr(cls,str(dtype).upper(),cls.UINT8)
[docs]@classmethoddefcheck(cls):"""Check if data types are valid"""formemberincls:asserthasattr(np,member.value)
#: Unsigned integer number stored with 8 bitsUINT8=enum.auto()#: Unsigned integer number stored with 16 bitsUINT16=enum.auto()#: Signed integer number stored with 16 bitsINT16=enum.auto()#: Float number stored with 32 bitsFLOAT32=enum.auto()#: Float number stored with 64 bitsFLOAT64=enum.auto()
ImageDatatypes.check()
[docs]classImageTypes(base.Choices):"""Image types"""#: Image filled with zerosZEROS=_("zeros")#: Empty image (filled with data from memory state)EMPTY=_("empty")#: 2D Gaussian imageGAUSS=_("gaussian")#: Image filled with random data (uniform law)UNIFORMRANDOM=_("random (uniform law)")#: Image filled with random data (normal law)NORMALRANDOM=_("random (normal law)")
[docs]classNewImageParam(gds.DataSet):"""New image dataset"""hide_image_dtype=Falsehide_image_type=Falsetitle=gds.StringItem(_("Title"))height=gds.IntItem(_("Height"),help=_("Image height (total number of rows)"),min=1)width=gds.IntItem(_("Width"),help=_("Image width (total number of columns)"),min=1)dtype=gds.ChoiceItem(_("Data type"),ImageDatatypes.get_choices()).set_prop("display",hide=gds.GetAttrProp("hide_image_dtype"))itype=gds.ChoiceItem(_("Type"),ImageTypes.get_choices()).set_prop("display",hide=gds.GetAttrProp("hide_image_type"))
DEFAULT_TITLE=_("Untitled image")
[docs]defnew_image_param(title:str|None=None,itype:ImageTypes|None=None,height:int|None=None,width:int|None=None,dtype:ImageDatatypes|None=None,)->NewImageParam:"""Create a new Image dataset instance. Args: title: dataset title (default: None, uses default title) itype: image type (default: None, uses default type) height: image height (default: None, uses default height) width: image width (default: None, uses default width) dtype: image data type (default: None, uses default data type) Returns: New image dataset instance """title=DEFAULT_TITLEiftitleisNoneelsetitleparam=NewImageParam(title=title,icon=get_icon("new_image.svg"))param.title=titleifheightisnotNone:param.height=heightifwidthisnotNone:param.width=widthifdtypeisnotNone:param.dtype=dtypeifitypeisnotNone:param.itype=itypereturnparam
[docs]defcreate_image_from_param(newparam:NewImageParam,addparam:gds.DataSet|None=None,edit:bool=False,parent:QW.QWidget|None=None,)->ImageObj|None:"""Create a new Image object from dialog box. Args: newparam: new image parameters addparam: additional parameters edit: Open a dialog box to edit parameters (default: False) parent: parent widget Returns: New image object or None if user cancelled """globalIMG_NB# pylint: disable=global-statementifnewparamisNone:newparam=new_image_param()ifnewparam.heightisNone:newparam.height=500ifnewparam.widthisNone:newparam.width=500ifnewparam.dtypeisNone:newparam.dtype=ImageDatatypes.UINT16incr_sig_nb=notnewparam.titleifincr_sig_nb:newparam.title=f"{newparam.title}{IMG_NB+1:d}"ifnoteditoraddparamisnotNoneornewparam.edit(parent=parent):prefix=newparam.itype.name.lower()ifincr_sig_nb:IMG_NB+=1image=create_image(newparam.title)shape=(newparam.height,newparam.width)dtype=newparam.dtype.valuep=addparamifnewparam.itype==ImageTypes.ZEROS:image.data=np.zeros(shape,dtype=dtype)elifnewparam.itype==ImageTypes.EMPTY:image.data=np.empty(shape,dtype=dtype)elifnewparam.itype==ImageTypes.GAUSS:ifpisNone:p=Gauss2DParam(_("2D-gaussian image"))ifp.aisNone:try:p.a=np.iinfo(dtype).max/2.0exceptValueError:p.a=10.0ifeditandnotp.edit(parent=parent):returnNonex,y=np.meshgrid(np.linspace(p.xmin,p.xmax,shape[1]),np.linspace(p.ymin,p.ymax,shape[0]),)zgauss=p.a*np.exp(-((np.sqrt((x-p.x0)**2+(y-p.y0)**2)-p.mu)**2)/(2.0*p.sigma**2))image.data=np.array(zgauss,dtype=dtype)ifimage.title==DEFAULT_TITLE:image.title=(f"{prefix}(a={p.a:g},μ={p.mu:g},σ={p.sigma:g}),"f"x0={p.x0:g},y0={p.y0:g})")elifnewparam.itypein(ImageTypes.UNIFORMRANDOM,ImageTypes.NORMALRANDOM):pclass={ImageTypes.UNIFORMRANDOM:base.UniformRandomParam,ImageTypes.NORMALRANDOM:base.NormalRandomParam,}[newparam.itype]ifpisNone:p=pclass(_("Image")+" - "+newparam.itype.value)p.set_from_datatype(dtype)ifeditandnotp.edit(parent=parent):returnNonerng=np.random.default_rng(p.seed)ifnewparam.itype==ImageTypes.UNIFORMRANDOM:data=rng.random(shape)image.data=scale_data_to_min_max(data,p.vmin,p.vmax)ifimage.title==DEFAULT_TITLE:image.title=(f"{prefix}(vmin={p.vmin:g},vmax={p.vmax:g},seed={p.seed})")elifnewparam.itype==ImageTypes.NORMALRANDOM:image.data=rng.normal(p.mu,p.sigma,size=shape)ifimage.title==DEFAULT_TITLE:image.title=f"{prefix}(μ={p.mu:g},σ={p.sigma:g},seed={p.seed})"else:raiseNotImplementedError(f"New param type: {newparam.itype.value}")returnimagereturnNone