Painting on a panel – Revisited

In 2012 I wrote an article about porting a C# project for painting on a panel over to Python and using PyQt. Now in 2020 with over 8 years of experience under my belt, I thought it would be fun to revisit this project and re-write it utilizing all those years of production experience.

The project is rather simple, create a widget where users can draw and have it store the strokes as vectors as well as let users pick the size and colour of the brush.

With my original design, the widget held code for everything, from the drawing to mouse moves to holding the data of the lines.

This seems like a perfect candidate for a Model, with views for the drawing\tool interaction as well as views for the colour and line width. As we’re going with a model it should be easy to add in two extra UI elements, a Shape list, and colour sample widget.

The tool has two modes, brush and erase. If we were expanding this, we might want to make these classes but just having them as enums will work great.

class PaintingToolEnum(object):
    """
    Enum for the different tool modes
    """
    BRUSH = 0
    ERASE = 1

One design element which I’m keeping from the original article is the shape class, which would store information on the colour, width, and the points that make up that shape.

Having a class to hold all the shape information means that there is a pretty good encapsulation of data, which makes it easy to save/load/render and manipulate as a whole rather than keeping it elsewhere without context.

Something to help draw out the shape, later on, is knowing the height and width of the final shape, so having a method to calculate and cache this and the end will stop us from having to calculate that every time we want to draw it.

class _SplineShape(object):
    """
    Internal class to hold info on the spline shapes. Not a real spline, just a line between points
    """
    def __init__(self, colour, width):
        """
        Constructor
        
        args:
            colour (QColor): The colour of the spline
            width (int): The width of the spline
        """
        super(_SplineShape, self).__init__()
        self._colour = colour
        self._width = width
        self._points = []
        self._minX = -1
        self._maxX = -1
        self._minY = -1
        self._maxY = -1
        
    def colour(self):
        """
        Get the Colour being used as a QColor
        """
        return self._colour
    
    def width(self):
        """
        Get the Width as an int
        """
        return self._width
    
    def points(self):
        """
        Get a list of all the points as a list of QPoint
        """
        return self._points
    
    def setPoints(self, points):
        """
        Set the points that make up the spline
        
        args:
            points (list of QPoint): The points that make up the spline
        """
        self._points = points
        self.finalizeShape()
    
    def addPoint(self, position):
        """
        Add a point to the spline shape
        
        args:
            position (QPoint): The point to add
        """
        if len(self._points) > 0 :
            if position == self._points[-1]:
                return
        self._points.append(position) 

    def finalizeShape(self):
        """
        Mark the shape as final so data can be cached out for use later on
        """
        xVals = [point.x() for point in self._points]
        yVals = [point.y() for point in self._points]
        self._minX = min(xVals) - self._width
        self._maxX = max(xVals) + self._width
        self._minY = min(yVals) - self._width
        self._maxY = max(yVals) + self._width

    def shapeRect(self):
        """
        Get the Rectangle of the shape
        
        returns:
            QRect of the shape
        """
        return QtCore.QRect(QtCore.QPoint(self._minX, self._minY), QtCore.QPoint(self._maxX, self._maxY))

A pretty big shift in the implementation is the use of a model in the heart of it. This central model will hold all the shapes, be responsible for all the shape logic, and provide signals for events that happen in the model. The idea is that multiple widgets can hook into various parts of the model and are just views on the same centralized data.

With this design we can have two different painting widgets using the same model, which would each other, or with some small changes, show a different view of the data.

Adding in some signals, this will help up hook in the model with the various views, such as the colour swatch, the line size, and the shape view.

class PaintingModel(QtCore.QAbstractItemModel):
    """
    The Model which handles the shapes
    
    Signals:
        modelReset (): Emits when the model is reset
        widthChange (int): The new line width
        colourChange (QColor): The new colour
        shapeAdded (_SplineShape): Emits when a new shape is added and finalized 
    """
    modelReset = QtCore.Signal()
    widthChange = QtCore.Signal(object)
    colourChange = QtCore.Signal(object)
    shapeAdded = QtCore.Signal(object)
    
    def __init__(self):
        """
        Constructor
        """
        super(PaintingModel, self).__init__()
        self._shapes = []
        self._width = 1
        self._colour = QtGui.QColor(100, 100, 100)
        self._currentShape = None
        
    def width(self):
        """
        Get the current width for all new shapes as a int
        """
        return self._width
    
    def setWidth(self, newWidth):
        """
        Set the current width for all new shapes
        
        args:
            newWidth (int): The line width
        """
        self._width = newWidth
        self.widthChange.emit(newWidth)
        
    def colour(self):
        """
        Get the current colour for all new shapes as a QColor
        """
        return self._colour
    
    def setColour(self, newColour):
        """
        Set the current colour for all new shapes
        
        args:
            newColour (QColor): The line colour
        """
        self._colour = newColour
        self.colourChange.emit(newColour)
        
    def splineShapes(self):
        """
        Get a list of shapes as _SplineShape instances
        """
        return self._shapes

    def newShape(self, position):
        """
        Start a new shape
        
        args:
            position (QPoint): The position to start the new shape at
        """
        newShape = _SplineShape(self._colour, self._width)
        newShape.addPoint(position)
        self._shapes.append(newShape)
        self._currentShape = newShape

    def addPointToCurrentShape(self, position):
        """
        Add a new point to the current shape
        
        args:
            position (QPoint): The new point to add to the shape
        """
        if self._currentShape is not None:
            self._currentShape.addPoint(position)
        
    def finishCurrentShape(self):
        """
        Mark the current shape as complete
        """
        self._currentShape.finalizeShape()
        self.beginInsertRows(self.createIndex(0,0), 0, 1)
        self.endInsertRows()
        self.shapeAdded.emit(self._currentShape)
        self._currentShape = None
        
    def clear(self):
        """
        Clear all the shapes. Will trigger the modelReset signal
        """
        self._shapes = []
        self._currentShape = None
        self.modelReset.emit()

Having the model do all the interaction with the data, means that we have a centralized interface for all actions. One task that we want to do is to be able to erase points. With the use of the _SplineShape’s we quickly do a distance check between a given location and a radius to see which points need to be removed.

Original Shape | Erase just removing points | Erase splitting up to sub-shapes

This is clearly not a good solution as it badly deforms the orginal shape. Instead we need to find out which points need to be removed and use that to split the orginal shape into smaller shapes, as shapes do not connect so there will be a clear gap in the lines.

Another technical hurdle is that we have to preserve the order of the shapes, making sure that any newly created shapes follow the originals location in the list of shapes.

    def erasePoint(self, location):
        """
        Erase a given point using the location and current width. Will split up lines into differnt
        shapes
        
        args:
            location (QPoint): The position to erase
        """
        newShapeOffset = 0
        for layerIdx, shape in enumerate(list(self._shapes)):
            idxToRemove = [idx for idx, point in enumerate(shape.points()) if QtCore.QLineF(point, location).length() < self._width]
            if len(idxToRemove) == 0:
                continue
            allPoints = shape.points()
            idxToRemove.insert(0, 0)
            idxToRemove.append(len(allPoints))
            for idx, points in enumerate([allPoints[idxToRemove[idx-1]:idxToRemove[idx]] for idx in xrange(1, len(idxToRemove))]):
                if idx == 0:
                    if len(points) == 0:
                        self._shapes.remove(shape)
                        newShapeOffset -= 1
                    else:
                        shape.setPoints(points)
                else:
                    if len(points) > 1:
                        newShape = _SplineShape(shape.colour(), shape.width())
                        newShape.setPoints(points)
                        # insert so it doesnt change the order
                        self._shapes.insert(layerIdx+newShapeOffset, newShape)
                        self.beginInsertRows(self.createIndex(0,0), 0, 1)
                        self.shapeAdded.emit(self._currentShape)
                        self.endInsertRows()
                        newShapeOffset += 1

The reason using a QAbstractItemModel here is that we can use this model with some standard views that Qt provides for some easy displaying of data, such as with the showing of the individual shapes, all being driven through this one model.

To accomplish this, Qt expects us to implement a few methods which we can somewhat mock out of the QAbstractItemModel to get some of the great benefits without needing some of the children and parent items.

Thanks to this we can throw a QListView on top of the model and see each of the shapes, their name and even get a small thumbnail of it.

When going through the design of the _SplineShape class, I mentioned the need of caching the min and max height & width and this is for that purpose. With this information we can create a QPixmap and use a QPainter with it to draw just the shape, so it is really zoomed in rather than have the render the whole space, which makes it hard to see.

For example this is the effect when the shape info is know and accounted for.

and this is when the whole image has to be drawn. Note that I’ve had to increase the line size to make it visible in the smaller render.

The way that view gets information from the model is through the data method, which uses as index, so the model knows what item its talking about, and the role, which describes what the view is trying to do, be it display text, images, font etc. By using the DisplayRole we can give it a name and with the DecorationRole we can give it a image to display in the view.

    def data(self, index, role=QtCore.Qt.DisplayRole):
        """
        ReImplemented Method
        
        Get data for the given index of the model given the role 
        """
        if not index.isValid():
            return None
        
        row = index.row()
        if index.column() == 0:
            if role == QtCore.Qt.DisplayRole:
                return "Shape #{}".format(row)

            if role == QtCore.Qt.DecorationRole:
                # Re-Render just this layer
                splineShape = self._shapes[row]
                rect = splineShape.shapeRect() 
                
                img = QtGui.QPixmap(rect.width(), rect.height())
                
                img.fill(QtCore.Qt.white)
                painter = QtGui.QPainter(img)
                painter.translate(-rect.x(), -rect.y())
                pen = QtGui.QPen(splineShape.colour(), splineShape.width(), QtCore.Qt.SolidLine)
                painter.setPen(pen)
                painter.drawPolyline(splineShape.points())
                    
                del painter
                return img.scaledToHeight(100)
        return None

    def columnCount(self, index=QtCore.QModelIndex()):
        """
        ReImplemented Method
        
        Get the number of columns in the model 
        """
        return 1

    def rowCount(self, index=QtCore.QModelIndex()):
        """
        ReImplemented Method
        
        Get the number of rows in the model 
        """
        return len(self._shapes)
    
    def index(self, row, column, parent=QtCore.QModelIndex()):
        """
        ReImplemented Method
        
        Get the index given the row and column. Parent doesnt matter as its a semi fake model 
        """
        if parent.isValid():
            return QtCore.QModelIndex()
        return self.createIndex(row, column, self)

    def parent(self, index):
        """
        ReImplemented Method
        
        Get the index of the model items parent, which is a QModelIndex as this is a fake model 
        """
        return QtCore.QModelIndex()

With the model done, we want to make a very simple colour swatch widget, which will take the model instance and draw whatever is the current colour, even if it changes.

This is a very simple widget, and just requires overriding the sizeHint and paintEvent method as well as hooks for the model and we’re all set.

class PaintingModelColourSample(QtGui.QWidget):
    """
    A view on the PaintingModel current colour
    """
    def __init__(self, parent=None):
        """
        Constructor
        
        kargs:
            parent (QWidget): The parent widget
        """
        super(PaintingModelColourSample, self).__init__(parent=parent)
        self._model = None
        self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred)
        
    def setModel(self, model):
        """
        Set the current model that the view is using
        
        args:
            model (PaintingModel): The model to drive this view
        """
        self._model = model
        self._model.colourChange.connect(self.repaint)
        
    def model(self):
        """
        Get the current model that the view is using
        """
        return self._model

    def paintEvent(self, event):
        """
        ReImplemented Method
        
        Drawing the colour swatch from the model 
        """
        painter = QtGui.QPainter()
        painter.begin(self)

        # Draw box around the widget
        painter.fillRect(0,0,self.width(), self.height(), self._model.colour())
        painter.drawRect(0,0, self.width(), self.height())
        painter.end()
    
    def sizeHint(self):
        """
        ReImplemented Method
        
        The size hint of the widget 
        """
        return QtCore.QSize(20,20)

Taking what we’ve got from the PaintingModelColourSample widget, we want to start writing our view on the model. This will act to draw the model lines as well as handle the mouse interaction.

class PaintingModelView(QtGui.QWidget):
    """
    A view on the PaintingModel, acting as an interface level between user and model
    """
    def __init__(self, parent=None):
        """
        Constructor
        
        kargs:
            parent (QWidget): The parent widget
        """
        super(PaintingModelView, self).__init__(parent=parent)
        self._model = None
        self._toolType = PaintingToolEnum.BRUSH
        self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)

    def setModel(self, model):
        """
        Set the current model that the view is using
        
        args:
            model (PaintingModel): The model to drive this view
        """
        self._model = model
        self._model.modelReset.connect(self._handleModelReset)
        
    def model(self):
        """
        Get the current model that the view is using
        """
        return self._model

    def setToolType(self, toolType):
        """
        Set the current tool type to be used by the view
        
        args:
            toolType (PaintingToolEnum): The current tool being used
        """
        self._toolType = toolType

    def _handleModelReset(self):
        """
        Handle the model's reset signal, repainting to clear the panel 
        """
        self.repaint()

    def paintEvent(self, event):
        """
        ReImplemented Method
        
        Drawing the data in the model 
        """
        painter = QtGui.QPainter()
        painter.begin(self)
        
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
        
        # Draw box around the widget
        painter.drawRect(0,0, self.width(), self.height())
        basePen = painter.pen()
        if self._model is not None:
            for splineShape in self._model.splineShapes():
                pen = QtGui.QPen(splineShape.colour(), splineShape.width(), QtCore.Qt.SolidLine)
                painter.setPen(pen)
                painter.drawPolyline(splineShape.points())

        painter.setPen(basePen)
        painter.end()

    def mousePressEvent(self, event):
        """
        ReImplemented Method
        
        Handle the mouse press event, applying the tool to the model if applicable 
        """
        if self._model is not None:
            if event.buttons() == QtCore.Qt.LeftButton:
                if self._toolType == PaintingToolEnum.BRUSH:
                    self._model.newShape(event.pos())
                elif self._toolType == PaintingToolEnum.ERASE:
                    self._model.erasePoint(event.pos())
                    pass
                self.repaint()
        
    def mouseReleaseEvent(self, event):
        """
        ReImplemented Method
        
        Handle the mouse release event, applying the tool to the model if applicable 
        """
        if self._model is not None:
            if self._toolType == PaintingToolEnum.BRUSH:
                self._model.finishCurrentShape()
            self.repaint()

    def mouseMoveEvent(self, event):
        """
        ReImplemented Method
        
        Handle the mouse move event, applying the tool to the model if applicable 
        """
        if self._model is not None:
            if event.buttons() == QtCore.Qt.LeftButton:
                if self._toolType == PaintingToolEnum.BRUSH:
                    self._model.addPointToCurrentShape(event.pos())
                elif self._toolType == PaintingToolEnum.ERASE:
                    self._model.erasePoint(event.pos())
            self.repaint()
    
    def sizeHint(self):
        """
        ReImplemented Method
        
        The size hint of the widget 
        """
        return QtCore.QSize(100, 100)

In the original article, I used a .UI file to combine all these new widgets, but that approach has a lot of drawbacks, so nowadays I prefer to just write it out all in code. The final tool to bring this together is very simple, just adding all the widgets and models, and attaching a few signals up and we’re done!

class PaintingTool(QtGui.QMainWindow):
    """
    Demo Tool Window
    """
    def __init__(self):
        """
        Constructor
        """
        super(PaintingTool, self).__init__()
        self.setupUi()

    def setupUi(self):
        """
        Setup the UI and the model.
        """
        # Layout
        buttonLayout = QtGui.QVBoxLayout()
        colourLayout = QtGui.QHBoxLayout()
        mainLayout = QtGui.QHBoxLayout()
        
        # Widgets
        mainWidget = QtGui.QWidget()
        self._brushRadio = QtGui.QRadioButton("Brush")
        self._eraserRadio = QtGui.QRadioButton("Eraser")
        changeColourButton = QtGui.QPushButton("Change Colour")
        self._colourSample = PaintingModelColourSample()
        self._thicknessSpinner = QtGui.QSpinBox()
        clearButton = QtGui.QPushButton("Clear")
        self._drawingFrame = PaintingModelView()
        self._layerView = QtGui.QListView()
        
        # Model
        self._paintingModel = PaintingModel()
        
        # Configure
        self._brushRadio.setChecked(True)
        self._thicknessSpinner.setMinimum(1)
        self._thicknessSpinner.setValue(self._paintingModel.width())
        self._drawingFrame.setModel(self._paintingModel)
        self._colourSample.setModel(self._paintingModel)
        self._layerView.setModel(self._paintingModel)
        self._layerView.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding)

        # Widgets to Layouts
        colourLayout.addWidget(changeColourButton)
        colourLayout.addWidget(self._colourSample)
        
        buttonLayout.addWidget(self._brushRadio)
        buttonLayout.addWidget(self._eraserRadio)
        buttonLayout.addLayout(colourLayout)
        buttonLayout.addWidget(self._thicknessSpinner)
        buttonLayout.addWidget(self._layerView)
        buttonLayout.addWidget(clearButton)
        
        mainLayout.addLayout(buttonLayout)
        mainLayout.addWidget(self._drawingFrame)
        
        # Layouts to Widgets
        mainWidget.setLayout(mainLayout)
        self.setCentralWidget(mainWidget)
        
        # Signals
        self._thicknessSpinner.valueChanged.connect(self._handleThicknessChange)
        changeColourButton.pressed.connect(self._handleChangeColour)
        clearButton.pressed.connect(self._handleClearButton)
        self._brushRadio.toggled.connect(self._handleToolTypeChange)
        self._eraserRadio.toggled.connect(self._handleToolTypeChange)

    def _handleThicknessChange(self, newValue):
        """
        Handle the Thickness value change to pass it on to the model
        """
        self._paintingModel.setWidth(newValue)

    def _handleChangeColour(self):
        """
        Handle the Colour change to pass it on to the model
        """
        colourDialog = QtGui.QColorDialog(self._paintingModel.colour())
        if colourDialog.exec_():
            self._paintingModel.setColour(colourDialog.selectedColor())
            
    def _handleClearButton(self):
        """
        Handle the clear button press
        """
        self._paintingModel.clear()

    def _handleToolTypeChange(self):
        """
        Handle the tool type changeing 
        """
        if self._brushRadio.isChecked():
            self._drawingFrame.setToolType(PaintingToolEnum.BRUSH)
        elif self._eraserRadio.isChecked():
            self._drawingFrame.setToolType(PaintingToolEnum.ERASE)

This is far from a perfect implementation, and optimization hasn’t been a concern, but a good look at creating and using Qt’s Models for non-standard problems and how we can leverage them.

Its also worth noting that this whole project was done in Python 2.7 with PySide, but shouldn’t pose a problem to port over to Python 3 with PySide2 and would require minimal changes.

What’s great about this is that it’s a great platform to expand and, adding in new brush tools, options to save/load, folders for layers/shapes! 

Download the full code below and have a go!

Thanks for reading

– Geoff Samuel