1
votes

I need to implement the undo functionality in this widget, activated with the key combination Ctrl + Z. I can draw lines on an image passed in input to the constructor. The idea is therefore to remove the last item from the list of lines (I add a line to this list every time I draw one) and redraw all the other lines when pressing Ctrl + Z. How can I implement this refresh? Is there a more effective way to do such a thing?

Code:

from PyQt5 import QtWidgets, Qt
from PyQt5.QtCore import QSize, QPoint
from PyQt5.QtGui import QImage
import numpy as np
import sys
from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QPixmap, QPainter, QPen

class DistanceWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(DistanceWindow, self).__init__(parent)
        self.axial = np.random.rand(512, 512)
        print(self.axial.shape)
        self.axial = QPixmap(QImage(self.axial, self.axial.shape[1], self.axial.shape[0], QImage.Format_Indexed8))
        self.axialWidget = DrawWidget(self.axial)


class DrawWidget(QtWidgets.QWidget):
    def __init__(self, image):
        super().__init__()
        self.drawing = False
        self.startPoint = None
        self.endPoint = None
        self.image = image
        self.setGeometry(100, 100, 500, 300)
        self.resize(self.image.width(), self.image.height())
        self.show()
        self.lines = []

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.startPoint = event.pos()

    def mouseMoveEvent(self, event):
        if self.startPoint:
            self.endPoint = event.pos()
            self.update()

    def mouseReleaseEvent(self, event):
        if self.startPoint and self.endPoint:
            self.updateImage()

    def paintEvent(self, event):
        painter = QPainter(self)
        dirtyRect = event.rect()
        painter.drawImage(dirtyRect, QImage(self.image), dirtyRect)
        if self.startPoint and self.endPoint:
            painter.drawLine(self.startPoint, self.endPoint)

    def updateImage(self):
        if self.startPoint and self.endPoint:
            painter = QPainter(self.image)
            painter.setPen(QPen(Qt.red, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
            painter.drawLine(self.startPoint, self.endPoint)
            firstPoint = np.array([self.startPoint.x(), self.startPoint.y()])
            secondPoint = np.array([self.endPoint.x(), self.endPoint.y()])
            distance = np.sqrt((secondPoint[0]-firstPoint[0])**2 + (secondPoint[1]-firstPoint[1])**2)
            painter.setPen(QPen(Qt.yellow))
            painter.drawText(secondPoint[0], secondPoint[1] + 10, str(distance) + 'mm')
            #line info
            line = {}
            line['points'] = [self.startPoint, self.endPoint]
            line['distance'] = distance
            self.lines.append(line)
            #####################################
            painter.end()
            self.startPoint = self.endPoint = None
            self.update()

    def keyPressEvent(self, event):
        if event.key() == (Qt.Key_Control and Qt.Key_Z):
            self.undo()

    def undo(self):
        #Delete the last line from self.lines and draw all the others

if __name__ == '__main__':
    app = QApplication(sys.argv)
    main = DistanceWindow()
    sys.exit(app.exec_())
    
2
This needs a minimal reproducible example. Please make it so that people can directly run the code and test it.Jussi Nurminen
@JussiNurminen Thanks, I added the code to reproduce the exampleGibser

2 Answers

2
votes

Update.

I repared bug.

If you draw lines at multiple times, and Ctrl+Z many times, and you redo,

you get the lastest image.

So, I repair my code and you can come to do the execution one by one. sorry.

Please try this.

After drawing lines,

and you push Ctrl+Z & Ctrl+Y in turn.

You can undo & redo implementation.

I would you like to compare how to implement them.

Normally, if you want to implement undo&redo, you can use QUndoStack. The concrete execution is writtin in QUndoCommand.

from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt, QLineF
from PyQt5.QtGui import QPainter, QImage, QPen
import sys
import numpy as np

class UndoCommand(QtWidgets.QUndoCommand):
    def __init__(self, startPoint, endPoint, image, parent=None):
        super(UndoCommand, self).__init__()
        self.draw_widget = parent
        self._startPoint = startPoint
        self._endPoint = endPoint
        self.image = image
        #originalimage
        self._image = QImage(image)
        self._init = True
        self.last_line = None
    def redo(self):
        #the contents of updateImage
        if self._startPoint and self._endPoint and self._init:
            painter = QPainter(self.image)
            painter.setPen(QPen(Qt.red, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
            painter.drawLine(self._startPoint, self._endPoint)
            firstPoint = np.array([self._startPoint.x(), self._startPoint.y()])
            secondPoint = np.array([self._endPoint.x(), self._endPoint.y()])
            delta = (secondPoint - firstPoint)
            delta = np.multiply(delta, self.draw_widget.pixelSpacing)
            distance = np.sqrt(delta[0] ** 2 + delta[1] ** 2)
            painter.setPen(QPen(Qt.yellow))
            painter.drawText(secondPoint[0], secondPoint[1] + 10, str(distance) + 'mm')
            #line info              
            line = {}
            line['points'] = [self._startPoint, self._endPoint]
            line['distance'] = distance
            self.draw_widget.lines.append(line)
            #####################################
            self.last_line = line            
            painter.end()            
            self.draw_widget.startPoint = self.draw_widget.endPoint = None
            self.draw_widget.image = self.image
            self.draw_widget.update()
            self._init = False
        else:
            self.draw_widget.lines.append(self.last_line)
            self.draw_widget.image = self.image
            self.draw_widget.update()
    def undo(self):
        self.draw_widget.image = self._image
        self.draw_widget.lines.remove(self.last_line) 
        self.draw_widget.update()
class DrawWidget(QtWidgets.QWidget):
    def __init__(self, image, pixelSpacing):
        super().__init__()
        self.undostack = QtWidgets.QUndoStack()
        self.lines = []
        self.drawing = False
        self.startPoint = None
        self.endPoint = None
        self.pixelSpacing = pixelSpacing
        self.image = image
        self.setGeometry(100, 100, 500, 300)
        self.resize(self.image.width(), self.image.height())        
        self.show()        
        
    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.startPoint = event.pos()

    def mouseMoveEvent(self, event):
        if self.startPoint:
            self.endPoint = event.pos()
            self.update()

    def mouseReleaseEvent(self, event):
        if self.startPoint and self.endPoint:
            undocommand = UndoCommand(self.startPoint, self.endPoint, QImage(self.image), self)
            self.undostack.push(undocommand)
            #self.undostack.redo does the same thing instead of this method.You need the same code in the redo method and add some codes for it.
#            self.updateImage()

    def paintEvent(self, event):
        painter = QPainter(self)
        dirtyRect = event.rect()
        painter.drawImage(dirtyRect, QImage(self.image), dirtyRect)
        if self.startPoint and self.endPoint:
            painter.drawLine(self.startPoint, self.endPoint)


#    def updateImage(self):
#        if self.startPoint and self.endPoint:
#            painter = QPainter(self.image)
#            painter.setPen(QPen(Qt.red, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
#            painter.drawLine(self.startPoint, self.endPoint)
#            firstPoint = np.array([self.startPoint.x(), self.startPoint.y()])
#            secondPoint = np.array([self.endPoint.x(), self.endPoint.y()])
#            delta = (secondPoint - firstPoint)
#            delta = np.multiply(delta, self.pixelSpacing)
#            distance = np.sqrt(delta[0] ** 2 + delta[1] ** 2)
#            painter.setPen(QPen(Qt.yellow))
#            painter.drawText(secondPoint[0], secondPoint[1] + 10, str(distance) + 'mm')
#            #line info
#            line = {}
#            line['points'] = [self.startPoint, self.endPoint]
#            line['distance'] = distance
#            self.lines.append(line)
#            #####################################
#            painter.end()
#            self.startPoint = self.endPoint = None
#            self.update()

    def keyPressEvent(self, event):
        if event.key() == (Qt.Key_Control and Qt.Key_Y):
            self.undostack.redo()
        if event.key() == (Qt.Key_Control and Qt.Key_Z):
            self.undostack.undo()

    def undo(self):
        #this is not used.
        pass
        #Delete the last line from self.lines and draw all the others
        
def main():
    
    app = QtWidgets.QApplication([]) if QtWidgets.QApplication.instance() is None else QtWidgets.QApplication.instance()
    app.setStyle(QtWidgets.QStyleFactory.create('Fusion'))
    d =  DrawWidget(QImage("first.png"), 10)
    d.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()
1
votes

When an undo support is going to be implemented, the "undoable" object must be able to restore its previous state. This is obviously not possible for raster based images, for which the "painting" is considered destructive: once a pixel color is altered, there's no way to know its previous state.

A possibility is to store the previous raster state, but this approach is certainly not suggested: if you always store the full image, you'll risk using too much memory, and implementing a system that only stores the portions of the image that has been modified is certainly not an option for your situation.

When dealing with vector graphics, the easiest way is to store the changes as painting "routines" and only save the image when actually needed, so that the modifications are only painted with the widget's paintEvent (obviously you need to modify the updateImage function to actually store the image).
This is usually much faster and allows to remove painting functions arbitrarily.

In the example below, I'm using the self.lines which you already created, but with some modifications to makes things simpler and more clear.

class DrawWidget(QtWidgets.QWidget):
    # ...

    def mouseMoveEvent(self, event):
        if self.startPoint:
            self.endPoint = event.pos()
            self.update()

    def mouseReleaseEvent(self, event):
        if self.startPoint and self.endPoint:
            line = QLineF(self.startPoint, self.endPoint)
            self.lines.append({
                'points': line, 
                'distance': line.length() * self.pixelSpacing, 
            })
            self.startPoint = self.endPoint = None
            self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHints(painter.Antialiasing)
        dirtyRect = event.rect()
        painter.drawImage(dirtyRect, QImage(self.image), dirtyRect)
        if self.startPoint and self.endPoint:
            painter.drawLine(self.startPoint, self.endPoint)
        linePen = QPen(Qt.red, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        for lineData in self.lines:
            line = lineData['points']            
            painter.setPen(linePen)
            painter.drawLine(line.p1(), line.p2())
            painter.setPen(Qt.yellow)
            painter.drawText(line.p2() + QPoint(0, 10), 
                '{}mm'.format(lineData['distance']))

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Z and event.modifiers() == Qt.ControlModifier:
            self.undo()

    def undo(self):
        if self.lines:
            self.lines.pop(-1)
            self.update()

Some notes about the modifications.
There's absolutely no need for NumPy for what you're doing. As you can see you can work out everything you need just with Qt's classes and functions; most importantly, in this case, I'm using a QLineF, which is an abstract representation of a floating point precision vector between two points (the distance between two points can be obtained with QLineF(p1, p2).length()). While this is obviously a little slower than python's math or numpy's function, using a QLine in such situations is certainly better, for the following reasons: you'll need a line anyway, you don't need a 30-40mb python module to compute a Pythagorean distance, it's a single object representing a single object, it keeps code simpler.
Key events cannot be used with binary operators, as they are integers, not binary flags: in fact, your code would call undo even when pressing Z only or with other modifiers; the Ctrl key is a modifier and as such cannot be combined with standard keys when looking for keyboard combination, so you need to check event.modifiers() instead.
This is obviously a very basic implementation, you can add a "redo" support just by storing the index of the current "command".

Finally, for more complex user cases, there's the QUndo framework, which is a bit complex than what you're probably going to need, but it's still important to know about it and understand when it's actually needed.