diff options
Diffstat (limited to 'src/silx/gui/plot/CompareImages.py')
-rw-r--r-- | src/silx/gui/plot/CompareImages.py | 1040 |
1 files changed, 368 insertions, 672 deletions
diff --git a/src/silx/gui/plot/CompareImages.py b/src/silx/gui/plot/CompareImages.py index 80e0db3..3823ae2 100644 --- a/src/silx/gui/plot/CompareImages.py +++ b/src/silx/gui/plot/CompareImages.py @@ -29,505 +29,30 @@ __license__ = "MIT" __date__ = "23/07/2018" -import enum import logging import numpy -import weakref -import collections import math import silx.image.bilinear from silx.gui import qt from silx.gui import plot -from silx.gui import icons from silx.gui.colors import Colormap from silx.gui.plot import tools +from silx.utils.deprecation import deprecated_warning from silx.utils.weakref import WeakMethodProxy +from silx.gui.plot.items import Scatter +from silx.math.colormap import normalize -_logger = logging.getLogger(__name__) - -from silx.opencl import ocl -if ocl is not None: - try: - from silx.opencl import sift - except ImportError: - # sift module is not available (e.g., in official Debian packages) - sift = None -else: # No OpenCL device or no pyopencl - sift = None - - -@enum.unique -class VisualizationMode(enum.Enum): - """Enum for each visualization mode available.""" - ONLY_A = 'a' - ONLY_B = 'b' - VERTICAL_LINE = 'vline' - HORIZONTAL_LINE = 'hline' - COMPOSITE_RED_BLUE_GRAY = "rbgchannel" - COMPOSITE_RED_BLUE_GRAY_NEG = "rbgnegchannel" - COMPOSITE_A_MINUS_B = "aminusb" - - -@enum.unique -class AlignmentMode(enum.Enum): - """Enum for each alignment mode available.""" - ORIGIN = 'origin' - CENTER = 'center' - STRETCH = 'stretch' - AUTO = 'auto' - - -AffineTransformation = collections.namedtuple("AffineTransformation", - ["tx", "ty", "sx", "sy", "rot"]) -"""Contains a 2D affine transformation: translation, scale and rotation""" - - -class CompareImagesToolBar(qt.QToolBar): - """ToolBar containing specific tools to custom the configuration of a - :class:`CompareImages` widget - - Use :meth:`setCompareWidget` to connect this toolbar to a specific - :class:`CompareImages` widget. - - :param Union[qt.QWidget,None] parent: Parent of this widget. - """ - def __init__(self, parent=None): - qt.QToolBar.__init__(self, parent) - - self.__compareWidget = None - - menu = qt.QMenu(self) - self.__visualizationToolButton = qt.QToolButton(self) - self.__visualizationToolButton.setMenu(menu) - self.__visualizationToolButton.setPopupMode(qt.QToolButton.InstantPopup) - self.addWidget(self.__visualizationToolButton) - self.__visualizationGroup = qt.QActionGroup(self) - self.__visualizationGroup.setExclusive(True) - self.__visualizationGroup.triggered.connect(self.__visualizationModeChanged) - - icon = icons.getQIcon("compare-mode-a") - action = qt.QAction(icon, "Display the first image only", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_A)) - action.setProperty("mode", VisualizationMode.ONLY_A) - menu.addAction(action) - self.__aModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-b") - action = qt.QAction(icon, "Display the second image only", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_B)) - action.setProperty("mode", VisualizationMode.ONLY_B) - menu.addAction(action) - self.__bModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-vline") - action = qt.QAction(icon, "Vertical compare mode", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_V)) - action.setProperty("mode", VisualizationMode.VERTICAL_LINE) - menu.addAction(action) - self.__vlineModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-hline") - action = qt.QAction(icon, "Horizontal compare mode", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_H)) - action.setProperty("mode", VisualizationMode.HORIZONTAL_LINE) - menu.addAction(action) - self.__hlineModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-rb-channel") - action = qt.QAction(icon, "Blue/red compare mode (additive mode)", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_C)) - action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY) - menu.addAction(action) - self.__brChannelModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-rbneg-channel") - action = qt.QAction(icon, "Yellow/cyan compare mode (subtractive mode)", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_W)) - action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG) - menu.addAction(action) - self.__ycChannelModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-a-minus-b") - action = qt.QAction(icon, "Raw A minus B compare mode", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_W)) - action.setProperty("mode", VisualizationMode.COMPOSITE_A_MINUS_B) - menu.addAction(action) - self.__ycChannelModeAction = action - self.__visualizationGroup.addAction(action) - - menu = qt.QMenu(self) - self.__alignmentToolButton = qt.QToolButton(self) - self.__alignmentToolButton.setMenu(menu) - self.__alignmentToolButton.setPopupMode(qt.QToolButton.InstantPopup) - self.addWidget(self.__alignmentToolButton) - self.__alignmentGroup = qt.QActionGroup(self) - self.__alignmentGroup.setExclusive(True) - self.__alignmentGroup.triggered.connect(self.__alignmentModeChanged) - - icon = icons.getQIcon("compare-align-origin") - action = qt.QAction(icon, "Align images on their upper-left pixel", self) - action.setProperty("mode", AlignmentMode.ORIGIN) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__originAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-center") - action = qt.QAction(icon, "Center images", self) - action.setProperty("mode", AlignmentMode.CENTER) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__centerAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-stretch") - action = qt.QAction(icon, "Stretch the second image on the first one", self) - action.setProperty("mode", AlignmentMode.STRETCH) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__stretchAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-auto") - action = qt.QAction(icon, "Auto-alignment of the second image", self) - action.setProperty("mode", AlignmentMode.AUTO) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__autoAlignAction = action - menu.addAction(action) - if sift is None: - action.setEnabled(False) - action.setToolTip("Sift module is not available") - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-keypoints") - action = qt.QAction(icon, "Display/hide alignment keypoints", self) - action.setCheckable(True) - action.triggered.connect(self.__keypointVisibilityChanged) - self.addAction(action) - self.__displayKeypoints = action - - def setCompareWidget(self, widget): - """ - Connect this tool bar to a specific :class:`CompareImages` widget. - - :param Union[None,CompareImages] widget: The widget to connect with. - """ - compareWidget = self.getCompareWidget() - if compareWidget is not None: - compareWidget.sigConfigurationChanged.disconnect(self.__updateSelectedActions) - compareWidget = widget - if compareWidget is None: - self.__compareWidget = None - else: - self.__compareWidget = weakref.ref(compareWidget) - if compareWidget is not None: - widget.sigConfigurationChanged.connect(self.__updateSelectedActions) - self.__updateSelectedActions() - - def getCompareWidget(self): - """Returns the connected widget. - - :rtype: CompareImages - """ - if self.__compareWidget is None: - return None - else: - return self.__compareWidget() - - def __updateSelectedActions(self): - """ - Update the state of this tool bar according to the state of the - connected :class:`CompareImages` widget. - """ - widget = self.getCompareWidget() - if widget is None: - return - - mode = widget.getVisualizationMode() - action = None - for a in self.__visualizationGroup.actions(): - actionMode = a.property("mode") - if mode == actionMode: - action = a - break - old = self.__visualizationGroup.blockSignals(True) - if action is not None: - # Check this action - action.setChecked(True) - else: - action = self.__visualizationGroup.checkedAction() - if action is not None: - # Uncheck this action - action.setChecked(False) - self.__updateVisualizationMenu() - self.__visualizationGroup.blockSignals(old) - - mode = widget.getAlignmentMode() - action = None - for a in self.__alignmentGroup.actions(): - actionMode = a.property("mode") - if mode == actionMode: - action = a - break - old = self.__alignmentGroup.blockSignals(True) - if action is not None: - # Check this action - action.setChecked(True) - else: - action = self.__alignmentGroup.checkedAction() - if action is not None: - # Uncheck this action - action.setChecked(False) - self.__updateAlignmentMenu() - self.__alignmentGroup.blockSignals(old) - - def __visualizationModeChanged(self, selectedAction): - """Called when user requesting changes of the visualization mode. - """ - self.__updateVisualizationMenu() - widget = self.getCompareWidget() - if widget is not None: - mode = selectedAction.property("mode") - widget.setVisualizationMode(mode) - - def __updateVisualizationMenu(self): - """Update the state of the action containing visualization menu. - """ - selectedAction = self.__visualizationGroup.checkedAction() - if selectedAction is not None: - self.__visualizationToolButton.setText(selectedAction.text()) - self.__visualizationToolButton.setIcon(selectedAction.icon()) - self.__visualizationToolButton.setToolTip(selectedAction.toolTip()) - else: - self.__visualizationToolButton.setText("") - self.__visualizationToolButton.setIcon(qt.QIcon()) - self.__visualizationToolButton.setToolTip("") - - def __alignmentModeChanged(self, selectedAction): - """Called when user requesting changes of the alignment mode. - """ - self.__updateAlignmentMenu() - widget = self.getCompareWidget() - if widget is not None: - mode = selectedAction.property("mode") - widget.setAlignmentMode(mode) - - def __updateAlignmentMenu(self): - """Update the state of the action containing alignment menu. - """ - selectedAction = self.__alignmentGroup.checkedAction() - if selectedAction is not None: - self.__alignmentToolButton.setText(selectedAction.text()) - self.__alignmentToolButton.setIcon(selectedAction.icon()) - self.__alignmentToolButton.setToolTip(selectedAction.toolTip()) - else: - self.__alignmentToolButton.setText("") - self.__alignmentToolButton.setIcon(qt.QIcon()) - self.__alignmentToolButton.setToolTip("") - - def __keypointVisibilityChanged(self): - """Called when action managing keypoints visibility changes""" - widget = self.getCompareWidget() - if widget is not None: - keypointsVisible = self.__displayKeypoints.isChecked() - widget.setKeypointsVisible(keypointsVisible) +from .tools.compare.core import sift +from .tools.compare.core import VisualizationMode +from .tools.compare.core import AlignmentMode +from .tools.compare.core import AffineTransformation +from .tools.compare.toolbar import CompareImagesToolBar +from .tools.compare.statusbar import CompareImagesStatusBar +from .tools.compare.core import _CompareImageItem -class CompareImagesStatusBar(qt.QStatusBar): - """StatusBar containing specific information contained in a - :class:`CompareImages` widget - - Use :meth:`setCompareWidget` to connect this toolbar to a specific - :class:`CompareImages` widget. - - :param Union[qt.QWidget,None] parent: Parent of this widget. - """ - def __init__(self, parent=None): - qt.QStatusBar.__init__(self, parent) - self.setSizeGripEnabled(False) - self.layout().setSpacing(0) - self.__compareWidget = None - self._label1 = qt.QLabel(self) - self._label1.setFrameShape(qt.QFrame.WinPanel) - self._label1.setFrameShadow(qt.QFrame.Sunken) - self._label2 = qt.QLabel(self) - self._label2.setFrameShape(qt.QFrame.WinPanel) - self._label2.setFrameShadow(qt.QFrame.Sunken) - self._transform = qt.QLabel(self) - self._transform.setFrameShape(qt.QFrame.WinPanel) - self._transform.setFrameShadow(qt.QFrame.Sunken) - self.addWidget(self._label1) - self.addWidget(self._label2) - self.addWidget(self._transform) - self._pos = None - self._updateStatusBar() - - def setCompareWidget(self, widget): - """ - Connect this tool bar to a specific :class:`CompareImages` widget. - - :param Union[None,CompareImages] widget: The widget to connect with. - """ - compareWidget = self.getCompareWidget() - if compareWidget is not None: - compareWidget.getPlot().sigPlotSignal.disconnect(self.__plotSignalReceived) - compareWidget.sigConfigurationChanged.disconnect(self.__dataChanged) - compareWidget = widget - if compareWidget is None: - self.__compareWidget = None - else: - self.__compareWidget = weakref.ref(compareWidget) - if compareWidget is not None: - compareWidget.getPlot().sigPlotSignal.connect(self.__plotSignalReceived) - compareWidget.sigConfigurationChanged.connect(self.__dataChanged) - - def getCompareWidget(self): - """Returns the connected widget. - - :rtype: CompareImages - """ - if self.__compareWidget is None: - return None - else: - return self.__compareWidget() - - def __plotSignalReceived(self, event): - """Called when old style signals at emmited from the plot.""" - if event["event"] == "mouseMoved": - x, y = event["x"], event["y"] - self.__mouseMoved(x, y) - - def __mouseMoved(self, x, y): - """Called when mouse move over the plot.""" - self._pos = x, y - self._updateStatusBar() - - def __dataChanged(self): - """Called when internal data from the connected widget changes.""" - self._updateStatusBar() - - def _formatData(self, data): - """Format pixel of an image. - - It supports intensity, RGB, and RGBA. - - :param Union[int,float,numpy.ndarray,str]: Value of a pixel - :rtype: str - """ - if data is None: - return "No data" - if isinstance(data, (int, numpy.integer)): - return "%d" % data - if isinstance(data, (float, numpy.floating)): - return "%f" % data - if isinstance(data, numpy.ndarray): - # RGBA value - if data.shape == (3,): - return "R:%d G:%d B:%d" % (data[0], data[1], data[2]) - elif data.shape == (4,): - return "R:%d G:%d B:%d A:%d" % (data[0], data[1], data[2], data[3]) - _logger.debug("Unsupported data format %s. Cast it to string.", type(data)) - return str(data) - - def _updateStatusBar(self): - """Update the content of the status bar""" - widget = self.getCompareWidget() - if widget is None: - self._label1.setText("Image1: NA") - self._label2.setText("Image2: NA") - self._transform.setVisible(False) - else: - transform = widget.getTransformation() - self._transform.setVisible(transform is not None) - if transform is not None: - has_notable_translation = not numpy.isclose(transform.tx, 0.0, atol=0.01) \ - or not numpy.isclose(transform.ty, 0.0, atol=0.01) - has_notable_scale = not numpy.isclose(transform.sx, 1.0, atol=0.01) \ - or not numpy.isclose(transform.sy, 1.0, atol=0.01) - has_notable_rotation = not numpy.isclose(transform.rot, 0.0, atol=0.01) - - strings = [] - if has_notable_translation: - strings.append("Translation") - if has_notable_scale: - strings.append("Scale") - if has_notable_rotation: - strings.append("Rotation") - if strings == []: - has_translation = not numpy.isclose(transform.tx, 0.0) \ - or not numpy.isclose(transform.ty, 0.0) - has_scale = not numpy.isclose(transform.sx, 1.0) \ - or not numpy.isclose(transform.sy, 1.0) - has_rotation = not numpy.isclose(transform.rot, 0.0) - if has_translation or has_scale or has_rotation: - text = "No big changes" - else: - text = "No changes" - else: - text = "+".join(strings) - self._transform.setText("Align: " + text) - - strings = [] - if not numpy.isclose(transform.ty, 0.0): - strings.append("Translation x: %0.3fpx" % transform.tx) - if not numpy.isclose(transform.ty, 0.0): - strings.append("Translation y: %0.3fpx" % transform.ty) - if not numpy.isclose(transform.sx, 1.0): - strings.append("Scale x: %0.3f" % transform.sx) - if not numpy.isclose(transform.sy, 1.0): - strings.append("Scale y: %0.3f" % transform.sy) - if not numpy.isclose(transform.rot, 0.0): - strings.append("Rotation: %0.3fdeg" % (transform.rot * 180 / numpy.pi)) - if strings == []: - text = "No transformation" - else: - text = "\n".join(strings) - self._transform.setToolTip(text) - - if self._pos is None: - self._label1.setText("Image1: NA") - self._label2.setText("Image2: NA") - else: - data1, data2 = widget.getRawPixelData(self._pos[0], self._pos[1]) - if isinstance(data1, str): - self._label1.setToolTip(data1) - text1 = "NA" - else: - self._label1.setToolTip("") - text1 = self._formatData(data1) - if isinstance(data2, str): - self._label2.setToolTip(data2) - text2 = "NA" - else: - self._label2.setToolTip("") - text2 = self._formatData(data2) - self._label1.setText("Image1: %s" % text1) - self._label2.setText("Image2: %s" % text2) +_logger = logging.getLogger(__name__) class CompareImages(qt.QMainWindow): @@ -550,22 +75,28 @@ class CompareImages(qt.QMainWindow): sigConfigurationChanged = qt.Signal() """Emitted when the configuration of the widget (visualization mode, - alignement mode...) have changed.""" + alignment mode...) have changed.""" def __init__(self, parent=None, backend=None): qt.QMainWindow.__init__(self, parent) self._resetZoomActive = True self._colormap = Colormap() """Colormap shared by all modes, except the compose images (rgb image)""" - self._colormapKeyPoints = Colormap('spring') + self._colormapKeyPoints = Colormap("spring") """Colormap used for sift keypoints""" + self._colormap.sigChanged.connect(self.__colormapChanged) + if parent is None: - self.setWindowTitle('Compare images') + self.setWindowTitle("Compare images") else: self.setWindowFlags(qt.Qt.Widget) self.__transformation = None + self.__item = _CompareImageItem() + self.__item.setName("_virtual") + self.__item.setColormap(self._colormap) + self.__raw1 = None self.__raw2 = None self.__data1 = None @@ -574,35 +105,44 @@ class CompareImages(qt.QMainWindow): self.__plot = plot.PlotWidget(parent=self, backend=backend) self.__plot.setDefaultColormap(self._colormap) - self.__plot.getXAxis().setLabel('Columns') - self.__plot.getYAxis().setLabel('Rows') - if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self.__plot.getXAxis().setLabel("Columns") + self.__plot.getYAxis().setLabel("Rows") + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward": self.__plot.getYAxis().setInverted(True) + self.__plot.addItem(self.__item) + self.__plot.setActiveImage(self.__item) self.__plot.setKeepDataAspectRatio(True) self.__plot.sigPlotSignal.connect(self.__plotSlot) self.__plot.setAxesDisplayed(False) + self.__scatter = Scatter() + self.__scatter.setZValue(1) + self.__scatter.setColormap(self._colormapKeyPoints) + self.__plot.addItem(self.__scatter) + self.setCentralWidget(self.__plot) legend = VisualizationMode.VERTICAL_LINE.name self.__plot.addXMarker( - 0, - legend=legend, - text='', - draggable=True, - color='blue', - constraint=WeakMethodProxy(self.__separatorConstraint)) + 0, + legend=legend, + text="", + draggable=True, + color="blue", + constraint=WeakMethodProxy(self.__separatorConstraint), + ) self.__vline = self.__plot._getMarker(legend) legend = VisualizationMode.HORIZONTAL_LINE.name self.__plot.addYMarker( - 0, - legend=legend, - text='', - draggable=True, - color='blue', - constraint=WeakMethodProxy(self.__separatorConstraint)) + 0, + legend=legend, + text="", + draggable=True, + color="blue", + constraint=WeakMethodProxy(self.__separatorConstraint), + ) self.__hline = self.__plot._getMarker(legend) # default values @@ -630,6 +170,26 @@ class CompareImages(qt.QMainWindow): if self._statusBar is not None: self.setStatusBar(self._statusBar) + def __getSealedColormap(self): + vrange = self._colormap.getColormapRange( + self.__item.getColormappedData(copy=False) + ) + sealed = self._colormap.copy() + sealed.setVRange(*vrange) + return sealed + + def __colormapChanged(self): + sealed = self.__getSealedColormap() + if self.__image1 is not None: + if self.__getImageMode(self.__image1.getData(copy=False)) == "intensity": + self.__image1.setColormap(sealed) + if self.__image2 is not None: + if self.__getImageMode(self.__image2.getData(copy=False)) == "intensity": + self.__image2.setColormap(sealed) + + if "COMPOSITE" in self.__visualizationMode.name: + self.__updateData() + def _createStatusBar(self, plot): self._statusBar = CompareImagesStatusBar(self) self._statusBar.setCompareWidget(self) @@ -644,6 +204,9 @@ class CompareImages(qt.QMainWindow): toolBar.setCompareWidget(self) self._compareToolBar = toolBar + def _getVirtualPlotItem(self): + return self.__item + def getPlot(self): """Returns the plot which is used to display the images. @@ -676,10 +239,15 @@ class CompareImages(qt.QMainWindow): It also could be a string containing information is some cases. :rtype: Tuple(Union[int,float,numpy.ndarray,str],Union[int,float,numpy.ndarray,str]) """ - data2 = None alignmentMode = self.__alignmentMode raw1, raw2 = self.__raw1, self.__raw2 - if alignmentMode == AlignmentMode.ORIGIN: + + if raw1 is None or raw2 is None: + x1 = x + y1 = y + x2 = x + y2 = y + elif alignmentMode == AlignmentMode.ORIGIN: x1 = x y1 = y x2 = x @@ -700,22 +268,29 @@ class CompareImages(qt.QMainWindow): x1 = x y1 = y # Not implemented - data2 = "Not implemented with sift" + x2 = -1 + y2 = -1 else: - assert(False) + assert False x1, y1 = int(x1), int(y1) - if raw1 is None or y1 < 0 or y1 >= raw1.shape[0] or x1 < 0 or x1 >= raw1.shape[1]: - data1 = None + x2, y2 = int(x2), int(y2) + + if raw1 is None: + data1 = "No image A" + elif y1 < 0 or y1 >= raw1.shape[0] or x1 < 0 or x1 >= raw1.shape[1]: + data1 = "" else: data1 = raw1[y1, x1] - if data2 is None: - x2, y2 = int(x2), int(y2) - if raw2 is None or y2 < 0 or y2 >= raw2.shape[0] or x2 < 0 or x2 >= raw2.shape[1]: - data2 = None - else: - data2 = raw2[y2, x2] + if raw2 is None: + data2 = "No image B" + elif alignmentMode == AlignmentMode.AUTO: + data2 = "Not implemented with sift" + elif y2 < 0 or y2 >= raw2.shape[0] or x2 < 0 or x2 >= raw2.shape[1]: + data2 = None + else: + data2 = raw2[y2, x2] return data1, data2 @@ -726,20 +301,31 @@ class CompareImages(qt.QMainWindow): """ if self.__visualizationMode == mode: return - previousMode = self.getVisualizationMode() self.__visualizationMode = mode - mode = self.getVisualizationMode() + self.__item.setVizualisationMode(mode) self.__vline.setVisible(mode == VisualizationMode.VERTICAL_LINE) self.__hline.setVisible(mode == VisualizationMode.HORIZONTAL_LINE) - visModeRawDisplay = (VisualizationMode.ONLY_A, - VisualizationMode.ONLY_B, - VisualizationMode.VERTICAL_LINE, - VisualizationMode.HORIZONTAL_LINE) - updateColormap = not(previousMode in visModeRawDisplay and - mode in visModeRawDisplay) - self.__updateData(updateColormap=updateColormap) + self.__updateData() self.sigConfigurationChanged.emit() + def centerLines(self): + """Center the line used to compare the 2 images.""" + if self.__image1 is None: + return + data_range = self.__plot.getDataRange() + + if data_range[0] is not None: + cx = (data_range[0][0] + data_range[0][1]) * 0.5 + else: + cx = 0 + if data_range[1] is not None: + cy = (data_range[1][0] + data_range[1][1]) * 0.5 + else: + cy = 0 + self.__vline.setPosition(cx, cy) + self.__hline.setPosition(cx, cy) + self.__updateSeparators() + def getVisualizationMode(self): """Returns the current interaction mode.""" return self.__visualizationMode @@ -752,13 +338,17 @@ class CompareImages(qt.QMainWindow): if self.__alignmentMode == mode: return self.__alignmentMode = mode - self.__updateData(updateColormap=False) + self.__updateData() self.sigConfigurationChanged.emit() def getAlignmentMode(self): """Returns the current selected alignemnt mode.""" return self.__alignmentMode + def getKeypointsVisible(self): + """Returns true if the keypoints are displayed""" + return self.__keypointsVisible + def setKeypointsVisible(self, isVisible): """Set keypoints visibility. @@ -776,16 +366,16 @@ class CompareImages(qt.QMainWindow): def __plotSlot(self, event): """Handle events from the plot""" - if event['event'] in ('markerMoving', 'markerMoved'): + if event["event"] in ("markerMoving", "markerMoved"): mode = self.getVisualizationMode() legend = mode.name - if event['label'] == legend: + if event["label"] == legend: if mode == VisualizationMode.VERTICAL_LINE: - value = int(float(str(event['xdata']))) + value = int(float(str(event["xdata"]))) elif mode == VisualizationMode.HORIZONTAL_LINE: - value = int(float(str(event['ydata']))) + value = int(float(str(event["ydata"]))) else: - assert(False) + assert False if self.__previousSeparatorPosition != value: self.__separatorMoved(value) self.__previousSeparatorPosition = value @@ -807,8 +397,7 @@ class CompareImages(qt.QMainWindow): return x, y def __updateSeparators(self): - """Redraw images according to the current state of the separators. - """ + """Redraw images according to the current state of the separators.""" mode = self.getVisualizationMode() if mode == VisualizationMode.VERTICAL_LINE: pos = self.__vline.getXPosition() @@ -820,7 +409,8 @@ class CompareImages(qt.QMainWindow): self.__previousSeparatorPosition = pos else: self.__image1.setOrigin((0, 0)) - self.__image2.setOrigin((0, 0)) + if self.__image2 is not None: + self.__image2.setOrigin((0, 0)) def __separatorMoved(self, pos): """Called when vertical or horizontal separators have moved. @@ -840,8 +430,9 @@ class CompareImages(qt.QMainWindow): data1 = self.__data1[:, 0:pos] data2 = self.__data2[:, pos:] self.__image1.setData(data1, copy=False) - self.__image2.setData(data2, copy=False) - self.__image2.setOrigin((pos, 0)) + if self.__image2 is not None: + self.__image2.setData(data2, copy=False) + self.__image2.setOrigin((pos, 0)) elif mode == VisualizationMode.HORIZONTAL_LINE: pos = int(pos) if pos <= 0: @@ -851,150 +442,209 @@ class CompareImages(qt.QMainWindow): data1 = self.__data1[0:pos, :] data2 = self.__data2[pos:, :] self.__image1.setData(data1, copy=False) - self.__image2.setData(data2, copy=False) - self.__image2.setOrigin((0, pos)) + if self.__image2 is not None: + self.__image2.setData(data2, copy=False) + self.__image2.setOrigin((0, pos)) else: - assert(False) + assert False - def setData(self, image1, image2, updateColormap=True): + def clear(self): + self.setData(None, None) + + def setData(self, image1, image2, updateColormap="deprecated"): """Set images to compare. Images can contains floating-point or integer values, or RGB and RGBA values, but should have comparable intensities. RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. + of unsigned integer 8-bits or floating-points between 0.0 to 1.0. :param numpy.ndarray image1: The first image :param numpy.ndarray image2: The second image """ + if updateColormap != "deprecated": + deprecated_warning( + "Argument", "setData's updateColormap argument", since_version="2.0.0" + ) + self.__raw1 = image1 self.__raw2 = image2 - self.__updateData(updateColormap=updateColormap) + self.__updateData() if self.isAutoResetZoom(): self.__plot.resetZoom() - def setImage1(self, image1, updateColormap=True): + def setImage1(self, image1, updateColormap="deprecated"): """Set image1 to be compared. Images can contains floating-point or integer values, or RGB and RGBA values, but should have comparable intensities. RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. + of unsigned integer 8-bits or floating-points between 0.0 to 1.0. :param numpy.ndarray image1: The first image """ + if updateColormap != "deprecated": + deprecated_warning( + "Argument", "setImage1's updateColormap argument", since_version="2.0.0" + ) + self.__raw1 = image1 - self.__updateData(updateColormap=updateColormap) + self.__updateData() if self.isAutoResetZoom(): self.__plot.resetZoom() - def setImage2(self, image2, updateColormap=True): + def setImage2(self, image2, updateColormap="deprecated"): """Set image2 to be compared. Images can contains floating-point or integer values, or RGB and RGBA values, but should have comparable intensities. RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. + of unsigned integer 8-bits or floating-points between 0.0 to 1.0. :param numpy.ndarray image2: The second image """ + if updateColormap != "deprecated": + deprecated_warning( + "Argument", "setImage2's updateColormap argument", since_version="2.0.0" + ) + self.__raw2 = image2 - self.__updateData(updateColormap=updateColormap) + self.__updateData() if self.isAutoResetZoom(): self.__plot.resetZoom() def __updateKeyPoints(self): - """Update the displayed keypoints using cached keypoints. - """ - if self.__keypointsVisible: + """Update the displayed keypoints using cached keypoints.""" + if self.__keypointsVisible and self.__matching_keypoints: data = self.__matching_keypoints else: data = [], [], [] - self.__plot.addScatter(x=data[0], - y=data[1], - z=1, - value=data[2], - colormap=self._colormapKeyPoints, - legend="keypoints") - - def __updateData(self, updateColormap): + self.__scatter.setData(x=data[0], y=data[1], value=data[2]) + + def __updateData(self): """Compute aligned image when the alignment mode changes. This function cache input images which are used when vertical/horizontal separators moves. """ raw1, raw2 = self.__raw1, self.__raw2 - if raw1 is None or raw2 is None: - return alignmentMode = self.getAlignmentMode() self.__transformation = None - if alignmentMode == AlignmentMode.ORIGIN: - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size, transparent=True) - data2 = self.__createMarginImage(raw2, size, transparent=True) - self.__matching_keypoints = [0.0], [0.0], [1.0] - elif alignmentMode == AlignmentMode.CENTER: - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size, transparent=True, center=True) - data2 = self.__createMarginImage(raw2, size, transparent=True, center=True) - self.__matching_keypoints = ([data1.shape[1] // 2], - [data1.shape[0] // 2], - [1.0]) - elif alignmentMode == AlignmentMode.STRETCH: - data1 = raw1 - data2 = self.__rescaleImage(raw2, data1.shape) - self.__matching_keypoints = ([0, data1.shape[1], data1.shape[1], 0], - [0, 0, data1.shape[0], data1.shape[0]], - [1.0, 1.0, 1.0, 1.0]) - elif alignmentMode == AlignmentMode.AUTO: - # TODO: sift implementation do not support RGBA images - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size) - data2 = self.__createMarginImage(raw2, size) - self.__matching_keypoints = [0.0], [0.0], [1.0] - try: - data1, data2 = self.__createSiftData(data1, data2) - if data2 is None: - raise ValueError("Unexpected None value") - except Exception as e: - # TODO: Display it on the GUI - _logger.error(e) - self.__setDefaultAlignmentMode() - return + if raw1 is None or raw2 is None: + # No need to realign the 2 images + # But create a dummy image when there is None for simplification + if raw1 is None: + data1 = numpy.empty((0, 0)) + else: + data1 = raw1 + if raw2 is None: + data2 = numpy.empty((0, 0)) + else: + data2 = raw2 + self.__matching_keypoints = None else: - assert(False) + if alignmentMode == AlignmentMode.ORIGIN: + yy = max(raw1.shape[0], raw2.shape[0]) + xx = max(raw1.shape[1], raw2.shape[1]) + size = yy, xx + data1 = self.__createMarginImage(raw1, size, transparent=True) + data2 = self.__createMarginImage(raw2, size, transparent=True) + self.__matching_keypoints = [0.0], [0.0], [1.0] + elif alignmentMode == AlignmentMode.CENTER: + yy = max(raw1.shape[0], raw2.shape[0]) + xx = max(raw1.shape[1], raw2.shape[1]) + size = yy, xx + data1 = self.__createMarginImage( + raw1, size, transparent=True, center=True + ) + data2 = self.__createMarginImage( + raw2, size, transparent=True, center=True + ) + self.__matching_keypoints = ( + [data1.shape[1] // 2], + [data1.shape[0] // 2], + [1.0], + ) + elif alignmentMode == AlignmentMode.STRETCH: + data1 = raw1 + data2 = self.__rescaleImage(raw2, data1.shape) + self.__matching_keypoints = ( + [0, data1.shape[1], data1.shape[1], 0], + [0, 0, data1.shape[0], data1.shape[0]], + [1.0, 1.0, 1.0, 1.0], + ) + elif alignmentMode == AlignmentMode.AUTO: + # TODO: sift implementation do not support RGBA images + yy = max(raw1.shape[0], raw2.shape[0]) + xx = max(raw1.shape[1], raw2.shape[1]) + size = yy, xx + data1 = self.__createMarginImage(raw1, size) + data2 = self.__createMarginImage(raw2, size) + self.__matching_keypoints = [0.0], [0.0], [1.0] + try: + data1, data2 = self.__createSiftData(data1, data2) + if data2 is None: + raise ValueError("Unexpected None value") + except Exception as e: + # TODO: Display it on the GUI + _logger.error(e) + self.__setDefaultAlignmentMode() + return + else: + assert False + + self.__item.setImageData1(data1) + self.__item.setImageData2(data2) mode = self.getVisualizationMode() if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG: - data1 = self.__composeImage(data1, data2, mode) - data2 = numpy.empty((0, 0)) + data1 = self.__composeRgbImage(data1, data2, mode) + data2 = None elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY: - data1 = self.__composeImage(data1, data2, mode) - data2 = numpy.empty((0, 0)) + data1 = self.__composeRgbImage(data1, data2, mode) + data2 = None elif mode == VisualizationMode.COMPOSITE_A_MINUS_B: - data1 = self.__composeImage(data1, data2, mode) - data2 = numpy.empty((0, 0)) + data1 = self.__composeAMinusBImage(data1, data2) + data2 = None elif mode == VisualizationMode.ONLY_A: - data2 = numpy.empty((0, 0)) + data2 = None elif mode == VisualizationMode.ONLY_B: data1 = numpy.empty((0, 0)) self.__data1, self.__data2 = data1, data2 - self.__plot.addImage(data1, z=0, legend="image1", resetzoom=False) - self.__plot.addImage(data2, z=0, legend="image2", resetzoom=False) + + colormap = self.__getSealedColormap() + mode1 = self.__getImageMode(self.__data1) + if mode1 == "intensity": + colormap1 = colormap + else: + colormap1 = None + self.__plot.addImage( + data1, z=0, legend="image1", resetzoom=False, colormap=colormap1 + ) self.__image1 = self.__plot.getImage("image1") - self.__image2 = self.__plot.getImage("image2") + + if data2 is not None: + mode2 = self.__getImageMode(data2) + if mode2 == "intensity": + colormap2 = colormap + else: + colormap2 = None + self.__plot.addImage( + data2, z=0, legend="image2", resetzoom=False, colormap=colormap2 + ) + self.__image2 = self.__plot.getImage("image2") + self.__image2.setVisible(True) + else: + if self.__image2 is not None: + self.__image2.setVisible(False) + self.__image2 = None + self.__data2 = numpy.empty((0, 0)) self.__updateKeyPoints() # Set the separator into the middle @@ -1004,27 +654,6 @@ class CompareImages(qt.QMainWindow): value = self.__data1.shape[0] // 2 self.__hline.setPosition(0, value) self.__updateSeparators() - if updateColormap: - self.__updateColormap() - - def __updateColormap(self): - # TODO: The colormap histogram will still be wrong - mode1 = self.__getImageMode(self.__data1) - mode2 = self.__getImageMode(self.__data2) - if mode1 == "intensity" and mode1 == mode2: - if self.__data1.size == 0: - vmin = self.__data2.min() - vmax = self.__data2.max() - elif self.__data2.size == 0: - vmin = self.__data1.min() - vmax = self.__data1.max() - else: - vmin = min(self.__data1.min(), self.__data2.min()) - vmax = max(self.__data1.max(), self.__data2.max()) - colormap = self.getColormap() - colormap.setVRange(vmin=vmin, vmax=vmax) - self.__image1.setColormap(colormap) - self.__image2.setColormap(colormap) def __getImageMode(self, image): """Returns a value identifying the way the image is stored in the @@ -1060,62 +689,117 @@ class CompareImages(qt.QMainWindow): data[:, :, c] = self.__rescaleArray(image[:, :, c], shape) return data - def __composeImage(self, data1, data2, mode): + def __composeRgbImage(self, data1, data2, mode): """Returns an RBG image containing composition of data1 and data2 in 2 different channels + A data image of a size of 0 is considered as missing. This does not + interrupt the processing. + :param numpy.ndarray data1: First image :param numpy.ndarray data1: Second image :param VisualizationMode mode: Composition mode. :rtype: numpy.ndarray """ - assert(data1.shape[0:2] == data2.shape[0:2]) - if mode == VisualizationMode.COMPOSITE_A_MINUS_B: - # TODO: this calculation has no interest of generating a 'composed' - # rgb image, this could be moved in an other function or doc - # should be modified - _type = data1.dtype - result = data1.astype(numpy.float64) - data2.astype(numpy.float64) - return result - mode1 = self.__getImageMode(data1) - if mode1 in ["rgb", "rgba"]: - intensity1 = self.__luminosityImage(data1) - vmin1, vmax1 = 0.0, 1.0 + if data1.size != 0 and data2.size != 0: + assert data1.shape[0:2] == data2.shape[0:2] + + sealed = self.__getSealedColormap() + vmin, vmax = sealed.getVRange() + + if data1.size == 0: + intensity1 = numpy.zeros(data2.shape[0:2]) else: - intensity1 = data1 - vmin1, vmax1 = data1.min(), data1.max() + mode1 = self.__getImageMode(data1) + if mode1 in ["rgb", "rgba"]: + intensity1 = self.__luminosityImage(data1) + else: + intensity1 = data1 - mode2 = self.__getImageMode(data2) - if mode2 in ["rgb", "rgba"]: - intensity2 = self.__luminosityImage(data2) - vmin2, vmax2 = 0.0, 1.0 + if data2.size == 0: + intensity2 = numpy.zeros(data1.shape[0:2]) else: - intensity2 = data2 - vmin2, vmax2 = data2.min(), data2.max() + mode2 = self.__getImageMode(data2) + if mode2 in ["rgb", "rgba"]: + intensity2 = self.__luminosityImage(data2) + else: + intensity2 = data2 - vmin, vmax = min(vmin1, vmin2) * 1.0, max(vmax1, vmax2) * 1.0 - shape = data1.shape + shape = intensity1.shape result = numpy.empty((shape[0], shape[1], 3), dtype=numpy.uint8) - a = (intensity1 - vmin) * (1.0 / (vmax - vmin)) * 255.0 - b = (intensity2 - vmin) * (1.0 / (vmax - vmin)) * 255.0 + a, _, _ = normalize( + intensity1, + norm=sealed.getNormalization(), + autoscale=sealed.getAutoscaleMode(), + vmin=sealed.getVMin(), + vmax=sealed.getVMax(), + gamma=sealed.getGammaNormalizationParameter(), + ) + b, _, _ = normalize( + intensity2, + norm=sealed.getNormalization(), + autoscale=sealed.getAutoscaleMode(), + vmin=sealed.getVMin(), + vmax=sealed.getVMax(), + gamma=sealed.getGammaNormalizationParameter(), + ) if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY: result[:, :, 0] = a - result[:, :, 1] = (a + b) / 2 + result[:, :, 1] = a // 2 + b // 2 result[:, :, 2] = b elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG: result[:, :, 0] = 255 - b - result[:, :, 1] = 255 - (a + b) / 2 + result[:, :, 1] = 255 - (a // 2 + b // 2) result[:, :, 2] = 255 - a return result - def __luminosityImage(self, image): + def __composeAMinusBImage(self, data1, data2): + """Returns an intensity image containing the composition of `A-B`. + + A data image of a size of 0 is considered as missing. This does not + interrupt the processing. + + :param numpy.ndarray data1: First image + :param numpy.ndarray data1: Second image + :rtype: numpy.ndarray + """ + if data1.size != 0 and data2.size != 0: + assert data1.shape[0:2] == data2.shape[0:2] + + data1 = self.__asIntensityImage(data1) + data2 = self.__asIntensityImage(data2) + if data1.size == 0: + result = data2 + elif data2.size == 0: + result = data1 + else: + result = data1.astype(numpy.float32) - data2.astype(numpy.float32) + return result + + def __asIntensityImage(self, image: numpy.ndarray): + """Returns an intensity image. + + If the image use a single channel, it will be returned as it is. + + If the image is an RBG(A) image, the luminosity (0..1) is extracted and + returned. The alpha channel is ignored. + + :rtype: numpy.ndarray + """ + mode = self.__getImageMode(image) + if mode in ["rgb", "rgba"]: + return self.__luminosityImage(image) + return image + + def __luminosityImage(self, image: numpy.ndarray): """Returns the luminosity channel from an RBG(A) image. + The alpha channel is ignored. :rtype: numpy.ndarray """ mode = self.__getImageMode(image) - assert(mode in ["rgb", "rgba"]) + assert mode in ["rgb", "rgba"] is_uint8 = image.dtype.type == numpy.uint8 # luminosity image = 0.21 * image[..., 0] + 0.72 * image[..., 1] + 0.07 * image[..., 2] @@ -1128,8 +812,10 @@ class CompareImages(qt.QMainWindow): :rtype: numpy.ndarray """ - y, x = numpy.ogrid[:shape[0], :shape[1]] - y, x = y * 1.0 * (image.shape[0] - 1) / (shape[0] - 1), x * 1.0 * (image.shape[1] - 1) / (shape[1] - 1) + y, x = numpy.ogrid[: shape[0], : shape[1]] + y, x = y * 1.0 * (image.shape[0] - 1) / (shape[0] - 1), x * 1.0 * ( + image.shape[1] - 1 + ) / (shape[1] - 1) b = silx.image.bilinear.BilinearImage(image) # TODO: could be optimized using strides x2d = numpy.zeros_like(y) + x @@ -1142,8 +828,8 @@ class CompareImages(qt.QMainWindow): :rtype: numpy.ndarray """ - assert(image.shape[0] <= size[0]) - assert(image.shape[1] <= size[1]) + assert image.shape[0] <= size[0] + assert image.shape[1] <= size[1] if image.shape == size: return image mode = self.__getImageMode(image) @@ -1156,7 +842,7 @@ class CompareImages(qt.QMainWindow): if mode == "intensity": data = numpy.zeros(size, dtype=image.dtype) - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1]] = image + data[pos0 : pos0 + image.shape[0], pos1 : pos1 + image.shape[1]] = image # TODO: It is maybe possible to put NaN on the margin else: if transparent: @@ -1164,9 +850,13 @@ class CompareImages(qt.QMainWindow): else: data = numpy.zeros((size[0], size[1], 3), dtype=numpy.uint8) depth = min(data.shape[2], image.shape[2]) - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 0:depth] = image[:, :, 0:depth] + data[ + pos0 : pos0 + image.shape[0], pos1 : pos1 + image.shape[1], 0:depth + ] = image[:, :, 0:depth] if transparent and depth == 3: - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 3] = 255 + data[ + pos0 : pos0 + image.shape[0], pos1 : pos1 + image.shape[1], 3 + ] = 255 return data def __toAffineTransformation(self, sift_result): @@ -1190,7 +880,7 @@ class CompareImages(qt.QMainWindow): return AffineTransformation(tx, ty, sx, sy, rot) def getTransformation(self): - """Retuns the affine transformation applied to the second image to align + """Returns the affine transformation applied to the second image to align it to the first image. This result is only valid for sift alignment. @@ -1219,9 +909,11 @@ class CompareImages(qt.QMainWindow): _logger.info("Number of Keypoints within image 1: %i" % keypoints.size) _logger.info(" within image 2: %i" % second_keypoints.size) - self.__matching_keypoints = (match[:].x[:, 0], - match[:].y[:, 0], - match[:].scale[:, 0]) + self.__matching_keypoints = ( + match[:].x[:, 0], + match[:].y[:, 0], + match[:].scale[:, 0], + ) matching_keypoints = match.shape[0] _logger.info("Matching keypoints: %i" % matching_keypoints) if matching_keypoints == 0: @@ -1241,6 +933,10 @@ class CompareImages(qt.QMainWindow): self.__transformation = self.__toAffineTransformation(result) return data1, data2 + def resetZoom(self, dataMargins=None): + """Reset the plot limits to the bounds of the data and redraw the plot.""" + self.__plot.resetZoom(dataMargins) + def setAutoResetZoom(self, activate=True): """ |