Gatan | AMETEKSkip to Main Content
No options found

Live thresholding with plots

Python Script

Code to process the live-view image in DigitalMicrograph, producing a binary display. The live image is thresholded, and several morphological operations are applied to create a binary display. Then the largest contiguous bright region is found and circled. A plot of the position of the center of mass of that bright region is also updated live.

Preview

'''
Code to process the live-view image in GMS, producing a binary display. 
The live image is thresholded and several morphological operations applied to
    produce a binary display. Then the largest contiguous bright region is found and circled.
    A plot of the position of the center of mass of that bright region is also updated live. 

WARNING: Due to a bug in GMS 3.5.0 and 3.5.1, this script will not run in those versions

Run the code with the live-view image, containing a rectangular ROI, front-most in GMS
    The code also works with an IS video played back with the IS player
    To stop calculation, delete the ROI. 
Frames are processed as often as possible.
Lines of code between #XXXXXXXX... lines are specific to thresholding and finding the largest region.
    All other lines of code are general, and can be re-used to produce other kinds of processed images from a live-view image
#Code written by Ben Miller. Last Updated Apr 2022
'''
import time 
import numpy as np
if not DM.IsScriptOnMainThread(): print('Scipy scripts cannot be run on Background Thread.'); exit()
import scipy.ndimage.filters as sfilt
from skimage.filters import threshold_otsu
import skimage.morphology as morph
import skimage.measure as measure
import traceback

#User editable variables are set here
#XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
filter_extent = 2 #(Default 2) Number defining the pixel radius to use for filter kernels
small_area_minimum = 200 #(Default 200) Number defining the min number of pixels below which dark/light patches of pixels are removed.
plot_data_for_N_frames = 200 #(Default 200) Number of points in the plots of measured parameters
print_timing = True      # (Default True) Select whether to output the time it takes to compute each frame
#XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

class CListen(DM.Py_ScriptObject):
    #Function to find an ROI placed on an image by the user, returning the ROI ID. 
    #If no ROI found, create a new one covering the entire image.
    def find_ROI(self,image):
        imageDisplay = image.GetImageDisplay(0)
        numROIs = imageDisplay.CountROIs()
        id = None
        for n in range(numROIs):
            roi = imageDisplay.GetROI(n) 
            if roi.IsRectangle():
                roi.SetVolatile(False)
                roi.SetResizable(False)  
                id = roi.GetID()
                break
        if id is None:
            #If No ROI is found, create one that covers the whole image. 
            print("\nRectangular ROI not found... using whole image")
            data_shape = image.GetNumArray().shape
            roi=DM.NewROI()
            roi.SetRectangle(0, 0, data_shape[0], data_shape[1])
            imageDisplay.AddROI(roi)
            roi.SetVolatile(False)
            roi.SetResizable(False) 
            id = roi.GetID()
        return id
    
    #XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    #Function to process the data (numpy array) from the ROI area, returning a 
    #binary image as well as a centroid location and convex area of the largest bright region
    def ROI_process(self,image_data):
        #pre-process the data with a gaussian blur
        image_data = sfilt.gaussian_filter(image_data, filter_extent)
        #threshold the data via the otsu method
        thresh = threshold_otsu(image=image_data)
        result = image_data < thresh
        #apply morphological operations to remove small dark and bright regions
        result = morph.remove_small_objects(result,min_size=small_area_minimum)
        result = morph.remove_small_holes(result,area_threshold=small_area_minimum)
        #find and measure all contiguous regions in the binary image
        thresh_labels = measure.label(result, connectivity=1, background=0).astype('uint32')
        region_props = sorted(measure.regionprops(thresh_labels), key=lambda r: r.area, reverse=True)
        return (result.astype("int8"),region_props[0].centroid,region_props[0].convex_area)
        
    #Funtion to add a circular ROI to an image (deleting existing ones if present)
    def add_circle_ROI(self,image,center,radius):
        #get the image display of image
        im_disp = image.GetImageDisplay(0)
        #if there is an exising ROI produced by this script, delete it
        try: 
            im_disp.DeleteROI(self.my_roi)
        except:
            # Circular ROI not found. 
            pass
        #Create new Cirlular ROI
        self.my_roi=DM.NewROI()
        self.my_roi.SetCircle(center[1],center[0],radius)
        #Set the color of the ROI
        self.my_roi.SetColor(1,1,0) 
        #Add the ROI to the image display
        im_disp.AddROI(self.my_roi)
    #Function to show an image as a lineplot with better defaults
    def display_DM_lineplot(self, image, 
            name=None,
            scale_y=1,
            origin_y = 0,
            scale_unit_y='a.u.',
            scale_x=1,
            origin_x = 0,
            scale_unit_x='',
            draw_style=1,
            legend=True):
        #Set image name (and thus the lineplot window title)
        if name is None:
            name = "Python LinePlot of "+image.GetName()
        image.SetName(name)
        #set calibration/scale
        image.SetIntensityScale(scale_y)
        image.SetIntensityOrigin(origin_y)
        image.SetIntensityUnitString(scale_unit_y)
        image.SetDimensionCalibration(0,origin_x,scale_x,scale_unit_x,0)
        #Get lineplot display
        image_document = DM.NewImageDocument("")
        image_display = image_document.AddImageDisplay(image,3)
        lineplot_display = DM.GetLinePlotImageDisplay(image_display)
        #Set a few more lineplot parameters
        lineplot_display.SetSliceDrawingStyle(0,draw_style)
        lineplot_display.SetLegendShown(legend)
        #Display Lineplot in DM
        image_document.Show()
        
    #XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

    #Initialization Function
    def __init__(self,img):
        try:
            #Create an index that is incremented each time data is processed.
            self.i = 0
            #get the original image and assign it to self.imgref
            self.imgref = img
            #Get the data from the region within an ROI
            self.roi = DM.GetROIFromID(self.find_ROI(self.imgref))
            val, val2, val3, val4 = self.roi.GetRectangle()
            self.data = self.imgref.GetNumArray()[int(val):int(val3),int(val2):int(val4)]
            #get the shape and calibration of the original image
            (input_sizex, input_sizey) = self.data.shape
            x_origin = self.imgref.GetDimensionOrigin(0); y_origin = self.imgref.GetDimensionOrigin(1)
            x_scale = self.imgref.GetDimensionScale(0);   y_scale = self.imgref.GetDimensionScale(1)
            #scale unit of microns causes problems for python in DM
            try: 
                x_unit = self.imgref.GetDimensionUnitString(0) ; y_unit = self.imgref.GetDimensionUnitString(1)
            except: 
                x_unit = self.imgref.GetDimensionUnitInfo(0)[0]; y_unit = self.imgref.GetDimensionUnitInfo(1)[0]
                if x_unit == 'micrometer': x_unit = 'um'        
                if y_unit == 'micrometer': y_unit = 'um'
            #Create a new image to contain the results of processing
            self.result_image = DM.CreateImage(self.ROI_process(self.data)[0])
            #Create a new image for the lineplot with 2 plots 
            self.plot_image = DM.CreateImage(np.zeros((2,plot_data_for_N_frames)))
            #Show the lineplot via function defined above
            self.display_DM_lineplot(self.plot_image,name="Measured Shifts",scale_y=y_scale,scale_unit_y=y_unit,scale_x=-1,scale_unit_x='frames')
            #Set the lineplot names for the legend 
                #This is not possible via Python script, so I use a short DM script, which is passed as a string to DM.ExecuteScriptString()
            dm_legend_names = ('ImageDisplay disp = GetFrontImage().ImageGetImageDisplay( 0 )\n'
                'disp.ImageDisplaySetSliceLabelByID(disp.ImageDisplayGetSliceIDByIndex( 0 ),"X-shift")\n'
                'disp.ImageDisplaySetSliceLabelByID(disp.ImageDisplayGetSliceIDByIndex( 1 ),"Y-shift")')
            DM.ExecuteScriptString(dm_legend_names)
            #Get the numpy array of the plot image so I can directly change the data values later
            self.plot_data = self.plot_image.GetNumArray()
            #Set the calibration based on the original data
            self.result_image.SetDimensionCalibration(0,x_origin,x_scale,x_unit,0)
            self.result_image.SetDimensionCalibration(1,y_origin,y_scale,y_unit,0)
            #Copy tags from original to processed image
            Tag_Copy(self.imgref, self.result_image)
            #Get the numpy array of the result image so I can directly change the data values later
            self.result_data=self.result_image.GetNumArray()
            #Display the result image in GMS
            self.result_image.ShowImage()
            #Set the image name which will be displayed in the image window's title bar
            self.result_image.SetName("Binary Map of "+img.GetName())
            DM.Py_ScriptObject.__init__(self)
            self.stop = 0
        except: print(traceback.format_exc())    
    
    #This function is run each time the image changes
    def HandleDataChangedEvent(self, flags, image):
        try:
            if not self.stop:
                #start timing
                start=time.perf_counter()     
                #XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
                #Get an (updated) ROI position
                val, val2, val3, val4 = self.roi.GetRectangle()
                #Get the data from the ROI area as a numpy array
                self.data = self.imgref.GetNumArray()[int(val):int(val3),int(val2):int(val4)]
                #Process the data and place in the result array (note that the [:] here is essential)               
                result = self.ROI_process(self.data)
                self.result_data[:]=result[0]
                #Add a circular ROI to the result image
                self.add_circle_ROI(self.result_image,result[1],np.sqrt(result[2]/np.pi))
                
                #Update Plot
                #place centroid result into plot data
                self.plot_data[:] = np.roll(self.plot_data,1)
                self.plot_data[:,0] = result[1]
                #after a few values are available, set blank values to mean instead of 0
                    #this makes the autosurvey limits for the plot more reasonable
                if self.i == 4: 
                    self.plot_data[0,5:] = np.mean(self.plot_data[0,:5])
                    self.plot_data[1,5:] = np.mean(self.plot_data[1,:5])
                #update plot calibrations, so the x axis stays in sync
                self.plot_image.SetDimensionCalibration(0,self.i,-1,'frames',0)
                self.plot_image.UpdateImage()
                #XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
                #Update the result image display
                self.result_image.UpdateImage()
                #end timing and output time to process this frame
                end=time.perf_counter()
                if print_timing: print("Processed Image "+str(self.i)+"  Processing Time= "+str(end-start))                                     
                #Increment an index each time data is processed. 
                self.i = self.i+1 
        except: print(traceback.format_exc())
        
    #Function to Delete Image Listener
    def __del__(self):
        print("Listener Deleted")
        DM.Py_ScriptObject.__del__(self)
        
    #Function to end processing by deleting or unregistering listener
    def RemoveListeners(self):
        try: 
            if not self.stop:
                self.stop = 1
                DM.DoEvents()
                global listener
                #DM 3.5.2 and higher have new function for unregistering listeners. 
                #DM 3.4.3 and lower should delete the listener instead
                #DM 3.5.0 and 3.5.1 have a fatal flaw regarding listeners, so this script is not compatible with those versions
                if (get_DM_version()[1][0] == "4" and get_DM_version()[0] == "3"):
                    del listener
                else:
                    listener.UnregisterAllListeners()
                print("Live Processing Script Ended")
        except: print(traceback.format_exc())
        
    #Remove listeners if source image window is closed
    def HandleWindowClosedEvent(self, event_flags, window):
        print("Window Closed")
        self.RemoveListeners()
                
    #Remove listeners if the ROI is deleted
    def HandleROIRemovedEvent(self, img_disp_event_flags, img_disp, roi_change_flag, roi_disp_change_flags, roi):
        print("ROI Removed")
        self.RemoveListeners()
        
#Function to get the currently used version of DigitalMicrograph 
def get_DM_version():
    #No Python script command exists to get the DM version, 
    #so we first run a DM script to put the values in the global tags
    dm_script = ('number minor, major, bugVersion\n'
        'GetApplicationVersion(major, minor, bugVersion)\n'
        'GetPersistentTagGroup().TagGroupSetTagAsLong("Python_Temp:DM_Version_Major",major)\n'
        'GetPersistentTagGroup().TagGroupSetTagAsLong("Python_Temp:DM_Version_Minor",minor)\n'
        'GetPersistentTagGroup().TagGroupSetTagAsLong("Python_Temp:DM_Version_bugVersion",bugVersion)')
    DM.ExecuteScriptString(dm_script)
    #Now get the information stored in the global tags by the DM script
    version = [0,0,0]
    _,version[0] = DM.GetPersistentTagGroup().GetTagAsString("Python_Temp:DM_Version_Major")
    _,version[1] = DM.GetPersistentTagGroup().GetTagAsString("Python_Temp:DM_Version_Minor")
    _,version[2] = DM.GetPersistentTagGroup().GetTagAsString("Python_Temp:DM_Version_bugVersion")
    return version  
          
def Tag_Copy(image_source, image_dest, subPath = None ):
    '''
    Copy all tags between source and destination.
    If no destination subPath is provided, the destination tags will be replaced.
    '''
    #Copy Tags
    tg_source = image_source.GetTagGroup()
    tg_dest = image_dest.GetTagGroup()
    if ( subPath != None ):
        tg_dest.SetTagAsTagGroup(subPath,tg_source.Clone())
    else:
        tg_dest.DeleteAllTags()
        tg_dest.CopyTagsFrom(tg_source.Clone())

#Main Code Starts Here

#Check that we are not running 3.5.0 or 3.5.1 which have a known bug affecting this script.
if (((get_DM_version()[1] == '51') or (get_DM_version()[1] == '50')) and get_DM_version()[0] == "3"):
    DM.OkDialog("Due to a bug in DigitalMicrograph 3.5.0 and 3.5.1, this script would cause DM to crash in those versions. \n\nScript Aborted.")
    exit()

#Get front image in GMS        
img1 = DM.GetFrontImage()
#Get the image window, so we can check if it gets closed
imageDoc = DM.GetFrontImageDocument()
imDocWin = imageDoc.GetWindow()
#Get the image display, for the ROI-removed listener
imageDisplay = img1.GetImageDisplay(0)

#Listeners are started here
#initiate the image listener
listener = CListen(img1)
#check if the source window closes
WindowClosedListenerID = listener.WindowHandleWindowClosedEvent(imDocWin, 'pythonplugin')
#check if the ROI has been deleted
ROIRemovedListenerID = listener.ImageDisplayHandleROIRemovedEvent(imageDisplay,'pythonplugin')
#check if the source image changes
DataChangedListenerID = listener.ImageHandleDataChangedEvent(img1, 'pythonplugin')
#IDs are not used in this script, but could be used to unregister individual listeners in DM 3.5.2 and higher.