butterfly_viewer.aux_splitview

Image viewing widget for individual images and sliding overlays with sync zoom and pan.

Not intended as a script. Used in Butterfly Viewer and Registrator.

Credits:

PyQt MDI Image Viewer by tpgit (http://tpgit.github.io/MDIImageViewer/) for sync pan and zoom.

   1#!/usr/bin/env python3
   2
   3"""Image viewing widget for individual images and sliding overlays with sync zoom and pan.
   4
   5Not intended as a script. Used in Butterfly Viewer and Registrator.
   6
   7Credits:
   8    PyQt MDI Image Viewer by tpgit (http://tpgit.github.io/MDIImageViewer/) for sync pan and zoom.
   9"""
  10# SPDX-License-Identifier: GPL-3.0-or-later
  11
  12
  13
  14import sip
  15import gc
  16import os
  17import math
  18import csv
  19from datetime import datetime
  20
  21from PyQt5 import QtCore, QtGui, QtWidgets
  22
  23from aux_viewing import SynchableGraphicsView
  24from aux_trackers import EventTracker, EventTrackerSplitBypassDeadzone
  25from aux_functions import strippedName
  26from aux_labels import FilenameLabel
  27from aux_scenes import CustomQGraphicsScene
  28from aux_comments import CommentItem
  29from aux_rulers import RulerItem
  30
  31
  32
  33sip.setapi('QDate', 2)
  34sip.setapi('QTime', 2)
  35sip.setapi('QDateTime', 2)
  36sip.setapi('QUrl', 2)
  37sip.setapi('QTextStream', 2)
  38sip.setapi('QVariant', 2)
  39sip.setapi('QString', 2)
  40
  41    
  42
  43class SplitView(QtWidgets.QFrame):
  44    """Image viewing widget for individual images and sliding overlays.
  45
  46    Creates an interface with a base image as a main image located at the top left  
  47    and optionally 3 other images (top-left, bottom-left, bottom-right) as a sliding overlay.
  48    Supports zoom and pan.
  49    Enables synchronized zoom and pan via signals.
  50    Input images for a given sliding overlay must have identical resolutions to 
  51    function properly.
  52    
  53    Args:
  54        pixmap (QPixmap): The main image to be viewed; the basis of the sliding overlay (main; topleft)
  55        filename_main_topleft (str): The image filepath of the main image.
  56        name (str): The name of the viewing widget.
  57        pixmap_topright (QPixmap): The top-right image of the sliding overlay (set None to exclude).
  58        pixmap_bottomleft (QPixmap): The bottom-left image of the sliding overlay (set None to exclude).
  59        pixmap_bottomright (QPixmap): The bottom-right image of the sliding overlay (set None to exclude).
  60    """
  61
  62    def __init__(self, pixmap_main_topleft=None, filename_main_topleft=None, name=None, 
  63            pixmap_topright=None, pixmap_bottomleft=None, pixmap_bottomright=None, transform_mode_smooth=False):
  64        super().__init__()
  65
  66        self.currentFile = filename_main_topleft
  67
  68        self.setWindowFlags(QtCore.Qt.FramelessWindowHint) # Clean appearance
  69        self.setFrameStyle(QtWidgets.QFrame.NoFrame)
  70
  71        self.viewName = name
  72        self.comment_position_object = None
  73
  74        pixmap_main_topleft = self.pixmap_none_ify(pixmap_main_topleft) # Return None if given QPixmap has zero width/height or is not a QPixmap
  75        pixmap_topright     = self.pixmap_none_ify(pixmap_topright)
  76        pixmap_bottomleft   = self.pixmap_none_ify(pixmap_bottomleft)
  77        pixmap_bottomright  = self.pixmap_none_ify(pixmap_bottomright)
  78
  79        self.pixmap_topright_exists     = (pixmap_topright is not None) # Boolean for existence of pixmap is easier to check
  80        self.pixmap_bottomright_exists  = (pixmap_bottomright is not None)
  81        self.pixmap_bottomleft_exists   = (pixmap_bottomleft is not None)
  82
  83        # Pixmaps which do no exist should be transparent, so they are made into an empty pixmap
  84        if not self.pixmap_bottomright_exists:
  85            pixmap_bottomright = QtGui.QPixmap()
  86
  87        if not self.pixmap_topright_exists:
  88            pixmap_topright = QtGui.QPixmap()
  89            
  90        if not self.pixmap_bottomleft_exists:
  91            pixmap_bottomleft = QtGui.QPixmap()
  92
  93        self._zoomFactorDelta = 1.25 # How much zoom for each zoom call
  94
  95        self.transform_mode_smooth = transform_mode_smooth
  96
  97        # Sliding overlay is based on the main pixmap view of the top-left pixmap
  98        # self._scene_main_topleft = QtWidgets.QGraphicsScene()
  99        self._scene_main_topleft = CustomQGraphicsScene()
 100        self._view_main_topleft = SynchableGraphicsView(self._scene_main_topleft)
 101
 102        self._view_main_topleft.setInteractive(True) # Functional settings
 103        self._view_main_topleft.setViewportUpdateMode(QtWidgets.QGraphicsView.MinimalViewportUpdate)
 104        self._view_main_topleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 105        self._view_main_topleft.setRenderHints(QtGui.QPainter.SmoothPixmapTransform | QtGui.QPainter.Antialiasing)
 106
 107        self._scene_main_topleft.changed.connect(self.sceneChanged) # Pass along underlying signals
 108        self._view_main_topleft.transformChanged.connect(self.transformChanged)
 109        self._view_main_topleft.transformChanged.connect(self.on_transformChanged)
 110        self._view_main_topleft.scrollChanged.connect(self.scrollChanged)
 111        self._view_main_topleft.wheelNotches.connect(self.handleWheelNotches)
 112        self._scene_main_topleft.right_click_comment.connect(self.on_right_click_comment)
 113        self._scene_main_topleft.right_click_ruler.connect(self.on_right_click_ruler)
 114        self._scene_main_topleft.right_click_save_all_comments.connect(self.on_right_click_save_all_comments)
 115        self._scene_main_topleft.right_click_load_comments.connect(self.on_right_click_load_comments)
 116        self._scene_main_topleft.right_click_relative_origin_position.connect(self.on_right_click_set_relative_origin_position)
 117        self._scene_main_topleft.changed_px_per_unit.connect(self.on_changed_px_per_unit)
 118        self._scene_main_topleft.right_click_single_transform_mode_smooth.connect(self.set_transform_mode_smooth)
 119        self._scene_main_topleft.right_click_all_transform_mode_smooth.connect(self.was_set_global_transform_mode)
 120        self._scene_main_topleft.right_click_background_color.connect(self.set_scene_background_color)
 121        self._scene_main_topleft.right_click_background_color.connect(self.was_set_scene_background_color)
 122
 123        self._pixmapItem_main_topleft = QtWidgets.QGraphicsPixmapItem()
 124        self._scene_main_topleft.addItem(self._pixmapItem_main_topleft)
 125        
 126        # A pseudo view directly atop the main view is needed to drive the position of the split and layout of the four pixmaps 
 127        self._view_layoutdriving_topleft = QtWidgets.QGraphicsView()
 128        self._view_layoutdriving_topleft.setStyleSheet("border: 0px; border-style: solid; background-color: rgba(0,0,0,0)")
 129        self._view_layoutdriving_topleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 130        self._view_layoutdriving_topleft.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 131        self._view_layoutdriving_topleft.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 132        self._view_layoutdriving_topleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
 133        
 134        # Add top right pixmap view
 135        self._pixmapItem_topright = QtWidgets.QGraphicsPixmapItem()
 136        self.pixmap_topright = pixmap_topright
 137        
 138        self._scene_topright = QtWidgets.QGraphicsScene()
 139        self._scene_topright.addItem(self._pixmapItem_topright)
 140        
 141        self._view_topright = QtWidgets.QGraphicsView(self._scene_topright)
 142        self._view_topright.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 143        self._view_topright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 144        self._view_topright.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 145        self._view_topright.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 146        self._view_topright.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 147        
 148        # Add bottom left pixmap view
 149        self._pixmapItem_bottomleft = QtWidgets.QGraphicsPixmapItem()
 150        self.pixmap_bottomleft = pixmap_bottomleft
 151        
 152        self._scene_bottomleft = QtWidgets.QGraphicsScene()
 153        self._scene_bottomleft.addItem(self._pixmapItem_bottomleft)
 154        
 155        self._view_bottomleft = QtWidgets.QGraphicsView(self._scene_bottomleft)
 156        self._view_bottomleft.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 157        self._view_bottomleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 158        self._view_bottomleft.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 159        self._view_bottomleft.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 160        self._view_bottomleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 161        
 162        # Add bottom right pixmap view
 163        self._pixmapItem_bottomright = QtWidgets.QGraphicsPixmapItem()
 164        self.pixmap_bottomright = pixmap_bottomright
 165        
 166        self._scene_bottomright = QtWidgets.QGraphicsScene()
 167        self._scene_bottomright.addItem(self._pixmapItem_bottomright)
 168        
 169        self._view_bottomright = QtWidgets.QGraphicsView(self._scene_bottomright)
 170        self._view_bottomright.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 171        self._view_bottomright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 172        self._view_bottomright.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 173        self._view_bottomright.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 174        self._view_bottomright.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 175
 176         # Make the sizes of the four views entirely dictated by the "layout driving" view
 177        size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored)
 178        self._view_main_topleft.setSizePolicy(size_policy)
 179        self._view_topright.setSizePolicy(size_policy)
 180        self._view_bottomright.setSizePolicy(size_policy)
 181        self._view_bottomleft.setSizePolicy(size_policy)
 182
 183        # By default the split is set to half the widget's size so all pixmap views are equally sized at the start
 184        self._view_layoutdriving_topleft.setMaximumWidth(self.width()/2.0)
 185        self._view_layoutdriving_topleft.setMaximumHeight(self.height()/2.0)
 186        
 187        if pixmap_main_topleft: # Instantiate transform and resizing
 188            self.pixmap_main_topleft = pixmap_main_topleft
 189        
 190        # SplitView layout
 191        self._layout = QtWidgets.QGridLayout()
 192        self._layout.setContentsMargins(0, 0, 0, 0)
 193        self._layout.setSpacing(0)
 194        
 195        self.setContentsMargins(0, 0, 0, 0)
 196
 197        # Labels for the four pixmap views
 198        self.label_main_topleft = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 199        self.label_topright     = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 200        self.label_bottomright  = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 201        self.label_bottomleft   = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 202
 203        self.label_main_topleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 204        self.label_topright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 205        self.label_bottomright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 206        self.label_bottomleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 207
 208        # Pushbutton to close the image window
 209        self.close_pushbutton = QtWidgets.QPushButton("×")
 210        self.close_pushbutton.setToolTip("Close image window")
 211        self.close_pushbutton.clicked.connect(self.was_clicked_close_pushbutton)
 212        self.close_pushbutton_always_visible = True
 213
 214        # Create deadzones along the bounds of SplitView to fix the issue of resize handles showing in QMdiArea despite windowless setting.
 215        # An event tracker "bypass" is needed for each deadzone because they hide the mouse from the sliding overlay, so the mouse must be separately tracked to ensure the split is updated.
 216        px_deadzone = 8
 217
 218        self.resize_deadzone_top = QtWidgets.QPushButton("")
 219        self.resize_deadzone_top.setFixedHeight(px_deadzone)
 220        self.resize_deadzone_top.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
 221        self.resize_deadzone_top.setEnabled(False)
 222        self.resize_deadzone_top.setStyleSheet("""
 223            QPushButton {
 224                color: transparent;
 225                background-color: transparent; 
 226                border: 0px black;
 227            }
 228            """)
 229        tracker_deadzone_top = EventTrackerSplitBypassDeadzone(self.resize_deadzone_top)
 230        tracker_deadzone_top.mouse_position_changed_global.connect(self.update_split_given_global)
 231
 232        self.resize_deadzone_bottom = QtWidgets.QPushButton("")
 233        self.resize_deadzone_bottom.setFixedHeight(px_deadzone)
 234        self.resize_deadzone_bottom.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
 235        self.resize_deadzone_bottom.setEnabled(False)
 236        self.resize_deadzone_bottom.setStyleSheet("""
 237            QPushButton {
 238                color: transparent;
 239                background-color: transparent; 
 240                border: 0px black;
 241            }
 242            """)
 243        tracker_deadzone_bottom = EventTrackerSplitBypassDeadzone(self.resize_deadzone_bottom)
 244        tracker_deadzone_bottom.mouse_position_changed_global.connect(self.update_split_given_global)
 245
 246        self.resize_deadzone_left = QtWidgets.QPushButton("")
 247        self.resize_deadzone_left.setFixedWidth(px_deadzone)
 248        self.resize_deadzone_left.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)
 249        self.resize_deadzone_left.setEnabled(False)
 250        self.resize_deadzone_left.setStyleSheet("""
 251            QPushButton {
 252                color: transparent;
 253                background-color: transparent; 
 254                border: 0px black;
 255            }
 256            """)
 257        tracker_deadzone_left = EventTrackerSplitBypassDeadzone(self.resize_deadzone_left)
 258        tracker_deadzone_left.mouse_position_changed_global.connect(self.update_split_given_global)
 259
 260        self.resize_deadzone_right = QtWidgets.QPushButton("")
 261        self.resize_deadzone_right.setFixedWidth(px_deadzone)
 262        self.resize_deadzone_right.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)
 263        self.resize_deadzone_right.setEnabled(False)
 264        self.resize_deadzone_right.setStyleSheet("""
 265            QPushButton {
 266                color: transparent;
 267                background-color: transparent; 
 268                border: 0px black;
 269            }
 270            """)
 271        tracker_deadzone_right = EventTrackerSplitBypassDeadzone(self.resize_deadzone_right)
 272        tracker_deadzone_right.mouse_position_changed_global.connect(self.update_split_given_global)
 273
 274        # A frame is placed over the border of the widget to highlight it as the active subwindow in Butterfly Viewer.
 275        self.frame_hud = QtWidgets.QFrame()
 276        self.frame_hud.setStyleSheet("border: 0px solid transparent")
 277        self.frame_hud.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 278
 279        # Set layout
 280        self._layout.addWidget(self._view_main_topleft, 0, 0, 2, 2)
 281        self._layout.addWidget(self._view_layoutdriving_topleft, 0, 0)
 282        self._layout.addWidget(self._view_topright, 0, 1)
 283        self._layout.addWidget(self._view_bottomleft, 1, 0)
 284        self._layout.addWidget(self._view_bottomright, 1, 1)
 285
 286        self._layout.addWidget(self.resize_deadzone_top, 0, 0, 2, 2, QtCore.Qt.AlignTop)
 287        self._layout.addWidget(self.resize_deadzone_bottom, 0, 0, 2, 2, QtCore.Qt.AlignBottom)
 288        self._layout.addWidget(self.resize_deadzone_left, 0, 0, 2, 2, QtCore.Qt.AlignLeft)
 289        self._layout.addWidget(self.resize_deadzone_right, 0, 0, 2, 2, QtCore.Qt.AlignRight)
 290
 291        self._layout.addWidget(self.frame_hud, 0, 0, 2, 2)
 292
 293        self._layout.addWidget(self.label_main_topleft, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
 294        self._layout.addWidget(self.label_topright, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)
 295        self._layout.addWidget(self.label_bottomright, 0, 0, 2, 2, QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)
 296        self._layout.addWidget(self.label_bottomleft, 0, 0, 2, 2, QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft)
 297
 298        self._layout.addWidget(self.close_pushbutton, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)
 299
 300        self.setLayout(self._layout)
 301
 302        self.set_scene_background_color(self._scene_main_topleft.background_color)
 303        self._view_main_topleft.setStyleSheet("border: 0px; border-style: solid; border-color: red; background-color: rgba(0,0,0,0)")
 304
 305        # Track the mouse position to know where to set the split
 306        self.tracker = EventTracker(self)
 307        self.tracker.mouse_position_changed.connect(self.positionChanged)
 308        self.tracker.mouse_position_changed.connect(self.on_positionChanged)
 309        
 310        # Create a rectangular box the size of one pixel in the main scene to show the user the size and position of the pixel over which their mouse is hovering 
 311        self.mouse_rect_scene_main_topleft = None
 312        self.create_mouse_rect()
 313
 314        # Allow users to lock the split and remember where the split was last set
 315        self.split_locked = False
 316        self.last_updated_point_of_split_on_scene_main = QtCore.QPoint()
 317
 318        self.enableScrollBars(False) # Clean look with no scrollbars
 319
 320    @property
 321    def currentFile(self):
 322        """str: Filepath of base image (filename_main_topleft)."""
 323        return self._currentFile
 324
 325    @currentFile.setter
 326    def currentFile(self, filename_main_topleft):
 327        self._currentFile = QtCore.QFileInfo(filename_main_topleft).canonicalFilePath()
 328        self._isUntitled = False
 329        self.setWindowTitle(self.userFriendlyCurrentFile)
 330
 331    @property
 332    def userFriendlyCurrentFile(self):
 333        """str: Filename of base image."""
 334        if self.currentFile:
 335            return strippedName(self.currentFile)
 336        else:
 337            return ""
 338    
 339    def set_close_pushbutton_always_visible(self, boolean):
 340        """Enable/disable the always-on visiblilty of the close X of the view.
 341        
 342        Arg:
 343            boolean (bool): True to show the close X always; False to hide unless mouse hovers over.
 344        """
 345        self.close_pushbutton_always_visible = boolean
 346        self.refresh_close_pushbutton_stylesheet()
 347            
 348    def refresh_close_pushbutton_stylesheet(self):
 349        """Refresh stylesheet of close pushbutton based on background color and visibility."""
 350        if not self.close_pushbutton:
 351            return
 352        always_visible = self.close_pushbutton_always_visible
 353        background_rgb =  self._scene_main_topleft.background_rgb
 354        avg_background_rgb = sum(background_rgb)/len(background_rgb)
 355        if not always_visible: # Hide unless hovered
 356            self.close_pushbutton.setStyleSheet("""
 357                QPushButton {
 358                    width: 1.8em;
 359                    height: 1.8em;
 360                    color: transparent;
 361                    background-color: rgba(223, 0, 0, 0); 
 362                    font-weight: bold;
 363                    border-width: 0px;
 364                    border-style: solid;
 365                    border-color: transparent;
 366                    font-size: 10pt;
 367                }
 368                QPushButton:hover {
 369                    color: white;
 370                    background-color: rgba(223, 0, 0, 223);
 371                }
 372                QPushButton:pressed {
 373                    color: white;
 374                    background-color: rgba(255, 0, 0, 255);
 375                }
 376                """)
 377        else: # Always visible
 378            if avg_background_rgb >= 223: # Unhovered is black X on light background
 379                self.close_pushbutton.setStyleSheet("""
 380                    QPushButton {
 381                        width: 1.8em;
 382                        height: 1.8em;
 383                        color: black;
 384                        background-color: rgba(223, 0, 0, 0); 
 385                        font-weight: bold;
 386                        border-width: 0px;
 387                        border-style: solid;
 388                        border-color: transparent;
 389                        font-size: 10pt;
 390                    }
 391                    QPushButton:hover {
 392                        color: white;
 393                        background-color: rgba(223, 0, 0, 223);
 394                    }
 395                    QPushButton:pressed {
 396                        color: white;
 397                        background-color: rgba(255, 0, 0, 255);
 398                    }
 399                    """)
 400            else: # Unhovered is white X on dark background
 401                self.close_pushbutton.setStyleSheet("""
 402                    QPushButton {
 403                        width: 1.8em;
 404                        height: 1.8em;
 405                        color: white;
 406                        background-color: rgba(223, 0, 0, 0); 
 407                        font-weight: bold;
 408                        border-width: 0px;
 409                        border-style: solid;
 410                        border-color: transparent;
 411                        font-size: 10pt;
 412                    }
 413                    QPushButton:hover {
 414                        color: white;
 415                        background-color: rgba(223, 0, 0, 223);
 416                    }
 417                    QPushButton:pressed {
 418                        color: white;
 419                        background-color: rgba(255, 0, 0, 255);
 420                    }
 421                    """)
 422            
 423        
 424    def set_scene_background(self, brush):
 425        """Set scene background color with QBrush.
 426        
 427        Args:
 428            brush (QBrush)
 429        """
 430        if not self._scene_main_topleft:
 431            return
 432        self._scene_main_topleft.setBackgroundBrush(brush)
 433
 434    def set_scene_background_color(self, color: list):
 435        """Set scene background color with color list.
 436
 437        The init for CustomQGraphicsScene contains the ground truth for selectable background colors.
 438        
 439        Args:
 440            color (list): Descriptor string and RGB int values. Example: ["White", 255, 255, 255].
 441        """
 442        if not self._scene_main_topleft:
 443            return
 444        rgb = color[1:4]
 445        rgb_clamp = [max(min(channel, 255), 0) for channel in rgb]
 446        brush = QtGui.QColor(rgb_clamp[0], rgb_clamp[1], rgb_clamp[2])
 447        self.set_scene_background(brush)
 448        self._scene_main_topleft.background_color = color
 449        self.refresh_close_pushbutton_stylesheet()
 450        
 451    
 452    def pixmap_none_ify(self, pixmap):
 453        """Return None if pixmap has no pixels.
 454        
 455        Args:
 456            pixmap (QPixmap)
 457
 458        Returns:
 459            None if pixmap has no pixels; same pixmap if it has pixels
 460        """
 461        if pixmap:
 462            if pixmap.width()==0 or pixmap.height==0:
 463                return None
 464            else:
 465                return pixmap
 466        else:
 467            return None
 468    
 469    @QtCore.pyqtSlot(QtCore.QPoint)
 470    def on_positionChanged(self, pos):
 471        """Update the position of the split and the 1x1 pixel rectangle.
 472
 473        Triggered when mouse is moved.
 474        
 475        Args:
 476            pos (QPoint): The position of the mouse relative to widget.
 477        """
 478        point_of_mouse_on_widget = pos
 479        
 480        self.update_split(point_of_mouse_on_widget)
 481        self.update_mouse_rect(point_of_mouse_on_widget)
 482
 483    def set_split(self, x_percent=0.5, y_percent=0.5, ignore_lock=False, percent_of_visible=False):
 484        """Set the position of the split with x and y as proportion of base image's resolution.
 485
 486        Sets split position using a proportion of x and y (by default of entire main pixmap; can be set to proportion of visible pixmap).
 487        Top left is x=0, y=0; bottom right is x=1, y=1.
 488        This is needed to position the split without mouse movement from user (for example, to preview the effect of the transparency sliders in Butterfly Viewer)
 489        
 490        Args:
 491            x_percent (float): The position of the split as a proportion (0-1) of the base image's horizontal resolution.
 492            y_percent (float): The position of the split as a proportion (0-1) of the base image's vertical resolution.
 493            ignore_lock (bool): True to ignore the lock status of the split; False to adhere.
 494            percent_of_visible (bool): True to set split as proportion of visible area; False as proportion of the full image resolution.
 495        """
 496        if percent_of_visible:
 497            x = x_percent*self.width()
 498            y = y_percent*self.height()
 499            point_of_split_on_widget = QtCore.QPoint(x, y)
 500        else:
 501            width_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().width()
 502            height_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().height()
 503
 504            x = x_percent*width_pixmap_main_topleft
 505            y = y_percent*height_pixmap_main_topleft
 506
 507            point_of_split_on_scene = QtCore.QPointF(x,y)
 508
 509            point_of_split_on_widget = self._view_main_topleft.mapFromScene(point_of_split_on_scene)
 510
 511        self.update_split(point_of_split_on_widget, ignore_lock=ignore_lock)
 512        
 513    def update_split(self, pos = None, pos_is_global=False, ignore_lock=False): 
 514        """Update the position of the split with mouse position.
 515        
 516        Args:
 517            pos (QPoint): Position of the mouse.
 518            pos_is_global (bool): True if given mouse position is relative to MdiChild; False if global position.
 519            ignore_lock (bool): True to ignore (bypass) the status of the split lock.
 520        """
 521        if pos is None: # Get position of the split from the mouse's global coordinates (can be slow!)
 522            point_of_cursor_global      = QtGui.QCursor.pos()
 523            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(point_of_cursor_global)
 524        else:
 525            if pos_is_global:
 526                point_of_mouse_on_widget = self._view_main_topleft.mapFromGlobal(pos)
 527            else:
 528                point_of_mouse_on_widget = pos
 529
 530        point_of_mouse_on_widget.setX(point_of_mouse_on_widget.x()+1) # Offset +1 needed to have mouse cursor be hovering over the main scene (e.g., to allow manipulation of graphics item)
 531        point_of_mouse_on_widget.setY(point_of_mouse_on_widget.y()+1)
 532
 533        self.last_updated_point_of_split_on_scene_main = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(), point_of_mouse_on_widget.y())
 534        
 535        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
 536        
 537        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
 538
 539        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
 540        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
 541        
 542        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
 543        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
 544        
 545        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
 546    
 547        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
 548        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
 549        
 550        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
 551        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 552        
 553        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
 554        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 555        
 556        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
 557        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
 558        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
 559        
 560        self._view_topright.setSceneRect(rect_of_scene_topright)
 561        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
 562        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
 563        
 564        self._view_topright.centerOn(top_left_of_scene_topright)
 565        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
 566        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)
 567
 568    def refresh_split_based_on_last_updated_point_of_split_on_scene_main(self): 
 569        """Refresh the position of the split using the previously recorded split location.
 570        
 571        This is needed to maintain the position of the split during synchronized zooming and panning.
 572        """ 
 573        point_of_mouse_on_widget = self._view_main_topleft.mapFromScene(self.last_updated_point_of_split_on_scene_main)
 574        
 575        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
 576        
 577        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
 578        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
 579        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
 580
 581        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
 582        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
 583        
 584        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
 585    
 586        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
 587        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
 588        
 589        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
 590        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 591        
 592        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
 593        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 594        
 595        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
 596        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
 597        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
 598        
 599        self._view_topright.setSceneRect(rect_of_scene_topright)
 600        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
 601        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
 602        
 603        self._view_topright.centerOn(top_left_of_scene_topright)
 604        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
 605        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)
 606    
 607    def update_split_given_global(self, pos_global):
 608        """Update the position of the split based on given global mouse position.
 609
 610        Convenience function.
 611
 612        Args:
 613            pos_global (QPoint): The position of the mouse in global coordinates.
 614        """
 615        self.update_split(pos = pos_global, pos_is_global=True)
 616
 617
 618    def on_right_click_comment(self, scene_pos):
 619        """Create an editable and movable comment on the scene of the main view at the given scene position.
 620
 621        Args:
 622            pos (QPointF): position of the comment datum on the scene.
 623        """
 624        pos_on_scene = scene_pos
 625        self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=pos_on_scene, set_cursor_on_creation=True))
 626
 627    def on_right_click_ruler(self, scene_pos, relative_origin_position, unit="px", px_per_unit=1.0, update_px_per_unit_on_existing=False):
 628        """Create a movable ruler on the scene of the main view at the given scene position.
 629
 630        Args:
 631            scene_pos (QPointF): The position of the ruler center on the scene.
 632            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
 633            unit (str): The text for labeling units of ruler values.
 634            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
 635            update_px_per_unit_on_existing (bool): False always. (Legacy from past versions; future work should remove.
 636        """
 637        placement_factor = 1/3
 638        px_per_unit = px_per_unit
 639        relative_origin_position = relative_origin_position
 640
 641        widget_width = self.width()
 642        widget_height = self.height()
 643
 644        pos_p1 = self._view_main_topleft.mapToScene(widget_width*placement_factor, widget_height*placement_factor)
 645        pos_p2 = self._view_main_topleft.mapToScene(widget_width*(1-placement_factor), widget_height*(1-placement_factor))
 646        self._scene_main_topleft.addItem(RulerItem(unit=unit, px_per_unit=px_per_unit, initial_pos_p1=pos_p1, initial_pos_p2=pos_p2, relative_origin_position=relative_origin_position))
 647
 648    def on_changed_px_per_unit(self, unit, px_per_unit):
 649        """Update the units and pixel-per-unit conversions of all rulers in main scene.
 650
 651        Args:
 652            unit (str): The text for labeling units of ruler values.
 653            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
 654        """
 655        for item in self._scene_main_topleft.items():
 656            if isinstance(item, RulerItem):
 657                if item.unit == unit:
 658                    item.set_and_refresh_px_per_unit(px_per_unit)
 659
 660    def on_right_click_save_all_comments(self):
 661        """Open a dialog window for user to save all existing comments on the main scene to .csv.
 662        
 663        Triggered from right-click menu on view.
 664        """
 665        self.display_loading_grayout(True, "Selecting folder and name for saving all comments in current view...", pseudo_load_time=0)
 666
 667        style = []
 668        x = []
 669        y = []
 670        color = []
 671        string = []
 672        i = -1
 673        for item in self._scene_main_topleft.items():
 674            
 675            if isinstance(item, CommentItem):
 676                i += 1
 677                
 678                comment_pos = item.get_scene_pos()
 679                comment_color = item.get_color()
 680                comment_string = item.get_comment_text_str()
 681
 682                style.append("plain text")
 683                x.append(comment_pos.x())
 684                y.append(comment_pos.y())
 685                color.append(comment_color)
 686                string.append(comment_string)
 687
 688
 689        folderpath = None
 690        filepath_mainview = self.currentFile
 691
 692        if filepath_mainview:
 693            folderpath = filepath_mainview
 694            folderpath = os.path.dirname(folderpath)
 695            folderpath = folderpath + "\\"
 696        else:
 697            self.display_loading_grayout(False, pseudo_load_time=0)
 698            return
 699        
 700        header = [["Butterfly Viewer"],
 701                  ["1.0"],
 702                  ["comments"],
 703                  ["no details"],
 704                  ["origin"],
 705                  [os.path.basename(filepath_mainview)],
 706                  ["Style", "Image x", "Image y", "Appearance", "String"]]
 707
 708        date_and_time = datetime.now().strftime('%Y-%m-%d %H%M%S') # Sets the default filename with date and time 
 709        filename = "Untitled comments" + " - " + os.path.basename(filepath_mainview).split('.')[0] + " - " + date_and_time + ".csv"
 710        name_filters = "CSV (*.csv)" # Allows users to select filetype of screenshot
 711        
 712        filepath, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save all comments of current view to .csv", folderpath+filename, name_filters)
 713
 714        if filepath:
 715            self.display_loading_grayout(True, "Saving all comments of current view to .csv...")
 716
 717            with open(filepath, "w", newline='') as csv_file:
 718                csv_writer = csv.writer(csv_file, delimiter="|")
 719                for row in header:
 720                    csv_writer.writerow(row)
 721                for row in zip(style, x, y, color, string):
 722                    csv_writer.writerow(row)
 723
 724        self.display_loading_grayout(False)
 725
 726
 727    def on_right_click_load_comments(self):
 728        """Open a dialog window for user to load comments to the main scene via .csv as saved previously.
 729        
 730        Triggered from right-click menu on view.
 731        """
 732        self.display_loading_grayout(True, "Selecting comment file (.csv) to load into current view...", pseudo_load_time=0)
 733
 734        folderpath = None
 735        filepath_mainview = self.currentFile
 736
 737        if filepath_mainview:
 738            folderpath = filepath_mainview
 739            folderpath = os.path.dirname(folderpath)
 740            # folderpath = folderpath + "\\"
 741        else:
 742            self.display_loading_grayout(False, pseudo_load_time=0)
 743            return
 744
 745        filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select comment file (.csv) to load into current view", folderpath, "Comma-Separated Value File (*.csv)")
 746
 747        if filename:
 748            self.display_loading_grayout(True, "Loading comments from selected .csv into current view...")
 749
 750            with open(filename, "r", newline='') as csv_file:
 751                csv_reader = csv.reader(csv_file, delimiter="|")
 752                csv_list = list(csv_reader)
 753
 754                i = None
 755
 756                try:
 757                    i = csv_list.index(["Style", "Image x", "Image y", "Appearance", "String"])
 758                except ValueError:
 759                    box_type = QtWidgets.QMessageBox.Warning
 760                    title = "Invalid .csv comment file"
 761                    text = "The selected .csv comment file does not have a format accepted by this app."
 762                    box_buttons = QtWidgets.QMessageBox.Close
 763                    box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
 764                    box.exec_()
 765                else:
 766                    i += 1 # Move to first comment item
 767                    no_comments = True
 768                    for row in csv_list[i:]:
 769                        if row[0] == "plain text":
 770                            no_comments = False
 771                            comment_x = float(row[1])
 772                            comment_y = float(row[2])
 773                            comment_color = row[3]
 774                            comment_string = row[4]
 775                            comment_pos = QtCore.QPointF(comment_x, comment_y)
 776                            self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=comment_pos, color=comment_color, comment_text=comment_string, set_cursor_on_creation=False))
 777                    if no_comments:
 778                        box_type = QtWidgets.QMessageBox.Warning
 779                        title = "No comments in .csv"
 780                        text = "No comments found in the selected .csv comment file."
 781                        box_buttons = QtWidgets.QMessageBox.Close
 782                        box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
 783                        box.exec_()
 784                    
 785        self.display_loading_grayout(False)
 786
 787    def on_right_click_set_relative_origin_position(self, string):
 788        """Set orientation of the coordinate system for rulers by positioning the relative origin.
 789
 790        Allows users to switch the coordinate orientation:
 791            "bottomleft" for Cartesian-style (positive X right, positive Y up)
 792            topleft" for image-style (positive X right, positive Y down)
 793
 794        Args:
 795            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
 796        """
 797        for item in self._scene_main_topleft.items():
 798            if isinstance(item, RulerItem):
 799                item.set_and_refresh_relative_origin_position(string)
 800
 801    def display_loading_grayout(self, boolean, text="Loading...", pseudo_load_time=0.2):
 802        """Emit signal for showing/hiding a grayout screen to indicate loading sequences.
 803
 804        Args:
 805            boolean (bool): True to show grayout; False to hide.
 806            text (str): The text to show on the grayout.
 807            pseudo_load_time (float): The delay (in seconds) to hide the grayout to give user feeling of action.
 808        """ 
 809        self.signal_display_loading_grayout.emit(boolean, text, pseudo_load_time)
 810        
 811    def update_mouse_rect(self, pos = None):
 812        """Update the position of red 1x1 outline at the pointer in the main scene.
 813
 814        Args:
 815            pos (QPoint): The position of the mouse on the widget. Set to None to make the function determine the position using the mouse global coordinates.
 816        """
 817        if not self.mouse_rect_scene_main_topleft:
 818            return
 819    
 820        if pos is None: # Get position of split on main scene by directly pulling mouse global coordinates
 821            point_of_cursor_global      = QtGui.QCursor.pos()
 822            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(QtGui.QCursor.pos())
 823        else:
 824            point_of_mouse_on_widget = pos
 825    
 826        mouse_rect_pos_origin = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(),point_of_mouse_on_widget.y())
 827        mouse_rect_pos_origin.setX(math.floor(mouse_rect_pos_origin.x() - self.mouse_rect_width + 1))
 828        mouse_rect_pos_origin.setY(math.floor(mouse_rect_pos_origin.y() - self.mouse_rect_height + 1))
 829        
 830        self.mouse_rect_scene_main_topleft.setPos(mouse_rect_pos_origin.x(), mouse_rect_pos_origin.y())
 831    
 832    # Signals
 833    signal_display_loading_grayout = QtCore.pyqtSignal(bool, str, float)
 834    """Emitted when comments are being saved or loaded."""
 835
 836    became_closed = QtCore.pyqtSignal()
 837    """Emitted when SplitView is closed."""
 838
 839    was_clicked_close_pushbutton = QtCore.pyqtSignal()
 840    """Emitted when close pushbutton is clicked (pressed+released)."""
 841
 842    was_set_global_transform_mode = QtCore.pyqtSignal(bool)
 843    """Emitted when transform mode is set for all views in right-click menu (passes it along)."""
 844
 845    was_set_scene_background_color = QtCore.pyqtSignal(list)
 846    """Emitted when background color is set in right-click menu (passes it along)."""
 847
 848    positionChanged = QtCore.pyqtSignal(QtCore.QPoint)
 849    """Emitted when mouse changes position."""
 850
 851    sceneChanged = QtCore.pyqtSignal('QList<QRectF>')
 852    """Scene Changed **Signal**.
 853
 854    Emitted whenever the |QGraphicsScene| content changes."""
 855    
 856    transformChanged = QtCore.pyqtSignal()
 857    """Transformed Changed **Signal**.
 858
 859    Emitted whenever the |QGraphicsView| Transform matrix has been changed."""
 860
 861    scrollChanged = QtCore.pyqtSignal()
 862    """Scroll Changed **Signal**.
 863
 864    Emitted whenever the scrollbar position or range has changed."""
 865
 866    def connectSbarSignals(self, slot):
 867        """Connect to scrollbar changed signals.
 868
 869        :param slot: slot to connect scrollbar signals to."""
 870        self._view_main_topleft.connectSbarSignals(slot)
 871
 872    def disconnectSbarSignals(self):
 873        self._view_main_topleft.disconnectSbarSignals()
 874
 875    @property
 876    def pixmap_main_topleft(self):
 877        """The currently viewed |QPixmap| (*QPixmap*)."""
 878        return self._pixmapItem_main_topleft.pixmap()
 879
 880    @pixmap_main_topleft.setter
 881    def pixmap_main_topleft(self, pixmap_main_topleft):
 882        if pixmap_main_topleft is not None:
 883            self._pixmapItem_main_topleft.setPixmap(pixmap_main_topleft)
 884            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
 885            self._pixmap_base_original = pixmap_main_topleft
 886            self.set_opacity_base(100)
 887
 888    @QtCore.pyqtSlot()
 889    def set_opacity_base(self, percent):
 890        """Set transparency of base image.
 891        
 892        Args:
 893            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 894        """
 895        
 896        self._opacity_base = percent
 897
 898        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_base_original.size())
 899        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 900        painter = QtGui.QPainter(pixmap_to_be_transparent)
 901        painter.setOpacity(percent/100)
 902        painter.drawPixmap(QtCore.QPoint(), self._pixmap_base_original)
 903        painter.end()
 904
 905        self._pixmapItem_main_topleft.setPixmap(pixmap_to_be_transparent)
 906    
 907    @property
 908    def pixmap_topright(self):
 909        """The currently viewed QPixmap of the top-right of the split."""
 910        return self._pixmapItem_topright.pixmap()
 911    
 912    @pixmap_topright.setter
 913    def pixmap_topright(self, pixmap):
 914        self._pixmap_topright_original = pixmap
 915        self.set_opacity_topright(100)
 916    
 917    @QtCore.pyqtSlot()
 918    def set_opacity_topright(self, percent):
 919        """Set transparency of top-right of sliding overlay.
 920        
 921        Allows users to see base image underneath.
 922        Provide enhanced integration and comparison of images (for example, blending raking light with color).
 923
 924        Args:
 925            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 926        """
 927        if not self.pixmap_topright_exists:
 928            self._opacity_topright = 100
 929            return
 930        
 931        self._opacity_topright = percent
 932
 933        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_topright_original.size())
 934        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 935        painter = QtGui.QPainter(pixmap_to_be_transparent)
 936        painter.setOpacity(percent/100)
 937        painter.drawPixmap(QtCore.QPoint(), self._pixmap_topright_original)
 938        painter.end()
 939
 940        self._pixmapItem_topright.setPixmap(pixmap_to_be_transparent)
 941    
 942
 943    @property
 944    def pixmap_bottomright(self):
 945        """The currently viewed QPixmap of the bottom-right of the split."""
 946        return self._pixmapItem_bottomright.pixmap()
 947    
 948    @pixmap_bottomright.setter
 949    def pixmap_bottomright(self, pixmap):
 950        self._pixmap_bottomright_original = pixmap
 951        self.set_opacity_bottomright(100)
 952    
 953    @QtCore.pyqtSlot()
 954    def set_opacity_bottomright(self, percent):
 955        """Set transparency of bottom-right of sliding overlay.
 956
 957        Args:
 958            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 959        """
 960        if not self.pixmap_bottomright_exists:
 961            self._opacity_bottomright = 100
 962            return
 963
 964        self._opacity_bottomright = percent
 965
 966        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomright_original.size())
 967        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 968        painter = QtGui.QPainter(pixmap_to_be_transparent)
 969        painter.setOpacity(percent/100)
 970        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomright_original)
 971        painter.end()
 972
 973        self._pixmapItem_bottomright.setPixmap(pixmap_to_be_transparent)
 974    
 975
 976    @property
 977    def pixmap_bottomleft(self):
 978        """The currently viewed QPixmap of the bottom-left of the split."""
 979        return self._pixmapItem_bottomleft.pixmap()
 980    
 981    @pixmap_bottomleft.setter
 982    def pixmap_bottomleft(self, pixmap):
 983        self._pixmap_bottomleft_original = pixmap
 984        self.set_opacity_bottomleft(100)
 985    
 986    @QtCore.pyqtSlot()
 987    def set_opacity_bottomleft(self, percent):
 988        """Set transparency of bottom-left of sliding overlay.
 989
 990        Args:
 991            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 992        """
 993        if not self.pixmap_bottomleft_exists:
 994            self._opacity_bottomleft = 100
 995            return
 996
 997        self._opacity_bottomleft = percent
 998
 999        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomleft_original.size())
1000        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
1001        painter = QtGui.QPainter(pixmap_to_be_transparent)
1002        painter.setOpacity(percent/100)
1003        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomleft_original)
1004        painter.end()
1005
1006        self._pixmapItem_bottomleft.setPixmap(pixmap_to_be_transparent)
1007    
1008    def moveEvent(self, event):
1009        """Override move event of frame."""
1010        super().moveEvent(event)
1011
1012    def resizeEvent(self, event):
1013        """Override resize event of frame to ensure scene is also resized."""
1014        self.resize_scene()
1015        super().resizeEvent(event) # Equivalent to QtWidgets.QFrame.resizeEvent(self, event)     
1016        
1017    def resize_scene(self):
1018        """Resize the scene to allow image to be panned just before the main pixmap leaves the viewport.
1019
1020        This is needed to expand the scene so that users can pan the pixmap such that its edges are at the center of the view.
1021        This changes the default behavior, which limits the scene to the bounds of the pixmap, thereby blocking users 
1022        from panning outside the bounds of the pixmap, which can feel abrupt and restrictive.
1023        This takes care of preventing users from panning too far away from the pixmap. 
1024        """
1025        scene_to_viewport_factor = self._view_main_topleft.zoomFactor
1026        
1027        width_viewport_window = self.width()
1028        height_viewport_window = self.height()
1029
1030        peek_precent = 0.1 # Percent of pixmap to be left "peeking" at bounds of pan
1031        peek_margin_x = width_viewport_window*peek_precent # [px]
1032        peek_margin_y = height_viewport_window*peek_precent
1033        
1034        width_viewport = (width_viewport_window - peek_margin_x)/scene_to_viewport_factor # This is the size of the viewport on the screen
1035        height_viewport = (height_viewport_window - peek_margin_y)/scene_to_viewport_factor
1036        
1037        width_pixmap = self._pixmapItem_main_topleft.pixmap().width()
1038        height_pixmap = self._pixmapItem_main_topleft.pixmap().height()
1039        
1040        width_scene = 2.0*(width_viewport + width_pixmap/2.0) # The scene spans twice the viewport plus the pixmap
1041        height_scene = 2.0*(height_viewport + height_pixmap/2.0)
1042        
1043        scene_rect = QtCore.QRectF(-width_scene/2.0 + width_pixmap/2.0,-height_scene/2.0 + height_pixmap/2.0,width_scene,height_scene)
1044        self._scene_main_topleft.setSceneRect(scene_rect)
1045    
1046    def set_transform_mode_smooth_on(self):
1047        """Set transform mode to smooth (interpolate) when zoomfactor is >= 1.0."""
1048        self.transform_mode_smooth = True
1049        self._scene_main_topleft.set_single_transform_mode_smooth(True)
1050        self.refresh_transform_mode()
1051            
1052    def set_transform_mode_smooth_off(self):
1053        """Set transform mode to non-smooth (non-interpolated) when zoomfactor is >= 1.0."""
1054        self.transform_mode_smooth = False
1055        self._scene_main_topleft.set_single_transform_mode_smooth(False)
1056        self.refresh_transform_mode()
1057
1058    def set_transform_mode_smooth(self, boolean):
1059        """Set transform mode when zoomfactor is >= 1.0.
1060
1061        Convenience function.
1062        
1063        Args:
1064            boolean (bool): True to smooth (interpolate); False to fast (not interpolate).
1065        """
1066        if boolean:
1067            self.set_transform_mode_smooth_on()
1068        elif not boolean:
1069            self.set_transform_mode_smooth_off()
1070            
1071    @QtCore.pyqtSlot()
1072    def on_transformChanged(self):
1073        """Resize scene if image transform is changed (for example, when zoomed)."""
1074        self.resize_scene()
1075        self.update_split()
1076
1077    @property
1078    def viewName(self):
1079        """str: The name of the SplitView."""
1080        return self._name
1081    
1082    @viewName.setter
1083    def viewName(self, name):
1084        self._name = name
1085
1086    @property
1087    def handDragging(self):
1088        """bool: The hand dragging state."""
1089        return self._view_main_topleft.handDragging
1090
1091    @property
1092    def scrollState(self):
1093        """tuple: The percentage of scene extents
1094        *(sceneWidthPercent, sceneHeightPercent)*"""
1095        return self._view_main_topleft.scrollState
1096
1097    @scrollState.setter
1098    def scrollState(self, state):
1099        self._view_main_topleft.scrollState = state
1100
1101    @property
1102    def zoomFactor(self):
1103        """float: The zoom scale factor."""
1104        return self._view_main_topleft.zoomFactor
1105
1106    @zoomFactor.setter
1107    def zoomFactor(self, newZoomFactor):
1108        """Apply zoom to all views, taking into account the transform mode.
1109        
1110        Args:
1111            newZoomFactor (float)
1112        """
1113        if newZoomFactor < 1.0:
1114            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1115            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1116            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1117            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1118        elif self.transform_mode_smooth:
1119            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1120            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1121            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1122            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1123        else:
1124            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.FastTransformation)
1125            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.FastTransformation)
1126            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.FastTransformation)
1127            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.FastTransformation)
1128
1129        self._view_main_topleft.zoomFactor = newZoomFactor
1130
1131        newZoomFactor = newZoomFactor / self._view_topright.transform().m11()
1132        self._view_topright.scale(newZoomFactor, newZoomFactor)
1133        self._view_bottomright.scale(newZoomFactor, newZoomFactor)
1134        self._view_bottomleft.scale(newZoomFactor, newZoomFactor)
1135
1136    def refresh_transform_mode(self):
1137        """Refresh zoom of all views, taking into account the transform mode."""
1138        self._view_main_topleft.zoomFactor
1139        if self.zoomFactor < 1.0:
1140            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1141            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1142            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1143            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1144        elif self.transform_mode_smooth:
1145            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1146            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1147            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1148            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1149        else:
1150            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.FastTransformation)
1151            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.FastTransformation)
1152            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.FastTransformation)
1153            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.FastTransformation)
1154
1155    @property
1156    def _horizontalScrollBar(self):
1157        """Get the SplitView horizontal scrollbar widget (*QScrollBar*).
1158
1159        (Only used for debugging purposes)"""
1160        return self._view_main_topleft.horizontalScrollBar()
1161
1162    @property
1163    def _verticalScrollBar(self):
1164        """Get the SplitView vertical scrollbar widget (*QScrollBar*).
1165
1166        (Only used for debugging purposes)"""
1167        return self._view_main_topleft.verticalScrollBar()
1168
1169    @property
1170    def _sceneRect(self):
1171        """Get the SplitView sceneRect (*QRectF*).
1172
1173        (Only used for debugging purposes)"""
1174        return self._view_main_topleft.sceneRect()
1175
1176    @QtCore.pyqtSlot()
1177    def scrollToTop(self):
1178        """Scroll to top of image."""
1179        self._view_main_topleft.scrollToTop()
1180
1181    @QtCore.pyqtSlot()
1182    def scrollToBottom(self):
1183        """Scroll to bottom of image."""
1184        self._view_main_topleft.scrollToBottom()
1185
1186    @QtCore.pyqtSlot()
1187    def scrollToBegin(self):
1188        """Scroll to left side of image."""
1189        self._view_main_topleft.scrollToBegin()
1190
1191    @QtCore.pyqtSlot()
1192    def scrollToEnd(self):
1193        """Scroll to right side of image."""
1194        self._view_main_topleft.scrollToEnd()
1195
1196    @QtCore.pyqtSlot()
1197    def centerView(self):
1198        """Center image in view."""
1199        self._view_main_topleft.centerView()
1200
1201    @QtCore.pyqtSlot(bool)
1202    def enableScrollBars(self, enable):
1203        """Set visiblility of the view's scrollbars.
1204
1205        :param bool enable: True to enable the scrollbars """
1206        self._view_main_topleft.enableScrollBars(enable)
1207
1208    @QtCore.pyqtSlot(bool)
1209    def enableHandDrag(self, enable):
1210        """Set whether dragging the view with the hand cursor is allowed.
1211
1212        :param bool enable: True to enable hand dragging """
1213        self._view_main_topleft.enableHandDrag(enable)
1214
1215    @QtCore.pyqtSlot()
1216    def zoomIn(self):
1217        """Zoom in on image."""
1218        self.scaleImage(self._zoomFactorDelta)
1219
1220    @QtCore.pyqtSlot()
1221    def zoomOut(self):
1222        """Zoom out on image."""
1223        self.scaleImage(1 / self._zoomFactorDelta)
1224
1225    @QtCore.pyqtSlot()
1226    def actualSize(self):
1227        """Change zoom to show image at actual size.
1228
1229        (image pixel is equal to screen pixel)"""
1230        self.scaleImage(1.0, combine=False)
1231
1232    @QtCore.pyqtSlot()
1233    def fitToWindow(self):
1234        """Fit image within view.
1235            
1236        If the viewport is wider than the main pixmap, then fit the pixmap to height; if the viewport is narrower, then fit the pixmap to width
1237        """
1238        if not self._pixmapItem_main_topleft.pixmap():
1239            return
1240
1241        padding_margin = 2 # Leaves visual gap between pixmap and border of viewport
1242        viewport_rect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1243                                                         -padding_margin, -padding_margin)
1244        aspect_ratio_viewport = viewport_rect.width()/viewport_rect.height()
1245        aspect_ratio_pixmap   = self._pixmapItem_main_topleft.pixmap().width()/self._pixmapItem_main_topleft.pixmap().height()
1246        if aspect_ratio_viewport > aspect_ratio_pixmap:
1247            self.fitHeight()
1248        else:
1249            self.fitWidth()
1250
1251        self.transformChanged.emit()
1252    
1253    @QtCore.pyqtSlot()
1254    def fitWidth(self):
1255        """Fit image width to view width."""
1256        if not self._pixmapItem_main_topleft.pixmap():
1257            return
1258        padding_margin = 2
1259        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1260                                                         -padding_margin, -padding_margin)
1261        factor = viewRect.width() / self._pixmapItem_main_topleft.pixmap().width()
1262        self.scaleImage(factor, combine=False)
1263        self._view_main_topleft.centerView()
1264    
1265    @QtCore.pyqtSlot()
1266    def fitHeight(self):
1267        """Fit image height to view height."""
1268        if not self._pixmapItem_main_topleft.pixmap():
1269            return
1270        padding_margin = 2
1271        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1272                                                         -padding_margin, -padding_margin)
1273        factor = viewRect.height() / self._pixmapItem_main_topleft.pixmap().height()
1274        self.scaleImage(factor, combine=False)
1275        self._view_main_topleft.centerView()
1276
1277    def handleWheelNotches(self, notches):
1278        """Handle wheel notch event from underlying |QGraphicsView|.
1279
1280        :param float notches: Mouse wheel notches"""
1281        self.scaleImage(self._zoomFactorDelta ** notches)
1282
1283    def closeEvent(self, event):
1284        """Overriden in order to disconnect scrollbar signals before
1285        closing.
1286
1287        :param QEvent event: instance of a |QEvent|
1288        
1289        If this isn't done Python crashes!"""
1290        #self.scrollChanged.disconnect() #doesn't prevent crash
1291        self.disconnectSbarSignals()
1292        
1293        self._scene_main_topleft.deleteLater()
1294        self._view_main_topleft.deleteLater()
1295        del self._pixmap_base_original
1296        
1297        self._scene_topright.deleteLater()
1298        self._view_topright.deleteLater()
1299        del self._pixmap_topright_original
1300        
1301        self._scene_bottomright.deleteLater()
1302        self._view_bottomright.deleteLater()
1303        del self._pixmap_bottomright_original
1304        
1305        self._scene_bottomleft.deleteLater()
1306        self._view_bottomleft.deleteLater()
1307        del self._pixmap_bottomleft_original
1308        
1309        super().closeEvent(event)
1310        gc.collect()
1311        self.became_closed.emit()
1312        
1313    def scaleImage(self, factor, combine=True):
1314        """Scale image by factor.
1315
1316        :param float factor: either new :attr:`zoomFactor` or amount to scale
1317                             current :attr:`zoomFactor`
1318
1319        :param bool combine: if ``True`` scales the current
1320                             :attr:`zoomFactor` by factor.  Otherwise
1321                             just sets :attr:`zoomFactor` to factor"""
1322        if not self._pixmapItem_main_topleft.pixmap():
1323            return
1324
1325        if combine:
1326            self.zoomFactor = self.zoomFactor * factor
1327        else:
1328            self.zoomFactor = factor
1329            
1330        self._view_main_topleft.checkTransformChanged()
1331
1332    def dumpTransform(self):
1333        """Dump view transform to stdout."""
1334        self._view_main_topleft.dumpTransform(self._view_main_topleft.transform(), " "*4)
1335    
1336    
1337    def create_mouse_rect(self):
1338        """Create a red 1x1 outline at the pointer in the main scene.
1339        
1340        Indicates to the user the size and position of the pixel over which the mouse is hovering.
1341        Helps to understand the position of individual pixels and their scale at the current zoom.
1342        """
1343    
1344        pen = QtGui.QPen() 
1345        pen.setWidth(0.1)
1346        pen.setColor(QtCore.Qt.red)
1347        pen.setCapStyle(QtCore.Qt.SquareCap)
1348        pen.setJoinStyle(QtCore.Qt.MiterJoin)
1349            
1350        brush = QtGui.QBrush()
1351        brush.setColor(QtCore.Qt.transparent)
1352        
1353        self.mouse_rect_width = 1
1354        self.mouse_rect_height = 1
1355        
1356        self.mouse_rect_topleft = QtCore.QPointF(0,0)
1357        self.mouse_rect_bottomright = QtCore.QPointF(self.mouse_rect_width-0.01, self.mouse_rect_height-0.01)
1358        self.mouse_rect = QtCore.QRectF(self.mouse_rect_topleft, self.mouse_rect_bottomright)
1359        
1360        self.mouse_rect_scene_main_topleft = QtWidgets.QGraphicsRectItem(self.mouse_rect) # To add the same item in two scenes, you need to create two unique items
1361        
1362        self.mouse_rect_scene_main_topleft.setPos(0,0)
1363        
1364        self.mouse_rect_scene_main_topleft.setBrush(brush)
1365        self.mouse_rect_scene_main_topleft.setPen(pen)
1366        
1367        self._scene_main_topleft.addItem(self.mouse_rect_scene_main_topleft)
1368
1369    def set_mouse_rect_visible(self, boolean):
1370        """Set the visibilty of the red 1x1 outline at the pointer in the main scene.
1371        
1372        Args:
1373            boolean (bool): True to show 1x1 outline; False to hide.
1374        """
1375        self.mouse_rect_scene_main_topleft.setVisible(boolean)
class SplitView(PyQt5.QtWidgets.QFrame):
  44class SplitView(QtWidgets.QFrame):
  45    """Image viewing widget for individual images and sliding overlays.
  46
  47    Creates an interface with a base image as a main image located at the top left  
  48    and optionally 3 other images (top-left, bottom-left, bottom-right) as a sliding overlay.
  49    Supports zoom and pan.
  50    Enables synchronized zoom and pan via signals.
  51    Input images for a given sliding overlay must have identical resolutions to 
  52    function properly.
  53    
  54    Args:
  55        pixmap (QPixmap): The main image to be viewed; the basis of the sliding overlay (main; topleft)
  56        filename_main_topleft (str): The image filepath of the main image.
  57        name (str): The name of the viewing widget.
  58        pixmap_topright (QPixmap): The top-right image of the sliding overlay (set None to exclude).
  59        pixmap_bottomleft (QPixmap): The bottom-left image of the sliding overlay (set None to exclude).
  60        pixmap_bottomright (QPixmap): The bottom-right image of the sliding overlay (set None to exclude).
  61    """
  62
  63    def __init__(self, pixmap_main_topleft=None, filename_main_topleft=None, name=None, 
  64            pixmap_topright=None, pixmap_bottomleft=None, pixmap_bottomright=None, transform_mode_smooth=False):
  65        super().__init__()
  66
  67        self.currentFile = filename_main_topleft
  68
  69        self.setWindowFlags(QtCore.Qt.FramelessWindowHint) # Clean appearance
  70        self.setFrameStyle(QtWidgets.QFrame.NoFrame)
  71
  72        self.viewName = name
  73        self.comment_position_object = None
  74
  75        pixmap_main_topleft = self.pixmap_none_ify(pixmap_main_topleft) # Return None if given QPixmap has zero width/height or is not a QPixmap
  76        pixmap_topright     = self.pixmap_none_ify(pixmap_topright)
  77        pixmap_bottomleft   = self.pixmap_none_ify(pixmap_bottomleft)
  78        pixmap_bottomright  = self.pixmap_none_ify(pixmap_bottomright)
  79
  80        self.pixmap_topright_exists     = (pixmap_topright is not None) # Boolean for existence of pixmap is easier to check
  81        self.pixmap_bottomright_exists  = (pixmap_bottomright is not None)
  82        self.pixmap_bottomleft_exists   = (pixmap_bottomleft is not None)
  83
  84        # Pixmaps which do no exist should be transparent, so they are made into an empty pixmap
  85        if not self.pixmap_bottomright_exists:
  86            pixmap_bottomright = QtGui.QPixmap()
  87
  88        if not self.pixmap_topright_exists:
  89            pixmap_topright = QtGui.QPixmap()
  90            
  91        if not self.pixmap_bottomleft_exists:
  92            pixmap_bottomleft = QtGui.QPixmap()
  93
  94        self._zoomFactorDelta = 1.25 # How much zoom for each zoom call
  95
  96        self.transform_mode_smooth = transform_mode_smooth
  97
  98        # Sliding overlay is based on the main pixmap view of the top-left pixmap
  99        # self._scene_main_topleft = QtWidgets.QGraphicsScene()
 100        self._scene_main_topleft = CustomQGraphicsScene()
 101        self._view_main_topleft = SynchableGraphicsView(self._scene_main_topleft)
 102
 103        self._view_main_topleft.setInteractive(True) # Functional settings
 104        self._view_main_topleft.setViewportUpdateMode(QtWidgets.QGraphicsView.MinimalViewportUpdate)
 105        self._view_main_topleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 106        self._view_main_topleft.setRenderHints(QtGui.QPainter.SmoothPixmapTransform | QtGui.QPainter.Antialiasing)
 107
 108        self._scene_main_topleft.changed.connect(self.sceneChanged) # Pass along underlying signals
 109        self._view_main_topleft.transformChanged.connect(self.transformChanged)
 110        self._view_main_topleft.transformChanged.connect(self.on_transformChanged)
 111        self._view_main_topleft.scrollChanged.connect(self.scrollChanged)
 112        self._view_main_topleft.wheelNotches.connect(self.handleWheelNotches)
 113        self._scene_main_topleft.right_click_comment.connect(self.on_right_click_comment)
 114        self._scene_main_topleft.right_click_ruler.connect(self.on_right_click_ruler)
 115        self._scene_main_topleft.right_click_save_all_comments.connect(self.on_right_click_save_all_comments)
 116        self._scene_main_topleft.right_click_load_comments.connect(self.on_right_click_load_comments)
 117        self._scene_main_topleft.right_click_relative_origin_position.connect(self.on_right_click_set_relative_origin_position)
 118        self._scene_main_topleft.changed_px_per_unit.connect(self.on_changed_px_per_unit)
 119        self._scene_main_topleft.right_click_single_transform_mode_smooth.connect(self.set_transform_mode_smooth)
 120        self._scene_main_topleft.right_click_all_transform_mode_smooth.connect(self.was_set_global_transform_mode)
 121        self._scene_main_topleft.right_click_background_color.connect(self.set_scene_background_color)
 122        self._scene_main_topleft.right_click_background_color.connect(self.was_set_scene_background_color)
 123
 124        self._pixmapItem_main_topleft = QtWidgets.QGraphicsPixmapItem()
 125        self._scene_main_topleft.addItem(self._pixmapItem_main_topleft)
 126        
 127        # A pseudo view directly atop the main view is needed to drive the position of the split and layout of the four pixmaps 
 128        self._view_layoutdriving_topleft = QtWidgets.QGraphicsView()
 129        self._view_layoutdriving_topleft.setStyleSheet("border: 0px; border-style: solid; background-color: rgba(0,0,0,0)")
 130        self._view_layoutdriving_topleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 131        self._view_layoutdriving_topleft.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 132        self._view_layoutdriving_topleft.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 133        self._view_layoutdriving_topleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
 134        
 135        # Add top right pixmap view
 136        self._pixmapItem_topright = QtWidgets.QGraphicsPixmapItem()
 137        self.pixmap_topright = pixmap_topright
 138        
 139        self._scene_topright = QtWidgets.QGraphicsScene()
 140        self._scene_topright.addItem(self._pixmapItem_topright)
 141        
 142        self._view_topright = QtWidgets.QGraphicsView(self._scene_topright)
 143        self._view_topright.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 144        self._view_topright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 145        self._view_topright.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 146        self._view_topright.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 147        self._view_topright.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 148        
 149        # Add bottom left pixmap view
 150        self._pixmapItem_bottomleft = QtWidgets.QGraphicsPixmapItem()
 151        self.pixmap_bottomleft = pixmap_bottomleft
 152        
 153        self._scene_bottomleft = QtWidgets.QGraphicsScene()
 154        self._scene_bottomleft.addItem(self._pixmapItem_bottomleft)
 155        
 156        self._view_bottomleft = QtWidgets.QGraphicsView(self._scene_bottomleft)
 157        self._view_bottomleft.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 158        self._view_bottomleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 159        self._view_bottomleft.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 160        self._view_bottomleft.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 161        self._view_bottomleft.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 162        
 163        # Add bottom right pixmap view
 164        self._pixmapItem_bottomright = QtWidgets.QGraphicsPixmapItem()
 165        self.pixmap_bottomright = pixmap_bottomright
 166        
 167        self._scene_bottomright = QtWidgets.QGraphicsScene()
 168        self._scene_bottomright.addItem(self._pixmapItem_bottomright)
 169        
 170        self._view_bottomright = QtWidgets.QGraphicsView(self._scene_bottomright)
 171        self._view_bottomright.setStyleSheet("border: 0px; border-style: solid; border-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);")
 172        self._view_bottomright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 173        self._view_bottomright.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 174        self._view_bottomright.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
 175        self._view_bottomright.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)
 176
 177         # Make the sizes of the four views entirely dictated by the "layout driving" view
 178        size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored)
 179        self._view_main_topleft.setSizePolicy(size_policy)
 180        self._view_topright.setSizePolicy(size_policy)
 181        self._view_bottomright.setSizePolicy(size_policy)
 182        self._view_bottomleft.setSizePolicy(size_policy)
 183
 184        # By default the split is set to half the widget's size so all pixmap views are equally sized at the start
 185        self._view_layoutdriving_topleft.setMaximumWidth(self.width()/2.0)
 186        self._view_layoutdriving_topleft.setMaximumHeight(self.height()/2.0)
 187        
 188        if pixmap_main_topleft: # Instantiate transform and resizing
 189            self.pixmap_main_topleft = pixmap_main_topleft
 190        
 191        # SplitView layout
 192        self._layout = QtWidgets.QGridLayout()
 193        self._layout.setContentsMargins(0, 0, 0, 0)
 194        self._layout.setSpacing(0)
 195        
 196        self.setContentsMargins(0, 0, 0, 0)
 197
 198        # Labels for the four pixmap views
 199        self.label_main_topleft = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 200        self.label_topright     = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 201        self.label_bottomright  = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 202        self.label_bottomleft   = FilenameLabel(visibility_based_on_text=True, belongs_to_split=True)
 203
 204        self.label_main_topleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 205        self.label_topright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 206        self.label_bottomright.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 207        self.label_bottomleft.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 208
 209        # Pushbutton to close the image window
 210        self.close_pushbutton = QtWidgets.QPushButton("×")
 211        self.close_pushbutton.setToolTip("Close image window")
 212        self.close_pushbutton.clicked.connect(self.was_clicked_close_pushbutton)
 213        self.close_pushbutton_always_visible = True
 214
 215        # Create deadzones along the bounds of SplitView to fix the issue of resize handles showing in QMdiArea despite windowless setting.
 216        # An event tracker "bypass" is needed for each deadzone because they hide the mouse from the sliding overlay, so the mouse must be separately tracked to ensure the split is updated.
 217        px_deadzone = 8
 218
 219        self.resize_deadzone_top = QtWidgets.QPushButton("")
 220        self.resize_deadzone_top.setFixedHeight(px_deadzone)
 221        self.resize_deadzone_top.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
 222        self.resize_deadzone_top.setEnabled(False)
 223        self.resize_deadzone_top.setStyleSheet("""
 224            QPushButton {
 225                color: transparent;
 226                background-color: transparent; 
 227                border: 0px black;
 228            }
 229            """)
 230        tracker_deadzone_top = EventTrackerSplitBypassDeadzone(self.resize_deadzone_top)
 231        tracker_deadzone_top.mouse_position_changed_global.connect(self.update_split_given_global)
 232
 233        self.resize_deadzone_bottom = QtWidgets.QPushButton("")
 234        self.resize_deadzone_bottom.setFixedHeight(px_deadzone)
 235        self.resize_deadzone_bottom.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
 236        self.resize_deadzone_bottom.setEnabled(False)
 237        self.resize_deadzone_bottom.setStyleSheet("""
 238            QPushButton {
 239                color: transparent;
 240                background-color: transparent; 
 241                border: 0px black;
 242            }
 243            """)
 244        tracker_deadzone_bottom = EventTrackerSplitBypassDeadzone(self.resize_deadzone_bottom)
 245        tracker_deadzone_bottom.mouse_position_changed_global.connect(self.update_split_given_global)
 246
 247        self.resize_deadzone_left = QtWidgets.QPushButton("")
 248        self.resize_deadzone_left.setFixedWidth(px_deadzone)
 249        self.resize_deadzone_left.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)
 250        self.resize_deadzone_left.setEnabled(False)
 251        self.resize_deadzone_left.setStyleSheet("""
 252            QPushButton {
 253                color: transparent;
 254                background-color: transparent; 
 255                border: 0px black;
 256            }
 257            """)
 258        tracker_deadzone_left = EventTrackerSplitBypassDeadzone(self.resize_deadzone_left)
 259        tracker_deadzone_left.mouse_position_changed_global.connect(self.update_split_given_global)
 260
 261        self.resize_deadzone_right = QtWidgets.QPushButton("")
 262        self.resize_deadzone_right.setFixedWidth(px_deadzone)
 263        self.resize_deadzone_right.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)
 264        self.resize_deadzone_right.setEnabled(False)
 265        self.resize_deadzone_right.setStyleSheet("""
 266            QPushButton {
 267                color: transparent;
 268                background-color: transparent; 
 269                border: 0px black;
 270            }
 271            """)
 272        tracker_deadzone_right = EventTrackerSplitBypassDeadzone(self.resize_deadzone_right)
 273        tracker_deadzone_right.mouse_position_changed_global.connect(self.update_split_given_global)
 274
 275        # A frame is placed over the border of the widget to highlight it as the active subwindow in Butterfly Viewer.
 276        self.frame_hud = QtWidgets.QFrame()
 277        self.frame_hud.setStyleSheet("border: 0px solid transparent")
 278        self.frame_hud.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
 279
 280        # Set layout
 281        self._layout.addWidget(self._view_main_topleft, 0, 0, 2, 2)
 282        self._layout.addWidget(self._view_layoutdriving_topleft, 0, 0)
 283        self._layout.addWidget(self._view_topright, 0, 1)
 284        self._layout.addWidget(self._view_bottomleft, 1, 0)
 285        self._layout.addWidget(self._view_bottomright, 1, 1)
 286
 287        self._layout.addWidget(self.resize_deadzone_top, 0, 0, 2, 2, QtCore.Qt.AlignTop)
 288        self._layout.addWidget(self.resize_deadzone_bottom, 0, 0, 2, 2, QtCore.Qt.AlignBottom)
 289        self._layout.addWidget(self.resize_deadzone_left, 0, 0, 2, 2, QtCore.Qt.AlignLeft)
 290        self._layout.addWidget(self.resize_deadzone_right, 0, 0, 2, 2, QtCore.Qt.AlignRight)
 291
 292        self._layout.addWidget(self.frame_hud, 0, 0, 2, 2)
 293
 294        self._layout.addWidget(self.label_main_topleft, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
 295        self._layout.addWidget(self.label_topright, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)
 296        self._layout.addWidget(self.label_bottomright, 0, 0, 2, 2, QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)
 297        self._layout.addWidget(self.label_bottomleft, 0, 0, 2, 2, QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft)
 298
 299        self._layout.addWidget(self.close_pushbutton, 0, 0, 2, 2, QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)
 300
 301        self.setLayout(self._layout)
 302
 303        self.set_scene_background_color(self._scene_main_topleft.background_color)
 304        self._view_main_topleft.setStyleSheet("border: 0px; border-style: solid; border-color: red; background-color: rgba(0,0,0,0)")
 305
 306        # Track the mouse position to know where to set the split
 307        self.tracker = EventTracker(self)
 308        self.tracker.mouse_position_changed.connect(self.positionChanged)
 309        self.tracker.mouse_position_changed.connect(self.on_positionChanged)
 310        
 311        # Create a rectangular box the size of one pixel in the main scene to show the user the size and position of the pixel over which their mouse is hovering 
 312        self.mouse_rect_scene_main_topleft = None
 313        self.create_mouse_rect()
 314
 315        # Allow users to lock the split and remember where the split was last set
 316        self.split_locked = False
 317        self.last_updated_point_of_split_on_scene_main = QtCore.QPoint()
 318
 319        self.enableScrollBars(False) # Clean look with no scrollbars
 320
 321    @property
 322    def currentFile(self):
 323        """str: Filepath of base image (filename_main_topleft)."""
 324        return self._currentFile
 325
 326    @currentFile.setter
 327    def currentFile(self, filename_main_topleft):
 328        self._currentFile = QtCore.QFileInfo(filename_main_topleft).canonicalFilePath()
 329        self._isUntitled = False
 330        self.setWindowTitle(self.userFriendlyCurrentFile)
 331
 332    @property
 333    def userFriendlyCurrentFile(self):
 334        """str: Filename of base image."""
 335        if self.currentFile:
 336            return strippedName(self.currentFile)
 337        else:
 338            return ""
 339    
 340    def set_close_pushbutton_always_visible(self, boolean):
 341        """Enable/disable the always-on visiblilty of the close X of the view.
 342        
 343        Arg:
 344            boolean (bool): True to show the close X always; False to hide unless mouse hovers over.
 345        """
 346        self.close_pushbutton_always_visible = boolean
 347        self.refresh_close_pushbutton_stylesheet()
 348            
 349    def refresh_close_pushbutton_stylesheet(self):
 350        """Refresh stylesheet of close pushbutton based on background color and visibility."""
 351        if not self.close_pushbutton:
 352            return
 353        always_visible = self.close_pushbutton_always_visible
 354        background_rgb =  self._scene_main_topleft.background_rgb
 355        avg_background_rgb = sum(background_rgb)/len(background_rgb)
 356        if not always_visible: # Hide unless hovered
 357            self.close_pushbutton.setStyleSheet("""
 358                QPushButton {
 359                    width: 1.8em;
 360                    height: 1.8em;
 361                    color: transparent;
 362                    background-color: rgba(223, 0, 0, 0); 
 363                    font-weight: bold;
 364                    border-width: 0px;
 365                    border-style: solid;
 366                    border-color: transparent;
 367                    font-size: 10pt;
 368                }
 369                QPushButton:hover {
 370                    color: white;
 371                    background-color: rgba(223, 0, 0, 223);
 372                }
 373                QPushButton:pressed {
 374                    color: white;
 375                    background-color: rgba(255, 0, 0, 255);
 376                }
 377                """)
 378        else: # Always visible
 379            if avg_background_rgb >= 223: # Unhovered is black X on light background
 380                self.close_pushbutton.setStyleSheet("""
 381                    QPushButton {
 382                        width: 1.8em;
 383                        height: 1.8em;
 384                        color: black;
 385                        background-color: rgba(223, 0, 0, 0); 
 386                        font-weight: bold;
 387                        border-width: 0px;
 388                        border-style: solid;
 389                        border-color: transparent;
 390                        font-size: 10pt;
 391                    }
 392                    QPushButton:hover {
 393                        color: white;
 394                        background-color: rgba(223, 0, 0, 223);
 395                    }
 396                    QPushButton:pressed {
 397                        color: white;
 398                        background-color: rgba(255, 0, 0, 255);
 399                    }
 400                    """)
 401            else: # Unhovered is white X on dark background
 402                self.close_pushbutton.setStyleSheet("""
 403                    QPushButton {
 404                        width: 1.8em;
 405                        height: 1.8em;
 406                        color: white;
 407                        background-color: rgba(223, 0, 0, 0); 
 408                        font-weight: bold;
 409                        border-width: 0px;
 410                        border-style: solid;
 411                        border-color: transparent;
 412                        font-size: 10pt;
 413                    }
 414                    QPushButton:hover {
 415                        color: white;
 416                        background-color: rgba(223, 0, 0, 223);
 417                    }
 418                    QPushButton:pressed {
 419                        color: white;
 420                        background-color: rgba(255, 0, 0, 255);
 421                    }
 422                    """)
 423            
 424        
 425    def set_scene_background(self, brush):
 426        """Set scene background color with QBrush.
 427        
 428        Args:
 429            brush (QBrush)
 430        """
 431        if not self._scene_main_topleft:
 432            return
 433        self._scene_main_topleft.setBackgroundBrush(brush)
 434
 435    def set_scene_background_color(self, color: list):
 436        """Set scene background color with color list.
 437
 438        The init for CustomQGraphicsScene contains the ground truth for selectable background colors.
 439        
 440        Args:
 441            color (list): Descriptor string and RGB int values. Example: ["White", 255, 255, 255].
 442        """
 443        if not self._scene_main_topleft:
 444            return
 445        rgb = color[1:4]
 446        rgb_clamp = [max(min(channel, 255), 0) for channel in rgb]
 447        brush = QtGui.QColor(rgb_clamp[0], rgb_clamp[1], rgb_clamp[2])
 448        self.set_scene_background(brush)
 449        self._scene_main_topleft.background_color = color
 450        self.refresh_close_pushbutton_stylesheet()
 451        
 452    
 453    def pixmap_none_ify(self, pixmap):
 454        """Return None if pixmap has no pixels.
 455        
 456        Args:
 457            pixmap (QPixmap)
 458
 459        Returns:
 460            None if pixmap has no pixels; same pixmap if it has pixels
 461        """
 462        if pixmap:
 463            if pixmap.width()==0 or pixmap.height==0:
 464                return None
 465            else:
 466                return pixmap
 467        else:
 468            return None
 469    
 470    @QtCore.pyqtSlot(QtCore.QPoint)
 471    def on_positionChanged(self, pos):
 472        """Update the position of the split and the 1x1 pixel rectangle.
 473
 474        Triggered when mouse is moved.
 475        
 476        Args:
 477            pos (QPoint): The position of the mouse relative to widget.
 478        """
 479        point_of_mouse_on_widget = pos
 480        
 481        self.update_split(point_of_mouse_on_widget)
 482        self.update_mouse_rect(point_of_mouse_on_widget)
 483
 484    def set_split(self, x_percent=0.5, y_percent=0.5, ignore_lock=False, percent_of_visible=False):
 485        """Set the position of the split with x and y as proportion of base image's resolution.
 486
 487        Sets split position using a proportion of x and y (by default of entire main pixmap; can be set to proportion of visible pixmap).
 488        Top left is x=0, y=0; bottom right is x=1, y=1.
 489        This is needed to position the split without mouse movement from user (for example, to preview the effect of the transparency sliders in Butterfly Viewer)
 490        
 491        Args:
 492            x_percent (float): The position of the split as a proportion (0-1) of the base image's horizontal resolution.
 493            y_percent (float): The position of the split as a proportion (0-1) of the base image's vertical resolution.
 494            ignore_lock (bool): True to ignore the lock status of the split; False to adhere.
 495            percent_of_visible (bool): True to set split as proportion of visible area; False as proportion of the full image resolution.
 496        """
 497        if percent_of_visible:
 498            x = x_percent*self.width()
 499            y = y_percent*self.height()
 500            point_of_split_on_widget = QtCore.QPoint(x, y)
 501        else:
 502            width_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().width()
 503            height_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().height()
 504
 505            x = x_percent*width_pixmap_main_topleft
 506            y = y_percent*height_pixmap_main_topleft
 507
 508            point_of_split_on_scene = QtCore.QPointF(x,y)
 509
 510            point_of_split_on_widget = self._view_main_topleft.mapFromScene(point_of_split_on_scene)
 511
 512        self.update_split(point_of_split_on_widget, ignore_lock=ignore_lock)
 513        
 514    def update_split(self, pos = None, pos_is_global=False, ignore_lock=False): 
 515        """Update the position of the split with mouse position.
 516        
 517        Args:
 518            pos (QPoint): Position of the mouse.
 519            pos_is_global (bool): True if given mouse position is relative to MdiChild; False if global position.
 520            ignore_lock (bool): True to ignore (bypass) the status of the split lock.
 521        """
 522        if pos is None: # Get position of the split from the mouse's global coordinates (can be slow!)
 523            point_of_cursor_global      = QtGui.QCursor.pos()
 524            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(point_of_cursor_global)
 525        else:
 526            if pos_is_global:
 527                point_of_mouse_on_widget = self._view_main_topleft.mapFromGlobal(pos)
 528            else:
 529                point_of_mouse_on_widget = pos
 530
 531        point_of_mouse_on_widget.setX(point_of_mouse_on_widget.x()+1) # Offset +1 needed to have mouse cursor be hovering over the main scene (e.g., to allow manipulation of graphics item)
 532        point_of_mouse_on_widget.setY(point_of_mouse_on_widget.y()+1)
 533
 534        self.last_updated_point_of_split_on_scene_main = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(), point_of_mouse_on_widget.y())
 535        
 536        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
 537        
 538        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
 539
 540        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
 541        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
 542        
 543        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
 544        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
 545        
 546        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
 547    
 548        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
 549        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
 550        
 551        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
 552        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 553        
 554        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
 555        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 556        
 557        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
 558        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
 559        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
 560        
 561        self._view_topright.setSceneRect(rect_of_scene_topright)
 562        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
 563        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
 564        
 565        self._view_topright.centerOn(top_left_of_scene_topright)
 566        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
 567        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)
 568
 569    def refresh_split_based_on_last_updated_point_of_split_on_scene_main(self): 
 570        """Refresh the position of the split using the previously recorded split location.
 571        
 572        This is needed to maintain the position of the split during synchronized zooming and panning.
 573        """ 
 574        point_of_mouse_on_widget = self._view_main_topleft.mapFromScene(self.last_updated_point_of_split_on_scene_main)
 575        
 576        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
 577        
 578        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
 579        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
 580        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
 581
 582        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
 583        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
 584        
 585        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
 586    
 587        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
 588        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
 589        
 590        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
 591        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 592        
 593        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
 594        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
 595        
 596        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
 597        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
 598        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
 599        
 600        self._view_topright.setSceneRect(rect_of_scene_topright)
 601        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
 602        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
 603        
 604        self._view_topright.centerOn(top_left_of_scene_topright)
 605        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
 606        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)
 607    
 608    def update_split_given_global(self, pos_global):
 609        """Update the position of the split based on given global mouse position.
 610
 611        Convenience function.
 612
 613        Args:
 614            pos_global (QPoint): The position of the mouse in global coordinates.
 615        """
 616        self.update_split(pos = pos_global, pos_is_global=True)
 617
 618
 619    def on_right_click_comment(self, scene_pos):
 620        """Create an editable and movable comment on the scene of the main view at the given scene position.
 621
 622        Args:
 623            pos (QPointF): position of the comment datum on the scene.
 624        """
 625        pos_on_scene = scene_pos
 626        self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=pos_on_scene, set_cursor_on_creation=True))
 627
 628    def on_right_click_ruler(self, scene_pos, relative_origin_position, unit="px", px_per_unit=1.0, update_px_per_unit_on_existing=False):
 629        """Create a movable ruler on the scene of the main view at the given scene position.
 630
 631        Args:
 632            scene_pos (QPointF): The position of the ruler center on the scene.
 633            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
 634            unit (str): The text for labeling units of ruler values.
 635            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
 636            update_px_per_unit_on_existing (bool): False always. (Legacy from past versions; future work should remove.
 637        """
 638        placement_factor = 1/3
 639        px_per_unit = px_per_unit
 640        relative_origin_position = relative_origin_position
 641
 642        widget_width = self.width()
 643        widget_height = self.height()
 644
 645        pos_p1 = self._view_main_topleft.mapToScene(widget_width*placement_factor, widget_height*placement_factor)
 646        pos_p2 = self._view_main_topleft.mapToScene(widget_width*(1-placement_factor), widget_height*(1-placement_factor))
 647        self._scene_main_topleft.addItem(RulerItem(unit=unit, px_per_unit=px_per_unit, initial_pos_p1=pos_p1, initial_pos_p2=pos_p2, relative_origin_position=relative_origin_position))
 648
 649    def on_changed_px_per_unit(self, unit, px_per_unit):
 650        """Update the units and pixel-per-unit conversions of all rulers in main scene.
 651
 652        Args:
 653            unit (str): The text for labeling units of ruler values.
 654            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
 655        """
 656        for item in self._scene_main_topleft.items():
 657            if isinstance(item, RulerItem):
 658                if item.unit == unit:
 659                    item.set_and_refresh_px_per_unit(px_per_unit)
 660
 661    def on_right_click_save_all_comments(self):
 662        """Open a dialog window for user to save all existing comments on the main scene to .csv.
 663        
 664        Triggered from right-click menu on view.
 665        """
 666        self.display_loading_grayout(True, "Selecting folder and name for saving all comments in current view...", pseudo_load_time=0)
 667
 668        style = []
 669        x = []
 670        y = []
 671        color = []
 672        string = []
 673        i = -1
 674        for item in self._scene_main_topleft.items():
 675            
 676            if isinstance(item, CommentItem):
 677                i += 1
 678                
 679                comment_pos = item.get_scene_pos()
 680                comment_color = item.get_color()
 681                comment_string = item.get_comment_text_str()
 682
 683                style.append("plain text")
 684                x.append(comment_pos.x())
 685                y.append(comment_pos.y())
 686                color.append(comment_color)
 687                string.append(comment_string)
 688
 689
 690        folderpath = None
 691        filepath_mainview = self.currentFile
 692
 693        if filepath_mainview:
 694            folderpath = filepath_mainview
 695            folderpath = os.path.dirname(folderpath)
 696            folderpath = folderpath + "\\"
 697        else:
 698            self.display_loading_grayout(False, pseudo_load_time=0)
 699            return
 700        
 701        header = [["Butterfly Viewer"],
 702                  ["1.0"],
 703                  ["comments"],
 704                  ["no details"],
 705                  ["origin"],
 706                  [os.path.basename(filepath_mainview)],
 707                  ["Style", "Image x", "Image y", "Appearance", "String"]]
 708
 709        date_and_time = datetime.now().strftime('%Y-%m-%d %H%M%S') # Sets the default filename with date and time 
 710        filename = "Untitled comments" + " - " + os.path.basename(filepath_mainview).split('.')[0] + " - " + date_and_time + ".csv"
 711        name_filters = "CSV (*.csv)" # Allows users to select filetype of screenshot
 712        
 713        filepath, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save all comments of current view to .csv", folderpath+filename, name_filters)
 714
 715        if filepath:
 716            self.display_loading_grayout(True, "Saving all comments of current view to .csv...")
 717
 718            with open(filepath, "w", newline='') as csv_file:
 719                csv_writer = csv.writer(csv_file, delimiter="|")
 720                for row in header:
 721                    csv_writer.writerow(row)
 722                for row in zip(style, x, y, color, string):
 723                    csv_writer.writerow(row)
 724
 725        self.display_loading_grayout(False)
 726
 727
 728    def on_right_click_load_comments(self):
 729        """Open a dialog window for user to load comments to the main scene via .csv as saved previously.
 730        
 731        Triggered from right-click menu on view.
 732        """
 733        self.display_loading_grayout(True, "Selecting comment file (.csv) to load into current view...", pseudo_load_time=0)
 734
 735        folderpath = None
 736        filepath_mainview = self.currentFile
 737
 738        if filepath_mainview:
 739            folderpath = filepath_mainview
 740            folderpath = os.path.dirname(folderpath)
 741            # folderpath = folderpath + "\\"
 742        else:
 743            self.display_loading_grayout(False, pseudo_load_time=0)
 744            return
 745
 746        filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select comment file (.csv) to load into current view", folderpath, "Comma-Separated Value File (*.csv)")
 747
 748        if filename:
 749            self.display_loading_grayout(True, "Loading comments from selected .csv into current view...")
 750
 751            with open(filename, "r", newline='') as csv_file:
 752                csv_reader = csv.reader(csv_file, delimiter="|")
 753                csv_list = list(csv_reader)
 754
 755                i = None
 756
 757                try:
 758                    i = csv_list.index(["Style", "Image x", "Image y", "Appearance", "String"])
 759                except ValueError:
 760                    box_type = QtWidgets.QMessageBox.Warning
 761                    title = "Invalid .csv comment file"
 762                    text = "The selected .csv comment file does not have a format accepted by this app."
 763                    box_buttons = QtWidgets.QMessageBox.Close
 764                    box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
 765                    box.exec_()
 766                else:
 767                    i += 1 # Move to first comment item
 768                    no_comments = True
 769                    for row in csv_list[i:]:
 770                        if row[0] == "plain text":
 771                            no_comments = False
 772                            comment_x = float(row[1])
 773                            comment_y = float(row[2])
 774                            comment_color = row[3]
 775                            comment_string = row[4]
 776                            comment_pos = QtCore.QPointF(comment_x, comment_y)
 777                            self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=comment_pos, color=comment_color, comment_text=comment_string, set_cursor_on_creation=False))
 778                    if no_comments:
 779                        box_type = QtWidgets.QMessageBox.Warning
 780                        title = "No comments in .csv"
 781                        text = "No comments found in the selected .csv comment file."
 782                        box_buttons = QtWidgets.QMessageBox.Close
 783                        box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
 784                        box.exec_()
 785                    
 786        self.display_loading_grayout(False)
 787
 788    def on_right_click_set_relative_origin_position(self, string):
 789        """Set orientation of the coordinate system for rulers by positioning the relative origin.
 790
 791        Allows users to switch the coordinate orientation:
 792            "bottomleft" for Cartesian-style (positive X right, positive Y up)
 793            topleft" for image-style (positive X right, positive Y down)
 794
 795        Args:
 796            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
 797        """
 798        for item in self._scene_main_topleft.items():
 799            if isinstance(item, RulerItem):
 800                item.set_and_refresh_relative_origin_position(string)
 801
 802    def display_loading_grayout(self, boolean, text="Loading...", pseudo_load_time=0.2):
 803        """Emit signal for showing/hiding a grayout screen to indicate loading sequences.
 804
 805        Args:
 806            boolean (bool): True to show grayout; False to hide.
 807            text (str): The text to show on the grayout.
 808            pseudo_load_time (float): The delay (in seconds) to hide the grayout to give user feeling of action.
 809        """ 
 810        self.signal_display_loading_grayout.emit(boolean, text, pseudo_load_time)
 811        
 812    def update_mouse_rect(self, pos = None):
 813        """Update the position of red 1x1 outline at the pointer in the main scene.
 814
 815        Args:
 816            pos (QPoint): The position of the mouse on the widget. Set to None to make the function determine the position using the mouse global coordinates.
 817        """
 818        if not self.mouse_rect_scene_main_topleft:
 819            return
 820    
 821        if pos is None: # Get position of split on main scene by directly pulling mouse global coordinates
 822            point_of_cursor_global      = QtGui.QCursor.pos()
 823            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(QtGui.QCursor.pos())
 824        else:
 825            point_of_mouse_on_widget = pos
 826    
 827        mouse_rect_pos_origin = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(),point_of_mouse_on_widget.y())
 828        mouse_rect_pos_origin.setX(math.floor(mouse_rect_pos_origin.x() - self.mouse_rect_width + 1))
 829        mouse_rect_pos_origin.setY(math.floor(mouse_rect_pos_origin.y() - self.mouse_rect_height + 1))
 830        
 831        self.mouse_rect_scene_main_topleft.setPos(mouse_rect_pos_origin.x(), mouse_rect_pos_origin.y())
 832    
 833    # Signals
 834    signal_display_loading_grayout = QtCore.pyqtSignal(bool, str, float)
 835    """Emitted when comments are being saved or loaded."""
 836
 837    became_closed = QtCore.pyqtSignal()
 838    """Emitted when SplitView is closed."""
 839
 840    was_clicked_close_pushbutton = QtCore.pyqtSignal()
 841    """Emitted when close pushbutton is clicked (pressed+released)."""
 842
 843    was_set_global_transform_mode = QtCore.pyqtSignal(bool)
 844    """Emitted when transform mode is set for all views in right-click menu (passes it along)."""
 845
 846    was_set_scene_background_color = QtCore.pyqtSignal(list)
 847    """Emitted when background color is set in right-click menu (passes it along)."""
 848
 849    positionChanged = QtCore.pyqtSignal(QtCore.QPoint)
 850    """Emitted when mouse changes position."""
 851
 852    sceneChanged = QtCore.pyqtSignal('QList<QRectF>')
 853    """Scene Changed **Signal**.
 854
 855    Emitted whenever the |QGraphicsScene| content changes."""
 856    
 857    transformChanged = QtCore.pyqtSignal()
 858    """Transformed Changed **Signal**.
 859
 860    Emitted whenever the |QGraphicsView| Transform matrix has been changed."""
 861
 862    scrollChanged = QtCore.pyqtSignal()
 863    """Scroll Changed **Signal**.
 864
 865    Emitted whenever the scrollbar position or range has changed."""
 866
 867    def connectSbarSignals(self, slot):
 868        """Connect to scrollbar changed signals.
 869
 870        :param slot: slot to connect scrollbar signals to."""
 871        self._view_main_topleft.connectSbarSignals(slot)
 872
 873    def disconnectSbarSignals(self):
 874        self._view_main_topleft.disconnectSbarSignals()
 875
 876    @property
 877    def pixmap_main_topleft(self):
 878        """The currently viewed |QPixmap| (*QPixmap*)."""
 879        return self._pixmapItem_main_topleft.pixmap()
 880
 881    @pixmap_main_topleft.setter
 882    def pixmap_main_topleft(self, pixmap_main_topleft):
 883        if pixmap_main_topleft is not None:
 884            self._pixmapItem_main_topleft.setPixmap(pixmap_main_topleft)
 885            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
 886            self._pixmap_base_original = pixmap_main_topleft
 887            self.set_opacity_base(100)
 888
 889    @QtCore.pyqtSlot()
 890    def set_opacity_base(self, percent):
 891        """Set transparency of base image.
 892        
 893        Args:
 894            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 895        """
 896        
 897        self._opacity_base = percent
 898
 899        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_base_original.size())
 900        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 901        painter = QtGui.QPainter(pixmap_to_be_transparent)
 902        painter.setOpacity(percent/100)
 903        painter.drawPixmap(QtCore.QPoint(), self._pixmap_base_original)
 904        painter.end()
 905
 906        self._pixmapItem_main_topleft.setPixmap(pixmap_to_be_transparent)
 907    
 908    @property
 909    def pixmap_topright(self):
 910        """The currently viewed QPixmap of the top-right of the split."""
 911        return self._pixmapItem_topright.pixmap()
 912    
 913    @pixmap_topright.setter
 914    def pixmap_topright(self, pixmap):
 915        self._pixmap_topright_original = pixmap
 916        self.set_opacity_topright(100)
 917    
 918    @QtCore.pyqtSlot()
 919    def set_opacity_topright(self, percent):
 920        """Set transparency of top-right of sliding overlay.
 921        
 922        Allows users to see base image underneath.
 923        Provide enhanced integration and comparison of images (for example, blending raking light with color).
 924
 925        Args:
 926            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 927        """
 928        if not self.pixmap_topright_exists:
 929            self._opacity_topright = 100
 930            return
 931        
 932        self._opacity_topright = percent
 933
 934        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_topright_original.size())
 935        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 936        painter = QtGui.QPainter(pixmap_to_be_transparent)
 937        painter.setOpacity(percent/100)
 938        painter.drawPixmap(QtCore.QPoint(), self._pixmap_topright_original)
 939        painter.end()
 940
 941        self._pixmapItem_topright.setPixmap(pixmap_to_be_transparent)
 942    
 943
 944    @property
 945    def pixmap_bottomright(self):
 946        """The currently viewed QPixmap of the bottom-right of the split."""
 947        return self._pixmapItem_bottomright.pixmap()
 948    
 949    @pixmap_bottomright.setter
 950    def pixmap_bottomright(self, pixmap):
 951        self._pixmap_bottomright_original = pixmap
 952        self.set_opacity_bottomright(100)
 953    
 954    @QtCore.pyqtSlot()
 955    def set_opacity_bottomright(self, percent):
 956        """Set transparency of bottom-right of sliding overlay.
 957
 958        Args:
 959            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 960        """
 961        if not self.pixmap_bottomright_exists:
 962            self._opacity_bottomright = 100
 963            return
 964
 965        self._opacity_bottomright = percent
 966
 967        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomright_original.size())
 968        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
 969        painter = QtGui.QPainter(pixmap_to_be_transparent)
 970        painter.setOpacity(percent/100)
 971        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomright_original)
 972        painter.end()
 973
 974        self._pixmapItem_bottomright.setPixmap(pixmap_to_be_transparent)
 975    
 976
 977    @property
 978    def pixmap_bottomleft(self):
 979        """The currently viewed QPixmap of the bottom-left of the split."""
 980        return self._pixmapItem_bottomleft.pixmap()
 981    
 982    @pixmap_bottomleft.setter
 983    def pixmap_bottomleft(self, pixmap):
 984        self._pixmap_bottomleft_original = pixmap
 985        self.set_opacity_bottomleft(100)
 986    
 987    @QtCore.pyqtSlot()
 988    def set_opacity_bottomleft(self, percent):
 989        """Set transparency of bottom-left of sliding overlay.
 990
 991        Args:
 992            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 993        """
 994        if not self.pixmap_bottomleft_exists:
 995            self._opacity_bottomleft = 100
 996            return
 997
 998        self._opacity_bottomleft = percent
 999
1000        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomleft_original.size())
1001        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
1002        painter = QtGui.QPainter(pixmap_to_be_transparent)
1003        painter.setOpacity(percent/100)
1004        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomleft_original)
1005        painter.end()
1006
1007        self._pixmapItem_bottomleft.setPixmap(pixmap_to_be_transparent)
1008    
1009    def moveEvent(self, event):
1010        """Override move event of frame."""
1011        super().moveEvent(event)
1012
1013    def resizeEvent(self, event):
1014        """Override resize event of frame to ensure scene is also resized."""
1015        self.resize_scene()
1016        super().resizeEvent(event) # Equivalent to QtWidgets.QFrame.resizeEvent(self, event)     
1017        
1018    def resize_scene(self):
1019        """Resize the scene to allow image to be panned just before the main pixmap leaves the viewport.
1020
1021        This is needed to expand the scene so that users can pan the pixmap such that its edges are at the center of the view.
1022        This changes the default behavior, which limits the scene to the bounds of the pixmap, thereby blocking users 
1023        from panning outside the bounds of the pixmap, which can feel abrupt and restrictive.
1024        This takes care of preventing users from panning too far away from the pixmap. 
1025        """
1026        scene_to_viewport_factor = self._view_main_topleft.zoomFactor
1027        
1028        width_viewport_window = self.width()
1029        height_viewport_window = self.height()
1030
1031        peek_precent = 0.1 # Percent of pixmap to be left "peeking" at bounds of pan
1032        peek_margin_x = width_viewport_window*peek_precent # [px]
1033        peek_margin_y = height_viewport_window*peek_precent
1034        
1035        width_viewport = (width_viewport_window - peek_margin_x)/scene_to_viewport_factor # This is the size of the viewport on the screen
1036        height_viewport = (height_viewport_window - peek_margin_y)/scene_to_viewport_factor
1037        
1038        width_pixmap = self._pixmapItem_main_topleft.pixmap().width()
1039        height_pixmap = self._pixmapItem_main_topleft.pixmap().height()
1040        
1041        width_scene = 2.0*(width_viewport + width_pixmap/2.0) # The scene spans twice the viewport plus the pixmap
1042        height_scene = 2.0*(height_viewport + height_pixmap/2.0)
1043        
1044        scene_rect = QtCore.QRectF(-width_scene/2.0 + width_pixmap/2.0,-height_scene/2.0 + height_pixmap/2.0,width_scene,height_scene)
1045        self._scene_main_topleft.setSceneRect(scene_rect)
1046    
1047    def set_transform_mode_smooth_on(self):
1048        """Set transform mode to smooth (interpolate) when zoomfactor is >= 1.0."""
1049        self.transform_mode_smooth = True
1050        self._scene_main_topleft.set_single_transform_mode_smooth(True)
1051        self.refresh_transform_mode()
1052            
1053    def set_transform_mode_smooth_off(self):
1054        """Set transform mode to non-smooth (non-interpolated) when zoomfactor is >= 1.0."""
1055        self.transform_mode_smooth = False
1056        self._scene_main_topleft.set_single_transform_mode_smooth(False)
1057        self.refresh_transform_mode()
1058
1059    def set_transform_mode_smooth(self, boolean):
1060        """Set transform mode when zoomfactor is >= 1.0.
1061
1062        Convenience function.
1063        
1064        Args:
1065            boolean (bool): True to smooth (interpolate); False to fast (not interpolate).
1066        """
1067        if boolean:
1068            self.set_transform_mode_smooth_on()
1069        elif not boolean:
1070            self.set_transform_mode_smooth_off()
1071            
1072    @QtCore.pyqtSlot()
1073    def on_transformChanged(self):
1074        """Resize scene if image transform is changed (for example, when zoomed)."""
1075        self.resize_scene()
1076        self.update_split()
1077
1078    @property
1079    def viewName(self):
1080        """str: The name of the SplitView."""
1081        return self._name
1082    
1083    @viewName.setter
1084    def viewName(self, name):
1085        self._name = name
1086
1087    @property
1088    def handDragging(self):
1089        """bool: The hand dragging state."""
1090        return self._view_main_topleft.handDragging
1091
1092    @property
1093    def scrollState(self):
1094        """tuple: The percentage of scene extents
1095        *(sceneWidthPercent, sceneHeightPercent)*"""
1096        return self._view_main_topleft.scrollState
1097
1098    @scrollState.setter
1099    def scrollState(self, state):
1100        self._view_main_topleft.scrollState = state
1101
1102    @property
1103    def zoomFactor(self):
1104        """float: The zoom scale factor."""
1105        return self._view_main_topleft.zoomFactor
1106
1107    @zoomFactor.setter
1108    def zoomFactor(self, newZoomFactor):
1109        """Apply zoom to all views, taking into account the transform mode.
1110        
1111        Args:
1112            newZoomFactor (float)
1113        """
1114        if newZoomFactor < 1.0:
1115            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1116            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1117            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1118            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1119        elif self.transform_mode_smooth:
1120            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1121            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1122            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1123            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1124        else:
1125            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.FastTransformation)
1126            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.FastTransformation)
1127            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.FastTransformation)
1128            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.FastTransformation)
1129
1130        self._view_main_topleft.zoomFactor = newZoomFactor
1131
1132        newZoomFactor = newZoomFactor / self._view_topright.transform().m11()
1133        self._view_topright.scale(newZoomFactor, newZoomFactor)
1134        self._view_bottomright.scale(newZoomFactor, newZoomFactor)
1135        self._view_bottomleft.scale(newZoomFactor, newZoomFactor)
1136
1137    def refresh_transform_mode(self):
1138        """Refresh zoom of all views, taking into account the transform mode."""
1139        self._view_main_topleft.zoomFactor
1140        if self.zoomFactor < 1.0:
1141            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1142            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1143            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1144            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1145        elif self.transform_mode_smooth:
1146            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1147            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1148            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1149            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1150        else:
1151            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.FastTransformation)
1152            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.FastTransformation)
1153            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.FastTransformation)
1154            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.FastTransformation)
1155
1156    @property
1157    def _horizontalScrollBar(self):
1158        """Get the SplitView horizontal scrollbar widget (*QScrollBar*).
1159
1160        (Only used for debugging purposes)"""
1161        return self._view_main_topleft.horizontalScrollBar()
1162
1163    @property
1164    def _verticalScrollBar(self):
1165        """Get the SplitView vertical scrollbar widget (*QScrollBar*).
1166
1167        (Only used for debugging purposes)"""
1168        return self._view_main_topleft.verticalScrollBar()
1169
1170    @property
1171    def _sceneRect(self):
1172        """Get the SplitView sceneRect (*QRectF*).
1173
1174        (Only used for debugging purposes)"""
1175        return self._view_main_topleft.sceneRect()
1176
1177    @QtCore.pyqtSlot()
1178    def scrollToTop(self):
1179        """Scroll to top of image."""
1180        self._view_main_topleft.scrollToTop()
1181
1182    @QtCore.pyqtSlot()
1183    def scrollToBottom(self):
1184        """Scroll to bottom of image."""
1185        self._view_main_topleft.scrollToBottom()
1186
1187    @QtCore.pyqtSlot()
1188    def scrollToBegin(self):
1189        """Scroll to left side of image."""
1190        self._view_main_topleft.scrollToBegin()
1191
1192    @QtCore.pyqtSlot()
1193    def scrollToEnd(self):
1194        """Scroll to right side of image."""
1195        self._view_main_topleft.scrollToEnd()
1196
1197    @QtCore.pyqtSlot()
1198    def centerView(self):
1199        """Center image in view."""
1200        self._view_main_topleft.centerView()
1201
1202    @QtCore.pyqtSlot(bool)
1203    def enableScrollBars(self, enable):
1204        """Set visiblility of the view's scrollbars.
1205
1206        :param bool enable: True to enable the scrollbars """
1207        self._view_main_topleft.enableScrollBars(enable)
1208
1209    @QtCore.pyqtSlot(bool)
1210    def enableHandDrag(self, enable):
1211        """Set whether dragging the view with the hand cursor is allowed.
1212
1213        :param bool enable: True to enable hand dragging """
1214        self._view_main_topleft.enableHandDrag(enable)
1215
1216    @QtCore.pyqtSlot()
1217    def zoomIn(self):
1218        """Zoom in on image."""
1219        self.scaleImage(self._zoomFactorDelta)
1220
1221    @QtCore.pyqtSlot()
1222    def zoomOut(self):
1223        """Zoom out on image."""
1224        self.scaleImage(1 / self._zoomFactorDelta)
1225
1226    @QtCore.pyqtSlot()
1227    def actualSize(self):
1228        """Change zoom to show image at actual size.
1229
1230        (image pixel is equal to screen pixel)"""
1231        self.scaleImage(1.0, combine=False)
1232
1233    @QtCore.pyqtSlot()
1234    def fitToWindow(self):
1235        """Fit image within view.
1236            
1237        If the viewport is wider than the main pixmap, then fit the pixmap to height; if the viewport is narrower, then fit the pixmap to width
1238        """
1239        if not self._pixmapItem_main_topleft.pixmap():
1240            return
1241
1242        padding_margin = 2 # Leaves visual gap between pixmap and border of viewport
1243        viewport_rect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1244                                                         -padding_margin, -padding_margin)
1245        aspect_ratio_viewport = viewport_rect.width()/viewport_rect.height()
1246        aspect_ratio_pixmap   = self._pixmapItem_main_topleft.pixmap().width()/self._pixmapItem_main_topleft.pixmap().height()
1247        if aspect_ratio_viewport > aspect_ratio_pixmap:
1248            self.fitHeight()
1249        else:
1250            self.fitWidth()
1251
1252        self.transformChanged.emit()
1253    
1254    @QtCore.pyqtSlot()
1255    def fitWidth(self):
1256        """Fit image width to view width."""
1257        if not self._pixmapItem_main_topleft.pixmap():
1258            return
1259        padding_margin = 2
1260        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1261                                                         -padding_margin, -padding_margin)
1262        factor = viewRect.width() / self._pixmapItem_main_topleft.pixmap().width()
1263        self.scaleImage(factor, combine=False)
1264        self._view_main_topleft.centerView()
1265    
1266    @QtCore.pyqtSlot()
1267    def fitHeight(self):
1268        """Fit image height to view height."""
1269        if not self._pixmapItem_main_topleft.pixmap():
1270            return
1271        padding_margin = 2
1272        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1273                                                         -padding_margin, -padding_margin)
1274        factor = viewRect.height() / self._pixmapItem_main_topleft.pixmap().height()
1275        self.scaleImage(factor, combine=False)
1276        self._view_main_topleft.centerView()
1277
1278    def handleWheelNotches(self, notches):
1279        """Handle wheel notch event from underlying |QGraphicsView|.
1280
1281        :param float notches: Mouse wheel notches"""
1282        self.scaleImage(self._zoomFactorDelta ** notches)
1283
1284    def closeEvent(self, event):
1285        """Overriden in order to disconnect scrollbar signals before
1286        closing.
1287
1288        :param QEvent event: instance of a |QEvent|
1289        
1290        If this isn't done Python crashes!"""
1291        #self.scrollChanged.disconnect() #doesn't prevent crash
1292        self.disconnectSbarSignals()
1293        
1294        self._scene_main_topleft.deleteLater()
1295        self._view_main_topleft.deleteLater()
1296        del self._pixmap_base_original
1297        
1298        self._scene_topright.deleteLater()
1299        self._view_topright.deleteLater()
1300        del self._pixmap_topright_original
1301        
1302        self._scene_bottomright.deleteLater()
1303        self._view_bottomright.deleteLater()
1304        del self._pixmap_bottomright_original
1305        
1306        self._scene_bottomleft.deleteLater()
1307        self._view_bottomleft.deleteLater()
1308        del self._pixmap_bottomleft_original
1309        
1310        super().closeEvent(event)
1311        gc.collect()
1312        self.became_closed.emit()
1313        
1314    def scaleImage(self, factor, combine=True):
1315        """Scale image by factor.
1316
1317        :param float factor: either new :attr:`zoomFactor` or amount to scale
1318                             current :attr:`zoomFactor`
1319
1320        :param bool combine: if ``True`` scales the current
1321                             :attr:`zoomFactor` by factor.  Otherwise
1322                             just sets :attr:`zoomFactor` to factor"""
1323        if not self._pixmapItem_main_topleft.pixmap():
1324            return
1325
1326        if combine:
1327            self.zoomFactor = self.zoomFactor * factor
1328        else:
1329            self.zoomFactor = factor
1330            
1331        self._view_main_topleft.checkTransformChanged()
1332
1333    def dumpTransform(self):
1334        """Dump view transform to stdout."""
1335        self._view_main_topleft.dumpTransform(self._view_main_topleft.transform(), " "*4)
1336    
1337    
1338    def create_mouse_rect(self):
1339        """Create a red 1x1 outline at the pointer in the main scene.
1340        
1341        Indicates to the user the size and position of the pixel over which the mouse is hovering.
1342        Helps to understand the position of individual pixels and their scale at the current zoom.
1343        """
1344    
1345        pen = QtGui.QPen() 
1346        pen.setWidth(0.1)
1347        pen.setColor(QtCore.Qt.red)
1348        pen.setCapStyle(QtCore.Qt.SquareCap)
1349        pen.setJoinStyle(QtCore.Qt.MiterJoin)
1350            
1351        brush = QtGui.QBrush()
1352        brush.setColor(QtCore.Qt.transparent)
1353        
1354        self.mouse_rect_width = 1
1355        self.mouse_rect_height = 1
1356        
1357        self.mouse_rect_topleft = QtCore.QPointF(0,0)
1358        self.mouse_rect_bottomright = QtCore.QPointF(self.mouse_rect_width-0.01, self.mouse_rect_height-0.01)
1359        self.mouse_rect = QtCore.QRectF(self.mouse_rect_topleft, self.mouse_rect_bottomright)
1360        
1361        self.mouse_rect_scene_main_topleft = QtWidgets.QGraphicsRectItem(self.mouse_rect) # To add the same item in two scenes, you need to create two unique items
1362        
1363        self.mouse_rect_scene_main_topleft.setPos(0,0)
1364        
1365        self.mouse_rect_scene_main_topleft.setBrush(brush)
1366        self.mouse_rect_scene_main_topleft.setPen(pen)
1367        
1368        self._scene_main_topleft.addItem(self.mouse_rect_scene_main_topleft)
1369
1370    def set_mouse_rect_visible(self, boolean):
1371        """Set the visibilty of the red 1x1 outline at the pointer in the main scene.
1372        
1373        Args:
1374            boolean (bool): True to show 1x1 outline; False to hide.
1375        """
1376        self.mouse_rect_scene_main_topleft.setVisible(boolean)

Image viewing widget for individual images and sliding overlays.

Creates an interface with a base image as a main image located at the top left
and optionally 3 other images (top-left, bottom-left, bottom-right) as a sliding overlay. Supports zoom and pan. Enables synchronized zoom and pan via signals. Input images for a given sliding overlay must have identical resolutions to function properly.

Arguments:
  • pixmap (QPixmap): The main image to be viewed; the basis of the sliding overlay (main; topleft)
  • filename_main_topleft (str): The image filepath of the main image.
  • name (str): The name of the viewing widget.
  • pixmap_topright (QPixmap): The top-right image of the sliding overlay (set None to exclude).
  • pixmap_bottomleft (QPixmap): The bottom-left image of the sliding overlay (set None to exclude).
  • pixmap_bottomright (QPixmap): The bottom-right image of the sliding overlay (set None to exclude).
currentFile

str: Filepath of base image (filename_main_topleft).

viewName

str: The name of the SplitView.

pixmap_topright

The currently viewed QPixmap of the top-right of the split.

pixmap_bottomleft

The currently viewed QPixmap of the bottom-left of the split.

pixmap_bottomright

The currently viewed QPixmap of the bottom-right of the split.

userFriendlyCurrentFile

str: Filename of base image.

def set_close_pushbutton_always_visible(self, boolean):
340    def set_close_pushbutton_always_visible(self, boolean):
341        """Enable/disable the always-on visiblilty of the close X of the view.
342        
343        Arg:
344            boolean (bool): True to show the close X always; False to hide unless mouse hovers over.
345        """
346        self.close_pushbutton_always_visible = boolean
347        self.refresh_close_pushbutton_stylesheet()

Enable/disable the always-on visiblilty of the close X of the view.

Arg:

boolean (bool): True to show the close X always; False to hide unless mouse hovers over.

def refresh_close_pushbutton_stylesheet(self):
349    def refresh_close_pushbutton_stylesheet(self):
350        """Refresh stylesheet of close pushbutton based on background color and visibility."""
351        if not self.close_pushbutton:
352            return
353        always_visible = self.close_pushbutton_always_visible
354        background_rgb =  self._scene_main_topleft.background_rgb
355        avg_background_rgb = sum(background_rgb)/len(background_rgb)
356        if not always_visible: # Hide unless hovered
357            self.close_pushbutton.setStyleSheet("""
358                QPushButton {
359                    width: 1.8em;
360                    height: 1.8em;
361                    color: transparent;
362                    background-color: rgba(223, 0, 0, 0); 
363                    font-weight: bold;
364                    border-width: 0px;
365                    border-style: solid;
366                    border-color: transparent;
367                    font-size: 10pt;
368                }
369                QPushButton:hover {
370                    color: white;
371                    background-color: rgba(223, 0, 0, 223);
372                }
373                QPushButton:pressed {
374                    color: white;
375                    background-color: rgba(255, 0, 0, 255);
376                }
377                """)
378        else: # Always visible
379            if avg_background_rgb >= 223: # Unhovered is black X on light background
380                self.close_pushbutton.setStyleSheet("""
381                    QPushButton {
382                        width: 1.8em;
383                        height: 1.8em;
384                        color: black;
385                        background-color: rgba(223, 0, 0, 0); 
386                        font-weight: bold;
387                        border-width: 0px;
388                        border-style: solid;
389                        border-color: transparent;
390                        font-size: 10pt;
391                    }
392                    QPushButton:hover {
393                        color: white;
394                        background-color: rgba(223, 0, 0, 223);
395                    }
396                    QPushButton:pressed {
397                        color: white;
398                        background-color: rgba(255, 0, 0, 255);
399                    }
400                    """)
401            else: # Unhovered is white X on dark background
402                self.close_pushbutton.setStyleSheet("""
403                    QPushButton {
404                        width: 1.8em;
405                        height: 1.8em;
406                        color: white;
407                        background-color: rgba(223, 0, 0, 0); 
408                        font-weight: bold;
409                        border-width: 0px;
410                        border-style: solid;
411                        border-color: transparent;
412                        font-size: 10pt;
413                    }
414                    QPushButton:hover {
415                        color: white;
416                        background-color: rgba(223, 0, 0, 223);
417                    }
418                    QPushButton:pressed {
419                        color: white;
420                        background-color: rgba(255, 0, 0, 255);
421                    }
422                    """)

Refresh stylesheet of close pushbutton based on background color and visibility.

def set_scene_background(self, brush):
425    def set_scene_background(self, brush):
426        """Set scene background color with QBrush.
427        
428        Args:
429            brush (QBrush)
430        """
431        if not self._scene_main_topleft:
432            return
433        self._scene_main_topleft.setBackgroundBrush(brush)

Set scene background color with QBrush.

Arguments:
  • brush (QBrush)
def set_scene_background_color(self, color: list):
435    def set_scene_background_color(self, color: list):
436        """Set scene background color with color list.
437
438        The init for CustomQGraphicsScene contains the ground truth for selectable background colors.
439        
440        Args:
441            color (list): Descriptor string and RGB int values. Example: ["White", 255, 255, 255].
442        """
443        if not self._scene_main_topleft:
444            return
445        rgb = color[1:4]
446        rgb_clamp = [max(min(channel, 255), 0) for channel in rgb]
447        brush = QtGui.QColor(rgb_clamp[0], rgb_clamp[1], rgb_clamp[2])
448        self.set_scene_background(brush)
449        self._scene_main_topleft.background_color = color
450        self.refresh_close_pushbutton_stylesheet()

Set scene background color with color list.

The init for CustomQGraphicsScene contains the ground truth for selectable background colors.

Arguments:
  • color (list): Descriptor string and RGB int values. Example: ["White", 255, 255, 255].
def pixmap_none_ify(self, pixmap):
453    def pixmap_none_ify(self, pixmap):
454        """Return None if pixmap has no pixels.
455        
456        Args:
457            pixmap (QPixmap)
458
459        Returns:
460            None if pixmap has no pixels; same pixmap if it has pixels
461        """
462        if pixmap:
463            if pixmap.width()==0 or pixmap.height==0:
464                return None
465            else:
466                return pixmap
467        else:
468            return None

Return None if pixmap has no pixels.

Arguments:
  • pixmap (QPixmap)
Returns:
  • None if pixmap has no pixels; same pixmap if it has pixels
@QtCore.pyqtSlot(QtCore.QPoint)
def on_positionChanged(self, pos):
470    @QtCore.pyqtSlot(QtCore.QPoint)
471    def on_positionChanged(self, pos):
472        """Update the position of the split and the 1x1 pixel rectangle.
473
474        Triggered when mouse is moved.
475        
476        Args:
477            pos (QPoint): The position of the mouse relative to widget.
478        """
479        point_of_mouse_on_widget = pos
480        
481        self.update_split(point_of_mouse_on_widget)
482        self.update_mouse_rect(point_of_mouse_on_widget)

Update the position of the split and the 1x1 pixel rectangle.

Triggered when mouse is moved.

Arguments:
  • pos (QPoint): The position of the mouse relative to widget.
def set_split( self, x_percent=0.5, y_percent=0.5, ignore_lock=False, percent_of_visible=False):
484    def set_split(self, x_percent=0.5, y_percent=0.5, ignore_lock=False, percent_of_visible=False):
485        """Set the position of the split with x and y as proportion of base image's resolution.
486
487        Sets split position using a proportion of x and y (by default of entire main pixmap; can be set to proportion of visible pixmap).
488        Top left is x=0, y=0; bottom right is x=1, y=1.
489        This is needed to position the split without mouse movement from user (for example, to preview the effect of the transparency sliders in Butterfly Viewer)
490        
491        Args:
492            x_percent (float): The position of the split as a proportion (0-1) of the base image's horizontal resolution.
493            y_percent (float): The position of the split as a proportion (0-1) of the base image's vertical resolution.
494            ignore_lock (bool): True to ignore the lock status of the split; False to adhere.
495            percent_of_visible (bool): True to set split as proportion of visible area; False as proportion of the full image resolution.
496        """
497        if percent_of_visible:
498            x = x_percent*self.width()
499            y = y_percent*self.height()
500            point_of_split_on_widget = QtCore.QPoint(x, y)
501        else:
502            width_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().width()
503            height_pixmap_main_topleft = self._pixmapItem_main_topleft.pixmap().height()
504
505            x = x_percent*width_pixmap_main_topleft
506            y = y_percent*height_pixmap_main_topleft
507
508            point_of_split_on_scene = QtCore.QPointF(x,y)
509
510            point_of_split_on_widget = self._view_main_topleft.mapFromScene(point_of_split_on_scene)
511
512        self.update_split(point_of_split_on_widget, ignore_lock=ignore_lock)

Set the position of the split with x and y as proportion of base image's resolution.

Sets split position using a proportion of x and y (by default of entire main pixmap; can be set to proportion of visible pixmap). Top left is x=0, y=0; bottom right is x=1, y=1. This is needed to position the split without mouse movement from user (for example, to preview the effect of the transparency sliders in Butterfly Viewer)

Arguments:
  • x_percent (float): The position of the split as a proportion (0-1) of the base image's horizontal resolution.
  • y_percent (float): The position of the split as a proportion (0-1) of the base image's vertical resolution.
  • ignore_lock (bool): True to ignore the lock status of the split; False to adhere.
  • percent_of_visible (bool): True to set split as proportion of visible area; False as proportion of the full image resolution.
def update_split(self, pos=None, pos_is_global=False, ignore_lock=False):
514    def update_split(self, pos = None, pos_is_global=False, ignore_lock=False): 
515        """Update the position of the split with mouse position.
516        
517        Args:
518            pos (QPoint): Position of the mouse.
519            pos_is_global (bool): True if given mouse position is relative to MdiChild; False if global position.
520            ignore_lock (bool): True to ignore (bypass) the status of the split lock.
521        """
522        if pos is None: # Get position of the split from the mouse's global coordinates (can be slow!)
523            point_of_cursor_global      = QtGui.QCursor.pos()
524            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(point_of_cursor_global)
525        else:
526            if pos_is_global:
527                point_of_mouse_on_widget = self._view_main_topleft.mapFromGlobal(pos)
528            else:
529                point_of_mouse_on_widget = pos
530
531        point_of_mouse_on_widget.setX(point_of_mouse_on_widget.x()+1) # Offset +1 needed to have mouse cursor be hovering over the main scene (e.g., to allow manipulation of graphics item)
532        point_of_mouse_on_widget.setY(point_of_mouse_on_widget.y()+1)
533
534        self.last_updated_point_of_split_on_scene_main = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(), point_of_mouse_on_widget.y())
535        
536        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
537        
538        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
539
540        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
541        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
542        
543        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
544        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
545        
546        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
547    
548        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
549        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
550        
551        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
552        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
553        
554        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
555        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
556        
557        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
558        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
559        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
560        
561        self._view_topright.setSceneRect(rect_of_scene_topright)
562        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
563        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
564        
565        self._view_topright.centerOn(top_left_of_scene_topright)
566        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
567        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)

Update the position of the split with mouse position.

Arguments:
  • pos (QPoint): Position of the mouse.
  • pos_is_global (bool): True if given mouse position is relative to MdiChild; False if global position.
  • ignore_lock (bool): True to ignore (bypass) the status of the split lock.
def refresh_split_based_on_last_updated_point_of_split_on_scene_main(self):
569    def refresh_split_based_on_last_updated_point_of_split_on_scene_main(self): 
570        """Refresh the position of the split using the previously recorded split location.
571        
572        This is needed to maintain the position of the split during synchronized zooming and panning.
573        """ 
574        point_of_mouse_on_widget = self._view_main_topleft.mapFromScene(self.last_updated_point_of_split_on_scene_main)
575        
576        point_of_bottom_right_on_widget = QtCore.QPointF(self.width(), self.height())
577        
578        point_of_widget_origin_on_scene_main = self._view_main_topleft.mapToScene(0,0)
579        point_of_split_on_scene_main = self._view_main_topleft.mapToScene(max(point_of_mouse_on_widget.x(),0),max(point_of_mouse_on_widget.y(),0))
580        point_of_bottom_right_on_scene_main = self._view_main_topleft.mapToScene(point_of_bottom_right_on_widget.x(), point_of_bottom_right_on_widget.y())
581
582        self._view_layoutdriving_topleft.setMaximumWidth(max(point_of_mouse_on_widget.x(),0))
583        self._view_layoutdriving_topleft.setMaximumHeight(max(point_of_mouse_on_widget.y(),0))
584        
585        render_buffer = 100 # Needed to prevent slight pixel offset of the sliding overlays when zoomed-out below ~0.5x
586    
587        top_left_of_scene_topright          = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_widget_origin_on_scene_main.y())
588        bottom_right_of_scene_topright      = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_split_on_scene_main.y() + render_buffer)
589        
590        top_left_of_scene_bottomright       = QtCore.QPointF(point_of_split_on_scene_main.x(), point_of_split_on_scene_main.y())
591        bottom_right_of_scene_bottomright   = QtCore.QPointF(point_of_bottom_right_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
592        
593        top_left_of_scene_bottomleft        = QtCore.QPointF(point_of_widget_origin_on_scene_main.x(), point_of_split_on_scene_main.y())
594        bottom_right_of_scene_bottomleft    = QtCore.QPointF(point_of_split_on_scene_main.x() + render_buffer, point_of_bottom_right_on_scene_main.y() + render_buffer)
595        
596        rect_of_scene_topright = QtCore.QRectF(top_left_of_scene_topright, bottom_right_of_scene_topright)
597        rect_of_scene_bottomright = QtCore.QRectF(top_left_of_scene_bottomright, bottom_right_of_scene_bottomright)
598        rect_of_scene_bottomleft = QtCore.QRectF(top_left_of_scene_bottomleft, bottom_right_of_scene_bottomleft)
599        
600        self._view_topright.setSceneRect(rect_of_scene_topright)
601        self._view_bottomright.setSceneRect(rect_of_scene_bottomright)
602        self._view_bottomleft.setSceneRect(rect_of_scene_bottomleft)
603        
604        self._view_topright.centerOn(top_left_of_scene_topright)
605        self._view_bottomright.centerOn(top_left_of_scene_bottomright)
606        self._view_bottomleft.centerOn(top_left_of_scene_bottomleft)

Refresh the position of the split using the previously recorded split location.

This is needed to maintain the position of the split during synchronized zooming and panning.

def update_split_given_global(self, pos_global):
608    def update_split_given_global(self, pos_global):
609        """Update the position of the split based on given global mouse position.
610
611        Convenience function.
612
613        Args:
614            pos_global (QPoint): The position of the mouse in global coordinates.
615        """
616        self.update_split(pos = pos_global, pos_is_global=True)

Update the position of the split based on given global mouse position.

Convenience function.

Arguments:
  • pos_global (QPoint): The position of the mouse in global coordinates.
def on_right_click_comment(self, scene_pos):
619    def on_right_click_comment(self, scene_pos):
620        """Create an editable and movable comment on the scene of the main view at the given scene position.
621
622        Args:
623            pos (QPointF): position of the comment datum on the scene.
624        """
625        pos_on_scene = scene_pos
626        self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=pos_on_scene, set_cursor_on_creation=True))

Create an editable and movable comment on the scene of the main view at the given scene position.

Arguments:
  • pos (QPointF): position of the comment datum on the scene.
def on_right_click_ruler( self, scene_pos, relative_origin_position, unit='px', px_per_unit=1.0, update_px_per_unit_on_existing=False):
628    def on_right_click_ruler(self, scene_pos, relative_origin_position, unit="px", px_per_unit=1.0, update_px_per_unit_on_existing=False):
629        """Create a movable ruler on the scene of the main view at the given scene position.
630
631        Args:
632            scene_pos (QPointF): The position of the ruler center on the scene.
633            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
634            unit (str): The text for labeling units of ruler values.
635            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
636            update_px_per_unit_on_existing (bool): False always. (Legacy from past versions; future work should remove.
637        """
638        placement_factor = 1/3
639        px_per_unit = px_per_unit
640        relative_origin_position = relative_origin_position
641
642        widget_width = self.width()
643        widget_height = self.height()
644
645        pos_p1 = self._view_main_topleft.mapToScene(widget_width*placement_factor, widget_height*placement_factor)
646        pos_p2 = self._view_main_topleft.mapToScene(widget_width*(1-placement_factor), widget_height*(1-placement_factor))
647        self._scene_main_topleft.addItem(RulerItem(unit=unit, px_per_unit=px_per_unit, initial_pos_p1=pos_p1, initial_pos_p2=pos_p2, relative_origin_position=relative_origin_position))

Create a movable ruler on the scene of the main view at the given scene position.

Arguments:
  • scene_pos (QPointF): The position of the ruler center on the scene.
  • relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
  • unit (str): The text for labeling units of ruler values.
  • px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
  • update_px_per_unit_on_existing (bool): False always. (Legacy from past versions; future work should remove.
def on_changed_px_per_unit(self, unit, px_per_unit):
649    def on_changed_px_per_unit(self, unit, px_per_unit):
650        """Update the units and pixel-per-unit conversions of all rulers in main scene.
651
652        Args:
653            unit (str): The text for labeling units of ruler values.
654            px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
655        """
656        for item in self._scene_main_topleft.items():
657            if isinstance(item, RulerItem):
658                if item.unit == unit:
659                    item.set_and_refresh_px_per_unit(px_per_unit)

Update the units and pixel-per-unit conversions of all rulers in main scene.

Arguments:
  • unit (str): The text for labeling units of ruler values.
  • px_per_unit (float): The conversion for pixels to units. For example, 10 means 10 pixels-per-unit, meaning the ruler value will show 1 when measuring 10 pixels.
def on_right_click_save_all_comments(self):
661    def on_right_click_save_all_comments(self):
662        """Open a dialog window for user to save all existing comments on the main scene to .csv.
663        
664        Triggered from right-click menu on view.
665        """
666        self.display_loading_grayout(True, "Selecting folder and name for saving all comments in current view...", pseudo_load_time=0)
667
668        style = []
669        x = []
670        y = []
671        color = []
672        string = []
673        i = -1
674        for item in self._scene_main_topleft.items():
675            
676            if isinstance(item, CommentItem):
677                i += 1
678                
679                comment_pos = item.get_scene_pos()
680                comment_color = item.get_color()
681                comment_string = item.get_comment_text_str()
682
683                style.append("plain text")
684                x.append(comment_pos.x())
685                y.append(comment_pos.y())
686                color.append(comment_color)
687                string.append(comment_string)
688
689
690        folderpath = None
691        filepath_mainview = self.currentFile
692
693        if filepath_mainview:
694            folderpath = filepath_mainview
695            folderpath = os.path.dirname(folderpath)
696            folderpath = folderpath + "\\"
697        else:
698            self.display_loading_grayout(False, pseudo_load_time=0)
699            return
700        
701        header = [["Butterfly Viewer"],
702                  ["1.0"],
703                  ["comments"],
704                  ["no details"],
705                  ["origin"],
706                  [os.path.basename(filepath_mainview)],
707                  ["Style", "Image x", "Image y", "Appearance", "String"]]
708
709        date_and_time = datetime.now().strftime('%Y-%m-%d %H%M%S') # Sets the default filename with date and time 
710        filename = "Untitled comments" + " - " + os.path.basename(filepath_mainview).split('.')[0] + " - " + date_and_time + ".csv"
711        name_filters = "CSV (*.csv)" # Allows users to select filetype of screenshot
712        
713        filepath, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save all comments of current view to .csv", folderpath+filename, name_filters)
714
715        if filepath:
716            self.display_loading_grayout(True, "Saving all comments of current view to .csv...")
717
718            with open(filepath, "w", newline='') as csv_file:
719                csv_writer = csv.writer(csv_file, delimiter="|")
720                for row in header:
721                    csv_writer.writerow(row)
722                for row in zip(style, x, y, color, string):
723                    csv_writer.writerow(row)
724
725        self.display_loading_grayout(False)

Open a dialog window for user to save all existing comments on the main scene to .csv.

Triggered from right-click menu on view.

def on_right_click_load_comments(self):
728    def on_right_click_load_comments(self):
729        """Open a dialog window for user to load comments to the main scene via .csv as saved previously.
730        
731        Triggered from right-click menu on view.
732        """
733        self.display_loading_grayout(True, "Selecting comment file (.csv) to load into current view...", pseudo_load_time=0)
734
735        folderpath = None
736        filepath_mainview = self.currentFile
737
738        if filepath_mainview:
739            folderpath = filepath_mainview
740            folderpath = os.path.dirname(folderpath)
741            # folderpath = folderpath + "\\"
742        else:
743            self.display_loading_grayout(False, pseudo_load_time=0)
744            return
745
746        filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select comment file (.csv) to load into current view", folderpath, "Comma-Separated Value File (*.csv)")
747
748        if filename:
749            self.display_loading_grayout(True, "Loading comments from selected .csv into current view...")
750
751            with open(filename, "r", newline='') as csv_file:
752                csv_reader = csv.reader(csv_file, delimiter="|")
753                csv_list = list(csv_reader)
754
755                i = None
756
757                try:
758                    i = csv_list.index(["Style", "Image x", "Image y", "Appearance", "String"])
759                except ValueError:
760                    box_type = QtWidgets.QMessageBox.Warning
761                    title = "Invalid .csv comment file"
762                    text = "The selected .csv comment file does not have a format accepted by this app."
763                    box_buttons = QtWidgets.QMessageBox.Close
764                    box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
765                    box.exec_()
766                else:
767                    i += 1 # Move to first comment item
768                    no_comments = True
769                    for row in csv_list[i:]:
770                        if row[0] == "plain text":
771                            no_comments = False
772                            comment_x = float(row[1])
773                            comment_y = float(row[2])
774                            comment_color = row[3]
775                            comment_string = row[4]
776                            comment_pos = QtCore.QPointF(comment_x, comment_y)
777                            self._scene_main_topleft.addItem(CommentItem(initial_scene_pos=comment_pos, color=comment_color, comment_text=comment_string, set_cursor_on_creation=False))
778                    if no_comments:
779                        box_type = QtWidgets.QMessageBox.Warning
780                        title = "No comments in .csv"
781                        text = "No comments found in the selected .csv comment file."
782                        box_buttons = QtWidgets.QMessageBox.Close
783                        box = QtWidgets.QMessageBox(box_type, title, text, box_buttons)
784                        box.exec_()
785                    
786        self.display_loading_grayout(False)

Open a dialog window for user to load comments to the main scene via .csv as saved previously.

Triggered from right-click menu on view.

def on_right_click_set_relative_origin_position(self, string):
788    def on_right_click_set_relative_origin_position(self, string):
789        """Set orientation of the coordinate system for rulers by positioning the relative origin.
790
791        Allows users to switch the coordinate orientation:
792            "bottomleft" for Cartesian-style (positive X right, positive Y up)
793            topleft" for image-style (positive X right, positive Y down)
794
795        Args:
796            relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
797        """
798        for item in self._scene_main_topleft.items():
799            if isinstance(item, RulerItem):
800                item.set_and_refresh_relative_origin_position(string)

Set orientation of the coordinate system for rulers by positioning the relative origin.

Allows users to switch the coordinate orientation:

"bottomleft" for Cartesian-style (positive X right, positive Y up) topleft" for image-style (positive X right, positive Y down)

Arguments:
  • relative_origin_position (str): The position of the origin for coordinate system ("topleft" or "bottomleft").
def display_loading_grayout(self, boolean, text='Loading...', pseudo_load_time=0.2):
802    def display_loading_grayout(self, boolean, text="Loading...", pseudo_load_time=0.2):
803        """Emit signal for showing/hiding a grayout screen to indicate loading sequences.
804
805        Args:
806            boolean (bool): True to show grayout; False to hide.
807            text (str): The text to show on the grayout.
808            pseudo_load_time (float): The delay (in seconds) to hide the grayout to give user feeling of action.
809        """ 
810        self.signal_display_loading_grayout.emit(boolean, text, pseudo_load_time)

Emit signal for showing/hiding a grayout screen to indicate loading sequences.

Arguments:
  • boolean (bool): True to show grayout; False to hide.
  • text (str): The text to show on the grayout.
  • pseudo_load_time (float): The delay (in seconds) to hide the grayout to give user feeling of action.
def update_mouse_rect(self, pos=None):
812    def update_mouse_rect(self, pos = None):
813        """Update the position of red 1x1 outline at the pointer in the main scene.
814
815        Args:
816            pos (QPoint): The position of the mouse on the widget. Set to None to make the function determine the position using the mouse global coordinates.
817        """
818        if not self.mouse_rect_scene_main_topleft:
819            return
820    
821        if pos is None: # Get position of split on main scene by directly pulling mouse global coordinates
822            point_of_cursor_global      = QtGui.QCursor.pos()
823            point_of_mouse_on_widget    = self._view_main_topleft.mapFromGlobal(QtGui.QCursor.pos())
824        else:
825            point_of_mouse_on_widget = pos
826    
827        mouse_rect_pos_origin = self._view_main_topleft.mapToScene(point_of_mouse_on_widget.x(),point_of_mouse_on_widget.y())
828        mouse_rect_pos_origin.setX(math.floor(mouse_rect_pos_origin.x() - self.mouse_rect_width + 1))
829        mouse_rect_pos_origin.setY(math.floor(mouse_rect_pos_origin.y() - self.mouse_rect_height + 1))
830        
831        self.mouse_rect_scene_main_topleft.setPos(mouse_rect_pos_origin.x(), mouse_rect_pos_origin.y())

Update the position of red 1x1 outline at the pointer in the main scene.

Arguments:
  • pos (QPoint): The position of the mouse on the widget. Set to None to make the function determine the position using the mouse global coordinates.
def signal_display_loading_grayout(unknown):

Emitted when comments are being saved or loaded.

def became_closed(unknown):

Emitted when SplitView is closed.

def was_clicked_close_pushbutton(unknown):

Emitted when close pushbutton is clicked (pressed+released).

def was_set_global_transform_mode(unknown):

Emitted when transform mode is set for all views in right-click menu (passes it along).

def was_set_scene_background_color(unknown):

Emitted when background color is set in right-click menu (passes it along).

def positionChanged(unknown):

Emitted when mouse changes position.

def sceneChanged(unknown):

Scene Changed Signal.

Emitted whenever the |QGraphicsScene| content changes.

def transformChanged(unknown):

Transformed Changed Signal.

Emitted whenever the |QGraphicsView| Transform matrix has been changed.

def scrollChanged(unknown):

Scroll Changed Signal.

Emitted whenever the scrollbar position or range has changed.

def connectSbarSignals(self, slot):
867    def connectSbarSignals(self, slot):
868        """Connect to scrollbar changed signals.
869
870        :param slot: slot to connect scrollbar signals to."""
871        self._view_main_topleft.connectSbarSignals(slot)

Connect to scrollbar changed signals.

Parameters
  • slot: slot to connect scrollbar signals to.
pixmap_main_topleft

The currently viewed |QPixmap| (QPixmap).

@QtCore.pyqtSlot()
def set_opacity_base(self, percent):
889    @QtCore.pyqtSlot()
890    def set_opacity_base(self, percent):
891        """Set transparency of base image.
892        
893        Args:
894            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
895        """
896        
897        self._opacity_base = percent
898
899        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_base_original.size())
900        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
901        painter = QtGui.QPainter(pixmap_to_be_transparent)
902        painter.setOpacity(percent/100)
903        painter.drawPixmap(QtCore.QPoint(), self._pixmap_base_original)
904        painter.end()
905
906        self._pixmapItem_main_topleft.setPixmap(pixmap_to_be_transparent)

Set transparency of base image.

Arguments:
  • percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
@QtCore.pyqtSlot()
def set_opacity_topright(self, percent):
918    @QtCore.pyqtSlot()
919    def set_opacity_topright(self, percent):
920        """Set transparency of top-right of sliding overlay.
921        
922        Allows users to see base image underneath.
923        Provide enhanced integration and comparison of images (for example, blending raking light with color).
924
925        Args:
926            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
927        """
928        if not self.pixmap_topright_exists:
929            self._opacity_topright = 100
930            return
931        
932        self._opacity_topright = percent
933
934        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_topright_original.size())
935        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
936        painter = QtGui.QPainter(pixmap_to_be_transparent)
937        painter.setOpacity(percent/100)
938        painter.drawPixmap(QtCore.QPoint(), self._pixmap_topright_original)
939        painter.end()
940
941        self._pixmapItem_topright.setPixmap(pixmap_to_be_transparent)

Set transparency of top-right of sliding overlay.

Allows users to see base image underneath. Provide enhanced integration and comparison of images (for example, blending raking light with color).

Arguments:
  • percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
@QtCore.pyqtSlot()
def set_opacity_bottomright(self, percent):
954    @QtCore.pyqtSlot()
955    def set_opacity_bottomright(self, percent):
956        """Set transparency of bottom-right of sliding overlay.
957
958        Args:
959            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
960        """
961        if not self.pixmap_bottomright_exists:
962            self._opacity_bottomright = 100
963            return
964
965        self._opacity_bottomright = percent
966
967        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomright_original.size())
968        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
969        painter = QtGui.QPainter(pixmap_to_be_transparent)
970        painter.setOpacity(percent/100)
971        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomright_original)
972        painter.end()
973
974        self._pixmapItem_bottomright.setPixmap(pixmap_to_be_transparent)

Set transparency of bottom-right of sliding overlay.

Arguments:
  • percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
@QtCore.pyqtSlot()
def set_opacity_bottomleft(self, percent):
 987    @QtCore.pyqtSlot()
 988    def set_opacity_bottomleft(self, percent):
 989        """Set transparency of bottom-left of sliding overlay.
 990
 991        Args:
 992            percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
 993        """
 994        if not self.pixmap_bottomleft_exists:
 995            self._opacity_bottomleft = 100
 996            return
 997
 998        self._opacity_bottomleft = percent
 999
1000        pixmap_to_be_transparent = QtGui.QPixmap(self._pixmap_bottomleft_original.size())
1001        pixmap_to_be_transparent.fill(QtCore.Qt.transparent)
1002        painter = QtGui.QPainter(pixmap_to_be_transparent)
1003        painter.setOpacity(percent/100)
1004        painter.drawPixmap(QtCore.QPoint(), self._pixmap_bottomleft_original)
1005        painter.end()
1006
1007        self._pixmapItem_bottomleft.setPixmap(pixmap_to_be_transparent)

Set transparency of bottom-left of sliding overlay.

Arguments:
  • percent (float,int): The transparency as percent opacity, where 100 is opaque (not transparent) and 0 is transparent (0-100).
def moveEvent(self, event):
1009    def moveEvent(self, event):
1010        """Override move event of frame."""
1011        super().moveEvent(event)

Override move event of frame.

def resizeEvent(self, event):
1013    def resizeEvent(self, event):
1014        """Override resize event of frame to ensure scene is also resized."""
1015        self.resize_scene()
1016        super().resizeEvent(event) # Equivalent to QtWidgets.QFrame.resizeEvent(self, event)     

Override resize event of frame to ensure scene is also resized.

def resize_scene(self):
1018    def resize_scene(self):
1019        """Resize the scene to allow image to be panned just before the main pixmap leaves the viewport.
1020
1021        This is needed to expand the scene so that users can pan the pixmap such that its edges are at the center of the view.
1022        This changes the default behavior, which limits the scene to the bounds of the pixmap, thereby blocking users 
1023        from panning outside the bounds of the pixmap, which can feel abrupt and restrictive.
1024        This takes care of preventing users from panning too far away from the pixmap. 
1025        """
1026        scene_to_viewport_factor = self._view_main_topleft.zoomFactor
1027        
1028        width_viewport_window = self.width()
1029        height_viewport_window = self.height()
1030
1031        peek_precent = 0.1 # Percent of pixmap to be left "peeking" at bounds of pan
1032        peek_margin_x = width_viewport_window*peek_precent # [px]
1033        peek_margin_y = height_viewport_window*peek_precent
1034        
1035        width_viewport = (width_viewport_window - peek_margin_x)/scene_to_viewport_factor # This is the size of the viewport on the screen
1036        height_viewport = (height_viewport_window - peek_margin_y)/scene_to_viewport_factor
1037        
1038        width_pixmap = self._pixmapItem_main_topleft.pixmap().width()
1039        height_pixmap = self._pixmapItem_main_topleft.pixmap().height()
1040        
1041        width_scene = 2.0*(width_viewport + width_pixmap/2.0) # The scene spans twice the viewport plus the pixmap
1042        height_scene = 2.0*(height_viewport + height_pixmap/2.0)
1043        
1044        scene_rect = QtCore.QRectF(-width_scene/2.0 + width_pixmap/2.0,-height_scene/2.0 + height_pixmap/2.0,width_scene,height_scene)
1045        self._scene_main_topleft.setSceneRect(scene_rect)

Resize the scene to allow image to be panned just before the main pixmap leaves the viewport.

This is needed to expand the scene so that users can pan the pixmap such that its edges are at the center of the view. This changes the default behavior, which limits the scene to the bounds of the pixmap, thereby blocking users from panning outside the bounds of the pixmap, which can feel abrupt and restrictive. This takes care of preventing users from panning too far away from the pixmap.

def set_transform_mode_smooth_on(self):
1047    def set_transform_mode_smooth_on(self):
1048        """Set transform mode to smooth (interpolate) when zoomfactor is >= 1.0."""
1049        self.transform_mode_smooth = True
1050        self._scene_main_topleft.set_single_transform_mode_smooth(True)
1051        self.refresh_transform_mode()

Set transform mode to smooth (interpolate) when zoomfactor is >= 1.0.

def set_transform_mode_smooth_off(self):
1053    def set_transform_mode_smooth_off(self):
1054        """Set transform mode to non-smooth (non-interpolated) when zoomfactor is >= 1.0."""
1055        self.transform_mode_smooth = False
1056        self._scene_main_topleft.set_single_transform_mode_smooth(False)
1057        self.refresh_transform_mode()

Set transform mode to non-smooth (non-interpolated) when zoomfactor is >= 1.0.

def set_transform_mode_smooth(self, boolean):
1059    def set_transform_mode_smooth(self, boolean):
1060        """Set transform mode when zoomfactor is >= 1.0.
1061
1062        Convenience function.
1063        
1064        Args:
1065            boolean (bool): True to smooth (interpolate); False to fast (not interpolate).
1066        """
1067        if boolean:
1068            self.set_transform_mode_smooth_on()
1069        elif not boolean:
1070            self.set_transform_mode_smooth_off()

Set transform mode when zoomfactor is >= 1.0.

Convenience function.

Arguments:
  • boolean (bool): True to smooth (interpolate); False to fast (not interpolate).
@QtCore.pyqtSlot()
def on_transformChanged(self):
1072    @QtCore.pyqtSlot()
1073    def on_transformChanged(self):
1074        """Resize scene if image transform is changed (for example, when zoomed)."""
1075        self.resize_scene()
1076        self.update_split()

Resize scene if image transform is changed (for example, when zoomed).

handDragging

bool: The hand dragging state.

scrollState

tuple: The percentage of scene extents (sceneWidthPercent, sceneHeightPercent)

zoomFactor

float: The zoom scale factor.

def refresh_transform_mode(self):
1137    def refresh_transform_mode(self):
1138        """Refresh zoom of all views, taking into account the transform mode."""
1139        self._view_main_topleft.zoomFactor
1140        if self.zoomFactor < 1.0:
1141            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1142            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1143            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1144            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1145        elif self.transform_mode_smooth:
1146            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1147            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1148            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.SmoothTransformation)
1149            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.SmoothTransformation)
1150        else:
1151            self._pixmapItem_main_topleft.setTransformationMode(QtCore.Qt.FastTransformation)
1152            self._pixmapItem_topright.setTransformationMode(QtCore.Qt.FastTransformation)
1153            self._pixmapItem_bottomright.setTransformationMode(QtCore.Qt.FastTransformation)
1154            self._pixmapItem_bottomleft.setTransformationMode(QtCore.Qt.FastTransformation)

Refresh zoom of all views, taking into account the transform mode.

@QtCore.pyqtSlot()
def scrollToTop(self):
1177    @QtCore.pyqtSlot()
1178    def scrollToTop(self):
1179        """Scroll to top of image."""
1180        self._view_main_topleft.scrollToTop()

Scroll to top of image.

@QtCore.pyqtSlot()
def scrollToBottom(self):
1182    @QtCore.pyqtSlot()
1183    def scrollToBottom(self):
1184        """Scroll to bottom of image."""
1185        self._view_main_topleft.scrollToBottom()

Scroll to bottom of image.

@QtCore.pyqtSlot()
def scrollToBegin(self):
1187    @QtCore.pyqtSlot()
1188    def scrollToBegin(self):
1189        """Scroll to left side of image."""
1190        self._view_main_topleft.scrollToBegin()

Scroll to left side of image.

@QtCore.pyqtSlot()
def scrollToEnd(self):
1192    @QtCore.pyqtSlot()
1193    def scrollToEnd(self):
1194        """Scroll to right side of image."""
1195        self._view_main_topleft.scrollToEnd()

Scroll to right side of image.

@QtCore.pyqtSlot()
def centerView(self):
1197    @QtCore.pyqtSlot()
1198    def centerView(self):
1199        """Center image in view."""
1200        self._view_main_topleft.centerView()

Center image in view.

@QtCore.pyqtSlot(bool)
def enableScrollBars(self, enable):
1202    @QtCore.pyqtSlot(bool)
1203    def enableScrollBars(self, enable):
1204        """Set visiblility of the view's scrollbars.
1205
1206        :param bool enable: True to enable the scrollbars """
1207        self._view_main_topleft.enableScrollBars(enable)

Set visiblility of the view's scrollbars.

Parameters
  • bool enable: True to enable the scrollbars
@QtCore.pyqtSlot(bool)
def enableHandDrag(self, enable):
1209    @QtCore.pyqtSlot(bool)
1210    def enableHandDrag(self, enable):
1211        """Set whether dragging the view with the hand cursor is allowed.
1212
1213        :param bool enable: True to enable hand dragging """
1214        self._view_main_topleft.enableHandDrag(enable)

Set whether dragging the view with the hand cursor is allowed.

Parameters
  • bool enable: True to enable hand dragging
@QtCore.pyqtSlot()
def zoomIn(self):
1216    @QtCore.pyqtSlot()
1217    def zoomIn(self):
1218        """Zoom in on image."""
1219        self.scaleImage(self._zoomFactorDelta)

Zoom in on image.

@QtCore.pyqtSlot()
def zoomOut(self):
1221    @QtCore.pyqtSlot()
1222    def zoomOut(self):
1223        """Zoom out on image."""
1224        self.scaleImage(1 / self._zoomFactorDelta)

Zoom out on image.

@QtCore.pyqtSlot()
def actualSize(self):
1226    @QtCore.pyqtSlot()
1227    def actualSize(self):
1228        """Change zoom to show image at actual size.
1229
1230        (image pixel is equal to screen pixel)"""
1231        self.scaleImage(1.0, combine=False)

Change zoom to show image at actual size.

(image pixel is equal to screen pixel)

@QtCore.pyqtSlot()
def fitToWindow(self):
1233    @QtCore.pyqtSlot()
1234    def fitToWindow(self):
1235        """Fit image within view.
1236            
1237        If the viewport is wider than the main pixmap, then fit the pixmap to height; if the viewport is narrower, then fit the pixmap to width
1238        """
1239        if not self._pixmapItem_main_topleft.pixmap():
1240            return
1241
1242        padding_margin = 2 # Leaves visual gap between pixmap and border of viewport
1243        viewport_rect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1244                                                         -padding_margin, -padding_margin)
1245        aspect_ratio_viewport = viewport_rect.width()/viewport_rect.height()
1246        aspect_ratio_pixmap   = self._pixmapItem_main_topleft.pixmap().width()/self._pixmapItem_main_topleft.pixmap().height()
1247        if aspect_ratio_viewport > aspect_ratio_pixmap:
1248            self.fitHeight()
1249        else:
1250            self.fitWidth()
1251
1252        self.transformChanged.emit()

Fit image within view.

If the viewport is wider than the main pixmap, then fit the pixmap to height; if the viewport is narrower, then fit the pixmap to width

@QtCore.pyqtSlot()
def fitWidth(self):
1254    @QtCore.pyqtSlot()
1255    def fitWidth(self):
1256        """Fit image width to view width."""
1257        if not self._pixmapItem_main_topleft.pixmap():
1258            return
1259        padding_margin = 2
1260        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1261                                                         -padding_margin, -padding_margin)
1262        factor = viewRect.width() / self._pixmapItem_main_topleft.pixmap().width()
1263        self.scaleImage(factor, combine=False)
1264        self._view_main_topleft.centerView()

Fit image width to view width.

@QtCore.pyqtSlot()
def fitHeight(self):
1266    @QtCore.pyqtSlot()
1267    def fitHeight(self):
1268        """Fit image height to view height."""
1269        if not self._pixmapItem_main_topleft.pixmap():
1270            return
1271        padding_margin = 2
1272        viewRect = self._view_main_topleft.viewport().rect().adjusted(padding_margin, padding_margin,
1273                                                         -padding_margin, -padding_margin)
1274        factor = viewRect.height() / self._pixmapItem_main_topleft.pixmap().height()
1275        self.scaleImage(factor, combine=False)
1276        self._view_main_topleft.centerView()

Fit image height to view height.

def handleWheelNotches(self, notches):
1278    def handleWheelNotches(self, notches):
1279        """Handle wheel notch event from underlying |QGraphicsView|.
1280
1281        :param float notches: Mouse wheel notches"""
1282        self.scaleImage(self._zoomFactorDelta ** notches)

Handle wheel notch event from underlying |QGraphicsView|.

Parameters
  • float notches: Mouse wheel notches
def closeEvent(self, event):
1284    def closeEvent(self, event):
1285        """Overriden in order to disconnect scrollbar signals before
1286        closing.
1287
1288        :param QEvent event: instance of a |QEvent|
1289        
1290        If this isn't done Python crashes!"""
1291        #self.scrollChanged.disconnect() #doesn't prevent crash
1292        self.disconnectSbarSignals()
1293        
1294        self._scene_main_topleft.deleteLater()
1295        self._view_main_topleft.deleteLater()
1296        del self._pixmap_base_original
1297        
1298        self._scene_topright.deleteLater()
1299        self._view_topright.deleteLater()
1300        del self._pixmap_topright_original
1301        
1302        self._scene_bottomright.deleteLater()
1303        self._view_bottomright.deleteLater()
1304        del self._pixmap_bottomright_original
1305        
1306        self._scene_bottomleft.deleteLater()
1307        self._view_bottomleft.deleteLater()
1308        del self._pixmap_bottomleft_original
1309        
1310        super().closeEvent(event)
1311        gc.collect()
1312        self.became_closed.emit()

Overriden in order to disconnect scrollbar signals before closing.

Parameters
  • QEvent event: instance of a |QEvent|

If this isn't done Python crashes!

def scaleImage(self, factor, combine=True):
1314    def scaleImage(self, factor, combine=True):
1315        """Scale image by factor.
1316
1317        :param float factor: either new :attr:`zoomFactor` or amount to scale
1318                             current :attr:`zoomFactor`
1319
1320        :param bool combine: if ``True`` scales the current
1321                             :attr:`zoomFactor` by factor.  Otherwise
1322                             just sets :attr:`zoomFactor` to factor"""
1323        if not self._pixmapItem_main_topleft.pixmap():
1324            return
1325
1326        if combine:
1327            self.zoomFactor = self.zoomFactor * factor
1328        else:
1329            self.zoomFactor = factor
1330            
1331        self._view_main_topleft.checkTransformChanged()

Scale image by factor.

Parameters
def dumpTransform(self):
1333    def dumpTransform(self):
1334        """Dump view transform to stdout."""
1335        self._view_main_topleft.dumpTransform(self._view_main_topleft.transform(), " "*4)

Dump view transform to stdout.

def create_mouse_rect(self):
1338    def create_mouse_rect(self):
1339        """Create a red 1x1 outline at the pointer in the main scene.
1340        
1341        Indicates to the user the size and position of the pixel over which the mouse is hovering.
1342        Helps to understand the position of individual pixels and their scale at the current zoom.
1343        """
1344    
1345        pen = QtGui.QPen() 
1346        pen.setWidth(0.1)
1347        pen.setColor(QtCore.Qt.red)
1348        pen.setCapStyle(QtCore.Qt.SquareCap)
1349        pen.setJoinStyle(QtCore.Qt.MiterJoin)
1350            
1351        brush = QtGui.QBrush()
1352        brush.setColor(QtCore.Qt.transparent)
1353        
1354        self.mouse_rect_width = 1
1355        self.mouse_rect_height = 1
1356        
1357        self.mouse_rect_topleft = QtCore.QPointF(0,0)
1358        self.mouse_rect_bottomright = QtCore.QPointF(self.mouse_rect_width-0.01, self.mouse_rect_height-0.01)
1359        self.mouse_rect = QtCore.QRectF(self.mouse_rect_topleft, self.mouse_rect_bottomright)
1360        
1361        self.mouse_rect_scene_main_topleft = QtWidgets.QGraphicsRectItem(self.mouse_rect) # To add the same item in two scenes, you need to create two unique items
1362        
1363        self.mouse_rect_scene_main_topleft.setPos(0,0)
1364        
1365        self.mouse_rect_scene_main_topleft.setBrush(brush)
1366        self.mouse_rect_scene_main_topleft.setPen(pen)
1367        
1368        self._scene_main_topleft.addItem(self.mouse_rect_scene_main_topleft)

Create a red 1x1 outline at the pointer in the main scene.

Indicates to the user the size and position of the pixel over which the mouse is hovering. Helps to understand the position of individual pixels and their scale at the current zoom.

def set_mouse_rect_visible(self, boolean):
1370    def set_mouse_rect_visible(self, boolean):
1371        """Set the visibilty of the red 1x1 outline at the pointer in the main scene.
1372        
1373        Args:
1374            boolean (bool): True to show 1x1 outline; False to hide.
1375        """
1376        self.mouse_rect_scene_main_topleft.setVisible(boolean)

Set the visibilty of the red 1x1 outline at the pointer in the main scene.

Arguments:
  • boolean (bool): True to show 1x1 outline; False to hide.