Cairo GTK draw a line with transparency (like a highlighter pen)
Asked Answered
P

2

17

I am trying to create a simple drawing application using Python, GTK3 and cairo. The tool should have different brushes and some kind of a highlighter pen. I figured I can use the alpha property of the stroke to create it. However, the connecting points are created overlapping and that creates a weird effect.

enter image description here

Here is the code responsible for this red brush and the highlighter mode:

def draw_brush(widget, x, y, odata, width=2.5, r=1, g=0, b=0, alpha=1):

    cr = cairo.Context(widget.surface)
    cr.set_source_rgba(r, g, b, alpha)
    cr.set_line_width(width)
    cr.set_line_cap(1)
    cr.set_line_join(0)   

    for stroke in odata:
        for i, point in enumerate(stroke):
            if len(stroke) == 1:
                radius = 2
                cr.arc(point['x'], point['y'], radius, 0, 2.0 * math.pi)
                cr.fill()
                cr.stroke()
            elif i != 0:
                cr.move_to(stroke[i - 1]['x'], stroke[i - 1]['y'])
                cr.line_to(point['x'], point['y'])                
                cr.stroke() 

    cr.save()

The code that draws on mouse click:

def motion_notify_event_cb(self, widget, event):

    point = {'x': event.x, 'y': event.y, 'time': time.time()}

    if self.odata:
        self.odata[-1].append(point)

    if widget.surface is None:
        return False

    if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
        if self.buttons['current'] == 'freehand':
            draw_brush(widget, event.x, event.y, self.odata)
        if self.buttons['current'] == 'highlight':
            draw_brush(widget, event.x, event.y, self.odata, width=12.5,
                       r=220/255, g=240/255, b=90/255, alpha=0.10)

    widget.queue_draw()

    return True

Can someone point out a way to prevent the overlapping points in this curve?

Update

Uli's solution seems to offer a partial remedy, but the stroke is still not good looking, it seems that it's redrawn over and over:

enter image description here

Update with partially working code

I still have not succeeded in creating a highlighter pen with cairo. The closest I can get is in the following gist. The application shutter, has a similar functionality but it's written in Perl on top of the libgoocanvas which is not maintained anymore. I hope a bounty here will change the situation ...

update

available operators (Linux, GTK+3):

In [3]: [item for item in dir(cairo) if item.startswith("OPERATOR")]
Out[3]: 
['OPERATOR_ADD',
 'OPERATOR_ATOP',
 'OPERATOR_CLEAR',
 'OPERATOR_DEST',
 'OPERATOR_DEST_ATOP',
 'OPERATOR_DEST_IN',
 'OPERATOR_DEST_OUT',
 'OPERATOR_DEST_OVER',
 'OPERATOR_IN',
 'OPERATOR_OUT',
 'OPERATOR_OVER',
 'OPERATOR_SATURATE',
 'OPERATOR_SOURCE',
 'OPERATOR_XOR']
Palmer answered 1/7, 2016 at 10:26 Comment(8)
My hunch is that this issue is one of blending, where the RGBA colors of the lines of the highlighter are being multiplied on top of each other, resulting in increasingly oqapue, brighter strokes. You might want to take a look at this. However, at least on my Window's install of python + gtk3 + cairo, the blend options are limited. What cairo.OPERATOR_* options are available on your system? You can list them with print dir(cairo).Valentinavalentine
Please see my latest update for the available operators.Palmer
In that case you have the same limitations as I do! My next question is what would you like to occur when there are self intersections when you draw with the highlighter. That is, when you are drawing a loop and the highlighter crosses twice over the same spot, should it look more opaque or should it look like you only drew over the intersecting spot once?Valentinavalentine
It should look like it is only drawn once. Multiple drawing should not be "OVER" like the default operator. I tried using "ATOP" but this totally kills transparency and colors look opaque, not like a highlighter pen.Palmer
In that case, the partial solution is to use OPERATOR_SOURCE. However, some other structural changes are going to have to be made to your code. Basically, we should draw to an empty texture in draw_brush and multiply those pixels manually with the real background pixels.Valentinavalentine
Can you fork my gist? And post the proposed changes there? Please add this as an answer here. I have already became happy with setting a very low alpha and the operator "ATOP". I really would appreciate it, and there is that bounty...Palmer
Ok I will post an answer and the relevant code once I have made all of those changes! It might take a while though, so expect it later today or tomorrow.Valentinavalentine
Let us continue this discussion in chat.Valentinavalentine
V
10

First, sorry for causing all of that confusion in the comments to your question. It turns out that I was complicating the problem for (partially) no reason! Here is my (heavily-modified) code:

#!/usr/bin/python

from __future__ import division
import math
import time
import cairo
import gi; gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk
from gi.repository.GdkPixbuf import Pixbuf
import random

class Brush(object):
    def __init__(self, width, rgba_color):
        self.width = width
        self.rgba_color = rgba_color
        self.stroke = []

    def add_point(self, point):
        self.stroke.append(point)

class Canvas(object):
    def __init__(self):
        self.draw_area = self.init_draw_area()
        self.brushes = []

    def draw(self, widget, cr):
        da = widget
        cr.set_source_rgba(0, 0, 0, 1)
        cr.paint()
        #cr.set_operator(cairo.OPERATOR_SOURCE)#gets rid over overlap, but problematic with multiple colors
        for brush in self.brushes:
            cr.set_source_rgba(*brush.rgba_color)
            cr.set_line_width(brush.width)
            cr.set_line_cap(1)
            cr.set_line_join(cairo.LINE_JOIN_ROUND)
            cr.new_path()
            for x, y in brush.stroke:
                cr.line_to(x, y)
            cr.stroke()

    def init_draw_area(self):
        draw_area = Gtk.DrawingArea()
        draw_area.connect('draw', self.draw)
        draw_area.connect('motion-notify-event', self.mouse_move)
        draw_area.connect('button-press-event', self.mouse_press)
        draw_area.connect('button-release-event', self.mouse_release)
        draw_area.set_events(draw_area.get_events() |
            Gdk.EventMask.BUTTON_PRESS_MASK |
            Gdk.EventMask.POINTER_MOTION_MASK |
            Gdk.EventMask.BUTTON_RELEASE_MASK)
        return draw_area

    def mouse_move(self, widget, event):
        if event.state & Gdk.EventMask.BUTTON_PRESS_MASK:
            curr_brush = self.brushes[-1]
            curr_brush.add_point((event.x, event.y))
            widget.queue_draw()

    def mouse_press(self, widget, event):
        if event.button == Gdk.BUTTON_PRIMARY:
            rgba_color = (random.random(), random.random(), random.random(), 0.5)
            brush = Brush(12, rgba_color)
            brush.add_point((event.x, event.y))
            self.brushes.append(brush)
            widget.queue_draw()
        elif event.button == Gdk.BUTTON_SECONDARY:
            self.brushes = []

    def mouse_release(self, widget, event):
        widget.queue_draw()

class DrawingApp(object):
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.window = Gtk.Window()
        self.window.set_border_width(8)
        self.window.set_default_size(self.width, self.height)
        self.window.connect('destroy', self.close)
        self.box = Gtk.Box(spacing=6)
        self.window.add(self.box)
        self.canvas = Canvas()
        self.box.pack_start(self.canvas.draw_area, True, True, 0)
        self.window.show_all()

    def close(self, window):
        Gtk.main_quit()

if __name__ == "__main__":
    DrawingApp(400, 400)
    Gtk.main()

Here are the list of changes I made:

  1. Replaced the inheritance in your code with a composition-based approach. That is, instead of inheriting from Gtk.Window or Gtk.DrawingArea, I created Brush, Canvas, and DrawingApp objects that contain these Gtk elements. The idea of this is to allow more flexibility in creating relevant classes to our application and hides all of the nasty Gtk internals as much as possible in setup functions. Hopefully this makes the code a bit clearer. I have no idea why all the tutorials for Gtk insist on using inheritance.
  2. Speaking of the Brush class, there is now a Brush class! Its purpose is simple: it just contains information about the coordinates draw for a given stroke, its line width, and its color. A list of brush strokes making the drawing is stored as a property of DrawingApp. This is convenient because...
  3. ... all of the rendering is contained within the draw function of the Canvas class! All this does is draw the black screen, followed by rendering the brush strokes one by one as individual paths to the screen. This solves the problem with the code provided by @UliSchlachter. While the idea of a single connected path was right (and I used that here), all of the iterations of that path were being accumulated and drawn on top of each other. This explains your update image, where the start of each stroke was more opaque due to accumulating the most incomplete strokes.
  4. For the sake of color variety, I made the app generate random highlighter colors every time you click with the left mouse button!

Note that the last point illustrates an issue with the blending. Try drawing multiple overlapping strokes and see what happens! You will find that the more overlaps there are, the more opaque it gets. You can use the cairo.OPERATOR_SOURCE setting to counteract this, but I don't think this is an ideal solution as I believe it overwrites the content underneath. Let me know if this solution is fine or if this also needs to be corrected. Here is a picture of the final result, for your reference:

Image of working highlighting application - note the multiple stroke colors!

Hope this helps!

Valentinavalentine answered 13/12, 2016 at 5:7 Comment(2)
Hi, this helps a lot! Would you be open for more communication via email?Palmer
I think an upgrade would be to draw each “brush” with the suggested operation cairo.OPERATOR_SOURCE, but use different groups (with cairo_push_group / cairo_pop_group_to_source / cairo_paint) to avoid different brushes erasing each other.Dualism
P
6

Each move_to() creates a new sub-path that is drawn separately. What you want is a single, connected path.

As far as I know, cairo turns a line_to()-call into a move_to() if there is no current point yet, so the following should work:

def draw_brush(widget, x, y, odata, width=2.5, r=1, g=0, b=0, alpha=1):

    cr = cairo.Context(widget.surface)
    cr.set_source_rgba(r, g, b, alpha)
    cr.set_line_width(width)
    cr.set_line_cap(1)
    cr.set_line_join(0)   

    for stroke in odata:
        cr.new_path()
        for i, point in enumerate(stroke):
            if len(stroke) == 1:
                radius = 2
                cr.arc(point['x'], point['y'], radius, 0, 2.0 * math.pi)
                cr.fill()
            else:
                cr.line_to(point['x'], point['y'])                
        cr.stroke()

    cr.save() # What's this for?

Note that I removed the cr.stroke() after the cr.fill(), because it doesn't do anything. The fill just cleared the path, so there is nothing to stroke.

Precedent answered 1/7, 2016 at 16:15 Comment(4)
This is already much better. Thank you for that. but it is still a problem with the recording of odata in the event handler I think. Take a look at the image abovePalmer
as for cr.save() I took the code from some example, and I am not sure it is needed really.Palmer
Uhm... no idea? Perhaps clear everything before drawing your stuff. Add cr.paint() before you set the source color. This should fill everything with black. If this helps, then you were drawing ontop of the old drawing and thus painting earlier lines multiple times, making them less transparent.Precedent
your last suggestion makes this fill everything with black with every stroke. So I can't create multiple strokes, or use a pre-existing canvasPalmer

© 2022 - 2024 — McMap. All rights reserved.