├── .gitignore ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── 00_using_from_python.py ├── 01_using_from_python_without_class_reference.py ├── 02_processing_function.py └── 03_annotate.py ├── pimsviewer ├── __init__.py ├── dimension.py ├── dimension.ui ├── example_plugins.py ├── gui.py ├── imagewidget.py ├── mainwindow.ui ├── pims_image.py ├── plugins.py ├── scroll_message_box.py ├── tests │ ├── __init__.py │ ├── test_gui.py │ └── test_plugins.py ├── utils.py └── wrapped_reader.py ├── screenshot.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | *.pro 30 | *.log 31 | *.pyc 32 | *.so 33 | *~ 34 | *.swp 35 | 36 | MANIFEST 37 | 38 | #PyCharm 39 | .idea 40 | tags 41 | .directory 42 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - "3.6" 6 | - "3.7" 7 | 8 | install: 9 | # needed to get xvfb running? 10 | - sudo apt-get install -y libegl1-mesa libdbus-1-3 libxkbcommon-x11-0 11 | - conda update --yes conda 12 | # Append the conda-forge channel, instead of adding it. See: 13 | # https://github.com/conda-forge/conda-forge.github.io/issues/232) 14 | - conda config --append channels conda-forge 15 | - conda create -n testenv --yes $DEPS nose pip python=$TRAVIS_PYTHON_VERSION 16 | - source activate testenv 17 | - python -m pip install . 18 | 19 | before_install: 20 | - wget http://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh 21 | - chmod +x miniconda.sh 22 | - ./miniconda.sh -b -p /home/travis/mc 23 | - export PATH=/home/travis/mc/bin:$PATH 24 | 25 | script: 26 | # for running the GUI 27 | - xvfb-run --server-args="-screen 0 1024x768x24" nosetests --nologcapture 28 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Version 2.0 2 | 3 | * Pimsviewer is completely re-written from scratch, it is not backwards compatible 4 | * Performance is improved by using native PyQt canvases for drawing instead of Matplotlib 5 | * A file info dialog is added, displaying most information that PIMS can infer from the file 6 | * The plugin system is revamped and example plugins are added that are enabled by default, with the most notable one an Annotate plugin that can annotate positions saved to a CSV file from Trackpy 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Notice and Statement for the pimsviewer Project 2 | ========================================================= 3 | 4 | Copyright (c) 2016-2017 pimsviewer contributors 5 | https://github.com/soft-matter/pimsviewer 6 | All rights reserved 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | * Neither the name of the soft-matter organization nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 23 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include setup.py 3 | include README.md 4 | include LICENSE 5 | include images/* 6 | recursive-include pimsviewer *.ui 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pimsviewer 2 | [![Anaconda-Server Badge](https://anaconda.org/conda-forge/pimsviewer/badges/version.svg)](https://anaconda.org/conda-forge/pimsviewer) 3 | 4 | A graphical user interface (GUI) for PIMS (screenshot below) 5 | 6 | This viewer is based on [PyQt5](https://www.riverbankcomputing.com/software/pyqt/intro) and is able to work with N-dimensional image files that are opened by PIMS. 7 | 8 | Also, it has a plugin infrastructure that can be used to extend the main functionality. 9 | 10 | ## Installation 11 | 12 | Pimsviewer can be installed using conda: 13 | 14 | ``` 15 | conda install -c conda-forge pimsviewer 16 | ``` 17 | 18 | Alternatively, it can also be installed using pip: 19 | 20 | ``` 21 | pip install pimsviewer 22 | ``` 23 | 24 | When installing the latest source version, always install it with `pip` (and not with `python setup.py develop`, this will lead to dependency errors for `PyQt`): 25 | 26 | Normal installation: 27 | 28 | ``` 29 | pip install . 30 | ``` 31 | 32 | Development installation: 33 | 34 | ``` 35 | pip install . -e 36 | ``` 37 | 38 | ## Starting the viewer 39 | 40 | After installing the viewer, an executable `pimsviewer` is available. Simply run the command via your terminal/command line interface. 41 | 42 | ``` 43 | $ pimsviewer --help 44 | Usage: pimsviewer [OPTIONS] [FILEPATH] 45 | Options: 46 | --example-plugins / --no-example-plugins 47 | Load additional example plugins 48 | --help Show this message and exit. 49 | ``` 50 | 51 | ## Screenshot 52 | 53 | ![Screenshot](/screenshot.png?raw=true) 54 | 55 | ## Examples 56 | 57 | All examples below are also available as script files in the `examples` folder. 58 | By running `pimsviewer --example-plugins`, you can preview the example plugins used below. 59 | 60 | ## Example 00: Using the viewer from Python 61 | 62 | You can use the viewer in a Python script as follows: 63 | 64 | ``` 65 | import sys 66 | from pimsviewer import GUI 67 | from PyQt5.QtWidgets import QApplication 68 | 69 | filepath = 'path/to/file' 70 | 71 | # Class names of extra plugins to add 72 | plugins = [] 73 | 74 | app = QApplication(sys.argv) 75 | gui = GUI(extra_plugins=plugins) 76 | gui.open(fileName=filepath) 77 | gui.show() 78 | 79 | sys.exit(app.exec_()) 80 | ``` 81 | 82 | ## Example 01: Using the viewer from Python (the shorter way) 83 | 84 | Or, if you do not need a reference to the actual object but you just want to start the program: 85 | 86 | ``` 87 | from pimsviewer import run 88 | 89 | run('path/to/file') 90 | ``` 91 | 92 | In both cases, you can omit the file path. 93 | 94 | ## Example 02: evaluating the effect of a processing function 95 | 96 | This example adds a processing function that adds an adjustable amount of noise 97 | to an image. The amount of noise is tunable with a slider. 98 | 99 | ``` 100 | from pimsviewer import run 101 | from pimsviewer.plugins import ProcessingPlugin 102 | 103 | run('path/to/file', [ProcessingPlugin]) 104 | ``` 105 | 106 | ## Example 03: annotating features on a video 107 | 108 | This example annotates features that were obtained via trackpy onto a video. 109 | Tracked positions are loaded from a pandas DataFrame CSV file by the user. 110 | 111 | ``` 112 | from pimsviewer import run 113 | from pimsviewer.plugins import AnnotatePlugin 114 | 115 | run('path/to/file', [AnnotatePlugin]) 116 | ``` 117 | 118 | ## Your own plugin? 119 | 120 | By looking at the code for the example plugins, it should be fairly easy to 121 | extend pimsviewer using your own plugins. Contact one of the maintainers if you 122 | have any trouble writing your own plugins. 123 | 124 | # Authors 125 | 126 | Pimsviewer version 1.0 was written by [Casper van der Wel](https://github.com/caspervdw), versions starting from 2.0 are written by [Ruben Verweij](https://github.com/rbnvrw). 127 | -------------------------------------------------------------------------------- /examples/00_using_from_python.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pimsviewer import GUI 3 | from PyQt5.QtWidgets import QApplication 4 | 5 | filepath = 'path/to/file' 6 | 7 | # Class names of extra plugins to add 8 | plugins = [] 9 | 10 | app = QApplication(sys.argv) 11 | gui = GUI(extra_plugins=plugins) 12 | gui.open(fileName=filepath) 13 | gui.show() 14 | 15 | sys.exit(app.exec_()) 16 | -------------------------------------------------------------------------------- /examples/01_using_from_python_without_class_reference.py: -------------------------------------------------------------------------------- 1 | from pimsviewer import run 2 | 3 | run('path/to/file') 4 | -------------------------------------------------------------------------------- /examples/02_processing_function.py: -------------------------------------------------------------------------------- 1 | from pimsviewer import run 2 | from pimsviewer.example_plugins import ProcessingPlugin 3 | 4 | run('path/to/file', [ProcessingPlugin]) 5 | -------------------------------------------------------------------------------- /examples/03_annotate.py: -------------------------------------------------------------------------------- 1 | from pimsviewer import run 2 | from pimsviewer.example_plugins import AnnotatePlugin 3 | 4 | run('path/to/file', [AnnotatePlugin]) 5 | -------------------------------------------------------------------------------- /pimsviewer/__init__.py: -------------------------------------------------------------------------------- 1 | name = 'pimsviewer' 2 | 3 | from .gui import GUI, run 4 | -------------------------------------------------------------------------------- /pimsviewer/dimension.py: -------------------------------------------------------------------------------- 1 | import os 2 | import numpy as np 3 | from PyQt5 import uic 4 | from PyQt5.QtCore import QDir, Qt, QTimer, pyqtSignal 5 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap 6 | from PyQt5.QtWidgets import (QHBoxLayout, QSlider, QWidget, QAction, QApplication, QFileDialog, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy, QStatusBar, QVBoxLayout, QDockWidget, QPushButton, QStyle, QLineEdit, QCheckBox, QInputDialog) 7 | 8 | class Dimension(QWidget): 9 | 10 | _playing = False 11 | _size = 0 12 | _position = 0 13 | _mergeable = False 14 | _merge = False 15 | _playable = False 16 | _fps = 5.0 17 | _max_playback_fps = 5.0 18 | 19 | play_event = pyqtSignal(QWidget) 20 | 21 | def __init__(self, name, size=0): 22 | super(Dimension, self).__init__() 23 | 24 | self.name = name 25 | self._size = size 26 | 27 | dirname = os.path.dirname(os.path.realpath(__file__)) 28 | uic.loadUi(os.path.join(dirname, 'dimension.ui'), self) 29 | 30 | self.playButton.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) 31 | self.playButton.clicked.connect(self.click_event) 32 | 33 | self.playTimer = QTimer() 34 | self.playTimer.timeout.connect(self.play_tick) 35 | 36 | self.posButton.pressed.connect(self.update_position_from_btn) 37 | 38 | self.slider.setMaximum(self.size-1) 39 | self.slider.valueChanged.connect(self.update_position_from_slider) 40 | 41 | self.mergeButton.clicked.connect(self.update_merge) 42 | 43 | if not self.mergeable: 44 | self.mergeButton.hide() 45 | 46 | self._merge = self.mergeButton.isChecked() 47 | 48 | self.fps = self._fps 49 | self.fpsButton.pressed.connect(self.fps_changed) 50 | 51 | self.hide() 52 | 53 | def merge_image_over_dimension(self, image): 54 | # problem here: could be two axes with same size 55 | # TODO: think of a clever fix for this 56 | try: 57 | ix = image.shape.index(self._size) 58 | except ValueError: 59 | return image 60 | 61 | if self.name != 'c': 62 | # I don't know what to do, sum over axis 63 | image = np.sum(image, axis=ix) 64 | 65 | return image 66 | 67 | def enable(self): 68 | if not self.playable: 69 | return 70 | 71 | self.setEnabled(True) 72 | self.playButton.setEnabled(True) 73 | self.posButton.setEnabled(True) 74 | self.slider.setEnabled(True) 75 | self.fpsButton.setEnabled(True) 76 | 77 | if self.mergeable: 78 | self.mergeButton.setEnabled(True) 79 | self.mergeButton.show() 80 | 81 | self.show() 82 | 83 | def disable(self): 84 | self.setEnabled(False) 85 | self.playButton.setEnabled(False) 86 | self.posButton.setEnabled(False) 87 | self.slider.setEnabled(False) 88 | self.fpsButton.setEnabled(False) 89 | self.mergeButton.setEnabled(False) 90 | 91 | def fps_changed(self): 92 | fps, ok = QInputDialog.getDouble(self, "Playback framerate", "New playback framerate", self.fps) 93 | 94 | if ok: 95 | self.fps = fps 96 | 97 | def click_event(self): 98 | if not self.playable: 99 | return 100 | 101 | if not self.playing: 102 | self.playing = True 103 | else: 104 | self.playing = False 105 | 106 | def play_tick(self): 107 | if not self.playing: 108 | return 109 | 110 | if self._fps > self._max_playback_fps: 111 | self.position += int(round(self._fps / self._max_playback_fps)) 112 | else: 113 | self.position += 1 114 | 115 | @property 116 | def size(self): 117 | return self._size 118 | 119 | @size.setter 120 | def size(self, size): 121 | self._size = size 122 | self.position = 0 123 | self.playing = False 124 | self.slider.setMinimum(0) 125 | self.slider.setMaximum(self.size-1) 126 | 127 | @property 128 | def fps(self): 129 | return self._fps 130 | 131 | @fps.setter 132 | def fps(self, fps): 133 | fps = float(fps) 134 | 135 | self._fps = fps 136 | play_fps = fps if fps < self._max_playback_fps else self._max_playback_fps 137 | self.playTimer.setInterval(int(round(1000.0 / play_fps))) 138 | self.fpsButton.setText('%d fps' % self.fps) 139 | 140 | @property 141 | def playable(self): 142 | return self._playable 143 | 144 | @playable.setter 145 | def playable(self, playable): 146 | self._playable = bool(playable) 147 | 148 | @property 149 | def playing(self): 150 | return self._playing 151 | 152 | @playing.setter 153 | def playing(self, playing): 154 | self._playing = bool(playing) 155 | if self._playing: 156 | self.playTimer.start() 157 | else: 158 | self.playTimer.stop() 159 | 160 | @property 161 | def position(self): 162 | return self._position 163 | 164 | def update_position_from_slider(self): 165 | position = self.slider.value() 166 | if position >= 0: 167 | self.position = position 168 | 169 | def update_position_from_btn(self): 170 | position, ok = QInputDialog.getInt(self, "'%s' position" % self.name, "New '%s' position (0-%d)" % (self.name, self.size-1), self.position, 0, self.size-1) 171 | 172 | if ok: 173 | self.position = position 174 | 175 | @position.setter 176 | def position(self, position): 177 | old_position = self.position 178 | 179 | while position < 0: 180 | position += self.size 181 | 182 | if position < self.size: 183 | self._position = position 184 | else: 185 | self._position = position - self.size 186 | 187 | self.slider.setValue(self.position) 188 | self.posButton.setText('%s=%d' % (self.name, self.position)) 189 | 190 | if old_position != self.position: 191 | self.play_event.emit(self) 192 | 193 | def update_merge(self): 194 | self.merge = self.mergeButton.isChecked() 195 | 196 | @property 197 | def merge(self): 198 | return self._merge 199 | 200 | @merge.setter 201 | def merge(self, merge): 202 | if not self.mergeable: 203 | merge = False 204 | 205 | if merge != self._merge: 206 | self._merge = bool(merge) 207 | self.mergeButton.setChecked(self._merge) 208 | self.play_event.emit(self) 209 | 210 | @property 211 | def mergeable(self): 212 | return self._mergeable 213 | 214 | @mergeable.setter 215 | def mergeable(self, mergeable): 216 | self._mergeable = bool(mergeable) 217 | if not mergeable: 218 | self.merge = False 219 | 220 | def __len__(self): 221 | return self.size 222 | 223 | def __str__(self): 224 | classname = self.__class__.__name__ 225 | playing = "playing" if self.playing else "not playing" 226 | return "<%s %s of length %d (%s)>" % (classname, self.name, self.size, playing) 227 | 228 | def __repr__(self): 229 | return self.__str__() 230 | 231 | -------------------------------------------------------------------------------- /pimsviewer/dimension.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dimension 4 | 5 | 6 | false 7 | 8 | 9 | 10 | 1 11 | 1 12 | 13 | 14 | 15 | 16 | 17 | 18 | false 19 | 20 | 21 | 22 | 23 | 24 | true 25 | 26 | 27 | 28 | 29 | 30 | 31 | false 32 | 33 | 34 | t = 0 35 | 36 | 37 | 38 | 39 | 40 | 41 | false 42 | 43 | 44 | 50 45 | 46 | 47 | 0 48 | 49 | 50 | false 51 | 52 | 53 | Qt::Horizontal 54 | 55 | 56 | QSlider::TicksBelow 57 | 58 | 59 | 50 60 | 61 | 62 | 63 | 64 | 65 | 66 | false 67 | 68 | 69 | Merge 70 | 71 | 72 | true 73 | 74 | 75 | true 76 | 77 | 78 | 79 | 80 | 81 | 82 | 10 fps 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /pimsviewer/example_plugins.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections import deque 3 | from pims.display import to_rgb 4 | from os import path 5 | 6 | from PIL import Image, ImageQt 7 | from PyQt5.QtCore import QDir, Qt, QRectF 8 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap 9 | from PyQt5.QtWidgets import (QHBoxLayout, QSlider, QWidget, QAction, QApplication, QFileDialog, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy, QStatusBar, QVBoxLayout, QDockWidget, QPushButton, QStyle, QLineEdit, QDialog, QGraphicsEllipseItem, QCheckBox, QDoubleSpinBox) 10 | 11 | import pandas as pd 12 | 13 | from pimsviewer.utils import pixmap_from_array 14 | from pimsviewer.plugins import Plugin 15 | 16 | class AnnotatePlugin(Plugin): 17 | name = 'Annotate plugin' 18 | 19 | def __init__(self, parent=None, positions_df=None): 20 | super(AnnotatePlugin, self).__init__(parent) 21 | 22 | self.x_name = 'x' 23 | self.y_name = 'y' 24 | self.r_name = 'r' 25 | 26 | self.unit_scaling = None 27 | self.positions_df = positions_df 28 | 29 | self.vbox = QVBoxLayout() 30 | self.setLayout(self.vbox) 31 | 32 | self.label = QLabel('Annotate Plugin') 33 | self.description = QLabel('Loads trajectories from CSV files containing the columns frame,x,y(,r) and draws circles at the specified locations.') 34 | self.description.setWordWrap(True) 35 | self.vbox.addWidget(self.label) 36 | self.vbox.addWidget(self.description) 37 | 38 | self.browseBtn = QPushButton('Open trajectories...') 39 | self.browseBtn.clicked.connect(self.open) 40 | self.vbox.addWidget(self.browseBtn) 41 | 42 | self.scaleLabel = QLabel('Scale factor (units/px)') 43 | self.scaleInput = QDoubleSpinBox() 44 | self.scaleInput.setMinimum(0) 45 | self.scaleInput.setValue(1.0) 46 | self.scaleInput.setDecimals(5) 47 | self.scaleInput.setSingleStep(0.1) 48 | self.scaleInput.valueChanged.connect(self.set_unit_scaling) 49 | self.vbox.addWidget(self.scaleLabel) 50 | self.vbox.addWidget(self.scaleInput) 51 | 52 | self.swapXYSwitch = QCheckBox('Swap X and Y columns') 53 | self.swapXYSwitch.stateChanged.connect(self.swap_xy) 54 | self.swapXYSwitch.setChecked(False) 55 | self.vbox.addWidget(self.swapXYSwitch) 56 | 57 | self.items = [] 58 | 59 | def clearAll(self, image_widget): 60 | for item in self.items: 61 | image_widget.removeItemFromScene(item) 62 | 63 | self.items = [] 64 | 65 | def rect_from_xyr(self, x, y, r, scaleFactor): 66 | x_top_left = (x - r)*scaleFactor 67 | y_top_left = (y - r)*scaleFactor 68 | size = 2.0*r*scaleFactor 69 | return QRectF(x_top_left, y_top_left, size, size) 70 | 71 | def showFrame(self, image_widget, dimensions): 72 | if self.positions_df is None: 73 | return 74 | self.set_unit_scaling() 75 | 76 | scaleFactor = image_widget.scaleFactor 77 | self.clearAll(image_widget) 78 | frame_no = dimensions['t'].position 79 | 80 | selection = self.positions_df[self.positions_df['frame'] == frame_no] 81 | 82 | for i, row in selection.iterrows(): 83 | x = float(row[self.x_name]) * self.unit_scaling 84 | y = float(row[self.y_name]) * self.unit_scaling 85 | 86 | r = float(row[self.r_name]) * self.unit_scaling 87 | if np.isnan(r): 88 | r = 10.0 89 | 90 | ellipse = QGraphicsEllipseItem(self.rect_from_xyr(x, y, r, scaleFactor)) 91 | pen = ellipse.pen() 92 | pen.setWidth(2) 93 | pen.setColor(Qt.red) 94 | ellipse.setPen(pen) 95 | image_widget.addItemToScene(ellipse) 96 | self.items.append(ellipse) 97 | 98 | def swap_xy(self): 99 | if not self.swapXYSwitch.isChecked(): 100 | self.x_name = 'x' 101 | self.y_name = 'y' 102 | else: 103 | self.x_name = 'y' 104 | self.y_name = 'x' 105 | 106 | def set_unit_scaling(self): 107 | if self.app.reader and self.unit_scaling is None: 108 | try: 109 | self.unit_scaling = 1.0 / self.app.reader.metadata['pixel_microns'] 110 | except: 111 | self.unit_scaling = 1.0 112 | print('Warning: AnnotatePlugin: file type not implemented for automatic coordinate scaling') 113 | 114 | self.unit_scaling = self.scaleInput.value() 115 | 116 | def open(self): 117 | currentDir = QDir.currentPath() 118 | if self.app is not None: 119 | parentFile = self.app.filename 120 | currentDir = path.dirname(parentFile) 121 | 122 | fileName, _ = QFileDialog.getOpenFileName(self, "Open trajectories", currentDir) 123 | if fileName: 124 | try: 125 | self.positions_df = pd.read_csv(fileName) 126 | except Exception as exception: 127 | QMessageBox.critical(self, "Error", "Cannot load %s: %s" % (fileName, exception)) 128 | return 129 | 130 | if self.app is not None: 131 | self.app.refreshPlugins() 132 | 133 | class ProcessingPlugin(Plugin): 134 | name = 'Processing plugin (example)' 135 | noise_level = 50 136 | 137 | def __init__(self, parent=None): 138 | super(ProcessingPlugin, self).__init__(parent) 139 | 140 | self.vbox = QVBoxLayout() 141 | self.setLayout(self.vbox) 142 | 143 | self.vbox.addWidget(QLabel('Example processing plugin')) 144 | self.vbox.addWidget(QLabel('Add noise:')) 145 | 146 | self.slider = QSlider(Qt.Horizontal) 147 | self.slider.setMinimum(10) 148 | self.slider.setMaximum(100) 149 | self.slider.setValue(50) 150 | self.slider.setTickPosition(QSlider.TicksBelow) 151 | self.slider.setTickInterval(10) 152 | self.slider.valueChanged.connect(self.update_noise) 153 | self.vbox.addWidget(self.slider) 154 | 155 | def update_noise(self): 156 | self.noise_level = self.slider.value() 157 | self.showFrame(self.parent().imageView, self.parent().dimensions) 158 | 159 | def activate(self): 160 | super(ProcessingPlugin, self).activate() 161 | 162 | self.showFrame(self.parent().imageView, self.parent().dimensions) 163 | 164 | def showFrame(self, image_widget, dimensions): 165 | arr = self.parent().get_current_frame() 166 | 167 | arr = arr + np.random.random(arr.shape) * self.noise_level / 100 * arr.max() 168 | arr = arr / np.max(arr) 169 | 170 | arr = (arr * 255.0).astype(np.uint8) 171 | 172 | image = pixmap_from_array(arr) 173 | 174 | image_widget.setPixmap(image) 175 | 176 | -------------------------------------------------------------------------------- /pimsviewer/gui.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import sys 3 | import click 4 | from PyQt5 import uic 5 | from PyQt5.QtCore import QDir, Qt, QMimeData 6 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap, QImageWriter 7 | from PyQt5.QtWidgets import (QHBoxLayout, QSlider, QWidget, QAction, QApplication, QFileDialog, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy, QStatusBar, QVBoxLayout, QDockWidget, QPushButton, QStyle, QLineEdit) 8 | 9 | from pimsviewer.example_plugins import AnnotatePlugin, Plugin, ProcessingPlugin 10 | from pimsviewer.imagewidget import ImageWidget 11 | from pimsviewer.dimension import Dimension 12 | from pimsviewer.wrapped_reader import WrappedReader 13 | from pimsviewer.scroll_message_box import ScrollMessageBox 14 | from pimsviewer.utils import get_supported_extensions, get_all_files_in_dir 15 | import pims 16 | import numpy as np 17 | 18 | try: 19 | from nd2reader import ND2Reader # remove after pims update 20 | except: 21 | pass 22 | 23 | class GUI(QMainWindow): 24 | name = "Pimsviewer" 25 | 26 | def __init__(self, extra_plugins=[]): 27 | super(GUI, self).__init__() 28 | 29 | dirname = path.dirname(path.realpath(__file__)) 30 | uic.loadUi(path.join(dirname, 'mainwindow.ui'), self) 31 | 32 | self.setWindowTitle(self.name) 33 | self.setCentralWidget(self.imageView) 34 | self.setStatusBar(self.statusbar) 35 | 36 | self.imageView.hover_event.connect(self.image_hover_event) 37 | self.reader = None 38 | self.dimensions = {} 39 | self.filename = None 40 | 41 | self.init_dimensions() 42 | 43 | self.plugins = [] 44 | self.pluginActions = [] 45 | self.init_plugins(extra_plugins) 46 | 47 | def init_plugins(self, extra_plugins=[]): 48 | self.plugins = [] 49 | 50 | for plugin_name in extra_plugins: 51 | extra_plugin = plugin_name(parent=self) 52 | if isinstance(extra_plugin, Plugin): 53 | self.plugins.append(extra_plugin) 54 | 55 | for plugin in self.plugins: 56 | action = QAction(plugin.name, self, triggered=plugin.activate) 57 | self.pluginActions.append(action) 58 | self.menuPlugins.addAction(action) 59 | 60 | def add_to_dock(self, widget): 61 | self.dockLayout.addWidget(widget) 62 | 63 | def updateWindowTitle(self): 64 | title = self.name 65 | if self.filename is not None: 66 | title += ' - %s' % path.basename(self.filename) 67 | self.setWindowTitle(title) 68 | 69 | def updateActions(self): 70 | hasfile = self.reader is not None 71 | self.actionClose.setEnabled(hasfile) 72 | self.actionFile_information.setEnabled(hasfile) 73 | self.actionSave.setEnabled(hasfile) 74 | self.actionOpen_next.setEnabled(hasfile) 75 | self.actionOpen_previous.setEnabled(hasfile) 76 | self.actionCopy.setEnabled(hasfile) 77 | 78 | fitWidth = self.actionFit_width.isChecked() 79 | self.actionZoom_in.setEnabled(not fitWidth) 80 | self.actionZoom_out.setEnabled(not fitWidth) 81 | self.actionNormal_size.setEnabled(not fitWidth) 82 | 83 | if not fitWidth: 84 | self.actionZoom_in.setEnabled(True) 85 | self.actionZoom_out.setEnabled(True) 86 | 87 | def zoomIn(self): 88 | self.imageView.scaleImage(1.25) 89 | self.refreshPlugins() 90 | 91 | def zoomOut(self): 92 | self.imageView.scaleImage(0.8) 93 | self.refreshPlugins() 94 | 95 | def normalSize(self): 96 | self.imageView.scaleImage(1.0, absolute=True) 97 | self.refreshPlugins() 98 | 99 | def fitToWindow(self): 100 | fitToWindow = self.actionFit_width.isChecked() 101 | self.imageView.fitWindow = fitToWindow 102 | 103 | self.imageView.doResize() 104 | self.updateActions() 105 | 106 | self.refreshPlugins() 107 | 108 | def about(self): 109 | QMessageBox.about(self, "About Pimsviewer", 110 | "

Viewer for Python IMage Sequence (PIMS).

" + 111 | "

See the Pimsviewer Github page for more information.

") 112 | 113 | def export(self): 114 | allowed_formats = QImageWriter.supportedImageFormats() 115 | allowed_formats = [str(f.data(), encoding='utf-8') for f in allowed_formats] 116 | filter_string = 'Images (' 117 | for f in allowed_formats: 118 | filter_string += '*.%s ' % f 119 | filter_string = filter_string[:-1] + ')' 120 | 121 | fileName, _ = QFileDialog.getSaveFileName(self, "Export File", QDir.currentPath(), filter_string) 122 | if not fileName: 123 | return 124 | 125 | self.imageView.image.pixmap().save(fileName) 126 | self.statusbar.showMessage('Image exported to %s' % fileName) 127 | 128 | def show_file_info(self): 129 | items = [] 130 | 131 | # Metadata 132 | for prop in self.reader.metadata: 133 | html = '

%s:

%s

' % (prop, self.reader.metadata[prop]) 134 | items.append(html) 135 | 136 | try: 137 | html = '

Framerate:

%.3f

' % (self.reader.frame_rate) 138 | items.append(html) 139 | num_frames = self.reader.sizes['t'] 140 | html = '

Duration:

%.3f s

' % (num_frames / self.reader.frame_rate) 141 | items.append(html) 142 | except (AttributeError): 143 | html = '

Framerate:

Unknown

' 144 | items.append(html) 145 | num_frames = self.reader.sizes['t'] 146 | html = '

Duration:

%d frames

' % (num_frames) 147 | items.append(html) 148 | 149 | # File path 150 | html = '

Filename:

%s

' % (self.filename) 151 | items.append(html) 152 | 153 | # Reader 154 | if isinstance(self.reader, WrappedReader): 155 | reader_type = type(self.reader.reader).__name__ 156 | else: 157 | reader_type = type(self.reader).__name__ 158 | html = '

PIMS reader:

%s

%s

' % (reader_type, self.reader.__repr__()) 159 | items.append(html) 160 | 161 | ScrollMessageBox(items, parent=self) 162 | 163 | def open(self, checked=False, fileName=None): 164 | if self.reader is not None: 165 | self.close_file() 166 | 167 | if fileName is None: 168 | fileName, _ = QFileDialog.getOpenFileName(self, "Open File", QDir.currentPath()) 169 | 170 | if fileName: 171 | try: 172 | self.reader = WrappedReader(pims.open(fileName)) 173 | except: 174 | QMessageBox.critical(self, "Error", "Cannot load %s." % fileName) 175 | return 176 | 177 | self.filename = fileName 178 | self.update_dimensions() 179 | self.showFrame() 180 | 181 | self.actionFit_width.setEnabled(True) 182 | self.updateActions() 183 | self.updateWindowTitle() 184 | 185 | def open_next_prev(self): 186 | direction_next = True 187 | if self.sender().objectName() == "actionOpen_previous": 188 | direction_next = False 189 | 190 | supported_extensions = get_supported_extensions() 191 | 192 | current_directory = path.dirname(self.filename) 193 | file_list = get_all_files_in_dir(current_directory, extensions=supported_extensions) 194 | if len(file_list) < 2: 195 | self.statusbar.showMessage('No file found for opening') 196 | return 197 | 198 | try: 199 | current_file_index = file_list.index(path.basename(self.filename)) 200 | except ValueError: 201 | self.statusbar.showMessage('No file found for opening') 202 | return 203 | 204 | next_index = current_file_index + 1 if direction_next else current_file_index - 1 205 | try: 206 | next_file = file_list[next_index] 207 | except IndexError: 208 | next_index = 0 if direction_next else -1 209 | next_file = file_list[next_index] 210 | 211 | self.open(fileName=path.join(current_directory, next_file)) 212 | 213 | def copy_image_to_clipboard(self): 214 | data = QMimeData() 215 | data.setImageData(self.imageView.image.pixmap()) 216 | app = QApplication.instance() 217 | app.clipboard().setMimeData(data) 218 | 219 | def close_file(self): 220 | self.reader.close() 221 | self.reader = None 222 | self.filename = None 223 | self.showFrame() 224 | self.updateWindowTitle() 225 | 226 | def init_dimensions(self): 227 | for dim in 'tvzcxy': 228 | self.dimensions[dim] = Dimension(dim, 0) 229 | self.dimensions[dim].play_event.connect(self.play_event) 230 | if dim not in ['x', 'y']: 231 | self.add_to_dock(self.dimensions[dim]) 232 | self.dimensions[dim].playable = True 233 | if dim in ['c', 'z', 'v']: 234 | self.dimensions[dim].mergeable = True 235 | else: 236 | self.dimensions[dim].mergeable = False 237 | self.dimensions[dim].merge = False 238 | 239 | def play_event(self, dimension): 240 | if not self.reader: 241 | return 242 | 243 | if len(self.reader.iter_axes) > 0: 244 | current_player = self.reader.iter_axes[0] 245 | if dimension.name != current_player: 246 | self.dimensions[current_player].playing = False 247 | 248 | self.reader.iter_axes = dimension.name 249 | self.showFrame() 250 | 251 | def image_hover_event(self, point): 252 | self.statusbar.showMessage('[%.1f, %.1f]' % (point.x(), point.y())) 253 | 254 | def update_dimensions(self): 255 | sizes = self.reader.sizes 256 | 257 | for dim in self.dimensions: 258 | if dim in sizes and sizes[dim] > 1: 259 | self.dimensions[dim].size = sizes[dim] 260 | self.dimensions[dim].enable() 261 | else: 262 | if dim in sizes: 263 | self.dimensions[dim].size = sizes[dim] 264 | else: 265 | self.dimensions[dim].size = 0 266 | 267 | self.dimensions[dim].disable() 268 | self.dimensions[dim].hide() 269 | 270 | # current playing axis 271 | self.reader.iter_axes = '' 272 | 273 | bundle_axes = '' 274 | if 'y' in self.reader.sizes: 275 | bundle_axes += 'y' 276 | if 'x' in self.reader.sizes: 277 | bundle_axes += 'x' 278 | 279 | self.reader.bundle_axes = bundle_axes 280 | 281 | if 't' in self.dimensions: 282 | try: 283 | self.dimensions['t'].fps = self.reader.frame_rate 284 | except AttributeError: 285 | self.statusbar.showMessage('Unable to read frame rate from file') 286 | 287 | def get_current_frame(self): 288 | self.reader.bundle_axes = 'xy' 289 | default_coords = {} 290 | 291 | for dim in self.dimensions: 292 | if dim not in self.reader.sizes: 293 | continue 294 | 295 | dim_obj = self.dimensions[dim] 296 | 297 | default_coords[dim] = dim_obj.position 298 | 299 | if dim_obj.merge and dim not in self.reader.bundle_axes: 300 | self.reader.bundle_axes += dim 301 | 302 | self.reader.default_coords = default_coords 303 | 304 | # always one playing axis at a time 305 | if len(self.reader.iter_axes) > 1: 306 | self.reader.iter_axes = self.reader.iter_axes[0] 307 | 308 | if len(self.reader.iter_axes) > 0: 309 | try: 310 | dim_obj = self.dimensions[self.reader.iter_axes[0]] 311 | i = dim_obj.position 312 | except KeyError: 313 | i = 0 314 | else: 315 | i = 0 316 | 317 | try: 318 | frame = self.reader[i] 319 | except IndexError: 320 | self.statusbar.showMessage('Unable to find %s=%d' % (dim_obj.name, i)) 321 | frame = self.reader[0] 322 | 323 | if 'c' in self.reader.bundle_axes: 324 | cix = self.reader.bundle_axes.index('c') 325 | if cix != 0: 326 | frame = np.swapaxes(frame, 0, cix) 327 | frame = np.swapaxes(frame, 1, 2).copy() 328 | 329 | return frame 330 | 331 | def refreshPlugins(self): 332 | for plugin in self.plugins: 333 | if plugin.active: 334 | plugin.showFrame(self.imageView, self.dimensions) 335 | 336 | def showFrame(self): 337 | if self.reader is None: 338 | self.imageView.setPixmap(None) 339 | return 340 | 341 | if len(self.dimensions) == 0: 342 | self.update_dimensions() 343 | 344 | image_data = self.get_current_frame() 345 | for bdim in self.reader.bundle_axes: 346 | if bdim in ['x', 'y']: 347 | continue 348 | image_data = self.dimensions[bdim].merge_image_over_dimension(image_data) 349 | 350 | self.imageView.setPixmap(image_data) 351 | self.refreshPlugins() 352 | 353 | @click.command() 354 | @click.argument('filepath', required=False, type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True)) 355 | @click.option('--example-plugins/--no-example-plugins', default=True, help='Load additional example plugins') 356 | def run(filepath, example_plugins): 357 | app = QApplication(sys.argv) 358 | 359 | if example_plugins: 360 | extra_plugins = [AnnotatePlugin, ProcessingPlugin] 361 | else: 362 | extra_plugins = [] 363 | 364 | gui = GUI(extra_plugins=extra_plugins) 365 | if filepath is not None: 366 | gui.open(fileName=filepath) 367 | gui.show() 368 | 369 | sys.exit(app.exec_()) 370 | 371 | if __name__ == '__main__': 372 | run() 373 | -------------------------------------------------------------------------------- /pimsviewer/imagewidget.py: -------------------------------------------------------------------------------- 1 | import pims 2 | import numpy as np 3 | from PyQt5.QtCore import QDir, Qt, QSize, QRectF, pyqtSignal, QPointF 4 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap 5 | from PyQt5.QtWidgets import (QAction, QApplication, QFileDialog, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy, QGraphicsView, QGraphicsScene) 6 | 7 | from pimsviewer.pims_image import PimsImage 8 | from pimsviewer.utils import image_to_pixmap 9 | 10 | class ImageWidget(QGraphicsView): 11 | 12 | hover_event = pyqtSignal(QPointF) 13 | 14 | def __init__(self, parent=None): 15 | super(ImageWidget, self).__init__(parent) 16 | 17 | self.scene = QGraphicsScene(self) 18 | self.scene.setSceneRect(QRectF()) 19 | self.setScene(self.scene) 20 | 21 | self.image = PimsImage(self) 22 | self.scene.addItem(self.image) 23 | 24 | self.setDragMode(QGraphicsView.ScrollHandDrag) 25 | 26 | self.fitWindow = True 27 | 28 | self.doResize() 29 | 30 | def addItemToScene(self, item): 31 | self.scene.addItem(item) 32 | 33 | def removeItemFromScene(self, item): 34 | self.scene.removeItem(item) 35 | 36 | def setPixmap(self, pixmap): 37 | if pixmap is None: 38 | self.image.setVisible(False) 39 | return 40 | 41 | if isinstance(pixmap, QImage): 42 | pixmap = image_to_pixmap(image) 43 | 44 | if not self.image.isVisible(): 45 | self.image.setVisible(True) 46 | 47 | if not isinstance(pixmap, QPixmap): 48 | pixmap = self.image.array_to_pixmap(pixmap) 49 | 50 | self.image.setPixmap(pixmap) 51 | self.doResize() 52 | 53 | def resizeEvent(self, event): 54 | super(ImageWidget, self).resizeEvent(event) 55 | self.doResize() 56 | 57 | def doResize(self): 58 | self.scaleImage(1.0) 59 | 60 | def adjustSize(self): 61 | super(ImageWidget, self).adjustSize() 62 | self.doResize() 63 | 64 | def scaleImage(self, factor, absolute=False): 65 | if not absolute: 66 | factor = self.image.scale() * factor 67 | 68 | if not self.fitWindow: 69 | self.image.setScale(factor) 70 | else: 71 | self.fitInView(self.image, Qt.KeepAspectRatio) 72 | 73 | @property 74 | def scaleFactor(self): 75 | return self.image.scale() 76 | 77 | 78 | -------------------------------------------------------------------------------- /pimsviewer/mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 706 10 | 592 11 | 12 | 13 | 14 | Pimsviewer 15 | 16 | 17 | 18 | 19 | 1 20 | 1 21 | 22 | 23 | 24 | 25 | 100 26 | 100 27 | 28 | 29 | 30 | 31 | 32 | 33 | 0 34 | 0 35 | 706 36 | 29 37 | 38 | 39 | 40 | 41 | File 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | View 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Plugins 67 | 68 | 69 | 70 | 71 | Help 72 | 73 | 74 | 75 | 76 | 77 | Edit 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 1 92 | 1 93 | 94 | 95 | 96 | QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable 97 | 98 | 99 | 8 100 | 101 | 102 | 103 | 104 | 1 105 | 1 106 | 107 | 108 | 109 | 110 | 0 111 | 112 | 113 | 0 114 | 115 | 116 | 0 117 | 118 | 119 | 0 120 | 121 | 122 | 0 123 | 124 | 125 | 126 | 127 | 128 | 129 | Open 130 | 131 | 132 | Ctrl+O 133 | 134 | 135 | 136 | 137 | false 138 | 139 | 140 | Open next 141 | 142 | 143 | Alt+Shift+O 144 | 145 | 146 | 147 | 148 | false 149 | 150 | 151 | Open previous 152 | 153 | 154 | Ctrl+Shift+O 155 | 156 | 157 | 158 | 159 | Open with 160 | 161 | 162 | Alt+O 163 | 164 | 165 | 166 | 167 | Info 168 | 169 | 170 | 171 | 172 | false 173 | 174 | 175 | Close 176 | 177 | 178 | Ctrl+W 179 | 180 | 181 | 182 | 183 | false 184 | 185 | 186 | Zoom in 187 | 188 | 189 | + 190 | 191 | 192 | 193 | 194 | false 195 | 196 | 197 | Zoom out 198 | 199 | 200 | - 201 | 202 | 203 | 204 | 205 | false 206 | 207 | 208 | Normal size 209 | 210 | 211 | 0 212 | 213 | 214 | 215 | 216 | true 217 | 218 | 219 | true 220 | 221 | 222 | Fit width 223 | 224 | 225 | Ctrl+F 226 | 227 | 228 | 229 | 230 | About 231 | 232 | 233 | F1 234 | 235 | 236 | 237 | 238 | false 239 | 240 | 241 | Export 242 | 243 | 244 | Ctrl+S 245 | 246 | 247 | 248 | 249 | false 250 | 251 | 252 | File information 253 | 254 | 255 | Ctrl+I 256 | 257 | 258 | 259 | 260 | false 261 | 262 | 263 | Copy 264 | 265 | 266 | Ctrl+C 267 | 268 | 269 | 270 | 271 | Quit 272 | 273 | 274 | Ctrl+Q 275 | 276 | 277 | 278 | 279 | 280 | ImageWidget 281 | QGraphicsView 282 |
pimsviewer/imagewidget.h
283 |
284 |
285 | 286 | 287 | 288 | actionOpen 289 | triggered() 290 | MainWindow 291 | open() 292 | 293 | 294 | -1 295 | -1 296 | 297 | 298 | 399 299 | 299 300 | 301 | 302 | 303 | 304 | actionClose 305 | triggered() 306 | MainWindow 307 | close_file() 308 | 309 | 310 | -1 311 | -1 312 | 313 | 314 | 399 315 | 299 316 | 317 | 318 | 319 | 320 | actionQuit 321 | triggered() 322 | MainWindow 323 | close() 324 | 325 | 326 | -1 327 | -1 328 | 329 | 330 | 399 331 | 299 332 | 333 | 334 | 335 | 336 | actionAbout 337 | triggered() 338 | MainWindow 339 | about() 340 | 341 | 342 | -1 343 | -1 344 | 345 | 346 | 399 347 | 299 348 | 349 | 350 | 351 | 352 | actionFit_width 353 | triggered(bool) 354 | MainWindow 355 | fitToWindow(bool) 356 | 357 | 358 | -1 359 | -1 360 | 361 | 362 | 399 363 | 299 364 | 365 | 366 | 367 | 368 | actionNormal_size 369 | triggered() 370 | MainWindow 371 | normalSize() 372 | 373 | 374 | -1 375 | -1 376 | 377 | 378 | 399 379 | 299 380 | 381 | 382 | 383 | 384 | actionZoom_in 385 | triggered() 386 | MainWindow 387 | zoomIn() 388 | 389 | 390 | -1 391 | -1 392 | 393 | 394 | 399 395 | 299 396 | 397 | 398 | 399 | 400 | actionZoom_out 401 | triggered() 402 | MainWindow 403 | zoomOut() 404 | 405 | 406 | -1 407 | -1 408 | 409 | 410 | 399 411 | 299 412 | 413 | 414 | 415 | 416 | actionFile_information 417 | triggered() 418 | MainWindow 419 | show_file_info() 420 | 421 | 422 | -1 423 | -1 424 | 425 | 426 | 352 427 | 295 428 | 429 | 430 | 431 | 432 | actionSave 433 | triggered() 434 | MainWindow 435 | export() 436 | 437 | 438 | -1 439 | -1 440 | 441 | 442 | 352 443 | 295 444 | 445 | 446 | 447 | 448 | actionOpen_next 449 | triggered() 450 | MainWindow 451 | open_next_prev() 452 | 453 | 454 | -1 455 | -1 456 | 457 | 458 | 352 459 | 295 460 | 461 | 462 | 463 | 464 | actionOpen_previous 465 | triggered() 466 | MainWindow 467 | open_next_prev() 468 | 469 | 470 | -1 471 | -1 472 | 473 | 474 | 352 475 | 295 476 | 477 | 478 | 479 | 480 | actionCopy 481 | triggered() 482 | MainWindow 483 | copy_image_to_clipboard() 484 | 485 | 486 | -1 487 | -1 488 | 489 | 490 | 352 491 | 295 492 | 493 | 494 | 495 | 496 | 497 | open() 498 | open_next_prev() 499 | close_file() 500 | zoomIn() 501 | copy_image_to_clipboard() 502 | zoomOut() 503 | normalSize() 504 | fitToWindow(bool) 505 | about() 506 | 507 |
508 | -------------------------------------------------------------------------------- /pimsviewer/pims_image.py: -------------------------------------------------------------------------------- 1 | import pims 2 | import numpy as np 3 | from PyQt5.QtCore import QDir, Qt, QSize, QRect, pyqtSignal, QPointF 4 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap, QPen 5 | from PyQt5.QtWidgets import (QAction, QApplication, QFileDialog, QLabel, 6 | QMainWindow, QMenu, QMessageBox, QScrollArea, 7 | QSizePolicy, QGraphicsPixmapItem) 8 | 9 | from pimsviewer.utils import pixmap_from_array, image_to_pixmap 10 | 11 | 12 | class PimsImage(QGraphicsPixmapItem): 13 | 14 | def __init__(self, parent): 15 | super(PimsImage, self).__init__() 16 | 17 | self.setAcceptHoverEvents(True) 18 | 19 | self.parent = parent 20 | 21 | def hoverMoveEvent(self, event): 22 | self.parent.hover_event.emit(event.lastPos()) 23 | 24 | def array_to_pixmap(self, array): 25 | array = np.swapaxes(pims.to_rgb(array), 0, 1) 26 | 27 | image = pixmap_from_array(array) 28 | 29 | return image 30 | 31 | -------------------------------------------------------------------------------- /pimsviewer/plugins.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections import deque 3 | from pims.display import to_rgb 4 | from os import path 5 | 6 | from PIL import Image, ImageQt 7 | from PyQt5.QtCore import QDir, Qt, QRectF 8 | from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap 9 | from PyQt5.QtWidgets import (QHBoxLayout, QSlider, QWidget, QAction, QApplication, QFileDialog, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy, QStatusBar, QVBoxLayout, QDockWidget, QPushButton, QStyle, QLineEdit, QDialog, QGraphicsEllipseItem) 10 | 11 | import pandas as pd 12 | 13 | from pimsviewer.utils import pixmap_from_array 14 | 15 | class Plugin(QDialog): 16 | name = 'Plugin' 17 | _active = False 18 | 19 | def __init__(self, parent=None): 20 | super(Plugin, self).__init__(parent) 21 | self.app = parent 22 | 23 | def activate(self): 24 | self.active = True 25 | self.show() 26 | 27 | @property 28 | def active(self): 29 | return self._active 30 | 31 | @active.setter 32 | def active(self, active): 33 | self._active = bool(active) 34 | 35 | def showFrame(self, image_widget): 36 | pass 37 | -------------------------------------------------------------------------------- /pimsviewer/scroll_message_box.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import (QWidget, QDialog, QMessageBox, QScrollArea, QVBoxLayout, QLabel) 2 | 3 | class ScrollMessageBox(QMessageBox): 4 | def __init__(self, items, *args, **kwargs): 5 | super(ScrollMessageBox, self).__init__(*args, **kwargs) 6 | 7 | scroll = QScrollArea(self) 8 | scroll.setWidgetResizable(True) 9 | 10 | self.content = QWidget() 11 | 12 | scroll.setWidget(self.content) 13 | 14 | layout = QVBoxLayout(self.content) 15 | for item in items: 16 | layout.addWidget(QLabel(item, self)) 17 | 18 | self.layout().addWidget(scroll, 0, 0, 1, self.layout().columnCount()) 19 | self.setStyleSheet("QScrollArea{min-width:300 px; min-height: 400px}") 20 | self.show() 21 | -------------------------------------------------------------------------------- /pimsviewer/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-matter/pimsviewer/9263ece121a58a0504c6e4d319ec6e18d1bb460a/pimsviewer/tests/__init__.py -------------------------------------------------------------------------------- /pimsviewer/tests/test_gui.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | from PyQt5.QtTest import QTest 4 | from PyQt5.QtCore import Qt 5 | from PyQt5.QtWidgets import QApplication 6 | 7 | from pimsviewer.gui import GUI 8 | 9 | class GuiTest(unittest.TestCase): 10 | app = None 11 | qapp = None 12 | 13 | def setUp(self): 14 | self.qapp = QApplication(sys.argv) 15 | self.app = GUI() 16 | 17 | def tearDown(self): 18 | self.app.close() 19 | self.qapp.exit() 20 | 21 | def test_init(self): 22 | self.assertEqual(self.app.windowTitle(), self.app.name) 23 | 24 | if __name__ == "__main__": 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /pimsviewer/tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtWidgets import QApplication 3 | 4 | import unittest 5 | 6 | from pimsviewer.gui import GUI 7 | from pimsviewer.example_plugins import ProcessingPlugin, AnnotatePlugin 8 | 9 | class PluginsTest(unittest.TestCase): 10 | def test_add_plugins(self): 11 | app = QApplication(sys.argv) 12 | gui = GUI(extra_plugins=[ProcessingPlugin, AnnotatePlugin]) 13 | 14 | self.assertTrue(len(gui.plugins) > 1) 15 | 16 | has_processing_plugin = False 17 | has_annotate_plugin = False 18 | for p in gui.plugins: 19 | if isinstance(p, ProcessingPlugin): 20 | has_processing_plugin = True 21 | if isinstance(p, AnnotatePlugin): 22 | has_annotate_plugin = True 23 | 24 | self.assertTrue(has_processing_plugin) 25 | self.assertTrue(has_annotate_plugin) 26 | gui.close() 27 | app.exit() 28 | 29 | if __name__ == "__main__": 30 | unittest.main() 31 | -------------------------------------------------------------------------------- /pimsviewer/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function, 2 | unicode_literals) 3 | import functools 4 | import numpy as np 5 | import pims 6 | from pims import to_rgb, normalize 7 | from pims.base_frames import FramesSequence, FramesSequenceND 8 | from pims.utils.sort import natural_keys 9 | from itertools import chain 10 | from os import listdir, path 11 | from os.path import isfile, join 12 | 13 | from PIL import Image, ImageQt 14 | from PyQt5.QtCore import Qt 15 | from PyQt5.QtGui import QImage, QPixmap 16 | 17 | def memoize(obj): 18 | """Memoize the function call result""" 19 | cache = obj.cache = {} 20 | 21 | @functools.wraps(obj) 22 | def memoizer(*args, **kwargs): 23 | """Memoize by storing the results in a dict""" 24 | key = str(args) + str(kwargs) 25 | if key not in cache: 26 | cache[key] = obj(*args, **kwargs) 27 | return cache[key] 28 | 29 | return memoizer 30 | 31 | 32 | def recursive_subclasses(cls): 33 | "Return all subclasses (and their subclasses, etc.)." 34 | # Source: http://stackoverflow.com/a/3862957/1221924 35 | return (cls.__subclasses__() + 36 | [g for s in cls.__subclasses__() for g in recursive_subclasses(s)]) 37 | 38 | 39 | def drop_dot(s): 40 | if s.startswith('.'): 41 | return s[1:] 42 | else: 43 | return s 44 | 45 | 46 | @memoize 47 | def get_available_readers(): 48 | readers = set(chain(recursive_subclasses(FramesSequence), 49 | recursive_subclasses(FramesSequenceND))) 50 | readers = [cls for cls in readers if not hasattr(cls, 'no_reader')] 51 | return readers 52 | 53 | 54 | def get_supported_extensions(): 55 | # list all readers derived from the pims baseclasses 56 | all_handlers = chain(recursive_subclasses(FramesSequence), 57 | recursive_subclasses(FramesSequenceND)) 58 | # keep handlers that support the file ext. use set to avoid duplicates. 59 | extensions = set(ext for h in all_handlers for ext in map(drop_dot, h.class_exts())) 60 | 61 | return extensions 62 | 63 | def get_all_files_in_dir(directory, extensions=None): 64 | if extensions is None: 65 | file_list = [f for f in listdir(directory) if isfile(join(directory, f))] 66 | else: 67 | file_list = [f for f in listdir(directory) if isfile(join(directory, f)) 68 | and drop_dot(path.splitext(f)[1]) in extensions] 69 | 70 | return sorted(file_list, key=natural_keys) 71 | 72 | def pixmap_from_array(array): 73 | # Convert to image 74 | image = Image.fromarray(array) 75 | pixmap = ImageQt.toqpixmap(image) 76 | 77 | return pixmap 78 | 79 | def image_to_pixmap(image): 80 | flags = Qt.ImageConversionFlags(Qt.ColorOnly & Qt.DiffuseDither) 81 | pixmap = QPixmap.fromImage(image, flags) 82 | 83 | return pixmap 84 | -------------------------------------------------------------------------------- /pimsviewer/wrapped_reader.py: -------------------------------------------------------------------------------- 1 | from pims import FramesSequenceND 2 | import numpy as np 3 | 4 | class WrappedReader(object): 5 | def __init__(self, reader): 6 | super(WrappedReader, self).__init__() 7 | self.reader = reader 8 | 9 | self._fallback_sizes = {} 10 | self._fallback_axis_order = {} 11 | 12 | def __getattr__(self, attr): 13 | if hasattr(self.reader, attr): 14 | value = getattr(self.reader, attr) 15 | self.setattr_only_self(attr, value) 16 | return value 17 | else: 18 | return self.get_fallback_function(attr) 19 | 20 | def setattr_only_self(self, attr, value): 21 | self.__dict__[attr] = value 22 | 23 | def __setattr__(self, attr, value): 24 | self.setattr_only_self(attr, value) 25 | 26 | if attr not in ['reader', '_fallback_sizes', '_fallback_axis_order']: 27 | setattr(self.reader, attr, value) 28 | 29 | def get_fallback_function(self, attr): 30 | if attr == 'sizes': 31 | return self.fallback_sizes 32 | 33 | if attr == 'default_coords': 34 | return self.fallback_def_coords 35 | 36 | raise AttributeError("Attribute '%s' not found in WrappedReader" % attr) 37 | 38 | def __getitem__(self, key): 39 | if isinstance(self.reader, FramesSequenceND): 40 | return self.reader[key] 41 | else: 42 | # provide a fallback for the FramesSequenceND behaviour 43 | frame = self.reader[0] 44 | index_values = [] 45 | index_order = [] 46 | for dim in self.sizes: 47 | if dim == 't': 48 | continue 49 | 50 | if dim in self.bundle_axes: 51 | index_values.append(slice(None)) 52 | index_order.append(self.fallback_axis_order[dim]) 53 | elif dim in self.iter_axes: 54 | index_values.append(key) 55 | index_order.append(self.fallback_axis_order[dim]) 56 | else: 57 | index_values.append(self.fallback_def_coords[dim]) 58 | index_order.append(self.fallback_axis_order[dim]) 59 | 60 | index_order, index_values = (t for t in zip(*sorted(zip(index_order, index_values)))) 61 | 62 | return frame[index_values] 63 | 64 | def __len__(self): 65 | return len(self.reader) 66 | 67 | def __iter__(self): 68 | return iter(self.reader[:]) 69 | 70 | def __enter__(self): 71 | return self.reader 72 | 73 | def __exit__(self): 74 | self.close() 75 | 76 | def __repr__(self): 77 | return "<>" % str(self.reader) 78 | 79 | def close(self): 80 | try: 81 | self.reader.close() 82 | except AttributeError: 83 | return 84 | 85 | @property 86 | def fallback_axis_order(self): 87 | if len(self._fallback_axis_order) > 0: 88 | return self._fallback_axis_order 89 | _ = self.fallback_sizes 90 | # set inside fallback_sizes() 91 | return self._fallback_axis_order 92 | 93 | @property 94 | def fallback_sizes(self): 95 | if len(self._fallback_sizes) > 0: 96 | return self._fallback_sizes 97 | 98 | order = {} 99 | sizes = {} 100 | 101 | sizes['t'] = len(self.reader) 102 | 103 | frame_shape = self.reader.frame_shape 104 | to_process = np.arange(0, len(frame_shape), 1) 105 | 106 | if len(to_process) > 2: 107 | c_ix = np.argmin(frame_shape) 108 | sizes['c'] = frame_shape[c_ix] 109 | order['c'] = c_ix 110 | to_process = np.delete(to_process, c_ix) 111 | 112 | if len(to_process) == 3: 113 | sizes['z'] = frame_shape[-1] 114 | order['z'] = to_process[-1] 115 | to_process = np.delete(to_process, -1) 116 | 117 | sizes['y'] = frame_shape[0] 118 | order['y'] = 0 119 | sizes['x'] = frame_shape[1] 120 | order['x'] = 1 121 | 122 | self._fallback_sizes = sizes 123 | self._fallback_axis_order = order 124 | 125 | return sizes 126 | 127 | @property 128 | def fallback_def_coords(self): 129 | coords = self.fallback_sizes 130 | for dim in coords: 131 | coords[dim] = 0 132 | return coords 133 | 134 | @fallback_def_coords.setter 135 | def fallback_def_coords(self, value): 136 | pass 137 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-matter/pimsviewer/9263ece121a58a0504c6e4d319ec6e18d1bb460a/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | try: 5 | descr = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() 6 | except IOError: 7 | descr = '' 8 | 9 | try: 10 | from pypandoc import convert 11 | 12 | descr = convert(descr, 'rst', format='md') 13 | except ImportError: 14 | pass 15 | 16 | setup_parameters = dict( 17 | name="pimsviewer", 18 | version='2.0', 19 | description="Viewer for Python IMage Sequence (PIMS).", 20 | author="Ruben Verweij", 21 | author_email="ruben@lighthacking.nl", 22 | url="https://github.com/soft-matter/pimsviewer", 23 | install_requires=['click', 'pims', 'PyQt5>=5.13.1', 'pandas', 'numpy', 'Pillow'], 24 | python_requires='>=3.0', 25 | packages=['pimsviewer'], 26 | package_dir={'pimsviewer': 'pimsviewer'}, 27 | package_data={'': ['*.ui']}, 28 | long_description=descr, 29 | long_description_content_type="text/markdown", 30 | entry_points={ 31 | 'gui_scripts': [ 32 | 'pimsviewer=pimsviewer.gui:run', 33 | ], 34 | }, 35 | ) 36 | 37 | setup(**setup_parameters) 38 | --------------------------------------------------------------------------------