drawingpygtkplotcairopixbuf

Cairo data plotter in PyGTK: should I use a pixbuffer?


I am trying to create a multichannel plot very similar to those used for audio editing, but for medical data.

This kind of program is to be used by a person who should (among other things) zoom and pan horizontally over the dataplot, in order to find and classify some meaningful events.

So, I have a data stream (a list of many tens of thousands of samples) which I plot on a gtk.DrawingArea using Cairo, with an initial "scale" based on first and last indexes of the data to plot, and a width ratio between the data interval to plot and the pixel width of the drawing area. I created some mouse events to "drag" the data around, much as most image viewers and even Google Maps do (but I am only working on the horizontal axis by now).

The fact is: redrawing while panning is quite slow, and I think it is because of the redrawing function, since it depends on the lengh of the interval being plotted (related to the "zoom" I set, showing a more dense data interval). I wonder if I should render the whole plot to a (big) pixbuffer, and only reposition this pixbuffer commit the corresponding part to the window drawing area.

So, my questions are: "How is this kind of 2d data plotting with pan/zoom usually done in Pygtk? Is there a 'standard' way of doing it? Should I create a huge pixbuffer which I could use as a cairo source, translating it and 'stamping' on the drawing area cairo surface?"

A shrinked part of my code follows:

class DataView(gtk.DrawingArea):
    """ Plots a 'rectangle' of the data, depending on predefined horizontal and vertical ranges """
    def __init__(self, channel):
        gtk.DrawingArea.__init__(self)
        self.connect("expose_event", self.expose)
        self.channel = dados.channel_content[channel]

        self.top = int(self.channel['pmax'])
        self.bottom = int(self.channel['pmin'])

        # this part defines position and size of the plotting
        self.x_offset = 0
        self.y_offset = 0
        self.x_scale = 1
        self.y_scale = 0.01

    def expose(self, widget, event):
        cr = widget.window.cairo_create()
        rect = self.get_allocation()
        w = rect.width
        h = rect.height

        cr.translate(0, h/2)
        cr.scale(1,-1)

        cr.save()
        self.x_scale = 1.*w/(signalpanel.end - signalpanel.start)
        cr.translate(self.x_offset, self.y_offset)
        cr.scale(self.x_scale, self.y_scale)

        step = 5
        # here I select a slice of my full data list
        stream = self.channel['recording'][signalpanel.start:signalpanel.end:step]

        # here I draw
        cr.move_to(0, stream[0])
        for n,s in enumerate(stream[1:]):
            cr.line_to((n+1)*step, s)
        cr.restore()
        cr.set_source_rgb(0,0,0)
        cr.set_line_width(1)
        cr.stroke()

class ChannelView(gtk.HBox):
    """ contains a DataView surrounded by all other satellite widgets """
    def __init__(self, channel):
        gtk.HBox.__init__(self)
        labelpanel = gtk.VBox()
        labelpanel.set_size_request(100, 100)
        dataview = DataView(channel)
        dataview.connect("motion_notify_event", onmove)
        dataview.connect("button_press_event", onpress)
        dataview.connect("button_release_event", onrelease)
        dataview.connect("destroy", gtk.main_quit)
        dataview.add_events(gtk.gdk.EXPOSURE_MASK
                    | gtk.gdk.LEAVE_NOTIFY_MASK
                    | gtk.gdk.BUTTON_PRESS_MASK
                    | gtk.gdk.BUTTON_RELEASE_MASK
                    | gtk.gdk.POINTER_MOTION_MASK
                    | gtk.gdk.POINTER_MOTION_HINT_MASK)
        self.pack_end(dataview, True, True)
        self.pack_end(gtk.VSeparator(), False, False)

        #populate labelpanel
        """ a lot of widget-creating code (ommited) """

# three functions to pan the data with the mouse
def onpress(widget, event):
    if event.button == 1:
        signalpanel.initial_position = event.x
        signalpanel.start_x = signalpanel.start
        signalpanel.end_x = signalpanel.end
    signalpanel.queue_draw()

def onmove(widget, event):
    if signalpanel.initial_position:
        signalpanel.start = max(0, int((signalpanel.start_x - (event.x-signalpanel.initial_position))*widget.x_scale))
        signalpanel.end = int((signalpanel.end_x - (event.x-signalpanel.initial_position))*widget.x_scale)
        print signalpanel.start, signalpanel.end
    signalpanel.queue_draw()

def onrelease(widget, event):
    signalpanel.initial_position = None
    signalpanel.queue_draw()

class PlotterPanel(gtk.VBox):
    """ Defines a vertical panel with special features to manage multichannel plots """
    def __init__(self):
        gtk.VBox.__init__(self)

        self.initial_position = None

        # now these are the indexes defining the slice to plot
        self.start = 0
        self.end = 20000 # full list has 120000 values

if __name__ == "__main__":
    folder = os.path.expanduser('~/Dropbox/01MIOTEC/06APNÉIA/Samples')
    dados = EDF_Reader(folder, 'Osas2002plusQRS.rec') # the file from where the data come from
    window = gtk.Window()
    signalpanel = PlotterPanel()
    signalpanel.pack_start(ChannelView('Resp abdomen'), True, True)
    window.add(signalpanel)
    window.connect("delete-event", gtk.main_quit)
    window.set_position(gtk.WIN_POS_CENTER)
    window.show_all()
    gtk.main()

Also, if anyone has any other tip on other ways of achieving the same goal, I would be very glad to receive it.

Thanks for reading

EDIT: I changed the code to make the variable step dependant on the proportion between the available pixels to plot and the interval lenght of the data do be plot. This way, if the window has only, say, 1000 pixels, a "slice" of the whole interval will be taken, which have only 1000 sample values. The result is not so smooth, but it's quite fast, and if one wants more detail, it could be zoomed in to increase resolution (thus recalculating the step)


Solution

  • I changed the code to make the variable step dependant on the proportion between the available pixels to plot and the interval lenght of the data do be plot. This way, if the window has only, say, 1000 pixels, a "slice" of the whole interval will be taken, which have only 1000 sample values. The result is not so smooth, but it's quite fast, and if one wants more detail, it could be zoomed in to increase resolution (thus recalculating the step):

    step = int(self.samples/float(w)) if step >= 1 else 1
    stream = self.channel['recording'][startsample:endsample:step]