├── requirements-dev.txt ├── docs ├── czi-logo.png ├── nlr-crop.png ├── nlr-start.png ├── create-filter.png ├── nlr-opencv-ui.png ├── existing-filters.png ├── nlr-opencv-cam-ui.png ├── filter-creation-window.png ├── documentation.md ├── changelog.md ├── user_guide.md ├── camera-interface.md └── processing_engine.md ├── src └── napari_live_recording │ ├── processing_engine │ ├── image_filters │ │ └── __init__.py │ ├── testImage.jpg │ ├── __init__.py │ └── processing_gui.py │ ├── napari.yaml │ ├── _test │ ├── conftest.py │ ├── test_recording.py │ ├── test_ui.py │ └── test_settings.py │ ├── __init__.py │ ├── control │ ├── devices │ │ ├── __init__.py │ │ ├── micro_manager.py │ │ ├── opencv.py │ │ ├── interface.py │ │ └── pymicroscope.py │ ├── frame_buffer.py │ └── __init__.py │ ├── common │ └── __init__.py │ └── ui │ ├── __init__.py │ └── widgets.py ├── tox.ini ├── LICENSE ├── .gitignore ├── pyproject.toml ├── setup.cfg ├── .github └── workflows │ └── test_and_deploy.yaml └── README.md /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-qt -------------------------------------------------------------------------------- /docs/czi-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/czi-logo.png -------------------------------------------------------------------------------- /docs/nlr-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/nlr-crop.png -------------------------------------------------------------------------------- /docs/nlr-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/nlr-start.png -------------------------------------------------------------------------------- /docs/create-filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/create-filter.png -------------------------------------------------------------------------------- /docs/nlr-opencv-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/nlr-opencv-ui.png -------------------------------------------------------------------------------- /docs/existing-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/existing-filters.png -------------------------------------------------------------------------------- /docs/nlr-opencv-cam-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/nlr-opencv-cam-ui.png -------------------------------------------------------------------------------- /docs/filter-creation-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/docs/filter-creation-window.png -------------------------------------------------------------------------------- /src/napari_live_recording/processing_engine/image_filters/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | image_filtersPath = os.path.dirname(os.path.realpath(__file__)) 4 | 5 | -------------------------------------------------------------------------------- /src/napari_live_recording/processing_engine/testImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopoabramo/napari-live-recording/HEAD/src/napari_live_recording/processing_engine/testImage.jpg -------------------------------------------------------------------------------- /src/napari_live_recording/processing_engine/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | defaultImagePath = os.path.join( 4 | os.path.dirname(os.path.realpath(__file__)), "testImage.jpg" 5 | ) 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/documentation.md: -------------------------------------------------------------------------------- 1 | # napari-live-plugin documentation 2 | 3 | 1. [User guide](./user_guide.md) 4 | 2. [Create an interface](./camera-interface.md) 5 | 3. [Processing-Engine](./processing_engine.md) 6 | 4. [Changelog](./changelog.md) -------------------------------------------------------------------------------- /src/napari_live_recording/napari.yaml: -------------------------------------------------------------------------------- 1 | name: napari-live-recording 2 | schema_version: 0.1.0 3 | contributions: 4 | commands: 5 | - id: napari-live-recording.open 6 | title: Live recording 7 | python_name: napari_live_recording:NapariLiveRecording 8 | widgets: 9 | - command: napari-live-recording.open 10 | display_name: Live recording -------------------------------------------------------------------------------- /src/napari_live_recording/_test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from napari_live_recording import NapariLiveRecording 3 | 4 | @pytest.fixture 5 | def recording_widget(make_napari_viewer): 6 | return NapariLiveRecording(make_napari_viewer()) 7 | 8 | @pytest.fixture(autouse=True) 9 | def cleanup(recording_widget): 10 | """ Performs exit cleanup for the test suite.""" 11 | # we yield control immediatly to the test; 12 | yield 13 | 14 | # after the test is done, we perform cleanup 15 | widget : NapariLiveRecording = recording_widget 16 | widget.on_close_callback() 17 | 18 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 0 19 | assert len(list(widget.mainController.deviceControllers.keys())) == 0 20 | -------------------------------------------------------------------------------- /src/napari_live_recording/__init__.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget, QApplication 2 | from napari_live_recording.ui import ViewerAnchor 3 | from napari_live_recording.control import MainController 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from napari.viewer import Viewer 8 | 9 | class NapariLiveRecording(QWidget): 10 | def __init__(self, napari_viewer: "Viewer") -> None: 11 | super().__init__() 12 | self.app = QApplication.instance() 13 | self.mainController = MainController() 14 | self.anchor = ViewerAnchor(napari_viewer, self.mainController) 15 | self.setLayout(self.anchor.mainLayout) 16 | self.app.lastWindowClosed.connect(self.on_close_callback) 17 | 18 | def on_close_callback(self) -> None: 19 | self.anchor.cleanup() 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | requires = 4 | tox>=4 5 | envlist = py{39, 310, 311}-{linux,macos,windows} 6 | 7 | [gh-actions] 8 | python = 9 | 3.9: py39 10 | 3.10: py310 11 | 3.11: py311 12 | 13 | [gh-actions:env] 14 | PLATFORM = 15 | ubuntu-latest: linux 16 | macos-latest: macos 17 | windows-latest: windows 18 | 19 | [testenv] 20 | platform = 21 | macos: darwin 22 | linux: linux 23 | windows: win32 24 | passenv = 25 | CI 26 | GITHUB_ACTIONS 27 | DISPLAY 28 | XAUTHORITY 29 | NUMPY_EXPERIMENTAL_ARRAY_FUNCTION 30 | PYVISTA_OFF_SCREEN 31 | deps = 32 | pytest # https://docs.pytest.org/en/latest/contents.html 33 | pytest-cov # https://pytest-cov.readthedocs.io/en/latest/ 34 | pytest-xvfb ; sys_platform == 'linux' 35 | napari 36 | magicgui 37 | pytest-qt 38 | qtpy 39 | commands = pytest -v --color=yes --cov=napari_live_recording --cov-report=xml 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Jacopo Abramo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual Studio Code settings folder 2 | .vscode/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | venv/ 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | .napari_cache 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask instance folder 62 | instance/ 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # MkDocs documentation 68 | /site/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # IPython Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # OS 80 | .DS_Store 81 | 82 | # written by setuptools_scm 83 | */_version.py 84 | 85 | # debugging files 86 | __main__.py 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42.0.0", "wheel", "setuptools_scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "src/napari_live_recording/_version.py" 7 | 8 | [tool.black] 9 | line-length = 79 10 | target-version = ['py38', 'py39', 'py310'] 11 | 12 | [tool.ruff] 13 | line-length = 79 14 | select = [ 15 | "E", "F", "W", #flake8 16 | "UP", # pyupgrade 17 | "I", # isort 18 | "BLE", # flake8-blind-exception 19 | "B", # flake8-bugbear 20 | "A", # flake8-builtins 21 | "C4", # flake8-comprehensions 22 | "ISC", # flake8-implicit-str-concat 23 | "G", # flake8-logging-format 24 | "PIE", # flake8-pie 25 | "SIM", # flake8-simplify 26 | ] 27 | ignore = [ 28 | "E501", # line too long. let black handle this 29 | "UP006", "UP007", # type annotation. As using magicgui require runtime type annotation then we disable this. 30 | "SIM117", # flake8-simplify - some of merged with statements are not looking great with black, reanble after drop python 3.9 31 | ] 32 | 33 | exclude = [ 34 | ".bzr", 35 | ".direnv", 36 | ".eggs", 37 | ".git", 38 | ".mypy_cache", 39 | ".pants.d", 40 | ".ruff_cache", 41 | ".svn", 42 | ".tox", 43 | ".venv", 44 | "__pypackages__", 45 | "_build", 46 | "buck-out", 47 | "build", 48 | "dist", 49 | "node_modules", 50 | "venv", 51 | "*vendored*", 52 | "*_vendor*", 53 | ] -------------------------------------------------------------------------------- /src/napari_live_recording/control/devices/__init__.py: -------------------------------------------------------------------------------- 1 | from inspect import isclass, getmembers 2 | from pkgutil import iter_modules 3 | from pathlib import Path 4 | from importlib import import_module 5 | from .interface import ICamera 6 | 7 | package_dir = Path(__file__).resolve().parent 8 | devicesDict = {} 9 | 10 | # iterate through the modules of the devices module 11 | # in order to find all submodules containing the class definitions 12 | # of all cameras 13 | for (_, module_name, _) in iter_modules([str(package_dir)]): 14 | # import the modulte and iterate through the attributes 15 | try: 16 | # we skip the interface module 17 | if module_name != "interface": 18 | module = import_module(f"{__name__}.{module_name}") 19 | for attr in getmembers(module, isclass): 20 | # attr[0]: class name as string 21 | # attr[1]: class object 22 | if attr[1] != ICamera and issubclass(attr[1], ICamera): 23 | devicesDict[attr[0]] = attr[1] 24 | except ImportError as e: 25 | # This check is added to make sure that modules from cameras 26 | # which must be added manually (i.e. Ximea's APIs) do not 27 | # cause issues when loading the plugin. 28 | # The camera won't be visibile in the supported camera list 29 | # but the plugin will still be working as expected. 30 | # In case there are cameras which require external components, 31 | # remember to wrap them in a try-except snippet and raise an 32 | # ImportError exception if there is any missing package. 33 | raise TypeError(f"Importing of {module_name} failed. Check napari's traceback for more informations. Exception: {e}") -------------------------------------------------------------------------------- /src/napari_live_recording/_test/test_recording.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | import pytest 3 | 4 | if TYPE_CHECKING: 5 | from napari_live_recording import NapariLiveRecording 6 | 7 | def test_mmcore_live_recording(recording_widget, qtbot): 8 | widget : "NapariLiveRecording" = recording_widget 9 | 10 | qtbot.addWidget(widget) 11 | 12 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(1) # MicroManager 13 | widget.anchor.selectionWidget.adapterComboBox.combobox.setCurrentIndex(8) # DemoCamera 14 | widget.anchor.selectionWidget.deviceComboBox.combobox.setCurrentIndex(0) # DCam 15 | 16 | widget.anchor.selectionWidget.addButton.click() 17 | 18 | # live acquisition is timed via a local timer; 19 | # we monitor a single timeout event to ensure that 20 | # we have a new layer added 21 | events = [widget.anchor.recordingWidget.live.toggled, widget.anchor.liveTimer.timeout] 22 | 23 | with qtbot.waitSignals(events, timeout=3000): 24 | widget.anchor.recordingWidget.live.toggle() 25 | assert widget.mainController.isAcquiring == True 26 | 27 | widget.anchor.recordingWidget.live.toggle() 28 | assert widget.mainController.isAcquiring == False 29 | 30 | # the plugin when acquiring live produces a layer with the ID of the camera; 31 | # we can check if the layer is present or not 32 | layer = widget.anchor.viewer.layers["Live MyCamera:MicroManager:DemoCamera DCam"] 33 | assert layer is not None 34 | 35 | @pytest.mark.skip(reason="Adding test in future release") 36 | def test_mmcore_stack_recording(recording_widget, qtbot): 37 | widget : NapariLiveRecording = recording_widget 38 | qtbot.addWidget(widget) 39 | 40 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(1) # MicroManager 41 | widget.anchor.selectionWidget.adapterComboBox.combobox.setCurrentIndex(8) # DemoCamera 42 | widget.anchor.selectionWidget.deviceComboBox.combobox.setCurrentIndex(0) # DCam 43 | 44 | widget.anchor.selectionWidget.addButton.click() -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.8 4 | 5 | - Putting up again origina CI workflows 6 | 7 | ## 0.3.7 8 | 9 | - Fixed unit tests and added workflows for test code coverage and automatic upload on new release 10 | 11 | ## 0.3.6 12 | 13 | - Small clean-up to fix conda feedstock 14 | 15 | ## 0.3.5 16 | 17 | - Updated documentation 18 | - Merging processing engine features into main branch 19 | 20 | ## 0.3.4 21 | 22 | - Hotfix to statically create Micro-Manager device adapter's dictionary 23 | - When opening the plugin it took too much time to inspect the available adapters 24 | 25 | ## 0.3.3 26 | 27 | - Added python-microscope interface (@PiaPritzke) 28 | - Added unit tests 29 | 30 | ## 0.3.2 31 | 32 | - HOTFIX: Removed reference of tifffile enumerator `PHOTOMETRIC` (see #22) 33 | 34 | ## 0.3.1 35 | 36 | - Added missing reference to `pymmcore-widgets` from `setup.cfg` 37 | - Added exception print when initializing list of available camera interfaces 38 | 39 | ## 0.3.0 40 | 41 | - Full rework of the plugin architecture ad user interface (hopefully for the last time) 42 | - Removed old device interface documentation 43 | - Fixed issues #16, #17 44 | - Added MicroManager interface (@felixwanitschke) 45 | 46 | ## 0.2.1 47 | 48 | - Added documentation of new architecture 49 | 50 | ## 0.2.0 51 | 52 | - Full architecture rework 53 | - Deleted old documentation 54 | - Adapted plugin to be compatible with `npe2` 55 | 56 | ## 0.1.7 57 | 58 | - Added better support for OpenCV ROI handling 59 | - Added support for multiple pixel formats for OpenCV 60 | 61 | ## 0.1.6 62 | 63 | - Minor fixes 64 | 65 | ## 0.1.5 66 | 67 | - Added live frame per second count (still imprecise) 68 | - Added Album mode (each manually acquired image showed on same layer) 69 | - Fixed ROI handling with more meaningful syntax 70 | - Fixed some problems with Ximea camera 71 | - Changed special function interface 72 | 73 | ## 0.1.4 74 | 75 | - Added ROI handling 76 | 77 | ## 0.1.3 78 | 79 | - Added ReadTheDocs documentation. 80 | 81 | ## 0.1.2 82 | 83 | - Fixed Ximea package import causing plugin to crash 84 | - Added list of cameras in documentation 85 | - Changed abstract methods of ICameras by removing unnecessary exceptions 86 | 87 | ## 0.1.1 88 | 89 | - Added documentation 90 | - Generic fixes 91 | 92 | ## 0.1.0 93 | 94 | - First release -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = napari-live-recording 3 | version = 0.3.8 4 | author = "Jacopo Abramo, Pia Pritzke, Felix Wanitschke" 5 | author_email = jacopo.abramo@gmail.com 6 | url = https://github.com/jethro33/napari-live-recording 7 | license = MIT 8 | description = A napari plugin for live video recording with a generic camera device. 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Intended Audience :: Developers 14 | Intended Audience :: Science/Research 15 | Intended Audience :: Education 16 | Framework :: napari 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3.9 20 | Programming Language :: Python :: 3.10 21 | Programming Language :: Python :: 3.11 22 | Operating System :: OS Independent 23 | License :: OSI Approved :: MIT License 24 | Topic :: Scientific/Engineering :: Image Processing 25 | Topic :: Scientific/Engineering :: Visualization 26 | project_urls = 27 | Bug Tracker = https://github.com/jacopoabramo/napari-live-recording/issues 28 | Documentation = https://github.com/jacopoabramo/napari-live-recording#README.md 29 | Source Code = https://github.com/jacopoabramo/napari-live-recording 30 | User Support = https://github.com/jacopoabramo/napari-live-recording/issues 31 | 32 | [options] 33 | packages = find: 34 | include_package_data = True 35 | install_requires = 36 | superqt 37 | numpy 38 | opencv-python 39 | tifffile 40 | napari[all] 41 | qtpy 42 | microscope >= 0.7.0 43 | pims 44 | pyqtgraph 45 | pymmcore-plus >= 0.6.7 46 | pymmcore-widgets 47 | 48 | python_requires = >=3.9 49 | package_dir = 50 | =src 51 | setup_requires = 52 | setuptools-scm 53 | 54 | [options.packages.find] 55 | where = src 56 | 57 | [options.entry_points] 58 | napari.manifest = 59 | napari-live-recording = napari_live_recording:napari.yaml 60 | 61 | [options.extras_require] 62 | testing = 63 | tox 64 | pytest # https://docs.pytest.org/en/latest/contents.html 65 | pytest-cov # https://pytest-cov.readthedocs.io/en/latest/ 66 | pytest-qt # https://pytest-qt.readthedocs.io/en/latest/ 67 | napari 68 | pyqt5 69 | 70 | [options.package_data] 71 | napari-live-recording = napari.yaml -------------------------------------------------------------------------------- /docs/user_guide.md: -------------------------------------------------------------------------------- 1 | # napari-live-recording user guide 2 | 3 | ## Installation 4 | 5 | You can install `napari-live-recording` via [pip]. It is reccomended to install `napari-live-recording` in a virtual environment. This can be do so via: 6 | 7 | - [venv], for example: 8 | 9 | python -m venv nlr 10 | nlr\Scripts\activate 11 | pip install napari-live-recording 12 | 13 | - [conda] or [mamba] 14 | 15 | mamba create -n nlr python=3.10 napari-live-recording 16 | 17 | Alternatively, if you want to install the plugin using the source code, you can do so by cloning the project and installing locally: 18 | 19 | git clone https://github.com/jacopoabramo/napari-live-recording 20 | cd napari-live-recording 21 | pip install . 22 | 23 | ## Launching the plugin 24 | 25 | In the same environment you installed the plugin, lunch napari with the command: 26 | 27 | napari 28 | 29 | After napari is started, click on `Plugins > Live recording (napari-live-recording)`: 30 | 31 |

32 | 33 |

34 | 35 | After a few seconds, the live recording widget will appear on the right side of napari: 36 | 37 |

38 | 39 |

40 | 41 | The `Interface` widget allows you to chose the type of hardware interface to control. The `Camera name` widget allows you to customize the identifier associated with your camera. Multiple cameras can run at the same time, and are handled concurrently. 42 | 43 | > [!NOTE] 44 | > The Micro-Manager interface in the plugin was designed to handle only one camera. Developers did not test the occurance of generating two Micro-Manager cameras at the same time. For feedback please open an issue. 45 | 46 | Each interface provides a way to select a specific hardware device. This can be an numeric identifier (ID) or serial number (SN). For example, selecting the OpenCV interface will look as below: 47 | 48 |

49 | 50 |

51 | 52 | To add this OpenCV grabber, click on `Add camera`, adding the controls for your camera as shown below: 53 | 54 |

55 | 56 |

57 | 58 | If you wish to remove the camera from the current list of devices, you can also click on `Delete camera` to remove it. 59 | 60 | ## Pipeline filtering 61 | 62 | For more information on how to create image processing pipelines, see [here](./processing_engine.md). 63 | 64 | [venv]: https://docs.python.org/3/library/venv.html 65 | [mamba]: https://mamba.readthedocs.io/en/latest/user_guide/mamba.html#mamba 66 | [conda]: https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: ${{ matrix.platform }} py${{ matrix.python-version }} 6 | runs-on: ${{ matrix.platform }} 7 | strategy: 8 | matrix: 9 | platform: [windows-latest, macos-latest] 10 | python-version: ['3.9', '3.10', '3.11'] 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | # these libraries enable testing on Qt on linux 21 | - uses: tlambert03/setup-qt-libs@v1 22 | 23 | # strategy borrowed from vispy for installing opengl libs on windows 24 | - name: Install Windows OpenGL 25 | if: runner.os == 'Windows' 26 | run: | 27 | git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git 28 | powershell gl-ci-helpers/appveyor/install_opengl.ps1 29 | 30 | # note: if you need dependencies from conda, considering using 31 | # setup-miniconda: https://github.com/conda-incubator/setup-miniconda 32 | # and 33 | # tox-conda: https://github.com/tox-dev/tox-conda 34 | # pymmcore-plus cli enables the possibility to launch 35 | # the mmcore installer from command line 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install setuptools tox tox-gh-actions 40 | python -m pip install pymmcore-plus[cli] 41 | mmcore install 42 | # this runs the platform-specific tests declared in tox.ini 43 | - name: Test with tox 44 | uses: aganders3/headless-gui@v1 45 | with: 46 | run: python -m tox 47 | env: 48 | PLATFORM: ${{ matrix.platform }} 49 | 50 | - name: Coverage 51 | uses: codecov/codecov-action@v3 52 | env: 53 | CODECOV_TOKEN: ${{ secrets.CODECOV }} 54 | 55 | deploy: 56 | needs: [test] 57 | runs-on: ubuntu-latest 58 | if: contains(github.ref, 'tags') 59 | steps: 60 | - uses: actions/checkout@v3 61 | - name: Set up Python 62 | uses: actions/setup-python@v4 63 | with: 64 | python-version: "3.x" 65 | - name: Install dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | pip install -U setuptools setuptools_scm wheel twine build 69 | - name: Build and publish 70 | env: 71 | TWINE_USERNAME: __token__ 72 | TWINE_PASSWORD: ${{ secrets.TWINE_TOKEN }} 73 | run: | 74 | git tag 75 | python -m build . 76 | twine upload dist/* -------------------------------------------------------------------------------- /src/napari_live_recording/control/devices/micro_manager.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from contextlib import contextmanager 3 | from pymmcore_plus import CMMCorePlus 4 | from pymmcore_widgets._device_property_table import DevicePropertyTable 5 | from napari_live_recording.common import ROI 6 | from napari_live_recording.control.devices.interface import ICamera 7 | from typing import Union, Any 8 | 9 | 10 | class MicroManager(ICamera): 11 | def __init__(self, name: str, deviceID: Union[str, int]) -> None: 12 | """MMC-Core VideoCapture wrapper. 13 | 14 | Args: 15 | name (str): user-defined camera name. 16 | deviceID (Union[str, int]): camera identifier. 17 | """ 18 | self.__capture = CMMCorePlus.instance() 19 | moduleName, deviceName = deviceID.split(" ") 20 | self.__capture.loadDevice(name, moduleName, deviceName) 21 | self.__capture.initializeDevice(name) 22 | self.__capture.setCameraDevice(name) 23 | self.__capture.initializeCircularBuffer() 24 | self.name = name 25 | self.settingsWidget = DevicePropertyTable() 26 | self.settingsWidget.filterDevices("camera", include_read_only=False) 27 | 28 | # read MMC-Core parameters 29 | width = int(self.__capture.getImageWidth()) 30 | height = int(self.__capture.getImageHeight()) 31 | 32 | # initialize region of interest 33 | # steps for height, width and offsets 34 | # are by default 1. We leave them as such 35 | sensorShape = ROI(offset_x=0, offset_y=0, height=height, width=width) 36 | 37 | parameters = {} 38 | 39 | super().__init__(name, deviceID, parameters, sensorShape) 40 | 41 | def setAcquisitionStatus(self, started: bool) -> None: 42 | if started == True and self.__capture.isSequenceRunning() != True: 43 | self.__capture.startContinuousSequenceAcquisition() 44 | elif started == False: 45 | self.__capture.stopSequenceAcquisition() 46 | 47 | def grabFrame(self) -> np.ndarray: 48 | while self.__capture.getRemainingImageCount() == 0: 49 | pass 50 | try: 51 | rawImg = self.__capture.getLastImage() 52 | img = self.__capture.fixImage(rawImg) 53 | return img 54 | except: 55 | pass 56 | 57 | def changeParameter(self, name: str, value: Any) -> None: 58 | # parameters handled via a different widget 59 | pass 60 | 61 | def changeROI(self, newROI: ROI): 62 | try: 63 | with self.acquisitionSuspended(): 64 | self.__capture.setROI( 65 | self.name, 66 | newROI.offset_x, 67 | newROI.offset_y, 68 | newROI.width, 69 | newROI.height, 70 | ) 71 | if newROI <= self.fullShape: 72 | self.roiShape = newROI 73 | except Exception as e: 74 | print("ROI", e) 75 | 76 | def close(self) -> None: 77 | if self.__capture.isSequenceRunning(): 78 | self.setAcquisitionStatus(False) 79 | self.__capture.unloadDevice(self.name) 80 | 81 | @contextmanager 82 | def acquisitionSuspended(self): 83 | if self.__capture.isSequenceRunning(): 84 | try: 85 | self.setAcquisitionStatus(False) 86 | yield 87 | finally: 88 | self.setAcquisitionStatus(True) 89 | else: 90 | yield 91 | -------------------------------------------------------------------------------- /src/napari_live_recording/control/frame_buffer.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections import deque 3 | from napari_live_recording.common import ROI 4 | from napari_live_recording.control.devices.interface import ICamera 5 | from qtpy.QtCore import QObject, Signal 6 | 7 | 8 | class Framebuffer(QObject): 9 | """Class for a ring like buffer to store frames temporarily.""" 10 | 11 | # signal for ending the recording as soon as the required number of frames were added 12 | appendingFinished = Signal(str) 13 | 14 | def __init__( 15 | self, 16 | stackSize: int, 17 | camera: ICamera, 18 | cameraKey: str, 19 | capacity: int, 20 | allowOverwrite: bool = True, 21 | ) -> None: 22 | super().__init__() 23 | self.stackSize = stackSize 24 | self.cameraKey = cameraKey 25 | self._appendedFrames = 0 26 | self.allowOverwrite = allowOverwrite 27 | self.frameShape = camera.roiShape.pixelSizes 28 | self.buffer = deque(maxlen=capacity) 29 | 30 | def clearBuffer(self): 31 | """Clearing the buffer and resetting the appended frames to zero""" 32 | try: 33 | self._appendedFrames = 0 34 | self.buffer.clear() 35 | except Exception as e: 36 | print("Clearing Error", e) 37 | 38 | def addFrame(self, newFrame): 39 | """Method for attaching a new frame to the buffer.""" 40 | try: 41 | # if required number of frames is reached and not toggled recording 42 | if self._appendedFrames == self.stackSize and not self.allowOverwrite: 43 | self.appendingFinished.emit(self.cameraKey) 44 | 45 | # when shapes of frames in buffer and new frame match, attach the frame and raise number of appended frames 46 | elif newFrame.shape == self.frameShape: 47 | self.buffer.appendleft(newFrame) 48 | if not self.allowOverwrite: 49 | self._appendedFrames += 1 50 | 51 | # when shapes do not match, set the new shape as default 52 | elif newFrame.shape != self.frameShape: 53 | self.frameShape = newFrame.shape 54 | except Exception as e: 55 | pass 56 | 57 | def popHead(self): 58 | """Return and delete the head (oldest frame) of the buffer""" 59 | try: 60 | frame = np.copy(self.buffer.pop()) 61 | return frame 62 | except Exception as e: 63 | pass 64 | 65 | def popTail(self): 66 | """Return and delete the tail (newest frame) of the buffer""" 67 | try: 68 | frame = np.copy(self.buffer.popleft()) 69 | return frame 70 | except Exception as e: 71 | pass 72 | 73 | def returnTail(self): 74 | """Return the tail (newest frame) of the buffer""" 75 | return np.copy(self.buffer[0]) 76 | 77 | def returnHead(self): 78 | """Return the head (oldest frame) of the buffer""" 79 | return np.copy(self.buffer[-1]) 80 | 81 | def changeROI(self, newROI: ROI): 82 | """Change the default shape when the ROI is changed""" 83 | self.frameShape = newROI.pixelSizes 84 | self.clearBuffer() 85 | 86 | def changeStacksize(self, newStacksize: int): 87 | self.stackSize = newStacksize 88 | 89 | @property 90 | def full(self) -> bool: 91 | return len(self.buffer) == self.stackSize 92 | 93 | @property 94 | def empty(self) -> bool: 95 | return len(self.buffer) == 0 96 | 97 | @property 98 | def length(self) -> int: 99 | return len(self.buffer) 100 | -------------------------------------------------------------------------------- /src/napari_live_recording/control/devices/opencv.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | from napari_live_recording.common import ROI, ColorType 4 | from napari_live_recording.control.devices.interface import ( 5 | ICamera, 6 | NumberParameter, 7 | ListParameter 8 | ) 9 | from typing import Union, Any 10 | from sys import platform 11 | 12 | class OpenCV(ICamera): 13 | 14 | msExposure = { 15 | "1 s": 0, 16 | "500 ms": -1, 17 | "250 ms": -2, 18 | "125 ms": -3, 19 | "62.5 ms": -4, 20 | "31.3 ms": -5, 21 | "15.6 ms": -6, # default 22 | "7.8 ms": -7, 23 | "3.9 ms": -8, 24 | "2 ms": -9, 25 | "976.6 us": -10, 26 | "488.3 us": -11, 27 | "244.1 us": -12, 28 | "122.1 us": -13 29 | } 30 | 31 | pixelFormats = { 32 | "RGB" : (cv2.COLOR_BGR2RGB, ColorType.RGB), # default 33 | "RGBA" : (cv2.COLOR_BGR2RGBA, ColorType.RGB), 34 | "BGR" : (None, ColorType.RGB), 35 | "Grayscale" : (cv2.COLOR_RGB2GRAY, ColorType.GRAYLEVEL) 36 | } 37 | 38 | def __init__(self, name: str, deviceID: Union[str, int]) -> None: 39 | """OpenCV VideoCapture wrapper. 40 | 41 | Args: 42 | name (str): user-defined camera name. 43 | deviceID (Union[str, int]): camera identifier. 44 | """ 45 | self.__capture = cv2.VideoCapture(int(deviceID)) 46 | 47 | # read OpenCV parameters 48 | width = int(self.__capture.get(cv2.CAP_PROP_FRAME_WIDTH)) 49 | height = int(self.__capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) 50 | 51 | # initialize region of interest 52 | # steps for height, width and offsets 53 | # are by default 1. We leave them as such 54 | sensorShape = ROI(offset_x=0, offset_y=0, height=height, width=width) 55 | 56 | parameters = {} 57 | 58 | # exposure time in OpenCV is treated differently on Windows, 59 | # as exposure times may only have a finite set of values 60 | if platform.startswith("win"): 61 | parameters["Exposure time"] = ListParameter(value=self.msExposure["15.6 ms"], 62 | options=list(self.msExposure.keys()), 63 | editable=True) 64 | else: 65 | parameters["Exposure time"] = NumberParameter(value=10e-3, 66 | valueLimits=(100e-6, 1), 67 | unit="s", 68 | editable=True) 69 | parameters["Pixel format"] = ListParameter(value=self.pixelFormats["RGB"], 70 | options=list(self.pixelFormats.keys()), 71 | editable=True) 72 | 73 | super().__init__(name, deviceID, parameters, sensorShape) 74 | format = self.pixelFormats["RGB"] 75 | self.__format = format[0] 76 | self._colorType = format[1] 77 | 78 | def setAcquisitionStatus(self, started: bool) -> None: 79 | pass 80 | 81 | def grabFrame(self) -> np.ndarray: 82 | _, img = self.__capture.read() 83 | y, h = self.roiShape.offset_y, self.roiShape.offset_y + self.roiShape.height 84 | x, w = self.roiShape.offset_x, self.roiShape.offset_x + self.roiShape.width 85 | img = img[y:h, x:w] 86 | img = (cv2.cvtColor(img, self.__format) if self.__format is not None else img) 87 | return img 88 | 89 | def changeParameter(self, name: str, value: Any) -> None: 90 | if name == "Exposure time": 91 | value = (self.msExposure[value] if platform.startswith("win") else value) 92 | self.__capture.set(cv2.CAP_PROP_EXPOSURE, value) 93 | elif name == "Pixel format": 94 | newFormat = self.pixelFormats[value] 95 | self.__format = newFormat[0] 96 | self._colorType = newFormat[1] 97 | else: 98 | raise ValueError(f"Unrecognized value \"{value}\" for parameter \"{name}\"") 99 | 100 | def changeROI(self, newROI: ROI): 101 | if newROI <= self.fullShape: 102 | self.roiShape = newROI 103 | 104 | def close(self) -> None: 105 | self.__capture.release() -------------------------------------------------------------------------------- /src/napari_live_recording/_test/test_ui.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from napari_live_recording import NapariLiveRecording 5 | 6 | def test_widget_startup_and_cleanup(recording_widget): 7 | widget : "NapariLiveRecording" = recording_widget 8 | 9 | assert widget.anchor.selectionWidget.camerasComboBox.value == ("Select device", 0) 10 | assert widget.anchor.selectionWidget.nameLineEdit.value == "MyCamera" 11 | 12 | 13 | def test_widget_add_mmcore_test_device(recording_widget): 14 | widget : "NapariLiveRecording" = recording_widget 15 | 16 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(1) # MicroManager 17 | widget.anchor.selectionWidget.adapterComboBox.combobox.setCurrentIndex(8) # DemoCamera 18 | widget.anchor.selectionWidget.deviceComboBox.combobox.setCurrentIndex(0) # DCam 19 | 20 | assert widget.anchor.selectionWidget.adapterComboBox.value == ("DemoCamera", 8) 21 | assert widget.anchor.selectionWidget.deviceComboBox.value == ("DCam", 0) 22 | assert widget.anchor.selectionWidget.nameLineEdit.value == "MyCamera" 23 | 24 | widget.anchor.selectionWidget.addButton.click() 25 | 26 | assert "MyCamera:MicroManager:DemoCamera DCam" in list(widget.anchor.cameraWidgetGroups.keys()) 27 | 28 | def test_widget_add_microscope_test_device(recording_widget): 29 | widget : "NapariLiveRecording" = recording_widget 30 | 31 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(3) # Microscope 32 | widget.anchor.selectionWidget.microscopeModuleComboBox.combobox.setCurrentIndex(6) # simulators 33 | widget.anchor.selectionWidget.microscopeDeviceComboBox.combobox.setCurrentIndex(0) # SimulatedCamera 34 | 35 | assert widget.anchor.selectionWidget.microscopeModuleComboBox.value == ("simulators", 6) 36 | assert widget.anchor.selectionWidget.microscopeDeviceComboBox.value == ("SimulatedCamera", 0) 37 | assert widget.anchor.selectionWidget.nameLineEdit.value == "MyCamera" 38 | 39 | widget.anchor.selectionWidget.addButton.click() 40 | 41 | assert "MyCamera:Microscope:simulators SimulatedCamera" in list(widget.anchor.cameraWidgetGroups.keys()) 42 | 43 | def test_widget_add_mmcore_microscope_devices(recording_widget): 44 | widget : "NapariLiveRecording" = recording_widget 45 | 46 | # add microscope device 47 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(3) # Microscope 48 | widget.anchor.selectionWidget.microscopeModuleComboBox.combobox.setCurrentIndex(6) # simulators 49 | widget.anchor.selectionWidget.microscopeDeviceComboBox.combobox.setCurrentIndex(0) # SimulatedCamera 50 | 51 | widget.anchor.selectionWidget.addButton.click() 52 | 53 | # add mmcore device 54 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(1) # MicroManager 55 | widget.anchor.selectionWidget.adapterComboBox.combobox.setCurrentIndex(8) # DemoCamera 56 | widget.anchor.selectionWidget.deviceComboBox.combobox.setCurrentIndex(0) # DCam 57 | 58 | widget.anchor.selectionWidget.addButton.click() 59 | 60 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 2 61 | assert len(list(widget.mainController.deviceControllers.keys())) == 2 62 | assert "MyCamera:Microscope:simulators SimulatedCamera" in list(widget.anchor.cameraWidgetGroups.keys()) 63 | assert "MyCamera:MicroManager:DemoCamera DCam" in list(widget.anchor.cameraWidgetGroups.keys()) 64 | 65 | # delete each camera; the delete button is nested a bit deep; 66 | # .layout() -> returns the QVBoxLayout of the cameraWidgetGroup tab; 67 | # .itemAt(1) -> returns the element of the layout; 68 | # .widget() -> returns the QGroupBox; 69 | # .layout() -> returns the QFormLayout of the QGroupBox; 70 | # .itemAt(0) -> returns the first element of the layout (the delete button); 71 | # .widget() -> returns the QPushButton; 72 | # .click() -> clicks the button 73 | widget.anchor.cameraWidgetGroups["MyCamera:Microscope:simulators SimulatedCamera"].deleteButton.click() 74 | 75 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 1 76 | assert len(list(widget.mainController.deviceControllers.keys())) == 1 77 | 78 | widget.anchor.cameraWidgetGroups["MyCamera:MicroManager:DemoCamera DCam"].deleteButton.click() 79 | 80 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 0 81 | assert len(list(widget.mainController.deviceControllers.keys())) == 0 82 | -------------------------------------------------------------------------------- /src/napari_live_recording/control/devices/interface.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from abc import abstractmethod 3 | from typing import Union, Tuple 4 | from qtpy.QtCore import QObject 5 | from napari_live_recording.common import ROI, ColorType 6 | from typing import Dict, List, Any 7 | from dataclasses import dataclass, replace 8 | from abc import ABC 9 | 10 | 11 | # the camera interface and setting management is heavily inspired by the work of Xavier Casas Moreno in ImSwitch 12 | # reference work: https://github.com/kasasxav/ImSwitch/blob/master/imswitch/imcontrol/model/managers/detectors/DetectorManager.py 13 | # Xavier Casas Moreno GitHub profile: https://github.com/kasasxav 14 | @dataclass 15 | class Parameter(ABC): 16 | editable: bool 17 | """Wether the parameter is readonly or not. 18 | """ 19 | 20 | 21 | @dataclass 22 | class NumberParameter(Parameter): 23 | value: Union[int, float] 24 | """Value of the parameter. 25 | """ 26 | 27 | unit: str 28 | """Unit measure of the specific parameter. 29 | """ 30 | 31 | valueLimits: Tuple[Union[int, float], Union[int, float]] 32 | """Upper and lower boundaries of the possible parameter's value. 33 | """ 34 | 35 | 36 | @dataclass 37 | class ListParameter(Parameter): 38 | value: str 39 | """Value of the parameter. 40 | """ 41 | 42 | options: List[str] 43 | """List of possible options for the parameter. 44 | """ 45 | 46 | 47 | class ICamera(QObject): 48 | def __init__( 49 | self, 50 | name: str, 51 | deviceID: Union[str, int], 52 | parameters: Dict[str, Any], 53 | sensorShape: ROI, 54 | ) -> None: 55 | """Generic camera device interface. Each device is initialized with a series of parameters. 56 | Live and recording are handled using child thread workers. 57 | 58 | Args: 59 | name (str): name of the camera device. 60 | deviceID (`Union[str, int]`): device ID. 61 | parameters (`Dict[str, Any]`): dictionary of parameters of the specific device. 62 | sensorShape (`ROI`): camera physical shape and information related to the widget steps. 63 | """ 64 | QObject.__init__(self) 65 | self.name = name 66 | self.deviceID = deviceID 67 | self.cameraKey = f"{self.name}:{self.__class__.__name__}:{str(self.deviceID)}" 68 | self.parameters = parameters 69 | self._roiShape = sensorShape 70 | self._fullShape = sensorShape 71 | self._colorType = ColorType.GRAYLEVEL 72 | try: 73 | self.settingsWidget = self.settingsWidget 74 | except: 75 | pass 76 | 77 | @property 78 | def colorType(self) -> ColorType: 79 | return self._colorType 80 | 81 | @property 82 | def fullShape(self) -> ROI: 83 | return self._fullShape 84 | 85 | @property 86 | def roiShape(self) -> ROI: 87 | return self._roiShape 88 | 89 | @roiShape.setter 90 | def roiShape(self, newROI: ROI) -> None: 91 | self._roiShape = replace(newROI) 92 | 93 | @abstractmethod 94 | def setAcquisitionStatus(self, started: bool) -> None: 95 | """Sets the current acquisition status of the camera device. 96 | - True: acquisition started; 97 | - False: acquisition stopped. 98 | """ 99 | raise NotImplementedError() 100 | 101 | @abstractmethod 102 | def grabFrame(self) -> np.ndarray: 103 | """Returns the latest captured frame as a numpy array.""" 104 | raise NotImplementedError() 105 | 106 | @abstractmethod 107 | def changeROI(self, newROI: ROI) -> None: 108 | """Changes the Region Of Interest of the sensor's device.""" 109 | raise NotImplementedError() 110 | 111 | @abstractmethod 112 | def changeParameter(name: str, value: Any) -> None: 113 | """Changes one of the settings of the device. 114 | 115 | Args: 116 | name (str): name of the setting. 117 | value (Any): new value for the specified setting. 118 | """ 119 | pass 120 | 121 | def close(self) -> None: 122 | """Optional method to close the device.""" 123 | pass 124 | 125 | def __enter__(self): 126 | self.setAcquisitionStatus(True) 127 | 128 | def __exit__(self, exc_type, exc_value, tb): 129 | self.setAcquisitionStatus(False) 130 | -------------------------------------------------------------------------------- /docs/camera-interface.md: -------------------------------------------------------------------------------- 1 | # Creating a camera interface 2 | 3 | The documentation is work in progress. For a first glance on how the Python interface looks like, please refer to the OpenCV grabber implementation in `napari-live-recording/control/devices/opencv.py`: 4 | 5 | ```py 6 | import cv2 7 | import numpy as np 8 | from napari_live_recording.common import ROI, ColorType 9 | from napari_live_recording.control.devices.interface import ( 10 | ICamera, 11 | NumberParameter, 12 | ListParameter 13 | ) 14 | from typing import Union, Any 15 | from sys import platform 16 | 17 | class OpenCV(ICamera): 18 | 19 | msExposure = { 20 | "1 s": 0, 21 | "500 ms": -1, 22 | "250 ms": -2, 23 | "125 ms": -3, 24 | "62.5 ms": -4, 25 | "31.3 ms": -5, 26 | "15.6 ms": -6, # default 27 | "7.8 ms": -7, 28 | "3.9 ms": -8, 29 | "2 ms": -9, 30 | "976.6 us": -10, 31 | "488.3 us": -11, 32 | "244.1 us": -12, 33 | "122.1 us": -13 34 | } 35 | 36 | pixelFormats = { 37 | "RGB" : (cv2.COLOR_BGR2RGB, ColorType.RGB), # default 38 | "RGBA" : (cv2.COLOR_BGR2RGBA, ColorType.RGB), 39 | "BGR" : (None, ColorType.RGB), 40 | "Grayscale" : (cv2.COLOR_RGB2GRAY, ColorType.GRAYLEVEL) 41 | } 42 | 43 | def __init__(self, name: str, deviceID: Union[str, int]) -> None: 44 | """OpenCV VideoCapture wrapper. 45 | 46 | Args: 47 | name (str): user-defined camera name. 48 | deviceID (Union[str, int]): camera identifier. 49 | """ 50 | self.__capture = cv2.VideoCapture(int(deviceID)) 51 | 52 | # read OpenCV parameters 53 | width = int(self.__capture.get(cv2.CAP_PROP_FRAME_WIDTH)) 54 | height = int(self.__capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) 55 | 56 | # initialize region of interest 57 | # steps for height, width and offsets 58 | # are by default 1. We leave them as such 59 | sensorShape = ROI(offset_x=0, offset_y=0, height=height, width=width) 60 | 61 | parameters = {} 62 | 63 | # exposure time in OpenCV is treated differently on Windows, 64 | # as exposure times may only have a finite set of values 65 | if platform.startswith("win"): 66 | parameters["Exposure time"] = ListParameter(value=self.msExposure["15.6 ms"], 67 | options=list(self.msExposure.keys()), 68 | editable=True) 69 | else: 70 | parameters["Exposure time"] = NumberParameter(value=10e-3, 71 | valueLimits=(100e-6, 1), 72 | unit="s", 73 | editable=True) 74 | parameters["Pixel format"] = ListParameter(value=self.pixelFormats["RGB"], 75 | options=list(self.pixelFormats.keys()), 76 | editable=True) 77 | 78 | super().__init__(name, deviceID, parameters, sensorShape) 79 | format = self.pixelFormats["RGB"] 80 | self.__format = format[0] 81 | self._colorType = format[1] 82 | 83 | def setAcquisitionStatus(self, started: bool) -> None: 84 | pass 85 | 86 | def grabFrame(self) -> np.ndarray: 87 | _, img = self.__capture.read() 88 | y, h = self.roiShape.offset_y, self.roiShape.offset_y + self.roiShape.height 89 | x, w = self.roiShape.offset_x, self.roiShape.offset_x + self.roiShape.width 90 | img = img[y:h, x:w] 91 | img = (cv2.cvtColor(img, self.__format) if self.__format is not None else img) 92 | return img 93 | 94 | def changeParameter(self, name: str, value: Any) -> None: 95 | if name == "Exposure time": 96 | value = (self.msExposure[value] if platform.startswith("win") else value) 97 | self.__capture.set(cv2.CAP_PROP_EXPOSURE, value) 98 | elif name == "Pixel format": 99 | newFormat = self.pixelFormats[value] 100 | self.__format = newFormat[0] 101 | self._colorType = newFormat[1] 102 | else: 103 | raise ValueError(f"Unrecognized value \"{value}\" for parameter \"{name}\"") 104 | 105 | def changeROI(self, newROI: ROI): 106 | if newROI <= self.fullShape: 107 | self.roiShape = newROI 108 | 109 | def close(self) -> None: 110 | self.__capture.release() 111 | ``` 112 | 113 | -------------------------------------------------------------------------------- /src/napari_live_recording/_test/test_settings.py: -------------------------------------------------------------------------------- 1 | from napari_live_recording.common import ROI 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | from qtpy.QtWidgets import QScrollArea 5 | from qtpy.QtWidgets import QComboBox, QSlider 6 | from superqt import QLabeledDoubleSlider 7 | 8 | if TYPE_CHECKING: 9 | from napari_live_recording import NapariLiveRecording 10 | 11 | def set_roi(widget: "NapariLiveRecording", height: int, width: int, offset_x: int, offset_y: int, cam_name: str): 12 | """Helper function to set the ROI of a widget.""" 13 | 14 | roi_widget = widget.anchor.cameraWidgetGroups[cam_name].roiWidget 15 | roi_widget.heightSpinBox.setValue(height) 16 | roi_widget.widthSpinBox.setValue(width) 17 | roi_widget.offsetXSpinBox.setValue(offset_x) 18 | roi_widget.offsetYSpinBox.setValue(offset_y) 19 | roi_widget.changeROIButton.click() 20 | 21 | def change_parameter(widget: "NapariLiveRecording", cam_name: str, param_index: int, value: Union[float, int, str, bool]): 22 | """Helper function to change a parameter of a widget.""" 23 | 24 | camera_widget_group = widget.anchor.cameraWidgetGroups[cam_name] 25 | scrollArea : QScrollArea = camera_widget_group.layout.itemAt(1).widget() 26 | layout = scrollArea.widget().layout() 27 | widget = layout.itemAt(param_index, 1).widget() 28 | if type(widget) == QComboBox: 29 | widget.setCurrentIndex(value) 30 | elif type(widget) == QSlider or type(widget) == QLabeledDoubleSlider: 31 | widget.setValue(value) 32 | 33 | # item = layout.itemAt(param_index) 34 | # widget = item.widget() 35 | # widget_layout = widget.layout() 36 | # target_widget = widget_layout.itemAt(param_index, 1).widget() 37 | # target_widget.setValue(value) 38 | 39 | def test_mmcore_settings_change(recording_widget): 40 | full_cam_name = "MyCamera:MicroManager:DemoCamera DCam" 41 | 42 | widget : "NapariLiveRecording" = recording_widget 43 | 44 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(1) # MicroManager 45 | widget.anchor.selectionWidget.adapterComboBox.combobox.setCurrentIndex(8) # DemoCamera 46 | widget.anchor.selectionWidget.deviceComboBox.combobox.setCurrentIndex(0) # DCam 47 | 48 | assert widget.anchor.selectionWidget.adapterComboBox.value == ("DemoCamera", 8) 49 | assert widget.anchor.selectionWidget.deviceComboBox.value == ("DCam", 0) 50 | assert widget.anchor.selectionWidget.nameLineEdit.value == "MyCamera" 51 | 52 | widget.anchor.selectionWidget.addButton.click() 53 | 54 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 1 55 | assert len(list(widget.mainController.deviceControllers.keys())) == 1 56 | assert full_cam_name in list(widget.anchor.cameraWidgetGroups.keys()) 57 | 58 | # Set the ROI using the helper function 59 | set_roi(widget, height=256, width=256, offset_x=128, offset_y=128, cam_name=full_cam_name) 60 | 61 | target_roi = ROI(128, 128, 256, 256) 62 | new_roi = widget.mainController.deviceControllers[full_cam_name].device.roiShape 63 | 64 | assert target_roi == new_roi 65 | assert target_roi.pixelSizes == new_roi.pixelSizes 66 | 67 | def test_microscope_settings_change(recording_widget): 68 | full_cam_name = "MyCamera:Microscope:simulators SimulatedCamera" 69 | 70 | widget : "NapariLiveRecording" = recording_widget 71 | 72 | # add microscope device 73 | widget.anchor.selectionWidget.camerasComboBox.combobox.setCurrentIndex(3) # Microscope 74 | widget.anchor.selectionWidget.microscopeModuleComboBox.combobox.setCurrentIndex(6) # simulators 75 | widget.anchor.selectionWidget.microscopeDeviceComboBox.combobox.setCurrentIndex(0) # SimulatedCamera 76 | 77 | widget.anchor.selectionWidget.addButton.click() 78 | 79 | assert len(list(widget.anchor.cameraWidgetGroups.keys())) == 1 80 | assert len(list(widget.mainController.deviceControllers.keys())) == 1 81 | assert full_cam_name in list(widget.anchor.cameraWidgetGroups.keys()) 82 | 83 | # Set the ROI using the helper function 84 | set_roi(widget, height=256, width=256, offset_x=128, offset_y=128, cam_name=full_cam_name) 85 | 86 | target_roi = ROI(128, 128, 256, 256) 87 | new_roi = widget.mainController.deviceControllers[full_cam_name].device.roiShape 88 | 89 | assert target_roi == new_roi 90 | assert target_roi.pixelSizes == new_roi.pixelSizes 91 | 92 | # image pattern (generic parameter) (index: 0) 93 | change_parameter(widget, full_cam_name, 0, 1) 94 | 95 | assert widget.mainController.deviceControllers[full_cam_name].device.parameters["image pattern"].value == "gradient" 96 | 97 | # exposure time parameter (index: 5) 98 | change_parameter(widget, full_cam_name, 5, 0.03) 99 | 100 | assert widget.mainController.deviceControllers[full_cam_name].device.parameters["Exposure time"].value == 0.03 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # napari-live-recording 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/jacopoabramo/napari-live-recording/raw/main/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/napari-live-recording.svg?color=green)](https://pypi.org/project/napari-live-recording) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/napari-live-recording.svg?color=green)](https://python.org) 6 | ![tests](https://github.com/jacopoabramo/napari-live-recording/actions/workflows/test_and_deploy.yaml/badge.svg) 7 | [![codecov](https://codecov.io/github/jacopoabramo/napari-live-recording/graph/badge.svg?token=WhI2MO452Z)](https://codecov.io/github/jacopoabramo/napari-live-recording) \ 8 | [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-live-recording)](https://napari-hub.org/plugins/napari-live-recording) 9 | [![Chan-Zuckerberg Initiative](https://custom-icon-badges.demolab.com/badge/Chan--Zuckerberg_Initiative-red?logo=czi)](https://chanzuckerberg.com/) 10 | 11 | This [napari] plugin was generated with [Cookiecutter] using with [@napari]'s [cookiecutter-napari-plugin] template. 12 | 13 | ## Description 14 | 15 | `napari-live-recording` (or `nlr`, if you like acronyms) is a medium-weight plugin part of the napari ecosystem that provides an easy 16 | access point for controlling area detector devices (most commonly reffered to as cameras) with a common interface. 17 | Other than that, the plugin also allows to create computation pipelines that can be executed real-time in a flow starting directly from the camera stream. 18 | 19 | > [!NOTE] 20 | > 21 | > ### Why medium weight? 22 | > `napari-live-recording` relies on multithreading to handle camera control, 23 | > image processing and data storage via a common pipelined infrastructure. 24 | > More details are provided in the documentation. 25 | 26 | The plugin allows the following operations: 27 | 28 | - snapping: capture a single image 29 | - live view: continously acquiring from the currently active camera and show the collected data on the napari viewer; 30 | - recording: stream data to disk from the currently active cameras 31 | 32 | When recording, the plugin allows to store images according to the following formats: 33 | 34 | - ImageJ TIFF 35 | - OME-TIFF 36 | 37 | > [!NOTE] 38 | > Future releases will also add further file formats to the recording options, specifically: 39 | > - HDF5 40 | > - MP4 41 | > 42 | > We will also provide a method to add custom metadata to the recorded image files. 43 | 44 | ## Supported cameras 45 | 46 | `napari-live-recording` aims to maintain itself agnostic for the type of cameras it controls. Via a common API (Application Programming Interface), 47 | it possible to define a controller for a specific camera. Instructions 48 | on how to do so are provided in the documentation. 49 | 50 | By default, the plugin is shipped with the following interfaces: 51 | 52 | - an [OpenCV](./src/napari_live_recording/control/devices/opencv.py) camera grabber; 53 | - a [Micro-Manager](./src/napari_live_recording/control/devices/micro_manager.py) interface via the package [`pymmcore-plus`](https://pypi.org/project/pymmcore-plus/); 54 | - an interface to the [microscope](./src/napari_live_recording/control/devices/pymicroscope.py) python package. 55 | 56 | ## Documentation 57 | 58 | To install and use the plugin you can review the documentation [here](./docs/documentation.md). 59 | 60 | ## Contributing 61 | 62 | Contributions are very welcome. Tests can be run with [tox], please ensure 63 | the coverage at least stays the same before you submit a pull request. 64 | 65 | ## Acknowledgments 66 | 67 | The developers would like to thank the [Chan-Zuckerberg Initiative (CZI)](https://chanzuckerberg.com/) for providing funding 68 | for this project via the [napari Ecosystem Grants](https://chanzuckerberg.com/science/programs-resources/imaging/napari/napari-live-recording-camera-control-through-napari/). 69 | 70 |

71 | 72 |

73 | 74 | ## License 75 | 76 | Distributed under the terms of the [MIT] license, 77 | "napari-live-recording" is free and open source software 78 | 79 | ## Issues 80 | 81 | If you encounter any problems, please [file an issue] along with a detailed description. 82 | 83 | [napari]: https://github.com/napari/napari 84 | [Cookiecutter]: https://github.com/audreyr/cookiecutter 85 | [@napari]: https://github.com/napari 86 | [MIT]: http://opensource.org/licenses/MIT 87 | [BSD-3]: http://opensource.org/licenses/BSD-3-Clause 88 | [GNU GPL v3.0]: http://www.gnu.org/licenses/gpl-3.0.txt 89 | [GNU LGPL v3.0]: http://www.gnu.org/licenses/lgpl-3.0.txt 90 | [Apache Software License 2.0]: http://www.apache.org/licenses/LICENSE-2.0 91 | [Mozilla Public License 2.0]: https://www.mozilla.org/media/MPL/2.0/index.txt 92 | [cookiecutter-napari-plugin]: https://github.com/napari/cookiecutter-napari-plugin 93 | 94 | [file an issue]: https://github.com/jacopoabramo/napari-live-recording/issues 95 | 96 | [napari]: https://github.com/napari/napari 97 | [tox]: https://tox.readthedocs.io/en/latest/ 98 | [pip]: https://pypi.org/project/pip/ 99 | [PyPI]: https://pypi.org/ 100 | -------------------------------------------------------------------------------- /docs/processing_engine.md: -------------------------------------------------------------------------------- 1 | # Processing Engine 2 | 3 | The general idea of the processing engine is that you can create custom filters that can be applied to acquired frames in the plugin. This enables the direct recording of alredy processed images, which might be convenient for everyday work. Those custom filters can actually consist of several single filter functions that are arranged in a certain order in which they wil work on the frames. So each of the single filters will be applied one after another resulting in a total effect on the frame. A user interface enables the convenient creation of those custom filters which will be referred to as **filter-groups**. 4 | 5 | ## 1. Filter Creation 6 | 7 | A filter creation window opens when the **"Create Filter"** button in the recording widget is pressed. 8 | 9 |

10 | 11 |

12 | 13 | The filter creation window is displayed in the following image and is operated from left to right. 14 | 15 |

16 | 17 |

18 | 19 | The left list contains all the filters that are currently available to the plugin. Each filter (basically a function that takes an array-like image as an input, does some calculations and outputs the new image) needs to be definded in a python file following a certain pattern (which will be described in the next chapter). A new filter can be added by loading a file containing such a funcion into the plugin. This is done by simply clicking the "Add new Function" button in the lower left corner. A file dialog window will open und you can select the desired python file. The new filter should then be displayed in the left list. All filters in the left list can be searched for via the search bar above the left list. 20 | 21 | The list in the centers serves for the purpose of creating new filter-groups. You can arrange certain filter functions in your desired order to create your custom filter. For this, just **drag and drop a filter from the left list to the right list** to add this filter to the filter-group. If you have added all functions needed, you can **arrange** them in a specific order, again **by drag and drop inside the right list**. A single filter-function can also be added twice to a custom filter by dragging it into the right list a second time. Filters from the right list can be **deleted by dragging them outside the right list**. You can delete all filters at once by clicking the "Clear" button. When an **item (a filter) in the right list is double clicked**, it's corresponding parameters are shown in a separate window, you can also **change certain parameters** here. It is for instance possible to apply the same filter function twice but with different parameters. You can test the action of your current filter group on a sample image by clicking the "Refresh" button in the left collumn. When changes are applied to the filter-group in the list you need to press the "Refresh" button again to see the result. You can also load your own sample image by clicking the "Load new Image" Button in the right collumn. When you are happy with the result of your filter-group, you **need to name your custom filter** (in the line with the label "Filter Name") and then **press the "Create Filter-Group"** Button in the central collumn. Your filter is now saved. When you try to create a filter with the same **name** as one that **already exists**, the old filter will be **overwritten**. 22 | 23 |

24 | 25 |

26 | 27 | By clicking the **"Show Existing Filters" button you can display the already existing filter-groups** (i.e. custom filters) referenced by their name. In the window you can delete a selected filter. You can also **load an already existing filter** by pressing the "Load" button. the filter will be loaded to the central list of the filter-creation window. Note, filter-functions that might be in this list at that state are deleted when an old filter is loaded. Loading of an old filter can serve for ammending this filter or can serve as a basis for a new filter. Remember to name your filter accordingly. 28 | The filter "No Filter" is always there. 29 | After you are done, your custom filters will be available for recording (How this is done will be discussed in one of the follwing chapters). 30 | 31 | ## 2. Creating new filter functions 32 | 33 | To create new filter functions you need to create a python file that contains this function anywhere on your PC. This file can later be loaded into the plugin. This file has to follow a certain pattern to work properly. 34 | A template filter function is shown below: 35 | 36 | ```py 37 | import cv2 as cv 38 | 39 | # list args of the filter function and their desired default values in the parameterDict 40 | default_value1 = 20 41 | default_value2 = 70 42 | parametersDict = {"parameter1": default_value1, "parameter2": default_value2} 43 | 44 | # give parameter hints for every parameter in paametersDict. This should contain a description as well as a hint which values (like:possible range, even/uneven numbers, parameter1 has to be larger than parameter2 ...) are allowed and which data-type is required. 45 | parametersHints = { 46 | "parameter1": "First threshold for the hysteresis procedure., needs to be smaller than parameter2, integer values", 47 | "parameter2": "Second threshold for the hysteresis procedure, integer values", 48 | } 49 | 50 | # give a description of the function 51 | functionDescription = "Finds edges in an image using the Canny algorithm. The function finds edges in the input image and marks them in the output map edges using the Canny algorithm. The smallest value between threshold1 and threshold2 is used for edge linking. The largest value is used to find initial segments of strong edges. See http://en.wikipedia.org/wiki/Canny_edge_detector" 52 | 53 | 54 | # use your desired function here. First input of the function is always the input-image. 55 | # Followed by positional parameters. The output-image is returned. 56 | 57 | def cv_canny(input, threshold1, threshold2): 58 | output = cv.Canny(input, threshold1, threshold2) 59 | return output 60 | ``` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/napari_live_recording/control/devices/pymicroscope.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import microscope 3 | from microscope.abc import Camera 4 | from typing import Union, Any 5 | from napari_live_recording.common import ROI 6 | from napari_live_recording.control.devices.interface import ( 7 | ICamera, 8 | NumberParameter, 9 | ListParameter 10 | ) 11 | import queue 12 | import importlib 13 | 14 | 15 | class Microscope(ICamera): 16 | 17 | index_dict = {} 18 | 19 | def __init__(self, name: str, deviceID: Union[str, int]) -> None: 20 | """ VideoCapture from Python Microscope. 21 | Args: 22 | name (str): user-defined camera name. 23 | deviceID (Union[str, int]): camera identifier. 24 | """ 25 | # received ID will be the module of the camera and camera class name 26 | # in the format of " " 27 | 28 | self.__module, cls = deviceID.split() 29 | 30 | import_str = "microscope." 31 | if self.__module != "simulators": 32 | import_str += "cameras." 33 | import_str += self.__module 34 | 35 | package = importlib.import_module(import_str) 36 | driver = getattr(package, cls) 37 | self.__camera: Camera = driver() 38 | 39 | cam_roi: microscope.ROI = self.__camera.get_roi() 40 | cam_binning: microscope.Binning = self.__camera.get_binning() 41 | 42 | sensorShape = ROI(offset_x=0, offset_y=0, height=cam_roi.height // cam_binning.v, width=cam_roi.width // cam_binning.h) 43 | 44 | parameters = {} 45 | 46 | for key, _ in self.__camera.get_all_settings().items(): 47 | if key == "display image number": 48 | # TODO: skipping; 49 | # this causes errors 50 | # on microscope side 51 | self.__camera.set_setting("display image number", False) 52 | continue 53 | if self.__camera.describe_setting(key)['type'] == 'enum': 54 | # create dictionary for combobox 55 | test_keys = ([item[1] for item in self.__camera.describe_setting(key)['values']]) 56 | test_value = ([item[0] for item in self.__camera.describe_setting(key)['values']]) 57 | temp_dic = dict(zip(test_keys, test_value)) 58 | self.index_dict[key] = temp_dic 59 | parameters[key] = ListParameter(value=list(self.index_dict[key].keys())[0], 60 | options=list(self.index_dict[key].keys()), 61 | editable=not (self.__camera.describe_setting(key)['readonly'])) 62 | 63 | elif self.__camera.describe_setting(key)['type'] == 'int': 64 | min_value = self.__camera.describe_setting(key)['values'][0] 65 | max_value = self.__camera.describe_setting(key)['values'][1] 66 | parameters[key] = NumberParameter(value= self.__camera.describe_setting(key)['values'][0], 67 | valueLimits = (min_value, max_value), 68 | unit = "unknown unit", 69 | editable = not (self.__camera.describe_setting(key)['readonly'])) 70 | 71 | elif self.__camera.describe_setting(key)['type'] == 'bool': 72 | parameters[key] = ListParameter(value=self.__camera.describe_setting(key)['values'], 73 | options=list(('True', 'False')), 74 | editable=not (self.__camera.describe_setting(key)['readonly'])) 75 | # initialize exposure time at 10 ms 76 | self.__camera.set_exposure_time(10e-3) 77 | if 'Exposure' not in parameters: 78 | parameters['Exposure time'] = NumberParameter(value=self.__camera.get_exposure_time(), 79 | # min and max values were determined by try and error since they are not included in describe_settings() for simulated camera 80 | valueLimits=(2e-3, 100e-3), unit="s", 81 | editable=True) 82 | 83 | self.__buffer = queue.Queue() 84 | self.__camera.set_client(self.__buffer) 85 | 86 | super().__init__(name, deviceID, parameters, sensorShape) 87 | 88 | def setAcquisitionStatus(self, started: bool) -> None: 89 | if started: 90 | self.__camera.enable() 91 | else: 92 | self.__camera.disable() 93 | 94 | def grabFrame(self) -> np.ndarray: 95 | # TODO: microscope works 96 | # by calling the trigger() method 97 | # for each frame to be acquired; 98 | # can this be done in a more efficient way? 99 | self.__camera.trigger() 100 | img = self.__buffer.get() 101 | return img 102 | 103 | 104 | def changeParameter(self, name: str, value: Any) -> None: 105 | if name == "Exposure time": 106 | self.__camera.set_exposure_time(float(value)) 107 | elif name == "transform": # parameter type = 'enum' 108 | '''(False, False, False): 0, (False, False, True): 1, (False, True, False): 2, (False, True, True): 3, 109 | (True, False, False): 4,(True, False, True): 5, (True, True, False): 6, (True, True, True): 7''' 110 | value_tuple = eval((value)) # converts the datatype of value from str to tuple 111 | self.__camera.set_transform(value_tuple) # set_transform method does not work with index like the other enum parameter 112 | else: 113 | # the enum microscope settings behave using index instead of the actual value; 114 | # we perform a preventive checks on the parameter type to avoid errors 115 | paramType = type(self.parameters[name]) 116 | if paramType == ListParameter: 117 | if self.__camera.describe_setting(name)['type'] == 'enum': 118 | self.__camera.set_setting(name, self.index_dict[name][value]) 119 | else: 120 | self.__camera.set_setting(name, value) 121 | self.parameters[name].value = value 122 | 123 | def changeROI(self, newROI: ROI): 124 | self.__camera.set_roi(microscope.ROI(newROI.offset_x, newROI.offset_y, newROI.width, newROI.height)) 125 | self._roiShape = newROI 126 | 127 | def close(self) -> None: 128 | self.__camera.set_client(None) 129 | self.__camera.shutdown() 130 | -------------------------------------------------------------------------------- /src/napari_live_recording/common/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import IntEnum 3 | from dataclasses import dataclass 4 | from functools import total_ordering 5 | import pymmcore_plus as mmc 6 | import os 7 | from qtpy.QtCore import QSettings, Qt 8 | import functools, pims 9 | 10 | 11 | settingsFilePath = os.path.join( 12 | os.path.dirname(os.path.realpath(__file__)), "./settings.ini" 13 | ) 14 | settings = QSettings(settingsFilePath, QSettings.IniFormat) 15 | 16 | class Settings: 17 | def __init__(self) -> None: 18 | self.settings = settings 19 | 20 | def setSetting(self, key, newValue): 21 | self.settings.setValue(key, newValue) 22 | 23 | def getSetting(self, key): 24 | if self.settings.contains(key): 25 | return self.settings.value(key) 26 | else: 27 | return None 28 | 29 | def getFilterGroupsDict(self): 30 | if self.settings.contains("availableFilterGroups"): 31 | return self.settings.value("availableFilterGroups") 32 | else: 33 | self.settings.setValue( 34 | "availableFilterGroups", {"No Filter": {"1.No Filter": None}} 35 | ) 36 | return self.settings.value("availableFilterGroups") 37 | 38 | def setFilterGroupsDict(self, newDict): 39 | newDict["No Filter"] = {"1.No Filter": None} 40 | self.settings.setValue("availableFilterGroups", newDict) 41 | 42 | 43 | def createPipelineFilter(filters): 44 | def composeFunctions(functionList): 45 | return functools.reduce( 46 | lambda f, g: lambda x: f(g(x)), functionList, lambda x: x 47 | ) 48 | 49 | functionList = [] 50 | 51 | for filter in filters.values(): 52 | filterPartial = functools.partial(filter[0], **filter[1]) 53 | functionList.append(pims.pipeline(filterPartial)) 54 | composedFunction = composeFunctions(list(reversed(functionList))) 55 | return composedFunction 56 | 57 | 58 | # equivalent number of milliseconds 59 | # for 30 Hz and 60 Hz refresh rates 60 | THIRTY_FPS = 33 61 | SIXTY_FPS = 16 62 | FileFormat = IntEnum( 63 | value="FileFormat", names=[("ImageJ TIFF", 1), ("OME-TIFF", 2), ("HDF5", 3)] 64 | ) 65 | 66 | RecordType = IntEnum( 67 | value="RecordType", 68 | names=[("Number of frames", 1), ("Time (seconds)", 2), ("Toggled", 3)], 69 | ) 70 | 71 | 72 | class ColorType(IntEnum): 73 | GRAYLEVEL = 0 74 | RGB = 1 75 | 76 | 77 | TIFF_PHOTOMETRIC_MAP = { 78 | # ColorType -> photometric, number of channels 79 | ColorType.GRAYLEVEL: ("minisblack", 1), 80 | ColorType.RGB: ("rgb", 3), 81 | } 82 | 83 | 84 | @dataclass(frozen=True) 85 | class WriterInfo: 86 | folder: str 87 | filename: str 88 | fileFormat: FileFormat 89 | recordType: RecordType 90 | stackSize: int = 0 91 | acquisitionTime: float = 0 92 | 93 | 94 | @total_ordering 95 | @dataclass 96 | class ROI: 97 | """Dataclass for ROI settings.""" 98 | 99 | offset_x: int = 0 100 | offset_y: int = 0 101 | height: int = 0 102 | width: int = 0 103 | ofs_x_step: int = 1 104 | ofs_y_step: int = 1 105 | width_step: int = 1 106 | height_step: int = 1 107 | 108 | def __le__(self, other: ROI) -> bool: 109 | return (self.offset_x + self.width <= other.offset_x + other.width) and ( 110 | self.offset_y + self.height <= other.offset_y + other.height 111 | ) 112 | 113 | @property 114 | def pixelSizes(self) -> tuple: 115 | """Returns the number of pixels along width and height of the current ROI.""" 116 | return (self.height - self.offset_y, self.width - self.offset_x) 117 | 118 | 119 | def getDocumentsFolder(): 120 | """Returns the user's documents folder if they are using a Windows system, 121 | or their home folder if they are using another operating system.""" 122 | 123 | if os.name == "nt": # Windows system, try to return documents directory 124 | try: 125 | import ctypes.wintypes 126 | 127 | CSIDL_PERSONAL = 5 # Documents 128 | SHGFP_TYPE_CURRENT = 0 # Current value 129 | 130 | buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) 131 | ctypes.windll.shell32.SHGetFolderPathW( 132 | 0, CSIDL_PERSONAL, 0, SHGFP_TYPE_CURRENT, buf 133 | ) 134 | 135 | return buf.value 136 | except ImportError: 137 | pass 138 | return os.path.expanduser("~") # Non-Windows system, return home directory 139 | 140 | 141 | baseRecordingFolder = os.path.join(getDocumentsFolder(), "napari-live-recording") 142 | 143 | 144 | # at startup initialize the base recording folder 145 | if not os.path.exists(baseRecordingFolder): 146 | os.mkdir(baseRecordingFolder) 147 | 148 | MMC_DEVICE_MAP = { 149 | 'ABSCamera': ['ABSCam'], 150 | 'AmScope': ['AmScope'], 151 | 'Andor': ['Andor'], 152 | 'ArduinoCounter': ['ArduinoCounterCamera'], 153 | 'AtikCamera': ['Universal Atik Cameras Device Adapter'], 154 | 'AxioCam': ['Zeiss AxioCam'], 155 | 'BaumerOptronic': ['BaumerOptronic'], 156 | 'DECamera': ['Direct Electron Camera'], 157 | 'DemoCamera': ['DCam'], 158 | 'FLICamera': ['FLICamera'], 159 | 'FakeCamera': ['FakeCamera'], 160 | 'HamamatsuHam': ['HamamatsuHam_DCAM'], 161 | 'HoribaEPIX': ['Horiba EFIS Camera', 162 | 'Horiba EFIS Camera', 163 | 'Horiba EFIS Camera', 164 | 'Horiba EFIS Camera', 165 | 'Horiba EFIS Camera'], 166 | 'JAI': ['JAICamera'], 167 | 'Mightex_C_Cam': ['Mightex_BUF_USBCCDCamera'], 168 | 'OpenCVgrabber': ['OpenCVgrabber'], 169 | 'PCO_Camera': ['pco_camera'], 170 | 'PVCAM': ['Camera-1', 'Camera-1', 'Camera-1', 'Camera-1'], 171 | 'RaptorEPIX': ['Raptor Falcon Camera', 172 | 'Raptor Falcon Camera', 173 | 'Raptor Falcon Camera', 174 | 'Raptor Falcon Camera', 175 | 'Raptor Falcon Camera', 176 | 'Raptor Falcon Camera', 177 | 'Raptor Falcon Camera', 178 | 'Raptor Falcon Camera', 179 | 'Raptor Falcon Camera', 180 | 'Raptor Falcon Camera', 181 | 'Raptor Falcon Camera', 182 | 'Raptor Falcon Camera', 183 | 'Raptor Falcon Camera', 184 | 'Raptor Falcon Camera', 185 | 'Raptor Falcon Camera'], 186 | 'TIScam': ['TIS_DCAM'], 187 | 'TSI': ['TSICam'], 188 | 'TwainCamera': ['TwainCam'], 189 | 'Utilities': ['Multi Camera'] 190 | } 191 | 192 | microscopeDeviceDict = { 193 | "andorsdk3": "AndorSDK3", # microscope.cameras.andorsdk3.AndorSDK3 194 | "atmcd": "AndorAtmcd", # microscope.cameras.atmcd.AndotAtmcd 195 | "pvcam": "PVCamera", # microscope.cameras.pvcam.PVCamera 196 | "ximea": "XimeaCamera", # microscope.cameras.ximea.XimeaCamera 197 | "hamamatsu": "HamamtsuCamera", 198 | "picamera": "PiCamera", 199 | "simulators": "SimulatedCamera", 200 | } 201 | -------------------------------------------------------------------------------- /src/napari_live_recording/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtCore import QTimer, Qt 2 | from qtpy.QtWidgets import ( 3 | QTabWidget, 4 | QVBoxLayout, 5 | QSpacerItem, 6 | QSizePolicy, 7 | ) 8 | from typing import Dict, TYPE_CHECKING 9 | from napari_live_recording.common import ( 10 | THIRTY_FPS, 11 | WriterInfo, 12 | Settings, 13 | ) 14 | from napari_live_recording.control.devices import devicesDict, ICamera 15 | from napari_live_recording.ui.widgets import ( 16 | CameraTab, 17 | RecordHandling, 18 | CameraSelection, 19 | ) 20 | import numpy as np 21 | 22 | if TYPE_CHECKING: 23 | from napari.viewer import Viewer 24 | from napari_live_recording.control import MainController 25 | 26 | class ViewerAnchor: 27 | """Class which handles the UI elements of the plugin.""" 28 | 29 | def __init__( 30 | self, 31 | napari_viewer: "Viewer", 32 | mainController: "MainController", 33 | ) -> None: 34 | self.viewer = napari_viewer 35 | self.mainController = mainController 36 | self.settings = Settings() 37 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 38 | self.mainLayout = QVBoxLayout() 39 | self.selectionWidget = CameraSelection() 40 | self.selectionWidget.setDeviceSelectionWidget(list(devicesDict.keys())) 41 | self.selectionWidget.setAvailableCameras(list(devicesDict.keys())) 42 | self.recordingWidget = RecordHandling() 43 | verticalSpacer = QSpacerItem(0, 1, QSizePolicy.Minimum, QSizePolicy.Minimum) 44 | self.mainLayout.addWidget(self.selectionWidget.group) 45 | self.mainLayout.addWidget(self.recordingWidget.group) 46 | self.mainLayout.setAlignment( 47 | self.selectionWidget.group, Qt.AlignmentFlag.AlignTop 48 | ) 49 | self.cameraWidgetGroups: Dict[str, CameraTab] = {} 50 | self.selectionWidget.newCameraRequested.connect(self.addCameraUI) 51 | self.recordingWidget.signals["snapRequested"].connect(self.snap) 52 | self.recordingWidget.signals["liveRequested"].connect(self.live) 53 | self.recordingWidget.signals["recordRequested"].connect(self.recordAndProcess) 54 | 55 | self.mainController.newMaxTimePoint.connect( 56 | self.recordingWidget.recordProgress.setMaximum 57 | ) 58 | self.mainController.newTimePoint.connect( 59 | self.recordingWidget.recordProgress.setValue 60 | ) 61 | self.recordingWidget.filterCreated.connect(self.refreshAvailableFilters) 62 | self.mainController.recordFinished.connect( 63 | lambda: self.recordingWidget.record.setChecked(False) 64 | ) 65 | self.mainController.cameraDeleted.connect(self.recordingWidget.live.setChecked) 66 | self.liveTimer = QTimer() 67 | self.liveTimer.timeout.connect(self._updateLiveLayers) 68 | self.liveTimer.setInterval(THIRTY_FPS) 69 | self.isFirstTab = True 70 | self.mainLayout.addItem(verticalSpacer) 71 | 72 | def addTabWidget(self, isFirstTab: bool): 73 | if isFirstTab: 74 | self.tabs = QTabWidget() 75 | self.mainLayout.insertWidget(self.mainLayout.count() - 1, self.tabs) 76 | self.isFirstTab = False 77 | else: 78 | pass 79 | 80 | def addCameraUI(self, interface: str, name: str, idx: int): 81 | self.addTabWidget(self.isFirstTab) 82 | camera: ICamera = devicesDict[interface](name, idx) 83 | cameraKey = f"{camera.name}:{camera.__class__.__name__}:{str(idx)}" 84 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 85 | tab = CameraTab(camera, self.filterGroupsDict, interface) 86 | 87 | self.mainController.addCamera(cameraKey, camera) 88 | tab.deleteButton.clicked.connect(lambda: self.deleteCameraUI(cameraKey)) 89 | self.cameraWidgetGroups[cameraKey] = tab 90 | self.tabs.addTab(tab.widget, cameraKey) 91 | 92 | def deleteCameraUI(self, cameraKey: str) -> None: 93 | self.mainController.deleteCamera(cameraKey) 94 | self.mainController.deviceControllers.pop(cameraKey) 95 | self.tabs.removeTab(self.tabs.currentIndex()) 96 | if self.tabs.count() == 0: 97 | self.mainLayout.removeWidget(self.tabs) 98 | self.tabs.setParent(None) 99 | self.isFirstTab = True 100 | del self.cameraWidgetGroups[cameraKey] 101 | 102 | def refreshAvailableFilters(self): 103 | for key in self.cameraWidgetGroups.keys(): 104 | tab = self.cameraWidgetGroups[key] 105 | previousIndex = tab.getFiltersComboCurrentIndex() 106 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 107 | tab.setFiltersCombo(self.filterGroupsDict) 108 | tab.setFiltersComboCurrentIndex(previousIndex) 109 | 110 | def recordAndProcess(self, status: bool) -> None: 111 | self.mainController.appendToBuffer(status) 112 | if status: 113 | filtersList = {} 114 | cameraKeys = list(self.cameraWidgetGroups.keys()) 115 | 116 | writerInfo = WriterInfo( 117 | folder=self.recordingWidget.folderTextEdit.text(), 118 | filename=self.recordingWidget.filenameTextEdit.text(), 119 | fileFormat=self.recordingWidget.formatComboBox.currentEnum(), 120 | recordType=self.recordingWidget.recordComboBox.currentEnum(), 121 | stackSize=self.recordingWidget.recordSize, 122 | acquisitionTime=self.recordingWidget.recordSize, 123 | ) 124 | writerInfoProcessed = WriterInfo( 125 | folder=self.recordingWidget.folderTextEdit.text(), 126 | filename=self.recordingWidget.filenameTextEdit.text() + "_processed", 127 | fileFormat=self.recordingWidget.formatComboBox.currentEnum(), 128 | recordType=self.recordingWidget.recordComboBox.currentEnum(), 129 | stackSize=self.recordingWidget.recordSize, 130 | acquisitionTime=self.recordingWidget.recordSize, 131 | ) 132 | 133 | for key in cameraKeys: 134 | cameraTab = self.cameraWidgetGroups[key] 135 | selectedFilter = cameraTab.getFiltersComboCurrentText() 136 | filtersList[key] = self.filterGroupsDict[selectedFilter] 137 | self.mainController.process(filtersList, writerInfoProcessed) 138 | self.mainController.record(cameraKeys, writerInfo) 139 | 140 | def snap(self) -> None: 141 | for key in self.mainController.deviceControllers.keys(): 142 | cameraTab = self.cameraWidgetGroups[key] 143 | selectedFilterName = cameraTab.getFiltersComboCurrentText() 144 | selectedFilter = self.filterGroupsDict[selectedFilterName] 145 | self._updateLayer( 146 | f"Snap {key}", self.mainController.snap(key, selectedFilter) 147 | ) 148 | 149 | def live(self, status: bool) -> None: 150 | self.mainController.appendToBuffer(status) 151 | cameraKeys = list(self.cameraWidgetGroups.keys()) 152 | filtersList = {} 153 | for key in cameraKeys: 154 | cameraTab = self.cameraWidgetGroups[key] 155 | selectedFilter = cameraTab.getFiltersComboCurrentText() 156 | filtersList[key] = self.filterGroupsDict[selectedFilter] 157 | self.mainController.live(status,filtersList) 158 | if status: 159 | self.liveTimer.start() 160 | else: 161 | self.liveTimer.stop() 162 | 163 | def cleanup(self) -> None: 164 | if ( 165 | len(self.mainController.deviceControllers.keys()) == 0 166 | and len(self.cameraWidgetGroups.keys()) == 0 167 | ): 168 | # no cleanup required 169 | return 170 | 171 | # first delete the controllers... 172 | # if self.mainController.isLive: 173 | # self.mainController.live(False) 174 | for key in self.mainController.deviceControllers.keys(): 175 | self.mainController.deleteCamera(key) 176 | self.mainController.deviceControllers.clear() 177 | 178 | # ... then delete the UI tabs 179 | self.tabs.clear() 180 | self.mainLayout.removeWidget(self.tabs) 181 | self.tabs.setParent(None) 182 | self.isFirstTab = True 183 | 184 | self.cameraWidgetGroups.clear() 185 | 186 | def _updateLiveLayers(self): 187 | try: 188 | for key in self.mainController.deviceControllers.keys(): 189 | # this copy may not be truly necessary 190 | # but it does not impact performance too much 191 | # so we keep it to avoid possible data corruption 192 | self._updateLayer( 193 | f"Live {key}", np.copy(self.mainController.returnNewestFrame(key)) 194 | ) 195 | except Exception as e: 196 | pass 197 | 198 | def _updateLayer(self, layerKey: str, data: np.ndarray) -> None: 199 | try: 200 | # layer is recreated in case the image changes type (i.e. grayscale -> RGB and viceversa) 201 | if data.ndim != self.viewer.layers[layerKey].data.ndim: 202 | self.viewer.layers.remove(layerKey) 203 | self.viewer.add_image(data, name=layerKey) 204 | else: 205 | self.viewer.layers[layerKey].data = data 206 | except KeyError: 207 | # needed in case the layer of that live recording does not exist 208 | self.viewer.add_image(data, name=layerKey) 209 | -------------------------------------------------------------------------------- /src/napari_live_recording/processing_engine/processing_gui.py: -------------------------------------------------------------------------------- 1 | from pyqtgraph import ImageView 2 | import cv2 as cv 3 | import numpy as np 4 | from ast import literal_eval 5 | from napari_live_recording.processing_engine.image_filters import * 6 | from napari_live_recording.common import createPipelineFilter, Settings 7 | from napari_live_recording.processing_engine import image_filters, defaultImagePath 8 | import importlib 9 | import pkgutil 10 | from qtpy.QtCore import Qt, Signal 11 | import shutil 12 | from qtpy.QtWidgets import ( 13 | QDialog, 14 | QMessageBox, 15 | QGroupBox, 16 | QWidget, 17 | QLineEdit, 18 | QLabel, 19 | QFormLayout, 20 | QGridLayout, 21 | QPushButton, 22 | QFileDialog, 23 | QVBoxLayout, 24 | QListWidgetItem, 25 | QDialogButtonBox, 26 | QListWidget, 27 | QAbstractItemView, 28 | ) 29 | 30 | 31 | class LeftList(QListWidget): 32 | """Left list widget containing functions available for image processing.""" 33 | 34 | DragDropSignalLeft = Signal() 35 | 36 | def __init__(self, parent=None): 37 | super(LeftList, self).__init__(parent) 38 | self.setDragDropMode(QAbstractItemView.DragDrop) 39 | self.setDefaultDropAction(Qt.MoveAction) 40 | self.setSortingEnabled(True) 41 | self.setDefaultDropAction(Qt.CopyAction) 42 | self.setSelectionRectVisible(True) 43 | self.setAcceptDrops(False) 44 | 45 | def convertFunctionsDictToItemList(self, functionsDict: dict): 46 | """Convenience method to convert the dictionary of functions with their parameters to QListWidgetItems""" 47 | if functionsDict == None: 48 | pass 49 | else: 50 | for key in functionsDict.keys(): 51 | # read the content of the dict and then store it as data connected to a QListWidgetItem 52 | function = functionsDict[key][0] 53 | parametersDict = functionsDict[key][1] 54 | parametersHints = functionsDict[key][2] 55 | functionDescription = functionsDict[key][3] 56 | item = QListWidgetItem() 57 | item.setText(key[2:]) 58 | item.setToolTip(functionDescription) 59 | item.setData(Qt.UserRole, [function, parametersDict, parametersHints]) 60 | self.addItem(item) 61 | 62 | def convertItemListToDict(self): 63 | """Convenience method to convert the QListWidgetItems in the list to a dictionary of functions with their parameters""" 64 | functionsDict = {} 65 | for i in range(self.count()): 66 | item = self.item(i) 67 | functionsDict[f"{i+1}." + item.text()] = [ 68 | item.data(Qt.UserRole)[0], 69 | item.data(Qt.UserRole)[1], 70 | item.data(Qt.UserRole)[2], 71 | item.toolTip(), 72 | ] 73 | return functionsDict 74 | 75 | 76 | class RightList(QListWidget): 77 | """Right list widget with functions to be used for image processing. Individual items are sortable, to delete individual items, the items can simply be dragged into the left list. It is possible to have the same function multiple times""" 78 | 79 | DragDropSignalRight = Signal() 80 | 81 | def __init__(self, parent=None): 82 | super(RightList, self).__init__(parent) 83 | self.setDragDropMode(QAbstractItemView.DragDrop) 84 | self.setDefaultDropAction(Qt.MoveAction) 85 | self.setSelectionRectVisible(True) 86 | self.setSortingEnabled(False) 87 | 88 | def convertFunctionsDictToItemList(self, functionsDict: dict): 89 | """Convenience method to convert the dictionary of functions with their parameters to QListWidgetItems""" 90 | if functionsDict == None: 91 | pass 92 | else: 93 | for key in functionsDict.keys(): 94 | function = functionsDict[key][0] 95 | parametersDict = functionsDict[key][1] 96 | parametersHints = functionsDict[key][2] 97 | functionDescription = functionsDict[key][3] 98 | item = QListWidgetItem() 99 | item.setText(key[2:]) 100 | item.setToolTip(functionDescription) 101 | item.setData(Qt.UserRole, [function, parametersDict, parametersHints]) 102 | self.addItem(item) 103 | 104 | def convertItemListToDict(self): 105 | """Convenience method to convert the QListWidgetItems in the list to a dictionary of functions with their parameters""" 106 | functionsDict = {} 107 | for i in range(self.count()): 108 | item = self.item(i) 109 | functionsDict[f"{i+1}." + item.text()] = [ 110 | item.data(Qt.UserRole)[0], 111 | item.data(Qt.UserRole)[1], 112 | item.data(Qt.UserRole)[2], 113 | item.toolTip(), 114 | ] 115 | return functionsDict 116 | 117 | 118 | class ParameterDialog(QDialog): 119 | """Dialog Window to change parameters of a function that is inside the right list. The dialog appears when an item is double clicked.""" 120 | 121 | def __init__(self, parameters, parameterHints, parent=None): 122 | super(ParameterDialog, self).__init__(parent) 123 | 124 | self.setWindowTitle("Change Parameters") 125 | QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel 126 | self.parameters = parameters 127 | self.buttonBox = QDialogButtonBox(QBtn) 128 | self.buttonBox.accepted.connect(self.accept) 129 | self.buttonBox.rejected.connect(self.reject) 130 | self.parameterDialogLayout = QFormLayout() 131 | self.setLayout(self.parameterDialogLayout) 132 | self.lineEdits = {} 133 | for parameter, value in self.parameters.items(): 134 | lineEdit = QLineEdit(f"{value}") 135 | lineEdit.setToolTip(parameterHints[parameter]) 136 | self.lineEdits[parameter] = lineEdit 137 | self.parameterDialogLayout.addRow(parameter, self.lineEdits[parameter]) 138 | self.parameterDialogLayout.addWidget(self.buttonBox) 139 | 140 | def getValues(self): 141 | """Get the values from the linedits and interpret the strings as values for the parameters. '(1,2)' will be interpreted as tuple(1,2)""" 142 | for linedit in self.lineEdits.keys(): 143 | self.lineEdits[linedit] = literal_eval(self.lineEdits[linedit].text()) 144 | return self.lineEdits 145 | 146 | 147 | class ExistingFilterGroupsDialog(QDialog): 148 | """Dialog Window to show existing filter groups. Possibility to delete or load existing filters""" 149 | 150 | filterDeleted = Signal() 151 | 152 | def __init__(self, parent=None): 153 | super(ExistingFilterGroupsDialog, self).__init__(parent) 154 | 155 | self.setWindowTitle("Exisiting Filter-Groups") 156 | self.buttonBox = QDialogButtonBox() 157 | self.settings = Settings() 158 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 159 | self.buttonBox.addButton(QDialogButtonBox.Ok) 160 | self.buttonBox.addButton(QDialogButtonBox.Cancel) 161 | self.buttonBox.button(QDialogButtonBox.Ok).setText("Load Filter") 162 | self.buttonBox.button(QDialogButtonBox.Cancel).setText("Delete Filter") 163 | self.buttonBox.accepted.connect(self.accept) 164 | self.buttonBox.rejected.connect(self.deleteFilterGroup) 165 | self.parameterDialogLayout = QVBoxLayout() 166 | self.listWidget = QListWidget() 167 | for filter in self.filterGroupsDict.keys(): 168 | item = QListWidgetItem() 169 | item.setText(filter) 170 | self.listWidget.addItem(item) 171 | self.setLayout(self.parameterDialogLayout) 172 | self.parameterDialogLayout.addWidget(self.listWidget) 173 | self.parameterDialogLayout.addWidget(self.buttonBox) 174 | 175 | def deleteFilterGroup(self): 176 | selectedItems = self.listWidget.selectedItems() 177 | if not selectedItems: 178 | return 179 | for item in selectedItems: 180 | self.listWidget.takeItem(self.listWidget.row(item)) 181 | text = item.text() 182 | self.filterGroupsDict.pop(text) 183 | self.settings.setFilterGroupsDict(self.filterGroupsDict) 184 | self.filterDeleted.emit() 185 | 186 | 187 | class FilterGroupCreationWidget(QWidget): 188 | """Main Window containing two lists, one Right List and one Left List. Drag items from the left to the right list to add them to the processing pipeline. The left listed can be searched for items. New items can be loaded from files.The right list can be cleared. By applying the right list, the functions (associated eith the items in the right list) will be put in to a processing pipeline.""" 189 | 190 | filterAdded = Signal() 191 | 192 | def __init__(self, parent=None): 193 | super(QWidget, self).__init__(parent) 194 | self.settings = Settings() 195 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 196 | self.filters_folder = image_filtersPath 197 | self.initializeMainWindow() 198 | 199 | self.loadFiles() 200 | 201 | def dragEnterEvent(self, e): 202 | e.accept() 203 | 204 | def openParameterDialogWindow(self, item: QListWidgetItem): 205 | """Dialog Window that gets opened when a item in the right list is double clicked.""" 206 | dialog = ParameterDialog(item.data(Qt.UserRole)[1], item.data(Qt.UserRole)[2]) 207 | answer = dialog.exec_() 208 | 209 | # if the user presses accept, then change the parameters of the corresponding function 210 | 211 | if answer: 212 | data = item.data(Qt.UserRole) 213 | values = dialog.getValues() 214 | data[1] = values 215 | item.setData(Qt.UserRole, data) 216 | data = item.data(Qt.UserRole) 217 | 218 | dialog.show() 219 | 220 | def dropEvent(self, e): 221 | e.accept() 222 | 223 | def loadFiles(self): 224 | """load the files from the folder 'image filters' and and load the functions from each file. The functions and parameters are stored as data associated with each item.""" 225 | self.leftList.clear() 226 | moduleList = [] 227 | for importer, modname, ispkg in pkgutil.iter_modules(image_filters.__path__): 228 | if not modname == "__init__": 229 | moduleList.append( 230 | "napari_live_recording.processing_engine.image_filters." + modname 231 | ) 232 | for module in map(importlib.import_module, moduleList): 233 | for func in filter(callable, module.__dict__.values()): 234 | loadedInformation = [ 235 | func, 236 | module.parametersDict, 237 | module.parametersHints, 238 | ] 239 | item = QListWidgetItem() 240 | item.setText(func.__name__) 241 | item.setToolTip(module.functionDescription) 242 | item.setData(Qt.UserRole, loadedInformation) 243 | self.leftList.addItem(item) 244 | 245 | def clearListWidget(self): 246 | """Clearing the right list.""" 247 | self.rightList.clear() 248 | self.filterNameLineEdit.clear() 249 | 250 | def returnRightListContent(self, isPreview: bool = False): 251 | """Use the items in the right list in their current order and create a nested function from them. For creating the nested function 'pims' used to achieve lazy evaluation.""" 252 | functionsDict = self.rightList.convertItemListToDict() 253 | filterName = self.filterNameLineEdit.text() 254 | 255 | if isPreview: 256 | composedFunction = createPipelineFilter(functionsDict) 257 | return composedFunction 258 | else: 259 | if filterName == "": 260 | self.alertWindow("Please Name your Filter-Group.") 261 | else: 262 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 263 | self.filterGroupsDict[filterName] = functionsDict 264 | self.settings.setFilterGroupsDict(self.filterGroupsDict) 265 | self.filterAdded.emit() 266 | 267 | def updatePreviewImage(self): 268 | try: 269 | currentFilterGroup = self.returnRightListContent(True) 270 | newImage = currentFilterGroup(self.image) 271 | self.imageView.setImage(newImage) 272 | except Exception as e: 273 | self.alertWindow(str(e)) 274 | print("Error", e) 275 | 276 | def alertWindow(self, text): 277 | dialog = QMessageBox() 278 | dialog.setText("Current Filters not applicable: " + text) 279 | dialog.exec() 280 | 281 | def updateDisplay(self, text: str): 282 | """Update the left list, i.e. hide items in the left list. Is used when text in the searchbar is changed.""" 283 | for i in range(self.leftList.count()): 284 | item = self.leftList.item(i) 285 | item.setHidden(True) 286 | items = self.leftList.findItems(text, Qt.MatchContains) 287 | for item in items: 288 | item.setHidden(False) 289 | 290 | def addFiles(self): 291 | """Adds selected files from QFiledialog to the 'image_filters'folder. After that the 'image_filters' folders is loaded again.""" 292 | filepaths, _ = QFileDialog.getOpenFileNames( 293 | self, caption="Load files", filter="Python Files (*.py)" 294 | ) 295 | for filepath in filepaths: 296 | if filepath != "": 297 | shutil.copy(filepath, self.filters_folder) 298 | self.loadFiles() 299 | 300 | def showExistingFilterGroups(self): 301 | existingFilterGroupsDialog = ExistingFilterGroupsDialog() 302 | existingFilterGroupsDialog.filterDeleted.connect( 303 | lambda: self.filterAdded.emit() 304 | ) 305 | accept = existingFilterGroupsDialog.exec() 306 | if accept: 307 | self.clearListWidget() 308 | item = existingFilterGroupsDialog.listWidget.currentItem() 309 | text = item.text() 310 | functionsDict = self.filterGroupsDict[text] 311 | self.rightList.convertFunctionsDictToItemList(functionsDict) 312 | else: 313 | pass 314 | existingFilterGroupsDialog.show() 315 | 316 | def loadPreviewImage(self, isDefault=False): 317 | """Method for loading the preview image. Either the default image or a selected image from a folder.""" 318 | if isDefault: 319 | if self.settings.settings.contains("Preview Image"): 320 | self.image = self.settings.getSetting("Preview Image") 321 | else: 322 | image_ = cv.imread(defaultImagePath) 323 | image_ = cv.transpose(image_) 324 | self.image = image_ 325 | self.imageView.setImage(self.image) 326 | else: 327 | try: 328 | filepath, _ = QFileDialog.getOpenFileName( 329 | self, 330 | caption="Load new preview image", 331 | filter="Image Files (*.png *.jpg *jpeg)", 332 | ) 333 | image_ = cv.imdecode( 334 | np.fromfile(filepath, np.uint8), cv.IMREAD_UNCHANGED 335 | ) 336 | image_ = cv.transpose(image_) 337 | image_ = cv.cvtColor(image_, cv.COLOR_BGR2RGB) 338 | self.image = image_ 339 | self.settings.setSetting("Preview Image", self.image) 340 | self.imageView.setImage(self.image) 341 | except: 342 | pass 343 | 344 | def initializeMainWindow(self): 345 | """Creates the widgets and the layouts of the MainWindow""" 346 | self.setAcceptDrops(True) 347 | # Left column 348 | self.leftContainer = QGroupBox() 349 | self.leftContainerLayout = QGridLayout() 350 | self.leftContainer.setLayout(self.leftContainerLayout) 351 | 352 | self.searchbar = QLineEdit() 353 | self.searchbar.textChanged.connect(self.updateDisplay) 354 | self.searchbar.setClearButtonEnabled(True) 355 | 356 | self.load_btn = QPushButton("Add new Function") 357 | self.load_btn.clicked.connect(self.addFiles) 358 | self.leftList = LeftList() 359 | 360 | self.leftContainerLayout.addWidget(self.searchbar, 0, 0, 1, 2) 361 | self.leftContainerLayout.addWidget(self.leftList, 1, 0, 1, 2) 362 | self.leftContainerLayout.addWidget(self.load_btn, 2, 0, 1, 2) 363 | 364 | # middle Column 365 | self.rightContainer = QGroupBox() 366 | self.rightContainerLayout = QGridLayout() 367 | self.rightContainer.setLayout(self.rightContainerLayout) 368 | 369 | self.rightList = RightList() 370 | self.rightList.count 371 | self.rightList.setMouseTracking(True) 372 | self.clear_btn = QPushButton("Clear") 373 | self.createFilter_btn = QPushButton("Create Filter-Group") 374 | self.filterNameLineEdit = QLineEdit() 375 | self.filterNameLabel = QLabel("Filter Name") 376 | self.loadExistingFilter_btn = QPushButton("Show Exisiting Filter-Groups") 377 | self.loadExistingFilter_btn.clicked.connect(self.showExistingFilterGroups) 378 | self.clear_btn.clicked.connect(self.clearListWidget) 379 | self.createFilter_btn.clicked.connect(self.returnRightListContent) 380 | self.rightList.itemDoubleClicked.connect(self.openParameterDialogWindow) 381 | self.rightContainerLayout.addWidget(self.loadExistingFilter_btn, 0, 0, 1, 2) 382 | self.rightContainerLayout.addWidget(self.rightList, 1, 0, 1, 2) 383 | self.rightContainerLayout.addWidget(self.clear_btn, 3, 0) 384 | self.rightContainerLayout.addWidget(self.createFilter_btn, 3, 1) 385 | self.rightContainerLayout.addWidget(self.filterNameLabel, 2, 0) 386 | self.rightContainerLayout.addWidget(self.filterNameLineEdit, 2, 1) 387 | 388 | # right Column 389 | self.previewContainer = QGroupBox() 390 | self.previewContainerLayout = QGridLayout() 391 | self.previewContainer.setLayout(self.previewContainerLayout) 392 | self.refresh_btn = QPushButton("Refresh") 393 | self.loadNewPreviewImage_btn = QPushButton("Load new Image") 394 | 395 | self.refresh_btn.clicked.connect(self.updatePreviewImage) 396 | self.loadNewPreviewImage_btn.clicked.connect(self.loadPreviewImage) 397 | 398 | self.imageView = ImageView(parent=self.previewContainer) 399 | self.imageView.ui.roiBtn.hide() 400 | self.imageView.ui.menuBtn.hide() 401 | self.loadPreviewImage(True) 402 | self.previewContainerLayout.addWidget(self.imageView, 0, 0, 1, 2) 403 | self.previewContainerLayout.addWidget(self.refresh_btn, 1, 0, 1, 1) 404 | self.previewContainerLayout.addWidget(self.loadNewPreviewImage_btn, 1, 1, 1, 1) 405 | 406 | # Combining all three columns 407 | layout = QGridLayout(self) 408 | 409 | layout.addWidget(self.leftContainer, 0, 0) 410 | layout.addWidget(self.rightContainer, 0, 1) 411 | layout.addWidget(self.previewContainer, 0, 2) 412 | layout.setColumnStretch(0, 1) 413 | layout.setColumnStretch(1, 1) 414 | layout.setColumnStretch(2, 1) 415 | 416 | 417 | -------------------------------------------------------------------------------- /src/napari_live_recording/control/__init__.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import tifffile.tifffile as tiff 3 | import os 4 | from contextlib import contextmanager 5 | from napari.qt.threading import thread_worker, FunctionWorker 6 | from qtpy.QtCore import QThread, QObject, Signal, QTimer 7 | from napari_live_recording.common import ( 8 | TIFF_PHOTOMETRIC_MAP, 9 | WriterInfo, 10 | RecordType, 11 | Settings, 12 | createPipelineFilter, 13 | ) 14 | from napari_live_recording.control.devices.interface import ICamera 15 | from napari_live_recording.control.frame_buffer import Framebuffer 16 | from typing import Dict, NamedTuple 17 | from functools import partial 18 | 19 | 20 | class SignalCounter(QObject): 21 | maxCountReached = Signal() 22 | 23 | def __init__(self) -> None: 24 | self.maxCount = 0 25 | self.count = 0 26 | super().__init__() 27 | 28 | def increaseCounter(self): 29 | self.count += 1 30 | if self.count == self.maxCount: 31 | self.maxCountReached.emit() 32 | self.count = 0 33 | 34 | 35 | class LocalController(NamedTuple): 36 | """Named tuple to wrap a camera device and the relative thread into which the device lives.""" 37 | 38 | thread: QThread 39 | device: ICamera 40 | 41 | 42 | class MainController(QObject): 43 | recordFinished = Signal() 44 | newTimePoint = Signal(int) 45 | newMaxTimePoint = Signal(int) 46 | cameraDeleted = Signal(bool) 47 | 48 | def __init__(self) -> None: 49 | """Main Controller class. Stores all camera objects to access live and stack recordings.""" 50 | super().__init__() 51 | self.deviceControllers: Dict[str, LocalController] = {} 52 | self.rawBuffers: Dict[str, Framebuffer] = {} 53 | self.preProcessingBuffers: Dict[str, Framebuffer] = {} 54 | self.postProcessingBuffers: Dict[str, Framebuffer] = {} 55 | self.settings = Settings() 56 | self.filterGroupsDict = self.settings.getFilterGroupsDict() 57 | self.stackSize = 50 58 | self.bufferWorker = None 59 | self.__isAcquiring = False 60 | self.isProcessing: Dict[str, bool] = {} 61 | self.isAppending: Dict[str, bool] = {} 62 | self.recordSignalCounter = SignalCounter() 63 | self.recordSignalCounter.maxCountReached.connect( 64 | lambda: self.recordFinished.emit() 65 | ) 66 | self.recordFinished.connect(self.resetRecordingCounter) 67 | 68 | @property 69 | def isAcquiring(self) -> bool: 70 | return self.__isAcquiring 71 | 72 | @contextmanager 73 | def appendToBufferPaused(self): 74 | if self.isAcquiring: 75 | try: 76 | self.appendToBuffer(False) 77 | yield 78 | finally: 79 | self.appendToBuffer(True) 80 | else: 81 | yield 82 | 83 | def addCamera(self, cameraKey: str, camera: ICamera) -> str: 84 | """Adds a new device in the controller, with a thread in which the device operates.""" 85 | thread = QThread() 86 | camera.moveToThread(thread) 87 | deviceController = LocalController(thread, camera) 88 | self.deviceControllers[cameraKey] = deviceController 89 | self.deviceControllers[cameraKey].thread.start() 90 | self.rawBuffers[cameraKey] = Framebuffer( 91 | self.stackSize, camera=camera, cameraKey=cameraKey, capacity=self.stackSize 92 | ) 93 | self.preProcessingBuffers[cameraKey] = Framebuffer( 94 | self.stackSize, camera=camera, cameraKey=cameraKey, capacity=self.stackSize 95 | ) 96 | self.postProcessingBuffers[cameraKey] = Framebuffer( 97 | self.stackSize, camera=camera, cameraKey=cameraKey, capacity=self.stackSize 98 | ) 99 | self.isProcessing[cameraKey] = False 100 | self.isAppending[cameraKey] = False 101 | 102 | self.recordSignalCounter.maxCount += 3 103 | return cameraKey 104 | 105 | def appendToBuffer(self, toggle: bool): 106 | self.__isAcquiring = toggle 107 | 108 | @thread_worker(worker_class=FunctionWorker, start_thread=False) 109 | def appendToBufferLoop(): 110 | while self.isAcquiring: 111 | for cameraKey in self.deviceControllers.keys(): 112 | try: 113 | if self.isAppending[cameraKey]: 114 | currentFrame = np.copy( 115 | self.deviceControllers[cameraKey].device.grabFrame() 116 | ) 117 | self.rawBuffers[cameraKey].addFrame(currentFrame) 118 | self.preProcessingBuffers[cameraKey].addFrame(currentFrame) 119 | except Exception as e: 120 | pass 121 | 122 | if self.isAcquiring: 123 | for key in self.deviceControllers.keys(): 124 | self.deviceControllers[key].device.setAcquisitionStatus(True) 125 | self.bufferWorker = appendToBufferLoop() 126 | self.bufferWorker.start() 127 | else: 128 | if self.bufferWorker is not None: 129 | self.bufferWorker.quit() 130 | 131 | for key in self.deviceControllers.keys(): 132 | self.deviceControllers[key].device.setAcquisitionStatus(False) 133 | try: 134 | self.rawBuffers[key].appendingFinished.disconnect() 135 | except: 136 | pass 137 | 138 | def processFrames( 139 | self, status: bool, type: str, camName: str, selectedFilterGroup: Dict = None 140 | ): 141 | @thread_worker( 142 | worker_class=FunctionWorker, 143 | start_thread=False, 144 | ) 145 | def processFramesLoop(camName: str) -> None: 146 | self.isProcessing[camName] = True 147 | # if no filter-group is selected for camName 148 | if list(selectedFilterGroup.values())[0] == None: 149 | while self.preProcessingBuffers[camName].empty: 150 | pass 151 | 152 | while ( 153 | self.isAppending[camName] 154 | or not self.preProcessingBuffers[camName].empty 155 | ): 156 | try: 157 | self.postProcessingBuffers[camName].addFrame( 158 | self.preProcessingBuffers[camName].popHead() 159 | ) 160 | except Exception as e: 161 | pass 162 | # if a certain filter-group is selected for camName 163 | else: 164 | filterFunction = createPipelineFilter(selectedFilterGroup) 165 | while self.preProcessingBuffers[camName].empty: 166 | pass 167 | 168 | while ( 169 | self.isAppending[camName] 170 | or not self.preProcessingBuffers[camName].empty 171 | ): 172 | try: 173 | frame_processed = filterFunction( 174 | self.preProcessingBuffers[camName].popHead() 175 | ) 176 | self.postProcessingBuffers[camName].addFrame(frame_processed) 177 | except Exception as e: 178 | pass 179 | self.isProcessing[camName] = False 180 | 181 | if type == "live": 182 | processingWorker = processFramesLoop(camName) 183 | processingWorker.finished.connect( 184 | lambda: processingWorker.finished.disconnect() 185 | ) 186 | if status: 187 | processingWorker.start() 188 | else: 189 | self.isProcessing[camName] = False 190 | # manually clear buffer so processing stops, cause empty buffer and self.isProcessing[camName] == False 191 | self.preProcessingBuffers[camName].clearBuffer() 192 | processingWorker.quit() 193 | 194 | # type == "recording" 195 | else: 196 | processingWorker = processFramesLoop(camName) 197 | 198 | processingWorker.finished.connect( 199 | lambda: self.closeWorkerConnection(processingWorker) 200 | ) 201 | if status: 202 | processingWorker.start() 203 | else: 204 | self.isProcessing[camName] = False 205 | processingWorker.quit() 206 | 207 | def changeStackSize(self, newStackSize: int): 208 | self.stackSize = newStackSize 209 | for cameraKey in self.deviceControllers.keys(): 210 | self.rawBuffers[cameraKey].changeStacksize(newStacksize=self.stackSize) 211 | self.preProcessingBuffers[cameraKey].changeStacksize( 212 | newStacksize=self.stackSize 213 | ) 214 | self.postProcessingBuffers[cameraKey].changeStacksize( 215 | newStacksize=self.stackSize 216 | ) 217 | 218 | def deleteCamera(self, cameraKey: str) -> None: 219 | """Deletes a camera device.""" 220 | try: 221 | self.appendToBuffer(False) 222 | self.processFrames(False, "nothing", cameraKey) 223 | self.__isAcquiring = False 224 | self.isAppending[cameraKey] = False 225 | self.isProcessing[cameraKey] = False 226 | self.isProcessing.pop(cameraKey) 227 | self.isAppending.pop(cameraKey) 228 | self.cameraDeleted.emit(False) 229 | 230 | self.deviceControllers[cameraKey].device.close() 231 | self.deviceControllers[cameraKey].thread.quit() 232 | self.deviceControllers[cameraKey].device.deleteLater() 233 | self.deviceControllers[cameraKey].thread.deleteLater() 234 | self.deviceControllers[cameraKey].device.setAcquisitionStatus(False) 235 | self.rawBuffers.pop(cameraKey) 236 | self.preProcessingBuffers.pop(cameraKey) 237 | self.postProcessingBuffers.pop(cameraKey) 238 | 239 | self.recordSignalCounter.maxCount -= 3 240 | except RuntimeError: 241 | # camera already deleted 242 | pass 243 | 244 | def returnNewestFrame(self, cameraKey: str) -> None: 245 | if self.isAcquiring: 246 | newestFrame = self.postProcessingBuffers[cameraKey].returnTail() 247 | return newestFrame 248 | else: 249 | pass 250 | 251 | def live(self, status: bool, filtersList: dict): 252 | for key in filtersList.keys(): 253 | self.isAppending[key] = status 254 | self.rawBuffers[key].allowOverwrite = status 255 | self.preProcessingBuffers[key].allowOverwrite = status 256 | self.postProcessingBuffers[key].allowOverwrite = status 257 | for key in filtersList.keys(): 258 | self.processFrames(status, "live", key, filtersList[key]) 259 | 260 | def snap(self, cameraKey: str, selectedFilter) -> np.ndarray: 261 | self.deviceControllers[cameraKey].device.setAcquisitionStatus(True) 262 | if list(selectedFilter.values())[0] == None: 263 | image = self.deviceControllers[cameraKey].device.grabFrame() 264 | else: 265 | image_ = self.deviceControllers[cameraKey].device.grabFrame() 266 | composedFunction = createPipelineFilter(selectedFilter) 267 | image = composedFunction(image_) 268 | self.deviceControllers[cameraKey].device.setAcquisitionStatus(False) 269 | return image 270 | 271 | def closeWorkerConnection(self, worker: FunctionWorker) -> None: 272 | self.recordSignalCounter.increaseCounter() 273 | worker.finished.disconnect() 274 | 275 | def process(self, filtersList: dict, writerInfo: WriterInfo) -> None: 276 | def closeFile(filename) -> None: 277 | files[filename].close() 278 | 279 | def timeStackBuffer(camName: str, acquisitionTime: float): 280 | #TODO change into timer 281 | self.preProcessingBuffers[camName].allowOverwrite = False 282 | self.preProcessingBuffers[camName].clearBuffer() 283 | self.preProcessingBuffers[camName].stackSize = round(acquisitionTime * 30) 284 | self.postProcessingBuffers[camName].allowOverwrite = False 285 | self.postProcessingBuffers[camName].clearBuffer() 286 | self.postProcessingBuffers[camName].stackSize = round(acquisitionTime * 30) 287 | 288 | def fixedStackBuffer(camName: str, stackSize: int): 289 | self.preProcessingBuffers[camName].allowOverwrite = False 290 | self.preProcessingBuffers[camName].clearBuffer() 291 | self.preProcessingBuffers[camName].stackSize = stackSize 292 | self.postProcessingBuffers[camName].allowOverwrite = False 293 | self.postProcessingBuffers[camName].clearBuffer() 294 | self.postProcessingBuffers[camName].stackSize = stackSize 295 | 296 | def toggledBuffer(camName: str): 297 | self.preProcessingBuffers[camName].allowOverwrite = True 298 | self.postProcessingBuffers[camName].allowOverwrite = True 299 | 300 | @thread_worker( 301 | worker_class=FunctionWorker, 302 | connect={"returned": closeFile}, 303 | start_thread=False, 304 | ) 305 | def stackWriteToFile(filename: str, camName: str, writeFunc) -> str: 306 | while not self.isProcessing[camName]: 307 | pass 308 | while ( 309 | not self.postProcessingBuffers[camName].empty 310 | or self.isProcessing[camName] 311 | ): 312 | try: 313 | if self.postProcessingBuffers[camName].empty: 314 | pass 315 | else: 316 | frame = self.postProcessingBuffers[camName].popHead() 317 | writeFunc(frame) 318 | except Exception as e: 319 | pass 320 | 321 | return filename 322 | 323 | @thread_worker( 324 | worker_class=FunctionWorker, 325 | connect={"returned": closeFile}, 326 | start_thread=False, 327 | ) 328 | def toggledWriteToFile(filename: str, camName: str, writeFunc) -> str: 329 | while not self.isProcessing[camName]: 330 | pass 331 | while ( 332 | not self.postProcessingBuffers[camName].empty 333 | or self.isProcessing[camName] 334 | ): 335 | try: 336 | if self.postProcessingBuffers[camName].empty: 337 | pass 338 | else: 339 | frame = self.postProcessingBuffers[camName].popHead() 340 | 341 | writeFunc(frame) 342 | except Exception as e: 343 | pass 344 | return filename 345 | 346 | # when building the writer function for a specific type of 347 | # file format, we expect the dictionary to have the appropriate arguments; 348 | # this job is handled by the user interface, so we do not need to add 349 | # any type of try-except clauses for the dictionary keys 350 | filenames = [ 351 | os.path.join( 352 | writerInfo.folder, 353 | camName.replace(":", "-").replace(" ", "-") + "_" + writerInfo.filename, 354 | ) 355 | for camName in filtersList.keys() 356 | ] 357 | sizes = [ 358 | self.deviceControllers[camName].device.roiShape.pixelSizes 359 | for camName in filtersList.keys() 360 | ] 361 | colorMaps = [ 362 | self.deviceControllers[camName].device.colorType 363 | for camName in filtersList.keys() 364 | ] 365 | files = {} 366 | extension = "" 367 | if writerInfo.fileFormat in [1, 2]: 368 | kwargs = dict() 369 | if writerInfo.fileFormat == 1: # ImageJ TIFF 370 | extension = ".tif" 371 | kwargs.update(dict(imagej=True)) 372 | else: # OME-TIFF 373 | extension = ".ome.tif" 374 | kwargs.update(dict(ome=True)) 375 | files = { 376 | filename: tiff.TiffWriter(filename + extension, **kwargs) 377 | for filename in filenames 378 | } 379 | writeFuncs = [ 380 | partial( 381 | file.write, 382 | photometric=TIFF_PHOTOMETRIC_MAP[colorMap][0], 383 | software="napari-live-recording", 384 | contiguous=kwargs.get("imagej", False), 385 | ) 386 | for file, size, colorMap in zip(list(files.values()), sizes, colorMaps) 387 | ] 388 | 389 | else: 390 | # TODO: implement HDF5 writing 391 | raise ValueError("Unsupported file format selected for recording!") 392 | 393 | fileWorkers = [] 394 | 395 | if writerInfo.recordType == RecordType["Number of frames"]: 396 | for camName in filtersList.keys(): 397 | fixedStackBuffer(camName, writerInfo.stackSize) 398 | 399 | fileWorkers = [ 400 | stackWriteToFile(filename, camName, writeFunc) 401 | for filename, camName, writeFunc in zip( 402 | filenames, filtersList.keys(), writeFuncs 403 | ) 404 | ] 405 | 406 | elif writerInfo.recordType == RecordType["Time (seconds)"]: 407 | for camName in filtersList.keys(): 408 | timeStackBuffer(camName, writerInfo.acquisitionTime) 409 | fileWorkers = [ 410 | stackWriteToFile(filename, camName, writeFunc) 411 | for filename, camName, writeFunc in zip( 412 | filenames, filtersList.keys(), writeFuncs 413 | ) 414 | ] 415 | 416 | elif writerInfo.recordType == RecordType["Toggled"]: 417 | for camName in filtersList.keys(): 418 | toggledBuffer(camName) 419 | fileWorkers = [ 420 | toggledWriteToFile(filename, camName, writeFunc) 421 | for filename, camName, writeFunc in zip( 422 | filenames, filtersList.keys(), writeFuncs 423 | ) 424 | ] 425 | 426 | for camName in filtersList.keys(): 427 | self.processFrames(True, "recording", camName, filtersList[camName]) 428 | 429 | for fileworker in fileWorkers: 430 | fileworker.finished.connect(lambda: self.closeWorkerConnection(fileworker)) 431 | fileworker.start() 432 | 433 | def record(self, camNames: list, writerInfo: WriterInfo) -> None: 434 | self.recordingTimer = QTimer(singleShot=True) 435 | 436 | def closeFile(filename) -> None: 437 | files[filename].close() 438 | 439 | def timeStackBuffer(camName: str, acquisitionTime: float): 440 | self.rawBuffers[camName].allowOverwrite = False 441 | self.rawBuffers[camName].clearBuffer() 442 | self.rawBuffers[camName].stackSize = round(acquisitionTime * 30) 443 | self.rawBuffers[camName].appendingFinished.connect( 444 | self.stopAppendingForRecording 445 | ) 446 | self.isAppending[camName] = True 447 | 448 | def fixedStackBuffer(camName: str, stackSize: int): 449 | self.rawBuffers[camName].allowOverwrite = False 450 | self.rawBuffers[camName].clearBuffer() 451 | self.rawBuffers[camName].stackSize = stackSize 452 | self.rawBuffers[camName].appendingFinished.connect( 453 | self.stopAppendingForRecording 454 | ) 455 | self.isAppending[camName] = True 456 | 457 | def toggledBuffer(camName: str): 458 | self.rawBuffers[camName].allowOverwrite = True 459 | self.isAppending[camName] = True 460 | 461 | @thread_worker( 462 | worker_class=FunctionWorker, 463 | connect={"returned": closeFile}, 464 | start_thread=False, 465 | ) 466 | def stackWriteToFile(filename: str, camName: str, writeFunc) -> str: 467 | try: 468 | while self.rawBuffers[camName].empty: 469 | pass 470 | while self.isAppending[camName] or not self.rawBuffers[camName].empty: 471 | try: 472 | frame = self.rawBuffers[camName].popHead() 473 | writeFunc(frame) 474 | except Exception as e: 475 | pass 476 | except Exception as e: 477 | pass 478 | return filename 479 | 480 | @thread_worker( 481 | worker_class=FunctionWorker, 482 | connect={"returned": closeFile}, 483 | start_thread=False, 484 | ) 485 | def toggledWriteToFile(filename: str, camName: str, writeFunc) -> str: 486 | while self.rawBuffers[camName].empty: 487 | pass 488 | while self.isAppending[camName] or not self.rawBuffers[camName].empty: 489 | try: 490 | writeFunc(self.rawBuffers[camName].popHead()) 491 | except: 492 | pass 493 | return filename 494 | 495 | # when building the writer function for a specific type of 496 | # file format, we expect the dictionary to have the appropriate arguments; 497 | # this job is handled by the user interface, so we do not need to add 498 | # any type of try-except clauses for the dictionary keys 499 | filenames = [ 500 | os.path.join( 501 | writerInfo.folder, camName.replace(":", "-") + "_" + writerInfo.filename 502 | ) 503 | for camName in camNames 504 | ] 505 | colorMaps = [ 506 | self.deviceControllers[camName].device.colorType for camName in camNames 507 | ] 508 | files = {} 509 | extension = "" 510 | if writerInfo.fileFormat in [1, 2]: 511 | kwargs = dict() 512 | if writerInfo.fileFormat == 1: # ImageJ TIFF 513 | extension = ".tif" 514 | kwargs.update(dict(imagej=True)) 515 | else: # OME-TIFF 516 | extension = ".ome.tif" 517 | kwargs.update(dict(ome=True)) 518 | files = { 519 | filename: tiff.TiffWriter(filename + extension, **kwargs) 520 | for filename in filenames 521 | } 522 | writeFuncs = [ 523 | partial( 524 | file.write, 525 | photometric=TIFF_PHOTOMETRIC_MAP[colorMap][0], 526 | software="napari-live-recording", 527 | contiguous=kwargs.get("imagej", False), 528 | ) 529 | for file, colorMap in zip(list(files.values()), colorMaps) 530 | ] 531 | else: 532 | # TODO: implement HDF5 writing 533 | raise ValueError( 534 | "Unsupported file format selected for recording! HDF5 will be implemented in the future." 535 | ) 536 | 537 | fileWorkers = [] 538 | 539 | if writerInfo.recordType == RecordType["Number of frames"]: 540 | for camName in camNames: 541 | fixedStackBuffer(camName, writerInfo.stackSize) 542 | 543 | fileWorkers = [ 544 | stackWriteToFile(filename, camName, writeFunc) 545 | for filename, camName, writeFunc in zip(filenames, camNames, writeFuncs) 546 | ] 547 | elif writerInfo.recordType == RecordType["Time (seconds)"]: 548 | for camName in camNames: 549 | timeStackBuffer(camName, writerInfo.acquisitionTime) 550 | fileWorkers = [ 551 | stackWriteToFile(filename, camName, writeFunc) 552 | for filename, camName, writeFunc in zip(filenames, camNames, writeFuncs) 553 | ] 554 | elif writerInfo.recordType == RecordType["Toggled"]: 555 | for camName in camNames: 556 | toggledBuffer(camName) 557 | fileWorkers = [ 558 | toggledWriteToFile(filename, camName, writeFunc) 559 | for filename, camName, writeFunc in zip(filenames, camNames, writeFuncs) 560 | ] 561 | 562 | for fileworker in fileWorkers: 563 | fileworker.finished.connect(lambda: self.closeWorkerConnection(fileworker)) 564 | fileworker.start() 565 | 566 | def stopAppendingForRecording(self, camName): 567 | self.isAppending[camName] = False 568 | 569 | def resetRecordingCounter(self): 570 | self.recordSignalCounter.count = 0 571 | 572 | def cleanup(self): 573 | for key in self.deviceControllers.keys(): 574 | self.deleteCamera(key) 575 | -------------------------------------------------------------------------------- /src/napari_live_recording/ui/widgets.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QObject 2 | import numpy as np 3 | import microscope.cameras 4 | from pkgutil import iter_modules 5 | from typing import Union 6 | from qtpy.QtCore import Qt, QObject, Signal, QTimer 7 | from qtpy.QtWidgets import ( 8 | QWidget, 9 | QLabel, 10 | QComboBox, 11 | QSpinBox, 12 | QLineEdit, 13 | QScrollArea, 14 | QPushButton, 15 | QFileDialog, 16 | QStackedWidget, 17 | QProgressBar, 18 | QFormLayout, 19 | QGridLayout, 20 | QGroupBox, 21 | ) 22 | from napari_live_recording.control.devices.interface import NumberParameter 23 | from napari_live_recording.control.devices import ICamera 24 | from superqt import QLabeledSlider, QLabeledDoubleSlider, QEnumComboBox 25 | from abc import ABC, abstractmethod 26 | from dataclasses import replace 27 | from enum import Enum 28 | from napari_live_recording.common import ( 29 | ROI, 30 | FileFormat, 31 | RecordType, 32 | MMC_DEVICE_MAP, 33 | microscopeDeviceDict, 34 | baseRecordingFolder, 35 | Settings, 36 | ) 37 | from typing import Dict, List, Tuple 38 | from napari_live_recording.processing_engine.processing_gui import ( 39 | FilterGroupCreationWidget, 40 | ) 41 | 42 | 43 | class Timer(QTimer): 44 | pass 45 | 46 | 47 | class LocalWidget(ABC): 48 | def __init__( 49 | self, 50 | internalWidget: QWidget, 51 | name: str, 52 | unit: str = "", 53 | orientation: str = "left", 54 | ) -> None: 55 | """Common widget constructor. 56 | 57 | Args: 58 | internalWidget (QWidget): widget to construct the form layout. 59 | name (str): parameter label description. 60 | unit (str, optional): parameter unit measure. Defaults to "". 61 | orientation (str, optional): label orientation on the layout. Defaults to "left". 62 | """ 63 | super().__init__() 64 | self.__name = name 65 | self.__unit = unit 66 | labelStr = ( 67 | self.__name + " (" + self.__unit + ")" if self.__unit != "" else self.__name 68 | ) 69 | self.label = QLabel(labelStr) 70 | self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) 71 | self.widget = internalWidget 72 | 73 | @property 74 | def isEnabled(self) -> bool: 75 | """Widget is enabled for editing (True) or not (False).""" 76 | return self.widget.isEnabled() 77 | 78 | @isEnabled.setter 79 | def isEnabled(self, enable: bool) -> None: 80 | """Sets widget enabled for editing (True) or not (False).""" 81 | self.widget.setEnabled(enable) 82 | 83 | @abstractmethod 84 | def changeWidgetSettings(self, newParam) -> None: 85 | """Common widget update parameter abstract method.""" 86 | pass 87 | 88 | @property 89 | @abstractmethod 90 | def value(self) -> None: 91 | """Widget current value.""" 92 | pass 93 | 94 | @value.setter 95 | @abstractmethod 96 | def value(self, value: Union[str, int, float]) -> None: 97 | """Widget value setter.""" 98 | pass 99 | 100 | @property 101 | @abstractmethod 102 | def signals(self) -> Dict[str, Signal]: 103 | """Common widget method to expose signals to the device.""" 104 | pass 105 | 106 | 107 | class ComboBox(LocalWidget): 108 | def __init__( 109 | self, param: List[str], name: str, unit: str = "", orientation: str = "left" 110 | ) -> None: 111 | """ComboBox widget. 112 | 113 | Args: 114 | param (List[str]): List of parameters added to the ComboBox. 115 | name (str): parameter label description. 116 | unit (str, optional): parameter unit measure. Defaults to "". 117 | orientation (str, optional): label orientation on the layout. Defaults to "left". 118 | """ 119 | self.combobox = QComboBox() 120 | self.combobox.addItems([str(item) for item in param]) 121 | super().__init__(self.combobox, name, unit, orientation) 122 | 123 | def changeWidgetSettings(self, newParam: List[str]) -> None: 124 | """ComboBox update widget parameter method. Old List of items is deleted. 125 | 126 | Args: 127 | newParam (List[str]): new List of parameters to add to the ComboBox. 128 | """ 129 | self.combobox.clear() 130 | self.combobox.addItems(newParam) 131 | 132 | @property 133 | def value(self) -> Tuple[str, int]: 134 | """Returns a Tuple containing the ComboBox current text and index.""" 135 | return (self.combobox.currentText(), self.combobox.currentIndex()) 136 | 137 | @value.setter 138 | def value(self, value: int) -> None: 139 | """Sets the ComboBox current showed value (based on elements indeces). 140 | 141 | Args: 142 | value (int): index of value to show on the ComboBox. 143 | """ 144 | self.combobox.setCurrentIndex(value) 145 | 146 | @property 147 | def signals(self) -> Dict[str, Signal]: 148 | """Returns a dictionary of signals available for the ComboBox widget. 149 | Exposed signals are: 150 | 151 | - currentIndexChanged, 152 | - currentTextChanged 153 | 154 | Returns: 155 | Dict: Dict of signals (key: function name, value: function objects). 156 | """ 157 | return { 158 | "currentIndexChanged": self.combobox.currentIndexChanged, 159 | "currentTextChanged": self.combobox.currentTextChanged, 160 | } 161 | 162 | 163 | class LabeledSlider(LocalWidget): 164 | def __init__( 165 | self, 166 | param: Union[Tuple[int, int, int], Tuple[float, float, float]], 167 | name: str, 168 | unit: str = "", 169 | orientation: str = "left", 170 | ) -> None: 171 | """Slider widget. 172 | 173 | Args: 174 | param (Tuple[int, int, int])): parameters for spinbox settings: (, , ) 175 | name (str): parameter label description. 176 | unit (str, optional): parameter unit measure. Defaults to "". 177 | orientation (str, optional): label orientation on the layout. Defaults to "left". 178 | """ 179 | if any(isinstance(parameter, float) for parameter in param): 180 | self.__slider = QLabeledDoubleSlider(Qt.Horizontal) 181 | else: 182 | self.__slider = QLabeledSlider(Qt.Horizontal) 183 | self.__slider.setRange(param[0], param[1]) 184 | self.__slider.setValue(param[2]) 185 | super().__init__(self.__slider, name, unit, orientation) 186 | 187 | def changeWidgetSettings(self, newParam: Tuple[int, int, int]) -> None: 188 | """Slider update widget parameter method. 189 | 190 | Args: 191 | newParam (Tuple[int, int, int]): new parameters for SpinBox settings: (, , ) 192 | """ 193 | self.__slider.setRange(newParam[0], newParam[1]) 194 | self.__slider.setValue(newParam[2]) 195 | 196 | @property 197 | def value(self) -> int: 198 | """Returns the Slider current value.""" 199 | return self.__slider.value() 200 | 201 | @value.setter 202 | def value(self, value: int) -> None: 203 | """Sets the DoubleSpinBox current value to show on the widget. 204 | 205 | Args: 206 | value (float): value to set. 207 | """ 208 | self.__slider.setValue(value) 209 | 210 | @property 211 | def signals(self) -> Dict[str, Signal]: 212 | """Returns a dictionary of signals available for the SpinBox widget. 213 | Exposed signals are: 214 | 215 | - valueChanged 216 | 217 | Returns: 218 | Dict: Dict of signals (key: function name, value: function objects). 219 | """ 220 | return {"valueChanged": self.__slider.valueChanged} 221 | 222 | 223 | class LineEdit(LocalWidget): 224 | def __init__( 225 | self, param: str, name: str, unit: str = "", orientation: str = "left" 226 | ) -> None: 227 | """LineEdit widget. 228 | 229 | Args: 230 | param (str): line edit contents 231 | name (str): parameter label description. 232 | unit (str, optional): parameter unit measure. Defaults to "". 233 | orientation (str, optional): label orientation on the layout. Defaults to "left". 234 | editable (bool, optional): sets the LineEdit to be editable. Defaults to False. 235 | """ 236 | self.__lineEdit = QLineEdit(param) 237 | super().__init__(self.__lineEdit, name, unit, orientation) 238 | 239 | def changeWidgetSettings(self, newParam: str) -> None: 240 | """Updates LineEdit text contents. 241 | 242 | Args: 243 | newParam (str): new string for LineEdit. 244 | """ 245 | self.__lineEdit.setText(newParam) 246 | 247 | @property 248 | def value(self) -> str: 249 | """Returns the LineEdit current text.""" 250 | return self.__lineEdit.text() 251 | 252 | @value.setter 253 | def value(self, value: str) -> None: 254 | """Sets the LineEdit current text to show on the widget. 255 | 256 | Args: 257 | value (str): string to set. 258 | """ 259 | self.__lineEdit.setText(value) 260 | 261 | @property 262 | def signals(self) -> Dict[str, Signal]: 263 | """Returns a dictionary of signals available for the LineEdit widget. 264 | Exposed signals are: 265 | 266 | - textChanged, 267 | - textEdited 268 | 269 | Returns: 270 | Dict: Dict of signals (key: function name, value: function objects). 271 | """ 272 | return { 273 | "textChanged": self.__lineEdit.textChanged, 274 | "textEdited": self.__lineEdit.textEdited, 275 | } 276 | 277 | 278 | class CameraSelection(QObject): 279 | newCameraRequested = Signal(str, str, str) 280 | 281 | def __init__(self) -> None: 282 | """Camera selection widget. It includes the following widgets: 283 | 284 | - a ComboBox for camera selection based on strings to identify each camera type; 285 | - a LineEdit for camera ID or serial number input 286 | - a QPushButton to add the camera 287 | 288 | Widget grid layout: 289 | |(0,0-1) ComboBox |(0,2) QPushButton| 290 | |(1,0) LineEdit|Line Edit(1,1) |(1,2) | 291 | 292 | The QPushButton remains disabled as long as no camera is selected (first index is highlited). 293 | """ 294 | super(CameraSelection, self).__init__() 295 | self.group = QGroupBox() 296 | self.layout = QFormLayout() 297 | self.stackedWidget = QStackedWidget() 298 | self.camerasComboBox = ComboBox([], "Interface") 299 | self.nameLineEdit = LineEdit(param="MyCamera", name="Camera name") 300 | self.idLineEdit = LineEdit(param="0", name="Camera ID/SN", orientation="right") 301 | self.adapterComboBox = ComboBox( 302 | list(MMC_DEVICE_MAP.keys()), name="Adapter", orientation="right" 303 | ) 304 | self.deviceComboBox = ComboBox([], name="Device", orientation="right") 305 | 306 | modules = [ 307 | module 308 | for _, module, _ in iter_modules(microscope.cameras.__path__) 309 | if "_" not in module 310 | ] 311 | modules.append("simulators") 312 | 313 | self.microscopeModuleComboBox = ComboBox( 314 | modules, name="Module", orientation="right" 315 | ) 316 | self.microscopeDeviceComboBox = ComboBox([], name="Device", orientation="right") 317 | self.addButton = QPushButton("Add camera") 318 | 319 | self.camerasComboBox.signals["currentIndexChanged"].connect(self.changeWidget) 320 | self.adapterComboBox.signals["currentIndexChanged"].connect( 321 | self.updateDeviceSelectionUI 322 | ) 323 | 324 | self.microscopeModuleComboBox.signals["currentTextChanged"].connect( 325 | self.updateMicroscopeDeviceSelectionUI 326 | ) 327 | 328 | self.camerasComboBox.signals["currentIndexChanged"].connect(self._setAddEnabled) 329 | self.addButton.clicked.connect(self.requestNewCamera) 330 | 331 | def requestNewCamera(self): 332 | interface = self.camerasComboBox.value[0] 333 | label = self.nameLineEdit.value 334 | module = "" 335 | device = "" 336 | if interface in ["MicroManager", "Microscope"]: 337 | if interface == "MicroManager": 338 | module = self.adapterComboBox.value[0] 339 | device = self.deviceComboBox.value[0] 340 | elif interface == "Microscope": 341 | module = self.microscopeModuleComboBox.value[0] 342 | device = self.microscopeDeviceComboBox.value[0] 343 | else: 344 | raise TypeError() 345 | self.newCameraRequested.emit(interface, label, module + " " + device) 346 | else: 347 | self.newCameraRequested.emit(interface, label, self.idLineEdit.value) 348 | self.camerasComboBox.signals["currentIndexChanged"].connect(self._setAddEnabled) 349 | 350 | def setAvailableCameras(self, cameras: List[str]) -> None: 351 | """Sets the ComboBox with the List of available camera devices. 352 | 353 | Args: 354 | cameras (List[str]): List of available camera devices. 355 | """ 356 | # we need to extend the List of available cameras with a selection text 357 | cameras.insert(0, "Select device") 358 | self.camerasComboBox.changeWidgetSettings(cameras) 359 | self.camerasComboBox.isEnabled = True 360 | 361 | def setDeviceSelectionWidget(self, cameras: List[str]) -> None: 362 | cameras.insert(0, "Select device") 363 | self.stackWidgets = {} 364 | self.stackLayouts = {} 365 | for camera in cameras: 366 | self.stackWidgets[camera] = QWidget() 367 | self.stackLayouts[camera] = QFormLayout() 368 | if camera == "MicroManager": 369 | self.stackLayouts[camera].addRow( 370 | self.adapterComboBox.label, self.adapterComboBox.widget 371 | ) 372 | self.stackLayouts[camera].addRow( 373 | self.deviceComboBox.label, self.deviceComboBox.widget 374 | ) 375 | 376 | elif camera == "Microscope": 377 | self.stackLayouts[camera].addRow( 378 | self.microscopeModuleComboBox.label, 379 | self.microscopeModuleComboBox.widget, 380 | ) 381 | self.stackLayouts[camera].addRow( 382 | self.microscopeDeviceComboBox.label, 383 | self.microscopeDeviceComboBox.widget, 384 | ) 385 | else: 386 | self.stackLayouts[camera].addRow( 387 | self.idLineEdit.label, self.idLineEdit.widget 388 | ) 389 | 390 | self.stackWidgets[camera].setLayout(self.stackLayouts[camera]) 391 | self.stackedWidget.addWidget(self.stackWidgets[camera]) 392 | 393 | self.layout.addRow(self.camerasComboBox.label, self.camerasComboBox.widget) 394 | self.layout.addRow(self.nameLineEdit.label, self.nameLineEdit.widget) 395 | self.layout.addRow(self.stackedWidget) 396 | self.layout.addRow(self.addButton) 397 | 398 | self.group.setLayout(self.layout) 399 | self.group.setFlat(True) 400 | 401 | def changeWidget(self, idx): 402 | self.stackedWidget.setCurrentIndex(idx) 403 | 404 | def updateDeviceSelectionUI(self, idx): 405 | self.deviceComboBox.changeWidgetSettings( 406 | MMC_DEVICE_MAP[list(MMC_DEVICE_MAP.keys())[idx]] 407 | ) 408 | 409 | def updateMicroscopeDeviceSelectionUI(self, key): 410 | self.microscopeDeviceComboBox.changeWidgetSettings([microscopeDeviceDict[key]]) 411 | 412 | def _setAddEnabled(self, idx: int): 413 | """Private method serving as an enable/disable mechanism for the Add button widget. 414 | This is done to avoid the first index, the "Select device" string, to be considered 415 | as a valid camera device (which is not). 416 | """ 417 | self.addButton.setEnabled(idx > 0) 418 | 419 | 420 | class RecordHandling(QObject): 421 | recordRequested = Signal(int) 422 | filterCreated = Signal() 423 | 424 | def __init__(self) -> None: 425 | """Recording Handling widget. Includes QPushButtons which allow to handle the following operations: 426 | 427 | - live viewing; 428 | - recording to output file; 429 | - single frame snap; 430 | 431 | Widget layout: 432 | |(0,1-2) QComboBox (File Format) |(0,2) QLabel | 433 | |(1,0-1) QLineEdit (Folder selection) |(1,2) QPushButton| 434 | |(2,0-2) QLineEdit (Record filename) |(2,2) QLabel | 435 | |(3,0-2) QSpinBox (Record size) |(3,2) QLabel | 436 | |(4,0-2) QPushButton (Snap) | 437 | |(5,0-2) QPushButton (Live) | 438 | |(6,0-2) QPushButton (Record) | 439 | 440 | """ 441 | QObject.__init__(self) 442 | self.settings = Settings() 443 | self.group = QGroupBox() 444 | self.layout = QGridLayout() 445 | 446 | self.formatLabel = QLabel("File format") 447 | self.formatComboBox = QEnumComboBox(enum_class=FileFormat) 448 | self.formatLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 449 | if self.settings.settings.contains("recordFolder"): 450 | folder = self.settings.getSetting("recordFolder") 451 | else: 452 | folder = baseRecordingFolder 453 | self.folderTextEdit = QLineEdit(folder) 454 | self.folderTextEdit.setReadOnly(True) 455 | 456 | self.folderButton = QPushButton("Select record folder") 457 | 458 | self.filenameTextEdit = QLineEdit("Filename") 459 | self.filenameLabel = QLabel("Record filename") 460 | self.filenameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 461 | 462 | self.recordComboBox = QEnumComboBox(enum_class=RecordType) 463 | 464 | self.recordSpinBox = QSpinBox() 465 | self.recordSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 466 | self.recordSpinBox.setRange(1, np.iinfo(np.uint16).max) 467 | self.recordSpinBox.setValue(100) 468 | 469 | self.snap = QPushButton("Snap") 470 | self.live = QPushButton("Live") 471 | self.record = QPushButton("Record") 472 | self.createFilter = QPushButton("Create Filter") 473 | 474 | self.live.setCheckable(True) 475 | self.record.setCheckable(True) 476 | 477 | self.recordSpinBox = QSpinBox() 478 | self.recordSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 479 | 480 | self.recordProgress = QProgressBar() 481 | 482 | # TODO: this is currently hardcoded 483 | # maybe should find a way to initialize 484 | # from outside the instance? 485 | self.recordSpinBox.setRange(1, 5000) 486 | self.recordSpinBox.setValue(100) 487 | 488 | self.layout.addWidget(self.formatComboBox, 0, 0, 1, 2) 489 | self.layout.addWidget(self.formatLabel, 0, 2) 490 | self.layout.addWidget(self.folderTextEdit, 1, 0, 1, 2) 491 | self.layout.addWidget(self.folderButton, 1, 2) 492 | self.layout.addWidget(self.filenameTextEdit, 2, 0, 1, 2) 493 | self.layout.addWidget(self.filenameLabel, 2, 2) 494 | self.layout.addWidget(self.recordSpinBox, 3, 0, 1, 2) 495 | self.layout.addWidget(self.recordComboBox, 3, 2) 496 | self.layout.addWidget(self.snap, 4, 0, 1, 3) 497 | self.layout.addWidget(self.live, 5, 0, 1, 3) 498 | self.layout.addWidget(self.record, 6, 0, 1, 3) 499 | self.layout.addWidget(self.recordProgress, 8, 0, 1, 3) 500 | self.layout.addWidget(self.createFilter, 7, 0, 1, 3) 501 | self.group.setLayout(self.layout) 502 | self.group.setFlat(True) 503 | 504 | # progress bar is hidden until recording is started 505 | self.recordProgress.hide() 506 | 507 | self.live.toggled.connect(self.handleLiveToggled) 508 | self.record.toggled.connect(self.handleRecordToggled) 509 | self.createFilter.clicked.connect(self.openFilterCreationWindow) 510 | 511 | self.folderButton.clicked.connect(self.handleFolderSelection) 512 | self.recordComboBox.currentEnumChanged.connect(self.handleRecordTypeChanged) 513 | 514 | def openFilterCreationWindow(self) -> None: 515 | self.selectionWindow = FilterGroupCreationWidget() 516 | self.selectionWindow.filterAdded.connect(lambda: self.filterCreated.emit()) 517 | self.selectionWindow.show() 518 | 519 | def handleFolderSelection(self) -> None: 520 | """Handles the selection of the output folder for the recording.""" 521 | folder = QFileDialog.getExistingDirectory( 522 | self.group, "Select output folder", self.folderTextEdit.text() 523 | ) 524 | if folder: 525 | self.folderTextEdit.setText(folder) 526 | self.settings.setSetting("recordFolder", folder) 527 | 528 | def handleRecordTypeChanged(self, recordType: RecordType) -> None: 529 | """Handles the change of the record type. 530 | 531 | Args: 532 | recordType (RecordType): new record type. 533 | """ 534 | if recordType == RecordType["Toggled"]: 535 | self.recordSpinBox.setEnabled(False) 536 | self.recordSpinBox.hide() 537 | else: 538 | self.recordSpinBox.show() 539 | self.recordSpinBox.setEnabled(True) 540 | if recordType == RecordType["Number of frames"]: 541 | newVal = 100 542 | elif recordType == RecordType["Time (seconds)"]: 543 | newVal = 1 544 | self.recordSpinBox.setValue(newVal) 545 | 546 | def setWidgetsEnabling(self, isEnabled: bool) -> None: 547 | """Enables/Disables all record handling widgets.""" 548 | self.snap.setEnabled(isEnabled) 549 | self.live.setEnabled(isEnabled) 550 | self.record.setEnabled(isEnabled) 551 | self.recordSpinBox.setEnabled(isEnabled) 552 | 553 | def handleLiveToggled(self, status: bool) -> None: 554 | """Enables/Disables pushbuttons when the live button is toggled. 555 | 556 | Args: 557 | status (bool): new live button status. 558 | """ 559 | self.snap.setEnabled(not status) 560 | self.record.setEnabled(not status) 561 | 562 | def handleRecordToggled(self, status: bool) -> None: 563 | """Enables/Disables pushbuttons when the record button is toggled. 564 | 565 | Args: 566 | status (bool): new live button status. 567 | """ 568 | self.snap.setEnabled(not status) 569 | self.live.setEnabled(not status) 570 | self.recordSpinBox.setEnabled(not status) 571 | 572 | @property 573 | def recordSize(self) -> int: 574 | """Returns the record size currently indicated in the QSpinBox widget.""" 575 | return self.recordSpinBox.value() 576 | 577 | @property 578 | def signals(self) -> Dict[str, Signal]: 579 | """Returns a dictionary of signals available for the RecordHandling widget. 580 | Exposed signals are: 581 | 582 | - snapRequested, 583 | - albumRequested, 584 | - liveRequested, 585 | - recordRequested 586 | 587 | Returns: 588 | Dict: Dict of signals (key: function name, value: function objects). 589 | """ 590 | return { 591 | "snapRequested": self.snap.clicked, 592 | "liveRequested": self.live.toggled, 593 | "recordRequested": self.record.toggled, 594 | } 595 | 596 | 597 | class ROIHandling(QWidget): 598 | changeROIRequested = Signal(ROI) 599 | fullROIRequested = Signal(ROI) 600 | 601 | def __init__(self, sensorShape: ROI) -> None: 602 | """ROI Handling widget. Defines a set of non-custom widgets to set the Region Of Interest of the device. 603 | This widget is common for all devices. 604 | """ 605 | QWidget.__init__(self) 606 | 607 | self.sensorFullROI = replace(sensorShape) 608 | 609 | self.offsetXLabel = QLabel("Offset X (px)") 610 | self.offsetXLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 611 | 612 | self.offsetXSpinBox = QSpinBox() 613 | self.offsetXSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 614 | self.offsetXSpinBox.setRange(0, self.sensorFullROI.width) 615 | self.offsetXSpinBox.setSingleStep(self.sensorFullROI.ofs_x_step) 616 | self.offsetXSpinBox.setValue(0) 617 | 618 | self.offsetYLabel = QLabel( 619 | "Offset Y (px)", 620 | ) 621 | self.offsetYLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 622 | 623 | self.offsetYSpinBox = QSpinBox() 624 | self.offsetYSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 625 | self.offsetYSpinBox.setRange(0, self.sensorFullROI.height) 626 | self.offsetYSpinBox.setSingleStep(self.sensorFullROI.ofs_y_step) 627 | self.offsetYSpinBox.setValue(0) 628 | 629 | self.widthLabel = QLabel("Width (px)") 630 | self.widthLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 631 | 632 | self.widthSpinBox = QSpinBox() 633 | self.widthSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 634 | self.widthSpinBox.setRange(0, self.sensorFullROI.width) 635 | self.widthSpinBox.setSingleStep(self.sensorFullROI.width_step) 636 | self.widthSpinBox.setValue(self.sensorFullROI.width) 637 | 638 | self.heightLabel = QLabel("Height (px)") 639 | self.heightLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 640 | 641 | self.heightSpinBox = QSpinBox() 642 | self.heightSpinBox.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter) 643 | self.heightSpinBox.setRange(0, self.sensorFullROI.height) 644 | self.heightSpinBox.setSingleStep(self.sensorFullROI.height_step) 645 | self.heightSpinBox.setValue(self.sensorFullROI.height) 646 | 647 | self.changeROIButton = QPushButton("Set ROI") 648 | self.fullROIButton = QPushButton("Full frame") 649 | 650 | layout = QGridLayout() 651 | layout.addWidget(self.offsetXLabel, 0, 0) 652 | layout.addWidget(self.offsetXSpinBox, 0, 1) 653 | layout.addWidget(self.offsetYSpinBox, 0, 2) 654 | layout.addWidget(self.offsetYLabel, 0, 3) 655 | 656 | layout.addWidget(self.widthLabel, 1, 0) 657 | layout.addWidget(self.widthSpinBox, 1, 1) 658 | layout.addWidget(self.heightSpinBox, 1, 2) 659 | layout.addWidget(self.heightLabel, 1, 3) 660 | layout.addWidget(self.changeROIButton, 2, 0, 1, 2) 661 | layout.addWidget(self.fullROIButton, 2, 2, 1, 2) 662 | 663 | # "clicked" signals are connected to private slots. 664 | # These slots expose the signals available to the user 665 | # to process the new ROI information if necessary. 666 | self.changeROIButton.clicked.connect(self._onROIChanged) 667 | self.fullROIButton.clicked.connect(self._onFullROI) 668 | 669 | self.setLayout(layout) 670 | 671 | def changeWidgetSettings(self, settings: ROI): 672 | """ROI handling update widget settings method. 673 | This method is useful whenever the ROI values are changed based 674 | on some device requirements and adapted. 675 | 676 | Args: 677 | settings (ROI): new ROI settings to change the widget values and steps. 678 | """ 679 | self.offsetXSpinBox.setSingleStep(settings.ofs_x_step) 680 | self.offsetXSpinBox.setValue(settings.offset_x) 681 | 682 | self.offsetYSpinBox.setSingleStep(settings.ofs_y_step) 683 | self.offsetYSpinBox.setValue(settings.offset_y) 684 | 685 | self.widthSpinBox.setSingleStep(settings.width_step) 686 | self.widthSpinBox.setValue(settings.width) 687 | 688 | self.heightSpinBox.setSingleStep(settings.height_step) 689 | self.heightSpinBox.setValue(settings.height) 690 | 691 | def _onROIChanged(self) -> None: 692 | """Private slot for ROI changed button pressed. Exposes a signal with the updated ROI settings.""" 693 | # read the current SpinBoxes status 694 | newRoi = ROI( 695 | offset_x=self.offsetXSpinBox.value(), 696 | ofs_x_step=self.offsetXSpinBox.singleStep(), 697 | offset_y=self.offsetYSpinBox.value(), 698 | ofs_y_step=self.offsetXSpinBox.singleStep(), 699 | width=self.widthSpinBox.value(), 700 | width_step=self.widthSpinBox.singleStep(), 701 | height=self.heightSpinBox.value(), 702 | height_step=self.heightSpinBox.singleStep(), 703 | ) 704 | self.changeROIRequested.emit(newRoi) 705 | 706 | def _onFullROI(self) -> None: 707 | """Private slot for full ROI button pressed. Exposes a signal with the full ROI settings. 708 | It also returns the widget settings to their original value. 709 | """ 710 | self.changeWidgetSettings(self.sensorFullROI) 711 | self.fullROIRequested.emit(replace(self.sensorFullROI)) 712 | 713 | @property 714 | def signals(self) -> Dict[str, Signal]: 715 | """Returns a dictionary of signals available for the ROIHandling widget. 716 | Exposed signals are: 717 | 718 | - changeROIRequested, 719 | - fullROIRequested, 720 | 721 | Returns: 722 | Dict: Dict of signals (key: function name, value: function objects). 723 | """ 724 | return { 725 | "changeROIRequested": self.changeROIRequested, 726 | "fullROIRequested": self.fullROIRequested, 727 | } 728 | 729 | 730 | class CameraTab(QObject): 731 | """Camera tab widget. Used to create a new tab for an added camera.""" 732 | 733 | def __init__( 734 | self, camera: ICamera, filterGroupsDict: dict, interface: str 735 | ) -> None: 736 | QObject.__init__(self) 737 | 738 | self.widget = QWidget() 739 | self.layout = QGridLayout() 740 | 741 | settingsLayout = QFormLayout() 742 | settingsGroup = QGroupBox() 743 | self.filterGroupsDict = filterGroupsDict 744 | 745 | self.roiWidget = ROIHandling(camera.fullShape) 746 | self.roiWidget.signals["changeROIRequested"].connect( 747 | lambda roi: camera.changeROI(roi) 748 | ) 749 | self.roiWidget.signals["fullROIRequested"].connect( 750 | lambda roi: camera.changeROI(roi) 751 | ) 752 | 753 | self.filtersCombo = ComboBox(self.filterGroupsDict.keys(), "Filters") 754 | self.filtersCombo.combobox.setCurrentText("No Filter") 755 | self.layout.addWidget(self.filtersCombo.widget, 0, 0, 1, 2) 756 | 757 | if interface == "MicroManager": 758 | self.layout.addWidget(camera.settingsWidget, 1, 0, 1, 2) 759 | 760 | else: 761 | scrollArea = QScrollArea() 762 | specificSettingsGroup = QWidget() 763 | specificSettingsLayout = QFormLayout() 764 | for name, parameter in camera.parameters.items(): 765 | if len(name) > 15: 766 | name = name[:15] 767 | if type(parameter) == NumberParameter: 768 | widget = LabeledSlider( 769 | (*parameter.valueLimits, parameter.value), name, parameter.unit 770 | ) 771 | widget.signals["valueChanged"].connect( 772 | lambda value, name=name: camera.changeParameter(name, value) 773 | ) 774 | else: # ListParameter 775 | widget = ComboBox(parameter.options, name) 776 | widget.signals["currentTextChanged"].connect( 777 | lambda text, name=name: camera.changeParameter(name, text) 778 | ) 779 | specificSettingsLayout.addRow(widget.label, widget.widget) 780 | 781 | specificSettingsGroup.setLayout(specificSettingsLayout) 782 | scrollArea.setWidget(specificSettingsGroup) 783 | scrollArea.setWidgetResizable(True) 784 | scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) 785 | scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 786 | self.layout.addWidget(scrollArea, 1, 0, 1, 2) 787 | 788 | self.deleteButton = QPushButton("Delete camera") 789 | 790 | settingsLayout.addRow(self.deleteButton) 791 | settingsLayout.addRow(self.roiWidget) 792 | settingsGroup.setLayout(settingsLayout) 793 | self.layout.addWidget(settingsGroup) 794 | self.widget.setLayout(self.layout) 795 | 796 | def getFiltersComboCurrentText(self): 797 | return self.filtersCombo.combobox.currentText() 798 | 799 | def setFiltersCombo(self, newValues: List[str]): 800 | self.filtersCombo.changeWidgetSettings(newValues) 801 | 802 | def getFiltersComboCurrentIndex(self): 803 | return self.filtersCombo.combobox.currentIndex() 804 | 805 | def setFiltersComboCurrentIndex(self, index: int): 806 | self.filtersCombo.combobox.setCurrentIndex(index) 807 | --------------------------------------------------------------------------------