Monday, June 4, 2012

Graphical Application for Scientific Programming Using TraitsUI & Chaco

I wrote about using ETS Traits for building UIs in Python in the last post. I continue in this post, using Chaco to embed scientific graphical applications in a Traits UI. For the most part, this post is an adaptation of Gael Varoquaux's tutorial on TraitsUI, except here Chaco, a 2-D plotting package fully integrated with Traits, is used, instead of matplotlib.

The Application
The tutorial has a simple application which simulates the process of a camera acquiring an image, and performs some processing on that image interactively, i.e at the click of a button. The results of the processing and the variables associated with the image are required to be displayed on the GUI continuously, as one image after another is acquired and processed. For simplicity let us divide the classes and functions in this application in three categories:
1. those that deal with the acquisition (or in this case simulating the acquisition) and the processing of the image
2. those that deal with displaying the interface
3. those that are used to handle the event loops in the application.

As one would expect, there is some overlap between the last two categories. The difference has been made simply to demonstrate the major features of this application - performing the data intensive work, rendering plots with Chaco and embedding them in a Traits UI, and finally deploying the UI interactively. A wide variety of scientific GUI applications can be covered between these three categories, and each of them uniquely demonstrates the advantages of working with Python and ETS tools.

Image Acquisition and Processing
The following are the functions and classes required to perform the acquisition and processing in the application.
from traits.api import HasTraits, Float, Enum
from traitsui.api import View, Item
from scipy import rand, indices, exp, sqrt, sum

class Experiment(HasTraits):
    width = Float(30, label = 'width', desc = 'width of the cloud')
    x = Float(50, label = 'X', desc = 'X position of the center')
    y = Float(50, label = 'Y', desc = 'Y position of the center')

class Results(HasTraits):
    """ Object used to display the results.
    """
    width = Float(30, label="Width", desc="width of the cloud") 
    x = Float(50, label="X", desc="X position of the center")
    y = Float(50, label="Y", desc="Y position of the center")

    view = View( Item('width', style='readonly'),
                 Item('x', style='readonly'),
                 Item('y', style='readonly'),
               )

class Camera(HasTraits):
    """ Camera objects. Implements both the camera parameters controls, and
        the picture acquisition.
    """
    exposure = Float(1, label="Exposure", desc="exposure, in ms")
    gain = Enum(1, 2, 3, label="Gain", desc="gain")

    def acquire(self, experiment):
       X, Y = indices((100, 100))
       Z = exp(-((X-experiment.x)**2+(Y-experiment.y)**2)/experiment.width**2)
       Z += 1-2*rand(100,100)
       Z *= self.exposure
       Z[Z>2] = 2
       Z = Z**self.gain
       return(Z)

    
def process(image, results_obj):
    """ Function called to do the processing """
    X, Y = indices(image.shape)
    x = sum(X*image)/sum(image)
    y = sum(Y*image)/sum(image)
    width = sqrt(abs(sum(((X-x)**2+(Y-y)**2)*image)/sum(image))) 
    results_obj.x = x
    results_obj.y = y
    results_obj.width = width

Among these classes only the Results class needs special attention. While the rest of the classes and the process function simply carry data attributes and process them, the Results class contains a 'view' attribute. This attribute enables the interface to display the results.

Threads and Flow Control
There are three threads in the application:
1. The thread handling the UI event loop. This is the top-level thread which is open at the start of the program. It triggers threads that are responsible for acquiring and processing the image, and displays data that it has gathered from those threads.
2. The acquisition thread, which keeps waiting for the camera to be triggered (through the GUI loop), acquires the image, and invokes the processing thread. Processing may take more time than acquisition, and hence a major role of this thread is to keep two different processing threads from running simultaneously. It does not trigger a new processing thread if one is still running.
3. The processing thread, which performs the numerically intensive work on the data, and dies when it is done.

Of these threads, the GUI event loops are handled by the .configure_traits() method of the HasTraits subclass which we use to make the main window of the GUI. So that leaves only the acquisition and the processing threads to be handled safely. The acquisition thread is subclassed from the threading.Thread class as follows (note that the imports from the previous snippet are still valid):
from threading import Thread
from time import sleep

class AcquisitionThread(Thread):
    wants_abort = False
    
    def process(self, image):
        try:
            if self.processing_job.isAlive():
                self.display("Processing too slow")
                return
        except AttributeError:
            pass
        self.processing_job = Thread(target = process, args = (image, \
                                                               self.results))
        self.processing_job.start()
    
    def run(self):
        self.display('Camera started')
        n_img = 0
        while not self.wants_abort:
            n_img += 1
            img = self.acquire(self.experiment)
            self.display('%d image captured' %n_img)
            self.image_show(img)
            self.process(img)
            sleep(1)
        self.display('Camera stopped')
The GUI Classes
The widget should look something like this:

The left half of the GUI is a Chaco image plot which displays the acquired image. The right half is a tabbed layout consisting of a general tab which has the Start/Stop button, an experiment tab that displays the variables of the experiment and the camera tab which displays the parameters of the acquisition process. The right half of the GUI is written in the following ControlPanel class, which is a container for the other classes.
from chaco.api import ArrayPlotData, Plot
from traits.api import HasTraits, Instance, String, Float, Enum, Button

class ControlPanel(HasTraits):
    experiment = Instance(Experiment, ())
    camera = Instance(Camera, ())
    plotdata = Instance(ArrayPlotData, ())
    results = Instance(Results, ())
    results_string = String()
    start_stop_acquisition = Button('Start / Stop Acquisition')
    acquisition_thread = Instance(AcquisitionThread)
    view = View(Group(Group(Item('start_stop_acquisition', show_label = False),
                            Item('results_string', show_label = False,
                                 style = 'custom'),
                            label = "Control", dock = 'tab'),
                      Group(Group(Item('experiment', style = 'custom',
                                       show_label = False), label = 'Input'),
                            Group(Item('results', style = 'custom',
                                       show_label = False), label ='Results'),
                            label = 'Experiment', dock = 'tab'),
                      Item('camera', style = 'custom', show_label = False,
                           dock = 'tab'),
                      layout = 'tabbed'))
    
    def _start_stop_acquisition_fired(self):
        """
        Method that gets called when the 'Start/Stop' button is clicked.
        """
        if self.acquisition_thread and self.acquisition_thread.isAlive():
            self.acquisition_thread.wants_abort = True
        else:
            self.acquisition_thread = AcquisitionThread()
            self.acquisition_thread.display = self.add_line
            self.acquisition_thread.acquire = self.camera.acquire
            self.acquisition_thread.experiment = self.experiment
            self.acquisition_thread.image_show = self.image_show
            self.acquisition_thread.results = self.results
            self.acquisition_thread.start()
    
    def add_line(self, string):
        """
        Writes a line to the control panel indicating the acquisition of the
        nth image
        """
        self.results_string = (string + "\n" + self.results_string)
    
    def image_show(self, image):
        """
        Method for rendering a random 2D array as an image.
        """
        self.plotdata.set_data('imagedata', image)
Note that although the ControlPanel class does not render the plot itself, it contains an ArrayPlotData attribute. ArrayPlotData is a class in chaco.api which is used to associate Numpy arrays with Chaco plots. Since a new array is created every time the Start/Stop Acquisition button is clicked, we need to set the contents of the Chaco plot to the new array. This is done by the ArrayPlotData().set_data() method. Every time the set_data() method is called it displays the new image by default. ArrayPlotData is the only Chaco class that needs to validated in the ControlPanel class, because it is all that is required to make the plot respond to the GUI event loop, while the plotting itself is done in a separate class.

The Chaco Plot and ComponentEditor
It is now that we come to the most important distinction from the original tutorial. As emphasized in section 4.1 (Making a Traits Editor for Matplotlib), Traits does not provide an editor for everything. The tutorial therefore goes on to make a somewhat complex traits editor for matplotlib plots. Although matplotlib is a fairly simple tool, making this editor requires some knowledge of matplotlib's backends and wxPython. This requirement is eliminated due to the use of ComponentEditor. A Chaco plot can be used in a TraitsUI with ComponentEditor, which is a class for wxPython editors for traits. The MPLFigureEditor can simply be replaced by ComponentEditor when using Chaco.

We first make a TraitsUI Handler subclass which processes the GUI event loop safely.

from traitsui.api import Handler
from pyface.api import GUI

class MainWindowHandler(Handler):
    
    def close(self, info, is_OK):
        if (info.object.panel.acquisition_thread and \
            info.object.panel.acquisition_thread.isAlive()):
            info.object.panel.acquisition_thread.wants_abort = True
            while info.object.panel.acquisition_thread.isAlive():
                sleep(0.1)
            GUI.process_events()
        return True
The pyface module is used by the traits GUIs to implement views and editors. The GUI.process_events() method processes any current GUI events.

Finally we make the main window class which combines the Chaco plot with the control panel.

class MainWindow(HasTraits):
    
    plot = Instance(Plot)
    plotdata = Instance(ArrayPlotData, ())
    panel = Instance(ControlPanel)
    
    def _panel_default(self):
        return ControlPanel(plotdata = self.plotdata)
    
    def _plot_default(self):
  self.plotdata = ArrayPlotData(imagedata=zeros((100,100)))
  plot = Plot(self.plotdata)
  plot.img_plot('imagedata')
  self.plot = plot
  return plot

    view = View(HSplit(Item('plot', editor = ComponentEditor(), \
                            dock = 'vertical'),
                       Item('panel', style = 'custom'),
                       show_labels = False),
                       resizable = True, handler = MainWindowHandler(),
                       buttons = NoButtons)
Note that the editor for the Chaco plot object is ComponentEditor and the MainWindowHandler object is used as the handler for the traits UI view. Calling the configure_traits() method on a MainWindow object will start the GUI. The entire script is available here.

This simple application amply demonstrates the kind of modularity one can reach by using traits. The data intensive processes, GUI events and their lower threads can be comfortably divided into different classes, and we have a wide variety of  editors and handlers to make them interactive.

Special thanks to Puneeth Chaganti and Pankaj Pandey who did most of the debugging, and to the people on the enthought-dev mailing list.

No comments:

Post a Comment