Source code for secv_guis.masked_scene

# -*- coding:utf-8 -*-


"""
This module contains the ``QGraphicsScene+QGraphicsView`` binomial, typically
used in Qt apps to display and navigate images, together with some specific
functionality to annotate.
"""


import numpy as np
from PySide2 import QtCore, QtWidgets, QtGui
#
from .utils import RandomColorGenerator, rgb_arr_to_rgb_pixmap, \
    bool_arr_to_rgba_pixmap, pixmap_to_arr
from .mouse_event_manager import MouseEventManager
from .objects import ObjectContainer


# #############################################################################
# ## SCENE (PAINT ETC)
# #############################################################################
[docs]class MaskedImageScene(QtWidgets.QGraphicsScene, ObjectContainer): """ Basic area that allows to display a color image, together with a set of binary masks on top of it. """ DEFAULT_MASK_ALPHA = 100 # 255 is opaque. Used if no colors are specified def __init__(self, img_arr=None, parent=None): """ :param img_arr: See ``update_image`` """ # super().__init__(parent) QtWidgets.QGraphicsScene.__init__(self, parent) ObjectContainer.__init__(self) # self.img_pmi = None self.h = None self.w = None # self.mask_pmis = {} # pmi_ref : (r, g, b, a) if img_arr is not None: self.update_image(img_arr)
[docs] def update_image(self, img_arr): """ Clears whole scene, and adds the given numpy array as Pixmap. :param img_arr: A ``np.uint8(h, w [, ?])`` array. """ pm = rgb_arr_to_rgb_pixmap(img_arr) self.clear() self.img_pmi = self.addPixmap(pm) # self.h, self.w = img_arr.shape[:2] self.setSceneRect(0, 0, self.w, self.h)
[docs] def num_items(self): """ Number of items in this scene, ordered from foreground to background. """ return len(self.items(QtCore.Qt.DescendingOrder))
[docs] def items(self, ascending=True): """ :returns: This scene's items. :param ascending: If true, items are given from background to foreground. """ o = QtCore.Qt.AscendingOrder if ascending else QtCore.Qt.AscendingOrder return super().items(o)
[docs] def add_mask(self, mask_arr, rgba=None, item_on_top=None): """ :param mask_arr: A ``np.bool(h, w)`` array. :param item_on_top: If given, mask will be added underneath that item. Otherwise will be added on top of item stack. :returns: The added ``PixmapItem``. """ # sanity check mask assert mask_arr.dtype == np.bool, "Mask must be np.bool(h, w)!" assert len(mask_arr.shape) == 2, "Mask must be np.bool(h, w)!" assert mask_arr.shape == (self.h, self.w), \ "Mask must have same (h, w) as image!" # sanity check color if rgba is None: r, g, b = next(RandomColorGenerator().generate(form="rgbArray")) rgba = (r, g, b, self.DEFAULT_MASK_ALPHA) assert all([0 <= c <= 255 for c in rgba]), \ "RGBA must be in [0, 255] range!" # add pixmap: if this fails, the method raises with no side effect pm = bool_arr_to_rgba_pixmap(mask_arr, rgba) pmi = self.addPixmap(pm) self.mask_pmis[pmi] = rgba # now we have side FX: if this fails roll back the add Pixmap if item_on_top is not None: try: pmi.stackBefore(item_on_top) except Exception: # roll back addition del self.mask_pmis[pmi] self.removeItem(pmi) raise RuntimeError( "Invalid other_item? {}".format(item_on_top)) # return pmi
[docs] def remove_mask(self, pmi): """ :param pmi: The PixmapItem to remove. It has to be a mask added via ``add_mask`` """ # check that exists in our dict rgba = self.mask_pmis[pmi] # self.removeItem(pmi) del self.mask_pmis[pmi] return rgba
[docs] def replace_mask_pmi(self, pmi, new_mask_arr, new_rgba=None): """ If we call ``remove_mask`` and then ``add_mask`` fails, we will lose the removed mask forever. This method updates the mask in an atomary way: either succeeds or does nothing. .. warning:: The input ``pmi`` gets removed from the scene and the reference becomes invalid. Use the reference returned by this function instead. """ # the problem is if we successfully remove and then fail to add. A # fix is to first add below the to-be-removed, and then remove old_rgba = self.mask_pmis[pmi] if new_rgba is None: new_rgba = old_rgba new_pmi = self.add_mask(new_mask_arr, rgba=new_rgba, item_on_top=pmi) self.remove_mask(pmi) return new_pmi
[docs] def mask_as_bool_arr(self, pmi): """ Asserts that the given ``pmi`` is in ``self.mask_pmis``, and returns the map as ``np.bool(h, w)`` array, in which all non-zero values are true. """ assert pmi in self.mask_pmis, "Given Item is not in mask_pmis!" # a has shape (h, w, 4) where 4->RGBA pixmap_format = QtGui.QImage.Format_RGBA8888 arr = pixmap_to_arr(pmi.pixmap(), pixmap_format) mask = (arr > 0).any(axis=-1) return mask
# ############################################################################# # ## VIEW (EVENTS ETC) # #############################################################################
[docs]class DisplayView(MouseEventManager, QtWidgets.QGraphicsView): """ In Qt applications, it is usual to wrap a scene with a view. This allows to dynamically and easily change the perspective on the scene. """ def __init__(self, scene=None, parent=None, scale_percent=15): """ :param scene: The scene to view. It can be set also afterwards via ``self.setScene``. """ QtWidgets.QGraphicsView.__init__(self, scene, parent) MouseEventManager.__init__(self, True) self.scale_factor_up = 1 + float(scale_percent) / 100 self.scale_factor_down = 1.0 / self.scale_factor_up # self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) # Set Anchors self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor) self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor) # if scene is not None: self.fit_in_scene()
[docs] def fit_in_scene(self): """ Moves perspective so that the whole scene can be seen in view. """ self.fitInView(self.scene().sceneRect(), QtCore.Qt.KeepAspectRatio)
[docs] def zoom(self, pos_x, pos_y, zoom_out=False): """ Source for wheel zoom: https://stackoverflow.com/a/29026916/4511978 """ # Save the scene pos qpos = QtCore.QPoint(pos_x, pos_y) old_pos = self.mapToScene(qpos) # Zoom scale_factor = (self.scale_factor_down if zoom_out else self.scale_factor_up) self.scale(scale_factor, scale_factor) # Get the new position new_pos = self.mapToScene(qpos) # Move scene to old position delta = new_pos - old_pos self.translate(delta.x(), delta.y())
[docs] def shift_view(self, delta_x, delta_y): """ Move perspective to shift through the scene """ self.horizontalScrollBar().setValue( self.horizontalScrollBar().value() - delta_x) self.verticalScrollBar().setValue( self.verticalScrollBar().value() - delta_y)
[docs] def on_mid_press(self, event): """ Override me """ self.fit_in_scene()
[docs] def on_wheel(self, event, has_ctrl, has_alt, has_shift): """ Override me """ if (has_ctrl, has_alt, has_shift) == (False, False, False): zoom_out = event.delta() <= 0 self.zoom(*event.pos().toTuple(), zoom_out)
[docs] def on_right_press(self, event): """ Override me """ self.setDragMode(self.ScrollHandDrag)
[docs] def on_right_release(self, event): """ Override me """ self.setDragMode(self.NoDrag)
[docs] def on_move(self, event, has_left, has_mid, has_right, this_pos, last_pos): """ Override me """ # # shift scene with right button if has_right: delta_x, delta_y = (this_pos - last_pos).toTuple() self.shift_view(delta_x, delta_y)