├── 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 | [](https://github.com/jacopoabramo/napari-live-recording/raw/main/LICENSE)
4 | [](https://pypi.org/project/napari-live-recording)
5 | [](https://python.org)
6 | 
7 | [](https://codecov.io/github/jacopoabramo/napari-live-recording) \
8 | [](https://napari-hub.org/plugins/napari-live-recording)
9 | [](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 |
--------------------------------------------------------------------------------