コード例 #1
0
ファイル: QtImageViewerPlus.py プロジェクト: WeiHao-19/TagLab
    def clear(self):

        QtImageViewer.clear(self)
        self.selected_blobs = []
        self.undo_data = Undo()

        for blob in self.annotations.seg_blobs:
            self.undrawBlob(blob)
            del blob

        self.annotations = Annotation()
コード例 #2
0
ファイル: QtImageViewerPlus.py プロジェクト: ldelprete/TagLab
    def __init__(self, taglab_dir):
        QtImageViewer.__init__(self)

        self.logfile = None #MUST be inited in Taglab.py
        self.project = Project()
        self.image = None
        self.channel = None
        self.annotations = Annotation()
        self.selected_blobs = []
        self.taglab_dir = taglab_dir
        self.tools = Tools(self)
        self.tools.createTools()

        self.undo_data = Undo()

        self.dragSelectionStart = None
        self.dragSelectionRect = None
        self.dragSelectionStyle = QPen(Qt.white, 1, Qt.DashLine)
        self.dragSelectionStyle.setCosmetic(True)

        # Set scrollbar
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)


        # DRAWING SETTINGS
        self.border_pen = QPen(Qt.black, 3)
        #        pen.setJoinStyle(Qt.MiterJoin)
        #        pen.setCapStyle(Qt.RoundCap)
        self.border_pen.setCosmetic(True)
        self.border_selected_pen = QPen(Qt.white, 3)
        self.border_selected_pen.setCosmetic(True)

        self.showCrossair = False
        self.mouseCoords = QPointF(0, 0)
        self.crackWidget = None

        self.setContextMenuPolicy(Qt.CustomContextMenu)

        self.refine_grow = 0.0 #maybe should in in tools
        self.refine_original_mask = None
        self.refine_original_blob = None

        self.active_label = None
コード例 #3
0
ファイル: Image.py プロジェクト: ldelprete/TagLab
    def __init__(self,
                 rect=[0.0, 0.0, 0.0, 0.0],
                 map_px_to_mm_factor=1.0,
                 width=None,
                 height=None,
                 channels=[],
                 id=None,
                 name=None,
                 acquisition_date="",
                 georef_filename="",
                 workspace=[],
                 metadata={},
                 annotations={},
                 working_area=[]):

        #we have to select a standanrd enforced!
        #in image standard (x, y, width height)
        #in numpy standard (y, x, height, width) #no the mixed format we use now I REFUSE to use it.
        #in range np format: (top, left, bottom, right)
        #in GIS standard (bottom, left, top, right)
        self.rect = rect  #coordinates of the image. (in the spatial reference system)
        self.map_px_to_mm_factor = map_px_to_mm_factor  #if we have a references system we should be able to recover this numner
        # otherwise we need to specify it.
        self.width = width
        self.height = height  #in pixels!

        self.annotations = Annotation()
        for data in annotations:
            blob = Blob(None, 0, 0, 0)
            blob.fromDict(data)
            self.annotations.addBlob(blob)

        self.channels = list(map(lambda c: Channel(**c), channels))

        self.id = id  # internal id used in correspondences it will never changes
        self.name = name  # a label for an annotated image
        self.workspace = workspace  # a polygon in spatial reference system
        self.working_area = working_area  # this is the working area of data exports for training
        self.acquisition_date = acquisition_date  # acquisition date is mandatory (format YYYY-MM-DD)
        self.georef_filename = georef_filename  # image file (GeoTiff) contained the georeferencing information
        self.metadata = metadata  # this follows image_metadata_template, do we want to allow freedom to add custome values?
コード例 #4
0
ファイル: Image.py プロジェクト: ldelprete/TagLab
class Image(object):
    def __init__(self,
                 rect=[0.0, 0.0, 0.0, 0.0],
                 map_px_to_mm_factor=1.0,
                 width=None,
                 height=None,
                 channels=[],
                 id=None,
                 name=None,
                 acquisition_date="",
                 georef_filename="",
                 workspace=[],
                 metadata={},
                 annotations={},
                 working_area=[]):

        #we have to select a standanrd enforced!
        #in image standard (x, y, width height)
        #in numpy standard (y, x, height, width) #no the mixed format we use now I REFUSE to use it.
        #in range np format: (top, left, bottom, right)
        #in GIS standard (bottom, left, top, right)
        self.rect = rect  #coordinates of the image. (in the spatial reference system)
        self.map_px_to_mm_factor = map_px_to_mm_factor  #if we have a references system we should be able to recover this numner
        # otherwise we need to specify it.
        self.width = width
        self.height = height  #in pixels!

        self.annotations = Annotation()
        for data in annotations:
            blob = Blob(None, 0, 0, 0)
            blob.fromDict(data)
            self.annotations.addBlob(blob)

        self.channels = list(map(lambda c: Channel(**c), channels))

        self.id = id  # internal id used in correspondences it will never changes
        self.name = name  # a label for an annotated image
        self.workspace = workspace  # a polygon in spatial reference system
        self.working_area = working_area  # this is the working area of data exports for training
        self.acquisition_date = acquisition_date  # acquisition date is mandatory (format YYYY-MM-DD)
        self.georef_filename = georef_filename  # image file (GeoTiff) contained the georeferencing information
        self.metadata = metadata  # this follows image_metadata_template, do we want to allow freedom to add custome values?

    def pixelSize(self):

        if self.map_px_to_mm_factor == "":
            return 1.0
        else:
            return float(self.map_px_to_mm_factor)

    def loadGeoInfo(self, filename):
        """
        Update the georeferencing information.
        """
        img = rio.open(filename)
        if img.crs is not None:
            # this image contains georeference information
            self.georef_filename = filename

    def addChannel(self, filename, type):
        """
        This image add a channel to this image. The functions update the size (in pixels) and
        the georeferencing information (if the image if georeferenced).
        The image data is loaded when the image channel is used for the first time.
        """

        img = rio.open(filename)

        # check image size consistency (all the channels must have the same size)
        if self.width is not None and self.height is not None:
            if self.width != img.width or self.height != img.height:
                raise Exception(
                    "Size of the images is not consistent! It is " +
                    str(img.width) + "x" + str(img.height) +
                    ", should have been: " + str(self.width) + "x" +
                    str(self.height))
                return

        # check image size limits
        if img.width > 32767 or img.height > 32767:
            raise Exception(
                "This map exceeds the image dimension handled by TagLab (the maximum size is 32767 x 32767)."
            )
            return

        if img.crs is not None:
            # this image contains georeference information
            self.georef_filename = filename

        self.width = img.width
        self.height = img.height

        self.channels.append(Channel(filename, type))

    def hasDEM(self):
        """
        It returns True if the image has a DEM channel, False otherwise.
        """
        for channel in self.channels:
            if channel.type == "DEM":
                return True

        return False

    def getChannel(self, type):
        for channel in self.channels:
            if channel.type == type:
                return channel
        return None

    def getChannelIndex(self, channel):
        try:
            index = self.channels.index(channel)
            return index
        except:
            return -1

    def getRGBChannel(self):
        """
        It returns the RGB channel (if exists).
        """
        return self.getChannel("RGB")

    def getDEMChannel(self):
        """
        It returns the DEM channel (if exists).
        """
        return self.getChannel("DEM")

    def save(self):
        data = self.__dict__.copy()
        return data
コード例 #5
0
ファイル: QtImageViewerPlus.py プロジェクト: WeiHao-19/TagLab
class QtImageViewerPlus(QtImageViewer):
    """
    PyQt image viewer widget with annotation capabilities.
    QGraphicsView handles a scene composed by an image plus shapes (rectangles, polygons, blobs).
    The input image (it must be a QImage) is internally converted into a QPixmap.
    """

    # Mouse button signals emit image scene (x, y) coordinates.
    leftMouseButtonPressed = pyqtSignal(float, float)
    rightMouseButtonPressed = pyqtSignal(float, float)
    leftMouseButtonReleased = pyqtSignal(float, float)
    rightMouseButtonReleased = pyqtSignal(float, float)
    #leftMouseButtonDoubleClicked = pyqtSignal(float, float)
    rightMouseButtonDoubleClicked = pyqtSignal(float, float)
    mouseMoveLeftPressed = pyqtSignal(float, float)

    # custom signal
    updateInfoPanel = pyqtSignal(Blob)

    activated = pyqtSignal()
    newSelection = pyqtSignal()

    def __init__(self):
        QtImageViewer.__init__(self)

        self.logfile = None  #MUST be inited in Taglab.py
        self.project = Project()
        self.image = None
        self.channel = None
        self.annotations = Annotation()
        self.selected_blobs = []

        self.tools = Tools(self)
        self.tools.createTools()

        self.undo_data = Undo()

        self.dragSelectionStart = None
        self.dragSelectionRect = None
        self.dragSelectionStyle = QPen(Qt.white, 1, Qt.DashLine)
        self.dragSelectionStyle.setCosmetic(True)

        # Set scrollbar
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)

        # DRAWING SETTINGS
        self.border_pen = QPen(Qt.black, 3)
        #        pen.setJoinStyle(Qt.MiterJoin)
        #        pen.setCapStyle(Qt.RoundCap)
        self.border_pen.setCosmetic(True)
        self.border_selected_pen = QPen(Qt.white, 3)
        self.border_selected_pen.setCosmetic(True)

        self.showCrossair = False
        self.mouseCoords = QPointF(0, 0)
        self.crackWidget = None

        self.setContextMenuPolicy(Qt.CustomContextMenu)

        self.refine_grow = 0.0  #maybe should in in tools
        self.refine_original_mask = None
        self.refine_original_blob = None

    def setProject(self, project):

        self.project = project

    def setImage(self, image, channel_idx=0):
        """
        Set the image to visualize. The first channel is visualized unless otherwise specified.
        """

        self.clear()

        self.image = image
        self.annotations = image.annotations
        self.selected_blobs = []

        for blob in self.annotations.seg_blobs:
            self.drawBlob(blob)

        self.scene.invalidate()
        self.tools.tools['RULER'].setPxToMM(image.map_px_to_mm_factor)
        self.px_to_mm = image.map_px_to_mm_factor

        self.setChannel(image.channels[channel_idx])

        self.activated.emit()

    def setChannel(self, channel, switch=False):
        """
        Set the image channel to visualize. If the channel has not been previously loaded it is loaded and cached.
        """

        if self.image is None:
            raise ("Image has not been previously set in ViewerPlus")

        self.channel = channel

        if channel.qimage is not None:
            img = channel.qimage
        else:
            QApplication.setOverrideCursor(Qt.WaitCursor)
            img = channel.loadData()
            QApplication.restoreOverrideCursor()

        if img.isNull():
            (channel.filename, filter) = QFileDialog.getOpenFileName(
                self, "Couldn't find the map, please select it:",
                QFileInfo(channel.filename).dir().path(),
                "Image Files (*.png *.jpg)")
            dir = QDir(os.getcwd())
            self.map_image_filename = dir.relativeFilePath(channel.filename)
            img = QImage(channel.filename)
            if img.isNull():
                raise Exception("Could not load or find the image: " +
                                channel.filename)

        if switch:
            self.setChannelImg(img, self.zoom_factor)
        else:
            self.setChannelImg(img)

    def setChannelImg(self, channel_img, zoomf=0.0):
        """
        Set the scene's current image (input image must be a QImage)
        For calculating the zoom factor automatically set it to 0.0.
        """
        self.setImg(channel_img, zoomf)

    def clear(self):

        QtImageViewer.clear(self)
        self.selected_blobs = []
        self.undo_data = Undo()

        for blob in self.annotations.seg_blobs:
            self.undrawBlob(blob)
            del blob

        self.annotations = Annotation()

    def drawBlob(self, blob, prev=False):
        # if it has just been created remove the current graphics item in order to set it again
        if blob.qpath_gitem is not None:
            self.scene.removeItem(blob.qpath_gitem)
            self.scene.removeItem(blob.id_item)
            del blob.qpath_gitem
            del blob.id_item
            blob.qpath_gitem = None
            blob.id_item = None

        blob.setupForDrawing()

        if prev is True:
            pen = self.border_pen_for_appended_blobs
        else:
            pen = self.border_selected_pen if blob in self.selected_blobs else self.border_pen
        brush = self.project.classBrushFromName(blob)

        blob.qpath_gitem = self.scene.addPath(blob.qpath, pen, brush)
        blob.qpath_gitem.setZValue(1)

        font_size = 12
        blob.id_item = TextItem(str(blob.id),
                                QFont("Arial", font_size, QFont.Bold))
        self.scene.addItem(blob.id_item)
        blob.id_item.setPos(blob.centroid[0], blob.centroid[1])
        blob.id_item.setTransformOriginPoint(
            QPointF(blob.centroid[0] + 14.0, blob.centroid[1] + 14.0))
        blob.id_item.setZValue(2)
        blob.id_item.setBrush(Qt.white)

        #blob.id_item.setDefaultTextColor(Qt.white)
        #blob.id_item.setFlag(QGraphicsItem.ItemIgnoresTransformations)
        #blob.qpath_gitem.setOpacity(self.transparency_value)

    def undrawBlob(self, blob):
        self.scene.removeItem(blob.qpath_gitem)
        self.scene.removeItem(blob.id_item)
        blob.qpath = None
        blob.qpath_gitem = None
        blob.id_item = None
        self.scene.invalidate()

    def applyTransparency(self, value):
        self.transparency_value = value / 100.0
        # current annotations
        for blob in self.annotations.seg_blobs:
            blob.qpath_gitem.setOpacity(self.transparency_value)

    #used for crossair cursor
    def drawForeground(self, painter, rect):
        if self.showCrossair:
            painter.setClipRect(rect)
            pen = QPen(Qt.white, 1)
            pen.setCosmetic(True)
            painter.setPen(pen)
            painter.drawLine(self.mouseCoords.x(), rect.top(),
                             self.mouseCoords.x(), rect.bottom())
            painter.drawLine(rect.left(), self.mouseCoords.y(), rect.right(),
                             self.mouseCoords.y())

#TOOLS and SELECTIONS

    def setTool(self, tool):

        self.tools.setTool(tool)

        if tool in [
                "FREEHAND", "RULER", "DEEPEXTREME"
        ] or (tool in ["CUT", "EDITBORDER"] and len(self.selected_blobs) > 1):
            self.resetSelection()
        if tool == "DEEPEXTREME":
            self.showCrossair = True
        else:
            self.showCrossair = False

        if tool == "MOVE":
            self.enablePan()
        else:
            self.disablePan()

    def resetTools(self):
        self.tools.resetTools()
        self.showCrossair = False
        self.scene.invalidate(self.scene.sceneRect())
        self.setDragMode(QGraphicsView.NoDrag)

#TODO not necessarily a slot

    @pyqtSlot(float, float)
    def selectOp(self, x, y):
        """
        Selection operation.
        """

        self.logfile.info("[SELECTION][DOUBLE-CLICK] Selection starts..")

        if self.tools.tool in ["RULER", "DEEPEXTREME"]:
            return

        if not (Qt.ShiftModifier & QApplication.queryKeyboardModifiers()):
            self.resetSelection()

        selected_blob = self.annotations.clickedBlob(x, y)

        if selected_blob:
            if selected_blob in self.selected_blobs:
                self.removeFromSelectedList(selected_blob)
            else:
                self.addToSelectedList(selected_blob)
                self.updateInfoPanel.emit(selected_blob)

        if len(self.selected_blobs) == 1:
            self.newSelection.emit()
        self.logfile.info("[SELECTION][DOUBLE-CLICK] Selection ends.")

#MOUSE EVENTS

    def mousePressEvent(self, event):
        """ Start mouse pan or zoom mode.
        """
        self.activated.emit()

        scenePos = self.mapToScene(event.pos())

        mods = event.modifiers()

        if event.button() == Qt.LeftButton:
            (x, y) = self.clipScenePos(scenePos)
            #used from area selection and pen drawing,

            if (self.panEnabled and not (mods & Qt.ShiftModifier)) or (
                    mods & Qt.ControlModifier):
                self.setDragMode(QGraphicsView.ScrollHandDrag)
            elif self.tools.tool == "MATCH":
                self.tools.leftPressed(x, y, mods)

            elif mods & Qt.ShiftModifier:
                self.dragSelectionStart = [x, y]
                self.logfile.info("[SELECTION][DRAG] Selection starts..")

            else:
                self.tools.leftPressed(x, y)
                #self.leftMouseButtonPressed.emit(clippedCoords[0], clippedCoords[1])

        # PANNING IS ALWAYS POSSIBLE WITH WHEEL BUTTON PRESSED (!)
        # if event.button() == Qt.MiddleButton:
        #     self.setDragMode(QGraphicsView.ScrollHandDrag)

        if event.button() == Qt.RightButton:
            clippedCoords = self.clipScenePos(scenePos)
            self.rightMouseButtonPressed.emit(clippedCoords[0],
                                              clippedCoords[1])

        QGraphicsView.mousePressEvent(self, event)

    def mouseReleaseEvent(self, event):
        """ Stop mouse pan or zoom mode (apply zoom if valid).
        """
        QGraphicsView.mouseReleaseEvent(self, event)

        scenePos = self.mapToScene(event.pos())

        if event.button() == Qt.LeftButton:
            self.setDragMode(QGraphicsView.NoDrag)
            (x, y) = self.clipScenePos(scenePos)

            if self.dragSelectionStart:
                if abs(x - self.dragSelectionStart[0]) < 5 and abs(
                        y - self.dragSelectionStart[1]) < 5:
                    self.selectOp(x, y)
                else:
                    self.dragSelectBlobs(x, y)
                    self.dragSelectionStart = None
                    if self.dragSelectionRect:
                        self.scene.removeItem(self.dragSelectionRect)
                        del self.dragSelectionRect
                        self.dragSelectionRect = None

                    self.logfile.info("[SELECTION][DRAG] Selection ends.")
            else:
                self.tools.leftReleased(x, y)

    def mouseMoveEvent(self, event):

        QGraphicsView.mouseMoveEvent(self, event)

        scenePos = self.mapToScene(event.pos())

        if self.showCrossair == True:
            self.mouseCoords = scenePos
            self.scene.invalidate(self.sceneRect(),
                                  QGraphicsScene.ForegroundLayer)

        if event.buttons() == Qt.LeftButton:
            (x, y) = self.clipScenePos(scenePos)

            if self.dragSelectionStart:
                start = self.dragSelectionStart
                if not self.dragSelectionRect:
                    self.dragSelectionRect = self.scene.addRect(
                        start[0], start[1], x - start[0], y - start[1],
                        self.dragSelectionStyle)
                self.dragSelectionRect.setRect(start[0], start[1],
                                               x - start[0], y - start[1])
                return

            if Qt.ControlModifier & QApplication.queryKeyboardModifiers():
                return

            self.tools.mouseMove(x, y)

    def mouseDoubleClickEvent(self, event):

        scenePos = self.mapToScene(event.pos())

        if event.button() == Qt.LeftButton:
            self.selectOp(scenePos.x(), scenePos.y())

    def wheelEvent(self, event):
        """ Zoom in/zoom out.
        """

        if self.zoomEnabled:

            pt = event.angleDelta()

            #self.zoom_factor = self.zoom_factor + pt.y() / 2400.0
            #uniform zoom.
            self.zoom_factor = self.zoom_factor * pow(pow(2, 1 / 2),
                                                      pt.y() / 100)
            if self.zoom_factor < self.ZOOM_FACTOR_MIN:
                self.zoom_factor = self.ZOOM_FACTOR_MIN
            if self.zoom_factor > self.ZOOM_FACTOR_MAX:
                self.zoom_factor = self.ZOOM_FACTOR_MAX

            self.updateViewer()

        # PAY ATTENTION !! THE WHEEL INTERACT ALSO WITH THE SCROLL BAR !!
        #QGraphicsView.wheelEvent(self, event)

#VISIBILITY AND SELECTION

    def dragSelectBlobs(self, x, y):
        sx = self.dragSelectionStart[0]
        sy = self.dragSelectionStart[1]
        self.resetSelection()
        for blob in self.annotations.seg_blobs:
            visible = self.project.isLabelVisible(blob.class_name)
            if not visible:
                continue
            box = blob.bbox

            if sx > box[1] or sy > box[
                    0] or x < box[1] + box[2] or y < box[0] + box[3]:
                continue
            self.addToSelectedList(blob)

    @pyqtSlot(str)
    def setActiveLabel(self, label):

        self.tools.tools["ASSIGN"].setActiveLabel(label)

    def setBlobVisible(self, blob, visibility):
        if blob.qpath_gitem is not None:
            blob.qpath_gitem.setVisible(visibility)
        if blob.id_item is not None:
            blob.id_item.setVisible(visibility)

    def updateVisibility(self):

        for blob in self.annotations.seg_blobs:
            visibility = self.project.isLabelVisible(blob.class_name)
            self.setBlobVisible(blob, visibility)

#SELECTED BLOBS MANAGEMENT

    def addToSelectedList(self, blob):
        """
        Add the given blob to the list of selected blob.
        """

        if blob in self.selected_blobs:
            self.logfile.info(
                "[SELECTION] An already selected blob has been added to the current selection."
            )
        else:
            self.selected_blobs.append(blob)
            str = "[SELECTION] A new blob (" + blob.blob_name + ";" + blob.class_name + ") has been selected."
            self.logfile.info(str)

        if not blob.qpath_gitem is None:
            blob.qpath_gitem.setPen(self.border_selected_pen)
            blob.qpath_gitem.setZValue(3)
            blob.id_item.setZValue(4)
        else:
            print("blob qpath_qitem is None!")
        self.scene.invalidate()

    def removeFromSelectedList(self, blob):
        try:
            # safer if iterating over selected_blobs and calling this function.
            self.selected_blobs = [
                x for x in self.selected_blobs if not x == blob
            ]
            if not blob.qpath_gitem is None:
                blob.qpath_gitem.setPen(self.border_pen)
                blob.qpath_gitem.setZValue(1)
                blob.id_item.setZValue(2)

            self.scene.invalidate()
        except Exception as e:
            print("Exception: e", e)
            pass

    def resetSelection(self):
        for blob in self.selected_blobs:
            if blob.qpath_gitem is None:
                print("Selected item with no path!")
            else:
                blob.qpath_gitem.setPen(self.border_pen)
                blob.qpath_gitem.setZValue(1)
                blob.id_item.setZValue(2)

        self.selected_blobs.clear()
        self.scene.invalidate(self.scene.sceneRect())

#CREATION and DESTRUCTION of BLOBS

    def addBlob(self, blob, selected=False):
        """
        The only function to add annotations. will take care of undo and QGraphicItems.
        """
        self.undo_data.addBlob(blob)
        #self.undo_data_operation['remove'].append(blob)
        self.annotations.addBlob(blob)
        self.drawBlob(blob)
        if selected:
            self.addToSelectedList(blob)

    def removeBlob(self, blob):
        """
        The only function to remove annotations.
        """
        self.removeFromSelectedList(blob)
        self.undrawBlob(blob)
        self.undo_data.removeBlob(blob)
        self.annotations.removeBlob(blob)

    def deleteSelectedBlobs(self):

        for blob in self.selected_blobs:
            self.removeBlob(blob)
        self.saveUndo()

    def setBlobClass(self, blob, class_name):

        if blob.class_name == class_name:
            return

        self.undo_data.setBlobClass(blob, class_name)

        blob.class_name = class_name
        #THIS should be removed: the color comes from the labels!
        blob.class_color = self.project.labels[blob.class_name].fill

        brush = self.project.classBrushFromName(blob)
        blob.qpath_gitem.setBrush(brush)

        self.scene.invalidate()

#UNDO STUFF
#UNDO STUFF

    def saveUndo(self):
        self.undo_data.saveUndo()

    def undo(self):
        operation = self.undo_data.undo()
        if operation is None:
            return

        for blob in operation['remove']:
            message = "[UNDO][REMOVE] BLOBID={:d} VERSION={:d}".format(
                blob.id, blob.version)
            self.logfile.info(message)
            self.removeFromSelectedList(blob)
            self.undrawBlob(blob)
            self.annotations.removeBlob(blob)

        for blob in operation['add']:
            message = "[UNDO][ADD] BLOBID={:d} VERSION={:d}".format(
                blob.id, blob.version)
            self.logfile.info(message)
            self.annotations.addBlob(blob)
            self.selected_blobs.append(blob)
            self.drawBlob(blob)

        for (blob, class_name) in operation['class']:
            blob.class_name = class_name
            brush = self.project.classBrushFromName(blob)
            blob.qpath_gitem.setBrush(brush)

        self.updateVisibility()

    def redo(self):
        operation = self.undo_data.redo()
        if operation is None:
            return

        for blob in operation['add']:
            message = "[REDO][ADD] BLOBID={:d} VERSION={:d}".format(
                blob.id, blob.version)
            self.logfile.info(message)
            self.removeFromSelectedList(blob)
            self.undrawBlob(blob)
            self.annotations.removeBlob(blob)

        for blob in operation['remove']:
            message = "[REDO][REMOVE] BLOBID={:d} VERSION={:d}".format(
                blob.id, blob.version)
            self.logfile.info(message)
            self.annotations.addBlob(blob)
            self.selected_blobs.append(blob)
            self.drawBlob(blob)

        for (blob, class_name) in operation['newclass']:
            blob.class_name = class_name
            brush = self.project.classBrushFromName(blob)
            blob.qpath_gitem.setBrush(brush)

        self.updateVisibility()