python-3.xgtk3pygtkpycairodrawingarea

PyGtk3 scaling GdkPixbuf makes Gtk.DrawingArea drawing slower


I've got the following hierarchy:

I've implemented a Zoom tool which basically scales a GdkPixbuf that I draw in my DrawingArea. Originally, the image is 1280x1040. When moving the scrolls, the Draw callback function takes about 0.005s to draw the GdkPixbuf - it looks really smooth.

However, when applying a zoom level of 300%, it takes up to 0.03s, making it look way less smooth. The portion of the DrawingArea that is visible remains always the same. It looks like if the drawing operation took into account the area that is not visible.

I've set up the following code so you guys can run it. The zoom ratio is already at 300%.

# -*- encoding: utf-8 -*-

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf

import cairo
import time


class MyWindow(Gtk.Window):

    def __init__(self):

        Gtk.Window.__init__(self, title="DrawingTool")
        self.set_default_size(800, 600)

        # The Zoom ratio
        self.ratio = 3.
        # The DrawingImage Brush
        self.brush = Brush()

        # Image
        filename = "image.jpg"
        self.original_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
        self.displayed_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
        self.scale_image()

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        # Zoom buttons
        self.button_zoom_in = Gtk.Button(label="Zoom-In")
        self.button_zoom_out = Gtk.Button(label="Zoom-Out")      
        # |ScrolledWindow
        # |-> Viewport
        # |--> DrawingArea 
        scrolledwindow = Gtk.ScrolledWindow()
        viewport = Gtk.Viewport()
        self.drawing_area = Gtk.DrawingArea()
        self.drawing_area.set_size_request(
                              self.displayed_pixbuf.get_width(), self.displayed_pixbuf.get_height())
        self.drawing_area.set_events(Gdk.EventMask.ALL_EVENTS_MASK)

        # Pack
        viewport.add(self.drawing_area)
        scrolledwindow.add(viewport)
        box.pack_start(self.button_zoom_in, False, True, 0)
        box.pack_start(self.button_zoom_out, False, True, 0)
        box.pack_start(scrolledwindow, True, True, 0)
        self.add(box)

        # Connect
        self.connect("destroy", Gtk.main_quit)
        self.button_zoom_in.connect("clicked", self.on_button_zoom_in_clicked)
        self.button_zoom_out.connect("clicked", self.on_button_zoom_out_clicked)
        self.drawing_area.connect("enter-notify-event", self.on_drawing_area_mouse_enter)
        self.drawing_area.connect("leave-notify-event", self.on_drawing_area_mouse_leave)
        self.drawing_area.connect("motion-notify-event", self.on_drawing_area_mouse_motion)
        self.drawing_area.connect("draw", self.on_drawing_area_draw)
        self.drawing_area.connect("button-press-event", self.on_drawing_area_button_press_event)
        self.drawing_area.connect("button-release-event", self.on_drawing_area_button_release_event)

        self.show_all()

    def on_button_zoom_in_clicked(self, widget):
        self.ratio += 0.1
        self.scale_image()
        self.drawing_area.queue_draw()

    def on_button_zoom_out_clicked(self, widget):
        self.ratio -= 0.1
        self.scale_image()
        self.drawing_area.queue_draw()

    def scale_image(self):
        self.displayed_pixbuf = self.original_pixbuf.scale_simple(self.original_pixbuf.get_width() * self.ratio, 
                                   self.original_pixbuf.get_height() * self.ratio, 2)

    def on_drawing_area_draw(self, drawable, cairo_context):

        start = time.time()

        # DrawingArea size depends on Pixbuf size
        self.drawing_area.get_window().resize(self.displayed_pixbuf.get_width(), 
                                              self.displayed_pixbuf .get_height())        
        self.drawing_area.set_size_request(self.displayed_pixbuf.get_width(), 
                                           self.displayed_pixbuf.get_height())
        # Draw image
        Gdk.cairo_set_source_pixbuf(cairo_context, self.displayed_pixbuf, 0, 0)
        cairo_context.paint()
        # Draw lines
        self.brush._draw(cairo_context)

        end = time.time()
        print(f"Runtime of the program is {end - start}")

    def on_drawing_area_mouse_enter(self, widget, event):
        print("In - DrawingArea")

    def on_drawing_area_mouse_leave(self, widget, event):
        print("Out - DrawingArea")

    def on_drawing_area_mouse_motion(self, widget, event):

        (x, y) = int(event.x), int(event.y)
        # Should not happen but just in case.
        if not ( (x >= 0 and x < self.displayed_pixbuf.get_width()) and
                 (y >= 0 and y < self.displayed_pixbuf.get_height()) ):
            return True 

        # If user is holding the left mouse button
        if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
            self.brush._add_point((x, y))
            self.drawing_area.queue_draw()

    def on_drawing_area_button_press_event(self, widget, event):
        self.brush._add_point((int(event.x), int(event.y)))

    def on_drawing_area_button_release_event(self, widget, event):
        self.brush._line_ended()


# ## ## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## ## ## ## ## ## ## ## # 
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
#                                                                           #
#   Brush :                                                                 #
#                                                                           #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # 
## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##

class Brush(object):

    default_rgba_color = (0, 0, 0, 1)

    def __init__(self, width=None, rgba_color=None):

        if rgba_color is None:
            rgba_color = self.default_rgba_color

        if width is None:
            width = 3

        self.__width = width
        self.__rgba_color = rgba_color
        self.__stroke = []
        self.__current_line = []

    def _line_ended(self):
        self.__stroke.append(self.__current_line.copy())
        self.__current_line = []

    def _add_point(self, point):
        self.__current_line.append(point)

    def _draw(self, cairo_context):

        cairo_context.set_source_rgba(*self.__rgba_color)
        cairo_context.set_line_width(self.__width)
        cairo_context.set_line_cap(cairo.LINE_CAP_ROUND)

        cairo_context.new_path()
        for line in self.__stroke:
            for x, y in line:
                cairo_context.line_to(x, y)
            cairo_context.new_sub_path()

        for x, y in self.__current_line:
            cairo_context.line_to(x, y)

        cairo_context.stroke()


# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# ~                          Getters & Setters                            ~ #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # 

    def _get_width(self):
        return self.__width

    def _set_width(self, width):
        self.__width = width

    def _get_rgba_color(self):
        return self.__rgba_color

    def _set_rgba_color(self, rgba_color):
        self.__rgba_color = rgba_color

    def _get_stroke(self):
        return self.__stroke

    def _get_current_line(self):
        return self.__current_line



MyWindow()
Gtk.main()

So, is this a normal unavoidable thing?

EDIT

This is the full code of the solution implemented.

# -*- encoding: utf-8 -*-

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf

import cairo
import time


class MyWindow(Gtk.Window):

    def __init__(self):

        Gtk.Window.__init__(self, title="DrawingTool")
        self.set_default_size(800, 600)

        # The Zoom ratio
        self.ratio = 3.
        # The DrawingImage Brush
        self.brush = Brush()

        # Image
        filename = "image.jpg"
        self.original_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
        self.displayed_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
        self.scale_image()

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        # Zoom buttons
        self.button_zoom_in = Gtk.Button(label="Zoom-In")
        self.button_zoom_out = Gtk.Button(label="Zoom-Out")      
        # |ScrolledWindow
        # |-> Viewport
        # |--> DrawingArea 
        scrolledwindow = Gtk.ScrolledWindow()
        self.viewport = Gtk.Viewport()
        self.drawing_area = Gtk.DrawingArea()
        self.drawing_area.set_size_request(
                              self.displayed_pixbuf.get_width(), self.displayed_pixbuf.get_height())
        self.drawing_area.set_events(Gdk.EventMask.ALL_EVENTS_MASK)

        # Pack
        self.viewport.add(self.drawing_area)
        scrolledwindow.add(self.viewport)
        box.pack_start(self.button_zoom_in, False, True, 0)
        box.pack_start(self.button_zoom_out, False, True, 0)
        box.pack_start(scrolledwindow, True, True, 0)
        self.add(box)

        # Connect
        self.connect("destroy", Gtk.main_quit)
        self.button_zoom_in.connect("clicked", self.on_button_zoom_in_clicked)
        self.button_zoom_out.connect("clicked", self.on_button_zoom_out_clicked)
        self.drawing_area.connect("enter-notify-event", self.on_drawing_area_mouse_enter)
        self.drawing_area.connect("leave-notify-event", self.on_drawing_area_mouse_leave)
        self.drawing_area.connect("motion-notify-event", self.on_drawing_area_mouse_motion)
        self.drawing_area.connect("draw", self.on_drawing_area_draw)
        self.drawing_area.connect("button-press-event", self.on_drawing_area_button_press_event)
        self.drawing_area.connect("button-release-event", self.on_drawing_area_button_release_event)
        scrolledwindow.get_hscrollbar().connect("value-changed", self.on_scrolledwindow_horizontal_scrollbar_value_changed)
        scrolledwindow.get_vscrollbar().connect("value-changed", self.on_scrolledwindow_vertical_scrollbar_value_changed)

        self.show_all()

    def on_button_zoom_in_clicked(self, widget):
        self.ratio += 0.1
        self.scale_image()
        self.drawing_area.queue_draw()

    def on_button_zoom_out_clicked(self, widget):
        self.ratio -= 0.1
        self.scale_image()
        self.drawing_area.queue_draw()

    def scale_image(self):
        self.displayed_pixbuf = self.original_pixbuf.scale_simple(self.original_pixbuf.get_width() * self.ratio, 
                                   self.original_pixbuf.get_height() * self.ratio, 2)

    def on_scrolledwindow_horizontal_scrollbar_value_changed(self, scrollbar):
        self.drawing_area.queue_draw()       

    def on_scrolledwindow_vertical_scrollbar_value_changed(self, scrollbar):
        self.drawing_area.queue_draw()

    def on_drawing_area_draw(self, drawable, cairo_context):

        start = time.time()

        # DrawingArea size depends on Pixbuf size
        self.drawing_area.get_window().resize(self.displayed_pixbuf.get_width(), 
                                              self.displayed_pixbuf .get_height())        
        self.drawing_area.set_size_request(self.displayed_pixbuf.get_width(), 
                                           self.displayed_pixbuf.get_height())

        # (x, y) offsets
        pixbuf_x = int(self.viewport.get_hadjustment().get_value())
        pixbuf_y = int(self.viewport.get_vadjustment().get_value())

        # Width and height of the image's clip
        width = cairo_context.get_target().get_width()
        height = cairo_context.get_target().get_height()
        if pixbuf_x + width > self.displayed_pixbuf.get_width():
            width = self.displayed_pixbuf.get_width() - pixbuf_x
        if pixbuf_y + height > self.displayed_pixbuf.get_height():
            height = self.displayed_pixbuf.get_height() - pixbuf_y

        if width > 0 and height > 0:

            # Create the area of the image that will be displayed in the right position
            image = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, width, height)
            self.displayed_pixbuf.copy_area(pixbuf_x, pixbuf_y, width, height, image, 0, 0)

            # Draw created area of the Sample's Pixbuf
            Gdk.cairo_set_source_pixbuf(cairo_context, image, pixbuf_x, pixbuf_y)
            cairo_context.paint() 

            # Draw brush strokes
            self.brush._draw(cairo_context)

        end = time.time()
        print(f"Runtime of the program is {end - start}")

    def on_drawing_area_mouse_enter(self, widget, event):
        print("In - DrawingArea")

    def on_drawing_area_mouse_leave(self, widget, event):
        print("Out - DrawingArea")

    def on_drawing_area_mouse_motion(self, widget, event):

        (x, y) = int(event.x), int(event.y)
        # Should not happen but just in case.
        if not ( (x >= 0 and x < self.displayed_pixbuf.get_width()) and
                 (y >= 0 and y < self.displayed_pixbuf.get_height()) ):
            return True 

        # If user is holding the left mouse button
        if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
            self.brush._add_point((x, y))
            self.drawing_area.queue_draw()

    def on_drawing_area_button_press_event(self, widget, event):
        self.brush._add_point((int(event.x), int(event.y)))

    def on_drawing_area_button_release_event(self, widget, event):
        self.brush._line_ended()


# ## ## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## ## ## ## ## ## ## ## # 
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
#                                                                           #
#   Brush :                                                                 #
#                                                                           #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # 
## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ##

class Brush(object):

    default_rgba_color = (0, 0, 0, 1)

    def __init__(self, width=None, rgba_color=None):

        if rgba_color is None:
            rgba_color = self.default_rgba_color

        if width is None:
            width = 3

        self.__width = width
        self.__rgba_color = rgba_color
        self.__stroke = []
        self.__current_line = []

    def _line_ended(self):
        self.__stroke.append(self.__current_line.copy())
        self.__current_line = []

    def _add_point(self, point):
        self.__current_line.append(point)

    def _draw(self, cairo_context):

        cairo_context.set_source_rgba(*self.__rgba_color)
        cairo_context.set_line_width(self.__width)
        cairo_context.set_line_cap(cairo.LINE_CAP_ROUND)

        cairo_context.new_path()
        for line in self.__stroke:
            for x, y in line:
                cairo_context.line_to(x, y)
            cairo_context.new_sub_path()

        for x, y in self.__current_line:
            cairo_context.line_to(x, y)

        cairo_context.stroke()


# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #
# ~                          Getters & Setters                            ~ #
# ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # 

    def _get_width(self):
        return self.__width

    def _set_width(self, width):
        self.__width = width

    def _get_rgba_color(self):
        return self.__rgba_color

    def _set_rgba_color(self, rgba_color):
        self.__rgba_color = rgba_color

    def _get_stroke(self):
        return self.__stroke

    def _get_current_line(self):
        return self.__current_line



MyWindow()
Gtk.main()

Solution

  • I've figured it out how to solve this efficiency issue. What I've done is, instead of drawing the whole image, I now draw the specific area of the image that needs to be redrawn.

    I'll explain each line below the code:

        ''' Draw method. '''
        def _draw(self, cairo_context, pixbuf):
    
            # Set drawing area size
            self.__drawing_area.get_window().resize(pixbuf.get_width(), pixbuf.get_height())
            self.__drawing_area.set_size_request(pixbuf.get_width(), pixbuf.get_height())
    
            # (x, y) offsets
            pixbuf_x = int(self.__viewport.get_hadjustment().get_value())
            pixbuf_y = int(self.__viewport.get_vadjustment().get_value())
    
            # Width and height of the image's clip
            width = cairo_context.get_target().get_width()
            height = cairo_context.get_target().get_height()
            if pixbuf_x + width > pixbuf.get_width():
                width = pixbuf.get_width() - pixbuf_x
            if pixbuf_y + height > pixbuf.get_height():
                height = pixbuf.get_height() - pixbuf_y
    
            if width > 0 and height > 0:
    
                # Create the area of the image that will be displayed in the right position
                image = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, width, height)
                pixbuf.copy_area(pixbuf_x, pixbuf_y, width, height, image, 0, 0)
    
                # Draw created area of the Sample's Pixbuf
                Gdk.cairo_set_source_pixbuf(cairo_context, image, pixbuf_x, pixbuf_y)
                cairo_context.paint() 
    
                # Draw brush strokes
                self.__brush._draw(cairo_context)
    

    These lines set the Gtk.DrawingArea size. When my Pixbuf size is larger than the Gtk.DrawingArea visible area, the scrollbars of the Gtk.ScrolledWindow know this and allow you to move along the image. The first line is needed for when the Pixbuf that you are drawing is smaller than the Gtk.DrawingArea visible area, so for instance when you connect mouse signals to the Gtk.DrawingArea, these are only emited when the mouse is over the image.

    # Set drawing area size
    self.__drawing_area.get_window().resize(pixbuf.get_width(), pixbuf.get_height())
    self.__drawing_area.set_size_request(pixbuf.get_width(), pixbuf.get_height())
    

    The x and y offsets are the left and top pixels that you need to:

    i) Tell cairo where to draw in the Gtk.DrawingArea

    ii) Clip your image

    I'm using the Gtk.Viewport but you can use also Gtk.ScrolledWindow.get_hadjustment().get_value() and Gtk.ScrolledWindow.get_vadjustment().get_value(), for example.

    # (x, y) offsets
    pixbuf_x = int(self.__viewport.get_hadjustment().get_value())
    pixbuf_y = int(self.__viewport.get_vadjustment().get_value())
    

    In the following lines I just calculate the width and height that I need in order to clip the image. This is done based on the size of the Gtk.DrawingArea visible area and your image size. With cairo_context.get_target().get_width() you are basically getting the width of the Gtk.DrawingArea visible area, and viceversa with the height.

    # Width and height of the image's clip
    width = cairo_context.get_target().get_width()
    height = cairo_context.get_target().get_height()
    if pixbuf_x + width > pixbuf.get_width():
        width = pixbuf.get_width() - pixbuf_x
    if pixbuf_y + height > pixbuf.get_height():
        height = pixbuf.get_height() - pixbuf_y
    

    Finally you just need to clip your original image and draw it in the right position of the Gtk.DrawingArea. The if-then-else is just a workaround I've made to overcome a problem when zooming out on the right-down edge because the values that the Gtk components return to get the offsets seems not to be updated when they need to.

    EDIT

    I've forgot to mention that you also need to redraw the image when the scrollbars move. Otherwise garbage will be rendered. See the last 2 connect methods in the full code in the edited part of the original question.